From 875013e47f7c621a73484936945865dc2ae9faad Mon Sep 17 00:00:00 2001 From: Mike Barnes Date: Fri, 2 Jan 2026 18:27:41 +1100 Subject: [PATCH] Merge tag 'v25.2' into develop # Conflicts: # README.md # app/build.gradle # app/lint-baseline.xml # app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsFragment.kt # app/src/main/java/com/keylesspalace/tusky/components/notifications/StatusNotificationViewHolder.kt # app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesFragment.kt # app/src/main/java/com/keylesspalace/tusky/util/ThemeUtils.kt # app/src/main/res/layout/activity_about.xml # app/src/main/res/layout/item_emoji_pref.xml # app/src/main/res/values-ar/strings.xml # app/src/main/res/values-bg/strings.xml # app/src/main/res/values-cy/strings.xml # app/src/main/res/values-de/strings.xml # app/src/main/res/values-fa/strings.xml # app/src/main/res/values-gd/strings.xml # app/src/main/res/values-gl/strings.xml # app/src/main/res/values-hu/strings.xml # app/src/main/res/values-is/strings.xml # app/src/main/res/values-it/strings.xml # app/src/main/res/values-ja/strings.xml # app/src/main/res/values-nl/strings.xml # app/src/main/res/values-oc/strings.xml # app/src/main/res/values-pt-rBR/strings.xml # app/src/main/res/values-pt-rPT/strings.xml # app/src/main/res/values-ru/strings.xml # app/src/main/res/values-si/strings.xml # app/src/main/res/values-sv/strings.xml # app/src/main/res/values-tr/strings.xml # app/src/main/res/values-uk/strings.xml # app/src/main/res/values-vi/strings.xml # app/src/main/res/values-zh-rCN/strings.xml # app/src/main/res/values/strings.xml # fastlane/metadata/android/ru/full_description.txt # fastlane/metadata/android/zh-Hans/full_description.txt --- .editorconfig | 12 +- .github/ISSUE_TEMPLATE/1.bug_report.yml | 41 + .github/ISSUE_TEMPLATE/2.feature_request.yml | 19 + .github/ISSUE_TEMPLATE/config.yml | 1 + .github/ci-gradle.properties | 24 + .github/workflows/ci.yml | 44 + .../workflows/populate-gradle-build-cache.yml | 34 + CHANGELOG.md | 88 + CONTRIBUTING.md | 14 +- ISSUE_TEMPLATE.md | 9 - README.md | 5 +- app/build.gradle | 62 +- app/lint-baseline.xml | 7341 ++--------------- app/lint.xml | 45 +- app/proguard-rules.pro | 79 +- .../52.json | 1009 +++ .../53.json | 1009 +++ .../54.json | 1016 +++ .../56.json | 1034 +++ .../58.json | 1040 +++ .../com/keylesspalace/tusky/MigrationsTest.kt | 2 +- app/src/main/AndroidManifest.xml | 11 +- .../com/keylesspalace/tusky/AboutActivity.kt | 57 +- .../tusky/AccountsInListFragment.kt | 58 +- .../com/keylesspalace/tusky/BaseActivity.java | 61 +- .../tusky/BottomSheetActivity.kt | 48 +- .../tusky/EditProfileActivity.kt | 233 +- .../keylesspalace/tusky/LicenseActivity.kt | 33 +- .../com/keylesspalace/tusky/ListsActivity.kt | 134 +- .../com/keylesspalace/tusky/MainActivity.kt | 607 +- .../keylesspalace/tusky/StatusListActivity.kt | 126 +- .../java/com/keylesspalace/tusky/TabData.kt | 47 +- .../tusky/TabPreferenceActivity.kt | 166 +- .../keylesspalace/tusky/TuskyApplication.kt | 42 +- .../keylesspalace/tusky/ViewMediaActivity.kt | 144 +- .../tusky/adapter/AccountFieldEditAdapter.kt | 17 +- .../tusky/adapter/AccountSelectionAdapter.kt | 7 +- .../tusky/adapter/EmojiAdapter.kt | 13 +- .../tusky/adapter/FollowRequestViewHolder.kt | 49 +- .../tusky/adapter/LocaleAdapter.kt | 6 +- .../tusky/adapter/NotificationsAdapter.java | 708 ++ .../tusky/adapter/PollAdapter.kt | 5 +- .../adapter/PreviewPollOptionsAdapter.kt | 6 +- .../adapter/ReportNotificationViewHolder.kt | 65 +- .../tusky/adapter/StatusBaseViewHolder.java | 375 +- .../adapter/StatusDetailedViewHolder.java | 57 +- .../tusky/adapter/StatusViewHolder.java | 2 +- .../keylesspalace/tusky/adapter/TabAdapter.kt | 6 +- .../tusky/appstore/CacheUpdater.kt | 24 +- .../keylesspalace/tusky/appstore/Events.kt | 17 +- .../keylesspalace/tusky/appstore/EventsHub.kt | 24 +- .../components/account/AccountActivity.kt | 275 +- .../components/account/AccountFieldAdapter.kt | 24 +- .../components/account/AccountPagerAdapter.kt | 6 +- .../components/account/AccountViewModel.kt | 122 +- .../account/list/ListSelectionFragment.kt | 260 + .../account/list/ListsForAccountFragment.kt | 200 - .../account/list/ListsForAccountViewModel.kt | 40 +- .../account/media/AccountMediaFragment.kt | 31 +- .../account/media/AccountMediaGridAdapter.kt | 51 +- .../media/AccountMediaRemoteMediator.kt | 5 +- .../account/media/AccountMediaViewModel.kt | 6 +- .../accountlist/AccountListActivity.kt | 7 +- .../accountlist/AccountListFragment.kt | 75 +- .../accountlist/adapter/AccountAdapter.kt | 4 +- .../accountlist/adapter/BlocksAdapter.kt | 17 +- .../adapter/FollowRequestsAdapter.kt | 1 - .../adapter/FollowRequestsHeaderAdapter.kt | 16 +- .../accountlist/adapter/MutesAdapter.kt | 17 +- .../announcements/AnnouncementAdapter.kt | 35 +- .../announcements/AnnouncementsActivity.kt | 58 +- .../announcements/AnnouncementsViewModel.kt | 37 +- .../components/compose/ComposeActivity.kt | 461 +- .../compose/ComposeAutoCompleteAdapter.kt | 12 +- .../components/compose/ComposeViewModel.kt | 196 +- .../components/compose/ImageDownsizer.kt | 12 +- .../components/compose/MediaPreviewAdapter.kt | 10 +- .../tusky/components/compose/MediaUploader.kt | 90 +- .../compose/dialog/AddPollDialog.kt | 12 +- .../compose/dialog/AddPollOptionsAdapter.kt | 17 +- .../compose/dialog/CaptionDialog.kt | 101 +- .../components/compose/dialog/FocusDialog.kt | 17 +- .../compose/view/ComposeOptionsView.kt | 5 +- .../compose/view/ComposeScheduleView.kt | 56 +- .../compose/view/FocusIndicatorView.kt | 12 +- .../components/compose/view/TootButton.kt | 5 +- .../conversation/ConversationAdapter.kt | 19 +- .../conversation/ConversationEntity.kt | 52 +- .../ConversationLoadStateAdapter.kt | 11 +- .../conversation/ConversationViewData.kt | 4 +- .../conversation/ConversationViewHolder.java | 4 +- .../conversation/ConversationsFragment.kt | 72 +- .../ConversationsRemoteMediator.kt | 5 +- .../conversation/ConversationsViewModel.kt | 12 +- .../DomainBlocksActivity.kt} | 7 +- .../domainblocks/DomainBlocksAdapter.kt | 34 + .../domainblocks/DomainBlocksFragment.kt | 96 + .../domainblocks/DomainBlocksPagingSource.kt | 19 + .../DomainBlocksRemoteMediator.kt | 57 + .../domainblocks/DomainBlocksRepository.kt | 69 + .../domainblocks/DomainBlocksViewModel.kt | 75 + .../tusky/components/drafts/DraftHelper.kt | 43 +- .../components/drafts/DraftMediaAdapter.kt | 9 +- .../tusky/components/drafts/DraftsActivity.kt | 22 +- .../tusky/components/drafts/DraftsAdapter.kt | 9 +- .../components/drafts/DraftsViewModel.kt | 8 +- .../components/filters/EditFilterActivity.kt | 50 +- .../components/filters/EditFilterViewModel.kt | 109 +- .../FilterExtensions.kt} | 31 +- .../components/filters/FiltersActivity.kt | 33 +- .../components/filters/FiltersAdapter.kt | 9 +- .../components/filters/FiltersViewModel.kt | 50 +- .../followedtags/FollowedTagsActivity.kt | 12 +- .../followedtags/FollowedTagsAdapter.kt | 23 +- .../followedtags/FollowedTagsViewModel.kt | 25 +- .../components/instanceinfo/InstanceInfo.kt | 4 +- .../instanceinfo/InstanceInfoRepository.kt | 192 +- .../adapter/DomainMutesAdapter.kt | 56 - .../fragment/InstanceListFragment.kt | 158 - .../interfaces/InstanceActionListener.kt | 5 - .../tusky/components/login/LoginActivity.kt | 25 +- .../components/login/LoginWebViewActivity.kt | 16 +- .../components/login/LoginWebViewViewModel.kt | 41 +- .../notifications/FollowViewHolder.kt | 111 - .../notifications/NotificationFetcher.kt | 93 +- .../notifications/NotificationHelper.java | 58 +- .../notifications/NotificationsFragment.kt | 692 -- .../NotificationsLoadStateViewHolder.kt | 73 - .../NotificationsPagingAdapter.kt | 207 - .../NotificationsPagingSource.kt | 216 - .../notifications/NotificationsRepository.kt | 76 - .../notifications/NotificationsViewModel.kt | 557 -- .../notifications/PushNotificationHelper.kt | 49 +- .../StatusNotificationViewHolder.kt | 387 - .../notifications/StatusViewHolder.kt | 60 - .../preference/AccountPreferencesFragment.kt | 77 +- .../preference/PreferencesActivity.kt | 54 +- .../preference/PreferencesFragment.kt | 21 +- .../preference/ProxyPreferencesFragment.kt | 5 +- .../TabFilterPreferencesFragment.kt | 42 +- .../tusky/components/report/ReportActivity.kt | 39 +- .../components/report/ReportViewModel.kt | 34 +- .../report/adapter/StatusViewHolder.kt | 60 +- .../report/adapter/StatusesAdapter.kt | 18 +- .../report/adapter/StatusesPagingSource.kt | 24 +- .../report/fragments/ReportDoneFragment.kt | 62 +- .../report/fragments/ReportNoteFragment.kt | 21 +- .../fragments/ReportStatusesFragment.kt | 32 +- .../report/model/StatusViewState.kt | 32 +- .../scheduled/ScheduledStatusActivity.kt | 9 +- .../scheduled/ScheduledStatusAdapter.kt | 21 +- .../scheduled/ScheduledStatusPagingSource.kt | 4 +- .../scheduled/ScheduledStatusViewModel.kt | 2 +- .../tusky/components/search/SearchActivity.kt | 30 +- .../components/search/SearchViewModel.kt | 72 +- .../search/adapter/SearchAccountsAdapter.kt | 12 +- .../search/adapter/SearchHashtagsAdapter.kt | 5 +- .../search/adapter/SearchPagingSource.kt | 6 +- .../search/adapter/SearchStatusesAdapter.kt | 12 +- .../fragments/SearchAccountsFragment.kt | 4 +- .../search/fragments/SearchFragment.kt | 25 +- .../fragments/SearchStatusesFragment.kt | 143 +- .../components/timeline/TimelineFragment.kt | 137 +- .../timeline/TimelinePagingAdapter.kt | 9 +- .../timeline/TimelineTypeMappers.kt | 93 +- .../components/timeline/util/TimelineUtils.kt | 11 +- .../viewmodel/CachedTimelineRemoteMediator.kt | 26 +- .../viewmodel/CachedTimelineViewModel.kt | 75 +- .../NetworkTimelineRemoteMediator.kt | 37 +- .../viewmodel/NetworkTimelineViewModel.kt | 110 +- .../timeline/viewmodel/TimelineViewModel.kt | 109 +- .../components/trending/TrendingActivity.kt | 2 +- .../trending/TrendingTagViewHolder.kt | 5 +- ...ndingAdapter.kt => TrendingTagsAdapter.kt} | 2 +- ...ingFragment.kt => TrendingTagsFragment.kt} | 48 +- ...gViewModel.kt => TrendingTagsViewModel.kt} | 59 +- .../ConversationLineItemDecoration.kt | 42 +- .../components/viewthread/ThreadAdapter.kt | 4 +- .../viewthread/ViewThreadFragment.kt | 109 +- .../viewthread/ViewThreadViewModel.kt | 190 +- .../viewthread/edits/ViewEditsAdapter.kt | 63 +- .../viewthread/edits/ViewEditsFragment.kt | 27 +- .../viewthread/edits/ViewEditsViewModel.kt | 14 +- .../keylesspalace/tusky/db/AccountEntity.kt | 23 +- .../keylesspalace/tusky/db/AccountManager.kt | 6 +- .../keylesspalace/tusky/db/AppDatabase.java | 29 +- .../com/keylesspalace/tusky/db/Converters.kt | 90 +- .../com/keylesspalace/tusky/db/DraftDao.kt | 8 +- .../com/keylesspalace/tusky/db/DraftEntity.kt | 21 +- .../com/keylesspalace/tusky/db/DraftsAlert.kt | 199 +- .../keylesspalace/tusky/db/InstanceEntity.kt | 6 +- .../com/keylesspalace/tusky/db/TimelineDao.kt | 132 +- .../tusky/db/TimelineStatusEntity.kt | 12 +- .../tusky/di/ActivitiesModule.kt | 8 +- .../keylesspalace/tusky/di/AppComponent.kt | 3 +- .../com/keylesspalace/tusky/di/AppInjector.kt | 6 +- .../com/keylesspalace/tusky/di/AppModule.kt | 2 +- .../tusky/di/CoroutineScopeModule.kt | 6 +- .../tusky/di/FragmentBuildersModule.kt | 26 +- .../keylesspalace/tusky/di/NetworkModule.kt | 68 +- .../keylesspalace/tusky/di/PlayerModule.kt | 182 + .../tusky/di/ViewModelFactory.kt | 31 +- .../keylesspalace/tusky/di/WorkerModule.kt | 8 +- .../keylesspalace/tusky/entity/AccessToken.kt | 6 +- .../com/keylesspalace/tusky/entity/Account.kt | 53 +- .../tusky/entity/Announcement.kt | 28 +- .../tusky/entity/AppCredentials.kt | 8 +- .../keylesspalace/tusky/entity/Attachment.kt | 59 +- .../com/keylesspalace/tusky/entity/Card.kt | 21 +- .../tusky/entity/Conversation.kt | 7 +- .../tusky/entity/DeletedStatus.kt | 21 +- .../com/keylesspalace/tusky/entity/Emoji.kt | 8 +- .../com/keylesspalace/tusky/entity/Error.kt | 6 +- .../com/keylesspalace/tusky/entity/Filter.kt | 17 +- .../tusky/entity/FilterKeyword.kt | 6 +- .../tusky/entity/FilterResult.kt | 7 +- .../keylesspalace/tusky/entity/FilterV1.kt | 11 +- .../com/keylesspalace/tusky/entity/HashTag.kt | 9 +- .../keylesspalace/tusky/entity/Instance.kt | 176 +- .../keylesspalace/tusky/entity/InstanceV1.kt | 107 + .../com/keylesspalace/tusky/entity/Marker.kt | 8 +- .../keylesspalace/tusky/entity/MastoList.kt | 22 +- .../tusky/entity/MediaUploadResult.kt | 3 + .../keylesspalace/tusky/entity/NewStatus.kt | 28 +- .../tusky/entity/Notification.kt | 54 +- .../entity/NotificationSubscribeResult.kt | 6 +- .../com/keylesspalace/tusky/entity/Poll.kt | 20 +- .../tusky/entity/Relationship.kt | 25 +- .../com/keylesspalace/tusky/entity/Report.kt | 10 +- .../tusky/entity/ScheduledStatus.kt | 8 +- .../tusky/entity/SearchResult.kt | 3 + .../com/keylesspalace/tusky/entity/Status.kt | 87 +- .../tusky/entity/StatusContext.kt | 3 + .../keylesspalace/tusky/entity/StatusEdit.kt | 12 +- .../tusky/entity/StatusParams.kt | 10 +- .../tusky/entity/StatusSource.kt | 6 +- .../tusky/entity/TimelineAccount.kt | 14 +- .../keylesspalace/tusky/entity/Translation.kt | 38 + .../tusky/entity/TrendingTagsResult.kt | 9 +- .../tusky/fragment/NotificationsFragment.java | 1260 +++ .../keylesspalace/tusky/fragment/SFragment.kt | 115 +- .../tusky/fragment/ViewImageFragment.kt | 233 +- .../tusky/fragment/ViewMediaFragment.kt | 12 +- .../tusky/fragment/ViewVideoFragment.kt | 475 +- .../tusky/interfaces/AccountActionListener.kt | 13 +- .../tusky/interfaces/PermissionRequester.kt | 8 +- .../interfaces/StatusActionListener.java | 3 +- .../{GuardedBooleanAdapter.kt => Guarded.kt} | 25 +- .../tusky/json/GuardedAdapter.kt | 63 + .../keylesspalace/tusky/json/Iso8601Utils.kt | 267 - .../tusky/json/Rfc3339DateJsonAdapter.kt | 56 - .../tusky/network/FilterModel.kt | 4 +- .../network/InstanceSwitchAuthInterceptor.kt | 7 +- .../tusky/network/MastodonApi.kt | 249 +- .../tusky/network/ProgressRequestBody.java | 76 - .../tusky/network/UriRequestBody.kt | 66 + .../tusky/pager/ImagePagerAdapter.kt | 3 +- .../tusky/pager/MainPagerAdapter.kt | 4 +- ...NotificationBlockStateBroadcastReceiver.kt | 20 +- .../receiver/SendStatusBroadcastReceiver.kt | 69 +- .../receiver/UnifiedPushBroadcastReceiver.kt | 17 +- .../tusky/service/SendStatusService.kt | 109 +- .../tusky/service/TuskyTileService.kt | 22 +- .../settings/AccountPreferenceDataStore.kt | 8 +- .../tusky/settings/ProxyConfiguration.kt | 13 +- .../tusky/settings/SettingsConstants.kt | 22 +- .../tusky/settings/SettingsDSL.kt | 15 +- .../tusky/usecase/LogoutUsecase.kt | 7 +- .../tusky/usecase/TimelineCases.kt | 81 +- .../tusky/util/AbsoluteTimeFormatter.kt | 20 +- .../tusky/util/ActivityExensions.kt | 25 + .../tusky/util/AlertDialogExtensions.kt | 63 + .../util/CompositeWithOpaqueBackground.kt | 131 + .../keylesspalace/tusky/util/CryptoUtil.kt | 6 +- .../tusky/util/CustomEmojiHelper.kt | 54 +- .../com/keylesspalace/tusky/util/Either.kt | 26 +- .../tusky/util/EmptyPagingSource.kt | 6 +- .../tusky/util/FlowExtensions.kt | 24 +- .../tusky/util/FocalPointUtil.kt | 7 +- .../tusky/util/GlideExtensions.kt | 46 + .../tusky/util/HttpHeaderLink.kt | 5 +- .../com/keylesspalace/tusky/util/IOUtils.kt | 48 +- .../tusky/util/ImageLoadingHelper.kt | 29 +- .../keylesspalace/tusky/util/LinkHelper.kt | 125 +- .../util/ListStatusAccessibilityDelegate.kt | 16 +- .../keylesspalace/tusky/util/LocaleUtils.kt | 10 +- .../keylesspalace/tusky/util/MediaUtils.kt | 56 +- .../tusky/util/NoUnderlineURLSpan.kt | 2 +- .../keylesspalace/tusky/util/PairedList.kt | 74 + .../tusky/util/RelativeTimeUpdater.kt | 37 + .../com/keylesspalace/tusky/util/Resource.kt | 12 +- .../tusky/util/ShareShortcutHelper.kt | 137 +- .../com/keylesspalace/tusky/util/Single.kt | 29 + .../tusky/util/SmartLengthInputFilter.kt | 28 +- .../com/keylesspalace/tusky/util/SpanUtils.kt | 29 +- .../tusky/util/StatusDisplayOptions.kt | 10 +- .../tusky/util/StatusParsingHelper.kt | 72 +- .../tusky/util/StatusViewHelper.kt | 49 +- .../keylesspalace/tusky/util/StringUtils.kt | 5 +- .../keylesspalace/tusky/util/ThemeUtils.kt | 31 +- .../tusky/util/ThrowableExtensions.kt | 10 +- .../tusky/util/ViewBindingExtensions.kt | 67 +- .../keylesspalace/tusky/util/ViewDataUtils.kt | 19 +- .../tusky/view/AdaptiveTabLayout.kt | 41 + .../tusky/view/BackgroundMessageView.kt | 3 +- .../tusky/view/ClickableSpanTextView.kt | 38 +- .../tusky/view/ExposedPlayPauseVideoView.kt | 41 - .../com/keylesspalace/tusky/view/GraphView.kt | 4 +- .../keylesspalace/tusky/view/LicenseCard.kt | 8 +- .../tusky/view/MediaPreviewImageView.kt | 15 +- .../tusky/view/SliderPreference.kt | 50 +- .../tusky/viewdata/AttachmentViewData.kt | 7 +- .../tusky/viewdata/NotificationViewData.java | 138 + .../tusky/viewdata/NotificationViewData.kt | 43 - .../tusky/viewdata/PollViewData.kt | 17 +- .../tusky/viewdata/StatusViewData.kt | 84 +- .../tusky/viewdata/TrendingViewData.kt | 14 +- .../viewmodel/AccountsInListViewModel.kt | 10 +- .../tusky/viewmodel/EditProfileViewModel.kt | 241 +- .../tusky/viewmodel/ListsViewModel.kt | 46 +- .../tusky/worker/NotificationWorker.kt | 10 +- .../tusky/worker/PruneCacheWorker.kt | 10 +- .../main/res/anim/activity_close_enter.xml | 22 + app/src/main/res/anim/activity_close_exit.xml | 25 + app/src/main/res/anim/activity_open_enter.xml | 23 + app/src/main/res/anim/activity_open_exit.xml | 22 + app/src/main/res/anim/fade_in.xml | 6 - app/src/main/res/anim/fade_out.xml | 6 - .../main/res/anim/fast_out_extra_slow_in.xml | 18 + app/src/main/res/anim/slide_from_left.xml | 6 - app/src/main/res/anim/slide_from_right.xml | 6 - app/src/main/res/anim/slide_to_left.xml | 6 - app/src/main/res/anim/slide_to_right.xml | 6 - .../color-v24/launcher_shadow_gradient.xml | 12 - .../main/res/drawable-v24/ic_notoemoji.xml | 51 - .../main/res/drawable/background_circle.xml | 4 +- ...lephant_error.xml => errorphant_error.xml} | 0 ...ant_offline.xml => errorphant_offline.xml} | 0 app/src/main/res/drawable/ic_arrow_back.xml | 11 + .../ic_arrow_back_with_background.xml | 12 + app/src/main/res/drawable/ic_blobmoji.xml | 11 - .../main/res/drawable/ic_content_copy_24.xml | 5 + app/src/main/res/drawable/ic_emoji_34dp.xml | 9 - app/src/main/res/drawable/ic_hot_24dp.xml | 10 + app/src/main/res/drawable/ic_link.xml | 9 + app/src/main/res/drawable/ic_more.xml | 10 + .../res/drawable/ic_more_with_background.xml | 12 + .../drawable/ic_notifications_off_24dp.xml | 12 - app/src/main/res/drawable/ic_notoemoji.xml | 23 - app/src/main/res/drawable/ic_twemoji.xml | 9 - .../res/drawable/profile_badge_background.xml | 6 - .../drawable/profile_badge_person_24dp.xml | 10 + .../res/layout-sw640dp/fragment_timeline.xml | 2 +- app/src/main/res/layout/activity_about.xml | 158 +- app/src/main/res/layout/activity_account.xml | 57 +- .../res/layout/activity_announcements.xml | 2 +- app/src/main/res/layout/activity_compose.xml | 2 +- app/src/main/res/layout/activity_drafts.xml | 2 +- app/src/main/res/layout/activity_license.xml | 24 +- app/src/main/res/layout/activity_main.xml | 63 +- .../res/layout/activity_scheduled_status.xml | 2 +- .../res/layout/activity_tab_preference.xml | 15 +- app/src/main/res/layout/dialog_filter.xml | 2 + app/src/main/res/layout/dialog_focus.xml | 1 + .../res/layout/dialog_image_description.xml | 43 +- app/src/main/res/layout/dialog_list.xml | 64 + .../res/layout/exo_player_control_view.xml | 159 + .../res/layout/fragment_accounts_in_list.xml | 4 +- ...ce_list.xml => fragment_domain_blocks.xml} | 6 +- .../res/layout/fragment_lists_for_account.xml | 56 - .../main/res/layout/fragment_lists_list.xml | 36 + .../main/res/layout/fragment_report_note.xml | 1 + .../fragment_timeline_notifications.xml | 1 - ...rending.xml => fragment_trending_tags.xml} | 0 .../main/res/layout/fragment_view_image.xml | 4 +- .../main/res/layout/fragment_view_thread.xml | 4 +- .../main/res/layout/fragment_view_video.xml | 11 +- .../main/res/layout/item_account_media.xml | 4 +- .../layout/item_add_or_remove_from_list.xml | 50 - app/src/main/res/layout/item_announcement.xml | 15 +- ...ted_domain.xml => item_blocked_domain.xml} | 6 +- app/src/main/res/layout/item_emoji_pref.xml | 114 - app/src/main/res/layout/item_follow.xml | 2 +- app/src/main/res/layout/item_list.xml | 39 +- .../main/res/layout/item_media_preview.xml | 5 +- .../res/layout/item_report_notification.xml | 2 + .../main/res/layout/item_report_status.xml | 7 +- app/src/main/res/layout/item_status.xml | 52 +- .../main/res/layout/item_status_detailed.xml | 66 +- .../main/res/layout/item_status_filtered.xml | 5 +- .../res/layout/item_status_notification.xml | 1 + .../main/res/layout/item_tab_preference.xml | 1 + .../res/layout/material_drawer_header.xml | 1 + .../main/res/layout/notifications_filter.xml | 19 + .../res/layout/view_background_message.xml | 2 +- .../main/res/layout/view_compose_schedule.xml | 4 +- app/src/main/res/layout/view_poll_preview.xml | 4 +- .../main/res/menu/fragment_notifications.xml | 9 +- app/src/main/res/menu/list_actions.xml | 4 +- app/src/main/res/menu/status_more.xml | 26 +- .../main/res/menu/status_more_for_user.xml | 2 +- .../main/res/menu/view_hashtag_toolbar.xml | 4 +- app/src/main/res/values-ar/strings.xml | 105 +- app/src/main/res/values-be/strings.xml | 31 +- app/src/main/res/values-bg/strings.xml | 196 +- app/src/main/res/values-bn-rBD/strings.xml | 10 +- app/src/main/res/values-bn-rIN/strings.xml | 9 +- app/src/main/res/values-ca/strings.xml | 11 +- app/src/main/res/values-ckb/strings.xml | 9 +- app/src/main/res/values-cs/strings.xml | 12 +- app/src/main/res/values-cy/strings.xml | 359 +- app/src/main/res/values-de/strings.xml | 87 +- app/src/main/res/values-el/strings.xml | 10 +- app/src/main/res/values-en-rAU/strings.xml | 2 + app/src/main/res/values-en-rGB/strings.xml | 38 +- app/src/main/res/values-eo/strings.xml | 11 +- app/src/main/res/values-es/strings.xml | 23 +- app/src/main/res/values-eu/strings.xml | 10 +- app/src/main/res/values-fa/strings.xml | 100 +- app/src/main/res/values-fi/strings.xml | 7 +- app/src/main/res/values-fr-rBE/strings.xml | 2 + app/src/main/res/values-fr/strings.xml | 45 +- app/src/main/res/values-fy/strings.xml | 5 +- app/src/main/res/values-ga/strings.xml | 9 +- app/src/main/res/values-gd/strings.xml | 38 +- app/src/main/res/values-gl/strings.xml | 106 +- app/src/main/res/values-hi/strings.xml | 7 - app/src/main/res/values-hu/strings.xml | 190 +- app/src/main/res/values-in/strings.xml | 3 - app/src/main/res/values-is/strings.xml | 93 +- app/src/main/res/values-it/strings.xml | 180 +- app/src/main/res/values-iw/strings.xml | 2 + app/src/main/res/values-ja/strings.xml | 70 +- app/src/main/res/values-kab/strings.xml | 2 - app/src/main/res/values-ko/strings.xml | 33 +- app/src/main/res/values-lb/strings.xml | 2 + app/src/main/res/values-lv/strings.xml | 14 +- app/src/main/res/values-nb-rNO/strings.xml | 14 +- .../main/res/values-night/theme_colors.xml | 2 + app/src/main/res/values-nl/strings.xml | 60 +- app/src/main/res/values-oc/strings.xml | 62 +- app/src/main/res/values-pa/strings.xml | 2 + app/src/main/res/values-pl/strings.xml | 13 +- app/src/main/res/values-pt-rBR/strings.xml | 68 +- app/src/main/res/values-pt-rPT/strings.xml | 291 +- app/src/main/res/values-ru/strings.xml | 665 +- app/src/main/res/values-sa/strings.xml | 10 +- app/src/main/res/values-si/strings.xml | 8 +- app/src/main/res/values-sk/strings.xml | 2 - app/src/main/res/values-sl/strings.xml | 37 +- app/src/main/res/values-sv/strings.xml | 85 +- app/src/main/res/values-ta/strings.xml | 4 +- app/src/main/res/values-te/strings.xml | 2 + app/src/main/res/values-th/strings.xml | 9 +- app/src/main/res/values-tr/strings.xml | 311 +- app/src/main/res/values-uk/strings.xml | 97 +- app/src/main/res/values-vi/strings.xml | 124 +- app/src/main/res/values-zh-rCN/strings.xml | 75 +- app/src/main/res/values-zh-rHK/strings.xml | 9 +- app/src/main/res/values-zh-rMO/strings.xml | 8 +- app/src/main/res/values-zh-rSG/strings.xml | 12 +- app/src/main/res/values-zh-rTW/strings.xml | 10 +- app/src/main/res/values/actions.xml | 3 +- app/src/main/res/values/dimens.xml | 14 + app/src/main/res/values/donottranslate.xml | 31 +- app/src/main/res/values/string-arrays.xml | 10 +- app/src/main/res/values/strings.xml | 85 +- app/src/main/res/values/styles.xml | 7 +- app/src/main/res/values/theme_colors.xml | 2 + .../tusky/BottomSheetActivityTest.kt | 204 +- .../com/keylesspalace/tusky/FilterV1Test.kt | 8 +- .../keylesspalace/tusky/MainActivityTest.kt | 11 +- .../tusky/StatusComparisonTest.kt | 8 +- .../ComposeActivityTest.kt | 244 +- .../ComposeTokenizerTest.kt | 3 +- .../{ComposeActivity => }/StatusLengthTest.kt | 12 +- .../NotificationsPagingSourceTest.kt | 145 - .../NotificationsViewModelTestBase.kt | 137 - ...icationsViewModelTestClearNotifications.kt | 65 - .../NotificationsViewModelTestFilter.kt | 66 - ...icationsViewModelTestNotificationAction.kt | 144 - .../NotificationsViewModelTestStatusAction.kt | 227 - ...ationsViewModelTestStatusDisplayOptions.kt | 103 - .../NotificationsViewModelTestUiState.kt | 88 - .../NotificationsViewModelTestVisibleId.kt | 43 - .../CachedTimelineRemoteMediatorTest.kt | 28 +- .../NetworkTimelineRemoteMediatorTest.kt | 56 +- .../tusky/components/timeline/StatusMocker.kt | 10 +- .../viewthread/ViewThreadViewModelTest.kt | 70 +- .../keylesspalace/tusky/db/TimelineDaoTest.kt | 6 +- ...anAdapterTest.kt => GuardedAdapterTest.kt} | 16 +- .../tusky/usecase/TimelineCasesTest.kt | 14 +- .../tusky/util/AbsoluteTimeFormatterTest.kt | 31 +- .../tusky/util/FlowExtensionsTest.kt | 4 +- .../tusky/util/LinkHelperTest.kt | 39 +- .../tusky/util/NumberUtilsTest.kt | 4 +- bitrise.yml | 9 + build.gradle | 4 +- doc/PaymentPolicy.md | 34 + Release.md => doc/Release.md | 9 +- doc/ViewModelInterface.md | 615 -- .../metadata/android/cy/changelogs/103.txt | 18 + .../metadata/android/cy/changelogs/104.txt | 10 + .../metadata/android/cy/changelogs/105.txt | 11 + .../metadata/android/cy/changelogs/106.txt | 5 + .../metadata/android/cy/changelogs/107.txt | 6 + .../metadata/android/cy/changelogs/108.txt | 5 + .../metadata/android/cy/changelogs/109.txt | 10 + .../metadata/android/cy/changelogs/111.txt | 15 + .../metadata/android/cy/changelogs/112.txt | 6 + .../metadata/android/cy/changelogs/113.txt | 15 + .../metadata/android/cy/changelogs/115.txt | 10 + .../metadata/android/cy/changelogs/117.txt | 7 + .../metadata/android/cy/changelogs/119.txt | 8 + .../metadata/android/cy/full_description.txt | 12 +- .../metadata/android/cy/short_description.txt | 2 +- .../metadata/android/de/changelogs/115.txt | 9 + .../metadata/android/de/changelogs/117.txt | 6 + .../metadata/android/el/changelogs/100.txt | 7 + .../metadata/android/el/full_description.txt | 12 + .../metadata/android/el/short_description.txt | 1 + fastlane/metadata/android/el/title.txt | 1 + .../metadata/android/en-US/changelogs/115.txt | 10 + .../metadata/android/en-US/changelogs/117.txt | 7 + .../metadata/android/en-US/changelogs/119.txt | 8 + .../images/phoneScreenshots/03_profile.png | Bin 634796 -> 662058 bytes .../images/phoneScreenshots/05_reply.png | Bin 324661 -> 352835 bytes .../metadata/android/fa/changelogs/112.txt | 6 + .../metadata/android/fa/changelogs/113.txt | 15 + .../metadata/android/fa/changelogs/115.txt | 10 + .../metadata/android/fa/changelogs/117.txt | 7 + .../metadata/android/gl/changelogs/103.txt | 2 +- .../metadata/android/gl/changelogs/104.txt | 11 + .../metadata/android/gl/changelogs/105.txt | 12 + .../metadata/android/gl/changelogs/106.txt | 5 + .../metadata/android/gl/changelogs/107.txt | 7 + .../metadata/android/gl/changelogs/108.txt | 5 + .../metadata/android/gl/changelogs/109.txt | 12 + .../metadata/android/gl/changelogs/111.txt | 17 + .../metadata/android/gl/changelogs/112.txt | 6 + .../metadata/android/gl/changelogs/113.txt | 15 + .../metadata/android/gl/changelogs/115.txt | 10 + .../metadata/android/gl/changelogs/117.txt | 7 + .../metadata/android/gl/changelogs/119.txt | 8 + .../metadata/android/gl/full_description.txt | 6 +- .../metadata/android/pt-PT/changelogs/100.txt | 7 + .../metadata/android/pt-PT/changelogs/103.txt | 18 + .../metadata/android/pt-PT/changelogs/104.txt | 10 + .../metadata/android/pt-PT/changelogs/117.txt | 7 + .../metadata/android/pt-PT/changelogs/91.txt | 6 + .../metadata/android/ru/changelogs/100.txt | 7 + .../metadata/android/ru/changelogs/103.txt | 18 + .../metadata/android/ru/changelogs/104.txt | 10 + .../metadata/android/ru/changelogs/105.txt | 11 + .../metadata/android/ru/changelogs/106.txt | 5 + .../metadata/android/ru/changelogs/107.txt | 6 + .../metadata/android/ru/changelogs/108.txt | 5 + .../metadata/android/ru/changelogs/109.txt | 10 + .../metadata/android/ru/changelogs/110.txt | 20 + .../metadata/android/ru/changelogs/111.txt | 15 + .../metadata/android/ru/changelogs/112.txt | 6 + .../metadata/android/ru/changelogs/113.txt | 15 + .../metadata/android/ru/changelogs/115.txt | 10 + .../metadata/android/ru/changelogs/89.txt | 7 + .../metadata/android/ru/changelogs/91.txt | 6 + .../metadata/android/ru/changelogs/94.txt | 9 + .../metadata/android/ru/changelogs/97.txt | 9 + .../metadata/android/ru/full_description.txt | 4 +- .../metadata/android/sl/changelogs/100.txt | 7 + .../metadata/android/sl/changelogs/103.txt | 18 + .../metadata/android/sl/changelogs/104.txt | 10 + .../metadata/android/sl/full_description.txt | 12 + .../metadata/android/sl/short_description.txt | 2 +- .../metadata/android/sv/changelogs/112.txt | 6 + .../metadata/android/sv/changelogs/113.txt | 15 + .../metadata/android/sv/changelogs/115.txt | 10 + .../metadata/android/sv/changelogs/117.txt | 7 + .../metadata/android/sv/changelogs/119.txt | 8 + .../metadata/android/tr/changelogs/100.txt | 7 + .../metadata/android/tr/changelogs/103.txt | 18 + .../metadata/android/tr/changelogs/104.txt | 10 + .../metadata/android/tr/changelogs/105.txt | 11 + .../metadata/android/tr/changelogs/106.txt | 5 + .../metadata/android/tr/changelogs/107.txt | 6 + .../metadata/android/tr/changelogs/108.txt | 5 + .../metadata/android/tr/changelogs/109.txt | 10 + .../metadata/android/tr/changelogs/110.txt | 20 + .../metadata/android/tr/changelogs/111.txt | 15 + .../metadata/android/tr/changelogs/112.txt | 6 + .../metadata/android/tr/changelogs/113.txt | 15 + .../metadata/android/tr/changelogs/115.txt | 10 + .../metadata/android/tr/changelogs/117.txt | 7 + .../metadata/android/tr/changelogs/119.txt | 8 + .../metadata/android/uk/changelogs/107.txt | 6 + .../metadata/android/uk/changelogs/108.txt | 5 + .../metadata/android/uk/changelogs/109.txt | 10 + .../metadata/android/uk/changelogs/110.txt | 20 + .../metadata/android/uk/changelogs/111.txt | 15 + .../metadata/android/uk/changelogs/112.txt | 6 + .../metadata/android/uk/changelogs/113.txt | 15 + .../metadata/android/uk/changelogs/115.txt | 10 + .../metadata/android/uk/changelogs/117.txt | 7 + .../metadata/android/vi/changelogs/111.txt | 15 + .../metadata/android/vi/changelogs/112.txt | 6 + .../metadata/android/vi/changelogs/113.txt | 13 + .../metadata/android/vi/changelogs/115.txt | 10 + .../metadata/android/vi/changelogs/117.txt | 7 + .../metadata/android/vi/changelogs/119.txt | 8 + .../android/zh-Hans/changelogs/58.txt | 8 +- .../android/zh-Hans/changelogs/61.txt | 2 +- .../android/zh-Hans/changelogs/67.txt | 6 +- .../android/zh-Hans/changelogs/68.txt | 2 +- .../android/zh-Hans/changelogs/70.txt | 6 +- .../android/zh-Hans/changelogs/72.txt | 2 +- .../android/zh-Hans/changelogs/80.txt | 2 +- .../android/zh-Hans/changelogs/87.txt | 6 +- .../android/zh-Hans/changelogs/89.txt | 2 +- .../android/zh-Hans/changelogs/94.txt | 2 +- .../android/zh-Hans/full_description.txt | 18 +- .../android/zh-Hans/short_description.txt | 2 +- .../android/zh-Hant/full_description.txt | 12 + .../android/zh-Hant/short_description.txt | 1 + gradle.properties | 11 +- gradle/libs.versions.toml | 102 +- gradle/wrapper/gradle-wrapper.jar | Bin 62076 -> 43453 bytes gradle/wrapper/gradle-wrapper.properties | 3 +- gradlew | 22 +- gradlew.bat | 20 +- renovate.json | 14 +- settings.gradle | 29 +- 630 files changed, 22153 insertions(+), 18732 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/1.bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/2.feature_request.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ci-gradle.properties create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/populate-gradle-build-cache.yml delete mode 100644 ISSUE_TEMPLATE.md create mode 100644 app/schemas/com.keylesspalace.tusky.db.AppDatabase/52.json create mode 100644 app/schemas/com.keylesspalace.tusky.db.AppDatabase/53.json create mode 100644 app/schemas/com.keylesspalace.tusky.db.AppDatabase/54.json create mode 100644 app/schemas/com.keylesspalace.tusky.db.AppDatabase/56.json create mode 100644 app/schemas/com.keylesspalace.tusky.db.AppDatabase/58.json create mode 100644 app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java create mode 100644 app/src/main/java/com/keylesspalace/tusky/components/account/list/ListSelectionFragment.kt delete mode 100644 app/src/main/java/com/keylesspalace/tusky/components/account/list/ListsForAccountFragment.kt rename app/src/main/java/com/keylesspalace/tusky/components/{instancemute/InstanceListActivity.kt => domainblocks/DomainBlocksActivity.kt} (78%) create mode 100644 app/src/main/java/com/keylesspalace/tusky/components/domainblocks/DomainBlocksAdapter.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/components/domainblocks/DomainBlocksFragment.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/components/domainblocks/DomainBlocksPagingSource.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/components/domainblocks/DomainBlocksRemoteMediator.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/components/domainblocks/DomainBlocksRepository.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/components/domainblocks/DomainBlocksViewModel.kt rename app/src/main/java/com/keylesspalace/tusky/components/{notifications/NotificationsLoadStateAdapter.kt => filters/FilterExtensions.kt} (50%) delete mode 100644 app/src/main/java/com/keylesspalace/tusky/components/instancemute/adapter/DomainMutesAdapter.kt delete mode 100644 app/src/main/java/com/keylesspalace/tusky/components/instancemute/fragment/InstanceListFragment.kt delete mode 100644 app/src/main/java/com/keylesspalace/tusky/components/instancemute/interfaces/InstanceActionListener.kt delete mode 100644 app/src/main/java/com/keylesspalace/tusky/components/notifications/FollowViewHolder.kt delete mode 100644 app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsFragment.kt delete mode 100644 app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsLoadStateViewHolder.kt delete mode 100644 app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsPagingAdapter.kt delete mode 100644 app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsPagingSource.kt delete mode 100644 app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsRepository.kt delete mode 100644 app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModel.kt delete mode 100644 app/src/main/java/com/keylesspalace/tusky/components/notifications/StatusNotificationViewHolder.kt delete mode 100644 app/src/main/java/com/keylesspalace/tusky/components/notifications/StatusViewHolder.kt rename app/src/main/java/com/keylesspalace/tusky/components/trending/{TrendingAdapter.kt => TrendingTagsAdapter.kt} (99%) rename app/src/main/java/com/keylesspalace/tusky/components/trending/{TrendingFragment.kt => TrendingTagsFragment.kt} (83%) rename app/src/main/java/com/keylesspalace/tusky/components/trending/viewmodel/{TrendingViewModel.kt => TrendingTagsViewModel.kt} (64%) create mode 100644 app/src/main/java/com/keylesspalace/tusky/di/PlayerModule.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/entity/InstanceV1.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/entity/Translation.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java rename app/src/main/java/com/keylesspalace/tusky/json/{GuardedBooleanAdapter.kt => Guarded.kt} (52%) create mode 100644 app/src/main/java/com/keylesspalace/tusky/json/GuardedAdapter.kt delete mode 100644 app/src/main/java/com/keylesspalace/tusky/json/Iso8601Utils.kt delete mode 100644 app/src/main/java/com/keylesspalace/tusky/json/Rfc3339DateJsonAdapter.kt delete mode 100644 app/src/main/java/com/keylesspalace/tusky/network/ProgressRequestBody.java create mode 100644 app/src/main/java/com/keylesspalace/tusky/network/UriRequestBody.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/util/ActivityExensions.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/util/AlertDialogExtensions.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/util/CompositeWithOpaqueBackground.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/util/GlideExtensions.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/util/PairedList.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/util/RelativeTimeUpdater.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/util/Single.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/view/AdaptiveTabLayout.kt delete mode 100644 app/src/main/java/com/keylesspalace/tusky/view/ExposedPlayPauseVideoView.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.java delete mode 100644 app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.kt create mode 100644 app/src/main/res/anim/activity_close_enter.xml create mode 100644 app/src/main/res/anim/activity_close_exit.xml create mode 100644 app/src/main/res/anim/activity_open_enter.xml create mode 100644 app/src/main/res/anim/activity_open_exit.xml delete mode 100644 app/src/main/res/anim/fade_in.xml delete mode 100644 app/src/main/res/anim/fade_out.xml create mode 100644 app/src/main/res/anim/fast_out_extra_slow_in.xml delete mode 100644 app/src/main/res/anim/slide_from_left.xml delete mode 100644 app/src/main/res/anim/slide_from_right.xml delete mode 100644 app/src/main/res/anim/slide_to_left.xml delete mode 100644 app/src/main/res/anim/slide_to_right.xml delete mode 100644 app/src/main/res/color-v24/launcher_shadow_gradient.xml delete mode 100644 app/src/main/res/drawable-v24/ic_notoemoji.xml rename app/src/main/res/drawable/{elephant_error.xml => errorphant_error.xml} (100%) rename app/src/main/res/drawable/{elephant_offline.xml => errorphant_offline.xml} (100%) create mode 100644 app/src/main/res/drawable/ic_arrow_back.xml create mode 100644 app/src/main/res/drawable/ic_arrow_back_with_background.xml delete mode 100644 app/src/main/res/drawable/ic_blobmoji.xml create mode 100644 app/src/main/res/drawable/ic_content_copy_24.xml delete mode 100644 app/src/main/res/drawable/ic_emoji_34dp.xml create mode 100644 app/src/main/res/drawable/ic_hot_24dp.xml create mode 100644 app/src/main/res/drawable/ic_link.xml create mode 100644 app/src/main/res/drawable/ic_more.xml create mode 100644 app/src/main/res/drawable/ic_more_with_background.xml delete mode 100644 app/src/main/res/drawable/ic_notifications_off_24dp.xml delete mode 100644 app/src/main/res/drawable/ic_notoemoji.xml delete mode 100644 app/src/main/res/drawable/ic_twemoji.xml delete mode 100644 app/src/main/res/drawable/profile_badge_background.xml create mode 100644 app/src/main/res/drawable/profile_badge_person_24dp.xml create mode 100644 app/src/main/res/layout/dialog_list.xml create mode 100644 app/src/main/res/layout/exo_player_control_view.xml rename app/src/main/res/layout/{fragment_instance_list.xml => fragment_domain_blocks.xml} (88%) delete mode 100644 app/src/main/res/layout/fragment_lists_for_account.xml create mode 100644 app/src/main/res/layout/fragment_lists_list.xml rename app/src/main/res/layout/{fragment_trending.xml => fragment_trending_tags.xml} (100%) delete mode 100644 app/src/main/res/layout/item_add_or_remove_from_list.xml rename app/src/main/res/layout/{item_muted_domain.xml => item_blocked_domain.xml} (91%) delete mode 100644 app/src/main/res/layout/item_emoji_pref.xml create mode 100644 app/src/main/res/layout/notifications_filter.xml create mode 100644 app/src/main/res/values-en-rAU/strings.xml create mode 100644 app/src/main/res/values-fr-rBE/strings.xml create mode 100644 app/src/main/res/values-iw/strings.xml create mode 100644 app/src/main/res/values-lb/strings.xml create mode 100644 app/src/main/res/values-pa/strings.xml create mode 100644 app/src/main/res/values-te/strings.xml rename app/src/test/java/com/keylesspalace/tusky/components/compose/{ComposeActivity => }/ComposeActivityTest.kt (62%) rename app/src/test/java/com/keylesspalace/tusky/components/compose/{ComposeTokenizer => }/ComposeTokenizerTest.kt (96%) rename app/src/test/java/com/keylesspalace/tusky/components/compose/{ComposeActivity => }/StatusLengthTest.kt (88%) delete mode 100644 app/src/test/java/com/keylesspalace/tusky/components/notifications/NotificationsPagingSourceTest.kt delete mode 100644 app/src/test/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModelTestBase.kt delete mode 100644 app/src/test/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModelTestClearNotifications.kt delete mode 100644 app/src/test/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModelTestFilter.kt delete mode 100644 app/src/test/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModelTestNotificationAction.kt delete mode 100644 app/src/test/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModelTestStatusAction.kt delete mode 100644 app/src/test/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModelTestStatusDisplayOptions.kt delete mode 100644 app/src/test/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModelTestUiState.kt delete mode 100644 app/src/test/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModelTestVisibleId.kt rename app/src/test/java/com/keylesspalace/tusky/json/{GuardedBooleanAdapterTest.kt => GuardedAdapterTest.kt} (89%) create mode 100644 doc/PaymentPolicy.md rename Release.md => doc/Release.md (81%) delete mode 100644 doc/ViewModelInterface.md create mode 100644 fastlane/metadata/android/cy/changelogs/103.txt create mode 100644 fastlane/metadata/android/cy/changelogs/104.txt create mode 100644 fastlane/metadata/android/cy/changelogs/105.txt create mode 100644 fastlane/metadata/android/cy/changelogs/106.txt create mode 100644 fastlane/metadata/android/cy/changelogs/107.txt create mode 100644 fastlane/metadata/android/cy/changelogs/108.txt create mode 100644 fastlane/metadata/android/cy/changelogs/109.txt create mode 100644 fastlane/metadata/android/cy/changelogs/111.txt create mode 100644 fastlane/metadata/android/cy/changelogs/112.txt create mode 100644 fastlane/metadata/android/cy/changelogs/113.txt create mode 100644 fastlane/metadata/android/cy/changelogs/115.txt create mode 100644 fastlane/metadata/android/cy/changelogs/117.txt create mode 100644 fastlane/metadata/android/cy/changelogs/119.txt create mode 100644 fastlane/metadata/android/de/changelogs/115.txt create mode 100644 fastlane/metadata/android/de/changelogs/117.txt create mode 100644 fastlane/metadata/android/el/changelogs/100.txt create mode 100644 fastlane/metadata/android/el/full_description.txt create mode 100644 fastlane/metadata/android/el/short_description.txt create mode 100644 fastlane/metadata/android/el/title.txt create mode 100644 fastlane/metadata/android/en-US/changelogs/115.txt create mode 100644 fastlane/metadata/android/en-US/changelogs/117.txt create mode 100644 fastlane/metadata/android/en-US/changelogs/119.txt create mode 100644 fastlane/metadata/android/fa/changelogs/112.txt create mode 100644 fastlane/metadata/android/fa/changelogs/113.txt create mode 100644 fastlane/metadata/android/fa/changelogs/115.txt create mode 100644 fastlane/metadata/android/fa/changelogs/117.txt create mode 100644 fastlane/metadata/android/gl/changelogs/104.txt create mode 100644 fastlane/metadata/android/gl/changelogs/105.txt create mode 100644 fastlane/metadata/android/gl/changelogs/106.txt create mode 100644 fastlane/metadata/android/gl/changelogs/107.txt create mode 100644 fastlane/metadata/android/gl/changelogs/108.txt create mode 100644 fastlane/metadata/android/gl/changelogs/109.txt create mode 100644 fastlane/metadata/android/gl/changelogs/111.txt create mode 100644 fastlane/metadata/android/gl/changelogs/112.txt create mode 100644 fastlane/metadata/android/gl/changelogs/113.txt create mode 100644 fastlane/metadata/android/gl/changelogs/115.txt create mode 100644 fastlane/metadata/android/gl/changelogs/117.txt create mode 100644 fastlane/metadata/android/gl/changelogs/119.txt create mode 100644 fastlane/metadata/android/pt-PT/changelogs/100.txt create mode 100644 fastlane/metadata/android/pt-PT/changelogs/103.txt create mode 100644 fastlane/metadata/android/pt-PT/changelogs/104.txt create mode 100644 fastlane/metadata/android/pt-PT/changelogs/117.txt create mode 100644 fastlane/metadata/android/pt-PT/changelogs/91.txt create mode 100644 fastlane/metadata/android/ru/changelogs/100.txt create mode 100644 fastlane/metadata/android/ru/changelogs/103.txt create mode 100644 fastlane/metadata/android/ru/changelogs/104.txt create mode 100644 fastlane/metadata/android/ru/changelogs/105.txt create mode 100644 fastlane/metadata/android/ru/changelogs/106.txt create mode 100644 fastlane/metadata/android/ru/changelogs/107.txt create mode 100644 fastlane/metadata/android/ru/changelogs/108.txt create mode 100644 fastlane/metadata/android/ru/changelogs/109.txt create mode 100644 fastlane/metadata/android/ru/changelogs/110.txt create mode 100644 fastlane/metadata/android/ru/changelogs/111.txt create mode 100644 fastlane/metadata/android/ru/changelogs/112.txt create mode 100644 fastlane/metadata/android/ru/changelogs/113.txt create mode 100644 fastlane/metadata/android/ru/changelogs/115.txt create mode 100644 fastlane/metadata/android/ru/changelogs/89.txt create mode 100644 fastlane/metadata/android/ru/changelogs/91.txt create mode 100644 fastlane/metadata/android/ru/changelogs/94.txt create mode 100644 fastlane/metadata/android/ru/changelogs/97.txt create mode 100644 fastlane/metadata/android/sl/changelogs/100.txt create mode 100644 fastlane/metadata/android/sl/changelogs/103.txt create mode 100644 fastlane/metadata/android/sl/changelogs/104.txt create mode 100644 fastlane/metadata/android/sl/full_description.txt create mode 100644 fastlane/metadata/android/sv/changelogs/112.txt create mode 100644 fastlane/metadata/android/sv/changelogs/113.txt create mode 100644 fastlane/metadata/android/sv/changelogs/115.txt create mode 100644 fastlane/metadata/android/sv/changelogs/117.txt create mode 100644 fastlane/metadata/android/sv/changelogs/119.txt create mode 100644 fastlane/metadata/android/tr/changelogs/100.txt create mode 100644 fastlane/metadata/android/tr/changelogs/103.txt create mode 100644 fastlane/metadata/android/tr/changelogs/104.txt create mode 100644 fastlane/metadata/android/tr/changelogs/105.txt create mode 100644 fastlane/metadata/android/tr/changelogs/106.txt create mode 100644 fastlane/metadata/android/tr/changelogs/107.txt create mode 100644 fastlane/metadata/android/tr/changelogs/108.txt create mode 100644 fastlane/metadata/android/tr/changelogs/109.txt create mode 100644 fastlane/metadata/android/tr/changelogs/110.txt create mode 100644 fastlane/metadata/android/tr/changelogs/111.txt create mode 100644 fastlane/metadata/android/tr/changelogs/112.txt create mode 100644 fastlane/metadata/android/tr/changelogs/113.txt create mode 100644 fastlane/metadata/android/tr/changelogs/115.txt create mode 100644 fastlane/metadata/android/tr/changelogs/117.txt create mode 100644 fastlane/metadata/android/tr/changelogs/119.txt create mode 100644 fastlane/metadata/android/uk/changelogs/107.txt create mode 100644 fastlane/metadata/android/uk/changelogs/108.txt create mode 100644 fastlane/metadata/android/uk/changelogs/109.txt create mode 100644 fastlane/metadata/android/uk/changelogs/110.txt create mode 100644 fastlane/metadata/android/uk/changelogs/111.txt create mode 100644 fastlane/metadata/android/uk/changelogs/112.txt create mode 100644 fastlane/metadata/android/uk/changelogs/113.txt create mode 100644 fastlane/metadata/android/uk/changelogs/115.txt create mode 100644 fastlane/metadata/android/uk/changelogs/117.txt create mode 100644 fastlane/metadata/android/vi/changelogs/111.txt create mode 100644 fastlane/metadata/android/vi/changelogs/112.txt create mode 100644 fastlane/metadata/android/vi/changelogs/113.txt create mode 100644 fastlane/metadata/android/vi/changelogs/115.txt create mode 100644 fastlane/metadata/android/vi/changelogs/117.txt create mode 100644 fastlane/metadata/android/vi/changelogs/119.txt create mode 100644 fastlane/metadata/android/zh-Hant/full_description.txt create mode 100644 fastlane/metadata/android/zh-Hant/short_description.txt diff --git a/.editorconfig b/.editorconfig index 47d830da0..e435f4e01 100644 --- a/.editorconfig +++ b/.editorconfig @@ -7,11 +7,21 @@ indent_style = space insert_final_newline = true trim_trailing_whitespace = true +[*.{java,kt}] +ij_kotlin_imports_layout = * + # Disable wildcard imports -[*.{java, kt}] ij_kotlin_name_count_to_use_star_import = 999 ij_kotlin_name_count_to_use_star_import_for_members = 999 ij_java_class_count_to_use_import_on_demand = 999 +ktlint_code_style = android_studio + +# Disable trailing comma +ktlint_standard_trailing-comma-on-call-site = disabled +ktlint_standard_trailing-comma-on-declaration-site = disabled + +max_line_length = off + [*.{yml,yaml}] indent_size = 2 diff --git a/.github/ISSUE_TEMPLATE/1.bug_report.yml b/.github/ISSUE_TEMPLATE/1.bug_report.yml new file mode 100644 index 000000000..7295570c8 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/1.bug_report.yml @@ -0,0 +1,41 @@ +name: Bug Report +description: If something isn't working as expected +labels: [bug] +body: + - type: markdown + attributes: + value: | + Make sure that you are submitting a new bug that was not previously reported or already fixed. + + Please use a concise and distinct title for the issue. + + If possible, attach screenshots, videos or links to posts to illustrate the problem. + - type: textarea + attributes: + label: Detailed description + validations: + required: false + - type: textarea + attributes: + label: Steps to reproduce the problem + description: What were you trying to do? + value: | + 1. + 2. + 3. + ... + validations: + required: false + - type: textarea + attributes: + label: Debug information + description: | + This info can be copied from the 'About' screen in Tusky 24+. + If you are on a lower version or can't access the screen, please provide us with the Tusky Version, Android Version, Device and the Mastodon instance this problem occurred on. + placeholder: | + Tusky Test 22.0-b814c2c0 + Android 12 + Fairphone 4 + mastodon.social + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/2.feature_request.yml b/.github/ISSUE_TEMPLATE/2.feature_request.yml new file mode 100644 index 000000000..8f66f49b6 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/2.feature_request.yml @@ -0,0 +1,19 @@ +name: Feature Request +description: I have a suggestion +labels: [enhancement] +body: + - type: markdown + attributes: + value: Please use a concise and distinct title for the issue. + - type: textarea + attributes: + label: Pitch + description: Describe your idea for a feature. Make sure it has not already been suggested/implemented/turned down before. + validations: + required: true + - type: textarea + attributes: + label: Motivation + description: Why do you think this feature is needed? Who would benefit from it? + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 000000000..0086358db --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1 @@ +blank_issues_enabled: true diff --git a/.github/ci-gradle.properties b/.github/ci-gradle.properties new file mode 100644 index 000000000..b27d29785 --- /dev/null +++ b/.github/ci-gradle.properties @@ -0,0 +1,24 @@ +# +# Copyright 2023 Tusky Contributors +# +# This file is a part of Tusky. +# +# 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. +# +# Tusky 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 Tusky; if not, +# see . +# + +# CI build workers are ephemeral, so don't benefit from the Gradle daemon +org.gradle.daemon=false +org.gradle.parallel=true +org.gradle.workers.max=2 + +kotlin.incremental=false +kotlin.compiler.execution.strategy=in-process diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..479c5232f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,44 @@ +name: CI + +on: + push: + tags: + - '*' + pull_request: + workflow_dispatch: + +jobs: + build: + name: Build + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + + - name: Gradle Wrapper Validation + uses: gradle/actions/wrapper-validation@v3 + + - name: Copy CI gradle.properties + run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties + + - name: Gradle Build Action + uses: gradle/gradle-build-action@v3 + with: + cache-read-only: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/develop' }} + + - name: ktlint + run: ./gradlew clean ktlintCheck + + - name: Regular lint + run: ./gradlew app:lintGreenDebug + + - name: Test + run: ./gradlew app:testGreenDebugUnitTest + + - name: Build + run: ./gradlew app:buildGreenDebug diff --git a/.github/workflows/populate-gradle-build-cache.yml b/.github/workflows/populate-gradle-build-cache.yml new file mode 100644 index 000000000..0207cceb1 --- /dev/null +++ b/.github/workflows/populate-gradle-build-cache.yml @@ -0,0 +1,34 @@ +# Build the app on each push to `develop`, populating the build cache to speed +# up CI on PRs. + +name: Populate build cache + +on: + push: + branches: + - develop + +jobs: + build: + name: app:buildGreenDebug + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + + - name: Gradle Wrapper Validation + uses: gradle/actions/wrapper-validation@v3 + + - name: Copy CI gradle.properties + run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties + + - uses: gradle/gradle-build-action@v3 + with: + cache-read-only: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/develop' }} + + - name: Run app:buildGreenDebug + run: ./gradlew app:buildGreenDebug diff --git a/CHANGELOG.md b/CHANGELOG.md index 1328cd7f5..ab13d8e7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,94 @@ ### Significant bug fixes +## v25.2 + +### Significant bug fixes + +- Fixes a bug that could sometimes crash Tusky when rotating the screen while viewing an account list [PR#4430](https://github.com/tuskyapp/Tusky/pull/4430) +- Fixes a bug that could crash Tusky at startup under certain conditions [PR#4431](https://github.com/tuskyapp/Tusky/pull/4431) +- Fixes a bug that caused Tusky to crash when custom emojis with too large dimensions were loaded [PR#4429](https://github.com/tuskyapp/Tusky/pull/4429) +- Makes Tusky work again with Iceshrimp by working around a quirk in their API implementation [PR#4426](https://github.com/tuskyapp/Tusky/pull/4426) +- Fixes a bug that made translations not work on some servers [PR#4422](https://github.com/tuskyapp/Tusky/pull/4422) + +## v25.1 + +### Significant bug fixes + +- Fixed two crashes at startup introduced in 25.0 [PR#4415](https://github.com/tuskyapp/Tusky/pull/4415) [PR#4417](https://github.com/tuskyapp/Tusky/pull/4417) + +## v25.0 + +### New features and other improvements + +- Added support for the [Mastodon translation api](https://docs.joinmastodon.org/methods/statuses/#translate). + You can now find a new option "translate" in the three-dot-menu on posts that are not in your display language when your server supports the translation api. + Support is determined by checking the `configuration.translation.enabled` attribute of the `/api/v2/instance` endpoint. + [PR#4307](https://github.com/tuskyapp/Tusky/pull/4307) +- The language of a post is now shown in the metadata section of the detail post view, if it is available. [PR#4127](https://github.com/tuskyapp/Tusky/pull/4127) +- The transitions between screens have been changed to feel faster and align more with default Android transitions. [PR#4285](https://github.com/tuskyapp/Tusky/pull/4285) +- The post statistic section below the detail post view is now always shown to prevent layout shifts on the first like or boost. + [PR#4205](https://github.com/tuskyapp/Tusky/pull/4205) [PR#4260](https://github.com/tuskyapp/Tusky/pull/4260) +- The filters for boosts/replies/self-boosts in the home timeline have moved from general preferences to account specific preferences. [PR#4115](https://github.com/tuskyapp/Tusky/pull/4115) +- The json parsing library has been migrated from Gson to Moshi. This change will make Tusky no longer crash on unexpected server responses. [PR#4309](https://github.com/tuskyapp/Tusky/pull/4309) +- Small layout improvements to the header of the profile view [PR#4375](https://github.com/tuskyapp/Tusky/pull/4375) [PR#4371](https://github.com/tuskyapp/Tusky/pull/4371) +- support for Android 14 Upside Down Cake [PR#4224](https://github.com/tuskyapp/Tusky/pull/4224) +- Various internal refactorings to improve performance and maintainability. + [PR#4269](https://github.com/tuskyapp/Tusky/pull/4269) + [PR#4290](https://github.com/tuskyapp/Tusky/pull/4290) + [PR#4291](https://github.com/tuskyapp/Tusky/pull/4291) + [PR#4296](https://github.com/tuskyapp/Tusky/pull/4296) + [PR#4364](https://github.com/tuskyapp/Tusky/pull/4364) + [PR#4366](https://github.com/tuskyapp/Tusky/pull/4366) + [PR#4372](https://github.com/tuskyapp/Tusky/pull/4372) + [PR#4356](https://github.com/tuskyapp/Tusky/pull/4356) + [PR#4348](https://github.com/tuskyapp/Tusky/pull/4348) + [PR#4339](https://github.com/tuskyapp/Tusky/pull/4339) + [PR#4337](https://github.com/tuskyapp/Tusky/pull/4337) + [PR#4336](https://github.com/tuskyapp/Tusky/pull/4336) + [PR#4330](https://github.com/tuskyapp/Tusky/pull/4330) + [PR#4235](https://github.com/tuskyapp/Tusky/pull/4235) + [PR#4081](https://github.com/tuskyapp/Tusky/pull/4081) + +### Significant bug fixes + +- The setting to hide the notification filter bar that was accidentally removed is back. [PR#4225](https://github.com/tuskyapp/Tusky/pull/4225) +- The profile picture in the bottom navigation bar now has the correct content description. [PR#4400](https://github.com/tuskyapp/Tusky/pull/4400) + +## v24.1 + +- The screen will stay on again while a video is playing. [PR#4168](https://github.com/tuskyapp/Tusky/pull/4168) +- A memory leak has been fixed. This should improve stability and performance. [PR#4150](https://github.com/tuskyapp/Tusky/pull/4150) [PR#4153](https://github.com/tuskyapp/Tusky/pull/4153) +- Emojis are now correctly counted as 1 character when composing a post. [PR#4152](https://github.com/tuskyapp/Tusky/pull/4152) +- Fixed a crash when text was selected on some devices. [PR#4166](https://github.com/tuskyapp/Tusky/pull/4166) +- The icons in the help texts of empty timelines will now always be correctly + aligned. [PR#4179](https://github.com/tuskyapp/Tusky/pull/4179) +- Fixed ANR caused by direct message badge [PR#4182](https://github.com/tuskyapp/Tusky/pull/4182) + +## v24.0 + +### New features and other improvements + +- The number of tabs that can be configured is no longer limited. [PR#4058](https://github.com/tuskyapp/Tusky/pull/4058) +- Blockquotes and code blocks in posts now look nicer [PR#4090](https://github.com/tuskyapp/Tusky/pull/4090) [PR#4091](https://github.com/tuskyapp/Tusky/pull/4091) +- The old behavior of the notification tab (pre Tusky 22.0) has been restored. [PR#4015](https://github.com/tuskyapp/Tusky/pull/4015) +- Role badges are now shown on profiles (Mastodon 4.2 feature). [PR#4029](https://github.com/tuskyapp/Tusky/pull/4029) +- The video player has been upgraded to Google Jetpack Media3; video compatibility should be improved, and you can now adjust playback speed. [PR#3857](https://github.com/tuskyapp/Tusky/pull/3857) +- New theme option to use the black theme when following the system design. [PR#3957](https://github.com/tuskyapp/Tusky/pull/3957) +- Following the system design is now the default theme setting. [PR#3813](https://github.com/tuskyapp/Tusky/pull/3957) +- A new view to see trending posts is available both in the menu and as custom tab. [PR#4007](https://github.com/tuskyapp/Tusky/pull/4007) +- A new option to hide self boosts has been added. [PR#4101](https://github.com/tuskyapp/Tusky/pull/4101) +- The `api/v2/instance` endpoint is now supported. [PR#4062](https://github.com/tuskyapp/Tusky/pull/4062) +- New settings for lists: + - Hide from the home timeline [PR#3932](https://github.com/tuskyapp/Tusky/pull/3932) + - Decide which replies should be shown in the list [PR#4072](https://github.com/tuskyapp/Tusky/pull/4072) +- The oldest supported Android version is now Android 7 Nougat [PR#4014](https://github.com/tuskyapp/Tusky/pull/4014) + +### Significant bug fixes + +- **Empty trends no longer causes Tusky to crash**, [PR#3853](https://github.com/tuskyapp/Tusky/pull/3853) + + ## v23.0 ### New features and other improvements diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0960c91eb..0bf4d3aa6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -22,8 +22,9 @@ We try to follow the [Guide to app architecture](https://developer.android.com/t ### Kotlin Tusky was originally written in Java, but is in the process of migrating to Kotlin. All new code must be written in Kotlin. -We try to follow the [Kotlin Style Guide](https://developer.android.com/kotlin/style-guide) and make format the code according to the default [ktlint codestyle](https://github.com/pinterest/ktlint). -You can check the codestyle by running `./gradlew ktlintCheck`. +We try to follow the [Kotlin Style Guide](https://developer.android.com/kotlin/style-guide) and format the code according to the default [ktlint codestyle](https://github.com/pinterest/ktlint). +You can check the codestyle by running `./gradlew ktlintCheck lint`. This will fail if you have any errors, and produces a detailed report which also lists warnings. +We intentionally have very few hard linting errors, so that new contributors can focus on what they want to achieve instead of fighting the linter. ### Text All English text that will be visible to users must be put in `app/src/main/res/values/strings.xml` so it is translateable into other languages. @@ -42,12 +43,15 @@ All icons are from the Material iconset, find new icons [here](https://fonts.goo We try to make Tusky as accessible as possible for as many people as possible. Please make sure that all touch targets are at least 48dpx48dp in size, Text has sufficient contrast and images or icons have a image description. See [this guide](https://developer.android.com/guide/topics/ui/accessibility/apps) for more information. ### Supported servers -Tusky is primarily a Mastodon client and aims to always support the newest Mastodon version. Other platforms implementing the Mastodon Api, e.g. Akkoma, GoToSocial or Pixelfed should also work with Tusky but no special effort is made to support their quirks or additional features. +Tusky is primarily a Mastodon client and aims to always support the newest Mastodon version. Other platforms implementing the Mastodon API, e.g. Akkoma, GoToSocial or Pixelfed should also work with Tusky, but no special effort is made to support their quirks or additional features. + +### Payment Policy +Our payment policy may be viewed [here](https://github.com/tuskyapp/Tusky/blob/develop/doc/PaymentPolicy.md). ## Troubleshooting / FAQ -- Tusky should be built with the newest version of Android Studio +- Tusky should be built with the newest version of Android Studio. - Tusky comes with two sets of build variants, "blue" and "green", which can be installed simultaneously and are distinguished by the colors of their icons. Green is intended for local development and testing, whereas blue is for releases. ## Resources -- [Mastodon Api documentation](https://docs.joinmastodon.org/api/) +- [Mastodon API documentation](https://docs.joinmastodon.org/api/) diff --git a/ISSUE_TEMPLATE.md b/ISSUE_TEMPLATE.md deleted file mode 100644 index a568a4dc8..000000000 --- a/ISSUE_TEMPLATE.md +++ /dev/null @@ -1,9 +0,0 @@ -[Issue text goes here]. - -* * * * -- Tusky Version: -- Android Version: -- Android Device: -- Mastodon instance (if applicable): - -- [ ] I searched or browsed the repo’s other issues to ensure this is not a duplicate. diff --git a/README.md b/README.md index 021901b24..0dd9f19b9 100644 --- a/README.md +++ b/README.md @@ -12,10 +12,11 @@ It is derived from [Tusky](https://tusky.app) and has been modified to reflect C ## Features - Material Design +- Most Mastodon APIs implemented - Multi-Account support - Respect device preferences for light/dark theming - Drafts - compose posts and save them for later -- Choose between different emoji styles +- Choose between different emoji styles - Optimized for all screen sizes - Completely open-source - no non-free dependencies like Google services @@ -26,4 +27,4 @@ If you have any bug reports, feature requests or questions please open an issue For translating Tusky into your language, visit https://weblate.tusky.app/ -### +### diff --git a/app/build.gradle b/app/build.gradle index fe7f676fd..139ca936c 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -24,15 +24,15 @@ final def SUPPORT_ACCOUNT_URL = "https://social.chinwag.org/@ChinwagNews" final def REGISTER_ACCOUNT_URL = "https://chinwag.org/auth/sign_up" android { - compileSdk 33 + compileSdk 34 namespace "com.keylesspalace.tusky" defaultConfig { applicationId APP_ID namespace "com.keylesspalace.tusky" - minSdk 23 - targetSdk 33 - versionCode 90 - versionName "23.0-CW0" + minSdk 24 + targetSdk 34 + versionCode 121 + versionName "25.2-CW0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables.useSupportLibrary = true @@ -44,10 +44,21 @@ android { buildConfigField("String", "REGISTER_ACCOUNT_URL", "\"$REGISTER_ACCOUNT_URL\"") } buildTypes { + debug { + isDefault true + } release { minifyEnabled true shrinkResources true proguardFiles 'proguard-rules.pro' + + kotlinOptions { + freeCompilerArgs = [ + "-Xno-param-assertions", + "-Xno-call-assertions", + "-Xno-receiver-assertions" + ] + } } } @@ -58,13 +69,13 @@ android { resValue "string", "app_name", APP_NAME + " Test" applicationIdSuffix ".test" versionNameSuffix "-" + gitSha + isDefault true } } lint { lintConfig file("lint.xml") - // Regenerate by deleting app/lint-baseline.xml, then run: - // ./gradlew lintBlueDebug + // Regenerate by deleting the file and running `./gradlew app:lintGreenDebug` baseline = file("lint-baseline.xml") } @@ -103,12 +114,6 @@ android { includeInApk false includeInBundle false } - // Can remove this once https://issuetracker.google.com/issues/260059413 is fixed. - // https://kotlinlang.org/docs/gradle-configure-project.html#gradle-java-toolchains-support - compileOptions { - sourceCompatibility JavaVersion.VERSION_11 - targetCompatibility JavaVersion.VERSION_11 - } applicationVariants.configureEach { variant -> variant.outputs.configureEach { outputFileName = "Tusky_${variant.versionName}_${variant.versionCode}_${gitSha}_" + @@ -119,6 +124,7 @@ android { ksp { arg("room.schemaLocation", "$projectDir/schemas") + arg("room.generateKotlin", "true") arg("room.incremental", "true") } @@ -132,7 +138,6 @@ configurations { // library versions are in PROJECT_ROOT/gradle/libs.versions.toml dependencies { implementation libs.kotlinx.coroutines.android - implementation libs.kotlinx.coroutines.rx3 implementation libs.bundles.androidx implementation libs.bundles.room @@ -140,28 +145,26 @@ dependencies { implementation libs.android.material - implementation libs.gson + implementation libs.bundles.moshi + ksp libs.moshi.kotlin.codegen implementation libs.bundles.retrofit implementation libs.networkresult.calladapter implementation libs.bundles.okhttp + implementation libs.okio implementation libs.conscrypt.android implementation libs.bundles.glide - kapt libs.glide.compiler - - implementation libs.bundles.rxjava3 - - implementation libs.bundles.autodispose + ksp libs.glide.compiler implementation libs.bundles.dagger kapt libs.bundles.dagger.processors implementation libs.sparkbutton - implementation libs.photoview + implementation libs.touchimageview implementation libs.bundles.material.drawer implementation libs.material.typeface @@ -189,3 +192,20 @@ dependencies { androidTestImplementation libs.androidx.room.testing androidTestImplementation libs.androidx.test.junit } + +// Work around warnings of: +// WARNING: Illegal reflective access by org.jetbrains.kotlin.kapt3.util.ModuleManipulationUtilsKt (file:/C:/Users/Andi/.gradle/caches/modules-2/files-2.1/org.jetbrains.kotlin/kotlin-annotation-processing-gradle/1.8.22/28dab7e0ee9ce62c03bf97de3543c911dc653700/kotlin-annotation-processing-gradle-1.8.22.jar) to constructor com.sun.tools.javac.util.Context() +// See https://youtrack.jetbrains.com/issue/KT-30589/Kapt-An-illegal-reflective-access-operation-has-occurred +tasks.withType(org.jetbrains.kotlin.gradle.internal.KaptWithoutKotlincTask).configureEach { + kaptProcessJvmArgs.addAll([ + "--add-opens", "jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED", + "--add-opens", "jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED", + "--add-opens", "jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED", + "--add-opens", "jdk.compiler/com.sun.tools.javac.jvm=ALL-UNNAMED", + "--add-opens", "jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED", + "--add-opens", "jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED", + "--add-opens", "jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED", + "--add-opens", "jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED", + "--add-opens", "jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED", + "--add-opens", "jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED"]) +} diff --git a/app/lint-baseline.xml b/app/lint-baseline.xml index 502aac30a..f77ebf931 100644 --- a/app/lint-baseline.xml +++ b/app/lint-baseline.xml @@ -1,48 +1,26 @@ - + + id="GestureBackNavigation" + message="If intercepting back events, this should be handled through the registration of callbacks on the window level; Please see https://developer.android.com/about/versions/13/features/predictive-back-gesture" + errorLine1=" if (keyCode == KeyEvent.KEYCODE_BACK) {" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/java/com/keylesspalace/tusky/components/compose/ComposeActivity.kt" + line="1314" + column="28"/> + id="DefaultLocale" + message="Implicitly using the default locale is a common source of bugs: Use `toUpperCase(Locale)` instead. For strings meant to be internal use `Locale.ROOT`, otherwise `Locale.getDefault()`." + errorLine1=" sb.append(language.toUpperCase());" + errorLine2=" ~~~~~~~~~~~"> - - - - - - - - + file="src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java" + line="104" + column="32"/> + id="PrivateResource" + message="Overriding `@layout/exo_player_control_view` which is marked as private in androidx.media3:media3-ui:1.3.0. If deliberate, use tools:override="true", otherwise pick a different name."> + file="src/main/res/layout/exo_player_control_view.xml"/> + id="PrivateResource" + message="The resource `@color/exo_bottom_bar_background` is marked as private in androidx.media3:media3-ui:1.3.0" + errorLine1=" android:background="@color/exo_bottom_bar_background"" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> - - - - - - - - - - - - - - - - - - - - - - - - - - - - + id="PrivateResource" + message="The resource `@dimen/exo_styled_controls_padding` is marked as private in androidx.media3:media3-ui:1.3.0" + errorLine1=" android:padding="@dimen/exo_styled_controls_padding"" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/layout/exo_player_control_view.xml" + line="41" + column="26"/> + id="PrivateResource" + message="The resource `@layout/exo_player_control_rewind_button` is marked as private in androidx.media3:media3-ui:1.3.0" + errorLine1=" <include layout="@layout/exo_player_control_rewind_button" />" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/layout/exo_player_control_view.xml" + line="48" + column="26"/> + id="PrivateResource" + message="The resource `@layout/exo_player_control_ffwd_button` is marked as private in androidx.media3:media3-ui:1.3.0" + errorLine1=" <include layout="@layout/exo_player_control_ffwd_button" />" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/layout/exo_player_control_view.xml" + line="54" + column="26"/> + id="PrivateResource" + message="The resource `@dimen/exo_styled_bottom_bar_height` is marked as private in androidx.media3:media3-ui:1.3.0" + errorLine1=" android:layout_height="@dimen/exo_styled_bottom_bar_height"" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + id="PrivateResource" + message="The resource `@dimen/exo_styled_bottom_bar_margin_top` is marked as private in androidx.media3:media3-ui:1.3.0" + errorLine1=" android:layout_marginTop="@dimen/exo_styled_bottom_bar_margin_top"" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/layout/exo_player_control_view.xml" + line="64" + column="35"/> + id="PrivateResource" + message="The resource `@color/exo_bottom_bar_background` is marked as private in androidx.media3:media3-ui:1.3.0" + errorLine1=" android:background="@color/exo_bottom_bar_background"" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/layout/exo_player_control_view.xml" + line="66" + column="29"/> + id="PrivateResource" + message="The resource `@dimen/exo_styled_bottom_bar_time_padding` is marked as private in androidx.media3:media3-ui:1.3.0" + errorLine1=" android:paddingStart="@dimen/exo_styled_bottom_bar_time_padding"" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/layout/exo_player_control_view.xml" + line="72" + column="35"/> + id="PrivateResource" + message="The resource `@dimen/exo_styled_bottom_bar_time_padding` is marked as private in androidx.media3:media3-ui:1.3.0" + errorLine1=" android:paddingEnd="@dimen/exo_styled_bottom_bar_time_padding"" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/layout/exo_player_control_view.xml" + line="73" + column="33"/> + id="PrivateResource" + message="The resource `@dimen/exo_styled_bottom_bar_time_padding` is marked as private in androidx.media3:media3-ui:1.3.0" + errorLine1=" android:paddingLeft="@dimen/exo_styled_bottom_bar_time_padding"" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/layout/exo_player_control_view.xml" + line="74" + column="34"/> + id="PrivateResource" + message="The resource `@dimen/exo_styled_bottom_bar_time_padding` is marked as private in androidx.media3:media3-ui:1.3.0" + errorLine1=" android:paddingRight="@dimen/exo_styled_bottom_bar_time_padding"" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/layout/exo_player_control_view.xml" + line="75" + column="35"/> + id="PrivateResource" + message="The resource `@dimen/exo_styled_progress_layout_height` is marked as private in androidx.media3:media3-ui:1.3.0" + errorLine1=" android:layout_height="@dimen/exo_styled_progress_layout_height"" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/layout/exo_player_control_view.xml" + line="141" + column="32"/> + + + + + + + + @@ -828,7 +230,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -839,7 +241,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -850,7 +252,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -861,542 +263,14 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -1521,7 +395,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~"> @@ -1532,7 +406,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~"> @@ -1543,7 +417,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~"> @@ -1554,7 +428,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~"> @@ -1565,7 +439,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~"> @@ -1620,4723 +494,85 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~"> + id="StringFormatTrivial" + message="This formatting string is trivial. Rather than using `String.format` to create your String, it will be more performant to concatenate your arguments with `+`. " + errorLine1=" (error) -> Log.e(TAG, String.format("Failed to %s account id %s", accept ? "accept" : "reject", id))" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + id="StringFormatTrivial" + message="This formatting string is trivial. Rather than using `String.format` to create your String, it will be more performant to concatenate your arguments with `+`. " + errorLine1=" LinkHelper.openLink(requireContext(), String.format("https://%s/admin/reports/%s", accountManager.getActiveAccount().getDomain(), reportId));" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java" + line="829" + column="61"/> + id="SmallSp" + message="Avoid using sizes smaller than `11sp`: `8sp`" + errorLine1=" android:textSize="8sp"" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/layout-land/item_trending_cell.xml" + line="42" + column="9"/> + id="SmallSp" + message="Avoid using sizes smaller than `11sp`: `8sp`" + errorLine1=" android:textSize="8sp"" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/layout/item_trending_cell.xml" + line="42" + column="9"/> + id="SmallSp" + message="Avoid using sizes smaller than `11sp`: `8sp`" + errorLine1=" android:textSize="8sp"" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/layout-land/item_trending_cell.xml" + line="59" + column="9"/> + id="SmallSp" + message="Avoid using sizes smaller than `11sp`: `8sp`" + errorLine1=" android:textSize="8sp"" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/layout/item_trending_cell.xml" + line="59" + column="9"/> + id="ReportShortcutUsage" + message="Calling this method indicates use of dynamic shortcuts, but there are no calls to methods that track shortcut usage, such as `pushDynamicShortcut` or `reportShortcutUsed`. Calling these methods is recommended, as they track shortcut usage and allow launchers to adjust which shortcuts appear based on activation history. Please see https://developer.android.com/develop/ui/views/launch/shortcuts/managing-shortcuts#track-usage" + errorLine1=" ShortcutManagerCompat.addDynamicShortcuts(context, listOf(shortcutInfo))" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + column="13"/> @@ -6357,142 +593,142 @@ errorLine2=" ^"> + errorLine1=" <ImageButton android:id="@id/exo_prev"" + errorLine2=" ~~~~~~~~~~~"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + errorLine1=" <ImageButton android:id="@id/exo_minimal_fullscreen"" + errorLine2=" ~~~~~~~~~~~"> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -6637,784 +873,311 @@ + id="RtlHardcoded" + message="Consider replacing `android:layout_marginLeft` with `android:layout_marginStart="8dp"` to better support right-to-left layouts" + errorLine1=" android:layout_marginLeft="8dp"" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/res/layout/item_list.xml" + line="37" + column="9"/> + + + + + + + + + errorLine1=" public abstract boolean deepEquals(NotificationViewData other);" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + file="src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.java" + line="43" + column="40"/> + errorLine1=" public Concrete(Notification.Type type, String id, TimelineAccount account," + errorLine2=" ~~~~~~~~~~~~~~~~~"> + file="src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.java" + line="54" + column="25"/> + errorLine1=" public Concrete(Notification.Type type, String id, TimelineAccount account," + errorLine2=" ~~~~~~"> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + errorLine1=" public Concrete(Notification.Type type, String id, TimelineAccount account," + errorLine2=" ~~~~~~~~~~~~~~~"> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + errorLine1=" public Notification.Type getType() {" + errorLine2=" ~~~~~~~~~~~~~~~~~"> + file="src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.java" + line="63" + column="16"/> + errorLine1=" public String getId() {" + errorLine2=" ~~~~~~"> + file="src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.java" + line="67" + column="16"/> + errorLine1=" public TimelineAccount getAccount() {" + errorLine2=" ~~~~~~~~~~~~~~~"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + errorLine1=" public void onRespondToFollowRequest(boolean accept, String id, int position) {" + errorLine2=" ~~~~~~"> + file="src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java" + line="801" + column="58"/> + errorLine1=" public void onViewStatusForNotificationId(String notificationId) {" + errorLine2=" ~~~~~~"> + file="src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java" + line="813" + column="47"/> + errorLine1=" public void onViewReport(String reportId) {" + errorLine2=" ~~~~~~"> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + file="src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java" + line="828" + column="30"/> diff --git a/app/lint.xml b/app/lint.xml index 0f87c7355..88ad21f05 100644 --- a/app/lint.xml +++ b/app/lint.xml @@ -29,17 +29,48 @@ Disable these for the time being. --> + - - + + + - - + + + + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 9ab3dd83f..0bcca6c2a 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -1,83 +1,44 @@ # GENERAL OPTIONS -# turn on all optimizations except those that are known to cause problems on Android --optimizations !code/simplification/cast,!field/*,!class/merging/* --optimizationpasses 6 -allowaccessmodification --dontpreverify --dontusemixedcaseclassnames --dontskipnonpubliclibraryclasses --keepattributes *Annotation* +# Preserve some attributes that may be required for reflection. +-keepattributes RuntimeVisible*Annotations, AnnotationDefault # For native methods, see http://proguard.sourceforge.net/manual/examples.html#native -keepclasseswithmembernames class * { native ; } -# keep setters in Views so that animations can still work. -# see http://proguard.sourceforge.net/manual/examples.html#beans --keepclassmembers public class * extends android.view.View { - void set*(***); - *** get*(); -} -# We want to keep methods in Activity that could be used in the XML attribute onClick --keepclassmembers class * extends android.app.Activity { - public void *(android.view.View); -} + # For enumeration classes, see http://proguard.sourceforge.net/manual/examples.html#enumerations --keepclassmembers enum * { +-keepclassmembers,allowoptimization enum * { public static **[] values(); public static ** valueOf(java.lang.String); } + -keepclassmembers class * implements android.os.Parcelable { public static final ** CREATOR; } --keepclassmembers class **.R$* { - public static ; +# Preserve annotated Javascript interface methods. +-keepclassmembers class * { + @android.webkit.JavascriptInterface ; } +# The support libraries contains references to newer platform versions. +# Don't warn about those in case this app is linking against an older +# platform version. We know about them, and they are safe. +-dontnote androidx.** +-dontwarn androidx.** + +# This class is deprecated, but remains for backward compatibility. +-dontwarn android.util.FloatMath + +# These classes are duplicated between android.jar and core-lambda-stubs.jar. +-dontnote java.lang.invoke.** + # TUSKY SPECIFIC OPTIONS -# keep members of our model classes, they are used in json de/serialization --keepclassmembers class com.keylesspalace.tusky.entity.* { *; } - --keep public enum com.keylesspalace.tusky.entity.*$** { - **[] $VALUES; - public *; -} - --keepclassmembers class com.keylesspalace.tusky.components.conversation.ConversationAccountEntity { *; } --keepclassmembers class com.keylesspalace.tusky.db.DraftAttachment { *; } - --keep enum com.keylesspalace.tusky.db.DraftAttachment$Type { - public *; -} - -# https://github.com/google/gson/blob/master/examples/android-proguard-example/proguard.cfg - -# Prevent proguard from stripping interface information from TypeAdapter, TypeAdapterFactory, -# JsonSerializer, JsonDeserializer instances (so they can be used in @JsonAdapter) --keep class * extends com.google.gson.TypeAdapter --keep class * implements com.google.gson.TypeAdapterFactory --keep class * implements com.google.gson.JsonSerializer --keep class * implements com.google.gson.JsonDeserializer - -# Retain generic signatures of TypeToken and its subclasses with R8 version 3.0 and higher. --keep,allowobfuscation,allowshrinking class com.google.gson.reflect.TypeToken --keep,allowobfuscation,allowshrinking class * extends com.google.gson.reflect.TypeToken - -# Retain generic signatures of classes used in MastodonApi so Retrofit works --keep,allowobfuscation,allowshrinking class io.reactivex.rxjava3.core.Single --keep,allowobfuscation,allowshrinking class retrofit2.Response --keep,allowobfuscation,allowshrinking class kotlin.collections.List --keep,allowobfuscation,allowshrinking class kotlin.collections.Map --keep,allowobfuscation,allowshrinking class retrofit2.Call - -# https://r8.googlesource.com/r8/+/refs/heads/master/compatibility-faq.md#retrofit --keepattributes Signature --keep class kotlin.coroutines.Continuation - # preserve line numbers for crash reporting -keepattributes SourceFile,LineNumberTable -renamesourcefileattribute SourceFile diff --git a/app/schemas/com.keylesspalace.tusky.db.AppDatabase/52.json b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/52.json new file mode 100644 index 000000000..18290c93e --- /dev/null +++ b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/52.json @@ -0,0 +1,1009 @@ +{ + "formatVersion": 1, + "database": { + "version": 52, + "identityHash": "233a8680f540e9a89950da21532ce85d", + "entities": [ + { + "tableName": "DraftEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `accountId` INTEGER NOT NULL, `inReplyToId` TEXT, `content` TEXT, `contentWarning` TEXT, `sensitive` INTEGER NOT NULL, `visibility` INTEGER NOT NULL, `attachments` TEXT NOT NULL, `poll` TEXT, `failedToSend` INTEGER NOT NULL, `failedToSendNew` INTEGER NOT NULL, `scheduledAt` TEXT, `language` TEXT, `statusId` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "contentWarning", + "columnName": "contentWarning", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sensitive", + "columnName": "sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "poll", + "columnName": "poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "failedToSend", + "columnName": "failedToSend", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "failedToSendNew", + "columnName": "failedToSendNew", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "scheduledAt", + "columnName": "scheduledAt", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "statusId", + "columnName": "statusId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "AccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `domain` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `clientId` TEXT, `clientSecret` TEXT, `isActive` INTEGER NOT NULL, `accountId` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `profilePictureUrl` TEXT NOT NULL, `notificationsEnabled` INTEGER NOT NULL, `notificationsMentioned` INTEGER NOT NULL, `notificationsFollowed` INTEGER NOT NULL, `notificationsFollowRequested` INTEGER NOT NULL, `notificationsReblogged` INTEGER NOT NULL, `notificationsFavorited` INTEGER NOT NULL, `notificationsPolls` INTEGER NOT NULL, `notificationsSubscriptions` INTEGER NOT NULL, `notificationsSignUps` INTEGER NOT NULL, `notificationsUpdates` INTEGER NOT NULL, `notificationsReports` INTEGER NOT NULL, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `defaultPostLanguage` TEXT NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `alwaysOpenSpoiler` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `lastNotificationId` TEXT NOT NULL, `notificationMarkerId` TEXT NOT NULL DEFAULT '0', `emojis` TEXT NOT NULL, `tabPreferences` TEXT NOT NULL, `notificationsFilter` TEXT NOT NULL, `oauthScopes` TEXT NOT NULL, `unifiedPushUrl` TEXT NOT NULL, `pushPubKey` TEXT NOT NULL, `pushPrivKey` TEXT NOT NULL, `pushAuth` TEXT NOT NULL, `pushServerKey` TEXT NOT NULL, `lastVisibleHomeTimelineStatusId` TEXT, `locked` INTEGER NOT NULL DEFAULT 0)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clientId", + "columnName": "clientId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "clientSecret", + "columnName": "clientSecret", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isActive", + "columnName": "isActive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profilePictureUrl", + "columnName": "profilePictureUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationsEnabled", + "columnName": "notificationsEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsMentioned", + "columnName": "notificationsMentioned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFollowed", + "columnName": "notificationsFollowed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFollowRequested", + "columnName": "notificationsFollowRequested", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsReblogged", + "columnName": "notificationsReblogged", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFavorited", + "columnName": "notificationsFavorited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsPolls", + "columnName": "notificationsPolls", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsSubscriptions", + "columnName": "notificationsSubscriptions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsSignUps", + "columnName": "notificationsSignUps", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsUpdates", + "columnName": "notificationsUpdates", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsReports", + "columnName": "notificationsReports", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationSound", + "columnName": "notificationSound", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationVibration", + "columnName": "notificationVibration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationLight", + "columnName": "notificationLight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultPostPrivacy", + "columnName": "defaultPostPrivacy", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultMediaSensitivity", + "columnName": "defaultMediaSensitivity", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultPostLanguage", + "columnName": "defaultPostLanguage", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "alwaysShowSensitiveMedia", + "columnName": "alwaysShowSensitiveMedia", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "alwaysOpenSpoiler", + "columnName": "alwaysOpenSpoiler", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mediaPreviewEnabled", + "columnName": "mediaPreviewEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastNotificationId", + "columnName": "lastNotificationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationMarkerId", + "columnName": "notificationMarkerId", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'0'" + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tabPreferences", + "columnName": "tabPreferences", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationsFilter", + "columnName": "notificationsFilter", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "oauthScopes", + "columnName": "oauthScopes", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unifiedPushUrl", + "columnName": "unifiedPushUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushPubKey", + "columnName": "pushPubKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushPrivKey", + "columnName": "pushPrivKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushAuth", + "columnName": "pushAuth", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushServerKey", + "columnName": "pushServerKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastVisibleHomeTimelineStatusId", + "columnName": "lastVisibleHomeTimelineStatusId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "locked", + "columnName": "locked", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_AccountEntity_domain_accountId", + "unique": true, + "columnNames": [ + "domain", + "accountId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_AccountEntity_domain_accountId` ON `${TABLE_NAME}` (`domain`, `accountId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "InstanceEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`instance` TEXT NOT NULL, `emojiList` TEXT, `maximumTootCharacters` INTEGER, `maxPollOptions` INTEGER, `maxPollOptionLength` INTEGER, `minPollDuration` INTEGER, `maxPollDuration` INTEGER, `charactersReservedPerUrl` INTEGER, `version` TEXT, `videoSizeLimit` INTEGER, `imageSizeLimit` INTEGER, `imageMatrixLimit` INTEGER, `maxMediaAttachments` INTEGER, `maxFields` INTEGER, `maxFieldNameLength` INTEGER, `maxFieldValueLength` INTEGER, PRIMARY KEY(`instance`))", + "fields": [ + { + "fieldPath": "instance", + "columnName": "instance", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojiList", + "columnName": "emojiList", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maximumTootCharacters", + "columnName": "maximumTootCharacters", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollOptions", + "columnName": "maxPollOptions", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollOptionLength", + "columnName": "maxPollOptionLength", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "minPollDuration", + "columnName": "minPollDuration", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollDuration", + "columnName": "maxPollDuration", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "charactersReservedPerUrl", + "columnName": "charactersReservedPerUrl", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "videoSizeLimit", + "columnName": "videoSizeLimit", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "imageSizeLimit", + "columnName": "imageSizeLimit", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "imageMatrixLimit", + "columnName": "imageMatrixLimit", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxMediaAttachments", + "columnName": "maxMediaAttachments", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxFields", + "columnName": "maxFields", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxFieldNameLength", + "columnName": "maxFieldNameLength", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxFieldValueLength", + "columnName": "maxFieldValueLength", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "instance" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "TimelineStatusEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `url` TEXT, `timelineUserId` INTEGER NOT NULL, `authorServerId` TEXT, `inReplyToId` TEXT, `inReplyToAccountId` TEXT, `content` TEXT, `createdAt` INTEGER NOT NULL, `editedAt` INTEGER, `emojis` TEXT, `reblogsCount` INTEGER NOT NULL, `favouritesCount` INTEGER NOT NULL, `repliesCount` INTEGER NOT NULL, `reblogged` INTEGER NOT NULL, `bookmarked` INTEGER NOT NULL, `favourited` INTEGER NOT NULL, `sensitive` INTEGER NOT NULL, `spoilerText` TEXT NOT NULL, `visibility` INTEGER NOT NULL, `attachments` TEXT, `mentions` TEXT, `tags` TEXT, `application` TEXT, `reblogServerId` TEXT, `reblogAccountId` TEXT, `poll` TEXT, `muted` INTEGER, `expanded` INTEGER NOT NULL, `contentCollapsed` INTEGER NOT NULL, `contentShowing` INTEGER NOT NULL, `pinned` INTEGER NOT NULL, `card` TEXT, `language` TEXT, `filtered` TEXT, PRIMARY KEY(`serverId`, `timelineUserId`), FOREIGN KEY(`authorServerId`, `timelineUserId`) REFERENCES `TimelineAccountEntity`(`serverId`, `timelineUserId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authorServerId", + "columnName": "authorServerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToAccountId", + "columnName": "inReplyToAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "editedAt", + "columnName": "editedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogsCount", + "columnName": "reblogsCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favouritesCount", + "columnName": "favouritesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "repliesCount", + "columnName": "repliesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reblogged", + "columnName": "reblogged", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookmarked", + "columnName": "bookmarked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favourited", + "columnName": "favourited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sensitive", + "columnName": "sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "spoilerText", + "columnName": "spoilerText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "mentions", + "columnName": "mentions", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "application", + "columnName": "application", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogServerId", + "columnName": "reblogServerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogAccountId", + "columnName": "reblogAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "poll", + "columnName": "poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "muted", + "columnName": "muted", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "expanded", + "columnName": "expanded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentCollapsed", + "columnName": "contentCollapsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentShowing", + "columnName": "contentShowing", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pinned", + "columnName": "pinned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "card", + "columnName": "card", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "filtered", + "columnName": "filtered", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "timelineUserId" + ] + }, + "indices": [ + { + "name": "index_TimelineStatusEntity_authorServerId_timelineUserId", + "unique": false, + "columnNames": [ + "authorServerId", + "timelineUserId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_TimelineStatusEntity_authorServerId_timelineUserId` ON `${TABLE_NAME}` (`authorServerId`, `timelineUserId`)" + } + ], + "foreignKeys": [ + { + "table": "TimelineAccountEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "authorServerId", + "timelineUserId" + ], + "referencedColumns": [ + "serverId", + "timelineUserId" + ] + } + ] + }, + { + "tableName": "TimelineAccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `timelineUserId` INTEGER NOT NULL, `localUsername` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `url` TEXT NOT NULL, `avatar` TEXT NOT NULL, `emojis` TEXT NOT NULL, `bot` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `timelineUserId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localUsername", + "columnName": "localUsername", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "avatar", + "columnName": "avatar", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bot", + "columnName": "bot", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "timelineUserId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "ConversationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `id` TEXT NOT NULL, `order` INTEGER NOT NULL, `accounts` TEXT NOT NULL, `unread` INTEGER NOT NULL, `s_id` TEXT NOT NULL, `s_url` TEXT, `s_inReplyToId` TEXT, `s_inReplyToAccountId` TEXT, `s_account` TEXT NOT NULL, `s_content` TEXT NOT NULL, `s_createdAt` INTEGER NOT NULL, `s_editedAt` INTEGER, `s_emojis` TEXT NOT NULL, `s_favouritesCount` INTEGER NOT NULL, `s_repliesCount` INTEGER NOT NULL, `s_favourited` INTEGER NOT NULL, `s_bookmarked` INTEGER NOT NULL, `s_sensitive` INTEGER NOT NULL, `s_spoilerText` TEXT NOT NULL, `s_attachments` TEXT NOT NULL, `s_mentions` TEXT NOT NULL, `s_tags` TEXT, `s_showingHiddenContent` INTEGER NOT NULL, `s_expanded` INTEGER NOT NULL, `s_collapsed` INTEGER NOT NULL, `s_muted` INTEGER NOT NULL, `s_poll` TEXT, `s_language` TEXT, PRIMARY KEY(`id`, `accountId`))", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accounts", + "columnName": "accounts", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unread", + "columnName": "unread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.id", + "columnName": "s_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.url", + "columnName": "s_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.inReplyToId", + "columnName": "s_inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.inReplyToAccountId", + "columnName": "s_inReplyToAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.account", + "columnName": "s_account", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.content", + "columnName": "s_content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.createdAt", + "columnName": "s_createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.editedAt", + "columnName": "s_editedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastStatus.emojis", + "columnName": "s_emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.favouritesCount", + "columnName": "s_favouritesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.repliesCount", + "columnName": "s_repliesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.favourited", + "columnName": "s_favourited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.bookmarked", + "columnName": "s_bookmarked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.sensitive", + "columnName": "s_sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.spoilerText", + "columnName": "s_spoilerText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.attachments", + "columnName": "s_attachments", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.mentions", + "columnName": "s_mentions", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.tags", + "columnName": "s_tags", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.showingHiddenContent", + "columnName": "s_showingHiddenContent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.expanded", + "columnName": "s_expanded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.collapsed", + "columnName": "s_collapsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.muted", + "columnName": "s_muted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.poll", + "columnName": "s_poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.language", + "columnName": "s_language", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id", + "accountId" + ] + }, + "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, '233a8680f540e9a89950da21532ce85d')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.keylesspalace.tusky.db.AppDatabase/53.json b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/53.json new file mode 100644 index 000000000..824768cb5 --- /dev/null +++ b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/53.json @@ -0,0 +1,1009 @@ +{ + "formatVersion": 1, + "database": { + "version": 53, + "identityHash": "233a8680f540e9a89950da21532ce85d", + "entities": [ + { + "tableName": "DraftEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `accountId` INTEGER NOT NULL, `inReplyToId` TEXT, `content` TEXT, `contentWarning` TEXT, `sensitive` INTEGER NOT NULL, `visibility` INTEGER NOT NULL, `attachments` TEXT NOT NULL, `poll` TEXT, `failedToSend` INTEGER NOT NULL, `failedToSendNew` INTEGER NOT NULL, `scheduledAt` TEXT, `language` TEXT, `statusId` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "contentWarning", + "columnName": "contentWarning", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sensitive", + "columnName": "sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "poll", + "columnName": "poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "failedToSend", + "columnName": "failedToSend", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "failedToSendNew", + "columnName": "failedToSendNew", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "scheduledAt", + "columnName": "scheduledAt", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "statusId", + "columnName": "statusId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "AccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `domain` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `clientId` TEXT, `clientSecret` TEXT, `isActive` INTEGER NOT NULL, `accountId` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `profilePictureUrl` TEXT NOT NULL, `notificationsEnabled` INTEGER NOT NULL, `notificationsMentioned` INTEGER NOT NULL, `notificationsFollowed` INTEGER NOT NULL, `notificationsFollowRequested` INTEGER NOT NULL, `notificationsReblogged` INTEGER NOT NULL, `notificationsFavorited` INTEGER NOT NULL, `notificationsPolls` INTEGER NOT NULL, `notificationsSubscriptions` INTEGER NOT NULL, `notificationsSignUps` INTEGER NOT NULL, `notificationsUpdates` INTEGER NOT NULL, `notificationsReports` INTEGER NOT NULL, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `defaultPostLanguage` TEXT NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `alwaysOpenSpoiler` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `lastNotificationId` TEXT NOT NULL, `notificationMarkerId` TEXT NOT NULL DEFAULT '0', `emojis` TEXT NOT NULL, `tabPreferences` TEXT NOT NULL, `notificationsFilter` TEXT NOT NULL, `oauthScopes` TEXT NOT NULL, `unifiedPushUrl` TEXT NOT NULL, `pushPubKey` TEXT NOT NULL, `pushPrivKey` TEXT NOT NULL, `pushAuth` TEXT NOT NULL, `pushServerKey` TEXT NOT NULL, `lastVisibleHomeTimelineStatusId` TEXT, `locked` INTEGER NOT NULL DEFAULT 0)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clientId", + "columnName": "clientId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "clientSecret", + "columnName": "clientSecret", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isActive", + "columnName": "isActive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profilePictureUrl", + "columnName": "profilePictureUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationsEnabled", + "columnName": "notificationsEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsMentioned", + "columnName": "notificationsMentioned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFollowed", + "columnName": "notificationsFollowed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFollowRequested", + "columnName": "notificationsFollowRequested", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsReblogged", + "columnName": "notificationsReblogged", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFavorited", + "columnName": "notificationsFavorited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsPolls", + "columnName": "notificationsPolls", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsSubscriptions", + "columnName": "notificationsSubscriptions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsSignUps", + "columnName": "notificationsSignUps", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsUpdates", + "columnName": "notificationsUpdates", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsReports", + "columnName": "notificationsReports", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationSound", + "columnName": "notificationSound", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationVibration", + "columnName": "notificationVibration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationLight", + "columnName": "notificationLight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultPostPrivacy", + "columnName": "defaultPostPrivacy", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultMediaSensitivity", + "columnName": "defaultMediaSensitivity", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultPostLanguage", + "columnName": "defaultPostLanguage", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "alwaysShowSensitiveMedia", + "columnName": "alwaysShowSensitiveMedia", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "alwaysOpenSpoiler", + "columnName": "alwaysOpenSpoiler", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mediaPreviewEnabled", + "columnName": "mediaPreviewEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastNotificationId", + "columnName": "lastNotificationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationMarkerId", + "columnName": "notificationMarkerId", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'0'" + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tabPreferences", + "columnName": "tabPreferences", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationsFilter", + "columnName": "notificationsFilter", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "oauthScopes", + "columnName": "oauthScopes", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unifiedPushUrl", + "columnName": "unifiedPushUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushPubKey", + "columnName": "pushPubKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushPrivKey", + "columnName": "pushPrivKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushAuth", + "columnName": "pushAuth", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushServerKey", + "columnName": "pushServerKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastVisibleHomeTimelineStatusId", + "columnName": "lastVisibleHomeTimelineStatusId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "locked", + "columnName": "locked", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_AccountEntity_domain_accountId", + "unique": true, + "columnNames": [ + "domain", + "accountId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_AccountEntity_domain_accountId` ON `${TABLE_NAME}` (`domain`, `accountId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "InstanceEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`instance` TEXT NOT NULL, `emojiList` TEXT, `maximumTootCharacters` INTEGER, `maxPollOptions` INTEGER, `maxPollOptionLength` INTEGER, `minPollDuration` INTEGER, `maxPollDuration` INTEGER, `charactersReservedPerUrl` INTEGER, `version` TEXT, `videoSizeLimit` INTEGER, `imageSizeLimit` INTEGER, `imageMatrixLimit` INTEGER, `maxMediaAttachments` INTEGER, `maxFields` INTEGER, `maxFieldNameLength` INTEGER, `maxFieldValueLength` INTEGER, PRIMARY KEY(`instance`))", + "fields": [ + { + "fieldPath": "instance", + "columnName": "instance", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojiList", + "columnName": "emojiList", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maximumTootCharacters", + "columnName": "maximumTootCharacters", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollOptions", + "columnName": "maxPollOptions", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollOptionLength", + "columnName": "maxPollOptionLength", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "minPollDuration", + "columnName": "minPollDuration", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollDuration", + "columnName": "maxPollDuration", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "charactersReservedPerUrl", + "columnName": "charactersReservedPerUrl", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "videoSizeLimit", + "columnName": "videoSizeLimit", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "imageSizeLimit", + "columnName": "imageSizeLimit", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "imageMatrixLimit", + "columnName": "imageMatrixLimit", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxMediaAttachments", + "columnName": "maxMediaAttachments", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxFields", + "columnName": "maxFields", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxFieldNameLength", + "columnName": "maxFieldNameLength", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxFieldValueLength", + "columnName": "maxFieldValueLength", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "instance" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "TimelineStatusEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `url` TEXT, `timelineUserId` INTEGER NOT NULL, `authorServerId` TEXT, `inReplyToId` TEXT, `inReplyToAccountId` TEXT, `content` TEXT, `createdAt` INTEGER NOT NULL, `editedAt` INTEGER, `emojis` TEXT, `reblogsCount` INTEGER NOT NULL, `favouritesCount` INTEGER NOT NULL, `repliesCount` INTEGER NOT NULL, `reblogged` INTEGER NOT NULL, `bookmarked` INTEGER NOT NULL, `favourited` INTEGER NOT NULL, `sensitive` INTEGER NOT NULL, `spoilerText` TEXT NOT NULL, `visibility` INTEGER NOT NULL, `attachments` TEXT, `mentions` TEXT, `tags` TEXT, `application` TEXT, `reblogServerId` TEXT, `reblogAccountId` TEXT, `poll` TEXT, `muted` INTEGER, `expanded` INTEGER NOT NULL, `contentCollapsed` INTEGER NOT NULL, `contentShowing` INTEGER NOT NULL, `pinned` INTEGER NOT NULL, `card` TEXT, `language` TEXT, `filtered` TEXT, PRIMARY KEY(`serverId`, `timelineUserId`), FOREIGN KEY(`authorServerId`, `timelineUserId`) REFERENCES `TimelineAccountEntity`(`serverId`, `timelineUserId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authorServerId", + "columnName": "authorServerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToAccountId", + "columnName": "inReplyToAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "editedAt", + "columnName": "editedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogsCount", + "columnName": "reblogsCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favouritesCount", + "columnName": "favouritesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "repliesCount", + "columnName": "repliesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reblogged", + "columnName": "reblogged", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookmarked", + "columnName": "bookmarked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favourited", + "columnName": "favourited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sensitive", + "columnName": "sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "spoilerText", + "columnName": "spoilerText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "mentions", + "columnName": "mentions", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "application", + "columnName": "application", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogServerId", + "columnName": "reblogServerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogAccountId", + "columnName": "reblogAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "poll", + "columnName": "poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "muted", + "columnName": "muted", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "expanded", + "columnName": "expanded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentCollapsed", + "columnName": "contentCollapsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentShowing", + "columnName": "contentShowing", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pinned", + "columnName": "pinned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "card", + "columnName": "card", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "filtered", + "columnName": "filtered", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "timelineUserId" + ] + }, + "indices": [ + { + "name": "index_TimelineStatusEntity_authorServerId_timelineUserId", + "unique": false, + "columnNames": [ + "authorServerId", + "timelineUserId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_TimelineStatusEntity_authorServerId_timelineUserId` ON `${TABLE_NAME}` (`authorServerId`, `timelineUserId`)" + } + ], + "foreignKeys": [ + { + "table": "TimelineAccountEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "authorServerId", + "timelineUserId" + ], + "referencedColumns": [ + "serverId", + "timelineUserId" + ] + } + ] + }, + { + "tableName": "TimelineAccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `timelineUserId` INTEGER NOT NULL, `localUsername` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `url` TEXT NOT NULL, `avatar` TEXT NOT NULL, `emojis` TEXT NOT NULL, `bot` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `timelineUserId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localUsername", + "columnName": "localUsername", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "avatar", + "columnName": "avatar", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bot", + "columnName": "bot", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "timelineUserId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "ConversationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `id` TEXT NOT NULL, `order` INTEGER NOT NULL, `accounts` TEXT NOT NULL, `unread` INTEGER NOT NULL, `s_id` TEXT NOT NULL, `s_url` TEXT, `s_inReplyToId` TEXT, `s_inReplyToAccountId` TEXT, `s_account` TEXT NOT NULL, `s_content` TEXT NOT NULL, `s_createdAt` INTEGER NOT NULL, `s_editedAt` INTEGER, `s_emojis` TEXT NOT NULL, `s_favouritesCount` INTEGER NOT NULL, `s_repliesCount` INTEGER NOT NULL, `s_favourited` INTEGER NOT NULL, `s_bookmarked` INTEGER NOT NULL, `s_sensitive` INTEGER NOT NULL, `s_spoilerText` TEXT NOT NULL, `s_attachments` TEXT NOT NULL, `s_mentions` TEXT NOT NULL, `s_tags` TEXT, `s_showingHiddenContent` INTEGER NOT NULL, `s_expanded` INTEGER NOT NULL, `s_collapsed` INTEGER NOT NULL, `s_muted` INTEGER NOT NULL, `s_poll` TEXT, `s_language` TEXT, PRIMARY KEY(`id`, `accountId`))", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accounts", + "columnName": "accounts", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unread", + "columnName": "unread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.id", + "columnName": "s_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.url", + "columnName": "s_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.inReplyToId", + "columnName": "s_inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.inReplyToAccountId", + "columnName": "s_inReplyToAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.account", + "columnName": "s_account", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.content", + "columnName": "s_content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.createdAt", + "columnName": "s_createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.editedAt", + "columnName": "s_editedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastStatus.emojis", + "columnName": "s_emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.favouritesCount", + "columnName": "s_favouritesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.repliesCount", + "columnName": "s_repliesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.favourited", + "columnName": "s_favourited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.bookmarked", + "columnName": "s_bookmarked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.sensitive", + "columnName": "s_sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.spoilerText", + "columnName": "s_spoilerText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.attachments", + "columnName": "s_attachments", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.mentions", + "columnName": "s_mentions", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.tags", + "columnName": "s_tags", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.showingHiddenContent", + "columnName": "s_showingHiddenContent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.expanded", + "columnName": "s_expanded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.collapsed", + "columnName": "s_collapsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.muted", + "columnName": "s_muted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.poll", + "columnName": "s_poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.language", + "columnName": "s_language", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id", + "accountId" + ] + }, + "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, '233a8680f540e9a89950da21532ce85d')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.keylesspalace.tusky.db.AppDatabase/54.json b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/54.json new file mode 100644 index 000000000..a02b302bb --- /dev/null +++ b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/54.json @@ -0,0 +1,1016 @@ +{ + "formatVersion": 1, + "database": { + "version": 54, + "identityHash": "c86c3e5ef2c1c5903657a0138b4b2520", + "entities": [ + { + "tableName": "DraftEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `accountId` INTEGER NOT NULL, `inReplyToId` TEXT, `content` TEXT, `contentWarning` TEXT, `sensitive` INTEGER NOT NULL, `visibility` INTEGER NOT NULL, `attachments` TEXT NOT NULL, `poll` TEXT, `failedToSend` INTEGER NOT NULL, `failedToSendNew` INTEGER NOT NULL, `scheduledAt` TEXT, `language` TEXT, `statusId` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "contentWarning", + "columnName": "contentWarning", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sensitive", + "columnName": "sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "poll", + "columnName": "poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "failedToSend", + "columnName": "failedToSend", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "failedToSendNew", + "columnName": "failedToSendNew", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "scheduledAt", + "columnName": "scheduledAt", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "statusId", + "columnName": "statusId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "AccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `domain` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `clientId` TEXT, `clientSecret` TEXT, `isActive` INTEGER NOT NULL, `accountId` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `profilePictureUrl` TEXT NOT NULL, `notificationsEnabled` INTEGER NOT NULL, `notificationsMentioned` INTEGER NOT NULL, `notificationsFollowed` INTEGER NOT NULL, `notificationsFollowRequested` INTEGER NOT NULL, `notificationsReblogged` INTEGER NOT NULL, `notificationsFavorited` INTEGER NOT NULL, `notificationsPolls` INTEGER NOT NULL, `notificationsSubscriptions` INTEGER NOT NULL, `notificationsSignUps` INTEGER NOT NULL, `notificationsUpdates` INTEGER NOT NULL, `notificationsReports` INTEGER NOT NULL, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `defaultPostLanguage` TEXT NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `alwaysOpenSpoiler` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `lastNotificationId` TEXT NOT NULL, `notificationMarkerId` TEXT NOT NULL DEFAULT '0', `emojis` TEXT NOT NULL, `tabPreferences` TEXT NOT NULL, `notificationsFilter` TEXT NOT NULL, `oauthScopes` TEXT NOT NULL, `unifiedPushUrl` TEXT NOT NULL, `pushPubKey` TEXT NOT NULL, `pushPrivKey` TEXT NOT NULL, `pushAuth` TEXT NOT NULL, `pushServerKey` TEXT NOT NULL, `lastVisibleHomeTimelineStatusId` TEXT, `locked` INTEGER NOT NULL DEFAULT 0, `hasDirectMessageBadge` INTEGER NOT NULL DEFAULT 0)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clientId", + "columnName": "clientId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "clientSecret", + "columnName": "clientSecret", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isActive", + "columnName": "isActive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profilePictureUrl", + "columnName": "profilePictureUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationsEnabled", + "columnName": "notificationsEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsMentioned", + "columnName": "notificationsMentioned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFollowed", + "columnName": "notificationsFollowed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFollowRequested", + "columnName": "notificationsFollowRequested", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsReblogged", + "columnName": "notificationsReblogged", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFavorited", + "columnName": "notificationsFavorited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsPolls", + "columnName": "notificationsPolls", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsSubscriptions", + "columnName": "notificationsSubscriptions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsSignUps", + "columnName": "notificationsSignUps", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsUpdates", + "columnName": "notificationsUpdates", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsReports", + "columnName": "notificationsReports", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationSound", + "columnName": "notificationSound", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationVibration", + "columnName": "notificationVibration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationLight", + "columnName": "notificationLight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultPostPrivacy", + "columnName": "defaultPostPrivacy", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultMediaSensitivity", + "columnName": "defaultMediaSensitivity", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultPostLanguage", + "columnName": "defaultPostLanguage", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "alwaysShowSensitiveMedia", + "columnName": "alwaysShowSensitiveMedia", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "alwaysOpenSpoiler", + "columnName": "alwaysOpenSpoiler", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mediaPreviewEnabled", + "columnName": "mediaPreviewEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastNotificationId", + "columnName": "lastNotificationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationMarkerId", + "columnName": "notificationMarkerId", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'0'" + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tabPreferences", + "columnName": "tabPreferences", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationsFilter", + "columnName": "notificationsFilter", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "oauthScopes", + "columnName": "oauthScopes", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unifiedPushUrl", + "columnName": "unifiedPushUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushPubKey", + "columnName": "pushPubKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushPrivKey", + "columnName": "pushPrivKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushAuth", + "columnName": "pushAuth", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushServerKey", + "columnName": "pushServerKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastVisibleHomeTimelineStatusId", + "columnName": "lastVisibleHomeTimelineStatusId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "locked", + "columnName": "locked", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "hasDirectMessageBadge", + "columnName": "hasDirectMessageBadge", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_AccountEntity_domain_accountId", + "unique": true, + "columnNames": [ + "domain", + "accountId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_AccountEntity_domain_accountId` ON `${TABLE_NAME}` (`domain`, `accountId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "InstanceEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`instance` TEXT NOT NULL, `emojiList` TEXT, `maximumTootCharacters` INTEGER, `maxPollOptions` INTEGER, `maxPollOptionLength` INTEGER, `minPollDuration` INTEGER, `maxPollDuration` INTEGER, `charactersReservedPerUrl` INTEGER, `version` TEXT, `videoSizeLimit` INTEGER, `imageSizeLimit` INTEGER, `imageMatrixLimit` INTEGER, `maxMediaAttachments` INTEGER, `maxFields` INTEGER, `maxFieldNameLength` INTEGER, `maxFieldValueLength` INTEGER, PRIMARY KEY(`instance`))", + "fields": [ + { + "fieldPath": "instance", + "columnName": "instance", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojiList", + "columnName": "emojiList", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maximumTootCharacters", + "columnName": "maximumTootCharacters", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollOptions", + "columnName": "maxPollOptions", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollOptionLength", + "columnName": "maxPollOptionLength", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "minPollDuration", + "columnName": "minPollDuration", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollDuration", + "columnName": "maxPollDuration", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "charactersReservedPerUrl", + "columnName": "charactersReservedPerUrl", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "videoSizeLimit", + "columnName": "videoSizeLimit", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "imageSizeLimit", + "columnName": "imageSizeLimit", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "imageMatrixLimit", + "columnName": "imageMatrixLimit", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxMediaAttachments", + "columnName": "maxMediaAttachments", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxFields", + "columnName": "maxFields", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxFieldNameLength", + "columnName": "maxFieldNameLength", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxFieldValueLength", + "columnName": "maxFieldValueLength", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "instance" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "TimelineStatusEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `url` TEXT, `timelineUserId` INTEGER NOT NULL, `authorServerId` TEXT, `inReplyToId` TEXT, `inReplyToAccountId` TEXT, `content` TEXT, `createdAt` INTEGER NOT NULL, `editedAt` INTEGER, `emojis` TEXT, `reblogsCount` INTEGER NOT NULL, `favouritesCount` INTEGER NOT NULL, `repliesCount` INTEGER NOT NULL, `reblogged` INTEGER NOT NULL, `bookmarked` INTEGER NOT NULL, `favourited` INTEGER NOT NULL, `sensitive` INTEGER NOT NULL, `spoilerText` TEXT NOT NULL, `visibility` INTEGER NOT NULL, `attachments` TEXT, `mentions` TEXT, `tags` TEXT, `application` TEXT, `reblogServerId` TEXT, `reblogAccountId` TEXT, `poll` TEXT, `muted` INTEGER, `expanded` INTEGER NOT NULL, `contentCollapsed` INTEGER NOT NULL, `contentShowing` INTEGER NOT NULL, `pinned` INTEGER NOT NULL, `card` TEXT, `language` TEXT, `filtered` TEXT, PRIMARY KEY(`serverId`, `timelineUserId`), FOREIGN KEY(`authorServerId`, `timelineUserId`) REFERENCES `TimelineAccountEntity`(`serverId`, `timelineUserId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authorServerId", + "columnName": "authorServerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToAccountId", + "columnName": "inReplyToAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "editedAt", + "columnName": "editedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogsCount", + "columnName": "reblogsCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favouritesCount", + "columnName": "favouritesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "repliesCount", + "columnName": "repliesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reblogged", + "columnName": "reblogged", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookmarked", + "columnName": "bookmarked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favourited", + "columnName": "favourited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sensitive", + "columnName": "sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "spoilerText", + "columnName": "spoilerText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "mentions", + "columnName": "mentions", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "application", + "columnName": "application", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogServerId", + "columnName": "reblogServerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogAccountId", + "columnName": "reblogAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "poll", + "columnName": "poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "muted", + "columnName": "muted", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "expanded", + "columnName": "expanded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentCollapsed", + "columnName": "contentCollapsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentShowing", + "columnName": "contentShowing", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pinned", + "columnName": "pinned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "card", + "columnName": "card", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "filtered", + "columnName": "filtered", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "timelineUserId" + ] + }, + "indices": [ + { + "name": "index_TimelineStatusEntity_authorServerId_timelineUserId", + "unique": false, + "columnNames": [ + "authorServerId", + "timelineUserId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_TimelineStatusEntity_authorServerId_timelineUserId` ON `${TABLE_NAME}` (`authorServerId`, `timelineUserId`)" + } + ], + "foreignKeys": [ + { + "table": "TimelineAccountEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "authorServerId", + "timelineUserId" + ], + "referencedColumns": [ + "serverId", + "timelineUserId" + ] + } + ] + }, + { + "tableName": "TimelineAccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `timelineUserId` INTEGER NOT NULL, `localUsername` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `url` TEXT NOT NULL, `avatar` TEXT NOT NULL, `emojis` TEXT NOT NULL, `bot` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `timelineUserId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localUsername", + "columnName": "localUsername", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "avatar", + "columnName": "avatar", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bot", + "columnName": "bot", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "timelineUserId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "ConversationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `id` TEXT NOT NULL, `order` INTEGER NOT NULL, `accounts` TEXT NOT NULL, `unread` INTEGER NOT NULL, `s_id` TEXT NOT NULL, `s_url` TEXT, `s_inReplyToId` TEXT, `s_inReplyToAccountId` TEXT, `s_account` TEXT NOT NULL, `s_content` TEXT NOT NULL, `s_createdAt` INTEGER NOT NULL, `s_editedAt` INTEGER, `s_emojis` TEXT NOT NULL, `s_favouritesCount` INTEGER NOT NULL, `s_repliesCount` INTEGER NOT NULL, `s_favourited` INTEGER NOT NULL, `s_bookmarked` INTEGER NOT NULL, `s_sensitive` INTEGER NOT NULL, `s_spoilerText` TEXT NOT NULL, `s_attachments` TEXT NOT NULL, `s_mentions` TEXT NOT NULL, `s_tags` TEXT, `s_showingHiddenContent` INTEGER NOT NULL, `s_expanded` INTEGER NOT NULL, `s_collapsed` INTEGER NOT NULL, `s_muted` INTEGER NOT NULL, `s_poll` TEXT, `s_language` TEXT, PRIMARY KEY(`id`, `accountId`))", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accounts", + "columnName": "accounts", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unread", + "columnName": "unread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.id", + "columnName": "s_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.url", + "columnName": "s_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.inReplyToId", + "columnName": "s_inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.inReplyToAccountId", + "columnName": "s_inReplyToAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.account", + "columnName": "s_account", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.content", + "columnName": "s_content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.createdAt", + "columnName": "s_createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.editedAt", + "columnName": "s_editedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastStatus.emojis", + "columnName": "s_emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.favouritesCount", + "columnName": "s_favouritesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.repliesCount", + "columnName": "s_repliesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.favourited", + "columnName": "s_favourited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.bookmarked", + "columnName": "s_bookmarked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.sensitive", + "columnName": "s_sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.spoilerText", + "columnName": "s_spoilerText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.attachments", + "columnName": "s_attachments", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.mentions", + "columnName": "s_mentions", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.tags", + "columnName": "s_tags", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.showingHiddenContent", + "columnName": "s_showingHiddenContent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.expanded", + "columnName": "s_expanded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.collapsed", + "columnName": "s_collapsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.muted", + "columnName": "s_muted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.poll", + "columnName": "s_poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.language", + "columnName": "s_language", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id", + "accountId" + ] + }, + "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, 'c86c3e5ef2c1c5903657a0138b4b2520')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.keylesspalace.tusky.db.AppDatabase/56.json b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/56.json new file mode 100644 index 000000000..a9f974eb8 --- /dev/null +++ b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/56.json @@ -0,0 +1,1034 @@ +{ + "formatVersion": 1, + "database": { + "version": 56, + "identityHash": "1d1eba6d905d2a6e16ae2daa81c0ab4a", + "entities": [ + { + "tableName": "DraftEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `accountId` INTEGER NOT NULL, `inReplyToId` TEXT, `content` TEXT, `contentWarning` TEXT, `sensitive` INTEGER NOT NULL, `visibility` INTEGER NOT NULL, `attachments` TEXT NOT NULL, `poll` TEXT, `failedToSend` INTEGER NOT NULL, `failedToSendNew` INTEGER NOT NULL, `scheduledAt` TEXT, `language` TEXT, `statusId` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "contentWarning", + "columnName": "contentWarning", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sensitive", + "columnName": "sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "poll", + "columnName": "poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "failedToSend", + "columnName": "failedToSend", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "failedToSendNew", + "columnName": "failedToSendNew", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "scheduledAt", + "columnName": "scheduledAt", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "statusId", + "columnName": "statusId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "AccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `domain` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `clientId` TEXT, `clientSecret` TEXT, `isActive` INTEGER NOT NULL, `accountId` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `profilePictureUrl` TEXT NOT NULL, `notificationsEnabled` INTEGER NOT NULL, `notificationsMentioned` INTEGER NOT NULL, `notificationsFollowed` INTEGER NOT NULL, `notificationsFollowRequested` INTEGER NOT NULL, `notificationsReblogged` INTEGER NOT NULL, `notificationsFavorited` INTEGER NOT NULL, `notificationsPolls` INTEGER NOT NULL, `notificationsSubscriptions` INTEGER NOT NULL, `notificationsSignUps` INTEGER NOT NULL, `notificationsUpdates` INTEGER NOT NULL, `notificationsReports` INTEGER NOT NULL, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `defaultPostLanguage` TEXT NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `alwaysOpenSpoiler` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `lastNotificationId` TEXT NOT NULL, `notificationMarkerId` TEXT NOT NULL DEFAULT '0', `emojis` TEXT NOT NULL, `tabPreferences` TEXT NOT NULL, `notificationsFilter` TEXT NOT NULL, `oauthScopes` TEXT NOT NULL, `unifiedPushUrl` TEXT NOT NULL, `pushPubKey` TEXT NOT NULL, `pushPrivKey` TEXT NOT NULL, `pushAuth` TEXT NOT NULL, `pushServerKey` TEXT NOT NULL, `lastVisibleHomeTimelineStatusId` TEXT, `locked` INTEGER NOT NULL DEFAULT 0, `hasDirectMessageBadge` INTEGER NOT NULL DEFAULT 0, `isShowHomeBoosts` INTEGER NOT NULL, `isShowHomeReplies` INTEGER NOT NULL, `isShowHomeSelfBoosts` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clientId", + "columnName": "clientId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "clientSecret", + "columnName": "clientSecret", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isActive", + "columnName": "isActive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profilePictureUrl", + "columnName": "profilePictureUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationsEnabled", + "columnName": "notificationsEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsMentioned", + "columnName": "notificationsMentioned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFollowed", + "columnName": "notificationsFollowed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFollowRequested", + "columnName": "notificationsFollowRequested", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsReblogged", + "columnName": "notificationsReblogged", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFavorited", + "columnName": "notificationsFavorited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsPolls", + "columnName": "notificationsPolls", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsSubscriptions", + "columnName": "notificationsSubscriptions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsSignUps", + "columnName": "notificationsSignUps", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsUpdates", + "columnName": "notificationsUpdates", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsReports", + "columnName": "notificationsReports", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationSound", + "columnName": "notificationSound", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationVibration", + "columnName": "notificationVibration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationLight", + "columnName": "notificationLight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultPostPrivacy", + "columnName": "defaultPostPrivacy", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultMediaSensitivity", + "columnName": "defaultMediaSensitivity", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultPostLanguage", + "columnName": "defaultPostLanguage", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "alwaysShowSensitiveMedia", + "columnName": "alwaysShowSensitiveMedia", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "alwaysOpenSpoiler", + "columnName": "alwaysOpenSpoiler", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mediaPreviewEnabled", + "columnName": "mediaPreviewEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastNotificationId", + "columnName": "lastNotificationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationMarkerId", + "columnName": "notificationMarkerId", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'0'" + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tabPreferences", + "columnName": "tabPreferences", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationsFilter", + "columnName": "notificationsFilter", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "oauthScopes", + "columnName": "oauthScopes", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unifiedPushUrl", + "columnName": "unifiedPushUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushPubKey", + "columnName": "pushPubKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushPrivKey", + "columnName": "pushPrivKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushAuth", + "columnName": "pushAuth", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushServerKey", + "columnName": "pushServerKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastVisibleHomeTimelineStatusId", + "columnName": "lastVisibleHomeTimelineStatusId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "locked", + "columnName": "locked", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "hasDirectMessageBadge", + "columnName": "hasDirectMessageBadge", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isShowHomeBoosts", + "columnName": "isShowHomeBoosts", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isShowHomeReplies", + "columnName": "isShowHomeReplies", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isShowHomeSelfBoosts", + "columnName": "isShowHomeSelfBoosts", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_AccountEntity_domain_accountId", + "unique": true, + "columnNames": [ + "domain", + "accountId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_AccountEntity_domain_accountId` ON `${TABLE_NAME}` (`domain`, `accountId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "InstanceEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`instance` TEXT NOT NULL, `emojiList` TEXT, `maximumTootCharacters` INTEGER, `maxPollOptions` INTEGER, `maxPollOptionLength` INTEGER, `minPollDuration` INTEGER, `maxPollDuration` INTEGER, `charactersReservedPerUrl` INTEGER, `version` TEXT, `videoSizeLimit` INTEGER, `imageSizeLimit` INTEGER, `imageMatrixLimit` INTEGER, `maxMediaAttachments` INTEGER, `maxFields` INTEGER, `maxFieldNameLength` INTEGER, `maxFieldValueLength` INTEGER, PRIMARY KEY(`instance`))", + "fields": [ + { + "fieldPath": "instance", + "columnName": "instance", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojiList", + "columnName": "emojiList", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maximumTootCharacters", + "columnName": "maximumTootCharacters", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollOptions", + "columnName": "maxPollOptions", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollOptionLength", + "columnName": "maxPollOptionLength", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "minPollDuration", + "columnName": "minPollDuration", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollDuration", + "columnName": "maxPollDuration", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "charactersReservedPerUrl", + "columnName": "charactersReservedPerUrl", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "videoSizeLimit", + "columnName": "videoSizeLimit", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "imageSizeLimit", + "columnName": "imageSizeLimit", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "imageMatrixLimit", + "columnName": "imageMatrixLimit", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxMediaAttachments", + "columnName": "maxMediaAttachments", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxFields", + "columnName": "maxFields", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxFieldNameLength", + "columnName": "maxFieldNameLength", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxFieldValueLength", + "columnName": "maxFieldValueLength", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "instance" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "TimelineStatusEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `url` TEXT, `timelineUserId` INTEGER NOT NULL, `authorServerId` TEXT, `inReplyToId` TEXT, `inReplyToAccountId` TEXT, `content` TEXT, `createdAt` INTEGER NOT NULL, `editedAt` INTEGER, `emojis` TEXT, `reblogsCount` INTEGER NOT NULL, `favouritesCount` INTEGER NOT NULL, `repliesCount` INTEGER NOT NULL, `reblogged` INTEGER NOT NULL, `bookmarked` INTEGER NOT NULL, `favourited` INTEGER NOT NULL, `sensitive` INTEGER NOT NULL, `spoilerText` TEXT NOT NULL, `visibility` INTEGER NOT NULL, `attachments` TEXT, `mentions` TEXT, `tags` TEXT, `application` TEXT, `reblogServerId` TEXT, `reblogAccountId` TEXT, `poll` TEXT, `muted` INTEGER, `expanded` INTEGER NOT NULL, `contentCollapsed` INTEGER NOT NULL, `contentShowing` INTEGER NOT NULL, `pinned` INTEGER NOT NULL, `card` TEXT, `language` TEXT, `filtered` TEXT, PRIMARY KEY(`serverId`, `timelineUserId`), FOREIGN KEY(`authorServerId`, `timelineUserId`) REFERENCES `TimelineAccountEntity`(`serverId`, `timelineUserId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authorServerId", + "columnName": "authorServerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToAccountId", + "columnName": "inReplyToAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "editedAt", + "columnName": "editedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogsCount", + "columnName": "reblogsCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favouritesCount", + "columnName": "favouritesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "repliesCount", + "columnName": "repliesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reblogged", + "columnName": "reblogged", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookmarked", + "columnName": "bookmarked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favourited", + "columnName": "favourited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sensitive", + "columnName": "sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "spoilerText", + "columnName": "spoilerText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "mentions", + "columnName": "mentions", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "application", + "columnName": "application", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogServerId", + "columnName": "reblogServerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogAccountId", + "columnName": "reblogAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "poll", + "columnName": "poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "muted", + "columnName": "muted", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "expanded", + "columnName": "expanded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentCollapsed", + "columnName": "contentCollapsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentShowing", + "columnName": "contentShowing", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pinned", + "columnName": "pinned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "card", + "columnName": "card", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "filtered", + "columnName": "filtered", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "timelineUserId" + ] + }, + "indices": [ + { + "name": "index_TimelineStatusEntity_authorServerId_timelineUserId", + "unique": false, + "columnNames": [ + "authorServerId", + "timelineUserId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_TimelineStatusEntity_authorServerId_timelineUserId` ON `${TABLE_NAME}` (`authorServerId`, `timelineUserId`)" + } + ], + "foreignKeys": [ + { + "table": "TimelineAccountEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "authorServerId", + "timelineUserId" + ], + "referencedColumns": [ + "serverId", + "timelineUserId" + ] + } + ] + }, + { + "tableName": "TimelineAccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `timelineUserId` INTEGER NOT NULL, `localUsername` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `url` TEXT NOT NULL, `avatar` TEXT NOT NULL, `emojis` TEXT NOT NULL, `bot` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `timelineUserId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localUsername", + "columnName": "localUsername", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "avatar", + "columnName": "avatar", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bot", + "columnName": "bot", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "timelineUserId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "ConversationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `id` TEXT NOT NULL, `order` INTEGER NOT NULL, `accounts` TEXT NOT NULL, `unread` INTEGER NOT NULL, `s_id` TEXT NOT NULL, `s_url` TEXT, `s_inReplyToId` TEXT, `s_inReplyToAccountId` TEXT, `s_account` TEXT NOT NULL, `s_content` TEXT NOT NULL, `s_createdAt` INTEGER NOT NULL, `s_editedAt` INTEGER, `s_emojis` TEXT NOT NULL, `s_favouritesCount` INTEGER NOT NULL, `s_repliesCount` INTEGER NOT NULL, `s_favourited` INTEGER NOT NULL, `s_bookmarked` INTEGER NOT NULL, `s_sensitive` INTEGER NOT NULL, `s_spoilerText` TEXT NOT NULL, `s_attachments` TEXT NOT NULL, `s_mentions` TEXT NOT NULL, `s_tags` TEXT, `s_showingHiddenContent` INTEGER NOT NULL, `s_expanded` INTEGER NOT NULL, `s_collapsed` INTEGER NOT NULL, `s_muted` INTEGER NOT NULL, `s_poll` TEXT, `s_language` TEXT, PRIMARY KEY(`id`, `accountId`))", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accounts", + "columnName": "accounts", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unread", + "columnName": "unread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.id", + "columnName": "s_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.url", + "columnName": "s_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.inReplyToId", + "columnName": "s_inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.inReplyToAccountId", + "columnName": "s_inReplyToAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.account", + "columnName": "s_account", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.content", + "columnName": "s_content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.createdAt", + "columnName": "s_createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.editedAt", + "columnName": "s_editedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastStatus.emojis", + "columnName": "s_emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.favouritesCount", + "columnName": "s_favouritesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.repliesCount", + "columnName": "s_repliesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.favourited", + "columnName": "s_favourited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.bookmarked", + "columnName": "s_bookmarked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.sensitive", + "columnName": "s_sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.spoilerText", + "columnName": "s_spoilerText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.attachments", + "columnName": "s_attachments", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.mentions", + "columnName": "s_mentions", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.tags", + "columnName": "s_tags", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.showingHiddenContent", + "columnName": "s_showingHiddenContent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.expanded", + "columnName": "s_expanded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.collapsed", + "columnName": "s_collapsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.muted", + "columnName": "s_muted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.poll", + "columnName": "s_poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.language", + "columnName": "s_language", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id", + "accountId" + ] + }, + "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, '1d1eba6d905d2a6e16ae2daa81c0ab4a')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.keylesspalace.tusky.db.AppDatabase/58.json b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/58.json new file mode 100644 index 000000000..65aa67d6c --- /dev/null +++ b/app/schemas/com.keylesspalace.tusky.db.AppDatabase/58.json @@ -0,0 +1,1040 @@ +{ + "formatVersion": 1, + "database": { + "version": 58, + "identityHash": "1d0e1cdf0b4c3f787333b9abf3b2b26a", + "entities": [ + { + "tableName": "DraftEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `accountId` INTEGER NOT NULL, `inReplyToId` TEXT, `content` TEXT, `contentWarning` TEXT, `sensitive` INTEGER NOT NULL, `visibility` INTEGER NOT NULL, `attachments` TEXT NOT NULL, `poll` TEXT, `failedToSend` INTEGER NOT NULL, `failedToSendNew` INTEGER NOT NULL, `scheduledAt` TEXT, `language` TEXT, `statusId` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "contentWarning", + "columnName": "contentWarning", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sensitive", + "columnName": "sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "poll", + "columnName": "poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "failedToSend", + "columnName": "failedToSend", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "failedToSendNew", + "columnName": "failedToSendNew", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "scheduledAt", + "columnName": "scheduledAt", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "statusId", + "columnName": "statusId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "AccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `domain` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `clientId` TEXT, `clientSecret` TEXT, `isActive` INTEGER NOT NULL, `accountId` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `profilePictureUrl` TEXT NOT NULL, `notificationsEnabled` INTEGER NOT NULL, `notificationsMentioned` INTEGER NOT NULL, `notificationsFollowed` INTEGER NOT NULL, `notificationsFollowRequested` INTEGER NOT NULL, `notificationsReblogged` INTEGER NOT NULL, `notificationsFavorited` INTEGER NOT NULL, `notificationsPolls` INTEGER NOT NULL, `notificationsSubscriptions` INTEGER NOT NULL, `notificationsSignUps` INTEGER NOT NULL, `notificationsUpdates` INTEGER NOT NULL, `notificationsReports` INTEGER NOT NULL, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `defaultPostLanguage` TEXT NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `alwaysOpenSpoiler` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `lastNotificationId` TEXT NOT NULL, `notificationMarkerId` TEXT NOT NULL DEFAULT '0', `emojis` TEXT NOT NULL, `tabPreferences` TEXT NOT NULL, `notificationsFilter` TEXT NOT NULL, `oauthScopes` TEXT NOT NULL, `unifiedPushUrl` TEXT NOT NULL, `pushPubKey` TEXT NOT NULL, `pushPrivKey` TEXT NOT NULL, `pushAuth` TEXT NOT NULL, `pushServerKey` TEXT NOT NULL, `lastVisibleHomeTimelineStatusId` TEXT, `locked` INTEGER NOT NULL DEFAULT 0, `hasDirectMessageBadge` INTEGER NOT NULL DEFAULT 0, `isShowHomeBoosts` INTEGER NOT NULL, `isShowHomeReplies` INTEGER NOT NULL, `isShowHomeSelfBoosts` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "clientId", + "columnName": "clientId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "clientSecret", + "columnName": "clientSecret", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isActive", + "columnName": "isActive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profilePictureUrl", + "columnName": "profilePictureUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationsEnabled", + "columnName": "notificationsEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsMentioned", + "columnName": "notificationsMentioned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFollowed", + "columnName": "notificationsFollowed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFollowRequested", + "columnName": "notificationsFollowRequested", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsReblogged", + "columnName": "notificationsReblogged", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsFavorited", + "columnName": "notificationsFavorited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsPolls", + "columnName": "notificationsPolls", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsSubscriptions", + "columnName": "notificationsSubscriptions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsSignUps", + "columnName": "notificationsSignUps", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsUpdates", + "columnName": "notificationsUpdates", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsReports", + "columnName": "notificationsReports", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationSound", + "columnName": "notificationSound", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationVibration", + "columnName": "notificationVibration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationLight", + "columnName": "notificationLight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultPostPrivacy", + "columnName": "defaultPostPrivacy", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultMediaSensitivity", + "columnName": "defaultMediaSensitivity", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultPostLanguage", + "columnName": "defaultPostLanguage", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "alwaysShowSensitiveMedia", + "columnName": "alwaysShowSensitiveMedia", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "alwaysOpenSpoiler", + "columnName": "alwaysOpenSpoiler", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mediaPreviewEnabled", + "columnName": "mediaPreviewEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastNotificationId", + "columnName": "lastNotificationId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationMarkerId", + "columnName": "notificationMarkerId", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'0'" + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tabPreferences", + "columnName": "tabPreferences", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationsFilter", + "columnName": "notificationsFilter", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "oauthScopes", + "columnName": "oauthScopes", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unifiedPushUrl", + "columnName": "unifiedPushUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushPubKey", + "columnName": "pushPubKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushPrivKey", + "columnName": "pushPrivKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushAuth", + "columnName": "pushAuth", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pushServerKey", + "columnName": "pushServerKey", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastVisibleHomeTimelineStatusId", + "columnName": "lastVisibleHomeTimelineStatusId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "locked", + "columnName": "locked", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "hasDirectMessageBadge", + "columnName": "hasDirectMessageBadge", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isShowHomeBoosts", + "columnName": "isShowHomeBoosts", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isShowHomeReplies", + "columnName": "isShowHomeReplies", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isShowHomeSelfBoosts", + "columnName": "isShowHomeSelfBoosts", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_AccountEntity_domain_accountId", + "unique": true, + "columnNames": [ + "domain", + "accountId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_AccountEntity_domain_accountId` ON `${TABLE_NAME}` (`domain`, `accountId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "InstanceEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`instance` TEXT NOT NULL, `emojiList` TEXT, `maximumTootCharacters` INTEGER, `maxPollOptions` INTEGER, `maxPollOptionLength` INTEGER, `minPollDuration` INTEGER, `maxPollDuration` INTEGER, `charactersReservedPerUrl` INTEGER, `version` TEXT, `videoSizeLimit` INTEGER, `imageSizeLimit` INTEGER, `imageMatrixLimit` INTEGER, `maxMediaAttachments` INTEGER, `maxFields` INTEGER, `maxFieldNameLength` INTEGER, `maxFieldValueLength` INTEGER, `translationEnabled` INTEGER, PRIMARY KEY(`instance`))", + "fields": [ + { + "fieldPath": "instance", + "columnName": "instance", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojiList", + "columnName": "emojiList", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maximumTootCharacters", + "columnName": "maximumTootCharacters", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollOptions", + "columnName": "maxPollOptions", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollOptionLength", + "columnName": "maxPollOptionLength", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "minPollDuration", + "columnName": "minPollDuration", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxPollDuration", + "columnName": "maxPollDuration", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "charactersReservedPerUrl", + "columnName": "charactersReservedPerUrl", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "videoSizeLimit", + "columnName": "videoSizeLimit", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "imageSizeLimit", + "columnName": "imageSizeLimit", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "imageMatrixLimit", + "columnName": "imageMatrixLimit", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxMediaAttachments", + "columnName": "maxMediaAttachments", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxFields", + "columnName": "maxFields", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxFieldNameLength", + "columnName": "maxFieldNameLength", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "maxFieldValueLength", + "columnName": "maxFieldValueLength", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "translationEnabled", + "columnName": "translationEnabled", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "instance" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "TimelineStatusEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `url` TEXT, `timelineUserId` INTEGER NOT NULL, `authorServerId` TEXT, `inReplyToId` TEXT, `inReplyToAccountId` TEXT, `content` TEXT, `createdAt` INTEGER NOT NULL, `editedAt` INTEGER, `emojis` TEXT, `reblogsCount` INTEGER NOT NULL, `favouritesCount` INTEGER NOT NULL, `repliesCount` INTEGER NOT NULL, `reblogged` INTEGER NOT NULL, `bookmarked` INTEGER NOT NULL, `favourited` INTEGER NOT NULL, `sensitive` INTEGER NOT NULL, `spoilerText` TEXT NOT NULL, `visibility` INTEGER NOT NULL, `attachments` TEXT, `mentions` TEXT, `tags` TEXT, `application` TEXT, `reblogServerId` TEXT, `reblogAccountId` TEXT, `poll` TEXT, `muted` INTEGER, `expanded` INTEGER NOT NULL, `contentCollapsed` INTEGER NOT NULL, `contentShowing` INTEGER NOT NULL, `pinned` INTEGER NOT NULL, `card` TEXT, `language` TEXT, `filtered` TEXT, PRIMARY KEY(`serverId`, `timelineUserId`), FOREIGN KEY(`authorServerId`, `timelineUserId`) REFERENCES `TimelineAccountEntity`(`serverId`, `timelineUserId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "authorServerId", + "columnName": "authorServerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToId", + "columnName": "inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "inReplyToAccountId", + "columnName": "inReplyToAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "editedAt", + "columnName": "editedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogsCount", + "columnName": "reblogsCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favouritesCount", + "columnName": "favouritesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "repliesCount", + "columnName": "repliesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reblogged", + "columnName": "reblogged", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookmarked", + "columnName": "bookmarked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "favourited", + "columnName": "favourited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sensitive", + "columnName": "sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "spoilerText", + "columnName": "spoilerText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "visibility", + "columnName": "visibility", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "mentions", + "columnName": "mentions", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "application", + "columnName": "application", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogServerId", + "columnName": "reblogServerId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "reblogAccountId", + "columnName": "reblogAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "poll", + "columnName": "poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "muted", + "columnName": "muted", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "expanded", + "columnName": "expanded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentCollapsed", + "columnName": "contentCollapsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentShowing", + "columnName": "contentShowing", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pinned", + "columnName": "pinned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "card", + "columnName": "card", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "filtered", + "columnName": "filtered", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "timelineUserId" + ] + }, + "indices": [ + { + "name": "index_TimelineStatusEntity_authorServerId_timelineUserId", + "unique": false, + "columnNames": [ + "authorServerId", + "timelineUserId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_TimelineStatusEntity_authorServerId_timelineUserId` ON `${TABLE_NAME}` (`authorServerId`, `timelineUserId`)" + } + ], + "foreignKeys": [ + { + "table": "TimelineAccountEntity", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "authorServerId", + "timelineUserId" + ], + "referencedColumns": [ + "serverId", + "timelineUserId" + ] + } + ] + }, + { + "tableName": "TimelineAccountEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `timelineUserId` INTEGER NOT NULL, `localUsername` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `url` TEXT NOT NULL, `avatar` TEXT NOT NULL, `emojis` TEXT NOT NULL, `bot` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `timelineUserId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localUsername", + "columnName": "localUsername", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "avatar", + "columnName": "avatar", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emojis", + "columnName": "emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bot", + "columnName": "bot", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "timelineUserId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "ConversationEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `id` TEXT NOT NULL, `order` INTEGER NOT NULL, `accounts` TEXT NOT NULL, `unread` INTEGER NOT NULL, `s_id` TEXT NOT NULL, `s_url` TEXT, `s_inReplyToId` TEXT, `s_inReplyToAccountId` TEXT, `s_account` TEXT NOT NULL, `s_content` TEXT NOT NULL, `s_createdAt` INTEGER NOT NULL, `s_editedAt` INTEGER, `s_emojis` TEXT NOT NULL, `s_favouritesCount` INTEGER NOT NULL, `s_repliesCount` INTEGER NOT NULL, `s_favourited` INTEGER NOT NULL, `s_bookmarked` INTEGER NOT NULL, `s_sensitive` INTEGER NOT NULL, `s_spoilerText` TEXT NOT NULL, `s_attachments` TEXT NOT NULL, `s_mentions` TEXT NOT NULL, `s_tags` TEXT, `s_showingHiddenContent` INTEGER NOT NULL, `s_expanded` INTEGER NOT NULL, `s_collapsed` INTEGER NOT NULL, `s_muted` INTEGER NOT NULL, `s_poll` TEXT, `s_language` TEXT, PRIMARY KEY(`id`, `accountId`))", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accounts", + "columnName": "accounts", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unread", + "columnName": "unread", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.id", + "columnName": "s_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.url", + "columnName": "s_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.inReplyToId", + "columnName": "s_inReplyToId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.inReplyToAccountId", + "columnName": "s_inReplyToAccountId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.account", + "columnName": "s_account", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.content", + "columnName": "s_content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.createdAt", + "columnName": "s_createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.editedAt", + "columnName": "s_editedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastStatus.emojis", + "columnName": "s_emojis", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.favouritesCount", + "columnName": "s_favouritesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.repliesCount", + "columnName": "s_repliesCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.favourited", + "columnName": "s_favourited", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.bookmarked", + "columnName": "s_bookmarked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.sensitive", + "columnName": "s_sensitive", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.spoilerText", + "columnName": "s_spoilerText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.attachments", + "columnName": "s_attachments", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.mentions", + "columnName": "s_mentions", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastStatus.tags", + "columnName": "s_tags", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.showingHiddenContent", + "columnName": "s_showingHiddenContent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.expanded", + "columnName": "s_expanded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.collapsed", + "columnName": "s_collapsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.muted", + "columnName": "s_muted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastStatus.poll", + "columnName": "s_poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastStatus.language", + "columnName": "s_language", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id", + "accountId" + ] + }, + "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, '1d0e1cdf0b4c3f787333b9abf3b2b26a')" + ] + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/keylesspalace/tusky/MigrationsTest.kt b/app/src/androidTest/java/com/keylesspalace/tusky/MigrationsTest.kt index 69641cc41..ccfc4ca62 100644 --- a/app/src/androidTest/java/com/keylesspalace/tusky/MigrationsTest.kt +++ b/app/src/androidTest/java/com/keylesspalace/tusky/MigrationsTest.kt @@ -19,7 +19,7 @@ class MigrationsTest { @Rule var helper: MigrationTestHelper = MigrationTestHelper( InstrumentationRegistry.getInstrumentation(), - AppDatabase::class.java.canonicalName, + AppDatabase::class.java.canonicalName!!, FrameworkSQLiteOpenHelperFactory() ) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 7ff58a008..3b814473d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -18,7 +18,8 @@ android:supportsRtl="true" android:theme="@style/TuskyTheme" android:usesCleartextTraffic="false" - android:localeConfig="@xml/locales_config"> + android:localeConfig="@xml/locales_config" + android:enableOnBackInvokedCallback="true"> @@ -148,7 +149,7 @@ - + @@ -188,14 +189,14 @@ android:icon="@drawable/ic_chinwag_logo_simple" android:label="@string/tusky_compose_post_quicksetting_label" android:permission="android.permission.BIND_QUICK_SETTINGS_TILE" - android:exported="true" - tools:targetApi="24"> + android:exported="true"> + val instanceInfo = instanceInfoRepository.getUpdatedInstanceInfoOrFallback() + binding.accountInfo.text = getString( + R.string.about_account_info, + account.username, + account.domain, + instanceInfo.version + ) + binding.accountInfoTitle.show() + binding.accountInfo.show() + } + } + if (BuildConfig.CUSTOM_INSTANCE.isBlank()) { binding.aboutPoweredByTusky.hide() } - binding.aboutLicenseInfoTextView.setClickableTextWithoutUnderlines(R.string.about_tusky_license) - binding.aboutWebsiteInfoTextView.setClickableTextWithoutUnderlines(R.string.about_project_site) - binding.aboutBugsFeaturesInfoTextView.setClickableTextWithoutUnderlines(R.string.about_bug_feature_request_site) + binding.aboutLicenseInfoTextView.setClickableTextWithoutUnderlines( + R.string.about_tusky_license + ) + binding.aboutWebsiteInfoTextView.setClickableTextWithoutUnderlines( + R.string.about_project_site + ) + binding.aboutBugsFeaturesInfoTextView.setClickableTextWithoutUnderlines( + R.string.about_bug_feature_request_site + ) binding.tuskyProfileButton.setOnClickListener { viewUrl(BuildConfig.SUPPORT_ACCOUNT_URL) @@ -47,6 +88,16 @@ class AboutActivity : BottomSheetActivity(), Injectable { binding.aboutLicensesButton.setOnClickListener { startActivityWithSlideInAnimation(Intent(this, LicenseActivity::class.java)) } + + binding.copyDeviceInfo.setOnClickListener { + val text = "${binding.versionTextView.text}\n\nDevice:\n\n${binding.deviceInfo.text}\n\nAccount:\n\n${binding.accountInfo.text}" + val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clip = ClipData.newPlainText("Tusky version information", text) + clipboard.setPrimaryClip(clip) + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) { + Toast.makeText(this, getString(R.string.about_copied), Toast.LENGTH_SHORT).show() + } + } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/AccountsInListFragment.kt b/app/src/main/java/com/keylesspalace/tusky/AccountsInListFragment.kt index a20526e95..0e88e0c7e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/AccountsInListFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/AccountsInListFragment.kt @@ -45,8 +45,8 @@ import com.keylesspalace.tusky.util.unsafeLazy import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.viewmodel.AccountsInListViewModel import com.keylesspalace.tusky.viewmodel.State -import kotlinx.coroutines.launch import javax.inject.Inject +import kotlinx.coroutines.launch private typealias AccountInfo = Pair @@ -82,11 +82,18 @@ class AccountsInListFragment : DialogFragment(), Injectable { super.onStart() dialog?.apply { // Stretch dialog to the window - window?.setLayout(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.MATCH_PARENT) + window?.setLayout( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.MATCH_PARENT + ) } } - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { return inflater.inflate(R.layout.fragment_accounts_in_list, container, false) } @@ -164,15 +171,27 @@ class AccountsInListFragment : DialogFragment(), Injectable { return oldItem.id == newItem.id } - override fun areContentsTheSame(oldItem: TimelineAccount, newItem: TimelineAccount): Boolean { + override fun areContentsTheSame( + oldItem: TimelineAccount, + newItem: TimelineAccount + ): Boolean { return oldItem == newItem } } - inner class Adapter : ListAdapter>(AccountDiffer) { + inner class Adapter : ListAdapter>( + AccountDiffer + ) { - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder { - val binding = ItemFollowRequestBinding.inflate(LayoutInflater.from(parent.context), parent, false) + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int + ): BindingHolder { + val binding = ItemFollowRequestBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) val holder = BindingHolder(binding) binding.notificationTextView.hide() @@ -186,7 +205,10 @@ class AccountsInListFragment : DialogFragment(), Injectable { return holder } - override fun onBindViewHolder(holder: BindingHolder, position: Int) { + override fun onBindViewHolder( + holder: BindingHolder, + position: Int + ) { val account = getItem(position) holder.binding.displayNameTextView.text = account.name.emojify(account.emojis, holder.binding.displayNameTextView, animateEmojis) holder.binding.usernameTextView.text = account.username @@ -204,10 +226,19 @@ class AccountsInListFragment : DialogFragment(), Injectable { } } - inner class SearchAdapter : ListAdapter>(SearchDiffer) { + inner class SearchAdapter : ListAdapter>( + SearchDiffer + ) { - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder { - val binding = ItemFollowRequestBinding.inflate(LayoutInflater.from(parent.context), parent, false) + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int + ): BindingHolder { + val binding = ItemFollowRequestBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) val holder = BindingHolder(binding) binding.notificationTextView.hide() @@ -224,7 +255,10 @@ class AccountsInListFragment : DialogFragment(), Injectable { return holder } - override fun onBindViewHolder(holder: BindingHolder, position: Int) { + override fun onBindViewHolder( + holder: BindingHolder, + position: Int + ) { val (account, inAList) = getItem(position) holder.binding.displayNameTextView.text = account.name.emojify(account.emojis, holder.binding.displayNameTextView, animateEmojis) diff --git a/app/src/main/java/com/keylesspalace/tusky/BaseActivity.java b/app/src/main/java/com/keylesspalace/tusky/BaseActivity.java index 62709d7c3..a5bc0b04a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/BaseActivity.java +++ b/app/src/main/java/com/keylesspalace/tusky/BaseActivity.java @@ -47,7 +47,9 @@ import com.keylesspalace.tusky.db.AccountManager; import com.keylesspalace.tusky.di.Injectable; import com.keylesspalace.tusky.interfaces.AccountSelectionListener; import com.keylesspalace.tusky.interfaces.PermissionRequester; +import com.keylesspalace.tusky.settings.AppTheme; import com.keylesspalace.tusky.settings.PrefKeys; +import com.keylesspalace.tusky.util.ActivityExtensions; import com.keylesspalace.tusky.util.ThemeUtils; import java.util.ArrayList; @@ -56,10 +58,17 @@ import java.util.List; import javax.inject.Inject; +import static com.keylesspalace.tusky.settings.PrefKeys.APP_THEME; +import static com.keylesspalace.tusky.util.ActivityExtensions.supportsOverridingActivityTransitions; + public abstract class BaseActivity extends AppCompatActivity implements Injectable { + + public static final String OPEN_WITH_SLIDE_IN = "OPEN_WITH_SLIDE_IN"; + private static final String TAG = "BaseActivity"; @Inject + @NonNull public AccountManager accountManager; private static final int REQUESTER_NONE = Integer.MAX_VALUE; @@ -69,14 +78,19 @@ public abstract class BaseActivity extends AppCompatActivity implements Injectab protected void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); + if (supportsOverridingActivityTransitions() && activityTransitionWasRequested()) { + overrideActivityTransition(OVERRIDE_TRANSITION_OPEN, R.anim.activity_open_enter, R.anim.activity_open_exit); + overrideActivityTransition(OVERRIDE_TRANSITION_CLOSE, R.anim.activity_close_enter, R.anim.activity_close_exit); + } + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(this); /* There isn't presently a way to globally change the theme of a whole application at * runtime, just individual activities. So, each activity has to set its theme before any * views are created. */ - String theme = preferences.getString("appTheme", ThemeUtils.APP_THEME_DEFAULT); + String theme = preferences.getString(APP_THEME, AppTheme.DEFAULT.getValue()); Log.d("activeTheme", theme); - if (theme.equals("black")) { + if (ThemeUtils.isBlack(getResources().getConfiguration(), theme)) { setTheme(R.style.TuskyBlackTheme); } @@ -87,7 +101,7 @@ public abstract class BaseActivity extends AppCompatActivity implements Injectab setTaskDescription(new ActivityManager.TaskDescription(appName, appIcon, recentsBackgroundColor)); - int style = textStyle(preferences.getString("statusTextSize", "medium")); + int style = textStyle(preferences.getString(PrefKeys.STATUS_TEXT_SIZE, "medium")); getTheme().applyStyle(style, true); if(requiresLogin()) { @@ -97,6 +111,10 @@ public abstract class BaseActivity extends AppCompatActivity implements Injectab requesters = new HashMap<>(); } + private boolean activityTransitionWasRequested() { + return getIntent().getBooleanExtra(OPEN_WITH_SLIDE_IN, false); + } + @Override protected void attachBaseContext(Context newBase) { SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(newBase); @@ -162,13 +180,8 @@ public abstract class BaseActivity extends AppCompatActivity implements Injectab return style; } - public void startActivityWithSlideInAnimation(Intent intent) { - super.startActivity(intent); - overridePendingTransition(R.anim.slide_from_right, R.anim.slide_to_left); - } - @Override - public boolean onOptionsItemSelected(MenuItem item) { + public boolean onOptionsItemSelected(@NonNull MenuItem item) { if (item.getItemId() == android.R.id.home) { getOnBackPressedDispatcher().onBackPressed(); return true; @@ -179,11 +192,10 @@ public abstract class BaseActivity extends AppCompatActivity implements Injectab @Override public void finish() { super.finish(); - overridePendingTransition(R.anim.slide_from_left, R.anim.slide_to_right); - } - - public void finishWithoutSlideOutAnimation() { - super.finish(); + // if this activity was opened with slide-in, close it with slide out + if (!supportsOverridingActivityTransitions() && activityTransitionWasRequested()) { + overridePendingTransition(R.anim.activity_close_enter, R.anim.activity_close_exit); + } } protected void redirectIfNotLoggedIn() { @@ -191,12 +203,12 @@ public abstract class BaseActivity extends AppCompatActivity implements Injectab if (account == null) { Intent intent = new Intent(this, LoginActivity.class); intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_NEW_TASK); - startActivityWithSlideInAnimation(intent); + ActivityExtensions.startActivityWithSlideInAnimation(this, intent); finish(); } } - protected void showErrorDialog(View anyView, @StringRes int descriptionId, @StringRes int actionId, View.OnClickListener listener) { + protected void showErrorDialog(@Nullable View anyView, @StringRes int descriptionId, @StringRes int actionId, @Nullable View.OnClickListener listener) { if (anyView != null) { Snackbar bar = Snackbar.make(anyView, getString(descriptionId), Snackbar.LENGTH_SHORT); bar.setAction(actionId, listener); @@ -204,7 +216,7 @@ public abstract class BaseActivity extends AppCompatActivity implements Injectab } } - public void showAccountChooserDialog(CharSequence dialogTitle, boolean showActiveAccount, AccountSelectionListener listener) { + public void showAccountChooserDialog(@Nullable CharSequence dialogTitle, boolean showActiveAccount, @NonNull AccountSelectionListener listener) { List accounts = accountManager.getAllAccountsOrderedByActive(); AccountEntity activeAccount = accountManager.getActiveAccount(); @@ -231,9 +243,9 @@ public abstract class BaseActivity extends AppCompatActivity implements Injectab adapter.addAll(accounts); new AlertDialog.Builder(this) - .setTitle(dialogTitle) - .setAdapter(adapter, (dialogInterface, index) -> listener.onAccountSelected(accounts.get(index))) - .show(); + .setTitle(dialogTitle) + .setAdapter(adapter, (dialogInterface, index) -> listener.onAccountSelected(accounts.get(index))) + .show(); } public @Nullable String getOpenAsText() { @@ -256,11 +268,10 @@ public abstract class BaseActivity extends AppCompatActivity implements Injectab public void openAsAccount(@NonNull String url, @NonNull AccountEntity account) { accountManager.setActiveAccount(account.getId()); - Intent intent = new Intent(this, MainActivity.class); - intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); - intent.putExtra(MainActivity.REDIRECT_URL, url); + Intent intent = MainActivity.redirectIntent(this, account.getId(), url); + startActivity(intent); - finishWithoutSlideOutAnimation(); + finish(); } @Override @@ -272,7 +283,7 @@ public abstract class BaseActivity extends AppCompatActivity implements Injectab } } - public void requestPermissions(String[] permissions, PermissionRequester requester) { + public void requestPermissions(@NonNull String[] permissions, @NonNull PermissionRequester requester) { ArrayList permissionsToRequest = new ArrayList<>(); for(String permission: permissions) { if (ContextCompat.checkSelfPermission(this, permission) != PackageManager.PERMISSION_GRANTED) { diff --git a/app/src/main/java/com/keylesspalace/tusky/BottomSheetActivity.kt b/app/src/main/java/com/keylesspalace/tusky/BottomSheetActivity.kt index d62b5c1d1..73c87cf2e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/BottomSheetActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/BottomSheetActivity.kt @@ -22,17 +22,17 @@ import android.view.View import android.widget.LinearLayout import android.widget.Toast import androidx.annotation.VisibleForTesting -import androidx.lifecycle.Lifecycle -import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider -import autodispose2.autoDispose +import androidx.lifecycle.lifecycleScope +import at.connyduck.calladapter.networkresult.fold import com.google.android.material.bottomsheet.BottomSheetBehavior import com.keylesspalace.tusky.components.account.AccountActivity import com.keylesspalace.tusky.components.viewthread.ViewThreadActivity import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.util.looksLikeMastodonUrl import com.keylesspalace.tusky.util.openLink -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation import javax.inject.Inject +import kotlinx.coroutines.launch /** this is the base class for all activities that open links * links are checked against the api if they are mastodon links so they can be opened in Tusky @@ -64,45 +64,48 @@ abstract class BottomSheetActivity : BaseActivity() { }) } - open fun viewUrl(url: String, lookupFallbackBehavior: PostLookupFallbackBehavior = PostLookupFallbackBehavior.OPEN_IN_BROWSER) { + open fun viewUrl( + url: String, + lookupFallbackBehavior: PostLookupFallbackBehavior = PostLookupFallbackBehavior.OPEN_IN_BROWSER + ) { if (!looksLikeMastodonUrl(url)) { openLink(url) return } - mastodonApi.searchObservable( - query = url, - resolve = true - ).observeOn(AndroidSchedulers.mainThread()) - .autoDispose(AndroidLifecycleScopeProvider.from(this, Lifecycle.Event.ON_DESTROY)) - .subscribe( - { (accounts, statuses) -> + lifecycleScope.launch { + mastodonApi.search( + query = url, + resolve = true + ).fold( + onSuccess = { (accounts, statuses) -> if (getCancelSearchRequested(url)) { - return@subscribe + return@launch } onEndSearch(url) if (statuses.isNotEmpty()) { viewThread(statuses[0].id, statuses[0].url) - return@subscribe + return@launch } - accounts.firstOrNull { it.url == url }?.let { account -> + accounts.firstOrNull { it.url.equals(url, ignoreCase = true) }?.let { account -> // Some servers return (unrelated) accounts for url searches (#2804) // Verify that the account's url matches the query viewAccount(account.id) - return@subscribe + return@launch } performUrlFallbackAction(url, lookupFallbackBehavior) }, - { + onFailure = { if (!getCancelSearchRequested(url)) { onEndSearch(url) performUrlFallbackAction(url, lookupFallbackBehavior) } } ) + } onBeginSearch(url) } @@ -121,10 +124,17 @@ abstract class BottomSheetActivity : BaseActivity() { startActivityWithSlideInAnimation(intent) } - protected open fun performUrlFallbackAction(url: String, fallbackBehavior: PostLookupFallbackBehavior) { + protected open fun performUrlFallbackAction( + url: String, + fallbackBehavior: PostLookupFallbackBehavior + ) { when (fallbackBehavior) { PostLookupFallbackBehavior.OPEN_IN_BROWSER -> openLink(url) - PostLookupFallbackBehavior.DISPLAY_ERROR -> Toast.makeText(this, getString(R.string.post_lookup_error_format, url), Toast.LENGTH_SHORT).show() + PostLookupFallbackBehavior.DISPLAY_ERROR -> Toast.makeText( + this, + getString(R.string.post_lookup_error_format, url), + Toast.LENGTH_SHORT + ).show() } } diff --git a/app/src/main/java/com/keylesspalace/tusky/EditProfileActivity.kt b/app/src/main/java/com/keylesspalace/tusky/EditProfileActivity.kt index 190421e69..e0f6a3bc8 100644 --- a/app/src/main/java/com/keylesspalace/tusky/EditProfileActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/EditProfileActivity.kt @@ -25,9 +25,11 @@ import android.view.Menu import android.view.MenuItem import android.view.View import android.widget.ImageView +import androidx.activity.OnBackPressedCallback import androidx.activity.viewModels +import androidx.appcompat.app.AlertDialog import androidx.core.view.isVisible -import androidx.lifecycle.LiveData +import androidx.core.widget.doAfterTextChanged import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.LinearLayoutManager import com.bumptech.glide.Glide @@ -46,15 +48,18 @@ import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.util.Error import com.keylesspalace.tusky.util.Loading import com.keylesspalace.tusky.util.Success +import com.keylesspalace.tusky.util.await import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.viewmodel.EditProfileViewModel +import com.keylesspalace.tusky.viewmodel.ProfileDataInUi import com.mikepenz.iconics.IconicsDrawable import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial import com.mikepenz.iconics.utils.colorInt import com.mikepenz.iconics.utils.sizeDp -import kotlinx.coroutines.launch import javax.inject.Inject +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch class EditProfileActivity : BaseActivity(), Injectable { @@ -96,6 +101,14 @@ class EditProfileActivity : BaseActivity(), Injectable { } } + private val currentProfileData + get() = ProfileDataInUi( + displayName = binding.displayNameEditText.text.toString(), + note = binding.noteEditText.text.toString(), + locked = binding.lockedCheckBox.isChecked, + fields = accountFieldEditAdapter.getFieldData() + ) + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -114,9 +127,17 @@ class EditProfileActivity : BaseActivity(), Injectable { binding.fieldList.layoutManager = LinearLayoutManager(this) binding.fieldList.adapter = accountFieldEditAdapter - val plusDrawable = IconicsDrawable(this, GoogleMaterial.Icon.gmd_add).apply { sizeDp = 12; colorInt = Color.WHITE } + val plusDrawable = IconicsDrawable(this, GoogleMaterial.Icon.gmd_add).apply { + sizeDp = 12 + colorInt = Color.WHITE + } - binding.addFieldButton.setCompoundDrawablesRelativeWithIntrinsicBounds(plusDrawable, null, null, null) + binding.addFieldButton.setCompoundDrawablesRelativeWithIntrinsicBounds( + plusDrawable, + null, + null, + null + ) binding.addFieldButton.setOnClickListener { accountFieldEditAdapter.addField() @@ -131,52 +152,64 @@ class EditProfileActivity : BaseActivity(), Injectable { viewModel.obtainProfile() - viewModel.profileData.observe(this) { profileRes -> - when (profileRes) { - is Success -> { - val me = profileRes.data - if (me != null) { - binding.displayNameEditText.setText(me.displayName) - binding.noteEditText.setText(me.source?.note) - binding.lockedCheckBox.isChecked = me.locked + lifecycleScope.launch { + viewModel.profileData.collect { profileRes -> + if (profileRes == null) return@collect + when (profileRes) { + is Success -> { + val me = profileRes.data + if (me != null) { + binding.displayNameEditText.setText(me.displayName) + binding.noteEditText.setText(me.source?.note) + binding.lockedCheckBox.isChecked = me.locked - accountFieldEditAdapter.setFields(me.source?.fields.orEmpty()) - binding.addFieldButton.isVisible = - (me.source?.fields?.size ?: 0) < maxAccountFields + accountFieldEditAdapter.setFields(me.source?.fields.orEmpty()) + binding.addFieldButton.isVisible = + (me.source?.fields?.size ?: 0) < maxAccountFields - if (viewModel.avatarData.value == null) { - Glide.with(this) - .load(me.avatar) - .placeholder(R.drawable.avatar_default) - .transform( - FitCenter(), - RoundedCorners(resources.getDimensionPixelSize(R.dimen.avatar_radius_80dp)) - ) - .into(binding.avatarPreview) - } + if (viewModel.avatarData.value == null) { + Glide.with(this@EditProfileActivity) + .load(me.avatar) + .placeholder(R.drawable.avatar_default) + .transform( + FitCenter(), + RoundedCorners( + resources.getDimensionPixelSize(R.dimen.avatar_radius_80dp) + ) + ) + .into(binding.avatarPreview) + } - if (viewModel.headerData.value == null) { - Glide.with(this) - .load(me.header) - .into(binding.headerPreview) + if (viewModel.headerData.value == null) { + Glide.with(this@EditProfileActivity) + .load(me.header) + .into(binding.headerPreview) + } } } + is Error -> { + Snackbar.make( + binding.avatarButton, + R.string.error_generic, + Snackbar.LENGTH_LONG + ) + .setAction(R.string.action_retry) { + viewModel.obtainProfile() + } + .show() + } + is Loading -> { } } - is Error -> { - Snackbar.make(binding.avatarButton, R.string.error_generic, Snackbar.LENGTH_LONG) - .setAction(R.string.action_retry) { - viewModel.obtainProfile() - } - .show() - } - is Loading -> { } } } lifecycleScope.launch { viewModel.instanceData.collect { instanceInfo -> maxAccountFields = instanceInfo.maxFields - accountFieldEditAdapter.setFieldLimits(instanceInfo.maxFieldNameLength, instanceInfo.maxFieldValueLength) + accountFieldEditAdapter.setFieldLimits( + instanceInfo.maxFieldNameLength, + instanceInfo.maxFieldValueLength + ) binding.addFieldButton.isVisible = accountFieldEditAdapter.itemCount < maxAccountFields } @@ -185,60 +218,85 @@ class EditProfileActivity : BaseActivity(), Injectable { observeImage(viewModel.avatarData, binding.avatarPreview, true) observeImage(viewModel.headerData, binding.headerPreview, false) - viewModel.saveData.observe( - this - ) { - when (it) { - is Success -> { - finish() - } - is Loading -> { - binding.saveProgressBar.visibility = View.VISIBLE - } - is Error -> { - onSaveFailure(it.errorMessage) + lifecycleScope.launch { + viewModel.saveData.collect { + if (it == null) return@collect + when (it) { + is Success -> { + finish() + } + is Loading -> { + binding.saveProgressBar.visibility = View.VISIBLE + } + is Error -> { + onSaveFailure(it.errorMessage) + } } } } + + binding.displayNameEditText.doAfterTextChanged { + viewModel.dataChanged(currentProfileData) + } + + binding.displayNameEditText.doAfterTextChanged { + viewModel.dataChanged(currentProfileData) + } + + binding.lockedCheckBox.setOnCheckedChangeListener { _, _ -> + viewModel.dataChanged(currentProfileData) + } + + accountFieldEditAdapter.onFieldsChanged = { + viewModel.dataChanged(currentProfileData) + } + + val onBackCallback = object : OnBackPressedCallback(enabled = false) { + override fun handleOnBackPressed() { + showUnsavedChangesDialog() + } + } + + onBackPressedDispatcher.addCallback(this, onBackCallback) + lifecycleScope.launch { + viewModel.isChanged.collect { dataWasChanged -> + onBackCallback.isEnabled = dataWasChanged + } + } } override fun onStop() { super.onStop() if (!isFinishing) { - viewModel.updateProfile( - binding.displayNameEditText.text.toString(), - binding.noteEditText.text.toString(), - binding.lockedCheckBox.isChecked, - accountFieldEditAdapter.getFieldData() - ) + viewModel.updateProfile(currentProfileData) } } private fun observeImage( - liveData: LiveData, + flow: StateFlow, imageView: ImageView, roundedCorners: Boolean ) { - liveData.observe( - this - ) { imageUri -> + lifecycleScope.launch { + flow.collect { imageUri -> - // skipping all caches so we can always reuse the same uri - val glide = Glide.with(imageView) - .load(imageUri) - .skipMemoryCache(true) - .diskCacheStrategy(DiskCacheStrategy.NONE) + // skipping all caches so we can always reuse the same uri + val glide = Glide.with(imageView) + .load(imageUri) + .skipMemoryCache(true) + .diskCacheStrategy(DiskCacheStrategy.NONE) - if (roundedCorners) { - glide.transform( - FitCenter(), - RoundedCorners(resources.getDimensionPixelSize(R.dimen.avatar_radius_80dp)) - ).into(imageView) - } else { - glide.into(imageView) + if (roundedCorners) { + glide.transform( + FitCenter(), + RoundedCorners(resources.getDimensionPixelSize(R.dimen.avatar_radius_80dp)) + ).into(imageView) + } else { + glide.into(imageView) + } + + imageView.show() } - - imageView.show() } } @@ -287,14 +345,7 @@ class EditProfileActivity : BaseActivity(), Injectable { return super.onOptionsItemSelected(item) } - private fun save() { - viewModel.save( - binding.displayNameEditText.text.toString(), - binding.noteEditText.text.toString(), - binding.lockedCheckBox.isChecked, - accountFieldEditAdapter.getFieldData() - ) - } + private fun save() = viewModel.save(currentProfileData) private fun onSaveFailure(msg: String?) { val errorMsg = msg ?: getString(R.string.error_media_upload_sending) @@ -304,6 +355,22 @@ class EditProfileActivity : BaseActivity(), Injectable { private fun onPickFailure(throwable: Throwable?) { Log.w("EditProfileActivity", "failed to pick media", throwable) - Snackbar.make(binding.avatarButton, R.string.error_media_upload_sending, Snackbar.LENGTH_LONG).show() + Snackbar.make( + binding.avatarButton, + R.string.error_media_upload_sending, + Snackbar.LENGTH_LONG + ).show() } + + private fun showUnsavedChangesDialog() = lifecycleScope.launch { + when (launchSaveDialog()) { + AlertDialog.BUTTON_POSITIVE -> save() + else -> finish() + } + } + + private suspend fun launchSaveDialog() = AlertDialog.Builder(this) + .setMessage(getString(R.string.dialog_save_profile_changes_message)) + .create() + .await(R.string.action_save, R.string.action_discard) } diff --git a/app/src/main/java/com/keylesspalace/tusky/LicenseActivity.kt b/app/src/main/java/com/keylesspalace/tusky/LicenseActivity.kt index ca81d244a..02e9b1a51 100644 --- a/app/src/main/java/com/keylesspalace/tusky/LicenseActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/LicenseActivity.kt @@ -19,11 +19,14 @@ import android.os.Bundle import android.util.Log import android.widget.TextView import androidx.annotation.RawRes +import androidx.lifecycle.lifecycleScope import com.keylesspalace.tusky.databinding.ActivityLicenseBinding -import com.keylesspalace.tusky.util.closeQuietly -import java.io.BufferedReader import java.io.IOException -import java.io.InputStreamReader +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import okio.buffer +import okio.source class LicenseActivity : BaseActivity() { @@ -44,23 +47,15 @@ class LicenseActivity : BaseActivity() { } private fun loadFileIntoTextView(@RawRes fileId: Int, textView: TextView) { - val sb = StringBuilder() - - val br = BufferedReader(InputStreamReader(resources.openRawResource(fileId))) - - try { - var line: String? = br.readLine() - while (line != null) { - sb.append(line) - sb.append('\n') - line = br.readLine() + lifecycleScope.launch { + textView.text = withContext(Dispatchers.IO) { + try { + resources.openRawResource(fileId).source().buffer().use { it.readUtf8() } + } catch (e: IOException) { + Log.w("LicenseActivity", e) + "" + } } - } catch (e: IOException) { - Log.w("LicenseActivity", e) } - - br.closeQuietly() - - textView.text = sb.toString() } } diff --git a/app/src/main/java/com/keylesspalace/tusky/ListsActivity.kt b/app/src/main/java/com/keylesspalace/tusky/ListsActivity.kt index e3de31026..c5c7956b4 100644 --- a/app/src/main/java/com/keylesspalace/tusky/ListsActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/ListsActivity.kt @@ -1,4 +1,4 @@ -/* Copyright 2017 Andrew Dawson +/* Copyright Tusky contributors * * This file is a part of Tusky. * @@ -23,11 +23,7 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.widget.EditText -import android.widget.FrameLayout -import android.widget.ImageButton import android.widget.PopupMenu -import android.widget.TextView import androidx.activity.viewModels import androidx.annotation.StringRes import androidx.appcompat.app.AlertDialog @@ -37,16 +33,17 @@ import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.ListAdapter -import androidx.recyclerview.widget.RecyclerView -import at.connyduck.sparkbutton.helpers.Utils -import com.google.android.material.color.MaterialColors import com.google.android.material.snackbar.Snackbar import com.keylesspalace.tusky.databinding.ActivityListsBinding +import com.keylesspalace.tusky.databinding.DialogListBinding +import com.keylesspalace.tusky.databinding.ItemListBinding import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.entity.MastoList +import com.keylesspalace.tusky.util.BindingHolder import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.show +import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.util.visible import com.keylesspalace.tusky.viewmodel.ListsViewModel @@ -56,18 +53,12 @@ import com.keylesspalace.tusky.viewmodel.ListsViewModel.LoadingState.ERROR_OTHER import com.keylesspalace.tusky.viewmodel.ListsViewModel.LoadingState.INITIAL import com.keylesspalace.tusky.viewmodel.ListsViewModel.LoadingState.LOADED import com.keylesspalace.tusky.viewmodel.ListsViewModel.LoadingState.LOADING -import com.mikepenz.iconics.IconicsDrawable -import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial -import com.mikepenz.iconics.utils.colorInt -import com.mikepenz.iconics.utils.sizeDp import dagger.android.DispatchingAndroidInjector import dagger.android.HasAndroidInjector -import kotlinx.coroutines.launch import javax.inject.Inject +import kotlinx.coroutines.launch -/** - * Created by charlag on 1/4/18. - */ +// TODO use the ListSelectionFragment (and/or its adapter or binding) here; but keep the LoadingState from here (?) class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector { @@ -118,7 +109,7 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector { viewModel.events.collect { event -> when (event) { Event.CREATE_ERROR -> showMessage(R.string.error_create_list) - Event.RENAME_ERROR -> showMessage(R.string.error_rename_list) + Event.UPDATE_ERROR -> showMessage(R.string.error_rename_list) Event.DELETE_ERROR -> showMessage(R.string.error_delete_list) } } @@ -126,16 +117,11 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector { } private fun showlistNameDialog(list: MastoList?) { - val layout = FrameLayout(this) - val editText = EditText(this) - editText.setHint(R.string.hint_list_name) - layout.addView(editText) - val margin = Utils.dpToPx(this, 8) - (editText.layoutParams as ViewGroup.MarginLayoutParams) - .setMargins(margin, margin, margin, 0) - + val binding = DialogListBinding.inflate(layoutInflater).apply { + replyPolicySpinner.setSelection(MastoList.ReplyPolicy.from(list?.repliesPolicy).ordinal) + } val dialog = AlertDialog.Builder(this) - .setView(layout) + .setView(binding.root) .setPositiveButton( if (list == null) { R.string.action_create_list @@ -143,17 +129,31 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector { R.string.action_rename_list } ) { _, _ -> - onPickedDialogName(editText.text, list?.id) + onPickedDialogName( + binding.nameText.text.toString(), + list?.id, + binding.exclusiveCheckbox.isChecked, + MastoList.ReplyPolicy.entries[binding.replyPolicySpinner.selectedItemPosition].policy + ) } .setNegativeButton(android.R.string.cancel, null) .show() - val positiveButton = dialog.getButton(Dialog.BUTTON_POSITIVE) - editText.doOnTextChanged { s, _, _, _ -> - positiveButton.isEnabled = s?.isNotBlank() == true + binding.nameText.let { editText -> + editText.doOnTextChanged { s, _, _, _ -> + dialog.getButton(Dialog.BUTTON_POSITIVE).isEnabled = s?.isNotBlank() == true + } + editText.setText(list?.title) + editText.text?.let { editText.setSelection(it.length) } + } + + list?.let { + if (it.exclusive == null) { + binding.exclusiveCheckbox.visible(false) + } else { + binding.exclusiveCheckbox.isChecked = it.exclusive + } } - editText.setText(list?.title) - editText.text?.let { editText.setSelection(it.length) } } private fun showListDeleteDialog(list: MastoList) { @@ -174,13 +174,13 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector { INITIAL, LOADING -> binding.messageView.hide() ERROR_NETWORK -> { binding.messageView.show() - binding.messageView.setup(R.drawable.elephant_offline, R.string.error_network) { + binding.messageView.setup(R.drawable.errorphant_offline, R.string.error_network) { viewModel.retryLoading() } } ERROR_OTHER -> { binding.messageView.show() - binding.messageView.setup(R.drawable.elephant_error, R.string.error_generic) { + binding.messageView.setup(R.drawable.errorphant_error, R.string.error_generic) { viewModel.retryLoading() } } @@ -192,6 +192,7 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector { R.string.message_empty, null ) + binding.messageView.showHelp(R.string.help_empty_lists) } else { binding.messageView.hide() } @@ -206,9 +207,9 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector { ).show() } - private fun onListSelected(listId: String, listTitle: String) { + private fun onListSelected(list: MastoList) { startActivityWithSlideInAnimation( - StatusListActivity.newListIntent(this, listId, listTitle) + StatusListActivity.newListIntent(this, list.id, list.title) ) } @@ -226,7 +227,7 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector { setOnMenuItemClickListener { item -> when (item.itemId) { R.id.list_edit -> openListSettings(list) - R.id.list_rename -> renameListDialog(list) + R.id.list_update -> renameListDialog(list) R.id.list_delete -> showListDeleteDialog(list) else -> return@setOnMenuItemClickListener false } @@ -247,51 +248,42 @@ class ListsActivity : BaseActivity(), Injectable, HasAndroidInjector { } private inner class ListsAdapter : - ListAdapter(ListsDiffer) { + ListAdapter>(ListsDiffer) { - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ListViewHolder { - return LayoutInflater.from(parent.context).inflate(R.layout.item_list, parent, false) - .let(this::ListViewHolder) - .apply { - val iconColor = MaterialColors.getColor(nameTextView, android.R.attr.textColorTertiary) - val context = nameTextView.context - val icon = IconicsDrawable(context, GoogleMaterial.Icon.gmd_list).apply { sizeDp = 20; colorInt = iconColor } + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int + ): BindingHolder { + return BindingHolder(ItemListBinding.inflate(LayoutInflater.from(parent.context), parent, false)) + } - nameTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(icon, null, null, null) + override fun onBindViewHolder(holder: BindingHolder, position: Int) { + val item = getItem(position) + holder.binding.listName.text = item.title + + holder.binding.moreButton.apply { + visible(true) + setOnClickListener { + onMore(item, holder.binding.moreButton) } - } - - override fun onBindViewHolder(holder: ListViewHolder, position: Int) { - holder.nameTextView.text = getItem(position).title - } - - private inner class ListViewHolder(view: View) : - RecyclerView.ViewHolder(view), - View.OnClickListener { - val nameTextView: TextView = view.findViewById(R.id.list_name_textview) - val moreButton: ImageButton = view.findViewById(R.id.editListButton) - - init { - view.setOnClickListener(this) - moreButton.setOnClickListener(this) } - override fun onClick(v: View) { - if (v == itemView) { - val list = getItem(bindingAdapterPosition) - onListSelected(list.id, list.title) - } else { - onMore(getItem(bindingAdapterPosition), v) - } + holder.itemView.setOnClickListener { + onListSelected(item) } } } - private fun onPickedDialogName(name: CharSequence, listId: String?) { + private fun onPickedDialogName( + name: String, + listId: String?, + exclusive: Boolean, + replyPolicy: String + ) { if (listId == null) { - viewModel.createNewList(name.toString()) + viewModel.createNewList(name, exclusive, replyPolicy) } else { - viewModel.renameList(listId, name.toString()) + viewModel.updateList(listId, name, exclusive, replyPolicy) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt b/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt index c70e83680..3adb29f2a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/MainActivity.kt @@ -16,6 +16,8 @@ package com.keylesspalace.tusky import android.Manifest +import android.annotation.SuppressLint +import android.app.NotificationManager import android.content.Context import android.content.DialogInterface import android.content.Intent @@ -26,6 +28,7 @@ import android.graphics.drawable.Animatable import android.graphics.drawable.BitmapDrawable import android.graphics.drawable.Drawable import android.net.Uri +import android.os.Build import android.os.Bundle import android.text.TextUtils import android.util.Log @@ -33,6 +36,7 @@ import android.view.KeyEvent import android.view.Menu import android.view.MenuInflater import android.view.MenuItem +import android.view.MenuItem.SHOW_AS_ACTION_NEVER import android.view.View import android.widget.ImageView import androidx.activity.OnBackPressedCallback @@ -41,15 +45,18 @@ import androidx.appcompat.content.res.AppCompatResources import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat +import androidx.core.content.IntentCompat import androidx.core.content.pm.ShortcutManagerCompat import androidx.core.view.GravityCompat import androidx.core.view.MenuProvider +import androidx.core.view.forEach +import androidx.core.view.isVisible +import androidx.drawerlayout.widget.DrawerLayout import androidx.lifecycle.lifecycleScope import androidx.preference.PreferenceManager import androidx.viewpager2.widget.MarginPageTransformer import at.connyduck.calladapter.networkresult.fold import com.bumptech.glide.Glide -import com.bumptech.glide.RequestManager import com.bumptech.glide.load.resource.bitmap.RoundedCorners import com.bumptech.glide.request.target.CustomTarget import com.bumptech.glide.request.target.FixedSizeDrawable @@ -60,8 +67,11 @@ import com.google.android.material.tabs.TabLayout.OnTabSelectedListener import com.google.android.material.tabs.TabLayoutMediator import com.keylesspalace.tusky.appstore.AnnouncementReadEvent import com.keylesspalace.tusky.appstore.CacheUpdater +import com.keylesspalace.tusky.appstore.ConversationsLoadingEvent import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.MainTabsChangedEvent +import com.keylesspalace.tusky.appstore.NewNotificationsEvent +import com.keylesspalace.tusky.appstore.NotificationsLoadingEvent import com.keylesspalace.tusky.appstore.ProfileEditedEvent import com.keylesspalace.tusky.components.account.AccountActivity import com.keylesspalace.tusky.components.accountlist.AccountListActivity @@ -81,8 +91,10 @@ import com.keylesspalace.tusky.components.trending.TrendingActivity import com.keylesspalace.tusky.databinding.ActivityMainBinding import com.keylesspalace.tusky.db.AccountEntity import com.keylesspalace.tusky.db.DraftsAlert +import com.keylesspalace.tusky.di.ApplicationScope import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.entity.Notification +import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.interfaces.AccountSelectionListener import com.keylesspalace.tusky.interfaces.ActionButtonActivity import com.keylesspalace.tusky.interfaces.FabFragment @@ -91,16 +103,17 @@ import com.keylesspalace.tusky.pager.MainPagerAdapter import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.usecase.DeveloperToolsUseCase import com.keylesspalace.tusky.usecase.LogoutUsecase +import com.keylesspalace.tusky.util.ShareShortcutHelper import com.keylesspalace.tusky.util.deleteStaleCachedMedia import com.keylesspalace.tusky.util.emojify import com.keylesspalace.tusky.util.getDimension import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.reduceSwipeSensitivity import com.keylesspalace.tusky.util.show +import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation +import com.keylesspalace.tusky.util.supportsOverridingActivityTransitions import com.keylesspalace.tusky.util.unsafeLazy -import com.keylesspalace.tusky.util.updateShortcut import com.keylesspalace.tusky.util.viewBinding -import com.keylesspalace.tusky.util.visible import com.mikepenz.iconics.IconicsDrawable import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial import com.mikepenz.iconics.utils.colorInt @@ -131,9 +144,10 @@ import com.mikepenz.materialdrawer.widget.AccountHeaderView import dagger.android.DispatchingAndroidInjector import dagger.android.HasAndroidInjector import de.c1710.filemojicompat_ui.helpers.EMOJI_PREFERENCE -import io.reactivex.rxjava3.schedulers.Schedulers -import kotlinx.coroutines.launch import javax.inject.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInjector, MenuProvider { @Inject @@ -154,21 +168,23 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje @Inject lateinit var developerToolsUseCase: DeveloperToolsUseCase + @Inject + lateinit var shareShortcutHelper: ShareShortcutHelper + + @Inject + @ApplicationScope + lateinit var externalScope: CoroutineScope + private val binding by viewBinding(ActivityMainBinding::inflate) private lateinit var header: AccountHeaderView - private var notificationTabPosition = 0 private var onTabSelectedListener: OnTabSelectedListener? = null private var unreadAnnouncementsCount = 0 private val preferences by unsafeLazy { PreferenceManager.getDefaultSharedPreferences(this) } - private lateinit var glide: RequestManager - - private var accountLocked: Boolean = false - // We need to know if the emoji pack has been changed private var selectedEmojiPack: String? = null @@ -178,37 +194,68 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje /** Adapter for the different timeline tabs */ private lateinit var tabAdapter: MainPagerAdapter + private var directMessageTab: TabLayout.Tab? = null + + private val onBackPressedCallback = object : OnBackPressedCallback(false) { + override fun handleOnBackPressed() { + when { + binding.mainDrawerLayout.isOpen -> { + binding.mainDrawerLayout.close() + } + binding.viewPager.currentItem != 0 -> { + binding.viewPager.currentItem = 0 + } + } + } + } + + @SuppressLint("RestrictedApi") override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val activeAccount = accountManager.activeAccount ?: return // will be redirected to LoginActivity by BaseActivity + if (supportsOverridingActivityTransitions() && explodeAnimationWasRequested()) { + overrideActivityTransition(OVERRIDE_TRANSITION_OPEN, R.anim.explode, R.anim.activity_open_exit) + } + var showNotificationTab = false - if (intent != null) { + + // check for savedInstanceState in order to not handle intent events more than once + if (intent != null && savedInstanceState == null) { + val notificationId = intent.getIntExtra(NOTIFICATION_ID, -1) + if (notificationId != -1) { + // opened from a notification action, cancel the notification + val notificationManager = getSystemService( + NOTIFICATION_SERVICE + ) as NotificationManager + notificationManager.cancel(intent.getStringExtra(NOTIFICATION_TAG), notificationId) + } + /** there are two possibilities the accountId can be passed to MainActivity: - * - from our code as long 'account_id' + * - from our code as Long Intent Extra TUSKY_ACCOUNT_ID * - from share shortcuts as String 'android.intent.extra.shortcut.ID' */ - var accountId = intent.getLongExtra(NotificationHelper.ACCOUNT_ID, -1) - if (accountId == -1L) { + var tuskyAccountId = intent.getLongExtra(TUSKY_ACCOUNT_ID, -1) + if (tuskyAccountId == -1L) { val accountIdString = intent.getStringExtra(ShortcutManagerCompat.EXTRA_SHORTCUT_ID) if (accountIdString != null) { - accountId = accountIdString.toLong() + tuskyAccountId = accountIdString.toLong() } } - val accountRequested = accountId != -1L - if (accountRequested && accountId != activeAccount.id) { - accountManager.setActiveAccount(accountId) + val accountRequested = tuskyAccountId != -1L + if (accountRequested && tuskyAccountId != activeAccount.id) { + accountManager.setActiveAccount(tuskyAccountId) } val openDrafts = intent.getBooleanExtra(OPEN_DRAFTS, false) - if (canHandleMimeType(intent.type)) { + if (canHandleMimeType(intent.type) || intent.hasExtra(COMPOSE_OPTIONS)) { // Sharing to Tusky from an external app if (accountRequested) { // The correct account is already active - forwardShare(intent) + forwardToComposeActivity(intent) } else { // No account was provided, show the chooser showAccountChooserDialog( @@ -219,10 +266,10 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje val requestedId = account.id if (requestedId == activeAccount.id) { // The correct account is already active - forwardShare(intent) + forwardToComposeActivity(intent) } else { // A different account was requested, restart the activity - intent.putExtra(NotificationHelper.ACCOUNT_ID, requestedId) + intent.putExtra(TUSKY_ACCOUNT_ID, requestedId) changeAccount(requestedId, intent) } } @@ -232,11 +279,14 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje } else if (openDrafts) { val intent = DraftsActivity.newIntent(this) startActivity(intent) - } else if (accountRequested && savedInstanceState == null) { + } else if (accountRequested && intent.hasExtra(NOTIFICATION_TYPE)) { // user clicked a notification, show follow requests for type FOLLOW_REQUEST, // otherwise show notification tab - if (intent.getStringExtra(NotificationHelper.TYPE) == Notification.Type.FOLLOW_REQUEST.name) { - val intent = AccountListActivity.newIntent(this, AccountListActivity.Type.FOLLOW_REQUESTS, accountLocked = true) + if (intent.getStringExtra(NOTIFICATION_TYPE) == Notification.Type.FOLLOW_REQUEST.name) { + val intent = AccountListActivity.newIntent( + this, + AccountListActivity.Type.FOLLOW_REQUESTS + ) startActivityWithSlideInAnimation(intent) } else { showNotificationTab = true @@ -245,17 +295,27 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje } window.statusBarColor = Color.TRANSPARENT // don't draw a status bar, the DrawerLayout and the MaterialDrawerLayout have their own setContentView(binding.root) - setSupportActionBar(binding.mainToolbar) - - glide = Glide.with(this) binding.composeButton.setOnClickListener { val composeIntent = Intent(applicationContext, ComposeActivity::class.java) startActivity(composeIntent) } + // Determine which of the three toolbars should be the supportActionBar (which hosts + // the options menu). val hideTopToolbar = preferences.getBoolean(PrefKeys.HIDE_TOP_TOOLBAR, false) - binding.mainToolbar.visible(!hideTopToolbar) + if (hideTopToolbar) { + when (preferences.getString(PrefKeys.MAIN_NAV_POSITION, "top")) { + "top" -> setSupportActionBar(binding.topNav) + "bottom" -> setSupportActionBar(binding.bottomNav) + } + binding.mainToolbar.hide() + // There's not enough space in the top/bottom bars to show the title as well. + supportActionBar?.setDisplayShowTitleEnabled(false) + } else { + setSupportActionBar(binding.mainToolbar) + binding.mainToolbar.show() + } loadDrawerAvatar(activeAccount.profilePictureUrl, true) @@ -266,7 +326,12 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje setupDrawer( savedInstanceState, addSearchButton = hideTopToolbar, - addTrendingButton = !accountManager.activeAccount!!.tabPreferences.hasTab(TRENDING) + addTrendingTagsButton = !accountManager.activeAccount!!.tabPreferences.hasTab( + TRENDING_TAGS + ), + addTrendingStatusesButton = !accountManager.activeAccount!!.tabPreferences.hasTab( + TRENDING_STATUSES + ) ) /* Fetch user info while we're doing other things. This has to be done after setting up the @@ -291,47 +356,57 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje is MainTabsChangedEvent -> { refreshMainDrawerItems( addSearchButton = hideTopToolbar, - addTrendingButton = !event.newTabs.hasTab(TRENDING) + addTrendingTagsButton = !event.newTabs.hasTab(TRENDING_TAGS), + addTrendingStatusesButton = !event.newTabs.hasTab(TRENDING_STATUSES) ) setupTabs(false) } - is AnnouncementReadEvent -> { unreadAnnouncementsCount-- updateAnnouncementsBadge() } + is NewNotificationsEvent -> { + directMessageTab?.let { + if (event.accountId == activeAccount.accountId) { + val hasDirectMessageNotification = + event.notifications.any { + it.type == Notification.Type.MENTION && it.status?.visibility == Status.Visibility.DIRECT + } + + if (hasDirectMessageNotification) { + showDirectMessageBadge(true) + } + } + } + } + is NotificationsLoadingEvent -> { + if (event.accountId == activeAccount.accountId) { + showDirectMessageBadge(false) + } + } + is ConversationsLoadingEvent -> { + if (event.accountId == activeAccount.accountId) { + showDirectMessageBadge(false) + } + } } } } - Schedulers.io().scheduleDirect { + externalScope.launch(Dispatchers.IO) { // Flush old media that was cached for sharing deleteStaleCachedMedia(applicationContext.getExternalFilesDir("Tusky")) } selectedEmojiPack = preferences.getString(EMOJI_PREFERENCE, "") - onBackPressedDispatcher.addCallback( - this, - object : OnBackPressedCallback(true) { - override fun handleOnBackPressed() { - when { - binding.mainDrawerLayout.isOpen -> { - binding.mainDrawerLayout.close() - } - binding.viewPager.currentItem != 0 -> { - binding.viewPager.currentItem = 0 - } - else -> { - finish() - } - } - } - } - ) + onBackPressedDispatcher.addCallback(this, onBackPressedCallback) - if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { + if ( + Build.VERSION.SDK_INT >= 33 && + ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED + ) { ActivityCompat.requestPermissions( this, arrayOf(Manifest.permission.POST_NOTIFICATIONS), @@ -343,6 +418,22 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje draftsAlert.observeInContext(this, true) } + private fun showDirectMessageBadge(showBadge: Boolean) { + directMessageTab?.let { tab -> + tab.badge?.isVisible = showBadge + + // TODO a bit cumbersome (also for resetting) + lifecycleScope.launch(Dispatchers.IO) { + accountManager.activeAccount?.let { + if (it.hasDirectMessageBadge != showBadge) { + it.hasDirectMessageBadge = showBadge + accountManager.saveAccount(it) + } + } + } + } + } + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { menuInflater.inflate(R.menu.activity_main, menu) menu.findItem(R.id.action_search)?.apply { @@ -353,6 +444,20 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje } } + override fun onPrepareMenu(menu: Menu) { + super.onPrepareMenu(menu) + + // If the main toolbar is hidden then there's no space in the top/bottomNav to show + // the menu items as icons, so forceably disable them + if (!binding.mainToolbar.isVisible) { + menu.forEach { + it.setShowAsAction( + SHOW_AS_ACTION_NEVER + ) + } + } + } + override fun onMenuItemSelected(item: MenuItem): Boolean { return when (item.itemId) { R.id.action_search -> { @@ -425,12 +530,23 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje } } - private fun forwardShare(intent: Intent) { - val composeIntent = Intent(this, ComposeActivity::class.java) - composeIntent.action = intent.action - composeIntent.type = intent.type - composeIntent.putExtras(intent) - composeIntent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + private fun forwardToComposeActivity(intent: Intent) { + val composeOptions = IntentCompat.getParcelableExtra( + intent, + COMPOSE_OPTIONS, + ComposeActivity.ComposeOptions::class.java + ) + + val composeIntent = if (composeOptions != null) { + ComposeActivity.startIntent(this, composeOptions) + } else { + Intent(this, ComposeActivity::class.java).apply { + action = intent.action + type = intent.type + putExtras(intent) + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + } + } startActivity(composeIntent) finish() } @@ -438,13 +554,14 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje private fun setupDrawer( savedInstanceState: Bundle?, addSearchButton: Boolean, - addTrendingButton: Boolean + addTrendingTagsButton: Boolean, + addTrendingStatusesButton: Boolean ) { val drawerOpenClickListener = View.OnClickListener { binding.mainDrawerLayout.open() } binding.mainToolbar.setNavigationOnClickListener(drawerOpenClickListener) - binding.topNavAvatar.setOnClickListener(drawerOpenClickListener) - binding.bottomNavAvatar.setOnClickListener(drawerOpenClickListener) + binding.topNav.setNavigationOnClickListener(drawerOpenClickListener) + binding.bottomNav.setNavigationOnClickListener(drawerOpenClickListener) header = AccountHeaderView(this).apply { headerBackgroundScaleType = ImageView.ScaleType.CENTER_CROP @@ -468,17 +585,21 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje header.currentProfileName.ellipsize = TextUtils.TruncateAt.END header.accountHeaderBackground.setColorFilter(getColor(R.color.headerBackgroundFilter)) - header.accountHeaderBackground.setBackgroundColor(MaterialColors.getColor(header, R.attr.colorBackgroundAccent)) - val animateAvatars = preferences.getBoolean("animateGifAvatars", false) + header.accountHeaderBackground.setBackgroundColor( + MaterialColors.getColor(header, R.attr.colorBackgroundAccent) + ) + val animateAvatars = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false) DrawerImageLoader.init(object : AbstractDrawerImageLoader() { override fun set(imageView: ImageView, uri: Uri, placeholder: Drawable, tag: String?) { if (animateAvatars) { - glide.load(uri) + Glide.with(imageView) + .load(uri) .placeholder(placeholder) .into(imageView) } else { - glide.asBitmap() + Glide.with(imageView) + .asBitmap() .load(uri) .placeholder(placeholder) .into(imageView) @@ -486,12 +607,12 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje } override fun cancel(imageView: ImageView) { - glide.clear(imageView) + // nothing to do, Glide already handles cancellation automatically } override fun placeholder(ctx: Context, tag: String?): Drawable { if (tag == DrawerImageLoader.Tags.PROFILE.name || tag == DrawerImageLoader.Tags.PROFILE_DRAWER_ITEM.name) { - return ctx.getDrawable(R.drawable.avatar_default)!! + return AppCompatResources.getDrawable(ctx, R.drawable.avatar_default)!! } return super.placeholder(ctx, tag) @@ -499,12 +620,33 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje }) binding.mainDrawer.apply { - refreshMainDrawerItems(addSearchButton, addTrendingButton) + refreshMainDrawerItems( + addSearchButton = addSearchButton, + addTrendingTagsButton = addTrendingTagsButton, + addTrendingStatusesButton = addTrendingStatusesButton + ) setSavedInstance(savedInstanceState) } + binding.mainDrawerLayout.addDrawerListener(object : DrawerLayout.DrawerListener { + override fun onDrawerSlide(drawerView: View, slideOffset: Float) { } + + override fun onDrawerOpened(drawerView: View) { + onBackPressedCallback.isEnabled = true + } + + override fun onDrawerClosed(drawerView: View) { + onBackPressedCallback.isEnabled = binding.tabLayout.selectedTabPosition > 0 + } + + override fun onDrawerStateChanged(newState: Int) { } + }) } - private fun refreshMainDrawerItems(addSearchButton: Boolean, addTrendingButton: Boolean) { + private fun refreshMainDrawerItems( + addSearchButton: Boolean, + addTrendingTagsButton: Boolean, + addTrendingStatusesButton: Boolean + ) { binding.mainDrawer.apply { itemAdapter.clear() tintStatusBar = true @@ -538,7 +680,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje nameRes = R.string.action_view_follow_requests iconicsIcon = GoogleMaterial.Icon.gmd_person_add onClick = { - val intent = AccountListActivity.newIntent(context, AccountListActivity.Type.FOLLOW_REQUESTS, accountLocked = accountLocked) + val intent = AccountListActivity.newIntent(context, AccountListActivity.Type.FOLLOW_REQUESTS) startActivityWithSlideInAnimation(intent) } }, @@ -621,7 +763,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje ) } - if (addTrendingButton) { + if (addTrendingTagsButton) { binding.mainDrawer.addItemsAtPosition( 5, primaryDrawerItem { @@ -633,6 +775,19 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje } ) } + + if (addTrendingStatusesButton) { + binding.mainDrawer.addItemsAtPosition( + 6, + primaryDrawerItem { + nameRes = R.string.title_public_trending_statuses + iconicsIcon = GoogleMaterial.Icon.gmd_local_fire_department + onClick = { + startActivityWithSlideInAnimation(StatusListActivity.newTrendingIntent(context)) + } + } + ) + } } if (BuildConfig.DEBUG) { @@ -702,6 +857,8 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje // Detach any existing mediator before changing tab contents and attaching a new mediator tabLayoutMediator?.detach() + directMessageTab = null + tabAdapter.tabs = tabs tabAdapter.notifyItemRangeChanged(0, tabs.size) @@ -712,6 +869,12 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje LIST -> tabs[position].arguments[1] else -> getString(tabs[position].text) } + if (tabs[position].id == DIRECT) { + val badge = tab.orCreateBadge + badge.isVisible = accountManager.activeAccount?.hasDirectMessageBadge ?: false + badge.backgroundColor = MaterialColors.getColor(binding.mainDrawer, com.google.android.material.R.attr.colorPrimary) + directMessageTab = tab + } }.also { it.attach() } // Selected tab is either @@ -737,9 +900,22 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje onTabSelectedListener = object : OnTabSelectedListener { override fun onTabSelected(tab: TabLayout.Tab) { + onBackPressedCallback.isEnabled = tab.position > 0 || binding.mainDrawerLayout.isOpen + binding.mainToolbar.title = tab.contentDescription refreshComposeButtonState(tabAdapter, tab.position) + + if (tab == directMessageTab) { + tab.badge?.isVisible = false + + accountManager.activeAccount?.let { + if (it.hasDirectMessageBadge) { + it.hasDirectMessageBadge = false + accountManager.saveAccount(it) + } + } + } } override fun onTabUnselected(tab: TabLayout.Tab) {} @@ -756,10 +932,13 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje activeTabLayout.addOnTabSelectedListener(it) } - val activeTabPosition = if (selectNotificationTab) notificationTabPosition else 0 - supportActionBar?.title = tabs[activeTabPosition].title(this@MainActivity) + supportActionBar?.title = tabs[position].title(this@MainActivity) binding.mainToolbar.setOnClickListener { - (tabAdapter.getFragment(activeTabLayout.selectedTabPosition) as? ReselectableFragment)?.onReselect() + ( + tabAdapter.getFragment( + activeTabLayout.selectedTabPosition + ) as? ReselectableFragment + )?.onReselect() } updateProfiles() @@ -790,7 +969,9 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje } // open LoginActivity to add new account if (profile.identifier == DRAWER_ITEM_ADD_ACCOUNT) { - startActivityWithSlideInAnimation(LoginActivity.getIntent(this, LoginActivity.MODE_ADDITIONAL_LOGIN)) + startActivityWithSlideInAnimation( + LoginActivity.getIntent(this, LoginActivity.MODE_ADDITIONAL_LOGIN) + ) return false } // change Account @@ -802,15 +983,18 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje cacheUpdater.stop() accountManager.setActiveAccount(newSelectedId) val intent = Intent(this, MainActivity::class.java) - intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + intent.putExtra(OPEN_WITH_EXPLODE_ANIMATION, true) if (forward != null) { intent.type = forward.type intent.action = forward.action intent.putExtras(forward) } startActivity(intent) - finishWithoutSlideOutAnimation() - overridePendingTransition(R.anim.explode, R.anim.explode) + finish() + if (!supportsOverridingActivityTransitions()) { + @Suppress("DEPRECATION") + overridePendingTransition(R.anim.explode, R.anim.activity_open_exit) + } } private fun logout() { @@ -833,7 +1017,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje LoginActivity.getIntent(this@MainActivity, LoginActivity.MODE_DEFAULT) } startActivity(intent) - finishWithoutSlideOutAnimation() + finish() } } .setNegativeButton(android.R.string.cancel, null) @@ -853,17 +1037,26 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje } private fun onFetchUserInfoSuccess(me: Account) { - glide.asBitmap() + Glide.with(header.accountHeaderBackground) + .asBitmap() .load(me.header) .into(header.accountHeaderBackground) loadDrawerAvatar(me.avatar, false) accountManager.updateActiveAccount(me) - NotificationHelper.createNotificationChannelsForAccount(accountManager.activeAccount!!, this) + NotificationHelper.createNotificationChannelsForAccount( + accountManager.activeAccount!!, + this + ) // Setup push notifications - showMigrationNoticeIfNecessary(this, binding.mainCoordinatorLayout, binding.composeButton, accountManager) + showMigrationNoticeIfNecessary( + this, + binding.mainCoordinatorLayout, + binding.composeButton, + accountManager + ) if (NotificationHelper.areNotificationsEnabled(this, accountManager)) { lifecycleScope.launch { enablePushNotificationsWithFallback(this@MainActivity, mastodonApi, accountManager) @@ -872,122 +1065,94 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje disableAllNotifications(this, accountManager) } - accountLocked = me.locked - updateProfiles() - updateShortcut(this, accountManager.activeAccount!!) + shareShortcutHelper.updateShortcuts() } + @SuppressLint("CheckResult") private fun loadDrawerAvatar(avatarUrl: String, showPlaceholder: Boolean) { val hideTopToolbar = preferences.getBoolean(PrefKeys.HIDE_TOP_TOOLBAR, false) - val animateAvatars = preferences.getBoolean("animateGifAvatars", false) + val animateAvatars = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false) - if (hideTopToolbar) { - val navOnBottom = preferences.getString("mainNavPosition", "top") == "bottom" - - val avatarView = if (navOnBottom) { - binding.bottomNavAvatar.show() - binding.bottomNavAvatar + val activeToolbar = if (hideTopToolbar) { + val navOnBottom = preferences.getString(PrefKeys.MAIN_NAV_POSITION, "top") == "bottom" + if (navOnBottom) { + binding.bottomNav } else { - binding.topNavAvatar.show() - binding.topNavAvatar - } - - if (animateAvatars) { - Glide.with(this) - .load(avatarUrl) - .placeholder(R.drawable.avatar_default) - .into(avatarView) - } else { - Glide.with(this) - .asBitmap() - .load(avatarUrl) - .placeholder(R.drawable.avatar_default) - .into(avatarView) + binding.topNav } } else { - binding.bottomNavAvatar.hide() - binding.topNavAvatar.hide() + binding.mainToolbar + } - val navIconSize = resources.getDimensionPixelSize(R.dimen.avatar_toolbar_nav_icon_size) + val navIconSize = resources.getDimensionPixelSize(R.dimen.avatar_toolbar_nav_icon_size) - if (animateAvatars) { - glide.asDrawable() - .load(avatarUrl) - .transform( - RoundedCorners(resources.getDimensionPixelSize(R.dimen.avatar_radius_36dp)) - ) - .apply { - if (showPlaceholder) { - placeholder(R.drawable.avatar_default) + if (animateAvatars) { + Glide.with(this) + .asDrawable() + .load(avatarUrl) + .transform( + RoundedCorners(resources.getDimensionPixelSize(R.dimen.avatar_radius_36dp)) + ) + .apply { + if (showPlaceholder) placeholder(R.drawable.avatar_default) + } + .into(object : CustomTarget(navIconSize, navIconSize) { + + override fun onLoadStarted(placeholder: Drawable?) { + placeholder?.let { + activeToolbar.navigationIcon = FixedSizeDrawable(it, navIconSize, navIconSize) } } - .into(object : CustomTarget(navIconSize, navIconSize) { - override fun onLoadStarted(placeholder: Drawable?) { - if (placeholder != null) { - binding.mainToolbar.navigationIcon = - FixedSizeDrawable(placeholder, navIconSize, navIconSize) - } - } + override fun onResourceReady( + resource: Drawable, + transition: Transition? + ) { + if (resource is Animatable) resource.start() + activeToolbar.navigationIcon = FixedSizeDrawable(resource, navIconSize, navIconSize) + } - override fun onResourceReady( - resource: Drawable, - transition: Transition? - ) { - if (resource is Animatable) { - resource.start() - } - binding.mainToolbar.navigationIcon = - FixedSizeDrawable(resource, navIconSize, navIconSize) - } - - override fun onLoadCleared(placeholder: Drawable?) { - if (placeholder != null) { - binding.mainToolbar.navigationIcon = - FixedSizeDrawable(placeholder, navIconSize, navIconSize) - } - } - }) - } else { - glide.asBitmap() - .load(avatarUrl) - .transform( - RoundedCorners(resources.getDimensionPixelSize(R.dimen.avatar_radius_36dp)) - ) - .apply { - if (showPlaceholder) { - placeholder(R.drawable.avatar_default) + override fun onLoadCleared(placeholder: Drawable?) { + placeholder?.let { + activeToolbar.navigationIcon = FixedSizeDrawable(it, navIconSize, navIconSize) } } - .into(object : CustomTarget(navIconSize, navIconSize) { - - override fun onLoadStarted(placeholder: Drawable?) { - if (placeholder != null) { - binding.mainToolbar.navigationIcon = - FixedSizeDrawable(placeholder, navIconSize, navIconSize) - } + }) + } else { + Glide.with(this) + .asBitmap() + .load(avatarUrl) + .transform( + RoundedCorners(resources.getDimensionPixelSize(R.dimen.avatar_radius_36dp)) + ) + .apply { + if (showPlaceholder) placeholder(R.drawable.avatar_default) + } + .into(object : CustomTarget(navIconSize, navIconSize) { + override fun onLoadStarted(placeholder: Drawable?) { + placeholder?.let { + activeToolbar.navigationIcon = FixedSizeDrawable(it, navIconSize, navIconSize) } + } - override fun onResourceReady( - resource: Bitmap, - transition: Transition? - ) { - binding.mainToolbar.navigationIcon = FixedSizeDrawable( - BitmapDrawable(resources, resource), - navIconSize, - navIconSize - ) - } + override fun onResourceReady( + resource: Bitmap, + transition: Transition? + ) { + activeToolbar.navigationIcon = FixedSizeDrawable( + BitmapDrawable(resources, resource), + navIconSize, + navIconSize + ) + } - override fun onLoadCleared(placeholder: Drawable?) { - if (placeholder != null) { - binding.mainToolbar.navigationIcon = - FixedSizeDrawable(placeholder, navIconSize, navIconSize) - } + override fun onLoadCleared(placeholder: Drawable?) { + placeholder?.let { + activeToolbar.navigationIcon = FixedSizeDrawable(it, navIconSize, navIconSize) } - }) - } + } + }) } } @@ -1007,7 +1172,12 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje } private fun updateAnnouncementsBadge() { - binding.mainDrawer.updateBadge(DRAWER_ITEM_ANNOUNCEMENTS, StringHolder(if (unreadAnnouncementsCount <= 0) null else unreadAnnouncementsCount.toString())) + binding.mainDrawer.updateBadge( + DRAWER_ITEM_ANNOUNCEMENTS, + StringHolder( + if (unreadAnnouncementsCount <= 0) null else unreadAnnouncementsCount.toString() + ) + ) } private fun updateProfiles() { @@ -1041,16 +1211,93 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, HasAndroidInje } } + private fun explodeAnimationWasRequested(): Boolean { + return intent.getBooleanExtra(OPEN_WITH_EXPLODE_ANIMATION, false) + } + override fun getActionButton() = binding.composeButton override fun androidInjector() = androidInjector companion object { + const val OPEN_WITH_EXPLODE_ANIMATION = "explode" + private const val TAG = "MainActivity" // logging tag private const val DRAWER_ITEM_ADD_ACCOUNT: Long = -13 private const val DRAWER_ITEM_ANNOUNCEMENTS: Long = 14 - const val REDIRECT_URL = "redirectUrl" - const val OPEN_DRAFTS = "draft" + private const val REDIRECT_URL = "redirectUrl" + private const val OPEN_DRAFTS = "draft" + private const val TUSKY_ACCOUNT_ID = "tuskyAccountId" + private const val COMPOSE_OPTIONS = "composeOptions" + private const val NOTIFICATION_TYPE = "notificationType" + private const val NOTIFICATION_TAG = "notificationTag" + private const val NOTIFICATION_ID = "notificationId" + + /** + * Switches the active account to the provided accountId and then stays on MainActivity + */ + @JvmStatic + fun accountSwitchIntent(context: Context, tuskyAccountId: Long): Intent { + return Intent(context, MainActivity::class.java).apply { + putExtra(TUSKY_ACCOUNT_ID, tuskyAccountId) + } + } + + /** + * Switches the active account to the accountId and takes the user to the correct place according to the notification they clicked + */ + @JvmStatic + fun openNotificationIntent( + context: Context, + tuskyAccountId: Long, + type: Notification.Type + ): Intent { + return accountSwitchIntent(context, tuskyAccountId).apply { + putExtra(NOTIFICATION_TYPE, type.name) + } + } + + /** + * Switches the active account to the accountId and then opens ComposeActivity with the provided options + * @param tuskyAccountId the id of the Tusky account to open the screen with. Set to -1 for current account. + * @param notificationId optional id of the notification that should be cancelled when this intent is opened + * @param notificationTag optional tag of the notification that should be cancelled when this intent is opened + */ + @JvmStatic + fun composeIntent( + context: Context, + options: ComposeActivity.ComposeOptions, + tuskyAccountId: Long = -1, + notificationTag: String? = null, + notificationId: Int = -1 + ): Intent { + return accountSwitchIntent(context, tuskyAccountId).apply { + action = Intent.ACTION_SEND // so it can be opened via shortcuts + putExtra(COMPOSE_OPTIONS, options) + putExtra(NOTIFICATION_TAG, notificationTag) + putExtra(NOTIFICATION_ID, notificationId) + } + } + + /** + * switches the active account to the accountId and then tries to resolve and show the provided url + */ + @JvmStatic + fun redirectIntent(context: Context, tuskyAccountId: Long, url: String): Intent { + return accountSwitchIntent(context, tuskyAccountId).apply { + putExtra(REDIRECT_URL, url) + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + } + } + + /** + * switches the active account to the provided accountId and then opens drafts + */ + fun draftIntent(context: Context, tuskyAccountId: Long): Intent { + return accountSwitchIntent(context, tuskyAccountId).apply { + putExtra(OPEN_DRAFTS, true) + } + } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/StatusListActivity.kt b/app/src/main/java/com/keylesspalace/tusky/StatusListActivity.kt index c055043d1..c844f2256 100644 --- a/app/src/main/java/com/keylesspalace/tusky/StatusListActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/StatusListActivity.kt @@ -27,17 +27,20 @@ import at.connyduck.calladapter.networkresult.fold import com.google.android.material.snackbar.Snackbar import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.PreferenceChangedEvent +import com.keylesspalace.tusky.components.filters.EditFilterActivity +import com.keylesspalace.tusky.components.filters.FiltersActivity import com.keylesspalace.tusky.components.timeline.TimelineFragment import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel.Kind import com.keylesspalace.tusky.databinding.ActivityStatuslistBinding import com.keylesspalace.tusky.entity.Filter import com.keylesspalace.tusky.entity.FilterV1 +import com.keylesspalace.tusky.util.isHttpNotFound +import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation import com.keylesspalace.tusky.util.viewBinding import dagger.android.DispatchingAndroidInjector import dagger.android.HasAndroidInjector -import kotlinx.coroutines.launch -import retrofit2.HttpException import javax.inject.Inject +import kotlinx.coroutines.launch class StatusListActivity : BottomSheetActivity(), HasAndroidInjector { @@ -47,7 +50,9 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector { @Inject lateinit var eventHub: EventHub - private val binding: ActivityStatuslistBinding by viewBinding(ActivityStatuslistBinding::inflate) + private val binding: ActivityStatuslistBinding by viewBinding( + ActivityStatuslistBinding::inflate + ) private lateinit var kind: Kind private var hashtag: String? = null private var followTagItem: MenuItem? = null @@ -74,6 +79,7 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector { Kind.FAVOURITES -> getString(R.string.title_favourites) Kind.BOOKMARKS -> getString(R.string.title_bookmarks) Kind.TAG -> getString(R.string.title_tag).format(hashtag) + Kind.PUBLIC_TRENDING_STATUSES -> getString(R.string.title_public_trending_statuses) else -> intent.getStringExtra(EXTRA_LIST_TITLE) } @@ -132,9 +138,19 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector { { followTagItem?.isVisible = false unfollowTagItem?.isVisible = true + + Snackbar.make( + binding.root, + getString(R.string.following_hashtag_success_format, tag), + Snackbar.LENGTH_SHORT + ).show() }, { - Snackbar.make(binding.root, getString(R.string.error_following_hashtag_format, tag), Snackbar.LENGTH_SHORT).show() + Snackbar.make( + binding.root, + getString(R.string.error_following_hashtag_format, tag), + Snackbar.LENGTH_SHORT + ).show() Log.e(TAG, "Failed to follow #$tag", it) } ) @@ -152,9 +168,19 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector { { followTagItem?.isVisible = true unfollowTagItem?.isVisible = false + + Snackbar.make( + binding.root, + getString(R.string.unfollowing_hashtag_success_format, tag), + Snackbar.LENGTH_SHORT + ).show() }, { - Snackbar.make(binding.root, getString(R.string.error_unfollowing_hashtag_format, tag), Snackbar.LENGTH_SHORT).show() + Snackbar.make( + binding.root, + getString(R.string.error_unfollowing_hashtag_format, tag), + Snackbar.LENGTH_SHORT + ).show() Log.e(TAG, "Failed to unfollow #$tag", it) } ) @@ -169,6 +195,7 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector { */ private fun updateMuteTagMenuItems() { val tag = hashtag ?: return + val hashedTag = "#$tag" muteTagItem?.isVisible = true muteTagItem?.isEnabled = false @@ -178,18 +205,17 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector { mastodonApi.getFilters().fold( { filters -> mutedFilter = filters.firstOrNull { filter -> - filter.context.contains(Filter.Kind.HOME.kind) && filter.keywords.any { - it.keyword == tag - } + // TODO shouldn't this be an exact match (only one keyword; exactly the hashtag)? + filter.context.contains(Filter.Kind.HOME.kind) && filter.title == hashedTag } updateTagMuteState(mutedFilter != null) }, { throwable -> - if (throwable is HttpException && throwable.code() == 404) { + if (throwable.isHttpNotFound()) { mastodonApi.getFiltersV1().fold( { filters -> mutedFilterV1 = filters.firstOrNull { filter -> - tag == filter.phrase && filter.context.contains(FilterV1.HOME) + hashedTag == filter.phrase && filter.context.contains(FilterV1.HOME) } updateTagMuteState(mutedFilterV1 != null) }, @@ -221,6 +247,9 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector { val tag = hashtag ?: return true lifecycleScope.launch { + var filterCreateSuccess = false + val hashedTag = "#$tag" + mastodonApi.createFilter( title = "#$tag", context = listOf(FilterV1.HOME), @@ -228,19 +257,31 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector { expiresInSeconds = null ).fold( { filter -> - if (mastodonApi.addFilterKeyword(filterId = filter.id, keyword = tag, wholeWord = true).isSuccess) { - mutedFilter = filter - updateTagMuteState(true) + if (mastodonApi.addFilterKeyword( + filterId = filter.id, + keyword = hashedTag, + wholeWord = true + ).isSuccess + ) { + // must be requested again; otherwise does not contain the keyword (but server does) + mutedFilter = mastodonApi.getFilter(filter.id).getOrNull() + + // TODO the preference key here ("home") is not meaningful; should probably be another event if any eventHub.dispatch(PreferenceChangedEvent(filter.context[0])) + filterCreateSuccess = true } else { - Snackbar.make(binding.root, getString(R.string.error_muting_hashtag_format, tag), Snackbar.LENGTH_SHORT).show() + Snackbar.make( + binding.root, + getString(R.string.error_muting_hashtag_format, tag), + Snackbar.LENGTH_SHORT + ).show() Log.e(TAG, "Failed to mute #$tag") } }, { throwable -> - if (throwable is HttpException && throwable.code() == 404) { + if (throwable.isHttpNotFound()) { mastodonApi.createFilterV1( - tag, + hashedTag, listOf(FilterV1.HOME), irreversible = false, wholeWord = true, @@ -248,20 +289,50 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector { ).fold( { filter -> mutedFilterV1 = filter - updateTagMuteState(true) eventHub.dispatch(PreferenceChangedEvent(filter.context[0])) + filterCreateSuccess = true }, { throwable -> - Snackbar.make(binding.root, getString(R.string.error_muting_hashtag_format, tag), Snackbar.LENGTH_SHORT).show() + Snackbar.make( + binding.root, + getString(R.string.error_muting_hashtag_format, tag), + Snackbar.LENGTH_SHORT + ).show() Log.e(TAG, "Failed to mute #$tag", throwable) } ) } else { - Snackbar.make(binding.root, getString(R.string.error_muting_hashtag_format, tag), Snackbar.LENGTH_SHORT).show() + Snackbar.make( + binding.root, + getString(R.string.error_muting_hashtag_format, tag), + Snackbar.LENGTH_SHORT + ).show() Log.e(TAG, "Failed to mute #$tag", throwable) } } ) + + if (filterCreateSuccess) { + updateTagMuteState(true) + Snackbar.make( + binding.root, + getString(R.string.muting_hashtag_success_format, tag), + Snackbar.LENGTH_LONG + ).apply { + setAction(R.string.action_view_filter) { + val intent = if (mutedFilter != null) { + Intent(this@StatusListActivity, EditFilterActivity::class.java).apply { + putExtra(EditFilterActivity.FILTER_TO_EDIT, mutedFilter) + } + } else { + Intent(this@StatusListActivity, FiltersActivity::class.java) + } + + startActivityWithSlideInAnimation(intent) + } + show() + } + } } return true @@ -307,9 +378,19 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector { eventHub.dispatch(PreferenceChangedEvent(Filter.Kind.HOME.kind)) mutedFilterV1 = null mutedFilter = null + + Snackbar.make( + binding.root, + getString(R.string.unmuting_hashtag_success_format, tag), + Snackbar.LENGTH_SHORT + ).show() }, { throwable -> - Snackbar.make(binding.root, getString(R.string.error_unmuting_hashtag_format, tag), Snackbar.LENGTH_SHORT).show() + Snackbar.make( + binding.root, + getString(R.string.error_unmuting_hashtag_format, tag), + Snackbar.LENGTH_SHORT + ).show() Log.e(TAG, "Failed to unmute #$tag", throwable) } ) @@ -351,5 +432,10 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector { putExtra(EXTRA_KIND, Kind.TAG.name) putExtra(EXTRA_HASHTAG, hashtag) } + + fun newTrendingIntent(context: Context) = + Intent(context, StatusListActivity::class.java).apply { + putExtra(EXTRA_KIND, Kind.PUBLIC_TRENDING_STATUSES.name) + } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/TabData.kt b/app/src/main/java/com/keylesspalace/tusky/TabData.kt index 09d1f1cf4..12c0346cb 100644 --- a/app/src/main/java/com/keylesspalace/tusky/TabData.kt +++ b/app/src/main/java/com/keylesspalace/tusky/TabData.kt @@ -20,10 +20,10 @@ import androidx.annotation.DrawableRes import androidx.annotation.StringRes import androidx.fragment.app.Fragment import com.keylesspalace.tusky.components.conversation.ConversationsFragment -import com.keylesspalace.tusky.components.notifications.NotificationsFragment import com.keylesspalace.tusky.components.timeline.TimelineFragment import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel -import com.keylesspalace.tusky.components.trending.TrendingFragment +import com.keylesspalace.tusky.components.trending.TrendingTagsFragment +import com.keylesspalace.tusky.fragment.NotificationsFragment import java.util.Objects /** this would be a good case for a sealed class, but that does not work nice with Room */ @@ -33,9 +33,11 @@ const val NOTIFICATIONS = "Notifications" const val LOCAL = "Local" const val FEDERATED = "Federated" const val DIRECT = "Direct" -const val TRENDING = "Trending" +const val TRENDING_TAGS = "TrendingTags" +const val TRENDING_STATUSES = "TrendingStatuses" const val HASHTAG = "Hashtag" const val LIST = "List" +const val BOOKMARKS = "Bookmarks" data class TabData( val id: String, @@ -52,9 +54,7 @@ data class TabData( other as TabData if (id != other.id) return false - if (arguments != other.arguments) return false - - return true + return arguments == other.arguments } override fun hashCode() = Objects.hash(id, arguments) @@ -94,11 +94,21 @@ fun createTabDataFromId(id: String, arguments: List = emptyList()): TabD icon = R.drawable.ic_reblog_direct_24dp, fragment = { ConversationsFragment.newInstance() } ) - TRENDING -> TabData( - id = TRENDING, + TRENDING_TAGS -> TabData( + id = TRENDING_TAGS, text = R.string.title_public_trending_hashtags, icon = R.drawable.ic_trending_up_24px, - fragment = { TrendingFragment.newInstance() } + fragment = { TrendingTagsFragment.newInstance() } + ) + TRENDING_STATUSES -> TabData( + id = TRENDING_STATUSES, + text = R.string.title_public_trending_statuses, + icon = R.drawable.ic_hot_24dp, + fragment = { + TimelineFragment.newInstance( + TimelineViewModel.Kind.PUBLIC_TRENDING_STATUSES + ) + } ) HASHTAG -> TabData( id = HASHTAG, @@ -106,16 +116,31 @@ fun createTabDataFromId(id: String, arguments: List = emptyList()): TabD icon = R.drawable.ic_hashtag, fragment = { args -> TimelineFragment.newHashtagInstance(args) }, arguments = arguments, - title = { context -> arguments.joinToString(separator = " ") { context.getString(R.string.title_tag, it) } } + title = { context -> + arguments.joinToString(separator = " ") { + context.getString(R.string.title_tag, it) + } + } ) LIST -> TabData( id = LIST, text = R.string.list, icon = R.drawable.ic_list, - fragment = { args -> TimelineFragment.newInstance(TimelineViewModel.Kind.LIST, args.getOrNull(0).orEmpty()) }, + fragment = { args -> + TimelineFragment.newInstance( + TimelineViewModel.Kind.LIST, + args.getOrNull(0).orEmpty() + ) + }, arguments = arguments, title = { arguments.getOrNull(1).orEmpty() } ) + BOOKMARKS -> TabData( + id = BOOKMARKS, + text = R.string.title_bookmarks, + icon = R.drawable.ic_bookmark_active_24dp, + fragment = { TimelineFragment.newInstance(TimelineViewModel.Kind.BOOKMARKS) } + ) else -> throw IllegalArgumentException("unknown tab type") } } diff --git a/app/src/main/java/com/keylesspalace/tusky/TabPreferenceActivity.kt b/app/src/main/java/com/keylesspalace/tusky/TabPreferenceActivity.kt index 096fe3363..23d8450cb 100644 --- a/app/src/main/java/com/keylesspalace/tusky/TabPreferenceActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/TabPreferenceActivity.kt @@ -15,18 +15,10 @@ package com.keylesspalace.tusky -import android.content.Intent import android.graphics.Color import android.os.Bundle -import android.util.Log -import android.view.Gravity import android.view.View -import android.view.ViewGroup -import android.widget.ArrayAdapter import android.widget.FrameLayout -import android.widget.LinearLayout -import android.widget.ProgressBar -import android.widget.TextView import androidx.activity.OnBackPressedCallback import androidx.appcompat.app.AlertDialog import androidx.appcompat.widget.AppCompatEditText @@ -38,34 +30,29 @@ import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import androidx.transition.TransitionManager -import at.connyduck.calladapter.networkresult.fold import at.connyduck.sparkbutton.helpers.Utils -import com.google.android.material.snackbar.Snackbar import com.google.android.material.transition.MaterialArcMotion import com.google.android.material.transition.MaterialContainerTransform import com.keylesspalace.tusky.adapter.ItemInteractionListener import com.keylesspalace.tusky.adapter.TabAdapter import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.MainTabsChangedEvent +import com.keylesspalace.tusky.components.account.list.ListSelectionFragment import com.keylesspalace.tusky.databinding.ActivityTabPreferenceBinding import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.entity.MastoList import com.keylesspalace.tusky.network.MastodonApi -import com.keylesspalace.tusky.util.getDimension -import com.keylesspalace.tusky.util.hide -import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.unsafeLazy import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.util.visible -import kotlinx.coroutines.CoroutineStart -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.awaitCancellation -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch +import dagger.android.DispatchingAndroidInjector +import dagger.android.HasAndroidInjector import java.util.regex.Pattern import javax.inject.Inject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch -class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListener { +class TabPreferenceActivity : BaseActivity(), Injectable, HasAndroidInjector, ItemInteractionListener, ListSelectionFragment.ListSelectionListener { @Inject lateinit var mastodonApi: MastodonApi @@ -73,6 +60,9 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene @Inject lateinit var eventHub: EventHub + @Inject + lateinit var dispatchingAndroidInjector: DispatchingAndroidInjector + private val binding by viewBinding(ActivityTabPreferenceBinding::inflate) private lateinit var currentTabs: MutableList @@ -82,9 +72,13 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene private var tabsChanged = false - private val selectedItemElevation by unsafeLazy { resources.getDimension(R.dimen.selected_drag_item_elevation) } + private val selectedItemElevation by unsafeLazy { + resources.getDimension(R.dimen.selected_drag_item_elevation) + } - private val hashtagRegex by unsafeLazy { Pattern.compile("([\\w_]*[\\p{Alpha}_][\\w_]*)", Pattern.CASE_INSENSITIVE) } + private val hashtagRegex by unsafeLazy { + Pattern.compile("([\\w_]*[\\p{Alpha}_][\\w_]*)", Pattern.CASE_INSENSITIVE) + } private val onFabDismissedCallback = object : OnBackPressedCallback(false) { override fun handleOnBackPressed() { @@ -109,14 +103,19 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene currentTabsAdapter = TabAdapter(currentTabs, false, this, currentTabs.size <= MIN_TAB_COUNT) binding.currentTabsRecyclerView.adapter = currentTabsAdapter binding.currentTabsRecyclerView.layoutManager = LinearLayoutManager(this) - binding.currentTabsRecyclerView.addItemDecoration(DividerItemDecoration(this, LinearLayoutManager.VERTICAL)) + binding.currentTabsRecyclerView.addItemDecoration( + DividerItemDecoration(this, LinearLayoutManager.VERTICAL) + ) addTabAdapter = TabAdapter(listOf(createTabDataFromId(DIRECT)), true, this) binding.addTabRecyclerView.adapter = addTabAdapter binding.addTabRecyclerView.layoutManager = LinearLayoutManager(this) touchHelper = ItemTouchHelper(object : ItemTouchHelper.Callback() { - override fun getMovementFlags(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder): Int { + override fun getMovementFlags( + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder + ): Int { return makeMovementFlags(ItemTouchHelper.UP or ItemTouchHelper.DOWN, ItemTouchHelper.END) } @@ -128,7 +127,11 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene return MIN_TAB_COUNT < currentTabs.size } - override fun onMove(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder): Boolean { + override fun onMove( + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder, + target: RecyclerView.ViewHolder + ): Boolean { val temp = currentTabs[viewHolder.bindingAdapterPosition] currentTabs[viewHolder.bindingAdapterPosition] = currentTabs[target.bindingAdapterPosition] currentTabs[target.bindingAdapterPosition] = temp @@ -148,7 +151,10 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene } } - override fun clearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) { + override fun clearView( + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder + ) { super.clearView(recyclerView, viewHolder) viewHolder.itemView.elevation = 0f } @@ -164,18 +170,12 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene toggleFab(false) } - binding.maxTabsInfo.text = resources.getQuantityString(R.plurals.max_tab_number_reached, MAX_TAB_COUNT, MAX_TAB_COUNT) - updateAvailableTabs() onBackPressedDispatcher.addCallback(onFabDismissedCallback) } override fun onTabAdded(tab: TabData) { - if (currentTabs.size >= MAX_TAB_COUNT) { - return - } - toggleFab(false) if (tab.id == HASHTAG) { @@ -273,81 +273,24 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene editText.requestFocus() } + private var listSelectDialog: ListSelectionFragment? = null + private fun showSelectListDialog() { - val adapter = object : ArrayAdapter(this, android.R.layout.simple_list_item_1) { - override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { - val view = super.getView(position, convertView, parent) - getItem(position)?.let { item -> (view as TextView).text = item.title } - return view - } - } + listSelectDialog = ListSelectionFragment.newInstance(null) + listSelectDialog?.show(supportFragmentManager, null) - val statusLayout = LinearLayout(this) - statusLayout.gravity = Gravity.CENTER - val progress = ProgressBar(this) - val preferredPadding = getDimension(this, androidx.appcompat.R.attr.dialogPreferredPadding) - progress.setPadding(preferredPadding, 0, preferredPadding, 0) - progress.visible(false) - - val noListsText = TextView(this) - noListsText.setPadding(preferredPadding, 0, preferredPadding, 0) - noListsText.text = getText(R.string.select_list_empty) - noListsText.visible(false) - - statusLayout.addView(progress) - statusLayout.addView(noListsText) - - val dialogBuilder = AlertDialog.Builder(this) - .setTitle(R.string.select_list_title) - .setNeutralButton(R.string.select_list_manage) { _, _ -> - val listIntent = Intent(applicationContext, ListsActivity::class.java) - startActivity(listIntent) - } - .setNegativeButton(android.R.string.cancel, null) - .setView(statusLayout) - .setAdapter(adapter) { _, position -> - adapter.getItem(position)?.let { item -> - val newTab = createTabDataFromId(LIST, listOf(item.id, item.title)) - currentTabs.add(newTab) - currentTabsAdapter.notifyItemInserted(currentTabs.size - 1) - updateAvailableTabs() - saveTabs() - } - } - - val showProgressBarJob = getProgressBarJob(progress, 500) - showProgressBarJob.start() - - val dialog = dialogBuilder.show() - - lifecycleScope.launch { - mastodonApi.getLists().fold( - { lists -> - showProgressBarJob.cancel() - adapter.addAll(lists) - if (lists.isEmpty()) { - noListsText.show() - } - }, - { throwable -> - dialog.hide() - Log.e("TabPreferenceActivity", "failed to load lists", throwable) - Snackbar.make(binding.root, R.string.error_list_load, Snackbar.LENGTH_LONG).show() - } - ) - } + return } - private fun getProgressBarJob(progressView: View, delayMs: Long) = this.lifecycleScope.launch( - start = CoroutineStart.LAZY - ) { - try { - delay(delayMs) - progressView.show() - awaitCancellation() - } finally { - progressView.hide() - } + override fun onListSelected(list: MastoList) { + listSelectDialog?.dismiss() + listSelectDialog = null + + val newTab = createTabDataFromId(LIST, listOf(list.id, list.title)) + currentTabs.add(newTab) + currentTabsAdapter.notifyItemInserted(currentTabs.size - 1) + updateAvailableTabs() + saveTabs() } private fun validateHashtag(input: CharSequence?): Boolean { @@ -378,17 +321,23 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene if (!currentTabs.contains(directMessagesTab)) { addableTabs.add(directMessagesTab) } - val trendingTab = createTabDataFromId(TRENDING) - if (!currentTabs.contains(trendingTab)) { - addableTabs.add(trendingTab) + val trendingTagsTab = createTabDataFromId(TRENDING_TAGS) + if (!currentTabs.contains(trendingTagsTab)) { + addableTabs.add(trendingTagsTab) + } + val bookmarksTab = createTabDataFromId(BOOKMARKS) + if (!currentTabs.contains(bookmarksTab)) { + addableTabs.add(bookmarksTab) + } + val trendingStatusesTab = createTabDataFromId(TRENDING_STATUSES) + if (!currentTabs.contains(trendingStatusesTab)) { + addableTabs.add(trendingStatusesTab) } addableTabs.add(createTabDataFromId(HASHTAG)) addableTabs.add(createTabDataFromId(LIST)) addTabAdapter.updateData(addableTabs) - - binding.maxTabsInfo.visible(addableTabs.size == 0 || currentTabs.size >= MAX_TAB_COUNT) currentTabsAdapter.setRemoveButtonVisible(currentTabs.size > MIN_TAB_COUNT) } @@ -419,8 +368,9 @@ class TabPreferenceActivity : BaseActivity(), Injectable, ItemInteractionListene } } + override fun androidInjector() = dispatchingAndroidInjector + companion object { private const val MIN_TAB_COUNT = 2 - private const val MAX_TAB_COUNT = 5 } } diff --git a/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.kt b/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.kt index e7c646991..0476903a8 100644 --- a/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.kt +++ b/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.kt @@ -22,12 +22,13 @@ import androidx.work.Constraints import androidx.work.ExistingPeriodicWorkPolicy import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkManager -import autodispose2.AutoDisposePlugins import com.keylesspalace.tusky.components.notifications.NotificationHelper import com.keylesspalace.tusky.di.AppInjector +import com.keylesspalace.tusky.settings.AppTheme +import com.keylesspalace.tusky.settings.NEW_INSTALL_SCHEMA_VERSION import com.keylesspalace.tusky.settings.PrefKeys +import com.keylesspalace.tusky.settings.PrefKeys.APP_THEME import com.keylesspalace.tusky.settings.SCHEMA_VERSION -import com.keylesspalace.tusky.util.APP_THEME_DEFAULT import com.keylesspalace.tusky.util.LocaleManager import com.keylesspalace.tusky.util.setAppNightMode import com.keylesspalace.tusky.worker.PruneCacheWorker @@ -37,11 +38,10 @@ import dagger.android.HasAndroidInjector import de.c1710.filemojicompat_defaults.DefaultEmojiPackList import de.c1710.filemojicompat_ui.helpers.EmojiPackHelper import de.c1710.filemojicompat_ui.helpers.EmojiPreference -import io.reactivex.rxjava3.plugins.RxJavaPlugins -import org.conscrypt.Conscrypt import java.security.Security import java.util.concurrent.TimeUnit import javax.inject.Inject +import org.conscrypt.Conscrypt class TuskyApplication : Application(), HasAndroidInjector { @Inject @@ -71,12 +71,13 @@ class TuskyApplication : Application(), HasAndroidInjector { Security.insertProviderAt(Conscrypt.newProvider(), 1) - AutoDisposePlugins.setHideProxies(false) // a small performance optimization - AppInjector.init(this) // Migrate shared preference keys and defaults from version to version. - val oldVersion = sharedPreferences.getInt(PrefKeys.SCHEMA_VERSION, 0) + val oldVersion = sharedPreferences.getInt( + PrefKeys.SCHEMA_VERSION, + NEW_INSTALL_SCHEMA_VERSION + ) if (oldVersion != SCHEMA_VERSION) { upgradeSharedPreferences(oldVersion, SCHEMA_VERSION) } @@ -87,15 +88,11 @@ class TuskyApplication : Application(), HasAndroidInjector { EmojiPackHelper.init(this, DefaultEmojiPackList.get(this), allowPackImports = false) // init night mode - val theme = sharedPreferences.getString("appTheme", APP_THEME_DEFAULT) + val theme = sharedPreferences.getString(APP_THEME, AppTheme.DEFAULT.value) setAppNightMode(theme) localeManager.setLocale() - RxJavaPlugins.setErrorHandler { - Log.w("RxJava", "undeliverable exception", it) - } - NotificationHelper.createWorkerNotificationChannel(this) WorkManager.initialize( @@ -130,6 +127,27 @@ class TuskyApplication : Application(), HasAndroidInjector { editor.remove(PrefKeys.MEDIA_PREVIEW_ENABLED) } + if (oldVersion < 2023072401) { + // The notifications filter / clear options are shown on a menu, not a separate bar, + // the preference to display them is not needed. + editor.remove(PrefKeys.Deprecated.SHOW_NOTIFICATIONS_FILTER) + } + + if (oldVersion != NEW_INSTALL_SCHEMA_VERSION && oldVersion < 2023082301) { + // Default value for appTheme is now THEME_SYSTEM. If the user is upgrading and + // didn't have an explicit preference set use the previous default, so the + // theme does not unexpectedly change. + if (!sharedPreferences.contains(APP_THEME)) { + editor.putString(APP_THEME, AppTheme.NIGHT.value) + } + } + + if (oldVersion < 2023112001) { + editor.remove(PrefKeys.TAB_FILTER_HOME_REPLIES) + editor.remove(PrefKeys.TAB_FILTER_HOME_BOOSTS) + editor.remove(PrefKeys.TAB_SHOW_HOME_SELF_BOOSTS) + } + editor.putInt(PrefKeys.SCHEMA_VERSION, newVersion) editor.apply() } diff --git a/app/src/main/java/com/keylesspalace/tusky/ViewMediaActivity.kt b/app/src/main/java/com/keylesspalace/tusky/ViewMediaActivity.kt index 1e017827c..18654ec5c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/ViewMediaActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/ViewMediaActivity.kt @@ -35,19 +35,17 @@ import android.util.Log import android.view.Menu import android.view.MenuItem import android.view.View +import android.view.WindowManager import android.webkit.MimeTypeMap import android.widget.Toast import androidx.core.app.ShareCompat import androidx.core.content.FileProvider import androidx.core.content.IntentCompat import androidx.fragment.app.FragmentActivity -import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope import androidx.viewpager2.adapter.FragmentStateAdapter import androidx.viewpager2.widget.ViewPager2 -import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider -import autodispose2.autoDispose import com.bumptech.glide.Glide -import com.bumptech.glide.request.FutureTarget import com.keylesspalace.tusky.BuildConfig.APPLICATION_ID import com.keylesspalace.tusky.components.viewthread.ViewThreadActivity import com.keylesspalace.tusky.databinding.ActivityViewMediaBinding @@ -57,20 +55,30 @@ import com.keylesspalace.tusky.fragment.ViewVideoFragment import com.keylesspalace.tusky.pager.ImagePagerAdapter import com.keylesspalace.tusky.pager.SingleImagePagerAdapter import com.keylesspalace.tusky.util.getTemporaryMediaFilename +import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation +import com.keylesspalace.tusky.util.submitAsync import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.viewdata.AttachmentViewData -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers -import io.reactivex.rxjava3.core.Single -import io.reactivex.rxjava3.schedulers.Schedulers +import dagger.android.DispatchingAndroidInjector +import dagger.android.HasAndroidInjector import java.io.File -import java.io.FileNotFoundException import java.io.FileOutputStream import java.io.IOException import java.util.Locale +import javax.inject.Inject +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.launch typealias ToolbarVisibilityListener = (isVisible: Boolean) -> Unit -class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener, ViewVideoFragment.VideoActionsListener { +class ViewMediaActivity : + BaseActivity(), + HasAndroidInjector, + ViewImageFragment.PhotoActionsListener, + ViewVideoFragment.VideoActionsListener { + + @Inject + lateinit var androidInjector: DispatchingAndroidInjector private val binding by viewBinding(ActivityViewMediaBinding::inflate) @@ -97,7 +105,11 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener supportPostponeEnterTransition() // Gather the parameters. - attachments = IntentCompat.getParcelableArrayListExtra(intent, EXTRA_ATTACHMENTS, AttachmentViewData::class.java) + attachments = IntentCompat.getParcelableArrayListExtra( + intent, + EXTRA_ATTACHMENTS, + AttachmentViewData::class.java + ) val initialPosition = intent.getIntExtra(EXTRA_ATTACHMENT_INDEX, 0) // Adapter is actually of existential type PageAdapter & SharedElementsTransitionListener @@ -119,6 +131,7 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener binding.viewPager.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() { override fun onPageSelected(position: Int) { binding.toolbar.title = getPageTitle(position) + adjustScreenWakefulness() } }) @@ -150,6 +163,8 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener window.sharedElementEnterTransition.removeListener(this) } }) + + adjustScreenWakefulness() } override fun onCreateOptionsMenu(menu: Menu): Boolean { @@ -206,7 +221,11 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener private fun downloadMedia() { val url = imageUrl ?: attachments!![binding.viewPager.currentItem].attachment.url val filename = Uri.parse(url).lastPathSegment - Toast.makeText(applicationContext, resources.getString(R.string.download_image, filename), Toast.LENGTH_SHORT).show() + Toast.makeText( + applicationContext, + resources.getString(R.string.download_image, filename), + Toast.LENGTH_SHORT + ).show() val downloadManager = getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager val request = DownloadManager.Request(Uri.parse(url)) @@ -216,8 +235,13 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener private fun requestDownloadMedia() { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { - requestPermissions(arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE)) { _, grantResults -> - if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) { + requestPermissions( + arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE) + ) { _, grantResults -> + if ( + grantResults.isNotEmpty() && + grantResults[0] == PackageManager.PERMISSION_GRANTED + ) { downloadMedia() } else { showErrorDialog( @@ -234,7 +258,9 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener private fun onOpenStatus() { val attach = attachments!![binding.viewPager.currentItem] - startActivityWithSlideInAnimation(ViewThreadActivity.startIntent(this, attach.statusId, attach.statusUrl)) + startActivityWithSlideInAnimation( + ViewThreadActivity.startIntent(this, attach.statusId, attach.statusUrl) + ) } private fun copyLink() { @@ -267,7 +293,9 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener private fun shareFile(file: File, mimeType: String?) { ShareCompat.IntentBuilder(this) .setType(mimeType) - .addStream(FileProvider.getUriForFile(applicationContext, "$APPLICATION_ID.fileprovider", file)) + .addStream( + FileProvider.getUriForFile(applicationContext, "$APPLICATION_ID.fileprovider", file) + ) .setChooserTitle(R.string.send_media_to) .startChooser() } @@ -278,46 +306,37 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener isCreating = true binding.progressBarShare.visibility = View.VISIBLE invalidateOptionsMenu() - val file = File(directory, getTemporaryMediaFilename("png")) - val futureTask: FutureTarget = - Glide.with(applicationContext).asBitmap().load(Uri.parse(url)).submit() - Single.fromCallable { - val bitmap = futureTask.get() - try { - val stream = FileOutputStream(file) - bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream) - stream.close() - return@fromCallable true - } catch (fnfe: FileNotFoundException) { - Log.e(TAG, "Error writing temporary media.") - } catch (ioe: IOException) { - Log.e(TAG, "Error writing temporary media.") - } - return@fromCallable false - } - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .doOnDispose { - futureTask.cancel(true) - } - .autoDispose(AndroidLifecycleScopeProvider.from(this, Lifecycle.Event.ON_DESTROY)) - .subscribe( - { result -> - Log.d(TAG, "Download image result: $result") - isCreating = false - invalidateOptionsMenu() - binding.progressBarShare.visibility = View.GONE - if (result) { - shareFile(file, "image/png") + + lifecycleScope.launch { + val file = File(directory, getTemporaryMediaFilename("png")) + val result = try { + val bitmap = + Glide.with(applicationContext).asBitmap().load(Uri.parse(url)).submitAsync() + try { + FileOutputStream(file).use { stream -> + bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream) } - }, - { error -> - isCreating = false - invalidateOptionsMenu() - binding.progressBarShare.visibility = View.GONE - Log.e(TAG, "Failed to download image", error) + true + } catch (ioe: IOException) { + // FileNotFoundException is covered by IOException + Log.e(TAG, "Error writing temporary media.") + false + }.also { result -> Log.d(TAG, "Download image result: $result") } + } catch (error: Throwable) { + if (error is CancellationException) { + throw error } - ) + Log.e(TAG, "Failed to download image", error) + false + } + + isCreating = false + invalidateOptionsMenu() + binding.progressBarShare.visibility = View.GONE + if (result) { + shareFile(file, "image/png") + } + } } private fun shareMediaFile(directory: File, url: String) { @@ -337,6 +356,19 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener shareFile(file, mimeType) } + // Prevent this activity from dimming or sleeping the screen if, and only if, it is playing video or audio + private fun adjustScreenWakefulness() { + attachments?.run { + if (get(binding.viewPager.currentItem).attachment.type == Attachment.Type.IMAGE) { + window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } else { + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } + } + } + + override fun androidInjector() = androidInjector + companion object { private const val EXTRA_ATTACHMENTS = "attachments" private const val EXTRA_ATTACHMENT_INDEX = "index" @@ -344,7 +376,11 @@ class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener private const val TAG = "ViewMediaActivity" @JvmStatic - fun newIntent(context: Context?, attachments: List, index: Int): Intent { + fun newIntent( + context: Context?, + attachments: List, + index: Int + ): Intent { val intent = Intent(context, ViewMediaActivity::class.java) intent.putParcelableArrayListExtra(EXTRA_ATTACHMENTS, ArrayList(attachments)) intent.putExtra(EXTRA_ATTACHMENT_INDEX, index) diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/AccountFieldEditAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/AccountFieldEditAdapter.kt index a57c0aba9..890e956f1 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/AccountFieldEditAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/AccountFieldEditAdapter.kt @@ -24,7 +24,9 @@ import com.keylesspalace.tusky.entity.StringField import com.keylesspalace.tusky.util.BindingHolder import com.keylesspalace.tusky.util.fixTextSelection -class AccountFieldEditAdapter : RecyclerView.Adapter>() { +class AccountFieldEditAdapter( + var onFieldsChanged: () -> Unit = { } +) : RecyclerView.Adapter>() { private val fieldData = mutableListOf() private var maxNameLength: Int? = null @@ -62,8 +64,15 @@ class AccountFieldEditAdapter : RecyclerView.Adapter { - val binding = ItemEditFieldBinding.inflate(LayoutInflater.from(parent.context), parent, false) + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int + ): BindingHolder { + val binding = ItemEditFieldBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) return BindingHolder(binding) } @@ -83,10 +92,12 @@ class AccountFieldEditAdapter : RecyclerView.Adapter fieldData.getOrNull(holder.bindingAdapterPosition)?.first = newText.toString() + onFieldsChanged() } holder.binding.accountFieldValueText.doAfterTextChanged { newText -> fieldData.getOrNull(holder.bindingAdapterPosition)?.second = newText.toString() + onFieldsChanged() } // Ensure the textview contents are selectable diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/AccountSelectionAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/AccountSelectionAdapter.kt index 0b0115c2a..e2f461201 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/AccountSelectionAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/AccountSelectionAdapter.kt @@ -28,7 +28,10 @@ import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.util.emojify import com.keylesspalace.tusky.util.loadAvatar -class AccountSelectionAdapter(context: Context) : ArrayAdapter(context, R.layout.item_autocomplete_account) { +class AccountSelectionAdapter(context: Context) : ArrayAdapter( + context, + R.layout.item_autocomplete_account +) { override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { val binding = if (convertView == null) { @@ -47,7 +50,7 @@ class AccountSelectionAdapter(context: Context) : ArrayAdapter(co binding.avatarBadge.visibility = View.GONE // We never want to display the bot badge here val avatarRadius = context.resources.getDimensionPixelSize(R.dimen.avatar_radius_42dp) - val animateAvatar = pm.getBoolean("animateGifAvatars", false) + val animateAvatar = pm.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false) loadAvatar(account.profilePictureUrl, binding.avatar, avatarRadius, animateAvatar) } diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/EmojiAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/EmojiAdapter.kt index 51aa43f73..f2162fad7 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/EmojiAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/EmojiAdapter.kt @@ -31,13 +31,20 @@ class EmojiAdapter( private val animate: Boolean ) : RecyclerView.Adapter>() { - private val emojiList: List = emojiList.filter { emoji -> emoji.visibleInPicker == null || emoji.visibleInPicker } + private val emojiList: List = emojiList.filter { emoji -> emoji.visibleInPicker } .sortedBy { it.shortcode.lowercase(Locale.ROOT) } override fun getItemCount() = emojiList.size - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder { - val binding = ItemEmojiButtonBinding.inflate(LayoutInflater.from(parent.context), parent, false) + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int + ): BindingHolder { + val binding = ItemEmojiButtonBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) return BindingHolder(binding) } diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/FollowRequestViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/FollowRequestViewHolder.kt index 446536e6e..902d36bcb 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/FollowRequestViewHolder.kt +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/FollowRequestViewHolder.kt @@ -16,17 +16,15 @@ package com.keylesspalace.tusky.adapter import android.graphics.Typeface -import android.text.SpannableStringBuilder +import android.text.SpannableString import android.text.Spanned import android.text.style.StyleSpan import androidx.recyclerview.widget.RecyclerView import com.keylesspalace.tusky.R -import com.keylesspalace.tusky.components.notifications.NotificationsPagingAdapter import com.keylesspalace.tusky.databinding.ItemFollowRequestBinding import com.keylesspalace.tusky.entity.TimelineAccount import com.keylesspalace.tusky.interfaces.AccountActionListener import com.keylesspalace.tusky.interfaces.LinkListener -import com.keylesspalace.tusky.util.StatusDisplayOptions import com.keylesspalace.tusky.util.emojify import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.loadAvatar @@ -35,33 +33,12 @@ import com.keylesspalace.tusky.util.setClickableText import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.unicodeWrap import com.keylesspalace.tusky.util.visible -import com.keylesspalace.tusky.viewdata.NotificationViewData class FollowRequestViewHolder( private val binding: ItemFollowRequestBinding, - private val accountActionListener: AccountActionListener, private val linkListener: LinkListener, private val showHeader: Boolean -) : NotificationsPagingAdapter.ViewHolder, RecyclerView.ViewHolder(binding.root) { - - override fun bind( - viewData: NotificationViewData, - payloads: List<*>?, - statusDisplayOptions: StatusDisplayOptions - ) { - // Skip updates with payloads. That indicates a timestamp update, and - // this view does not have timestamps. - if (!payloads.isNullOrEmpty()) return - - setupWithAccount( - viewData.account, - statusDisplayOptions.animateAvatars, - statusDisplayOptions.animateEmojis, - statusDisplayOptions.showBotOverlay - ) - - setupActionListener(accountActionListener, viewData.account.id) - } +) : RecyclerView.ViewHolder(binding.root) { fun setupWithAccount( account: TimelineAccount, @@ -72,7 +49,7 @@ class FollowRequestViewHolder( val wrappedName = account.name.unicodeWrap() val emojifiedName: CharSequence = wrappedName.emojify( account.emojis, - itemView, + binding.displayNameTextView, animateEmojis ) binding.displayNameTextView.text = emojifiedName @@ -81,17 +58,15 @@ class FollowRequestViewHolder( R.string.notification_follow_request_format, wrappedName ) - binding.notificationTextView.text = SpannableStringBuilder(wholeMessage).apply { - setSpan( - StyleSpan(Typeface.BOLD), - 0, - wrappedName.length, - Spanned.SPAN_EXCLUSIVE_EXCLUSIVE - ) - }.emojify(account.emojis, itemView, animateEmojis) + binding.notificationTextView.text = SpannableString(wholeMessage).apply { + setSpan(StyleSpan(Typeface.BOLD), 0, wrappedName.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + }.emojify(account.emojis, binding.notificationTextView, animateEmojis) } binding.notificationTextView.visible(showHeader) - val formattedUsername = itemView.context.getString(R.string.post_username_format, account.username) + val formattedUsername = itemView.context.getString( + R.string.post_username_format, + account.username + ) binding.usernameTextView.text = formattedUsername if (account.note.isEmpty()) { binding.accountNote.hide() @@ -102,7 +77,9 @@ class FollowRequestViewHolder( .emojify(account.emojis, binding.accountNote, animateEmojis) setClickableText(binding.accountNote, emojifiedNote, emptyList(), null, linkListener) } - val avatarRadius = binding.avatar.context.resources.getDimensionPixelSize(R.dimen.avatar_radius_48dp) + val avatarRadius = binding.avatar.context.resources.getDimensionPixelSize( + R.dimen.avatar_radius_48dp + ) loadAvatar(account.avatar, binding.avatar, avatarRadius, animateAvatar) binding.avatarBadge.visible(showBotOverlay && account.bot) } diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/LocaleAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/LocaleAdapter.kt index daf8381e9..a7803565c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/LocaleAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/LocaleAdapter.kt @@ -26,7 +26,11 @@ import com.keylesspalace.tusky.util.getTuskyDisplayName import com.keylesspalace.tusky.util.modernLanguageCode import java.util.Locale -class LocaleAdapter(context: Context, resource: Int, locales: List) : ArrayAdapter(context, resource, locales) { +class LocaleAdapter(context: Context, resource: Int, locales: List) : ArrayAdapter( + context, + resource, + locales +) { override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { return (super.getView(position, convertView, parent) as TextView).apply { setTextColor(MaterialColors.getColor(this, android.R.attr.textColorTertiary)) diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java b/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java new file mode 100644 index 000000000..179464543 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/NotificationsAdapter.java @@ -0,0 +1,708 @@ +/* Copyright 2021 Tusky Contributors + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.adapter; + +import android.content.Context; +import android.graphics.Color; +import android.graphics.PorterDuff; +import android.graphics.Typeface; +import android.graphics.drawable.Drawable; +import android.text.InputFilter; +import android.text.SpannableStringBuilder; +import android.text.Spanned; +import android.text.TextUtils; +import android.text.style.StyleSpan; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.annotation.ColorRes; +import androidx.annotation.DrawableRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.content.ContextCompat; +import androidx.recyclerview.widget.RecyclerView; + +import com.bumptech.glide.Glide; +import com.keylesspalace.tusky.R; +import com.keylesspalace.tusky.databinding.ItemFollowRequestBinding; +import com.keylesspalace.tusky.databinding.ItemReportNotificationBinding; +import com.keylesspalace.tusky.entity.Emoji; +import com.keylesspalace.tusky.entity.Notification; +import com.keylesspalace.tusky.entity.Status; +import com.keylesspalace.tusky.entity.TimelineAccount; +import com.keylesspalace.tusky.interfaces.AccountActionListener; +import com.keylesspalace.tusky.interfaces.LinkListener; +import com.keylesspalace.tusky.interfaces.StatusActionListener; +import com.keylesspalace.tusky.util.AbsoluteTimeFormatter; +import com.keylesspalace.tusky.util.CardViewMode; +import com.keylesspalace.tusky.util.CustomEmojiHelper; +import com.keylesspalace.tusky.util.ImageLoadingHelper; +import com.keylesspalace.tusky.util.LinkHelper; +import com.keylesspalace.tusky.util.SmartLengthInputFilter; +import com.keylesspalace.tusky.util.StatusDisplayOptions; +import com.keylesspalace.tusky.util.StringUtils; +import com.keylesspalace.tusky.util.TimestampUtils; +import com.keylesspalace.tusky.viewdata.NotificationViewData; +import com.keylesspalace.tusky.viewdata.StatusViewData; + +import java.util.Date; +import java.util.List; + +import at.connyduck.sparkbutton.helpers.Utils; + +public class NotificationsAdapter extends RecyclerView.Adapter implements LinkListener{ + + public interface AdapterDataSource { + int getItemCount(); + + T getItemAt(int pos); + } + + + private static final int VIEW_TYPE_STATUS = 0; + private static final int VIEW_TYPE_STATUS_NOTIFICATION = 1; + private static final int VIEW_TYPE_FOLLOW = 2; + private static final int VIEW_TYPE_FOLLOW_REQUEST = 3; + private static final int VIEW_TYPE_PLACEHOLDER = 4; + private static final int VIEW_TYPE_REPORT = 5; + private static final int VIEW_TYPE_UNKNOWN = 6; + + private static final InputFilter[] COLLAPSE_INPUT_FILTER = new InputFilter[]{SmartLengthInputFilter.INSTANCE}; + private static final InputFilter[] NO_INPUT_FILTER = new InputFilter[0]; + + private final String accountId; + private StatusDisplayOptions statusDisplayOptions; + private final StatusActionListener statusListener; + private final NotificationActionListener notificationActionListener; + private final AccountActionListener accountActionListener; + private final AdapterDataSource dataSource; + private final AbsoluteTimeFormatter absoluteTimeFormatter = new AbsoluteTimeFormatter(); + + public NotificationsAdapter(String accountId, + AdapterDataSource dataSource, + StatusDisplayOptions statusDisplayOptions, + StatusActionListener statusListener, + NotificationActionListener notificationActionListener, + AccountActionListener accountActionListener) { + + this.accountId = accountId; + this.dataSource = dataSource; + this.statusDisplayOptions = statusDisplayOptions; + this.statusListener = statusListener; + this.notificationActionListener = notificationActionListener; + this.accountActionListener = accountActionListener; + } + + @NonNull + @Override + public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + LayoutInflater inflater = LayoutInflater.from(parent.getContext()); + switch (viewType) { + case VIEW_TYPE_STATUS: { + View view = inflater + .inflate(R.layout.item_status, parent, false); + return new StatusViewHolder(view); + } + case VIEW_TYPE_STATUS_NOTIFICATION: { + View view = inflater + .inflate(R.layout.item_status_notification, parent, false); + return new StatusNotificationViewHolder(view, statusDisplayOptions, absoluteTimeFormatter); + } + case VIEW_TYPE_FOLLOW: { + View view = inflater + .inflate(R.layout.item_follow, parent, false); + return new FollowViewHolder(view, statusDisplayOptions); + } + case VIEW_TYPE_FOLLOW_REQUEST: { + ItemFollowRequestBinding binding = ItemFollowRequestBinding.inflate(inflater, parent, false); + return new FollowRequestViewHolder(binding, this, true); + } + case VIEW_TYPE_PLACEHOLDER: { + View view = inflater + .inflate(R.layout.item_status_placeholder, parent, false); + return new PlaceholderViewHolder(view); + } + case VIEW_TYPE_REPORT: { + ItemReportNotificationBinding binding = ItemReportNotificationBinding.inflate(inflater, parent, false); + return new ReportNotificationViewHolder(binding); + } + default: + case VIEW_TYPE_UNKNOWN: { + View view = new View(parent.getContext()); + view.setLayoutParams( + new ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + Utils.dpToPx(parent.getContext(), 24) + ) + ); + return new RecyclerView.ViewHolder(view) { + }; + } + } + } + + @Override + public void onBindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position) { + bindViewHolder(viewHolder, position, null); + } + + @Override + public void onBindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position, @NonNull List payloads) { + bindViewHolder(viewHolder, position, payloads); + } + + private void bindViewHolder(@NonNull RecyclerView.ViewHolder viewHolder, int position, @Nullable List payloads) { + Object payloadForHolder = payloads != null && !payloads.isEmpty() ? payloads.get(0) : null; + if (position < this.dataSource.getItemCount()) { + NotificationViewData notification = dataSource.getItemAt(position); + if (notification instanceof NotificationViewData.Placeholder) { + if (payloadForHolder == null) { + NotificationViewData.Placeholder placeholder = ((NotificationViewData.Placeholder) notification); + PlaceholderViewHolder holder = (PlaceholderViewHolder) viewHolder; + holder.setup(statusListener, placeholder.isLoading()); + } + return; + } + NotificationViewData.Concrete concreteNotification = + (NotificationViewData.Concrete) notification; + switch (viewHolder.getItemViewType()) { + case VIEW_TYPE_STATUS: { + StatusViewHolder holder = (StatusViewHolder) viewHolder; + StatusViewData.Concrete status = concreteNotification.getStatusViewData(); + if (status == null) { + /* in some very rare cases servers sends null status even though they should not, + * we have to handle it somehow */ + holder.showStatusContent(false); + } else { + if (payloads == null) { + holder.showStatusContent(true); + } + holder.setupWithStatus(status, statusListener, statusDisplayOptions, payloadForHolder); + } + if (concreteNotification.getType() == Notification.Type.POLL) { + holder.setPollInfo(accountId.equals(concreteNotification.getAccount().getId())); + } else { + holder.hideStatusInfo(); + } + break; + } + case VIEW_TYPE_STATUS_NOTIFICATION: { + StatusNotificationViewHolder holder = (StatusNotificationViewHolder) viewHolder; + StatusViewData.Concrete statusViewData = concreteNotification.getStatusViewData(); + if (payloadForHolder == null) { + if (statusViewData == null) { + /* in some very rare cases servers sends null status even though they should not, + * we have to handle it somehow */ + holder.showNotificationContent(false); + } else { + holder.showNotificationContent(true); + + Status status = statusViewData.getActionable(); + holder.setDisplayName(status.getAccount().getDisplayName(), status.getAccount().getEmojis()); + holder.setUsername(status.getAccount().getUsername()); + holder.setCreatedAt(status.getCreatedAt()); + + if (concreteNotification.getType() == Notification.Type.STATUS || + concreteNotification.getType() == Notification.Type.UPDATE) { + holder.setAvatar(status.getAccount().getAvatar(), status.getAccount().getBot()); + } else { + holder.setAvatars(status.getAccount().getAvatar(), + concreteNotification.getAccount().getAvatar()); + } + } + + holder.setMessage(concreteNotification, statusListener); + holder.setupButtons(notificationActionListener, + concreteNotification.getAccount().getId(), + concreteNotification.getId()); + } else { + if (payloadForHolder instanceof List) + for (Object item : (List) payloadForHolder) { + if (StatusBaseViewHolder.Key.KEY_CREATED.equals(item) && statusViewData != null) { + holder.setCreatedAt(statusViewData.getStatus().getActionableStatus().getCreatedAt()); + } + } + } + break; + } + case VIEW_TYPE_FOLLOW: { + if (payloadForHolder == null) { + FollowViewHolder holder = (FollowViewHolder) viewHolder; + holder.setMessage(concreteNotification.getAccount(), concreteNotification.getType() == Notification.Type.SIGN_UP); + holder.setupButtons(notificationActionListener, concreteNotification.getAccount().getId()); + } + break; + } + case VIEW_TYPE_FOLLOW_REQUEST: { + if (payloadForHolder == null) { + FollowRequestViewHolder holder = (FollowRequestViewHolder) viewHolder; + holder.setupWithAccount(concreteNotification.getAccount(), statusDisplayOptions.animateAvatars(), statusDisplayOptions.animateEmojis(), statusDisplayOptions.showBotOverlay()); + holder.setupActionListener(accountActionListener, concreteNotification.getAccount().getId()); + } + break; + } + case VIEW_TYPE_REPORT: { + if (payloadForHolder == null) { + ReportNotificationViewHolder holder = (ReportNotificationViewHolder) viewHolder; + holder.setupWithReport(concreteNotification.getAccount(), concreteNotification.getReport(), statusDisplayOptions.animateAvatars(), statusDisplayOptions.animateEmojis()); + holder.setupActionListener(notificationActionListener, concreteNotification.getReport().getTargetAccount().getId(), concreteNotification.getAccount().getId(), concreteNotification.getReport().getId()); + } + } + default: + } + } + } + + @Override + public int getItemCount() { + return dataSource.getItemCount(); + } + + public void setMediaPreviewEnabled(boolean mediaPreviewEnabled) { + this.statusDisplayOptions = statusDisplayOptions.copy( + statusDisplayOptions.animateAvatars(), + mediaPreviewEnabled, + statusDisplayOptions.useAbsoluteTime(), + statusDisplayOptions.showBotOverlay(), + statusDisplayOptions.useBlurhash(), + CardViewMode.NONE, + statusDisplayOptions.confirmReblogs(), + statusDisplayOptions.confirmFavourites(), + statusDisplayOptions.hideStats(), + statusDisplayOptions.animateEmojis(), + statusDisplayOptions.showStatsInline(), + statusDisplayOptions.showSensitiveMedia(), + statusDisplayOptions.openSpoiler() + ); + } + + public boolean isMediaPreviewEnabled() { + return this.statusDisplayOptions.mediaPreviewEnabled(); + } + + @Override + public int getItemViewType(int position) { + NotificationViewData notification = dataSource.getItemAt(position); + if (notification instanceof NotificationViewData.Concrete) { + NotificationViewData.Concrete concrete = ((NotificationViewData.Concrete) notification); + switch (concrete.getType()) { + case MENTION: + case POLL: { + return VIEW_TYPE_STATUS; + } + case STATUS: + case FAVOURITE: + case REBLOG: + case UPDATE: { + return VIEW_TYPE_STATUS_NOTIFICATION; + } + case FOLLOW: + case SIGN_UP: { + return VIEW_TYPE_FOLLOW; + } + case FOLLOW_REQUEST: { + return VIEW_TYPE_FOLLOW_REQUEST; + } + case REPORT: { + return VIEW_TYPE_REPORT; + } + default: { + return VIEW_TYPE_UNKNOWN; + } + } + } else if (notification instanceof NotificationViewData.Placeholder) { + return VIEW_TYPE_PLACEHOLDER; + } else { + throw new AssertionError("Unknown notification type"); + } + + + } + + public interface NotificationActionListener { + void onViewAccount(String id); + + void onViewStatusForNotificationId(String notificationId); + + void onViewReport(String reportId); + + void onExpandedChange(boolean expanded, int position); + + /** + * Called when the status {@link android.widget.ToggleButton} responsible for collapsing long + * status content is interacted with. + * + * @param isCollapsed Whether the status content is shown in a collapsed state or fully. + * @param position The position of the status in the list. + */ + void onNotificationContentCollapsedChange(boolean isCollapsed, int position); + } + + private static class FollowViewHolder extends RecyclerView.ViewHolder { + private final TextView message; + private final TextView usernameView; + private final TextView displayNameView; + private final ImageView avatar; + private final StatusDisplayOptions statusDisplayOptions; + + FollowViewHolder(View itemView, StatusDisplayOptions statusDisplayOptions) { + super(itemView); + message = itemView.findViewById(R.id.notification_text); + usernameView = itemView.findViewById(R.id.notification_username); + displayNameView = itemView.findViewById(R.id.notification_display_name); + avatar = itemView.findViewById(R.id.notification_avatar); + this.statusDisplayOptions = statusDisplayOptions; + } + + void setMessage(TimelineAccount account, Boolean isSignUp) { + Context context = message.getContext(); + + String format = context.getString(isSignUp ? R.string.notification_sign_up_format : R.string.notification_follow_format); + String wrappedDisplayName = StringUtils.unicodeWrap(account.getName()); + String wholeMessage = String.format(format, wrappedDisplayName); + CharSequence emojifiedMessage = CustomEmojiHelper.emojify( + wholeMessage, account.getEmojis(), message, statusDisplayOptions.animateEmojis() + ); + message.setText(emojifiedMessage); + + String username = context.getString(R.string.post_username_format, account.getUsername()); + usernameView.setText(username); + + CharSequence emojifiedDisplayName = CustomEmojiHelper.emojify( + wrappedDisplayName, account.getEmojis(), usernameView, statusDisplayOptions.animateEmojis() + ); + + displayNameView.setText(emojifiedDisplayName); + + int avatarRadius = avatar.getContext().getResources() + .getDimensionPixelSize(R.dimen.avatar_radius_42dp); + + ImageLoadingHelper.loadAvatar(account.getAvatar(), avatar, avatarRadius, + statusDisplayOptions.animateAvatars(), null); + + } + + void setupButtons(final NotificationActionListener listener, final String accountId) { + itemView.setOnClickListener(v -> listener.onViewAccount(accountId)); + } + } + + private static class StatusNotificationViewHolder extends RecyclerView.ViewHolder + implements View.OnClickListener { + + private final View container; + private final TextView message; +// private final View statusNameBar; + private final TextView displayName; + private final TextView username; + private final TextView timestampInfo; + private final TextView statusContent; + private final ImageView statusAvatar; + private final ImageView notificationAvatar; + private final TextView contentWarningDescriptionTextView; + private final Button contentWarningButton; + private final Button contentCollapseButton; // TODO: This code SHOULD be based on StatusBaseViewHolder + private final StatusDisplayOptions statusDisplayOptions; + private final AbsoluteTimeFormatter absoluteTimeFormatter; + + private String accountId; + private String notificationId; + private NotificationActionListener notificationActionListener; + private StatusViewData.Concrete statusViewData; + + private final int avatarRadius48dp; + private final int avatarRadius36dp; + private final int avatarRadius24dp; + + StatusNotificationViewHolder( + View itemView, + StatusDisplayOptions statusDisplayOptions, + AbsoluteTimeFormatter absoluteTimeFormatter + ) { + super(itemView); + message = itemView.findViewById(R.id.notification_top_text); +// statusNameBar = itemView.findViewById(R.id.status_name_bar); + displayName = itemView.findViewById(R.id.status_display_name); + username = itemView.findViewById(R.id.status_username); + timestampInfo = itemView.findViewById(R.id.status_meta_info); + statusContent = itemView.findViewById(R.id.notification_content); + statusAvatar = itemView.findViewById(R.id.notification_status_avatar); + notificationAvatar = itemView.findViewById(R.id.notification_notification_avatar); + contentWarningDescriptionTextView = itemView.findViewById(R.id.notification_content_warning_description); + contentWarningButton = itemView.findViewById(R.id.notification_content_warning_button); + contentCollapseButton = itemView.findViewById(R.id.button_toggle_notification_content); + + container = itemView.findViewById(R.id.notification_container); + + this.statusDisplayOptions = statusDisplayOptions; + this.absoluteTimeFormatter = absoluteTimeFormatter; + + int darkerFilter = Color.rgb(123, 123, 123); + statusAvatar.setColorFilter(darkerFilter, PorterDuff.Mode.MULTIPLY); + notificationAvatar.setColorFilter(darkerFilter, PorterDuff.Mode.MULTIPLY); + + itemView.setOnClickListener(this); + message.setOnClickListener(this); + statusContent.setOnClickListener(this); + + this.avatarRadius48dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_48dp); + this.avatarRadius36dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_36dp); + this.avatarRadius24dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_24dp); + } + + private void showNotificationContent(boolean show) { +// statusNameBar.setVisibility(show ? View.VISIBLE : View.GONE); + contentWarningDescriptionTextView.setVisibility(show ? View.VISIBLE : View.GONE); + contentWarningButton.setVisibility(show ? View.VISIBLE : View.GONE); + statusContent.setVisibility(show ? View.VISIBLE : View.GONE); + statusAvatar.setVisibility(show ? View.VISIBLE : View.GONE); + notificationAvatar.setVisibility(show ? View.VISIBLE : View.GONE); + } + + private void setDisplayName(String name, List emojis) { + CharSequence emojifiedName = CustomEmojiHelper.emojify(name, emojis, displayName, statusDisplayOptions.animateEmojis()); + displayName.setText(emojifiedName); + } + + private void setUsername(String name) { + Context context = username.getContext(); + String format = context.getString(R.string.post_username_format); + String usernameText = String.format(format, name); + username.setText(usernameText); + } + + protected void setCreatedAt(@Nullable Date createdAt) { + if (statusDisplayOptions.useAbsoluteTime()) { + timestampInfo.setText(absoluteTimeFormatter.format(createdAt, true)); + } else { + // This is the visible timestampInfo. + String readout; + /* This one is for screen-readers. Frequently, they would mispronounce timestamps like "17m" + * as 17 meters instead of minutes. */ + CharSequence readoutAloud; + if (createdAt != null) { + long then = createdAt.getTime(); + long now = new Date().getTime(); + readout = TimestampUtils.getRelativeTimeSpanString(timestampInfo.getContext(), then, now); + readoutAloud = android.text.format.DateUtils.getRelativeTimeSpanString(then, now, + android.text.format.DateUtils.SECOND_IN_MILLIS, + android.text.format.DateUtils.FORMAT_ABBREV_RELATIVE); + } else { + // unknown minutes~ + readout = "?m"; + readoutAloud = "? minutes"; + } + timestampInfo.setText(readout); + timestampInfo.setContentDescription(readoutAloud); + } + } + + Drawable getIconWithColor(Context context, @DrawableRes int drawable, @ColorRes int color) { + Drawable icon = ContextCompat.getDrawable(context, drawable); + if (icon != null) { + icon.setColorFilter(context.getColor(color), PorterDuff.Mode.SRC_ATOP); + } + return icon; + } + + void setMessage(NotificationViewData.Concrete notificationViewData, LinkListener listener) { + this.statusViewData = notificationViewData.getStatusViewData(); + + String displayName = StringUtils.unicodeWrap(notificationViewData.getAccount().getName()); + Notification.Type type = notificationViewData.getType(); + + Context context = message.getContext(); + String format; + Drawable icon; + switch (type) { + default: + case FAVOURITE: { + icon = getIconWithColor(context, R.drawable.ic_star_24dp, R.color.tusky_orange); + format = context.getString(R.string.notification_favourite_format); + break; + } + case REBLOG: { + icon = getIconWithColor(context, R.drawable.ic_repeat_24dp, R.color.tusky_blue); + format = context.getString(R.string.notification_reblog_format); + break; + } + case STATUS: { + icon = getIconWithColor(context, R.drawable.ic_home_24dp, R.color.tusky_blue); + format = context.getString(R.string.notification_subscription_format); + break; + } + case UPDATE: { + icon = getIconWithColor(context, R.drawable.ic_edit_24dp, R.color.tusky_blue); + format = context.getString(R.string.notification_update_format); + break; + } + } + message.setCompoundDrawablesWithIntrinsicBounds(icon, null, null, null); + String wholeMessage = String.format(format, displayName); + final SpannableStringBuilder str = new SpannableStringBuilder(wholeMessage); + int displayNameIndex = format.indexOf("%s"); + str.setSpan( + new StyleSpan(Typeface.BOLD), + displayNameIndex, + displayNameIndex + displayName.length(), + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE + ); + CharSequence emojifiedText = CustomEmojiHelper.emojify( + str, notificationViewData.getAccount().getEmojis(), message, statusDisplayOptions.animateEmojis() + ); + message.setText(emojifiedText); + + if (statusViewData != null) { + boolean hasSpoiler = !TextUtils.isEmpty(statusViewData.getStatus().getSpoilerText()); + contentWarningDescriptionTextView.setVisibility(hasSpoiler ? View.VISIBLE : View.GONE); + contentWarningButton.setVisibility(hasSpoiler ? View.VISIBLE : View.GONE); + if (statusViewData.isExpanded()) { + contentWarningButton.setText(R.string.post_content_warning_show_less); + } else { + contentWarningButton.setText(R.string.post_content_warning_show_more); + } + + contentWarningButton.setOnClickListener(view -> { + if (getBindingAdapterPosition() != RecyclerView.NO_POSITION) { + notificationActionListener.onExpandedChange(!statusViewData.isExpanded(), getBindingAdapterPosition()); + } + statusContent.setVisibility(statusViewData.isExpanded() ? View.GONE : View.VISIBLE); + }); + + setupContentAndSpoiler(listener); + } + + } + + void setupButtons(final NotificationActionListener listener, final String accountId, + final String notificationId) { + this.notificationActionListener = listener; + this.accountId = accountId; + this.notificationId = notificationId; + } + + void setAvatar(@Nullable String statusAvatarUrl, boolean isBot) { + statusAvatar.setPaddingRelative(0, 0, 0, 0); + + ImageLoadingHelper.loadAvatar(statusAvatarUrl, + statusAvatar, avatarRadius48dp, statusDisplayOptions.animateAvatars(), null); + + if (statusDisplayOptions.showBotOverlay() && isBot) { + notificationAvatar.setVisibility(View.VISIBLE); + Glide.with(notificationAvatar) + .load(R.drawable.bot_badge) + .into(notificationAvatar); + + } else { + notificationAvatar.setVisibility(View.GONE); + } + } + + void setAvatars(@Nullable String statusAvatarUrl, @Nullable String notificationAvatarUrl) { + int padding = Utils.dpToPx(statusAvatar.getContext(), 12); + statusAvatar.setPaddingRelative(0, 0, padding, padding); + + ImageLoadingHelper.loadAvatar(statusAvatarUrl, + statusAvatar, avatarRadius36dp, statusDisplayOptions.animateAvatars(), null); + + notificationAvatar.setVisibility(View.VISIBLE); + ImageLoadingHelper.loadAvatar(notificationAvatarUrl, notificationAvatar, + avatarRadius24dp, statusDisplayOptions.animateAvatars(), null); + } + + @Override + public void onClick(View v) { + if (notificationActionListener == null) + return; + + if (v == container || v == statusContent) { + notificationActionListener.onViewStatusForNotificationId(notificationId); + } + else if (v == message) { + notificationActionListener.onViewAccount(accountId); + } + } + + private void setupContentAndSpoiler(final LinkListener listener) { + + boolean shouldShowContentIfSpoiler = statusViewData.isExpanded(); + boolean hasSpoiler = !TextUtils.isEmpty(statusViewData.getStatus(). getSpoilerText()); + if (!shouldShowContentIfSpoiler && hasSpoiler) { + statusContent.setVisibility(View.GONE); + } else { + statusContent.setVisibility(View.VISIBLE); + } + + Spanned content = statusViewData.getContent(); + List emojis = statusViewData.getActionable().getEmojis(); + + if (statusViewData.isCollapsible() && (statusViewData.isExpanded() || !hasSpoiler)) { + contentCollapseButton.setOnClickListener(view -> { + int position = getBindingAdapterPosition(); + if (position != RecyclerView.NO_POSITION && notificationActionListener != null) { + notificationActionListener.onNotificationContentCollapsedChange(!statusViewData.isCollapsed(), position); + } + }); + + contentCollapseButton.setVisibility(View.VISIBLE); + if (statusViewData.isCollapsed()) { + contentCollapseButton.setText(R.string.post_content_warning_show_more); + statusContent.setFilters(COLLAPSE_INPUT_FILTER); + } else { + contentCollapseButton.setText(R.string.post_content_warning_show_less); + statusContent.setFilters(NO_INPUT_FILTER); + } + } else { + contentCollapseButton.setVisibility(View.GONE); + statusContent.setFilters(NO_INPUT_FILTER); + } + + CharSequence emojifiedText = CustomEmojiHelper.emojify( + content, emojis, statusContent, statusDisplayOptions.animateEmojis() + ); + LinkHelper.setClickableText(statusContent, emojifiedText, statusViewData.getActionable().getMentions(), statusViewData.getActionable().getTags(), listener); + + CharSequence emojifiedContentWarning = CustomEmojiHelper.emojify( + statusViewData.getStatus().getSpoilerText(), + statusViewData.getActionable().getEmojis(), + contentWarningDescriptionTextView, + statusDisplayOptions.animateEmojis() + ); + contentWarningDescriptionTextView.setText(emojifiedContentWarning); + } + + } + + + @Override + public void onViewTag(@NonNull String tag) { + + } + + @Override + public void onViewAccount(@NonNull String id) { + + } + + @Override + public void onViewUrl(@NonNull String url) { + + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/PollAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/PollAdapter.kt index 3e4e1dad9..aac3c8159 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/PollAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/PollAdapter.kt @@ -67,7 +67,10 @@ class PollAdapter : RecyclerView.Adapter>() { .map { pollOptions.indexOf(it) } } - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder { + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int + ): BindingHolder { val binding = ItemPollBinding.inflate(LayoutInflater.from(parent.context), parent, false) return BindingHolder(binding) } diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/PreviewPollOptionsAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/PreviewPollOptionsAdapter.kt index 6b59672d1..10c2f14f7 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/PreviewPollOptionsAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/PreviewPollOptionsAdapter.kt @@ -40,7 +40,11 @@ class PreviewPollOptionsAdapter : RecyclerView.Adapter() { } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PreviewViewHolder { - return PreviewViewHolder(LayoutInflater.from(parent.context).inflate(R.layout.item_poll_preview_option, parent, false)) + return PreviewViewHolder( + LayoutInflater.from( + parent.context + ).inflate(R.layout.item_poll_preview_option, parent, false) + ) } override fun getItemCount() = options.size diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/ReportNotificationViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/ReportNotificationViewHolder.kt index 7502c24e9..b894c372d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/ReportNotificationViewHolder.kt +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/ReportNotificationViewHolder.kt @@ -20,76 +20,33 @@ import androidx.core.content.ContextCompat import androidx.recyclerview.widget.RecyclerView import at.connyduck.sparkbutton.helpers.Utils import com.keylesspalace.tusky.R -import com.keylesspalace.tusky.components.notifications.NotificationActionListener -import com.keylesspalace.tusky.components.notifications.NotificationsPagingAdapter +import com.keylesspalace.tusky.adapter.NotificationsAdapter.NotificationActionListener import com.keylesspalace.tusky.databinding.ItemReportNotificationBinding import com.keylesspalace.tusky.entity.Report import com.keylesspalace.tusky.entity.TimelineAccount -import com.keylesspalace.tusky.util.StatusDisplayOptions import com.keylesspalace.tusky.util.emojify import com.keylesspalace.tusky.util.getRelativeTimeSpanString import com.keylesspalace.tusky.util.loadAvatar import com.keylesspalace.tusky.util.unicodeWrap -import com.keylesspalace.tusky.viewdata.NotificationViewData -import java.util.Date class ReportNotificationViewHolder( - private val binding: ItemReportNotificationBinding, - private val notificationActionListener: NotificationActionListener -) : NotificationsPagingAdapter.ViewHolder, RecyclerView.ViewHolder(binding.root) { + private val binding: ItemReportNotificationBinding +) : RecyclerView.ViewHolder(binding.root) { - override fun bind( - viewData: NotificationViewData, - payloads: List<*>?, - statusDisplayOptions: StatusDisplayOptions - ) { - // Skip updates with payloads. That indicates a timestamp update, and - // this view does not have timestamps. - if (!payloads.isNullOrEmpty()) return - - setupWithReport( - viewData.account, - viewData.report!!, - statusDisplayOptions.animateAvatars, - statusDisplayOptions.animateEmojis - ) - setupActionListener( - notificationActionListener, - viewData.report.targetAccount.id, - viewData.account.id, - viewData.report.id - ) - } - - private fun setupWithReport( + fun setupWithReport( reporter: TimelineAccount, report: Report, animateAvatar: Boolean, animateEmojis: Boolean ) { - val reporterName = reporter.name.unicodeWrap().emojify( - reporter.emojis, - binding.root, - animateEmojis - ) - val reporteeName = report.targetAccount.name.unicodeWrap().emojify( - report.targetAccount.emojis, - itemView, - animateEmojis - ) - val icon = ContextCompat.getDrawable(binding.root.context, R.drawable.ic_flag_24dp) + val reporterName = reporter.name.unicodeWrap().emojify(reporter.emojis, binding.notificationTopText, animateEmojis) + val reporteeName = report.targetAccount.name.unicodeWrap().emojify(report.targetAccount.emojis, binding.notificationTopText, animateEmojis) + + val icon = ContextCompat.getDrawable(itemView.context, R.drawable.ic_flag_24dp) binding.notificationTopText.setCompoundDrawablesWithIntrinsicBounds(icon, null, null, null) - binding.notificationTopText.text = itemView.context.getString( - R.string.notification_header_report_format, - reporterName, - reporteeName - ) - binding.notificationSummary.text = itemView.context.getString( - R.string.notification_summary_report_format, - getRelativeTimeSpanString(itemView.context, report.createdAt.time, Date().time), - report.status_ids?.size ?: 0 - ) + binding.notificationTopText.text = itemView.context.getString(R.string.notification_header_report_format, reporterName, reporteeName) + binding.notificationSummary.text = itemView.context.getString(R.string.notification_summary_report_format, getRelativeTimeSpanString(itemView.context, report.createdAt.time, System.currentTimeMillis()), report.statusIds?.size ?: 0) binding.notificationCategory.text = getTranslatedCategory(itemView.context, report.category) // Fancy avatar inset @@ -110,7 +67,7 @@ class ReportNotificationViewHolder( ) } - private fun setupActionListener( + fun setupActionListener( listener: NotificationActionListener, reporteeId: String, reporterId: String, diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java index de4d23802..03267c49b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java @@ -48,13 +48,16 @@ import com.keylesspalace.tusky.entity.Filter; import com.keylesspalace.tusky.entity.FilterResult; import com.keylesspalace.tusky.entity.HashTag; import com.keylesspalace.tusky.entity.Status; +import com.keylesspalace.tusky.entity.Translation; import com.keylesspalace.tusky.interfaces.StatusActionListener; import com.keylesspalace.tusky.util.AbsoluteTimeFormatter; import com.keylesspalace.tusky.util.AttachmentHelper; import com.keylesspalace.tusky.util.CardViewMode; +import com.keylesspalace.tusky.util.CompositeWithOpaqueBackground; import com.keylesspalace.tusky.util.CustomEmojiHelper; import com.keylesspalace.tusky.util.ImageLoadingHelper; import com.keylesspalace.tusky.util.LinkHelper; +import com.keylesspalace.tusky.util.LocaleUtilsKt; import com.keylesspalace.tusky.util.NumberUtils; import com.keylesspalace.tusky.util.StatusDisplayOptions; import com.keylesspalace.tusky.util.TimestampUtils; @@ -65,10 +68,13 @@ import com.keylesspalace.tusky.viewdata.PollOptionViewData; import com.keylesspalace.tusky.viewdata.PollViewData; import com.keylesspalace.tusky.viewdata.PollViewDataKt; import com.keylesspalace.tusky.viewdata.StatusViewData; +import com.keylesspalace.tusky.viewdata.TranslationViewData; import java.text.NumberFormat; +import java.util.Collections; import java.util.Date; import java.util.List; +import java.util.Locale; import at.connyduck.sparkbutton.SparkButton; import at.connyduck.sparkbutton.helpers.Utils; @@ -114,10 +120,13 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { private final TextView cardDescription; private final TextView cardUrl; private final PollAdapter pollAdapter; - protected LinearLayout filteredPlaceholder; - protected TextView filteredPlaceholderLabel; - protected Button filteredPlaceholderShowButton; - protected ConstraintLayout statusContainer; + protected final LinearLayout filteredPlaceholder; + protected final TextView filteredPlaceholderLabel; + protected final Button filteredPlaceholderShowButton; + protected final ConstraintLayout statusContainer; + private final TextView translationStatusView; + private final Button untranslateButton; + private final NumberFormat numberFormat = NumberFormat.getNumberInstance(); private final AbsoluteTimeFormatter absoluteTimeFormatter = new AbsoluteTimeFormatter(); @@ -128,7 +137,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { private final Drawable mediaPreviewUnloaded; - protected StatusBaseViewHolder(View itemView) { + protected StatusBaseViewHolder(@NonNull View itemView) { super(itemView); displayName = itemView.findViewById(R.id.status_display_name); username = itemView.findViewById(R.id.status_username); @@ -149,10 +158,10 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { sensitiveMediaWarning = itemView.findViewById(R.id.status_sensitive_media_warning); sensitiveMediaShow = itemView.findViewById(R.id.status_sensitive_media_button); mediaLabels = new TextView[]{ - itemView.findViewById(R.id.status_media_label_0), - itemView.findViewById(R.id.status_media_label_1), - itemView.findViewById(R.id.status_media_label_2), - itemView.findViewById(R.id.status_media_label_3) + itemView.findViewById(R.id.status_media_label_0), + itemView.findViewById(R.id.status_media_label_1), + itemView.findViewById(R.id.status_media_label_2), + itemView.findViewById(R.id.status_media_label_3) }; mediaDescriptions = new CharSequence[mediaLabels.length]; contentWarningDescription = itemView.findViewById(R.id.status_content_warning_description); @@ -180,6 +189,9 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { pollOptions.setLayoutManager(new LinearLayoutManager(pollOptions.getContext())); ((DefaultItemAnimator) pollOptions.getItemAnimator()).setSupportsChangeAnimations(false); + translationStatusView = itemView.findViewById(R.id.status_translation_status); + untranslateButton = itemView.findViewById(R.id.status_button_untranslate); + this.avatarRadius48dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_48dp); this.avatarRadius36dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_36dp); this.avatarRadius24dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_24dp); @@ -189,14 +201,14 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { TouchDelegateHelper.expandTouchSizeToFillRow((ViewGroup) itemView, CollectionsKt.listOfNotNull(replyButton, reblogButton, favouriteButton, bookmarkButton, moreButton)); } - protected void setDisplayName(String name, List customEmojis, StatusDisplayOptions statusDisplayOptions) { + protected void setDisplayName(@NonNull String name, @Nullable List customEmojis, @NonNull StatusDisplayOptions statusDisplayOptions) { CharSequence emojifiedName = CustomEmojiHelper.emojify( - name, customEmojis, displayName, statusDisplayOptions.animateEmojis() + name, customEmojis, displayName, statusDisplayOptions.animateEmojis() ); displayName.setText(emojifiedName); } - protected void setUsername(String name) { + protected void setUsername(@Nullable String name) { Context context = username.getContext(); String usernameText = context.getString(R.string.post_username_format, name); username.setText(usernameText); @@ -208,7 +220,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { protected void setSpoilerAndContent(@NonNull StatusViewData.Concrete status, @NonNull StatusDisplayOptions statusDisplayOptions, - final StatusActionListener listener) { + final @NonNull StatusActionListener listener) { Status actionable = status.getActionable(); String spoilerText = status.getSpoilerText(); @@ -219,7 +231,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { if (sensitive) { CharSequence emojiSpoiler = CustomEmojiHelper.emojify( - spoilerText, emojis, contentWarningDescription, statusDisplayOptions.animateEmojis() + spoilerText, emojis, contentWarningDescription, statusDisplayOptions.animateEmojis() ); contentWarningDescription.setText(emojiSpoiler); contentWarningDescription.setVisibility(View.VISIBLE); @@ -269,9 +281,9 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { Status actionable = status.getActionable(); Spanned content = status.getContent(); List mentions = actionable.getMentions(); - List tags =actionable.getTags(); + List tags = actionable.getTags(); List emojis = actionable.getEmojis(); - PollViewData poll = PollViewDataKt.toViewData(actionable.getPoll()); + PollViewData poll = PollViewDataKt.toViewData(status.getPoll()); if (expanded) { CharSequence emojifiedText = CustomEmojiHelper.emojify(content, emojis, this.content, statusDisplayOptions.animateEmojis()); @@ -302,7 +314,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { } private void setAvatar(String url, - @Nullable String rebloggedUrl, + @Nullable String rebloggedUrl, boolean isBot, StatusDisplayOptions statusDisplayOptions) { @@ -313,8 +325,8 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { if (statusDisplayOptions.showBotOverlay() && isBot) { avatarInset.setVisibility(View.VISIBLE); Glide.with(avatarInset) - .load(R.drawable.bot_badge) - .into(avatarInset); + .load(R.drawable.bot_badge) + .into(avatarInset); } else { avatarInset.setVisibility(View.GONE); } @@ -328,17 +340,21 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { avatarInset.setVisibility(View.VISIBLE); avatarInset.setBackground(null); ImageLoadingHelper.loadAvatar(rebloggedUrl, avatarInset, avatarRadius24dp, - statusDisplayOptions.animateAvatars()); + statusDisplayOptions.animateAvatars(), null); avatarRadius = avatarRadius36dp; } - ImageLoadingHelper.loadAvatar(url, avatar, avatarRadius, - statusDisplayOptions.animateAvatars()); - + ImageLoadingHelper.loadAvatar( + url, + avatar, + avatarRadius, + statusDisplayOptions.animateAvatars(), + Collections.singletonList(new CompositeWithOpaqueBackground(MaterialColors.getColor(avatar, android.R.attr.colorBackground))) + ); } - protected void setMetaData(StatusViewData.Concrete statusViewData, StatusDisplayOptions statusDisplayOptions, StatusActionListener listener) { + protected void setMetaData(@NonNull StatusViewData.Concrete statusViewData, @NonNull StatusDisplayOptions statusDisplayOptions, @NonNull StatusActionListener listener) { Status status = statusViewData.getActionable(); Date createdAt = status.getCreatedAt(); @@ -377,8 +393,8 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { long then = createdAt.getTime(); long now = System.currentTimeMillis(); return DateUtils.getRelativeTimeSpanString(then, now, - DateUtils.SECOND_IN_MILLIS, - DateUtils.FORMAT_ABBREV_RELATIVE); + DateUtils.SECOND_IN_MILLIS, + DateUtils.FORMAT_ABBREV_RELATIVE); } } } @@ -461,9 +477,9 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { imageView.removeFocalPoint(); Glide.with(imageView) - .load(placeholder) - .centerInside() - .into(imageView); + .load(placeholder) + .centerInside() + .into(imageView); } else { Focus focus = meta != null ? meta.getFocus() : null; @@ -471,29 +487,29 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { imageView.setFocalPoint(focus); Glide.with(imageView.getContext()) - .load(previewUrl) - .placeholder(placeholder) - .centerInside() - .addListener(imageView) - .into(imageView); + .load(previewUrl) + .placeholder(placeholder) + .centerInside() + .addListener(imageView) + .into(imageView); } else { imageView.removeFocalPoint(); Glide.with(imageView) - .load(previewUrl) - .placeholder(placeholder) - .centerInside() - .into(imageView); + .load(previewUrl) + .placeholder(placeholder) + .centerInside() + .into(imageView); } } } protected void setMediaPreviews( - final List attachments, - boolean sensitive, - final StatusActionListener listener, - boolean showingContent, - boolean useBlurhash + final @NonNull List attachments, + boolean sensitive, + final @NonNull StatusActionListener listener, + boolean showingContent, + boolean useBlurhash ) { mediaPreview.setVisibility(View.VISIBLE); @@ -512,10 +528,10 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { } loadImage( - imageView, - showingContent ? previewUrl : null, - attachment.getMeta(), - useBlurhash ? attachment.getBlurhash() : null + imageView, + showingContent ? previewUrl : null, + attachment.getMeta(), + useBlurhash ? attachment.getBlurhash() : null ); final Attachment.Type type = attachment.getType(); @@ -577,13 +593,13 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { private void updateMediaLabel(int index, boolean sensitive, boolean showingContent) { Context context = itemView.getContext(); CharSequence label = (sensitive && !showingContent) ? - context.getString(R.string.post_sensitive_media_title) : - mediaDescriptions[index]; + context.getString(R.string.post_sensitive_media_title) : + mediaDescriptions[index]; mediaLabels[index].setText(label); } - protected void setMediaLabel(List attachments, boolean sensitive, - final StatusActionListener listener, boolean showingContent) { + protected void setMediaLabel(@NonNull List attachments, boolean sensitive, + final @NonNull StatusActionListener listener, boolean showingContent) { Context context = itemView.getContext(); for (int i = 0; i < mediaLabels.length; i++) { TextView mediaLabel = mediaLabels[i]; @@ -604,7 +620,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { } } - private void setAttachmentClickListener(View view, StatusActionListener listener, + private void setAttachmentClickListener(View view, @NonNull StatusActionListener listener, int index, Attachment attachment, boolean animateTransition) { view.setOnClickListener(v -> { int position = getBindingAdapterPosition(); @@ -628,10 +644,10 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { sensitiveMediaShow.setVisibility(View.GONE); } - protected void setupButtons(final StatusActionListener listener, - final String accountId, - final String statusContent, - StatusDisplayOptions statusDisplayOptions) { + protected void setupButtons(final @NonNull StatusActionListener listener, + final @NonNull String accountId, + final @Nullable String statusContent, + @NonNull StatusDisplayOptions statusDisplayOptions) { View.OnClickListener profileButtonClickListener = button -> listener.onViewAccount(accountId); avatar.setOnClickListener(profileButtonClickListener); @@ -721,8 +737,9 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { } popup.setOnMenuItemClickListener(item -> { listener.onReblog(!buttonState, position); - if(!buttonState) { + if (!buttonState) { reblogButton.playAnimation(); + reblogButton.setChecked(true); } return true; }); @@ -742,16 +759,17 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { } popup.setOnMenuItemClickListener(item -> { listener.onFavourite(!buttonState, position); - if(!buttonState) { + if (!buttonState) { favouriteButton.playAnimation(); + favouriteButton.setChecked(true); } return true; }); popup.show(); } - public void setupWithStatus(StatusViewData.Concrete status, final StatusActionListener listener, - StatusDisplayOptions statusDisplayOptions) { + public void setupWithStatus(@NonNull StatusViewData.Concrete status, final @NonNull StatusActionListener listener, + @NonNull StatusDisplayOptions statusDisplayOptions) { this.setupWithStatus(status, listener, statusDisplayOptions, null); } @@ -762,16 +780,16 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { if (payloads == null) { Status actionable = status.getActionable(); setDisplayName(actionable.getAccount().getName(), actionable.getAccount().getEmojis(), statusDisplayOptions); - setUsername(status.getUsername()); + setUsername(actionable.getAccount().getUsername()); setMetaData(status, statusDisplayOptions, listener); setIsReply(actionable.getInReplyToId() != null); setReplyCount(actionable.getRepliesCount(), statusDisplayOptions.showStatsInline()); setAvatar(actionable.getAccount().getAvatar(), status.getRebloggedAvatar(), - actionable.getAccount().getBot(), statusDisplayOptions); + actionable.getAccount().getBot(), statusDisplayOptions); setReblogged(actionable.getReblogged()); setFavourited(actionable.getFavourited()); setBookmarked(actionable.getBookmarked()); - List attachments = actionable.getAttachments(); + List attachments = status.getAttachments(); boolean sensitive = actionable.getSensitive(); if (statusDisplayOptions.mediaPreviewEnabled() && hasPreviewableAttachment(attachments)) { setMediaPreviews(attachments, sensitive, listener, status.isShowingContent(), statusDisplayOptions.useBlurhash()); @@ -793,8 +811,11 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { setupCard(status, status.isExpanded(), statusDisplayOptions.cardViewMode(), statusDisplayOptions, listener); setupButtons(listener, actionable.getAccount().getId(), status.getContent().toString(), - statusDisplayOptions); - setRebloggingEnabled(actionable.rebloggingAllowed(), actionable.getVisibility()); + statusDisplayOptions); + + setTranslationStatus(status, listener); + + setRebloggingEnabled(actionable.isRebloggingAllowed(), actionable.getVisibility()); setSpoilerAndContent(status, statusDisplayOptions, listener); @@ -819,6 +840,29 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { } } + private void setTranslationStatus(StatusViewData.Concrete status, StatusActionListener listener) { + var translationViewData = status.getTranslation(); + if (translationViewData != null) { + if (translationViewData instanceof TranslationViewData.Loaded) { + Translation translation = ((TranslationViewData.Loaded) translationViewData).getData(); + translationStatusView.setVisibility(View.VISIBLE); + var langName = LocaleUtilsKt.localeNameForUntrustedISO639LangCode(translation.getDetectedSourceLanguage()); + translationStatusView.setText(translationStatusView.getContext().getString(R.string.label_translated, langName, translation.getProvider())); + untranslateButton.setVisibility(View.VISIBLE); + untranslateButton.setOnClickListener((v) -> listener.onUntranslate(getBindingAdapterPosition())); + } else { + translationStatusView.setVisibility(View.VISIBLE); + translationStatusView.setText(R.string.label_translating); + untranslateButton.setVisibility(View.GONE); + untranslateButton.setOnClickListener(null); + } + } else { + translationStatusView.setVisibility(View.GONE); + untranslateButton.setVisibility(View.GONE); + untranslateButton.setOnClickListener(null); + } + } + private void setupFilterPlaceholder(StatusViewData.Concrete status, StatusActionListener listener, StatusDisplayOptions displayOptions) { if (status.getFilterAction() != Filter.Action.WARN) { showFilteredPlaceholder(false); @@ -838,12 +882,10 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { } filteredPlaceholderLabel.setText(itemView.getContext().getString(R.string.status_filter_placeholder_label_format, matchedFilter.getTitle())); - filteredPlaceholderShowButton.setOnClickListener(view -> { - listener.clearWarningAction(getBindingAdapterPosition()); - }); + filteredPlaceholderShowButton.setOnClickListener(view -> listener.clearWarningAction(getBindingAdapterPosition())); } - protected static boolean hasPreviewableAttachment(List attachments) { + protected static boolean hasPreviewableAttachment(@NonNull List attachments) { for (Attachment attachment : attachments) { if (attachment.getType() == Attachment.Type.AUDIO || attachment.getType() == Attachment.Type.UNKNOWN) { return false; @@ -858,54 +900,84 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { Status actionable = status.getActionable(); String description = context.getString(R.string.description_status, - actionable.getAccount().getDisplayName(), - getContentWarningDescription(context, status), - (TextUtils.isEmpty(status.getSpoilerText()) || !actionable.getSensitive() || status.isExpanded() ? status.getContent() : ""), - getCreatedAtDescription(actionable.getCreatedAt(), statusDisplayOptions), - actionable.getEditedAt() != null ? context.getString(R.string.description_post_edited) : "", - getReblogDescription(context, status), - status.getUsername(), - actionable.getReblogged() ? context.getString(R.string.description_post_reblogged) : "", - actionable.getFavourited() ? context.getString(R.string.description_post_favourited) : "", - actionable.getBookmarked() ? context.getString(R.string.description_post_bookmarked) : "", - getMediaDescription(context, status), - getVisibilityDescription(context, actionable.getVisibility()), - getFavsText(context, actionable.getFavouritesCount()), - getReblogsText(context, actionable.getReblogsCount()), - getPollDescription(status, context, statusDisplayOptions) + // 1 display_name + actionable.getAccount().getDisplayName(), + // 2 CW? + getContentWarningDescription(context, status), + // 3 content? + (TextUtils.isEmpty(status.getSpoilerText()) || !actionable.getSensitive() || status.isExpanded() ? status.getContent() : ""), + // 4 date + getCreatedAtDescription(actionable.getCreatedAt(), statusDisplayOptions), + // 5 edited? + actionable.getEditedAt() != null ? context.getString(R.string.description_post_edited) : "", + // 6 reposted_by? + getReblogDescription(context, status), + // 7 username + actionable.getAccount().getUsername(), + // 8 reposted + actionable.getReblogged() ? context.getString(R.string.description_post_reblogged) : "", + // 9 favorited + actionable.getFavourited() ? context.getString(R.string.description_post_favourited) : "", + // 10 bookmarked + actionable.getBookmarked() ? context.getString(R.string.description_post_bookmarked) : "", + // 11 media + getMediaDescription(context, status), + // 12 visibility + getVisibilityDescription(context, actionable.getVisibility()), + // 13 fav_number + getFavsText(context, actionable.getFavouritesCount()), + // 14 reblog_number + getReblogsText(context, actionable.getReblogsCount()), + // 15 poll? + getPollDescription(status, context, statusDisplayOptions), + // 16 translated? + getTranslatedDescription(context, status.getTranslation()) ); itemView.setContentDescription(description); } + private String getTranslatedDescription(Context context, TranslationViewData translationViewData) { + if (translationViewData == null) { + return ""; + } else if (translationViewData instanceof TranslationViewData.Loading) { + return context.getString(R.string.label_translating); + } else { + Translation translation = ((TranslationViewData.Loaded) translationViewData).getData(); + var langName = LocaleUtilsKt.localeNameForUntrustedISO639LangCode(translation.getDetectedSourceLanguage()); + return context.getString(R.string.label_translated, langName, translation.getProvider()); + } + } + private static CharSequence getReblogDescription(Context context, @NonNull StatusViewData.Concrete status) { + @Nullable Status reblog = status.getRebloggingStatus(); if (reblog != null) { return context - .getString(R.string.post_boosted_format, reblog.getAccount().getUsername()); + .getString(R.string.post_boosted_format, reblog.getAccount().getUsername()); } else { return ""; } } private static CharSequence getMediaDescription(Context context, - @NonNull StatusViewData.Concrete status) { - if (status.getActionable().getAttachments().isEmpty()) { + @NonNull StatusViewData.Concrete viewData) { + if (viewData.getAttachments().isEmpty()) { return ""; } StringBuilder mediaDescriptions = CollectionsKt.fold( - status.getActionable().getAttachments(), - new StringBuilder(), - (builder, a) -> { - if (a.getDescription() == null) { - String placeholder = - context.getString(R.string.description_post_media_no_description_placeholder); - return builder.append(placeholder); - } else { - builder.append("; "); - return builder.append(a.getDescription()); - } - }); + viewData.getAttachments(), + new StringBuilder(), + (builder, a) -> { + if (a.getDescription() == null) { + String placeholder = + context.getString(R.string.description_post_media_no_description_placeholder); + return builder.append(placeholder); + } else { + builder.append("; "); + return builder.append(a.getDescription()); + } + }); return context.getString(R.string.description_post_media, mediaDescriptions); } @@ -918,7 +990,8 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { } } - protected static CharSequence getVisibilityDescription(Context context, Status.Visibility visibility) { + @NonNull + protected static CharSequence getVisibilityDescription(@NonNull Context context, @Nullable Status.Visibility visibility) { if (visibility == null) { return ""; @@ -947,7 +1020,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { private CharSequence getPollDescription(@NonNull StatusViewData.Concrete status, Context context, StatusDisplayOptions statusDisplayOptions) { - PollViewData poll = PollViewDataKt.toViewData(status.getActionable().getPoll()); + PollViewData poll = PollViewDataKt.toViewData(status.getPoll()); if (poll == null) { return ""; } else { @@ -962,27 +1035,21 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { } } args[4] = getPollInfoText(System.currentTimeMillis(), poll, statusDisplayOptions, - context); + context); return context.getString(R.string.description_poll, args); } } - protected CharSequence getFavsText(Context context, int count) { - if (count > 0) { - String countString = numberFormat.format(count); - return HtmlCompat.fromHtml(context.getResources().getQuantityString(R.plurals.favs, count, countString), HtmlCompat.FROM_HTML_MODE_LEGACY); - } else { - return ""; - } + @NonNull + protected CharSequence getFavsText(@NonNull Context context, int count) { + String countString = numberFormat.format(count); + return HtmlCompat.fromHtml(context.getResources().getQuantityString(R.plurals.favs, count, countString), HtmlCompat.FROM_HTML_MODE_LEGACY); } - protected CharSequence getReblogsText(Context context, int count) { - if (count > 0) { - String countString = numberFormat.format(count); - return HtmlCompat.fromHtml(context.getResources().getQuantityString(R.plurals.reblogs, count, countString), HtmlCompat.FROM_HTML_MODE_LEGACY); - } else { - return ""; - } + @NonNull + protected CharSequence getReblogsText(@NonNull Context context, int count) { + String countString = numberFormat.format(count); + return HtmlCompat.fromHtml(context.getResources().getQuantityString(R.plurals.reblogs, count, countString), HtmlCompat.FROM_HTML_MODE_LEGACY); } private void setupPoll(PollViewData poll, List emojis, @@ -1005,26 +1072,26 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { } }; pollAdapter.setup( - poll.getOptions(), - poll.getVotesCount(), - poll.getVotersCount(), - emojis, - PollAdapter.RESULT, - viewThreadListener, - statusDisplayOptions.animateEmojis() + poll.getOptions(), + poll.getVotesCount(), + poll.getVotersCount(), + emojis, + PollAdapter.RESULT, + viewThreadListener, + statusDisplayOptions.animateEmojis() ); pollButton.setVisibility(View.GONE); } else { // voting possible pollAdapter.setup( - poll.getOptions(), - poll.getVotesCount(), - poll.getVotersCount(), - emojis, - poll.getMultiple() ? PollAdapter.MULTIPLE : PollAdapter.SINGLE, - null, - statusDisplayOptions.animateEmojis() + poll.getOptions(), + poll.getVotesCount(), + poll.getVotersCount(), + emojis, + poll.getMultiple() ? PollAdapter.MULTIPLE : PollAdapter.SINGLE, + null, + statusDisplayOptions.animateEmojis() ); pollButton.setVisibility(View.VISIBLE); @@ -1077,11 +1144,11 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { } protected void setupCard( - final StatusViewData.Concrete status, - boolean expanded, - final CardViewMode cardViewMode, - final StatusDisplayOptions statusDisplayOptions, - final StatusActionListener listener + final @NonNull StatusViewData.Concrete status, + boolean expanded, + final @NonNull CardViewMode cardViewMode, + final @NonNull StatusDisplayOptions statusDisplayOptions, + final @NonNull StatusActionListener listener ) { if (cardView == null) { return; @@ -1095,7 +1162,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { actionable.getPoll() == null && card != null && !TextUtils.isEmpty(card.getUrl()) && - (!actionable.getSensitive() || expanded) && + (TextUtils.isEmpty(actionable.getSpoilerText()) || expanded) && (!status.isCollapsible() || !status.isCollapsed())) { cardView.setVisibility(View.VISIBLE); @@ -1119,14 +1186,14 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { if (statusDisplayOptions.mediaPreviewEnabled() && !actionable.getSensitive() && !TextUtils.isEmpty(card.getImage())) { int radius = cardImage.getContext().getResources() - .getDimensionPixelSize(R.dimen.card_radius); + .getDimensionPixelSize(R.dimen.card_radius); ShapeAppearanceModel.Builder cardImageShape = ShapeAppearanceModel.builder(); if (card.getWidth() > card.getHeight()) { cardView.setOrientation(LinearLayout.VERTICAL); cardImage.getLayoutParams().height = cardImage.getContext().getResources() - .getDimensionPixelSize(R.dimen.card_image_vertical_height); + .getDimensionPixelSize(R.dimen.card_image_vertical_height); cardImage.getLayoutParams().width = ViewGroup.LayoutParams.MATCH_PARENT; cardInfo.getLayoutParams().height = ViewGroup.LayoutParams.MATCH_PARENT; cardInfo.getLayoutParams().width = ViewGroup.LayoutParams.WRAP_CONTENT; @@ -1136,7 +1203,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { cardView.setOrientation(LinearLayout.HORIZONTAL); cardImage.getLayoutParams().height = ViewGroup.LayoutParams.MATCH_PARENT; cardImage.getLayoutParams().width = cardImage.getContext().getResources() - .getDimensionPixelSize(R.dimen.card_image_horizontal_width); + .getDimensionPixelSize(R.dimen.card_image_horizontal_width); cardInfo.getLayoutParams().height = ViewGroup.LayoutParams.WRAP_CONTENT; cardInfo.getLayoutParams().width = ViewGroup.LayoutParams.MATCH_PARENT; cardImageShape.setTopLeftCorner(CornerFamily.ROUNDED, radius); @@ -1148,40 +1215,40 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { cardImage.setScaleType(ImageView.ScaleType.CENTER_CROP); RequestBuilder builder = Glide.with(cardImage.getContext()) - .load(card.getImage()) - .dontTransform(); + .load(card.getImage()) + .dontTransform(); if (statusDisplayOptions.useBlurhash() && !TextUtils.isEmpty(card.getBlurhash())) { builder = builder.placeholder(decodeBlurHash(card.getBlurhash())); } builder.into(cardImage); } else if (statusDisplayOptions.useBlurhash() && !TextUtils.isEmpty(card.getBlurhash())) { int radius = cardImage.getContext().getResources() - .getDimensionPixelSize(R.dimen.card_radius); + .getDimensionPixelSize(R.dimen.card_radius); cardView.setOrientation(LinearLayout.HORIZONTAL); cardImage.getLayoutParams().height = ViewGroup.LayoutParams.MATCH_PARENT; cardImage.getLayoutParams().width = cardImage.getContext().getResources() - .getDimensionPixelSize(R.dimen.card_image_horizontal_width); + .getDimensionPixelSize(R.dimen.card_image_horizontal_width); cardInfo.getLayoutParams().height = ViewGroup.LayoutParams.WRAP_CONTENT; cardInfo.getLayoutParams().width = ViewGroup.LayoutParams.MATCH_PARENT; ShapeAppearanceModel cardImageShape = ShapeAppearanceModel.builder() - .setTopLeftCorner(CornerFamily.ROUNDED, radius) - .setBottomLeftCorner(CornerFamily.ROUNDED, radius) - .build(); + .setTopLeftCorner(CornerFamily.ROUNDED, radius) + .setBottomLeftCorner(CornerFamily.ROUNDED, radius) + .build(); cardImage.setShapeAppearanceModel(cardImageShape); cardImage.setScaleType(ImageView.ScaleType.CENTER_CROP); Glide.with(cardImage.getContext()) - .load(decodeBlurHash(card.getBlurhash())) - .dontTransform() - .into(cardImage); + .load(decodeBlurHash(card.getBlurhash())) + .dontTransform() + .into(cardImage); } else { cardView.setOrientation(LinearLayout.HORIZONTAL); cardImage.getLayoutParams().height = ViewGroup.LayoutParams.MATCH_PARENT; cardImage.getLayoutParams().width = cardImage.getContext().getResources() - .getDimensionPixelSize(R.dimen.card_image_horizontal_width); + .getDimensionPixelSize(R.dimen.card_image_horizontal_width); cardInfo.getLayoutParams().height = ViewGroup.LayoutParams.WRAP_CONTENT; cardInfo.getLayoutParams().width = ViewGroup.LayoutParams.MATCH_PARENT; @@ -1190,8 +1257,8 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { cardImage.setScaleType(ImageView.ScaleType.CENTER); Glide.with(cardImage.getContext()) - .load(R.drawable.card_image_placeholder) - .into(cardImage); + .load(R.drawable.card_image_placeholder) + .into(cardImage); } View.OnClickListener visitLink = v -> listener.onViewUrl(card.getUrl()); @@ -1199,8 +1266,8 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { cardView.setOnClickListener(visitLink); // View embedded photos in our image viewer instead of opening the browser cardImage.setOnClickListener(card.getType().equals(Card.TYPE_PHOTO) && !TextUtils.isEmpty(card.getEmbedUrl()) ? - v -> cardView.getContext().startActivity(ViewMediaActivity.newSingleImageIntent(cardView.getContext(), card.getEmbedUrl())) : - visitLink); + v -> cardView.getContext().startActivity(ViewMediaActivity.newSingleImageIntent(cardView.getContext(), card.getEmbedUrl())) : + visitLink); cardView.setClipToOutline(true); } else { diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java index 76eda1107..bb5a78e4e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java @@ -9,11 +9,13 @@ import android.text.method.LinkMovementMethod; import android.text.style.DynamicDrawableSpan; import android.text.style.ImageSpan; import android.view.View; +import android.widget.Button; import android.widget.TextView; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.content.res.AppCompatResources; +import androidx.appcompat.widget.ViewUtils; import androidx.recyclerview.widget.RecyclerView; import com.keylesspalace.tusky.R; @@ -23,6 +25,7 @@ import com.keylesspalace.tusky.util.CardViewMode; import com.keylesspalace.tusky.util.LinkHelper; import com.keylesspalace.tusky.util.NoUnderlineURLSpan; import com.keylesspalace.tusky.util.StatusDisplayOptions; +import com.keylesspalace.tusky.util.ViewExtensionsKt; import com.keylesspalace.tusky.viewdata.StatusViewData; import java.text.DateFormat; @@ -35,7 +38,7 @@ public class StatusDetailedViewHolder extends StatusBaseViewHolder { private static final DateFormat dateFormat = DateFormat.getDateTimeInstance(DateFormat.DEFAULT, DateFormat.SHORT); - public StatusDetailedViewHolder(View view) { + public StatusDetailedViewHolder(@NonNull View view) { super(view); reblogs = view.findViewById(R.id.status_reblogs); favourites = view.findViewById(R.id.status_favourites); @@ -43,7 +46,7 @@ public class StatusDetailedViewHolder extends StatusBaseViewHolder { } @Override - protected void setMetaData(StatusViewData.Concrete statusViewData, StatusDisplayOptions statusDisplayOptions, StatusActionListener listener) { + protected void setMetaData(@NonNull StatusViewData.Concrete statusViewData, @NonNull StatusDisplayOptions statusDisplayOptions, @NonNull StatusActionListener listener) { Status status = statusViewData.getActionable(); @@ -57,8 +60,8 @@ public class StatusDetailedViewHolder extends StatusBaseViewHolder { if (visibilityIcon != null) { ImageSpan visibilityIconSpan = new ImageSpan( - visibilityIcon, - Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q ? DynamicDrawableSpan.ALIGN_CENTER : DynamicDrawableSpan.ALIGN_BASELINE + visibilityIcon, + Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q ? DynamicDrawableSpan.ALIGN_CENTER : DynamicDrawableSpan.ALIGN_BASELINE ); sb.setSpan(visibilityIconSpan, 0, visibilityString.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); } @@ -67,7 +70,6 @@ public class StatusDetailedViewHolder extends StatusBaseViewHolder { Date createdAt = status.getCreatedAt(); if (createdAt != null) { - sb.append(" "); sb.append(dateFormat.format(createdAt)); } @@ -95,10 +97,16 @@ public class StatusDetailedViewHolder extends StatusBaseViewHolder { } } + String language = status.getLanguage(); + + if (language != null) { + sb.append(metadataJoiner); + sb.append(language.toUpperCase()); + } + Status.Application app = status.getApplication(); if (app != null) { - sb.append(metadataJoiner); if (app.getWebsite() != null) { @@ -114,25 +122,8 @@ public class StatusDetailedViewHolder extends StatusBaseViewHolder { } private void setReblogAndFavCount(int reblogCount, int favCount, StatusActionListener listener) { - - if (reblogCount > 0) { - reblogs.setText(getReblogsText(reblogs.getContext(), reblogCount)); - reblogs.setVisibility(View.VISIBLE); - } else { - reblogs.setVisibility(View.GONE); - } - if (favCount > 0) { - favourites.setText(getFavsText(favourites.getContext(), favCount)); - favourites.setVisibility(View.VISIBLE); - } else { - favourites.setVisibility(View.GONE); - } - - if (reblogs.getVisibility() == View.GONE && favourites.getVisibility() == View.GONE) { - infoDivider.setVisibility(View.GONE); - } else { - infoDivider.setVisibility(View.VISIBLE); - } + reblogs.setText(getReblogsText(reblogs.getContext(), reblogCount)); + favourites.setText(getFavsText(favourites.getContext(), favCount)); reblogs.setOnClickListener(v -> { int position = getBindingAdapterPosition(); @@ -155,8 +146,8 @@ public class StatusDetailedViewHolder extends StatusBaseViewHolder { @Nullable Object payloads) { // We never collapse statuses in the detail view StatusViewData.Concrete uncollapsedStatus = (status.isCollapsible() && status.isCollapsed()) ? - status.copyWithCollapsed(false) : - status; + status.copyWithCollapsed(false) : + status; super.setupWithStatus(uncollapsedStatus, listener, statusDisplayOptions, payloads); setupCard(uncollapsedStatus, status.isExpanded(), CardViewMode.FULL_WIDTH, statusDisplayOptions, listener); // Always show card for detailed status @@ -165,7 +156,7 @@ public class StatusDetailedViewHolder extends StatusBaseViewHolder { if (!statusDisplayOptions.hideStats()) { setReblogAndFavCount(actionable.getReblogsCount(), - actionable.getFavouritesCount(), listener); + actionable.getFavouritesCount(), listener); } else { hideQuantitativeStats(); } @@ -197,7 +188,7 @@ public class StatusDetailedViewHolder extends StatusBaseViewHolder { } final Drawable visibilityDrawable = AppCompatResources.getDrawable( - this.metaInfo.getContext(), visibilityIcon + this.metaInfo.getContext(), visibilityIcon ); if (visibilityDrawable == null) { return null; @@ -205,10 +196,10 @@ public class StatusDetailedViewHolder extends StatusBaseViewHolder { final int size = (int) this.metaInfo.getTextSize(); visibilityDrawable.setBounds( - 0, - 0, - size, - size + 0, + 0, + size, + size ); visibilityDrawable.setTint(this.metaInfo.getCurrentTextColor()); diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java index 304cf93a5..327f7cfbb 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java @@ -51,7 +51,7 @@ public class StatusViewHolder extends StatusBaseViewHolder { private final TextView favouritedCountLabel; private final TextView reblogsCountLabel; - public StatusViewHolder(View itemView) { + public StatusViewHolder(@NonNull View itemView) { super(itemView); statusInfo = itemView.findViewById(R.id.status_info); contentCollapseButton = itemView.findViewById(R.id.button_toggle_content); diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/TabAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/TabAdapter.kt index e3e4f27e8..7643aec3b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/TabAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/TabAdapter.kt @@ -56,7 +56,11 @@ class TabAdapter( override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder { val binding = if (small) { - ItemTabPreferenceSmallBinding.inflate(LayoutInflater.from(parent.context), parent, false) + ItemTabPreferenceSmallBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) } else { ItemTabPreferenceBinding.inflate(LayoutInflater.from(parent.context), parent, false) } diff --git a/app/src/main/java/com/keylesspalace/tusky/appstore/CacheUpdater.kt b/app/src/main/java/com/keylesspalace/tusky/appstore/CacheUpdater.kt index 9515457b3..627a84344 100644 --- a/app/src/main/java/com/keylesspalace/tusky/appstore/CacheUpdater.kt +++ b/app/src/main/java/com/keylesspalace/tusky/appstore/CacheUpdater.kt @@ -1,20 +1,18 @@ package com.keylesspalace.tusky.appstore -import com.google.gson.Gson import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AppDatabase +import javax.inject.Inject import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.launch -import javax.inject.Inject class CacheUpdater @Inject constructor( eventHub: EventHub, accountManager: AccountManager, - appDatabase: AppDatabase, - gson: Gson + appDatabase: AppDatabase ) { private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) @@ -26,22 +24,20 @@ class CacheUpdater @Inject constructor( eventHub.events.collect { event -> val accountId = accountManager.activeAccount?.id ?: return@collect when (event) { - is FavoriteEvent -> - timelineDao.setFavourited(accountId, event.statusId, event.favourite) - is ReblogEvent -> - timelineDao.setReblogged(accountId, event.statusId, event.reblog) - is BookmarkEvent -> - timelineDao.setBookmarked(accountId, event.statusId, event.bookmark) + is StatusChangedEvent -> { + val status = event.status + timelineDao.update( + accountId = accountId, + status = status + ) + } is UnfollowEvent -> timelineDao.removeAllByUser(accountId, event.accountId) is StatusDeletedEvent -> timelineDao.delete(accountId, event.statusId) is PollVoteEvent -> { - val pollString = gson.toJson(event.poll) - timelineDao.setVoted(accountId, event.statusId, pollString) + timelineDao.setVoted(accountId, event.statusId, event.poll) } - is PinEvent -> - timelineDao.setPinned(accountId, event.statusId, event.pinned) } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/appstore/Events.kt b/app/src/main/java/com/keylesspalace/tusky/appstore/Events.kt index 494d67974..cf2046359 100644 --- a/app/src/main/java/com/keylesspalace/tusky/appstore/Events.kt +++ b/app/src/main/java/com/keylesspalace/tusky/appstore/Events.kt @@ -2,24 +2,29 @@ package com.keylesspalace.tusky.appstore import com.keylesspalace.tusky.TabData import com.keylesspalace.tusky.entity.Account +import com.keylesspalace.tusky.entity.Notification import com.keylesspalace.tusky.entity.Poll +import com.keylesspalace.tusky.entity.ScheduledStatus import com.keylesspalace.tusky.entity.Status -data class FavoriteEvent(val statusId: String, val favourite: Boolean) : Event -data class ReblogEvent(val statusId: String, val reblog: Boolean) : Event -data class BookmarkEvent(val statusId: String, val bookmark: Boolean) : Event +data class StatusChangedEvent(val status: Status) : Event data class MuteConversationEvent(val statusId: String, val mute: Boolean) : Event data class UnfollowEvent(val accountId: String) : Event data class BlockEvent(val accountId: String) : Event data class MuteEvent(val accountId: String) : Event data class StatusDeletedEvent(val statusId: String) : Event data class StatusComposedEvent(val status: Status) : Event -data class StatusScheduledEvent(val status: Status) : Event -data class StatusEditedEvent(val originalId: String, val status: Status) : Event +data class StatusScheduledEvent(val scheduledStatus: ScheduledStatus) : Event data class ProfileEditedEvent(val newProfileData: Account) : Event data class PreferenceChangedEvent(val preferenceKey: String) : Event data class MainTabsChangedEvent(val newTabs: List) : Event data class PollVoteEvent(val statusId: String, val poll: Poll) : Event data class DomainMuteEvent(val instance: String) : Event data class AnnouncementReadEvent(val announcementId: String) : Event -data class PinEvent(val statusId: String, val pinned: Boolean) : Event +data class FilterUpdatedEvent(val filterContext: List) : Event +data class NewNotificationsEvent( + val accountId: String, + val notifications: List +) : Event +data class ConversationsLoadingEvent(val accountId: String) : Event +data class NotificationsLoadingEvent(val accountId: String) : Event diff --git a/app/src/main/java/com/keylesspalace/tusky/appstore/EventsHub.kt b/app/src/main/java/com/keylesspalace/tusky/appstore/EventsHub.kt index 4030b116f..88634681a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/appstore/EventsHub.kt +++ b/app/src/main/java/com/keylesspalace/tusky/appstore/EventsHub.kt @@ -1,19 +1,33 @@ package com.keylesspalace.tusky.appstore -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableSharedFlow +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.lifecycleScope +import java.util.function.Consumer import javax.inject.Inject import javax.inject.Singleton +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.launch interface Event @Singleton class EventHub @Inject constructor() { - private val sharedEventFlow: MutableSharedFlow = MutableSharedFlow() - val events: Flow = sharedEventFlow + private val _events = MutableSharedFlow() + val events: SharedFlow = _events.asSharedFlow() suspend fun dispatch(event: Event) { - sharedEventFlow.emit(event) + _events.emit(event) + } + + // TODO remove as soon as NotificationsFragment is Kotlin + fun subscribe(lifecycleOwner: LifecycleOwner, consumer: Consumer) { + lifecycleOwner.lifecycleScope.launch { + events.collect { event -> + consumer.accept(event) + } + } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/account/AccountActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/account/AccountActivity.kt index 42a07c0b2..5c46fca65 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/account/AccountActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/account/AccountActivity.kt @@ -22,9 +22,11 @@ import android.content.Context import android.content.Intent import android.content.res.ColorStateList import android.graphics.Color -import android.graphics.drawable.LayerDrawable +import android.graphics.Typeface import android.os.Bundle +import android.text.SpannableStringBuilder import android.text.TextWatcher +import android.text.style.StyleSpan import android.view.Menu import android.view.MenuInflater import android.view.MenuItem @@ -32,10 +34,12 @@ import android.view.View import android.view.ViewGroup import androidx.activity.viewModels import androidx.annotation.ColorInt +import androidx.annotation.DrawableRes import androidx.annotation.Px import androidx.appcompat.app.AlertDialog import androidx.appcompat.content.res.AppCompatResources import androidx.core.app.ActivityOptionsCompat +import androidx.core.graphics.ColorUtils import androidx.core.view.MenuProvider import androidx.core.view.ViewCompat import androidx.core.view.WindowCompat @@ -43,11 +47,13 @@ import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsCompat.Type.systemBars import androidx.core.view.updatePadding import androidx.core.widget.doAfterTextChanged +import androidx.lifecycle.lifecycleScope import androidx.preference.PreferenceManager import androidx.recyclerview.widget.LinearLayoutManager import androidx.viewpager2.widget.MarginPageTransformer import com.bumptech.glide.Glide import com.google.android.material.appbar.AppBarLayout +import com.google.android.material.chip.Chip import com.google.android.material.color.MaterialColors import com.google.android.material.floatingactionbutton.FloatingActionButton import com.google.android.material.shape.MaterialShapeDrawable @@ -60,7 +66,7 @@ import com.keylesspalace.tusky.EditProfileActivity import com.keylesspalace.tusky.R import com.keylesspalace.tusky.StatusListActivity import com.keylesspalace.tusky.ViewMediaActivity -import com.keylesspalace.tusky.components.account.list.ListsForAccountFragment +import com.keylesspalace.tusky.components.account.list.ListSelectionFragment import com.keylesspalace.tusky.components.accountlist.AccountListActivity import com.keylesspalace.tusky.components.compose.ComposeActivity import com.keylesspalace.tusky.components.report.ReportActivity @@ -86,6 +92,7 @@ import com.keylesspalace.tusky.util.parseAsMastodonHtml import com.keylesspalace.tusky.util.reduceSwipeSensitivity import com.keylesspalace.tusky.util.setClickableText import com.keylesspalace.tusky.util.show +import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation import com.keylesspalace.tusky.util.unsafeLazy import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.util.visible @@ -102,6 +109,7 @@ import java.text.SimpleDateFormat import java.util.Locale import javax.inject.Inject import kotlin.math.abs +import kotlinx.coroutines.launch class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider, HasAndroidInjector, LinkListener { @@ -173,9 +181,9 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide viewModel.setAccountInfo(intent.getStringExtra(KEY_ACCOUNT_ID)!!) val sharedPrefs = PreferenceManager.getDefaultSharedPreferences(this) - animateAvatar = sharedPrefs.getBoolean("animateGifAvatars", false) + animateAvatar = sharedPrefs.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false) animateEmojis = sharedPrefs.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false) - hideFab = sharedPrefs.getBoolean("fabHide", false) + hideFab = sharedPrefs.getBoolean(PrefKeys.FAB_HIDE, false) handleWindowInsets() setupToolbar() @@ -261,9 +269,18 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide binding.accountFragmentViewPager.adapter = adapter binding.accountFragmentViewPager.offscreenPageLimit = 2 - val pageTitles = arrayOf(getString(R.string.title_posts), getString(R.string.title_posts_with_replies), getString(R.string.title_posts_pinned), getString(R.string.title_media)) + val pageTitles = + arrayOf( + getString(R.string.title_posts), + getString(R.string.title_posts_with_replies), + getString(R.string.title_posts_pinned), + getString(R.string.title_media) + ) - TabLayoutMediator(binding.accountTabLayout, binding.accountFragmentViewPager) { tab, position -> + TabLayoutMediator( + binding.accountTabLayout, + binding.accountFragmentViewPager + ) { tab, position -> tab.text = pageTitles[position] }.attach() @@ -295,7 +312,15 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide val right = insets.getInsets(systemBars()).right val bottom = insets.getInsets(systemBars()).bottom val left = insets.getInsets(systemBars()).left - binding.accountCoordinatorLayout.updatePadding(right = right, bottom = bottom, left = left) + binding.accountCoordinatorLayout.updatePadding( + right = right, + bottom = bottom, + left = left + ) + binding.swipeToRefreshLayout.setProgressViewEndTarget( + false, + top + resources.getDimensionPixelSize(R.dimen.account_swiperefresh_distance) + ) WindowInsetsCompat.CONSUMED } @@ -312,30 +337,24 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide val appBarElevation = resources.getDimension(R.dimen.actionbar_elevation) - val toolbarBackground = MaterialShapeDrawable.createWithElevationOverlay(this, appBarElevation) + val toolbarBackground = MaterialShapeDrawable.createWithElevationOverlay( + this, + appBarElevation + ) toolbarBackground.fillColor = ColorStateList.valueOf(Color.TRANSPARENT) binding.accountToolbar.background = toolbarBackground - // Provide a non-transparent background to the navigation and overflow icons to ensure - // they remain visible over whatever the profile background image might be. - val backgroundCircle = AppCompatResources.getDrawable(this, R.drawable.background_circle)!! - backgroundCircle.alpha = 210 // Any lower than this and the backgrounds interfere - binding.accountToolbar.navigationIcon = LayerDrawable( - arrayOf( - backgroundCircle, - binding.accountToolbar.navigationIcon - ) - ) - binding.accountToolbar.overflowIcon = LayerDrawable( - arrayOf( - backgroundCircle, - binding.accountToolbar.overflowIcon - ) + binding.accountToolbar.setNavigationIcon(R.drawable.ic_arrow_back_with_background) + binding.accountToolbar.setOverflowIcon( + AppCompatResources.getDrawable(this, R.drawable.ic_more_with_background) ) binding.accountHeaderInfoContainer.background = MaterialShapeDrawable.createWithElevationOverlay(this, appBarElevation) - val avatarBackground = MaterialShapeDrawable.createWithElevationOverlay(this, appBarElevation).apply { + val avatarBackground = MaterialShapeDrawable.createWithElevationOverlay( + this, + appBarElevation + ).apply { fillColor = ColorStateList.valueOf(toolbarColor) elevation = appBarElevation shapeAppearanceModel = ShapeAppearanceModel.builder() @@ -375,11 +394,17 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide binding.accountAvatarImageView.visible(scaledAvatarSize > 0) - val transparencyPercent = (abs(verticalOffset) / titleVisibleHeight.toFloat()).coerceAtMost(1f) + val transparencyPercent = (abs(verticalOffset) / titleVisibleHeight.toFloat()).coerceAtMost( + 1f + ) window.statusBarColor = argbEvaluator.evaluate(transparencyPercent, statusBarColorTransparent, statusBarColorOpaque) as Int - val evaluatedToolbarColor = argbEvaluator.evaluate(transparencyPercent, Color.TRANSPARENT, toolbarColor) as Int + val evaluatedToolbarColor = argbEvaluator.evaluate( + transparencyPercent, + Color.TRANSPARENT, + toolbarColor + ) as Int toolbarBackground.fillColor = ColorStateList.valueOf(evaluatedToolbarColor) @@ -397,31 +422,46 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide * Subscribe to data loaded at the view model */ private fun subscribeObservables() { - viewModel.accountData.observe(this) { - when (it) { - is Success -> onAccountChanged(it.data) - is Error -> { - Snackbar.make(binding.accountCoordinatorLayout, R.string.error_generic, Snackbar.LENGTH_LONG) + lifecycleScope.launch { + viewModel.accountData.collect { + if (it == null) return@collect + when (it) { + is Success -> onAccountChanged(it.data) + is Error -> { + Snackbar.make( + binding.accountCoordinatorLayout, + R.string.error_generic, + Snackbar.LENGTH_LONG + ) + .setAction(R.string.action_retry) { viewModel.refresh() } + .show() + } + is Loading -> { } + } + } + } + lifecycleScope.launch { + viewModel.relationshipData.collect { + val relation = it?.data + if (relation != null) { + onRelationshipChanged(relation) + } + + if (it is Error) { + Snackbar.make( + binding.accountCoordinatorLayout, + R.string.error_generic, + Snackbar.LENGTH_LONG + ) .setAction(R.string.action_retry) { viewModel.refresh() } .show() } - is Loading -> { } } } - viewModel.relationshipData.observe(this) { - val relation = it?.data - if (relation != null) { - onRelationshipChanged(relation) + lifecycleScope.launch { + viewModel.noteSaved.collect { + binding.saveNoteInfo.visible(it, View.INVISIBLE) } - - if (it is Error) { - Snackbar.make(binding.accountCoordinatorLayout, R.string.error_generic, Snackbar.LENGTH_LONG) - .setAction(R.string.action_retry) { viewModel.refresh() } - .show() - } - } - viewModel.noteSaved.observe(this) { - binding.saveNoteInfo.visible(it, View.INVISIBLE) } // "Post failed" dialog should display in this activity @@ -438,10 +478,10 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide */ private fun setupRefreshLayout() { binding.swipeToRefreshLayout.setOnRefreshListener { onRefresh() } - viewModel.isRefreshing.observe( - this - ) { isRefreshing -> - binding.swipeToRefreshLayout.isRefreshing = isRefreshing == true + lifecycleScope.launch { + viewModel.isRefreshing.collect { isRefreshing -> + binding.swipeToRefreshLayout.isRefreshing = isRefreshing == true + } } binding.swipeToRefreshLayout.setColorSchemeResources(R.color.chinwag_green) } @@ -460,25 +500,33 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide val fullUsername = getFullUsername(loadedAccount) val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager clipboard.setPrimaryClip(ClipData.newPlainText(null, fullUsername)) - Snackbar.make(binding.root, getString(R.string.account_username_copied), Snackbar.LENGTH_SHORT) + Snackbar.make( + binding.root, + getString(R.string.account_username_copied), + Snackbar.LENGTH_SHORT + ) .show() } true } } - val emojifiedNote = account.note.parseAsMastodonHtml().emojify(account.emojis, binding.accountNoteTextView, animateEmojis) + val emojifiedNote = account.note.parseAsMastodonHtml().emojify( + account.emojis, + binding.accountNoteTextView, + animateEmojis + ) setClickableText(binding.accountNoteTextView, emojifiedNote, emptyList(), null, this) - accountFieldAdapter.fields = account.fields.orEmpty() - accountFieldAdapter.emojis = account.emojis.orEmpty() + accountFieldAdapter.fields = account.fields + accountFieldAdapter.emojis = account.emojis accountFieldAdapter.notifyDataSetChanged() binding.accountLockedImageView.visible(account.locked) - binding.accountBadgeTextView.visible(account.bot) updateAccountAvatar() updateToolbar() + updateBadges() updateMovedAccount() updateRemoteAccount() updateAccountJoinedDate() @@ -491,6 +539,39 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide } } + private fun updateBadges() { + binding.accountBadgeContainer.removeAllViews() + + val isLight = resources.getBoolean(R.bool.lightNavigationBar) + + if (loadedAccount?.bot == true) { + val badgeView = + getBadge( + getColor(R.color.tusky_grey_50), + R.drawable.ic_bot_24dp, + getString(R.string.profile_badge_bot_text), + isLight + ) + binding.accountBadgeContainer.addView(badgeView) + } + + loadedAccount?.roles?.forEach { role -> + val badgeColor = if (role.color.isNotBlank()) { + Color.parseColor(role.color) + } else { + // sometimes the color is not set for a role, in this case fall back to our default blue + getColor(R.color.tusky_blue) + } + + val sb = SpannableStringBuilder("${role.name} ${viewModel.domain}") + sb.setSpan(StyleSpan(Typeface.BOLD), 0, role.name.length, 0) + + val badgeView = getBadge(badgeColor, R.drawable.profile_badge_person_24dp, sb, isLight) + + binding.accountBadgeContainer.addView(badgeView) + } + } + private fun updateAccountJoinedDate() { loadedAccount?.let { account -> try { @@ -579,7 +660,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide */ private fun updateRemoteAccount() { loadedAccount?.let { account -> - if (account.isRemote()) { + if (account.isRemote) { binding.accountRemoveView.show() binding.accountRemoveView.setOnClickListener { openLink(account.url) @@ -772,13 +853,16 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide loadedAccount?.let { loadedAccount -> val muteDomain = menu.findItem(R.id.action_mute_domain) domain = getDomain(loadedAccount.url) - if (domain.isEmpty()) { + when { // If we can't get the domain, there's no way we can mute it anyway... - menu.removeItem(R.id.action_mute_domain) - } else { - if (blockingDomain) { + // If the account is from our own domain, muting it is no-op + domain.isEmpty() || viewModel.isFromOwnDomain -> { + menu.removeItem(R.id.action_mute_domain) + } + blockingDomain -> { muteDomain.title = getString(R.string.action_unmute_domain, domain) - } else { + } + else -> { muteDomain.title = getString(R.string.action_mute_domain, domain) } } @@ -837,7 +921,9 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide } else { AlertDialog.Builder(this) .setMessage(getString(R.string.mute_domain_warning, instance)) - .setPositiveButton(getString(R.string.mute_domain_warning_dialog_ok)) { _, _ -> viewModel.blockDomain(instance) } + .setPositiveButton( + getString(R.string.mute_domain_warning_dialog_ok) + ) { _, _ -> viewModel.blockDomain(instance) } .setNegativeButton(android.R.string.cancel, null) .show() } @@ -930,7 +1016,12 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide sendIntent.action = Intent.ACTION_SEND sendIntent.putExtra(Intent.EXTRA_TEXT, url) sendIntent.type = "text/plain" - startActivity(Intent.createChooser(sendIntent, resources.getText(R.string.send_account_link_to))) + startActivity( + Intent.createChooser( + sendIntent, + resources.getText(R.string.send_account_link_to) + ) + ) } return true } @@ -942,7 +1033,12 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide sendIntent.action = Intent.ACTION_SEND sendIntent.putExtra(Intent.EXTRA_TEXT, fullUsername) sendIntent.type = "text/plain" - startActivity(Intent.createChooser(sendIntent, resources.getText(R.string.send_account_username_to))) + startActivity( + Intent.createChooser( + sendIntent, + resources.getText(R.string.send_account_username_to) + ) + ) } return true } @@ -955,7 +1051,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide return true } R.id.action_add_or_remove_from_list -> { - ListsForAccountFragment.newInstance(viewModel.accountId).show(supportFragmentManager, null) + ListSelectionFragment.newInstance(viewModel.accountId).show(supportFragmentManager, null) return true } R.id.action_mute_domain -> { @@ -973,7 +1069,9 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide } R.id.action_report -> { loadedAccount?.let { loadedAccount -> - startActivity(ReportActivity.getIntent(this, viewModel.accountId, loadedAccount.username)) + startActivity( + ReportActivity.getIntent(this, viewModel.accountId, loadedAccount.username) + ) } return true } @@ -990,7 +1088,7 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide } private fun getFullUsername(account: Account): String { - return if (account.isRemote()) { + return if (account.isRemote) { "@" + account.username } else { val localUsername = account.localUsername @@ -1000,6 +1098,51 @@ class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvide } } + private fun getBadge( + @ColorInt baseColor: Int, + @DrawableRes icon: Int, + text: CharSequence, + isLight: Boolean + ): Chip { + val badge = Chip(this) + + // text color with maximum contrast + val textColor = if (isLight) Color.BLACK else Color.WHITE + // badge color with 50% transparency so it blends in with the theme background + val backgroundColor = Color.argb( + 128, + Color.red(baseColor), + Color.green(baseColor), + Color.blue(baseColor) + ) + // a color between the text color and the badge color + val outlineColor = ColorUtils.blendARGB(textColor, baseColor, 0.7f) + + // configure the badge + badge.text = text + badge.setTextColor(textColor) + badge.chipStrokeWidth = resources.getDimension(R.dimen.profile_badge_stroke_width) + badge.chipStrokeColor = ColorStateList.valueOf(outlineColor) + badge.setChipIconResource(icon) + badge.isChipIconVisible = true + badge.chipIconSize = resources.getDimension(R.dimen.profile_badge_icon_size) + badge.chipIconTint = ColorStateList.valueOf(outlineColor) + badge.chipBackgroundColor = ColorStateList.valueOf(backgroundColor) + + // badge isn't clickable, so disable all related behavior + badge.isClickable = false + badge.isFocusable = false + badge.setEnsureMinTouchTargetSize(false) + + // reset some chip defaults so it looks better for our badge usecase + badge.iconStartPadding = resources.getDimension(R.dimen.profile_badge_icon_start_padding) + badge.iconEndPadding = resources.getDimension(R.dimen.profile_badge_icon_end_padding) + badge.minHeight = resources.getDimensionPixelSize(R.dimen.profile_badge_min_height) + badge.chipMinHeight = resources.getDimension(R.dimen.profile_badge_min_height) + badge.updatePadding(top = 0, bottom = 0) + return badge + } + override fun androidInjector() = dispatchingAndroidInjector companion object { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/account/AccountFieldAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/account/AccountFieldAdapter.kt index 86acb8132..1d6562825 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/account/AccountFieldAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/account/AccountFieldAdapter.kt @@ -38,8 +38,15 @@ class AccountFieldAdapter( override fun getItemCount() = fields.size - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder { - val binding = ItemAccountFieldBinding.inflate(LayoutInflater.from(parent.context), parent, false) + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int + ): BindingHolder { + val binding = ItemAccountFieldBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) return BindingHolder(binding) } @@ -51,11 +58,20 @@ class AccountFieldAdapter( val emojifiedName = field.name.emojify(emojis, nameTextView, animateEmojis) nameTextView.text = emojifiedName - val emojifiedValue = field.value.parseAsMastodonHtml().emojify(emojis, valueTextView, animateEmojis) + val emojifiedValue = field.value.parseAsMastodonHtml().emojify( + emojis, + valueTextView, + animateEmojis + ) setClickableText(valueTextView, emojifiedValue, emptyList(), null, linkListener) if (field.verifiedAt != null) { - valueTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, R.drawable.ic_check_circle, 0) + valueTextView.setCompoundDrawablesRelativeWithIntrinsicBounds( + 0, + 0, + R.drawable.ic_check_circle, + 0 + ) } else { valueTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, 0, 0) } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/account/AccountPagerAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/account/AccountPagerAdapter.kt index baeeea43f..f41448ebc 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/account/AccountPagerAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/account/AccountPagerAdapter.kt @@ -33,7 +33,11 @@ class AccountPagerAdapter( override fun createFragment(position: Int): Fragment { return when (position) { 0 -> TimelineFragment.newInstance(TimelineViewModel.Kind.USER, accountId, false) - 1 -> TimelineFragment.newInstance(TimelineViewModel.Kind.USER_WITH_REPLIES, accountId, false) + 1 -> TimelineFragment.newInstance( + TimelineViewModel.Kind.USER_WITH_REPLIES, + accountId, + false + ) 2 -> TimelineFragment.newInstance(TimelineViewModel.Kind.USER_PINNED, accountId, false) 3 -> AccountMediaFragment.newInstance(accountId) else -> throw AssertionError("Page $position is out of AccountPagerAdapter bounds") diff --git a/app/src/main/java/com/keylesspalace/tusky/components/account/AccountViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/account/AccountViewModel.kt index 5b32e3404..91d6dd0ea 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/account/AccountViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/account/AccountViewModel.kt @@ -1,7 +1,6 @@ package com.keylesspalace.tusky.components.account import android.util.Log -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import at.connyduck.calladapter.networkresult.fold @@ -19,58 +18,83 @@ import com.keylesspalace.tusky.util.Error import com.keylesspalace.tusky.util.Loading import com.keylesspalace.tusky.util.Resource import com.keylesspalace.tusky.util.Success -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch +import com.keylesspalace.tusky.util.getDomain import javax.inject.Inject +import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch class AccountViewModel @Inject constructor( private val mastodonApi: MastodonApi, private val eventHub: EventHub, - private val accountManager: AccountManager + accountManager: AccountManager ) : ViewModel() { - val accountData = MutableLiveData>() - val relationshipData = MutableLiveData>() + private val _accountData = MutableStateFlow(null as Resource?) + val accountData: StateFlow?> = _accountData.asStateFlow() - val noteSaved = MutableLiveData() + private val _relationshipData = MutableStateFlow(null as Resource?) + val relationshipData: StateFlow?> = _relationshipData.asStateFlow() + + private val _noteSaved = MutableStateFlow(false) + val noteSaved: StateFlow = _noteSaved.asStateFlow() + + private val _isRefreshing = MutableSharedFlow(1, onBufferOverflow = BufferOverflow.DROP_OLDEST) + val isRefreshing: SharedFlow = _isRefreshing.asSharedFlow() - val isRefreshing = MutableLiveData().apply { value = false } private var isDataLoading = false lateinit var accountId: String var isSelf = false + /** the domain of the viewed account **/ + var domain = "" + + /** True if the viewed account has the same domain as the active account */ + var isFromOwnDomain = false + private var noteUpdateJob: Job? = null + private val activeAccount = accountManager.activeAccount!! + init { viewModelScope.launch { eventHub.events.collect { event -> - if (event is ProfileEditedEvent && event.newProfileData.id == accountData.value?.data?.id) { - accountData.postValue(Success(event.newProfileData)) + if (event is ProfileEditedEvent && event.newProfileData.id == _accountData.value?.data?.id) { + _accountData.value = Success(event.newProfileData) } } } } private fun obtainAccount(reload: Boolean = false) { - if (accountData.value == null || reload) { + if (_accountData.value == null || reload) { isDataLoading = true - accountData.postValue(Loading()) + _accountData.value = Loading() viewModelScope.launch { mastodonApi.account(accountId) .fold( { account -> - accountData.postValue(Success(account)) + domain = getDomain(account.url) + isFromOwnDomain = domain == activeAccount.domain + + _accountData.value = Success(account) isDataLoading = false - isRefreshing.postValue(false) + _isRefreshing.emit(false) }, { t -> Log.w(TAG, "failed obtaining account", t) - accountData.postValue(Error(cause = t)) + _accountData.value = Error(cause = t) isDataLoading = false - isRefreshing.postValue(false) + _isRefreshing.emit(false) } ) } @@ -78,18 +102,25 @@ class AccountViewModel @Inject constructor( } private fun obtainRelationship(reload: Boolean = false) { - if (relationshipData.value == null || reload) { - relationshipData.postValue(Loading()) + if (_relationshipData.value == null || reload) { + _relationshipData.value = Loading() viewModelScope.launch { mastodonApi.relationships(listOf(accountId)) .fold( { relationships -> - relationshipData.postValue(if (relationships.isNotEmpty()) Success(relationships[0]) else Error()) + _relationshipData.value = + if (relationships.isNotEmpty()) { + Success( + relationships[0] + ) + } else { + Error() + } }, { t -> Log.w(TAG, "failed obtaining relationships", t) - relationshipData.postValue(Error(cause = t)) + _relationshipData.value = Error(cause = t) } ) } @@ -97,7 +128,7 @@ class AccountViewModel @Inject constructor( } fun changeFollowState() { - val relationship = relationshipData.value?.data + val relationship = _relationshipData.value?.data if (relationship?.following == true || relationship?.requested == true) { changeRelationship(RelationShipAction.UNFOLLOW) } else { @@ -106,7 +137,7 @@ class AccountViewModel @Inject constructor( } fun changeBlockState() { - if (relationshipData.value?.data?.blocking == true) { + if (_relationshipData.value?.data?.blocking == true) { changeRelationship(RelationShipAction.UNBLOCK) } else { changeRelationship(RelationShipAction.BLOCK) @@ -122,9 +153,9 @@ class AccountViewModel @Inject constructor( } fun changeSubscribingState() { - val relationship = relationshipData.value?.data - if (relationship?.notifying == true || /* Mastodon 3.3.0rc1 */ - relationship?.subscribing == true /* Pleroma */ + val relationship = _relationshipData.value?.data + if (relationship?.notifying == true || // Mastodon 3.3.0rc1 + relationship?.subscribing == true // Pleroma ) { changeRelationship(RelationShipAction.UNSUBSCRIBE) } else { @@ -136,9 +167,9 @@ class AccountViewModel @Inject constructor( viewModelScope.launch { mastodonApi.blockDomain(instance).fold({ eventHub.dispatch(DomainMuteEvent(instance)) - val relation = relationshipData.value?.data + val relation = _relationshipData.value?.data if (relation != null) { - relationshipData.postValue(Success(relation.copy(blockingDomain = true))) + _relationshipData.value = Success(relation.copy(blockingDomain = true)) } }, { e -> Log.e(TAG, "Error muting $instance", e) @@ -149,9 +180,9 @@ class AccountViewModel @Inject constructor( fun unblockDomain(instance: String) { viewModelScope.launch { mastodonApi.unblockDomain(instance).fold({ - val relation = relationshipData.value?.data + val relation = _relationshipData.value?.data if (relation != null) { - relationshipData.postValue(Success(relation.copy(blockingDomain = false))) + _relationshipData.value = Success(relation.copy(blockingDomain = false)) } }, { e -> Log.e(TAG, "Error unmuting $instance", e) @@ -160,7 +191,7 @@ class AccountViewModel @Inject constructor( } fun changeShowReblogsState() { - if (relationshipData.value?.data?.showingReblogs == true) { + if (_relationshipData.value?.data?.showingReblogs == true) { changeRelationship(RelationShipAction.FOLLOW, false) } else { changeRelationship(RelationShipAction.FOLLOW, true) @@ -175,9 +206,9 @@ class AccountViewModel @Inject constructor( parameter: Boolean? = null, duration: Int? = null ) = viewModelScope.launch { - val relation = relationshipData.value?.data - val account = accountData.value?.data - val isMastodon = relationshipData.value?.data?.notifying != null + val relation = _relationshipData.value?.data + val account = _accountData.value?.data + val isMastodon = _relationshipData.value?.data?.notifying != null if (relation != null && account != null) { // optimistically post new state for faster response @@ -210,7 +241,7 @@ class AccountViewModel @Inject constructor( } } } - relationshipData.postValue(Loading(newRelation)) + _relationshipData.value = Loading(newRelation) } val relationshipCall = when (relationshipAction) { @@ -245,7 +276,7 @@ class AccountViewModel @Inject constructor( relationshipCall.fold( { relationship -> - relationshipData.postValue(Success(relationship)) + _relationshipData.value = Success(relationship) when (relationshipAction) { RelationShipAction.UNFOLLOW -> eventHub.dispatch(UnfollowEvent(accountId)) @@ -256,22 +287,22 @@ class AccountViewModel @Inject constructor( }, { t -> Log.w(TAG, "failed loading relationship", t) - relationshipData.postValue(Error(relation, cause = t)) + _relationshipData.value = Error(relation, cause = t) } ) } fun noteChanged(newNote: String) { - noteSaved.postValue(false) + _noteSaved.value = false noteUpdateJob?.cancel() noteUpdateJob = viewModelScope.launch { delay(1500) mastodonApi.updateAccountNote(accountId, newNote) .fold( { - noteSaved.postValue(true) + _noteSaved.value = true delay(4000) - noteSaved.postValue(false) + _noteSaved.value = false }, { t -> Log.w(TAG, "Error updating note", t) @@ -298,12 +329,19 @@ class AccountViewModel @Inject constructor( fun setAccountInfo(accountId: String) { this.accountId = accountId - this.isSelf = accountManager.activeAccount?.accountId == accountId + this.isSelf = activeAccount.accountId == accountId reload(false) } enum class RelationShipAction { - FOLLOW, UNFOLLOW, BLOCK, UNBLOCK, MUTE, UNMUTE, SUBSCRIBE, UNSUBSCRIBE + FOLLOW, + UNFOLLOW, + BLOCK, + UNBLOCK, + MUTE, + UNMUTE, + SUBSCRIBE, + UNSUBSCRIBE } companion object { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/account/list/ListSelectionFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/account/list/ListSelectionFragment.kt new file mode 100644 index 000000000..585c7e311 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/account/list/ListSelectionFragment.kt @@ -0,0 +1,260 @@ +/* Copyright Tusky Contributors + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . + */ + +package com.keylesspalace.tusky.components.account.list + +import android.app.Dialog +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.app.AlertDialog +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import com.google.android.material.snackbar.Snackbar +import com.keylesspalace.tusky.ListsActivity +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.databinding.FragmentListsListBinding +import com.keylesspalace.tusky.databinding.ItemListBinding +import com.keylesspalace.tusky.di.Injectable +import com.keylesspalace.tusky.di.ViewModelFactory +import com.keylesspalace.tusky.entity.MastoList +import com.keylesspalace.tusky.util.BindingHolder +import com.keylesspalace.tusky.util.hide +import com.keylesspalace.tusky.util.show +import com.keylesspalace.tusky.util.visible +import javax.inject.Inject +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch + +class ListSelectionFragment : DialogFragment(), Injectable { + + interface ListSelectionListener { + fun onListSelected(list: MastoList) + } + + @Inject + lateinit var viewModelFactory: ViewModelFactory + + private val viewModel: ListsForAccountViewModel by viewModels { viewModelFactory } + + private var _binding: FragmentListsListBinding? = null + + // This property is only valid between onCreateDialog and onDestroyView + private val binding get() = _binding!! + + private val adapter = Adapter() + + private var selectListener: ListSelectionListener? = null + private var accountId: String? = null + + override fun onAttach(context: Context) { + super.onAttach(context) + selectListener = context as? ListSelectionListener + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setStyle(STYLE_NORMAL, R.style.TuskyDialogFragmentStyle) + accountId = requireArguments().getString(ARG_ACCOUNT_ID) + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val context = requireContext() + + _binding = FragmentListsListBinding.inflate(layoutInflater) + binding.listsView.adapter = adapter + + val dialogBuilder = AlertDialog.Builder(context) + .setView(binding.root) + .setTitle(R.string.select_list_title) + .setNeutralButton(R.string.select_list_manage) { _, _ -> + val listIntent = Intent(context, ListsActivity::class.java) + startActivity(listIntent) + } + .setNegativeButton(if (accountId != null) R.string.button_done else android.R.string.cancel, null) + + val dialog = dialogBuilder.create() + + val showProgressBarJob = getProgressBarJob(binding.progressBar, 500) + showProgressBarJob.start() + + // TODO change this to a (single) LoadState like elsewhere? + lifecycleScope.launch { + viewModel.states.collectLatest { states -> + binding.progressBar.hide() + showProgressBarJob.cancel() + if (states.isEmpty()) { + binding.messageView.show() + binding.messageView.setup(R.drawable.elephant_friend_empty, R.string.no_lists) + } else { + binding.listsView.show() + adapter.submitList(states) + } + } + } + + lifecycleScope.launch { + viewModel.loadError.collectLatest { error -> + Log.e(TAG, "failed to load lists", error) + binding.progressBar.hide() + showProgressBarJob.cancel() + binding.listsView.hide() + binding.messageView.apply { + show() + setup(error) { load() } + } + } + } + + lifecycleScope.launch { + viewModel.actionError.collectLatest { error -> + when (error.type) { + ActionError.Type.ADD -> { + Snackbar.make( + binding.root, + R.string.failed_to_add_to_list, + Snackbar.LENGTH_LONG + ) + .setAction(R.string.action_retry) { + viewModel.addAccountToList(accountId!!, error.listId) + } + .show() + } + ActionError.Type.REMOVE -> { + Snackbar.make( + binding.root, + R.string.failed_to_remove_from_list, + Snackbar.LENGTH_LONG + ) + .setAction(R.string.action_retry) { + viewModel.removeAccountFromList(accountId!!, error.listId) + } + .show() + } + } + } + } + + lifecycleScope.launch { + load() + } + + return dialog + } + + private fun getProgressBarJob(progressView: View, delayMs: Long) = this.lifecycleScope.launch( + start = CoroutineStart.LAZY + ) { + try { + delay(delayMs) + progressView.show() + awaitCancellation() + } finally { + progressView.hide() + } + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + private fun load() { + binding.progressBar.show() + binding.listsView.hide() + binding.messageView.hide() + viewModel.load(accountId) + } + + private object Differ : DiffUtil.ItemCallback() { + override fun areItemsTheSame( + oldItem: AccountListState, + newItem: AccountListState + ): Boolean { + return oldItem.list.id == newItem.list.id + } + + override fun areContentsTheSame( + oldItem: AccountListState, + newItem: AccountListState + ): Boolean { + return oldItem == newItem + } + } + + inner class Adapter : + ListAdapter>(Differ) { + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int + ): BindingHolder { + return BindingHolder(ItemListBinding.inflate(LayoutInflater.from(parent.context), parent, false)) + } + + override fun onBindViewHolder(holder: BindingHolder, position: Int) { + val item = getItem(position) + holder.binding.listName.text = item.list.title + accountId?.let { accountId -> + holder.binding.addButton.apply { + visible(!item.includesAccount) + setOnClickListener { + viewModel.addAccountToList(accountId, item.list.id) + } + } + holder.binding.removeButton.apply { + visible(item.includesAccount) + setOnClickListener { + viewModel.removeAccountFromList(accountId, item.list.id) + } + } + } + + holder.itemView.setOnClickListener { + selectListener?.onListSelected(item.list) + + accountId?.let { accountId -> + if (item.includesAccount) { + viewModel.removeAccountFromList(accountId, item.list.id) + } else { + viewModel.addAccountToList(accountId, item.list.id) + } + } + } + } + } + + companion object { + private const val TAG = "ListsListFragment" + private const val ARG_ACCOUNT_ID = "accountId" + + fun newInstance(accountId: String?): ListSelectionFragment { + val args = Bundle().apply { + putString(ARG_ACCOUNT_ID, accountId) + } + return ListSelectionFragment().apply { arguments = args } + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/account/list/ListsForAccountFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/account/list/ListsForAccountFragment.kt deleted file mode 100644 index 08c93756b..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/components/account/list/ListsForAccountFragment.kt +++ /dev/null @@ -1,200 +0,0 @@ -/* Copyright 2022 kyori19 - * - * This file is a part of Tusky. - * - * 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. - * - * Tusky 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 Tusky; if not, - * see . - */ - -package com.keylesspalace.tusky.components.account.list - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.LinearLayout -import androidx.fragment.app.DialogFragment -import androidx.fragment.app.viewModels -import androidx.lifecycle.lifecycleScope -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.ListAdapter -import com.google.android.material.snackbar.Snackbar -import com.keylesspalace.tusky.R -import com.keylesspalace.tusky.databinding.FragmentListsForAccountBinding -import com.keylesspalace.tusky.databinding.ItemAddOrRemoveFromListBinding -import com.keylesspalace.tusky.di.Injectable -import com.keylesspalace.tusky.di.ViewModelFactory -import com.keylesspalace.tusky.util.BindingHolder -import com.keylesspalace.tusky.util.hide -import com.keylesspalace.tusky.util.show -import com.keylesspalace.tusky.util.viewBinding -import com.keylesspalace.tusky.util.visible -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.launch -import javax.inject.Inject - -class ListsForAccountFragment : DialogFragment(), Injectable { - - @Inject - lateinit var viewModelFactory: ViewModelFactory - - private val viewModel: ListsForAccountViewModel by viewModels { viewModelFactory } - private val binding by viewBinding(FragmentListsForAccountBinding::bind) - - private val adapter = Adapter() - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setStyle(STYLE_NORMAL, R.style.TuskyDialogFragmentStyle) - - viewModel.setup(requireArguments().getString(ARG_ACCOUNT_ID)!!) - } - - override fun onStart() { - super.onStart() - dialog?.apply { - window?.setLayout( - LinearLayout.LayoutParams.MATCH_PARENT, - LinearLayout.LayoutParams.MATCH_PARENT - ) - } - } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - return inflater.inflate(R.layout.fragment_lists_for_account, container, false) - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - binding.listsView.layoutManager = LinearLayoutManager(view.context) - binding.listsView.adapter = adapter - - viewLifecycleOwner.lifecycleScope.launch { - viewModel.states.collectLatest { states -> - binding.progressBar.hide() - if (states.isEmpty()) { - binding.messageView.show() - binding.messageView.setup(R.drawable.elephant_friend_empty, R.string.no_lists) { - load() - } - } else { - binding.listsView.show() - adapter.submitList(states) - } - } - } - - viewLifecycleOwner.lifecycleScope.launch { - viewModel.loadError.collectLatest { error -> - binding.progressBar.hide() - binding.listsView.hide() - binding.messageView.apply { - show() - setup(error) { load() } - } - } - } - - viewLifecycleOwner.lifecycleScope.launch { - viewModel.actionError.collectLatest { error -> - when (error.type) { - ActionError.Type.ADD -> { - Snackbar.make(binding.root, R.string.failed_to_add_to_list, Snackbar.LENGTH_LONG) - .setAction(R.string.action_retry) { - viewModel.addAccountToList(error.listId) - } - .show() - } - ActionError.Type.REMOVE -> { - Snackbar.make(binding.root, R.string.failed_to_remove_from_list, Snackbar.LENGTH_LONG) - .setAction(R.string.action_retry) { - viewModel.removeAccountFromList(error.listId) - } - .show() - } - } - } - } - - binding.doneButton.setOnClickListener { - dismiss() - } - - load() - } - - private fun load() { - binding.progressBar.show() - binding.listsView.hide() - binding.messageView.hide() - viewModel.load() - } - - private object Differ : DiffUtil.ItemCallback() { - override fun areItemsTheSame( - oldItem: AccountListState, - newItem: AccountListState - ): Boolean { - return oldItem.list.id == newItem.list.id - } - - override fun areContentsTheSame( - oldItem: AccountListState, - newItem: AccountListState - ): Boolean { - return oldItem == newItem - } - } - - inner class Adapter : - ListAdapter>(Differ) { - override fun onCreateViewHolder( - parent: ViewGroup, - viewType: Int - ): BindingHolder { - val binding = - ItemAddOrRemoveFromListBinding.inflate(LayoutInflater.from(parent.context), parent, false) - return BindingHolder(binding) - } - - override fun onBindViewHolder(holder: BindingHolder, position: Int) { - val item = getItem(position) - holder.binding.listNameView.text = item.list.title - holder.binding.addButton.apply { - visible(!item.includesAccount) - setOnClickListener { - viewModel.addAccountToList(item.list.id) - } - } - holder.binding.removeButton.apply { - visible(item.includesAccount) - setOnClickListener { - viewModel.removeAccountFromList(item.list.id) - } - } - } - } - - companion object { - private const val ARG_ACCOUNT_ID = "accountId" - - fun newInstance(accountId: String): ListsForAccountFragment { - val args = Bundle().apply { - putString(ARG_ACCOUNT_ID, accountId) - } - return ListsForAccountFragment().apply { arguments = args } - } - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/account/list/ListsForAccountViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/account/list/ListsForAccountViewModel.kt index 110096966..b0546eceb 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/account/list/ListsForAccountViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/account/list/ListsForAccountViewModel.kt @@ -24,14 +24,13 @@ import at.connyduck.calladapter.networkresult.onSuccess import at.connyduck.calladapter.networkresult.runCatching import com.keylesspalace.tusky.entity.MastoList import com.keylesspalace.tusky.network.MastodonApi +import javax.inject.Inject import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch -import javax.inject.Inject data class AccountListState( val list: MastoList, @@ -54,35 +53,30 @@ class ListsForAccountViewModel @Inject constructor( private val mastodonApi: MastodonApi ) : ViewModel() { - private lateinit var accountId: String - private val _states = MutableSharedFlow>(1) - val states: SharedFlow> = _states + val states: SharedFlow> = _states.asSharedFlow() private val _loadError = MutableSharedFlow(1) - val loadError: SharedFlow = _loadError + val loadError: SharedFlow = _loadError.asSharedFlow() private val _actionError = MutableSharedFlow(1) - val actionError: SharedFlow = _actionError + val actionError: SharedFlow = _actionError.asSharedFlow() - fun setup(accountId: String) { - this.accountId = accountId - } - - fun load() { + fun load(accountId: String?) { _loadError.resetReplayCache() viewModelScope.launch { runCatching { - val (all, includes) = listOf( - async { mastodonApi.getLists() }, - async { mastodonApi.getListsIncludesAccount(accountId) } - ).awaitAll() + val all = mastodonApi.getLists().getOrThrow() + var includes: List = emptyList() + if (accountId != null) { + includes = mastodonApi.getListsIncludesAccount(accountId).getOrThrow() + } _states.emit( - all.getOrThrow().map { list -> + all.map { listState -> AccountListState( - list = list, - includesAccount = includes.getOrThrow().any { it.id == list.id } + list = listState, + includesAccount = includes.any { it.id == listState.id } ) } ) @@ -93,7 +87,9 @@ class ListsForAccountViewModel @Inject constructor( } } - fun addAccountToList(listId: String) { + // TODO there is no "progress" visible for these + + fun addAccountToList(accountId: String, listId: String) { _actionError.resetReplayCache() viewModelScope.launch { mastodonApi.addAccountToList(listId, listOf(accountId)) @@ -114,7 +110,7 @@ class ListsForAccountViewModel @Inject constructor( } } - fun removeAccountFromList(listId: String) { + fun removeAccountFromList(accountId: String, listId: String) { _actionError.resetReplayCache() viewModelScope.launch { mastodonApi.deleteAccountFromList(listId, listOf(accountId)) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaFragment.kt index 680e598fd..d0f534a9e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaFragment.kt @@ -49,9 +49,9 @@ import com.mikepenz.iconics.IconicsDrawable import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial import com.mikepenz.iconics.utils.colorInt import com.mikepenz.iconics.utils.sizeDp +import javax.inject.Inject import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch -import javax.inject.Inject /** * Fragment with multiple columns of media previews for the specified account. @@ -82,22 +82,23 @@ class AccountMediaFragment : override fun onViewCreated(view: View, savedInstanceState: Bundle?) { requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED) - val alwaysShowSensitiveMedia = accountManager.activeAccount!!.alwaysShowSensitiveMedia - val preferences = PreferenceManager.getDefaultSharedPreferences(view.context) val useBlurhash = preferences.getBoolean(PrefKeys.USE_BLURHASH, true) adapter = AccountMediaGridAdapter( - alwaysShowSensitiveMedia = alwaysShowSensitiveMedia, useBlurhash = useBlurhash, context = view.context, onAttachmentClickListener = ::onAttachmentClick ) val columnCount = view.context.resources.getInteger(R.integer.profile_media_column_count) - val imageSpacing = view.context.resources.getDimensionPixelSize(R.dimen.profile_media_spacing) + val imageSpacing = view.context.resources.getDimensionPixelSize( + R.dimen.profile_media_spacing + ) - binding.recyclerView.addItemDecoration(GridSpacingItemDecoration(columnCount, imageSpacing, 0)) + binding.recyclerView.addItemDecoration( + GridSpacingItemDecoration(columnCount, imageSpacing, 0) + ) binding.recyclerView.layoutManager = GridLayoutManager(view.context, columnCount) binding.recyclerView.adapter = adapter @@ -127,7 +128,11 @@ class AccountMediaFragment : is LoadState.NotLoading -> { if (loadState.append is LoadState.NotLoading && loadState.source.refresh is LoadState.NotLoading) { binding.statusView.show() - binding.statusView.setup(R.drawable.elephant_friend_empty, R.string.message_empty, null) + binding.statusView.setup( + R.drawable.elephant_friend_empty, + R.string.message_empty, + null + ) } } is LoadState.Error -> { @@ -178,11 +183,19 @@ class AccountMediaFragment : Attachment.Type.GIFV, Attachment.Type.VIDEO, Attachment.Type.AUDIO -> { - val intent = ViewMediaActivity.newIntent(context, attachmentsFromSameStatus, currentIndex) + val intent = ViewMediaActivity.newIntent( + context, + attachmentsFromSameStatus, + currentIndex + ) if (activity != null) { val url = selected.attachment.url ViewCompat.setTransitionName(view, url) - val options = ActivityOptionsCompat.makeSceneTransitionAnimation(requireActivity(), view, url) + val options = ActivityOptionsCompat.makeSceneTransitionAnimation( + requireActivity(), + view, + url + ) startActivity(intent, options.toBundle()) } else { startActivity(intent) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaGridAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaGridAdapter.kt index 48b3a21da..c392927d3 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaGridAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaGridAdapter.kt @@ -21,36 +21,57 @@ import com.keylesspalace.tusky.util.getFormattedDescription import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.viewdata.AttachmentViewData -import java.util.Random +import kotlin.random.Random class AccountMediaGridAdapter( - private val alwaysShowSensitiveMedia: Boolean, private val useBlurhash: Boolean, context: Context, private val onAttachmentClickListener: (AttachmentViewData, View) -> Unit ) : PagingDataAdapter>( object : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: AttachmentViewData, newItem: AttachmentViewData): Boolean { + override fun areItemsTheSame( + oldItem: AttachmentViewData, + newItem: AttachmentViewData + ): Boolean { return oldItem.attachment.id == newItem.attachment.id } - override fun areContentsTheSame(oldItem: AttachmentViewData, newItem: AttachmentViewData): Boolean { + override fun areContentsTheSame( + oldItem: AttachmentViewData, + newItem: AttachmentViewData + ): Boolean { return oldItem == newItem } } ) { - private val baseItemBackgroundColor = MaterialColors.getColor(context, com.google.android.material.R.attr.colorSurface, Color.BLACK) - private val videoIndicator = AppCompatResources.getDrawable(context, R.drawable.ic_play_indicator) - private val mediaHiddenDrawable = AppCompatResources.getDrawable(context, R.drawable.ic_hide_media_24dp) + private val baseItemBackgroundColor = MaterialColors.getColor( + context, + com.google.android.material.R.attr.colorSurface, + Color.BLACK + ) + private val videoIndicator = AppCompatResources.getDrawable( + context, + R.drawable.ic_play_indicator + ) + private val mediaHiddenDrawable = AppCompatResources.getDrawable( + context, + R.drawable.ic_hide_media_24dp + ) private val itemBgBaseHSV = FloatArray(3) - private val random = Random() - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder { - val binding = ItemAccountMediaBinding.inflate(LayoutInflater.from(parent.context), parent, false) + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int + ): BindingHolder { + val binding = ItemAccountMediaBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) Color.colorToHSV(baseItemBackgroundColor, itemBgBaseHSV) - itemBgBaseHSV[2] = itemBgBaseHSV[2] + random.nextFloat() / 3f - 1f / 6f + itemBgBaseHSV[2] = itemBgBaseHSV[2] + Random.nextFloat() / 3f - 1f / 6f binding.root.setBackgroundColor(Color.HSVToColor(itemBgBaseHSV)) return BindingHolder(binding) } @@ -72,7 +93,11 @@ class AccountMediaGridAdapter( if (item.attachment.type == Attachment.Type.AUDIO) { overlay.hide() - imageView.setPadding(context.resources.getDimensionPixelSize(R.dimen.profile_media_audio_icon_padding)) + imageView.setPadding( + context.resources.getDimensionPixelSize( + R.dimen.profile_media_audio_icon_padding + ) + ) Glide.with(imageView) .load(R.drawable.ic_music_box_preview_24dp) @@ -80,7 +105,7 @@ class AccountMediaGridAdapter( .into(imageView) imageView.contentDescription = item.attachment.getFormattedDescription(context) - } else if (item.sensitive && !item.isRevealed && !alwaysShowSensitiveMedia) { + } else if (item.sensitive && !item.isRevealed) { overlay.show() overlay.setImageDrawable(mediaHiddenDrawable) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaRemoteMediator.kt b/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaRemoteMediator.kt index 315b0380c..77fb1dfb1 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaRemoteMediator.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaRemoteMediator.kt @@ -20,6 +20,7 @@ import androidx.paging.LoadType import androidx.paging.PagingState import androidx.paging.RemoteMediator import com.keylesspalace.tusky.components.timeline.util.ifExpected +import com.keylesspalace.tusky.db.AccountEntity import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.viewdata.AttachmentViewData import retrofit2.HttpException @@ -27,9 +28,9 @@ import retrofit2.HttpException @OptIn(ExperimentalPagingApi::class) class AccountMediaRemoteMediator( private val api: MastodonApi, + private val activeAccount: AccountEntity, private val viewModel: AccountMediaViewModel ) : RemoteMediator() { - override suspend fun load( loadType: LoadType, state: PagingState @@ -58,7 +59,7 @@ class AccountMediaRemoteMediator( } val attachments = statuses.flatMap { status -> - AttachmentViewData.list(status) + AttachmentViewData.list(status, activeAccount.alwaysShowSensitiveMedia) } if (loadType == LoadType.REFRESH) { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaViewModel.kt index ddbcb71d0..b42a7282d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaViewModel.kt @@ -21,11 +21,13 @@ import androidx.paging.ExperimentalPagingApi import androidx.paging.Pager import androidx.paging.PagingConfig import androidx.paging.cachedIn +import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.viewdata.AttachmentViewData import javax.inject.Inject class AccountMediaViewModel @Inject constructor( + accountManager: AccountManager, api: MastodonApi ) : ViewModel() { @@ -35,6 +37,8 @@ class AccountMediaViewModel @Inject constructor( var currentSource: AccountMediaPagingSource? = null + val activeAccount = accountManager.activeAccount!! + @OptIn(ExperimentalPagingApi::class) val media = Pager( config = PagingConfig( @@ -48,7 +52,7 @@ class AccountMediaViewModel @Inject constructor( currentSource = source } }, - remoteMediator = AccountMediaRemoteMediator(api, this) + remoteMediator = AccountMediaRemoteMediator(api, activeAccount, this) ).flow .cachedIn(viewModelScope) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountListActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountListActivity.kt index f5981c0bc..2419dc915 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountListActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountListActivity.kt @@ -48,7 +48,6 @@ class AccountListActivity : BottomSheetActivity(), HasAndroidInjector { val type = intent.getSerializableExtra(EXTRA_TYPE) as Type val id: String? = intent.getStringExtra(EXTRA_ID) - val accountLocked: Boolean = intent.getBooleanExtra(EXTRA_ACCOUNT_LOCKED, false) setSupportActionBar(binding.includedToolbar.toolbar) supportActionBar?.apply { @@ -66,7 +65,7 @@ class AccountListActivity : BottomSheetActivity(), HasAndroidInjector { } supportFragmentManager.commit { - replace(R.id.fragment_container, AccountListFragment.newInstance(type, id, accountLocked)) + replace(R.id.fragment_container, AccountListFragment.newInstance(type, id)) } } @@ -75,13 +74,11 @@ class AccountListActivity : BottomSheetActivity(), HasAndroidInjector { companion object { private const val EXTRA_TYPE = "type" private const val EXTRA_ID = "id" - private const val EXTRA_ACCOUNT_LOCKED = "acc_locked" - fun newIntent(context: Context, type: Type, id: String? = null, accountLocked: Boolean = false): Intent { + fun newIntent(context: Context, type: Type, id: String? = null): Intent { return Intent(context, AccountListActivity::class.java).apply { putExtra(EXTRA_TYPE, type) putExtra(EXTRA_ID, id) - putExtra(EXTRA_ACCOUNT_LOCKED, accountLocked) } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountListFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountListFragment.kt index 0287bfb4b..48ac0c021 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountListFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountListFragment.kt @@ -19,7 +19,6 @@ import android.os.Bundle import android.util.Log import android.view.View import androidx.fragment.app.Fragment -import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.preference.PreferenceManager import androidx.recyclerview.widget.ConcatAdapter @@ -28,10 +27,7 @@ import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.SimpleItemAnimator import at.connyduck.calladapter.networkresult.fold -import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider.from -import autodispose2.autoDispose import com.google.android.material.snackbar.Snackbar -import com.keylesspalace.tusky.BaseActivity import com.keylesspalace.tusky.BottomSheetActivity import com.keylesspalace.tusky.PostLookupFallbackBehavior import com.keylesspalace.tusky.R @@ -56,13 +52,13 @@ import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.util.HttpHeaderLink import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.show +import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.view.EndlessOnScrollListener -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import javax.inject.Inject +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.launch import retrofit2.Response -import java.io.IOException -import javax.inject.Inject class AccountListFragment : Fragment(R.layout.fragment_account_list), @@ -97,7 +93,9 @@ class AccountListFragment : val layoutManager = LinearLayoutManager(view.context) binding.recyclerView.layoutManager = layoutManager (binding.recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false - binding.recyclerView.addItemDecoration(DividerItemDecoration(view.context, DividerItemDecoration.VERTICAL)) + binding.recyclerView.addItemDecoration( + DividerItemDecoration(view.context, DividerItemDecoration.VERTICAL) + ) binding.swipeRefreshLayout.setOnRefreshListener { fetchAccounts() } binding.swipeRefreshLayout.setColorSchemeResources(R.color.chinwag_green) @@ -107,15 +105,18 @@ class AccountListFragment : val animateEmojis = pm.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false) val showBotOverlay = pm.getBoolean(PrefKeys.SHOW_BOT_OVERLAY, true) + val activeAccount = accountManager.activeAccount!! + adapter = when (type) { Type.BLOCKS -> BlocksAdapter(this, animateAvatar, animateEmojis, showBotOverlay) Type.MUTES -> MutesAdapter(this, animateAvatar, animateEmojis, showBotOverlay) Type.FOLLOW_REQUESTS -> { val headerAdapter = FollowRequestsHeaderAdapter( - instanceName = accountManager.activeAccount!!.domain, - accountLocked = arguments?.getBoolean(ARG_ACCOUNT_LOCKED) == true + instanceName = activeAccount.domain, + accountLocked = activeAccount.locked ) - val followRequestsAdapter = FollowRequestsAdapter(this, this, animateAvatar, animateEmojis, showBotOverlay) + val followRequestsAdapter = + FollowRequestsAdapter(this, this, animateAvatar, animateEmojis, showBotOverlay) binding.recyclerView.adapter = ConcatAdapter(headerAdapter, followRequestsAdapter) followRequestsAdapter } @@ -140,15 +141,13 @@ class AccountListFragment : } override fun onViewTag(tag: String) { - (activity as BaseActivity?) - ?.startActivityWithSlideInAnimation(StatusListActivity.newHashtagIntent(requireContext(), tag)) + activity?.startActivityWithSlideInAnimation( + StatusListActivity.newHashtagIntent(requireContext(), tag) + ) } override fun onViewAccount(id: String) { - (activity as BaseActivity?)?.let { - val intent = AccountActivity.getIntent(it, id) - it.startActivityWithSlideInAnimation(intent) - } + activity?.startActivityWithSlideInAnimation(AccountActivity.getIntent(requireContext(), id)) } override fun onViewUrl(url: String) { @@ -224,7 +223,11 @@ class AccountListFragment : val unblockedUser = blocksAdapter.removeItem(position) if (unblockedUser != null) { - Snackbar.make(binding.recyclerView, R.string.confirmation_unblocked, Snackbar.LENGTH_LONG) + Snackbar.make( + binding.recyclerView, + R.string.confirmation_unblocked, + Snackbar.LENGTH_LONG + ) .setAction(R.string.action_undo) { blocksAdapter.addItem(unblockedUser, position) onBlock(true, id, position) @@ -242,22 +245,17 @@ class AccountListFragment : Log.e(TAG, "Failed to $verb account accountId $accountId") } - override fun onRespondToFollowRequest( - accept: Boolean, - accountId: String, - position: Int - ) { - if (accept) { - api.authorizeFollowRequest(accountId) - } else { - api.rejectFollowRequest(accountId) - }.observeOn(AndroidSchedulers.mainThread()) - .autoDispose(from(this, Lifecycle.Event.ON_DESTROY)) - .subscribe( - { + override fun onRespondToFollowRequest(accept: Boolean, accountId: String, position: Int) { + viewLifecycleOwner.lifecycleScope.launch { + if (accept) { + api.authorizeFollowRequest(accountId) + } else { + api.rejectFollowRequest(accountId) + }.fold( + onSuccess = { onRespondToFollowRequestSuccess(position) }, - { throwable -> + onFailure = { throwable -> val verb = if (accept) { "accept" } else { @@ -266,6 +264,7 @@ class AccountListFragment : Log.e(TAG, "Failed to $verb account id $accountId.", throwable) } ) + } } private fun onRespondToFollowRequestSuccess(position: Int) { @@ -330,7 +329,13 @@ class AccountListFragment : val linkHeader = response.headers()["Link"] onFetchAccountsSuccess(accountList, linkHeader) - } catch (exception: IOException) { + } catch (exception: Exception) { + if (exception is CancellationException) { + // Scope is cancelled, probably because the fragment is destroyed. + // We must not touch any views anymore, so rethrow the exception. + // (CancellationException in a cancelled scope is normal and will be ignored) + throw exception + } onFetchAccountsFailure(exception) } } @@ -404,14 +409,12 @@ class AccountListFragment : private const val TAG = "AccountList" // logging tag private const val ARG_TYPE = "type" private const val ARG_ID = "id" - private const val ARG_ACCOUNT_LOCKED = "acc_locked" - fun newInstance(type: Type, id: String? = null, accountLocked: Boolean = false): AccountListFragment { + fun newInstance(type: Type, id: String? = null): AccountListFragment { return AccountListFragment().apply { arguments = Bundle(3).apply { putSerializable(ARG_TYPE, type) putString(ARG_ID, id) - putBoolean(ARG_ACCOUNT_LOCKED, accountLocked) } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/AccountAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/AccountAdapter.kt index 7d050e7e4..1a9a7513c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/AccountAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/AccountAdapter.kt @@ -60,9 +60,7 @@ abstract class AccountAdapter internal constructo } } - private fun createFooterViewHolder( - parent: ViewGroup - ): RecyclerView.ViewHolder { + private fun createFooterViewHolder(parent: ViewGroup): RecyclerView.ViewHolder { val binding = ItemFooterBinding.inflate(LayoutInflater.from(parent.context), parent, false) return BindingHolder(binding) } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/BlocksAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/BlocksAdapter.kt index 2ef520d5e..c1132e7f7 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/BlocksAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/BlocksAdapter.kt @@ -39,16 +39,27 @@ class BlocksAdapter( ) { override fun createAccountViewHolder(parent: ViewGroup): BindingHolder { - val binding = ItemBlockedUserBinding.inflate(LayoutInflater.from(parent.context), parent, false) + val binding = ItemBlockedUserBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) return BindingHolder(binding) } - override fun onBindAccountViewHolder(viewHolder: BindingHolder, position: Int) { + override fun onBindAccountViewHolder( + viewHolder: BindingHolder, + position: Int + ) { val account = accountList[position] val binding = viewHolder.binding val context = binding.root.context - val emojifiedName = account.name.emojify(account.emojis, binding.blockedUserDisplayName, animateEmojis) + val emojifiedName = account.name.emojify( + account.emojis, + binding.blockedUserDisplayName, + animateEmojis + ) binding.blockedUserDisplayName.text = emojifiedName val formattedUsername = context.getString(R.string.post_username_format, account.username) binding.blockedUserUsername.text = formattedUsername diff --git a/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/FollowRequestsAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/FollowRequestsAdapter.kt index fc860e59e..cdce38142 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/FollowRequestsAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/FollowRequestsAdapter.kt @@ -44,7 +44,6 @@ class FollowRequestsAdapter( ) return FollowRequestViewHolder( binding, - accountActionListener, linkListener, showHeader = false ) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/FollowRequestsHeaderAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/FollowRequestsHeaderAdapter.kt index 85cf4e20a..069e799e1 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/FollowRequestsHeaderAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/FollowRequestsHeaderAdapter.kt @@ -27,12 +27,22 @@ class FollowRequestsHeaderAdapter( private val accountLocked: Boolean ) : RecyclerView.Adapter>() { - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder { - val binding = ItemFollowRequestsHeaderBinding.inflate(LayoutInflater.from(parent.context), parent, false) + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int + ): BindingHolder { + val binding = ItemFollowRequestsHeaderBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) return BindingHolder(binding) } - override fun onBindViewHolder(viewHolder: BindingHolder, position: Int) { + override fun onBindViewHolder( + viewHolder: BindingHolder, + position: Int + ) { viewHolder.binding.root.text = viewHolder.binding.root.context.getString(R.string.follow_requests_info, instanceName) } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/MutesAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/MutesAdapter.kt index 288d13394..d685730de 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/MutesAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/MutesAdapter.kt @@ -42,18 +42,29 @@ class MutesAdapter( private val mutingNotificationsMap = HashMap() override fun createAccountViewHolder(parent: ViewGroup): BindingHolder { - val binding = ItemMutedUserBinding.inflate(LayoutInflater.from(parent.context), parent, false) + val binding = ItemMutedUserBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) return BindingHolder(binding) } - override fun onBindAccountViewHolder(viewHolder: BindingHolder, position: Int) { + override fun onBindAccountViewHolder( + viewHolder: BindingHolder, + position: Int + ) { val account = accountList[position] val binding = viewHolder.binding val context = binding.root.context val mutingNotifications = mutingNotificationsMap[account.id] - val emojifiedName = account.name.emojify(account.emojis, binding.mutedUserDisplayName, animateEmojis) + val emojifiedName = account.name.emojify( + account.emojis, + binding.mutedUserDisplayName, + animateEmojis + ) binding.mutedUserDisplayName.text = emojifiedName val formattedUsername = context.getString(R.string.post_username_format, account.username) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementAdapter.kt index 8f30c5e49..1ce538927 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementAdapter.kt @@ -15,6 +15,7 @@ package com.keylesspalace.tusky.components.announcements +import android.annotation.SuppressLint import android.os.Build import android.text.SpannableStringBuilder import android.view.ContextThemeWrapper @@ -29,13 +30,13 @@ import com.keylesspalace.tusky.R import com.keylesspalace.tusky.databinding.ItemAnnouncementBinding import com.keylesspalace.tusky.entity.Announcement import com.keylesspalace.tusky.interfaces.LinkListener +import com.keylesspalace.tusky.util.AbsoluteTimeFormatter import com.keylesspalace.tusky.util.BindingHolder import com.keylesspalace.tusky.util.EmojiSpan import com.keylesspalace.tusky.util.emojify import com.keylesspalace.tusky.util.parseAsMastodonHtml import com.keylesspalace.tusky.util.setClickableText import com.keylesspalace.tusky.util.visible -import java.lang.ref.WeakReference interface AnnouncementActionListener : LinkListener { fun openReactionPicker(announcementId: String, target: View) @@ -50,19 +51,35 @@ class AnnouncementAdapter( private val animateEmojis: Boolean = false ) : RecyclerView.Adapter>() { - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder { - val binding = ItemAnnouncementBinding.inflate(LayoutInflater.from(parent.context), parent, false) + private val absoluteTimeFormatter = AbsoluteTimeFormatter() + + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int + ): BindingHolder { + val binding = ItemAnnouncementBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) return BindingHolder(binding) } + @SuppressLint("SetTextI18n") override fun onBindViewHolder(holder: BindingHolder, position: Int) { val item = items[position] + holder.binding.announcementDate.text = absoluteTimeFormatter.format(item.publishedAt, false) + val text = holder.binding.text val chips = holder.binding.chipGroup val addReactionChip = holder.binding.addReactionChip - val emojifiedText: CharSequence = item.content.parseAsMastodonHtml().emojify(item.emojis, text, animateEmojis) + val emojifiedText: CharSequence = item.content.parseAsMastodonHtml().emojify( + item.emojis, + text, + animateEmojis + ) setClickableText(text, emojifiedText, item.mentions, item.tags, listener) @@ -93,14 +110,20 @@ class AnnouncementAdapter( // we set the EmojiSpan on a space, because otherwise the Chip won't have the right size // https://github.com/tuskyapp/Tusky/issues/2308 val spanBuilder = SpannableStringBuilder(" ${reaction.count}") - val span = EmojiSpan(WeakReference(this)) + val span = EmojiSpan(this) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { span.contentDescription = reaction.name } spanBuilder.setSpan(span, 0, 1, 0) Glide.with(this) .asDrawable() - .load(if (animateEmojis) { reaction.url } else { reaction.staticUrl }) + .load( + if (animateEmojis) { + reaction.url + } else { + reaction.staticUrl + } + ) .into(span.getTarget(animateEmojis)) this.text = spanBuilder } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementsActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementsActivity.kt index 73fd84a6d..80e6d7af4 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementsActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementsActivity.kt @@ -26,6 +26,7 @@ import android.view.View import android.widget.PopupWindow import androidx.activity.viewModels import androidx.core.view.MenuProvider +import androidx.lifecycle.lifecycleScope import androidx.preference.PreferenceManager import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager @@ -44,6 +45,7 @@ import com.keylesspalace.tusky.util.Loading import com.keylesspalace.tusky.util.Success import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.show +import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation import com.keylesspalace.tusky.util.unsafeLazy import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.view.EmojiPicker @@ -52,6 +54,7 @@ import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial import com.mikepenz.iconics.utils.colorInt import com.mikepenz.iconics.utils.sizeDp import javax.inject.Inject +import kotlinx.coroutines.launch class AnnouncementsActivity : BottomSheetActivity(), @@ -110,35 +113,46 @@ class AnnouncementsActivity : binding.announcementsList.adapter = adapter - viewModel.announcements.observe(this) { - when (it) { - is Success -> { - binding.progressBar.hide() - binding.swipeRefreshLayout.isRefreshing = false - if (it.data.isNullOrEmpty()) { - binding.errorMessageView.setup(R.drawable.elephant_friend_empty, R.string.no_announcements) - binding.errorMessageView.show() - } else { + lifecycleScope.launch { + viewModel.announcements.collect { + if (it == null) return@collect + when (it) { + is Success -> { + binding.progressBar.hide() + binding.swipeRefreshLayout.isRefreshing = false + if (it.data.isNullOrEmpty()) { + binding.errorMessageView.setup( + R.drawable.elephant_friend_empty, + R.string.no_announcements + ) + binding.errorMessageView.show() + } else { + binding.errorMessageView.hide() + } + adapter.updateList(it.data ?: listOf()) + } + is Loading -> { binding.errorMessageView.hide() } - adapter.updateList(it.data ?: listOf()) - } - is Loading -> { - binding.errorMessageView.hide() - } - is Error -> { - binding.progressBar.hide() - binding.swipeRefreshLayout.isRefreshing = false - binding.errorMessageView.setup(R.drawable.elephant_error, R.string.error_generic) { - refreshAnnouncements() + is Error -> { + binding.progressBar.hide() + binding.swipeRefreshLayout.isRefreshing = false + binding.errorMessageView.setup( + R.drawable.errorphant_error, + R.string.error_generic + ) { + refreshAnnouncements() + } + binding.errorMessageView.show() } - binding.errorMessageView.show() } } } - viewModel.emojis.observe(this) { - picker.adapter = EmojiAdapter(it, this, animateEmojis) + lifecycleScope.launch { + viewModel.emoji.collect { + picker.adapter = EmojiAdapter(it, this@AnnouncementsActivity, animateEmojis) + } } viewModel.load() diff --git a/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementsViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementsViewModel.kt index 8abad91ac..1e1503c6e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementsViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementsViewModel.kt @@ -16,8 +16,6 @@ package com.keylesspalace.tusky.components.announcements import android.util.Log -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import at.connyduck.calladapter.networkresult.fold @@ -31,8 +29,11 @@ import com.keylesspalace.tusky.util.Error import com.keylesspalace.tusky.util.Loading import com.keylesspalace.tusky.util.Resource import com.keylesspalace.tusky.util.Success -import kotlinx.coroutines.launch import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch class AnnouncementsViewModel @Inject constructor( private val instanceInfoRepo: InstanceInfoRepository, @@ -40,31 +41,33 @@ class AnnouncementsViewModel @Inject constructor( private val eventHub: EventHub ) : ViewModel() { - private val announcementsMutable = MutableLiveData>>() - val announcements: LiveData>> = announcementsMutable + private val _announcements = MutableStateFlow(null as Resource>?) + val announcements: StateFlow>?> = _announcements.asStateFlow() - private val emojisMutable = MutableLiveData>() - val emojis: LiveData> = emojisMutable + private val _emoji = MutableStateFlow(emptyList()) + val emoji: StateFlow> = _emoji.asStateFlow() init { viewModelScope.launch { - emojisMutable.postValue(instanceInfoRepo.getEmojis()) + _emoji.value = instanceInfoRepo.getEmojis() } } fun load() { viewModelScope.launch { - announcementsMutable.postValue(Loading()) + _announcements.value = Loading() mastodonApi.listAnnouncements() .fold( { - announcementsMutable.postValue(Success(it)) + _announcements.value = Success(it) it.filter { announcement -> !announcement.read } .forEach { announcement -> mastodonApi.dismissAnnouncement(announcement.id) .fold( { - eventHub.dispatch(AnnouncementReadEvent(announcement.id)) + eventHub.dispatch( + AnnouncementReadEvent(announcement.id) + ) }, { throwable -> Log.d( @@ -77,7 +80,7 @@ class AnnouncementsViewModel @Inject constructor( } }, { - announcementsMutable.postValue(Error(cause = it)) + _announcements.value = Error(cause = it) } ) } @@ -88,9 +91,9 @@ class AnnouncementsViewModel @Inject constructor( mastodonApi.addAnnouncementReaction(announcementId, name) .fold( { - announcementsMutable.postValue( + _announcements.value = Success( - announcements.value!!.data!!.map { announcement -> + announcements.value?.data?.map { announcement -> if (announcement.id == announcementId) { announcement.copy( reactions = if (announcement.reactions.find { reaction -> reaction.name == name } != null) { @@ -107,7 +110,7 @@ class AnnouncementsViewModel @Inject constructor( } else { listOf( *announcement.reactions.toTypedArray(), - emojis.value!!.find { emoji -> emoji.shortcode == name }!!.run { + emoji.value.find { emoji -> emoji.shortcode == name }!!.run { Announcement.Reaction( name, 1, @@ -124,7 +127,6 @@ class AnnouncementsViewModel @Inject constructor( } } ) - ) }, { Log.w(TAG, "Failed to add reaction to the announcement.", it) @@ -138,7 +140,7 @@ class AnnouncementsViewModel @Inject constructor( mastodonApi.removeAnnouncementReaction(announcementId, name) .fold( { - announcementsMutable.postValue( + _announcements.value = Success( announcements.value!!.data!!.map { announcement -> if (announcement.id == announcementId) { @@ -163,7 +165,6 @@ class AnnouncementsViewModel @Inject constructor( } } ) - ) }, { Log.w(TAG, "Failed to remove reaction from the announcement.", it) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeActivity.kt index c0c4579c0..de354eda3 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeActivity.kt @@ -16,7 +16,6 @@ package com.keylesspalace.tusky.components.compose import android.Manifest -import android.app.NotificationManager import android.app.ProgressDialog import android.content.ClipData import android.content.Context @@ -26,6 +25,7 @@ import android.content.pm.PackageManager import android.graphics.Bitmap import android.graphics.PorterDuff import android.graphics.PorterDuffColorFilter +import android.icu.text.BreakIterator import android.net.Uri import android.os.Build import android.os.Bundle @@ -70,6 +70,7 @@ import com.canhub.cropper.CropImage import com.canhub.cropper.CropImageContract import com.canhub.cropper.options import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetBehavior.BottomSheetCallback import com.google.android.material.color.MaterialColors import com.google.android.material.snackbar.Snackbar import com.keylesspalace.tusky.BaseActivity @@ -94,8 +95,9 @@ import com.keylesspalace.tusky.entity.Attachment import com.keylesspalace.tusky.entity.Emoji import com.keylesspalace.tusky.entity.NewPoll import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.settings.AppTheme import com.keylesspalace.tusky.settings.PrefKeys -import com.keylesspalace.tusky.util.APP_THEME_DEFAULT +import com.keylesspalace.tusky.settings.PrefKeys.APP_THEME import com.keylesspalace.tusky.util.MentionSpan import com.keylesspalace.tusky.util.PickMediaFiles import com.keylesspalace.tusky.util.getInitialLanguages @@ -114,11 +116,6 @@ import com.mikepenz.iconics.IconicsDrawable import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial import com.mikepenz.iconics.utils.colorInt import com.mikepenz.iconics.utils.sizeDp -import kotlinx.coroutines.flow.collect -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.launch -import kotlinx.parcelize.Parcelize import java.io.File import java.io.IOException import java.text.DecimalFormat @@ -126,6 +123,11 @@ import java.util.Locale import javax.inject.Inject import kotlin.math.max import kotlin.math.min +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.parcelize.Parcelize class ComposeActivity : BaseActivity(), @@ -162,14 +164,23 @@ class ComposeActivity : private var maxUploadMediaNumber = InstanceInfoRepository.DEFAULT_MAX_MEDIA_ATTACHMENTS - private val takePicture = registerForActivityResult(ActivityResultContracts.TakePicture()) { success -> - if (success) { - pickMedia(photoUploadUri!!) + private val takePicture = + registerForActivityResult(ActivityResultContracts.TakePicture()) { success -> + if (success) { + pickMedia(photoUploadUri!!) + } } - } private val pickMediaFile = registerForActivityResult(PickMediaFiles()) { uris -> if (viewModel.media.value.size + uris.size > maxUploadMediaNumber) { - Toast.makeText(this, resources.getQuantityString(R.plurals.error_upload_max_media_reached, maxUploadMediaNumber, maxUploadMediaNumber), Toast.LENGTH_SHORT).show() + Toast.makeText( + this, + resources.getQuantityString( + R.plurals.error_upload_max_media_reached, + maxUploadMediaNumber, + maxUploadMediaNumber + ), + Toast.LENGTH_SHORT + ).show() } else { uris.forEach { uri -> pickMedia(uri) @@ -190,7 +201,8 @@ class ComposeActivity : uriNew, size, itemOld.description, - null, // Intentionally reset focus when cropping + // Intentionally reset focus when cropping + null, itemOld ) } @@ -204,27 +216,30 @@ class ComposeActivity : viewModel.cropImageItemOld = null } + private val onBackPressedCallback = object : OnBackPressedCallback(false) { + override fun handleOnBackPressed() { + if (composeOptionsBehavior.state == BottomSheetBehavior.STATE_EXPANDED || + addMediaBehavior.state == BottomSheetBehavior.STATE_EXPANDED || + emojiBehavior.state == BottomSheetBehavior.STATE_EXPANDED || + scheduleBehavior.state == BottomSheetBehavior.STATE_EXPANDED + ) { + composeOptionsBehavior.state = BottomSheetBehavior.STATE_HIDDEN + addMediaBehavior.state = BottomSheetBehavior.STATE_HIDDEN + emojiBehavior.state = BottomSheetBehavior.STATE_HIDDEN + scheduleBehavior.state = BottomSheetBehavior.STATE_HIDDEN + return + } + + handleCloseButton() + } + } + public override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - val notificationId = intent.getIntExtra(NOTIFICATION_ID_EXTRA, -1) - if (notificationId != -1) { - // ComposeActivity was opened from a notification, delete the notification - val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager - notificationManager.cancel(notificationId) - } + activeAccount = accountManager.activeAccount ?: return - // If started from an intent then compose as the account ID from the intent. - // Otherwise use the active account. If null then the user is not logged in, - // and return from the activity. - val intentAccountId = intent.getLongExtra(ACCOUNT_ID_EXTRA, -1) - activeAccount = if (intentAccountId != -1L) { - accountManager.getAccountById(intentAccountId) - } else { - accountManager.activeAccount - } ?: return - - val theme = preferences.getString("appTheme", APP_THEME_DEFAULT) + val theme = preferences.getString(APP_THEME, AppTheme.DEFAULT.value) if (theme == "black") { setTheme(R.style.TuskyDialogActivityBlackTheme) } @@ -236,7 +251,11 @@ class ComposeActivity : val mediaAdapter = MediaPreviewAdapter( this, onAddCaption = { item -> - CaptionDialog.newInstance(item.localId, item.description, item.uri).show(supportFragmentManager, "caption_dialog") + CaptionDialog.newInstance( + item.localId, + item.description, + item.uri + ).show(supportFragmentManager, "caption_dialog") }, onAddFocus = { item -> makeFocusDialog(item.focus, item.uri) { newFocus -> @@ -254,7 +273,11 @@ class ComposeActivity : /* If the composer is started up as a reply to another post, override the "starting" state * based on what the intent from the reply request passes. */ - val composeOptions: ComposeOptions? = IntentCompat.getParcelableExtra(intent, COMPOSE_OPTIONS_EXTRA, ComposeOptions::class.java) + val composeOptions: ComposeOptions? = IntentCompat.getParcelableExtra( + intent, + COMPOSE_OPTIONS_EXTRA, + ComposeOptions::class.java + ) viewModel.setup(composeOptions) setupButtons() @@ -280,7 +303,7 @@ class ComposeActivity : binding.composeScheduleView.setDateTime(composeOptions?.scheduledAt) } - setupLanguageSpinner(getInitialLanguages(composeOptions?.language, accountManager.activeAccount)) + setupLanguageSpinner(getInitialLanguages(composeOptions?.language, activeAccount)) setupComposeField(preferences, viewModel.startingText) setupContentWarningField(composeOptions?.contentWarning) setupPollView() @@ -317,12 +340,20 @@ class ComposeActivity : if (type.startsWith("image/") || type.startsWith("video/") || type.startsWith("audio/")) { when (intent.action) { Intent.ACTION_SEND -> { - IntentCompat.getParcelableExtra(intent, Intent.EXTRA_STREAM, Uri::class.java)?.let { uri -> + IntentCompat.getParcelableExtra( + intent, + Intent.EXTRA_STREAM, + Uri::class.java + )?.let { uri -> pickMedia(uri) } } Intent.ACTION_SEND_MULTIPLE -> { - IntentCompat.getParcelableArrayListExtra(intent, Intent.EXTRA_STREAM, Uri::class.java)?.forEach { uri -> + IntentCompat.getParcelableArrayListExtra( + intent, + Intent.EXTRA_STREAM, + Uri::class.java + )?.forEach { uri -> pickMedia(uri) } } @@ -342,7 +373,13 @@ class ComposeActivity : val end = binding.composeEditField.selectionEnd.coerceAtLeast(0) val left = min(start, end) val right = max(start, end) - binding.composeEditField.text.replace(left, right, shareBody, 0, shareBody.length) + binding.composeEditField.text.replace( + left, + right, + shareBody, + 0, + shareBody.length + ) // move edittext cursor to first when shareBody parsed binding.composeEditField.text.insert(0, "\n") binding.composeEditField.setSelection(0) @@ -355,23 +392,48 @@ class ComposeActivity : if (replyingStatusAuthor != null) { binding.composeReplyView.show() binding.composeReplyView.text = getString(R.string.replying_to, replyingStatusAuthor) - val arrowDownIcon = IconicsDrawable(this, GoogleMaterial.Icon.gmd_arrow_drop_down).apply { sizeDp = 12 } + val arrowDownIcon = IconicsDrawable( + this, + GoogleMaterial.Icon.gmd_arrow_drop_down + ).apply { + sizeDp = 12 + } setDrawableTint(this, arrowDownIcon, android.R.attr.textColorTertiary) - binding.composeReplyView.setCompoundDrawablesRelativeWithIntrinsicBounds(null, null, arrowDownIcon, null) + binding.composeReplyView.setCompoundDrawablesRelativeWithIntrinsicBounds( + null, + null, + arrowDownIcon, + null + ) binding.composeReplyView.setOnClickListener { - TransitionManager.beginDelayedTransition(binding.composeReplyContentView.parent as ViewGroup) + TransitionManager.beginDelayedTransition( + binding.composeReplyContentView.parent as ViewGroup + ) if (binding.composeReplyContentView.isVisible) { binding.composeReplyContentView.hide() - binding.composeReplyView.setCompoundDrawablesRelativeWithIntrinsicBounds(null, null, arrowDownIcon, null) + binding.composeReplyView.setCompoundDrawablesRelativeWithIntrinsicBounds( + null, + null, + arrowDownIcon, + null + ) } else { binding.composeReplyContentView.show() - val arrowUpIcon = IconicsDrawable(this, GoogleMaterial.Icon.gmd_arrow_drop_up).apply { sizeDp = 12 } + val arrowUpIcon = IconicsDrawable( + this, + GoogleMaterial.Icon.gmd_arrow_drop_up + ).apply { sizeDp = 12 } setDrawableTint(this, arrowUpIcon, android.R.attr.textColorTertiary) - binding.composeReplyView.setCompoundDrawablesRelativeWithIntrinsicBounds(null, null, arrowUpIcon, null) + binding.composeReplyView.setCompoundDrawablesRelativeWithIntrinsicBounds( + null, + null, + arrowUpIcon, + null + ) } } } @@ -382,13 +444,21 @@ class ComposeActivity : if (startingContentWarning != null) { binding.composeContentWarningField.setText(startingContentWarning) } - binding.composeContentWarningField.doOnTextChanged { _, _, _, _ -> updateVisibleCharactersLeft() } + binding.composeContentWarningField.doOnTextChanged { newContentWarning, _, _, _ -> + updateVisibleCharactersLeft() + viewModel.updateContentWarning(newContentWarning?.toString()) + } } private fun setupComposeField(preferences: SharedPreferences, startingText: String?) { binding.composeEditField.setOnReceiveContentListener(this) - binding.composeEditField.setOnKeyListener { _, keyCode, event -> this.onKeyDown(keyCode, event) } + binding.composeEditField.setOnKeyListener { _, keyCode, event -> + this.onKeyDown( + keyCode, + event + ) + } binding.composeEditField.setAdapter( ComposeAutoCompleteAdapter( @@ -408,6 +478,7 @@ class ComposeActivity : binding.composeEditField.doAfterTextChanged { editable -> highlightSpans(editable!!, mentionColour) updateVisibleCharactersLeft() + viewModel.updateContent(editable.toString()) } // work around Android platform bug -> https://issuetracker.google.com/issues/67102093 @@ -433,7 +504,9 @@ class ComposeActivity : } lifecycleScope.launch { - viewModel.showContentWarning.combine(viewModel.markMediaAsSensitive) { showContentWarning, markSensitive -> + viewModel.showContentWarning.combine( + viewModel.markMediaAsSensitive + ) { showContentWarning, markSensitive -> updateSensitiveMediaToggle(markSensitive, showContentWarning) showContentWarning(showContentWarning) }.collect() @@ -448,7 +521,10 @@ class ComposeActivity : mediaAdapter.submitList(media) binding.composeMediaPreviewBar.visible(media.isNotEmpty()) - updateSensitiveMediaToggle(viewModel.markMediaAsSensitive.value, viewModel.showContentWarning.value) + updateSensitiveMediaToggle( + viewModel.markMediaAsSensitive.value, + viewModel.showContentWarning.value + ) } } @@ -485,10 +561,21 @@ class ComposeActivity : if (throwable is UploadServerError) { displayTransientMessage(throwable.errorMessage) } else { - displayTransientMessage(R.string.error_media_upload_sending) + displayTransientMessage( + getString( + R.string.error_media_upload_sending_fmt, + throwable.message + ) + ) } } } + + lifecycleScope.launch { + viewModel.closeConfirmation.collect { + updateOnBackPressedCallbackState() + } + } } private fun setupButtons() { @@ -499,6 +586,17 @@ class ComposeActivity : scheduleBehavior = BottomSheetBehavior.from(binding.composeScheduleView) emojiBehavior = BottomSheetBehavior.from(binding.emojiView) + val bottomSheetCallback = object : BottomSheetCallback() { + override fun onStateChanged(bottomSheet: View, newState: Int) { + updateOnBackPressedCallbackState() + } + override fun onSlide(bottomSheet: View, slideOffset: Float) { } + } + composeOptionsBehavior.addBottomSheetCallback(bottomSheetCallback) + addMediaBehavior.addBottomSheetCallback(bottomSheetCallback) + scheduleBehavior.addBottomSheetCallback(bottomSheetCallback) + emojiBehavior.addBottomSheetCallback(bottomSheetCallback) + enableButton(binding.composeEmojiButton, clickable = false, colorActive = false) // Setup the interface buttons. @@ -519,46 +617,58 @@ class ComposeActivity : val textColor = MaterialColors.getColor(binding.root, android.R.attr.textColorTertiary) - val cameraIcon = IconicsDrawable(this, GoogleMaterial.Icon.gmd_camera_alt).apply { colorInt = textColor; sizeDp = 18 } - binding.actionPhotoTake.setCompoundDrawablesRelativeWithIntrinsicBounds(cameraIcon, null, null, null) + val cameraIcon = IconicsDrawable(this, GoogleMaterial.Icon.gmd_camera_alt).apply { + colorInt = textColor + sizeDp = 18 + } + binding.actionPhotoTake.setCompoundDrawablesRelativeWithIntrinsicBounds( + cameraIcon, + null, + null, + null + ) - val imageIcon = IconicsDrawable(this, GoogleMaterial.Icon.gmd_image).apply { colorInt = textColor; sizeDp = 18 } - binding.actionPhotoPick.setCompoundDrawablesRelativeWithIntrinsicBounds(imageIcon, null, null, null) + val imageIcon = IconicsDrawable(this, GoogleMaterial.Icon.gmd_image).apply { + colorInt = textColor + sizeDp = 18 + } + binding.actionPhotoPick.setCompoundDrawablesRelativeWithIntrinsicBounds( + imageIcon, + null, + null, + null + ) - val pollIcon = IconicsDrawable(this, GoogleMaterial.Icon.gmd_poll).apply { colorInt = textColor; sizeDp = 18 } - binding.addPollTextActionTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(pollIcon, null, null, null) + val pollIcon = IconicsDrawable(this, GoogleMaterial.Icon.gmd_poll).apply { + colorInt = textColor + sizeDp = 18 + } + binding.addPollTextActionTextView.setCompoundDrawablesRelativeWithIntrinsicBounds( + pollIcon, + null, + null, + null + ) - binding.actionPhotoTake.visible(Intent(MediaStore.ACTION_IMAGE_CAPTURE).resolveActivity(packageManager) != null) + binding.actionPhotoTake.visible( + Intent(MediaStore.ACTION_IMAGE_CAPTURE).resolveActivity(packageManager) != null + ) binding.actionPhotoTake.setOnClickListener { initiateCameraApp() } binding.actionPhotoPick.setOnClickListener { onMediaPick() } binding.addPollTextActionTextView.setOnClickListener { openPollDialog() } - onBackPressedDispatcher.addCallback( - this, - object : OnBackPressedCallback(true) { - override fun handleOnBackPressed() { - if (composeOptionsBehavior.state == BottomSheetBehavior.STATE_EXPANDED || - addMediaBehavior.state == BottomSheetBehavior.STATE_EXPANDED || - emojiBehavior.state == BottomSheetBehavior.STATE_EXPANDED || - scheduleBehavior.state == BottomSheetBehavior.STATE_EXPANDED - ) { - composeOptionsBehavior.state = BottomSheetBehavior.STATE_HIDDEN - addMediaBehavior.state = BottomSheetBehavior.STATE_HIDDEN - emojiBehavior.state = BottomSheetBehavior.STATE_HIDDEN - scheduleBehavior.state = BottomSheetBehavior.STATE_HIDDEN - return - } - - handleCloseButton() - } - } - ) + onBackPressedDispatcher.addCallback(this, onBackPressedCallback) } private fun setupLanguageSpinner(initialLanguages: List) { binding.composePostLanguageButton.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { - override fun onItemSelected(parent: AdapterView<*>, view: View?, position: Int, id: Long) { + override fun onItemSelected( + parent: AdapterView<*>, + view: View?, + position: Int, + id: Long + ) { viewModel.postLanguage = (parent.adapter.getItem(position) as Locale).modernLanguageCode } @@ -588,7 +698,7 @@ class ComposeActivity : a.getDimensionPixelSize(0, 1) } - val animateAvatars = preferences.getBoolean("animateGifAvatars", false) + val animateAvatars = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false) loadAvatar( activeAccount.profilePictureUrl, binding.composeAvatar, @@ -601,10 +711,23 @@ class ComposeActivity : ) } + private fun updateOnBackPressedCallbackState() { + val confirmation = viewModel.closeConfirmation.value + onBackPressedCallback.isEnabled = confirmation != ConfirmationKind.NONE || + composeOptionsBehavior.state != BottomSheetBehavior.STATE_HIDDEN || + addMediaBehavior.state != BottomSheetBehavior.STATE_HIDDEN || + emojiBehavior.state != BottomSheetBehavior.STATE_HIDDEN || + scheduleBehavior.state != BottomSheetBehavior.STATE_HIDDEN + } + private fun replaceTextAtCaret(text: CharSequence) { // If you select "backward" in an editable, you get SelectionStart > SelectionEnd - val start = binding.composeEditField.selectionStart.coerceAtMost(binding.composeEditField.selectionEnd) - val end = binding.composeEditField.selectionStart.coerceAtLeast(binding.composeEditField.selectionEnd) + val start = binding.composeEditField.selectionStart.coerceAtMost( + binding.composeEditField.selectionEnd + ) + val end = binding.composeEditField.selectionStart.coerceAtLeast( + binding.composeEditField.selectionEnd + ) val textToInsert = if (start > 0 && !binding.composeEditField.text[start - 1].isWhitespace()) { " $text" } else { @@ -618,8 +741,12 @@ class ComposeActivity : fun prependSelectedWordsWith(text: CharSequence) { // If you select "backward" in an editable, you get SelectionStart > SelectionEnd - val start = binding.composeEditField.selectionStart.coerceAtMost(binding.composeEditField.selectionEnd) - val end = binding.composeEditField.selectionStart.coerceAtLeast(binding.composeEditField.selectionEnd) + val start = binding.composeEditField.selectionStart.coerceAtMost( + binding.composeEditField.selectionEnd + ) + val end = binding.composeEditField.selectionStart.coerceAtLeast( + binding.composeEditField.selectionEnd + ) val editorText = binding.composeEditField.text if (start == end) { @@ -687,7 +814,10 @@ class ComposeActivity : this.viewModel.toggleMarkSensitive() } - private fun updateSensitiveMediaToggle(markMediaSensitive: Boolean, contentWarningShown: Boolean) { + private fun updateSensitiveMediaToggle( + markMediaSensitive: Boolean, + contentWarningShown: Boolean + ) { if (viewModel.media.value.isEmpty()) { binding.composeHideMediaButton.hide() binding.descriptionMissingWarningButton.hide() @@ -704,7 +834,10 @@ class ComposeActivity : getColor(R.color.chinwag_green) } else { binding.composeHideMediaButton.setImageResource(R.drawable.ic_eye_24dp) - MaterialColors.getColor(binding.composeHideMediaButton, android.R.attr.textColorTertiary) + MaterialColors.getColor( + binding.composeHideMediaButton, + android.R.attr.textColorTertiary + ) } } binding.composeHideMediaButton.drawable.colorFilter = PorterDuffColorFilter(color, PorterDuff.Mode.SRC_IN) @@ -726,7 +859,10 @@ class ComposeActivity : enableButton(binding.composeScheduleButton, clickable = false, colorActive = false) } else { @ColorInt val color = if (binding.composeScheduleView.time == null) { - MaterialColors.getColor(binding.composeScheduleButton, android.R.attr.textColorTertiary) + MaterialColors.getColor( + binding.composeScheduleButton, + android.R.attr.textColorTertiary + ) } else { getColor(R.color.chinwag_green) } @@ -757,7 +893,11 @@ class ComposeActivity : binding.composeToggleVisibilityButton.setImageResource(iconRes) if (viewModel.editing) { // Can't update visibility on published status - enableButton(binding.composeToggleVisibilityButton, clickable = false, colorActive = false) + enableButton( + binding.composeToggleVisibilityButton, + clickable = false, + colorActive = false + ) } } @@ -794,7 +934,11 @@ class ComposeActivity : private fun showEmojis() { binding.emojiView.adapter?.let { if (it.itemCount == 0) { - val errorMessage = getString(R.string.error_no_custom_emojis, accountManager.activeAccount!!.domain) + val errorMessage = + getString( + R.string.error_no_custom_emojis, + accountManager.activeAccount!!.domain + ) displayTransientMessage(errorMessage) } else { if (emojiBehavior.state == BottomSheetBehavior.STATE_HIDDEN || emojiBehavior.state == BottomSheetBehavior.STATE_COLLAPSED) { @@ -822,7 +966,7 @@ class ComposeActivity : private fun onMediaPick() { addMediaBehavior.addBottomSheetCallback( - object : BottomSheetBehavior.BottomSheetCallback() { + object : BottomSheetCallback() { override fun onStateChanged(bottomSheet: View, newState: Int) { // Wait until bottom sheet is not collapsed and show next screen after if (newState == BottomSheetBehavior.STATE_COLLAPSED) { @@ -861,9 +1005,14 @@ class ComposeActivity : private fun setupPollView() { val margin = resources.getDimensionPixelSize(R.dimen.compose_media_preview_margin) - val marginBottom = resources.getDimensionPixelSize(R.dimen.compose_media_preview_margin_bottom) + val marginBottom = resources.getDimensionPixelSize( + R.dimen.compose_media_preview_margin_bottom + ) - val layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT) + val layoutParams = LinearLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ) layoutParams.setMargins(margin, margin, margin, marginBottom) binding.pollPreview.layoutParams = layoutParams @@ -885,13 +1034,13 @@ class ComposeActivity : } private fun removePoll() { - viewModel.poll.value = null + viewModel.updatePoll(null) binding.pollPreview.hide() } override fun onVisibilityChanged(visibility: Status.Visibility) { composeOptionsBehavior.state = BottomSheetBehavior.STATE_COLLAPSED - viewModel.statusVisibility.value = visibility + viewModel.changeStatusVisibility(visibility) } @VisibleForTesting @@ -914,7 +1063,10 @@ class ComposeActivity : val textColor = if (remainingLength < 0) { getColor(R.color.tusky_red) } else { - MaterialColors.getColor(binding.composeCharactersLeftView, android.R.attr.textColorTertiary) + MaterialColors.getColor( + binding.composeCharactersLeftView, + android.R.attr.textColorTertiary + ) } binding.composeCharactersLeftView.setTextColor(textColor) } @@ -926,7 +1078,9 @@ class ComposeActivity : } private fun verifyScheduledTime(): Boolean { - return binding.composeScheduleView.verifyScheduledTime(binding.composeScheduleView.getDateTime(viewModel.scheduledAt.value)) + return binding.composeScheduleView.verifyScheduledTime( + binding.composeScheduleView.getDateTime(viewModel.scheduledAt.value) + ) } private fun onSendClicked() { @@ -976,7 +1130,11 @@ class ComposeActivity : } } - override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { + override fun onRequestPermissionsResult( + requestCode: Int, + permissions: Array, + grantResults: IntArray + ) { super.onRequestPermissionsResult(requestCode, permissions, grantResults) if (requestCode == PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE) { @@ -1051,14 +1209,20 @@ class ComposeActivity : val tempFile = createNewImageFile(this, if (isPng) ".png" else ".jpg") // "Authority" must be the same as the android:authorities string in AndroidManifest.xml - val uriNew = FileProvider.getUriForFile(this, BuildConfig.APPLICATION_ID + ".fileprovider", tempFile) + val uriNew = FileProvider.getUriForFile( + this, + BuildConfig.APPLICATION_ID + ".fileprovider", + tempFile + ) viewModel.cropImageItemOld = item cropImage.launch( options(uri = item.uri) { setOutputUri(uriNew) - setOutputCompressFormat(if (isPng) Bitmap.CompressFormat.PNG else Bitmap.CompressFormat.JPEG) + setOutputCompressFormat( + if (isPng) Bitmap.CompressFormat.PNG else Bitmap.CompressFormat.JPEG + ) } ) } @@ -1067,9 +1231,28 @@ class ComposeActivity : viewModel.removeMediaFromQueue(item) } + private fun sanitizePickMediaDescription(description: String?): String? { + if (description == null) { + return null + } + + // The Gboard android keyboard attaches this text whenever the user + // pastes something from the keyboard's suggestion bar. + // Due to different end user locales, the exact text may vary, but at + // least in version 13.4.08, all of the translations contained the + // string "Gboard". + if ("Gboard" in description) { + return null + } + + return description + } + private fun pickMedia(uri: Uri, description: String? = null) { + val sanitizedDescription = sanitizePickMediaDescription(description) + lifecycleScope.launch { - viewModel.pickMedia(uri, description).onFailure { throwable -> + viewModel.pickMedia(uri, sanitizedDescription).onFailure { throwable -> val errorString = when (throwable) { is FileSizeException -> { val decimalFormat = DecimalFormat("0.##") @@ -1077,7 +1260,9 @@ class ComposeActivity : val formattedSize = decimalFormat.format(allowedSizeInMb) getString(R.string.error_multimedia_size_limit, formattedSize) } - is VideoOrImageException -> getString(R.string.error_media_upload_image_or_video) + is VideoOrImageException -> getString( + R.string.error_media_upload_image_or_video + ) else -> getString(R.string.error_media_upload_opening) } displayTransientMessage(errorString) @@ -1086,16 +1271,23 @@ class ComposeActivity : } private fun showContentWarning(show: Boolean) { - TransitionManager.beginDelayedTransition(binding.composeContentWarningBar.parent as ViewGroup) + TransitionManager.beginDelayedTransition( + binding.composeContentWarningBar.parent as ViewGroup + ) @ColorInt val color = if (show) { binding.composeContentWarningBar.show() - binding.composeContentWarningField.setSelection(binding.composeContentWarningField.text.length) + binding.composeContentWarningField.setSelection( + binding.composeContentWarningField.text.length + ) binding.composeContentWarningField.requestFocus() getColor(R.color.chinwag_green) } else { binding.composeContentWarningBar.hide() binding.composeEditField.requestFocus() - MaterialColors.getColor(binding.composeContentWarningButton, android.R.attr.textColorTertiary) + MaterialColors.getColor( + binding.composeContentWarningButton, + android.R.attr.textColorTertiary + ) } binding.composeContentWarningButton.drawable.colorFilter = PorterDuffColorFilter(color, PorterDuff.Mode.SRC_IN) } @@ -1130,10 +1322,10 @@ class ComposeActivity : private fun handleCloseButton() { val contentText = binding.composeEditField.text.toString() val contentWarning = binding.composeContentWarningField.text.toString() - when (viewModel.handleCloseButton(contentText, contentWarning)) { + when (viewModel.closeConfirmation.value) { ConfirmationKind.NONE -> { viewModel.stopUploads() - finishWithoutSlideOutAnimation() + finish() } ConfirmationKind.SAVE_OR_DISCARD -> getSaveAsDraftOrDiscardDialog(contentText, contentWarning).show() @@ -1149,7 +1341,10 @@ class ComposeActivity : /** * User is editing a new post, and can either save the changes as a draft or discard them. */ - private fun getSaveAsDraftOrDiscardDialog(contentText: String, contentWarning: String): AlertDialog.Builder { + private fun getSaveAsDraftOrDiscardDialog( + contentText: String, + contentWarning: String + ): AlertDialog.Builder { val warning = if (viewModel.media.value.isNotEmpty()) { R.string.compose_save_draft_loses_media } else { @@ -1172,7 +1367,10 @@ class ComposeActivity : * User is editing an existing draft, and can either update the draft with the new changes or * discard them. */ - private fun getUpdateDraftOrDiscardDialog(contentText: String, contentWarning: String): AlertDialog.Builder { + private fun getUpdateDraftOrDiscardDialog( + contentText: String, + contentWarning: String + ): AlertDialog.Builder { val warning = if (viewModel.media.value.isNotEmpty()) { R.string.compose_save_draft_loses_media } else { @@ -1187,7 +1385,7 @@ class ComposeActivity : } .setNegativeButton(R.string.action_discard) { _, _ -> viewModel.stopUploads() - finishWithoutSlideOutAnimation() + finish() } } @@ -1203,7 +1401,7 @@ class ComposeActivity : } .setNegativeButton(R.string.action_discard) { _, _ -> viewModel.stopUploads() - finishWithoutSlideOutAnimation() + finish() } } @@ -1217,7 +1415,7 @@ class ComposeActivity : .setPositiveButton(R.string.action_delete) { _, _ -> viewModel.deleteDraft() viewModel.stopUploads() - finishWithoutSlideOutAnimation() + finish() } .setNegativeButton(R.string.action_continue_edit) { _, _ -> // Do nothing, dialog will dismiss, user can continue editing @@ -1226,7 +1424,7 @@ class ComposeActivity : private fun deleteDraftAndFinish() { viewModel.deleteDraft() - finishWithoutSlideOutAnimation() + finish() } private fun saveDraftAndFinish(contentText: String, contentWarning: String) { @@ -1244,7 +1442,7 @@ class ComposeActivity : } viewModel.saveDraft(contentText, contentWarning) dialog?.cancel() - finishWithoutSlideOutAnimation() + finish() } } @@ -1276,10 +1474,15 @@ class ComposeActivity : val state: State ) { enum class Type { - IMAGE, VIDEO, AUDIO; + IMAGE, + VIDEO, + AUDIO } enum class State { - UPLOADING, UNPROCESSED, PROCESSED, PUBLISHED + UPLOADING, + UNPROCESSED, + PROCESSED, + PUBLISHED } } @@ -1350,8 +1553,6 @@ class ComposeActivity : private const val PERMISSIONS_REQUEST_READ_EXTERNAL_STORAGE = 1 internal const val COMPOSE_OPTIONS_EXTRA = "COMPOSE_OPTIONS" - private const val NOTIFICATION_ID_EXTRA = "NOTIFICATION_ID" - private const val ACCOUNT_ID_EXTRA = "ACCOUNT_ID" private const val PHOTO_UPLOAD_URI_KEY = "PHOTO_UPLOAD_URI" private const val VISIBILITY_KEY = "VISIBILITY" private const val SCHEDULED_TIME_KEY = "SCHEDULE" @@ -1359,26 +1560,12 @@ class ComposeActivity : /** * @param options ComposeOptions to configure the ComposeActivity - * @param notificationId the id of the notification that starts the Activity - * @param accountId the id of the account to compose with, null for the current account * @return an Intent to start the ComposeActivity */ @JvmStatic - @JvmOverloads - fun startIntent( - context: Context, - options: ComposeOptions, - notificationId: Int? = null, - accountId: Long? = null - ): Intent { + fun startIntent(context: Context, options: ComposeOptions): Intent { return Intent(context, ComposeActivity::class.java).apply { putExtra(COMPOSE_OPTIONS_EXTRA, options) - if (notificationId != null) { - putExtra(NOTIFICATION_ID_EXTRA, notificationId) - } - if (accountId != null) { - putExtra(ACCOUNT_ID_EXTRA, accountId) - } } } @@ -1410,7 +1597,7 @@ class ComposeActivity : */ @JvmStatic fun statusLength(body: Spanned, contentWarning: Spanned?, urlLength: Int): Int { - var length = body.length - body.getSpans(0, body.length, URLSpan::class.java) + var length = body.toString().perceivedCharacterLength() - body.getSpans(0, body.length, URLSpan::class.java) .fold(0) { acc, span -> // Accumulate a count of characters to be *ignored* in the final length acc + when (span) { @@ -1423,15 +1610,25 @@ class ComposeActivity : } else -> { // Expected to be negative if the URL length < maxUrlLength - span.url.length - urlLength + span.url.perceivedCharacterLength() - urlLength } } } // Content warning text is treated as is, URLs or mentions there are not special - contentWarning?.let { length += it.length } - + contentWarning?.let { length += it.toString().perceivedCharacterLength() } return length } + + // String.length would count emojis as multiple characters but Mastodon counts them as 1, so we need this workaround + private fun String.perceivedCharacterLength(): Int { + val breakIterator = BreakIterator.getCharacterInstance() + breakIterator.setText(this) + var count = 0 + while (breakIterator.next() != BreakIterator.DONE) { + count++ + } + return count + } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeAutoCompleteAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeAutoCompleteAdapter.kt index e825798cf..c37644cee 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeAutoCompleteAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeAutoCompleteAdapter.kt @@ -108,7 +108,9 @@ class ComposeAutoCompleteAdapter( val account = accountResult.account binding.username.text = context.getString(R.string.post_username_format, account.username) binding.displayName.text = account.name.emojify(account.emojis, binding.displayName, animateEmojis) - val avatarRadius = context.resources.getDimensionPixelSize(R.dimen.avatar_radius_42dp) + val avatarRadius = context.resources.getDimensionPixelSize( + R.dimen.avatar_radius_42dp + ) loadAvatar( account.avatar, binding.avatar, @@ -143,12 +145,12 @@ class ComposeAutoCompleteAdapter( } } - sealed class AutocompleteResult { - class AccountResult(val account: TimelineAccount) : AutocompleteResult() + sealed interface AutocompleteResult { + class AccountResult(val account: TimelineAccount) : AutocompleteResult - class HashtagResult(val hashtag: String) : AutocompleteResult() + class HashtagResult(val hashtag: String) : AutocompleteResult - class EmojiResult(val emoji: Emoji) : AutocompleteResult() + class EmojiResult(val emoji: Emoji) : AutocompleteResult } interface AutocompletionProvider { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeViewModel.kt index 90389d28c..5ba8f9a06 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeViewModel.kt @@ -38,22 +38,24 @@ import com.keylesspalace.tusky.service.MediaToSend import com.keylesspalace.tusky.service.ServiceClient import com.keylesspalace.tusky.service.StatusToSend import com.keylesspalace.tusky.util.randomAlphanumericString +import javax.inject.Inject import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext -import javax.inject.Inject -@OptIn(FlowPreview::class) class ComposeViewModel @Inject constructor( private val api: MastodonApi, private val accountManager: AccountManager, @@ -78,34 +80,61 @@ class ComposeViewModel @Inject constructor( private var modifiedInitialState: Boolean = false private var hasScheduledTimeChanged: Boolean = false - val instanceInfo: SharedFlow = instanceInfoRepo::getInstanceInfo.asFlow() + private var currentContent: String? = "" + private var currentContentWarning: String? = "" + + val instanceInfo: SharedFlow = instanceInfoRepo::getUpdatedInstanceInfoOrFallback.asFlow() .shareIn(viewModelScope, SharingStarted.Eagerly, replay = 1) val emoji: SharedFlow> = instanceInfoRepo::getEmojis.asFlow() .shareIn(viewModelScope, SharingStarted.Eagerly, replay = 1) - val markMediaAsSensitive: MutableStateFlow = + private val _markMediaAsSensitive = MutableStateFlow(accountManager.activeAccount?.defaultMediaSensitivity ?: false) + val markMediaAsSensitive: StateFlow = _markMediaAsSensitive.asStateFlow() - val statusVisibility: MutableStateFlow = MutableStateFlow(Status.Visibility.UNKNOWN) - val showContentWarning: MutableStateFlow = MutableStateFlow(false) - val poll: MutableStateFlow = MutableStateFlow(null) - val scheduledAt: MutableStateFlow = MutableStateFlow(null) + private val _statusVisibility = MutableStateFlow(Status.Visibility.UNKNOWN) + val statusVisibility: StateFlow = _statusVisibility.asStateFlow() - val media: MutableStateFlow> = MutableStateFlow(emptyList()) - val uploadError = MutableSharedFlow(replay = 0, extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) + private val _showContentWarning = MutableStateFlow(false) + val showContentWarning: StateFlow = _showContentWarning.asStateFlow() - lateinit var composeKind: ComposeKind + private val _poll = MutableStateFlow(null as NewPoll?) + val poll: StateFlow = _poll.asStateFlow() + + private val _scheduledAt = MutableStateFlow(null as String?) + val scheduledAt: StateFlow = _scheduledAt.asStateFlow() + + private val _media = MutableStateFlow(emptyList()) + val media: StateFlow> = _media.asStateFlow() + + private val _uploadError = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + val uploadError: SharedFlow = _uploadError.asSharedFlow() + + private val _closeConfirmation = MutableStateFlow(ConfirmationKind.NONE) + val closeConfirmation: StateFlow = _closeConfirmation.asStateFlow() + + private lateinit var composeKind: ComposeKind // Used in ComposeActivity to pass state to result function when cropImage contract inflight var cropImageItemOld: QueuedMedia? = null private var setupComplete = false - suspend fun pickMedia(mediaUri: Uri, description: String? = null, focus: Attachment.Focus? = null): Result = withContext(Dispatchers.IO) { + suspend fun pickMedia( + mediaUri: Uri, + description: String? = null, + focus: Attachment.Focus? = null + ): Result = withContext( + Dispatchers.IO + ) { try { val (type, uri, size) = mediaUploader.prepareMedia(mediaUri, instanceInfo.first()) - val mediaItems = media.value + val mediaItems = _media.value if (type != QueuedMedia.Type.IMAGE && mediaItems.isNotEmpty() && mediaItems[0].type == QueuedMedia.Type.IMAGE @@ -130,7 +159,7 @@ class ComposeViewModel @Inject constructor( ): QueuedMedia { var stashMediaItem: QueuedMedia? = null - media.update { mediaList -> + _media.update { mediaList -> val mediaItem = QueuedMedia( localId = mediaUploader.getNewLocalMediaId(), uri = uri, @@ -157,7 +186,7 @@ class ComposeViewModel @Inject constructor( mediaUploader .uploadMedia(mediaItem, instanceInfo.first()) .collect { event -> - val item = media.value.find { it.localId == mediaItem.localId } + val item = _media.value.find { it.localId == mediaItem.localId } ?: return@collect val newMediaItem = when (event) { is UploadEvent.ProgressEvent -> @@ -166,15 +195,19 @@ class ComposeViewModel @Inject constructor( item.copy( id = event.mediaId, uploadPercent = -1, - state = if (event.processed) { QueuedMedia.State.PROCESSED } else { QueuedMedia.State.UNPROCESSED } + state = if (event.processed) { + QueuedMedia.State.PROCESSED + } else { + QueuedMedia.State.UNPROCESSED + } ) is UploadEvent.ErrorEvent -> { - media.update { mediaList -> mediaList.filter { it.localId != mediaItem.localId } } - uploadError.emit(event.error) + _media.update { mediaList -> mediaList.filter { it.localId != mediaItem.localId } } + _uploadError.emit(event.error) return@collect } } - media.update { mediaList -> + _media.update { mediaList -> mediaList.map { mediaItem -> if (mediaItem.localId == newMediaItem.localId) { newMediaItem @@ -185,11 +218,22 @@ class ComposeViewModel @Inject constructor( } } } + updateCloseConfirmation() return mediaItem } - private fun addUploadedMedia(id: String, type: QueuedMedia.Type, uri: Uri, description: String?, focus: Attachment.Focus?) { - media.update { mediaList -> + fun changeStatusVisibility(visibility: Status.Visibility) { + _statusVisibility.value = visibility + } + + private fun addUploadedMedia( + id: String, + type: QueuedMedia.Type, + uri: Uri, + description: String?, + focus: Attachment.Focus? + ) { + _media.update { mediaList -> val mediaItem = QueuedMedia( localId = mediaUploader.getNewLocalMediaId(), uri = uri, @@ -207,22 +251,38 @@ class ComposeViewModel @Inject constructor( fun removeMediaFromQueue(item: QueuedMedia) { mediaUploader.cancelUploadScope(item.localId) - media.update { mediaList -> mediaList.filter { it.localId != item.localId } } + _media.update { mediaList -> mediaList.filter { it.localId != item.localId } } + updateCloseConfirmation() } fun toggleMarkSensitive() { - this.markMediaAsSensitive.value = this.markMediaAsSensitive.value != true + this._markMediaAsSensitive.value = this._markMediaAsSensitive.value != true } - fun handleCloseButton(contentText: String?, contentWarning: String?): ConfirmationKind { - return if (didChange(contentText, contentWarning)) { + fun updateContent(newContent: String?) { + currentContent = newContent + updateCloseConfirmation() + } + + fun updateContentWarning(newContentWarning: String?) { + currentContentWarning = newContentWarning + updateCloseConfirmation() + } + + private fun updateCloseConfirmation() { + val contentWarning = if (_showContentWarning.value) { + currentContentWarning + } else { + "" + } + this._closeConfirmation.value = if (didChange(currentContent, contentWarning)) { when (composeKind) { - ComposeKind.NEW -> if (isEmpty(contentText, contentWarning)) { + ComposeKind.NEW -> if (isEmpty(currentContent, contentWarning)) { ConfirmationKind.NONE } else { ConfirmationKind.SAVE_OR_DISCARD } - ComposeKind.EDIT_DRAFT -> if (isEmpty(contentText, contentWarning)) { + ComposeKind.EDIT_DRAFT -> if (isEmpty(currentContent, contentWarning)) { ConfirmationKind.CONTINUE_EDITING_OR_DISCARD_DRAFT } else { ConfirmationKind.UPDATE_OR_DISCARD @@ -238,20 +298,21 @@ class ComposeViewModel @Inject constructor( private fun didChange(content: String?, contentWarning: String?): Boolean { val textChanged = content.orEmpty() != startingText.orEmpty() val contentWarningChanged = contentWarning.orEmpty() != startingContentWarning - val mediaChanged = media.value.isNotEmpty() - val pollChanged = poll.value != null + val mediaChanged = _media.value.isNotEmpty() + val pollChanged = _poll.value != null val didScheduledTimeChange = hasScheduledTimeChanged return modifiedInitialState || textChanged || contentWarningChanged || mediaChanged || pollChanged || didScheduledTimeChange } private fun isEmpty(content: String?, contentWarning: String?): Boolean { - return !modifiedInitialState && (content.isNullOrBlank() && contentWarning.isNullOrBlank() && media.value.isEmpty() && poll.value == null) + return !modifiedInitialState && (content.isNullOrBlank() && contentWarning.isNullOrBlank() && _media.value.isEmpty() && _poll.value == null) } fun contentWarningChanged(value: Boolean) { - showContentWarning.value = value + _showContentWarning.value = value contentWarningStateChanged = true + updateCloseConfirmation() } fun deleteDraft() { @@ -263,12 +324,12 @@ class ComposeViewModel @Inject constructor( } fun stopUploads() { - mediaUploader.cancelUploadScope(*media.value.map { it.localId }.toIntArray()) + mediaUploader.cancelUploadScope(*_media.value.map { it.localId }.toIntArray()) } fun shouldShowSaveDraftDialog(): Boolean { // if any of the media files need to be downloaded first it could take a while, so show a loading dialog - return media.value.any { mediaValue -> + return _media.value.any { mediaValue -> mediaValue.uri.scheme == "https" } } @@ -277,7 +338,7 @@ class ComposeViewModel @Inject constructor( val mediaUris: MutableList = mutableListOf() val mediaDescriptions: MutableList = mutableListOf() val mediaFocus: MutableList = mutableListOf() - media.value.forEach { item -> + for (item in _media.value) { mediaUris.add(item.uri.toString()) mediaDescriptions.add(item.description) mediaFocus.add(item.focus) @@ -289,15 +350,15 @@ class ComposeViewModel @Inject constructor( inReplyToId = inReplyToId, content = content, contentWarning = contentWarning, - sensitive = markMediaAsSensitive.value, - visibility = statusVisibility.value, + sensitive = _markMediaAsSensitive.value, + visibility = _statusVisibility.value, mediaUris = mediaUris, mediaDescriptions = mediaDescriptions, mediaFocus = mediaFocus, - poll = poll.value, + poll = _poll.value, failedToSend = false, failedToSendAlert = false, - scheduledAt = scheduledAt.value, + scheduledAt = _scheduledAt.value, language = postLanguage, statusId = originalStatusId ) @@ -307,16 +368,12 @@ class ComposeViewModel @Inject constructor( * Send status to the server. * Uses current state plus provided arguments. */ - suspend fun sendStatus( - content: String, - spoilerText: String, - accountId: Long - ) { + suspend fun sendStatus(content: String, spoilerText: String, accountId: Long) { if (!scheduledTootId.isNullOrEmpty()) { api.deleteScheduledStatus(scheduledTootId!!) } - val attachedMedia = media.value.map { item -> + val attachedMedia = _media.value.map { item -> MediaToSend( localId = item.localId, id = item.id, @@ -329,12 +386,12 @@ class ComposeViewModel @Inject constructor( val tootToSend = StatusToSend( text = content, warningText = spoilerText, - visibility = statusVisibility.value.serverString(), - sensitive = attachedMedia.isNotEmpty() && (markMediaAsSensitive.value || showContentWarning.value), + visibility = _statusVisibility.value.serverString, + sensitive = attachedMedia.isNotEmpty() && (_markMediaAsSensitive.value || _showContentWarning.value), media = attachedMedia, - scheduledAt = scheduledAt.value, + scheduledAt = _scheduledAt.value, inReplyToId = inReplyToId, - poll = poll.value, + poll = _poll.value, replyingStatusContent = null, replyingStatusAuthorUsername = null, accountId = accountId, @@ -349,7 +406,7 @@ class ComposeViewModel @Inject constructor( } private fun updateMediaItem(localId: Int, mutator: (QueuedMedia) -> QueuedMedia) { - media.update { mediaList -> + _media.update { mediaList -> mediaList.map { mediaItem -> if (mediaItem.localId == localId) { mutator(mediaItem) @@ -373,9 +430,9 @@ class ComposeViewModel @Inject constructor( } fun searchAutocompleteSuggestions(token: String): List { - when (token[0]) { - '@' -> { - return api.searchAccountsSync(query = token.substring(1), limit = 10) + return when (token[0]) { + '@' -> runBlocking { + api.searchAccounts(query = token.substring(1), limit = 10) .fold({ accounts -> accounts.map { AutocompleteResult.AccountResult(it) } }, { e -> @@ -383,8 +440,12 @@ class ComposeViewModel @Inject constructor( emptyList() }) } - '#' -> { - return api.searchSync(query = token, type = SearchType.Hashtag.apiParameter, limit = 10) + '#' -> runBlocking { + api.search( + query = token, + type = SearchType.Hashtag.apiParameter, + limit = 10 + ) .fold({ searchResult -> searchResult.hashtags.map { AutocompleteResult.HashtagResult(it.name) } }, { e -> @@ -396,7 +457,7 @@ class ComposeViewModel @Inject constructor( val emojiList = emoji.replayCache.firstOrNull() ?: return emptyList() val incomplete = token.substring(1) - return emojiList.filter { emoji -> + emojiList.filter { emoji -> emoji.shortcode.contains(incomplete, ignoreCase = true) }.sortedBy { emoji -> emoji.shortcode.indexOf(incomplete, ignoreCase = true) @@ -406,7 +467,7 @@ class ComposeViewModel @Inject constructor( } else -> { Log.w(TAG, "Unexpected autocompletion token: $token") - return emptyList() + emptyList() } } } @@ -434,7 +495,7 @@ class ComposeViewModel @Inject constructor( startingContentWarning = contentWarning } if (!contentWarningStateChanged) { - showContentWarning.value = !contentWarning.isNullOrBlank() + _showContentWarning.value = !contentWarning.isNullOrBlank() } // recreate media list @@ -468,7 +529,7 @@ class ComposeViewModel @Inject constructor( if (tootVisibility.num != Status.Visibility.UNKNOWN.num) { startingVisibility = tootVisibility } - statusVisibility.value = startingVisibility + _statusVisibility.value = startingVisibility val mentionedUsernames = composeOptions?.mentionedUsernames if (mentionedUsernames != null) { val builder = StringBuilder() @@ -480,30 +541,33 @@ class ComposeViewModel @Inject constructor( startingText = builder.toString() } - scheduledAt.value = composeOptions?.scheduledAt + _scheduledAt.value = composeOptions?.scheduledAt - composeOptions?.sensitive?.let { markMediaAsSensitive.value = it } + composeOptions?.sensitive?.let { _markMediaAsSensitive.value = it } val poll = composeOptions?.poll if (poll != null && composeOptions.mediaAttachments.isNullOrEmpty()) { - this.poll.value = poll + this._poll.value = poll } replyingStatusContent = composeOptions?.replyingStatusContent replyingStatusAuthor = composeOptions?.replyingStatusAuthor + updateCloseConfirmation() + setupComplete = true } - fun updatePoll(newPoll: NewPoll) { - poll.value = newPoll + fun updatePoll(newPoll: NewPoll?) { + _poll.value = newPoll + updateCloseConfirmation() } fun updateScheduledAt(newScheduledAt: String?) { - if (newScheduledAt != scheduledAt.value) { + if (newScheduledAt != _scheduledAt.value) { hasScheduledTimeChanged = true } - scheduledAt.value = newScheduledAt + _scheduledAt.value = newScheduledAt } val editing: Boolean diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/ImageDownsizer.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/ImageDownsizer.kt index 39b444688..fd355ca51 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/ImageDownsizer.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/ImageDownsizer.kt @@ -42,7 +42,7 @@ fun downsizeImage( tempFile: File ): Boolean { val decodeBoundsInputStream = try { - contentResolver.openInputStream(uri) + contentResolver.openInputStream(uri) ?: return false } catch (e: FileNotFoundException) { return false } @@ -54,10 +54,10 @@ fun downsizeImage( // Get EXIF data, for orientation info. val orientation = getImageOrientation(uri, contentResolver) /* Unfortunately, there isn't a determined worst case compression ratio for image - * formats. So, the only way to tell if they're too big is to compress them and - * test, and keep trying at smaller sizes. The initial estimate should be good for - * many cases, so it should only iterate once, but the loop is used to be absolutely - * sure it gets downsized to below the limit. */ + * formats. So, the only way to tell if they're too big is to compress them and + * test, and keep trying at smaller sizes. The initial estimate should be good for + * many cases, so it should only iterate once, but the loop is used to be absolutely + * sure it gets downsized to below the limit. */ var scaledImageSize = 1024 do { val outputStream = try { @@ -66,7 +66,7 @@ fun downsizeImage( return false } val decodeBitmapInputStream = try { - contentResolver.openInputStream(uri) + contentResolver.openInputStream(uri) ?: return false } catch (e: FileNotFoundException) { return false } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaPreviewAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaPreviewAdapter.kt index 6cd590d7e..bfc814293 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaPreviewAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaPreviewAdapter.kt @@ -113,11 +113,17 @@ class MediaPreviewAdapter( private val differ = AsyncListDiffer( this, object : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: ComposeActivity.QueuedMedia, newItem: ComposeActivity.QueuedMedia): Boolean { + override fun areItemsTheSame( + oldItem: ComposeActivity.QueuedMedia, + newItem: ComposeActivity.QueuedMedia + ): Boolean { return oldItem.localId == newItem.localId } - override fun areContentsTheSame(oldItem: ComposeActivity.QueuedMedia, newItem: ComposeActivity.QueuedMedia): Boolean { + override fun areContentsTheSame( + oldItem: ComposeActivity.QueuedMedia, + newItem: ComposeActivity.QueuedMedia + ): Boolean { return oldItem == newItem } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaUploader.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaUploader.kt index 58c3cf2a0..4f32d8dc0 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaUploader.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/MediaUploader.kt @@ -30,12 +30,16 @@ import com.keylesspalace.tusky.R import com.keylesspalace.tusky.components.compose.ComposeActivity.QueuedMedia import com.keylesspalace.tusky.components.instanceinfo.InstanceInfo import com.keylesspalace.tusky.network.MediaUploadApi -import com.keylesspalace.tusky.network.ProgressRequestBody +import com.keylesspalace.tusky.network.asRequestBody import com.keylesspalace.tusky.util.MEDIA_SIZE_UNKNOWN import com.keylesspalace.tusky.util.getImageSquarePixels import com.keylesspalace.tusky.util.getMediaSize import com.keylesspalace.tusky.util.getServerErrorMessage import com.keylesspalace.tusky.util.randomAlphanumericString +import java.io.File +import java.io.IOException +import javax.inject.Inject +import javax.inject.Singleton import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -52,21 +56,20 @@ import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.shareIn import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MultipartBody +import okio.buffer +import okio.sink +import okio.source import retrofit2.HttpException -import java.io.File -import java.io.FileInputStream -import java.io.FileOutputStream -import java.io.IOException -import java.util.Date -import javax.inject.Inject -import javax.inject.Singleton sealed interface FinalUploadEvent -sealed class UploadEvent { - data class ProgressEvent(val percentage: Int) : UploadEvent() - data class FinishedEvent(val mediaId: String, val processed: Boolean) : UploadEvent(), FinalUploadEvent - data class ErrorEvent(val error: Throwable) : UploadEvent(), FinalUploadEvent +sealed interface UploadEvent { + data class ProgressEvent(val percentage: Int) : UploadEvent + data class FinishedEvent( + val mediaId: String, + val processed: Boolean + ) : UploadEvent, FinalUploadEvent + data class ErrorEvent(val error: Throwable) : UploadEvent, FinalUploadEvent } data class UploadData( @@ -79,11 +82,7 @@ fun createNewImageFile(context: Context, suffix: String = ".jpg"): File { val randomId = randomAlphanumericString(12) val imageFileName = "Tusky_${randomId}_" val storageDir = context.getExternalFilesDir(Environment.DIRECTORY_PICTURES) - return File.createTempFile( - imageFileName, /* prefix */ - suffix, /* suffix */ - storageDir /* directory */ - ) + return File.createTempFile(imageFileName, suffix, storageDir) } data class PreparedMedia(val type: QueuedMedia.Type, val uri: Uri, val size: Long) @@ -163,22 +162,22 @@ class MediaUploader @Inject constructor( val suffix = "." + MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType ?: "tmp") - contentResolver.openInputStream(inUri).use { input -> + contentResolver.openInputStream(inUri)?.source().use { input -> if (input == null) { Log.w(TAG, "Media input is null") uri = inUri return@use } val file = File.createTempFile("randomTemp1", suffix, context.cacheDir) - FileOutputStream(file.absoluteFile).use { out -> - input.copyTo(out) - uri = FileProvider.getUriForFile( - context, - BuildConfig.APPLICATION_ID + ".fileprovider", - file - ) - mediaSize = getMediaSize(contentResolver, uri) + file.absoluteFile.sink().buffer().use { out -> + out.writeAll(input) } + uri = FileProvider.getUriForFile( + context, + BuildConfig.APPLICATION_ID + ".fileprovider", + file + ) + mediaSize = getMediaSize(contentResolver, uri) } } ContentResolver.SCHEME_FILE -> { @@ -191,17 +190,18 @@ class MediaUploader @Inject constructor( val suffix = inputFile.name.substringAfterLast('.', "tmp") mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(suffix) val file = File.createTempFile("randomTemp1", ".$suffix", context.cacheDir) - val input = FileInputStream(inputFile) - FileOutputStream(file.absoluteFile).use { out -> - input.copyTo(out) - uri = FileProvider.getUriForFile( - context, - BuildConfig.APPLICATION_ID + ".fileprovider", - file - ) - mediaSize = getMediaSize(contentResolver, uri) + inputFile.source().use { input -> + file.absoluteFile.sink().buffer().use { out -> + out.writeAll(input) + } } + uri = FileProvider.getUriForFile( + context, + BuildConfig.APPLICATION_ID + ".fileprovider", + file + ) + mediaSize = getMediaSize(contentResolver, uri) } else -> { Log.w(TAG, "Unknown uri scheme $uri") @@ -254,9 +254,9 @@ class MediaUploader @Inject constructor( // .m4a files. See https://github.com/tuskyapp/Tusky/issues/3189 for details. // Sniff the content of the file to determine the actual type. if (mimeType != null && ( - mimeType.startsWith("audio/", ignoreCase = true) || - mimeType.startsWith("video/", ignoreCase = true) - ) + mimeType.startsWith("audio/", ignoreCase = true) || + mimeType.startsWith("video/", ignoreCase = true) + ) ) { val retriever = MediaMetadataRetriever() retriever.setDataSource(context, media.uri) @@ -264,22 +264,20 @@ class MediaUploader @Inject constructor( } val map = MimeTypeMap.getSingleton() val fileExtension = map.getExtensionFromMimeType(mimeType) - val filename = "%s_%s_%s.%s".format( + val filename = "%s_%d_%s.%s".format( context.getString(R.string.app_name), - Date().time.toString(), + System.currentTimeMillis(), randomAlphanumericString(10), fileExtension ) - val stream = contentResolver.openInputStream(media.uri) - if (mimeType == null) mimeType = "multipart/form-data" var lastProgress = -1 - val fileBody = ProgressRequestBody( - stream!!, - media.mediaSize, - mimeType.toMediaTypeOrNull()!! + val fileBody = media.uri.asRequestBody( + contentResolver, + requireNotNull(mimeType.toMediaTypeOrNull()) { "Invalid Content Type" }, + media.mediaSize ) { percentage -> if (percentage != lastProgress) { trySend(UploadEvent.ProgressEvent(percentage)) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/AddPollDialog.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/AddPollDialog.kt index 2d76e2d03..1d8168db6 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/AddPollDialog.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/AddPollDialog.kt @@ -60,7 +60,9 @@ fun showAddPollDialog( binding.pollChoices.adapter = adapter var durations = context.resources.getIntArray(R.array.poll_duration_values).toList() - val durationLabels = context.resources.getStringArray(R.array.poll_duration_names).filterIndexed { index, _ -> durations[index] in minDuration..maxDuration } + val durationLabels = context.resources.getStringArray( + R.array.poll_duration_names + ).filterIndexed { index, _ -> durations[index] in minDuration..maxDuration } binding.pollDurationSpinner.adapter = ArrayAdapter(context, android.R.layout.simple_spinner_item, durationLabels).apply { setDropDownViewResource(androidx.appcompat.R.layout.support_simple_spinner_dropdown_item) } @@ -75,8 +77,8 @@ fun showAddPollDialog( } } - val DAY_SECONDS = 60 * 60 * 24 - val desiredDuration = poll?.expiresIn ?: DAY_SECONDS + val secondsInADay = 60 * 60 * 24 + val desiredDuration = poll?.expiresIn ?: secondsInADay val pollDurationId = durations.indexOfLast { it <= desiredDuration } @@ -105,5 +107,7 @@ fun showAddPollDialog( dialog.show() // make the dialog focusable so the keyboard does not stay behind it - dialog.window?.clearFlags(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM) + dialog.window?.clearFlags( + WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM + ) } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/AddPollOptionsAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/AddPollOptionsAdapter.kt index ded1b7cfd..ad7f7db18 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/AddPollOptionsAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/AddPollOptionsAdapter.kt @@ -41,8 +41,15 @@ class AddPollOptionsAdapter( notifyItemInserted(options.size - 1) } - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder { - val binding = ItemAddPollOptionBinding.inflate(LayoutInflater.from(parent.context), parent, false) + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int + ): BindingHolder { + val binding = ItemAddPollOptionBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) val holder = BindingHolder(binding) binding.optionEditText.filters = arrayOf(InputFilter.LengthFilter(maxOptionLength)) @@ -75,10 +82,6 @@ class AddPollOptionsAdapter( } private fun validateInput(): Boolean { - if (options.contains("") || options.distinct().size != options.size) { - return false - } - - return true + return !(options.contains("") || options.distinct().size != options.size) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/CaptionDialog.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/CaptionDialog.kt index a1dc032cd..154c83ed8 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/CaptionDialog.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/CaptionDialog.kt @@ -15,7 +15,6 @@ package com.keylesspalace.tusky.components.compose.dialog -import android.app.Dialog import android.content.Context import android.graphics.drawable.Drawable import android.net.Uri @@ -25,8 +24,7 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.view.WindowManager -import android.widget.EditText -import androidx.appcompat.app.AlertDialog +import android.widget.LinearLayout import androidx.core.os.BundleCompat import androidx.core.os.bundleOf import androidx.fragment.app.DialogFragment @@ -36,45 +34,56 @@ import com.bumptech.glide.request.target.CustomTarget import com.bumptech.glide.request.transition.Transition import com.keylesspalace.tusky.R import com.keylesspalace.tusky.databinding.DialogImageDescriptionBinding +import com.keylesspalace.tusky.util.hide +import com.keylesspalace.tusky.util.viewBinding // https://github.com/tootsuite/mastodon/blob/c6904c0d3766a2ea8a81ab025c127169ecb51373/app/models/media_attachment.rb#L32 private const val MEDIA_DESCRIPTION_CHARACTER_LIMIT = 1500 class CaptionDialog : DialogFragment() { private lateinit var listener: Listener - private lateinit var input: EditText - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val context = requireContext() + private val binding by viewBinding(DialogImageDescriptionBinding::bind) - val binding = DialogImageDescriptionBinding.inflate(layoutInflater) + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setStyle(STYLE_NORMAL, R.style.TuskyDialogFragmentStyle) + } - input = binding.imageDescriptionText + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View = inflater.inflate(R.layout.dialog_image_description, container, false) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { val imageView = binding.imageDescriptionView - imageView.maximumScale = 6f + imageView.maxZoom = 6f - input.hint = resources.getQuantityString( + binding.imageDescriptionText.hint = resources.getQuantityString( R.plurals.hint_describe_for_visually_impaired, MEDIA_DESCRIPTION_CHARACTER_LIMIT, MEDIA_DESCRIPTION_CHARACTER_LIMIT ) - input.filters = arrayOf(InputFilter.LengthFilter(MEDIA_DESCRIPTION_CHARACTER_LIMIT)) - input.setText(arguments?.getString(EXISTING_DESCRIPTION_ARG)) + binding.imageDescriptionText.filters = arrayOf(InputFilter.LengthFilter(MEDIA_DESCRIPTION_CHARACTER_LIMIT)) + binding.imageDescriptionText.setText(arguments?.getString(EXISTING_DESCRIPTION_ARG)) + savedInstanceState?.getCharSequence(DESCRIPTION_KEY)?.let { + binding.imageDescriptionText.setText(it) + } + binding.cancelButton.setOnClickListener { + dismiss() + } val localId = arguments?.getInt(LOCAL_ID_ARG) ?: error("Missing localId") - val dialog = AlertDialog.Builder(context) - .setView(binding.root) - .setPositiveButton(android.R.string.ok) { _, _ -> - listener.onUpdateDescription(localId, input.text.toString()) - } - .setNegativeButton(android.R.string.cancel, null) - .create() + binding.okButton.setOnClickListener { + listener.onUpdateDescription(localId, binding.imageDescriptionText.text.toString()) + dismiss() + } isCancelable = true - val window = dialog.window - window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE) val previewUri = BundleCompat.getParcelable(requireArguments(), PREVIEW_URI_ARG, Uri::class.java) ?: error("Preview Uri is null") + // Load the image and manually set it into the ImageView because it doesn't have a fixed size. Glide.with(this) .load(previewUri) @@ -90,27 +99,30 @@ class CaptionDialog : DialogFragment() { ) { imageView.setImageDrawable(resource) } - }) - return dialog + override fun onLoadFailed(errorDrawable: Drawable?) { + super.onLoadFailed(errorDrawable) + imageView.hide() + } + }) + } + + override fun onStart() { + super.onStart() + dialog?.apply { + window?.setLayout( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.MATCH_PARENT + ) + window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE) + } } override fun onSaveInstanceState(outState: Bundle) { - outState.putString(DESCRIPTION_KEY, input.text.toString()) + outState.putCharSequence(DESCRIPTION_KEY, binding.imageDescriptionText.text) super.onSaveInstanceState(outState) } - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - savedInstanceState?.getString(DESCRIPTION_KEY)?.let { - input.setText(it) - } - return super.onCreateView(inflater, container, savedInstanceState) - } - override fun onAttach(context: Context) { super.onAttach(context) listener = context as? Listener ?: error("Activity is not ComposeCaptionDialog.Listener") @@ -121,17 +133,14 @@ class CaptionDialog : DialogFragment() { } companion object { - fun newInstance( - localId: Int, - existingDescription: String?, - previewUri: Uri - ) = CaptionDialog().apply { - arguments = bundleOf( - LOCAL_ID_ARG to localId, - EXISTING_DESCRIPTION_ARG to existingDescription, - PREVIEW_URI_ARG to previewUri - ) - } + fun newInstance(localId: Int, existingDescription: String?, previewUri: Uri) = + CaptionDialog().apply { + arguments = bundleOf( + LOCAL_ID_ARG to localId, + EXISTING_DESCRIPTION_ARG to existingDescription, + PREVIEW_URI_ARG to previewUri + ) + } private const val DESCRIPTION_KEY = "description" private const val EXISTING_DESCRIPTION_ARG = "existing_description" diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/FocusDialog.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/FocusDialog.kt index 93c99ee6f..6cfbeedfc 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/FocusDialog.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/FocusDialog.kt @@ -49,12 +49,23 @@ fun T.makeFocusDialog( .load(previewUri) .downsample(DownsampleStrategy.CENTER_INSIDE) .listener(object : RequestListener { - override fun onLoadFailed(p0: GlideException?, p1: Any?, p2: Target?, p3: Boolean): Boolean { + override fun onLoadFailed( + p0: GlideException?, + p1: Any?, + p2: Target, + p3: Boolean + ): Boolean { return false } - override fun onResourceReady(resource: Drawable?, model: Any?, target: Target?, dataSource: DataSource?, isFirstResource: Boolean): Boolean { - val width = resource!!.intrinsicWidth + override fun onResourceReady( + resource: Drawable, + model: Any, + target: Target?, + dataSource: DataSource, + isFirstResource: Boolean + ): Boolean { + val width = resource.intrinsicWidth val height = resource.intrinsicHeight dialogBinding.focusIndicator.setImageSize(width, height) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/view/ComposeOptionsView.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/view/ComposeOptionsView.kt index 02ec9a9bc..e576bfef2 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/view/ComposeOptionsView.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/view/ComposeOptionsView.kt @@ -21,7 +21,10 @@ import android.widget.RadioGroup import com.keylesspalace.tusky.R import com.keylesspalace.tusky.entity.Status -class ComposeOptionsView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : RadioGroup(context, attrs) { +class ComposeOptionsView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : RadioGroup( + context, + attrs +) { var listener: ComposeOptionsListener? = null diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/view/ComposeScheduleView.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/view/ComposeScheduleView.kt index f2e00d341..a68906d9e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/view/ComposeScheduleView.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/view/ComposeScheduleView.kt @@ -57,7 +57,9 @@ class ComposeScheduleView ).apply { timeZone = TimeZone.getTimeZone("UTC") } - private var scheduleDateTime: Calendar? = null + + /** The date/time the user has chosen to schedule the status, in UTC */ + private var scheduleDateTimeUtc: Calendar? = null init { binding.scheduledDateTime.setOnClickListener { openPickDateDialog() } @@ -71,13 +73,13 @@ class ComposeScheduleView } private fun updateScheduleUi() { - if (scheduleDateTime == null) { + if (scheduleDateTimeUtc == null) { binding.scheduledDateTime.text = "" binding.invalidScheduleWarning.visibility = GONE return } - val scheduled = scheduleDateTime!!.time + val scheduled = scheduleDateTimeUtc!!.time binding.scheduledDateTime.text = String.format( "%s %s", dateFormat.format(scheduled), @@ -98,21 +100,37 @@ class ComposeScheduleView } fun resetSchedule() { - scheduleDateTime = null + scheduleDateTimeUtc = null updateScheduleUi() } fun openPickDateDialog() { - val yesterday = Calendar.getInstance().timeInMillis - 24 * 60 * 60 * 1000 + // The earliest point in time the calendar should display. Start with current date/time + val earliest = calendar().apply { + // Add the minimum scheduling interval. This may roll the calendar over to the + // next day (e.g. if the current time is 23:57). + add(Calendar.SECOND, MINIMUM_SCHEDULED_SECONDS) + // Clear out the time components, so it's midnight + set(Calendar.HOUR_OF_DAY, 0) + set(Calendar.MINUTE, 0) + set(Calendar.SECOND, 0) + set(Calendar.MILLISECOND, 0) + } val calendarConstraints = CalendarConstraints.Builder() - .setValidator( - DateValidatorPointForward.from(yesterday) - ) + .setValidator(DateValidatorPointForward.from(earliest.timeInMillis)) .build() initializeSuggestedTime() + + // Work around a misfeature in MaterialDatePicker. The `selection` is treated as + // millis-from-epoch, in UTC, which is good. However, it is also *displayed* in UTC + // instead of converting to the user's local timezone. + // + // So we have to add the TZ offset before setting it in the picker + val tzOffset = TimeZone.getDefault().getOffset(scheduleDateTimeUtc!!.timeInMillis) + val picker = MaterialDatePicker.Builder .datePicker() - .setSelection(scheduleDateTime!!.timeInMillis) + .setSelection(scheduleDateTimeUtc!!.timeInMillis + tzOffset) .setCalendarConstraints(calendarConstraints) .build() picker.addOnPositiveButtonClickListener { selection: Long -> onDateSet(selection) } @@ -129,11 +147,12 @@ class ComposeScheduleView private fun openPickTimeDialog() { val pickerBuilder = MaterialTimePicker.Builder() - scheduleDateTime?.let { + scheduleDateTimeUtc?.let { pickerBuilder.setHour(it[Calendar.HOUR_OF_DAY]) .setMinute(it[Calendar.MINUTE]) } + pickerBuilder.setTitleText(dateFormat.format(scheduleDateTimeUtc!!.timeInMillis)) pickerBuilder.setTimeFormat(getTimeFormat(context)) val picker = pickerBuilder.build() @@ -154,7 +173,7 @@ class ComposeScheduleView fun setDateTime(scheduledAt: String?) { val date = getDateTime(scheduledAt) ?: return initializeSuggestedTime() - scheduleDateTime!!.time = date + scheduleDateTimeUtc!!.time = date updateScheduleUi() } @@ -180,31 +199,32 @@ class ComposeScheduleView // see https://github.com/material-components/material-components-android/issues/882 newDate.timeZone = TimeZone.getTimeZone("UTC") newDate.timeInMillis = selection - scheduleDateTime!![newDate[Calendar.YEAR], newDate[Calendar.MONTH]] = newDate[Calendar.DATE] + scheduleDateTimeUtc!![newDate[Calendar.YEAR], newDate[Calendar.MONTH]] = newDate[Calendar.DATE] openPickTimeDialog() } private fun onTimeSet(hourOfDay: Int, minute: Int) { initializeSuggestedTime() - scheduleDateTime?.set(Calendar.HOUR_OF_DAY, hourOfDay) - scheduleDateTime?.set(Calendar.MINUTE, minute) + scheduleDateTimeUtc?.set(Calendar.HOUR_OF_DAY, hourOfDay) + scheduleDateTimeUtc?.set(Calendar.MINUTE, minute) updateScheduleUi() listener?.onTimeSet(time) } val time: String? - get() = scheduleDateTime?.time?.let { iso8601.format(it) } + get() = scheduleDateTimeUtc?.time?.let { iso8601.format(it) } private fun initializeSuggestedTime() { - if (scheduleDateTime == null) { - scheduleDateTime = calendar().apply { + if (scheduleDateTimeUtc == null) { + scheduleDateTimeUtc = calendar().apply { add(Calendar.MINUTE, 15) } } } companion object { - var MINIMUM_SCHEDULED_SECONDS = 330 // Minimum is 5 minutes, pad 30 seconds for posting + // Minimum is 5 minutes, pad 30 seconds for posting + private const val MINIMUM_SCHEDULED_SECONDS = 330 fun calendar(): Calendar = Calendar.getInstance(TimeZone.getDefault()) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/view/FocusIndicatorView.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/view/FocusIndicatorView.kt index 6e0b83dc5..7cda8cc21 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/view/FocusIndicatorView.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/view/FocusIndicatorView.kt @@ -68,7 +68,9 @@ class FocusIndicatorView return offset.toFloat() + ((value + 1.0f) / 2.0f) * innerLimit.toFloat() // From range -1..1 } - @SuppressLint("ClickableViewAccessibility") // Android Studio wants us to implement PerformClick for accessibility, but that unfortunately cannot be made meaningful for this widget. + @SuppressLint( + "ClickableViewAccessibility" + ) // Android Studio wants us to implement PerformClick for accessibility, but that unfortunately cannot be made meaningful for this widget. override fun onTouchEvent(event: MotionEvent): Boolean { if (event.actionMasked == MotionEvent.ACTION_CANCEL) { return false @@ -112,7 +114,13 @@ class FocusIndicatorView curtainPath.reset() // Draw a flood fill with a hole cut out of it curtainPath.fillType = Path.FillType.WINDING - curtainPath.addRect(0.0f, 0.0f, this.width.toFloat(), this.height.toFloat(), Path.Direction.CW) + curtainPath.addRect( + 0.0f, + 0.0f, + this.width.toFloat(), + this.height.toFloat(), + Path.Direction.CW + ) curtainPath.addCircle(x, y, circleRadius, Path.Direction.CCW) canvas.drawPath(curtainPath, curtainPaint) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/compose/view/TootButton.kt b/app/src/main/java/com/keylesspalace/tusky/components/compose/view/TootButton.kt index 43e8f6ef9..995e400e1 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/compose/view/TootButton.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/compose/view/TootButton.kt @@ -60,7 +60,10 @@ class TootButton Status.Visibility.PRIVATE, Status.Visibility.DIRECT -> { setText(R.string.action_send) - IconicsDrawable(context, GoogleMaterial.Icon.gmd_lock).apply { sizeDp = 18; colorInt = Color.WHITE } + IconicsDrawable(context, GoogleMaterial.Icon.gmd_lock).apply { + sizeDp = 18 + colorInt = Color.WHITE + } } else -> { null diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationAdapter.kt index a5a8ed27d..beca7342d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationAdapter.kt @@ -38,7 +38,9 @@ class ConversationAdapter( } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ConversationViewHolder { - val view = LayoutInflater.from(parent.context).inflate(R.layout.item_conversation, parent, false) + val view = LayoutInflater.from( + parent.context + ).inflate(R.layout.item_conversation, parent, false) return ConversationViewHolder(view, statusDisplayOptions, listener) } @@ -58,15 +60,24 @@ class ConversationAdapter( companion object { val CONVERSATION_COMPARATOR = object : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: ConversationViewData, newItem: ConversationViewData): Boolean { + override fun areItemsTheSame( + oldItem: ConversationViewData, + newItem: ConversationViewData + ): Boolean { return oldItem.id == newItem.id } - override fun areContentsTheSame(oldItem: ConversationViewData, newItem: ConversationViewData): Boolean { + override fun areContentsTheSame( + oldItem: ConversationViewData, + newItem: ConversationViewData + ): Boolean { return false // Items are different always. It allows to refresh timestamp on every view holder update } - override fun getChangePayload(oldItem: ConversationViewData, newItem: ConversationViewData): Any? { + override fun getChangePayload( + oldItem: ConversationViewData, + newItem: ConversationViewData + ): Any? { return if (oldItem == newItem) { // If items are equal - update timestamp only listOf(StatusBaseViewHolder.Key.KEY_CREATED) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationEntity.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationEntity.kt index f830b5bd9..d38898c77 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationEntity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationEntity.kt @@ -27,6 +27,7 @@ import com.keylesspalace.tusky.entity.Poll import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.TimelineAccount import com.keylesspalace.tusky.viewdata.StatusViewData +import com.squareup.moshi.JsonClass import java.util.Date @Entity(primaryKeys = ["id", "accountId"]) @@ -50,6 +51,7 @@ data class ConversationEntity( } } +@JsonClass(generateAdapter = true) data class ConversationAccountEntity( val id: String, val localUsername: String, @@ -131,7 +133,7 @@ data class ConversationStatusEntity( poll = poll, card = null, language = language, - filtered = null + filtered = emptyList() ), isExpanded = expanded, isShowingContent = showingHiddenContent, @@ -140,21 +142,16 @@ data class ConversationStatusEntity( } } -fun TimelineAccount.toEntity() = - ConversationAccountEntity( - id = id, - localUsername = localUsername, - username = username, - displayName = name, - avatar = avatar, - emojis = emojis.orEmpty() - ) +fun TimelineAccount.toEntity() = ConversationAccountEntity( + id = id, + localUsername = localUsername, + username = username, + displayName = name, + avatar = avatar, + emojis = emojis.orEmpty() +) -fun Status.toEntity( - expanded: Boolean, - contentShowing: Boolean, - contentCollapsed: Boolean -) = +fun Status.toEntity(expanded: Boolean, contentShowing: Boolean, contentCollapsed: Boolean) = ConversationStatusEntity( id = id, url = url, @@ -177,7 +174,7 @@ fun Status.toEntity( showingHiddenContent = contentShowing, expanded = expanded, collapsed = contentCollapsed, - muted = muted ?: false, + muted = muted, poll = poll, language = language ) @@ -188,16 +185,15 @@ fun Conversation.toEntity( expanded: Boolean, contentShowing: Boolean, contentCollapsed: Boolean -) = - ConversationEntity( - accountId = accountId, - id = id, - order = order, - accounts = accounts.map { it.toEntity() }, - unread = unread, - lastStatus = lastStatus!!.toEntity( - expanded = expanded, - contentShowing = contentShowing, - contentCollapsed = contentCollapsed - ) +) = ConversationEntity( + accountId = accountId, + id = id, + order = order, + accounts = accounts.map { it.toEntity() }, + unread = unread, + lastStatus = lastStatus!!.toEntity( + expanded = expanded, + contentShowing = contentShowing, + contentCollapsed = contentCollapsed ) +) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationLoadStateAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationLoadStateAdapter.kt index 7ff4daa74..b8373f986 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationLoadStateAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationLoadStateAdapter.kt @@ -27,7 +27,10 @@ class ConversationLoadStateAdapter( private val retryCallback: () -> Unit ) : LoadStateAdapter>() { - override fun onBindViewHolder(holder: BindingHolder, loadState: LoadState) { + override fun onBindViewHolder( + holder: BindingHolder, + loadState: LoadState + ) { val binding = holder.binding binding.progressBar.visible(loadState == LoadState.Loading) binding.retryButton.visible(loadState is LoadState.Error) @@ -47,7 +50,11 @@ class ConversationLoadStateAdapter( parent: ViewGroup, loadState: LoadState ): BindingHolder { - val binding = ItemNetworkStateBinding.inflate(LayoutInflater.from(parent.context), parent, false) + val binding = ItemNetworkStateBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) return BindingHolder(binding) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewData.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewData.kt index b197084df..944425438 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewData.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewData.kt @@ -29,7 +29,7 @@ data class ConversationViewData( accountId: Long, favourited: Boolean = lastStatus.status.favourited, bookmarked: Boolean = lastStatus.status.bookmarked, - muted: Boolean = lastStatus.status.muted ?: false, + muted: Boolean = lastStatus.status.muted, poll: Poll? = lastStatus.status.poll, expanded: Boolean = lastStatus.isExpanded, collapsed: Boolean = lastStatus.isCollapsed, @@ -57,7 +57,7 @@ data class ConversationViewData( fun StatusViewData.Concrete.toConversationStatusEntity( favourited: Boolean = status.favourited, bookmarked: Boolean = status.bookmarked, - muted: Boolean = status.muted ?: false, + muted: Boolean = status.muted, poll: Poll? = status.poll, expanded: Boolean = isExpanded, collapsed: Boolean = isCollapsed, diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewHolder.java index 722a9f3c5..3c3103e0d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewHolder.java @@ -78,7 +78,7 @@ public class ConversationViewHolder extends StatusBaseViewHolder { if (payloads == null) { TimelineAccount account = status.getAccount(); - setupCollapsedState(statusViewData.isCollapsible(), statusViewData.isCollapsed(), statusViewData.isExpanded(), statusViewData.getSpoilerText(), listener); + setupCollapsedState(statusViewData.isCollapsible(), statusViewData.isCollapsed(), statusViewData.isExpanded(), status.getSpoilerText(), listener); setDisplayName(account.getDisplayName(), account.getEmojis(), statusDisplayOptions); setUsername(account.getUsername()); @@ -144,7 +144,7 @@ public class ConversationViewHolder extends StatusBaseViewHolder { ImageView avatarView = avatars[i]; if (i < accounts.size()) { ImageLoadingHelper.loadAvatar(accounts.get(i).getAvatar(), avatarView, - avatarRadius48dp, statusDisplayOptions.animateAvatars()); + avatarRadius48dp, statusDisplayOptions.animateAvatars(), null); avatarView.setVisibility(View.VISIBLE); } else { avatarView.setVisibility(View.GONE); diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt index 132263500..b95276c51 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt @@ -40,6 +40,7 @@ import com.google.android.material.color.MaterialColors import com.keylesspalace.tusky.R import com.keylesspalace.tusky.StatusListActivity import com.keylesspalace.tusky.adapter.StatusBaseViewHolder +import com.keylesspalace.tusky.appstore.ConversationsLoadingEvent import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.PreferenceChangedEvent import com.keylesspalace.tusky.components.account.AccountActivity @@ -54,6 +55,7 @@ import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.util.CardViewMode import com.keylesspalace.tusky.util.StatusDisplayOptions import com.keylesspalace.tusky.util.hide +import com.keylesspalace.tusky.util.isAnyLoading import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.viewdata.AttachmentViewData @@ -61,12 +63,12 @@ import com.mikepenz.iconics.IconicsDrawable import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial import com.mikepenz.iconics.utils.colorInt import com.mikepenz.iconics.utils.sizeDp -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.launch import javax.inject.Inject import kotlin.time.DurationUnit import kotlin.time.toDuration +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch class ConversationsFragment : SFragment(), @@ -89,7 +91,11 @@ class ConversationsFragment : private var hideFab = false - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { return inflater.inflate(R.layout.fragment_timeline, container, false) } @@ -99,14 +105,14 @@ class ConversationsFragment : val preferences = PreferenceManager.getDefaultSharedPreferences(view.context) val statusDisplayOptions = StatusDisplayOptions( - animateAvatars = preferences.getBoolean("animateGifAvatars", false), + animateAvatars = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false), mediaPreviewEnabled = accountManager.activeAccount?.mediaPreviewEnabled ?: true, - useAbsoluteTime = preferences.getBoolean("absoluteTimeView", false), - showBotOverlay = preferences.getBoolean("showBotOverlay", true), - useBlurhash = preferences.getBoolean("useBlurhash", true), + useAbsoluteTime = preferences.getBoolean(PrefKeys.ABSOLUTE_TIME_VIEW, false), + showBotOverlay = preferences.getBoolean(PrefKeys.SHOW_BOT_OVERLAY, true), + useBlurhash = preferences.getBoolean(PrefKeys.USE_BLURHASH, true), cardViewMode = CardViewMode.NONE, - confirmReblogs = preferences.getBoolean("confirmReblogs", true), - confirmFavourites = preferences.getBoolean("confirmFavourites", false), + confirmReblogs = preferences.getBoolean(PrefKeys.CONFIRM_REBLOGS, true), + confirmFavourites = preferences.getBoolean(PrefKeys.CONFIRM_FAVOURITES, false), hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false), animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false), showStatsInline = preferences.getBoolean(PrefKeys.SHOW_STATS_INLINE, false), @@ -128,18 +134,37 @@ class ConversationsFragment : binding.statusView.hide() binding.progressBar.hide() + if (loadState.isAnyLoading()) { + lifecycleScope.launch { + eventHub.dispatch( + ConversationsLoadingEvent( + accountManager.activeAccount?.accountId ?: "" + ) + ) + } + } + if (adapter.itemCount == 0) { when (loadState.refresh) { is LoadState.NotLoading -> { if (loadState.append is LoadState.NotLoading && loadState.source.refresh is LoadState.NotLoading) { binding.statusView.show() - binding.statusView.setup(R.drawable.elephant_friend_empty, R.string.message_empty, null) + binding.statusView.setup( + R.drawable.elephant_friend_empty, + R.string.message_empty, + null + ) + binding.statusView.showHelp(R.string.help_empty_conversations) } } + is LoadState.Error -> { binding.statusView.show() - binding.statusView.setup((loadState.refresh as LoadState.Error).error) { refreshContent() } + binding.statusView.setup( + (loadState.refresh as LoadState.Error).error + ) { refreshContent() } } + is LoadState.Loading -> { binding.progressBar.show() } @@ -223,6 +248,7 @@ class ConversationsFragment : refreshContent() true } + else -> false } } @@ -231,11 +257,14 @@ class ConversationsFragment : binding.recyclerView.setHasFixedSize(true) binding.recyclerView.layoutManager = LinearLayoutManager(context) - binding.recyclerView.addItemDecoration(DividerItemDecoration(context, DividerItemDecoration.VERTICAL)) + binding.recyclerView.addItemDecoration( + DividerItemDecoration(context, DividerItemDecoration.VERTICAL) + ) (binding.recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false - binding.recyclerView.adapter = adapter.withLoadStateFooter(ConversationLoadStateAdapter(adapter::retry)) + binding.recyclerView.adapter = + adapter.withLoadStateFooter(ConversationLoadStateAdapter(adapter::retry)) } private fun refreshContent() { @@ -263,13 +292,15 @@ class ConversationsFragment : } } + override val onMoreTranslate: ((translate: Boolean, position: Int) -> Unit)? = null + override fun onMore(view: View, position: Int) { adapter.peek(position)?.let { conversation -> val popup = PopupMenu(requireContext(), view) popup.inflate(R.menu.conversation_more) - if (conversation.lastStatus.status.muted == true) { + if (conversation.lastStatus.status.muted) { popup.menu.removeItem(R.id.status_mute_conversation) } else { popup.menu.removeItem(R.id.status_unmute_conversation) @@ -289,7 +320,11 @@ class ConversationsFragment : override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) { adapter.peek(position)?.let { conversation -> - viewMedia(attachmentIndex, AttachmentViewData.list(conversation.lastStatus.status), view) + viewMedia( + attachmentIndex, + AttachmentViewData.list(conversation.lastStatus.status), + view + ) } } @@ -361,6 +396,10 @@ class ConversationsFragment : } } + override fun onUntranslate(position: Int) { + // not needed + } + private fun deleteConversation(conversation: ConversationViewData) { AlertDialog.Builder(requireContext()) .setMessage(R.string.dialog_delete_conversation_warning) @@ -377,6 +416,7 @@ class ConversationsFragment : PrefKeys.FAB_HIDE -> { hideFab = sharedPreferences.getBoolean(PrefKeys.FAB_HIDE, false) } + PrefKeys.MEDIA_PREVIEW_ENABLED -> { val enabled = accountManager.activeAccount!!.mediaPreviewEnabled val oldMediaPreviewEnabled = adapter.mediaPreviewEnabled diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsRemoteMediator.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsRemoteMediator.kt index b00c99a95..cf81e5ef6 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsRemoteMediator.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsRemoteMediator.kt @@ -38,7 +38,10 @@ class ConversationsRemoteMediator( } try { - val conversationsResponse = api.getConversations(maxId = nextKey, limit = state.config.pageSize) + val conversationsResponse = api.getConversations( + maxId = nextKey, + limit = state.config.pageSize + ) val conversations = conversationsResponse.body() if (!conversationsResponse.isSuccessful || conversations == null) { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsViewModel.kt index 78bb7c8e5..2972293ae 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsViewModel.kt @@ -29,9 +29,9 @@ import com.keylesspalace.tusky.db.AppDatabase import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.usecase.TimelineCases import com.keylesspalace.tusky.util.EmptyPagingSource +import javax.inject.Inject import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch -import javax.inject.Inject class ConversationsViewModel @Inject constructor( private val timelineCases: TimelineCases, @@ -91,7 +91,11 @@ class ConversationsViewModel @Inject constructor( fun voteInPoll(choices: List, conversation: ConversationViewData) { viewModelScope.launch { - timelineCases.voteInPoll(conversation.lastStatus.id, conversation.lastStatus.status.poll?.id!!, choices) + timelineCases.voteInPoll( + conversation.lastStatus.id, + conversation.lastStatus.status.poll?.id!!, + choices + ) .fold({ poll -> val newConversation = conversation.toEntity( accountId = accountManager.activeAccount!!.id, @@ -155,12 +159,12 @@ class ConversationsViewModel @Inject constructor( try { timelineCases.muteConversation( conversation.lastStatus.id, - !(conversation.lastStatus.status.muted ?: false) + !conversation.lastStatus.status.muted ) val newConversation = conversation.toEntity( accountId = accountManager.activeAccount!!.id, - muted = !(conversation.lastStatus.status.muted ?: false) + muted = !conversation.lastStatus.status.muted ) database.conversationDao().insert(newConversation) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/instancemute/InstanceListActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/domainblocks/DomainBlocksActivity.kt similarity index 78% rename from app/src/main/java/com/keylesspalace/tusky/components/instancemute/InstanceListActivity.kt rename to app/src/main/java/com/keylesspalace/tusky/components/domainblocks/DomainBlocksActivity.kt index 667360c53..618174907 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/instancemute/InstanceListActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/domainblocks/DomainBlocksActivity.kt @@ -1,15 +1,14 @@ -package com.keylesspalace.tusky.components.instancemute +package com.keylesspalace.tusky.components.domainblocks import android.os.Bundle import com.keylesspalace.tusky.BaseActivity import com.keylesspalace.tusky.R -import com.keylesspalace.tusky.components.instancemute.fragment.InstanceListFragment import com.keylesspalace.tusky.databinding.ActivityAccountListBinding import dagger.android.DispatchingAndroidInjector import dagger.android.HasAndroidInjector import javax.inject.Inject -class InstanceListActivity : BaseActivity(), HasAndroidInjector { +class DomainBlocksActivity : BaseActivity(), HasAndroidInjector { @Inject lateinit var androidInjector: DispatchingAndroidInjector @@ -28,7 +27,7 @@ class InstanceListActivity : BaseActivity(), HasAndroidInjector { supportFragmentManager .beginTransaction() - .replace(R.id.fragment_container, InstanceListFragment()) + .replace(R.id.fragment_container, DomainBlocksFragment()) .commit() } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/domainblocks/DomainBlocksAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/domainblocks/DomainBlocksAdapter.kt new file mode 100644 index 000000000..29d42aafe --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/domainblocks/DomainBlocksAdapter.kt @@ -0,0 +1,34 @@ +package com.keylesspalace.tusky.components.domainblocks + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.paging.PagingDataAdapter +import com.keylesspalace.tusky.components.followedtags.FollowedTagsAdapter.Companion.STRING_COMPARATOR +import com.keylesspalace.tusky.databinding.ItemBlockedDomainBinding +import com.keylesspalace.tusky.util.BindingHolder + +class DomainBlocksAdapter( + private val onUnmute: (String) -> Unit +) : PagingDataAdapter>(STRING_COMPARATOR) { + + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int + ): BindingHolder { + val binding = ItemBlockedDomainBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) + return BindingHolder(binding) + } + + override fun onBindViewHolder(holder: BindingHolder, position: Int) { + getItem(position)?.let { instance -> + holder.binding.blockedDomain.text = instance + holder.binding.blockedDomainUnblock.setOnClickListener { + onUnmute(instance) + } + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/domainblocks/DomainBlocksFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/domainblocks/DomainBlocksFragment.kt new file mode 100644 index 000000000..1adfb911c --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/domainblocks/DomainBlocksFragment.kt @@ -0,0 +1,96 @@ +package com.keylesspalace.tusky.components.domainblocks + +import android.os.Bundle +import android.util.Log +import android.view.View +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.lifecycleScope +import androidx.paging.LoadState +import androidx.recyclerview.widget.DividerItemDecoration +import androidx.recyclerview.widget.LinearLayoutManager +import com.google.android.material.snackbar.Snackbar +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.databinding.FragmentDomainBlocksBinding +import com.keylesspalace.tusky.di.Injectable +import com.keylesspalace.tusky.di.ViewModelFactory +import com.keylesspalace.tusky.util.hide +import com.keylesspalace.tusky.util.show +import com.keylesspalace.tusky.util.viewBinding +import com.keylesspalace.tusky.util.visible +import javax.inject.Inject +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch + +class DomainBlocksFragment : Fragment(R.layout.fragment_domain_blocks), Injectable { + + @Inject + lateinit var viewModelFactory: ViewModelFactory + + private val binding by viewBinding(FragmentDomainBlocksBinding::bind) + + private val viewModel: DomainBlocksViewModel by viewModels { viewModelFactory } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + val adapter = DomainBlocksAdapter(viewModel::unblock) + + binding.recyclerView.setHasFixedSize(true) + binding.recyclerView.addItemDecoration( + DividerItemDecoration(view.context, DividerItemDecoration.VERTICAL) + ) + binding.recyclerView.adapter = adapter + binding.recyclerView.layoutManager = LinearLayoutManager(view.context) + + viewLifecycleOwner.lifecycleScope.launch { + viewModel.uiEvents.collect { event -> + showSnackbar(event) + } + } + + lifecycleScope.launch { + viewModel.domainPager.collectLatest { pagingData -> + adapter.submitData(pagingData) + } + } + + adapter.addLoadStateListener { loadState -> + binding.progressBar.visible( + loadState.refresh == LoadState.Loading && adapter.itemCount == 0 + ) + + if (loadState.refresh is LoadState.Error) { + binding.recyclerView.hide() + binding.messageView.show() + val errorState = loadState.refresh as LoadState.Error + binding.messageView.setup(errorState.error) { adapter.retry() } + Log.w(TAG, "error loading blocked domains", errorState.error) + } else if (loadState.refresh is LoadState.NotLoading && adapter.itemCount == 0) { + binding.recyclerView.hide() + binding.messageView.show() + binding.messageView.setup(R.drawable.elephant_friend_empty, R.string.message_empty) + } else { + binding.recyclerView.show() + binding.messageView.hide() + } + } + } + + private fun showSnackbar(event: SnackbarEvent) { + val message = if (event.throwable == null) { + getString(event.message, event.domain) + } else { + Log.w(TAG, event.throwable) + val error = event.throwable.localizedMessage ?: getString(R.string.ui_error_unknown) + getString(event.message, event.domain, error) + } + + Snackbar.make(binding.recyclerView, message, Snackbar.LENGTH_LONG) + .setTextMaxLines(5) + .setAction(event.actionText, event.action) + .show() + } + + companion object { + private const val TAG = "DomainBlocksFragment" + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/domainblocks/DomainBlocksPagingSource.kt b/app/src/main/java/com/keylesspalace/tusky/components/domainblocks/DomainBlocksPagingSource.kt new file mode 100644 index 000000000..0438a268f --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/domainblocks/DomainBlocksPagingSource.kt @@ -0,0 +1,19 @@ +package com.keylesspalace.tusky.components.domainblocks + +import androidx.paging.PagingSource +import androidx.paging.PagingState + +class DomainBlocksPagingSource( + private val domains: List, + private val nextKey: String? +) : PagingSource() { + override fun getRefreshKey(state: PagingState): String? = null + + override suspend fun load(params: LoadParams): LoadResult { + return if (params is LoadParams.Refresh) { + LoadResult.Page(domains, null, nextKey) + } else { + LoadResult.Page(emptyList(), null, null) + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/domainblocks/DomainBlocksRemoteMediator.kt b/app/src/main/java/com/keylesspalace/tusky/components/domainblocks/DomainBlocksRemoteMediator.kt new file mode 100644 index 000000000..09f99044e --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/domainblocks/DomainBlocksRemoteMediator.kt @@ -0,0 +1,57 @@ +package com.keylesspalace.tusky.components.domainblocks + +import androidx.paging.ExperimentalPagingApi +import androidx.paging.LoadType +import androidx.paging.PagingState +import androidx.paging.RemoteMediator +import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.util.HttpHeaderLink +import retrofit2.HttpException +import retrofit2.Response + +@OptIn(ExperimentalPagingApi::class) +class DomainBlocksRemoteMediator( + private val api: MastodonApi, + private val repository: DomainBlocksRepository +) : RemoteMediator() { + + override suspend fun load( + loadType: LoadType, + state: PagingState + ): MediatorResult { + return try { + val response = request(loadType) + ?: return MediatorResult.Success(endOfPaginationReached = true) + + return applyResponse(response) + } catch (e: Exception) { + MediatorResult.Error(e) + } + } + + private suspend fun request(loadType: LoadType): Response>? { + return when (loadType) { + LoadType.PREPEND -> null + LoadType.APPEND -> api.domainBlocks(maxId = repository.nextKey) + LoadType.REFRESH -> { + repository.nextKey = null + repository.domains.clear() + api.domainBlocks() + } + } + } + + private fun applyResponse(response: Response>): MediatorResult { + val tags = response.body() + if (!response.isSuccessful || tags == null) { + return MediatorResult.Error(HttpException(response)) + } + + val links = HttpHeaderLink.parse(response.headers()["Link"]) + repository.nextKey = HttpHeaderLink.findByRelationType(links, "next")?.uri?.getQueryParameter("max_id") + repository.domains.addAll(tags) + repository.invalidate() + + return MediatorResult.Success(endOfPaginationReached = repository.nextKey == null) + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/domainblocks/DomainBlocksRepository.kt b/app/src/main/java/com/keylesspalace/tusky/components/domainblocks/DomainBlocksRepository.kt new file mode 100644 index 000000000..bdc9b9367 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/domainblocks/DomainBlocksRepository.kt @@ -0,0 +1,69 @@ +/* + * Copyright 2023 Tusky Contributors + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . + */ + +package com.keylesspalace.tusky.components.domainblocks + +import androidx.paging.ExperimentalPagingApi +import androidx.paging.InvalidatingPagingSourceFactory +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.PagingSource +import at.connyduck.calladapter.networkresult.NetworkResult +import at.connyduck.calladapter.networkresult.onSuccess +import com.keylesspalace.tusky.network.MastodonApi +import javax.inject.Inject + +class DomainBlocksRepository @Inject constructor( + private val api: MastodonApi +) { + val domains: MutableList = mutableListOf() + var nextKey: String? = null + + private var factory = InvalidatingPagingSourceFactory { + DomainBlocksPagingSource(domains.toList(), nextKey) + } + + @OptIn(ExperimentalPagingApi::class) + val domainPager = Pager( + config = PagingConfig(pageSize = PAGE_SIZE, initialLoadSize = PAGE_SIZE), + remoteMediator = DomainBlocksRemoteMediator(api, this), + pagingSourceFactory = factory + ).flow + + /** Invalidate the active paging source, see [PagingSource.invalidate] */ + fun invalidate() { + factory.invalidate() + } + + suspend fun block(domain: String): NetworkResult { + return api.blockDomain(domain).onSuccess { + domains.add(domain) + factory.invalidate() + } + } + + suspend fun unblock(domain: String): NetworkResult { + return api.unblockDomain(domain).onSuccess { + domains.remove(domain) + factory.invalidate() + } + } + + companion object { + private const val PAGE_SIZE = 20 + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/domainblocks/DomainBlocksViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/domainblocks/DomainBlocksViewModel.kt new file mode 100644 index 000000000..aa316c651 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/domainblocks/DomainBlocksViewModel.kt @@ -0,0 +1,75 @@ +package com.keylesspalace.tusky.components.domainblocks + +import android.view.View +import androidx.annotation.StringRes +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.paging.cachedIn +import at.connyduck.calladapter.networkresult.fold +import at.connyduck.calladapter.networkresult.onFailure +import com.keylesspalace.tusky.R +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.launch + +class DomainBlocksViewModel @Inject constructor( + private val repo: DomainBlocksRepository +) : ViewModel() { + + val domainPager = repo.domainPager.cachedIn(viewModelScope) + + private val _uiEvents = MutableSharedFlow() + val uiEvents: SharedFlow = _uiEvents.asSharedFlow() + + fun block(domain: String) { + viewModelScope.launch { + repo.block(domain).onFailure { e -> + _uiEvents.emit( + SnackbarEvent( + message = R.string.error_blocking_domain, + domain = domain, + throwable = e, + actionText = R.string.action_retry, + action = { block(domain) } + ) + ) + } + } + } + + fun unblock(domain: String) { + viewModelScope.launch { + repo.unblock(domain).fold({ + _uiEvents.emit( + SnackbarEvent( + message = R.string.confirmation_domain_unmuted, + domain = domain, + throwable = null, + actionText = R.string.action_undo, + action = { block(domain) } + ) + ) + }, { e -> + _uiEvents.emit( + SnackbarEvent( + message = R.string.error_unblocking_domain, + domain = domain, + throwable = e, + actionText = R.string.action_retry, + action = { unblock(domain) } + ) + ) + }) + } + } +} + +class SnackbarEvent( + @StringRes val message: Int, + val domain: String, + val throwable: Throwable?, + @StringRes val actionText: Int, + val action: (View) -> Unit +) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftHelper.kt b/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftHelper.kt index 2dc802e6a..7cfed4ea8 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftHelper.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftHelper.kt @@ -29,18 +29,18 @@ import com.keylesspalace.tusky.entity.Attachment import com.keylesspalace.tusky.entity.NewPoll import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.util.copyToFile -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import okhttp3.OkHttpClient -import okhttp3.Request -import okio.buffer -import okio.sink import java.io.File import java.io.IOException import java.text.SimpleDateFormat import java.util.Date import java.util.Locale import javax.inject.Inject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request +import okio.buffer +import okio.sink class DraftHelper @Inject constructor( val context: Context, @@ -101,16 +101,17 @@ class DraftHelper @Inject constructor( } } - val attachments: MutableList = mutableListOf() - for (i in mediaUris.indices) { - attachments.add( - DraftAttachment( - uriString = uris[i].toString(), - description = mediaDescriptions[i], - focus = mediaFocus[i], - type = types[i] + val attachments: List = buildList(mediaUris.size) { + for (i in mediaUris.indices) { + add( + DraftAttachment( + uriString = uris[i].toString(), + description = mediaDescriptions[i], + focus = mediaFocus[i], + type = types[i] + ) ) - ) + } } val draft = DraftEntity( @@ -186,10 +187,8 @@ class DraftHelper @Inject constructor( val response = okHttpClient.newCall(request).execute() - val sink = file.sink().buffer() - - response.body?.source()?.use { input -> - sink.use { output -> + file.sink().buffer().use { output -> + response.body?.source()?.use { input -> output.writeAll(input) } } @@ -200,6 +199,10 @@ class DraftHelper @Inject constructor( } else { this.copyToFile(contentResolver, file) } - return FileProvider.getUriForFile(context, BuildConfig.APPLICATION_ID + ".fileprovider", file) + return FileProvider.getUriForFile( + context, + BuildConfig.APPLICATION_ID + ".fileprovider", + file + ) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftMediaAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftMediaAdapter.kt index 2165f7e0c..fd6816b7b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftMediaAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftMediaAdapter.kt @@ -35,7 +35,10 @@ class DraftMediaAdapter( return oldItem == newItem } - override fun areContentsTheSame(oldItem: DraftAttachment, newItem: DraftAttachment): Boolean { + override fun areContentsTheSame( + oldItem: DraftAttachment, + newItem: DraftAttachment + ): Boolean { return oldItem == newItem } } @@ -75,7 +78,9 @@ class DraftMediaAdapter( RecyclerView.ViewHolder(imageView) { init { val thumbnailViewSize = - imageView.context.resources.getDimensionPixelSize(R.dimen.compose_media_preview_size) + imageView.context.resources.getDimensionPixelSize( + R.dimen.compose_media_preview_size + ) val layoutParams = ConstraintLayout.LayoutParams(thumbnailViewSize, thumbnailViewSize) val margin = itemView.context.resources .getDimensionPixelSize(R.dimen.compose_media_preview_margin) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsActivity.kt index 6d9a2aa16..1db7982c6 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsActivity.kt @@ -35,12 +35,12 @@ import com.keylesspalace.tusky.databinding.ActivityDraftsBinding import com.keylesspalace.tusky.db.DraftEntity import com.keylesspalace.tusky.db.DraftsAlert import com.keylesspalace.tusky.di.ViewModelFactory +import com.keylesspalace.tusky.util.isHttpNotFound import com.keylesspalace.tusky.util.parseAsMastodonHtml import com.keylesspalace.tusky.util.visible +import javax.inject.Inject import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch -import retrofit2.HttpException -import javax.inject.Inject class DraftsActivity : BaseActivity(), DraftActionListener { @@ -74,7 +74,9 @@ class DraftsActivity : BaseActivity(), DraftActionListener { binding.draftsRecyclerView.adapter = adapter binding.draftsRecyclerView.layoutManager = LinearLayoutManager(this) - binding.draftsRecyclerView.addItemDecoration(DividerItemDecoration(this, DividerItemDecoration.VERTICAL)) + binding.draftsRecyclerView.addItemDecoration( + DividerItemDecoration(this, DividerItemDecoration.VERTICAL) + ) bottomSheet = BottomSheetBehavior.from(binding.bottomSheet.root) @@ -131,13 +133,21 @@ class DraftsActivity : BaseActivity(), DraftActionListener { Log.w(TAG, "failed loading reply information", throwable) - if (throwable is HttpException && throwable.code() == 404) { + if (throwable.isHttpNotFound()) { // the original status to which a reply was drafted has been deleted // let's open the ComposeActivity without reply information - Toast.makeText(context, getString(R.string.drafts_post_reply_removed), Toast.LENGTH_LONG).show() + Toast.makeText( + context, + getString(R.string.drafts_post_reply_removed), + Toast.LENGTH_LONG + ).show() openDraftWithoutReply(draft) } else { - Snackbar.make(binding.root, getString(R.string.drafts_failed_loading_reply), Snackbar.LENGTH_SHORT) + Snackbar.make( + binding.root, + getString(R.string.drafts_failed_loading_reply), + Snackbar.LENGTH_SHORT + ) .show() } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsAdapter.kt index 1edf354d6..1c8ddbfed 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsAdapter.kt @@ -47,7 +47,10 @@ class DraftsAdapter( } ) { - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder { + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int + ): BindingHolder { val binding = ItemDraftBinding.inflate(LayoutInflater.from(parent.context), parent, false) val viewHolder = BindingHolder(binding) @@ -77,7 +80,9 @@ class DraftsAdapter( holder.binding.content.text = draft.content holder.binding.draftMediaPreview.visible(draft.attachments.isNotEmpty()) - (holder.binding.draftMediaPreview.adapter as DraftMediaAdapter).submitList(draft.attachments) + (holder.binding.draftMediaPreview.adapter as DraftMediaAdapter).submitList( + draft.attachments + ) if (draft.poll != null) { holder.binding.draftPoll.show() diff --git a/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsViewModel.kt index e748aebb9..813a424fa 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsViewModel.kt @@ -26,8 +26,8 @@ import com.keylesspalace.tusky.db.AppDatabase import com.keylesspalace.tusky.db.DraftEntity import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.network.MastodonApi -import kotlinx.coroutines.launch import javax.inject.Inject +import kotlinx.coroutines.launch class DraftsViewModel @Inject constructor( val database: AppDatabase, @@ -38,7 +38,11 @@ class DraftsViewModel @Inject constructor( val drafts = Pager( config = PagingConfig(pageSize = 20), - pagingSourceFactory = { database.draftDao().draftsPagingSource(accountManager.activeAccount?.id!!) } + pagingSourceFactory = { + database.draftDao().draftsPagingSource( + accountManager.activeAccount?.id!! + ) + } ).flow .cachedIn(viewModelScope) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/filters/EditFilterActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/filters/EditFilterActivity.kt index 2ef7902cc..709e2c5f7 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/filters/EditFilterActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/filters/EditFilterActivity.kt @@ -1,6 +1,7 @@ package com.keylesspalace.tusky.components.filters import android.content.Context +import android.content.DialogInterface.BUTTON_POSITIVE import android.os.Bundle import android.view.View import android.widget.AdapterView @@ -18,18 +19,19 @@ import com.google.android.material.switchmaterial.SwitchMaterial import com.keylesspalace.tusky.BaseActivity import com.keylesspalace.tusky.R import com.keylesspalace.tusky.appstore.EventHub +import com.keylesspalace.tusky.appstore.FilterUpdatedEvent import com.keylesspalace.tusky.databinding.ActivityEditFilterBinding import com.keylesspalace.tusky.databinding.DialogFilterBinding import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.entity.Filter import com.keylesspalace.tusky.entity.FilterKeyword import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.util.isHttpNotFound import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.util.visible -import kotlinx.coroutines.launch -import retrofit2.HttpException import java.util.Date import javax.inject.Inject +import kotlinx.coroutines.launch class EditFilterActivity : BaseActivity() { @Inject @@ -81,7 +83,11 @@ class EditFilterActivity : BaseActivity() { binding.actionChip.setOnClickListener { showAddKeywordDialog() } binding.filterSaveButton.setOnClickListener { saveChanges() } - binding.filterDeleteButton.setOnClickListener { deleteFilter() } + binding.filterDeleteButton.setOnClickListener { + lifecycleScope.launch { + if (showDeleteFilterDialog(filter.title) == BUTTON_POSITIVE) deleteFilter() + } + } binding.filterDeleteButton.visible(originalFilter != null) for (switch in contextSwitches.keys) { @@ -109,7 +115,12 @@ class EditFilterActivity : BaseActivity() { ) } binding.filterDurationSpinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { - override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) { + override fun onItemSelected( + parent: AdapterView<*>?, + view: View?, + position: Int, + id: Long + ) { viewModel.setDuration( if (originalFilter?.expiresAt == null) { position @@ -259,8 +270,17 @@ class EditFilterActivity : BaseActivity() { lifecycleScope.launch { if (viewModel.saveChanges(this@EditFilterActivity)) { finish() + // Possibly affected contexts: any context affected by the original filter OR any context affected by the updated filter + val affectedContexts = viewModel.contexts.value.map { + it.kind + }.union(originalFilter?.context ?: listOf()).distinct() + eventHub.dispatch(FilterUpdatedEvent(affectedContexts)) } else { - Snackbar.make(binding.root, "Error saving filter '${viewModel.title.value}'", Snackbar.LENGTH_SHORT).show() + Snackbar.make( + binding.root, + "Error saving filter '${viewModel.title.value}'", + Snackbar.LENGTH_SHORT + ).show() } } } @@ -273,17 +293,25 @@ class EditFilterActivity : BaseActivity() { finish() }, { throwable -> - if (throwable is HttpException && throwable.code() == 404) { + if (throwable.isHttpNotFound()) { api.deleteFilterV1(filter.id).fold( { finish() }, { - Snackbar.make(binding.root, "Error deleting filter '${filter.title}'", Snackbar.LENGTH_SHORT).show() + Snackbar.make( + binding.root, + "Error deleting filter '${filter.title}'", + Snackbar.LENGTH_SHORT + ).show() } ) } else { - Snackbar.make(binding.root, "Error deleting filter '${filter.title}'", Snackbar.LENGTH_SHORT).show() + Snackbar.make( + binding.root, + "Error deleting filter '${filter.title}'", + Snackbar.LENGTH_SHORT + ).show() } } ) @@ -298,7 +326,11 @@ class EditFilterActivity : BaseActivity() { // but create/edit take a number of seconds (relative to the time the operation is posted) fun getSecondsForDurationIndex(index: Int, context: Context?, default: Date? = null): Int? { return when (index) { - -1 -> if (default == null) { default } else { ((default.time - System.currentTimeMillis()) / 1000).toInt() } + -1 -> if (default == null) { + default + } else { + ((default.time - System.currentTimeMillis()) / 1000).toInt() + } 0 -> null else -> context?.resources?.getIntArray(R.array.filter_duration_values)?.get(index) } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/filters/EditFilterViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/filters/EditFilterViewModel.kt index d33031d65..83c8dacf1 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/filters/EditFilterViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/filters/EditFilterViewModel.kt @@ -8,82 +8,94 @@ import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.entity.Filter import com.keylesspalace.tusky.entity.FilterKeyword import com.keylesspalace.tusky.network.MastodonApi -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.withContext -import retrofit2.HttpException +import com.keylesspalace.tusky.util.isHttpNotFound import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.withContext class EditFilterViewModel @Inject constructor(val api: MastodonApi, val eventHub: EventHub) : ViewModel() { private var originalFilter: Filter? = null - val title = MutableStateFlow("") - val keywords = MutableStateFlow(listOf()) - val action = MutableStateFlow(Filter.Action.WARN) - val duration = MutableStateFlow(0) - val contexts = MutableStateFlow(listOf()) + + private val _title = MutableStateFlow("") + val title: StateFlow = _title.asStateFlow() + + private val _keywords = MutableStateFlow(listOf()) + val keywords: StateFlow> = _keywords.asStateFlow() + + private val _action = MutableStateFlow(Filter.Action.WARN) + val action: StateFlow = _action.asStateFlow() + + private val _duration = MutableStateFlow(0) + val duration: StateFlow = _duration.asStateFlow() + + private val _contexts = MutableStateFlow(listOf()) + val contexts: StateFlow> = _contexts.asStateFlow() fun load(filter: Filter) { originalFilter = filter - title.value = filter.title - keywords.value = filter.keywords - action.value = filter.action - duration.value = if (filter.expiresAt == null) { + _title.value = filter.title + _keywords.value = filter.keywords + _action.value = filter.action + _duration.value = if (filter.expiresAt == null) { 0 } else { -1 } - contexts.value = filter.kinds + _contexts.value = filter.kinds } fun addKeyword(keyword: FilterKeyword) { - keywords.value += keyword + _keywords.value += keyword } fun deleteKeyword(keyword: FilterKeyword) { - keywords.value = keywords.value.filterNot { it == keyword } + _keywords.value = _keywords.value.filterNot { it == keyword } } fun modifyKeyword(original: FilterKeyword, updated: FilterKeyword) { - val index = keywords.value.indexOf(original) + val index = _keywords.value.indexOf(original) if (index >= 0) { - keywords.value = keywords.value.toMutableList().apply { + _keywords.value = _keywords.value.toMutableList().apply { set(index, updated) } } } fun setTitle(title: String) { - this.title.value = title + this._title.value = title } fun setDuration(index: Int) { - duration.value = index + _duration.value = index } fun setAction(action: Filter.Action) { - this.action.value = action + this._action.value = action } fun addContext(context: Filter.Kind) { - if (!contexts.value.contains(context)) { - contexts.value += context + if (!_contexts.value.contains(context)) { + _contexts.value += context } } fun removeContext(context: Filter.Kind) { - contexts.value = contexts.value.filter { it != context } + _contexts.value = _contexts.value.filter { it != context } } fun validate(): Boolean { - return title.value.isNotBlank() && - keywords.value.isNotEmpty() && - contexts.value.isNotEmpty() + return _title.value.isNotBlank() && + _keywords.value.isNotEmpty() && + _contexts.value.isNotEmpty() } suspend fun saveChanges(context: Context): Boolean { - val contexts = contexts.value.map { it.kind } - val title = title.value - val durationIndex = duration.value - val action = action.value.action + val contexts = _contexts.value.map { it.kind } + val title = _title.value + val durationIndex = _duration.value + val action = _action.value.action return withContext(viewModelScope.coroutineContext) { originalFilter?.let { filter -> @@ -92,7 +104,13 @@ class EditFilterViewModel @Inject constructor(val api: MastodonApi, val eventHub } } - private suspend fun createFilter(title: String, contexts: List, action: String, durationIndex: Int, context: Context): Boolean { + private suspend fun createFilter( + title: String, + contexts: List, + action: String, + durationIndex: Int, + context: Context + ): Boolean { val expiresInSeconds = EditFilterActivity.getSecondsForDurationIndex(durationIndex, context) api.createFilter( title = title, @@ -102,13 +120,17 @@ class EditFilterViewModel @Inject constructor(val api: MastodonApi, val eventHub ).fold( { newFilter -> // This is _terrible_, but the all-in-one update filter api Just Doesn't Work - return keywords.value.map { keyword -> - api.addFilterKeyword(filterId = newFilter.id, keyword = keyword.keyword, wholeWord = keyword.wholeWord) + return _keywords.value.map { keyword -> + api.addFilterKeyword( + filterId = newFilter.id, + keyword = keyword.keyword, + wholeWord = keyword.wholeWord + ) }.none { it.isFailure } }, { throwable -> return ( - throwable is HttpException && throwable.code() == 404 && + throwable.isHttpNotFound() && // Endpoint not found, fall back to v1 api createFilterV1(contexts, expiresInSeconds) ) @@ -116,7 +138,14 @@ class EditFilterViewModel @Inject constructor(val api: MastodonApi, val eventHub ) } - private suspend fun updateFilter(originalFilter: Filter, title: String, contexts: List, action: String, durationIndex: Int, context: Context): Boolean { + private suspend fun updateFilter( + originalFilter: Filter, + title: String, + contexts: List, + action: String, + durationIndex: Int, + context: Context + ): Boolean { val expiresInSeconds = EditFilterActivity.getSecondsForDurationIndex(durationIndex, context) api.updateFilter( id = originalFilter.id, @@ -127,7 +156,7 @@ class EditFilterViewModel @Inject constructor(val api: MastodonApi, val eventHub ).fold( { // This is _terrible_, but the all-in-one update filter api Just Doesn't Work - val results = keywords.value.map { keyword -> + val results = _keywords.value.map { keyword -> if (keyword.id.isEmpty()) { api.addFilterKeyword(filterId = originalFilter.id, keyword = keyword.keyword, wholeWord = keyword.wholeWord) } else { @@ -135,13 +164,13 @@ class EditFilterViewModel @Inject constructor(val api: MastodonApi, val eventHub } } + originalFilter.keywords.filter { keyword -> // Deleted keywords - keywords.value.none { it.id == keyword.id } + _keywords.value.none { it.id == keyword.id } }.map { api.deleteFilterKeyword(it.id) } return results.none { it.isFailure } }, { throwable -> - if (throwable is HttpException && throwable.code() == 404) { + if (throwable.isHttpNotFound()) { // Endpoint not found, fall back to v1 api if (updateFilterV1(contexts, expiresInSeconds)) { return true @@ -153,13 +182,13 @@ class EditFilterViewModel @Inject constructor(val api: MastodonApi, val eventHub } private suspend fun createFilterV1(context: List, expiresInSeconds: Int?): Boolean { - return keywords.value.map { keyword -> + return _keywords.value.map { keyword -> api.createFilterV1(keyword.keyword, context, false, keyword.wholeWord, expiresInSeconds) }.none { it.isFailure } } private suspend fun updateFilterV1(context: List, expiresInSeconds: Int?): Boolean { - val results = keywords.value.map { keyword -> + val results = _keywords.value.map { keyword -> if (originalFilter == null) { api.createFilterV1( phrase = keyword.keyword, diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsLoadStateAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/filters/FilterExtensions.kt similarity index 50% rename from app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsLoadStateAdapter.kt rename to app/src/main/java/com/keylesspalace/tusky/components/filters/FilterExtensions.kt index 0a281ccd9..0f14bc5fd 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsLoadStateAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/filters/FilterExtensions.kt @@ -15,24 +15,17 @@ * see . */ -package com.keylesspalace.tusky.components.notifications +package com.keylesspalace.tusky.components.filters -import android.view.ViewGroup -import androidx.paging.LoadState -import androidx.paging.LoadStateAdapter +import android.app.Activity +import androidx.appcompat.app.AlertDialog +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.util.await -/** Show load state and retry options when loading notifications */ -class NotificationsLoadStateAdapter( - private val retry: () -> Unit -) : LoadStateAdapter() { - override fun onCreateViewHolder( - parent: ViewGroup, - loadState: LoadState - ): NotificationsLoadStateViewHolder { - return NotificationsLoadStateViewHolder.create(parent, retry) - } - - override fun onBindViewHolder(holder: NotificationsLoadStateViewHolder, loadState: LoadState) { - holder.bind(loadState) - } -} +internal suspend fun Activity.showDeleteFilterDialog(filterTitle: String) = AlertDialog.Builder( + this +) + .setMessage(getString(R.string.dialog_delete_filter_text, filterTitle)) + .setCancelable(true) + .create() + .await(R.string.dialog_delete_filter_positive_action, android.R.string.cancel) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/filters/FiltersActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/filters/FiltersActivity.kt index 54c822957..f6ff1c4b7 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/filters/FiltersActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/filters/FiltersActivity.kt @@ -1,5 +1,6 @@ package com.keylesspalace.tusky.components.filters +import android.content.DialogInterface.BUTTON_POSITIVE import android.content.Intent import android.os.Bundle import androidx.activity.viewModels @@ -11,10 +12,11 @@ import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.entity.Filter import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.show +import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.util.visible -import kotlinx.coroutines.launch import javax.inject.Inject +import kotlinx.coroutines.launch class FiltersActivity : BaseActivity(), FiltersListener { @Inject @@ -53,25 +55,36 @@ class FiltersActivity : BaseActivity(), FiltersListener { private fun observeViewModel() { lifecycleScope.launch { viewModel.state.collect { state -> - binding.progressBar.visible(state.loadingState == FiltersViewModel.LoadingState.LOADING) + binding.progressBar.visible( + state.loadingState == FiltersViewModel.LoadingState.LOADING + ) binding.swipeRefreshLayout.isRefreshing = state.loadingState == FiltersViewModel.LoadingState.LOADING - binding.addFilterButton.visible(state.loadingState == FiltersViewModel.LoadingState.LOADED) + binding.addFilterButton.visible( + state.loadingState == FiltersViewModel.LoadingState.LOADED + ) when (state.loadingState) { FiltersViewModel.LoadingState.INITIAL, FiltersViewModel.LoadingState.LOADING -> binding.messageView.hide() FiltersViewModel.LoadingState.ERROR_NETWORK -> { - binding.messageView.setup(R.drawable.elephant_offline, R.string.error_network) { + binding.messageView.setup( + R.drawable.errorphant_offline, + R.string.error_network + ) { loadFilters() } binding.messageView.show() } FiltersViewModel.LoadingState.ERROR_OTHER -> { - binding.messageView.setup(R.drawable.elephant_error, R.string.error_generic) { + binding.messageView.setup( + R.drawable.errorphant_error, + R.string.error_generic + ) { loadFilters() } binding.messageView.show() } FiltersViewModel.LoadingState.LOADED -> { + binding.filtersList.adapter = FiltersAdapter(this@FiltersActivity, state.filters) if (state.filters.isEmpty()) { binding.messageView.setup( R.drawable.elephant_friend_empty, @@ -81,7 +94,6 @@ class FiltersActivity : BaseActivity(), FiltersListener { binding.messageView.show() } else { binding.messageView.hide() - binding.filtersList.adapter = FiltersAdapter(this@FiltersActivity, state.filters) } } } @@ -99,12 +111,15 @@ class FiltersActivity : BaseActivity(), FiltersListener { putExtra(EditFilterActivity.FILTER_TO_EDIT, filter) } } - startActivity(intent) - overridePendingTransition(R.anim.slide_from_right, R.anim.slide_to_left) + startActivityWithSlideInAnimation(intent) } override fun deleteFilter(filter: Filter) { - viewModel.deleteFilter(filter, binding.root) + lifecycleScope.launch { + if (showDeleteFilterDialog(filter.title) == BUTTON_POSITIVE) { + viewModel.deleteFilter(filter, binding.root) + } + } } override fun updateFilter(updatedFilter: Filter) { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/filters/FiltersAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/filters/FiltersAdapter.kt index f6e6791a7..96d51fc6e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/filters/FiltersAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/filters/FiltersAdapter.kt @@ -14,8 +14,13 @@ class FiltersAdapter(val listener: FiltersListener, val filters: List) : override fun getItemCount(): Int = filters.size - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder { - return BindingHolder(ItemRemovableBinding.inflate(LayoutInflater.from(parent.context), parent, false)) + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int + ): BindingHolder { + return BindingHolder( + ItemRemovableBinding.inflate(LayoutInflater.from(parent.context), parent, false) + ) } override fun onBindViewHolder(holder: BindingHolder, position: Int) { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/filters/FiltersViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/filters/FiltersViewModel.kt index e28d251b8..0f1a5a2ac 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/filters/FiltersViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/filters/FiltersViewModel.kt @@ -9,11 +9,12 @@ import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.PreferenceChangedEvent import com.keylesspalace.tusky.entity.Filter import com.keylesspalace.tusky.network.MastodonApi -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.launch -import retrofit2.HttpException +import com.keylesspalace.tusky.util.isHttpNotFound import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch class FiltersViewModel @Inject constructor( private val api: MastodonApi, @@ -21,13 +22,17 @@ class FiltersViewModel @Inject constructor( ) : ViewModel() { enum class LoadingState { - INITIAL, LOADING, LOADED, ERROR_NETWORK, ERROR_OTHER + INITIAL, + LOADING, + LOADED, + ERROR_NETWORK, + ERROR_OTHER } data class State(val filters: List, val loadingState: LoadingState) - val state: Flow get() = _state private val _state = MutableStateFlow(State(emptyList(), LoadingState.INITIAL)) + val state: StateFlow = _state.asStateFlow() fun load() { this@FiltersViewModel._state.value = _state.value.copy(loadingState = LoadingState.LOADING) @@ -38,14 +43,13 @@ class FiltersViewModel @Inject constructor( this@FiltersViewModel._state.value = State(filters, LoadingState.LOADED) }, { throwable -> - if (throwable is HttpException && throwable.code() == 404) { + if (throwable.isHttpNotFound()) { api.getFiltersV1().fold( { filters -> this@FiltersViewModel._state.value = State(filters.map { it.toFilter() }, LoadingState.LOADED) }, - { throwable -> + { _ -> // TODO log errors (also below) - this@FiltersViewModel._state.value = _state.value.copy(loadingState = LoadingState.ERROR_OTHER) } ) @@ -62,23 +66,41 @@ class FiltersViewModel @Inject constructor( viewModelScope.launch { api.deleteFilter(filter.id).fold( { - this@FiltersViewModel._state.value = State(this@FiltersViewModel._state.value.filters.filter { it.id != filter.id }, LoadingState.LOADED) + this@FiltersViewModel._state.value = State( + this@FiltersViewModel._state.value.filters.filter { + it.id != filter.id + }, + LoadingState.LOADED + ) for (context in filter.context) { eventHub.dispatch(PreferenceChangedEvent(context)) } }, { throwable -> - if (throwable is HttpException && throwable.code() == 404) { + if (throwable.isHttpNotFound()) { api.deleteFilterV1(filter.id).fold( { - this@FiltersViewModel._state.value = State(this@FiltersViewModel._state.value.filters.filter { it.id != filter.id }, LoadingState.LOADED) + this@FiltersViewModel._state.value = State( + this@FiltersViewModel._state.value.filters.filter { + it.id != filter.id + }, + LoadingState.LOADED + ) }, { - Snackbar.make(parent, "Error deleting filter '${filter.title}'", Snackbar.LENGTH_SHORT).show() + Snackbar.make( + parent, + "Error deleting filter '${filter.title}'", + Snackbar.LENGTH_SHORT + ).show() } ) } else { - Snackbar.make(parent, "Error deleting filter '${filter.title}'", Snackbar.LENGTH_SHORT).show() + Snackbar.make( + parent, + "Error deleting filter '${filter.title}'", + Snackbar.LENGTH_SHORT + ).show() } } ) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsActivity.kt index b6b56d4a3..e250dc989 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsActivity.kt @@ -29,9 +29,9 @@ import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.util.visible +import javax.inject.Inject import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch -import javax.inject.Inject class FollowedTagsActivity : BaseActivity(), @@ -81,7 +81,9 @@ class FollowedTagsActivity : binding.followedTagsView.adapter = adapter binding.followedTagsView.setHasFixedSize(true) binding.followedTagsView.layoutManager = LinearLayoutManager(this) - binding.followedTagsView.addItemDecoration(DividerItemDecoration(this, DividerItemDecoration.VERTICAL)) + binding.followedTagsView.addItemDecoration( + DividerItemDecoration(this, DividerItemDecoration.VERTICAL) + ) (binding.followedTagsView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false val hideFab = sharedPreferences.getBoolean(PrefKeys.FAB_HIDE, false) @@ -101,7 +103,9 @@ class FollowedTagsActivity : private fun setupAdapter(): FollowedTagsAdapter { return FollowedTagsAdapter(this, viewModel).apply { addLoadStateListener { loadState -> - binding.followedTagsProgressBar.visible(loadState.refresh == LoadState.Loading && itemCount == 0) + binding.followedTagsProgressBar.visible( + loadState.refresh == LoadState.Loading && itemCount == 0 + ) if (loadState.refresh is LoadState.Error) { binding.followedTagsView.hide() @@ -145,7 +149,7 @@ class FollowedTagsActivity : lifecycleScope.launch { api.unfollowTag(tagName).fold( { - viewModel.tags.removeAt(position) + viewModel.tags.removeIf { tag -> tag.name == tagName } Snackbar.make( this@FollowedTagsActivity, binding.followedTagsView, diff --git a/app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsAdapter.kt index 4cdc9f97a..211be5630 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsAdapter.kt @@ -15,13 +15,22 @@ class FollowedTagsAdapter( private val actionListener: HashtagActionListener, private val viewModel: FollowedTagsViewModel ) : PagingDataAdapter>(STRING_COMPARATOR) { - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder = - BindingHolder(ItemFollowedHashtagBinding.inflate(LayoutInflater.from(parent.context), parent, false)) + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int + ): BindingHolder = BindingHolder( + ItemFollowedHashtagBinding.inflate(LayoutInflater.from(parent.context), parent, false) + ) - override fun onBindViewHolder(holder: BindingHolder, position: Int) { + override fun onBindViewHolder( + holder: BindingHolder, + position: Int + ) { viewModel.tags[position].let { tag -> holder.itemView.findViewById(R.id.followed_tag).text = tag.name - holder.itemView.findViewById(R.id.followed_tag_unfollow).setOnClickListener { + holder.itemView.findViewById( + R.id.followed_tag_unfollow + ).setOnClickListener { actionListener.unfollow(tag.name, holder.bindingAdapterPosition) } } @@ -31,8 +40,10 @@ class FollowedTagsAdapter( companion object { val STRING_COMPARATOR = object : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: String, newItem: String): Boolean = oldItem == newItem - override fun areContentsTheSame(oldItem: String, newItem: String): Boolean = oldItem == newItem + override fun areItemsTheSame(oldItem: String, newItem: String): Boolean = + oldItem == newItem + override fun areContentsTheSame(oldItem: String, newItem: String): Boolean = + oldItem == newItem } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsViewModel.kt index 1a1b794bb..f3376c13b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsViewModel.kt @@ -14,6 +14,7 @@ import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.entity.HashTag import com.keylesspalace.tusky.network.MastodonApi import javax.inject.Inject +import kotlinx.coroutines.runBlocking class FollowedTagsViewModel @Inject constructor( private val api: MastodonApi @@ -35,14 +36,22 @@ class FollowedTagsViewModel @Inject constructor( } ).flow.cachedIn(viewModelScope) - fun searchAutocompleteSuggestions(token: String): List { - return api.searchSync(query = token, type = SearchType.Hashtag.apiParameter, limit = 10) - .fold({ searchResult -> - searchResult.hashtags.map { ComposeAutoCompleteAdapter.AutocompleteResult.HashtagResult(it.name) } - }, { e -> - Log.e(TAG, "Autocomplete search for $token failed.", e) - emptyList() - }) + fun searchAutocompleteSuggestions( + token: String + ): List { + return runBlocking { + api.search(query = token, type = SearchType.Hashtag.apiParameter, limit = 10) + .fold({ searchResult -> + searchResult.hashtags.map { + ComposeAutoCompleteAdapter.AutocompleteResult.HashtagResult( + it.name + ) + } + }, { e -> + Log.e(TAG, "Autocomplete search for $token failed.", e) + emptyList() + }) + } } companion object { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/instanceinfo/InstanceInfo.kt b/app/src/main/java/com/keylesspalace/tusky/components/instanceinfo/InstanceInfo.kt index bb97621c1..33a1122f3 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/instanceinfo/InstanceInfo.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/instanceinfo/InstanceInfo.kt @@ -28,5 +28,7 @@ data class InstanceInfo( val maxMediaAttachments: Int, val maxFields: Int, val maxFieldNameLength: Int?, - val maxFieldValueLength: Int? + val maxFieldValueLength: Int?, + val version: String?, + val translationEnabled: Boolean?, ) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/instanceinfo/InstanceInfoRepository.kt b/app/src/main/java/com/keylesspalace/tusky/components/instanceinfo/InstanceInfoRepository.kt index bfc5a9b8c..cd73d3f21 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/instanceinfo/InstanceInfoRepository.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/instanceinfo/InstanceInfoRepository.kt @@ -16,27 +16,64 @@ package com.keylesspalace.tusky.components.instanceinfo import android.util.Log -import at.connyduck.calladapter.networkresult.fold +import at.connyduck.calladapter.networkresult.NetworkResult import at.connyduck.calladapter.networkresult.getOrElse +import at.connyduck.calladapter.networkresult.getOrThrow +import at.connyduck.calladapter.networkresult.map import at.connyduck.calladapter.networkresult.onSuccess +import at.connyduck.calladapter.networkresult.recoverCatching import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AppDatabase import com.keylesspalace.tusky.db.EmojisEntity import com.keylesspalace.tusky.db.InstanceInfoEntity +import com.keylesspalace.tusky.di.ApplicationScope import com.keylesspalace.tusky.entity.Emoji +import com.keylesspalace.tusky.entity.Instance +import com.keylesspalace.tusky.entity.InstanceV1 import com.keylesspalace.tusky.network.MastodonApi -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext +import com.keylesspalace.tusky.util.isHttpNotFound +import java.util.concurrent.ConcurrentHashMap import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +@Singleton class InstanceInfoRepository @Inject constructor( private val api: MastodonApi, db: AppDatabase, - accountManager: AccountManager + private val accountManager: AccountManager, + @ApplicationScope + private val externalScope: CoroutineScope ) { - private val dao = db.instanceDao() - private val instanceName = accountManager.activeAccount!!.domain + private val instanceName + get() = accountManager.activeAccount!!.domain + + /** In-memory cache for instance data, per instance domain. */ + private var instanceInfoCache = ConcurrentHashMap() + + fun precache() { + // We are avoiding some duplicate work but we are not trying too hard. + // We might request it multiple times in parallel which is not a big problem. + // We might also get the results in random order or write them twice but it's also + // not a problem. + // We are just trying to avoid 2 things: + // - fetching it when we already have it + // - caching default value (we want to rather re-fetch if it fails) + if (instanceInfoCache[instanceName] == null) { + externalScope.launch { + fetchAndPersistInstanceInfo().onSuccess { fetched -> + instanceInfoCache[fetched.instance] = fetched.toInfoOrDefault() + } + } + } + } + + val cachedInstanceInfoOrFallback: InstanceInfo + get() = instanceInfoCache[instanceName] ?: null.toInfoOrDefault() /** * Returns the custom emojis of the instance. @@ -57,53 +94,114 @@ class InstanceInfoRepository @Inject constructor( * Will always try to fetch the most up-to-date data from the api, falls back to cache in case it is not available. * Never throws, returns defaults of vanilla Mastodon in case of error. */ - suspend fun getInstanceInfo(): InstanceInfo = withContext(Dispatchers.IO) { - api.getInstance() - .fold( - { instance -> - val instanceEntity = InstanceInfoEntity( - instance = instanceName, - maximumTootCharacters = instance.configuration?.statuses?.maxCharacters ?: instance.maxTootChars, - maxPollOptions = instance.configuration?.polls?.maxOptions ?: instance.pollConfiguration?.maxOptions, - maxPollOptionLength = instance.configuration?.polls?.maxCharactersPerOption ?: instance.pollConfiguration?.maxOptionChars, - minPollDuration = instance.configuration?.polls?.minExpiration ?: instance.pollConfiguration?.minExpiration, - maxPollDuration = instance.configuration?.polls?.maxExpiration ?: instance.pollConfiguration?.maxExpiration, - charactersReservedPerUrl = instance.configuration?.statuses?.charactersReservedPerUrl, - version = instance.version, - videoSizeLimit = instance.configuration?.mediaAttachments?.videoSizeLimit ?: instance.uploadLimit, - imageSizeLimit = instance.configuration?.mediaAttachments?.imageSizeLimit ?: instance.uploadLimit, - imageMatrixLimit = instance.configuration?.mediaAttachments?.imageMatrixLimit, - maxMediaAttachments = instance.configuration?.statuses?.maxMediaAttachments ?: instance.maxMediaAttachments, - maxFields = instance.pleroma?.metadata?.fieldLimits?.maxFields, - maxFieldNameLength = instance.pleroma?.metadata?.fieldLimits?.nameLength, - maxFieldValueLength = instance.pleroma?.metadata?.fieldLimits?.valueLength + suspend fun getUpdatedInstanceInfoOrFallback(): InstanceInfo = + withContext(Dispatchers.IO) { + fetchAndPersistInstanceInfo() + .getOrElse { throwable -> + Log.w( + TAG, + "failed to load instance, falling back to cache and default values", + throwable ) - dao.upsert(instanceEntity) - instanceEntity - }, - { throwable -> - Log.w(TAG, "failed to instance, falling back to cache and default values", throwable) dao.getInstanceInfo(instanceName) } - ).let { instanceInfo: InstanceInfoEntity? -> - InstanceInfo( - maxChars = instanceInfo?.maximumTootCharacters ?: DEFAULT_CHARACTER_LIMIT, - pollMaxOptions = instanceInfo?.maxPollOptions ?: DEFAULT_MAX_OPTION_COUNT, - pollMaxLength = instanceInfo?.maxPollOptionLength ?: DEFAULT_MAX_OPTION_LENGTH, - pollMinDuration = instanceInfo?.minPollDuration ?: DEFAULT_MIN_POLL_DURATION, - pollMaxDuration = instanceInfo?.maxPollDuration ?: DEFAULT_MAX_POLL_DURATION, - charactersReservedPerUrl = instanceInfo?.charactersReservedPerUrl ?: DEFAULT_CHARACTERS_RESERVED_PER_URL, - videoSizeLimit = instanceInfo?.videoSizeLimit ?: DEFAULT_VIDEO_SIZE_LIMIT, - imageSizeLimit = instanceInfo?.imageSizeLimit ?: DEFAULT_IMAGE_SIZE_LIMIT, - imageMatrixLimit = instanceInfo?.imageMatrixLimit ?: DEFAULT_IMAGE_MATRIX_LIMIT, - maxMediaAttachments = instanceInfo?.maxMediaAttachments ?: DEFAULT_MAX_MEDIA_ATTACHMENTS, - maxFields = instanceInfo?.maxFields ?: DEFAULT_MAX_ACCOUNT_FIELDS, - maxFieldNameLength = instanceInfo?.maxFieldNameLength, - maxFieldValueLength = instanceInfo?.maxFieldValueLength - ) + }.toInfoOrDefault() + + private suspend fun InstanceInfoRepository.fetchAndPersistInstanceInfo(): NetworkResult = + fetchRemoteInstanceInfo() + .onSuccess { instanceInfoEntity -> + dao.upsert(instanceInfoEntity) + } + + private suspend fun fetchRemoteInstanceInfo(): NetworkResult { + val instance = this.instanceName + return api.getInstance() + .map { it.toEntity() } + .recoverCatching { t -> + if (t.isHttpNotFound()) { + api.getInstanceV1().map { it.toEntity(instance) }.getOrThrow() + } else { + throw t + } } } + private fun InstanceInfoEntity?.toInfoOrDefault() = InstanceInfo( + maxChars = this?.maximumTootCharacters ?: DEFAULT_CHARACTER_LIMIT, + pollMaxOptions = this?.maxPollOptions ?: DEFAULT_MAX_OPTION_COUNT, + pollMaxLength = this?.maxPollOptionLength ?: DEFAULT_MAX_OPTION_LENGTH, + pollMinDuration = this?.minPollDuration ?: DEFAULT_MIN_POLL_DURATION, + pollMaxDuration = this?.maxPollDuration ?: DEFAULT_MAX_POLL_DURATION, + charactersReservedPerUrl = this?.charactersReservedPerUrl + ?: DEFAULT_CHARACTERS_RESERVED_PER_URL, + videoSizeLimit = this?.videoSizeLimit ?: DEFAULT_VIDEO_SIZE_LIMIT, + imageSizeLimit = this?.imageSizeLimit ?: DEFAULT_IMAGE_SIZE_LIMIT, + imageMatrixLimit = this?.imageMatrixLimit ?: DEFAULT_IMAGE_MATRIX_LIMIT, + maxMediaAttachments = this?.maxMediaAttachments + ?: DEFAULT_MAX_MEDIA_ATTACHMENTS, + maxFields = this?.maxFields ?: DEFAULT_MAX_ACCOUNT_FIELDS, + maxFieldNameLength = this?.maxFieldNameLength, + maxFieldValueLength = this?.maxFieldValueLength, + version = this?.version, + translationEnabled = this?.translationEnabled + ) + + private fun Instance.toEntity() = InstanceInfoEntity( + instance = domain, + maximumTootCharacters = this.configuration?.statuses?.maxCharacters + ?: DEFAULT_CHARACTER_LIMIT, + maxPollOptions = this.configuration?.polls?.maxOptions ?: DEFAULT_MAX_OPTION_COUNT, + maxPollOptionLength = this.configuration?.polls?.maxCharactersPerOption + ?: DEFAULT_MAX_OPTION_LENGTH, + minPollDuration = this.configuration?.polls?.minExpirationSeconds + ?: DEFAULT_MIN_POLL_DURATION, + maxPollDuration = this.configuration?.polls?.maxExpirationSeconds + ?: DEFAULT_MAX_POLL_DURATION, + charactersReservedPerUrl = this.configuration?.statuses?.charactersReservedPerUrl + ?: DEFAULT_CHARACTERS_RESERVED_PER_URL, + version = this.version, + videoSizeLimit = this.configuration?.mediaAttachments?.videoSizeLimitBytes?.toInt() + ?: DEFAULT_VIDEO_SIZE_LIMIT, + imageSizeLimit = this.configuration?.mediaAttachments?.imageSizeLimitBytes?.toInt() + ?: DEFAULT_IMAGE_SIZE_LIMIT, + imageMatrixLimit = this.configuration?.mediaAttachments?.imagePixelCountLimit?.toInt() + ?: DEFAULT_IMAGE_MATRIX_LIMIT, + maxMediaAttachments = this.configuration?.statuses?.maxMediaAttachments + ?: DEFAULT_MAX_MEDIA_ATTACHMENTS, + maxFields = this.pleroma?.metadata?.fieldLimits?.maxFields, + maxFieldNameLength = this.pleroma?.metadata?.fieldLimits?.nameLength, + maxFieldValueLength = this.pleroma?.metadata?.fieldLimits?.valueLength, + translationEnabled = this.configuration?.translation?.enabled + ) + + private fun InstanceV1.toEntity(instanceName: String) = + InstanceInfoEntity( + instance = instanceName, + maximumTootCharacters = this.configuration?.statuses?.maxCharacters + ?: this.maxTootChars, + maxPollOptions = this.configuration?.polls?.maxOptions + ?: this.pollConfiguration?.maxOptions, + maxPollOptionLength = this.configuration?.polls?.maxCharactersPerOption + ?: this.pollConfiguration?.maxOptionChars, + minPollDuration = this.configuration?.polls?.minExpiration + ?: this.pollConfiguration?.minExpiration, + maxPollDuration = this.configuration?.polls?.maxExpiration + ?: this.pollConfiguration?.maxExpiration, + charactersReservedPerUrl = this.configuration?.statuses?.charactersReservedPerUrl, + version = this.version, + videoSizeLimit = this.configuration?.mediaAttachments?.videoSizeLimit + ?: this.uploadLimit, + imageSizeLimit = this.configuration?.mediaAttachments?.imageSizeLimit + ?: this.uploadLimit, + imageMatrixLimit = this.configuration?.mediaAttachments?.imageMatrixLimit, + maxMediaAttachments = this.configuration?.statuses?.maxMediaAttachments + ?: this.maxMediaAttachments, + maxFields = this.pleroma?.metadata?.fieldLimits?.maxFields, + maxFieldNameLength = this.pleroma?.metadata?.fieldLimits?.nameLength, + maxFieldValueLength = this.pleroma?.metadata?.fieldLimits?.valueLength, + translationEnabled = null, + ) + companion object { private const val TAG = "InstanceInfoRepo" diff --git a/app/src/main/java/com/keylesspalace/tusky/components/instancemute/adapter/DomainMutesAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/instancemute/adapter/DomainMutesAdapter.kt deleted file mode 100644 index 13d8f2d83..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/components/instancemute/adapter/DomainMutesAdapter.kt +++ /dev/null @@ -1,56 +0,0 @@ -package com.keylesspalace.tusky.components.instancemute.adapter - -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.recyclerview.widget.RecyclerView -import com.keylesspalace.tusky.components.instancemute.interfaces.InstanceActionListener -import com.keylesspalace.tusky.databinding.ItemMutedDomainBinding -import com.keylesspalace.tusky.util.BindingHolder - -class DomainMutesAdapter( - private val actionListener: InstanceActionListener -) : RecyclerView.Adapter>() { - - var instances: MutableList = mutableListOf() - var bottomLoading: Boolean = false - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder { - val binding = ItemMutedDomainBinding.inflate(LayoutInflater.from(parent.context), parent, false) - return BindingHolder(binding) - } - - override fun onBindViewHolder(holder: BindingHolder, position: Int) { - val instance = instances[position] - - holder.binding.mutedDomain.text = instance - holder.binding.mutedDomainUnmute.setOnClickListener { - actionListener.mute(false, instance, holder.bindingAdapterPosition) - } - } - - override fun getItemCount(): Int { - var count = instances.size - if (bottomLoading) { - ++count - } - return count - } - - fun addItems(newInstances: List) { - val end = instances.size - instances.addAll(newInstances) - notifyItemRangeInserted(end, instances.size) - } - - fun addItem(instance: String) { - instances.add(instance) - notifyItemInserted(instances.size) - } - - fun removeItem(position: Int) { - if (position >= 0 && position < instances.size) { - instances.removeAt(position) - notifyItemRemoved(position) - } - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/instancemute/fragment/InstanceListFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/instancemute/fragment/InstanceListFragment.kt deleted file mode 100644 index 1da0a2b7d..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/components/instancemute/fragment/InstanceListFragment.kt +++ /dev/null @@ -1,158 +0,0 @@ -package com.keylesspalace.tusky.components.instancemute.fragment - -import android.os.Bundle -import android.util.Log -import android.view.View -import androidx.fragment.app.Fragment -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.lifecycleScope -import androidx.recyclerview.widget.DividerItemDecoration -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView -import at.connyduck.calladapter.networkresult.fold -import autodispose2.androidx.lifecycle.AndroidLifecycleScopeProvider.from -import autodispose2.autoDispose -import com.google.android.material.snackbar.Snackbar -import com.keylesspalace.tusky.R -import com.keylesspalace.tusky.components.instancemute.adapter.DomainMutesAdapter -import com.keylesspalace.tusky.components.instancemute.interfaces.InstanceActionListener -import com.keylesspalace.tusky.databinding.FragmentInstanceListBinding -import com.keylesspalace.tusky.di.Injectable -import com.keylesspalace.tusky.network.MastodonApi -import com.keylesspalace.tusky.util.HttpHeaderLink -import com.keylesspalace.tusky.util.hide -import com.keylesspalace.tusky.util.show -import com.keylesspalace.tusky.util.viewBinding -import com.keylesspalace.tusky.view.EndlessOnScrollListener -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers -import kotlinx.coroutines.launch -import javax.inject.Inject - -class InstanceListFragment : Fragment(R.layout.fragment_instance_list), Injectable, InstanceActionListener { - - @Inject - lateinit var api: MastodonApi - - private val binding by viewBinding(FragmentInstanceListBinding::bind) - - private var fetching = false - private var bottomId: String? = null - private var adapter = DomainMutesAdapter(this) - private lateinit var scrollListener: EndlessOnScrollListener - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - binding.recyclerView.setHasFixedSize(true) - binding.recyclerView.addItemDecoration(DividerItemDecoration(view.context, DividerItemDecoration.VERTICAL)) - binding.recyclerView.adapter = adapter - - val layoutManager = LinearLayoutManager(view.context) - binding.recyclerView.layoutManager = layoutManager - - scrollListener = object : EndlessOnScrollListener(layoutManager) { - override fun onLoadMore(totalItemsCount: Int, view: RecyclerView) { - if (bottomId != null) { - fetchInstances(bottomId) - } - } - } - - binding.recyclerView.addOnScrollListener(scrollListener) - fetchInstances() - } - - override fun mute(mute: Boolean, instance: String, position: Int) { - viewLifecycleOwner.lifecycleScope.launch { - if (mute) { - api.blockDomain(instance).fold({ - adapter.addItem(instance) - }, { e -> - Log.e(TAG, "Error muting domain $instance", e) - }) - } else { - api.unblockDomain(instance).fold({ - adapter.removeItem(position) - Snackbar.make(binding.recyclerView, getString(R.string.confirmation_domain_unmuted, instance), Snackbar.LENGTH_LONG) - .setAction(R.string.action_undo) { - mute(true, instance, position) - } - .show() - }, { e -> - Log.e(TAG, "Error unmuting domain $instance", e) - }) - } - } - } - - private fun fetchInstances(id: String? = null) { - if (fetching) { - return - } - fetching = true - binding.instanceProgressBar.show() - - if (id != null) { - binding.recyclerView.post { adapter.bottomLoading = true } - } - - api.domainBlocks(id, bottomId) - .observeOn(AndroidSchedulers.mainThread()) - .autoDispose(from(this, Lifecycle.Event.ON_DESTROY)) - .subscribe( - { response -> - val instances = response.body() - - if (response.isSuccessful && instances != null) { - onFetchInstancesSuccess(instances, response.headers()["Link"]) - } else { - onFetchInstancesFailure(Exception(response.message())) - } - }, - { throwable -> - onFetchInstancesFailure(throwable) - } - ) - } - - private fun onFetchInstancesSuccess(instances: List, linkHeader: String?) { - adapter.bottomLoading = false - binding.instanceProgressBar.hide() - - val links = HttpHeaderLink.parse(linkHeader) - val next = HttpHeaderLink.findByRelationType(links, "next") - val fromId = next?.uri?.getQueryParameter("max_id") - adapter.addItems(instances) - bottomId = fromId - fetching = false - - if (adapter.itemCount == 0) { - binding.messageView.show() - binding.messageView.setup( - R.drawable.elephant_friend_empty, - R.string.message_empty, - null - ) - } else { - binding.messageView.hide() - } - } - - private fun onFetchInstancesFailure(throwable: Throwable) { - fetching = false - binding.instanceProgressBar.hide() - Log.e(TAG, "Fetch failure", throwable) - - if (adapter.itemCount == 0) { - binding.messageView.show() - binding.messageView.setup(throwable) { - binding.messageView.hide() - this.fetchInstances(null) - } - } - } - - companion object { - private const val TAG = "InstanceList" // logging tag - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/instancemute/interfaces/InstanceActionListener.kt b/app/src/main/java/com/keylesspalace/tusky/components/instancemute/interfaces/InstanceActionListener.kt deleted file mode 100644 index 9b88ad966..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/components/instancemute/interfaces/InstanceActionListener.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.keylesspalace.tusky.components.instancemute.interfaces - -interface InstanceActionListener { - fun mute(mute: Boolean, instance: String, position: Int) -} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/login/LoginActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/login/LoginActivity.kt index 6cda99d00..71d19c17c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/login/LoginActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/login/LoginActivity.kt @@ -42,10 +42,11 @@ import com.keylesspalace.tusky.util.getNonNullString import com.keylesspalace.tusky.util.openLinkInCustomTab import com.keylesspalace.tusky.util.rickRoll import com.keylesspalace.tusky.util.shouldRickRoll +import com.keylesspalace.tusky.util.supportsOverridingActivityTransitions import com.keylesspalace.tusky.util.viewBinding +import javax.inject.Inject import kotlinx.coroutines.launch import okhttp3.HttpUrl -import javax.inject.Inject /** Main login page, the first thing that users see. Has prompt for instance and login button. */ class LoginActivity : BaseActivity(), Injectable { @@ -125,13 +126,6 @@ class LoginActivity : BaseActivity(), Injectable { return false } - override fun finish() { - super.finish() - if (isAdditionalLogin() || isAccountMigration()) { - overridePendingTransition(R.anim.slide_from_left, R.anim.slide_to_right) - } - } - override fun onCreateOptionsMenu(menu: Menu?): Boolean { menu?.add(R.string.action_browser_login)?.apply { setOnMenuItemClickListener { @@ -215,7 +209,11 @@ class LoginActivity : BaseActivity(), Injectable { } } - private fun redirectUserToAuthorizeAndLogin(domain: String, clientId: String, openInWebView: Boolean) { + private fun redirectUserToAuthorizeAndLogin( + domain: String, + clientId: String, + openInWebView: Boolean + ) { // To authorize this app and log in it's necessary to redirect to the domain given, // login there, and the server will redirect back to the app with its response. val uri = HttpUrl.Builder() @@ -330,10 +328,13 @@ class LoginActivity : BaseActivity(), Injectable { ) val intent = Intent(this, MainActivity::class.java) - intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + intent.putExtra(MainActivity.OPEN_WITH_EXPLODE_ANIMATION, true) startActivity(intent) - finish() - overridePendingTransition(R.anim.explode, R.anim.explode) + finishAffinity() + if (!supportsOverridingActivityTransitions()) { + @Suppress("DEPRECATION") + overridePendingTransition(R.anim.explode, R.anim.activity_open_exit) + } }, { e -> setLoading(false) binding.domainTextInputLayout.error = diff --git a/app/src/main/java/com/keylesspalace/tusky/components/login/LoginWebViewActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/login/LoginWebViewActivity.kt index be7e323ad..421f81032 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/login/LoginWebViewActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/login/LoginWebViewActivity.kt @@ -45,9 +45,9 @@ import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.util.visible +import javax.inject.Inject import kotlinx.coroutines.launch import kotlinx.parcelize.Parcelize -import javax.inject.Inject /** Contract for starting [LoginWebViewActivity]. */ class OauthLogin : ActivityResultContract() { @@ -91,15 +91,15 @@ data class LoginData( val oauthRedirectUrl: Uri ) : Parcelable -sealed class LoginResult : Parcelable { +sealed interface LoginResult : Parcelable { @Parcelize - data class Ok(val code: String) : LoginResult() + data class Ok(val code: String) : LoginResult @Parcelize - data class Err(val errorMessage: String) : LoginResult() + data class Err(val errorMessage: String) : LoginResult @Parcelize - object Cancel : LoginResult() + data object Cancel : LoginResult } /** Activity to do Oauth process using WebView. */ @@ -227,14 +227,10 @@ class LoginWebViewActivity : BaseActivity(), Injectable { super.onDestroy() } - override fun finish() { - super.finishWithoutSlideOutAnimation() - } - override fun requiresLogin() = false private fun sendResult(result: LoginResult) { setResult(Activity.RESULT_OK, OauthLogin.makeResultIntent(result)) - finishWithoutSlideOutAnimation() + finish() } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/login/LoginWebViewViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/login/LoginWebViewViewModel.kt index 39dd311aa..2e9198d16 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/login/LoginWebViewViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/login/LoginWebViewViewModel.kt @@ -20,15 +20,18 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import at.connyduck.calladapter.networkresult.fold import com.keylesspalace.tusky.network.MastodonApi -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.launch +import com.keylesspalace.tusky.util.isHttpNotFound import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch class LoginWebViewViewModel @Inject constructor( private val api: MastodonApi ) : ViewModel() { - val instanceRules: MutableStateFlow> = MutableStateFlow(emptyList()) + private val _instanceRules = MutableStateFlow(emptyList()) + val instanceRules = _instanceRules.asStateFlow() private var domain: String? = null @@ -36,11 +39,33 @@ class LoginWebViewViewModel @Inject constructor( if (this.domain == null) { this.domain = domain viewModelScope.launch { - api.getInstance(domain).fold({ instance -> - instanceRules.value = instance.rules?.map { rule -> rule.text }.orEmpty() - }, { throwable -> - Log.w("LoginWebViewViewModel", "failed to load instance info", throwable) - }) + api.getInstance(domain).fold( + { instance -> + _instanceRules.value = instance.rules.map { rule -> rule.text } + }, + { throwable -> + if (throwable.isHttpNotFound()) { + api.getInstanceV1(domain).fold( + { instance -> + _instanceRules.value = instance.rules.map { rule -> rule.text } + }, + { throwable -> + Log.w( + "LoginWebViewViewModel", + "failed to load instance info", + throwable + ) + } + ) + } else { + Log.w( + "LoginWebViewViewModel", + "failed to load instance info", + throwable + ) + } + } + ) } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/FollowViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/FollowViewHolder.kt deleted file mode 100644 index 70f564c0c..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/FollowViewHolder.kt +++ /dev/null @@ -1,111 +0,0 @@ -/* - * Copyright 2023 Tusky Contributors - * - * This file is a part of Tusky. - * - * 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. - * - * Tusky 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 Tusky; if not, - * see . - */ - -package com.keylesspalace.tusky.components.notifications - -import androidx.recyclerview.widget.RecyclerView -import com.keylesspalace.tusky.R -import com.keylesspalace.tusky.databinding.ItemFollowBinding -import com.keylesspalace.tusky.entity.Notification -import com.keylesspalace.tusky.entity.TimelineAccount -import com.keylesspalace.tusky.interfaces.LinkListener -import com.keylesspalace.tusky.util.StatusDisplayOptions -import com.keylesspalace.tusky.util.emojify -import com.keylesspalace.tusky.util.loadAvatar -import com.keylesspalace.tusky.util.parseAsMastodonHtml -import com.keylesspalace.tusky.util.setClickableText -import com.keylesspalace.tusky.util.unicodeWrap -import com.keylesspalace.tusky.viewdata.NotificationViewData - -class FollowViewHolder( - private val binding: ItemFollowBinding, - private val notificationActionListener: NotificationActionListener, - private val linkListener: LinkListener -) : NotificationsPagingAdapter.ViewHolder, RecyclerView.ViewHolder(binding.root) { - private val avatarRadius42dp = itemView.context.resources.getDimensionPixelSize( - R.dimen.avatar_radius_42dp - ) - - override fun bind( - viewData: NotificationViewData, - payloads: List<*>?, - statusDisplayOptions: StatusDisplayOptions - ) { - // Skip updates with payloads. That indicates a timestamp update, and - // this view does not have timestamps. - if (!payloads.isNullOrEmpty()) return - - setMessage( - viewData.account, - viewData.type === Notification.Type.SIGN_UP, - statusDisplayOptions.animateAvatars, - statusDisplayOptions.animateEmojis - ) - setupButtons(notificationActionListener, viewData.account.id) - } - - private fun setMessage( - account: TimelineAccount, - isSignUp: Boolean, - animateAvatars: Boolean, - animateEmojis: Boolean - ) { - val context = binding.notificationText.context - val format = - context.getString( - if (isSignUp) { - R.string.notification_sign_up_format - } else { - R.string.notification_follow_format - } - ) - val wrappedDisplayName = account.name.unicodeWrap() - val wholeMessage = String.format(format, wrappedDisplayName) - val emojifiedMessage = - wholeMessage.emojify( - account.emojis, - binding.notificationText, - animateEmojis - ) - binding.notificationText.text = emojifiedMessage - val username = context.getString(R.string.post_username_format, account.username) - binding.notificationUsername.text = username - val emojifiedDisplayName = wrappedDisplayName.emojify( - account.emojis, - binding.notificationUsername, - animateEmojis - ) - binding.notificationDisplayName.text = emojifiedDisplayName - loadAvatar( - account.avatar, - binding.notificationAvatar, - avatarRadius42dp, - animateAvatars - ) - - val emojifiedNote = account.note.parseAsMastodonHtml().emojify( - account.emojis, - binding.notificationAccountNote, - animateEmojis - ) - setClickableText(binding.notificationAccountNote, emojifiedNote, emptyList(), null, linkListener) - } - - private fun setupButtons(listener: NotificationActionListener, accountId: String) { - binding.root.setOnClickListener { listener.onViewAccount(accountId) } - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationFetcher.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationFetcher.kt index 633ca08f7..91e97a41d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationFetcher.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationFetcher.kt @@ -4,44 +4,73 @@ import android.app.NotificationManager import android.content.Context import android.util.Log import androidx.annotation.WorkerThread +import com.keylesspalace.tusky.appstore.EventHub +import com.keylesspalace.tusky.appstore.NewNotificationsEvent import com.keylesspalace.tusky.components.notifications.NotificationHelper.filterNotification import com.keylesspalace.tusky.db.AccountEntity import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.entity.Marker import com.keylesspalace.tusky.entity.Notification import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.util.HttpHeaderLink import com.keylesspalace.tusky.util.isLessThan -import kotlinx.coroutines.delay import javax.inject.Inject import kotlin.math.min import kotlin.time.Duration.Companion.milliseconds +import kotlinx.coroutines.delay + +/** Models next/prev links from the "Links" header in an API response */ +data class Links(val next: String?, val prev: String?) { + companion object { + fun from(linkHeader: String?): Links { + val links = HttpHeaderLink.parse(linkHeader) + return Links( + next = HttpHeaderLink.findByRelationType(links, "next")?.uri?.getQueryParameter( + "max_id" + ), + prev = HttpHeaderLink.findByRelationType(links, "prev")?.uri?.getQueryParameter( + "min_id" + ) + ) + } + } +} /** * Fetch Mastodon notifications and show Android notifications, with summaries, for them. * * Should only be called by a worker thread. * - * @see NotificationWorker + * @see com.keylesspalace.tusky.worker.NotificationWorker * @see Background worker */ @WorkerThread class NotificationFetcher @Inject constructor( private val mastodonApi: MastodonApi, private val accountManager: AccountManager, - private val context: Context + private val context: Context, + private val eventHub: EventHub ) { suspend fun fetchAndShow() { for (account in accountManager.getAllAccountsOrderedByActive()) { if (account.notificationsEnabled) { try { - val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + val notificationManager = context.getSystemService( + Context.NOTIFICATION_SERVICE + ) as NotificationManager // Create sorted list of new notifications val notifications = fetchNewNotifications(account) .filter { filterNotification(notificationManager, account, it) } - .sortedWith(compareBy({ it.id.length }, { it.id })) // oldest notifications first + .sortedWith( + compareBy({ it.id.length }, { it.id }) + ) // oldest notifications first .toMutableList() + // TODO do this before filter above? But one could argue that (for example) a tab badge is also a notification + // (and should therefore adhere to the notification config). + eventHub.dispatch(NewNotificationsEvent(account.accountId, notifications)) + // There's a maximum limit on the number of notifications an Android app // can display. If the total number of notifications (current notifications, // plus new ones) exceeds this then some newer notifications will be dropped. @@ -49,13 +78,18 @@ class NotificationFetcher @Inject constructor( // Err on the side of removing *older* notifications to make room for newer // notifications. val currentAndroidNotifications = notificationManager.activeNotifications - .sortedWith(compareBy({ it.tag.length }, { it.tag })) // oldest notifications first + .sortedWith( + compareBy({ it.tag.length }, { it.tag }) + ) // oldest notifications first // Check to see if any notifications need to be removed val toRemove = currentAndroidNotifications.size + notifications.size - MAX_NOTIFICATIONS if (toRemove > 0) { // Prefer to cancel old notifications first - currentAndroidNotifications.subList(0, min(toRemove, currentAndroidNotifications.size)) + currentAndroidNotifications.subList( + 0, + min(toRemove, currentAndroidNotifications.size) + ) .forEach { notificationManager.cancel(it.tag, it.id) } // Still got notifications to remove? Trim the list of new notifications, @@ -65,23 +99,33 @@ class NotificationFetcher @Inject constructor( } } + val notificationsByType = notifications.groupBy { it.type } + // Make and send the new notifications // TODO: Use the batch notification API available in NotificationManagerCompat // 1.11 and up (https://developer.android.com/jetpack/androidx/releases/core#1.11.0-alpha01) // when it is released. - notifications.forEachIndexed { index, notification -> - val androidNotification = NotificationHelper.make( - context, - notificationManager, - notification, - account, - index == 0 - ) - notificationManager.notify(notification.id, account.id.toInt(), androidNotification) - // Android will rate limit / drop notifications if they're posted too - // quickly. There is no indication to the user that this happened. - // See https://github.com/tuskyapp/Tusky/pull/3626#discussion_r1192963664 - delay(1000.milliseconds) + + notificationsByType.forEach { notificationsGroup -> + notificationsGroup.value.forEach { notification -> + val androidNotification = NotificationHelper.make( + context, + notificationManager, + notification, + account, + notificationsGroup.value.size == 1 + ) + notificationManager.notify( + notification.id, + account.id.toInt(), + androidNotification + ) + + // Android will rate limit / drop notifications if they're posted too + // quickly. There is no indication to the user that this happened. + // See https://github.com/tuskyapp/Tusky/pull/3626#discussion_r1192963664 + delay(1000.milliseconds) + } } NotificationHelper.updateSummaryNotifications( @@ -127,7 +171,14 @@ class NotificationFetcher @Inject constructor( Log.d(TAG, "getting notification marker for ${account.fullName}") val remoteMarkerId = fetchMarker(authHeader, account)?.lastReadId ?: "0" val localMarkerId = account.notificationMarkerId - val markerId = if (remoteMarkerId.isLessThan(localMarkerId)) localMarkerId else remoteMarkerId + val markerId = if (remoteMarkerId.isLessThan( + localMarkerId + ) + ) { + localMarkerId + } else { + remoteMarkerId + } val readingPosition = account.lastNotificationId var minId: String? = if (readingPosition.isLessThan(markerId)) markerId else readingPosition diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationHelper.java b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationHelper.java index 6a42e8977..a2059ba64 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationHelper.java +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationHelper.java @@ -85,13 +85,6 @@ public class NotificationHelper { /** Dynamic notification IDs start here */ private static int notificationId = NOTIFICATION_ID_PRUNE_CACHE + 1; - /** - * constants used in Intents - */ - public static final String ACCOUNT_ID = "account_id"; - - public static final String TYPE = APPLICATION_ID + ".notification.type"; - private static final String TAG = "NotificationHelper"; public static final String REPLY_ACTION = "REPLY_ACTION"; @@ -104,7 +97,7 @@ public class NotificationHelper { public static final String KEY_SENDER_ACCOUNT_FULL_NAME = "KEY_SENDER_ACCOUNT_FULL_NAME"; - public static final String KEY_NOTIFICATION_ID = "KEY_NOTIFICATION_ID"; + public static final String KEY_SERVER_NOTIFICATION_ID = "KEY_SERVER_NOTIFICATION_ID"; public static final String KEY_CITED_STATUS_ID = "KEY_CITED_STATUS_ID"; @@ -156,7 +149,7 @@ public class NotificationHelper { * @return the new notification */ @NonNull - public static android.app.Notification make(final Context context, NotificationManager notificationManager, Notification body, AccountEntity account, boolean isFirstOfBatch) { + public static android.app.Notification make(final @NonNull Context context, @NonNull NotificationManager notificationManager, @NonNull Notification body, @NonNull AccountEntity account, boolean isOnlyOneInGroup) { body = body.rewriteToStatusTypeIfNeeded(account.getAccountId()); String mastodonNotificationId = body.getId(); int accountId = (int) account.getId(); @@ -208,8 +201,7 @@ public class NotificationHelper { builder.setLargeIcon(accountAvatar); // Reply to mention action; RemoteInput is available from KitKat Watch, but buttons are available from Nougat - if (body.getType() == Notification.Type.MENTION - && android.os.Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + if (body.getType() == Notification.Type.MENTION) { RemoteInput replyRemoteInput = new RemoteInput.Builder(KEY_REPLY) .setLabel(context.getString(R.string.label_quick_reply)) .build(); @@ -245,11 +237,11 @@ public class NotificationHelper { Bundle extras = new Bundle(); // Add the sending account's name, so it can be used when summarising this notification extras.putString(EXTRA_ACCOUNT_NAME, body.getAccount().getName()); - extras.putString(EXTRA_NOTIFICATION_TYPE, body.getType().toString()); + extras.putString(EXTRA_NOTIFICATION_TYPE, body.getType().name()); builder.addExtras(extras); // Only alert for the first notification of a batch to avoid multiple alerts at once - if(!isFirstOfBatch) { + if(!isOnlyOneInGroup) { builder.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY); } @@ -278,14 +270,14 @@ public class NotificationHelper { * @param notificationManager the system's NotificationManager * @param account the account for which the notification should be shown */ - public static void updateSummaryNotifications(Context context, NotificationManager notificationManager, AccountEntity account) { + public static void updateSummaryNotifications(@NonNull Context context, @NonNull NotificationManager notificationManager, @NonNull AccountEntity account) { // Map from the channel ID to a list of notifications in that channel. Those are the // notifications that will be summarised. Map> channelGroups = new HashMap<>(); int accountId = (int) account.getId(); // Initialise the map with all channel IDs. - for (Notification.Type ty : Notification.Type.values()) { + for (Notification.Type ty : Notification.Type.getEntries()) { channelGroups.put(getChannelId(account, ty), new ArrayList<>()); } @@ -325,11 +317,11 @@ public class NotificationHelper { // Create a notification that summarises the other notifications in this group // All notifications in this group have the same type, so get it from the first. - String notificationType = members.get(0).getNotification().extras.getString(EXTRA_NOTIFICATION_TYPE); + String typeName = members.get(0).getNotification().extras.getString(EXTRA_NOTIFICATION_TYPE, Notification.Type.UNKNOWN.name()); + Notification.Type notificationType = Notification.Type.valueOf(typeName); + + Intent summaryResultIntent = MainActivity.openNotificationIntent(context, accountId, notificationType); - Intent summaryResultIntent = new Intent(context, MainActivity.class); - summaryResultIntent.putExtra(ACCOUNT_ID, (long) accountId); - summaryResultIntent.putExtra(TYPE, notificationType); TaskStackBuilder summaryStackBuilder = TaskStackBuilder.create(context); summaryStackBuilder.addParentStack(MainActivity.class); summaryStackBuilder.addNextIntent(summaryResultIntent); @@ -373,10 +365,8 @@ public class NotificationHelper { private static NotificationCompat.Builder newAndroidNotification(Context context, Notification body, AccountEntity account) { - // we have to switch account here - Intent eventResultIntent = new Intent(context, MainActivity.class); - eventResultIntent.putExtra(ACCOUNT_ID, account.getId()); - eventResultIntent.putExtra(TYPE, body.getType().name()); + Intent eventResultIntent = MainActivity.openNotificationIntent(context, account.getId(), body.getType()); + TaskStackBuilder eventStackBuilder = TaskStackBuilder.create(context); eventStackBuilder.addParentStack(MainActivity.class); eventStackBuilder.addNextIntent(eventResultIntent); @@ -422,7 +412,7 @@ public class NotificationHelper { .putExtra(KEY_SENDER_ACCOUNT_ID, account.getId()) .putExtra(KEY_SENDER_ACCOUNT_IDENTIFIER, account.getIdentifier()) .putExtra(KEY_SENDER_ACCOUNT_FULL_NAME, account.getFullName()) - .putExtra(KEY_NOTIFICATION_ID, notificationId) + .putExtra(KEY_SERVER_NOTIFICATION_ID, body.getId()) .putExtra(KEY_CITED_STATUS_ID, inReplyToId) .putExtra(KEY_VISIBILITY, replyVisibility) .putExtra(KEY_SPOILER, contentWarning) @@ -464,14 +454,10 @@ public class NotificationHelper { composeOptions.setLanguage(actionableStatus.getLanguage()); composeOptions.setKind(ComposeActivity.ComposeKind.NEW); - Intent composeIntent = ComposeActivity.startIntent( - context, - composeOptions, - notificationId, - account.getId() - ); + Intent composeIntent = MainActivity.composeIntent(context, composeOptions, account.getId(), body.getId(), (int)account.getId()); - composeIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + // make sure a new instance of MainActivity is started and old ones get destroyed + composeIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); return PendingIntent.getActivity(context.getApplicationContext(), notificationId, @@ -624,7 +610,7 @@ public class NotificationHelper { } - public static void enablePullNotifications(Context context) { + public static void enablePullNotifications(@NonNull Context context) { WorkManager workManager = WorkManager.getInstance(context); workManager.cancelAllWorkByTag(NOTIFICATION_PULL_TAG); @@ -652,7 +638,7 @@ public class NotificationHelper { Log.d(TAG, "enabled notification checks with "+ PeriodicWorkRequest.MIN_PERIODIC_INTERVAL_MILLIS + "ms interval"); } - public static void disablePullNotifications(Context context) { + public static void disablePullNotifications(@NonNull Context context) { WorkManager.getInstance(context).cancelAllWorkByTag(NOTIFICATION_PULL_TAG); Log.d(TAG, "disabled notification checks"); } @@ -668,7 +654,7 @@ public class NotificationHelper { } } - public static boolean filterNotification(NotificationManager notificationManager, AccountEntity account, @NonNull Notification notification) { + public static boolean filterNotification(@NonNull NotificationManager notificationManager, @NonNull AccountEntity account, @NonNull Notification notification) { return filterNotification(notificationManager, account, notification.getType()); } @@ -854,7 +840,7 @@ public class NotificationHelper { PollOption option = options.get(i); builder.append(buildDescription(option.getTitle(), PollViewDataKt.calculatePercent(option.getVotesCount(), poll.getVotersCount(), poll.getVotesCount()), - poll.getOwnVotes() != null && poll.getOwnVotes().contains(i), + poll.getOwnVotes().contains(i), context)); builder.append('\n'); } @@ -874,7 +860,7 @@ public class NotificationHelper { if (mutable) { return PendingIntent.FLAG_UPDATE_CURRENT | (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S ? PendingIntent.FLAG_MUTABLE : 0); } else { - return PendingIntent.FLAG_UPDATE_CURRENT | (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M ? PendingIntent.FLAG_IMMUTABLE : 0); + return PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE; } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsFragment.kt deleted file mode 100644 index baa66e5b3..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsFragment.kt +++ /dev/null @@ -1,692 +0,0 @@ -/* - * Copyright 2023 Tusky Contributors - * - * This file is a part of Tusky. - * - * 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. - * - * Tusky 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 Tusky; if not, - * see . - */ - -package com.keylesspalace.tusky.components.notifications - -import android.app.Dialog -import android.content.DialogInterface -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 androidx.appcompat.app.AlertDialog -import androidx.coordinatorlayout.widget.CoordinatorLayout -import androidx.core.view.MenuProvider -import androidx.core.view.isVisible -import androidx.fragment.app.DialogFragment -import androidx.fragment.app.viewModels -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.repeatOnLifecycle -import androidx.paging.LoadState -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.DividerItemDecoration -import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView -import androidx.recyclerview.widget.RecyclerView.NO_POSITION -import androidx.recyclerview.widget.RecyclerView.SCROLL_STATE_IDLE -import androidx.recyclerview.widget.SimpleItemAnimator -import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener -import at.connyduck.sparkbutton.helpers.Utils -import com.google.android.material.appbar.AppBarLayout.ScrollingViewBehavior -import com.google.android.material.color.MaterialColors -import com.google.android.material.snackbar.Snackbar -import com.keylesspalace.tusky.R -import com.keylesspalace.tusky.adapter.StatusBaseViewHolder -import com.keylesspalace.tusky.databinding.FragmentTimelineNotificationsBinding -import com.keylesspalace.tusky.di.Injectable -import com.keylesspalace.tusky.di.ViewModelFactory -import com.keylesspalace.tusky.entity.Notification -import com.keylesspalace.tusky.entity.Status -import com.keylesspalace.tusky.fragment.SFragment -import com.keylesspalace.tusky.interfaces.AccountActionListener -import com.keylesspalace.tusky.interfaces.ActionButtonActivity -import com.keylesspalace.tusky.interfaces.ReselectableFragment -import com.keylesspalace.tusky.interfaces.StatusActionListener -import com.keylesspalace.tusky.util.ListStatusAccessibilityDelegate -import com.keylesspalace.tusky.util.openLink -import com.keylesspalace.tusky.util.viewBinding -import com.keylesspalace.tusky.viewdata.AttachmentViewData.Companion.list -import com.keylesspalace.tusky.viewdata.NotificationViewData -import com.mikepenz.iconics.IconicsDrawable -import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial -import com.mikepenz.iconics.utils.colorInt -import com.mikepenz.iconics.utils.sizeDp -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.collect -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.distinctUntilChangedBy -import kotlinx.coroutines.flow.filterIsInstance -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.launch -import java.io.IOException -import javax.inject.Inject - -class NotificationsFragment : - SFragment(), - StatusActionListener, - NotificationActionListener, - AccountActionListener, - OnRefreshListener, - MenuProvider, - Injectable, - ReselectableFragment { - - @Inject - lateinit var viewModelFactory: ViewModelFactory - - private val viewModel: NotificationsViewModel by viewModels { viewModelFactory } - - private val binding by viewBinding(FragmentTimelineNotificationsBinding::bind) - - private lateinit var adapter: NotificationsPagingAdapter - - private lateinit var layoutManager: LinearLayoutManager - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - adapter = NotificationsPagingAdapter( - notificationDiffCallback, - accountId = viewModel.account.accountId, - statusActionListener = this, - notificationActionListener = this, - accountActionListener = this, - statusDisplayOptions = viewModel.statusDisplayOptions.value - ) - } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - return inflater.inflate(R.layout.fragment_timeline_notifications, container, false) - } - - private fun updateFilterVisibility(showFilter: Boolean) { - val params = binding.swipeRefreshLayout.layoutParams as CoordinatorLayout.LayoutParams - if (showFilter) { - binding.appBarOptions.setExpanded(true, false) - binding.appBarOptions.visibility = View.VISIBLE - // Set content behaviour to hide filter on scroll - params.behavior = ScrollingViewBehavior() - } else { - binding.appBarOptions.setExpanded(false, false) - binding.appBarOptions.visibility = View.GONE - // Clear behaviour to hide app bar - params.behavior = null - } - } - - private fun confirmClearNotifications() { - AlertDialog.Builder(requireContext()) - .setMessage(R.string.notification_clear_text) - .setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int -> clearNotifications() } - .setNegativeButton(android.R.string.cancel, null) - .show() - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED) - - // Setup the SwipeRefreshLayout. - binding.swipeRefreshLayout.setOnRefreshListener(this) - binding.swipeRefreshLayout.setColorSchemeResources(R.color.chinwag_green) - - // Setup the RecyclerView. - binding.recyclerView.setHasFixedSize(true) - layoutManager = LinearLayoutManager(context) - binding.recyclerView.layoutManager = layoutManager - binding.recyclerView.setAccessibilityDelegateCompat( - ListStatusAccessibilityDelegate( - binding.recyclerView, - this - ) { pos: Int -> - val notification = adapter.snapshot().getOrNull(pos) - // We support replies only for now - if (notification is NotificationViewData) { - notification.statusViewData - } else { - null - } - } - ) - binding.recyclerView.addItemDecoration( - DividerItemDecoration( - context, - DividerItemDecoration.VERTICAL - ) - ) - - binding.recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { - val actionButton = (activity as ActionButtonActivity).actionButton - - override fun onScrolled(view: RecyclerView, dx: Int, dy: Int) { - actionButton?.let { fab -> - if (!viewModel.uiState.value.showFabWhileScrolling) { - if (dy > 0 && fab.isShown) { - fab.hide() // Hide when scrolling down - } else if (dy < 0 && !fab.isShown) { - fab.show() // Show when scrolling up - } - } else if (!fab.isShown) { - fab.show() - } - } - } - - @Suppress("SyntheticAccessor") - override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) { - newState != SCROLL_STATE_IDLE && return - - // Save the ID of the first notification visible in the list, so the user's - // reading position is always restorable. - layoutManager.findFirstVisibleItemPosition().takeIf { it != NO_POSITION }?.let { position -> - adapter.snapshot().getOrNull(position)?.id?.let { id -> - viewModel.accept(InfallibleUiAction.SaveVisibleId(visibleId = id)) - } - } - } - }) - - binding.recyclerView.adapter = adapter.withLoadStateHeaderAndFooter( - header = NotificationsLoadStateAdapter { adapter.retry() }, - footer = NotificationsLoadStateAdapter { adapter.retry() } - ) - - binding.buttonClear.setOnClickListener { confirmClearNotifications() } - binding.buttonFilter.setOnClickListener { showFilterDialog() } - (binding.recyclerView.itemAnimator as SimpleItemAnimator?)!!.supportsChangeAnimations = - false - - // Signal the user that a refresh has loaded new items above their current position - // by scrolling up slightly to disclose the new content - adapter.registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() { - override fun onItemRangeInserted(positionStart: Int, itemCount: Int) { - if (positionStart == 0 && adapter.itemCount != itemCount) { - binding.recyclerView.post { - if (getView() != null) { - binding.recyclerView.scrollBy(0, Utils.dpToPx(requireContext(), -30)) - } - } - } - } - }) - - // update post timestamps - val updateTimestampFlow = flow { - while (true) { - delay(60000) - emit(Unit) - } - }.onEach { - adapter.notifyItemRangeChanged(0, adapter.itemCount, listOf(StatusBaseViewHolder.Key.KEY_CREATED)) - } - - viewLifecycleOwner.lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.STARTED) { - launch { - viewModel.pagingData.collectLatest { pagingData -> - Log.d(TAG, "Submitting data to adapter") - adapter.submitData(pagingData) - } - } - - // Show errors from the view model as snack bars. - // - // Errors are shown: - // - Indefinitely, so the user has a chance to read and understand - // the message - // - With a max of 5 text lines, to allow space for longer errors. - // E.g., on a typical device, an error message like "Bookmarking - // post failed: Unable to resolve host 'mastodon.social': No - // address associated with hostname" is 3 lines. - // - With a "Retry" option if the error included a UiAction to retry. - launch { - viewModel.uiError.collect { error -> - Log.d(TAG, error.toString()) - val message = getString( - error.message, - error.throwable.localizedMessage - ?: getString(R.string.ui_error_unknown) - ) - val snackbar = Snackbar.make( - // Without this the FAB will not move out of the way - (activity as ActionButtonActivity).actionButton ?: binding.root, - message, - Snackbar.LENGTH_INDEFINITE - ).setTextMaxLines(5) - error.action?.let { action -> - snackbar.setAction(R.string.action_retry) { - viewModel.accept(action) - } - } - snackbar.show() - - // The status view has pre-emptively updated its state to show - // that the action succeeded. Since it hasn't, re-bind the view - // to show the correct data. - error.action?.let { action -> - action is StatusAction || return@let - - val position = adapter.snapshot().indexOfFirst { - it?.statusViewData?.status?.id == (action as StatusAction).statusViewData.id - } - if (position != RecyclerView.NO_POSITION) { - adapter.notifyItemChanged(position) - } - } - } - } - - // Show successful notification action as brief snackbars, so the - // user is clear the action has happened. - launch { - viewModel.uiSuccess - .filterIsInstance() - .collect { - Snackbar.make( - (activity as ActionButtonActivity).actionButton ?: binding.root, - getString(it.msg), - Snackbar.LENGTH_SHORT - ).show() - - when (it) { - // The follow request is no longer valid, refresh the adapter to - // remove it. - is NotificationActionSuccess.AcceptFollowRequest, - is NotificationActionSuccess.RejectFollowRequest -> adapter.refresh() - } - } - } - - // Update adapter data when status actions are successful, and re-bind to update - // the UI. - launch { - viewModel.uiSuccess - .filterIsInstance() - .collect { - val indexedViewData = adapter.snapshot() - .withIndex() - .firstOrNull { notificationViewData -> - notificationViewData.value?.statusViewData?.status?.id == - it.action.statusViewData.id - } ?: return@collect - - val statusViewData = - indexedViewData.value?.statusViewData ?: return@collect - - val status = when (it) { - is StatusActionSuccess.Bookmark -> - statusViewData.status.copy(bookmarked = it.action.state) - is StatusActionSuccess.Favourite -> - statusViewData.status.copy(favourited = it.action.state) - is StatusActionSuccess.Reblog -> - statusViewData.status.copy(reblogged = it.action.state) - is StatusActionSuccess.VoteInPoll -> - statusViewData.status.copy( - poll = it.action.poll.votedCopy(it.action.choices) - ) - } - indexedViewData.value?.statusViewData = statusViewData.copy( - status = status - ) - - adapter.notifyItemChanged(indexedViewData.index) - } - } - - // Refresh adapter on mutes and blocks - launch { - viewModel.uiSuccess.collectLatest { - when (it) { - is UiSuccess.Block, is UiSuccess.Mute, is UiSuccess.MuteConversation -> - adapter.refresh() - else -> { /* nothing to do */ - } - } - } - } - - // Update filter option visibility from uiState - launch { - viewModel.uiState.collectLatest { updateFilterVisibility(it.showFilterOptions) } - } - - // Update status display from statusDisplayOptions. If the new options request - // relative time display collect the flow to periodically update the timestamp in the list gui elements. - launch { - viewModel.statusDisplayOptions - .collectLatest { - // NOTE this this also triggered (emitted?) on resume. - - adapter.statusDisplayOptions = it - adapter.notifyItemRangeChanged(0, adapter.itemCount, null) - - if (!it.useAbsoluteTime) { - updateTimestampFlow.collect() - } - } - } - - // Update the UI from the loadState - adapter.loadStateFlow - .distinctUntilChangedBy { it.refresh } - .collect { loadState -> - binding.recyclerView.isVisible = true - binding.progressBar.isVisible = loadState.refresh is LoadState.Loading && - !binding.swipeRefreshLayout.isRefreshing - binding.swipeRefreshLayout.isRefreshing = - loadState.refresh is LoadState.Loading && !binding.progressBar.isVisible - - binding.statusView.isVisible = false - if (loadState.refresh is LoadState.NotLoading) { - if (adapter.itemCount == 0) { - binding.statusView.setup( - R.drawable.elephant_friend_empty, - R.string.message_empty - ) - binding.recyclerView.isVisible = false - binding.statusView.isVisible = true - } else { - binding.statusView.isVisible = false - } - } - - if (loadState.refresh is LoadState.Error) { - when ((loadState.refresh as LoadState.Error).error) { - is IOException -> { - binding.statusView.setup( - R.drawable.elephant_offline, - R.string.error_network - ) { adapter.retry() } - } - else -> { - binding.statusView.setup( - R.drawable.elephant_error, - R.string.error_generic - ) { adapter.retry() } - } - } - binding.recyclerView.isVisible = false - binding.statusView.isVisible = true - } - } - } - } - } - - override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { - menuInflater.inflate(R.menu.fragment_notifications, menu) - menu.findItem(R.id.action_refresh)?.apply { - icon = IconicsDrawable(requireContext(), GoogleMaterial.Icon.gmd_refresh).apply { - sizeDp = 20 - colorInt = MaterialColors.getColor(binding.root, android.R.attr.textColorPrimary) - } - } - } - - override fun onMenuItemSelected(menuItem: MenuItem): Boolean { - return when (menuItem.itemId) { - R.id.action_refresh -> { - binding.swipeRefreshLayout.isRefreshing = true - onRefresh() - true - } - R.id.load_newest -> { - viewModel.accept(InfallibleUiAction.LoadNewest) - true - } - else -> false - } - } - - override fun onRefresh() { - binding.progressBar.isVisible = false - adapter.refresh() - NotificationHelper.clearNotificationsForAccount(requireContext(), viewModel.account) - } - - override fun onPause() { - super.onPause() - - // Save the ID of the first notification visible in the list - val position = layoutManager.findFirstVisibleItemPosition() - if (position >= 0) { - adapter.snapshot().getOrNull(position)?.id?.let { id -> - viewModel.accept(InfallibleUiAction.SaveVisibleId(visibleId = id)) - } - } - } - - override fun onResume() { - super.onResume() - NotificationHelper.clearNotificationsForAccount(requireContext(), viewModel.account) - } - - override fun onReply(position: Int) { - val status = adapter.peek(position)?.statusViewData?.status ?: return - super.reply(status) - } - - override fun onReblog(reblog: Boolean, position: Int) { - val statusViewData = adapter.peek(position)?.statusViewData ?: return - viewModel.accept(StatusAction.Reblog(reblog, statusViewData)) - } - - override fun onFavourite(favourite: Boolean, position: Int) { - val statusViewData = adapter.peek(position)?.statusViewData ?: return - viewModel.accept(StatusAction.Favourite(favourite, statusViewData)) - } - - override fun onBookmark(bookmark: Boolean, position: Int) { - val statusViewData = adapter.peek(position)?.statusViewData ?: return - viewModel.accept(StatusAction.Bookmark(bookmark, statusViewData)) - } - - override fun onVoteInPoll(position: Int, choices: List) { - val statusViewData = adapter.peek(position)?.statusViewData ?: return - val poll = statusViewData.status.poll ?: return - viewModel.accept(StatusAction.VoteInPoll(poll, choices, statusViewData)) - } - - override fun onMore(view: View, position: Int) { - val status = adapter.peek(position)?.statusViewData?.status ?: return - super.more(status, view, position) - } - - override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) { - val status = adapter.peek(position)?.statusViewData?.status ?: return - super.viewMedia(attachmentIndex, list(status), view) - } - - override fun onViewThread(position: Int) { - val status = adapter.peek(position)?.statusViewData?.status ?: return - super.viewThread(status.actionableId, status.actionableStatus.url) - } - - override fun onOpenReblog(position: Int) { - val account = adapter.peek(position)?.account!! - onViewAccount(account.id) - } - - override fun onExpandedChange(expanded: Boolean, position: Int) { - val notificationViewData = adapter.snapshot()[position] ?: return - notificationViewData.statusViewData = notificationViewData.statusViewData?.copy( - isExpanded = expanded - ) - adapter.notifyItemChanged(position) - } - - override fun onContentHiddenChange(isShowing: Boolean, position: Int) { - val notificationViewData = adapter.snapshot()[position] ?: return - notificationViewData.statusViewData = notificationViewData.statusViewData?.copy( - isShowingContent = isShowing - ) - adapter.notifyItemChanged(position) - } - - override fun onLoadMore(position: Int) { - // Empty -- this fragment doesn't show placeholders - } - - override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) { - val notificationViewData = adapter.snapshot()[position] ?: return - notificationViewData.statusViewData = notificationViewData.statusViewData?.copy( - isCollapsed = isCollapsed - ) - adapter.notifyItemChanged(position) - } - - override fun onNotificationContentCollapsedChange(isCollapsed: Boolean, position: Int) { - onContentCollapsedChange(isCollapsed, position) - } - - override fun clearWarningAction(position: Int) { - } - - private fun clearNotifications() { - binding.swipeRefreshLayout.isRefreshing = false - binding.progressBar.isVisible = false - viewModel.accept(FallibleUiAction.ClearNotifications) - } - - private fun showFilterDialog() { - FilterDialogFragment(viewModel.uiState.value.activeFilter) { filter -> - if (viewModel.uiState.value.activeFilter != filter) { - viewModel.accept(InfallibleUiAction.ApplyFilter(filter)) - } - } - .show(parentFragmentManager, "dialogFilter") - } - - override fun onViewTag(tag: String) { - super.viewTag(tag) - } - - override fun onViewAccount(id: String) { - super.viewAccount(id) - } - - override fun onMute(mute: Boolean, id: String, position: Int, notifications: Boolean) { - adapter.refresh() - } - - override fun onBlock(block: Boolean, id: String, position: Int) { - adapter.refresh() - } - - override fun onRespondToFollowRequest(accept: Boolean, accountId: String, position: Int) { - if (accept) { - viewModel.accept(NotificationAction.AcceptFollowRequest(accountId)) - } else { - viewModel.accept(NotificationAction.RejectFollowRequest(accountId)) - } - } - - override fun onViewThreadForStatus(status: Status) { - super.viewThread(status.actionableId, status.actionableStatus.url) - } - - override fun onViewReport(reportId: String) { - requireContext().openLink( - "https://${viewModel.account.domain}/admin/reports/$reportId" - ) - } - - public override fun removeItem(position: Int) { - // Empty -- this fragment doesn't remove items - } - - override fun onReselect() { - if (isAdded) { - binding.appBarOptions.setExpanded(true, false) - layoutManager.scrollToPosition(0) - } - } - - companion object { - private const val TAG = "NotificationsFragment" - fun newInstance() = NotificationsFragment() - - private val notificationDiffCallback: DiffUtil.ItemCallback = - object : DiffUtil.ItemCallback() { - override fun areItemsTheSame( - oldItem: NotificationViewData, - newItem: NotificationViewData - ): Boolean { - return oldItem.id == newItem.id - } - - override fun areContentsTheSame( - oldItem: NotificationViewData, - newItem: NotificationViewData - ): Boolean { - return false - } - - override fun getChangePayload( - oldItem: NotificationViewData, - newItem: NotificationViewData - ): Any? { - return if (oldItem == newItem) { - // If items are equal - update timestamp only - listOf(StatusBaseViewHolder.Key.KEY_CREATED) - } else { - // If items are different - update a whole view holder - null - } - } - } - } -} - -class FilterDialogFragment( - private val activeFilter: Set, - private val listener: ((filter: Set) -> Unit) -) : DialogFragment() { - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val context = requireContext() - - val items = Notification.Type.visibleTypes.map { getString(it.uiString) }.toTypedArray() - val checkedItems = Notification.Type.visibleTypes.map { - !activeFilter.contains(it) - }.toBooleanArray() - - val builder = AlertDialog.Builder(context) - .setTitle(R.string.notifications_apply_filter) - .setMultiChoiceItems(items, checkedItems) { _, which, isChecked -> - checkedItems[which] = isChecked - } - .setPositiveButton(android.R.string.ok) { _, _ -> - val excludes: MutableSet = HashSet() - for (i in Notification.Type.visibleTypes.indices) { - if (!checkedItems[i]) excludes.add(Notification.Type.visibleTypes[i]) - } - listener(excludes) - } - .setNegativeButton(android.R.string.cancel) { _, _ -> } - return builder.create() - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsLoadStateViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsLoadStateViewHolder.kt deleted file mode 100644 index f3c006d32..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsLoadStateViewHolder.kt +++ /dev/null @@ -1,73 +0,0 @@ -/* - * Copyright 2023 Tusky Contributors - * - * This file is a part of Tusky. - * - * 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. - * - * Tusky 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 Tusky; if not, - * see . - */ - -package com.keylesspalace.tusky.components.notifications - -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.core.view.isVisible -import androidx.paging.LoadState -import androidx.recyclerview.widget.RecyclerView -import com.keylesspalace.tusky.R -import com.keylesspalace.tusky.databinding.ItemNotificationsLoadStateFooterViewBinding -import java.net.SocketTimeoutException - -/** - * Display the header/footer loading state to the user. - * - * Either: - * - * 1. A page is being loaded, display a progress view, or - * 2. An error occurred, display an error message with a "retry" button - * - * @param retry function to invoke if the user clicks the "retry" button - */ -class NotificationsLoadStateViewHolder( - private val binding: ItemNotificationsLoadStateFooterViewBinding, - retry: () -> Unit -) : RecyclerView.ViewHolder(binding.root) { - init { - binding.retryButton.setOnClickListener { retry.invoke() } - } - - fun bind(loadState: LoadState) { - if (loadState is LoadState.Error) { - val ctx = binding.root.context - binding.errorMsg.text = when (loadState.error) { - is SocketTimeoutException -> ctx.getString(R.string.socket_timeout_exception) - // Other exceptions to consider: - // - UnknownHostException, default text is: - // Unable to resolve "%s": No address associated with hostname - else -> loadState.error.localizedMessage - } - } - binding.progressBar.isVisible = loadState is LoadState.Loading - binding.retryButton.isVisible = loadState is LoadState.Error - binding.errorMsg.isVisible = loadState is LoadState.Error - } - - companion object { - fun create(parent: ViewGroup, retry: () -> Unit): NotificationsLoadStateViewHolder { - val binding = ItemNotificationsLoadStateFooterViewBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false - ) - return NotificationsLoadStateViewHolder(binding, retry) - } - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsPagingAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsPagingAdapter.kt deleted file mode 100644 index faa1aefce..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsPagingAdapter.kt +++ /dev/null @@ -1,207 +0,0 @@ -/* - * Copyright 2023 Tusky Contributors - * - * This file is a part of Tusky. - * - * 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. - * - * Tusky 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 Tusky; if not, - * see . - */ - -package com.keylesspalace.tusky.components.notifications - -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.paging.PagingDataAdapter -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.RecyclerView -import com.keylesspalace.tusky.adapter.FollowRequestViewHolder -import com.keylesspalace.tusky.adapter.ReportNotificationViewHolder -import com.keylesspalace.tusky.databinding.ItemFollowBinding -import com.keylesspalace.tusky.databinding.ItemFollowRequestBinding -import com.keylesspalace.tusky.databinding.ItemReportNotificationBinding -import com.keylesspalace.tusky.databinding.ItemStatusBinding -import com.keylesspalace.tusky.databinding.ItemStatusNotificationBinding -import com.keylesspalace.tusky.databinding.SimpleListItem1Binding -import com.keylesspalace.tusky.entity.Notification -import com.keylesspalace.tusky.entity.Status -import com.keylesspalace.tusky.interfaces.AccountActionListener -import com.keylesspalace.tusky.interfaces.StatusActionListener -import com.keylesspalace.tusky.util.AbsoluteTimeFormatter -import com.keylesspalace.tusky.util.StatusDisplayOptions -import com.keylesspalace.tusky.viewdata.NotificationViewData - -/** How to present the notification in the UI */ -enum class NotificationViewKind { - /** View as the original status */ - STATUS, - - /** View as the original status, with the interaction type above */ - NOTIFICATION, - FOLLOW, - FOLLOW_REQUEST, - REPORT, - UNKNOWN; - - companion object { - fun from(kind: Notification.Type?): NotificationViewKind { - return when (kind) { - Notification.Type.MENTION, - Notification.Type.POLL, - Notification.Type.UNKNOWN -> STATUS - Notification.Type.FAVOURITE, - Notification.Type.REBLOG, - Notification.Type.STATUS, - Notification.Type.UPDATE -> NOTIFICATION - Notification.Type.FOLLOW, - Notification.Type.SIGN_UP -> FOLLOW - Notification.Type.FOLLOW_REQUEST -> FOLLOW_REQUEST - Notification.Type.REPORT -> REPORT - null -> UNKNOWN - } - } - } -} - -interface NotificationActionListener { - fun onViewAccount(id: String) - fun onViewThreadForStatus(status: Status) - fun onViewReport(reportId: String) - - /** - * Called when the status has a content warning and the visibility of the content behind - * the warning is being changed. - * - * @param expanded the desired state of the content behind the content warning - * @param position the adapter position of the view - * - */ - fun onExpandedChange(expanded: Boolean, position: Int) - - /** - * Called when the status [android.widget.ToggleButton] responsible for collapsing long - * status content is interacted with. - * - * @param isCollapsed Whether the status content is shown in a collapsed state or fully. - * @param position The position of the status in the list. - */ - fun onNotificationContentCollapsedChange(isCollapsed: Boolean, position: Int) -} - -class NotificationsPagingAdapter( - diffCallback: DiffUtil.ItemCallback, - /** ID of the the account that notifications are being displayed for */ - private val accountId: String, - private val statusActionListener: StatusActionListener, - private val notificationActionListener: NotificationActionListener, - private val accountActionListener: AccountActionListener, - var statusDisplayOptions: StatusDisplayOptions -) : PagingDataAdapter(diffCallback) { - - private val absoluteTimeFormatter = AbsoluteTimeFormatter() - - /** View holders in this adapter must implement this interface */ - interface ViewHolder { - /** Bind the data from the notification and payloads to the view */ - fun bind( - viewData: NotificationViewData, - payloads: List<*>?, - statusDisplayOptions: StatusDisplayOptions - ) - } - - override fun getItemViewType(position: Int): Int { - return NotificationViewKind.from(getItem(position)?.type).ordinal - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { - val inflater = LayoutInflater.from(parent.context) - - return when (NotificationViewKind.values()[viewType]) { - NotificationViewKind.STATUS -> { - StatusViewHolder( - ItemStatusBinding.inflate(inflater, parent, false), - statusActionListener, - accountId - ) - } - NotificationViewKind.NOTIFICATION -> { - StatusNotificationViewHolder( - ItemStatusNotificationBinding.inflate(inflater, parent, false), - statusActionListener, - notificationActionListener, - absoluteTimeFormatter - ) - } - NotificationViewKind.FOLLOW -> { - FollowViewHolder( - ItemFollowBinding.inflate(inflater, parent, false), - notificationActionListener, - statusActionListener - ) - } - NotificationViewKind.FOLLOW_REQUEST -> { - FollowRequestViewHolder( - ItemFollowRequestBinding.inflate(inflater, parent, false), - accountActionListener, - statusActionListener, - showHeader = true - ) - } - NotificationViewKind.REPORT -> { - ReportNotificationViewHolder( - ItemReportNotificationBinding.inflate(inflater, parent, false), - notificationActionListener - ) - } - else -> { - FallbackNotificationViewHolder( - SimpleListItem1Binding.inflate(inflater, parent, false) - ) - } - } - } - - override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - bindViewHolder(holder, position, null) - } - - override fun onBindViewHolder( - holder: RecyclerView.ViewHolder, - position: Int, - payloads: MutableList - ) { - bindViewHolder(holder, position, payloads) - } - - private fun bindViewHolder( - holder: RecyclerView.ViewHolder, - position: Int, - payloads: List<*>? - ) { - getItem(position)?.let { (holder as ViewHolder).bind(it, payloads, statusDisplayOptions) } - } - - /** - * Notification view holder to use if no other type is appropriate. Should never normally - * be used, but is useful when migrating code. - */ - private class FallbackNotificationViewHolder( - val binding: SimpleListItem1Binding - ) : ViewHolder, RecyclerView.ViewHolder(binding.root) { - override fun bind( - viewData: NotificationViewData, - payloads: List<*>?, - statusDisplayOptions: StatusDisplayOptions - ) { - binding.text1.text = viewData.statusViewData?.content - } - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsPagingSource.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsPagingSource.kt deleted file mode 100644 index b754989d0..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsPagingSource.kt +++ /dev/null @@ -1,216 +0,0 @@ -/* - * Copyright 2023 Tusky Contributors - * - * This file is a part of Tusky. - * - * 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. - * - * Tusky 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 Tusky; if not, - * see . - */ - -package com.keylesspalace.tusky.components.notifications - -import android.util.Log -import androidx.paging.PagingSource -import androidx.paging.PagingState -import com.google.gson.Gson -import com.keylesspalace.tusky.entity.Notification -import com.keylesspalace.tusky.network.MastodonApi -import com.keylesspalace.tusky.util.HttpHeaderLink -import kotlinx.coroutines.async -import kotlinx.coroutines.coroutineScope -import okhttp3.Headers -import retrofit2.Response -import javax.inject.Inject - -/** Models next/prev links from the "Links" header in an API response */ -data class Links(val next: String?, val prev: String?) { - companion object { - fun from(linkHeader: String?): Links { - val links = HttpHeaderLink.parse(linkHeader) - return Links( - next = HttpHeaderLink.findByRelationType(links, "next")?.uri?.getQueryParameter( - "max_id" - ), - prev = HttpHeaderLink.findByRelationType(links, "prev")?.uri?.getQueryParameter( - "min_id" - ) - ) - } - } -} - -/** [PagingSource] for Mastodon Notifications, identified by the Notification ID */ -class NotificationsPagingSource @Inject constructor( - private val mastodonApi: MastodonApi, - private val gson: Gson, - private val notificationFilter: Set -) : PagingSource() { - override suspend fun load(params: LoadParams): LoadResult { - Log.d(TAG, "load() with ${params.javaClass.simpleName} for key: ${params.key}") - - try { - val response = when (params) { - is LoadParams.Refresh -> { - getInitialPage(params) - } - is LoadParams.Append -> mastodonApi.notifications( - maxId = params.key, - limit = params.loadSize, - excludes = notificationFilter - ) - is LoadParams.Prepend -> mastodonApi.notifications( - minId = params.key, - limit = params.loadSize, - excludes = notificationFilter - ) - } - - if (!response.isSuccessful) { - val code = response.code() - - val msg = response.errorBody()?.string()?.let { errorBody -> - if (errorBody.isBlank()) return@let "no reason given" - - val error = try { - gson.fromJson(errorBody, com.keylesspalace.tusky.entity.Error::class.java) - } catch (e: Exception) { - return@let "$errorBody ($e)" - } - - when (val desc = error.error_description) { - null -> error.error - else -> "${error.error}: $desc" - } - } ?: "no reason given" - return LoadResult.Error(Throwable("HTTP $code: $msg")) - } - - val links = Links.from(response.headers()["link"]) - return LoadResult.Page( - data = response.body()!!, - nextKey = links.next, - prevKey = links.prev - ) - } catch (e: Exception) { - return LoadResult.Error(e) - } - } - - /** - * Fetch the initial page of notifications, using params.key as the ID of the initial - * notification to fetch. - * - * - If there is no key, a page of the most recent notifications is returned - * - If the notification exists, and is not filtered, a page of notifications is returned - * - If the notification does not exist, or is filtered, the page of notifications immediately - * before is returned (if non-empty) - * - If there is no page of notifications immediately before then the page immediately after - * is returned (if non-empty) - * - Finally, fall back to the most recent notifications - */ - private suspend fun getInitialPage(params: LoadParams): Response> = coroutineScope { - // If the key is null this is straightforward, just return the most recent notifications. - val key = params.key - ?: return@coroutineScope mastodonApi.notifications( - limit = params.loadSize, - excludes = notificationFilter - ) - - // It's important to return *something* from this state. If an empty page is returned - // (even with next/prev links) Pager3 assumes there is no more data to load and stops. - // - // In addition, the Mastodon API does not let you fetch a page that contains a given key. - // You can fetch the page immediately before the key, or the page immediately after, but - // you can not fetch the page itself. - - // First, try and get the notification itself, and the notifications immediately before - // it. This is so that a full page of results can be returned. Returning just the - // single notification means the displayed list can jump around a bit as more data is - // loaded. - // - // Make both requests, and wait for the first to complete. - val deferredNotification = async { mastodonApi.notification(id = key) } - val deferredNotificationPage = async { - mastodonApi.notifications(maxId = key, limit = params.loadSize, excludes = notificationFilter) - } - - val notification = deferredNotification.await() - if (notification.isSuccessful) { - // If this was successful we must still check that the user is not filtering this type - // of notification, as fetching a single notification ignores filters. Returning this - // notification if the user is filtering the type is wrong. - notification.body()?.let { body -> - if (!notificationFilter.contains(body.type)) { - // Notification is *not* filtered. We can return this, but need the next page of - // notifications as well - - // Collect all notifications in to this list - val notifications = mutableListOf(body) - val notificationPage = deferredNotificationPage.await() - if (notificationPage.isSuccessful) { - notificationPage.body()?.let { - notifications.addAll(it) - } - } - - // "notifications" now contains at least one notification we can return, and - // hopefully a full page. - - // Build correct max_id and min_id links for the response. The "min_id" to use - // when fetching the next page is the same as "key". The "max_id" is the ID of - // the oldest notification in the list. - val maxId = notifications.last().id - val headers = Headers.Builder() - .add("link: ; rel=\"next\", ; rel=\"prev\"") - .build() - - return@coroutineScope Response.success(notifications, headers) - } - } - } - - // The user's last read notification was missing or is filtered. Use the page of - // notifications chronologically older than their desired notification. This page must - // *not* be empty (as noted earlier, if it is, paging stops). - deferredNotificationPage.await().let { response -> - if (response.isSuccessful) { - if (!response.body().isNullOrEmpty()) return@coroutineScope response - } - } - - // There were no notifications older than the user's desired notification. Return the page - // of notifications immediately newer than their desired notification. This page must - // *not* be empty (as noted earlier, if it is, paging stops). - mastodonApi.notifications(minId = key, limit = params.loadSize, excludes = notificationFilter).let { response -> - if (response.isSuccessful) { - if (!response.body().isNullOrEmpty()) return@coroutineScope response - } - } - - // Everything failed -- fallback to fetching the most recent notifications - return@coroutineScope mastodonApi.notifications( - limit = params.loadSize, - excludes = notificationFilter - ) - } - - override fun getRefreshKey(state: PagingState): String? { - return state.anchorPosition?.let { anchorPosition -> - val id = state.closestItemToPosition(anchorPosition)?.id - Log.d(TAG, " getRefreshKey returning $id") - return id - } - } - - companion object { - private const val TAG = "NotificationsPagingSource" - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsRepository.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsRepository.kt deleted file mode 100644 index 4bec1aa32..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsRepository.kt +++ /dev/null @@ -1,76 +0,0 @@ -/* - * Copyright 2023 Tusky Contributors - * - * This file is a part of Tusky. - * - * 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. - * - * Tusky 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 Tusky; if not, - * see . - */ - -package com.keylesspalace.tusky.components.notifications - -import android.util.Log -import androidx.paging.InvalidatingPagingSourceFactory -import androidx.paging.Pager -import androidx.paging.PagingConfig -import androidx.paging.PagingData -import androidx.paging.PagingSource -import com.google.gson.Gson -import com.keylesspalace.tusky.entity.Notification -import com.keylesspalace.tusky.network.MastodonApi -import kotlinx.coroutines.flow.Flow -import okhttp3.ResponseBody -import retrofit2.Response -import javax.inject.Inject - -class NotificationsRepository @Inject constructor( - private val mastodonApi: MastodonApi, - private val gson: Gson -) { - private var factory: InvalidatingPagingSourceFactory? = null - - /** - * @return flow of Mastodon [Notification], excluding all types in [filter]. - * Notifications are loaded in [pageSize] increments. - */ - fun getNotificationsStream( - filter: Set, - pageSize: Int = PAGE_SIZE, - initialKey: String? = null - ): Flow> { - Log.d(TAG, "getNotificationsStream(), filtering: $filter") - - factory = InvalidatingPagingSourceFactory { - NotificationsPagingSource(mastodonApi, gson, filter) - } - - return Pager( - config = PagingConfig(pageSize = pageSize, initialLoadSize = pageSize), - initialKey = initialKey, - pagingSourceFactory = factory!! - ).flow - } - - /** Invalidate the active paging source, see [PagingSource.invalidate] */ - fun invalidate() { - factory?.invalidate() - } - - /** Clear notifications */ - suspend fun clearNotifications(): Response { - return mastodonApi.clearNotifications() - } - - companion object { - private const val TAG = "NotificationsRepository" - private const val PAGE_SIZE = 30 - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModel.kt deleted file mode 100644 index d1d41a947..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModel.kt +++ /dev/null @@ -1,557 +0,0 @@ -/* - * Copyright 2023 Tusky Contributors - * - * This file is a part of Tusky. - * - * 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. - * - * Tusky 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 Tusky; if not, - * see . - */ - -package com.keylesspalace.tusky.components.notifications - -import android.content.SharedPreferences -import android.util.Log -import androidx.annotation.StringRes -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import androidx.paging.PagingData -import androidx.paging.cachedIn -import androidx.paging.map -import at.connyduck.calladapter.networkresult.getOrThrow -import com.keylesspalace.tusky.R -import com.keylesspalace.tusky.appstore.BlockEvent -import com.keylesspalace.tusky.appstore.EventHub -import com.keylesspalace.tusky.appstore.MuteConversationEvent -import com.keylesspalace.tusky.appstore.MuteEvent -import com.keylesspalace.tusky.appstore.PreferenceChangedEvent -import com.keylesspalace.tusky.components.timeline.util.ifExpected -import com.keylesspalace.tusky.db.AccountManager -import com.keylesspalace.tusky.entity.Notification -import com.keylesspalace.tusky.entity.Poll -import com.keylesspalace.tusky.settings.PrefKeys -import com.keylesspalace.tusky.usecase.TimelineCases -import com.keylesspalace.tusky.util.StatusDisplayOptions -import com.keylesspalace.tusky.util.deserialize -import com.keylesspalace.tusky.util.serialize -import com.keylesspalace.tusky.util.throttleFirst -import com.keylesspalace.tusky.util.toViewData -import com.keylesspalace.tusky.viewdata.NotificationViewData -import com.keylesspalace.tusky.viewdata.StatusViewData -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.flow.filterIsInstance -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.getAndUpdate -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.onStart -import kotlinx.coroutines.flow.receiveAsFlow -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.launch -import kotlinx.coroutines.rx3.await -import retrofit2.HttpException -import javax.inject.Inject -import kotlin.time.Duration.Companion.milliseconds -import kotlin.time.ExperimentalTime - -data class UiState( - /** Filtered notification types */ - val activeFilter: Set = emptySet(), - - /** True if the UI to filter and clear notifications should be shown */ - val showFilterOptions: Boolean = false, - - /** True if the FAB should be shown while scrolling */ - val showFabWhileScrolling: Boolean = true -) - -/** Preferences the UI reacts to */ -data class UiPrefs( - val showFabWhileScrolling: Boolean, - val showFilter: Boolean -) { - companion object { - /** Relevant preference keys. Changes to any of these trigger a display update */ - val prefKeys = setOf( - PrefKeys.FAB_HIDE, - PrefKeys.SHOW_NOTIFICATIONS_FILTER - ) - } -} - -/** Parent class for all UI actions, fallible or infallible. */ -sealed class UiAction - -/** Actions the user can trigger from the UI. These actions may fail. */ -sealed class FallibleUiAction : UiAction() { - /** Clear all notifications */ - object ClearNotifications : FallibleUiAction() -} - -/** - * Actions the user can trigger from the UI that either cannot fail, or if they do fail, - * do not show an error. - */ -sealed class InfallibleUiAction : UiAction() { - /** Apply a new filter to the notification list */ - // This saves the list to the local database, which triggers a refresh of the data. - // Saving the data can't fail, which is why this is infallible. Refreshing the - // data may fail, but that's handled by the paging system / adapter refresh logic. - data class ApplyFilter(val filter: Set) : InfallibleUiAction() - - /** - * User is leaving the fragment, save the ID of the visible notification. - * - * Infallible because if it fails there's nowhere to show the error, and nothing the user - * can do. - */ - data class SaveVisibleId(val visibleId: String) : InfallibleUiAction() - - /** Ignore the saved reading position, load the page with the newest items */ - // Resets the account's `lastNotificationId`, which can't fail, which is why this is - // infallible. Reloading the data may fail, but that's handled by the paging system / - // adapter refresh logic. - object LoadNewest : InfallibleUiAction() -} - -/** Actions the user can trigger on an individual notification. These may fail. */ -sealed class NotificationAction : FallibleUiAction() { - data class AcceptFollowRequest(val accountId: String) : NotificationAction() - - data class RejectFollowRequest(val accountId: String) : NotificationAction() -} - -sealed class UiSuccess { - // These three are from menu items on the status. Currently they don't come to the - // viewModel as actions, they're noticed when events are posted. That will change, - // but for the moment we can still report them to the UI. Typically, receiving any - // of these three should trigger the UI to refresh. - - /** A user was blocked */ - object Block : UiSuccess() - - /** A user was muted */ - object Mute : UiSuccess() - - /** A conversation was muted */ - object MuteConversation : UiSuccess() -} - -/** The result of a successful action on a notification */ -sealed class NotificationActionSuccess( - /** String resource with an error message to show the user */ - @StringRes val msg: Int, - - /** - * The original action, in case additional information is required from it to display the - * message. - */ - open val action: NotificationAction -) : UiSuccess() { - data class AcceptFollowRequest(override val action: NotificationAction) : - NotificationActionSuccess(R.string.ui_success_accepted_follow_request, action) - data class RejectFollowRequest(override val action: NotificationAction) : - NotificationActionSuccess(R.string.ui_success_rejected_follow_request, action) - - companion object { - fun from(action: NotificationAction) = when (action) { - is NotificationAction.AcceptFollowRequest -> AcceptFollowRequest(action) - is NotificationAction.RejectFollowRequest -> RejectFollowRequest(action) - } - } -} - -/** Actions the user can trigger on an individual status */ -sealed class StatusAction( - open val statusViewData: StatusViewData.Concrete -) : FallibleUiAction() { - /** Set the bookmark state for a status */ - data class Bookmark(val state: Boolean, override val statusViewData: StatusViewData.Concrete) : - StatusAction(statusViewData) - - /** Set the favourite state for a status */ - data class Favourite(val state: Boolean, override val statusViewData: StatusViewData.Concrete) : - StatusAction(statusViewData) - - /** Set the reblog state for a status */ - data class Reblog(val state: Boolean, override val statusViewData: StatusViewData.Concrete) : - StatusAction(statusViewData) - - /** Vote in a poll */ - data class VoteInPoll( - val poll: Poll, - val choices: List, - override val statusViewData: StatusViewData.Concrete - ) : StatusAction(statusViewData) -} - -/** Changes to a status' visible state after API calls */ -sealed class StatusActionSuccess(open val action: StatusAction) : UiSuccess() { - data class Bookmark(override val action: StatusAction.Bookmark) : - StatusActionSuccess(action) - - data class Favourite(override val action: StatusAction.Favourite) : - StatusActionSuccess(action) - - data class Reblog(override val action: StatusAction.Reblog) : - StatusActionSuccess(action) - - data class VoteInPoll(override val action: StatusAction.VoteInPoll) : - StatusActionSuccess(action) - - companion object { - fun from(action: StatusAction) = when (action) { - is StatusAction.Bookmark -> Bookmark(action) - is StatusAction.Favourite -> Favourite(action) - is StatusAction.Reblog -> Reblog(action) - is StatusAction.VoteInPoll -> VoteInPoll(action) - } - } -} - -/** Errors from fallible view model actions that the UI will need to show */ -sealed class UiError( - /** The exception associated with the error */ - open val throwable: Throwable, - - /** String resource with an error message to show the user */ - @StringRes val message: Int, - - /** The action that failed. Can be resent to retry the action */ - open val action: UiAction? = null -) { - data class ClearNotifications(override val throwable: Throwable) : UiError( - throwable, - R.string.ui_error_clear_notifications - ) - - data class Bookmark( - override val throwable: Throwable, - override val action: StatusAction.Bookmark - ) : UiError(throwable, R.string.ui_error_bookmark, action) - - data class Favourite( - override val throwable: Throwable, - override val action: StatusAction.Favourite - ) : UiError(throwable, R.string.ui_error_favourite, action) - - data class Reblog( - override val throwable: Throwable, - override val action: StatusAction.Reblog - ) : UiError(throwable, R.string.ui_error_reblog, action) - - data class VoteInPoll( - override val throwable: Throwable, - override val action: StatusAction.VoteInPoll - ) : UiError(throwable, R.string.ui_error_vote, action) - - data class AcceptFollowRequest( - override val throwable: Throwable, - override val action: NotificationAction.AcceptFollowRequest - ) : UiError(throwable, R.string.ui_error_accept_follow_request, action) - - data class RejectFollowRequest( - override val throwable: Throwable, - override val action: NotificationAction.RejectFollowRequest - ) : UiError(throwable, R.string.ui_error_reject_follow_request, action) - - companion object { - fun make(throwable: Throwable, action: FallibleUiAction) = when (action) { - is StatusAction.Bookmark -> Bookmark(throwable, action) - is StatusAction.Favourite -> Favourite(throwable, action) - is StatusAction.Reblog -> Reblog(throwable, action) - is StatusAction.VoteInPoll -> VoteInPoll(throwable, action) - is NotificationAction.AcceptFollowRequest -> AcceptFollowRequest(throwable, action) - is NotificationAction.RejectFollowRequest -> RejectFollowRequest(throwable, action) - FallibleUiAction.ClearNotifications -> ClearNotifications(throwable) - } - } -} - -@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class, ExperimentalTime::class) -class NotificationsViewModel @Inject constructor( - private val repository: NotificationsRepository, - private val preferences: SharedPreferences, - private val accountManager: AccountManager, - private val timelineCases: TimelineCases, - private val eventHub: EventHub -) : ViewModel() { - /** The account to display notifications for */ - val account = accountManager.activeAccount!! - - val uiState: StateFlow - - /** Flow of changes to statusDisplayOptions, for use by the UI */ - val statusDisplayOptions: StateFlow - - val pagingData: Flow> - - /** Flow of user actions received from the UI */ - private val uiAction = MutableSharedFlow() - - /** Flow that can be used to trigger a full reload */ - private val reload = MutableStateFlow(0) - - /** Flow of successful action results */ - // Note: This is a SharedFlow instead of a StateFlow because success state does not need to be - // retained. A message is shown once to a user and then dismissed. Re-collecting the flow - // (e.g., after a device orientation change) should not re-show the most recent success - // message, as it will be confusing to the user. - val uiSuccess = MutableSharedFlow() - - /** Channel for error results */ - // Errors are sent to a channel to ensure that any errors that occur *before* there are any - // subscribers are retained. If this was a SharedFlow any errors would be dropped, and if it - // was a StateFlow any errors would be retained, and there would need to be an explicit - // mechanism to dismiss them. - private val _uiErrorChannel = Channel() - - /** Expose UI errors as a flow */ - val uiError = _uiErrorChannel.receiveAsFlow() - - /** Accept UI actions in to actionStateFlow */ - val accept: (UiAction) -> Unit = { action -> - viewModelScope.launch { uiAction.emit(action) } - } - - init { - // Handle changes to notification filters - val notificationFilter = uiAction - .filterIsInstance() - .distinctUntilChanged() - // Save each change back to the active account - .onEach { action -> - Log.d(TAG, "notificationFilter: $action") - account.notificationsFilter = serialize(action.filter) - accountManager.saveAccount(account) - } - // Load the initial filter from the active account - .onStart { - emit( - InfallibleUiAction.ApplyFilter( - filter = deserialize(account.notificationsFilter) - ) - ) - } - - // Reset the last notification ID to "0" to fetch the newest notifications, and - // increment `reload` to trigger creation of a new PagingSource. - viewModelScope.launch { - uiAction - .filterIsInstance() - .collectLatest { - account.lastNotificationId = "0" - accountManager.saveAccount(account) - reload.getAndUpdate { it + 1 } - } - } - - // Save the visible notification ID - viewModelScope.launch { - uiAction - .filterIsInstance() - .distinctUntilChanged() - .collectLatest { action -> - Log.d(TAG, "Saving visible ID: ${action.visibleId}, active account = ${account.id}") - account.lastNotificationId = action.visibleId - accountManager.saveAccount(account) - } - } - - // Set initial status display options from the user's preferences. - // - // Then collect future preference changes and emit new values in to - // statusDisplayOptions if necessary. - statusDisplayOptions = MutableStateFlow( - StatusDisplayOptions.from( - preferences, - account - ) - ) - - viewModelScope.launch { - eventHub.events - .filterIsInstance() - .filter { StatusDisplayOptions.prefKeys.contains(it.preferenceKey) } - .map { - statusDisplayOptions.value.make( - preferences, - it.preferenceKey, - account - ) - } - .collect { - statusDisplayOptions.emit(it) - } - } - - // Handle UiAction.ClearNotifications - viewModelScope.launch { - uiAction.filterIsInstance() - .collectLatest { - try { - repository.clearNotifications().apply { - if (this.isSuccessful) { - repository.invalidate() - } else { - _uiErrorChannel.send(UiError.make(HttpException(this), it)) - } - } - } catch (e: Exception) { - ifExpected(e) { _uiErrorChannel.send(UiError.make(e, it)) } - } - } - } - - // Handle NotificationAction.* - viewModelScope.launch { - uiAction.filterIsInstance() - .throttleFirst(THROTTLE_TIMEOUT) - .collect { action -> - try { - when (action) { - is NotificationAction.AcceptFollowRequest -> - timelineCases.acceptFollowRequest(action.accountId).await() - is NotificationAction.RejectFollowRequest -> - timelineCases.rejectFollowRequest(action.accountId).await() - } - uiSuccess.emit(NotificationActionSuccess.from(action)) - } catch (e: Exception) { - ifExpected(e) { _uiErrorChannel.send(UiError.make(e, action)) } - } - } - } - - // Handle StatusAction.* - viewModelScope.launch { - uiAction.filterIsInstance() - .throttleFirst(THROTTLE_TIMEOUT) // avoid double-taps - .collect { action -> - try { - when (action) { - is StatusAction.Bookmark -> - timelineCases.bookmark( - action.statusViewData.actionableId, - action.state - ) - is StatusAction.Favourite -> - timelineCases.favourite( - action.statusViewData.actionableId, - action.state - ) - is StatusAction.Reblog -> - timelineCases.reblog( - action.statusViewData.actionableId, - action.state - ) - is StatusAction.VoteInPoll -> - timelineCases.voteInPoll( - action.statusViewData.actionableId, - action.poll.id, - action.choices - ) - }.getOrThrow() - uiSuccess.emit(StatusActionSuccess.from(action)) - } catch (t: Throwable) { - _uiErrorChannel.send(UiError.make(t, action)) - } - } - } - - // Handle events that should refresh the list - viewModelScope.launch { - eventHub.events.collectLatest { - when (it) { - is BlockEvent -> uiSuccess.emit(UiSuccess.Block) - is MuteEvent -> uiSuccess.emit(UiSuccess.Mute) - is MuteConversationEvent -> uiSuccess.emit(UiSuccess.MuteConversation) - } - } - } - - // Re-fetch notifications if either of `notificationFilter` or `reload` flows have - // new items. - pagingData = combine(notificationFilter, reload) { action, _ -> action } - .flatMapLatest { action -> - getNotifications(filters = action.filter, initialKey = getInitialKey()) - }.cachedIn(viewModelScope) - - uiState = combine(notificationFilter, getUiPrefs()) { filter, prefs -> - UiState( - activeFilter = filter.filter, - showFilterOptions = prefs.showFilter, - showFabWhileScrolling = prefs.showFabWhileScrolling - ) - }.stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000), - initialValue = UiState() - ) - } - - private fun getNotifications( - filters: Set, - initialKey: String? = null - ): Flow> { - return repository.getNotificationsStream(filter = filters, initialKey = initialKey) - .map { pagingData -> - pagingData.map { notification -> - notification.toViewData( - isShowingContent = statusDisplayOptions.value.showSensitiveMedia || - !(notification.status?.actionableStatus?.sensitive ?: false), - isExpanded = statusDisplayOptions.value.openSpoiler, - isCollapsed = true - ) - } - } - } - - // The database stores "0" as the last notification ID if notifications have not been - // fetched. Convert to null to ensure a full fetch in this case - private fun getInitialKey(): String? { - val initialKey = when (val id = account.lastNotificationId) { - "0" -> null - else -> id - } - Log.d(TAG, "Restoring at $initialKey") - return initialKey - } - - /** - * @return Flow of relevant preferences that change the UI - */ - // TODO: Preferences should be in a repository - private fun getUiPrefs() = eventHub.events - .filterIsInstance() - .filter { UiPrefs.prefKeys.contains(it.preferenceKey) } - .map { toPrefs() } - .onStart { emit(toPrefs()) } - - private fun toPrefs() = UiPrefs( - showFabWhileScrolling = !preferences.getBoolean(PrefKeys.FAB_HIDE, false), - showFilter = preferences.getBoolean(PrefKeys.SHOW_NOTIFICATIONS_FILTER, true) - ) - - companion object { - private const val TAG = "NotificationsViewModel" - private val THROTTLE_TIMEOUT = 500.milliseconds - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/PushNotificationHelper.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/PushNotificationHelper.kt index f61307e3e..c89823e6d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/PushNotificationHelper.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/PushNotificationHelper.kt @@ -66,7 +66,9 @@ fun showMigrationNoticeIfNecessary( Snackbar.make(parent, R.string.tips_push_notification_migration, Snackbar.LENGTH_INDEFINITE) .setAnchorView(anchorView) - .setAction(R.string.action_details) { showMigrationExplanationDialog(context, accountManager) } + .setAction( + R.string.action_details + ) { showMigrationExplanationDialog(context, accountManager) } .show() } @@ -75,7 +77,9 @@ private fun showMigrationExplanationDialog(context: Context, accountManager: Acc if (currentAccountNeedsMigration(accountManager)) { setMessage(R.string.dialog_push_notification_migration) setPositiveButton(R.string.title_migration_relogin) { _, _ -> - context.startActivity(LoginActivity.getIntent(context, LoginActivity.MODE_MIGRATION)) + context.startActivity( + LoginActivity.getIntent(context, LoginActivity.MODE_MIGRATION) + ) } } else { setMessage(R.string.dialog_push_notification_migration_other_accounts) @@ -89,12 +93,21 @@ private fun showMigrationExplanationDialog(context: Context, accountManager: Acc } } -private suspend fun enableUnifiedPushNotificationsForAccount(context: Context, api: MastodonApi, accountManager: AccountManager, account: AccountEntity) { +private suspend fun enableUnifiedPushNotificationsForAccount( + context: Context, + api: MastodonApi, + accountManager: AccountManager, + account: AccountEntity +) { if (isUnifiedPushNotificationEnabledForAccount(account)) { // Already registered, update the subscription to match notification settings updateUnifiedPushSubscription(context, api, accountManager, account) } else { - UnifiedPush.registerAppWithDialog(context, account.id.toString(), features = arrayListOf(UnifiedPush.FEATURE_BYTES_MESSAGE)) + UnifiedPush.registerAppWithDialog( + context, + account.id.toString(), + features = arrayListOf(UnifiedPush.FEATURE_BYTES_MESSAGE) + ) } } @@ -116,7 +129,11 @@ private fun isUnifiedPushAvailable(context: Context): Boolean = fun canEnablePushNotifications(context: Context, accountManager: AccountManager): Boolean = isUnifiedPushAvailable(context) && !anyAccountNeedsMigration(accountManager) -suspend fun enablePushNotificationsWithFallback(context: Context, api: MastodonApi, accountManager: AccountManager) { +suspend fun enablePushNotificationsWithFallback( + context: Context, + api: MastodonApi, + accountManager: AccountManager +) { if (!canEnablePushNotifications(context, accountManager)) { // No UP distributors NotificationHelper.enablePullNotifications(context) @@ -151,9 +168,14 @@ fun disableAllNotifications(context: Context, accountManager: AccountManager) { private fun buildSubscriptionData(context: Context, account: AccountEntity): Map = buildMap { - val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + val notificationManager = context.getSystemService( + Context.NOTIFICATION_SERVICE + ) as NotificationManager Notification.Type.visibleTypes.forEach { - put("data[alerts][${it.presentation}]", NotificationHelper.filterNotification(notificationManager, account, it)) + put( + "data[alerts][${it.presentation}]", + NotificationHelper.filterNotification(notificationManager, account, it) + ) } } @@ -196,7 +218,12 @@ suspend fun registerUnifiedPushEndpoint( } // Synchronize the enabled / disabled state of notifications with server-side subscription -suspend fun updateUnifiedPushSubscription(context: Context, api: MastodonApi, accountManager: AccountManager, account: AccountEntity) { +suspend fun updateUnifiedPushSubscription( + context: Context, + api: MastodonApi, + accountManager: AccountManager, + account: AccountEntity +) { withContext(Dispatchers.IO) { api.updatePushNotificationSubscription( "Bearer ${account.accessToken}", @@ -211,7 +238,11 @@ suspend fun updateUnifiedPushSubscription(context: Context, api: MastodonApi, ac } } -suspend fun unregisterUnifiedPushEndpoint(api: MastodonApi, accountManager: AccountManager, account: AccountEntity) { +suspend fun unregisterUnifiedPushEndpoint( + api: MastodonApi, + accountManager: AccountManager, + account: AccountEntity +) { withContext(Dispatchers.IO) { api.unsubscribePushNotifications("Bearer ${account.accessToken}", account.domain) .onFailure { throwable -> diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/StatusNotificationViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/StatusNotificationViewHolder.kt deleted file mode 100644 index c433e73c9..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/StatusNotificationViewHolder.kt +++ /dev/null @@ -1,387 +0,0 @@ -/* - * Copyright 2023 Tusky Contributors - * - * This file is a part of Tusky. - * - * 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. - * - * Tusky 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 Tusky; if not, - * see . - */ - -package com.keylesspalace.tusky.components.notifications - -import android.content.Context -import android.graphics.PorterDuff -import android.graphics.Typeface -import android.graphics.drawable.Drawable -import android.text.InputFilter -import android.text.SpannableStringBuilder -import android.text.Spanned -import android.text.TextUtils -import android.text.format.DateUtils -import android.text.style.StyleSpan -import android.view.View -import androidx.annotation.ColorRes -import androidx.annotation.DrawableRes -import androidx.core.content.ContextCompat -import androidx.recyclerview.widget.RecyclerView -import at.connyduck.sparkbutton.helpers.Utils -import com.bumptech.glide.Glide -import com.keylesspalace.tusky.R -import com.keylesspalace.tusky.adapter.StatusBaseViewHolder -import com.keylesspalace.tusky.databinding.ItemStatusNotificationBinding -import com.keylesspalace.tusky.entity.Emoji -import com.keylesspalace.tusky.entity.Notification -import com.keylesspalace.tusky.interfaces.LinkListener -import com.keylesspalace.tusky.interfaces.StatusActionListener -import com.keylesspalace.tusky.util.AbsoluteTimeFormatter -import com.keylesspalace.tusky.util.SmartLengthInputFilter -import com.keylesspalace.tusky.util.StatusDisplayOptions -import com.keylesspalace.tusky.util.emojify -import com.keylesspalace.tusky.util.getRelativeTimeSpanString -import com.keylesspalace.tusky.util.loadAvatar -import com.keylesspalace.tusky.util.setClickableText -import com.keylesspalace.tusky.util.unicodeWrap -import com.keylesspalace.tusky.viewdata.NotificationViewData -import com.keylesspalace.tusky.viewdata.StatusViewData -import java.util.Date - -/** - * View holder for a status with an activity to be notified about (posted, boosted, - * favourited, or edited, per [NotificationViewKind.from]). - * - * Shows a line with the activity, and who initiated the activity. Clicking this should - * go to the profile page for the initiator. - * - * Displays the original status below that. Clicking this should go to the original - * status in context. - */ -internal class StatusNotificationViewHolder( - private val binding: ItemStatusNotificationBinding, - private val statusActionListener: StatusActionListener, - private val notificationActionListener: NotificationActionListener, - private val absoluteTimeFormatter: AbsoluteTimeFormatter -) : NotificationsPagingAdapter.ViewHolder, RecyclerView.ViewHolder(binding.root) { - private val avatarRadius48dp = itemView.context.resources.getDimensionPixelSize( - R.dimen.avatar_radius_48dp - ) - private val avatarRadius36dp = itemView.context.resources.getDimensionPixelSize( - R.dimen.avatar_radius_36dp - ) - private val avatarRadius24dp = itemView.context.resources.getDimensionPixelSize( - R.dimen.avatar_radius_24dp - ) - - override fun bind( - viewData: NotificationViewData, - payloads: List<*>?, - statusDisplayOptions: StatusDisplayOptions - ) { - val statusViewData = viewData.statusViewData - if (payloads.isNullOrEmpty()) { - // Hide null statuses. Shouldn't happen according to the spec, but some servers - // have been seen to do this (https://github.com/tuskyapp/Tusky/issues/2252) - if (statusViewData == null) { - showNotificationContent(false) - } else { - showNotificationContent(true) - val (_, _, account, _, _, _, _, createdAt) = statusViewData.actionable - setDisplayName(account.name, account.emojis, statusDisplayOptions.animateEmojis) - setUsername(account.username) - setCreatedAt(createdAt, statusDisplayOptions.useAbsoluteTime) - if (viewData.type == Notification.Type.STATUS || - viewData.type == Notification.Type.UPDATE - ) { - setAvatar( - account.avatar, - account.bot, - statusDisplayOptions.animateAvatars, - statusDisplayOptions.showBotOverlay - ) - } else { - setAvatars( - account.avatar, - viewData.account.avatar, - statusDisplayOptions.animateAvatars - ) - } - - binding.notificationContainer.setOnClickListener { - notificationActionListener.onViewThreadForStatus(statusViewData.status) - } - binding.notificationContent.setOnClickListener { - notificationActionListener.onViewThreadForStatus(statusViewData.status) - } - binding.notificationTopText.setOnClickListener { - notificationActionListener.onViewAccount(viewData.account.id) - } - } - setMessage(viewData, statusActionListener, statusDisplayOptions.animateEmojis) - } else { - for (item in payloads) { - if (StatusBaseViewHolder.Key.KEY_CREATED == item && statusViewData != null) { - setCreatedAt( - statusViewData.status.actionableStatus.createdAt, - statusDisplayOptions.useAbsoluteTime - ) - } - } - } - } - - private fun showNotificationContent(show: Boolean) { - binding.statusDisplayName.visibility = if (show) View.VISIBLE else View.GONE - binding.statusUsername.visibility = if (show) View.VISIBLE else View.GONE - binding.statusMetaInfo.visibility = if (show) View.VISIBLE else View.GONE - binding.notificationContentWarningDescription.visibility = - if (show) View.VISIBLE else View.GONE - binding.notificationContentWarningButton.visibility = - if (show) View.VISIBLE else View.GONE - binding.notificationContent.visibility = if (show) View.VISIBLE else View.GONE - binding.notificationStatusAvatar.visibility = if (show) View.VISIBLE else View.GONE - binding.notificationNotificationAvatar.visibility = if (show) View.VISIBLE else View.GONE - } - - private fun setDisplayName(name: String, emojis: List?, animateEmojis: Boolean) { - val emojifiedName = name.emojify(emojis, binding.statusDisplayName, animateEmojis) - binding.statusDisplayName.text = emojifiedName - } - - private fun setUsername(name: String) { - val context = binding.statusUsername.context - val format = context.getString(R.string.post_username_format) - val usernameText = String.format(format, name) - binding.statusUsername.text = usernameText - } - - private fun setCreatedAt(createdAt: Date?, useAbsoluteTime: Boolean) { - if (useAbsoluteTime) { - binding.statusMetaInfo.text = absoluteTimeFormatter.format(createdAt, true) - } else { - // This is the visible timestampInfo. - val readout: String - /* This one is for screen-readers. Frequently, they would mispronounce timestamps like "17m" - * as 17 meters instead of minutes. */ - val readoutAloud: CharSequence - if (createdAt != null) { - val then = createdAt.time - val now = Date().time - readout = getRelativeTimeSpanString(binding.statusMetaInfo.context, then, now) - readoutAloud = DateUtils.getRelativeTimeSpanString( - then, - now, - DateUtils.SECOND_IN_MILLIS, - DateUtils.FORMAT_ABBREV_RELATIVE - ) - } else { - // unknown minutes~ - readout = "?m" - readoutAloud = "? minutes" - } - binding.statusMetaInfo.text = readout - binding.statusMetaInfo.contentDescription = readoutAloud - } - } - - private fun getIconWithColor( - context: Context, - @DrawableRes drawable: Int, - @ColorRes color: Int - ): Drawable? { - val icon = ContextCompat.getDrawable(context, drawable) - icon?.setColorFilter(context.getColor(color), PorterDuff.Mode.SRC_ATOP) - return icon - } - - private fun setAvatar(statusAvatarUrl: String?, isBot: Boolean, animateAvatars: Boolean, showBotOverlay: Boolean) { - binding.notificationStatusAvatar.setPaddingRelative(0, 0, 0, 0) - loadAvatar( - statusAvatarUrl, - binding.notificationStatusAvatar, - avatarRadius48dp, - animateAvatars - ) - if (showBotOverlay && isBot) { - binding.notificationNotificationAvatar.visibility = View.VISIBLE - Glide.with(binding.notificationNotificationAvatar) - .load(R.drawable.bot_badge) - .into(binding.notificationNotificationAvatar) - } else { - binding.notificationNotificationAvatar.visibility = View.GONE - } - } - - private fun setAvatars(statusAvatarUrl: String?, notificationAvatarUrl: String?, animateAvatars: Boolean) { - val padding = Utils.dpToPx(binding.notificationStatusAvatar.context, 12) - binding.notificationStatusAvatar.setPaddingRelative(0, 0, padding, padding) - loadAvatar( - statusAvatarUrl, - binding.notificationStatusAvatar, - avatarRadius36dp, - animateAvatars - ) - binding.notificationNotificationAvatar.visibility = View.VISIBLE - loadAvatar( - notificationAvatarUrl, - binding.notificationNotificationAvatar, - avatarRadius24dp, - animateAvatars - ) - } - - fun setMessage( - notificationViewData: NotificationViewData, - listener: LinkListener, - animateEmojis: Boolean - ) { - val statusViewData = notificationViewData.statusViewData - val displayName = notificationViewData.account.name.unicodeWrap() - val type = notificationViewData.type - val context = binding.notificationTopText.context - val format: String - val icon: Drawable? - when (type) { - Notification.Type.FAVOURITE -> { - icon = getIconWithColor(context, R.drawable.ic_star_24dp, R.color.tusky_orange) - format = context.getString(R.string.notification_favourite_format) - } - Notification.Type.REBLOG -> { - icon = getIconWithColor(context, R.drawable.ic_repeat_24dp, R.color.chinwag_green) - format = context.getString(R.string.notification_reblog_format) - } - Notification.Type.STATUS -> { - icon = getIconWithColor(context, R.drawable.ic_home_24dp, R.color.chinwag_green) - format = context.getString(R.string.notification_subscription_format) - } - Notification.Type.UPDATE -> { - icon = getIconWithColor(context, R.drawable.ic_edit_24dp, R.color.chinwag_green) - format = context.getString(R.string.notification_update_format) - } - else -> { - icon = getIconWithColor(context, R.drawable.ic_star_24dp, R.color.tusky_orange) - format = context.getString(R.string.notification_favourite_format) - } - } - binding.notificationTopText.setCompoundDrawablesWithIntrinsicBounds( - icon, - null, - null, - null - ) - val wholeMessage = String.format(format, displayName) - val str = SpannableStringBuilder(wholeMessage) - val displayNameIndex = format.indexOf("%s") - str.setSpan( - StyleSpan(Typeface.BOLD), - displayNameIndex, - displayNameIndex + displayName.length, - Spanned.SPAN_EXCLUSIVE_EXCLUSIVE - ) - val emojifiedText = str.emojify( - notificationViewData.account.emojis, - binding.notificationTopText, - animateEmojis - ) - binding.notificationTopText.text = emojifiedText - if (statusViewData != null) { - val hasSpoiler = !TextUtils.isEmpty(statusViewData.status.spoilerText) - binding.notificationContentWarningDescription.visibility = - if (hasSpoiler) View.VISIBLE else View.GONE - binding.notificationContentWarningButton.visibility = - if (hasSpoiler) View.VISIBLE else View.GONE - if (statusViewData.isExpanded) { - binding.notificationContentWarningButton.setText( - R.string.post_content_warning_show_less - ) - } else { - binding.notificationContentWarningButton.setText( - R.string.post_content_warning_show_more - ) - } - binding.notificationContentWarningButton.setOnClickListener { - if (bindingAdapterPosition != RecyclerView.NO_POSITION) { - notificationActionListener.onExpandedChange( - !statusViewData.isExpanded, - bindingAdapterPosition - ) - } - binding.notificationContent.visibility = - if (statusViewData.isExpanded) View.GONE else View.VISIBLE - } - setupContentAndSpoiler(listener, statusViewData, animateEmojis) - } - } - - private fun setupContentAndSpoiler( - listener: LinkListener, - statusViewData: StatusViewData.Concrete, - animateEmojis: Boolean - ) { - val shouldShowContentIfSpoiler = statusViewData.isExpanded - val hasSpoiler = !TextUtils.isEmpty(statusViewData.status.spoilerText) - if (!shouldShowContentIfSpoiler && hasSpoiler) { - binding.notificationContent.visibility = View.GONE - } else { - binding.notificationContent.visibility = View.VISIBLE - } - val content = statusViewData.content - val emojis = statusViewData.actionable.emojis - if (statusViewData.isCollapsible && (statusViewData.isExpanded || !hasSpoiler)) { - binding.buttonToggleNotificationContent.setOnClickListener { - val position = bindingAdapterPosition - if (position != RecyclerView.NO_POSITION) { - notificationActionListener.onNotificationContentCollapsedChange( - !statusViewData.isCollapsed, - position - ) - } - } - binding.buttonToggleNotificationContent.visibility = View.VISIBLE - if (statusViewData.isCollapsed) { - binding.buttonToggleNotificationContent.setText( - R.string.post_content_warning_show_more - ) - binding.notificationContent.filters = COLLAPSE_INPUT_FILTER - } else { - binding.buttonToggleNotificationContent.setText( - R.string.post_content_warning_show_less - ) - binding.notificationContent.filters = NO_INPUT_FILTER - } - } else { - binding.buttonToggleNotificationContent.visibility = View.GONE - binding.notificationContent.filters = NO_INPUT_FILTER - } - val emojifiedText = - content.emojify( - emojis, - binding.notificationContent, - animateEmojis - ) - setClickableText( - binding.notificationContent, - emojifiedText, - statusViewData.actionable.mentions, - statusViewData.actionable.tags, - listener - ) - val emojifiedContentWarning: CharSequence = statusViewData.spoilerText.emojify( - statusViewData.actionable.emojis, - binding.notificationContentWarningDescription, - animateEmojis - ) - binding.notificationContentWarningDescription.text = emojifiedContentWarning - } - - companion object { - private val COLLAPSE_INPUT_FILTER = arrayOf(SmartLengthInputFilter) - private val NO_INPUT_FILTER = arrayOfNulls(0) - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/StatusViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/StatusViewHolder.kt deleted file mode 100644 index c719c084a..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/StatusViewHolder.kt +++ /dev/null @@ -1,60 +0,0 @@ -/* - * Copyright 2023 Tusky Contributors - * - * This file is a part of Tusky. - * - * 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. - * - * Tusky 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 Tusky; if not, - * see . - */ - -package com.keylesspalace.tusky.components.notifications - -import com.keylesspalace.tusky.adapter.StatusViewHolder -import com.keylesspalace.tusky.databinding.ItemStatusBinding -import com.keylesspalace.tusky.entity.Notification -import com.keylesspalace.tusky.interfaces.StatusActionListener -import com.keylesspalace.tusky.util.StatusDisplayOptions -import com.keylesspalace.tusky.viewdata.NotificationViewData - -internal class StatusViewHolder( - binding: ItemStatusBinding, - private val statusActionListener: StatusActionListener, - private val accountId: String -) : NotificationsPagingAdapter.ViewHolder, StatusViewHolder(binding.root) { - - override fun bind( - viewData: NotificationViewData, - payloads: List<*>?, - statusDisplayOptions: StatusDisplayOptions - ) { - val statusViewData = viewData.statusViewData - if (statusViewData == null) { - // Hide null statuses. Shouldn't happen according to the spec, but some servers - // have been seen to do this (https://github.com/tuskyapp/Tusky/issues/2252) - showStatusContent(false) - } else { - if (payloads.isNullOrEmpty()) { - showStatusContent(true) - } - setupWithStatus( - statusViewData, - statusActionListener, - statusDisplayOptions, - payloads?.firstOrNull() - ) - } - if (viewData.type == Notification.Type.POLL) { - setPollInfo(accountId == viewData.account.id) - } else { - hideStatusInfo() - } - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/preference/AccountPreferencesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/preference/AccountPreferencesFragment.kt index b0fedb2b1..b9f77a1db 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/preference/AccountPreferencesFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/preference/AccountPreferencesFragment.kt @@ -30,9 +30,9 @@ import com.keylesspalace.tusky.R import com.keylesspalace.tusky.TabPreferenceActivity import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.components.accountlist.AccountListActivity +import com.keylesspalace.tusky.components.domainblocks.DomainBlocksActivity import com.keylesspalace.tusky.components.filters.FiltersActivity import com.keylesspalace.tusky.components.followedtags.FollowedTagsActivity -import com.keylesspalace.tusky.components.instancemute.InstanceListActivity import com.keylesspalace.tusky.components.login.LoginActivity import com.keylesspalace.tusky.components.notifications.currentAccountNeedsMigration import com.keylesspalace.tusky.db.AccountManager @@ -51,15 +51,16 @@ import com.keylesspalace.tusky.util.getInitialLanguages import com.keylesspalace.tusky.util.getLocaleList import com.keylesspalace.tusky.util.getTuskyDisplayName import com.keylesspalace.tusky.util.makeIcon +import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation import com.keylesspalace.tusky.util.unsafeLazy import com.mikepenz.iconics.IconicsDrawable import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial import com.mikepenz.iconics.utils.colorInt import com.mikepenz.iconics.utils.sizeRes +import javax.inject.Inject import retrofit2.Call import retrofit2.Callback import retrofit2.Response -import javax.inject.Inject class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable { @Inject @@ -74,7 +75,11 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable { @Inject lateinit var accountPreferenceDataStore: AccountPreferenceDataStore - private val iconSize by unsafeLazy { resources.getDimensionPixelSize(R.dimen.preference_icon_size) } + private val iconSize by unsafeLazy { + resources.getDimensionPixelSize( + R.dimen.preference_icon_size + ) + } override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { val context = requireContext() @@ -96,11 +101,7 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable { setIcon(R.drawable.ic_tabs) setOnPreferenceClickListener { val intent = Intent(context, TabPreferenceActivity::class.java) - activity?.startActivity(intent) - activity?.overridePendingTransition( - R.anim.slide_from_right, - R.anim.slide_to_left - ) + activity?.startActivityWithSlideInAnimation(intent) true } } @@ -110,11 +111,7 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable { setIcon(R.drawable.ic_hashtag) setOnPreferenceClickListener { val intent = Intent(context, FollowedTagsActivity::class.java) - activity?.startActivity(intent) - activity?.overridePendingTransition( - R.anim.slide_from_right, - R.anim.slide_to_left - ) + activity?.startActivityWithSlideInAnimation(intent) true } } @@ -125,11 +122,7 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable { setOnPreferenceClickListener { val intent = Intent(context, AccountListActivity::class.java) intent.putExtra("type", AccountListActivity.Type.MUTES) - activity?.startActivity(intent) - activity?.overridePendingTransition( - R.anim.slide_from_right, - R.anim.slide_to_left - ) + activity?.startActivityWithSlideInAnimation(intent) true } } @@ -143,11 +136,7 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable { setOnPreferenceClickListener { val intent = Intent(context, AccountListActivity::class.java) intent.putExtra("type", AccountListActivity.Type.BLOCKS) - activity?.startActivity(intent) - activity?.overridePendingTransition( - R.anim.slide_from_right, - R.anim.slide_to_left - ) + activity?.startActivityWithSlideInAnimation(intent) true } } @@ -156,12 +145,8 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable { setTitle(R.string.title_domain_mutes) setIcon(R.drawable.ic_mute_24dp) setOnPreferenceClickListener { - val intent = Intent(context, InstanceListActivity::class.java) - activity?.startActivity(intent) - activity?.overridePendingTransition( - R.anim.slide_from_right, - R.anim.slide_to_left - ) + val intent = Intent(context, DomainBlocksActivity::class.java) + activity?.startActivityWithSlideInAnimation(intent) true } } @@ -172,7 +157,7 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable { setIcon(R.drawable.ic_logout) setOnPreferenceClickListener { val intent = LoginActivity.getIntent(context, LoginActivity.MODE_MIGRATION) - (activity as BaseActivity).startActivityWithSlideInAnimation(intent) + activity?.startActivityWithSlideInAnimation(intent) true } } @@ -195,17 +180,20 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable { key = PrefKeys.DEFAULT_POST_PRIVACY setSummaryProvider { entry } val visibility = accountManager.activeAccount?.defaultPostPrivacy ?: Status.Visibility.PUBLIC - value = visibility.serverString() + value = visibility.serverString setIcon(getIconForVisibility(visibility)) setOnPreferenceChangeListener { _, newValue -> - setIcon(getIconForVisibility(Status.Visibility.byString(newValue as String))) + setIcon( + getIconForVisibility(Status.Visibility.byString(newValue as String)) + ) syncWithServer(visibility = newValue) true } } listPreference { - val locales = getLocaleList(getInitialLanguages(null, accountManager.activeAccount)) + val locales = + getLocaleList(getInitialLanguages(null, accountManager.activeAccount)) setTitle(R.string.pref_default_post_language) // Explicitly add "System default" to the start of the list entries = ( @@ -267,6 +255,12 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable { preferenceDataStore = accountPreferenceDataStore } } + preferenceCategory(R.string.pref_title_per_timeline_preferences) { + preference { + setTitle(R.string.pref_title_post_tabs) + fragment = TabFilterPreferencesFragment::class.qualifiedName + } + } } } @@ -283,14 +277,20 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable { startActivity(intent) } else { activity?.let { - val intent = PreferencesActivity.newIntent(it, PreferencesActivity.NOTIFICATION_PREFERENCES) - it.startActivity(intent) - it.overridePendingTransition(R.anim.slide_from_right, R.anim.slide_to_left) + val intent = PreferencesActivity.newIntent( + it, + PreferencesActivity.NOTIFICATION_PREFERENCES + ) + it.startActivityWithSlideInAnimation(intent) } } } - private fun syncWithServer(visibility: String? = null, sensitive: Boolean? = null, language: String? = null) { + private fun syncWithServer( + visibility: String? = null, + sensitive: Boolean? = null, + language: String? = null + ) { // TODO these could also be "datastore backed" preferences (a ServerPreferenceDataStore); follow-up of issue #3204 mastodonApi.accountUpdateSource(visibility, sensitive, language) @@ -348,8 +348,7 @@ class AccountPreferencesFragment : PreferenceFragmentCompat(), Injectable { private fun launchFilterActivity() { val intent = Intent(context, FiltersActivity::class.java) - activity?.startActivity(intent) - activity?.overridePendingTransition(R.anim.slide_from_right, R.anim.slide_to_left) + (activity as? BaseActivity)?.startActivityWithSlideInAnimation(intent) } companion object { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesActivity.kt index b47df1596..3e82f01d2 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesActivity.kt @@ -33,14 +33,16 @@ import com.keylesspalace.tusky.R import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.PreferenceChangedEvent import com.keylesspalace.tusky.databinding.ActivityPreferencesBinding +import com.keylesspalace.tusky.settings.AppTheme import com.keylesspalace.tusky.settings.PrefKeys -import com.keylesspalace.tusky.util.APP_THEME_DEFAULT +import com.keylesspalace.tusky.settings.PrefKeys.APP_THEME import com.keylesspalace.tusky.util.getNonNullString import com.keylesspalace.tusky.util.setAppNightMode +import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation import dagger.android.DispatchingAndroidInjector import dagger.android.HasAndroidInjector -import kotlinx.coroutines.launch import javax.inject.Inject +import kotlinx.coroutines.launch class PreferencesActivity : BaseActivity(), @@ -113,10 +115,10 @@ class PreferencesActivity : fragment.setTargetFragment(caller, 0) supportFragmentManager.commit { setCustomAnimations( - R.anim.slide_from_right, - R.anim.slide_to_left, - R.anim.slide_from_left, - R.anim.slide_to_right + R.anim.activity_open_enter, + R.anim.activity_open_exit, + R.anim.activity_close_enter, + R.anim.activity_close_exit ) replace(R.id.fragment_container, fragment) addToBackStack(null) @@ -126,16 +128,16 @@ class PreferencesActivity : override fun onResume() { super.onResume() - PreferenceManager.getDefaultSharedPreferences(this).registerOnSharedPreferenceChangeListener(this) + PreferenceManager.getDefaultSharedPreferences( + this + ).registerOnSharedPreferenceChangeListener(this) } override fun onPause() { super.onPause() - PreferenceManager.getDefaultSharedPreferences(this).unregisterOnSharedPreferenceChangeListener(this) - } - - private fun saveInstanceState(outState: Bundle) { - outState.putBoolean(EXTRA_RESTART_ON_BACK, restartActivitiesOnBackPressedCallback.isEnabled) + PreferenceManager.getDefaultSharedPreferences( + this + ).unregisterOnSharedPreferenceChangeListener(this) } override fun onSaveInstanceState(outState: Bundle) { @@ -143,23 +145,25 @@ class PreferencesActivity : super.onSaveInstanceState(outState) } - override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) { + override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) { + sharedPreferences ?: return + key ?: return when (key) { - "appTheme" -> { - val theme = sharedPreferences.getNonNullString("appTheme", APP_THEME_DEFAULT) + APP_THEME -> { + val theme = sharedPreferences.getNonNullString(APP_THEME, AppTheme.DEFAULT.value) Log.d("activeTheme", theme) setAppNightMode(theme) restartActivitiesOnBackPressedCallback.isEnabled = true - this.restartCurrentActivity() + this.recreate() } PrefKeys.UI_TEXT_SCALE_RATIO -> { restartActivitiesOnBackPressedCallback.isEnabled = true - this.restartCurrentActivity() + this.recreate() } - "statusTextSize", "absoluteTimeView", "showBotOverlay", "animateGifAvatars", "useBlurhash", - "showSelfUsername", "showCardsInTimelines", "confirmReblogs", "confirmFavourites", - "enableSwipeForTabs", "mainNavPosition", PrefKeys.HIDE_TOP_TOOLBAR, PrefKeys.SHOW_STATS_INLINE -> { + PrefKeys.STATUS_TEXT_SIZE, PrefKeys.ABSOLUTE_TIME_VIEW, PrefKeys.SHOW_BOT_OVERLAY, PrefKeys.ANIMATE_GIF_AVATARS, PrefKeys.USE_BLURHASH, + PrefKeys.SHOW_SELF_USERNAME, PrefKeys.SHOW_CARDS_IN_TIMELINES, PrefKeys.CONFIRM_REBLOGS, PrefKeys.CONFIRM_FAVOURITES, + PrefKeys.ENABLE_SWIPE_FOR_TABS, PrefKeys.MAIN_NAV_POSITION, PrefKeys.HIDE_TOP_TOOLBAR, PrefKeys.SHOW_STATS_INLINE -> { restartActivitiesOnBackPressedCallback.isEnabled = true } } @@ -168,16 +172,6 @@ class PreferencesActivity : } } - private fun restartCurrentActivity() { - intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK - val savedInstanceState = Bundle() - saveInstanceState(savedInstanceState) - intent.putExtras(savedInstanceState) - startActivityWithSlideInAnimation(intent) - finish() - overridePendingTransition(R.anim.fade_in, R.anim.fade_out) - } - override fun androidInjector() = androidInjector companion object { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesFragment.kt index b07de0918..a7dc4e92e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesFragment.kt @@ -49,7 +49,11 @@ class PreferencesFragment : PreferenceFragmentCompat(), Injectable { @Inject lateinit var localeManager: LocaleManager - private val iconSize by unsafeLazy { resources.getDimensionPixelSize(R.dimen.preference_icon_size) } + private val iconSize by unsafeLazy { + resources.getDimensionPixelSize( + R.dimen.preference_icon_size + ) + } enum class ReadingOrder { /** User scrolls up, reading statuses oldest to newest */ @@ -75,7 +79,7 @@ class PreferencesFragment : PreferenceFragmentCompat(), Injectable { makePreferenceScreen { preferenceCategory(R.string.pref_title_appearance_settings) { listPreference { - setDefaultValue(AppTheme.AUTO_SYSTEM.value) + setDefaultValue(AppTheme.DEFAULT.value) setEntries(R.array.app_theme_names) entryValues = AppTheme.stringValues() key = PrefKeys.APP_THEME @@ -209,7 +213,7 @@ class PreferencesFragment : PreferenceFragmentCompat(), Injectable { } switchPreference { - setDefaultValue(false) + setDefaultValue(true) key = PrefKeys.SHOW_NOTIFICATIONS_FILTER setTitle(R.string.pref_title_show_notifications_filter) isSingleLineTitle = false @@ -253,13 +257,6 @@ class PreferencesFragment : PreferenceFragmentCompat(), Injectable { } } - preferenceCategory(R.string.pref_title_timeline_filters) { - preference { - setTitle(R.string.pref_title_post_tabs) - fragment = TabFilterPreferencesFragment::class.qualifiedName - } - } - preferenceCategory(R.string.pref_title_wellbeing_mode) { switchPreference { title = getString(R.string.limit_notifications) @@ -267,7 +264,9 @@ class PreferencesFragment : PreferenceFragmentCompat(), Injectable { key = PrefKeys.WELLBEING_LIMITED_NOTIFICATIONS setOnPreferenceChangeListener { _, value -> for (account in accountManager.accounts) { - val notificationFilter = deserialize(account.notificationsFilter).toMutableSet() + val notificationFilter = deserialize( + account.notificationsFilter + ).toMutableSet() if (value == true) { notificationFilter.add(Notification.Type.FAVOURITE) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/preference/ProxyPreferencesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/preference/ProxyPreferencesFragment.kt index da63db127..84f1336f1 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/preference/ProxyPreferencesFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/preference/ProxyPreferencesFragment.kt @@ -59,7 +59,10 @@ class ProxyPreferencesFragment : PreferenceFragmentCompat() { MAX_PROXY_PORT ) - validatedEditTextPreference(portErrorMessage, ProxyConfiguration::isValidProxyPort) { + validatedEditTextPreference( + portErrorMessage, + ProxyConfiguration::isValidProxyPort + ) { setTitle(R.string.pref_title_http_proxy_port) key = PrefKeys.HTTP_PROXY_PORT isIconSpaceReserved = false diff --git a/app/src/main/java/com/keylesspalace/tusky/components/preference/TabFilterPreferencesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/preference/TabFilterPreferencesFragment.kt index 023905291..93fe05cd0 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/preference/TabFilterPreferencesFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/preference/TabFilterPreferencesFragment.kt @@ -16,32 +16,62 @@ package com.keylesspalace.tusky.components.preference import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup import androidx.preference.PreferenceFragmentCompat +import com.google.android.material.color.MaterialColors import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.di.Injectable +import com.keylesspalace.tusky.settings.AccountPreferenceDataStore import com.keylesspalace.tusky.settings.PrefKeys -import com.keylesspalace.tusky.settings.checkBoxPreference import com.keylesspalace.tusky.settings.makePreferenceScreen import com.keylesspalace.tusky.settings.preferenceCategory +import com.keylesspalace.tusky.settings.switchPreference +import javax.inject.Inject + +class TabFilterPreferencesFragment : PreferenceFragmentCompat(), Injectable { + + @Inject + lateinit var accountPreferenceDataStore: AccountPreferenceDataStore + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + // Give view a background color so transitions show up correctly + return super.onCreateView(inflater, container, savedInstanceState).also { view -> + view.setBackgroundColor(MaterialColors.getColor(view, android.R.attr.colorBackground)) + } + } -class TabFilterPreferencesFragment : PreferenceFragmentCompat() { override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { makePreferenceScreen { preferenceCategory(R.string.title_home) { category -> category.isIconSpaceReserved = false - checkBoxPreference { + switchPreference { setTitle(R.string.pref_title_show_boosts) key = PrefKeys.TAB_FILTER_HOME_BOOSTS - setDefaultValue(true) + preferenceDataStore = accountPreferenceDataStore isIconSpaceReserved = false } - checkBoxPreference { + switchPreference { setTitle(R.string.pref_title_show_replies) key = PrefKeys.TAB_FILTER_HOME_REPLIES - setDefaultValue(true) + preferenceDataStore = accountPreferenceDataStore isIconSpaceReserved = false } + + switchPreference { + setTitle(R.string.pref_title_show_self_boosts) + setSummary(R.string.pref_title_show_self_boosts_description) + key = PrefKeys.TAB_SHOW_HOME_SELF_BOOSTS + preferenceDataStore = accountPreferenceDataStore + isIconSpaceReserved = false + }.apply { dependency = PrefKeys.TAB_FILTER_HOME_BOOSTS } } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/report/ReportActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/report/ReportActivity.kt index 6f1ee6dc1..72ebbd203 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/report/ReportActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/report/ReportActivity.kt @@ -19,6 +19,7 @@ import android.content.Context import android.content.Intent import android.os.Bundle import androidx.activity.viewModels +import androidx.lifecycle.lifecycleScope import com.keylesspalace.tusky.BottomSheetActivity import com.keylesspalace.tusky.R import com.keylesspalace.tusky.components.report.adapter.ReportPagerAdapter @@ -28,6 +29,7 @@ import com.keylesspalace.tusky.util.viewBinding import dagger.android.DispatchingAndroidInjector import dagger.android.HasAndroidInjector import javax.inject.Inject +import kotlinx.coroutines.launch class ReportActivity : BottomSheetActivity(), HasAndroidInjector { @@ -46,7 +48,9 @@ class ReportActivity : BottomSheetActivity(), HasAndroidInjector { val accountId = intent?.getStringExtra(ACCOUNT_ID) val accountUserName = intent?.getStringExtra(ACCOUNT_USERNAME) if (accountId.isNullOrBlank() || accountUserName.isNullOrBlank()) { - throw IllegalStateException("accountId ($accountId) or accountUserName ($accountUserName) is null") + throw IllegalStateException( + "accountId ($accountId) or accountUserName ($accountUserName) is null" + ) } viewModel.init(accountId, accountUserName, intent?.getStringExtra(STATUS_ID)) @@ -80,8 +84,9 @@ class ReportActivity : BottomSheetActivity(), HasAndroidInjector { } private fun subscribeObservables() { - viewModel.navigation.observe(this) { screen -> - if (screen != null) { + lifecycleScope.launch { + viewModel.navigation.collect { screen -> + if (screen == null) return@collect viewModel.navigated() when (screen) { Screen.Statuses -> showStatusesPage() @@ -93,10 +98,12 @@ class ReportActivity : BottomSheetActivity(), HasAndroidInjector { } } - viewModel.checkUrl.observe(this) { - if (!it.isNullOrBlank()) { - viewModel.urlChecked() - viewUrl(it) + lifecycleScope.launch { + viewModel.checkUrl.collect { + if (!it.isNullOrBlank()) { + viewModel.urlChecked() + viewUrl(it) + } } } } @@ -130,13 +137,17 @@ class ReportActivity : BottomSheetActivity(), HasAndroidInjector { private const val STATUS_ID = "status_id" @JvmStatic - fun getIntent(context: Context, accountId: String, userName: String, statusId: String? = null) = - Intent(context, ReportActivity::class.java) - .apply { - putExtra(ACCOUNT_ID, accountId) - putExtra(ACCOUNT_USERNAME, userName) - putExtra(STATUS_ID, statusId) - } + fun getIntent( + context: Context, + accountId: String, + userName: String, + statusId: String? = null + ) = Intent(context, ReportActivity::class.java) + .apply { + putExtra(ACCOUNT_ID, accountId) + putExtra(ACCOUNT_USERNAME, userName) + putExtra(STATUS_ID, statusId) + } } override fun androidInjector() = androidInjector diff --git a/app/src/main/java/com/keylesspalace/tusky/components/report/ReportViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/report/ReportViewModel.kt index f30726a23..b2b84d5ae 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/report/ReportViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/report/ReportViewModel.kt @@ -15,8 +15,6 @@ package com.keylesspalace.tusky.components.report -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.paging.Pager @@ -37,32 +35,35 @@ import com.keylesspalace.tusky.util.Loading import com.keylesspalace.tusky.util.Resource import com.keylesspalace.tusky.util.Success import com.keylesspalace.tusky.util.toViewData +import javax.inject.Inject import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch -import javax.inject.Inject class ReportViewModel @Inject constructor( private val mastodonApi: MastodonApi, private val eventHub: EventHub ) : ViewModel() { - private val navigationMutable = MutableLiveData() - val navigation: LiveData = navigationMutable + private val navigationMutable = MutableStateFlow(null as Screen?) + val navigation: StateFlow = navigationMutable.asStateFlow() - private val muteStateMutable = MutableLiveData>() - val muteState: LiveData> = muteStateMutable + private val muteStateMutable = MutableStateFlow(null as Resource?) + val muteState: StateFlow?> = muteStateMutable.asStateFlow() - private val blockStateMutable = MutableLiveData>() - val blockState: LiveData> = blockStateMutable + private val blockStateMutable = MutableStateFlow(null as Resource?) + val blockState: StateFlow?> = blockStateMutable.asStateFlow() - private val reportingStateMutable = MutableLiveData>() - var reportingState: LiveData> = reportingStateMutable + private val reportingStateMutable = MutableStateFlow(null as Resource?) + var reportingState: StateFlow?> = reportingStateMutable.asStateFlow() - private val checkUrlMutable = MutableLiveData() - val checkUrl: LiveData = checkUrlMutable + private val checkUrlMutable = MutableStateFlow(null as String?) + val checkUrl: StateFlow = checkUrlMutable.asStateFlow() private val accountIdFlow = MutableSharedFlow( replay = 1, @@ -196,7 +197,12 @@ class ReportViewModel @Inject constructor( fun doReport() { reportingStateMutable.value = Loading() viewModelScope.launch { - mastodonApi.report(accountId, selectedIds.toList(), reportNote, if (isRemoteAccount) isRemoteNotify else null) + mastodonApi.report( + accountId, + selectedIds.toList(), + reportNote, + if (isRemoteAccount) isRemoteNotify else null + ) .fold({ reportingStateMutable.value = Success(true) }, { error -> diff --git a/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusViewHolder.kt index 0ee7ec5db..f8db53107 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusViewHolder.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusViewHolder.kt @@ -50,7 +50,9 @@ class StatusViewHolder( private val getStatusForPosition: (Int) -> StatusViewData.Concrete? ) : RecyclerView.ViewHolder(binding.root) { - private val mediaViewHeight = itemView.context.resources.getDimensionPixelSize(R.dimen.status_media_preview_height) + private val mediaViewHeight = itemView.context.resources.getDimensionPixelSize( + R.dimen.status_media_preview_height + ) private val statusViewHelper = StatusViewHelper(itemView) private val absoluteTimeFormatter = AbsoluteTimeFormatter() @@ -93,7 +95,11 @@ class StatusViewHolder( mediaViewHeight ) - statusViewHelper.setupPollReadonly(viewData.status.poll.toViewData(), viewData.status.emojis, statusDisplayOptions) + statusViewHelper.setupPollReadonly( + viewData.status.poll.toViewData(), + viewData.status.emojis, + statusDisplayOptions + ) setCreatedAt(viewData.status.createdAt) } @@ -103,15 +109,26 @@ class StatusViewHolder( shouldTrimStatus(viewdata.content), viewState.isCollapsed(viewdata.id, true), viewState.isContentShow(viewdata.id, viewdata.status.sensitive), - viewdata.spoilerText + viewdata.status.spoilerText ) - if (viewdata.spoilerText.isBlank()) { - setTextVisible(true, viewdata.content, viewdata.status.mentions, viewdata.status.tags, viewdata.status.emojis, adapterHandler) + if (viewdata.status.spoilerText.isBlank()) { + setTextVisible( + true, + viewdata.content, + viewdata.status.mentions, + viewdata.status.tags, + viewdata.status.emojis, + adapterHandler + ) binding.statusContentWarningButton.hide() binding.statusContentWarningDescription.hide() } else { - val emojiSpoiler = viewdata.spoilerText.emojify(viewdata.status.emojis, binding.statusContentWarningDescription, statusDisplayOptions.animateEmojis) + val emojiSpoiler = viewdata.status.spoilerText.emojify( + viewdata.status.emojis, + binding.statusContentWarningDescription, + statusDisplayOptions.animateEmojis + ) binding.statusContentWarningDescription.text = emojiSpoiler binding.statusContentWarningDescription.show() binding.statusContentWarningButton.show() @@ -121,11 +138,25 @@ class StatusViewHolder( val contentShown = viewState.isContentShow(viewdata.id, true) binding.statusContentWarningDescription.invalidate() viewState.setContentShow(viewdata.id, !contentShown) - setTextVisible(!contentShown, viewdata.content, viewdata.status.mentions, viewdata.status.tags, viewdata.status.emojis, adapterHandler) + setTextVisible( + !contentShown, + viewdata.content, + viewdata.status.mentions, + viewdata.status.tags, + viewdata.status.emojis, + adapterHandler + ) setContentWarningButtonText(!contentShown) } } - setTextVisible(viewState.isContentShow(viewdata.id, true), viewdata.content, viewdata.status.mentions, viewdata.status.tags, viewdata.status.emojis, adapterHandler) + setTextVisible( + viewState.isContentShow(viewdata.id, true), + viewdata.content, + viewdata.status.mentions, + viewdata.status.tags, + viewdata.status.emojis, + adapterHandler + ) } } } @@ -147,7 +178,11 @@ class StatusViewHolder( listener: LinkListener ) { if (expanded) { - val emojifiedText = content.emojify(emojis, binding.statusContent, statusDisplayOptions.animateEmojis) + val emojifiedText = content.emojify( + emojis, + binding.statusContent, + statusDisplayOptions.animateEmojis + ) setClickableText(binding.statusContent, emojifiedText, mentions, tags, listener) } else { setClickableMentions(binding.statusContent, mentions, listener) @@ -174,7 +209,12 @@ class StatusViewHolder( } } - private fun setupCollapsedState(collapsible: Boolean, collapsed: Boolean, expanded: Boolean, spoilerText: String) { + private fun setupCollapsedState( + collapsible: Boolean, + collapsed: Boolean, + expanded: Boolean, + spoilerText: String + ) { /* input filter for TextViews have to be set before text */ if (collapsible && (expanded || TextUtils.isEmpty(spoilerText))) { binding.buttonToggleContent.setOnClickListener { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusesAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusesAdapter.kt index 7e2c24174..db5c9bb96 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusesAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusesAdapter.kt @@ -36,7 +36,11 @@ class StatusesAdapter( } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): StatusViewHolder { - val binding = ItemReportStatusBinding.inflate(LayoutInflater.from(parent.context), parent, false) + val binding = ItemReportStatusBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) return StatusViewHolder( binding, statusDisplayOptions, @@ -54,11 +58,15 @@ class StatusesAdapter( companion object { val STATUS_COMPARATOR = object : DiffUtil.ItemCallback() { - override fun areContentsTheSame(oldItem: StatusViewData.Concrete, newItem: StatusViewData.Concrete): Boolean = - oldItem == newItem + override fun areContentsTheSame( + oldItem: StatusViewData.Concrete, + newItem: StatusViewData.Concrete + ): Boolean = oldItem == newItem - override fun areItemsTheSame(oldItem: StatusViewData.Concrete, newItem: StatusViewData.Concrete): Boolean = - oldItem.id == newItem.id + override fun areItemsTheSame( + oldItem: StatusViewData.Concrete, + newItem: StatusViewData.Concrete + ): Boolean = oldItem.id == newItem.id } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusesPagingSource.kt b/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusesPagingSource.kt index c007239d8..9b9238ed5 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusesPagingSource.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusesPagingSource.kt @@ -18,12 +18,11 @@ package com.keylesspalace.tusky.components.report.adapter import android.util.Log import androidx.paging.PagingSource import androidx.paging.PagingState +import at.connyduck.calladapter.networkresult.getOrThrow import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.network.MastodonApi -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async -import kotlinx.coroutines.rx3.await -import kotlinx.coroutines.withContext +import kotlinx.coroutines.coroutineScope class StatusesPagingSource( private val accountId: String, @@ -40,9 +39,12 @@ class StatusesPagingSource( val key = params.key try { val result = if (params is LoadParams.Refresh && key != null) { - withContext(Dispatchers.IO) { + // Use coroutineScope to ensure that one failed call will cancel the other one + // and the source Exception will be propagated locally. + coroutineScope { val initialStatus = async { getSingleStatus(key) } - val additionalStatuses = async { getStatusList(maxId = key, limit = params.loadSize - 1) } + val additionalStatuses = + async { getStatusList(maxId = key, limit = params.loadSize - 1) } listOf(initialStatus.await()) + additionalStatuses.await() } } else { @@ -72,17 +74,21 @@ class StatusesPagingSource( } private suspend fun getSingleStatus(statusId: String): Status { - return mastodonApi.statusObservable(statusId).await() + return mastodonApi.status(statusId).getOrThrow() } - private suspend fun getStatusList(minId: String? = null, maxId: String? = null, limit: Int): List { - return mastodonApi.accountStatusesObservable( + private suspend fun getStatusList( + minId: String? = null, + maxId: String? = null, + limit: Int + ): List { + return mastodonApi.accountStatuses( accountId = accountId, maxId = maxId, sinceId = null, minId = minId, limit = limit, excludeReblogs = true - ).await() + ).getOrThrow() } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportDoneFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportDoneFragment.kt index 0f8065776..2b5ed2628 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportDoneFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportDoneFragment.kt @@ -19,6 +19,7 @@ import android.os.Bundle import android.view.View import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels +import androidx.lifecycle.lifecycleScope import com.keylesspalace.tusky.R import com.keylesspalace.tusky.components.report.ReportViewModel import com.keylesspalace.tusky.components.report.Screen @@ -30,6 +31,7 @@ import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.viewBinding import javax.inject.Inject +import kotlinx.coroutines.launch class ReportDoneFragment : Fragment(R.layout.fragment_report_done), Injectable { @@ -47,37 +49,43 @@ class ReportDoneFragment : Fragment(R.layout.fragment_report_done), Injectable { } private fun subscribeObservables() { - viewModel.muteState.observe(viewLifecycleOwner) { - if (it !is Loading) { - binding.buttonMute.show() - binding.progressMute.show() - } else { - binding.buttonMute.hide() - binding.progressMute.hide() - } - - binding.buttonMute.setText( - when (it.data) { - true -> R.string.action_unmute - else -> R.string.action_mute + viewLifecycleOwner.lifecycleScope.launch { + viewModel.muteState.collect { + if (it == null) return@collect + if (it !is Loading) { + binding.buttonMute.show() + binding.progressMute.show() + } else { + binding.buttonMute.hide() + binding.progressMute.hide() } - ) + + binding.buttonMute.setText( + when (it.data) { + true -> R.string.action_unmute + else -> R.string.action_mute + } + ) + } } - viewModel.blockState.observe(viewLifecycleOwner) { - if (it !is Loading) { - binding.buttonBlock.show() - binding.progressBlock.show() - } else { - binding.buttonBlock.hide() - binding.progressBlock.hide() - } - binding.buttonBlock.setText( - when (it.data) { - true -> R.string.action_unblock - else -> R.string.action_block + viewLifecycleOwner.lifecycleScope.launch { + viewModel.blockState.collect { + if (it == null) return@collect + if (it !is Loading) { + binding.buttonBlock.show() + binding.progressBlock.show() + } else { + binding.buttonBlock.hide() + binding.progressBlock.hide() } - ) + binding.buttonBlock.setText( + when (it.data) { + true -> R.string.action_unblock + else -> R.string.action_block + } + ) + } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportNoteFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportNoteFragment.kt index d77685e64..215414ff9 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportNoteFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportNoteFragment.kt @@ -20,6 +20,7 @@ import android.view.View import androidx.core.widget.doAfterTextChanged import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels +import androidx.lifecycle.lifecycleScope import com.google.android.material.snackbar.Snackbar import com.keylesspalace.tusky.R import com.keylesspalace.tusky.components.report.ReportViewModel @@ -35,6 +36,7 @@ import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.viewBinding import java.io.IOException import javax.inject.Inject +import kotlinx.coroutines.launch class ReportNoteFragment : Fragment(R.layout.fragment_report_note), Injectable { @@ -79,11 +81,14 @@ class ReportNoteFragment : Fragment(R.layout.fragment_report_note), Injectable { } private fun subscribeObservables() { - viewModel.reportingState.observe(viewLifecycleOwner) { - when (it) { - is Success -> viewModel.navigateTo(Screen.Done) - is Loading -> showLoading() - is Error -> showError(it.cause) + viewLifecycleOwner.lifecycleScope.launch { + viewModel.reportingState.collect { + if (it == null) return@collect + when (it) { + is Success -> viewModel.navigateTo(Screen.Done) + is Loading -> showLoading() + is Error -> showError(it.cause) + } } } } @@ -95,7 +100,11 @@ class ReportNoteFragment : Fragment(R.layout.fragment_report_note), Injectable { binding.buttonBack.isEnabled = true binding.progressBar.hide() - Snackbar.make(binding.buttonBack, if (error is IOException) R.string.error_network else R.string.error_generic, Snackbar.LENGTH_LONG) + Snackbar.make( + binding.buttonBack, + if (error is IOException) R.string.error_network else R.string.error_generic, + Snackbar.LENGTH_LONG + ) .setAction(R.string.action_retry) { sendReport() } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportStatusesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportStatusesFragment.kt index a1cd5fdf6..7652b8419 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportStatusesFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportStatusesFragment.kt @@ -59,9 +59,9 @@ import com.mikepenz.iconics.IconicsDrawable import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial import com.mikepenz.iconics.utils.colorInt import com.mikepenz.iconics.utils.sizeDp +import javax.inject.Inject import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch -import javax.inject.Inject class ReportStatusesFragment : Fragment(R.layout.fragment_report_statuses), @@ -93,7 +93,11 @@ class ReportStatusesFragment : if (v != null) { val url = actionable.attachments[idx].url ViewCompat.setTransitionName(v, url) - val options = ActivityOptionsCompat.makeSceneTransitionAnimation(requireActivity(), v, url) + val options = ActivityOptionsCompat.makeSceneTransitionAnimation( + requireActivity(), + v, + url + ) startActivity(intent, options.toBundle()) } else { startActivity(intent) @@ -149,12 +153,12 @@ class ReportStatusesFragment : val statusDisplayOptions = StatusDisplayOptions( animateAvatars = false, mediaPreviewEnabled = accountManager.activeAccount?.mediaPreviewEnabled ?: true, - useAbsoluteTime = preferences.getBoolean("absoluteTimeView", false), + useAbsoluteTime = preferences.getBoolean(PrefKeys.ABSOLUTE_TIME_VIEW, false), showBotOverlay = false, - useBlurhash = preferences.getBoolean("useBlurhash", true), + useBlurhash = preferences.getBoolean(PrefKeys.USE_BLURHASH, true), cardViewMode = CardViewMode.NONE, - confirmReblogs = preferences.getBoolean("confirmReblogs", true), - confirmFavourites = preferences.getBoolean("confirmFavourites", false), + confirmReblogs = preferences.getBoolean(PrefKeys.CONFIRM_REBLOGS, true), + confirmFavourites = preferences.getBoolean(PrefKeys.CONFIRM_FAVOURITES, false), hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false), animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false), showStatsInline = preferences.getBoolean(PrefKeys.SHOW_STATS_INLINE, false), @@ -164,7 +168,9 @@ class ReportStatusesFragment : adapter = StatusesAdapter(statusDisplayOptions, viewModel.statusViewState, this) - binding.recyclerView.addItemDecoration(DividerItemDecoration(requireContext(), DividerItemDecoration.VERTICAL)) + binding.recyclerView.addItemDecoration( + DividerItemDecoration(requireContext(), DividerItemDecoration.VERTICAL) + ) binding.recyclerView.layoutManager = LinearLayoutManager(requireContext()) binding.recyclerView.adapter = adapter (binding.recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false @@ -185,7 +191,9 @@ class ReportStatusesFragment : binding.progressBarBottom.visible(loadState.append == LoadState.Loading) binding.progressBarTop.visible(loadState.prepend == LoadState.Loading) - binding.progressBarLoading.visible(loadState.refresh == LoadState.Loading && !binding.swipeRefreshLayout.isRefreshing) + binding.progressBarLoading.visible( + loadState.refresh == LoadState.Loading && !binding.swipeRefreshLayout.isRefreshing + ) if (loadState.refresh != LoadState.Loading) { binding.swipeRefreshLayout.isRefreshing = false @@ -221,9 +229,13 @@ class ReportStatusesFragment : return viewModel.isStatusChecked(id) } - override fun onViewAccount(id: String) = startActivity(AccountActivity.getIntent(requireContext(), id)) + override fun onViewAccount(id: String) = startActivity( + AccountActivity.getIntent(requireContext(), id) + ) - override fun onViewTag(tag: String) = startActivity(StatusListActivity.newHashtagIntent(requireContext(), tag)) + override fun onViewTag(tag: String) = startActivity( + StatusListActivity.newHashtagIntent(requireContext(), tag) + ) override fun onViewUrl(url: String) = viewModel.checkClickedUrl(url) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/report/model/StatusViewState.kt b/app/src/main/java/com/keylesspalace/tusky/components/report/model/StatusViewState.kt index 2bcade2fe..893838c60 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/report/model/StatusViewState.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/report/model/StatusViewState.kt @@ -20,17 +20,35 @@ class StatusViewState { private val contentShownState = HashMap() private val longContentCollapsedState = HashMap() - fun isMediaShow(id: String, isSensitive: Boolean): Boolean = isStateEnabled(mediaShownState, id, !isSensitive) + fun isMediaShow(id: String, isSensitive: Boolean): Boolean = isStateEnabled( + mediaShownState, + id, + !isSensitive + ) fun setMediaShow(id: String, isShow: Boolean) = setStateEnabled(mediaShownState, id, isShow) - fun isContentShow(id: String, isSensitive: Boolean): Boolean = isStateEnabled(contentShownState, id, !isSensitive) + fun isContentShow(id: String, isSensitive: Boolean): Boolean = isStateEnabled( + contentShownState, + id, + !isSensitive + ) fun setContentShow(id: String, isShow: Boolean) = setStateEnabled(contentShownState, id, isShow) - fun isCollapsed(id: String, isCollapsed: Boolean): Boolean = isStateEnabled(longContentCollapsedState, id, isCollapsed) - fun setCollapsed(id: String, isCollapsed: Boolean) = setStateEnabled(longContentCollapsedState, id, isCollapsed) + fun isCollapsed(id: String, isCollapsed: Boolean): Boolean = isStateEnabled( + longContentCollapsedState, + id, + isCollapsed + ) + fun setCollapsed(id: String, isCollapsed: Boolean) = + setStateEnabled(longContentCollapsedState, id, isCollapsed) - private fun isStateEnabled(map: Map, id: String, def: Boolean): Boolean = map[id] - ?: def + private fun isStateEnabled(map: Map, id: String, def: Boolean): Boolean = + map[id] + ?: def - private fun setStateEnabled(map: MutableMap, id: String, state: Boolean) = map.put(id, state) + private fun setStateEnabled(map: MutableMap, id: String, state: Boolean) = + map.put( + id, + state + ) } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/scheduled/ScheduledStatusActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/scheduled/ScheduledStatusActivity.kt index f5df80515..2d3adf397 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/scheduled/ScheduledStatusActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/scheduled/ScheduledStatusActivity.kt @@ -45,9 +45,9 @@ import com.mikepenz.iconics.IconicsDrawable import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial import com.mikepenz.iconics.utils.colorInt import com.mikepenz.iconics.utils.sizeDp +import javax.inject.Inject import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch -import javax.inject.Inject class ScheduledStatusActivity : BaseActivity(), @@ -109,7 +109,10 @@ class ScheduledStatusActivity : if (loadState.refresh is LoadState.NotLoading) { binding.progressBar.hide() if (adapter.itemCount == 0) { - binding.errorMessageView.setup(R.drawable.elephant_friend_empty, R.string.no_scheduled_posts) + binding.errorMessageView.setup( + R.drawable.elephant_friend_empty, + R.string.no_scheduled_posts + ) binding.errorMessageView.show() } else { binding.errorMessageView.hide() @@ -127,7 +130,7 @@ class ScheduledStatusActivity : } override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { - menuInflater.inflate(R.menu.activity_announcements, menu) + menuInflater.inflate(R.menu.activity_scheduled_status, menu) menu.findItem(R.id.action_search)?.apply { icon = IconicsDrawable(this@ScheduledStatusActivity, GoogleMaterial.Icon.gmd_search).apply { sizeDp = 20 diff --git a/app/src/main/java/com/keylesspalace/tusky/components/scheduled/ScheduledStatusAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/scheduled/ScheduledStatusAdapter.kt index 9d51bc53c..a13a41e47 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/scheduled/ScheduledStatusAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/scheduled/ScheduledStatusAdapter.kt @@ -36,18 +36,31 @@ class ScheduledStatusAdapter( return oldItem.id == newItem.id } - override fun areContentsTheSame(oldItem: ScheduledStatus, newItem: ScheduledStatus): Boolean { + override fun areContentsTheSame( + oldItem: ScheduledStatus, + newItem: ScheduledStatus + ): Boolean { return oldItem == newItem } } ) { - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder { - val binding = ItemScheduledStatusBinding.inflate(LayoutInflater.from(parent.context), parent, false) + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int + ): BindingHolder { + val binding = ItemScheduledStatusBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) return BindingHolder(binding) } - override fun onBindViewHolder(holder: BindingHolder, position: Int) { + override fun onBindViewHolder( + holder: BindingHolder, + position: Int + ) { getItem(position)?.let { item -> holder.binding.edit.isEnabled = true holder.binding.delete.isEnabled = true diff --git a/app/src/main/java/com/keylesspalace/tusky/components/scheduled/ScheduledStatusPagingSource.kt b/app/src/main/java/com/keylesspalace/tusky/components/scheduled/ScheduledStatusPagingSource.kt index c9af661e4..57535b810 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/scheduled/ScheduledStatusPagingSource.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/scheduled/ScheduledStatusPagingSource.kt @@ -18,9 +18,9 @@ package com.keylesspalace.tusky.components.scheduled import android.util.Log import androidx.paging.PagingSource import androidx.paging.PagingState +import at.connyduck.calladapter.networkresult.getOrThrow import com.keylesspalace.tusky.entity.ScheduledStatus import com.keylesspalace.tusky.network.MastodonApi -import kotlinx.coroutines.rx3.await class ScheduledStatusPagingSourceFactory( private val mastodonApi: MastodonApi @@ -63,7 +63,7 @@ class ScheduledStatusPagingSource( val result = mastodonApi.scheduledStatuses( maxId = params.key, limit = params.loadSize - ).await() + ).getOrThrow() LoadResult.Page( data = result, diff --git a/app/src/main/java/com/keylesspalace/tusky/components/scheduled/ScheduledStatusViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/scheduled/ScheduledStatusViewModel.kt index 483c8e4df..821364b9e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/scheduled/ScheduledStatusViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/scheduled/ScheduledStatusViewModel.kt @@ -25,8 +25,8 @@ import at.connyduck.calladapter.networkresult.fold import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.entity.ScheduledStatus import com.keylesspalace.tusky.network.MastodonApi -import kotlinx.coroutines.launch import javax.inject.Inject +import kotlinx.coroutines.launch class ScheduledStatusViewModel @Inject constructor( val mastodonApi: MastodonApi, diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/SearchActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/SearchActivity.kt index 176fb8079..7cdff23fa 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/search/SearchActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/SearchActivity.kt @@ -40,7 +40,7 @@ import dagger.android.DispatchingAndroidInjector import dagger.android.HasAndroidInjector import javax.inject.Inject -class SearchActivity : BottomSheetActivity(), HasAndroidInjector, MenuProvider { +class SearchActivity : BottomSheetActivity(), HasAndroidInjector, MenuProvider, SearchView.OnQueryTextListener { @Inject lateinit var androidInjector: DispatchingAndroidInjector @@ -91,18 +91,12 @@ class SearchActivity : BottomSheetActivity(), HasAndroidInjector, MenuProvider { searchViewMenuItem.expandActionView() val searchView = searchViewMenuItem.actionView as SearchView setupSearchView(searchView) - - searchView.setQuery(viewModel.currentQuery, false) } override fun onMenuItemSelected(menuItem: MenuItem): Boolean { return false } - override fun finish() { - super.finishWithoutSlideOutAnimation() - } - private fun getPageTitle(position: Int): CharSequence { return when (position) { 0 -> getString(R.string.title_posts) @@ -121,7 +115,13 @@ class SearchActivity : BottomSheetActivity(), HasAndroidInjector, MenuProvider { private fun setupSearchView(searchView: SearchView) { searchView.setIconifiedByDefault(false) - searchView.setSearchableInfo((getSystemService(Context.SEARCH_SERVICE) as? SearchManager)?.getSearchableInfo(componentName)) + searchView.setSearchableInfo( + ( + getSystemService( + Context.SEARCH_SERVICE + ) as? SearchManager + )?.getSearchableInfo(componentName) + ) // SearchView has a bug. If it's displayed 'app:showAsAction="always"' it's too wide, // pushing other icons (including the options menu '...' icon) off the edge of the @@ -150,9 +150,23 @@ class SearchActivity : BottomSheetActivity(), HasAndroidInjector, MenuProvider { val pxBuffer = ((48 * 2) * resources.displayMetrics.density).toInt() searchView.maxWidth = pxScreenWidth - pxBuffer + // Keep text that was entered also when switching to a different tab (before the search is executed) + searchView.setOnQueryTextListener(this) + searchView.setQuery(viewModel.currentSearchFieldContent ?: "", false) + searchView.requestFocus() } + override fun onQueryTextSubmit(query: String?): Boolean { + return false + } + + override fun onQueryTextChange(newText: String?): Boolean { + viewModel.currentSearchFieldContent = newText + + return false + } + override fun androidInjector() = androidInjector companion object { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/SearchViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/SearchViewModel.kt index a43776ddc..ba075449e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/search/SearchViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/SearchViewModel.kt @@ -23,7 +23,9 @@ import androidx.paging.PagingConfig import androidx.paging.cachedIn import at.connyduck.calladapter.networkresult.NetworkResult import at.connyduck.calladapter.networkresult.fold +import at.connyduck.calladapter.networkresult.map import at.connyduck.calladapter.networkresult.onFailure +import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository import com.keylesspalace.tusky.components.search.adapter.SearchPagingSourceFactory import com.keylesspalace.tusky.db.AccountEntity import com.keylesspalace.tusky.db.AccountManager @@ -33,18 +35,25 @@ import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.usecase.TimelineCases import com.keylesspalace.tusky.util.toViewData import com.keylesspalace.tusky.viewdata.StatusViewData +import com.keylesspalace.tusky.viewdata.TranslationViewData +import javax.inject.Inject import kotlinx.coroutines.Deferred import kotlinx.coroutines.async import kotlinx.coroutines.launch -import javax.inject.Inject class SearchViewModel @Inject constructor( mastodonApi: MastodonApi, private val timelineCases: TimelineCases, - private val accountManager: AccountManager + private val accountManager: AccountManager, + private val instanceInfoRepository: InstanceInfoRepository, ) : ViewModel() { + init { + instanceInfoRepository.precache() + } + var currentQuery: String = "" + var currentSearchFieldContent: String? = null val activeAccount: AccountEntity? get() = accountManager.activeAccount @@ -55,23 +64,26 @@ class SearchViewModel @Inject constructor( private val loadedStatuses: MutableList = mutableListOf() - private val statusesPagingSourceFactory = SearchPagingSourceFactory(mastodonApi, SearchType.Status, loadedStatuses) { - it.statuses.map { status -> - status.toViewData( - isShowingContent = alwaysShowSensitiveMedia || !status.actionableStatus.sensitive, - isExpanded = alwaysOpenSpoiler, - isCollapsed = true - ) - }.apply { - loadedStatuses.addAll(this) + private val statusesPagingSourceFactory = + SearchPagingSourceFactory(mastodonApi, SearchType.Status, loadedStatuses) { + it.statuses.map { status -> + status.toViewData( + isShowingContent = alwaysShowSensitiveMedia || !status.actionableStatus.sensitive, + isExpanded = alwaysOpenSpoiler, + isCollapsed = true + ) + }.apply { + loadedStatuses.addAll(this) + } + } + private val accountsPagingSourceFactory = + SearchPagingSourceFactory(mastodonApi, SearchType.Account) { + it.accounts + } + private val hashtagsPagingSourceFactory = + SearchPagingSourceFactory(mastodonApi, SearchType.Hashtag) { + it.hashtags } - } - private val accountsPagingSourceFactory = SearchPagingSourceFactory(mastodonApi, SearchType.Account) { - it.accounts - } - private val hashtagsPagingSourceFactory = SearchPagingSourceFactory(mastodonApi, SearchType.Hashtag) { - it.hashtags - } val statusesFlow = Pager( config = PagingConfig(pageSize = DEFAULT_LOAD_SIZE, initialLoadSize = DEFAULT_LOAD_SIZE), @@ -189,6 +201,30 @@ class SearchViewModel @Inject constructor( } } + fun supportsTranslation(): Boolean = + instanceInfoRepository.cachedInstanceInfoOrFallback.translationEnabled == true + + suspend fun translate(statusViewData: StatusViewData.Concrete): NetworkResult { + updateStatusViewData(statusViewData.copy(translation = TranslationViewData.Loading)) + return timelineCases.translate(statusViewData.actionableId) + .map { translation -> + updateStatusViewData( + statusViewData.copy( + translation = TranslationViewData.Loaded( + translation + ) + ) + ) + } + .onFailure { + updateStatusViewData(statusViewData.copy(translation = null)) + } + } + + fun untranslate(statusViewData: StatusViewData.Concrete) { + updateStatusViewData(statusViewData.copy(translation = null)) + } + private fun updateStatusViewData(newStatusViewData: StatusViewData.Concrete) { val idx = loadedStatuses.indexOfFirst { it.id == newStatusViewData.id } if (idx >= 0) { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchAccountsAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchAccountsAdapter.kt index a8b913081..6efdcb364 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchAccountsAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchAccountsAdapter.kt @@ -48,11 +48,15 @@ class SearchAccountsAdapter(private val linkListener: LinkListener, private val companion object { val ACCOUNT_COMPARATOR = object : DiffUtil.ItemCallback() { - override fun areContentsTheSame(oldItem: TimelineAccount, newItem: TimelineAccount): Boolean = - oldItem == newItem + override fun areContentsTheSame( + oldItem: TimelineAccount, + newItem: TimelineAccount + ): Boolean = oldItem == newItem - override fun areItemsTheSame(oldItem: TimelineAccount, newItem: TimelineAccount): Boolean = - oldItem.id == newItem.id + override fun areItemsTheSame( + oldItem: TimelineAccount, + newItem: TimelineAccount + ): Boolean = oldItem.id == newItem.id } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchHashtagsAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchHashtagsAdapter.kt index 50bd0f933..052ccc9b9 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchHashtagsAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchHashtagsAdapter.kt @@ -27,7 +27,10 @@ import com.keylesspalace.tusky.util.BindingHolder class SearchHashtagsAdapter(private val linkListener: LinkListener) : PagingDataAdapter>(HASHTAG_COMPARATOR) { - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder { + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int + ): BindingHolder { val binding = ItemHashtagBinding.inflate(LayoutInflater.from(parent.context), parent, false) return BindingHolder(binding) } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchPagingSource.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchPagingSource.kt index 10339536a..d91f929af 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchPagingSource.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchPagingSource.kt @@ -17,10 +17,10 @@ package com.keylesspalace.tusky.components.search.adapter import androidx.paging.PagingSource import androidx.paging.PagingState +import at.connyduck.calladapter.networkresult.getOrThrow import com.keylesspalace.tusky.components.search.SearchType import com.keylesspalace.tusky.entity.SearchResult import com.keylesspalace.tusky.network.MastodonApi -import kotlinx.coroutines.rx3.await class SearchPagingSource( private val mastodonApi: MastodonApi, @@ -54,14 +54,14 @@ class SearchPagingSource( val currentKey = params.key ?: 0 try { - val data = mastodonApi.searchObservable( + val data = mastodonApi.search( query = searchRequest, type = searchType.apiParameter, resolve = true, limit = params.loadSize, offset = currentKey, following = false - ).await() + ).getOrThrow() val res = parser(data) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchStatusesAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchStatusesAdapter.kt index a7c4c90eb..1d3cabe21 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchStatusesAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchStatusesAdapter.kt @@ -45,11 +45,15 @@ class SearchStatusesAdapter( companion object { val STATUS_COMPARATOR = object : DiffUtil.ItemCallback() { - override fun areContentsTheSame(oldItem: StatusViewData.Concrete, newItem: StatusViewData.Concrete): Boolean = - oldItem == newItem + override fun areContentsTheSame( + oldItem: StatusViewData.Concrete, + newItem: StatusViewData.Concrete + ): Boolean = oldItem == newItem - override fun areItemsTheSame(oldItem: StatusViewData.Concrete, newItem: StatusViewData.Concrete): Boolean = - oldItem.id == newItem.id + override fun areItemsTheSame( + oldItem: StatusViewData.Concrete, + newItem: StatusViewData.Concrete + ): Boolean = oldItem.id == newItem.id } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchAccountsFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchAccountsFragment.kt index 1bcfaaaa0..8e1c8100f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchAccountsFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchAccountsFragment.kt @@ -38,7 +38,9 @@ class SearchAccountsFragment : SearchFragment() { } override fun createAdapter(): PagingDataAdapter { - val preferences = PreferenceManager.getDefaultSharedPreferences(binding.searchRecyclerView.context) + val preferences = PreferenceManager.getDefaultSharedPreferences( + binding.searchRecyclerView.context + ) return SearchAccountsAdapter( this, diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchFragment.kt index 4744bbb64..cb8ea8cb2 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchFragment.kt @@ -28,16 +28,17 @@ import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.interfaces.LinkListener import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.util.visible import com.mikepenz.iconics.IconicsDrawable import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial import com.mikepenz.iconics.utils.colorInt import com.mikepenz.iconics.utils.sizeDp +import javax.inject.Inject import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch -import javax.inject.Inject abstract class SearchFragment : Fragment(R.layout.fragment_search), @@ -92,8 +93,12 @@ abstract class SearchFragment : val isNewSearch = currentQuery != viewModel.currentQuery - binding.searchProgressBar.visible(loadState.refresh == LoadState.Loading && isNewSearch && !binding.swipeRefreshLayout.isRefreshing) - binding.searchRecyclerView.visible(loadState.refresh is LoadState.NotLoading || !isNewSearch || binding.swipeRefreshLayout.isRefreshing) + binding.searchProgressBar.visible( + loadState.refresh == LoadState.Loading && isNewSearch && !binding.swipeRefreshLayout.isRefreshing + ) + binding.searchRecyclerView.visible( + loadState.refresh is LoadState.NotLoading || !isNewSearch || binding.swipeRefreshLayout.isRefreshing + ) if (loadState.refresh != LoadState.Loading) { binding.swipeRefreshLayout.isRefreshing = false @@ -102,12 +107,14 @@ abstract class SearchFragment : binding.progressBarBottom.visible(loadState.append == LoadState.Loading) - binding.searchNoResultsText.visible(loadState.refresh is LoadState.NotLoading && adapter.itemCount == 0 && viewModel.currentQuery.isNotEmpty()) + binding.searchNoResultsText.visible( + loadState.refresh is LoadState.NotLoading && adapter.itemCount == 0 && viewModel.currentQuery.isNotEmpty() + ) } } override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { - menuInflater.inflate(R.menu.fragment_timeline, menu) + menuInflater.inflate(R.menu.fragment_search, menu) menu.findItem(R.id.action_refresh)?.apply { icon = IconicsDrawable(requireContext(), GoogleMaterial.Icon.gmd_refresh).apply { sizeDp = 20 @@ -147,11 +154,15 @@ abstract class SearchFragment : } override fun onViewAccount(id: String) { - bottomSheetActivity?.startActivityWithSlideInAnimation(AccountActivity.getIntent(requireContext(), id)) + bottomSheetActivity?.startActivityWithSlideInAnimation( + AccountActivity.getIntent(requireContext(), id) + ) } override fun onViewTag(tag: String) { - bottomSheetActivity?.startActivityWithSlideInAnimation(StatusListActivity.newHashtagIntent(requireContext(), tag)) + bottomSheetActivity?.startActivityWithSlideInAnimation( + StatusListActivity.newHashtagIntent(requireContext(), tag) + ) } override fun onViewUrl(url: String) { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt index 1144a06dc..abbfea9d0 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt @@ -39,6 +39,7 @@ import androidx.preference.PreferenceManager import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager import at.connyduck.calladapter.networkresult.fold +import at.connyduck.calladapter.networkresult.onFailure import com.google.android.material.snackbar.Snackbar import com.keylesspalace.tusky.BaseActivity import com.keylesspalace.tusky.R @@ -56,14 +57,17 @@ import com.keylesspalace.tusky.interfaces.AccountSelectionListener import com.keylesspalace.tusky.interfaces.StatusActionListener import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.util.CardViewMode +import com.keylesspalace.tusky.util.ListStatusAccessibilityDelegate import com.keylesspalace.tusky.util.StatusDisplayOptions import com.keylesspalace.tusky.util.openLink +import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation import com.keylesspalace.tusky.view.showMuteAccountDialog import com.keylesspalace.tusky.viewdata.AttachmentViewData import com.keylesspalace.tusky.viewdata.StatusViewData +import java.util.Locale +import javax.inject.Inject import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.launch -import javax.inject.Inject class SearchStatusesFragment : SearchFragment(), StatusActionListener { @Inject @@ -76,16 +80,18 @@ class SearchStatusesFragment : SearchFragment(), Status get() = super.adapter as SearchStatusesAdapter override fun createAdapter(): PagingDataAdapter { - val preferences = PreferenceManager.getDefaultSharedPreferences(binding.searchRecyclerView.context) + val preferences = PreferenceManager.getDefaultSharedPreferences( + binding.searchRecyclerView.context + ) val statusDisplayOptions = StatusDisplayOptions( - animateAvatars = preferences.getBoolean("animateGifAvatars", false), + animateAvatars = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false), mediaPreviewEnabled = viewModel.mediaPreviewEnabled, - useAbsoluteTime = preferences.getBoolean("absoluteTimeView", false), - showBotOverlay = preferences.getBoolean("showBotOverlay", true), - useBlurhash = preferences.getBoolean("useBlurhash", true), + useAbsoluteTime = preferences.getBoolean(PrefKeys.ABSOLUTE_TIME_VIEW, false), + showBotOverlay = preferences.getBoolean(PrefKeys.SHOW_BOT_OVERLAY, true), + useBlurhash = preferences.getBoolean(PrefKeys.USE_BLURHASH, true), cardViewMode = CardViewMode.NONE, - confirmReblogs = preferences.getBoolean("confirmReblogs", true), - confirmFavourites = preferences.getBoolean("confirmFavourites", false), + confirmReblogs = preferences.getBoolean(PrefKeys.CONFIRM_REBLOGS, true), + confirmFavourites = preferences.getBoolean(PrefKeys.CONFIRM_FAVOURITES, false), hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false), animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false), showStatsInline = preferences.getBoolean(PrefKeys.SHOW_STATS_INLINE, false), @@ -93,8 +99,24 @@ class SearchStatusesFragment : SearchFragment(), Status openSpoiler = accountManager.activeAccount!!.alwaysOpenSpoiler ) - binding.searchRecyclerView.addItemDecoration(DividerItemDecoration(binding.searchRecyclerView.context, DividerItemDecoration.VERTICAL)) - binding.searchRecyclerView.layoutManager = LinearLayoutManager(binding.searchRecyclerView.context) + binding.searchRecyclerView.setAccessibilityDelegateCompat( + ListStatusAccessibilityDelegate(binding.searchRecyclerView, this) { pos -> + if (pos in 0 until adapter.itemCount) { + adapter.peek(pos) + } else { + null + } + } + ) + + binding.searchRecyclerView.addItemDecoration( + DividerItemDecoration( + binding.searchRecyclerView.context, + DividerItemDecoration.VERTICAL + ) + ) + binding.searchRecyclerView.layoutManager = + LinearLayoutManager(binding.searchRecyclerView.context) return SearchStatusesAdapter(statusDisplayOptions, this) } @@ -123,7 +145,7 @@ class SearchStatusesFragment : SearchFragment(), Status } override fun onMore(view: View, position: Int) { - searchAdapter.peek(position)?.status?.let { + searchAdapter.peek(position)?.let { more(it, view, position) } } @@ -151,6 +173,7 @@ class SearchStatusesFragment : SearchFragment(), Status startActivity(intent) } } + Attachment.Type.UNKNOWN -> { context?.openLink(actionable.attachments[attachmentIndex].url) } @@ -207,6 +230,12 @@ class SearchStatusesFragment : SearchFragment(), Status } } + override fun onUntranslate(position: Int) { + searchAdapter.peek(position)?.let { + viewModel.untranslate(it) + } + } + companion object { fun newInstance() = SearchStatusesFragment() } @@ -236,7 +265,8 @@ class SearchStatusesFragment : SearchFragment(), Status bottomSheetActivity?.startActivityWithSlideInAnimation(intent) } - private fun more(status: Status, view: View, position: Int) { + private fun more(statusViewData: StatusViewData.Concrete, view: View, position: Int) { + val status = statusViewData.status val id = status.actionableId val accountId = status.actionableStatus.account.id val accountUsername = status.actionableStatus.account.username @@ -252,15 +282,20 @@ class SearchStatusesFragment : SearchFragment(), Status menu.findItem(R.id.status_open_as).isVisible = !statusUrl.isNullOrBlank() when (status.visibility) { Status.Visibility.PUBLIC, Status.Visibility.UNLISTED -> { - val textId = getString(if (status.isPinned()) R.string.unpin_action else R.string.pin_action) + val textId = + getString( + if (status.pinned) R.string.unpin_action else R.string.pin_action + ) menu.add(0, R.id.pin, 1, textId) } + Status.Visibility.PRIVATE -> { var reblogged = status.reblogged if (status.reblog != null) reblogged = status.reblog.reblogged menu.findItem(R.id.status_reblog_private).isVisible = !reblogged menu.findItem(R.id.status_unreblog_private).isVisible = reblogged } + Status.Visibility.UNKNOWN, Status.Visibility.DIRECT -> { } // Ignore } @@ -278,13 +313,14 @@ class SearchStatusesFragment : SearchFragment(), Status openAsItem.title = openAsText } - val mutable = statusIsByCurrentUser || accountIsInMentions(viewModel.activeAccount, status.mentions) + val mutable = + statusIsByCurrentUser || accountIsInMentions(viewModel.activeAccount, status.mentions) val muteConversationItem = popup.menu.findItem(R.id.status_mute_conversation).apply { isVisible = mutable } if (mutable) { muteConversationItem.setTitle( - if (status.muted == true) { + if (status.muted) { R.string.action_unmute_conversation } else { R.string.action_mute_conversation @@ -292,6 +328,14 @@ class SearchStatusesFragment : SearchFragment(), Status ) } + // translation not there for your own posts + popup.menu.findItem(R.id.status_translate)?.let { translateItem -> + translateItem.isVisible = + !status.language.equals(Locale.getDefault().language, ignoreCase = true) && + viewModel.supportsTranslation() + translateItem.setTitle(if (statusViewData.translation != null) R.string.action_show_original else R.string.action_translate) + } + popup.setOnMenuItemClickListener { item -> when (item.itemId) { R.id.post_share_content -> { @@ -305,72 +349,115 @@ class SearchStatusesFragment : SearchFragment(), Status statusToShare.content sendIntent.putExtra(Intent.EXTRA_TEXT, stringToShare) sendIntent.type = "text/plain" - startActivity(Intent.createChooser(sendIntent, resources.getText(R.string.send_post_content_to))) + startActivity( + Intent.createChooser( + sendIntent, + resources.getText(R.string.send_post_content_to) + ) + ) return@setOnMenuItemClickListener true } + R.id.post_share_link -> { val sendIntent = Intent() sendIntent.action = Intent.ACTION_SEND sendIntent.putExtra(Intent.EXTRA_TEXT, statusUrl) sendIntent.type = "text/plain" - startActivity(Intent.createChooser(sendIntent, resources.getText(R.string.send_post_link_to))) + startActivity( + Intent.createChooser( + sendIntent, + resources.getText(R.string.send_post_link_to) + ) + ) return@setOnMenuItemClickListener true } + R.id.status_copy_link -> { - val clipboard = requireActivity().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clipboard = requireActivity().getSystemService( + Context.CLIPBOARD_SERVICE + ) as ClipboardManager clipboard.setPrimaryClip(ClipData.newPlainText(null, statusUrl)) return@setOnMenuItemClickListener true } + R.id.status_open_as -> { showOpenAsDialog(statusUrl!!, item.title) return@setOnMenuItemClickListener true } + R.id.status_download_media -> { requestDownloadAllMedia(status) return@setOnMenuItemClickListener true } + R.id.status_mute_conversation -> { searchAdapter.peek(position)?.let { foundStatus -> - viewModel.muteConversation(foundStatus, status.muted != true) + viewModel.muteConversation(foundStatus, !status.muted) } return@setOnMenuItemClickListener true } + R.id.status_mute -> { onMute(accountId, accountUsername) return@setOnMenuItemClickListener true } + R.id.status_block -> { onBlock(accountId, accountUsername) return@setOnMenuItemClickListener true } + R.id.status_report -> { openReportPage(accountId, accountUsername, id) return@setOnMenuItemClickListener true } + R.id.status_unreblog_private -> { onReblog(false, position) return@setOnMenuItemClickListener true } + R.id.status_reblog_private -> { onReblog(true, position) return@setOnMenuItemClickListener true } + R.id.status_delete -> { showConfirmDeleteDialog(id, position) return@setOnMenuItemClickListener true } + R.id.status_delete_and_redraft -> { showConfirmEditDialog(id, position, status) return@setOnMenuItemClickListener true } + R.id.status_edit -> { editStatus(id, position, status) return@setOnMenuItemClickListener true } + R.id.pin -> { - viewModel.pinAccount(status, !status.isPinned()) + viewModel.pinAccount(status, !status.pinned) return@setOnMenuItemClickListener true } + + R.id.status_translate -> { + if (statusViewData.translation != null) { + viewModel.untranslate(statusViewData) + } else { + lifecycleScope.launch { + viewModel.translate(statusViewData) + .onFailure { + Snackbar.make( + requireView(), + getString(R.string.ui_error_translate, it.message), + Snackbar.LENGTH_LONG + ).show() + } + } + } + } } false } @@ -418,7 +505,9 @@ class SearchStatusesFragment : SearchFragment(), Status val uri = Uri.parse(url) val filename = uri.lastPathSegment - val downloadManager = requireActivity().getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager + val downloadManager = requireActivity().getSystemService( + Context.DOWNLOAD_SERVICE + ) as DownloadManager val request = DownloadManager.Request(uri) request.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, filename) downloadManager.enqueue(request) @@ -445,7 +534,9 @@ class SearchStatusesFragment : SearchFragment(), Status } private fun openReportPage(accountId: String, accountUsername: String, statusId: String) { - startActivity(ReportActivity.getIntent(requireContext(), accountId, accountUsername, statusId)) + startActivity( + ReportActivity.getIntent(requireContext(), accountId, accountUsername, statusId) + ) } private fun showConfirmDeleteDialog(id: String, position: Int) { @@ -471,7 +562,7 @@ class SearchStatusesFragment : SearchFragment(), Status { deletedStatus -> removeItem(position) - val redraftStatus = if (deletedStatus.isEmpty()) { + val redraftStatus = if (deletedStatus.isEmpty) { status.toDeletedStatus() } else { deletedStatus @@ -495,7 +586,11 @@ class SearchStatusesFragment : SearchFragment(), Status }, { error -> Log.w("SearchStatusesFragment", "error deleting status", error) - Toast.makeText(context, R.string.error_generic, Toast.LENGTH_SHORT).show() + Toast.makeText( + context, + R.string.error_generic, + Toast.LENGTH_SHORT + ).show() } ) } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt index 13486267a..b5c9bf73b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt @@ -36,16 +36,15 @@ import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.SimpleItemAnimator import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener +import at.connyduck.calladapter.networkresult.onFailure import at.connyduck.sparkbutton.helpers.Utils -import autodispose2.androidx.lifecycle.autoDispose import com.google.android.material.color.MaterialColors -import com.keylesspalace.tusky.BaseActivity +import com.google.android.material.snackbar.Snackbar import com.keylesspalace.tusky.R import com.keylesspalace.tusky.adapter.StatusBaseViewHolder import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.PreferenceChangedEvent import com.keylesspalace.tusky.appstore.StatusComposedEvent -import com.keylesspalace.tusky.appstore.StatusEditedEvent import com.keylesspalace.tusky.components.accountlist.AccountListActivity import com.keylesspalace.tusky.components.accountlist.AccountListActivity.Companion.newIntent import com.keylesspalace.tusky.components.preference.PreferencesFragment.ReadingOrder @@ -67,20 +66,20 @@ import com.keylesspalace.tusky.util.ListStatusAccessibilityDelegate import com.keylesspalace.tusky.util.StatusDisplayOptions import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.show +import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation import com.keylesspalace.tusky.util.unsafeLazy +import com.keylesspalace.tusky.util.updateRelativeTimePeriodically import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.viewdata.AttachmentViewData import com.keylesspalace.tusky.viewdata.StatusViewData +import com.keylesspalace.tusky.viewdata.TranslationViewData import com.mikepenz.iconics.IconicsDrawable import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial import com.mikepenz.iconics.utils.colorInt import com.mikepenz.iconics.utils.sizeDp -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers -import io.reactivex.rxjava3.core.Observable +import javax.inject.Inject import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch -import java.util.concurrent.TimeUnit -import javax.inject.Inject class TimelineFragment : SFragment(), @@ -233,16 +232,23 @@ class TimelineFragment : is LoadState.NotLoading -> { if (loadState.append is LoadState.NotLoading && loadState.source.refresh is LoadState.NotLoading) { binding.statusView.show() - binding.statusView.setup(R.drawable.elephant_friend_empty, R.string.message_empty) + binding.statusView.setup( + R.drawable.elephant_friend_empty, + R.string.message_empty + ) if (kind == TimelineViewModel.Kind.HOME) { binding.statusView.showHelp(R.string.help_empty_home) } } } + is LoadState.Error -> { binding.statusView.show() - binding.statusView.setup((loadState.refresh as LoadState.Error).error) { onRefresh() } + binding.statusView.setup( + (loadState.refresh as LoadState.Error).error + ) { onRefresh() } } + is LoadState.Loading -> { binding.progressBar.show() } @@ -256,7 +262,10 @@ class TimelineFragment : binding.recyclerView.post { if (getView() != null) { if (isSwipeToRefreshEnabled) { - binding.recyclerView.scrollBy(0, Utils.dpToPx(requireContext(), -30)) + binding.recyclerView.scrollBy( + 0, + Utils.dpToPx(requireContext(), -30) + ) } else { binding.recyclerView.scrollToPosition(0) } @@ -277,7 +286,7 @@ class TimelineFragment : if (actionButtonPresent()) { val preferences = PreferenceManager.getDefaultSharedPreferences(requireContext()) - hideFab = preferences.getBoolean("fabHide", false) + hideFab = preferences.getBoolean(PrefKeys.FAB_HIDE, false) binding.recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { override fun onScrolled(view: RecyclerView, dx: Int, dy: Int) { val composeButton = (activity as ActionButtonActivity).actionButton @@ -302,16 +311,22 @@ class TimelineFragment : is PreferenceChangedEvent -> { onPreferenceChanged(event.preferenceKey) } + is StatusComposedEvent -> { val status = event.status handleStatusComposeEvent(status) } - is StatusEditedEvent -> { - handleStatusComposeEvent(event.status) - } } } } + + updateRelativeTimePeriodically { + adapter.notifyItemRangeChanged( + 0, + adapter.itemCount, + listOf(StatusBaseViewHolder.Key.KEY_CREATED) + ) + } } override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { @@ -339,6 +354,7 @@ class TimelineFragment : false } } + else -> false } } @@ -356,7 +372,11 @@ class TimelineFragment : val statusIdBelowLoadMore = statusIdBelowLoadMore ?: return var status: StatusViewData? - while (adapter.peek(position).let { status = it; it != null }) { + while (adapter.peek(position).let { + status = it + it != null + } + ) { if (status?.id == statusIdBelowLoadMore) { val lastVisiblePosition = (binding.recyclerView.layoutManager as LinearLayoutManager).findLastVisibleItemPosition() @@ -402,6 +422,17 @@ class TimelineFragment : adapter.refresh() } + override val onMoreTranslate = + { translate: Boolean, position: Int -> + if (translate) { + onTranslate(position) + } else { + onUntranslate( + position + ) + } + } + override fun onReply(position: Int) { val status = adapter.peek(position)?.asStatusOrNull() ?: return super.reply(status.status) @@ -412,6 +443,25 @@ class TimelineFragment : viewModel.reblog(reblog, status) } + private fun onTranslate(position: Int) { + val status = adapter.peek(position)?.asStatusOrNull() ?: return + lifecycleScope.launch { + viewModel.translate(status) + .onFailure { + Snackbar.make( + requireView(), + getString(R.string.ui_error_translate, it.message), + Snackbar.LENGTH_LONG + ).show() + } + } + } + + override fun onUntranslate(position: Int) { + val status = adapter.peek(position)?.asStatusOrNull() ?: return + viewModel.untranslate(status) + } + override fun onFavourite(favourite: Boolean, position: Int) { val status = adapter.peek(position)?.asStatusOrNull() ?: return viewModel.favorite(favourite, status) @@ -434,7 +484,12 @@ class TimelineFragment : override fun onMore(view: View, position: Int) { val status = adapter.peek(position)?.asStatusOrNull() ?: return - super.more(status.status, view, position) + super.more( + status.status, + view, + position, + (status.translation as? TranslationViewData.Loaded)?.data + ) } override fun onOpenReblog(position: Int) { @@ -455,19 +510,20 @@ class TimelineFragment : override fun onShowReblogs(position: Int) { val statusId = adapter.peek(position)?.asStatusOrNull()?.id ?: return val intent = newIntent(requireContext(), AccountListActivity.Type.REBLOGGED, statusId) - (activity as BaseActivity).startActivityWithSlideInAnimation(intent) + activity?.startActivityWithSlideInAnimation(intent) } override fun onShowFavs(position: Int) { val statusId = adapter.peek(position)?.asStatusOrNull()?.id ?: return val intent = newIntent(requireContext(), AccountListActivity.Type.FAVOURITED, statusId) - (activity as BaseActivity).startActivityWithSlideInAnimation(intent) + activity?.startActivityWithSlideInAnimation(intent) } override fun onLoadMore(position: Int) { val placeholder = adapter.peek(position)?.asPlaceholderOrNull() ?: return loadMorePosition = position - statusIdBelowLoadMore = if (position + 1 < adapter.itemCount) adapter.peek(position + 1)?.id else null + statusIdBelowLoadMore = + if (position + 1 < adapter.itemCount) adapter.peek(position + 1)?.id else null viewModel.loadMore(placeholder.id) } @@ -502,9 +558,9 @@ class TimelineFragment : override fun onViewAccount(id: String) { if (( - viewModel.kind == TimelineViewModel.Kind.USER || - viewModel.kind == TimelineViewModel.Kind.USER_WITH_REPLIES - ) && + viewModel.kind == TimelineViewModel.Kind.USER || + viewModel.kind == TimelineViewModel.Kind.USER_WITH_REPLIES + ) && viewModel.id == id ) { /* If already viewing an account page, then any requests to view that account page @@ -520,6 +576,7 @@ class TimelineFragment : PrefKeys.FAB_HIDE -> { hideFab = sharedPreferences.getBoolean(PrefKeys.FAB_HIDE, false) } + PrefKeys.MEDIA_PREVIEW_ENABLED -> { val enabled = accountManager.activeAccount!!.mediaPreviewEnabled val oldMediaPreviewEnabled = adapter.mediaPreviewEnabled @@ -528,6 +585,7 @@ class TimelineFragment : adapter.notifyItemRangeChanged(0, adapter.itemCount) } } + PrefKeys.READING_ORDER -> { readingOrder = ReadingOrder.from( sharedPreferences.getString(PrefKeys.READING_ORDER, null) @@ -540,11 +598,14 @@ class TimelineFragment : when (kind) { TimelineViewModel.Kind.HOME, TimelineViewModel.Kind.PUBLIC_FEDERATED, - TimelineViewModel.Kind.PUBLIC_LOCAL -> adapter.refresh() + TimelineViewModel.Kind.PUBLIC_LOCAL, + TimelineViewModel.Kind.PUBLIC_TRENDING_STATUSES -> adapter.refresh() + TimelineViewModel.Kind.USER, TimelineViewModel.Kind.USER_WITH_REPLIES -> if (status.account.id == viewModel.id) { adapter.refresh() } + TimelineViewModel.Kind.TAG, TimelineViewModel.Kind.FAVOURITES, TimelineViewModel.Kind.LIST, @@ -569,13 +630,14 @@ class TimelineFragment : override fun onPause() { super.onPause() - (binding.recyclerView.layoutManager as? LinearLayoutManager)?.findFirstVisibleItemPosition()?.let { position -> - if (position != RecyclerView.NO_POSITION) { - adapter.snapshot().getOrNull(position)?.id?.let { statusId -> - viewModel.saveReadingPosition(statusId) + (binding.recyclerView.layoutManager as? LinearLayoutManager)?.findFirstVisibleItemPosition() + ?.let { position -> + if (position != RecyclerView.NO_POSITION) { + adapter.snapshot().getOrNull(position)?.id?.let { statusId -> + viewModel.saveReadingPosition(statusId) + } } } - } } override fun onResume() { @@ -589,25 +651,6 @@ class TimelineFragment : if (talkBackWasEnabled && !wasEnabled) { adapter.notifyItemRangeChanged(0, adapter.itemCount) } - startUpdateTimestamp() - } - - /** - * Start to update adapter every minute to refresh timestamp - * If setting absoluteTimeView is false - * Auto dispose observable on pause - */ - private fun startUpdateTimestamp() { - val preferences = PreferenceManager.getDefaultSharedPreferences(requireContext()) - val useAbsoluteTime = preferences.getBoolean(PrefKeys.ABSOLUTE_TIME_VIEW, false) - if (!useAbsoluteTime) { - Observable.interval(0, 1, TimeUnit.MINUTES) - .observeOn(AndroidSchedulers.mainThread()) - .autoDispose(this, Lifecycle.Event.ON_PAUSE) - .subscribe { - adapter.notifyItemRangeChanged(0, adapter.itemCount, listOf(StatusBaseViewHolder.Key.KEY_CREATED)) - } - } } override fun onReselect() { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelinePagingAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelinePagingAdapter.kt index a232989b3..716a30199 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelinePagingAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelinePagingAdapter.kt @@ -53,7 +53,9 @@ class TimelinePagingAdapter( StatusViewHolder(inflater.inflate(R.layout.item_status_wrapper, viewGroup, false)) } VIEW_TYPE_PLACEHOLDER -> { - PlaceholderViewHolder(inflater.inflate(R.layout.item_status_placeholder, viewGroup, false)) + PlaceholderViewHolder( + inflater.inflate(R.layout.item_status_placeholder, viewGroup, false) + ) } else -> { StatusViewHolder(inflater.inflate(R.layout.item_status, viewGroup, false)) @@ -124,10 +126,7 @@ class TimelinePagingAdapter( return false // Items are different always. It allows to refresh timestamp on every view holder update } - override fun getChangePayload( - oldItem: StatusViewData, - newItem: StatusViewData - ): Any? { + override fun getChangePayload(oldItem: StatusViewData, newItem: StatusViewData): Any? { return if (oldItem == newItem) { // If items are equal - update timestamp only listOf(StatusBaseViewHolder.Key.KEY_CREATED) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineTypeMappers.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineTypeMappers.kt index bdb3d64d7..4f8251d9e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineTypeMappers.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineTypeMappers.kt @@ -13,11 +13,11 @@ * You should have received a copy of the GNU General Public License along with Tusky; if not, * see . */ +@file:OptIn(ExperimentalStdlibApi::class) + package com.keylesspalace.tusky.components.timeline import android.util.Log -import com.google.gson.Gson -import com.google.gson.reflect.TypeToken import com.keylesspalace.tusky.db.TimelineAccountEntity import com.keylesspalace.tusky.db.TimelineStatusEntity import com.keylesspalace.tusky.db.TimelineStatusWithAccount @@ -29,6 +29,9 @@ import com.keylesspalace.tusky.entity.Poll import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.TimelineAccount import com.keylesspalace.tusky.viewdata.StatusViewData +import com.keylesspalace.tusky.viewdata.TranslationViewData +import com.squareup.moshi.Moshi +import com.squareup.moshi.adapter import java.util.Date private const val TAG = "TimelineTypeMappers" @@ -38,12 +41,7 @@ data class Placeholder( val loading: Boolean ) -private val attachmentArrayListType = object : TypeToken>() {}.type -private val emojisListType = object : TypeToken>() {}.type -private val mentionListType = object : TypeToken>() {}.type -private val tagListType = object : TypeToken>() {}.type - -fun TimelineAccount.toEntity(accountId: Long, gson: Gson): TimelineAccountEntity { +fun TimelineAccount.toEntity(accountId: Long, moshi: Moshi): TimelineAccountEntity { return TimelineAccountEntity( serverId = id, timelineUserId = accountId, @@ -52,12 +50,12 @@ fun TimelineAccount.toEntity(accountId: Long, gson: Gson): TimelineAccountEntity displayName = name, url = url, avatar = avatar, - emojis = gson.toJson(emojis), + emojis = moshi.adapter>().toJson(emojis), bot = bot ) } -fun TimelineAccountEntity.toAccount(gson: Gson): TimelineAccount { +fun TimelineAccountEntity.toAccount(moshi: Moshi): TimelineAccount { return TimelineAccount( id = serverId, localUsername = localUsername, @@ -67,7 +65,7 @@ fun TimelineAccountEntity.toAccount(gson: Gson): TimelineAccount { url = url, avatar = avatar, bot = bot, - emojis = gson.fromJson(emojis, emojisListType) + emojis = moshi.adapter?>().fromJson(emojis).orEmpty() ) } @@ -106,13 +104,13 @@ fun Placeholder.toEntity(timelineUserId: Long): TimelineStatusEntity { card = null, repliesCount = 0, language = null, - filtered = null + filtered = emptyList() ) } fun Status.toEntity( timelineUserId: Long, - gson: Gson, + moshi: Moshi, expanded: Boolean, contentShowing: Boolean, contentCollapsed: Boolean @@ -127,7 +125,7 @@ fun Status.toEntity( content = actionableStatus.content, createdAt = actionableStatus.createdAt.time, editedAt = actionableStatus.editedAt?.time, - emojis = actionableStatus.emojis.let(gson::toJson), + emojis = actionableStatus.emojis.let { moshi.adapter>().toJson(it) }, reblogsCount = actionableStatus.reblogsCount, favouritesCount = actionableStatus.favouritesCount, reblogged = actionableStatus.reblogged, @@ -136,44 +134,44 @@ fun Status.toEntity( sensitive = actionableStatus.sensitive, spoilerText = actionableStatus.spoilerText, visibility = actionableStatus.visibility, - attachments = actionableStatus.attachments.let(gson::toJson), - mentions = actionableStatus.mentions.let(gson::toJson), - tags = actionableStatus.tags.let(gson::toJson), - application = actionableStatus.application.let(gson::toJson), + attachments = actionableStatus.attachments.let { moshi.adapter>().toJson(it) }, + mentions = actionableStatus.mentions.let { moshi.adapter>().toJson(it) }, + tags = actionableStatus.tags.let { moshi.adapter?>().toJson(it) }, + application = actionableStatus.application.let { moshi.adapter().toJson(it) }, reblogServerId = reblog?.id, reblogAccountId = reblog?.let { this.account.id }, - poll = actionableStatus.poll.let(gson::toJson), + poll = actionableStatus.poll.let { moshi.adapter().toJson(it) }, muted = actionableStatus.muted, expanded = expanded, contentShowing = contentShowing, contentCollapsed = contentCollapsed, - pinned = actionableStatus.pinned == true, - card = actionableStatus.card?.let(gson::toJson), + pinned = actionableStatus.pinned, + card = actionableStatus.card?.let { moshi.adapter().toJson(it) }, repliesCount = actionableStatus.repliesCount, language = actionableStatus.language, filtered = actionableStatus.filtered ) } -fun TimelineStatusWithAccount.toViewData(gson: Gson, isDetailed: Boolean = false): StatusViewData { +fun TimelineStatusWithAccount.toViewData(moshi: Moshi, isDetailed: Boolean = false, translation: TranslationViewData? = null): StatusViewData { if (this.account == null) { Log.d(TAG, "Constructing Placeholder(${this.status.serverId}, ${this.status.expanded})") return StatusViewData.Placeholder(this.status.serverId, this.status.expanded) } - val attachments: ArrayList = gson.fromJson(status.attachments, attachmentArrayListType) ?: arrayListOf() - val mentions: List = gson.fromJson(status.mentions, mentionListType) ?: emptyList() - val tags: List? = gson.fromJson(status.tags, tagListType) - val application = gson.fromJson(status.application, Status.Application::class.java) - val emojis: List = gson.fromJson(status.emojis, emojisListType) ?: emptyList() - val poll: Poll? = gson.fromJson(status.poll, Poll::class.java) - val card: Card? = gson.fromJson(status.card, Card::class.java) + val attachments: List = status.attachments?.let { moshi.adapter?>().fromJson(it) }.orEmpty() + val mentions: List = status.mentions?.let { moshi.adapter?>().fromJson(it) }.orEmpty() + val tags: List? = status.tags?.let { moshi.adapter?>().fromJson(it) } + val application = status.application?.let { moshi.adapter().fromJson(it) } + val emojis: List = status.emojis?.let { moshi.adapter?>().fromJson(it) }.orEmpty() + val poll: Poll? = status.poll?.let { moshi.adapter().fromJson(it) } + val card: Card? = status.card?.let { moshi.adapter().fromJson(it) } val reblog = status.reblogServerId?.let { id -> Status( id = id, url = status.url, - account = account.toAccount(gson), + account = account.toAccount(moshi), inReplyToId = status.inReplyToId, inReplyToAccountId = status.inReplyToAccountId, reblog = null, @@ -194,26 +192,28 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson, isDetailed: Boolean = false tags = tags, application = application, pinned = false, - muted = status.muted, + muted = status.muted ?: false, poll = poll, card = card, repliesCount = status.repliesCount, language = status.language, - filtered = status.filtered + filtered = status.filtered.orEmpty(), ) } val status = if (reblog != null) { Status( id = status.serverId, - url = null, // no url for reblogs - account = this.reblogAccount!!.toAccount(gson), + // no url for reblogs + url = null, + account = this.reblogAccount!!.toAccount(moshi), inReplyToId = null, inReplyToAccountId = null, reblog = reblog, content = "", - createdAt = Date(status.createdAt), // lie but whatever? + // lie but whatever? + createdAt = Date(status.createdAt), editedAt = null, - emojis = listOf(), + emojis = emptyList(), reblogsCount = 0, favouritesCount = 0, reblogged = false, @@ -222,27 +222,27 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson, isDetailed: Boolean = false sensitive = false, spoilerText = "", visibility = status.visibility, - attachments = ArrayList(), - mentions = listOf(), - tags = listOf(), + attachments = emptyList(), + mentions = emptyList(), + tags = emptyList(), application = null, pinned = status.pinned, - muted = status.muted, + muted = status.muted ?: false, poll = null, card = null, repliesCount = status.repliesCount, language = status.language, - filtered = status.filtered + filtered = status.filtered.orEmpty() ) } else { Status( id = status.serverId, url = status.url, - account = account.toAccount(gson), + account = account.toAccount(moshi), inReplyToId = status.inReplyToId, inReplyToAccountId = status.inReplyToAccountId, reblog = null, - content = status.content.orEmpty(), + content = translation?.data?.content ?: status.content.orEmpty(), createdAt = Date(status.createdAt), editedAt = status.editedAt?.let { Date(it) }, emojis = emojis, @@ -259,12 +259,12 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson, isDetailed: Boolean = false tags = tags, application = application, pinned = status.pinned, - muted = status.muted, + muted = status.muted ?: false, poll = poll, card = card, repliesCount = status.repliesCount, language = status.language, - filtered = status.filtered + filtered = status.filtered.orEmpty() ) } return StatusViewData.Concrete( @@ -272,6 +272,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson, isDetailed: Boolean = false isExpanded = this.status.expanded, isShowingContent = this.status.contentShowing, isCollapsed = this.status.contentCollapsed, - isDetailed = isDetailed + isDetailed = isDetailed, + translation = translation, ) } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/util/TimelineUtils.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/util/TimelineUtils.kt index c8d95fd81..91d436f63 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/util/TimelineUtils.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/util/TimelineUtils.kt @@ -1,14 +1,13 @@ package com.keylesspalace.tusky.components.timeline.util -import retrofit2.HttpException +import com.squareup.moshi.JsonDataException import java.io.IOException +import retrofit2.HttpException -fun Throwable.isExpected() = this is IOException || this is HttpException +fun Throwable.isExpected() = + this is IOException || this is HttpException || this is JsonDataException -inline fun ifExpected( - t: Throwable, - cb: () -> T -): T { +inline fun ifExpected(t: Throwable, cb: () -> T): T { if (t.isExpected()) { return cb() } else { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt index 89afefecd..625cdf910 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt @@ -15,12 +15,12 @@ package com.keylesspalace.tusky.components.timeline.viewmodel +import android.util.Log import androidx.paging.ExperimentalPagingApi import androidx.paging.LoadType import androidx.paging.PagingState import androidx.paging.RemoteMediator import androidx.room.withTransaction -import com.google.gson.Gson import com.keylesspalace.tusky.components.timeline.Placeholder import com.keylesspalace.tusky.components.timeline.toEntity import com.keylesspalace.tusky.components.timeline.util.ifExpected @@ -30,6 +30,7 @@ import com.keylesspalace.tusky.db.TimelineStatusEntity import com.keylesspalace.tusky.db.TimelineStatusWithAccount import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.network.MastodonApi +import com.squareup.moshi.Moshi import retrofit2.HttpException @OptIn(ExperimentalPagingApi::class) @@ -37,7 +38,7 @@ class CachedTimelineRemoteMediator( accountManager: AccountManager, private val api: MastodonApi, private val db: AppDatabase, - private val gson: Gson + private val moshi: Moshi ) : RemoteMediator() { private var initialRefresh = false @@ -67,7 +68,8 @@ class CachedTimelineRemoteMediator( topId?.let { cachedTopId -> val statusResponse = api.homeTimeline( maxId = cachedTopId, - sinceId = topPlaceholderId, // so already existing placeholders don't get accidentally overwritten + // so already existing placeholders don't get accidentally overwritten + sinceId = topPlaceholderId, limit = state.config.pageSize ) @@ -117,6 +119,7 @@ class CachedTimelineRemoteMediator( return MediatorResult.Success(endOfPaginationReached = statuses.isEmpty()) } catch (e: Exception) { return ifExpected(e) { + Log.w(TAG, "Failed to load timeline", e) MediatorResult.Error(e) } } @@ -129,7 +132,10 @@ class CachedTimelineRemoteMediator( * @param statuses the new statuses * @return the number of old statuses that have been cleared from the database */ - private suspend fun replaceStatusRange(statuses: List, state: PagingState): Int { + private suspend fun replaceStatusRange( + statuses: List, + state: PagingState + ): Int { val overlappedStatuses = if (statuses.isNotEmpty()) { timelineDao.deleteRange(activeAccount.id, statuses.last().id, statuses.first().id) } else { @@ -137,8 +143,8 @@ class CachedTimelineRemoteMediator( } for (status in statuses) { - timelineDao.insertAccount(status.account.toEntity(activeAccount.id, gson)) - status.reblog?.account?.toEntity(activeAccount.id, gson)?.let { rebloggedAccount -> + timelineDao.insertAccount(status.account.toEntity(activeAccount.id, moshi)) + status.reblog?.account?.toEntity(activeAccount.id, moshi)?.let { rebloggedAccount -> timelineDao.insertAccount(rebloggedAccount) } @@ -160,13 +166,13 @@ class CachedTimelineRemoteMediator( } else { oldStatus?.expanded ?: activeAccount.alwaysOpenSpoiler } - val contentShowing = oldStatus?.contentShowing ?: activeAccount.alwaysShowSensitiveMedia || !status.actionableStatus.sensitive + val contentShowing = oldStatus?.contentShowing ?: (activeAccount.alwaysShowSensitiveMedia || !status.actionableStatus.sensitive) val contentCollapsed = oldStatus?.contentCollapsed ?: true timelineDao.insertStatus( status.toEntity( timelineUserId = activeAccount.id, - gson = gson, + moshi = moshi, expanded = expanded, contentShowing = contentShowing, contentCollapsed = contentCollapsed @@ -175,4 +181,8 @@ class CachedTimelineRemoteMediator( } return overlappedStatuses } + + companion object { + private const val TAG = "CachedTimelineRM" + } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt index d33bf7e41..8177c2fa0 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt @@ -26,12 +26,10 @@ import androidx.paging.cachedIn import androidx.paging.filter import androidx.paging.map import androidx.room.withTransaction -import com.google.gson.Gson -import com.keylesspalace.tusky.appstore.BookmarkEvent +import at.connyduck.calladapter.networkresult.NetworkResult +import at.connyduck.calladapter.networkresult.map +import at.connyduck.calladapter.networkresult.onFailure import com.keylesspalace.tusky.appstore.EventHub -import com.keylesspalace.tusky.appstore.FavoriteEvent -import com.keylesspalace.tusky.appstore.PinEvent -import com.keylesspalace.tusky.appstore.ReblogEvent import com.keylesspalace.tusky.components.preference.PreferencesFragment.ReadingOrder.NEWEST_FIRST import com.keylesspalace.tusky.components.preference.PreferencesFragment.ReadingOrder.OLDEST_FIRST import com.keylesspalace.tusky.components.timeline.Placeholder @@ -43,18 +41,22 @@ import com.keylesspalace.tusky.db.AppDatabase import com.keylesspalace.tusky.db.TimelineStatusWithAccount import com.keylesspalace.tusky.entity.Filter import com.keylesspalace.tusky.entity.Poll +import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.network.FilterModel import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.usecase.TimelineCases import com.keylesspalace.tusky.util.EmptyPagingSource import com.keylesspalace.tusky.viewdata.StatusViewData +import com.keylesspalace.tusky.viewdata.TranslationViewData +import com.squareup.moshi.Moshi +import javax.inject.Inject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.asExecutor +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import retrofit2.HttpException -import javax.inject.Inject /** * TimelineViewModel that caches all statuses in a local database @@ -67,7 +69,7 @@ class CachedTimelineViewModel @Inject constructor( sharedPreferences: SharedPreferences, filterModel: FilterModel, private val db: AppDatabase, - private val gson: Gson + private val moshi: Moshi ) : TimelineViewModel( timelineCases, api, @@ -79,10 +81,13 @@ class CachedTimelineViewModel @Inject constructor( private var currentPagingSource: PagingSource? = null + /** Map from status id to translation. */ + private val translations = MutableStateFlow(mapOf()) + @OptIn(ExperimentalPagingApi::class) override val statuses = Pager( config = PagingConfig(pageSize = LOAD_AT_ONCE), - remoteMediator = CachedTimelineRemoteMediator(accountManager, api, db, gson), + remoteMediator = CachedTimelineRemoteMediator(accountManager, api, db, moshi), pagingSourceFactory = { val activeAccount = accountManager.activeAccount if (activeAccount == null) { @@ -94,15 +99,24 @@ class CachedTimelineViewModel @Inject constructor( } } ).flow - .map { pagingData -> + // Apply cachedIn() early to be able to combine with translation flow. + // This will not cache ViewData's but practically we don't need this. + // If you notice that this flow is used in more than once place consider + // adding another cachedIn() for the overall result. + .cachedIn(viewModelScope) + .combine(translations) { pagingData, translations -> pagingData.map(Dispatchers.Default.asExecutor()) { timelineStatus -> - timelineStatus.toViewData(gson) + val translation = translations[timelineStatus.status.serverId] + timelineStatus.toViewData( + moshi, + isDetailed = false, + translation = translation + ) }.filter(Dispatchers.Default.asExecutor()) { statusViewData -> shouldFilterStatus(statusViewData) != Filter.Action.HIDE } } .flowOn(Dispatchers.Default) - .cachedIn(viewModelScope) override fun updatePoll(newPoll: Poll, status: StatusViewData.Concrete) { // handled by CacheUpdater @@ -204,15 +218,15 @@ class CachedTimelineViewModel @Inject constructor( } for (status in statuses) { - timelineDao.insertAccount(status.account.toEntity(activeAccount.id, gson)) - status.reblog?.account?.toEntity(activeAccount.id, gson) + timelineDao.insertAccount(status.account.toEntity(activeAccount.id, moshi)) + status.reblog?.account?.toEntity(activeAccount.id, moshi) ?.let { rebloggedAccount -> timelineDao.insertAccount(rebloggedAccount) } timelineDao.insertStatus( status.toEntity( timelineUserId = activeAccount.id, - gson = gson, + moshi = moshi, expanded = activeAccount.alwaysOpenSpoiler, contentShowing = activeAccount.alwaysShowSensitiveMedia || !status.actionableStatus.sensitive, contentCollapsed = true @@ -253,19 +267,7 @@ class CachedTimelineViewModel @Inject constructor( .insertStatus(Placeholder(placeholderId, loading = false).toEntity(activeAccount.id)) } - override fun handleReblogEvent(reblogEvent: ReblogEvent) { - // handled by CacheUpdater - } - - override fun handleFavEvent(favEvent: FavoriteEvent) { - // handled by CacheUpdater - } - - override fun handleBookmarkEvent(bookmarkEvent: BookmarkEvent) { - // handled by CacheUpdater - } - - override fun handlePinEvent(pinEvent: PinEvent) { + override fun handleStatusChangedEvent(status: Status) { // handled by CacheUpdater } @@ -291,8 +293,23 @@ class CachedTimelineViewModel @Inject constructor( } } + override suspend fun translate(status: StatusViewData.Concrete): NetworkResult { + translations.value = translations.value + (status.id to TranslationViewData.Loading) + return timelineCases.translate(status.actionableId) + .map { translation -> + translations.value = + translations.value + (status.id to TranslationViewData.Loaded(translation)) + } + .onFailure { + translations.value = translations.value - status.id + } + } + + override fun untranslate(status: StatusViewData.Concrete) { + translations.value = translations.value - status.id + } + companion object { private const val TAG = "CachedTimelineViewModel" - private const val MAX_STATUSES_IN_CACHE = 1000 } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineRemoteMediator.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineRemoteMediator.kt index 98da15bea..f19b2240f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineRemoteMediator.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineRemoteMediator.kt @@ -15,6 +15,7 @@ package com.keylesspalace.tusky.components.timeline.viewmodel +import android.util.Log import androidx.paging.ExperimentalPagingApi import androidx.paging.LoadType import androidx.paging.PagingState @@ -32,6 +33,14 @@ class NetworkTimelineRemoteMediator( private val viewModel: NetworkTimelineViewModel ) : RemoteMediator() { + private val statusIds = mutableSetOf() + + init { + if (viewModel.kind == TimelineViewModel.Kind.PUBLIC_TRENDING_STATUSES) { + statusIds.addAll(viewModel.statusData.map { it.id }) + } + } + override suspend fun load( loadType: LoadType, state: PagingState @@ -67,7 +76,7 @@ class NetworkTimelineRemoteMediator( s.asStatusOrNull()?.id == status.id }?.asStatusOrNull() - val contentShowing = oldStatus?.isShowingContent ?: activeAccount.alwaysShowSensitiveMedia || !status.actionableStatus.sensitive + val contentShowing = oldStatus?.isShowingContent ?: (activeAccount.alwaysShowSensitiveMedia || !status.actionableStatus.sensitive) val expanded = oldStatus?.isExpanded ?: activeAccount.alwaysOpenSpoiler val contentCollapsed = oldStatus?.isCollapsed ?: true @@ -87,6 +96,10 @@ class NetworkTimelineRemoteMediator( false } + if (viewModel.kind == TimelineViewModel.Kind.PUBLIC_TRENDING_STATUSES) { + statusIds.addAll(data.map { it.id }) + } + viewModel.statusData.addAll(0, data) if (insertPlaceholder) { @@ -95,19 +108,35 @@ class NetworkTimelineRemoteMediator( } else { val linkHeader = statusResponse.headers()["Link"] val links = HttpHeaderLink.parse(linkHeader) - val nextId = HttpHeaderLink.findByRelationType(links, "next")?.uri?.getQueryParameter("max_id") + val next = HttpHeaderLink.findByRelationType(links, "next") - viewModel.nextKey = nextId + var filteredData = data + if (viewModel.kind == TimelineViewModel.Kind.PUBLIC_TRENDING_STATUSES) { + // Trending statuses use offset for paging, not IDs. If a new status has been added to the remote + // feed after we performed the initial fetch, then the feed will have moved, but our offset won't. + // As a result, we'd get repeat statuses. This addresses that. + filteredData = data.filter { !statusIds.contains(it.id) } + statusIds.addAll(filteredData.map { it.id }) - viewModel.statusData.addAll(data) + viewModel.nextKey = next?.uri?.getQueryParameter("offset") + } else { + viewModel.nextKey = next?.uri?.getQueryParameter("max_id") + } + + viewModel.statusData.addAll(filteredData) } viewModel.currentSource?.invalidate() return MediatorResult.Success(endOfPaginationReached = statuses.isEmpty()) } catch (e: Exception) { return ifExpected(e) { + Log.w(TAG, "Failed to load timeline", e) MediatorResult.Error(e) } } } + + companion object { + private const val TAG = "NetworkTimelineRM" + } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt index f32443aee..60eed28e0 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt @@ -23,11 +23,10 @@ import androidx.paging.Pager import androidx.paging.PagingConfig import androidx.paging.cachedIn import androidx.paging.filter -import com.keylesspalace.tusky.appstore.BookmarkEvent +import at.connyduck.calladapter.networkresult.NetworkResult +import at.connyduck.calladapter.networkresult.map +import at.connyduck.calladapter.networkresult.onFailure import com.keylesspalace.tusky.appstore.EventHub -import com.keylesspalace.tusky.appstore.FavoriteEvent -import com.keylesspalace.tusky.appstore.PinEvent -import com.keylesspalace.tusky.appstore.ReblogEvent import com.keylesspalace.tusky.components.timeline.util.ifExpected import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.entity.Filter @@ -41,6 +40,9 @@ import com.keylesspalace.tusky.util.isLessThan import com.keylesspalace.tusky.util.isLessThanOrEqual import com.keylesspalace.tusky.util.toViewData import com.keylesspalace.tusky.viewdata.StatusViewData +import com.keylesspalace.tusky.viewdata.TranslationViewData +import java.io.IOException +import javax.inject.Inject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.asExecutor import kotlinx.coroutines.flow.flowOn @@ -48,8 +50,6 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import retrofit2.HttpException import retrofit2.Response -import java.io.IOException -import javax.inject.Inject /** * TimelineViewModel that caches all statuses in an in-memory list @@ -61,7 +61,14 @@ class NetworkTimelineViewModel @Inject constructor( accountManager: AccountManager, sharedPreferences: SharedPreferences, filterModel: FilterModel -) : TimelineViewModel(timelineCases, api, eventHub, accountManager, sharedPreferences, filterModel) { +) : TimelineViewModel( + timelineCases, + api, + eventHub, + accountManager, + sharedPreferences, + filterModel +) { var currentSource: NetworkTimelinePagingSource? = null @@ -142,7 +149,8 @@ class NetworkTimelineViewModel @Inject constructor( try { val placeholderIndex = statusData.indexOfFirst { it is StatusViewData.Placeholder && it.id == placeholderId } - statusData[placeholderIndex] = StatusViewData.Placeholder(placeholderId, isLoading = true) + statusData[placeholderIndex] = + StatusViewData.Placeholder(placeholderId, isLoading = true) val idAbovePlaceholder = statusData.getOrNull(placeholderIndex - 1)?.id @@ -172,11 +180,19 @@ class NetworkTimelineViewModel @Inject constructor( if (statuses.isNotEmpty()) { val firstId = statuses.first().id val lastId = statuses.last().id - val overlappedFrom = statusData.indexOfFirst { it.asStatusOrNull()?.id?.isLessThanOrEqual(firstId) ?: false } - val overlappedTo = statusData.indexOfFirst { it.asStatusOrNull()?.id?.isLessThan(lastId) ?: false } + val overlappedFrom = statusData.indexOfFirst { + it.asStatusOrNull()?.id?.isLessThanOrEqual(firstId) ?: false + } + val overlappedTo = statusData.indexOfFirst { + it.asStatusOrNull()?.id?.isLessThan(lastId) ?: false + } if (overlappedFrom < overlappedTo) { - data.mapIndexed { i, status -> i to statusData.firstOrNull { it.asStatusOrNull()?.id == status.id }?.asStatusOrNull() } + data.mapIndexed { i, status -> + i to statusData.firstOrNull { + it.asStatusOrNull()?.id == status.id + }?.asStatusOrNull() + } .filter { (_, oldStatus) -> oldStatus != null } .forEach { (i, oldStatus) -> data[i] = data[i].asStatusOrNull()!! @@ -189,12 +205,18 @@ class NetworkTimelineViewModel @Inject constructor( statusData.removeAll { status -> when (status) { - is StatusViewData.Placeholder -> lastId.isLessThan(status.id) && status.id.isLessThanOrEqual(firstId) - is StatusViewData.Concrete -> lastId.isLessThan(status.id) && status.id.isLessThanOrEqual(firstId) + is StatusViewData.Placeholder -> lastId.isLessThan(status.id) && status.id.isLessThanOrEqual( + firstId + ) + + is StatusViewData.Concrete -> lastId.isLessThan(status.id) && status.id.isLessThanOrEqual( + firstId + ) } } } else { - data[data.size - 1] = StatusViewData.Placeholder(statuses.last().id, isLoading = false) + data[data.size - 1] = + StatusViewData.Placeholder(statuses.last().id, isLoading = false) } } @@ -219,27 +241,13 @@ class NetworkTimelineViewModel @Inject constructor( currentSource?.invalidate() } - override fun handleReblogEvent(reblogEvent: ReblogEvent) { - updateStatusById(reblogEvent.statusId) { - it.copy(status = it.status.copy(reblogged = reblogEvent.reblog)) - } - } - - override fun handleFavEvent(favEvent: FavoriteEvent) { - updateActionableStatusById(favEvent.statusId) { - it.copy(favourited = favEvent.favourite) - } - } - - override fun handleBookmarkEvent(bookmarkEvent: BookmarkEvent) { - updateActionableStatusById(bookmarkEvent.statusId) { - it.copy(bookmarked = bookmarkEvent.bookmark) - } - } - - override fun handlePinEvent(pinEvent: PinEvent) { - updateActionableStatusById(pinEvent.statusId) { - it.copy(pinned = pinEvent.pinned) + override fun handleStatusChangedEvent(status: Status) { + updateStatusById(status.id) { oldViewData -> + status.toViewData( + isShowingContent = oldViewData.isShowingContent, + isExpanded = oldViewData.isExpanded, + isCollapsed = oldViewData.isCollapsed + ) } } @@ -250,8 +258,8 @@ class NetworkTimelineViewModel @Inject constructor( } override fun clearWarning(status: StatusViewData.Concrete) { - updateActionableStatusById(status.actionableId) { - it.copy(filtered = null) + updateActionableStatusById(status.id) { + it.copy(filtered = emptyList()) } } @@ -263,6 +271,21 @@ class NetworkTimelineViewModel @Inject constructor( currentSource?.invalidate() } + override suspend fun translate(status: StatusViewData.Concrete): NetworkResult { + status.copy(translation = TranslationViewData.Loading).update() + return timelineCases.translate(status.actionableId) + .map { translation -> + status.copy(translation = TranslationViewData.Loaded(translation)).update() + } + .onFailure { + status.update() + } + } + + override fun untranslate(status: StatusViewData.Concrete) { + status.copy(translation = null).update() + } + @Throws(IOException::class, HttpException::class) suspend fun fetchStatusesForKind( fromId: String?, @@ -278,6 +301,7 @@ class NetworkTimelineViewModel @Inject constructor( val additionalHashtags = tags.subList(1, tags.size) api.hashtagTimeline(firstHashtag, additionalHashtags, null, fromId, uptoId, limit) } + Kind.USER -> api.accountStatuses( id!!, fromId, @@ -287,6 +311,7 @@ class NetworkTimelineViewModel @Inject constructor( onlyMedia = null, pinned = null ) + Kind.USER_PINNED -> api.accountStatuses( id!!, fromId, @@ -296,6 +321,7 @@ class NetworkTimelineViewModel @Inject constructor( onlyMedia = null, pinned = true ) + Kind.USER_WITH_REPLIES -> api.accountStatuses( id!!, fromId, @@ -305,14 +331,17 @@ class NetworkTimelineViewModel @Inject constructor( onlyMedia = null, pinned = null ) + Kind.FAVOURITES -> api.favourites(fromId, uptoId, limit) Kind.BOOKMARKS -> api.bookmarks(fromId, uptoId, limit) Kind.LIST -> api.listTimeline(id!!, fromId, uptoId, limit) + Kind.PUBLIC_TRENDING_STATUSES -> api.trendingStatuses(limit = limit, offset = fromId) } } private fun StatusViewData.Concrete.update() { - val position = statusData.indexOfFirst { viewData -> viewData.asStatusOrNull()?.id == this.id } + val position = + statusData.indexOfFirst { viewData -> viewData.asStatusOrNull()?.id == this.id } statusData[position] = this currentSource?.invalidate() } @@ -326,10 +355,7 @@ class NetworkTimelineViewModel @Inject constructor( updateViewDataAt(pos, updater) } - private inline fun updateActionableStatusById( - id: String, - updater: (Status) -> Status - ) { + private inline fun updateActionableStatusById(id: String, updater: (Status) -> Status) { val pos = statusData.indexOfFirst { it.asStatusOrNull()?.id == id } if (pos == -1) return updateViewDataAt(pos) { vd -> diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt index adab92b33..3d02b90dc 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt @@ -20,20 +20,19 @@ import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.paging.PagingData +import at.connyduck.calladapter.networkresult.NetworkResult import at.connyduck.calladapter.networkresult.fold import at.connyduck.calladapter.networkresult.getOrElse import at.connyduck.calladapter.networkresult.getOrThrow import com.keylesspalace.tusky.appstore.BlockEvent -import com.keylesspalace.tusky.appstore.BookmarkEvent import com.keylesspalace.tusky.appstore.DomainMuteEvent import com.keylesspalace.tusky.appstore.Event import com.keylesspalace.tusky.appstore.EventHub -import com.keylesspalace.tusky.appstore.FavoriteEvent +import com.keylesspalace.tusky.appstore.FilterUpdatedEvent import com.keylesspalace.tusky.appstore.MuteConversationEvent import com.keylesspalace.tusky.appstore.MuteEvent -import com.keylesspalace.tusky.appstore.PinEvent import com.keylesspalace.tusky.appstore.PreferenceChangedEvent -import com.keylesspalace.tusky.appstore.ReblogEvent +import com.keylesspalace.tusky.appstore.StatusChangedEvent import com.keylesspalace.tusky.appstore.StatusDeletedEvent import com.keylesspalace.tusky.appstore.UnfollowEvent import com.keylesspalace.tusky.components.preference.PreferencesFragment.ReadingOrder @@ -42,18 +41,19 @@ import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.entity.Filter import com.keylesspalace.tusky.entity.FilterV1 import com.keylesspalace.tusky.entity.Poll +import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.network.FilterModel import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.usecase.TimelineCases +import com.keylesspalace.tusky.util.isHttpNotFound import com.keylesspalace.tusky.viewdata.StatusViewData import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.launch -import retrofit2.HttpException abstract class TimelineViewModel( - private val timelineCases: TimelineCases, + protected val timelineCases: TimelineCases, private val api: MastodonApi, private val eventHub: EventHub, protected val accountManager: AccountManager, @@ -74,13 +74,10 @@ abstract class TimelineViewModel( private var alwaysOpenSpoilers = false private var filterRemoveReplies = false private var filterRemoveReblogs = false + private var filterRemoveSelfReblogs = false protected var readingOrder: ReadingOrder = ReadingOrder.OLDEST_FIRST - fun init( - kind: Kind, - id: String?, - tags: List - ) { + fun init(kind: Kind, id: String?, tags: List) { this.kind = kind this.id = id this.tags = tags @@ -89,9 +86,11 @@ abstract class TimelineViewModel( if (kind == Kind.HOME) { // Note the variable is "true if filter" but the underlying preference/settings text is "true if show" filterRemoveReplies = - !sharedPreferences.getBoolean(PrefKeys.TAB_FILTER_HOME_REPLIES, true) + !(accountManager.activeAccount?.isShowHomeReplies ?: true) filterRemoveReblogs = - !sharedPreferences.getBoolean(PrefKeys.TAB_FILTER_HOME_BOOSTS, true) + !(accountManager.activeAccount?.isShowHomeBoosts ?: true) + filterRemoveSelfReblogs = + !(accountManager.activeAccount?.isShowHomeSelfBoosts ?: true) } readingOrder = ReadingOrder.from(sharedPreferences.getString(PrefKeys.READING_ORDER, null)) @@ -136,23 +135,24 @@ abstract class TimelineViewModel( } } - fun voteInPoll(choices: List, status: StatusViewData.Concrete): Job = viewModelScope.launch { - val poll = status.status.actionableStatus.poll ?: run { - Log.w(TAG, "No poll on status ${status.id}") - return@launch - } + fun voteInPoll(choices: List, status: StatusViewData.Concrete): Job = + viewModelScope.launch { + val poll = status.status.actionableStatus.poll ?: run { + Log.w(TAG, "No poll on status ${status.id}") + return@launch + } - val votedPoll = poll.votedCopy(choices) - updatePoll(votedPoll, status) + val votedPoll = poll.votedCopy(choices) + updatePoll(votedPoll, status) - try { - timelineCases.voteInPoll(status.actionableId, poll.id, choices).getOrThrow() - } catch (t: Exception) { - ifExpected(t) { - Log.d(TAG, "Failed to vote in poll: " + status.actionableId, t) + try { + timelineCases.voteInPoll(status.actionableId, poll.id, choices).getOrThrow() + } catch (t: Exception) { + ifExpected(t) { + Log.d(TAG, "Failed to vote in poll: " + status.actionableId, t) + } } } - } abstract fun updatePoll(newPoll: Poll, status: StatusViewData.Concrete) @@ -170,13 +170,7 @@ abstract class TimelineViewModel( abstract fun loadMore(placeholderId: String) - abstract fun handleReblogEvent(reblogEvent: ReblogEvent) - - abstract fun handleFavEvent(favEvent: FavoriteEvent) - - abstract fun handleBookmarkEvent(bookmarkEvent: BookmarkEvent) - - abstract fun handlePinEvent(pinEvent: PinEvent) + abstract fun handleStatusChangedEvent(status: Status) abstract fun fullReload() @@ -192,7 +186,8 @@ abstract class TimelineViewModel( val status = statusViewData.asStatusOrNull()?.status ?: return Filter.Action.NONE return if ( (status.inReplyToId != null && filterRemoveReplies) || - (status.reblog != null && filterRemoveReblogs) + (status.reblog != null && filterRemoveReblogs) || + ((status.account.id == status.reblog?.account?.id) && filterRemoveSelfReblogs) ) { return Filter.Action.HIDE } else { @@ -204,7 +199,7 @@ abstract class TimelineViewModel( private fun onPreferenceChanged(key: String) { when (key) { PrefKeys.TAB_FILTER_HOME_REPLIES -> { - val filter = sharedPreferences.getBoolean(PrefKeys.TAB_FILTER_HOME_REPLIES, true) + val filter = accountManager.activeAccount?.isShowHomeReplies ?: true val oldRemoveReplies = filterRemoveReplies filterRemoveReplies = kind == Kind.HOME && !filter if (oldRemoveReplies != filterRemoveReplies) { @@ -212,13 +207,21 @@ abstract class TimelineViewModel( } } PrefKeys.TAB_FILTER_HOME_BOOSTS -> { - val filter = sharedPreferences.getBoolean(PrefKeys.TAB_FILTER_HOME_BOOSTS, true) + val filter = accountManager.activeAccount?.isShowHomeBoosts ?: true val oldRemoveReblogs = filterRemoveReblogs filterRemoveReblogs = kind == Kind.HOME && !filter if (oldRemoveReblogs != filterRemoveReblogs) { fullReload() } } + PrefKeys.TAB_SHOW_HOME_SELF_BOOSTS -> { + val filter = accountManager.activeAccount?.isShowHomeSelfBoosts ?: true + val oldRemoveSelfReblogs = filterRemoveSelfReblogs + filterRemoveSelfReblogs = kind == Kind.HOME && !filter + if (oldRemoveSelfReblogs != filterRemoveSelfReblogs) { + fullReload() + } + } FilterV1.HOME, FilterV1.NOTIFICATIONS, FilterV1.THREAD, FilterV1.PUBLIC, FilterV1.ACCOUNT -> { if (filterContextMatchesKind(kind, listOf(key))) { reloadFilters() @@ -237,10 +240,7 @@ abstract class TimelineViewModel( private fun handleEvent(event: Event) { when (event) { - is FavoriteEvent -> handleFavEvent(event) - is ReblogEvent -> handleReblogEvent(event) - is BookmarkEvent -> handleBookmarkEvent(event) - is PinEvent -> handlePinEvent(event) + is StatusChangedEvent -> handleStatusChangedEvent(event.status) is MuteConversationEvent -> fullReload() is UnfollowEvent -> { if (kind == Kind.HOME) { @@ -274,6 +274,11 @@ abstract class TimelineViewModel( is PreferenceChangedEvent -> { onPreferenceChanged(event.preferenceKey) } + is FilterUpdatedEvent -> { + if (filterContextMatchesKind(kind, event.filterContext)) { + fullReload() + } + } } } @@ -286,7 +291,7 @@ abstract class TimelineViewModel( invalidate() }, { throwable -> - if (throwable is HttpException && throwable.code() == 404) { + if (throwable.isHttpNotFound()) { // Fallback to client-side filter code val filters = api.getFiltersV1().getOrElse { Log.e(TAG, "Failed to fetch filters", it) @@ -308,25 +313,35 @@ abstract class TimelineViewModel( } } + abstract suspend fun translate(status: StatusViewData.Concrete): NetworkResult + abstract fun untranslate(status: StatusViewData.Concrete) + companion object { private const val TAG = "TimelineVM" internal const val LOAD_AT_ONCE = 30 - fun filterContextMatchesKind( - kind: Kind, - filterContext: List - ): Boolean { + fun filterContextMatchesKind(kind: Kind, filterContext: List): Boolean { return filterContext.contains(kind.toFilterKind().kind) } } enum class Kind { - HOME, PUBLIC_LOCAL, PUBLIC_FEDERATED, TAG, USER, USER_PINNED, USER_WITH_REPLIES, FAVOURITES, LIST, BOOKMARKS; + HOME, + PUBLIC_LOCAL, + PUBLIC_FEDERATED, + TAG, + USER, + USER_PINNED, + USER_WITH_REPLIES, + FAVOURITES, + LIST, + BOOKMARKS, + PUBLIC_TRENDING_STATUSES; fun toFilterKind(): Filter.Kind { return when (valueOf(name)) { HOME, LIST -> Filter.Kind.HOME - PUBLIC_FEDERATED, PUBLIC_LOCAL, TAG, FAVOURITES -> Filter.Kind.PUBLIC + PUBLIC_FEDERATED, PUBLIC_LOCAL, TAG, FAVOURITES, PUBLIC_TRENDING_STATUSES -> Filter.Kind.PUBLIC USER, USER_WITH_REPLIES, USER_PINNED -> Filter.Kind.ACCOUNT else -> Filter.Kind.PUBLIC } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingActivity.kt b/app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingActivity.kt index 3270e9c26..bdf2cdc79 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingActivity.kt @@ -48,7 +48,7 @@ class TrendingActivity : BaseActivity(), HasAndroidInjector { if (supportFragmentManager.findFragmentById(R.id.fragmentContainer) == null) { supportFragmentManager.commit { - val fragment = TrendingFragment.newInstance() + val fragment = TrendingTagsFragment.newInstance() replace(R.id.fragmentContainer, fragment) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingTagViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingTagViewHolder.kt index 58aad9e84..ac1e8c72d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingTagViewHolder.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingTagViewHolder.kt @@ -25,10 +25,7 @@ class TrendingTagViewHolder( private val binding: ItemTrendingCellBinding ) : RecyclerView.ViewHolder(binding.root) { - fun setup( - tagViewData: TrendingViewData.Tag, - onViewTag: (String) -> Unit - ) { + fun setup(tagViewData: TrendingViewData.Tag, onViewTag: (String) -> Unit) { binding.tag.text = binding.root.context.getString(R.string.title_tag, tagViewData.name) binding.graph.maxTrendingValue = tagViewData.maxTrendingValue diff --git a/app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingTagsAdapter.kt similarity index 99% rename from app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingAdapter.kt rename to app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingTagsAdapter.kt index b40d67670..4137d200e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingTagsAdapter.kt @@ -24,7 +24,7 @@ import com.keylesspalace.tusky.databinding.ItemTrendingCellBinding import com.keylesspalace.tusky.databinding.ItemTrendingDateBinding import com.keylesspalace.tusky.viewdata.TrendingViewData -class TrendingAdapter( +class TrendingTagsAdapter( private val onViewTag: (String) -> Unit ) : ListAdapter(TrendingDifferCallback) { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingTagsFragment.kt similarity index 83% rename from app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingFragment.kt rename to app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingTagsFragment.kt index 2ff9f2cd7..782231c2d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingTagsFragment.kt @@ -30,11 +30,10 @@ import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.SimpleItemAnimator import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener import at.connyduck.sparkbutton.helpers.Utils -import com.keylesspalace.tusky.BaseActivity import com.keylesspalace.tusky.R import com.keylesspalace.tusky.StatusListActivity -import com.keylesspalace.tusky.components.trending.viewmodel.TrendingViewModel -import com.keylesspalace.tusky.databinding.FragmentTrendingBinding +import com.keylesspalace.tusky.components.trending.viewmodel.TrendingTagsViewModel +import com.keylesspalace.tusky.databinding.FragmentTrendingTagsBinding import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.di.ViewModelFactory import com.keylesspalace.tusky.interfaces.ActionButtonActivity @@ -42,14 +41,15 @@ import com.keylesspalace.tusky.interfaces.RefreshableFragment import com.keylesspalace.tusky.interfaces.ReselectableFragment import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.show +import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.viewdata.TrendingViewData +import javax.inject.Inject import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch -import javax.inject.Inject -class TrendingFragment : - Fragment(R.layout.fragment_trending), +class TrendingTagsFragment : + Fragment(R.layout.fragment_trending_tags), OnRefreshListener, Injectable, ReselectableFragment, @@ -58,11 +58,11 @@ class TrendingFragment : @Inject lateinit var viewModelFactory: ViewModelFactory - private val viewModel: TrendingViewModel by viewModels { viewModelFactory } + private val viewModel: TrendingTagsViewModel by viewModels { viewModelFactory } - private val binding by viewBinding(FragmentTrendingBinding::bind) + private val binding by viewBinding(FragmentTrendingTagsBinding::bind) - private val adapter = TrendingAdapter(::onViewTag) + private val adapter = TrendingTagsAdapter(::onViewTag) override fun onConfigurationChanged(newConfig: Configuration) { super.onConfigurationChanged(newConfig) @@ -111,8 +111,8 @@ class TrendingFragment : spanSizeLookup = object : SpanSizeLookup() { override fun getSpanSize(position: Int): Int { return when (adapter.getItemViewType(position)) { - TrendingAdapter.VIEW_TYPE_HEADER -> columnCount - TrendingAdapter.VIEW_TYPE_TAG -> 1 + TrendingTagsAdapter.VIEW_TYPE_HEADER -> columnCount + TrendingTagsAdapter.VIEW_TYPE_TAG -> 1 else -> -1 } } @@ -136,18 +136,20 @@ class TrendingFragment : } fun onViewTag(tag: String) { - (requireActivity() as BaseActivity).startActivityWithSlideInAnimation(StatusListActivity.newHashtagIntent(requireContext(), tag)) + requireActivity().startActivityWithSlideInAnimation( + StatusListActivity.newHashtagIntent(requireContext(), tag) + ) } - private fun processViewState(uiState: TrendingViewModel.TrendingUiState) { + private fun processViewState(uiState: TrendingTagsViewModel.TrendingTagsUiState) { Log.d(TAG, uiState.loadingState.name) when (uiState.loadingState) { - TrendingViewModel.LoadingState.INITIAL -> clearLoadingState() - TrendingViewModel.LoadingState.LOADING -> applyLoadingState() - TrendingViewModel.LoadingState.REFRESHING -> applyRefreshingState() - TrendingViewModel.LoadingState.LOADED -> applyLoadedState(uiState.trendingViewData) - TrendingViewModel.LoadingState.ERROR_NETWORK -> networkError() - TrendingViewModel.LoadingState.ERROR_OTHER -> otherError() + TrendingTagsViewModel.LoadingState.INITIAL -> clearLoadingState() + TrendingTagsViewModel.LoadingState.LOADING -> applyLoadingState() + TrendingTagsViewModel.LoadingState.REFRESHING -> applyRefreshingState() + TrendingTagsViewModel.LoadingState.LOADED -> applyLoadedState(uiState.trendingViewData) + TrendingTagsViewModel.LoadingState.ERROR_NETWORK -> networkError() + TrendingTagsViewModel.LoadingState.ERROR_OTHER -> otherError() } } @@ -194,7 +196,7 @@ class TrendingFragment : binding.swipeRefreshLayout.isRefreshing = false binding.messageView.setup( - R.drawable.elephant_offline, + R.drawable.errorphant_offline, R.string.error_network ) { refreshContent() } } @@ -206,7 +208,7 @@ class TrendingFragment : binding.swipeRefreshLayout.isRefreshing = false binding.messageView.setup( - R.drawable.elephant_error, + R.drawable.errorphant_error, R.string.error_generic ) { refreshContent() } } @@ -247,8 +249,8 @@ class TrendingFragment : } companion object { - private const val TAG = "TrendingFragment" + private const val TAG = "TrendingTagsFragment" - fun newInstance() = TrendingFragment() + fun newInstance() = TrendingTagsFragment() } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/trending/viewmodel/TrendingViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/trending/viewmodel/TrendingTagsViewModel.kt similarity index 64% rename from app/src/main/java/com/keylesspalace/tusky/components/trending/viewmodel/TrendingViewModel.kt rename to app/src/main/java/com/keylesspalace/tusky/components/trending/viewmodel/TrendingTagsViewModel.kt index d1877a2fc..3237d8ead 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/trending/viewmodel/TrendingViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/trending/viewmodel/TrendingTagsViewModel.kt @@ -27,29 +27,34 @@ import com.keylesspalace.tusky.entity.start import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.util.toViewData import com.keylesspalace.tusky.viewdata.TrendingViewData +import java.io.IOException +import javax.inject.Inject import kotlinx.coroutines.async import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.launch -import java.io.IOException -import javax.inject.Inject -class TrendingViewModel @Inject constructor( +class TrendingTagsViewModel @Inject constructor( private val mastodonApi: MastodonApi, private val eventHub: EventHub ) : ViewModel() { enum class LoadingState { - INITIAL, LOADING, REFRESHING, LOADED, ERROR_NETWORK, ERROR_OTHER + INITIAL, + LOADING, + REFRESHING, + LOADED, + ERROR_NETWORK, + ERROR_OTHER } - data class TrendingUiState( + data class TrendingTagsUiState( val trendingViewData: List, val loadingState: LoadingState ) - val uiState: Flow get() = _uiState - private val _uiState = MutableStateFlow(TrendingUiState(listOf(), LoadingState.INITIAL)) + val uiState: Flow get() = _uiState + private val _uiState = MutableStateFlow(TrendingTagsUiState(listOf(), LoadingState.INITIAL)) init { invalidate() @@ -73,38 +78,42 @@ class TrendingViewModel @Inject constructor( */ fun invalidate(refresh: Boolean = false) = viewModelScope.launch { if (refresh) { - _uiState.value = TrendingUiState(emptyList(), LoadingState.REFRESHING) + _uiState.value = TrendingTagsUiState(emptyList(), LoadingState.REFRESHING) } else { - _uiState.value = TrendingUiState(emptyList(), LoadingState.LOADING) + _uiState.value = TrendingTagsUiState(emptyList(), LoadingState.LOADING) } val deferredFilters = async { mastodonApi.getFilters() } mastodonApi.trendingTags().fold( { tagResponse -> - val homeFilters = deferredFilters.await().getOrNull()?.filter { filter -> - filter.context.contains(Filter.Kind.HOME.kind) - } - val tags = tagResponse - .filter { tag -> - homeFilters?.none { filter -> - filter.keywords.any { keyword -> keyword.keyword.equals(tag.name, ignoreCase = true) } - } ?: false + + val firstTag = tagResponse.firstOrNull() + _uiState.value = if (firstTag == null) { + TrendingTagsUiState(emptyList(), LoadingState.LOADED) + } else { + val homeFilters = deferredFilters.await().getOrNull()?.filter { filter -> + filter.context.contains(Filter.Kind.HOME.kind) } - .sortedByDescending { tag -> tag.history.sumOf { it.uses.toLongOrNull() ?: 0 } } - .toViewData() + val tags = tagResponse + .filter { tag -> + homeFilters?.none { filter -> + filter.keywords.any { keyword -> keyword.keyword.equals(tag.name, ignoreCase = true) } + } ?: false + } + .sortedByDescending { tag -> tag.history.sumOf { it.uses.toLongOrNull() ?: 0 } } + .toViewData() - val firstTag = tagResponse.first() - val header = TrendingViewData.Header(firstTag.start(), firstTag.end()) - - _uiState.value = TrendingUiState(listOf(header) + tags, LoadingState.LOADED) + val header = TrendingViewData.Header(firstTag.start, firstTag.end) + TrendingTagsUiState(listOf(header) + tags, LoadingState.LOADED) + } }, { error -> Log.w(TAG, "failed loading trending tags", error) if (error is IOException) { - _uiState.value = TrendingUiState(emptyList(), LoadingState.ERROR_NETWORK) + _uiState.value = TrendingTagsUiState(emptyList(), LoadingState.ERROR_NETWORK) } else { - _uiState.value = TrendingUiState(emptyList(), LoadingState.ERROR_OTHER) + _uiState.value = TrendingTagsUiState(emptyList(), LoadingState.ERROR_OTHER) } } ) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ConversationLineItemDecoration.kt b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ConversationLineItemDecoration.kt index 36e36d10f..520445fac 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ConversationLineItemDecoration.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ConversationLineItemDecoration.kt @@ -24,42 +24,52 @@ import androidx.core.view.forEach import androidx.recyclerview.widget.RecyclerView import com.keylesspalace.tusky.R -class ConversationLineItemDecoration(private val context: Context) : RecyclerView.ItemDecoration() { +class ConversationLineItemDecoration(context: Context) : RecyclerView.ItemDecoration() { + private val divider: Drawable = ContextCompat.getDrawable( + context, + R.drawable.conversation_thread_line + )!! - private val divider: Drawable = ContextCompat.getDrawable(context, R.drawable.conversation_thread_line)!! + private val avatarTopMargin = context.resources.getDimensionPixelSize( + R.dimen.account_avatar_margin + ) + private val halfAvatarHeight = context.resources.getDimensionPixelSize(R.dimen.timeline_status_avatar_height) / 2 + private val statusLineMarginStart = context.resources.getDimensionPixelSize( + R.dimen.status_line_margin_start + ) override fun onDraw(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State) { - val dividerStart = parent.paddingStart + context.resources.getDimensionPixelSize(R.dimen.status_line_margin_start) + val dividerStart = parent.paddingStart + statusLineMarginStart val dividerEnd = dividerStart + divider.intrinsicWidth - val avatarMargin = context.resources.getDimensionPixelSize(R.dimen.account_avatar_margin) - val items = (parent.adapter as ThreadAdapter).currentList - parent.forEach { child -> + parent.forEach { statusItemView -> + val position = parent.getChildAdapterPosition(statusItemView) - val position = parent.getChildAdapterPosition(child) - - val current = items.getOrNull(position) - - if (current != null) { + items.getOrNull(position)?.let { current -> val above = items.getOrNull(position - 1) val dividerTop = if (above != null && above.id == current.status.inReplyToId) { - child.top + statusItemView.top } else { - child.top + avatarMargin + statusItemView.top + avatarTopMargin + halfAvatarHeight } val below = items.getOrNull(position + 1) val dividerBottom = if (below != null && current.id == below.status.inReplyToId && !current.isDetailed) { - child.bottom + statusItemView.bottom } else { - child.top + avatarMargin + statusItemView.top + avatarTopMargin + halfAvatarHeight } if (parent.layoutDirection == View.LAYOUT_DIRECTION_LTR) { divider.setBounds(dividerStart, dividerTop, dividerEnd, dividerBottom) } else { - divider.setBounds(canvas.width - dividerEnd, dividerTop, canvas.width - dividerStart, dividerBottom) + divider.setBounds( + canvas.width - dividerEnd, + dividerTop, + canvas.width - dividerStart, + dividerBottom + ) } divider.draw(canvas) } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ThreadAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ThreadAdapter.kt index 1edffcaf9..0c1c7fc5b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ThreadAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ThreadAdapter.kt @@ -43,7 +43,9 @@ class ThreadAdapter( StatusViewHolder(inflater.inflate(R.layout.item_status_wrapper, parent, false)) } VIEW_TYPE_STATUS_DETAILED -> { - StatusDetailedViewHolder(inflater.inflate(R.layout.item_status_detailed, parent, false)) + StatusDetailedViewHolder( + inflater.inflate(R.layout.item_status_detailed, parent, false) + ) } else -> error("Unknown item type: $viewType") } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadFragment.kt index 0e9f467e7..87aebe17f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadFragment.kt @@ -35,8 +35,8 @@ import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.SimpleItemAnimator import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener +import at.connyduck.calladapter.networkresult.onFailure import com.google.android.material.snackbar.Snackbar -import com.keylesspalace.tusky.BaseActivity import com.keylesspalace.tusky.R import com.keylesspalace.tusky.components.accountlist.AccountListActivity import com.keylesspalace.tusky.components.accountlist.AccountListActivity.Companion.newIntent @@ -53,14 +53,16 @@ import com.keylesspalace.tusky.util.StatusDisplayOptions import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.openLink import com.keylesspalace.tusky.util.show +import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.viewdata.AttachmentViewData.Companion.list import com.keylesspalace.tusky.viewdata.StatusViewData +import com.keylesspalace.tusky.viewdata.TranslationViewData +import javax.inject.Inject import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import javax.inject.Inject class ViewThreadFragment : SFragment(), @@ -98,18 +100,18 @@ class ViewThreadFragment : val preferences = PreferenceManager.getDefaultSharedPreferences(requireContext()) val statusDisplayOptions = StatusDisplayOptions( - animateAvatars = preferences.getBoolean("animateGifAvatars", false), + animateAvatars = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false), mediaPreviewEnabled = accountManager.activeAccount!!.mediaPreviewEnabled, - useAbsoluteTime = preferences.getBoolean("absoluteTimeView", false), - showBotOverlay = preferences.getBoolean("showBotOverlay", true), - useBlurhash = preferences.getBoolean("useBlurhash", true), - cardViewMode = if (preferences.getBoolean("showCardsInTimelines", false)) { + useAbsoluteTime = preferences.getBoolean(PrefKeys.ABSOLUTE_TIME_VIEW, false), + showBotOverlay = preferences.getBoolean(PrefKeys.SHOW_BOT_OVERLAY, true), + useBlurhash = preferences.getBoolean(PrefKeys.USE_BLURHASH, true), + cardViewMode = if (preferences.getBoolean(PrefKeys.SHOW_CARDS_IN_TIMELINES, false)) { CardViewMode.INDENTED } else { CardViewMode.NONE }, - confirmReblogs = preferences.getBoolean("confirmReblogs", true), - confirmFavourites = preferences.getBoolean("confirmFavourites", false), + confirmReblogs = preferences.getBoolean(PrefKeys.CONFIRM_REBLOGS, true), + confirmFavourites = preferences.getBoolean(PrefKeys.CONFIRM_FAVOURITES, false), hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false), animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false), showStatsInline = preferences.getBoolean(PrefKeys.SHOW_STATS_INLINE, false), @@ -166,6 +168,7 @@ class ViewThreadFragment : initialProgressBar = getProgressBarJob(binding.initialProgressBar, 500) initialProgressBar.start() } + is ThreadUiState.LoadingThread -> { if (uiState.statusViewDatum == null) { // no detailed statuses available, e.g. because author is blocked @@ -189,6 +192,7 @@ class ViewThreadFragment : binding.recyclerView.show() binding.statusView.hide() } + is ThreadUiState.Error -> { Log.w(TAG, "failed to load status", uiState.throwable) initialProgressBar.cancel() @@ -200,8 +204,11 @@ class ViewThreadFragment : binding.recyclerView.hide() binding.statusView.show() - binding.statusView.setup(uiState.throwable) { viewModel.retry(thisThreadsStatusId) } + binding.statusView.setup( + uiState.throwable + ) { viewModel.retry(thisThreadsStatusId) } } + is ThreadUiState.Success -> { if (uiState.statusViewData.none { viewData -> viewData.isDetailed }) { // no detailed statuses available, e.g. because author is blocked @@ -229,6 +236,7 @@ class ViewThreadFragment : binding.recyclerView.show() binding.statusView.hide() } + is ThreadUiState.Refreshing -> { threadProgressBar.cancel() } @@ -236,7 +244,7 @@ class ViewThreadFragment : } } - lifecycleScope.launch { + viewLifecycleOwner.lifecycleScope.launch { viewModel.errors.collect { throwable -> Log.w(TAG, "failed to load status context", throwable) Snackbar.make(binding.root, R.string.error_generic, Snackbar.LENGTH_SHORT) @@ -268,14 +276,17 @@ class ViewThreadFragment : viewModel.toggleRevealButton() true } + R.id.action_open_in_web -> { context?.openLink(requireArguments().getString(URL_EXTRA)!!) true } + R.id.action_refresh -> { onRefresh() true } + else -> false } } @@ -295,17 +306,18 @@ class ViewThreadFragment : * any time `view` is hidden. */ @CheckResult - private fun getProgressBarJob(view: View, delayMs: Long) = viewLifecycleOwner.lifecycleScope.launch( - start = CoroutineStart.LAZY - ) { - try { - delay(delayMs) - view.show() - awaitCancellation() - } finally { - view.hide() + private fun getProgressBarJob(view: View, delayMs: Long) = + viewLifecycleOwner.lifecycleScope.launch( + start = CoroutineStart.LAZY + ) { + try { + delay(delayMs) + view.show() + awaitCancellation() + } finally { + view.hide() + } } - } override fun onRefresh() { viewModel.refresh(thisThreadsStatusId) @@ -320,6 +332,36 @@ class ViewThreadFragment : viewModel.reblog(reblog, status) } + override val onMoreTranslate: ((translate: Boolean, position: Int) -> Unit) = + { translate: Boolean, position: Int -> + if (translate) { + onTranslate(position) + } else { + onUntranslate( + position + ) + } + } + + private fun onTranslate(position: Int) { + val status = adapter.currentList[position] + lifecycleScope.launch { + viewModel.translate(status) + .onFailure { + Snackbar.make( + requireView(), + getString(R.string.ui_error_translate, it.message), + Snackbar.LENGTH_LONG + ).show() + } + } + } + + override fun onUntranslate(position: Int) { + val status = adapter.currentList[position] + viewModel.untranslate(status) + } + override fun onFavourite(favourite: Boolean, position: Int) { val status = adapter.currentList[position] viewModel.favorite(favourite, status) @@ -331,12 +373,22 @@ class ViewThreadFragment : } override fun onMore(view: View, position: Int) { - super.more(adapter.currentList[position].status, view, position) + val viewData = adapter.currentList[position] + super.more( + viewData.status, + view, + position, + (viewData.translation as? TranslationViewData.Loaded)?.data + ) } override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) { val status = adapter.currentList[position].status - super.viewMedia(attachmentIndex, list(status), view) + super.viewMedia( + attachmentIndex, + list(status, alwaysShowSensitiveMedia), + view + ) } override fun onViewThread(position: Int) { @@ -379,13 +431,13 @@ class ViewThreadFragment : override fun onShowReblogs(position: Int) { val statusId = adapter.currentList[position].id val intent = newIntent(requireContext(), AccountListActivity.Type.REBLOGGED, statusId) - (requireActivity() as BaseActivity).startActivityWithSlideInAnimation(intent) + requireActivity().startActivityWithSlideInAnimation(intent) } override fun onShowFavs(position: Int) { val statusId = adapter.currentList[position].id val intent = newIntent(requireContext(), AccountListActivity.Type.FAVOURITED, statusId) - (requireActivity() as BaseActivity).startActivityWithSlideInAnimation(intent) + requireActivity().startActivityWithSlideInAnimation(intent) } override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) { @@ -421,7 +473,12 @@ class ViewThreadFragment : val viewEditsFragment = ViewEditsFragment.newInstance(status.actionableId) parentFragmentManager.commit { - setCustomAnimations(R.anim.slide_from_right, R.anim.slide_to_left, R.anim.slide_from_left, R.anim.slide_to_right) + setCustomAnimations( + R.anim.activity_open_enter, + R.anim.activity_open_exit, + R.anim.activity_close_enter, + R.anim.activity_close_exit + ) replace(R.id.fragment_container, viewEditsFragment, "ViewEditsFragment_$id") addToBackStack(null) } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadViewModel.kt index f4b94400b..795707c8d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadViewModel.kt @@ -18,19 +18,17 @@ package com.keylesspalace.tusky.components.viewthread import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import at.connyduck.calladapter.networkresult.NetworkResult import at.connyduck.calladapter.networkresult.fold import at.connyduck.calladapter.networkresult.getOrElse import at.connyduck.calladapter.networkresult.getOrThrow -import com.google.gson.Gson +import at.connyduck.calladapter.networkresult.map +import at.connyduck.calladapter.networkresult.onFailure import com.keylesspalace.tusky.appstore.BlockEvent -import com.keylesspalace.tusky.appstore.BookmarkEvent import com.keylesspalace.tusky.appstore.EventHub -import com.keylesspalace.tusky.appstore.FavoriteEvent -import com.keylesspalace.tusky.appstore.PinEvent -import com.keylesspalace.tusky.appstore.ReblogEvent +import com.keylesspalace.tusky.appstore.StatusChangedEvent import com.keylesspalace.tusky.appstore.StatusComposedEvent import com.keylesspalace.tusky.appstore.StatusDeletedEvent -import com.keylesspalace.tusky.appstore.StatusEditedEvent import com.keylesspalace.tusky.components.timeline.toViewData import com.keylesspalace.tusky.components.timeline.util.ifExpected import com.keylesspalace.tusky.db.AccountManager @@ -41,36 +39,43 @@ import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.network.FilterModel import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.usecase.TimelineCases +import com.keylesspalace.tusky.util.isHttpNotFound import com.keylesspalace.tusky.util.toViewData import com.keylesspalace.tusky.viewdata.StatusViewData +import com.keylesspalace.tusky.viewdata.TranslationViewData +import com.squareup.moshi.Moshi +import javax.inject.Inject import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import retrofit2.HttpException -import javax.inject.Inject class ViewThreadViewModel @Inject constructor( private val api: MastodonApi, private val filterModel: FilterModel, private val timelineCases: TimelineCases, eventHub: EventHub, - accountManager: AccountManager, + private val accountManager: AccountManager, private val db: AppDatabase, - private val gson: Gson + private val moshi: Moshi ) : ViewModel() { - private val _uiState: MutableStateFlow = MutableStateFlow(ThreadUiState.Loading) - val uiState: Flow - get() = _uiState + private val _uiState = MutableStateFlow(ThreadUiState.Loading as ThreadUiState) + val uiState: Flow = _uiState.asStateFlow() - private val _errors = MutableSharedFlow(replay = 0, extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) - val errors: Flow - get() = _errors + private val _errors = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + val errors: SharedFlow = _errors.asSharedFlow() var isInitialLoad: Boolean = true @@ -86,14 +91,10 @@ class ViewThreadViewModel @Inject constructor( eventHub.events .collect { event -> when (event) { - is FavoriteEvent -> handleFavEvent(event) - is ReblogEvent -> handleReblogEvent(event) - is BookmarkEvent -> handleBookmarkEvent(event) - is PinEvent -> handlePinEvent(event) + is StatusChangedEvent -> handleStatusChangedEvent(event.status) is BlockEvent -> removeAllByAccountId(event.accountId) is StatusComposedEvent -> handleStatusComposedEvent(event) is StatusDeletedEvent -> handleStatusDeletedEvent(event) - is StatusEditedEvent -> handleStatusEditedEvent(event) } } } @@ -107,13 +108,13 @@ class ViewThreadViewModel @Inject constructor( viewModelScope.launch { Log.d(TAG, "Finding status with: $id") val contextCall = async { api.statusContext(id) } - val timelineStatus = db.timelineDao().getStatus(id) + val timelineStatus = db.timelineDao().getStatus(accountManager.activeAccount!!.id, id) var detailedStatus = if (timelineStatus != null) { Log.d(TAG, "Loaded status from local timeline") val viewData = timelineStatus.toViewData( - gson, - isDetailed = true + moshi, + isDetailed = true, ) as StatusViewData.Concrete // Return the correct status, depending on which one matched. If you do not do @@ -144,15 +145,22 @@ class ViewThreadViewModel @Inject constructor( // for the status. Ignore errors, the user still has a functioning UI if the fetch // failed. if (timelineStatus != null) { - val viewData = api.status(id).getOrNull()?.toViewData(isDetailed = true) - if (viewData != null) { detailedStatus = viewData } + api.status(id).getOrNull()?.let { result -> + db.timelineDao().update( + accountId = accountManager.activeAccount!!.id, + status = result + ) + detailedStatus = result.toViewData(isDetailed = true) + } } val contextResult = contextCall.await() contextResult.fold({ statusContext -> - val ancestors = statusContext.ancestors.map { status -> status.toViewData() }.filter() - val descendants = statusContext.descendants.map { status -> status.toViewData() }.filter() + val ancestors = + statusContext.ancestors.map { status -> status.toViewData() }.filter() + val descendants = + statusContext.descendants.map { status -> status.toViewData() }.filter() val statuses = ancestors + detailedStatus + descendants _uiState.value = ThreadUiState.Success( @@ -186,6 +194,7 @@ class ViewThreadViewModel @Inject constructor( is ThreadUiState.Success -> uiState.statusViewData.find { status -> status.isDetailed } + is ThreadUiState.LoadingThread -> uiState.statusViewDatum else -> null } @@ -221,25 +230,26 @@ class ViewThreadViewModel @Inject constructor( } } - fun voteInPoll(choices: List, status: StatusViewData.Concrete): Job = viewModelScope.launch { - val poll = status.status.actionableStatus.poll ?: run { - Log.w(TAG, "No poll on status ${status.id}") - return@launch - } + fun voteInPoll(choices: List, status: StatusViewData.Concrete): Job = + viewModelScope.launch { + val poll = status.status.actionableStatus.poll ?: run { + Log.w(TAG, "No poll on status ${status.id}") + return@launch + } - val votedPoll = poll.votedCopy(choices) - updateStatus(status.id) { status -> - status.copy(poll = votedPoll) - } + val votedPoll = poll.votedCopy(choices) + updateStatus(status.id) { status -> + status.copy(poll = votedPoll) + } - try { - timelineCases.voteInPoll(status.actionableId, poll.id, choices).getOrThrow() - } catch (t: Exception) { - ifExpected(t) { - Log.d(TAG, "Failed to vote in poll: " + status.actionableId, t) + try { + timelineCases.voteInPoll(status.actionableId, poll.id, choices).getOrThrow() + } catch (t: Exception) { + ifExpected(t) { + Log.d(TAG, "Failed to vote in poll: " + status.actionableId, t) + } } } - } fun removeStatus(statusToRemove: StatusViewData.Concrete) { updateSuccess { uiState -> @@ -277,27 +287,38 @@ class ViewThreadViewModel @Inject constructor( } } - private fun handleFavEvent(event: FavoriteEvent) { - updateStatus(event.statusId) { status -> - status.copy(favourited = event.favourite) + suspend fun translate(status: StatusViewData.Concrete): NetworkResult { + updateStatusViewData(status.id) { viewData -> + viewData.copy(translation = TranslationViewData.Loading) + } + return timelineCases.translate(status.actionableId) + .map { translation -> + updateStatusViewData(status.id) { viewData -> + viewData.copy(translation = TranslationViewData.Loaded(translation)) + } + } + .onFailure { + updateStatusViewData(status.id) { viewData -> + viewData.copy(translation = null) + } + } + } + + fun untranslate(status: StatusViewData.Concrete) { + updateStatusViewData(status.id) { viewData -> + viewData.copy(translation = null) } } - private fun handleReblogEvent(event: ReblogEvent) { - updateStatus(event.statusId) { status -> - status.copy(reblogged = event.reblog) - } - } - - private fun handleBookmarkEvent(event: BookmarkEvent) { - updateStatus(event.statusId) { status -> - status.copy(bookmarked = event.bookmark) - } - } - - private fun handlePinEvent(event: PinEvent) { - updateStatus(event.statusId) { status -> - status.copy(pinned = event.pinned) + private fun handleStatusChangedEvent(status: Status) { + updateStatusViewData(status.id) { viewData -> + status.toViewData( + isShowingContent = viewData.isShowingContent, + isExpanded = viewData.isExpanded, + isCollapsed = viewData.isCollapsed, + isDetailed = viewData.isDetailed, + translation = viewData.translation, + ) } } @@ -316,7 +337,8 @@ class ViewThreadViewModel @Inject constructor( updateSuccess { uiState -> val statuses = uiState.statusViewData val detailedIndex = statuses.indexOfFirst { status -> status.isDetailed } - val repliedIndex = statuses.indexOfFirst { status -> eventStatus.inReplyToId == status.id } + val repliedIndex = + statuses.indexOfFirst { status -> eventStatus.inReplyToId == status.id } if (detailedIndex != -1 && repliedIndex >= detailedIndex) { // there is a new reply to the detailed status or below -> display it val newStatuses = statuses.subList(0, repliedIndex + 1) + @@ -329,20 +351,6 @@ class ViewThreadViewModel @Inject constructor( } } - private fun handleStatusEditedEvent(event: StatusEditedEvent) { - updateSuccess { uiState -> - uiState.copy( - statusViewData = uiState.statusViewData.map { status -> - if (status.actionableId == event.originalId) { - event.status.toViewData() - } else { - status - } - } - ) - } - } - private fun handleStatusDeletedEvent(event: StatusDeletedEvent) { updateSuccess { uiState -> uiState.copy( @@ -362,12 +370,14 @@ class ViewThreadViewModel @Inject constructor( }, revealButton = RevealButtonState.REVEAL ) + RevealButtonState.REVEAL -> uiState.copy( statusViewData = uiState.statusViewData.map { viewData -> viewData.copy(isExpanded = true) }, revealButton = RevealButtonState.HIDE ) + else -> uiState } } @@ -420,7 +430,7 @@ class ViewThreadViewModel @Inject constructor( updateStatuses() }, { throwable -> - if (throwable is HttpException && throwable.code() == 404) { + if (throwable.isHttpNotFound()) { val filters = api.getFiltersV1().getOrElse { Log.w(TAG, "Failed to fetch filters", it) return@launch @@ -459,12 +469,13 @@ class ViewThreadViewModel @Inject constructor( } } - private fun Status.toViewData( - isDetailed: Boolean = false - ): StatusViewData.Concrete { - val oldStatus = (_uiState.value as? ThreadUiState.Success)?.statusViewData?.find { it.id == this.id } + private fun Status.toViewData(isDetailed: Boolean = false): StatusViewData.Concrete { + val oldStatus = (_uiState.value as? ThreadUiState.Success)?.statusViewData?.find { + it.id == this.id + } return toViewData( - isShowingContent = oldStatus?.isShowingContent ?: (alwaysShowSensitiveMedia || !actionableStatus.sensitive), + isShowingContent = oldStatus?.isShowingContent + ?: (alwaysShowSensitiveMedia || !actionableStatus.sensitive), isExpanded = oldStatus?.isExpanded ?: alwaysOpenSpoiler, isCollapsed = oldStatus?.isCollapsed ?: !isDetailed, isDetailed = oldStatus?.isDetailed ?: isDetailed @@ -481,7 +492,10 @@ class ViewThreadViewModel @Inject constructor( } } - private fun updateStatusViewData(statusId: String, updater: (StatusViewData.Concrete) -> StatusViewData.Concrete) { + private fun updateStatusViewData( + statusId: String, + updater: (StatusViewData.Concrete) -> StatusViewData.Concrete + ) { updateSuccess { uiState -> uiState.copy( statusViewData = uiState.statusViewData.map { viewData -> @@ -505,7 +519,7 @@ class ViewThreadViewModel @Inject constructor( fun clearWarning(viewData: StatusViewData.Concrete) { updateStatus(viewData.id) { status -> - status.copy(filtered = null) + status.copy(filtered = emptyList()) } } @@ -516,7 +530,7 @@ class ViewThreadViewModel @Inject constructor( sealed interface ThreadUiState { /** The initial load of the detailed status for this thread */ - object Loading : ThreadUiState + data object Loading : ThreadUiState /** Loading the detailed status has completed, now loading ancestors/descendants */ data class LoadingThread( @@ -535,9 +549,11 @@ sealed interface ThreadUiState { ) : ThreadUiState /** Refreshing the thread with a swipe */ - object Refreshing : ThreadUiState + data object Refreshing : ThreadUiState } enum class RevealButtonState { - NO_BUTTON, REVEAL, HIDE + NO_BUTTON, + REVEAL, + HIDE } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/edits/ViewEditsAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/edits/ViewEditsAdapter.kt index 33085f82b..006d90b02 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/edits/ViewEditsAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/edits/ViewEditsAdapter.kt @@ -5,10 +5,7 @@ import android.graphics.Typeface.DEFAULT_BOLD import android.graphics.drawable.ColorDrawable import android.graphics.drawable.Drawable import android.text.Editable -import android.text.Html -import android.text.Spannable import android.text.SpannableStringBuilder -import android.text.Spanned import android.text.TextPaint import android.text.style.CharacterStyle import android.util.TypedValue @@ -29,6 +26,7 @@ import com.keylesspalace.tusky.entity.StatusEdit import com.keylesspalace.tusky.interfaces.LinkListener import com.keylesspalace.tusky.util.AbsoluteTimeFormatter import com.keylesspalace.tusky.util.BindingHolder +import com.keylesspalace.tusky.util.TuskyTagHandler import com.keylesspalace.tusky.util.aspectRatios import com.keylesspalace.tusky.util.decodeBlurHash import com.keylesspalace.tusky.util.emojify @@ -42,7 +40,6 @@ import org.xml.sax.XMLReader class ViewEditsAdapter( private val edits: List, - private val animateAvatars: Boolean, private val animateEmojis: Boolean, private val useBlurhash: Boolean, private val listener: LinkListener @@ -51,16 +48,20 @@ class ViewEditsAdapter( private val absoluteTimeFormatter = AbsoluteTimeFormatter() /** Size of large text in this theme, in px */ - var largeTextSizePx: Float = 0f + private var largeTextSizePx: Float = 0f /** Size of medium text in this theme, in px */ - var mediumTextSizePx: Float = 0f + private var mediumTextSizePx: Float = 0f override fun onCreateViewHolder( parent: ViewGroup, viewType: Int ): BindingHolder { - val binding = ItemStatusEditBinding.inflate(LayoutInflater.from(parent.context), parent, false) + val binding = ItemStatusEditBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) binding.statusEditMediaPreview.clipToOutline = true @@ -95,7 +96,10 @@ class ViewEditsAdapter( } else { largeTextSizePx } - binding.statusEditContentWarningDescription.setTextSize(TypedValue.COMPLEX_UNIT_PX, variableTextSize) + binding.statusEditContentWarningDescription.setTextSize( + TypedValue.COMPLEX_UNIT_PX, + variableTextSize + ) binding.statusEditContent.setTextSize(TypedValue.COMPLEX_UNIT_PX, variableTextSize) binding.statusEditMediaSensitivity.setTextSize(TypedValue.COMPLEX_UNIT_PX, variableTextSize) @@ -118,10 +122,16 @@ class ViewEditsAdapter( val emojifiedText = edit .content - .parseAsMastodonHtml(TuskyTagHandler(context)) + .parseAsMastodonHtml(EditsTagHandler(context)) .emojify(edit.emojis, binding.statusEditContent, animateEmojis) - setClickableText(binding.statusEditContent, emojifiedText, emptyList(), emptyList(), listener) + setClickableText( + binding.statusEditContent, + emojifiedText, + emptyList(), + emptyList(), + listener + ) if (edit.poll == null) { binding.statusEditPollOptions.hide() @@ -224,7 +234,7 @@ class ViewEditsAdapter( * Handle XML tags created by [ViewEditsViewModel] and create custom spans to display inserted or * deleted text. */ -class TuskyTagHandler(val context: Context) : Html.TagHandler { +class EditsTagHandler(val context: Context) : TuskyTagHandler() { /** Class to mark the start of a span of deleted text */ class Del @@ -255,34 +265,7 @@ class TuskyTagHandler(val context: Context) : Html.TagHandler { ) } } - } - } - - /** @return the last span in [text] of type [kind], or null if that kind is not in text */ - private fun getLast(text: Spanned, kind: Class): Any? { - val spans = text.getSpans(0, text.length, kind) - return spans?.get(spans.size - 1) - } - - /** - * Mark the start of a span of [text] with [mark] so it can be discovered later by [end]. - */ - private fun start(text: SpannableStringBuilder, mark: Any) { - val len = text.length - text.setSpan(mark, len, len, Spannable.SPAN_MARK_MARK) - } - - /** - * Set a [span] over the [text] most from the point recently marked with [mark] to the end - * of the text. - */ - private fun end(text: SpannableStringBuilder, mark: Class, span: Any) { - val len = text.length - val obj = getLast(text, mark) - val where = text.getSpanStart(obj) - text.removeSpan(obj) - if (where != len) { - text.setSpan(span, where, len, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + else -> super.handleTag(opening, tag, output, xmlReader) } } @@ -320,7 +303,7 @@ class TuskyTagHandler(val context: Context) : Html.TagHandler { // won't be called for it. const val DELETED_TEXT_EL = "tusky-del" - /** XML element to represet text that has been inserted */ + /** XML element to represent text that has been inserted */ // Can't be an element that Android's HTML parser recognises, otherwise the tagHandler // won't be called for it. const val INSERTED_TEXT_EL = "tusky-ins" diff --git a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/edits/ViewEditsFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/edits/ViewEditsFragment.kt index 7404f86aa..2a25ee872 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/edits/ViewEditsFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/edits/ViewEditsFragment.kt @@ -46,14 +46,15 @@ import com.keylesspalace.tusky.util.emojify import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.loadAvatar import com.keylesspalace.tusky.util.show +import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation import com.keylesspalace.tusky.util.unicodeWrap import com.keylesspalace.tusky.util.viewBinding import com.mikepenz.iconics.IconicsDrawable import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial import com.mikepenz.iconics.utils.colorInt import com.mikepenz.iconics.utils.sizeDp -import kotlinx.coroutines.launch import javax.inject.Inject +import kotlinx.coroutines.launch class ViewEditsFragment : Fragment(R.layout.fragment_view_edits), @@ -90,7 +91,9 @@ class ViewEditsFragment : val animateAvatars = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false) val animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false) val useBlurhash = preferences.getBoolean(PrefKeys.USE_BLURHASH, true) - val avatarRadius: Int = requireContext().resources.getDimensionPixelSize(R.dimen.avatar_radius_48dp) + val avatarRadius: Int = requireContext().resources.getDimensionPixelSize( + R.dimen.avatar_radius_48dp + ) viewLifecycleOwner.lifecycleScope.launch { viewModel.uiState.collect { uiState -> @@ -132,17 +135,23 @@ class ViewEditsFragment : binding.recyclerView.adapter = ViewEditsAdapter( edits = uiState.edits, - animateAvatars = animateAvatars, animateEmojis = animateEmojis, useBlurhash = useBlurhash, listener = this@ViewEditsFragment ) // Focus on the most recent version - (binding.recyclerView.layoutManager as LinearLayoutManager).scrollToPosition(0) + (binding.recyclerView.layoutManager as LinearLayoutManager).scrollToPosition( + 0 + ) val account = uiState.edits.first().account - loadAvatar(account.avatar, binding.statusAvatar, avatarRadius, animateAvatars) + loadAvatar( + account.avatar, + binding.statusAvatar, + avatarRadius, + animateAvatars + ) binding.statusDisplayName.text = account.name.unicodeWrap().emojify(account.emojis, binding.statusDisplayName, animateEmojis) binding.statusUsername.text = account.username @@ -185,11 +194,15 @@ class ViewEditsFragment : } override fun onViewAccount(id: String) { - bottomSheetActivity?.startActivityWithSlideInAnimation(AccountActivity.getIntent(requireContext(), id)) + bottomSheetActivity?.startActivityWithSlideInAnimation( + AccountActivity.getIntent(requireContext(), id) + ) } override fun onViewTag(tag: String) { - bottomSheetActivity?.startActivityWithSlideInAnimation(StatusListActivity.newHashtagIntent(requireContext(), tag)) + bottomSheetActivity?.startActivityWithSlideInAnimation( + StatusListActivity.newHashtagIntent(requireContext(), tag) + ) } override fun onViewUrl(url: String) { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/edits/ViewEditsViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/edits/ViewEditsViewModel.kt index 93f663587..a110c57a9 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/edits/ViewEditsViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/edits/ViewEditsViewModel.kt @@ -18,10 +18,11 @@ package com.keylesspalace.tusky.components.viewthread.edits import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import at.connyduck.calladapter.networkresult.getOrElse -import com.keylesspalace.tusky.components.viewthread.edits.TuskyTagHandler.Companion.DELETED_TEXT_EL -import com.keylesspalace.tusky.components.viewthread.edits.TuskyTagHandler.Companion.INSERTED_TEXT_EL +import com.keylesspalace.tusky.components.viewthread.edits.EditsTagHandler.Companion.DELETED_TEXT_EL +import com.keylesspalace.tusky.components.viewthread.edits.EditsTagHandler.Companion.INSERTED_TEXT_EL import com.keylesspalace.tusky.entity.StatusEdit import com.keylesspalace.tusky.network.MastodonApi +import javax.inject.Inject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -41,11 +42,10 @@ import org.pageseeder.diffx.token.impl.SpaceToken import org.pageseeder.diffx.xml.NamespaceSet import org.pageseeder.xmlwriter.XML.NamespaceAware import org.pageseeder.xmlwriter.XMLStringWriter -import javax.inject.Inject class ViewEditsViewModel @Inject constructor(private val api: MastodonApi) : ViewModel() { - private val _uiState: MutableStateFlow = MutableStateFlow(EditsUiState.Initial) + private val _uiState = MutableStateFlow(EditsUiState.Initial as EditsUiState) val uiState: StateFlow = _uiState.asStateFlow() /** The API call to fetch edit history returned less than two items */ @@ -132,12 +132,12 @@ class ViewEditsViewModel @Inject constructor(private val api: MastodonApi) : Vie } sealed interface EditsUiState { - object Initial : EditsUiState - object Loading : EditsUiState + data object Initial : EditsUiState + data object Loading : EditsUiState // "Refreshing" state is necessary, otherwise a refresh state transition is Success -> Success, // and state flows don't emit repeated states, so the UI never updates. - object Refreshing : EditsUiState + data object Refreshing : EditsUiState class Error(val throwable: Throwable) : EditsUiState data class Success( val edits: List diff --git a/app/src/main/java/com/keylesspalace/tusky/db/AccountEntity.kt b/app/src/main/java/com/keylesspalace/tusky/db/AccountEntity.kt index cdde765f7..40098593c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/AccountEntity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/AccountEntity.kt @@ -38,8 +38,10 @@ data class AccountEntity( @field:PrimaryKey(autoGenerate = true) var id: Long, val domain: String, var accessToken: String, - var clientId: String?, // nullable for backward compatibility - var clientSecret: String?, // nullable for backward compatibility + // nullable for backward compatibility + var clientId: String?, + // nullable for backward compatibility + var clientSecret: String?, var isActive: Boolean, var accountId: String = "", var username: String = "", @@ -100,7 +102,18 @@ data class AccountEntity( * ID of the status at the top of the visible list in the home timeline when the * user navigated away. */ - var lastVisibleHomeTimelineStatusId: String? = null + var lastVisibleHomeTimelineStatusId: String? = null, + + /** true if the connected Mastodon account is locked (has to manually approve all follow requests **/ + @ColumnInfo(defaultValue = "0") + var locked: Boolean = false, + + @ColumnInfo(defaultValue = "0") + var hasDirectMessageBadge: Boolean = false, + + var isShowHomeBoosts: Boolean = true, + var isShowHomeReplies: Boolean = true, + var isShowHomeSelfBoosts: Boolean = true ) { val identifier: String @@ -125,9 +138,7 @@ data class AccountEntity( other as AccountEntity if (id == other.id) return true - if (domain == other.domain && accountId == other.accountId) return true - - return false + return domain == other.domain && accountId == other.accountId } override fun hashCode(): Int { diff --git a/app/src/main/java/com/keylesspalace/tusky/db/AccountManager.kt b/app/src/main/java/com/keylesspalace/tusky/db/AccountManager.kt index 61ce076a5..d7397ea4e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/AccountManager.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/AccountManager.kt @@ -156,6 +156,7 @@ class AccountManager @Inject constructor(db: AppDatabase) { it.defaultPostLanguage = account.source?.language.orEmpty() it.defaultMediaSensitivity = account.source?.sensitive ?: false it.emojis = account.emojis.orEmpty() + it.locked = account.locked Log.d(TAG, "updateActiveAccount: saving account with id " + it.id) accountDao.insertOrReplace(it) @@ -235,7 +236,10 @@ class AccountManager @Inject constructor(db: AppDatabase) { */ fun shouldDisplaySelfUsername(context: Context): Boolean { val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context) - val showUsernamePreference = sharedPreferences.getString(PrefKeys.SHOW_SELF_USERNAME, "disambiguate") + val showUsernamePreference = sharedPreferences.getString( + PrefKeys.SHOW_SELF_USERNAME, + "disambiguate" + ) if (showUsernamePreference == "always") { return true } diff --git a/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java b/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java index a51273545..39261cb55 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java +++ b/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java @@ -42,11 +42,16 @@ import java.io.File; TimelineAccountEntity.class, ConversationEntity.class }, - version = 51, + // Note: Starting with version 54, database versions in Tusky are always even. + // This is to reserve odd version numbers for use by forks. + version = 58, autoMigrations = { @AutoMigration(from = 48, to = 49), @AutoMigration(from = 49, to = 50, spec = AppDatabase.MIGRATION_49_50.class), - @AutoMigration(from = 50, to = 51) + @AutoMigration(from = 50, to = 51), + @AutoMigration(from = 51, to = 52), + @AutoMigration(from = 53, to = 54), // hasDirectMessageBadge in AccountEntity + @AutoMigration(from = 56, to = 58) // translationEnabled in InstanceEntity/InstanceInfoEntity } ) public abstract class AppDatabase extends RoomDatabase { @@ -673,4 +678,24 @@ public abstract class AppDatabase extends RoomDatabase { @DeleteColumn(tableName = "AccountEntity", columnName = "activeNotifications") static class MIGRATION_49_50 implements AutoMigrationSpec { } + + /** + * TabData.TRENDING was renamed to TabData.TRENDING_TAGS, and the text + * representation was changed from "Trending" to "TrendingTags". + */ + public static final Migration MIGRATION_52_53 = new Migration(52, 53) { + @Override + public void migrate(@NonNull SupportSQLiteDatabase database) { + database.execSQL("UPDATE `AccountEntity` SET `tabpreferences` = REPLACE(tabpreferences, 'Trending:', 'TrendingTags:')"); + } + }; + + public static final Migration MIGRATION_54_56 = new Migration(54, 56) { + @Override + public void migrate(@NonNull SupportSQLiteDatabase database) { + database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `isShowHomeBoosts` INTEGER NOT NULL DEFAULT 1"); + database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `isShowHomeReplies` INTEGER NOT NULL DEFAULT 1"); + database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `isShowHomeSelfBoosts` INTEGER NOT NULL DEFAULT 1"); + } + }; } diff --git a/app/src/main/java/com/keylesspalace/tusky/db/Converters.kt b/app/src/main/java/com/keylesspalace/tusky/db/Converters.kt index 491cd53d8..026978802 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/Converters.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/Converters.kt @@ -17,38 +17,40 @@ package com.keylesspalace.tusky.db import androidx.room.ProvidedTypeConverter import androidx.room.TypeConverter -import com.google.gson.Gson -import com.google.gson.reflect.TypeToken import com.keylesspalace.tusky.TabData import com.keylesspalace.tusky.components.conversation.ConversationAccountEntity import com.keylesspalace.tusky.createTabDataFromId import com.keylesspalace.tusky.entity.Attachment +import com.keylesspalace.tusky.entity.Card import com.keylesspalace.tusky.entity.Emoji import com.keylesspalace.tusky.entity.FilterResult import com.keylesspalace.tusky.entity.HashTag import com.keylesspalace.tusky.entity.NewPoll import com.keylesspalace.tusky.entity.Poll import com.keylesspalace.tusky.entity.Status +import com.squareup.moshi.Moshi +import com.squareup.moshi.adapter import java.net.URLDecoder import java.net.URLEncoder import java.util.Date import javax.inject.Inject import javax.inject.Singleton +@OptIn(ExperimentalStdlibApi::class) @ProvidedTypeConverter @Singleton class Converters @Inject constructor( - private val gson: Gson + private val moshi: Moshi ) { @TypeConverter - fun jsonToEmojiList(emojiListJson: String?): List? { - return gson.fromJson(emojiListJson, object : TypeToken>() {}.type) + fun jsonToEmojiList(emojiListJson: String?): List { + return emojiListJson?.let { moshi.adapter?>().fromJson(it) }.orEmpty() } @TypeConverter - fun emojiListToJson(emojiList: List?): String { - return gson.toJson(emojiList) + fun emojiListToJson(emojiList: List): String { + return moshi.adapter>().toJson(emojiList) } @TypeConverter @@ -66,64 +68,69 @@ class Converters @Inject constructor( return str?.split(";") ?.map { val data = it.split(":") - createTabDataFromId(data[0], data.drop(1).map { s -> URLDecoder.decode(s, "UTF-8") }) + createTabDataFromId( + data[0], + data.drop(1).map { s -> URLDecoder.decode(s, "UTF-8") } + ) } } @TypeConverter fun tabDataToString(tabData: List?): String? { // List name may include ":" - return tabData?.joinToString(";") { it.id + ":" + it.arguments.joinToString(":") { s -> URLEncoder.encode(s, "UTF-8") } } + return tabData?.joinToString(";") { + it.id + ":" + it.arguments.joinToString(":") { s -> URLEncoder.encode(s, "UTF-8") } + } } @TypeConverter fun accountToJson(account: ConversationAccountEntity?): String { - return gson.toJson(account) + return moshi.adapter().toJson(account) } @TypeConverter fun jsonToAccount(accountJson: String?): ConversationAccountEntity? { - return gson.fromJson(accountJson, ConversationAccountEntity::class.java) + return accountJson?.let { moshi.adapter().fromJson(it) } } @TypeConverter - fun accountListToJson(accountList: List?): String { - return gson.toJson(accountList) + fun accountListToJson(accountList: List): String { + return moshi.adapter>().toJson(accountList) } @TypeConverter - fun jsonToAccountList(accountListJson: String?): List? { - return gson.fromJson(accountListJson, object : TypeToken>() {}.type) + fun jsonToAccountList(accountListJson: String?): List { + return accountListJson?.let { moshi.adapter?>().fromJson(it) }.orEmpty() } @TypeConverter - fun attachmentListToJson(attachmentList: List?): String { - return gson.toJson(attachmentList) + fun attachmentListToJson(attachmentList: List): String { + return moshi.adapter>().toJson(attachmentList) } @TypeConverter - fun jsonToAttachmentList(attachmentListJson: String?): List? { - return gson.fromJson(attachmentListJson, object : TypeToken>() {}.type) + fun jsonToAttachmentList(attachmentListJson: String?): List { + return attachmentListJson?.let { moshi.adapter?>().fromJson(it) }.orEmpty() } @TypeConverter - fun mentionListToJson(mentionArray: List?): String? { - return gson.toJson(mentionArray) + fun mentionListToJson(mentionArray: List): String { + return moshi.adapter>().toJson(mentionArray) } @TypeConverter - fun jsonToMentionArray(mentionListJson: String?): List? { - return gson.fromJson(mentionListJson, object : TypeToken>() {}.type) + fun jsonToMentionArray(mentionListJson: String?): List { + return mentionListJson?.let { moshi.adapter?>().fromJson(it) }.orEmpty() } @TypeConverter - fun tagListToJson(tagArray: List?): String? { - return gson.toJson(tagArray) + fun tagListToJson(tagArray: List?): String { + return moshi.adapter?>().toJson(tagArray) } @TypeConverter fun jsonToTagArray(tagListJson: String?): List? { - return gson.fromJson(tagListJson, object : TypeToken>() {}.type) + return tagListJson?.let { moshi.adapter?>().fromJson(it) } } @TypeConverter @@ -137,42 +144,47 @@ class Converters @Inject constructor( } @TypeConverter - fun pollToJson(poll: Poll?): String? { - return gson.toJson(poll) + fun pollToJson(poll: Poll?): String { + return moshi.adapter().toJson(poll) } @TypeConverter fun jsonToPoll(pollJson: String?): Poll? { - return gson.fromJson(pollJson, Poll::class.java) + return pollJson?.let { moshi.adapter().fromJson(it) } } @TypeConverter - fun newPollToJson(newPoll: NewPoll?): String? { - return gson.toJson(newPoll) + fun newPollToJson(newPoll: NewPoll?): String { + return moshi.adapter().toJson(newPoll) } @TypeConverter fun jsonToNewPoll(newPollJson: String?): NewPoll? { - return gson.fromJson(newPollJson, NewPoll::class.java) + return newPollJson?.let { moshi.adapter().fromJson(it) } } @TypeConverter - fun draftAttachmentListToJson(draftAttachments: List?): String? { - return gson.toJson(draftAttachments) + fun draftAttachmentListToJson(draftAttachments: List): String { + return moshi.adapter>().toJson(draftAttachments) } @TypeConverter - fun jsonToDraftAttachmentList(draftAttachmentListJson: String?): List? { - return gson.fromJson(draftAttachmentListJson, object : TypeToken>() {}.type) + fun jsonToDraftAttachmentList(draftAttachmentListJson: String?): List { + return draftAttachmentListJson?.let { moshi.adapter?>().fromJson(it) }.orEmpty() } @TypeConverter - fun filterResultListToJson(filterResults: List?): String? { - return gson.toJson(filterResults) + fun filterResultListToJson(filterResults: List?): String { + return moshi.adapter?>().toJson(filterResults) } @TypeConverter fun jsonToFilterResultList(filterResultListJson: String?): List? { - return gson.fromJson(filterResultListJson, object : TypeToken>() {}.type) + return filterResultListJson?.let { moshi.adapter?>().fromJson(it) } + } + + @TypeConverter + fun cardToJson(card: Card?): String { + return moshi.adapter().toJson(card) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/db/DraftDao.kt b/app/src/main/java/com/keylesspalace/tusky/db/DraftDao.kt index 7b1f62b8c..4d7239d02 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/DraftDao.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/DraftDao.kt @@ -15,12 +15,12 @@ package com.keylesspalace.tusky.db -import androidx.lifecycle.LiveData import androidx.paging.PagingSource import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query +import kotlinx.coroutines.flow.Flow @Dao interface DraftDao { @@ -32,9 +32,11 @@ interface DraftDao { fun draftsPagingSource(accountId: Long): PagingSource @Query("SELECT COUNT(*) FROM DraftEntity WHERE accountId = :accountId AND failedToSendNew = 1") - fun draftsNeedUserAlert(accountId: Long): LiveData + fun draftsNeedUserAlert(accountId: Long): Flow - @Query("UPDATE DraftEntity SET failedToSendNew = 0 WHERE accountId = :accountId AND failedToSendNew = 1") + @Query( + "UPDATE DraftEntity SET failedToSendNew = 0 WHERE accountId = :accountId AND failedToSendNew = 1" + ) suspend fun draftsClearNeedUserAlert(accountId: Long) @Query("SELECT * FROM DraftEntity WHERE accountId = :accountId") diff --git a/app/src/main/java/com/keylesspalace/tusky/db/DraftEntity.kt b/app/src/main/java/com/keylesspalace/tusky/db/DraftEntity.kt index 2a479ab34..a5928ca0b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/DraftEntity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/DraftEntity.kt @@ -21,10 +21,10 @@ import androidx.core.net.toUri import androidx.room.Entity import androidx.room.PrimaryKey import androidx.room.TypeConverters -import com.google.gson.annotations.SerializedName import com.keylesspalace.tusky.entity.Attachment import com.keylesspalace.tusky.entity.NewPoll import com.keylesspalace.tusky.entity.Status +import com.squareup.moshi.JsonClass import kotlinx.parcelize.Parcelize @Entity @@ -46,22 +46,21 @@ data class DraftEntity( val statusId: String? ) -/** - * The alternate names are here because we accidentally published versions were DraftAttachment was minified - * Tusky 15: uriString = e, description = f, type = g - * Tusky 16 beta: uriString = i, description = j, type = k - */ +@JsonClass(generateAdapter = true) @Parcelize data class DraftAttachment( - @SerializedName(value = "uriString", alternate = ["e", "i"]) val uriString: String, - @SerializedName(value = "description", alternate = ["f", "j"]) val description: String?, - @SerializedName(value = "focus") val focus: Attachment.Focus?, - @SerializedName(value = "type", alternate = ["g", "k"]) val type: Type + val uriString: String, + val description: String?, + val focus: Attachment.Focus?, + val type: Type ) : Parcelable { val uri: Uri get() = uriString.toUri() + @JsonClass(generateAdapter = false) enum class Type { - IMAGE, VIDEO, AUDIO; + IMAGE, + VIDEO, + AUDIO } } diff --git a/app/src/main/java/com/keylesspalace/tusky/db/DraftsAlert.kt b/app/src/main/java/com/keylesspalace/tusky/db/DraftsAlert.kt index 9d11f47d3..5b2993f68 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/DraftsAlert.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/DraftsAlert.kt @@ -1,99 +1,100 @@ -/* Copyright 2023 Andi McClure - * - * This file is a part of Tusky. - * - * 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. - * - * Tusky 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 Tusky; if not, - * see . */ - -package com.keylesspalace.tusky.db - -import android.content.Context -import android.content.DialogInterface -import android.util.Log -import androidx.appcompat.app.AlertDialog -import androidx.lifecycle.LifecycleCoroutineScope -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.lifecycleScope -import com.keylesspalace.tusky.R -import com.keylesspalace.tusky.components.drafts.DraftsActivity -import kotlinx.coroutines.launch -import javax.inject.Inject -import javax.inject.Singleton - -/** - * This class manages an alert popup when a post has failed and been saved to drafts. - * It must be separately registered in each lifetime in which it is to appear, - * and it only appears if the post failure belongs to the current user. - */ - -private const val TAG = "DraftsAlert" - -@Singleton -class DraftsAlert @Inject constructor(db: AppDatabase) { - // For tracking when a media upload fails in the service - private val draftDao: DraftDao = db.draftDao() - - @Inject - lateinit var accountManager: AccountManager - - fun observeInContext(context: T, showAlert: Boolean) where T : Context, T : LifecycleOwner { - accountManager.activeAccount?.let { activeAccount -> - val coroutineScope = context.lifecycleScope - - // Assume a single MainActivity, AccountActivity or DraftsActivity never sees more then one user id in its lifetime. - val activeAccountId = activeAccount.id - - // This LiveData will be automatically disposed when the activity is destroyed. - val draftsNeedUserAlert = draftDao.draftsNeedUserAlert(activeAccountId) - - // observe ensures that this gets called at the most appropriate moment wrt the context lifecycle— - // at init, at next onResume, or immediately if the context is resumed already. - if (showAlert) { - draftsNeedUserAlert.observe(context) { count -> - Log.d(TAG, "User id $activeAccountId changed: Notification-worthy draft count $count") - if (count > 0) { - AlertDialog.Builder(context) - .setTitle(R.string.action_post_failed) - .setMessage( - context.resources.getQuantityString(R.plurals.action_post_failed_detail, count) - ) - .setPositiveButton(R.string.action_post_failed_show_drafts) { _: DialogInterface?, _: Int -> - clearDraftsAlert(coroutineScope, activeAccountId) // User looked at drafts - - val intent = DraftsActivity.newIntent(context) - context.startActivity(intent) - } - .setNegativeButton(R.string.action_post_failed_do_nothing) { _: DialogInterface?, _: Int -> - clearDraftsAlert(coroutineScope, activeAccountId) // User doesn't care - } - .show() - } - } - } else { - draftsNeedUserAlert.observe(context) { - Log.d(TAG, "User id $activeAccountId: Clean out notification-worthy drafts") - clearDraftsAlert(coroutineScope, activeAccountId) - } - } - } ?: run { - Log.w(TAG, "Attempted to observe drafts, but there is no active account") - } - } - - /** - * Clear drafts alert for specified user - */ - private fun clearDraftsAlert(coroutineScope: LifecycleCoroutineScope, id: Long) { - coroutineScope.launch { - draftDao.draftsClearNeedUserAlert(id) - } - } -} +/* Copyright 2023 Andi McClure + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.db + +import android.content.Context +import android.content.DialogInterface +import android.util.Log +import androidx.appcompat.app.AlertDialog +import androidx.lifecycle.LifecycleCoroutineScope +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.lifecycleScope +import com.keylesspalace.tusky.R +import com.keylesspalace.tusky.components.drafts.DraftsActivity +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.launch + +/** + * This class manages an alert popup when a post has failed and been saved to drafts. + * It must be separately registered in each lifetime in which it is to appear, + * and it only appears if the post failure belongs to the current user. + */ + +private const val TAG = "DraftsAlert" + +@Singleton +class DraftsAlert @Inject constructor(db: AppDatabase) { + // For tracking when a media upload fails in the service + private val draftDao: DraftDao = db.draftDao() + + @Inject + lateinit var accountManager: AccountManager + + fun observeInContext(context: T, showAlert: Boolean) where T : Context, T : LifecycleOwner { + accountManager.activeAccount?.let { activeAccount -> + val coroutineScope = context.lifecycleScope + + // Assume a single MainActivity, AccountActivity or DraftsActivity never sees more then one user id in its lifetime. + val activeAccountId = activeAccount.id + + val draftsNeedUserAlert = draftDao.draftsNeedUserAlert(activeAccountId) + + // observe ensures that this gets called at the most appropriate moment wrt the context lifecycle— + // at init, at next onResume, or immediately if the context is resumed already. + coroutineScope.launch { + if (showAlert) { + draftsNeedUserAlert.collect { count -> + Log.d(TAG, "User id $activeAccountId changed: Notification-worthy draft count $count") + if (count > 0) { + AlertDialog.Builder(context) + .setTitle(R.string.action_post_failed) + .setMessage( + context.resources.getQuantityString(R.plurals.action_post_failed_detail, count) + ) + .setPositiveButton(R.string.action_post_failed_show_drafts) { _: DialogInterface?, _: Int -> + clearDraftsAlert(coroutineScope, activeAccountId) // User looked at drafts + + val intent = DraftsActivity.newIntent(context) + context.startActivity(intent) + } + .setNegativeButton(R.string.action_post_failed_do_nothing) { _: DialogInterface?, _: Int -> + clearDraftsAlert(coroutineScope, activeAccountId) // User doesn't care + } + .show() + } + } + } else { + draftsNeedUserAlert.collect { + Log.d(TAG, "User id $activeAccountId: Clean out notification-worthy drafts") + clearDraftsAlert(coroutineScope, activeAccountId) + } + } + } + } ?: run { + Log.w(TAG, "Attempted to observe drafts, but there is no active account") + } + } + + /** + * Clear drafts alert for specified user + */ + private fun clearDraftsAlert(coroutineScope: LifecycleCoroutineScope, id: Long) { + coroutineScope.launch { + draftDao.draftsClearNeedUserAlert(id) + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/db/InstanceEntity.kt b/app/src/main/java/com/keylesspalace/tusky/db/InstanceEntity.kt index efcfe5278..4db2ee050 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/InstanceEntity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/InstanceEntity.kt @@ -38,7 +38,8 @@ data class InstanceEntity( val maxMediaAttachments: Int?, val maxFields: Int?, val maxFieldNameLength: Int?, - val maxFieldValueLength: Int? + val maxFieldValueLength: Int?, + val translationEnabled: Boolean?, ) @TypeConverters(Converters::class) @@ -62,5 +63,6 @@ data class InstanceInfoEntity( val maxMediaAttachments: Int?, val maxFields: Int?, val maxFieldNameLength: Int?, - val maxFieldValueLength: Int? + val maxFieldValueLength: Int?, + val translationEnabled: Boolean?, ) diff --git a/app/src/main/java/com/keylesspalace/tusky/db/TimelineDao.kt b/app/src/main/java/com/keylesspalace/tusky/db/TimelineDao.kt index 0bf1267fa..3cd49baac 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/TimelineDao.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/TimelineDao.kt @@ -20,6 +20,13 @@ import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy.Companion.REPLACE import androidx.room.Query +import androidx.room.TypeConverters +import com.keylesspalace.tusky.entity.Attachment +import com.keylesspalace.tusky.entity.Card +import com.keylesspalace.tusky.entity.Emoji +import com.keylesspalace.tusky.entity.HashTag +import com.keylesspalace.tusky.entity.Poll +import com.keylesspalace.tusky.entity.Status @Dao abstract class TimelineDao { @@ -72,9 +79,10 @@ FROM TimelineStatusEntity s LEFT JOIN TimelineAccountEntity a ON (s.timelineUserId = a.timelineUserId AND s.authorServerId = a.serverId) LEFT JOIN TimelineAccountEntity rb ON (s.timelineUserId = rb.timelineUserId AND s.reblogAccountId = rb.serverId) WHERE (s.serverId = :statusId OR s.reblogServerId = :statusId) -AND s.authorServerId IS NOT NULL""" +AND s.authorServerId IS NOT NULL +AND s.timelineUserId = :accountId""" ) - abstract suspend fun getStatus(statusId: String): TimelineStatusWithAccount? + abstract suspend fun getStatus(accountId: Long, statusId: String): TimelineStatusWithAccount? @Query( """DELETE FROM TimelineStatusEntity WHERE timelineUserId = :accountId AND @@ -85,11 +93,82 @@ AND ) abstract suspend fun deleteRange(accountId: Long, minId: String, maxId: String): Int + suspend fun update(accountId: Long, status: Status) { + update( + accountId = accountId, + statusId = status.id, + content = status.content, + editedAt = status.editedAt?.time, + emojis = status.emojis, + reblogsCount = status.reblogsCount, + favouritesCount = status.favouritesCount, + repliesCount = status.repliesCount, + reblogged = status.reblogged, + bookmarked = status.bookmarked, + favourited = status.favourited, + sensitive = status.sensitive, + spoilerText = status.spoilerText, + visibility = status.visibility, + attachments = status.attachments, + mentions = status.mentions, + tags = status.tags, + poll = status.poll, + muted = status.muted, + pinned = status.pinned, + card = status.card, + language = status.language + ) + } + @Query( - """UPDATE TimelineStatusEntity SET favourited = :favourited -WHERE timelineUserId = :accountId AND (serverId = :statusId OR reblogServerId = :statusId)""" + """UPDATE TimelineStatusEntity + SET content = :content, + editedAt = :editedAt, + emojis = :emojis, + reblogsCount = :reblogsCount, + favouritesCount = :favouritesCount, + repliesCount = :repliesCount, + reblogged = :reblogged, + bookmarked = :bookmarked, + favourited = :favourited, + sensitive = :sensitive, + spoilerText = :spoilerText, + visibility = :visibility, + attachments = :attachments, + mentions = :mentions, + tags = :tags, + poll = :poll, + muted = :muted, + pinned = :pinned, + card = :card, + language = :language + WHERE timelineUserId = :accountId AND (serverId = :statusId OR reblogServerId = :statusId)""" + ) + @TypeConverters(Converters::class) + protected abstract suspend fun update( + accountId: Long, + statusId: String, + content: String?, + editedAt: Long?, + emojis: List, + reblogsCount: Int, + favouritesCount: Int, + repliesCount: Int, + reblogged: Boolean, + bookmarked: Boolean, + favourited: Boolean, + sensitive: Boolean, + spoilerText: String, + visibility: Status.Visibility, + attachments: List, + mentions: List, + tags: List?, + poll: Poll?, + muted: Boolean?, + pinned: Boolean, + card: Card?, + language: String? ) - abstract suspend fun setFavourited(accountId: Long, statusId: String, favourited: Boolean) @Query( """UPDATE TimelineStatusEntity SET bookmarked = :bookmarked @@ -168,7 +247,8 @@ AND serverId = :statusId""" """UPDATE TimelineStatusEntity SET poll = :poll WHERE timelineUserId = :accountId AND (serverId = :statusId OR reblogServerId = :statusId)""" ) - abstract suspend fun setVoted(accountId: Long, statusId: String, poll: String) + @TypeConverters(Converters::class) + abstract suspend fun setVoted(accountId: Long, statusId: String, poll: Poll) @Query( """UPDATE TimelineStatusEntity SET expanded = :expanded @@ -180,13 +260,21 @@ WHERE timelineUserId = :accountId AND (serverId = :statusId OR reblogServerId = """UPDATE TimelineStatusEntity SET contentShowing = :contentShowing WHERE timelineUserId = :accountId AND (serverId = :statusId OR reblogServerId = :statusId)""" ) - abstract suspend fun setContentShowing(accountId: Long, statusId: String, contentShowing: Boolean) + abstract suspend fun setContentShowing( + accountId: Long, + statusId: String, + contentShowing: Boolean + ) @Query( """UPDATE TimelineStatusEntity SET contentCollapsed = :contentCollapsed WHERE timelineUserId = :accountId AND (serverId = :statusId OR reblogServerId = :statusId)""" ) - abstract suspend fun setContentCollapsed(accountId: Long, statusId: String, contentCollapsed: Boolean) + abstract suspend fun setContentCollapsed( + accountId: Long, + statusId: String, + contentCollapsed: Boolean + ) @Query( """UPDATE TimelineStatusEntity SET pinned = :pinned @@ -203,39 +291,53 @@ AND timelineUserId = :accountId ) abstract suspend fun deleteAllFromInstance(accountId: Long, instanceDomain: String) - @Query("UPDATE TimelineStatusEntity SET filtered = NULL WHERE timelineUserId = :accountId AND (serverId = :statusId OR reblogServerId = :statusId)") + @Query( + "UPDATE TimelineStatusEntity SET filtered = NULL WHERE timelineUserId = :accountId AND (serverId = :statusId OR reblogServerId = :statusId)" + ) abstract suspend fun clearWarning(accountId: Long, statusId: String): Int - @Query("SELECT serverId FROM TimelineStatusEntity WHERE timelineUserId = :accountId ORDER BY LENGTH(serverId) DESC, serverId DESC LIMIT 1") + @Query( + "SELECT serverId FROM TimelineStatusEntity WHERE timelineUserId = :accountId ORDER BY LENGTH(serverId) DESC, serverId DESC LIMIT 1" + ) abstract suspend fun getTopId(accountId: Long): String? - @Query("SELECT serverId FROM TimelineStatusEntity WHERE timelineUserId = :accountId AND authorServerId IS NULL ORDER BY LENGTH(serverId) DESC, serverId DESC LIMIT 1") + @Query( + "SELECT serverId FROM TimelineStatusEntity WHERE timelineUserId = :accountId AND authorServerId IS NULL ORDER BY LENGTH(serverId) DESC, serverId DESC LIMIT 1" + ) abstract suspend fun getTopPlaceholderId(accountId: Long): String? /** * Returns the id directly above [serverId], or null if [serverId] is the id of the top status */ - @Query("SELECT serverId FROM TimelineStatusEntity WHERE timelineUserId = :accountId AND (LENGTH(:serverId) < LENGTH(serverId) OR (LENGTH(:serverId) = LENGTH(serverId) AND :serverId < serverId)) ORDER BY LENGTH(serverId) ASC, serverId ASC LIMIT 1") + @Query( + "SELECT serverId FROM TimelineStatusEntity WHERE timelineUserId = :accountId AND (LENGTH(:serverId) < LENGTH(serverId) OR (LENGTH(:serverId) = LENGTH(serverId) AND :serverId < serverId)) ORDER BY LENGTH(serverId) ASC, serverId ASC LIMIT 1" + ) abstract suspend fun getIdAbove(accountId: Long, serverId: String): String? /** * Returns the ID directly below [serverId], or null if [serverId] is the ID of the bottom * status */ - @Query("SELECT serverId FROM TimelineStatusEntity WHERE timelineUserId = :accountId AND (LENGTH(:serverId) > LENGTH(serverId) OR (LENGTH(:serverId) = LENGTH(serverId) AND :serverId > serverId)) ORDER BY LENGTH(serverId) DESC, serverId DESC LIMIT 1") + @Query( + "SELECT serverId FROM TimelineStatusEntity WHERE timelineUserId = :accountId AND (LENGTH(:serverId) > LENGTH(serverId) OR (LENGTH(:serverId) = LENGTH(serverId) AND :serverId > serverId)) ORDER BY LENGTH(serverId) DESC, serverId DESC LIMIT 1" + ) abstract suspend fun getIdBelow(accountId: Long, serverId: String): String? /** * Returns the id of the next placeholder after [serverId] */ - @Query("SELECT serverId FROM TimelineStatusEntity WHERE timelineUserId = :accountId AND authorServerId IS NULL AND (LENGTH(:serverId) > LENGTH(serverId) OR (LENGTH(:serverId) = LENGTH(serverId) AND :serverId > serverId)) ORDER BY LENGTH(serverId) DESC, serverId DESC LIMIT 1") + @Query( + "SELECT serverId FROM TimelineStatusEntity WHERE timelineUserId = :accountId AND authorServerId IS NULL AND (LENGTH(:serverId) > LENGTH(serverId) OR (LENGTH(:serverId) = LENGTH(serverId) AND :serverId > serverId)) ORDER BY LENGTH(serverId) DESC, serverId DESC LIMIT 1" + ) abstract suspend fun getNextPlaceholderIdAfter(accountId: Long, serverId: String): String? @Query("SELECT COUNT(*) FROM TimelineStatusEntity WHERE timelineUserId = :accountId") abstract suspend fun getStatusCount(accountId: Long): Int /** Developer tools: Find N most recent status IDs */ - @Query("SELECT serverId FROM TimelineStatusEntity WHERE timelineUserId = :accountId ORDER BY LENGTH(serverId) DESC, serverId DESC LIMIT :count") + @Query( + "SELECT serverId FROM TimelineStatusEntity WHERE timelineUserId = :accountId ORDER BY LENGTH(serverId) DESC, serverId DESC LIMIT :count" + ) abstract suspend fun getMostRecentNStatusIds(accountId: Long, count: Int): List /** Developer tools: Convert a status to a placeholder */ diff --git a/app/src/main/java/com/keylesspalace/tusky/db/TimelineStatusEntity.kt b/app/src/main/java/com/keylesspalace/tusky/db/TimelineStatusEntity.kt index f0f7f98ed..ba32f6380 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/TimelineStatusEntity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/TimelineStatusEntity.kt @@ -50,7 +50,8 @@ import com.keylesspalace.tusky.entity.Status ) @TypeConverters(Converters::class) data class TimelineStatusEntity( - val serverId: String, // id never flips: we need it for sorting so it's a real id + // id never flips: we need it for sorting so it's a real id + val serverId: String, val url: String?, // our local id for the logged in user in case there are multiple accounts per instance val timelineUserId: Long, @@ -74,7 +75,8 @@ data class TimelineStatusEntity( val mentions: String?, val tags: String?, val application: String?, - val reblogServerId: String?, // if it has a reblogged status, it's id is stored here + // if it has a reblogged status, it's id is stored here + val reblogServerId: String?, val reblogAccountId: String?, val poll: String?, val muted: Boolean?, @@ -109,8 +111,10 @@ data class TimelineAccountEntity( data class TimelineStatusWithAccount( @Embedded val status: TimelineStatusEntity, + // null when placeholder @Embedded(prefix = "a_") - val account: TimelineAccountEntity? = null, // null when placeholder + val account: TimelineAccountEntity? = null, + // null when no reblog @Embedded(prefix = "rb_") - val reblogAccount: TimelineAccountEntity? = null // null when no reblog + val reblogAccount: TimelineAccountEntity? = null ) diff --git a/app/src/main/java/com/keylesspalace/tusky/di/ActivitiesModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/ActivitiesModule.kt index 2ceb97213..a8fe4b9b6 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/ActivitiesModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/ActivitiesModule.kt @@ -29,11 +29,11 @@ import com.keylesspalace.tusky.components.account.AccountActivity import com.keylesspalace.tusky.components.accountlist.AccountListActivity import com.keylesspalace.tusky.components.announcements.AnnouncementsActivity import com.keylesspalace.tusky.components.compose.ComposeActivity +import com.keylesspalace.tusky.components.domainblocks.DomainBlocksActivity import com.keylesspalace.tusky.components.drafts.DraftsActivity import com.keylesspalace.tusky.components.filters.EditFilterActivity import com.keylesspalace.tusky.components.filters.FiltersActivity import com.keylesspalace.tusky.components.followedtags.FollowedTagsActivity -import com.keylesspalace.tusky.components.instancemute.InstanceListActivity import com.keylesspalace.tusky.components.login.LoginActivity import com.keylesspalace.tusky.components.login.LoginWebViewActivity import com.keylesspalace.tusky.components.preference.PreferencesActivity @@ -94,13 +94,13 @@ abstract class ActivitiesModule { @ContributesAndroidInjector(modules = [FragmentBuildersModule::class]) abstract fun contributesPreferencesActivity(): PreferencesActivity - @ContributesAndroidInjector + @ContributesAndroidInjector(modules = [FragmentBuildersModule::class]) abstract fun contributesViewMediaActivity(): ViewMediaActivity @ContributesAndroidInjector abstract fun contributesLicenseActivity(): LicenseActivity - @ContributesAndroidInjector + @ContributesAndroidInjector(modules = [FragmentBuildersModule::class]) abstract fun contributesTabPreferenceActivity(): TabPreferenceActivity @ContributesAndroidInjector @@ -113,7 +113,7 @@ abstract class ActivitiesModule { abstract fun contributesReportActivity(): ReportActivity @ContributesAndroidInjector(modules = [FragmentBuildersModule::class]) - abstract fun contributesInstanceListActivity(): InstanceListActivity + abstract fun contributesInstanceListActivity(): DomainBlocksActivity @ContributesAndroidInjector abstract fun contributesScheduledStatusActivity(): ScheduledStatusActivity diff --git a/app/src/main/java/com/keylesspalace/tusky/di/AppComponent.kt b/app/src/main/java/com/keylesspalace/tusky/di/AppComponent.kt index d922ab370..91045baef 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/AppComponent.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/AppComponent.kt @@ -36,7 +36,8 @@ import javax.inject.Singleton ServicesModule::class, BroadcastReceiverModule::class, ViewModelModule::class, - WorkerModule::class + WorkerModule::class, + PlayerModule::class ] ) interface AppComponent { diff --git a/app/src/main/java/com/keylesspalace/tusky/di/AppInjector.kt b/app/src/main/java/com/keylesspalace/tusky/di/AppInjector.kt index 6446a7357..fa6fae851 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/AppInjector.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/AppInjector.kt @@ -68,7 +68,11 @@ object AppInjector { if (activity is FragmentActivity) { activity.supportFragmentManager.registerFragmentLifecycleCallbacks( object : FragmentManager.FragmentLifecycleCallbacks() { - override fun onFragmentPreAttached(fm: FragmentManager, f: Fragment, context: Context) { + override fun onFragmentPreAttached( + fm: FragmentManager, + f: Fragment, + context: Context + ) { if (f is Injectable) { AndroidSupportInjection.inject(f) } diff --git a/app/src/main/java/com/keylesspalace/tusky/di/AppModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/AppModule.kt index bc2c7d753..0f9277dda 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/AppModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/AppModule.kt @@ -68,7 +68,7 @@ class AppModule { AppDatabase.MIGRATION_38_39, AppDatabase.MIGRATION_39_40, AppDatabase.MIGRATION_40_41, AppDatabase.MIGRATION_41_42, AppDatabase.MIGRATION_42_43, AppDatabase.MIGRATION_43_44, AppDatabase.MIGRATION_44_45, AppDatabase.MIGRATION_45_46, AppDatabase.MIGRATION_46_47, - AppDatabase.MIGRATION_47_48 + AppDatabase.MIGRATION_47_48, AppDatabase.MIGRATION_52_53, AppDatabase.MIGRATION_54_56 ) .build() } diff --git a/app/src/main/java/com/keylesspalace/tusky/di/CoroutineScopeModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/CoroutineScopeModule.kt index bee62f7ec..7eef6543a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/CoroutineScopeModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/CoroutineScopeModule.kt @@ -19,10 +19,11 @@ package com.keylesspalace.tusky.di import dagger.Module import dagger.Provides +import javax.inject.Qualifier +import javax.inject.Singleton import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob -import javax.inject.Qualifier /** * Scope for potentially long-running tasks that should outlive the viewmodel that @@ -40,5 +41,6 @@ annotation class ApplicationScope class CoroutineScopeModule { @ApplicationScope @Provides - fun providesApplicationScope() = CoroutineScope(SupervisorJob() + Dispatchers.Default) + @Singleton + fun providesApplicationScope() = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) } diff --git a/app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt index aee1feab4..b1a8b17ad 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/FragmentBuildersModule.kt @@ -16,15 +16,15 @@ package com.keylesspalace.tusky.di import com.keylesspalace.tusky.AccountsInListFragment -import com.keylesspalace.tusky.components.account.list.ListsForAccountFragment +import com.keylesspalace.tusky.components.account.list.ListSelectionFragment import com.keylesspalace.tusky.components.account.media.AccountMediaFragment import com.keylesspalace.tusky.components.accountlist.AccountListFragment import com.keylesspalace.tusky.components.conversation.ConversationsFragment -import com.keylesspalace.tusky.components.instancemute.fragment.InstanceListFragment -import com.keylesspalace.tusky.components.notifications.NotificationsFragment +import com.keylesspalace.tusky.components.domainblocks.DomainBlocksFragment import com.keylesspalace.tusky.components.preference.AccountPreferencesFragment import com.keylesspalace.tusky.components.preference.NotificationPreferencesFragment import com.keylesspalace.tusky.components.preference.PreferencesFragment +import com.keylesspalace.tusky.components.preference.TabFilterPreferencesFragment import com.keylesspalace.tusky.components.report.fragments.ReportDoneFragment import com.keylesspalace.tusky.components.report.fragments.ReportNoteFragment import com.keylesspalace.tusky.components.report.fragments.ReportStatusesFragment @@ -32,16 +32,14 @@ import com.keylesspalace.tusky.components.search.fragments.SearchAccountsFragmen import com.keylesspalace.tusky.components.search.fragments.SearchHashtagsFragment import com.keylesspalace.tusky.components.search.fragments.SearchStatusesFragment import com.keylesspalace.tusky.components.timeline.TimelineFragment -import com.keylesspalace.tusky.components.trending.TrendingFragment +import com.keylesspalace.tusky.components.trending.TrendingTagsFragment import com.keylesspalace.tusky.components.viewthread.ViewThreadFragment import com.keylesspalace.tusky.components.viewthread.edits.ViewEditsFragment +import com.keylesspalace.tusky.fragment.NotificationsFragment +import com.keylesspalace.tusky.fragment.ViewVideoFragment import dagger.Module import dagger.android.ContributesAndroidInjector -/** - * Created by charlag on 3/24/18. - */ - @Module abstract class FragmentBuildersModule { @ContributesAndroidInjector @@ -84,7 +82,7 @@ abstract class FragmentBuildersModule { abstract fun reportDoneFragment(): ReportDoneFragment @ContributesAndroidInjector - abstract fun instanceListFragment(): InstanceListFragment + abstract fun instanceListFragment(): DomainBlocksFragment @ContributesAndroidInjector abstract fun searchStatusesFragment(): SearchStatusesFragment @@ -99,8 +97,14 @@ abstract class FragmentBuildersModule { abstract fun preferencesFragment(): PreferencesFragment @ContributesAndroidInjector - abstract fun listsForAccountFragment(): ListsForAccountFragment + abstract fun listsForAccountFragment(): ListSelectionFragment @ContributesAndroidInjector - abstract fun trendingFragment(): TrendingFragment + abstract fun trendingTagsFragment(): TrendingTagsFragment + + @ContributesAndroidInjector + abstract fun viewVideoFragment(): ViewVideoFragment + + @ContributesAndroidInjector + abstract fun tabFilterPreferencesFragment(): TabFilterPreferencesFragment } diff --git a/app/src/main/java/com/keylesspalace/tusky/di/NetworkModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/NetworkModule.kt index 03b4ad394..b06ed2afd 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/NetworkModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/NetworkModule.kt @@ -20,11 +20,12 @@ import android.content.SharedPreferences import android.os.Build import android.util.Log import at.connyduck.calladapter.networkresult.NetworkResultCallAdapterFactory -import com.google.gson.Gson -import com.google.gson.GsonBuilder import com.keylesspalace.tusky.BuildConfig import com.keylesspalace.tusky.db.AccountManager -import com.keylesspalace.tusky.json.Rfc3339DateJsonAdapter +import com.keylesspalace.tusky.entity.Attachment +import com.keylesspalace.tusky.entity.Notification +import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.json.GuardedAdapter import com.keylesspalace.tusky.network.InstanceSwitchAuthInterceptor import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MediaUploadApi @@ -33,35 +34,56 @@ import com.keylesspalace.tusky.settings.PrefKeys.HTTP_PROXY_PORT import com.keylesspalace.tusky.settings.PrefKeys.HTTP_PROXY_SERVER import com.keylesspalace.tusky.settings.ProxyConfiguration import com.keylesspalace.tusky.util.getNonNullString +import com.squareup.moshi.Moshi +import com.squareup.moshi.adapters.EnumJsonAdapter +import com.squareup.moshi.adapters.Rfc3339DateJsonAdapter import dagger.Module import dagger.Provides -import okhttp3.Cache -import okhttp3.OkHttp -import okhttp3.OkHttpClient -import okhttp3.logging.HttpLoggingInterceptor -import retrofit2.Retrofit -import retrofit2.adapter.rxjava3.RxJava3CallAdapterFactory -import retrofit2.converter.gson.GsonConverterFactory -import retrofit2.create import java.net.IDN import java.net.InetSocketAddress import java.net.Proxy import java.util.Date import java.util.concurrent.TimeUnit import javax.inject.Singleton +import okhttp3.Cache +import okhttp3.OkHttp +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import retrofit2.Retrofit +import retrofit2.converter.moshi.MoshiConverterFactory +import retrofit2.create /** * Created by charlag on 3/24/18. */ @Module -class NetworkModule { +object NetworkModule { + + private const val TAG = "NetworkModule" @Provides @Singleton - fun providesGson(): Gson = GsonBuilder() - .registerTypeAdapter(Date::class.java, Rfc3339DateJsonAdapter()) - .create() + fun providesMoshi(): Moshi = Moshi.Builder() + .add(Date::class.java, Rfc3339DateJsonAdapter()) + .add(GuardedAdapter.ANNOTATION_FACTORY) + // Enum types with fallback value + .add( + Attachment.Type::class.java, + EnumJsonAdapter.create(Attachment.Type::class.java) + .withUnknownFallback(Attachment.Type.UNKNOWN) + ) + .add( + Notification.Type::class.java, + EnumJsonAdapter.create(Notification.Type::class.java) + .withUnknownFallback(Notification.Type.UNKNOWN) + ) + .add( + Status.Visibility::class.java, + EnumJsonAdapter.create(Status.Visibility::class.java) + .withUnknownFallback(Status.Visibility.UNKNOWN) + ) + .build() @Provides @Singleton @@ -104,7 +126,9 @@ class NetworkModule { .apply { addInterceptor(InstanceSwitchAuthInterceptor(accountManager)) if (BuildConfig.DEBUG) { - addInterceptor(HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BASIC }) + addInterceptor( + HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BASIC } + ) } } .build() @@ -112,14 +136,10 @@ class NetworkModule { @Provides @Singleton - fun providesRetrofit( - httpClient: OkHttpClient, - gson: Gson - ): Retrofit { + fun providesRetrofit(httpClient: OkHttpClient, moshi: Moshi): Retrofit { return Retrofit.Builder().baseUrl("https://" + MastodonApi.PLACEHOLDER_DOMAIN) .client(httpClient) - .addConverterFactory(GsonConverterFactory.create(gson)) - .addCallAdapterFactory(RxJava3CallAdapterFactory.create()) + .addConverterFactory(MoshiConverterFactory.create(moshi)) .addCallAdapterFactory(NetworkResultCallAdapterFactory.create()) .build() } @@ -141,8 +161,4 @@ class NetworkModule { .build() .create() } - - companion object { - private const val TAG = "NetworkModule" - } } diff --git a/app/src/main/java/com/keylesspalace/tusky/di/PlayerModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/PlayerModule.kt new file mode 100644 index 000000000..f12587fc5 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/di/PlayerModule.kt @@ -0,0 +1,182 @@ +/* + * Copyright 2024 Tusky Contributors + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . + */ + +package com.keylesspalace.tusky.di + +import android.content.Context +import android.os.Looper +import androidx.annotation.OptIn +import androidx.media3.common.C +import androidx.media3.common.Format +import androidx.media3.common.MimeTypes +import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.DataSource +import androidx.media3.datasource.DefaultDataSource +import androidx.media3.datasource.okhttp.OkHttpDataSource +import androidx.media3.exoplayer.DefaultRenderersFactory +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.RenderersFactory +import androidx.media3.exoplayer.audio.AudioSink +import androidx.media3.exoplayer.audio.DefaultAudioSink +import androidx.media3.exoplayer.audio.MediaCodecAudioRenderer +import androidx.media3.exoplayer.mediacodec.MediaCodecSelector +import androidx.media3.exoplayer.metadata.MetadataRenderer +import androidx.media3.exoplayer.source.MediaSource +import androidx.media3.exoplayer.source.ProgressiveMediaSource +import androidx.media3.exoplayer.text.TextRenderer +import androidx.media3.exoplayer.video.MediaCodecVideoRenderer +import androidx.media3.extractor.ExtractorsFactory +import androidx.media3.extractor.flac.FlacExtractor +import androidx.media3.extractor.mkv.MatroskaExtractor +import androidx.media3.extractor.mp3.Mp3Extractor +import androidx.media3.extractor.mp4.FragmentedMp4Extractor +import androidx.media3.extractor.mp4.Mp4Extractor +import androidx.media3.extractor.ogg.OggExtractor +import androidx.media3.extractor.text.SubtitleParser +import androidx.media3.extractor.text.ttml.TtmlParser +import androidx.media3.extractor.text.webvtt.Mp4WebvttParser +import androidx.media3.extractor.text.webvtt.WebvttParser +import androidx.media3.extractor.wav.WavExtractor +import dagger.Module +import dagger.Provides +import okhttp3.OkHttpClient + +@Module +@OptIn(UnstableApi::class) +object PlayerModule { + @Provides + fun provideAudioSink(context: Context): AudioSink { + return DefaultAudioSink.Builder(context) + .build() + } + + @Provides + fun provideRenderersFactory(context: Context, audioSink: AudioSink): RenderersFactory { + return RenderersFactory { eventHandler, + videoRendererEventListener, + audioRendererEventListener, + textRendererOutput, + metadataRendererOutput -> + arrayOf( + MediaCodecVideoRenderer( + context, + MediaCodecSelector.DEFAULT, + DefaultRenderersFactory.DEFAULT_ALLOWED_VIDEO_JOINING_TIME_MS, + // enableDecoderFallback = true, helps playing videos even if one decoder fails + true, + eventHandler, + videoRendererEventListener, + DefaultRenderersFactory.MAX_DROPPED_VIDEO_FRAME_COUNT_TO_NOTIFY + ), + MediaCodecAudioRenderer( + context, + MediaCodecSelector.DEFAULT, + // enableDecoderFallback = true + true, + eventHandler, + audioRendererEventListener, + audioSink + ), + TextRenderer( + textRendererOutput, + eventHandler.looper + ), + MetadataRenderer( + metadataRendererOutput, + eventHandler.looper + ) + ) + } + } + + @Provides + fun providesSubtitleParserFactory(): SubtitleParser.Factory { + return object : SubtitleParser.Factory { + override fun supportsFormat(format: Format): Boolean { + return when (format.sampleMimeType) { + MimeTypes.TEXT_VTT, + MimeTypes.APPLICATION_MP4VTT, + MimeTypes.APPLICATION_TTML -> true + + else -> false + } + } + + override fun getCueReplacementBehavior(format: Format): Int { + return when (val mimeType = format.sampleMimeType) { + MimeTypes.TEXT_VTT -> WebvttParser.CUE_REPLACEMENT_BEHAVIOR + MimeTypes.APPLICATION_MP4VTT -> Mp4WebvttParser.CUE_REPLACEMENT_BEHAVIOR + MimeTypes.APPLICATION_TTML -> TtmlParser.CUE_REPLACEMENT_BEHAVIOR + else -> throw IllegalArgumentException("Unsupported MIME type: $mimeType") + } + } + + override fun create(format: Format): SubtitleParser { + return when (val mimeType = format.sampleMimeType) { + MimeTypes.TEXT_VTT -> WebvttParser() + MimeTypes.APPLICATION_MP4VTT -> Mp4WebvttParser() + MimeTypes.APPLICATION_TTML -> TtmlParser() + else -> throw IllegalArgumentException("Unsupported MIME type: $mimeType") + } + } + } + } + + @Provides + fun provideExtractorsFactory(subtitleParserFactory: SubtitleParser.Factory): ExtractorsFactory { + // Extractors order is optimized according to + // https://docs.google.com/document/d/1w2mKaWMxfz2Ei8-LdxqbPs1VLe_oudB-eryXXw9OvQQ + return ExtractorsFactory { + arrayOf( + FlacExtractor(), + WavExtractor(), + Mp4Extractor(subtitleParserFactory), + FragmentedMp4Extractor(subtitleParserFactory), + OggExtractor(), + MatroskaExtractor(subtitleParserFactory), + Mp3Extractor() + ) + } + } + + @Provides + fun provideDataSourceFactory(context: Context, okHttpClient: OkHttpClient): DataSource.Factory { + return DefaultDataSource.Factory(context, OkHttpDataSource.Factory(okHttpClient)) + } + + @Provides + fun provideMediaSourceFactory( + dataSourceFactory: DataSource.Factory, + extractorsFactory: ExtractorsFactory + ): MediaSource.Factory { + // Only progressive download is supported for Mastodon attachments + return ProgressiveMediaSource.Factory(dataSourceFactory, extractorsFactory) + } + + @Provides + fun provideExoPlayer( + context: Context, + renderersFactory: RenderersFactory, + mediaSourceFactory: MediaSource.Factory + ): ExoPlayer { + return ExoPlayer.Builder(context, renderersFactory, mediaSourceFactory) + .setLooper(Looper.getMainLooper()) + .setHandleAudioBecomingNoisy(true) // automatically pause when unplugging headphones + .setWakeMode(C.WAKE_MODE_NONE) // playback is always in the foreground + .build() + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt b/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt index af1972d5f..d2c86a598 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/ViewModelFactory.kt @@ -27,18 +27,18 @@ import com.keylesspalace.tusky.components.account.media.AccountMediaViewModel import com.keylesspalace.tusky.components.announcements.AnnouncementsViewModel import com.keylesspalace.tusky.components.compose.ComposeViewModel import com.keylesspalace.tusky.components.conversation.ConversationsViewModel +import com.keylesspalace.tusky.components.domainblocks.DomainBlocksViewModel import com.keylesspalace.tusky.components.drafts.DraftsViewModel import com.keylesspalace.tusky.components.filters.EditFilterViewModel import com.keylesspalace.tusky.components.filters.FiltersViewModel import com.keylesspalace.tusky.components.followedtags.FollowedTagsViewModel import com.keylesspalace.tusky.components.login.LoginWebViewViewModel -import com.keylesspalace.tusky.components.notifications.NotificationsViewModel import com.keylesspalace.tusky.components.report.ReportViewModel import com.keylesspalace.tusky.components.scheduled.ScheduledStatusViewModel import com.keylesspalace.tusky.components.search.SearchViewModel import com.keylesspalace.tusky.components.timeline.viewmodel.CachedTimelineViewModel import com.keylesspalace.tusky.components.timeline.viewmodel.NetworkTimelineViewModel -import com.keylesspalace.tusky.components.trending.viewmodel.TrendingViewModel +import com.keylesspalace.tusky.components.trending.viewmodel.TrendingTagsViewModel import com.keylesspalace.tusky.components.viewthread.ViewThreadViewModel import com.keylesspalace.tusky.components.viewthread.edits.ViewEditsViewModel import com.keylesspalace.tusky.viewmodel.AccountsInListViewModel @@ -54,12 +54,19 @@ import javax.inject.Singleton import kotlin.reflect.KClass @Singleton -class ViewModelFactory @Inject constructor(private val viewModels: MutableMap, Provider>) : ViewModelProvider.Factory { +class ViewModelFactory @Inject constructor( + private val viewModels: MutableMap, Provider> +) : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") - override fun create(modelClass: Class): T = viewModels[modelClass]?.get() as T + override fun create(modelClass: Class): T = + viewModels[modelClass]?.get() as T } -@Target(AnnotationTarget.FUNCTION, AnnotationTarget.PROPERTY_GETTER, AnnotationTarget.PROPERTY_SETTER) +@Target( + AnnotationTarget.FUNCTION, + AnnotationTarget.PROPERTY_GETTER, + AnnotationTarget.PROPERTY_SETTER +) @Retention(AnnotationRetention.RUNTIME) @MapKey internal annotation class ViewModelKey(val value: KClass) @@ -167,13 +174,8 @@ abstract class ViewModelModule { @Binds @IntoMap - @ViewModelKey(NotificationsViewModel::class) - internal abstract fun notificationsViewModel(viewModel: NotificationsViewModel): ViewModel - - @Binds - @IntoMap - @ViewModelKey(TrendingViewModel::class) - internal abstract fun trendingViewModel(viewModel: TrendingViewModel): ViewModel + @ViewModelKey(TrendingTagsViewModel::class) + internal abstract fun trendingTagsViewModel(viewModel: TrendingTagsViewModel): ViewModel @Binds @IntoMap @@ -185,5 +187,10 @@ abstract class ViewModelModule { @ViewModelKey(EditFilterViewModel::class) internal abstract fun editFilterViewModel(viewModel: EditFilterViewModel): ViewModel + @Binds + @IntoMap + @ViewModelKey(DomainBlocksViewModel::class) + internal abstract fun instanceMuteViewModel(viewModel: DomainBlocksViewModel): ViewModel + // Add more ViewModels here } diff --git a/app/src/main/java/com/keylesspalace/tusky/di/WorkerModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/WorkerModule.kt index 212d4d319..a2cccf992 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/WorkerModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/WorkerModule.kt @@ -36,10 +36,14 @@ abstract class WorkerModule { @Binds @IntoMap @WorkerKey(NotificationWorker::class) - internal abstract fun bindNotificationWorkerFactory(worker: NotificationWorker.Factory): ChildWorkerFactory + internal abstract fun bindNotificationWorkerFactory( + worker: NotificationWorker.Factory + ): ChildWorkerFactory @Binds @IntoMap @WorkerKey(PruneCacheWorker::class) - internal abstract fun bindPruneCacheWorkerFactory(worker: PruneCacheWorker.Factory): ChildWorkerFactory + internal abstract fun bindPruneCacheWorkerFactory( + worker: PruneCacheWorker.Factory + ): ChildWorkerFactory } diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/AccessToken.kt b/app/src/main/java/com/keylesspalace/tusky/entity/AccessToken.kt index e974ce196..150b4b4d0 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/AccessToken.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/AccessToken.kt @@ -15,8 +15,10 @@ package com.keylesspalace.tusky.entity -import com.google.gson.annotations.SerializedName +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +@JsonClass(generateAdapter = true) data class AccessToken( - @SerializedName("access_token") val accessToken: String + @Json(name = "access_token") val accessToken: String ) diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Account.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Account.kt index 43b9bd9c9..15e2f99af 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Account.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Account.kt @@ -15,29 +15,34 @@ package com.keylesspalace.tusky.entity -import com.google.gson.annotations.SerializedName +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass import java.util.Date +@JsonClass(generateAdapter = true) data class Account( val id: String, - @SerializedName("username") val localUsername: String, - @SerializedName("acct") val username: String, - @SerializedName("display_name") val displayName: String?, // should never be null per Api definition, but some servers break the contract - @SerializedName("created_at") val createdAt: Date, + @Json(name = "username") val localUsername: String, + @Json(name = "acct") val username: String, + // should never be null per Api definition, but some servers break the contract + @Json(name = "display_name") val displayName: String? = null, + @Json(name = "created_at") val createdAt: Date, val note: String, val url: String, val avatar: String, val header: String, val locked: Boolean = false, - @SerializedName("followers_count") val followersCount: Int = 0, - @SerializedName("following_count") val followingCount: Int = 0, - @SerializedName("statuses_count") val statusesCount: Int = 0, + @Json(name = "followers_count") val followersCount: Int = 0, + @Json(name = "following_count") val followingCount: Int = 0, + @Json(name = "statuses_count") val statusesCount: Int = 0, val source: AccountSource? = null, val bot: Boolean = false, - val emojis: List? = emptyList(), // nullable for backward compatibility - val fields: List? = emptyList(), // nullable for backward compatibility - val moved: Account? = null - + // default value for backward compatibility + val emojis: List = emptyList(), + // default value for backward compatibility + val fields: List = emptyList(), + val moved: Account? = null, + val roles: List = emptyList() ) { val name: String @@ -47,24 +52,34 @@ data class Account( displayName } - fun isRemote(): Boolean = this.username != this.localUsername + val isRemote: Boolean + get() = this.username != this.localUsername } +@JsonClass(generateAdapter = true) data class AccountSource( - val privacy: Status.Visibility?, - val sensitive: Boolean?, - val note: String?, - val fields: List?, - val language: String? + val privacy: Status.Visibility = Status.Visibility.PUBLIC, + val sensitive: Boolean? = null, + val note: String? = null, + val fields: List = emptyList(), + val language: String? = null ) +@JsonClass(generateAdapter = true) data class Field( val name: String, val value: String, - @SerializedName("verified_at") val verifiedAt: Date? + @Json(name = "verified_at") val verifiedAt: Date? = null ) +@JsonClass(generateAdapter = true) data class StringField( val name: String, val value: String ) + +@JsonClass(generateAdapter = true) +data class Role( + val name: String, + val color: String +) diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Announcement.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Announcement.kt index 5837815b0..792c2423b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Announcement.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Announcement.kt @@ -15,18 +15,20 @@ package com.keylesspalace.tusky.entity -import com.google.gson.annotations.SerializedName +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass import java.util.Date +@JsonClass(generateAdapter = true) data class Announcement( val id: String, val content: String, - @SerializedName("starts_at") val startsAt: Date?, - @SerializedName("ends_at") val endsAt: Date?, - @SerializedName("all_day") val allDay: Boolean, - @SerializedName("published_at") val publishedAt: Date, - @SerializedName("updated_at") val updatedAt: Date, - val read: Boolean, + @Json(name = "starts_at") val startsAt: Date? = null, + @Json(name = "ends_at") val endsAt: Date? = null, + @Json(name = "all_day") val allDay: Boolean, + @Json(name = "published_at") val publishedAt: Date, + @Json(name = "updated_at") val updatedAt: Date, + val read: Boolean = false, val mentions: List, val statuses: List, val tags: List, @@ -36,21 +38,21 @@ data class Announcement( override fun equals(other: Any?): Boolean { if (this === other) return true - if (other == null || javaClass != other.javaClass) return false + if (other !is Announcement) return false - val announcement = other as Announcement? - return id == announcement?.id + return id == other.id } override fun hashCode(): Int { return id.hashCode() } + @JsonClass(generateAdapter = true) data class Reaction( val name: String, val count: Int, - val me: Boolean, - val url: String?, - @SerializedName("static_url") val staticUrl: String? + val me: Boolean = false, + val url: String? = null, + @Json(name = "static_url") val staticUrl: String? = null ) } diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/AppCredentials.kt b/app/src/main/java/com/keylesspalace/tusky/entity/AppCredentials.kt index fe6b0c3ce..50914132c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/AppCredentials.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/AppCredentials.kt @@ -15,9 +15,11 @@ package com.keylesspalace.tusky.entity -import com.google.gson.annotations.SerializedName +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +@JsonClass(generateAdapter = true) data class AppCredentials( - @SerializedName("client_id") val clientId: String, - @SerializedName("client_secret") val clientSecret: String + @Json(name = "client_id") val clientId: String, + @Json(name = "client_secret") val clientSecret: String ) diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Attachment.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Attachment.kt index 0ebd9c370..564ba3dad 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Attachment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Attachment.kt @@ -16,65 +16,50 @@ package com.keylesspalace.tusky.entity import android.os.Parcelable -import com.google.gson.JsonDeserializationContext -import com.google.gson.JsonDeserializer -import com.google.gson.JsonElement -import com.google.gson.JsonParseException -import com.google.gson.annotations.JsonAdapter -import com.google.gson.annotations.SerializedName +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass import kotlinx.parcelize.Parcelize +@JsonClass(generateAdapter = true) @Parcelize data class Attachment( val id: String, val url: String, - @SerializedName("preview_url") val previewUrl: String?, // can be null for e.g. audio attachments - val meta: MetaData?, + // can be null for e.g. audio attachments + @Json(name = "preview_url") val previewUrl: String? = null, + val meta: MetaData? = null, val type: Type, - val description: String?, - val blurhash: String? + val description: String? = null, + val blurhash: String? = null ) : Parcelable { - @JsonAdapter(MediaTypeDeserializer::class) + @JsonClass(generateAdapter = false) enum class Type { - @SerializedName("image") + @Json(name = "image") IMAGE, - @SerializedName("gifv") + @Json(name = "gifv") GIFV, - @SerializedName("video") + @Json(name = "video") VIDEO, - @SerializedName("audio") + @Json(name = "audio") AUDIO, - @SerializedName("unknown") UNKNOWN } - class MediaTypeDeserializer : JsonDeserializer { - @Throws(JsonParseException::class) - override fun deserialize(json: JsonElement, classOfT: java.lang.reflect.Type, context: JsonDeserializationContext): Type { - return when (json.toString()) { - "\"image\"" -> Type.IMAGE - "\"gifv\"" -> Type.GIFV - "\"video\"" -> Type.VIDEO - "\"audio\"" -> Type.AUDIO - else -> Type.UNKNOWN - } - } - } - /** * The meta data of an [Attachment]. */ + @JsonClass(generateAdapter = true) @Parcelize data class MetaData( - val focus: Focus?, - val duration: Float?, - val original: Size?, - val small: Size? + val focus: Focus? = null, + val duration: Float? = null, + val original: Size? = null, + val small: Size? = null ) : Parcelable /** @@ -83,6 +68,7 @@ data class Attachment( * See here for more details what the x and y mean: * https://github.com/jonom/jquery-focuspoint#1-calculate-your-images-focus-point */ + @JsonClass(generateAdapter = true) @Parcelize data class Focus( val x: Float, @@ -94,10 +80,11 @@ data class Attachment( /** * The size of an image, used to specify the width/height. */ + @JsonClass(generateAdapter = true) @Parcelize data class Size( - val width: Int, - val height: Int, - val aspect: Double + val width: Int = 0, + val height: Int = 0, + val aspect: Double = 0.0 ) : Parcelable } diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Card.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Card.kt index 05cac1a0f..baba8cefa 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Card.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Card.kt @@ -15,19 +15,21 @@ package com.keylesspalace.tusky.entity -import com.google.gson.annotations.SerializedName +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +@JsonClass(generateAdapter = true) data class Card( val url: String, val title: String, - val description: String, - @SerializedName("author_name") val authorName: String, - val image: String, + val description: String = "", + @Json(name = "author_name") val authorName: String = "", + val image: String? = null, val type: String, - val width: Int, - val height: Int, - val blurhash: String?, - @SerializedName("embed_url") val embedUrl: String? + val width: Int = 0, + val height: Int = 0, + val blurhash: String? = null, + @Json(name = "embed_url") val embedUrl: String? = null ) { override fun hashCode() = url.hashCode() @@ -36,8 +38,7 @@ data class Card( if (other !is Card) { return false } - val account = other as Card? - return account?.url == this.url + return other.url == this.url } companion object { diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Conversation.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Conversation.kt index e5a547f11..177073fb7 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Conversation.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Conversation.kt @@ -15,11 +15,14 @@ package com.keylesspalace.tusky.entity -import com.google.gson.annotations.SerializedName +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +@JsonClass(generateAdapter = true) data class Conversation( val id: String, val accounts: List, - @SerializedName("last_status") val lastStatus: Status?, // should never be null, but apparently its possible https://github.com/tuskyapp/Tusky/issues/1038 + // should never be null, but apparently it's possible https://github.com/tuskyapp/Tusky/issues/1038 + @Json(name = "last_status") val lastStatus: Status? = null, val unread: Boolean ) diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/DeletedStatus.kt b/app/src/main/java/com/keylesspalace/tusky/entity/DeletedStatus.kt index c400a1af6..eb6f5a6a5 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/DeletedStatus.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/DeletedStatus.kt @@ -15,21 +15,22 @@ package com.keylesspalace.tusky.entity -import com.google.gson.annotations.SerializedName +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass import java.util.Date +@JsonClass(generateAdapter = true) data class DeletedStatus( val text: String?, - @SerializedName("in_reply_to_id") val inReplyToId: String?, - @SerializedName("spoiler_text") val spoilerText: String, + @Json(name = "in_reply_to_id") val inReplyToId: String? = null, + @Json(name = "spoiler_text") val spoilerText: String, val visibility: Status.Visibility, val sensitive: Boolean, - @SerializedName("media_attachments") val attachments: List?, - val poll: Poll?, - @SerializedName("created_at") val createdAt: Date, - val language: String? + @Json(name = "media_attachments") val attachments: List, + val poll: Poll? = null, + @Json(name = "created_at") val createdAt: Date, + val language: String? = null ) { - fun isEmpty(): Boolean { - return text == null && attachments == null - } + val isEmpty: Boolean + get() = text == null && attachments.isEmpty() } diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Emoji.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Emoji.kt index 130831a2d..c4325a60d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Emoji.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Emoji.kt @@ -16,13 +16,15 @@ package com.keylesspalace.tusky.entity import android.os.Parcelable -import com.google.gson.annotations.SerializedName +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass import kotlinx.parcelize.Parcelize +@JsonClass(generateAdapter = true) @Parcelize data class Emoji( val shortcode: String, val url: String, - @SerializedName("static_url") val staticUrl: String, - @SerializedName("visible_in_picker") val visibleInPicker: Boolean? + @Json(name = "static_url") val staticUrl: String, + @Json(name = "visible_in_picker") val visibleInPicker: Boolean = true ) : Parcelable diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Error.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Error.kt index f78cafacd..5a170d59c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Error.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Error.kt @@ -17,8 +17,12 @@ package com.keylesspalace.tusky.entity +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + /** @see [Error](https://docs.joinmastodon.org/entities/Error/) */ +@JsonClass(generateAdapter = true) data class Error( val error: String, - val error_description: String? + @Json(name = "error_description") val errorDescription: String? = null ) diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Filter.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Filter.kt index 236e216e1..f85be3834 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Filter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Filter.kt @@ -1,18 +1,21 @@ package com.keylesspalace.tusky.entity import android.os.Parcelable -import com.google.gson.annotations.SerializedName -import kotlinx.parcelize.Parcelize +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass import java.util.Date +import kotlinx.parcelize.Parcelize +@JsonClass(generateAdapter = true) @Parcelize data class Filter( val id: String, val title: String, val context: List, - @SerializedName("expires_at") val expiresAt: Date?, - @SerializedName("filter_action") private val filterAction: String, - val keywords: List + @Json(name = "expires_at") val expiresAt: Date? = null, + @Json(name = "filter_action") val filterAction: String, + // This field is mandatory according to the API documentation but is in fact optional in some instances + val keywords: List = emptyList(), // val statuses: List, ) : Parcelable { enum class Action(val action: String) { @@ -21,7 +24,7 @@ data class Filter( HIDE("hide"); companion object { - fun from(action: String): Action = values().firstOrNull { it.action == action } ?: WARN + fun from(action: String): Action = entries.firstOrNull { it.action == action } ?: WARN } } enum class Kind(val kind: String) { @@ -32,7 +35,7 @@ data class Filter( ACCOUNT("account"); companion object { - fun from(kind: String): Kind = values().firstOrNull { it.kind == kind } ?: PUBLIC + fun from(kind: String): Kind = entries.firstOrNull { it.kind == kind } ?: PUBLIC } } diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/FilterKeyword.kt b/app/src/main/java/com/keylesspalace/tusky/entity/FilterKeyword.kt index c62ac4090..8947975ce 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/FilterKeyword.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/FilterKeyword.kt @@ -1,12 +1,14 @@ package com.keylesspalace.tusky.entity import android.os.Parcelable -import com.google.gson.annotations.SerializedName +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass import kotlinx.parcelize.Parcelize +@JsonClass(generateAdapter = true) @Parcelize data class FilterKeyword( val id: String, val keyword: String, - @SerializedName("whole_word") val wholeWord: Boolean + @Json(name = "whole_word") val wholeWord: Boolean ) : Parcelable diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/FilterResult.kt b/app/src/main/java/com/keylesspalace/tusky/entity/FilterResult.kt index f51af22ff..c8ffa6950 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/FilterResult.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/FilterResult.kt @@ -1,9 +1,10 @@ package com.keylesspalace.tusky.entity -import com.google.gson.annotations.SerializedName +import com.squareup.moshi.JsonClass +@JsonClass(generateAdapter = true) data class FilterResult( val filter: Filter, - @SerializedName("keyword_matches") val keywordMatches: List?, - @SerializedName("status_matches") val statusMatches: List? +// @Json(name = "keyword_matches") val keywordMatches: List? = null, +// @Json(name = "status_matches") val statusMatches: List? = null ) diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/FilterV1.kt b/app/src/main/java/com/keylesspalace/tusky/entity/FilterV1.kt index a93ccff5e..7a8c9b17e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/FilterV1.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/FilterV1.kt @@ -15,16 +15,18 @@ package com.keylesspalace.tusky.entity -import com.google.gson.annotations.SerializedName +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass import java.util.Date +@JsonClass(generateAdapter = true) data class FilterV1( val id: String, val phrase: String, val context: List, - @SerializedName("expires_at") val expiresAt: Date?, + @Json(name = "expires_at") val expiresAt: Date? = null, val irreversible: Boolean, - @SerializedName("whole_word") val wholeWord: Boolean + @Json(name = "whole_word") val wholeWord: Boolean ) { companion object { const val HOME = "home" @@ -42,8 +44,7 @@ data class FilterV1( if (other !is FilterV1) { return false } - val filter = other as FilterV1? - return filter?.id.equals(id) + return other.id == id } fun toFilter(): Filter { diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/HashTag.kt b/app/src/main/java/com/keylesspalace/tusky/entity/HashTag.kt index e2401d939..384d6f4d2 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/HashTag.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/HashTag.kt @@ -1,3 +1,10 @@ package com.keylesspalace.tusky.entity -data class HashTag(val name: String, val url: String, val following: Boolean? = null) +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class HashTag( + val name: String, + val url: String, + val following: Boolean? = null +) diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Instance.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Instance.kt index 77864cfeb..92e71ba65 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Instance.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Instance.kt @@ -1,98 +1,98 @@ -/* Copyright 2018 Levi Bard - * - * This file is a part of Tusky. - * - * 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. - * - * Tusky 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 Tusky; if not, - * see . */ - package com.keylesspalace.tusky.entity -import com.google.gson.annotations.SerializedName +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +@JsonClass(generateAdapter = true) data class Instance( - val uri: String, - // val title: String, - // val description: String, - // val email: String, + val domain: String, +// val title: String, val version: String, - // val urls: Map, - // val stats: Map?, - // val thumbnail: String?, - // val languages: List, - // @SerializedName("contact_account") val contactAccount: Account, - @SerializedName("max_toot_chars") val maxTootChars: Int?, - @SerializedName("poll_limits") val pollConfiguration: PollConfiguration?, - val configuration: InstanceConfiguration?, - @SerializedName("max_media_attachments") val maxMediaAttachments: Int?, - val pleroma: PleromaConfiguration?, - @SerializedName("upload_limit") val uploadLimit: Int?, - val rules: List? +// @Json(name = "source_url") val sourceUrl: String, +// val description: String, +// val usage: Usage, +// val thumbnail: Thumbnail, +// val languages: List, + val configuration: Configuration? = null, +// val registrations: Registrations, +// val contact: Contact, + val rules: List = emptyList(), + val pleroma: PleromaConfiguration? = null ) { - override fun hashCode(): Int { - return uri.hashCode() + @JsonClass(generateAdapter = true) + data class Usage(val users: Users) { + @JsonClass(generateAdapter = true) + data class Users(@Json(name = "active_month") val activeMonth: Int) } - override fun equals(other: Any?): Boolean { - if (other !is Instance) { - return false - } - val instance = other as Instance? - return instance?.uri.equals(uri) + @JsonClass(generateAdapter = true) + data class Thumbnail( + val url: String, + val blurhash: String? = null, + val versions: Versions? = null + ) { + @JsonClass(generateAdapter = true) + data class Versions( + @Json(name = "@1x") val at1x: String? = null, + @Json(name = "@2x") val at2x: String? = null + ) } + + @JsonClass(generateAdapter = true) + data class Configuration( + val urls: Urls? = null, + val accounts: Accounts? = null, + val statuses: Statuses? = null, + @Json(name = "media_attachments") val mediaAttachments: MediaAttachments? = null, + val polls: Polls? = null, + val translation: Translation? = null + ) { + @JsonClass(generateAdapter = true) + data class Urls(@Json(name = "streaming_api") val streamingApi: String? = null) + + @JsonClass(generateAdapter = true) + data class Accounts(@Json(name = "max_featured_tags") val maxFeaturedTags: Int) + + @JsonClass(generateAdapter = true) + data class Statuses( + @Json(name = "max_characters") val maxCharacters: Int? = null, + @Json(name = "max_media_attachments") val maxMediaAttachments: Int? = null, + @Json(name = "characters_reserved_per_url") val charactersReservedPerUrl: Int? = null + ) + + @JsonClass(generateAdapter = true) + data class MediaAttachments( + // Warning: This is an array in mastodon and a dictionary in friendica + // @Json(name = "supported_mime_types") val supportedMimeTypes: List = emptyList(), + @Json(name = "image_size_limit") val imageSizeLimitBytes: Long? = null, + @Json(name = "image_matrix_limit") val imagePixelCountLimit: Long? = null, + @Json(name = "video_size_limit") val videoSizeLimitBytes: Long? = null, + @Json(name = "video_matrix_limit") val videoPixelCountLimit: Long? = null, + @Json(name = "video_frame_rate_limit") val videoFrameRateLimit: Int? = null + ) + + @JsonClass(generateAdapter = true) + data class Polls( + @Json(name = "max_options") val maxOptions: Int? = null, + @Json(name = "max_characters_per_option") val maxCharactersPerOption: Int? = null, + @Json(name = "min_expiration") val minExpirationSeconds: Int? = null, + @Json(name = "max_expiration") val maxExpirationSeconds: Int? = null + ) + + @JsonClass(generateAdapter = true) + data class Translation(val enabled: Boolean) + } + + @JsonClass(generateAdapter = true) + data class Registrations( + val enabled: Boolean, + @Json(name = "approval_required") val approvalRequired: Boolean, + val message: String? = null + ) + + @JsonClass(generateAdapter = true) + data class Contact(val email: String, val account: Account) + + @JsonClass(generateAdapter = true) + data class Rule(val id: String, val text: String) } - -data class PollConfiguration( - @SerializedName("max_options") val maxOptions: Int?, - @SerializedName("max_option_chars") val maxOptionChars: Int?, - @SerializedName("max_characters_per_option") val maxCharactersPerOption: Int?, - @SerializedName("min_expiration") val minExpiration: Int?, - @SerializedName("max_expiration") val maxExpiration: Int? -) - -data class InstanceConfiguration( - val statuses: StatusConfiguration?, - @SerializedName("media_attachments") val mediaAttachments: MediaAttachmentConfiguration?, - val polls: PollConfiguration? -) - -data class StatusConfiguration( - @SerializedName("max_characters") val maxCharacters: Int?, - @SerializedName("max_media_attachments") val maxMediaAttachments: Int?, - @SerializedName("characters_reserved_per_url") val charactersReservedPerUrl: Int? -) - -data class MediaAttachmentConfiguration( - @SerializedName("supported_mime_types") val supportedMimeTypes: List?, - @SerializedName("image_size_limit") val imageSizeLimit: Int?, - @SerializedName("image_matrix_limit") val imageMatrixLimit: Int?, - @SerializedName("video_size_limit") val videoSizeLimit: Int?, - @SerializedName("video_frame_rate_limit") val videoFrameRateLimit: Int?, - @SerializedName("video_matrix_limit") val videoMatrixLimit: Int? -) - -data class PleromaConfiguration( - val metadata: PleromaMetadata? -) - -data class PleromaMetadata( - @SerializedName("fields_limits") val fieldLimits: PleromaFieldLimits -) - -data class PleromaFieldLimits( - @SerializedName("max_fields") val maxFields: Int?, - @SerializedName("name_length") val nameLength: Int?, - @SerializedName("value_length") val valueLength: Int? -) - -data class InstanceRules( - val id: String, - val text: String -) diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/InstanceV1.kt b/app/src/main/java/com/keylesspalace/tusky/entity/InstanceV1.kt new file mode 100644 index 000000000..beddfdff1 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/entity/InstanceV1.kt @@ -0,0 +1,107 @@ +/* Copyright 2018 Levi Bard + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.entity + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class InstanceV1( + val uri: String, + // val title: String, + // val description: String, + // val email: String, + val version: String, + // val urls: Map, + // val stats: Map?, + // val thumbnail: String?, + // val languages: List, + // @Json(name = "contact_account") val contactAccount: Account?, + @Json(name = "max_toot_chars") val maxTootChars: Int? = null, + @Json(name = "poll_limits") val pollConfiguration: PollConfiguration? = null, + val configuration: InstanceConfiguration? = null, + @Json(name = "max_media_attachments") val maxMediaAttachments: Int? = null, + val pleroma: PleromaConfiguration? = null, + @Json(name = "upload_limit") val uploadLimit: Int? = null, + val rules: List = emptyList() +) { + override fun hashCode(): Int { + return uri.hashCode() + } + + override fun equals(other: Any?): Boolean { + if (other !is InstanceV1) { + return false + } + return other.uri == uri + } +} + +@JsonClass(generateAdapter = true) +data class PollConfiguration( + @Json(name = "max_options") val maxOptions: Int? = null, + @Json(name = "max_option_chars") val maxOptionChars: Int? = null, + @Json(name = "max_characters_per_option") val maxCharactersPerOption: Int? = null, + @Json(name = "min_expiration") val minExpiration: Int? = null, + @Json(name = "max_expiration") val maxExpiration: Int? = null +) + +@JsonClass(generateAdapter = true) +data class InstanceConfiguration( + val statuses: StatusConfiguration? = null, + @Json(name = "media_attachments") val mediaAttachments: MediaAttachmentConfiguration? = null, + val polls: PollConfiguration? = null +) + +@JsonClass(generateAdapter = true) +data class StatusConfiguration( + @Json(name = "max_characters") val maxCharacters: Int? = null, + @Json(name = "max_media_attachments") val maxMediaAttachments: Int? = null, + @Json(name = "characters_reserved_per_url") val charactersReservedPerUrl: Int? = null +) + +@JsonClass(generateAdapter = true) +data class MediaAttachmentConfiguration( + @Json(name = "supported_mime_types") val supportedMimeTypes: List = emptyList(), + @Json(name = "image_size_limit") val imageSizeLimit: Int? = null, + @Json(name = "image_matrix_limit") val imageMatrixLimit: Int? = null, + @Json(name = "video_size_limit") val videoSizeLimit: Int? = null, + @Json(name = "video_frame_rate_limit") val videoFrameRateLimit: Int? = null, + @Json(name = "video_matrix_limit") val videoMatrixLimit: Int? = null +) + +@JsonClass(generateAdapter = true) +data class PleromaConfiguration( + val metadata: PleromaMetadata? = null +) + +@JsonClass(generateAdapter = true) +data class PleromaMetadata( + @Json(name = "fields_limits") val fieldLimits: PleromaFieldLimits +) + +@JsonClass(generateAdapter = true) +data class PleromaFieldLimits( + @Json(name = "max_fields") val maxFields: Int? = null, + @Json(name = "name_length") val nameLength: Int? = null, + @Json(name = "value_length") val valueLength: Int? = null +) + +@JsonClass(generateAdapter = true) +data class InstanceRules( + val id: String, + val text: String +) diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Marker.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Marker.kt index 78572054d..a1ecfb5f9 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Marker.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Marker.kt @@ -1,15 +1,17 @@ package com.keylesspalace.tusky.entity -import com.google.gson.annotations.SerializedName +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass import java.util.Date /** * API type for saving the scroll position of a timeline. */ +@JsonClass(generateAdapter = true) data class Marker( - @SerializedName("last_read_id") + @Json(name = "last_read_id") val lastReadId: String, val version: Int, - @SerializedName("updated_at") + @Json(name = "updated_at") val updatedAt: Date ) diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/MastoList.kt b/app/src/main/java/com/keylesspalace/tusky/entity/MastoList.kt index bfec7cc52..4a552a95c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/MastoList.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/MastoList.kt @@ -16,11 +16,27 @@ package com.keylesspalace.tusky.entity +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + /** * Created by charlag on 1/4/18. */ - +@JsonClass(generateAdapter = true) data class MastoList( val id: String, - val title: String -) + val title: String, + val exclusive: Boolean? = null, + @Json(name = "replies_policy") val repliesPolicy: String? = null +) { + enum class ReplyPolicy(val policy: String) { + NONE("none"), + LIST("list"), + FOLLOWED("followed"); + + companion object { + fun from(policy: String?): ReplyPolicy = + entries.firstOrNull { it.policy == policy } ?: LIST + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/MediaUploadResult.kt b/app/src/main/java/com/keylesspalace/tusky/entity/MediaUploadResult.kt index 15910f621..0202588bd 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/MediaUploadResult.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/MediaUploadResult.kt @@ -1,9 +1,12 @@ package com.keylesspalace.tusky.entity +import com.squareup.moshi.JsonClass + /** * The same as Attachment, except the url is null - see https://docs.joinmastodon.org/methods/statuses/media/ * We are only interested in the id, so other attributes are omitted */ +@JsonClass(generateAdapter = true) data class MediaUploadResult( val id: String ) diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/NewStatus.kt b/app/src/main/java/com/keylesspalace/tusky/entity/NewStatus.kt index 1a353eadf..ec0de23c3 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/NewStatus.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/NewStatus.kt @@ -16,35 +16,39 @@ package com.keylesspalace.tusky.entity import android.os.Parcelable -import com.google.gson.annotations.SerializedName +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass import kotlinx.parcelize.Parcelize +@JsonClass(generateAdapter = true) data class NewStatus( val status: String, - @SerializedName("spoiler_text") val warningText: String, - @SerializedName("in_reply_to_id") val inReplyToId: String?, + @Json(name = "spoiler_text") val warningText: String, + @Json(name = "in_reply_to_id") val inReplyToId: String? = null, val visibility: String, val sensitive: Boolean, - @SerializedName("media_ids") val mediaIds: List?, - @SerializedName("media_attributes") val mediaAttributes: List?, - @SerializedName("scheduled_at") val scheduledAt: String?, - val poll: NewPoll?, - val language: String? + @Json(name = "media_ids") val mediaIds: List = emptyList(), + @Json(name = "media_attributes") val mediaAttributes: List = emptyList(), + @Json(name = "scheduled_at") val scheduledAt: String? = null, + val poll: NewPoll? = null, + val language: String? = null ) +@JsonClass(generateAdapter = true) @Parcelize data class NewPoll( val options: List, - @SerializedName("expires_in") val expiresIn: Int, + @Json(name = "expires_in") val expiresIn: Int, val multiple: Boolean ) : Parcelable // It would be nice if we could reuse MediaToSend, // but the server requires a different format for focus +@JsonClass(generateAdapter = true) @Parcelize data class MediaAttribute( val id: String, - val description: String?, - val focus: String?, - val thumbnail: String? + val description: String? = null, + val focus: String? = null, + val thumbnail: String? = null ) : Parcelable diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Notification.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Notification.kt index a7007e57c..25b8b1f92 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Notification.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Notification.kt @@ -16,69 +16,73 @@ package com.keylesspalace.tusky.entity import androidx.annotation.StringRes -import com.google.gson.JsonDeserializationContext -import com.google.gson.JsonDeserializer -import com.google.gson.JsonElement -import com.google.gson.JsonParseException -import com.google.gson.annotations.JsonAdapter import com.keylesspalace.tusky.R +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +@JsonClass(generateAdapter = true) data class Notification( val type: Type, val id: String, val account: TimelineAccount, - val status: Status?, - val report: Report? + val status: Status? = null, + val report: Report? = null ) { /** From https://docs.joinmastodon.org/entities/Notification/#type */ - @JsonAdapter(NotificationTypeAdapter::class) + @JsonClass(generateAdapter = false) enum class Type(val presentation: String, @StringRes val uiString: Int) { UNKNOWN("unknown", R.string.notification_unknown_name), /** Someone mentioned you */ + @Json(name = "mention") MENTION("mention", R.string.notification_mention_name), /** Someone boosted one of your statuses */ + @Json(name = "reblog") REBLOG("reblog", R.string.notification_boost_name), /** Someone favourited one of your statuses */ + @Json(name = "favourite") FAVOURITE("favourite", R.string.notification_favourite_name), /** Someone followed you */ + @Json(name = "follow") FOLLOW("follow", R.string.notification_follow_name), /** Someone requested to follow you */ + @Json(name = "follow_request") FOLLOW_REQUEST("follow_request", R.string.notification_follow_request_name), /** A poll you have voted in or created has ended */ + @Json(name = "poll") POLL("poll", R.string.notification_poll_name), /** Someone you enabled notifications for has posted a status */ + @Json(name = "status") STATUS("status", R.string.notification_subscription_name), /** Someone signed up (optionally sent to admins) */ + @Json(name = "admin.sign_up") SIGN_UP("admin.sign_up", R.string.notification_sign_up_name), /** A status you interacted with has been updated */ + @Json(name = "update") UPDATE("update", R.string.notification_update_name), /** A new report has been filed */ + @Json(name = "admin.report") REPORT("admin.report", R.string.notification_report_name); companion object { @JvmStatic fun byString(s: String): Type { - values().forEach { - if (s == it.presentation) { - return it - } - } - return UNKNOWN + return entries.firstOrNull { it.presentation == s } ?: UNKNOWN } /** Notification types for UI display (omits UNKNOWN) */ - val visibleTypes = listOf(MENTION, REBLOG, FAVOURITE, FOLLOW, FOLLOW_REQUEST, POLL, STATUS, SIGN_UP, UPDATE, REPORT) + val visibleTypes = + listOf(MENTION, REBLOG, FAVOURITE, FOLLOW, FOLLOW_REQUEST, POLL, STATUS, SIGN_UP, UPDATE, REPORT) } override fun toString(): String { @@ -94,28 +98,18 @@ data class Notification( if (other !is Notification) { return false } - val notification = other as Notification? - return notification?.id == this.id + return other.id == this.id } - class NotificationTypeAdapter : JsonDeserializer { - - @Throws(JsonParseException::class) - override fun deserialize( - json: JsonElement, - typeOfT: java.lang.reflect.Type, - context: JsonDeserializationContext - ): Type { - return Type.byString(json.asString) - } - } + /** Helper for Java */ + fun copyWithStatus(status: Status?): Notification = copy(status = status) // for Pleroma compatibility that uses Mention type fun rewriteToStatusTypeIfNeeded(accountId: String): Notification { if (type == Type.MENTION && status != null) { return if (status.mentions.any { - it.id == accountId - } + it.id == accountId + } ) { this } else { diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/NotificationSubscribeResult.kt b/app/src/main/java/com/keylesspalace/tusky/entity/NotificationSubscribeResult.kt index 6bdaa1438..d0d84b93f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/NotificationSubscribeResult.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/NotificationSubscribeResult.kt @@ -15,10 +15,12 @@ package com.keylesspalace.tusky.entity -import com.google.gson.annotations.SerializedName +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +@JsonClass(generateAdapter = true) data class NotificationSubscribeResult( val id: Int, val endpoint: String, - @SerializedName("server_key") val serverKey: String + @Json(name = "server_key") val serverKey: String ) diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Poll.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Poll.kt index 584b76f8c..11d8ae946 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Poll.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Poll.kt @@ -1,24 +1,27 @@ package com.keylesspalace.tusky.entity -import com.google.gson.annotations.SerializedName +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass import java.util.Date +@JsonClass(generateAdapter = true) data class Poll( val id: String, - @SerializedName("expires_at") val expiresAt: Date?, + @Json(name = "expires_at") val expiresAt: Date? = null, val expired: Boolean, val multiple: Boolean, - @SerializedName("votes_count") val votesCount: Int, - @SerializedName("voters_count") val votersCount: Int?, // nullable for compatibility with Pleroma + @Json(name = "votes_count") val votesCount: Int, + // nullable for compatibility with Pleroma + @Json(name = "voters_count") val votersCount: Int? = null, val options: List, - val voted: Boolean, - @SerializedName("own_votes") val ownVotes: List? + val voted: Boolean = false, + @Json(name = "own_votes") val ownVotes: List = emptyList() ) { fun votedCopy(choices: List): Poll { val newOptions = options.mapIndexed { index, option -> if (choices.contains(index)) { - option.copy(votesCount = option.votesCount + 1) + option.copy(votesCount = (option.votesCount ?: 0) + 1) } else { option } @@ -41,7 +44,8 @@ data class Poll( ) } +@JsonClass(generateAdapter = true) data class PollOption( val title: String, - @SerializedName("votes_count") val votesCount: Int + @Json(name = "votes_count") val votesCount: Int? = null ) diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Relationship.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Relationship.kt index e99bcce6d..3ad7f1f41 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Relationship.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Relationship.kt @@ -15,25 +15,28 @@ package com.keylesspalace.tusky.entity -import com.google.gson.annotations.JsonAdapter -import com.google.gson.annotations.SerializedName -import com.keylesspalace.tusky.json.GuardedBooleanAdapter +import com.keylesspalace.tusky.json.Guarded +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +@JsonClass(generateAdapter = true) data class Relationship( val id: String, val following: Boolean, - @SerializedName("followed_by") val followedBy: Boolean, + @Json(name = "followed_by") val followedBy: Boolean, val blocking: Boolean, val muting: Boolean, - @SerializedName("muting_notifications") val mutingNotifications: Boolean, + @Json(name = "muting_notifications") val mutingNotifications: Boolean, val requested: Boolean, - @SerializedName("showing_reblogs") val showingReblogs: Boolean, + @Json(name = "showing_reblogs") val showingReblogs: Boolean, /* Pleroma extension, same as 'notifying' on Mastodon. * Some instances like qoto.org have a custom subscription feature where 'subscribing' is a json object, - * so we use the custom GuardedBooleanAdapter to ignore the field if it is not a boolean. + * so we use GuardedAdapter to ignore the field if it is not a boolean. */ - @JsonAdapter(GuardedBooleanAdapter::class) val subscribing: Boolean? = null, - @SerializedName("domain_blocking") val blockingDomain: Boolean, - val note: String?, // nullable for backward compatibility / feature detection - val notifying: Boolean? // since 3.3.0rc + @Guarded val subscribing: Boolean? = null, + @Json(name = "domain_blocking") val blockingDomain: Boolean, + // nullable for backward compatibility / feature detection + val note: String? = null, + // since 3.3.0rc + val notifying: Boolean? = null ) diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Report.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Report.kt index 8de7b957d..faac322ca 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Report.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Report.kt @@ -1,12 +1,14 @@ package com.keylesspalace.tusky.entity -import com.google.gson.annotations.SerializedName +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass import java.util.Date +@JsonClass(generateAdapter = true) data class Report( val id: String, val category: String, - val status_ids: List?, - @SerializedName("created_at") val createdAt: Date, - @SerializedName("target_account") val targetAccount: TimelineAccount + @Json(name = "status_ids") val statusIds: List? = null, + @Json(name = "created_at") val createdAt: Date, + @Json(name = "target_account") val targetAccount: TimelineAccount ) diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/ScheduledStatus.kt b/app/src/main/java/com/keylesspalace/tusky/entity/ScheduledStatus.kt index dfaeb499c..6be354d10 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/ScheduledStatus.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/ScheduledStatus.kt @@ -15,11 +15,13 @@ package com.keylesspalace.tusky.entity -import com.google.gson.annotations.SerializedName +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +@JsonClass(generateAdapter = true) data class ScheduledStatus( val id: String, - @SerializedName("scheduled_at") val scheduledAt: String, + @Json(name = "scheduled_at") val scheduledAt: String, val params: StatusParams, - @SerializedName("media_attachments") val mediaAttachments: ArrayList + @Json(name = "media_attachments") val mediaAttachments: List ) diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/SearchResult.kt b/app/src/main/java/com/keylesspalace/tusky/entity/SearchResult.kt index 5bc78cf72..27bce6d8b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/SearchResult.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/SearchResult.kt @@ -15,6 +15,9 @@ package com.keylesspalace.tusky.entity +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) data class SearchResult( val accounts: List, val statuses: List, diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Status.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Status.kt index 0b9a0796d..db72bf44f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Status.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Status.kt @@ -17,40 +17,48 @@ package com.keylesspalace.tusky.entity import android.text.SpannableStringBuilder import android.text.style.URLSpan -import com.google.gson.annotations.SerializedName import com.keylesspalace.tusky.util.parseAsMastodonHtml +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass import java.util.Date +@JsonClass(generateAdapter = true) data class Status( val id: String, - val url: String?, // not present if it's reblog + // not present if it's reblog + val url: String? = null, val account: TimelineAccount, - @SerializedName("in_reply_to_id") val inReplyToId: String?, - @SerializedName("in_reply_to_account_id") val inReplyToAccountId: String?, - val reblog: Status?, + @Json(name = "in_reply_to_id") val inReplyToId: String? = null, + @Json(name = "in_reply_to_account_id") val inReplyToAccountId: String? = null, + val reblog: Status? = null, val content: String, - @SerializedName("created_at") val createdAt: Date, - @SerializedName("edited_at") val editedAt: Date?, + @Json(name = "created_at") val createdAt: Date, + @Json(name = "edited_at") val editedAt: Date? = null, val emojis: List, - @SerializedName("reblogs_count") val reblogsCount: Int, - @SerializedName("favourites_count") val favouritesCount: Int, - @SerializedName("replies_count") val repliesCount: Int, - val reblogged: Boolean, - val favourited: Boolean, - val bookmarked: Boolean, + @Json(name = "reblogs_count") val reblogsCount: Int, + @Json(name = "favourites_count") val favouritesCount: Int, + @Json(name = "replies_count") val repliesCount: Int, + val reblogged: Boolean = false, + val favourited: Boolean = false, + val bookmarked: Boolean = false, val sensitive: Boolean, - @SerializedName("spoiler_text") val spoilerText: String, + @Json(name = "spoiler_text") val spoilerText: String, val visibility: Visibility, - @SerializedName("media_attachments") val attachments: List, + @Json(name = "media_attachments") val attachments: List, val mentions: List, - val tags: List?, - val application: Application?, - val pinned: Boolean?, - val muted: Boolean?, - val poll: Poll?, - val card: Card?, - val language: String?, - val filtered: List? + // Use null to mark the absence of tags because of semantic differences in LinkHelper + val tags: List? = null, + val application: Application? = null, + val pinned: Boolean = false, + val muted: Boolean = false, + val poll: Poll? = null, + /** Preview card for links included within status content. */ + val card: Card? = null, + /** ISO 639 language code for this status. */ + val language: String? = null, + /** If the current token has an authorized user: The filter and keywords that matched this status. + * Iceshrimp and maybe other implementations explicitly send filtered=null so we can't default to empty list. */ + val filtered: List? = null ) { val actionableId: String @@ -66,30 +74,30 @@ data class Status( fun copyWithPoll(poll: Poll?): Status = copy(poll = poll) fun copyWithPinned(pinned: Boolean): Status = copy(pinned = pinned) + @JsonClass(generateAdapter = false) enum class Visibility(val num: Int) { UNKNOWN(0), - @SerializedName("public") + @Json(name = "public") PUBLIC(1), - @SerializedName("unlisted") + @Json(name = "unlisted") UNLISTED(2), - @SerializedName("private") + @Json(name = "private") PRIVATE(3), - @SerializedName("direct") + @Json(name = "direct") DIRECT(4); - fun serverString(): String { - return when (this) { + val serverString: String + get() = when (this) { PUBLIC -> "public" UNLISTED -> "unlisted" PRIVATE -> "private" DIRECT -> "direct" UNKNOWN -> "unknown" } - } companion object { @@ -119,13 +127,10 @@ data class Status( } } - fun rebloggingAllowed(): Boolean { - return (visibility != Visibility.DIRECT && visibility != Visibility.UNKNOWN) - } - - fun isPinned(): Boolean { - return pinned ?: false - } + val isRebloggingAllowed: Boolean + get() { + return (visibility != Visibility.DIRECT && visibility != Visibility.UNKNOWN) + } fun toDeletedStatus(): DeletedStatus { return DeletedStatus( @@ -160,16 +165,18 @@ data class Status( return builder.toString() } + @JsonClass(generateAdapter = true) data class Mention( val id: String, val url: String, - @SerializedName("acct") val username: String, - @SerializedName("username") val localUsername: String + @Json(name = "acct") val username: String, + @Json(name = "username") val localUsername: String ) + @JsonClass(generateAdapter = true) data class Application( val name: String, - val website: String? + val website: String? = null ) companion object { diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/StatusContext.kt b/app/src/main/java/com/keylesspalace/tusky/entity/StatusContext.kt index ce5bb1440..35da03109 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/StatusContext.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/StatusContext.kt @@ -15,6 +15,9 @@ package com.keylesspalace.tusky.entity +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) data class StatusContext( val ancestors: List, val descendants: List diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/StatusEdit.kt b/app/src/main/java/com/keylesspalace/tusky/entity/StatusEdit.kt index 0e77b0fd9..0e922a70e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/StatusEdit.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/StatusEdit.kt @@ -1,15 +1,17 @@ package com.keylesspalace.tusky.entity -import com.google.gson.annotations.SerializedName +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass import java.util.Date +@JsonClass(generateAdapter = true) data class StatusEdit( val content: String, - @SerializedName("spoiler_text") val spoilerText: String, + @Json(name = "spoiler_text") val spoilerText: String, val sensitive: Boolean, - @SerializedName("created_at") val createdAt: Date, + @Json(name = "created_at") val createdAt: Date, val account: TimelineAccount, - val poll: Poll?, - @SerializedName("media_attachments") val mediaAttachments: List, + val poll: Poll? = null, + @Json(name = "media_attachments") val mediaAttachments: List, val emojis: List ) diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/StatusParams.kt b/app/src/main/java/com/keylesspalace/tusky/entity/StatusParams.kt index d3235337b..7c378d6cd 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/StatusParams.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/StatusParams.kt @@ -15,12 +15,14 @@ package com.keylesspalace.tusky.entity -import com.google.gson.annotations.SerializedName +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +@JsonClass(generateAdapter = true) data class StatusParams( val text: String, - val sensitive: Boolean, + val sensitive: Boolean? = null, val visibility: Status.Visibility, - @SerializedName("spoiler_text") val spoilerText: String, - @SerializedName("in_reply_to_id") val inReplyToId: String? + @Json(name = "spoiler_text") val spoilerText: String? = null, + @Json(name = "in_reply_to_id") val inReplyToId: String? = null ) diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/StatusSource.kt b/app/src/main/java/com/keylesspalace/tusky/entity/StatusSource.kt index 98a01d8b9..9b2fc97be 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/StatusSource.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/StatusSource.kt @@ -15,10 +15,12 @@ package com.keylesspalace.tusky.entity -import com.google.gson.annotations.SerializedName +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +@JsonClass(generateAdapter = true) data class StatusSource( val id: String, val text: String, - @SerializedName("spoiler_text") val spoilerText: String + @Json(name = "spoiler_text") val spoilerText: String ) diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/TimelineAccount.kt b/app/src/main/java/com/keylesspalace/tusky/entity/TimelineAccount.kt index f801d4969..649c9ff95 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/TimelineAccount.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/TimelineAccount.kt @@ -15,22 +15,26 @@ package com.keylesspalace.tusky.entity -import com.google.gson.annotations.SerializedName +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass /** * Same as [Account], but only with the attributes required in timelines. * Prefer this class over [Account] because it uses way less memory & deserializes faster from json. */ +@JsonClass(generateAdapter = true) data class TimelineAccount( val id: String, - @SerializedName("username") val localUsername: String, - @SerializedName("acct") val username: String, - @SerializedName("display_name") val displayName: String?, // should never be null per Api definition, but some servers break the contract + @Json(name = "username") val localUsername: String, + @Json(name = "acct") val username: String, + // should never be null per Api definition, but some servers break the contract + @Json(name = "display_name") val displayName: String? = null, val url: String, val avatar: String, val note: String, val bot: Boolean = false, - val emojis: List? = emptyList() // nullable for backward compatibility + // optional for backward compatibility + val emojis: List = emptyList() ) { val name: String diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Translation.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Translation.kt new file mode 100644 index 000000000..a9b79494c --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Translation.kt @@ -0,0 +1,38 @@ +package com.keylesspalace.tusky.entity + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class MediaTranslation( + val id: String, + val description: String, +) + +/** + * Represents the result of machine translating some status content. + * + * See [doc](https://docs.joinmastodon.org/entities/Translation/). + */ +@JsonClass(generateAdapter = true) +data class Translation( + val content: String, + @Json(name = "spoiler_text") + val spoilerText: String? = null, + val poll: TranslatedPoll? = null, + @Json(name = "media_attachments") + val mediaAttachments: List = emptyList(), + @Json(name = "detected_source_language") + val detectedSourceLanguage: String, + val provider: String, +) + +@JsonClass(generateAdapter = true) +data class TranslatedPoll( + val options: List +) + +@JsonClass(generateAdapter = true) +data class TranslatedPollOption( + val title: String +) diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/TrendingTagsResult.kt b/app/src/main/java/com/keylesspalace/tusky/entity/TrendingTagsResult.kt index 786695559..e8cba0e43 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/TrendingTagsResult.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/TrendingTagsResult.kt @@ -15,6 +15,7 @@ package com.keylesspalace.tusky.entity +import com.squareup.moshi.JsonClass import java.util.Date /** @@ -25,6 +26,7 @@ import java.util.Date * @param history A list of [TrendingTagHistory]. Each element contains metrics per day for this hashtag. * (@param following This is not listed in the APIs at the time of writing, but an instance is delivering it.) */ +@JsonClass(generateAdapter = true) data class TrendingTag( val name: String, val history: List @@ -37,11 +39,14 @@ data class TrendingTag( * @param accounts The number of accounts that have posted with this hashtag. * @param uses The number of posts with this hashtag. */ +@JsonClass(generateAdapter = true) data class TrendingTagHistory( val day: String, val accounts: String, val uses: String ) -fun TrendingTag.start() = Date(history.last().day.toLong() * 1000L) -fun TrendingTag.end() = Date(history.first().day.toLong() * 1000L) +val TrendingTag.start + get() = Date(history.last().day.toLong() * 1000L) +val TrendingTag.end + get() = Date(history.first().day.toLong() * 1000L) diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java b/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java new file mode 100644 index 000000000..ac81fb832 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/NotificationsFragment.java @@ -0,0 +1,1260 @@ +/* Copyright 2017 Andrew Dawson + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.fragment; + +import static com.keylesspalace.tusky.util.StringUtils.isLessThan; + +import android.app.Activity; +import android.content.Context; +import android.content.DialogInterface; +import android.content.SharedPreferences; +import android.os.Bundle; +import android.util.Log; +import android.util.SparseBooleanArray; +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.ArrayAdapter; +import android.widget.ListView; +import android.widget.PopupWindow; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; +import androidx.arch.core.util.Function; +import androidx.coordinatorlayout.widget.CoordinatorLayout; +import androidx.core.util.Pair; +import androidx.core.view.MenuProvider; +import androidx.lifecycle.Lifecycle; +import androidx.preference.PreferenceManager; +import androidx.recyclerview.widget.AsyncDifferConfig; +import androidx.recyclerview.widget.AsyncListDiffer; +import androidx.recyclerview.widget.DiffUtil; +import androidx.recyclerview.widget.DividerItemDecoration; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.ListUpdateCallback; +import androidx.recyclerview.widget.RecyclerView; +import androidx.recyclerview.widget.SimpleItemAnimator; +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; + +import com.google.android.material.appbar.AppBarLayout; +import com.google.android.material.floatingactionbutton.FloatingActionButton; +import com.keylesspalace.tusky.R; +import com.keylesspalace.tusky.adapter.NotificationsAdapter; +import com.keylesspalace.tusky.adapter.StatusBaseViewHolder; +import com.keylesspalace.tusky.appstore.BlockEvent; +import com.keylesspalace.tusky.appstore.EventHub; +import com.keylesspalace.tusky.appstore.PreferenceChangedEvent; +import com.keylesspalace.tusky.appstore.StatusChangedEvent; +import com.keylesspalace.tusky.components.notifications.NotificationHelper; +import com.keylesspalace.tusky.databinding.FragmentTimelineNotificationsBinding; +import com.keylesspalace.tusky.db.AccountEntity; +import com.keylesspalace.tusky.db.AccountManager; +import com.keylesspalace.tusky.di.Injectable; +import com.keylesspalace.tusky.entity.Notification; +import com.keylesspalace.tusky.entity.Poll; +import com.keylesspalace.tusky.entity.Relationship; +import com.keylesspalace.tusky.entity.Status; +import com.keylesspalace.tusky.interfaces.AccountActionListener; +import com.keylesspalace.tusky.interfaces.ActionButtonActivity; +import com.keylesspalace.tusky.interfaces.ReselectableFragment; +import com.keylesspalace.tusky.interfaces.StatusActionListener; +import com.keylesspalace.tusky.settings.PrefKeys; +import com.keylesspalace.tusky.util.CardViewMode; +import com.keylesspalace.tusky.util.Either; +import com.keylesspalace.tusky.util.HttpHeaderLink; +import com.keylesspalace.tusky.util.LinkHelper; +import com.keylesspalace.tusky.util.ListStatusAccessibilityDelegate; +import com.keylesspalace.tusky.util.ListUtils; +import com.keylesspalace.tusky.util.NotificationTypeConverterKt; +import com.keylesspalace.tusky.util.PairedList; +import com.keylesspalace.tusky.util.RelativeTimeUpdater; +import com.keylesspalace.tusky.util.Single; +import com.keylesspalace.tusky.util.StatusDisplayOptions; +import com.keylesspalace.tusky.util.ViewDataUtils; +import com.keylesspalace.tusky.view.EndlessOnScrollListener; +import com.keylesspalace.tusky.viewdata.AttachmentViewData; +import com.keylesspalace.tusky.viewdata.NotificationViewData; +import com.keylesspalace.tusky.viewdata.StatusViewData; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.Set; + +import javax.inject.Inject; + +import at.connyduck.sparkbutton.helpers.Utils; +import kotlin.Unit; +import kotlin.collections.CollectionsKt; +import kotlin.jvm.functions.Function1; +import kotlin.jvm.functions.Function2; +import kotlinx.coroutines.Job; + +public class NotificationsFragment extends SFragment implements + SwipeRefreshLayout.OnRefreshListener, + StatusActionListener, + NotificationsAdapter.NotificationActionListener, + AccountActionListener, + Injectable, + MenuProvider, + ReselectableFragment { + private static final String TAG = "NotificationF"; // logging tag + + private static final int LOAD_AT_ONCE = 30; + private int maxPlaceholderId = 0; + + private final Set notificationFilter = new HashSet<>(); + + private final ArrayList jobs = new ArrayList<>(); + + private enum FetchEnd { + TOP, + BOTTOM, + MIDDLE + } + + /** + * Placeholder for the notificationsEnabled. Consider moving to the separate class to hide constructor + * and reuse in different places as needed. + */ + private static final class Placeholder { + final long id; + + public static Placeholder getInstance(long id) { + return new Placeholder(id); + } + + private Placeholder(long id) { + this.id = id; + } + } + + @Inject + AccountManager accountManager; + @Inject + EventHub eventHub; + + private FragmentTimelineNotificationsBinding binding; + + private LinearLayoutManager layoutManager; + private EndlessOnScrollListener scrollListener; + private NotificationsAdapter adapter; + private boolean hideFab; + private boolean topLoading; + private boolean bottomLoading; + private String bottomId; + private boolean alwaysShowSensitiveMedia; + private boolean alwaysOpenSpoiler; + private boolean showNotificationsFilter; + private boolean showingError; + + // Each element is either a Notification for loading data or a Placeholder + private final PairedList, NotificationViewData> notifications + = new PairedList<>(new Function<>() { + @Override + public NotificationViewData apply(Either input) { + if (input.isRight()) { + Notification notification = input.asRight() + .rewriteToStatusTypeIfNeeded(accountManager.getActiveAccount().getAccountId()); + + boolean sensitiveStatus = notification.getStatus() != null && notification.getStatus().getActionableStatus().getSensitive(); + + return ViewDataUtils.notificationToViewData( + notification, + alwaysShowSensitiveMedia || !sensitiveStatus, + alwaysOpenSpoiler, + true + ); + } else { + return new NotificationViewData.Placeholder(input.asLeft().id, false); + } + } + }); + + public static NotificationsFragment newInstance() { + NotificationsFragment fragment = new NotificationsFragment(); + Bundle arguments = new Bundle(); + fragment.setArguments(arguments); + return fragment; + } + + @Nullable + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, + @Nullable Bundle savedInstanceState) { + requireActivity().addMenuProvider(this, getViewLifecycleOwner(), Lifecycle.State.RESUMED); + + binding = FragmentTimelineNotificationsBinding.inflate(inflater, container, false); + + @NonNull Context context = inflater.getContext(); // from inflater to silence warning + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(requireContext()); + + boolean showNotificationsFilterSetting = preferences.getBoolean("showNotificationsFilter", true); + // Clear notifications on filter visibility change to force refresh + if (showNotificationsFilterSetting != showNotificationsFilter) + notifications.clear(); + showNotificationsFilter = showNotificationsFilterSetting; + + // Setup the SwipeRefreshLayout. + binding.swipeRefreshLayout.setOnRefreshListener(this); + binding.swipeRefreshLayout.setColorSchemeResources(R.color.tusky_blue); + + loadNotificationsFilter(); + + // Setup the RecyclerView. + binding.recyclerView.setHasFixedSize(true); + layoutManager = new LinearLayoutManager(context); + binding.recyclerView.setLayoutManager(layoutManager); + binding.recyclerView.setAccessibilityDelegateCompat( + new ListStatusAccessibilityDelegate(binding.recyclerView, this, (pos) -> { + NotificationViewData notification = notifications.getPairedItemOrNull(pos); + // We support replies only for now + if (notification instanceof NotificationViewData.Concrete) { + return ((NotificationViewData.Concrete) notification).getStatusViewData(); + } else { + return null; + } + })); + + binding.recyclerView.addItemDecoration(new DividerItemDecoration(context, DividerItemDecoration.VERTICAL)); + + StatusDisplayOptions statusDisplayOptions = new StatusDisplayOptions( + preferences.getBoolean("animateGifAvatars", false), + accountManager.getActiveAccount().getMediaPreviewEnabled(), + preferences.getBoolean("absoluteTimeView", false), + preferences.getBoolean("showBotOverlay", true), + preferences.getBoolean("useBlurhash", true), + CardViewMode.NONE, + preferences.getBoolean("confirmReblogs", true), + preferences.getBoolean("confirmFavourites", false), + preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false), + preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false), + preferences.getBoolean(PrefKeys.SHOW_STATS_INLINE, false), + accountManager.getActiveAccount().getAlwaysShowSensitiveMedia(), + accountManager.getActiveAccount().getAlwaysOpenSpoiler() + ); + + adapter = new NotificationsAdapter(accountManager.getActiveAccount().getAccountId(), + dataSource, statusDisplayOptions, this, this, this); + alwaysShowSensitiveMedia = accountManager.getActiveAccount().getAlwaysShowSensitiveMedia(); + alwaysOpenSpoiler = accountManager.getActiveAccount().getAlwaysOpenSpoiler(); + binding.recyclerView.setAdapter(adapter); + + topLoading = false; + bottomLoading = false; + bottomId = null; + + updateAdapter(); + + binding.buttonClear.setOnClickListener(v -> confirmClearNotifications()); + binding.buttonFilter.setOnClickListener(v -> showFilterMenu()); + + if (notifications.isEmpty()) { + binding.swipeRefreshLayout.setEnabled(false); + sendFetchNotificationsRequest(null, null, FetchEnd.BOTTOM, -1); + } else { + binding.progressBar.setVisibility(View.GONE); + } + + ((SimpleItemAnimator) binding.recyclerView.getItemAnimator()).setSupportsChangeAnimations(false); + + updateFilterVisibility(); + + return binding.getRoot(); + } + + @Override + public void onDestroyView() { + super.onDestroyView(); + binding = null; + } + + @Override + public void onCreateMenu(@NonNull Menu menu, @NonNull MenuInflater menuInflater) { + menuInflater.inflate(R.menu.fragment_notifications, menu); + } + + @Override + public boolean onMenuItemSelected(@NonNull MenuItem menuItem) { + if (menuItem.getItemId() == R.id.action_refresh) { + binding.swipeRefreshLayout.setRefreshing(true); + onRefresh(); + return true; + } else if (menuItem.getItemId() == R.id.action_edit_notification_filter) { + showFilterMenu(); + return true; + } else if (menuItem.getItemId() == R.id.action_clear_notifications) { + confirmClearNotifications(); + return true; + } + + return false; + } + + private void updateFilterVisibility() { + CoordinatorLayout.LayoutParams params = + (CoordinatorLayout.LayoutParams) binding.swipeRefreshLayout.getLayoutParams(); + if (showNotificationsFilter && !showingError) { + binding.appBarOptions.setExpanded(true, false); + binding.appBarOptions.setVisibility(View.VISIBLE); + // Set content behaviour to hide filter on scroll + params.setBehavior(new AppBarLayout.ScrollingViewBehavior()); + } else { + binding.appBarOptions.setExpanded(false, false); + binding.appBarOptions.setVisibility(View.GONE); + // Clear behaviour to hide app bar + params.setBehavior(null); + } + } + + private void confirmClearNotifications() { + new AlertDialog.Builder(requireContext()) + .setMessage(R.string.notification_clear_text) + .setPositiveButton(android.R.string.ok, (DialogInterface dia, int which) -> clearNotifications()) + .setNegativeButton(android.R.string.cancel, null) + .show(); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + Activity activity = getActivity(); + if (activity == null) throw new AssertionError("Activity is null"); + + // This is delayed until onActivityCreated solely because MainActivity.composeButton + // isn't guaranteed to be set until then. + // Use a modified scroll listener that both loads more notificationsEnabled as it + // goes, and hides the compose button on down-scroll. + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(activity); + hideFab = preferences.getBoolean("fabHide", false); + scrollListener = new EndlessOnScrollListener(layoutManager) { + @Override + public void onScrolled(@NonNull RecyclerView view, int dx, int dy) { + super.onScrolled(view, dx, dy); + + ActionButtonActivity activity = (ActionButtonActivity) getActivity(); + FloatingActionButton composeButton = activity.getActionButton(); + + if (composeButton != null) { + if (hideFab) { + if (dy > 0 && composeButton.isShown()) { + composeButton.hide(); // Hides the button if we're scrolling down + } else if (dy < 0 && !composeButton.isShown()) { + composeButton.show(); // Shows it if we are scrolling up + } + } else if (!composeButton.isShown()) { + composeButton.show(); + } + } + } + + @Override + public void onLoadMore(int totalItemsCount, @NonNull RecyclerView view) { + NotificationsFragment.this.onLoadMore(); + } + }; + + binding.recyclerView.addOnScrollListener(scrollListener); + + eventHub.subscribe( + getViewLifecycleOwner(), + event -> { + if (event instanceof StatusChangedEvent) { + Status updatedStatus = ((StatusChangedEvent) event).getStatus(); + updateStatus(updatedStatus.getActionableId(), s -> updatedStatus); + } else if (event instanceof BlockEvent) { + removeAllByAccountId(((BlockEvent) event).getAccountId()); + } else if (event instanceof PreferenceChangedEvent) { + onPreferenceChanged(((PreferenceChangedEvent) event).getPreferenceKey()); + } + } + ); + + RelativeTimeUpdater.updateRelativeTimePeriodically(this, this::updateAdapter); + } + + @Override + public void onRefresh() { + binding.statusView.setVisibility(View.GONE); + this.showingError = false; + Either first = CollectionsKt.firstOrNull(this.notifications); + String topId; + if (first != null && first.isRight()) { + topId = first.asRight().getId(); + } else { + topId = null; + } + sendFetchNotificationsRequest(null, topId, FetchEnd.TOP, -1); + } + + @Nullable + @Override + protected Function2 getOnMoreTranslate() { + return null; + } + + @Override + public void onReply(int position) { + super.reply(notifications.get(position).asRight().getStatus()); + } + + @Override + public void onReblog(final boolean reblog, final int position) { + final Notification notification = notifications.get(position).asRight(); + final Status status = notification.getStatus(); + Objects.requireNonNull(status, "Reblog on notification without status"); + timelineCases.reblogOld(status.getId(), reblog) + .subscribe( + getViewLifecycleOwner(), + (newStatus) -> setReblogForStatus(status.getId(), reblog), + (t) -> Log.d(getClass().getSimpleName(), + "Failed to reblog status: " + status.getId(), t) + ); + } + + private void setReblogForStatus(String statusId, boolean reblog) { + updateStatus(statusId, (s) -> s.copyWithReblogged(reblog)); + } + + @Override + public void onFavourite(final boolean favourite, final int position) { + final Notification notification = notifications.get(position).asRight(); + final Status status = notification.getStatus(); + + timelineCases.favouriteOld(status.getId(), favourite) + .subscribe( + getViewLifecycleOwner(), + (newStatus) -> setFavouriteForStatus(status.getId(), favourite), + (t) -> Log.d(getClass().getSimpleName(), + "Failed to favourite status: " + status.getId(), t) + ); + } + + private void setFavouriteForStatus(String statusId, boolean favourite) { + updateStatus(statusId, (s) -> s.copyWithFavourited(favourite)); + } + + @Override + public void onBookmark(final boolean bookmark, final int position) { + final Notification notification = notifications.get(position).asRight(); + final Status status = notification.getStatus(); + + timelineCases.bookmarkOld(status.getActionableId(), bookmark) + .subscribe( + getViewLifecycleOwner(), + (newStatus) -> setBookmarkForStatus(status.getId(), bookmark), + (t) -> Log.d(getClass().getSimpleName(), + "Failed to bookmark status: " + status.getId(), t) + ); + } + + private void setBookmarkForStatus(String statusId, boolean bookmark) { + updateStatus(statusId, (s) -> s.copyWithBookmarked(bookmark)); + } + + public void onVoteInPoll(int position, @NonNull List choices) { + final Notification notification = notifications.get(position).asRight(); + final Status status = notification.getStatus().getActionableStatus(); + timelineCases.voteInPollOld(status.getId(), status.getPoll().getId(), choices) + .subscribe( + getViewLifecycleOwner(), + (newPoll) -> setVoteForPoll(status, newPoll), + (t) -> Log.d(TAG, "Failed to vote in poll: " + status.getId(), t) + ); + } + + @Override + public void clearWarningAction(int position) { + + } + + private void setVoteForPoll(Status status, Poll poll) { + updateStatus(status.getId(), (s) -> s.copyWithPoll(poll)); + } + + @Override + public void onMore(@NonNull View view, int position) { + Notification notification = notifications.get(position).asRight(); + super.more(notification.getStatus(), view, position, null); + } + + @Override + public void onViewMedia(int position, int attachmentIndex, @Nullable View view) { + Notification notification = notifications.get(position).asRightOrNull(); + if (notification == null || notification.getStatus() == null) return; + Status status = notification.getStatus(); + super.viewMedia(attachmentIndex, AttachmentViewData.list(status, accountManager.getActiveAccount().getAlwaysShowSensitiveMedia()), view); + } + + @Override + public void onViewThread(int position) { + Notification notification = notifications.get(position).asRight(); + Status status = notification.getStatus(); + if (status == null) return; + super.viewThread(status.getActionableId(), status.getActionableStatus().getUrl()); + } + + @Override + public void onOpenReblog(int position) { + Notification notification = notifications.get(position).asRight(); + onViewAccount(notification.getAccount().getId()); + } + + @Override + public void onExpandedChange(boolean expanded, int position) { + updateViewDataAt(position, (vd) -> vd.copyWithExpanded(expanded)); + } + + @Override + public void onContentHiddenChange(boolean isShowing, int position) { + updateViewDataAt(position, (vd) -> vd.copyWithShowingContent(isShowing)); + } + + @Override + public void onLoadMore(int position) { + // Check bounds before accessing list, + if (notifications.size() >= position && position > 0) { + Notification previous = notifications.get(position - 1).asRightOrNull(); + Notification next = notifications.get(position + 1).asRightOrNull(); + if (previous == null || next == null) { + Log.e(TAG, "Failed to load more, invalid placeholder position: " + position); + return; + } + sendFetchNotificationsRequest(previous.getId(), next.getId(), FetchEnd.MIDDLE, position); + Placeholder placeholder = notifications.get(position).asLeft(); + NotificationViewData notificationViewData = + new NotificationViewData.Placeholder(placeholder.id, true); + notifications.setPairedItem(position, notificationViewData); + updateAdapter(); + } else { + Log.d(TAG, "error loading more"); + } + } + + @Override + public void onContentCollapsedChange(boolean isCollapsed, int position) { + updateViewDataAt(position, (vd) -> vd.copyWithCollapsed(isCollapsed)); + } + + @Override + public void onUntranslate(int position) { + // not needed + } + + private void updateStatus(String statusId, Function mapper) { + int index = CollectionsKt.indexOfFirst(this.notifications, (s) -> s.isRight() && + s.asRight().getStatus() != null && + s.asRight().getStatus().getId().equals(statusId)); + if (index == -1) return; + + // We have quite some graph here: + // + // Notification --------> Status + // ^ + // | + // StatusViewData + // ^ + // | + // NotificationViewData -----+ + // + // So if we have "new" status we need to update all references to be sure that data is + // up-to-date: + // 1. update status + // 2. update notification + // 3. update statusViewData + // 4. update notificationViewData + + Status oldStatus = notifications.get(index).asRight().getStatus(); + NotificationViewData.Concrete oldViewData = + (NotificationViewData.Concrete) this.notifications.getPairedItem(index); + Status newStatus = mapper.apply(oldStatus); + Notification newNotification = this.notifications.get(index).asRight() + .copyWithStatus(newStatus); + StatusViewData.Concrete newStatusViewData = + Objects.requireNonNull(oldViewData.getStatusViewData()).copyWithStatus(newStatus); + NotificationViewData.Concrete newViewData = oldViewData.copyWithStatus(newStatusViewData); + + notifications.set(index, new Either.Right<>(newNotification)); + notifications.setPairedItem(index, newViewData); + + updateAdapter(); + } + + private void updateViewDataAt(int position, + Function mapper) { + if (position < 0 || position >= notifications.size()) { + String message = String.format( + Locale.getDefault(), + "Tried to access out of bounds status position: %d of %d", + position, + notifications.size() - 1 + ); + Log.e(TAG, message); + return; + } + NotificationViewData someViewData = this.notifications.getPairedItem(position); + if (!(someViewData instanceof NotificationViewData.Concrete)) { + return; + } + NotificationViewData.Concrete oldViewData = (NotificationViewData.Concrete) someViewData; + StatusViewData.Concrete oldStatusViewData = oldViewData.getStatusViewData(); + if (oldStatusViewData == null) return; + + NotificationViewData.Concrete newViewData = + oldViewData.copyWithStatus(mapper.apply(oldStatusViewData)); + notifications.setPairedItem(position, newViewData); + + updateAdapter(); + } + + @Override + public void onNotificationContentCollapsedChange(boolean isCollapsed, int position) { + onContentCollapsedChange(isCollapsed, position); + } + + private void clearNotifications() { + // Cancel all ongoing requests + binding.swipeRefreshLayout.setRefreshing(false); + resetNotificationsLoad(); + + // Show friend elephant + binding.statusView.setVisibility(View.VISIBLE); + binding.statusView.setup(R.drawable.elephant_friend_empty, R.string.message_empty, null); + updateFilterVisibility(); + + // Update adapter + updateAdapter(); + + // Execute clear notifications request + timelineCases.clearNotificationsOld() + .subscribe( + getViewLifecycleOwner(), + response -> { + // Nothing to do + }, + throwable -> { + // Reload notifications on failure + fullyRefreshWithProgressBar(true); + }); + } + + private void resetNotificationsLoad() { + for (Job job : jobs) { + job.cancel(null); + } + jobs.clear(); + bottomLoading = false; + topLoading = false; + + // Disable load more + bottomId = null; + + // Clear exists notifications + notifications.clear(); + } + + + private void showFilterMenu() { + List notificationsList = Notification.Type.Companion.getVisibleTypes(); + List list = new ArrayList<>(); + for (Notification.Type type : notificationsList) { + list.add(getNotificationText(type)); + } + + ArrayAdapter adapter = new ArrayAdapter<>(getContext(), android.R.layout.simple_list_item_multiple_choice, list); + PopupWindow window = new PopupWindow(getContext()); + View view = LayoutInflater.from(getContext()).inflate(R.layout.notifications_filter, (ViewGroup) getView(), false); + final ListView listView = view.findViewById(R.id.listView); + view.findViewById(R.id.buttonApply) + .setOnClickListener(v -> { + SparseBooleanArray checkedItems = listView.getCheckedItemPositions(); + Set excludes = new HashSet<>(); + for (int i = 0; i < notificationsList.size(); i++) { + if (!checkedItems.get(i, false)) + excludes.add(notificationsList.get(i)); + } + window.dismiss(); + applyFilterChanges(excludes); + + }); + + listView.setAdapter(adapter); + listView.setChoiceMode(ListView.CHOICE_MODE_MULTIPLE); + for (int i = 0; i < notificationsList.size(); i++) { + if (!notificationFilter.contains(notificationsList.get(i))) + listView.setItemChecked(i, true); + } + window.setContentView(view); + window.setFocusable(true); + window.setWidth(ViewGroup.LayoutParams.WRAP_CONTENT); + window.setHeight(ViewGroup.LayoutParams.WRAP_CONTENT); + window.showAsDropDown(binding.buttonFilter); + + } + + private String getNotificationText(Notification.Type type) { + switch (type) { + case MENTION: + return getString(R.string.notification_mention_name); + case FAVOURITE: + return getString(R.string.notification_favourite_name); + case REBLOG: + return getString(R.string.notification_boost_name); + case FOLLOW: + return getString(R.string.notification_follow_name); + case FOLLOW_REQUEST: + return getString(R.string.notification_follow_request_name); + case POLL: + return getString(R.string.notification_poll_name); + case STATUS: + return getString(R.string.notification_subscription_name); + case SIGN_UP: + return getString(R.string.notification_sign_up_name); + case UPDATE: + return getString(R.string.notification_update_name); + case REPORT: + return getString(R.string.notification_report_name); + default: + return "Unknown"; + } + } + + private void applyFilterChanges(Set newSet) { + List notifications = Notification.Type.Companion.getVisibleTypes(); + boolean isChanged = false; + for (Notification.Type type : notifications) { + if (notificationFilter.contains(type) && !newSet.contains(type)) { + notificationFilter.remove(type); + isChanged = true; + } else if (!notificationFilter.contains(type) && newSet.contains(type)) { + notificationFilter.add(type); + isChanged = true; + } + } + if (isChanged) { + saveNotificationsFilter(); + fullyRefreshWithProgressBar(true); + } + + } + + private void loadNotificationsFilter() { + AccountEntity account = accountManager.getActiveAccount(); + if (account != null) { + notificationFilter.clear(); + notificationFilter.addAll(NotificationTypeConverterKt.deserialize( + account.getNotificationsFilter())); + } + } + + private void saveNotificationsFilter() { + AccountEntity account = accountManager.getActiveAccount(); + if (account != null) { + account.setNotificationsFilter(NotificationTypeConverterKt.serialize(notificationFilter)); + accountManager.saveAccount(account); + } + } + + @Override + public void onViewTag(@NonNull String tag) { + super.viewTag(tag); + } + + @Override + public void onViewAccount(@NonNull String id) { + super.viewAccount(id); + } + + @Override + public void onMute(boolean mute, String id, int position, boolean notifications) { + // No muting from notifications yet + } + + @Override + public void onBlock(boolean block, String id, int position) { + // No blocking from notifications yet + } + + @Override + public void onRespondToFollowRequest(boolean accept, String id, int position) { + final Single request = accept ? + timelineCases.acceptFollowRequestOld(id) : + timelineCases.rejectFollowRequestOld(id); + request.subscribe( + getViewLifecycleOwner(), + (relationship) -> fullyRefreshWithProgressBar(true), + (error) -> Log.e(TAG, String.format("Failed to %s account id %s", accept ? "accept" : "reject", id)) + ); + } + + @Override + public void onViewStatusForNotificationId(String notificationId) { + for (Either either : notifications) { + Notification notification = either.asRightOrNull(); + if (notification != null && notification.getId().equals(notificationId)) { + Status status = notification.getStatus(); + if (status != null) { + super.viewThread(status.getActionableId(), status.getActionableStatus().getUrl()); + return; + } + } + } + Log.w(TAG, "Didn't find a notification for ID: " + notificationId); + } + + @Override + public void onViewReport(String reportId) { + LinkHelper.openLink(requireContext(), String.format("https://%s/admin/reports/%s", accountManager.getActiveAccount().getDomain(), reportId)); + } + + private void onPreferenceChanged(String key) { + switch (key) { + case "fabHide": { + hideFab = PreferenceManager.getDefaultSharedPreferences(requireContext()).getBoolean("fabHide", false); + break; + } + case "mediaPreviewEnabled": { + boolean enabled = accountManager.getActiveAccount().getMediaPreviewEnabled(); + if (enabled != adapter.isMediaPreviewEnabled()) { + adapter.setMediaPreviewEnabled(enabled); + fullyRefresh(); + } + break; + } + case "showNotificationsFilter": { + if (isAdded()) { + showNotificationsFilter = PreferenceManager.getDefaultSharedPreferences(requireContext()).getBoolean("showNotificationsFilter", true); + updateFilterVisibility(); + fullyRefreshWithProgressBar(true); + } + break; + } + } + } + + @Override + public void removeItem(int position) { + notifications.remove(position); + updateAdapter(); + } + + private void removeAllByAccountId(String accountId) { + // Using iterator to safely remove items while iterating + Iterator> iterator = notifications.iterator(); + while (iterator.hasNext()) { + Either notification = iterator.next(); + Notification maybeNotification = notification.asRightOrNull(); + if (maybeNotification != null && maybeNotification.getAccount().getId().equals(accountId)) { + iterator.remove(); + } + } + updateAdapter(); + } + + private void onLoadMore() { + if (bottomId == null) { + // Already loaded everything + return; + } + + // Check for out-of-bounds when loading + // This is required to allow full-timeline reloads of collapsible statuses when the settings + // change. + if (notifications.size() > 0) { + Either last = notifications.get(notifications.size() - 1); + if (last.isRight()) { + final Placeholder placeholder = newPlaceholder(); + notifications.add(new Either.Left<>(placeholder)); + NotificationViewData viewData = + new NotificationViewData.Placeholder(placeholder.id, true); + notifications.setPairedItem(notifications.size() - 1, viewData); + updateAdapter(); + } + } + + sendFetchNotificationsRequest(bottomId, null, FetchEnd.BOTTOM, -1); + } + + private Placeholder newPlaceholder() { + Placeholder placeholder = Placeholder.getInstance(maxPlaceholderId); + maxPlaceholderId--; + return placeholder; + } + + private void jumpToTop() { + if (isAdded()) { + //binding.appBarOptions.setExpanded(true, false); + layoutManager.scrollToPosition(0); + scrollListener.reset(); + } + } + + private void sendFetchNotificationsRequest(String fromId, String uptoId, + final FetchEnd fetchEnd, final int pos) { + // If there is a fetch already ongoing, record however many fetches are requested and + // fulfill them after it's complete. + if (fetchEnd == FetchEnd.TOP && topLoading) { + return; + } + if (fetchEnd == FetchEnd.BOTTOM && bottomLoading) { + return; + } + if (fetchEnd == FetchEnd.TOP) { + topLoading = true; + } + if (fetchEnd == FetchEnd.BOTTOM) { + bottomLoading = true; + } + + Job notificationCall = timelineCases.notificationsOld(fromId, uptoId, LOAD_AT_ONCE, showNotificationsFilter ? notificationFilter : null) + .subscribe( + getViewLifecycleOwner(), + response -> { + if (response.isSuccessful()) { + String linkHeader = response.headers().get("Link"); + onFetchNotificationsSuccess(response.body(), linkHeader, fetchEnd, pos); + } else { + onFetchNotificationsFailure(new Exception(response.message()), fetchEnd, pos); + } + }, + throwable -> onFetchNotificationsFailure(throwable, fetchEnd, pos) + ); + jobs.add(notificationCall); + } + + private void onFetchNotificationsSuccess(List notifications, String linkHeader, + FetchEnd fetchEnd, int pos) { + List links = HttpHeaderLink.Companion.parse(linkHeader); + HttpHeaderLink next = HttpHeaderLink.Companion.findByRelationType(links, "next"); + String fromId = null; + if (next != null) { + fromId = next.getUri().getQueryParameter("max_id"); + } + + switch (fetchEnd) { + case TOP: { + update(notifications, this.notifications.isEmpty() ? fromId : null); + break; + } + case MIDDLE: { + replacePlaceholderWithNotifications(notifications, pos); + break; + } + case BOTTOM: { + + if (!this.notifications.isEmpty() + && !this.notifications.get(this.notifications.size() - 1).isRight()) { + this.notifications.remove(this.notifications.size() - 1); + updateAdapter(); + } + + if (adapter.getItemCount() > 1) { + addItems(notifications, fromId); + } else { + update(notifications, fromId); + } + + break; + } + } + + saveNewestNotificationId(notifications); + + if (fetchEnd == FetchEnd.TOP) { + topLoading = false; + } + if (fetchEnd == FetchEnd.BOTTOM) { + bottomLoading = false; + } + + if (notifications.size() == 0 && adapter.getItemCount() == 0) { + binding.statusView.setVisibility(View.VISIBLE); + binding.statusView.setup(R.drawable.elephant_friend_empty, R.string.message_empty, null); + } + + updateFilterVisibility(); + binding.swipeRefreshLayout.setEnabled(true); + binding.swipeRefreshLayout.setRefreshing(false); + binding.progressBar.setVisibility(View.GONE); + } + + private void onFetchNotificationsFailure(Throwable throwable, FetchEnd fetchEnd, int position) { + binding.swipeRefreshLayout.setRefreshing(false); + if (fetchEnd == FetchEnd.MIDDLE && !notifications.get(position).isRight()) { + Placeholder placeholder = notifications.get(position).asLeft(); + NotificationViewData placeholderVD = + new NotificationViewData.Placeholder(placeholder.id, false); + notifications.setPairedItem(position, placeholderVD); + updateAdapter(); + } else if (this.notifications.isEmpty()) { + binding.statusView.setVisibility(View.VISIBLE); + binding.swipeRefreshLayout.setEnabled(false); + this.showingError = true; + if (throwable instanceof IOException) { + binding.statusView.setup(R.drawable.errorphant_offline, R.string.error_network, __ -> { + binding.progressBar.setVisibility(View.VISIBLE); + this.onRefresh(); + return Unit.INSTANCE; + }); + } else { + binding.statusView.setup(R.drawable.errorphant_error, R.string.error_generic, __ -> { + binding.progressBar.setVisibility(View.VISIBLE); + this.onRefresh(); + return Unit.INSTANCE; + }); + } + updateFilterVisibility(); + } + Log.e(TAG, "Fetch failure: " + throwable.getMessage()); + + if (fetchEnd == FetchEnd.TOP) { + topLoading = false; + } + if (fetchEnd == FetchEnd.BOTTOM) { + bottomLoading = false; + } + + binding.progressBar.setVisibility(View.GONE); + } + + private void saveNewestNotificationId(List notifications) { + + AccountEntity account = accountManager.getActiveAccount(); + if (account != null) { + String lastNotificationId = account.getLastNotificationId(); + + for (Notification noti : notifications) { + if (isLessThan(lastNotificationId, noti.getId())) { + lastNotificationId = noti.getId(); + } + } + + if (!account.getLastNotificationId().equals(lastNotificationId)) { + Log.d(TAG, "saving newest noti id: " + lastNotificationId); + account.setLastNotificationId(lastNotificationId); + accountManager.saveAccount(account); + } + } + } + + private void update(@Nullable List newNotifications, @Nullable String fromId) { + if (ListUtils.isEmpty(newNotifications)) { + updateAdapter(); + return; + } + if (fromId != null) { + bottomId = fromId; + } + List> liftedNew = + liftNotificationList(newNotifications); + if (notifications.isEmpty()) { + notifications.addAll(liftedNew); + } else { + int index = notifications.indexOf(liftedNew.get(newNotifications.size() - 1)); + if (index > 0) { + notifications.subList(0, index).clear(); + } + + int newIndex = liftedNew.indexOf(notifications.get(0)); + if (newIndex == -1) { + if (index == -1 && liftedNew.size() >= LOAD_AT_ONCE) { + liftedNew.add(new Either.Left<>(newPlaceholder())); + } + notifications.addAll(0, liftedNew); + } else { + notifications.addAll(0, liftedNew.subList(0, newIndex)); + } + } + updateAdapter(); + } + + private void addItems(List newNotifications, @Nullable String fromId) { + bottomId = fromId; + if (ListUtils.isEmpty(newNotifications)) { + return; + } + int end = notifications.size(); + List> liftedNew = liftNotificationList(newNotifications); + Either last = notifications.get(end - 1); + if (last != null && !liftedNew.contains(last)) { + notifications.addAll(liftedNew); + updateAdapter(); + } + } + + private void replacePlaceholderWithNotifications(List newNotifications, int pos) { + // Remove placeholder + notifications.remove(pos); + + if (ListUtils.isEmpty(newNotifications)) { + updateAdapter(); + return; + } + + List> liftedNew = liftNotificationList(newNotifications); + + // If we fetched less posts than in the limit, it means that the hole is not filled + // If we fetched at least as much it means that there are more posts to load and we should + // insert new placeholder + if (newNotifications.size() >= LOAD_AT_ONCE) { + liftedNew.add(new Either.Left<>(newPlaceholder())); + } + + notifications.addAll(pos, liftedNew); + updateAdapter(); + } + + private final Function1> notificationLifter = + Either.Right::new; + + private List> liftNotificationList(List list) { + return CollectionsKt.map(list, notificationLifter); + } + + private void fullyRefreshWithProgressBar(boolean isShow) { + resetNotificationsLoad(); + if (isShow) { + binding.progressBar.setVisibility(View.VISIBLE); + binding.statusView.setVisibility(View.GONE); + } + updateAdapter(); + sendFetchNotificationsRequest(null, null, FetchEnd.TOP, -1); + } + + private void fullyRefresh() { + fullyRefreshWithProgressBar(false); + } + + @Nullable + private Pair findReplyPosition(@NonNull String statusId) { + for (int i = 0; i < notifications.size(); i++) { + Notification notification = notifications.get(i).asRightOrNull(); + if (notification != null + && notification.getStatus() != null + && notification.getType() == Notification.Type.MENTION + && (statusId.equals(notification.getStatus().getId()) + || (notification.getStatus().getReblog() != null + && statusId.equals(notification.getStatus().getReblog().getId())))) { + return new Pair<>(i, notification); + } + } + return null; + } + + private void updateAdapter() { + differ.submitList(notifications.getPairedCopy()); + } + + private final ListUpdateCallback listUpdateCallback = new ListUpdateCallback() { + @Override + public void onInserted(int position, int count) { + if (isAdded()) { + adapter.notifyItemRangeInserted(position, count); + Context context = getContext(); + // scroll up when new items at the top are loaded while being at the start + // https://github.com/tuskyapp/Tusky/pull/1905#issuecomment-677819724 + if (position == 0 && context != null && adapter.getItemCount() != count) { + binding.recyclerView.scrollBy(0, Utils.dpToPx(context, -30)); + } + } + } + + @Override + public void onRemoved(int position, int count) { + adapter.notifyItemRangeRemoved(position, count); + } + + @Override + public void onMoved(int fromPosition, int toPosition) { + adapter.notifyItemMoved(fromPosition, toPosition); + } + + @Override + public void onChanged(int position, int count, Object payload) { + adapter.notifyItemRangeChanged(position, count, payload); + } + }; + + private final AsyncListDiffer + differ = new AsyncListDiffer<>(listUpdateCallback, + new AsyncDifferConfig.Builder<>(diffCallback).build()); + + private final NotificationsAdapter.AdapterDataSource dataSource = + new NotificationsAdapter.AdapterDataSource<>() { + @Override + public int getItemCount() { + return differ.getCurrentList().size(); + } + + @Override + public NotificationViewData getItemAt(int pos) { + return differ.getCurrentList().get(pos); + } + }; + + private static final DiffUtil.ItemCallback diffCallback + = new DiffUtil.ItemCallback<>() { + + @Override + public boolean areItemsTheSame(NotificationViewData oldItem, NotificationViewData newItem) { + return oldItem.getViewDataId() == newItem.getViewDataId(); + } + + @Override + public boolean areContentsTheSame(@NonNull NotificationViewData oldItem, @NonNull NotificationViewData newItem) { + return false; + } + + @Nullable + @Override + public Object getChangePayload(@NonNull NotificationViewData oldItem, @NonNull NotificationViewData newItem) { + if (oldItem.deepEquals(newItem)) { + // If items are equal - update timestamp only + return Collections.singletonList(StatusBaseViewHolder.Key.KEY_CREATED); + } else + // If items are different - update a whole view holder + return null; + } + }; + + @Override + public void onResume() { + super.onResume(); + + NotificationHelper.clearNotificationsForAccount(requireContext(), accountManager.getActiveAccount()); + + String rawAccountNotificationFilter = accountManager.getActiveAccount().getNotificationsFilter(); + Set accountNotificationFilter = NotificationTypeConverterKt.deserialize(rawAccountNotificationFilter); + if (!notificationFilter.equals(accountNotificationFilter)) { + loadNotificationsFilter(); + fullyRefreshWithProgressBar(true); + } + } + + @Override + public void onReselect() { + jumpToTop(); + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.kt b/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.kt index a99236839..110a99e95 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.kt @@ -46,21 +46,25 @@ import com.keylesspalace.tusky.ViewMediaActivity.Companion.newIntent import com.keylesspalace.tusky.components.compose.ComposeActivity import com.keylesspalace.tusky.components.compose.ComposeActivity.Companion.startIntent import com.keylesspalace.tusky.components.compose.ComposeActivity.ComposeOptions +import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository import com.keylesspalace.tusky.components.report.ReportActivity.Companion.getIntent import com.keylesspalace.tusky.db.AccountEntity import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.entity.Attachment import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.entity.Translation import com.keylesspalace.tusky.interfaces.AccountSelectionListener import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.usecase.TimelineCases import com.keylesspalace.tusky.util.openLink import com.keylesspalace.tusky.util.parseAsMastodonHtml +import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation import com.keylesspalace.tusky.view.showMuteAccountDialog import com.keylesspalace.tusky.viewdata.AttachmentViewData -import kotlinx.coroutines.launch +import java.util.Locale import javax.inject.Inject +import kotlinx.coroutines.launch /* Note from Andrew on Jan. 22, 2017: This class is a design problem for me, so I left it with an * awkward name. TimelineFragment and NotificationFragment have significant overlap but the nature @@ -71,6 +75,10 @@ import javax.inject.Inject abstract class SFragment : Fragment(), Injectable { protected abstract fun removeItem(position: Int) protected abstract fun onReblog(reblog: Boolean, position: Int) + + /** `null` if translation is not supported on this screen */ + protected abstract val onMoreTranslate: ((translate: Boolean, position: Int) -> Unit)? + private lateinit var bottomSheetActivity: BottomSheetActivity @Inject @@ -82,9 +90,11 @@ abstract class SFragment : Fragment(), Injectable { @Inject lateinit var timelineCases: TimelineCases + @Inject + lateinit var instanceInfoRepository: InstanceInfoRepository + override fun startActivity(intent: Intent) { - super.startActivity(intent) - requireActivity().overridePendingTransition(R.anim.slide_from_right, R.anim.slide_to_left) + requireActivity().startActivityWithSlideInAnimation(intent) } override fun onAttach(context: Context) { @@ -96,6 +106,13 @@ abstract class SFragment : Fragment(), Injectable { } } + override fun onResume() { + super.onResume() + + // make sure we have instance info for when we'll need it + instanceInfoRepository.precache() + } + protected fun openReblog(status: Status?) { if (status == null) return bottomSheetActivity.viewAccount(status.account.id) @@ -140,7 +157,7 @@ abstract class SFragment : Fragment(), Injectable { requireActivity().startActivity(intent) } - protected fun more(status: Status, view: View, position: Int) { + protected fun more(status: Status, view: View, position: Int, translation: Translation?) { val id = status.actionableId val accountId = status.actionableStatus.account.id val accountUsername = status.actionableStatus.account.username @@ -158,18 +175,28 @@ abstract class SFragment : Fragment(), Injectable { val menu = popup.menu when (status.visibility) { Status.Visibility.PUBLIC, Status.Visibility.UNLISTED -> { - menu.add(0, R.id.pin, 1, getString(if (status.isPinned()) R.string.unpin_action else R.string.pin_action)) + menu.add( + 0, + R.id.pin, + 1, + getString( + if (status.pinned) R.string.unpin_action else R.string.pin_action + ) + ) } + Status.Visibility.PRIVATE -> { val reblogged = status.reblog?.reblogged ?: status.reblogged menu.findItem(R.id.status_reblog_private).isVisible = !reblogged menu.findItem(R.id.status_unreblog_private).isVisible = reblogged } + else -> {} } } else { popup.inflate(R.menu.status_more) - popup.menu.findItem(R.id.status_download_media).isVisible = status.attachments.isNotEmpty() + popup.menu.findItem(R.id.status_download_media).isVisible = + status.attachments.isNotEmpty() } val menu = popup.menu val openAsItem = menu.findItem(R.id.status_open_as) @@ -180,17 +207,27 @@ abstract class SFragment : Fragment(), Injectable { openAsItem.title = openAsText } val muteConversationItem = menu.findItem(R.id.status_mute_conversation) - val mutable = statusIsByCurrentUser || accountIsInMentions(activeAccount, status.mentions) + val mutable = + statusIsByCurrentUser || accountIsInMentions(activeAccount, status.mentions) muteConversationItem.isVisible = mutable if (mutable) { muteConversationItem.setTitle( - if (status.muted != true) { + if (!status.muted) { R.string.action_mute_conversation } else { R.string.action_unmute_conversation } ) } + + // translation not there for your own posts + menu.findItem(R.id.status_translate)?.let { translateItem -> + translateItem.isVisible = onMoreTranslate != null && + !status.language.equals(Locale.getDefault().language, ignoreCase = true) && + instanceInfoRepository.cachedInstanceInfoOrFallback.translationEnabled == true + translateItem.setTitle(if (translation != null) R.string.action_show_original else R.string.action_translate) + } + popup.setOnMenuItemClickListener { item: MenuItem -> when (item.itemId) { R.id.post_share_content -> { @@ -212,6 +249,7 @@ abstract class SFragment : Fragment(), Injectable { ) return@setOnMenuItemClickListener true } + R.id.post_share_link -> { val sendIntent = Intent().apply { action = Intent.ACTION_SEND @@ -226,68 +264,91 @@ abstract class SFragment : Fragment(), Injectable { ) return@setOnMenuItemClickListener true } + R.id.status_copy_link -> { - (requireActivity().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager).apply { + ( + requireActivity().getSystemService( + Context.CLIPBOARD_SERVICE + ) as ClipboardManager + ).apply { setPrimaryClip(ClipData.newPlainText(null, statusUrl)) } return@setOnMenuItemClickListener true } + R.id.status_open_as -> { showOpenAsDialog(statusUrl, item.title) return@setOnMenuItemClickListener true } + R.id.status_download_media -> { requestDownloadAllMedia(status) return@setOnMenuItemClickListener true } + R.id.status_mute -> { onMute(accountId, accountUsername) return@setOnMenuItemClickListener true } + R.id.status_block -> { onBlock(accountId, accountUsername) return@setOnMenuItemClickListener true } + R.id.status_report -> { openReportPage(accountId, accountUsername, id) return@setOnMenuItemClickListener true } + R.id.status_unreblog_private -> { onReblog(false, position) return@setOnMenuItemClickListener true } + R.id.status_reblog_private -> { onReblog(true, position) return@setOnMenuItemClickListener true } + R.id.status_delete -> { showConfirmDeleteDialog(id, position) return@setOnMenuItemClickListener true } + R.id.status_delete_and_redraft -> { showConfirmEditDialog(id, position, status) return@setOnMenuItemClickListener true } + R.id.status_edit -> { editStatus(id, status) return@setOnMenuItemClickListener true } + R.id.pin -> { lifecycleScope.launch { - timelineCases.pin(status.id, !status.isPinned()).onFailure { e: Throwable -> - val message = e.message - ?: getString(if (status.isPinned()) R.string.failed_to_unpin else R.string.failed_to_pin) - Snackbar.make(requireView(), message, Snackbar.LENGTH_LONG).show() - } + timelineCases.pin(status.id, !status.pinned) + .onFailure { e: Throwable -> + val message = e.message + ?: getString(if (status.pinned) R.string.failed_to_unpin else R.string.failed_to_pin) + Snackbar.make(requireView(), message, Snackbar.LENGTH_LONG) + .show() + } } return@setOnMenuItemClickListener true } + R.id.status_mute_conversation -> { lifecycleScope.launch { - timelineCases.muteConversation(status.id, status.muted != true) + timelineCases.muteConversation(status.id, !status.muted) } return@setOnMenuItemClickListener true } + + R.id.status_translate -> { + onMoreTranslate?.invoke(translation == null, position) + } } false } @@ -295,7 +356,10 @@ abstract class SFragment : Fragment(), Injectable { } private fun onMute(accountId: String, accountUsername: String) { - showMuteAccountDialog(this.requireActivity(), accountUsername) { notifications: Boolean?, duration: Int? -> + showMuteAccountDialog( + this.requireActivity(), + accountUsername + ) { notifications: Boolean?, duration: Int? -> lifecycleScope.launch { timelineCases.mute(accountId, notifications == true, duration) } @@ -332,6 +396,7 @@ abstract class SFragment : Fragment(), Injectable { startActivity(intent) } } + Attachment.Type.UNKNOWN -> { requireContext().openLink(attachment.url) } @@ -379,7 +444,7 @@ abstract class SFragment : Fragment(), Injectable { timelineCases.delete(id).fold( { deletedStatus -> removeItem(position) - val sourceStatus = if (deletedStatus.isEmpty()) { + val sourceStatus = if (deletedStatus.isEmpty) { status.toDeletedStatus() } else { deletedStatus @@ -459,13 +524,18 @@ abstract class SFragment : Fragment(), Injectable { private fun downloadAllMedia(status: Status) { Toast.makeText(context, R.string.downloading_media, Toast.LENGTH_SHORT).show() - val downloadManager = requireActivity().getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager + val downloadManager = requireActivity().getSystemService( + Context.DOWNLOAD_SERVICE + ) as DownloadManager for ((_, url) in status.attachments) { val uri = Uri.parse(url) downloadManager.enqueue( DownloadManager.Request(uri).apply { - setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, uri.lastPathSegment) + setDestinationInExternalPublicDir( + Environment.DIRECTORY_DOWNLOADS, + uri.lastPathSegment + ) } ) } @@ -474,7 +544,7 @@ abstract class SFragment : Fragment(), Injectable { private fun requestDownloadAllMedia(status: Status) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { val permissions = arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE) - (activity as BaseActivity).requestPermissions(permissions) { _: Array?, grantResults: IntArray -> + (activity as BaseActivity).requestPermissions(permissions) { _, grantResults -> if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) { downloadAllMedia(status) } else { @@ -492,7 +562,10 @@ abstract class SFragment : Fragment(), Injectable { companion object { private const val TAG = "SFragment" - private fun accountIsInMentions(account: AccountEntity?, mentions: List): Boolean { + private fun accountIsInMentions( + account: AccountEntity?, + mentions: List + ): Boolean { return mentions.any { mention -> account?.username == mention.username && account.domain == Uri.parse(mention.url)?.host } diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewImageFragment.kt b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewImageFragment.kt index 24c8feb03..6a18eca2e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewImageFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewImageFragment.kt @@ -19,27 +19,35 @@ import android.animation.Animator import android.animation.AnimatorListenerAdapter import android.annotation.SuppressLint import android.content.Context +import android.graphics.PointF import android.graphics.drawable.Drawable import android.os.Bundle +import android.view.GestureDetector import android.view.LayoutInflater import android.view.MotionEvent import android.view.View import android.view.ViewGroup import android.widget.ImageView import androidx.core.os.BundleCompat +import androidx.core.view.GestureDetectorCompat +import androidx.lifecycle.lifecycleScope import com.bumptech.glide.Glide import com.bumptech.glide.load.DataSource import com.bumptech.glide.load.engine.GlideException import com.bumptech.glide.request.RequestListener import com.bumptech.glide.request.target.Target -import com.github.chrisbanes.photoview.PhotoViewAttacher +import com.keylesspalace.tusky.R import com.keylesspalace.tusky.ViewMediaActivity import com.keylesspalace.tusky.databinding.FragmentViewImageBinding import com.keylesspalace.tusky.entity.Attachment import com.keylesspalace.tusky.util.hide +import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.util.visible -import io.reactivex.rxjava3.subjects.BehaviorSubject +import com.ortiz.touchview.OnTouchCoordinatesListener +import com.ortiz.touchview.TouchImageView import kotlin.math.abs +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.launch class ViewImageFragment : ViewMediaFragment() { interface PhotoActionsListener { @@ -48,13 +56,11 @@ class ViewImageFragment : ViewMediaFragment() { fun onPhotoTap() } - private var _binding: FragmentViewImageBinding? = null - private val binding get() = _binding!! + private val binding by viewBinding(FragmentViewImageBinding::bind) - private lateinit var attacher: PhotoViewAttacher private lateinit var photoActionsListener: PhotoActionsListener private lateinit var toolbar: View - private var transition = BehaviorSubject.create() + private var transition: CompletableDeferred? = null private var shouldStartTransition = false // Volatile: Image requests happen on background thread and we want to see updates to it @@ -81,11 +87,14 @@ class ViewImageFragment : ViewMediaFragment() { loadImageFromNetwork(url, previewUrl, binding.photoView) } - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { toolbar = (requireActivity() as ViewMediaActivity).toolbar - this.transition = BehaviorSubject.create() - _binding = FragmentViewImageBinding.inflate(inflater, container, false) - return binding.root + this.transition = CompletableDeferred() + return inflater.inflate(R.layout.fragment_view_image, container, false) } @SuppressLint("ClickableViewAccessibility") @@ -93,7 +102,11 @@ class ViewImageFragment : ViewMediaFragment() { super.onViewCreated(view, savedInstanceState) val arguments = this.requireArguments() - val attachment = BundleCompat.getParcelable(arguments, ARG_ATTACHMENT, Attachment::class.java) + val attachment = BundleCompat.getParcelable( + arguments, + ARG_ATTACHMENT, + Attachment::class.java + ) this.shouldStartTransition = arguments.getBoolean(ARG_START_POSTPONED_TRANSITION) val url: String? var description: String? = null @@ -108,85 +121,126 @@ class ViewImageFragment : ViewMediaFragment() { } } - attacher = PhotoViewAttacher(binding.photoView).apply { - // This prevents conflicts with ViewPager - setAllowParentInterceptOnEdge(true) + val singleTapDetector = GestureDetectorCompat( + requireContext(), + object : GestureDetector.SimpleOnGestureListener() { + override fun onDown(e: MotionEvent) = true + override fun onSingleTapConfirmed(e: MotionEvent): Boolean { + photoActionsListener.onPhotoTap() + return false + } + } + ) - // Clicking outside the photo closes the viewer. - setOnOutsidePhotoTapListener { photoActionsListener.onDismiss() } - setOnClickListener { onMediaTap() } + binding.photoView.setOnTouchCoordinatesListener(object : OnTouchCoordinatesListener { + /** Y coordinate of the last single-finger drag */ + var lastDragY: Float? = null - /* A vertical swipe motion also closes the viewer. This is especially useful when the photo - * mostly fills the screen so clicking outside is difficult. */ - setOnSingleFlingListener { _, _, velocityX, velocityY -> - var result = false - if (abs(velocityY) > abs(velocityX)) { + override fun onTouchCoordinate(view: View, event: MotionEvent, bitmapPoint: PointF) { + singleTapDetector.onTouchEvent(event) + + // Two fingers have gone down after a single finger drag. Finish the drag + if (event.pointerCount == 2 && lastDragY != null) { + onGestureEnd(view) + lastDragY = null + } + + // Stop the parent view from handling touches if either (a) the user has 2+ + // fingers on the screen, or (b) the image has been zoomed in, and can be scrolled + // horizontally in both directions. + // + // This stops things like ViewPager2 from trying to intercept a left/right swipe + // and ensures that the image does not appear to "stick" to the screen as different + // views fight over who should be handling the swipe. + // + // If the view can be scrolled in one direction it's OK to let the parent intercept, + // which allows the user to swipe between images even if one or more of them have + // been zoomed in. + if (event.pointerCount >= 2 || view.canScrollHorizontally(1) && view.canScrollHorizontally(-1)) { + when (event.action) { + MotionEvent.ACTION_DOWN, MotionEvent.ACTION_MOVE -> { + view.parent.requestDisallowInterceptTouchEvent(true) + } + + MotionEvent.ACTION_UP -> { + view.parent.requestDisallowInterceptTouchEvent(false) + } + } + return + } + + // The user is dragging the image around + if (event.pointerCount == 1) { + // If the image is zoomed then the swipe-to-dismiss functionality is disabled + if ((view as TouchImageView).isZoomed) return + + // The user's finger just went down, start recording where they are dragging from + if (event.action == MotionEvent.ACTION_DOWN) { + lastDragY = event.rawY + return + } + + // The user is dragging the un-zoomed image to possibly fling it up or down + // to dismiss. + if (event.action == MotionEvent.ACTION_MOVE) { + // lastDragY may be null; e.g., the user was performing a two-finger drag, + // and has lifted one finger. In this case do nothing + lastDragY ?: return + + // Compute the Y offset of the drag, and scale/translate the photoview + // accordingly. + val diff = event.rawY - lastDragY!! + if (view.translationY != 0f || abs(diff) > 40) { + // Drag has definitely started, stop the parent from interfering + view.parent.requestDisallowInterceptTouchEvent(true) + view.translationY += diff + val scale = (-abs(view.translationY) / 720 + 1).coerceAtLeast(0.5f) + view.scaleY = scale + view.scaleX = scale + lastDragY = event.rawY + } + return + } + + // The user has finished dragging. Allow the parent to handle touch events if + // appropriate, and end the gesture. + if (event.action == MotionEvent.ACTION_UP || event.action == MotionEvent.ACTION_CANCEL) { + view.parent.requestDisallowInterceptTouchEvent(false) + if (lastDragY != null) onGestureEnd(view) + lastDragY = null + return + } + } + } + + /** + * Handle the end of the user's gesture. + * + * If the user was previously dragging, and the image has been dragged a sufficient + * distance then we are done. Otherwise, animate the image back to its starting position. + */ + private fun onGestureEnd(view: View) { + if (abs(view.translationY) > 180) { photoActionsListener.onDismiss() - result = true + } else { + view.animate().translationY(0f).scaleX(1f).scaleY(1f).start() } - result } - } - - var lastY = 0f - - binding.photoView.setOnTouchListener { v, event -> - // This part is for scaling/translating on vertical move. - // We use raw coordinates to get the correct ones during scaling - - if (event.action == MotionEvent.ACTION_DOWN) { - lastY = event.rawY - } else if (event.pointerCount == 1 && - attacher.scale == 1f && - event.action == MotionEvent.ACTION_MOVE - ) { - val diff = event.rawY - lastY - // This code is to prevent transformations during page scrolling - // If we are already translating or we reached the threshold, then transform. - if (binding.photoView.translationY != 0f || abs(diff) > 40) { - binding.photoView.translationY += (diff) - val scale = (-abs(binding.photoView.translationY) / 720 + 1).coerceAtLeast(0.5f) - binding.photoView.scaleY = scale - binding.photoView.scaleX = scale - lastY = event.rawY - return@setOnTouchListener true - } - } else if (event.action == MotionEvent.ACTION_UP || event.action == MotionEvent.ACTION_CANCEL) { - onGestureEnd() - } - attacher.onTouch(v, event) - } + }) finalizeViewSetup(url, attachment?.previewUrl, description) } - private fun onGestureEnd() { - if (_binding == null) { - return - } - if (abs(binding.photoView.translationY) > 180) { - photoActionsListener.onDismiss() - } else { - binding.photoView.animate().translationY(0f).scaleX(1f).scaleY(1f).start() - } - } - - private fun onMediaTap() { - photoActionsListener.onPhotoTap() - } - override fun onToolbarVisibilityChange(visible: Boolean) { - if (_binding == null || !userVisibleHint) { - return - } + if (!userVisibleHint) return + isDescriptionVisible = showingDescription && visible val alpha = if (isDescriptionVisible) 1.0f else 0.0f binding.captionSheet.animate().alpha(alpha) .setListener(object : AnimatorListenerAdapter() { override fun onAnimationEnd(animation: Animator) { - if (_binding != null) { - binding.captionSheet.visible(isDescriptionVisible) - } + view ?: return + binding.captionSheet.visible(isDescriptionVisible) animation.removeListener(this) } }) @@ -194,9 +248,7 @@ class ViewImageFragment : ViewMediaFragment() { } override fun onDestroyView() { - Glide.with(this).clear(binding.photoView) - transition.onComplete() - _binding = null + transition = null super.onDestroyView() } @@ -259,7 +311,7 @@ class ViewImageFragment : ViewMediaFragment() { override fun onLoadFailed( e: GlideException?, - model: Any, + model: Any?, target: Target, isFirstResource: Boolean ): Boolean { @@ -270,7 +322,7 @@ class ViewImageFragment : ViewMediaFragment() { photoActionsListener.onBringUp() } // Hide progress bar only on fail request from internet - if (!isCacheRequest && _binding != null) binding.progressBar.hide() + if (!isCacheRequest) binding.progressBar.hide() // We don't want to overwrite preview with null when main image fails to load return !isCacheRequest } @@ -283,9 +335,7 @@ class ViewImageFragment : ViewMediaFragment() { dataSource: DataSource, isFirstResource: Boolean ): Boolean { - if (_binding != null) { - binding.progressBar.hide() // Always hide the progress bar on success - } + binding.progressBar.hide() // Always hide the progress bar on success if (!startedTransition || !shouldStartTransition) { // Set this right away so that we don't have to concurrent post() requests @@ -297,23 +347,20 @@ class ViewImageFragment : ViewMediaFragment() { if (shouldStartTransition) photoActionsListener.onBringUp() } } else { - // This wait for transition. If there's no transition then we should hit - // another branch. take() will unsubscribe after we have it to not leak memory - transition - .take(1) - .subscribe { + // This waits for transition. If there's no transition then we should hit + // another branch. When the view is destroyed the coroutine is automatically canceled. + transition?.let { + viewLifecycleOwner.lifecycleScope.launch { + it.await() target.onResourceReady(resource, null) - // It's needed. Don't ask why, I don't know, setImageDrawable() should - // do it by itself but somehow it doesn't work automatically. - // Just do it. If you don't, image will jump around when touched. - attacher.update() } + } } return true } } override fun onTransitionEnd() { - this.transition.onNext(Unit) + this.transition?.complete(Unit) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewMediaFragment.kt b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewMediaFragment.kt index 2f8aaf1d9..a3f880cb7 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewMediaFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewMediaFragment.kt @@ -47,7 +47,10 @@ abstract class ViewMediaFragment : Fragment() { protected val ARG_SINGLE_IMAGE_URL = "singleImageUrl" @JvmStatic - fun newInstance(attachment: Attachment, shouldStartPostponedTransition: Boolean): ViewMediaFragment { + fun newInstance( + attachment: Attachment, + shouldStartPostponedTransition: Boolean + ): ViewMediaFragment { val arguments = Bundle(2) arguments.putParcelable(ARG_ATTACHMENT, attachment) arguments.putBoolean(ARG_START_POSTPONED_TRANSITION, shouldStartPostponedTransition) @@ -82,7 +85,12 @@ abstract class ViewMediaFragment : Fragment() { showingDescription = !TextUtils.isEmpty(description) isDescriptionVisible = showingDescription - setupMediaView(url, previewUrl, description, showingDescription && mediaActivity.isToolbarVisible) + setupMediaView( + url, + previewUrl, + description, + showingDescription && mediaActivity.isToolbarVisible + ) toolbarVisibilityDisposable = (activity as ViewMediaActivity) .addToolbarVisibilityListener { isVisible -> diff --git a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewVideoFragment.kt b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewVideoFragment.kt index 68dc6687a..24dd51353 100644 --- a/app/src/main/java/com/keylesspalace/tusky/fragment/ViewVideoFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/fragment/ViewVideoFragment.kt @@ -19,33 +19,57 @@ import android.animation.Animator import android.animation.AnimatorListenerAdapter import android.annotation.SuppressLint import android.content.Context +import android.graphics.drawable.Drawable import android.os.Bundle import android.os.Handler import android.os.Looper import android.text.method.ScrollingMovementMethod import android.view.GestureDetector -import android.view.KeyEvent +import android.view.Gravity import android.view.LayoutInflater import android.view.MotionEvent import android.view.View import android.view.ViewGroup -import android.widget.MediaController +import android.widget.FrameLayout +import android.widget.LinearLayout +import androidx.annotation.OptIn import androidx.core.view.GestureDetectorCompat +import androidx.media3.common.AudioAttributes +import androidx.media3.common.C +import androidx.media3.common.MediaItem +import androidx.media3.common.PlaybackException +import androidx.media3.common.Player +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.util.EventLogger +import androidx.media3.ui.AspectRatioFrameLayout +import com.bumptech.glide.Glide +import com.bumptech.glide.request.target.CustomTarget +import com.bumptech.glide.request.transition.Transition +import com.google.android.material.snackbar.Snackbar +import com.keylesspalace.tusky.BuildConfig +import com.keylesspalace.tusky.R import com.keylesspalace.tusky.ViewMediaActivity import com.keylesspalace.tusky.databinding.FragmentViewVideoBinding +import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.entity.Attachment import com.keylesspalace.tusky.util.hide +import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.util.visible -import com.keylesspalace.tusky.view.ExposedPlayPauseVideoView +import javax.inject.Inject +import javax.inject.Provider import kotlin.math.abs -class ViewVideoFragment : ViewMediaFragment() { +@OptIn(UnstableApi::class) +class ViewVideoFragment : ViewMediaFragment(), Injectable { interface VideoActionsListener { fun onDismiss() } - private var _binding: FragmentViewVideoBinding? = null - private val binding get() = _binding!! + @Inject + lateinit var playerProvider: Provider + + private val binding by viewBinding(FragmentViewVideoBinding::bind) private lateinit var videoActionsListener: VideoActionsListener private lateinit var toolbar: View @@ -54,39 +78,274 @@ class ViewVideoFragment : ViewMediaFragment() { // Hoist toolbar hiding to activity so it can track state across different fragments // This is explicitly stored as runnable so that we pass it to the handler later for cancellation mediaActivity.onPhotoTap() - mediaController.hide() } private lateinit var mediaActivity: ViewMediaActivity - private lateinit var mediaController: MediaController + private lateinit var mediaPlayerListener: Player.Listener private var isAudio = false - companion object { - private const val TOOLBAR_HIDE_DELAY_MS = 3000L - } + private lateinit var mediaAttachment: Attachment + + private var player: ExoPlayer? = null + + /** The saved seek position, if the fragment is being resumed */ + private var savedSeekPosition: Long = 0 + + /** Have we received at least one "READY" event? */ + private var haveStarted = false + + /** Is there a pending autohide? (We can't rely on Android's tracking because that clears on suspend.) */ + private var pendingHideToolbar = false + + /** Prevent the next play start from queueing a toolbar hide. */ + private var suppressNextHideToolbar = false override fun onAttach(context: Context) { super.onAttach(context) + videoActionsListener = context as VideoActionsListener } - override fun onResume() { - super.onResume() + @SuppressLint("PrivateResource", "MissingInflatedId") + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + mediaActivity = activity as ViewMediaActivity + toolbar = mediaActivity.toolbar + val rootView = inflater.inflate(R.layout.fragment_view_video, container, false) - if (_binding != null) { - if (mediaActivity.isToolbarVisible && !isAudio) { - hideToolbarAfterDelay(TOOLBAR_HIDE_DELAY_MS) + // Move the controls to the bottom of the screen, with enough bottom margin to clear the seekbar + val controls = rootView.findViewById( + androidx.media3.ui.R.id.exo_center_controls + ) + val layoutParams = controls.layoutParams as FrameLayout.LayoutParams + layoutParams.gravity = Gravity.CENTER_HORIZONTAL or Gravity.BOTTOM + layoutParams.bottomMargin = rootView.context.resources.getDimension(androidx.media3.ui.R.dimen.exo_styled_bottom_bar_height) + .toInt() + controls.layoutParams = layoutParams + + return rootView + } + + @SuppressLint("ClickableViewAccessibility") + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + val attachment = arguments?.getParcelable(ARG_ATTACHMENT) + ?: throw IllegalArgumentException("attachment has to be set") + + val url = attachment.url + isAudio = attachment.type == Attachment.Type.AUDIO + + /** + * Handle single taps, flings, and dragging + */ + val touchListener = object : View.OnTouchListener { + var lastY = 0f + + /** The view that contains the playing content */ + // binding.videoView is fullscreen, and includes the controls, so don't use that + // when scaling in response to the user dragging on the screen + val contentFrame = binding.videoView.findViewById( + androidx.media3.ui.R.id.exo_content_frame + ) + + /** Handle taps and flings */ + val simpleGestureDetector = GestureDetectorCompat( + requireContext(), + object : GestureDetector.SimpleOnGestureListener() { + override fun onDown(e: MotionEvent) = true + + /** A single tap should show/hide the media description */ + override fun onSingleTapUp(e: MotionEvent): Boolean { + mediaActivity.onPhotoTap() + return true // Do not pass gestures through to media3 + } + + /** A fling up/down should dismiss the fragment */ + override fun onFling( + e1: MotionEvent?, + e2: MotionEvent, + velocityX: Float, + velocityY: Float + ): Boolean { + if (abs(velocityY) > abs(velocityX)) { + videoActionsListener.onDismiss() + return true + } + return true // Do not pass gestures through to media3 + } + } + ) + + @SuppressLint("ClickableViewAccessibility") + override fun onTouch(v: View?, event: MotionEvent): Boolean { + // Track movement, and scale / translate the video display accordingly + if (event.action == MotionEvent.ACTION_DOWN) { + lastY = event.rawY + } else if (event.pointerCount == 1 && event.action == MotionEvent.ACTION_MOVE) { + val diff = event.rawY - lastY + if (contentFrame.translationY != 0f || abs(diff) > 40) { + contentFrame.translationY += diff + val scale = (-abs(contentFrame.translationY) / 720 + 1).coerceAtLeast(0.5f) + contentFrame.scaleY = scale + contentFrame.scaleX = scale + lastY = event.rawY + } + } else if (event.action == MotionEvent.ACTION_UP || event.action == MotionEvent.ACTION_CANCEL) { + if (abs(contentFrame.translationY) > 180) { + videoActionsListener.onDismiss() + } else { + contentFrame.animate().translationY(0f).scaleX(1f).scaleY(1f).start() + } + } + + simpleGestureDetector.onTouchEvent(event) + + // Do not pass gestures through to media3 + // We have to do this because otherwise taps to hide will be double-handled and media3 will re-show itself + // media3 has a property to disable "hide on tap" but "show on tap" is unconditional + return true + } + } + + mediaPlayerListener = object : Player.Listener { + @SuppressLint("ClickableViewAccessibility", "SyntheticAccessor") + @OptIn(UnstableApi::class) + override fun onPlaybackStateChanged(playbackState: Int) { + when (playbackState) { + Player.STATE_READY -> { + if (!haveStarted) { + // Wait until the media is loaded before accepting taps as we don't want toolbar to + // be hidden until then. + binding.videoView.setOnTouchListener(touchListener) + + binding.progressBar.hide() + binding.videoView.useController = true + binding.videoView.showController() + haveStarted = true + } else { + // This isn't a real "done loading"; this is a resume event after backgrounding. + if (mediaActivity.isToolbarVisible) { + // Before suspend, the toolbar/description were visible, so description is visible already. + // But media3 will have automatically hidden the video controls on suspend, so we need to match the description state. + binding.videoView.showController() + if (!pendingHideToolbar) { + suppressNextHideToolbar = true // The user most recently asked us to show the toolbar, so don't hide it when play starts. + } + } else { + mediaActivity.onPhotoTap() + } + } + } + else -> { /* do nothing */ } + } + } + + override fun onIsPlayingChanged(isPlaying: Boolean) { + if (isAudio) return + if (isPlaying) { + if (suppressNextHideToolbar) { + suppressNextHideToolbar = false + } else { + hideToolbarAfterDelay(TOOLBAR_HIDE_DELAY_MS) + } + } else { + handler.removeCallbacks(hideToolbar) + } + } + + @SuppressLint("SyntheticAccessor") + override fun onPlayerError(error: PlaybackException) { + binding.progressBar.hide() + val message = getString( + R.string.error_media_playback, + error.cause?.message ?: error.message + ) + Snackbar.make(binding.root, message, Snackbar.LENGTH_INDEFINITE) + .setTextMaxLines(10) + .setAction(R.string.action_retry) { player?.prepare() } + .show() + } + } + + savedSeekPosition = savedInstanceState?.getLong(SEEK_POSITION) ?: 0 + + mediaAttachment = attachment + + finalizeViewSetup(url, attachment.previewUrl, attachment.description) + } + + override fun onStart() { + super.onStart() + + initializePlayer() + binding.videoView.onResume() + } + + override fun onStop() { + super.onStop() + + // This might be multi-window, so pause everything now. + binding.videoView.onPause() + releasePlayer() + handler.removeCallbacks(hideToolbar) + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + outState.putLong(SEEK_POSITION, savedSeekPosition) + } + + private fun initializePlayer() { + player = playerProvider.get().apply { + setAudioAttributes( + AudioAttributes.Builder() + .setContentType(if (isAudio) C.AUDIO_CONTENT_TYPE_UNKNOWN else C.AUDIO_CONTENT_TYPE_MOVIE) + .setUsage(C.USAGE_MEDIA) + .build(), + true + ) + if (BuildConfig.DEBUG) addAnalyticsListener(EventLogger("$TAG:ExoPlayer")) + setMediaItem(MediaItem.fromUri(mediaAttachment.url)) + addListener(mediaPlayerListener) + repeatMode = Player.REPEAT_MODE_ONE + playWhenReady = true + seekTo(savedSeekPosition) + prepare() + } + + binding.videoView.player = player + + // Audio-only files might have a preview image. If they do, set it as the artwork + if (isAudio) { + mediaAttachment.previewUrl?.let { url -> + Glide.with(this).load(url).into(object : CustomTarget() { + @SuppressLint("SyntheticAccessor") + override fun onResourceReady( + resource: Drawable, + transition: Transition? + ) { + view ?: return + binding.videoView.defaultArtwork = resource + } + + @SuppressLint("SyntheticAccessor") + override fun onLoadCleared(placeholder: Drawable?) { + view ?: return + binding.videoView.defaultArtwork = null + } + }) } - binding.videoView.start() } } - override fun onPause() { - super.onPause() - - if (_binding != null) { - handler.removeCallbacks(hideToolbar) - binding.videoView.pause() - mediaController.hide() + private fun releasePlayer() { + player?.let { + savedSeekPosition = it.currentPosition + it.release() + player = null + binding.videoView.player = null } } @@ -105,153 +364,21 @@ class ViewVideoFragment : ViewMediaFragment() { binding.mediaDescription.elevation = binding.videoView.elevation + 1 binding.videoView.transitionName = url - binding.videoView.setVideoPath(url) - mediaController = object : MediaController(mediaActivity) { - override fun show(timeout: Int) { - // We're doing manual auto-close management. - // Also, take focus back from the pause button so we can use the back button. - super.show(0) - mediaController.requestFocus() - } - override fun dispatchKeyEvent(event: KeyEvent?): Boolean { - if (event?.keyCode == KeyEvent.KEYCODE_BACK) { - if (event.action == KeyEvent.ACTION_UP) { - hide() - activity?.supportFinishAfterTransition() - } - return true - } - return super.dispatchKeyEvent(event) - } - } - - mediaController.setMediaPlayer(binding.videoView) - binding.videoView.setMediaController(mediaController) binding.videoView.requestFocus() - binding.videoView.setPlayPauseListener(object : ExposedPlayPauseVideoView.PlayPauseListener { - override fun onPlay() { - if (!isAudio) { - hideToolbarAfterDelay(TOOLBAR_HIDE_DELAY_MS) - } - } - - override fun onPause() { - if (!isAudio) { - handler.removeCallbacks(hideToolbar) - } - } - }) - binding.videoView.setOnPreparedListener { mp -> - val containerWidth = binding.videoContainer.measuredWidth.toFloat() - val containerHeight = binding.videoContainer.measuredHeight.toFloat() - val videoWidth = mp.videoWidth.toFloat() - val videoHeight = mp.videoHeight.toFloat() - - if (isAudio) { - binding.videoView.layoutParams.height = 1 - binding.videoView.layoutParams.width = ViewGroup.LayoutParams.MATCH_PARENT - } else if (containerWidth / containerHeight > videoWidth / videoHeight) { - binding.videoView.layoutParams.height = ViewGroup.LayoutParams.MATCH_PARENT - binding.videoView.layoutParams.width = ViewGroup.LayoutParams.WRAP_CONTENT - } else { - binding.videoView.layoutParams.height = ViewGroup.LayoutParams.WRAP_CONTENT - binding.videoView.layoutParams.width = ViewGroup.LayoutParams.MATCH_PARENT - } - - // Wait until the media is loaded before accepting taps as we don't want toolbar to - // be hidden until then. - binding.videoView.setOnTouchListener { _, _ -> - mediaActivity.onPhotoTap() - false - } - - // Audio doesn't cause the controller to show automatically - if (isAudio) { - mediaController.show() - } - - binding.progressBar.hide() - mp.isLooping = true - } if (requireArguments().getBoolean(ARG_START_POSTPONED_TRANSITION)) { mediaActivity.onBringUp() } } - private fun hideToolbarAfterDelay(delayMilliseconds: Long) { - handler.postDelayed(hideToolbar, delayMilliseconds) - } - - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - mediaActivity = activity as ViewMediaActivity - toolbar = mediaActivity.toolbar - _binding = FragmentViewVideoBinding.inflate(inflater, container, false) - return binding.root - } - - @SuppressLint("ClickableViewAccessibility") - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - val attachment = arguments?.getParcelable(ARG_ATTACHMENT) - ?: throw IllegalArgumentException("attachment has to be set") - - val url = attachment.url - isAudio = attachment.type == Attachment.Type.AUDIO - - val gestureDetector = GestureDetectorCompat( - requireContext(), - object : GestureDetector.SimpleOnGestureListener() { - override fun onDown(event: MotionEvent): Boolean { - return true - } - - override fun onFling( - e1: MotionEvent, - e2: MotionEvent, - velocityX: Float, - velocityY: Float - ): Boolean { - if (abs(velocityY) > abs(velocityX)) { - videoActionsListener.onDismiss() - return true - } - return false - } - } - ) - - var lastY = 0f - binding.root.setOnTouchListener { _, event -> - if (event.action == MotionEvent.ACTION_DOWN) { - lastY = event.rawY - } else if (event.pointerCount == 1 && event.action == MotionEvent.ACTION_MOVE) { - val diff = event.rawY - lastY - if (binding.videoView.translationY != 0f || abs(diff) > 40) { - binding.videoView.translationY += diff - val scale = (-abs(binding.videoView.translationY) / 720 + 1).coerceAtLeast(0.5f) - binding.videoView.scaleY = scale - binding.videoView.scaleX = scale - lastY = event.rawY - return@setOnTouchListener true - } - } else if (event.action == MotionEvent.ACTION_UP || event.action == MotionEvent.ACTION_CANCEL) { - if (abs(binding.videoView.translationY) > 180) { - videoActionsListener.onDismiss() - } else { - binding.videoView.animate().translationY(0f).scaleX(1f).scaleY(1f).start() - } - } - - gestureDetector.onTouchEvent(event) - } - - finalizeViewSetup(url, attachment.previewUrl, attachment.description) + private fun hideToolbarAfterDelay(delayMilliseconds: Int) { + pendingHideToolbar = true + handler.postDelayed(hideToolbar, delayMilliseconds.toLong()) } override fun onToolbarVisibilityChange(visible: Boolean) { - if (_binding == null || !userVisibleHint) { + if (!userVisibleHint) { return } @@ -265,27 +392,33 @@ class ViewVideoFragment : ViewMediaFragment() { binding.mediaDescription.animate().alpha(alpha) .setListener(object : AnimatorListenerAdapter() { + @SuppressLint("SyntheticAccessor") override fun onAnimationEnd(animation: Animator) { - if (_binding != null) { - binding.mediaDescription.visible(isDescriptionVisible) - } + view ?: return + binding.mediaDescription.visible(isDescriptionVisible) animation.removeListener(this) } }) .start() - if (visible && binding.videoView.isPlaying && !isAudio) { - hideToolbarAfterDelay(TOOLBAR_HIDE_DELAY_MS) + // media3 controls bar + if (visible) { + binding.videoView.showController() } else { - handler.removeCallbacks(hideToolbar) + binding.videoView.hideController() } + + // Either the user just requested toolbar display, or we just hid it. + // Either way, any pending hides are no longer appropriate. + pendingHideToolbar = false + handler.removeCallbacks(hideToolbar) } - override fun onTransitionEnd() { - } + override fun onTransitionEnd() { } - override fun onDestroyView() { - super.onDestroyView() - _binding = null + companion object { + private const val TAG = "ViewVideoFragment" + private const val TOOLBAR_HIDE_DELAY_MS = 4_000 + private const val SEEK_POSITION = "seekPosition" } } diff --git a/app/src/main/java/com/keylesspalace/tusky/interfaces/AccountActionListener.kt b/app/src/main/java/com/keylesspalace/tusky/interfaces/AccountActionListener.kt index c353d0f3e..654b50dd4 100644 --- a/app/src/main/java/com/keylesspalace/tusky/interfaces/AccountActionListener.kt +++ b/app/src/main/java/com/keylesspalace/tusky/interfaces/AccountActionListener.kt @@ -12,12 +12,11 @@ * * You should have received a copy of the GNU General Public License along with Tusky; if not, * see . */ +package com.keylesspalace.tusky.interfaces -package com.keylesspalace.tusky.interfaces; - -public interface AccountActionListener { - void onViewAccount(String id); - void onMute(final boolean mute, final String id, final int position, final boolean notifications); - void onBlock(final boolean block, final String id, final int position); - void onRespondToFollowRequest(final boolean accept, final String id, final int position); +interface AccountActionListener { + fun onViewAccount(id: String) + fun onMute(mute: Boolean, id: String, position: Int, notifications: Boolean) + fun onBlock(block: Boolean, id: String, position: Int) + fun onRespondToFollowRequest(accept: Boolean, id: String, position: Int) } diff --git a/app/src/main/java/com/keylesspalace/tusky/interfaces/PermissionRequester.kt b/app/src/main/java/com/keylesspalace/tusky/interfaces/PermissionRequester.kt index ca83e085b..d31bd1feb 100644 --- a/app/src/main/java/com/keylesspalace/tusky/interfaces/PermissionRequester.kt +++ b/app/src/main/java/com/keylesspalace/tusky/interfaces/PermissionRequester.kt @@ -1,5 +1,5 @@ -package com.keylesspalace.tusky.interfaces; +package com.keylesspalace.tusky.interfaces -public interface PermissionRequester { - void onRequestPermissionsResult(String[] permissions, int[] grantResults); -} \ No newline at end of file +fun interface PermissionRequester { + fun onRequestPermissionsResult(permissions: Array, grantResults: IntArray) +} diff --git a/app/src/main/java/com/keylesspalace/tusky/interfaces/StatusActionListener.java b/app/src/main/java/com/keylesspalace/tusky/interfaces/StatusActionListener.java index e142683a1..75f6ed749 100644 --- a/app/src/main/java/com/keylesspalace/tusky/interfaces/StatusActionListener.java +++ b/app/src/main/java/com/keylesspalace/tusky/interfaces/StatusActionListener.java @@ -64,7 +64,8 @@ public interface StatusActionListener extends LinkListener { void onVoteInPoll(int position, @NonNull List choices); default void onShowEdits(int position) {} - + void clearWarningAction(int position); + void onUntranslate(int position); } diff --git a/app/src/main/java/com/keylesspalace/tusky/json/GuardedBooleanAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/json/Guarded.kt similarity index 52% rename from app/src/main/java/com/keylesspalace/tusky/json/GuardedBooleanAdapter.kt rename to app/src/main/java/com/keylesspalace/tusky/json/Guarded.kt index 8ed702a81..3d37b3867 100644 --- a/app/src/main/java/com/keylesspalace/tusky/json/GuardedBooleanAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/json/Guarded.kt @@ -1,4 +1,5 @@ -/* Copyright 2022 Tusky Contributors +/* + * Copyright 2024 Tusky Contributors * * This file is a part of Tusky. * @@ -11,23 +12,13 @@ * Public License for more details. * * You should have received a copy of the GNU General Public License along with Tusky; if not, - * see . */ + * see . + */ package com.keylesspalace.tusky.json -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 +import com.squareup.moshi.JsonQualifier -class GuardedBooleanAdapter : JsonDeserializer { - @Throws(JsonParseException::class) - override fun deserialize(json: JsonElement, typeOfT: Type, context: JsonDeserializationContext): Boolean? { - return if (json.isJsonObject) { - null - } else { - json.asBoolean - } - } -} +@Retention(AnnotationRetention.RUNTIME) +@JsonQualifier +internal annotation class Guarded diff --git a/app/src/main/java/com/keylesspalace/tusky/json/GuardedAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/json/GuardedAdapter.kt new file mode 100644 index 000000000..11cb1f3af --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/json/GuardedAdapter.kt @@ -0,0 +1,63 @@ +/* + * Copyright 2024 Tusky Contributors + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . + */ + +package com.keylesspalace.tusky.json + +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.JsonDataException +import com.squareup.moshi.JsonReader +import com.squareup.moshi.JsonWriter +import com.squareup.moshi.Moshi +import com.squareup.moshi.Types +import java.lang.reflect.Type + +/** + * This adapter tries to parse the value using a delegated parser + * and returns null in case of error. + */ +class GuardedAdapter private constructor( + private val delegate: JsonAdapter +) : JsonAdapter() { + + override fun fromJson(reader: JsonReader): T? { + return try { + delegate.fromJson(reader) + } catch (e: JsonDataException) { + reader.skipValue() + null + } + } + + override fun toJson(writer: JsonWriter, value: T?) { + delegate.toJson(writer, value) + } + + companion object { + val ANNOTATION_FACTORY = object : Factory { + override fun create( + type: Type, + annotations: Set, + moshi: Moshi + ): JsonAdapter<*>? { + val delegateAnnotations = + Types.nextAnnotations(annotations, Guarded::class.java) ?: return null + val delegate = moshi.nextAdapter(this, type, delegateAnnotations) + return GuardedAdapter(delegate) + } + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/json/Iso8601Utils.kt b/app/src/main/java/com/keylesspalace/tusky/json/Iso8601Utils.kt deleted file mode 100644 index 983333a9c..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/json/Iso8601Utils.kt +++ /dev/null @@ -1,267 +0,0 @@ -package com.keylesspalace.tusky.json - -/* - * Copyright (C) 2011 FasterXML, LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT 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 com.google.gson.JsonParseException -import java.util.Calendar -import java.util.Date -import java.util.GregorianCalendar -import java.util.Locale -import java.util.TimeZone -import kotlin.math.min -import kotlin.math.pow - -/* - * Jackson’s date formatter, pruned to Moshi's needs. Forked from this file: - * https://github.com/FasterXML/jackson-databind/blob/67ebf7305f492285a8f9f4de31545f5f16fc7c3a/src/main/java/com/fasterxml/jackson/databind/util/ISO8601Utils.java - * - * Utilities methods for manipulating dates in iso8601 format. This is much much faster and GC - * friendly than using SimpleDateFormat so highly suitable if you (un)serialize lots of date - * objects. - * - * Supported parse format: - * `[yyyy-MM-dd|yyyyMMdd][T(hh:mm[:ss[.sss]]|hhmm[ss[.sss]])]?[Z|[+-]hh[:]mm]]` - * - * @see [this specification](http://www.w3.org/TR/NOTE-datetime) - */ - -/** ID to represent the 'GMT' string */ -private const val GMT_ID = "GMT" - -/** The GMT timezone, prefetched to avoid more lookups. */ -private val TIMEZONE_Z: TimeZone = TimeZone.getTimeZone(GMT_ID) - -/** Returns `date` formatted as yyyy-MM-ddThh:mm:ss.sssZ */ -internal fun Date.formatIsoDate(): String { - val calendar: Calendar = GregorianCalendar(TIMEZONE_Z, Locale.US) - calendar.time = this - - // estimate capacity of buffer as close as we can (yeah, that's pedantic ;) - val capacity = "yyyy-MM-ddThh:mm:ss.sssZ".length - val formatted = StringBuilder(capacity) - padInt(formatted, calendar[Calendar.YEAR], "yyyy".length) - formatted.append('-') - padInt(formatted, calendar[Calendar.MONTH] + 1, "MM".length) - formatted.append('-') - padInt(formatted, calendar[Calendar.DAY_OF_MONTH], "dd".length) - formatted.append('T') - padInt(formatted, calendar[Calendar.HOUR_OF_DAY], "hh".length) - formatted.append(':') - padInt(formatted, calendar[Calendar.MINUTE], "mm".length) - formatted.append(':') - padInt(formatted, calendar[Calendar.SECOND], "ss".length) - formatted.append('.') - padInt(formatted, calendar[Calendar.MILLISECOND], "sss".length) - formatted.append('Z') - return formatted.toString() -} - -/** - * Parse a date from ISO-8601 formatted string. It expects a format - * `[yyyy-MM-dd|yyyyMMdd][T(hh:mm[:ss[.sss]]|hhmm[ss[.sss]])]?[Z|[+-]hh:mm]]` - * - * @receiver ISO string to parse in the appropriate format. - * @return the parsed date - */ -internal fun String.parseIsoDate(): Date { - return try { - var offset = 0 - - // extract year - val year = parseInt(this, offset, 4.let { offset += it; offset }) - if (checkOffset(this, offset, '-')) { - offset += 1 - } - - // extract month - val month = parseInt(this, offset, 2.let { offset += it; offset }) - if (checkOffset(this, offset, '-')) { - offset += 1 - } - - // extract day - val day = parseInt(this, offset, 2.let { offset += it; offset }) - // default time value - var hour = 0 - var minutes = 0 - var seconds = 0 - // always use 0 otherwise returned date will include millis of current time - var milliseconds = 0 - - // if the value has no time component (and no time zone), we are done - val hasT = checkOffset(this, offset, 'T') - if (!hasT && this.length <= offset) { - return GregorianCalendar(year, month - 1, day).time - } - if (hasT) { - // extract hours, minutes, seconds and milliseconds - hour = parseInt(this, 1.let { offset += it; offset }, 2.let { offset += it; offset }) - if (checkOffset(this, offset, ':')) { - offset += 1 - } - minutes = parseInt(this, offset, 2.let { offset += it; offset }) - if (checkOffset(this, offset, ':')) { - offset += 1 - } - // second and milliseconds can be optional - if (this.length > offset) { - val c = this[offset] - if (c != 'Z' && c != '+' && c != '-') { - seconds = parseInt(this, offset, 2.let { offset += it; offset }) - if (seconds in 60..62) seconds = 59 // truncate up to 3 leap seconds - // milliseconds can be optional in the format - if (checkOffset(this, offset, '.')) { - offset += 1 - val endOffset = indexOfNonDigit(this, offset + 1) // assume at least one digit - val parseEndOffset = min(endOffset, offset + 3) // parse up to 3 digits - val fraction = parseInt(this, offset, parseEndOffset) - milliseconds = - (10.0.pow((3 - (parseEndOffset - offset)).toDouble()) * fraction).toInt() - offset = endOffset - } - } - } - } - - // extract timezone - require(this.length > offset) { "No time zone indicator" } - val timezone: TimeZone - val timezoneIndicator = this[offset] - if (timezoneIndicator == 'Z') { - timezone = TIMEZONE_Z - } else if (timezoneIndicator == '+' || timezoneIndicator == '-') { - val timezoneOffset = this.substring(offset) - // 18-Jun-2015, tatu: Minor simplification, skip offset of "+0000"/"+00:00" - if ("+0000" == timezoneOffset || "+00:00" == timezoneOffset) { - timezone = TIMEZONE_Z - } else { - // 18-Jun-2015, tatu: Looks like offsets only work from GMT, not UTC... - // not sure why, but it is what it is. - val timezoneId = GMT_ID + timezoneOffset - timezone = TimeZone.getTimeZone(timezoneId) - val act = timezone.id - if (act != timezoneId) { - /* - * 22-Jan-2015, tatu: Looks like canonical version has colons, but we may be given - * one without. If so, don't sweat. - * Yes, very inefficient. Hopefully not hit often. - * If it becomes a perf problem, add 'loose' comparison instead. - */ - val cleaned = act.replace(":", "") - if (cleaned != timezoneId) { - throw IndexOutOfBoundsException( - "Mismatching time zone indicator: $timezoneId given, resolves to ${timezone.id}" - ) - } - } - } - } else { - throw IndexOutOfBoundsException( - "Invalid time zone indicator '$timezoneIndicator'" - ) - } - val calendar: Calendar = GregorianCalendar(timezone) - calendar.isLenient = false - calendar[Calendar.YEAR] = year - calendar[Calendar.MONTH] = month - 1 - calendar[Calendar.DAY_OF_MONTH] = day - calendar[Calendar.HOUR_OF_DAY] = hour - calendar[Calendar.MINUTE] = minutes - calendar[Calendar.SECOND] = seconds - calendar[Calendar.MILLISECOND] = milliseconds - calendar.time - // If we get a ParseException it'll already have the right message/offset. - // Other exception types can convert here. - } catch (e: IndexOutOfBoundsException) { - throw JsonParseException("Not an RFC 3339 date: $this", e) - } catch (e: IllegalArgumentException) { - throw JsonParseException("Not an RFC 3339 date: $this", e) - } -} - -/** - * Check if the expected character exist at the given offset in the value. - * - * @param value the string to check at the specified offset - * @param offset the offset to look for the expected character - * @param expected the expected character - * @return true if the expected character exist at the given offset - */ -private fun checkOffset(value: String, offset: Int, expected: Char): Boolean { - return offset < value.length && value[offset] == expected -} - -/** - * Parse an integer located between 2 given offsets in a string - * - * @param value the string to parse - * @param beginIndex the start index for the integer in the string - * @param endIndex the end index for the integer in the string - * @return the int - * @throws NumberFormatException if the value is not a number - */ -private fun parseInt(value: String, beginIndex: Int, endIndex: Int): Int { - if (beginIndex < 0 || endIndex > value.length || beginIndex > endIndex) { - throw NumberFormatException(value) - } - // use same logic as in Integer.parseInt() but less generic we're not supporting negative values - var i = beginIndex - var result = 0 - var digit: Int - if (i < endIndex) { - digit = Character.digit(value[i++], 10) - if (digit < 0) { - throw NumberFormatException("Invalid number: " + value.substring(beginIndex, endIndex)) - } - result = -digit - } - while (i < endIndex) { - digit = Character.digit(value[i++], 10) - if (digit < 0) { - throw NumberFormatException("Invalid number: " + value.substring(beginIndex, endIndex)) - } - result *= 10 - result -= digit - } - return -result -} - -/** - * Zero pad a number to a specified length - * - * @param buffer buffer to use for padding - * @param value the integer value to pad if necessary. - * @param length the length of the string we should zero pad - */ -private fun padInt(buffer: StringBuilder, value: Int, length: Int) { - val strValue = value.toString() - for (i in length - strValue.length downTo 1) { - buffer.append('0') - } - buffer.append(strValue) -} - -/** - * Returns the index of the first character in the string that is not a digit, starting at offset. - */ -private fun indexOfNonDigit(string: String, offset: Int): Int { - for (i in offset until string.length) { - val c = string[i] - if (c < '0' || c > '9') return i - } - return string.length -} diff --git a/app/src/main/java/com/keylesspalace/tusky/json/Rfc3339DateJsonAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/json/Rfc3339DateJsonAdapter.kt deleted file mode 100644 index c1241abcc..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/json/Rfc3339DateJsonAdapter.kt +++ /dev/null @@ -1,56 +0,0 @@ -// https://github.com/google/gson/blob/master/extras/src/main/java/com/google/gson/typeadapters/UtcDateTypeAdapter.java -/* - * Copyright (C) 2011 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 com.keylesspalace.tusky.json - -import android.util.Log -import com.google.gson.JsonParseException -import com.google.gson.TypeAdapter -import com.google.gson.stream.JsonReader -import com.google.gson.stream.JsonToken -import com.google.gson.stream.JsonWriter -import java.io.IOException -import java.util.Date - -class Rfc3339DateJsonAdapter : TypeAdapter() { - - @Throws(IOException::class) - override fun write(writer: JsonWriter, date: Date?) { - if (date == null) { - writer.nullValue() - } else { - writer.value(date.formatIsoDate()) - } - } - - @Throws(IOException::class) - override fun read(reader: JsonReader): Date? { - return when (reader.peek()) { - JsonToken.NULL -> { - reader.nextNull() - null - } - else -> { - try { - reader.nextString().parseIsoDate() - } catch (jpe: JsonParseException) { - Log.w("Rfc3339DateJsonAdapter", jpe) - null - } - } - } - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/network/FilterModel.kt b/app/src/main/java/com/keylesspalace/tusky/network/FilterModel.kt index 26a7141ec..3763fa548 100644 --- a/app/src/main/java/com/keylesspalace/tusky/network/FilterModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/network/FilterModel.kt @@ -47,11 +47,11 @@ class FilterModel @Inject constructor() { } } - val matchingKind = status.filtered?.filter { result -> + val matchingKind = status.filtered.orEmpty().filter { result -> result.filter.kinds.contains(kind) } - return if (matchingKind.isNullOrEmpty()) { + return if (matchingKind.isEmpty()) { Filter.Action.NONE } else { matchingKind.maxOf { it.filter.action } diff --git a/app/src/main/java/com/keylesspalace/tusky/network/InstanceSwitchAuthInterceptor.kt b/app/src/main/java/com/keylesspalace/tusky/network/InstanceSwitchAuthInterceptor.kt index 6033165f9..6aabaa13d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/network/InstanceSwitchAuthInterceptor.kt +++ b/app/src/main/java/com/keylesspalace/tusky/network/InstanceSwitchAuthInterceptor.kt @@ -17,6 +17,7 @@ package com.keylesspalace.tusky.network import android.util.Log import com.keylesspalace.tusky.db.AccountManager +import java.io.IOException import okhttp3.HttpUrl import okhttp3.Interceptor import okhttp3.MediaType.Companion.toMediaType @@ -24,7 +25,6 @@ import okhttp3.Protocol import okhttp3.Request import okhttp3.Response import okhttp3.ResponseBody.Companion.toResponseBody -import java.io.IOException class InstanceSwitchAuthInterceptor(private val accountManager: AccountManager) : Interceptor { @@ -57,7 +57,10 @@ class InstanceSwitchAuthInterceptor(private val accountManager: AccountManager) val newRequest: Request = builder.build() if (MastodonApi.PLACEHOLDER_DOMAIN == newRequest.url.host) { - Log.w("ISAInterceptor", "no user logged in or no domain header specified - can't make request to " + newRequest.url) + Log.w( + "ISAInterceptor", + "no user logged in or no domain header specified - can't make request to " + newRequest.url + ) return Response.Builder() .code(400) .message("Bad Request") diff --git a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt index a50ca7964..5498f5382 100644 --- a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt +++ b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt @@ -29,6 +29,7 @@ import com.keylesspalace.tusky.entity.FilterKeyword import com.keylesspalace.tusky.entity.FilterV1 import com.keylesspalace.tusky.entity.HashTag import com.keylesspalace.tusky.entity.Instance +import com.keylesspalace.tusky.entity.InstanceV1 import com.keylesspalace.tusky.entity.Marker import com.keylesspalace.tusky.entity.MastoList import com.keylesspalace.tusky.entity.MediaUploadResult @@ -44,11 +45,10 @@ import com.keylesspalace.tusky.entity.StatusContext import com.keylesspalace.tusky.entity.StatusEdit import com.keylesspalace.tusky.entity.StatusSource import com.keylesspalace.tusky.entity.TimelineAccount +import com.keylesspalace.tusky.entity.Translation import com.keylesspalace.tusky.entity.TrendingTag -import io.reactivex.rxjava3.core.Single import okhttp3.MultipartBody import okhttp3.RequestBody -import okhttp3.ResponseBody import retrofit2.Call import retrofit2.Response import retrofit2.http.Body @@ -84,11 +84,21 @@ interface MastodonApi { suspend fun getCustomEmojis(): NetworkResult> @GET("api/v1/instance") - suspend fun getInstance(@Header(DOMAIN_HEADER) domain: String? = null): NetworkResult + suspend fun getInstanceV1( + @Header(DOMAIN_HEADER) domain: String? = null + ): NetworkResult + + @GET("api/v2/instance") + suspend fun getInstance( + @Header(DOMAIN_HEADER) domain: String? = null + ): NetworkResult @GET("api/v1/filters") suspend fun getFiltersV1(): NetworkResult> + @GET("api/v2/filters/{filterId}") + suspend fun getFilter(@Path("filterId") filterId: String): NetworkResult + @GET("api/v2/filters") suspend fun getFilters(): NetworkResult> @@ -130,20 +140,18 @@ interface MastodonApi { @GET("api/v1/notifications") suspend fun notifications( /** Return results older than this ID */ - @Query("max_id") maxId: String? = null, - /** Return results immediately newer than this ID */ - @Query("min_id") minId: String? = null, + @Query("max_id") maxId: String?, + /** Return results newer than this ID */ + @Query("since_id") sinceId: String?, /** Maximum number of results to return. Defaults to 15, max is 30 */ - @Query("limit") limit: Int? = null, + @Query("limit") limit: Int?, /** Types to excludes from the results */ - @Query("exclude_types[]") excludes: Set? = null + @Query("exclude_types[]") excludes: Set? ): Response> /** Fetch a single notification */ @GET("api/v1/notifications/{id}") - suspend fun notification( - @Path("id") id: String - ): Response + suspend fun notification(@Path("id") id: String): Response @GET("api/v1/markers") suspend fun markersWithAuth( @@ -170,7 +178,7 @@ interface MastodonApi { ): Response> @POST("api/v1/notifications/clear") - suspend fun clearNotifications(): Response + suspend fun clearNotifications(): NetworkResult @FormUrlEncoded @PUT("api/v1/media/{mediaId}") @@ -181,9 +189,7 @@ interface MastodonApi { ): NetworkResult @GET("api/v1/media/{mediaId}") - suspend fun getMedia( - @Path("mediaId") mediaId: String - ): Response + suspend fun getMedia(@Path("mediaId") mediaId: String): Response @POST("api/v1/statuses") suspend fun createStatus( @@ -193,10 +199,16 @@ interface MastodonApi { @Body status: NewStatus ): NetworkResult + @POST("api/v1/statuses") + suspend fun createScheduledStatus( + @Header("Authorization") auth: String, + @Header(DOMAIN_HEADER) domain: String, + @Header("Idempotency-Key") idempotencyKey: String, + @Body status: NewStatus + ): NetworkResult + @GET("api/v1/statuses/{id}") - suspend fun status( - @Path("id") statusId: String - ): NetworkResult + suspend fun status(@Path("id") statusId: String): NetworkResult @PUT("api/v1/statuses/{id}") suspend fun editStatus( @@ -207,25 +219,14 @@ interface MastodonApi { @Body editedStatus: NewStatus ): NetworkResult - @GET("api/v1/statuses/{id}") - suspend fun statusAsync( - @Path("id") statusId: String - ): NetworkResult - @GET("api/v1/statuses/{id}/source") - suspend fun statusSource( - @Path("id") statusId: String - ): NetworkResult + suspend fun statusSource(@Path("id") statusId: String): NetworkResult @GET("api/v1/statuses/{id}/context") - suspend fun statusContext( - @Path("id") statusId: String - ): NetworkResult + suspend fun statusContext(@Path("id") statusId: String): NetworkResult @GET("api/v1/statuses/{id}/history") - suspend fun statusEdits( - @Path("id") statusId: String - ): NetworkResult> + suspend fun statusEdits(@Path("id") statusId: String): NetworkResult> @GET("api/v1/statuses/{id}/reblogged_by") suspend fun statusRebloggedBy( @@ -240,70 +241,48 @@ interface MastodonApi { ): Response> @DELETE("api/v1/statuses/{id}") - suspend fun deleteStatus( - @Path("id") statusId: String - ): NetworkResult + suspend fun deleteStatus(@Path("id") statusId: String): NetworkResult @POST("api/v1/statuses/{id}/reblog") - suspend fun reblogStatus( - @Path("id") statusId: String - ): NetworkResult + suspend fun reblogStatus(@Path("id") statusId: String): NetworkResult @POST("api/v1/statuses/{id}/unreblog") - suspend fun unreblogStatus( - @Path("id") statusId: String - ): NetworkResult + suspend fun unreblogStatus(@Path("id") statusId: String): NetworkResult @POST("api/v1/statuses/{id}/favourite") - suspend fun favouriteStatus( - @Path("id") statusId: String - ): NetworkResult + suspend fun favouriteStatus(@Path("id") statusId: String): NetworkResult @POST("api/v1/statuses/{id}/unfavourite") - suspend fun unfavouriteStatus( - @Path("id") statusId: String - ): NetworkResult + suspend fun unfavouriteStatus(@Path("id") statusId: String): NetworkResult @POST("api/v1/statuses/{id}/bookmark") - suspend fun bookmarkStatus( - @Path("id") statusId: String - ): NetworkResult + suspend fun bookmarkStatus(@Path("id") statusId: String): NetworkResult @POST("api/v1/statuses/{id}/unbookmark") - suspend fun unbookmarkStatus( - @Path("id") statusId: String - ): NetworkResult + suspend fun unbookmarkStatus(@Path("id") statusId: String): NetworkResult @POST("api/v1/statuses/{id}/pin") - suspend fun pinStatus( - @Path("id") statusId: String - ): NetworkResult + suspend fun pinStatus(@Path("id") statusId: String): NetworkResult @POST("api/v1/statuses/{id}/unpin") - suspend fun unpinStatus( - @Path("id") statusId: String - ): NetworkResult + suspend fun unpinStatus(@Path("id") statusId: String): NetworkResult @POST("api/v1/statuses/{id}/mute") - suspend fun muteConversation( - @Path("id") statusId: String - ): NetworkResult + suspend fun muteConversation(@Path("id") statusId: String): NetworkResult @POST("api/v1/statuses/{id}/unmute") - suspend fun unmuteConversation( - @Path("id") statusId: String - ): NetworkResult + suspend fun unmuteConversation(@Path("id") statusId: String): NetworkResult @GET("api/v1/scheduled_statuses") - fun scheduledStatuses( + suspend fun scheduledStatuses( @Query("limit") limit: Int? = null, @Query("max_id") maxId: String? = null - ): Single> + ): NetworkResult> @DELETE("api/v1/scheduled_statuses/{id}") suspend fun deleteScheduledStatus( @Path("id") scheduledStatusId: String - ): NetworkResult + ): NetworkResult @GET("api/v1/accounts/verify_credentials") suspend fun accountVerifyCredentials( @@ -345,18 +324,8 @@ interface MastodonApi { @Query("following") following: Boolean? = null ): NetworkResult> - @GET("api/v1/accounts/search") - fun searchAccountsSync( - @Query("q") query: String, - @Query("resolve") resolve: Boolean? = null, - @Query("limit") limit: Int? = null, - @Query("following") following: Boolean? = null - ): NetworkResult> - @GET("api/v1/accounts/{id}") - suspend fun account( - @Path("id") accountId: String - ): NetworkResult + suspend fun account(@Path("id") accountId: String): NetworkResult /** * Method to fetch statuses for the specified account. @@ -399,19 +368,13 @@ interface MastodonApi { ): NetworkResult @POST("api/v1/accounts/{id}/unfollow") - suspend fun unfollowAccount( - @Path("id") accountId: String - ): NetworkResult + suspend fun unfollowAccount(@Path("id") accountId: String): NetworkResult @POST("api/v1/accounts/{id}/block") - suspend fun blockAccount( - @Path("id") accountId: String - ): NetworkResult + suspend fun blockAccount(@Path("id") accountId: String): NetworkResult @POST("api/v1/accounts/{id}/unblock") - suspend fun unblockAccount( - @Path("id") accountId: String - ): NetworkResult + suspend fun unblockAccount(@Path("id") accountId: String): NetworkResult @FormUrlEncoded @POST("api/v1/accounts/{id}/mute") @@ -422,9 +385,7 @@ interface MastodonApi { ): NetworkResult @POST("api/v1/accounts/{id}/unmute") - suspend fun unmuteAccount( - @Path("id") accountId: String - ): NetworkResult + suspend fun unmuteAccount(@Path("id") accountId: String): NetworkResult @GET("api/v1/accounts/relationships") suspend fun relationships( @@ -432,37 +393,27 @@ interface MastodonApi { ): NetworkResult> @POST("api/v1/pleroma/accounts/{id}/subscribe") - suspend fun subscribeAccount( - @Path("id") accountId: String - ): NetworkResult + suspend fun subscribeAccount(@Path("id") accountId: String): NetworkResult @POST("api/v1/pleroma/accounts/{id}/unsubscribe") - suspend fun unsubscribeAccount( - @Path("id") accountId: String - ): NetworkResult + suspend fun unsubscribeAccount(@Path("id") accountId: String): NetworkResult @GET("api/v1/blocks") - suspend fun blocks( - @Query("max_id") maxId: String? - ): Response> + suspend fun blocks(@Query("max_id") maxId: String?): Response> @GET("api/v1/mutes") - suspend fun mutes( - @Query("max_id") maxId: String? - ): Response> + suspend fun mutes(@Query("max_id") maxId: String?): Response> @GET("api/v1/domain_blocks") - fun domainBlocks( + suspend fun domainBlocks( @Query("max_id") maxId: String? = null, @Query("since_id") sinceId: String? = null, @Query("limit") limit: Int? = null - ): Single>> + ): Response> @FormUrlEncoded @POST("api/v1/domain_blocks") - suspend fun blockDomain( - @Field("domain") domain: String - ): NetworkResult + suspend fun blockDomain(@Field("domain") domain: String): NetworkResult @FormUrlEncoded // @DELETE doesn't support fields @@ -484,19 +435,13 @@ interface MastodonApi { ): Response> @GET("api/v1/follow_requests") - suspend fun followRequests( - @Query("max_id") maxId: String? - ): Response> + suspend fun followRequests(@Query("max_id") maxId: String?): Response> @POST("api/v1/follow_requests/{id}/authorize") - fun authorizeFollowRequest( - @Path("id") accountId: String - ): Single + suspend fun authorizeFollowRequest(@Path("id") accountId: String): NetworkResult @POST("api/v1/follow_requests/{id}/reject") - fun rejectFollowRequest( - @Path("id") accountId: String - ): Single + suspend fun rejectFollowRequest(@Path("id") accountId: String): NetworkResult @FormUrlEncoded @POST("api/v1/apps") @@ -538,20 +483,22 @@ interface MastodonApi { @FormUrlEncoded @POST("api/v1/lists") suspend fun createList( - @Field("title") title: String + @Field("title") title: String, + @Field("exclusive") exclusive: Boolean?, + @Field("replies_policy") replyPolicy: String ): NetworkResult @FormUrlEncoded @PUT("api/v1/lists/{listId}") suspend fun updateList( @Path("listId") listId: String, - @Field("title") title: String + @Field("title") title: String, + @Field("exclusive") exclusive: Boolean?, + @Field("replies_policy") replyPolicy: String ): NetworkResult @DELETE("api/v1/lists/{listId}") - suspend fun deleteList( - @Path("listId") listId: String - ): NetworkResult + suspend fun deleteList(@Path("listId") listId: String): NetworkResult @GET("api/v1/lists/{listId}/accounts") suspend fun getAccountsInList( @@ -581,9 +528,7 @@ interface MastodonApi { ): Response> @DELETE("/api/v1/conversations/{id}") - suspend fun deleteConversation( - @Path("id") conversationId: String - ) + suspend fun deleteConversation(@Path("id") conversationId: String) @FormUrlEncoded @POST("api/v1/filters") @@ -607,9 +552,7 @@ interface MastodonApi { ): NetworkResult @DELETE("api/v1/filters/{id}") - suspend fun deleteFilterV1( - @Path("id") id: String - ): NetworkResult + suspend fun deleteFilterV1(@Path("id") id: String): NetworkResult @FormUrlEncoded @POST("api/v2/filters") @@ -631,9 +574,7 @@ interface MastodonApi { ): NetworkResult @DELETE("api/v2/filters/{id}") - suspend fun deleteFilter( - @Path("id") id: String - ): NetworkResult + suspend fun deleteFilter(@Path("id") id: String): NetworkResult @FormUrlEncoded @POST("api/v2/filters/{filterId}/keywords") @@ -654,7 +595,7 @@ interface MastodonApi { @DELETE("api/v2/filters/keywords/{keywordId}") suspend fun deleteFilterKeyword( @Path("keywordId") keywordId: String - ): NetworkResult + ): NetworkResult @FormUrlEncoded @POST("api/v1/polls/{id}/votes") @@ -669,21 +610,19 @@ interface MastodonApi { ): NetworkResult> @POST("api/v1/announcements/{id}/dismiss") - suspend fun dismissAnnouncement( - @Path("id") announcementId: String - ): NetworkResult + suspend fun dismissAnnouncement(@Path("id") announcementId: String): NetworkResult @PUT("api/v1/announcements/{id}/reactions/{name}") suspend fun addAnnouncementReaction( @Path("id") announcementId: String, @Path("name") name: String - ): NetworkResult + ): NetworkResult @DELETE("api/v1/announcements/{id}/reactions/{name}") suspend fun removeAnnouncementReaction( @Path("id") announcementId: String, @Path("name") name: String - ): NetworkResult + ): NetworkResult @FormUrlEncoded @POST("api/v1/reports") @@ -695,32 +634,17 @@ interface MastodonApi { ): NetworkResult @GET("api/v1/accounts/{id}/statuses") - fun accountStatusesObservable( + suspend fun accountStatuses( @Path("id") accountId: String, @Query("max_id") maxId: String?, @Query("since_id") sinceId: String?, @Query("min_id") minId: String?, @Query("limit") limit: Int?, @Query("exclude_reblogs") excludeReblogs: Boolean? - ): Single> - - @GET("api/v1/statuses/{id}") - fun statusObservable( - @Path("id") statusId: String - ): Single + ): NetworkResult> @GET("api/v2/search") - fun searchObservable( - @Query("q") query: String?, - @Query("type") type: String? = null, - @Query("resolve") resolve: Boolean? = null, - @Query("limit") limit: Int? = null, - @Query("offset") offset: Int? = null, - @Query("following") following: Boolean? = null - ): Single - - @GET("api/v2/search") - fun searchSync( + suspend fun search( @Query("q") query: String?, @Query("type") type: String? = null, @Query("resolve") resolve: Boolean? = null, @@ -762,7 +686,7 @@ interface MastodonApi { suspend fun unsubscribePushNotifications( @Header("Authorization") auth: String, @Header(DOMAIN_HEADER) domain: String - ): NetworkResult + ): NetworkResult @GET("api/v1/tags/{name}") suspend fun tag(@Path("name") name: String): NetworkResult @@ -783,4 +707,17 @@ interface MastodonApi { @GET("api/v1/trends/tags") suspend fun trendingTags(): NetworkResult> + + @GET("api/v1/trends/statuses") + suspend fun trendingStatuses( + @Query("limit") limit: Int? = null, + @Query("offset") offset: String? = null + ): Response> + + @FormUrlEncoded + @POST("api/v1/statuses/{id}/translate") + suspend fun translate( + @Path("id") statusId: String, + @Field("lang") targetLanguage: String? + ): NetworkResult } diff --git a/app/src/main/java/com/keylesspalace/tusky/network/ProgressRequestBody.java b/app/src/main/java/com/keylesspalace/tusky/network/ProgressRequestBody.java deleted file mode 100644 index f499ed5ef..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/network/ProgressRequestBody.java +++ /dev/null @@ -1,76 +0,0 @@ -/* Copyright 2017 Andrew Dawson - * - * This file is a part of Tusky. - * - * 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. - * - * Tusky 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 Tusky; if not, - * see . */ - -package com.keylesspalace.tusky.network; - -import androidx.annotation.NonNull; - -import java.io.IOException; -import java.io.InputStream; - -import okhttp3.MediaType; -import okhttp3.RequestBody; -import okio.BufferedSink; - -public final class ProgressRequestBody extends RequestBody { - private final InputStream content; - private final long contentLength; - private final UploadCallback uploadListener; - private final MediaType mediaType; - - private static final int DEFAULT_BUFFER_SIZE = 2048; - - public interface UploadCallback { - void onProgressUpdate(int percentage); - } - - public ProgressRequestBody(final InputStream content, long contentLength, final MediaType mediaType, final UploadCallback listener) { - this.content = content; - this.contentLength = contentLength; - this.mediaType = mediaType; - this.uploadListener = listener; - } - - @Override - public MediaType contentType() { - return mediaType; - } - - @Override - public long contentLength() { - return contentLength; - } - - @Override - public void writeTo(@NonNull BufferedSink sink) throws IOException { - - byte[] buffer = new byte[DEFAULT_BUFFER_SIZE]; - long uploaded = 0; - - try { - int read; - while ((read = content.read(buffer)) != -1) { - uploadListener.onProgressUpdate((int)(100 * uploaded / contentLength)); - - uploaded += read; - sink.write(buffer, 0, read); - } - - uploadListener.onProgressUpdate((int)(100 * uploaded / contentLength)); - } finally { - content.close(); - } - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/network/UriRequestBody.kt b/app/src/main/java/com/keylesspalace/tusky/network/UriRequestBody.kt new file mode 100644 index 000000000..bdd5a6e61 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/network/UriRequestBody.kt @@ -0,0 +1,66 @@ +/* + * Copyright 2024 Tusky Contributors + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . + */ +package com.keylesspalace.tusky.network + +import android.content.ContentResolver +import android.net.Uri +import java.io.FileNotFoundException +import okhttp3.MediaType +import okhttp3.RequestBody +import okio.Buffer +import okio.BufferedSink +import okio.source + +// Align with Okio Segment size for better performance +private const val DEFAULT_CHUNK_SIZE = 8192L + +fun interface UploadCallback { + fun onProgressUpdate(percentage: Int) +} + +fun Uri.asRequestBody( + contentResolver: ContentResolver, + contentType: MediaType? = null, + contentLength: Long = -1L, + uploadListener: UploadCallback? = null +): RequestBody { + return object : RequestBody() { + override fun contentType(): MediaType? = contentType + + override fun contentLength(): Long = contentLength + + override fun writeTo(sink: BufferedSink) { + val buffer = Buffer() + var uploaded: Long = 0 + val inputStream = contentResolver.openInputStream(this@asRequestBody) + ?: throw FileNotFoundException("Unavailable ContentProvider") + + inputStream.source().use { source -> + while (true) { + val read = source.read(buffer, DEFAULT_CHUNK_SIZE) + if (read == -1L) { + break + } + sink.write(buffer, read) + uploaded += read + uploadListener?.let { if (contentLength > 0L) it.onProgressUpdate((100L * uploaded / contentLength).toInt()) } + } + uploadListener?.onProgressUpdate(100) + } + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/pager/ImagePagerAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/pager/ImagePagerAdapter.kt index 26c5fc05a..80a30adea 100644 --- a/app/src/main/java/com/keylesspalace/tusky/pager/ImagePagerAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/pager/ImagePagerAdapter.kt @@ -14,7 +14,8 @@ class ImagePagerAdapter( ) : ViewMediaAdapter(activity) { private var didTransition = false - private val fragments = MutableList?>(attachments.size) { null } + private val fragments = + MutableList?>(attachments.size) { null } override fun getItemCount() = attachments.size diff --git a/app/src/main/java/com/keylesspalace/tusky/pager/MainPagerAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/pager/MainPagerAdapter.kt index c635f929c..a8d9c7069 100644 --- a/app/src/main/java/com/keylesspalace/tusky/pager/MainPagerAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/pager/MainPagerAdapter.kt @@ -20,7 +20,9 @@ import androidx.fragment.app.FragmentActivity import com.keylesspalace.tusky.TabData import com.keylesspalace.tusky.util.CustomFragmentStateAdapter -class MainPagerAdapter(var tabs: List, activity: FragmentActivity) : CustomFragmentStateAdapter(activity) { +class MainPagerAdapter(var tabs: List, activity: FragmentActivity) : CustomFragmentStateAdapter( + activity +) { override fun createFragment(position: Int): Fragment { val tab = tabs[position] diff --git a/app/src/main/java/com/keylesspalace/tusky/receiver/NotificationBlockStateBroadcastReceiver.kt b/app/src/main/java/com/keylesspalace/tusky/receiver/NotificationBlockStateBroadcastReceiver.kt index 20b18a9f9..a2656d0f1 100644 --- a/app/src/main/java/com/keylesspalace/tusky/receiver/NotificationBlockStateBroadcastReceiver.kt +++ b/app/src/main/java/com/keylesspalace/tusky/receiver/NotificationBlockStateBroadcastReceiver.kt @@ -24,12 +24,13 @@ import com.keylesspalace.tusky.components.notifications.canEnablePushNotificatio import com.keylesspalace.tusky.components.notifications.isUnifiedPushNotificationEnabledForAccount import com.keylesspalace.tusky.components.notifications.updateUnifiedPushSubscription import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.di.ApplicationScope import com.keylesspalace.tusky.network.MastodonApi import dagger.android.AndroidInjection -import kotlinx.coroutines.DelicateCoroutinesApi -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.launch import javax.inject.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.launch @DelicateCoroutinesApi class NotificationBlockStateBroadcastReceiver : BroadcastReceiver() { @@ -39,6 +40,10 @@ class NotificationBlockStateBroadcastReceiver : BroadcastReceiver() { @Inject lateinit var accountManager: AccountManager + @Inject + @ApplicationScope + lateinit var externalScope: CoroutineScope + override fun onReceive(context: Context, intent: Intent) { AndroidInjection.inject(this, context) if (Build.VERSION.SDK_INT < 28) return @@ -60,7 +65,14 @@ class NotificationBlockStateBroadcastReceiver : BroadcastReceiver() { accountManager.getAccountByIdentifier(gid)?.let { account -> if (isUnifiedPushNotificationEnabledForAccount(account)) { // Update UnifiedPush notification subscription - GlobalScope.launch { updateUnifiedPushSubscription(context, mastodonApi, accountManager, account) } + externalScope.launch { + updateUnifiedPushSubscription( + context, + mastodonApi, + accountManager, + account + ) + } } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/receiver/SendStatusBroadcastReceiver.kt b/app/src/main/java/com/keylesspalace/tusky/receiver/SendStatusBroadcastReceiver.kt index f93498702..23eeadb04 100644 --- a/app/src/main/java/com/keylesspalace/tusky/receiver/SendStatusBroadcastReceiver.kt +++ b/app/src/main/java/com/keylesspalace/tusky/receiver/SendStatusBroadcastReceiver.kt @@ -15,6 +15,7 @@ package com.keylesspalace.tusky.receiver +import android.annotation.SuppressLint import android.content.BroadcastReceiver import android.content.Context import android.content.Intent @@ -39,16 +40,23 @@ class SendStatusBroadcastReceiver : BroadcastReceiver() { @Inject lateinit var accountManager: AccountManager + @SuppressLint("MissingPermission") override fun onReceive(context: Context, intent: Intent) { AndroidInjection.inject(this, context) if (intent.action == NotificationHelper.REPLY_ACTION) { - val notificationId = intent.getIntExtra(NotificationHelper.KEY_NOTIFICATION_ID, -1) + val serverNotificationId = intent.getStringExtra(NotificationHelper.KEY_SERVER_NOTIFICATION_ID) val senderId = intent.getLongExtra(NotificationHelper.KEY_SENDER_ACCOUNT_ID, -1) - val senderIdentifier = intent.getStringExtra(NotificationHelper.KEY_SENDER_ACCOUNT_IDENTIFIER) - val senderFullName = intent.getStringExtra(NotificationHelper.KEY_SENDER_ACCOUNT_FULL_NAME) + val senderIdentifier = intent.getStringExtra( + NotificationHelper.KEY_SENDER_ACCOUNT_IDENTIFIER + ) + val senderFullName = intent.getStringExtra( + NotificationHelper.KEY_SENDER_ACCOUNT_FULL_NAME + ) val citedStatusId = intent.getStringExtra(NotificationHelper.KEY_CITED_STATUS_ID) - val visibility = intent.getSerializableExtra(NotificationHelper.KEY_VISIBILITY) as Status.Visibility + val visibility = intent.getSerializableExtra( + NotificationHelper.KEY_VISIBILITY + ) as Status.Visibility val spoiler = intent.getStringExtra(NotificationHelper.KEY_SPOILER).orEmpty() val mentions = intent.getStringArrayExtra(NotificationHelper.KEY_MENTIONS).orEmpty() @@ -61,21 +69,23 @@ class SendStatusBroadcastReceiver : BroadcastReceiver() { if (account == null) { Log.w(TAG, "Account \"$senderId\" not found in database. Aborting quick reply!") - val builder = NotificationCompat.Builder(context, NotificationHelper.CHANNEL_MENTION + senderIdentifier) + val notification = NotificationCompat.Builder( + context, + NotificationHelper.CHANNEL_MENTION + senderIdentifier + ) .setSmallIcon(R.drawable.ic_notify) .setColor(context.getColor(R.color.chinwag_green)) .setGroup(senderFullName) - .setDefaults(0) // So it doesn't ring twice, notify only in Target callback + .setDefaults(0) // We don't want this to make any sound or vibration + .setOnlyAlertOnce(true) + .setContentTitle(context.getString(R.string.error_generic)) + .setContentText(context.getString(R.string.error_sender_account_gone)) + .setSubText(senderFullName) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .setCategory(NotificationCompat.CATEGORY_SOCIAL) + .build() - builder.setContentTitle(context.getString(R.string.error_generic)) - builder.setContentText(context.getString(R.string.error_sender_account_gone)) - - builder.setSubText(senderFullName) - builder.setVisibility(NotificationCompat.VISIBILITY_PUBLIC) - builder.setCategory(NotificationCompat.CATEGORY_SOCIAL) - builder.setOnlyAlertOnce(true) - - notificationManager.notify(notificationId, builder.build()) + notificationManager.notify(serverNotificationId, senderId.toInt(), notification) } else { val text = mentions.joinToString(" ", postfix = " ") { "@$it" } + message.toString() @@ -84,7 +94,7 @@ class SendStatusBroadcastReceiver : BroadcastReceiver() { StatusToSend( text = text, warningText = spoiler, - visibility = visibility.serverString(), + visibility = visibility.serverString, sensitive = false, media = emptyList(), scheduledAt = null, @@ -103,22 +113,25 @@ class SendStatusBroadcastReceiver : BroadcastReceiver() { context.startService(sendIntent) - val builder = NotificationCompat.Builder(context, NotificationHelper.CHANNEL_MENTION + senderIdentifier) + // Notifications with remote input active can't be cancelled, so let's replace it with another one that will dismiss automatically + val notification = NotificationCompat.Builder( + context, + NotificationHelper.CHANNEL_MENTION + senderIdentifier + ) .setSmallIcon(R.drawable.ic_notify) .setColor(context.getColor(R.color.notification_color)) .setGroup(senderFullName) - .setDefaults(0) // So it doesn't ring twice, notify only in Target callback + .setDefaults(0) // We don't want this to make any sound or vibration + .setOnlyAlertOnce(true) + .setContentTitle(context.getString(R.string.reply_sending)) + .setContentText(context.getString(R.string.reply_sending_long)) + .setSubText(senderFullName) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .setCategory(NotificationCompat.CATEGORY_SOCIAL) + .setTimeoutAfter(5000) + .build() - builder.setContentTitle(context.getString(R.string.post_sent)) - builder.setContentText(context.getString(R.string.post_sent_long)) - - builder.setSubText(senderFullName) - builder.setVisibility(NotificationCompat.VISIBILITY_PUBLIC) - builder.setCategory(NotificationCompat.CATEGORY_SOCIAL) - builder.setOnlyAlertOnce(true) - - // There is a separate "I am sending" notification, so simply remove the handled one. - notificationManager.cancel(notificationId) + notificationManager.notify(serverNotificationId, senderId.toInt(), notification) } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/receiver/UnifiedPushBroadcastReceiver.kt b/app/src/main/java/com/keylesspalace/tusky/receiver/UnifiedPushBroadcastReceiver.kt index b95e53105..40fe48438 100644 --- a/app/src/main/java/com/keylesspalace/tusky/receiver/UnifiedPushBroadcastReceiver.kt +++ b/app/src/main/java/com/keylesspalace/tusky/receiver/UnifiedPushBroadcastReceiver.kt @@ -23,14 +23,15 @@ import androidx.work.WorkManager import com.keylesspalace.tusky.components.notifications.registerUnifiedPushEndpoint import com.keylesspalace.tusky.components.notifications.unregisterUnifiedPushEndpoint import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.di.ApplicationScope import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.worker.NotificationWorker import dagger.android.AndroidInjection +import javax.inject.Inject +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.DelicateCoroutinesApi -import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch import org.unifiedpush.android.connector.MessagingReceiver -import javax.inject.Inject @DelicateCoroutinesApi class UnifiedPushBroadcastReceiver : MessagingReceiver() { @@ -44,6 +45,10 @@ class UnifiedPushBroadcastReceiver : MessagingReceiver() { @Inject lateinit var mastodonApi: MastodonApi + @Inject + @ApplicationScope + lateinit var externalScope: CoroutineScope + override fun onReceive(context: Context, intent: Intent) { super.onReceive(context, intent) AndroidInjection.inject(this, context) @@ -61,9 +66,9 @@ class UnifiedPushBroadcastReceiver : MessagingReceiver() { AndroidInjection.inject(this, context) Log.d(TAG, "Endpoint available for account $instance: $endpoint") accountManager.getAccountById(instance.toLong())?.let { - // Launch the coroutine in global scope -- it is short and we don't want to lose the registration event - // and there is no saner way to use structured concurrency in a receiver - GlobalScope.launch { registerUnifiedPushEndpoint(context, mastodonApi, accountManager, it, endpoint) } + externalScope.launch { + registerUnifiedPushEndpoint(context, mastodonApi, accountManager, it, endpoint) + } } } @@ -74,7 +79,7 @@ class UnifiedPushBroadcastReceiver : MessagingReceiver() { Log.d(TAG, "Endpoint unregistered for account $instance") accountManager.getAccountById(instance.toLong())?.let { // It's fine if the account does not exist anymore -- that means it has been logged out - GlobalScope.launch { unregisterUnifiedPushEndpoint(mastodonApi, accountManager, it) } + externalScope.launch { unregisterUnifiedPushEndpoint(mastodonApi, accountManager, it) } } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/service/SendStatusService.kt b/app/src/main/java/com/keylesspalace/tusky/service/SendStatusService.kt index cf03115b3..84f5c5981 100644 --- a/app/src/main/java/com/keylesspalace/tusky/service/SendStatusService.kt +++ b/app/src/main/java/com/keylesspalace/tusky/service/SendStatusService.kt @@ -1,3 +1,18 @@ +/* Copyright 2019 Tusky Contributors + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + package com.keylesspalace.tusky.service import android.app.Notification @@ -21,8 +36,8 @@ import at.connyduck.calladapter.networkresult.fold import com.keylesspalace.tusky.MainActivity import com.keylesspalace.tusky.R import com.keylesspalace.tusky.appstore.EventHub +import com.keylesspalace.tusky.appstore.StatusChangedEvent import com.keylesspalace.tusky.appstore.StatusComposedEvent -import com.keylesspalace.tusky.appstore.StatusEditedEvent import com.keylesspalace.tusky.appstore.StatusScheduledEvent import com.keylesspalace.tusky.components.compose.MediaUploader import com.keylesspalace.tusky.components.compose.UploadEvent @@ -34,10 +49,14 @@ import com.keylesspalace.tusky.entity.Attachment import com.keylesspalace.tusky.entity.MediaAttribute import com.keylesspalace.tusky.entity.NewPoll import com.keylesspalace.tusky.entity.NewStatus +import com.keylesspalace.tusky.entity.ScheduledStatus import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.util.unsafeLazy import dagger.android.AndroidInjection +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.TimeUnit +import javax.inject.Inject import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -46,9 +65,6 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.parcelize.Parcelize import retrofit2.HttpException -import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.TimeUnit -import javax.inject.Inject class SendStatusService : Service(), Injectable { @@ -73,7 +89,9 @@ class SendStatusService : Service(), Injectable { private val statusesToSend = ConcurrentHashMap() private val sendJobs = ConcurrentHashMap() - private val notificationManager by unsafeLazy { getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager } + private val notificationManager by unsafeLazy { + getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + } override fun onCreate() { AndroidInjection.inject(this) @@ -88,7 +106,12 @@ class SendStatusService : Service(), Injectable { ?: throw IllegalStateException("SendStatusService started without $KEY_STATUS extra") if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val channel = NotificationChannel(CHANNEL_ID, getString(R.string.send_post_notification_channel_name), NotificationManager.IMPORTANCE_LOW) + val channel = + NotificationChannel( + CHANNEL_ID, + getString(R.string.send_post_notification_channel_name), + NotificationManager.IMPORTANCE_LOW + ) notificationManager.createNotificationChannel(channel) } @@ -104,7 +127,11 @@ class SendStatusService : Service(), Injectable { .setProgress(1, 0, true) .setOngoing(true) .setColor(getColor(R.color.notification_color)) - .addAction(0, getString(android.R.string.cancel), cancelSendingIntent(sendingNotificationId)) + .addAction( + 0, + getString(android.R.string.cancel), + cancelSendingIntent(sendingNotificationId) + ) if (statusesToSend.size == 0 || Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_DETACH) @@ -115,15 +142,23 @@ class SendStatusService : Service(), Injectable { statusesToSend[sendingNotificationId] = statusToSend sendStatus(sendingNotificationId--) - } else { - if (intent.hasExtra(KEY_CANCEL)) { - cancelSending(intent.getIntExtra(KEY_CANCEL, 0)) - } + } else if (intent.hasExtra(KEY_CANCEL)) { + cancelSending(intent.getIntExtra(KEY_CANCEL, 0)) } return START_NOT_STICKY } + override fun onTimeout(startId: Int) { + // https://developer.android.com/about/versions/14/changes/fgs-types-required#short-service + // max time for short service reached on Android 14+, stop sending + statusesToSend.forEach { (statusId, _) -> + serviceScope.launch { + failSending(statusId) + } + } + } + private fun sendStatus(statusId: Int) { // when statusToSend == null, sending has been canceled val statusToSend = statusesToSend[statusId] ?: return @@ -222,13 +257,24 @@ class SendStatusService : Service(), Injectable { } ) + val scheduled = !statusToSend.scheduledAt.isNullOrEmpty() + val sendResult = if (isNew) { - mastodonApi.createStatus( - "Bearer " + account.accessToken, - account.domain, - statusToSend.idempotencyKey, - newStatus - ) + if (!scheduled) { + mastodonApi.createStatus( + "Bearer " + account.accessToken, + account.domain, + statusToSend.idempotencyKey, + newStatus + ) + } else { + mastodonApi.createScheduledStatus( + "Bearer " + account.accessToken, + account.domain, + statusToSend.idempotencyKey, + newStatus + ) + } } else { mastodonApi.editStatus( statusToSend.statusId!!, @@ -248,14 +294,12 @@ class SendStatusService : Service(), Injectable { mediaUploader.cancelUploadScope(*statusToSend.media.map { it.localId }.toIntArray()) - val scheduled = !statusToSend.scheduledAt.isNullOrEmpty() - if (scheduled) { - eventHub.dispatch(StatusScheduledEvent(sentStatus)) + eventHub.dispatch(StatusScheduledEvent(sentStatus as ScheduledStatus)) } else if (!isNew) { - eventHub.dispatch(StatusEditedEvent(statusToSend.statusId!!, sentStatus)) + eventHub.dispatch(StatusChangedEvent(sentStatus as Status)) } else { - eventHub.dispatch(StatusComposedEvent(sentStatus)) + eventHub.dispatch(StatusComposedEvent(sentStatus as Status)) } notificationManager.cancel(statusId) @@ -281,7 +325,9 @@ class SendStatusService : Service(), Injectable { // when statusToSend == null, sending has been canceled val statusToSend = statusesToSend[statusId] ?: return - val backoff = TimeUnit.SECONDS.toMillis(statusToSend.retries.toLong()).coerceAtMost(MAX_RETRY_INTERVAL) + val backoff = TimeUnit.SECONDS.toMillis( + statusToSend.retries.toLong() + ).coerceAtMost(MAX_RETRY_INTERVAL) delay(backoff) sendStatus(statusId) @@ -289,7 +335,10 @@ class SendStatusService : Service(), Injectable { private fun stopSelfWhenDone() { if (statusesToSend.isEmpty()) { - ServiceCompat.stopForeground(this@SendStatusService, ServiceCompat.STOP_FOREGROUND_REMOVE) + ServiceCompat.stopForeground( + this@SendStatusService, + ServiceCompat.STOP_FOREGROUND_REMOVE + ) stopSelf() } } @@ -379,9 +428,7 @@ class SendStatusService : Service(), Injectable { accountId: Long, statusId: Int ): Notification { - val intent = Intent(this, MainActivity::class.java) - intent.putExtra(NotificationHelper.ACCOUNT_ID, accountId) - intent.putExtra(MainActivity.OPEN_DRAFTS, true) + val intent = MainActivity.draftIntent(this, accountId) val pendingIntent = PendingIntent.getActivity( this, @@ -418,10 +465,7 @@ class SendStatusService : Service(), Injectable { private var sendingNotificationId = -1 // use negative ids to not clash with other notis private var errorNotificationId = Int.MIN_VALUE // use even more negative ids to not clash with other notis - fun sendStatusIntent( - context: Context, - statusToSend: StatusToSend - ): Intent { + fun sendStatusIntent(context: Context, statusToSend: StatusToSend): Intent { val intent = Intent(context, SendStatusService::class.java) intent.putExtra(KEY_STATUS, statusToSend) @@ -469,7 +513,8 @@ data class StatusToSend( @Parcelize data class MediaToSend( val localId: Int, - val id: String?, // null if media is not yet completely uploaded + // null if media is not yet completely uploaded + val id: String?, val uri: String, val description: String?, val focus: Attachment.Focus?, diff --git a/app/src/main/java/com/keylesspalace/tusky/service/TuskyTileService.kt b/app/src/main/java/com/keylesspalace/tusky/service/TuskyTileService.kt index 1e170da84..86d64075c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/service/TuskyTileService.kt +++ b/app/src/main/java/com/keylesspalace/tusky/service/TuskyTileService.kt @@ -15,25 +15,31 @@ package com.keylesspalace.tusky.service -import android.annotation.TargetApi +import android.annotation.SuppressLint +import android.app.PendingIntent import android.content.Intent +import android.os.Build import android.service.quicksettings.TileService import com.keylesspalace.tusky.MainActivity +import com.keylesspalace.tusky.components.compose.ComposeActivity /** * Small Addition that adds in a QuickSettings tile * opens the Compose activity or shows an account selector when multiple accounts are present */ - -@TargetApi(24) class TuskyTileService : TileService() { + @SuppressLint("StartActivityAndCollapseDeprecated") + @Suppress("DEPRECATION") override fun onClick() { - val intent = Intent(this, MainActivity::class.java).apply { - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - action = Intent.ACTION_SEND - type = "text/plain" + val intent = MainActivity.composeIntent(this, ComposeActivity.ComposeOptions()) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + val pendingIntent = PendingIntent.getActivity(this, 1, intent, PendingIntent.FLAG_IMMUTABLE) + startActivityAndCollapse(pendingIntent) + } else { + startActivityAndCollapse(intent) } - startActivityAndCollapse(intent) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/settings/AccountPreferenceDataStore.kt b/app/src/main/java/com/keylesspalace/tusky/settings/AccountPreferenceDataStore.kt index a95134414..479b43310 100644 --- a/app/src/main/java/com/keylesspalace/tusky/settings/AccountPreferenceDataStore.kt +++ b/app/src/main/java/com/keylesspalace/tusky/settings/AccountPreferenceDataStore.kt @@ -6,9 +6,9 @@ import com.keylesspalace.tusky.appstore.PreferenceChangedEvent import com.keylesspalace.tusky.db.AccountEntity import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.di.ApplicationScope +import javax.inject.Inject import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch -import javax.inject.Inject class AccountPreferenceDataStore @Inject constructor( private val accountManager: AccountManager, @@ -22,6 +22,9 @@ class AccountPreferenceDataStore @Inject constructor( PrefKeys.ALWAYS_SHOW_SENSITIVE_MEDIA -> account.alwaysShowSensitiveMedia PrefKeys.ALWAYS_OPEN_SPOILER -> account.alwaysOpenSpoiler PrefKeys.MEDIA_PREVIEW_ENABLED -> account.mediaPreviewEnabled + PrefKeys.TAB_FILTER_HOME_BOOSTS -> account.isShowHomeBoosts + PrefKeys.TAB_FILTER_HOME_REPLIES -> account.isShowHomeReplies + PrefKeys.TAB_SHOW_HOME_SELF_BOOSTS -> account.isShowHomeSelfBoosts else -> defValue } } @@ -31,6 +34,9 @@ class AccountPreferenceDataStore @Inject constructor( PrefKeys.ALWAYS_SHOW_SENSITIVE_MEDIA -> account.alwaysShowSensitiveMedia = value PrefKeys.ALWAYS_OPEN_SPOILER -> account.alwaysOpenSpoiler = value PrefKeys.MEDIA_PREVIEW_ENABLED -> account.mediaPreviewEnabled = value + PrefKeys.TAB_FILTER_HOME_BOOSTS -> account.isShowHomeBoosts = value + PrefKeys.TAB_FILTER_HOME_REPLIES -> account.isShowHomeReplies = value + PrefKeys.TAB_SHOW_HOME_SELF_BOOSTS -> account.isShowHomeSelfBoosts = value } accountManager.saveAccount(account) diff --git a/app/src/main/java/com/keylesspalace/tusky/settings/ProxyConfiguration.kt b/app/src/main/java/com/keylesspalace/tusky/settings/ProxyConfiguration.kt index fbe8084bc..db8046c78 100644 --- a/app/src/main/java/com/keylesspalace/tusky/settings/ProxyConfiguration.kt +++ b/app/src/main/java/com/keylesspalace/tusky/settings/ProxyConfiguration.kt @@ -31,6 +31,13 @@ class ProxyConfiguration private constructor( } } -private val PROXY_RANGE = IntRange(ProxyConfiguration.MIN_PROXY_PORT, ProxyConfiguration.MAX_PROXY_PORT) -private val IP_ADDRESS_REGEX = Regex("^(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$") -private val HOSTNAME_REGEX = Regex("^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\\-]*[a-zA-Z0-9])\\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\\-]*[A-Za-z0-9])$") +private val PROXY_RANGE = + IntRange(ProxyConfiguration.MIN_PROXY_PORT, ProxyConfiguration.MAX_PROXY_PORT) +private val IP_ADDRESS_REGEX = + Regex( + "^(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$" + ) +private val HOSTNAME_REGEX = + Regex( + "^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\\-]*[a-zA-Z0-9])\\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\\-]*[A-Za-z0-9])$" + ) diff --git a/app/src/main/java/com/keylesspalace/tusky/settings/SettingsConstants.kt b/app/src/main/java/com/keylesspalace/tusky/settings/SettingsConstants.kt index 1a64f69b0..6005c9600 100644 --- a/app/src/main/java/com/keylesspalace/tusky/settings/SettingsConstants.kt +++ b/app/src/main/java/com/keylesspalace/tusky/settings/SettingsConstants.kt @@ -5,10 +5,14 @@ enum class AppTheme(val value: String) { DAY("day"), BLACK("black"), AUTO("auto"), - AUTO_SYSTEM("auto_system"); + AUTO_SYSTEM("auto_system"), + AUTO_SYSTEM_BLACK("auto_system_black"); companion object { - fun stringValues() = values().map { it.value }.toTypedArray() + fun stringValues() = entries.map { it.value }.toTypedArray() + + @JvmField + val DEFAULT = AUTO_SYSTEM } } @@ -41,7 +45,10 @@ enum class AppTheme(val value: String) { * * - Adding a new preference that does not change the interpretation of an existing preference */ -const val SCHEMA_VERSION = 2023022701 +const val SCHEMA_VERSION = 2023112001 + +/** The schema version for fresh installs */ +const val NEW_INSTALL_SCHEMA_VERSION = 0 object PrefKeys { // Note: not all of these keys are actually used as SharedPreferences keys but we must give @@ -49,7 +56,6 @@ object PrefKeys { const val SCHEMA_VERSION: String = "schema_version" const val APP_THEME = "appTheme" - const val EMOJI = "selected_emoji_font" const val FAB_HIDE = "fabHide" const val LANGUAGE = "language" const val STATUS_TEXT_SIZE = "statusTextSize" @@ -61,8 +67,8 @@ object PrefKeys { const val ANIMATE_GIF_AVATARS = "animateGifAvatars" const val USE_BLURHASH = "useBlurhash" const val SHOW_SELF_USERNAME = "showSelfUsername" - const val SHOW_NOTIFICATIONS_FILTER = "showNotificationsFilter" const val SHOW_CARDS_IN_TIMELINES = "showCardsInTimelines" + const val SHOW_NOTIFICATIONS_FILTER = "showNotificationsFilter" const val CONFIRM_REBLOGS = "confirmReblogs" const val CONFIRM_FAVOURITES = "confirmFavourites" const val ENABLE_SWIPE_FOR_TABS = "enableSwipeForTabs" @@ -101,7 +107,13 @@ object PrefKeys { const val TAB_FILTER_HOME_REPLIES = "tabFilterHomeReplies_v2" // This was changed once to reset an unintentionally set default. const val TAB_FILTER_HOME_BOOSTS = "tabFilterHomeBoosts" + const val TAB_SHOW_HOME_SELF_BOOSTS = "tabShowHomeSelfBoosts" /** UI text scaling factor, stored as float, 100 = 100% = no scaling */ const val UI_TEXT_SCALE_RATIO = "uiTextScaleRatio" + + /** Keys that are no longer used (e.g., the preference has been removed */ + object Deprecated { + const val SHOW_NOTIFICATIONS_FILTER = "showNotificationsFilter" + } } diff --git a/app/src/main/java/com/keylesspalace/tusky/settings/SettingsDSL.kt b/app/src/main/java/com/keylesspalace/tusky/settings/SettingsDSL.kt index 720dc817f..c1b57427d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/settings/SettingsDSL.kt +++ b/app/src/main/java/com/keylesspalace/tusky/settings/SettingsDSL.kt @@ -6,7 +6,6 @@ import androidx.activity.result.ActivityResultRegistryOwner import androidx.annotation.StringRes import androidx.core.widget.doAfterTextChanged import androidx.lifecycle.LifecycleOwner -import androidx.preference.CheckBoxPreference import androidx.preference.EditTextPreference import androidx.preference.ListPreference import androidx.preference.Preference @@ -36,7 +35,10 @@ inline fun PreferenceParent.listPreference(builder: ListPreference.() -> Unit): return pref } -inline fun PreferenceParent.emojiPreference(activity: A, builder: EmojiPickerPreference.() -> Unit): EmojiPickerPreference +inline fun PreferenceParent.emojiPreference( + activity: A, + builder: EmojiPickerPreference.() -> Unit +): EmojiPickerPreference where A : Context, A : ActivityResultRegistryOwner, A : LifecycleOwner { val pref = EmojiPickerPreference.get(activity) builder(pref) @@ -86,15 +88,6 @@ inline fun PreferenceParent.validatedEditTextPreference( return pref } -inline fun PreferenceParent.checkBoxPreference( - builder: CheckBoxPreference.() -> Unit -): CheckBoxPreference { - val pref = CheckBoxPreference(context) - builder(pref) - addPref(pref) - return pref -} - inline fun PreferenceParent.preferenceCategory( @StringRes title: Int? = null, builder: PreferenceParent.(PreferenceCategory) -> Unit diff --git a/app/src/main/java/com/keylesspalace/tusky/usecase/LogoutUsecase.kt b/app/src/main/java/com/keylesspalace/tusky/usecase/LogoutUsecase.kt index f8d3b11ca..1bcab4179 100644 --- a/app/src/main/java/com/keylesspalace/tusky/usecase/LogoutUsecase.kt +++ b/app/src/main/java/com/keylesspalace/tusky/usecase/LogoutUsecase.kt @@ -7,7 +7,7 @@ import com.keylesspalace.tusky.components.notifications.disableUnifiedPushNotifi import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AppDatabase import com.keylesspalace.tusky.network.MastodonApi -import com.keylesspalace.tusky.util.removeShortcut +import com.keylesspalace.tusky.util.ShareShortcutHelper import javax.inject.Inject class LogoutUsecase @Inject constructor( @@ -15,7 +15,8 @@ class LogoutUsecase @Inject constructor( private val api: MastodonApi, private val db: AppDatabase, private val accountManager: AccountManager, - private val draftHelper: DraftHelper + private val draftHelper: DraftHelper, + private val shareShortcutHelper: ShareShortcutHelper ) { /** @@ -57,7 +58,7 @@ class LogoutUsecase @Inject constructor( draftHelper.deleteAllDraftsAndAttachmentsForAccount(activeAccount.id) // remove shortcut associated with the account - removeShortcut(context, activeAccount) + shareShortcutHelper.removeShortcut(activeAccount) return otherAccountAvailable } diff --git a/app/src/main/java/com/keylesspalace/tusky/usecase/TimelineCases.kt b/app/src/main/java/com/keylesspalace/tusky/usecase/TimelineCases.kt index fc6ccbf3d..24afc0637 100644 --- a/app/src/main/java/com/keylesspalace/tusky/usecase/TimelineCases.kt +++ b/app/src/main/java/com/keylesspalace/tusky/usecase/TimelineCases.kt @@ -20,24 +20,26 @@ import at.connyduck.calladapter.networkresult.NetworkResult import at.connyduck.calladapter.networkresult.fold import at.connyduck.calladapter.networkresult.onFailure import at.connyduck.calladapter.networkresult.onSuccess +import at.connyduck.calladapter.networkresult.runCatching import com.keylesspalace.tusky.appstore.BlockEvent -import com.keylesspalace.tusky.appstore.BookmarkEvent import com.keylesspalace.tusky.appstore.EventHub -import com.keylesspalace.tusky.appstore.FavoriteEvent import com.keylesspalace.tusky.appstore.MuteConversationEvent import com.keylesspalace.tusky.appstore.MuteEvent -import com.keylesspalace.tusky.appstore.PinEvent import com.keylesspalace.tusky.appstore.PollVoteEvent -import com.keylesspalace.tusky.appstore.ReblogEvent +import com.keylesspalace.tusky.appstore.StatusChangedEvent import com.keylesspalace.tusky.appstore.StatusDeletedEvent import com.keylesspalace.tusky.entity.DeletedStatus +import com.keylesspalace.tusky.entity.Notification import com.keylesspalace.tusky.entity.Poll import com.keylesspalace.tusky.entity.Relationship import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.entity.Translation import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.util.Single import com.keylesspalace.tusky.util.getServerErrorMessage -import io.reactivex.rxjava3.core.Single +import java.util.Locale import javax.inject.Inject +import retrofit2.Response /** * Created by charlag on 3/24/18. @@ -53,31 +55,49 @@ class TimelineCases @Inject constructor( mastodonApi.reblogStatus(statusId) } else { mastodonApi.unreblogStatus(statusId) - }.onSuccess { - eventHub.dispatch(ReblogEvent(statusId, reblog)) + }.onSuccess { status -> + if (status.reblog != null) { + // when reblogging, the Mastodon Api does not return the reblogged status directly + // but the newly created status with reblog set to the reblogged status + eventHub.dispatch(StatusChangedEvent(status.reblog)) + } else { + eventHub.dispatch(StatusChangedEvent(status)) + } } } + fun reblogOld(statusId: String, reblog: Boolean): Single { + return Single { reblog(statusId, reblog) } + } + suspend fun favourite(statusId: String, favourite: Boolean): NetworkResult { return if (favourite) { mastodonApi.favouriteStatus(statusId) } else { mastodonApi.unfavouriteStatus(statusId) - }.onSuccess { - eventHub.dispatch(FavoriteEvent(statusId, favourite)) + }.onSuccess { status -> + eventHub.dispatch(StatusChangedEvent(status)) } } + fun favouriteOld(statusId: String, favourite: Boolean): Single { + return Single { favourite(statusId, favourite) } + } + suspend fun bookmark(statusId: String, bookmark: Boolean): NetworkResult { return if (bookmark) { mastodonApi.bookmarkStatus(statusId) } else { mastodonApi.unbookmarkStatus(statusId) - }.onSuccess { - eventHub.dispatch(BookmarkEvent(statusId, bookmark)) + }.onSuccess { status -> + eventHub.dispatch(StatusChangedEvent(status)) } } + fun bookmarkOld(statusId: String, bookmark: Boolean): Single { + return Single { bookmark(statusId, bookmark) } + } + suspend fun muteConversation(statusId: String, mute: Boolean): NetworkResult { return if (mute) { mastodonApi.muteConversation(statusId) @@ -118,7 +138,7 @@ class TimelineCases @Inject constructor( } else { mastodonApi.unpinStatus(statusId) }.fold({ status -> - eventHub.dispatch(PinEvent(statusId, pin)) + eventHub.dispatch(StatusChangedEvent(status)) NetworkResult.success(status) }, { e -> Log.w(TAG, "Failed to change pin state", e) @@ -126,7 +146,11 @@ class TimelineCases @Inject constructor( }) } - suspend fun voteInPoll(statusId: String, pollId: String, choices: List): NetworkResult { + suspend fun voteInPoll( + statusId: String, + pollId: String, + choices: List + ): NetworkResult { if (choices.isEmpty()) { return NetworkResult.failure(IllegalStateException()) } @@ -136,12 +160,35 @@ class TimelineCases @Inject constructor( } } - fun acceptFollowRequest(accountId: String): Single { - return mastodonApi.authorizeFollowRequest(accountId) + fun voteInPollOld(statusId: String, pollId: String, choices: List): Single { + return Single { voteInPoll(statusId, pollId, choices) } } - fun rejectFollowRequest(accountId: String): Single { - return mastodonApi.rejectFollowRequest(accountId) + fun acceptFollowRequestOld(accountId: String): Single { + return Single { mastodonApi.authorizeFollowRequest(accountId) } + } + + fun rejectFollowRequestOld(accountId: String): Single { + return Single { mastodonApi.rejectFollowRequest(accountId) } + } + + fun notificationsOld( + maxId: String?, + sinceId: String?, + limit: Int?, + excludes: Set? + ): Single>> { + return Single { runCatching { mastodonApi.notifications(maxId, sinceId, limit, excludes) } } + } + + fun clearNotificationsOld(): Single { + return Single { mastodonApi.clearNotifications() } + } + + suspend fun translate( + statusId: String + ): NetworkResult { + return mastodonApi.translate(statusId, Locale.getDefault().language) } companion object { diff --git a/app/src/main/java/com/keylesspalace/tusky/util/AbsoluteTimeFormatter.kt b/app/src/main/java/com/keylesspalace/tusky/util/AbsoluteTimeFormatter.kt index 86f2e66fa..dd40b8438 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/AbsoluteTimeFormatter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/AbsoluteTimeFormatter.kt @@ -22,10 +22,22 @@ import java.util.Locale import java.util.TimeZone class AbsoluteTimeFormatter @JvmOverloads constructor(private val tz: TimeZone = TimeZone.getDefault()) { - private val sameDaySdf = SimpleDateFormat("HH:mm", Locale.getDefault()).apply { this.timeZone = tz } - private val sameYearSdf = SimpleDateFormat("dd MMM, HH:mm", Locale.getDefault()).apply { this.timeZone = tz } - private val otherYearSdf = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).apply { this.timeZone = tz } - private val otherYearCompleteSdf = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()).apply { this.timeZone = tz } + private val sameDaySdf = SimpleDateFormat( + "HH:mm", + Locale.getDefault() + ).apply { this.timeZone = tz } + private val sameYearSdf = SimpleDateFormat("dd MMM, HH:mm", Locale.getDefault()).apply { + this.timeZone = tz + } + private val otherYearSdf = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).apply { + this.timeZone = tz + } + private val otherYearCompleteSdf = SimpleDateFormat( + "yyyy-MM-dd HH:mm", + Locale.getDefault() + ).apply { + this.timeZone = tz + } @JvmOverloads fun format(time: Date?, shortFormat: Boolean = true, now: Date = Date()): String { diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ActivityExensions.kt b/app/src/main/java/com/keylesspalace/tusky/util/ActivityExensions.kt new file mode 100644 index 000000000..bfe27ecd7 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/ActivityExensions.kt @@ -0,0 +1,25 @@ +@file:JvmName("ActivityExtensions") + +package com.keylesspalace.tusky.util + +import android.app.Activity +import android.content.Intent +import android.os.Build +import com.keylesspalace.tusky.BaseActivity +import com.keylesspalace.tusky.R + +fun Activity.startActivityWithSlideInAnimation(intent: Intent) { + // the new transition api needs to be called by the activity that is the result of the transition, + // so we pass a flag that BaseActivity will respect. + intent.putExtra(BaseActivity.OPEN_WITH_SLIDE_IN, true) + startActivity(intent) + if (!supportsOverridingActivityTransitions()) { + // the old api needs to be called by the activity that starts the transition + @Suppress("DEPRECATION") + overridePendingTransition(R.anim.activity_open_enter, R.anim.activity_open_exit) + } +} + +fun supportsOverridingActivityTransitions(): Boolean { + return Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE +} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/AlertDialogExtensions.kt b/app/src/main/java/com/keylesspalace/tusky/util/AlertDialogExtensions.kt new file mode 100644 index 000000000..70362db50 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/AlertDialogExtensions.kt @@ -0,0 +1,63 @@ +/* + * Copyright 2023 Tusky Contributors + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . + */ + +package com.keylesspalace.tusky.util + +import android.content.DialogInterface +import androidx.annotation.StringRes +import androidx.appcompat.app.AlertDialog +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.suspendCancellableCoroutine + +/** + * Wait for the alert dialog buttons to be clicked, return the ID of the clicked button + * + * @param positiveText Text to show on the positive button + * @param negativeText Optional text to show on the negative button + * @param neutralText Optional text to show on the neutral button + */ +@OptIn(ExperimentalCoroutinesApi::class) +suspend fun AlertDialog.await( + positiveText: String, + negativeText: String? = null, + neutralText: String? = null +) = suspendCancellableCoroutine { cont -> + val listener = DialogInterface.OnClickListener { _, which -> + cont.resume(which) { dismiss() } + } + + setButton(AlertDialog.BUTTON_POSITIVE, positiveText, listener) + negativeText?.let { setButton(AlertDialog.BUTTON_NEGATIVE, it, listener) } + neutralText?.let { setButton(AlertDialog.BUTTON_NEUTRAL, it, listener) } + + setOnCancelListener { cont.cancel() } + cont.invokeOnCancellation { dismiss() } + show() +} + +/** + * @see [AlertDialog.await] + */ +suspend fun AlertDialog.await( + @StringRes positiveTextResource: Int, + @StringRes negativeTextResource: Int? = null, + @StringRes neutralTextResource: Int? = null +) = await( + context.getString(positiveTextResource), + negativeTextResource?.let { context.getString(it) }, + neutralTextResource?.let { context.getString(it) } +) diff --git a/app/src/main/java/com/keylesspalace/tusky/util/CompositeWithOpaqueBackground.kt b/app/src/main/java/com/keylesspalace/tusky/util/CompositeWithOpaqueBackground.kt new file mode 100644 index 000000000..ded746e8e --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/CompositeWithOpaqueBackground.kt @@ -0,0 +1,131 @@ +/* + * Copyright 2023 Tusky Contributors + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . + */ + +package com.keylesspalace.tusky.util + +import android.graphics.Bitmap +import android.graphics.BitmapShader +import android.graphics.Canvas +import android.graphics.ColorMatrix +import android.graphics.ColorMatrixColorFilter +import android.graphics.Paint +import android.graphics.Shader +import com.bumptech.glide.load.Key +import com.bumptech.glide.load.engine.bitmap_recycle.BitmapPool +import com.bumptech.glide.load.resource.bitmap.BitmapTransformation +import com.bumptech.glide.util.Util +import java.nio.ByteBuffer +import java.security.MessageDigest + +/** + * Set an opaque background behind the non-transparent areas of a bitmap. + * + * Profile images may have areas that are partially transparent (i.e., alpha value >= 1 and < 255). + * + * Displaying those can be a problem if there is anything drawn under them, as it will show + * through the image. + * + * Fix this, by: + * + * - Creating a mask that matches the partially transparent areas of the image + * - Creating a new bitmap that, in the areas that match the mask, contains a background color + * - Composite the original image over the top + * + * So the partially transparent areas on the original image are composited over the original + * background, the fully transparent areas on the original image are left transparent. + */ +class CompositeWithOpaqueBackground(val backgroundColor: Int) : BitmapTransformation() { + + override fun equals(other: Any?): Boolean { + if (other is CompositeWithOpaqueBackground) { + return other.backgroundColor == backgroundColor + } + return false + } + + override fun hashCode() = Util.hashCode(ID.hashCode(), Util.hashCode(backgroundColor)) + + override fun updateDiskCacheKey(messageDigest: MessageDigest) { + messageDigest.update(ID_BYTES) + messageDigest.update(ByteBuffer.allocate(Int.SIZE_BYTES).putInt(backgroundColor).array()) + } + + override fun transform( + pool: BitmapPool, + toTransform: Bitmap, + outWidth: Int, + outHeight: Int + ): Bitmap { + // If the input bitmap has no alpha channel then there's nothing to do + if (!toTransform.hasAlpha()) return toTransform + + // Convert the background to a bitmap. + val backgroundBitmap = pool.get(outWidth, outHeight, Bitmap.Config.ARGB_8888) + backgroundBitmap.eraseColor(backgroundColor) + + // Convert the alphaBitmap (where the alpha channel has 8bpp) to a mask of 1bpp + // TODO: toTransform.extractAlpha(paint, ...) could be used here, but I can't find any + // useful documentation covering paints and mask filters. + val maskBitmap = pool.get(outWidth, outHeight, Bitmap.Config.ALPHA_8).apply { + val canvas = Canvas(this) + canvas.drawBitmap(toTransform, 0f, 0f, EXTRACT_MASK_PAINT) + } + + val shader = BitmapShader(backgroundBitmap, Shader.TileMode.REPEAT, Shader.TileMode.REPEAT) + val paintShader = Paint() + paintShader.isAntiAlias = true + paintShader.shader = shader + paintShader.style = Paint.Style.FILL_AND_STROKE + + // Write the background to a new bitmap, masked to just the non-transparent areas of the + // original image + val dest = pool.get(outWidth, outHeight, toTransform.config) + val canvas = Canvas(dest) + canvas.drawBitmap(maskBitmap, 0f, 0f, paintShader) + + // Finally, write the original bitmap over the top + canvas.drawBitmap(toTransform, 0f, 0f, null) + + // Clean up intermediate bitmaps + pool.put(maskBitmap) + pool.put(backgroundBitmap) + + return dest + } + + companion object { + @Suppress("unused") + private const val TAG = "CompositeWithOpaqueBackground" + private val ID = CompositeWithOpaqueBackground::class.qualifiedName!! + private val ID_BYTES = ID.toByteArray(Key.CHARSET) + + /** Paint with a color filter that converts 8bpp alpha images to a 1bpp mask */ + private val EXTRACT_MASK_PAINT = Paint().apply { + colorFilter = ColorMatrixColorFilter( + ColorMatrix( + floatArrayOf( + 0f, 0f, 0f, 0f, 0f, + 0f, 0f, 0f, 0f, 0f, + 0f, 0f, 0f, 0f, 0f, + 0f, 0f, 0f, 255f, 0f + ) + ) + ) + isAntiAlias = false + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/CryptoUtil.kt b/app/src/main/java/com/keylesspalace/tusky/util/CryptoUtil.kt index f4fa4b5ba..d2fe7d570 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/CryptoUtil.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/CryptoUtil.kt @@ -16,13 +16,13 @@ package com.keylesspalace.tusky.util import android.util.Base64 +import java.security.KeyPairGenerator +import java.security.SecureRandom +import java.security.Security import org.bouncycastle.jce.ECNamedCurveTable import org.bouncycastle.jce.interfaces.ECPrivateKey import org.bouncycastle.jce.interfaces.ECPublicKey import org.bouncycastle.jce.provider.BouncyCastleProvider -import java.security.KeyPairGenerator -import java.security.SecureRandom -import java.security.Security object CryptoUtil { const val CURVE_PRIME256_V1 = "prime256v1" diff --git a/app/src/main/java/com/keylesspalace/tusky/util/CustomEmojiHelper.kt b/app/src/main/java/com/keylesspalace/tusky/util/CustomEmojiHelper.kt index bd5facb73..0ef40b312 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/CustomEmojiHelper.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/CustomEmojiHelper.kt @@ -24,10 +24,12 @@ import android.graphics.drawable.Drawable import android.text.SpannableStringBuilder import android.text.style.ReplacementSpan import android.view.View +import android.widget.TextView import com.bumptech.glide.Glide import com.bumptech.glide.request.target.CustomTarget import com.bumptech.glide.request.target.Target import com.bumptech.glide.request.transition.Transition +import com.keylesspalace.tusky.R import com.keylesspalace.tusky.entity.Emoji import java.lang.ref.WeakReference import java.util.regex.Pattern @@ -39,8 +41,8 @@ import java.util.regex.Pattern * @param view a reference to the a view the emojis will be shown in (should be the TextView, but parents of the TextView are also acceptable) * @return the text with the shortcodes replaced by EmojiSpans */ -fun CharSequence.emojify(emojis: List?, view: View, animate: Boolean): CharSequence { - if (emojis.isNullOrEmpty()) { +fun CharSequence.emojify(emojis: List, view: View, animate: Boolean): CharSequence { + if (emojis.isEmpty()) { return this } @@ -51,22 +53,46 @@ fun CharSequence.emojify(emojis: List?, view: View, animate: Boolean): Ch .matcher(this) while (matcher.find()) { - val span = EmojiSpan(WeakReference(view)) + val span = EmojiSpan(view) builder.setSpan(span, matcher.start(), matcher.end(), 0) Glide.with(view) .asDrawable() - .load(if (animate) { url } else { staticUrl }) + .load( + if (animate) { + url + } else { + staticUrl + } + ) .into(span.getTarget(animate)) } } return builder } -class EmojiSpan(val viewWeakReference: WeakReference) : ReplacementSpan() { +class EmojiSpan(view: View) : ReplacementSpan() { + + private val viewWeakReference = WeakReference(view) + + private val emojiSize: Int = if (view is TextView) { + view.paint.textSize + } else { + // sometimes it is not possible to determine the TextView the emoji will be shown in, + // e.g. because it is passed to a library, so we fallback to a size that should be large + // enough in most cases + view.context.resources.getDimension(R.dimen.fallback_emoji_size) + }.times(1.2).toInt() + var imageDrawable: Drawable? = null - override fun getSize(paint: Paint, text: CharSequence, start: Int, end: Int, fm: Paint.FontMetricsInt?): Int { + override fun getSize( + paint: Paint, + text: CharSequence, + start: Int, + end: Int, + fm: Paint.FontMetricsInt? + ): Int { if (fm != null) { /* update FontMetricsInt or otherwise span does not get drawn when * it covers the whole text */ @@ -77,10 +103,20 @@ class EmojiSpan(val viewWeakReference: WeakReference) : ReplacementSpan() fm.bottom = metrics.bottom } - return (paint.textSize * 1.2).toInt() + return emojiSize } - override fun draw(canvas: Canvas, text: CharSequence, start: Int, end: Int, x: Float, top: Int, y: Int, bottom: Int, paint: Paint) { + override fun draw( + canvas: Canvas, + text: CharSequence, + start: Int, + end: Int, + x: Float, + top: Int, + y: Int, + bottom: Int, + paint: Paint + ) { imageDrawable?.let { drawable -> canvas.save() @@ -112,7 +148,7 @@ class EmojiSpan(val viewWeakReference: WeakReference) : ReplacementSpan() } fun getTarget(animate: Boolean): Target { - return object : CustomTarget() { + return object : CustomTarget(emojiSize, emojiSize) { override fun onResourceReady(resource: Drawable, transition: Transition?) { viewWeakReference.get()?.let { view -> if (animate && resource is Animatable) { diff --git a/app/src/main/java/com/keylesspalace/tusky/util/Either.kt b/app/src/main/java/com/keylesspalace/tusky/util/Either.kt index 728ccd0e0..3b26c3daa 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/Either.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/Either.kt @@ -21,27 +21,29 @@ package com.keylesspalace.tusky.util * Class to represent sum type/tagged union/variant/ADT e.t.c. * It is either Left or Right. */ -sealed class Either { - data class Left(val value: L) : Either() - data class Right(val value: R) : Either() +sealed interface Either { + data class Left(val value: L) : Either + data class Right(val value: R) : Either - fun isRight() = this is Right + fun isRight(): Boolean = this is Right - fun isLeft() = this is Left + fun isLeft(): Boolean = this is Left - fun asLeftOrNull() = (this as? Left)?.value + fun asLeftOrNull(): L? = (this as? Left)?.value - fun asRightOrNull() = (this as? Right)?.value + fun asRightOrNull(): R? = (this as? Right)?.value fun asLeft(): L = (this as Left).value fun asRight(): R = (this as Right).value - inline fun map(crossinline mapper: (R) -> N): Either { - return if (this.isLeft()) { - Left(this.asLeft()) - } else { - Right(mapper(this.asRight())) + companion object { + inline fun Either.map(mapper: (R) -> N): Either { + return if (this.isLeft()) { + Left(this.asLeft()) + } else { + Right(mapper(this.asRight())) + } } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/util/EmptyPagingSource.kt b/app/src/main/java/com/keylesspalace/tusky/util/EmptyPagingSource.kt index 41a12aa3d..d08a9c129 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/EmptyPagingSource.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/EmptyPagingSource.kt @@ -6,5 +6,9 @@ import androidx.paging.PagingState class EmptyPagingSource : PagingSource() { override fun getRefreshKey(state: PagingState): Int? = null - override suspend fun load(params: LoadParams): LoadResult = LoadResult.Page(emptyList(), null, null) + override suspend fun load(params: LoadParams): LoadResult = LoadResult.Page( + emptyList(), + null, + null + ) } diff --git a/app/src/main/java/com/keylesspalace/tusky/util/FlowExtensions.kt b/app/src/main/java/com/keylesspalace/tusky/util/FlowExtensions.kt index 7fcf77359..54eaa8a23 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/FlowExtensions.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/FlowExtensions.kt @@ -17,12 +17,11 @@ package com.keylesspalace.tusky.util -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flow import kotlin.time.Duration -import kotlin.time.ExperimentalTime import kotlin.time.TimeMark import kotlin.time.TimeSource +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow /** * Returns a flow that mirrors the original flow, but filters out values that occur within @@ -54,16 +53,13 @@ import kotlin.time.TimeSource * @param timeout Emissions within this duration of the last emission are filtered * @param timeSource Used to measure elapsed time. Normally only overridden in tests */ -@OptIn(ExperimentalTime::class) -fun Flow.throttleFirst( - timeout: Duration, - timeSource: TimeSource = TimeSource.Monotonic -) = flow { - var marker: TimeMark? = null - collect { - if (marker == null || marker!!.elapsedNow() >= timeout) { - emit(it) - marker = timeSource.markNow() +fun Flow.throttleFirst(timeout: Duration, timeSource: TimeSource = TimeSource.Monotonic) = + flow { + var marker: TimeMark? = null + collect { + if (marker == null || marker!!.elapsedNow() >= timeout) { + emit(it) + marker = timeSource.markNow() + } } } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/FocalPointUtil.kt b/app/src/main/java/com/keylesspalace/tusky/util/FocalPointUtil.kt index 41d1034c7..d26d70cef 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/FocalPointUtil.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/FocalPointUtil.kt @@ -144,12 +144,7 @@ object FocalPointUtil { * the image. So it won't put the very edge of the image in center, because that would * leave part of the view empty. */ - fun focalOffset( - view: Float, - image: Float, - scale: Float, - focal: Float - ): Float { + fun focalOffset(view: Float, image: Float, scale: Float, focal: Float): Float { // The fraction of the image that will be in view: val inView = view / (scale * image) var offset = 0f diff --git a/app/src/main/java/com/keylesspalace/tusky/util/GlideExtensions.kt b/app/src/main/java/com/keylesspalace/tusky/util/GlideExtensions.kt new file mode 100644 index 000000000..bc5ad2b3a --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/GlideExtensions.kt @@ -0,0 +1,46 @@ +package com.keylesspalace.tusky.util + +import com.bumptech.glide.RequestBuilder +import com.bumptech.glide.load.DataSource +import com.bumptech.glide.load.engine.GlideException +import com.bumptech.glide.request.RequestListener +import com.bumptech.glide.request.target.Target +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlinx.coroutines.suspendCancellableCoroutine + +/** + * Allows waiting for a Glide request to complete without blocking a background thread. + */ +suspend fun RequestBuilder.submitAsync( + width: Int = Int.MIN_VALUE, + height: Int = Int.MIN_VALUE +): R { + return suspendCancellableCoroutine { continuation -> + val target = addListener( + object : RequestListener { + override fun onLoadFailed( + e: GlideException?, + model: Any?, + target: Target, + isFirstResource: Boolean + ): Boolean { + continuation.resumeWithException(e ?: GlideException("Image loading failed")) + return false + } + + override fun onResourceReady( + resource: R & Any, + model: Any, + target: Target?, + dataSource: DataSource, + isFirstResource: Boolean + ): Boolean { + continuation.resume(resource) + return false + } + } + ).submit(width, height) + continuation.invokeOnCancellation { target.cancel(true) } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/HttpHeaderLink.kt b/app/src/main/java/com/keylesspalace/tusky/util/HttpHeaderLink.kt index a5ccd35be..8b902b518 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/HttpHeaderLink.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/HttpHeaderLink.kt @@ -123,10 +123,7 @@ constructor( * @param relationType of the parameter "rel", commonly "next" or "prev" * @return the link matching the given relation type */ - fun findByRelationType( - links: List, - relationType: String - ): HttpHeaderLink? { + fun findByRelationType(links: List, relationType: String): HttpHeaderLink? { return links.find { link -> link.parameters.any { parameter -> parameter.name == "rel" && parameter.value == relationType diff --git a/app/src/main/java/com/keylesspalace/tusky/util/IOUtils.kt b/app/src/main/java/com/keylesspalace/tusky/util/IOUtils.kt index ece76bdfd..f17e970ff 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/IOUtils.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/IOUtils.kt @@ -19,49 +19,29 @@ import android.content.ContentResolver import android.net.Uri import java.io.Closeable import java.io.File -import java.io.FileNotFoundException -import java.io.FileOutputStream import java.io.IOException -import java.io.InputStream +import okio.buffer +import okio.sink +import okio.source -private const val DEFAULT_BLOCKSIZE = 16384 - -fun Closeable?.closeQuietly() { +fun Closeable.closeQuietly() { try { - this?.close() + close() } catch (e: IOException) { // intentionally unhandled } } -fun Uri.copyToFile( - contentResolver: ContentResolver, - file: File -): Boolean { - val from: InputStream? - val to: FileOutputStream - - try { - from = contentResolver.openInputStream(this) - to = FileOutputStream(file) - } catch (e: FileNotFoundException) { - return false - } - - if (from == null) return false - - val chunk = ByteArray(DEFAULT_BLOCKSIZE) - try { - while (true) { - val bytes = from.read(chunk, 0, chunk.size) - if (bytes < 0) break - to.write(chunk, 0, bytes) +fun Uri.copyToFile(contentResolver: ContentResolver, file: File): Boolean { + return try { + val inputStream = contentResolver.openInputStream(this) ?: return false + inputStream.source().use { source -> + file.sink().buffer().use { bufferedSink -> + bufferedSink.writeAll(source) + } } + true } catch (e: IOException) { - return false + false } - - from.closeQuietly() - to.closeQuietly() - return true } diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ImageLoadingHelper.kt b/app/src/main/java/com/keylesspalace/tusky/util/ImageLoadingHelper.kt index 1430801c2..0979f5964 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/ImageLoadingHelper.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/ImageLoadingHelper.kt @@ -3,39 +3,50 @@ package com.keylesspalace.tusky.util import android.content.Context +import android.graphics.Bitmap import android.graphics.drawable.BitmapDrawable import android.widget.ImageView import androidx.annotation.Px import com.bumptech.glide.Glide +import com.bumptech.glide.load.MultiTransformation +import com.bumptech.glide.load.Transformation import com.bumptech.glide.load.resource.bitmap.CenterCrop import com.bumptech.glide.load.resource.bitmap.RoundedCorners import com.keylesspalace.tusky.R private val centerCropTransformation = CenterCrop() -fun loadAvatar(url: String?, imageView: ImageView, @Px radius: Int, animate: Boolean) { +fun loadAvatar( + url: String?, + imageView: ImageView, + @Px radius: Int, + animate: Boolean, + transforms: List>? = null +) { if (url.isNullOrBlank()) { Glide.with(imageView) .load(R.drawable.avatar_default) .into(imageView) } else { + val multiTransformation = MultiTransformation( + buildList { + transforms?.let { this.addAll(it) } + add(centerCropTransformation) + add(RoundedCorners(radius)) + } + ) + if (animate) { Glide.with(imageView) .load(url) - .transform( - centerCropTransformation, - RoundedCorners(radius) - ) + .transform(multiTransformation) .placeholder(R.drawable.avatar_default) .into(imageView) } else { Glide.with(imageView) .asBitmap() .load(url) - .transform( - centerCropTransformation, - RoundedCorners(radius) - ) + .transform(multiTransformation) .placeholder(R.drawable.avatar_default) .into(imageView) } diff --git a/app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.kt b/app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.kt index 04176a11e..50d3ccfa7 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/LinkHelper.kt @@ -22,11 +22,14 @@ import android.content.Context import android.content.Intent import android.graphics.Color import android.net.Uri +import android.os.Build import android.text.Spannable import android.text.SpannableStringBuilder import android.text.Spanned import android.text.method.LinkMovementMethod import android.text.style.ClickableSpan +import android.text.style.ForegroundColorSpan +import android.text.style.QuoteSpan import android.text.style.URLSpan import android.util.Log import android.view.MotionEvent @@ -34,10 +37,12 @@ import android.view.MotionEvent.ACTION_UP import android.view.View import android.widget.TextView import androidx.annotation.VisibleForTesting +import androidx.appcompat.content.res.AppCompatResources import androidx.browser.customtabs.CustomTabColorSchemeParams import androidx.browser.customtabs.CustomTabsIntent import androidx.core.net.toUri import androidx.preference.PreferenceManager +import at.connyduck.sparkbutton.helpers.Utils import com.google.android.material.color.MaterialColors import com.keylesspalace.tusky.R import com.keylesspalace.tusky.entity.HashTag @@ -64,19 +69,26 @@ fun getDomain(urlString: String?): String { * @param mentions any '@' mentions which are known to be in the content * @param listener to notify about particular spans that are clicked */ -fun setClickableText(view: TextView, content: CharSequence, mentions: List, tags: List?, listener: LinkListener) { - val spannableContent = markupHiddenUrls(view.context, content) +fun setClickableText( + view: TextView, + content: CharSequence, + mentions: List, + tags: List?, + listener: LinkListener +) { + val spannableContent = markupHiddenUrls(view, content) view.text = spannableContent.apply { - getSpans(0, content.length, URLSpan::class.java).forEach { - setClickableText(it, this, mentions, tags, listener) + styleQuoteSpans(view) + getSpans(0, spannableContent.length, URLSpan::class.java).forEach { span -> + setClickableText(span, this, mentions, tags, listener) } } view.movementMethod = NoTrailingSpaceLinkMovementMethod.getInstance() } @VisibleForTesting -fun markupHiddenUrls(context: Context, content: CharSequence): SpannableStringBuilder { +fun markupHiddenUrls(view: TextView, content: CharSequence): SpannableStringBuilder { val spannableContent = SpannableStringBuilder(content) val originalSpans = spannableContent.getSpans(0, content.length, URLSpan::class.java) val obscuredLinkSpans = originalSpans.filter { @@ -85,7 +97,10 @@ fun markupHiddenUrls(context: Context, content: CharSequence): SpannableStringBu return@filter if (firstCharacter == '#' || firstCharacter == '@') { false } else { - val text = spannableContent.subSequence(start, spannableContent.getSpanEnd(it)).toString() + val text = spannableContent.subSequence( + start, + spannableContent.getSpanEnd(it) + ).toString() .split(' ').lastOrNull().orEmpty() var textDomain = getDomain(text) if (textDomain.isBlank()) { @@ -99,8 +114,30 @@ fun markupHiddenUrls(context: Context, content: CharSequence): SpannableStringBu val start = spannableContent.getSpanStart(span) val end = spannableContent.getSpanEnd(span) val originalText = spannableContent.subSequence(start, end) - val replacementText = context.getString(R.string.url_domain_notifier, originalText, getDomain(span.url)) - spannableContent.replace(start, end, replacementText) // this also updates the span locations + val replacementText = view.context.getString( + R.string.url_domain_notifier, + originalText, + getDomain(span.url) + ) + spannableContent.replace( + start, + end, + replacementText + ) // this also updates the span locations + + val linkDrawable = AppCompatResources.getDrawable(view.context, R.drawable.ic_link)!! + // ImageSpan does not always align the icon correctly in the line, let's use our custom emoji span for this + val linkDrawableSpan = EmojiSpan(view) + linkDrawableSpan.imageDrawable = linkDrawable + + val placeholderIndex = replacementText.indexOf("🔗") + + spannableContent.setSpan( + linkDrawableSpan, + start + placeholderIndex, + start + placeholderIndex + "🔗".length, + 0 + ) } return spannableContent @@ -140,7 +177,12 @@ fun getTagName(text: CharSequence, tags: List?): String? { } } -private fun getCustomSpanForTag(text: CharSequence, tags: List?, span: URLSpan, listener: LinkListener): ClickableSpan? { +private fun getCustomSpanForTag( + text: CharSequence, + tags: List?, + span: URLSpan, + listener: LinkListener +): ClickableSpan? { return getTagName(text, tags)?.let { object : NoUnderlineURLSpan(span.url) { override fun onClick(view: View) = listener.onViewTag(it) @@ -148,19 +190,53 @@ private fun getCustomSpanForTag(text: CharSequence, tags: List?, span: } } -private fun getCustomSpanForMention(mentions: List, span: URLSpan, listener: LinkListener): ClickableSpan? { +private fun getCustomSpanForMention( + mentions: List, + span: URLSpan, + listener: LinkListener +): ClickableSpan? { // https://github.com/tuskyapp/Tusky/pull/2339 return mentions.firstOrNull { it.url == span.url }?.let { getCustomSpanForMentionUrl(span.url, it.id, listener) } } -private fun getCustomSpanForMentionUrl(url: String, mentionId: String, listener: LinkListener): ClickableSpan { +private fun getCustomSpanForMentionUrl( + url: String, + mentionId: String, + listener: LinkListener +): ClickableSpan { return object : MentionSpan(url) { override fun onClick(view: View) = listener.onViewAccount(mentionId) } } +private fun SpannableStringBuilder.styleQuoteSpans(view: TextView) { + getSpans(0, length, QuoteSpan::class.java).forEach { span -> + val start = getSpanStart(span) + val end = getSpanEnd(span) + val flags = getSpanFlags(span) + + val quoteColor = MaterialColors.getColor(view, android.R.attr.textColorTertiary) + + val newQuoteSpan = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + QuoteSpan( + quoteColor, + Utils.dpToPx(view.context, 3), + Utils.dpToPx(view.context, 8) + ) + } else { + QuoteSpan(quoteColor) + } + + val quoteColorSpan = ForegroundColorSpan(quoteColor) + + removeSpan(span) + setSpan(newQuoteSpan, start, end, flags) + setSpan(quoteColorSpan, start, end, flags) + } +} + /** * Put mentions in a piece of text and makes them clickable, associating them with callbacks to * notify when they're clicked. @@ -216,7 +292,9 @@ fun createClickableText(text: String, link: String): CharSequence { */ fun Context.openLink(url: String) { val uri = url.toUri().normalizeScheme() - val useCustomTabs = PreferenceManager.getDefaultSharedPreferences(this).getBoolean("customTabs", false) + val useCustomTabs = PreferenceManager.getDefaultSharedPreferences( + this + ).getBoolean("customTabs", false) if (useCustomTabs) { openLinkInCustomTab(uri, this) @@ -248,9 +326,21 @@ private fun openLinkInBrowser(uri: Uri?, context: Context) { * @param context context */ fun openLinkInCustomTab(uri: Uri, context: Context) { - val toolbarColor = MaterialColors.getColor(context, com.google.android.material.R.attr.colorSurface, Color.BLACK) - val navigationbarColor = MaterialColors.getColor(context, android.R.attr.navigationBarColor, Color.BLACK) - val navigationbarDividerColor = MaterialColors.getColor(context, R.attr.dividerColor, Color.BLACK) + val toolbarColor = MaterialColors.getColor( + context, + com.google.android.material.R.attr.colorSurface, + Color.BLACK + ) + val navigationbarColor = MaterialColors.getColor( + context, + android.R.attr.navigationBarColor, + Color.BLACK + ) + val navigationbarDividerColor = MaterialColors.getColor( + context, + R.attr.dividerColor, + Color.BLACK + ) val colorSchemeParams = CustomTabColorSchemeParams.Builder() .setToolbarColor(toolbarColor) .setNavigationBarColor(navigationbarColor) @@ -258,6 +348,7 @@ fun openLinkInCustomTab(uri: Uri, context: Context) { .build() val customTabsIntent = CustomTabsIntent.Builder() .setDefaultColorSchemeParams(colorSchemeParams) + .setShareState(CustomTabsIntent.SHARE_STATE_ON) .setShowTitle(true) .build() @@ -284,6 +375,8 @@ fun openLinkInCustomTab(uri: Uri, context: Context) { // https://gts.foo.bar/@goblin/statuses/01GH9XANCJ0TA8Y95VE9H3Y0Q2 // https://gts.foo.bar/@goblin // https://foo.microblog.pub/o/5b64045effd24f48a27d7059f6cb38f5 +// https://bookwyrm.foo.bar/user/User +// https://bookwyrm.foo.bar/user/User/comment/123456 fun looksLikeMastodonUrl(urlString: String): Boolean { val uri: URI try { @@ -304,6 +397,8 @@ fun looksLikeMastodonUrl(urlString: String): Boolean { it.matches("^/@[^/]+/\\d+$".toRegex()) || it.matches("^/users/[^/]+/statuses/\\d+$".toRegex()) || it.matches("^/users/\\w+$".toRegex()) || + it.matches("^/user/[^/]+/comment/\\d+$".toRegex()) || + it.matches("^/user/\\w+$".toRegex()) || it.matches("^/notice/[a-zA-Z0-9]+$".toRegex()) || it.matches("^/objects/[-a-f0-9]+$".toRegex()) || it.matches("^/notes/[a-z0-9]+$".toRegex()) || diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ListStatusAccessibilityDelegate.kt b/app/src/main/java/com/keylesspalace/tusky/util/ListStatusAccessibilityDelegate.kt index 9402edd03..537c7acdc 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/ListStatusAccessibilityDelegate.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/ListStatusAccessibilityDelegate.kt @@ -48,14 +48,14 @@ class ListStatusAccessibilityDelegate( val pos = recyclerView.getChildAdapterPosition(host) val status = statusProvider.getStatus(pos) ?: return if (status is StatusViewData.Concrete) { - if (status.spoilerText.isNotEmpty()) { + if (status.status.spoilerText.isNotEmpty()) { info.addAction(if (status.isExpanded) collapseCwAction else expandCwAction) } info.addAction(replyAction) val actionable = status.actionable - if (actionable.rebloggingAllowed()) { + if (actionable.isRebloggingAllowed) { info.addAction(if (actionable.reblogged) unreblogAction else reblogAction) } info.addAction(if (actionable.favourited) unfavouriteAction else favouriteAction) @@ -94,11 +94,7 @@ class ListStatusAccessibilityDelegate( } } - override fun performAccessibilityAction( - host: View, - action: Int, - args: Bundle? - ): Boolean { + override fun performAccessibilityAction(host: View, action: Int, args: Bundle?): Boolean { val pos = recyclerView.getChildAdapterPosition(host) when (action) { R.id.action_reply -> { @@ -114,7 +110,11 @@ class ListStatusAccessibilityDelegate( R.id.action_open_profile -> { interrupt() statusActionListener.onViewAccount( - (statusProvider.getStatus(pos) as StatusViewData.Concrete).actionable.account.id + ( + statusProvider.getStatus( + pos + ) as StatusViewData.Concrete + ).actionable.account.id ) } R.id.action_open_media_1 -> { diff --git a/app/src/main/java/com/keylesspalace/tusky/util/LocaleUtils.kt b/app/src/main/java/com/keylesspalace/tusky/util/LocaleUtils.kt index 655348b4d..105f99ced 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/LocaleUtils.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/LocaleUtils.kt @@ -59,7 +59,10 @@ private fun ensureLanguagesAreFirst(locales: MutableList, languages: Lis } } -fun getInitialLanguages(language: String? = null, activeAccount: AccountEntity? = null): List { +fun getInitialLanguages( + language: String? = null, + activeAccount: AccountEntity? = null +): List { val selected = listOfNotNull(language, activeAccount?.defaultPostLanguage) val system = AppCompatDelegate.getApplicationLocales().toList() + LocaleListCompat.getDefault().toList() @@ -77,3 +80,8 @@ fun getLocaleList(initialLanguages: List): List { ensureLanguagesAreFirst(locales, initialLanguages) return locales } + +fun localeNameForUntrustedISO639LangCode(code: String): String { + // It seems like it never throws? + return Locale(code).displayName +} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/MediaUtils.kt b/app/src/main/java/com/keylesspalace/tusky/util/MediaUtils.kt index b01200bb5..27c4f549b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/MediaUtils.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/MediaUtils.kt @@ -27,11 +27,10 @@ import androidx.exifinterface.media.ExifInterface import java.io.File import java.io.FileNotFoundException import java.io.IOException -import java.io.InputStream import java.text.SimpleDateFormat -import java.util.Calendar import java.util.Date import java.util.Locale +import kotlin.time.Duration.Companion.hours /** * Helper methods for obtaining and resizing media files @@ -68,16 +67,18 @@ fun getMediaSize(contentResolver: ContentResolver, uri: Uri?): Long { } @Throws(FileNotFoundException::class) -fun getImageSquarePixels(contentResolver: ContentResolver, uri: Uri): Long { - val input = contentResolver.openInputStream(uri) +fun getImageSquarePixels(contentResolver: ContentResolver, uri: Uri): Int { + val input = contentResolver.openInputStream(uri) ?: throw FileNotFoundException("Unavailable ContentProvider") val options = BitmapFactory.Options() options.inJustDecodeBounds = true - BitmapFactory.decodeStream(input, null, options) + try { + BitmapFactory.decodeStream(input, null, options) + } finally { + input.closeQuietly() + } - input.closeQuietly() - - return (options.outWidth * options.outHeight).toLong() + return options.outWidth * options.outHeight } fun calculateInSampleSize(options: BitmapFactory.Options, reqWidth: Int, reqHeight: Int): Int { @@ -147,27 +148,23 @@ fun reorientBitmap(bitmap: Bitmap?, orientation: Int): Bitmap? { } fun getImageOrientation(uri: Uri, contentResolver: ContentResolver): Int { - val inputStream: InputStream? try { - inputStream = contentResolver.openInputStream(uri) - } catch (e: FileNotFoundException) { - Log.w(TAG, e) - return ExifInterface.ORIENTATION_UNDEFINED - } - if (inputStream == null) { - return ExifInterface.ORIENTATION_UNDEFINED - } - val exifInterface: ExifInterface - try { - exifInterface = ExifInterface(inputStream) + val inputStream = contentResolver.openInputStream(uri) + ?: return ExifInterface.ORIENTATION_UNDEFINED + + try { + val exifInterface = ExifInterface(inputStream) + return exifInterface.getAttributeInt( + ExifInterface.TAG_ORIENTATION, + ExifInterface.ORIENTATION_NORMAL + ) + } finally { + inputStream.closeQuietly() + } } catch (e: IOException) { Log.w(TAG, e) - inputStream.closeQuietly() return ExifInterface.ORIENTATION_UNDEFINED } - val orientation = exifInterface.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL) - inputStream.closeQuietly() - return orientation } fun deleteStaleCachedMedia(mediaDirectory: File?) { @@ -176,12 +173,10 @@ fun deleteStaleCachedMedia(mediaDirectory: File?) { return } - val twentyfourHoursAgo = Calendar.getInstance() - twentyfourHoursAgo.add(Calendar.HOUR, -24) - val unixTime = twentyfourHoursAgo.timeInMillis + val unixTime = System.currentTimeMillis() - 24.hours.inWholeMilliseconds val files = mediaDirectory.listFiles { file -> unixTime > file.lastModified() && file.name.contains(MEDIA_TEMP_PREFIX) } - if (files == null || files.isEmpty()) { + if (files.isNullOrEmpty()) { // Nothing to do return } @@ -196,5 +191,8 @@ fun deleteStaleCachedMedia(mediaDirectory: File?) { } fun getTemporaryMediaFilename(extension: String): String { - return "${MEDIA_TEMP_PREFIX}_${SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())}.$extension" + return "${MEDIA_TEMP_PREFIX}_${SimpleDateFormat( + "yyyyMMdd_HHmmss", + Locale.US + ).format(Date())}.$extension" } diff --git a/app/src/main/java/com/keylesspalace/tusky/util/NoUnderlineURLSpan.kt b/app/src/main/java/com/keylesspalace/tusky/util/NoUnderlineURLSpan.kt index 12be84d8a..59d8bbabe 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/NoUnderlineURLSpan.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/NoUnderlineURLSpan.kt @@ -19,7 +19,7 @@ import android.text.TextPaint import android.text.style.URLSpan import android.view.View -open class NoUnderlineURLSpan constructor(val url: String) : URLSpan(url) { +open class NoUnderlineURLSpan(val url: String) : URLSpan(url) { // This should not be necessary. But if you don't do this the [StatusLengthTest] tests // fail. Without this, accessing the `url` property, or calling `getUrl()` (which should diff --git a/app/src/main/java/com/keylesspalace/tusky/util/PairedList.kt b/app/src/main/java/com/keylesspalace/tusky/util/PairedList.kt new file mode 100644 index 000000000..bdcf23925 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/PairedList.kt @@ -0,0 +1,74 @@ +package com.keylesspalace.tusky.util + +import androidx.arch.core.util.Function + +/** + * This list implementation can help to keep two lists in sync - like real models and view models. + * + * Every operation on the main list triggers update of the supplementary list (but not vice versa). + * + * This makes sure that the main list is always the source of truth. + * + * Main list is projected to the supplementary list by the passed mapper function. + * + * Paired list is newer actually exposed and clients are provided with `getPairedCopy()`, + * `getPairedItem()` and `setPairedItem()`. This prevents modifications of the + * supplementary list size so lists are always have the same length. + * + * This implementation will not try to recover from exceptional cases so lists may be out of sync + * after the exception. + * + * It is most useful with immutable data because we cannot track changes inside stored objects. + * + * @param T type of elements in the main list + * @param V type of elements in supplementary list + * @param mapper Function, which will be used to translate items from the main list to the + * supplementary one. + * @constructor + */ +class PairedList(private val mapper: Function) : AbstractMutableList() { + private val main: MutableList = ArrayList() + private val synced: MutableList = ArrayList() + + val pairedCopy: List + get() = ArrayList(synced) + + fun getPairedItem(index: Int): V { + return synced[index] + } + + fun getPairedItemOrNull(index: Int): V? { + return synced.getOrNull(index) + } + + fun setPairedItem(index: Int, element: V) { + synced[index] = element + } + + override fun get(index: Int): T { + return main[index] + } + + override fun set(index: Int, element: T): T { + synced[index] = mapper.apply(element) + return main.set(index, element) + } + + override fun add(element: T): Boolean { + synced.add(mapper.apply(element)) + return main.add(element) + } + + override fun add(index: Int, element: T) { + synced.add(index, mapper.apply(element)) + main.add(index, element) + } + + override fun removeAt(index: Int): T { + synced.removeAt(index) + return main.removeAt(index) + } + + override val size: Int + get() = main.size +} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/RelativeTimeUpdater.kt b/app/src/main/java/com/keylesspalace/tusky/util/RelativeTimeUpdater.kt new file mode 100644 index 000000000..a4dd85748 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/RelativeTimeUpdater.kt @@ -0,0 +1,37 @@ +@file:JvmName("RelativeTimeUpdater") + +package com.keylesspalace.tusky.util + +import androidx.fragment.app.Fragment +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.coroutineScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.preference.PreferenceManager +import com.keylesspalace.tusky.settings.PrefKeys +import kotlin.time.Duration.Companion.minutes +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +private val UPDATE_INTERVAL = 1.minutes + +/** + * Helper method to update adapter periodically to refresh timestamp + * if setting absoluteTimeView is false. + * Start updates when the Fragment becomes visible and stop when it is hidden. + */ +fun Fragment.updateRelativeTimePeriodically(callback: Runnable) { + val preferences = PreferenceManager.getDefaultSharedPreferences(requireContext()) + val lifecycle = viewLifecycleOwner.lifecycle + lifecycle.coroutineScope.launch { + // This child coroutine will launch each time the Fragment moves to the STARTED state + lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { + val useAbsoluteTime = preferences.getBoolean(PrefKeys.ABSOLUTE_TIME_VIEW, false) + if (!useAbsoluteTime) { + while (true) { + callback.run() + delay(UPDATE_INTERVAL) + } + } + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/Resource.kt b/app/src/main/java/com/keylesspalace/tusky/util/Resource.kt index ddc88f45d..0f212b0ca 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/Resource.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/Resource.kt @@ -1,14 +1,16 @@ package com.keylesspalace.tusky.util -sealed class Resource(open val data: T?) +sealed interface Resource { + val data: T? +} -class Loading (override val data: T? = null) : Resource(data) +class Loading(override val data: T? = null) : Resource -class Success (override val data: T? = null) : Resource(data) +class Success(override val data: T? = null) : Resource -class Error ( +class Error( override val data: T? = null, val errorMessage: String? = null, var consumed: Boolean = false, val cause: Throwable? = null -) : Resource(data) +) : Resource diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ShareShortcutHelper.kt b/app/src/main/java/com/keylesspalace/tusky/util/ShareShortcutHelper.kt index 9d8e4b238..504cb47f7 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/ShareShortcutHelper.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/ShareShortcutHelper.kt @@ -21,76 +21,95 @@ import android.content.Context import android.content.Intent import android.graphics.Bitmap import android.graphics.Canvas -import android.text.TextUtils +import android.util.Log +import androidx.appcompat.content.res.AppCompatResources import androidx.core.app.Person import androidx.core.content.pm.ShortcutInfoCompat import androidx.core.content.pm.ShortcutManagerCompat import androidx.core.graphics.drawable.IconCompat +import androidx.core.graphics.drawable.toBitmap import com.bumptech.glide.Glide +import com.bumptech.glide.load.engine.GlideException import com.keylesspalace.tusky.MainActivity import com.keylesspalace.tusky.R -import com.keylesspalace.tusky.components.notifications.NotificationHelper import com.keylesspalace.tusky.db.AccountEntity -import io.reactivex.rxjava3.core.Single -import io.reactivex.rxjava3.schedulers.Schedulers +import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.di.ApplicationScope +import javax.inject.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch -fun updateShortcut(context: Context, account: AccountEntity) { - Single.fromCallable { - val innerSize = context.resources.getDimensionPixelSize(R.dimen.adaptive_bitmap_inner_size) - val outerSize = context.resources.getDimensionPixelSize(R.dimen.adaptive_bitmap_outer_size) +class ShareShortcutHelper @Inject constructor( + private val context: Context, + private val accountManager: AccountManager, + @ApplicationScope private val externalScope: CoroutineScope +) { - val bmp = if (TextUtils.isEmpty(account.profilePictureUrl)) { - Glide.with(context) - .asBitmap() - .load(R.drawable.avatar_default) - .submit(innerSize, innerSize) - .get() - } else { - Glide.with(context) - .asBitmap() - .load(account.profilePictureUrl) - .error(R.drawable.avatar_default) - .submit(innerSize, innerSize) - .get() + fun updateShortcuts() { + externalScope.launch(Dispatchers.IO) { + val innerSize = context.resources.getDimensionPixelSize(R.dimen.adaptive_bitmap_inner_size) + val outerSize = context.resources.getDimensionPixelSize(R.dimen.adaptive_bitmap_outer_size) + + val maxNumberOfShortcuts = ShortcutManagerCompat.getMaxShortcutCountPerActivity(context) + + val shortcuts = accountManager.accounts.take(maxNumberOfShortcuts).mapNotNull { account -> + + val bmp = try { + Glide.with(context) + .asBitmap() + .load(account.profilePictureUrl) + .submitAsync(innerSize, innerSize) + } catch (e: GlideException) { + // https://github.com/bumptech/glide/issues/4672 :/ + Log.w(TAG, "failed to load avatar ${account.profilePictureUrl}", e) + AppCompatResources.getDrawable(context, R.drawable.avatar_default)?.toBitmap(innerSize, innerSize) ?: return@mapNotNull null + } + + // inset the loaded bitmap inside a 108dp transparent canvas so it looks good as adaptive icon + val outBmp = Bitmap.createBitmap(outerSize, outerSize, Bitmap.Config.ARGB_8888) + + val canvas = Canvas(outBmp) + canvas.drawBitmap( + bmp, + (outerSize - innerSize).toFloat() / 2f, + (outerSize - innerSize).toFloat() / 2f, + null + ) + + val icon = IconCompat.createWithAdaptiveBitmap(outBmp) + + val person = Person.Builder() + .setIcon(icon) + .setName(account.displayName) + .setKey(account.identifier) + .build() + + // This intent will be sent when the user clicks on one of the launcher shortcuts. Intent from share sheet will be different + val intent = Intent(context, MainActivity::class.java).apply { + action = Intent.ACTION_SEND + type = "text/plain" + putExtra(ShortcutManagerCompat.EXTRA_SHORTCUT_ID, account.id.toString()) + } + + ShortcutInfoCompat.Builder(context, account.id.toString()) + .setIntent(intent) + .setCategories(setOf("com.keylesspalace.tusky.Share")) + .setShortLabel(account.displayName) + .setPerson(person) + .setIcon(icon) + .build() + } + + ShortcutManagerCompat.setDynamicShortcuts(context, shortcuts) } - - // inset the loaded bitmap inside a 108dp transparent canvas so it looks good as adaptive icon - val outBmp = Bitmap.createBitmap(outerSize, outerSize, Bitmap.Config.ARGB_8888) - - val canvas = Canvas(outBmp) - canvas.drawBitmap(bmp, (outerSize - innerSize).toFloat() / 2f, (outerSize - innerSize).toFloat() / 2f, null) - - val icon = IconCompat.createWithAdaptiveBitmap(outBmp) - - val person = Person.Builder() - .setIcon(icon) - .setName(account.displayName) - .setKey(account.identifier) - .build() - - // This intent will be sent when the user clicks on one of the launcher shortcuts. Intent from share sheet will be different - val intent = Intent(context, MainActivity::class.java).apply { - action = Intent.ACTION_SEND - type = "text/plain" - putExtra(NotificationHelper.ACCOUNT_ID, account.id) - } - - val shortcutInfo = ShortcutInfoCompat.Builder(context, account.id.toString()) - .setIntent(intent) - .setCategories(setOf("com.keylesspalace.tusky.Share")) - .setShortLabel(account.displayName) - .setPerson(person) - .setLongLived(true) - .setIcon(icon) - .build() - - ShortcutManagerCompat.addDynamicShortcuts(context, listOf(shortcutInfo)) } - .subscribeOn(Schedulers.io()) - .onErrorReturnItem(false) - .subscribe() -} -fun removeShortcut(context: Context, account: AccountEntity) { - ShortcutManagerCompat.removeDynamicShortcuts(context, listOf(account.id.toString())) + fun removeShortcut(account: AccountEntity) { + ShortcutManagerCompat.removeDynamicShortcuts(context, listOf(account.id.toString())) + } + + companion object { + private const val TAG = "ShareShortcutHelper" + } } diff --git a/app/src/main/java/com/keylesspalace/tusky/util/Single.kt b/app/src/main/java/com/keylesspalace/tusky/util/Single.kt new file mode 100644 index 000000000..d77bdb5a7 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/Single.kt @@ -0,0 +1,29 @@ +package com.keylesspalace.tusky.util + +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.lifecycleScope +import at.connyduck.calladapter.networkresult.NetworkResult +import at.connyduck.calladapter.networkresult.fold +import java.util.function.Consumer +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch + +/** + * Simple reimplementation of RxJava's Single using a Kotlin coroutine, + * intended to be consumed by legacy Java code only. + */ +class Single(private val producer: suspend CoroutineScope.() -> NetworkResult) { + fun subscribe( + owner: LifecycleOwner, + onSuccess: Consumer, + onError: Consumer + ): Job { + return owner.lifecycleScope.launch { + producer().fold( + onSuccess = { onSuccess.accept(it) }, + onFailure = { onError.accept(it) } + ) + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/SmartLengthInputFilter.kt b/app/src/main/java/com/keylesspalace/tusky/util/SmartLengthInputFilter.kt index c7b583e57..c5e7e1a08 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/SmartLengthInputFilter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/SmartLengthInputFilter.kt @@ -15,6 +15,7 @@ package com.keylesspalace.tusky.util +import android.icu.text.BreakIterator import android.text.InputFilter import android.text.SpannableStringBuilder import android.text.Spanned @@ -54,7 +55,14 @@ fun shouldTrimStatus(message: Spanned): Boolean { */ object SmartLengthInputFilter : InputFilter { /** {@inheritDoc} */ - override fun filter(source: CharSequence, start: Int, end: Int, dest: Spanned, dstart: Int, dend: Int): CharSequence? { + override fun filter( + source: CharSequence, + start: Int, + end: Int, + dest: Spanned, + dstart: Int, + dend: Int + ): CharSequence? { // Code originally imported from InputFilter.LengthFilter but heavily customized and converted to Kotlin. // https://android.googlesource.com/platform/frameworks/base/+/master/core/java/android/text/InputFilter.java#175 @@ -72,20 +80,10 @@ object SmartLengthInputFilter : InputFilter { if (source[keep].isLetterOrDigit()) { var boundary: Int - // Android N+ offer a clone of the ICU APIs in Java for better internationalization and - // unicode support. Using the ICU version of BreakIterator grants better support for - // those without having to add the ICU4J library at a minimum Api trade-off. - if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.N) { - val iterator = android.icu.text.BreakIterator.getWordInstance() - iterator.setText(source.toString()) - boundary = iterator.following(keep) - if (keep - boundary > RUNWAY) boundary = iterator.preceding(keep) - } else { - val iterator = java.text.BreakIterator.getWordInstance() - iterator.setText(source.toString()) - boundary = iterator.following(keep) - if (keep - boundary > RUNWAY) boundary = iterator.preceding(keep) - } + val iterator = BreakIterator.getWordInstance() + iterator.setText(source.toString()) + boundary = iterator.following(keep) + if (keep - boundary > RUNWAY) boundary = iterator.preceding(keep) keep = boundary } else { diff --git a/app/src/main/java/com/keylesspalace/tusky/util/SpanUtils.kt b/app/src/main/java/com/keylesspalace/tusky/util/SpanUtils.kt index f89f5fea5..faa671a3c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/SpanUtils.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/SpanUtils.kt @@ -1,7 +1,6 @@ package com.keylesspalace.tusky.util import android.content.Context -import android.os.Build import android.text.Spannable import android.text.SpannableStringBuilder import android.text.Spanned @@ -36,14 +35,17 @@ private const val HTTPS_URL_REGEX = "(?:(^|\\b)https://[^\\s]+)" /** * Dump of android.util.Patterns.WEB_URL */ -private val STRICT_WEB_URL_PATTERN = Pattern.compile("(((?:(?i:http|https|rtsp)://(?:(?:[a-zA-Z0-9\\\$\\-\\_\\.\\+\\!\\*\\'\\(\\)\\,\\;\\?\\&\\=]|(?:\\%[a-fA-F0-9]{2})){1,64}(?:\\:(?:[a-zA-Z0-9\\\$\\-\\_\\.\\+\\!\\*\\'\\(\\)\\,\\;\\?\\&\\=]|(?:\\%[a-fA-F0-9]{2})){1,25})?\\@)?)?(?:(([a-zA-Z0-9[ -\uD7FF豈-\uFDCFﷰ-\uFFEF\uD800\uDC00-\uD83F\uDFFD\uD840\uDC00-\uD87F\uDFFD\uD880\uDC00-\uD8BF\uDFFD\uD8C0\uDC00-\uD8FF\uDFFD\uD900\uDC00-\uD93F\uDFFD\uD940\uDC00-\uD97F\uDFFD\uD980\uDC00-\uD9BF\uDFFD\uD9C0\uDC00-\uD9FF\uDFFD\uDA00\uDC00-\uDA3F\uDFFD\uDA40\uDC00-\uDA7F\uDFFD\uDA80\uDC00-\uDABF\uDFFD\uDAC0\uDC00-\uDAFF\uDFFD\uDB00\uDC00-\uDB3F\uDFFD\uDB44\uDC00-\uDB7F\uDFFD&&[^ [ - ]\u2028\u2029  ]]](?:[a-zA-Z0-9[ -\uD7FF豈-\uFDCFﷰ-\uFFEF\uD800\uDC00-\uD83F\uDFFD\uD840\uDC00-\uD87F\uDFFD\uD880\uDC00-\uD8BF\uDFFD\uD8C0\uDC00-\uD8FF\uDFFD\uD900\uDC00-\uD93F\uDFFD\uD940\uDC00-\uD97F\uDFFD\uD980\uDC00-\uD9BF\uDFFD\uD9C0\uDC00-\uD9FF\uDFFD\uDA00\uDC00-\uDA3F\uDFFD\uDA40\uDC00-\uDA7F\uDFFD\uDA80\uDC00-\uDABF\uDFFD\uDAC0\uDC00-\uDAFF\uDFFD\uDB00\uDC00-\uDB3F\uDFFD\uDB44\uDC00-\uDB7F\uDFFD&&[^ [ - ]\u2028\u2029  ]]_\\-]{0,61}[a-zA-Z0-9[ -\uD7FF豈-\uFDCFﷰ-\uFFEF\uD800\uDC00-\uD83F\uDFFD\uD840\uDC00-\uD87F\uDFFD\uD880\uDC00-\uD8BF\uDFFD\uD8C0\uDC00-\uD8FF\uDFFD\uD900\uDC00-\uD93F\uDFFD\uD940\uDC00-\uD97F\uDFFD\uD980\uDC00-\uD9BF\uDFFD\uD9C0\uDC00-\uD9FF\uDFFD\uDA00\uDC00-\uDA3F\uDFFD\uDA40\uDC00-\uDA7F\uDFFD\uDA80\uDC00-\uDABF\uDFFD\uDAC0\uDC00-\uDAFF\uDFFD\uDB00\uDC00-\uDB3F\uDFFD\uDB44\uDC00-\uDB7F\uDFFD&&[^ [ - ]\u2028\u2029  ]]]){0,1}\\.)+(xn\\-\\-[\\w\\-]{0,58}\\w|[a-zA-Z[ -\uD7FF豈-\uFDCFﷰ-\uFFEF\uD800\uDC00-\uD83F\uDFFD\uD840\uDC00-\uD87F\uDFFD\uD880\uDC00-\uD8BF\uDFFD\uD8C0\uDC00-\uD8FF\uDFFD\uD900\uDC00-\uD93F\uDFFD\uD940\uDC00-\uD97F\uDFFD\uD980\uDC00-\uD9BF\uDFFD\uD9C0\uDC00-\uD9FF\uDFFD\uDA00\uDC00-\uDA3F\uDFFD\uDA40\uDC00-\uDA7F\uDFFD\uDA80\uDC00-\uDABF\uDFFD\uDAC0\uDC00-\uDAFF\uDFFD\uDB00\uDC00-\uDB3F\uDFFD\uDB44\uDC00-\uDB7F\uDFFD&&[^ [ - ]\u2028\u2029  ]]]{2,63})|((25[0-5]|2[0-4][0-9]|[0-1][0-9]{2}|[1-9][0-9]|[1-9])\\.(25[0-5]|2[0-4][0-9]|[0-1][0-9]{2}|[1-9][0-9]|[1-9]|0)\\.(25[0-5]|2[0-4][0-9]|[0-1][0-9]{2}|[1-9][0-9]|[1-9]|0)\\.(25[0-5]|2[0-4][0-9]|[0-1][0-9]{2}|[1-9][0-9]|[0-9]))))(?:\\:\\d{1,5})?)([/\\?](?:(?:[a-zA-Z0-9[ -\uD7FF豈-\uFDCFﷰ-\uFFEF\uD800\uDC00-\uD83F\uDFFD\uD840\uDC00-\uD87F\uDFFD\uD880\uDC00-\uD8BF\uDFFD\uD8C0\uDC00-\uD8FF\uDFFD\uD900\uDC00-\uD93F\uDFFD\uD940\uDC00-\uD97F\uDFFD\uD980\uDC00-\uD9BF\uDFFD\uD9C0\uDC00-\uD9FF\uDFFD\uDA00\uDC00-\uDA3F\uDFFD\uDA40\uDC00-\uDA7F\uDFFD\uDA80\uDC00-\uDABF\uDFFD\uDAC0\uDC00-\uDAFF\uDFFD\uDB00\uDC00-\uDB3F\uDFFD\uDB44\uDC00-\uDB7F\uDFFD&&[^ [ - ]\u2028\u2029  ]];/\\?:@&=#~\\-\\.\\+!\\*'\\(\\),_\\\$])|(?:%[a-fA-F0-9]{2}))*)?(?:\\b|\$|^))") +private val STRICT_WEB_URL_PATTERN = Pattern.compile( + "(((?:(?i:http|https|rtsp)://(?:(?:[a-zA-Z0-9\\\$\\-\\_\\.\\+\\!\\*\\'\\(\\)\\,\\;\\?\\&\\=]|(?:\\%[a-fA-F0-9]{2})){1,64}(?:\\:(?:[a-zA-Z0-9\\\$\\-\\_\\.\\+\\!\\*\\'\\(\\)\\,\\;\\?\\&\\=]|(?:\\%[a-fA-F0-9]{2})){1,25})?\\@)?)?(?:(([a-zA-Z0-9[ -\uD7FF豈-\uFDCFﷰ-\uFFEF\uD800\uDC00-\uD83F\uDFFD\uD840\uDC00-\uD87F\uDFFD\uD880\uDC00-\uD8BF\uDFFD\uD8C0\uDC00-\uD8FF\uDFFD\uD900\uDC00-\uD93F\uDFFD\uD940\uDC00-\uD97F\uDFFD\uD980\uDC00-\uD9BF\uDFFD\uD9C0\uDC00-\uD9FF\uDFFD\uDA00\uDC00-\uDA3F\uDFFD\uDA40\uDC00-\uDA7F\uDFFD\uDA80\uDC00-\uDABF\uDFFD\uDAC0\uDC00-\uDAFF\uDFFD\uDB00\uDC00-\uDB3F\uDFFD\uDB44\uDC00-\uDB7F\uDFFD&&[^ [ - ]\u2028\u2029  ]]](?:[a-zA-Z0-9[ -\uD7FF豈-\uFDCFﷰ-\uFFEF\uD800\uDC00-\uD83F\uDFFD\uD840\uDC00-\uD87F\uDFFD\uD880\uDC00-\uD8BF\uDFFD\uD8C0\uDC00-\uD8FF\uDFFD\uD900\uDC00-\uD93F\uDFFD\uD940\uDC00-\uD97F\uDFFD\uD980\uDC00-\uD9BF\uDFFD\uD9C0\uDC00-\uD9FF\uDFFD\uDA00\uDC00-\uDA3F\uDFFD\uDA40\uDC00-\uDA7F\uDFFD\uDA80\uDC00-\uDABF\uDFFD\uDAC0\uDC00-\uDAFF\uDFFD\uDB00\uDC00-\uDB3F\uDFFD\uDB44\uDC00-\uDB7F\uDFFD&&[^ [ - ]\u2028\u2029  ]]_\\-]{0,61}[a-zA-Z0-9[ -\uD7FF豈-\uFDCFﷰ-\uFFEF\uD800\uDC00-\uD83F\uDFFD\uD840\uDC00-\uD87F\uDFFD\uD880\uDC00-\uD8BF\uDFFD\uD8C0\uDC00-\uD8FF\uDFFD\uD900\uDC00-\uD93F\uDFFD\uD940\uDC00-\uD97F\uDFFD\uD980\uDC00-\uD9BF\uDFFD\uD9C0\uDC00-\uD9FF\uDFFD\uDA00\uDC00-\uDA3F\uDFFD\uDA40\uDC00-\uDA7F\uDFFD\uDA80\uDC00-\uDABF\uDFFD\uDAC0\uDC00-\uDAFF\uDFFD\uDB00\uDC00-\uDB3F\uDFFD\uDB44\uDC00-\uDB7F\uDFFD&&[^ [ - ]\u2028\u2029  ]]]){0,1}\\.)+(xn\\-\\-[\\w\\-]{0,58}\\w|[a-zA-Z[ -\uD7FF豈-\uFDCFﷰ-\uFFEF\uD800\uDC00-\uD83F\uDFFD\uD840\uDC00-\uD87F\uDFFD\uD880\uDC00-\uD8BF\uDFFD\uD8C0\uDC00-\uD8FF\uDFFD\uD900\uDC00-\uD93F\uDFFD\uD940\uDC00-\uD97F\uDFFD\uD980\uDC00-\uD9BF\uDFFD\uD9C0\uDC00-\uD9FF\uDFFD\uDA00\uDC00-\uDA3F\uDFFD\uDA40\uDC00-\uDA7F\uDFFD\uDA80\uDC00-\uDABF\uDFFD\uDAC0\uDC00-\uDAFF\uDFFD\uDB00\uDC00-\uDB3F\uDFFD\uDB44\uDC00-\uDB7F\uDFFD&&[^ [ - ]\u2028\u2029  ]]]{2,63})|((25[0-5]|2[0-4][0-9]|[0-1][0-9]{2}|[1-9][0-9]|[1-9])\\.(25[0-5]|2[0-4][0-9]|[0-1][0-9]{2}|[1-9][0-9]|[1-9]|0)\\.(25[0-5]|2[0-4][0-9]|[0-1][0-9]{2}|[1-9][0-9]|[1-9]|0)\\.(25[0-5]|2[0-4][0-9]|[0-1][0-9]{2}|[1-9][0-9]|[0-9]))))(?:\\:\\d{1,5})?)([/\\?](?:(?:[a-zA-Z0-9[ -\uD7FF豈-\uFDCFﷰ-\uFFEF\uD800\uDC00-\uD83F\uDFFD\uD840\uDC00-\uD87F\uDFFD\uD880\uDC00-\uD8BF\uDFFD\uD8C0\uDC00-\uD8FF\uDFFD\uD900\uDC00-\uD93F\uDFFD\uD940\uDC00-\uD97F\uDFFD\uD980\uDC00-\uD9BF\uDFFD\uD9C0\uDC00-\uD9FF\uDFFD\uDA00\uDC00-\uDA3F\uDFFD\uDA40\uDC00-\uDA7F\uDFFD\uDA80\uDC00-\uDABF\uDFFD\uDAC0\uDC00-\uDAFF\uDFFD\uDB00\uDC00-\uDB3F\uDFFD\uDB44\uDC00-\uDB7F\uDFFD&&[^ [ - ]\u2028\u2029  ]];/\\?:@&=#~\\-\\.\\+!\\*'\\(\\),_\\\$])|(?:%[a-fA-F0-9]{2}))*)?(?:\\b|\$|^))" +) private val spanClasses = listOf(ForegroundColorSpan::class.java, URLSpan::class.java) private val finders = mapOf( FoundMatchType.HTTP_URL to PatternFinder(':', HTTP_URL_REGEX, 5, Character::isWhitespace), FoundMatchType.HTTPS_URL to PatternFinder(':', HTTPS_URL_REGEX, 6, Character::isWhitespace), FoundMatchType.TAG to PatternFinder('#', TAG_REGEX, 1, ::isValidForTagPrefix), - FoundMatchType.MENTION to PatternFinder('@', MENTION_REGEX, 1, Character::isWhitespace) // TODO: We also need a proper validator for mentions + // TODO: We also need a proper validator for mentions + FoundMatchType.MENTION to PatternFinder('@', MENTION_REGEX, 1, Character::isWhitespace) ) private enum class FoundMatchType { @@ -88,7 +90,12 @@ fun highlightSpans(text: Spannable, colour: Int) { start = found.start end = found.end if (start in 0 until end) { - text.setSpan(getSpan(found.matchType, string, colour, start, end), start, end, Spanned.SPAN_INCLUSIVE_EXCLUSIVE) + text.setSpan( + getSpan(found.matchType, string, colour, start, end), + start, + end, + Spanned.SPAN_INCLUSIVE_EXCLUSIVE + ) start += finders[found.matchType]!!.searchPrefixWidth } } @@ -98,8 +105,6 @@ fun highlightSpans(text: Spannable, colour: Int) { * Replaces text of the form [iconics name] with their spanned counterparts (ImageSpan). */ fun addDrawables(text: CharSequence, color: Int, size: Int, context: Context): Spannable { - val alignment = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) DynamicDrawableSpan.ALIGN_CENTER else DynamicDrawableSpan.ALIGN_BASELINE - val builder = SpannableStringBuilder(text) val pattern = Pattern.compile("\\[iconics ([0-9a-z_]+)\\]") @@ -112,7 +117,7 @@ fun addDrawables(text: CharSequence, color: Int, size: Int, context: Context): S drawable.setBounds(0, 0, size, size) drawable.setTint(color) - builder.setSpan(ImageSpan(drawable, alignment), matcher.start(), matcher.end(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + builder.setSpan(ImageSpan(drawable, DynamicDrawableSpan.ALIGN_BASELINE), matcher.start(), matcher.end(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) } return builder @@ -128,7 +133,7 @@ private fun findPattern(string: String, fromIndex: Int): FindCharsResult { val result = FindCharsResult() for (i in fromIndex..string.lastIndex) { val c = string[i] - for (matchType in FoundMatchType.values()) { + for (matchType in FoundMatchType.entries) { val finder = finders[matchType] if (finder!!.searchCharacter == c && ( @@ -184,7 +189,13 @@ private fun findEndOfPattern(string: String, result: FindCharsResult, pattern: P } } -private fun getSpan(matchType: FoundMatchType, string: String, colour: Int, start: Int, end: Int): CharacterStyle { +private fun getSpan( + matchType: FoundMatchType, + string: String, + colour: Int, + start: Int, + end: Int +): CharacterStyle { return when (matchType) { FoundMatchType.HTTP_URL -> NoUnderlineURLSpan(string.substring(start, end)) FoundMatchType.HTTPS_URL -> NoUnderlineURLSpan(string.substring(start, end)) diff --git a/app/src/main/java/com/keylesspalace/tusky/util/StatusDisplayOptions.kt b/app/src/main/java/com/keylesspalace/tusky/util/StatusDisplayOptions.kt index 7151f93ac..e55e1706d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/StatusDisplayOptions.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/StatusDisplayOptions.kt @@ -53,11 +53,7 @@ data class StatusDisplayOptions( /** * @return a new StatusDisplayOptions adapted to whichever preference changed. */ - fun make( - preferences: SharedPreferences, - key: String, - account: AccountEntity - ) = when (key) { + fun make(preferences: SharedPreferences, key: String, account: AccountEntity) = when (key) { PrefKeys.ANIMATE_GIF_AVATARS -> copy( animateAvatars = preferences.getBoolean(key, false) ) @@ -91,7 +87,9 @@ data class StatusDisplayOptions( PrefKeys.ALWAYS_OPEN_SPOILER -> copy( openSpoiler = account.alwaysOpenSpoiler ) - else -> { this } + else -> { + this + } } companion object { diff --git a/app/src/main/java/com/keylesspalace/tusky/util/StatusParsingHelper.kt b/app/src/main/java/com/keylesspalace/tusky/util/StatusParsingHelper.kt index b8c3c6a0d..56d60e954 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/StatusParsingHelper.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/StatusParsingHelper.kt @@ -17,19 +17,24 @@ package com.keylesspalace.tusky.util +import android.text.Editable import android.text.Html.TagHandler +import android.text.Spannable import android.text.SpannableStringBuilder import android.text.Spanned +import android.text.style.TypefaceSpan import androidx.core.text.parseAsHtml +import org.xml.sax.XMLReader /** * parse a String containing html from the Mastodon api to Spanned */ @JvmOverloads -fun String.parseAsMastodonHtml(tagHandler: TagHandler? = null): Spanned { +fun String.parseAsMastodonHtml(tagHandler: TagHandler? = tuskyTagHandler): Spanned { return this.replace("
", "
 ") .replace("
", "
 ") .replace("
", "
 ") + .replace("\n", "
") .replace(" ", "  ") .parseAsHtml(tagHandler = tagHandler) /* Html.fromHtml returns trailing whitespace if the html ends in a

tag, which @@ -37,30 +42,53 @@ fun String.parseAsMastodonHtml(tagHandler: TagHandler? = null): Spanned { .trimTrailingWhitespace() } -fun replaceCrashingCharacters(content: Spanned): Spanned { - return replaceCrashingCharacters(content as CharSequence) as Spanned -} +val tuskyTagHandler = TuskyTagHandler() -fun replaceCrashingCharacters(content: CharSequence): CharSequence? { - var replacing = false - var builder: SpannableStringBuilder? = null - val length = content.length - for (index in 0 until length) { - val character = content[index] +open class TuskyTagHandler : TagHandler { - // If there are more than one or two, switch to a map - if (character == SOFT_HYPHEN) { - if (!replacing) { - replacing = true - builder = SpannableStringBuilder(content, 0, index) + class Code + + override fun handleTag(opening: Boolean, tag: String, output: Editable, xmlReader: XMLReader) { + when (tag) { + "code" -> { + if (opening) { + start(output as SpannableStringBuilder, Code()) + } else { + end( + output as SpannableStringBuilder, + Code::class.java, + TypefaceSpan("monospace") + ) + } } - builder!!.append(ASCII_HYPHEN) - } else if (replacing) { - builder!!.append(character) } } - return if (replacing) builder else content -} -private const val SOFT_HYPHEN = '\u00ad' -private const val ASCII_HYPHEN = '-' + /** @return the last span in [text] of type [kind], or null if that kind is not in text */ + protected fun getLast(text: Spanned, kind: Class): Any? { + val spans = text.getSpans(0, text.length, kind) + return spans?.get(spans.size - 1) + } + + /** + * Mark the start of a span of [text] with [mark] so it can be discovered later by [end]. + */ + protected fun start(text: SpannableStringBuilder, mark: Any) { + val len = text.length + text.setSpan(mark, len, len, Spannable.SPAN_MARK_MARK) + } + + /** + * Set a [span] over the [text] from the point recently marked with [mark] to the end + * of the text. + */ + protected fun end(text: SpannableStringBuilder, mark: Class, span: Any) { + val len = text.length + val obj = getLast(text, mark) + val where = text.getSpanStart(obj) + text.removeSpan(obj) + if (where != len) { + text.setSpan(span, where, len, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/StatusViewHelper.kt b/app/src/main/java/com/keylesspalace/tusky/util/StatusViewHelper.kt index 2148a3b4c..db56aea4f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/StatusViewHelper.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/StatusViewHelper.kt @@ -68,7 +68,9 @@ class StatusViewHelper(private val itemView: View) { itemView.findViewById(R.id.status_media_overlay_3) ) - val sensitiveMediaWarning = itemView.findViewById(R.id.status_sensitive_media_warning) + val sensitiveMediaWarning = itemView.findViewById( + R.id.status_sensitive_media_warning + ) val sensitiveMediaShow = itemView.findViewById(R.id.status_sensitive_media_button) val mediaLabel = itemView.findViewById(R.id.status_media_label) if (statusDisplayOptions.mediaPreviewEnabled) { @@ -86,7 +88,10 @@ class StatusViewHelper(private val itemView: View) { return } - val mediaPreviewUnloaded = ColorDrawable(MaterialColors.getColor(context, R.attr.colorBackgroundAccent, Color.BLACK)) + val mediaPreviewUnloaded = + ColorDrawable( + MaterialColors.getColor(context, R.attr.colorBackgroundAccent, Color.BLACK) + ) val n = min(attachments.size, Status.MAX_MEDIA_ATTACHMENTS) @@ -246,7 +251,9 @@ class StatusViewHelper(private val itemView: View) { private fun getLabelTypeText(context: Context, type: Attachment.Type): String { return when (type) { Attachment.Type.IMAGE -> context.getString(R.string.post_media_images) - Attachment.Type.GIFV, Attachment.Type.VIDEO -> context.getString(R.string.post_media_video) + Attachment.Type.GIFV, Attachment.Type.VIDEO -> context.getString( + R.string.post_media_video + ) Attachment.Type.AUDIO -> context.getString(R.string.post_media_audio) else -> context.getString(R.string.post_media_attachments) } @@ -262,7 +269,11 @@ class StatusViewHelper(private val itemView: View) { } } - fun setupPollReadonly(poll: PollViewData?, emojis: List, statusDisplayOptions: StatusDisplayOptions) { + fun setupPollReadonly( + poll: PollViewData?, + emojis: List, + statusDisplayOptions: StatusDisplayOptions + ) { val pollResults = listOf( itemView.findViewById(R.id.status_poll_option_result_0), itemView.findViewById(R.id.status_poll_option_result_1), @@ -287,7 +298,12 @@ class StatusViewHelper(private val itemView: View) { } } - private fun getPollInfoText(timestamp: Long, poll: PollViewData, pollDescription: TextView, useAbsoluteTime: Boolean): CharSequence { + private fun getPollInfoText( + timestamp: Long, + poll: PollViewData, + pollDescription: TextView, + useAbsoluteTime: Boolean + ): CharSequence { val context = pollDescription.context val votesText = if (poll.votersCount == null) { @@ -301,7 +317,10 @@ class StatusViewHelper(private val itemView: View) { context.getString(R.string.poll_info_closed) } else { if (useAbsoluteTime) { - context.getString(R.string.poll_info_time_absolute, absoluteTimeFormatter.format(poll.expiresAt, false)) + context.getString( + R.string.poll_info_time_absolute, + absoluteTimeFormatter.format(poll.expiresAt, false) + ) } else { formatPollDuration(context, poll.expiresAt!!.time, timestamp) } @@ -310,14 +329,26 @@ class StatusViewHelper(private val itemView: View) { return context.getString(R.string.poll_info_format, votesText, pollDurationInfo) } - private fun setupPollResult(poll: PollViewData, emojis: List, pollResults: List, animateEmojis: Boolean) { + private fun setupPollResult( + poll: PollViewData, + emojis: List, + pollResults: List, + animateEmojis: Boolean + ) { val options = poll.options for (i in 0 until Status.MAX_POLL_OPTIONS) { if (i < options.size) { - val percent = calculatePercent(options[i].votesCount, poll.votersCount, poll.votesCount) + val percent = + calculatePercent(options[i].votesCount, poll.votersCount, poll.votesCount) - val pollOptionText = buildDescription(options[i].title, percent, options[i].voted, pollResults[i].context) + val pollOptionText = + buildDescription( + options[i].title, + percent, + options[i].voted, + pollResults[i].context + ) pollResults[i].text = pollOptionText.emojify(emojis, pollResults[i], animateEmojis) pollResults[i].visibility = View.VISIBLE diff --git a/app/src/main/java/com/keylesspalace/tusky/util/StringUtils.kt b/app/src/main/java/com/keylesspalace/tusky/util/StringUtils.kt index 7a4a3659d..3a9388d65 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/StringUtils.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/StringUtils.kt @@ -3,15 +3,14 @@ package com.keylesspalace.tusky.util import android.text.Spanned -import java.util.Random +import kotlin.random.Random private const val POSSIBLE_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ" fun randomAlphanumericString(count: Int): String { val chars = CharArray(count) - val random = Random() for (i in 0 until count) { - chars[i] = POSSIBLE_CHARS[random.nextInt(POSSIBLE_CHARS.length)] + chars[i] = POSSIBLE_CHARS[Random.nextInt(POSSIBLE_CHARS.length)] } return String(chars) } diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ThemeUtils.kt b/app/src/main/java/com/keylesspalace/tusky/util/ThemeUtils.kt index fe556e5a2..fc7df19df 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/ThemeUtils.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/ThemeUtils.kt @@ -17,6 +17,7 @@ package com.keylesspalace.tusky.util import android.content.Context +import android.content.res.Configuration import android.graphics.Color import android.graphics.PorterDuff import android.graphics.drawable.Drawable @@ -24,19 +25,13 @@ import androidx.annotation.AttrRes import androidx.appcompat.app.AppCompatDelegate import androidx.core.content.res.use import com.google.android.material.color.MaterialColors +import com.keylesspalace.tusky.settings.AppTheme /** * Provides runtime compatibility to obtain theme information and re-theme views, especially where * the ability to do so is not supported in resource files. */ -private const val THEME_NIGHT = "night" -private const val THEME_DAY = "day" -private const val THEME_BLACK = "black" -private const val THEME_AUTO = "auto" -private const val THEME_SYSTEM = "auto_system" -const val APP_THEME_DEFAULT = THEME_SYSTEM - fun getDimension(context: Context, @AttrRes attribute: Int): Int { return context.obtainStyledAttributes(intArrayOf(attribute)).use { array -> array.getDimensionPixelSize(0, -1) @@ -52,16 +47,28 @@ fun setDrawableTint(context: Context, drawable: Drawable, @AttrRes attribute: In fun setAppNightMode(flavor: String?) { when (flavor) { - THEME_NIGHT, THEME_BLACK -> AppCompatDelegate.setDefaultNightMode( + AppTheme.NIGHT.value, AppTheme.BLACK.value -> AppCompatDelegate.setDefaultNightMode( AppCompatDelegate.MODE_NIGHT_YES ) - THEME_DAY -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO) - THEME_AUTO -> AppCompatDelegate.setDefaultNightMode( + AppTheme.DAY.value -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO) + AppTheme.AUTO.value -> AppCompatDelegate.setDefaultNightMode( AppCompatDelegate.MODE_NIGHT_AUTO_TIME ) - THEME_SYSTEM -> AppCompatDelegate.setDefaultNightMode( + AppTheme.AUTO_SYSTEM.value, AppTheme.AUTO_SYSTEM_BLACK.value -> AppCompatDelegate.setDefaultNightMode( AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM ) - else -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES) + else -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM) + } +} + +fun isBlack(config: Configuration, theme: String?): Boolean { + return when (theme) { + AppTheme.BLACK.value -> true + AppTheme.AUTO_SYSTEM_BLACK.value -> when (config.uiMode and Configuration.UI_MODE_NIGHT_MASK) { + Configuration.UI_MODE_NIGHT_NO -> false + Configuration.UI_MODE_NIGHT_YES -> true + else -> false + } + else -> false } } diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ThrowableExtensions.kt b/app/src/main/java/com/keylesspalace/tusky/util/ThrowableExtensions.kt index a3811a358..8cf894dab 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/ThrowableExtensions.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/ThrowableExtensions.kt @@ -2,10 +2,10 @@ package com.keylesspalace.tusky.util import android.content.Context import com.keylesspalace.tusky.R +import java.io.IOException import org.json.JSONException import org.json.JSONObject import retrofit2.HttpException -import java.io.IOException /** * checks if this throwable indicates an error causes by a 4xx/5xx server response and @@ -30,9 +30,9 @@ fun Throwable.getServerErrorMessage(): String? { /** @return A drawable resource to accompany the error message for this throwable */ fun Throwable.getDrawableRes(): Int = when (this) { - is IOException -> R.drawable.elephant_offline - is HttpException -> R.drawable.elephant_offline - else -> R.drawable.elephant_error + is IOException -> R.drawable.errorphant_offline + is HttpException -> R.drawable.errorphant_offline + else -> R.drawable.errorphant_error } /** @return A string error message for this throwable */ @@ -40,3 +40,5 @@ fun Throwable.getErrorString(context: Context): String = getServerErrorMessage() is IOException -> context.getString(R.string.error_network) else -> context.getString(R.string.error_generic) } + +fun Throwable.isHttpNotFound(): Boolean = (this as? HttpException)?.code() == 404 diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ViewBindingExtensions.kt b/app/src/main/java/com/keylesspalace/tusky/util/ViewBindingExtensions.kt index f398e2b6a..4ea3edeab 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/ViewBindingExtensions.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/ViewBindingExtensions.kt @@ -4,16 +4,14 @@ import android.view.LayoutInflater import android.view.View import androidx.appcompat.app.AppCompatActivity import androidx.fragment.app.Fragment -import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.Observer import androidx.viewbinding.ViewBinding -import kotlin.properties.ReadOnlyProperty -import kotlin.reflect.KProperty /** - * https://medium.com/@Zhuinden/simple-one-liner-viewbinding-in-fragments-and-activities-with-kotlin-961430c6c07c + * Original code: https://medium.com/@Zhuinden/simple-one-liner-viewbinding-in-fragments-and-activities-with-kotlin-961430c6c07c + * Refactor: https://bladecoder.medium.com/viewlifecyclelazy-and-other-ways-to-avoid-view-memory-leaks-in-android-fragments-4aa982e6e579 */ inline fun AppCompatActivity.viewBinding( @@ -22,49 +20,32 @@ inline fun AppCompatActivity.viewBinding( bindingInflater(layoutInflater) } -class FragmentViewBindingDelegate( - val fragment: Fragment, - val viewBindingFactory: (View) -> T -) : ReadOnlyProperty { - private var binding: T? = null +private class ViewLifecycleLazy( + private val fragment: Fragment, + private val initializer: (View) -> T +) : Lazy, LifecycleEventObserver { + private var cached: T? = null - init { - fragment.lifecycle.addObserver(object : DefaultLifecycleObserver { - val viewLifecycleOwnerLiveDataObserver = - Observer { - val viewLifecycleOwner = it ?: return@Observer - - viewLifecycleOwner.lifecycle.addObserver(object : DefaultLifecycleObserver { - override fun onDestroy(owner: LifecycleOwner) { - binding = null - } - }) - } - - override fun onCreate(owner: LifecycleOwner) { - fragment.viewLifecycleOwnerLiveData.observeForever(viewLifecycleOwnerLiveDataObserver) + override val value: T + get() { + return cached ?: run { + val newValue = initializer(fragment.requireView()) + cached = newValue + fragment.viewLifecycleOwner.lifecycle.addObserver(this) + newValue } - - override fun onDestroy(owner: LifecycleOwner) { - fragment.viewLifecycleOwnerLiveData.removeObserver(viewLifecycleOwnerLiveDataObserver) - } - }) - } - - override fun getValue(thisRef: Fragment, property: KProperty<*>): T { - val binding = binding - if (binding != null) { - return binding } - val lifecycle = fragment.viewLifecycleOwner.lifecycle - if (!lifecycle.currentState.isAtLeast(Lifecycle.State.INITIALIZED)) { - throw IllegalStateException("Should not attempt to get bindings when Fragment views are destroyed.") - } + override fun isInitialized() = cached != null - return viewBindingFactory(thisRef.requireView()).also { this.binding = it } + override fun toString() = cached.toString() + + override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) { + if (event == Lifecycle.Event.ON_DESTROY) { + cached = null + } } } -fun Fragment.viewBinding(viewBindingFactory: (View) -> T) = - FragmentViewBindingDelegate(this, viewBindingFactory) +fun Fragment.viewBinding(viewBindingFactory: (View) -> T): Lazy = + ViewLifecycleLazy(this, viewBindingFactory) diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.kt b/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.kt index f6ae9e7d7..0975b00f1 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.kt @@ -31,36 +31,43 @@ * * You should have received a copy of the GNU General Public License along with Tusky; if not, * see . */ + package com.keylesspalace.tusky.util +import androidx.paging.CombinedLoadStates +import androidx.paging.LoadState import com.keylesspalace.tusky.entity.Notification import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.TrendingTag import com.keylesspalace.tusky.viewdata.NotificationViewData import com.keylesspalace.tusky.viewdata.StatusViewData +import com.keylesspalace.tusky.viewdata.TranslationViewData import com.keylesspalace.tusky.viewdata.TrendingViewData fun Status.toViewData( isShowingContent: Boolean, isExpanded: Boolean, isCollapsed: Boolean, - isDetailed: Boolean = false + isDetailed: Boolean = false, + translation: TranslationViewData? = null, ): StatusViewData.Concrete { return StatusViewData.Concrete( status = this, isShowingContent = isShowingContent, isCollapsed = isCollapsed, isExpanded = isExpanded, - isDetailed = isDetailed + isDetailed = isDetailed, + translation = translation, ) } +@JvmName("notificationToViewData") fun Notification.toViewData( isShowingContent: Boolean, isExpanded: Boolean, isCollapsed: Boolean -): NotificationViewData { - return NotificationViewData( +): NotificationViewData.Concrete { + return NotificationViewData.Concrete( this.type, this.id, this.account, @@ -86,3 +93,7 @@ fun List.toViewData(): List { ) } } + +fun CombinedLoadStates.isAnyLoading(): Boolean { + return this.refresh == LoadState.Loading || this.append == LoadState.Loading || this.prepend == LoadState.Loading +} diff --git a/app/src/main/java/com/keylesspalace/tusky/view/AdaptiveTabLayout.kt b/app/src/main/java/com/keylesspalace/tusky/view/AdaptiveTabLayout.kt new file mode 100644 index 000000000..800165f90 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/view/AdaptiveTabLayout.kt @@ -0,0 +1,41 @@ +package com.keylesspalace.tusky.view + +import android.content.Context +import android.util.AttributeSet +import android.view.ViewGroup +import com.google.android.material.tabs.TabLayout + +/** + * Workaround for "auto" mode not behaving as expected. + * + * Switches the tab display mode depending on available size: start out with "scrollable" but + * if there is enough room switch to "fixed" (and re-measure). + * + * Idea taken from https://stackoverflow.com/a/44894143 + */ +class AdaptiveTabLayout @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : TabLayout(context, attrs, defStyleAttr) { + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + tabMode = MODE_SCROLLABLE // make sure to only measure the "minimum width" + super.onMeasure(widthMeasureSpec, heightMeasureSpec) + + if (tabCount < 2) { + return + } + + val tabLayout = getChildAt(0) as ViewGroup + var widthOfAllTabs = 0 + for (i in 0 until tabLayout.childCount) { + widthOfAllTabs += tabLayout.getChildAt(i).measuredWidth + } + if (widthOfAllTabs <= measuredWidth) { + // fill all space if there is enough room + tabMode = MODE_FIXED + super.onMeasure(widthMeasureSpec, heightMeasureSpec) + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/view/BackgroundMessageView.kt b/app/src/main/java/com/keylesspalace/tusky/view/BackgroundMessageView.kt index 650d92ccb..c33533628 100644 --- a/app/src/main/java/com/keylesspalace/tusky/view/BackgroundMessageView.kt +++ b/app/src/main/java/com/keylesspalace/tusky/view/BackgroundMessageView.kt @@ -33,7 +33,7 @@ class BackgroundMessageView @JvmOverloads constructor( orientation = VERTICAL if (isInEditMode) { - setup(R.drawable.elephant_offline, R.string.error_network) {} + setup(R.drawable.errorphant_offline, R.string.error_network) {} } } @@ -61,6 +61,7 @@ class BackgroundMessageView @JvmOverloads constructor( binding.imageView.setImageResource(imageRes) binding.button.setOnClickListener(clickListener) binding.button.visible(clickListener != null) + binding.helpText.visible(false) } fun showHelp(@StringRes helpRes: Int) { diff --git a/app/src/main/java/com/keylesspalace/tusky/view/ClickableSpanTextView.kt b/app/src/main/java/com/keylesspalace/tusky/view/ClickableSpanTextView.kt index 4caf47c14..1fc01c999 100644 --- a/app/src/main/java/com/keylesspalace/tusky/view/ClickableSpanTextView.kt +++ b/app/src/main/java/com/keylesspalace/tusky/view/ClickableSpanTextView.kt @@ -25,6 +25,9 @@ import android.graphics.Paint import android.graphics.Path import android.graphics.Rect import android.graphics.RectF +import android.os.Build +import android.text.Selection +import android.text.Spannable import android.text.Spanned import android.text.style.ClickableSpan import android.text.style.URLSpan @@ -192,6 +195,26 @@ class ClickableSpanTextView @JvmOverloads constructor( } } + override fun dispatchTouchEvent(event: MotionEvent): Boolean { + // workaround to https://code.google.com/p/android/issues/detail?id=191430 + // from https://stackoverflow.com/a/36740247 + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.O_MR1) { + val startSelection = selectionStart + val endSelection = selectionEnd + + val content = text + if (content is Spannable && (startSelection < 0 || endSelection < 0)) { + Selection.setSelection(content as Spannable?, content.length) + } else if (startSelection != endSelection) { + if (event.actionMasked == ACTION_DOWN) { + text = null + text = content + } + } + } + return super.dispatchTouchEvent(event) + } + /** * Handle some touch events. * @@ -231,7 +254,10 @@ class ClickableSpanTextView @JvmOverloads constructor( activeEntry = entry continue } - Log.v(TAG, "Overlap: ${(entry.value as URLSpan).url} ${(activeEntry.value as URLSpan).url}") + Log.v( + TAG, + "Overlap: ${(entry.value as URLSpan).url} ${(activeEntry.value as URLSpan).url}" + ) if (isClickOnFirst(entry.key, activeEntry.key, x, y)) { activeEntry = entry } @@ -347,21 +373,21 @@ class ClickableSpanTextView @JvmOverloads constructor( return firstDiff < secondDiff } - override fun onDraw(canvas: Canvas?) { + override fun onDraw(canvas: Canvas) { super.onDraw(canvas) // Paint span boundaries. Optimised out on release builds, or debug builds where // showSpanBoundaries is false. if (BuildConfig.DEBUG && showSpanBoundaries) { - canvas?.save() + canvas.save() for (entry in delegateRects) { - canvas?.drawRect(entry.key, paddingDebugPaint) + canvas.drawRect(entry.key, paddingDebugPaint) } for (entry in spanRects) { - canvas?.drawRect(entry.key, spanDebugPaint) + canvas.drawRect(entry.key, spanDebugPaint) } - canvas?.restore() + canvas.restore() } } diff --git a/app/src/main/java/com/keylesspalace/tusky/view/ExposedPlayPauseVideoView.kt b/app/src/main/java/com/keylesspalace/tusky/view/ExposedPlayPauseVideoView.kt deleted file mode 100644 index 95605b18f..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/view/ExposedPlayPauseVideoView.kt +++ /dev/null @@ -1,41 +0,0 @@ -package com.keylesspalace.tusky.view - -import android.content.Context -import android.util.AttributeSet -import android.widget.VideoView - -class ExposedPlayPauseVideoView @JvmOverloads constructor( - context: Context, - attrs: AttributeSet? = null, - defStyleAttr: Int = 0 -) : - VideoView(context, attrs, defStyleAttr) { - - private var listener: PlayPauseListener? = null - private var playing = false - - fun setPlayPauseListener(listener: PlayPauseListener) { - this.listener = listener - } - - override fun start() { - super.start() - if (!playing) { - playing = true - listener?.onPlay() - } - } - - override fun pause() { - super.pause() - if (playing) { - playing = false - listener?.onPause() - } - } - - interface PlayPauseListener { - fun onPlay() - fun onPause() - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/view/GraphView.kt b/app/src/main/java/com/keylesspalace/tusky/view/GraphView.kt index 937db93ad..6915bde21 100644 --- a/app/src/main/java/com/keylesspalace/tusky/view/GraphView.kt +++ b/app/src/main/java/com/keylesspalace/tusky/view/GraphView.kt @@ -265,14 +265,14 @@ class GraphView @JvmOverloads constructor( private fun dataSpacing(data: List) = width.toFloat() / max(data.size - 1, 1).toFloat() - override fun onDraw(canvas: Canvas?) { + override fun onDraw(canvas: Canvas) { super.onDraw(canvas) if (primaryLinePath.isEmpty && width > 0) { initializeVertices() } - canvas?.apply { + canvas.apply { drawRect(sizeRect, graphPaint) val pointDistance = dataSpacing(primaryLineData) diff --git a/app/src/main/java/com/keylesspalace/tusky/view/LicenseCard.kt b/app/src/main/java/com/keylesspalace/tusky/view/LicenseCard.kt index 394cd3692..1b232eae1 100644 --- a/app/src/main/java/com/keylesspalace/tusky/view/LicenseCard.kt +++ b/app/src/main/java/com/keylesspalace/tusky/view/LicenseCard.kt @@ -37,7 +37,13 @@ class LicenseCard init { val binding = CardLicenseBinding.inflate(LayoutInflater.from(context), this) - setCardBackgroundColor(MaterialColors.getColor(context, com.google.android.material.R.attr.colorSurface, Color.BLACK)) + setCardBackgroundColor( + MaterialColors.getColor( + context, + com.google.android.material.R.attr.colorSurface, + Color.BLACK + ) + ) val (name, license, link) = context.theme.obtainStyledAttributes( attrs, diff --git a/app/src/main/java/com/keylesspalace/tusky/view/MediaPreviewImageView.kt b/app/src/main/java/com/keylesspalace/tusky/view/MediaPreviewImageView.kt index 717bd1441..b68f4b7ad 100644 --- a/app/src/main/java/com/keylesspalace/tusky/view/MediaPreviewImageView.kt +++ b/app/src/main/java/com/keylesspalace/tusky/view/MediaPreviewImageView.kt @@ -96,11 +96,22 @@ open class MediaPreviewImageView } } - override fun onLoadFailed(e: GlideException?, model: Any?, target: Target?, isFirstResource: Boolean): Boolean { + override fun onLoadFailed( + e: GlideException?, + model: Any?, + target: Target, + isFirstResource: Boolean + ): Boolean { return false } - override fun onResourceReady(resource: Drawable?, model: Any?, target: Target?, dataSource: DataSource?, isFirstResource: Boolean): Boolean { + override fun onResourceReady( + resource: Drawable, + model: Any, + target: Target?, + dataSource: DataSource, + isFirstResource: Boolean + ): Boolean { recalculateMatrix(width, height, resource) return false } diff --git a/app/src/main/java/com/keylesspalace/tusky/view/SliderPreference.kt b/app/src/main/java/com/keylesspalace/tusky/view/SliderPreference.kt index 742a2cd76..f8ac67452 100644 --- a/app/src/main/java/com/keylesspalace/tusky/view/SliderPreference.kt +++ b/app/src/main/java/com/keylesspalace/tusky/view/SliderPreference.kt @@ -4,7 +4,6 @@ import android.content.Context import android.content.res.TypedArray import android.graphics.drawable.Drawable import android.util.AttributeSet -import android.view.View.VISIBLE import androidx.appcompat.content.res.AppCompatResources import androidx.preference.Preference import androidx.preference.PreferenceViewHolder @@ -12,6 +11,8 @@ import com.google.android.material.slider.LabelFormatter.LABEL_GONE import com.google.android.material.slider.Slider import com.keylesspalace.tusky.R import com.keylesspalace.tusky.databinding.PrefSliderBinding +import com.keylesspalace.tusky.util.hide +import com.keylesspalace.tusky.util.show import java.lang.Float.max import java.lang.Float.min @@ -44,7 +45,7 @@ class SliderPreference @JvmOverloads constructor( * @see Slider.getValue * @see Slider.setValue */ - var value: Float = defaultValue + var value: Float = DEFAULT_VALUE get() = _value set(v) { val clamped = max(max(v, valueFrom), min(v, valueTo)) @@ -67,7 +68,7 @@ class SliderPreference @JvmOverloads constructor( * Format string to be applied to values before setting the summary. For more control set * [SliderPreference.formatter] */ - var format: String = defaultFormat + var format: String = DEFAULT_FORMAT /** * Function that will be used to format the summary. The default formatter formats using the @@ -95,13 +96,18 @@ class SliderPreference @JvmOverloads constructor( // preference layout to the right of the title and summary. layoutResource = R.layout.pref_slider - val a = context.obtainStyledAttributes(attrs, R.styleable.SliderPreference, defStyleAttr, defStyleRes) + val a = context.obtainStyledAttributes( + attrs, + R.styleable.SliderPreference, + defStyleAttr, + defStyleRes + ) - value = a.getFloat(R.styleable.SliderPreference_android_value, defaultValue) - valueFrom = a.getFloat(R.styleable.SliderPreference_android_valueFrom, defaultValueFrom) - valueTo = a.getFloat(R.styleable.SliderPreference_android_valueTo, defaultValueTo) - stepSize = a.getFloat(R.styleable.SliderPreference_android_stepSize, defaultStepSize) - format = a.getString(R.styleable.SliderPreference_format) ?: defaultFormat + value = a.getFloat(R.styleable.SliderPreference_android_value, DEFAULT_VALUE) + valueFrom = a.getFloat(R.styleable.SliderPreference_android_valueFrom, DEFAULT_VALUE_FROM) + valueTo = a.getFloat(R.styleable.SliderPreference_android_valueTo, DEFAULT_VALUE_TO) + stepSize = a.getFloat(R.styleable.SliderPreference_android_stepSize, DEFAULT_STEP_SIZE) + format = a.getString(R.styleable.SliderPreference_format) ?: DEFAULT_FORMAT val decrementIconResource = a.getResourceId(R.styleable.SliderPreference_iconStart, -1) if (decrementIconResource != -1) { @@ -117,11 +123,11 @@ class SliderPreference @JvmOverloads constructor( } override fun onGetDefaultValue(a: TypedArray, i: Int): Any { - return a.getFloat(i, defaultValue) + return a.getFloat(i, DEFAULT_VALUE) } override fun onSetInitialValue(defaultValue: Any?) { - value = getPersistedFloat((defaultValue ?: Companion.defaultValue) as Float) + value = getPersistedFloat((defaultValue ?: DEFAULT_VALUE) as Float) } override fun onBindViewHolder(holder: PreferenceViewHolder) { @@ -130,6 +136,8 @@ class SliderPreference @JvmOverloads constructor( binding.root.isClickable = false + binding.slider.clearOnChangeListeners() + binding.slider.clearOnSliderTouchListeners() binding.slider.addOnChangeListener(this) binding.slider.addOnSliderTouchListener(this) binding.slider.value = value // sliderValue @@ -141,24 +149,24 @@ class SliderPreference @JvmOverloads constructor( binding.slider.labelBehavior = LABEL_GONE binding.slider.isEnabled = isEnabled - binding.summary.visibility = VISIBLE + binding.summary.show() binding.summary.text = formatter(value) decrementIcon?.let { icon -> binding.decrement.icon = icon - binding.decrement.visibility = VISIBLE + binding.decrement.show() binding.decrement.setOnClickListener { value -= stepSize } - } + } ?: binding.decrement.hide() incrementIcon?.let { icon -> binding.increment.icon = icon - binding.increment.visibility = VISIBLE + binding.increment.show() binding.increment.setOnClickListener { value += stepSize } - } + } ?: binding.increment.hide() } override fun onValueChange(slider: Slider, value: Float, fromUser: Boolean) { @@ -176,10 +184,10 @@ class SliderPreference @JvmOverloads constructor( companion object { private const val TAG = "SliderPreference" - private const val defaultValueFrom = 0F - private const val defaultValueTo = 1F - private const val defaultValue = 0.5F - private const val defaultStepSize = 0.1F - private const val defaultFormat = "%3.1f" + private const val DEFAULT_VALUE_FROM = 0F + private const val DEFAULT_VALUE_TO = 1F + private const val DEFAULT_VALUE = 0.5F + private const val DEFAULT_STEP_SIZE = 0.1F + private const val DEFAULT_FORMAT = "%3.1f" } } diff --git a/app/src/main/java/com/keylesspalace/tusky/viewdata/AttachmentViewData.kt b/app/src/main/java/com/keylesspalace/tusky/viewdata/AttachmentViewData.kt index ae24cebe1..2626cfeef 100644 --- a/app/src/main/java/com/keylesspalace/tusky/viewdata/AttachmentViewData.kt +++ b/app/src/main/java/com/keylesspalace/tusky/viewdata/AttachmentViewData.kt @@ -35,7 +35,10 @@ data class AttachmentViewData( companion object { @JvmStatic - fun list(status: Status): List { + fun list( + status: Status, + alwaysShowSensitiveMedia: Boolean = false + ): List { val actionable = status.actionableStatus return actionable.attachments.map { attachment -> AttachmentViewData( @@ -43,7 +46,7 @@ data class AttachmentViewData( statusId = actionable.id, statusUrl = actionable.url!!, sensitive = actionable.sensitive, - isRevealed = !actionable.sensitive + isRevealed = alwaysShowSensitiveMedia || !actionable.sensitive ) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.java b/app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.java new file mode 100644 index 000000000..c70e2fc71 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.java @@ -0,0 +1,138 @@ +/* Copyright 2017 Andrew Dawson + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . */ + +package com.keylesspalace.tusky.viewdata; + +import androidx.annotation.Nullable; + +import com.keylesspalace.tusky.entity.Notification; +import com.keylesspalace.tusky.entity.Report; +import com.keylesspalace.tusky.entity.TimelineAccount; + +import java.util.Objects; + +/** + * Created by charlag on 12/07/2017. + *

+ * Class to represent data required to display either a notification or a placeholder. + * It is either a {@link Placeholder} or a {@link Concrete}. + * It is modelled this way because close relationship between placeholder and concrete notification + * is fine in this case. Placeholder case is not modelled as a type of notification because + * invariants would be violated and because it would model domain incorrectly. It is preferable to + * {@link com.keylesspalace.tusky.util.Either} because class hierarchy is cheaper, faster and + * more native. + */ +public abstract class NotificationViewData { + private NotificationViewData() { + } + + public abstract long getViewDataId(); + + public abstract boolean deepEquals(NotificationViewData other); + + public static final class Concrete extends NotificationViewData { + private final Notification.Type type; + private final String id; + private final TimelineAccount account; + @Nullable + private final StatusViewData.Concrete statusViewData; + @Nullable + private final Report report; + + public Concrete(Notification.Type type, String id, TimelineAccount account, + @Nullable StatusViewData.Concrete statusViewData, @Nullable Report report) { + this.type = type; + this.id = id; + this.account = account; + this.statusViewData = statusViewData; + this.report = report; + } + + public Notification.Type getType() { + return type; + } + + public String getId() { + return id; + } + + public TimelineAccount getAccount() { + return account; + } + + @Nullable + public StatusViewData.Concrete getStatusViewData() { + return statusViewData; + } + + @Nullable + public Report getReport() { + return report; + } + + @Override + public long getViewDataId() { + return id.hashCode(); + } + + @Override + public boolean deepEquals(NotificationViewData o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Concrete concrete = (Concrete) o; + return type == concrete.type && + Objects.equals(id, concrete.id) && + account.getId().equals(concrete.account.getId()) && + (Objects.equals(statusViewData, concrete.statusViewData)) && + (Objects.equals(report, concrete.report)); + } + + @Override + public int hashCode() { + + return Objects.hash(type, id, account, statusViewData); + } + + public Concrete copyWithStatus(@Nullable StatusViewData.Concrete statusViewData) { + return new Concrete(type, id, account, statusViewData, report); + } + } + + public static final class Placeholder extends NotificationViewData { + private final long id; + private final boolean isLoading; + + public Placeholder(long id, boolean isLoading) { + this.id = id; + this.isLoading = isLoading; + } + + public boolean isLoading() { + return isLoading; + } + + @Override + public long getViewDataId() { + return id; + } + + @Override + public boolean deepEquals(NotificationViewData other) { + if (!(other instanceof Placeholder)) return false; + Placeholder that = (Placeholder) other; + return isLoading == that.isLoading && id == that.id; + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.kt b/app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.kt deleted file mode 100644 index 759d633e2..000000000 --- a/app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.kt +++ /dev/null @@ -1,43 +0,0 @@ -/* - * Copyright 2023 Tusky Contributors - * - * This file is a part of Tusky. - * - * 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. - * - * Tusky 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 Tusky; if not, - * see . - */ - -/* - * This file is a part of Tusky. - * - * 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. - * - * Tusky 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 Tusky; if not, - * see . */ -package com.keylesspalace.tusky.viewdata - -import com.keylesspalace.tusky.entity.Notification -import com.keylesspalace.tusky.entity.Report -import com.keylesspalace.tusky.entity.TimelineAccount - -data class NotificationViewData( - val type: Notification.Type, - val id: String, - val account: TimelineAccount, - var statusViewData: StatusViewData.Concrete?, - val report: Report? -) diff --git a/app/src/main/java/com/keylesspalace/tusky/viewdata/PollViewData.kt b/app/src/main/java/com/keylesspalace/tusky/viewdata/PollViewData.kt index 7281bf060..07d95982e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/viewdata/PollViewData.kt +++ b/app/src/main/java/com/keylesspalace/tusky/viewdata/PollViewData.kt @@ -43,8 +43,8 @@ data class PollOptionViewData( var voted: Boolean ) -fun calculatePercent(fraction: Int, totalVoters: Int?, totalVotes: Int): Int { - return if (fraction == 0) { +fun calculatePercent(fraction: Int?, totalVoters: Int?, totalVotes: Int): Int { + return if (fraction == null || fraction == 0) { 0 } else { val total = totalVoters ?: totalVotes @@ -53,7 +53,10 @@ fun calculatePercent(fraction: Int, totalVoters: Int?, totalVotes: Int): Int { } fun buildDescription(title: String, percent: Int, voted: Boolean, context: Context): Spanned { - val builder = SpannableStringBuilder(context.getString(R.string.poll_percent_format, percent).parseAsHtml()) + val builder = + SpannableStringBuilder( + context.getString(R.string.poll_percent_format, percent).parseAsHtml() + ) if (voted) { builder.append(" ✓ ") } else { @@ -71,7 +74,11 @@ fun Poll?.toViewData(): PollViewData? { multiple = multiple, votesCount = votesCount, votersCount = votersCount, - options = options.mapIndexed { index, option -> option.toViewData(ownVotes?.contains(index) == true) }, + options = options.mapIndexed { index, option -> + option.toViewData( + ownVotes.contains(index) + ) + }, voted = voted ) } @@ -79,7 +86,7 @@ fun Poll?.toViewData(): PollViewData? { fun PollOption.toViewData(voted: Boolean): PollOptionViewData { return PollOptionViewData( title = title, - votesCount = votesCount, + votesCount = votesCount ?: 0, selected = false, voted = voted ) diff --git a/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.kt b/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.kt index 5ef3ef373..6f8d5d698 100644 --- a/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.kt +++ b/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.kt @@ -14,14 +14,25 @@ * see . */ package com.keylesspalace.tusky.viewdata -import android.os.Build import android.text.Spanned +import com.keylesspalace.tusky.entity.Attachment import com.keylesspalace.tusky.entity.Filter import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.entity.Translation import com.keylesspalace.tusky.util.parseAsMastodonHtml -import com.keylesspalace.tusky.util.replaceCrashingCharacters import com.keylesspalace.tusky.util.shouldTrimStatus +sealed interface TranslationViewData { + val data: Translation? + + data class Loaded(override val data: Translation) : TranslationViewData + + data object Loading : TranslationViewData { + override val data: Translation? + get() = null + } +} + /** * Created by charlag on 11/07/2017. * @@ -43,22 +54,38 @@ sealed class StatusViewData { * @return Whether the post is collapsed or fully expanded. */ val isCollapsed: Boolean, - val isDetailed: Boolean = false + val isDetailed: Boolean = false, + val translation: TranslationViewData? = null, ) : StatusViewData() { override val id: String get() = status.id + val content: Spanned = + (translation?.data?.content ?: actionable.content).parseAsMastodonHtml() + + val attachments: List = + actionable.attachments.translated { translation -> map { it.translated(translation) } } + + val spoilerText: String = + actionable.spoilerText.translated { translation -> translation.spoilerText ?: this } + + val poll = actionable.poll?.translated { translation -> + val translatedOptionsText = translation.poll?.options?.map { option -> + option.title + } ?: return@translated this + val translatedOptions = options.zip(translatedOptionsText) { option, translatedText -> + option.copy(title = translatedText) + } + copy(options = translatedOptions) + } + /** * Specifies whether the content of this post is long enough to be automatically * collapsed or if it should show all content regardless. * * @return Whether the post is collapsible or never collapsed. */ - val isCollapsible: Boolean - - val content: Spanned - val spoilerText: String - val username: String + val isCollapsible: Boolean = shouldTrimStatus(this.content) val actionable: Status get() = status.actionableStatus @@ -76,26 +103,39 @@ sealed class StatusViewData { val rebloggingStatus: Status? get() = if (status.reblog != null) status else null - init { - if (Build.VERSION.SDK_INT == 23) { - // https://github.com/tuskyapp/Tusky/issues/563 - this.content = replaceCrashingCharacters(status.actionableStatus.content.parseAsMastodonHtml()) - this.spoilerText = - replaceCrashingCharacters(status.actionableStatus.spoilerText).toString() - this.username = - replaceCrashingCharacters(status.actionableStatus.account.username).toString() - } else { - this.content = status.actionableStatus.content.parseAsMastodonHtml() - this.spoilerText = status.actionableStatus.spoilerText - this.username = status.actionableStatus.account.username - } - this.isCollapsible = shouldTrimStatus(this.content) + /** Helper for Java */ + fun copyWithStatus(status: Status): Concrete { + return copy(status = status) + } + + /** Helper for Java */ + fun copyWithExpanded(isExpanded: Boolean): Concrete { + return copy(isExpanded = isExpanded) + } + + /** Helper for Java */ + fun copyWithShowingContent(isShowingContent: Boolean): Concrete { + return copy(isShowingContent = isShowingContent) } /** Helper for Java */ fun copyWithCollapsed(isCollapsed: Boolean): Concrete { return copy(isCollapsed = isCollapsed) } + + private fun Attachment.translated(translation: Translation): Attachment { + val translatedDescription = + translation.mediaAttachments.find { it.id == id }?.description + ?: return this + return copy(description = translatedDescription) + } + + private inline fun T.translated(mapper: T.(Translation) -> T): T = + if (translation is TranslationViewData.Loaded) { + mapper(translation.data) + } else { + this + } } data class Placeholder( diff --git a/app/src/main/java/com/keylesspalace/tusky/viewdata/TrendingViewData.kt b/app/src/main/java/com/keylesspalace/tusky/viewdata/TrendingViewData.kt index 6eae73319..99b09409d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/viewdata/TrendingViewData.kt +++ b/app/src/main/java/com/keylesspalace/tusky/viewdata/TrendingViewData.kt @@ -17,15 +17,14 @@ package com.keylesspalace.tusky.viewdata import java.util.Date -sealed class TrendingViewData { - abstract val id: String +sealed interface TrendingViewData { + val id: String data class Header( val start: Date, val end: Date - ) : TrendingViewData() { - override val id: String - get() = start.toString() + end.toString() + ) : TrendingViewData { + override val id: String = start.toString() + end.toString() } data class Tag( @@ -33,8 +32,7 @@ sealed class TrendingViewData { val usage: List, val accounts: List, val maxTrendingValue: Long - ) : TrendingViewData() { - override val id: String - get() = name + ) : TrendingViewData { + override val id: String = name } } diff --git a/app/src/main/java/com/keylesspalace/tusky/viewmodel/AccountsInListViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/viewmodel/AccountsInListViewModel.kt index aafe4ce05..5cfb2a31e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/viewmodel/AccountsInListViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/viewmodel/AccountsInListViewModel.kt @@ -23,15 +23,19 @@ import at.connyduck.calladapter.networkresult.fold import com.keylesspalace.tusky.entity.TimelineAccount import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.util.Either +import com.keylesspalace.tusky.util.Either.Companion.map import com.keylesspalace.tusky.util.Either.Left import com.keylesspalace.tusky.util.Either.Right import com.keylesspalace.tusky.util.withoutFirstWhich +import javax.inject.Inject import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch -import javax.inject.Inject -data class State(val accounts: Either>, val searchResult: List?) +data class State( + val accounts: Either>, + val searchResult: List? +) class AccountsInListViewModel @Inject constructor(private val api: MastodonApi) : ViewModel() { @@ -114,7 +118,7 @@ class AccountsInListViewModel @Inject constructor(private val api: MastodonApi) } } - private inline fun updateState(crossinline fn: State.() -> State) { + private inline fun updateState(fn: State.() -> State) { _state.value = fn(_state.value) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/viewmodel/EditProfileViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/viewmodel/EditProfileViewModel.kt index fe5110381..b04f5fdf6 100644 --- a/app/src/main/java/com/keylesspalace/tusky/viewmodel/EditProfileViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/viewmodel/EditProfileViewModel.kt @@ -18,7 +18,6 @@ package com.keylesspalace.tusky.viewmodel import android.app.Application import android.net.Uri import androidx.core.net.toUri -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import at.connyduck.calladapter.networkresult.fold @@ -35,23 +34,31 @@ import com.keylesspalace.tusky.util.Resource import com.keylesspalace.tusky.util.Success import com.keylesspalace.tusky.util.getServerErrorMessage import com.keylesspalace.tusky.util.randomAlphanumericString -import kotlinx.coroutines.FlowPreview +import java.io.File +import javax.inject.Inject import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.launch import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MultipartBody -import okhttp3.RequestBody import okhttp3.RequestBody.Companion.asRequestBody import okhttp3.RequestBody.Companion.toRequestBody -import java.io.File -import javax.inject.Inject private const val HEADER_FILE_NAME = "header.png" private const val AVATAR_FILE_NAME = "avatar.png" +internal data class ProfileDataInUi( + val displayName: String, + val note: String, + val locked: Boolean, + val fields: List +) + class EditProfileViewModel @Inject constructor( private val mastodonApi: MastodonApi, private val eventHub: EventHub, @@ -59,28 +66,37 @@ class EditProfileViewModel @Inject constructor( private val instanceInfoRepo: InstanceInfoRepository ) : ViewModel() { - val profileData = MutableLiveData>() - val avatarData = MutableLiveData() - val headerData = MutableLiveData() - val saveData = MutableLiveData>() + private val _profileData = MutableStateFlow(null as Resource?) + val profileData: StateFlow?> = _profileData.asStateFlow() - @OptIn(FlowPreview::class) - val instanceData: Flow = instanceInfoRepo::getInstanceInfo.asFlow() + private val _avatarData = MutableStateFlow(null as Uri?) + val avatarData: StateFlow = _avatarData.asStateFlow() + + private val _headerData = MutableStateFlow(null as Uri?) + val headerData: StateFlow = _headerData.asStateFlow() + + private val _saveData = MutableStateFlow(null as Resource?) + val saveData: StateFlow?> = _saveData.asStateFlow() + + val instanceData: Flow = instanceInfoRepo::getUpdatedInstanceInfoOrFallback.asFlow() .shareIn(viewModelScope, SharingStarted.Eagerly, replay = 1) - private var oldProfileData: Account? = null + private val _isChanged = MutableStateFlow(false) + val isChanged = _isChanged.asStateFlow() + + private var apiProfileAccount: Account? = null fun obtainProfile() = viewModelScope.launch { - if (profileData.value == null || profileData.value is Error) { - profileData.postValue(Loading()) + if (_profileData.value == null || _profileData.value is Error) { + _profileData.value = Loading() mastodonApi.accountVerifyCredentials().fold( { profile -> - oldProfileData = profile - profileData.postValue(Success(profile)) + apiProfileAccount = profile + _profileData.value = Success(profile) }, { - profileData.postValue(Error()) + _profileData.value = Error() } ) } @@ -91,108 +107,169 @@ class EditProfileViewModel @Inject constructor( fun getHeaderUri() = getCacheFileForName(HEADER_FILE_NAME).toUri() fun newAvatarPicked() { - avatarData.value = getAvatarUri() + _avatarData.value = getAvatarUri() } fun newHeaderPicked() { - headerData.value = getHeaderUri() + _headerData.value = getHeaderUri() } - fun save(newDisplayName: String, newNote: String, newLocked: Boolean, newFields: List) { - if (saveData.value is Loading || profileData.value !is Success) { + internal fun dataChanged(newProfileData: ProfileDataInUi) { + _isChanged.value = getProfileDiff(apiProfileAccount, newProfileData).hasChanges() + } + + internal fun save(newProfileData: ProfileDataInUi) { + if (_saveData.value is Loading || _profileData.value !is Success) { return } - saveData.value = Loading() + _saveData.value = Loading() - val displayName = if (oldProfileData?.displayName == newDisplayName) { - null - } else { - newDisplayName.toRequestBody(MultipartBody.FORM) - } - - val note = if (oldProfileData?.source?.note == newNote) { - null - } else { - newNote.toRequestBody(MultipartBody.FORM) - } - - val locked = if (oldProfileData?.locked == newLocked) { - null - } else { - newLocked.toString().toRequestBody(MultipartBody.FORM) - } - - val avatar = if (avatarData.value != null) { - val avatarBody = getCacheFileForName(AVATAR_FILE_NAME).asRequestBody("image/png".toMediaTypeOrNull()) - MultipartBody.Part.createFormData("avatar", randomAlphanumericString(12), avatarBody) - } else { - null - } - - val header = if (headerData.value != null) { - val headerBody = getCacheFileForName(HEADER_FILE_NAME).asRequestBody("image/png".toMediaTypeOrNull()) - MultipartBody.Part.createFormData("header", randomAlphanumericString(12), headerBody) - } else { - null - } - - // when one field changed, all have to be sent or they unchanged ones would get overridden - val fieldsUnchanged = oldProfileData?.source?.fields == newFields - val field1 = calculateFieldToUpdate(newFields.getOrNull(0), fieldsUnchanged) - val field2 = calculateFieldToUpdate(newFields.getOrNull(1), fieldsUnchanged) - val field3 = calculateFieldToUpdate(newFields.getOrNull(2), fieldsUnchanged) - val field4 = calculateFieldToUpdate(newFields.getOrNull(3), fieldsUnchanged) - - if (displayName == null && note == null && locked == null && avatar == null && header == null && - field1 == null && field2 == null && field3 == null && field4 == null - ) { - /** if nothing has changed, there is no need to make a network request */ - saveData.postValue(Success()) + val diff = getProfileDiff(apiProfileAccount, newProfileData) + if (!diff.hasChanges()) { + // if nothing has changed, there is no need to make an api call + _saveData.value = Success() return } viewModelScope.launch { + var avatarFileBody: MultipartBody.Part? = null + diff.avatarFile?.let { + avatarFileBody = MultipartBody.Part.createFormData( + "avatar", + randomAlphanumericString(12), + it.asRequestBody("image/png".toMediaTypeOrNull()) + ) + } + + var headerFileBody: MultipartBody.Part? = null + diff.headerFile?.let { + headerFileBody = MultipartBody.Part.createFormData( + "header", + randomAlphanumericString(12), + it.asRequestBody("image/png".toMediaTypeOrNull()) + ) + } + mastodonApi.accountUpdateCredentials( - displayName, note, locked, avatar, header, - field1?.first, field1?.second, field2?.first, field2?.second, field3?.first, field3?.second, field4?.first, field4?.second + diff.displayName?.toRequestBody(MultipartBody.FORM), + diff.note?.toRequestBody(MultipartBody.FORM), + diff.locked?.toString()?.toRequestBody(MultipartBody.FORM), + avatarFileBody, + headerFileBody, + diff.field1?.first?.toRequestBody(MultipartBody.FORM), + diff.field1?.second?.toRequestBody(MultipartBody.FORM), + diff.field2?.first?.toRequestBody(MultipartBody.FORM), + diff.field2?.second?.toRequestBody(MultipartBody.FORM), + diff.field3?.first?.toRequestBody(MultipartBody.FORM), + diff.field3?.second?.toRequestBody(MultipartBody.FORM), + diff.field4?.first?.toRequestBody(MultipartBody.FORM), + diff.field4?.second?.toRequestBody(MultipartBody.FORM) ).fold( - { newProfileData -> - saveData.postValue(Success()) - eventHub.dispatch(ProfileEditedEvent(newProfileData)) + { newAccountData -> + _saveData.value = Success() + eventHub.dispatch(ProfileEditedEvent(newAccountData)) }, { throwable -> - saveData.postValue(Error(errorMessage = throwable.getServerErrorMessage())) + _saveData.value = Error(errorMessage = throwable.getServerErrorMessage()) } ) } } // cache activity state for rotation change - fun updateProfile(newDisplayName: String, newNote: String, newLocked: Boolean, newFields: List) { - if (profileData.value is Success) { - val newProfileSource = profileData.value?.data?.source?.copy(note = newNote, fields = newFields) - val newProfile = profileData.value?.data?.copy( - displayName = newDisplayName, - locked = newLocked, + internal fun updateProfile(newProfileData: ProfileDataInUi) { + if (_profileData.value is Success) { + val newProfileSource = _profileData.value?.data?.source?.copy( + note = newProfileData.note, + fields = newProfileData.fields + ) + val newProfile = _profileData.value?.data?.copy( + displayName = newProfileData.displayName, + locked = newProfileData.locked, source = newProfileSource ) - profileData.postValue(Success(newProfile)) + _profileData.value = Success(newProfile) } } - private fun calculateFieldToUpdate(newField: StringField?, fieldsUnchanged: Boolean): Pair? { + private fun getProfileDiff( + oldProfileAccount: Account?, + newProfileData: ProfileDataInUi + ): DiffProfileData { + val displayName = if (oldProfileAccount?.displayName == newProfileData.displayName) { + null + } else { + newProfileData.displayName + } + + val note = if (oldProfileAccount?.source?.note == newProfileData.note) { + null + } else { + newProfileData.note + } + + val locked = if (oldProfileAccount?.locked == newProfileData.locked) { + null + } else { + newProfileData.locked + } + + val avatarFile = if (_avatarData.value != null) { + getCacheFileForName(AVATAR_FILE_NAME) + } else { + null + } + + val headerFile = if (_headerData.value != null) { + getCacheFileForName(HEADER_FILE_NAME) + } else { + null + } + + // when one field changed, all have to be sent or they unchanged ones would get overridden + val allFieldsUnchanged = oldProfileAccount?.source?.fields == newProfileData.fields + val field1 = calculateFieldToUpdate(newProfileData.fields.getOrNull(0), allFieldsUnchanged) + val field2 = calculateFieldToUpdate(newProfileData.fields.getOrNull(1), allFieldsUnchanged) + val field3 = calculateFieldToUpdate(newProfileData.fields.getOrNull(2), allFieldsUnchanged) + val field4 = calculateFieldToUpdate(newProfileData.fields.getOrNull(3), allFieldsUnchanged) + + return DiffProfileData( + displayName, note, locked, field1, field2, field3, field4, headerFile, avatarFile + ) + } + + private fun calculateFieldToUpdate( + newField: StringField?, + fieldsUnchanged: Boolean + ): Pair? { if (fieldsUnchanged || newField == null) { return null } return Pair( - newField.name.toRequestBody(MultipartBody.FORM), - newField.value.toRequestBody(MultipartBody.FORM) + newField.name, + newField.value ) } private fun getCacheFileForName(filename: String): File { return File(application.cacheDir, filename) } + + private data class DiffProfileData( + val displayName: String?, + val note: String?, + val locked: Boolean?, + val field1: Pair?, + val field2: Pair?, + val field3: Pair?, + val field4: Pair?, + val headerFile: File?, + val avatarFile: File? + ) { + fun hasChanges() = displayName != null || note != null || locked != null || + avatarFile != null || headerFile != null || field1 != null || field2 != null || + field3 != null || field4 != null + } } diff --git a/app/src/main/java/com/keylesspalace/tusky/viewmodel/ListsViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/viewmodel/ListsViewModel.kt index f701847c6..4a9a6a484 100644 --- a/app/src/main/java/com/keylesspalace/tusky/viewmodel/ListsViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/viewmodel/ListsViewModel.kt @@ -23,30 +23,44 @@ import com.keylesspalace.tusky.entity.MastoList import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.util.replacedFirstWhich import com.keylesspalace.tusky.util.withoutFirstWhich -import kotlinx.coroutines.channels.BufferOverflow -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.launch import java.io.IOException import java.net.ConnectException import javax.inject.Inject +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch internal class ListsViewModel @Inject constructor(private val api: MastodonApi) : ViewModel() { enum class LoadingState { - INITIAL, LOADING, LOADED, ERROR_NETWORK, ERROR_OTHER + INITIAL, + LOADING, + LOADED, + ERROR_NETWORK, + ERROR_OTHER } enum class Event { - CREATE_ERROR, DELETE_ERROR, RENAME_ERROR + CREATE_ERROR, + DELETE_ERROR, + UPDATE_ERROR } data class State(val lists: List, val loadingState: LoadingState) - val state: Flow get() = _state - val events: Flow get() = _events private val _state = MutableStateFlow(State(listOf(), LoadingState.INITIAL)) - private val _events = MutableSharedFlow(replay = 0, extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) + val state: StateFlow = _state.asStateFlow() + + private val _events = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + val events: SharedFlow = _events.asSharedFlow() fun retryLoading() { loadIfNeeded() @@ -84,9 +98,9 @@ internal class ListsViewModel @Inject constructor(private val api: MastodonApi) } } - fun createNewList(listName: String) { + fun createNewList(listName: String, exclusive: Boolean, replyPolicy: String) { viewModelScope.launch { - api.createList(listName).fold( + api.createList(listName, exclusive, replyPolicy).fold( { list -> updateState { copy(lists = lists + list) @@ -99,16 +113,16 @@ internal class ListsViewModel @Inject constructor(private val api: MastodonApi) } } - fun renameList(listId: String, listName: String) { + fun updateList(listId: String, listName: String, exclusive: Boolean, replyPolicy: String) { viewModelScope.launch { - api.updateList(listId, listName).fold( + api.updateList(listId, listName, exclusive, replyPolicy).fold( { list -> updateState { copy(lists = lists.replacedFirstWhich(list) { it.id == listId }) } }, { - sendEvent(Event.RENAME_ERROR) + sendEvent(Event.UPDATE_ERROR) } ) } @@ -129,7 +143,7 @@ internal class ListsViewModel @Inject constructor(private val api: MastodonApi) } } - private inline fun updateState(crossinline fn: State.() -> State) { + private inline fun updateState(fn: State.() -> State) { _state.value = fn(_state.value) } diff --git a/app/src/main/java/com/keylesspalace/tusky/worker/NotificationWorker.kt b/app/src/main/java/com/keylesspalace/tusky/worker/NotificationWorker.kt index cc99b78b1..a7362630c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/worker/NotificationWorker.kt +++ b/app/src/main/java/com/keylesspalace/tusky/worker/NotificationWorker.kt @@ -34,14 +34,20 @@ class NotificationWorker( params: WorkerParameters, private val notificationsFetcher: NotificationFetcher ) : CoroutineWorker(appContext, params) { - val notification: Notification = NotificationHelper.createWorkerNotification(applicationContext, R.string.notification_notification_worker) + val notification: Notification = NotificationHelper.createWorkerNotification( + applicationContext, + R.string.notification_notification_worker + ) override suspend fun doWork(): Result { notificationsFetcher.fetchAndShow() return Result.success() } - override suspend fun getForegroundInfo() = ForegroundInfo(NOTIFICATION_ID_FETCH_NOTIFICATION, notification) + override suspend fun getForegroundInfo() = ForegroundInfo( + NOTIFICATION_ID_FETCH_NOTIFICATION, + notification + ) class Factory @Inject constructor( private val notificationsFetcher: NotificationFetcher diff --git a/app/src/main/java/com/keylesspalace/tusky/worker/PruneCacheWorker.kt b/app/src/main/java/com/keylesspalace/tusky/worker/PruneCacheWorker.kt index 5a65a2ef9..c69a035a6 100644 --- a/app/src/main/java/com/keylesspalace/tusky/worker/PruneCacheWorker.kt +++ b/app/src/main/java/com/keylesspalace/tusky/worker/PruneCacheWorker.kt @@ -38,7 +38,10 @@ class PruneCacheWorker( private val appDatabase: AppDatabase, private val accountManager: AccountManager ) : CoroutineWorker(appContext, workerParams) { - val notification: Notification = NotificationHelper.createWorkerNotification(applicationContext, R.string.notification_prune_cache) + val notification: Notification = NotificationHelper.createWorkerNotification( + applicationContext, + R.string.notification_prune_cache + ) override suspend fun doWork(): Result { for (account in accountManager.accounts) { @@ -48,7 +51,10 @@ class PruneCacheWorker( return Result.success() } - override suspend fun getForegroundInfo() = ForegroundInfo(NOTIFICATION_ID_PRUNE_CACHE, notification) + override suspend fun getForegroundInfo() = ForegroundInfo( + NOTIFICATION_ID_PRUNE_CACHE, + notification + ) companion object { private const val TAG = "PruneCacheWorker" diff --git a/app/src/main/res/anim/activity_close_enter.xml b/app/src/main/res/anim/activity_close_enter.xml new file mode 100644 index 000000000..2c08910bf --- /dev/null +++ b/app/src/main/res/anim/activity_close_enter.xml @@ -0,0 +1,22 @@ + + + + + diff --git a/app/src/main/res/anim/activity_close_exit.xml b/app/src/main/res/anim/activity_close_exit.xml new file mode 100644 index 000000000..feb59397d --- /dev/null +++ b/app/src/main/res/anim/activity_close_exit.xml @@ -0,0 +1,25 @@ + + + + + + + diff --git a/app/src/main/res/anim/activity_open_enter.xml b/app/src/main/res/anim/activity_open_enter.xml new file mode 100644 index 000000000..f2bd3cc94 --- /dev/null +++ b/app/src/main/res/anim/activity_open_enter.xml @@ -0,0 +1,23 @@ + + + + + + + diff --git a/app/src/main/res/anim/activity_open_exit.xml b/app/src/main/res/anim/activity_open_exit.xml new file mode 100644 index 000000000..2c08910bf --- /dev/null +++ b/app/src/main/res/anim/activity_open_exit.xml @@ -0,0 +1,22 @@ + + + + + diff --git a/app/src/main/res/anim/fade_in.xml b/app/src/main/res/anim/fade_in.xml deleted file mode 100644 index 972e757ec..000000000 --- a/app/src/main/res/anim/fade_in.xml +++ /dev/null @@ -1,6 +0,0 @@ - - \ No newline at end of file diff --git a/app/src/main/res/anim/fade_out.xml b/app/src/main/res/anim/fade_out.xml deleted file mode 100644 index 9b48ae8f6..000000000 --- a/app/src/main/res/anim/fade_out.xml +++ /dev/null @@ -1,6 +0,0 @@ - - \ No newline at end of file diff --git a/app/src/main/res/anim/fast_out_extra_slow_in.xml b/app/src/main/res/anim/fast_out_extra_slow_in.xml new file mode 100644 index 000000000..f419778d0 --- /dev/null +++ b/app/src/main/res/anim/fast_out_extra_slow_in.xml @@ -0,0 +1,18 @@ + + + diff --git a/app/src/main/res/anim/slide_from_left.xml b/app/src/main/res/anim/slide_from_left.xml deleted file mode 100644 index 5c7fe5227..000000000 --- a/app/src/main/res/anim/slide_from_left.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - \ No newline at end of file diff --git a/app/src/main/res/anim/slide_from_right.xml b/app/src/main/res/anim/slide_from_right.xml deleted file mode 100644 index 3c595d04b..000000000 --- a/app/src/main/res/anim/slide_from_right.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - \ No newline at end of file diff --git a/app/src/main/res/anim/slide_to_left.xml b/app/src/main/res/anim/slide_to_left.xml deleted file mode 100644 index 21688e2ca..000000000 --- a/app/src/main/res/anim/slide_to_left.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - \ No newline at end of file diff --git a/app/src/main/res/anim/slide_to_right.xml b/app/src/main/res/anim/slide_to_right.xml deleted file mode 100644 index 8ded764f7..000000000 --- a/app/src/main/res/anim/slide_to_right.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - \ No newline at end of file diff --git a/app/src/main/res/color-v24/launcher_shadow_gradient.xml b/app/src/main/res/color-v24/launcher_shadow_gradient.xml deleted file mode 100644 index 98ce83382..000000000 --- a/app/src/main/res/color-v24/launcher_shadow_gradient.xml +++ /dev/null @@ -1,12 +0,0 @@ - - \ No newline at end of file diff --git a/app/src/main/res/drawable-v24/ic_notoemoji.xml b/app/src/main/res/drawable-v24/ic_notoemoji.xml deleted file mode 100644 index d016e35c0..000000000 --- a/app/src/main/res/drawable-v24/ic_notoemoji.xml +++ /dev/null @@ -1,51 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/drawable/background_circle.xml b/app/src/main/res/drawable/background_circle.xml index e10c97565..923aaee68 100644 --- a/app/src/main/res/drawable/background_circle.xml +++ b/app/src/main/res/drawable/background_circle.xml @@ -1,5 +1,5 @@ - + android:shape="oval" > + diff --git a/app/src/main/res/drawable/elephant_error.xml b/app/src/main/res/drawable/errorphant_error.xml similarity index 100% rename from app/src/main/res/drawable/elephant_error.xml rename to app/src/main/res/drawable/errorphant_error.xml diff --git a/app/src/main/res/drawable/elephant_offline.xml b/app/src/main/res/drawable/errorphant_offline.xml similarity index 100% rename from app/src/main/res/drawable/elephant_offline.xml rename to app/src/main/res/drawable/errorphant_offline.xml diff --git a/app/src/main/res/drawable/ic_arrow_back.xml b/app/src/main/res/drawable/ic_arrow_back.xml new file mode 100644 index 000000000..89d18543f --- /dev/null +++ b/app/src/main/res/drawable/ic_arrow_back.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/ic_arrow_back_with_background.xml b/app/src/main/res/drawable/ic_arrow_back_with_background.xml new file mode 100644 index 000000000..1a4d711f0 --- /dev/null +++ b/app/src/main/res/drawable/ic_arrow_back_with_background.xml @@ -0,0 +1,12 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_blobmoji.xml b/app/src/main/res/drawable/ic_blobmoji.xml deleted file mode 100644 index be3332ce7..000000000 --- a/app/src/main/res/drawable/ic_blobmoji.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - diff --git a/app/src/main/res/drawable/ic_content_copy_24.xml b/app/src/main/res/drawable/ic_content_copy_24.xml new file mode 100644 index 000000000..bac0f6001 --- /dev/null +++ b/app/src/main/res/drawable/ic_content_copy_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_emoji_34dp.xml b/app/src/main/res/drawable/ic_emoji_34dp.xml deleted file mode 100644 index b00cb9684..000000000 --- a/app/src/main/res/drawable/ic_emoji_34dp.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_hot_24dp.xml b/app/src/main/res/drawable/ic_hot_24dp.xml new file mode 100644 index 000000000..9d4e6643f --- /dev/null +++ b/app/src/main/res/drawable/ic_hot_24dp.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_link.xml b/app/src/main/res/drawable/ic_link.xml new file mode 100644 index 000000000..5bb6b6862 --- /dev/null +++ b/app/src/main/res/drawable/ic_link.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_more.xml b/app/src/main/res/drawable/ic_more.xml new file mode 100644 index 000000000..f84f9b3f1 --- /dev/null +++ b/app/src/main/res/drawable/ic_more.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_more_with_background.xml b/app/src/main/res/drawable/ic_more_with_background.xml new file mode 100644 index 000000000..755b37298 --- /dev/null +++ b/app/src/main/res/drawable/ic_more_with_background.xml @@ -0,0 +1,12 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_notifications_off_24dp.xml b/app/src/main/res/drawable/ic_notifications_off_24dp.xml deleted file mode 100644 index 627eafd22..000000000 --- a/app/src/main/res/drawable/ic_notifications_off_24dp.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_notoemoji.xml b/app/src/main/res/drawable/ic_notoemoji.xml deleted file mode 100644 index 55628c019..000000000 --- a/app/src/main/res/drawable/ic_notoemoji.xml +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - - - - - - - - - diff --git a/app/src/main/res/drawable/ic_twemoji.xml b/app/src/main/res/drawable/ic_twemoji.xml deleted file mode 100644 index 70c4b513b..000000000 --- a/app/src/main/res/drawable/ic_twemoji.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - diff --git a/app/src/main/res/drawable/profile_badge_background.xml b/app/src/main/res/drawable/profile_badge_background.xml deleted file mode 100644 index be4bcf3ef..000000000 --- a/app/src/main/res/drawable/profile_badge_background.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/profile_badge_person_24dp.xml b/app/src/main/res/drawable/profile_badge_person_24dp.xml new file mode 100644 index 000000000..628c29de0 --- /dev/null +++ b/app/src/main/res/drawable/profile_badge_person_24dp.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/layout-sw640dp/fragment_timeline.xml b/app/src/main/res/layout-sw640dp/fragment_timeline.xml index 6d014e702..af49280d9 100644 --- a/app/src/main/res/layout-sw640dp/fragment_timeline.xml +++ b/app/src/main/res/layout-sw640dp/fragment_timeline.xml @@ -46,7 +46,7 @@ app:layout_constraintLeft_toLeftOf="parent" app:layout_constraintRight_toRightOf="parent" app:layout_constraintTop_toTopOf="parent" - tools:src="@drawable/elephant_error" + tools:src="@drawable/errorphant_error" tools:visibility="visible" /> diff --git a/app/src/main/res/layout/activity_about.xml b/app/src/main/res/layout/activity_about.xml index 9048accef..0b46cda4c 100644 --- a/app/src/main/res/layout/activity_about.xml +++ b/app/src/main/res/layout/activity_about.xml @@ -21,104 +21,190 @@ android:layout_gravity="center" android:textDirection="anyRtl"> - + + + + + android:textAppearance="@style/TextAppearance.AppCompat.Medium" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@id/versionTextView" + tools:text="Your device" /> + + + + + + + + - + android:textAppearance="@style/TextAppearance.AppCompat.Subhead" + app:layout_constraintEnd_toEndOf="@+id/copyDeviceInfo" + app:layout_constraintStart_toStartOf="@+id/deviceInfo" + app:layout_constraintTop_toBottomOf="@+id/accountInfo" /> + app:layout_constraintEnd_toEndOf="@+id/aboutWebsiteInfoTextView" + app:layout_constraintStart_toStartOf="@+id/aboutWebsiteInfoTextView" + app:layout_constraintTop_toBottomOf="@id/aboutWebsiteInfoTextView" />