Repository: Vavassor/Tusky Branch: develop Commit: 43ae0bb45563 Files: 1670 Total size: 8.3 MB Directory structure: gitextract_7rvtut99/ ├── .editorconfig ├── .gitattributes ├── .github/ │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE/ │ │ ├── 1.bug_report.yml │ │ ├── 2.feature_request.yml │ │ └── config.yml │ ├── actions/ │ │ └── setup/ │ │ └── action.yml │ ├── ci-gradle.properties │ ├── renovate.json │ └── workflows/ │ ├── check-and-build.yml │ ├── deploy-release.yml │ └── deploy-test.yml ├── .gitignore ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE.txt ├── README.md ├── app/ │ ├── build.gradle │ ├── getGitSha.gradle │ ├── lint-baseline.xml │ ├── lint.xml │ ├── proguard-rules.pro │ ├── schemas/ │ │ └── com.keylesspalace.tusky.db.AppDatabase/ │ │ ├── 10.json │ │ ├── 11.json │ │ ├── 12.json │ │ ├── 13.json │ │ ├── 14.json │ │ ├── 15.json │ │ ├── 16.json │ │ ├── 17.json │ │ ├── 18.json │ │ ├── 19.json │ │ ├── 20.json │ │ ├── 21.json │ │ ├── 22.json │ │ ├── 23.json │ │ ├── 24.json │ │ ├── 25.json │ │ ├── 26.json │ │ ├── 27.json │ │ ├── 28.json │ │ ├── 29.json │ │ ├── 30.json │ │ ├── 31.json │ │ ├── 32.json │ │ ├── 33.json │ │ ├── 34.json │ │ ├── 35.json │ │ ├── 36.json │ │ ├── 37.json │ │ ├── 38.json │ │ ├── 39.json │ │ ├── 40.json │ │ ├── 41.json │ │ ├── 42.json │ │ ├── 43.json │ │ ├── 44.json │ │ ├── 45.json │ │ ├── 46.json │ │ ├── 47.json │ │ ├── 48.json │ │ ├── 49.json │ │ ├── 50.json │ │ ├── 51.json │ │ ├── 52.json │ │ ├── 53.json │ │ ├── 54.json │ │ ├── 56.json │ │ ├── 58.json │ │ ├── 60.json │ │ ├── 62.json │ │ ├── 64.json │ │ ├── 66.json │ │ ├── 68.json │ │ └── 70.json │ └── src/ │ ├── green/ │ │ └── res/ │ │ └── values/ │ │ └── flavor-colors.xml │ ├── main/ │ │ ├── AndroidManifest.xml │ │ ├── java/ │ │ │ └── com/ │ │ │ └── keylesspalace/ │ │ │ └── tusky/ │ │ │ ├── AboutActivity.kt │ │ │ ├── AccountsInListFragment.kt │ │ │ ├── BaseActivity.kt │ │ │ ├── BottomSheetActivity.kt │ │ │ ├── EditProfileActivity.kt │ │ │ ├── LicenseActivity.kt │ │ │ ├── ListsActivity.kt │ │ │ ├── MainActivity.kt │ │ │ ├── MainViewModel.kt │ │ │ ├── StatusListActivity.kt │ │ │ ├── TabData.kt │ │ │ ├── TabPreferenceActivity.kt │ │ │ ├── TuskyApplication.kt │ │ │ ├── ViewMediaActivity.kt │ │ │ ├── adapter/ │ │ │ │ ├── AccountFieldEditAdapter.kt │ │ │ │ ├── AccountSelectionAdapter.kt │ │ │ │ ├── AccountViewHolder.kt │ │ │ │ ├── EmojiAdapter.kt │ │ │ │ ├── FilteredStatusViewHolder.kt │ │ │ │ ├── FollowRequestViewHolder.kt │ │ │ │ ├── LoadMoreViewHolder.kt │ │ │ │ ├── LoadStateFooterAdapter.kt │ │ │ │ ├── LocaleAdapter.kt │ │ │ │ ├── PlaceholderViewHolder.kt │ │ │ │ ├── PollAdapter.kt │ │ │ │ ├── PreviewPollOptionsAdapter.kt │ │ │ │ ├── StatusBaseViewHolder.java │ │ │ │ ├── StatusDetailedViewHolder.java │ │ │ │ ├── StatusViewHolder.java │ │ │ │ └── TabAdapter.kt │ │ │ ├── appstore/ │ │ │ │ ├── CacheUpdater.kt │ │ │ │ ├── Events.kt │ │ │ │ └── EventsHub.kt │ │ │ ├── components/ │ │ │ │ ├── account/ │ │ │ │ │ ├── AccountActivity.kt │ │ │ │ │ ├── AccountFieldAdapter.kt │ │ │ │ │ ├── AccountPagerAdapter.kt │ │ │ │ │ ├── AccountViewModel.kt │ │ │ │ │ ├── list/ │ │ │ │ │ │ ├── ListSelectionFragment.kt │ │ │ │ │ │ └── ListsForAccountViewModel.kt │ │ │ │ │ └── media/ │ │ │ │ │ ├── AccountMediaFragment.kt │ │ │ │ │ ├── AccountMediaGridAdapter.kt │ │ │ │ │ ├── AccountMediaPagingSource.kt │ │ │ │ │ ├── AccountMediaRemoteMediator.kt │ │ │ │ │ ├── AccountMediaViewModel.kt │ │ │ │ │ ├── GridSpacingItemDecoration.kt │ │ │ │ │ └── SquareImageView.kt │ │ │ │ ├── accountlist/ │ │ │ │ │ ├── AccountListActivity.kt │ │ │ │ │ ├── AccountListFragment.kt │ │ │ │ │ ├── AccountListPagingSource.kt │ │ │ │ │ ├── AccountListRemoteMediator.kt │ │ │ │ │ ├── AccountListViewModel.kt │ │ │ │ │ ├── AccountViewData.kt │ │ │ │ │ └── adapter/ │ │ │ │ │ ├── AccountAdapter.kt │ │ │ │ │ ├── BlocksAdapter.kt │ │ │ │ │ ├── FollowAdapter.kt │ │ │ │ │ ├── FollowRequestsAdapter.kt │ │ │ │ │ ├── FollowRequestsHeaderAdapter.kt │ │ │ │ │ └── MutesAdapter.kt │ │ │ │ ├── announcements/ │ │ │ │ │ ├── AnnouncementAdapter.kt │ │ │ │ │ ├── AnnouncementsActivity.kt │ │ │ │ │ └── AnnouncementsViewModel.kt │ │ │ │ ├── compose/ │ │ │ │ │ ├── ComposeActivity.kt │ │ │ │ │ ├── ComposeAutoCompleteAdapter.kt │ │ │ │ │ ├── ComposeTokenizer.kt │ │ │ │ │ ├── ComposeViewModel.kt │ │ │ │ │ ├── ImageDownsizer.kt │ │ │ │ │ ├── MediaPreviewAdapter.kt │ │ │ │ │ ├── MediaUploader.kt │ │ │ │ │ ├── dialog/ │ │ │ │ │ │ ├── AddPollDialog.kt │ │ │ │ │ │ ├── AddPollOptionsAdapter.kt │ │ │ │ │ │ ├── CaptionDialog.kt │ │ │ │ │ │ └── FocusDialog.kt │ │ │ │ │ └── view/ │ │ │ │ │ ├── ComposeOptionsView.kt │ │ │ │ │ ├── ComposeScheduleView.kt │ │ │ │ │ ├── EditTextTyped.kt │ │ │ │ │ ├── FocusIndicatorView.kt │ │ │ │ │ ├── PollPreviewView.kt │ │ │ │ │ ├── ProgressImageView.kt │ │ │ │ │ └── TootButton.kt │ │ │ │ ├── conversation/ │ │ │ │ │ ├── ConversationEntity.kt │ │ │ │ │ ├── ConversationPagingAdapter.kt │ │ │ │ │ ├── ConversationViewData.kt │ │ │ │ │ ├── ConversationViewHolder.java │ │ │ │ │ ├── ConversationsFragment.kt │ │ │ │ │ ├── ConversationsRemoteMediator.kt │ │ │ │ │ └── ConversationsViewModel.kt │ │ │ │ ├── domainblocks/ │ │ │ │ │ ├── DomainBlocksActivity.kt │ │ │ │ │ ├── DomainBlocksAdapter.kt │ │ │ │ │ ├── DomainBlocksFragment.kt │ │ │ │ │ ├── DomainBlocksPagingSource.kt │ │ │ │ │ ├── DomainBlocksRemoteMediator.kt │ │ │ │ │ ├── DomainBlocksRepository.kt │ │ │ │ │ └── DomainBlocksViewModel.kt │ │ │ │ ├── drafts/ │ │ │ │ │ ├── DraftHelper.kt │ │ │ │ │ ├── DraftMediaAdapter.kt │ │ │ │ │ ├── DraftsActivity.kt │ │ │ │ │ ├── DraftsAdapter.kt │ │ │ │ │ └── DraftsViewModel.kt │ │ │ │ ├── filters/ │ │ │ │ │ ├── EditFilterActivity.kt │ │ │ │ │ ├── EditFilterViewModel.kt │ │ │ │ │ ├── FilterExpiration.kt │ │ │ │ │ ├── FilterExtensions.kt │ │ │ │ │ ├── FiltersActivity.kt │ │ │ │ │ ├── FiltersAdapter.kt │ │ │ │ │ ├── FiltersListener.kt │ │ │ │ │ └── FiltersViewModel.kt │ │ │ │ ├── followedtags/ │ │ │ │ │ ├── FollowedTagsActivity.kt │ │ │ │ │ ├── FollowedTagsAdapter.kt │ │ │ │ │ ├── FollowedTagsPagingSource.kt │ │ │ │ │ ├── FollowedTagsRemoteMediator.kt │ │ │ │ │ └── FollowedTagsViewModel.kt │ │ │ │ ├── instanceinfo/ │ │ │ │ │ ├── InstanceInfo.kt │ │ │ │ │ └── InstanceInfoRepository.kt │ │ │ │ ├── login/ │ │ │ │ │ ├── LoginActivity.kt │ │ │ │ │ ├── LoginWebViewActivity.kt │ │ │ │ │ └── LoginWebViewViewModel.kt │ │ │ │ ├── notifications/ │ │ │ │ │ ├── FollowViewHolder.kt │ │ │ │ │ ├── ModerationWarningViewHolder.kt │ │ │ │ │ ├── NotificationPolicySummaryAdapter.kt │ │ │ │ │ ├── NotificationTypeMappers.kt │ │ │ │ │ ├── NotificationsFragment.kt │ │ │ │ │ ├── NotificationsPagingAdapter.kt │ │ │ │ │ ├── NotificationsRemoteMediator.kt │ │ │ │ │ ├── NotificationsViewModel.kt │ │ │ │ │ ├── ReportNotificationViewHolder.kt │ │ │ │ │ ├── SeveredRelationshipNotificationViewHolder.kt │ │ │ │ │ ├── StatusNotificationViewHolder.kt │ │ │ │ │ ├── StatusViewHolder.kt │ │ │ │ │ ├── UnknownNotificationViewHolder.kt │ │ │ │ │ └── requests/ │ │ │ │ │ ├── NotificationRequestsActivity.kt │ │ │ │ │ ├── NotificationRequestsAdapter.kt │ │ │ │ │ ├── NotificationRequestsPagingSource.kt │ │ │ │ │ ├── NotificationRequestsRemoteMediator.kt │ │ │ │ │ ├── NotificationRequestsViewModel.kt │ │ │ │ │ └── details/ │ │ │ │ │ ├── NotificationRequestDetailsActivity.kt │ │ │ │ │ ├── NotificationRequestDetailsFragment.kt │ │ │ │ │ ├── NotificationRequestDetailsPagingSource.kt │ │ │ │ │ ├── NotificationRequestDetailsRemoteMediator.kt │ │ │ │ │ └── NotificationRequestDetailsViewModel.kt │ │ │ │ ├── preference/ │ │ │ │ │ ├── AccountPreferencesFragment.kt │ │ │ │ │ ├── BasePreferencesFragment.kt │ │ │ │ │ ├── NotificationPreferencesFragment.kt │ │ │ │ │ ├── PreferencesActivity.kt │ │ │ │ │ ├── PreferencesFragment.kt │ │ │ │ │ ├── ProxyPreferencesFragment.kt │ │ │ │ │ ├── TabFilterPreferencesFragment.kt │ │ │ │ │ └── notificationpolicies/ │ │ │ │ │ ├── NotificationPoliciesActivity.kt │ │ │ │ │ ├── NotificationPoliciesFragment.kt │ │ │ │ │ ├── NotificationPoliciesViewModel.kt │ │ │ │ │ └── NotificationPolicyPreference.kt │ │ │ │ ├── report/ │ │ │ │ │ ├── ReportActivity.kt │ │ │ │ │ ├── ReportViewModel.kt │ │ │ │ │ ├── Screen.kt │ │ │ │ │ ├── adapter/ │ │ │ │ │ │ ├── AdapterHandler.kt │ │ │ │ │ │ ├── ReportPagerAdapter.kt │ │ │ │ │ │ ├── StatusViewHolder.kt │ │ │ │ │ │ ├── StatusesAdapter.kt │ │ │ │ │ │ └── StatusesPagingSource.kt │ │ │ │ │ ├── fragments/ │ │ │ │ │ │ ├── ReportDoneFragment.kt │ │ │ │ │ │ ├── ReportNoteFragment.kt │ │ │ │ │ │ └── ReportStatusesFragment.kt │ │ │ │ │ └── model/ │ │ │ │ │ └── StatusViewState.kt │ │ │ │ ├── scheduled/ │ │ │ │ │ ├── ScheduledStatusActivity.kt │ │ │ │ │ ├── ScheduledStatusAdapter.kt │ │ │ │ │ ├── ScheduledStatusPagingSource.kt │ │ │ │ │ └── ScheduledStatusViewModel.kt │ │ │ │ ├── search/ │ │ │ │ │ ├── SearchActivity.kt │ │ │ │ │ ├── SearchType.kt │ │ │ │ │ ├── SearchViewModel.kt │ │ │ │ │ ├── adapter/ │ │ │ │ │ │ ├── SearchAccountsAdapter.kt │ │ │ │ │ │ ├── SearchHashtagsAdapter.kt │ │ │ │ │ │ ├── SearchPagerAdapter.kt │ │ │ │ │ │ ├── SearchPagingSource.kt │ │ │ │ │ │ ├── SearchPagingSourceFactory.kt │ │ │ │ │ │ └── SearchStatusesAdapter.kt │ │ │ │ │ └── fragments/ │ │ │ │ │ ├── SearchAccountsFragment.kt │ │ │ │ │ ├── SearchFragment.kt │ │ │ │ │ ├── SearchHashtagsFragment.kt │ │ │ │ │ └── SearchStatusesFragment.kt │ │ │ │ ├── systemnotifications/ │ │ │ │ │ ├── NotificationChannelData.kt │ │ │ │ │ ├── NotificationFetcher.kt │ │ │ │ │ └── NotificationService.kt │ │ │ │ ├── timeline/ │ │ │ │ │ ├── TimelineFragment.kt │ │ │ │ │ ├── TimelinePagingAdapter.kt │ │ │ │ │ ├── TimelineTypeMappers.kt │ │ │ │ │ ├── util/ │ │ │ │ │ │ └── TimelineUtils.kt │ │ │ │ │ └── viewmodel/ │ │ │ │ │ ├── CachedTimelineRemoteMediator.kt │ │ │ │ │ ├── CachedTimelineViewModel.kt │ │ │ │ │ ├── NetworkTimelinePagingSource.kt │ │ │ │ │ ├── NetworkTimelineRemoteMediator.kt │ │ │ │ │ ├── NetworkTimelineViewModel.kt │ │ │ │ │ └── TimelineViewModel.kt │ │ │ │ ├── trending/ │ │ │ │ │ ├── TrendingActivity.kt │ │ │ │ │ ├── TrendingDateViewHolder.kt │ │ │ │ │ ├── TrendingTagViewHolder.kt │ │ │ │ │ ├── TrendingTagsAdapter.kt │ │ │ │ │ ├── TrendingTagsFragment.kt │ │ │ │ │ └── viewmodel/ │ │ │ │ │ └── TrendingTagsViewModel.kt │ │ │ │ └── viewthread/ │ │ │ │ ├── ConversationLineItemDecoration.kt │ │ │ │ ├── ThreadAdapter.kt │ │ │ │ ├── ViewThreadActivity.kt │ │ │ │ ├── ViewThreadFragment.kt │ │ │ │ ├── ViewThreadViewModel.kt │ │ │ │ └── edits/ │ │ │ │ ├── ViewEditsAdapter.kt │ │ │ │ ├── ViewEditsFragment.kt │ │ │ │ └── ViewEditsViewModel.kt │ │ │ ├── db/ │ │ │ │ ├── AccountManager.kt │ │ │ │ ├── AppDatabase.java │ │ │ │ ├── ConversationsDao.kt │ │ │ │ ├── Converters.kt │ │ │ │ ├── DatabaseCleaner.kt │ │ │ │ ├── DraftsAlert.kt │ │ │ │ ├── dao/ │ │ │ │ │ ├── AccountDao.kt │ │ │ │ │ ├── DraftDao.kt │ │ │ │ │ ├── InstanceDao.kt │ │ │ │ │ ├── NotificationPolicyDao.kt │ │ │ │ │ ├── NotificationsDao.kt │ │ │ │ │ ├── TimelineAccountDao.kt │ │ │ │ │ ├── TimelineDao.kt │ │ │ │ │ └── TimelineStatusDao.kt │ │ │ │ └── entity/ │ │ │ │ ├── AccountEntity.kt │ │ │ │ ├── DraftEntity.kt │ │ │ │ ├── HomeTimelineEntity.kt │ │ │ │ ├── InstanceEntity.kt │ │ │ │ ├── NotificationEntity.kt │ │ │ │ ├── NotificationPolicyEntity.kt │ │ │ │ ├── TimelineAccountEntity.kt │ │ │ │ └── TimelineStatusEntity.kt │ │ │ ├── di/ │ │ │ │ ├── CoroutineScopeModule.kt │ │ │ │ ├── NetworkModule.kt │ │ │ │ ├── NotificationManagerModule.kt │ │ │ │ ├── PlayerModule.kt │ │ │ │ ├── PreferencesEntryPoint.kt │ │ │ │ └── StorageModule.kt │ │ │ ├── entity/ │ │ │ │ ├── AccessToken.kt │ │ │ │ ├── Account.kt │ │ │ │ ├── AccountWarning.kt │ │ │ │ ├── Announcement.kt │ │ │ │ ├── AppCredentials.kt │ │ │ │ ├── Attachment.kt │ │ │ │ ├── Conversation.kt │ │ │ │ ├── DeletedStatus.kt │ │ │ │ ├── Emoji.kt │ │ │ │ ├── Error.kt │ │ │ │ ├── Filter.kt │ │ │ │ ├── FilterKeyword.kt │ │ │ │ ├── FilterResult.kt │ │ │ │ ├── FilterV1.kt │ │ │ │ ├── HashTag.kt │ │ │ │ ├── Instance.kt │ │ │ │ ├── InstanceV1.kt │ │ │ │ ├── Marker.kt │ │ │ │ ├── MastoList.kt │ │ │ │ ├── MediaUploadResult.kt │ │ │ │ ├── NewStatus.kt │ │ │ │ ├── Notification.kt │ │ │ │ ├── NotificationPolicy.kt │ │ │ │ ├── NotificationRequest.kt │ │ │ │ ├── NotificationSubscribeResult.kt │ │ │ │ ├── Poll.kt │ │ │ │ ├── PreviewCard.kt │ │ │ │ ├── Relationship.kt │ │ │ │ ├── RelationshipSeveranceEvent.kt │ │ │ │ ├── Report.kt │ │ │ │ ├── ScheduledStatus.kt │ │ │ │ ├── SearchResult.kt │ │ │ │ ├── Status.kt │ │ │ │ ├── StatusContext.kt │ │ │ │ ├── StatusEdit.kt │ │ │ │ ├── StatusParams.kt │ │ │ │ ├── StatusSource.kt │ │ │ │ ├── TimelineAccount.kt │ │ │ │ ├── Translation.kt │ │ │ │ └── TrendingTagsResult.kt │ │ │ ├── fragment/ │ │ │ │ ├── SFragment.kt │ │ │ │ ├── ViewImageFragment.kt │ │ │ │ ├── ViewMediaFragment.kt │ │ │ │ └── ViewVideoFragment.kt │ │ │ ├── interfaces/ │ │ │ │ ├── AccountActionListener.kt │ │ │ │ ├── AccountSelectionListener.kt │ │ │ │ ├── ActionButtonActivity.java │ │ │ │ ├── HashtagActionListener.kt │ │ │ │ ├── LinkListener.kt │ │ │ │ ├── RefreshableFragment.kt │ │ │ │ ├── ReselectableFragment.kt │ │ │ │ └── StatusActionListener.kt │ │ │ ├── json/ │ │ │ │ ├── Guarded.kt │ │ │ │ ├── GuardedAdapter.kt │ │ │ │ └── NotificationTypeAdapter.kt │ │ │ ├── network/ │ │ │ │ ├── ApiFactory.kt │ │ │ │ ├── FailingCall.kt │ │ │ │ ├── FilterModel.kt │ │ │ │ ├── MastodonApi.kt │ │ │ │ ├── MediaUploadApi.kt │ │ │ │ └── UriRequestBody.kt │ │ │ ├── pager/ │ │ │ │ ├── ImagePagerAdapter.kt │ │ │ │ ├── MainPagerAdapter.kt │ │ │ │ └── SingleImagePagerAdapter.kt │ │ │ ├── receiver/ │ │ │ │ ├── NotificationBlockStateBroadcastReceiver.kt │ │ │ │ ├── SendStatusBroadcastReceiver.kt │ │ │ │ └── UnifiedPushBroadcastReceiver.kt │ │ │ ├── service/ │ │ │ │ ├── SendStatusService.kt │ │ │ │ ├── ServiceClient.kt │ │ │ │ └── TuskyTileService.kt │ │ │ ├── settings/ │ │ │ │ ├── AccountPreferenceDataStore.kt │ │ │ │ ├── DefaultReplyVisibility.kt │ │ │ │ ├── ProxyConfiguration.kt │ │ │ │ ├── SettingsConstants.kt │ │ │ │ └── SettingsDSL.kt │ │ │ ├── usecase/ │ │ │ │ ├── DeveloperToolsUseCase.kt │ │ │ │ ├── LogoutUsecase.kt │ │ │ │ ├── NotificationPolicyUsecase.kt │ │ │ │ └── TimelineCases.kt │ │ │ ├── util/ │ │ │ │ ├── AbsoluteTimeFormatter.kt │ │ │ │ ├── ActivityExtensions.kt │ │ │ │ ├── AlertDialogExtensions.kt │ │ │ │ ├── AsciiFolding.kt │ │ │ │ ├── AttachmentHelper.kt │ │ │ │ ├── BindingHolder.kt │ │ │ │ ├── BlurHashDecoder.kt │ │ │ │ ├── BlurhashDrawable.kt │ │ │ │ ├── BundleExtensions.kt │ │ │ │ ├── CardViewMode.kt │ │ │ │ ├── CompositeWithOpaqueBackground.kt │ │ │ │ ├── CryptoUtil.kt │ │ │ │ ├── CustomEmojiHelper.kt │ │ │ │ ├── CustomFragmentStateAdapter.kt │ │ │ │ ├── FocalPointUtil.kt │ │ │ │ ├── GlideExtensions.kt │ │ │ │ ├── GlideModule.kt │ │ │ │ ├── HttpHeaderLink.kt │ │ │ │ ├── IOUtils.kt │ │ │ │ ├── IconUtils.kt │ │ │ │ ├── ImageLoadingHelper.kt │ │ │ │ ├── Lazy.kt │ │ │ │ ├── LifecycleExtensions.kt │ │ │ │ ├── LinkHelper.kt │ │ │ │ ├── ListStatusAccessibilityDelegate.kt │ │ │ │ ├── ListUtils.kt │ │ │ │ ├── LocaleExtensions.kt │ │ │ │ ├── LocaleManager.kt │ │ │ │ ├── LocaleUtils.kt │ │ │ │ ├── MediaUtils.kt │ │ │ │ ├── NoUnderlineURLSpan.kt │ │ │ │ ├── NumberUtils.kt │ │ │ │ ├── PickMediaFiles.kt │ │ │ │ ├── RelativeTimeUpdater.kt │ │ │ │ ├── Resource.kt │ │ │ │ ├── RickRoll.kt │ │ │ │ ├── ShareShortcutHelper.kt │ │ │ │ ├── SharedPreferencesExtensions.kt │ │ │ │ ├── SmartLengthInputFilter.kt │ │ │ │ ├── SpanUtils.kt │ │ │ │ ├── StatusDisplayOptions.kt │ │ │ │ ├── StatusParsingHelper.kt │ │ │ │ ├── StatusViewHelper.kt │ │ │ │ ├── StringUtils.kt │ │ │ │ ├── ThemeUtils.kt │ │ │ │ ├── ThrowableExtensions.kt │ │ │ │ ├── TimestampUtils.kt │ │ │ │ ├── TouchDelegateHelper.kt │ │ │ │ ├── ViewBindingExtensions.kt │ │ │ │ ├── ViewDataUtils.kt │ │ │ │ ├── ViewExtensions.kt │ │ │ │ └── twittertext/ │ │ │ │ ├── Regex.java │ │ │ │ └── TldLists.java │ │ │ ├── view/ │ │ │ │ ├── AdaptiveTabLayout.kt │ │ │ │ ├── BackgroundMessageView.kt │ │ │ │ ├── BezelImageView.kt │ │ │ │ ├── ClickableSpanTextView.kt │ │ │ │ ├── ConfirmationBottomSheet.kt │ │ │ │ ├── EmojiPicker.kt │ │ │ │ ├── GraphView.kt │ │ │ │ ├── HashtagPickerDialog.kt │ │ │ │ ├── LicenseCard.kt │ │ │ │ ├── MediaPreviewImageView.kt │ │ │ │ ├── MediaPreviewLayout.kt │ │ │ │ ├── MuteAccountDialog.kt │ │ │ │ ├── SliderPreference.kt │ │ │ │ └── TuskySwipeRefreshLayout.kt │ │ │ ├── viewdata/ │ │ │ │ ├── AttachmentViewData.kt │ │ │ │ ├── NotificationViewData.kt │ │ │ │ ├── PollViewData.kt │ │ │ │ ├── StatusViewData.kt │ │ │ │ └── TrendingViewData.kt │ │ │ ├── viewmodel/ │ │ │ │ ├── AccountsInListViewModel.kt │ │ │ │ ├── EditProfileViewModel.kt │ │ │ │ └── ListsViewModel.kt │ │ │ └── worker/ │ │ │ ├── NotificationWorker.kt │ │ │ └── PruneCacheWorker.kt │ │ └── res/ │ │ ├── anim/ │ │ │ ├── activity_close_enter.xml │ │ │ ├── activity_close_exit.xml │ │ │ ├── activity_open_enter.xml │ │ │ ├── activity_open_exit.xml │ │ │ └── fast_out_extra_slow_in.xml │ │ ├── color/ │ │ │ ├── account_tab_font_color.xml │ │ │ ├── color_background_transparent_60.xml │ │ │ ├── compound_button_color.xml │ │ │ ├── selectable_chip_background.xml │ │ │ └── text_input_layout_box_stroke_color.xml │ │ ├── drawable/ │ │ │ ├── audio_file_preview.xml │ │ │ ├── avatar_border.xml │ │ │ ├── avatar_default.xml │ │ │ ├── background_dialog_activity.xml │ │ │ ├── badge_background.xml │ │ │ ├── bot_badge.xml │ │ │ ├── card_image_placeholder.xml │ │ │ ├── conversation_thread_line.xml │ │ │ ├── description_bg_expanded.xml │ │ │ ├── dialog_background.xml │ │ │ ├── elephant_friend.xml │ │ │ ├── elephant_friend_empty.xml │ │ │ ├── errorphant_error.xml │ │ │ ├── errorphant_offline.xml │ │ │ ├── ic_add_24dp.xml │ │ │ ├── ic_add_a_photo_32dp_filled.xml │ │ │ ├── ic_arrow_back_24dp.xml │ │ │ ├── ic_arrow_drop_down_24dp.xml │ │ │ ├── ic_arrow_drop_up_24dp.xml │ │ │ ├── ic_attach_file_24dp.xml │ │ │ ├── ic_block_24dp.xml │ │ │ ├── ic_bookmark_24dp.xml │ │ │ ├── ic_bookmark_24dp_filled.xml │ │ │ ├── ic_bot_24dp.xml │ │ │ ├── ic_bottom_navigation_24dp.xml │ │ │ ├── ic_bottom_navigation_24dp_mirrored.xml │ │ │ ├── ic_campaign_24dp.xml │ │ │ ├── ic_cancel_24dp_filled.xml │ │ │ ├── ic_check_24dp.xml │ │ │ ├── ic_check_box_outline_blank_18dp.xml │ │ │ ├── ic_check_circle_24dp.xml │ │ │ ├── ic_chevron_right_24dp.xml │ │ │ ├── ic_close_24dp.xml │ │ │ ├── ic_comments_disabled_24dp.xml │ │ │ ├── ic_content_copy_24dp.xml │ │ │ ├── ic_developer_mode_24dp.xml │ │ │ ├── ic_done_outline_24dp.xml │ │ │ ├── ic_download_24dp.xml │ │ │ ├── ic_drag_indicator_24dp.xml │ │ │ ├── ic_edit_24dp_filled.xml │ │ │ ├── ic_edit_document_24dp.xml │ │ │ ├── ic_email_alternate_18dp.xml │ │ │ ├── ic_email_alternate_24dp.xml │ │ │ ├── ic_error_24dp.xml │ │ │ ├── ic_feedback_24dp_filled.xml │ │ │ ├── ic_filter_alt_24dp.xml │ │ │ ├── ic_flag_24dp.xml │ │ │ ├── ic_format_size_24dp.xml │ │ │ ├── ic_gavel_24dp.xml │ │ │ ├── ic_gif_box_24dp.xml │ │ │ ├── ic_group_24dp.xml │ │ │ ├── ic_group_24dp_filled.xml │ │ │ ├── ic_heart_broken_24.xml │ │ │ ├── ic_help_24dp.xml │ │ │ ├── ic_home_24dp.xml │ │ │ ├── ic_home_24dp_filled.xml │ │ │ ├── ic_image_24dp.xml │ │ │ ├── ic_info_24dp.xml │ │ │ ├── ic_insert_chart_24dp.xml │ │ │ ├── ic_insert_chart_24dp_filled.xml │ │ │ ├── ic_list_alt_24dp.xml │ │ │ ├── ic_list_alt_24dp_filled.xml │ │ │ ├── ic_local_fire_department_24dp.xml │ │ │ ├── ic_local_fire_department_24dp_filled.xml │ │ │ ├── ic_lock_24dp.xml │ │ │ ├── ic_lock_24dp_filled.xml │ │ │ ├── ic_lock_open_24dp.xml │ │ │ ├── ic_logout_24dp.xml │ │ │ ├── ic_mail_24dp.xml │ │ │ ├── ic_mail_24dp_filled.xml │ │ │ ├── ic_manage_accounts_24dp.xml │ │ │ ├── ic_mood_24dp.xml │ │ │ ├── ic_more_horiz_24dp.xml │ │ │ ├── ic_more_vert_24dp.xml │ │ │ ├── ic_music_box_24dp.xml │ │ │ ├── ic_notifications_24dp.xml │ │ │ ├── ic_notifications_24dp_filled.xml │ │ │ ├── ic_notifications_active_24dp.xml │ │ │ ├── ic_open_in_new_24dp.xml │ │ │ ├── ic_palette_24dp.xml │ │ │ ├── ic_person_24dp.xml │ │ │ ├── ic_person_add_24dp_mirrored.xml │ │ │ ├── ic_person_add_24dp_mirrored_filled.xml │ │ │ ├── ic_person_remove_24dp_mirrored.xml │ │ │ ├── ic_photo_camera_24dp.xml │ │ │ ├── ic_public_24dp.xml │ │ │ ├── ic_radio_button_unchecked_18dp.xml │ │ │ ├── ic_reblog_direct_24dp.xml │ │ │ ├── ic_repeat_18dp.xml │ │ │ ├── ic_repeat_24dp.xml │ │ │ ├── ic_repeat_active_24dp.xml │ │ │ ├── ic_reply_18dp.xml │ │ │ ├── ic_reply_24dp.xml │ │ │ ├── ic_reply_all_24dp.xml │ │ │ ├── ic_schedule_24dp.xml │ │ │ ├── ic_search_24dp.xml │ │ │ ├── ic_send_24dp.xml │ │ │ ├── ic_settings_24dp.xml │ │ │ ├── ic_share_24dp.xml │ │ │ ├── ic_slideshow_24dp.xml │ │ │ ├── ic_sort_24dp.xml │ │ │ ├── ic_spellcheck_24dp.xml │ │ │ ├── ic_star_24dp.xml │ │ │ ├── ic_star_24dp_filled.xml │ │ │ ├── ic_tabs_24dp.xml │ │ │ ├── ic_tag_24dp.xml │ │ │ ├── ic_translate_24dp.xml │ │ │ ├── ic_trip_24dp.xml │ │ │ ├── ic_verified_18dp.xml │ │ │ ├── ic_visibility_24dp.xml │ │ │ ├── ic_visibility_off_24dp.xml │ │ │ ├── ic_volume_off_24dp.xml │ │ │ ├── ic_volume_up_24dp.xml │ │ │ ├── ic_whatshot_24dp.xml │ │ │ ├── ic_whatshot_24dp_filled.xml │ │ │ ├── ic_zoom_in_24dp.xml │ │ │ ├── ic_zoom_out_24dp.xml │ │ │ ├── launcher_foreground.xml │ │ │ ├── launcher_monochrome.xml │ │ │ ├── materialdrawer_shape_large.xml │ │ │ ├── materialdrawer_shape_small.xml │ │ │ ├── media_preview_outline.xml │ │ │ ├── media_warning_bg.xml │ │ │ ├── play_indicator.xml │ │ │ ├── poll_option_background.xml │ │ │ ├── poll_option_shape.xml │ │ │ ├── report_success_background.xml │ │ │ ├── round_button.xml │ │ │ ├── splashscreen.xml │ │ │ ├── status_divider.xml │ │ │ ├── tab_icon_bookmarks.xml │ │ │ ├── tab_icon_direct.xml │ │ │ ├── tab_icon_home.xml │ │ │ ├── tab_icon_list.xml │ │ │ ├── tab_icon_local.xml │ │ │ ├── tab_icon_notifications.xml │ │ │ ├── tab_icon_trending_posts.xml │ │ │ ├── tab_icon_trending_tags.xml │ │ │ ├── tab_indicator_bottom.xml │ │ │ ├── tab_indicator_top.xml │ │ │ ├── text_placeholder.xml │ │ │ ├── toolbar_icon_arrow_back_with_background.xml │ │ │ ├── toolbar_icon_more_with_background.xml │ │ │ ├── tusky_notification_icon.xml │ │ │ └── tusky_quicksettings_icon.xml │ │ ├── layout/ │ │ │ ├── activity_about.xml │ │ │ ├── activity_account.xml │ │ │ ├── activity_account_list.xml │ │ │ ├── activity_announcements.xml │ │ │ ├── activity_compose.xml │ │ │ ├── activity_drafts.xml │ │ │ ├── activity_edit_filter.xml │ │ │ ├── activity_edit_profile.xml │ │ │ ├── activity_filters.xml │ │ │ ├── activity_followed_tags.xml │ │ │ ├── activity_license.xml │ │ │ ├── activity_lists.xml │ │ │ ├── activity_login.xml │ │ │ ├── activity_login_webview.xml │ │ │ ├── activity_main.xml │ │ │ ├── activity_notification_policy.xml │ │ │ ├── activity_notification_request_details.xml │ │ │ ├── activity_notification_requests.xml │ │ │ ├── activity_preferences.xml │ │ │ ├── activity_report.xml │ │ │ ├── activity_scheduled_status.xml │ │ │ ├── activity_search.xml │ │ │ ├── activity_statuslist.xml │ │ │ ├── activity_tab_preference.xml │ │ │ ├── activity_trending.xml │ │ │ ├── activity_view_media.xml │ │ │ ├── activity_view_thread.xml │ │ │ ├── bottomsheet_confirmation.xml │ │ │ ├── card_license.xml │ │ │ ├── dialog_add_poll.xml │ │ │ ├── dialog_filter.xml │ │ │ ├── dialog_focus.xml │ │ │ ├── dialog_image_description.xml │ │ │ ├── dialog_list.xml │ │ │ ├── dialog_mute_account.xml │ │ │ ├── dialog_pick_hashtag.xml │ │ │ ├── exo_player_control_view.xml │ │ │ ├── fragment_account_list.xml │ │ │ ├── fragment_accounts_in_list.xml │ │ │ ├── fragment_domain_blocks.xml │ │ │ ├── fragment_lists_list.xml │ │ │ ├── fragment_notification_request_details.xml │ │ │ ├── fragment_report_done.xml │ │ │ ├── fragment_report_note.xml │ │ │ ├── fragment_report_statuses.xml │ │ │ ├── fragment_search.xml │ │ │ ├── fragment_timeline.xml │ │ │ ├── fragment_timeline_notifications.xml │ │ │ ├── fragment_trending_tags.xml │ │ │ ├── fragment_view_edits.xml │ │ │ ├── fragment_view_image.xml │ │ │ ├── fragment_view_thread.xml │ │ │ ├── fragment_view_video.xml │ │ │ ├── item_account.xml │ │ │ ├── item_account_field.xml │ │ │ ├── item_account_media.xml │ │ │ ├── item_add_poll_option.xml │ │ │ ├── item_announcement.xml │ │ │ ├── item_autocomplete_account.xml │ │ │ ├── item_autocomplete_emoji.xml │ │ │ ├── item_autocomplete_hashtag.xml │ │ │ ├── item_blocked_domain.xml │ │ │ ├── item_blocked_user.xml │ │ │ ├── item_conversation.xml │ │ │ ├── item_draft.xml │ │ │ ├── item_edit_field.xml │ │ │ ├── item_emoji_button.xml │ │ │ ├── item_filtered_notifications_info.xml │ │ │ ├── item_follow.xml │ │ │ ├── item_follow_request.xml │ │ │ ├── item_follow_requests_header.xml │ │ │ ├── item_followed_hashtag.xml │ │ │ ├── item_hashtag.xml │ │ │ ├── item_image_preview_overlay.xml │ │ │ ├── item_list.xml │ │ │ ├── item_load_more.xml │ │ │ ├── item_media_preview.xml │ │ │ ├── item_moderation_warning_notification.xml │ │ │ ├── item_muted_user.xml │ │ │ ├── item_network_state.xml │ │ │ ├── item_notification_request.xml │ │ │ ├── item_notifications_load_state_footer_view.xml │ │ │ ├── item_placeholder.xml │ │ │ ├── item_poll.xml │ │ │ ├── item_poll_preview_option.xml │ │ │ ├── item_preview_card.xml │ │ │ ├── item_reblog_option.xml │ │ │ ├── item_removable.xml │ │ │ ├── item_report_notification.xml │ │ │ ├── item_report_status.xml │ │ │ ├── item_scheduled_status.xml │ │ │ ├── item_severed_relationship_notification.xml │ │ │ ├── item_status.xml │ │ │ ├── item_status_bottom_sheet.xml │ │ │ ├── item_status_detailed.xml │ │ │ ├── item_status_edit.xml │ │ │ ├── item_status_filtered.xml │ │ │ ├── item_status_notification.xml │ │ │ ├── item_tab_preference.xml │ │ │ ├── item_tab_preference_small.xml │ │ │ ├── item_trending_cell.xml │ │ │ ├── item_trending_date.xml │ │ │ ├── item_unknown_notification.xml │ │ │ ├── material_drawer_header.xml │ │ │ ├── notifications_filter.xml │ │ │ ├── pref_slider.xml │ │ │ ├── preference_material_switch.xml │ │ │ ├── preference_notification_policy.xml │ │ │ ├── search_view.xml │ │ │ ├── simple_list_item_1.xml │ │ │ ├── toolbar_basic.xml │ │ │ ├── view_background_message.xml │ │ │ ├── view_compose_options.xml │ │ │ ├── view_compose_schedule.xml │ │ │ └── view_poll_preview.xml │ │ ├── layout-land/ │ │ │ ├── fragment_report_done.xml │ │ │ └── item_trending_cell.xml │ │ ├── layout-sw640dp/ │ │ │ ├── fragment_timeline.xml │ │ │ ├── fragment_timeline_notifications.xml │ │ │ └── fragment_view_thread.xml │ │ ├── menu/ │ │ │ ├── account_toolbar.xml │ │ │ ├── activity_announcements.xml │ │ │ ├── activity_main.xml │ │ │ ├── activity_notification_requests.xml │ │ │ ├── activity_scheduled_status.xml │ │ │ ├── conversation_more.xml │ │ │ ├── edit_profile_toolbar.xml │ │ │ ├── fragment_account_media.xml │ │ │ ├── fragment_conversations.xml │ │ │ ├── fragment_notifications.xml │ │ │ ├── fragment_report_statuses.xml │ │ │ ├── fragment_search.xml │ │ │ ├── fragment_timeline.xml │ │ │ ├── fragment_view_edits.xml │ │ │ ├── fragment_view_thread.xml │ │ │ ├── list_actions.xml │ │ │ ├── search_toolbar.xml │ │ │ ├── status_more.xml │ │ │ ├── status_more_for_user.xml │ │ │ ├── view_hashtag_toolbar.xml │ │ │ └── view_media_toolbar.xml │ │ ├── mipmap-anydpi-v26/ │ │ │ └── ic_launcher.xml │ │ ├── raw/ │ │ │ ├── apache.txt │ │ │ ├── isrg_root_x1.pem │ │ │ └── isrg_root_x2.pem │ │ ├── values/ │ │ │ ├── actions.xml │ │ │ ├── attrs.xml │ │ │ ├── colors.xml │ │ │ ├── dimens.xml │ │ │ ├── donottranslate.xml │ │ │ ├── ids.xml │ │ │ ├── string-arrays.xml │ │ │ ├── strings.xml │ │ │ ├── styles.xml │ │ │ ├── theme_colors.xml │ │ │ ├── toot_button.xml │ │ │ └── values.xml │ │ ├── values-ar/ │ │ │ └── strings.xml │ │ ├── values-be/ │ │ │ └── strings.xml │ │ ├── values-ber/ │ │ │ └── strings.xml │ │ ├── values-bg/ │ │ │ └── strings.xml │ │ ├── values-bn-rBD/ │ │ │ └── strings.xml │ │ ├── values-bn-rIN/ │ │ │ └── strings.xml │ │ ├── values-ca/ │ │ │ └── strings.xml │ │ ├── values-ckb/ │ │ │ └── strings.xml │ │ ├── values-cs/ │ │ │ └── strings.xml │ │ ├── values-cy/ │ │ │ └── strings.xml │ │ ├── values-de/ │ │ │ └── strings.xml │ │ ├── values-el/ │ │ │ └── strings.xml │ │ ├── values-en-rAU/ │ │ │ └── strings.xml │ │ ├── values-en-rGB/ │ │ │ └── strings.xml │ │ ├── values-eo/ │ │ │ └── strings.xml │ │ ├── values-es/ │ │ │ └── strings.xml │ │ ├── values-eu/ │ │ │ └── strings.xml │ │ ├── values-fa/ │ │ │ └── strings.xml │ │ ├── values-fi/ │ │ │ └── strings.xml │ │ ├── values-fr/ │ │ │ └── strings.xml │ │ ├── values-fr-rBE/ │ │ │ └── strings.xml │ │ ├── values-fy/ │ │ │ └── strings.xml │ │ ├── values-ga/ │ │ │ └── strings.xml │ │ ├── values-gd/ │ │ │ └── strings.xml │ │ ├── values-gl/ │ │ │ └── strings.xml │ │ ├── values-hi/ │ │ │ └── strings.xml │ │ ├── values-hu/ │ │ │ └── strings.xml │ │ ├── values-in/ │ │ │ └── strings.xml │ │ ├── values-is/ │ │ │ └── strings.xml │ │ ├── values-it/ │ │ │ └── strings.xml │ │ ├── values-iw/ │ │ │ └── strings.xml │ │ ├── values-ja/ │ │ │ └── strings.xml │ │ ├── values-kab/ │ │ │ └── strings.xml │ │ ├── values-ko/ │ │ │ └── strings.xml │ │ ├── values-large/ │ │ │ ├── dimens.xml │ │ │ └── styles.xml │ │ ├── values-large-land/ │ │ │ └── dimens.xml │ │ ├── values-lb/ │ │ │ └── strings.xml │ │ ├── values-lv/ │ │ │ └── strings.xml │ │ ├── values-ml/ │ │ │ └── strings.xml │ │ ├── values-nb-rNO/ │ │ │ └── strings.xml │ │ ├── values-night/ │ │ │ └── theme_colors.xml │ │ ├── values-nl/ │ │ │ └── strings.xml │ │ ├── values-oc/ │ │ │ └── strings.xml │ │ ├── values-or/ │ │ │ └── strings.xml │ │ ├── values-pa/ │ │ │ └── strings.xml │ │ ├── values-pl/ │ │ │ └── strings.xml │ │ ├── values-pt-rBR/ │ │ │ └── strings.xml │ │ ├── values-pt-rPT/ │ │ │ └── strings.xml │ │ ├── values-ru/ │ │ │ └── strings.xml │ │ ├── values-sa/ │ │ │ └── strings.xml │ │ ├── values-si/ │ │ │ └── strings.xml │ │ ├── values-sk/ │ │ │ └── strings.xml │ │ ├── values-sl/ │ │ │ └── strings.xml │ │ ├── values-small/ │ │ │ └── integer.xml │ │ ├── values-sv/ │ │ │ └── strings.xml │ │ ├── values-sw380dp/ │ │ │ └── toot_button.xml │ │ ├── values-ta/ │ │ │ └── strings.xml │ │ ├── values-te/ │ │ │ └── strings.xml │ │ ├── values-th/ │ │ │ └── strings.xml │ │ ├── values-tr/ │ │ │ └── strings.xml │ │ ├── values-uk/ │ │ │ └── strings.xml │ │ ├── values-v27/ │ │ │ └── styles.xml │ │ ├── values-v35/ │ │ │ └── values.xml │ │ ├── values-vi/ │ │ │ └── strings.xml │ │ ├── values-w640dp/ │ │ │ ├── dimens.xml │ │ │ └── integers.xml │ │ ├── values-zh-rCN/ │ │ │ └── strings.xml │ │ ├── values-zh-rHK/ │ │ │ └── strings.xml │ │ ├── values-zh-rMO/ │ │ │ └── strings.xml │ │ ├── values-zh-rSG/ │ │ │ └── strings.xml │ │ ├── values-zh-rTW/ │ │ │ └── strings.xml │ │ └── xml/ │ │ ├── file_paths.xml │ │ ├── locales_config.xml │ │ ├── network_security_config.xml │ │ ├── searchable.xml │ │ └── share_shortcuts.xml │ └── test/ │ └── java/ │ └── com/ │ └── keylesspalace/ │ └── tusky/ │ ├── BottomSheetActivityTest.kt │ ├── FilterV1Test.kt │ ├── FocalPointUtilTest.kt │ ├── MainActivityTest.kt │ ├── SpanUtilsTest.kt │ ├── StatusComparisonTest.kt │ ├── StringUtilsTest.kt │ ├── TuskyApplication.kt │ ├── components/ │ │ ├── compose/ │ │ │ ├── ComposeActivityTest.kt │ │ │ ├── ComposeTokenizerTest.kt │ │ │ ├── ComposeViewModelTest.kt │ │ │ └── StatusLengthTest.kt │ │ ├── notifications/ │ │ │ ├── NotificationFaker.kt │ │ │ └── NotificationsRemoteMediatorTest.kt │ │ ├── timeline/ │ │ │ ├── CachedTimelineRemoteMediatorTest.kt │ │ │ ├── NetworkTimelinePagingSourceTest.kt │ │ │ ├── NetworkTimelineRemoteMediatorTest.kt │ │ │ └── TimelineFaker.kt │ │ └── viewthread/ │ │ └── ViewThreadViewModelTest.kt │ ├── db/ │ │ ├── MigrationsTest.kt │ │ └── dao/ │ │ ├── DatabaseCleanerTest.kt │ │ ├── NotificationsDaoTest.kt │ │ └── TimelineDaoTest.kt │ ├── entity/ │ │ └── ProxyConfigurationTest.kt │ ├── json/ │ │ └── GuardedAdapterTest.kt │ ├── network/ │ │ └── ApiFactoryTest.kt │ ├── usecase/ │ │ └── TimelineCasesTest.kt │ └── util/ │ ├── AbsoluteTimeFormatterTest.kt │ ├── HttpHeaderLinkTest.kt │ ├── LinkHelperTest.kt │ ├── LocaleUtilsTest.kt │ ├── NumberUtilsTest.kt │ ├── RickRollTest.kt │ ├── SmartLengthInputFilterTest.kt │ └── TimestampUtilsTest.kt ├── assets/ │ └── tusky_banner.xcf ├── build.gradle ├── doc/ │ ├── PaymentPolicy.md │ └── Release.md ├── fastlane/ │ └── metadata/ │ └── android/ │ ├── ar/ │ │ ├── changelogs/ │ │ │ ├── 61.txt │ │ │ ├── 68.txt │ │ │ └── 70.txt │ │ ├── full_description.txt │ │ ├── short_description.txt │ │ └── title.txt │ ├── be/ │ │ ├── full_description.txt │ │ ├── short_description.txt │ │ └── title.txt │ ├── bg/ │ │ ├── changelogs/ │ │ │ ├── 61.txt │ │ │ ├── 67.txt │ │ │ ├── 68.txt │ │ │ ├── 70.txt │ │ │ ├── 74.txt │ │ │ └── 77.txt │ │ ├── full_description.txt │ │ ├── short_description.txt │ │ └── title.txt │ ├── bn-BD/ │ │ ├── full_description.txt │ │ ├── short_description.txt │ │ └── title.txt │ ├── bn-IN/ │ │ ├── changelogs/ │ │ │ └── 67.txt │ │ ├── full_description.txt │ │ ├── short_description.txt │ │ └── title.txt │ ├── ca/ │ │ ├── changelogs/ │ │ │ ├── 100.txt │ │ │ ├── 58.txt │ │ │ ├── 61.txt │ │ │ ├── 67.txt │ │ │ ├── 68.txt │ │ │ ├── 70.txt │ │ │ ├── 72.txt │ │ │ ├── 74.txt │ │ │ ├── 77.txt │ │ │ ├── 80.txt │ │ │ ├── 82.txt │ │ │ ├── 83.txt │ │ │ ├── 87.txt │ │ │ ├── 89.txt │ │ │ ├── 91.txt │ │ │ ├── 94.txt │ │ │ └── 97.txt │ │ ├── full_description.txt │ │ ├── short_description.txt │ │ └── title.txt │ ├── ckb/ │ │ ├── changelogs/ │ │ │ └── 77.txt │ │ ├── full_description.txt │ │ ├── short_description.txt │ │ └── title.txt │ ├── cs/ │ │ ├── changelogs/ │ │ │ ├── 58.txt │ │ │ ├── 61.txt │ │ │ ├── 67.txt │ │ │ ├── 68.txt │ │ │ ├── 70.txt │ │ │ ├── 72.txt │ │ │ ├── 74.txt │ │ │ ├── 77.txt │ │ │ ├── 80.txt │ │ │ ├── 82.txt │ │ │ ├── 83.txt │ │ │ ├── 87.txt │ │ │ ├── 89.txt │ │ │ ├── 91.txt │ │ │ ├── 94.txt │ │ │ └── 97.txt │ │ ├── full_description.txt │ │ ├── short_description.txt │ │ └── title.txt │ ├── cy/ │ │ ├── changelogs/ │ │ │ ├── 100.txt │ │ │ ├── 103.txt │ │ │ ├── 104.txt │ │ │ ├── 105.txt │ │ │ ├── 106.txt │ │ │ ├── 107.txt │ │ │ ├── 108.txt │ │ │ ├── 109.txt │ │ │ ├── 110.txt │ │ │ ├── 111.txt │ │ │ ├── 112.txt │ │ │ ├── 113.txt │ │ │ ├── 115.txt │ │ │ ├── 117.txt │ │ │ ├── 119.txt │ │ │ ├── 123.txt │ │ │ ├── 124.txt │ │ │ ├── 127.txt │ │ │ ├── 131.txt │ │ │ ├── 58.txt │ │ │ ├── 61.txt │ │ │ ├── 67.txt │ │ │ ├── 68.txt │ │ │ ├── 70.txt │ │ │ ├── 72.txt │ │ │ ├── 74.txt │ │ │ ├── 77.txt │ │ │ ├── 80.txt │ │ │ ├── 82.txt │ │ │ ├── 83.txt │ │ │ ├── 87.txt │ │ │ ├── 89.txt │ │ │ ├── 91.txt │ │ │ ├── 94.txt │ │ │ └── 97.txt │ │ ├── full_description.txt │ │ ├── short_description.txt │ │ └── title.txt │ ├── de/ │ │ ├── changelogs/ │ │ │ ├── 100.txt │ │ │ ├── 103.txt │ │ │ ├── 104.txt │ │ │ ├── 105.txt │ │ │ ├── 106.txt │ │ │ ├── 107.txt │ │ │ ├── 108.txt │ │ │ ├── 109.txt │ │ │ ├── 110.txt │ │ │ ├── 111.txt │ │ │ ├── 112.txt │ │ │ ├── 113.txt │ │ │ ├── 115.txt │ │ │ ├── 117.txt │ │ │ ├── 119.txt │ │ │ ├── 123.txt │ │ │ ├── 124.txt │ │ │ ├── 127.txt │ │ │ ├── 131.txt │ │ │ ├── 58.txt │ │ │ ├── 61.txt │ │ │ ├── 67.txt │ │ │ ├── 68.txt │ │ │ ├── 70.txt │ │ │ ├── 72.txt │ │ │ ├── 74.txt │ │ │ ├── 77.txt │ │ │ ├── 80.txt │ │ │ ├── 82.txt │ │ │ ├── 83.txt │ │ │ ├── 87.txt │ │ │ ├── 89.txt │ │ │ ├── 91.txt │ │ │ ├── 94.txt │ │ │ └── 97.txt │ │ ├── full_description.txt │ │ ├── short_description.txt │ │ └── title.txt │ ├── el/ │ │ ├── changelogs/ │ │ │ └── 100.txt │ │ ├── full_description.txt │ │ ├── short_description.txt │ │ └── title.txt │ ├── en-US/ │ │ ├── changelogs/ │ │ │ ├── 100.txt │ │ │ ├── 103.txt │ │ │ ├── 104.txt │ │ │ ├── 105.txt │ │ │ ├── 106.txt │ │ │ ├── 107.txt │ │ │ ├── 108.txt │ │ │ ├── 109.txt │ │ │ ├── 110.txt │ │ │ ├── 111.txt │ │ │ ├── 112.txt │ │ │ ├── 113.txt │ │ │ ├── 115.txt │ │ │ ├── 117.txt │ │ │ ├── 119.txt │ │ │ ├── 123.txt │ │ │ ├── 124.txt │ │ │ ├── 127.txt │ │ │ ├── 131.txt │ │ │ ├── 133.txt │ │ │ ├── 58.txt │ │ │ ├── 61.txt │ │ │ ├── 67.txt │ │ │ ├── 68.txt │ │ │ ├── 70.txt │ │ │ ├── 72.txt │ │ │ ├── 74.txt │ │ │ ├── 77.txt │ │ │ ├── 80.txt │ │ │ ├── 82.txt │ │ │ ├── 83.txt │ │ │ ├── 87.txt │ │ │ ├── 89.txt │ │ │ ├── 91.txt │ │ │ ├── 94.txt │ │ │ └── 97.txt │ │ ├── full_description.txt │ │ ├── short_description.txt │ │ └── title.txt │ ├── eo/ │ │ ├── full_description.txt │ │ ├── short_description.txt │ │ └── title.txt │ ├── es/ │ │ ├── changelogs/ │ │ │ ├── 100.txt │ │ │ ├── 103.txt │ │ │ ├── 58.txt │ │ │ ├── 61.txt │ │ │ ├── 67.txt │ │ │ ├── 68.txt │ │ │ ├── 70.txt │ │ │ ├── 72.txt │ │ │ ├── 74.txt │ │ │ ├── 77.txt │ │ │ ├── 80.txt │ │ │ ├── 82.txt │ │ │ ├── 83.txt │ │ │ ├── 87.txt │ │ │ ├── 89.txt │ │ │ ├── 91.txt │ │ │ ├── 94.txt │ │ │ └── 97.txt │ │ ├── full_description.txt │ │ ├── short_description.txt │ │ └── title.txt │ ├── eu/ │ │ ├── changelogs/ │ │ │ ├── 58.txt │ │ │ ├── 61.txt │ │ │ ├── 67.txt │ │ │ ├── 68.txt │ │ │ ├── 70.txt │ │ │ ├── 72.txt │ │ │ ├── 74.txt │ │ │ ├── 77.txt │ │ │ ├── 80.txt │ │ │ ├── 82.txt │ │ │ ├── 83.txt │ │ │ ├── 87.txt │ │ │ └── 97.txt │ │ ├── full_description.txt │ │ ├── short_description.txt │ │ └── title.txt │ ├── fa/ │ │ ├── changelogs/ │ │ │ ├── 100.txt │ │ │ ├── 103.txt │ │ │ ├── 104.txt │ │ │ ├── 105.txt │ │ │ ├── 106.txt │ │ │ ├── 107.txt │ │ │ ├── 108.txt │ │ │ ├── 109.txt │ │ │ ├── 110.txt │ │ │ ├── 111.txt │ │ │ ├── 112.txt │ │ │ ├── 113.txt │ │ │ ├── 115.txt │ │ │ ├── 117.txt │ │ │ ├── 119.txt │ │ │ ├── 123.txt │ │ │ ├── 124.txt │ │ │ ├── 127.txt │ │ │ ├── 131.txt │ │ │ ├── 133.txt │ │ │ ├── 58.txt │ │ │ ├── 61.txt │ │ │ ├── 67.txt │ │ │ ├── 68.txt │ │ │ ├── 70.txt │ │ │ ├── 72.txt │ │ │ ├── 74.txt │ │ │ ├── 77.txt │ │ │ ├── 80.txt │ │ │ ├── 82.txt │ │ │ ├── 83.txt │ │ │ ├── 87.txt │ │ │ ├── 89.txt │ │ │ ├── 91.txt │ │ │ ├── 94.txt │ │ │ └── 97.txt │ │ ├── full_description.txt │ │ ├── short_description.txt │ │ └── title.txt │ ├── fi/ │ │ └── title.txt │ ├── fr/ │ │ ├── changelogs/ │ │ │ ├── 100.txt │ │ │ ├── 103.txt │ │ │ ├── 104.txt │ │ │ ├── 58.txt │ │ │ ├── 61.txt │ │ │ ├── 67.txt │ │ │ ├── 68.txt │ │ │ ├── 70.txt │ │ │ ├── 72.txt │ │ │ ├── 74.txt │ │ │ ├── 77.txt │ │ │ ├── 80.txt │ │ │ ├── 82.txt │ │ │ ├── 83.txt │ │ │ ├── 87.txt │ │ │ ├── 89.txt │ │ │ ├── 91.txt │ │ │ ├── 94.txt │ │ │ └── 97.txt │ │ ├── full_description.txt │ │ ├── short_description.txt │ │ └── title.txt │ ├── ga/ │ │ ├── short_description.txt │ │ └── title.txt │ ├── gd/ │ │ ├── short_description.txt │ │ └── title.txt │ ├── gl/ │ │ ├── changelogs/ │ │ │ ├── 100.txt │ │ │ ├── 103.txt │ │ │ ├── 104.txt │ │ │ ├── 105.txt │ │ │ ├── 106.txt │ │ │ ├── 107.txt │ │ │ ├── 108.txt │ │ │ ├── 109.txt │ │ │ ├── 110.txt │ │ │ ├── 111.txt │ │ │ ├── 112.txt │ │ │ ├── 113.txt │ │ │ ├── 115.txt │ │ │ ├── 117.txt │ │ │ ├── 119.txt │ │ │ ├── 123.txt │ │ │ ├── 124.txt │ │ │ ├── 127.txt │ │ │ ├── 131.txt │ │ │ ├── 133.txt │ │ │ ├── 58.txt │ │ │ ├── 61.txt │ │ │ ├── 67.txt │ │ │ ├── 68.txt │ │ │ ├── 70.txt │ │ │ ├── 72.txt │ │ │ ├── 74.txt │ │ │ ├── 77.txt │ │ │ ├── 80.txt │ │ │ ├── 82.txt │ │ │ ├── 83.txt │ │ │ ├── 87.txt │ │ │ ├── 89.txt │ │ │ ├── 91.txt │ │ │ ├── 94.txt │ │ │ └── 97.txt │ │ ├── full_description.txt │ │ ├── short_description.txt │ │ └── title.txt │ ├── hi/ │ │ ├── changelogs/ │ │ │ └── 68.txt │ │ ├── short_description.txt │ │ └── title.txt │ ├── hu/ │ │ ├── changelogs/ │ │ │ ├── 100.txt │ │ │ ├── 103.txt │ │ │ ├── 104.txt │ │ │ ├── 105.txt │ │ │ ├── 106.txt │ │ │ ├── 107.txt │ │ │ ├── 108.txt │ │ │ ├── 109.txt │ │ │ ├── 110.txt │ │ │ ├── 111.txt │ │ │ ├── 58.txt │ │ │ ├── 61.txt │ │ │ ├── 67.txt │ │ │ ├── 68.txt │ │ │ ├── 70.txt │ │ │ ├── 72.txt │ │ │ ├── 74.txt │ │ │ ├── 77.txt │ │ │ ├── 80.txt │ │ │ ├── 82.txt │ │ │ ├── 83.txt │ │ │ ├── 87.txt │ │ │ ├── 89.txt │ │ │ ├── 91.txt │ │ │ ├── 94.txt │ │ │ └── 97.txt │ │ ├── full_description.txt │ │ ├── short_description.txt │ │ └── title.txt │ ├── id/ │ │ ├── changelogs/ │ │ │ ├── 100.txt │ │ │ ├── 58.txt │ │ │ ├── 61.txt │ │ │ ├── 67.txt │ │ │ ├── 68.txt │ │ │ ├── 70.txt │ │ │ ├── 72.txt │ │ │ ├── 74.txt │ │ │ ├── 77.txt │ │ │ ├── 80.txt │ │ │ ├── 82.txt │ │ │ ├── 83.txt │ │ │ ├── 87.txt │ │ │ ├── 89.txt │ │ │ ├── 91.txt │ │ │ ├── 94.txt │ │ │ └── 97.txt │ │ ├── full_description.txt │ │ ├── short_description.txt │ │ └── title.txt │ ├── is/ │ │ ├── changelogs/ │ │ │ ├── 127.txt │ │ │ ├── 58.txt │ │ │ ├── 61.txt │ │ │ ├── 67.txt │ │ │ ├── 68.txt │ │ │ ├── 70.txt │ │ │ ├── 72.txt │ │ │ ├── 74.txt │ │ │ ├── 77.txt │ │ │ ├── 80.txt │ │ │ ├── 82.txt │ │ │ ├── 83.txt │ │ │ ├── 87.txt │ │ │ └── 89.txt │ │ ├── full_description.txt │ │ ├── short_description.txt │ │ └── title.txt │ ├── it/ │ │ ├── changelogs/ │ │ │ ├── 100.txt │ │ │ ├── 123.txt │ │ │ ├── 124.txt │ │ │ ├── 127.txt │ │ │ ├── 131.txt │ │ │ ├── 133.txt │ │ │ ├── 58.txt │ │ │ ├── 61.txt │ │ │ ├── 67.txt │ │ │ ├── 68.txt │ │ │ ├── 70.txt │ │ │ ├── 74.txt │ │ │ └── 77.txt │ │ ├── full_description.txt │ │ ├── short_description.txt │ │ └── title.txt │ ├── ja/ │ │ ├── changelogs/ │ │ │ ├── 68.txt │ │ │ ├── 70.txt │ │ │ └── 74.txt │ │ ├── full_description.txt │ │ ├── short_description.txt │ │ └── title.txt │ ├── kab/ │ │ ├── short_description.txt │ │ └── title.txt │ ├── ko/ │ │ ├── changelogs/ │ │ │ ├── 58.txt │ │ │ ├── 61.txt │ │ │ └── 67.txt │ │ ├── full_description.txt │ │ ├── short_description.txt │ │ └── title.txt │ ├── lv/ │ │ ├── short_description.txt │ │ └── title.txt │ ├── nb-NO/ │ │ ├── changelogs/ │ │ │ ├── 100.txt │ │ │ ├── 103.txt │ │ │ ├── 104.txt │ │ │ ├── 105.txt │ │ │ ├── 106.txt │ │ │ ├── 107.txt │ │ │ ├── 108.txt │ │ │ ├── 109.txt │ │ │ ├── 110.txt │ │ │ ├── 111.txt │ │ │ ├── 58.txt │ │ │ ├── 61.txt │ │ │ ├── 67.txt │ │ │ ├── 68.txt │ │ │ ├── 70.txt │ │ │ ├── 72.txt │ │ │ ├── 74.txt │ │ │ ├── 77.txt │ │ │ ├── 80.txt │ │ │ ├── 82.txt │ │ │ ├── 83.txt │ │ │ ├── 87.txt │ │ │ ├── 89.txt │ │ │ ├── 91.txt │ │ │ ├── 94.txt │ │ │ └── 97.txt │ │ ├── full_description.txt │ │ ├── short_description.txt │ │ └── title.txt │ ├── nl/ │ │ ├── changelogs/ │ │ │ ├── 100.txt │ │ │ ├── 58.txt │ │ │ ├── 61.txt │ │ │ ├── 67.txt │ │ │ ├── 68.txt │ │ │ ├── 70.txt │ │ │ ├── 72.txt │ │ │ ├── 74.txt │ │ │ ├── 77.txt │ │ │ ├── 80.txt │ │ │ ├── 82.txt │ │ │ ├── 83.txt │ │ │ ├── 87.txt │ │ │ ├── 89.txt │ │ │ ├── 91.txt │ │ │ └── 94.txt │ │ ├── full_description.txt │ │ ├── short_description.txt │ │ └── title.txt │ ├── pl/ │ │ ├── changelogs/ │ │ │ ├── 100.txt │ │ │ ├── 58.txt │ │ │ ├── 61.txt │ │ │ ├── 67.txt │ │ │ ├── 68.txt │ │ │ ├── 70.txt │ │ │ ├── 72.txt │ │ │ ├── 74.txt │ │ │ ├── 77.txt │ │ │ ├── 80.txt │ │ │ ├── 82.txt │ │ │ ├── 83.txt │ │ │ ├── 87.txt │ │ │ ├── 89.txt │ │ │ ├── 91.txt │ │ │ ├── 94.txt │ │ │ └── 97.txt │ │ ├── full_description.txt │ │ ├── short_description.txt │ │ └── title.txt │ ├── pt-BR/ │ │ ├── changelogs/ │ │ │ ├── 100.txt │ │ │ ├── 58.txt │ │ │ ├── 61.txt │ │ │ ├── 67.txt │ │ │ ├── 68.txt │ │ │ ├── 70.txt │ │ │ ├── 72.txt │ │ │ ├── 74.txt │ │ │ ├── 77.txt │ │ │ ├── 80.txt │ │ │ ├── 82.txt │ │ │ ├── 83.txt │ │ │ ├── 87.txt │ │ │ ├── 89.txt │ │ │ ├── 91.txt │ │ │ ├── 94.txt │ │ │ └── 97.txt │ │ ├── full_description.txt │ │ ├── short_description.txt │ │ └── title.txt │ ├── pt-PT/ │ │ ├── changelogs/ │ │ │ ├── 100.txt │ │ │ ├── 103.txt │ │ │ ├── 104.txt │ │ │ ├── 117.txt │ │ │ ├── 131.txt │ │ │ ├── 58.txt │ │ │ ├── 61.txt │ │ │ ├── 67.txt │ │ │ ├── 68.txt │ │ │ ├── 70.txt │ │ │ ├── 72.txt │ │ │ ├── 74.txt │ │ │ ├── 77.txt │ │ │ ├── 80.txt │ │ │ ├── 82.txt │ │ │ ├── 83.txt │ │ │ ├── 87.txt │ │ │ ├── 89.txt │ │ │ └── 91.txt │ │ ├── full_description.txt │ │ ├── short_description.txt │ │ └── title.txt │ ├── ru/ │ │ ├── changelogs/ │ │ │ ├── 100.txt │ │ │ ├── 103.txt │ │ │ ├── 104.txt │ │ │ ├── 105.txt │ │ │ ├── 106.txt │ │ │ ├── 107.txt │ │ │ ├── 108.txt │ │ │ ├── 109.txt │ │ │ ├── 110.txt │ │ │ ├── 111.txt │ │ │ ├── 112.txt │ │ │ ├── 113.txt │ │ │ ├── 115.txt │ │ │ ├── 58.txt │ │ │ ├── 61.txt │ │ │ ├── 67.txt │ │ │ ├── 68.txt │ │ │ ├── 70.txt │ │ │ ├── 72.txt │ │ │ ├── 74.txt │ │ │ ├── 77.txt │ │ │ ├── 80.txt │ │ │ ├── 82.txt │ │ │ ├── 83.txt │ │ │ ├── 87.txt │ │ │ ├── 89.txt │ │ │ ├── 91.txt │ │ │ ├── 94.txt │ │ │ └── 97.txt │ │ ├── full_description.txt │ │ ├── short_description.txt │ │ └── title.txt │ ├── sa/ │ │ ├── changelogs/ │ │ │ ├── 72.txt │ │ │ └── 74.txt │ │ ├── full_description.txt │ │ ├── short_description.txt │ │ └── title.txt │ ├── si/ │ │ └── title.txt │ ├── sk/ │ │ ├── changelogs/ │ │ │ ├── 67.txt │ │ │ └── 68.txt │ │ ├── short_description.txt │ │ └── title.txt │ ├── sl/ │ │ ├── changelogs/ │ │ │ ├── 100.txt │ │ │ ├── 103.txt │ │ │ └── 104.txt │ │ ├── full_description.txt │ │ ├── short_description.txt │ │ └── title.txt │ ├── sv/ │ │ ├── changelogs/ │ │ │ ├── 100.txt │ │ │ ├── 103.txt │ │ │ ├── 104.txt │ │ │ ├── 105.txt │ │ │ ├── 106.txt │ │ │ ├── 107.txt │ │ │ ├── 108.txt │ │ │ ├── 109.txt │ │ │ ├── 110.txt │ │ │ ├── 111.txt │ │ │ ├── 112.txt │ │ │ ├── 113.txt │ │ │ ├── 115.txt │ │ │ ├── 117.txt │ │ │ ├── 119.txt │ │ │ ├── 58.txt │ │ │ ├── 61.txt │ │ │ ├── 67.txt │ │ │ ├── 68.txt │ │ │ ├── 70.txt │ │ │ ├── 72.txt │ │ │ ├── 74.txt │ │ │ ├── 77.txt │ │ │ ├── 80.txt │ │ │ ├── 82.txt │ │ │ ├── 83.txt │ │ │ ├── 87.txt │ │ │ ├── 89.txt │ │ │ ├── 91.txt │ │ │ ├── 94.txt │ │ │ └── 97.txt │ │ ├── full_description.txt │ │ ├── short_description.txt │ │ └── title.txt │ ├── ta/ │ │ ├── short_description.txt │ │ └── title.txt │ ├── th/ │ │ ├── changelogs/ │ │ │ └── 72.txt │ │ ├── full_description.txt │ │ ├── short_description.txt │ │ └── title.txt │ ├── tr/ │ │ ├── changelogs/ │ │ │ ├── 100.txt │ │ │ ├── 103.txt │ │ │ ├── 104.txt │ │ │ ├── 105.txt │ │ │ ├── 106.txt │ │ │ ├── 107.txt │ │ │ ├── 108.txt │ │ │ ├── 109.txt │ │ │ ├── 110.txt │ │ │ ├── 111.txt │ │ │ ├── 112.txt │ │ │ ├── 113.txt │ │ │ ├── 115.txt │ │ │ ├── 117.txt │ │ │ ├── 119.txt │ │ │ ├── 123.txt │ │ │ ├── 124.txt │ │ │ ├── 58.txt │ │ │ ├── 61.txt │ │ │ ├── 67.txt │ │ │ ├── 68.txt │ │ │ ├── 70.txt │ │ │ ├── 72.txt │ │ │ ├── 74.txt │ │ │ ├── 77.txt │ │ │ ├── 80.txt │ │ │ ├── 82.txt │ │ │ ├── 83.txt │ │ │ ├── 87.txt │ │ │ ├── 89.txt │ │ │ ├── 91.txt │ │ │ ├── 94.txt │ │ │ └── 97.txt │ │ ├── full_description.txt │ │ ├── short_description.txt │ │ └── title.txt │ ├── uk/ │ │ ├── changelogs/ │ │ │ ├── 100.txt │ │ │ ├── 103.txt │ │ │ ├── 104.txt │ │ │ ├── 105.txt │ │ │ ├── 106.txt │ │ │ ├── 107.txt │ │ │ ├── 108.txt │ │ │ ├── 109.txt │ │ │ ├── 110.txt │ │ │ ├── 111.txt │ │ │ ├── 112.txt │ │ │ ├── 113.txt │ │ │ ├── 115.txt │ │ │ ├── 117.txt │ │ │ ├── 119.txt │ │ │ ├── 123.txt │ │ │ ├── 124.txt │ │ │ ├── 127.txt │ │ │ ├── 131.txt │ │ │ ├── 58.txt │ │ │ ├── 61.txt │ │ │ ├── 67.txt │ │ │ ├── 68.txt │ │ │ ├── 70.txt │ │ │ ├── 72.txt │ │ │ ├── 74.txt │ │ │ ├── 77.txt │ │ │ ├── 80.txt │ │ │ ├── 82.txt │ │ │ ├── 83.txt │ │ │ ├── 87.txt │ │ │ ├── 89.txt │ │ │ ├── 91.txt │ │ │ ├── 94.txt │ │ │ └── 97.txt │ │ ├── full_description.txt │ │ ├── short_description.txt │ │ └── title.txt │ ├── vi/ │ │ ├── changelogs/ │ │ │ ├── 100.txt │ │ │ ├── 103.txt │ │ │ ├── 104.txt │ │ │ ├── 105.txt │ │ │ ├── 106.txt │ │ │ ├── 107.txt │ │ │ ├── 108.txt │ │ │ ├── 109.txt │ │ │ ├── 110.txt │ │ │ ├── 111.txt │ │ │ ├── 112.txt │ │ │ ├── 113.txt │ │ │ ├── 115.txt │ │ │ ├── 117.txt │ │ │ ├── 119.txt │ │ │ ├── 123.txt │ │ │ ├── 124.txt │ │ │ ├── 127.txt │ │ │ ├── 131.txt │ │ │ ├── 133.txt │ │ │ ├── 58.txt │ │ │ ├── 61.txt │ │ │ ├── 67.txt │ │ │ ├── 68.txt │ │ │ ├── 70.txt │ │ │ ├── 72.txt │ │ │ ├── 74.txt │ │ │ ├── 77.txt │ │ │ ├── 80.txt │ │ │ ├── 82.txt │ │ │ ├── 83.txt │ │ │ ├── 87.txt │ │ │ ├── 89.txt │ │ │ ├── 91.txt │ │ │ ├── 94.txt │ │ │ └── 97.txt │ │ ├── full_description.txt │ │ ├── short_description.txt │ │ └── title.txt │ ├── zh-Hans/ │ │ ├── changelogs/ │ │ │ ├── 100.txt │ │ │ ├── 58.txt │ │ │ ├── 61.txt │ │ │ ├── 67.txt │ │ │ ├── 68.txt │ │ │ ├── 70.txt │ │ │ ├── 72.txt │ │ │ ├── 74.txt │ │ │ ├── 77.txt │ │ │ ├── 80.txt │ │ │ ├── 82.txt │ │ │ ├── 83.txt │ │ │ ├── 87.txt │ │ │ ├── 89.txt │ │ │ ├── 91.txt │ │ │ ├── 94.txt │ │ │ └── 97.txt │ │ ├── full_description.txt │ │ ├── short_description.txt │ │ └── title.txt │ └── zh-Hant/ │ ├── full_description.txt │ ├── short_description.txt │ └── title.txt ├── gradle/ │ ├── libs.versions.toml │ ├── verification-metadata.xml │ └── wrapper/ │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradle.properties ├── gradlew ├── gradlew.bat └── settings.gradle ================================================ FILE CONTENTS ================================================ ================================================ FILE: .editorconfig ================================================ root = true [*] charset = utf-8 indent_size = 4 indent_style = space insert_final_newline = true trim_trailing_whitespace = true [*.{java,kt}] ij_kotlin_imports_layout = * # Disable wildcard imports 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 ================================================ FILE: .gitattributes ================================================ * text=auto eol=lf *.bat text eol=crlf *.jar binary ================================================ FILE: .github/FUNDING.yml ================================================ open_collective: tusky ================================================ FILE: .github/ISSUE_TEMPLATE/1.bug_report.yml ================================================ 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 ================================================ FILE: .github/ISSUE_TEMPLATE/2.feature_request.yml ================================================ 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 ================================================ FILE: .github/ISSUE_TEMPLATE/config.yml ================================================ blank_issues_enabled: true ================================================ FILE: .github/actions/setup/action.yml ================================================ name: 'Setup build environment' description: 'Sets up an environment for building Tusky' runs: using: "composite" steps: - uses: actions/setup-java@v4 with: java-version: '21' distribution: 'temurin' - name: Copy CI gradle.properties shell: bash run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties - name: Gradle Build Action uses: gradle/actions/setup-gradle@v4 with: cache-read-only: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/develop' }} ================================================ FILE: .github/ci-gradle.properties ================================================ # # 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 ================================================ FILE: .github/renovate.json ================================================ { "$schema": "https://docs.renovatebot.com/renovate-schema.json", "extends": [ "config:recommended" ], "packageRules": [ { "groupName": "Kotlin", "groupSlug": "kotlin", "matchPackageNames": [ "com.google.devtools.ksp{/,}**", "/org.jetbrains.kotlin.*/" ] } ] } ================================================ FILE: .github/workflows/check-and-build.yml ================================================ name: Check and build on: pull_request: workflow_call: jobs: build: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 - name: Setup uses: ./.github/actions/setup - name: Licensee run: ./gradlew licensee - 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 ================================================ FILE: .github/workflows/deploy-release.yml ================================================ # When a tag is created, create a release build and upload it to Google Play name: Deploy release to Google Play on: push: tags: - '*' jobs: check-and-build: uses: ./.github/workflows/check-and-build.yml deploy: runs-on: ubuntu-latest needs: check-and-build environment: Release steps: - name: Checkout uses: actions/checkout@v4 - name: Setup uses: ./.github/actions/setup - name: Build Blue aab run: ./gradlew app:bundleBlueRelease - uses: r0adkll/sign-android-release@f30bdd30588842ac76044ecdbd4b6d0e3e813478 name: Sign Tusky Blue aab id: sign_aab with: releaseDirectory: app/build/outputs/bundle/blueRelease signingKeyBase64: ${{ secrets.KEYSTORE }} alias: ${{ secrets.KEY_ALIAS }} keyStorePassword: ${{ secrets.KEYSTORE_PASSWORD }} keyPassword: ${{ secrets.KEY_PASSWORD }} - name: Generate whatsnew id: generate-whatsnew run: | mkdir whatsnew cp $(find fastlane/metadata/android/en-US/changelogs | sort -n -k6 -t/ | tail -n 1) whatsnew/whatsnew-en-US - name: Upload AAB to Google Play id: upload-release-asset-aab uses: r0adkll/upload-google-play@v1.1.3 with: serviceAccountJsonPlainText: ${{ secrets.SERVICE_ACCOUNT_JSON }} packageName: com.keylesspalace.tusky releaseFiles: ${{steps.sign_aab.outputs.signedReleaseFile}} track: internal whatsNewDirectory: whatsnew status: completed mappingFile: app/build/outputs/mapping/blueRelease/mapping.txt ================================================ FILE: .github/workflows/deploy-test.yml ================================================ # Deploy Tusky Nightly on each push to develop name: Deploy Tusky Nightly to Google Play on: push: branches: - develop concurrency: group: ${{ github.workflow }} cancel-in-progress: true jobs: check-and-build: uses: ./.github/workflows/check-and-build.yml deploy: runs-on: ubuntu-latest needs: check-and-build environment: Test env: BUILD_NUMBER: ${{ github.run_number }} steps: - name: Checkout uses: actions/checkout@v4 - name: Setup uses: ./.github/actions/setup - name: Set versionCode run: | export VERSION_CODE=$(( ${BUILD_NUMBER} + 11000 )) sed -i'.original' -e "s/^\([[:space:]]*versionCode[[:space:]]*\)[0-9]*/\\1$VERSION_CODE/" app/build.gradle - name: Build Green aab run: ./gradlew app:bundleGreenRelease - uses: r0adkll/sign-android-release@f30bdd30588842ac76044ecdbd4b6d0e3e813478 name: Sign Tusky Green aab id: sign_aab with: releaseDirectory: app/build/outputs/bundle/greenRelease signingKeyBase64: ${{ secrets.KEYSTORE }} alias: ${{ secrets.KEY_ALIAS }} keyStorePassword: ${{ secrets.KEYSTORE_PASSWORD }} keyPassword: ${{ secrets.KEY_PASSWORD }} - name: Generate whatsnew id: generate-whatsnew run: | mkdir whatsnew git log -3 --pretty=%B | head -c 500 > whatsnew/whatsnew-en-US - name: Upload AAB to Google Play id: upload-release-asset-aab uses: r0adkll/upload-google-play@v1.1.3 with: serviceAccountJsonPlainText: ${{ secrets.SERVICE_ACCOUNT_JSON }} packageName: com.keylesspalace.tusky.test releaseFiles: ${{steps.sign_aab.outputs.signedReleaseFile}} track: production whatsNewDirectory: whatsnew status: completed mappingFile: app/build/outputs/mapping/greenRelease/mapping.txt ================================================ FILE: .gitignore ================================================ *.iml .gradle /local.properties /.idea .DS_Store build /captures .externalNativeBuild app/release app-release.apk .kotlin ================================================ FILE: CHANGELOG.md ================================================ # Tusky changelog ## Unreleased or Tusky Nightly ### New features and other improvements ### Significant bug fixes ## v29.0 ### New features and other improvements - New iconset https://github.com/tuskyapp/Tusky/pull/5012 - The layout of polls has been improved and a "show results" button was added https://github.com/tuskyapp/Tusky/pull/4980 https://github.com/tuskyapp/Tusky/pull/5047 https://github.com/tuskyapp/Tusky/pull/5095 - Support for the "blur" filter action (Mastodon 4.4 feature) https://github.com/tuskyapp/Tusky/pull/5038 - The quality of the image viewer has been improved https://github.com/tuskyapp/Tusky/pull/5068 https://github.com/tuskyapp/Tusky/pull/5067 - An additional dialog will now prevent accidentally dismissing the media caption dialog https://github.com/tuskyapp/Tusky/pull/4999 - Tusky will now send a `delete_media` parameter when deleting a post to help servers clean up their media faster (Mastodon 4.4 feature) https://github.com/tuskyapp/Tusky/pull/5082 - Boosts and favorites are now confirmed via a bottom sheet instead of a drop down https://github.com/tuskyapp/Tusky/pull/5084 - All account list views now have better error handling https://github.com/tuskyapp/Tusky/pull/5028 - Support for Android 16 https://github.com/tuskyapp/Tusky/pull/5071 - Several internal code improvements https://github.com/tuskyapp/Tusky/pull/5036 https://github.com/tuskyapp/Tusky/pull/5094 https://github.com/tuskyapp/Tusky/pull/5055 https://github.com/tuskyapp/Tusky/pull/5024 - Improved localizations ### Significant bug fixes - caches are now correctly deleted when logging out https://github.com/tuskyapp/Tusky/pull/4997 https://github.com/tuskyapp/Tusky/pull/5004 - several layout fixes on Android 15+ https://github.com/tuskyapp/Tusky/pull/5053 https://github.com/tuskyapp/Tusky/pull/5041 https://github.com/tuskyapp/Tusky/pull/5003 - fixes a crash that occurs when Tusky is used on a device with two or more UnifiedPush providers https://github.com/tuskyapp/Tusky/pull/5015 ## v28.0 ### New features and other improvements - Support for Android 15 and edge-to-edge mode https://github.com/tuskyapp/Tusky/pull/4897 - Improves the reliability of push notifications https://github.com/tuskyapp/Tusky/pull/4896 https://github.com/tuskyapp/Tusky/pull/4883 - Replies in timeline are now clearly marked as such by a text above them https://github.com/tuskyapp/Tusky/pull/4834 - Several improvements to how notifications are rendered in the notifications tab https://github.com/tuskyapp/Tusky/pull/4929 - support for the new Mastodon 4.3 notification types `severed_relationships` and `moderation_warning` - The "unknown notification type" notification now shows the unknown type and a info dialog when you click it - The account note is now shown again for follow request and follow notifications - The icon for the " just posted" notification is now a bell instead of a home - Adds a text above mention notifications that indicates if it is a (private) reply or (private) mention - Follow requests won't be filtered by default in the notification tab. This change will only affect new logins and not existing ones. - Link Preview Cards got a new design and now support the fediverse:creator feature https://github.com/tuskyapp/Tusky/pull/4782 - The possible selections for mute durations are now 1 hour, 6 hours, 1 day, 7 days, 30 days and 180 days https://github.com/tuskyapp/Tusky/pull/4943 - The rendering of trending tags has been improved https://github.com/tuskyapp/Tusky/pull/4889 https://github.com/tuskyapp/Tusky/pull/4924 - The app will no longer make database queries on the main thread, which improves performance https://github.com/tuskyapp/Tusky/pull/4786 - Wellbeing mode will no longer hide the "follows you" badge on profiles https://github.com/tuskyapp/Tusky/pull/4940 - It is now possible to select boost visibility when the "Show confirmation before boosting" option is active https://github.com/tuskyapp/Tusky/pull/4944 ### Significant bug fixes - Fixes a bug where more than 4 profile fields could not be edited (on instances that allow more than 4) https://github.com/tuskyapp/Tusky/commit/1157be18cf3bbd44426f4cdaae35e69b9f3cecca - Fixes a bug where a dropdown was partially hidden by the keyboard https://github.com/tuskyapp/Tusky/pull/4913 - Tusky side timeline filters apply to own posts again https://github.com/tuskyapp/Tusky/pull/4879 - Fixes a bug where media previews would flicker when interacting with a post https://github.com/tuskyapp/Tusky/pull/4971 - Tusky now ignores invalid publishing dates of preview cards that caused some posts not to load https://github.com/tuskyapp/Tusky/pull/4993 ## v27.2 ### Significant bug fixes - The title of a hashtag tab now shows the actual hashtags again (instead of just "Hashtags") https://github.com/tuskyapp/Tusky/pull/4868 - Makes sure the background color of a dialogs is correct https://github.com/tuskyapp/Tusky/pull/4864 - Fixes an issue where Tusky would freeze while loading a timeline gap https://github.com/tuskyapp/Tusky/pull/4865 ## v27.1 ### New features and other improvements - The width of the tab indicator has been increased https://github.com/tuskyapp/Tusky/pull/4849 ### Significant bug fixes - Improves rendering of some animated custom emojis https://github.com/tuskyapp/Tusky/pull/4281 - Fixes an issue where the input field for media descriptions was too small in some cases https://github.com/tuskyapp/Tusky/pull/4831 - Fixes an issue where hashtags at the end of posts were duplicated https://github.com/tuskyapp/Tusky/pull/4845 - Fixes an issue that prevented lists from being edited https://github.com/tuskyapp/Tusky/pull/4851 ## v27.0 ### New features and other improvements - Tusky has been redesigned with Material 3 https://github.com/tuskyapp/Tusky/pull/4637 https://github.com/tuskyapp/Tusky/pull/4673 - Support for Notification Policies (Mastodon 4.3 feature) https://github.com/tuskyapp/Tusky/pull/4768 - Hashtags at the end of posts are now shown in a separate bar https://github.com/tuskyapp/Tusky/pull/4761 - Full support for folding devices https://github.com/tuskyapp/Tusky/pull/4689 - Improved post rendering in some edge cases https://github.com/tuskyapp/Tusky/pull/4650 https://github.com/tuskyapp/Tusky/pull/4672 https://github.com/tuskyapp/Tusky/pull/4723 - Descriptions can now be added to audio attachments https://github.com/tuskyapp/Tusky/pull/4711 - The screen keyboard now pops up automatically when opening a dialog that contains a textfield https://github.com/tuskyapp/Tusky/pull/4667 ### Significant bug fixes - fixes a bug where Tusky would drop your draft when switching apps https://github.com/tuskyapp/Tusky/pull/4685 https://github.com/tuskyapp/Tusky/pull/4813 https://github.com/tuskyapp/Tusky/pull/4818 - fixes a bug where Tusky would drop media that is being added to a post https://github.com/tuskyapp/Tusky/pull/4662 - fixes a bug that caused the login to fail in some cases https://github.com/tuskyapp/Tusky/pull/4704 ## v26.2 ### Significant bug fixes - Fixes a bug where Tusky would not correctly switch between accounts https://github.com/tuskyapp/Tusky/pull/4636 - Fixes a crash when a status in a notification contains a reblog (happens when subscribed to a Friendica group) https://github.com/tuskyapp/Tusky/pull/4638 - Long video descriptions can no longer cover the video controls https://github.com/tuskyapp/Tusky/pull/4632 - Fixes a bug where Tusky's URL detection algorithm was different from Mastodon's https://github.com/tuskyapp/Tusky/pull/4642 ## v26.1 ### New features and other improvements - The "Reply privacy" account preference now has two additional options: "Match default post privacy" and "Direct". "Match default post privacy" is the default for new accounts. https://github.com/tuskyapp/Tusky/pull/4568 - Tusky now includes ISRG root certificates to keep working on Android 7 and servers that use Let's Encrypt. https://github.com/tuskyapp/Tusky/pull/4609 - The soft keyboard will now be hidden after performing a search. https://github.com/tuskyapp/Tusky/pull/4578 ### Significant bug fixes - Fixes a bug where Tusky sometimes mixes up timelines and/or notifications of accounts. https://github.com/tuskyapp/Tusky/pull/4577 https://github.com/tuskyapp/Tusky/pull/4599 - Fixes two bugs where Tusky would not provide the translation option even though the server is configured correctly. https://github.com/tuskyapp/Tusky/pull/4560 https://github.com/tuskyapp/Tusky/pull/4590 - Fixes a rare bug where Tusky would sometimes randomly crash on startup. https://github.com/tuskyapp/Tusky/pull/4569 - Fixes a bug where the timeline would randomly jump to the position of the last clicked "show more" placeholder when "Reading order" was set to "Oldest first". https://github.com/tuskyapp/Tusky/pull/4619 ## v26.0 ### New features and other improvements - The blue primary color that previously was the same for all themes is now slightly lighter in the dark theme and darker in the light theme for better contrast. Consequently, the color that is used on top of the primary color (e.g. on buttons) is now dark instead of white in the dark theme. [PR#3921](https://github.com/tuskyapp/Tusky/pull/3921) [PR#4507](https://github.com/tuskyapp/Tusky/pull/4507) - New account preference "default reply privacy". Note that in contrast to the "default post privacy" this setting will not be synced with the server as Mastodon does not have this feature. [PR#4496](https://github.com/tuskyapp/Tusky/pull/4496) - New preference "Show confirmation before following" [PR#4445](https://github.com/tuskyapp/Tusky/pull/4445) - The notification tab is now cached on the device for better offline behavior. Since it shares the cache with the home timeline, interactions with posts will now sync between those tabs more often than before. [PR#4026](https://github.com/tuskyapp/Tusky/pull/4026) - Tusky will now only make one call to the server to check which version of the filters api is supported and cache the result instead of everytime filters are needed. [PR#4539](https://github.com/tuskyapp/Tusky/pull/4539) - The "Hide compose button while scrolling" preference, which had the main purpose of making content behind the button accessible, has been removed and bottom padding added to all lists that could be obscured by buttons. [PR#4486](https://github.com/tuskyapp/Tusky/pull/4486) - When viewing media of a translated post the media descriptions will now also be translated [PR#4463](https://github.com/tuskyapp/Tusky/pull/4463) - The custom emojis in the emoji picker are now sorted by category [PR#4533](https://github.com/tuskyapp/Tusky/pull/4533) - Various internal refactorings to improve performance and maintainability. [PR#4515](https://github.com/tuskyapp/Tusky/pull/4515) [PR#4502](https://github.com/tuskyapp/Tusky/pull/4502) [PR#4472](https://github.com/tuskyapp/Tusky/pull/4472) [PR#4470](https://github.com/tuskyapp/Tusky/pull/4470) [PR#4443](https://github.com/tuskyapp/Tusky/pull/4443) [PR#4441](https://github.com/tuskyapp/Tusky/pull/4441) [PR#4461](https://github.com/tuskyapp/Tusky/pull/4461) [PR#4447](https://github.com/tuskyapp/Tusky/pull/4447) [PR#4411](https://github.com/tuskyapp/Tusky/pull/4411) [PR#4413](https://github.com/tuskyapp/Tusky/pull/4413) ### Significant bug fixes - Posts with null media focus values will no longer cause Tusky to show an error [PR#4462](https://github.com/tuskyapp/Tusky/pull/4462) - A lot of other bugfixes, mostly smaller display bugs [PR#4536](https://github.com/tuskyapp/Tusky/pull/4536) [PR#4537](https://github.com/tuskyapp/Tusky/pull/4537) [PR#4527](https://github.com/tuskyapp/Tusky/pull/4527) [PR#4521](https://github.com/tuskyapp/Tusky/pull/4521) [PR#4525](https://github.com/tuskyapp/Tusky/pull/4525) [PR#4518](https://github.com/tuskyapp/Tusky/pull/4518) [PR#4514](https://github.com/tuskyapp/Tusky/pull/4514) [PR#4491](https://github.com/tuskyapp/Tusky/pull/4491) [PR#4490](https://github.com/tuskyapp/Tusky/pull/4490) [PR#4474](https://github.com/tuskyapp/Tusky/pull/4474) [PR#4436](https://github.com/tuskyapp/Tusky/pull/4436) ## 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 - **New preference to scale UI text**, [PR#3248](https://github.com/tuskyapp/Tusky/pull/3248) by [@nikclayton](https://mastodon.social/@nikclayton) ### Significant bug fixes - **Save account information correctly**, [PR#3720](https://github.com/tuskyapp/Tusky/pull/3720) by [@connyduck](https://chaos.social/@ConnyDuck) - If you were logged in with multiple accounts it was possible to switch accounts in a way that the UI showed the new account, but database operations were happening using the old account. - **"pull" notifications on devices running Android versions <= 11**, [PR#3649](https://github.com/tuskyapp/Tusky/pull/3649) by [@nikclayton](https://mastodon.social/@nikclayton) - Pull notifications (i.e., not using ntfy.sh) could silently fail on devices running Android 11 and below - **Work around Android bug where text fields could "forget" they can copy/paste**, [PR#3707](https://github.com/tuskyapp/Tusky/pull/3707) by [@nikclayton](https://mastodon.social/@nikclayton) - **Viewing "diffs" in edit history will not extend off screen edge**, [PR#3431](https://github.com/tuskyapp/Tusky/pull/3431) by [@nikclayton](https://mastodon.social/@nikclayton) - **Don't crash if your server has no post edit history**, [PR#3747](https://github.com/tuskyapp/Tusky/pull/3747) by [@nikclayton](https://mastodon.social/@nikclayton) - Your Mastodon server might know that a post has been edited, but not know the details of those edits. Trying to view the history of those statuses no longer crashes. - **Add a "Delete" button when editing a filter**, [PR#3553](https://github.com/tuskyapp/Tusky/pull/3553) by [@Tak](https://mastodon.gamedev.place/@Tak) - **Show non-square emoji correctly**, [PR#3711](https://github.com/tuskyapp/Tusky/pull/3711) by [@connyduck](https://chaos.social/@ConnyDuck) - **Potential crash when editing profile fields**, [PR#3808](https://github.com/tuskyapp/Tusky/pull/3808) by [@nikclayton](https://mastodon.social/@nikclayton) - **Oversized context menu when editing image descriptions**, [PR#3787](https://github.com/tuskyapp/Tusky/pull/3787) by [@connyduck](https://chaos.social/@ConnyDuck) ## v23.0 beta 2 ### Significant bug fixes - **Potential crash when editing profile fields**, [PR#3808](https://github.com/tuskyapp/Tusky/pull/3808) by [@nikclayton](https://mastodon.social/@nikclayton) - **Oversized context menu when editing image descriptions**, [PR#3787](https://github.com/tuskyapp/Tusky/pull/3787) by [@connyduck](https://chaos.social/@ConnyDuck) ## v23.0 beta 1 ### New features and other improvements - **New preference to scale UI text**, [PR#3248](https://github.com/tuskyapp/Tusky/pull/3248) by [@nikclayton](https://mastodon.social/@nikclayton) ### Significant bug fixes - **Save account information correctly**, [PR#3720](https://github.com/tuskyapp/Tusky/pull/3720) by [@connyduck](https://chaos.social/@ConnyDuck) - If you were logged in with multiple accounts it was possible to switch accounts in a way that the UI showed the new account, but database operations were happening using the old account. - **"pull" notifications on devices running Android versions <= 11**, [PR#3649](https://github.com/tuskyapp/Tusky/pull/3649) by [@nikclayton](https://mastodon.social/@nikclayton) - Pull notifications (i.e., not using ntfy.sh) could silently fail on devices running Android 11 and below - **Work around Android bug where text fields could "forget" they can copy/paste**, [PR#3707](https://github.com/tuskyapp/Tusky/pull/3707) by [@nikclayton](https://mastodon.social/@nikclayton) - **Viewing "diffs" in edit history will not extend off screen edge**, [PR#3431](https://github.com/tuskyapp/Tusky/pull/3431) by [@nikclayton](https://mastodon.social/@nikclayton) - **Don't crash if your server has no post edit history**, [PR#3747](https://github.com/tuskyapp/Tusky/pull/3747) by [@nikclayton](https://mastodon.social/@nikclayton) - Your Mastodon server might know that a post has been edited, but not know the details of those edits. Trying to view the history of those statuses no longer crashes. - **Add a "Delete" button when editing a filter**, [PR#3553](https://github.com/tuskyapp/Tusky/pull/3553) by [@Tak](https://mastodon.gamedev.place/@Tak) - **Show non-square emoji correctly**, [PR#3711](https://github.com/tuskyapp/Tusky/pull/3711) by [@connyduck](https://chaos.social/@ConnyDuck) ## v22.0 ### New features and other improvements - **View trending hashtags**, [PR#3149](https://github.com/tuskyapp/Tusky/pull/3149) by [@knossos](https://fosstodon.org/@knossos) - View trending hashtags from the side menu, or by adding them to a new tab. - **Edit image description and focus point**, [PR#3215](https://github.com/tuskyapp/Tusky/pull/3215) by [@Tak](https://mastodon.gamedev.place/@Tak) - Edit image descriptions and focus points when editing posts. - **View profile banner images**, [PR#3274](https://github.com/tuskyapp/Tusky/pull/3274) by [@Tak](https://mastodon.gamedev.place/@Tak) - Tap the banner image on any profile to view it full size, save, share, etc. - **Follow new hashtags**, [PR#3275](https://github.com/tuskyapp/Tusky/pull/3275) by [@nikclayton](https://mastodon.social/@nikclayton) - Follow new hashtags from the "Followed hashtags" screen. - **Better ordering when selecting languages**, [PR#3293](https://github.com/tuskyapp/Tusky/pull/3293) by [@Tak](https://mastodon.gamedev.place/@Tak) - Tusky will prioritise the language of the post being replied to, your default posting language, configured Tusky languages, and configured system languages when ordering the list of languages to post in. - **"Load more" break is more prominent**, [PR#3376](https://github.com/tuskyapp/Tusky/pull/3376) by [@lakoja](https://freiburg.social/@lakoja) - Adjusted the design so the "Load more" break in a timeline is more obvious. - **Add "Refresh" menu**, [PR#3121](https://github.com/tuskyapp/Tusky/pull/3121) by [@nikclayton](https://mastodon.social/@nikclayton) - Tusky timelines can now be refreshed from a menu as well as swiping, making this accessible to assistive devices. - **Notifications timeline improvements**, [PR#3159](https://github.com/tuskyapp/Tusky/pull/3159) by [@nikclayton](https://mastodon.social/@nikclayton) - Notifications no longer need to "Load more", they are loaded automatically as you scroll. - Errors when interacting with notifications are displayed to the user, with a "Retry" option. - **Show the difference between versions of a post**, [PR#3314](https://github.com/tuskyapp/Tusky/pull/3314) by [@nikclayton](https://mastodon.social/@nikclayton) - Viewing the edits to a post highlights the differences (text that was added or deleted) between the different versions. - **Support Mastodon v4 filters**, [PR#3188](https://github.com/tuskyapp/Tusky/pull/3188) by [@Tak](https://mastodon.gamedev.place/@Tak) - Mastodon v4 introduced additional [filtering controls](https://docs.joinmastodon.org/user/moderating/#filters). - **Option to show post statistics in the timeline**, [PR#3413](https://github.com/tuskyapp/Tusky/pull/3413) - Tusky can now (optionally) show the number of replies, reposts, and favourites a post has received, in the timeline. - **Expanded tappable area for links, hashtags, and mentions in a post**, [PR#3382](https://github.com/tuskyapp/Tusky/pull/3382) by [@nikclayton](https://mastodon.social/@nikclayton) - Links, hashtags, and mentions in a post now react to taps that are a little above, below, or to the side of the tappable text, making them more accessible. ### Significant bug fixes - **Remember selected tab and position**, [PR#3255](https://github.com/tuskyapp/Tusky/pull/3255) by [@nikclayton](https://mastodon.social/@nikclayton) - Changing your tab settings (adding, removing, re-ordering) remembers your reading position in those tabs. - **Show player controls during audio playback**, [PR#3286](https://github.com/tuskyapp/Tusky/pull/3286) by [@EricFrohnhoefer](https://mastodon.social/@EricFrohnhoefer) - A regression from v21.0 where the media player controls could not be used. - **Keep notifications until read**, [PR#3312](https://github.com/tuskyapp/Tusky/pull/3312) by [@lakoja](https://freiburg.social/@lakoja) - Opening Tusky would dismiss all active Tusky Android notifications. - **Fix copying URLs at the end of a post**, [PR#3380](https://github.com/tuskyapp/Tusky/pull/3380) by [@nikclayton](https://mastodon.social/@nikclayton) - Copying a URL from the end of a post could include an extra Unicode whitespace character, making the URL unusable as is. - **Correctly display mixed RTL and LTR text in profiles**, [PR#3328](https://github.com/tuskyapp/Tusky/pull/3328) by [@nikclayton](https://mastodon.social/@nikclayton) - Profile text that contained a mix of right-to-left and left-to-right writing directions would display incorrectly. - **Stop showing duplicates of edited posts in threads**, [PR#3377](https://github.com/tuskyapp/Tusky/pull/3377) by [@Tak](https://mastodon.gamedev.place/@Tak) - Editing a post in thread view would show the old and new version of the post in the thread. - **Correct post length calculation**, [PR#3392](https://github.com/tuskyapp/Tusky/pull/3392) by [@nikclayton](https://mastodon.social/@nikclayton) - In a post that mentioned a user (e.g., `@tusky@mastodon.social`) Tusky was incorrectly including the `@mastodon.social` part when calculating the post's length, leading to incorrect "This post is too long" errors. - **Always publish image captions**, [PR#3421](https://github.com/tuskyapp/Tusky/pull/3421) by [@lakoja](https://freiburg.social/@lakoja) - Finishing editing an image caption before the image had finished loading would lose the caption. - **Clicking "Compose" from a notification would set the wrong account**, [PR#3688](https://github.com/tuskyapp/Tusky/pull/3688) ## v22.0 beta 7 ### Significant bug fixes - **Fetch all outstanding Mastodon notifications when creating Android notifications**, [PR#3700](https://github.com/tuskyapp/Tusky/pull/3700) - **Clicking "Compose" from a notification would set the wrong account**, [PR#3688](https://github.com/tuskyapp/Tusky/pull/3688) - **Ensure "last read notification ID" is saved to the correct account**, [PR#3697](https://github.com/tuskyapp/Tusky/pull/3697) ## v22.0 beta 6 ### Significant bug fixes - **Save reading position in the Notifications tab more frequently**, [PR#3685](https://github.com/tuskyapp/Tusky/pull/3685) ## v22.0 beta 5 ## Significant bug fixes - **Rolled back APNG library to fix broken animated emojis**, [PR#3676](https://github.com/tuskyapp/Tusky/pull/3676) - **Save local copy of notification marker in case server does not support the API**, [PR#3672](https://github.com/tuskyapp/Tusky/pull/3672) ## v22.0 beta 4 ### Significant bug fixes - **Fixed repeated fetch of notifications if configured with multiple accounts**, [PR#3660](https://github.com/tuskyapp/Tusky/pull/3660) ## v22.0 beta 3 ### Significant bug fixes - **Fixed crash when viewing a thread**, [PR#3622](https://github.com/tuskyapp/Tusky/pull/3622) - **Fixed crash processing Mastodon filters**, [PR#3634](https://github.com/tuskyapp/Tusky/pull/3634) - **Links in bios of follow/follow request notifications are clickable**, [PR#3646](https://github.com/tuskyapp/Tusky/pull/3646) - **Android Notifications updates**, [PR#3636](https://github.com/tuskyapp/Tusky/pull/3626) - Android notification for a Mastodon notification should only be shown once - Android notifications are grouped by Mastodon notification type (follow, mention, boost, etc) - Potential for missing notifications has been removed ## v22.0 beta 2 ### Significant bug fixes - **Improved notification loading speed**, [PR#3598](https://github.com/tuskyapp/Tusky/pull/3598) - **Restore showing 0/1/1+ for replies**, [PR#3590](https://github.com/tuskyapp/Tusky/pull/3590) - **Show filter titles, not filter keywords, on filtered posts**, [PR#3589](https://github.com/tuskyapp/Tusky/pull/3589) - **Fixed a bug where opening a status could open an unrelated link**, [PR#3600](https://github.com/tuskyapp/Tusky/pull/3600) - **Show "Add" button in correct place when there are no filters**, [PR#3561](https://github.com/tuskyapp/Tusky/pull/3561) - **Fixed assorted crashes** ## v22.0 beta 1 ### New features and other improvements - **View trending hashtags**, [PR#3149](https://github.com/tuskyapp/Tusky/pull/3149) by [@knossos](https://fosstodon.org/@knossos) - View trending hashtags from the side menu, or by adding them to a new tab. - **Edit image description and focus point**, [PR#3215](https://github.com/tuskyapp/Tusky/pull/3215) by [@Tak](https://mastodon.gamedev.place/@Tak) - Edit image descriptions and focus points when editing posts. - **View profile banner images**, [PR#3274](https://github.com/tuskyapp/Tusky/pull/3274) by [@Tak](https://mastodon.gamedev.place/@Tak) - Tap the banner image on any profile to view it full size, save, share, etc. - **Follow new hashtags**, [PR#3275](https://github.com/tuskyapp/Tusky/pull/3275) by [@nikclayton](https://mastodon.social/@nikclayton) - Follow new hashtags from the "Followed hashtags" screen. - **Better ordering when selecting languages**, [PR#3293](https://github.com/tuskyapp/Tusky/pull/3293) by [@Tak](https://mastodon.gamedev.place/@Tak) - Tusky will prioritise the language of the post being replied to, your default posting language, configured Tusky languages, and configured system languages when ordering the list of languages to post in. - **"Load more" break is more prominent**, [PR#3376](https://github.com/tuskyapp/Tusky/pull/3376) by [@lakoja](https://freiburg.social/@lakoja) - Adjusted the design so the "Load more" break in a timeline is more obvious. - **Add "Refresh" menu**, [PR#3121](https://github.com/tuskyapp/Tusky/pull/3121) by [@nikclayton](https://mastodon.social/@nikclayton) - Tusky timelines can now be refreshed from a menu as well as swiping, making this accessible to assistive devices. - **Notifications timeline improvements**, [PR#3159](https://github.com/tuskyapp/Tusky/pull/3159) by [@nikclayton](https://mastodon.social/@nikclayton) - Notifications no longer need to "Load more", they are loaded automatically as you scroll. - Errors when interacting with notifications are displayed to the user, with a "Retry" option. - **Show the difference between versions of a post**, [PR#3314](https://github.com/tuskyapp/Tusky/pull/3314) by [@nikclayton](https://mastodon.social/@nikclayton) - Viewing the edits to a post highlights the differences (text that was added or deleted) between the different versions. - **Support Mastodon v4 filters**, [PR#3188](https://github.com/tuskyapp/Tusky/pull/3188) by [@Tak](https://mastodon.gamedev.place/@Tak) - Mastodon v4 introduced additional [filtering controls](https://docs.joinmastodon.org/user/moderating/#filters). - **Option to show post statistics in the timeline**, [PR#3413](https://github.com/tuskyapp/Tusky/pull/3413) - Tusky can now (optionally) show the number of replies, reposts, and favourites a post has received, in the timeline. - **Expanded tappable area for links, hashtags, and mentions in a post**, [PR#3382](https://github.com/tuskyapp/Tusky/pull/3382) by [@nikclayton](https://mastodon.social/@nikclayton) - Links, hashtags, and mentions in a post now react to taps that are a little above, below, or to the side of the tappable text, making them more accessible. ### Significant bug fixes - **Remember selected tab and position**, [PR#3255](https://github.com/tuskyapp/Tusky/pull/3255) by [@nikclayton](https://mastodon.social/@nikclayton) - Changing your tab settings (adding, removing, re-ordering) remembers your reading position in those tabs. - **Show player controls during audio playback**, [PR#3286](https://github.com/tuskyapp/Tusky/pull/3286) by [@EricFrohnhoefer](https://mastodon.social/@EricFrohnhoefer) - A regression from v21.0 where the media player controls could not be used. - **Keep notifications until read**, [PR#3312](https://github.com/tuskyapp/Tusky/pull/3312) by [@lakoja](https://freiburg.social/@lakoja) - Opening Tusky would dismiss all active Tusky Android notifications. - **Fix copying URLs at the end of a post**, [PR#3380](https://github.com/tuskyapp/Tusky/pull/3380) by [@nikclayton](https://mastodon.social/@nikclayton) - Copying a URL from the end of a post could include an extra Unicode whitespace character, making the URL unusable as is. - **Correctly display mixed RTL and LTR text in profiles**, [PR#3328](https://github.com/tuskyapp/Tusky/pull/3328) by [@nikclayton](https://mastodon.social/@nikclayton) - Profile text that contained a mix of right-to-left and left-to-right writing directions would display incorrectly. - **Stop showing duplicates of edited posts in threads**, [PR#3377](https://github.com/tuskyapp/Tusky/pull/3377) by [@Tak](https://mastodon.gamedev.place/@Tak) - Editing a post in thread view would show the old and new version of the post in the thread. - **Correct post length calculation**, [PR#3392](https://github.com/tuskyapp/Tusky/pull/3392) by [@nikclayton](https://mastodon.social/@nikclayton) - In a post that mentioned a user (e.g., `@tusky@mastodon.social`) Tusky was incorrectly including the `@mastodon.social` part when calculating the post's length, leading to incorrect "This post is too long" errors. - **Always publish image captions**, [PR#3421](https://github.com/tuskyapp/Tusky/pull/3421) by [@lakoja](https://freiburg.social/@lakoja) - Finishing editing an image caption before the image had finished loading would lose the caption. ================================================ FILE: CONTRIBUTING.md ================================================ # Contributing Thanks for your interest in contributing to Tusky! Here are some informations to help you get started. If you have any questions, don't hesitate to open an issue or join our [development chat on Matrix](https://riot.im/app/#/room/#Tusky:matrix.org). ## Contributing translations Translations are managed on our [Weblate](https://weblate.tusky.app/projects/tusky/tusky/). You can create an account and translate texts through the interface, no coding knowledge required. To add a new language, click on the 'Start a new translation' button on at the bottom of the page. - Use gender-neutral language - Address users informally (e.g. in German "du" and never "Sie") ## Contributing code ### Prerequisites You should have a general understanding of Android development and Git. ### Architecture We try to follow the [Guide to app architecture](https://developer.android.com/topic/architecture). ### 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 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. Try to keep texts friendly and concise. If there is untranslatable text that you don't want to keep as a string constant in Kotlin code, you can use the string resource file `app/src/main/res/values/donottranslate.xml`. ### Viewbinding We use [Viewbinding](https://developer.android.com/topic/libraries/view-binding) to reference views. No contribution using another mechanism will be accepted. There are useful extensions in `src/main/java/com/keylesspalace/tusky/util/ViewExtensions.kt` that make working with viewbinding easier. ### Themes There are three themes in the app, so any visual changes should be checked with each of them to ensure they look appropriate no matter which theme is selected. Usually, you can use existing color attributes like `?attr/colorPrimary` and `?attr/textColorSecondary`. ### Icons All icons are from the rounded variant of the Material Symbols icon set with weight 400 and grade 0. New icons can be found [here](https://fonts.google.com/icons?icon.style=Rounded&icon.size=24). Usually we prefer outlined icons, but there are cases where a filled one is a better choice. If the icon needs to have an active/inactive state it is a good idea to use the outlined icon for the inactive and the filled one for the active state. Icons should be imported as vector drawables and named `ic_icon_name_sizedp_modifier.xml`, e.g. `ic_home_24dp` or `ic_notifications_24dp_filled`. ### Accessibility 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. ### 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 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/) ## AI policy Tusky does not want any contributions, code or otherwise, created with so-called generative AI tools. Please only submit your own work. ================================================ FILE: LICENSE.txt ================================================ GNU GENERAL PUBLIC LICENSE Version 3, 29 June 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The GNU General Public License is a free, copyleft license for software and other kinds of works. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others. For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights. Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it. For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions. Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users. Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free. The precise terms and conditions for copying, distribution and modification follow. TERMS AND CONDITIONS 0. Definitions. "This License" refers to version 3 of the GNU General Public License. "Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. "The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. A "covered work" means either the unmodified Program or a work based on the Program. To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. 1. Source Code. The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. The Corresponding Source for a work in source code form is that same work. 2. Basic Permissions. All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. 3. Protecting Users' Legal Rights From Anti-Circumvention Law. No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. 4. Conveying Verbatim Copies. You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. 5. Conveying Modified Source Versions. You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: a) The work must carry prominent notices stating that you modified it, and giving a relevant date. b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. 6. Conveying Non-Source Forms. You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. "Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. 7. Additional Terms. "Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or d) Limiting the use for publicity purposes of names of licensors or authors of the material; or e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. 8. Termination. You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. 9. Acceptance Not Required for Having Copies. You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. 10. Automatic Licensing of Downstream Recipients. Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. 11. Patents. A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. 12. No Surrender of Others' Freedom. If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. 13. Use with the GNU Affero General Public License. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. 15. Disclaimer of Warranty. THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. Limitation of Liability. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 17. Interpretation of Sections 15 and 16. If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. Copyright (C) This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode: Copyright (C) This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an "about box". You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see . The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read . ================================================ FILE: README.md ================================================ This repository has moved to Codeberg: https://codeberg.org/tusky/Tusky # Tusky [Get it on F-Droid](https://f-droid.org/repository/browse/?fdid=com.keylesspalace.tusky) [Get it on Google Play](https://play.google.com/store/apps/details?id=com.keylesspalace.tusky&utm_source=github&pcampaignid=MKT-Other-global-all-co-prtnr-py-PartBadge-Mar2515-1) ================================================ FILE: app/build.gradle ================================================ import app.cash.licensee.SpdxId plugins { alias(libs.plugins.android.application) alias(libs.plugins.google.ksp) alias(libs.plugins.hilt.android) alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.parcelize) alias(libs.plugins.licensee) } apply from: 'getGitSha.gradle' final def gitSha = ext.getGitSha() // The app name final def APP_NAME = "Tusky" // The application id. Must be unique, e.g. based on your domain final def APP_ID = "com.keylesspalace.tusky" // url of a custom app logo. Recommended size at least 600x600. Keep empty to use the Tusky elephant friend. final def CUSTOM_LOGO_URL = "" // e.g. mastodon.social. Keep empty to not suggest any instance on the signup screen final def CUSTOM_INSTANCE = "" // link to your support account. Will be linked on the about page when not empty. final def SUPPORT_ACCOUNT_URL = "https://mastodon.social/@Tusky" android { compileSdk 36 namespace "com.keylesspalace.tusky" defaultConfig { applicationId APP_ID namespace "com.keylesspalace.tusky" minSdk 24 targetSdk 36 versionCode 133 versionName "29.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables.useSupportLibrary = true resValue "string", "app_name", APP_NAME buildConfigField("String", "CUSTOM_LOGO_URL", "\"$CUSTOM_LOGO_URL\"") buildConfigField("String", "CUSTOM_INSTANCE", "\"$CUSTOM_INSTANCE\"") buildConfigField("String", "SUPPORT_ACCOUNT_URL", "\"$SUPPORT_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" ] } } } flavorDimensions += "color" productFlavors { blue {} green { resValue "string", "app_name", APP_NAME + " Test" applicationIdSuffix ".test" versionNameSuffix "-" + gitSha isDefault true } } lint { lintConfig file("lint.xml") // Regenerate by deleting the file and running `./gradlew app:lintGreenDebug` baseline = file("lint-baseline.xml") } buildFeatures { buildConfig true resValues true viewBinding true } testOptions { unitTests { returnDefaultValues = true includeAndroidResources = true } unitTests.all { systemProperty 'robolectric.logging.enabled', 'true' systemProperty 'robolectric.lazyload', 'ON' } } sourceSets { // workaround to have migrations available in unit tests // https://github.com/robolectric/robolectric/issues/3928#issuecomment-395309991 debug.assets.srcDirs += files("$projectDir/schemas".toString()) } // Exclude unneeded files added by libraries packagingOptions.resources.excludes += [ 'LICENSE_OFL', 'LICENSE_UNICODE', 'META-INF/androidx/**', 'META-INF/NOTICE.md', 'DebugProbesKt.bin' ] bundle { language { // bundle all languages in every apk so the dynamic language switching works enableSplit = false } } dependenciesInfo { includeInApk false includeInBundle false } applicationVariants.configureEach { variant -> variant.outputs.configureEach { outputFileName = "Tusky_${variant.versionName}_${variant.versionCode}_${gitSha}_" + "${variant.flavorName}_${buildType.name}.apk" } } } ksp { arg("room.schemaLocation", "$projectDir/schemas") arg("room.generateKotlin", "true") arg("room.incremental", "true") } licensee { allow(SpdxId.Apache_20) allow(SpdxId.MIT) allowUrl('https://github.com/AOMediaCodec/libavif/blob/master/LICENSE') allowUrl('https://www.bouncycastle.org/licence.html') } configurations { // JNI-only libraries don't play nicely with Robolectric // see https://github.com/tuskyapp/Tusky/pull/3367 testImplementation.exclude group: "org.conscrypt", module: "conscrypt-android" testRuntime.exclude group: "org.conscrypt", module: "conscrypt-android" } // library versions are in PROJECT_ROOT/gradle/libs.versions.toml dependencies { implementation libs.kotlinx.coroutines.android implementation libs.bundles.androidx implementation libs.bundles.room ksp libs.androidx.room.compiler implementation libs.android.material 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 ksp libs.glide.compiler implementation libs.hilt.android ksp libs.hilt.compiler implementation libs.androidx.hilt.work ksp libs.androidx.hilt.compiler implementation libs.sparkbutton implementation libs.touchimageview implementation libs.bundles.material.drawer implementation libs.image.cropper implementation libs.bundles.filemojicompat implementation libs.bouncycastle implementation libs.unified.push implementation libs.bundles.xmldiff testImplementation libs.androidx.test.junit testImplementation libs.robolectric testImplementation libs.bundles.mockito testImplementation libs.mockwebserver testImplementation libs.androidx.core.testing testImplementation libs.androidx.room.testing testImplementation libs.kotlinx.coroutines.test testImplementation libs.androidx.work.testing testImplementation libs.turbine } ================================================ FILE: app/getGitSha.gradle ================================================ import org.gradle.api.provider.ValueSourceParameters import javax.inject.Inject // Must wrap this in a ValueSource in order to get well-defined fail behavior without confusing Gradle on repeat builds. abstract class GitShaValueSource implements ValueSource { @Inject abstract ExecOperations getExecOperations() @Override String obtain() { try { def output = new ByteArrayOutputStream() execOperations.exec { it.commandLine 'git', 'rev-parse', '--short=8', 'HEAD' it.standardOutput = output } return output.toString().trim() } catch (GradleException ignore) { // Git executable unavailable, or we are not building in a git repo. Fall through: } return "unknown" } } // Export closure ext.getGitSha = { providers.of(GitShaValueSource) {}.get() } ================================================ FILE: app/lint-baseline.xml ================================================ ================================================ FILE: app/lint.xml ================================================ ================================================ FILE: app/proguard-rules.pro ================================================ # GENERAL OPTIONS -allowaccessmodification # 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 ; } # For enumeration classes, see http://proguard.sourceforge.net/manual/examples.html#enumerations -keepclassmembers,allowoptimization enum * { public static **[] values(); public static ** valueOf(java.lang.String); } -keepclassmembers class * implements android.os.Parcelable { public static final ** CREATOR; } # TUSKY SPECIFIC OPTIONS # preserve line numbers for crash reporting -keepattributes SourceFile,LineNumberTable -renamesourcefileattribute SourceFile # Bouncy Castle -- Keep EC -keep class org.bouncycastle.jcajce.provider.asymmetric.EC$* { *; } -keep class org.bouncycastle.jcajce.provider.asymmetric.ec.KeyPairGeneratorSpi$EC # Preference fragments can be referenced by name, ensure they remain # https://github.com/tuskyapp/Tusky/issues/3161 -keep class * extends androidx.preference.PreferenceFragmentCompat # remove all logging from production apk -assumenosideeffects class android.util.Log { public static *** getStackTraceString(...); public static *** d(...); public static *** w(...); public static *** v(...); public static *** i(...); } -assumenosideeffects class java.lang.String { public static java.lang.String format(...); } # remove some kotlin overhead -assumenosideeffects class kotlin.jvm.internal.Intrinsics { static void checkNotNull(java.lang.Object); static void checkNotNull(java.lang.Object, java.lang.String); static void checkParameterIsNotNull(java.lang.Object, java.lang.String); static void checkParameterIsNotNull(java.lang.Object, java.lang.String); static void checkNotNullParameter(java.lang.Object, java.lang.String); static void checkExpressionValueIsNotNull(java.lang.Object, java.lang.String); static void checkNotNullExpressionValue(java.lang.Object, java.lang.String); static void checkReturnedValueIsNotNull(java.lang.Object, java.lang.String); static void checkReturnedValueIsNotNull(java.lang.Object, java.lang.String, java.lang.String); static void throwUninitializedProperty(java.lang.String); static void throwUninitializedPropertyAccessException(java.lang.String); } # there is no need for edit mode in production builds, allow it to be pruned -assumenosideeffects public class * extends android.view.View { boolean isInEditMode(); } -assumevalues public class * extends android.view.View { boolean isInEditMode() return false; } -checkdiscard class com.keylesspalace.tusky.usecase.DeveloperToolsUseCase ================================================ FILE: app/schemas/com.keylesspalace.tusky.db.AppDatabase/10.json ================================================ { "formatVersion": 1, "database": { "version": 10, "identityHash": "69e310ef98c0f305934d25e763ee0140", "entities": [ { "tableName": "TootEntity", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `text` TEXT, `urls` TEXT, `descriptions` TEXT, `contentWarning` TEXT, `inReplyToId` TEXT, `inReplyToText` TEXT, `inReplyToUsername` TEXT, `visibility` INTEGER)", "fields": [ { "fieldPath": "uid", "columnName": "uid", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "text", "columnName": "text", "affinity": "TEXT", "notNull": false }, { "fieldPath": "urls", "columnName": "urls", "affinity": "TEXT", "notNull": false }, { "fieldPath": "descriptions", "columnName": "descriptions", "affinity": "TEXT", "notNull": false }, { "fieldPath": "contentWarning", "columnName": "contentWarning", "affinity": "TEXT", "notNull": false }, { "fieldPath": "inReplyToId", "columnName": "inReplyToId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "inReplyToText", "columnName": "inReplyToText", "affinity": "TEXT", "notNull": false }, { "fieldPath": "inReplyToUsername", "columnName": "inReplyToUsername", "affinity": "TEXT", "notNull": false }, { "fieldPath": "visibility", "columnName": "visibility", "affinity": "INTEGER", "notNull": false } ], "primaryKey": { "columnNames": [ "uid" ], "autoGenerate": true }, "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, `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, `notificationsReblogged` INTEGER NOT NULL, `notificationsFavorited` INTEGER NOT NULL, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `lastNotificationId` TEXT NOT NULL, `activeNotifications` TEXT NOT NULL, `emojis` TEXT 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": "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": "notificationsReblogged", "columnName": "notificationsReblogged", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "notificationsFavorited", "columnName": "notificationsFavorited", "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": "alwaysShowSensitiveMedia", "columnName": "alwaysShowSensitiveMedia", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "mediaPreviewEnabled", "columnName": "mediaPreviewEnabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastNotificationId", "columnName": "lastNotificationId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "activeNotifications", "columnName": "activeNotifications", "affinity": "TEXT", "notNull": true }, { "fieldPath": "emojis", "columnName": "emojis", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "indices": [ { "name": "index_AccountEntity_domain_accountId", "unique": true, "columnNames": [ "domain", "accountId" ], "createSql": "CREATE UNIQUE INDEX `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, 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 } ], "primaryKey": { "columnNames": [ "instance" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] } ], "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, \"69e310ef98c0f305934d25e763ee0140\")" ] } } ================================================ FILE: app/schemas/com.keylesspalace.tusky.db.AppDatabase/11.json ================================================ { "formatVersion": 1, "database": { "version": 11, "identityHash": "f5e93302cf53d4250e455b701bea102f", "entities": [ { "tableName": "TootEntity", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `text` TEXT, `urls` TEXT, `descriptions` TEXT, `contentWarning` TEXT, `inReplyToId` TEXT, `inReplyToText` TEXT, `inReplyToUsername` TEXT, `visibility` INTEGER)", "fields": [ { "fieldPath": "uid", "columnName": "uid", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "text", "columnName": "text", "affinity": "TEXT", "notNull": false }, { "fieldPath": "urls", "columnName": "urls", "affinity": "TEXT", "notNull": false }, { "fieldPath": "descriptions", "columnName": "descriptions", "affinity": "TEXT", "notNull": false }, { "fieldPath": "contentWarning", "columnName": "contentWarning", "affinity": "TEXT", "notNull": false }, { "fieldPath": "inReplyToId", "columnName": "inReplyToId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "inReplyToText", "columnName": "inReplyToText", "affinity": "TEXT", "notNull": false }, { "fieldPath": "inReplyToUsername", "columnName": "inReplyToUsername", "affinity": "TEXT", "notNull": false }, { "fieldPath": "visibility", "columnName": "visibility", "affinity": "INTEGER", "notNull": false } ], "primaryKey": { "columnNames": [ "uid" ], "autoGenerate": true }, "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, `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, `notificationsReblogged` INTEGER NOT NULL, `notificationsFavorited` INTEGER NOT NULL, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `lastNotificationId` TEXT NOT NULL, `activeNotifications` TEXT NOT NULL, `emojis` TEXT 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": "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": "notificationsReblogged", "columnName": "notificationsReblogged", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "notificationsFavorited", "columnName": "notificationsFavorited", "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": "alwaysShowSensitiveMedia", "columnName": "alwaysShowSensitiveMedia", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "mediaPreviewEnabled", "columnName": "mediaPreviewEnabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastNotificationId", "columnName": "lastNotificationId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "activeNotifications", "columnName": "activeNotifications", "affinity": "TEXT", "notNull": true }, { "fieldPath": "emojis", "columnName": "emojis", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "indices": [ { "name": "index_AccountEntity_domain_accountId", "unique": true, "columnNames": [ "domain", "accountId" ], "createSql": "CREATE UNIQUE INDEX `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, 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 } ], "primaryKey": { "columnNames": [ "instance" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "TimelineStatusEntity", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `url` TEXT, `timelineUserId` INTEGER NOT NULL, `authorServerId` TEXT, `instance` TEXT, `inReplyToId` TEXT, `inReplyToAccountId` TEXT, `content` TEXT, `createdAt` INTEGER NOT NULL, `emojis` TEXT, `reblogsCount` INTEGER NOT NULL, `favouritesCount` INTEGER NOT NULL, `reblogged` INTEGER NOT NULL, `favourited` INTEGER NOT NULL, `sensitive` INTEGER NOT NULL, `spoilerText` TEXT, `visibility` INTEGER, `attachments` TEXT, `mentions` TEXT, `application` TEXT, `reblogServerId` TEXT, `reblogAccountId` 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": "instance", "columnName": "instance", "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": "emojis", "columnName": "emojis", "affinity": "TEXT", "notNull": false }, { "fieldPath": "reblogsCount", "columnName": "reblogsCount", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "favouritesCount", "columnName": "favouritesCount", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "reblogged", "columnName": "reblogged", "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": false }, { "fieldPath": "visibility", "columnName": "visibility", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "attachments", "columnName": "attachments", "affinity": "TEXT", "notNull": false }, { "fieldPath": "mentions", "columnName": "mentions", "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 } ], "primaryKey": { "columnNames": [ "serverId", "timelineUserId" ], "autoGenerate": false }, "indices": [ { "name": "index_TimelineStatusEntity_authorServerId_timelineUserId", "unique": false, "columnNames": [ "authorServerId", "timelineUserId" ], "createSql": "CREATE INDEX `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, `instance` TEXT 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, PRIMARY KEY(`serverId`, `timelineUserId`))", "fields": [ { "fieldPath": "serverId", "columnName": "serverId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "timelineUserId", "columnName": "timelineUserId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "instance", "columnName": "instance", "affinity": "TEXT", "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 } ], "primaryKey": { "columnNames": [ "serverId", "timelineUserId" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] } ], "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, \"f5e93302cf53d4250e455b701bea102f\")" ] } } ================================================ FILE: app/schemas/com.keylesspalace.tusky.db.AppDatabase/12.json ================================================ { "formatVersion": 1, "database": { "version": 12, "identityHash": "d4d3d4c683ab7f681459b9edab92301c", "entities": [ { "tableName": "TootEntity", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `text` TEXT, `urls` TEXT, `descriptions` TEXT, `contentWarning` TEXT, `inReplyToId` TEXT, `inReplyToText` TEXT, `inReplyToUsername` TEXT, `visibility` INTEGER)", "fields": [ { "fieldPath": "uid", "columnName": "uid", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "text", "columnName": "text", "affinity": "TEXT", "notNull": false }, { "fieldPath": "urls", "columnName": "urls", "affinity": "TEXT", "notNull": false }, { "fieldPath": "descriptions", "columnName": "descriptions", "affinity": "TEXT", "notNull": false }, { "fieldPath": "contentWarning", "columnName": "contentWarning", "affinity": "TEXT", "notNull": false }, { "fieldPath": "inReplyToId", "columnName": "inReplyToId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "inReplyToText", "columnName": "inReplyToText", "affinity": "TEXT", "notNull": false }, { "fieldPath": "inReplyToUsername", "columnName": "inReplyToUsername", "affinity": "TEXT", "notNull": false }, { "fieldPath": "visibility", "columnName": "visibility", "affinity": "INTEGER", "notNull": false } ], "primaryKey": { "columnNames": [ "uid" ], "autoGenerate": true }, "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, `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, `notificationsReblogged` INTEGER NOT NULL, `notificationsFavorited` INTEGER NOT NULL, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `lastNotificationId` TEXT NOT NULL, `activeNotifications` TEXT NOT NULL, `emojis` TEXT NOT NULL, `tabPreferences` TEXT 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": "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": "notificationsReblogged", "columnName": "notificationsReblogged", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "notificationsFavorited", "columnName": "notificationsFavorited", "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": "alwaysShowSensitiveMedia", "columnName": "alwaysShowSensitiveMedia", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "mediaPreviewEnabled", "columnName": "mediaPreviewEnabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastNotificationId", "columnName": "lastNotificationId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "activeNotifications", "columnName": "activeNotifications", "affinity": "TEXT", "notNull": true }, { "fieldPath": "emojis", "columnName": "emojis", "affinity": "TEXT", "notNull": true }, { "fieldPath": "tabPreferences", "columnName": "tabPreferences", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "indices": [ { "name": "index_AccountEntity_domain_accountId", "unique": true, "columnNames": [ "domain", "accountId" ], "createSql": "CREATE UNIQUE INDEX `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, 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 } ], "primaryKey": { "columnNames": [ "instance" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "TimelineStatusEntity", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `url` TEXT, `timelineUserId` INTEGER NOT NULL, `authorServerId` TEXT, `instance` TEXT, `inReplyToId` TEXT, `inReplyToAccountId` TEXT, `content` TEXT, `createdAt` INTEGER NOT NULL, `emojis` TEXT, `reblogsCount` INTEGER NOT NULL, `favouritesCount` INTEGER NOT NULL, `reblogged` INTEGER NOT NULL, `favourited` INTEGER NOT NULL, `sensitive` INTEGER NOT NULL, `spoilerText` TEXT, `visibility` INTEGER, `attachments` TEXT, `mentions` TEXT, `application` TEXT, `reblogServerId` TEXT, `reblogAccountId` 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": "instance", "columnName": "instance", "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": "emojis", "columnName": "emojis", "affinity": "TEXT", "notNull": false }, { "fieldPath": "reblogsCount", "columnName": "reblogsCount", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "favouritesCount", "columnName": "favouritesCount", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "reblogged", "columnName": "reblogged", "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": false }, { "fieldPath": "visibility", "columnName": "visibility", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "attachments", "columnName": "attachments", "affinity": "TEXT", "notNull": false }, { "fieldPath": "mentions", "columnName": "mentions", "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 } ], "primaryKey": { "columnNames": [ "serverId", "timelineUserId" ], "autoGenerate": false }, "indices": [ { "name": "index_TimelineStatusEntity_authorServerId_timelineUserId", "unique": false, "columnNames": [ "authorServerId", "timelineUserId" ], "createSql": "CREATE INDEX `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, `instance` TEXT 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, PRIMARY KEY(`serverId`, `timelineUserId`))", "fields": [ { "fieldPath": "serverId", "columnName": "serverId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "timelineUserId", "columnName": "timelineUserId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "instance", "columnName": "instance", "affinity": "TEXT", "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 } ], "primaryKey": { "columnNames": [ "serverId", "timelineUserId" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "ConversationEntity", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `id` TEXT 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_emojis` TEXT NOT NULL, `s_favouritesCount` INTEGER NOT NULL, `s_favourited` INTEGER NOT NULL, `s_sensitive` INTEGER NOT NULL, `s_spoilerText` TEXT NOT NULL, `s_attachments` TEXT NOT NULL, `s_mentions` TEXT NOT NULL, `s_showingHiddenContent` INTEGER NOT NULL, `s_expanded` INTEGER NOT NULL, `s_collapsible` INTEGER NOT NULL, `s_collapsed` INTEGER NOT NULL, PRIMARY KEY(`id`, `accountId`))", "fields": [ { "fieldPath": "accountId", "columnName": "accountId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "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.emojis", "columnName": "s_emojis", "affinity": "TEXT", "notNull": true }, { "fieldPath": "lastStatus.favouritesCount", "columnName": "s_favouritesCount", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastStatus.favourited", "columnName": "s_favourited", "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.showingHiddenContent", "columnName": "s_showingHiddenContent", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastStatus.expanded", "columnName": "s_expanded", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastStatus.collapsible", "columnName": "s_collapsible", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastStatus.collapsed", "columnName": "s_collapsed", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "id", "accountId" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] } ], "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, \"d4d3d4c683ab7f681459b9edab92301c\")" ] } } ================================================ FILE: app/schemas/com.keylesspalace.tusky.db.AppDatabase/13.json ================================================ { "formatVersion": 1, "database": { "version": 13, "identityHash": "9a63a3ab2c05004022c350aab0e472c0", "entities": [ { "tableName": "TootEntity", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `text` TEXT, `urls` TEXT, `descriptions` TEXT, `contentWarning` TEXT, `inReplyToId` TEXT, `inReplyToText` TEXT, `inReplyToUsername` TEXT, `visibility` INTEGER)", "fields": [ { "fieldPath": "uid", "columnName": "uid", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "text", "columnName": "text", "affinity": "TEXT", "notNull": false }, { "fieldPath": "urls", "columnName": "urls", "affinity": "TEXT", "notNull": false }, { "fieldPath": "descriptions", "columnName": "descriptions", "affinity": "TEXT", "notNull": false }, { "fieldPath": "contentWarning", "columnName": "contentWarning", "affinity": "TEXT", "notNull": false }, { "fieldPath": "inReplyToId", "columnName": "inReplyToId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "inReplyToText", "columnName": "inReplyToText", "affinity": "TEXT", "notNull": false }, { "fieldPath": "inReplyToUsername", "columnName": "inReplyToUsername", "affinity": "TEXT", "notNull": false }, { "fieldPath": "visibility", "columnName": "visibility", "affinity": "INTEGER", "notNull": false } ], "primaryKey": { "columnNames": [ "uid" ], "autoGenerate": true }, "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, `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, `notificationsReblogged` INTEGER NOT NULL, `notificationsFavorited` INTEGER NOT NULL, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `lastNotificationId` TEXT NOT NULL, `activeNotifications` TEXT NOT NULL, `emojis` TEXT NOT NULL, `tabPreferences` TEXT 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": "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": "notificationsReblogged", "columnName": "notificationsReblogged", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "notificationsFavorited", "columnName": "notificationsFavorited", "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": "alwaysShowSensitiveMedia", "columnName": "alwaysShowSensitiveMedia", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "mediaPreviewEnabled", "columnName": "mediaPreviewEnabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastNotificationId", "columnName": "lastNotificationId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "activeNotifications", "columnName": "activeNotifications", "affinity": "TEXT", "notNull": true }, { "fieldPath": "emojis", "columnName": "emojis", "affinity": "TEXT", "notNull": true }, { "fieldPath": "tabPreferences", "columnName": "tabPreferences", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "indices": [ { "name": "index_AccountEntity_domain_accountId", "unique": true, "columnNames": [ "domain", "accountId" ], "createSql": "CREATE UNIQUE INDEX `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, 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 } ], "primaryKey": { "columnNames": [ "instance" ], "autoGenerate": false }, "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, `emojis` TEXT, `reblogsCount` INTEGER NOT NULL, `favouritesCount` INTEGER NOT NULL, `reblogged` INTEGER NOT NULL, `favourited` INTEGER NOT NULL, `sensitive` INTEGER NOT NULL, `spoilerText` TEXT, `visibility` INTEGER, `attachments` TEXT, `mentions` TEXT, `application` TEXT, `reblogServerId` TEXT, `reblogAccountId` 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": "emojis", "columnName": "emojis", "affinity": "TEXT", "notNull": false }, { "fieldPath": "reblogsCount", "columnName": "reblogsCount", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "favouritesCount", "columnName": "favouritesCount", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "reblogged", "columnName": "reblogged", "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": false }, { "fieldPath": "visibility", "columnName": "visibility", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "attachments", "columnName": "attachments", "affinity": "TEXT", "notNull": false }, { "fieldPath": "mentions", "columnName": "mentions", "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 } ], "primaryKey": { "columnNames": [ "serverId", "timelineUserId" ], "autoGenerate": false }, "indices": [ { "name": "index_TimelineStatusEntity_authorServerId_timelineUserId", "unique": false, "columnNames": [ "authorServerId", "timelineUserId" ], "createSql": "CREATE INDEX `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, 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 } ], "primaryKey": { "columnNames": [ "serverId", "timelineUserId" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "ConversationEntity", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `id` TEXT 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_emojis` TEXT NOT NULL, `s_favouritesCount` INTEGER NOT NULL, `s_favourited` INTEGER NOT NULL, `s_sensitive` INTEGER NOT NULL, `s_spoilerText` TEXT NOT NULL, `s_attachments` TEXT NOT NULL, `s_mentions` TEXT NOT NULL, `s_showingHiddenContent` INTEGER NOT NULL, `s_expanded` INTEGER NOT NULL, `s_collapsible` INTEGER NOT NULL, `s_collapsed` INTEGER NOT NULL, PRIMARY KEY(`id`, `accountId`))", "fields": [ { "fieldPath": "accountId", "columnName": "accountId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "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.emojis", "columnName": "s_emojis", "affinity": "TEXT", "notNull": true }, { "fieldPath": "lastStatus.favouritesCount", "columnName": "s_favouritesCount", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastStatus.favourited", "columnName": "s_favourited", "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.showingHiddenContent", "columnName": "s_showingHiddenContent", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastStatus.expanded", "columnName": "s_expanded", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastStatus.collapsible", "columnName": "s_collapsible", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastStatus.collapsed", "columnName": "s_collapsed", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "id", "accountId" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] } ], "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, \"9a63a3ab2c05004022c350aab0e472c0\")" ] } } ================================================ FILE: app/schemas/com.keylesspalace.tusky.db.AppDatabase/14.json ================================================ { "formatVersion": 1, "database": { "version": 14, "identityHash": "b9ca62605345d229ced2bb0c1f2db79b", "entities": [ { "tableName": "TootEntity", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `text` TEXT, `urls` TEXT, `descriptions` TEXT, `contentWarning` TEXT, `inReplyToId` TEXT, `inReplyToText` TEXT, `inReplyToUsername` TEXT, `visibility` INTEGER)", "fields": [ { "fieldPath": "uid", "columnName": "uid", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "text", "columnName": "text", "affinity": "TEXT", "notNull": false }, { "fieldPath": "urls", "columnName": "urls", "affinity": "TEXT", "notNull": false }, { "fieldPath": "descriptions", "columnName": "descriptions", "affinity": "TEXT", "notNull": false }, { "fieldPath": "contentWarning", "columnName": "contentWarning", "affinity": "TEXT", "notNull": false }, { "fieldPath": "inReplyToId", "columnName": "inReplyToId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "inReplyToText", "columnName": "inReplyToText", "affinity": "TEXT", "notNull": false }, { "fieldPath": "inReplyToUsername", "columnName": "inReplyToUsername", "affinity": "TEXT", "notNull": false }, { "fieldPath": "visibility", "columnName": "visibility", "affinity": "INTEGER", "notNull": false } ], "primaryKey": { "columnNames": [ "uid" ], "autoGenerate": true }, "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, `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, `notificationsReblogged` INTEGER NOT NULL, `notificationsFavorited` INTEGER NOT NULL, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `lastNotificationId` TEXT NOT NULL, `activeNotifications` TEXT NOT NULL, `emojis` TEXT NOT NULL, `tabPreferences` TEXT NOT NULL, `notificationsFilter` TEXT 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": "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": "notificationsReblogged", "columnName": "notificationsReblogged", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "notificationsFavorited", "columnName": "notificationsFavorited", "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": "alwaysShowSensitiveMedia", "columnName": "alwaysShowSensitiveMedia", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "mediaPreviewEnabled", "columnName": "mediaPreviewEnabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastNotificationId", "columnName": "lastNotificationId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "activeNotifications", "columnName": "activeNotifications", "affinity": "TEXT", "notNull": true }, { "fieldPath": "emojis", "columnName": "emojis", "affinity": "TEXT", "notNull": true }, { "fieldPath": "tabPreferences", "columnName": "tabPreferences", "affinity": "TEXT", "notNull": true }, { "fieldPath": "notificationsFilter", "columnName": "notificationsFilter", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "indices": [ { "name": "index_AccountEntity_domain_accountId", "unique": true, "columnNames": [ "domain", "accountId" ], "createSql": "CREATE UNIQUE INDEX `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, 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 } ], "primaryKey": { "columnNames": [ "instance" ], "autoGenerate": false }, "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, `emojis` TEXT, `reblogsCount` INTEGER NOT NULL, `favouritesCount` INTEGER NOT NULL, `reblogged` INTEGER NOT NULL, `favourited` INTEGER NOT NULL, `sensitive` INTEGER NOT NULL, `spoilerText` TEXT, `visibility` INTEGER, `attachments` TEXT, `mentions` TEXT, `application` TEXT, `reblogServerId` TEXT, `reblogAccountId` 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": "emojis", "columnName": "emojis", "affinity": "TEXT", "notNull": false }, { "fieldPath": "reblogsCount", "columnName": "reblogsCount", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "favouritesCount", "columnName": "favouritesCount", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "reblogged", "columnName": "reblogged", "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": false }, { "fieldPath": "visibility", "columnName": "visibility", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "attachments", "columnName": "attachments", "affinity": "TEXT", "notNull": false }, { "fieldPath": "mentions", "columnName": "mentions", "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 } ], "primaryKey": { "columnNames": [ "serverId", "timelineUserId" ], "autoGenerate": false }, "indices": [ { "name": "index_TimelineStatusEntity_authorServerId_timelineUserId", "unique": false, "columnNames": [ "authorServerId", "timelineUserId" ], "createSql": "CREATE INDEX `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, 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 } ], "primaryKey": { "columnNames": [ "serverId", "timelineUserId" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "ConversationEntity", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `id` TEXT 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_emojis` TEXT NOT NULL, `s_favouritesCount` INTEGER NOT NULL, `s_favourited` INTEGER NOT NULL, `s_sensitive` INTEGER NOT NULL, `s_spoilerText` TEXT NOT NULL, `s_attachments` TEXT NOT NULL, `s_mentions` TEXT NOT NULL, `s_showingHiddenContent` INTEGER NOT NULL, `s_expanded` INTEGER NOT NULL, `s_collapsible` INTEGER NOT NULL, `s_collapsed` INTEGER NOT NULL, PRIMARY KEY(`id`, `accountId`))", "fields": [ { "fieldPath": "accountId", "columnName": "accountId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "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.emojis", "columnName": "s_emojis", "affinity": "TEXT", "notNull": true }, { "fieldPath": "lastStatus.favouritesCount", "columnName": "s_favouritesCount", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastStatus.favourited", "columnName": "s_favourited", "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.showingHiddenContent", "columnName": "s_showingHiddenContent", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastStatus.expanded", "columnName": "s_expanded", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastStatus.collapsible", "columnName": "s_collapsible", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastStatus.collapsed", "columnName": "s_collapsed", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "id", "accountId" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] } ], "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, \"b9ca62605345d229ced2bb0c1f2db79b\")" ] } } ================================================ FILE: app/schemas/com.keylesspalace.tusky.db.AppDatabase/15.json ================================================ { "formatVersion": 1, "database": { "version": 15, "identityHash": "6a01315ce9f7d402cb61e611140e3c0a", "entities": [ { "tableName": "TootEntity", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `text` TEXT, `urls` TEXT, `descriptions` TEXT, `contentWarning` TEXT, `inReplyToId` TEXT, `inReplyToText` TEXT, `inReplyToUsername` TEXT, `visibility` INTEGER)", "fields": [ { "fieldPath": "uid", "columnName": "uid", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "text", "columnName": "text", "affinity": "TEXT", "notNull": false }, { "fieldPath": "urls", "columnName": "urls", "affinity": "TEXT", "notNull": false }, { "fieldPath": "descriptions", "columnName": "descriptions", "affinity": "TEXT", "notNull": false }, { "fieldPath": "contentWarning", "columnName": "contentWarning", "affinity": "TEXT", "notNull": false }, { "fieldPath": "inReplyToId", "columnName": "inReplyToId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "inReplyToText", "columnName": "inReplyToText", "affinity": "TEXT", "notNull": false }, { "fieldPath": "inReplyToUsername", "columnName": "inReplyToUsername", "affinity": "TEXT", "notNull": false }, { "fieldPath": "visibility", "columnName": "visibility", "affinity": "INTEGER", "notNull": false } ], "primaryKey": { "columnNames": [ "uid" ], "autoGenerate": true }, "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, `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, `notificationsReblogged` INTEGER NOT NULL, `notificationsFavorited` INTEGER NOT NULL, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `lastNotificationId` TEXT NOT NULL, `activeNotifications` TEXT NOT NULL, `emojis` TEXT NOT NULL, `tabPreferences` TEXT NOT NULL, `notificationsFilter` TEXT 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": "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": "notificationsReblogged", "columnName": "notificationsReblogged", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "notificationsFavorited", "columnName": "notificationsFavorited", "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": "alwaysShowSensitiveMedia", "columnName": "alwaysShowSensitiveMedia", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "mediaPreviewEnabled", "columnName": "mediaPreviewEnabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastNotificationId", "columnName": "lastNotificationId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "activeNotifications", "columnName": "activeNotifications", "affinity": "TEXT", "notNull": true }, { "fieldPath": "emojis", "columnName": "emojis", "affinity": "TEXT", "notNull": true }, { "fieldPath": "tabPreferences", "columnName": "tabPreferences", "affinity": "TEXT", "notNull": true }, { "fieldPath": "notificationsFilter", "columnName": "notificationsFilter", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "indices": [ { "name": "index_AccountEntity_domain_accountId", "unique": true, "columnNames": [ "domain", "accountId" ], "createSql": "CREATE UNIQUE INDEX `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, 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 } ], "primaryKey": { "columnNames": [ "instance" ], "autoGenerate": false }, "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, `emojis` TEXT, `reblogsCount` INTEGER NOT NULL, `favouritesCount` INTEGER NOT NULL, `reblogged` INTEGER NOT NULL, `favourited` INTEGER NOT NULL, `sensitive` INTEGER NOT NULL, `spoilerText` TEXT, `visibility` INTEGER, `attachments` TEXT, `mentions` TEXT, `application` TEXT, `reblogServerId` TEXT, `reblogAccountId` TEXT, `poll` 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": "emojis", "columnName": "emojis", "affinity": "TEXT", "notNull": false }, { "fieldPath": "reblogsCount", "columnName": "reblogsCount", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "favouritesCount", "columnName": "favouritesCount", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "reblogged", "columnName": "reblogged", "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": false }, { "fieldPath": "visibility", "columnName": "visibility", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "attachments", "columnName": "attachments", "affinity": "TEXT", "notNull": false }, { "fieldPath": "mentions", "columnName": "mentions", "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 } ], "primaryKey": { "columnNames": [ "serverId", "timelineUserId" ], "autoGenerate": false }, "indices": [ { "name": "index_TimelineStatusEntity_authorServerId_timelineUserId", "unique": false, "columnNames": [ "authorServerId", "timelineUserId" ], "createSql": "CREATE INDEX `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, 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 } ], "primaryKey": { "columnNames": [ "serverId", "timelineUserId" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "ConversationEntity", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `id` TEXT 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_emojis` TEXT NOT NULL, `s_favouritesCount` INTEGER NOT NULL, `s_favourited` INTEGER NOT NULL, `s_sensitive` INTEGER NOT NULL, `s_spoilerText` TEXT NOT NULL, `s_attachments` TEXT NOT NULL, `s_mentions` TEXT NOT NULL, `s_showingHiddenContent` INTEGER NOT NULL, `s_expanded` INTEGER NOT NULL, `s_collapsible` INTEGER NOT NULL, `s_collapsed` INTEGER NOT NULL, `s_poll` TEXT, PRIMARY KEY(`id`, `accountId`))", "fields": [ { "fieldPath": "accountId", "columnName": "accountId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "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.emojis", "columnName": "s_emojis", "affinity": "TEXT", "notNull": true }, { "fieldPath": "lastStatus.favouritesCount", "columnName": "s_favouritesCount", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastStatus.favourited", "columnName": "s_favourited", "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.showingHiddenContent", "columnName": "s_showingHiddenContent", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastStatus.expanded", "columnName": "s_expanded", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastStatus.collapsible", "columnName": "s_collapsible", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastStatus.collapsed", "columnName": "s_collapsed", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastStatus.poll", "columnName": "s_poll", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "id", "accountId" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] } ], "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, \"6a01315ce9f7d402cb61e611140e3c0a\")" ] } } ================================================ FILE: app/schemas/com.keylesspalace.tusky.db.AppDatabase/16.json ================================================ { "formatVersion": 1, "database": { "version": 16, "identityHash": "821df8c72aa78a288b4ae9fe2df21dda", "entities": [ { "tableName": "TootEntity", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `text` TEXT, `urls` TEXT, `descriptions` TEXT, `contentWarning` TEXT, `inReplyToId` TEXT, `inReplyToText` TEXT, `inReplyToUsername` TEXT, `visibility` INTEGER)", "fields": [ { "fieldPath": "uid", "columnName": "uid", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "text", "columnName": "text", "affinity": "TEXT", "notNull": false }, { "fieldPath": "urls", "columnName": "urls", "affinity": "TEXT", "notNull": false }, { "fieldPath": "descriptions", "columnName": "descriptions", "affinity": "TEXT", "notNull": false }, { "fieldPath": "contentWarning", "columnName": "contentWarning", "affinity": "TEXT", "notNull": false }, { "fieldPath": "inReplyToId", "columnName": "inReplyToId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "inReplyToText", "columnName": "inReplyToText", "affinity": "TEXT", "notNull": false }, { "fieldPath": "inReplyToUsername", "columnName": "inReplyToUsername", "affinity": "TEXT", "notNull": false }, { "fieldPath": "visibility", "columnName": "visibility", "affinity": "INTEGER", "notNull": false } ], "primaryKey": { "columnNames": [ "uid" ], "autoGenerate": true }, "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, `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, `notificationsReblogged` INTEGER NOT NULL, `notificationsFavorited` INTEGER NOT NULL, `notificationsPolls` INTEGER NOT NULL, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `lastNotificationId` TEXT NOT NULL, `activeNotifications` TEXT NOT NULL, `emojis` TEXT NOT NULL, `tabPreferences` TEXT NOT NULL, `notificationsFilter` TEXT 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": "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": "notificationsReblogged", "columnName": "notificationsReblogged", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "notificationsFavorited", "columnName": "notificationsFavorited", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "notificationsPolls", "columnName": "notificationsPolls", "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": "alwaysShowSensitiveMedia", "columnName": "alwaysShowSensitiveMedia", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "mediaPreviewEnabled", "columnName": "mediaPreviewEnabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastNotificationId", "columnName": "lastNotificationId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "activeNotifications", "columnName": "activeNotifications", "affinity": "TEXT", "notNull": true }, { "fieldPath": "emojis", "columnName": "emojis", "affinity": "TEXT", "notNull": true }, { "fieldPath": "tabPreferences", "columnName": "tabPreferences", "affinity": "TEXT", "notNull": true }, { "fieldPath": "notificationsFilter", "columnName": "notificationsFilter", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "indices": [ { "name": "index_AccountEntity_domain_accountId", "unique": true, "columnNames": [ "domain", "accountId" ], "createSql": "CREATE UNIQUE INDEX `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, 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 } ], "primaryKey": { "columnNames": [ "instance" ], "autoGenerate": false }, "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, `emojis` TEXT, `reblogsCount` INTEGER NOT NULL, `favouritesCount` INTEGER NOT NULL, `reblogged` INTEGER NOT NULL, `favourited` INTEGER NOT NULL, `sensitive` INTEGER NOT NULL, `spoilerText` TEXT, `visibility` INTEGER, `attachments` TEXT, `mentions` TEXT, `application` TEXT, `reblogServerId` TEXT, `reblogAccountId` TEXT, `poll` 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": "emojis", "columnName": "emojis", "affinity": "TEXT", "notNull": false }, { "fieldPath": "reblogsCount", "columnName": "reblogsCount", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "favouritesCount", "columnName": "favouritesCount", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "reblogged", "columnName": "reblogged", "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": false }, { "fieldPath": "visibility", "columnName": "visibility", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "attachments", "columnName": "attachments", "affinity": "TEXT", "notNull": false }, { "fieldPath": "mentions", "columnName": "mentions", "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 } ], "primaryKey": { "columnNames": [ "serverId", "timelineUserId" ], "autoGenerate": false }, "indices": [ { "name": "index_TimelineStatusEntity_authorServerId_timelineUserId", "unique": false, "columnNames": [ "authorServerId", "timelineUserId" ], "createSql": "CREATE INDEX `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, 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 } ], "primaryKey": { "columnNames": [ "serverId", "timelineUserId" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "ConversationEntity", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `id` TEXT 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_emojis` TEXT NOT NULL, `s_favouritesCount` INTEGER NOT NULL, `s_favourited` INTEGER NOT NULL, `s_sensitive` INTEGER NOT NULL, `s_spoilerText` TEXT NOT NULL, `s_attachments` TEXT NOT NULL, `s_mentions` TEXT NOT NULL, `s_showingHiddenContent` INTEGER NOT NULL, `s_expanded` INTEGER NOT NULL, `s_collapsible` INTEGER NOT NULL, `s_collapsed` INTEGER NOT NULL, `s_poll` TEXT, PRIMARY KEY(`id`, `accountId`))", "fields": [ { "fieldPath": "accountId", "columnName": "accountId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "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.emojis", "columnName": "s_emojis", "affinity": "TEXT", "notNull": true }, { "fieldPath": "lastStatus.favouritesCount", "columnName": "s_favouritesCount", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastStatus.favourited", "columnName": "s_favourited", "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.showingHiddenContent", "columnName": "s_showingHiddenContent", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastStatus.expanded", "columnName": "s_expanded", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastStatus.collapsible", "columnName": "s_collapsible", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastStatus.collapsed", "columnName": "s_collapsed", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastStatus.poll", "columnName": "s_poll", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "id", "accountId" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] } ], "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, \"821df8c72aa78a288b4ae9fe2df21dda\")" ] } } ================================================ FILE: app/schemas/com.keylesspalace.tusky.db.AppDatabase/17.json ================================================ { "formatVersion": 1, "database": { "version": 17, "identityHash": "4e6bfccf6ec0812dc0bc58d5bc8cf556", "entities": [ { "tableName": "TootEntity", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `text` TEXT, `urls` TEXT, `descriptions` TEXT, `contentWarning` TEXT, `inReplyToId` TEXT, `inReplyToText` TEXT, `inReplyToUsername` TEXT, `visibility` INTEGER)", "fields": [ { "fieldPath": "uid", "columnName": "uid", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "text", "columnName": "text", "affinity": "TEXT", "notNull": false }, { "fieldPath": "urls", "columnName": "urls", "affinity": "TEXT", "notNull": false }, { "fieldPath": "descriptions", "columnName": "descriptions", "affinity": "TEXT", "notNull": false }, { "fieldPath": "contentWarning", "columnName": "contentWarning", "affinity": "TEXT", "notNull": false }, { "fieldPath": "inReplyToId", "columnName": "inReplyToId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "inReplyToText", "columnName": "inReplyToText", "affinity": "TEXT", "notNull": false }, { "fieldPath": "inReplyToUsername", "columnName": "inReplyToUsername", "affinity": "TEXT", "notNull": false }, { "fieldPath": "visibility", "columnName": "visibility", "affinity": "INTEGER", "notNull": false } ], "primaryKey": { "columnNames": [ "uid" ], "autoGenerate": true }, "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, `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, `notificationsReblogged` INTEGER NOT NULL, `notificationsFavorited` INTEGER NOT NULL, `notificationsPolls` INTEGER NOT NULL, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `lastNotificationId` TEXT NOT NULL, `activeNotifications` TEXT NOT NULL, `emojis` TEXT NOT NULL, `tabPreferences` TEXT NOT NULL, `notificationsFilter` TEXT 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": "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": "notificationsReblogged", "columnName": "notificationsReblogged", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "notificationsFavorited", "columnName": "notificationsFavorited", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "notificationsPolls", "columnName": "notificationsPolls", "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": "alwaysShowSensitiveMedia", "columnName": "alwaysShowSensitiveMedia", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "mediaPreviewEnabled", "columnName": "mediaPreviewEnabled", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastNotificationId", "columnName": "lastNotificationId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "activeNotifications", "columnName": "activeNotifications", "affinity": "TEXT", "notNull": true }, { "fieldPath": "emojis", "columnName": "emojis", "affinity": "TEXT", "notNull": true }, { "fieldPath": "tabPreferences", "columnName": "tabPreferences", "affinity": "TEXT", "notNull": true }, { "fieldPath": "notificationsFilter", "columnName": "notificationsFilter", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "indices": [ { "name": "index_AccountEntity_domain_accountId", "unique": true, "columnNames": [ "domain", "accountId" ], "createSql": "CREATE UNIQUE INDEX `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, 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 } ], "primaryKey": { "columnNames": [ "instance" ], "autoGenerate": false }, "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, `emojis` TEXT, `reblogsCount` INTEGER NOT NULL, `favouritesCount` INTEGER NOT NULL, `reblogged` INTEGER NOT NULL, `favourited` INTEGER NOT NULL, `sensitive` INTEGER NOT NULL, `spoilerText` TEXT, `visibility` INTEGER, `attachments` TEXT, `mentions` TEXT, `application` TEXT, `reblogServerId` TEXT, `reblogAccountId` TEXT, `poll` 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": "emojis", "columnName": "emojis", "affinity": "TEXT", "notNull": false }, { "fieldPath": "reblogsCount", "columnName": "reblogsCount", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "favouritesCount", "columnName": "favouritesCount", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "reblogged", "columnName": "reblogged", "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": false }, { "fieldPath": "visibility", "columnName": "visibility", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "attachments", "columnName": "attachments", "affinity": "TEXT", "notNull": false }, { "fieldPath": "mentions", "columnName": "mentions", "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 } ], "primaryKey": { "columnNames": [ "serverId", "timelineUserId" ], "autoGenerate": false }, "indices": [ { "name": "index_TimelineStatusEntity_authorServerId_timelineUserId", "unique": false, "columnNames": [ "authorServerId", "timelineUserId" ], "createSql": "CREATE INDEX `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": { "columnNames": [ "serverId", "timelineUserId" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "ConversationEntity", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `id` TEXT 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_emojis` TEXT NOT NULL, `s_favouritesCount` INTEGER NOT NULL, `s_favourited` INTEGER NOT NULL, `s_sensitive` INTEGER NOT NULL, `s_spoilerText` TEXT NOT NULL, `s_attachments` TEXT NOT NULL, `s_mentions` TEXT NOT NULL, `s_showingHiddenContent` INTEGER NOT NULL, `s_expanded` INTEGER NOT NULL, `s_collapsible` INTEGER NOT NULL, `s_collapsed` INTEGER NOT NULL, `s_poll` TEXT, PRIMARY KEY(`id`, `accountId`))", "fields": [ { "fieldPath": "accountId", "columnName": "accountId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "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.emojis", "columnName": "s_emojis", "affinity": "TEXT", "notNull": true }, { "fieldPath": "lastStatus.favouritesCount", "columnName": "s_favouritesCount", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastStatus.favourited", "columnName": "s_favourited", "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.showingHiddenContent", "columnName": "s_showingHiddenContent", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastStatus.expanded", "columnName": "s_expanded", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastStatus.collapsible", "columnName": "s_collapsible", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastStatus.collapsed", "columnName": "s_collapsed", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastStatus.poll", "columnName": "s_poll", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "id", "accountId" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] } ], "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, \"4e6bfccf6ec0812dc0bc58d5bc8cf556\")" ] } } ================================================ FILE: app/schemas/com.keylesspalace.tusky.db.AppDatabase/18.json ================================================ { "formatVersion": 1, "database": { "version": 18, "identityHash": "33d7d9b8ba14c87b96ce795c337bfc57", "entities": [ { "tableName": "TootEntity", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `text` TEXT, `urls` TEXT, `descriptions` TEXT, `contentWarning` TEXT, `inReplyToId` TEXT, `inReplyToText` TEXT, `inReplyToUsername` TEXT, `visibility` INTEGER)", "fields": [ { "fieldPath": "uid", "columnName": "uid", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "text", "columnName": "text", "affinity": "TEXT", "notNull": false }, { "fieldPath": "urls", "columnName": "urls", "affinity": "TEXT", "notNull": false }, { "fieldPath": "descriptions", "columnName": "descriptions", "affinity": "TEXT", "notNull": false }, { "fieldPath": "contentWarning", "columnName": "contentWarning", "affinity": "TEXT", "notNull": false }, { "fieldPath": "inReplyToId", "columnName": "inReplyToId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "inReplyToText", "columnName": "inReplyToText", "affinity": "TEXT", "notNull": false }, { "fieldPath": "inReplyToUsername", "columnName": "inReplyToUsername", "affinity": "TEXT", "notNull": false }, { "fieldPath": "visibility", "columnName": "visibility", "affinity": "INTEGER", "notNull": false } ], "primaryKey": { "columnNames": [ "uid" ], "autoGenerate": true }, "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, `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, `notificationsReblogged` INTEGER NOT NULL, `notificationsFavorited` INTEGER NOT NULL, `notificationsPolls` INTEGER NOT NULL, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `alwaysOpenSpoiler` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `lastNotificationId` TEXT NOT NULL, `activeNotifications` TEXT NOT NULL, `emojis` TEXT NOT NULL, `tabPreferences` TEXT NOT NULL, `notificationsFilter` TEXT 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": "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": "notificationsReblogged", "columnName": "notificationsReblogged", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "notificationsFavorited", "columnName": "notificationsFavorited", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "notificationsPolls", "columnName": "notificationsPolls", "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": "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": "activeNotifications", "columnName": "activeNotifications", "affinity": "TEXT", "notNull": true }, { "fieldPath": "emojis", "columnName": "emojis", "affinity": "TEXT", "notNull": true }, { "fieldPath": "tabPreferences", "columnName": "tabPreferences", "affinity": "TEXT", "notNull": true }, { "fieldPath": "notificationsFilter", "columnName": "notificationsFilter", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "indices": [ { "name": "index_AccountEntity_domain_accountId", "unique": true, "columnNames": [ "domain", "accountId" ], "createSql": "CREATE UNIQUE INDEX `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, 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 } ], "primaryKey": { "columnNames": [ "instance" ], "autoGenerate": false }, "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, `emojis` TEXT, `reblogsCount` INTEGER NOT NULL, `favouritesCount` INTEGER NOT NULL, `reblogged` INTEGER NOT NULL, `favourited` INTEGER NOT NULL, `sensitive` INTEGER NOT NULL, `spoilerText` TEXT, `visibility` INTEGER, `attachments` TEXT, `mentions` TEXT, `application` TEXT, `reblogServerId` TEXT, `reblogAccountId` TEXT, `poll` 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": "emojis", "columnName": "emojis", "affinity": "TEXT", "notNull": false }, { "fieldPath": "reblogsCount", "columnName": "reblogsCount", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "favouritesCount", "columnName": "favouritesCount", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "reblogged", "columnName": "reblogged", "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": false }, { "fieldPath": "visibility", "columnName": "visibility", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "attachments", "columnName": "attachments", "affinity": "TEXT", "notNull": false }, { "fieldPath": "mentions", "columnName": "mentions", "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 } ], "primaryKey": { "columnNames": [ "serverId", "timelineUserId" ], "autoGenerate": false }, "indices": [ { "name": "index_TimelineStatusEntity_authorServerId_timelineUserId", "unique": false, "columnNames": [ "authorServerId", "timelineUserId" ], "createSql": "CREATE INDEX `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": { "columnNames": [ "serverId", "timelineUserId" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "ConversationEntity", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `id` TEXT 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_emojis` TEXT NOT NULL, `s_favouritesCount` INTEGER NOT NULL, `s_favourited` INTEGER NOT NULL, `s_sensitive` INTEGER NOT NULL, `s_spoilerText` TEXT NOT NULL, `s_attachments` TEXT NOT NULL, `s_mentions` TEXT NOT NULL, `s_showingHiddenContent` INTEGER NOT NULL, `s_expanded` INTEGER NOT NULL, `s_collapsible` INTEGER NOT NULL, `s_collapsed` INTEGER NOT NULL, `s_poll` TEXT, PRIMARY KEY(`id`, `accountId`))", "fields": [ { "fieldPath": "accountId", "columnName": "accountId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "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.emojis", "columnName": "s_emojis", "affinity": "TEXT", "notNull": true }, { "fieldPath": "lastStatus.favouritesCount", "columnName": "s_favouritesCount", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastStatus.favourited", "columnName": "s_favourited", "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.showingHiddenContent", "columnName": "s_showingHiddenContent", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastStatus.expanded", "columnName": "s_expanded", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastStatus.collapsible", "columnName": "s_collapsible", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastStatus.collapsed", "columnName": "s_collapsed", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastStatus.poll", "columnName": "s_poll", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "id", "accountId" ], "autoGenerate": false }, "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, '33d7d9b8ba14c87b96ce795c337bfc57')" ] } } ================================================ FILE: app/schemas/com.keylesspalace.tusky.db.AppDatabase/19.json ================================================ { "formatVersion": 1, "database": { "version": 19, "identityHash": "84ebd39cba4d6749251d330851b70e36", "entities": [ { "tableName": "TootEntity", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `text` TEXT, `urls` TEXT, `descriptions` TEXT, `contentWarning` TEXT, `inReplyToId` TEXT, `inReplyToText` TEXT, `inReplyToUsername` TEXT, `visibility` INTEGER, `poll` TEXT)", "fields": [ { "fieldPath": "uid", "columnName": "uid", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "text", "columnName": "text", "affinity": "TEXT", "notNull": false }, { "fieldPath": "urls", "columnName": "urls", "affinity": "TEXT", "notNull": false }, { "fieldPath": "descriptions", "columnName": "descriptions", "affinity": "TEXT", "notNull": false }, { "fieldPath": "contentWarning", "columnName": "contentWarning", "affinity": "TEXT", "notNull": false }, { "fieldPath": "inReplyToId", "columnName": "inReplyToId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "inReplyToText", "columnName": "inReplyToText", "affinity": "TEXT", "notNull": false }, { "fieldPath": "inReplyToUsername", "columnName": "inReplyToUsername", "affinity": "TEXT", "notNull": false }, { "fieldPath": "visibility", "columnName": "visibility", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "poll", "columnName": "poll", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "uid" ], "autoGenerate": true }, "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, `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, `notificationsReblogged` INTEGER NOT NULL, `notificationsFavorited` INTEGER NOT NULL, `notificationsPolls` INTEGER NOT NULL, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `alwaysOpenSpoiler` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `lastNotificationId` TEXT NOT NULL, `activeNotifications` TEXT NOT NULL, `emojis` TEXT NOT NULL, `tabPreferences` TEXT NOT NULL, `notificationsFilter` TEXT 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": "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": "notificationsReblogged", "columnName": "notificationsReblogged", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "notificationsFavorited", "columnName": "notificationsFavorited", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "notificationsPolls", "columnName": "notificationsPolls", "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": "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": "activeNotifications", "columnName": "activeNotifications", "affinity": "TEXT", "notNull": true }, { "fieldPath": "emojis", "columnName": "emojis", "affinity": "TEXT", "notNull": true }, { "fieldPath": "tabPreferences", "columnName": "tabPreferences", "affinity": "TEXT", "notNull": true }, { "fieldPath": "notificationsFilter", "columnName": "notificationsFilter", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "indices": [ { "name": "index_AccountEntity_domain_accountId", "unique": true, "columnNames": [ "domain", "accountId" ], "createSql": "CREATE UNIQUE INDEX `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, 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 } ], "primaryKey": { "columnNames": [ "instance" ], "autoGenerate": false }, "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, `emojis` TEXT, `reblogsCount` INTEGER NOT NULL, `favouritesCount` INTEGER NOT NULL, `reblogged` INTEGER NOT NULL, `favourited` INTEGER NOT NULL, `sensitive` INTEGER NOT NULL, `spoilerText` TEXT, `visibility` INTEGER, `attachments` TEXT, `mentions` TEXT, `application` TEXT, `reblogServerId` TEXT, `reblogAccountId` TEXT, `poll` 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": "emojis", "columnName": "emojis", "affinity": "TEXT", "notNull": false }, { "fieldPath": "reblogsCount", "columnName": "reblogsCount", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "favouritesCount", "columnName": "favouritesCount", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "reblogged", "columnName": "reblogged", "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": false }, { "fieldPath": "visibility", "columnName": "visibility", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "attachments", "columnName": "attachments", "affinity": "TEXT", "notNull": false }, { "fieldPath": "mentions", "columnName": "mentions", "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 } ], "primaryKey": { "columnNames": [ "serverId", "timelineUserId" ], "autoGenerate": false }, "indices": [ { "name": "index_TimelineStatusEntity_authorServerId_timelineUserId", "unique": false, "columnNames": [ "authorServerId", "timelineUserId" ], "createSql": "CREATE INDEX `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": { "columnNames": [ "serverId", "timelineUserId" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "ConversationEntity", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `id` TEXT 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_emojis` TEXT NOT NULL, `s_favouritesCount` INTEGER NOT NULL, `s_favourited` INTEGER NOT NULL, `s_sensitive` INTEGER NOT NULL, `s_spoilerText` TEXT NOT NULL, `s_attachments` TEXT NOT NULL, `s_mentions` TEXT NOT NULL, `s_showingHiddenContent` INTEGER NOT NULL, `s_expanded` INTEGER NOT NULL, `s_collapsible` INTEGER NOT NULL, `s_collapsed` INTEGER NOT NULL, `s_poll` TEXT, PRIMARY KEY(`id`, `accountId`))", "fields": [ { "fieldPath": "accountId", "columnName": "accountId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "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.emojis", "columnName": "s_emojis", "affinity": "TEXT", "notNull": true }, { "fieldPath": "lastStatus.favouritesCount", "columnName": "s_favouritesCount", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastStatus.favourited", "columnName": "s_favourited", "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.showingHiddenContent", "columnName": "s_showingHiddenContent", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastStatus.expanded", "columnName": "s_expanded", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastStatus.collapsible", "columnName": "s_collapsible", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastStatus.collapsed", "columnName": "s_collapsed", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastStatus.poll", "columnName": "s_poll", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "id", "accountId" ], "autoGenerate": false }, "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, '84ebd39cba4d6749251d330851b70e36')" ] } } ================================================ FILE: app/schemas/com.keylesspalace.tusky.db.AppDatabase/20.json ================================================ { "formatVersion": 1, "database": { "version": 20, "identityHash": "611700a54bdc155d6bc9d87b8b2af2aa", "entities": [ { "tableName": "TootEntity", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `text` TEXT, `urls` TEXT, `descriptions` TEXT, `contentWarning` TEXT, `inReplyToId` TEXT, `inReplyToText` TEXT, `inReplyToUsername` TEXT, `visibility` INTEGER, `poll` TEXT)", "fields": [ { "fieldPath": "uid", "columnName": "uid", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "text", "columnName": "text", "affinity": "TEXT", "notNull": false }, { "fieldPath": "urls", "columnName": "urls", "affinity": "TEXT", "notNull": false }, { "fieldPath": "descriptions", "columnName": "descriptions", "affinity": "TEXT", "notNull": false }, { "fieldPath": "contentWarning", "columnName": "contentWarning", "affinity": "TEXT", "notNull": false }, { "fieldPath": "inReplyToId", "columnName": "inReplyToId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "inReplyToText", "columnName": "inReplyToText", "affinity": "TEXT", "notNull": false }, { "fieldPath": "inReplyToUsername", "columnName": "inReplyToUsername", "affinity": "TEXT", "notNull": false }, { "fieldPath": "visibility", "columnName": "visibility", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "poll", "columnName": "poll", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "uid" ], "autoGenerate": true }, "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, `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, `notificationsReblogged` INTEGER NOT NULL, `notificationsFavorited` INTEGER NOT NULL, `notificationsPolls` INTEGER NOT NULL, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `alwaysOpenSpoiler` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `lastNotificationId` TEXT NOT NULL, `activeNotifications` TEXT NOT NULL, `emojis` TEXT NOT NULL, `tabPreferences` TEXT NOT NULL, `notificationsFilter` TEXT 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": "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": "notificationsReblogged", "columnName": "notificationsReblogged", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "notificationsFavorited", "columnName": "notificationsFavorited", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "notificationsPolls", "columnName": "notificationsPolls", "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": "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": "activeNotifications", "columnName": "activeNotifications", "affinity": "TEXT", "notNull": true }, { "fieldPath": "emojis", "columnName": "emojis", "affinity": "TEXT", "notNull": true }, { "fieldPath": "tabPreferences", "columnName": "tabPreferences", "affinity": "TEXT", "notNull": true }, { "fieldPath": "notificationsFilter", "columnName": "notificationsFilter", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "indices": [ { "name": "index_AccountEntity_domain_accountId", "unique": true, "columnNames": [ "domain", "accountId" ], "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, 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 } ], "primaryKey": { "columnNames": [ "instance" ], "autoGenerate": false }, "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, `emojis` TEXT, `reblogsCount` INTEGER NOT NULL, `favouritesCount` INTEGER NOT NULL, `reblogged` INTEGER NOT NULL, `bookmarked` INTEGER NOT NULL, `favourited` INTEGER NOT NULL, `sensitive` INTEGER NOT NULL, `spoilerText` TEXT, `visibility` INTEGER, `attachments` TEXT, `mentions` TEXT, `application` TEXT, `reblogServerId` TEXT, `reblogAccountId` TEXT, `poll` 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": "emojis", "columnName": "emojis", "affinity": "TEXT", "notNull": false }, { "fieldPath": "reblogsCount", "columnName": "reblogsCount", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "favouritesCount", "columnName": "favouritesCount", "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": false }, { "fieldPath": "visibility", "columnName": "visibility", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "attachments", "columnName": "attachments", "affinity": "TEXT", "notNull": false }, { "fieldPath": "mentions", "columnName": "mentions", "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 } ], "primaryKey": { "columnNames": [ "serverId", "timelineUserId" ], "autoGenerate": false }, "indices": [ { "name": "index_TimelineStatusEntity_authorServerId_timelineUserId", "unique": false, "columnNames": [ "authorServerId", "timelineUserId" ], "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": { "columnNames": [ "serverId", "timelineUserId" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "ConversationEntity", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `id` TEXT 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_emojis` TEXT NOT NULL, `s_favouritesCount` 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_showingHiddenContent` INTEGER NOT NULL, `s_expanded` INTEGER NOT NULL, `s_collapsible` INTEGER NOT NULL, `s_collapsed` INTEGER NOT NULL, `s_poll` TEXT, PRIMARY KEY(`id`, `accountId`))", "fields": [ { "fieldPath": "accountId", "columnName": "accountId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "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.emojis", "columnName": "s_emojis", "affinity": "TEXT", "notNull": true }, { "fieldPath": "lastStatus.favouritesCount", "columnName": "s_favouritesCount", "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.showingHiddenContent", "columnName": "s_showingHiddenContent", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastStatus.expanded", "columnName": "s_expanded", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastStatus.collapsible", "columnName": "s_collapsible", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastStatus.collapsed", "columnName": "s_collapsed", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastStatus.poll", "columnName": "s_poll", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "id", "accountId" ], "autoGenerate": false }, "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, '611700a54bdc155d6bc9d87b8b2af2aa')" ] } } ================================================ FILE: app/schemas/com.keylesspalace.tusky.db.AppDatabase/21.json ================================================ { "formatVersion": 1, "database": { "version": 21, "identityHash": "7570c84ffeb4f90521f91dc7ef3e7da1", "entities": [ { "tableName": "TootEntity", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `text` TEXT, `urls` TEXT, `descriptions` TEXT, `contentWarning` TEXT, `inReplyToId` TEXT, `inReplyToText` TEXT, `inReplyToUsername` TEXT, `visibility` INTEGER, `poll` TEXT)", "fields": [ { "fieldPath": "uid", "columnName": "uid", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "text", "columnName": "text", "affinity": "TEXT", "notNull": false }, { "fieldPath": "urls", "columnName": "urls", "affinity": "TEXT", "notNull": false }, { "fieldPath": "descriptions", "columnName": "descriptions", "affinity": "TEXT", "notNull": false }, { "fieldPath": "contentWarning", "columnName": "contentWarning", "affinity": "TEXT", "notNull": false }, { "fieldPath": "inReplyToId", "columnName": "inReplyToId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "inReplyToText", "columnName": "inReplyToText", "affinity": "TEXT", "notNull": false }, { "fieldPath": "inReplyToUsername", "columnName": "inReplyToUsername", "affinity": "TEXT", "notNull": false }, { "fieldPath": "visibility", "columnName": "visibility", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "poll", "columnName": "poll", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "uid" ], "autoGenerate": true }, "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, `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, `notificationsReblogged` INTEGER NOT NULL, `notificationsFavorited` INTEGER NOT NULL, `notificationsPolls` INTEGER NOT NULL, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `alwaysOpenSpoiler` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `lastNotificationId` TEXT NOT NULL, `activeNotifications` TEXT NOT NULL, `emojis` TEXT NOT NULL, `tabPreferences` TEXT NOT NULL, `notificationsFilter` TEXT 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": "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": "notificationsReblogged", "columnName": "notificationsReblogged", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "notificationsFavorited", "columnName": "notificationsFavorited", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "notificationsPolls", "columnName": "notificationsPolls", "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": "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": "activeNotifications", "columnName": "activeNotifications", "affinity": "TEXT", "notNull": true }, { "fieldPath": "emojis", "columnName": "emojis", "affinity": "TEXT", "notNull": true }, { "fieldPath": "tabPreferences", "columnName": "tabPreferences", "affinity": "TEXT", "notNull": true }, { "fieldPath": "notificationsFilter", "columnName": "notificationsFilter", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "indices": [ { "name": "index_AccountEntity_domain_accountId", "unique": true, "columnNames": [ "domain", "accountId" ], "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, `version` TEXT, 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": "version", "columnName": "version", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "instance" ], "autoGenerate": false }, "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, `emojis` TEXT, `reblogsCount` INTEGER NOT NULL, `favouritesCount` INTEGER NOT NULL, `reblogged` INTEGER NOT NULL, `bookmarked` INTEGER NOT NULL, `favourited` INTEGER NOT NULL, `sensitive` INTEGER NOT NULL, `spoilerText` TEXT, `visibility` INTEGER, `attachments` TEXT, `mentions` TEXT, `application` TEXT, `reblogServerId` TEXT, `reblogAccountId` TEXT, `poll` 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": "emojis", "columnName": "emojis", "affinity": "TEXT", "notNull": false }, { "fieldPath": "reblogsCount", "columnName": "reblogsCount", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "favouritesCount", "columnName": "favouritesCount", "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": false }, { "fieldPath": "visibility", "columnName": "visibility", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "attachments", "columnName": "attachments", "affinity": "TEXT", "notNull": false }, { "fieldPath": "mentions", "columnName": "mentions", "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 } ], "primaryKey": { "columnNames": [ "serverId", "timelineUserId" ], "autoGenerate": false }, "indices": [ { "name": "index_TimelineStatusEntity_authorServerId_timelineUserId", "unique": false, "columnNames": [ "authorServerId", "timelineUserId" ], "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": { "columnNames": [ "serverId", "timelineUserId" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "ConversationEntity", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `id` TEXT 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_emojis` TEXT NOT NULL, `s_favouritesCount` 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_showingHiddenContent` INTEGER NOT NULL, `s_expanded` INTEGER NOT NULL, `s_collapsible` INTEGER NOT NULL, `s_collapsed` INTEGER NOT NULL, `s_poll` TEXT, PRIMARY KEY(`id`, `accountId`))", "fields": [ { "fieldPath": "accountId", "columnName": "accountId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "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.emojis", "columnName": "s_emojis", "affinity": "TEXT", "notNull": true }, { "fieldPath": "lastStatus.favouritesCount", "columnName": "s_favouritesCount", "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.showingHiddenContent", "columnName": "s_showingHiddenContent", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastStatus.expanded", "columnName": "s_expanded", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastStatus.collapsible", "columnName": "s_collapsible", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastStatus.collapsed", "columnName": "s_collapsed", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastStatus.poll", "columnName": "s_poll", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "id", "accountId" ], "autoGenerate": false }, "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, '7570c84ffeb4f90521f91dc7ef3e7da1')" ] } } ================================================ FILE: app/schemas/com.keylesspalace.tusky.db.AppDatabase/22.json ================================================ { "formatVersion": 1, "database": { "version": 22, "identityHash": "eaa3c4d012fe743948343983fe1ae493", "entities": [ { "tableName": "TootEntity", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `text` TEXT, `urls` TEXT, `descriptions` TEXT, `contentWarning` TEXT, `inReplyToId` TEXT, `inReplyToText` TEXT, `inReplyToUsername` TEXT, `visibility` INTEGER, `poll` TEXT)", "fields": [ { "fieldPath": "uid", "columnName": "uid", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "text", "columnName": "text", "affinity": "TEXT", "notNull": false }, { "fieldPath": "urls", "columnName": "urls", "affinity": "TEXT", "notNull": false }, { "fieldPath": "descriptions", "columnName": "descriptions", "affinity": "TEXT", "notNull": false }, { "fieldPath": "contentWarning", "columnName": "contentWarning", "affinity": "TEXT", "notNull": false }, { "fieldPath": "inReplyToId", "columnName": "inReplyToId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "inReplyToText", "columnName": "inReplyToText", "affinity": "TEXT", "notNull": false }, { "fieldPath": "inReplyToUsername", "columnName": "inReplyToUsername", "affinity": "TEXT", "notNull": false }, { "fieldPath": "visibility", "columnName": "visibility", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "poll", "columnName": "poll", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "uid" ], "autoGenerate": true }, "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, `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, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `alwaysOpenSpoiler` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `lastNotificationId` TEXT NOT NULL, `activeNotifications` TEXT NOT NULL, `emojis` TEXT NOT NULL, `tabPreferences` TEXT NOT NULL, `notificationsFilter` TEXT 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": "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": "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": "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": "activeNotifications", "columnName": "activeNotifications", "affinity": "TEXT", "notNull": true }, { "fieldPath": "emojis", "columnName": "emojis", "affinity": "TEXT", "notNull": true }, { "fieldPath": "tabPreferences", "columnName": "tabPreferences", "affinity": "TEXT", "notNull": true }, { "fieldPath": "notificationsFilter", "columnName": "notificationsFilter", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "indices": [ { "name": "index_AccountEntity_domain_accountId", "unique": true, "columnNames": [ "domain", "accountId" ], "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, `version` TEXT, 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": "version", "columnName": "version", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "instance" ], "autoGenerate": false }, "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, `emojis` TEXT, `reblogsCount` INTEGER NOT NULL, `favouritesCount` INTEGER NOT NULL, `reblogged` INTEGER NOT NULL, `bookmarked` INTEGER NOT NULL, `favourited` INTEGER NOT NULL, `sensitive` INTEGER NOT NULL, `spoilerText` TEXT, `visibility` INTEGER, `attachments` TEXT, `mentions` TEXT, `application` TEXT, `reblogServerId` TEXT, `reblogAccountId` TEXT, `poll` 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": "emojis", "columnName": "emojis", "affinity": "TEXT", "notNull": false }, { "fieldPath": "reblogsCount", "columnName": "reblogsCount", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "favouritesCount", "columnName": "favouritesCount", "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": false }, { "fieldPath": "visibility", "columnName": "visibility", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "attachments", "columnName": "attachments", "affinity": "TEXT", "notNull": false }, { "fieldPath": "mentions", "columnName": "mentions", "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 } ], "primaryKey": { "columnNames": [ "serverId", "timelineUserId" ], "autoGenerate": false }, "indices": [ { "name": "index_TimelineStatusEntity_authorServerId_timelineUserId", "unique": false, "columnNames": [ "authorServerId", "timelineUserId" ], "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": { "columnNames": [ "serverId", "timelineUserId" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "ConversationEntity", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `id` TEXT 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_emojis` TEXT NOT NULL, `s_favouritesCount` 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_showingHiddenContent` INTEGER NOT NULL, `s_expanded` INTEGER NOT NULL, `s_collapsible` INTEGER NOT NULL, `s_collapsed` INTEGER NOT NULL, `s_poll` TEXT, PRIMARY KEY(`id`, `accountId`))", "fields": [ { "fieldPath": "accountId", "columnName": "accountId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "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.emojis", "columnName": "s_emojis", "affinity": "TEXT", "notNull": true }, { "fieldPath": "lastStatus.favouritesCount", "columnName": "s_favouritesCount", "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.showingHiddenContent", "columnName": "s_showingHiddenContent", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastStatus.expanded", "columnName": "s_expanded", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastStatus.collapsible", "columnName": "s_collapsible", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastStatus.collapsed", "columnName": "s_collapsed", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastStatus.poll", "columnName": "s_poll", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "id", "accountId" ], "autoGenerate": false }, "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, 'eaa3c4d012fe743948343983fe1ae493')" ] } } ================================================ FILE: app/schemas/com.keylesspalace.tusky.db.AppDatabase/23.json ================================================ { "formatVersion": 1, "database": { "version": 23, "identityHash": "03a7436643ef356198742c5f8e054f5f", "entities": [ { "tableName": "TootEntity", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `text` TEXT, `urls` TEXT, `descriptions` TEXT, `contentWarning` TEXT, `inReplyToId` TEXT, `inReplyToText` TEXT, `inReplyToUsername` TEXT, `visibility` INTEGER, `poll` TEXT)", "fields": [ { "fieldPath": "uid", "columnName": "uid", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "text", "columnName": "text", "affinity": "TEXT", "notNull": false }, { "fieldPath": "urls", "columnName": "urls", "affinity": "TEXT", "notNull": false }, { "fieldPath": "descriptions", "columnName": "descriptions", "affinity": "TEXT", "notNull": false }, { "fieldPath": "contentWarning", "columnName": "contentWarning", "affinity": "TEXT", "notNull": false }, { "fieldPath": "inReplyToId", "columnName": "inReplyToId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "inReplyToText", "columnName": "inReplyToText", "affinity": "TEXT", "notNull": false }, { "fieldPath": "inReplyToUsername", "columnName": "inReplyToUsername", "affinity": "TEXT", "notNull": false }, { "fieldPath": "visibility", "columnName": "visibility", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "poll", "columnName": "poll", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "uid" ], "autoGenerate": true }, "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, `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, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `alwaysOpenSpoiler` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `lastNotificationId` TEXT NOT NULL, `activeNotifications` TEXT NOT NULL, `emojis` TEXT NOT NULL, `tabPreferences` TEXT NOT NULL, `notificationsFilter` TEXT 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": "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": "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": "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": "activeNotifications", "columnName": "activeNotifications", "affinity": "TEXT", "notNull": true }, { "fieldPath": "emojis", "columnName": "emojis", "affinity": "TEXT", "notNull": true }, { "fieldPath": "tabPreferences", "columnName": "tabPreferences", "affinity": "TEXT", "notNull": true }, { "fieldPath": "notificationsFilter", "columnName": "notificationsFilter", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "indices": [ { "name": "index_AccountEntity_domain_accountId", "unique": true, "columnNames": [ "domain", "accountId" ], "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, `version` TEXT, 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": "version", "columnName": "version", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "instance" ], "autoGenerate": false }, "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, `emojis` TEXT, `reblogsCount` INTEGER NOT NULL, `favouritesCount` INTEGER NOT NULL, `reblogged` INTEGER NOT NULL, `bookmarked` INTEGER NOT NULL, `favourited` INTEGER NOT NULL, `sensitive` INTEGER NOT NULL, `spoilerText` TEXT, `visibility` INTEGER, `attachments` TEXT, `mentions` TEXT, `application` TEXT, `reblogServerId` TEXT, `reblogAccountId` TEXT, `poll` TEXT, `muted` INTEGER, 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": "emojis", "columnName": "emojis", "affinity": "TEXT", "notNull": false }, { "fieldPath": "reblogsCount", "columnName": "reblogsCount", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "favouritesCount", "columnName": "favouritesCount", "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": false }, { "fieldPath": "visibility", "columnName": "visibility", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "attachments", "columnName": "attachments", "affinity": "TEXT", "notNull": false }, { "fieldPath": "mentions", "columnName": "mentions", "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 } ], "primaryKey": { "columnNames": [ "serverId", "timelineUserId" ], "autoGenerate": false }, "indices": [ { "name": "index_TimelineStatusEntity_authorServerId_timelineUserId", "unique": false, "columnNames": [ "authorServerId", "timelineUserId" ], "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": { "columnNames": [ "serverId", "timelineUserId" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "ConversationEntity", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `id` TEXT 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_emojis` TEXT NOT NULL, `s_favouritesCount` 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_showingHiddenContent` INTEGER NOT NULL, `s_expanded` INTEGER NOT NULL, `s_collapsible` INTEGER NOT NULL, `s_collapsed` INTEGER NOT NULL, `s_poll` TEXT, PRIMARY KEY(`id`, `accountId`))", "fields": [ { "fieldPath": "accountId", "columnName": "accountId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "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.emojis", "columnName": "s_emojis", "affinity": "TEXT", "notNull": true }, { "fieldPath": "lastStatus.favouritesCount", "columnName": "s_favouritesCount", "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.showingHiddenContent", "columnName": "s_showingHiddenContent", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastStatus.expanded", "columnName": "s_expanded", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastStatus.collapsible", "columnName": "s_collapsible", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastStatus.collapsed", "columnName": "s_collapsed", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastStatus.poll", "columnName": "s_poll", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "id", "accountId" ], "autoGenerate": false }, "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, '03a7436643ef356198742c5f8e054f5f')" ] } } ================================================ FILE: app/schemas/com.keylesspalace.tusky.db.AppDatabase/24.json ================================================ { "formatVersion": 1, "database": { "version": 24, "identityHash": "ea8559bbdf434c7b9086384a9a4cc8e6", "entities": [ { "tableName": "TootEntity", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `text` TEXT, `urls` TEXT, `descriptions` TEXT, `contentWarning` TEXT, `inReplyToId` TEXT, `inReplyToText` TEXT, `inReplyToUsername` TEXT, `visibility` INTEGER, `poll` TEXT)", "fields": [ { "fieldPath": "uid", "columnName": "uid", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "text", "columnName": "text", "affinity": "TEXT", "notNull": false }, { "fieldPath": "urls", "columnName": "urls", "affinity": "TEXT", "notNull": false }, { "fieldPath": "descriptions", "columnName": "descriptions", "affinity": "TEXT", "notNull": false }, { "fieldPath": "contentWarning", "columnName": "contentWarning", "affinity": "TEXT", "notNull": false }, { "fieldPath": "inReplyToId", "columnName": "inReplyToId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "inReplyToText", "columnName": "inReplyToText", "affinity": "TEXT", "notNull": false }, { "fieldPath": "inReplyToUsername", "columnName": "inReplyToUsername", "affinity": "TEXT", "notNull": false }, { "fieldPath": "visibility", "columnName": "visibility", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "poll", "columnName": "poll", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "uid" ], "autoGenerate": true }, "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, `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, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `alwaysOpenSpoiler` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `lastNotificationId` TEXT NOT NULL, `activeNotifications` TEXT NOT NULL, `emojis` TEXT NOT NULL, `tabPreferences` TEXT NOT NULL, `notificationsFilter` TEXT 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": "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": "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": "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": "activeNotifications", "columnName": "activeNotifications", "affinity": "TEXT", "notNull": true }, { "fieldPath": "emojis", "columnName": "emojis", "affinity": "TEXT", "notNull": true }, { "fieldPath": "tabPreferences", "columnName": "tabPreferences", "affinity": "TEXT", "notNull": true }, { "fieldPath": "notificationsFilter", "columnName": "notificationsFilter", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "indices": [ { "name": "index_AccountEntity_domain_accountId", "unique": true, "columnNames": [ "domain", "accountId" ], "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, `version` TEXT, 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": "version", "columnName": "version", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "instance" ], "autoGenerate": false }, "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, `emojis` TEXT, `reblogsCount` INTEGER NOT NULL, `favouritesCount` INTEGER NOT NULL, `reblogged` INTEGER NOT NULL, `bookmarked` INTEGER NOT NULL, `favourited` INTEGER NOT NULL, `sensitive` INTEGER NOT NULL, `spoilerText` TEXT, `visibility` INTEGER, `attachments` TEXT, `mentions` TEXT, `application` TEXT, `reblogServerId` TEXT, `reblogAccountId` TEXT, `poll` TEXT, `muted` INTEGER, 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": "emojis", "columnName": "emojis", "affinity": "TEXT", "notNull": false }, { "fieldPath": "reblogsCount", "columnName": "reblogsCount", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "favouritesCount", "columnName": "favouritesCount", "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": false }, { "fieldPath": "visibility", "columnName": "visibility", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "attachments", "columnName": "attachments", "affinity": "TEXT", "notNull": false }, { "fieldPath": "mentions", "columnName": "mentions", "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 } ], "primaryKey": { "columnNames": [ "serverId", "timelineUserId" ], "autoGenerate": false }, "indices": [ { "name": "index_TimelineStatusEntity_authorServerId_timelineUserId", "unique": false, "columnNames": [ "authorServerId", "timelineUserId" ], "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": { "columnNames": [ "serverId", "timelineUserId" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "ConversationEntity", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `id` TEXT 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_emojis` TEXT NOT NULL, `s_favouritesCount` 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_showingHiddenContent` INTEGER NOT NULL, `s_expanded` INTEGER NOT NULL, `s_collapsible` INTEGER NOT NULL, `s_collapsed` INTEGER NOT NULL, `s_poll` TEXT, PRIMARY KEY(`id`, `accountId`))", "fields": [ { "fieldPath": "accountId", "columnName": "accountId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "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.emojis", "columnName": "s_emojis", "affinity": "TEXT", "notNull": true }, { "fieldPath": "lastStatus.favouritesCount", "columnName": "s_favouritesCount", "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.showingHiddenContent", "columnName": "s_showingHiddenContent", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastStatus.expanded", "columnName": "s_expanded", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastStatus.collapsible", "columnName": "s_collapsible", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastStatus.collapsed", "columnName": "s_collapsed", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastStatus.poll", "columnName": "s_poll", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "id", "accountId" ], "autoGenerate": false }, "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, 'ea8559bbdf434c7b9086384a9a4cc8e6')" ] } } ================================================ FILE: app/schemas/com.keylesspalace.tusky.db.AppDatabase/25.json ================================================ { "formatVersion": 1, "database": { "version": 25, "identityHash": "e2cb844862443c2c5cc884c11f120d43", "entities": [ { "tableName": "TootEntity", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `text` TEXT, `urls` TEXT, `descriptions` TEXT, `contentWarning` TEXT, `inReplyToId` TEXT, `inReplyToText` TEXT, `inReplyToUsername` TEXT, `visibility` INTEGER, `poll` TEXT)", "fields": [ { "fieldPath": "uid", "columnName": "uid", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "text", "columnName": "text", "affinity": "TEXT", "notNull": false }, { "fieldPath": "urls", "columnName": "urls", "affinity": "TEXT", "notNull": false }, { "fieldPath": "descriptions", "columnName": "descriptions", "affinity": "TEXT", "notNull": false }, { "fieldPath": "contentWarning", "columnName": "contentWarning", "affinity": "TEXT", "notNull": false }, { "fieldPath": "inReplyToId", "columnName": "inReplyToId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "inReplyToText", "columnName": "inReplyToText", "affinity": "TEXT", "notNull": false }, { "fieldPath": "inReplyToUsername", "columnName": "inReplyToUsername", "affinity": "TEXT", "notNull": false }, { "fieldPath": "visibility", "columnName": "visibility", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "poll", "columnName": "poll", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "uid" ], "autoGenerate": true }, "indices": [], "foreignKeys": [] }, { "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)", "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 } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "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, `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, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `alwaysOpenSpoiler` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `lastNotificationId` TEXT NOT NULL, `activeNotifications` TEXT NOT NULL, `emojis` TEXT NOT NULL, `tabPreferences` TEXT NOT NULL, `notificationsFilter` TEXT 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": "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": "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": "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": "activeNotifications", "columnName": "activeNotifications", "affinity": "TEXT", "notNull": true }, { "fieldPath": "emojis", "columnName": "emojis", "affinity": "TEXT", "notNull": true }, { "fieldPath": "tabPreferences", "columnName": "tabPreferences", "affinity": "TEXT", "notNull": true }, { "fieldPath": "notificationsFilter", "columnName": "notificationsFilter", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "indices": [ { "name": "index_AccountEntity_domain_accountId", "unique": true, "columnNames": [ "domain", "accountId" ], "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, `version` TEXT, 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": "version", "columnName": "version", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "instance" ], "autoGenerate": false }, "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, `emojis` TEXT, `reblogsCount` INTEGER NOT NULL, `favouritesCount` INTEGER NOT NULL, `reblogged` INTEGER NOT NULL, `bookmarked` INTEGER NOT NULL, `favourited` INTEGER NOT NULL, `sensitive` INTEGER NOT NULL, `spoilerText` TEXT, `visibility` INTEGER, `attachments` TEXT, `mentions` TEXT, `application` TEXT, `reblogServerId` TEXT, `reblogAccountId` TEXT, `poll` TEXT, `muted` INTEGER, 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": "emojis", "columnName": "emojis", "affinity": "TEXT", "notNull": false }, { "fieldPath": "reblogsCount", "columnName": "reblogsCount", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "favouritesCount", "columnName": "favouritesCount", "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": false }, { "fieldPath": "visibility", "columnName": "visibility", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "attachments", "columnName": "attachments", "affinity": "TEXT", "notNull": false }, { "fieldPath": "mentions", "columnName": "mentions", "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 } ], "primaryKey": { "columnNames": [ "serverId", "timelineUserId" ], "autoGenerate": false }, "indices": [ { "name": "index_TimelineStatusEntity_authorServerId_timelineUserId", "unique": false, "columnNames": [ "authorServerId", "timelineUserId" ], "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": { "columnNames": [ "serverId", "timelineUserId" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "ConversationEntity", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `id` TEXT 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_emojis` TEXT NOT NULL, `s_favouritesCount` 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_showingHiddenContent` INTEGER NOT NULL, `s_expanded` INTEGER NOT NULL, `s_collapsible` INTEGER NOT NULL, `s_collapsed` INTEGER NOT NULL, `s_poll` TEXT, PRIMARY KEY(`id`, `accountId`))", "fields": [ { "fieldPath": "accountId", "columnName": "accountId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "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.emojis", "columnName": "s_emojis", "affinity": "TEXT", "notNull": true }, { "fieldPath": "lastStatus.favouritesCount", "columnName": "s_favouritesCount", "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.showingHiddenContent", "columnName": "s_showingHiddenContent", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastStatus.expanded", "columnName": "s_expanded", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastStatus.collapsible", "columnName": "s_collapsible", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastStatus.collapsed", "columnName": "s_collapsed", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastStatus.poll", "columnName": "s_poll", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "id", "accountId" ], "autoGenerate": false }, "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, 'e2cb844862443c2c5cc884c11f120d43')" ] } } ================================================ FILE: app/schemas/com.keylesspalace.tusky.db.AppDatabase/26.json ================================================ { "formatVersion": 1, "database": { "version": 26, "identityHash": "14fb3d5743b7a89e8e62463e05f086ab", "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)", "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 } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "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, `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, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `alwaysOpenSpoiler` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `lastNotificationId` TEXT NOT NULL, `activeNotifications` TEXT NOT NULL, `emojis` TEXT NOT NULL, `tabPreferences` TEXT NOT NULL, `notificationsFilter` TEXT 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": "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": "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": "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": "activeNotifications", "columnName": "activeNotifications", "affinity": "TEXT", "notNull": true }, { "fieldPath": "emojis", "columnName": "emojis", "affinity": "TEXT", "notNull": true }, { "fieldPath": "tabPreferences", "columnName": "tabPreferences", "affinity": "TEXT", "notNull": true }, { "fieldPath": "notificationsFilter", "columnName": "notificationsFilter", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "indices": [ { "name": "index_AccountEntity_domain_accountId", "unique": true, "columnNames": [ "domain", "accountId" ], "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, `version` TEXT, 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": "version", "columnName": "version", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "instance" ], "autoGenerate": false }, "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, `emojis` TEXT, `reblogsCount` INTEGER NOT NULL, `favouritesCount` INTEGER NOT NULL, `reblogged` INTEGER NOT NULL, `bookmarked` INTEGER NOT NULL, `favourited` INTEGER NOT NULL, `sensitive` INTEGER NOT NULL, `spoilerText` TEXT, `visibility` INTEGER, `attachments` TEXT, `mentions` TEXT, `application` TEXT, `reblogServerId` TEXT, `reblogAccountId` TEXT, `poll` TEXT, `muted` INTEGER, 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": "emojis", "columnName": "emojis", "affinity": "TEXT", "notNull": false }, { "fieldPath": "reblogsCount", "columnName": "reblogsCount", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "favouritesCount", "columnName": "favouritesCount", "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": false }, { "fieldPath": "visibility", "columnName": "visibility", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "attachments", "columnName": "attachments", "affinity": "TEXT", "notNull": false }, { "fieldPath": "mentions", "columnName": "mentions", "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 } ], "primaryKey": { "columnNames": [ "serverId", "timelineUserId" ], "autoGenerate": false }, "indices": [ { "name": "index_TimelineStatusEntity_authorServerId_timelineUserId", "unique": false, "columnNames": [ "authorServerId", "timelineUserId" ], "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": { "columnNames": [ "serverId", "timelineUserId" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "ConversationEntity", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `id` TEXT 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_emojis` TEXT NOT NULL, `s_favouritesCount` 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_showingHiddenContent` INTEGER NOT NULL, `s_expanded` INTEGER NOT NULL, `s_collapsible` INTEGER NOT NULL, `s_collapsed` INTEGER NOT NULL, `s_poll` TEXT, PRIMARY KEY(`id`, `accountId`))", "fields": [ { "fieldPath": "accountId", "columnName": "accountId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "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.emojis", "columnName": "s_emojis", "affinity": "TEXT", "notNull": true }, { "fieldPath": "lastStatus.favouritesCount", "columnName": "s_favouritesCount", "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.showingHiddenContent", "columnName": "s_showingHiddenContent", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastStatus.expanded", "columnName": "s_expanded", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastStatus.collapsible", "columnName": "s_collapsible", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastStatus.collapsed", "columnName": "s_collapsed", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastStatus.poll", "columnName": "s_poll", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "id", "accountId" ], "autoGenerate": false }, "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, '14fb3d5743b7a89e8e62463e05f086ab')" ] } } ================================================ FILE: app/schemas/com.keylesspalace.tusky.db.AppDatabase/27.json ================================================ { "formatVersion": 1, "database": { "version": 27, "identityHash": "be914d4eb3f406b6970fef53a925afa1", "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)", "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 } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "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, `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, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `alwaysOpenSpoiler` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `lastNotificationId` TEXT NOT NULL, `activeNotifications` TEXT NOT NULL, `emojis` TEXT NOT NULL, `tabPreferences` TEXT NOT NULL, `notificationsFilter` TEXT 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": "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": "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": "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": "activeNotifications", "columnName": "activeNotifications", "affinity": "TEXT", "notNull": true }, { "fieldPath": "emojis", "columnName": "emojis", "affinity": "TEXT", "notNull": true }, { "fieldPath": "tabPreferences", "columnName": "tabPreferences", "affinity": "TEXT", "notNull": true }, { "fieldPath": "notificationsFilter", "columnName": "notificationsFilter", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "indices": [ { "name": "index_AccountEntity_domain_accountId", "unique": true, "columnNames": [ "domain", "accountId" ], "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, `version` TEXT, 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": "version", "columnName": "version", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "instance" ], "autoGenerate": false }, "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, `emojis` TEXT, `reblogsCount` INTEGER NOT NULL, `favouritesCount` INTEGER NOT NULL, `reblogged` INTEGER NOT NULL, `bookmarked` INTEGER NOT NULL, `favourited` INTEGER NOT NULL, `sensitive` INTEGER NOT NULL, `spoilerText` TEXT, `visibility` INTEGER, `attachments` TEXT, `mentions` TEXT, `application` TEXT, `reblogServerId` TEXT, `reblogAccountId` TEXT, `poll` TEXT, `muted` INTEGER, 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": "emojis", "columnName": "emojis", "affinity": "TEXT", "notNull": false }, { "fieldPath": "reblogsCount", "columnName": "reblogsCount", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "favouritesCount", "columnName": "favouritesCount", "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": false }, { "fieldPath": "visibility", "columnName": "visibility", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "attachments", "columnName": "attachments", "affinity": "TEXT", "notNull": false }, { "fieldPath": "mentions", "columnName": "mentions", "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 } ], "primaryKey": { "columnNames": [ "serverId", "timelineUserId" ], "autoGenerate": false }, "indices": [ { "name": "index_TimelineStatusEntity_authorServerId_timelineUserId", "unique": false, "columnNames": [ "authorServerId", "timelineUserId" ], "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": { "columnNames": [ "serverId", "timelineUserId" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "ConversationEntity", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `id` TEXT 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_emojis` TEXT NOT NULL, `s_favouritesCount` 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_showingHiddenContent` INTEGER NOT NULL, `s_expanded` INTEGER NOT NULL, `s_collapsible` INTEGER NOT NULL, `s_collapsed` INTEGER NOT NULL, `s_muted` INTEGER NOT NULL, `s_poll` TEXT, PRIMARY KEY(`id`, `accountId`))", "fields": [ { "fieldPath": "accountId", "columnName": "accountId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "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.emojis", "columnName": "s_emojis", "affinity": "TEXT", "notNull": true }, { "fieldPath": "lastStatus.favouritesCount", "columnName": "s_favouritesCount", "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.showingHiddenContent", "columnName": "s_showingHiddenContent", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastStatus.expanded", "columnName": "s_expanded", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastStatus.collapsible", "columnName": "s_collapsible", "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 } ], "primaryKey": { "columnNames": [ "id", "accountId" ], "autoGenerate": false }, "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, 'be914d4eb3f406b6970fef53a925afa1')" ] } } ================================================ FILE: app/schemas/com.keylesspalace.tusky.db.AppDatabase/28.json ================================================ { "formatVersion": 1, "database": { "version": 28, "identityHash": "867026e095d84652026e902709389c00", "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)", "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 } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "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, `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, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `alwaysOpenSpoiler` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `lastNotificationId` TEXT NOT NULL, `activeNotifications` TEXT NOT NULL, `emojis` TEXT NOT NULL, `tabPreferences` TEXT NOT NULL, `notificationsFilter` TEXT 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": "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": "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": "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": "activeNotifications", "columnName": "activeNotifications", "affinity": "TEXT", "notNull": true }, { "fieldPath": "emojis", "columnName": "emojis", "affinity": "TEXT", "notNull": true }, { "fieldPath": "tabPreferences", "columnName": "tabPreferences", "affinity": "TEXT", "notNull": true }, { "fieldPath": "notificationsFilter", "columnName": "notificationsFilter", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "indices": [ { "name": "index_AccountEntity_domain_accountId", "unique": true, "columnNames": [ "domain", "accountId" ], "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, `version` TEXT, 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": "version", "columnName": "version", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "instance" ], "autoGenerate": false }, "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, `emojis` TEXT, `reblogsCount` INTEGER NOT NULL, `favouritesCount` 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, `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, 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": "emojis", "columnName": "emojis", "affinity": "TEXT", "notNull": false }, { "fieldPath": "reblogsCount", "columnName": "reblogsCount", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "favouritesCount", "columnName": "favouritesCount", "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": "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 } ], "primaryKey": { "columnNames": [ "serverId", "timelineUserId" ], "autoGenerate": false }, "indices": [ { "name": "index_TimelineStatusEntity_authorServerId_timelineUserId", "unique": false, "columnNames": [ "authorServerId", "timelineUserId" ], "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": { "columnNames": [ "serverId", "timelineUserId" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "ConversationEntity", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `id` TEXT 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_emojis` TEXT NOT NULL, `s_favouritesCount` 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_showingHiddenContent` INTEGER NOT NULL, `s_expanded` INTEGER NOT NULL, `s_collapsible` INTEGER NOT NULL, `s_collapsed` INTEGER NOT NULL, `s_muted` INTEGER NOT NULL, `s_poll` TEXT, PRIMARY KEY(`id`, `accountId`))", "fields": [ { "fieldPath": "accountId", "columnName": "accountId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "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.emojis", "columnName": "s_emojis", "affinity": "TEXT", "notNull": true }, { "fieldPath": "lastStatus.favouritesCount", "columnName": "s_favouritesCount", "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.showingHiddenContent", "columnName": "s_showingHiddenContent", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastStatus.expanded", "columnName": "s_expanded", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastStatus.collapsible", "columnName": "s_collapsible", "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 } ], "primaryKey": { "columnNames": [ "id", "accountId" ], "autoGenerate": false }, "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, '867026e095d84652026e902709389c00')" ] } } ================================================ FILE: app/schemas/com.keylesspalace.tusky.db.AppDatabase/29.json ================================================ { "formatVersion": 1, "database": { "version": 29, "identityHash": "62c289344334da2db091ad4ba0a49c6a", "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)", "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 } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "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, `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, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `alwaysOpenSpoiler` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `lastNotificationId` TEXT NOT NULL, `activeNotifications` TEXT NOT NULL, `emojis` TEXT NOT NULL, `tabPreferences` TEXT NOT NULL, `notificationsFilter` TEXT 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": "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": "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": "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": "activeNotifications", "columnName": "activeNotifications", "affinity": "TEXT", "notNull": true }, { "fieldPath": "emojis", "columnName": "emojis", "affinity": "TEXT", "notNull": true }, { "fieldPath": "tabPreferences", "columnName": "tabPreferences", "affinity": "TEXT", "notNull": true }, { "fieldPath": "notificationsFilter", "columnName": "notificationsFilter", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "indices": [ { "name": "index_AccountEntity_domain_accountId", "unique": true, "columnNames": [ "domain", "accountId" ], "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, `version` TEXT, 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": "version", "columnName": "version", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "instance" ], "autoGenerate": false }, "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, `emojis` TEXT, `reblogsCount` INTEGER NOT NULL, `favouritesCount` 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, 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": "emojis", "columnName": "emojis", "affinity": "TEXT", "notNull": false }, { "fieldPath": "reblogsCount", "columnName": "reblogsCount", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "favouritesCount", "columnName": "favouritesCount", "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 } ], "primaryKey": { "columnNames": [ "serverId", "timelineUserId" ], "autoGenerate": false }, "indices": [ { "name": "index_TimelineStatusEntity_authorServerId_timelineUserId", "unique": false, "columnNames": [ "authorServerId", "timelineUserId" ], "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": { "columnNames": [ "serverId", "timelineUserId" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "ConversationEntity", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `id` TEXT 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_emojis` TEXT NOT NULL, `s_favouritesCount` 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_collapsible` INTEGER NOT NULL, `s_collapsed` INTEGER NOT NULL, `s_muted` INTEGER NOT NULL, `s_poll` TEXT, PRIMARY KEY(`id`, `accountId`))", "fields": [ { "fieldPath": "accountId", "columnName": "accountId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "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.emojis", "columnName": "s_emojis", "affinity": "TEXT", "notNull": true }, { "fieldPath": "lastStatus.favouritesCount", "columnName": "s_favouritesCount", "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.collapsible", "columnName": "s_collapsible", "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 } ], "primaryKey": { "columnNames": [ "id", "accountId" ], "autoGenerate": false }, "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, '62c289344334da2db091ad4ba0a49c6a')" ] } } ================================================ FILE: app/schemas/com.keylesspalace.tusky.db.AppDatabase/30.json ================================================ { "formatVersion": 1, "database": { "version": 30, "identityHash": "a75615171612bdfc9e3d4201ebf6071a", "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)", "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 } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "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, `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, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `alwaysOpenSpoiler` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `lastNotificationId` TEXT NOT NULL, `activeNotifications` TEXT NOT NULL, `emojis` TEXT NOT NULL, `tabPreferences` TEXT NOT NULL, `notificationsFilter` TEXT 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": "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": "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": "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": "activeNotifications", "columnName": "activeNotifications", "affinity": "TEXT", "notNull": true }, { "fieldPath": "emojis", "columnName": "emojis", "affinity": "TEXT", "notNull": true }, { "fieldPath": "tabPreferences", "columnName": "tabPreferences", "affinity": "TEXT", "notNull": true }, { "fieldPath": "notificationsFilter", "columnName": "notificationsFilter", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "indices": [ { "name": "index_AccountEntity_domain_accountId", "unique": true, "columnNames": [ "domain", "accountId" ], "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, 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 } ], "primaryKey": { "columnNames": [ "instance" ], "autoGenerate": false }, "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, `emojis` TEXT, `reblogsCount` INTEGER NOT NULL, `favouritesCount` 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, 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": "emojis", "columnName": "emojis", "affinity": "TEXT", "notNull": false }, { "fieldPath": "reblogsCount", "columnName": "reblogsCount", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "favouritesCount", "columnName": "favouritesCount", "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 } ], "primaryKey": { "columnNames": [ "serverId", "timelineUserId" ], "autoGenerate": false }, "indices": [ { "name": "index_TimelineStatusEntity_authorServerId_timelineUserId", "unique": false, "columnNames": [ "authorServerId", "timelineUserId" ], "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": { "columnNames": [ "serverId", "timelineUserId" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "ConversationEntity", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `id` TEXT 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_emojis` TEXT NOT NULL, `s_favouritesCount` 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_collapsible` INTEGER NOT NULL, `s_collapsed` INTEGER NOT NULL, `s_muted` INTEGER NOT NULL, `s_poll` TEXT, PRIMARY KEY(`id`, `accountId`))", "fields": [ { "fieldPath": "accountId", "columnName": "accountId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "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.emojis", "columnName": "s_emojis", "affinity": "TEXT", "notNull": true }, { "fieldPath": "lastStatus.favouritesCount", "columnName": "s_favouritesCount", "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.collapsible", "columnName": "s_collapsible", "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 } ], "primaryKey": { "columnNames": [ "id", "accountId" ], "autoGenerate": false }, "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, 'a75615171612bdfc9e3d4201ebf6071a')" ] } } ================================================ FILE: app/schemas/com.keylesspalace.tusky.db.AppDatabase/31.json ================================================ { "formatVersion": 1, "database": { "version": 31, "identityHash": "a75615171612bdfc9e3d4201ebf6071a", "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)", "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 } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "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, `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, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `alwaysOpenSpoiler` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `lastNotificationId` TEXT NOT NULL, `activeNotifications` TEXT NOT NULL, `emojis` TEXT NOT NULL, `tabPreferences` TEXT NOT NULL, `notificationsFilter` TEXT 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": "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": "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": "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": "activeNotifications", "columnName": "activeNotifications", "affinity": "TEXT", "notNull": true }, { "fieldPath": "emojis", "columnName": "emojis", "affinity": "TEXT", "notNull": true }, { "fieldPath": "tabPreferences", "columnName": "tabPreferences", "affinity": "TEXT", "notNull": true }, { "fieldPath": "notificationsFilter", "columnName": "notificationsFilter", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "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, 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 } ], "primaryKey": { "columnNames": [ "instance" ], "autoGenerate": false }, "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, `emojis` TEXT, `reblogsCount` INTEGER NOT NULL, `favouritesCount` 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, 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": "emojis", "columnName": "emojis", "affinity": "TEXT", "notNull": false }, { "fieldPath": "reblogsCount", "columnName": "reblogsCount", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "favouritesCount", "columnName": "favouritesCount", "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 } ], "primaryKey": { "columnNames": [ "serverId", "timelineUserId" ], "autoGenerate": false }, "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": { "columnNames": [ "serverId", "timelineUserId" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "ConversationEntity", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `id` TEXT 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_emojis` TEXT NOT NULL, `s_favouritesCount` 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_collapsible` INTEGER NOT NULL, `s_collapsed` INTEGER NOT NULL, `s_muted` INTEGER NOT NULL, `s_poll` TEXT, PRIMARY KEY(`id`, `accountId`))", "fields": [ { "fieldPath": "accountId", "columnName": "accountId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "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.emojis", "columnName": "s_emojis", "affinity": "TEXT", "notNull": true }, { "fieldPath": "lastStatus.favouritesCount", "columnName": "s_favouritesCount", "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.collapsible", "columnName": "s_collapsible", "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 } ], "primaryKey": { "columnNames": [ "id", "accountId" ], "autoGenerate": false }, "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, 'a75615171612bdfc9e3d4201ebf6071a')" ] } } ================================================ FILE: app/schemas/com.keylesspalace.tusky.db.AppDatabase/32.json ================================================ { "formatVersion": 1, "database": { "version": 32, "identityHash": "c92343960c9d46d9cfd49f1873cce47d", "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)", "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 } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "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, `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, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `alwaysOpenSpoiler` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `lastNotificationId` TEXT NOT NULL, `activeNotifications` TEXT NOT NULL, `emojis` TEXT NOT NULL, `tabPreferences` TEXT NOT NULL, `notificationsFilter` TEXT 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": "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": "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": "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": "activeNotifications", "columnName": "activeNotifications", "affinity": "TEXT", "notNull": true }, { "fieldPath": "emojis", "columnName": "emojis", "affinity": "TEXT", "notNull": true }, { "fieldPath": "tabPreferences", "columnName": "tabPreferences", "affinity": "TEXT", "notNull": true }, { "fieldPath": "notificationsFilter", "columnName": "notificationsFilter", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "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, 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 } ], "primaryKey": { "columnNames": [ "instance" ], "autoGenerate": false }, "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, `emojis` TEXT, `reblogsCount` INTEGER NOT NULL, `favouritesCount` 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, 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": "emojis", "columnName": "emojis", "affinity": "TEXT", "notNull": false }, { "fieldPath": "reblogsCount", "columnName": "reblogsCount", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "favouritesCount", "columnName": "favouritesCount", "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 } ], "primaryKey": { "columnNames": [ "serverId", "timelineUserId" ], "autoGenerate": false }, "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": { "columnNames": [ "serverId", "timelineUserId" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "ConversationEntity", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `id` TEXT 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_emojis` TEXT NOT NULL, `s_favouritesCount` 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_collapsible` INTEGER NOT NULL, `s_collapsed` INTEGER NOT NULL, `s_muted` INTEGER NOT NULL, `s_poll` TEXT, PRIMARY KEY(`id`, `accountId`))", "fields": [ { "fieldPath": "accountId", "columnName": "accountId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "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.emojis", "columnName": "s_emojis", "affinity": "TEXT", "notNull": true }, { "fieldPath": "lastStatus.favouritesCount", "columnName": "s_favouritesCount", "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.collapsible", "columnName": "s_collapsible", "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 } ], "primaryKey": { "columnNames": [ "id", "accountId" ], "autoGenerate": false }, "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, 'c92343960c9d46d9cfd49f1873cce47d')" ] } } ================================================ FILE: app/schemas/com.keylesspalace.tusky.db.AppDatabase/33.json ================================================ { "formatVersion": 1, "database": { "version": 33, "identityHash": "920a0e0c9a600bd236f6bf959b469c18", "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)", "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 } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "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, `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, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `alwaysOpenSpoiler` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `lastNotificationId` TEXT NOT NULL, `activeNotifications` TEXT NOT NULL, `emojis` TEXT NOT NULL, `tabPreferences` TEXT NOT NULL, `notificationsFilter` TEXT 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": "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": "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": "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": "activeNotifications", "columnName": "activeNotifications", "affinity": "TEXT", "notNull": true }, { "fieldPath": "emojis", "columnName": "emojis", "affinity": "TEXT", "notNull": true }, { "fieldPath": "tabPreferences", "columnName": "tabPreferences", "affinity": "TEXT", "notNull": true }, { "fieldPath": "notificationsFilter", "columnName": "notificationsFilter", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "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, 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 } ], "primaryKey": { "columnNames": [ "instance" ], "autoGenerate": false }, "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, `emojis` TEXT, `reblogsCount` INTEGER NOT NULL, `favouritesCount` 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, 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": "emojis", "columnName": "emojis", "affinity": "TEXT", "notNull": false }, { "fieldPath": "reblogsCount", "columnName": "reblogsCount", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "favouritesCount", "columnName": "favouritesCount", "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 } ], "primaryKey": { "columnNames": [ "serverId", "timelineUserId" ], "autoGenerate": false }, "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": { "columnNames": [ "serverId", "timelineUserId" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "ConversationEntity", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `id` TEXT 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_emojis` TEXT NOT NULL, `s_favouritesCount` 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, PRIMARY KEY(`id`, `accountId`))", "fields": [ { "fieldPath": "accountId", "columnName": "accountId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "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.emojis", "columnName": "s_emojis", "affinity": "TEXT", "notNull": true }, { "fieldPath": "lastStatus.favouritesCount", "columnName": "s_favouritesCount", "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 } ], "primaryKey": { "columnNames": [ "id", "accountId" ], "autoGenerate": false }, "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, '920a0e0c9a600bd236f6bf959b469c18')" ] } } ================================================ FILE: app/schemas/com.keylesspalace.tusky.db.AppDatabase/34.json ================================================ { "formatVersion": 1, "database": { "version": 34, "identityHash": "7f766d68ab5d72a7988cd81c183e9a9d", "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)", "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 } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "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, `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, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `alwaysOpenSpoiler` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `lastNotificationId` TEXT NOT NULL, `activeNotifications` TEXT NOT NULL, `emojis` TEXT NOT NULL, `tabPreferences` TEXT NOT NULL, `notificationsFilter` TEXT 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": "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": "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": "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": "activeNotifications", "columnName": "activeNotifications", "affinity": "TEXT", "notNull": true }, { "fieldPath": "emojis", "columnName": "emojis", "affinity": "TEXT", "notNull": true }, { "fieldPath": "tabPreferences", "columnName": "tabPreferences", "affinity": "TEXT", "notNull": true }, { "fieldPath": "notificationsFilter", "columnName": "notificationsFilter", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "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, 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 } ], "primaryKey": { "columnNames": [ "instance" ], "autoGenerate": false }, "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, `emojis` TEXT, `reblogsCount` INTEGER NOT NULL, `favouritesCount` 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, 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": "emojis", "columnName": "emojis", "affinity": "TEXT", "notNull": false }, { "fieldPath": "reblogsCount", "columnName": "reblogsCount", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "favouritesCount", "columnName": "favouritesCount", "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 } ], "primaryKey": { "columnNames": [ "serverId", "timelineUserId" ], "autoGenerate": false }, "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": { "columnNames": [ "serverId", "timelineUserId" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "ConversationEntity", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `id` TEXT 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_emojis` TEXT NOT NULL, `s_favouritesCount` 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, PRIMARY KEY(`id`, `accountId`))", "fields": [ { "fieldPath": "accountId", "columnName": "accountId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "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.emojis", "columnName": "s_emojis", "affinity": "TEXT", "notNull": true }, { "fieldPath": "lastStatus.favouritesCount", "columnName": "s_favouritesCount", "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 } ], "primaryKey": { "columnNames": [ "id", "accountId" ], "autoGenerate": false }, "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, '7f766d68ab5d72a7988cd81c183e9a9d')" ] } } ================================================ FILE: app/schemas/com.keylesspalace.tusky.db.AppDatabase/35.json ================================================ { "formatVersion": 1, "database": { "version": 35, "identityHash": "9e6c0bb60538683a16c30fa3e1cc24f2", "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)", "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 } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "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, `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, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `alwaysOpenSpoiler` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `lastNotificationId` TEXT NOT NULL, `activeNotifications` TEXT NOT NULL, `emojis` TEXT NOT NULL, `tabPreferences` TEXT NOT NULL, `notificationsFilter` TEXT 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": "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": "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": "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": "activeNotifications", "columnName": "activeNotifications", "affinity": "TEXT", "notNull": true }, { "fieldPath": "emojis", "columnName": "emojis", "affinity": "TEXT", "notNull": true }, { "fieldPath": "tabPreferences", "columnName": "tabPreferences", "affinity": "TEXT", "notNull": true }, { "fieldPath": "notificationsFilter", "columnName": "notificationsFilter", "affinity": "TEXT", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "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, 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 } ], "primaryKey": { "columnNames": [ "instance" ], "autoGenerate": false }, "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, `emojis` TEXT, `reblogsCount` INTEGER NOT NULL, `favouritesCount` 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, 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": "emojis", "columnName": "emojis", "affinity": "TEXT", "notNull": false }, { "fieldPath": "reblogsCount", "columnName": "reblogsCount", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "favouritesCount", "columnName": "favouritesCount", "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 } ], "primaryKey": { "columnNames": [ "serverId", "timelineUserId" ], "autoGenerate": false }, "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": { "columnNames": [ "serverId", "timelineUserId" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "ConversationEntity", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `id` TEXT 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_emojis` TEXT NOT NULL, `s_favouritesCount` 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, PRIMARY KEY(`id`, `accountId`))", "fields": [ { "fieldPath": "accountId", "columnName": "accountId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "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.emojis", "columnName": "s_emojis", "affinity": "TEXT", "notNull": true }, { "fieldPath": "lastStatus.favouritesCount", "columnName": "s_favouritesCount", "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 } ], "primaryKey": { "columnNames": [ "id", "accountId" ], "autoGenerate": false }, "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, '9e6c0bb60538683a16c30fa3e1cc24f2')" ] } } ================================================ FILE: app/schemas/com.keylesspalace.tusky.db.AppDatabase/36.json ================================================ { "formatVersion": 1, "database": { "version": 36, "identityHash": "1b7461c291f67fe0b21f77b95de6a6be", "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)", "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 } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "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, `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, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `alwaysOpenSpoiler` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `lastNotificationId` TEXT NOT NULL, `activeNotifications` TEXT NOT NULL, `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)", "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": "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": "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": "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": "activeNotifications", "columnName": "activeNotifications", "affinity": "TEXT", "notNull": true }, { "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 } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "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, 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 } ], "primaryKey": { "columnNames": [ "instance" ], "autoGenerate": false }, "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, `emojis` TEXT, `reblogsCount` INTEGER NOT NULL, `favouritesCount` 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, 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": "emojis", "columnName": "emojis", "affinity": "TEXT", "notNull": false }, { "fieldPath": "reblogsCount", "columnName": "reblogsCount", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "favouritesCount", "columnName": "favouritesCount", "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 } ], "primaryKey": { "columnNames": [ "serverId", "timelineUserId" ], "autoGenerate": false }, "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": { "columnNames": [ "serverId", "timelineUserId" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "ConversationEntity", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `id` TEXT 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_emojis` TEXT NOT NULL, `s_favouritesCount` 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, PRIMARY KEY(`id`, `accountId`))", "fields": [ { "fieldPath": "accountId", "columnName": "accountId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "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.emojis", "columnName": "s_emojis", "affinity": "TEXT", "notNull": true }, { "fieldPath": "lastStatus.favouritesCount", "columnName": "s_favouritesCount", "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 } ], "primaryKey": { "columnNames": [ "id", "accountId" ], "autoGenerate": false }, "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, '1b7461c291f67fe0b21f77b95de6a6be')" ] } } ================================================ FILE: app/schemas/com.keylesspalace.tusky.db.AppDatabase/37.json ================================================ { "formatVersion": 1, "database": { "version": 37, "identityHash": "11033751d382aa8a1c6fc68833097d35", "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)", "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 } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "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, `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, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `alwaysOpenSpoiler` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `lastNotificationId` TEXT NOT NULL, `activeNotifications` TEXT NOT NULL, `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)", "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": "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": "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": "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": "activeNotifications", "columnName": "activeNotifications", "affinity": "TEXT", "notNull": true }, { "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 } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "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, 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 } ], "primaryKey": { "columnNames": [ "instance" ], "autoGenerate": false }, "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, `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, 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": "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 } ], "primaryKey": { "columnNames": [ "serverId", "timelineUserId" ], "autoGenerate": false }, "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": { "columnNames": [ "serverId", "timelineUserId" ], "autoGenerate": false }, "indices": [], "foreignKeys": [] }, { "tableName": "ConversationEntity", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `id` TEXT 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_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, PRIMARY KEY(`id`, `accountId`))", "fields": [ { "fieldPath": "accountId", "columnName": "accountId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "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.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 } ], "primaryKey": { "columnNames": [ "id", "accountId" ], "autoGenerate": false }, "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, '11033751d382aa8a1c6fc68833097d35')" ] } } ================================================ FILE: app/schemas/com.keylesspalace.tusky.db.AppDatabase/38.json ================================================ { "formatVersion": 1, "database": { "version": 38, "identityHash": "798fc8d34064eb671c079689d4650ea5", "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)", "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 } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "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, `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, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `alwaysOpenSpoiler` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `lastNotificationId` TEXT NOT NULL, `activeNotifications` TEXT NOT NULL, `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)", "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": "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": "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": "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": "activeNotifications", "columnName": "activeNotifications", "affinity": "TEXT", "notNull": true }, { "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 } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "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, 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 } ], "primaryKey": { "columnNames": [ "instance" ], "autoGenerate": false }, "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, `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, 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": "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 } ], "primaryKey": { "columnNames": [ "serverId", "timelineUserId" ], "autoGenerate": false }, "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": { "columnNames": [ "serverId", "timelineUserId" ], "autoGenerate": false }, "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_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, 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.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 } ], "primaryKey": { "columnNames": [ "id", "accountId" ], "autoGenerate": false }, "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, '798fc8d34064eb671c079689d4650ea5')" ] } } ================================================ FILE: app/schemas/com.keylesspalace.tusky.db.AppDatabase/39.json ================================================ { "formatVersion": 1, "database": { "version": 39, "identityHash": "ed3b752a3faec9d092d5ac0a2823d5d5", "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)", "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 } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "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, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `alwaysOpenSpoiler` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `lastNotificationId` TEXT NOT NULL, `activeNotifications` TEXT NOT NULL, `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)", "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": "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": "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": "activeNotifications", "columnName": "activeNotifications", "affinity": "TEXT", "notNull": true }, { "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 } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "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, 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 } ], "primaryKey": { "columnNames": [ "instance" ], "autoGenerate": false }, "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, `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, 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": "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 } ], "primaryKey": { "columnNames": [ "serverId", "timelineUserId" ], "autoGenerate": false }, "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": { "columnNames": [ "serverId", "timelineUserId" ], "autoGenerate": false }, "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_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, 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.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 } ], "primaryKey": { "columnNames": [ "id", "accountId" ], "autoGenerate": false }, "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, 'ed3b752a3faec9d092d5ac0a2823d5d5')" ] } } ================================================ FILE: app/schemas/com.keylesspalace.tusky.db.AppDatabase/40.json ================================================ { "formatVersion": 1, "database": { "version": 40, "identityHash": "0423fb3f7d09db5f12023f2f4e7297b5", "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)", "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 } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "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, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `alwaysOpenSpoiler` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `lastNotificationId` TEXT NOT NULL, `activeNotifications` TEXT NOT NULL, `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)", "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": "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": "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": "activeNotifications", "columnName": "activeNotifications", "affinity": "TEXT", "notNull": true }, { "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 } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "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": { "columnNames": [ "instance" ], "autoGenerate": false }, "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, `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, 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": "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 } ], "primaryKey": { "columnNames": [ "serverId", "timelineUserId" ], "autoGenerate": false }, "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": { "columnNames": [ "serverId", "timelineUserId" ], "autoGenerate": false }, "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_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, 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.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 } ], "primaryKey": { "columnNames": [ "id", "accountId" ], "autoGenerate": false }, "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, '0423fb3f7d09db5f12023f2f4e7297b5')" ] } } ================================================ FILE: app/schemas/com.keylesspalace.tusky.db.AppDatabase/41.json ================================================ { "formatVersion": 1, "database": { "version": 41, "identityHash": "1de8f20c7f28e1f11b33e7a55137feef", "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, `scheduledAt` 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": "scheduledAt", "columnName": "scheduledAt", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "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, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `alwaysOpenSpoiler` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `lastNotificationId` TEXT NOT NULL, `activeNotifications` TEXT NOT NULL, `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)", "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": "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": "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": "activeNotifications", "columnName": "activeNotifications", "affinity": "TEXT", "notNull": true }, { "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 } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "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": { "columnNames": [ "instance" ], "autoGenerate": false }, "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, `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, 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": "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 } ], "primaryKey": { "columnNames": [ "serverId", "timelineUserId" ], "autoGenerate": false }, "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": { "columnNames": [ "serverId", "timelineUserId" ], "autoGenerate": false }, "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_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, 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.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 } ], "primaryKey": { "columnNames": [ "id", "accountId" ], "autoGenerate": false }, "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, '1de8f20c7f28e1f11b33e7a55137feef')" ] } } ================================================ FILE: app/schemas/com.keylesspalace.tusky.db.AppDatabase/42.json ================================================ { "formatVersion": 1, "database": { "version": 42, "identityHash": "a62399cb3859de7fcbb9bd7053f7cb1d", "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, `scheduledAt` TEXT, `language` 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": "scheduledAt", "columnName": "scheduledAt", "affinity": "TEXT", "notNull": false }, { "fieldPath": "language", "columnName": "language", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "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, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `alwaysOpenSpoiler` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `lastNotificationId` TEXT NOT NULL, `activeNotifications` TEXT NOT NULL, `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)", "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": "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": "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": "activeNotifications", "columnName": "activeNotifications", "affinity": "TEXT", "notNull": true }, { "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 } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "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": { "columnNames": [ "instance" ], "autoGenerate": false }, "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, `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, 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": "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 } ], "primaryKey": { "columnNames": [ "serverId", "timelineUserId" ], "autoGenerate": false }, "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": { "columnNames": [ "serverId", "timelineUserId" ], "autoGenerate": false }, "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_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.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": { "columnNames": [ "id", "accountId" ], "autoGenerate": false }, "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, 'a62399cb3859de7fcbb9bd7053f7cb1d')" ] } } ================================================ FILE: app/schemas/com.keylesspalace.tusky.db.AppDatabase/43.json ================================================ { "formatVersion": 1, "database": { "version": 43, "identityHash": "bf68abe55bb58765da7f9d6f7ef618e2", "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, `scheduledAt` TEXT, `language` 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": "scheduledAt", "columnName": "scheduledAt", "affinity": "TEXT", "notNull": false }, { "fieldPath": "language", "columnName": "language", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "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, `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, `activeNotifications` TEXT NOT NULL, `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)", "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": "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": "activeNotifications", "columnName": "activeNotifications", "affinity": "TEXT", "notNull": true }, { "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 } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "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": { "columnNames": [ "instance" ], "autoGenerate": false }, "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, `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, 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": "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 } ], "primaryKey": { "columnNames": [ "serverId", "timelineUserId" ], "autoGenerate": false }, "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": { "columnNames": [ "serverId", "timelineUserId" ], "autoGenerate": false }, "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_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.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": { "columnNames": [ "id", "accountId" ], "autoGenerate": false }, "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, 'bf68abe55bb58765da7f9d6f7ef618e2')" ] } } ================================================ FILE: app/schemas/com.keylesspalace.tusky.db.AppDatabase/44.json ================================================ { "formatVersion": 1, "database": { "version": 44, "identityHash": "7b5271980102f35e55438f46777e3d46", "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, `scheduledAt` TEXT, `language` 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": "scheduledAt", "columnName": "scheduledAt", "affinity": "TEXT", "notNull": false }, { "fieldPath": "language", "columnName": "language", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "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, `activeNotifications` TEXT NOT NULL, `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)", "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": "activeNotifications", "columnName": "activeNotifications", "affinity": "TEXT", "notNull": true }, { "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 } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "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": { "columnNames": [ "instance" ], "autoGenerate": false }, "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, `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, 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": "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 } ], "primaryKey": { "columnNames": [ "serverId", "timelineUserId" ], "autoGenerate": false }, "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": { "columnNames": [ "serverId", "timelineUserId" ], "autoGenerate": false }, "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_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.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": { "columnNames": [ "id", "accountId" ], "autoGenerate": false }, "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, '7b5271980102f35e55438f46777e3d46')" ] } } ================================================ FILE: app/schemas/com.keylesspalace.tusky.db.AppDatabase/45.json ================================================ { "formatVersion": 1, "database": { "version": 45, "identityHash": "cb4d4c0de04e945005adbb43bc534378", "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, `scheduledAt` TEXT, `language` 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": "scheduledAt", "columnName": "scheduledAt", "affinity": "TEXT", "notNull": false }, { "fieldPath": "language", "columnName": "language", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "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, `activeNotifications` TEXT NOT NULL, `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)", "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": "activeNotifications", "columnName": "activeNotifications", "affinity": "TEXT", "notNull": true }, { "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 } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "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": { "columnNames": [ "instance" ], "autoGenerate": false }, "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, 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 } ], "primaryKey": { "columnNames": [ "serverId", "timelineUserId" ], "autoGenerate": false }, "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": { "columnNames": [ "serverId", "timelineUserId" ], "autoGenerate": false }, "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": { "columnNames": [ "id", "accountId" ], "autoGenerate": false }, "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, 'cb4d4c0de04e945005adbb43bc534378')" ] } } ================================================ FILE: app/schemas/com.keylesspalace.tusky.db.AppDatabase/46.json ================================================ { "formatVersion": 1, "database": { "version": 46, "identityHash": "3cdfad61c4cf7e1ad5c70783e60e6845", "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, `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": "scheduledAt", "columnName": "scheduledAt", "affinity": "TEXT", "notNull": false }, { "fieldPath": "language", "columnName": "language", "affinity": "TEXT", "notNull": false }, { "fieldPath": "statusId", "columnName": "statusId", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "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, `activeNotifications` TEXT NOT NULL, `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)", "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": "activeNotifications", "columnName": "activeNotifications", "affinity": "TEXT", "notNull": true }, { "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 } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "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": { "columnNames": [ "instance" ], "autoGenerate": false }, "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, 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 } ], "primaryKey": { "columnNames": [ "serverId", "timelineUserId" ], "autoGenerate": false }, "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": { "columnNames": [ "serverId", "timelineUserId" ], "autoGenerate": false }, "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": { "columnNames": [ "id", "accountId" ], "autoGenerate": false }, "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, '3cdfad61c4cf7e1ad5c70783e60e6845')" ] } } ================================================ FILE: app/schemas/com.keylesspalace.tusky.db.AppDatabase/47.json ================================================ { "formatVersion": 1, "database": { "version": 47, "identityHash": "496e1f2135a296e49eef88551ecbdd2c", "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": { "columnNames": [ "id" ], "autoGenerate": true }, "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, `activeNotifications` TEXT NOT NULL, `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)", "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": "activeNotifications", "columnName": "activeNotifications", "affinity": "TEXT", "notNull": true }, { "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 } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "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": { "columnNames": [ "instance" ], "autoGenerate": false }, "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, 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 } ], "primaryKey": { "columnNames": [ "serverId", "timelineUserId" ], "autoGenerate": false }, "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": { "columnNames": [ "serverId", "timelineUserId" ], "autoGenerate": false }, "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": { "columnNames": [ "id", "accountId" ], "autoGenerate": false }, "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, '496e1f2135a296e49eef88551ecbdd2c')" ] } } ================================================ FILE: app/schemas/com.keylesspalace.tusky.db.AppDatabase/48.json ================================================ { "formatVersion": 1, "database": { "version": 48, "identityHash": "a394ca5b45df9358fdc4d2eaae69cce3", "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, `activeNotifications` TEXT NOT NULL, `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)", "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": "activeNotifications", "columnName": "activeNotifications", "affinity": "TEXT", "notNull": true }, { "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 } ], "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, 'a394ca5b45df9358fdc4d2eaae69cce3')" ] } } ================================================ FILE: app/schemas/com.keylesspalace.tusky.db.AppDatabase/49.json ================================================ { "formatVersion": 1, "database": { "version": 49, "identityHash": "e7085677596f03c64da3d26e05321a08", "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, `activeNotifications` TEXT NOT NULL, `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)", "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": "activeNotifications", "columnName": "activeNotifications", "affinity": "TEXT", "notNull": true }, { "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 } ], "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, 'e7085677596f03c64da3d26e05321a08')" ] } } ================================================ FILE: app/schemas/com.keylesspalace.tusky.db.AppDatabase/50.json ================================================ { "formatVersion": 1, "database": { "version": 50, "identityHash": "4eaf69e915d4a15f021547b725101acd", "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, `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)", "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": "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 } ], "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, '4eaf69e915d4a15f021547b725101acd')" ] } } ================================================ FILE: app/schemas/com.keylesspalace.tusky.db.AppDatabase/51.json ================================================ { "formatVersion": 1, "database": { "version": 51, "identityHash": "446158bf571fbd08787628bb829fa3c0", "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)", "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 } ], "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, '446158bf571fbd08787628bb829fa3c0')" ] } } ================================================ FILE: app/schemas/com.keylesspalace.tusky.db.AppDatabase/52.json ================================================ { "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')" ] } } ================================================ FILE: app/schemas/com.keylesspalace.tusky.db.AppDatabase/53.json ================================================ { "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')" ] } } ================================================ FILE: app/schemas/com.keylesspalace.tusky.db.AppDatabase/54.json ================================================ { "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')" ] } } ================================================ FILE: app/schemas/com.keylesspalace.tusky.db.AppDatabase/56.json ================================================ { "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')" ] } } ================================================ FILE: app/schemas/com.keylesspalace.tusky.db.AppDatabase/58.json ================================================ { "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')" ] } } ================================================ FILE: app/schemas/com.keylesspalace.tusky.db.AppDatabase/60.json ================================================ { "formatVersion": 1, "database": { "version": 60, "identityHash": "1f8ec0c172cc1cae16313d737f6f8e34", "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, `tuskyAccountId` INTEGER NOT NULL, `authorServerId` TEXT NOT NULL, `inReplyToId` TEXT, `inReplyToAccountId` TEXT, `content` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `editedAt` INTEGER, `emojis` TEXT NOT NULL, `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 NOT NULL, `mentions` TEXT NOT NULL, `tags` TEXT NOT NULL, `application` TEXT, `poll` TEXT, `muted` INTEGER NOT NULL, `expanded` INTEGER NOT NULL, `contentCollapsed` INTEGER NOT NULL, `contentShowing` INTEGER NOT NULL, `pinned` INTEGER NOT NULL, `card` TEXT, `language` TEXT, `filtered` TEXT NOT NULL, PRIMARY KEY(`serverId`, `tuskyAccountId`), FOREIGN KEY(`authorServerId`, `tuskyAccountId`) REFERENCES `TimelineAccountEntity`(`serverId`, `tuskyAccountId`) 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": "tuskyAccountId", "columnName": "tuskyAccountId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "authorServerId", "columnName": "authorServerId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "inReplyToId", "columnName": "inReplyToId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "inReplyToAccountId", "columnName": "inReplyToAccountId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": true }, { "fieldPath": "createdAt", "columnName": "createdAt", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "editedAt", "columnName": "editedAt", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "emojis", "columnName": "emojis", "affinity": "TEXT", "notNull": true }, { "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": true }, { "fieldPath": "mentions", "columnName": "mentions", "affinity": "TEXT", "notNull": true }, { "fieldPath": "tags", "columnName": "tags", "affinity": "TEXT", "notNull": true }, { "fieldPath": "application", "columnName": "application", "affinity": "TEXT", "notNull": false }, { "fieldPath": "poll", "columnName": "poll", "affinity": "TEXT", "notNull": false }, { "fieldPath": "muted", "columnName": "muted", "affinity": "INTEGER", "notNull": true }, { "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": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "serverId", "tuskyAccountId" ] }, "indices": [ { "name": "index_TimelineStatusEntity_authorServerId_tuskyAccountId", "unique": false, "columnNames": [ "authorServerId", "tuskyAccountId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_TimelineStatusEntity_authorServerId_tuskyAccountId` ON `${TABLE_NAME}` (`authorServerId`, `tuskyAccountId`)" } ], "foreignKeys": [ { "table": "TimelineAccountEntity", "onDelete": "NO ACTION", "onUpdate": "NO ACTION", "columns": [ "authorServerId", "tuskyAccountId" ], "referencedColumns": [ "serverId", "tuskyAccountId" ] } ] }, { "tableName": "TimelineAccountEntity", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `tuskyAccountId` 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`, `tuskyAccountId`))", "fields": [ { "fieldPath": "serverId", "columnName": "serverId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "tuskyAccountId", "columnName": "tuskyAccountId", "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", "tuskyAccountId" ] }, "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": [] }, { "tableName": "NotificationEntity", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tuskyAccountId` INTEGER NOT NULL, `type` TEXT, `id` TEXT NOT NULL, `accountId` TEXT, `statusId` TEXT, `reportId` TEXT, `loading` INTEGER NOT NULL, PRIMARY KEY(`id`, `tuskyAccountId`), FOREIGN KEY(`accountId`, `tuskyAccountId`) REFERENCES `TimelineAccountEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION , FOREIGN KEY(`statusId`, `tuskyAccountId`) REFERENCES `TimelineStatusEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION , FOREIGN KEY(`reportId`, `tuskyAccountId`) REFERENCES `NotificationReportEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", "fields": [ { "fieldPath": "tuskyAccountId", "columnName": "tuskyAccountId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "type", "columnName": "type", "affinity": "TEXT", "notNull": false }, { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "accountId", "columnName": "accountId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "statusId", "columnName": "statusId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "reportId", "columnName": "reportId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loading", "columnName": "loading", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id", "tuskyAccountId" ] }, "indices": [ { "name": "index_NotificationEntity_accountId_tuskyAccountId", "unique": false, "columnNames": [ "accountId", "tuskyAccountId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_accountId_tuskyAccountId` ON `${TABLE_NAME}` (`accountId`, `tuskyAccountId`)" }, { "name": "index_NotificationEntity_statusId_tuskyAccountId", "unique": false, "columnNames": [ "statusId", "tuskyAccountId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_statusId_tuskyAccountId` ON `${TABLE_NAME}` (`statusId`, `tuskyAccountId`)" }, { "name": "index_NotificationEntity_reportId_tuskyAccountId", "unique": false, "columnNames": [ "reportId", "tuskyAccountId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_reportId_tuskyAccountId` ON `${TABLE_NAME}` (`reportId`, `tuskyAccountId`)" } ], "foreignKeys": [ { "table": "TimelineAccountEntity", "onDelete": "NO ACTION", "onUpdate": "NO ACTION", "columns": [ "accountId", "tuskyAccountId" ], "referencedColumns": [ "serverId", "tuskyAccountId" ] }, { "table": "TimelineStatusEntity", "onDelete": "NO ACTION", "onUpdate": "NO ACTION", "columns": [ "statusId", "tuskyAccountId" ], "referencedColumns": [ "serverId", "tuskyAccountId" ] }, { "table": "NotificationReportEntity", "onDelete": "NO ACTION", "onUpdate": "NO ACTION", "columns": [ "reportId", "tuskyAccountId" ], "referencedColumns": [ "serverId", "tuskyAccountId" ] } ] }, { "tableName": "NotificationReportEntity", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tuskyAccountId` INTEGER NOT NULL, `serverId` TEXT NOT NULL, `category` TEXT NOT NULL, `statusIds` TEXT, `createdAt` INTEGER NOT NULL, `targetAccountId` TEXT, PRIMARY KEY(`serverId`, `tuskyAccountId`), FOREIGN KEY(`targetAccountId`, `tuskyAccountId`) REFERENCES `TimelineAccountEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", "fields": [ { "fieldPath": "tuskyAccountId", "columnName": "tuskyAccountId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "serverId", "columnName": "serverId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "category", "columnName": "category", "affinity": "TEXT", "notNull": true }, { "fieldPath": "statusIds", "columnName": "statusIds", "affinity": "TEXT", "notNull": false }, { "fieldPath": "createdAt", "columnName": "createdAt", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "targetAccountId", "columnName": "targetAccountId", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "serverId", "tuskyAccountId" ] }, "indices": [ { "name": "index_NotificationReportEntity_targetAccountId_tuskyAccountId", "unique": false, "columnNames": [ "targetAccountId", "tuskyAccountId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationReportEntity_targetAccountId_tuskyAccountId` ON `${TABLE_NAME}` (`targetAccountId`, `tuskyAccountId`)" } ], "foreignKeys": [ { "table": "TimelineAccountEntity", "onDelete": "NO ACTION", "onUpdate": "NO ACTION", "columns": [ "targetAccountId", "tuskyAccountId" ], "referencedColumns": [ "serverId", "tuskyAccountId" ] } ] }, { "tableName": "HomeTimelineEntity", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tuskyAccountId` INTEGER NOT NULL, `id` TEXT NOT NULL, `statusId` TEXT, `reblogAccountId` TEXT, `loading` INTEGER NOT NULL, PRIMARY KEY(`id`, `tuskyAccountId`), FOREIGN KEY(`statusId`, `tuskyAccountId`) REFERENCES `TimelineStatusEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION , FOREIGN KEY(`reblogAccountId`, `tuskyAccountId`) REFERENCES `TimelineAccountEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", "fields": [ { "fieldPath": "tuskyAccountId", "columnName": "tuskyAccountId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "statusId", "columnName": "statusId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "reblogAccountId", "columnName": "reblogAccountId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loading", "columnName": "loading", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id", "tuskyAccountId" ] }, "indices": [ { "name": "index_HomeTimelineEntity_statusId_tuskyAccountId", "unique": false, "columnNames": [ "statusId", "tuskyAccountId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_HomeTimelineEntity_statusId_tuskyAccountId` ON `${TABLE_NAME}` (`statusId`, `tuskyAccountId`)" }, { "name": "index_HomeTimelineEntity_reblogAccountId_tuskyAccountId", "unique": false, "columnNames": [ "reblogAccountId", "tuskyAccountId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_HomeTimelineEntity_reblogAccountId_tuskyAccountId` ON `${TABLE_NAME}` (`reblogAccountId`, `tuskyAccountId`)" } ], "foreignKeys": [ { "table": "TimelineStatusEntity", "onDelete": "NO ACTION", "onUpdate": "NO ACTION", "columns": [ "statusId", "tuskyAccountId" ], "referencedColumns": [ "serverId", "tuskyAccountId" ] }, { "table": "TimelineAccountEntity", "onDelete": "NO ACTION", "onUpdate": "NO ACTION", "columns": [ "reblogAccountId", "tuskyAccountId" ], "referencedColumns": [ "serverId", "tuskyAccountId" ] } ] } ], "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, '1f8ec0c172cc1cae16313d737f6f8e34')" ] } } ================================================ FILE: app/schemas/com.keylesspalace.tusky.db.AppDatabase/62.json ================================================ { "formatVersion": 1, "database": { "version": 62, "identityHash": "f50579baaea33d99c59a34671799682a", "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, `defaultReplyPrivacy` 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": "defaultReplyPrivacy", "columnName": "defaultReplyPrivacy", "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, `tuskyAccountId` INTEGER NOT NULL, `authorServerId` TEXT NOT NULL, `inReplyToId` TEXT, `inReplyToAccountId` TEXT, `content` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `editedAt` INTEGER, `emojis` TEXT NOT NULL, `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 NOT NULL, `mentions` TEXT NOT NULL, `tags` TEXT NOT NULL, `application` TEXT, `poll` TEXT, `muted` INTEGER NOT NULL, `expanded` INTEGER NOT NULL, `contentCollapsed` INTEGER NOT NULL, `contentShowing` INTEGER NOT NULL, `pinned` INTEGER NOT NULL, `card` TEXT, `language` TEXT, `filtered` TEXT NOT NULL, PRIMARY KEY(`serverId`, `tuskyAccountId`), FOREIGN KEY(`authorServerId`, `tuskyAccountId`) REFERENCES `TimelineAccountEntity`(`serverId`, `tuskyAccountId`) 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": "tuskyAccountId", "columnName": "tuskyAccountId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "authorServerId", "columnName": "authorServerId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "inReplyToId", "columnName": "inReplyToId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "inReplyToAccountId", "columnName": "inReplyToAccountId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": true }, { "fieldPath": "createdAt", "columnName": "createdAt", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "editedAt", "columnName": "editedAt", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "emojis", "columnName": "emojis", "affinity": "TEXT", "notNull": true }, { "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": true }, { "fieldPath": "mentions", "columnName": "mentions", "affinity": "TEXT", "notNull": true }, { "fieldPath": "tags", "columnName": "tags", "affinity": "TEXT", "notNull": true }, { "fieldPath": "application", "columnName": "application", "affinity": "TEXT", "notNull": false }, { "fieldPath": "poll", "columnName": "poll", "affinity": "TEXT", "notNull": false }, { "fieldPath": "muted", "columnName": "muted", "affinity": "INTEGER", "notNull": true }, { "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": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "serverId", "tuskyAccountId" ] }, "indices": [ { "name": "index_TimelineStatusEntity_authorServerId_tuskyAccountId", "unique": false, "columnNames": [ "authorServerId", "tuskyAccountId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_TimelineStatusEntity_authorServerId_tuskyAccountId` ON `${TABLE_NAME}` (`authorServerId`, `tuskyAccountId`)" } ], "foreignKeys": [ { "table": "TimelineAccountEntity", "onDelete": "NO ACTION", "onUpdate": "NO ACTION", "columns": [ "authorServerId", "tuskyAccountId" ], "referencedColumns": [ "serverId", "tuskyAccountId" ] } ] }, { "tableName": "TimelineAccountEntity", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `tuskyAccountId` 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`, `tuskyAccountId`))", "fields": [ { "fieldPath": "serverId", "columnName": "serverId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "tuskyAccountId", "columnName": "tuskyAccountId", "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", "tuskyAccountId" ] }, "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": [] }, { "tableName": "NotificationEntity", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tuskyAccountId` INTEGER NOT NULL, `type` TEXT, `id` TEXT NOT NULL, `accountId` TEXT, `statusId` TEXT, `reportId` TEXT, `loading` INTEGER NOT NULL, PRIMARY KEY(`id`, `tuskyAccountId`), FOREIGN KEY(`accountId`, `tuskyAccountId`) REFERENCES `TimelineAccountEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION , FOREIGN KEY(`statusId`, `tuskyAccountId`) REFERENCES `TimelineStatusEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION , FOREIGN KEY(`reportId`, `tuskyAccountId`) REFERENCES `NotificationReportEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", "fields": [ { "fieldPath": "tuskyAccountId", "columnName": "tuskyAccountId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "type", "columnName": "type", "affinity": "TEXT", "notNull": false }, { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "accountId", "columnName": "accountId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "statusId", "columnName": "statusId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "reportId", "columnName": "reportId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loading", "columnName": "loading", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id", "tuskyAccountId" ] }, "indices": [ { "name": "index_NotificationEntity_accountId_tuskyAccountId", "unique": false, "columnNames": [ "accountId", "tuskyAccountId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_accountId_tuskyAccountId` ON `${TABLE_NAME}` (`accountId`, `tuskyAccountId`)" }, { "name": "index_NotificationEntity_statusId_tuskyAccountId", "unique": false, "columnNames": [ "statusId", "tuskyAccountId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_statusId_tuskyAccountId` ON `${TABLE_NAME}` (`statusId`, `tuskyAccountId`)" }, { "name": "index_NotificationEntity_reportId_tuskyAccountId", "unique": false, "columnNames": [ "reportId", "tuskyAccountId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_reportId_tuskyAccountId` ON `${TABLE_NAME}` (`reportId`, `tuskyAccountId`)" } ], "foreignKeys": [ { "table": "TimelineAccountEntity", "onDelete": "NO ACTION", "onUpdate": "NO ACTION", "columns": [ "accountId", "tuskyAccountId" ], "referencedColumns": [ "serverId", "tuskyAccountId" ] }, { "table": "TimelineStatusEntity", "onDelete": "NO ACTION", "onUpdate": "NO ACTION", "columns": [ "statusId", "tuskyAccountId" ], "referencedColumns": [ "serverId", "tuskyAccountId" ] }, { "table": "NotificationReportEntity", "onDelete": "NO ACTION", "onUpdate": "NO ACTION", "columns": [ "reportId", "tuskyAccountId" ], "referencedColumns": [ "serverId", "tuskyAccountId" ] } ] }, { "tableName": "NotificationReportEntity", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tuskyAccountId` INTEGER NOT NULL, `serverId` TEXT NOT NULL, `category` TEXT NOT NULL, `statusIds` TEXT, `createdAt` INTEGER NOT NULL, `targetAccountId` TEXT, PRIMARY KEY(`serverId`, `tuskyAccountId`), FOREIGN KEY(`targetAccountId`, `tuskyAccountId`) REFERENCES `TimelineAccountEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", "fields": [ { "fieldPath": "tuskyAccountId", "columnName": "tuskyAccountId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "serverId", "columnName": "serverId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "category", "columnName": "category", "affinity": "TEXT", "notNull": true }, { "fieldPath": "statusIds", "columnName": "statusIds", "affinity": "TEXT", "notNull": false }, { "fieldPath": "createdAt", "columnName": "createdAt", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "targetAccountId", "columnName": "targetAccountId", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "serverId", "tuskyAccountId" ] }, "indices": [ { "name": "index_NotificationReportEntity_targetAccountId_tuskyAccountId", "unique": false, "columnNames": [ "targetAccountId", "tuskyAccountId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationReportEntity_targetAccountId_tuskyAccountId` ON `${TABLE_NAME}` (`targetAccountId`, `tuskyAccountId`)" } ], "foreignKeys": [ { "table": "TimelineAccountEntity", "onDelete": "NO ACTION", "onUpdate": "NO ACTION", "columns": [ "targetAccountId", "tuskyAccountId" ], "referencedColumns": [ "serverId", "tuskyAccountId" ] } ] }, { "tableName": "HomeTimelineEntity", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tuskyAccountId` INTEGER NOT NULL, `id` TEXT NOT NULL, `statusId` TEXT, `reblogAccountId` TEXT, `loading` INTEGER NOT NULL, PRIMARY KEY(`id`, `tuskyAccountId`), FOREIGN KEY(`statusId`, `tuskyAccountId`) REFERENCES `TimelineStatusEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION , FOREIGN KEY(`reblogAccountId`, `tuskyAccountId`) REFERENCES `TimelineAccountEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", "fields": [ { "fieldPath": "tuskyAccountId", "columnName": "tuskyAccountId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "statusId", "columnName": "statusId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "reblogAccountId", "columnName": "reblogAccountId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loading", "columnName": "loading", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id", "tuskyAccountId" ] }, "indices": [ { "name": "index_HomeTimelineEntity_statusId_tuskyAccountId", "unique": false, "columnNames": [ "statusId", "tuskyAccountId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_HomeTimelineEntity_statusId_tuskyAccountId` ON `${TABLE_NAME}` (`statusId`, `tuskyAccountId`)" }, { "name": "index_HomeTimelineEntity_reblogAccountId_tuskyAccountId", "unique": false, "columnNames": [ "reblogAccountId", "tuskyAccountId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_HomeTimelineEntity_reblogAccountId_tuskyAccountId` ON `${TABLE_NAME}` (`reblogAccountId`, `tuskyAccountId`)" } ], "foreignKeys": [ { "table": "TimelineStatusEntity", "onDelete": "NO ACTION", "onUpdate": "NO ACTION", "columns": [ "statusId", "tuskyAccountId" ], "referencedColumns": [ "serverId", "tuskyAccountId" ] }, { "table": "TimelineAccountEntity", "onDelete": "NO ACTION", "onUpdate": "NO ACTION", "columns": [ "reblogAccountId", "tuskyAccountId" ], "referencedColumns": [ "serverId", "tuskyAccountId" ] } ] } ], "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, 'f50579baaea33d99c59a34671799682a')" ] } } ================================================ FILE: app/schemas/com.keylesspalace.tusky.db.AppDatabase/64.json ================================================ { "formatVersion": 1, "database": { "version": 64, "identityHash": "12c1f266e9fb1d7fea3dde12866eb338", "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, `defaultReplyPrivacy` 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": "defaultReplyPrivacy", "columnName": "defaultReplyPrivacy", "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, `filterV2Supported` INTEGER NOT NULL DEFAULT false, 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 }, { "fieldPath": "filterV2Supported", "columnName": "filterV2Supported", "affinity": "INTEGER", "notNull": true, "defaultValue": "false" } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "instance" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "TimelineStatusEntity", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `url` TEXT, `tuskyAccountId` INTEGER NOT NULL, `authorServerId` TEXT NOT NULL, `inReplyToId` TEXT, `inReplyToAccountId` TEXT, `content` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `editedAt` INTEGER, `emojis` TEXT NOT NULL, `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 NOT NULL, `mentions` TEXT NOT NULL, `tags` TEXT NOT NULL, `application` TEXT, `poll` TEXT, `muted` INTEGER NOT NULL, `expanded` INTEGER NOT NULL, `contentCollapsed` INTEGER NOT NULL, `contentShowing` INTEGER NOT NULL, `pinned` INTEGER NOT NULL, `card` TEXT, `language` TEXT, `filtered` TEXT NOT NULL, PRIMARY KEY(`serverId`, `tuskyAccountId`), FOREIGN KEY(`authorServerId`, `tuskyAccountId`) REFERENCES `TimelineAccountEntity`(`serverId`, `tuskyAccountId`) 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": "tuskyAccountId", "columnName": "tuskyAccountId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "authorServerId", "columnName": "authorServerId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "inReplyToId", "columnName": "inReplyToId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "inReplyToAccountId", "columnName": "inReplyToAccountId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": true }, { "fieldPath": "createdAt", "columnName": "createdAt", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "editedAt", "columnName": "editedAt", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "emojis", "columnName": "emojis", "affinity": "TEXT", "notNull": true }, { "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": true }, { "fieldPath": "mentions", "columnName": "mentions", "affinity": "TEXT", "notNull": true }, { "fieldPath": "tags", "columnName": "tags", "affinity": "TEXT", "notNull": true }, { "fieldPath": "application", "columnName": "application", "affinity": "TEXT", "notNull": false }, { "fieldPath": "poll", "columnName": "poll", "affinity": "TEXT", "notNull": false }, { "fieldPath": "muted", "columnName": "muted", "affinity": "INTEGER", "notNull": true }, { "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": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "serverId", "tuskyAccountId" ] }, "indices": [ { "name": "index_TimelineStatusEntity_authorServerId_tuskyAccountId", "unique": false, "columnNames": [ "authorServerId", "tuskyAccountId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_TimelineStatusEntity_authorServerId_tuskyAccountId` ON `${TABLE_NAME}` (`authorServerId`, `tuskyAccountId`)" } ], "foreignKeys": [ { "table": "TimelineAccountEntity", "onDelete": "NO ACTION", "onUpdate": "NO ACTION", "columns": [ "authorServerId", "tuskyAccountId" ], "referencedColumns": [ "serverId", "tuskyAccountId" ] } ] }, { "tableName": "TimelineAccountEntity", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `tuskyAccountId` 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`, `tuskyAccountId`))", "fields": [ { "fieldPath": "serverId", "columnName": "serverId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "tuskyAccountId", "columnName": "tuskyAccountId", "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", "tuskyAccountId" ] }, "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": [] }, { "tableName": "NotificationEntity", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tuskyAccountId` INTEGER NOT NULL, `type` TEXT, `id` TEXT NOT NULL, `accountId` TEXT, `statusId` TEXT, `reportId` TEXT, `loading` INTEGER NOT NULL, PRIMARY KEY(`id`, `tuskyAccountId`), FOREIGN KEY(`accountId`, `tuskyAccountId`) REFERENCES `TimelineAccountEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION , FOREIGN KEY(`statusId`, `tuskyAccountId`) REFERENCES `TimelineStatusEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION , FOREIGN KEY(`reportId`, `tuskyAccountId`) REFERENCES `NotificationReportEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", "fields": [ { "fieldPath": "tuskyAccountId", "columnName": "tuskyAccountId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "type", "columnName": "type", "affinity": "TEXT", "notNull": false }, { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "accountId", "columnName": "accountId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "statusId", "columnName": "statusId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "reportId", "columnName": "reportId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loading", "columnName": "loading", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id", "tuskyAccountId" ] }, "indices": [ { "name": "index_NotificationEntity_accountId_tuskyAccountId", "unique": false, "columnNames": [ "accountId", "tuskyAccountId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_accountId_tuskyAccountId` ON `${TABLE_NAME}` (`accountId`, `tuskyAccountId`)" }, { "name": "index_NotificationEntity_statusId_tuskyAccountId", "unique": false, "columnNames": [ "statusId", "tuskyAccountId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_statusId_tuskyAccountId` ON `${TABLE_NAME}` (`statusId`, `tuskyAccountId`)" }, { "name": "index_NotificationEntity_reportId_tuskyAccountId", "unique": false, "columnNames": [ "reportId", "tuskyAccountId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_reportId_tuskyAccountId` ON `${TABLE_NAME}` (`reportId`, `tuskyAccountId`)" } ], "foreignKeys": [ { "table": "TimelineAccountEntity", "onDelete": "NO ACTION", "onUpdate": "NO ACTION", "columns": [ "accountId", "tuskyAccountId" ], "referencedColumns": [ "serverId", "tuskyAccountId" ] }, { "table": "TimelineStatusEntity", "onDelete": "NO ACTION", "onUpdate": "NO ACTION", "columns": [ "statusId", "tuskyAccountId" ], "referencedColumns": [ "serverId", "tuskyAccountId" ] }, { "table": "NotificationReportEntity", "onDelete": "NO ACTION", "onUpdate": "NO ACTION", "columns": [ "reportId", "tuskyAccountId" ], "referencedColumns": [ "serverId", "tuskyAccountId" ] } ] }, { "tableName": "NotificationReportEntity", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tuskyAccountId` INTEGER NOT NULL, `serverId` TEXT NOT NULL, `category` TEXT NOT NULL, `statusIds` TEXT, `createdAt` INTEGER NOT NULL, `targetAccountId` TEXT, PRIMARY KEY(`serverId`, `tuskyAccountId`), FOREIGN KEY(`targetAccountId`, `tuskyAccountId`) REFERENCES `TimelineAccountEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", "fields": [ { "fieldPath": "tuskyAccountId", "columnName": "tuskyAccountId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "serverId", "columnName": "serverId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "category", "columnName": "category", "affinity": "TEXT", "notNull": true }, { "fieldPath": "statusIds", "columnName": "statusIds", "affinity": "TEXT", "notNull": false }, { "fieldPath": "createdAt", "columnName": "createdAt", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "targetAccountId", "columnName": "targetAccountId", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "serverId", "tuskyAccountId" ] }, "indices": [ { "name": "index_NotificationReportEntity_targetAccountId_tuskyAccountId", "unique": false, "columnNames": [ "targetAccountId", "tuskyAccountId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationReportEntity_targetAccountId_tuskyAccountId` ON `${TABLE_NAME}` (`targetAccountId`, `tuskyAccountId`)" } ], "foreignKeys": [ { "table": "TimelineAccountEntity", "onDelete": "NO ACTION", "onUpdate": "NO ACTION", "columns": [ "targetAccountId", "tuskyAccountId" ], "referencedColumns": [ "serverId", "tuskyAccountId" ] } ] }, { "tableName": "HomeTimelineEntity", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tuskyAccountId` INTEGER NOT NULL, `id` TEXT NOT NULL, `statusId` TEXT, `reblogAccountId` TEXT, `loading` INTEGER NOT NULL, PRIMARY KEY(`id`, `tuskyAccountId`), FOREIGN KEY(`statusId`, `tuskyAccountId`) REFERENCES `TimelineStatusEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION , FOREIGN KEY(`reblogAccountId`, `tuskyAccountId`) REFERENCES `TimelineAccountEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", "fields": [ { "fieldPath": "tuskyAccountId", "columnName": "tuskyAccountId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "statusId", "columnName": "statusId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "reblogAccountId", "columnName": "reblogAccountId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loading", "columnName": "loading", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id", "tuskyAccountId" ] }, "indices": [ { "name": "index_HomeTimelineEntity_statusId_tuskyAccountId", "unique": false, "columnNames": [ "statusId", "tuskyAccountId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_HomeTimelineEntity_statusId_tuskyAccountId` ON `${TABLE_NAME}` (`statusId`, `tuskyAccountId`)" }, { "name": "index_HomeTimelineEntity_reblogAccountId_tuskyAccountId", "unique": false, "columnNames": [ "reblogAccountId", "tuskyAccountId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_HomeTimelineEntity_reblogAccountId_tuskyAccountId` ON `${TABLE_NAME}` (`reblogAccountId`, `tuskyAccountId`)" } ], "foreignKeys": [ { "table": "TimelineStatusEntity", "onDelete": "NO ACTION", "onUpdate": "NO ACTION", "columns": [ "statusId", "tuskyAccountId" ], "referencedColumns": [ "serverId", "tuskyAccountId" ] }, { "table": "TimelineAccountEntity", "onDelete": "NO ACTION", "onUpdate": "NO ACTION", "columns": [ "reblogAccountId", "tuskyAccountId" ], "referencedColumns": [ "serverId", "tuskyAccountId" ] } ] } ], "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, '12c1f266e9fb1d7fea3dde12866eb338')" ] } } ================================================ FILE: app/schemas/com.keylesspalace.tusky.db.AppDatabase/66.json ================================================ { "formatVersion": 1, "database": { "version": 66, "identityHash": "a17a9b196abd59db5104b46ea19c4d10", "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, `profileHeaderUrl` TEXT NOT NULL DEFAULT '', `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, `defaultReplyPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `defaultPostLanguage` TEXT NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `alwaysOpenSpoiler` INTEGER NOT NULL DEFAULT 0, `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": "profileHeaderUrl", "columnName": "profileHeaderUrl", "affinity": "TEXT", "notNull": true, "defaultValue": "''" }, { "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": "defaultReplyPrivacy", "columnName": "defaultReplyPrivacy", "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, "defaultValue": "0" }, { "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, `filterV2Supported` INTEGER NOT NULL DEFAULT false, 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 }, { "fieldPath": "filterV2Supported", "columnName": "filterV2Supported", "affinity": "INTEGER", "notNull": true, "defaultValue": "false" } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "instance" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "TimelineStatusEntity", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `url` TEXT, `tuskyAccountId` INTEGER NOT NULL, `authorServerId` TEXT NOT NULL, `inReplyToId` TEXT, `inReplyToAccountId` TEXT, `content` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `editedAt` INTEGER, `emojis` TEXT NOT NULL, `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 NOT NULL, `mentions` TEXT NOT NULL, `tags` TEXT NOT NULL, `application` TEXT, `poll` TEXT, `muted` INTEGER NOT NULL, `expanded` INTEGER NOT NULL, `contentCollapsed` INTEGER NOT NULL, `contentShowing` INTEGER NOT NULL, `pinned` INTEGER NOT NULL, `card` TEXT, `language` TEXT, `filtered` TEXT NOT NULL, PRIMARY KEY(`serverId`, `tuskyAccountId`), FOREIGN KEY(`authorServerId`, `tuskyAccountId`) REFERENCES `TimelineAccountEntity`(`serverId`, `tuskyAccountId`) 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": "tuskyAccountId", "columnName": "tuskyAccountId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "authorServerId", "columnName": "authorServerId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "inReplyToId", "columnName": "inReplyToId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "inReplyToAccountId", "columnName": "inReplyToAccountId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": true }, { "fieldPath": "createdAt", "columnName": "createdAt", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "editedAt", "columnName": "editedAt", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "emojis", "columnName": "emojis", "affinity": "TEXT", "notNull": true }, { "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": true }, { "fieldPath": "mentions", "columnName": "mentions", "affinity": "TEXT", "notNull": true }, { "fieldPath": "tags", "columnName": "tags", "affinity": "TEXT", "notNull": true }, { "fieldPath": "application", "columnName": "application", "affinity": "TEXT", "notNull": false }, { "fieldPath": "poll", "columnName": "poll", "affinity": "TEXT", "notNull": false }, { "fieldPath": "muted", "columnName": "muted", "affinity": "INTEGER", "notNull": true }, { "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": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "serverId", "tuskyAccountId" ] }, "indices": [ { "name": "index_TimelineStatusEntity_authorServerId_tuskyAccountId", "unique": false, "columnNames": [ "authorServerId", "tuskyAccountId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_TimelineStatusEntity_authorServerId_tuskyAccountId` ON `${TABLE_NAME}` (`authorServerId`, `tuskyAccountId`)" } ], "foreignKeys": [ { "table": "TimelineAccountEntity", "onDelete": "NO ACTION", "onUpdate": "NO ACTION", "columns": [ "authorServerId", "tuskyAccountId" ], "referencedColumns": [ "serverId", "tuskyAccountId" ] } ] }, { "tableName": "TimelineAccountEntity", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `tuskyAccountId` 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`, `tuskyAccountId`))", "fields": [ { "fieldPath": "serverId", "columnName": "serverId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "tuskyAccountId", "columnName": "tuskyAccountId", "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", "tuskyAccountId" ] }, "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": [] }, { "tableName": "NotificationEntity", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tuskyAccountId` INTEGER NOT NULL, `type` TEXT, `id` TEXT NOT NULL, `accountId` TEXT, `statusId` TEXT, `reportId` TEXT, `loading` INTEGER NOT NULL, PRIMARY KEY(`id`, `tuskyAccountId`), FOREIGN KEY(`accountId`, `tuskyAccountId`) REFERENCES `TimelineAccountEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION , FOREIGN KEY(`statusId`, `tuskyAccountId`) REFERENCES `TimelineStatusEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION , FOREIGN KEY(`reportId`, `tuskyAccountId`) REFERENCES `NotificationReportEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", "fields": [ { "fieldPath": "tuskyAccountId", "columnName": "tuskyAccountId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "type", "columnName": "type", "affinity": "TEXT", "notNull": false }, { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "accountId", "columnName": "accountId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "statusId", "columnName": "statusId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "reportId", "columnName": "reportId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loading", "columnName": "loading", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id", "tuskyAccountId" ] }, "indices": [ { "name": "index_NotificationEntity_accountId_tuskyAccountId", "unique": false, "columnNames": [ "accountId", "tuskyAccountId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_accountId_tuskyAccountId` ON `${TABLE_NAME}` (`accountId`, `tuskyAccountId`)" }, { "name": "index_NotificationEntity_statusId_tuskyAccountId", "unique": false, "columnNames": [ "statusId", "tuskyAccountId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_statusId_tuskyAccountId` ON `${TABLE_NAME}` (`statusId`, `tuskyAccountId`)" }, { "name": "index_NotificationEntity_reportId_tuskyAccountId", "unique": false, "columnNames": [ "reportId", "tuskyAccountId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_reportId_tuskyAccountId` ON `${TABLE_NAME}` (`reportId`, `tuskyAccountId`)" } ], "foreignKeys": [ { "table": "TimelineAccountEntity", "onDelete": "NO ACTION", "onUpdate": "NO ACTION", "columns": [ "accountId", "tuskyAccountId" ], "referencedColumns": [ "serverId", "tuskyAccountId" ] }, { "table": "TimelineStatusEntity", "onDelete": "NO ACTION", "onUpdate": "NO ACTION", "columns": [ "statusId", "tuskyAccountId" ], "referencedColumns": [ "serverId", "tuskyAccountId" ] }, { "table": "NotificationReportEntity", "onDelete": "NO ACTION", "onUpdate": "NO ACTION", "columns": [ "reportId", "tuskyAccountId" ], "referencedColumns": [ "serverId", "tuskyAccountId" ] } ] }, { "tableName": "NotificationReportEntity", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tuskyAccountId` INTEGER NOT NULL, `serverId` TEXT NOT NULL, `category` TEXT NOT NULL, `statusIds` TEXT, `createdAt` INTEGER NOT NULL, `targetAccountId` TEXT, PRIMARY KEY(`serverId`, `tuskyAccountId`), FOREIGN KEY(`targetAccountId`, `tuskyAccountId`) REFERENCES `TimelineAccountEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", "fields": [ { "fieldPath": "tuskyAccountId", "columnName": "tuskyAccountId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "serverId", "columnName": "serverId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "category", "columnName": "category", "affinity": "TEXT", "notNull": true }, { "fieldPath": "statusIds", "columnName": "statusIds", "affinity": "TEXT", "notNull": false }, { "fieldPath": "createdAt", "columnName": "createdAt", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "targetAccountId", "columnName": "targetAccountId", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "serverId", "tuskyAccountId" ] }, "indices": [ { "name": "index_NotificationReportEntity_targetAccountId_tuskyAccountId", "unique": false, "columnNames": [ "targetAccountId", "tuskyAccountId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationReportEntity_targetAccountId_tuskyAccountId` ON `${TABLE_NAME}` (`targetAccountId`, `tuskyAccountId`)" } ], "foreignKeys": [ { "table": "TimelineAccountEntity", "onDelete": "NO ACTION", "onUpdate": "NO ACTION", "columns": [ "targetAccountId", "tuskyAccountId" ], "referencedColumns": [ "serverId", "tuskyAccountId" ] } ] }, { "tableName": "HomeTimelineEntity", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tuskyAccountId` INTEGER NOT NULL, `id` TEXT NOT NULL, `statusId` TEXT, `reblogAccountId` TEXT, `loading` INTEGER NOT NULL, PRIMARY KEY(`id`, `tuskyAccountId`), FOREIGN KEY(`statusId`, `tuskyAccountId`) REFERENCES `TimelineStatusEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION , FOREIGN KEY(`reblogAccountId`, `tuskyAccountId`) REFERENCES `TimelineAccountEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", "fields": [ { "fieldPath": "tuskyAccountId", "columnName": "tuskyAccountId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "statusId", "columnName": "statusId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "reblogAccountId", "columnName": "reblogAccountId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loading", "columnName": "loading", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id", "tuskyAccountId" ] }, "indices": [ { "name": "index_HomeTimelineEntity_statusId_tuskyAccountId", "unique": false, "columnNames": [ "statusId", "tuskyAccountId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_HomeTimelineEntity_statusId_tuskyAccountId` ON `${TABLE_NAME}` (`statusId`, `tuskyAccountId`)" }, { "name": "index_HomeTimelineEntity_reblogAccountId_tuskyAccountId", "unique": false, "columnNames": [ "reblogAccountId", "tuskyAccountId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_HomeTimelineEntity_reblogAccountId_tuskyAccountId` ON `${TABLE_NAME}` (`reblogAccountId`, `tuskyAccountId`)" } ], "foreignKeys": [ { "table": "TimelineStatusEntity", "onDelete": "NO ACTION", "onUpdate": "NO ACTION", "columns": [ "statusId", "tuskyAccountId" ], "referencedColumns": [ "serverId", "tuskyAccountId" ] }, { "table": "TimelineAccountEntity", "onDelete": "NO ACTION", "onUpdate": "NO ACTION", "columns": [ "reblogAccountId", "tuskyAccountId" ], "referencedColumns": [ "serverId", "tuskyAccountId" ] } ] } ], "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, 'a17a9b196abd59db5104b46ea19c4d10')" ] } } ================================================ FILE: app/schemas/com.keylesspalace.tusky.db.AppDatabase/68.json ================================================ { "formatVersion": 1, "database": { "version": 68, "identityHash": "45583265bb92757d39163ee6c19dc4e5", "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, `profileHeaderUrl` TEXT NOT NULL DEFAULT '', `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, `notificationsUpdates` INTEGER NOT NULL, `notificationsAdmin` INTEGER NOT NULL DEFAULT true, `notificationsOther` INTEGER NOT NULL DEFAULT true, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultReplyPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `defaultPostLanguage` TEXT NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `alwaysOpenSpoiler` INTEGER NOT NULL DEFAULT 0, `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": "profileHeaderUrl", "columnName": "profileHeaderUrl", "affinity": "TEXT", "notNull": true, "defaultValue": "''" }, { "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": "notificationsUpdates", "columnName": "notificationsUpdates", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "notificationsAdmin", "columnName": "notificationsAdmin", "affinity": "INTEGER", "notNull": true, "defaultValue": "true" }, { "fieldPath": "notificationsOther", "columnName": "notificationsOther", "affinity": "INTEGER", "notNull": true, "defaultValue": "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": "defaultReplyPrivacy", "columnName": "defaultReplyPrivacy", "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, "defaultValue": "0" }, { "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, `filterV2Supported` INTEGER NOT NULL DEFAULT false, 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 }, { "fieldPath": "filterV2Supported", "columnName": "filterV2Supported", "affinity": "INTEGER", "notNull": true, "defaultValue": "false" } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "instance" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "TimelineStatusEntity", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `url` TEXT, `tuskyAccountId` INTEGER NOT NULL, `authorServerId` TEXT NOT NULL, `inReplyToId` TEXT, `inReplyToAccountId` TEXT, `content` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `editedAt` INTEGER, `emojis` TEXT NOT NULL, `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 NOT NULL, `mentions` TEXT NOT NULL, `tags` TEXT NOT NULL, `application` TEXT, `poll` TEXT, `muted` INTEGER NOT NULL, `expanded` INTEGER NOT NULL, `contentCollapsed` INTEGER NOT NULL, `contentShowing` INTEGER NOT NULL, `pinned` INTEGER NOT NULL, `card` TEXT, `language` TEXT, `filtered` TEXT NOT NULL, PRIMARY KEY(`serverId`, `tuskyAccountId`), FOREIGN KEY(`authorServerId`, `tuskyAccountId`) REFERENCES `TimelineAccountEntity`(`serverId`, `tuskyAccountId`) 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": "tuskyAccountId", "columnName": "tuskyAccountId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "authorServerId", "columnName": "authorServerId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "inReplyToId", "columnName": "inReplyToId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "inReplyToAccountId", "columnName": "inReplyToAccountId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": true }, { "fieldPath": "createdAt", "columnName": "createdAt", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "editedAt", "columnName": "editedAt", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "emojis", "columnName": "emojis", "affinity": "TEXT", "notNull": true }, { "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": true }, { "fieldPath": "mentions", "columnName": "mentions", "affinity": "TEXT", "notNull": true }, { "fieldPath": "tags", "columnName": "tags", "affinity": "TEXT", "notNull": true }, { "fieldPath": "application", "columnName": "application", "affinity": "TEXT", "notNull": false }, { "fieldPath": "poll", "columnName": "poll", "affinity": "TEXT", "notNull": false }, { "fieldPath": "muted", "columnName": "muted", "affinity": "INTEGER", "notNull": true }, { "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": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "serverId", "tuskyAccountId" ] }, "indices": [ { "name": "index_TimelineStatusEntity_authorServerId_tuskyAccountId", "unique": false, "columnNames": [ "authorServerId", "tuskyAccountId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_TimelineStatusEntity_authorServerId_tuskyAccountId` ON `${TABLE_NAME}` (`authorServerId`, `tuskyAccountId`)" } ], "foreignKeys": [ { "table": "TimelineAccountEntity", "onDelete": "NO ACTION", "onUpdate": "NO ACTION", "columns": [ "authorServerId", "tuskyAccountId" ], "referencedColumns": [ "serverId", "tuskyAccountId" ] } ] }, { "tableName": "TimelineAccountEntity", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `tuskyAccountId` INTEGER NOT NULL, `localUsername` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `url` TEXT NOT NULL, `avatar` TEXT NOT NULL, `note` TEXT NOT NULL DEFAULT '', `emojis` TEXT NOT NULL, `bot` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `tuskyAccountId`))", "fields": [ { "fieldPath": "serverId", "columnName": "serverId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "tuskyAccountId", "columnName": "tuskyAccountId", "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": "note", "columnName": "note", "affinity": "TEXT", "notNull": true, "defaultValue": "''" }, { "fieldPath": "emojis", "columnName": "emojis", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bot", "columnName": "bot", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "serverId", "tuskyAccountId" ] }, "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": [] }, { "tableName": "NotificationEntity", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tuskyAccountId` INTEGER NOT NULL, `type` TEXT, `id` TEXT NOT NULL, `accountId` TEXT, `statusId` TEXT, `reportId` TEXT, `event` TEXT, `moderationWarning` TEXT, `loading` INTEGER NOT NULL, PRIMARY KEY(`id`, `tuskyAccountId`), FOREIGN KEY(`accountId`, `tuskyAccountId`) REFERENCES `TimelineAccountEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION , FOREIGN KEY(`statusId`, `tuskyAccountId`) REFERENCES `TimelineStatusEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION , FOREIGN KEY(`reportId`, `tuskyAccountId`) REFERENCES `NotificationReportEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", "fields": [ { "fieldPath": "tuskyAccountId", "columnName": "tuskyAccountId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "type", "columnName": "type", "affinity": "TEXT", "notNull": false }, { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "accountId", "columnName": "accountId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "statusId", "columnName": "statusId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "reportId", "columnName": "reportId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "event", "columnName": "event", "affinity": "TEXT", "notNull": false }, { "fieldPath": "moderationWarning", "columnName": "moderationWarning", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loading", "columnName": "loading", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id", "tuskyAccountId" ] }, "indices": [ { "name": "index_NotificationEntity_accountId_tuskyAccountId", "unique": false, "columnNames": [ "accountId", "tuskyAccountId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_accountId_tuskyAccountId` ON `${TABLE_NAME}` (`accountId`, `tuskyAccountId`)" }, { "name": "index_NotificationEntity_statusId_tuskyAccountId", "unique": false, "columnNames": [ "statusId", "tuskyAccountId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_statusId_tuskyAccountId` ON `${TABLE_NAME}` (`statusId`, `tuskyAccountId`)" }, { "name": "index_NotificationEntity_reportId_tuskyAccountId", "unique": false, "columnNames": [ "reportId", "tuskyAccountId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_reportId_tuskyAccountId` ON `${TABLE_NAME}` (`reportId`, `tuskyAccountId`)" } ], "foreignKeys": [ { "table": "TimelineAccountEntity", "onDelete": "NO ACTION", "onUpdate": "NO ACTION", "columns": [ "accountId", "tuskyAccountId" ], "referencedColumns": [ "serverId", "tuskyAccountId" ] }, { "table": "TimelineStatusEntity", "onDelete": "NO ACTION", "onUpdate": "NO ACTION", "columns": [ "statusId", "tuskyAccountId" ], "referencedColumns": [ "serverId", "tuskyAccountId" ] }, { "table": "NotificationReportEntity", "onDelete": "NO ACTION", "onUpdate": "NO ACTION", "columns": [ "reportId", "tuskyAccountId" ], "referencedColumns": [ "serverId", "tuskyAccountId" ] } ] }, { "tableName": "NotificationReportEntity", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tuskyAccountId` INTEGER NOT NULL, `serverId` TEXT NOT NULL, `category` TEXT NOT NULL, `statusIds` TEXT, `createdAt` INTEGER NOT NULL, `targetAccountId` TEXT, PRIMARY KEY(`serverId`, `tuskyAccountId`), FOREIGN KEY(`targetAccountId`, `tuskyAccountId`) REFERENCES `TimelineAccountEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", "fields": [ { "fieldPath": "tuskyAccountId", "columnName": "tuskyAccountId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "serverId", "columnName": "serverId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "category", "columnName": "category", "affinity": "TEXT", "notNull": true }, { "fieldPath": "statusIds", "columnName": "statusIds", "affinity": "TEXT", "notNull": false }, { "fieldPath": "createdAt", "columnName": "createdAt", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "targetAccountId", "columnName": "targetAccountId", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "serverId", "tuskyAccountId" ] }, "indices": [ { "name": "index_NotificationReportEntity_targetAccountId_tuskyAccountId", "unique": false, "columnNames": [ "targetAccountId", "tuskyAccountId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationReportEntity_targetAccountId_tuskyAccountId` ON `${TABLE_NAME}` (`targetAccountId`, `tuskyAccountId`)" } ], "foreignKeys": [ { "table": "TimelineAccountEntity", "onDelete": "NO ACTION", "onUpdate": "NO ACTION", "columns": [ "targetAccountId", "tuskyAccountId" ], "referencedColumns": [ "serverId", "tuskyAccountId" ] } ] }, { "tableName": "HomeTimelineEntity", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tuskyAccountId` INTEGER NOT NULL, `id` TEXT NOT NULL, `statusId` TEXT, `reblogAccountId` TEXT, `loading` INTEGER NOT NULL, PRIMARY KEY(`id`, `tuskyAccountId`), FOREIGN KEY(`statusId`, `tuskyAccountId`) REFERENCES `TimelineStatusEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION , FOREIGN KEY(`reblogAccountId`, `tuskyAccountId`) REFERENCES `TimelineAccountEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", "fields": [ { "fieldPath": "tuskyAccountId", "columnName": "tuskyAccountId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "statusId", "columnName": "statusId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "reblogAccountId", "columnName": "reblogAccountId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loading", "columnName": "loading", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id", "tuskyAccountId" ] }, "indices": [ { "name": "index_HomeTimelineEntity_statusId_tuskyAccountId", "unique": false, "columnNames": [ "statusId", "tuskyAccountId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_HomeTimelineEntity_statusId_tuskyAccountId` ON `${TABLE_NAME}` (`statusId`, `tuskyAccountId`)" }, { "name": "index_HomeTimelineEntity_reblogAccountId_tuskyAccountId", "unique": false, "columnNames": [ "reblogAccountId", "tuskyAccountId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_HomeTimelineEntity_reblogAccountId_tuskyAccountId` ON `${TABLE_NAME}` (`reblogAccountId`, `tuskyAccountId`)" } ], "foreignKeys": [ { "table": "TimelineStatusEntity", "onDelete": "NO ACTION", "onUpdate": "NO ACTION", "columns": [ "statusId", "tuskyAccountId" ], "referencedColumns": [ "serverId", "tuskyAccountId" ] }, { "table": "TimelineAccountEntity", "onDelete": "NO ACTION", "onUpdate": "NO ACTION", "columns": [ "reblogAccountId", "tuskyAccountId" ], "referencedColumns": [ "serverId", "tuskyAccountId" ] } ] }, { "tableName": "NotificationPolicyEntity", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tuskyAccountId` INTEGER NOT NULL, `pendingRequestsCount` INTEGER NOT NULL, `pendingNotificationsCount` INTEGER NOT NULL, PRIMARY KEY(`tuskyAccountId`))", "fields": [ { "fieldPath": "tuskyAccountId", "columnName": "tuskyAccountId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "pendingRequestsCount", "columnName": "pendingRequestsCount", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "pendingNotificationsCount", "columnName": "pendingNotificationsCount", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "tuskyAccountId" ] }, "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, '45583265bb92757d39163ee6c19dc4e5')" ] } } ================================================ FILE: app/schemas/com.keylesspalace.tusky.db.AppDatabase/70.json ================================================ { "formatVersion": 1, "database": { "version": 70, "identityHash": "f1ac7b67aa0a9a279f7f35f5817b6a17", "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, `profileHeaderUrl` TEXT NOT NULL DEFAULT '', `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, `notificationsUpdates` INTEGER NOT NULL, `notificationsAdmin` INTEGER NOT NULL DEFAULT true, `notificationsOther` INTEGER NOT NULL DEFAULT true, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultReplyPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `defaultPostLanguage` TEXT NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `alwaysOpenSpoiler` INTEGER NOT NULL DEFAULT 0, `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": "profileHeaderUrl", "columnName": "profileHeaderUrl", "affinity": "TEXT", "notNull": true, "defaultValue": "''" }, { "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": "notificationsUpdates", "columnName": "notificationsUpdates", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "notificationsAdmin", "columnName": "notificationsAdmin", "affinity": "INTEGER", "notNull": true, "defaultValue": "true" }, { "fieldPath": "notificationsOther", "columnName": "notificationsOther", "affinity": "INTEGER", "notNull": true, "defaultValue": "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": "defaultReplyPrivacy", "columnName": "defaultReplyPrivacy", "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, "defaultValue": "0" }, { "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, `mastodonApiVersion` INTEGER, `filterV2Supported` INTEGER NOT NULL DEFAULT false, 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 }, { "fieldPath": "mastodonApiVersion", "columnName": "mastodonApiVersion", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "filterV2Supported", "columnName": "filterV2Supported", "affinity": "INTEGER", "notNull": true, "defaultValue": "false" } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "instance" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "TimelineStatusEntity", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `url` TEXT, `tuskyAccountId` INTEGER NOT NULL, `authorServerId` TEXT NOT NULL, `inReplyToId` TEXT, `inReplyToAccountId` TEXT, `content` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `editedAt` INTEGER, `emojis` TEXT NOT NULL, `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 NOT NULL, `mentions` TEXT NOT NULL, `tags` TEXT NOT NULL, `application` TEXT, `poll` TEXT, `muted` INTEGER NOT NULL, `expanded` INTEGER NOT NULL, `contentCollapsed` INTEGER NOT NULL, `contentShowing` INTEGER NOT NULL, `pinned` INTEGER NOT NULL, `card` TEXT, `language` TEXT, `filtered` TEXT NOT NULL, PRIMARY KEY(`serverId`, `tuskyAccountId`), FOREIGN KEY(`authorServerId`, `tuskyAccountId`) REFERENCES `TimelineAccountEntity`(`serverId`, `tuskyAccountId`) 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": "tuskyAccountId", "columnName": "tuskyAccountId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "authorServerId", "columnName": "authorServerId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "inReplyToId", "columnName": "inReplyToId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "inReplyToAccountId", "columnName": "inReplyToAccountId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "content", "columnName": "content", "affinity": "TEXT", "notNull": true }, { "fieldPath": "createdAt", "columnName": "createdAt", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "editedAt", "columnName": "editedAt", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "emojis", "columnName": "emojis", "affinity": "TEXT", "notNull": true }, { "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": true }, { "fieldPath": "mentions", "columnName": "mentions", "affinity": "TEXT", "notNull": true }, { "fieldPath": "tags", "columnName": "tags", "affinity": "TEXT", "notNull": true }, { "fieldPath": "application", "columnName": "application", "affinity": "TEXT", "notNull": false }, { "fieldPath": "poll", "columnName": "poll", "affinity": "TEXT", "notNull": false }, { "fieldPath": "muted", "columnName": "muted", "affinity": "INTEGER", "notNull": true }, { "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": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "serverId", "tuskyAccountId" ] }, "indices": [ { "name": "index_TimelineStatusEntity_authorServerId_tuskyAccountId", "unique": false, "columnNames": [ "authorServerId", "tuskyAccountId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_TimelineStatusEntity_authorServerId_tuskyAccountId` ON `${TABLE_NAME}` (`authorServerId`, `tuskyAccountId`)" } ], "foreignKeys": [ { "table": "TimelineAccountEntity", "onDelete": "NO ACTION", "onUpdate": "NO ACTION", "columns": [ "authorServerId", "tuskyAccountId" ], "referencedColumns": [ "serverId", "tuskyAccountId" ] } ] }, { "tableName": "TimelineAccountEntity", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `tuskyAccountId` INTEGER NOT NULL, `localUsername` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `url` TEXT NOT NULL, `avatar` TEXT NOT NULL, `note` TEXT NOT NULL DEFAULT '', `emojis` TEXT NOT NULL, `bot` INTEGER NOT NULL, PRIMARY KEY(`serverId`, `tuskyAccountId`))", "fields": [ { "fieldPath": "serverId", "columnName": "serverId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "tuskyAccountId", "columnName": "tuskyAccountId", "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": "note", "columnName": "note", "affinity": "TEXT", "notNull": true, "defaultValue": "''" }, { "fieldPath": "emojis", "columnName": "emojis", "affinity": "TEXT", "notNull": true }, { "fieldPath": "bot", "columnName": "bot", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "serverId", "tuskyAccountId" ] }, "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": [] }, { "tableName": "NotificationEntity", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tuskyAccountId` INTEGER NOT NULL, `type` TEXT, `id` TEXT NOT NULL, `accountId` TEXT, `statusId` TEXT, `reportId` TEXT, `event` TEXT, `moderationWarning` TEXT, `loading` INTEGER NOT NULL, PRIMARY KEY(`id`, `tuskyAccountId`), FOREIGN KEY(`accountId`, `tuskyAccountId`) REFERENCES `TimelineAccountEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION , FOREIGN KEY(`statusId`, `tuskyAccountId`) REFERENCES `TimelineStatusEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION , FOREIGN KEY(`reportId`, `tuskyAccountId`) REFERENCES `NotificationReportEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", "fields": [ { "fieldPath": "tuskyAccountId", "columnName": "tuskyAccountId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "type", "columnName": "type", "affinity": "TEXT", "notNull": false }, { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "accountId", "columnName": "accountId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "statusId", "columnName": "statusId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "reportId", "columnName": "reportId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "event", "columnName": "event", "affinity": "TEXT", "notNull": false }, { "fieldPath": "moderationWarning", "columnName": "moderationWarning", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loading", "columnName": "loading", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id", "tuskyAccountId" ] }, "indices": [ { "name": "index_NotificationEntity_accountId_tuskyAccountId", "unique": false, "columnNames": [ "accountId", "tuskyAccountId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_accountId_tuskyAccountId` ON `${TABLE_NAME}` (`accountId`, `tuskyAccountId`)" }, { "name": "index_NotificationEntity_statusId_tuskyAccountId", "unique": false, "columnNames": [ "statusId", "tuskyAccountId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_statusId_tuskyAccountId` ON `${TABLE_NAME}` (`statusId`, `tuskyAccountId`)" }, { "name": "index_NotificationEntity_reportId_tuskyAccountId", "unique": false, "columnNames": [ "reportId", "tuskyAccountId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_reportId_tuskyAccountId` ON `${TABLE_NAME}` (`reportId`, `tuskyAccountId`)" } ], "foreignKeys": [ { "table": "TimelineAccountEntity", "onDelete": "NO ACTION", "onUpdate": "NO ACTION", "columns": [ "accountId", "tuskyAccountId" ], "referencedColumns": [ "serverId", "tuskyAccountId" ] }, { "table": "TimelineStatusEntity", "onDelete": "NO ACTION", "onUpdate": "NO ACTION", "columns": [ "statusId", "tuskyAccountId" ], "referencedColumns": [ "serverId", "tuskyAccountId" ] }, { "table": "NotificationReportEntity", "onDelete": "NO ACTION", "onUpdate": "NO ACTION", "columns": [ "reportId", "tuskyAccountId" ], "referencedColumns": [ "serverId", "tuskyAccountId" ] } ] }, { "tableName": "NotificationReportEntity", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tuskyAccountId` INTEGER NOT NULL, `serverId` TEXT NOT NULL, `category` TEXT NOT NULL, `statusIds` TEXT, `createdAt` INTEGER NOT NULL, `targetAccountId` TEXT, PRIMARY KEY(`serverId`, `tuskyAccountId`), FOREIGN KEY(`targetAccountId`, `tuskyAccountId`) REFERENCES `TimelineAccountEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", "fields": [ { "fieldPath": "tuskyAccountId", "columnName": "tuskyAccountId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "serverId", "columnName": "serverId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "category", "columnName": "category", "affinity": "TEXT", "notNull": true }, { "fieldPath": "statusIds", "columnName": "statusIds", "affinity": "TEXT", "notNull": false }, { "fieldPath": "createdAt", "columnName": "createdAt", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "targetAccountId", "columnName": "targetAccountId", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "serverId", "tuskyAccountId" ] }, "indices": [ { "name": "index_NotificationReportEntity_targetAccountId_tuskyAccountId", "unique": false, "columnNames": [ "targetAccountId", "tuskyAccountId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_NotificationReportEntity_targetAccountId_tuskyAccountId` ON `${TABLE_NAME}` (`targetAccountId`, `tuskyAccountId`)" } ], "foreignKeys": [ { "table": "TimelineAccountEntity", "onDelete": "NO ACTION", "onUpdate": "NO ACTION", "columns": [ "targetAccountId", "tuskyAccountId" ], "referencedColumns": [ "serverId", "tuskyAccountId" ] } ] }, { "tableName": "HomeTimelineEntity", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tuskyAccountId` INTEGER NOT NULL, `id` TEXT NOT NULL, `statusId` TEXT, `reblogAccountId` TEXT, `loading` INTEGER NOT NULL, PRIMARY KEY(`id`, `tuskyAccountId`), FOREIGN KEY(`statusId`, `tuskyAccountId`) REFERENCES `TimelineStatusEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION , FOREIGN KEY(`reblogAccountId`, `tuskyAccountId`) REFERENCES `TimelineAccountEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION )", "fields": [ { "fieldPath": "tuskyAccountId", "columnName": "tuskyAccountId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "statusId", "columnName": "statusId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "reblogAccountId", "columnName": "reblogAccountId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "loading", "columnName": "loading", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id", "tuskyAccountId" ] }, "indices": [ { "name": "index_HomeTimelineEntity_statusId_tuskyAccountId", "unique": false, "columnNames": [ "statusId", "tuskyAccountId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_HomeTimelineEntity_statusId_tuskyAccountId` ON `${TABLE_NAME}` (`statusId`, `tuskyAccountId`)" }, { "name": "index_HomeTimelineEntity_reblogAccountId_tuskyAccountId", "unique": false, "columnNames": [ "reblogAccountId", "tuskyAccountId" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_HomeTimelineEntity_reblogAccountId_tuskyAccountId` ON `${TABLE_NAME}` (`reblogAccountId`, `tuskyAccountId`)" } ], "foreignKeys": [ { "table": "TimelineStatusEntity", "onDelete": "NO ACTION", "onUpdate": "NO ACTION", "columns": [ "statusId", "tuskyAccountId" ], "referencedColumns": [ "serverId", "tuskyAccountId" ] }, { "table": "TimelineAccountEntity", "onDelete": "NO ACTION", "onUpdate": "NO ACTION", "columns": [ "reblogAccountId", "tuskyAccountId" ], "referencedColumns": [ "serverId", "tuskyAccountId" ] } ] }, { "tableName": "NotificationPolicyEntity", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tuskyAccountId` INTEGER NOT NULL, `pendingRequestsCount` INTEGER NOT NULL, `pendingNotificationsCount` INTEGER NOT NULL, PRIMARY KEY(`tuskyAccountId`))", "fields": [ { "fieldPath": "tuskyAccountId", "columnName": "tuskyAccountId", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "pendingRequestsCount", "columnName": "pendingRequestsCount", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "pendingNotificationsCount", "columnName": "pendingNotificationsCount", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "tuskyAccountId" ] }, "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, 'f1ac7b67aa0a9a279f7f35f5817b6a17')" ] } } ================================================ FILE: app/src/green/res/values/flavor-colors.xml ================================================ @color/tusky_green #097b44 #39ff9e ================================================ FILE: app/src/main/AndroidManifest.xml ================================================ ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/AboutActivity.kt ================================================ package com.keylesspalace.tusky import android.content.Intent import android.os.Build import android.os.Bundle import android.text.SpannableString import android.text.SpannableStringBuilder import android.text.method.LinkMovementMethod import android.text.style.URLSpan import android.text.util.Linkify import android.widget.TextView import androidx.annotation.StringRes import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat.Type.systemBars import androidx.core.view.updatePadding import androidx.lifecycle.lifecycleScope import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository import com.keylesspalace.tusky.databinding.ActivityAboutBinding import com.keylesspalace.tusky.util.NoUnderlineURLSpan import com.keylesspalace.tusky.util.copyToClipboard import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject import kotlinx.coroutines.launch @AndroidEntryPoint class AboutActivity : BottomSheetActivity() { @Inject lateinit var instanceInfoRepository: InstanceInfoRepository override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val binding = ActivityAboutBinding.inflate(layoutInflater) setContentView(binding.root) setSupportActionBar(binding.includedToolbar.toolbar) supportActionBar?.run { setDisplayHomeAsUpEnabled(true) setDisplayShowHomeEnabled(true) } setTitle(R.string.about_title_activity) ViewCompat.setOnApplyWindowInsetsListener(binding.scrollView) { scrollView, insets -> val systemBarInsets = insets.getInsets(systemBars()) scrollView.updatePadding(bottom = systemBarInsets.bottom) insets.inset(0, 0, 0, systemBarInsets.bottom) } binding.versionTextView.text = getString(R.string.about_app_version, getString(R.string.app_name), BuildConfig.VERSION_NAME) binding.deviceInfo.text = getString( R.string.about_device_info, Build.MANUFACTURER, Build.MODEL, Build.VERSION.RELEASE, Build.VERSION.SDK_INT ) lifecycleScope.launch { accountManager.activeAccount?.let { account -> 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.tuskyProfileButton.setOnClickListener { viewUrl(BuildConfig.SUPPORT_ACCOUNT_URL) } binding.aboutLicensesButton.setOnClickListener { startActivityWithSlideInAnimation(Intent(this, LicenseActivity::class.java)) } binding.copyDeviceInfo.setOnClickListener { copyToClipboard( "${binding.versionTextView.text}\n\nDevice:\n\n${binding.deviceInfo.text}\n\nAccount:\n\n${binding.accountInfo.text}", getString(R.string.about_copied), "Tusky version information", ) } } } private fun TextView.setClickableTextWithoutUnderlines(@StringRes textId: Int) { val text = SpannableString(context.getText(textId)) Linkify.addLinks(text, Linkify.WEB_URLS) val builder = SpannableStringBuilder(text) val urlSpans = text.getSpans(0, text.length, URLSpan::class.java) for (span in urlSpans) { val start = builder.getSpanStart(span) val end = builder.getSpanEnd(span) val flags = builder.getSpanFlags(span) val customSpan = NoUnderlineURLSpan(span.url) builder.removeSpan(span) builder.setSpan(customSpan, start, end, flags) } setText(builder) linksClickable = true movementMethod = LinkMovementMethod.getInstance() } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/AccountsInListFragment.kt ================================================ /* 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 import android.content.SharedPreferences import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.LinearLayout import androidx.appcompat.widget.SearchView 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.keylesspalace.tusky.databinding.FragmentAccountsInListBinding import com.keylesspalace.tusky.databinding.ItemFollowRequestBinding import com.keylesspalace.tusky.entity.TimelineAccount import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.util.BindingHolder 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.unsafeLazy import com.keylesspalace.tusky.util.visible import com.keylesspalace.tusky.viewmodel.AccountsInListViewModel import com.keylesspalace.tusky.viewmodel.State import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject import kotlinx.coroutines.launch private typealias AccountInfo = Pair @AndroidEntryPoint class AccountsInListFragment : DialogFragment() { @Inject lateinit var preferences: SharedPreferences private val viewModel: AccountsInListViewModel by viewModels() private lateinit var binding: FragmentAccountsInListBinding private lateinit var listId: String private lateinit var listName: String private val radius by unsafeLazy { resources.getDimensionPixelSize(R.dimen.avatar_radius_48dp) } private val animateAvatar by unsafeLazy { preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false) } private val animateEmojis by unsafeLazy { preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false) } private val showBotOverlay by unsafeLazy { preferences.getBoolean(PrefKeys.SHOW_BOT_OVERLAY, true) } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val args = requireArguments() listId = args.getString(LIST_ID_ARG)!! listName = args.getString(LIST_NAME_ARG)!! viewModel.load(listId) } override fun getTheme() = R.style.TuskyDialogFragment override fun onStart() { super.onStart() dialog?.apply { // Stretch dialog to the window window?.setLayout( LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.MATCH_PARENT ) } } override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { binding = FragmentAccountsInListBinding.inflate(layoutInflater) val adapter = Adapter() val searchAdapter = SearchAdapter() binding.accountsRecycler.layoutManager = LinearLayoutManager(binding.root.context) binding.accountsRecycler.adapter = adapter binding.accountsSearchRecycler.layoutManager = LinearLayoutManager(binding.root.context) binding.accountsSearchRecycler.adapter = searchAdapter lifecycleScope.launch { viewModel.state.collect { state -> adapter.submitList(state.accounts.getOrDefault(emptyList())) state.accounts.fold( onSuccess = { binding.messageView.hide() }, onFailure = { handleError(it) } ) setupSearchView(searchAdapter, state) } } binding.searchView.isSubmitButtonEnabled = true binding.searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener { override fun onQueryTextSubmit(query: String?): Boolean { viewModel.search(query.orEmpty()) return true } override fun onQueryTextChange(newText: String?): Boolean { // Close event is not sent so we use this instead if (newText.isNullOrBlank()) { viewModel.search("") } return true } }) return binding.root } private fun setupSearchView(searchAdapter: SearchAdapter, state: State) { if (state.searchResult == null) { searchAdapter.submitList(listOf()) binding.accountsSearchRecycler.hide() binding.accountsRecycler.show() } else { val listAccounts = state.accounts.getOrDefault(emptyList()) val newList = state.searchResult.map { acc -> acc to listAccounts.contains(acc) } searchAdapter.submitList(newList) binding.accountsSearchRecycler.show() binding.accountsRecycler.hide() } } private fun handleError(error: Throwable) { binding.messageView.show() binding.messageView.setup(error) { _: View -> binding.messageView.hide() viewModel.load(listId) } } private fun onRemoveFromList(accountId: String) { viewModel.deleteAccountFromList(listId, accountId) } private fun onAddToList(account: TimelineAccount) { viewModel.addAccountToList(listId, account) } private object AccountDiffer : DiffUtil.ItemCallback() { override fun areItemsTheSame(oldItem: TimelineAccount, newItem: TimelineAccount): Boolean { return oldItem.id == newItem.id } override fun areContentsTheSame( oldItem: TimelineAccount, newItem: TimelineAccount ): Boolean { return oldItem == newItem } } inner class Adapter : ListAdapter>( AccountDiffer ) { 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() binding.acceptButton.hide() binding.rejectButton.setOnClickListener { onRemoveFromList(getItem(holder.bindingAdapterPosition).id) } binding.rejectButton.contentDescription = binding.root.context.getString(R.string.action_remove_from_list) return holder } 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 holder.binding.avatarBadge.visible(showBotOverlay && account.bot) loadAvatar(account.avatar, holder.binding.avatar, radius, animateAvatar) } } private object SearchDiffer : DiffUtil.ItemCallback() { override fun areItemsTheSame(oldItem: AccountInfo, newItem: AccountInfo): Boolean { return oldItem.first.id == newItem.first.id } override fun areContentsTheSame(oldItem: AccountInfo, newItem: AccountInfo): Boolean { return oldItem == newItem } } inner class SearchAdapter : ListAdapter>( SearchDiffer ) { 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() binding.acceptButton.hide() binding.rejectButton.setOnClickListener { val (account, inAList) = getItem(holder.bindingAdapterPosition) if (inAList) { onRemoveFromList(account.id) } else { onAddToList(account) } } return holder } 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) holder.binding.usernameTextView.text = account.username holder.binding.avatarBadge.visible(showBotOverlay && account.bot) loadAvatar(account.avatar, holder.binding.avatar, radius, animateAvatar) holder.binding.rejectButton.apply { contentDescription = if (inAList) { setImageResource(R.drawable.ic_close_24dp) getString(R.string.action_remove_from_list) } else { setImageResource(R.drawable.ic_add_24dp) getString(R.string.action_add_to_list) } } } } companion object { private const val LIST_ID_ARG = "listId" private const val LIST_NAME_ARG = "listName" @JvmStatic fun newInstance(listId: String, listName: String): AccountsInListFragment { val args = Bundle().apply { putString(LIST_ID_ARG, listId) putString(LIST_NAME_ARG, listName) } return AccountsInListFragment().apply { arguments = args } } } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/BaseActivity.kt ================================================ /* 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 import android.app.ActivityManager.TaskDescription import android.content.Context import android.content.DialogInterface import android.content.Intent import android.content.SharedPreferences import android.graphics.BitmapFactory import android.graphics.Color import android.os.Build import android.os.Bundle import android.view.MenuItem import android.view.View import android.view.ViewGroup import androidx.annotation.StyleRes import androidx.appcompat.app.AppCompatActivity import androidx.core.graphics.Insets import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsCompat.Type.displayCutout import androidx.core.view.WindowInsetsCompat.Type.systemBars import androidx.core.view.updateLayoutParams import androidx.core.view.updatePadding import androidx.lifecycle.ViewModelProvider.Factory import androidx.lifecycle.lifecycleScope import com.google.android.material.R as materialR import com.google.android.material.color.MaterialColors import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.keylesspalace.tusky.MainActivity.Companion.redirectIntent import com.keylesspalace.tusky.adapter.AccountSelectionAdapter import com.keylesspalace.tusky.components.login.LoginActivity import com.keylesspalace.tusky.components.login.LoginActivity.Companion.getIntent import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.entity.AccountEntity import com.keylesspalace.tusky.di.PreferencesEntryPoint import com.keylesspalace.tusky.interfaces.AccountSelectionListener import com.keylesspalace.tusky.settings.AppTheme import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.util.ActivityConstants import com.keylesspalace.tusky.util.isBlack import com.keylesspalace.tusky.util.overrideActivityTransitionCompat import dagger.hilt.EntryPoints import javax.inject.Inject import kotlinx.coroutines.launch /** * All activities inheriting from BaseActivity must be annotated with @AndroidEntryPoint */ abstract class BaseActivity : AppCompatActivity() { @Inject lateinit var accountManager: AccountManager @Inject lateinit var preferences: SharedPreferences /** * Allows overriding the default ViewModelProvider.Factory for testing purposes. */ var viewModelProviderFactory: Factory? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) if (activityTransitionWasRequested()) { overrideActivityTransitionCompat( ActivityConstants.OVERRIDE_TRANSITION_OPEN, R.anim.activity_open_enter, R.anim.activity_open_exit ) overrideActivityTransitionCompat( ActivityConstants.OVERRIDE_TRANSITION_CLOSE, R.anim.activity_close_enter, R.anim.activity_close_exit ) } /* 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. */ val theme = preferences.getString(PrefKeys.APP_THEME, AppTheme.DEFAULT.value) if (isBlack(resources.configuration, theme)) { setTheme(R.style.TuskyBlackTheme) } else if (this is MainActivity) { // Replace the SplashTheme of MainActivity setTheme(R.style.TuskyTheme) } /* Set the taskdescription programmatically - by default the primary color is used. * On newer Android versions (or launchers?) this doesn't seem to have an effect. */ val appName = getString(R.string.app_name) val recentsBackgroundColor = MaterialColors.getColor( this, materialR.attr.colorSurface, Color.BLACK ) val taskDescription = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { TaskDescription.Builder() .setLabel(appName) .setIcon(R.mipmap.ic_launcher) .setPrimaryColor(recentsBackgroundColor) .build() } else { val appIcon = BitmapFactory.decodeResource(resources, R.mipmap.ic_launcher) @Suppress("DEPRECATION") TaskDescription(appName, appIcon, recentsBackgroundColor) } setTaskDescription(taskDescription) val style = textStyle(preferences.getString(PrefKeys.STATUS_TEXT_SIZE, "medium")) getTheme().applyStyle(style, true) if (requiresLogin()) { redirectIfNotLoggedIn() } } override fun onPostCreate(savedInstanceState: Bundle?) { super.onPostCreate(savedInstanceState) // currently only ComposeActivity on tablets is floating if (!window.isFloating) { window.decorView.setBackgroundColor(Color.BLACK) val contentView: View = findViewById(android.R.id.content) contentView.setBackgroundColor(MaterialColors.getColor(contentView, android.R.attr.colorBackground)) // handle left/right insets. This is relevant for edge-to-edge mode in landscape orientation ViewCompat.setOnApplyWindowInsetsListener(contentView) { _, insets -> val systemBarInsets = insets.getInsets(systemBars()) val displayCutoutInsets = insets.getInsets(displayCutout()) // use padding for system bar insets so they get our background color and margin for cutout insets to turn them black contentView.updatePadding(left = systemBarInsets.left, right = systemBarInsets.right) contentView.updateLayoutParams { leftMargin = displayCutoutInsets.left rightMargin = displayCutoutInsets.right } WindowInsetsCompat.Builder(insets) .setInsets(systemBars(), Insets.of(0, systemBarInsets.top, 0, systemBarInsets.bottom)) .setInsets(displayCutout(), Insets.of(0, displayCutoutInsets.top, 0, displayCutoutInsets.bottom)) .build() } } } private fun activityTransitionWasRequested(): Boolean { return intent.getBooleanExtra(OPEN_WITH_SLIDE_IN, false) } override fun attachBaseContext(newBase: Context) { // injected preferences not yet available at this point of the lifecycle val preferences = EntryPoints.get(newBase.applicationContext, PreferencesEntryPoint::class.java) .preferences() // Scale text in the UI from PrefKeys.UI_TEXT_SCALE_RATIO val uiScaleRatio = preferences.getFloat(PrefKeys.UI_TEXT_SCALE_RATIO, 100f) val configuration = newBase.resources.configuration // Adjust `fontScale` in the configuration. // // You can't repeatedly adjust the `fontScale` in `newBase` because that will contain the // result of previous adjustments. E.g., going from 100% to 80% to 100% does not return // you to the original 100%, it leaves it at 80%. // // Instead, calculate the new scale from the application context. This is unaffected by // changes to the base context. It does contain contain any changes to the font scale from // "Settings > Display > Font size" in the device settings, so scaling performed here // is in addition to any scaling in the device settings. val appConfiguration = newBase.applicationContext.resources.configuration // This only adjusts the fonts, anything measured in `dp` is unaffected by this. // You can try to adjust `densityDpi` as shown in the commented out code below. This // works, to a point. However, dialogs do not react well to this. Beyond a certain // scale (~ 120%) the right hand edge of the dialog will clip off the right of the // screen. // // So for now, just adjust the font scale // // val displayMetrics = appContext.resources.displayMetrics // configuration.densityDpi = ((displayMetrics.densityDpi * uiScaleRatio).toInt()) configuration.fontScale = appConfiguration.fontScale * uiScaleRatio / 100f val fontScaleContext = newBase.createConfigurationContext(configuration) super.attachBaseContext(fontScaleContext) } override val defaultViewModelProviderFactory: Factory get() = viewModelProviderFactory ?: super.defaultViewModelProviderFactory protected open fun requiresLogin(): Boolean = true override fun onOptionsItemSelected(item: MenuItem): Boolean { if (item.itemId == android.R.id.home) { onBackPressedDispatcher.onBackPressed() return true } return super.onOptionsItemSelected(item) } private fun redirectIfNotLoggedIn() { val currentAccounts = accountManager.accounts if (currentAccounts.isEmpty()) { val intent = getIntent(this@BaseActivity, LoginActivity.MODE_DEFAULT) intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK) startActivity(intent) finish() } } fun showAccountChooserDialog( dialogTitle: CharSequence?, showActiveAccount: Boolean, listener: AccountSelectionListener ) { val accounts = accountManager.accounts.toMutableList() val activeAccount = accountManager.activeAccount when (accounts.size) { 1 -> { listener.onAccountSelected(activeAccount!!) return } 2 -> if (!showActiveAccount) { for (account in accounts) { if (activeAccount !== account) { listener.onAccountSelected(account) return } } } } if (!showActiveAccount && activeAccount != null) { accounts.remove(activeAccount) } val adapter = AccountSelectionAdapter( this, preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false), preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false) ) adapter.addAll(accounts) MaterialAlertDialogBuilder(this) .setTitle(dialogTitle) .setAdapter(adapter) { _: DialogInterface?, index: Int -> listener.onAccountSelected(accounts[index]) } .show() } val openAsText: String? get() { val accounts = accountManager.accounts when (accounts.size) { 0, 1 -> return null 2 -> { for (account in accounts) { if (account !== accountManager.activeAccount) { return getString(R.string.action_open_as, account.fullName) } } return null } else -> return getString(R.string.action_open_as, "…") } } fun openAsAccount(url: String, account: AccountEntity) { lifecycleScope.launch { accountManager.setActiveAccount(account.id) val intent = redirectIntent(this@BaseActivity, account.id, url) startActivity(intent) finish() } } companion object { const val OPEN_WITH_SLIDE_IN = "OPEN_WITH_SLIDE_IN" @StyleRes private fun textStyle(name: String?): Int = when (name) { "smallest" -> R.style.TextSizeSmallest "small" -> R.style.TextSizeSmall "medium" -> R.style.TextSizeMedium "large" -> R.style.TextSizeLarge "largest" -> R.style.TextSizeLargest else -> R.style.TextSizeMedium } } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/BottomSheetActivity.kt ================================================ /* Copyright 2018 Conny Duck * * 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 import android.content.Context import android.content.Intent import android.os.Bundle import android.view.View import android.widget.LinearLayout import android.widget.Toast import androidx.annotation.VisibleForTesting import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat.Type.ime import androidx.core.view.WindowInsetsCompat.Type.systemBars import androidx.core.view.updatePadding 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 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 * Subclasses must have a bottom sheet with Id item_status_bottom_sheet in their layout hierarchy */ abstract class BottomSheetActivity : BaseActivity() { lateinit var bottomSheet: BottomSheetBehavior var searchUrl: String? = null @Inject lateinit var mastodonApi: MastodonApi override fun onPostCreate(savedInstanceState: Bundle?) { super.onPostCreate(savedInstanceState) val bottomSheetLayout: LinearLayout = findViewById(R.id.item_status_bottom_sheet) bottomSheet = BottomSheetBehavior.from(bottomSheetLayout) bottomSheet.state = BottomSheetBehavior.STATE_HIDDEN bottomSheet.addBottomSheetCallback(object : BottomSheetBehavior.BottomSheetCallback() { override fun onStateChanged(bottomSheet: View, newState: Int) { if (newState == BottomSheetBehavior.STATE_HIDDEN) { cancelActiveSearch() } } override fun onSlide(bottomSheet: View, slideOffset: Float) {} }) ViewCompat.setOnApplyWindowInsetsListener(bottomSheetLayout) { _, insets -> val systemBarsInsets = insets.getInsets(systemBars() or ime()) val bottomInsets = systemBarsInsets.bottom bottomSheetLayout.updatePadding(bottom = bottomInsets) insets } } open fun viewUrl( url: String, lookupFallbackBehavior: PostLookupFallbackBehavior = PostLookupFallbackBehavior.OPEN_IN_BROWSER ) { if (!looksLikeMastodonUrl(url)) { openLink(url) return } lifecycleScope.launch { mastodonApi.search( query = url, resolve = true ).fold( onSuccess = { (accounts, statuses) -> if (getCancelSearchRequested(url)) { return@launch } onEndSearch(url) if (statuses.isNotEmpty()) { viewThread(statuses[0].id, statuses[0].url) return@launch } 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@launch } performUrlFallbackAction(url, lookupFallbackBehavior) }, onFailure = { if (!getCancelSearchRequested(url)) { onEndSearch(url) performUrlFallbackAction(url, lookupFallbackBehavior) } } ) } onBeginSearch(url) } open fun viewThread(statusId: String, url: String?) { if (!isSearching()) { val intent = Intent(this, ViewThreadActivity::class.java) intent.putExtra("id", statusId) intent.putExtra("url", url) startActivityWithSlideInAnimation(intent) } } open fun viewAccount(id: String) { val intent = AccountActivity.getIntent(this, id) startActivityWithSlideInAnimation(intent) } 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() } } @VisibleForTesting fun onBeginSearch(url: String) { searchUrl = url showQuerySheet() } @VisibleForTesting fun getCancelSearchRequested(url: String): Boolean { return url != searchUrl } @VisibleForTesting fun isSearching(): Boolean { return searchUrl != null } @VisibleForTesting fun onEndSearch(url: String?) { if (url == searchUrl) { // Don't clear query if there's no match, // since we might just now be getting the response for a canceled search searchUrl = null hideQuerySheet() } } @VisibleForTesting fun cancelActiveSearch() { if (isSearching()) { onEndSearch(searchUrl) } } @VisibleForTesting(otherwise = VisibleForTesting.PROTECTED) open fun openLink(url: String) { (this as Context).openLink(url) } private fun showQuerySheet() { bottomSheet.state = BottomSheetBehavior.STATE_COLLAPSED } private fun hideQuerySheet() { bottomSheet.state = BottomSheetBehavior.STATE_HIDDEN } } enum class PostLookupFallbackBehavior { OPEN_IN_BROWSER, DISPLAY_ERROR } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/EditProfileActivity.kt ================================================ /* 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 import android.content.Intent import android.graphics.Bitmap import android.net.Uri import android.os.Bundle import android.util.Log 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.graphics.Insets import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsCompat.Type.ime import androidx.core.view.WindowInsetsCompat.Type.systemBars import androidx.core.view.isVisible import androidx.core.view.updatePadding import androidx.core.widget.doAfterTextChanged import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.LinearLayoutManager import com.bumptech.glide.Glide import com.bumptech.glide.load.engine.DiskCacheStrategy import com.bumptech.glide.load.resource.bitmap.FitCenter import com.bumptech.glide.load.resource.bitmap.RoundedCorners import com.canhub.cropper.CropImage import com.canhub.cropper.CropImageContract import com.canhub.cropper.options import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.snackbar.Snackbar import com.keylesspalace.tusky.adapter.AccountFieldEditAdapter import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository import com.keylesspalace.tusky.databinding.ActivityEditProfileBinding 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 dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch @AndroidEntryPoint class EditProfileActivity : BaseActivity() { companion object { const val AVATAR_SIZE = 400 const val HEADER_WIDTH = 1500 const val HEADER_HEIGHT = 500 } private val viewModel: EditProfileViewModel by viewModels() private val binding by viewBinding(ActivityEditProfileBinding::inflate) private val accountFieldEditAdapter = AccountFieldEditAdapter() private var maxAccountFields = InstanceInfoRepository.DEFAULT_MAX_ACCOUNT_FIELDS private enum class PickType { AVATAR, HEADER } private val cropImage = registerForActivityResult(CropImageContract()) { result -> if (result is CropImage.CancelledResult) { return@registerForActivityResult } if (!result.isSuccessful) { return@registerForActivityResult onPickFailure(result.error) } if (result.uriContent == viewModel.getAvatarUri()) { viewModel.newAvatarPicked() } else { viewModel.newHeaderPicked() } } 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) setContentView(binding.root) setSupportActionBar(binding.includedToolbar.toolbar) supportActionBar?.run { setTitle(R.string.title_edit_profile) setDisplayHomeAsUpEnabled(true) setDisplayShowHomeEnabled(true) } ViewCompat.setOnApplyWindowInsetsListener(binding.root) { scrollView, insets -> // if keyboard visible -> set inset on the root to push the scrollview up // if keyboard hidden -> set inset on the scrollview so last element does not get obscured by navigation bar // scrollview has clipToPadding set to false so it draws behind the navigation bar in edge-to-edge mode val imeInsets = insets.getInsets(ime()) val systemBarsInsets = insets.getInsets(systemBars()) binding.root.updatePadding(bottom = imeInsets.bottom) val scrollViewPadding = if (imeInsets.bottom == 0) { systemBarsInsets.bottom } else { 0 } binding.scrollView.updatePadding(bottom = scrollViewPadding) WindowInsetsCompat.Builder(insets) .setInsets(ime(), Insets.of(imeInsets.left, imeInsets.top, imeInsets.right, 0)) .setInsets(systemBars(), Insets.of(systemBarsInsets.left, systemBarsInsets.top, imeInsets.right, 0)) .build() } binding.avatarButton.setOnClickListener { pickMedia(PickType.AVATAR) } binding.headerButton.setOnClickListener { pickMedia(PickType.HEADER) } binding.fieldList.layoutManager = LinearLayoutManager(this) binding.fieldList.adapter = accountFieldEditAdapter binding.addFieldButton.setOnClickListener { accountFieldEditAdapter.addField() if (accountFieldEditAdapter.itemCount >= maxAccountFields) { it.isVisible = false } binding.scrollView.post { binding.scrollView.smoothScrollTo(0, it.bottom) } } viewModel.obtainProfile() 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 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@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 -> { } } } } lifecycleScope.launch { viewModel.instanceData.collect { instanceInfo -> maxAccountFields = instanceInfo.maxFields accountFieldEditAdapter.setFieldLimits( instanceInfo.maxFieldNameLength, instanceInfo.maxFieldValueLength ) binding.addFieldButton.isVisible = accountFieldEditAdapter.itemCount < maxAccountFields } } observeImage(viewModel.avatarData, binding.avatarPreview, true) observeImage(viewModel.headerData, binding.headerPreview, false) 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(currentProfileData) } } private fun observeImage( flow: StateFlow, imageView: ImageView, roundedCorners: Boolean ) { 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) if (roundedCorners) { glide.transform( FitCenter(), RoundedCorners(resources.getDimensionPixelSize(R.dimen.avatar_radius_80dp)) ).into(imageView) } else { glide.into(imageView) } imageView.show() } } } private fun pickMedia(pickType: PickType) { val intent = Intent(Intent.ACTION_GET_CONTENT) intent.addCategory(Intent.CATEGORY_OPENABLE) intent.type = "image/*" when (pickType) { PickType.AVATAR -> { cropImage.launch( options { setRequestedSize(AVATAR_SIZE, AVATAR_SIZE) setAspectRatio(AVATAR_SIZE, AVATAR_SIZE) setImageSource(includeGallery = true, includeCamera = false) setOutputUri(viewModel.getAvatarUri()) setOutputCompressFormat(Bitmap.CompressFormat.PNG) } ) } PickType.HEADER -> { cropImage.launch( options { setRequestedSize(HEADER_WIDTH, HEADER_HEIGHT) setAspectRatio(HEADER_WIDTH, HEADER_HEIGHT) setImageSource(includeGallery = true, includeCamera = false) setOutputUri(viewModel.getHeaderUri()) setOutputCompressFormat(Bitmap.CompressFormat.PNG) } ) } } } override fun onCreateOptionsMenu(menu: Menu): Boolean { menuInflater.inflate(R.menu.edit_profile_toolbar, menu) return super.onCreateOptionsMenu(menu) } override fun onOptionsItemSelected(item: MenuItem): Boolean { when (item.itemId) { R.id.action_save -> { save() return true } } return super.onOptionsItemSelected(item) } private fun save() = viewModel.save(currentProfileData) private fun onSaveFailure(msg: String?) { val errorMsg = msg ?: getString(R.string.error_media_upload_sending) Snackbar.make(binding.avatarButton, errorMsg, Snackbar.LENGTH_LONG).show() binding.saveProgressBar.visibility = View.GONE } 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() } private fun showUnsavedChangesDialog() = lifecycleScope.launch { when (launchSaveDialog()) { AlertDialog.BUTTON_POSITIVE -> save() else -> finish() } } private suspend fun launchSaveDialog() = MaterialAlertDialogBuilder(this) .setMessage(getString(R.string.dialog_save_profile_changes_message)) .create() .await(R.string.action_save, R.string.action_discard) } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/LicenseActivity.kt ================================================ /* Copyright 2018 Conny Duck * * 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 import android.os.Bundle import android.util.Log import android.widget.TextView import androidx.annotation.RawRes import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat.Type.systemBars import androidx.core.view.updatePadding import androidx.lifecycle.lifecycleScope import com.keylesspalace.tusky.databinding.ActivityLicenseBinding import dagger.hilt.android.AndroidEntryPoint import java.io.IOException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import okio.buffer import okio.source @AndroidEntryPoint class LicenseActivity : BaseActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val binding = ActivityLicenseBinding.inflate(layoutInflater) setContentView(binding.root) setSupportActionBar(binding.includedToolbar.toolbar) supportActionBar?.run { setDisplayHomeAsUpEnabled(true) setDisplayShowHomeEnabled(true) } setTitle(R.string.title_licenses) ViewCompat.setOnApplyWindowInsetsListener(binding.scrollView) { scrollView, insets -> val systemBarInsets = insets.getInsets(systemBars()) scrollView.updatePadding(bottom = systemBarInsets.bottom) insets.inset(0, 0, 0, systemBarInsets.bottom) } loadFileIntoTextView(R.raw.apache, binding.licenseApacheTextView) } private fun loadFileIntoTextView(@RawRes fileId: Int, textView: TextView) { lifecycleScope.launch { textView.text = withContext(Dispatchers.IO) { try { resources.openRawResource(fileId).source().buffer().use { it.readUtf8() } } catch (e: IOException) { Log.w("LicenseActivity", e) "" } } } } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/ListsActivity.kt ================================================ /* 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 import android.app.Dialog import android.content.Context import android.content.Intent import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.view.WindowManager import android.widget.PopupMenu import androidx.activity.viewModels import androidx.annotation.StringRes import androidx.core.widget.doOnTextChanged import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.ListAdapter import com.google.android.material.dialog.MaterialAlertDialogBuilder 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.entity.MastoList import com.keylesspalace.tusky.util.BindingHolder import com.keylesspalace.tusky.util.ensureBottomMargin import com.keylesspalace.tusky.util.ensureBottomPadding 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 import com.keylesspalace.tusky.viewmodel.ListsViewModel.Event import com.keylesspalace.tusky.viewmodel.ListsViewModel.LoadingState.ERROR_NETWORK 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 dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch // TODO use the ListSelectionFragment (and/or its adapter or binding) here; but keep the LoadingState from here (?) @AndroidEntryPoint class ListsActivity : BaseActivity() { private val viewModel: ListsViewModel by viewModels() private val binding by viewBinding(ActivityListsBinding::inflate) private val adapter = ListsAdapter() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(binding.root) setSupportActionBar(binding.includedToolbar.toolbar) supportActionBar?.apply { title = getString(R.string.title_lists) setDisplayHomeAsUpEnabled(true) setDisplayShowHomeEnabled(true) } binding.addListButton.ensureBottomMargin() binding.listsRecycler.ensureBottomPadding(fab = true) binding.listsRecycler.adapter = adapter binding.listsRecycler.layoutManager = LinearLayoutManager(this) binding.listsRecycler.addItemDecoration( DividerItemDecoration(this, DividerItemDecoration.VERTICAL) ) binding.swipeRefreshLayout.setOnRefreshListener { viewModel.retryLoading() } lifecycleScope.launch { viewModel.state.collect(this@ListsActivity::update) } viewModel.retryLoading() binding.addListButton.setOnClickListener { showlistNameDialog(null) } lifecycleScope.launch { viewModel.events.collect { event -> when (event) { Event.CREATE_ERROR -> showMessage(R.string.error_create_list) Event.UPDATE_ERROR -> showMessage(R.string.error_rename_list) Event.DELETE_ERROR -> showMessage(R.string.error_delete_list) } } } } private fun showlistNameDialog(list: MastoList?) { var selectedReplyPolicyIndex = MastoList.ReplyPolicy.from(list?.repliesPolicy).ordinal val replyPolicies = resources.getStringArray(R.array.list_reply_policies_display) val binding = DialogListBinding.inflate(layoutInflater).apply { replyPolicyDropDown.setText(replyPolicies[selectedReplyPolicyIndex]) replyPolicyDropDown.setSimpleItems(replyPolicies) replyPolicyDropDown.setOnItemClickListener { _, _, position, _ -> selectedReplyPolicyIndex = position } } val inset = resources.getDimensionPixelSize(R.dimen.dialog_inset) val dialog = MaterialAlertDialogBuilder(this) .setView(binding.root) .setBackgroundInsetTop(inset) .setBackgroundInsetEnd(inset) .setBackgroundInsetBottom(inset) .setBackgroundInsetStart(inset) .setPositiveButton( if (list == null) { R.string.action_create_list } else { R.string.action_rename_list } ) { _, _ -> onPickedDialogName( binding.nameText.text.toString(), list?.id, binding.exclusiveCheckbox.isChecked, MastoList.ReplyPolicy.entries[selectedReplyPolicyIndex].policy ) } .setNegativeButton(android.R.string.cancel, null) .show() // yes, SOFT_INPUT_ADJUST_RESIZE is deprecated, but without it the dropdown can get behind the keyboard dialog.window?.setSoftInputMode( WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE or WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE ) binding.nameText.let { editText -> editText.doOnTextChanged { s, _, _, _ -> dialog.getButton(Dialog.BUTTON_POSITIVE).isEnabled = s?.isNotBlank() == true } editText.setText(list?.title) editText.requestFocus() editText.text?.let { editText.setSelection(it.length) } } list?.let { if (it.exclusive == null) { binding.exclusiveCheckbox.visible(false) } else { binding.exclusiveCheckbox.isChecked = it.exclusive } } } private fun showListDeleteDialog(list: MastoList) { MaterialAlertDialogBuilder(this) .setMessage(getString(R.string.dialog_delete_list_warning, list.title)) .setPositiveButton(R.string.action_delete) { _, _ -> viewModel.deleteList(list.id) } .setNegativeButton(android.R.string.cancel, null) .show() } private fun update(state: ListsViewModel.State) { adapter.submitList(state.lists) binding.progressBar.visible(state.loadingState == LOADING) binding.swipeRefreshLayout.isRefreshing = state.loadingState == LOADING when (state.loadingState) { INITIAL, LOADING -> binding.messageView.hide() ERROR_NETWORK -> { binding.messageView.show() binding.messageView.setup(R.drawable.errorphant_offline, R.string.error_network) { viewModel.retryLoading() } } ERROR_OTHER -> { binding.messageView.show() binding.messageView.setup(R.drawable.errorphant_error, R.string.error_generic) { viewModel.retryLoading() } } LOADED -> if (state.lists.isEmpty()) { binding.messageView.show() binding.messageView.setup( R.drawable.elephant_friend_empty, R.string.message_empty, null ) binding.messageView.showHelp(R.string.help_empty_lists) } else { binding.messageView.hide() } } } private fun showMessage(@StringRes messageId: Int) { Snackbar.make( binding.listsRecycler, messageId, Snackbar.LENGTH_SHORT ).show() } private fun onListSelected(list: MastoList) { startActivityWithSlideInAnimation( StatusListActivity.newListIntent(this, list.id, list.title) ) } private fun openListSettings(list: MastoList) { AccountsInListFragment.newInstance(list.id, list.title).show(supportFragmentManager, null) } private fun renameListDialog(list: MastoList) { showlistNameDialog(list) } private fun onMore(list: MastoList, view: View) { PopupMenu(view.context, view).apply { inflate(R.menu.list_actions) setOnMenuItemClickListener { item -> when (item.itemId) { R.id.list_edit -> openListSettings(list) R.id.list_update -> renameListDialog(list) R.id.list_delete -> showListDeleteDialog(list) else -> return@setOnMenuItemClickListener false } true } show() } } private object ListsDiffer : DiffUtil.ItemCallback() { override fun areItemsTheSame(oldItem: MastoList, newItem: MastoList): Boolean { return oldItem.id == newItem.id } override fun areContentsTheSame(oldItem: MastoList, newItem: MastoList): Boolean { return oldItem == newItem } } private inner class ListsAdapter : ListAdapter>(ListsDiffer) { 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.title holder.binding.moreButton.apply { visible(true) setOnClickListener { onMore(item, holder.binding.moreButton) } } holder.itemView.setOnClickListener { onListSelected(item) } } } private fun onPickedDialogName( name: String, listId: String?, exclusive: Boolean, replyPolicy: String ) { if (listId == null) { viewModel.createNewList(name, exclusive, replyPolicy) } else { viewModel.updateList(listId, name, exclusive, replyPolicy) } } companion object { fun newIntent(context: Context) = Intent(context, ListsActivity::class.java) } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/MainActivity.kt ================================================ /* Copyright 2020 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 import android.Manifest import android.annotation.SuppressLint import android.app.NotificationManager import android.content.Context import android.content.DialogInterface import android.content.Intent import android.content.pm.PackageManager import android.graphics.Bitmap import android.graphics.Color import android.graphics.drawable.Animatable 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 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.view.ViewGroup import android.view.ViewGroup.LayoutParams import android.widget.ImageView import androidx.activity.OnBackPressedCallback import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels import androidx.appcompat.app.AlertDialog import androidx.appcompat.content.res.AppCompatResources import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.core.app.ActivityCompat import androidx.core.content.pm.ShortcutManagerCompat import androidx.core.graphics.drawable.toDrawable import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.view.MenuProvider import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat.Type.systemBars import androidx.core.view.forEach import androidx.core.view.isVisible import androidx.core.view.updateLayoutParams import androidx.core.view.updatePadding import androidx.lifecycle.lifecycleScope import androidx.viewpager2.widget.MarginPageTransformer import com.bumptech.glide.Glide import com.bumptech.glide.load.resource.bitmap.RoundedCorners import com.bumptech.glide.request.target.CustomTarget import com.bumptech.glide.request.target.FixedSizeDrawable import com.bumptech.glide.request.transition.Transition import com.google.android.material.R as materialR import com.google.android.material.color.MaterialColors import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.tabs.TabLayout import com.google.android.material.tabs.TabLayout.OnTabSelectedListener import com.google.android.material.tabs.TabLayoutMediator import com.keylesspalace.tusky.appstore.CacheUpdater import com.keylesspalace.tusky.appstore.EventHub 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.compose.ComposeActivity.Companion.canHandleMimeType import com.keylesspalace.tusky.components.drafts.DraftsActivity import com.keylesspalace.tusky.components.login.LoginActivity import com.keylesspalace.tusky.components.preference.PreferencesActivity import com.keylesspalace.tusky.components.scheduled.ScheduledStatusActivity import com.keylesspalace.tusky.components.search.SearchActivity import com.keylesspalace.tusky.components.systemnotifications.NotificationService import com.keylesspalace.tusky.components.trending.TrendingActivity import com.keylesspalace.tusky.databinding.ActivityMainBinding import com.keylesspalace.tusky.db.DraftsAlert import com.keylesspalace.tusky.db.entity.AccountEntity import com.keylesspalace.tusky.entity.Notification import com.keylesspalace.tusky.interfaces.AccountSelectionListener import com.keylesspalace.tusky.interfaces.ActionButtonActivity import com.keylesspalace.tusky.interfaces.ReselectableFragment 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.emojify import com.keylesspalace.tusky.util.getParcelableExtraCompat 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.viewBinding import com.mikepenz.materialdrawer.holder.BadgeStyle import com.mikepenz.materialdrawer.holder.ColorHolder import com.mikepenz.materialdrawer.holder.StringHolder import com.mikepenz.materialdrawer.model.AbstractDrawerItem import com.mikepenz.materialdrawer.model.DividerDrawerItem import com.mikepenz.materialdrawer.model.PrimaryDrawerItem import com.mikepenz.materialdrawer.model.ProfileDrawerItem import com.mikepenz.materialdrawer.model.ProfileSettingDrawerItem import com.mikepenz.materialdrawer.model.SecondaryDrawerItem import com.mikepenz.materialdrawer.model.interfaces.IProfile import com.mikepenz.materialdrawer.model.interfaces.descriptionRes import com.mikepenz.materialdrawer.model.interfaces.descriptionText import com.mikepenz.materialdrawer.model.interfaces.iconRes import com.mikepenz.materialdrawer.model.interfaces.iconUrl import com.mikepenz.materialdrawer.model.interfaces.nameRes import com.mikepenz.materialdrawer.model.interfaces.nameText import com.mikepenz.materialdrawer.util.AbstractDrawerImageLoader import com.mikepenz.materialdrawer.util.DrawerImageLoader import com.mikepenz.materialdrawer.util.addItems import com.mikepenz.materialdrawer.util.addItemsAtPosition import com.mikepenz.materialdrawer.util.updateBadge import com.mikepenz.materialdrawer.widget.AccountHeaderView import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.migration.OptionalInject import javax.inject.Inject import kotlinx.coroutines.launch @OptionalInject @AndroidEntryPoint class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { @Inject lateinit var eventHub: EventHub @Inject lateinit var notificationService: NotificationService @Inject lateinit var cacheUpdater: CacheUpdater @Inject lateinit var logoutUsecase: LogoutUsecase @Inject lateinit var draftsAlert: DraftsAlert @Inject lateinit var developerToolsUseCase: DeveloperToolsUseCase private val viewModel: MainViewModel by viewModels() private val binding by viewBinding(ActivityMainBinding::inflate) private lateinit var activeAccount: AccountEntity private lateinit var header: AccountHeaderView private var onTabSelectedListener: OnTabSelectedListener? = null /** Mediate between binding.viewPager and the chosen tab layout */ private var tabLayoutMediator: TabLayoutMediator? = null /** 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() { binding.viewPager.currentItem = 0 } } private val requestNotificationPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted -> if (isGranted) { viewModel.setupNotifications(this) } } @SuppressLint("RestrictedApi") override fun onCreate(savedInstanceState: Bundle?) { // Newer Android versions don't need to install the compat Splash Screen // and it can cause theming bugs. if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { installSplashScreen() } super.onCreate(savedInstanceState) // make sure MainActivity doesn't hide other activities when launcher icon is clicked again if (!isTaskRoot && intent.hasCategory(Intent.CATEGORY_LAUNCHER) && intent.action == Intent.ACTION_MAIN ) { finish() return } // will be redirected to LoginActivity by BaseActivity activeAccount = accountManager.activeAccount ?: return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && ActivityCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED ) { requestNotificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) } else { viewModel.setupNotifications(this) } var showNotificationTab = false // check for savedInstanceState in order to not handle intent events more than once if (intent != null && savedInstanceState == null) { showNotificationTab = handleIntent(intent, activeAccount) if (isFinishing) { // handleIntent() finished this activity and started a new one - no need to continue initialization return } } setContentView(binding.root) val bottomBarHeight = if (preferences.getString(PrefKeys.MAIN_NAV_POSITION, "top") == "bottom") { resources.getDimensionPixelSize(R.dimen.bottomAppBarHeight) } else { 0 } val fabMargin = resources.getDimensionPixelSize(R.dimen.fabMargin) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) { ViewCompat.setOnApplyWindowInsetsListener(binding.viewPager) { _, insets -> val systemBarsInsets = insets.getInsets(systemBars()) val bottomInsets = systemBarsInsets.bottom binding.composeButton.updateLayoutParams { bottomMargin = bottomBarHeight + fabMargin + bottomInsets } binding.mainDrawer.recyclerView.updatePadding(bottom = bottomInsets) if (preferences.getString(PrefKeys.MAIN_NAV_POSITION, "top") == "top") { insets } else { binding.viewPager.updatePadding(bottom = bottomBarHeight + bottomInsets) /* BottomAppBar could handle size and insets automatically, but then it gets quite large, so we do it like this instead */ binding.bottomNav.updateLayoutParams { height = bottomBarHeight + bottomInsets } binding.bottomTabLayout.updateLayoutParams { bottomMargin = bottomInsets } insets.inset(0, 0, 0, bottomInsets) } } } else { // don't draw a status bar, the DrawerLayout and the MaterialDrawerLayout have their own // on Vanilla Ice Cream (API 35) and up there is no status bar color because of edge-to-edge mode @Suppress("DEPRECATION") window.statusBarColor = Color.TRANSPARENT binding.composeButton.updateLayoutParams { bottomMargin = bottomBarHeight + fabMargin } binding.viewPager.updatePadding(bottom = bottomBarHeight) } 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) if (hideTopToolbar) { when (preferences.getString(PrefKeys.MAIN_NAV_POSITION, "top")) { "top" -> setSupportActionBar(binding.topNav) "bottom" -> setSupportActionBar(binding.bottomNav) } // this is a bit hacky, but when the mainToolbar is GONE, the toolbar size gets messed up for some reason binding.mainToolbar.layoutParams.height = 0 binding.mainToolbar.visibility = View.INVISIBLE // 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.layoutParams.height = LayoutParams.WRAP_CONTENT binding.mainToolbar.show() } addMenuProvider(this) binding.viewPager.reduceSwipeSensitivity() setupDrawer( savedInstanceState, addSearchButton = hideTopToolbar, addTrendingTagsButton = !activeAccount.tabPreferences.hasTab( TRENDING_TAGS ), addTrendingStatusesButton = !activeAccount.tabPreferences.hasTab( TRENDING_STATUSES ) ) lifecycleScope.launch { viewModel.accounts.collect(::updateProfiles) } lifecycleScope.launch { viewModel.unreadAnnouncementsCount.collect(::updateAnnouncementsBadge) } // Initialise the tab adapter and set to viewpager. Fragments appear to be leaked if the // adapter changes over the life of the viewPager (the adapter, not its contents), so set // the initial list of tabs to empty, and set the full list later in setupTabs(). See // https://github.com/tuskyapp/Tusky/issues/3251 for details. tabAdapter = MainPagerAdapter(emptyList(), this@MainActivity) binding.viewPager.adapter = tabAdapter binding.viewPager.offscreenPageLimit = 2 lifecycleScope.launch { viewModel.tabs.collect(::setupTabs) } if (showNotificationTab) { val position = viewModel.tabs.value.indexOfFirst { it.id == NOTIFICATIONS } if (position != -1) { binding.viewPager.setCurrentItem(position, false) } } lifecycleScope.launch { viewModel.showDirectMessagesBadge.collect { showBadge -> updateDirectMessageBadge(showBadge) } } onBackPressedDispatcher.addCallback(this@MainActivity, onBackPressedCallback) // "Post failed" dialog should display in this activity draftsAlert.observeInContext(this@MainActivity, true) } override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) val showNotificationTab = handleIntent(intent, activeAccount) if (showNotificationTab) { val tabs = activeAccount.tabPreferences val position = tabs.indexOfFirst { it.id == NOTIFICATIONS } if (position != -1) { binding.viewPager.setCurrentItem(position, false) } } } override fun onDestroy() { cacheUpdater.stop() super.onDestroy() } /** Handle an incoming Intent, * @returns true when the intent is coming from an notification and the interface should show the notification tab. */ private fun handleIntent(intent: Intent, activeAccount: AccountEntity): Boolean { 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 Intent Extra TUSKY_ACCOUNT_ID * - from share shortcuts as String 'android.intent.extra.shortcut.ID' */ var tuskyAccountId = intent.getLongExtra(TUSKY_ACCOUNT_ID, -1) if (tuskyAccountId == -1L) { val accountIdString = intent.getStringExtra(ShortcutManagerCompat.EXTRA_SHORTCUT_ID) if (accountIdString != null) { tuskyAccountId = accountIdString.toLong() } } val accountRequested = tuskyAccountId != -1L if (accountRequested && tuskyAccountId != activeAccount.id) { changeAccount(tuskyAccountId, intent) return false } val openDrafts = intent.getBooleanExtra(OPEN_DRAFTS, false) if (canHandleMimeType(intent.type) || intent.hasExtra(COMPOSE_OPTIONS)) { // Sharing to Tusky from an external app if (accountRequested) { // The correct account is already active forwardToComposeActivity(intent) } else { // No account was provided, show the chooser showAccountChooserDialog( getString(R.string.action_share_as), true, object : AccountSelectionListener { override fun onAccountSelected(account: AccountEntity) { val requestedId = account.id if (requestedId == activeAccount.id) { // The correct account is already active forwardToComposeActivity(intent) } else { // A different account was requested, restart the activity intent.putExtra(TUSKY_ACCOUNT_ID, requestedId) changeAccount(requestedId, intent) } } } ) } } else if (openDrafts) { val draftsIntent = DraftsActivity.newIntent(this) startActivity(draftsIntent) } 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(NOTIFICATION_TYPE) == Notification.Type.FollowRequest.name) { val accountListIntent = AccountListActivity.newIntent( this, AccountListActivity.Type.FOLLOW_REQUESTS ) startActivityWithSlideInAnimation(accountListIntent) } else { return true } } return false } private fun updateDirectMessageBadge(showBadge: Boolean) { directMessageTab?.badge?.isVisible = showBadge } override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { menuInflater.inflate(R.menu.activity_main, menu) } 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 -> { startActivity(SearchActivity.getIntent(this@MainActivity)) true } else -> super.onOptionsItemSelected(item) } } override fun dispatchKeyEvent(event: KeyEvent): Boolean { // Allow software back press to be properly dispatched to drawer layout val handled = when (event.action) { KeyEvent.ACTION_DOWN -> binding.mainDrawerLayout.onKeyDown(event.keyCode, event) KeyEvent.ACTION_UP -> binding.mainDrawerLayout.onKeyUp(event.keyCode, event) else -> false } return handled || super.dispatchKeyEvent(event) } override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean { when (keyCode) { KeyEvent.KEYCODE_MENU -> { if (binding.mainDrawerLayout.isOpen) { binding.mainDrawerLayout.close() } else { binding.mainDrawerLayout.open() } return true } KeyEvent.KEYCODE_SEARCH -> { startActivityWithSlideInAnimation(SearchActivity.getIntent(this)) return true } } if (event.isCtrlPressed || event.isShiftPressed) { // FIXME: blackberry keyONE raises SHIFT key event even CTRL IS PRESSED when (keyCode) { KeyEvent.KEYCODE_N -> { // open compose activity by pressing SHIFT + N (or CTRL + N) val composeIntent = Intent(applicationContext, ComposeActivity::class.java) startActivity(composeIntent) return true } } } return super.onKeyDown(keyCode, event) } public override fun onPostCreate(savedInstanceState: Bundle?) { super.onPostCreate(savedInstanceState) if (intent != null) { val redirectUrl = intent.getStringExtra(REDIRECT_URL) if (redirectUrl != null) { viewUrl(redirectUrl, PostLookupFallbackBehavior.DISPLAY_ERROR) } } } private fun forwardToComposeActivity(intent: Intent) { val composeOptions = intent.getParcelableExtraCompat(COMPOSE_OPTIONS) val composeIntent = if (composeOptions != null) { ComposeActivity.startIntent(this, composeOptions) } else { Intent(this, ComposeActivity::class.java).apply { action = intent.action type = intent.type putExtras(intent) } } startActivity(composeIntent) } private fun setupDrawer( savedInstanceState: Bundle?, addSearchButton: Boolean, addTrendingTagsButton: Boolean, addTrendingStatusesButton: Boolean ) { val drawerOpenClickListener = View.OnClickListener { binding.mainDrawerLayout.open() } binding.mainToolbar.setNavigationOnClickListener(drawerOpenClickListener) binding.topNav.setNavigationOnClickListener(drawerOpenClickListener) binding.bottomNav.setNavigationOnClickListener(drawerOpenClickListener) header = AccountHeaderView(this).apply { headerBackgroundScaleType = ImageView.ScaleType.CENTER_CROP currentHiddenInList = true onAccountHeaderListener = { _: View?, profile: IProfile, current: Boolean -> handleProfileClick(profile, current) } addProfile( ProfileSettingDrawerItem().apply { identifier = DRAWER_ITEM_ADD_ACCOUNT nameRes = R.string.add_account_name descriptionRes = R.string.add_account_description iconRes = R.drawable.ic_add_24dp isIconTinted = true }, 0 ) attachToSliderView(binding.mainDrawer) dividerBelowHeader = false closeDrawerOnProfileListClick = true } header.currentProfileName.maxLines = 1 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(PrefKeys.ANIMATE_GIF_AVATARS, false) DrawerImageLoader.init(object : AbstractDrawerImageLoader() { override fun set(imageView: ImageView, uri: Uri, placeholder: Drawable, tag: String?) { if (animateAvatars) { Glide.with(imageView) .load(uri) .placeholder(placeholder) .into(imageView) } else { Glide.with(imageView) .asBitmap() .load(uri) .placeholder(placeholder) .into(imageView) } } override fun cancel(imageView: 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 AppCompatResources.getDrawable(ctx, R.drawable.avatar_default)!! } return super.placeholder(ctx, tag) } }) binding.mainDrawer.apply { refreshMainDrawerItems( addSearchButton = addSearchButton, addTrendingTagsButton = addTrendingTagsButton, addTrendingStatusesButton = addTrendingStatusesButton ) setSavedInstance(savedInstanceState) } } private fun refreshMainDrawerItems( addSearchButton: Boolean, addTrendingTagsButton: Boolean, addTrendingStatusesButton: Boolean ) { binding.mainDrawer.apply { itemAdapter.clear() tintStatusBar = true addItems( primaryDrawerItem { nameRes = R.string.action_edit_profile iconRes = R.drawable.ic_person_24dp onClick = { val intent = Intent(context, EditProfileActivity::class.java) startActivityWithSlideInAnimation(intent) } }, primaryDrawerItem { nameRes = R.string.action_view_favourites isSelectable = false iconRes = R.drawable.ic_star_24dp onClick = { val intent = StatusListActivity.newFavouritesIntent(context) startActivityWithSlideInAnimation(intent) } }, primaryDrawerItem { nameRes = R.string.action_view_bookmarks iconRes = R.drawable.ic_bookmark_24dp onClick = { val intent = StatusListActivity.newBookmarksIntent(context) startActivityWithSlideInAnimation(intent) } }, primaryDrawerItem { nameRes = R.string.action_view_follow_requests iconRes = R.drawable.ic_person_add_24dp_mirrored onClick = { val intent = AccountListActivity.newIntent(context, AccountListActivity.Type.FOLLOW_REQUESTS) startActivityWithSlideInAnimation(intent) } }, primaryDrawerItem { nameRes = R.string.action_lists iconRes = R.drawable.ic_list_alt_24dp onClick = { startActivityWithSlideInAnimation(ListsActivity.newIntent(context)) } }, primaryDrawerItem { nameRes = R.string.action_access_drafts iconRes = R.drawable.ic_edit_document_24dp onClick = { val intent = DraftsActivity.newIntent(context) startActivityWithSlideInAnimation(intent) } }, primaryDrawerItem { nameRes = R.string.action_access_scheduled_posts iconRes = R.drawable.ic_schedule_24dp onClick = { startActivityWithSlideInAnimation(ScheduledStatusActivity.newIntent(context)) } }, primaryDrawerItem { identifier = DRAWER_ITEM_ANNOUNCEMENTS nameRes = R.string.title_announcements iconRes = R.drawable.ic_campaign_24dp onClick = { startActivityWithSlideInAnimation(AnnouncementsActivity.newIntent(context)) } badgeStyle = BadgeStyle().apply { textColor = ColorHolder.fromColor(MaterialColors.getColor(binding.mainDrawer, materialR.attr.colorOnPrimary)) color = ColorHolder.fromColor(MaterialColors.getColor(binding.mainDrawer, materialR.attr.colorPrimary)) } }, DividerDrawerItem(), secondaryDrawerItem { nameRes = R.string.action_view_account_preferences iconRes = R.drawable.ic_manage_accounts_24dp onClick = { val intent = PreferencesActivity.newIntent(context, PreferencesActivity.ACCOUNT_PREFERENCES) startActivityWithSlideInAnimation(intent) } }, secondaryDrawerItem { nameRes = R.string.action_view_preferences iconRes = R.drawable.ic_settings_24dp onClick = { val intent = PreferencesActivity.newIntent(context, PreferencesActivity.GENERAL_PREFERENCES) startActivityWithSlideInAnimation(intent) } }, secondaryDrawerItem { nameRes = R.string.about_title_activity iconRes = R.drawable.ic_info_24dp onClick = { val intent = Intent(context, AboutActivity::class.java) startActivityWithSlideInAnimation(intent) } }, secondaryDrawerItem { nameRes = R.string.action_logout iconRes = R.drawable.ic_logout_24dp onClick = ::logout } ) if (addSearchButton) { binding.mainDrawer.addItemsAtPosition( 4, primaryDrawerItem { nameRes = R.string.action_search iconRes = R.drawable.ic_search_24dp onClick = { startActivityWithSlideInAnimation(SearchActivity.getIntent(context)) } } ) } if (addTrendingTagsButton) { binding.mainDrawer.addItemsAtPosition( 5, primaryDrawerItem { nameRes = R.string.title_public_trending_hashtags iconRes = R.drawable.ic_whatshot_24dp onClick = { startActivityWithSlideInAnimation(TrendingActivity.getIntent(context)) } } ) } if (addTrendingStatusesButton) { binding.mainDrawer.addItemsAtPosition( 6, primaryDrawerItem { nameRes = R.string.title_public_trending_statuses iconRes = R.drawable.ic_local_fire_department_24dp onClick = { startActivityWithSlideInAnimation(StatusListActivity.newTrendingIntent(context)) } } ) } } if (BuildConfig.DEBUG) { // Add a "Developer tools" entry. Code that makes it easier to // set the app state at runtime belongs here, it will never // be exposed to users. binding.mainDrawer.addItems( DividerDrawerItem(), secondaryDrawerItem { nameText = "Developer tools" isEnabled = true iconRes = R.drawable.ic_developer_mode_24dp onClick = { showDeveloperToolsDialog() } } ) } } private fun showDeveloperToolsDialog(): AlertDialog { return MaterialAlertDialogBuilder(this) .setTitle("Developer Tools") .setItems( arrayOf("Create \"Load more\" gap") ) { _, which -> Log.d(TAG, "Developer tools: $which") when (which) { 0 -> { Log.d(TAG, "Creating \"Load more\" gap") lifecycleScope.launch { developerToolsUseCase.createLoadMoreGap( activeAccount.id ) } } } } .show() } override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(binding.mainDrawer.saveInstanceState(outState)) } private fun setupTabs(tabs: List) { val activeTabLayout = if (preferences.getString(PrefKeys.MAIN_NAV_POSITION, "top") == "bottom") { binding.topNav.hide() binding.bottomTabLayout } else { binding.bottomNav.hide() binding.tabLayout } // Save the previous tab so it can be restored later val previousTab = tabAdapter.tabs.getOrNull(binding.viewPager.currentItem) // 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) tabLayoutMediator = TabLayoutMediator(activeTabLayout, binding.viewPager, true) { tab: TabLayout.Tab, position: Int -> tab.icon = AppCompatResources.getDrawable(this@MainActivity, tabs[position].icon) tab.contentDescription = tabs[position].title(this) if (tabs[position].id == DIRECT) { val badge = tab.orCreateBadge badge.isVisible = activeAccount.hasDirectMessageBadge badge.backgroundColor = MaterialColors.getColor(binding.mainDrawer, materialR.attr.colorPrimary) directMessageTab = tab } }.also { it.attach() } updateDirectMessageBadge(viewModel.showDirectMessagesBadge.value) val position = previousTab?.let { tabs.indexOfFirst { it == previousTab } } .takeIf { it != -1 } ?: 0 binding.viewPager.setCurrentItem(position, false) val pageMargin = resources.getDimensionPixelSize(R.dimen.tab_page_margin) binding.viewPager.setPageTransformer(MarginPageTransformer(pageMargin)) val enableSwipeForTabs = preferences.getBoolean(PrefKeys.ENABLE_SWIPE_FOR_TABS, true) binding.viewPager.isUserInputEnabled = enableSwipeForTabs onTabSelectedListener?.let { activeTabLayout.removeOnTabSelectedListener(it) } onTabSelectedListener = object : OnTabSelectedListener { override fun onTabSelected(tab: TabLayout.Tab) { onBackPressedCallback.isEnabled = tab.position > 0 binding.mainToolbar.title = tab.contentDescription if (tab == directMessageTab) { viewModel.dismissDirectMessagesBadge() } } override fun onTabUnselected(tab: TabLayout.Tab) {} override fun onTabReselected(tab: TabLayout.Tab) { val fragment = tabAdapter.getFragment(tab.position) if (fragment is ReselectableFragment) { fragment.onReselect() } } }.also { activeTabLayout.addOnTabSelectedListener(it) } supportActionBar?.title = tabs[position].title(this@MainActivity) binding.mainToolbar.setOnClickListener { ( tabAdapter.getFragment( activeTabLayout.selectedTabPosition ) as? ReselectableFragment )?.onReselect() } } private fun handleProfileClick(profile: IProfile, current: Boolean): Boolean { // open profile when active image was clicked if (current) { val intent = AccountActivity.getIntent(this, activeAccount.accountId) startActivityWithSlideInAnimation(intent) return false } // open LoginActivity to add new account if (profile.identifier == DRAWER_ITEM_ADD_ACCOUNT) { startActivityWithSlideInAnimation( LoginActivity.getIntent(this, LoginActivity.MODE_ADDITIONAL_LOGIN) ) return false } // change Account changeAccount(profile.identifier, null) return false } private fun changeAccount( newSelectedId: Long, forward: Intent?, ) = lifecycleScope.launch { cacheUpdater.stop() accountManager.setActiveAccount(newSelectedId) val intent = Intent(this@MainActivity, MainActivity::class.java) if (forward != null) { intent.type = forward.type intent.action = forward.action intent.putExtras(forward) } intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK startActivity(intent) finish() } private fun logout() { MaterialAlertDialogBuilder(this) .setTitle(R.string.action_logout) .setMessage(getString(R.string.action_logout_confirm, activeAccount.fullName)) .setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int -> binding.appBar.hide() binding.viewPager.hide() binding.progressBar.show() binding.bottomNav.hide() binding.composeButton.hide() lifecycleScope.launch { val otherAccountAvailable = logoutUsecase.logout(activeAccount) val intent = if (otherAccountAvailable) { Intent(this@MainActivity, MainActivity::class.java) } else { LoginActivity.getIntent(this@MainActivity, LoginActivity.MODE_DEFAULT) } startActivity(intent) finish() } } .setNegativeButton(android.R.string.cancel, null) .show() } @SuppressLint("CheckResult") private fun loadDrawerAvatar(avatarUrl: String, showPlaceholder: Boolean = true) { val hideTopToolbar = preferences.getBoolean(PrefKeys.HIDE_TOP_TOOLBAR, false) val animateAvatars = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false) val activeToolbar = if (hideTopToolbar) { val navOnBottom = preferences.getString(PrefKeys.MAIN_NAV_POSITION, "top") == "bottom" if (navOnBottom) { binding.bottomNav } else { binding.topNav } } else { binding.mainToolbar } val navIconSize = resources.getDimensionPixelSize(R.dimen.avatar_toolbar_nav_icon_size) 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) } } override fun onResourceReady( resource: Drawable, transition: Transition? ) { if (resource is Animatable) resource.start() activeToolbar.navigationIcon = FixedSizeDrawable(resource, navIconSize, navIconSize) } override fun onLoadCleared(placeholder: Drawable?) { placeholder?.let { activeToolbar.navigationIcon = FixedSizeDrawable(it, 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? ) { activeToolbar.navigationIcon = FixedSizeDrawable( resource.toDrawable(resources), navIconSize, navIconSize ) } override fun onLoadCleared(placeholder: Drawable?) { placeholder?.let { activeToolbar.navigationIcon = FixedSizeDrawable(it, navIconSize, navIconSize) } } }) } } private fun updateAnnouncementsBadge(unreadAnnouncementsCount: Int) { binding.mainDrawer.updateBadge( DRAWER_ITEM_ANNOUNCEMENTS, StringHolder( if (unreadAnnouncementsCount <= 0) null else unreadAnnouncementsCount.toString() ) ) } private fun updateProfiles(accounts: List) { if (accounts.isEmpty()) { return } val activeProfile = accounts.first() loadDrawerAvatar(activeProfile.profilePictureUrl) Glide.with(header.accountHeaderBackground) .asBitmap() .load(activeProfile.profileHeaderUrl) .into(header.accountHeaderBackground) val animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false) val profiles: MutableList = accounts.map { acc -> ProfileDrawerItem().apply { isSelected = acc == activeProfile nameText = acc.displayName.emojify(acc.emojis, header, animateEmojis) iconUrl = acc.profilePictureUrl isNameShown = true identifier = acc.id descriptionText = acc.fullName } }.toMutableList() // reuse the already existing "add account" item for (profile in header.profiles.orEmpty()) { if (profile.identifier == DRAWER_ITEM_ADD_ACCOUNT) { profiles.add(profile) break } } header.clear() header.profiles = profiles header.setActiveProfile(activeProfile.id) binding.mainToolbar.subtitle = if (accountManager.shouldDisplaySelfUsername()) { activeProfile.fullName } else { null } } override fun getActionButton() = binding.composeButton companion object { private const val TAG = "MainActivity" // logging tag private const val DRAWER_ITEM_ADD_ACCOUNT: Long = -13 private const val DRAWER_ITEM_ANNOUNCEMENTS: Long = 14 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) flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK } } /** * 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) } } /** * 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) } } } } private inline fun primaryDrawerItem(block: PrimaryDrawerItem.() -> Unit): PrimaryDrawerItem { return PrimaryDrawerItem() .apply { isSelectable = false isIconTinted = true } .apply(block) } private inline fun secondaryDrawerItem(block: SecondaryDrawerItem.() -> Unit): SecondaryDrawerItem { return SecondaryDrawerItem() .apply { isSelectable = false isIconTinted = true } .apply(block) } private var AbstractDrawerItem<*, *>.onClick: () -> Unit get() = throw UnsupportedOperationException() set(value) { onDrawerItemClickListener = { _, _, _ -> value() false } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/MainViewModel.kt ================================================ /* 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 import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import at.connyduck.calladapter.networkresult.fold import com.keylesspalace.tusky.appstore.AnnouncementReadEvent import com.keylesspalace.tusky.appstore.ConversationsLoadingEvent import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.NewNotificationsEvent import com.keylesspalace.tusky.appstore.NotificationsLoadingEvent import com.keylesspalace.tusky.components.systemnotifications.NotificationService import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.entity.Emoji import com.keylesspalace.tusky.entity.Notification import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.util.ShareShortcutHelper import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch @HiltViewModel class MainViewModel @Inject constructor( private val api: MastodonApi, private val eventHub: EventHub, private val accountManager: AccountManager, private val shareShortcutHelper: ShareShortcutHelper, private val notificationService: NotificationService, ) : ViewModel() { private val activeAccount = accountManager.activeAccount!! val accounts: StateFlow> = accountManager.accountsFlow .map { accounts -> accounts.map { account -> AccountViewData( id = account.id, domain = account.domain, username = account.username, displayName = account.displayName, profilePictureUrl = account.profilePictureUrl, profileHeaderUrl = account.profileHeaderUrl, emojis = account.emojis ) } } .stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) val tabs: StateFlow> = accountManager.activeAccount(viewModelScope) .mapNotNull { account -> account?.tabPreferences } .stateIn(viewModelScope, SharingStarted.Eagerly, activeAccount.tabPreferences) private val _unreadAnnouncementsCount = MutableStateFlow(0) val unreadAnnouncementsCount: StateFlow = _unreadAnnouncementsCount.asStateFlow() val showDirectMessagesBadge: StateFlow = accountManager.activeAccount(viewModelScope) .map { account -> account?.hasDirectMessageBadge == true } .stateIn(viewModelScope, SharingStarted.Eagerly, false) init { loadAccountData() fetchAnnouncements() collectEvents() } private fun loadAccountData() { viewModelScope.launch { api.accountVerifyCredentials().fold( { userInfo -> accountManager.updateAccount(activeAccount, userInfo) shareShortcutHelper.updateShortcuts() }, { throwable -> Log.e(TAG, "Failed to fetch user info.", throwable) } ) } } private fun fetchAnnouncements() { viewModelScope.launch { api.announcements() .fold( { announcements -> _unreadAnnouncementsCount.value = announcements.count { !it.read } }, { throwable -> Log.w(TAG, "Failed to fetch announcements.", throwable) } ) } } private fun collectEvents() { viewModelScope.launch { eventHub.events.collect { event -> when (event) { is AnnouncementReadEvent -> { _unreadAnnouncementsCount.value-- } is NewNotificationsEvent -> { if (event.accountId == activeAccount.accountId) { val hasDirectMessageNotification = event.notifications.any { it.type == Notification.Type.Mention && it.status?.visibility == Status.Visibility.DIRECT } if (hasDirectMessageNotification) { accountManager.updateAccount(activeAccount) { copy(hasDirectMessageBadge = true) } } } } is NotificationsLoadingEvent -> { if (event.accountId == activeAccount.accountId) { accountManager.updateAccount(activeAccount) { copy(hasDirectMessageBadge = false) } } } is ConversationsLoadingEvent -> { if (event.accountId == activeAccount.accountId) { accountManager.updateAccount(activeAccount) { copy(hasDirectMessageBadge = false) } } } } } } } fun dismissDirectMessagesBadge() { viewModelScope.launch { accountManager.updateAccount(activeAccount) { copy(hasDirectMessageBadge = false) } } } fun setupNotifications(activity: MainActivity) { // TODO this is only called on full app (re) start; so changes in-between (push distributor uninstalled/subscription changed, or // notifications fully disabled) will get unnoticed; and also an app restart cannot be easily triggered by the user. // TODO it's quite odd to separate channel creation (for an account) from the "is enabled by channels" question below notificationService.createNotificationChannelsForAccount(activeAccount) if (notificationService.areNotificationsEnabledBySystem()) { viewModelScope.launch { notificationService.setupNotifications(activity) } } else { viewModelScope.launch { notificationService.disableAllNotifications() } } } companion object { private const val TAG = "MainViewModel" } } data class AccountViewData( val id: Long, val domain: String, val username: String, val displayName: String, val profilePictureUrl: String, val profileHeaderUrl: String, val emojis: List ) { val fullName: String get() = "@$username@$domain" } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/StatusListActivity.kt ================================================ /* 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 import android.content.Context import android.content.Intent import android.os.Bundle import android.util.Log import android.view.Menu import android.view.MenuItem import androidx.fragment.app.commit import androidx.lifecycle.lifecycleScope import at.connyduck.calladapter.networkresult.fold import com.google.android.material.snackbar.Snackbar import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.FilterUpdatedEvent import com.keylesspalace.tusky.components.filters.EditFilterActivity import com.keylesspalace.tusky.components.filters.FilterExpiration 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.hilt.android.AndroidEntryPoint import javax.inject.Inject import kotlinx.coroutines.launch @AndroidEntryPoint class StatusListActivity : BottomSheetActivity() { @Inject lateinit var eventHub: EventHub private val binding: ActivityStatuslistBinding by viewBinding( ActivityStatuslistBinding::inflate ) private lateinit var kind: Kind private var hashtag: String? = null private var followTagItem: MenuItem? = null private var unfollowTagItem: MenuItem? = null private var muteTagItem: MenuItem? = null private var unmuteTagItem: MenuItem? = null /** The filter muting hashtag, null if unknown or hashtag is not filtered */ private var mutedFilterV1: FilterV1? = null private var mutedFilter: Filter? = null override fun onCreate(savedInstanceState: Bundle?) { Log.d("StatusListActivity", "onCreate") super.onCreate(savedInstanceState) setContentView(binding.root) setSupportActionBar(binding.includedToolbar.toolbar) kind = Kind.valueOf(intent.getStringExtra(EXTRA_KIND)!!) val listId = intent.getStringExtra(EXTRA_LIST_ID) hashtag = intent.getStringExtra(EXTRA_HASHTAG) val title = when (kind) { Kind.FAVOURITES -> getString(R.string.title_favourites) Kind.BOOKMARKS -> getString(R.string.title_bookmarks) Kind.TAG -> getString(R.string.hashtag_format, hashtag) Kind.PUBLIC_TRENDING_STATUSES -> getString(R.string.title_public_trending_statuses) else -> intent.getStringExtra(EXTRA_LIST_TITLE) } supportActionBar?.run { setTitle(title) setDisplayHomeAsUpEnabled(true) setDisplayShowHomeEnabled(true) } if (supportFragmentManager.findFragmentById(R.id.fragmentContainer) == null) { supportFragmentManager.commit { val fragment = if (kind == Kind.TAG) { TimelineFragment.newHashtagInstance(listOf(hashtag!!)) } else { TimelineFragment.newInstance(kind, listId) } replace(R.id.fragmentContainer, fragment) } } } override fun onCreateOptionsMenu(menu: Menu): Boolean { val tag = hashtag if (kind == Kind.TAG && tag != null) { lifecycleScope.launch { mastodonApi.tag(tag).fold( { tagEntity -> menuInflater.inflate(R.menu.view_hashtag_toolbar, menu) followTagItem = menu.findItem(R.id.action_follow_hashtag) unfollowTagItem = menu.findItem(R.id.action_unfollow_hashtag) muteTagItem = menu.findItem(R.id.action_mute_hashtag) unmuteTagItem = menu.findItem(R.id.action_unmute_hashtag) followTagItem?.isVisible = tagEntity.following == false unfollowTagItem?.isVisible = tagEntity.following == true followTagItem?.setOnMenuItemClickListener { followTag() } unfollowTagItem?.setOnMenuItemClickListener { unfollowTag() } muteTagItem?.setOnMenuItemClickListener { muteTag() } unmuteTagItem?.setOnMenuItemClickListener { unmuteTag() } updateMuteTagMenuItems() }, { Log.w(TAG, "Failed to query tag #$tag", it) } ) } } return super.onCreateOptionsMenu(menu) } private fun followTag(): Boolean { val tag = hashtag if (tag != null) { lifecycleScope.launch { mastodonApi.followTag(tag).fold( { 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() Log.e(TAG, "Failed to follow #$tag", it) } ) } } return true } private fun unfollowTag(): Boolean { val tag = hashtag if (tag != null) { lifecycleScope.launch { mastodonApi.unfollowTag(tag).fold( { 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() Log.e(TAG, "Failed to unfollow #$tag", it) } ) } } return true } /** * Determine if the current hashtag is muted, and update the UI state accordingly. */ private fun updateMuteTagMenuItems() { val tag = hashtag ?: return val hashedTag = "#$tag" muteTagItem?.isVisible = true muteTagItem?.isEnabled = false unmuteTagItem?.isVisible = false lifecycleScope.launch { mastodonApi.getFilters().fold( { filters -> mutedFilter = filters.firstOrNull { filter -> // TODO shouldn't this be an exact match (only one keyword; exactly the hashtag)? filter.context.contains(Filter.Kind.HOME) && filter.title == hashedTag } updateTagMuteState(mutedFilter != null) }, { throwable -> if (throwable.isHttpNotFound()) { mastodonApi.getFiltersV1().fold( { filters -> mutedFilterV1 = filters.firstOrNull { filter -> hashedTag == filter.phrase && filter.context.contains(Filter.Kind.HOME.kind) } updateTagMuteState(mutedFilterV1 != null) }, { throwable2 -> Log.e(TAG, "Error getting filters: $throwable2") } ) } else { Log.e(TAG, "Error getting filters: $throwable") } } ) } } private fun updateTagMuteState(muted: Boolean) { if (muted) { muteTagItem?.isVisible = false muteTagItem?.isEnabled = false unmuteTagItem?.isVisible = true } else { unmuteTagItem?.isVisible = false muteTagItem?.isEnabled = true muteTagItem?.isVisible = true } } private fun muteTag(): Boolean { val tag = hashtag ?: return true lifecycleScope.launch { var filterCreateSuccess = false val hashedTag = "#$tag" mastodonApi.createFilter( title = "#$tag", context = listOf(Filter.Kind.HOME), filterAction = Filter.Action.WARN, expiresIn = FilterExpiration.never ).fold( { filter -> 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() eventHub.dispatch(FilterUpdatedEvent(filter.context)) filterCreateSuccess = true } else { 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.isHttpNotFound()) { mastodonApi.createFilterV1( hashedTag, listOf(Filter.Kind.HOME.kind), irreversible = false, wholeWord = true, expiresIn = FilterExpiration.never ).fold( { filter -> mutedFilterV1 = filter eventHub.dispatch(FilterUpdatedEvent(filter.context.map { Filter.Kind.valueOf(it) })) filterCreateSuccess = true }, { throwable2 -> Snackbar.make( binding.root, getString(R.string.error_muting_hashtag_format, tag), Snackbar.LENGTH_SHORT ).show() Log.e(TAG, "Failed to mute #$tag", throwable2) } ) } else { 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 } private fun unmuteTag(): Boolean { lifecycleScope.launch { val tag = hashtag val result = if (mutedFilter != null) { val filter = mutedFilter!! if (filter.context.size > 1) { // This filter exists in multiple contexts, just remove the home context mastodonApi.updateFilter( id = filter.id, context = filter.context.filterNot { it == Filter.Kind.HOME } ) } else { mastodonApi.deleteFilter(filter.id) } } else if (mutedFilterV1 != null) { mutedFilterV1?.let { filter -> if (filter.context.size > 1) { // This filter exists in multiple contexts, just remove the home context mastodonApi.updateFilterV1( id = filter.id, phrase = filter.phrase, context = filter.context.filterNot { it == Filter.Kind.HOME.kind }, irreversible = null, wholeWord = null, expiresIn = FilterExpiration.never ) } else { mastodonApi.deleteFilterV1(filter.id) } } } else { null } result?.fold( { updateTagMuteState(false) eventHub.dispatch(FilterUpdatedEvent(listOf(Filter.Kind.HOME))) 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() Log.e(TAG, "Failed to unmute #$tag", throwable) } ) } return true } companion object { private const val EXTRA_KIND = "kind" private const val EXTRA_LIST_ID = "id" private const val EXTRA_LIST_TITLE = "title" private const val EXTRA_HASHTAG = "tag" const val TAG = "StatusListActivity" fun newFavouritesIntent(context: Context) = Intent(context, StatusListActivity::class.java).apply { putExtra(EXTRA_KIND, Kind.FAVOURITES.name) } fun newBookmarksIntent(context: Context) = Intent(context, StatusListActivity::class.java).apply { putExtra(EXTRA_KIND, Kind.BOOKMARKS.name) } fun newListIntent(context: Context, listId: String, listTitle: String) = Intent(context, StatusListActivity::class.java).apply { putExtra(EXTRA_KIND, Kind.LIST.name) putExtra(EXTRA_LIST_ID, listId) putExtra(EXTRA_LIST_TITLE, listTitle) } @JvmStatic fun newHashtagIntent(context: Context, hashtag: String) = Intent(context, StatusListActivity::class.java).apply { 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) } } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/TabData.kt ================================================ /* Copyright 2019 Conny Duck * * 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 import android.content.Context 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.TrendingTagsFragment import java.util.Objects /** this would be a good case for a sealed class, but that does not work nice with Room */ const val HOME = "Home" const val NOTIFICATIONS = "Notifications" const val LOCAL = "Local" const val FEDERATED = "Federated" const val DIRECT = "Direct" 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, @StringRes val text: Int, @DrawableRes val icon: Int, val fragment: (List) -> Fragment, val arguments: List = emptyList(), val title: (Context) -> String = { context -> context.getString(text) } ) { override fun equals(other: Any?): Boolean { if (this === other) return true if (javaClass != other?.javaClass) return false other as TabData if (id != other.id) return false return arguments == other.arguments } override fun hashCode() = Objects.hash(id, arguments) } fun List.hasTab(id: String): Boolean = this.any { it.id == id } fun createTabDataFromId(id: String, arguments: List = emptyList()): TabData { return when (id) { HOME -> TabData( id = HOME, text = R.string.title_home, icon = R.drawable.tab_icon_home, fragment = { TimelineFragment.newInstance(TimelineViewModel.Kind.HOME) } ) NOTIFICATIONS -> TabData( id = NOTIFICATIONS, text = R.string.title_notifications, icon = R.drawable.tab_icon_notifications, fragment = { NotificationsFragment.newInstance() } ) LOCAL -> TabData( id = LOCAL, text = R.string.title_public_local, icon = R.drawable.tab_icon_local, fragment = { TimelineFragment.newInstance(TimelineViewModel.Kind.PUBLIC_LOCAL) } ) FEDERATED -> TabData( id = FEDERATED, text = R.string.title_public_federated, icon = R.drawable.ic_public_24dp, fragment = { TimelineFragment.newInstance(TimelineViewModel.Kind.PUBLIC_FEDERATED) } ) DIRECT -> TabData( id = DIRECT, text = R.string.title_direct_messages, icon = R.drawable.tab_icon_direct, fragment = { ConversationsFragment.newInstance() } ) TRENDING_TAGS -> TabData( id = TRENDING_TAGS, text = R.string.title_public_trending_hashtags, icon = R.drawable.tab_icon_trending_tags, fragment = { TrendingTagsFragment.newInstance() } ) TRENDING_STATUSES -> TabData( id = TRENDING_STATUSES, text = R.string.title_public_trending_statuses, icon = R.drawable.tab_icon_trending_posts, fragment = { TimelineFragment.newInstance( TimelineViewModel.Kind.PUBLIC_TRENDING_STATUSES ) } ) HASHTAG -> TabData( id = HASHTAG, text = R.string.hashtags, icon = R.drawable.ic_tag_24dp, fragment = { args -> TimelineFragment.newHashtagInstance(args) }, arguments = arguments, title = { context -> arguments.joinToString(separator = " ") { context.getString(R.string.hashtag_format, it) } } ) LIST -> TabData( id = LIST, text = R.string.list, icon = R.drawable.tab_icon_list, 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.tab_icon_bookmarks, fragment = { TimelineFragment.newInstance(TimelineViewModel.Kind.BOOKMARKS) } ) else -> throw IllegalArgumentException("unknown tab type") } } fun defaultTabs(): List { return listOf( createTabDataFromId(HOME), createTabDataFromId(NOTIFICATIONS), createTabDataFromId(LOCAL), createTabDataFromId(DIRECT) ) } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/TabPreferenceActivity.kt ================================================ /* Copyright 2019 Conny Duck * * 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 import android.graphics.Color import android.os.Bundle import android.view.View import android.view.ViewGroup import androidx.activity.OnBackPressedCallback import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat.Type.systemBars import androidx.core.view.updateLayoutParams import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import androidx.transition.TransitionManager 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.components.account.list.ListSelectionFragment import com.keylesspalace.tusky.databinding.ActivityTabPreferenceBinding import com.keylesspalace.tusky.entity.MastoList import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.util.ensureBottomPadding import com.keylesspalace.tusky.util.unsafeLazy import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.util.visible import com.keylesspalace.tusky.view.showHashtagPickerDialog import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject import kotlinx.coroutines.launch @AndroidEntryPoint class TabPreferenceActivity : BaseActivity(), ItemInteractionListener, ListSelectionFragment.ListSelectionListener { @Inject lateinit var mastodonApi: MastodonApi @Inject lateinit var eventHub: EventHub private val binding by viewBinding(ActivityTabPreferenceBinding::inflate) private lateinit var currentTabs: MutableList private lateinit var currentTabsAdapter: TabAdapter private lateinit var touchHelper: ItemTouchHelper private lateinit var addTabAdapter: TabAdapter private val selectedItemElevation by unsafeLazy { resources.getDimension(R.dimen.selected_drag_item_elevation) } private val onFabDismissedCallback = object : OnBackPressedCallback(false) { override fun handleOnBackPressed() { toggleFab(false) } } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(binding.root) setSupportActionBar(binding.includedToolbar.toolbar) supportActionBar?.apply { setTitle(R.string.title_tab_preferences) setDisplayHomeAsUpEnabled(true) setDisplayShowHomeEnabled(true) } binding.currentTabsRecyclerView.ensureBottomPadding(fab = true) ViewCompat.setOnApplyWindowInsetsListener(binding.actionButton) { _, insets -> val bottomInset = insets.getInsets(systemBars()).bottom val actionButtonMargin = resources.getDimensionPixelSize(R.dimen.fabMargin) binding.actionButton.updateLayoutParams { bottomMargin = bottomInset + actionButtonMargin } binding.sheet.updateLayoutParams { bottomMargin = bottomInset + actionButtonMargin } insets.inset(0, 0, 0, bottomInset) } currentTabs = accountManager.activeAccount?.tabPreferences.orEmpty().toMutableList() 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) ) 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 { return makeMovementFlags(ItemTouchHelper.UP or ItemTouchHelper.DOWN, ItemTouchHelper.END) } override fun isLongPressDragEnabled(): Boolean { return true } override fun isItemViewSwipeEnabled(): Boolean { return MIN_TAB_COUNT < currentTabs.size } 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 currentTabsAdapter.notifyItemMoved(viewHolder.bindingAdapterPosition, target.bindingAdapterPosition) saveTabs() return true } override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { onTabRemoved(viewHolder.bindingAdapterPosition) } override fun onSelectedChanged(viewHolder: RecyclerView.ViewHolder?, actionState: Int) { if (actionState == ItemTouchHelper.ACTION_STATE_DRAG) { viewHolder?.itemView?.elevation = selectedItemElevation } } override fun clearView( recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder ) { super.clearView(recyclerView, viewHolder) viewHolder.itemView.elevation = 0f } }) touchHelper.attachToRecyclerView(binding.currentTabsRecyclerView) binding.actionButton.setOnClickListener { toggleFab(true) } binding.scrim.setOnClickListener { toggleFab(false) } updateAvailableTabs() onBackPressedDispatcher.addCallback(onFabDismissedCallback) } override fun onTabAdded(tab: TabData) { toggleFab(false) if (tab.id == HASHTAG) { showAddHashtagDialog() return } if (tab.id == LIST) { showSelectListDialog() return } currentTabs.add(tab) currentTabsAdapter.notifyItemInserted(currentTabs.size - 1) updateAvailableTabs() saveTabs() } override fun onTabRemoved(position: Int) { currentTabs.removeAt(position) currentTabsAdapter.notifyItemRemoved(position) updateAvailableTabs() saveTabs() } override fun onActionChipClicked(tab: TabData, tabPosition: Int) { showAddHashtagDialog(tab, tabPosition) } override fun onChipClicked(tab: TabData, tabPosition: Int, chipPosition: Int) { val newArguments = tab.arguments.filterIndexed { i, _ -> i != chipPosition } val newTab = tab.copy(arguments = newArguments) currentTabs[tabPosition] = newTab saveTabs() currentTabsAdapter.notifyItemChanged(tabPosition) } private fun toggleFab(expand: Boolean) { val transition = MaterialContainerTransform().apply { startView = if (expand) binding.actionButton else binding.sheet val endView: View = if (expand) binding.sheet else binding.actionButton this.endView = endView addTarget(endView) scrimColor = Color.TRANSPARENT setPathMotion(MaterialArcMotion()) } TransitionManager.beginDelayedTransition(binding.root, transition) binding.actionButton.visible(!expand) binding.sheet.visible(expand) binding.scrim.visible(expand) onFabDismissedCallback.isEnabled = expand } private fun showAddHashtagDialog(tab: TabData? = null, tabPosition: Int = 0) { showHashtagPickerDialog(mastodonApi, R.string.add_hashtag_title) { hashtag -> if (tab == null) { val newTab = createTabDataFromId(HASHTAG, listOf(hashtag)) currentTabs.add(newTab) currentTabsAdapter.notifyItemInserted(currentTabs.size - 1) } else { val newTab = tab.copy(arguments = tab.arguments + hashtag) currentTabs[tabPosition] = newTab currentTabsAdapter.notifyItemChanged(tabPosition) } updateAvailableTabs() saveTabs() } } private var listSelectDialog: ListSelectionFragment? = null private fun showSelectListDialog() { listSelectDialog = ListSelectionFragment.newInstance(null) listSelectDialog?.show(supportFragmentManager, null) return } 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 updateAvailableTabs() { val addableTabs: MutableList = mutableListOf() val homeTab = createTabDataFromId(HOME) if (!currentTabs.contains(homeTab)) { addableTabs.add(homeTab) } val notificationTab = createTabDataFromId(NOTIFICATIONS) if (!currentTabs.contains(notificationTab)) { addableTabs.add(notificationTab) } val localTab = createTabDataFromId(LOCAL) if (!currentTabs.contains(localTab)) { addableTabs.add(localTab) } val federatedTab = createTabDataFromId(FEDERATED) if (!currentTabs.contains(federatedTab)) { addableTabs.add(federatedTab) } val directMessagesTab = createTabDataFromId(DIRECT) if (!currentTabs.contains(directMessagesTab)) { addableTabs.add(directMessagesTab) } 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) currentTabsAdapter.setRemoveButtonVisible(currentTabs.size > MIN_TAB_COUNT) } override fun onStartDelete(viewHolder: RecyclerView.ViewHolder) { touchHelper.startSwipe(viewHolder) } override fun onStartDrag(viewHolder: RecyclerView.ViewHolder) { touchHelper.startDrag(viewHolder) } private fun saveTabs() { accountManager.activeAccount?.let { lifecycleScope.launch { accountManager.updateAccount(it) { copy(tabPreferences = currentTabs) } } } } companion object { private const val MIN_TAB_COUNT = 2 } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/TuskyApplication.kt ================================================ /* Copyright 2020 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 import android.app.Application import android.app.NotificationManager import android.content.SharedPreferences import android.os.Build import android.util.Log import androidx.core.content.edit import androidx.hilt.work.HiltWorkerFactory import androidx.work.Configuration import androidx.work.Constraints import androidx.work.ExistingPeriodicWorkPolicy import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkManager 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.LocaleManager import com.keylesspalace.tusky.util.setAppNightMode import com.keylesspalace.tusky.worker.PruneCacheWorker import dagger.hilt.android.HiltAndroidApp import de.c1710.filemojicompat_defaults.DefaultEmojiPackList import de.c1710.filemojicompat_ui.helpers.EmojiPackHelper import de.c1710.filemojicompat_ui.helpers.EmojiPreference import java.security.Security import java.util.concurrent.TimeUnit import javax.inject.Inject import org.conscrypt.Conscrypt @HiltAndroidApp class TuskyApplication : Application(), Configuration.Provider { @Inject lateinit var workerFactory: HiltWorkerFactory @Inject lateinit var localeManager: LocaleManager @Inject lateinit var preferences: SharedPreferences @Inject lateinit var notificationManager: NotificationManager override fun onCreate() { // Uncomment me to get StrictMode violation logs // if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) { // StrictMode.setThreadPolicy(StrictMode.ThreadPolicy.Builder() // .detectDiskReads() // .detectDiskWrites() // .detectNetwork() // .detectUnbufferedIo() // .penaltyLog() // .build()) // } super.onCreate() Security.insertProviderAt(Conscrypt.newProvider(), 1) val workManager = WorkManager.getInstance(this) // Migrate shared preference keys and defaults from version to version. val oldVersion = preferences.getInt( PrefKeys.SCHEMA_VERSION, NEW_INSTALL_SCHEMA_VERSION ) if (oldVersion != SCHEMA_VERSION) { if (oldVersion < 2025021701) { // A new periodic work request is enqueued by unique name (and not tag anymore): stop the old one workManager.cancelAllWorkByTag("pullNotifications") } if (oldVersion < 2025032401 && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { // delete old now unused notification channels for (channel in notificationManager.notificationChannels) { if (channel.id.startsWith("CHANNEL_SIGN_UP") || channel.id.startsWith("CHANNEL_REPORT") || channel.id.startsWith("CHANNEL_BOOST")) { notificationManager.deleteNotificationChannel(channel.id) } } } upgradeSharedPreferences(oldVersion, SCHEMA_VERSION) } // In this case, we want to have the emoji preferences merged with the other ones // Copied from PreferenceManager.getDefaultSharedPreferenceName EmojiPreference.sharedPreferenceName = packageName + "_preferences" EmojiPackHelper.init(this, DefaultEmojiPackList.get(this), allowPackImports = false) // init night mode val theme = preferences.getString(APP_THEME, AppTheme.DEFAULT.value) setAppNightMode(theme) localeManager.setLocale() // Prune the database every ~ 12 hours when the device is idle. val pruneCacheWorker = PeriodicWorkRequestBuilder(12, TimeUnit.HOURS) .setConstraints(Constraints.Builder().setRequiresDeviceIdle(true).build()) .build() workManager.enqueueUniquePeriodicWork( PruneCacheWorker.PERIODIC_WORK_TAG, ExistingPeriodicWorkPolicy.KEEP, pruneCacheWorker ) } override val workManagerConfiguration: Configuration get() = Configuration.Builder() .setWorkerFactory(workerFactory) .build() private fun upgradeSharedPreferences(oldVersion: Int, newVersion: Int) { Log.d(TAG, "Upgrading shared preferences: $oldVersion -> $newVersion") preferences.edit { if (oldVersion < 2023022701) { // These preferences are (now) handled in AccountPreferenceHandler. Remove them from shared for clarity. remove(PrefKeys.ALWAYS_OPEN_SPOILER) remove(PrefKeys.ALWAYS_SHOW_SENSITIVE_MEDIA) remove(PrefKeys.MEDIA_PREVIEW_ENABLED) } 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 (!preferences.contains(APP_THEME)) { putString(APP_THEME, AppTheme.NIGHT.value) } } if (oldVersion < 2023112001) { remove(PrefKeys.TAB_FILTER_HOME_REPLIES) remove(PrefKeys.TAB_FILTER_HOME_BOOSTS) remove(PrefKeys.TAB_SHOW_HOME_SELF_BOOSTS) } if (oldVersion < 2024060201) { remove(PrefKeys.Deprecated.FAB_HIDE) } putInt(PrefKeys.SCHEMA_VERSION, newVersion) } } companion object { private const val TAG = "TuskyApplication" } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/ViewMediaActivity.kt ================================================ /* 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 import android.Manifest import android.animation.Animator import android.animation.AnimatorListenerAdapter import android.app.DownloadManager import android.content.Context import android.content.Intent import android.graphics.Bitmap import android.graphics.Color import android.net.Uri import android.os.Build import android.os.Bundle import android.os.Environment import android.transition.Transition 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.activity.result.contract.ActivityResultContracts import androidx.core.app.ShareCompat import androidx.core.content.FileProvider import androidx.core.net.toUri import androidx.fragment.app.FragmentActivity import androidx.lifecycle.lifecycleScope import androidx.viewpager2.adapter.FragmentStateAdapter import androidx.viewpager2.widget.ViewPager2 import com.bumptech.glide.Glide import com.google.android.material.snackbar.Snackbar import com.keylesspalace.tusky.BuildConfig.APPLICATION_ID import com.keylesspalace.tusky.components.viewthread.ViewThreadActivity import com.keylesspalace.tusky.databinding.ActivityViewMediaBinding import com.keylesspalace.tusky.entity.Attachment import com.keylesspalace.tusky.fragment.ViewImageFragment import com.keylesspalace.tusky.fragment.ViewVideoFragment import com.keylesspalace.tusky.pager.ImagePagerAdapter import com.keylesspalace.tusky.pager.SingleImagePagerAdapter import com.keylesspalace.tusky.util.copyToClipboard import com.keylesspalace.tusky.util.getParcelableArrayListExtraCompat 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 dagger.hilt.android.AndroidEntryPoint import java.io.File import java.io.FileOutputStream import java.io.IOException import kotlinx.coroutines.CancellationException import kotlinx.coroutines.launch typealias ToolbarVisibilityListener = (isVisible: Boolean) -> Unit @AndroidEntryPoint class ViewMediaActivity : BaseActivity(), ViewImageFragment.PhotoActionsListener, ViewVideoFragment.VideoActionsListener { private val binding by viewBinding(ActivityViewMediaBinding::inflate) var isToolbarVisible = true private set private var attachments: ArrayList? = null private val toolbarVisibilityListeners = mutableListOf() private var imageUrl: String? = null private val requestDownloadMediaPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted -> if (isGranted) { downloadMedia() } else { Snackbar.make(binding.toolbar, getString(R.string.error_media_download_permission), Snackbar.LENGTH_SHORT) .setAction(R.string.action_retry) { requestDownloadMedia() } .show() } } fun addToolbarVisibilityListener(listener: ToolbarVisibilityListener): Function0 { this.toolbarVisibilityListeners.add(listener) listener(isToolbarVisible) return { toolbarVisibilityListeners.remove(listener) } } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(binding.root) supportPostponeEnterTransition() // Gather the parameters. attachments = intent.getParcelableArrayListExtraCompat(EXTRA_ATTACHMENTS) val initialPosition = intent.getIntExtra(EXTRA_ATTACHMENT_INDEX, 0) val adapter: ViewMediaAdapter = if (attachments != null) { val realAttachs = attachments!!.map(AttachmentViewData::attachment) // Setup the view pager. ImagePagerAdapter(this, realAttachs, initialPosition) } else { imageUrl = intent.getStringExtra(EXTRA_SINGLE_IMAGE_URL) ?: throw IllegalArgumentException("attachment list or image url has to be set") SingleImagePagerAdapter(this, imageUrl!!) } binding.viewPager.adapter = adapter binding.viewPager.setCurrentItem(initialPosition, false) binding.viewPager.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() { override fun onPageSelected(position: Int) { binding.toolbar.title = getPageTitle(position) adjustScreenWakefulness() } }) // Setup the toolbar. setSupportActionBar(binding.toolbar) supportActionBar?.run { setDisplayHomeAsUpEnabled(true) setDisplayShowHomeEnabled(true) title = getPageTitle(initialPosition) } binding.toolbar.setNavigationOnClickListener { supportFinishAfterTransition() } binding.toolbar.setOnMenuItemClickListener { item: MenuItem -> when (item.itemId) { R.id.action_download -> requestDownloadMedia() R.id.action_open_status -> onOpenStatus() R.id.action_share_media -> shareMedia() R.id.action_copy_media_link -> copyLink() } true } // yes it is deprecated, but it looks cool so it stays for now window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_LOW_PROFILE if (Build.VERSION.SDK_INT < Build.VERSION_CODES.VANILLA_ICE_CREAM) { @Suppress("DEPRECATION") window.statusBarColor = Color.BLACK } window.sharedElementEnterTransition.addListener(object : NoopTransitionListener { override fun onTransitionEnd(transition: Transition) { adapter.onTransitionEnd(binding.viewPager.currentItem) window.sharedElementEnterTransition.removeListener(this) } }) adjustScreenWakefulness() } override fun onCreateOptionsMenu(menu: Menu): Boolean { menuInflater.inflate(R.menu.view_media_toolbar, menu) // We don't support 'open status' from single image views menu.findItem(R.id.action_open_status)?.isVisible = (attachments != null) return true } override fun onPrepareOptionsMenu(menu: Menu?): Boolean { menu?.findItem(R.id.action_share_media)?.isEnabled = !isCreating return true } override fun onBringUp() { supportStartPostponedEnterTransition() } override fun onDismiss() { supportFinishAfterTransition() } override fun onPhotoTap() { isToolbarVisible = !isToolbarVisible for (listener in toolbarVisibilityListeners) { listener(isToolbarVisible) } val visibility = if (isToolbarVisible) View.VISIBLE else View.INVISIBLE val alpha = if (isToolbarVisible) 1.0f else 0.0f if (isToolbarVisible) { // If to be visible, need to make visible immediately and animate alpha binding.appBarLayout.alpha = 0.0f binding.appBarLayout.visibility = visibility } binding.appBarLayout.animate().alpha(alpha) .setListener(object : AnimatorListenerAdapter() { override fun onAnimationEnd(animation: Animator) { binding.appBarLayout.visibility = visibility animation.removeListener(this) } }) .start() } private fun getPageTitle(position: Int): CharSequence { if (attachments == null) { return "" } return "${position + 1}/${attachments?.size}" } private fun downloadMedia() { val url = imageUrl ?: attachments!![binding.viewPager.currentItem].attachment.url val filename = url.toUri().lastPathSegment Toast.makeText( applicationContext, resources.getString(R.string.download_image, filename), Toast.LENGTH_SHORT ).show() val downloadManager = getSystemService(DOWNLOAD_SERVICE) as DownloadManager val request = DownloadManager.Request(url.toUri()) request.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, filename) downloadManager.enqueue(request) } private fun requestDownloadMedia() { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { requestDownloadMediaPermissionLauncher.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE) } else { downloadMedia() } } private fun onOpenStatus() { val attach = attachments!![binding.viewPager.currentItem] startActivityWithSlideInAnimation( ViewThreadActivity.startIntent(this, attach.statusId, attach.statusUrl) ) } private fun copyLink() { copyToClipboard( imageUrl ?: attachments!![binding.viewPager.currentItem].attachment.url, getString(R.string.url_copied), ) } private fun shareMedia() { val directory = applicationContext.getExternalFilesDir("Tusky") if (directory == null || !(directory.exists())) { Log.e(TAG, "Error obtaining directory to save temporary media.") return } if (imageUrl != null) { shareImage(directory, imageUrl!!) } else { val attachment = attachments!![binding.viewPager.currentItem].attachment when (attachment.type) { Attachment.Type.IMAGE -> shareImage(directory, attachment.url) Attachment.Type.AUDIO, Attachment.Type.VIDEO, Attachment.Type.GIFV -> shareMediaFile(directory, attachment.url) else -> Log.e(TAG, "Unknown media format for sharing.") } } } private fun shareFile(file: File, mimeType: String?) { ShareCompat.IntentBuilder(this) .setType(mimeType) .addStream( FileProvider.getUriForFile(applicationContext, "$APPLICATION_ID.fileprovider", file) ) .setChooserTitle(R.string.send_media_to) .startChooser() } private var isCreating: Boolean = false private fun shareImage(directory: File, url: String) { isCreating = true binding.progressBarShare.visibility = View.VISIBLE invalidateOptionsMenu() lifecycleScope.launch { val file = File(directory, getTemporaryMediaFilename("png")) val result = try { val bitmap = Glide.with(applicationContext).asBitmap().load(url.toUri()).submitAsync() try { FileOutputStream(file).use { stream -> bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream) } 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) { val uri = url.toUri() val mimeTypeMap = MimeTypeMap.getSingleton() val extension = MimeTypeMap.getFileExtensionFromUrl(url) val mimeType = mimeTypeMap.getMimeTypeFromExtension(extension) val filename = getTemporaryMediaFilename(extension) val file = File(directory, filename) val downloadManager = getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager val request = DownloadManager.Request(uri) request.setDestinationUri(Uri.fromFile(file)) request.setVisibleInDownloadsUi(false) downloadManager.enqueue(request) 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) } } } companion object { private const val EXTRA_ATTACHMENTS = "attachments" private const val EXTRA_ATTACHMENT_INDEX = "index" private const val EXTRA_SINGLE_IMAGE_URL = "single_image" private const val TAG = "ViewMediaActivity" @JvmStatic 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) return intent } @JvmStatic fun newSingleImageIntent(context: Context, url: String): Intent { val intent = Intent(context, ViewMediaActivity::class.java) intent.putExtra(EXTRA_SINGLE_IMAGE_URL, url) return intent } } } abstract class ViewMediaAdapter(activity: FragmentActivity) : FragmentStateAdapter(activity) { abstract fun onTransitionEnd(position: Int) } interface NoopTransitionListener : Transition.TransitionListener { override fun onTransitionEnd(transition: Transition) { } override fun onTransitionResume(transition: Transition) { } override fun onTransitionPause(transition: Transition) { } override fun onTransitionCancel(transition: Transition) { } override fun onTransitionStart(transition: Transition) { } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/adapter/AccountFieldEditAdapter.kt ================================================ /* Copyright 2018 Conny Duck * * 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.view.LayoutInflater import android.view.ViewGroup import androidx.core.widget.doAfterTextChanged import androidx.recyclerview.widget.RecyclerView import com.keylesspalace.tusky.databinding.ItemEditFieldBinding import com.keylesspalace.tusky.entity.StringField import com.keylesspalace.tusky.util.BindingHolder import com.keylesspalace.tusky.util.fixTextSelection class AccountFieldEditAdapter( var onFieldsChanged: () -> Unit = { } ) : RecyclerView.Adapter>() { private val fieldData = mutableListOf() private var maxNameLength: Int? = null private var maxValueLength: Int? = null fun setFields(fields: List) { fieldData.clear() fields.forEach { field -> fieldData.add(MutableStringPair(field.name, field.value)) } if (fieldData.isEmpty()) { fieldData.add(MutableStringPair("", "")) } notifyDataSetChanged() } fun setFieldLimits(maxNameLength: Int?, maxValueLength: Int?) { this.maxNameLength = maxNameLength this.maxValueLength = maxValueLength notifyDataSetChanged() } fun getFieldData(): List { return fieldData.map { StringField(it.first, it.second) } } fun addField() { fieldData.add(MutableStringPair("", "")) notifyItemInserted(fieldData.size - 1) } override fun getItemCount() = fieldData.size override fun onCreateViewHolder( parent: ViewGroup, viewType: Int ): BindingHolder { val binding = ItemEditFieldBinding.inflate( LayoutInflater.from(parent.context), parent, false ) return BindingHolder(binding) } override fun onBindViewHolder(holder: BindingHolder, position: Int) { holder.binding.accountFieldNameText.setText(fieldData[position].first) holder.binding.accountFieldValueText.setText(fieldData[position].second) holder.binding.accountFieldNameTextLayout.isCounterEnabled = maxNameLength != null maxNameLength?.let { holder.binding.accountFieldNameTextLayout.counterMaxLength = it } holder.binding.accountFieldValueTextLayout.isCounterEnabled = maxValueLength != null maxValueLength?.let { holder.binding.accountFieldValueTextLayout.counterMaxLength = it } holder.binding.accountFieldNameText.doAfterTextChanged { newText -> 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 holder.binding.accountFieldNameText.fixTextSelection() holder.binding.accountFieldValueText.fixTextSelection() } class MutableStringPair(var first: String, var second: String) } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/adapter/AccountSelectionAdapter.kt ================================================ /* Copyright 2019 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.adapter import android.content.Context import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.ArrayAdapter import com.keylesspalace.tusky.R import com.keylesspalace.tusky.databinding.ItemAutocompleteAccountBinding import com.keylesspalace.tusky.db.entity.AccountEntity import com.keylesspalace.tusky.util.emojify import com.keylesspalace.tusky.util.loadAvatar class AccountSelectionAdapter( context: Context, private val animateAvatars: Boolean, private val animateEmojis: Boolean ) : ArrayAdapter( context, R.layout.item_autocomplete_account ) { override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { val binding = if (convertView == null) { ItemAutocompleteAccountBinding.inflate(LayoutInflater.from(context), parent, false) } else { ItemAutocompleteAccountBinding.bind(convertView) } val account = getItem(position) if (account != null) { binding.username.text = account.fullName binding.displayName.text = account.displayName.emojify(account.emojis, binding.displayName, animateEmojis) binding.avatarBadge.visibility = View.GONE // We never want to display the bot badge here val avatarRadius = context.resources.getDimensionPixelSize(R.dimen.avatar_radius_42dp) loadAvatar(account.profilePictureUrl, binding.avatar, avatarRadius, animateAvatars) } return binding.root } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/adapter/AccountViewHolder.kt ================================================ package com.keylesspalace.tusky.adapter import androidx.recyclerview.widget.RecyclerView import com.keylesspalace.tusky.R import com.keylesspalace.tusky.databinding.ItemAccountBinding import com.keylesspalace.tusky.entity.TimelineAccount import com.keylesspalace.tusky.interfaces.AccountActionListener import com.keylesspalace.tusky.interfaces.LinkListener import com.keylesspalace.tusky.util.emojify import com.keylesspalace.tusky.util.loadAvatar import com.keylesspalace.tusky.util.visible class AccountViewHolder( private val binding: ItemAccountBinding ) : RecyclerView.ViewHolder(binding.root) { private lateinit var accountId: String fun setupWithAccount( account: TimelineAccount, animateAvatar: Boolean, animateEmojis: Boolean, showBotOverlay: Boolean ) { accountId = account.id binding.accountUsername.text = binding.accountUsername.context.getString( R.string.post_username_format, account.username ) val emojifiedName = account.name.emojify( account.emojis, binding.accountDisplayName, animateEmojis ) binding.accountDisplayName.text = emojifiedName val avatarRadius = binding.accountAvatar.context.resources .getDimensionPixelSize(R.dimen.avatar_radius_48dp) loadAvatar(account.avatar, binding.accountAvatar, avatarRadius, animateAvatar) binding.accountBotBadge.visible(showBotOverlay && account.bot) } fun setupActionListener(listener: AccountActionListener) { itemView.setOnClickListener { listener.onViewAccount(accountId) } } fun setupLinkListener(listener: LinkListener) { itemView.setOnClickListener { listener.onViewAccount( accountId ) } } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/adapter/EmojiAdapter.kt ================================================ /* Copyright 2018 Conny Duck * * 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.view.LayoutInflater import android.view.ViewGroup import androidx.appcompat.widget.TooltipCompat import androidx.recyclerview.widget.RecyclerView import com.bumptech.glide.Glide import com.keylesspalace.tusky.databinding.ItemEmojiButtonBinding import com.keylesspalace.tusky.entity.Emoji import com.keylesspalace.tusky.util.BindingHolder import java.util.Locale class EmojiAdapter( emojiList: List, private val onEmojiSelectedListener: OnEmojiSelectedListener, private val animate: Boolean ) : RecyclerView.Adapter>() { private val emojiList: List = emojiList.filter { emoji -> emoji.visibleInPicker } .sortedBy { it.shortcode.lowercase(Locale.ROOT) } .sortedBy { it.category?.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 ) return BindingHolder(binding) } override fun onBindViewHolder(holder: BindingHolder, position: Int) { val emoji = emojiList[position] val emojiImageView = holder.binding.root if (animate) { Glide.with(emojiImageView) .load(emoji.url) .into(emojiImageView) } else { Glide.with(emojiImageView) .asBitmap() .load(emoji.url) .into(emojiImageView) } emojiImageView.setOnClickListener { onEmojiSelectedListener.onEmojiSelected(emoji.shortcode) } emojiImageView.contentDescription = emoji.shortcode TooltipCompat.setTooltipText(emojiImageView, emoji.shortcode) } } interface OnEmojiSelectedListener { fun onEmojiSelected(shortcode: String) } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/adapter/FilteredStatusViewHolder.kt ================================================ /* 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.adapter import androidx.recyclerview.widget.RecyclerView import com.keylesspalace.tusky.R import com.keylesspalace.tusky.components.notifications.NotificationsViewHolder import com.keylesspalace.tusky.databinding.ItemStatusFilteredBinding import com.keylesspalace.tusky.entity.Filter import com.keylesspalace.tusky.entity.FilterResult import com.keylesspalace.tusky.interfaces.StatusActionListener import com.keylesspalace.tusky.util.StatusDisplayOptions import com.keylesspalace.tusky.viewdata.NotificationViewData import com.keylesspalace.tusky.viewdata.StatusViewData class FilteredStatusViewHolder( private val binding: ItemStatusFilteredBinding, listener: StatusActionListener ) : NotificationsViewHolder, RecyclerView.ViewHolder(binding.root) { init { binding.statusFilterShowAnyway.setOnClickListener { listener.clearWarningAction(bindingAdapterPosition) } } override fun bind( viewData: NotificationViewData.Concrete, payloads: List<*>, statusDisplayOptions: StatusDisplayOptions ) { if (payloads.isEmpty()) { bind(viewData.statusViewData!!) } } fun bind(viewData: StatusViewData.Concrete) { val matchedFilterResult: FilterResult? = viewData.actionable.filtered.orEmpty().find { filterResult -> filterResult.filter.action == Filter.Action.WARN } val matchedFilterTitle = matchedFilterResult?.filter?.title.orEmpty() binding.statusFilterLabel.text = itemView.context.getString( R.string.status_filter_placeholder_label_format, matchedFilterTitle ) } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/adapter/FollowRequestViewHolder.kt ================================================ /* 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.graphics.Typeface 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.NotificationsViewHolder 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 import com.keylesspalace.tusky.util.parseAsMastodonHtml 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 accountListener: AccountActionListener, private val linkListener: LinkListener, private val showHeader: Boolean ) : RecyclerView.ViewHolder(binding.root), NotificationsViewHolder { override fun bind( viewData: NotificationViewData.Concrete, payloads: List<*>, statusDisplayOptions: StatusDisplayOptions ) { if (payloads.isNotEmpty()) { return } setupWithAccount( viewData.account, statusDisplayOptions.animateAvatars, statusDisplayOptions.animateEmojis, statusDisplayOptions.showBotOverlay ) setupActionListener(accountListener, viewData.account.id) } fun setupWithAccount( account: TimelineAccount, animateAvatar: Boolean, animateEmojis: Boolean, showBotOverlay: Boolean ) { val wrappedName = account.name.unicodeWrap() val emojifiedName: CharSequence = wrappedName.emojify( account.emojis, binding.displayNameTextView, animateEmojis ) binding.displayNameTextView.text = emojifiedName if (showHeader) { val wholeMessage: String = itemView.context.getString( R.string.notification_follow_request_format, wrappedName ) 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 ) binding.usernameTextView.text = formattedUsername if (account.note.isEmpty()) { binding.accountNote.hide() } else { binding.accountNote.show() val emojifiedNote = account.note.parseAsMastodonHtml() .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 ) loadAvatar(account.avatar, binding.avatar, avatarRadius, animateAvatar) binding.avatarBadge.visible(showBotOverlay && account.bot) } fun setupActionListener(listener: AccountActionListener, accountId: String) { binding.acceptButton.setOnClickListener { val position = bindingAdapterPosition if (position != RecyclerView.NO_POSITION) { listener.onRespondToFollowRequest(true, accountId, position) } } binding.rejectButton.setOnClickListener { val position = bindingAdapterPosition if (position != RecyclerView.NO_POSITION) { listener.onRespondToFollowRequest(false, accountId, position) } } itemView.setOnClickListener { listener.onViewAccount(accountId) } binding.accountNote.setOnClickListener { listener.onViewAccount(accountId) } } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/adapter/LoadMoreViewHolder.kt ================================================ /* 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 androidx.recyclerview.widget.RecyclerView import com.keylesspalace.tusky.databinding.ItemLoadMoreBinding import com.keylesspalace.tusky.interfaces.StatusActionListener import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.visible /** * Placeholder for missing parts in timelines. * * Displays a "Load more" button to load the gap, or a * circular progress bar if the missing page is being loaded. */ class LoadMoreViewHolder( private val binding: ItemLoadMoreBinding, listener: StatusActionListener ) : RecyclerView.ViewHolder(binding.root) { init { binding.loadMoreButton.setOnClickListener { binding.loadMoreButton.hide() binding.loadMoreProgressBar.show() listener.onLoadMore(bindingAdapterPosition) } } fun setup(loading: Boolean) { binding.loadMoreButton.visible(!loading) binding.loadMoreProgressBar.visible(loading) } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/adapter/LoadStateFooterAdapter.kt ================================================ /* 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.view.LayoutInflater import android.view.ViewGroup import androidx.paging.LoadState import androidx.paging.LoadStateAdapter import com.keylesspalace.tusky.databinding.ItemNetworkStateBinding import com.keylesspalace.tusky.util.BindingHolder import com.keylesspalace.tusky.util.visible class LoadStateFooterAdapter( private val retryCallback: () -> Unit ) : LoadStateAdapter>() { override fun onBindViewHolder( holder: BindingHolder, loadState: LoadState ) { val binding = holder.binding binding.progressBar.visible(loadState == LoadState.Loading) binding.retryButton.visible(loadState is LoadState.Error) val msg = if (loadState is LoadState.Error) { loadState.error.message } else { null } binding.errorMsg.visible(msg != null) binding.errorMsg.text = msg binding.retryButton.setOnClickListener { retryCallback() } } override fun onCreateViewHolder( parent: ViewGroup, loadState: LoadState ): BindingHolder { val binding = ItemNetworkStateBinding.inflate( LayoutInflater.from(parent.context), parent, false ) return BindingHolder(binding) } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/adapter/LocaleAdapter.kt ================================================ /* Copyright 2022 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.Typeface import android.view.View import android.view.ViewGroup import android.widget.ArrayAdapter import android.widget.TextView import com.google.android.material.color.MaterialColors 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 ) { 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)) typeface = Typeface.DEFAULT_BOLD text = super.getItem(position)?.modernLanguageCode?.uppercase() } } override fun getDropDownView(position: Int, convertView: View?, parent: ViewGroup): View { return (super.getDropDownView(position, convertView, parent) as TextView).apply { setTextColor(MaterialColors.getColor(this, android.R.attr.textColorTertiary)) text = super.getItem(position)?.getTuskyDisplayName(context) } } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/adapter/PlaceholderViewHolder.kt ================================================ /* Copyright 2025 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.view.ViewGroup import androidx.core.view.updateLayoutParams import androidx.core.view.updatePaddingRelative import androidx.recyclerview.widget.RecyclerView import com.keylesspalace.tusky.R import com.keylesspalace.tusky.databinding.ItemPlaceholderBinding import com.keylesspalace.tusky.util.visible class PlaceholderViewHolder( binding: ItemPlaceholderBinding, mode: Mode, ) : RecyclerView.ViewHolder(binding.root) { init { val res = binding.root.context.resources binding.topPlaceholder.visible(mode != Mode.STATUS) binding.reblogButtonPlaceholder.visible(mode != Mode.CONVERSATION) if (mode == Mode.NOTIFICATION) { binding.topPlaceholder.updatePaddingRelative( start = res.getDimensionPixelSize(R.dimen.status_info_padding_large) ) } if (mode == Mode.CONVERSATION) { binding.moreButtonPlaceHolder.updateLayoutParams { marginEnd = res.getDimensionPixelSize(R.dimen.conversation_placeholder_more_button_inset) } } } enum class Mode { STATUS, NOTIFICATION, CONVERSATION } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/adapter/PollAdapter.kt ================================================ /* Copyright 2019 Conny Duck * * 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.res.ColorStateList import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.recyclerview.widget.RecyclerView import com.google.android.material.color.MaterialColors import com.keylesspalace.tusky.R import com.keylesspalace.tusky.databinding.ItemPollBinding import com.keylesspalace.tusky.entity.Emoji import com.keylesspalace.tusky.util.BindingHolder import com.keylesspalace.tusky.util.emojify import com.keylesspalace.tusky.util.visible import com.keylesspalace.tusky.viewdata.PollOptionViewData import com.keylesspalace.tusky.viewdata.buildDescription import com.keylesspalace.tusky.viewdata.calculatePercent class PollAdapter : RecyclerView.Adapter>() { private var pollOptions: List = emptyList() private var voteCount: Int = 0 private var votersCount: Int? = null private var mode = RESULT private var emojis: List = emptyList() private var resultClickListener: View.OnClickListener? = null private var animateEmojis = false private var enabled = true @JvmOverloads fun setup( options: List, voteCount: Int, votersCount: Int?, emojis: List, mode: Int, resultClickListener: View.OnClickListener?, animateEmojis: Boolean, enabled: Boolean = true ) { this.pollOptions = options this.voteCount = voteCount this.votersCount = votersCount this.emojis = emojis this.mode = mode this.resultClickListener = resultClickListener this.animateEmojis = animateEmojis this.enabled = enabled notifyDataSetChanged() } fun getSelected(): List { return pollOptions.filter { it.selected } .map { pollOptions.indexOf(it) } } override fun onCreateViewHolder( parent: ViewGroup, viewType: Int ): BindingHolder { val binding = ItemPollBinding.inflate(LayoutInflater.from(parent.context), parent, false) return BindingHolder(binding) } override fun getItemCount() = pollOptions.size override fun onBindViewHolder(holder: BindingHolder, position: Int) { val option = pollOptions[position] val resultTextView = holder.binding.statusPollOptionResult val radioButton = holder.binding.statusPollRadioButton val checkBox = holder.binding.statusPollCheckbox resultTextView.visible(mode == RESULT) radioButton.visible(mode == SINGLE) checkBox.visible(mode == MULTIPLE) radioButton.isEnabled = enabled checkBox.isEnabled = enabled if (mode == RESULT) { val percent = calculatePercent(option.votesCount, votersCount, voteCount) resultTextView.text = buildDescription(option.title, percent, option.voted, resultTextView.context, resultTextView) .emojify(emojis, resultTextView, animateEmojis) val level = percent * 100 val optionColor = if (option.voted) { R.color.colorBackgroundHighlight } else { R.color.colorBackgroundAccent } holder.binding.pollLayout.setBackgroundResource(R.drawable.poll_option_background) holder.binding.pollLayout.background.level = level holder.binding.pollLayout.background.setTint(resultTextView.context.getColor(optionColor)) holder.binding.root.strokeColor = holder.binding.root.context.getColor(optionColor) resultTextView.setOnClickListener(resultClickListener) } else { holder.binding.pollLayout.background = null if (option.selected) { holder.binding.root.setCardBackgroundColor(ColorStateList.valueOf(MaterialColors.getColor(holder.binding.root, com.google.android.material.R.attr.colorSurface))) holder.binding.root.strokeColor = MaterialColors.getColor(holder.binding.root, com.google.android.material.R.attr.colorSurface) } else { holder.binding.root.setCardBackgroundColor(ColorStateList.valueOf(MaterialColors.getColor(holder.binding.root, android.R.attr.colorBackground))) holder.binding.root.strokeColor = MaterialColors.getColor(holder.binding.root, R.attr.colorBackgroundAccent) } if (mode == SINGLE) { radioButton.text = option.title.emojify(emojis, radioButton, animateEmojis) radioButton.isChecked = option.selected radioButton.setOnClickListener { pollOptions.forEachIndexed { index, pollOption -> pollOption.selected = index == holder.bindingAdapterPosition notifyItemChanged(index) } } } else { // mode == MULTIPLE checkBox.text = option.title.emojify(emojis, checkBox, animateEmojis) checkBox.isChecked = option.selected checkBox.setOnCheckedChangeListener { _, isChecked -> pollOptions[holder.bindingAdapterPosition].selected = isChecked notifyItemChanged(holder.bindingAdapterPosition) } } } } companion object { const val RESULT = 0 const val SINGLE = 1 const val MULTIPLE = 2 } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/adapter/PreviewPollOptionsAdapter.kt ================================================ /* 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.adapter import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.TextView import androidx.recyclerview.widget.RecyclerView import com.keylesspalace.tusky.R class PreviewPollOptionsAdapter : RecyclerView.Adapter() { private var options: List = emptyList() private var multiple: Boolean = false private var clickListener: View.OnClickListener? = null fun update(newOptions: List, multiple: Boolean) { this.options = newOptions this.multiple = multiple notifyDataSetChanged() } fun setOnClickListener(l: View.OnClickListener?) { clickListener = l } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PreviewViewHolder { return PreviewViewHolder( LayoutInflater.from( parent.context ).inflate(R.layout.item_poll_preview_option, parent, false) ) } override fun getItemCount() = options.size override fun onBindViewHolder(holder: PreviewViewHolder, position: Int) { val textView = holder.itemView as TextView val iconId = if (multiple) { R.drawable.ic_check_box_outline_blank_18dp } else { R.drawable.ic_radio_button_unchecked_18dp } textView.setCompoundDrawablesRelativeWithIntrinsicBounds(iconId, 0, 0, 0) textView.text = options[position] textView.setOnClickListener(clickListener) } } class PreviewViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java ================================================ package com.keylesspalace.tusky.adapter; import static com.keylesspalace.tusky.viewdata.PollViewDataKt.buildDescription; import android.content.Context; import android.graphics.Typeface; import android.graphics.drawable.BitmapDrawable; import android.graphics.drawable.ColorDrawable; import android.graphics.drawable.Drawable; import android.net.Uri; import android.text.Spannable; 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.Gravity; import android.view.View; import android.view.ViewGroup; import android.widget.Button; import android.widget.ImageButton; import android.widget.ImageView; import android.widget.LinearLayout; import android.widget.TextView; import androidx.annotation.DrawableRes; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.PluralsRes; import androidx.appcompat.content.res.AppCompatResources; import androidx.appcompat.widget.TooltipCompat; import androidx.constraintlayout.widget.ConstraintLayout; import androidx.recyclerview.widget.DefaultItemAnimator; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; import com.bumptech.glide.Glide; import com.bumptech.glide.RequestBuilder; import com.google.android.material.button.MaterialButton; import com.google.android.material.card.MaterialCardView; import com.google.android.material.color.MaterialColors; import com.google.android.material.imageview.ShapeableImageView; import com.google.android.material.shape.CornerFamily; import com.google.android.material.shape.ShapeAppearanceModel; import com.keylesspalace.tusky.R; import com.keylesspalace.tusky.ViewMediaActivity; import com.keylesspalace.tusky.entity.Attachment; import com.keylesspalace.tusky.entity.Attachment.Focus; import com.keylesspalace.tusky.entity.Attachment.MetaData; import com.keylesspalace.tusky.entity.Emoji; import com.keylesspalace.tusky.entity.Filter; import com.keylesspalace.tusky.entity.HashTag; import com.keylesspalace.tusky.entity.PreviewCard; import com.keylesspalace.tusky.entity.Status; import com.keylesspalace.tusky.entity.TimelineAccount; 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.BlurhashDrawable; 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; import com.keylesspalace.tusky.util.TouchDelegateHelper; import com.keylesspalace.tusky.view.MediaPreviewImageView; import com.keylesspalace.tusky.view.MediaPreviewLayout; 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 at.connyduck.sparkbutton.SparkButton; import at.connyduck.sparkbutton.helpers.Utils; import kotlin.collections.CollectionsKt; public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { public static class Key { public static final String KEY_CREATED = "created"; } private final String TAG = "StatusBaseViewHolder"; private final TextView displayName; private final TextView username; private final ImageButton replyButton; private final TextView replyCountLabel; private final SparkButton reblogButton; private final SparkButton favouriteButton; private final SparkButton bookmarkButton; private final ImageButton moreButton; protected final ConstraintLayout mediaContainer; protected final MediaPreviewLayout mediaPreview; private final TextView sensitiveMediaWarning; private final View sensitiveMediaShow; protected final TextView[] mediaLabels; protected final MaterialCardView[] mediaLabelContainers; protected final CharSequence[] mediaDescriptions; private final MaterialButton contentWarningButton; private final ImageView avatarInset; public final ImageView avatar; public final TextView metaInfo; public final TextView content; public final TextView contentWarningDescription; private final RecyclerView pollOptions; private final TextView pollDescription; private final Button pollButton; private final Button pollResultsButton; private final MaterialCardView cardView; private final LinearLayout cardLayout; private final ShapeableImageView cardImage; private final TextView cardTitle; private final TextView cardMetadata; private final TextView cardAuthor; private final TextView cardAuthorButton; private final PollAdapter pollAdapter; protected final ConstraintLayout statusContainer; private final TextView translationStatusView; private final Button untranslateButton; private final TextView trailingHashtagView; private final NumberFormat numberFormat = NumberFormat.getNumberInstance(); private final AbsoluteTimeFormatter absoluteTimeFormatter = new AbsoluteTimeFormatter(); protected final int avatarRadius48dp; private final int avatarRadius36dp; private final int avatarRadius24dp; private final Drawable mediaPreviewUnloaded; protected StatusBaseViewHolder(@NonNull View itemView) { super(itemView); displayName = itemView.findViewById(R.id.status_display_name); username = itemView.findViewById(R.id.status_username); metaInfo = itemView.findViewById(R.id.status_meta_info); content = itemView.findViewById(R.id.status_content); avatar = itemView.findViewById(R.id.status_avatar); replyButton = itemView.findViewById(R.id.status_reply); replyCountLabel = itemView.findViewById(R.id.status_replies); reblogButton = itemView.findViewById(R.id.status_inset); favouriteButton = itemView.findViewById(R.id.status_favourite); bookmarkButton = itemView.findViewById(R.id.status_bookmark); moreButton = itemView.findViewById(R.id.status_more); mediaContainer = itemView.findViewById(R.id.status_media_preview_container); mediaContainer.setClipToOutline(true); mediaPreview = itemView.findViewById(R.id.status_media_preview); 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) }; mediaLabelContainers = new MaterialCardView[]{ itemView.findViewById(R.id.status_media_label_container_0), itemView.findViewById(R.id.status_media_label_container_1), itemView.findViewById(R.id.status_media_label_container_2), itemView.findViewById(R.id.status_media_label_container_3) }; mediaDescriptions = new CharSequence[mediaLabels.length]; contentWarningDescription = itemView.findViewById(R.id.status_content_warning_description); contentWarningButton = itemView.findViewById(R.id.status_content_warning_button); avatarInset = itemView.findViewById(R.id.status_avatar_inset); pollOptions = itemView.findViewById(R.id.status_poll_options); pollDescription = itemView.findViewById(R.id.status_poll_description); pollButton = itemView.findViewById(R.id.status_poll_button); pollResultsButton = itemView.findViewById(R.id.status_poll_results_button); cardView = itemView.findViewById(R.id.status_card_view); cardLayout = itemView.findViewById(R.id.status_card_layout); cardImage = itemView.findViewById(R.id.card_image); cardTitle = itemView.findViewById(R.id.card_title); cardMetadata = itemView.findViewById(R.id.card_metadata); cardAuthor = itemView.findViewById(R.id.card_author); cardAuthorButton = itemView.findViewById(R.id.card_author_button); statusContainer = itemView.findViewById(R.id.status_container); pollAdapter = new PollAdapter(); pollOptions.setAdapter(pollAdapter); 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); trailingHashtagView = itemView.findViewById(R.id.status_trailing_hashtags_content); 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); mediaPreviewUnloaded = new ColorDrawable(MaterialColors.getColor(itemView, R.attr.colorBackgroundAccent)); TouchDelegateHelper.expandTouchSizeToFillRow((ViewGroup) itemView, CollectionsKt.listOfNotNull(replyButton, reblogButton, favouriteButton, bookmarkButton, moreButton)); } protected void setDisplayName(@NonNull String name, @NonNull List customEmojis, @NonNull StatusDisplayOptions statusDisplayOptions) { CharSequence emojifiedName = CustomEmojiHelper.emojify( name, customEmojis, displayName, statusDisplayOptions.animateEmojis() ); displayName.setText(emojifiedName); } protected void setUsername(@Nullable String name) { Context context = username.getContext(); String usernameText = context.getString(R.string.post_username_format, name); username.setText(usernameText); } public void toggleContentWarning() { contentWarningButton.performClick(); } protected void setSpoilerAndContent(@NonNull StatusViewData.Concrete status, @NonNull StatusDisplayOptions statusDisplayOptions, final @NonNull StatusActionListener listener) { Status actionable = status.getActionable(); String spoilerText = status.getSpoilerText(); List emojis = actionable.getEmojis(); boolean sensitive = !TextUtils.isEmpty(spoilerText); boolean expanded = status.isExpanded(); if (sensitive) { CharSequence emojiSpoiler = CustomEmojiHelper.emojify( spoilerText, emojis, contentWarningDescription, statusDisplayOptions.animateEmojis() ); contentWarningDescription.setText(emojiSpoiler); contentWarningDescription.setVisibility(View.VISIBLE); boolean hasContent = !TextUtils.isEmpty(status.getContent()); if (hasContent) { contentWarningButton.setVisibility(View.VISIBLE); setContentWarningButtonText(expanded); contentWarningButton.setOnClickListener(view -> toggleExpandedState(true, !expanded, status, statusDisplayOptions, listener)); } else { contentWarningButton.setVisibility(View.GONE); } this.setTextVisible(true, expanded, status, statusDisplayOptions, listener); } else { contentWarningDescription.setVisibility(View.GONE); contentWarningButton.setVisibility(View.GONE); this.setTextVisible(false, true, status, statusDisplayOptions, listener); } } private void setContentWarningButtonText(boolean expanded) { if (expanded) { contentWarningButton.setText(R.string.post_content_warning_show_less); } else { contentWarningButton.setText(R.string.post_content_warning_show_more); } } protected void toggleExpandedState(boolean sensitive, boolean expanded, @NonNull final StatusViewData.Concrete status, @NonNull final StatusDisplayOptions statusDisplayOptions, @NonNull final StatusActionListener listener) { contentWarningDescription.invalidate(); int adapterPosition = getBindingAdapterPosition(); if (adapterPosition != RecyclerView.NO_POSITION) { listener.onExpandedChange(expanded, adapterPosition); } setContentWarningButtonText(expanded); this.setTextVisible(sensitive, expanded, status, statusDisplayOptions, listener); setupCard(status, expanded, !status.isShowingContent(), statusDisplayOptions.cardViewMode(), statusDisplayOptions, listener); } private void setTextVisible(boolean sensitive, boolean expanded, @NonNull final StatusViewData.Concrete status, @NonNull final StatusDisplayOptions statusDisplayOptions, final StatusActionListener listener) { Status actionable = status.getActionable(); Spanned content = status.getContent(); List mentions = actionable.getMentions(); List tags = actionable.getTags(); List emojis = actionable.getEmojis(); PollViewData poll = PollViewDataKt.toViewData(status.getPoll()); if (expanded) { CharSequence emojifiedText = CustomEmojiHelper.emojify(content, emojis, this.content, statusDisplayOptions.animateEmojis()); LinkHelper.setClickableText(this.content, emojifiedText, mentions, tags, listener, this.trailingHashtagView); if (trailingHashtagView != null && status.isCollapsible() && status.isCollapsed()) { trailingHashtagView.setVisibility(View.GONE); } for (int i = 0; i < mediaLabels.length; ++i) { updateMediaLabel(i, sensitive, true); } if (poll != null) { setupPoll(poll, emojis, statusDisplayOptions, listener); } else { hidePoll(); } } else { hidePoll(); if (trailingHashtagView != null) { trailingHashtagView.setVisibility(View.GONE); } LinkHelper.setClickableMentions(this.content, mentions, listener); } if (TextUtils.isEmpty(this.content.getText())) { this.content.setVisibility(View.GONE); } else { this.content.setVisibility(View.VISIBLE); } } private void hidePoll() { pollButton.setVisibility(View.GONE); pollResultsButton.setVisibility(View.GONE); pollDescription.setVisibility(View.GONE); pollOptions.setVisibility(View.GONE); } private void setAvatar(String url, @Nullable String rebloggedUrl, boolean isBot, StatusDisplayOptions statusDisplayOptions) { int avatarRadius; if (TextUtils.isEmpty(rebloggedUrl)) { avatar.setPaddingRelative(0, 0, 0, 0); if (statusDisplayOptions.showBotOverlay() && isBot) { avatarInset.setVisibility(View.VISIBLE); Glide.with(avatarInset) .load(R.drawable.bot_badge) .into(avatarInset); } else { avatarInset.setVisibility(View.GONE); } avatarRadius = avatarRadius48dp; } else { int padding = Utils.dpToPx(avatar.getContext(), 12); avatar.setPaddingRelative(0, 0, padding, padding); avatarInset.setVisibility(View.VISIBLE); avatarInset.setBackground(null); ImageLoadingHelper.loadAvatar(rebloggedUrl, avatarInset, avatarRadius24dp, statusDisplayOptions.animateAvatars(), null); avatarRadius = avatarRadius36dp; } ImageLoadingHelper.loadAvatar( url, avatar, avatarRadius, statusDisplayOptions.animateAvatars(), Collections.singletonList(new CompositeWithOpaqueBackground(MaterialColors.getColor(avatar, android.R.attr.colorBackground))) ); } protected void setMetaData(@NonNull StatusViewData.Concrete statusViewData, @NonNull StatusDisplayOptions statusDisplayOptions, @NonNull StatusActionListener listener) { Status status = statusViewData.getActionable(); Date createdAt = status.getCreatedAt(); Date editedAt = status.getEditedAt(); String timestampText; if (statusDisplayOptions.useAbsoluteTime()) { timestampText = absoluteTimeFormatter.format(createdAt, true); } else { if (createdAt == null) { timestampText = "?m"; } else { long then = createdAt.getTime(); long now = System.currentTimeMillis(); timestampText = TimestampUtils.getRelativeTimeSpanString(metaInfo.getContext(), then, now); } } if (editedAt != null) { timestampText = metaInfo.getContext().getString(R.string.post_timestamp_with_edited_indicator, timestampText); } metaInfo.setText(timestampText); } private CharSequence getCreatedAtDescription(Date createdAt, StatusDisplayOptions statusDisplayOptions) { if (statusDisplayOptions.useAbsoluteTime()) { return absoluteTimeFormatter.format(createdAt, true); } else { /* This one is for screen-readers. Frequently, they would mispronounce timestamps like "17m" * as 17 meters instead of minutes. */ if (createdAt == null) { return "? minutes"; } else { long then = createdAt.getTime(); long now = System.currentTimeMillis(); return DateUtils.getRelativeTimeSpanString(then, now, DateUtils.SECOND_IN_MILLIS, DateUtils.FORMAT_ABBREV_RELATIVE); } } } protected void setReplyButtonImage(boolean isReply) { if (isReply) { replyButton.setImageResource(R.drawable.ic_reply_all_24dp); } else { replyButton.setImageResource(R.drawable.ic_reply_24dp); } } protected void setReplyCount(int repliesCount, boolean fullStats) { // This label only exists in the non-detailed view (to match the web ui) if (replyCountLabel == null) return; if (fullStats) { replyCountLabel.setText(NumberUtils.formatNumber(repliesCount, 1000)); return; } // Show "0", "1", or "1+" for replies otherwise, so the user knows if there is a thread // that they can click through to read. replyCountLabel.setText((repliesCount > 1 ? replyCountLabel.getContext().getString(R.string.status_count_one_plus) : Integer.toString(repliesCount))); } private void setReblogged(boolean reblogged) { reblogButton.setChecked(reblogged); } // This should only be called after setReblogged, in order to override the tint correctly. private void setRebloggingEnabled(boolean enabled, Status.Visibility visibility) { reblogButton.setEnabled(enabled && visibility != Status.Visibility.PRIVATE); if (enabled) { int inactiveId; int activeId; if (visibility == Status.Visibility.PRIVATE) { inactiveId = R.drawable.ic_lock_24dp; activeId = R.drawable.ic_lock_24dp_filled; } else { inactiveId = R.drawable.ic_repeat_24dp; activeId = R.drawable.ic_repeat_active_24dp; } reblogButton.setInactiveImage(inactiveId); reblogButton.setActiveImage(activeId); } else { int disabledId; if (visibility == Status.Visibility.DIRECT) { disabledId = R.drawable.ic_mail_24dp; } else { disabledId = R.drawable.ic_lock_24dp; } reblogButton.setInactiveImage(disabledId); reblogButton.setActiveImage(disabledId); } } protected void setFavourited(boolean favourited) { favouriteButton.setChecked(favourited); } protected void setBookmarked(boolean bookmarked) { bookmarkButton.setChecked(bookmarked); } private BitmapDrawable decodeBlurHash(String blurhash) { return new BlurhashDrawable(this.avatar.getContext(), blurhash); } private void loadImage(MediaPreviewImageView imageView, @Nullable String previewUrl, @Nullable MetaData meta, @Nullable String blurhash) { Drawable placeholder = blurhash != null ? decodeBlurHash(blurhash) : mediaPreviewUnloaded; if (TextUtils.isEmpty(previewUrl)) { imageView.removeFocalPoint(); Glide.with(imageView) .load(placeholder) .centerInside() .into(imageView); } else { Focus focus = meta != null ? meta.getFocus() : null; if (focus != null) { // If there is a focal point for this attachment: imageView.setFocalPoint(focus); Glide.with(imageView.getContext()) .load(previewUrl) .placeholder(placeholder) .centerInside() .addListener(imageView) .into(imageView); } else { imageView.removeFocalPoint(); Glide.with(imageView) .load(previewUrl) .placeholder(placeholder) .centerInside() .into(imageView); } } } protected void setMediaPreviews( final @NonNull List attachments, boolean sensitive, final @NonNull StatusActionListener listener, boolean showingContent, boolean useBlurhash, final @NonNull Filter filter ) { mediaPreview.setVisibility(View.VISIBLE); mediaPreview.setAspectRatios(AttachmentHelper.aspectRatios(attachments)); mediaPreview.forEachIndexed((i, imageView, descriptionIndicator) -> { Attachment attachment = attachments.get(i); String previewUrl = attachment.getPreviewUrl(); String description = attachment.getDescription(); boolean hasDescription = !TextUtils.isEmpty(description); if (hasDescription) { imageView.setContentDescription(description); } else { imageView.setContentDescription(imageView.getContext().getString(R.string.action_view_media)); } loadImage( imageView, showingContent ? previewUrl : null, attachment.getMeta(), useBlurhash ? attachment.getBlurhash() : null ); final Attachment.Type type = attachment.getType(); if (showingContent && (type == Attachment.Type.VIDEO || type == Attachment.Type.GIFV)) { imageView.setForegroundGravity(Gravity.CENTER); imageView.setForeground(AppCompatResources.getDrawable(itemView.getContext(), R.drawable.play_indicator)); } else { imageView.setForeground(null); } final CharSequence formattedDescription = AttachmentHelper.getFormattedDescription(attachment, imageView.getContext()); setAttachmentClickListener(imageView, listener, i, formattedDescription, true); if (filter != null) { sensitiveMediaWarning.setText(sensitiveMediaWarning.getContext().getString(R.string.status_filter_placeholder_label_format, filter.getTitle())); } else if (sensitive) { sensitiveMediaWarning.setText(R.string.post_sensitive_media_title); } else { sensitiveMediaWarning.setText(R.string.post_media_hidden_title); } sensitiveMediaWarning.setVisibility(showingContent ? View.GONE : View.VISIBLE); sensitiveMediaShow.setVisibility(showingContent ? View.VISIBLE : View.GONE); descriptionIndicator.setVisibility(hasDescription && showingContent ? View.VISIBLE : View.GONE); sensitiveMediaShow.setOnClickListener(v -> { if (getBindingAdapterPosition() != RecyclerView.NO_POSITION) { listener.onContentHiddenChange(false, getBindingAdapterPosition()); } v.setVisibility(View.GONE); sensitiveMediaWarning.setVisibility(View.VISIBLE); descriptionIndicator.setVisibility(View.GONE); }); sensitiveMediaWarning.setOnClickListener(v -> { if (getBindingAdapterPosition() != RecyclerView.NO_POSITION) { listener.onContentHiddenChange(true, getBindingAdapterPosition()); } v.setVisibility(View.GONE); sensitiveMediaShow.setVisibility(View.VISIBLE); descriptionIndicator.setVisibility(hasDescription ? View.VISIBLE : View.GONE); }); return null; }); } @DrawableRes private static int getLabelIcon(Attachment.Type type) { return switch (type) { case IMAGE -> R.drawable.ic_image_24dp; case GIFV -> R.drawable.ic_gif_box_24dp; case VIDEO -> R.drawable.ic_slideshow_24dp; case AUDIO -> R.drawable.ic_music_box_24dp; default -> R.drawable.ic_attach_file_24dp; }; } 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]; mediaLabels[index].setText(label); } 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]; if (i < attachments.size()) { Attachment attachment = attachments.get(i); mediaLabelContainers[i].setVisibility(View.VISIBLE); mediaDescriptions[i] = AttachmentHelper.getFormattedDescription(attachment, context); updateMediaLabel(i, sensitive, showingContent); // Set the icon next to the label. int drawableId = getLabelIcon(attachments.get(0).getType()); mediaLabel.setCompoundDrawablesRelativeWithIntrinsicBounds(drawableId, 0, 0, 0); setAttachmentClickListener(mediaLabel, listener, i, mediaDescriptions[i], false); } else { mediaLabelContainers[i].setVisibility(View.GONE); } } } private void setAttachmentClickListener(@NonNull View view, @NonNull StatusActionListener listener, int index, CharSequence description, boolean animateTransition) { view.setOnClickListener(v -> { int position = getBindingAdapterPosition(); if (position != RecyclerView.NO_POSITION) { if (sensitiveMediaWarning.getVisibility() == View.VISIBLE) { listener.onContentHiddenChange(true, getBindingAdapterPosition()); } else { listener.onViewMedia(position, index, animateTransition ? v : null); } } }); TooltipCompat.setTooltipText(view, description); } protected void hideSensitiveMediaWarning() { sensitiveMediaWarning.setVisibility(View.GONE); sensitiveMediaShow.setVisibility(View.GONE); } 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); displayName.setOnClickListener(profileButtonClickListener); replyButton.setOnClickListener(v -> { int position = getBindingAdapterPosition(); if (position != RecyclerView.NO_POSITION) { listener.onReply(position); } }); if (reblogButton != null) { reblogButton.setEventListener((button, buttonState) -> { // return true to play animation int position = getBindingAdapterPosition(); if (position != RecyclerView.NO_POSITION) { listener.onReblog(!buttonState, position, null, button); } return false; }); } favouriteButton.setEventListener((button, buttonState) -> { // return true to play animation int position = getBindingAdapterPosition(); if (position != RecyclerView.NO_POSITION) { listener.onFavourite(!buttonState, position, button); } return false; }); bookmarkButton.setEventListener((button, buttonState) -> { int position = getBindingAdapterPosition(); if (position != RecyclerView.NO_POSITION) { listener.onBookmark(!buttonState, position); } return true; }); moreButton.setOnClickListener(v -> { int position = getBindingAdapterPosition(); if (position != RecyclerView.NO_POSITION) { listener.onMore(v, position); } }); /* Even though the content TextView is a child of the container, it won't respond to clicks * if it contains URLSpans without also setting its listener. The surrounding spans will * just eat the clicks instead of deferring to the parent listener, but WILL respond to a * listener directly on the TextView, for whatever reason. */ View.OnClickListener viewThreadListener = v -> { int position = getBindingAdapterPosition(); if (position != RecyclerView.NO_POSITION) { listener.onViewThread(position); } }; content.setOnClickListener(viewThreadListener); itemView.setOnClickListener(viewThreadListener); } public void setupWithStatus(@NonNull StatusViewData.Concrete status, @NonNull final StatusActionListener listener, @NonNull StatusDisplayOptions statusDisplayOptions, @NonNull List payloads, final boolean showStatusInfo) { if (payloads.isEmpty()) { Status actionable = status.getActionable(); setDisplayName(actionable.getAccount().getName(), actionable.getAccount().getEmojis(), statusDisplayOptions); setUsername(actionable.getAccount().getUsername()); setMetaData(status, statusDisplayOptions, listener); setReplyButtonImage(actionable.isReply()); setReplyCount(actionable.getRepliesCount(), statusDisplayOptions.showStatsInline()); setAvatar(actionable.getAccount().getAvatar(), status.getRebloggedAvatar(), actionable.getAccount().getBot(), statusDisplayOptions); setReblogged(actionable.getReblogged()); setFavourited(actionable.getFavourited()); setBookmarked(actionable.getBookmarked()); List attachments = status.getAttachments(); boolean sensitive = actionable.getSensitive(); if (attachments.isEmpty()) { mediaContainer.setVisibility(View.GONE); } else if (statusDisplayOptions.mediaPreviewEnabled() && hasPreviewableAttachment(attachments)) { mediaContainer.setVisibility(View.VISIBLE); setMediaPreviews(attachments, sensitive, listener, status.isShowingContent(), statusDisplayOptions.useBlurhash(), status.getFilter()); if (attachments.isEmpty()) { hideSensitiveMediaWarning(); } // Hide the unused label. for (MaterialCardView mediaLabelContainer : mediaLabelContainers) { mediaLabelContainer.setVisibility(View.GONE); } } else { mediaContainer.setVisibility(View.VISIBLE); setMediaLabel(attachments, sensitive, listener, status.isShowingContent()); // Hide all unused views. mediaPreview.setVisibility(View.GONE); hideSensitiveMediaWarning(); } setupCard(status, status.isExpanded(), !status.isShowingContent(), statusDisplayOptions.cardViewMode(), statusDisplayOptions, listener); setupButtons(listener, actionable.getAccount().getId(), status.getContent().toString(), statusDisplayOptions); setTranslationStatus(status, listener); setRebloggingEnabled(actionable.isRebloggingAllowed(), actionable.getVisibility()); setSpoilerAndContent(status, statusDisplayOptions, listener); setDescriptionForStatus(status, statusDisplayOptions); // Workaround for RecyclerView 1.0.0 / androidx.core 1.0.0 // RecyclerView tries to set AccessibilityDelegateCompat to null // but ViewCompat code replaces is with the default one. RecyclerView never // fetches another one from its delegate because it checks that it's set so we remove it // and let RecyclerView ask for a new delegate. itemView.setAccessibilityDelegate(null); } else { for (Object item : payloads) { if (Key.KEY_CREATED.equals(item)) { setMetaData(status, statusDisplayOptions, listener); if (status.getStatus().getCard() != null && status.getStatus().getCard().getPublishedAt() != null) { // there is a preview card showing the published time, we need to refresh it as well setupCard(status, status.isExpanded(), !status.isShowingContent(), statusDisplayOptions.cardViewMode(), statusDisplayOptions, listener); } break; } } } } 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); } } protected static boolean hasPreviewableAttachment(@NonNull List attachments) { for (Attachment attachment : attachments) { if (attachment.getType() == Attachment.Type.AUDIO || attachment.getType() == Attachment.Type.UNKNOWN) { return false; } } return true; } private void setDescriptionForStatus(@NonNull StatusViewData.Concrete status, StatusDisplayOptions statusDisplayOptions) { Context context = itemView.getContext(); Status actionable = status.getActionable(); String description = context.getString(R.string.description_status, // 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()); } else { return ""; } } private static CharSequence getMediaDescription(Context context, @NonNull StatusViewData.Concrete viewData) { if (viewData.getAttachments().isEmpty()) { return ""; } StringBuilder mediaDescriptions = CollectionsKt.fold( 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); } private static CharSequence getContentWarningDescription(Context context, @NonNull StatusViewData.Concrete status) { if (!TextUtils.isEmpty(status.getSpoilerText())) { return context.getString(R.string.description_post_cw, status.getSpoilerText()); } else { return ""; } } @NonNull protected static CharSequence getVisibilityDescription(@NonNull Context context, @Nullable Status.Visibility visibility) { if (visibility == null) { return ""; } int resource; switch (visibility) { case PUBLIC: resource = R.string.description_visibility_public; break; case UNLISTED: resource = R.string.description_visibility_unlisted; break; case PRIVATE: resource = R.string.description_visibility_private; break; case DIRECT: resource = R.string.description_visibility_direct; break; default: return ""; } return context.getString(resource); } private CharSequence getPollDescription(@NonNull StatusViewData.Concrete status, Context context, StatusDisplayOptions statusDisplayOptions) { PollViewData poll = PollViewDataKt.toViewData(status.getPoll()); if (poll == null) { return ""; } else { Object[] args = new CharSequence[5]; List options = poll.getOptions(); for (int i = 0; i < args.length; i++) { if (i < options.size()) { int percent = PollViewDataKt.calculatePercent(options.get(i).getVotesCount(), poll.getVotersCount(), poll.getVotesCount()); args[i] = buildDescription(options.get(i).getTitle(), percent, options.get(i).getVoted(), context, null); } else { args[i] = ""; } } args[4] = getPollInfoText(System.currentTimeMillis(), poll, statusDisplayOptions, context); return context.getString(R.string.description_poll, args); } } @NonNull protected CharSequence getFavsText(@NonNull Context context, int count) { return getMetaDataText(context, R.plurals.favs, count); } @NonNull protected CharSequence getReblogsText(@NonNull Context context, int count) { return getMetaDataText(context, R.plurals.reblogs, count); } private CharSequence getMetaDataText(@NonNull Context context, @PluralsRes int text, int count) { String countString = numberFormat.format(count); String textString = context.getResources().getQuantityString(text, count, countString); SpannableStringBuilder sb = new SpannableStringBuilder(textString); int countIndex = textString.indexOf(countString); sb.setSpan(new StyleSpan(Typeface.BOLD), countIndex, countIndex + countString.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); return sb; } private void setupPoll(PollViewData poll, List emojis, StatusDisplayOptions statusDisplayOptions, StatusActionListener listener) { long timestamp = System.currentTimeMillis(); boolean expired = poll.getExpired() || (poll.getExpiresAt() != null && timestamp > poll.getExpiresAt().getTime()); Context context = pollDescription.getContext(); pollOptions.setVisibility(View.VISIBLE); if (expired || poll.getVoted()) { // no voting possible View.OnClickListener viewThreadListener = v -> { int position = getBindingAdapterPosition(); if (position != RecyclerView.NO_POSITION) { listener.onViewThread(position); } }; pollAdapter.setup( poll.getOptions(), poll.getVotesCount(), poll.getVotersCount(), emojis, PollAdapter.RESULT, viewThreadListener, statusDisplayOptions.animateEmojis() ); pollButton.setVisibility(View.GONE); pollResultsButton.setVisibility(View.GONE); } else { // voting possible pollAdapter.setup( poll.getOptions(), poll.getVotesCount(), poll.getVotersCount(), emojis, poll.getMultiple() ? PollAdapter.MULTIPLE : PollAdapter.SINGLE, null, statusDisplayOptions.animateEmojis() ); pollButton.setVisibility(View.VISIBLE); pollResultsButton.setVisibility(View.VISIBLE); pollButton.setOnClickListener(v -> { int position = getBindingAdapterPosition(); if (position != RecyclerView.NO_POSITION) { List pollResult = pollAdapter.getSelected(); if (!pollResult.isEmpty()) { listener.onVoteInPoll(position, pollResult); } } }); pollResultsButton.setOnClickListener(v -> { int position = getBindingAdapterPosition(); if (position != RecyclerView.NO_POSITION) { listener.onShowPollResults(position); } }); } pollDescription.setVisibility(View.VISIBLE); pollDescription.setText(getPollInfoText(timestamp, poll, statusDisplayOptions, context)); } private CharSequence getPollInfoText(long timestamp, PollViewData poll, StatusDisplayOptions statusDisplayOptions, Context context) { String votesText; if (poll.getVotersCount() == null) { String voters = numberFormat.format(poll.getVotesCount()); votesText = context.getResources().getQuantityString(R.plurals.poll_info_votes, poll.getVotesCount(), voters); } else { String voters = numberFormat.format(poll.getVotersCount()); votesText = context.getResources().getQuantityString(R.plurals.poll_info_people, poll.getVotersCount(), voters); } CharSequence pollDurationInfo; if (poll.getExpired()) { pollDurationInfo = context.getString(R.string.poll_info_closed); } else if (poll.getExpiresAt() == null) { return votesText; } else { if (statusDisplayOptions.useAbsoluteTime()) { pollDurationInfo = context.getString(R.string.poll_info_time_absolute, absoluteTimeFormatter.format(poll.getExpiresAt(), false)); } else { pollDurationInfo = TimestampUtils.formatPollDuration(pollDescription.getContext(), poll.getExpiresAt().getTime(), timestamp); } } return pollDescription.getContext().getString(R.string.poll_info_format, votesText, pollDurationInfo); } protected void setupCard( final @NonNull StatusViewData.Concrete status, boolean expanded, boolean blurMedia, final @NonNull CardViewMode cardViewMode, final @NonNull StatusDisplayOptions statusDisplayOptions, final @NonNull StatusActionListener listener ) { if (cardView == null) { return; } final Context context = cardView.getContext(); final Status actionable = status.getActionable(); final PreviewCard card = actionable.getCard(); if (cardViewMode != CardViewMode.NONE && actionable.getAttachments().isEmpty() && actionable.getPoll() == null && card != null && !TextUtils.isEmpty(card.getUrl()) && (TextUtils.isEmpty(actionable.getSpoilerText()) || expanded) && (!status.isCollapsible() || !status.isCollapsed())) { cardView.setVisibility(View.VISIBLE); cardTitle.setText(card.getTitle()); String providerName = card.getProviderName(); if (TextUtils.isEmpty(providerName)) { providerName = Uri.parse(card.getUrl()).getHost(); } if (TextUtils.isEmpty(providerName)) { cardMetadata.setVisibility(View.GONE); } else { cardMetadata.setVisibility(View.VISIBLE); if (card.getPublishedAt() == null) { cardMetadata.setText(providerName); } else { String metadataJoiner = context.getString(R.string.metadata_joiner); cardMetadata.setText(providerName + metadataJoiner + TimestampUtils.getRelativeTimeSpanString(context, card.getPublishedAt().getTime(), System.currentTimeMillis())); } } String cardAuthorName; final TimelineAccount cardAuthorAccount; if (card.getAuthors().isEmpty()) { cardAuthorAccount = null; cardAuthorName = card.getAuthorName(); } else { cardAuthorName = card.getAuthors().get(0).getName(); cardAuthorAccount = card.getAuthors().get(0).getAccount(); if (cardAuthorAccount != null) { cardAuthorName = cardAuthorAccount.getName(); } } final boolean hasNoAuthorName = TextUtils.isEmpty(cardAuthorName); if (hasNoAuthorName && TextUtils.isEmpty(card.getDescription())) { cardAuthor.setVisibility(View.GONE); cardAuthorButton.setVisibility(View.GONE); } else if (hasNoAuthorName) { cardAuthor.setVisibility(View.VISIBLE); cardAuthor.setText(card.getDescription()); cardAuthorButton.setVisibility(View.GONE); } else if (cardAuthorAccount == null) { cardAuthor.setVisibility(View.VISIBLE); cardAuthor.setText(context.getString(R.string.preview_card_by_author, cardAuthorName)); cardAuthorButton.setVisibility(View.GONE); } else { cardAuthorButton.setVisibility(View.VISIBLE); final String buttonText = context.getString(R.string.preview_card_more_by_author, cardAuthorName); final CharSequence emojifiedButtonText = CustomEmojiHelper.emojify(buttonText, cardAuthorAccount.getEmojis(), cardAuthorButton, statusDisplayOptions.animateEmojis()); cardAuthorButton.setText(emojifiedButtonText); cardAuthorButton.setOnClickListener(v-> listener.onViewAccount(cardAuthorAccount.getId())); cardAuthor.setVisibility(View.GONE); } // Statuses from other activitypub sources can be marked sensitive even if there's no media, // so let's blur the preview in that case // If media previews are disabled, show placeholder for cards as well if (statusDisplayOptions.mediaPreviewEnabled() && !blurMedia && !actionable.getSensitive() && !TextUtils.isEmpty(card.getImage())) { int radius = context.getResources().getDimensionPixelSize(R.dimen.inner_card_radius); ShapeAppearanceModel.Builder cardImageShape = ShapeAppearanceModel.builder(); if (card.getWidth() > card.getHeight()) { cardLayout.setOrientation(LinearLayout.VERTICAL); cardImage.getLayoutParams().height = cardImage.getContext().getResources() .getDimensionPixelSize(R.dimen.card_image_vertical_height); cardImage.getLayoutParams().width = ViewGroup.LayoutParams.MATCH_PARENT; cardImageShape.setTopLeftCorner(CornerFamily.ROUNDED, radius); cardImageShape.setTopRightCorner(CornerFamily.ROUNDED, radius); } else { cardLayout.setOrientation(LinearLayout.HORIZONTAL); cardImage.getLayoutParams().height = ViewGroup.LayoutParams.MATCH_PARENT; cardImage.getLayoutParams().width = cardImage.getContext().getResources() .getDimensionPixelSize(R.dimen.card_image_horizontal_width); cardImageShape.setTopLeftCorner(CornerFamily.ROUNDED, radius); cardImageShape.setBottomLeftCorner(CornerFamily.ROUNDED, radius); } cardImage.setShapeAppearanceModel(cardImageShape.build()); cardImage.setScaleType(ImageView.ScaleType.CENTER_CROP); RequestBuilder builder = Glide.with(cardImage.getContext()) .load(card.getImage()); if (statusDisplayOptions.useBlurhash() && !TextUtils.isEmpty(card.getBlurhash())) { builder = builder.placeholder(decodeBlurHash(card.getBlurhash())); } builder.centerInside() .into(cardImage); } else if (statusDisplayOptions.useBlurhash() && !TextUtils.isEmpty(card.getBlurhash())) { int radius = cardImage.getContext().getResources() .getDimensionPixelSize(R.dimen.inner_card_radius); cardLayout.setOrientation(LinearLayout.HORIZONTAL); cardImage.getLayoutParams().height = ViewGroup.LayoutParams.MATCH_PARENT; cardImage.getLayoutParams().width = cardImage.getContext().getResources() .getDimensionPixelSize(R.dimen.card_image_horizontal_width); ShapeAppearanceModel cardImageShape = ShapeAppearanceModel.builder() .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())) .into(cardImage); } else { cardLayout.setOrientation(LinearLayout.HORIZONTAL); cardImage.getLayoutParams().height = ViewGroup.LayoutParams.MATCH_PARENT; cardImage.getLayoutParams().width = cardImage.getContext().getResources() .getDimensionPixelSize(R.dimen.card_image_horizontal_width); cardImage.setShapeAppearanceModel(new ShapeAppearanceModel()); cardImage.setScaleType(ImageView.ScaleType.CENTER); Glide.with(cardImage.getContext()) .load(R.drawable.card_image_placeholder) .into(cardImage); } View.OnClickListener visitLink = v -> listener.onViewUrl(card.getUrl()); cardView.setOnClickListener(visitLink); // View embedded photos in our image viewer instead of opening the browser cardImage.setOnClickListener(card.getType().equals(PreviewCard.TYPE_PHOTO) && !TextUtils.isEmpty(card.getEmbedUrl()) ? v -> cardView.getContext().startActivity(ViewMediaActivity.newSingleImageIntent(cardView.getContext(), card.getEmbedUrl())) : visitLink); } else { cardView.setVisibility(View.GONE); } } public void showStatusContent(boolean show) { int visibility = show ? View.VISIBLE : View.GONE; avatar.setVisibility(visibility); avatarInset.setVisibility(visibility); displayName.setVisibility(visibility); username.setVisibility(visibility); metaInfo.setVisibility(visibility); contentWarningDescription.setVisibility(visibility); contentWarningButton.setVisibility(visibility); content.setVisibility(visibility); cardView.setVisibility(visibility); mediaContainer.setVisibility(visibility); pollOptions.setVisibility(visibility); pollButton.setVisibility(visibility); pollResultsButton.setVisibility(visibility); pollDescription.setVisibility(visibility); replyButton.setVisibility(visibility); reblogButton.setVisibility(visibility); favouriteButton.setVisibility(visibility); bookmarkButton.setVisibility(visibility); moreButton.setVisibility(visibility); } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java ================================================ package com.keylesspalace.tusky.adapter; import android.content.Context; import android.graphics.drawable.Drawable; import android.os.Build; import android.text.SpannableStringBuilder; import android.text.Spanned; import android.text.method.LinkMovementMethod; import android.text.style.DynamicDrawableSpan; import android.text.style.ImageSpan; import android.view.View; import android.widget.TextView; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.content.res.AppCompatResources; import androidx.recyclerview.widget.RecyclerView; import com.keylesspalace.tusky.R; import com.keylesspalace.tusky.entity.Status; import com.keylesspalace.tusky.interfaces.StatusActionListener; 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.viewdata.StatusViewData; import java.text.DateFormat; import java.util.Date; import java.util.List; public class StatusDetailedViewHolder extends StatusBaseViewHolder { private final TextView reblogs; private final TextView favourites; private final View infoDivider; private static final DateFormat dateFormat = DateFormat.getDateTimeInstance(DateFormat.DEFAULT, DateFormat.SHORT); public StatusDetailedViewHolder(@NonNull View view) { super(view); reblogs = view.findViewById(R.id.status_reblogs); favourites = view.findViewById(R.id.status_favourites); infoDivider = view.findViewById(R.id.status_info_divider); } @Override protected void setMetaData(@NonNull StatusViewData.Concrete statusViewData, @NonNull StatusDisplayOptions statusDisplayOptions, @NonNull StatusActionListener listener) { Status status = statusViewData.getActionable(); Status.Visibility visibility = status.getVisibility(); Context context = metaInfo.getContext(); Drawable visibilityIcon = getVisibilityIcon(visibility); CharSequence visibilityString = getVisibilityDescription(context, visibility); SpannableStringBuilder sb = new SpannableStringBuilder(visibilityString); if (visibilityIcon != null) { ImageSpan visibilityIconSpan = new ImageSpan( 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); } String metadataJoiner = context.getString(R.string.metadata_joiner); Date createdAt = status.getCreatedAt(); if (createdAt != null) { sb.append(" "); sb.append(dateFormat.format(createdAt)); } Date editedAt = status.getEditedAt(); if (editedAt != null) { String editedAtString = context.getString(R.string.post_edited, dateFormat.format(editedAt)); sb.append(metadataJoiner); int spanStart = sb.length(); int spanEnd = spanStart + editedAtString.length(); sb.append(editedAtString); if (statusViewData.getStatus().getEditedAt() != null) { NoUnderlineURLSpan editedClickSpan = new NoUnderlineURLSpan("") { @Override public void onClick(@NonNull View view) { listener.onShowEdits(getBindingAdapterPosition()); } }; sb.setSpan(editedClickSpan, spanStart, spanEnd, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); } } 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) { CharSequence text = LinkHelper.createClickableText(app.getName(), app.getWebsite()); sb.append(text); } else { sb.append(app.getName()); } } metaInfo.setMovementMethod(LinkMovementMethod.getInstance()); metaInfo.setText(sb); } private void setReblogAndFavCount(int reblogCount, int favCount, StatusActionListener listener) { reblogs.setText(getReblogsText(reblogs.getContext(), reblogCount)); favourites.setText(getFavsText(favourites.getContext(), favCount)); reblogs.setOnClickListener(v -> { int position = getBindingAdapterPosition(); if (position != RecyclerView.NO_POSITION) { listener.onShowReblogs(position); } }); favourites.setOnClickListener(v -> { int position = getBindingAdapterPosition(); if (position != RecyclerView.NO_POSITION) { listener.onShowFavs(position); } }); } @Override public void setupWithStatus(@NonNull final StatusViewData.Concrete status, @NonNull final StatusActionListener listener, @NonNull StatusDisplayOptions statusDisplayOptions, @NonNull List payloads, final boolean showStatusInfo) { // We never collapse statuses in the detail view StatusViewData.Concrete uncollapsedStatus = (status.isCollapsible() && status.isCollapsed()) ? status.copyWithCollapsed(false) : status; super.setupWithStatus(uncollapsedStatus, listener, statusDisplayOptions, payloads, showStatusInfo); setupCard(uncollapsedStatus, status.isExpanded(), !status.isShowingContent(), CardViewMode.FULL_WIDTH, statusDisplayOptions, listener); // Always show card for detailed status if (payloads.isEmpty()) { Status actionable = uncollapsedStatus.getActionable(); if (!statusDisplayOptions.hideStats()) { setReblogAndFavCount(actionable.getReblogsCount(), actionable.getFavouritesCount(), listener); } else { hideQuantitativeStats(); } } } private @Nullable Drawable getVisibilityIcon(@Nullable Status.Visibility visibility) { if (visibility == null) { return null; } int visibilityIcon; switch (visibility) { case PUBLIC: visibilityIcon = R.drawable.ic_public_24dp; break; case UNLISTED: visibilityIcon = R.drawable.ic_lock_open_24dp; break; case PRIVATE: visibilityIcon = R.drawable.ic_lock_24dp; break; case DIRECT: visibilityIcon = R.drawable.ic_mail_24dp; break; default: return null; } final Drawable visibilityDrawable = AppCompatResources.getDrawable( this.metaInfo.getContext(), visibilityIcon ); if (visibilityDrawable == null) { return null; } final int size = (int) this.metaInfo.getTextSize(); visibilityDrawable.setBounds( 0, 0, size, size ); visibilityDrawable.setTint(this.metaInfo.getCurrentTextColor()); return visibilityDrawable; } private void hideQuantitativeStats() { reblogs.setVisibility(View.GONE); favourites.setVisibility(View.GONE); infoDivider.setVisibility(View.GONE); } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java ================================================ /* 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.adapter; import android.content.Context; import android.text.InputFilter; import android.text.TextUtils; import android.view.View; import android.widget.Button; import android.widget.TextView; import androidx.annotation.NonNull; import androidx.recyclerview.widget.RecyclerView; import com.keylesspalace.tusky.R; import com.keylesspalace.tusky.entity.Filter; import com.keylesspalace.tusky.entity.Status; import com.keylesspalace.tusky.entity.TimelineAccount; import com.keylesspalace.tusky.interfaces.StatusActionListener; import com.keylesspalace.tusky.util.CustomEmojiHelper; import com.keylesspalace.tusky.util.NumberUtils; import com.keylesspalace.tusky.util.SmartLengthInputFilter; import com.keylesspalace.tusky.util.StatusDisplayOptions; import com.keylesspalace.tusky.util.StringUtils; import com.keylesspalace.tusky.viewdata.StatusViewData; import java.util.Collections; import java.util.List; public class StatusViewHolder extends StatusBaseViewHolder { private static final InputFilter[] COLLAPSE_INPUT_FILTER = new InputFilter[]{SmartLengthInputFilter.INSTANCE}; private static final InputFilter[] NO_INPUT_FILTER = new InputFilter[0]; private final TextView statusInfo; private final Button contentCollapseButton; private final TextView favouritedCountLabel; private final TextView reblogsCountLabel; public StatusViewHolder(@NonNull View itemView) { super(itemView); statusInfo = itemView.findViewById(R.id.status_info); contentCollapseButton = itemView.findViewById(R.id.button_toggle_content); favouritedCountLabel = itemView.findViewById(R.id.status_favourites_count); reblogsCountLabel = itemView.findViewById(R.id.status_insets); } @Override public void setupWithStatus(@NonNull StatusViewData.Concrete status, @NonNull final StatusActionListener listener, @NonNull StatusDisplayOptions statusDisplayOptions, @NonNull List payloads, final boolean showStatusInfo) { if (payloads.isEmpty()) { boolean sensitive = !TextUtils.isEmpty(status.getActionable().getSpoilerText()); boolean expanded = status.isExpanded(); setupCollapsedState(sensitive, expanded, status, listener); if (!showStatusInfo || (status.getFilter() != null && status.getFilter().getAction() == Filter.Action.WARN)) { hideStatusInfo(); } else { Status rebloggingStatus = status.getRebloggingStatus(); boolean isReplyOnly = rebloggingStatus == null && status.isReply(); boolean isReplySelf = isReplyOnly && status.isSelfReply(); boolean hasStatusInfo = rebloggingStatus != null | isReplyOnly; TimelineAccount statusInfoAccount = rebloggingStatus != null ? rebloggingStatus.getAccount() : status.getRepliedToAccount(); if (!hasStatusInfo) { hideStatusInfo(); } else { setStatusInfoContent(statusInfoAccount, isReplyOnly, isReplySelf, statusDisplayOptions); } if (isReplyOnly) { statusInfo.setOnClickListener(null); } else { statusInfo.setOnClickListener(v -> listener.onOpenReblog(getBindingAdapterPosition())); } } } reblogsCountLabel.setVisibility(statusDisplayOptions.showStatsInline() ? View.VISIBLE : View.INVISIBLE); favouritedCountLabel.setVisibility(statusDisplayOptions.showStatsInline() ? View.VISIBLE : View.INVISIBLE); setFavouritedCount(status.getActionable().getFavouritesCount()); setReblogsCount(status.getActionable().getReblogsCount()); super.setupWithStatus(status, listener, statusDisplayOptions, payloads, showStatusInfo); } private void setStatusInfoContent(final TimelineAccount account, final boolean isReply, final boolean isSelfReply, final StatusDisplayOptions statusDisplayOptions) { Context context = statusInfo.getContext(); CharSequence accountName = account != null ? account.getName() : ""; CharSequence wrappedName = StringUtils.unicodeWrap(accountName); CharSequence translatedText = ""; if (!isReply) { translatedText = context.getString(R.string.post_boosted_format, wrappedName); } else if (isSelfReply) { translatedText = context.getString(R.string.post_replied_self); } else { if (account != null && accountName.length() > 0) { translatedText = context.getString(R.string.post_replied_format, wrappedName); } else { translatedText = context.getString(R.string.post_replied); } } CharSequence emojifiedText = CustomEmojiHelper.emojify( translatedText, account != null ? account.getEmojis() : Collections.emptyList(), statusInfo, statusDisplayOptions.animateEmojis() ); statusInfo.setText(emojifiedText); statusInfo.setCompoundDrawablesWithIntrinsicBounds(isReply ? R.drawable.ic_reply_18dp : R.drawable.ic_repeat_18dp, 0, 0, 0); statusInfo.setVisibility(View.VISIBLE); } protected void setReblogsCount(int reblogsCount) { reblogsCountLabel.setText(NumberUtils.formatNumber(reblogsCount, 1000)); } protected void setFavouritedCount(int favouritedCount) { favouritedCountLabel.setText(NumberUtils.formatNumber(favouritedCount, 1000)); } protected void hideStatusInfo() { statusInfo.setVisibility(View.GONE); } protected @NonNull TextView getStatusInfo() { return statusInfo; } private void setupCollapsedState(boolean sensitive, boolean expanded, final StatusViewData.Concrete status, final StatusActionListener listener) { /* input filter for TextViews have to be set before text */ if (status.isCollapsible() && (!sensitive || expanded)) { contentCollapseButton.setOnClickListener(view -> { int position = getBindingAdapterPosition(); if (position != RecyclerView.NO_POSITION) listener.onContentCollapsedChange(!status.isCollapsed(), position); }); contentCollapseButton.setVisibility(View.VISIBLE); if (status.isCollapsed()) { contentCollapseButton.setText(R.string.post_content_warning_show_more); content.setFilters(COLLAPSE_INPUT_FILTER); } else { contentCollapseButton.setText(R.string.post_content_warning_show_less); content.setFilters(NO_INPUT_FILTER); } } else { contentCollapseButton.setVisibility(View.GONE); content.setFilters(NO_INPUT_FILTER); } } public void showStatusContent(boolean show) { super.showStatusContent(show); contentCollapseButton.setVisibility(show ? View.VISIBLE : View.GONE); } @Override protected void toggleExpandedState(boolean sensitive, boolean expanded, @NonNull StatusViewData.Concrete status, @NonNull StatusDisplayOptions statusDisplayOptions, @NonNull final StatusActionListener listener) { setupCollapsedState(sensitive, expanded, status, listener); super.toggleExpandedState(sensitive, expanded, status, statusDisplayOptions, listener); } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/adapter/TabAdapter.kt ================================================ /* Copyright 2019 Conny Duck * * 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.view.LayoutInflater import android.view.MotionEvent import android.view.ViewGroup import androidx.core.view.size import androidx.recyclerview.widget.RecyclerView import androidx.viewbinding.ViewBinding import com.google.android.material.chip.Chip import com.keylesspalace.tusky.HASHTAG import com.keylesspalace.tusky.LIST import com.keylesspalace.tusky.R import com.keylesspalace.tusky.TabData import com.keylesspalace.tusky.databinding.ItemTabPreferenceBinding import com.keylesspalace.tusky.databinding.ItemTabPreferenceSmallBinding import com.keylesspalace.tusky.util.BindingHolder import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.setDrawableTint import com.keylesspalace.tusky.util.show interface ItemInteractionListener { fun onTabAdded(tab: TabData) fun onTabRemoved(position: Int) fun onStartDelete(viewHolder: RecyclerView.ViewHolder) fun onStartDrag(viewHolder: RecyclerView.ViewHolder) fun onActionChipClicked(tab: TabData, tabPosition: Int) fun onChipClicked(tab: TabData, tabPosition: Int, chipPosition: Int) } class TabAdapter( private var data: List, private val small: Boolean, private val listener: ItemInteractionListener, private var removeButtonEnabled: Boolean = false ) : RecyclerView.Adapter>() { fun updateData(newData: List) { this.data = newData notifyDataSetChanged() } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder { val binding = if (small) { ItemTabPreferenceSmallBinding.inflate( LayoutInflater.from(parent.context), parent, false ) } else { ItemTabPreferenceBinding.inflate(LayoutInflater.from(parent.context), parent, false) } return BindingHolder(binding) } override fun onBindViewHolder(holder: BindingHolder, position: Int) { val context = holder.itemView.context val tab = data[position] if (small) { val binding = holder.binding as ItemTabPreferenceSmallBinding binding.textView.setText(tab.text) binding.textView.setCompoundDrawablesRelativeWithIntrinsicBounds(tab.icon, 0, 0, 0) binding.textView.setOnClickListener { listener.onTabAdded(tab) } } else { val binding = holder.binding as ItemTabPreferenceBinding if (tab.id == LIST) { binding.textView.text = tab.arguments.getOrNull(1).orEmpty() } else { binding.textView.setText(tab.text) } binding.textView.setCompoundDrawablesRelativeWithIntrinsicBounds(tab.icon, 0, 0, 0) binding.imageView.setOnTouchListener { _, event -> if (event.action == MotionEvent.ACTION_DOWN) { listener.onStartDrag(holder) true } else { false } } binding.removeButton.setOnClickListener { listener.onTabRemoved(holder.bindingAdapterPosition) } binding.removeButton.isEnabled = removeButtonEnabled setDrawableTint( holder.itemView.context, binding.removeButton.drawable, (if (removeButtonEnabled) android.R.attr.textColorTertiary else R.attr.textColorDisabled) ) if (tab.id == HASHTAG) { binding.chipGroup.show() /* * The chip group will always contain the actionChip (it is defined in the xml layout). * The other dynamic chips are inserted in front of the actionChip. * This code tries to reuse already added chips to reduce the number of Views created. */ tab.arguments.forEachIndexed { i, arg -> val chip = binding.chipGroup.getChildAt(i).takeUnless { it.id == R.id.actionChip } as Chip? ?: Chip(context).apply { setCloseIconResource(R.drawable.ic_cancel_24dp_filled) isCheckable = false binding.chipGroup.addView(this, binding.chipGroup.size - 1) } chip.text = arg if (tab.arguments.size <= 1) { chip.isCloseIconVisible = false chip.setOnClickListener(null) } else { chip.isCloseIconVisible = true chip.setOnClickListener { listener.onChipClicked(tab, holder.bindingAdapterPosition, i) } } } while (binding.chipGroup.size - 1 > tab.arguments.size) { binding.chipGroup.removeViewAt(tab.arguments.size) } binding.actionChip.setOnClickListener { listener.onActionChipClicked(tab, holder.bindingAdapterPosition) } } else { binding.chipGroup.hide() } } } override fun getItemCount() = data.size fun setRemoveButtonVisible(enabled: Boolean) { if (removeButtonEnabled != enabled) { removeButtonEnabled = enabled notifyDataSetChanged() } } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/appstore/CacheUpdater.kt ================================================ package com.keylesspalace.tusky.appstore import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AppDatabase import com.squareup.moshi.Moshi import javax.inject.Inject import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.launch /** * Updates the database cache in response to events. * This is important for the home timeline and notifications to be up to date. */ class CacheUpdater @Inject constructor( eventHub: EventHub, accountManager: AccountManager, appDatabase: AppDatabase, moshi: Moshi ) { private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val timelineDao = appDatabase.timelineDao() private val statusDao = appDatabase.timelineStatusDao() private val notificationsDao = appDatabase.notificationsDao() init { scope.launch { eventHub.events.collect { event -> val tuskyAccountId = accountManager.activeAccount?.id ?: return@collect when (event) { is StatusChangedEvent -> statusDao.update(tuskyAccountId = tuskyAccountId, status = event.status) is UnfollowEvent -> timelineDao.removeStatusesAndReblogsByUser(tuskyAccountId, event.accountId) is BlockEvent -> removeAllByUser(tuskyAccountId, event.accountId) is MuteEvent -> removeAllByUser(tuskyAccountId, event.accountId) is DomainMuteEvent -> { timelineDao.deleteAllFromInstance(tuskyAccountId, event.instance) notificationsDao.deleteAllFromInstance(tuskyAccountId, event.instance) } is StatusDeletedEvent -> { timelineDao.deleteAllWithStatus(tuskyAccountId, event.statusId) notificationsDao.deleteAllWithStatus(tuskyAccountId, event.statusId) } is PollVoteEvent -> statusDao.setVoted(tuskyAccountId, event.statusId, event.poll) is PollShowResultsEvent -> statusDao.setShowResults(tuskyAccountId, event.statusId) } } } } private suspend fun removeAllByUser(tuskyAccountId: Long, accountId: String) { timelineDao.removeAllByUser(tuskyAccountId, accountId) notificationsDao.removeAllByUser(tuskyAccountId, accountId) } fun stop() { this.scope.cancel() } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/appstore/Events.kt ================================================ package com.keylesspalace.tusky.appstore import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.entity.Filter import com.keylesspalace.tusky.entity.Notification import com.keylesspalace.tusky.entity.Poll import com.keylesspalace.tusky.entity.Status data class StatusChangedEvent(val status: Status) : 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 scheduledStatusId: String) : Event data class ProfileEditedEvent(val newProfileData: Account) : Event data class PreferenceChangedEvent(val preferenceKey: String) : Event data class PollVoteEvent(val statusId: String, val poll: Poll) : Event data class PollShowResultsEvent(val statusId: String) : Event data class DomainMuteEvent(val instance: String) : Event data class AnnouncementReadEvent(val announcementId: String) : 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 ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/appstore/EventsHub.kt ================================================ package com.keylesspalace.tusky.appstore import javax.inject.Inject import javax.inject.Singleton import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow interface Event @Singleton class EventHub @Inject constructor() { private val _events = MutableSharedFlow() val events: SharedFlow = _events.asSharedFlow() suspend fun dispatch(event: Event) { _events.emit(event) } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/account/AccountActivity.kt ================================================ /* Copyright 2018 Conny Duck * * 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 import android.animation.ArgbEvaluator import android.content.Context import android.content.Intent import android.content.res.ColorStateList import android.graphics.Color import android.graphics.Typeface import android.os.Build 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 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.content.res.AppCompatResources import androidx.core.app.ActivityOptionsCompat import androidx.core.graphics.ColorUtils import androidx.core.graphics.toColorInt import androidx.core.view.MenuProvider import androidx.core.view.ViewCompat import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat.Type.systemBars import androidx.core.view.updateLayoutParams import androidx.core.view.updatePadding import androidx.core.widget.doAfterTextChanged import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.LinearLayoutManager import androidx.viewpager2.widget.MarginPageTransformer import com.bumptech.glide.Glide import com.google.android.material.R as materialR 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.dialog.MaterialAlertDialogBuilder import com.google.android.material.floatingactionbutton.FloatingActionButton import com.google.android.material.shape.MaterialShapeDrawable import com.google.android.material.shape.ShapeAppearanceModel import com.google.android.material.snackbar.Snackbar import com.google.android.material.tabs.TabLayout import com.google.android.material.tabs.TabLayoutMediator import com.keylesspalace.tusky.BottomSheetActivity 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.ListSelectionFragment import com.keylesspalace.tusky.components.accountlist.AccountListActivity import com.keylesspalace.tusky.components.compose.ComposeActivity import com.keylesspalace.tusky.components.report.ReportActivity import com.keylesspalace.tusky.databinding.ActivityAccountBinding import com.keylesspalace.tusky.db.DraftsAlert import com.keylesspalace.tusky.db.entity.AccountEntity import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.entity.Relationship import com.keylesspalace.tusky.interfaces.AccountSelectionListener import com.keylesspalace.tusky.interfaces.ActionButtonActivity import com.keylesspalace.tusky.interfaces.LinkListener import com.keylesspalace.tusky.interfaces.ReselectableFragment import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.util.Error import com.keylesspalace.tusky.util.Loading import com.keylesspalace.tusky.util.Success import com.keylesspalace.tusky.util.copyToClipboard import com.keylesspalace.tusky.util.emojify import com.keylesspalace.tusky.util.ensureBottomMargin import com.keylesspalace.tusky.util.getDomain import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.loadAvatar 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.viewBinding import com.keylesspalace.tusky.util.visible import com.keylesspalace.tusky.view.showMuteAccountDialog import dagger.hilt.android.AndroidEntryPoint import java.text.NumberFormat import java.text.ParseException import java.text.SimpleDateFormat import java.util.Locale import javax.inject.Inject import kotlin.math.abs import kotlinx.coroutines.launch @AndroidEntryPoint class AccountActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider, LinkListener { @Inject lateinit var draftsAlert: DraftsAlert private val viewModel: AccountViewModel by viewModels() private val binding: ActivityAccountBinding by viewBinding(ActivityAccountBinding::inflate) private lateinit var accountFieldAdapter: AccountFieldAdapter private var followState: FollowState = FollowState.NOT_FOLLOWING private var blocking: Boolean = false private var muting: Boolean = false private var blockingDomain: Boolean = false private var showingReblogs: Boolean = false private var subscribing: Boolean = false private var loadedAccount: Account? = null private var animateAvatar: Boolean = false private var animateEmojis: Boolean = false // for scroll animation private var oldOffset: Int = 0 @ColorInt private var toolbarColor: Int = 0 @ColorInt private var statusBarColorTransparent: Int = 0 @ColorInt private var statusBarColorOpaque: Int = 0 private var avatarSize: Float = 0f @Px private var titleVisibleHeight: Int = 0 private lateinit var domain: String private enum class FollowState { NOT_FOLLOWING, FOLLOWING, REQUESTED } private lateinit var adapter: AccountPagerAdapter private var noteWatcher: TextWatcher? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) loadResources() makeNotificationBarTransparent() setContentView(binding.root) addMenuProvider(this) // Obtain information to fill out the profile. viewModel.setAccountInfo(intent.getStringExtra(KEY_ACCOUNT_ID)!!) animateAvatar = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false) animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false) handleWindowInsets() setupToolbar() setupTabs() setupAccountViews() setupRefreshLayout() subscribeObservables() if (viewModel.isSelf) { updateButtons() binding.saveNoteInfo.hide() } else { binding.saveNoteInfo.visibility = View.INVISIBLE } } /** * Load colors and dimensions from resources */ private fun loadResources() { toolbarColor = MaterialColors.getColor(binding.accountToolbar, materialR.attr.colorSurface) statusBarColorTransparent = getColor(R.color.transparent_statusbar_background) statusBarColorOpaque = MaterialColors.getColor(binding.accountToolbar, materialR.attr.colorPrimaryDark) avatarSize = resources.getDimension(R.dimen.account_activity_avatar_size) titleVisibleHeight = resources.getDimensionPixelSize(R.dimen.account_activity_scroll_title_visible_height) } /** * Setup account widgets visibility and actions */ private fun setupAccountViews() { // Initialise the default UI states. binding.accountFloatingActionButton.hide() binding.accountFollowButton.hide() binding.accountMuteButton.hide() binding.accountFollowsYouTextView.hide() // setup the RecyclerView for the account fields accountFieldAdapter = AccountFieldAdapter(this, animateEmojis) binding.accountFieldList.isNestedScrollingEnabled = false binding.accountFieldList.layoutManager = LinearLayoutManager(this) binding.accountFieldList.adapter = accountFieldAdapter val accountListClickListener = { v: View -> val type = when (v.id) { R.id.accountFollowers -> AccountListActivity.Type.FOLLOWERS R.id.accountFollowing -> AccountListActivity.Type.FOLLOWS else -> throw AssertionError() } val accountListIntent = AccountListActivity.newIntent(this, type, viewModel.accountId) startActivityWithSlideInAnimation(accountListIntent) } binding.accountFollowers.setOnClickListener(accountListClickListener) binding.accountFollowing.setOnClickListener(accountListClickListener) binding.accountStatuses.setOnClickListener { // Make nice ripple effect on tab binding.accountTabLayout.getTabAt(0)!!.select() val poorTabView = (binding.accountTabLayout.getChildAt(0) as ViewGroup).getChildAt(0) poorTabView.isPressed = true binding.accountTabLayout.postDelayed({ poorTabView.isPressed = false }, 300) } // If wellbeing mode is enabled, follow stats and posts count should be hidden val wellbeingEnabled = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_PROFILE, false) if (wellbeingEnabled) { binding.accountStatuses.hide() binding.accountFollowers.hide() binding.accountFollowing.hide() } } /** * Init timeline tabs */ private fun setupTabs() { // Setup the tabs and timeline pager. adapter = AccountPagerAdapter(this, viewModel.accountId) binding.accountFragmentViewPager.reduceSwipeSensitivity() 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) ) TabLayoutMediator( binding.accountTabLayout, binding.accountFragmentViewPager ) { tab, position -> tab.text = pageTitles[position] }.attach() val pageMargin = resources.getDimensionPixelSize(R.dimen.tab_page_margin) binding.accountFragmentViewPager.setPageTransformer(MarginPageTransformer(pageMargin)) val enableSwipeForTabs = preferences.getBoolean(PrefKeys.ENABLE_SWIPE_FOR_TABS, true) binding.accountFragmentViewPager.isUserInputEnabled = enableSwipeForTabs binding.accountTabLayout.addOnTabSelectedListener(object : TabLayout.OnTabSelectedListener { override fun onTabReselected(tab: TabLayout.Tab?) { tab?.position?.let { position -> (adapter.getFragment(position) as? ReselectableFragment)?.onReselect() } } override fun onTabUnselected(tab: TabLayout.Tab?) {} override fun onTabSelected(tab: TabLayout.Tab?) {} }) } private fun handleWindowInsets() { binding.accountFloatingActionButton.ensureBottomMargin() ViewCompat.setOnApplyWindowInsetsListener(binding.accountCoordinatorLayout) { _, insets -> val systemBarInsets = insets.getInsets(systemBars()) val top = systemBarInsets.top binding.accountToolbar.updateLayoutParams { topMargin = top } binding.swipeToRefreshLayout.setProgressViewEndTarget( false, top + resources.getDimensionPixelSize(R.dimen.account_swiperefresh_distance) ) insets.inset(0, top, 0, 0) } } private fun setupToolbar() { // Setup the toolbar. setSupportActionBar(binding.accountToolbar) supportActionBar?.run { setDisplayHomeAsUpEnabled(true) setDisplayShowHomeEnabled(true) setDisplayShowTitleEnabled(false) } binding.accountToolbar.setBackgroundColor(Color.TRANSPARENT) binding.accountToolbar.setNavigationIcon(R.drawable.toolbar_icon_arrow_back_with_background) binding.accountToolbar.overflowIcon = AppCompatResources.getDrawable(this, R.drawable.toolbar_icon_more_with_background) val avatarBackground = MaterialShapeDrawable().apply { fillColor = ColorStateList.valueOf(toolbarColor) shapeAppearanceModel = ShapeAppearanceModel.builder() .setAllCornerSizes(resources.getDimension(R.dimen.account_avatar_background_radius)) .build() } binding.accountAvatarImageView.background = avatarBackground // Add a listener to change the toolbar icon color when it enters/exits its collapsed state. binding.accountAppBarLayout.addOnOffsetChangedListener(object : AppBarLayout.OnOffsetChangedListener { override fun onOffsetChanged(appBarLayout: AppBarLayout, verticalOffset: Int) { if (verticalOffset == oldOffset) { return } oldOffset = verticalOffset if (titleVisibleHeight + verticalOffset < 0) { supportActionBar?.setDisplayShowTitleEnabled(true) } else { supportActionBar?.setDisplayShowTitleEnabled(false) } val scaledAvatarSize = (avatarSize + verticalOffset) / avatarSize binding.accountAvatarImageView.scaleX = scaledAvatarSize binding.accountAvatarImageView.scaleY = scaledAvatarSize binding.accountAvatarImageView.visible(scaledAvatarSize > 0) val transparencyPercent = (abs(verticalOffset) / titleVisibleHeight.toFloat()).coerceAtMost( 1f ) if (Build.VERSION.SDK_INT < Build.VERSION_CODES.VANILLA_ICE_CREAM) { @Suppress("DEPRECATION") window.statusBarColor = argbEvaluator.evaluate(transparencyPercent, statusBarColorTransparent, statusBarColorOpaque) as Int } val evaluatedToolbarColor = argbEvaluator.evaluate( transparencyPercent, Color.TRANSPARENT, toolbarColor ) as Int binding.accountToolbar.setBackgroundColor(evaluatedToolbarColor) binding.accountStatusBarScrim.setBackgroundColor(evaluatedToolbarColor) binding.swipeToRefreshLayout.isEnabled = verticalOffset == 0 } }) } private fun makeNotificationBarTransparent() { WindowCompat.setDecorFitsSystemWindows(window, false) if (Build.VERSION.SDK_INT < Build.VERSION_CODES.VANILLA_ICE_CREAM) { @Suppress("DEPRECATION") window.statusBarColor = statusBarColorTransparent } } /** * Subscribe to data loaded at the view model */ private fun subscribeObservables() { lifecycleScope.launch { viewModel.accountData.collect { if (it == null) return@collect when (it) { is Success -> { onAccountChanged(it.data) binding.swipeToRefreshLayout.isEnabled = true } is Error -> { Snackbar.make( binding.accountCoordinatorLayout, R.string.error_generic, Snackbar.LENGTH_LONG ) .setAction(R.string.action_retry) { viewModel.refresh() } .show() binding.swipeToRefreshLayout.isEnabled = true } 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() } } } lifecycleScope.launch { viewModel.noteSaved.collect { binding.saveNoteInfo.visible(it, View.INVISIBLE) } } // "Post failed" dialog should display in this activity draftsAlert.observeInContext(this, true) } private fun onRefresh() { viewModel.refresh() adapter.refreshContent() } /** * Setup swipe to refresh layout */ private fun setupRefreshLayout() { binding.swipeToRefreshLayout.isEnabled = false // will only be enabled after the first load completed binding.swipeToRefreshLayout.setOnRefreshListener { onRefresh() } lifecycleScope.launch { viewModel.isRefreshing.collect { binding.swipeToRefreshLayout.isRefreshing = it } } } private fun onAccountChanged(account: Account?) { loadedAccount = account ?: return val usernameFormatted = getString(R.string.post_username_format, account.username) binding.accountUsernameTextView.text = usernameFormatted binding.accountDisplayNameTextView.text = account.name.emojify(account.emojis, binding.accountDisplayNameTextView, animateEmojis) // Long press on username to copy it to clipboard for (view in listOf(binding.accountUsernameTextView, binding.accountDisplayNameTextView)) { view.setOnLongClickListener { loadedAccount?.let { loadedAccount -> copyToClipboard( getFullUsername(loadedAccount), getString(R.string.account_username_copied), ) } true } } val emojifiedNote = account.note.parseAsMastodonHtml().emojify( account.emojis, binding.accountNoteTextView, animateEmojis ) setClickableText(binding.accountNoteTextView, emojifiedNote, emptyList(), null, this) accountFieldAdapter.fields = account.fields accountFieldAdapter.emojis = account.emojis accountFieldAdapter.notifyDataSetChanged() binding.accountLockedImageView.visible(account.locked) updateAccountAvatar() updateToolbar() updateBadges() updateMovedAccount() updateRemoteAccount() updateAccountJoinedDate() updateAccountStats() invalidateOptionsMenu() binding.accountMuteButton.setOnClickListener { viewModel.unmuteAccount() updateMuteButton() } } 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()) { role.color.toColorInt() } 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.ic_person_24dp, sb, isLight) binding.accountBadgeContainer.addView(badgeView) } } private fun updateAccountJoinedDate() { loadedAccount?.let { account -> try { binding.accountDateJoined.text = resources.getString( R.string.account_date_joined, SimpleDateFormat("MMMM, yyyy", Locale.getDefault()).format(account.createdAt) ) binding.accountDateJoined.visibility = View.VISIBLE } catch (e: ParseException) { binding.accountDateJoined.visibility = View.GONE } } } /** * Load account's avatar and header image */ private fun updateAccountAvatar() { loadedAccount?.let { account -> loadAvatar( account.avatar, binding.accountAvatarImageView, resources.getDimensionPixelSize(R.dimen.avatar_radius_94dp), animateAvatar ) Glide.with(this) .asBitmap() .load(account.header) .centerCrop() .into(binding.accountHeaderImageView) binding.accountAvatarImageView.setOnClickListener { view -> viewImage(view, account.avatar) } binding.accountHeaderImageView.setOnClickListener { view -> viewImage(view, account.header) } } } private fun viewImage(view: View, uri: String) { view.transitionName = uri startActivity( ViewMediaActivity.newSingleImageIntent(view.context, uri), ActivityOptionsCompat.makeSceneTransitionAnimation(this, view, uri).toBundle() ) } /** * Update toolbar views for loaded account */ private fun updateToolbar() { loadedAccount?.let { account -> supportActionBar?.title = account.name.emojify(account.emojis, binding.accountToolbar, animateEmojis) supportActionBar?.subtitle = String.format(getString(R.string.post_username_format), account.username) } } /** * Update moved account info */ private fun updateMovedAccount() { loadedAccount?.moved?.let { movedAccount -> binding.accountMovedView.show() binding.accountMovedView.setOnClickListener { onViewAccount(movedAccount.id) } binding.accountMovedDisplayName.text = movedAccount.name binding.accountMovedUsername.text = getString(R.string.post_username_format, movedAccount.username) val avatarRadius = resources.getDimensionPixelSize(R.dimen.avatar_radius_48dp) loadAvatar(movedAccount.avatar, binding.accountMovedAvatar, avatarRadius, animateAvatar) binding.accountMovedText.text = getString(R.string.account_moved_description, movedAccount.name) } } /** * Check is account remote and update info if so */ private fun updateRemoteAccount() { loadedAccount?.let { account -> if (account.isRemote) { binding.accountRemoveView.show() binding.accountRemoveView.setOnClickListener { openLink(account.url) } } } } /** * Update account stat info */ private fun updateAccountStats() { loadedAccount?.let { account -> val numberFormat = NumberFormat.getNumberInstance() binding.accountFollowersTextView.text = numberFormat.format(account.followersCount) binding.accountFollowingTextView.text = numberFormat.format(account.followingCount) binding.accountStatusesTextView.text = numberFormat.format(account.statusesCount) binding.accountFloatingActionButton.setOnClickListener { mention() } binding.accountFollowButton.setOnClickListener { val confirmFollows = preferences.getBoolean(PrefKeys.CONFIRM_FOLLOWS, false) if (viewModel.isSelf) { val intent = Intent(this@AccountActivity, EditProfileActivity::class.java) startActivity(intent) return@setOnClickListener } if (blocking) { viewModel.changeBlockState() return@setOnClickListener } when (followState) { FollowState.NOT_FOLLOWING -> { if (confirmFollows) { showFollowWarningDialog() } else { viewModel.changeFollowState() } } FollowState.REQUESTED -> { showFollowRequestPendingDialog() } FollowState.FOLLOWING -> { showUnfollowWarningDialog() } } updateFollowButton() updateSubscribeButton() } } } private fun onRelationshipChanged(relation: Relationship) { followState = when { relation.following -> FollowState.FOLLOWING relation.requested -> FollowState.REQUESTED else -> FollowState.NOT_FOLLOWING } blocking = relation.blocking muting = relation.muting blockingDomain = relation.blockingDomain showingReblogs = relation.showingReblogs binding.accountFollowsYouTextView.visible(relation.followedBy) // because subscribing is Pleroma extension, enable it __only__ when we have non-null subscribing field // it's also now supported in Mastodon 3.3.0rc but called notifying and use different API call if (!viewModel.isSelf && followState == FollowState.FOLLOWING && (relation.subscribing != null || relation.notifying != null) ) { binding.accountSubscribeButton.show() binding.accountSubscribeButton.setOnClickListener { viewModel.changeSubscribingState() } if (relation.notifying != null) { subscribing = relation.notifying } else if (relation.subscribing != null) { subscribing = relation.subscribing } } // remove the listener so it doesn't fire on non-user changes binding.accountNoteTextInputLayout.editText?.removeTextChangedListener(noteWatcher) binding.accountNoteTextInputLayout.visible(relation.note != null) binding.accountNoteTextInputLayout.editText?.setText(relation.note) noteWatcher = binding.accountNoteTextInputLayout.editText?.doAfterTextChanged { s -> viewModel.noteChanged(s.toString()) } updateButtons() } private fun updateFollowButton() { if (viewModel.isSelf) { binding.accountFollowButton.setText(R.string.action_edit_own_profile) return } if (blocking) { binding.accountFollowButton.setText(R.string.action_unblock) return } when (followState) { FollowState.NOT_FOLLOWING -> { binding.accountFollowButton.setText(R.string.action_follow) } FollowState.REQUESTED -> { binding.accountFollowButton.setText(R.string.state_follow_requested) } FollowState.FOLLOWING -> { binding.accountFollowButton.setText(R.string.action_unfollow) } } } private fun updateMuteButton() { if (muting) { binding.accountMuteButton.setIconResource(R.drawable.ic_volume_up_24dp) } else { binding.accountMuteButton.hide() } } private fun updateSubscribeButton() { if (followState != FollowState.FOLLOWING) { binding.accountSubscribeButton.hide() } if (subscribing) { binding.accountSubscribeButton.setIconResource(R.drawable.ic_notifications_active_24dp) binding.accountSubscribeButton.contentDescription = getString(R.string.action_unsubscribe_account) } else { binding.accountSubscribeButton.setIconResource(R.drawable.ic_notifications_24dp) binding.accountSubscribeButton.contentDescription = getString(R.string.action_subscribe_account) } } private fun updateButtons() { invalidateOptionsMenu() if (loadedAccount?.moved == null) { binding.accountFollowButton.show() updateFollowButton() updateSubscribeButton() if (blocking) { binding.accountFloatingActionButton.hide() binding.accountMuteButton.hide() } else { binding.accountFloatingActionButton.show() binding.accountMuteButton.visible(muting) updateMuteButton() } } else { binding.accountFloatingActionButton.hide() binding.accountFollowButton.hide() binding.accountMuteButton.hide() binding.accountSubscribeButton.hide() } } override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { menuInflater.inflate(R.menu.account_toolbar, menu) val openAsItem = menu.findItem(R.id.action_open_as) val title = openAsText if (title == null) { openAsItem.isVisible = false } else { openAsItem.title = title } if (!viewModel.isSelf) { val block = menu.findItem(R.id.action_block) block.title = if (blocking) { getString(R.string.action_unblock) } else { getString(R.string.action_block) } val mute = menu.findItem(R.id.action_mute) mute.title = if (muting) { getString(R.string.action_unmute) } else { getString(R.string.action_mute) } loadedAccount?.let { loadedAccount -> val muteDomain = menu.findItem(R.id.action_mute_domain) domain = getDomain(loadedAccount.url) when { // If we can't get the domain, there's no way we can mute it anyway... // 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 -> { muteDomain.title = getString(R.string.action_mute_domain, domain) } } } if (followState == FollowState.FOLLOWING) { val showReblogs = menu.findItem(R.id.action_show_reblogs) showReblogs.title = if (showingReblogs) { getString(R.string.action_hide_reblogs) } else { getString(R.string.action_show_reblogs) } } else { menu.removeItem(R.id.action_show_reblogs) } } else { // It shouldn't be possible to block, mute or report yourself. menu.removeItem(R.id.action_block) menu.removeItem(R.id.action_mute) menu.removeItem(R.id.action_mute_domain) menu.removeItem(R.id.action_show_reblogs) menu.removeItem(R.id.action_report) } if (!viewModel.isSelf && followState != FollowState.FOLLOWING) { menu.removeItem(R.id.action_add_or_remove_from_list) } } private fun showFollowRequestPendingDialog() { MaterialAlertDialogBuilder(this) .setMessage(R.string.dialog_message_cancel_follow_request) .setPositiveButton(android.R.string.ok) { _, _ -> viewModel.changeFollowState() } .setNegativeButton(android.R.string.cancel, null) .show() } private fun showUnfollowWarningDialog() { MaterialAlertDialogBuilder(this) .setMessage(R.string.dialog_unfollow_warning) .setPositiveButton(android.R.string.ok) { _, _ -> viewModel.changeFollowState() } .setNegativeButton(android.R.string.cancel, null) .show() } private fun showFollowWarningDialog() { MaterialAlertDialogBuilder(this) .setMessage(R.string.dialog_follow_warning) .setPositiveButton(android.R.string.ok) { _, _ -> viewModel.changeFollowState() } .setNegativeButton(android.R.string.cancel, null) .show() } private fun toggleBlockDomain(instance: String) { if (blockingDomain) { viewModel.unblockDomain(instance) } else { MaterialAlertDialogBuilder(this) .setMessage(getString(R.string.mute_domain_warning, instance)) .setPositiveButton( getString(R.string.mute_domain_warning_dialog_ok) ) { _, _ -> viewModel.blockDomain(instance) } .setNegativeButton(android.R.string.cancel, null) .show() } } private fun toggleBlock() { if (viewModel.relationshipData.value?.data?.blocking != true) { MaterialAlertDialogBuilder(this) .setMessage(getString(R.string.dialog_block_warning, loadedAccount?.username)) .setPositiveButton(android.R.string.ok) { _, _ -> viewModel.changeBlockState() } .setNegativeButton(android.R.string.cancel, null) .show() } else { viewModel.changeBlockState() } } private fun toggleMute() { if (viewModel.relationshipData.value?.data?.muting != true) { loadedAccount?.let { showMuteAccountDialog( this, it.username ) { notifications, duration -> viewModel.muteAccount(notifications, duration) } } } else { viewModel.unmuteAccount() } } private fun mention() { loadedAccount?.let { val options = if (viewModel.isSelf) { ComposeActivity.ComposeOptions(kind = ComposeActivity.ComposeKind.NEW) } else { ComposeActivity.ComposeOptions( mentionedUsernames = setOf(it.username), kind = ComposeActivity.ComposeKind.NEW ) } val intent = ComposeActivity.startIntent(this, options) startActivity(intent) } } override fun onViewTag(tag: String) { val intent = StatusListActivity.newHashtagIntent(this, tag) startActivityWithSlideInAnimation(intent) } override fun onViewAccount(id: String) { val intent = Intent(this, AccountActivity::class.java) intent.putExtra("id", id) startActivityWithSlideInAnimation(intent) } override fun onViewUrl(url: String) { viewUrl(url) } override fun onMenuItemSelected(item: MenuItem): Boolean { when (item.itemId) { R.id.action_open_in_web -> { // If the account isn't loaded yet, eat the input. loadedAccount?.let { loadedAccount -> openLink(loadedAccount.url) } return true } R.id.action_open_as -> { loadedAccount?.let { loadedAccount -> showAccountChooserDialog( item.title, false, object : AccountSelectionListener { override fun onAccountSelected(account: AccountEntity) { openAsAccount(loadedAccount.url, account) } } ) } } R.id.action_share_account_link -> { // If the account isn't loaded yet, eat the input. loadedAccount?.let { loadedAccount -> val url = loadedAccount.url val sendIntent = Intent() 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) ) ) } return true } R.id.action_share_account_username -> { // If the account isn't loaded yet, eat the input. loadedAccount?.let { loadedAccount -> val fullUsername = getFullUsername(loadedAccount) val sendIntent = Intent() 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) ) ) } return true } R.id.action_block -> { toggleBlock() return true } R.id.action_mute -> { toggleMute() return true } R.id.action_add_or_remove_from_list -> { ListSelectionFragment.newInstance(viewModel.accountId).show(supportFragmentManager, null) return true } R.id.action_mute_domain -> { toggleBlockDomain(domain) return true } R.id.action_show_reblogs -> { viewModel.changeShowReblogsState() return true } R.id.action_refresh -> { onRefresh() return true } R.id.action_report -> { loadedAccount?.let { loadedAccount -> startActivity( ReportActivity.getIntent(this, viewModel.accountId, loadedAccount.username) ) } return true } } return false } override fun getActionButton(): FloatingActionButton? { return if (!blocking) { binding.accountFloatingActionButton } else { null } } private fun getFullUsername(account: Account): String { return if (account.isRemote) { "@" + account.username } else { val localUsername = account.localUsername // Note: !! here will crash if this pane is ever shown to a logged-out user. With AccountActivity this is believed to be impossible. val domain = accountManager.activeAccount!!.domain "@$localUsername@$domain" } } 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) badge.isCloseIconVisible = 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 } companion object { private const val KEY_ACCOUNT_ID = "id" private val argbEvaluator = ArgbEvaluator() @JvmStatic fun getIntent(context: Context, accountId: String): Intent { val intent = Intent(context, AccountActivity::class.java) intent.putExtra(KEY_ACCOUNT_ID, accountId) return intent } } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/account/AccountFieldAdapter.kt ================================================ /* Copyright 2018 Conny Duck * * 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 import android.view.LayoutInflater import android.view.ViewGroup import androidx.recyclerview.widget.RecyclerView import com.keylesspalace.tusky.R import com.keylesspalace.tusky.databinding.ItemAccountFieldBinding import com.keylesspalace.tusky.entity.Emoji import com.keylesspalace.tusky.entity.Field import com.keylesspalace.tusky.interfaces.LinkListener import com.keylesspalace.tusky.util.BindingHolder import com.keylesspalace.tusky.util.emojify import com.keylesspalace.tusky.util.parseAsMastodonHtml import com.keylesspalace.tusky.util.setClickableText class AccountFieldAdapter( private val linkListener: LinkListener, private val animateEmojis: Boolean ) : RecyclerView.Adapter>() { var emojis: List = emptyList() var fields: List = emptyList() override fun getItemCount() = fields.size override fun onCreateViewHolder( parent: ViewGroup, viewType: Int ): BindingHolder { val binding = ItemAccountFieldBinding.inflate( LayoutInflater.from(parent.context), parent, false ) return BindingHolder(binding) } override fun onBindViewHolder(holder: BindingHolder, position: Int) { val field = fields[position] val nameTextView = holder.binding.accountFieldName val valueTextView = holder.binding.accountFieldValue val emojifiedName = field.name.emojify(emojis, nameTextView, animateEmojis) nameTextView.text = emojifiedName 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_verified_18dp, 0 ) } else { valueTextView.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, 0, 0) } } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/account/AccountPagerAdapter.kt ================================================ /* 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.components.account import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentActivity import com.keylesspalace.tusky.components.account.media.AccountMediaFragment import com.keylesspalace.tusky.components.timeline.TimelineFragment import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel import com.keylesspalace.tusky.interfaces.RefreshableFragment import com.keylesspalace.tusky.util.CustomFragmentStateAdapter class AccountPagerAdapter( activity: FragmentActivity, private val accountId: String ) : CustomFragmentStateAdapter(activity) { override fun getItemCount() = TAB_COUNT 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 ) 2 -> TimelineFragment.newInstance(TimelineViewModel.Kind.USER_PINNED, accountId, false) 3 -> AccountMediaFragment.newInstance(accountId) else -> throw AssertionError("Page $position is out of AccountPagerAdapter bounds") } } fun refreshContent() { for (i in 0 until TAB_COUNT) { val fragment = getFragment(i) if (fragment != null && fragment is RefreshableFragment) { (fragment as RefreshableFragment).refreshContent() } } } companion object { private const val TAB_COUNT = 4 } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/account/AccountViewModel.kt ================================================ package com.keylesspalace.tusky.components.account import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import at.connyduck.calladapter.networkresult.fold import com.keylesspalace.tusky.appstore.BlockEvent import com.keylesspalace.tusky.appstore.DomainMuteEvent import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.MuteEvent import com.keylesspalace.tusky.appstore.ProfileEditedEvent import com.keylesspalace.tusky.appstore.UnfollowEvent import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.entity.Relationship import com.keylesspalace.tusky.network.MastodonApi 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 com.keylesspalace.tusky.util.getDomain import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch @HiltViewModel class AccountViewModel @Inject constructor( private val mastodonApi: MastodonApi, private val eventHub: EventHub, accountManager: AccountManager ) : ViewModel() { private val _accountData = MutableStateFlow(null as Resource?) val accountData: StateFlow?> = _accountData.asStateFlow() 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 = MutableStateFlow(false) val isRefreshing: StateFlow = _isRefreshing.asStateFlow() 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.value = Success(event.newProfileData) } } } } private fun obtainAccount(reload: Boolean = false) { if (_accountData.value == null || reload) { if (reload) { _isRefreshing.value = true } _accountData.value = Loading() viewModelScope.launch { mastodonApi.account(accountId) .fold( { account -> domain = getDomain(account.url) isFromOwnDomain = domain == activeAccount.domain _accountData.value = Success(account) _isRefreshing.value = false }, { t -> Log.w(TAG, "failed obtaining account", t) _accountData.value = Error(cause = t) _isRefreshing.value = false } ) } } } private fun obtainRelationship(reload: Boolean = false) { if (_relationshipData.value == null || reload) { _relationshipData.value = Loading() viewModelScope.launch { mastodonApi.relationships(listOf(accountId)) .fold( { relationships -> _relationshipData.value = if (relationships.isNotEmpty()) { Success( relationships[0] ) } else { Error() } }, { t -> Log.w(TAG, "failed obtaining relationships", t) _relationshipData.value = Error(cause = t) } ) } } } fun changeFollowState() { val relationship = _relationshipData.value?.data if (relationship?.following == true || relationship?.requested == true) { changeRelationship(RelationShipAction.UNFOLLOW) } else { changeRelationship(RelationShipAction.FOLLOW) } } fun changeBlockState() { if (_relationshipData.value?.data?.blocking == true) { changeRelationship(RelationShipAction.UNBLOCK) } else { changeRelationship(RelationShipAction.BLOCK) } } fun muteAccount(notifications: Boolean, duration: Int?) { changeRelationship(RelationShipAction.MUTE, notifications, duration) } fun unmuteAccount() { changeRelationship(RelationShipAction.UNMUTE) } fun changeSubscribingState() { val relationship = _relationshipData.value?.data if (relationship?.notifying == true || // Mastodon 3.3.0rc1 relationship?.subscribing == true // Pleroma ) { changeRelationship(RelationShipAction.UNSUBSCRIBE) } else { changeRelationship(RelationShipAction.SUBSCRIBE) } } fun blockDomain(instance: String) { viewModelScope.launch { mastodonApi.blockDomain(instance).fold({ eventHub.dispatch(DomainMuteEvent(instance)) val relation = _relationshipData.value?.data if (relation != null) { _relationshipData.value = Success(relation.copy(blockingDomain = true)) } }, { e -> Log.e(TAG, "Error muting $instance", e) }) } } fun unblockDomain(instance: String) { viewModelScope.launch { mastodonApi.unblockDomain(instance).fold({ val relation = _relationshipData.value?.data if (relation != null) { _relationshipData.value = Success(relation.copy(blockingDomain = false)) } }, { e -> Log.e(TAG, "Error unmuting $instance", e) }) } } fun changeShowReblogsState() { if (_relationshipData.value?.data?.showingReblogs == true) { changeRelationship(RelationShipAction.FOLLOW, false) } else { changeRelationship(RelationShipAction.FOLLOW, true) } } /** * @param parameter showReblogs if RelationShipAction.FOLLOW, notifications if MUTE */ private fun changeRelationship( relationshipAction: RelationShipAction, 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 if (relation != null && account != null) { // optimistically post new state for faster response val newRelation = when (relationshipAction) { RelationShipAction.FOLLOW -> { if (account.locked) { relation.copy(requested = true) } else { relation.copy(following = true) } } RelationShipAction.UNFOLLOW -> relation.copy(following = false) RelationShipAction.BLOCK -> relation.copy(blocking = true) RelationShipAction.UNBLOCK -> relation.copy(blocking = false) RelationShipAction.MUTE -> relation.copy(muting = true) RelationShipAction.UNMUTE -> relation.copy(muting = false) RelationShipAction.SUBSCRIBE -> { if (isMastodon) { relation.copy(notifying = true) } else { relation.copy(subscribing = true) } } RelationShipAction.UNSUBSCRIBE -> { if (isMastodon) { relation.copy(notifying = false) } else { relation.copy(subscribing = false) } } } _relationshipData.value = Loading(newRelation) } val relationshipCall = when (relationshipAction) { RelationShipAction.FOLLOW -> mastodonApi.followAccount( accountId, showReblogs = parameter ?: true ) RelationShipAction.UNFOLLOW -> mastodonApi.unfollowAccount(accountId) RelationShipAction.BLOCK -> mastodonApi.blockAccount(accountId) RelationShipAction.UNBLOCK -> mastodonApi.unblockAccount(accountId) RelationShipAction.MUTE -> mastodonApi.muteAccount( accountId, parameter ?: true, duration ) RelationShipAction.UNMUTE -> mastodonApi.unmuteAccount(accountId) RelationShipAction.SUBSCRIBE -> { if (isMastodon) { mastodonApi.followAccount(accountId, notify = true) } else { mastodonApi.subscribeAccount(accountId) } } RelationShipAction.UNSUBSCRIBE -> { if (isMastodon) { mastodonApi.followAccount(accountId, notify = false) } else { mastodonApi.unsubscribeAccount(accountId) } } } relationshipCall.fold( { relationship -> _relationshipData.value = Success(relationship) when (relationshipAction) { RelationShipAction.UNFOLLOW -> eventHub.dispatch(UnfollowEvent(accountId)) RelationShipAction.BLOCK -> eventHub.dispatch(BlockEvent(accountId)) RelationShipAction.MUTE -> eventHub.dispatch(MuteEvent(accountId)) else -> { } } }, { t -> Log.w(TAG, "failed loading relationship", t) _relationshipData.value = Error(relation, cause = t) } ) } fun noteChanged(newNote: String) { _noteSaved.value = false noteUpdateJob?.cancel() noteUpdateJob = viewModelScope.launch { delay(1500) mastodonApi.updateAccountNote(accountId, newNote) .fold( { _noteSaved.value = true delay(4000) _noteSaved.value = false }, { t -> Log.w(TAG, "Error updating note", t) } ) } } fun refresh() { reload(true) } private fun reload(isReload: Boolean = false) { if (_isRefreshing.value) { return } accountId.let { obtainAccount(isReload) if (!isSelf) { obtainRelationship(isReload) } } } fun setAccountInfo(accountId: String) { this.accountId = accountId this.isSelf = activeAccount.accountId == accountId reload(false) } enum class RelationShipAction { FOLLOW, UNFOLLOW, BLOCK, UNBLOCK, MUTE, UNMUTE, SUBSCRIBE, UNSUBSCRIBE } companion object { const val TAG = "AccountViewModel" } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/account/list/ListSelectionFragment.kt ================================================ /* 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.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.dialog.MaterialAlertDialogBuilder 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.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 dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch @AndroidEntryPoint class ListSelectionFragment : DialogFragment() { interface ListSelectionListener { fun onListSelected(list: MastoList) } private val viewModel: ListsForAccountViewModel by viewModels() 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) accountId = requireArguments().getString(ARG_ACCOUNT_ID) } override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { val context = requireContext() val binding = FragmentListsListBinding.inflate(layoutInflater) val adapter = Adapter() binding.listsView.adapter = adapter val dialogBuilder = MaterialAlertDialogBuilder(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(binding) } } } } 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(binding) } return dialog } private fun getProgressBarJob(progressView: View, delayMs: Long) = this.lifecycleScope.launch( start = CoroutineStart.LAZY ) { try { delay(delayMs) progressView.show() awaitCancellation() } finally { progressView.hide() } } private fun load(binding: FragmentListsListBinding) { 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 } } } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/account/list/ListsForAccountViewModel.kt ================================================ /* 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 androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import at.connyduck.calladapter.networkresult.getOrThrow import at.connyduck.calladapter.networkresult.onFailure 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 dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch data class AccountListState( val list: MastoList, val includesAccount: Boolean ) data class ActionError( val error: Throwable, val type: Type, val listId: String ) : Throwable(error) { enum class Type { ADD, REMOVE } } @HiltViewModel @OptIn(ExperimentalCoroutinesApi::class) class ListsForAccountViewModel @Inject constructor( private val mastodonApi: MastodonApi ) : ViewModel() { private val _states = MutableSharedFlow>(1) val states: SharedFlow> = _states.asSharedFlow() private val _loadError = MutableSharedFlow(1) val loadError: SharedFlow = _loadError.asSharedFlow() private val _actionError = MutableSharedFlow(1) val actionError: SharedFlow = _actionError.asSharedFlow() fun load(accountId: String?) { _loadError.resetReplayCache() viewModelScope.launch { runCatching { val all = mastodonApi.getLists().getOrThrow() var includes: List = emptyList() if (accountId != null) { includes = mastodonApi.getListsIncludesAccount(accountId).getOrThrow() } _states.emit( all.map { listState -> AccountListState( list = listState, includesAccount = includes.any { it.id == listState.id } ) } ) } .onFailure { _loadError.emit(it) } } } // TODO there is no "progress" visible for these fun addAccountToList(accountId: String, listId: String) { _actionError.resetReplayCache() viewModelScope.launch { mastodonApi.addAccountToList(listId, listOf(accountId)) .onSuccess { _states.emit( _states.first().map { state -> if (state.list.id == listId) { state.copy(includesAccount = true) } else { state } } ) } .onFailure { _actionError.emit(ActionError(it, ActionError.Type.ADD, listId)) } } } fun removeAccountFromList(accountId: String, listId: String) { _actionError.resetReplayCache() viewModelScope.launch { mastodonApi.deleteAccountFromList(listId, listOf(accountId)) .onSuccess { _states.emit( _states.first().map { state -> if (state.list.id == listId) { state.copy(includesAccount = false) } else { state } } ) } .onFailure { _actionError.emit(ActionError(it, ActionError.Type.REMOVE, listId)) } } } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaFragment.kt ================================================ /* Copyright 2022 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.media import android.content.SharedPreferences import android.os.Bundle import android.view.Menu import android.view.MenuInflater import android.view.MenuItem import android.view.View import androidx.core.app.ActivityOptionsCompat import androidx.core.view.MenuProvider import androidx.core.view.ViewCompat import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.paging.LoadState import androidx.recyclerview.widget.GridLayoutManager import com.keylesspalace.tusky.R import com.keylesspalace.tusky.ViewMediaActivity import com.keylesspalace.tusky.databinding.FragmentTimelineBinding import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.entity.Attachment import com.keylesspalace.tusky.interfaces.ActionButtonActivity import com.keylesspalace.tusky.interfaces.RefreshableFragment import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.util.ensureBottomPadding import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.openLink import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.viewdata.AttachmentViewData import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch /** * Fragment with multiple columns of media previews for the specified account. */ @AndroidEntryPoint class AccountMediaFragment : Fragment(R.layout.fragment_timeline), RefreshableFragment, MenuProvider { @Inject lateinit var accountManager: AccountManager @Inject lateinit var preferences: SharedPreferences private val binding by viewBinding(FragmentTimelineBinding::bind) private val viewModel: AccountMediaViewModel by viewModels() private var adapter: AccountMediaGridAdapter? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) viewModel.accountId = arguments?.getString(ACCOUNT_ID_ARG)!! } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED) val useBlurhash = preferences.getBoolean(PrefKeys.USE_BLURHASH, true) val hasFab = (activity as? ActionButtonActivity?)?.actionButton != null binding.recyclerView.ensureBottomPadding(fab = hasFab) val adapter = AccountMediaGridAdapter( useBlurhash = useBlurhash, context = view.context, onAttachmentClickListener = ::onAttachmentClick ) this.adapter = adapter val columnCount = view.context.resources.getInteger(R.integer.profile_media_column_count) val imageSpacing = view.context.resources.getDimensionPixelSize( R.dimen.profile_media_spacing ) binding.recyclerView.addItemDecoration( GridSpacingItemDecoration(columnCount, imageSpacing, 0) ) binding.recyclerView.layoutManager = GridLayoutManager(view.context, columnCount) binding.recyclerView.adapter = adapter binding.swipeRefreshLayout.isEnabled = false binding.swipeRefreshLayout.setOnRefreshListener { refreshContent() } binding.statusView.visibility = View.GONE viewLifecycleOwner.lifecycleScope.launch { viewModel.media.collectLatest { pagingData -> adapter.submitData(pagingData) } } adapter.addLoadStateListener { loadState -> binding.statusView.hide() binding.progressBar.hide() if (loadState.refresh != LoadState.Loading && loadState.source.refresh != LoadState.Loading) { binding.swipeRefreshLayout.isRefreshing = false } 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 ) } } is LoadState.Error -> { binding.statusView.show() binding.statusView.setup((loadState.refresh as LoadState.Error).error) } is LoadState.Loading -> { binding.progressBar.show() } } } } } override fun onDestroyView() { // Clear the adapter to prevent leaking the View adapter = null super.onDestroyView() } override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { menuInflater.inflate(R.menu.fragment_account_media, menu) } override fun onMenuItemSelected(menuItem: MenuItem): Boolean { return when (menuItem.itemId) { R.id.action_refresh -> { binding.swipeRefreshLayout.isRefreshing = true refreshContent() true } else -> false } } private fun onAttachmentClick(selected: AttachmentViewData, view: View) { if (!selected.isRevealed) { viewModel.revealAttachment(selected) return } val attachmentsFromSameStatus = viewModel.attachmentData.filter { attachmentViewData -> attachmentViewData.statusId == selected.statusId } val currentIndex = attachmentsFromSameStatus.indexOf(selected) when (selected.attachment.type) { Attachment.Type.IMAGE, Attachment.Type.GIFV, Attachment.Type.VIDEO, Attachment.Type.AUDIO -> { val intent = ViewMediaActivity.newIntent( view.context, attachmentsFromSameStatus, currentIndex ) if (activity != null) { val url = selected.attachment.url ViewCompat.setTransitionName(view, url) val options = ActivityOptionsCompat.makeSceneTransitionAnimation( requireActivity(), view, url ) startActivity(intent, options.toBundle()) } else { startActivity(intent) } } Attachment.Type.UNKNOWN -> { context?.openLink(selected.attachment.unknownUrl) } } } override fun refreshContent() { adapter?.refresh() } companion object { fun newInstance(accountId: String): AccountMediaFragment { val fragment = AccountMediaFragment() val args = Bundle(1) args.putString(ACCOUNT_ID_ARG, accountId) fragment.arguments = args return fragment } private const val ACCOUNT_ID_ARG = "account_id" private const val TAG = "AccountMediaFragment" } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaGridAdapter.kt ================================================ package com.keylesspalace.tusky.components.account.media import android.content.Context import android.graphics.Color import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.appcompat.content.res.AppCompatResources import androidx.appcompat.widget.TooltipCompat import androidx.core.view.setPadding import androidx.paging.PagingDataAdapter import androidx.recyclerview.widget.DiffUtil import com.bumptech.glide.Glide import com.google.android.material.R as materialR import com.google.android.material.color.MaterialColors import com.keylesspalace.tusky.R import com.keylesspalace.tusky.databinding.ItemAccountMediaBinding import com.keylesspalace.tusky.entity.Attachment import com.keylesspalace.tusky.util.BindingHolder import com.keylesspalace.tusky.util.BlurhashDrawable 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 kotlin.random.Random class AccountMediaGridAdapter( private val useBlurhash: Boolean, context: Context, private val onAttachmentClickListener: (AttachmentViewData, View) -> Unit ) : PagingDataAdapter>( object : DiffUtil.ItemCallback() { override fun areItemsTheSame( oldItem: AttachmentViewData, newItem: AttachmentViewData ): Boolean { return oldItem.attachment.id == newItem.attachment.id } override fun areContentsTheSame( oldItem: AttachmentViewData, newItem: AttachmentViewData ): Boolean { return oldItem == newItem } } ) { private val baseItemBackgroundColor = MaterialColors.getColor( context, materialR.attr.colorSurface, Color.BLACK ) private val videoIndicator = AppCompatResources.getDrawable( context, R.drawable.play_indicator ) private val mediaHiddenDrawable = AppCompatResources.getDrawable( context, R.drawable.ic_visibility_off_24dp ) private val itemBgBaseHSV = FloatArray(3) 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 binding.root.setBackgroundColor(Color.HSVToColor(itemBgBaseHSV)) return BindingHolder(binding) } override fun onBindViewHolder(holder: BindingHolder, position: Int) { val context = holder.binding.root.context getItem(position)?.let { item -> val imageView = holder.binding.accountMediaImageView val overlay = holder.binding.accountMediaImageViewOverlay val blurhash = item.attachment.blurhash val placeholder = if (useBlurhash && blurhash != null) { BlurhashDrawable(context, blurhash) } else { null } if (item.attachment.type == Attachment.Type.AUDIO) { overlay.hide() imageView.setPadding( context.resources.getDimensionPixelSize( R.dimen.profile_media_audio_icon_padding ) ) Glide.with(imageView) .load(R.drawable.ic_music_box_24dp) .centerInside() .into(imageView) imageView.contentDescription = item.attachment.getFormattedDescription(context) } else if (item.sensitive && !item.isRevealed) { overlay.show() overlay.setImageDrawable(mediaHiddenDrawable) imageView.setPadding(0) Glide.with(imageView) .load(placeholder) .centerInside() .into(imageView) imageView.contentDescription = imageView.context.getString(R.string.post_media_hidden_title) } else { if (item.attachment.type == Attachment.Type.VIDEO || item.attachment.type == Attachment.Type.GIFV) { overlay.show() overlay.setImageDrawable(videoIndicator) } else { overlay.hide() } imageView.setPadding(0) Glide.with(imageView) .asBitmap() .load(item.attachment.previewUrl) .placeholder(placeholder) .centerInside() .into(imageView) imageView.contentDescription = item.attachment.getFormattedDescription(context) } holder.binding.root.setOnClickListener { onAttachmentClickListener(item, imageView) } TooltipCompat.setTooltipText(holder.binding.root, imageView.contentDescription) } } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaPagingSource.kt ================================================ /* Copyright 2022 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.media import androidx.paging.PagingSource import androidx.paging.PagingState import com.keylesspalace.tusky.viewdata.AttachmentViewData class AccountMediaPagingSource( private val viewModel: AccountMediaViewModel ) : PagingSource() { override fun getRefreshKey(state: PagingState): String? = null override suspend fun load(params: LoadParams): LoadResult { return if (params is LoadParams.Refresh) { val list = viewModel.attachmentData.toList() LoadResult.Page(list, null, list.lastOrNull()?.statusId) } else { LoadResult.Page(emptyList(), null, null) } } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaRemoteMediator.kt ================================================ /* Copyright 2022 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.media import androidx.paging.ExperimentalPagingApi 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.entity.AccountEntity import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.viewdata.AttachmentViewData 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 ): MediatorResult { try { val statusResponse = when (loadType) { LoadType.REFRESH -> { api.accountStatuses(viewModel.accountId, onlyMedia = true) } LoadType.PREPEND -> { return MediatorResult.Success(endOfPaginationReached = true) } LoadType.APPEND -> { val maxId = state.lastItemOrNull()?.statusId if (maxId != null) { api.accountStatuses(viewModel.accountId, maxId = maxId, onlyMedia = true) } else { return MediatorResult.Success(endOfPaginationReached = false) } } } val statuses = statusResponse.body() if (!statusResponse.isSuccessful || statuses == null) { return MediatorResult.Error(HttpException(statusResponse)) } val attachments = statuses.flatMap { status -> status.attachments.map { attachment -> AttachmentViewData( attachment = attachment, statusId = status.id, statusUrl = status.url.orEmpty(), sensitive = status.sensitive, isRevealed = activeAccount.alwaysShowSensitiveMedia || !status.sensitive ) } } if (loadType == LoadType.REFRESH) { viewModel.attachmentData.clear() } viewModel.attachmentData.addAll(attachments) viewModel.currentSource?.invalidate() return MediatorResult.Success(endOfPaginationReached = statuses.isEmpty()) } catch (e: Exception) { return ifExpected(e) { MediatorResult.Error(e) } } } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/account/media/AccountMediaViewModel.kt ================================================ /* Copyright 2022 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.media import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope 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 dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject @HiltViewModel class AccountMediaViewModel @Inject constructor( accountManager: AccountManager, api: MastodonApi ) : ViewModel() { lateinit var accountId: String val attachmentData: MutableList = mutableListOf() var currentSource: AccountMediaPagingSource? = null val activeAccount = accountManager.activeAccount!! @OptIn(ExperimentalPagingApi::class) val media = Pager( config = PagingConfig( pageSize = LOAD_AT_ONCE, prefetchDistance = LOAD_AT_ONCE * 2 ), pagingSourceFactory = { AccountMediaPagingSource( viewModel = this ).also { source -> currentSource = source } }, remoteMediator = AccountMediaRemoteMediator(api, activeAccount, this) ).flow .cachedIn(viewModelScope) fun revealAttachment(viewData: AttachmentViewData) { val position = attachmentData.indexOfFirst { oldViewData -> oldViewData.id == viewData.id } attachmentData[position] = viewData.copy(isRevealed = true) currentSource?.invalidate() } companion object { private const val LOAD_AT_ONCE = 30 } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/account/media/GridSpacingItemDecoration.kt ================================================ /* Copyright 2022 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.media import android.graphics.Rect import android.view.View import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView.ItemDecoration class GridSpacingItemDecoration( private val spanCount: Int, private val spacing: Int, private val topOffset: Int ) : ItemDecoration() { override fun getItemOffsets( outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State ) { val position = parent.getChildAdapterPosition(view) // item position if (position < topOffset) return val column = (position - topOffset) % spanCount // item column outRect.left = column * spacing / spanCount // column * ((1f / spanCount) * spacing) outRect.right = spacing - (column + 1) * spacing / spanCount // spacing - (column + 1) * ((1f / spanCount) * spacing) if (position - topOffset >= spanCount) { outRect.top = spacing // item top } } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/account/media/SquareImageView.kt ================================================ package com.keylesspalace.tusky.components.account.media import android.content.Context import android.util.AttributeSet import androidx.appcompat.widget.AppCompatImageView /** * Created by charlag on 26/10/2017. */ class SquareImageView : AppCompatImageView { constructor(context: Context) : super(context) constructor(context: Context, attributes: AttributeSet) : super(context, attributes) constructor(context: Context, attributes: AttributeSet, defStyleAttr: Int) : super(context, attributes, defStyleAttr) override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { super.onMeasure(widthMeasureSpec, heightMeasureSpec) val width = measuredWidth setMeasuredDimension(width, width) } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountListActivity.kt ================================================ /* 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.components.accountlist import android.content.Context import android.content.Intent import android.os.Bundle import androidx.fragment.app.commit import com.keylesspalace.tusky.BottomSheetActivity import com.keylesspalace.tusky.R import com.keylesspalace.tusky.databinding.ActivityAccountListBinding import com.keylesspalace.tusky.util.getSerializableExtraCompat import dagger.hilt.android.AndroidEntryPoint @AndroidEntryPoint class AccountListActivity : BottomSheetActivity() { enum class Type { FOLLOWS, FOLLOWERS, BLOCKS, MUTES, FOLLOW_REQUESTS, REBLOGGED, FAVOURITED } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val binding = ActivityAccountListBinding.inflate(layoutInflater) setContentView(binding.root) val type = intent.getSerializableExtraCompat(EXTRA_TYPE)!! val id: String? = intent.getStringExtra(EXTRA_ID) setSupportActionBar(binding.includedToolbar.toolbar) supportActionBar?.apply { when (type) { Type.BLOCKS -> setTitle(R.string.title_blocks) Type.MUTES -> setTitle(R.string.title_mutes) Type.FOLLOW_REQUESTS -> setTitle(R.string.title_follow_requests) Type.FOLLOWERS -> setTitle(R.string.title_followers) Type.FOLLOWS -> setTitle(R.string.title_follows) Type.REBLOGGED -> setTitle(R.string.title_reblogged_by) Type.FAVOURITED -> setTitle(R.string.title_favourited_by) } setDisplayHomeAsUpEnabled(true) setDisplayShowHomeEnabled(true) } if (supportFragmentManager.findFragmentById(R.id.fragment_container) == null) { supportFragmentManager.commit { val fragment = AccountListFragment.newInstance(type, id) replace(R.id.fragment_container, fragment) } } } companion object { private const val EXTRA_TYPE = "type" private const val EXTRA_ID = "id" 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) } } } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountListFragment.kt ================================================ /* 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.components.accountlist import android.content.SharedPreferences 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.ConcatAdapter import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.SimpleItemAnimator import com.google.android.material.snackbar.BaseTransientBottomBar import com.google.android.material.snackbar.Snackbar import com.keylesspalace.tusky.BottomSheetActivity import com.keylesspalace.tusky.PostLookupFallbackBehavior import com.keylesspalace.tusky.R import com.keylesspalace.tusky.StatusListActivity import com.keylesspalace.tusky.adapter.LoadStateFooterAdapter import com.keylesspalace.tusky.components.account.AccountActivity import com.keylesspalace.tusky.components.accountlist.AccountListActivity.Type import com.keylesspalace.tusky.components.accountlist.adapter.BlocksAdapter import com.keylesspalace.tusky.components.accountlist.adapter.FollowAdapter import com.keylesspalace.tusky.components.accountlist.adapter.FollowRequestsAdapter import com.keylesspalace.tusky.components.accountlist.adapter.FollowRequestsHeaderAdapter import com.keylesspalace.tusky.components.accountlist.adapter.MutesAdapter import com.keylesspalace.tusky.databinding.FragmentAccountListBinding import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.interfaces.AccountActionListener import com.keylesspalace.tusky.interfaces.LinkListener import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.util.ensureBottomPadding import com.keylesspalace.tusky.util.getSerializableCompat 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 dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.lifecycle.withCreationCallback import javax.inject.Inject import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch @AndroidEntryPoint class AccountListFragment : Fragment(R.layout.fragment_account_list), AccountActionListener, LinkListener { @Inject lateinit var accountManager: AccountManager @Inject lateinit var preferences: SharedPreferences private val binding by viewBinding(FragmentAccountListBinding::bind) private val viewModel: AccountListViewModel by viewModels( extrasProducer = { defaultViewModelCreationExtras.withCreationCallback { factory -> factory.create( type = requireArguments().getSerializableCompat(ARG_TYPE)!!, accountId = requireArguments().getString(ARG_ID) ) } } ) private lateinit var type: Type private var id: String? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) type = requireArguments().getSerializableCompat(ARG_TYPE)!! id = requireArguments().getString(ARG_ID) } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { binding.recyclerView.ensureBottomPadding() binding.recyclerView.setHasFixedSize(true) val layoutManager = LinearLayoutManager(view.context) binding.recyclerView.layoutManager = layoutManager (binding.recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false binding.recyclerView.addItemDecoration( DividerItemDecoration(view.context, DividerItemDecoration.VERTICAL) ) val animateAvatar = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false) val animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false) val showBotOverlay = preferences.getBoolean(PrefKeys.SHOW_BOT_OVERLAY, true) val activeAccount = accountManager.activeAccount!! val adapter = when (type) { Type.BLOCKS -> BlocksAdapter(this, animateAvatar, animateEmojis, showBotOverlay) Type.MUTES -> MutesAdapter(this, animateAvatar, animateEmojis, showBotOverlay) Type.FOLLOW_REQUESTS -> { val headerAdapter = FollowRequestsHeaderAdapter( instanceName = activeAccount.domain, accountLocked = activeAccount.locked ) val followRequestsAdapter = FollowRequestsAdapter(this, this, animateAvatar, animateEmojis, showBotOverlay) binding.recyclerView.adapter = ConcatAdapter(headerAdapter, followRequestsAdapter) followRequestsAdapter } else -> FollowAdapter(this, animateAvatar, animateEmojis, showBotOverlay) } binding.recyclerView.adapter = adapter.withLoadStateFooter(LoadStateFooterAdapter(adapter::retry)) binding.swipeRefreshLayout.setOnRefreshListener { adapter.refresh() } lifecycleScope.launch { viewModel.accountPager.collectLatest { pagingData -> adapter.submitData(pagingData) } } lifecycleScope.launch { viewModel.uiEvents.collect { event -> val message = if (event.throwable != null) { getString(event.message, event.user, event.throwable.message ?: getString(R.string.error_generic)) } else { getString(event.message, event.user) } Snackbar.make(binding.recyclerView, message, Snackbar.LENGTH_LONG) .setAction(event.actionText, event.action) .addCallback(object : BaseTransientBottomBar.BaseCallback() { override fun onDismissed(transientBottomBar: Snackbar, eventType: Int) { viewModel.consumeEvent(event) } }) .show() } } adapter.addLoadStateListener { loadState -> binding.progressBar.visible( loadState.refresh == LoadState.Loading && adapter.itemCount == 0 ) if (loadState.refresh != LoadState.Loading) { binding.swipeRefreshLayout.isRefreshing = false } 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 accounts", 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() } } } override fun onViewTag(tag: String) { activity?.startActivityWithSlideInAnimation( StatusListActivity.newHashtagIntent(requireContext(), tag) ) } override fun onViewAccount(id: String) { activity?.startActivityWithSlideInAnimation(AccountActivity.getIntent(requireContext(), id)) } override fun onViewUrl(url: String) { (activity as BottomSheetActivity?)?.viewUrl(url, PostLookupFallbackBehavior.OPEN_IN_BROWSER) } override fun onMute(mute: Boolean, id: String, position: Int, notifications: Boolean) { if (mute) { viewModel.mute(id, notifications) } else { viewModel.unmute(id) } } override fun onBlock(block: Boolean, id: String, position: Int) { viewModel.unblock(id) } override fun onRespondToFollowRequest(accept: Boolean, accountIdRequestingFollow: String, position: Int) { viewModel.respondToFollowRequest(accept, accountIdRequestingFollow) } companion object { private const val TAG = "AccountListFragment" private const val ARG_TYPE = "type" private const val ARG_ID = "id" fun newInstance(type: Type, id: String? = null): AccountListFragment { return AccountListFragment().apply { arguments = Bundle(2).apply { putSerializable(ARG_TYPE, type) putString(ARG_ID, id) } } } } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountListPagingSource.kt ================================================ /* Copyright 2025 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.accountlist import androidx.paging.PagingSource import androidx.paging.PagingState class AccountListPagingSource( private val accounts: 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(accounts, null, nextKey) } else { LoadResult.Page(emptyList(), null, null) } } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountListRemoteMediator.kt ================================================ /* Copyright 2025 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.accountlist import androidx.paging.ExperimentalPagingApi import androidx.paging.LoadType import androidx.paging.PagingState import androidx.paging.RemoteMediator import at.connyduck.calladapter.networkresult.getOrElse import com.keylesspalace.tusky.components.accountlist.AccountListActivity.Type import com.keylesspalace.tusky.entity.TimelineAccount import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.util.HttpHeaderLink import retrofit2.HttpException import retrofit2.Response @OptIn(ExperimentalPagingApi::class) class AccountListRemoteMediator( private val api: MastodonApi, private val viewModel: AccountListViewModel, private val fetchRelationships: Boolean ) : 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 -> getFetchCallByListType(fromId = viewModel.nextKey) LoadType.REFRESH -> { viewModel.nextKey = null viewModel.accounts.clear() getFetchCallByListType(null) } } } private suspend fun applyResponse(response: Response>): MediatorResult { val accounts = response.body() if (!response.isSuccessful || accounts == null) { return MediatorResult.Error(HttpException(response)) } val links = HttpHeaderLink.parse(response.headers()["Link"]) viewModel.nextKey = HttpHeaderLink.findByRelationType(links, "next")?.uri?.getQueryParameter("max_id") val relationships = if (fetchRelationships) { api.relationships(accounts.map { it.id }).getOrElse { e -> return MediatorResult.Error(e) } } else { emptyList() } val viewModels = accounts.map { account -> account.toViewData( mutingNotifications = relationships.find { it.id == account.id }?.mutingNotifications == true ) } viewModel.accounts.addAll(viewModels) viewModel.invalidate() return MediatorResult.Success(endOfPaginationReached = viewModel.nextKey == null) } private fun requireId(type: Type, id: String?): String { return requireNotNull(id) { "id must not be null for type " + type.name } } private suspend fun getFetchCallByListType(fromId: String?): Response> { return when (viewModel.type) { Type.FOLLOWS -> { val accountId = requireId(viewModel.type, viewModel.accountId) api.accountFollowing(accountId, fromId) } Type.FOLLOWERS -> { val accountId = requireId(viewModel.type, viewModel.accountId) api.accountFollowers(accountId, fromId) } Type.BLOCKS -> api.blocks(fromId) Type.MUTES -> api.mutes(fromId) Type.FOLLOW_REQUESTS -> api.followRequests(fromId) Type.REBLOGGED -> { val statusId = requireId(viewModel.type, viewModel.accountId) api.statusRebloggedBy(statusId, fromId) } Type.FAVOURITED -> { val statusId = requireId(viewModel.type, viewModel.accountId) api.statusFavouritedBy(statusId, fromId) } } } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountListViewModel.kt ================================================ /* Copyright 2025 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.accountlist import android.view.View import androidx.annotation.StringRes import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.paging.ExperimentalPagingApi import androidx.paging.InvalidatingPagingSourceFactory import androidx.paging.Pager import androidx.paging.PagingConfig import androidx.paging.cachedIn import at.connyduck.calladapter.networkresult.fold import at.connyduck.calladapter.networkresult.onFailure import com.keylesspalace.tusky.R import com.keylesspalace.tusky.network.MastodonApi import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @HiltViewModel(assistedFactory = AccountListViewModel.Factory::class) class AccountListViewModel @AssistedInject constructor( private val api: MastodonApi, @Assisted("type") val type: AccountListActivity.Type, @Assisted("id") val accountId: String? ) : ViewModel() { private val factory = InvalidatingPagingSourceFactory { AccountListPagingSource(accounts.toList(), nextKey) } @OptIn(ExperimentalPagingApi::class) val accountPager = Pager( config = PagingConfig(40), remoteMediator = AccountListRemoteMediator(api, this, fetchRelationships = type == AccountListActivity.Type.MUTES), pagingSourceFactory = factory ).flow .cachedIn(viewModelScope) val accounts: MutableList = mutableListOf() var nextKey: String? = null private val _uiEvents = MutableStateFlow>(emptyList()) val uiEvents: Flow = _uiEvents.map { it.firstOrNull() }.filterNotNull().distinctUntilChanged() fun invalidate() { factory.invalidate() } // this is called by the mute notification toggle fun mute(accountId: String, notifications: Boolean) { val accountViewData = accounts.find { it.id == accountId } ?: return viewModelScope.launch { api.muteAccount(accountId, notifications).onFailure { e -> sendEvent( SnackbarEvent( message = R.string.mute_failure, user = "@${accountViewData.account.username}", throwable = e, actionText = R.string.action_retry, action = { mute(accountId, notifications) } ) ) } } } // this is called when unmuting is undone private fun remute(accountViewData: AccountViewData) { viewModelScope.launch { api.muteAccount(accountViewData.id).fold({ accounts.add(accountViewData) invalidate() }, { e -> sendEvent( SnackbarEvent( message = R.string.mute_failure, user = "@${accountViewData.account.username}", throwable = e, actionText = R.string.action_retry, action = { block(accountViewData) } ) ) }) } } fun unmute(accountId: String) { val accountViewData = accounts.find { it.id == accountId } ?: return viewModelScope.launch { api.unmuteAccount(accountId).fold({ accounts.removeIf { it.id == accountId } invalidate() sendEvent( SnackbarEvent( message = R.string.unmute_success, user = "@${accountViewData.account.username}", throwable = null, actionText = R.string.action_undo, action = { remute(accountViewData) } ) ) }, { error -> sendEvent( SnackbarEvent( message = R.string.unmute_failure, user = "@${accountViewData.account.username}", throwable = error, actionText = R.string.action_retry, action = { unmute(accountId) } ) ) }) } } fun unblock(accountId: String) { val accountViewData = accounts.find { it.id == accountId } ?: return viewModelScope.launch { api.unblockAccount(accountId).fold({ accounts.removeIf { it.id == accountId } invalidate() sendEvent( SnackbarEvent( message = R.string.unblock_success, user = "@${accountViewData.account.username}", throwable = null, actionText = R.string.action_undo, action = { block(accountViewData) } ) ) }, { e -> sendEvent( SnackbarEvent( message = R.string.unblock_failure, user = "@${accountViewData.account.username}", throwable = e, actionText = R.string.action_retry, action = { unblock(accountId) } ) ) }) } } private fun block(accountViewData: AccountViewData) { viewModelScope.launch { api.blockAccount(accountViewData.id).fold({ accounts.add(accountViewData) invalidate() }, { e -> sendEvent( SnackbarEvent( message = R.string.block_failure, user = "@${accountViewData.account.username}", throwable = e, actionText = R.string.action_retry, action = { block(accountViewData) } ) ) }) } } fun respondToFollowRequest(accept: Boolean, accountId: String) { val accountViewData = accounts.find { it.id == accountId } ?: return viewModelScope.launch { if (accept) { api.authorizeFollowRequest(accountId) } else { api.rejectFollowRequest(accountId) }.fold({ accounts.removeIf { it.id == accountId } invalidate() }, { e -> sendEvent( SnackbarEvent( message = if (accept) R.string.accept_follow_request_failure else R.string.reject_follow_request_failure, user = "@${accountViewData.account.username}", throwable = e, actionText = R.string.action_retry, action = { respondToFollowRequest(accept, accountId) } ) ) }) } } fun consumeEvent(event: SnackbarEvent) { println("event consumed $event") _uiEvents.update { uiEvents -> uiEvents - event } } private fun sendEvent(event: SnackbarEvent) { println("event sent $event") _uiEvents.update { uiEvents -> uiEvents + event } } @AssistedFactory interface Factory { fun create( @Assisted("type") type: AccountListActivity.Type, @Assisted("id") accountId: String? ): AccountListViewModel } } class SnackbarEvent( @StringRes val message: Int, val user: String, @StringRes val actionText: Int, val action: (View) -> Unit, val throwable: Throwable? = null ) ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/accountlist/AccountViewData.kt ================================================ /* Copyright 2025 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.accountlist import com.keylesspalace.tusky.entity.TimelineAccount data class AccountViewData( val account: TimelineAccount, val mutingNotifications: Boolean ) { val id: String get() = account.id } fun TimelineAccount.toViewData( mutingNotifications: Boolean ) = AccountViewData( account = this, mutingNotifications = mutingNotifications ) ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/AccountAdapter.kt ================================================ /* 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.components.accountlist.adapter import androidx.paging.PagingDataAdapter import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView import com.keylesspalace.tusky.components.accountlist.AccountViewData import com.keylesspalace.tusky.interfaces.AccountActionListener abstract class AccountAdapter( protected val accountActionListener: AccountActionListener, protected val animateAvatar: Boolean, protected val animateEmojis: Boolean, protected val showBotOverlay: Boolean ) : PagingDataAdapter(AccountViewDataDifferCallback) { companion object { private val AccountViewDataDifferCallback = object : DiffUtil.ItemCallback() { override fun areItemsTheSame( oldItem: AccountViewData, newItem: AccountViewData ): Boolean { return oldItem.id == newItem.id } override fun areContentsTheSame( oldItem: AccountViewData, newItem: AccountViewData ): Boolean { return oldItem == newItem } } } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/BlocksAdapter.kt ================================================ /* 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.components.accountlist.adapter import android.view.LayoutInflater import android.view.ViewGroup import com.keylesspalace.tusky.R import com.keylesspalace.tusky.databinding.ItemBlockedUserBinding import com.keylesspalace.tusky.interfaces.AccountActionListener import com.keylesspalace.tusky.util.BindingHolder import com.keylesspalace.tusky.util.emojify import com.keylesspalace.tusky.util.loadAvatar import com.keylesspalace.tusky.util.visible /** Displays a list of blocked accounts. */ class BlocksAdapter( accountActionListener: AccountActionListener, animateAvatar: Boolean, animateEmojis: Boolean, showBotOverlay: Boolean ) : AccountAdapter>( accountActionListener = accountActionListener, animateAvatar = animateAvatar, animateEmojis = animateEmojis, showBotOverlay = showBotOverlay ) { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder { return BindingHolder( ItemBlockedUserBinding.inflate(LayoutInflater.from(parent.context), parent, false) ) } override fun onBindViewHolder(viewHolder: BindingHolder, position: Int) { getItem(position)?.let { viewData -> val account = viewData.account val binding = viewHolder.binding val context = binding.root.context 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 val avatarRadius = context.resources.getDimensionPixelSize(R.dimen.avatar_radius_48dp) loadAvatar(account.avatar, binding.blockedUserAvatar, avatarRadius, animateAvatar) binding.blockedUserBotBadge.visible(showBotOverlay && account.bot) binding.blockedUserUnblock.setOnClickListener { accountActionListener.onBlock(false, account.id, position) } binding.root.setOnClickListener { accountActionListener.onViewAccount(account.id) } } } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/FollowAdapter.kt ================================================ /* 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.components.accountlist.adapter import android.view.LayoutInflater import android.view.ViewGroup import com.keylesspalace.tusky.adapter.AccountViewHolder import com.keylesspalace.tusky.databinding.ItemAccountBinding import com.keylesspalace.tusky.interfaces.AccountActionListener /** Displays either a follows or following list. */ class FollowAdapter( accountActionListener: AccountActionListener, animateAvatar: Boolean, animateEmojis: Boolean, showBotOverlay: Boolean ) : AccountAdapter( accountActionListener = accountActionListener, animateAvatar = animateAvatar, animateEmojis = animateEmojis, showBotOverlay = showBotOverlay ) { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AccountViewHolder { val binding = ItemAccountBinding.inflate(LayoutInflater.from(parent.context), parent, false) return AccountViewHolder(binding) } override fun onBindViewHolder(viewHolder: AccountViewHolder, position: Int) { getItem(position)?.let { viewData -> viewHolder.setupWithAccount( viewData.account, animateAvatar, animateEmojis, showBotOverlay ) viewHolder.setupActionListener(accountActionListener) } } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/FollowRequestsAdapter.kt ================================================ /* 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.components.accountlist.adapter import android.view.LayoutInflater import android.view.ViewGroup import com.keylesspalace.tusky.adapter.FollowRequestViewHolder import com.keylesspalace.tusky.databinding.ItemFollowRequestBinding import com.keylesspalace.tusky.interfaces.AccountActionListener import com.keylesspalace.tusky.interfaces.LinkListener /** Displays a list of follow requests with accept/reject buttons. */ class FollowRequestsAdapter( accountActionListener: AccountActionListener, private val linkListener: LinkListener, animateAvatar: Boolean, animateEmojis: Boolean, showBotOverlay: Boolean ) : AccountAdapter( accountActionListener = accountActionListener, animateAvatar = animateAvatar, animateEmojis = animateEmojis, showBotOverlay = showBotOverlay ) { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): FollowRequestViewHolder { val binding = ItemFollowRequestBinding.inflate(LayoutInflater.from(parent.context), parent, false) return FollowRequestViewHolder(binding, accountActionListener, linkListener, showHeader = false) } override fun onBindViewHolder(viewHolder: FollowRequestViewHolder, position: Int) { getItem(position)?.let { viewData -> viewHolder.setupWithAccount( account = viewData.account, animateAvatar = animateAvatar, animateEmojis = animateEmojis, showBotOverlay = showBotOverlay ) viewHolder.setupActionListener(accountActionListener, viewData.account.id) } } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/FollowRequestsHeaderAdapter.kt ================================================ /* Copyright 2020 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.accountlist.adapter import android.view.LayoutInflater import android.view.ViewGroup import androidx.recyclerview.widget.RecyclerView import com.keylesspalace.tusky.R import com.keylesspalace.tusky.databinding.ItemFollowRequestsHeaderBinding import com.keylesspalace.tusky.util.BindingHolder class FollowRequestsHeaderAdapter( private val instanceName: String, private val accountLocked: Boolean ) : RecyclerView.Adapter>() { 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 ) { viewHolder.binding.root.text = viewHolder.binding.root.context.getString(R.string.follow_requests_info, instanceName) } override fun getItemCount() = if (accountLocked) 0 else 1 } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/accountlist/adapter/MutesAdapter.kt ================================================ /* 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.accountlist.adapter import android.view.LayoutInflater import android.view.ViewGroup import androidx.core.view.ViewCompat import com.keylesspalace.tusky.R import com.keylesspalace.tusky.databinding.ItemMutedUserBinding import com.keylesspalace.tusky.interfaces.AccountActionListener import com.keylesspalace.tusky.util.BindingHolder import com.keylesspalace.tusky.util.emojify import com.keylesspalace.tusky.util.loadAvatar import com.keylesspalace.tusky.util.visible /** Displays a list of muted accounts with mute/unmute account button and mute/unmute notifications switch */ class MutesAdapter( accountActionListener: AccountActionListener, animateAvatar: Boolean, animateEmojis: Boolean, showBotOverlay: Boolean ) : AccountAdapter>( accountActionListener = accountActionListener, animateAvatar = animateAvatar, animateEmojis = animateEmojis, showBotOverlay = showBotOverlay ) { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder { return BindingHolder( ItemMutedUserBinding.inflate(LayoutInflater.from(parent.context), parent, false) ) } override fun onBindViewHolder(viewHolder: BindingHolder, position: Int) { getItem(position)?.let { viewData -> val account = viewData.account val binding = viewHolder.binding val context = binding.root.context 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) binding.mutedUserUsername.text = formattedUsername val avatarRadius = context.resources.getDimensionPixelSize(R.dimen.avatar_radius_48dp) loadAvatar(account.avatar, binding.mutedUserAvatar, avatarRadius, animateAvatar) binding.mutedUserBotBadge.visible(showBotOverlay && account.bot) val unmuteString = context.getString(R.string.action_unmute_desc, formattedUsername) binding.mutedUserUnmute.contentDescription = unmuteString ViewCompat.setTooltipText(binding.mutedUserUnmute, unmuteString) binding.mutedUserMuteNotifications.setOnCheckedChangeListener(null) binding.mutedUserMuteNotifications.isChecked = viewData.mutingNotifications binding.mutedUserUnmute.setOnClickListener { accountActionListener.onMute( false, account.id, viewHolder.bindingAdapterPosition, false ) } binding.mutedUserMuteNotifications.setOnCheckedChangeListener { _, isChecked -> accountActionListener.onMute( true, account.id, viewHolder.bindingAdapterPosition, isChecked ) } binding.root.setOnClickListener { accountActionListener.onViewAccount(account.id) } } } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementAdapter.kt ================================================ /* Copyright 2020 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.announcements import android.annotation.SuppressLint import android.graphics.drawable.Drawable import android.os.Build import android.text.SpannableString import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.core.view.size import androidx.recyclerview.widget.RecyclerView import com.bumptech.glide.Glide import com.bumptech.glide.request.target.Target import com.google.android.material.chip.Chip 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.clearEmojiTargets import com.keylesspalace.tusky.util.emojify import com.keylesspalace.tusky.util.parseAsMastodonHtml import com.keylesspalace.tusky.util.setClickableText import com.keylesspalace.tusky.util.setEmojiTargets import com.keylesspalace.tusky.util.visible interface AnnouncementActionListener : LinkListener { fun openReactionPicker(announcementId: String, target: View) fun addReaction(announcementId: String, name: String) fun removeReaction(announcementId: String, name: String) } class AnnouncementAdapter( private var items: List = emptyList(), private val listener: AnnouncementActionListener, private val wellbeingEnabled: Boolean = false, private val animateEmojis: Boolean = false ) : RecyclerView.Adapter>() { 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 ) setClickableText(text, emojifiedText, item.mentions, item.tags, listener) // If wellbeing mode is enabled, announcement badge counts should not be shown. if (wellbeingEnabled) { // Since reactions are not visible in wellbeing mode, // we shouldn't be able to add any ourselves. addReactionChip.visibility = View.GONE return } // hide button if announcement badge limit is already reached addReactionChip.visible(item.reactions.size < 8) val requestManager = Glide.with(chips) chips.clearEmojiTargets() val targets = ArrayList>(item.reactions.size) item.reactions.forEachIndexed { i, reaction -> ( chips.getChildAt(i)?.takeUnless { it.id == R.id.addReactionChip } as Chip? ?: Chip(chips.context).apply { isCheckable = true checkedIcon = null isCloseIconVisible = false setChipBackgroundColorResource(R.color.selectable_chip_background) chips.addView(this, i) } ) .apply { if (reaction.url == null) { this.text = "${reaction.name} ${reaction.count}" } else { // 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 spannable = SpannableString(" ${reaction.count}") val span = EmojiSpan(this) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { span.contentDescription = reaction.name } val target = span.createGlideTarget(this, animateEmojis) spannable.setSpan(span, 0, 1, 0) requestManager .asDrawable() .load( if (animateEmojis) { reaction.url } else { reaction.staticUrl } ) .into(target) targets.add(target) this.text = spannable } isChecked = reaction.me setOnClickListener { if (reaction.me) { listener.removeReaction(item.id, reaction.name) } else { listener.addReaction(item.id, reaction.name) } } } } while (chips.size - 1 > item.reactions.size) { chips.removeViewAt(item.reactions.size) } // Store Glide targets for later cancellation chips.setEmojiTargets(targets) addReactionChip.setOnClickListener { listener.openReactionPicker(item.id, it) } } override fun onViewRecycled(holder: BindingHolder) { holder.binding.chipGroup.clearEmojiTargets() } override fun getItemCount() = items.size fun updateList(items: List) { this.items = items notifyDataSetChanged() } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementsActivity.kt ================================================ /* Copyright 2020 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.announcements import android.content.Context import android.content.Intent import android.os.Bundle import android.view.Menu import android.view.MenuInflater import android.view.MenuItem import android.view.View import android.widget.PopupWindow import androidx.activity.viewModels import androidx.core.view.MenuProvider import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager import com.keylesspalace.tusky.BottomSheetActivity import com.keylesspalace.tusky.R import com.keylesspalace.tusky.StatusListActivity import com.keylesspalace.tusky.adapter.EmojiAdapter import com.keylesspalace.tusky.adapter.OnEmojiSelectedListener import com.keylesspalace.tusky.databinding.ActivityAnnouncementsBinding import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.util.Error import com.keylesspalace.tusky.util.Loading import com.keylesspalace.tusky.util.Success import com.keylesspalace.tusky.util.ensureBottomPadding 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 import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch @AndroidEntryPoint class AnnouncementsActivity : BottomSheetActivity(), AnnouncementActionListener, OnEmojiSelectedListener, MenuProvider { private val viewModel: AnnouncementsViewModel by viewModels() private val binding by viewBinding(ActivityAnnouncementsBinding::inflate) private lateinit var adapter: AnnouncementAdapter private val picker by unsafeLazy { EmojiPicker(this) } private val pickerDialog by unsafeLazy { PopupWindow(this) .apply { contentView = picker isFocusable = true setOnDismissListener { currentAnnouncementId = null } } } private var currentAnnouncementId: String? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(binding.root) addMenuProvider(this) setSupportActionBar(binding.includedToolbar.toolbar) supportActionBar?.apply { title = getString(R.string.title_announcements) setDisplayHomeAsUpEnabled(true) setDisplayShowHomeEnabled(true) } binding.swipeRefreshLayout.setOnRefreshListener(this::refreshAnnouncements) binding.announcementsList.ensureBottomPadding() binding.announcementsList.setHasFixedSize(true) binding.announcementsList.layoutManager = LinearLayoutManager(this) val divider = DividerItemDecoration(this, DividerItemDecoration.VERTICAL) binding.announcementsList.addItemDecoration(divider) val wellbeingEnabled = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false) val animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false) adapter = AnnouncementAdapter(emptyList(), this, wellbeingEnabled, animateEmojis) binding.announcementsList.adapter = adapter 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() } is Error -> { binding.progressBar.hide() binding.swipeRefreshLayout.isRefreshing = false binding.errorMessageView.setup( R.drawable.errorphant_error, R.string.error_generic ) { refreshAnnouncements() } binding.errorMessageView.show() } } } } lifecycleScope.launch { viewModel.emoji.collect { picker.adapter = EmojiAdapter(it, this@AnnouncementsActivity, animateEmojis) } } viewModel.load() binding.progressBar.show() } override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { menuInflater.inflate(R.menu.activity_announcements, menu) } override fun onMenuItemSelected(menuItem: MenuItem): Boolean { return when (menuItem.itemId) { R.id.action_refresh -> { binding.swipeRefreshLayout.isRefreshing = true refreshAnnouncements() true } else -> false } } private fun refreshAnnouncements() { viewModel.load() binding.swipeRefreshLayout.isRefreshing = true } override fun openReactionPicker(announcementId: String, target: View) { currentAnnouncementId = announcementId pickerDialog.showAsDropDown(target) } override fun onEmojiSelected(shortcode: String) { viewModel.addReaction(currentAnnouncementId!!, shortcode) pickerDialog.dismiss() } override fun addReaction(announcementId: String, name: String) { viewModel.addReaction(announcementId, name) } override fun removeReaction(announcementId: String, name: String) { viewModel.removeReaction(announcementId, name) } override fun onViewTag(tag: String) { val intent = StatusListActivity.newHashtagIntent(this, tag) startActivityWithSlideInAnimation(intent) } override fun onViewAccount(id: String) { viewAccount(id) } override fun onViewUrl(url: String) { viewUrl(url) } companion object { fun newIntent(context: Context) = Intent(context, AnnouncementsActivity::class.java) } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/announcements/AnnouncementsViewModel.kt ================================================ /* Copyright 2020 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.announcements import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import at.connyduck.calladapter.networkresult.fold import com.keylesspalace.tusky.appstore.AnnouncementReadEvent import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository import com.keylesspalace.tusky.entity.Announcement import com.keylesspalace.tusky.entity.Emoji import com.keylesspalace.tusky.network.MastodonApi 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 dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch @HiltViewModel class AnnouncementsViewModel @Inject constructor( private val instanceInfoRepo: InstanceInfoRepository, private val mastodonApi: MastodonApi, private val eventHub: EventHub ) : ViewModel() { private val _announcements = MutableStateFlow(null as Resource>?) val announcements: StateFlow>?> = _announcements.asStateFlow() private val _emoji = MutableStateFlow(emptyList()) val emoji: StateFlow> = _emoji.asStateFlow() init { viewModelScope.launch { _emoji.value = instanceInfoRepo.getEmojis() } } fun load() { viewModelScope.launch { _announcements.value = Loading() mastodonApi.announcements() .fold( { _announcements.value = Success(it) it.filter { announcement -> !announcement.read } .forEach { announcement -> mastodonApi.dismissAnnouncement(announcement.id) .fold( { eventHub.dispatch( AnnouncementReadEvent(announcement.id) ) }, { throwable -> Log.d( TAG, "Failed to mark announcement as read.", throwable ) } ) } }, { _announcements.value = Error(cause = it) } ) } } fun addReaction(announcementId: String, name: String) { viewModelScope.launch { mastodonApi.addAnnouncementReaction(announcementId, name) .fold( { _announcements.value = Success( announcements.value?.data?.map { announcement -> if (announcement.id == announcementId) { announcement.copy( reactions = if (announcement.reactions.find { reaction -> reaction.name == name } != null) { announcement.reactions.map { reaction -> if (reaction.name == name) { reaction.copy( count = reaction.count + 1, me = true ) } else { reaction } } } else { listOf( *announcement.reactions.toTypedArray(), emoji.value.find { emoji -> emoji.shortcode == name }!!.run { Announcement.Reaction( name, 1, true, url, staticUrl ) } ) } ) } else { announcement } } ) }, { Log.w(TAG, "Failed to add reaction to the announcement.", it) } ) } } fun removeReaction(announcementId: String, name: String) { viewModelScope.launch { mastodonApi.removeAnnouncementReaction(announcementId, name) .fold( { _announcements.value = Success( announcements.value!!.data!!.map { announcement -> if (announcement.id == announcementId) { announcement.copy( reactions = announcement.reactions.mapNotNull { reaction -> if (reaction.name == name) { if (reaction.count > 1) { reaction.copy( count = reaction.count - 1, me = false ) } else { null } } else { reaction } } ) } else { announcement } } ) }, { Log.w(TAG, "Failed to remove reaction from the announcement.", it) } ) } } companion object { private const val TAG = "AnnouncementsViewModel" } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeActivity.kt ================================================ /* 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.components.compose import android.Manifest import android.content.ClipData import android.content.Context import android.content.Intent import android.content.SharedPreferences import android.graphics.Bitmap import android.icu.text.BreakIterator import android.net.Uri import android.os.Build import android.os.Bundle import android.os.Parcelable import android.provider.MediaStore import android.text.Spanned import android.text.style.URLSpan import android.util.Log import android.view.KeyEvent import android.view.MenuItem import android.view.View import android.view.ViewGroup import android.widget.AdapterView import android.widget.ImageButton import android.widget.LinearLayout import android.widget.PopupMenu import android.widget.Toast import androidx.activity.OnBackPressedCallback import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels import androidx.annotation.AttrRes import androidx.annotation.ColorInt import androidx.annotation.StringRes import androidx.annotation.VisibleForTesting import androidx.appcompat.content.res.AppCompatResources import androidx.core.content.FileProvider import androidx.core.content.res.use import androidx.core.view.ContentInfoCompat import androidx.core.view.OnReceiveContentListener import androidx.core.view.WindowInsetsCompat.Type.ime import androidx.core.view.WindowInsetsCompat.Type.systemBars import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.core.view.updateLayoutParams import androidx.core.view.updatePadding import androidx.core.widget.doAfterTextChanged import androidx.core.widget.doOnTextChanged import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.LinearLayoutManager import androidx.transition.TransitionManager import com.canhub.cropper.CropImage import com.canhub.cropper.CropImageContract import com.canhub.cropper.options import com.google.android.material.R as materialR 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.dialog.MaterialAlertDialogBuilder import com.google.android.material.snackbar.Snackbar import com.keylesspalace.tusky.BaseActivity import com.keylesspalace.tusky.BuildConfig import com.keylesspalace.tusky.R import com.keylesspalace.tusky.adapter.EmojiAdapter import com.keylesspalace.tusky.adapter.LocaleAdapter import com.keylesspalace.tusky.adapter.OnEmojiSelectedListener import com.keylesspalace.tusky.components.compose.ComposeViewModel.ConfirmationKind import com.keylesspalace.tusky.components.compose.ComposeViewModel.QueuedMedia import com.keylesspalace.tusky.components.compose.dialog.CaptionDialog import com.keylesspalace.tusky.components.compose.dialog.makeFocusDialog import com.keylesspalace.tusky.components.compose.dialog.showAddPollDialog import com.keylesspalace.tusky.components.compose.view.ComposeOptionsListener import com.keylesspalace.tusky.components.compose.view.ComposeScheduleView import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository import com.keylesspalace.tusky.databinding.ActivityComposeBinding import com.keylesspalace.tusky.db.entity.AccountEntity import com.keylesspalace.tusky.db.entity.DraftAttachment 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.settings.PrefKeys.APP_THEME import com.keylesspalace.tusky.util.MentionSpan import com.keylesspalace.tusky.util.PickMediaFiles import com.keylesspalace.tusky.util.defaultFinders import com.keylesspalace.tusky.util.getInitialLanguages import com.keylesspalace.tusky.util.getLocaleList import com.keylesspalace.tusky.util.getMediaSize import com.keylesspalace.tusky.util.getParcelableArrayListExtraCompat import com.keylesspalace.tusky.util.getParcelableCompat import com.keylesspalace.tusky.util.getParcelableExtraCompat import com.keylesspalace.tusky.util.getSerializableCompat import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.highlightSpans import com.keylesspalace.tusky.util.loadAvatar import com.keylesspalace.tusky.util.map import com.keylesspalace.tusky.util.modernLanguageCode import com.keylesspalace.tusky.util.setDrawableTint import com.keylesspalace.tusky.util.setOnWindowInsetsChangeListener import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.util.visible import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.migration.OptionalInject import java.io.File import java.io.IOException import java.text.DecimalFormat import java.util.Locale 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 @OptionalInject @AndroidEntryPoint class ComposeActivity : BaseActivity(), ComposeOptionsListener, ComposeAutoCompleteAdapter.AutocompletionProvider, OnEmojiSelectedListener, OnReceiveContentListener, ComposeScheduleView.OnTimeSetListener, CaptionDialog.Listener { private lateinit var composeOptionsBehavior: BottomSheetBehavior<*> private lateinit var addMediaBehavior: BottomSheetBehavior<*> private lateinit var emojiBehavior: BottomSheetBehavior<*> private lateinit var scheduleBehavior: BottomSheetBehavior<*> /** The account that is being used to compose the status */ private lateinit var activeAccount: AccountEntity private var photoUploadUri: Uri? = null @VisibleForTesting var highlightFinders = defaultFinders @VisibleForTesting var maximumTootCharacters = InstanceInfoRepository.DEFAULT_CHARACTER_LIMIT var charactersReservedPerUrl = InstanceInfoRepository.DEFAULT_CHARACTERS_RESERVED_PER_URL private val viewModel: ComposeViewModel by viewModels() private val binding by viewBinding(ActivityComposeBinding::inflate) private var maxUploadMediaNumber = InstanceInfoRepository.DEFAULT_MAX_MEDIA_ATTACHMENTS private val takePictureLauncher = registerForActivityResult(ActivityResultContracts.TakePicture()) { success -> if (success) { viewModel.pickMedia(photoUploadUri!!) } } private val pickMediaFilePermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted -> if (isGranted) { pickMediaFileLauncher.launch(true) } else { Snackbar.make( binding.activityCompose, R.string.error_media_upload_permission, Snackbar.LENGTH_SHORT ).apply { setAction(R.string.action_retry) { onMediaPick() } // necessary so snackbar is shown over everything view.elevation = resources.getDimension(R.dimen.compose_activity_snackbar_elevation) show() } } } private val pickMediaFileLauncher = 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() } else { viewModel.pickMedia( uris.map { uri -> ComposeViewModel.MediaData(uri) } ) } } // Contract kicked off by editImageInQueue; expects viewModel.cropImageItemOld set private val cropImage = registerForActivityResult(CropImageContract()) { result -> val uriNew = result.uriContent if (result.isSuccessful && uriNew != null) { viewModel.cropImageItemOld?.let { itemOld -> val size = getMediaSize(contentResolver, uriNew) viewModel.addMediaToQueue( type = itemOld.type, uri = uriNew, mediaSize = size, description = itemOld.description, // Intentionally reset focus when cropping focus = null, replaceItem = itemOld ) } } else if (result == CropImage.CancelledResult) { Log.w(TAG, "Edit image cancelled by user") } else { Log.w(TAG, "Edit image failed: " + result.error) displayTransientMessage(R.string.error_image_edit_failed) } 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) activeAccount = accountManager.activeAccount ?: return val theme = preferences.getString(APP_THEME, AppTheme.DEFAULT.value) if (theme == "black") { setTheme(R.style.TuskyDialogActivityBlackTheme) } setContentView(binding.root) binding.composeBottomBar.setOnWindowInsetsChangeListener { windowInsets -> val insets = windowInsets.getInsets(systemBars() or ime()) val bottomBarHeight = resources.getDimensionPixelSize(R.dimen.compose_bottom_bar_height) val bottomBarPadding = resources.getDimensionPixelSize(R.dimen.compose_bottom_bar_padding_vertical) binding.composeBottomBar.updatePadding(bottom = insets.bottom + bottomBarPadding) binding.addMediaBottomSheet.updatePadding(bottom = insets.bottom + bottomBarHeight) binding.emojiView.updatePadding(bottom = insets.bottom + bottomBarHeight) binding.composeOptionsBottomSheet.updatePadding(bottom = insets.bottom + bottomBarHeight) binding.composeScheduleView.updatePadding(bottom = insets.bottom + bottomBarHeight) binding.composeMainScrollView.updateLayoutParams { bottomMargin = insets.bottom + bottomBarHeight } } setupActionBar() setupAvatar(activeAccount) val mediaAdapter = MediaPreviewAdapter( this, onAddCaption = { item -> CaptionDialog.newInstance( item.localId, item.description, item.uri ).show(supportFragmentManager, "caption_dialog") }, onAddFocus = { item -> makeFocusDialog(item.focus, item.uri) { newFocus -> viewModel.updateFocus(item.localId, newFocus) } // TODO this is inconsistent to CaptionDialog (device rotation)? }, onEditImage = this::editImageInQueue, onRemove = this::removeMediaFromQueue ) binding.composeMediaPreviewBar.layoutManager = LinearLayoutManager(this, LinearLayoutManager.HORIZONTAL, false) binding.composeMediaPreviewBar.adapter = mediaAdapter binding.composeMediaPreviewBar.itemAnimator = null /* 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? = intent.getParcelableExtraCompat(COMPOSE_OPTIONS_EXTRA) viewModel.setup(composeOptions) setupButtons() subscribeToUpdates(mediaAdapter) if (accountManager.shouldDisplaySelfUsername()) { binding.composeUsernameView.text = getString( R.string.compose_active_account_description, activeAccount.fullName ) binding.composeUsernameView.show() } else { binding.composeUsernameView.hide() } setupReplyViews(composeOptions?.replyingStatusAuthor, composeOptions?.replyingStatusContent) val statusContent = composeOptions?.content if (!statusContent.isNullOrEmpty()) { binding.composeEditField.setText(statusContent) } if (!composeOptions?.scheduledAt.isNullOrEmpty()) { binding.composeScheduleView.setDateTime(composeOptions.scheduledAt) } setupLanguageSpinner(getInitialLanguages(composeOptions?.language, activeAccount)) setupComposeField(preferences, viewModel.startingText) setupContentWarningField(composeOptions?.contentWarning) setupPollView() applyShareIntent(intent, savedInstanceState) /* Finally, overwrite state with data from saved instance state. */ savedInstanceState?.let { photoUploadUri = it.getParcelableCompat(PHOTO_UPLOAD_URI_KEY) setStatusVisibility(it.getSerializableCompat(VISIBILITY_KEY)!!) it.getBoolean(CONTENT_WARNING_VISIBLE_KEY).apply { viewModel.contentWarningChanged(this) } it.getString(SCHEDULED_TIME_KEY)?.let { time -> viewModel.updateScheduledAt(time) } } binding.composeEditField.post { binding.composeEditField.requestFocus() } } private fun applyShareIntent(intent: Intent, savedInstanceState: Bundle?) { if (savedInstanceState == null) { /* Get incoming images being sent through a share action from another app. Only do this * when savedInstanceState is null, otherwise both the images from the intent and the * instance state will be re-queued. */ intent.type?.also { type -> if (type.startsWith("image/") || type.startsWith("video/") || type.startsWith("audio/")) { when (intent.action) { Intent.ACTION_SEND -> { intent.getParcelableExtraCompat(Intent.EXTRA_STREAM)?.let { uri -> viewModel.pickMedia(uri) } } Intent.ACTION_SEND_MULTIPLE -> { intent.getParcelableArrayListExtraCompat(Intent.EXTRA_STREAM) ?.map { uri -> ComposeViewModel.MediaData(uri) }?.let(viewModel::pickMedia) } } } val subject = intent.getStringExtra(Intent.EXTRA_SUBJECT) val text = intent.getStringExtra(Intent.EXTRA_TEXT).orEmpty() val shareBody = if (!subject.isNullOrBlank() && subject !in text) { subject + '\n' + text } else { text } if (shareBody.isNotBlank()) { val start = binding.composeEditField.selectionStart.coerceAtLeast(0) 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 ) // move edittext cursor to first when shareBody parsed binding.composeEditField.text.insert(0, "\n") binding.composeEditField.setSelection(0) } } } } private fun setupReplyViews(replyingStatusAuthor: String?, replyingStatusContent: String?) { if (replyingStatusAuthor != null) { binding.composeReplyView.show() binding.composeReplyView.text = getString(R.string.replying_to, replyingStatusAuthor) val arrowDownIcon = AppCompatResources.getDrawable(this, R.drawable.ic_arrow_drop_down_24dp)!! setDrawableTint(this, arrowDownIcon, android.R.attr.textColorTertiary) binding.composeReplyView.setCompoundDrawablesRelativeWithIntrinsicBounds( null, null, arrowDownIcon, null ) binding.composeReplyView.setOnClickListener { TransitionManager.beginDelayedTransition( binding.composeReplyContentView.parent as ViewGroup ) if (binding.composeReplyContentView.isVisible) { binding.composeReplyContentView.hide() binding.composeReplyView.setCompoundDrawablesRelativeWithIntrinsicBounds( null, null, arrowDownIcon, null ) } else { binding.composeReplyContentView.show() val arrowUpIcon = AppCompatResources.getDrawable(this, R.drawable.ic_arrow_drop_up_24dp)!! setDrawableTint(this, arrowUpIcon, android.R.attr.textColorTertiary) binding.composeReplyView.setCompoundDrawablesRelativeWithIntrinsicBounds( null, null, arrowUpIcon, null ) } } } replyingStatusContent?.let { binding.composeReplyContentView.text = it } } private fun setupContentWarningField(startingContentWarning: String?) { if (startingContentWarning != null) { binding.composeContentWarningField.setText(startingContentWarning) } 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.setAdapter( ComposeAutoCompleteAdapter( this, animateAvatar = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false), animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false), showBotBadge = preferences.getBoolean(PrefKeys.SHOW_BOT_OVERLAY, true) ) ) binding.composeEditField.setTokenizer(ComposeTokenizer()) binding.composeEditField.setText(startingText) binding.composeEditField.setSelection(binding.composeEditField.length()) val mentionColour = binding.composeEditField.linkTextColors.defaultColor binding.composeEditField.text.highlightSpans(mentionColour, highlightFinders) binding.composeEditField.doAfterTextChanged { editable -> editable!!.highlightSpans(mentionColour, highlightFinders) updateVisibleCharactersLeft() viewModel.updateContent(editable.toString()) } // work around Android platform bug -> https://issuetracker.google.com/issues/67102093 if (Build.VERSION.SDK_INT == Build.VERSION_CODES.O || Build.VERSION.SDK_INT == Build.VERSION_CODES.O_MR1 ) { binding.composeEditField.setLayerType(View.LAYER_TYPE_SOFTWARE, null) } } private fun subscribeToUpdates(mediaAdapter: MediaPreviewAdapter) { lifecycleScope.launch { viewModel.instanceInfo.collect { instanceData -> maximumTootCharacters = instanceData.maxChars charactersReservedPerUrl = instanceData.charactersReservedPerUrl maxUploadMediaNumber = instanceData.maxMediaAttachments updateVisibleCharactersLeft() } } lifecycleScope.launch { viewModel.emoji.collect(::setEmojiList) } lifecycleScope.launch { viewModel.showContentWarning.combine( viewModel.markMediaAsSensitive ) { showContentWarning, markSensitive -> updateSensitiveMediaToggle(markSensitive, showContentWarning) showContentWarning(showContentWarning) }.collect() } lifecycleScope.launch { viewModel.statusVisibility.collect(::setStatusVisibility) } lifecycleScope.launch { viewModel.media.collect { media -> mediaAdapter.submitList(media) binding.composeMediaPreviewBar.visible(media.isNotEmpty()) updateSensitiveMediaToggle( viewModel.markMediaAsSensitive.value, viewModel.showContentWarning.value ) } } lifecycleScope.launch { viewModel.poll.collect { poll -> binding.pollPreview.visible(poll != null) poll?.let(binding.pollPreview::setPoll) } } lifecycleScope.launch { viewModel.scheduledAt.collect { scheduledAt -> if (scheduledAt == null) { binding.composeScheduleView.resetSchedule() } else { binding.composeScheduleView.setDateTime(scheduledAt) } updateScheduleButton() } } lifecycleScope.launch { viewModel.media.combine(viewModel.poll) { media, poll -> val active = poll == null && media.size < maxUploadMediaNumber && (media.isEmpty() || media.first().type == QueuedMedia.Type.IMAGE) enableButton(binding.composeAddMediaButton, active, active) enablePollButton(media.isEmpty()) }.collect() } lifecycleScope.launch { viewModel.uploadError.collect { throwable -> val errorString = when (throwable) { is UploadServerError -> throwable.errorMessage is FileSizeException -> { val decimalFormat = DecimalFormat("0.##") val allowedSizeInMb = throwable.allowedSizeInBytes.toDouble() / (1024 * 1024) 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 CouldNotOpenFileException -> getString(R.string.error_media_upload_opening) is MediaTypeException -> getString(R.string.error_media_upload_opening) else -> getString( R.string.error_media_upload_sending_fmt, throwable.message ) } displayTransientMessage(errorString) } } lifecycleScope.launch { viewModel.closeConfirmation.collect { updateOnBackPressedCallbackState() } } } private fun setupButtons() { binding.composeOptionsBottomSheet.listener = this composeOptionsBehavior = BottomSheetBehavior.from(binding.composeOptionsBottomSheet) addMediaBehavior = BottomSheetBehavior.from(binding.addMediaBottomSheet) scheduleBehavior = BottomSheetBehavior.from(binding.composeScheduleView) emojiBehavior = BottomSheetBehavior.from(binding.emojiView) composeOptionsBehavior.state = BottomSheetBehavior.STATE_HIDDEN addMediaBehavior.state = BottomSheetBehavior.STATE_HIDDEN scheduleBehavior.state = BottomSheetBehavior.STATE_HIDDEN emojiBehavior.state = BottomSheetBehavior.STATE_HIDDEN 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. binding.composeTootButton.setOnClickListener { onSendClicked() } binding.composeAddMediaButton.setOnClickListener { openPickDialog() } binding.composeToggleVisibilityButton.setOnClickListener { showComposeOptions() } binding.composeContentWarningButton.setOnClickListener { onContentWarningChanged() } binding.composeEmojiButton.setOnClickListener { showEmojis() } binding.composeHideMediaButton.setOnClickListener { toggleHideMedia() } binding.composeScheduleButton.setOnClickListener { onScheduleClick() } binding.composeScheduleView.setResetOnClickListener { resetSchedule() } binding.composeScheduleView.setListener(this) binding.atButton.setOnClickListener { atButtonClicked() } binding.hashButton.setOnClickListener { hashButtonClicked() } binding.descriptionMissingWarningButton.setOnClickListener { displayTransientMessage(R.string.hint_media_description_missing) } 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, onBackPressedCallback) } private fun setupLanguageSpinner(initialLanguages: List) { binding.composePostLanguageButton.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { override fun onItemSelected( parent: AdapterView<*>, view: View?, position: Int, id: Long ) { viewModel.postLanguage = (parent.adapter.getItem(position) as Locale).modernLanguageCode } override fun onNothingSelected(parent: AdapterView<*>) { parent.setSelection(0) } } binding.composePostLanguageButton.apply { adapter = LocaleAdapter(context, android.R.layout.simple_spinner_dropdown_item, getLocaleList(initialLanguages)) setSelection(0) } } private fun setupActionBar() { setSupportActionBar(binding.toolbar) supportActionBar?.run { title = null setDisplayHomeAsUpEnabled(true) setDisplayShowHomeEnabled(true) setHomeAsUpIndicator(R.drawable.ic_close_24dp) } } private fun setupAvatar(activeAccount: AccountEntity) { val actionBarSizeAttr = intArrayOf(androidx.appcompat.R.attr.actionBarSize) val avatarSize = obtainStyledAttributes(null, actionBarSizeAttr).use { a -> a.getDimensionPixelSize(0, 1) } val animateAvatars = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false) loadAvatar( activeAccount.profilePictureUrl, binding.composeAvatar, avatarSize / 8, animateAvatars ) binding.composeAvatar.contentDescription = getString( R.string.compose_active_account_description, activeAccount.fullName ) } 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 textToInsert = if (start > 0 && !binding.composeEditField.text[start - 1].isWhitespace()) { " $text" } else { text } binding.composeEditField.text.replace(start, end, textToInsert) // Set the cursor after the inserted text binding.composeEditField.setSelection(start + text.length) } 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 editorText = binding.composeEditField.text if (start == end) { // No selection, just insert text at caret editorText.insert(start, text) // Set the cursor after the inserted text binding.composeEditField.setSelection(start + text.length) } else { var wasWord: Boolean var isWord = end < editorText.length && !Character.isWhitespace(editorText[end]) var newEnd = end // Iterate the selection backward so we don't have to juggle indices on insertion var index = end - 1 while (index >= start - 1 && index >= 0) { wasWord = isWord isWord = !Character.isWhitespace(editorText[index]) if (wasWord && !isWord) { // We've reached the beginning of a word, perform insert editorText.insert(index + 1, text) newEnd += text.length } --index } if (start == 0 && isWord) { // Special case when the selection includes the start of the text editorText.insert(0, text) newEnd += text.length } // Keep the same text (including insertions) selected binding.composeEditField.setSelection(start, newEnd) } } private fun atButtonClicked() { prependSelectedWordsWith("@") } private fun hashButtonClicked() { prependSelectedWordsWith("#") } override fun onSaveInstanceState(outState: Bundle) { outState.putParcelable(PHOTO_UPLOAD_URI_KEY, photoUploadUri) outState.putSerializable(VISIBILITY_KEY, viewModel.statusVisibility.value) outState.putBoolean(CONTENT_WARNING_VISIBLE_KEY, viewModel.showContentWarning.value) outState.putString(SCHEDULED_TIME_KEY, viewModel.scheduledAt.value) super.onSaveInstanceState(outState) } private fun displayTransientMessage(message: String) { val bar = Snackbar.make(binding.activityCompose, message, Snackbar.LENGTH_LONG) // necessary so snackbar is shown over everything bar.view.elevation = resources.getDimension(R.dimen.compose_activity_snackbar_elevation) bar.setAnchorView(R.id.composeBottomBar) bar.show() } private fun displayTransientMessage(@StringRes stringId: Int) { displayTransientMessage(getString(stringId)) } private fun toggleHideMedia() { this.viewModel.toggleMarkSensitive() } private fun updateSensitiveMediaToggle( markMediaSensitive: Boolean, contentWarningShown: Boolean ) { if (viewModel.media.value.isEmpty()) { binding.composeHideMediaButton.hide() binding.descriptionMissingWarningButton.hide() } else { binding.composeHideMediaButton.show() @AttrRes val color = if (contentWarningShown) { binding.composeHideMediaButton.setImageResource(R.drawable.ic_visibility_off_24dp) binding.composeHideMediaButton.isClickable = false materialR.attr.colorPrimary } else { binding.composeHideMediaButton.isClickable = true if (markMediaSensitive) { binding.composeHideMediaButton.setImageResource(R.drawable.ic_visibility_off_24dp) materialR.attr.colorPrimary } else { binding.composeHideMediaButton.setImageResource(R.drawable.ic_visibility_24dp) android.R.attr.textColorTertiary } } binding.composeHideMediaButton.drawable.setTint( MaterialColors.getColor( binding.composeHideMediaButton, color ) ) val oneMediaWithoutDescription = viewModel.media.value.any { media -> media.description.isNullOrEmpty() } binding.descriptionMissingWarningButton.visibility = if (oneMediaWithoutDescription) View.VISIBLE else View.GONE } } private fun updateScheduleButton() { if (viewModel.editing) { // Can't reschedule a published status enableButton(binding.composeScheduleButton, clickable = false, colorActive = false) } else { @ColorInt val color = MaterialColors.getColor( binding.composeScheduleButton, if (binding.composeScheduleView.time == null) { android.R.attr.textColorTertiary } else { materialR.attr.colorPrimary } ) binding.composeScheduleButton.drawable.setTint(color) } } private fun enableButtons(enable: Boolean, editing: Boolean) { binding.composeAddMediaButton.isClickable = enable binding.composeToggleVisibilityButton.isClickable = enable && !editing binding.composeEmojiButton.isClickable = enable binding.composeHideMediaButton.isClickable = enable binding.composeScheduleButton.isClickable = enable && !editing binding.composeTootButton.isEnabled = enable } private fun setStatusVisibility(visibility: Status.Visibility) { binding.composeOptionsBottomSheet.setStatusVisibility(visibility) binding.composeTootButton.setStatusVisibility(visibility) val iconRes = when (visibility) { Status.Visibility.PUBLIC -> R.drawable.ic_public_24dp Status.Visibility.PRIVATE -> R.drawable.ic_lock_24dp Status.Visibility.DIRECT -> R.drawable.ic_mail_24dp Status.Visibility.UNLISTED -> R.drawable.ic_lock_open_24dp else -> R.drawable.ic_lock_open_24dp } binding.composeToggleVisibilityButton.setImageResource(iconRes) if (viewModel.editing) { // Can't update visibility on published status enableButton( binding.composeToggleVisibilityButton, clickable = false, colorActive = false ) } } private fun showComposeOptions() { if (composeOptionsBehavior.state == BottomSheetBehavior.STATE_HIDDEN || composeOptionsBehavior.state == BottomSheetBehavior.STATE_COLLAPSED) { composeOptionsBehavior.state = BottomSheetBehavior.STATE_EXPANDED addMediaBehavior.state = BottomSheetBehavior.STATE_HIDDEN emojiBehavior.state = BottomSheetBehavior.STATE_HIDDEN scheduleBehavior.setState(BottomSheetBehavior.STATE_HIDDEN) } else { composeOptionsBehavior.setState(BottomSheetBehavior.STATE_HIDDEN) } } private fun onScheduleClick() { if (viewModel.scheduledAt.value == null) { binding.composeScheduleView.openPickDateDialog() } else { showScheduleView() } } private fun showScheduleView() { if (scheduleBehavior.state == BottomSheetBehavior.STATE_HIDDEN || scheduleBehavior.state == BottomSheetBehavior.STATE_COLLAPSED) { scheduleBehavior.state = BottomSheetBehavior.STATE_EXPANDED composeOptionsBehavior.state = BottomSheetBehavior.STATE_HIDDEN addMediaBehavior.state = BottomSheetBehavior.STATE_HIDDEN emojiBehavior.setState(BottomSheetBehavior.STATE_HIDDEN) } else { scheduleBehavior.setState(BottomSheetBehavior.STATE_HIDDEN) } } private fun showEmojis() { binding.emojiView.adapter?.let { if (it.itemCount == 0) { val errorMessage = getString( R.string.error_no_custom_emojis, activeAccount ) displayTransientMessage(errorMessage) } else { if (emojiBehavior.state == BottomSheetBehavior.STATE_HIDDEN || emojiBehavior.state == BottomSheetBehavior.STATE_COLLAPSED) { emojiBehavior.state = BottomSheetBehavior.STATE_EXPANDED composeOptionsBehavior.state = BottomSheetBehavior.STATE_HIDDEN addMediaBehavior.state = BottomSheetBehavior.STATE_HIDDEN scheduleBehavior.setState(BottomSheetBehavior.STATE_HIDDEN) } else { emojiBehavior.setState(BottomSheetBehavior.STATE_HIDDEN) } } } } private fun openPickDialog() { if (addMediaBehavior.state == BottomSheetBehavior.STATE_HIDDEN || addMediaBehavior.state == BottomSheetBehavior.STATE_COLLAPSED) { addMediaBehavior.state = BottomSheetBehavior.STATE_EXPANDED composeOptionsBehavior.state = BottomSheetBehavior.STATE_HIDDEN emojiBehavior.state = BottomSheetBehavior.STATE_HIDDEN scheduleBehavior.setState(BottomSheetBehavior.STATE_HIDDEN) } else { addMediaBehavior.setState(BottomSheetBehavior.STATE_HIDDEN) } } private fun onMediaPick() { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { pickMediaFilePermissionLauncher.launch(Manifest.permission.READ_EXTERNAL_STORAGE) } else { pickMediaFileLauncher.launch(true) } addMediaBehavior.setState(BottomSheetBehavior.STATE_HIDDEN) } private fun openPollDialog() = lifecycleScope.launch { addMediaBehavior.state = BottomSheetBehavior.STATE_HIDDEN val instanceParams = viewModel.instanceInfo.first() showAddPollDialog( context = this@ComposeActivity, poll = viewModel.poll.value, maxOptionCount = instanceParams.pollMaxOptions, maxOptionLength = instanceParams.pollMaxLength, minDuration = instanceParams.pollMinDuration, maxDuration = instanceParams.pollMaxDuration, onUpdatePoll = viewModel::updatePoll ) } 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 layoutParams = LinearLayout.LayoutParams( ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT ) layoutParams.setMargins(margin, margin, margin, marginBottom) binding.pollPreview.layoutParams = layoutParams binding.pollPreview.setOnClickListener { val popup = PopupMenu(this, binding.pollPreview) val editId = 1 val removeId = 2 popup.menu.add(0, editId, 0, R.string.edit_poll) popup.menu.add(0, removeId, 0, R.string.action_remove) popup.setOnMenuItemClickListener { menuItem -> when (menuItem.itemId) { editId -> openPollDialog() removeId -> removePoll() } true } popup.show() } } private fun removePoll() { viewModel.updatePoll(null) binding.pollPreview.hide() } override fun onVisibilityChanged(visibility: Status.Visibility) { composeOptionsBehavior.state = BottomSheetBehavior.STATE_HIDDEN viewModel.changeStatusVisibility(visibility) } @VisibleForTesting fun calculateTextLength(): Int { return statusLength( binding.composeEditField.text, binding.composeContentWarningField.text, charactersReservedPerUrl ) } @VisibleForTesting val selectedLanguage: String? get() = viewModel.postLanguage private fun updateVisibleCharactersLeft() { val remainingLength = maximumTootCharacters - calculateTextLength() binding.composeCharactersLeftView.text = String.format(Locale.getDefault(), "%d", remainingLength) val textColor = if (remainingLength < 0) { getColor(R.color.warning_color) } else { MaterialColors.getColor( binding.composeCharactersLeftView, android.R.attr.textColorTertiary ) } binding.composeCharactersLeftView.setTextColor(textColor) } private fun onContentWarningChanged() { val showWarning = binding.composeContentWarningBar.isGone viewModel.contentWarningChanged(showWarning) updateVisibleCharactersLeft() } private fun verifyScheduledTime(): Boolean { return binding.composeScheduleView.verifyScheduledTime( binding.composeScheduleView.getDateTime(viewModel.scheduledAt.value) ) } private fun onSendClicked() { if (verifyScheduledTime()) { sendStatus() } else { showScheduleView() } } /** This is for the fancy keyboards which can insert images and stuff, and drag&drop etc */ override fun onReceiveContent(view: View, contentInfo: ContentInfoCompat): ContentInfoCompat? { if (contentInfo.clip.description.hasMimeType("image/*")) { val split = contentInfo.partition { item: ClipData.Item -> item.uri != null } split.first?.let { content -> val description = (contentInfo.clip.description.label as String?)?.let { // 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 it) { null } else { it } } viewModel.pickMedia( content.clip.map { clipItem -> ComposeViewModel.MediaData( uri = clipItem.uri, description = description ) } ) } return split.second } return contentInfo } private fun sendStatus() { enableButtons(false, viewModel.editing) val contentText = binding.composeEditField.text.toString() var spoilerText = "" if (viewModel.showContentWarning.value) { spoilerText = binding.composeContentWarningField.text.toString() } val characterCount = calculateTextLength() if ((characterCount <= 0 || contentText.isBlank()) && viewModel.media.value.isEmpty()) { binding.composeEditField.error = getString(R.string.error_empty) enableButtons(true, viewModel.editing) } else if (characterCount <= maximumTootCharacters) { lifecycleScope.launch { viewModel.sendStatus(contentText, spoilerText, activeAccount.id) deleteDraftAndFinish() } } else { binding.composeEditField.error = getString(R.string.error_compose_character_limit) enableButtons(true, viewModel.editing) } } private fun initiateCameraApp() { addMediaBehavior.state = BottomSheetBehavior.STATE_HIDDEN val photoFile: File = try { createNewImageFile(this) } catch (ex: IOException) { displayTransientMessage(R.string.error_media_upload_opening) return } // Continue only if the File was successfully created photoUploadUri = FileProvider.getUriForFile( this, BuildConfig.APPLICATION_ID + ".fileprovider", photoFile ).also { uri -> takePictureLauncher.launch(uri) } } private fun enableButton(button: ImageButton, clickable: Boolean, colorActive: Boolean) { button.isEnabled = clickable setDrawableTint( this, button.drawable, if (colorActive) { android.R.attr.textColorTertiary } else { R.attr.textColorDisabled } ) } private fun enablePollButton(enable: Boolean) { binding.addPollTextActionTextView.isEnabled = enable val textColor = MaterialColors.getColor( binding.addPollTextActionTextView, if (enable) { android.R.attr.textColorTertiary } else { R.attr.textColorDisabled } ) binding.addPollTextActionTextView.setTextColor(textColor) binding.addPollTextActionTextView.compoundDrawablesRelative[0].setTint(textColor) } private fun editImageInQueue(item: QueuedMedia) { // If input image is lossless, output image should be lossless. // Currently the only supported lossless format is png. val mimeType: String? = contentResolver.getType(item.uri) val isPng: Boolean = mimeType != null && mimeType.endsWith("/png") 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 ) viewModel.cropImageItemOld = item cropImage.launch( options(uri = item.uri) { setOutputUri(uriNew) setOutputCompressFormat( if (isPng) Bitmap.CompressFormat.PNG else Bitmap.CompressFormat.JPEG ) } ) } private fun removeMediaFromQueue(item: QueuedMedia) { viewModel.removeMediaFromQueue(item) } private fun showContentWarning(show: Boolean) { TransitionManager.beginDelayedTransition( binding.composeContentWarningBar.parent as ViewGroup ) @AttrRes val color = if (show) { binding.composeContentWarningBar.show() binding.composeContentWarningField.setSelection( binding.composeContentWarningField.text.length ) binding.composeContentWarningField.requestFocus() materialR.attr.colorPrimary } else { binding.composeContentWarningBar.hide() binding.composeEditField.requestFocus() android.R.attr.textColorTertiary } binding.composeContentWarningButton.drawable.setTint( MaterialColors.getColor( binding.composeHideMediaButton, color ) ) } override fun onOptionsItemSelected(item: MenuItem): Boolean { if (item.itemId == android.R.id.home) { handleCloseButton() return true } return super.onOptionsItemSelected(item) } override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean { if (event.action == KeyEvent.ACTION_DOWN) { if (event.isCtrlPressed) { if (keyCode == KeyEvent.KEYCODE_ENTER) { // send toot by pressing CTRL + ENTER this.onSendClicked() return true } } if (keyCode == KeyEvent.KEYCODE_BACK) { onBackPressedDispatcher.onBackPressed() return true } } return super.onKeyDown(keyCode, event) } private fun handleCloseButton() { val contentText = binding.composeEditField.text.toString() val contentWarning = binding.composeContentWarningField.text.toString() when (viewModel.closeConfirmation.value) { ConfirmationKind.NONE -> { viewModel.stopUploads() finish() } ConfirmationKind.SAVE_OR_DISCARD -> getSaveAsDraftOrDiscardDialog(contentText, contentWarning).show() ConfirmationKind.UPDATE_OR_DISCARD -> getUpdateDraftOrDiscardDialog(contentText, contentWarning).show() ConfirmationKind.CONTINUE_EDITING_OR_DISCARD_CHANGES -> getContinueEditingOrDiscardDialog().show() ConfirmationKind.CONTINUE_EDITING_OR_DISCARD_DRAFT -> getDeleteEmptyDraftOrContinueEditing().show() } } /** * 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 ): MaterialAlertDialogBuilder { val warning = if (viewModel.media.value.isNotEmpty()) { R.string.compose_save_draft_loses_media } else { R.string.compose_save_draft } return MaterialAlertDialogBuilder(this) .setMessage(warning) .setPositiveButton(R.string.action_save) { _, _ -> viewModel.stopUploads() saveDraftAndFinish(contentText, contentWarning) } .setNegativeButton(R.string.action_delete) { _, _ -> viewModel.stopUploads() deleteDraftAndFinish() } } /** * 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 ): MaterialAlertDialogBuilder { val warning = if (viewModel.media.value.isNotEmpty()) { R.string.compose_save_draft_loses_media } else { R.string.compose_save_draft } return MaterialAlertDialogBuilder(this) .setMessage(warning) .setPositiveButton(R.string.action_save) { _, _ -> viewModel.stopUploads() saveDraftAndFinish(contentText, contentWarning) } .setNegativeButton(R.string.action_discard) { _, _ -> viewModel.stopUploads() finish() } } /** * User is editing a post (scheduled, or posted), and can either go back to editing, or * discard the changes. */ private fun getContinueEditingOrDiscardDialog(): MaterialAlertDialogBuilder { return MaterialAlertDialogBuilder(this) .setMessage(R.string.compose_unsaved_changes) .setPositiveButton(R.string.action_continue_edit) { _, _ -> // Do nothing, dialog will dismiss, user can continue editing } .setNegativeButton(R.string.action_discard) { _, _ -> viewModel.stopUploads() finish() } } /** * User is editing an existing draft and making it empty. * The user can either delete the empty draft or go back to editing. */ private fun getDeleteEmptyDraftOrContinueEditing(): MaterialAlertDialogBuilder { return MaterialAlertDialogBuilder(this) .setMessage(R.string.compose_delete_draft) .setPositiveButton(R.string.action_delete) { _, _ -> viewModel.deleteDraft() viewModel.stopUploads() finish() } .setNegativeButton(R.string.action_continue_edit) { _, _ -> // Do nothing, dialog will dismiss, user can continue editing } } private fun deleteDraftAndFinish() { viewModel.deleteDraft() finish() } private fun saveDraftAndFinish(contentText: String, contentWarning: String) { lifecycleScope.launch { viewModel.saveDraft(contentText, contentWarning) finish() } } override fun search(token: String): List { return viewModel.searchAutocompleteSuggestions(token) } override fun onEmojiSelected(shortcode: String) { replaceTextAtCaret(":$shortcode: ") } private fun setEmojiList(emojiList: List?) { if (emojiList != null) { val animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false) binding.emojiView.adapter = EmojiAdapter(emojiList, this@ComposeActivity, animateEmojis) enableButton(binding.composeEmojiButton, true, emojiList.isNotEmpty()) } } override fun onTimeSet(time: String?) { viewModel.updateScheduledAt(time) if (verifyScheduledTime()) { scheduleBehavior.state = BottomSheetBehavior.STATE_HIDDEN } else { showScheduleView() } } private fun resetSchedule() { viewModel.updateScheduledAt(null) scheduleBehavior.state = BottomSheetBehavior.STATE_HIDDEN } override fun onUpdateDescription(localId: Int, description: String) { viewModel.updateDescription(localId, description) } /** * Status' kind. This particularly affects how the status is handled if the user * backs out of the edit. */ enum class ComposeKind { /** Status is new */ NEW, /** Editing a posted status */ EDIT_POSTED, /** Editing a status started as an existing draft */ EDIT_DRAFT, /** Editing an an existing scheduled status */ EDIT_SCHEDULED } @Parcelize data class ComposeOptions( // Let's keep fields var until all consumers are Kotlin var scheduledTootId: String? = null, var draftId: Int? = null, var content: String? = null, var mediaUrls: List? = null, var mediaDescriptions: List? = null, var mentionedUsernames: Set? = null, var inReplyToId: String? = null, var replyVisibility: Status.Visibility? = null, var visibility: Status.Visibility? = null, var contentWarning: String? = null, var replyingStatusAuthor: String? = null, var replyingStatusContent: String? = null, var mediaAttachments: List? = null, var draftAttachments: List? = null, var scheduledAt: String? = null, var sensitive: Boolean? = null, var poll: NewPoll? = null, var modifiedInitialState: Boolean? = null, var language: String? = null, var statusId: String? = null, var kind: ComposeKind? = null ) : Parcelable companion object { private const val TAG = "ComposeActivity" // logging tag internal const val COMPOSE_OPTIONS_EXTRA = "COMPOSE_OPTIONS" private const val PHOTO_UPLOAD_URI_KEY = "PHOTO_UPLOAD_URI" private const val VISIBILITY_KEY = "VISIBILITY" private const val SCHEDULED_TIME_KEY = "SCHEDULE" private const val CONTENT_WARNING_VISIBLE_KEY = "CONTENT_WARNING_VISIBLE" /** * @param options ComposeOptions to configure the ComposeActivity * @return an Intent to start the ComposeActivity */ @JvmStatic fun startIntent(context: Context, options: ComposeOptions): Intent { return Intent(context, ComposeActivity::class.java).apply { putExtra(COMPOSE_OPTIONS_EXTRA, options) } } fun canHandleMimeType(mimeType: String?): Boolean { return mimeType != null && (mimeType.startsWith("image/") || mimeType.startsWith("video/") || mimeType.startsWith("audio/") || mimeType == "text/plain") } /** * Calculate the effective status length. * * Some text is counted differently: * * In the status body: * * - URLs always count for [urlLength] characters irrespective of their actual length * (https://docs.joinmastodon.org/user/posting/#links) * - Mentions ("@user@some.instance") only count the "@user" part * (https://docs.joinmastodon.org/user/posting/#mentions) * - Hashtags are always treated as their actual length, including the "#" * (https://docs.joinmastodon.org/user/posting/#hashtags) * * Content warning text is always treated as its full length, URLs and other entities * are not treated differently. * * @param body status body text * @param contentWarning optional content warning text * @param urlLength the number of characters attributed to URLs * @return the effective status length */ @JvmStatic fun statusLength(body: Spanned, contentWarning: Spanned?, urlLength: Int): Int { 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) { is MentionSpan -> { // Ignore everything from the second "@" (if present) span.url.length - ( span.url.indexOf("@", 1).takeIf { it >= 0 } ?: span.url.length ) } else -> { // Expected to be negative if the URL length < maxUrlLength span.url.perceivedCharacterLength() - urlLength } } } // Content warning text is treated as is, URLs or mentions there are not special 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 } } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeAutoCompleteAdapter.kt ================================================ /* Copyright 2022 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.compose import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.BaseAdapter import android.widget.Filter import android.widget.Filterable import androidx.annotation.WorkerThread import com.bumptech.glide.Glide import com.keylesspalace.tusky.R import com.keylesspalace.tusky.databinding.ItemAutocompleteAccountBinding import com.keylesspalace.tusky.databinding.ItemAutocompleteEmojiBinding import com.keylesspalace.tusky.databinding.ItemAutocompleteHashtagBinding import com.keylesspalace.tusky.entity.Emoji import com.keylesspalace.tusky.entity.TimelineAccount import com.keylesspalace.tusky.util.emojify import com.keylesspalace.tusky.util.loadAvatar import com.keylesspalace.tusky.util.visible class ComposeAutoCompleteAdapter( private val autocompletionProvider: AutocompletionProvider, private val animateAvatar: Boolean, private val animateEmojis: Boolean, private val showBotBadge: Boolean, // if true, @ # : are returned in the result, otherwise only the raw value private val withDecoration: Boolean = true, ) : BaseAdapter(), Filterable { private var resultList: List = emptyList() override fun getCount() = resultList.size override fun getItem(index: Int): AutocompleteResult { return resultList[index] } override fun getItemId(position: Int): Long { return position.toLong() } override fun getFilter() = object : Filter() { override fun convertResultToString(resultValue: Any): CharSequence { return when (resultValue) { is AutocompleteResult.AccountResult -> if (withDecoration) "@${resultValue.account.username}" else resultValue.account.username is AutocompleteResult.HashtagResult -> if (withDecoration) "#${resultValue.hashtag}" else resultValue.hashtag is AutocompleteResult.EmojiResult -> if (withDecoration) ":${resultValue.emoji.shortcode}:" else resultValue.emoji.shortcode else -> "" } } @WorkerThread override fun performFiltering(constraint: CharSequence?): FilterResults { val filterResults = FilterResults() if (constraint != null) { val results = autocompletionProvider.search(constraint.toString()) filterResults.values = results filterResults.count = results.size } return filterResults } @Suppress("UNCHECKED_CAST") override fun publishResults(constraint: CharSequence?, results: FilterResults) { if (results.count > 0) { resultList = results.values as List notifyDataSetChanged() } else { notifyDataSetInvalidated() } } } override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { val itemViewType = getItemViewType(position) val context = parent.context val view: View = convertView ?: run { val layoutInflater = LayoutInflater.from(context) val binding = when (itemViewType) { ACCOUNT_VIEW_TYPE -> ItemAutocompleteAccountBinding.inflate(layoutInflater) HASHTAG_VIEW_TYPE -> ItemAutocompleteHashtagBinding.inflate(layoutInflater) EMOJI_VIEW_TYPE -> ItemAutocompleteEmojiBinding.inflate(layoutInflater) else -> throw AssertionError("unknown view type") } binding.root.tag = binding binding.root } when (val binding = view.tag) { is ItemAutocompleteAccountBinding -> { val accountResult = getItem(position) as AutocompleteResult.AccountResult 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 ) loadAvatar( account.avatar, binding.avatar, avatarRadius, animateAvatar ) binding.avatarBadge.visible(showBotBadge && account.bot) } is ItemAutocompleteHashtagBinding -> { val result = getItem(position) as AutocompleteResult.HashtagResult binding.root.text = context.getString(R.string.hashtag_format, result.hashtag) } is ItemAutocompleteEmojiBinding -> { val emojiResult = getItem(position) as AutocompleteResult.EmojiResult val (shortcode, url) = emojiResult.emoji binding.shortcode.text = context.getString(R.string.emoji_shortcode_format, shortcode) Glide.with(binding.preview) .load(url) .into(binding.preview) } } return view } override fun getViewTypeCount() = 3 override fun getItemViewType(position: Int): Int { return when (getItem(position)) { is AutocompleteResult.AccountResult -> ACCOUNT_VIEW_TYPE is AutocompleteResult.HashtagResult -> HASHTAG_VIEW_TYPE is AutocompleteResult.EmojiResult -> EMOJI_VIEW_TYPE } } sealed interface AutocompleteResult { class AccountResult(val account: TimelineAccount) : AutocompleteResult class HashtagResult(val hashtag: String) : AutocompleteResult class EmojiResult(val emoji: Emoji) : AutocompleteResult } interface AutocompletionProvider { fun search(token: String): List } companion object { private const val ACCOUNT_VIEW_TYPE = 0 private const val HASHTAG_VIEW_TYPE = 1 private const val EMOJI_VIEW_TYPE = 2 } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeTokenizer.kt ================================================ /* 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.components.compose import android.text.SpannableString import android.text.Spanned import android.text.TextUtils import android.widget.MultiAutoCompleteTextView class ComposeTokenizer : MultiAutoCompleteTextView.Tokenizer { private fun isMentionOrHashtagAllowedCharacter(character: Char): Boolean { return Character.isLetterOrDigit(character) || character == '_' || // simple usernames character == '-' || // extended usernames character == '.' // domain dot } override fun findTokenStart(text: CharSequence, cursor: Int): Int { if (cursor == 0) { return cursor } var i = cursor var character = text[i - 1] // go up to first illegal character or character we're looking for (@, # or :) while (i > 0 && !(character == '@' || character == '#' || character == ':')) { if (!isMentionOrHashtagAllowedCharacter(character)) { return cursor } i-- character = if (i == 0) ' ' else text[i - 1] } // maybe caught domain name? try search username if (i > 2 && character == '@') { var j = i - 1 var character2 = text[i - 2] // again go up to first illegal character or tag "@" while (j > 0 && character2 != '@') { if (!isMentionOrHashtagAllowedCharacter(character2)) { break } j-- character2 = if (j == 0) ' ' else text[j - 1] } // found mention symbol, override cursor if (character2 == '@') { i = j character = character2 } } if (i < 1 || (character != '@' && character != '#' && character != ':') || i > 1 && !Character.isWhitespace(text[i - 2]) ) { return cursor } return i - 1 } override fun findTokenEnd(text: CharSequence, cursor: Int): Int { var i = cursor val length = text.length while (i < length) { if (text[i] == ' ') { return i } else { i++ } } return length } override fun terminateToken(text: CharSequence): CharSequence { var i = text.length while (i > 0 && text[i - 1] == ' ') { i-- } return if (i > 0 && text[i - 1] == ' ') { text } else if (text is Spanned) { val s = SpannableString("$text ") TextUtils.copySpansFrom(text, 0, text.length, Object::class.java, s, 0) s } else { "$text " } } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/compose/ComposeViewModel.kt ================================================ /* 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.components.compose import android.net.Uri import android.util.Log import androidx.core.net.toUri import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import at.connyduck.calladapter.networkresult.fold import com.keylesspalace.tusky.components.compose.ComposeActivity.ComposeKind import com.keylesspalace.tusky.components.compose.ComposeAutoCompleteAdapter.AutocompleteResult import com.keylesspalace.tusky.components.drafts.DraftHelper import com.keylesspalace.tusky.components.instanceinfo.InstanceInfo import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository import com.keylesspalace.tusky.components.search.SearchType import com.keylesspalace.tusky.db.AccountManager 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.network.MastodonApi 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 dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async 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 @HiltViewModel class ComposeViewModel @Inject constructor( private val api: MastodonApi, private val accountManager: AccountManager, private val mediaUploader: MediaUploader, private val serviceClient: ServiceClient, private val draftHelper: DraftHelper, instanceInfoRepo: InstanceInfoRepository ) : ViewModel() { private var replyingStatusAuthor: String? = null private var replyingStatusContent: String? = null internal var startingText: String? = null internal var postLanguage: String? = null private var draftId: Int = 0 private var scheduledTootId: String? = null private var startingContentWarning: String = "" private var inReplyToId: String? = null private var originalStatusId: String? = null private var startingVisibility: Status.Visibility = Status.Visibility.UNKNOWN private var contentWarningStateChanged: Boolean = false private var modifiedInitialState: Boolean = false private var hasScheduledTimeChanged: Boolean = false 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) private val _markMediaAsSensitive = MutableStateFlow(accountManager.activeAccount?.defaultMediaSensitivity == true) val markMediaAsSensitive: StateFlow = _markMediaAsSensitive.asStateFlow() private val _statusVisibility = MutableStateFlow(Status.Visibility.UNKNOWN) val statusVisibility: StateFlow = _statusVisibility.asStateFlow() private val _showContentWarning = MutableStateFlow(false) val showContentWarning: StateFlow = _showContentWarning.asStateFlow() 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 fun pickMedia(uri: Uri) { pickMedia(listOf(MediaData(uri))) } fun pickMedia(mediaList: List) = viewModelScope.launch(Dispatchers.IO) { val instanceInfo = instanceInfo.first() mediaList.map { m -> async { mediaUploader.prepareMedia(m.uri, instanceInfo) } }.forEachIndexed { index, preparedMedia -> preparedMedia.await().fold({ (type, uri, size) -> if (type != QueuedMedia.Type.IMAGE && _media.value.firstOrNull()?.type == QueuedMedia.Type.IMAGE ) { _uploadError.emit(VideoOrImageException()) } else { val pickedMedia = mediaList[index] addMediaToQueue(type, uri, size, pickedMedia.description, pickedMedia.focus) } }, { error -> _uploadError.emit(error) }) } } fun addMediaToQueue( type: QueuedMedia.Type, uri: Uri, mediaSize: Long, description: String? = null, focus: Attachment.Focus? = null, replaceItem: QueuedMedia? = null ): QueuedMedia { val mediaItem = QueuedMedia( localId = mediaUploader.getNewLocalMediaId(), uri = uri, type = type, mediaSize = mediaSize, description = description, focus = focus, state = QueuedMedia.State.UPLOADING ) _media.update { mediaList -> if (replaceItem != null) { mediaUploader.cancelUploadScope(replaceItem.localId) mediaList.map { if (it.localId == replaceItem.localId) mediaItem else it } } else { // Append mediaList + mediaItem } } viewModelScope.launch { mediaUploader .uploadMedia(mediaItem, instanceInfo.first()) .collect { event -> val item = _media.value.find { it.localId == mediaItem.localId } ?: return@collect val newMediaItem = when (event) { is UploadEvent.ProgressEvent -> item.copy(uploadPercent = event.percentage) is UploadEvent.FinishedEvent -> item.copy( id = event.mediaId, uploadPercent = -1, 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) return@collect } } _media.update { mediaList -> mediaList.map { mediaItem -> if (mediaItem.localId == newMediaItem.localId) { newMediaItem } else { mediaItem } } } } } updateCloseConfirmation() return mediaItem } 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, type = type, mediaSize = 0, uploadPercent = -1, id = id, description = description, focus = focus, state = QueuedMedia.State.PUBLISHED ) mediaList + mediaItem } } fun removeMediaFromQueue(item: QueuedMedia) { mediaUploader.cancelUploadScope(item.localId) _media.update { mediaList -> mediaList.filter { it.localId != item.localId } } updateCloseConfirmation() } fun toggleMarkSensitive() { this._markMediaAsSensitive.value = this._markMediaAsSensitive.value != true } 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(currentContent, contentWarning)) { ConfirmationKind.NONE } else { ConfirmationKind.SAVE_OR_DISCARD } ComposeKind.EDIT_DRAFT -> if (isEmpty(currentContent, contentWarning)) { ConfirmationKind.CONTINUE_EDITING_OR_DISCARD_DRAFT } else { ConfirmationKind.UPDATE_OR_DISCARD } ComposeKind.EDIT_POSTED -> ConfirmationKind.CONTINUE_EDITING_OR_DISCARD_CHANGES ComposeKind.EDIT_SCHEDULED -> ConfirmationKind.CONTINUE_EDITING_OR_DISCARD_CHANGES } } else { ConfirmationKind.NONE } } 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 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) } fun contentWarningChanged(value: Boolean) { _showContentWarning.value = value contentWarningStateChanged = true updateCloseConfirmation() } fun deleteDraft() { viewModelScope.launch { if (draftId != 0) { draftHelper.deleteDraftAndAttachments(draftId) } } } fun stopUploads() { mediaUploader.cancelUploadScope(*_media.value.map { it.localId }.toIntArray()) } suspend fun saveDraft(content: String, contentWarning: String) { val mediaUris: MutableList = mutableListOf() val mediaDescriptions: MutableList = mutableListOf() val mediaFocus: MutableList = mutableListOf() for (item in _media.value) { mediaUris.add(item.uri.toString()) mediaDescriptions.add(item.description) mediaFocus.add(item.focus) } draftHelper.saveDraft( draftId = draftId, accountId = accountManager.activeAccount?.id!!, inReplyToId = inReplyToId, content = content, contentWarning = contentWarning, sensitive = _markMediaAsSensitive.value, visibility = _statusVisibility.value, mediaUris = mediaUris, mediaDescriptions = mediaDescriptions, mediaFocus = mediaFocus, poll = _poll.value, failedToSend = false, failedToSendAlert = false, scheduledAt = _scheduledAt.value, language = postLanguage, statusId = originalStatusId ) } /** * Send status to the server. * Uses current state plus provided arguments. */ suspend fun sendStatus(content: String, spoilerText: String, accountId: Long) { if (!scheduledTootId.isNullOrEmpty()) { api.deleteScheduledStatus(scheduledTootId!!) } val attachedMedia = _media.value.map { item -> MediaToSend( localId = item.localId, id = item.id, uri = item.uri.toString(), description = item.description, focus = item.focus, processed = item.state == QueuedMedia.State.PROCESSED || item.state == QueuedMedia.State.PUBLISHED ) } val tootToSend = StatusToSend( text = content, warningText = spoilerText, visibility = _statusVisibility.value.stringValue, sensitive = attachedMedia.isNotEmpty() && (_markMediaAsSensitive.value || _showContentWarning.value), media = attachedMedia, scheduledAt = _scheduledAt.value, inReplyToId = inReplyToId, poll = _poll.value, replyingStatusContent = null, replyingStatusAuthorUsername = null, accountId = accountId, draftId = draftId, idempotencyKey = randomAlphanumericString(16), retries = 0, language = postLanguage, statusId = originalStatusId ) serviceClient.sendToot(tootToSend) } private fun updateMediaItem(localId: Int, mutator: (QueuedMedia) -> QueuedMedia) { _media.update { mediaList -> mediaList.map { mediaItem -> if (mediaItem.localId == localId) { mutator(mediaItem) } else { mediaItem } } } } fun updateDescription(localId: Int, description: String) { updateMediaItem(localId) { mediaItem -> mediaItem.copy(description = description) } } fun updateFocus(localId: Int, focus: Attachment.Focus) { updateMediaItem(localId) { mediaItem -> mediaItem.copy(focus = focus) } } fun searchAutocompleteSuggestions(token: String): List { return when (token[0]) { '@' -> runBlocking { api.searchAccounts(query = token.substring(1), limit = 10) .fold({ accounts -> accounts.map { AutocompleteResult.AccountResult(it) } }, { e -> Log.e(TAG, "Autocomplete search for $token failed.", e) emptyList() }) } '#' -> runBlocking { api.search( query = token, type = SearchType.Hashtag.apiParameter, limit = 10 ) .fold({ searchResult -> searchResult.hashtags.map { AutocompleteResult.HashtagResult(it.name) } }, { e -> Log.e(TAG, "Autocomplete search for $token failed.", e) emptyList() }) } ':' -> { val emojiList = emoji.replayCache.firstOrNull() ?: return emptyList() val incomplete = token.substring(1) emojiList.filter { emoji -> emoji.shortcode.contains(incomplete, ignoreCase = true) }.sortedBy { emoji -> emoji.shortcode.indexOf(incomplete, ignoreCase = true) }.map { emoji -> AutocompleteResult.EmojiResult(emoji) } } else -> { Log.w(TAG, "Unexpected autocompletion token: $token") emptyList() } } } fun setup(composeOptions: ComposeActivity.ComposeOptions?) { if (setupComplete) { return } composeKind = composeOptions?.kind ?: ComposeKind.NEW inReplyToId = composeOptions?.inReplyToId val activeAccount = accountManager.activeAccount!! val preferredVisibility = if (inReplyToId != null) { activeAccount.defaultReplyPrivacy.toVisibilityOr(activeAccount.defaultPostPrivacy) } else { activeAccount.defaultPostPrivacy } val replyVisibility = composeOptions?.replyVisibility ?: Status.Visibility.UNKNOWN startingVisibility = Status.Visibility.fromInt( preferredVisibility.int.coerceAtLeast(replyVisibility.int) ) modifiedInitialState = composeOptions?.modifiedInitialState == true val contentWarning = composeOptions?.contentWarning if (contentWarning != null) { startingContentWarning = contentWarning } if (!contentWarningStateChanged) { _showContentWarning.value = !contentWarning.isNullOrBlank() } // recreate media list val draftAttachments = composeOptions?.draftAttachments if (draftAttachments != null) { // when coming from DraftActivity draftAttachments.map { attachment -> MediaData(attachment.uri, attachment.description, attachment.focus) }.let(::pickMedia) } else { composeOptions?.mediaAttachments?.forEach { a -> // when coming from redraft or ScheduledTootActivity val mediaType = when (a.type) { Attachment.Type.VIDEO, Attachment.Type.GIFV -> QueuedMedia.Type.VIDEO Attachment.Type.UNKNOWN, Attachment.Type.IMAGE -> QueuedMedia.Type.IMAGE Attachment.Type.AUDIO -> QueuedMedia.Type.AUDIO } addUploadedMedia(a.id, mediaType, a.url.toUri(), a.description, a.meta?.focus) } } draftId = composeOptions?.draftId ?: 0 scheduledTootId = composeOptions?.scheduledTootId originalStatusId = composeOptions?.statusId startingText = composeOptions?.content currentContent = composeOptions?.content postLanguage = composeOptions?.language val tootVisibility = composeOptions?.visibility ?: Status.Visibility.UNKNOWN if (tootVisibility.int != Status.Visibility.UNKNOWN.int) { startingVisibility = tootVisibility } _statusVisibility.value = startingVisibility val mentionedUsernames = composeOptions?.mentionedUsernames if (mentionedUsernames != null) { val builder = StringBuilder() for (name in mentionedUsernames) { builder.append('@') builder.append(name) builder.append(' ') } startingText = builder.toString() } _scheduledAt.value = composeOptions?.scheduledAt composeOptions?.sensitive?.let { _markMediaAsSensitive.value = it } val poll = composeOptions?.poll if (poll != null && composeOptions.mediaAttachments.isNullOrEmpty()) { this._poll.value = poll } replyingStatusContent = composeOptions?.replyingStatusContent replyingStatusAuthor = composeOptions?.replyingStatusAuthor updateCloseConfirmation() setupComplete = true } fun updatePoll(newPoll: NewPoll?) { _poll.value = newPoll updateCloseConfirmation() } fun updateScheduledAt(newScheduledAt: String?) { if (newScheduledAt != _scheduledAt.value) { hasScheduledTimeChanged = true } _scheduledAt.value = newScheduledAt } val editing: Boolean get() = !originalStatusId.isNullOrEmpty() private companion object { const val TAG = "ComposeViewModel" } enum class ConfirmationKind { NONE, // just close SAVE_OR_DISCARD, UPDATE_OR_DISCARD, CONTINUE_EDITING_OR_DISCARD_CHANGES, // editing post CONTINUE_EDITING_OR_DISCARD_DRAFT // edit draft } data class QueuedMedia( val localId: Int, val uri: Uri, val type: Type, val mediaSize: Long, val uploadPercent: Int = 0, val id: String? = null, val description: String? = null, val focus: Attachment.Focus? = null, val state: State ) { enum class Type { IMAGE, VIDEO, AUDIO } enum class State { UPLOADING, UNPROCESSED, PROCESSED, PUBLISHED } } data class MediaData( val uri: Uri, val description: String? = null, val focus: Attachment.Focus? = null ) } /** * Thrown when trying to add an image when video is already present or the other way around */ class VideoOrImageException : Exception() ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/compose/ImageDownsizer.kt ================================================ /* Copyright 2022 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.compose import android.content.ContentResolver import android.graphics.Bitmap import android.graphics.Bitmap.CompressFormat import android.graphics.BitmapFactory import android.net.Uri import com.keylesspalace.tusky.util.calculateInSampleSize import com.keylesspalace.tusky.util.closeQuietly import com.keylesspalace.tusky.util.getImageOrientation import com.keylesspalace.tusky.util.reorientBitmap import java.io.File import java.io.FileNotFoundException import java.io.FileOutputStream /** * @param uri the uri pointing to the input file * @param sizeLimit the maximum number of bytes the output image is allowed to have * @param contentResolver to resolve the specified input uri * @param tempFile the file where the result will be stored * @return true when the image was successfully resized, false otherwise */ fun downsizeImage( uri: Uri, sizeLimit: Int, contentResolver: ContentResolver, tempFile: File ): Boolean { val decodeBoundsInputStream = try { contentResolver.openInputStream(uri) ?: return false } catch (e: FileNotFoundException) { return false } // Initially, just get the image dimensions. val options = BitmapFactory.Options() options.inJustDecodeBounds = true BitmapFactory.decodeStream(decodeBoundsInputStream, null, options) decodeBoundsInputStream.closeQuietly() // 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. */ var scaledImageSize = 1024 do { val outputStream = try { FileOutputStream(tempFile) } catch (e: FileNotFoundException) { return false } val decodeBitmapInputStream = try { contentResolver.openInputStream(uri) ?: return false } catch (e: FileNotFoundException) { return false } options.inSampleSize = calculateInSampleSize(options, scaledImageSize, scaledImageSize) options.inJustDecodeBounds = false val scaledBitmap: Bitmap = try { BitmapFactory.decodeStream(decodeBitmapInputStream, null, options) } catch (error: OutOfMemoryError) { return false } finally { decodeBitmapInputStream.closeQuietly() } ?: return false val reorientedBitmap = reorientBitmap(scaledBitmap, orientation) if (reorientedBitmap == null) { scaledBitmap.recycle() return false } /* Retain transparency if there is any by encoding as png */ val format: CompressFormat = if (!reorientedBitmap.hasAlpha()) { CompressFormat.JPEG } else { CompressFormat.PNG } reorientedBitmap.compress(format, 85, outputStream) reorientedBitmap.recycle() scaledImageSize /= 2 } while (tempFile.length() > sizeLimit) return true } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/compose/MediaPreviewAdapter.kt ================================================ /* 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.components.compose import android.content.Context import android.view.View import android.view.ViewGroup import android.widget.ImageView import android.widget.PopupMenu import androidx.constraintlayout.widget.ConstraintLayout import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView import com.bumptech.glide.Glide import com.bumptech.glide.load.engine.DiskCacheStrategy import com.keylesspalace.tusky.R import com.keylesspalace.tusky.components.compose.view.ProgressImageView class MediaPreviewAdapter( context: Context, private val onAddCaption: (ComposeViewModel.QueuedMedia) -> Unit, private val onAddFocus: (ComposeViewModel.QueuedMedia) -> Unit, private val onEditImage: (ComposeViewModel.QueuedMedia) -> Unit, private val onRemove: (ComposeViewModel.QueuedMedia) -> Unit ) : ListAdapter( object : DiffUtil.ItemCallback() { override fun areItemsTheSame( oldItem: ComposeViewModel.QueuedMedia, newItem: ComposeViewModel.QueuedMedia ) = oldItem.localId == newItem.localId override fun areContentsTheSame( oldItem: ComposeViewModel.QueuedMedia, newItem: ComposeViewModel.QueuedMedia ) = oldItem == newItem } ) { private fun onMediaClick(item: ComposeViewModel.QueuedMedia, view: View) { val popup = PopupMenu(view.context, view) val addCaptionId = 1 val addFocusId = 2 val editImageId = 3 val removeId = 4 popup.menu.add(0, addCaptionId, 0, R.string.action_set_caption) if (item.type == ComposeViewModel.QueuedMedia.Type.IMAGE) { popup.menu.add(0, addFocusId, 0, R.string.action_set_focus) if (item.state != ComposeViewModel.QueuedMedia.State.PUBLISHED) { // Already-published items can't be edited popup.menu.add(0, editImageId, 0, R.string.action_edit_image) } } popup.menu.add(0, removeId, 0, R.string.action_remove) popup.setOnMenuItemClickListener { menuItem -> when (menuItem.itemId) { addCaptionId -> onAddCaption(item) addFocusId -> onAddFocus(item) editImageId -> onEditImage(item) removeId -> onRemove(item) } true } popup.show() } private val thumbnailViewSize = context.resources.getDimensionPixelSize(R.dimen.compose_media_preview_size) override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PreviewViewHolder { return PreviewViewHolder(ProgressImageView(parent.context)) } override fun onBindViewHolder(holder: PreviewViewHolder, position: Int) { val item = getItem(position) holder.progressImageView.setChecked(!item.description.isNullOrEmpty()) holder.progressImageView.setProgress(item.uploadPercent) if (item.type == ComposeViewModel.QueuedMedia.Type.AUDIO) { // TODO: Fancy waveform display? holder.progressImageView.setImageResource(R.drawable.audio_file_preview) } else { val imageView = holder.progressImageView val focus = item.focus if (focus != null) { imageView.setFocalPoint(focus) } else { imageView.removeFocalPoint() // Probably unnecessary since we have no UI for removal once added. } var glide = Glide.with(holder.itemView.context) .load(item.uri) .diskCacheStrategy(DiskCacheStrategy.NONE) .dontAnimate() .centerInside() if (focus != null) { glide = glide.addListener(imageView) } glide.into(imageView) } holder.progressImageView.setOnClickListener { onMediaClick(item, holder.progressImageView) } } inner class PreviewViewHolder(val progressImageView: ProgressImageView) : RecyclerView.ViewHolder(progressImageView) { init { val layoutParams = ConstraintLayout.LayoutParams(thumbnailViewSize, thumbnailViewSize) val margin = itemView.context.resources .getDimensionPixelSize(R.dimen.compose_media_preview_margin) val marginBottom = itemView.context.resources .getDimensionPixelSize(R.dimen.compose_media_preview_margin_bottom) layoutParams.setMargins(margin, 0, margin, marginBottom) progressImageView.layoutParams = layoutParams progressImageView.scaleType = ImageView.ScaleType.CENTER_CROP } } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/compose/MediaUploader.kt ================================================ /* 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.components.compose import android.content.ContentResolver import android.content.Context import android.media.MediaMetadataRetriever import android.media.MediaMetadataRetriever.METADATA_KEY_MIMETYPE import android.net.Uri import android.os.Environment import android.util.Log import android.webkit.MimeTypeMap import androidx.core.content.FileProvider import androidx.core.net.toUri import com.keylesspalace.tusky.BuildConfig import com.keylesspalace.tusky.R import com.keylesspalace.tusky.components.compose.ComposeViewModel.QueuedMedia import com.keylesspalace.tusky.components.instanceinfo.InstanceInfo import com.keylesspalace.tusky.network.MediaUploadApi 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 dagger.hilt.android.qualifiers.ApplicationContext import java.io.File import java.io.IOException import javax.inject.Inject import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.cancel import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest 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 sealed interface 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( val flow: Flow, val scope: CoroutineScope ) fun createNewImageFile(context: Context, suffix: String = ".jpg"): File { // Create an image file name val randomId = randomAlphanumericString(12) val imageFileName = "Tusky_${randomId}_" val storageDir = context.getExternalFilesDir(Environment.DIRECTORY_PICTURES) return File.createTempFile(imageFileName, suffix, storageDir) } data class PreparedMedia(val type: QueuedMedia.Type, val uri: Uri, val size: Long) class FileSizeException(val allowedSizeInBytes: Int) : Exception() class MediaTypeException : Exception() class CouldNotOpenFileException : Exception() class UploadServerError(val errorMessage: String) : Exception() class MediaUploader @Inject constructor( @ApplicationContext private val context: Context, private val mediaUploadApi: MediaUploadApi ) { private companion object { private const val TAG = "MediaUploader" private val uploads = mutableMapOf() private var mostRecentId: Int = 0 } fun getNewLocalMediaId(): Int { return mostRecentId++ } suspend fun getMediaUploadState(localId: Int): FinalUploadEvent { return uploads[localId]?.flow ?.filterIsInstance() ?.first() ?: UploadEvent.ErrorEvent(IllegalStateException("media upload with id $localId not found")) } /** * Uploads media. * @param media the media to upload * @param instanceInfo info about the current media to make sure the media gets resized correctly * @return A Flow emitting upload events. * The Flow is hot, in order to cancel upload or clear resources call [cancelUploadScope]. */ @OptIn(ExperimentalCoroutinesApi::class) fun uploadMedia(media: QueuedMedia, instanceInfo: InstanceInfo): Flow { val uploadScope = CoroutineScope(Dispatchers.IO) val uploadFlow = flow { if (shouldResizeMedia(media, instanceInfo)) { emit(downsize(media, instanceInfo)) } else { emit(media) } } .flatMapLatest { upload(it) } .catch { exception -> emit(UploadEvent.ErrorEvent(exception)) } .shareIn(uploadScope, SharingStarted.Lazily, 1) uploads[media.localId] = UploadData(uploadFlow, uploadScope) return uploadFlow } /** * Cancels the CoroutineScope of a media upload. * Call this when to abort the upload or to clean up resources after upload info is no longer needed */ fun cancelUploadScope(vararg localMediaIds: Int) { localMediaIds.forEach { localId -> uploads.remove(localId)?.scope?.cancel() } } fun prepareMedia(inUri: Uri, instanceInfo: InstanceInfo): Result = runCatching { var mediaSize = MEDIA_SIZE_UNKNOWN var uri = inUri val mimeType: String? try { when (inUri.scheme) { ContentResolver.SCHEME_CONTENT -> { mimeType = contentResolver.getType(uri) val suffix = "." + MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType ?: "tmp") 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) 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 -> { val path = uri.path if (path == null) { Log.w(TAG, "empty uri path $uri") throw CouldNotOpenFileException() } val inputFile = File(path) val suffix = inputFile.name.substringAfterLast('.', "tmp") mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(suffix) val file = File.createTempFile("randomTemp1", ".$suffix", context.cacheDir) 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") throw CouldNotOpenFileException() } } } catch (e: IOException) { Log.w(TAG, e) throw CouldNotOpenFileException() } if (mediaSize == MEDIA_SIZE_UNKNOWN) { Log.w(TAG, "Could not determine file size of upload") throw MediaTypeException() } if (mimeType != null) { when (mimeType.substring(0, mimeType.indexOf('/'))) { "video" -> { if (mediaSize > instanceInfo.videoSizeLimit) { throw FileSizeException(instanceInfo.videoSizeLimit) } PreparedMedia(QueuedMedia.Type.VIDEO, uri, mediaSize) } "image" -> { PreparedMedia(QueuedMedia.Type.IMAGE, uri, mediaSize) } "audio" -> { if (mediaSize > instanceInfo.videoSizeLimit) { throw FileSizeException(instanceInfo.videoSizeLimit) } PreparedMedia(QueuedMedia.Type.AUDIO, uri, mediaSize) } else -> { throw MediaTypeException() } } } else { Log.w(TAG, "Could not determine mime type of upload") throw MediaTypeException() } } private val contentResolver = context.contentResolver private fun upload(media: QueuedMedia): Flow { return callbackFlow { var mimeType = contentResolver.getType(media.uri) // Android's MIME type suggestions from file extensions is broken for at least // .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) ) ) { val retriever = MediaMetadataRetriever() retriever.setDataSource(context, media.uri) mimeType = retriever.extractMetadata(METADATA_KEY_MIMETYPE) } val map = MimeTypeMap.getSingleton() val fileExtension = map.getExtensionFromMimeType(mimeType) val filename = "${context.getString(R.string.app_name)}_${System.currentTimeMillis()}_${randomAlphanumericString(10)}.$fileExtension" if (mimeType == null) mimeType = "multipart/form-data" var lastProgress = -1 val fileBody = media.uri.asRequestBody( contentResolver, requireNotNull(mimeType.toMediaTypeOrNull()) { "Invalid Content Type" }, media.mediaSize ) { percentage -> if (percentage != lastProgress) { trySend(UploadEvent.ProgressEvent(percentage)) } lastProgress = percentage } val body = MultipartBody.Part.createFormData("file", filename, fileBody) val description = if (media.description != null) { MultipartBody.Part.createFormData("description", media.description) } else { null } val focus = if (media.focus != null) { MultipartBody.Part.createFormData("focus", "${media.focus.x},${media.focus.y}") } else { null } val uploadResponse = mediaUploadApi.uploadMedia(body, description, focus) val responseBody = uploadResponse.body() if (uploadResponse.isSuccessful && responseBody != null) { send(UploadEvent.FinishedEvent(responseBody.id, uploadResponse.code() == 200)) } else { val error = HttpException(uploadResponse) val errorMessage = error.getServerErrorMessage() if (errorMessage == null) { throw error } else { throw UploadServerError(errorMessage) } } awaitClose() } } private fun downsize(media: QueuedMedia, instanceInfo: InstanceInfo): QueuedMedia { val file = createNewImageFile(context) downsizeImage(media.uri, instanceInfo.imageSizeLimit, contentResolver, file) return media.copy(uri = file.toUri(), mediaSize = file.length()) } private fun shouldResizeMedia(media: QueuedMedia, instanceInfo: InstanceInfo): Boolean { return media.type == QueuedMedia.Type.IMAGE && (media.mediaSize > instanceInfo.imageSizeLimit || getImageSquarePixels(context.contentResolver, media.uri) > instanceInfo.imageMatrixLimit) } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/AddPollDialog.kt ================================================ /* 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 . */ @file:JvmName("AddPollDialog") package com.keylesspalace.tusky.components.compose.dialog import android.content.Context import android.view.LayoutInflater import android.view.WindowManager import androidx.appcompat.app.AlertDialog import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.textfield.TextInputEditText import com.keylesspalace.tusky.R import com.keylesspalace.tusky.databinding.DialogAddPollBinding import com.keylesspalace.tusky.entity.NewPoll fun showAddPollDialog( context: Context, poll: NewPoll?, maxOptionCount: Int, maxOptionLength: Int, minDuration: Int, maxDuration: Int, onUpdatePoll: (NewPoll) -> Unit ) { val binding = DialogAddPollBinding.inflate(LayoutInflater.from(context)) val inset = context.resources.getDimensionPixelSize(R.dimen.dialog_inset) val dialog = MaterialAlertDialogBuilder(context) .setIcon(R.drawable.ic_insert_chart_24dp_filled) .setTitle(R.string.create_poll_title) .setView(binding.root) .setBackgroundInsetTop(inset) .setBackgroundInsetEnd(inset) .setBackgroundInsetBottom(inset) .setBackgroundInsetStart(inset) .setNegativeButton(android.R.string.cancel, null) .setPositiveButton(android.R.string.ok, null) .create() val adapter = AddPollOptionsAdapter( options = poll?.options?.toMutableList() ?: mutableListOf("", ""), maxOptionLength = maxOptionLength, onOptionRemoved = { valid -> binding.addChoiceButton.isEnabled = true dialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = valid }, onOptionChanged = { valid -> dialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = valid } ) 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 } binding.pollDurationDropDown.setSimpleItems(durationLabels.toTypedArray()) durations = durations.filter { it in minDuration..maxDuration } binding.addChoiceButton.setOnClickListener { if (adapter.itemCount < maxOptionCount) { adapter.addChoice() } if (adapter.itemCount >= maxOptionCount) { it.isEnabled = false } } val secondsInADay = 60 * 60 * 24 val desiredDuration = poll?.expiresIn ?: secondsInADay var selectedDurationIndex = durations.indexOfLast { it <= desiredDuration } binding.pollDurationDropDown.setText(durationLabels[selectedDurationIndex], false) binding.pollDurationDropDown.setOnItemClickListener { _, _, position, _ -> selectedDurationIndex = position } binding.multipleChoicesCheckBox.isChecked = poll?.multiple == true dialog.setOnShowListener { val button = dialog.getButton(AlertDialog.BUTTON_POSITIVE) button.setOnClickListener { onUpdatePoll( NewPoll( options = adapter.pollOptions, expiresIn = durations[selectedDurationIndex], multiple = binding.multipleChoicesCheckBox.isChecked ) ) dialog.dismiss() } } dialog.show() // yes, SOFT_INPUT_ADJUST_RESIZE is deprecated, but without it the dropdown can get behind the keyboard dialog.window?.setSoftInputMode( WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE or WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE ) binding.pollChoices.post { val firstItemView = binding.pollChoices.layoutManager?.findViewByPosition(0) val editText = firstItemView?.findViewById(R.id.optionEditText) editText?.requestFocus() editText?.setSelection(editText.length()) } // 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 ) } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/AddPollOptionsAdapter.kt ================================================ /* 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.components.compose.dialog import android.text.InputFilter import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.core.widget.doOnTextChanged import androidx.recyclerview.widget.RecyclerView import com.keylesspalace.tusky.R import com.keylesspalace.tusky.databinding.ItemAddPollOptionBinding import com.keylesspalace.tusky.util.BindingHolder import com.keylesspalace.tusky.util.visible class AddPollOptionsAdapter( private var options: MutableList, private val maxOptionLength: Int, private val onOptionRemoved: (Boolean) -> Unit, private val onOptionChanged: (Boolean) -> Unit ) : RecyclerView.Adapter>() { val pollOptions: List get() = options.toList() fun addChoice() { options.add("") notifyItemInserted(options.size - 1) } 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)) binding.optionEditText.doOnTextChanged { s, _, _, _ -> val pos = holder.bindingAdapterPosition if (pos != RecyclerView.NO_POSITION) { options[pos] = s.toString() onOptionChanged(validateInput()) } } return holder } override fun getItemCount() = options.size override fun onBindViewHolder(holder: BindingHolder, position: Int) { holder.binding.optionEditText.setText(options[position]) holder.binding.optionTextInputLayout.hint = holder.binding.root.context.getString(R.string.poll_new_choice_hint, position + 1) holder.binding.deleteButton.visible(position > 1, View.INVISIBLE) holder.binding.deleteButton.setOnClickListener { holder.binding.optionEditText.clearFocus() options.removeAt(holder.bindingAdapterPosition) notifyItemRemoved(holder.bindingAdapterPosition) onOptionRemoved(validateInput()) } } private fun validateInput(): Boolean { return !(options.contains("") || options.distinct().size != options.size) } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/CaptionDialog.kt ================================================ /* 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.components.compose.dialog import android.app.Dialog import android.content.Context import android.graphics.drawable.Animatable import android.graphics.drawable.Drawable import android.net.Uri import android.os.Bundle import android.text.InputFilter import android.view.View import android.view.WindowManager import android.widget.LinearLayout import androidx.appcompat.app.AlertDialog import androidx.core.os.bundleOf import androidx.fragment.app.DialogFragment import com.bumptech.glide.Glide import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy import com.bumptech.glide.request.target.CustomTarget import com.bumptech.glide.request.transition.Transition import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.keylesspalace.tusky.R import com.keylesspalace.tusky.databinding.DialogImageDescriptionBinding import com.keylesspalace.tusky.util.getParcelableCompat import com.keylesspalace.tusky.util.hide // 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 binding: DialogImageDescriptionBinding private var animatable: Animatable? = null override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { val localId = arguments?.getInt(LOCAL_ID_ARG) ?: error("Missing localId") val inset = requireContext().resources.getDimensionPixelSize(R.dimen.dialog_inset) return MaterialAlertDialogBuilder(requireContext()) .setView(createView(savedInstanceState)) .setBackgroundInsetTop(inset) .setBackgroundInsetEnd(inset) .setBackgroundInsetBottom(inset) .setBackgroundInsetStart(inset) .setPositiveButton(android.R.string.ok) { _, _ -> listener.onUpdateDescription(localId, binding.imageDescriptionText.text.toString()) } .setNegativeButton(android.R.string.cancel, null) .create() } private fun createView(savedInstanceState: Bundle?): View { binding = DialogImageDescriptionBinding.inflate(layoutInflater) val imageView = binding.imageDescriptionView imageView.maxZoom = 6f val imageDescriptionText = binding.imageDescriptionText imageDescriptionText.post { imageDescriptionText.requestFocus() imageDescriptionText.setSelection(imageDescriptionText.length()) } binding.imageDescriptionText.hint = resources.getQuantityString( R.plurals.hint_describe_for_visually_impaired, MEDIA_DESCRIPTION_CHARACTER_LIMIT, MEDIA_DESCRIPTION_CHARACTER_LIMIT ) 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) } isCancelable = false dialog?.setCanceledOnTouchOutside(false) // Dialog is full screen anyway. But without this, taps in navbar while keyboard is up can dismiss the dialog. val previewUri = arguments?.getParcelableCompat(PREVIEW_URI_ARG) ?: 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) .downsample(DownsampleStrategy.CENTER_INSIDE) .into(object : CustomTarget(4096, 4096) { override fun onLoadCleared(placeholder: Drawable?) { imageView.setImageDrawable(placeholder) } override fun onResourceReady( resource: Drawable, transition: Transition? ) { if (resource is Animatable) { resource.callback = object : Drawable.Callback { override fun invalidateDrawable(who: Drawable) { imageView.invalidate() } override fun scheduleDrawable(who: Drawable, what: Runnable, `when`: Long) { imageView.postDelayed(what, `when`) } override fun unscheduleDrawable(who: Drawable, what: Runnable) { imageView.removeCallbacks(what) } } resource.start() animatable = resource } imageView.setImageDrawable(resource) } override fun onLoadFailed(errorDrawable: Drawable?) { super.onLoadFailed(errorDrawable) imageView.hide() } }) return binding.root } override fun onStart() { super.onStart() dialog?.apply { window?.setLayout( LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT ) window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE) } (dialog as AlertDialog?)?.getButton(AlertDialog.BUTTON_NEGATIVE)?.setOnClickListener { if (arguments?.getString(EXISTING_DESCRIPTION_ARG).orEmpty() != binding.imageDescriptionText.text.toString()) { MaterialAlertDialogBuilder(requireContext()) .setMessage(R.string.confirm_dismiss_caption) .setPositiveButton(R.string.yes) { _, _ -> dialog?.dismiss() } .setNegativeButton(R.string.no, null) .show() } else { dialog?.dismiss() } } } override fun onSaveInstanceState(outState: Bundle) { outState.putCharSequence(DESCRIPTION_KEY, binding.imageDescriptionText.text) super.onSaveInstanceState(outState) } override fun onAttach(context: Context) { super.onAttach(context) listener = context as? Listener ?: error("Activity is not ComposeCaptionDialog.Listener") } override fun onDestroyView() { super.onDestroyView() animatable?.stop() (animatable as? Drawable?)?.callback = null } interface Listener { fun onUpdateDescription(localId: Int, description: String) } 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 ) } private const val DESCRIPTION_KEY = "description" private const val EXISTING_DESCRIPTION_ARG = "existing_description" private const val PREVIEW_URI_ARG = "preview_uri" private const val LOCAL_ID_ARG = "local_id" } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/compose/dialog/FocusDialog.kt ================================================ /* 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.components.compose.dialog import android.content.DialogInterface import android.graphics.drawable.Drawable import android.net.Uri import android.view.WindowManager import android.widget.FrameLayout import androidx.appcompat.app.AppCompatActivity import androidx.lifecycle.LifecycleOwner 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.load.resource.bitmap.DownsampleStrategy import com.bumptech.glide.request.RequestListener import com.bumptech.glide.request.target.Target import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.keylesspalace.tusky.databinding.DialogFocusBinding import com.keylesspalace.tusky.entity.Attachment.Focus import kotlinx.coroutines.launch fun T.makeFocusDialog( existingFocus: Focus?, previewUri: Uri, onUpdateFocus: suspend (Focus) -> Unit ) where T : AppCompatActivity, T : LifecycleOwner { val focus = existingFocus ?: Focus(0.0f, 0.0f) // Default to center val dialogBinding = DialogFocusBinding.inflate(layoutInflater) dialogBinding.focusIndicator.setFocus(focus) Glide.with(this) .load(previewUri) .downsample(DownsampleStrategy.CENTER_INSIDE) .listener(object : RequestListener { override fun onLoadFailed( error: GlideException?, model: Any?, target: Target, isFirstResource: Boolean ): Boolean { return false } override fun onResourceReady( resource: Drawable, model: Any, target: Target?, dataSource: DataSource, isFirstResource: Boolean ): Boolean { val width = resource.intrinsicWidth val height = resource.intrinsicHeight val viewWidth = dialogBinding.imageView.width val viewHeight = dialogBinding.imageView.height val scaledHeight = (viewWidth.toFloat() / width.toFloat()) * height dialogBinding.focusIndicator.setImageSize(viewWidth, scaledHeight.toInt()) // We want the dialog to be a little taller than the image, so you can slide your thumb past the image border, // but if it's *too* much taller that looks weird. See if a threshold has been crossed: if (width > height) { val maxHeight = dialogBinding.focusIndicator.maxAttractiveHeight() if (viewHeight > maxHeight) { val verticalShrinkLayout = FrameLayout.LayoutParams(viewWidth, maxHeight) dialogBinding.imageView.layoutParams = verticalShrinkLayout dialogBinding.focusIndicator.layoutParams = verticalShrinkLayout } } return false // Pass through } }) .into(dialogBinding.imageView) val okListener = { dialog: DialogInterface, _: Int -> lifecycleScope.launch { onUpdateFocus(dialogBinding.focusIndicator.getFocus()) } dialog.dismiss() } val dialog = MaterialAlertDialogBuilder(this) .setView(dialogBinding.root) .setPositiveButton(android.R.string.ok, okListener) .setNegativeButton(android.R.string.cancel, null) .create() val window = dialog.window window?.setSoftInputMode( WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE ) dialog.show() } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/compose/view/ComposeOptionsView.kt ================================================ /* Copyright 2018 Conny Duck * * 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.compose.view import android.content.Context import android.util.AttributeSet 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 ) { var listener: ComposeOptionsListener? = null init { inflate(context, R.layout.view_compose_options, this) setOnCheckedChangeListener { _, checkedId -> val visibility = when (checkedId) { R.id.publicRadioButton -> Status.Visibility.PUBLIC R.id.unlistedRadioButton -> Status.Visibility.UNLISTED R.id.privateRadioButton -> Status.Visibility.PRIVATE R.id.directRadioButton -> Status.Visibility.DIRECT else -> Status.Visibility.PUBLIC } listener?.onVisibilityChanged(visibility) } } fun setStatusVisibility(visibility: Status.Visibility) { val selectedButton = when (visibility) { Status.Visibility.PUBLIC -> R.id.publicRadioButton Status.Visibility.UNLISTED -> R.id.unlistedRadioButton Status.Visibility.PRIVATE -> R.id.privateRadioButton Status.Visibility.DIRECT -> R.id.directRadioButton else -> R.id.directRadioButton } check(selectedButton) } } interface ComposeOptionsListener { fun onVisibilityChanged(visibility: Status.Visibility) } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/compose/view/ComposeScheduleView.kt ================================================ /* Copyright 2019 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.compose.view import android.annotation.SuppressLint import android.content.Context import android.util.AttributeSet import android.view.LayoutInflater import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.content.res.AppCompatResources import androidx.constraintlayout.widget.ConstraintLayout import com.google.android.material.datepicker.CalendarConstraints import com.google.android.material.datepicker.DateValidatorPointForward import com.google.android.material.datepicker.MaterialDatePicker import com.google.android.material.timepicker.MaterialTimePicker import com.google.android.material.timepicker.TimeFormat import com.keylesspalace.tusky.R import com.keylesspalace.tusky.databinding.ViewComposeScheduleBinding import java.text.ParseException import java.text.SimpleDateFormat import java.util.Calendar import java.util.Date import java.util.Locale import java.util.TimeZone class ComposeScheduleView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 ) : ConstraintLayout(context, attrs, defStyleAttr) { interface OnTimeSetListener { fun onTimeSet(time: String?) } private var binding = ViewComposeScheduleBinding.inflate( (context.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater), this ) private var listener: OnTimeSetListener? = null private var dateFormat = SimpleDateFormat.getDateInstance() private var timeFormat = SimpleDateFormat.getTimeInstance() private var iso8601 = SimpleDateFormat( "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.getDefault() ).apply { timeZone = TimeZone.getTimeZone("UTC") } /** The date/time the user has chosen to schedule the status, in UTC */ private var scheduleDateTimeUtc: Calendar? = null init { binding.scheduledDateTime.setOnClickListener { openPickDateDialog() } binding.invalidScheduleWarning.setText(R.string.warning_scheduling_interval) updateScheduleUi() setEditIcons() } fun setListener(listener: OnTimeSetListener?) { this.listener = listener } private fun updateScheduleUi() { if (scheduleDateTimeUtc == null) { binding.scheduledDateTime.text = "" binding.invalidScheduleWarning.visibility = GONE return } val scheduled = scheduleDateTimeUtc!!.time @SuppressLint("SetTextI18n") binding.scheduledDateTime.text = "${dateFormat.format(scheduled)} ${timeFormat.format(scheduled)}" verifyScheduledTime(scheduled) } private fun setEditIcons() { val icon = AppCompatResources.getDrawable(context, R.drawable.ic_edit_24dp_filled) ?: return val size = binding.scheduledDateTime.lineHeight icon.setBounds(0, 0, size, size) binding.scheduledDateTime.setCompoundDrawablesRelative(null, null, icon, null) } fun setResetOnClickListener(listener: OnClickListener?) { binding.resetScheduleButton.setOnClickListener(listener) } fun resetSchedule() { scheduleDateTimeUtc = null updateScheduleUi() } fun openPickDateDialog() { // 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(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(scheduleDateTimeUtc!!.timeInMillis + tzOffset) .setCalendarConstraints(calendarConstraints) .build() picker.addOnPositiveButtonClickListener { selection: Long -> onDateSet(selection) } picker.show((context as AppCompatActivity).supportFragmentManager, "date_picker") } private fun getTimeFormat(context: Context): Int { return if (android.text.format.DateFormat.is24HourFormat(context)) { TimeFormat.CLOCK_24H } else { TimeFormat.CLOCK_12H } } private fun openPickTimeDialog() { val pickerBuilder = MaterialTimePicker.Builder() 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() picker.addOnPositiveButtonClickListener { onTimeSet(picker.hour, picker.minute) } picker.show((context as AppCompatActivity).supportFragmentManager, "time_picker") } fun getDateTime(scheduledAt: String?): Date? { scheduledAt?.let { try { return iso8601.parse(it) } catch (_: ParseException) { } } return null } fun setDateTime(scheduledAt: String?) { val date = getDateTime(scheduledAt) ?: return initializeSuggestedTime() scheduleDateTimeUtc!!.time = date updateScheduleUi() } fun verifyScheduledTime(scheduledTime: Date?): Boolean { val valid: Boolean = if (scheduledTime != null) { val minimumScheduledTime = calendar() minimumScheduledTime.add( Calendar.SECOND, MINIMUM_SCHEDULED_SECONDS ) scheduledTime.after(minimumScheduledTime.time) } else { true } binding.invalidScheduleWarning.visibility = if (valid) GONE else VISIBLE return valid } private fun onDateSet(selection: Long) { initializeSuggestedTime() val newDate = calendar() // working around bug in DatePicker where date is UTC #1720 // see https://github.com/material-components/material-components-android/issues/882 newDate.timeZone = TimeZone.getTimeZone("UTC") newDate.timeInMillis = selection scheduleDateTimeUtc!![newDate[Calendar.YEAR], newDate[Calendar.MONTH]] = newDate[Calendar.DATE] openPickTimeDialog() } private fun onTimeSet(hourOfDay: Int, minute: Int) { initializeSuggestedTime() scheduleDateTimeUtc?.set(Calendar.HOUR_OF_DAY, hourOfDay) scheduleDateTimeUtc?.set(Calendar.MINUTE, minute) updateScheduleUi() listener?.onTimeSet(time) } val time: String? get() = scheduleDateTimeUtc?.time?.let { iso8601.format(it) } private fun initializeSuggestedTime() { if (scheduleDateTimeUtc == null) { scheduleDateTimeUtc = calendar().apply { add(Calendar.MINUTE, 15) } } } companion object { // Minimum is 5 minutes, pad 30 seconds for posting private const val MINIMUM_SCHEDULED_SECONDS = 330 fun calendar(): Calendar = Calendar.getInstance(TimeZone.getDefault()) } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/compose/view/EditTextTyped.kt ================================================ /* Copyright 2018 Conny Duck * * 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.compose.view import android.content.Context import android.text.InputType import android.text.method.KeyListener import android.util.AttributeSet import android.view.inputmethod.EditorInfo import android.view.inputmethod.InputConnection import androidx.appcompat.widget.AppCompatMultiAutoCompleteTextView import androidx.core.view.OnReceiveContentListener import androidx.core.view.ViewCompat import androidx.core.view.inputmethod.EditorInfoCompat import androidx.core.view.inputmethod.InputConnectionCompat import androidx.emoji2.viewsintegration.EmojiEditTextHelper class EditTextTyped @JvmOverloads constructor( context: Context, attributeSet: AttributeSet? = null ) : AppCompatMultiAutoCompleteTextView(context, attributeSet) { private val emojiEditTextHelper: EmojiEditTextHelper = EmojiEditTextHelper(this) init { // fix a bug with autocomplete and some keyboards val newInputType = inputType and (inputType xor InputType.TYPE_TEXT_FLAG_AUTO_COMPLETE) inputType = newInputType super.setKeyListener(emojiEditTextHelper.getKeyListener(keyListener)) } override fun setKeyListener(input: KeyListener?) { if (input != null) { super.setKeyListener(emojiEditTextHelper.getKeyListener(input)) } else { super.setKeyListener(input) } } fun setOnReceiveContentListener(listener: OnReceiveContentListener) { ViewCompat.setOnReceiveContentListener(this, arrayOf("image/*"), listener) } override fun onCreateInputConnection(editorInfo: EditorInfo): InputConnection { val connection = super.onCreateInputConnection(editorInfo) EditorInfoCompat.setContentMimeTypes(editorInfo, arrayOf("image/*")) return emojiEditTextHelper.onCreateInputConnection( InputConnectionCompat.createWrapper(this, connection, editorInfo), editorInfo )!! } /** * Override pasting to ensure that formatted content is always pasted as * plain text. */ override fun onTextContextMenuItem(id: Int): Boolean { if (id == android.R.id.paste) { return super.onTextContextMenuItem(android.R.id.pasteAsPlainText) } return super.onTextContextMenuItem(id) } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/compose/view/FocusIndicatorView.kt ================================================ package com.keylesspalace.tusky.components.compose.view import android.annotation.SuppressLint import android.content.Context import android.graphics.Canvas import android.graphics.Color import android.graphics.Paint import android.graphics.Path import android.graphics.Point import android.util.AttributeSet import android.view.MotionEvent import android.view.View import com.keylesspalace.tusky.entity.Attachment import kotlin.math.ceil import kotlin.math.max import kotlin.math.min class FocusIndicatorView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 ) : View(context, attrs, defStyleAttr) { private var focus: Attachment.Focus? = null private var imageSize: Point? = null private var circleRadius: Float? = null fun setImageSize(width: Int, height: Int) { this.imageSize = Point(width, height) if (focus != null) { invalidate() } } fun setFocus(focus: Attachment.Focus) { this.focus = focus if (imageSize != null) { invalidate() } } // Assumes setFocus called first fun getFocus(): Attachment.Focus { return focus!! } // This needs to be consistent every time it is consulted over the lifetime of the object, // so base it on the view width/height whenever the first access occurs. private fun getCircleRadius(): Float { val circleRadius = this.circleRadius if (circleRadius != null) { return circleRadius } val newCircleRadius = min(this.width, this.height).toFloat() / 4.0f this.circleRadius = newCircleRadius return newCircleRadius } // Remember focus uses -1..1 y-down coordinates (so focus value should be negated for y) private fun axisToFocus(value: Float, innerLimit: Int, outerLimit: Int): Float { val offset = (outerLimit - innerLimit) / 2 // Assume image is centered in widget frame val result = (value - offset) / innerLimit.toFloat() * 2.0f - 1.0f // To range -1..1 return min(1.0f, max(-1.0f, result)) // Clamp } private fun axisFromFocus(value: Float, innerLimit: Int, outerLimit: Int): Float { val offset = (outerLimit - innerLimit) / 2 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. override fun onTouchEvent(event: MotionEvent): Boolean { if (event.actionMasked == MotionEvent.ACTION_CANCEL) { return false } val imageSize = this.imageSize ?: return false // Convert touch xy to point inside image focus = Attachment.Focus(axisToFocus(event.x, imageSize.x, this.width), -axisToFocus(event.y, imageSize.y, this.height)) invalidate() return true } private val transparentDarkGray = 0x40000000 private val strokeWidth = 4.0f * this.resources.displayMetrics.density private val curtainPaint = Paint(Paint.ANTI_ALIAS_FLAG) private val strokePaint = Paint(Paint.ANTI_ALIAS_FLAG) private val curtainPath = Path() init { curtainPaint.color = transparentDarkGray curtainPaint.style = Paint.Style.FILL strokePaint.style = Paint.Style.STROKE strokePaint.strokeWidth = strokeWidth strokePaint.color = Color.WHITE } override fun onDraw(canvas: Canvas) { super.onDraw(canvas) val imageSize = this.imageSize val focus = this.focus if (imageSize != null && focus?.x != null && focus.y != null) { val x = axisFromFocus(focus.x, imageSize.x, this.width) val y = axisFromFocus(-focus.y, imageSize.y, this.height) val circleRadius = getCircleRadius() 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.addCircle(x, y, circleRadius, Path.Direction.CCW) canvas.drawPath(curtainPath, curtainPaint) canvas.drawCircle(x, y, circleRadius, strokePaint) // Draw white circle canvas.drawCircle(x, y, strokeWidth / 2.0f, strokePaint) // Draw white dot } } // Give a "safe" height based on currently set image size. Assume imageSize is set and height>width already checked fun maxAttractiveHeight(): Int { val height = this.imageSize!!.y val circleRadius = getCircleRadius() // Give us enough space for the image, plus on each side half a focus indicator circle, plus a strokeWidth return ceil(height.toFloat() + circleRadius * 2.0f + strokeWidth).toInt() } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/compose/view/PollPreviewView.kt ================================================ /* 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.components.compose.view import android.content.Context import android.content.res.ColorStateList import android.util.AttributeSet import android.view.LayoutInflater import com.google.android.material.R as materialR import com.google.android.material.card.MaterialCardView import com.google.android.material.color.MaterialColors import com.keylesspalace.tusky.R import com.keylesspalace.tusky.adapter.PreviewPollOptionsAdapter import com.keylesspalace.tusky.databinding.ViewPollPreviewBinding import com.keylesspalace.tusky.entity.NewPoll class PollPreviewView @JvmOverloads constructor( context: Context?, attrs: AttributeSet? = null, defStyleAttr: Int = materialR.attr.materialCardViewOutlinedStyle ) : MaterialCardView(context, attrs, defStyleAttr) { private val adapter = PreviewPollOptionsAdapter() private val binding = ViewPollPreviewBinding.inflate(LayoutInflater.from(context), this) init { setStrokeColor(ColorStateList.valueOf(MaterialColors.getColor(this, materialR.attr.colorOutline))) strokeWidth elevation = 0f binding.pollPreviewOptions.adapter = adapter } fun setPoll(poll: NewPoll) { adapter.update(poll.options, poll.multiple) val pollDurationId = resources.getIntArray(R.array.poll_duration_values).indexOfLast { it <= poll.expiresIn } binding.pollDurationPreview.text = resources.getStringArray(R.array.poll_duration_names)[pollDurationId] } override fun setOnClickListener(l: OnClickListener?) { super.setOnClickListener(l) adapter.setOnClickListener(l) } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/compose/view/ProgressImageView.kt ================================================ /* 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.components.compose.view import android.content.Context import android.graphics.Canvas import android.graphics.Color import android.graphics.Paint import android.graphics.PorterDuff import android.graphics.PorterDuffXfermode import android.graphics.RectF import android.util.AttributeSet import androidx.appcompat.content.res.AppCompatResources import at.connyduck.sparkbutton.helpers.Utils import com.google.android.material.R as materialR import com.google.android.material.color.MaterialColors import com.keylesspalace.tusky.R import com.keylesspalace.tusky.view.MediaPreviewImageView class ProgressImageView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 ) : MediaPreviewImageView(context, attrs, defStyleAttr) { private var progress = -1 private val progressRect = RectF() private val biggerRect = RectF() private val circlePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = MaterialColors.getColor(this@ProgressImageView, materialR.attr.colorPrimary) strokeWidth = Utils.dpToPx(context, 4).toFloat() style = Paint.Style.STROKE } private val clearPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { xfermode = PorterDuffXfermode(PorterDuff.Mode.DST_OUT) } private val markBgPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.FILL color = MaterialColors.getColor(this@ProgressImageView, android.R.attr.colorBackground) } private val captionDrawable = AppCompatResources.getDrawable( context, R.drawable.ic_spellcheck_24dp )!! private val circleRadius = Utils.dpToPx(context, 14) private val circleMargin = Utils.dpToPx(context, 14) fun setProgress(progress: Int) { this.progress = progress if (progress != -1) { setColorFilter(Color.rgb(123, 123, 123), PorterDuff.Mode.MULTIPLY) } else { clearColorFilter() } invalidate() } fun setChecked(checked: Boolean) { val backgroundColor = if (checked) materialR.attr.colorPrimary else android.R.attr.colorBackground val foregroundColor = if (checked) materialR.attr.colorOnPrimary else android.R.attr.textColorTertiary markBgPaint.color = MaterialColors.getColor(this, backgroundColor) captionDrawable.setTint(MaterialColors.getColor(this, foregroundColor)) invalidate() } override fun onDraw(canvas: Canvas) { super.onDraw(canvas) val angle = progress / 100f * 360 - 90 val halfWidth = width / 2f val halfHeight = height / 2f progressRect[halfWidth * 0.75f, halfHeight * 0.75f, halfWidth * 1.25f] = halfHeight * 1.25f biggerRect.set(progressRect) val margin = 8 biggerRect[progressRect.left - margin, progressRect.top - margin, progressRect.right + margin] = progressRect.bottom + margin canvas.saveLayer(biggerRect, null) if (progress != -1) { canvas.drawOval(progressRect, circlePaint) canvas.drawArc(biggerRect, angle, 360 - angle - 90, true, clearPaint) } canvas.restore() val circleY = height - circleMargin - circleRadius / 2 val circleX = width - circleMargin - circleRadius / 2 canvas.drawCircle(circleX.toFloat(), circleY.toFloat(), circleRadius.toFloat(), markBgPaint) captionDrawable.setBounds( width - circleMargin - circleRadius, height - circleMargin - circleRadius, width - circleMargin, height - circleMargin ) captionDrawable.draw(canvas) } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/compose/view/TootButton.kt ================================================ /* Copyright 2018 Conny Duck * * 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.compose.view import android.content.Context import android.util.AttributeSet import androidx.appcompat.content.res.AppCompatResources import com.google.android.material.button.MaterialButton import com.keylesspalace.tusky.R import com.keylesspalace.tusky.entity.Status class TootButton @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 ) : MaterialButton(context, attrs, defStyleAttr) { private val smallStyle: Boolean = context.resources.getBoolean(R.bool.show_small_toot_button) init { if (smallStyle) { setIconResource(R.drawable.ic_send_24dp) iconPadding = 0 } else { setText(R.string.action_send) } val padding = resources.getDimensionPixelSize(R.dimen.toot_button_horizontal_padding) setPadding(padding, 0, padding, 0) } fun setStatusVisibility(visibility: Status.Visibility) { if (!smallStyle) { icon = when (visibility) { Status.Visibility.PUBLIC -> { setText(R.string.action_send_public) null } Status.Visibility.UNLISTED -> { setText(R.string.action_send) null } Status.Visibility.PRIVATE, Status.Visibility.DIRECT -> { setText(R.string.action_send) AppCompatResources.getDrawable(context, R.drawable.ic_lock_24dp) } else -> { null } } } } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationEntity.kt ================================================ /* 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.components.conversation import androidx.room.Embedded import androidx.room.Entity import androidx.room.TypeConverters import com.keylesspalace.tusky.db.Converters import com.keylesspalace.tusky.entity.Attachment import com.keylesspalace.tusky.entity.Conversation import com.keylesspalace.tusky.entity.Emoji import com.keylesspalace.tusky.entity.HashTag 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"]) @TypeConverters(Converters::class) data class ConversationEntity( val accountId: Long, val id: String, val order: Int, val accounts: List, val unread: Boolean, @Embedded(prefix = "s_") val lastStatus: ConversationStatusEntity ) { fun toViewData(): ConversationViewData { return ConversationViewData( id = id, order = order, accounts = accounts, unread = unread, lastStatus = lastStatus.toViewData() ) } } @JsonClass(generateAdapter = true) data class ConversationAccountEntity( val id: String, val localUsername: String, val username: String, val displayName: String, val avatar: String, val emojis: List ) { fun toAccount(): TimelineAccount { return TimelineAccount( id = id, localUsername = localUsername, username = username, displayName = displayName, note = "", url = "", avatar = avatar, emojis = emojis ) } } @TypeConverters(Converters::class) data class ConversationStatusEntity( val id: String, val url: String?, val inReplyToId: String?, val inReplyToAccountId: String?, val account: ConversationAccountEntity, val content: String, val createdAt: Date, val editedAt: Date?, val emojis: List, val favouritesCount: Int, val repliesCount: Int, val favourited: Boolean, val bookmarked: Boolean, val sensitive: Boolean, val spoilerText: String, val attachments: List, val mentions: List, val tags: List?, val showingHiddenContent: Boolean, val expanded: Boolean, val collapsed: Boolean, val muted: Boolean, val poll: Poll?, val language: String? ) { fun toViewData(): StatusViewData.Concrete { return StatusViewData.Concrete( status = Status( id = id, url = url, account = account.toAccount(), inReplyToId = inReplyToId, inReplyToAccountId = inReplyToAccountId, content = content, reblog = null, createdAt = createdAt, editedAt = editedAt, emojis = emojis, reblogsCount = 0, favouritesCount = favouritesCount, repliesCount = repliesCount, reblogged = false, favourited = favourited, bookmarked = bookmarked, sensitive = sensitive, spoilerText = spoilerText, visibility = Status.Visibility.DIRECT, attachments = attachments, mentions = mentions, tags = tags.orEmpty(), application = null, pinned = false, muted = muted, poll = poll, card = null, language = language, filtered = emptyList() ), isExpanded = expanded, isShowingContent = showingHiddenContent, isCollapsed = collapsed ) } } fun TimelineAccount.toEntity() = ConversationAccountEntity( id = id, localUsername = localUsername, username = username, displayName = name, avatar = avatar, emojis = emojis ) fun Status.toEntity(expanded: Boolean, contentShowing: Boolean, contentCollapsed: Boolean) = ConversationStatusEntity( id = id, url = url, inReplyToId = inReplyToId, inReplyToAccountId = inReplyToAccountId, account = account.toEntity(), content = content, createdAt = createdAt, editedAt = editedAt, emojis = emojis, favouritesCount = favouritesCount, repliesCount = repliesCount, favourited = favourited, bookmarked = bookmarked, sensitive = sensitive, spoilerText = spoilerText, attachments = attachments, mentions = mentions, tags = tags, showingHiddenContent = contentShowing, expanded = expanded, collapsed = contentCollapsed, muted = muted, poll = poll, language = language ) fun Conversation.toEntity( accountId: Long, order: Int, 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 ) ) ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationPagingAdapter.kt ================================================ /* 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.components.conversation 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.R import com.keylesspalace.tusky.adapter.PlaceholderViewHolder import com.keylesspalace.tusky.adapter.StatusBaseViewHolder import com.keylesspalace.tusky.databinding.ItemPlaceholderBinding import com.keylesspalace.tusky.interfaces.StatusActionListener import com.keylesspalace.tusky.util.StatusDisplayOptions class ConversationPagingAdapter( private var statusDisplayOptions: StatusDisplayOptions, private val listener: StatusActionListener ) : PagingDataAdapter(CONVERSATION_COMPARATOR) { var mediaPreviewEnabled: Boolean get() = statusDisplayOptions.mediaPreviewEnabled set(mediaPreviewEnabled) { statusDisplayOptions = statusDisplayOptions.copy( mediaPreviewEnabled = mediaPreviewEnabled ) } override fun getItemViewType(position: Int): Int { return if (getItem(position) == null) { VIEW_TYPE_PLACEHOLDER } else { VIEW_TYPE_CONVERSATION } } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { val layoutInflater = LayoutInflater.from(parent.context) return if (viewType == VIEW_TYPE_CONVERSATION) { ConversationViewHolder(layoutInflater.inflate(R.layout.item_conversation, parent, false), statusDisplayOptions, listener) } else { PlaceholderViewHolder( ItemPlaceholderBinding.inflate(layoutInflater, parent, false), mode = PlaceholderViewHolder.Mode.CONVERSATION ) } } override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { onBindViewHolder(holder, position, emptyList()) } override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int, payloads: List) { val conversationViewData = getItem(position) if (holder is ConversationViewHolder && conversationViewData != null) { holder.setupWithConversation(conversationViewData, payloads) } } companion object { private const val VIEW_TYPE_PLACEHOLDER = 0 private const val VIEW_TYPE_CONVERSATION = 1 private val CONVERSATION_COMPARATOR = object : DiffUtil.ItemCallback() { override fun areItemsTheSame( oldItem: ConversationViewData, newItem: ConversationViewData ): Boolean { return oldItem.id == newItem.id } 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? { return if (oldItem == newItem) { // If items are equal - update timestamp only StatusBaseViewHolder.Key.KEY_CREATED } else { // If items are different - update the whole view holder null } } } } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewData.kt ================================================ /* Copyright 2022 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.conversation import com.keylesspalace.tusky.entity.Poll import com.keylesspalace.tusky.viewdata.StatusViewData data class ConversationViewData( val id: String, val order: Int, val accounts: List, val unread: Boolean, val lastStatus: StatusViewData.Concrete ) { fun toEntity( accountId: Long, favourited: Boolean = lastStatus.status.favourited, bookmarked: Boolean = lastStatus.status.bookmarked, muted: Boolean = lastStatus.status.muted, poll: Poll? = lastStatus.status.poll, expanded: Boolean = lastStatus.isExpanded, collapsed: Boolean = lastStatus.isCollapsed, showingHiddenContent: Boolean = lastStatus.isShowingContent ): ConversationEntity { return ConversationEntity( accountId = accountId, id = id, order = order, accounts = accounts, unread = unread, lastStatus = lastStatus.toConversationStatusEntity( favourited = favourited, bookmarked = bookmarked, muted = muted, poll = poll, expanded = expanded, collapsed = collapsed, showingHiddenContent = showingHiddenContent ) ) } } fun StatusViewData.Concrete.toConversationStatusEntity( favourited: Boolean = status.favourited, bookmarked: Boolean = status.bookmarked, muted: Boolean = status.muted, poll: Poll? = status.poll, expanded: Boolean = isExpanded, collapsed: Boolean = isCollapsed, showingHiddenContent: Boolean = isShowingContent ): ConversationStatusEntity { return ConversationStatusEntity( id = id, url = status.url, inReplyToId = status.inReplyToId, inReplyToAccountId = status.inReplyToAccountId, account = status.account.toEntity(), content = status.content, createdAt = status.createdAt, editedAt = status.editedAt, emojis = status.emojis, favouritesCount = status.favouritesCount, repliesCount = status.repliesCount, favourited = favourited, bookmarked = bookmarked, sensitive = status.sensitive, spoilerText = status.spoilerText, attachments = status.attachments, mentions = status.mentions, tags = status.tags, showingHiddenContent = showingHiddenContent, expanded = expanded, collapsed = collapsed, muted = muted, poll = poll, language = status.language ) } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewHolder.java ================================================ /* 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.components.conversation; import android.content.Context; import android.text.InputFilter; import android.text.TextUtils; import android.view.View; import android.widget.Button; import android.widget.ImageView; import android.widget.TextView; import androidx.annotation.NonNull; import androidx.recyclerview.widget.RecyclerView; import com.keylesspalace.tusky.R; import com.keylesspalace.tusky.adapter.StatusBaseViewHolder; import com.keylesspalace.tusky.entity.Attachment; import com.keylesspalace.tusky.entity.Status; import com.keylesspalace.tusky.entity.TimelineAccount; import com.keylesspalace.tusky.interfaces.StatusActionListener; import com.keylesspalace.tusky.util.ImageLoadingHelper; import com.keylesspalace.tusky.util.SmartLengthInputFilter; import com.keylesspalace.tusky.util.StatusDisplayOptions; import com.keylesspalace.tusky.viewdata.StatusViewData; import java.util.List; public class ConversationViewHolder extends StatusBaseViewHolder { private static final InputFilter[] COLLAPSE_INPUT_FILTER = new InputFilter[]{SmartLengthInputFilter.INSTANCE}; private static final InputFilter[] NO_INPUT_FILTER = new InputFilter[0]; private final TextView conversationNameTextView; private final Button contentCollapseButton; private final ImageView[] avatars; private final StatusDisplayOptions statusDisplayOptions; private final StatusActionListener listener; ConversationViewHolder(View itemView, StatusDisplayOptions statusDisplayOptions, StatusActionListener listener) { super(itemView); conversationNameTextView = itemView.findViewById(R.id.conversation_name); contentCollapseButton = itemView.findViewById(R.id.button_toggle_content); avatars = new ImageView[]{ avatar, itemView.findViewById(R.id.status_avatar_1), itemView.findViewById(R.id.status_avatar_2) }; this.statusDisplayOptions = statusDisplayOptions; this.listener = listener; } void setupWithConversation( @NonNull ConversationViewData conversation, @NonNull List payloads ) { StatusViewData.Concrete statusViewData = conversation.getLastStatus(); Status status = statusViewData.getStatus(); if (payloads.isEmpty()) { TimelineAccount account = status.getAccount(); setupCollapsedState(statusViewData.isCollapsible(), statusViewData.isCollapsed(), statusViewData.isExpanded(), status.getSpoilerText(), listener); String displayName = account.getDisplayName(); if (displayName == null) { displayName = ""; } setDisplayName(displayName, account.getEmojis(), statusDisplayOptions); setUsername(account.getUsername()); setMetaData(statusViewData, statusDisplayOptions, listener); setFavourited(status.getFavourited()); setBookmarked(status.getBookmarked()); List attachments = status.getAttachments(); boolean sensitive = status.getSensitive(); if (attachments.isEmpty()) { mediaContainer.setVisibility(View.GONE); } else if (statusDisplayOptions.mediaPreviewEnabled() && hasPreviewableAttachment(attachments)) { mediaContainer.setVisibility(View.VISIBLE); setMediaPreviews(attachments, sensitive, listener, statusViewData.isShowingContent(), statusDisplayOptions.useBlurhash(), statusViewData.getFilter()); if (attachments.isEmpty()) { hideSensitiveMediaWarning(); } // Hide the unused label. for (TextView mediaLabel : mediaLabels) { mediaLabel.setVisibility(View.GONE); } } else { mediaContainer.setVisibility(View.VISIBLE); setMediaLabel(attachments, sensitive, listener, statusViewData.isShowingContent()); // Hide all unused views. mediaPreview.setVisibility(View.GONE); hideSensitiveMediaWarning(); } setupButtons(listener, account.getId(), statusViewData.getContent().toString(), statusDisplayOptions); setSpoilerAndContent(statusViewData, statusDisplayOptions, listener); setConversationName(conversation.getAccounts()); setAvatars(conversation.getAccounts()); } else { for (Object item : payloads) { if (Key.KEY_CREATED.equals(item)) { setMetaData(statusViewData, statusDisplayOptions, listener); } } } } private void setConversationName(List accounts) { Context context = conversationNameTextView.getContext(); String conversationName = ""; if (accounts.size() == 1) { conversationName = context.getString(R.string.conversation_1_recipients, accounts.get(0).getUsername()); } else if (accounts.size() == 2) { conversationName = context.getString(R.string.conversation_2_recipients, accounts.get(0).getUsername(), accounts.get(1).getUsername()); } else if (accounts.size() > 2) { conversationName = context.getString(R.string.conversation_more_recipients, accounts.get(0).getUsername(), accounts.get(1).getUsername(), accounts.size() - 2); } conversationNameTextView.setText(conversationName); } private void setAvatars(List accounts) { for (int i = 0; i < avatars.length; i++) { ImageView avatarView = avatars[i]; if (i < accounts.size()) { ImageLoadingHelper.loadAvatar(accounts.get(i).getAvatar(), avatarView, avatarRadius48dp, statusDisplayOptions.animateAvatars(), null); avatarView.setVisibility(View.VISIBLE); } else { avatarView.setVisibility(View.GONE); } } } private void setupCollapsedState(boolean collapsible, boolean collapsed, boolean expanded, String spoilerText, final StatusActionListener listener) { /* input filter for TextViews have to be set before text */ if (collapsible && (expanded || TextUtils.isEmpty(spoilerText))) { contentCollapseButton.setOnClickListener(view -> { int position = getBindingAdapterPosition(); if (position != RecyclerView.NO_POSITION) listener.onContentCollapsedChange(!collapsed, position); }); contentCollapseButton.setVisibility(View.VISIBLE); if (collapsed) { contentCollapseButton.setText(R.string.post_content_warning_show_more); content.setFilters(COLLAPSE_INPUT_FILTER); } else { contentCollapseButton.setText(R.string.post_content_warning_show_less); content.setFilters(NO_INPUT_FILTER); } } else { contentCollapseButton.setVisibility(View.GONE); content.setFilters(NO_INPUT_FILTER); } } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt ================================================ /* 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.components.conversation import android.content.SharedPreferences import android.os.Bundle import android.view.Menu import android.view.MenuInflater import android.view.MenuItem import android.view.View import androidx.appcompat.widget.PopupMenu import androidx.core.view.MenuProvider import androidx.fragment.app.viewModels import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.paging.LoadState import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.SimpleItemAnimator import at.connyduck.sparkbutton.SparkButton import at.connyduck.sparkbutton.helpers.Utils import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.keylesspalace.tusky.R import com.keylesspalace.tusky.StatusListActivity import com.keylesspalace.tusky.adapter.LoadStateFooterAdapter 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 import com.keylesspalace.tusky.databinding.FragmentTimelineBinding import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.fragment.SFragment 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.StatusDisplayOptions import com.keylesspalace.tusky.util.ensureBottomPadding import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.isAnyLoading import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.updateRelativeTimePeriodically import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.view.ConfirmationBottomSheet.Companion.confirmFavourite import com.keylesspalace.tusky.viewdata.AttachmentViewData import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch @AndroidEntryPoint class ConversationsFragment : SFragment(R.layout.fragment_timeline), StatusActionListener, ReselectableFragment, MenuProvider { @Inject lateinit var eventHub: EventHub @Inject lateinit var preferences: SharedPreferences private val viewModel: ConversationsViewModel by viewModels() private val binding by viewBinding(FragmentTimelineBinding::bind) private var adapter: ConversationPagingAdapter? = null private var buttonToAnimate: SparkButton? = null override fun onViewCreated(view: View, savedInstanceState: Bundle?) { requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED) val statusDisplayOptions = StatusDisplayOptions( animateAvatars = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false), mediaPreviewEnabled = accountManager.activeAccount?.mediaPreviewEnabled != 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 = CardViewMode.NONE, 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), showSensitiveMedia = accountManager.activeAccount!!.alwaysShowSensitiveMedia, openSpoiler = accountManager.activeAccount!!.alwaysOpenSpoiler ) val adapter = ConversationPagingAdapter(statusDisplayOptions, this) this.adapter = adapter setupRecyclerView(adapter) binding.swipeRefreshLayout.setOnRefreshListener { refreshContent() } adapter.addLoadStateListener { loadState -> if (loadState.refresh != LoadState.Loading && loadState.source.refresh != LoadState.Loading) { binding.swipeRefreshLayout.isRefreshing = false } binding.statusView.hide() binding.progressBar.hide() if (loadState.isAnyLoading()) { viewLifecycleOwner.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.showHelp(R.string.help_empty_conversations) } } is LoadState.Error -> { binding.statusView.show() binding.statusView.setup( (loadState.refresh as LoadState.Error).error ) { refreshContent() } } is LoadState.Loading -> { binding.progressBar.show() } } } } adapter.registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() { override fun onItemRangeInserted(positionStart: Int, itemCount: Int) { val firstPos = (binding.recyclerView.layoutManager as LinearLayoutManager).findFirstCompletelyVisibleItemPosition() if (firstPos == 0 && positionStart == 0 && adapter.itemCount != itemCount) { binding.recyclerView.post { if (getView() != null) { binding.recyclerView.scrollBy(0, Utils.dpToPx(requireContext(), -30)) } } } } }) // Workaround RecyclerView jumping to bottom on first load because of the load state footer // https://issuetracker.google.com/issues/184874613#comment7 var firstLoad = true adapter.addOnPagesUpdatedListener { if (adapter.itemCount > 0 && firstLoad) { (binding.recyclerView.layoutManager as LinearLayoutManager).scrollToPositionWithOffset(0, 0) firstLoad = false } } viewLifecycleOwner.lifecycleScope.launch { viewModel.conversationFlow.collectLatest { pagingData -> adapter.submitData(pagingData) } } updateRelativeTimePeriodically(preferences, adapter) viewLifecycleOwner.lifecycleScope.launch { eventHub.events.collect { event -> if (event is PreferenceChangedEvent) { onPreferenceChanged(adapter, event.preferenceKey) } } } } override fun onDestroyView() { // Clear the adapter to prevent leaking the View adapter = null buttonToAnimate = null super.onDestroyView() } override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { menuInflater.inflate(R.menu.fragment_conversations, menu) } override fun onMenuItemSelected(menuItem: MenuItem): Boolean { return when (menuItem.itemId) { R.id.action_refresh -> { binding.swipeRefreshLayout.isRefreshing = true refreshContent() true } else -> false } } private fun setupRecyclerView(adapter: ConversationPagingAdapter) { binding.recyclerView.ensureBottomPadding(fab = true) binding.recyclerView.setHasFixedSize(true) binding.recyclerView.layoutManager = LinearLayoutManager(context) binding.recyclerView.addItemDecoration( DividerItemDecoration(context, DividerItemDecoration.VERTICAL) ) (binding.recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false binding.recyclerView.adapter = adapter.withLoadStateFooter(LoadStateFooterAdapter(adapter::retry)) } private fun refreshContent() { adapter?.refresh() } override fun onReblog(reblog: Boolean, position: Int, visibility: Status.Visibility?, button: SparkButton?) { // its impossible to reblog private messages } override fun onFavourite(favourite: Boolean, position: Int, button: SparkButton?) { adapter?.peek(position)?.let { conversation -> buttonToAnimate = button if (favourite) { confirmFavourite(preferences) { viewModel.favourite(true, conversation) buttonToAnimate?.playAnimation() buttonToAnimate?.isChecked = true } } else { viewModel.favourite(false, conversation) buttonToAnimate?.isChecked = false } } } override fun onBookmark(bookmark: Boolean, position: Int) { adapter?.peek(position)?.let { conversation -> viewModel.bookmark(bookmark, conversation) } } 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) { popup.menu.removeItem(R.id.status_mute_conversation) } else { popup.menu.removeItem(R.id.status_unmute_conversation) } popup.setOnMenuItemClickListener { item -> when (item.itemId) { R.id.status_mute_conversation -> viewModel.muteConversation(conversation) R.id.status_unmute_conversation -> viewModel.muteConversation(conversation) R.id.conversation_delete -> deleteConversation(conversation) } true } popup.show() } } override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) { adapter?.peek(position)?.let { conversation -> viewMedia( attachmentIndex, AttachmentViewData.list(conversation.lastStatus), view ) } } override fun onViewThread(position: Int) { adapter?.peek(position)?.let { conversation -> viewThread(conversation.lastStatus.id, conversation.lastStatus.status.url) } } override fun onOpenReblog(position: Int) { // there are no reblogs in conversations } override fun onExpandedChange(expanded: Boolean, position: Int) { adapter?.peek(position)?.let { conversation -> viewModel.expandHiddenStatus(expanded, conversation) } } override fun onContentHiddenChange(isShowing: Boolean, position: Int) { adapter?.peek(position)?.let { conversation -> viewModel.showContent(isShowing, conversation) } } override fun onLoadMore(position: Int) { // not using the old way of pagination } override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) { adapter?.peek(position)?.let { conversation -> viewModel.collapseLongStatus(isCollapsed, conversation) } } override fun onViewAccount(id: String) { val intent = AccountActivity.getIntent(requireContext(), id) startActivity(intent) } override fun onViewTag(tag: String) { val intent = StatusListActivity.newHashtagIntent(requireContext(), tag) startActivity(intent) } override fun removeItem(position: Int) { // not needed } override fun onReply(position: Int) { adapter?.peek(position)?.let { conversation -> reply(conversation.lastStatus.status) } } override fun onVoteInPoll(position: Int, choices: List) { adapter?.peek(position)?.let { conversation -> viewModel.voteInPoll(choices, conversation) } } override fun onShowPollResults(position: Int) { adapter?.peek(position)?.let { conversation -> viewModel.showPollResults(conversation) } } override fun clearWarningAction(position: Int) { } override fun onReselect() { if (view != null) { binding.recyclerView.layoutManager?.scrollToPosition(0) binding.recyclerView.stopScroll() } } override fun onUntranslate(position: Int) { // not needed } private fun deleteConversation(conversation: ConversationViewData) { MaterialAlertDialogBuilder(requireContext()) .setMessage(R.string.dialog_delete_conversation_warning) .setNegativeButton(android.R.string.cancel, null) .setPositiveButton(android.R.string.ok) { _, _ -> viewModel.remove(conversation) } .show() } private fun onPreferenceChanged(adapter: ConversationPagingAdapter, key: String) { when (key) { PrefKeys.MEDIA_PREVIEW_ENABLED -> { val enabled = accountManager.activeAccount!!.mediaPreviewEnabled val oldMediaPreviewEnabled = adapter.mediaPreviewEnabled if (enabled != oldMediaPreviewEnabled) { adapter.mediaPreviewEnabled = enabled adapter.notifyItemRangeChanged(0, adapter.itemCount) } } } } companion object { fun newInstance() = ConversationsFragment() } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsRemoteMediator.kt ================================================ package com.keylesspalace.tusky.components.conversation import androidx.paging.ExperimentalPagingApi import androidx.paging.LoadType import androidx.paging.PagingState import androidx.paging.RemoteMediator import androidx.room.withTransaction import com.keylesspalace.tusky.db.AppDatabase import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.util.HttpHeaderLink import retrofit2.HttpException @OptIn(ExperimentalPagingApi::class) class ConversationsRemoteMediator( private val api: MastodonApi, private val db: AppDatabase, private val viewModel: ConversationsViewModel ) : RemoteMediator() { private var nextKey: String? = null private var order: Int = 0 override suspend fun load( loadType: LoadType, state: PagingState ): MediatorResult { val activeAccount = viewModel.activeAccountFlow.value if (activeAccount == null) { return MediatorResult.Success(endOfPaginationReached = true) } if (loadType == LoadType.PREPEND) { return MediatorResult.Success(endOfPaginationReached = true) } if (loadType == LoadType.REFRESH) { nextKey = null order = 0 } try { val conversationsResponse = api.getConversations( maxId = nextKey, limit = state.config.pageSize ) val conversations = conversationsResponse.body() if (!conversationsResponse.isSuccessful || conversations == null) { return MediatorResult.Error(HttpException(conversationsResponse)) } db.withTransaction { if (loadType == LoadType.REFRESH) { db.conversationDao().deleteForAccount(activeAccount.id) } val linkHeader = conversationsResponse.headers()["Link"] val links = HttpHeaderLink.parse(linkHeader) nextKey = HttpHeaderLink.findByRelationType(links, "next")?.uri?.getQueryParameter("max_id") db.conversationDao().insert( conversations .filterNot { it.lastStatus == null } .map { conversation -> val expanded = activeAccount.alwaysOpenSpoiler val contentShowing = activeAccount.alwaysShowSensitiveMedia || !conversation.lastStatus!!.sensitive val contentCollapsed = true conversation.toEntity( accountId = activeAccount.id, order = order++, expanded = expanded, contentShowing = contentShowing, contentCollapsed = contentCollapsed ) } ) } return MediatorResult.Success(endOfPaginationReached = nextKey == null) } catch (e: Exception) { return MediatorResult.Error(e) } } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsViewModel.kt ================================================ /* 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.components.conversation import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.paging.ExperimentalPagingApi import androidx.paging.Pager import androidx.paging.PagingConfig import androidx.paging.cachedIn import androidx.paging.map import at.connyduck.calladapter.networkresult.fold import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AppDatabase import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.usecase.TimelineCases import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch @HiltViewModel class ConversationsViewModel @Inject constructor( private val timelineCases: TimelineCases, private val database: AppDatabase, private val api: MastodonApi, accountManager: AccountManager ) : ViewModel() { val activeAccountFlow = accountManager.activeAccount(viewModelScope) private val accountId: Long = activeAccountFlow.value!!.id @OptIn(ExperimentalPagingApi::class) val conversationFlow = Pager( config = PagingConfig( pageSize = 30 ), remoteMediator = ConversationsRemoteMediator(api, database, this), pagingSourceFactory = { database.conversationDao().conversationsForAccount(accountId) } ) .flow .map { pagingData -> pagingData.map { conversation -> conversation.toViewData() } } .cachedIn(viewModelScope) fun favourite(favourite: Boolean, conversation: ConversationViewData) { viewModelScope.launch { timelineCases.favourite(conversation.lastStatus.id, favourite).fold({ val newConversation = conversation.toEntity( accountId = accountId, favourited = favourite ) saveConversationToDb(newConversation) }, { e -> Log.w(TAG, "failed to favourite status", e) }) } } fun bookmark(bookmark: Boolean, conversation: ConversationViewData) { viewModelScope.launch { timelineCases.bookmark(conversation.lastStatus.id, bookmark).fold({ val newConversation = conversation.toEntity( accountId = accountId, bookmarked = bookmark ) saveConversationToDb(newConversation) }, { e -> Log.w(TAG, "failed to bookmark status", e) }) } } fun voteInPoll(choices: List, conversation: ConversationViewData) { viewModelScope.launch { timelineCases.voteInPoll( conversation.lastStatus.id, conversation.lastStatus.status.poll?.id!!, choices ) .fold({ poll -> val newConversation = conversation.toEntity( accountId = accountId, poll = poll ) saveConversationToDb(newConversation) }, { e -> Log.w(TAG, "failed to vote in poll", e) }) } } fun showPollResults(conversation: ConversationViewData) = viewModelScope.launch { conversation.lastStatus.status.poll?.let { poll -> saveConversationToDb( conversation.toEntity(accountId = accountId, poll = poll.copy(voted = true)) ) } } fun expandHiddenStatus(expanded: Boolean, conversation: ConversationViewData) { viewModelScope.launch { val newConversation = conversation.toEntity( accountId = accountId, expanded = expanded ) saveConversationToDb(newConversation) } } fun collapseLongStatus(collapsed: Boolean, conversation: ConversationViewData) { viewModelScope.launch { val newConversation = conversation.toEntity( accountId = accountId, collapsed = collapsed ) saveConversationToDb(newConversation) } } fun showContent(showing: Boolean, conversation: ConversationViewData) { viewModelScope.launch { val newConversation = conversation.toEntity( accountId = accountId, showingHiddenContent = showing ) saveConversationToDb(newConversation) } } fun remove(conversation: ConversationViewData) { viewModelScope.launch { try { api.deleteConversation(conversationId = conversation.id) database.conversationDao().delete( id = conversation.id, accountId = accountId ) } catch (e: Exception) { Log.w(TAG, "failed to delete conversation", e) } } } fun muteConversation(conversation: ConversationViewData) { viewModelScope.launch { try { timelineCases.muteConversation( conversation.lastStatus.id, !conversation.lastStatus.status.muted ) val newConversation = conversation.toEntity( accountId = accountId, muted = !conversation.lastStatus.status.muted ) database.conversationDao().insert(newConversation) } catch (e: Exception) { Log.w(TAG, "failed to mute conversation", e) } } } private suspend fun saveConversationToDb(conversation: ConversationEntity) { database.conversationDao().insert(conversation) } companion object { private const val TAG = "ConversationsViewModel" } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/domainblocks/DomainBlocksActivity.kt ================================================ package com.keylesspalace.tusky.components.domainblocks import android.os.Bundle import com.keylesspalace.tusky.BaseActivity import com.keylesspalace.tusky.R import com.keylesspalace.tusky.databinding.ActivityAccountListBinding import dagger.hilt.android.AndroidEntryPoint @AndroidEntryPoint class DomainBlocksActivity : BaseActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val binding = ActivityAccountListBinding.inflate(layoutInflater) setContentView(binding.root) setSupportActionBar(binding.includedToolbar.toolbar) supportActionBar?.apply { setTitle(R.string.title_domain_mutes) setDisplayHomeAsUpEnabled(true) setDisplayShowHomeEnabled(true) } supportFragmentManager .beginTransaction() .replace(R.id.fragment_container, DomainBlocksFragment()) .commit() } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/domainblocks/DomainBlocksAdapter.kt ================================================ 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) } } } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/domainblocks/DomainBlocksFragment.kt ================================================ 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.util.ensureBottomPadding 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 dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch @AndroidEntryPoint class DomainBlocksFragment : Fragment(R.layout.fragment_domain_blocks) { private val binding by viewBinding(FragmentDomainBlocksBinding::bind) private val viewModel: DomainBlocksViewModel by viewModels() override fun onViewCreated(view: View, savedInstanceState: Bundle?) { val adapter = DomainBlocksAdapter(viewModel::unblock) binding.recyclerView.ensureBottomPadding() 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) } } viewLifecycleOwner.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" } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/domainblocks/DomainBlocksPagingSource.kt ================================================ 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) } } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/domainblocks/DomainBlocksRemoteMediator.kt ================================================ 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) } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/domainblocks/DomainBlocksRepository.kt ================================================ /* * 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 } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/domainblocks/DomainBlocksViewModel.kt ================================================ 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 dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.launch @HiltViewModel 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 ) ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftHelper.kt ================================================ /* 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.components.drafts import android.content.Context import android.net.Uri import android.util.Log import android.webkit.MimeTypeMap import androidx.core.content.FileProvider import androidx.core.net.toUri import com.keylesspalace.tusky.BuildConfig import com.keylesspalace.tusky.db.AppDatabase import com.keylesspalace.tusky.db.entity.DraftAttachment import com.keylesspalace.tusky.db.entity.DraftEntity 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 dagger.hilt.android.qualifiers.ApplicationContext 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( @ApplicationContext val context: Context, private val okHttpClient: OkHttpClient, db: AppDatabase ) { private val draftDao = db.draftDao() suspend fun saveDraft( draftId: Int, accountId: Long, inReplyToId: String?, content: String?, contentWarning: String?, sensitive: Boolean, visibility: Status.Visibility, mediaUris: List, mediaDescriptions: List, mediaFocus: List, poll: NewPoll?, failedToSend: Boolean, failedToSendAlert: Boolean, scheduledAt: String?, language: String?, statusId: String? ) = withContext(Dispatchers.IO) { val externalFilesDir = context.getExternalFilesDir("Tusky") if (externalFilesDir == null || !(externalFilesDir.exists())) { Log.e("DraftHelper", "Error obtaining directory to save media.") throw Exception() } val draftDirectory = File(externalFilesDir, "Drafts") if (!draftDirectory.exists()) { draftDirectory.mkdir() } val uris = mediaUris.map { uriString -> uriString.toUri() }.mapIndexedNotNull { index, uri -> if (uri.isInFolder(draftDirectory)) { uri } else { uri.copyToFolder(draftDirectory, index) } } val types = uris.map { uri -> val mimeType = context.contentResolver.getType(uri) when (mimeType?.substring(0, mimeType.indexOf('/'))) { "video" -> DraftAttachment.Type.VIDEO "image" -> DraftAttachment.Type.IMAGE "audio" -> DraftAttachment.Type.AUDIO else -> throw IllegalStateException("unknown media type") } } 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( id = draftId, accountId = accountId, inReplyToId = inReplyToId, content = content, contentWarning = contentWarning, sensitive = sensitive, visibility = visibility, attachments = attachments, poll = poll, failedToSend = failedToSend, failedToSendNew = failedToSendAlert, scheduledAt = scheduledAt, language = language, statusId = statusId ) draftDao.insertOrReplace(draft) Log.d("DraftHelper", "saved draft to db") } suspend fun deleteDraftAndAttachments(draftId: Int) { draftDao.find(draftId)?.let { draft -> deleteDraftAndAttachments(draft) } } private suspend fun deleteDraftAndAttachments(draft: DraftEntity) { deleteAttachments(draft) draftDao.delete(draft.id) } suspend fun deleteAllDraftsAndAttachmentsForAccount(accountId: Long) { draftDao.loadDrafts(accountId).forEach { draft -> deleteDraftAndAttachments(draft) } } suspend fun deleteAttachments(draft: DraftEntity) = withContext(Dispatchers.IO) { draft.attachments.forEach { attachment -> if (context.contentResolver.delete(attachment.uri, null, null) == 0) { Log.e("DraftHelper", "Did not delete file ${attachment.uriString}") } } } private fun Uri.isInFolder(folder: File): Boolean { val filePath = path ?: return true return File(filePath).parentFile == folder } private fun Uri.copyToFolder(folder: File, index: Int): Uri? { val contentResolver = context.contentResolver val timeStamp: String = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date()) val fileExtension = if (scheme == "https") { lastPathSegment?.substringAfterLast('.', "tmp") } else { val mimeType = contentResolver.getType(this) val map = MimeTypeMap.getSingleton() map.getExtensionFromMimeType(mimeType) } val filename = "Tusky_Draft_Media_${timeStamp}_$index.$fileExtension" val file = File(folder, filename) if (scheme == "https") { // saving redrafted media try { val request = Request.Builder().url(toString()).build() val response = okHttpClient.newCall(request).execute() file.sink().buffer().use { output -> response.body?.source()?.use { input -> output.writeAll(input) } } } catch (ex: IOException) { Log.w("DraftHelper", "failed to save media", ex) return null } } else { this.copyToFile(contentResolver, file) } return FileProvider.getUriForFile( context, BuildConfig.APPLICATION_ID + ".fileprovider", file ) } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftMediaAdapter.kt ================================================ /* Copyright 2020 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.drafts import android.view.ViewGroup import android.widget.ImageView import androidx.constraintlayout.widget.ConstraintLayout import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView import com.bumptech.glide.Glide import com.bumptech.glide.load.engine.DiskCacheStrategy import com.keylesspalace.tusky.R import com.keylesspalace.tusky.db.entity.DraftAttachment import com.keylesspalace.tusky.view.MediaPreviewImageView class DraftMediaAdapter( private val attachmentClick: () -> Unit ) : ListAdapter( object : DiffUtil.ItemCallback() { override fun areItemsTheSame(oldItem: DraftAttachment, newItem: DraftAttachment): Boolean { return oldItem == newItem } override fun areContentsTheSame( oldItem: DraftAttachment, newItem: DraftAttachment ): Boolean { return oldItem == newItem } } ) { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DraftMediaViewHolder { return DraftMediaViewHolder(MediaPreviewImageView(parent.context)) } override fun onBindViewHolder(holder: DraftMediaViewHolder, position: Int) { getItem(position)?.let { attachment -> if (attachment.type == DraftAttachment.Type.AUDIO) { holder.imageView.clearFocus() holder.imageView.setImageResource(R.drawable.audio_file_preview) } else { if (attachment.focus != null) { holder.imageView.setFocalPoint(attachment.focus) } else { holder.imageView.clearFocus() } var glide = Glide.with(holder.itemView.context) .load(attachment.uri) .diskCacheStrategy(DiskCacheStrategy.NONE) .dontAnimate() .centerInside() if (attachment.focus != null) { glide = glide.addListener(holder.imageView) } glide.into(holder.imageView) } } } inner class DraftMediaViewHolder(val imageView: MediaPreviewImageView) : RecyclerView.ViewHolder(imageView) { init { val thumbnailViewSize = 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) val marginBottom = itemView.context.resources .getDimensionPixelSize(R.dimen.compose_media_preview_margin_bottom) layoutParams.setMargins(margin, 0, margin, marginBottom) imageView.layoutParams = layoutParams imageView.scaleType = ImageView.ScaleType.CENTER_CROP imageView.setOnClickListener { attachmentClick() } } } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsActivity.kt ================================================ /* Copyright 2020 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.drafts import android.content.Context import android.content.Intent import android.os.Bundle import android.util.Log import android.widget.LinearLayout import android.widget.Toast import androidx.activity.viewModels import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager import at.connyduck.calladapter.networkresult.fold import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.snackbar.Snackbar import com.keylesspalace.tusky.BaseActivity import com.keylesspalace.tusky.R import com.keylesspalace.tusky.components.compose.ComposeActivity import com.keylesspalace.tusky.databinding.ActivityDraftsBinding import com.keylesspalace.tusky.db.DraftsAlert import com.keylesspalace.tusky.db.entity.DraftEntity import com.keylesspalace.tusky.util.ensureBottomPadding import com.keylesspalace.tusky.util.isHttpNotFound import com.keylesspalace.tusky.util.parseAsMastodonHtml import com.keylesspalace.tusky.util.visible import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch @AndroidEntryPoint class DraftsActivity : BaseActivity(), DraftActionListener { @Inject lateinit var draftsAlert: DraftsAlert private val viewModel: DraftsViewModel by viewModels() private lateinit var binding: ActivityDraftsBinding private lateinit var bottomSheet: BottomSheetBehavior override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = ActivityDraftsBinding.inflate(layoutInflater) setContentView(binding.root) setSupportActionBar(binding.includedToolbar.toolbar) supportActionBar?.apply { title = getString(R.string.title_drafts) setDisplayHomeAsUpEnabled(true) setDisplayShowHomeEnabled(true) } binding.draftsRecyclerView.ensureBottomPadding() binding.draftsErrorMessageView.setup(R.drawable.elephant_friend_empty, R.string.no_drafts) val adapter = DraftsAdapter(this) binding.draftsRecyclerView.adapter = adapter binding.draftsRecyclerView.layoutManager = LinearLayoutManager(this) binding.draftsRecyclerView.addItemDecoration( DividerItemDecoration(this, DividerItemDecoration.VERTICAL) ) bottomSheet = BottomSheetBehavior.from(binding.bottomSheet.root) lifecycleScope.launch { viewModel.drafts.collectLatest { draftData -> adapter.submitData(draftData) } } adapter.addLoadStateListener { binding.draftsErrorMessageView.visible(adapter.itemCount == 0) } // If a failed post is saved to drafts while this activity is up, do nothing; the user is already in the drafts view. draftsAlert.observeInContext(this, false) } override fun onOpenDraft(draft: DraftEntity) { if (draft.inReplyToId == null) { openDraftWithoutReply(draft) return } val context = this as Context lifecycleScope.launch { bottomSheet.state = BottomSheetBehavior.STATE_COLLAPSED viewModel.getStatus(draft.inReplyToId) .fold( { status -> val composeOptions = ComposeActivity.ComposeOptions( draftId = draft.id, content = draft.content, contentWarning = draft.contentWarning, inReplyToId = draft.inReplyToId, replyingStatusContent = status.content.parseAsMastodonHtml().toString(), replyingStatusAuthor = status.account.localUsername, draftAttachments = draft.attachments, poll = draft.poll, sensitive = draft.sensitive, visibility = draft.visibility, scheduledAt = draft.scheduledAt, language = draft.language, statusId = draft.statusId, kind = ComposeActivity.ComposeKind.EDIT_DRAFT ) bottomSheet.state = BottomSheetBehavior.STATE_HIDDEN startActivity(ComposeActivity.startIntent(context, composeOptions)) }, { throwable -> bottomSheet.state = BottomSheetBehavior.STATE_HIDDEN Log.w(TAG, "failed loading reply information", throwable) 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() openDraftWithoutReply(draft) } else { Snackbar.make( binding.root, getString(R.string.drafts_failed_loading_reply), Snackbar.LENGTH_SHORT ) .show() } } ) } } private fun openDraftWithoutReply(draft: DraftEntity) { val composeOptions = ComposeActivity.ComposeOptions( draftId = draft.id, content = draft.content, contentWarning = draft.contentWarning, draftAttachments = draft.attachments, poll = draft.poll, sensitive = draft.sensitive, visibility = draft.visibility, scheduledAt = draft.scheduledAt, language = draft.language, statusId = draft.statusId, kind = ComposeActivity.ComposeKind.EDIT_DRAFT ) startActivity(ComposeActivity.startIntent(this, composeOptions)) } override fun onDeleteDraft(draft: DraftEntity) { viewModel.deleteDraft(draft) Snackbar.make(binding.root, getString(R.string.draft_deleted), Snackbar.LENGTH_LONG) .setAction(R.string.action_undo) { viewModel.restoreDraft(draft) } .show() } companion object { const val TAG = "DraftsActivity" fun newIntent(context: Context) = Intent(context, DraftsActivity::class.java) } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsAdapter.kt ================================================ /* 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.components.drafts import android.view.LayoutInflater import android.view.ViewGroup import androidx.paging.PagingDataAdapter import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.keylesspalace.tusky.databinding.ItemDraftBinding import com.keylesspalace.tusky.db.entity.DraftEntity import com.keylesspalace.tusky.util.BindingHolder import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.visible interface DraftActionListener { fun onOpenDraft(draft: DraftEntity) fun onDeleteDraft(draft: DraftEntity) } class DraftsAdapter( private val listener: DraftActionListener ) : PagingDataAdapter>( object : DiffUtil.ItemCallback() { override fun areItemsTheSame(oldItem: DraftEntity, newItem: DraftEntity): Boolean { return oldItem.id == newItem.id } override fun areContentsTheSame(oldItem: DraftEntity, newItem: DraftEntity): Boolean { return oldItem == newItem } } ) { override fun onCreateViewHolder( parent: ViewGroup, viewType: Int ): BindingHolder { val binding = ItemDraftBinding.inflate(LayoutInflater.from(parent.context), parent, false) val viewHolder = BindingHolder(binding) binding.draftMediaPreview.layoutManager = LinearLayoutManager(binding.root.context, RecyclerView.HORIZONTAL, false) binding.draftMediaPreview.adapter = DraftMediaAdapter { getItem(viewHolder.bindingAdapterPosition)?.let { draft -> listener.onOpenDraft(draft) } } return viewHolder } override fun onBindViewHolder(holder: BindingHolder, position: Int) { getItem(position)?.let { draft -> holder.binding.root.setOnClickListener { listener.onOpenDraft(draft) } holder.binding.deleteButton.setOnClickListener { listener.onDeleteDraft(draft) } holder.binding.draftSendingInfo.visible(draft.failedToSend) holder.binding.contentWarning.visible(!draft.contentWarning.isNullOrEmpty()) holder.binding.contentWarning.text = draft.contentWarning holder.binding.content.text = draft.content holder.binding.draftMediaPreview.visible(draft.attachments.isNotEmpty()) (holder.binding.draftMediaPreview.adapter as DraftMediaAdapter).submitList( draft.attachments ) if (draft.poll != null) { holder.binding.draftPoll.show() holder.binding.draftPoll.setPoll(draft.poll) } else { holder.binding.draftPoll.hide() } } } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/drafts/DraftsViewModel.kt ================================================ /* Copyright 2020 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.drafts import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.paging.Pager import androidx.paging.PagingConfig import androidx.paging.cachedIn import at.connyduck.calladapter.networkresult.NetworkResult import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AppDatabase import com.keylesspalace.tusky.db.entity.DraftEntity import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.network.MastodonApi import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.launch @HiltViewModel class DraftsViewModel @Inject constructor( val database: AppDatabase, val accountManager: AccountManager, val api: MastodonApi, private val draftHelper: DraftHelper ) : ViewModel() { val drafts = Pager( config = PagingConfig( pageSize = 20 ), pagingSourceFactory = { database.draftDao().draftsPagingSource( accountManager.activeAccount?.id!! ) } ).flow .cachedIn(viewModelScope) private val deletedDrafts: MutableList = mutableListOf() fun deleteDraft(draft: DraftEntity) { // this does not immediately delete media files to avoid unnecessary file operations // in case the user decides to restore the draft viewModelScope.launch { database.draftDao().delete(draft.id) deletedDrafts.add(draft) } } fun restoreDraft(draft: DraftEntity) { viewModelScope.launch { database.draftDao().insertOrReplace(draft) deletedDrafts.remove(draft) } } suspend fun getStatus(statusId: String): NetworkResult { return api.status(statusId) } override fun onCleared() { viewModelScope.launch { deletedDrafts.forEach { draftHelper.deleteAttachments(it) } } } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/filters/EditFilterActivity.kt ================================================ /* 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.components.filters import android.content.DialogInterface.BUTTON_POSITIVE import android.os.Bundle import android.view.WindowManager import android.widget.AdapterView import androidx.activity.viewModels import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat.Type.systemBars import androidx.core.view.size import androidx.core.view.updatePadding import androidx.core.widget.doAfterTextChanged import androidx.lifecycle.lifecycleScope import at.connyduck.calladapter.networkresult.fold import com.google.android.material.chip.Chip import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.materialswitch.MaterialSwitch import com.google.android.material.snackbar.Snackbar 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.components.instanceinfo.InstanceInfoRepository import com.keylesspalace.tusky.databinding.ActivityEditFilterBinding import com.keylesspalace.tusky.databinding.DialogFilterBinding import com.keylesspalace.tusky.entity.Filter import com.keylesspalace.tusky.entity.FilterKeyword import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.util.getParcelableExtraCompat import com.keylesspalace.tusky.util.isHttpNotFound import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.util.visible import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject import kotlinx.coroutines.launch @AndroidEntryPoint class EditFilterActivity : BaseActivity() { @Inject lateinit var api: MastodonApi @Inject lateinit var eventHub: EventHub @Inject lateinit var instanceInfoRepository: InstanceInfoRepository private val binding by viewBinding(ActivityEditFilterBinding::inflate) private val viewModel: EditFilterViewModel by viewModels() private lateinit var filter: Filter private var originalFilter: Filter? = null private lateinit var contextSwitches: Map override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) originalFilter = intent.getParcelableExtraCompat(FILTER_TO_EDIT) filter = originalFilter ?: Filter(context = emptyList(), action = Filter.Action.WARN) binding.apply { contextSwitches = mapOf( filterContextHome to Filter.Kind.HOME, filterContextNotifications to Filter.Kind.NOTIFICATIONS, filterContextPublic to Filter.Kind.PUBLIC, filterContextThread to Filter.Kind.THREAD, filterContextAccount to Filter.Kind.ACCOUNT ) } setContentView(binding.root) setSupportActionBar(binding.includedToolbar.toolbar) supportActionBar?.run { // Back button setDisplayHomeAsUpEnabled(true) setDisplayShowHomeEnabled(true) } setTitle( if (originalFilter == null) { R.string.filter_addition_title } else { R.string.filter_edit_title } ) ViewCompat.setOnApplyWindowInsetsListener(binding.scrollView) { scrollView, insets -> val systemBarsInsets = insets.getInsets(systemBars()) scrollView.updatePadding(bottom = systemBarsInsets.bottom) insets.inset(0, 0, 0, systemBarsInsets.bottom) } binding.actionChip.setOnClickListener { showAddKeywordDialog() } binding.filterSaveButton.setOnClickListener { saveChanges() } binding.filterDeleteButton.setOnClickListener { lifecycleScope.launch { if (showDeleteFilterDialog(filter.title) == BUTTON_POSITIVE) deleteFilter() } } binding.filterDeleteButton.visible(originalFilter != null) for (switch in contextSwitches.keys) { switch.setOnCheckedChangeListener { _, isChecked -> val context = contextSwitches[switch]!! if (isChecked) { viewModel.addContext(context) } else { viewModel.removeContext(context) } validateSaveButton() } } binding.filterTitle.doAfterTextChanged { editable -> viewModel.setTitle(editable.toString()) validateSaveButton() } // blur filter is supported in mastodon api version 5+ val blurFilterSupported = instanceInfoRepository.cachedInstanceInfoOrFallback.mastodonApiVersion?.let { it >= 5 } == true binding.filterActionBlur.visible(blurFilterSupported) binding.filterActionGroup.setOnCheckedChangeListener { _, checkedId -> val action = when (checkedId) { R.id.filter_action_blur -> Filter.Action.BLUR R.id.filter_action_hide -> Filter.Action.HIDE else -> Filter.Action.WARN } viewModel.setAction(action) } binding.filterDurationDropDown.onItemClickListener = AdapterView.OnItemClickListener { _, _, position, _ -> viewModel.setDuration( if (originalFilter?.expiresAt == null) { position } else { position - 1 } ) } validateSaveButton() if (originalFilter == null) { binding.filterActionWarn.isChecked = true initializeDurationDropDown(false) } else { loadFilter() } observeModel() } private fun observeModel() { lifecycleScope.launch { viewModel.title.collect { title -> if (title != binding.filterTitle.text.toString()) { // We also get this callback when typing in the field, // which messes with the cursor focus binding.filterTitle.setText(title) } } } lifecycleScope.launch { viewModel.keywords.collect { keywords -> updateKeywords(keywords) } } lifecycleScope.launch { viewModel.contexts.collect { contexts -> for (entry in contextSwitches) { entry.key.isChecked = contexts.contains(entry.value) } } } lifecycleScope.launch { viewModel.action.collect { action -> when (action) { Filter.Action.BLUR -> binding.filterActionBlur.isChecked = true Filter.Action.HIDE -> binding.filterActionHide.isChecked = true else -> binding.filterActionWarn.isChecked = true } } } } // Populate the UI from the filter's members private fun loadFilter() { viewModel.load(filter) initializeDurationDropDown(withNoChange = filter.expiresAt != null) } private fun initializeDurationDropDown(withNoChange: Boolean) { val durationNames = if (withNoChange) { arrayOf(getString(R.string.duration_no_change)) + resources.getStringArray(R.array.filter_duration_names) } else { resources.getStringArray(R.array.filter_duration_names) } binding.filterDurationDropDown.setSimpleItems(durationNames) binding.filterDurationDropDown.setText(durationNames[0], false) } private fun updateKeywords(newKeywords: List) { newKeywords.forEachIndexed { index, filterKeyword -> val chip = binding.keywordChips.getChildAt(index).takeUnless { it.id == R.id.actionChip } as Chip? ?: Chip(this).apply { setCloseIconResource(R.drawable.ic_cancel_24dp_filled) isCheckable = false binding.keywordChips.addView(this, binding.keywordChips.size - 1) } chip.text = if (filterKeyword.wholeWord) { binding.root.context.getString( R.string.filter_keyword_display_format, filterKeyword.keyword ) } else { filterKeyword.keyword } chip.isCloseIconVisible = true chip.setOnClickListener { showEditKeywordDialog(newKeywords[index]) } chip.setOnCloseIconClickListener { viewModel.deleteKeyword(newKeywords[index]) } } while (binding.keywordChips.size - 1 > newKeywords.size) { binding.keywordChips.removeViewAt(newKeywords.size) } filter = filter.copy(keywords = newKeywords) validateSaveButton() } private fun showAddKeywordDialog() { val binding = DialogFilterBinding.inflate(layoutInflater) binding.phraseWholeWord.isChecked = true val dialog = MaterialAlertDialogBuilder(this) .setTitle(R.string.filter_keyword_addition_title) .setView(binding.root) .setPositiveButton(android.R.string.ok) { _, _ -> viewModel.addKeyword( FilterKeyword( "", binding.phraseEditText.text.toString(), binding.phraseWholeWord.isChecked ) ) } .setNegativeButton(android.R.string.cancel, null) .show() dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE) val editText = binding.phraseEditText editText.requestFocus() editText.setSelection(editText.length()) } private fun showEditKeywordDialog(keyword: FilterKeyword) { val binding = DialogFilterBinding.inflate(layoutInflater) binding.phraseEditText.setText(keyword.keyword) binding.phraseWholeWord.isChecked = keyword.wholeWord val dialog = MaterialAlertDialogBuilder(this) .setTitle(R.string.filter_edit_keyword_title) .setView(binding.root) .setPositiveButton(R.string.filter_dialog_update_button) { _, _ -> viewModel.modifyKeyword( keyword, keyword.copy( keyword = binding.phraseEditText.text.toString(), wholeWord = binding.phraseWholeWord.isChecked ) ) } .setNegativeButton(android.R.string.cancel, null) .show() dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE) val editText = binding.phraseEditText editText.requestFocus() editText.setSelection(editText.length()) } private fun validateSaveButton() { binding.filterSaveButton.isEnabled = viewModel.validate() } private fun saveChanges() { // TODO use a progress bar here (see EditProfileActivity/activity_edit_profile.xml for example)? 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 .union(originalFilter?.context.orEmpty()) .distinct() eventHub.dispatch(FilterUpdatedEvent(affectedContexts)) } else { Snackbar.make( binding.root, getString(R.string.error_saving_filter, viewModel.title.value), Snackbar.LENGTH_SHORT ).show() } } } private fun deleteFilter() { originalFilter?.let { filter -> lifecycleScope.launch { api.deleteFilter(filter.id).fold( { finish() }, { throwable -> if (throwable.isHttpNotFound()) { api.deleteFilterV1(filter.id).fold( { finish() }, { Snackbar.make( binding.root, getString(R.string.error_deleting_filter, filter.title), Snackbar.LENGTH_SHORT ).show() } ) } else { Snackbar.make( binding.root, getString(R.string.error_deleting_filter, filter.title), Snackbar.LENGTH_SHORT ).show() } } ) } } } companion object { const val FILTER_TO_EDIT = "FilterToEdit" } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/filters/EditFilterViewModel.kt ================================================ /* 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.components.filters import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import at.connyduck.calladapter.networkresult.fold import com.keylesspalace.tusky.R 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 dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.withContext @HiltViewModel class EditFilterViewModel @Inject constructor(val api: MastodonApi) : ViewModel() { private var originalFilter: Filter? = null 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) { 0 } else { -1 } _contexts.value = filter.context } fun addKeyword(keyword: FilterKeyword) { _keywords.value += keyword } fun deleteKeyword(keyword: FilterKeyword) { _keywords.value = _keywords.value.filterNot { it == keyword } } fun modifyKeyword(original: FilterKeyword, updated: FilterKeyword) { val index = _keywords.value.indexOf(original) if (index >= 0) { _keywords.value = _keywords.value.toMutableList().apply { set(index, updated) } } } fun setTitle(title: String) { this._title.value = title } fun setDuration(index: Int) { _duration.value = index } fun setAction(action: Filter.Action) { this._action.value = action } fun addContext(context: Filter.Kind) { if (!_contexts.value.contains(context)) { _contexts.value += context } } fun removeContext(context: Filter.Kind) { _contexts.value = _contexts.value.filter { it != context } } fun validate(): Boolean { return _title.value.isNotBlank() && _keywords.value.isNotEmpty() && _contexts.value.isNotEmpty() } suspend fun saveChanges(context: Context): Boolean { val contexts = _contexts.value val title = _title.value val durationIndex = _duration.value val action = _action.value return withContext(viewModelScope.coroutineContext) { originalFilter?.let { filter -> updateFilter(filter, title, contexts, action, durationIndex, context) } ?: createFilter(title, contexts, action, durationIndex, context) } } private suspend fun createFilter( title: String, contexts: List, action: Filter.Action, durationIndex: Int, context: Context ): Boolean { val expiration = getExpirationForDurationIndex(durationIndex, context) api.createFilter( title = title, context = contexts, filterAction = action, expiresIn = expiration ).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 ) }.none { it.isFailure } }, { throwable -> return ( throwable.isHttpNotFound() && // Endpoint not found, fall back to v1 api createFilterV1(contexts.map(Filter.Kind::kind), expiration) ) } ) } private suspend fun updateFilter( originalFilter: Filter, title: String, contexts: List, action: Filter.Action, durationIndex: Int, context: Context ): Boolean { val expiration = getExpirationForDurationIndex(durationIndex, context) api.updateFilter( id = originalFilter.id, title = title, context = contexts, filterAction = action, expires = expiration ).fold( { // This is _terrible_, but the all-in-one update filter api Just Doesn't Work val results = _keywords.value.map { keyword -> if (keyword.id.isEmpty()) { api.addFilterKeyword(filterId = originalFilter.id, keyword = keyword.keyword, wholeWord = keyword.wholeWord) } else { api.updateFilterKeyword(keywordId = keyword.id, keyword = keyword.keyword, wholeWord = keyword.wholeWord) } } + originalFilter.keywords.filter { keyword -> // Deleted keywords _keywords.value.none { it.id == keyword.id } }.map { api.deleteFilterKeyword(it.id) } return results.none { it.isFailure } }, { throwable -> if (throwable.isHttpNotFound()) { // Endpoint not found, fall back to v1 api if (updateFilterV1(contexts.map(Filter.Kind::kind), expiration)) { return true } } return false } ) } private suspend fun createFilterV1(context: List, expiration: FilterExpiration?): Boolean { return _keywords.value.map { keyword -> api.createFilterV1(keyword.keyword, context, false, keyword.wholeWord, expiration) }.none { it.isFailure } } private suspend fun updateFilterV1(context: List, expiration: FilterExpiration?): Boolean { val results = _keywords.value.map { keyword -> if (originalFilter == null) { api.createFilterV1( phrase = keyword.keyword, context = context, irreversible = false, wholeWord = keyword.wholeWord, expiresIn = expiration ) } else { api.updateFilterV1( id = originalFilter!!.id, phrase = keyword.keyword, context = context, irreversible = false, wholeWord = keyword.wholeWord, expiresIn = expiration ) } } // Don't handle deleted keywords here because there's only one keyword per v1 filter anyway return results.none { it.isFailure } } companion object { // Mastodon *stores* the absolute date in the filter, // but create/edit take a number of seconds (relative to the time the operation is posted) private fun getExpirationForDurationIndex(index: Int, context: Context): FilterExpiration? { return when (index) { -1 -> FilterExpiration.unchanged 0 -> FilterExpiration.never else -> FilterExpiration.seconds( context.resources.getIntArray(R.array.filter_duration_values)[index] ) } } } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/filters/FilterExpiration.kt ================================================ /* 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.components.filters import kotlin.jvm.JvmInline /** * Custom class to have typesafety for filter expirations. * Retrofit will call toString when sending this class as part of a form-urlencoded body. */ @JvmInline value class FilterExpiration private constructor(val seconds: Int) { override fun toString(): String { return if (seconds < 0) "" else seconds.toString() } companion object { val unchanged: FilterExpiration? = null val never: FilterExpiration = FilterExpiration(-1) fun seconds(seconds: Int): FilterExpiration = FilterExpiration(seconds) } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/filters/FilterExtensions.kt ================================================ /* * 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.filters import android.app.Activity import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.keylesspalace.tusky.R import com.keylesspalace.tusky.util.await internal suspend fun Activity.showDeleteFilterDialog(filterTitle: String): Int { return MaterialAlertDialogBuilder(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) } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/filters/FiltersActivity.kt ================================================ package com.keylesspalace.tusky.components.filters import android.content.DialogInterface.BUTTON_POSITIVE import android.content.Intent import android.os.Bundle import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.DividerItemDecoration import com.keylesspalace.tusky.BaseActivity import com.keylesspalace.tusky.R import com.keylesspalace.tusky.databinding.ActivityFiltersBinding import com.keylesspalace.tusky.entity.Filter import com.keylesspalace.tusky.util.ensureBottomMargin import com.keylesspalace.tusky.util.ensureBottomPadding import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.launchAndRepeatOnLifecycle import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.util.visible import com.keylesspalace.tusky.util.withSlideInAnimation import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch @AndroidEntryPoint class FiltersActivity : BaseActivity(), FiltersListener { private val binding by viewBinding(ActivityFiltersBinding::inflate) private val viewModel: FiltersViewModel by viewModels() private val editFilterLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { // refresh the filters upon returning from EditFilterActivity reloadFilters() } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(binding.root) setSupportActionBar(binding.includedToolbar.toolbar) supportActionBar?.run { // Back button setDisplayHomeAsUpEnabled(true) setDisplayShowHomeEnabled(true) } binding.filtersList.ensureBottomPadding(fab = true) binding.addFilterButton.ensureBottomMargin() binding.addFilterButton.setOnClickListener { launchEditFilterActivity() } binding.swipeRefreshLayout.setOnRefreshListener { reloadFilters() } setTitle(R.string.pref_title_timeline_filters) binding.filtersList.addItemDecoration( DividerItemDecoration(this, DividerItemDecoration.VERTICAL) ) observeViewModel() } private fun observeViewModel() { launchAndRepeatOnLifecycle { viewModel.state.collect { state -> binding.progressBar.visible( state.loadingState == FiltersViewModel.LoadingState.LOADING ) binding.swipeRefreshLayout.isRefreshing = state.loadingState == FiltersViewModel.LoadingState.LOADING 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.errorphant_offline, R.string.error_network ) { reloadFilters() } binding.messageView.show() } FiltersViewModel.LoadingState.ERROR_OTHER -> { binding.messageView.setup( R.drawable.errorphant_error, R.string.error_generic ) { reloadFilters() } 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, R.string.message_empty, null ) binding.messageView.show() } else { binding.messageView.hide() } } } } } } private fun reloadFilters() { viewModel.reload() } private fun launchEditFilterActivity(filter: Filter? = null) { val intent = Intent(this, EditFilterActivity::class.java).apply { if (filter != null) { putExtra(EditFilterActivity.FILTER_TO_EDIT, filter) } }.withSlideInAnimation() editFilterLauncher.launch(intent) } override fun deleteFilter(filter: Filter) { lifecycleScope.launch { if (showDeleteFilterDialog(filter.title) == BUTTON_POSITIVE) { viewModel.deleteFilter(filter, binding.root) } } } override fun updateFilter(updatedFilter: Filter) { launchEditFilterActivity(updatedFilter) } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/filters/FiltersAdapter.kt ================================================ package com.keylesspalace.tusky.components.filters import android.view.LayoutInflater import android.view.ViewGroup import androidx.recyclerview.widget.RecyclerView import com.keylesspalace.tusky.R import com.keylesspalace.tusky.databinding.ItemRemovableBinding import com.keylesspalace.tusky.entity.Filter import com.keylesspalace.tusky.util.BindingHolder import com.keylesspalace.tusky.util.getRelativeTimeSpanString class FiltersAdapter(val listener: FiltersListener, val filters: List) : RecyclerView.Adapter>() { 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 onBindViewHolder(holder: BindingHolder, position: Int) { val binding = holder.binding val resources = binding.root.resources val actions = resources.getStringArray(R.array.filter_actions) val contexts = resources.getStringArray(R.array.filter_contexts) val filter = filters[position] val context = binding.root.context binding.textPrimary.text = if (filter.expiresAt == null) { filter.title } else { context.getString( R.string.filter_expiration_format, filter.title, getRelativeTimeSpanString(binding.root.context, filter.expiresAt.time, System.currentTimeMillis()) ) } binding.textSecondary.text = context.getString( R.string.filter_description_format, actions.getOrNull(filter.action.ordinal - 1), filter.context.map { contexts.getOrNull(it.ordinal) }.joinToString("/") ) binding.delete.setOnClickListener { listener.deleteFilter(filter) } binding.root.setOnClickListener { listener.updateFilter(filter) } } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/filters/FiltersListener.kt ================================================ package com.keylesspalace.tusky.components.filters import com.keylesspalace.tusky.entity.Filter interface FiltersListener { fun deleteFilter(filter: Filter) fun updateFilter(updatedFilter: Filter) } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/filters/FiltersViewModel.kt ================================================ package com.keylesspalace.tusky.components.filters import android.util.Log import android.view.View import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import at.connyduck.calladapter.networkresult.fold import com.google.android.material.snackbar.Snackbar import com.keylesspalace.tusky.R import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.FilterUpdatedEvent import com.keylesspalace.tusky.entity.Filter import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.util.isHttpNotFound import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @HiltViewModel class FiltersViewModel @Inject constructor( private val api: MastodonApi, private val eventHub: EventHub ) : ViewModel() { enum class LoadingState { INITIAL, LOADING, LOADED, ERROR_NETWORK, ERROR_OTHER } data class State(val filters: List, val loadingState: LoadingState) private val _state = MutableStateFlow(State(emptyList(), LoadingState.INITIAL)) val state: StateFlow = _state.asStateFlow() private val loadTrigger = MutableStateFlow(0) init { viewModelScope.launch { observeLoad() } } private suspend fun observeLoad() { loadTrigger.collectLatest { _state.update { it.copy(loadingState = LoadingState.LOADING) } api.getFilters().fold( { filters -> _state.value = State(filters, LoadingState.LOADED) }, { throwable -> if (throwable.isHttpNotFound()) { Log.i(TAG, "failed loading filters v2, falling back to v1", throwable) api.getFiltersV1().fold( { filters -> _state.value = State(filters.map { it.toFilter() }, LoadingState.LOADED) }, { t -> Log.w(TAG, "failed loading filters v1", t) _state.value = State(emptyList(), LoadingState.ERROR_OTHER) } ) } else { Log.w(TAG, "failed loading filters v2", throwable) _state.update { it.copy(loadingState = LoadingState.ERROR_NETWORK) } } } ) } } fun reload() { loadTrigger.update { it + 1 } } suspend fun deleteFilter(filter: Filter, parent: View) { // First wait for a pending loading operation to complete _state.first { it.loadingState > LoadingState.LOADING } api.deleteFilter(filter.id).fold( { _state.update { currentState -> State( currentState.filters.filter { it.id != filter.id }, LoadingState.LOADED ) } eventHub.dispatch(FilterUpdatedEvent(filter.context)) }, { throwable -> if (throwable.isHttpNotFound()) { api.deleteFilterV1(filter.id).fold( { _state.update { currentState -> State( currentState.filters.filter { it.id != filter.id }, LoadingState.LOADED ) } }, { Snackbar.make( parent, parent.context.getString(R.string.error_deleting_filter, filter.title), Snackbar.LENGTH_SHORT ).show() } ) } else { Snackbar.make( parent, parent.context.getString(R.string.error_deleting_filter, filter.title), Snackbar.LENGTH_SHORT ).show() } } ) } companion object { private const val TAG = "FiltersViewModel" } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsActivity.kt ================================================ package com.keylesspalace.tusky.components.followedtags import android.content.SharedPreferences import android.os.Bundle import android.util.Log import androidx.activity.viewModels import androidx.lifecycle.lifecycleScope import androidx.paging.LoadState import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.SimpleItemAnimator import at.connyduck.calladapter.networkresult.fold import com.google.android.material.snackbar.Snackbar import com.keylesspalace.tusky.BaseActivity import com.keylesspalace.tusky.R import com.keylesspalace.tusky.StatusListActivity import com.keylesspalace.tusky.databinding.ActivityFollowedTagsBinding import com.keylesspalace.tusky.interfaces.HashtagActionListener import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.util.copyToClipboard import com.keylesspalace.tusky.util.ensureBottomMargin import com.keylesspalace.tusky.util.ensureBottomPadding 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 com.keylesspalace.tusky.view.showHashtagPickerDialog import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch @AndroidEntryPoint class FollowedTagsActivity : BaseActivity(), HashtagActionListener { @Inject lateinit var api: MastodonApi @Inject lateinit var sharedPreferences: SharedPreferences private val binding by viewBinding(ActivityFollowedTagsBinding::inflate) private val viewModel: FollowedTagsViewModel by viewModels() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(binding.root) setSupportActionBar(binding.includedToolbar.toolbar) supportActionBar?.run { setTitle(R.string.title_followed_hashtags) // Back button setDisplayHomeAsUpEnabled(true) setDisplayShowHomeEnabled(true) } binding.fab.ensureBottomMargin() binding.followedTagsView.ensureBottomPadding(fab = true) binding.fab.setOnClickListener { showDialog() } setupAdapter().let { adapter -> setupRecyclerView(adapter) lifecycleScope.launch { viewModel.pager.collectLatest { pagingData -> adapter.submitData(pagingData) } } } } private fun setupRecyclerView(adapter: FollowedTagsAdapter) { binding.followedTagsView.adapter = adapter binding.followedTagsView.setHasFixedSize(true) binding.followedTagsView.layoutManager = LinearLayoutManager(this) binding.followedTagsView.addItemDecoration( DividerItemDecoration(this, DividerItemDecoration.VERTICAL) ) (binding.followedTagsView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false } private fun setupAdapter(): FollowedTagsAdapter { return FollowedTagsAdapter(this, viewModel).apply { addLoadStateListener { loadState -> binding.followedTagsProgressBar.visible( loadState.refresh == LoadState.Loading && itemCount == 0 ) if (loadState.refresh is LoadState.Error) { binding.followedTagsView.hide() binding.followedTagsMessageView.show() val errorState = loadState.refresh as LoadState.Error binding.followedTagsMessageView.setup(errorState.error) { retry() } Log.w(TAG, "error loading followed hashtags", errorState.error) } else { binding.followedTagsView.show() binding.followedTagsMessageView.hide() } } } } private fun follow(tagName: String, position: Int = -1) { lifecycleScope.launch { val snackbarText = api.followTag(tagName).fold( { if (position == -1) { viewModel.tags.add(it) } else { viewModel.tags.add(position, it) } viewModel.currentSource?.invalidate() getString(R.string.follow_hashtag_success, tagName) }, { t -> Log.w(TAG, "failed to follow hashtag $tagName", t) getString(R.string.error_following_hashtag_format, tagName) } ) Snackbar.make( this@FollowedTagsActivity, binding.followedTagsView, snackbarText, Snackbar.LENGTH_SHORT ) .show() } } override fun unfollow(tagName: String, position: Int) { lifecycleScope.launch { api.unfollowTag(tagName).fold( { viewModel.tags.removeIf { tag -> tag.name == tagName } Snackbar.make( this@FollowedTagsActivity, binding.followedTagsView, getString(R.string.confirmation_hashtag_unfollowed, tagName), Snackbar.LENGTH_LONG ) .setAction(R.string.action_undo) { follow(tagName, position) } .show() viewModel.currentSource?.invalidate() }, { Snackbar.make( this@FollowedTagsActivity, binding.followedTagsView, getString( R.string.error_unfollowing_hashtag_format, tagName ), Snackbar.LENGTH_SHORT ) .show() } ) } } override fun viewTag(tagName: String) { startActivity(StatusListActivity.newHashtagIntent(this, tagName)) } override fun copyTagName(tagName: String) { copyToClipboard( "#$tagName", getString(R.string.confirmation_hashtag_copied, tagName), ) } private fun showDialog() { showHashtagPickerDialog(api, R.string.dialog_follow_hashtag_title) { hashtag -> follow(hashtag) } } companion object { const val TAG = "FollowedTagsActivity" } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsAdapter.kt ================================================ package com.keylesspalace.tusky.components.followedtags import android.view.LayoutInflater import android.view.ViewGroup import android.widget.ImageButton import android.widget.TextView import androidx.paging.PagingDataAdapter import androidx.recyclerview.widget.DiffUtil import com.keylesspalace.tusky.R import com.keylesspalace.tusky.databinding.ItemFollowedHashtagBinding import com.keylesspalace.tusky.interfaces.HashtagActionListener import com.keylesspalace.tusky.util.BindingHolder 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 onBindViewHolder( holder: BindingHolder, position: Int ) { viewModel.tags[position].let { tag -> holder.itemView.findViewById(R.id.followed_tag).apply { text = tag.name setOnClickListener { actionListener.viewTag(tag.name) } setOnLongClickListener { actionListener.copyTagName(tag.name) true } } holder.itemView.findViewById( R.id.followed_tag_unfollow ).setOnClickListener { actionListener.unfollow(tag.name, holder.bindingAdapterPosition) } } } override fun getItemCount(): Int = viewModel.tags.size 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 } } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsPagingSource.kt ================================================ package com.keylesspalace.tusky.components.followedtags import androidx.paging.PagingSource import androidx.paging.PagingState class FollowedTagsPagingSource(private val viewModel: FollowedTagsViewModel) : PagingSource() { override fun getRefreshKey(state: PagingState): String? = null override suspend fun load(params: LoadParams): LoadResult { return if (params is LoadParams.Refresh) { LoadResult.Page(viewModel.tags.map { it.name }, null, viewModel.nextKey) } else { LoadResult.Page(emptyList(), null, null) } } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsRemoteMediator.kt ================================================ package com.keylesspalace.tusky.components.followedtags import androidx.paging.ExperimentalPagingApi import androidx.paging.LoadType import androidx.paging.PagingState import androidx.paging.RemoteMediator import com.keylesspalace.tusky.entity.HashTag import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.util.HttpHeaderLink import retrofit2.HttpException import retrofit2.Response @OptIn(ExperimentalPagingApi::class) class FollowedTagsRemoteMediator( private val api: MastodonApi, private val viewModel: FollowedTagsViewModel ) : 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.followedTags(maxId = viewModel.nextKey) LoadType.REFRESH -> { viewModel.nextKey = null viewModel.tags.clear() api.followedTags() } } } 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"]) viewModel.nextKey = HttpHeaderLink.findByRelationType(links, "next")?.uri?.getQueryParameter("max_id") viewModel.tags.addAll(tags) viewModel.currentSource?.invalidate() return MediatorResult.Success(endOfPaginationReached = viewModel.nextKey == null) } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/followedtags/FollowedTagsViewModel.kt ================================================ package com.keylesspalace.tusky.components.followedtags import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.paging.ExperimentalPagingApi import androidx.paging.Pager import androidx.paging.PagingConfig import androidx.paging.cachedIn import com.keylesspalace.tusky.entity.HashTag import com.keylesspalace.tusky.network.MastodonApi import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject @HiltViewModel class FollowedTagsViewModel @Inject constructor( val api: MastodonApi ) : ViewModel() { val tags: MutableList = mutableListOf() var nextKey: String? = null var currentSource: FollowedTagsPagingSource? = null @OptIn(ExperimentalPagingApi::class) val pager = Pager( config = PagingConfig( pageSize = 100 ), remoteMediator = FollowedTagsRemoteMediator(api, this), pagingSourceFactory = { FollowedTagsPagingSource( viewModel = this ).also { source -> currentSource = source } } ).flow.cachedIn(viewModelScope) companion object { private const val TAG = "FollowedTagsViewModel" } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/instanceinfo/InstanceInfo.kt ================================================ /* Copyright 2022 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.instanceinfo data class InstanceInfo( val maxChars: Int, val pollMaxOptions: Int, val pollMaxLength: Int, val pollMinDuration: Int, val pollMaxDuration: Int, val charactersReservedPerUrl: Int, val videoSizeLimit: Int, val imageSizeLimit: Int, val imageMatrixLimit: Int, val maxMediaAttachments: Int, val maxFields: Int, val maxFieldNameLength: Int?, val maxFieldValueLength: Int?, val version: String?, val translationEnabled: Boolean?, val mastodonApiVersion: Int?, ) ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/instanceinfo/InstanceInfoRepository.kt ================================================ /* Copyright 2022 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.instanceinfo import android.util.Log 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 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.entity.EmojisEntity import com.keylesspalace.tusky.db.entity.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 com.keylesspalace.tusky.util.isHttpNotFound import java.util.concurrent.ConcurrentHashMap import javax.inject.Inject import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext class InstanceInfoRepository @Inject constructor( private val api: MastodonApi, db: AppDatabase, private val accountManager: AccountManager, @ApplicationScope private val externalScope: CoroutineScope ) { private val dao = db.instanceDao() private val instanceName get() = accountManager.activeAccount!!.domain 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().fold({ fetched -> instanceInfoCache[instanceName] = fetched.toInfoOrDefault() }, { e -> Log.w(TAG, "failed to precache instance info", e) }) } } } val cachedInstanceInfoOrFallback: InstanceInfo get() = instanceInfoCache[instanceName] ?: null.toInfoOrDefault() /** * Returns the custom emojis of the instance. * Will always try to fetch them from the api, falls back to cached Emojis in case it is not available. * Never throws, returns empty list in case of error. */ suspend fun getEmojis(): List = withContext(Dispatchers.IO) { api.getCustomEmojis() .onSuccess { emojiList -> dao.upsert(EmojisEntity(instanceName, emojiList)) } .getOrElse { throwable -> Log.w(TAG, "failed to load custom emojis, falling back to cache", throwable) dao.getEmojiInfo(instanceName)?.emojiList.orEmpty() } } /** * Returns information about the instance. * 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 getUpdatedInstanceInfoOrFallback(): InstanceInfo = withContext(Dispatchers.IO) { fetchAndPersistInstanceInfo() .getOrElse { throwable -> Log.w( TAG, "failed to load instance, falling back to cache and default values", throwable ) dao.getInstanceInfo(instanceName) } }.toInfoOrDefault() suspend fun saveFilterV2Support(filterV2Supported: Boolean) = dao.setFilterV2Support(instanceName, filterV2Supported) suspend fun isFilterV2Supported(): Boolean = dao.getFilterV2Support(instanceName) 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, mastodonApiVersion = this?.mastodonApiVersion, ) 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.configuration?.accounts?.maxProfileFields ?: this.pleroma?.metadata?.fieldLimits?.maxFields, maxFieldNameLength = this.pleroma?.metadata?.fieldLimits?.nameLength, maxFieldValueLength = this.pleroma?.metadata?.fieldLimits?.valueLength, translationEnabled = this.configuration?.translation?.enabled, mastodonApiVersion = this.apiVersions?.mastodon, ) 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, mastodonApiVersion = null, ) companion object { private const val TAG = "InstanceInfoRepo" /** In-memory cache for instance data, per instance domain. */ private var instanceInfoCache = ConcurrentHashMap() const val DEFAULT_CHARACTER_LIMIT = 500 private const val DEFAULT_MAX_OPTION_COUNT = 4 private const val DEFAULT_MAX_OPTION_LENGTH = 50 private const val DEFAULT_MIN_POLL_DURATION = 300 private const val DEFAULT_MAX_POLL_DURATION = 604800 private const val DEFAULT_VIDEO_SIZE_LIMIT = 41943040 // 40MiB private const val DEFAULT_IMAGE_SIZE_LIMIT = 10485760 // 10MiB private const val DEFAULT_IMAGE_MATRIX_LIMIT = 16777216 // 4096^2 Pixels // Mastodon only counts URLs as this long in terms of status character limits const val DEFAULT_CHARACTERS_RESERVED_PER_URL = 23 const val DEFAULT_MAX_MEDIA_ATTACHMENTS = 4 const val DEFAULT_MAX_ACCOUNT_FIELDS = 4 } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/login/LoginActivity.kt ================================================ /* 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.components.login import android.content.Context import android.content.Intent import android.net.Uri import android.os.Bundle import android.text.method.LinkMovementMethod import android.util.Log import android.view.Menu import android.view.View import android.widget.TextView import androidx.core.content.edit import androidx.core.net.toUri import androidx.core.view.WindowInsetsCompat.Type.ime import androidx.core.view.WindowInsetsCompat.Type.systemBars import androidx.core.view.updatePadding import androidx.lifecycle.lifecycleScope import at.connyduck.calladapter.networkresult.fold import com.bumptech.glide.Glide import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.keylesspalace.tusky.BaseActivity import com.keylesspalace.tusky.BuildConfig import com.keylesspalace.tusky.MainActivity import com.keylesspalace.tusky.R import com.keylesspalace.tusky.databinding.ActivityLoginBinding import com.keylesspalace.tusky.entity.AccessToken import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.util.getNonNullString import com.keylesspalace.tusky.util.openLinkInCustomTab import com.keylesspalace.tusky.util.rickRoll import com.keylesspalace.tusky.util.setOnWindowInsetsChangeListener import com.keylesspalace.tusky.util.shouldRickRoll import com.keylesspalace.tusky.util.viewBinding import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject import kotlinx.coroutines.launch import okhttp3.HttpUrl /** Main login page, the first thing that users see. Has prompt for instance and login button. */ @AndroidEntryPoint class LoginActivity : BaseActivity() { @Inject lateinit var mastodonApi: MastodonApi private val binding by viewBinding(ActivityLoginBinding::inflate) private val oauthRedirectUri: String get() { val scheme = getString(R.string.oauth_scheme) val host = BuildConfig.APPLICATION_ID return "$scheme://$host/" } private val doWebViewAuth = registerForActivityResult(OauthLogin()) { result -> when (result) { is LoginResult.Ok -> fetchOauthToken(result.code) is LoginResult.Err -> displayError(result.errorMessage) is LoginResult.Cancel -> setLoading(false) } } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(binding.root) binding.loginScrollView.setOnWindowInsetsChangeListener { windowInsets -> val insets = windowInsets.getInsets(systemBars() or ime()) binding.loginScrollView.updatePadding(bottom = insets.bottom) } if (savedInstanceState == null && BuildConfig.CUSTOM_INSTANCE.isNotBlank() && !isAdditionalLogin() ) { binding.domainEditText.setText(BuildConfig.CUSTOM_INSTANCE) binding.domainEditText.setSelection(BuildConfig.CUSTOM_INSTANCE.length) } if (BuildConfig.CUSTOM_LOGO_URL.isNotBlank()) { Glide.with(binding.loginLogo) .load(BuildConfig.CUSTOM_LOGO_URL) .placeholder(null) .into(binding.loginLogo) } binding.loginButton.setOnClickListener { onLoginClick(true) } binding.whatsAnInstanceTextView.setOnClickListener { val dialog = MaterialAlertDialogBuilder(this) .setMessage(R.string.dialog_whats_an_instance) .setPositiveButton(R.string.action_close, null) .show() val textView = dialog.findViewById(android.R.id.message) textView?.movementMethod = LinkMovementMethod.getInstance() } setSupportActionBar(binding.toolbar) supportActionBar?.setDisplayHomeAsUpEnabled(isAdditionalLogin()) supportActionBar?.setDisplayShowTitleEnabled(false) } override fun requiresLogin(): Boolean { return false } override fun onCreateOptionsMenu(menu: Menu?): Boolean { menu?.add(R.string.action_browser_login)?.apply { setOnMenuItemClickListener { onLoginClick(false) true } } return super.onCreateOptionsMenu(menu) } private fun onLoginClick(openInWebView: Boolean) { binding.loginButton.isEnabled = false binding.domainTextInputLayout.error = null val domain = canonicalizeDomain(binding.domainEditText.text.toString()) try { HttpUrl.Builder().host(domain).scheme("https").build() } catch (_: IllegalArgumentException) { setLoading(false) binding.domainTextInputLayout.error = getString(R.string.error_invalid_domain) return } if (shouldRickRoll(this, domain)) { rickRoll(this) return } setLoading(true) lifecycleScope.launch { mastodonApi.authenticateApp( domain, getString(R.string.app_name), oauthRedirectUri, OAUTH_SCOPES, getString(R.string.tusky_website) ).fold( { credentials -> // Save credentials so we can access them after we opened another activity for auth. preferences.edit { putString(DOMAIN, domain) putString(CLIENT_ID, credentials.clientId) putString(CLIENT_SECRET, credentials.clientSecret) } redirectUserToAuthorizeAndLogin(domain, credentials.clientId, openInWebView) }, { e -> binding.loginButton.isEnabled = true binding.domainTextInputLayout.error = getString(R.string.error_failed_app_registration) setLoading(false) Log.e(TAG, Log.getStackTraceString(e)) return@launch } ) } } 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 = Uri.Builder() .scheme("https") .authority(domain) .path(MastodonApi.ENDPOINT_AUTHORIZE) .appendQueryParameter("client_id", clientId) .appendQueryParameter("redirect_uri", oauthRedirectUri) .appendQueryParameter("response_type", "code") .appendQueryParameter("scope", OAUTH_SCOPES) .build() if (openInWebView) { doWebViewAuth.launch(LoginData(domain, uri, oauthRedirectUri.toUri())) } else { openLinkInCustomTab(uri, this) } } override fun onStart() { super.onStart() /* Check if we are resuming during authorization by seeing if the intent contains the * redirect that was given to the server. If so, its response is here! */ val uri = intent.data if (uri?.toString()?.startsWith(oauthRedirectUri) == true) { // This should either have returned an authorization code or an error. val code = uri.getQueryParameter("code") val error = uri.getQueryParameter("error") if (code != null) { fetchOauthToken(code) } else { displayError(error) } } else { // first show or user cancelled login setLoading(false) } } private fun displayError(error: String?) { // Authorization failed. Put the error response where the user can read it and they // can try again. setLoading(false) binding.domainTextInputLayout.error = if (error == null) { // This case means a junk response was received somehow. getString(R.string.error_authorization_unknown) } else { // Use error returned by the server or fall back to the generic message Log.e(TAG, getString(R.string.error_authorization_denied) + " " + error) error.ifBlank { getString(R.string.error_authorization_denied) } } } private fun fetchOauthToken(code: String) { setLoading(true) /* restore variables from SharedPreferences */ val domain = preferences.getNonNullString(DOMAIN, "") val clientId = preferences.getNonNullString(CLIENT_ID, "") val clientSecret = preferences.getNonNullString(CLIENT_SECRET, "") lifecycleScope.launch { mastodonApi.fetchOAuthToken( domain, clientId, clientSecret, oauthRedirectUri, code, "authorization_code" ).fold( { accessToken -> fetchAccountDetails(accessToken, domain, clientId, clientSecret) }, { e -> setLoading(false) binding.domainTextInputLayout.error = getString(R.string.error_retrieving_oauth_token) Log.e(TAG, getString(R.string.error_retrieving_oauth_token), e) } ) } } private suspend fun fetchAccountDetails( accessToken: AccessToken, domain: String, clientId: String, clientSecret: String ) { mastodonApi.accountVerifyCredentials( domain = domain, auth = "Bearer ${accessToken.accessToken}" ).fold({ newAccount -> accountManager.addAccount( accessToken = accessToken.accessToken, domain = domain, clientId = clientId, clientSecret = clientSecret, oauthScopes = OAUTH_SCOPES, newAccount = newAccount ) val intent = Intent(this, MainActivity::class.java) intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) startActivity(intent) }, { e -> setLoading(false) binding.domainTextInputLayout.error = getString(R.string.error_loading_account_details) Log.e(TAG, getString(R.string.error_loading_account_details), e) }) } private fun setLoading(loadingState: Boolean) { if (loadingState) { binding.loginLoadingLayout.visibility = View.VISIBLE binding.loginInputLayout.visibility = View.GONE } else { binding.loginLoadingLayout.visibility = View.GONE binding.loginInputLayout.visibility = View.VISIBLE binding.loginButton.isEnabled = true } } private fun isAdditionalLogin(): Boolean { return intent.getIntExtra(LOGIN_MODE, MODE_DEFAULT) == MODE_ADDITIONAL_LOGIN } companion object { private const val TAG = "LoginActivity" // logging tag private const val OAUTH_SCOPES = "read write follow push" private const val LOGIN_MODE = "LOGIN_MODE" private const val DOMAIN = "domain" private const val CLIENT_ID = "clientId" private const val CLIENT_SECRET = "clientSecret" const val MODE_DEFAULT = 0 const val MODE_ADDITIONAL_LOGIN = 1 @JvmStatic fun getIntent(context: Context, mode: Int): Intent { val loginIntent = Intent(context, LoginActivity::class.java) loginIntent.putExtra(LOGIN_MODE, mode) return loginIntent } /** Make sure the user-entered text is just a fully-qualified domain name. */ private fun canonicalizeDomain(domain: String): String { // Strip any schemes out. var s = domain.replaceFirst("http://", "") s = s.replaceFirst("https://", "") // If a username was included (e.g. username@example.com), just take what's after the '@'. val at = s.lastIndexOf('@') if (at != -1) { s = s.substring(at + 1) } return s.trim { it <= ' ' } } } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/login/LoginWebViewActivity.kt ================================================ /* Copyright 2022 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.login import android.annotation.SuppressLint import android.app.Activity import android.content.Context import android.content.Intent import android.graphics.Color import android.net.Uri import android.os.Bundle import android.os.Parcelable import android.util.Log import android.webkit.CookieManager import android.webkit.WebResourceError import android.webkit.WebResourceRequest import android.webkit.WebStorage import android.webkit.WebView import android.webkit.WebViewClient import androidx.activity.result.contract.ActivityResultContract import androidx.activity.viewModels import androidx.core.net.toUri import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsCompat.Type.ime import androidx.core.view.WindowInsetsCompat.Type.systemBars import androidx.core.view.updatePadding import androidx.lifecycle.lifecycleScope import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.keylesspalace.tusky.BaseActivity import com.keylesspalace.tusky.BuildConfig import com.keylesspalace.tusky.R import com.keylesspalace.tusky.databinding.ActivityLoginWebviewBinding import com.keylesspalace.tusky.util.getParcelableExtraCompat import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.util.visible import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch import kotlinx.parcelize.Parcelize /** Contract for starting [LoginWebViewActivity]. */ class OauthLogin : ActivityResultContract() { override fun createIntent(context: Context, input: LoginData): Intent { val intent = Intent(context, LoginWebViewActivity::class.java) intent.putExtra(DATA_EXTRA, input) return intent } override fun parseResult(resultCode: Int, intent: Intent?): LoginResult { // Can happen automatically on up or back press return if (resultCode == Activity.RESULT_CANCELED) { LoginResult.Cancel } else { intent?.getParcelableExtraCompat(RESULT_EXTRA) ?: LoginResult.Err("failed parsing LoginWebViewActivity result") } } companion object { private const val RESULT_EXTRA = "result" private const val DATA_EXTRA = "data" fun parseData(intent: Intent): LoginData { return intent.getParcelableExtraCompat(DATA_EXTRA)!! } fun makeResultIntent(result: LoginResult): Intent { val intent = Intent() intent.putExtra(RESULT_EXTRA, result) return intent } } } @Parcelize data class LoginData( val domain: String, val url: Uri, val oauthRedirectUrl: Uri ) : Parcelable sealed interface LoginResult : Parcelable { @Parcelize data class Ok(val code: String) : LoginResult @Parcelize data class Err(val errorMessage: String) : LoginResult @Parcelize data object Cancel : LoginResult } /** Activity to do Oauth process using WebView. */ @AndroidEntryPoint class LoginWebViewActivity : BaseActivity() { private val binding by viewBinding(ActivityLoginWebviewBinding::inflate) private val viewModel: LoginWebViewViewModel by viewModels() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) if (BuildConfig.DEBUG) { WebView.setWebContentsDebuggingEnabled(true) } val data = OauthLogin.parseData(intent) setContentView(binding.root) setSupportActionBar(binding.loginToolbar) supportActionBar?.setDisplayHomeAsUpEnabled(true) supportActionBar?.setDisplayShowTitleEnabled(true) setTitle(R.string.title_login) ViewCompat.setOnApplyWindowInsetsListener(binding.loginWebView) { _, insets -> val bottomInsets = insets.getInsets(systemBars() or ime()).bottom binding.root.updatePadding(bottom = bottomInsets) WindowInsetsCompat.CONSUMED } val webView = binding.loginWebView webView.settings.allowContentAccess = false webView.settings.allowFileAccess = false webView.settings.displayZoomControls = false webView.settings.javaScriptCanOpenWindowsAutomatically = false // JavaScript needs to be enabled because otherwise 2FA does not work in some instances @SuppressLint("SetJavaScriptEnabled") webView.settings.javaScriptEnabled = true webView.settings.userAgentString += " Tusky/${BuildConfig.VERSION_NAME}" val oauthUrl = data.oauthRedirectUrl webView.webViewClient = object : WebViewClient() { override fun onPageFinished(view: WebView?, url: String?) { binding.loginProgress.hide() } override fun onReceivedError( view: WebView, request: WebResourceRequest, error: WebResourceError ) { Log.d("LoginWeb", "Failed to load ${data.url}: $error") sendResult(LoginResult.Err(getString(R.string.error_could_not_load_login_page))) } override fun shouldOverrideUrlLoading( view: WebView, request: WebResourceRequest ): Boolean { return shouldOverrideUrlLoading(request.url) } /* overriding this deprecated method is necessary for it to work on api levels < 24 */ @Suppress("OVERRIDE_DEPRECATION") override fun shouldOverrideUrlLoading(view: WebView?, urlString: String?): Boolean { val url = urlString?.toUri() ?: return false return shouldOverrideUrlLoading(url) } fun shouldOverrideUrlLoading(url: Uri): Boolean { return if (url.scheme == oauthUrl.scheme && url.host == oauthUrl.host) { val error = url.getQueryParameter("error") if (error != null) { sendResult(LoginResult.Err(error)) } else { val code = url.getQueryParameter("code").orEmpty() sendResult(LoginResult.Ok(code)) } true } else { false } } } webView.setBackgroundColor(Color.TRANSPARENT) if (savedInstanceState == null) { webView.loadUrl(data.url.toString()) } else { webView.restoreState(savedInstanceState) } binding.loginRules.text = getString(R.string.instance_rule_info, data.domain) viewModel.init(data.domain) lifecycleScope.launch { viewModel.instanceRules.collect { instanceRules -> binding.loginRules.visible(instanceRules.isNotEmpty()) binding.loginRules.setOnClickListener { MaterialAlertDialogBuilder(this@LoginWebViewActivity) .setTitle(getString(R.string.instance_rule_title, data.domain)) .setMessage( instanceRules.joinToString(separator = "\n\n") { "• $it" } ) .setPositiveButton(android.R.string.ok, null) .show() } } } } override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) binding.loginWebView.saveState(outState) } override fun onDestroy() { if (isFinishing) { // We don't want to keep user session in WebView, we just want our own accessToken WebStorage.getInstance().deleteAllData() CookieManager.getInstance().removeAllCookies(null) } super.onDestroy() } override fun requiresLogin() = false private fun sendResult(result: LoginResult) { setResult(Activity.RESULT_OK, OauthLogin.makeResultIntent(result)) finish() } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/login/LoginWebViewViewModel.kt ================================================ /* Copyright 2022 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.login import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import at.connyduck.calladapter.networkresult.fold import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.util.isHttpNotFound import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch @HiltViewModel class LoginWebViewViewModel @Inject constructor( private val api: MastodonApi ) : ViewModel() { private val _instanceRules = MutableStateFlow(emptyList()) val instanceRules = _instanceRules.asStateFlow() private var domain: String? = null fun init(domain: String) { if (this.domain == null) { this.domain = domain viewModelScope.launch { 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 } }, { throwable2 -> Log.w( "LoginWebViewViewModel", "failed to load instance info", throwable2 ) } ) } else { Log.w( "LoginWebViewViewModel", "failed to load instance info", throwable ) } } ) } } } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/notifications/FollowViewHolder.kt ================================================ /* 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.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.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 import com.keylesspalace.tusky.util.parseAsMastodonHtml 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 FollowViewHolder( private val binding: ItemFollowBinding, private val listener: AccountActionListener, private val linkListener: LinkListener ) : RecyclerView.ViewHolder(binding.root), NotificationsViewHolder { override fun bind( viewData: NotificationViewData.Concrete, payloads: List<*>, statusDisplayOptions: StatusDisplayOptions ) { if (payloads.isNotEmpty()) { return } val context = itemView.context val account = viewData.account val messageTemplate = context.getString(if (viewData.type == Notification.Type.SignUp) R.string.notification_sign_up_format else R.string.notification_follow_format) val wrappedDisplayName = account.name.unicodeWrap() binding.notificationText.text = messageTemplate.format(wrappedDisplayName) .emojify(account.emojis, binding.notificationText, statusDisplayOptions.animateEmojis) binding.notificationUsername.text = context.getString(R.string.post_username_format, viewData.account.username) val emojifiedDisplayName = wrappedDisplayName.emojify( account.emojis, binding.notificationDisplayName, statusDisplayOptions.animateEmojis ) binding.notificationDisplayName.text = emojifiedDisplayName if (account.note.isEmpty()) { binding.accountNote.hide() } else { binding.accountNote.show() val emojifiedNote = account.note.parseAsMastodonHtml() .emojify(account.emojis, binding.accountNote, statusDisplayOptions.animateEmojis) setClickableText(binding.accountNote, emojifiedNote, emptyList(), null, linkListener) } val avatarRadius = context.resources .getDimensionPixelSize(R.dimen.avatar_radius_42dp) loadAvatar( account.avatar, binding.notificationAvatar, avatarRadius, statusDisplayOptions.animateAvatars ) binding.avatarBadge.visible(statusDisplayOptions.showBotOverlay && account.bot) itemView.setOnClickListener { listener.onViewAccount(account.id) } binding.accountNote.setOnClickListener { listener.onViewAccount(account.id) } } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/notifications/ModerationWarningViewHolder.kt ================================================ /* Copyright 2025 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.Intent import androidx.core.net.toUri import androidx.recyclerview.widget.RecyclerView import com.keylesspalace.tusky.databinding.ItemModerationWarningNotificationBinding import com.keylesspalace.tusky.util.StatusDisplayOptions import com.keylesspalace.tusky.viewdata.NotificationViewData class ModerationWarningViewHolder( private val binding: ItemModerationWarningNotificationBinding, private val instanceDomain: String ) : RecyclerView.ViewHolder(binding.root), NotificationsViewHolder { override fun bind( viewData: NotificationViewData.Concrete, payloads: List<*>, statusDisplayOptions: StatusDisplayOptions ) { if (payloads.isNotEmpty()) { return } val warning = viewData.moderationWarning!! binding.moderationWarningDescription.setText(warning.action.text) binding.root.setOnClickListener { val intent = Intent(Intent.ACTION_VIEW, "https://$instanceDomain/disputes/strikes/${warning.id}".toUri()) binding.root.context.startActivity(intent) } } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationPolicySummaryAdapter.kt ================================================ /* 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.components.notifications import android.view.LayoutInflater import android.view.ViewGroup import androidx.recyclerview.widget.RecyclerView import com.keylesspalace.tusky.R import com.keylesspalace.tusky.databinding.ItemFilteredNotificationsInfoBinding import com.keylesspalace.tusky.db.entity.NotificationPolicyEntity import com.keylesspalace.tusky.util.BindingHolder import java.text.NumberFormat class NotificationPolicySummaryAdapter( private val onOpenDetails: () -> Unit ) : RecyclerView.Adapter>() { private var state: NotificationPolicyEntity? = null fun updateState(newState: NotificationPolicyEntity?) { val oldShowInfo = state.shouldShowInfo() val newShowInfo = newState.shouldShowInfo() state = newState if (oldShowInfo && !newShowInfo) { notifyItemRemoved(0) } else if (!oldShowInfo && newShowInfo) { notifyItemInserted(0) } else if (oldShowInfo && newShowInfo) { notifyItemChanged(0) } } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingHolder { val binding = ItemFilteredNotificationsInfoBinding.inflate( LayoutInflater.from(parent.context), parent, false ) binding.root.setOnClickListener { onOpenDetails() } return BindingHolder(binding) } override fun getItemCount() = if (state.shouldShowInfo()) 1 else 0 override fun onBindViewHolder(holder: BindingHolder, position: Int) { state?.let { policyState -> val binding = holder.binding val context = holder.binding.root.context binding.notificationPolicySummaryDescription.text = context.getString(R.string.notifications_from_people_you_may_know, policyState.pendingRequestsCount) binding.notificationPolicySummaryBadge.text = NumberFormat.getInstance().format(policyState.pendingNotificationsCount) } } private fun NotificationPolicyEntity?.shouldShowInfo(): Boolean { return this != null && this.pendingNotificationsCount > 0 } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationTypeMappers.kt ================================================ /* 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.components.notifications import com.keylesspalace.tusky.components.timeline.LoadMorePlaceholder import com.keylesspalace.tusky.components.timeline.toAccount import com.keylesspalace.tusky.components.timeline.toStatus import com.keylesspalace.tusky.db.entity.NotificationDataEntity import com.keylesspalace.tusky.db.entity.NotificationEntity import com.keylesspalace.tusky.db.entity.NotificationReportEntity import com.keylesspalace.tusky.db.entity.TimelineAccountEntity import com.keylesspalace.tusky.entity.Filter import com.keylesspalace.tusky.entity.Notification import com.keylesspalace.tusky.entity.Report import com.keylesspalace.tusky.util.toViewData import com.keylesspalace.tusky.viewdata.NotificationViewData import com.keylesspalace.tusky.viewdata.StatusViewData import com.keylesspalace.tusky.viewdata.TranslationViewData fun LoadMorePlaceholder.toNotificationEntity( tuskyAccountId: Long ) = NotificationEntity( id = this.id, tuskyAccountId = tuskyAccountId, type = null, accountId = null, statusId = null, reportId = null, event = null, moderationWarning = null, loading = loading ) fun Notification.toEntity( tuskyAccountId: Long ) = NotificationEntity( tuskyAccountId = tuskyAccountId, type = type, id = id, accountId = account.id, statusId = status?.reblog?.id ?: status?.id, reportId = report?.id, event = event, moderationWarning = moderationWarning, loading = false ) fun Notification.toViewData( isShowingContent: Boolean, isExpanded: Boolean, isCollapsed: Boolean, filter: Filter?, ): NotificationViewData.Concrete = NotificationViewData.Concrete( id = id, type = type, account = account, statusViewData = status?.toViewData( isShowingContent = isShowingContent, isExpanded = isExpanded, isCollapsed = isCollapsed, filter = filter, ), report = report, moderationWarning = moderationWarning, event = event ) fun Report.toEntity( tuskyAccountId: Long ) = NotificationReportEntity( tuskyAccountId = tuskyAccountId, serverId = id, category = category, statusIds = statusIds, createdAt = createdAt, targetAccountId = targetAccount.id ) fun NotificationDataEntity.toViewData( translation: TranslationViewData? = null ): NotificationViewData { if (type == null || account == null) { return NotificationViewData.LoadMore(id = id, isLoading = loading) } return NotificationViewData.Concrete( id = id, type = type, account = account.toAccount(), statusViewData = if (status != null && statusAccount != null) { StatusViewData.Concrete( status = status.toStatus(statusAccount), isExpanded = this.status.expanded, isShowingContent = this.status.contentShowing, isCollapsed = this.status.contentCollapsed, translation = translation ) } else { null }, report = if (report != null && reportTargetAccount != null) { report.toReport(reportTargetAccount) } else { null }, event = event, moderationWarning = moderationWarning ) } fun NotificationReportEntity.toReport( account: TimelineAccountEntity ) = Report( id = serverId, category = category, statusIds = statusIds, createdAt = createdAt, targetAccount = account.toAccount() ) ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsFragment.kt ================================================ /* 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.components.notifications import android.content.DialogInterface import android.content.SharedPreferences import android.os.Bundle import android.view.LayoutInflater import android.view.Menu import android.view.MenuInflater import android.view.MenuItem import android.view.View import android.view.ViewGroup import android.widget.ArrayAdapter import android.widget.ListView import android.widget.PopupWindow import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.core.view.MenuProvider 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.ConcatAdapter import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.SimpleItemAnimator import androidx.swiperefreshlayout.widget.SwipeRefreshLayout import at.connyduck.calladapter.networkresult.onFailure import at.connyduck.sparkbutton.SparkButton import at.connyduck.sparkbutton.helpers.Utils import com.google.android.material.appbar.AppBarLayout import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.snackbar.Snackbar import com.keylesspalace.tusky.BaseActivity import com.keylesspalace.tusky.R import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.PreferenceChangedEvent import com.keylesspalace.tusky.components.notifications.requests.NotificationRequestsActivity import com.keylesspalace.tusky.components.preference.PreferencesFragment.ReadingOrder import com.keylesspalace.tusky.components.systemnotifications.NotificationChannelData import com.keylesspalace.tusky.components.systemnotifications.NotificationService import com.keylesspalace.tusky.databinding.FragmentTimelineNotificationsBinding import com.keylesspalace.tusky.databinding.NotificationsFilterBinding import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.fragment.SFragment import com.keylesspalace.tusky.interfaces.AccountActionListener 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.ListStatusAccessibilityDelegate import com.keylesspalace.tusky.util.StatusDisplayOptions import com.keylesspalace.tusky.util.StatusProvider import com.keylesspalace.tusky.util.ensureBottomPadding 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.updateRelativeTimePeriodically import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.view.ConfirmationBottomSheet.Companion.confirmFavourite import com.keylesspalace.tusky.view.ConfirmationBottomSheet.Companion.confirmReblog import com.keylesspalace.tusky.viewdata.AttachmentViewData import com.keylesspalace.tusky.viewdata.NotificationViewData import com.keylesspalace.tusky.viewdata.TranslationViewData import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch @AndroidEntryPoint class NotificationsFragment : SFragment(R.layout.fragment_timeline_notifications), SwipeRefreshLayout.OnRefreshListener, StatusActionListener, NotificationActionListener, AccountActionListener, MenuProvider, ReselectableFragment { @Inject lateinit var preferences: SharedPreferences @Inject lateinit var eventHub: EventHub @Inject lateinit var notificationService: NotificationService private val binding by viewBinding(FragmentTimelineNotificationsBinding::bind) private val viewModel: NotificationsViewModel by viewModels() private var notificationsAdapter: NotificationsPagingAdapter? = null private var notificationsPolicyAdapter: NotificationPolicySummaryAdapter? = null private var showNotificationsFilterBar: Boolean = true private var readingOrder: ReadingOrder = ReadingOrder.NEWEST_FIRST /** see [com.keylesspalace.tusky.components.timeline.TimelineFragment] for explanation of the load more mechanism */ private var loadMorePosition: Int? = null private var statusIdBelowLoadMore: String? = null private var buttonToAnimate: SparkButton? = null override fun onViewCreated(view: View, savedInstanceState: Bundle?) { requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED) val activeAccount = accountManager.activeAccount ?: return val statusDisplayOptions = StatusDisplayOptions( animateAvatars = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false), mediaPreviewEnabled = activeAccount.mediaPreviewEnabled, 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 }, 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), showSensitiveMedia = activeAccount.alwaysShowSensitiveMedia, openSpoiler = activeAccount.alwaysOpenSpoiler ) binding.recyclerView.ensureBottomPadding(fab = true) // setup the notifications filter bar showNotificationsFilterBar = preferences.getBoolean(PrefKeys.SHOW_NOTIFICATIONS_FILTER, true) updateFilterBarVisibility() binding.buttonClear.setOnClickListener { confirmClearNotifications() } binding.buttonFilter.setOnClickListener { showFilterMenu() } // Setup the SwipeRefreshLayout. binding.swipeRefreshLayout.setOnRefreshListener(this) // Setup the RecyclerView. binding.recyclerView.setHasFixedSize(true) val adapter = NotificationsPagingAdapter( accountId = activeAccount.accountId, statusListener = this, notificationActionListener = this, accountActionListener = this, statusDisplayOptions = statusDisplayOptions, instanceName = activeAccount.domain ) this.notificationsAdapter = adapter binding.recyclerView.layoutManager = LinearLayoutManager(context) binding.recyclerView.setAccessibilityDelegateCompat( ListStatusAccessibilityDelegate( binding.recyclerView, this, StatusProvider { pos: Int -> if (pos in 0 until adapter.itemCount) { val notification = adapter.peek(pos) // We support replies only for now if (notification is NotificationViewData.Concrete) { return@StatusProvider notification.statusViewData } else { return@StatusProvider null } } else { null } } ) ) val notificationsPolicyAdapter = NotificationPolicySummaryAdapter { (activity as BaseActivity).startActivityWithSlideInAnimation(NotificationRequestsActivity.newIntent(requireContext())) } this.notificationsPolicyAdapter = notificationsPolicyAdapter binding.recyclerView.adapter = ConcatAdapter(notificationsPolicyAdapter, notificationsAdapter) (binding.recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false binding.recyclerView.addItemDecoration( DividerItemDecoration(context, DividerItemDecoration.VERTICAL) ) readingOrder = ReadingOrder.from(preferences.getString(PrefKeys.READING_ORDER, null)) notificationsPolicyAdapter.registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() { override fun onItemRangeInserted(positionStart: Int, itemCount: Int) { binding.recyclerView.scrollToPosition(0) } }) adapter.addLoadStateListener { loadState -> if (loadState.refresh != LoadState.Loading && loadState.source.refresh != LoadState.Loading) { binding.swipeRefreshLayout.isRefreshing = false } binding.statusView.hide() binding.progressBar.hide() 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) } } is LoadState.Error -> { binding.statusView.show() binding.statusView.setup((loadState.refresh as LoadState.Error).error) { onRefresh() } } is LoadState.Loading -> { binding.progressBar.show() } } } } adapter.registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() { override fun onItemRangeInserted(positionStart: Int, itemCount: Int) { val firstPos = (binding.recyclerView.layoutManager as LinearLayoutManager).findFirstCompletelyVisibleItemPosition() if (firstPos == 0 && positionStart == 0 && adapter.itemCount != itemCount) { binding.recyclerView.post { if (getView() != null) { binding.recyclerView.scrollBy( 0, Utils.dpToPx(binding.recyclerView.context, -30) ) } } loadMorePosition = null } if (readingOrder == ReadingOrder.OLDEST_FIRST) { updateReadingPositionForOldestFirst(adapter) } } }) viewLifecycleOwner.lifecycleScope.launch { viewModel.notifications.collectLatest { pagingData -> adapter.submitData(pagingData) } } viewLifecycleOwner.lifecycleScope.launch { eventHub.events.collect { event -> if (event is PreferenceChangedEvent) { onPreferenceChanged(adapter, event.preferenceKey) } } } viewLifecycleOwner.lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.RESUMED) { accountManager.activeAccount?.let { account -> notificationService.clearNotificationsForAccount(account) } } } updateRelativeTimePeriodically(preferences, adapter) viewLifecycleOwner.lifecycleScope.launch { viewModel.notificationPolicy.collect { notificationsPolicyAdapter.updateState(it) } } } override fun onDestroyView() { // Clear the adapters to prevent leaking the View notificationsAdapter = null notificationsPolicyAdapter = null buttonToAnimate = null super.onDestroyView() } override fun onReselect() { if (view != null) { binding.recyclerView.layoutManager?.scrollToPosition(0) binding.recyclerView.stopScroll() } } override fun onRefresh() { notificationsAdapter?.refresh() viewModel.loadNotificationPolicy() } override fun onViewAccount(id: String) { super.viewAccount(id) } override fun onMute(mute: Boolean, id: String, position: Int, notifications: Boolean) { // not needed, muting via the more menu on statuses is handled in SFragment } override fun onBlock(block: Boolean, id: String, position: Int) { // not needed, blocking via the more menu on statuses is handled in SFragment } override fun onRespondToFollowRequest(accept: Boolean, accountIdRequestingFollow: String, position: Int) { val notification = notificationsAdapter?.peek(position) ?: return viewModel.respondToFollowRequest(accept, accountIdRequestingFollow = accountIdRequestingFollow, notificationId = notification.id) } override fun onViewReport(reportId: String) { requireContext().openLink( "https://${accountManager.activeAccount!!.domain}/admin/reports/$reportId" ) } override fun onViewTag(tag: String) { super.viewTag(tag) } override fun onReply(position: Int) { val status = notificationsAdapter?.peek(position)?.asStatusOrNull() ?: return super.reply(status.status) } override fun removeItem(position: Int) { val notification = notificationsAdapter?.peek(position) ?: return viewModel.remove(notification.id) } override fun onReblog(reblog: Boolean, position: Int, visibility: Status.Visibility?, button: SparkButton?) { val status = notificationsAdapter?.peek(position)?.asStatusOrNull() ?: return buttonToAnimate = button if (reblog && visibility == null) { confirmReblog(preferences) { visibility -> viewModel.reblog(true, status, visibility) buttonToAnimate?.playAnimation() buttonToAnimate?.isChecked = true } } else { viewModel.reblog(reblog, status, visibility ?: Status.Visibility.PUBLIC) if (reblog) { buttonToAnimate?.playAnimation() } buttonToAnimate?.isChecked = reblog } } override val onMoreTranslate: (translate: Boolean, position: Int) -> Unit get() = { translate: Boolean, position: Int -> if (translate) { onTranslate(position) } else { onUntranslate(position) } } private fun onTranslate(position: Int) { val status = notificationsAdapter?.peek(position)?.asStatusOrNull() ?: return viewLifecycleOwner.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 = notificationsAdapter?.peek(position)?.asStatusOrNull() ?: return viewModel.untranslate(status) } override fun onFavourite(favourite: Boolean, position: Int, button: SparkButton?) { val status = notificationsAdapter?.peek(position)?.asStatusOrNull() ?: return buttonToAnimate = button if (favourite) { confirmFavourite(preferences) { viewModel.favorite(true, status) buttonToAnimate?.playAnimation() buttonToAnimate?.isChecked = true } } else { viewModel.favorite(false, status) buttonToAnimate?.isChecked = false } } override fun onBookmark(bookmark: Boolean, position: Int) { val status = notificationsAdapter?.peek(position)?.asStatusOrNull() ?: return viewModel.bookmark(bookmark, status) } override fun onVoteInPoll(position: Int, choices: List) { val status = notificationsAdapter?.peek(position)?.asStatusOrNull() ?: return viewModel.voteInPoll(choices, status) } override fun onShowPollResults(position: Int) { notificationsAdapter?.peek(position)?.asStatusOrNull()?.let { status -> viewModel.showPollResults(status) } } override fun clearWarningAction(position: Int) { val status = notificationsAdapter?.peek(position)?.asStatusOrNull() ?: return viewModel.clearWarning(status) } override fun onMore(view: View, position: Int) { val status = notificationsAdapter?.peek(position)?.asStatusOrNull() ?: return super.more( status.status, view, position, (status.translation as? TranslationViewData.Loaded)?.data ) } override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) { val status = notificationsAdapter?.peek(position)?.asStatusOrNull() ?: return super.viewMedia(attachmentIndex, AttachmentViewData.list(status), view) } override fun onViewThread(position: Int) { val status = notificationsAdapter?.peek(position)?.asStatusOrNull()?.status ?: return super.viewThread(status.id, status.url) } override fun onOpenReblog(position: Int) { val status = notificationsAdapter?.peek(position)?.asStatusOrNull() ?: return super.openReblog(status.status) } override fun onExpandedChange(expanded: Boolean, position: Int) { val status = notificationsAdapter?.peek(position)?.asStatusOrNull() ?: return viewModel.changeExpanded(expanded, status) } override fun onContentHiddenChange(isShowing: Boolean, position: Int) { val status = notificationsAdapter?.peek(position)?.asStatusOrNull() ?: return viewModel.changeContentShowing(isShowing, status) } override fun onLoadMore(position: Int) { val adapter = this.notificationsAdapter val placeholder = adapter?.peek(position)?.asPlaceholderOrNull() ?: return loadMorePosition = position statusIdBelowLoadMore = if (position + 1 < adapter.itemCount) adapter.peek(position + 1)?.id else null viewModel.loadMore(placeholder.id) } override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) { val status = notificationsAdapter?.peek(position)?.asStatusOrNull() ?: return viewModel.changeContentCollapsed(isCollapsed, status) } private fun confirmClearNotifications() { MaterialAlertDialogBuilder(requireContext()) .setMessage(R.string.notification_clear_text) .setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int -> clearNotifications() } .setNegativeButton(android.R.string.cancel, null) .show() } private fun clearNotifications() { viewModel.clearNotifications() } private fun showFilterMenu() { val notificationTypeList = NotificationChannelData.entries.map { type -> getString(type.title) } val adapter = ArrayAdapter(requireContext(), android.R.layout.simple_list_item_multiple_choice, notificationTypeList) val window = PopupWindow(requireContext(), null, com.google.android.material.R.attr.listPopupWindowStyle) val menuBinding = NotificationsFilterBinding.inflate(LayoutInflater.from(requireContext()), binding.root as ViewGroup, false) menuBinding.buttonApply.setOnClickListener { val checkedItems = menuBinding.listView.getCheckedItemPositions() val excludes = NotificationChannelData.entries.filterIndexed { index, _ -> !checkedItems[index, false] } window.dismiss() viewModel.updateNotificationFilters(excludes.toSet()) } menuBinding.listView.setAdapter(adapter) menuBinding.listView.setChoiceMode(ListView.CHOICE_MODE_MULTIPLE) NotificationChannelData.entries.forEachIndexed { index, type -> menuBinding.listView.setItemChecked(index, !viewModel.excludes.value.contains(type)) } window.setContentView(menuBinding.root) window.isFocusable = true window.width = ViewGroup.LayoutParams.WRAP_CONTENT window.height = ViewGroup.LayoutParams.WRAP_CONTENT window.showAsDropDown(binding.buttonFilter) } private fun onPreferenceChanged(adapter: NotificationsPagingAdapter, key: String) { when (key) { PrefKeys.MEDIA_PREVIEW_ENABLED -> { val enabled = accountManager.activeAccount!!.mediaPreviewEnabled val oldMediaPreviewEnabled = adapter.mediaPreviewEnabled if (enabled != oldMediaPreviewEnabled) { adapter.mediaPreviewEnabled = enabled } } PrefKeys.SHOW_NOTIFICATIONS_FILTER -> { if (view != null) { showNotificationsFilterBar = preferences.getBoolean(PrefKeys.SHOW_NOTIFICATIONS_FILTER, true) updateFilterBarVisibility() } } PrefKeys.READING_ORDER -> { readingOrder = ReadingOrder.from( preferences.getString(PrefKeys.READING_ORDER, null) ) } } } private fun updateFilterBarVisibility() { val params = binding.swipeRefreshLayout.layoutParams as CoordinatorLayout.LayoutParams if (showNotificationsFilterBar) { binding.appBarOptions.setExpanded(true, false) binding.appBarOptions.show() // Set content behaviour to hide filter on scroll params.behavior = AppBarLayout.ScrollingViewBehavior() } else { binding.appBarOptions.setExpanded(false, false) binding.appBarOptions.hide() // Clear behaviour to hide app bar params.behavior = null } } private fun updateReadingPositionForOldestFirst(adapter: NotificationsPagingAdapter) { var position = loadMorePosition ?: return val notificationIdBelowLoadMore = statusIdBelowLoadMore ?: return var notification: NotificationViewData? while (adapter.peek(position).let { notification = it it != null } ) { if (notification?.id == notificationIdBelowLoadMore) { val lastVisiblePosition = (binding.recyclerView.layoutManager as LinearLayoutManager).findLastVisibleItemPosition() if (position > lastVisiblePosition) { binding.recyclerView.scrollToPosition(position) } break } position++ } loadMorePosition = null } override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { menuInflater.inflate(R.menu.fragment_notifications, menu) } override fun onMenuItemSelected(menuItem: MenuItem) = when (menuItem.itemId) { R.id.action_refresh -> { binding.swipeRefreshLayout.isRefreshing = true onRefresh() true } R.id.action_edit_notification_filter -> { showFilterMenu() true } R.id.action_clear_notifications -> { confirmClearNotifications() true } else -> false } companion object { fun newInstance() = NotificationsFragment() } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsPagingAdapter.kt ================================================ /* 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.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.R import com.keylesspalace.tusky.adapter.FilteredStatusViewHolder import com.keylesspalace.tusky.adapter.FollowRequestViewHolder import com.keylesspalace.tusky.adapter.LoadMoreViewHolder import com.keylesspalace.tusky.adapter.PlaceholderViewHolder import com.keylesspalace.tusky.adapter.StatusBaseViewHolder import com.keylesspalace.tusky.databinding.ItemFollowBinding import com.keylesspalace.tusky.databinding.ItemFollowRequestBinding import com.keylesspalace.tusky.databinding.ItemLoadMoreBinding import com.keylesspalace.tusky.databinding.ItemModerationWarningNotificationBinding import com.keylesspalace.tusky.databinding.ItemPlaceholderBinding import com.keylesspalace.tusky.databinding.ItemReportNotificationBinding import com.keylesspalace.tusky.databinding.ItemSeveredRelationshipNotificationBinding import com.keylesspalace.tusky.databinding.ItemStatusFilteredBinding import com.keylesspalace.tusky.databinding.ItemStatusNotificationBinding import com.keylesspalace.tusky.databinding.ItemUnknownNotificationBinding import com.keylesspalace.tusky.entity.Filter import com.keylesspalace.tusky.entity.Notification 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 interface NotificationActionListener { fun onViewReport(reportId: String) } interface NotificationsViewHolder { fun bind( viewData: NotificationViewData.Concrete, payloads: List<*>, statusDisplayOptions: StatusDisplayOptions ) } class NotificationsPagingAdapter( private val accountId: String, private var statusDisplayOptions: StatusDisplayOptions, private val statusListener: StatusActionListener, private val notificationActionListener: NotificationActionListener, private val accountActionListener: AccountActionListener, private val instanceName: String ) : PagingDataAdapter(NotificationsDifferCallback) { var mediaPreviewEnabled: Boolean get() = statusDisplayOptions.mediaPreviewEnabled set(mediaPreviewEnabled) { statusDisplayOptions = statusDisplayOptions.copy( mediaPreviewEnabled = mediaPreviewEnabled ) notifyItemRangeChanged(0, itemCount) } private val absoluteTimeFormatter = AbsoluteTimeFormatter() init { stateRestorationPolicy = StateRestorationPolicy.PREVENT_WHEN_EMPTY } override fun getItemViewType(position: Int): Int { return when (val notification = getItem(position)) { is NotificationViewData.LoadMore -> VIEW_TYPE_LOAD_MORE is NotificationViewData.Concrete -> { when (notification.type) { Notification.Type.Mention, Notification.Type.Poll -> if (notification.statusViewData?.filter?.action == Filter.Action.WARN) { VIEW_TYPE_STATUS_FILTERED } else { VIEW_TYPE_STATUS } Notification.Type.Status, Notification.Type.Update -> if (notification.statusViewData?.filter?.action == Filter.Action.WARN) { VIEW_TYPE_STATUS_FILTERED } else { VIEW_TYPE_STATUS_NOTIFICATION } Notification.Type.Favourite, Notification.Type.Reblog -> VIEW_TYPE_STATUS_NOTIFICATION Notification.Type.Follow, Notification.Type.SignUp -> VIEW_TYPE_FOLLOW Notification.Type.FollowRequest -> VIEW_TYPE_FOLLOW_REQUEST Notification.Type.Report -> VIEW_TYPE_REPORT Notification.Type.SeveredRelationship -> VIEW_TYPE_SEVERED_RELATIONSHIP Notification.Type.ModerationWarning -> VIEW_TYPE_MODERATION_WARNING else -> VIEW_TYPE_UNKNOWN } } null -> VIEW_TYPE_PLACEHOLDER } } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { val inflater = LayoutInflater.from(parent.context) return when (viewType) { VIEW_TYPE_PLACEHOLDER -> PlaceholderViewHolder( ItemPlaceholderBinding.inflate(inflater, parent, false), mode = PlaceholderViewHolder.Mode.NOTIFICATION ) VIEW_TYPE_STATUS -> StatusViewHolder( inflater.inflate(R.layout.item_status, parent, false), statusListener, accountId ) VIEW_TYPE_STATUS_FILTERED -> FilteredStatusViewHolder( ItemStatusFilteredBinding.inflate(inflater, parent, false), statusListener ) VIEW_TYPE_STATUS_NOTIFICATION -> StatusNotificationViewHolder( ItemStatusNotificationBinding.inflate(inflater, parent, false), statusListener, absoluteTimeFormatter ) VIEW_TYPE_FOLLOW -> FollowViewHolder( ItemFollowBinding.inflate(inflater, parent, false), accountActionListener, statusListener ) VIEW_TYPE_FOLLOW_REQUEST -> FollowRequestViewHolder( ItemFollowRequestBinding.inflate(inflater, parent, false), accountActionListener, statusListener, true ) VIEW_TYPE_LOAD_MORE -> LoadMoreViewHolder( ItemLoadMoreBinding.inflate(inflater, parent, false), statusListener ) VIEW_TYPE_REPORT -> ReportNotificationViewHolder( ItemReportNotificationBinding.inflate(inflater, parent, false), notificationActionListener, accountActionListener ) VIEW_TYPE_SEVERED_RELATIONSHIP -> SeveredRelationshipNotificationViewHolder( ItemSeveredRelationshipNotificationBinding.inflate(inflater, parent, false), instanceName ) VIEW_TYPE_MODERATION_WARNING -> ModerationWarningViewHolder( ItemModerationWarningNotificationBinding.inflate(inflater, parent, false), instanceName ) else -> UnknownNotificationViewHolder( ItemUnknownNotificationBinding.inflate(inflater, parent, false) ) } } override fun onBindViewHolder(viewHolder: RecyclerView.ViewHolder, position: Int) { onBindViewHolder(viewHolder, position, emptyList()) } override fun onBindViewHolder(viewHolder: RecyclerView.ViewHolder, position: Int, payloads: List) { getItem(position)?.let { notification -> when (notification) { is NotificationViewData.Concrete -> (viewHolder as NotificationsViewHolder).bind(notification, payloads, statusDisplayOptions) is NotificationViewData.LoadMore -> { (viewHolder as LoadMoreViewHolder).setup(notification.isLoading) } } } } companion object { private const val VIEW_TYPE_PLACEHOLDER = 0 private const val VIEW_TYPE_STATUS = 1 private const val VIEW_TYPE_STATUS_FILTERED = 2 private const val VIEW_TYPE_STATUS_NOTIFICATION = 3 private const val VIEW_TYPE_FOLLOW = 4 private const val VIEW_TYPE_FOLLOW_REQUEST = 5 private const val VIEW_TYPE_LOAD_MORE = 6 private const val VIEW_TYPE_REPORT = 7 private const val VIEW_TYPE_SEVERED_RELATIONSHIP = 8 private const val VIEW_TYPE_MODERATION_WARNING = 9 private const val VIEW_TYPE_UNKNOWN = 10 val NotificationsDifferCallback = 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 // Items are different always. It allows to refresh timestamp on every view holder update } override fun getChangePayload( oldItem: NotificationViewData, newItem: NotificationViewData ): Any? { return if (oldItem == newItem) { // If items are equal - update timestamp only StatusBaseViewHolder.Key.KEY_CREATED } else { // If items are different - update the whole view holder null } } } } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsRemoteMediator.kt ================================================ /* 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.components.notifications 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.keylesspalace.tusky.components.systemnotifications.toTypes import com.keylesspalace.tusky.components.timeline.LoadMorePlaceholder import com.keylesspalace.tusky.components.timeline.toEntity import com.keylesspalace.tusky.components.timeline.util.ifExpected import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AppDatabase import com.keylesspalace.tusky.db.entity.AccountEntity import com.keylesspalace.tusky.db.entity.NotificationDataEntity import com.keylesspalace.tusky.db.entity.TimelineStatusEntity import com.keylesspalace.tusky.entity.Notification import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.util.isLessThan import retrofit2.HttpException @OptIn(ExperimentalPagingApi::class) class NotificationsRemoteMediator( private val viewModel: NotificationsViewModel, private val accountManager: AccountManager, private val api: MastodonApi, private val db: AppDatabase ) : RemoteMediator() { private var initialRefresh = false private val notificationsDao = db.notificationsDao() private val accountDao = db.timelineAccountDao() private val statusDao = db.timelineStatusDao() override suspend fun load( loadType: LoadType, state: PagingState ): MediatorResult { val activeAccount = viewModel.activeAccountFlow.value if (activeAccount == null) { return MediatorResult.Success(endOfPaginationReached = true) } val excludes = viewModel.excludes.value.toTypes() try { var dbEmpty = false val topPlaceholderId = if (loadType == LoadType.REFRESH) { notificationsDao.getTopPlaceholderId(activeAccount.id) } else { null // don't execute the query if it is not needed } if (!initialRefresh && loadType == LoadType.REFRESH) { val topId = notificationsDao.getTopId(activeAccount.id) topId?.let { cachedTopId -> val notificationResponse = api.notifications( maxId = cachedTopId, // so already existing placeholders don't get accidentally overwritten sinceId = topPlaceholderId, limit = state.config.pageSize, excludes = excludes ) val notifications = notificationResponse.body() if (notificationResponse.isSuccessful && notifications != null) { db.withTransaction { replaceNotificationRange(notifications, state, activeAccount) } } } initialRefresh = true dbEmpty = topId == null } val notificationResponse = when (loadType) { LoadType.REFRESH -> { api.notifications(sinceId = topPlaceholderId, limit = state.config.pageSize, excludes = excludes) } LoadType.PREPEND -> { return MediatorResult.Success(endOfPaginationReached = true) } LoadType.APPEND -> { val maxId = state.pages.findLast { it.data.isNotEmpty() }?.data?.lastOrNull()?.id api.notifications(maxId = maxId, limit = state.config.pageSize, excludes = excludes) } } val notifications = notificationResponse.body() if (!notificationResponse.isSuccessful || notifications == null) { return MediatorResult.Error(HttpException(notificationResponse)) } db.withTransaction { val overlappedNotifications = replaceNotificationRange(notifications, state, activeAccount) /* In case we loaded a whole page and there was no overlap with existing statuses, we insert a placeholder because there might be even more unknown statuses */ if (loadType == LoadType.REFRESH && overlappedNotifications == 0 && notifications.size == state.config.pageSize && !dbEmpty) { /* This overrides the last of the newly loaded statuses with a placeholder to guarantee the placeholder has an id that exists on the server as not all servers handle client generated ids as expected */ notificationsDao.insertNotification( LoadMorePlaceholder(notifications.last().id, loading = false).toNotificationEntity(activeAccount.id) ) } } return MediatorResult.Success(endOfPaginationReached = notifications.isEmpty()) } catch (e: Exception) { return ifExpected(e) { Log.w(TAG, "Failed to load notifications", e) MediatorResult.Error(e) } } } /** * Deletes all notifications in a given range and inserts new notifications. * This is necessary so notifications that have been deleted on the server are cleaned up. * Should be run in a transaction as it executes multiple db updates * @param notifications the new notifications * @return the number of old notifications that have been cleared from the database */ private suspend fun replaceNotificationRange( notifications: List, state: PagingState, activeAccount: AccountEntity ): Int { val overlappedNotifications = if (notifications.isNotEmpty()) { notificationsDao.deleteRange(activeAccount.id, notifications.last().id, notifications.first().id) } else { 0 } for (notification in notifications) { accountDao.insert(notification.account.toEntity(activeAccount.id)) notification.report?.let { report -> accountDao.insert(report.targetAccount.toEntity(activeAccount.id)) notificationsDao.insertReport(report.toEntity(activeAccount.id)) } // check if we already have one of the newly loaded statuses cached locally // in case we do, copy the local state (expanded, contentShowing, contentCollapsed) over so it doesn't get lost var oldStatus: TimelineStatusEntity? = null for (page in state.pages) { oldStatus = page.data.find { s -> s.id == notification.id }?.status if (oldStatus != null) break } notification.status?.let { status -> val expanded = oldStatus?.expanded ?: activeAccount.alwaysOpenSpoiler val contentShowing = oldStatus?.contentShowing ?: (activeAccount.alwaysShowSensitiveMedia || !status.sensitive) val contentCollapsed = oldStatus?.contentCollapsed ?: true val statusToInsert = status.reblog ?: status accountDao.insert(statusToInsert.account.toEntity(activeAccount.id)) statusDao.insert( statusToInsert.toEntity( tuskyAccountId = activeAccount.id, expanded = expanded, contentShowing = contentShowing, contentCollapsed = contentCollapsed ) ) } notificationsDao.insertNotification( notification.toEntity( activeAccount.id ) ) } notifications.firstOrNull()?.let { notification -> saveNewestNotificationId(notification) } return overlappedNotifications } private suspend fun saveNewestNotificationId(notification: Notification) { viewModel.activeAccountFlow.value?.let { activeAccount -> val lastNotificationId: String = activeAccount.lastNotificationId val newestNotificationId = notification.id if (lastNotificationId.isLessThan(newestNotificationId)) { Log.d(TAG, "saving newest noti id: $lastNotificationId for account ${activeAccount.id}") accountManager.updateAccount(activeAccount) { copy(lastNotificationId = newestNotificationId) } } } } companion object { private const val TAG = "NotificationsRM" } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModel.kt ================================================ /* 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.components.notifications import android.content.SharedPreferences import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.paging.ExperimentalPagingApi import androidx.paging.Pager import androidx.paging.PagingConfig import androidx.paging.cachedIn import androidx.paging.filter import androidx.paging.map import androidx.room.withTransaction 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.appstore.EventHub import com.keylesspalace.tusky.appstore.FilterUpdatedEvent import com.keylesspalace.tusky.appstore.PreferenceChangedEvent import com.keylesspalace.tusky.components.preference.PreferencesFragment.ReadingOrder import com.keylesspalace.tusky.components.systemnotifications.NotificationChannelData import com.keylesspalace.tusky.components.systemnotifications.toTypes import com.keylesspalace.tusky.components.timeline.LoadMorePlaceholder import com.keylesspalace.tusky.components.timeline.toEntity import com.keylesspalace.tusky.components.timeline.util.ifExpected import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AppDatabase import com.keylesspalace.tusky.db.entity.NotificationPolicyEntity import com.keylesspalace.tusky.entity.Filter import com.keylesspalace.tusky.entity.Notification 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.NotificationPolicyUsecase import com.keylesspalace.tusky.usecase.TimelineCases import com.keylesspalace.tusky.viewdata.NotificationViewData import com.keylesspalace.tusky.viewdata.StatusViewData import com.keylesspalace.tusky.viewdata.TranslationViewData import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import retrofit2.HttpException @HiltViewModel class NotificationsViewModel @Inject constructor( private val timelineCases: TimelineCases, private val api: MastodonApi, eventHub: EventHub, private val accountManager: AccountManager, private val preferences: SharedPreferences, private val filterModel: FilterModel, private val db: AppDatabase, private val notificationPolicyUsecase: NotificationPolicyUsecase ) : ViewModel() { val activeAccountFlow = accountManager.activeAccount(viewModelScope) private val accountId: Long = activeAccountFlow.value!!.id private val refreshTrigger = MutableStateFlow(0L) val excludes: StateFlow> = activeAccountFlow .map { account -> account?.notificationsFilter.orEmpty() } .stateIn(viewModelScope, SharingStarted.Eagerly, activeAccountFlow.value?.notificationsFilter.orEmpty()) /** Map from notification id to translation. */ private val translations = MutableStateFlow(mapOf()) private var remoteMediator = NotificationsRemoteMediator(this, accountManager, api, db) private var readingOrder: ReadingOrder = ReadingOrder.from(preferences.getString(PrefKeys.READING_ORDER, null)) @OptIn(ExperimentalPagingApi::class, ExperimentalCoroutinesApi::class) val notifications = refreshTrigger.flatMapLatest { Pager( config = PagingConfig( pageSize = LOAD_AT_ONCE ), remoteMediator = remoteMediator, pagingSourceFactory = { db.notificationsDao().getNotifications(accountId) } ).flow .cachedIn(viewModelScope) .combine(translations) { pagingData, translations -> pagingData.map { notification -> val translation = translations[notification.status?.serverId] notification.toViewData(translation = translation) }.filter { notificationViewData -> shouldFilterStatus(notificationViewData)?.action != Filter.Action.HIDE } } } .flowOn(Dispatchers.Default) val notificationPolicy: Flow = notificationPolicyUsecase.info init { viewModelScope.launch { eventHub.events.collect { event -> if (event is PreferenceChangedEvent) { onPreferenceChanged(event.preferenceKey) } if (event is FilterUpdatedEvent && event.filterContext.contains(Filter.Kind.NOTIFICATIONS)) { filterModel.init(Filter.Kind.NOTIFICATIONS) refreshTrigger.value += 1 } } } viewModelScope.launch { val needsRefresh = filterModel.init(Filter.Kind.NOTIFICATIONS) if (needsRefresh) { refreshTrigger.value++ } } loadNotificationPolicy() } fun loadNotificationPolicy() { viewModelScope.launch { notificationPolicyUsecase.getNotificationPolicy() } } fun updateNotificationFilters(newFilters: Set) { val account = activeAccountFlow.value if (newFilters != excludes.value && account != null) { viewModelScope.launch { accountManager.updateAccount(account) { copy(notificationsFilter = newFilters) } db.notificationsDao().cleanupNotifications(accountId, 0) refreshTrigger.value++ } } } private fun shouldFilterStatus(notificationViewData: NotificationViewData): Filter? { return when ((notificationViewData as? NotificationViewData.Concrete)?.type) { Notification.Type.Mention, Notification.Type.Poll, Notification.Type.Status, Notification.Type.Update -> { val account = activeAccountFlow.value notificationViewData.statusViewData?.let { statusViewData -> if (statusViewData.status.account.id == account?.accountId) { return null } statusViewData.filter = filterModel.shouldFilterStatus(statusViewData.actionable) return statusViewData.filter } null } else -> null } } fun respondToFollowRequest(accept: Boolean, accountIdRequestingFollow: String, notificationId: String) { viewModelScope.launch { if (accept) { api.authorizeFollowRequest(accountIdRequestingFollow) } else { api.rejectFollowRequest(accountIdRequestingFollow) }.fold( onSuccess = { // since the follow request has been responded, the notification can be deleted. The Ui will update automatically. db.notificationsDao().delete(accountId, notificationId) if (accept) { // refresh the notifications so the new follow notification will be loaded refreshTrigger.value++ } }, onFailure = { t -> Log.e(TAG, "Failed to to respond to follow request from account id $accountIdRequestingFollow.", t) } ) } } fun reblog(reblog: Boolean, status: StatusViewData.Concrete, visibility: Status.Visibility = Status.Visibility.PUBLIC): Job = viewModelScope.launch { timelineCases.reblog(status.actionableId, reblog, visibility).onFailure { t -> ifExpected(t) { Log.w(TAG, "Failed to reblog status " + status.actionableId, t) } } } fun favorite(favorite: Boolean, status: StatusViewData.Concrete): Job = viewModelScope.launch { timelineCases.favourite(status.actionableId, favorite).onFailure { t -> ifExpected(t) { Log.d(TAG, "Failed to favourite status " + status.actionableId, t) } } } fun bookmark(bookmark: Boolean, status: StatusViewData.Concrete): Job = viewModelScope.launch { timelineCases.bookmark(status.actionableId, bookmark).onFailure { t -> ifExpected(t) { Log.d(TAG, "Failed to bookmark status " + status.actionableId, t) } } } fun voteInPoll(choices: List, status: StatusViewData.Concrete) = viewModelScope.launch { val poll = status.status.actionableStatus.poll ?: run { Log.d(TAG, "No poll on status ${status.id}") return@launch } timelineCases.voteInPoll(status.actionableId, poll.id, choices).onFailure { t -> ifExpected(t) { Log.d(TAG, "Failed to vote in poll: " + status.actionableId, t) } } } fun showPollResults(status: StatusViewData.Concrete) = viewModelScope.launch { timelineCases.showPollResults(status.actionableId) } fun changeExpanded(expanded: Boolean, status: StatusViewData.Concrete) { viewModelScope.launch { db.timelineStatusDao() .setExpanded(accountId, status.id, expanded) } } fun changeContentShowing(isShowing: Boolean, status: StatusViewData.Concrete) { viewModelScope.launch { db.timelineStatusDao() .setContentShowing(accountId, status.id, isShowing) } } fun changeContentCollapsed(isCollapsed: Boolean, status: StatusViewData.Concrete) { viewModelScope.launch { db.timelineStatusDao() .setContentCollapsed(accountId, status.id, isCollapsed) } } fun remove(notificationId: String) { viewModelScope.launch { db.notificationsDao().delete(accountId, notificationId) } } fun clearWarning(status: StatusViewData.Concrete) { viewModelScope.launch { db.timelineStatusDao().clearWarning(accountId, status.actionableId) } } fun clearNotifications() { viewModelScope.launch { api.clearNotifications().fold( { db.notificationsDao().cleanupNotifications(accountId, 0) }, { t -> Log.w(TAG, "failed to clear notifications", t) } ) } } suspend fun translate(status: StatusViewData.Concrete): NetworkResult { translations.value += (status.id to TranslationViewData.Loading) return timelineCases.translate(status.actionableId) .map { translation -> translations.value += (status.id to TranslationViewData.Loaded(translation)) } .onFailure { translations.value -= status.id } } fun untranslate(status: StatusViewData.Concrete) { translations.value -= status.id } fun loadMore(placeholderId: String) { viewModelScope.launch { try { val notificationsDao = db.notificationsDao() notificationsDao.insertNotification( LoadMorePlaceholder(placeholderId, loading = true).toNotificationEntity( accountId ) ) val (idAbovePlaceholder, idBelowPlaceholder) = db.withTransaction { notificationsDao.getIdAbove(accountId, placeholderId) to notificationsDao.getIdBelow(accountId, placeholderId) } val response = when (readingOrder) { // Using minId, loads up to LOAD_AT_ONCE statuses with IDs immediately // after minId and no larger than maxId ReadingOrder.OLDEST_FIRST -> api.notifications( maxId = idAbovePlaceholder, minId = idBelowPlaceholder, limit = TimelineViewModel.LOAD_AT_ONCE, excludes = excludes.value.toTypes() ) // Using sinceId, loads up to LOAD_AT_ONCE statuses immediately before // maxId, and no smaller than minId. ReadingOrder.NEWEST_FIRST -> api.notifications( maxId = idAbovePlaceholder, sinceId = idBelowPlaceholder, limit = TimelineViewModel.LOAD_AT_ONCE, excludes = excludes.value.toTypes() ) } val notifications = response.body() if (!response.isSuccessful || notifications == null) { loadMoreFailed(placeholderId, HttpException(response)) return@launch } val account = activeAccountFlow.value ?: return@launch val statusDao = db.timelineStatusDao() val accountDao = db.timelineAccountDao() db.withTransaction { notificationsDao.delete(accountId, placeholderId) val overlappedNotifications = if (notifications.isNotEmpty()) { notificationsDao.deleteRange( accountId, notifications.last().id, notifications.first().id ) } else { 0 } for (notification in notifications) { accountDao.insert(notification.account.toEntity(accountId)) notification.report?.let { report -> accountDao.insert(report.targetAccount.toEntity(accountId)) notificationsDao.insertReport(report.toEntity(accountId)) } notification.status?.let { status -> val statusToInsert = status.reblog ?: status accountDao.insert(statusToInsert.account.toEntity(accountId)) statusDao.insert( statusToInsert.toEntity( tuskyAccountId = accountId, expanded = account.alwaysOpenSpoiler, contentShowing = account.alwaysShowSensitiveMedia || !status.sensitive, contentCollapsed = true ) ) } notificationsDao.insertNotification( notification.toEntity( accountId ) ) } /* In case we loaded a whole page and there was no overlap with existing notifications, we insert a placeholder because there might be even more unknown notifications */ if (overlappedNotifications == 0 && notifications.size == TimelineViewModel.LOAD_AT_ONCE) { /* This overrides the first/last of the newly loaded notifications with a placeholder to guarantee the placeholder has an id that exists on the server as not all servers handle client generated ids as expected */ val idToConvert = when (readingOrder) { ReadingOrder.OLDEST_FIRST -> notifications.first().id ReadingOrder.NEWEST_FIRST -> notifications.last().id } notificationsDao.insertNotification( LoadMorePlaceholder( idToConvert, loading = false ).toNotificationEntity(accountId) ) } } } catch (e: Exception) { ifExpected(e) { loadMoreFailed(placeholderId, e) } } } } private suspend fun loadMoreFailed(placeholderId: String, e: Exception) { Log.w(TAG, "failed loading notifications", e) val activeAccount = accountManager.activeAccount!! db.notificationsDao() .insertNotification( LoadMorePlaceholder(placeholderId, loading = false).toNotificationEntity(activeAccount.id) ) } private fun onPreferenceChanged(key: String) { when (key) { PrefKeys.READING_ORDER -> { readingOrder = ReadingOrder.from( preferences.getString(PrefKeys.READING_ORDER, null) ) } } } companion object { private const val LOAD_AT_ONCE = 30 private const val TAG = "NotificationsViewModel" } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/notifications/ReportNotificationViewHolder.kt ================================================ /* 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.components.notifications import android.content.Context import android.text.TextUtils import androidx.recyclerview.widget.RecyclerView import com.keylesspalace.tusky.R import com.keylesspalace.tusky.databinding.ItemReportNotificationBinding import com.keylesspalace.tusky.interfaces.AccountActionListener import com.keylesspalace.tusky.util.StatusDisplayOptions import com.keylesspalace.tusky.util.getRelativeTimeSpanString import com.keylesspalace.tusky.util.loadAvatar import com.keylesspalace.tusky.util.unicodeWrap import com.keylesspalace.tusky.util.updateEmojiTargets import com.keylesspalace.tusky.viewdata.NotificationViewData class ReportNotificationViewHolder( private val binding: ItemReportNotificationBinding, private val listener: NotificationActionListener, private val accountActionListener: AccountActionListener ) : RecyclerView.ViewHolder(binding.root), NotificationsViewHolder { override fun bind( viewData: NotificationViewData.Concrete, payloads: List<*>, statusDisplayOptions: StatusDisplayOptions ) { if (payloads.isNotEmpty()) { return } val report = viewData.report!! val reporter = viewData.account binding.notificationTopText.updateEmojiTargets { val reporterName = reporter.name.unicodeWrap().emojify(reporter.emojis, statusDisplayOptions.animateEmojis) val reporteeName = report.targetAccount.name.unicodeWrap().emojify(report.targetAccount.emojis, statusDisplayOptions.animateEmojis) // Context.getString() returns a String and doesn't support Spannable. // Convert the placeholders to the format used by TextUtils.expandTemplate which does. val topText = view.context.getString(R.string.notification_header_report_format, "^1", "^2") view.text = TextUtils.expandTemplate(topText, 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) loadAvatar( report.targetAccount.avatar, binding.notificationReporteeAvatar, itemView.context.resources.getDimensionPixelSize(R.dimen.avatar_radius_36dp), statusDisplayOptions.animateAvatars, ) loadAvatar( reporter.avatar, binding.notificationReporterAvatar, itemView.context.resources.getDimensionPixelSize(R.dimen.avatar_radius_24dp), statusDisplayOptions.animateAvatars, ) binding.notificationReporteeAvatar.setOnClickListener { val position = bindingAdapterPosition if (position != RecyclerView.NO_POSITION) { accountActionListener.onViewAccount(report.targetAccount.id) } } binding.notificationReporterAvatar.setOnClickListener { val position = bindingAdapterPosition if (position != RecyclerView.NO_POSITION) { accountActionListener.onViewAccount(reporter.id) } } itemView.setOnClickListener { listener.onViewReport(report.id) } } private fun getTranslatedCategory(context: Context, rawCategory: String): String { return when (rawCategory) { "violation" -> context.getString(R.string.report_category_violation) "spam" -> context.getString(R.string.report_category_spam) "legal" -> context.getString(R.string.report_category_legal) "other" -> context.getString(R.string.report_category_other) else -> rawCategory } } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/notifications/SeveredRelationshipNotificationViewHolder.kt ================================================ /* Copyright 2025 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.components.systemnotifications.NotificationService import com.keylesspalace.tusky.databinding.ItemSeveredRelationshipNotificationBinding import com.keylesspalace.tusky.util.StatusDisplayOptions import com.keylesspalace.tusky.viewdata.NotificationViewData class SeveredRelationshipNotificationViewHolder( private val binding: ItemSeveredRelationshipNotificationBinding, private val instanceName: String ) : RecyclerView.ViewHolder(binding.root), NotificationsViewHolder { override fun bind( viewData: NotificationViewData.Concrete, payloads: List<*>, statusDisplayOptions: StatusDisplayOptions ) { if (payloads.isNotEmpty()) { return } val event = viewData.event!! val context = binding.root.context binding.severedRelationshipText.text = NotificationService.severedRelationShipText( context, event, instanceName ) } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/notifications/StatusNotificationViewHolder.kt ================================================ /* * 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.annotation.SuppressLint import android.content.res.ColorStateList import android.graphics.Typeface import android.text.InputFilter import android.text.Spanned import android.text.format.DateUtils import android.text.style.StyleSpan import android.view.View import androidx.core.text.toSpannable 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.entity.Status 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.hide import com.keylesspalace.tusky.util.loadAvatar 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 import com.keylesspalace.tusky.viewdata.StatusViewData import java.util.Date internal class StatusNotificationViewHolder( private val binding: ItemStatusNotificationBinding, private val statusActionListener: StatusActionListener, private val absoluteTimeFormatter: AbsoluteTimeFormatter ) : NotificationsViewHolder, 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.Concrete, payloads: List<*>, statusDisplayOptions: StatusDisplayOptions ) { val statusViewData = viewData.statusViewData if (payloads.isEmpty()) { /* in some very rare cases servers sends null status even though they should not */ if (statusViewData == null) { showNotificationContent(false) } else { showNotificationContent(true) val account = statusViewData.actionable.account val createdAt = statusViewData.actionable.createdAt 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 ) } val viewThreadListener = View.OnClickListener { val position = bindingAdapterPosition if (position != RecyclerView.NO_POSITION) { statusActionListener.onViewThread(position) } } binding.notificationContainer.setOnClickListener(viewThreadListener) binding.notificationContent.setOnClickListener(viewThreadListener) binding.notificationTopText.setOnClickListener { statusActionListener.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.visible(show) binding.statusUsername.visible(show) binding.statusMetaInfo.visible(show) binding.notificationContentWarningDescription.visible(show) binding.notificationContentWarningButton.visible(show) binding.notificationContent.visible(show) binding.notificationStatusAvatar.visible(show) binding.notificationNotificationAvatar.visible(show) binding.notificationAttachmentInfo.visible(show) } 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 { val readout: String // visible timestamp val readoutAloud: CharSequence // for screenreaders so they don't mispronounce timestamps like "17m" as 17 meters val then = createdAt.time val now = System.currentTimeMillis() readout = getRelativeTimeSpanString(binding.statusMetaInfo.context, then, now) readoutAloud = DateUtils.getRelativeTimeSpanString( then, now, DateUtils.SECOND_IN_MILLIS, DateUtils.FORMAT_ABBREV_RELATIVE ) binding.statusMetaInfo.text = readout binding.statusMetaInfo.contentDescription = readoutAloud } } 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 ) } @SuppressLint("UseCompatTextViewDrawableApis") fun setMessage( notificationViewData: NotificationViewData.Concrete, 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: Int val iconColor: Int when (type) { Notification.Type.Favourite -> { icon = R.drawable.ic_star_24dp_filled iconColor = R.color.favoriteButtonActiveColor format = context.getString(R.string.notification_favourite_format) } Notification.Type.Reblog -> { icon = R.drawable.ic_repeat_24dp iconColor = R.color.colorPrimary format = context.getString(R.string.notification_reblog_format) } Notification.Type.Status -> { icon = R.drawable.ic_notifications_active_24dp iconColor = R.color.colorPrimary format = context.getString(R.string.notification_subscription_format) } Notification.Type.Update -> { icon = R.drawable.ic_edit_24dp_filled iconColor = R.color.colorPrimary format = context.getString(R.string.notification_update_format) } else -> { icon = R.drawable.ic_star_24dp_filled iconColor = R.color.favoriteButtonActiveColor format = context.getString(R.string.notification_favourite_format) } } binding.notificationTopText.setCompoundDrawablesRelativeWithIntrinsicBounds(icon, 0, 0, 0) binding.notificationTopText.compoundDrawableTintList = ColorStateList.valueOf(context.getColor(iconColor)) val wholeMessage = String.format(format, displayName).toSpannable() val displayNameIndex = format.indexOf("%1\$s") wholeMessage.setSpan( StyleSpan(Typeface.BOLD), displayNameIndex, displayNameIndex + displayName.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE ) val emojifiedText = wholeMessage.emojify( notificationViewData.account.emojis, binding.notificationTopText, animateEmojis ) binding.notificationTopText.text = emojifiedText if (statusViewData != null) { val hasSpoiler = statusViewData.status.spoilerText.isNotEmpty() 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) { statusActionListener.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 = statusViewData.status.spoilerText.isNotEmpty() 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) { statusActionListener.onContentCollapsedChange( !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 binding.notificationAttachmentInfo.hide() } else { binding.buttonToggleNotificationContent.setText( R.string.post_content_warning_show_less ) binding.notificationContent.filters = NO_INPUT_FILTER setupAttachmentInfo(statusViewData.status) } } else { binding.buttonToggleNotificationContent.visibility = View.GONE binding.notificationContent.filters = NO_INPUT_FILTER setupAttachmentInfo(statusViewData.status) } val emojifiedText = content.emojify( emojis = emojis, view = binding.notificationContent, animate = animateEmojis ) setClickableText( binding.notificationContent, emojifiedText, statusViewData.actionable.mentions, statusViewData.actionable.tags, listener, ) val emojifiedContentWarning: CharSequence = statusViewData.status.spoilerText.emojify( statusViewData.actionable.emojis, binding.notificationContentWarningDescription, animateEmojis ) binding.notificationContentWarningDescription.text = emojifiedContentWarning } private fun setupAttachmentInfo(status: Status) { if (status.attachments.isNotEmpty()) { binding.notificationAttachmentInfo.show() binding.notificationAttachmentInfo.setCompoundDrawablesRelativeWithIntrinsicBounds(R.drawable.ic_attach_file_24dp, 0, 0, 0) val attachmentCount = status.attachments.size val attachmentText = binding.root.context.resources.getQuantityString(R.plurals.media_attachments, attachmentCount, attachmentCount) binding.notificationAttachmentInfo.text = attachmentText } else if (status.poll != null) { binding.notificationAttachmentInfo.show() binding.notificationAttachmentInfo.setCompoundDrawablesRelativeWithIntrinsicBounds(R.drawable.ic_insert_chart_24dp, 0, 0, 0) binding.notificationAttachmentInfo.setText(R.string.poll) } else { binding.notificationAttachmentInfo.hide() } } companion object { private val COLLAPSE_INPUT_FILTER: Array = arrayOf(SmartLengthInputFilter) private val NO_INPUT_FILTER: Array = arrayOf() } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/notifications/StatusViewHolder.kt ================================================ /* * 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.View import com.keylesspalace.tusky.R import com.keylesspalace.tusky.adapter.StatusViewHolder import com.keylesspalace.tusky.entity.Notification import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.interfaces.StatusActionListener import com.keylesspalace.tusky.util.StatusDisplayOptions import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.viewdata.NotificationViewData internal class StatusViewHolder( itemView: View, private val statusActionListener: StatusActionListener, private val accountId: String ) : NotificationsViewHolder, StatusViewHolder(itemView) { override fun bind( viewData: NotificationViewData.Concrete, payloads: List<*>, statusDisplayOptions: StatusDisplayOptions ) { val statusViewData = viewData.statusViewData if (statusViewData == null) { /* in some very rare cases servers sends null status even though they should not */ showStatusContent(false) } else { if (payloads.isEmpty()) { showStatusContent(true) } setupWithStatus( statusViewData, statusActionListener, statusDisplayOptions, payloads, false ) if (payloads.isNotEmpty()) { return } val res = itemView.resources if (viewData.type == Notification.Type.Poll) { statusInfo.setText(if (accountId == viewData.account.id) R.string.poll_ended_created else R.string.poll_ended_voted) statusInfo.setCompoundDrawablesRelativeWithIntrinsicBounds(R.drawable.ic_insert_chart_24dp_filled, 0, 0, 0) statusInfo.setCompoundDrawablePadding(res.getDimensionPixelSize(R.dimen.status_info_drawable_padding_large)) statusInfo.setPadding(res.getDimensionPixelSize(R.dimen.status_info_padding_large), 0, 0, 0) statusInfo.show() } else if (viewData.type == Notification.Type.Mention) { statusInfo.setCompoundDrawablePadding(res.getDimensionPixelSize(R.dimen.status_info_drawable_padding_small)) statusInfo.setPaddingRelative(res.getDimensionPixelSize(R.dimen.status_info_padding_small), 0, 0, 0) statusInfo.show() if (viewData.statusViewData.status.inReplyToAccountId == accountId) { statusInfo.setCompoundDrawablesRelativeWithIntrinsicBounds(R.drawable.ic_reply_18dp, 0, 0, 0) if (viewData.statusViewData.status.visibility == Status.Visibility.DIRECT) { statusInfo.setText(R.string.notification_info_private_reply) } else { statusInfo.setText(R.string.notification_info_reply) } } else { statusInfo.setCompoundDrawablesRelativeWithIntrinsicBounds(R.drawable.ic_email_alternate_18dp, 0, 0, 0) if (viewData.statusViewData.status.visibility == Status.Visibility.DIRECT) { statusInfo.setText(R.string.notification_info_private_mention) } else { statusInfo.setText(R.string.notification_info_mention) } } } else { hideStatusInfo() } } } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/notifications/UnknownNotificationViewHolder.kt ================================================ /* * 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.google.android.material.dialog.MaterialAlertDialogBuilder import com.keylesspalace.tusky.R import com.keylesspalace.tusky.databinding.ItemUnknownNotificationBinding import com.keylesspalace.tusky.util.StatusDisplayOptions import com.keylesspalace.tusky.viewdata.NotificationViewData internal class UnknownNotificationViewHolder( private val binding: ItemUnknownNotificationBinding, ) : NotificationsViewHolder, RecyclerView.ViewHolder(binding.root) { override fun bind( viewData: NotificationViewData.Concrete, payloads: List<*>, statusDisplayOptions: StatusDisplayOptions ) { binding.unknownNotificationType.text = viewData.type.name binding.root.setOnClickListener { MaterialAlertDialogBuilder(binding.root.context) .setMessage(R.string.unknown_notification_type_explanation) .setPositiveButton(android.R.string.ok, null) .show() } } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/notifications/requests/NotificationRequestsActivity.kt ================================================ /* 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.components.notifications.requests import android.content.Context import android.content.Intent import android.os.Bundle import android.util.Log import android.view.Menu import android.view.MenuInflater import android.view.MenuItem import androidx.activity.result.contract.ActivityResultContract import androidx.activity.viewModels import androidx.core.view.MenuProvider import androidx.lifecycle.lifecycleScope import androidx.paging.LoadState import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.SimpleItemAnimator import com.google.android.material.snackbar.BaseTransientBottomBar.LENGTH_LONG import com.google.android.material.snackbar.Snackbar import com.keylesspalace.tusky.BaseActivity import com.keylesspalace.tusky.R import com.keylesspalace.tusky.components.notifications.requests.details.NotificationRequestDetailsActivity import com.keylesspalace.tusky.components.preference.notificationpolicies.NotificationPoliciesActivity import com.keylesspalace.tusky.databinding.ActivityNotificationRequestsBinding import com.keylesspalace.tusky.entity.Emoji import com.keylesspalace.tusky.entity.NotificationRequest import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.util.getErrorString 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 dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch @AndroidEntryPoint class NotificationRequestsActivity : BaseActivity(), MenuProvider { private val viewModel: NotificationRequestsViewModel by viewModels() private val binding by viewBinding(ActivityNotificationRequestsBinding::inflate) private val notificationRequestDetails = registerForActivityResult(NotificationRequestDetailsResultContract()) { id -> if (id != null) { viewModel.removeNotificationRequest(id) } } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(binding.root) addMenuProvider(this) setSupportActionBar(binding.includedToolbar.toolbar) supportActionBar?.run { setTitle(R.string.filtered_notifications_title) setDisplayHomeAsUpEnabled(true) setDisplayShowHomeEnabled(true) } setupAdapter().let { adapter -> setupRecyclerView(adapter) lifecycleScope.launch { viewModel.pager.collectLatest { pagingData -> adapter.submitData(pagingData) } } } lifecycleScope.launch { viewModel.error.collect { error -> Snackbar.make( binding.root, error.getErrorString(this@NotificationRequestsActivity), LENGTH_LONG ).show() } } } private fun setupRecyclerView(adapter: NotificationRequestsAdapter) { binding.notificationRequestsView.adapter = adapter binding.notificationRequestsView.setHasFixedSize(true) binding.notificationRequestsView.layoutManager = LinearLayoutManager(this) binding.notificationRequestsView.addItemDecoration( DividerItemDecoration(this, DividerItemDecoration.VERTICAL) ) (binding.notificationRequestsView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false } private fun setupAdapter(): NotificationRequestsAdapter { return NotificationRequestsAdapter( onAcceptRequest = viewModel::acceptNotificationRequest, onDismissRequest = viewModel::dismissNotificationRequest, onOpenDetails = ::onOpenRequestDetails, animateAvatar = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false), animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false) ).apply { addLoadStateListener { loadState -> binding.notificationRequestsProgressBar.visible( loadState.refresh == LoadState.Loading && itemCount == 0 ) if (loadState.refresh is LoadState.Error) { binding.notificationRequestsView.hide() binding.notificationRequestsMessageView.show() val errorState = loadState.refresh as LoadState.Error binding.notificationRequestsMessageView.setup(errorState.error) { retry() } Log.w(TAG, "error loading notification requests", errorState.error) } else { binding.notificationRequestsView.show() binding.notificationRequestsMessageView.hide() } } } } override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { menuInflater.inflate(R.menu.activity_notification_requests, menu) } override fun onMenuItemSelected(menuItem: MenuItem): Boolean { return when (menuItem.itemId) { R.id.open_settings -> { val intent = NotificationPoliciesActivity.newIntent(this) startActivityWithSlideInAnimation(intent) true } else -> false } } private fun onOpenRequestDetails(reqeuest: NotificationRequest) { notificationRequestDetails.launch( NotificationRequestDetailsResultContractInput( notificationRequestId = reqeuest.id, accountId = reqeuest.account.id, accountName = reqeuest.account.name, accountEmojis = reqeuest.account.emojis ) ) } class NotificationRequestDetailsResultContractInput( val notificationRequestId: String, val accountId: String, val accountName: String, val accountEmojis: List ) class NotificationRequestDetailsResultContract : ActivityResultContract() { override fun createIntent(context: Context, input: NotificationRequestDetailsResultContractInput): Intent { return NotificationRequestDetailsActivity.newIntent( notificationRequestId = input.notificationRequestId, accountId = input.accountId, accountName = input.accountName, accountEmojis = input.accountEmojis, context = context ) } override fun parseResult(resultCode: Int, intent: Intent?): String? { return intent?.getStringExtra(NotificationRequestDetailsActivity.EXTRA_NOTIFICATION_REQUEST_ID) } } companion object { private const val TAG = "NotificationRequestsActivity" fun newIntent(context: Context) = Intent(context, NotificationRequestsActivity::class.java) } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/notifications/requests/NotificationRequestsAdapter.kt ================================================ /* 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.components.notifications.requests import android.view.LayoutInflater import android.view.ViewGroup import androidx.annotation.OptIn import androidx.paging.PagingDataAdapter import androidx.recyclerview.widget.DiffUtil import com.google.android.material.badge.ExperimentalBadgeUtils import com.keylesspalace.tusky.R import com.keylesspalace.tusky.databinding.ItemNotificationRequestBinding import com.keylesspalace.tusky.entity.NotificationRequest import com.keylesspalace.tusky.util.BindingHolder import com.keylesspalace.tusky.util.emojify import com.keylesspalace.tusky.util.loadAvatar import java.text.NumberFormat class NotificationRequestsAdapter( private val onAcceptRequest: (notificationRequestId: String) -> Unit, private val onDismissRequest: (notificationRequestId: String) -> Unit, private val onOpenDetails: (notificationRequest: NotificationRequest) -> Unit, private val animateAvatar: Boolean, private val animateEmojis: Boolean, ) : PagingDataAdapter>(NOTIFICATION_REQUEST_COMPARATOR) { private val numberFormat: NumberFormat = NumberFormat.getNumberInstance() override fun onCreateViewHolder( parent: ViewGroup, viewType: Int ): BindingHolder { val binding = ItemNotificationRequestBinding.inflate( LayoutInflater.from(parent.context), parent, false ) return BindingHolder(binding) } @OptIn(ExperimentalBadgeUtils::class) override fun onBindViewHolder(holder: BindingHolder, position: Int) { getItem(position)?.let { notificationRequest -> val binding = holder.binding val context = binding.root.context val account = notificationRequest.account val avatarRadius = context.resources.getDimensionPixelSize(R.dimen.avatar_radius_48dp) loadAvatar(account.avatar, binding.notificationRequestAvatar, avatarRadius, animateAvatar) binding.notificationRequestBadge.text = numberFormat.format(notificationRequest.notificationsCount) val emojifiedName = account.name.emojify( account.emojis, binding.notificationRequestDisplayName, animateEmojis ) binding.notificationRequestDisplayName.text = emojifiedName val formattedUsername = context.getString(R.string.post_username_format, account.username) binding.notificationRequestUsername.text = formattedUsername binding.notificationRequestAccept.setOnClickListener { onAcceptRequest(notificationRequest.id) } binding.notificationRequestDismiss.setOnClickListener { onDismissRequest(notificationRequest.id) } binding.root.setOnClickListener { onOpenDetails(notificationRequest) } } } companion object { val NOTIFICATION_REQUEST_COMPARATOR = object : DiffUtil.ItemCallback() { override fun areItemsTheSame(oldItem: NotificationRequest, newItem: NotificationRequest): Boolean = oldItem.id == newItem.id override fun areContentsTheSame(oldItem: NotificationRequest, newItem: NotificationRequest): Boolean = oldItem == newItem } } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/notifications/requests/NotificationRequestsPagingSource.kt ================================================ /* 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.components.notifications.requests import androidx.paging.PagingSource import androidx.paging.PagingState import com.keylesspalace.tusky.entity.NotificationRequest class NotificationRequestsPagingSource( private val requests: 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(requests.toList(), null, nextKey) } else { LoadResult.Page(emptyList(), null, null) } } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/notifications/requests/NotificationRequestsRemoteMediator.kt ================================================ /* 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.components.notifications.requests import androidx.paging.ExperimentalPagingApi import androidx.paging.LoadType import androidx.paging.PagingState import androidx.paging.RemoteMediator import com.keylesspalace.tusky.entity.NotificationRequest import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.util.HttpHeaderLink import retrofit2.HttpException import retrofit2.Response @OptIn(ExperimentalPagingApi::class) class NotificationRequestsRemoteMediator( private val api: MastodonApi, private val viewModel: NotificationRequestsViewModel ) : 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.getNotificationRequests(maxId = viewModel.nextKey) LoadType.REFRESH -> { viewModel.nextKey = null viewModel.requestData.clear() api.getNotificationRequests() } } } private fun applyResponse(response: Response>): MediatorResult { val notificationRequests = response.body() if (!response.isSuccessful || notificationRequests == null) { return MediatorResult.Error(HttpException(response)) } val links = HttpHeaderLink.parse(response.headers()["Link"]) viewModel.nextKey = HttpHeaderLink.findByRelationType(links, "next")?.uri?.getQueryParameter("max_id") viewModel.requestData.addAll(notificationRequests) viewModel.currentSource?.invalidate() return MediatorResult.Success(endOfPaginationReached = viewModel.nextKey == null) } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/notifications/requests/NotificationRequestsViewModel.kt ================================================ /* 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.components.notifications.requests import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.paging.ExperimentalPagingApi import androidx.paging.Pager import androidx.paging.PagingConfig import androidx.paging.cachedIn import at.connyduck.calladapter.networkresult.fold import com.keylesspalace.tusky.appstore.BlockEvent import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.MuteEvent import com.keylesspalace.tusky.entity.NotificationRequest import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.usecase.NotificationPolicyUsecase import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.launch @HiltViewModel class NotificationRequestsViewModel @Inject constructor( private val api: MastodonApi, private val eventHub: EventHub, private val notificationPolicyUsecase: NotificationPolicyUsecase ) : ViewModel() { var currentSource: NotificationRequestsPagingSource? = null val requestData: MutableList = mutableListOf() var nextKey: String? = null @OptIn(ExperimentalPagingApi::class) val pager = Pager( config = PagingConfig( pageSize = 20, initialLoadSize = 20 ), remoteMediator = NotificationRequestsRemoteMediator(api, this), pagingSourceFactory = { NotificationRequestsPagingSource( requests = requestData, nextKey = nextKey ).also { source -> currentSource = source } } ).flow .cachedIn(viewModelScope) private val _error = MutableSharedFlow( replay = 0, extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST ) val error: SharedFlow = _error.asSharedFlow() init { viewModelScope.launch { eventHub.events .collect { event -> when (event) { is BlockEvent -> removeAllByAccount(event.accountId) is MuteEvent -> removeAllByAccount(event.accountId) } } } } fun acceptNotificationRequest(id: String) { viewModelScope.launch { api.acceptNotificationRequest(id).fold({ removeNotificationRequest(id) }, { error -> Log.w(TAG, "failed to dismiss notifications request", error) _error.emit(error) }) } } fun dismissNotificationRequest(id: String) { viewModelScope.launch { api.dismissNotificationRequest(id).fold({ removeNotificationRequest(id) }, { error -> Log.w(TAG, "failed to dismiss notifications request", error) _error.emit(error) }) } } fun removeNotificationRequest(id: String) { requestData.forEach { request -> if (request.id == id) { viewModelScope.launch { notificationPolicyUsecase.updateCounts(request.notificationsCount) } } } requestData.removeAll { request -> request.id == id } currentSource?.invalidate() } private fun removeAllByAccount(accountId: String) { requestData.removeAll { request -> request.account.id == accountId } currentSource?.invalidate() } companion object { private const val TAG = "NotificationRequestsViewModel" } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/notifications/requests/details/NotificationRequestDetailsActivity.kt ================================================ /* 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.components.notifications.requests.details import android.content.Context import android.content.Intent import android.os.Bundle import androidx.activity.viewModels import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat.Type.systemBars import androidx.core.view.updatePadding import androidx.lifecycle.lifecycleScope import com.keylesspalace.tusky.BottomSheetActivity import com.keylesspalace.tusky.R import com.keylesspalace.tusky.databinding.ActivityNotificationRequestDetailsBinding import com.keylesspalace.tusky.entity.Emoji import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.util.emojify import com.keylesspalace.tusky.util.getParcelableArrayListExtraCompat import com.keylesspalace.tusky.util.viewBinding import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.lifecycle.withCreationCallback import kotlin.getValue import kotlinx.coroutines.launch @AndroidEntryPoint class NotificationRequestDetailsActivity : BottomSheetActivity() { private val viewModel: NotificationRequestDetailsViewModel by viewModels( extrasProducer = { defaultViewModelCreationExtras.withCreationCallback { factory -> factory.create( notificationRequestId = intent.getStringExtra(EXTRA_NOTIFICATION_REQUEST_ID)!!, accountId = intent.getStringExtra(EXTRA_ACCOUNT_ID)!! ) } } ) private val binding by viewBinding(ActivityNotificationRequestDetailsBinding::inflate) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(binding.root) setSupportActionBar(binding.includedToolbar.toolbar) val animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false) val emojis: List = intent.getParcelableArrayListExtraCompat(EXTRA_ACCOUNT_EMOJIS)!! val title = getString(R.string.notifications_from, intent.getStringExtra(EXTRA_ACCOUNT_NAME)) .emojify(emojis, binding.includedToolbar.toolbar, animateEmojis) supportActionBar?.run { setTitle(title) setDisplayHomeAsUpEnabled(true) setDisplayShowHomeEnabled(true) } ViewCompat.setOnApplyWindowInsetsListener(binding.bottomBar) { view, insets -> val bottomInsets = insets.getInsets(systemBars()).bottom view.updatePadding(bottom = bottomInsets) insets.inset(0, 0, 0, bottomInsets) } lifecycleScope.launch { viewModel.finish.collect { finishMode -> setResult(RESULT_OK, Intent().apply { putExtra(EXTRA_NOTIFICATION_REQUEST_ID, intent.getStringExtra(EXTRA_NOTIFICATION_REQUEST_ID)!!) }) finish() } } binding.acceptButton.setOnClickListener { viewModel.acceptNotificationRequest() } binding.dismissButton.setOnClickListener { viewModel.dismissNotificationRequest() } } companion object { const val EXTRA_NOTIFICATION_REQUEST_ID = "notificationRequestId" private const val EXTRA_ACCOUNT_ID = "accountId" private const val EXTRA_ACCOUNT_NAME = "accountName" private const val EXTRA_ACCOUNT_EMOJIS = "accountEmojis" fun newIntent( notificationRequestId: String, accountId: String, accountName: String, accountEmojis: List, context: Context ) = Intent(context, NotificationRequestDetailsActivity::class.java).apply { putExtra(EXTRA_NOTIFICATION_REQUEST_ID, notificationRequestId) putExtra(EXTRA_ACCOUNT_ID, accountId) putExtra(EXTRA_ACCOUNT_NAME, accountName) putExtra(EXTRA_ACCOUNT_EMOJIS, ArrayList(accountEmojis)) } } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/notifications/requests/details/NotificationRequestDetailsFragment.kt ================================================ /* 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.components.notifications.requests.details import android.content.Context import android.content.Intent import android.content.SharedPreferences import android.os.Bundle import android.util.Log import android.view.View import androidx.fragment.app.activityViewModels import androidx.lifecycle.lifecycleScope import androidx.paging.LoadState import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.SimpleItemAnimator import at.connyduck.calladapter.networkresult.onFailure import at.connyduck.sparkbutton.SparkButton import com.google.android.material.snackbar.BaseTransientBottomBar.LENGTH_LONG import com.google.android.material.snackbar.Snackbar import com.keylesspalace.tusky.R import com.keylesspalace.tusky.components.notifications.NotificationActionListener import com.keylesspalace.tusky.components.notifications.NotificationsPagingAdapter import com.keylesspalace.tusky.databinding.FragmentNotificationRequestDetailsBinding import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.fragment.SFragment import com.keylesspalace.tusky.interfaces.AccountActionListener import com.keylesspalace.tusky.interfaces.StatusActionListener import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.util.CardViewMode import com.keylesspalace.tusky.util.StatusDisplayOptions import com.keylesspalace.tusky.util.getErrorString import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.openLink import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.util.visible import com.keylesspalace.tusky.view.ConfirmationBottomSheet.Companion.confirmFavourite import com.keylesspalace.tusky.view.ConfirmationBottomSheet.Companion.confirmReblog import com.keylesspalace.tusky.viewdata.AttachmentViewData import com.keylesspalace.tusky.viewdata.TranslationViewData import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject import kotlin.getValue import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch @AndroidEntryPoint class NotificationRequestDetailsFragment : SFragment(R.layout.fragment_notification_request_details), StatusActionListener, NotificationActionListener, AccountActionListener { @Inject lateinit var preferences: SharedPreferences private val viewModel: NotificationRequestDetailsViewModel by activityViewModels() private val binding by viewBinding(FragmentNotificationRequestDetailsBinding::bind) private var adapter: NotificationsPagingAdapter? = null private var buttonToAnimate: SparkButton? = null override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) setupAdapter().let { adapter -> this.adapter = adapter setupRecyclerView(adapter) lifecycleScope.launch { viewModel.pager.collectLatest { pagingData -> adapter.submitData(pagingData) } } } lifecycleScope.launch { viewModel.error.collect { error -> Snackbar.make( binding.root, error.getErrorString(requireContext()), LENGTH_LONG ).show() } } } private fun setupRecyclerView(adapter: NotificationsPagingAdapter) { binding.recyclerView.adapter = adapter binding.recyclerView.setHasFixedSize(true) binding.recyclerView.layoutManager = LinearLayoutManager(requireContext()) binding.recyclerView.addItemDecoration( DividerItemDecoration(requireContext(), DividerItemDecoration.VERTICAL) ) (binding.recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false } private fun setupAdapter(): NotificationsPagingAdapter { val activeAccount = accountManager.activeAccount!! val statusDisplayOptions = StatusDisplayOptions( animateAvatars = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false), mediaPreviewEnabled = activeAccount.mediaPreviewEnabled, 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 }, 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), showSensitiveMedia = activeAccount.alwaysShowSensitiveMedia, openSpoiler = activeAccount.alwaysOpenSpoiler ) return NotificationsPagingAdapter( accountId = activeAccount.accountId, statusDisplayOptions = statusDisplayOptions, statusListener = this, notificationActionListener = this, accountActionListener = this, instanceName = activeAccount.domain ).apply { addLoadStateListener { loadState -> binding.progressBar.visible( loadState.refresh == LoadState.Loading && itemCount == 0 ) if (loadState.refresh is LoadState.Error) { binding.recyclerView.hide() binding.statusView.show() val errorState = loadState.refresh as LoadState.Error binding.statusView.setup(errorState.error) { retry() } Log.w(TAG, "error loading notifications for user ${viewModel.accountId}", errorState.error) } else { binding.recyclerView.show() binding.statusView.hide() } } } } override fun onReply(position: Int) { val status = adapter?.peek(position)?.asStatusOrNull() ?: return super.reply(status.status) } override fun removeItem(position: Int) { val notification = adapter?.peek(position) ?: return viewModel.remove(notification) } override fun onReblog(reblog: Boolean, position: Int, visibility: Status.Visibility?, button: SparkButton?) { val status = adapter?.peek(position)?.asStatusOrNull() ?: return buttonToAnimate = button if (reblog && visibility == null) { confirmReblog(preferences) { visibility -> viewModel.reblog(true, status, visibility) buttonToAnimate?.playAnimation() buttonToAnimate?.isChecked = true } } else { viewModel.reblog(reblog, status, visibility ?: Status.Visibility.PUBLIC) if (reblog) { buttonToAnimate?.playAnimation() } buttonToAnimate?.isChecked = reblog } } override val onMoreTranslate: ((Boolean, Int) -> Unit)? get() = { translate: Boolean, position: Int -> if (translate) { onTranslate(position) } else { onUntranslate(position) } } override fun onFavourite(favourite: Boolean, position: Int, button: SparkButton?) { val status = adapter?.peek(position)?.asStatusOrNull() ?: return buttonToAnimate = button if (favourite) { confirmFavourite(preferences) { viewModel.favorite(true, status) buttonToAnimate?.playAnimation() buttonToAnimate?.isChecked = true } } else { viewModel.favorite(false, status) buttonToAnimate?.isChecked = false } } override fun onBookmark(bookmark: Boolean, position: Int) { val status = adapter?.peek(position)?.asStatusOrNull() ?: return viewModel.bookmark(bookmark, status) } override fun onMore(view: View, position: Int) { val status = adapter?.peek(position)?.asStatusOrNull() ?: return super.more( status.status, view, position, (status.translation as? TranslationViewData.Loaded)?.data ) } override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) { val status = adapter?.peek(position)?.asStatusOrNull() ?: return super.viewMedia(attachmentIndex, AttachmentViewData.list(status), view) } override fun onViewThread(position: Int) { val status = adapter?.peek(position)?.asStatusOrNull()?.status ?: return super.viewThread(status.id, status.url) } override fun onOpenReblog(position: Int) { val status = adapter?.peek(position)?.asStatusOrNull() ?: return super.openReblog(status.status) } override fun onExpandedChange(expanded: Boolean, position: Int) { val status = adapter?.peek(position)?.asStatusOrNull() ?: return viewModel.changeExpanded(expanded, status) } override fun onContentHiddenChange(isShowing: Boolean, position: Int) { val status = adapter?.peek(position)?.asStatusOrNull() ?: return viewModel.changeContentShowing(isShowing, status) } override fun onLoadMore(position: Int) { // not applicable here } override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) { val status = adapter?.peek(position)?.asStatusOrNull() ?: return viewModel.changeContentCollapsed(isCollapsed, status) } override fun onVoteInPoll(position: Int, choices: List) { val status = adapter?.peek(position)?.asStatusOrNull() ?: return viewModel.voteInPoll(choices, status) } override fun onShowPollResults(position: Int) { adapter?.peek(position)?.asStatusOrNull()?.let { status -> viewModel.showPollResults(status) } } override fun clearWarningAction(position: Int) { // not applicable here } private fun onTranslate(position: Int) { val status = adapter?.peek(position)?.asStatusOrNull() ?: return viewLifecycleOwner.lifecycleScope.launch { viewModel.translate(status) .onFailure { Snackbar.make( requireView(), getString(R.string.ui_error_translate, it.message), LENGTH_LONG ).show() } } } override fun onUntranslate(position: Int) { val status = adapter?.peek(position)?.asStatusOrNull() ?: return viewModel.untranslate(status) } override fun onViewTag(tag: String) { super.viewTag(tag) } override fun onViewAccount(id: String) { super.viewAccount(id) } override fun onViewReport(reportId: String) { requireContext().openLink( "https://${accountManager.activeAccount!!.domain}/admin/reports/$reportId" ) } override fun onMute(mute: Boolean, id: String, position: Int, notifications: Boolean) { // not needed, muting via the more menu on statuses is handled in SFragment } override fun onBlock(block: Boolean, id: String, position: Int) { // not needed, blocking via the more menu on statuses is handled in SFragment } override fun onRespondToFollowRequest(accept: Boolean, accountIdRequestingFollow: String, position: Int) { val notification = adapter?.peek(position) ?: return viewModel.respondToFollowRequest(accept, accountId = accountIdRequestingFollow, notification = notification) } override fun onDestroyView() { adapter = null buttonToAnimate = null super.onDestroyView() } companion object { private const val TAG = "NotificationRequestsDetailsFragment" private const val EXTRA_ACCOUNT_ID = "accountId" fun newIntent(accountId: String, context: Context) = Intent(context, NotificationRequestDetailsActivity::class.java).apply { putExtra(EXTRA_ACCOUNT_ID, accountId) } } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/notifications/requests/details/NotificationRequestDetailsPagingSource.kt ================================================ /* 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.components.notifications.requests.details import androidx.paging.PagingSource import androidx.paging.PagingState import com.keylesspalace.tusky.viewdata.NotificationViewData class NotificationRequestDetailsPagingSource( private val notifications: 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(notifications.toList(), null, nextKey) } else { LoadResult.Page(emptyList(), null, null) } } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/notifications/requests/details/NotificationRequestDetailsRemoteMediator.kt ================================================ /* 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.components.notifications.requests.details import androidx.paging.ExperimentalPagingApi import androidx.paging.LoadType import androidx.paging.PagingState import androidx.paging.RemoteMediator import com.keylesspalace.tusky.components.notifications.toViewData import com.keylesspalace.tusky.entity.Filter import com.keylesspalace.tusky.entity.Notification import com.keylesspalace.tusky.util.HttpHeaderLink import com.keylesspalace.tusky.viewdata.NotificationViewData import retrofit2.HttpException import retrofit2.Response @OptIn(ExperimentalPagingApi::class) class NotificationRequestDetailsRemoteMediator( private val viewModel: NotificationRequestDetailsViewModel ) : 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 -> viewModel.api.notifications(maxId = viewModel.nextKey, accountId = viewModel.accountId) LoadType.REFRESH -> { viewModel.nextKey = null viewModel.notificationData.clear() viewModel.api.notifications(accountId = viewModel.accountId) } } } private fun applyResponse(response: Response>): MediatorResult { val notifications = response.body() if (!response.isSuccessful || notifications == null) { return MediatorResult.Error(HttpException(response)) } val links = HttpHeaderLink.parse(response.headers()["Link"]) viewModel.nextKey = HttpHeaderLink.findByRelationType(links, "next")?.uri?.getQueryParameter("max_id") val alwaysShowSensitiveMedia = viewModel.accountManager.activeAccount?.alwaysShowSensitiveMedia == true val alwaysOpenSpoiler = viewModel.accountManager.activeAccount?.alwaysOpenSpoiler == false val notificationData = notifications.map { notification -> notification.toViewData( isShowingContent = notification.status?.shouldShowContent(alwaysShowSensitiveMedia, Filter.Kind.NOTIFICATIONS) ?: true, isExpanded = alwaysOpenSpoiler, isCollapsed = true, filter = notification.status?.getApplicableFilter(Filter.Kind.NOTIFICATIONS), ) } viewModel.notificationData.addAll(notificationData) viewModel.currentSource?.invalidate() return MediatorResult.Success(endOfPaginationReached = viewModel.nextKey == null) } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/notifications/requests/details/NotificationRequestDetailsViewModel.kt ================================================ /* 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.components.notifications.requests.details import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.paging.ExperimentalPagingApi import androidx.paging.Pager 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.appstore.BlockEvent import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.MuteEvent import com.keylesspalace.tusky.appstore.StatusChangedEvent import com.keylesspalace.tusky.components.timeline.util.ifExpected import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.usecase.TimelineCases import com.keylesspalace.tusky.viewdata.NotificationViewData import com.keylesspalace.tusky.viewdata.StatusViewData import com.keylesspalace.tusky.viewdata.TranslationViewData import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.launch @HiltViewModel(assistedFactory = NotificationRequestDetailsViewModel.Factory::class) class NotificationRequestDetailsViewModel @AssistedInject constructor( val api: MastodonApi, val accountManager: AccountManager, val timelineCases: TimelineCases, val eventHub: EventHub, @Assisted("notificationRequestId") val notificationRequestId: String, @Assisted("accountId") val accountId: String ) : ViewModel() { var currentSource: NotificationRequestDetailsPagingSource? = null val notificationData: MutableList = mutableListOf() var nextKey: String? = null @OptIn(ExperimentalPagingApi::class) val pager = Pager( config = PagingConfig( pageSize = 20, initialLoadSize = 20 ), remoteMediator = NotificationRequestDetailsRemoteMediator(this), pagingSourceFactory = { NotificationRequestDetailsPagingSource( notifications = notificationData, nextKey = nextKey ).also { source -> currentSource = source } } ).flow .cachedIn(viewModelScope) private val _error = MutableSharedFlow( replay = 0, extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST ) val error: SharedFlow = _error.asSharedFlow() private val _finish = MutableSharedFlow( replay = 0, extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST ) val finish: SharedFlow = _finish.asSharedFlow() init { viewModelScope.launch { eventHub.events .collect { event -> when (event) { is StatusChangedEvent -> updateStatus(event.status) is BlockEvent -> removeIfAccount(event.accountId) is MuteEvent -> removeIfAccount(event.accountId) } } } } fun acceptNotificationRequest() { viewModelScope.launch { api.acceptNotificationRequest(notificationRequestId).fold( { _finish.emit(Unit) }, { error -> Log.w(TAG, "failed to dismiss notifications request", error) _error.emit(error) } ) } } fun dismissNotificationRequest() { viewModelScope.launch { api.dismissNotificationRequest(notificationRequestId).fold({ _finish.emit(Unit) }, { error -> Log.w(TAG, "failed to dismiss notifications request", error) _error.emit(error) }) } } private fun updateStatus(status: Status) { val position = notificationData.indexOfFirst { it.asStatusOrNull()?.id == status.id } if (position == -1) { return } val viewData = notificationData[position].statusViewData?.copy(status = status) notificationData[position] = notificationData[position].copy(statusViewData = viewData) currentSource?.invalidate() } private fun removeIfAccount(accountId: String) { // if the account we are displaying notifications from got blocked or muted, we can exit if (accountId == this.accountId) { viewModelScope.launch { _finish.emit(Unit) } } } fun remove(notification: NotificationViewData) { notificationData.remove(notification) currentSource?.invalidate() } fun reblog(reblog: Boolean, status: StatusViewData.Concrete, visibility: Status.Visibility = Status.Visibility.PUBLIC) = viewModelScope.launch { timelineCases.reblog(status.actionableId, reblog, visibility).onFailure { t -> ifExpected(t) { Log.w(TAG, "Failed to reblog status " + status.actionableId, t) } } } fun favorite(favorite: Boolean, status: StatusViewData.Concrete) = viewModelScope.launch { timelineCases.favourite(status.actionableId, favorite).onFailure { t -> ifExpected(t) { Log.w(TAG, "Failed to favourite status " + status.actionableId, t) } } } fun bookmark(bookmark: Boolean, status: StatusViewData.Concrete) = viewModelScope.launch { timelineCases.bookmark(status.actionableId, bookmark).onFailure { t -> ifExpected(t) { Log.w(TAG, "Failed to favourite status " + status.actionableId, t) } } } fun changeExpanded(expanded: Boolean, status: StatusViewData.Concrete) { updateStatusViewData(status.id) { it.copy(isExpanded = expanded) } } fun changeContentShowing(isShowing: Boolean, status: StatusViewData.Concrete) { updateStatusViewData(status.id) { it.copy(isShowingContent = isShowing) } } fun changeContentCollapsed(isCollapsed: Boolean, status: StatusViewData.Concrete) { updateStatusViewData(status.id) { it.copy(isCollapsed = isCollapsed) } } fun voteInPoll(choices: List, status: StatusViewData.Concrete) = viewModelScope.launch { val poll = status.status.actionableStatus.poll ?: run { Log.w(TAG, "No poll on status ${status.id}") return@launch } timelineCases.voteInPoll(status.actionableId, poll.id, choices).onFailure { t -> ifExpected(t) { Log.w(TAG, "Failed to vote in poll: " + status.actionableId, t) } } } fun showPollResults(status: StatusViewData.Concrete) = viewModelScope.launch { timelineCases.showPollResults(status.actionableId) } 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) { it.copy(translation = null) } } fun respondToFollowRequest(accept: Boolean, accountId: String, notification: NotificationViewData) { viewModelScope.launch { if (accept) { api.authorizeFollowRequest(accountId) } else { api.rejectFollowRequest(accountId) }.fold( onSuccess = { // since the follow request has been responded, the notification can be deleted remove(notification) }, onFailure = { t -> Log.w(TAG, "Failed to to respond to follow request from account id $accountId.", t) } ) } } private fun updateStatusViewData( statusId: String, updater: (StatusViewData.Concrete) -> StatusViewData.Concrete ) { val position = notificationData.indexOfFirst { it.asStatusOrNull()?.id == statusId } val statusViewData = notificationData.getOrNull(position)?.statusViewData ?: return notificationData[position] = notificationData[position].copy(statusViewData = updater(statusViewData)) currentSource?.invalidate() } companion object { private const val TAG = "NotificationRequestsViewModel" } @AssistedFactory interface Factory { fun create( @Assisted("notificationRequestId") notificationRequestId: String, @Assisted("accountId") accountId: String ): NotificationRequestDetailsViewModel } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/preference/AccountPreferencesFragment.kt ================================================ /* Copyright 2018 Conny Duck * * 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.preference import android.content.Intent import android.graphics.drawable.Drawable import android.os.Build import android.os.Bundle import android.util.Log import androidx.lifecycle.lifecycleScope import androidx.preference.ListPreference import at.connyduck.calladapter.networkresult.fold import com.google.android.material.snackbar.Snackbar import com.keylesspalace.tusky.BaseActivity import com.keylesspalace.tusky.BuildConfig import com.keylesspalace.tusky.R import com.keylesspalace.tusky.TabPreferenceActivity import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.PreferenceChangedEvent 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.preference.notificationpolicies.NotificationPoliciesActivity import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.settings.AccountPreferenceDataStore import com.keylesspalace.tusky.settings.DefaultReplyVisibility import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.settings.listPreference import com.keylesspalace.tusky.settings.makePreferenceScreen import com.keylesspalace.tusky.settings.preference import com.keylesspalace.tusky.settings.preferenceCategory import com.keylesspalace.tusky.settings.switchPreference import com.keylesspalace.tusky.util.getInitialLanguages import com.keylesspalace.tusky.util.getLocaleList import com.keylesspalace.tusky.util.getTuskyDisplayName import com.keylesspalace.tusky.util.icon import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject import kotlinx.coroutines.launch @AndroidEntryPoint class AccountPreferencesFragment : BasePreferencesFragment() { @Inject lateinit var accountManager: AccountManager @Inject lateinit var mastodonApi: MastodonApi @Inject lateinit var eventHub: EventHub @Inject lateinit var accountPreferenceDataStore: AccountPreferenceDataStore override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { val context = requireContext() makePreferenceScreen { preference { setTitle(R.string.pref_title_edit_notification_settings) icon = icon(R.drawable.ic_notifications_24dp) setOnPreferenceClickListener { openNotificationSystemPrefs() true } } preference { setTitle(R.string.title_tab_preferences) icon = icon(R.drawable.ic_tabs_24dp) setOnPreferenceClickListener { val intent = Intent(context, TabPreferenceActivity::class.java) activity?.startActivityWithSlideInAnimation(intent) true } } preference { setTitle(R.string.title_followed_hashtags) icon = icon(R.drawable.ic_tag_24dp) setOnPreferenceClickListener { val intent = Intent(context, FollowedTagsActivity::class.java) activity?.startActivityWithSlideInAnimation(intent) true } } preference { setTitle(R.string.action_view_mutes) icon = icon(R.drawable.ic_volume_off_24dp) setOnPreferenceClickListener { val intent = Intent(context, AccountListActivity::class.java) intent.putExtra("type", AccountListActivity.Type.MUTES) activity?.startActivityWithSlideInAnimation(intent) true } } preference { setTitle(R.string.action_view_blocks) icon = icon(R.drawable.ic_block_24dp) setOnPreferenceClickListener { val intent = Intent(context, AccountListActivity::class.java) intent.putExtra("type", AccountListActivity.Type.BLOCKS) activity?.startActivityWithSlideInAnimation(intent) true } } preference { setTitle(R.string.title_domain_mutes) icon = icon(R.drawable.ic_volume_off_24dp) setOnPreferenceClickListener { val intent = Intent(context, DomainBlocksActivity::class.java) activity?.startActivityWithSlideInAnimation(intent) true } } preference { setTitle(R.string.pref_title_timeline_filters) icon = icon(R.drawable.ic_filter_alt_24dp) setOnPreferenceClickListener { launchFilterActivity() true } } preferenceCategory(R.string.pref_publishing) { listPreference { setTitle(R.string.pref_default_post_privacy) setEntries(R.array.post_privacy_names) setEntryValues(R.array.post_privacy_values) key = PrefKeys.DEFAULT_POST_PRIVACY setSummaryProvider { entry } val visibility = accountManager.activeAccount?.defaultPostPrivacy ?: Status.Visibility.PUBLIC value = visibility.stringValue icon = getIconForVisibility(visibility) isPersistent = false // its saved to the account and shouldn't be in shared preferences setOnPreferenceChangeListener { _, newValue -> icon = getIconForVisibility(Status.Visibility.fromStringValue(newValue as String)) if (accountManager.activeAccount?.defaultReplyPrivacy == DefaultReplyVisibility.MATCH_DEFAULT_POST_VISIBILITY) { findPreference(PrefKeys.DEFAULT_REPLY_PRIVACY)?.icon = icon } syncWithServer(visibility = newValue) true } } val activeAccount = accountManager.activeAccount if (activeAccount != null) { listPreference { setTitle(R.string.pref_default_reply_privacy) setEntries(R.array.reply_privacy_names) setEntryValues(R.array.reply_privacy_values) key = PrefKeys.DEFAULT_REPLY_PRIVACY setSummaryProvider { entry } val visibility = activeAccount.defaultReplyPrivacy value = visibility.stringValue icon = getIconForVisibility(visibility.toVisibilityOr(activeAccount.defaultPostPrivacy)) isPersistent = false // its saved to the account and shouldn't be in shared preferences setOnPreferenceChangeListener { _, newValue -> val newVisibility = DefaultReplyVisibility.fromStringValue(newValue as String) icon = getIconForVisibility(newVisibility.toVisibilityOr(activeAccount.defaultPostPrivacy)) viewLifecycleOwner.lifecycleScope.launch { accountManager.updateAccount(activeAccount) { copy(defaultReplyPrivacy = newVisibility) } eventHub.dispatch(PreferenceChangedEvent(key)) } true } } preference { setSummary(R.string.pref_default_reply_privacy_explanation) shouldDisableView = false isEnabled = false } } listPreference { 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 = ( listOf(context.getString(R.string.system_default)) + locales.map { it.getTuskyDisplayName(context) } ).toTypedArray() entryValues = (listOf("") + locales.map { it.language }).toTypedArray() key = PrefKeys.DEFAULT_POST_LANGUAGE icon = icon(R.drawable.ic_translate_24dp) value = accountManager.activeAccount?.defaultPostLanguage.orEmpty() isPersistent = false // This will be entirely server-driven setSummaryProvider { entry } setOnPreferenceChangeListener { _, newValue -> syncWithServer(language = (newValue as String)) true } } switchPreference { setTitle(R.string.pref_default_media_sensitivity) icon = icon(R.drawable.ic_visibility_24dp) key = PrefKeys.DEFAULT_MEDIA_SENSITIVITY val sensitivity = accountManager.activeAccount?.defaultMediaSensitivity == true setDefaultValue(sensitivity) icon = getIconForSensitivity(sensitivity) setOnPreferenceChangeListener { _, newValue -> icon = getIconForSensitivity(newValue as Boolean) syncWithServer(sensitive = newValue) true } } } preferenceCategory(R.string.pref_title_timelines) { // TODO having no activeAccount in this fragment does not really make sense, enforce it? // All other locations here make it optional, however. switchPreference { key = PrefKeys.MEDIA_PREVIEW_ENABLED setTitle(R.string.pref_title_show_media_preview) preferenceDataStore = accountPreferenceDataStore } switchPreference { key = PrefKeys.ALWAYS_SHOW_SENSITIVE_MEDIA setTitle(R.string.pref_title_alway_show_sensitive_media) preferenceDataStore = accountPreferenceDataStore } switchPreference { key = PrefKeys.ALWAYS_OPEN_SPOILER setTitle(R.string.pref_title_alway_open_spoiler) preferenceDataStore = accountPreferenceDataStore } } preferenceCategory(R.string.pref_title_per_timeline_preferences) { preference { setTitle(R.string.pref_title_post_tabs) fragment = TabFilterPreferencesFragment::class.qualifiedName } } preference { setTitle(R.string.notification_policies_title) setOnPreferenceClickListener { activity?.let { val intent = NotificationPoliciesActivity.newIntent(it) it.startActivityWithSlideInAnimation(intent) } true } } } } override fun onResume() { super.onResume() requireActivity().setTitle(R.string.action_view_account_preferences) } private fun openNotificationSystemPrefs() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val intent = Intent() intent.action = "android.settings.APP_NOTIFICATION_SETTINGS" intent.putExtra("android.provider.extra.APP_PACKAGE", BuildConfig.APPLICATION_ID) startActivity(intent) } else { activity?.let { val intent = PreferencesActivity.newIntent( it, PreferencesActivity.NOTIFICATION_PREFERENCES ) it.startActivityWithSlideInAnimation(intent) } } } 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 viewLifecycleOwner.lifecycleScope.launch { mastodonApi.accountUpdateSource(visibility, sensitive, language) .fold({ account: Account -> accountManager.activeAccount?.let { accountManager.updateAccount(it) { copy( defaultPostPrivacy = account.source?.privacy ?: Status.Visibility.PUBLIC, defaultMediaSensitivity = account.source?.sensitive == true, defaultPostLanguage = language.orEmpty() ) } } }, { t -> Log.e("AccountPreferences", "failed updating settings on server", t) showErrorSnackbar(visibility, sensitive) }) } } private fun showErrorSnackbar(visibility: String?, sensitive: Boolean?) { view?.let { view -> Snackbar.make(view, R.string.pref_failed_to_sync, Snackbar.LENGTH_LONG) .setAction(R.string.action_retry) { syncWithServer(visibility, sensitive) } .show() } } private fun getIconForVisibility(visibility: Status.Visibility): Drawable? { val iconRes = when (visibility) { Status.Visibility.PRIVATE -> R.drawable.ic_lock_24dp Status.Visibility.UNLISTED -> R.drawable.ic_lock_open_24dp Status.Visibility.DIRECT -> R.drawable.ic_mail_24dp else -> R.drawable.ic_public_24dp } return icon(iconRes) } private fun getIconForSensitivity(sensitive: Boolean): Drawable? { return if (sensitive) { icon(R.drawable.ic_visibility_off_24dp) } else { icon(R.drawable.ic_visibility_24dp) } } private fun launchFilterActivity() { val intent = Intent(context, FiltersActivity::class.java) (activity as? BaseActivity)?.startActivityWithSlideInAnimation(intent) } companion object { fun newInstance() = AccountPreferencesFragment() } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/preference/BasePreferencesFragment.kt ================================================ package com.keylesspalace.tusky.components.preference import android.os.Bundle import android.view.View import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat.Type.systemBars import androidx.core.view.updatePadding import androidx.preference.PreferenceFragmentCompat abstract class BasePreferencesFragment : PreferenceFragmentCompat() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) ViewCompat.setOnApplyWindowInsetsListener(listView) { listView, insets -> val systemBarsInsets = insets.getInsets(systemBars()) listView.updatePadding(bottom = systemBarsInsets.bottom) insets.inset(0, 0, 0, systemBarsInsets.bottom) } } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/preference/NotificationPreferencesFragment.kt ================================================ /* Copyright 2018 Conny Duck * * 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.preference import android.os.Bundle import androidx.lifecycle.lifecycleScope import com.keylesspalace.tusky.R import com.keylesspalace.tusky.components.systemnotifications.NotificationService import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.entity.AccountEntity import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.settings.makePreferenceScreen import com.keylesspalace.tusky.settings.preferenceCategory import com.keylesspalace.tusky.settings.switchPreference import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject import kotlinx.coroutines.launch @AndroidEntryPoint class NotificationPreferencesFragment : BasePreferencesFragment() { @Inject lateinit var accountManager: AccountManager @Inject lateinit var notificationService: NotificationService override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { val activeAccount = accountManager.activeAccount ?: return makePreferenceScreen { switchPreference { setTitle(R.string.pref_title_notifications_enabled) key = PrefKeys.NOTIFICATIONS_ENABLED isIconSpaceReserved = false isChecked = activeAccount.notificationsEnabled setOnPreferenceChangeListener { _, newValue -> updateAccount { copy(notificationsEnabled = newValue as Boolean) } if (notificationService.areNotificationsEnabledBySystem()) { notificationService.enablePullNotifications() } else { notificationService.disablePullNotifications() } true } } preferenceCategory(R.string.pref_title_notification_filters) { category -> category.dependency = PrefKeys.NOTIFICATIONS_ENABLED category.isIconSpaceReserved = false switchPreference { setTitle(R.string.notification_follow_name) setSummary(R.string.notification_follow_description) isIconSpaceReserved = false isChecked = activeAccount.notificationsFollowed setOnPreferenceChangeListener { _, newValue -> updateAccount { copy(notificationsFollowed = newValue as Boolean) } true } } switchPreference { setTitle(R.string.notification_follow_request_name) setSummary(R.string.notification_follow_request_description) isIconSpaceReserved = false isChecked = activeAccount.notificationsFollowRequested setOnPreferenceChangeListener { _, newValue -> updateAccount { copy(notificationsFollowRequested = newValue as Boolean) } true } } switchPreference { setTitle(R.string.notification_boost_name) setSummary(R.string.notification_boost_description) isIconSpaceReserved = false isChecked = activeAccount.notificationsReblogged setOnPreferenceChangeListener { _, newValue -> updateAccount { copy(notificationsReblogged = newValue as Boolean) } true } } switchPreference { setTitle(R.string.notification_favourite_name) setSummary(R.string.notification_favourite_description) isIconSpaceReserved = false isChecked = activeAccount.notificationsFavorited setOnPreferenceChangeListener { _, newValue -> updateAccount { copy(notificationsFavorited = newValue as Boolean) } true } } switchPreference { setTitle(R.string.notification_poll_name) setSummary(R.string.notification_poll_description) isIconSpaceReserved = false isChecked = activeAccount.notificationsPolls setOnPreferenceChangeListener { _, newValue -> updateAccount { copy(notificationsPolls = newValue as Boolean) } true } } switchPreference { setTitle(R.string.notification_subscription_name) setSummary(R.string.notification_subscription_description) isIconSpaceReserved = false isChecked = activeAccount.notificationsSubscriptions setOnPreferenceChangeListener { _, newValue -> updateAccount { copy(notificationsSubscriptions = newValue as Boolean) } true } } switchPreference { setTitle(R.string.notification_update_name) setSummary(R.string.notification_update_description) isIconSpaceReserved = false isChecked = activeAccount.notificationsUpdates setOnPreferenceChangeListener { _, newValue -> updateAccount { copy(notificationsUpdates = newValue as Boolean) } true } } switchPreference { setTitle(R.string.notification_channel_admin) setSummary(R.string.notification_channel_admin_description) isIconSpaceReserved = false isChecked = activeAccount.notificationsAdmin setOnPreferenceChangeListener { _, newValue -> updateAccount { copy(notificationsAdmin = newValue as Boolean) } true } } switchPreference { setTitle(R.string.notification_channel_other) setSummary(R.string.notification_channel_other_description) isIconSpaceReserved = false isChecked = activeAccount.notificationsOther setOnPreferenceChangeListener { _, newValue -> updateAccount { copy(notificationsOther = newValue as Boolean) } true } } } preferenceCategory(R.string.pref_title_notification_alerts) { category -> category.dependency = PrefKeys.NOTIFICATIONS_ENABLED category.isIconSpaceReserved = false switchPreference { setTitle(R.string.pref_title_notification_alert_sound) key = PrefKeys.NOTIFICATION_ALERT_SOUND isIconSpaceReserved = false isChecked = activeAccount.notificationSound setOnPreferenceChangeListener { _, newValue -> updateAccount { copy(notificationSound = newValue as Boolean) } true } } switchPreference { setTitle(R.string.pref_title_notification_alert_vibrate) key = PrefKeys.NOTIFICATION_ALERT_VIBRATE isIconSpaceReserved = false isChecked = activeAccount.notificationVibration setOnPreferenceChangeListener { _, newValue -> updateAccount { copy(notificationVibration = newValue as Boolean) } true } } switchPreference { setTitle(R.string.pref_title_notification_alert_light) key = PrefKeys.NOTIFICATION_ALERT_LIGHT isIconSpaceReserved = false isChecked = activeAccount.notificationLight setOnPreferenceChangeListener { _, newValue -> updateAccount { copy(notificationLight = newValue as Boolean) } true } } } } } private fun updateAccount(changer: AccountEntity.() -> AccountEntity) { viewLifecycleOwner.lifecycleScope.launch { accountManager.activeAccount?.let { account -> accountManager.updateAccount(account, changer) } } } override fun onResume() { super.onResume() requireActivity().setTitle(R.string.pref_title_edit_notification_settings) } companion object { fun newInstance(): NotificationPreferencesFragment { return NotificationPreferencesFragment() } } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesActivity.kt ================================================ /* Copyright 2018 Conny Duck * * 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.preference import android.content.Context import android.content.Intent import android.content.SharedPreferences import android.os.Build import android.os.Bundle import android.util.Log import androidx.activity.OnBackPressedCallback import androidx.core.view.WindowCompat import androidx.fragment.app.Fragment import androidx.fragment.app.commit import androidx.lifecycle.lifecycleScope import androidx.preference.Preference import androidx.preference.PreferenceFragmentCompat import com.keylesspalace.tusky.BaseActivity import com.keylesspalace.tusky.MainActivity 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.settings.PrefKeys.APP_THEME import com.keylesspalace.tusky.util.getNonNullString import com.keylesspalace.tusky.util.setAppNightMode import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation import dagger.hilt.android.AndroidEntryPoint import de.c1710.filemojicompat_ui.helpers.EMOJI_PREFERENCE import javax.inject.Inject import kotlinx.coroutines.launch @AndroidEntryPoint class PreferencesActivity : BaseActivity(), SharedPreferences.OnSharedPreferenceChangeListener, PreferenceFragmentCompat.OnPreferenceStartFragmentCallback { @Inject lateinit var eventHub: EventHub private val restartActivitiesOnBackPressedCallback = object : OnBackPressedCallback(false) { override fun handleOnBackPressed() { /* Switching themes won't actually change the theme of activities on the back stack. * Either the back stack activities need to all be recreated, or do the easier thing, which * is hijack the back button press and use it to launch a new MainActivity and clear the * back stack. */ val intent = Intent(this@PreferencesActivity, MainActivity::class.java) intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK startActivityWithSlideInAnimation(intent) } } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) // Workaround for edge-to-edge mode not working when an activity is recreated // https://stackoverflow.com/questions/79319740/edge-to-edge-doesnt-work-when-activity-recreated-or-appcompatdelegate-setdefaul if (savedInstanceState != null && Build.VERSION.SDK_INT >= 35) { WindowCompat.setDecorFitsSystemWindows(window, false) } val binding = ActivityPreferencesBinding.inflate(layoutInflater) setContentView(binding.root) setSupportActionBar(binding.includedToolbar.toolbar) supportActionBar?.run { setDisplayHomeAsUpEnabled(true) setDisplayShowHomeEnabled(true) } val preferenceType = intent.getIntExtra(EXTRA_PREFERENCE_TYPE, 0) val fragmentTag = "preference_fragment_$preferenceType" val fragment: Fragment = supportFragmentManager.findFragmentByTag(fragmentTag) ?: when (preferenceType) { GENERAL_PREFERENCES -> PreferencesFragment.newInstance() ACCOUNT_PREFERENCES -> AccountPreferencesFragment.newInstance() NOTIFICATION_PREFERENCES -> NotificationPreferencesFragment.newInstance() else -> throw IllegalArgumentException("preferenceType not known") } supportFragmentManager.commit { replace(R.id.fragment_container, fragment, fragmentTag) } onBackPressedDispatcher.addCallback(this, restartActivitiesOnBackPressedCallback) restartActivitiesOnBackPressedCallback.isEnabled = savedInstanceState?.getBoolean(EXTRA_RESTART_ON_BACK, false) == true } override fun onPreferenceStartFragment( caller: PreferenceFragmentCompat, pref: Preference ): Boolean { val args = pref.extras val fragment = supportFragmentManager.fragmentFactory.instantiate( classLoader, pref.fragment!! ) fragment.arguments = args supportFragmentManager.commit { 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, fragment) addToBackStack(null) } return true } override fun onResume() { super.onResume() preferences.registerOnSharedPreferenceChangeListener(this) } override fun onPause() { super.onPause() preferences.unregisterOnSharedPreferenceChangeListener(this) } override fun onSaveInstanceState(outState: Bundle) { outState.putBoolean(EXTRA_RESTART_ON_BACK, restartActivitiesOnBackPressedCallback.isEnabled) super.onSaveInstanceState(outState) } override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) { sharedPreferences ?: return key ?: return when (key) { APP_THEME -> { val theme = sharedPreferences.getNonNullString(APP_THEME, AppTheme.DEFAULT.value) Log.d("activeTheme", theme) setAppNightMode(theme) restartActivitiesOnBackPressedCallback.isEnabled = true this.recreate() } PrefKeys.UI_TEXT_SCALE_RATIO -> { restartActivitiesOnBackPressedCallback.isEnabled = true this.recreate() } 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, EMOJI_PREFERENCE, PrefKeys.ENABLE_SWIPE_FOR_TABS, PrefKeys.MAIN_NAV_POSITION, PrefKeys.HIDE_TOP_TOOLBAR, PrefKeys.SHOW_STATS_INLINE -> { restartActivitiesOnBackPressedCallback.isEnabled = true } } lifecycleScope.launch { eventHub.dispatch(PreferenceChangedEvent(key)) } } companion object { @Suppress("unused") private const val TAG = "PreferencesActivity" const val GENERAL_PREFERENCES = 0 const val ACCOUNT_PREFERENCES = 1 const val NOTIFICATION_PREFERENCES = 2 private const val EXTRA_PREFERENCE_TYPE = "EXTRA_PREFERENCE_TYPE" private const val EXTRA_RESTART_ON_BACK = "restart" @JvmStatic fun newIntent(context: Context, preferenceType: Int): Intent { val intent = Intent(context, PreferencesActivity::class.java) intent.putExtra(EXTRA_PREFERENCE_TYPE, preferenceType) return intent } } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesFragment.kt ================================================ /* Copyright 2018 Conny Duck * * 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.preference import android.content.SharedPreferences import android.os.Bundle import androidx.annotation.DrawableRes import androidx.lifecycle.lifecycleScope import androidx.preference.Preference import com.keylesspalace.tusky.R import com.keylesspalace.tusky.components.systemnotifications.NotificationChannelData import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.settings.AppTheme import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.settings.emojiPreference import com.keylesspalace.tusky.settings.listPreference import com.keylesspalace.tusky.settings.makePreferenceScreen import com.keylesspalace.tusky.settings.preference import com.keylesspalace.tusky.settings.preferenceCategory import com.keylesspalace.tusky.settings.sliderPreference import com.keylesspalace.tusky.settings.switchPreference import com.keylesspalace.tusky.util.LocaleManager import com.keylesspalace.tusky.util.icon import dagger.hilt.android.AndroidEntryPoint import de.c1710.filemojicompat_ui.views.picker.preference.EmojiPickerPreference import javax.inject.Inject import kotlinx.coroutines.launch @AndroidEntryPoint class PreferencesFragment : BasePreferencesFragment() { @Inject lateinit var accountManager: AccountManager @Inject lateinit var localeManager: LocaleManager @Inject lateinit var sharedPrefs: SharedPreferences enum class ReadingOrder { /** User scrolls up, reading statuses oldest to newest */ OLDEST_FIRST, /** User scrolls down, reading statuses newest to oldest. Default behaviour. */ NEWEST_FIRST; companion object { fun from(s: String?): ReadingOrder { s ?: return NEWEST_FIRST return try { valueOf(s.uppercase()) } catch (_: Throwable) { NEWEST_FIRST } } } } override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { makePreferenceScreen { preferenceCategory(R.string.pref_title_appearance_settings) { listPreference { setDefaultValue(AppTheme.DEFAULT.value) setEntries(R.array.app_theme_names) entryValues = AppTheme.stringValues() key = PrefKeys.APP_THEME setSummaryProvider { entry } setTitle(R.string.pref_title_app_theme) icon = icon(R.drawable.ic_palette_24dp) } emojiPreference(requireActivity()) { setTitle(R.string.emoji_style) icon = icon(R.drawable.ic_mood_24dp) } listPreference { setDefaultValue("default") setEntries(R.array.language_entries) setEntryValues(R.array.language_values) key = PrefKeys.LANGUAGE + "_" // deliberately not the actual key, the real handling happens in LocaleManager setSummaryProvider { entry } setTitle(R.string.pref_title_language) icon = icon(R.drawable.ic_translate_24dp) preferenceDataStore = localeManager } sliderPreference { key = PrefKeys.UI_TEXT_SCALE_RATIO setDefaultValue(100F) valueTo = 150F valueFrom = 50F stepSize = 5F setTitle(R.string.pref_ui_text_size) format = "%.0f%%" decrementIcon = icon(R.drawable.ic_zoom_out_24dp) incrementIcon = icon(R.drawable.ic_zoom_in_24dp) icon = icon(R.drawable.ic_format_size_24dp) } listPreference { setDefaultValue("medium") setEntries(R.array.post_text_size_names) setEntryValues(R.array.post_text_size_values) key = PrefKeys.STATUS_TEXT_SIZE setSummaryProvider { entry } setTitle(R.string.pref_post_text_size) icon = icon(R.drawable.ic_format_size_24dp) } listPreference { setDefaultValue(ReadingOrder.NEWEST_FIRST.name) setEntries(R.array.reading_order_names) setEntryValues(R.array.reading_order_values) key = PrefKeys.READING_ORDER setSummaryProvider { entry } setTitle(R.string.pref_title_reading_order) icon = icon(R.drawable.ic_sort_24dp) } listPreference { setDefaultValue("top") setEntries(R.array.pref_main_nav_position_options) setEntryValues(R.array.pref_main_nav_position_values) key = PrefKeys.MAIN_NAV_POSITION setSummaryProvider { entry } setTitle(R.string.pref_main_nav_position) icon = icon( navigationPositionIcon( sharedPrefs.getString(PrefKeys.MAIN_NAV_POSITION, "top").orEmpty() ) ) setOnPreferenceChangeListener { _, newValue -> icon = icon(navigationPositionIcon(newValue.toString())) true } } listPreference { setDefaultValue("disambiguate") setEntries(R.array.pref_show_self_username_names) setEntryValues(R.array.pref_show_self_username_values) key = PrefKeys.SHOW_SELF_USERNAME setSummaryProvider { entry } setTitle(R.string.pref_title_show_self_username) } switchPreference { setDefaultValue(false) key = PrefKeys.HIDE_TOP_TOOLBAR setTitle(R.string.pref_title_hide_top_toolbar) } switchPreference { setDefaultValue(true) key = PrefKeys.SHOW_NOTIFICATIONS_FILTER setTitle(R.string.pref_title_show_notifications_filter) } switchPreference { setDefaultValue(false) key = PrefKeys.ABSOLUTE_TIME_VIEW setTitle(R.string.pref_title_absolute_time) } switchPreference { setDefaultValue(true) key = PrefKeys.SHOW_BOT_OVERLAY setTitle(R.string.pref_title_bot_overlay) icon = icon(R.drawable.ic_bot_24dp) } switchPreference { setDefaultValue(false) key = PrefKeys.ANIMATE_GIF_AVATARS setTitle(R.string.pref_title_animate_gif_avatars) } switchPreference { setDefaultValue(false) key = PrefKeys.ANIMATE_CUSTOM_EMOJIS setTitle(R.string.pref_title_animate_custom_emojis) } switchPreference { setDefaultValue(true) key = PrefKeys.USE_BLURHASH setTitle(R.string.pref_title_gradient_for_media) } switchPreference { setDefaultValue(false) key = PrefKeys.SHOW_CARDS_IN_TIMELINES setTitle(R.string.pref_title_show_cards_in_timelines) } switchPreference { setDefaultValue(true) key = PrefKeys.CONFIRM_REBLOGS setTitle(R.string.pref_title_confirm_reblogs) } switchPreference { setDefaultValue(false) key = PrefKeys.CONFIRM_FAVOURITES setTitle(R.string.pref_title_confirm_favourites) } switchPreference { setDefaultValue(false) key = PrefKeys.CONFIRM_FOLLOWS setTitle(R.string.pref_title_confirm_follows) } switchPreference { setDefaultValue(true) key = PrefKeys.ENABLE_SWIPE_FOR_TABS setTitle(R.string.pref_title_enable_swipe_for_tabs) } switchPreference { setDefaultValue(false) key = PrefKeys.SHOW_STATS_INLINE setTitle(R.string.pref_title_show_stat_inline) } } preferenceCategory(R.string.pref_title_browser_settings) { switchPreference { setDefaultValue(false) key = PrefKeys.CUSTOM_TABS setTitle(R.string.pref_title_custom_tabs) } } preferenceCategory(R.string.pref_title_wellbeing_mode) { switchPreference { title = getString(R.string.limit_notifications) setDefaultValue(false) key = PrefKeys.WELLBEING_LIMITED_NOTIFICATIONS setOnPreferenceChangeListener { _, value -> for (account in accountManager.accounts) { val notificationFilter = account.notificationsFilter.toMutableSet() if (value == true) { notificationFilter.add(NotificationChannelData.FAVOURITE) notificationFilter.add(NotificationChannelData.FOLLOW) notificationFilter.add(NotificationChannelData.REBLOG) } else { notificationFilter.remove(NotificationChannelData.FAVOURITE) notificationFilter.remove(NotificationChannelData.FOLLOW) notificationFilter.remove(NotificationChannelData.REBLOG) } lifecycleScope.launch { accountManager.updateAccount(account) { copy(notificationsFilter = notificationFilter) } } } true } } switchPreference { title = getString(R.string.wellbeing_hide_stats_posts) setDefaultValue(false) key = PrefKeys.WELLBEING_HIDE_STATS_POSTS } switchPreference { title = getString(R.string.wellbeing_hide_stats_profile) setDefaultValue(false) key = PrefKeys.WELLBEING_HIDE_STATS_PROFILE } } preferenceCategory(R.string.pref_title_proxy_settings) { preference { setTitle(R.string.pref_title_http_proxy_settings) fragment = ProxyPreferencesFragment::class.qualifiedName summaryProvider = ProxyPreferencesFragment.SummaryProvider } } } } @DrawableRes private fun navigationPositionIcon(position: String): Int { return if (position == "bottom") { R.drawable.ic_bottom_navigation_24dp } else { R.drawable.ic_bottom_navigation_24dp_mirrored } } override fun onResume() { super.onResume() requireActivity().setTitle(R.string.action_view_preferences) } override fun onDisplayPreferenceDialog(preference: Preference) { if (!EmojiPickerPreference.onDisplayPreferenceDialog(this, preference)) { super.onDisplayPreferenceDialog(preference) } } companion object { fun newInstance(): PreferencesFragment { return PreferencesFragment() } } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/preference/ProxyPreferencesFragment.kt ================================================ /* Copyright 2018 Conny Duck * 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.preference import android.os.Bundle import androidx.preference.Preference import com.keylesspalace.tusky.R import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.settings.ProxyConfiguration import com.keylesspalace.tusky.settings.ProxyConfiguration.Companion.MAX_PROXY_PORT import com.keylesspalace.tusky.settings.ProxyConfiguration.Companion.MIN_PROXY_PORT import com.keylesspalace.tusky.settings.makePreferenceScreen import com.keylesspalace.tusky.settings.preferenceCategory import com.keylesspalace.tusky.settings.switchPreference import com.keylesspalace.tusky.settings.validatedEditTextPreference import com.keylesspalace.tusky.util.getNonNullString import kotlin.system.exitProcess class ProxyPreferencesFragment : BasePreferencesFragment() { private var pendingRestart = false override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { makePreferenceScreen { switchPreference { setTitle(R.string.pref_title_http_proxy_enable) isIconSpaceReserved = false key = PrefKeys.HTTP_PROXY_ENABLED setDefaultValue(false) } preferenceCategory { category -> category.dependency = PrefKeys.HTTP_PROXY_ENABLED category.isIconSpaceReserved = false validatedEditTextPreference(null, ProxyConfiguration::isValidHostname) { setTitle(R.string.pref_title_http_proxy_server) key = PrefKeys.HTTP_PROXY_SERVER isIconSpaceReserved = false setSummaryProvider { text } } val portErrorMessage = getString( R.string.pref_title_http_proxy_port_message, MIN_PROXY_PORT, MAX_PROXY_PORT ) validatedEditTextPreference( portErrorMessage, ProxyConfiguration::isValidProxyPort ) { setTitle(R.string.pref_title_http_proxy_port) key = PrefKeys.HTTP_PROXY_PORT isIconSpaceReserved = false setSummaryProvider { text } } } } } override fun onResume() { super.onResume() requireActivity().setTitle(R.string.pref_title_http_proxy_settings) } override fun onPause() { super.onPause() if (pendingRestart) { pendingRestart = false exitProcess(0) } } object SummaryProvider : Preference.SummaryProvider { override fun provideSummary(preference: Preference): CharSequence { val sharedPreferences = preference.sharedPreferences sharedPreferences ?: return "" if (!sharedPreferences.getBoolean(PrefKeys.HTTP_PROXY_ENABLED, false)) { return preference.context.getString(R.string.pref_summary_http_proxy_disabled) } val missing = preference.context.getString(R.string.pref_summary_http_proxy_missing) val server = sharedPreferences.getNonNullString(PrefKeys.HTTP_PROXY_SERVER, missing) val port = try { sharedPreferences.getNonNullString(PrefKeys.HTTP_PROXY_PORT, "-1").toInt() } catch (e: NumberFormatException) { -1 } if (port < MIN_PROXY_PORT || port > MAX_PROXY_PORT) { val invalid = preference.context.getString(R.string.pref_summary_http_proxy_invalid) return "$server:$invalid" } return "$server:$port" } } companion object { fun newInstance(): ProxyPreferencesFragment { return ProxyPreferencesFragment() } } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/preference/TabFilterPreferencesFragment.kt ================================================ /* Copyright 2018 Conny Duck * * 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.preference import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import com.google.android.material.color.MaterialColors import com.keylesspalace.tusky.R import com.keylesspalace.tusky.settings.AccountPreferenceDataStore import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.settings.makePreferenceScreen import com.keylesspalace.tusky.settings.preferenceCategory import com.keylesspalace.tusky.settings.switchPreference import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject @AndroidEntryPoint class TabFilterPreferencesFragment : BasePreferencesFragment() { @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)) } } override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { makePreferenceScreen { preferenceCategory(R.string.title_home) { category -> category.isIconSpaceReserved = false switchPreference { setTitle(R.string.pref_title_show_boosts) key = PrefKeys.TAB_FILTER_HOME_BOOSTS preferenceDataStore = accountPreferenceDataStore isIconSpaceReserved = false } switchPreference { setTitle(R.string.pref_title_show_replies) key = PrefKeys.TAB_FILTER_HOME_REPLIES 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 } } } } override fun onResume() { super.onResume() requireActivity().setTitle(R.string.pref_title_post_tabs) } companion object { fun newInstance(): TabFilterPreferencesFragment { return TabFilterPreferencesFragment() } } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/preference/notificationpolicies/NotificationPoliciesActivity.kt ================================================ /* 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.components.preference.notificationpolicies import android.content.Context import android.content.Intent import android.os.Bundle import androidx.activity.viewModels import androidx.lifecycle.lifecycleScope import com.google.android.material.snackbar.BaseTransientBottomBar.LENGTH_LONG import com.google.android.material.snackbar.Snackbar import com.keylesspalace.tusky.BaseActivity import com.keylesspalace.tusky.R import com.keylesspalace.tusky.databinding.ActivityNotificationPolicyBinding import com.keylesspalace.tusky.usecase.NotificationPolicyState import com.keylesspalace.tusky.util.getErrorString import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.util.visible import dagger.hilt.android.AndroidEntryPoint import kotlin.getValue import kotlinx.coroutines.launch @AndroidEntryPoint class NotificationPoliciesActivity : BaseActivity() { private val viewModel: NotificationPoliciesViewModel by viewModels() private val binding by viewBinding(ActivityNotificationPolicyBinding::inflate) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(binding.root) setSupportActionBar(binding.includedToolbar.toolbar) supportActionBar?.run { setTitle(R.string.notification_policies_title) setDisplayHomeAsUpEnabled(true) setDisplayShowHomeEnabled(true) } lifecycleScope.launch { viewModel.state.collect { state -> binding.progressBar.visible(state is NotificationPolicyState.Loading) binding.preferenceFragment.visible(state is NotificationPolicyState.Loaded) binding.messageView.visible(state !is NotificationPolicyState.Loading && state !is NotificationPolicyState.Loaded) when (state) { is NotificationPolicyState.Loading -> { } is NotificationPolicyState.Error -> binding.messageView.setup(state.throwable) { viewModel.loadPolicy() } is NotificationPolicyState.Loaded -> { } NotificationPolicyState.Unsupported -> binding.messageView.setup(R.drawable.errorphant_error, R.string.notification_policies_not_supported) { viewModel.loadPolicy() } } } } lifecycleScope.launch { viewModel.error.collect { error -> Snackbar.make( binding.root, error.getErrorString(this@NotificationPoliciesActivity), LENGTH_LONG ).show() } } } companion object { fun newIntent(context: Context) = Intent(context, NotificationPoliciesActivity::class.java) } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/preference/notificationpolicies/NotificationPoliciesFragment.kt ================================================ /* 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.components.preference.notificationpolicies import android.os.Bundle import android.view.View import androidx.fragment.app.activityViewModels import androidx.lifecycle.lifecycleScope import androidx.preference.PreferenceFragmentCompat import com.keylesspalace.tusky.R import com.keylesspalace.tusky.settings.makePreferenceScreen import com.keylesspalace.tusky.settings.preferenceCategory import com.keylesspalace.tusky.usecase.NotificationPolicyState import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch @AndroidEntryPoint class NotificationPoliciesFragment : PreferenceFragmentCompat() { val viewModel: NotificationPoliciesViewModel by activityViewModels() override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { makePreferenceScreen { preferenceCategory(title = R.string.notification_policies_filter_out) { category -> category.isIconSpaceReserved = false notificationPolicyPreference { setTitle(R.string.notification_policies_filter_dont_follow_title) setSummary(R.string.notification_policies_filter_dont_follow_description) key = KEY_NOT_FOLLOWING setOnPreferenceChangeListener { _, newValue -> viewModel.updatePolicy(forNotFollowing = newValue as String) true } } notificationPolicyPreference { setTitle(R.string.notification_policies_filter_not_following_title) setSummary(R.string.notification_policies_filter_not_following_description) key = KEY_NOT_FOLLOWERS setOnPreferenceChangeListener { _, newValue -> viewModel.updatePolicy(forNotFollowers = newValue as String) true } } notificationPolicyPreference { setTitle(R.string.unknown_notification_filter_new_accounts_title) setSummary(R.string.unknown_notification_filter_new_accounts_description) key = KEY_NEW_ACCOUNTS setOnPreferenceChangeListener { _, newValue -> viewModel.updatePolicy(forNewAccounts = newValue as String) true } } notificationPolicyPreference { setTitle(R.string.unknown_notification_filter_unsolicited_private_mentions_title) setSummary(R.string.unknown_notification_filter_unsolicited_private_mentions_description) key = KEY_PRIVATE_MENTIONS setOnPreferenceChangeListener { _, newValue -> viewModel.updatePolicy(forPrivateMentions = newValue as String) true } } notificationPolicyPreference { setTitle(R.string.unknown_notification_filter_moderated_accounts) setSummary(R.string.unknown_notification_filter_moderated_accounts_description) key = KEY_LIMITED_ACCOUNTS setOnPreferenceChangeListener { _, newValue -> viewModel.updatePolicy(forLimitedAccounts = newValue as String) true } } } } } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) viewLifecycleOwner.lifecycleScope.launch { viewModel.state.collect { state -> if (state is NotificationPolicyState.Loaded) { findPreference(KEY_NOT_FOLLOWING)?.value = state.policy.forNotFollowing.name.lowercase() findPreference(KEY_NOT_FOLLOWERS)?.value = state.policy.forNotFollowers.name.lowercase() findPreference(KEY_NEW_ACCOUNTS)?.value = state.policy.forNewAccounts.name.lowercase() findPreference(KEY_PRIVATE_MENTIONS)?.value = state.policy.forPrivateMentions.name.lowercase() findPreference(KEY_LIMITED_ACCOUNTS)?.value = state.policy.forLimitedAccounts.name.lowercase() } } } } companion object { fun newInstance(): NotificationPoliciesFragment { return NotificationPoliciesFragment() } private const val KEY_NOT_FOLLOWING = "NOT_FOLLOWING" private const val KEY_NOT_FOLLOWERS = "NOT_FOLLOWERS" private const val KEY_NEW_ACCOUNTS = "NEW_ACCOUNTS" private const val KEY_PRIVATE_MENTIONS = "PRIVATE MENTIONS" private const val KEY_LIMITED_ACCOUNTS = "LIMITED_ACCOUNTS" } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/preference/notificationpolicies/NotificationPoliciesViewModel.kt ================================================ /* 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.components.preference.notificationpolicies import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import at.connyduck.calladapter.networkresult.onFailure import com.keylesspalace.tusky.usecase.NotificationPolicyState import com.keylesspalace.tusky.usecase.NotificationPolicyUsecase import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.launch @HiltViewModel class NotificationPoliciesViewModel @Inject constructor( private val usecase: NotificationPolicyUsecase ) : ViewModel() { val state: StateFlow = usecase.state private val _error = MutableSharedFlow( replay = 0, extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST ) val error: SharedFlow = _error.asSharedFlow() init { loadPolicy() } fun loadPolicy() { viewModelScope.launch { usecase.getNotificationPolicy() } } fun updatePolicy( forNotFollowing: String? = null, forNotFollowers: String? = null, forNewAccounts: String? = null, forPrivateMentions: String? = null, forLimitedAccounts: String? = null ) { viewModelScope.launch { usecase.updatePolicy( forNotFollowing = forNotFollowing, forNotFollowers = forNotFollowers, forNewAccounts = forNewAccounts, forPrivateMentions = forPrivateMentions, forLimitedAccounts = forLimitedAccounts ).onFailure { error -> Log.w(TAG, "failed to update notifications policy", error) _error.emit(error) } } } companion object { private const val TAG = "NotificationPoliciesViewModel" } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/preference/notificationpolicies/NotificationPolicyPreference.kt ================================================ /* 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.components.preference.notificationpolicies import android.content.Context import android.widget.TextView import androidx.preference.ListPreference import androidx.preference.PreferenceViewHolder import com.keylesspalace.tusky.R import com.keylesspalace.tusky.settings.PreferenceParent class NotificationPolicyPreference( context: Context ) : ListPreference(context) { init { widgetLayoutResource = R.layout.preference_notification_policy setEntries(R.array.notification_policy_options) setEntryValues(R.array.notification_policy_value) isIconSpaceReserved = false } override fun onBindViewHolder(holder: PreferenceViewHolder) { super.onBindViewHolder(holder) val switchView: TextView = holder.findViewById(R.id.notification_policy_value) as TextView switchView.text = entries.getOrNull(findIndexOfValue(value)) } } inline fun PreferenceParent.notificationPolicyPreference(builder: NotificationPolicyPreference.() -> Unit): NotificationPolicyPreference { val pref = NotificationPolicyPreference(context) builder(pref) addPref(pref) return pref } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/report/ReportActivity.kt ================================================ /* Copyright 2019 Joel Pyska * * 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.report import android.content.Context import android.content.Intent import android.os.Bundle import androidx.activity.viewModels import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat.Type.systemBars import androidx.core.view.updatePadding import androidx.lifecycle.lifecycleScope import com.keylesspalace.tusky.BottomSheetActivity import com.keylesspalace.tusky.R import com.keylesspalace.tusky.components.report.adapter.ReportPagerAdapter import com.keylesspalace.tusky.databinding.ActivityReportBinding import com.keylesspalace.tusky.util.viewBinding import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch @AndroidEntryPoint class ReportActivity : BottomSheetActivity() { private val viewModel: ReportViewModel by viewModels() private val binding by viewBinding(ActivityReportBinding::inflate) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) 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" ) } viewModel.init(accountId, accountUserName, intent?.getStringExtra(STATUS_ID)) setContentView(binding.root) setSupportActionBar(binding.includedToolbar.toolbar) supportActionBar?.apply { title = getString(R.string.report_username_format, viewModel.accountUserName) setDisplayHomeAsUpEnabled(true) setDisplayShowHomeEnabled(true) setHomeAsUpIndicator(R.drawable.ic_close_24dp) } ViewCompat.setOnApplyWindowInsetsListener(binding.wizard) { wizard, insets -> val systemBarInsets = insets.getInsets(systemBars()) wizard.updatePadding(bottom = systemBarInsets.bottom) insets.inset(0, 0, 0, systemBarInsets.bottom) } initViewPager() if (savedInstanceState == null) { viewModel.navigateTo(Screen.Statuses) } subscribeObservables() } private fun initViewPager() { binding.wizard.isUserInputEnabled = false // Odd workaround for text field losing focus on first focus // (unfixed old bug: https://github.com/material-components/material-components-android/issues/500) binding.wizard.offscreenPageLimit = 1 binding.wizard.adapter = ReportPagerAdapter(this) } private fun subscribeObservables() { lifecycleScope.launch { viewModel.navigation.collect { screen -> if (screen == null) return@collect viewModel.navigated() when (screen) { Screen.Statuses -> showStatusesPage() Screen.Note -> showNotesPage() Screen.Done -> showDonePage() Screen.Back -> showPreviousScreen() Screen.Finish -> closeScreen() } } } lifecycleScope.launch { viewModel.checkUrl.collect { if (!it.isNullOrBlank()) { viewModel.urlChecked() viewUrl(it) } } } } private fun showPreviousScreen() { when (binding.wizard.currentItem) { 0 -> closeScreen() 1 -> showStatusesPage() } } private fun showDonePage() { binding.wizard.currentItem = 2 } private fun showNotesPage() { binding.wizard.currentItem = 1 } private fun closeScreen() { finish() } private fun showStatusesPage() { binding.wizard.currentItem = 0 } companion object { private const val ACCOUNT_ID = "account_id" private const val ACCOUNT_USERNAME = "account_username" 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) } } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/report/ReportViewModel.kt ================================================ /* Copyright 2019 Joel Pyska * * 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.report import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.paging.Pager import androidx.paging.PagingConfig import androidx.paging.cachedIn import androidx.paging.map import at.connyduck.calladapter.networkresult.fold import com.keylesspalace.tusky.appstore.BlockEvent import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.MuteEvent import com.keylesspalace.tusky.components.report.adapter.StatusesPagingSource import com.keylesspalace.tusky.components.report.model.StatusViewState import com.keylesspalace.tusky.entity.Relationship import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.network.MastodonApi 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 com.keylesspalace.tusky.util.toViewData import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.ExperimentalCoroutinesApi 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 @HiltViewModel @OptIn(ExperimentalCoroutinesApi::class) class ReportViewModel @Inject constructor( private val mastodonApi: MastodonApi, private val eventHub: EventHub ) : ViewModel() { private val navigationMutable = MutableStateFlow(null as Screen?) val navigation: StateFlow = navigationMutable.asStateFlow() private val muteStateMutable = MutableStateFlow(null as Resource?) val muteState: StateFlow?> = muteStateMutable.asStateFlow() private val blockStateMutable = MutableStateFlow(null as Resource?) val blockState: StateFlow?> = blockStateMutable.asStateFlow() private val reportingStateMutable = MutableStateFlow(null as Resource?) var reportingState: StateFlow?> = reportingStateMutable.asStateFlow() private val checkUrlMutable = MutableStateFlow(null as String?) val checkUrl: StateFlow = checkUrlMutable.asStateFlow() private val accountIdFlow = MutableSharedFlow( replay = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST ) val statusesFlow = accountIdFlow.flatMapLatest { accountId -> Pager( initialKey = statusId, config = PagingConfig( pageSize = 20, initialLoadSize = 20 ), pagingSourceFactory = { StatusesPagingSource(accountId, mastodonApi) } ).flow } .map { pagingData -> /* TODO: refactor reports to use the isShowingContent / isExpanded / isCollapsed attributes from StatusViewData.Concrete instead of StatusViewState */ pagingData.map { status -> status.toViewData(false, false, false, filter = null) } } .cachedIn(viewModelScope) private val selectedIds = HashSet() val statusViewState = StatusViewState() var reportNote: String = "" var isRemoteNotify = false private var statusId: String? = null lateinit var accountUserName: String lateinit var accountId: String var isRemoteAccount: Boolean = false var remoteServer: String? = null fun init(accountId: String, userName: String, statusId: String?) { this.accountId = accountId this.accountUserName = userName this.statusId = statusId statusId?.let { selectedIds.add(it) } isRemoteAccount = userName.contains('@') if (isRemoteAccount) { remoteServer = userName.substring(userName.indexOf('@') + 1) } obtainRelationship() viewModelScope.launch { accountIdFlow.emit(accountId) } } fun navigateTo(screen: Screen) { navigationMutable.value = screen } fun navigated() { navigationMutable.value = null } private fun obtainRelationship() { val ids = listOf(accountId) muteStateMutable.value = Loading() blockStateMutable.value = Loading() viewModelScope.launch { mastodonApi.relationships(ids).fold( { data -> updateRelationship(data.getOrNull(0)) }, { updateRelationship(null) } ) } } private fun updateRelationship(relationship: Relationship?) { if (relationship != null) { muteStateMutable.value = Success(relationship.muting) blockStateMutable.value = Success(relationship.blocking) } else { muteStateMutable.value = Error(false) blockStateMutable.value = Error(false) } } fun toggleMute() { val alreadyMuted = muteStateMutable.value?.data == true viewModelScope.launch { if (alreadyMuted) { mastodonApi.unmuteAccount(accountId) } else { mastodonApi.muteAccount(accountId) }.fold( { relationship -> val muting = relationship.muting muteStateMutable.value = Success(muting) if (muting) { eventHub.dispatch(MuteEvent(accountId)) } }, { t -> muteStateMutable.value = Error(false, t.message) } ) } muteStateMutable.value = Loading() } fun toggleBlock() { val alreadyBlocked = blockStateMutable.value?.data == true viewModelScope.launch { if (alreadyBlocked) { mastodonApi.unblockAccount(accountId) } else { mastodonApi.blockAccount(accountId) }.fold({ relationship -> val blocking = relationship.blocking blockStateMutable.value = Success(blocking) if (blocking) { eventHub.dispatch(BlockEvent(accountId)) } }, { t -> blockStateMutable.value = Error(false, t.message) }) } blockStateMutable.value = Loading() } fun doReport() { reportingStateMutable.value = Loading() viewModelScope.launch { mastodonApi.report( accountId, selectedIds.toList(), reportNote, if (isRemoteAccount) isRemoteNotify else null ) .fold({ reportingStateMutable.value = Success(true) }, { error -> reportingStateMutable.value = Error(cause = error) }) } } fun checkClickedUrl(url: String?) { checkUrlMutable.value = url } fun urlChecked() { checkUrlMutable.value = null } fun setStatusChecked(status: Status, checked: Boolean) { if (checked) { selectedIds.add(status.id) } else { selectedIds.remove(status.id) } } fun isStatusChecked(id: String): Boolean { return selectedIds.contains(id) } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/report/Screen.kt ================================================ /* Copyright 2019 Joel Pyska * * 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.report enum class Screen { Statuses, Note, Done, Back, Finish } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/report/adapter/AdapterHandler.kt ================================================ /* Copyright 2019 Joel Pyska * * 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.report.adapter import android.view.View import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.interfaces.LinkListener import com.keylesspalace.tusky.viewdata.StatusViewData interface AdapterHandler : LinkListener { fun showMedia(v: View?, status: StatusViewData.Concrete, idx: Int) fun setStatusChecked(status: Status, isChecked: Boolean) fun isStatusChecked(id: String): Boolean } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/report/adapter/ReportPagerAdapter.kt ================================================ /* Copyright 2019 Joel Pyska * * 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.report.adapter import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentActivity import androidx.viewpager2.adapter.FragmentStateAdapter import com.keylesspalace.tusky.components.report.fragments.ReportDoneFragment import com.keylesspalace.tusky.components.report.fragments.ReportNoteFragment import com.keylesspalace.tusky.components.report.fragments.ReportStatusesFragment class ReportPagerAdapter(activity: FragmentActivity) : FragmentStateAdapter(activity) { override fun createFragment(position: Int): Fragment { return when (position) { 0 -> ReportStatusesFragment.newInstance() 1 -> ReportNoteFragment.newInstance() 2 -> ReportDoneFragment.newInstance() else -> throw IllegalArgumentException("Unknown page index: $position") } } override fun getItemCount() = 3 } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusViewHolder.kt ================================================ /* Copyright 2019 Joel Pyska * * 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.report.adapter import android.text.Spanned import android.text.TextUtils import android.view.View import androidx.recyclerview.widget.RecyclerView import com.keylesspalace.tusky.R import com.keylesspalace.tusky.components.report.model.StatusViewState import com.keylesspalace.tusky.databinding.ItemReportStatusBinding import com.keylesspalace.tusky.entity.Emoji import com.keylesspalace.tusky.entity.HashTag import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.interfaces.LinkListener import com.keylesspalace.tusky.util.AbsoluteTimeFormatter import com.keylesspalace.tusky.util.StatusDisplayOptions import com.keylesspalace.tusky.util.StatusViewHelper import com.keylesspalace.tusky.util.StatusViewHelper.Companion.COLLAPSE_INPUT_FILTER import com.keylesspalace.tusky.util.StatusViewHelper.Companion.NO_INPUT_FILTER import com.keylesspalace.tusky.util.emojify import com.keylesspalace.tusky.util.getRelativeTimeSpanString import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.setClickableMentions import com.keylesspalace.tusky.util.setClickableText import com.keylesspalace.tusky.util.shouldTrimStatus import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.viewdata.StatusViewData import com.keylesspalace.tusky.viewdata.toViewData import java.util.Date class StatusViewHolder( private val binding: ItemReportStatusBinding, private val statusDisplayOptions: StatusDisplayOptions, private val viewState: StatusViewState, private val adapterHandler: AdapterHandler, 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 statusViewHelper = StatusViewHelper(itemView) private val absoluteTimeFormatter = AbsoluteTimeFormatter() private val previewListener = object : StatusViewHelper.MediaPreviewListener { override fun onViewMedia(v: View?, idx: Int) { viewdata()?.let { viewdata -> adapterHandler.showMedia(v, viewdata, idx) } } override fun onContentHiddenChange(isShowing: Boolean) { viewdata()?.id?.let { id -> viewState.setMediaShow(id, isShowing) } } } init { binding.statusSelection.setOnCheckedChangeListener { _, isChecked -> viewdata()?.let { viewdata -> adapterHandler.setStatusChecked(viewdata.status, isChecked) } } binding.statusMediaPreviewContainer.clipToOutline = true } fun bind(viewData: StatusViewData.Concrete) { binding.statusSelection.isChecked = adapterHandler.isStatusChecked(viewData.id) updateTextView() val sensitive = viewData.status.sensitive statusViewHelper.setMediasPreview( statusDisplayOptions, viewData.status.attachments, sensitive, previewListener, viewState.isMediaShow(viewData.id, viewData.status.sensitive), mediaViewHeight ) statusViewHelper.setupPollReadonly( viewData.status.poll.toViewData(), viewData.status.emojis, statusDisplayOptions ) setCreatedAt(viewData.status.createdAt) } private fun updateTextView() { viewdata()?.let { viewdata -> setupCollapsedState( shouldTrimStatus(viewdata.content), viewState.isCollapsed(viewdata.id, true), viewState.isContentShow(viewdata.id, viewdata.status.sensitive), viewdata.status.spoilerText ) 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.status.spoilerText.emojify( viewdata.status.emojis, binding.statusContentWarningDescription, statusDisplayOptions.animateEmojis ) binding.statusContentWarningDescription.text = emojiSpoiler binding.statusContentWarningDescription.show() binding.statusContentWarningButton.show() setContentWarningButtonText(viewState.isContentShow(viewdata.id, true)) binding.statusContentWarningButton.setOnClickListener { viewdata()?.let { viewdata -> 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 ) setContentWarningButtonText(!contentShown) } } setTextVisible( viewState.isContentShow(viewdata.id, true), viewdata.content, viewdata.status.mentions, viewdata.status.tags, viewdata.status.emojis, adapterHandler ) } } } private fun setContentWarningButtonText(contentShown: Boolean) { if (contentShown) { binding.statusContentWarningButton.setText(R.string.post_content_warning_show_less) } else { binding.statusContentWarningButton.setText(R.string.post_content_warning_show_more) } } private fun setTextVisible( expanded: Boolean, content: Spanned, mentions: List, tags: List?, emojis: List, listener: LinkListener ) { if (expanded) { val emojifiedText = content.emojify( emojis, binding.statusContent, statusDisplayOptions.animateEmojis ) setClickableText(binding.statusContent, emojifiedText, mentions, tags, listener) } else { setClickableMentions(binding.statusContent, mentions, listener) } if (binding.statusContent.text.isNullOrBlank()) { binding.statusContent.hide() } else { binding.statusContent.show() } } private fun setCreatedAt(createdAt: Date?) { if (statusDisplayOptions.useAbsoluteTime) { binding.timestampInfo.text = absoluteTimeFormatter.format(createdAt) } else { binding.timestampInfo.text = if (createdAt != null) { val then = createdAt.time val now = System.currentTimeMillis() getRelativeTimeSpanString(binding.timestampInfo.context, then, now) } else { // unknown minutes~ "?m" } } } 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 { viewdata()?.let { viewdata -> viewState.setCollapsed(viewdata.id, !collapsed) updateTextView() } } binding.buttonToggleContent.show() if (collapsed) { binding.buttonToggleContent.setText(R.string.post_content_show_more) binding.statusContent.filters = COLLAPSE_INPUT_FILTER } else { binding.buttonToggleContent.setText(R.string.post_content_show_less) binding.statusContent.filters = NO_INPUT_FILTER } } else { binding.buttonToggleContent.hide() binding.statusContent.filters = NO_INPUT_FILTER } } private fun viewdata() = getStatusForPosition(bindingAdapterPosition) } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusesAdapter.kt ================================================ /* Copyright 2019 Joel Pyska * * 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.report.adapter 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.components.report.model.StatusViewState import com.keylesspalace.tusky.databinding.ItemReportStatusBinding import com.keylesspalace.tusky.util.StatusDisplayOptions import com.keylesspalace.tusky.viewdata.StatusViewData class StatusesAdapter( private val statusDisplayOptions: StatusDisplayOptions, private val statusViewState: StatusViewState, private val adapterHandler: AdapterHandler ) : PagingDataAdapter(STATUS_COMPARATOR) { private val statusForPosition: (Int) -> StatusViewData.Concrete? = { position: Int -> if (position != RecyclerView.NO_POSITION) getItem(position) else null } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): StatusViewHolder { val binding = ItemReportStatusBinding.inflate( LayoutInflater.from(parent.context), parent, false ) return StatusViewHolder( binding, statusDisplayOptions, statusViewState, adapterHandler, statusForPosition ) } override fun onBindViewHolder(holder: StatusViewHolder, position: Int) { getItem(position)?.let { status -> holder.bind(status) } } companion object { val STATUS_COMPARATOR = object : DiffUtil.ItemCallback() { 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 } } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusesPagingSource.kt ================================================ /* 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.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.async import kotlinx.coroutines.coroutineScope class StatusesPagingSource( private val accountId: String, private val mastodonApi: MastodonApi ) : PagingSource() { override fun getRefreshKey(state: PagingState): String? { return state.anchorPosition?.let { anchorPosition -> state.closestItemToPosition(anchorPosition)?.id } } override suspend fun load(params: LoadParams): LoadResult { val key = params.key try { val result = if (params is LoadParams.Refresh && key != null) { // 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) } listOf(initialStatus.await()) + additionalStatuses.await() } } else { val maxId = if (params is LoadParams.Refresh || params is LoadParams.Append) { params.key } else { null } val minId = if (params is LoadParams.Prepend) { params.key } else { null } getStatusList(minId = minId, maxId = maxId, limit = params.loadSize) } return LoadResult.Page( data = result, prevKey = result.firstOrNull()?.id, nextKey = result.lastOrNull()?.id ) } catch (e: Exception) { Log.w("StatusesPagingSource", "failed to load statuses", e) return LoadResult.Error(e) } } private suspend fun getSingleStatus(statusId: String): Status { return mastodonApi.status(statusId).getOrThrow() } 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 ).getOrThrow() } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportDoneFragment.kt ================================================ /* Copyright 2019 Joel Pyska * * 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.report.fragments 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 import com.keylesspalace.tusky.databinding.FragmentReportDoneBinding import com.keylesspalace.tusky.util.Loading import com.keylesspalace.tusky.util.viewBinding import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch @AndroidEntryPoint class ReportDoneFragment : Fragment(R.layout.fragment_report_done) { private val viewModel: ReportViewModel by activityViewModels() private val binding by viewBinding(FragmentReportDoneBinding::bind) override fun onViewCreated(view: View, savedInstanceState: Bundle?) { binding.textReported.text = getString(R.string.report_sent_success, viewModel.accountUserName) handleClicks() subscribeObservables() } private fun subscribeObservables() { viewLifecycleOwner.lifecycleScope.launch { viewModel.muteState.collect { if (it == null) return@collect if (it !is Loading) { binding.buttonMute.visibility = View.VISIBLE binding.progressMute.visibility = View.GONE } else { binding.buttonMute.visibility = View.INVISIBLE binding.progressMute.visibility = View.VISIBLE } binding.buttonMute.setText( when (it.data) { true -> R.string.action_unmute else -> R.string.action_mute } ) } } viewLifecycleOwner.lifecycleScope.launch { viewModel.blockState.collect { if (it == null) return@collect if (it !is Loading) { binding.buttonBlock.visibility = View.VISIBLE binding.progressBlock.visibility = View.GONE } else { binding.buttonBlock.visibility = View.INVISIBLE binding.progressBlock.visibility = View.VISIBLE } binding.buttonBlock.setText( when (it.data) { true -> R.string.action_unblock else -> R.string.action_block } ) } } } private fun handleClicks() { binding.buttonDone.setOnClickListener { viewModel.navigateTo(Screen.Finish) } binding.buttonBlock.setOnClickListener { viewModel.toggleBlock() } binding.buttonMute.setOnClickListener { viewModel.toggleMute() } } companion object { fun newInstance() = ReportDoneFragment() } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportNoteFragment.kt ================================================ /* Copyright 2019 Joel Pyska * * 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.report.fragments import android.os.Bundle 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 import com.keylesspalace.tusky.components.report.Screen import com.keylesspalace.tusky.databinding.FragmentReportNoteBinding import com.keylesspalace.tusky.util.Error 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.viewBinding import dagger.hilt.android.AndroidEntryPoint import java.io.IOException import kotlinx.coroutines.launch @AndroidEntryPoint class ReportNoteFragment : Fragment(R.layout.fragment_report_note) { private val viewModel: ReportViewModel by activityViewModels() private val binding by viewBinding(FragmentReportNoteBinding::bind) override fun onViewCreated(view: View, savedInstanceState: Bundle?) { fillViews() handleChanges() handleClicks() subscribeObservables() } private fun handleChanges() { binding.editNote.doAfterTextChanged { viewModel.reportNote = it?.toString().orEmpty() } binding.checkIsNotifyRemote.setOnCheckedChangeListener { _, isChecked -> viewModel.isRemoteNotify = isChecked } } private fun fillViews() { binding.editNote.setText(viewModel.reportNote) if (viewModel.isRemoteAccount) { binding.checkIsNotifyRemote.show() binding.reportDescriptionRemoteInstance.show() } else { binding.checkIsNotifyRemote.hide() binding.reportDescriptionRemoteInstance.hide() } if (viewModel.isRemoteAccount) { binding.checkIsNotifyRemote.text = getString(R.string.report_remote_instance, viewModel.remoteServer) } binding.checkIsNotifyRemote.isChecked = viewModel.isRemoteNotify } private fun subscribeObservables() { 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) } } } } private fun showError(error: Throwable?) { binding.editNote.isEnabled = true binding.checkIsNotifyRemote.isEnabled = true binding.buttonReport.isEnabled = true 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 ) .setAction(R.string.action_retry) { sendReport() } .show() } private fun sendReport() { viewModel.doReport() } private fun showLoading() { binding.buttonReport.isEnabled = false binding.buttonBack.isEnabled = false binding.editNote.isEnabled = false binding.checkIsNotifyRemote.isEnabled = false binding.progressBar.show() } private fun handleClicks() { binding.buttonBack.setOnClickListener { viewModel.navigateTo(Screen.Back) } binding.buttonReport.setOnClickListener { sendReport() } } companion object { fun newInstance() = ReportNoteFragment() } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/report/fragments/ReportStatusesFragment.kt ================================================ /* Copyright 2019 Joel Pyska * * 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.report.fragments import android.content.SharedPreferences import android.os.Bundle import android.view.Menu import android.view.MenuInflater import android.view.MenuItem import android.view.View import androidx.core.app.ActivityOptionsCompat import androidx.core.view.MenuProvider import androidx.core.view.ViewCompat import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.paging.LoadState import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.SimpleItemAnimator import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener import com.google.android.material.snackbar.Snackbar import com.keylesspalace.tusky.R import com.keylesspalace.tusky.StatusListActivity import com.keylesspalace.tusky.ViewMediaActivity import com.keylesspalace.tusky.components.account.AccountActivity import com.keylesspalace.tusky.components.report.ReportViewModel import com.keylesspalace.tusky.components.report.Screen import com.keylesspalace.tusky.components.report.adapter.AdapterHandler import com.keylesspalace.tusky.components.report.adapter.StatusesAdapter import com.keylesspalace.tusky.databinding.FragmentReportStatusesBinding import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.entity.Attachment import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.util.CardViewMode import com.keylesspalace.tusky.util.StatusDisplayOptions import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.util.visible import com.keylesspalace.tusky.viewdata.AttachmentViewData import com.keylesspalace.tusky.viewdata.StatusViewData import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch @AndroidEntryPoint class ReportStatusesFragment : Fragment(R.layout.fragment_report_statuses), OnRefreshListener, MenuProvider, AdapterHandler { @Inject lateinit var accountManager: AccountManager @Inject lateinit var preferences: SharedPreferences private val viewModel: ReportViewModel by activityViewModels() private val binding by viewBinding(FragmentReportStatusesBinding::bind) private var adapter: StatusesAdapter? = null private var snackbarErrorRetry: Snackbar? = null override fun showMedia(v: View?, status: StatusViewData.Concrete, idx: Int) { when (status.attachments[idx].type) { Attachment.Type.GIFV, Attachment.Type.VIDEO, Attachment.Type.IMAGE, Attachment.Type.AUDIO -> { val attachments = AttachmentViewData.list(status) val intent = ViewMediaActivity.newIntent(requireContext(), attachments, idx) if (v != null) { val url = status.attachments[idx].url ViewCompat.setTransitionName(v, url) val options = ActivityOptionsCompat.makeSceneTransitionAnimation( requireActivity(), v, url ) startActivity(intent, options.toBundle()) } else { startActivity(intent) } } Attachment.Type.UNKNOWN -> { } } } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED) handleClicks() initStatusesView() binding.swipeRefreshLayout.setOnRefreshListener(this) } override fun onDestroyView() { // Clear the adapter to prevent leaking the View adapter = null snackbarErrorRetry = null super.onDestroyView() } override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { menuInflater.inflate(R.menu.fragment_report_statuses, menu) } override fun onMenuItemSelected(menuItem: MenuItem): Boolean { return when (menuItem.itemId) { R.id.action_refresh -> { binding.swipeRefreshLayout.isRefreshing = true onRefresh() true } else -> false } } override fun onRefresh() { snackbarErrorRetry?.dismiss() snackbarErrorRetry = null adapter?.refresh() } private fun initStatusesView() { val statusDisplayOptions = StatusDisplayOptions( animateAvatars = false, mediaPreviewEnabled = accountManager.activeAccount?.mediaPreviewEnabled ?: true, useAbsoluteTime = preferences.getBoolean(PrefKeys.ABSOLUTE_TIME_VIEW, false), showBotOverlay = false, useBlurhash = preferences.getBoolean(PrefKeys.USE_BLURHASH, true), cardViewMode = CardViewMode.NONE, 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), showSensitiveMedia = accountManager.activeAccount!!.alwaysShowSensitiveMedia, openSpoiler = accountManager.activeAccount!!.alwaysOpenSpoiler ) val adapter = StatusesAdapter(statusDisplayOptions, viewModel.statusViewState, this) this.adapter = adapter binding.recyclerView.addItemDecoration( DividerItemDecoration(requireContext(), DividerItemDecoration.VERTICAL) ) binding.recyclerView.layoutManager = LinearLayoutManager(requireContext()) binding.recyclerView.adapter = adapter (binding.recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false viewLifecycleOwner.lifecycleScope.launch { viewModel.statusesFlow.collectLatest { pagingData -> adapter.submitData(pagingData) } } adapter.addLoadStateListener { loadState -> if (loadState.refresh is LoadState.Error || loadState.append is LoadState.Error || loadState.prepend is LoadState.Error ) { showError(adapter) } binding.progressBarBottom.visible(loadState.append == LoadState.Loading) binding.progressBarTop.visible(loadState.prepend == LoadState.Loading) binding.progressBarLoading.visible( loadState.refresh == LoadState.Loading && !binding.swipeRefreshLayout.isRefreshing ) if (loadState.refresh != LoadState.Loading) { binding.swipeRefreshLayout.isRefreshing = false } } } private fun showError(adapter: StatusesAdapter) { if (snackbarErrorRetry?.isShown != true) { snackbarErrorRetry = Snackbar.make(binding.swipeRefreshLayout, R.string.failed_fetch_posts, Snackbar.LENGTH_INDEFINITE) .setAction(R.string.action_retry) { adapter.retry() }.also { it.show() } } } private fun handleClicks() { binding.buttonCancel.setOnClickListener { viewModel.navigateTo(Screen.Back) } binding.buttonContinue.setOnClickListener { viewModel.navigateTo(Screen.Note) } } override fun setStatusChecked(status: Status, isChecked: Boolean) { viewModel.setStatusChecked(status, isChecked) } override fun isStatusChecked(id: String): Boolean { return viewModel.isStatusChecked(id) } override fun onViewAccount(id: String) = startActivity( AccountActivity.getIntent(requireContext(), id) ) override fun onViewTag(tag: String) = startActivity( StatusListActivity.newHashtagIntent(requireContext(), tag) ) override fun onViewUrl(url: String) = viewModel.checkClickedUrl(url) companion object { fun newInstance() = ReportStatusesFragment() } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/report/model/StatusViewState.kt ================================================ /* Copyright 2019 Joel Pyska * * 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.report.model class StatusViewState { private val mediaShownState = HashMap() private val contentShownState = HashMap() private val longContentCollapsedState = HashMap() 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 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) 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 ) } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/scheduled/ScheduledStatusActivity.kt ================================================ /* 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.components.scheduled import android.content.Context import android.content.Intent import android.os.Bundle import android.view.Menu import android.view.MenuInflater import android.view.MenuItem import androidx.activity.viewModels import androidx.core.view.MenuProvider import androidx.lifecycle.lifecycleScope import androidx.paging.LoadState import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.keylesspalace.tusky.BaseActivity import com.keylesspalace.tusky.R import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.StatusScheduledEvent import com.keylesspalace.tusky.components.compose.ComposeActivity import com.keylesspalace.tusky.databinding.ActivityScheduledStatusBinding import com.keylesspalace.tusky.entity.ScheduledStatus import com.keylesspalace.tusky.util.ensureBottomPadding import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.viewBinding import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch @AndroidEntryPoint class ScheduledStatusActivity : BaseActivity(), ScheduledStatusActionListener, MenuProvider { @Inject lateinit var eventHub: EventHub private val viewModel: ScheduledStatusViewModel by viewModels() private val binding by viewBinding(ActivityScheduledStatusBinding::inflate) private val adapter = ScheduledStatusAdapter(this) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(binding.root) addMenuProvider(this) setSupportActionBar(binding.includedToolbar.toolbar) supportActionBar?.run { title = getString(R.string.title_scheduled_posts) setDisplayHomeAsUpEnabled(true) setDisplayShowHomeEnabled(true) } binding.scheduledTootList.ensureBottomPadding() binding.swipeRefreshLayout.setOnRefreshListener(this::refreshStatuses) binding.scheduledTootList.setHasFixedSize(true) binding.scheduledTootList.layoutManager = LinearLayoutManager(this) val divider = DividerItemDecoration(this, DividerItemDecoration.VERTICAL) binding.scheduledTootList.addItemDecoration(divider) binding.scheduledTootList.adapter = adapter lifecycleScope.launch { viewModel.data.collectLatest { pagingData -> adapter.submitData(pagingData) } } adapter.addLoadStateListener { loadState -> if (loadState.refresh is LoadState.Error) { binding.progressBar.hide() binding.errorMessageView.show() val errorState = loadState.refresh as LoadState.Error binding.errorMessageView.setup(errorState.error) { refreshStatuses() } } if (loadState.refresh != LoadState.Loading) { binding.swipeRefreshLayout.isRefreshing = false } 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.show() } else { binding.errorMessageView.hide() } } } lifecycleScope.launch { eventHub.events.collect { event -> if (event is StatusScheduledEvent) { adapter.refresh() } } } } override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { menuInflater.inflate(R.menu.activity_scheduled_status, menu) } override fun onMenuItemSelected(menuItem: MenuItem): Boolean { return when (menuItem.itemId) { R.id.action_refresh -> { binding.swipeRefreshLayout.isRefreshing = true refreshStatuses() true } else -> false } } private fun refreshStatuses() { adapter.refresh() } override fun edit(item: ScheduledStatus) { val intent = ComposeActivity.startIntent( this, ComposeActivity.ComposeOptions( scheduledTootId = item.id, content = item.params.text, contentWarning = item.params.spoilerText, mediaAttachments = item.mediaAttachments, inReplyToId = item.params.inReplyToId, visibility = item.params.visibility, scheduledAt = item.scheduledAt, sensitive = item.params.sensitive, kind = ComposeActivity.ComposeKind.EDIT_SCHEDULED ) ) startActivity(intent) } override fun delete(item: ScheduledStatus) { MaterialAlertDialogBuilder(this) .setMessage(R.string.delete_scheduled_post_warning) .setNegativeButton(android.R.string.cancel, null) .setPositiveButton(android.R.string.ok) { _, _ -> viewModel.deleteScheduledStatus(item) } .show() } companion object { fun newIntent(context: Context) = Intent(context, ScheduledStatusActivity::class.java) } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/scheduled/ScheduledStatusAdapter.kt ================================================ /* 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.components.scheduled import android.view.LayoutInflater import android.view.ViewGroup import androidx.paging.PagingDataAdapter import androidx.recyclerview.widget.DiffUtil import com.keylesspalace.tusky.databinding.ItemScheduledStatusBinding import com.keylesspalace.tusky.entity.ScheduledStatus import com.keylesspalace.tusky.util.BindingHolder interface ScheduledStatusActionListener { fun edit(item: ScheduledStatus) fun delete(item: ScheduledStatus) } class ScheduledStatusAdapter( val listener: ScheduledStatusActionListener ) : PagingDataAdapter>( object : DiffUtil.ItemCallback() { override fun areItemsTheSame(oldItem: ScheduledStatus, newItem: ScheduledStatus): Boolean { return oldItem.id == newItem.id } 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 ) return BindingHolder(binding) } override fun onBindViewHolder( holder: BindingHolder, position: Int ) { getItem(position)?.let { item -> holder.binding.edit.isEnabled = true holder.binding.delete.isEnabled = true holder.binding.text.text = item.params.text holder.binding.edit.setOnClickListener { listener.edit(item) } holder.binding.delete.setOnClickListener { listener.delete(item) } } } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/scheduled/ScheduledStatusPagingSource.kt ================================================ /* 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.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 class ScheduledStatusPagingSourceFactory( private val mastodonApi: MastodonApi ) : () -> ScheduledStatusPagingSource { private val scheduledTootsCache = mutableListOf() private var pagingSource: ScheduledStatusPagingSource? = null override fun invoke(): ScheduledStatusPagingSource { return ScheduledStatusPagingSource(mastodonApi, scheduledTootsCache).also { pagingSource = it } } fun remove(status: ScheduledStatus) { scheduledTootsCache.remove(status) pagingSource?.invalidate() } } class ScheduledStatusPagingSource( private val mastodonApi: MastodonApi, private val scheduledStatusesCache: MutableList ) : PagingSource() { override fun getRefreshKey(state: PagingState): String? { return null } override suspend fun load(params: LoadParams): LoadResult { return if (params is LoadParams.Refresh && scheduledStatusesCache.isNotEmpty()) { LoadResult.Page( data = scheduledStatusesCache, prevKey = null, nextKey = scheduledStatusesCache.lastOrNull()?.id ) } else { try { val result = mastodonApi.scheduledStatuses( maxId = params.key, limit = params.loadSize ).getOrThrow() LoadResult.Page( data = result, prevKey = null, nextKey = result.lastOrNull()?.id ) } catch (e: Exception) { Log.w("ScheduledStatuses", "Error loading scheduled statuses", e) LoadResult.Error(e) } } } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/scheduled/ScheduledStatusViewModel.kt ================================================ /* 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.components.scheduled import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.paging.Pager import androidx.paging.PagingConfig import androidx.paging.cachedIn import at.connyduck.calladapter.networkresult.fold import com.keylesspalace.tusky.entity.ScheduledStatus import com.keylesspalace.tusky.network.MastodonApi import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.launch @HiltViewModel class ScheduledStatusViewModel @Inject constructor( val mastodonApi: MastodonApi ) : ViewModel() { private val pagingSourceFactory = ScheduledStatusPagingSourceFactory(mastodonApi) val data = Pager( config = PagingConfig( pageSize = 20, initialLoadSize = 20 ), pagingSourceFactory = pagingSourceFactory ).flow .cachedIn(viewModelScope) fun deleteScheduledStatus(status: ScheduledStatus) { viewModelScope.launch { mastodonApi.deleteScheduledStatus(status.id).fold( { pagingSourceFactory.remove(status) }, { throwable -> Log.w("ScheduledTootViewModel", "Error deleting scheduled status", throwable) } ) } } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/search/SearchActivity.kt ================================================ /* 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.components.search import android.app.SearchManager import android.content.Context import android.content.Intent import android.os.Bundle import android.view.Menu import android.view.MenuInflater import android.view.MenuItem import android.view.MotionEvent import androidx.activity.viewModels import androidx.appcompat.widget.SearchView import androidx.core.view.MenuProvider import com.google.android.material.tabs.TabLayout import com.google.android.material.tabs.TabLayoutMediator import com.keylesspalace.tusky.BottomSheetActivity import com.keylesspalace.tusky.R import com.keylesspalace.tusky.components.search.adapter.SearchPagerAdapter import com.keylesspalace.tusky.databinding.ActivitySearchBinding import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.util.reduceSwipeSensitivity import com.keylesspalace.tusky.util.viewBinding import dagger.hilt.android.AndroidEntryPoint @AndroidEntryPoint class SearchActivity : BottomSheetActivity(), MenuProvider, SearchView.OnQueryTextListener { private val viewModel: SearchViewModel by viewModels() private val binding by viewBinding(ActivitySearchBinding::inflate) private lateinit var searchView: SearchView override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(binding.root) setSupportActionBar(binding.toolbar) supportActionBar?.apply { setDisplayHomeAsUpEnabled(true) setDisplayShowHomeEnabled(true) setDisplayShowTitleEnabled(false) } addMenuProvider(this) setupPages() handleIntent(intent) } private fun setupPages() { binding.pages.reduceSwipeSensitivity() binding.pages.adapter = SearchPagerAdapter(this) val enableSwipeForTabs = preferences.getBoolean(PrefKeys.ENABLE_SWIPE_FOR_TABS, true) binding.pages.isUserInputEnabled = enableSwipeForTabs TabLayoutMediator(binding.tabs, binding.pages) { tab, position -> tab.text = getPageTitle(position) }.attach() } override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) handleIntent(intent) } override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { menuInflater.inflate(R.menu.search_toolbar, menu) val searchViewMenuItem = menu.findItem(R.id.action_search) searchViewMenuItem.expandActionView() searchView = searchViewMenuItem.actionView as SearchView setupSearchView() setupClearFocusOnClickListeners() } override fun onMenuItemSelected(menuItem: MenuItem): Boolean { return false } private fun getPageTitle(position: Int): CharSequence { return when (position) { 0 -> getString(R.string.title_posts) 1 -> getString(R.string.title_accounts) 2 -> getString(R.string.title_hashtags_dialog) else -> throw IllegalArgumentException("Unknown page index: $position") } } private fun handleIntent(intent: Intent) { if (Intent.ACTION_SEARCH == intent.action) { viewModel.currentQuery = intent.getStringExtra(SearchManager.QUERY).orEmpty() viewModel.search(viewModel.currentQuery) searchView.clearFocus() } } private fun setupClearFocusOnClickListeners() { binding.overlayPagesClickView.setOnTouchListener { view, event -> if (event.action == MotionEvent.ACTION_DOWN) { searchView.clearFocus() view.performClick() } false } binding.toolbar.setOnClickListener { searchView.clearFocus() } binding.tabs.addOnTabSelectedListener(object : TabLayout.OnTabSelectedListener { override fun onTabSelected(p0: TabLayout.Tab?) { searchView.clearFocus() } override fun onTabUnselected(p0: TabLayout.Tab?) {} override fun onTabReselected(p0: TabLayout.Tab?) { searchView.clearFocus() } }) } private fun setupSearchView() { searchView.setIconifiedByDefault(false) 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 // screen. // // E.g., see: // // - https://stackoverflow.com/questions/41662373/android-toolbar-searchview-too-wide-to-move-other-items // - https://stackoverflow.com/questions/51525088/how-to-control-size-of-a-searchview-in-toolbar // - https://stackoverflow.com/questions/36976163/push-icons-away-when-expandig-searchview-in-android-toolbar // - https://issuetracker.google.com/issues/36976484 // // The fix is to use 'app:showAsAction="ifRoom|collapseActionView"' and then immediately // expand it after inflating. That sets the width correctly. // // But if you do that code in AppCompatDelegateImpl activates, and when the user presses // the "Back" button the SearchView is first set to its collapsed state. The user has to // press "Back" again to exit the activity. This is clearly unacceptable. // // It appears to be impossible to override this behaviour on API level < 33. // // SearchView does allow you to specify the maximum width. So take the screen width, // subtract 48dp * 2 (for the menu icon and back icon on either side), convert to pixels, // and use that. val pxScreenWidth = resources.displayMetrics.widthPixels 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) if (viewModel.currentSearchFieldContent == "") searchView.requestFocus() } override fun onQueryTextSubmit(query: String?): Boolean { return false } override fun onQueryTextChange(newText: String?): Boolean { viewModel.currentSearchFieldContent = newText return false } companion object { const val TAG = "SearchActivity" fun getIntent(context: Context) = Intent(context, SearchActivity::class.java) } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/search/SearchType.kt ================================================ /* 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.components.search enum class SearchType(val apiParameter: String) { Status("statuses"), Account("accounts"), Hashtag("hashtags") } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/search/SearchViewModel.kt ================================================ /* 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.components.search import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.paging.Pager 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.AccountManager import com.keylesspalace.tusky.db.entity.AccountEntity import com.keylesspalace.tusky.entity.DeletedStatus import com.keylesspalace.tusky.entity.Filter import com.keylesspalace.tusky.entity.Status 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 dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.Deferred import kotlinx.coroutines.async import kotlinx.coroutines.launch @HiltViewModel class SearchViewModel @Inject constructor( mastodonApi: MastodonApi, private val timelineCases: TimelineCases, 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 val mediaPreviewEnabled = activeAccount?.mediaPreviewEnabled == true val alwaysShowSensitiveMedia = activeAccount?.alwaysShowSensitiveMedia == true val alwaysOpenSpoiler = activeAccount?.alwaysOpenSpoiler == true private val loadedStatuses: MutableList = mutableListOf() private val statusesPagingSourceFactory = SearchPagingSourceFactory(mastodonApi, SearchType.Status, loadedStatuses) { it.statuses.map { status -> status.toViewData( isShowingContent = status.shouldShowContent(alwaysShowSensitiveMedia, Filter.Kind.PUBLIC), isExpanded = alwaysOpenSpoiler, isCollapsed = true, filter = status.getApplicableFilter(Filter.Kind.PUBLIC), ) }.apply { loadedStatuses.addAll(this) } } 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 ), pagingSourceFactory = statusesPagingSourceFactory ).flow .cachedIn(viewModelScope) val accountsFlow = Pager( config = PagingConfig( pageSize = DEFAULT_LOAD_SIZE, initialLoadSize = DEFAULT_LOAD_SIZE ), pagingSourceFactory = accountsPagingSourceFactory ).flow .cachedIn(viewModelScope) val hashtagsFlow = Pager( config = PagingConfig( pageSize = DEFAULT_LOAD_SIZE, initialLoadSize = DEFAULT_LOAD_SIZE ), pagingSourceFactory = hashtagsPagingSourceFactory ).flow .cachedIn(viewModelScope) fun search(query: String) { loadedStatuses.clear() statusesPagingSourceFactory.newSearch(query) accountsPagingSourceFactory.newSearch(query) hashtagsPagingSourceFactory.newSearch(query) } fun removeItem(statusViewData: StatusViewData.Concrete, deleteMedia: Boolean) { viewModelScope.launch { if (timelineCases.delete(statusViewData.id, deleteMedia).isSuccess) { if (loadedStatuses.remove(statusViewData)) { statusesPagingSourceFactory.invalidate() } } } } fun clearStatusCache() { loadedStatuses.clear() } fun expandedChange(statusViewData: StatusViewData.Concrete, expanded: Boolean) { updateStatusViewData(statusViewData.copy(isExpanded = expanded)) } fun reblog(statusViewData: StatusViewData.Concrete, reblog: Boolean, visibility: Status.Visibility = Status.Visibility.PUBLIC) { viewModelScope.launch { timelineCases.reblog(statusViewData.id, reblog, visibility).fold({ updateStatus( statusViewData.status.copy( reblogged = reblog, reblog = statusViewData.status.reblog?.copy(reblogged = reblog) ) ) }, { t -> Log.d(TAG, "Failed to reblog status ${statusViewData.id}", t) }) } } fun contentHiddenChange(statusViewData: StatusViewData.Concrete, isShowing: Boolean) { updateStatusViewData(statusViewData.copy(isShowingContent = isShowing)) } fun collapsedChange(statusViewData: StatusViewData.Concrete, collapsed: Boolean) { updateStatusViewData(statusViewData.copy(isCollapsed = collapsed)) } fun voteInPoll(statusViewData: StatusViewData.Concrete, choices: List) { val votedPoll = statusViewData.status.actionableStatus.poll!!.votedCopy(choices) updateStatus(statusViewData.status.copy(poll = votedPoll)) viewModelScope.launch { timelineCases.voteInPoll(statusViewData.id, votedPoll.id, choices) .onFailure { t -> Log.d(TAG, "Failed to vote in poll: ${statusViewData.id}", t) } } } fun showPollResults(status: StatusViewData.Concrete) = viewModelScope.launch { timelineCases.showPollResults(status.actionableId) } fun favorite(statusViewData: StatusViewData.Concrete, isFavorited: Boolean) { updateStatus(statusViewData.status.copy(favourited = isFavorited)) viewModelScope.launch { timelineCases.favourite(statusViewData.id, isFavorited) } } fun bookmark(statusViewData: StatusViewData.Concrete, isBookmarked: Boolean) { updateStatus(statusViewData.status.copy(bookmarked = isBookmarked)) viewModelScope.launch { timelineCases.bookmark(statusViewData.id, isBookmarked) } } fun muteAccount(accountId: String, notifications: Boolean, duration: Int?) { viewModelScope.launch { timelineCases.mute(accountId, notifications, duration) } } fun pinAccount(status: Status, isPin: Boolean) { viewModelScope.launch { timelineCases.pin(status.id, isPin) } } fun blockAccount(accountId: String) { viewModelScope.launch { timelineCases.block(accountId) } } fun deleteStatusAsync(id: String, deleteMedia: Boolean): Deferred> { return viewModelScope.async { timelineCases.delete(id, deleteMedia) } } fun muteConversation(statusViewData: StatusViewData.Concrete, mute: Boolean) { updateStatus(statusViewData.status.copy(muted = mute)) viewModelScope.launch { timelineCases.muteConversation(statusViewData.id, mute) } } 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) { loadedStatuses[idx] = newStatusViewData statusesPagingSourceFactory.invalidate() } } private fun updateStatus(newStatus: Status) { val statusViewData = loadedStatuses.find { it.id == newStatus.id } if (statusViewData != null) { updateStatusViewData(statusViewData.copy(status = newStatus)) } } companion object { private const val TAG = "SearchViewModel" private const val DEFAULT_LOAD_SIZE = 20 } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchAccountsAdapter.kt ================================================ /* 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.components.search.adapter import android.view.LayoutInflater import android.view.ViewGroup import androidx.paging.PagingDataAdapter import androidx.recyclerview.widget.DiffUtil import com.keylesspalace.tusky.adapter.AccountViewHolder import com.keylesspalace.tusky.databinding.ItemAccountBinding import com.keylesspalace.tusky.entity.TimelineAccount import com.keylesspalace.tusky.interfaces.LinkListener class SearchAccountsAdapter(private val linkListener: LinkListener, private val animateAvatars: Boolean, private val animateEmojis: Boolean, private val showBotOverlay: Boolean) : PagingDataAdapter(ACCOUNT_COMPARATOR) { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AccountViewHolder { val binding = ItemAccountBinding.inflate( LayoutInflater.from(parent.context), parent, false ) return AccountViewHolder(binding) } override fun onBindViewHolder(holder: AccountViewHolder, position: Int) { getItem(position)?.let { item -> holder.apply { setupWithAccount(item, animateAvatars, animateEmojis, showBotOverlay) setupLinkListener(linkListener) } } } companion object { val ACCOUNT_COMPARATOR = object : DiffUtil.ItemCallback() { override fun areContentsTheSame( oldItem: TimelineAccount, newItem: TimelineAccount ): Boolean = oldItem == newItem override fun areItemsTheSame( oldItem: TimelineAccount, newItem: TimelineAccount ): Boolean = oldItem.id == newItem.id } } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchHashtagsAdapter.kt ================================================ /* 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.components.search.adapter import android.view.LayoutInflater import android.view.ViewGroup import androidx.paging.PagingDataAdapter import androidx.recyclerview.widget.DiffUtil import com.keylesspalace.tusky.R import com.keylesspalace.tusky.databinding.ItemHashtagBinding import com.keylesspalace.tusky.entity.HashTag import com.keylesspalace.tusky.interfaces.LinkListener import com.keylesspalace.tusky.util.BindingHolder class SearchHashtagsAdapter(private val linkListener: LinkListener) : PagingDataAdapter>(HASHTAG_COMPARATOR) { override fun onCreateViewHolder( parent: ViewGroup, viewType: Int ): BindingHolder { val binding = ItemHashtagBinding.inflate(LayoutInflater.from(parent.context), parent, false) return BindingHolder(binding) } override fun onBindViewHolder(holder: BindingHolder, position: Int) { getItem(position)?.let { (name) -> holder.binding.root.text = holder.binding.root.context.getString(R.string.hashtag_format, name) holder.binding.root.setOnClickListener { linkListener.onViewTag(name) } } } companion object { val HASHTAG_COMPARATOR = object : DiffUtil.ItemCallback() { override fun areContentsTheSame(oldItem: HashTag, newItem: HashTag): Boolean = oldItem.name == newItem.name override fun areItemsTheSame(oldItem: HashTag, newItem: HashTag): Boolean = oldItem.name == newItem.name } } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchPagerAdapter.kt ================================================ /* Copyright 2019 Joel Pyska * * 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.search.adapter import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentActivity import androidx.viewpager2.adapter.FragmentStateAdapter import com.keylesspalace.tusky.components.search.fragments.SearchAccountsFragment import com.keylesspalace.tusky.components.search.fragments.SearchHashtagsFragment import com.keylesspalace.tusky.components.search.fragments.SearchStatusesFragment class SearchPagerAdapter(activity: FragmentActivity) : FragmentStateAdapter(activity) { override fun createFragment(position: Int): Fragment { return when (position) { 0 -> SearchStatusesFragment.newInstance() 1 -> SearchAccountsFragment.newInstance() 2 -> SearchHashtagsFragment.newInstance() else -> throw IllegalArgumentException("Unknown page index: $position") } } override fun getItemCount() = 3 } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchPagingSource.kt ================================================ /* 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.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 class SearchPagingSource( private val mastodonApi: MastodonApi, private val searchType: SearchType, private val searchRequest: String, private val initialItems: List?, private val parser: (SearchResult) -> List ) : PagingSource() { override fun getRefreshKey(state: PagingState): Int? { return null } override suspend fun load(params: LoadParams): LoadResult { if (searchRequest.isEmpty()) { return LoadResult.Page( data = emptyList(), prevKey = null, nextKey = null ) } if (params.key == null && !initialItems.isNullOrEmpty()) { return LoadResult.Page( data = initialItems.toList(), prevKey = null, nextKey = initialItems.size ) } val currentKey = params.key ?: 0 try { val data = mastodonApi.search( query = searchRequest, type = searchType.apiParameter, resolve = true, limit = params.loadSize, offset = currentKey, following = false ).getOrThrow() val res = parser(data) val nextKey = if (res.isEmpty()) { null } else { currentKey + res.size } return LoadResult.Page( data = res, prevKey = null, nextKey = nextKey ) } catch (e: Exception) { return LoadResult.Error(e) } } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchPagingSourceFactory.kt ================================================ /* 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.components.search.adapter import com.keylesspalace.tusky.components.search.SearchType import com.keylesspalace.tusky.entity.SearchResult import com.keylesspalace.tusky.network.MastodonApi class SearchPagingSourceFactory( private val mastodonApi: MastodonApi, private val searchType: SearchType, private val initialItems: List? = null, private val parser: (SearchResult) -> List ) : () -> SearchPagingSource { private var searchRequest: String = "" private var currentSource: SearchPagingSource? = null override fun invoke(): SearchPagingSource { return SearchPagingSource( mastodonApi = mastodonApi, searchType = searchType, searchRequest = searchRequest, initialItems = initialItems, parser = parser ).also { source -> currentSource = source } } fun newSearch(newSearchRequest: String) { this.searchRequest = newSearchRequest currentSource?.invalidate() } fun invalidate() { currentSource?.invalidate() } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchStatusesAdapter.kt ================================================ /* 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.components.search.adapter import android.view.LayoutInflater import android.view.ViewGroup import androidx.paging.PagingDataAdapter import androidx.recyclerview.widget.DiffUtil import com.keylesspalace.tusky.R import com.keylesspalace.tusky.adapter.StatusBaseViewHolder import com.keylesspalace.tusky.adapter.StatusViewHolder import com.keylesspalace.tusky.interfaces.StatusActionListener import com.keylesspalace.tusky.util.StatusDisplayOptions import com.keylesspalace.tusky.viewdata.StatusViewData class SearchStatusesAdapter( private val statusDisplayOptions: StatusDisplayOptions, private val statusListener: StatusActionListener ) : PagingDataAdapter(STATUS_COMPARATOR) { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): StatusViewHolder { val view = LayoutInflater.from(parent.context) .inflate(R.layout.item_status, parent, false) return StatusViewHolder(view) } override fun onBindViewHolder(holder: StatusViewHolder, position: Int) { onBindViewHolder(holder, position, emptyList()) } override fun onBindViewHolder(holder: StatusViewHolder, position: Int, payloads: List) { getItem(position)?.let { item -> holder.setupWithStatus(item, statusListener, statusDisplayOptions, payloads, true) } } companion object { val STATUS_COMPARATOR = object : DiffUtil.ItemCallback() { override fun areItemsTheSame( oldItem: StatusViewData.Concrete, newItem: StatusViewData.Concrete ): Boolean { return oldItem.id == newItem.id } override fun areContentsTheSame( oldItem: StatusViewData.Concrete, newItem: StatusViewData.Concrete ): Boolean { return false // Items are different always. It allows to refresh timestamp on every view holder update } override fun getChangePayload( oldItem: StatusViewData.Concrete, newItem: StatusViewData.Concrete ): Any? { return if (oldItem == newItem) { // If items are equal - update timestamp only StatusBaseViewHolder.Key.KEY_CREATED } else { // If items are different - update the whole view holder null } } } } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchAccountsFragment.kt ================================================ /* 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.components.search.fragments import android.content.SharedPreferences import android.os.Bundle import android.view.View import androidx.paging.PagingData import androidx.paging.PagingDataAdapter import androidx.recyclerview.widget.DividerItemDecoration import com.keylesspalace.tusky.components.search.adapter.SearchAccountsAdapter import com.keylesspalace.tusky.entity.TimelineAccount import com.keylesspalace.tusky.settings.PrefKeys import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject import kotlinx.coroutines.flow.Flow @AndroidEntryPoint class SearchAccountsFragment : SearchFragment() { @Inject lateinit var preferences: SharedPreferences override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) binding.searchRecyclerView.addItemDecoration( DividerItemDecoration( binding.searchRecyclerView.context, DividerItemDecoration.VERTICAL ) ) } override fun createAdapter(): PagingDataAdapter { return SearchAccountsAdapter( this, preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false), preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false), preferences.getBoolean(PrefKeys.SHOW_BOT_OVERLAY, true) ) } override val data: Flow> get() = viewModel.accountsFlow companion object { fun newInstance() = SearchAccountsFragment() } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchFragment.kt ================================================ package com.keylesspalace.tusky.components.search.fragments import android.os.Bundle import android.view.Menu import android.view.MenuInflater import android.view.MenuItem import android.view.View import androidx.core.view.MenuProvider import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.paging.LoadState import androidx.paging.PagingData import androidx.paging.PagingDataAdapter import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.SimpleItemAnimator import androidx.swiperefreshlayout.widget.SwipeRefreshLayout import com.google.android.material.snackbar.Snackbar import com.keylesspalace.tusky.BottomSheetActivity import com.keylesspalace.tusky.R import com.keylesspalace.tusky.StatusListActivity import com.keylesspalace.tusky.components.account.AccountActivity import com.keylesspalace.tusky.components.search.SearchViewModel import com.keylesspalace.tusky.databinding.FragmentSearchBinding import com.keylesspalace.tusky.interfaces.LinkListener import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.util.ensureBottomPadding import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.util.visible import javax.inject.Inject import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch abstract class SearchFragment : Fragment(R.layout.fragment_search), LinkListener, SwipeRefreshLayout.OnRefreshListener, MenuProvider { @Inject lateinit var mastodonApi: MastodonApi protected val viewModel: SearchViewModel by activityViewModels() protected val binding by viewBinding(FragmentSearchBinding::bind) private var snackbarErrorRetry: Snackbar? = null abstract fun createAdapter(): PagingDataAdapter abstract val data: Flow> protected var adapter: PagingDataAdapter? = null private var currentQuery: String = "" override fun onViewCreated(view: View, savedInstanceState: Bundle?) { val adapter = initAdapter() binding.swipeRefreshLayout.setOnRefreshListener(this) binding.searchRecyclerView.ensureBottomPadding() requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED) subscribeObservables(adapter) } override fun onDestroyView() { // Clear the adapter to prevent leaking the View adapter = null snackbarErrorRetry = null super.onDestroyView() } private fun subscribeObservables(adapter: PagingDataAdapter) { viewLifecycleOwner.lifecycleScope.launch { data.collectLatest { pagingData -> adapter.submitData(pagingData) } } adapter.addLoadStateListener { loadState -> if (loadState.refresh is LoadState.Error) { showError(adapter) } 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 ) if (loadState.refresh != LoadState.Loading) { binding.swipeRefreshLayout.isRefreshing = false currentQuery = viewModel.currentQuery } binding.progressBarBottom.visible(loadState.append == LoadState.Loading) 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_search, menu) } override fun onMenuItemSelected(menuItem: MenuItem): Boolean { return when (menuItem.itemId) { R.id.action_refresh -> { binding.swipeRefreshLayout.isRefreshing = true onRefresh() true } else -> false } } private fun initAdapter(): PagingDataAdapter { binding.searchRecyclerView.layoutManager = LinearLayoutManager(binding.searchRecyclerView.context) val adapter = createAdapter() this.adapter = adapter binding.searchRecyclerView.adapter = adapter binding.searchRecyclerView.setHasFixedSize(true) (binding.searchRecyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false return adapter } private fun showError(adapter: PagingDataAdapter) { if (snackbarErrorRetry?.isShown != true) { snackbarErrorRetry = Snackbar.make(binding.root, R.string.failed_search, Snackbar.LENGTH_INDEFINITE) .setAction(R.string.action_retry) { snackbarErrorRetry = null adapter.retry() }.also { it.show() } } } override fun onViewAccount(id: String) { bottomSheetActivity?.startActivityWithSlideInAnimation( AccountActivity.getIntent(requireContext(), id) ) } override fun onViewTag(tag: String) { bottomSheetActivity?.startActivityWithSlideInAnimation( StatusListActivity.newHashtagIntent(requireContext(), tag) ) } override fun onViewUrl(url: String) { bottomSheetActivity?.viewUrl(url) } protected val bottomSheetActivity get() = (activity as? BottomSheetActivity) override fun onRefresh() { snackbarErrorRetry?.dismiss() snackbarErrorRetry = null adapter?.refresh() } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchHashtagsFragment.kt ================================================ /* 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.components.search.fragments import android.os.Bundle import android.view.View import androidx.paging.PagingData import androidx.paging.PagingDataAdapter import androidx.recyclerview.widget.DividerItemDecoration import com.keylesspalace.tusky.components.search.adapter.SearchHashtagsAdapter import com.keylesspalace.tusky.entity.HashTag import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.flow.Flow @AndroidEntryPoint class SearchHashtagsFragment : SearchFragment() { override val data: Flow> get() = viewModel.hashtagsFlow override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) binding.searchRecyclerView.addItemDecoration( DividerItemDecoration( binding.searchRecyclerView.context, DividerItemDecoration.VERTICAL ) ) } override fun createAdapter(): PagingDataAdapter = SearchHashtagsAdapter(this) companion object { fun newInstance() = SearchHashtagsFragment() } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt ================================================ /* 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.components.search.fragments import android.Manifest import android.app.DownloadManager import android.content.Intent import android.content.SharedPreferences import android.os.Build import android.os.Bundle import android.os.Environment import android.util.Log import android.view.View import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.widget.PopupMenu import androidx.core.app.ActivityOptionsCompat import androidx.core.content.getSystemService import androidx.core.net.toUri import androidx.core.view.ViewCompat import androidx.lifecycle.lifecycleScope import androidx.paging.PagingData import androidx.paging.PagingDataAdapter import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager import at.connyduck.calladapter.networkresult.fold import at.connyduck.calladapter.networkresult.onFailure import at.connyduck.sparkbutton.SparkButton import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.snackbar.Snackbar import com.keylesspalace.tusky.R import com.keylesspalace.tusky.ViewMediaActivity import com.keylesspalace.tusky.components.compose.ComposeActivity import com.keylesspalace.tusky.components.compose.ComposeActivity.ComposeOptions import com.keylesspalace.tusky.components.report.ReportActivity import com.keylesspalace.tusky.components.search.adapter.SearchStatusesAdapter import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.entity.AccountEntity import com.keylesspalace.tusky.entity.Attachment import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.Status.Mention 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.copyToClipboard import com.keylesspalace.tusky.util.openLink import com.keylesspalace.tusky.util.startActivityWithSlideInAnimation import com.keylesspalace.tusky.util.updateRelativeTimePeriodically import com.keylesspalace.tusky.view.ConfirmationBottomSheet.Companion.confirmFavourite import com.keylesspalace.tusky.view.ConfirmationBottomSheet.Companion.confirmReblog import com.keylesspalace.tusky.view.showMuteAccountDialog import com.keylesspalace.tusky.viewdata.AttachmentViewData import com.keylesspalace.tusky.viewdata.StatusViewData import dagger.hilt.android.AndroidEntryPoint import java.util.Locale import javax.inject.Inject import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.launch @AndroidEntryPoint class SearchStatusesFragment : SearchFragment(), StatusActionListener { @Inject lateinit var accountManager: AccountManager @Inject lateinit var preferences: SharedPreferences override val data: Flow> get() = viewModel.statusesFlow private var pendingMediaDownloads: List? = null private val downloadAllMediaPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted -> if (isGranted) { pendingMediaDownloads?.let { downloadAllMedia(it) } } else { Toast.makeText( context, R.string.error_media_download_permission, Toast.LENGTH_SHORT ).show() } pendingMediaDownloads = null } private var buttonToAnimate: SparkButton? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) pendingMediaDownloads = savedInstanceState?.getStringArrayList(PENDING_MEDIA_DOWNLOADS_STATE_KEY) } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) adapter?.let { updateRelativeTimePeriodically(preferences, it) } } override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) pendingMediaDownloads?.let { outState.putStringArrayList(PENDING_MEDIA_DOWNLOADS_STATE_KEY, ArrayList(it)) } } override fun createAdapter(): PagingDataAdapter { val statusDisplayOptions = StatusDisplayOptions( animateAvatars = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false), mediaPreviewEnabled = viewModel.mediaPreviewEnabled, 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, 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), showSensitiveMedia = accountManager.activeAccount!!.alwaysShowSensitiveMedia, openSpoiler = accountManager.activeAccount!!.alwaysOpenSpoiler ) val adapter = SearchStatusesAdapter(statusDisplayOptions, this) 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 adapter } override fun onDestroyView() { buttonToAnimate = null super.onDestroyView() } override fun onRefresh() { viewModel.clearStatusCache() super.onRefresh() } override fun onContentHiddenChange(isShowing: Boolean, position: Int) { adapter?.peek(position)?.let { viewModel.contentHiddenChange(it, isShowing) } } override fun onReply(position: Int) { adapter?.peek(position)?.let { status -> reply(status) } } override fun onFavourite(favourite: Boolean, position: Int, button: SparkButton?) { val status = adapter?.peek(position)?.asStatusOrNull() ?: return if (favourite) { confirmFavourite(preferences) { viewModel.favorite(status, true) buttonToAnimate?.playAnimation() buttonToAnimate?.isChecked = true } } else { viewModel.favorite(status, false) buttonToAnimate?.isChecked = false } } override fun onBookmark(bookmark: Boolean, position: Int) { adapter?.peek(position)?.let { status -> viewModel.bookmark(status, bookmark) } } override fun onMore(view: View, position: Int) { adapter?.peek(position)?.let { more(it, view, position) } } override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) { adapter?.peek(position)?.let { status -> when (status.attachments[attachmentIndex].type) { Attachment.Type.GIFV, Attachment.Type.VIDEO, Attachment.Type.IMAGE, Attachment.Type.AUDIO -> { val attachments = AttachmentViewData.list(status) val intent = ViewMediaActivity.newIntent( requireContext(), attachments, attachmentIndex ) if (view != null) { val url = status.attachments[attachmentIndex].url ViewCompat.setTransitionName(view, url) val options = ActivityOptionsCompat.makeSceneTransitionAnimation( requireActivity(), view, url ) startActivity(intent, options.toBundle()) } else { startActivity(intent) } } Attachment.Type.UNKNOWN -> { context?.openLink(status.attachments[attachmentIndex].unknownUrl) } } } } override fun onViewThread(position: Int) { adapter?.peek(position)?.status?.let { status -> val actionableStatus = status.actionableStatus bottomSheetActivity?.viewThread(actionableStatus.id, actionableStatus.url) } } override fun onOpenReblog(position: Int) { adapter?.peek(position)?.status?.let { status -> bottomSheetActivity?.viewAccount(status.account.id) } } override fun onExpandedChange(expanded: Boolean, position: Int) { adapter?.peek(position)?.let { viewModel.expandedChange(it, expanded) } } override fun onLoadMore(position: Int) { // Not possible here } override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) { adapter?.peek(position)?.let { viewModel.collapsedChange(it, isCollapsed) } } override fun onVoteInPoll(position: Int, choices: List) { adapter?.peek(position)?.let { viewModel.voteInPoll(it, choices) } } override fun onShowPollResults(position: Int) { adapter?.peek(position)?.asStatusOrNull()?.let { status -> viewModel.showPollResults(status) } } override fun clearWarningAction(position: Int) {} private fun removeItem(position: Int, deleteMedia: Boolean) { adapter?.peek(position)?.let { viewModel.removeItem(it, deleteMedia) } } override fun onReblog(reblog: Boolean, position: Int, visibility: Status.Visibility?, button: SparkButton?) { adapter?.peek(position)?.let { status -> buttonToAnimate = button if (reblog && visibility == null) { confirmReblog(preferences) { visibility -> viewModel.reblog(status, true, visibility) buttonToAnimate?.playAnimation() buttonToAnimate?.isChecked = true } } else { viewModel.reblog(status, reblog, visibility ?: Status.Visibility.PUBLIC) if (reblog) { buttonToAnimate?.playAnimation() } buttonToAnimate?.isChecked = false } } } override fun onUntranslate(position: Int) { adapter?.peek(position)?.let { viewModel.untranslate(it) } } private fun reply(status: StatusViewData.Concrete) { val actionableStatus = status.actionable val mentionedUsernames = actionableStatus.mentions.map { it.username } .toMutableSet() .apply { add(actionableStatus.account.username) remove(viewModel.activeAccount?.username) } val intent = ComposeActivity.startIntent( requireContext(), ComposeOptions( inReplyToId = status.actionableId, replyVisibility = actionableStatus.visibility, contentWarning = actionableStatus.spoilerText, mentionedUsernames = mentionedUsernames, replyingStatusAuthor = actionableStatus.account.localUsername, replyingStatusContent = status.content.toString(), language = actionableStatus.language, kind = ComposeActivity.ComposeKind.NEW ) ) bottomSheetActivity?.startActivityWithSlideInAnimation(intent) } 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 val statusUrl = status.actionableStatus.url val loggedInAccountId = viewModel.activeAccount?.accountId val popup = PopupMenu(view.context, view) val statusIsByCurrentUser = loggedInAccountId?.equals(accountId) == true // Give a different menu depending on whether this is the user's own toot or not. if (statusIsByCurrentUser) { popup.inflate(R.menu.status_more_for_user) val menu = popup.menu menu.findItem(R.id.status_open_as).isVisible = !statusUrl.isNullOrBlank() when (status.visibility) { Status.Visibility.PUBLIC, Status.Visibility.UNLISTED -> { 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 } } else { popup.inflate(R.menu.status_more) val menu = popup.menu menu.findItem(R.id.status_download_media).isVisible = status.attachments.isNotEmpty() } val openAsItem = popup.menu.findItem(R.id.status_open_as) val openAsText = bottomSheetActivity?.openAsText if (openAsText == null) { openAsItem.isVisible = false } else { openAsItem.title = openAsText } 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) { R.string.action_unmute_conversation } else { R.string.action_mute_conversation } ) } // 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 -> { val statusToShare: Status = status.actionableStatus val sendIntent = Intent() sendIntent.action = Intent.ACTION_SEND val stringToShare = statusToShare.account.username + " - " + statusToShare.content sendIntent.putExtra(Intent.EXTRA_TEXT, stringToShare) sendIntent.type = "text/plain" 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) ) ) return@setOnMenuItemClickListener true } R.id.status_copy_link -> { statusUrl?.let { requireActivity().copyToClipboard(it, getString(R.string.url_copied)) } 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 -> { adapter?.peek(position)?.let { foundStatus -> 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, Status.Visibility.PRIVATE) return@setOnMenuItemClickListener true } R.id.status_reblog_private -> { onReblog(true, position, Status.Visibility.PRIVATE) 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 -> { viewModel.pinAccount(status, !status.pinned) return@setOnMenuItemClickListener true } R.id.status_translate -> { if (statusViewData.translation != null) { viewModel.untranslate(statusViewData) } else { viewLifecycleOwner.lifecycleScope.launch { viewModel.translate(statusViewData) .onFailure { Snackbar.make( requireView(), getString(R.string.ui_error_translate, it.message), Snackbar.LENGTH_LONG ).show() } } } } } false } popup.show() } private fun onBlock(accountId: String, accountUsername: String) { MaterialAlertDialogBuilder(requireContext()) .setMessage(getString(R.string.dialog_block_warning, accountUsername)) .setPositiveButton(android.R.string.ok) { _, _ -> viewModel.blockAccount(accountId) } .setNegativeButton(android.R.string.cancel, null) .show() } private fun onMute(accountId: String, accountUsername: String) { showMuteAccountDialog( this.requireActivity(), accountUsername ) { notifications, duration -> viewModel.muteAccount(accountId, notifications, duration) } } private fun accountIsInMentions(account: AccountEntity?, mentions: List): Boolean { return mentions.firstOrNull { account?.username == it.username && account.domain == it.url.toUri().host } != null } private fun showOpenAsDialog(statusUrl: String, dialogTitle: CharSequence?) { bottomSheetActivity?.showAccountChooserDialog( dialogTitle, false, object : AccountSelectionListener { override fun onAccountSelected(account: AccountEntity) { bottomSheetActivity?.openAsAccount(statusUrl, account) } } ) } private fun downloadAllMedia(mediaUrls: List) { Toast.makeText(context, R.string.downloading_media, Toast.LENGTH_SHORT).show() val downloadManager: DownloadManager = requireContext().getSystemService()!! for (url in mediaUrls) { val uri = url.toUri() val request = DownloadManager.Request(uri) request.setDestinationInExternalPublicDir( Environment.DIRECTORY_DOWNLOADS, uri.lastPathSegment ) downloadManager.enqueue(request) } } private fun requestDownloadAllMedia(status: Status) { if (status.attachments.isEmpty()) { return } val mediaUrls = status.attachments.map { it.url } if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { pendingMediaDownloads = mediaUrls downloadAllMediaPermissionLauncher.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE) } else { downloadAllMedia(mediaUrls) } } private fun openReportPage(accountId: String, accountUsername: String, statusId: String) { startActivity( ReportActivity.getIntent(requireContext(), accountId, accountUsername, statusId) ) } private fun showConfirmDeleteDialog(id: String, position: Int) { context?.let { MaterialAlertDialogBuilder(it) .setMessage(R.string.dialog_delete_post_warning) .setPositiveButton(android.R.string.ok) { _, _ -> viewModel.deleteStatusAsync(id, true) removeItem(position, true) } .setNegativeButton(android.R.string.cancel, null) .show() } } private fun showConfirmEditDialog(id: String, position: Int, status: Status) { context?.let { context -> MaterialAlertDialogBuilder(context) .setMessage(R.string.dialog_redraft_post_warning) .setPositiveButton(android.R.string.ok) { _, _ -> viewLifecycleOwner.lifecycleScope.launch { viewModel.deleteStatusAsync(id, false).await().fold( { deletedStatus -> removeItem(position, false) val redraftStatus = if (deletedStatus.isEmpty) { status.toDeletedStatus() } else { deletedStatus } val intent = ComposeActivity.startIntent( context, ComposeOptions( content = redraftStatus.text.orEmpty(), inReplyToId = redraftStatus.inReplyToId, visibility = redraftStatus.visibility, contentWarning = redraftStatus.spoilerText, mediaAttachments = redraftStatus.attachments, sensitive = redraftStatus.sensitive, poll = redraftStatus.poll?.toNewPoll(status.createdAt), language = redraftStatus.language, kind = ComposeActivity.ComposeKind.NEW ) ) startActivity(intent) }, { error -> Log.w("SearchStatusesFragment", "error deleting status", error) Toast.makeText( context, R.string.error_generic, Toast.LENGTH_SHORT ).show() } ) } } .setNegativeButton(android.R.string.cancel, null) .show() } } private fun editStatus(id: String, status: Status) { viewLifecycleOwner.lifecycleScope.launch { mastodonApi.statusSource(id).fold( { source -> val composeOptions = ComposeOptions( content = source.text, inReplyToId = status.inReplyToId, visibility = status.visibility, contentWarning = source.spoilerText, mediaAttachments = status.attachments, sensitive = status.sensitive, language = status.language, statusId = source.id, poll = status.poll?.toNewPoll(status.createdAt), kind = ComposeActivity.ComposeKind.EDIT_POSTED ) startActivity(ComposeActivity.startIntent(requireContext(), composeOptions)) }, { Snackbar.make( requireView(), getString(R.string.error_status_source_load), Snackbar.LENGTH_SHORT ).show() } ) } } companion object { private const val PENDING_MEDIA_DOWNLOADS_STATE_KEY = "pending_media_downloads" fun newInstance() = SearchStatusesFragment() } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/systemnotifications/NotificationChannelData.kt ================================================ package com.keylesspalace.tusky.components.systemnotifications import androidx.annotation.Keep import androidx.annotation.StringRes import com.keylesspalace.tusky.R import com.keylesspalace.tusky.db.entity.AccountEntity import com.keylesspalace.tusky.entity.Notification @Keep enum class NotificationChannelData( val notificationTypes: List, @StringRes val title: Int, @StringRes val description: Int, ) { MENTION( listOf(Notification.Type.Mention), R.string.notification_mention_name, R.string.notification_mention_descriptions, ), REBLOG( listOf(Notification.Type.Reblog), R.string.notification_boost_name, R.string.notification_boost_description ), FAVOURITE( listOf(Notification.Type.Favourite), R.string.notification_favourite_name, R.string.notification_favourite_description ), FOLLOW( listOf(Notification.Type.Follow), R.string.notification_follow_name, R.string.notification_follow_description ), FOLLOW_REQUEST( listOf(Notification.Type.FollowRequest), R.string.notification_follow_request_name, R.string.notification_follow_request_description ), POLL( listOf(Notification.Type.Poll), R.string.notification_poll_name, R.string.notification_poll_description ), SUBSCRIPTIONS( listOf(Notification.Type.Status), R.string.notification_subscription_name, R.string.notification_subscription_description ), UPDATES( listOf(Notification.Type.Update), R.string.notification_update_name, R.string.notification_update_description ), ADMIN( listOf(Notification.Type.SignUp, Notification.Type.Report), R.string.notification_channel_admin, R.string.notification_channel_admin_description ), OTHER( listOf(Notification.Type.SeveredRelationship, Notification.Type.ModerationWarning), R.string.notification_channel_other, R.string.notification_channel_other_description ); fun getChannelId(account: AccountEntity): String { return getChannelId(account.identifier) } fun getChannelId(accountIdentifier: String): String { return "CHANNEL_${name}$accountIdentifier" } } fun Set.toTypes(): Set { return flatMap { channelData -> channelData.notificationTypes }.toSet() } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/systemnotifications/NotificationFetcher.kt ================================================ package com.keylesspalace.tusky.components.systemnotifications import android.util.Log import androidx.annotation.WorkerThread import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.NewNotificationsEvent import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.entity.AccountEntity 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 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" ) ) } } } /** * Fetch Mastodon notifications and show Android notifications, with summaries, for them. * * Should only be called by a worker thread. * * @see com.keylesspalace.tusky.worker.NotificationWorker * @see Background worker */ @WorkerThread class NotificationFetcher @Inject constructor( private val mastodonApi: MastodonApi, private val accountManager: AccountManager, private val eventHub: EventHub, private val notificationService: NotificationService, ) { suspend fun fetchAndShow(accountId: Long?) { for (account in accountManager.accounts) { if (accountId != null && account.id != accountId) { continue } if (account.notificationsEnabled) { try { val notifications = fetchNewNotifications(account) .filter { notificationService.filterNotification(account, it.type) } .sortedWith( compareBy({ it.id.length }, { it.id }) ) // oldest notifications first eventHub.dispatch(NewNotificationsEvent(account.accountId, notifications)) notificationService.show(account, notifications) } catch (e: Exception) { Log.e(TAG, "Error while fetching notifications", e) } } } } /** * Fetch new Mastodon Notifications and update the marker position. * * Here, "new" means "notifications with IDs newer than notifications the user has already * seen." * * The "water mark" for Mastodon Notification IDs are stored in three places. * * - acccount.lastNotificationId -- the ID of the top-most notification when the user last * left the Notifications tab. * - The Mastodon "marker" API -- the ID of the most recent notification fetched here. * - account.notificationMarkerId -- local version of the value from the Mastodon marker * API, in case the Mastodon server does not implement that API. * * The user may have refreshed the "Notifications" tab and seen notifications newer than the * ones that were last fetched here. So `lastNotificationId` takes precedence if it is greater * than the marker. */ private suspend fun fetchNewNotifications(account: AccountEntity): List { val authHeader = "Bearer ${account.accessToken}" // Figure out where to read from. Choose the most recent notification ID from: // // - The Mastodon marker API (if the server supports it) // - account.notificationMarkerId // - account.lastNotificationId 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 readingPosition = account.lastNotificationId var minId: String? = if (readingPosition.isLessThan(markerId)) markerId else readingPosition Log.d(TAG, " remoteMarkerId: $remoteMarkerId") Log.d(TAG, " localMarkerId: $localMarkerId") Log.d(TAG, " readingPosition: $readingPosition") Log.d(TAG, "Getting Notifications for ${account.fullName}, min_id: $minId.") // Fetch all outstanding notifications val notifications: List = buildList { while (minId != null) { val response = mastodonApi.notificationsWithAuth( authHeader, account.domain, minId = minId ) if (!response.isSuccessful) break // Notifications are returned in the page in order, newest first, // (https://github.com/mastodon/documentation/issues/1226), insert the // new page at the head of the list. response.body()?.let { addAll(0, it) } // Get the previous page, which will be chronologically newer // notifications. If it doesn't exist this is null and the loop // will exit. val links = Links.from(response.headers()["link"]) minId = links.prev } } // Save the newest notification ID in the marker. notifications.firstOrNull()?.let { val newMarkerId = notifications.first().id Log.d(TAG, "Updating notification marker for ${account.fullName} to: $newMarkerId") mastodonApi.updateMarkersWithAuth( auth = authHeader, domain = account.domain, notificationsLastReadId = newMarkerId ) accountManager.updateAccount(account) { copy(notificationMarkerId = newMarkerId) } } Log.d(TAG, "Got ${notifications.size} Notifications.") return notifications } private suspend fun fetchMarker(authHeader: String, account: AccountEntity): Marker? { return try { val allMarkers = mastodonApi.markersWithAuth( authHeader, account.domain, listOf("notifications") ) val notificationMarker = allMarkers["notifications"] Log.d(TAG, "Fetched marker for ${account.fullName}: $notificationMarker") notificationMarker } catch (e: Exception) { Log.e(TAG, "Failed to fetch marker", e) null } } companion object { private const val TAG = "NotificationFetcher" } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/systemnotifications/NotificationService.kt ================================================ package com.keylesspalace.tusky.components.systemnotifications import android.Manifest import android.app.Activity import android.app.NotificationChannel import android.app.NotificationChannelGroup import android.app.NotificationManager import android.app.PendingIntent import android.content.Context import android.content.Intent import android.content.SharedPreferences import android.content.pm.PackageManager import android.graphics.BitmapFactory import android.os.Build import android.os.Bundle import android.provider.Settings import android.service.notification.StatusBarNotification import android.util.Log import androidx.annotation.StringRes import androidx.annotation.VisibleForTesting import androidx.core.app.ActivityCompat import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat.NotificationWithIdAndTag import androidx.core.app.RemoteInput import androidx.core.app.TaskStackBuilder import androidx.core.content.edit import androidx.core.net.toUri import androidx.work.Constraints import androidx.work.Data import androidx.work.ExistingPeriodicWorkPolicy import androidx.work.NetworkType import androidx.work.OneTimeWorkRequest import androidx.work.OutOfQuotaPolicy import androidx.work.PeriodicWorkRequest import androidx.work.WorkManager import at.connyduck.calladapter.networkresult.fold import at.connyduck.calladapter.networkresult.onFailure import at.connyduck.calladapter.networkresult.onSuccess import com.bumptech.glide.Glide import com.bumptech.glide.load.resource.bitmap.RoundedCorners import com.keylesspalace.tusky.BuildConfig import com.keylesspalace.tusky.MainActivity import com.keylesspalace.tusky.MainActivity.Companion.composeIntent import com.keylesspalace.tusky.MainActivity.Companion.openNotificationIntent import com.keylesspalace.tusky.R import com.keylesspalace.tusky.components.compose.ComposeActivity import com.keylesspalace.tusky.components.compose.ComposeActivity.ComposeOptions import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.entity.AccountEntity import com.keylesspalace.tusky.di.ApplicationScope import com.keylesspalace.tusky.entity.Notification import com.keylesspalace.tusky.entity.NotificationSubscribeResult import com.keylesspalace.tusky.entity.RelationshipSeveranceEvent import com.keylesspalace.tusky.entity.visibleNotificationTypes import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.receiver.SendStatusBroadcastReceiver import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.util.CryptoUtil import com.keylesspalace.tusky.util.parseAsMastodonHtml import com.keylesspalace.tusky.util.unicodeWrap import com.keylesspalace.tusky.viewdata.buildDescription import com.keylesspalace.tusky.viewdata.calculatePercent import com.keylesspalace.tusky.worker.NotificationWorker import dagger.hilt.android.qualifiers.ApplicationContext import java.text.NumberFormat import java.util.concurrent.ExecutionException import java.util.concurrent.TimeUnit import javax.inject.Inject import javax.inject.Singleton import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.unifiedpush.android.connector.UnifiedPush import retrofit2.HttpException @Singleton class NotificationService @Inject constructor( private val notificationManager: NotificationManager, private val accountManager: AccountManager, private val api: MastodonApi, private val preferences: SharedPreferences, @ApplicationContext private val context: Context, @ApplicationScope private val applicationScope: CoroutineScope, ) { private var workManager: WorkManager = WorkManager.getInstance(context) private var notificationId: Int = NOTIFICATION_ID_PRUNE_CACHE + 1 init { createWorkerNotificationChannel() } fun areNotificationsEnabledBySystem(): Boolean { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { // on Android >= O, notifications are enabled, if at least one channel is enabled if (notificationManager.areNotificationsEnabled()) { for (channel in notificationManager.notificationChannels) { if (channel != null && channel.importance > NotificationManager.IMPORTANCE_NONE) { Log.d(TAG, "Notifications enabled for app by the system.") return true } } } Log.d(TAG, "Notifications disabled for app by the system.") return false } else { // on Android < O, notifications are enabled, if at least one account has notification enabled return accountManager.areNotificationsEnabled() } } suspend fun setupNotifications(activity: Activity) { resetPushWhenDistributorIsMissing() if (arePushNotificationsAvailable()) { setupPushNotifications(activity) } // At least as a fallback and otherwise as main source when there are no push distributors installed: enablePullNotifications() } fun enablePullNotifications() { val workRequest: PeriodicWorkRequest = PeriodicWorkRequest.Builder( NotificationWorker::class.java, PeriodicWorkRequest.MIN_PERIODIC_INTERVAL_MILLIS, TimeUnit.MILLISECONDS, ) .setConstraints( Constraints.Builder() .setRequiredNetworkType(NetworkType.CONNECTED) .build() ) .build() workManager.enqueueUniquePeriodicWork(NOTIFICATION_PULL_NAME, ExistingPeriodicWorkPolicy.KEEP, workRequest) Log.d(TAG, "Enabled pull checks with ${PeriodicWorkRequest.MIN_PERIODIC_INTERVAL_MILLIS / 60000} minutes interval.") } fun createNotificationChannelsForAccount(account: AccountEntity) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val channelGroup = NotificationChannelGroup(account.identifier, account.fullName) notificationManager.createNotificationChannelGroup(channelGroup) val channels = NotificationChannelData.entries.map { NotificationChannel( it.getChannelId(account), context.getString(it.title), NotificationManager.IMPORTANCE_DEFAULT ).apply { description = context.getString(it.description) enableLights(true) lightColor = -0xd46f27 enableVibration(true) setShowBadge(true) group = account.identifier } } notificationManager.createNotificationChannels(channels) } } private fun deleteNotificationChannelsForAccount(account: AccountEntity) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { notificationManager.deleteNotificationChannelGroup(account.identifier) } } private fun enqueueOneTimeWorker(account: AccountEntity?) { val oneTimeRequestBuilder = OneTimeWorkRequest.Builder(NotificationWorker::class.java) .setExpedited(OutOfQuotaPolicy.DROP_WORK_REQUEST) .setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build()) account?.let { val data = Data.Builder() data.putLong(NotificationWorker.KEY_ACCOUNT_ID, account.id) oneTimeRequestBuilder.setInputData(data.build()) } workManager.enqueue(oneTimeRequestBuilder.build()) } fun disablePullNotifications() { workManager.cancelUniqueWork(NOTIFICATION_PULL_NAME) Log.d(TAG, "Disabled pull checks.") } fun clearNotificationsForAccount(account: AccountEntity) { for (androidNotification in notificationManager.activeNotifications) { if (account.id.toInt() == androidNotification.id) { notificationManager.cancel(androidNotification.tag, androidNotification.id) } } } fun filterNotification(account: AccountEntity, type: Notification.Type): Boolean { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val channelId = getChannelId(account, type) ?: // unknown notificationtype return false val channel = notificationManager.getNotificationChannel(channelId) return channel != null && channel.importance > NotificationManager.IMPORTANCE_NONE } return when (type) { Notification.Type.Mention -> account.notificationsMentioned Notification.Type.Status -> account.notificationsSubscriptions Notification.Type.Follow -> account.notificationsFollowed Notification.Type.FollowRequest -> account.notificationsFollowRequested Notification.Type.Reblog -> account.notificationsReblogged Notification.Type.Favourite -> account.notificationsFavorited Notification.Type.Poll -> account.notificationsPolls Notification.Type.SignUp -> account.notificationsAdmin Notification.Type.Update -> account.notificationsUpdates Notification.Type.Report -> account.notificationsAdmin else -> account.notificationsOther } } fun show(account: AccountEntity, notifications: List) { if (ActivityCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { return } if (notifications.isEmpty()) { return } val newNotifications = ArrayList() val notificationsByType: Map> = notifications.groupBy { it.type } for ((type, notificationsForOneType) in notificationsByType) { val summary = createSummaryNotification(account, type, notificationsForOneType) ?: continue // NOTE Enqueue the summary first: Needed to avoid rate limit problems: // ie. single notification is enqueued but later the summary one is filtered and thus no grouping // takes place. newNotifications.add(summary) for (notification in notificationsForOneType) { val single = createNotification(notification, account) ?: continue newNotifications.add(single) } } val notificationManagerCompat = NotificationManagerCompat.from(context) // NOTE having multiple summary notifications: this here should still collapse them in only one occurrence notificationManagerCompat.notify(newNotifications) } private fun createNotification(apiNotification: Notification, account: AccountEntity): NotificationWithIdAndTag? { val baseNotification = createBaseNotification(apiNotification, account) ?: return null return NotificationWithIdAndTag( apiNotification.id, account.id.toInt(), baseNotification ) } @VisibleForTesting fun createBaseNotification(apiNotification: Notification, account: AccountEntity): android.app.Notification? { val channelId = getChannelId(account, apiNotification.type) ?: return null val body = apiNotification.rewriteToStatusTypeIfNeeded(account.accountId) // Check for an existing notification matching this account and api notification var existingAndroidNotification: android.app.Notification? = null val activeNotifications = notificationManager.activeNotifications for (androidNotification in activeNotifications) { if (body.id == androidNotification.tag && account.id.toInt() == androidNotification.id) { existingAndroidNotification = androidNotification.notification } } notificationId++ val builder = if (existingAndroidNotification == null) { getNotificationBuilder(body, account, channelId) } else { NotificationCompat.Builder(context, existingAndroidNotification) } builder .setContentTitle(titleForType(body, account)) .setContentText(bodyForType(body, account)) if (body.type == Notification.Type.Mention || body.type == Notification.Type.Poll) { builder.setStyle( NotificationCompat.BigTextStyle() .bigText(bodyForType(body, account)) ) } if (body.type != Notification.Type.SeveredRelationship && body.type != Notification.Type.ModerationWarning) { val accountAvatar = try { Glide.with(context) .asBitmap() .load(body.account.avatar) .transform(RoundedCorners(20)) .submit() .get() } catch (e: ExecutionException) { Log.d(TAG, "Error loading account avatar", e) BitmapFactory.decodeResource(context.resources, R.drawable.avatar_default) } catch (e: InterruptedException) { Log.d(TAG, "Error loading account avatar", e) BitmapFactory.decodeResource(context.resources, R.drawable.avatar_default) } builder.setLargeIcon(accountAvatar) } // Reply to mention action; RemoteInput is available from KitKat Watch, but buttons are available from Nougat if (body.type == Notification.Type.Mention) { val replyRemoteInput = RemoteInput.Builder(KEY_REPLY) .setLabel(context.getString(R.string.label_quick_reply)) .build() val quickReplyPendingIntent = getStatusReplyIntent(body, account, notificationId) val quickReplyAction = NotificationCompat.Action.Builder( R.drawable.ic_reply_24dp, context.getString(R.string.action_quick_reply), quickReplyPendingIntent ) .addRemoteInput(replyRemoteInput) .build() builder.addAction(quickReplyAction) val composeIntent = getStatusComposeIntent(body, account, notificationId) val composeAction = NotificationCompat.Action.Builder( R.drawable.ic_reply_24dp, context.getString(R.string.action_compose_shortcut), composeIntent ) .setShowsUserInterface(true) .build() builder.addAction(composeAction) } builder.addExtras( Bundle().apply { // Add the sending account's name, so it can be used also later when summarising this notification putString(EXTRA_ACCOUNT_NAME, body.account.name) putString(EXTRA_NOTIFICATION_TYPE, body.type.name) } ) return builder.build() } /** * Create a notification that summarises the other notifications in this group. * * NOTE: We always create a summary notification (even for only one notification of that type): * - No need to especially track the grouping * - No need to change an existing single notification when there arrives another one of its group * - Only the summary one will get announced */ private fun createSummaryNotification(account: AccountEntity, type: Notification.Type, additionalNotifications: List): NotificationWithIdAndTag? { val typeChannelId = getChannelId(account, type) ?: return null val summaryStackBuilder = TaskStackBuilder.create(context) summaryStackBuilder.addParentStack(MainActivity::class.java) val summaryResultIntent = openNotificationIntent(context, account.id, type) summaryStackBuilder.addNextIntent(summaryResultIntent) val summaryResultPendingIntent = summaryStackBuilder.getPendingIntent( (notificationId + account.id * 10000).toInt(), pendingIntentFlags(false) ) val activeNotifications = getActiveNotifications(account.id, typeChannelId) val notificationCount = activeNotifications.size + additionalNotifications.size val title = context.resources.getQuantityString(R.plurals.notification_title_summary, notificationCount, notificationCount) val text = joinNames(activeNotifications, additionalNotifications) val summaryBuilder = NotificationCompat.Builder(context, typeChannelId) .setSmallIcon(R.drawable.tusky_notification_icon) .setContentIntent(summaryResultPendingIntent) .setColor(context.getColor(R.color.notification_color)) .setAutoCancel(true) .setContentTitle(title) .setContentText(text) .setShortcutId(account.id.toString()) .setSubText(account.fullName) .setVisibility(NotificationCompat.VISIBILITY_PRIVATE) .setCategory(NotificationCompat.CATEGORY_SOCIAL) .setGroup(typeChannelId) .setGroupSummary(true) .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY) setSoundVibrationLight(account, summaryBuilder) val summaryTag = "$GROUP_SUMMARY_TAG.$typeChannelId" return NotificationWithIdAndTag(summaryTag, account.id.toInt(), summaryBuilder.build()) } fun createWorkerNotification(@StringRes titleResource: Int): android.app.Notification { val title = context.getString(titleResource) return NotificationCompat.Builder(context, CHANNEL_BACKGROUND_TASKS) .setContentTitle(title) .setTicker(title) .setSmallIcon(R.drawable.tusky_notification_icon) .setOngoing(true) .build() } private fun getChannelId(account: AccountEntity, type: Notification.Type): String? { return NotificationChannelData.entries.find { data -> data.notificationTypes.contains(type) }?.getChannelId(account) } /** * Return all active notifications, ignoring notifications that: * - belong to a different account * - belong to a different type * - are summary notifications */ private fun getActiveNotifications(accountId: Long, typeChannelId: String): List { return notificationManager.activeNotifications.filter { val channelId = it.notification.group it.id == accountId.toInt() && channelId == typeChannelId && it.tag != "$GROUP_SUMMARY_TAG.$channelId" } } private fun getNotificationBuilder(notification: Notification, account: AccountEntity, channelId: String): NotificationCompat.Builder { val notificationType = notification.type val eventResultPendingIntent = if (notificationType == Notification.Type.ModerationWarning) { val warning = notification.moderationWarning!! val intent = Intent(Intent.ACTION_VIEW, "https://${account.domain}/disputes/strikes/${warning.id}".toUri()) PendingIntent.getActivity(context, account.id.toInt(), intent, pendingIntentFlags(false)) } else { val eventResultIntent = openNotificationIntent(context, account.id, notificationType) val eventStackBuilder = TaskStackBuilder.create(context) eventStackBuilder.addParentStack(MainActivity::class.java) eventStackBuilder.addNextIntent(eventResultIntent) eventStackBuilder.getPendingIntent( account.id.toInt(), pendingIntentFlags(false) ) } val builder = NotificationCompat.Builder(context, channelId) .setSmallIcon(R.drawable.tusky_notification_icon) .setContentIntent(eventResultPendingIntent) .setColor(context.getColor(R.color.notification_color)) .setAutoCancel(true) .setShortcutId(account.id.toString()) .setSubText(account.fullName) .setVisibility(NotificationCompat.VISIBILITY_PRIVATE) .setCategory(NotificationCompat.CATEGORY_SOCIAL) .setOnlyAlertOnce(true) .setGroup(channelId) .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY) // Only ever alert for the summary notification setSoundVibrationLight(account, builder) return builder } private fun titleForType(notification: Notification, account: AccountEntity): String? { val accountName = notification.account.name.unicodeWrap() when (notification.type) { Notification.Type.Mention -> return context.getString(R.string.notification_mention_format, accountName) Notification.Type.Status -> return context.getString(R.string.notification_subscription_format, accountName) Notification.Type.Follow -> return context.getString(R.string.notification_follow_format, accountName) Notification.Type.FollowRequest -> return context.getString(R.string.notification_follow_request_format, accountName) Notification.Type.Favourite -> return context.getString(R.string.notification_favourite_format, accountName) Notification.Type.Reblog -> return context.getString(R.string.notification_reblog_format, accountName) Notification.Type.Poll -> return if (notification.status!!.account.id == account.accountId) { context.getString(R.string.poll_ended_created) } else { context.getString(R.string.poll_ended_voted) } Notification.Type.SignUp -> return context.getString(R.string.notification_sign_up_format, accountName) Notification.Type.Update -> return context.getString(R.string.notification_update_format, accountName) Notification.Type.Report -> return context.getString(R.string.notification_report_format, account.domain) Notification.Type.SeveredRelationship -> return context.getString(R.string.relationship_severance_event_title) Notification.Type.ModerationWarning -> return context.getString(R.string.moderation_warning) is Notification.Type.Unknown -> return null } } private fun bodyForType(notification: Notification, account: AccountEntity): String? { val alwaysOpenSpoiler = account.alwaysOpenSpoiler when (notification.type) { Notification.Type.Follow, Notification.Type.FollowRequest, Notification.Type.SignUp -> return "@" + notification.account.username Notification.Type.Mention, Notification.Type.Favourite, Notification.Type.Reblog, Notification.Type.Status -> return if (!notification.status?.spoilerText.isNullOrEmpty() && !alwaysOpenSpoiler) { notification.status.spoilerText } else { notification.status?.content?.parseAsMastodonHtml()?.toString() } Notification.Type.Poll -> if (!notification.status?.spoilerText.isNullOrEmpty() && !alwaysOpenSpoiler) { return notification.status.spoilerText } else { val poll = notification.status?.poll ?: return null val builder = StringBuilder(notification.status.content.parseAsMastodonHtml()) builder.append('\n') poll.options.forEachIndexed { i, option -> builder.append( buildDescription( option.title, calculatePercent(option.votesCount, poll.votersCount, poll.votesCount), poll.ownVotes.contains(i), context ) ) builder.append('\n') } return builder.toString() } Notification.Type.Report -> return context.getString( R.string.notification_header_report_format, notification.account.name.unicodeWrap(), notification.report!!.targetAccount.name.unicodeWrap() ) Notification.Type.SeveredRelationship -> return severedRelationShipText(context, notification.event!!, account.domain) Notification.Type.ModerationWarning -> return context.getString(notification.moderationWarning!!.action.text) else -> return null } } private fun createWorkerNotificationChannel() { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { return } val channel = NotificationChannel( CHANNEL_BACKGROUND_TASKS, context.getString(R.string.notification_listenable_worker_name), NotificationManager.IMPORTANCE_NONE ) channel.description = context.getString(R.string.notification_listenable_worker_description) channel.enableLights(false) channel.enableVibration(false) channel.setShowBadge(false) notificationManager.createNotificationChannel(channel) } private fun setSoundVibrationLight(account: AccountEntity, builder: NotificationCompat.Builder) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { return // Do nothing on Android O or newer, the system uses only the channel settings } builder.setDefaults(0) if (account.notificationSound) { builder.setSound(Settings.System.DEFAULT_NOTIFICATION_URI) } if (account.notificationVibration) { builder.setVibrate(longArrayOf(500, 500)) } if (account.notificationLight) { builder.setLights(-0xd46f27, 300, 1000) } } private fun joinNames(notifications1: List, notifications2: List): String? { val names = java.util.ArrayList(notifications1.size + notifications2.size) for (notification in notifications1) { val author = notification.notification.extras.getString(EXTRA_ACCOUNT_NAME) ?: continue names.add(author) } for (noti in notifications2) { names.add(noti.account.name) } if (names.size > 3) { val length = names.size return context.getString( R.string.notification_summary_large, names[length - 1].unicodeWrap(), names[length - 2].unicodeWrap(), names[length - 3].unicodeWrap(), length - 3 ) } else if (names.size == 3) { return context.getString( R.string.notification_summary_medium, names[2].unicodeWrap(), names[1].unicodeWrap(), names[0].unicodeWrap() ) } else if (names.size == 2) { return context.getString( R.string.notification_summary_small, names[1].unicodeWrap(), names[0].unicodeWrap() ) } return null } private fun getStatusReplyIntent(apiNotification: Notification, account: AccountEntity, requestCode: Int): PendingIntent { val status = checkNotNull(apiNotification.status) val inReplyToId = status.id val actionableStatus = status.actionableStatus val replyVisibility = actionableStatus.visibility val contentWarning = actionableStatus.spoilerText val mentions = actionableStatus.mentions val mentionedUsernames = buildSet { add(actionableStatus.account.username) for (mention in mentions) { add(mention.username) } remove(account.username) } val replyIntent = Intent(context, SendStatusBroadcastReceiver::class.java) .setAction(REPLY_ACTION) .putExtra(KEY_SENDER_ACCOUNT_ID, account.id) .putExtra(KEY_SENDER_ACCOUNT_IDENTIFIER, account.identifier) .putExtra(KEY_SENDER_ACCOUNT_FULL_NAME, account.fullName) .putExtra(KEY_SERVER_NOTIFICATION_ID, apiNotification.id) .putExtra(KEY_CITED_STATUS_ID, inReplyToId) .putExtra(KEY_VISIBILITY, replyVisibility) .putExtra(KEY_SPOILER, contentWarning) .putExtra(KEY_MENTIONS, mentionedUsernames.toTypedArray()) return PendingIntent.getBroadcast( context.applicationContext, requestCode, replyIntent, pendingIntentFlags(true) ) } private fun getStatusComposeIntent(apiNotification: Notification, account: AccountEntity, requestCode: Int): PendingIntent { val status = checkNotNull(apiNotification.status) val citedLocalAuthor = status.account.localUsername val citedText = status.content.parseAsMastodonHtml().toString() val inReplyToId = status.id val actionableStatus = status.actionableStatus val replyVisibility = actionableStatus.visibility val contentWarning = actionableStatus.spoilerText val mentions = actionableStatus.mentions val mentionedUsernames = buildSet { add(actionableStatus.account.username) for (mention in mentions) { add(mention.username) } remove(account.username) } val composeOptions = ComposeOptions() composeOptions.inReplyToId = inReplyToId composeOptions.replyVisibility = replyVisibility composeOptions.contentWarning = contentWarning composeOptions.replyingStatusAuthor = citedLocalAuthor composeOptions.replyingStatusContent = citedText composeOptions.mentionedUsernames = mentionedUsernames composeOptions.modifiedInitialState = true composeOptions.language = actionableStatus.language composeOptions.kind = ComposeActivity.ComposeKind.NEW val composeIntent = composeIntent(context, composeOptions, account.id, apiNotification.id, account.id.toInt()) // make sure a new instance of MainActivity is started and old ones get destroyed composeIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) return PendingIntent.getActivity( context.applicationContext, requestCode, composeIntent, pendingIntentFlags(false) ) } private fun pendingIntentFlags(mutable: Boolean): Int { return if (mutable) { PendingIntent.FLAG_UPDATE_CURRENT or (if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0) } else { PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE } } suspend fun disableAllNotifications() { disablePushNotificationsForAllAccounts() disablePullNotifications() } suspend fun disableNotificationsForAccount(account: AccountEntity) { disablePushNotificationsForAccount(account) deleteNotificationChannelsForAccount(account) if (!areNotificationsEnabledBySystem()) { // TODO this is sort of a hack, it means: are there now no active accounts? disablePullNotifications() } } // // Push notification section // fun arePushNotificationsAvailable(): Boolean = UnifiedPush.getDistributors(context).isNotEmpty() private suspend fun setupPushNotifications(activity: Activity) { accountManager.accounts.forEach { val notificationGroupEnabled = Build.VERSION.SDK_INT < 28 || notificationManager.getNotificationChannelGroup(it.identifier)?.isBlocked == false val shouldEnable = it.notificationsEnabled && notificationGroupEnabled if (shouldEnable) { setupPushNotificationsForAccount(activity, it) Log.d(TAG, "Enabled push notifications for account ${it.id}.") } else { disablePushNotificationsForAccount(it) Log.d(TAG, "Disabled push notifications for account ${it.id}.") } } } private suspend fun setupPushNotificationsForAccount(activity: Activity, account: AccountEntity) { val currentSubscription = getActiveSubscription(account) if (currentSubscription != null) { val alertData = buildAlertsMap(account) if (alertData != currentSubscription.alerts) { // Update the subscription to match notification settings updatePushSubscription(account) } else { Log.d(TAG, "Nothing to be done. Current push subscription matches for account ${account.id}.") } } else { Log.d(TAG, "Trying to create a UnifiedPush subscription for account ${account.id}") // When changing the local UP distributor this is necessary first to enable the following callbacks (i. e. onNewEndpoint); // make sure this is done in any inconsistent case (is not too often and doesn't hurt). unregisterPushEndpoint(account) UnifiedPush.registerAppWithDialog(activity, account.id.toString(), features = arrayListOf(UnifiedPush.FEATURE_BYTES_MESSAGE)) // Will lead to call of registerPushEndpoint() } } private fun resetPushWhenDistributorIsMissing() { val lastUsedPushProvider = preferences.getString(PrefKeys.LAST_USED_PUSH_PROVDER, null) // NOTE UnifiedPush.getSavedDistributor() cannot be used here as that is already null here if the // distributor was uninstalled. if (lastUsedPushProvider.isNullOrEmpty() || UnifiedPush.getDistributors(context).contains(lastUsedPushProvider)) { return } Log.w(TAG, "Previous push provider ($lastUsedPushProvider) uninstalled. Resetting all accounts.") preferences.edit { remove(PrefKeys.LAST_USED_PUSH_PROVDER) } applicationScope.launch { accountManager.accounts.forEach { // reset all accounts, also does resetPushSettingsInAccount() unregisterPushEndpoint(it) } } } private suspend fun getActiveSubscription(account: AccountEntity): NotificationSubscribeResult? { api.pushNotificationSubscription( "Bearer ${account.accessToken}", account.domain ).fold( onSuccess = { if (!account.matchesPushSubscription(it.endpoint)) { Log.w(TAG, "Server push endpoint does not match previously registered one: ${it.endpoint} vs. ${account.unifiedPushUrl}") return null } return it }, onFailure = { throwable -> if (throwable is HttpException && throwable.code() == 404) { // this is alright; there is no subscription on the server return null } Log.e(TAG, "Cannot get push subscription for account " + account.id + ": " + throwable.message, throwable) return null } ) } private suspend fun disablePushNotificationsForAllAccounts() { accountManager.accounts.forEach { disablePushNotificationsForAccount(it) } } private suspend fun disablePushNotificationsForAccount(account: AccountEntity) { if (!account.isPushNotificationsEnabled()) { return } unregisterPushEndpoint(account) // this probably does nothing (distributor to handle this is missing) UnifiedPush.unregisterApp(context, account.id.toString()) } fun fetchNotificationsOnPushMessage(account: AccountEntity) { // TODO should there be a rate limit here? Ie. we could be silent (can we?) for another notification in a short timeframe. Log.d(TAG, "Fetching notifications because of push for account ${account.id}") enqueueOneTimeWorker(account) } private fun buildAlertsMap(account: AccountEntity): Map = buildMap { visibleNotificationTypes.forEach { put(it.name, filterNotification(account, it)) } } private fun buildAlertSubscriptionData(account: AccountEntity): Map = buildAlertsMap(account).mapKeys { "data[alerts][${it.key}]" } // Called by UnifiedPush callback in UnifiedPushBroadcastReceiver suspend fun registerPushEndpoint( account: AccountEntity, endpoint: String ) = withContext(Dispatchers.IO) { // Generate a prime256v1 key pair for WebPush // Decryption is unimplemented for now, since Mastodon uses an old WebPush // standard which does not send needed information for decryption in the payload // This makes it not directly compatible with UnifiedPush // As of now, we use it purely as a way to trigger a pull val keyPair = CryptoUtil.generateECKeyPair(CryptoUtil.CURVE_PRIME256_V1) val auth = CryptoUtil.secureRandomBytesEncoded(16) api.subscribePushNotifications( "Bearer ${account.accessToken}", account.domain, endpoint, keyPair.pubkey, auth, buildAlertSubscriptionData(account) ).onFailure { throwable -> Log.w(TAG, "Error setting push endpoint for account ${account.id}", throwable) disablePushNotificationsForAccount(account) }.onSuccess { Log.d(TAG, "UnifiedPush registration succeeded for account ${account.id}") accountManager.updateAccount(account) { copy( pushPubKey = keyPair.pubkey, pushPrivKey = keyPair.privKey, pushAuth = auth, pushServerKey = it.serverKey, unifiedPushUrl = endpoint ) } UnifiedPush.getAckDistributor(context)?.let { Log.d(TAG, "Saving distributor to preferences: $it") preferences.edit { putString(PrefKeys.LAST_USED_PUSH_PROVDER, it) } // TODO once this is selected it cannot be changed (except by wiping the application or uninstalling the provider) } } } // Synchronize the enabled / disabled state of notifications with server-side subscription (also NotificationBlockStateBroadcastReceiver). suspend fun updatePushSubscription(account: AccountEntity) { withContext(Dispatchers.IO) { api.updatePushNotificationSubscription( "Bearer ${account.accessToken}", account.domain, buildAlertSubscriptionData(account) ).onSuccess { Log.d(TAG, "UnifiedPush subscription updated for account ${account.id}") accountManager.updateAccount(account) { copy(pushServerKey = it.serverKey) } }.onFailure { throwable -> Log.e(TAG, "Could not update subscription ${throwable.message}") } } } suspend fun unregisterPushEndpoint(account: AccountEntity) { withContext(Dispatchers.IO) { api.unsubscribePushNotifications("Bearer ${account.accessToken}", account.domain) .onFailure { throwable -> Log.w(TAG, "Error unregistering push endpoint for account " + account.id, throwable) } .onSuccess { Log.d(TAG, "UnifiedPush unregistration succeeded for account " + account.id) resetPushSettingsInAccount(account) } } } private suspend fun resetPushSettingsInAccount(account: AccountEntity) { accountManager.updateAccount(account) { copy( pushPubKey = "", pushPrivKey = "", pushAuth = "", pushServerKey = "", unifiedPushUrl = "" ) } } companion object { const val TAG = "NotificationService" const val KEY_CITED_STATUS_ID: String = "KEY_CITED_STATUS_ID" const val KEY_MENTIONS: String = "KEY_MENTIONS" const val KEY_REPLY: String = "KEY_REPLY" const val KEY_SENDER_ACCOUNT_FULL_NAME: String = "KEY_SENDER_ACCOUNT_FULL_NAME" const val KEY_SENDER_ACCOUNT_ID: String = "KEY_SENDER_ACCOUNT_ID" const val KEY_SENDER_ACCOUNT_IDENTIFIER: String = "KEY_SENDER_ACCOUNT_IDENTIFIER" const val KEY_SERVER_NOTIFICATION_ID: String = "KEY_SERVER_NOTIFICATION_ID" const val KEY_SPOILER: String = "KEY_SPOILER" const val KEY_VISIBILITY: String = "KEY_VISIBILITY" const val NOTIFICATION_ID_FETCH_NOTIFICATION: Int = 0 const val NOTIFICATION_ID_PRUNE_CACHE: Int = 1 const val REPLY_ACTION: String = "REPLY_ACTION" private const val CHANNEL_BACKGROUND_TASKS: String = "CHANNEL_BACKGROUND_TASKS" private const val EXTRA_ACCOUNT_NAME = BuildConfig.APPLICATION_ID + ".notification.extra.account_name" private const val EXTRA_NOTIFICATION_TYPE = BuildConfig.APPLICATION_ID + ".notification.extra.notification_type" private const val GROUP_SUMMARY_TAG = BuildConfig.APPLICATION_ID + ".notification.group_summary" private const val NOTIFICATION_PULL_NAME = "pullNotifications" private val numberFormat = NumberFormat.getNumberInstance() fun severedRelationShipText( context: Context, event: RelationshipSeveranceEvent, instanceName: String ): String { return when (event.type) { RelationshipSeveranceEvent.Type.DOMAIN_BLOCK -> { val followers = numberFormat.format(event.followersCount) val following = numberFormat.format(event.followingCount) val followingText = context.resources.getQuantityString(R.plurals.accounts, event.followingCount, following) context.getString(R.string.relationship_severance_event_domain_block, instanceName, event.targetName, followers, followingText) } RelationshipSeveranceEvent.Type.USER_DOMAIN_BLOCK -> { val followers = numberFormat.format(event.followersCount) val following = numberFormat.format(event.followingCount) val followingText = context.resources.getQuantityString(R.plurals.accounts, event.followingCount, following) context.getString(R.string.relationship_severance_event_user_domain_block, event.targetName, followers, followingText) } RelationshipSeveranceEvent.Type.ACCOUNT_SUSPENSION -> { context.getString(R.string.relationship_severance_event_account_suspension, instanceName, event.targetName) } } } } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt ================================================ /* 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.components.timeline import android.content.SharedPreferences import android.os.Bundle import android.util.Log import android.view.Menu import android.view.MenuInflater import android.view.MenuItem import android.view.View import android.view.accessibility.AccessibilityManager import androidx.core.content.getSystemService import androidx.core.view.MenuProvider import androidx.lifecycle.Lifecycle import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.lifecycleScope import androidx.paging.LoadState import androidx.recyclerview.widget.DividerItemDecoration 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.SparkButton import at.connyduck.sparkbutton.helpers.Utils import com.google.android.material.snackbar.Snackbar import com.keylesspalace.tusky.R import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.PreferenceChangedEvent import com.keylesspalace.tusky.appstore.StatusComposedEvent import com.keylesspalace.tusky.components.accountlist.AccountListActivity import com.keylesspalace.tusky.components.accountlist.AccountListActivity.Companion.newIntent import com.keylesspalace.tusky.components.preference.PreferencesFragment.ReadingOrder import com.keylesspalace.tusky.components.timeline.viewmodel.CachedTimelineViewModel import com.keylesspalace.tusky.components.timeline.viewmodel.NetworkTimelineViewModel import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel import com.keylesspalace.tusky.databinding.FragmentTimelineBinding import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.fragment.SFragment import com.keylesspalace.tusky.interfaces.ActionButtonActivity import com.keylesspalace.tusky.interfaces.RefreshableFragment 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.ListStatusAccessibilityDelegate import com.keylesspalace.tusky.util.StatusDisplayOptions import com.keylesspalace.tusky.util.ensureBottomPadding 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.view.ConfirmationBottomSheet.Companion.confirmFavourite import com.keylesspalace.tusky.view.ConfirmationBottomSheet.Companion.confirmReblog import com.keylesspalace.tusky.viewdata.AttachmentViewData import com.keylesspalace.tusky.viewdata.StatusViewData import com.keylesspalace.tusky.viewdata.TranslationViewData import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch @AndroidEntryPoint class TimelineFragment : SFragment(R.layout.fragment_timeline), OnRefreshListener, StatusActionListener, ReselectableFragment, RefreshableFragment, MenuProvider { @Inject lateinit var eventHub: EventHub @Inject lateinit var preferences: SharedPreferences private val viewModel: TimelineViewModel by unsafeLazy { val viewModelProvider = ViewModelProvider(viewModelStore, defaultViewModelProviderFactory, defaultViewModelCreationExtras) if (kind == TimelineViewModel.Kind.HOME) { viewModelProvider[CachedTimelineViewModel::class.java] } else { viewModelProvider[NetworkTimelineViewModel::class.java] } } private val binding by viewBinding(FragmentTimelineBinding::bind) private lateinit var kind: TimelineViewModel.Kind private var adapter: TimelinePagingAdapter? = null private var isSwipeToRefreshEnabled = true /** * Adapter position of the placeholder that was most recently clicked to "Load more". If null * then there is no active "Load more" operation */ private var loadMorePosition: Int? = null /** ID of the status immediately below the most recent "Load more" placeholder click */ // The Paging library assumes that the user will be scrolling down a list of items, // and if new items are loaded but not visible then it's reasonable to scroll to the top // of the inserted items. It does not seem to be possible to disable that behaviour. // // That behaviour should depend on the user's preferred reading order. If they prefer to // read oldest first then the list should be scrolled to the bottom of the freshly // inserted statuses. // // To do this: // // 1. When "Load more" is clicked (onLoadMore()): // a. Remember the adapter position of the "Load more" item in loadMorePosition // b. Remember the ID of the status immediately below the "Load more" item in // statusIdBelowLoadMore // 2. After the new items have been inserted, search the adapter for the position of the // status with id == statusIdBelowLoadMore. // 3. If this position is still visible on screen then do nothing, otherwise, scroll the view // so that the status is visible. // // The user can then scroll up to read the new statuses. private var statusIdBelowLoadMore: String? = null /** The user's preferred reading order */ private lateinit var readingOrder: ReadingOrder private var buttonToAnimate: SparkButton? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val arguments = requireArguments() kind = TimelineViewModel.Kind.valueOf(arguments.getString(KIND_ARG)!!) val id: String? = if (kind == TimelineViewModel.Kind.USER || kind == TimelineViewModel.Kind.USER_PINNED || kind == TimelineViewModel.Kind.USER_WITH_REPLIES || kind == TimelineViewModel.Kind.LIST ) { arguments.getString(ID_ARG)!! } else { null } val tags = if (kind == TimelineViewModel.Kind.TAG) { arguments.getStringArrayList(HASHTAGS_ARG)!! } else { listOf() } viewModel.init( kind, id, tags ) isSwipeToRefreshEnabled = arguments.getBoolean(ARG_ENABLE_SWIPE_TO_REFRESH, true) readingOrder = ReadingOrder.from(preferences.getString(PrefKeys.READING_ORDER, null)) } private fun createAdapter(): TimelinePagingAdapter { val statusDisplayOptions = StatusDisplayOptions( animateAvatars = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false), mediaPreviewEnabled = accountManager.activeAccount!!.mediaPreviewEnabled, 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 }, 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), showSensitiveMedia = accountManager.activeAccount!!.alwaysShowSensitiveMedia, openSpoiler = accountManager.activeAccount!!.alwaysOpenSpoiler ) return TimelinePagingAdapter( statusDisplayOptions, this ) } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED) val adapter = createAdapter() this.adapter = adapter setupSwipeRefreshLayout() setupRecyclerView(adapter) adapter.addLoadStateListener { loadState -> if (loadState.refresh != LoadState.Loading && loadState.source.refresh != LoadState.Loading) { binding.swipeRefreshLayout.isRefreshing = false } binding.statusView.hide() binding.progressBar.hide() 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 ) 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() } } is LoadState.Loading -> { binding.progressBar.show() } } } } adapter.registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() { override fun onItemRangeInserted(positionStart: Int, itemCount: Int) { val firstPos = (binding.recyclerView.layoutManager as LinearLayoutManager).findFirstCompletelyVisibleItemPosition() if (firstPos == 0 && positionStart == 0 && adapter.itemCount != itemCount) { binding.recyclerView.post { if (getView() != null) { if (isSwipeToRefreshEnabled) { binding.recyclerView.scrollBy( 0, Utils.dpToPx(requireContext(), -30) ) } else { binding.recyclerView.scrollToPosition(0) } } } // we loaded new posts at the top - no need to handle "load more" anymore loadMorePosition = null } if (readingOrder == ReadingOrder.OLDEST_FIRST) { updateReadingPositionForOldestFirst(adapter) } } }) viewLifecycleOwner.lifecycleScope.launch { viewModel.statuses.collectLatest { pagingData -> adapter.submitData(pagingData) } } viewLifecycleOwner.lifecycleScope.launch { eventHub.events.collect { event -> when (event) { is PreferenceChangedEvent -> { onPreferenceChanged(adapter, event.preferenceKey) } is StatusComposedEvent -> { val status = event.status handleStatusComposeEvent(adapter, status) } } } } updateRelativeTimePeriodically(preferences, adapter) } override fun onDestroyView() { // Clear the adapter to prevent leaking the View adapter = null buttonToAnimate = null super.onDestroyView() } override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { if (isSwipeToRefreshEnabled) { menuInflater.inflate(R.menu.fragment_timeline, menu) } } override fun onMenuItemSelected(menuItem: MenuItem): Boolean { return when (menuItem.itemId) { R.id.action_refresh -> { if (isSwipeToRefreshEnabled) { binding.swipeRefreshLayout.isRefreshing = true refreshContent() true } else { false } } else -> false } } /** * Set the correct reading position in the timeline after the user clicked "Load more", * assuming the reading position should be below the freshly-loaded statuses. */ // Note: The positionStart parameter to onItemRangeInserted() does not always // match the adapter position where data was inserted (which is why loadMorePosition // is tracked manually, see this bug report for another example: // https://github.com/android/architecture-components-samples/issues/726). private fun updateReadingPositionForOldestFirst(adapter: TimelinePagingAdapter) { var position = loadMorePosition ?: return val statusIdBelowLoadMore = statusIdBelowLoadMore ?: return var status: StatusViewData? while (adapter.peek(position).let { status = it it != null } ) { if (status?.id == statusIdBelowLoadMore) { val lastVisiblePosition = (binding.recyclerView.layoutManager as LinearLayoutManager).findLastVisibleItemPosition() if (position > lastVisiblePosition) { binding.recyclerView.scrollToPosition(position) } break } position++ } loadMorePosition = null } private fun setupSwipeRefreshLayout() { binding.swipeRefreshLayout.isEnabled = isSwipeToRefreshEnabled binding.swipeRefreshLayout.setOnRefreshListener(this) } private fun setupRecyclerView(adapter: TimelinePagingAdapter) { val hasFab = (activity as? ActionButtonActivity?)?.actionButton != null binding.recyclerView.ensureBottomPadding(fab = hasFab) binding.recyclerView.setAccessibilityDelegateCompat( ListStatusAccessibilityDelegate(binding.recyclerView, this) { pos -> if (pos in 0 until adapter.itemCount) { adapter.peek(pos) } else { null } } ) binding.recyclerView.layoutManager = LinearLayoutManager(context) val divider = DividerItemDecoration(context, RecyclerView.VERTICAL) binding.recyclerView.addItemDecoration(divider) // CWs are expanded without animation, buttons animate itself, we don't need it basically (binding.recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false binding.recyclerView.adapter = adapter } override fun onRefresh() { binding.statusView.hide() 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) } override fun onReblog(reblog: Boolean, position: Int, visibility: Status.Visibility?, button: SparkButton?) { val status = adapter?.peek(position)?.asStatusOrNull() ?: return buttonToAnimate = button if (reblog && visibility == null) { confirmReblog(preferences) { visibility -> viewModel.reblog(true, status, visibility) buttonToAnimate?.playAnimation() buttonToAnimate?.isChecked = true } } else { viewModel.reblog(reblog, status, visibility ?: Status.Visibility.PUBLIC) if (reblog) { buttonToAnimate?.playAnimation() } buttonToAnimate?.isChecked = reblog } } private fun onTranslate(position: Int) { val status = adapter?.peek(position)?.asStatusOrNull() ?: return viewLifecycleOwner.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, button: SparkButton?) { val status = adapter?.peek(position)?.asStatusOrNull() ?: return buttonToAnimate = button if (favourite) { confirmFavourite(preferences) { viewModel.favorite(true, status) buttonToAnimate?.playAnimation() buttonToAnimate?.isChecked = true } } else { viewModel.favorite(false, status) } } override fun onBookmark(bookmark: Boolean, position: Int) { val status = adapter?.peek(position)?.asStatusOrNull() ?: return viewModel.bookmark(bookmark, status) } override fun onVoteInPoll(position: Int, choices: List) { val status = adapter?.peek(position)?.asStatusOrNull() ?: return viewModel.voteInPoll(choices, status) } override fun onShowPollResults(position: Int) { adapter?.peek(position)?.asStatusOrNull()?.let { status -> viewModel.showPollResults(status) } } override fun clearWarningAction(position: Int) { val status = adapter?.peek(position)?.asStatusOrNull() ?: return viewModel.clearWarning(status) } override fun onMore(view: View, position: Int) { val status = adapter?.peek(position)?.asStatusOrNull() ?: return super.more( status.status, view, position, (status.translation as? TranslationViewData.Loaded)?.data ) } override fun onOpenReblog(position: Int) { val status = adapter?.peek(position)?.asStatusOrNull() ?: return super.openReblog(status.status) } override fun onExpandedChange(expanded: Boolean, position: Int) { val status = adapter?.peek(position)?.asStatusOrNull() ?: return viewModel.changeExpanded(expanded, status) } override fun onContentHiddenChange(isShowing: Boolean, position: Int) { val status = adapter?.peek(position)?.asStatusOrNull() ?: return viewModel.changeContentShowing(isShowing, status) } override fun onShowReblogs(position: Int) { val statusId = adapter?.peek(position)?.asStatusOrNull()?.id ?: return val intent = newIntent(requireContext(), AccountListActivity.Type.REBLOGGED, statusId) 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?.startActivityWithSlideInAnimation(intent) } override fun onLoadMore(position: Int) { val adapter = this.adapter val placeholder = adapter?.peek(position)?.asPlaceholderOrNull() ?: return loadMorePosition = position statusIdBelowLoadMore = if (position + 1 < adapter.itemCount) adapter.peek(position + 1)?.id else null viewModel.loadMore(placeholder.id) } override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) { val status = adapter?.peek(position)?.asStatusOrNull() ?: return viewModel.changeContentCollapsed(isCollapsed, status) } override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) { val status = adapter?.peek(position)?.asStatusOrNull() ?: return super.viewMedia( attachmentIndex, AttachmentViewData.list(status), view ) } override fun onViewThread(position: Int) { val status = adapter?.peek(position)?.asStatusOrNull() ?: return super.viewThread(status.actionable.id, status.actionable.url) } override fun onViewTag(tag: String) { if (viewModel.kind == TimelineViewModel.Kind.TAG && viewModel.tags.size == 1 && viewModel.tags.contains(tag) ) { // If already viewing a tag page, then ignore any request to view that tag again. return } super.viewTag(tag) } override fun onViewAccount(id: String) { if (( 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 * should be ignored. */ return } super.viewAccount(id) } private fun onPreferenceChanged(adapter: TimelinePagingAdapter, key: String) { when (key) { PrefKeys.MEDIA_PREVIEW_ENABLED -> { val enabled = accountManager.activeAccount!!.mediaPreviewEnabled val oldMediaPreviewEnabled = adapter.mediaPreviewEnabled if (enabled != oldMediaPreviewEnabled) { adapter.mediaPreviewEnabled = enabled adapter.notifyItemRangeChanged(0, adapter.itemCount) } } PrefKeys.READING_ORDER -> { readingOrder = ReadingOrder.from( preferences.getString(PrefKeys.READING_ORDER, null) ) } } } private fun handleStatusComposeEvent(adapter: TimelinePagingAdapter, status: Status) { when (kind) { TimelineViewModel.Kind.HOME, TimelineViewModel.Kind.PUBLIC_FEDERATED, 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, TimelineViewModel.Kind.BOOKMARKS, TimelineViewModel.Kind.USER_PINNED -> return } } public override fun removeItem(position: Int) { val status = adapter?.peek(position)?.asStatusOrNull() ?: return viewModel.removeStatusWithId(status.id) } private var talkBackWasEnabled = false 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) } } } } override fun onResume() { super.onResume() val a11yManager = requireContext().getSystemService() val wasEnabled = talkBackWasEnabled talkBackWasEnabled = a11yManager?.isEnabled == true Log.d(TAG, "talkback was enabled: $wasEnabled, now $talkBackWasEnabled") if (talkBackWasEnabled && !wasEnabled) { val adapter = requireNotNull(this.adapter) adapter.notifyItemRangeChanged(0, adapter.itemCount) } } override fun onReselect() { if (view != null) { binding.recyclerView.layoutManager?.scrollToPosition(0) binding.recyclerView.stopScroll() } } override fun refreshContent() { onRefresh() } companion object { private const val TAG = "TimelineF" // logging tag private const val KIND_ARG = "kind" private const val ID_ARG = "id" private const val HASHTAGS_ARG = "hashtags" private const val ARG_ENABLE_SWIPE_TO_REFRESH = "enableSwipeToRefresh" fun newInstance( kind: TimelineViewModel.Kind, hashtagOrId: String? = null, enableSwipeToRefresh: Boolean = true ): TimelineFragment { val fragment = TimelineFragment() val arguments = Bundle(3) arguments.putString(KIND_ARG, kind.name) arguments.putString(ID_ARG, hashtagOrId) arguments.putBoolean(ARG_ENABLE_SWIPE_TO_REFRESH, enableSwipeToRefresh) fragment.arguments = arguments return fragment } @JvmStatic fun newHashtagInstance(hashtags: List): TimelineFragment { val fragment = TimelineFragment() val arguments = Bundle(3) arguments.putString(KIND_ARG, TimelineViewModel.Kind.TAG.name) arguments.putStringArrayList(HASHTAGS_ARG, ArrayList(hashtags)) arguments.putBoolean(ARG_ENABLE_SWIPE_TO_REFRESH, true) fragment.arguments = arguments return fragment } } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelinePagingAdapter.kt ================================================ /* 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.components.timeline 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.R import com.keylesspalace.tusky.adapter.FilteredStatusViewHolder import com.keylesspalace.tusky.adapter.LoadMoreViewHolder import com.keylesspalace.tusky.adapter.PlaceholderViewHolder import com.keylesspalace.tusky.adapter.StatusBaseViewHolder import com.keylesspalace.tusky.adapter.StatusViewHolder import com.keylesspalace.tusky.databinding.ItemLoadMoreBinding import com.keylesspalace.tusky.databinding.ItemPlaceholderBinding import com.keylesspalace.tusky.databinding.ItemStatusFilteredBinding import com.keylesspalace.tusky.entity.Filter import com.keylesspalace.tusky.interfaces.StatusActionListener import com.keylesspalace.tusky.util.StatusDisplayOptions import com.keylesspalace.tusky.viewdata.StatusViewData class TimelinePagingAdapter( private var statusDisplayOptions: StatusDisplayOptions, private val statusListener: StatusActionListener ) : PagingDataAdapter(TimelineDifferCallback) { var mediaPreviewEnabled: Boolean get() = statusDisplayOptions.mediaPreviewEnabled set(mediaPreviewEnabled) { statusDisplayOptions = statusDisplayOptions.copy( mediaPreviewEnabled = mediaPreviewEnabled ) } init { stateRestorationPolicy = StateRestorationPolicy.PREVENT_WHEN_EMPTY } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { val inflater = LayoutInflater.from(parent.context) return when (viewType) { VIEW_TYPE_PLACEHOLDER -> { PlaceholderViewHolder( ItemPlaceholderBinding.inflate(inflater, parent, false), mode = PlaceholderViewHolder.Mode.STATUS ) } VIEW_TYPE_STATUS_FILTERED -> { FilteredStatusViewHolder( ItemStatusFilteredBinding.inflate(inflater, parent, false), statusListener ) } VIEW_TYPE_LOAD_MORE -> { LoadMoreViewHolder( ItemLoadMoreBinding.inflate(inflater, parent, false), statusListener ) } else -> { StatusViewHolder(inflater.inflate(R.layout.item_status, parent, false)) } } } override fun onBindViewHolder(viewHolder: RecyclerView.ViewHolder, position: Int) { onBindViewHolder(viewHolder, position, emptyList()) } override fun onBindViewHolder( viewHolder: RecyclerView.ViewHolder, position: Int, payloads: List ) { val viewData = getItem(position) if (viewData is StatusViewData.LoadMore) { val holder = viewHolder as LoadMoreViewHolder holder.setup(viewData.isLoading) } else if (viewData is StatusViewData.Concrete) { if (viewData.filter?.action == Filter.Action.WARN) { val holder = viewHolder as FilteredStatusViewHolder holder.bind(viewData) } else { val holder = viewHolder as StatusViewHolder holder.setupWithStatus( viewData, statusListener, statusDisplayOptions, payloads, true ) } } } override fun getItemViewType(position: Int): Int { val viewData = getItem(position) return when { viewData == null -> VIEW_TYPE_PLACEHOLDER viewData is StatusViewData.LoadMore -> VIEW_TYPE_LOAD_MORE viewData.filter?.action == Filter.Action.WARN -> VIEW_TYPE_STATUS_FILTERED else -> VIEW_TYPE_STATUS } } companion object { private const val VIEW_TYPE_PLACEHOLDER = 0 private const val VIEW_TYPE_STATUS = 1 private const val VIEW_TYPE_STATUS_FILTERED = 2 private const val VIEW_TYPE_LOAD_MORE = 3 private val TimelineDifferCallback = object : DiffUtil.ItemCallback() { override fun areItemsTheSame( oldItem: StatusViewData, newItem: StatusViewData ): Boolean { return oldItem.id == newItem.id } override fun areContentsTheSame( oldItem: StatusViewData, newItem: StatusViewData ): Boolean { return false // Items are different always. It allows to refresh timestamp on every view holder update } override fun getChangePayload(oldItem: StatusViewData, newItem: StatusViewData): Any? { return if (oldItem == newItem) { // If items are equal - update timestamp only StatusBaseViewHolder.Key.KEY_CREATED } else { // If items are different - update the whole view holder null } } } } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineTypeMappers.kt ================================================ /* 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.components.timeline import com.keylesspalace.tusky.db.entity.HomeTimelineData import com.keylesspalace.tusky.db.entity.HomeTimelineEntity import com.keylesspalace.tusky.db.entity.TimelineAccountEntity import com.keylesspalace.tusky.db.entity.TimelineStatusEntity 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 java.util.Date data class LoadMorePlaceholder( val id: String, val loading: Boolean ) fun TimelineAccount.toEntity(tuskyAccountId: Long): TimelineAccountEntity { return TimelineAccountEntity( serverId = id, tuskyAccountId = tuskyAccountId, localUsername = localUsername, username = username, displayName = name, url = url, avatar = avatar, emojis = emojis, note = note, bot = bot ) } fun TimelineAccountEntity.toAccount(): TimelineAccount { return TimelineAccount( id = serverId, localUsername = localUsername, username = username, displayName = displayName, note = note, url = url, avatar = avatar, bot = bot, emojis = emojis ) } fun LoadMorePlaceholder.toEntity(tuskyAccountId: Long): HomeTimelineEntity { return HomeTimelineEntity( id = this.id, tuskyAccountId = tuskyAccountId, statusId = null, reblogAccountId = null, loading = this.loading ) } fun Status.toEntity( tuskyAccountId: Long, expanded: Boolean, contentShowing: Boolean, contentCollapsed: Boolean ) = TimelineStatusEntity( serverId = id, url = actionableStatus.url, tuskyAccountId = tuskyAccountId, authorServerId = actionableStatus.account.id, inReplyToId = actionableStatus.inReplyToId, inReplyToAccountId = actionableStatus.inReplyToAccountId, content = actionableStatus.content, createdAt = actionableStatus.createdAt.time, editedAt = actionableStatus.editedAt?.time, emojis = actionableStatus.emojis, reblogsCount = actionableStatus.reblogsCount, favouritesCount = actionableStatus.favouritesCount, reblogged = actionableStatus.reblogged, favourited = actionableStatus.favourited, bookmarked = actionableStatus.bookmarked, sensitive = actionableStatus.sensitive, spoilerText = actionableStatus.spoilerText, visibility = actionableStatus.visibility, attachments = actionableStatus.attachments, mentions = actionableStatus.mentions, tags = actionableStatus.tags, application = actionableStatus.application, poll = actionableStatus.poll, muted = actionableStatus.muted, expanded = expanded, contentShowing = contentShowing, contentCollapsed = contentCollapsed, pinned = actionableStatus.pinned, card = actionableStatus.card, repliesCount = actionableStatus.repliesCount, language = actionableStatus.language, filtered = actionableStatus.filtered.orEmpty() ) fun TimelineStatusEntity.toStatus( account: TimelineAccountEntity, ) = Status( id = serverId, url = url, account = account.toAccount(), inReplyToId = inReplyToId, inReplyToAccountId = inReplyToAccountId, reblog = null, content = content, createdAt = Date(createdAt), editedAt = editedAt?.let { Date(it) }, emojis = emojis, reblogsCount = reblogsCount, favouritesCount = favouritesCount, reblogged = reblogged, favourited = favourited, bookmarked = bookmarked, sensitive = sensitive, spoilerText = spoilerText, visibility = visibility, attachments = attachments, mentions = mentions, tags = tags, application = application, pinned = false, muted = muted, poll = poll, card = card, repliesCount = repliesCount, language = language, filtered = filtered, ) fun HomeTimelineData.toViewData( isDetailed: Boolean = false, translation: TranslationViewData? = null, ): StatusViewData { if (this.account == null || this.status == null) { return StatusViewData.LoadMore(this.id, loading) } val originalStatus = status.toStatus(account) val status = if (reblogAccount != null) { Status( id = id, // no url for reblogs url = null, account = reblogAccount.toAccount(), inReplyToId = status.inReplyToId, inReplyToAccountId = status.inReplyToAccountId, reblog = originalStatus, content = status.content, // lie but whatever? createdAt = Date(status.createdAt), editedAt = null, emojis = emptyList(), reblogsCount = status.reblogsCount, favouritesCount = status.favouritesCount, reblogged = status.reblogged, favourited = status.favourited, bookmarked = status.bookmarked, sensitive = status.sensitive, spoilerText = status.spoilerText, visibility = status.visibility, attachments = emptyList(), mentions = emptyList(), tags = emptyList(), application = null, pinned = false, muted = status.muted, poll = null, card = null, repliesCount = status.repliesCount, language = status.language, filtered = status.filtered, ) } else { originalStatus } return StatusViewData.Concrete( status = status, isExpanded = this.status.expanded, isShowingContent = this.status.contentShowing, isCollapsed = this.status.contentCollapsed, isDetailed = isDetailed, repliedToAccount = repliedToAccount?.toAccount(), translation = translation, ) } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/timeline/util/TimelineUtils.kt ================================================ package com.keylesspalace.tusky.components.timeline.util import com.squareup.moshi.JsonDataException import java.io.IOException import retrofit2.HttpException fun Throwable.isExpected() = this is IOException || this is HttpException || this is JsonDataException inline fun ifExpected(t: Throwable, cb: () -> T): T { if (t.isExpected()) { return cb() } else { throw t } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt ================================================ /* 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.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.keylesspalace.tusky.components.timeline.LoadMorePlaceholder import com.keylesspalace.tusky.components.timeline.toEntity import com.keylesspalace.tusky.components.timeline.util.ifExpected import com.keylesspalace.tusky.db.AppDatabase import com.keylesspalace.tusky.db.entity.AccountEntity import com.keylesspalace.tusky.db.entity.HomeTimelineData import com.keylesspalace.tusky.db.entity.HomeTimelineEntity import com.keylesspalace.tusky.db.entity.TimelineStatusEntity import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.network.MastodonApi import retrofit2.HttpException @OptIn(ExperimentalPagingApi::class) class CachedTimelineRemoteMediator( private val viewModel: CachedTimelineViewModel, private val api: MastodonApi, private val db: AppDatabase, ) : RemoteMediator() { private var initialRefresh = false private val timelineDao = db.timelineDao() private val statusDao = db.timelineStatusDao() private val accountDao = db.timelineAccountDao() override suspend fun load( loadType: LoadType, state: PagingState ): MediatorResult { val activeAccount = viewModel.activeAccountFlow.value if (activeAccount == null) { return MediatorResult.Success(endOfPaginationReached = true) } try { var dbEmpty = false val topPlaceholderId = if (loadType == LoadType.REFRESH) { timelineDao.getTopPlaceholderId(activeAccount.id) } else { null // don't execute the query if it is not needed } if (!initialRefresh && loadType == LoadType.REFRESH) { val topId = timelineDao.getTopId(activeAccount.id) topId?.let { cachedTopId -> val statusResponse = api.homeTimeline( maxId = cachedTopId, // so already existing placeholders don't get accidentally overwritten sinceId = topPlaceholderId, limit = state.config.pageSize ) val statuses = statusResponse.body() if (statusResponse.isSuccessful && statuses != null) { db.withTransaction { replaceStatusRange(statuses, state, activeAccount) } } } initialRefresh = true dbEmpty = topId == null } val statusResponse = when (loadType) { LoadType.REFRESH -> { api.homeTimeline(sinceId = topPlaceholderId, limit = state.config.pageSize) } LoadType.PREPEND -> { return MediatorResult.Success(endOfPaginationReached = true) } LoadType.APPEND -> { val maxId = state.pages.findLast { it.data.isNotEmpty() }?.data?.lastOrNull()?.status?.serverId api.homeTimeline(maxId = maxId, limit = state.config.pageSize) } } val statuses = statusResponse.body() if (!statusResponse.isSuccessful || statuses == null) { return MediatorResult.Error(HttpException(statusResponse)) } db.withTransaction { val overlappedStatuses = replaceStatusRange(statuses, state, activeAccount) /* In case we loaded a whole page and there was no overlap with existing statuses, we insert a placeholder because there might be even more unknown statuses */ if (loadType == LoadType.REFRESH && overlappedStatuses == 0 && statuses.size == state.config.pageSize && !dbEmpty) { /* This overrides the last of the newly loaded statuses with a placeholder to guarantee the placeholder has an id that exists on the server as not all servers handle client generated ids as expected */ timelineDao.insertHomeTimelineItem( LoadMorePlaceholder(statuses.last().id, loading = false).toEntity(activeAccount.id) ) } } return MediatorResult.Success(endOfPaginationReached = statuses.isEmpty()) } catch (e: Exception) { return ifExpected(e) { Log.w(TAG, "Failed to load timeline", e) MediatorResult.Error(e) } } } /** * Deletes all statuses in a given range and inserts new statuses. * This is necessary so statuses that have been deleted on the server are cleaned up. * Should be run in a transaction as it executes multiple db updates * @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, activeAccount: AccountEntity ): Int { val overlappedStatuses = if (statuses.isNotEmpty()) { timelineDao.deleteRange(activeAccount.id, statuses.last().id, statuses.first().id) } else { 0 } for (status in statuses) { accountDao.insert(status.account.toEntity(activeAccount.id)) status.reblog?.account?.toEntity(activeAccount.id)?.let { rebloggedAccount -> accountDao.insert(rebloggedAccount) } // check if we already have one of the newly loaded statuses cached locally // in case we do, copy the local state (expanded, contentShowing, contentCollapsed) over so it doesn't get lost var oldStatus: TimelineStatusEntity? = null for (page in state.pages) { oldStatus = page.data.find { s -> s.status?.serverId == status.actionableId }?.status if (oldStatus != null) break } val expanded = oldStatus?.expanded ?: activeAccount.alwaysOpenSpoiler val contentShowing = oldStatus?.contentShowing ?: status.shouldShowContent(activeAccount.alwaysShowSensitiveMedia, viewModel.kind.toFilterKind()) val contentCollapsed = oldStatus?.contentCollapsed != false statusDao.insert( status.actionableStatus.toEntity( tuskyAccountId = activeAccount.id, expanded = expanded, contentShowing = contentShowing, contentCollapsed = contentCollapsed ) ) timelineDao.insertHomeTimelineItem( HomeTimelineEntity( tuskyAccountId = activeAccount.id, id = status.id, statusId = status.actionableId, reblogAccountId = if (status.reblog != null) { status.account.id } else { null } ) ) } return overlappedStatuses } companion object { private const val TAG = "CachedTimelineRM" } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt ================================================ /* 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.components.timeline.viewmodel import android.content.SharedPreferences import android.util.Log import androidx.lifecycle.viewModelScope import androidx.paging.ExperimentalPagingApi import androidx.paging.Pager import androidx.paging.PagingConfig import androidx.paging.PagingSource import androidx.paging.cachedIn import androidx.paging.filter import androidx.paging.map import androidx.room.withTransaction 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.components.preference.PreferencesFragment.ReadingOrder.NEWEST_FIRST import com.keylesspalace.tusky.components.preference.PreferencesFragment.ReadingOrder.OLDEST_FIRST import com.keylesspalace.tusky.components.timeline.LoadMorePlaceholder import com.keylesspalace.tusky.components.timeline.toEntity import com.keylesspalace.tusky.components.timeline.toViewData import com.keylesspalace.tusky.components.timeline.util.ifExpected import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AppDatabase import com.keylesspalace.tusky.db.entity.HomeTimelineData import com.keylesspalace.tusky.db.entity.HomeTimelineEntity import com.keylesspalace.tusky.entity.Filter import com.keylesspalace.tusky.network.FilterModel import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.usecase.TimelineCases import com.keylesspalace.tusky.viewdata.StatusViewData import com.keylesspalace.tusky.viewdata.TranslationViewData import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.launch import retrofit2.HttpException /** * TimelineViewModel that caches all statuses in a local database */ @HiltViewModel class CachedTimelineViewModel @Inject constructor( timelineCases: TimelineCases, private val api: MastodonApi, eventHub: EventHub, accountManager: AccountManager, sharedPreferences: SharedPreferences, filterModel: FilterModel, private val db: AppDatabase ) : TimelineViewModel( timelineCases, eventHub, accountManager, sharedPreferences, filterModel ) { 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(this, api, db), pagingSourceFactory = { db.timelineDao().getHomeTimeline(accountId).also { newPagingSource -> this.currentPagingSource = newPagingSource } } ).flow // 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 { timelineData -> val translation = translations[timelineData.status?.serverId] val viewData = timelineData.toViewData( isDetailed = false, translation = translation ) viewData.filter = shouldFilterStatus(viewData) viewData }.filter { statusViewData -> statusViewData.filter?.action != Filter.Action.HIDE } } .flowOn(Dispatchers.Default) override fun changeExpanded(expanded: Boolean, status: StatusViewData.Concrete) { viewModelScope.launch { db.timelineStatusDao() .setExpanded(accountId, status.actionableId, expanded) } } override fun changeContentShowing(isShowing: Boolean, status: StatusViewData.Concrete) { viewModelScope.launch { db.timelineStatusDao() .setContentShowing(accountId, status.actionableId, isShowing) } } override fun changeContentCollapsed(isCollapsed: Boolean, status: StatusViewData.Concrete) { viewModelScope.launch { db.timelineStatusDao() .setContentCollapsed(accountId, status.actionableId, isCollapsed) } } override fun clearWarning(status: StatusViewData.Concrete) { viewModelScope.launch { db.timelineStatusDao().clearWarning(accountId, status.actionableId) } } override fun removeStatusWithId(id: String) { // handled by CacheUpdater } override fun loadMore(placeholderId: String) { viewModelScope.launch { try { val timelineDao = db.timelineDao() val statusDao = db.timelineStatusDao() val accountDao = db.timelineAccountDao() timelineDao.insertHomeTimelineItem( LoadMorePlaceholder(placeholderId, loading = true).toEntity(tuskyAccountId = accountId) ) val (idAbovePlaceholder, idBelowPlaceholder) = db.withTransaction { timelineDao.getIdAbove(accountId, placeholderId) to timelineDao.getIdBelow(accountId, placeholderId) } val response = when (readingOrder) { // Using minId, loads up to LOAD_AT_ONCE statuses with IDs immediately // after minId and no larger than maxId OLDEST_FIRST -> api.homeTimeline( maxId = idAbovePlaceholder, minId = idBelowPlaceholder, limit = LOAD_AT_ONCE ) // Using sinceId, loads up to LOAD_AT_ONCE statuses immediately before // maxId, and no smaller than minId. NEWEST_FIRST -> api.homeTimeline( maxId = idAbovePlaceholder, sinceId = idBelowPlaceholder, limit = LOAD_AT_ONCE ) } val statuses = response.body() if (!response.isSuccessful || statuses == null) { loadMoreFailed(placeholderId, HttpException(response)) return@launch } val account = activeAccountFlow.value if (account == null) { return@launch } db.withTransaction { timelineDao.deleteHomeTimelineItem(accountId, placeholderId) val overlappedStatuses = if (statuses.isNotEmpty()) { timelineDao.deleteRange( accountId, statuses.last().id, statuses.first().id ) } else { 0 } for (status in statuses) { accountDao.insert(status.account.toEntity(accountId)) status.reblog?.account?.toEntity(accountId) ?.let { rebloggedAccount -> accountDao.insert(rebloggedAccount) } statusDao.insert( status.actionableStatus.toEntity( tuskyAccountId = accountId, expanded = account.alwaysOpenSpoiler, contentShowing = status.shouldShowContent(account.alwaysShowSensitiveMedia, kind.toFilterKind()), contentCollapsed = true, ) ) timelineDao.insertHomeTimelineItem( HomeTimelineEntity( tuskyAccountId = accountId, id = status.id, statusId = status.actionableId, reblogAccountId = if (status.reblog != null) { status.account.id } else { null } ) ) } /* In case we loaded a whole page and there was no overlap with existing statuses, we insert a placeholder because there might be even more unknown statuses */ if (overlappedStatuses == 0 && statuses.size == LOAD_AT_ONCE) { /* This overrides the first/last of the newly loaded statuses with a placeholder to guarantee the placeholder has an id that exists on the server as not all servers handle client generated ids as expected */ val idToConvert = when (readingOrder) { OLDEST_FIRST -> statuses.first().id NEWEST_FIRST -> statuses.last().id } timelineDao.insertHomeTimelineItem( LoadMorePlaceholder( idToConvert, loading = false ).toEntity(accountId) ) } } } catch (e: Exception) { ifExpected(e) { loadMoreFailed(placeholderId, e) } } } } private suspend fun loadMoreFailed(placeholderId: String, e: Exception) { Log.w(TAG, "failed loading statuses", e) val activeAccount = accountManager.activeAccount!! db.timelineDao() .insertHomeTimelineItem(LoadMorePlaceholder(placeholderId, loading = false).toEntity(activeAccount.id)) } override fun fullReload() { viewModelScope.launch { val activeAccount = accountManager.activeAccount!! db.timelineDao().removeAllHomeTimelineItems(activeAccount.id) } } override fun saveReadingPosition(statusId: String) { viewModelScope.launch { accountManager.activeAccount?.let { account -> Log.d(TAG, "Saving position at: $statusId") accountManager.updateAccount(account) { copy(lastVisibleHomeTimelineStatusId = statusId) } } } } override suspend fun invalidate() { // invalidating when we don't have statuses yet can cause empty timelines because it cancels the network load if (db.timelineDao().getHomeTimelineItemCount(accountId) > 0) { currentPagingSource?.invalidate() } } override suspend fun translate(status: StatusViewData.Concrete): NetworkResult { translations.value += (status.id to TranslationViewData.Loading) return timelineCases.translate(status.actionableId) .map { translation -> translations.value += (status.actionableId to TranslationViewData.Loaded(translation)) } .onFailure { translations.value -= status.actionableId } } override fun untranslate(status: StatusViewData.Concrete) { translations.value -= status.actionableId } companion object { private const val TAG = "CachedTimelineViewModel" } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelinePagingSource.kt ================================================ /* 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.components.timeline.viewmodel import androidx.paging.PagingSource import androidx.paging.PagingState import com.keylesspalace.tusky.viewdata.StatusViewData class NetworkTimelinePagingSource( private val viewModel: NetworkTimelineViewModel ) : PagingSource() { override fun getRefreshKey(state: PagingState): String? = null override suspend fun load(params: LoadParams): LoadResult { return if (params is LoadParams.Refresh) { val list = viewModel.statusData.toList() LoadResult.Page(list, null, viewModel.nextKey) } else { LoadResult.Page(emptyList(), null, null) } } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineRemoteMediator.kt ================================================ /* 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.components.timeline.viewmodel import android.util.Log import androidx.paging.ExperimentalPagingApi import androidx.paging.LoadType import androidx.paging.PagingState import androidx.paging.RemoteMediator import com.keylesspalace.tusky.components.timeline.util.ifExpected import com.keylesspalace.tusky.util.HttpHeaderLink import com.keylesspalace.tusky.util.toViewData import com.keylesspalace.tusky.viewdata.StatusViewData import retrofit2.HttpException @OptIn(ExperimentalPagingApi::class) 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 ): MediatorResult { try { val statusResponse = when (loadType) { LoadType.REFRESH -> { viewModel.fetchStatusesForKind(null, null, limit = state.config.pageSize) } LoadType.PREPEND -> { return MediatorResult.Success(endOfPaginationReached = true) } LoadType.APPEND -> { val maxId = viewModel.nextKey if (maxId != null) { viewModel.fetchStatusesForKind(maxId, null, limit = state.config.pageSize) } else { return MediatorResult.Success(endOfPaginationReached = true) } } } val statuses = statusResponse.body() if (!statusResponse.isSuccessful || statuses == null) { return MediatorResult.Error(HttpException(statusResponse)) } val activeAccount = viewModel.activeAccountFlow.value!! val data = statuses.map { status -> val oldStatus = viewModel.statusData.find { s -> s.asStatusOrNull()?.id == status.id }?.asStatusOrNull() val filter = oldStatus?.filter ?: status.getApplicableFilter(viewModel.kind.toFilterKind()) val contentShowing = oldStatus?.isShowingContent ?: status.shouldShowContent(activeAccount.alwaysShowSensitiveMedia, viewModel.kind.toFilterKind()) val expanded = oldStatus?.isExpanded ?: activeAccount.alwaysOpenSpoiler val contentCollapsed = oldStatus?.isCollapsed != false status.toViewData( isShowingContent = contentShowing, isExpanded = expanded, isCollapsed = contentCollapsed, filter = filter, ) } if (loadType == LoadType.REFRESH && viewModel.statusData.isNotEmpty()) { val insertPlaceholder = if (statuses.isNotEmpty()) { !viewModel.statusData.removeAll { statusViewData -> statuses.any { status -> status.id == statusViewData.asStatusOrNull()?.id } } } else { false } if (viewModel.kind == TimelineViewModel.Kind.PUBLIC_TRENDING_STATUSES) { statusIds.addAll(data.map { it.id }) } viewModel.statusData.addAll(0, data) if (insertPlaceholder) { viewModel.statusData[statuses.size - 1] = StatusViewData.LoadMore(statuses.last().id, false) } } else { val linkHeader = statusResponse.headers()["Link"] val links = HttpHeaderLink.parse(linkHeader) val next = HttpHeaderLink.findByRelationType(links, "next") 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.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" } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt ================================================ /* 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.components.timeline.viewmodel import android.content.SharedPreferences import android.util.Log import androidx.lifecycle.viewModelScope import androidx.paging.ExperimentalPagingApi import androidx.paging.Pager import androidx.paging.PagingConfig import androidx.paging.cachedIn import androidx.paging.filter import at.connyduck.calladapter.networkresult.NetworkResult import at.connyduck.calladapter.networkresult.map import at.connyduck.calladapter.networkresult.onFailure import com.keylesspalace.tusky.appstore.BlockEvent import com.keylesspalace.tusky.appstore.DomainMuteEvent import com.keylesspalace.tusky.appstore.Event import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.MuteEvent import com.keylesspalace.tusky.appstore.PollShowResultsEvent import com.keylesspalace.tusky.appstore.PollVoteEvent import com.keylesspalace.tusky.appstore.StatusChangedEvent import com.keylesspalace.tusky.appstore.StatusDeletedEvent import com.keylesspalace.tusky.appstore.UnfollowEvent import com.keylesspalace.tusky.components.timeline.util.ifExpected import com.keylesspalace.tusky.db.AccountManager 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.getDomain 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 dagger.hilt.android.lifecycle.HiltViewModel import java.io.IOException import javax.inject.Inject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.asExecutor import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import retrofit2.HttpException import retrofit2.Response /** * TimelineViewModel that caches all statuses in an in-memory list */ @HiltViewModel class NetworkTimelineViewModel @Inject constructor( timelineCases: TimelineCases, private val api: MastodonApi, eventHub: EventHub, accountManager: AccountManager, sharedPreferences: SharedPreferences, filterModel: FilterModel ) : TimelineViewModel( timelineCases, eventHub, accountManager, sharedPreferences, filterModel ) { var currentSource: NetworkTimelinePagingSource? = null val statusData: MutableList = mutableListOf() var nextKey: String? = null @OptIn(ExperimentalPagingApi::class) override val statuses = Pager( config = PagingConfig( pageSize = LOAD_AT_ONCE ), pagingSourceFactory = { NetworkTimelinePagingSource( viewModel = this ).also { source -> currentSource = source } }, remoteMediator = NetworkTimelineRemoteMediator(this) ).flow .map { pagingData -> pagingData.filter(Dispatchers.Default.asExecutor()) { statusViewData -> shouldFilterStatus(statusViewData)?.action != Filter.Action.HIDE } } .flowOn(Dispatchers.Default) .cachedIn(viewModelScope) init { viewModelScope.launch { eventHub.events .collect { event -> handleEvent(event) } } } private fun handleEvent(event: Event) { when (event) { is StatusChangedEvent -> handleStatusChangedEvent(event.status) is PollVoteEvent -> handlePollVote(event.statusId, event.poll) is PollShowResultsEvent -> handlePollShowResults(event.statusId) is UnfollowEvent -> { if (kind == Kind.HOME) { val id = event.accountId removeAllByAccountId(id) } } is BlockEvent -> { if (kind != Kind.USER && kind != Kind.USER_WITH_REPLIES && kind != Kind.USER_PINNED) { val id = event.accountId removeAllByAccountId(id) } } is MuteEvent -> { if (kind != Kind.USER && kind != Kind.USER_WITH_REPLIES && kind != Kind.USER_PINNED) { val id = event.accountId removeAllByAccountId(id) } } is DomainMuteEvent -> { if (kind != Kind.USER && kind != Kind.USER_WITH_REPLIES && kind != Kind.USER_PINNED) { val instance = event.instance removeAllByInstance(instance) } } is StatusDeletedEvent -> { if (kind != Kind.USER && kind != Kind.USER_WITH_REPLIES && kind != Kind.USER_PINNED) { removeStatusWithId(event.statusId) } } } } override fun changeExpanded(expanded: Boolean, status: StatusViewData.Concrete) { status.copy( isExpanded = expanded ).update() } override fun changeContentShowing(isShowing: Boolean, status: StatusViewData.Concrete) { status.copy( isShowingContent = isShowing ).update() } override fun changeContentCollapsed(isCollapsed: Boolean, status: StatusViewData.Concrete) { status.copy( isCollapsed = isCollapsed ).update() } private fun removeAllByAccountId(accountId: String) { statusData.removeAll { vd -> val status = vd.asStatusOrNull()?.status ?: return@removeAll false status.account.id == accountId || status.actionableStatus.account.id == accountId } currentSource?.invalidate() } private fun removeAllByInstance(instance: String) { statusData.removeAll { vd -> val status = vd.asStatusOrNull()?.status ?: return@removeAll false getDomain(status.account.url) == instance } currentSource?.invalidate() } override fun removeStatusWithId(id: String) { statusData.removeAll { vd -> val status = vd.asStatusOrNull()?.status ?: return@removeAll false status.id == id || status.reblog?.id == id } currentSource?.invalidate() } override fun loadMore(placeholderId: String) { viewModelScope.launch { try { val placeholderIndex = statusData.indexOfFirst { it is StatusViewData.LoadMore && it.id == placeholderId } statusData[placeholderIndex] = StatusViewData.LoadMore(placeholderId, isLoading = true) val idAbovePlaceholder = statusData.getOrNull(placeholderIndex - 1)?.id val statusResponse = fetchStatusesForKind( fromId = idAbovePlaceholder, uptoId = null, limit = 20 ) val statuses = statusResponse.body() if (!statusResponse.isSuccessful || statuses == null) { loadMoreFailed(placeholderId, HttpException(statusResponse)) return@launch } statusData.removeAt(placeholderIndex) val activeAccount = accountManager.activeAccount!! val data: MutableList = statuses.map { status -> status.toViewData( isShowingContent = status.shouldShowContent(activeAccount.alwaysShowSensitiveMedia, kind.toFilterKind()), isExpanded = activeAccount.alwaysOpenSpoiler, isCollapsed = true, filter = status.getApplicableFilter(kind.toFilterKind()), ) }.toMutableList() 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 } if (overlappedFrom < overlappedTo) { 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()!! .copy( isShowingContent = oldStatus!!.isShowingContent, isExpanded = oldStatus.isExpanded, isCollapsed = oldStatus.isCollapsed ) } statusData.removeAll { status -> when (status) { is StatusViewData.LoadMore -> 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.LoadMore(statuses.last().id, isLoading = false) } } statusData.addAll(placeholderIndex, data) currentSource?.invalidate() } catch (e: Exception) { ifExpected(e) { loadMoreFailed(placeholderId, e) } } } } private fun loadMoreFailed(placeholderId: String, e: Exception) { Log.w("NetworkTimelineVM", "failed loading statuses", e) val index = statusData.indexOfFirst { it is StatusViewData.LoadMore && it.id == placeholderId } statusData[index] = StatusViewData.LoadMore(placeholderId, isLoading = false) currentSource?.invalidate() } private fun handleStatusChangedEvent(status: Status) { updateStatusByActionableId(status.id) { status } } private fun handlePollVote(statusId: String, poll: Poll) { updateStatusByActionableId(statusId) { status -> status.copy(poll = poll) } } private fun handlePollShowResults(statusId: String) { updateStatusByActionableId(statusId) { status -> status.copy(poll = status.poll?.copy(voted = true)) } } override fun fullReload() { nextKey = statusData.firstOrNull { it is StatusViewData.Concrete }?.asStatusOrNull()?.id statusData.clear() currentSource?.invalidate() } override fun clearWarning(status: StatusViewData.Concrete) { updateStatusByActionableId(status.actionableId) { it.copy(filtered = emptyList()) } } override fun saveReadingPosition(statusId: String) { /** Does nothing for non-cached timelines */ } override suspend fun invalidate() { 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?, uptoId: String?, limit: Int ): Response> { return when (kind) { Kind.HOME -> api.homeTimeline(maxId = fromId, sinceId = uptoId, limit = limit) Kind.PUBLIC_FEDERATED -> api.publicTimeline(null, fromId, uptoId, limit) Kind.PUBLIC_LOCAL -> api.publicTimeline(true, fromId, uptoId, limit) Kind.TAG -> { val firstHashtag = tags[0] val additionalHashtags = tags.subList(1, tags.size) api.hashtagTimeline(firstHashtag, additionalHashtags, null, fromId, uptoId, limit) } Kind.USER -> api.accountStatuses( id!!, fromId, uptoId, limit, excludeReplies = true, onlyMedia = null, pinned = null ) Kind.USER_PINNED -> api.accountStatuses( id!!, fromId, uptoId, limit, excludeReplies = null, onlyMedia = null, pinned = true ) Kind.USER_WITH_REPLIES -> api.accountStatuses( id!!, fromId, uptoId, limit, excludeReplies = null, 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 } statusData[position] = this currentSource?.invalidate() } private inline fun updateStatusByActionableId(id: String, updater: (Status) -> Status) { // posts can be multiple times in the timeline, e.g. once the original and once as boost statusData.forEachIndexed { index, status -> if (status.asStatusOrNull()?.actionableId == id) { updateViewDataAt(index) { vd -> if (vd.status.reblog != null) { vd.copy(status = vd.status.copy(reblog = updater(vd.status.reblog))) } else { vd.copy(status = updater(vd.status)) } } } } } private inline fun updateViewDataAt( position: Int, updater: (StatusViewData.Concrete) -> StatusViewData.Concrete ) { val status = statusData.getOrNull(position)?.asStatusOrNull() ?: return statusData[position] = updater(status) currentSource?.invalidate() } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt ================================================ /* 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.components.timeline.viewmodel import android.content.SharedPreferences 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.getOrThrow import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.FilterUpdatedEvent import com.keylesspalace.tusky.appstore.PreferenceChangedEvent import com.keylesspalace.tusky.components.preference.PreferencesFragment.ReadingOrder import com.keylesspalace.tusky.components.timeline.util.ifExpected import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.entity.Filter import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.network.FilterModel import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.usecase.TimelineCases import com.keylesspalace.tusky.viewdata.StatusViewData import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.launch abstract class TimelineViewModel( protected val timelineCases: TimelineCases, private val eventHub: EventHub, val accountManager: AccountManager, private val sharedPreferences: SharedPreferences, private val filterModel: FilterModel ) : ViewModel() { val activeAccountFlow = accountManager.activeAccount(viewModelScope) protected val accountId: Long = activeAccountFlow.value!!.id abstract val statuses: Flow> var kind: Kind = Kind.HOME private set var id: String? = null private set var tags: List = emptyList() private set protected var alwaysShowSensitiveMedia = false 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) { this.kind = kind this.id = id this.tags = tags val activeAccount = activeAccountFlow.value!! if (kind == Kind.HOME) { // Note the variable is "true if filter" but the underlying preference/settings text is "true if show" filterRemoveReplies = !activeAccount.isShowHomeReplies filterRemoveReblogs = !activeAccount.isShowHomeBoosts filterRemoveSelfReblogs = !activeAccount.isShowHomeSelfBoosts } readingOrder = ReadingOrder.from(sharedPreferences.getString(PrefKeys.READING_ORDER, null)) this.alwaysShowSensitiveMedia = activeAccount.alwaysShowSensitiveMedia this.alwaysOpenSpoilers = activeAccount.alwaysOpenSpoiler viewModelScope.launch { eventHub.events .collect { event -> when (event) { is PreferenceChangedEvent -> { onPreferenceChanged(event.preferenceKey) } is FilterUpdatedEvent -> { if (filterContextMatchesKind(this@TimelineViewModel.kind, event.filterContext)) { filterModel.init(kind.toFilterKind()) invalidate() } } } } } viewModelScope.launch { val needsRefresh = filterModel.init(kind.toFilterKind()) if (needsRefresh) { fullReload() } } } fun reblog(reblog: Boolean, status: StatusViewData.Concrete, visibility: Status.Visibility = Status.Visibility.PUBLIC): Job = viewModelScope.launch { try { timelineCases.reblog(status.actionableId, reblog, visibility).getOrThrow() } catch (t: Exception) { ifExpected(t) { Log.d(TAG, "Failed to reblog status " + status.actionableId, t) } } } fun favorite(favorite: Boolean, status: StatusViewData.Concrete): Job = viewModelScope.launch { try { timelineCases.favourite(status.actionableId, favorite).getOrThrow() } catch (t: Exception) { ifExpected(t) { Log.d(TAG, "Failed to favourite status " + status.actionableId, t) } } } fun bookmark(bookmark: Boolean, status: StatusViewData.Concrete): Job = viewModelScope.launch { try { timelineCases.bookmark(status.actionableId, bookmark).getOrThrow() } catch (t: Exception) { ifExpected(t) { Log.d(TAG, "Failed to bookmark status " + status.actionableId, t) } } } 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 } 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 showPollResults(status: StatusViewData.Concrete) = viewModelScope.launch { timelineCases.showPollResults(status.actionableId) } abstract fun changeExpanded(expanded: Boolean, status: StatusViewData.Concrete) abstract fun changeContentShowing(isShowing: Boolean, status: StatusViewData.Concrete) abstract fun changeContentCollapsed(isCollapsed: Boolean, status: StatusViewData.Concrete) abstract fun removeStatusWithId(id: String) abstract fun loadMore(placeholderId: String) abstract fun fullReload() abstract fun clearWarning(status: StatusViewData.Concrete) /** Saves the user's reading position so it can be restored later */ abstract fun saveReadingPosition(statusId: String) /** Triggered when currently displayed data must be reloaded. */ protected abstract suspend fun invalidate() protected fun shouldFilterStatus(statusViewData: StatusViewData): Filter? { val status = statusViewData.asStatusOrNull()?.status ?: return null return if ( (status.isReply && filterRemoveReplies) || (status.reblog != null && filterRemoveReblogs) || (status.account.id == status.reblog?.account?.id && filterRemoveSelfReblogs) ) { Filter(context = listOf(kind.toFilterKind()), action = Filter.Action.HIDE) } else if (status.actionableStatus.account.id == activeAccountFlow.value?.accountId) { // Mastodon filters don't apply for own posts null } else { filterModel.shouldFilterStatus(status.actionableStatus) } } private fun onPreferenceChanged(key: String) { activeAccountFlow.value?.let { activeAccount -> when (key) { PrefKeys.TAB_FILTER_HOME_REPLIES -> { val filter = !activeAccount.isShowHomeReplies val oldRemoveReplies = filterRemoveReplies filterRemoveReplies = kind == Kind.HOME && !filter if (oldRemoveReplies != filterRemoveReplies) { fullReload() } } PrefKeys.TAB_FILTER_HOME_BOOSTS -> { val filter = !activeAccount.isShowHomeBoosts val oldRemoveReblogs = filterRemoveReblogs filterRemoveReblogs = kind == Kind.HOME && !filter if (oldRemoveReblogs != filterRemoveReblogs) { fullReload() } } PrefKeys.TAB_SHOW_HOME_SELF_BOOSTS -> { val filter = !activeAccount.isShowHomeSelfBoosts val oldRemoveSelfReblogs = filterRemoveSelfReblogs filterRemoveSelfReblogs = kind == Kind.HOME && !filter if (oldRemoveSelfReblogs != filterRemoveSelfReblogs) { fullReload() } } PrefKeys.ALWAYS_SHOW_SENSITIVE_MEDIA -> { // it is ok if only newly loaded statuses are affected, no need to fully refresh alwaysShowSensitiveMedia = activeAccount.alwaysShowSensitiveMedia } PrefKeys.READING_ORDER -> { readingOrder = ReadingOrder.from(sharedPreferences.getString(PrefKeys.READING_ORDER, null)) } } } } 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 = filterContext.contains(kind.toFilterKind()) } enum class Kind { 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, PUBLIC_TRENDING_STATUSES -> Filter.Kind.PUBLIC USER, USER_WITH_REPLIES, USER_PINNED -> Filter.Kind.ACCOUNT else -> Filter.Kind.PUBLIC } } } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingActivity.kt ================================================ /* 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.components.trending import android.content.Context import android.content.Intent import android.os.Bundle import androidx.fragment.app.commit import com.keylesspalace.tusky.BaseActivity import com.keylesspalace.tusky.R import com.keylesspalace.tusky.databinding.ActivityTrendingBinding import com.keylesspalace.tusky.util.viewBinding import dagger.hilt.android.AndroidEntryPoint @AndroidEntryPoint class TrendingActivity : BaseActivity() { private val binding: ActivityTrendingBinding by viewBinding(ActivityTrendingBinding::inflate) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(binding.root) setSupportActionBar(binding.includedToolbar.toolbar) supportActionBar?.run { setTitle(R.string.title_public_trending_hashtags) setDisplayHomeAsUpEnabled(true) setDisplayShowHomeEnabled(true) } if (supportFragmentManager.findFragmentById(R.id.fragmentContainer) == null) { supportFragmentManager.commit { val fragment = TrendingTagsFragment.newInstance() replace(R.id.fragmentContainer, fragment) } } } companion object { fun getIntent(context: Context) = Intent(context, TrendingActivity::class.java) } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingDateViewHolder.kt ================================================ /* 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.trending import androidx.recyclerview.widget.RecyclerView import com.keylesspalace.tusky.R import com.keylesspalace.tusky.databinding.ItemTrendingDateBinding import java.text.SimpleDateFormat import java.util.Date import java.util.Locale import java.util.TimeZone class TrendingDateViewHolder( private val binding: ItemTrendingDateBinding ) : RecyclerView.ViewHolder(binding.root) { private val dateFormat = SimpleDateFormat("EEE dd MMM yyyy", Locale.getDefault()).apply { this.timeZone = TimeZone.getDefault() } fun setup(start: Date, end: Date) { binding.dates.text = itemView.context.getString( R.string.date_range, dateFormat.format(start), dateFormat.format(end) ) } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingTagViewHolder.kt ================================================ /* 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.trending import androidx.recyclerview.widget.RecyclerView import com.keylesspalace.tusky.R import com.keylesspalace.tusky.databinding.ItemTrendingCellBinding import com.keylesspalace.tusky.util.formatNumber import com.keylesspalace.tusky.viewdata.TrendingViewData import java.text.NumberFormat class TrendingTagViewHolder( private val binding: ItemTrendingCellBinding ) : RecyclerView.ViewHolder(binding.root) { private val numberFormat: NumberFormat = NumberFormat.getNumberInstance() fun setup(tagViewData: TrendingViewData.Tag, onViewTag: (String) -> Unit) { binding.tag.text = binding.root.context.getString(R.string.hashtag_format, tagViewData.name) binding.graph.maxTrendingValue = tagViewData.maxTrendingValue binding.graph.primaryLineData = tagViewData.usage binding.graph.secondaryLineData = tagViewData.accounts binding.totalUsage.text = formatNumber(tagViewData.usage.sum(), 1000) val totalAccounts = tagViewData.accounts.sum() binding.totalAccounts.text = formatNumber(totalAccounts, 1000) binding.currentUsage.text = numberFormat.format(tagViewData.usage.last()) binding.currentAccounts.text = numberFormat.format(tagViewData.accounts.last()) itemView.setOnClickListener { onViewTag(tagViewData.name) } itemView.contentDescription = itemView.context.getString( R.string.accessibility_talking_about_tag, totalAccounts, tagViewData.name ) } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingTagsAdapter.kt ================================================ /* 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.components.trending import android.view.LayoutInflater import android.view.ViewGroup import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView import com.keylesspalace.tusky.databinding.ItemTrendingCellBinding import com.keylesspalace.tusky.databinding.ItemTrendingDateBinding import com.keylesspalace.tusky.viewdata.TrendingViewData class TrendingTagsAdapter( private val onViewTag: (String) -> Unit ) : ListAdapter(TrendingDifferCallback) { init { stateRestorationPolicy = StateRestorationPolicy.PREVENT_WHEN_EMPTY } override fun onCreateViewHolder(viewGroup: ViewGroup, viewType: Int): RecyclerView.ViewHolder { return when (viewType) { VIEW_TYPE_TAG -> { val binding = ItemTrendingCellBinding.inflate(LayoutInflater.from(viewGroup.context)) TrendingTagViewHolder(binding) } else -> { val binding = ItemTrendingDateBinding.inflate(LayoutInflater.from(viewGroup.context)) TrendingDateViewHolder(binding) } } } override fun onBindViewHolder(viewHolder: RecyclerView.ViewHolder, position: Int) { when (val viewData = getItem(position)) { is TrendingViewData.Tag -> { val holder = viewHolder as TrendingTagViewHolder holder.setup(viewData, onViewTag) } is TrendingViewData.Header -> { val holder = viewHolder as TrendingDateViewHolder holder.setup(viewData.start, viewData.end) } } } override fun getItemViewType(position: Int): Int { return if (getItem(position) is TrendingViewData.Tag) { VIEW_TYPE_TAG } else { VIEW_TYPE_HEADER } } companion object { const val VIEW_TYPE_HEADER = 0 const val VIEW_TYPE_TAG = 1 val TrendingDifferCallback = object : DiffUtil.ItemCallback() { override fun areItemsTheSame( oldItem: TrendingViewData, newItem: TrendingViewData ): Boolean { return oldItem.id == newItem.id } override fun areContentsTheSame( oldItem: TrendingViewData, newItem: TrendingViewData ): Boolean { return oldItem == newItem } } } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/trending/TrendingTagsFragment.kt ================================================ /* 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.trending import android.content.res.Configuration import android.os.Bundle import android.util.Log import android.view.View import android.view.accessibility.AccessibilityManager import androidx.core.content.getSystemService import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.GridLayoutManager.SpanSizeLookup import androidx.recyclerview.widget.LinearLayoutManager 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.R import com.keylesspalace.tusky.StatusListActivity import com.keylesspalace.tusky.components.trending.viewmodel.TrendingTagsViewModel import com.keylesspalace.tusky.databinding.FragmentTrendingTagsBinding import com.keylesspalace.tusky.interfaces.ActionButtonActivity import com.keylesspalace.tusky.interfaces.RefreshableFragment import com.keylesspalace.tusky.interfaces.ReselectableFragment import com.keylesspalace.tusky.util.ensureBottomPadding 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 dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch @AndroidEntryPoint class TrendingTagsFragment : Fragment(R.layout.fragment_trending_tags), OnRefreshListener, ReselectableFragment, RefreshableFragment { private val viewModel: TrendingTagsViewModel by viewModels() private val binding by viewBinding(FragmentTrendingTagsBinding::bind) private var adapter: TrendingTagsAdapter? = null override fun onConfigurationChanged(newConfig: Configuration) { super.onConfigurationChanged(newConfig) val columnCount = requireContext().resources.getInteger(R.integer.trending_column_count) adapter?.let { setupLayoutManager(it, columnCount) } } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { val adapter = TrendingTagsAdapter(::onViewTag) this.adapter = adapter binding.swipeRefreshLayout.setOnRefreshListener(this) setupRecyclerView(adapter) adapter.registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() { override fun onItemRangeInserted(positionStart: Int, itemCount: Int) { val firstPos = (binding.recyclerView.layoutManager as LinearLayoutManager).findFirstCompletelyVisibleItemPosition() if (firstPos == 0 && positionStart == 0 && adapter.itemCount != itemCount) { binding.recyclerView.post { if (getView() != null) { binding.recyclerView.scrollBy( 0, Utils.dpToPx(requireContext(), -30) ) } } } } }) viewLifecycleOwner.lifecycleScope.launch { viewModel.uiState.collectLatest { trendingState -> processViewState(adapter, trendingState) } } } override fun onDestroyView() { // Clear the adapter to prevent leaking the View adapter = null super.onDestroyView() } private fun setupLayoutManager(adapter: TrendingTagsAdapter, columnCount: Int) { binding.recyclerView.layoutManager = GridLayoutManager(context, columnCount).apply { spanSizeLookup = object : SpanSizeLookup() { override fun getSpanSize(position: Int): Int { return when (adapter.getItemViewType(position)) { TrendingTagsAdapter.VIEW_TYPE_HEADER -> columnCount TrendingTagsAdapter.VIEW_TYPE_TAG -> 1 else -> -1 } } } } } private fun setupRecyclerView(adapter: TrendingTagsAdapter) { binding.recyclerView.ensureBottomPadding(fab = actionButtonPresent()) val columnCount = requireContext().resources.getInteger(R.integer.trending_column_count) setupLayoutManager(adapter, columnCount) binding.recyclerView.setHasFixedSize(true) (binding.recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false binding.recyclerView.adapter = adapter } override fun onRefresh() { viewModel.invalidate(true) } fun onViewTag(tag: String) { requireActivity().startActivityWithSlideInAnimation( StatusListActivity.newHashtagIntent(requireContext(), tag) ) } private fun processViewState( adapter: TrendingTagsAdapter, uiState: TrendingTagsViewModel.TrendingTagsUiState ) { Log.d(TAG, uiState.loadingState.name) when (uiState.loadingState) { TrendingTagsViewModel.LoadingState.INITIAL -> clearLoadingState() TrendingTagsViewModel.LoadingState.LOADING -> applyLoadingState() TrendingTagsViewModel.LoadingState.REFRESHING -> applyRefreshingState() TrendingTagsViewModel.LoadingState.LOADED -> applyLoadedState(adapter, uiState.trendingViewData) TrendingTagsViewModel.LoadingState.ERROR_NETWORK -> networkError() TrendingTagsViewModel.LoadingState.ERROR_OTHER -> otherError() } } private fun applyLoadedState(adapter: TrendingTagsAdapter, viewData: List) { clearLoadingState() adapter.submitList(viewData) if (viewData.isEmpty()) { binding.recyclerView.hide() binding.messageView.show() binding.messageView.setup( R.drawable.elephant_friend_empty, R.string.message_empty, null ) } else { binding.recyclerView.show() binding.messageView.hide() } binding.progressBar.hide() } private fun applyRefreshingState() { binding.swipeRefreshLayout.isRefreshing = true } private fun applyLoadingState() { binding.recyclerView.hide() binding.messageView.hide() binding.progressBar.show() } private fun clearLoadingState() { binding.swipeRefreshLayout.isRefreshing = false binding.progressBar.hide() binding.messageView.hide() } private fun networkError() { binding.recyclerView.hide() binding.messageView.show() binding.progressBar.hide() binding.swipeRefreshLayout.isRefreshing = false binding.messageView.setup( R.drawable.errorphant_offline, R.string.error_network ) { refreshContent() } } private fun otherError() { binding.recyclerView.hide() binding.messageView.show() binding.progressBar.hide() binding.swipeRefreshLayout.isRefreshing = false binding.messageView.setup( R.drawable.errorphant_error, R.string.error_generic ) { refreshContent() } } private fun actionButtonPresent(): Boolean { return (activity as? ActionButtonActivity?)?.actionButton != null } private var talkBackWasEnabled = false override fun onResume() { super.onResume() val a11yManager = requireContext().getSystemService() val wasEnabled = talkBackWasEnabled talkBackWasEnabled = a11yManager?.isEnabled == true Log.d(TAG, "talkback was enabled: $wasEnabled, now $talkBackWasEnabled") if (talkBackWasEnabled && !wasEnabled) { val adapter = requireNotNull(this.adapter) adapter.notifyItemRangeChanged(0, adapter.itemCount) } } override fun onReselect() { if (view != null) { binding.recyclerView.layoutManager?.scrollToPosition(0) binding.recyclerView.stopScroll() } } override fun refreshContent() { onRefresh() } companion object { private const val TAG = "TrendingTagsFragment" fun newInstance() = TrendingTagsFragment() } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/trending/viewmodel/TrendingTagsViewModel.kt ================================================ /* 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.trending.viewmodel import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import at.connyduck.calladapter.networkresult.fold import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.PreferenceChangedEvent import com.keylesspalace.tusky.entity.Filter import com.keylesspalace.tusky.entity.end 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 dagger.hilt.android.lifecycle.HiltViewModel 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 @HiltViewModel class TrendingTagsViewModel @Inject constructor( private val mastodonApi: MastodonApi, private val eventHub: EventHub ) : ViewModel() { enum class LoadingState { INITIAL, LOADING, REFRESHING, LOADED, ERROR_NETWORK, ERROR_OTHER } data class TrendingTagsUiState( val trendingViewData: List, val loadingState: LoadingState ) val uiState: Flow get() = _uiState private val _uiState = MutableStateFlow(TrendingTagsUiState(listOf(), LoadingState.INITIAL)) init { invalidate() // Collect PreferenceChangedEvent, FiltersActivity creates them when a filter is created // or deleted. Unfortunately, there's nothing in the event to determine if it's a filter // that was modified, so refresh on every preference change. viewModelScope.launch { eventHub.events .filterIsInstance() .collect { invalidate() } } } /** * Invalidate the current list of trending tags and fetch a new list. * * A tag is excluded if it is filtered by the user on their home timeline. */ fun invalidate(refresh: Boolean = false) = viewModelScope.launch { if (refresh) { _uiState.value = TrendingTagsUiState(emptyList(), LoadingState.REFRESHING) } else { _uiState.value = TrendingTagsUiState(emptyList(), LoadingState.LOADING) } val deferredFilters = async { mastodonApi.getFilters() } mastodonApi.trendingTags().fold( { tagResponse -> 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) } 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 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 = TrendingTagsUiState(emptyList(), LoadingState.ERROR_NETWORK) } else { _uiState.value = TrendingTagsUiState(emptyList(), LoadingState.ERROR_OTHER) } } ) } companion object { private const val TAG = "TrendingViewModel" } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/viewthread/ConversationLineItemDecoration.kt ================================================ /* 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.components.viewthread import android.content.Context import android.graphics.Canvas import android.graphics.drawable.Drawable import android.view.View import androidx.core.content.ContextCompat import androidx.core.view.forEach import androidx.recyclerview.widget.RecyclerView import com.keylesspalace.tusky.R class ConversationLineItemDecoration(context: Context) : RecyclerView.ItemDecoration() { 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 + statusLineMarginStart val dividerEnd = dividerStart + divider.intrinsicWidth val items = (parent.adapter as ThreadAdapter).currentList parent.forEach { statusItemView -> val position = parent.getChildAdapterPosition(statusItemView) items.getOrNull(position)?.let { current -> val above = items.getOrNull(position - 1) val dividerTop = if (above != null && above.id == current.status.inReplyToId) { statusItemView.top } else { statusItemView.top + avatarTopMargin + halfAvatarHeight } val below = items.getOrNull(position + 1) val dividerBottom = if (below != null && current.id == below.status.inReplyToId && !current.isDetailed) { statusItemView.bottom } else { 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.draw(canvas) } } } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/viewthread/ThreadAdapter.kt ================================================ /* 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.components.viewthread import android.view.LayoutInflater import android.view.ViewGroup import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView import com.keylesspalace.tusky.R import com.keylesspalace.tusky.adapter.FilteredStatusViewHolder import com.keylesspalace.tusky.adapter.StatusBaseViewHolder import com.keylesspalace.tusky.adapter.StatusDetailedViewHolder import com.keylesspalace.tusky.adapter.StatusViewHolder import com.keylesspalace.tusky.databinding.ItemStatusFilteredBinding import com.keylesspalace.tusky.entity.Filter import com.keylesspalace.tusky.interfaces.StatusActionListener import com.keylesspalace.tusky.util.StatusDisplayOptions import com.keylesspalace.tusky.viewdata.StatusViewData class ThreadAdapter( private val statusDisplayOptions: StatusDisplayOptions, private val statusActionListener: StatusActionListener ) : ListAdapter(ThreadDifferCallback) { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { val inflater = LayoutInflater.from(parent.context) return when (viewType) { VIEW_TYPE_STATUS -> StatusViewHolder(inflater.inflate(R.layout.item_status, parent, false)) VIEW_TYPE_STATUS_FILTERED -> FilteredStatusViewHolder( ItemStatusFilteredBinding.inflate(inflater, parent, false), statusActionListener ) VIEW_TYPE_STATUS_DETAILED -> StatusDetailedViewHolder( inflater.inflate(R.layout.item_status_detailed, parent, false) ) else -> error("Unknown item type: $viewType") } } override fun onBindViewHolder(viewHolder: RecyclerView.ViewHolder, position: Int) { onBindViewHolder(viewHolder, position, emptyList()) } override fun onBindViewHolder(viewHolder: RecyclerView.ViewHolder, position: Int, payloads: List) { val status = getItem(position) if (viewHolder is FilteredStatusViewHolder) { viewHolder.bind(status) } else if (viewHolder is StatusBaseViewHolder) { viewHolder.setupWithStatus(status, statusActionListener, statusDisplayOptions, payloads, false) } } override fun getItemViewType(position: Int): Int { val viewData = getItem(position) return if (viewData.isDetailed) { VIEW_TYPE_STATUS_DETAILED } else if (viewData.filter?.action == Filter.Action.WARN) { VIEW_TYPE_STATUS_FILTERED } else { VIEW_TYPE_STATUS } } companion object { private const val VIEW_TYPE_STATUS = 0 private const val VIEW_TYPE_STATUS_DETAILED = 1 private const val VIEW_TYPE_STATUS_FILTERED = 2 val ThreadDifferCallback = object : DiffUtil.ItemCallback() { override fun areItemsTheSame( oldItem: StatusViewData.Concrete, newItem: StatusViewData.Concrete ): Boolean { return oldItem.id == newItem.id } override fun areContentsTheSame( oldItem: StatusViewData.Concrete, newItem: StatusViewData.Concrete ): Boolean { return false // Items are different always. It allows to refresh timestamp on every view holder update } override fun getChangePayload( oldItem: StatusViewData.Concrete, newItem: StatusViewData.Concrete ): Any? { return if (oldItem == newItem) { // If items are equal - update timestamp only StatusBaseViewHolder.Key.KEY_CREATED } else { // If items are different - update the whole view holder null } } } } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadActivity.kt ================================================ /* Copyright 2022 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.viewthread import android.content.Context import android.content.Intent import android.os.Bundle import androidx.fragment.app.commit import com.keylesspalace.tusky.BottomSheetActivity import com.keylesspalace.tusky.R import com.keylesspalace.tusky.databinding.ActivityViewThreadBinding import com.keylesspalace.tusky.util.viewBinding import dagger.hilt.android.AndroidEntryPoint @AndroidEntryPoint class ViewThreadActivity : BottomSheetActivity() { private val binding by viewBinding(ActivityViewThreadBinding::inflate) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(binding.root) setSupportActionBar(binding.includedToolbar.toolbar) supportActionBar?.run { setDisplayHomeAsUpEnabled(true) setDisplayShowHomeEnabled(true) setDisplayShowTitleEnabled(true) } setTitle(R.string.title_view_thread) val id = intent.getStringExtra(ID_EXTRA)!! val url = intent.getStringExtra(URL_EXTRA)!! val fragment = supportFragmentManager.findFragmentByTag(FRAGMENT_TAG + id) as ViewThreadFragment? ?: ViewThreadFragment.newInstance(id, url) supportFragmentManager.commit { replace(R.id.fragment_container, fragment, FRAGMENT_TAG + id) } } companion object { fun startIntent(context: Context, id: String, url: String): Intent { val intent = Intent(context, ViewThreadActivity::class.java) intent.putExtra(ID_EXTRA, id) intent.putExtra(URL_EXTRA, url) return intent } private const val ID_EXTRA = "id" private const val URL_EXTRA = "url" private const val FRAGMENT_TAG = "ViewThreadFragment_" } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadFragment.kt ================================================ /* Copyright 2022 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.viewthread import android.content.SharedPreferences import android.os.Bundle import android.util.Log import android.view.Menu import android.view.MenuInflater import android.view.MenuItem import android.view.View import android.widget.LinearLayout import androidx.annotation.CheckResult import androidx.core.view.MenuProvider import androidx.fragment.app.commit import androidx.fragment.app.viewModels import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope 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 at.connyduck.sparkbutton.SparkButton import com.google.android.material.snackbar.Snackbar import com.keylesspalace.tusky.R import com.keylesspalace.tusky.components.accountlist.AccountListActivity import com.keylesspalace.tusky.components.accountlist.AccountListActivity.Companion.newIntent import com.keylesspalace.tusky.components.viewthread.edits.ViewEditsFragment import com.keylesspalace.tusky.databinding.FragmentViewThreadBinding import com.keylesspalace.tusky.db.DraftsAlert import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.fragment.SFragment 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.ensureBottomPadding 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.updateRelativeTimePeriodically import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.view.ConfirmationBottomSheet.Companion.confirmFavourite import com.keylesspalace.tusky.view.ConfirmationBottomSheet.Companion.confirmReblog import com.keylesspalace.tusky.viewdata.AttachmentViewData.Companion.list import com.keylesspalace.tusky.viewdata.StatusViewData import com.keylesspalace.tusky.viewdata.TranslationViewData import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.delay import kotlinx.coroutines.launch @AndroidEntryPoint class ViewThreadFragment : SFragment(R.layout.fragment_view_thread), OnRefreshListener, StatusActionListener, MenuProvider { @Inject lateinit var preferences: SharedPreferences @Inject lateinit var draftsAlert: DraftsAlert private val viewModel: ViewThreadViewModel by viewModels() private val binding by viewBinding(FragmentViewThreadBinding::bind) private var adapter: ThreadAdapter? = null private lateinit var thisThreadsStatusId: String private var alwaysShowSensitiveMedia = false private var alwaysOpenSpoiler = false private var buttonToAnimate: SparkButton? = null /** * State of the "reveal" menu item that shows/hides content that is behind a content * warning. Setting this invalidates the menu to redraw the menu item. */ private var revealButtonState = RevealButtonState.NO_BUTTON set(value) { field = value requireActivity().invalidateMenu() } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) thisThreadsStatusId = requireArguments().getString(ID_EXTRA)!! } private fun createAdapter(): ThreadAdapter { val statusDisplayOptions = StatusDisplayOptions( animateAvatars = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false), mediaPreviewEnabled = accountManager.activeAccount!!.mediaPreviewEnabled, 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 }, 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), showSensitiveMedia = accountManager.activeAccount!!.alwaysShowSensitiveMedia, openSpoiler = accountManager.activeAccount!!.alwaysOpenSpoiler ) return ThreadAdapter(statusDisplayOptions, this) } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED) val adapter = createAdapter() this.adapter = adapter binding.swipeRefreshLayout.setOnRefreshListener(this) binding.recyclerView.ensureBottomPadding() binding.recyclerView.setHasFixedSize(true) binding.recyclerView.layoutManager = LinearLayoutManager(context) binding.recyclerView.setAccessibilityDelegateCompat( ListStatusAccessibilityDelegate( binding.recyclerView, this ) { index -> adapter.currentList.getOrNull(index) } ) val divider = DividerItemDecoration(context, LinearLayout.VERTICAL) binding.recyclerView.addItemDecoration(divider) binding.recyclerView.addItemDecoration(ConversationLineItemDecoration(requireContext())) alwaysShowSensitiveMedia = accountManager.activeAccount!!.alwaysShowSensitiveMedia alwaysOpenSpoiler = accountManager.activeAccount!!.alwaysOpenSpoiler binding.recyclerView.adapter = adapter (binding.recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false var initialProgressBar = getProgressBarJob(binding.initialProgressBar, 500) var threadProgressBar = getProgressBarJob(binding.threadProgressBar, 500) viewLifecycleOwner.lifecycleScope.launch { viewModel.uiState.collect { uiState -> when (uiState) { is ThreadUiState.Loading -> { revealButtonState = RevealButtonState.NO_BUTTON binding.recyclerView.hide() binding.statusView.hide() initialProgressBar = getProgressBarJob(binding.initialProgressBar, 500) initialProgressBar.start() } is ThreadUiState.LoadingThread -> { if (uiState.statusViewDatum == null) { // no detailed statuses available, e.g. because author is blocked activity?.finish() return@collect } initialProgressBar.cancel() threadProgressBar = getProgressBarJob(binding.threadProgressBar, 500) threadProgressBar.start() if (viewModel.isInitialLoad) { adapter.submitList(listOf(uiState.statusViewDatum)) // else this "submit one and then all on success below" will always center on the one } revealButtonState = uiState.revealButton binding.swipeRefreshLayout.isRefreshing = false binding.recyclerView.show() binding.statusView.hide() } is ThreadUiState.Error -> { Log.w(TAG, "failed to load status", uiState.throwable) initialProgressBar.cancel() threadProgressBar.cancel() revealButtonState = RevealButtonState.NO_BUTTON binding.swipeRefreshLayout.isRefreshing = false binding.recyclerView.hide() binding.statusView.show() 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 activity?.finish() return@collect } threadProgressBar.cancel() adapter.submitList(uiState.statusViewData) { if (lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED) && viewModel.isInitialLoad) { viewModel.isInitialLoad = false // Ensure the top of the status is visible (binding.recyclerView.layoutManager as LinearLayoutManager).scrollToPositionWithOffset( uiState.detailedStatusPosition, 0 ) } } revealButtonState = uiState.revealButton binding.swipeRefreshLayout.isRefreshing = false binding.recyclerView.show() binding.statusView.hide() } is ThreadUiState.Refreshing -> { threadProgressBar.cancel() } } } } 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) .setAction(R.string.action_retry) { viewModel.retry(thisThreadsStatusId) } .show() } } updateRelativeTimePeriodically(preferences, adapter) draftsAlert.observeInContext(requireActivity(), true) viewModel.loadThread(thisThreadsStatusId) } override fun onDestroyView() { // Clear the adapter to prevent leaking the View adapter = null buttonToAnimate = null super.onDestroyView() } override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { menuInflater.inflate(R.menu.fragment_view_thread, menu) val actionReveal = menu.findItem(R.id.action_reveal) actionReveal.isVisible = revealButtonState != RevealButtonState.NO_BUTTON actionReveal.setIcon( when (revealButtonState) { RevealButtonState.REVEAL -> R.drawable.ic_visibility_24dp else -> R.drawable.ic_visibility_off_24dp } ) } override fun onMenuItemSelected(menuItem: MenuItem): Boolean { return when (menuItem.itemId) { R.id.action_reveal -> { viewModel.toggleRevealButton() true } R.id.action_open_in_web -> { context?.openLink(requireArguments().getString(URL_EXTRA)!!) true } R.id.action_refresh -> { onRefresh() true } else -> false } } /** * Create a job to implement a delayed-visible progress bar. * * Delaying the visibility of the progress bar can improve user perception of UI speed because * fewer UI elements are appearing and disappearing. * * When started the job will wait `delayMs` then show `view`. If the job is cancelled at * 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() } } override fun onRefresh() { viewModel.refresh(thisThreadsStatusId) } override fun onReply(position: Int) { val viewData = adapter?.currentList?.getOrNull(position) ?: return super.reply(viewData.status) } override fun onReblog(reblog: Boolean, position: Int, visibility: Status.Visibility?, button: SparkButton?) { val status = adapter?.currentList?.getOrNull(position) ?: return buttonToAnimate = button if (reblog && visibility == null) { confirmReblog(preferences) { visibility -> viewModel.reblog(true, status, visibility) buttonToAnimate?.playAnimation() buttonToAnimate?.isChecked = true } } else { viewModel.reblog(reblog, status, visibility ?: Status.Visibility.PUBLIC) if (reblog) { buttonToAnimate?.playAnimation() } buttonToAnimate?.isChecked = false } } 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?.getOrNull(position) ?: return viewLifecycleOwner.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?.getOrNull(position) ?: return viewModel.untranslate(status) } override fun onFavourite(favourite: Boolean, position: Int, button: SparkButton?) { val status = adapter?.currentList?.getOrNull(position) ?: return buttonToAnimate = button if (favourite) { confirmFavourite(preferences) { viewModel.favorite(true, status) buttonToAnimate?.playAnimation() buttonToAnimate?.isChecked = true } } else { viewModel.favorite(false, status) buttonToAnimate?.isChecked = false } } override fun onBookmark(bookmark: Boolean, position: Int) { val status = adapter?.currentList?.getOrNull(position) ?: return viewModel.bookmark(bookmark, status) } override fun onMore(view: View, position: Int) { val viewData = adapter?.currentList?.getOrNull(position) ?: return 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?.getOrNull(position) ?: return super.viewMedia( attachmentIndex, list(status, alwaysShowSensitiveMedia), view ) } override fun onViewThread(position: Int) { val status = adapter?.currentList?.getOrNull(position) ?: return if (thisThreadsStatusId == status.id) { // If already viewing this thread, don't reopen it. return } super.viewThread(status.actionableId, status.actionable.url) } override fun onViewUrl(url: String) { val status: StatusViewData.Concrete? = viewModel.detailedStatus() if (status != null && status.status.url == url) { // already viewing the status with this url // probably just a preview federated and the user is clicking again to view more -> open the browser // this can happen with some friendica statuses requireContext().openLink(url) return } super.onViewUrl(url) } override fun onOpenReblog(position: Int) { // there are no reblogs in threads } override fun onExpandedChange(expanded: Boolean, position: Int) { val status = adapter?.currentList?.getOrNull(position) ?: return viewModel.changeExpanded(expanded, status) } override fun onContentHiddenChange(isShowing: Boolean, position: Int) { val status = adapter?.currentList?.getOrNull(position) ?: return viewModel.changeContentShowing(isShowing, status) } override fun onLoadMore(position: Int) { // only used in timelines } override fun onShowReblogs(position: Int) { val status = adapter?.currentList?.getOrNull(position) ?: return val intent = newIntent(requireContext(), AccountListActivity.Type.REBLOGGED, status.id) requireActivity().startActivityWithSlideInAnimation(intent) } override fun onShowFavs(position: Int) { val status = adapter?.currentList?.getOrNull(position) ?: return val intent = newIntent(requireContext(), AccountListActivity.Type.FAVOURITED, status.id) requireActivity().startActivityWithSlideInAnimation(intent) } override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) { val status = adapter?.currentList?.getOrNull(position) ?: return viewModel.changeContentCollapsed(isCollapsed, status) } override fun onViewTag(tag: String) { super.viewTag(tag) } override fun onViewAccount(id: String) { super.viewAccount(id) } public override fun removeItem(position: Int) { adapter?.currentList?.getOrNull(position)?.let { status -> if (status.isDetailed) { // the main status we are viewing is being removed, finish the activity activity?.finish() return } viewModel.removeStatus(status) } } override fun onVoteInPoll(position: Int, choices: List) { val status = adapter?.currentList?.getOrNull(position) ?: return viewModel.voteInPoll(choices, status) } override fun onShowPollResults(position: Int) { adapter?.currentList?.getOrNull(position)?.let { status -> viewModel.showPollResults(status) } } override fun onShowEdits(position: Int) { val status = adapter?.currentList?.getOrNull(position) ?: return val viewEditsFragment = ViewEditsFragment.newInstance(status.actionableId) parentFragmentManager.commit { 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) } } override fun clearWarningAction(position: Int) { val status = adapter?.currentList?.getOrNull(position) ?: return viewModel.clearWarning(status) } companion object { private const val TAG = "ViewThreadFragment" private const val ID_EXTRA = "id" private const val URL_EXTRA = "url" fun newInstance(id: String, url: String): ViewThreadFragment { val arguments = Bundle(2) val fragment = ViewThreadFragment() arguments.putString(ID_EXTRA, id) arguments.putString(URL_EXTRA, url) fragment.arguments = arguments return fragment } } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadViewModel.kt ================================================ /* Copyright 2022 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.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 at.connyduck.calladapter.networkresult.map import at.connyduck.calladapter.networkresult.onFailure import at.connyduck.calladapter.networkresult.onSuccess import com.keylesspalace.tusky.appstore.BlockEvent import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.StatusChangedEvent import com.keylesspalace.tusky.appstore.StatusComposedEvent import com.keylesspalace.tusky.appstore.StatusDeletedEvent import com.keylesspalace.tusky.components.timeline.toStatus import com.keylesspalace.tusky.components.timeline.util.ifExpected import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AppDatabase import com.keylesspalace.tusky.entity.Filter 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.toViewData import com.keylesspalace.tusky.viewdata.StatusViewData import com.keylesspalace.tusky.viewdata.TranslationViewData import dagger.hilt.android.lifecycle.HiltViewModel 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 @HiltViewModel class ViewThreadViewModel @Inject constructor( private val api: MastodonApi, private val filterModel: FilterModel, private val timelineCases: TimelineCases, private val db: AppDatabase, eventHub: EventHub, accountManager: AccountManager, ) : ViewModel() { private val activeAccount = accountManager.activeAccount!! 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: SharedFlow = _errors.asSharedFlow() var isInitialLoad: Boolean = true private val alwaysShowSensitiveMedia: Boolean = activeAccount.alwaysShowSensitiveMedia private val alwaysOpenSpoiler: Boolean = activeAccount.alwaysOpenSpoiler init { viewModelScope.launch { eventHub.events .collect { event -> when (event) { is StatusChangedEvent -> handleStatusChangedEvent(event.status) is BlockEvent -> removeAllByAccountId(event.accountId) is StatusComposedEvent -> handleStatusComposedEvent(event) is StatusDeletedEvent -> handleStatusDeletedEvent(event) } } } } fun loadThread(id: String) { _uiState.value = ThreadUiState.Loading viewModelScope.launch { Log.d(TAG, "Finding status with: $id") val filterCall = async { filterModel.init(Filter.Kind.THREAD) } val contextCall = async { api.statusContext(id) } val statusAndAccount = db.timelineStatusDao().getStatusWithAccount(activeAccount.id, id) var detailedStatus = if (statusAndAccount != null) { Log.d(TAG, "Loaded status from local timeline") StatusViewData.Concrete( status = statusAndAccount.first.toStatus(statusAndAccount.second), isExpanded = statusAndAccount.first.expanded, isShowingContent = statusAndAccount.first.contentShowing, isCollapsed = statusAndAccount.first.contentCollapsed, isDetailed = true, // NOTE repliedToAccount is null here: this avoids showing "in reply to" over every post translation = null ) } else { Log.d(TAG, "Loaded status from network") val result = api.status(id).getOrElse { exception -> _uiState.value = ThreadUiState.Error(exception) return@launch } result.toViewData(isDetailed = true) } _uiState.value = ThreadUiState.LoadingThread( statusViewDatum = detailedStatus, revealButton = detailedStatus.getRevealButtonState() ) // If the detailedStatus was loaded from the database it might be out-of-date // compared to the remote one. Now the user has a working UI do a background fetch // for the status. Ignore errors, the user still has a functioning UI if the fetch // failed. Update the database when the fetch was successful. if (statusAndAccount != null) { api.status(id).onSuccess { result -> db.timelineStatusDao().update(tuskyAccountId = activeAccount.id, status = result) detailedStatus = result.toViewData(isDetailed = true) } } filterCall.await() // make sure FilterModel is initialized before using it 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 statuses = ancestors + detailedStatus + descendants _uiState.value = ThreadUiState.Success( statusViewData = statuses, detailedStatusPosition = ancestors.size, revealButton = statuses.getRevealButtonState() ) }, { throwable -> _errors.emit(throwable) _uiState.value = ThreadUiState.Success( statusViewData = listOf(detailedStatus), detailedStatusPosition = 0, revealButton = RevealButtonState.NO_BUTTON ) }) } } fun retry(id: String) { _uiState.value = ThreadUiState.Loading loadThread(id) } fun refresh(id: String) { _uiState.value = ThreadUiState.Refreshing loadThread(id) } fun detailedStatus(): StatusViewData.Concrete? { return when (val uiState = _uiState.value) { is ThreadUiState.Success -> uiState.statusViewData.find { status -> status.isDetailed } is ThreadUiState.LoadingThread -> uiState.statusViewDatum else -> null } } fun reblog(reblog: Boolean, status: StatusViewData.Concrete, visibility: Status.Visibility = Status.Visibility.PUBLIC): Job = viewModelScope.launch { try { timelineCases.reblog(status.actionableId, reblog, visibility).getOrThrow() } catch (t: Exception) { ifExpected(t) { Log.d(TAG, "Failed to reblog status " + status.actionableId, t) } } } fun favorite(favorite: Boolean, status: StatusViewData.Concrete): Job = viewModelScope.launch { try { timelineCases.favourite(status.actionableId, favorite).getOrThrow() } catch (t: Exception) { ifExpected(t) { Log.d(TAG, "Failed to favourite status " + status.actionableId, t) } } } fun bookmark(bookmark: Boolean, status: StatusViewData.Concrete): Job = viewModelScope.launch { try { timelineCases.bookmark(status.actionableId, bookmark).getOrThrow() } catch (t: Exception) { ifExpected(t) { Log.d(TAG, "Failed to bookmark status " + status.actionableId, t) } } } 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) } 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 showPollResults(status: StatusViewData.Concrete) = viewModelScope.launch { updateStatus(status.id) { it.copy(poll = it.poll?.copy(voted = true)) } } fun removeStatus(statusToRemove: StatusViewData.Concrete) { updateSuccess { uiState -> uiState.copy( statusViewData = uiState.statusViewData.filterNot { status -> status == statusToRemove } ) } } fun changeExpanded(expanded: Boolean, status: StatusViewData.Concrete) { updateSuccess { uiState -> val statuses = uiState.statusViewData.map { viewData -> if (viewData.id == status.id) { viewData.copy(isExpanded = expanded) } else { viewData } } uiState.copy( statusViewData = statuses, revealButton = statuses.getRevealButtonState() ) } } fun changeContentShowing(isShowing: Boolean, status: StatusViewData.Concrete) { updateStatusViewData(status.id) { viewData -> viewData.copy(isShowingContent = isShowing) } } fun changeContentCollapsed(isCollapsed: Boolean, status: StatusViewData.Concrete) { updateStatusViewData(status.id) { viewData -> viewData.copy(isCollapsed = isCollapsed) } } 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 handleStatusChangedEvent(status: Status) { updateStatusViewData(status.id) { viewData -> status.toViewData( isShowingContent = viewData.isShowingContent, isExpanded = viewData.isExpanded, isCollapsed = viewData.isCollapsed, isDetailed = viewData.isDetailed, translation = viewData.translation, filter = viewData.filter, ) } } private fun removeAllByAccountId(accountId: String) { updateSuccess { uiState -> uiState.copy( statusViewData = uiState.statusViewData.filter { viewData -> viewData.status.account.id != accountId } ) } } private fun handleStatusComposedEvent(event: StatusComposedEvent) { val eventStatus = event.status updateSuccess { uiState -> val statuses = uiState.statusViewData val detailedIndex = statuses.indexOfFirst { status -> status.isDetailed } 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) + eventStatus.toViewData() + statuses.subList(repliedIndex + 1, statuses.size) uiState.copy(statusViewData = newStatuses) } else { uiState } } } private fun handleStatusDeletedEvent(event: StatusDeletedEvent) { updateSuccess { uiState -> uiState.copy( statusViewData = uiState.statusViewData.filter { status -> status.id != event.statusId } ) } } fun toggleRevealButton() { updateSuccess { uiState -> when (uiState.revealButton) { RevealButtonState.HIDE -> uiState.copy( statusViewData = uiState.statusViewData.map { viewData -> viewData.copy(isExpanded = false) }, revealButton = RevealButtonState.REVEAL ) RevealButtonState.REVEAL -> uiState.copy( statusViewData = uiState.statusViewData.map { viewData -> viewData.copy(isExpanded = true) }, revealButton = RevealButtonState.HIDE ) else -> uiState } } } private fun StatusViewData.Concrete.getRevealButtonState(): RevealButtonState { val hasWarnings = status.spoilerText.isNotEmpty() return if (hasWarnings) { if (isExpanded) { RevealButtonState.HIDE } else { RevealButtonState.REVEAL } } else { RevealButtonState.NO_BUTTON } } /** * Get the reveal button state based on the state of all the statuses in the list. * * - If any status sets it to REVEAL, use REVEAL * - If no status sets it to REVEAL, but at least one uses HIDE, use HIDE * - Otherwise use NO_BUTTON */ private fun List.getRevealButtonState(): RevealButtonState { var seenHide = false forEach { when (val state = it.getRevealButtonState()) { RevealButtonState.NO_BUTTON -> return@forEach RevealButtonState.REVEAL -> return state RevealButtonState.HIDE -> seenHide = true } } if (seenHide) { return RevealButtonState.HIDE } return RevealButtonState.NO_BUTTON } private fun List.filter(): List { return filter { status -> if (status.isDetailed || status.status.account.id == activeAccount.accountId) { true } else { status.filter = filterModel.shouldFilterStatus(status.status) status.filter?.action != Filter.Action.HIDE } } } 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 ?: actionableStatus.shouldShowContent(alwaysShowSensitiveMedia, Filter.Kind.THREAD), isExpanded = oldStatus?.isExpanded ?: alwaysOpenSpoiler, isCollapsed = oldStatus?.isCollapsed ?: !isDetailed, isDetailed = oldStatus?.isDetailed ?: isDetailed, filter = oldStatus?.filter ?: actionableStatus.getApplicableFilter(Filter.Kind.THREAD), ) } private inline fun updateSuccess(updater: (ThreadUiState.Success) -> ThreadUiState.Success) { _uiState.update { uiState -> if (uiState is ThreadUiState.Success) { updater(uiState) } else { uiState } } } private fun updateStatusViewData( statusId: String, updater: (StatusViewData.Concrete) -> StatusViewData.Concrete ) { updateSuccess { uiState -> uiState.copy( statusViewData = uiState.statusViewData.map { viewData -> if (viewData.id == statusId) { updater(viewData) } else { viewData } } ) } } private fun updateStatus(statusId: String, updater: (Status) -> Status) { updateStatusViewData(statusId) { viewData -> viewData.copy( status = updater(viewData.status) ) } } fun clearWarning(viewData: StatusViewData.Concrete) { updateStatus(viewData.id) { status -> status.copy(filtered = emptyList()) } } companion object { private const val TAG = "ViewThreadViewModel" } } sealed interface ThreadUiState { /** The initial load of the detailed status for this thread */ data object Loading : ThreadUiState /** Loading the detailed status has completed, now loading ancestors/descendants */ data class LoadingThread( val statusViewDatum: StatusViewData.Concrete?, val revealButton: RevealButtonState ) : ThreadUiState /** An error occurred at any point */ class Error(val throwable: Throwable) : ThreadUiState /** Successfully loaded the full thread */ data class Success( val statusViewData: List, val revealButton: RevealButtonState, val detailedStatusPosition: Int ) : ThreadUiState /** Refreshing the thread with a swipe */ data object Refreshing : ThreadUiState } enum class RevealButtonState { NO_BUTTON, REVEAL, HIDE } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/viewthread/edits/ViewEditsAdapter.kt ================================================ package com.keylesspalace.tusky.components.viewthread.edits import android.content.Context import android.graphics.Typeface.DEFAULT_BOLD import android.graphics.drawable.Drawable import android.text.Editable import android.text.SpannableStringBuilder import android.text.TextPaint import android.text.style.CharacterStyle import android.util.TypedValue import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.core.graphics.drawable.toDrawable import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.bumptech.glide.Glide import com.google.android.material.color.MaterialColors import com.keylesspalace.tusky.R import com.keylesspalace.tusky.adapter.PollAdapter import com.keylesspalace.tusky.adapter.PollAdapter.Companion.MULTIPLE import com.keylesspalace.tusky.adapter.PollAdapter.Companion.SINGLE import com.keylesspalace.tusky.databinding.ItemStatusEditBinding import com.keylesspalace.tusky.entity.Attachment.Focus 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.BlurhashDrawable import com.keylesspalace.tusky.util.TuskyTagHandler import com.keylesspalace.tusky.util.aspectRatios import com.keylesspalace.tusky.util.emojify import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.parseAsMastodonHtml import com.keylesspalace.tusky.util.setClickableText import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.visible import com.keylesspalace.tusky.viewdata.toViewData import org.xml.sax.XMLReader class ViewEditsAdapter( private val edits: List, private val animateEmojis: Boolean, private val useBlurhash: Boolean, private val listener: LinkListener ) : RecyclerView.Adapter>() { private val absoluteTimeFormatter = AbsoluteTimeFormatter() /** Size of large text in this theme, in px */ private var largeTextSizePx: Float = 0f /** Size of medium text in this theme, in px */ private var mediumTextSizePx: Float = 0f override fun onCreateViewHolder( parent: ViewGroup, viewType: Int ): BindingHolder { val binding = ItemStatusEditBinding.inflate( LayoutInflater.from(parent.context), parent, false ) binding.statusEditMediaPreview.clipToOutline = true val typedValue = TypedValue() val context = binding.root.context val displayMetrics = context.resources.displayMetrics context.theme.resolveAttribute(R.attr.status_text_large, typedValue, true) largeTextSizePx = typedValue.getDimension(displayMetrics) context.theme.resolveAttribute(R.attr.status_text_medium, typedValue, true) mediumTextSizePx = typedValue.getDimension(displayMetrics) return BindingHolder(binding) } override fun onBindViewHolder(holder: BindingHolder, position: Int) { val edit = edits[position] val binding = holder.binding val context = binding.root.context val infoStringRes = if (position == edits.lastIndex) { R.string.status_created_info } else { R.string.status_edit_info } // Show the most recent version of the status using large text to make it clearer for // the user, and for similarity with thread view. val variableTextSize = if (position == edits.lastIndex) { mediumTextSizePx } else { largeTextSizePx } binding.statusEditContentWarningDescription.setTextSize( TypedValue.COMPLEX_UNIT_PX, variableTextSize ) binding.statusEditContent.setTextSize(TypedValue.COMPLEX_UNIT_PX, variableTextSize) binding.statusEditMediaSensitivity.setTextSize(TypedValue.COMPLEX_UNIT_PX, variableTextSize) val timestamp = absoluteTimeFormatter.format(edit.createdAt, false) binding.statusEditInfo.text = context.getString(infoStringRes, timestamp) if (edit.spoilerText.isEmpty()) { binding.statusEditContentWarningDescription.hide() binding.statusEditContentWarningSeparator.hide() } else { binding.statusEditContentWarningDescription.show() binding.statusEditContentWarningSeparator.show() binding.statusEditContentWarningDescription.text = edit.spoilerText.emojify( edit.emojis, binding.statusEditContentWarningDescription, animateEmojis ) } val emojifiedText = edit .content .parseAsMastodonHtml(EditsTagHandler(context)) .emojify(edit.emojis, binding.statusEditContent, animateEmojis) setClickableText( binding.statusEditContent, emojifiedText, emptyList(), emptyList(), listener, ) if (edit.poll == null) { binding.statusEditPollOptions.hide() binding.statusEditPollDescription.hide() } else { binding.statusEditPollOptions.show() // not used for now since not reported by the api // https://github.com/mastodon/mastodon/issues/22571 // binding.statusEditPollDescription.show() val pollAdapter = PollAdapter() binding.statusEditPollOptions.adapter = pollAdapter binding.statusEditPollOptions.layoutManager = LinearLayoutManager(context) pollAdapter.setup( options = edit.poll.options.map { it.toViewData(false) }, voteCount = 0, votersCount = null, emojis = edit.emojis, mode = if (edit.poll.multiple) { // not reported by the api MULTIPLE } else { SINGLE }, resultClickListener = null, animateEmojis = animateEmojis, enabled = false ) } if (edit.mediaAttachments.isEmpty()) { binding.statusEditMediaPreview.hide() binding.statusEditMediaSensitivity.hide() } else { binding.statusEditMediaPreview.show() binding.statusEditMediaPreview.aspectRatios = edit.mediaAttachments.aspectRatios() binding.statusEditMediaPreview.forEachIndexed { index, imageView, descriptionIndicator -> val attachment = edit.mediaAttachments[index] val hasDescription = !attachment.description.isNullOrBlank() if (hasDescription) { imageView.contentDescription = attachment.description } else { imageView.contentDescription = imageView.context.getString(R.string.action_view_media) } descriptionIndicator.visibility = if (hasDescription) View.VISIBLE else View.GONE val blurhash = attachment.blurhash val placeholder: Drawable = if (blurhash != null && useBlurhash) { BlurhashDrawable(context, blurhash) } else { MaterialColors.getColor(imageView, R.attr.colorBackgroundAccent).toDrawable() } if (attachment.previewUrl.isNullOrEmpty()) { imageView.removeFocalPoint() Glide.with(imageView) .load(placeholder) .centerInside() .into(imageView) } else { val focus: Focus? = attachment.meta?.focus if (focus != null) { imageView.setFocalPoint(focus) Glide.with(imageView.context) .load(attachment.previewUrl) .placeholder(placeholder) .centerInside() .addListener(imageView) .into(imageView) } else { imageView.removeFocalPoint() Glide.with(imageView) .load(attachment.previewUrl) .placeholder(placeholder) .centerInside() .into(imageView) } } } binding.statusEditMediaSensitivity.visible(edit.sensitive) } } override fun getItemCount() = edits.size companion object { private const val VIEW_TYPE_EDITS_NEWEST = 0 private const val VIEW_TYPE_EDITS = 1 } } /** * Handle XML tags created by [ViewEditsViewModel] and create custom spans to display inserted or * deleted text. */ class EditsTagHandler(val context: Context) : TuskyTagHandler() { /** Class to mark the start of a span of deleted text */ class Del /** Class to mark the start of a span of inserted text */ class Ins override fun handleTag(opening: Boolean, tag: String, output: Editable, xmlReader: XMLReader) { when (tag) { DELETED_TEXT_EL -> { if (opening) { start(output as SpannableStringBuilder, Del()) } else { end( output as SpannableStringBuilder, Del::class.java, DeletedTextSpan(context) ) } } INSERTED_TEXT_EL -> { if (opening) { start(output as SpannableStringBuilder, Ins()) } else { end( output as SpannableStringBuilder, Ins::class.java, InsertedTextSpan(context) ) } } else -> super.handleTag(opening, tag, output, xmlReader) } } /** Span that signifies deleted text */ class DeletedTextSpan(context: Context) : CharacterStyle() { private var bgColor: Int init { bgColor = context.getColor(R.color.view_edits_background_delete) } override fun updateDrawState(tp: TextPaint) { tp.bgColor = bgColor tp.isStrikeThruText = true } } /** Span that signifies inserted text */ class InsertedTextSpan(context: Context) : CharacterStyle() { private var bgColor: Int init { bgColor = context.getColor(R.color.view_edits_background_insert) } override fun updateDrawState(tp: TextPaint) { tp.bgColor = bgColor tp.typeface = DEFAULT_BOLD } } companion object { /** XML element to represent text that has been deleted */ // Can't be an element that Android's HTML parser recognises, otherwise the tagHandler // won't be called for it. const val DELETED_TEXT_EL = "tusky-del" /** 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" } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/viewthread/edits/ViewEditsFragment.kt ================================================ /* Copyright 2022 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.viewthread.edits import android.content.SharedPreferences import android.os.Bundle import android.util.Log import android.view.Menu import android.view.MenuInflater import android.view.MenuItem import android.view.View import android.widget.LinearLayout import androidx.core.view.MenuProvider import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.SimpleItemAnimator import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener import com.keylesspalace.tusky.BottomSheetActivity import com.keylesspalace.tusky.R import com.keylesspalace.tusky.StatusListActivity import com.keylesspalace.tusky.components.account.AccountActivity import com.keylesspalace.tusky.databinding.FragmentViewEditsBinding import com.keylesspalace.tusky.interfaces.LinkListener import com.keylesspalace.tusky.settings.PrefKeys 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 dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject import kotlinx.coroutines.launch @AndroidEntryPoint class ViewEditsFragment : Fragment(R.layout.fragment_view_edits), LinkListener, OnRefreshListener, MenuProvider { @Inject lateinit var preferences: SharedPreferences private val viewModel: ViewEditsViewModel by viewModels() private val binding by viewBinding(FragmentViewEditsBinding::bind) private lateinit var statusId: String override fun onViewCreated(view: View, savedInstanceState: Bundle?) { requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED) binding.swipeRefreshLayout.setOnRefreshListener(this) binding.recyclerView.setHasFixedSize(true) binding.recyclerView.layoutManager = LinearLayoutManager(context) val divider = DividerItemDecoration(context, LinearLayout.VERTICAL) binding.recyclerView.addItemDecoration(divider) (binding.recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false statusId = requireArguments().getString(STATUS_ID_EXTRA)!! 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 ) viewLifecycleOwner.lifecycleScope.launch { viewModel.uiState.collect { uiState -> when (uiState) { EditsUiState.Initial -> {} EditsUiState.Loading -> { binding.recyclerView.hide() binding.statusView.hide() binding.initialProgressBar.show() } EditsUiState.Refreshing -> {} is EditsUiState.Error -> { Log.w(TAG, "failed to load edits", uiState.throwable) binding.swipeRefreshLayout.isRefreshing = false binding.recyclerView.hide() binding.statusView.show() binding.initialProgressBar.hide() when (uiState.throwable) { is ViewEditsViewModel.MissingEditsException -> { binding.statusView.setup( R.drawable.elephant_friend_empty, R.string.error_missing_edits ) } else -> { binding.statusView.setup(uiState.throwable) { viewModel.loadEdits(statusId, force = true) } } } } is EditsUiState.Success -> { binding.swipeRefreshLayout.isRefreshing = false binding.recyclerView.show() binding.statusView.hide() binding.initialProgressBar.hide() binding.recyclerView.adapter = ViewEditsAdapter( edits = uiState.edits, animateEmojis = animateEmojis, useBlurhash = useBlurhash, listener = this@ViewEditsFragment ) // Focus on the most recent version (binding.recyclerView.layoutManager as LinearLayoutManager).scrollToPosition( 0 ) val account = uiState.edits.first().account loadAvatar( account.avatar, binding.statusAvatar, avatarRadius, animateAvatars ) binding.statusDisplayName.text = account.name.unicodeWrap().emojify(account.emojis, binding.statusDisplayName, animateEmojis) binding.statusUsername.text = account.username } } } } viewModel.loadEdits(statusId) } override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { menuInflater.inflate(R.menu.fragment_view_edits, menu) } override fun onMenuItemSelected(menuItem: MenuItem): Boolean { return when (menuItem.itemId) { R.id.action_refresh -> { binding.swipeRefreshLayout.isRefreshing = true onRefresh() true } else -> false } } override fun onResume() { super.onResume() requireActivity().title = getString(R.string.title_edits) } override fun onRefresh() { viewModel.loadEdits(statusId, force = true, refreshing = true) } override fun onViewAccount(id: String) { bottomSheetActivity?.startActivityWithSlideInAnimation( AccountActivity.getIntent(requireContext(), id) ) } override fun onViewTag(tag: String) { bottomSheetActivity?.startActivityWithSlideInAnimation( StatusListActivity.newHashtagIntent(requireContext(), tag) ) } override fun onViewUrl(url: String) { bottomSheetActivity?.viewUrl(url) } private val bottomSheetActivity get() = (activity as? BottomSheetActivity) companion object { private const val TAG = "ViewEditsFragment" private const val STATUS_ID_EXTRA = "id" fun newInstance(statusId: String): ViewEditsFragment { val arguments = Bundle(1) val fragment = ViewEditsFragment() arguments.putString(STATUS_ID_EXTRA, statusId) fragment.arguments = arguments return fragment } } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/components/viewthread/edits/ViewEditsViewModel.kt ================================================ /* Copyright 2022 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.viewthread.edits import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import at.connyduck.calladapter.networkresult.getOrElse 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 dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import org.pageseeder.diffx.api.LoadingException import org.pageseeder.diffx.api.Operator import org.pageseeder.diffx.config.DiffConfig import org.pageseeder.diffx.config.TextGranularity import org.pageseeder.diffx.config.WhiteSpaceProcessing import org.pageseeder.diffx.core.OptimisticXMLProcessor import org.pageseeder.diffx.format.XMLDiffOutput import org.pageseeder.diffx.load.SAXLoader import org.pageseeder.diffx.token.XMLToken import org.pageseeder.diffx.token.XMLTokenType import org.pageseeder.diffx.token.impl.SpaceToken import org.pageseeder.diffx.xml.NamespaceSet import org.pageseeder.xmlwriter.XML.NamespaceAware import org.pageseeder.xmlwriter.XMLStringWriter @HiltViewModel class ViewEditsViewModel @Inject constructor(private val api: MastodonApi) : ViewModel() { 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 */ class MissingEditsException : Exception() fun loadEdits(statusId: String, force: Boolean = false, refreshing: Boolean = false) { if (!force && _uiState.value !is EditsUiState.Initial) return if (refreshing) { _uiState.value = EditsUiState.Refreshing } else { _uiState.value = EditsUiState.Loading } viewModelScope.launch { val edits = api.statusEdits(statusId).getOrElse { _uiState.value = EditsUiState.Error(it) return@launch } // `edits` might have fewer than the minimum number of entries because of // https://github.com/mastodon/mastodon/issues/25398. if (edits.size < 2) { _uiState.value = EditsUiState.Error(MissingEditsException()) return@launch } // Diff each status' content against the previous version, producing new // content with additional `ins` or `del` elements marking inserted or // deleted content. // // This can be CPU intensive depending on the number of edits and the size // of each, so don't run this on Dispatchers.Main. viewModelScope.launch(Dispatchers.Default) { val sortedEdits = edits.sortedBy { it.createdAt } .reversed() .toMutableList() SAXLoader.setXMLReaderClass("org.xmlpull.v1.sax2.Driver") val loader = SAXLoader() loader.config = DiffConfig( false, WhiteSpaceProcessing.PRESERVE, TextGranularity.SPACE_WORD ) val processor = OptimisticXMLProcessor() processor.setCoalesce(true) val output = HtmlDiffOutput() try { // The XML processor expects `br` to be closed var currentContent = loader.load(sortedEdits[0].content.replace("
", "
")) var previousContent = loader.load(sortedEdits[1].content.replace("
", "
")) for (i in 1 until sortedEdits.size) { processor.diff(previousContent, currentContent, output) sortedEdits[i - 1] = sortedEdits[i - 1].copy( content = output.xml.toString() ) if (i < sortedEdits.size - 1) { currentContent = previousContent previousContent = loader.load( sortedEdits[i + 1].content.replace("
", "
") ) } } _uiState.value = EditsUiState.Success(sortedEdits) } catch (_: LoadingException) { // Something failed parsing the XML from the server. Rather than // show an error just return the sorted edits so the user can at // least visually scan the differences. _uiState.value = EditsUiState.Success(sortedEdits) } } } } companion object { const val TAG = "ViewEditsViewModel" } } sealed interface 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. data object Refreshing : EditsUiState class Error(val throwable: Throwable) : EditsUiState data class Success( val edits: List ) : EditsUiState } /** * Add elements wrapping inserted or deleted content. */ class HtmlDiffOutput : XMLDiffOutput { /** XML Output */ lateinit var xml: XMLStringWriter private set override fun start() { xml = XMLStringWriter(NamespaceAware.Yes) } override fun handle(operator: Operator, token: XMLToken) { if (operator.isEdit) { handleEdit(operator, token) } else { token.toXML(xml) } } override fun end() { xml.flush() } override fun setWriteXMLDeclaration(show: Boolean) { // This space intentionally left blank } override fun setNamespaces(namespaces: NamespaceSet?) { // This space intentionally left blank } private fun handleEdit(operator: Operator, token: XMLToken) { if (token == SpaceToken.NEW_LINE) { if (operator == Operator.INS) { token.toXML(xml) } return } when (token.type) { XMLTokenType.START_ELEMENT -> token.toXML(xml) XMLTokenType.END_ELEMENT -> token.toXML(xml) XMLTokenType.TEXT -> { // wrap the characters in a element when (operator) { Operator.DEL -> DELETED_TEXT_EL Operator.INS -> INSERTED_TEXT_EL else -> null }?.let { xml.openElement(it, false) } token.toXML(xml) xml.closeElement() } else -> { // Only include inserted content if (operator === Operator.INS) { token.toXML(xml) } } } } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/db/AccountManager.kt ================================================ /* Copyright 2018 Conny Duck * * 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.SharedPreferences import android.util.Log import androidx.room.withTransaction import com.keylesspalace.tusky.db.dao.AccountDao import com.keylesspalace.tusky.db.entity.AccountEntity import com.keylesspalace.tusky.di.ApplicationScope import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.settings.PrefKeys import java.util.Locale import javax.inject.Inject import javax.inject.Singleton import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.runBlocking /** * This class is the main interface to all account related operations. */ private const val TAG = "AccountManager" @Singleton class AccountManager @Inject constructor( private val db: AppDatabase, private val preferences: SharedPreferences, @ApplicationScope private val applicationScope: CoroutineScope ) { private val accountDao: AccountDao = db.accountDao() /** A StateFlow that will update everytime an account in the database changes, is added or removed. * The first account is the currently active one. */ val accountsFlow: StateFlow> = runBlocking { accountDao.allAccounts() .stateIn(CoroutineScope(applicationScope.coroutineContext + Dispatchers.IO)) } /** A snapshot of all accounts in the database with the active account first */ val accounts: List get() = accountsFlow.value /** A snapshot currently active account, if there is one */ val activeAccount: AccountEntity? get() = accounts.firstOrNull() /** Returns a StateFlow for updates to the currently active account. * Note that always the same account will be emitted, * even if it is no longer active and that it will emit null when the account got removed. * @param scope the [CoroutineScope] this flow will be active in. */ fun activeAccount(scope: CoroutineScope): StateFlow { val activeAccount = activeAccount return accountsFlow.map { accounts -> accounts.find { account -> activeAccount?.id == account.id } }.stateIn(scope, SharingStarted.Lazily, activeAccount) } /** * Adds a new account and makes it the active account. * @param accessToken the access token for the new account * @param domain the domain of the account's Mastodon instance * @param clientId the oauth client id used to sign in the account * @param clientSecret the oauth client secret used to sign in the account * @param oauthScopes the oauth scopes granted to the account * @param newAccount the [Account] as returned by the Mastodon Api */ suspend fun addAccount( accessToken: String, domain: String, clientId: String, clientSecret: String, oauthScopes: String, newAccount: Account ) = db.withTransaction { activeAccount?.let { Log.d(TAG, "addAccount: saving account with id " + it.id) accountDao.insertOrReplace(it.copy(isActive = false)) } // check if this is a relogin with an existing account, if yes update it, otherwise create a new one val existingAccount = accounts.find { account -> domain == account.domain && newAccount.id == account.accountId } val newAccountEntity = if (existingAccount != null) { existingAccount.copy( accessToken = accessToken, clientId = clientId, clientSecret = clientSecret, oauthScopes = oauthScopes, isActive = true ) } else { val maxAccountId = accounts.maxOfOrNull { it.id } ?: 0 val newAccountId = maxAccountId + 1 AccountEntity( id = newAccountId, domain = domain.lowercase(Locale.ROOT), accessToken = accessToken, clientId = clientId, clientSecret = clientSecret, oauthScopes = oauthScopes, isActive = true, accountId = newAccount.id ) } updateAccount(newAccountEntity, newAccount) } /** * Saves an already known account to the database. * New accounts must be created with [addAccount] * @param account The account to save * @param changer make the changes to save here - this is to make sure no stale data gets re-saved to the database */ suspend fun updateAccount(account: AccountEntity, changer: AccountEntity.() -> AccountEntity) { accounts.find { it.id == account.id }?.let { acc -> Log.d(TAG, "updateAccount: saving account with id " + acc.id) accountDao.insertOrReplace(changer(acc)) } } /** * Updates an account with new information from the Mastodon api * and saves it in the database. * @param accountEntity the [AccountEntity] to update * @param account the [Account] object which the newest data from the api */ suspend fun updateAccount(accountEntity: AccountEntity, account: Account) { // make sure no stale data gets re-saved to the database val accountToUpdate = accounts.find { it.id == accountEntity.id } ?: accountEntity val newAccount = accountToUpdate.copy( accountId = account.id, username = account.username, displayName = account.name, profilePictureUrl = account.avatar, profileHeaderUrl = account.header, defaultPostPrivacy = account.source?.privacy ?: Status.Visibility.PUBLIC, defaultPostLanguage = account.source?.language.orEmpty(), defaultMediaSensitivity = account.source?.sensitive == true, emojis = account.emojis, locked = account.locked ) Log.d(TAG, "updateAccount: saving account with id " + accountToUpdate.id) accountDao.insertOrReplace(newAccount) } /** * Removes an account from the database. * @return the new active account, or null if no other account was found */ suspend fun remove(account: AccountEntity): AccountEntity? = db.withTransaction { Log.d(TAG, "remove: deleting account with id " + account.id) accountDao.delete(account) accounts.find { it.id != account.id }?.let { otherAccount -> val otherAccountActive = otherAccount.copy( isActive = true ) Log.d(TAG, "remove: saving account with id " + otherAccountActive.id) accountDao.insertOrReplace(otherAccountActive) otherAccountActive } } /** * Changes the active account * @param accountId the database id of the new active account */ suspend fun setActiveAccount(accountId: Long) = db.withTransaction { Log.d(TAG, "setActiveAccount $accountId") val newActiveAccount = accounts.find { (id) -> id == accountId } ?: return@withTransaction // invalid accountId passed, do nothing activeAccount?.let { accountDao.insertOrReplace(it.copy(isActive = false)) } accountDao.insertOrReplace(newActiveAccount.copy(isActive = true)) } /** * @return true if at least one account has notifications enabled */ fun areNotificationsEnabled(): Boolean { return accounts.any { it.notificationsEnabled } } /** * Finds an account by its database id * @param accountId the id of the account * @return the requested account or null if it was not found */ fun getAccountById(accountId: Long): AccountEntity? { return accounts.find { (id) -> id == accountId } } /** * Finds an account by its string identifier * @param identifier the string identifier of the account * @return the requested account or null if it was not found */ fun getAccountByIdentifier(identifier: String): AccountEntity? { return accounts.find { identifier == it.identifier } } /** * @return true if the name of the currently-selected account should be displayed in UIs */ fun shouldDisplaySelfUsername(): Boolean { val showUsernamePreference = preferences.getString( PrefKeys.SHOW_SELF_USERNAME, "disambiguate" ) if (showUsernamePreference == "always") { return true } if (showUsernamePreference == "never") { return false } return accounts.size > 1 // "disambiguate" } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java ================================================ /* 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.db; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.room.AutoMigration; import androidx.room.Database; import androidx.room.DeleteColumn; import androidx.room.RoomDatabase; import androidx.room.migration.AutoMigrationSpec; import androidx.room.migration.Migration; import androidx.sqlite.db.SupportSQLiteDatabase; import com.keylesspalace.tusky.TabDataKt; import com.keylesspalace.tusky.components.conversation.ConversationEntity; import com.keylesspalace.tusky.db.dao.AccountDao; import com.keylesspalace.tusky.db.dao.DraftDao; import com.keylesspalace.tusky.db.dao.InstanceDao; import com.keylesspalace.tusky.db.dao.NotificationPolicyDao; import com.keylesspalace.tusky.db.dao.NotificationsDao; import com.keylesspalace.tusky.db.dao.TimelineAccountDao; import com.keylesspalace.tusky.db.dao.TimelineDao; import com.keylesspalace.tusky.db.dao.TimelineStatusDao; import com.keylesspalace.tusky.db.entity.AccountEntity; import com.keylesspalace.tusky.db.entity.DraftEntity; import com.keylesspalace.tusky.db.entity.HomeTimelineEntity; import com.keylesspalace.tusky.db.entity.InstanceEntity; import com.keylesspalace.tusky.db.entity.NotificationEntity; import com.keylesspalace.tusky.db.entity.NotificationPolicyEntity; import com.keylesspalace.tusky.db.entity.NotificationReportEntity; import com.keylesspalace.tusky.db.entity.TimelineAccountEntity; import com.keylesspalace.tusky.db.entity.TimelineStatusEntity; import java.io.File; /** * DB version & declare DAO */ @Database( entities = { DraftEntity.class, AccountEntity.class, InstanceEntity.class, TimelineStatusEntity.class, TimelineAccountEntity.class, ConversationEntity.class, NotificationEntity.class, NotificationReportEntity.class, HomeTimelineEntity.class, NotificationPolicyEntity.class }, // Note: Starting with version 54, database versions in Tusky are always even. // This is to reserve odd version numbers for use by forks. version = 70, autoMigrations = { @AutoMigration(from = 48, to = 49), @AutoMigration(from = 49, to = 50, spec = AppDatabase.MIGRATION_49_50.class), @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 @AutoMigration(from = 62, to = 64), // filterV2Available in InstanceEntity @AutoMigration(from = 64, to = 66), // added profileHeaderUrl to AccountEntity @AutoMigration(from = 66, to = 68, spec = AppDatabase.MIGRATION_66_68.class), // added event and moderationAction to NotificationEntity, new NotificationPolicyEntity @AutoMigration(from = 68, to = 70), // added mastodonApiVersion to InstanceEntity } ) public abstract class AppDatabase extends RoomDatabase { @NonNull public abstract AccountDao accountDao(); @NonNull public abstract InstanceDao instanceDao(); @NonNull public abstract ConversationsDao conversationDao(); @NonNull public abstract TimelineDao timelineDao(); @NonNull public abstract DraftDao draftDao(); @NonNull public abstract NotificationsDao notificationsDao(); @NonNull public abstract TimelineStatusDao timelineStatusDao(); @NonNull public abstract TimelineAccountDao timelineAccountDao(); @NonNull public abstract NotificationPolicyDao notificationPolicyDao(); public static final Migration MIGRATION_2_3 = new Migration(2, 3) { @Override public void migrate(@NonNull SupportSQLiteDatabase database) { database.execSQL("CREATE TABLE TootEntity2 (uid INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, text TEXT, urls TEXT, contentWarning TEXT);"); database.execSQL("INSERT INTO TootEntity2 SELECT * FROM TootEntity;"); database.execSQL("DROP TABLE TootEntity;"); database.execSQL("ALTER TABLE TootEntity2 RENAME TO TootEntity;"); } }; public static final Migration MIGRATION_3_4 = new Migration(3, 4) { @Override public void migrate(@NonNull SupportSQLiteDatabase database) { database.execSQL("ALTER TABLE TootEntity ADD COLUMN inReplyToId TEXT"); database.execSQL("ALTER TABLE TootEntity ADD COLUMN inReplyToText TEXT"); database.execSQL("ALTER TABLE TootEntity ADD COLUMN inReplyToUsername TEXT"); database.execSQL("ALTER TABLE TootEntity ADD COLUMN visibility INTEGER"); } }; public static final Migration MIGRATION_4_5 = new Migration(4, 5) { @Override public void migrate(@NonNull SupportSQLiteDatabase database) { database.execSQL("CREATE TABLE `AccountEntity` (" + "`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " + "`domain` TEXT NOT NULL, `accessToken` TEXT NOT NULL, " + "`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, " + "`notificationsReblogged` INTEGER NOT NULL, " + "`notificationsFavorited` INTEGER NOT NULL, " + "`notificationSound` INTEGER NOT NULL, " + "`notificationVibration` INTEGER NOT NULL, " + "`notificationLight` INTEGER NOT NULL, " + "`lastNotificationId` TEXT NOT NULL, " + "`activeNotifications` TEXT NOT NULL)"); database.execSQL("CREATE UNIQUE INDEX `index_AccountEntity_domain_accountId` ON `AccountEntity` (`domain`, `accountId`)"); } }; public static final Migration MIGRATION_5_6 = new Migration(5, 6) { @Override public void migrate(@NonNull SupportSQLiteDatabase database) { database.execSQL("CREATE TABLE IF NOT EXISTS `EmojiListEntity` (`instance` TEXT NOT NULL, `emojiList` TEXT NOT NULL, PRIMARY KEY(`instance`))"); } }; public static final Migration MIGRATION_6_7 = new Migration(6, 7) { @Override public void migrate(@NonNull SupportSQLiteDatabase database) { database.execSQL("CREATE TABLE IF NOT EXISTS `InstanceEntity` (`instance` TEXT NOT NULL, `emojiList` TEXT, `maximumTootCharacters` INTEGER, PRIMARY KEY(`instance`))"); database.execSQL("INSERT OR REPLACE INTO `InstanceEntity` SELECT `instance`,`emojiList`, NULL FROM `EmojiListEntity`;"); database.execSQL("DROP TABLE `EmojiListEntity`;"); } }; public static final Migration MIGRATION_7_8 = new Migration(7, 8) { @Override public void migrate(@NonNull SupportSQLiteDatabase database) { database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `emojis` TEXT NOT NULL DEFAULT '[]'"); } }; public static final Migration MIGRATION_8_9 = new Migration(8, 9) { @Override public void migrate(@NonNull SupportSQLiteDatabase database) { database.execSQL("ALTER TABLE `TootEntity` ADD COLUMN `descriptions` TEXT DEFAULT '[]'"); } }; public static final Migration MIGRATION_9_10 = new Migration(9, 10) { @Override public void migrate(@NonNull SupportSQLiteDatabase database) { database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `defaultPostPrivacy` INTEGER NOT NULL DEFAULT 1"); database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `defaultMediaSensitivity` INTEGER NOT NULL DEFAULT 0"); database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `alwaysShowSensitiveMedia` INTEGER NOT NULL DEFAULT 0"); database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `mediaPreviewEnabled` INTEGER NOT NULL DEFAULT '1'"); } }; public static final Migration MIGRATION_10_11 = new Migration(10, 11) { @Override public void migrate(@NonNull SupportSQLiteDatabase database) { database.execSQL("CREATE TABLE IF NOT EXISTS `TimelineAccountEntity` (" + "`serverId` TEXT NOT NULL, " + "`timelineUserId` INTEGER NOT NULL, " + "`instance` TEXT 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," + "PRIMARY KEY(`serverId`, `timelineUserId`))"); database.execSQL("CREATE TABLE IF NOT EXISTS `TimelineStatusEntity` (" + "`serverId` TEXT NOT NULL, " + "`url` TEXT, " + "`timelineUserId` INTEGER NOT NULL, " + "`authorServerId` TEXT," + "`instance` TEXT, " + "`inReplyToId` TEXT, " + "`inReplyToAccountId` TEXT, " + "`content` TEXT, " + "`createdAt` INTEGER NOT NULL, " + "`emojis` TEXT, " + "`reblogsCount` INTEGER NOT NULL, " + "`favouritesCount` INTEGER NOT NULL, " + "`reblogged` INTEGER NOT NULL, " + "`favourited` INTEGER NOT NULL, " + "`sensitive` INTEGER NOT NULL, " + "`spoilerText` TEXT, " + "`visibility` INTEGER, " + "`attachments` TEXT, " + "`mentions` TEXT, " + "`application` TEXT, " + "`reblogServerId` TEXT, " + "`reblogAccountId` TEXT," + " PRIMARY KEY(`serverId`, `timelineUserId`)," + " FOREIGN KEY(`authorServerId`, `timelineUserId`) REFERENCES `TimelineAccountEntity`(`serverId`, `timelineUserId`) " + "ON UPDATE NO ACTION ON DELETE NO ACTION )"); database.execSQL("CREATE INDEX IF NOT EXISTS" + "`index_TimelineStatusEntity_authorServerId_timelineUserId` " + "ON `TimelineStatusEntity` (`authorServerId`, `timelineUserId`)"); } }; public static final Migration MIGRATION_11_12 = new Migration(11, 12) { @Override public void migrate(@NonNull SupportSQLiteDatabase database) { String defaultTabs = TabDataKt.HOME + ";" + TabDataKt.NOTIFICATIONS + ";" + TabDataKt.LOCAL + ";" + TabDataKt.FEDERATED; database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `tabPreferences` TEXT NOT NULL DEFAULT '" + defaultTabs + "'"); database.execSQL("CREATE TABLE IF NOT EXISTS `ConversationEntity` (" + "`accountId` INTEGER NOT NULL, " + "`id` TEXT 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_emojis` TEXT NOT NULL, " + "`s_favouritesCount` INTEGER NOT NULL, " + "`s_favourited` INTEGER NOT NULL, " + "`s_sensitive` INTEGER NOT NULL, " + "`s_spoilerText` TEXT NOT NULL, " + "`s_attachments` TEXT NOT NULL, " + "`s_mentions` TEXT NOT NULL, " + "`s_showingHiddenContent` INTEGER NOT NULL, " + "`s_expanded` INTEGER NOT NULL, " + "`s_collapsible` INTEGER NOT NULL, " + "`s_collapsed` INTEGER NOT NULL, " + "PRIMARY KEY(`id`, `accountId`))"); } }; public static final Migration MIGRATION_12_13 = new Migration(12, 13) { @Override public void migrate(@NonNull SupportSQLiteDatabase database) { database.execSQL("DROP TABLE IF EXISTS `TimelineAccountEntity`"); database.execSQL("DROP TABLE IF EXISTS `TimelineStatusEntity`"); database.execSQL("CREATE TABLE IF NOT EXISTS `TimelineAccountEntity` (" + "`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," + "PRIMARY KEY(`serverId`, `timelineUserId`))"); database.execSQL("CREATE TABLE IF NOT EXISTS `TimelineStatusEntity` (" + "`serverId` TEXT NOT NULL, " + "`url` TEXT, " + "`timelineUserId` INTEGER NOT NULL, " + "`authorServerId` TEXT," + "`inReplyToId` TEXT, " + "`inReplyToAccountId` TEXT, " + "`content` TEXT, " + "`createdAt` INTEGER NOT NULL, " + "`emojis` TEXT, " + "`reblogsCount` INTEGER NOT NULL, " + "`favouritesCount` INTEGER NOT NULL, " + "`reblogged` INTEGER NOT NULL, " + "`favourited` INTEGER NOT NULL, " + "`sensitive` INTEGER NOT NULL, " + "`spoilerText` TEXT, " + "`visibility` INTEGER, " + "`attachments` TEXT, " + "`mentions` TEXT, " + "`application` TEXT, " + "`reblogServerId` TEXT, " + "`reblogAccountId` TEXT," + " PRIMARY KEY(`serverId`, `timelineUserId`)," + " FOREIGN KEY(`authorServerId`, `timelineUserId`) REFERENCES `TimelineAccountEntity`(`serverId`, `timelineUserId`) " + "ON UPDATE NO ACTION ON DELETE NO ACTION )"); database.execSQL("CREATE INDEX IF NOT EXISTS" + "`index_TimelineStatusEntity_authorServerId_timelineUserId` " + "ON `TimelineStatusEntity` (`authorServerId`, `timelineUserId`)"); } }; public static final Migration MIGRATION_10_13 = new Migration(10, 13) { @Override public void migrate(@NonNull SupportSQLiteDatabase database) { MIGRATION_11_12.migrate(database); MIGRATION_12_13.migrate(database); } }; public static final Migration MIGRATION_13_14 = new Migration(13, 14) { @Override public void migrate(@NonNull SupportSQLiteDatabase database) { database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `notificationsFilter` TEXT NOT NULL DEFAULT '[]'"); } }; public static final Migration MIGRATION_14_15 = new Migration(14, 15) { @Override public void migrate(@NonNull SupportSQLiteDatabase database) { database.execSQL("ALTER TABLE `TimelineStatusEntity` ADD COLUMN `poll` TEXT"); database.execSQL("ALTER TABLE `ConversationEntity` ADD COLUMN `s_poll` TEXT"); } }; public static final Migration MIGRATION_15_16 = new Migration(15, 16) { @Override public void migrate(@NonNull SupportSQLiteDatabase database) { database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `notificationsPolls` INTEGER NOT NULL DEFAULT 1"); } }; public static final Migration MIGRATION_16_17 = new Migration(16, 17) { @Override public void migrate(@NonNull SupportSQLiteDatabase database) { database.execSQL("ALTER TABLE `TimelineAccountEntity` ADD COLUMN `bot` INTEGER NOT NULL DEFAULT 0"); } }; public static final Migration MIGRATION_17_18 = new Migration(17, 18) { @Override public void migrate(@NonNull SupportSQLiteDatabase database) { database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `alwaysOpenSpoiler` INTEGER NOT NULL DEFAULT 0"); } }; public static final Migration MIGRATION_18_19 = new Migration(18, 19) { @Override public void migrate(@NonNull SupportSQLiteDatabase database) { database.execSQL("ALTER TABLE `InstanceEntity` ADD COLUMN `maxPollOptions` INTEGER"); database.execSQL("ALTER TABLE `InstanceEntity` ADD COLUMN `maxPollOptionLength` INTEGER"); database.execSQL("ALTER TABLE `TootEntity` ADD COLUMN `poll` TEXT"); } }; public static final Migration MIGRATION_19_20 = new Migration(19, 20) { @Override public void migrate(@NonNull SupportSQLiteDatabase database) { database.execSQL("ALTER TABLE `TimelineStatusEntity` ADD COLUMN `bookmarked` INTEGER NOT NULL DEFAULT 0"); database.execSQL("ALTER TABLE `ConversationEntity` ADD COLUMN `s_bookmarked` INTEGER NOT NULL DEFAULT 0"); } }; public static final Migration MIGRATION_20_21 = new Migration(20, 21) { @Override public void migrate(@NonNull SupportSQLiteDatabase database) { database.execSQL("ALTER TABLE `InstanceEntity` ADD COLUMN `version` TEXT"); } }; public static final Migration MIGRATION_21_22 = new Migration(21, 22) { @Override public void migrate(@NonNull SupportSQLiteDatabase database) { database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `notificationsFollowRequested` INTEGER NOT NULL DEFAULT 0"); } }; public static final Migration MIGRATION_22_23 = new Migration(22, 23) { @Override public void migrate(@NonNull SupportSQLiteDatabase database) { database.execSQL("ALTER TABLE `TimelineStatusEntity` ADD COLUMN `muted` INTEGER"); } }; public static final Migration MIGRATION_23_24 = new Migration(23, 24) { @Override public void migrate(@NonNull SupportSQLiteDatabase database) { database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `notificationsSubscriptions` INTEGER NOT NULL DEFAULT 1"); } }; public static final Migration MIGRATION_24_25 = new Migration(24, 25) { @Override public void migrate(@NonNull SupportSQLiteDatabase database) { database.execSQL( "CREATE TABLE IF NOT EXISTS `DraftEntity` (" + "`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)" ); } }; public static class Migration25_26 extends Migration { private final File oldDraftDirectory; public Migration25_26(@Nullable File oldDraftDirectory) { super(25, 26); this.oldDraftDirectory = oldDraftDirectory; } @Override public void migrate(@NonNull SupportSQLiteDatabase database) { database.execSQL("DROP TABLE `TootEntity`"); if (oldDraftDirectory != null && oldDraftDirectory.isDirectory()) { File[] oldDraftFiles = oldDraftDirectory.listFiles(); if (oldDraftFiles != null) { for (File file : oldDraftFiles) { if (!file.isDirectory()) { file.delete(); } } } } } } public static final Migration MIGRATION_26_27 = new Migration(26, 27) { @Override public void migrate(@NonNull SupportSQLiteDatabase database) { database.execSQL("ALTER TABLE `ConversationEntity` ADD COLUMN `s_muted` INTEGER NOT NULL DEFAULT 0"); } }; public static final Migration MIGRATION_27_28 = new Migration(27, 28) { @Override public void migrate(@NonNull SupportSQLiteDatabase database) { database.execSQL("DROP TABLE IF EXISTS `TimelineAccountEntity`"); database.execSQL("DROP TABLE IF EXISTS `TimelineStatusEntity`"); database.execSQL("CREATE TABLE IF NOT EXISTS `TimelineAccountEntity` (" + "`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`) )"); database.execSQL("CREATE TABLE IF NOT EXISTS `TimelineStatusEntity` (" + "`serverId` TEXT NOT NULL," + "`url` TEXT," + "`timelineUserId` INTEGER NOT NULL," + "`authorServerId` TEXT," + "`inReplyToId` TEXT," + "`inReplyToAccountId` TEXT," + "`content` TEXT," + "`createdAt` INTEGER NOT NULL," + "`emojis` TEXT," + "`reblogsCount` INTEGER NOT NULL," + "`favouritesCount` 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," + "`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," + "PRIMARY KEY(`serverId`, `timelineUserId`)," + "FOREIGN KEY(`authorServerId`, `timelineUserId`) REFERENCES `TimelineAccountEntity`(`serverId`, `timelineUserId`)" + "ON UPDATE NO ACTION ON DELETE NO ACTION )"); database.execSQL("CREATE INDEX IF NOT EXISTS `index_TimelineStatusEntity_authorServerId_timelineUserId`" + "ON `TimelineStatusEntity` (`authorServerId`, `timelineUserId`)"); } }; public static final Migration MIGRATION_28_29 = new Migration(28, 29) { @Override public void migrate(@NonNull SupportSQLiteDatabase database) { database.execSQL("ALTER TABLE `ConversationEntity` ADD COLUMN `s_tags` TEXT"); database.execSQL("ALTER TABLE `TimelineStatusEntity` ADD COLUMN `tags` TEXT"); } }; public static final Migration MIGRATION_29_30 = new Migration(29, 30) { @Override public void migrate(@NonNull SupportSQLiteDatabase database) { database.execSQL("ALTER TABLE `InstanceEntity` ADD COLUMN `charactersReservedPerUrl` INTEGER"); database.execSQL("ALTER TABLE `InstanceEntity` ADD COLUMN `minPollDuration` INTEGER"); database.execSQL("ALTER TABLE `InstanceEntity` ADD COLUMN `maxPollDuration` INTEGER"); } }; public static final Migration MIGRATION_30_31 = new Migration(30, 31) { @Override public void migrate(@NonNull SupportSQLiteDatabase database) { // no actual scheme change, but placeholder ids are now used differently so the cache needs to be cleared to avoid bugs database.execSQL("DELETE FROM `TimelineAccountEntity`"); database.execSQL("DELETE FROM `TimelineStatusEntity`"); } }; public static final Migration MIGRATION_31_32 = new Migration(31, 32) { @Override public void migrate(@NonNull SupportSQLiteDatabase database) { database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `notificationsSignUps` INTEGER NOT NULL DEFAULT 1"); } }; public static final Migration MIGRATION_32_33 = new Migration(32, 33) { @Override public void migrate(@NonNull SupportSQLiteDatabase database) { // ConversationEntity lost the s_collapsible column // since SQLite does not support removing columns and it is just a cache table, we recreate the whole table. database.execSQL("DROP TABLE `ConversationEntity`"); database.execSQL("CREATE TABLE IF NOT EXISTS `ConversationEntity` (" + "`accountId` INTEGER NOT NULL," + "`id` TEXT 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_emojis` TEXT NOT NULL," + "`s_favouritesCount` 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," + "PRIMARY KEY(`id`, `accountId`))"); } }; public static final Migration MIGRATION_33_34 = new Migration(33, 34) { @Override public void migrate(@NonNull SupportSQLiteDatabase database) { database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `notificationsUpdates` INTEGER NOT NULL DEFAULT 1"); } }; public static final Migration MIGRATION_34_35 = new Migration(34, 35) { @Override public void migrate(@NonNull SupportSQLiteDatabase database) { database.execSQL("ALTER TABLE `TimelineStatusEntity` ADD COLUMN `card` TEXT"); } }; public static final Migration MIGRATION_35_36 = new Migration(35, 36) { @Override public void migrate(@NonNull SupportSQLiteDatabase database) { database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `oauthScopes` TEXT NOT NULL DEFAULT ''"); database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `unifiedPushUrl` TEXT NOT NULL DEFAULT ''"); database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `pushPubKey` TEXT NOT NULL DEFAULT ''"); database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `pushPrivKey` TEXT NOT NULL DEFAULT ''"); database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `pushAuth` TEXT NOT NULL DEFAULT ''"); database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `pushServerKey` TEXT NOT NULL DEFAULT ''"); } }; public static final Migration MIGRATION_36_37 = new Migration(36, 37) { @Override public void migrate(@NonNull SupportSQLiteDatabase database) { database.execSQL("ALTER TABLE `TimelineStatusEntity` ADD COLUMN `repliesCount` INTEGER NOT NULL DEFAULT 0"); database.execSQL("ALTER TABLE `ConversationEntity` ADD COLUMN `s_repliesCount` INTEGER NOT NULL DEFAULT 0"); } }; public static final Migration MIGRATION_37_38 = new Migration(37, 38) { @Override public void migrate(@NonNull SupportSQLiteDatabase database) { // database needs to be cleaned because the ConversationAccountEntity got a new attribute database.execSQL("DELETE FROM `ConversationEntity`"); database.execSQL("ALTER TABLE `ConversationEntity` ADD COLUMN `order` INTEGER NOT NULL DEFAULT 0"); // timestamps are now serialized differently so all cache tables that contain them need to be cleaned database.execSQL("DELETE FROM `TimelineStatusEntity`"); } }; public static final Migration MIGRATION_38_39 = new Migration(38, 39) { @Override public void migrate(@NonNull SupportSQLiteDatabase database) { database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `clientId` TEXT"); database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `clientSecret` TEXT"); } }; public static final Migration MIGRATION_39_40 = new Migration(39, 40) { @Override public void migrate(@NonNull SupportSQLiteDatabase database) { database.execSQL("ALTER TABLE `InstanceEntity` ADD COLUMN `videoSizeLimit` INTEGER"); database.execSQL("ALTER TABLE `InstanceEntity` ADD COLUMN `imageSizeLimit` INTEGER"); database.execSQL("ALTER TABLE `InstanceEntity` ADD COLUMN `imageMatrixLimit` INTEGER"); database.execSQL("ALTER TABLE `InstanceEntity` ADD COLUMN `maxMediaAttachments` INTEGER"); database.execSQL("ALTER TABLE `InstanceEntity` ADD COLUMN `maxFields` INTEGER"); database.execSQL("ALTER TABLE `InstanceEntity` ADD COLUMN `maxFieldNameLength` INTEGER"); database.execSQL("ALTER TABLE `InstanceEntity` ADD COLUMN `maxFieldValueLength` INTEGER"); } }; public static final Migration MIGRATION_40_41 = new Migration(40, 41) { @Override public void migrate(@NonNull SupportSQLiteDatabase database) { database.execSQL("ALTER TABLE `DraftEntity` ADD COLUMN `scheduledAt` TEXT"); } }; public static final Migration MIGRATION_41_42 = new Migration(41, 42) { @Override public void migrate(@NonNull SupportSQLiteDatabase database) { database.execSQL("ALTER TABLE `DraftEntity` ADD COLUMN `language` TEXT"); database.execSQL("ALTER TABLE `TimelineStatusEntity` ADD COLUMN `language` TEXT"); database.execSQL("ALTER TABLE `ConversationEntity` ADD COLUMN `s_language` TEXT"); } }; public static final Migration MIGRATION_42_43 = new Migration(42, 43) { @Override public void migrate(@NonNull SupportSQLiteDatabase database) { database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `defaultPostLanguage` TEXT NOT NULL DEFAULT ''"); } }; public static final Migration MIGRATION_43_44 = new Migration(43, 44) { @Override public void migrate(@NonNull SupportSQLiteDatabase database) { database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `notificationsReports` INTEGER NOT NULL DEFAULT 1"); } }; public static final Migration MIGRATION_44_45 = new Migration(44, 45) { @Override public void migrate(@NonNull SupportSQLiteDatabase database) { database.execSQL("ALTER TABLE `TimelineStatusEntity` ADD COLUMN `editedAt` INTEGER"); database.execSQL("ALTER TABLE `ConversationEntity` ADD COLUMN `s_editedAt` INTEGER"); } }; public static final Migration MIGRATION_45_46 = new Migration(45, 46) { @Override public void migrate(@NonNull SupportSQLiteDatabase database) { database.execSQL("ALTER TABLE `DraftEntity` ADD COLUMN `statusId` TEXT"); } }; public static final Migration MIGRATION_46_47 = new Migration(46, 47) { @Override public void migrate(@NonNull SupportSQLiteDatabase database) { database.execSQL("ALTER TABLE `DraftEntity` ADD COLUMN `failedToSendNew` INTEGER NOT NULL DEFAULT 0"); } }; public static final Migration MIGRATION_47_48 = new Migration(47, 48) { @Override public void migrate(@NonNull SupportSQLiteDatabase database) { database.execSQL("ALTER TABLE `TimelineStatusEntity` ADD COLUMN `filtered` TEXT"); } }; @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"); } }; public static final Migration MIGRATION_58_60 = new Migration(58, 60) { @Override public void migrate(@NonNull SupportSQLiteDatabase database) { // drop the old tables - they are only caches anyway database.execSQL("DROP TABLE `TimelineStatusEntity`"); database.execSQL("DROP TABLE `TimelineAccountEntity`"); // create the new tables database.execSQL(""" CREATE TABLE IF NOT EXISTS `TimelineAccountEntity` ( `serverId` TEXT NOT NULL, `tuskyAccountId` 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`, `tuskyAccountId`) )""" ); database.execSQL(""" CREATE TABLE IF NOT EXISTS `TimelineStatusEntity` ( `serverId` TEXT NOT NULL, `url` TEXT, `tuskyAccountId` INTEGER NOT NULL, `authorServerId` TEXT NOT NULL, `inReplyToId` TEXT, `inReplyToAccountId` TEXT, `content` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `editedAt` INTEGER, `emojis` TEXT NOT NULL, `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 NOT NULL, `mentions` TEXT NOT NULL, `tags` TEXT NOT NULL, `application` TEXT, `poll` TEXT, `muted` INTEGER NOT NULL, `expanded` INTEGER NOT NULL, `contentCollapsed` INTEGER NOT NULL, `contentShowing` INTEGER NOT NULL, `pinned` INTEGER NOT NULL, `card` TEXT, `language` TEXT, `filtered` TEXT NOT NULL, PRIMARY KEY(`serverId`, `tuskyAccountId`), FOREIGN KEY(`authorServerId`, `tuskyAccountId`) REFERENCES `TimelineAccountEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION )""" ); database.execSQL( "CREATE INDEX IF NOT EXISTS `index_TimelineStatusEntity_authorServerId_tuskyAccountId` ON `TimelineStatusEntity` (`authorServerId`, `tuskyAccountId`)" ); database.execSQL(""" CREATE TABLE IF NOT EXISTS `HomeTimelineEntity` ( `tuskyAccountId` INTEGER NOT NULL, `id` TEXT NOT NULL, `statusId` TEXT, `reblogAccountId` TEXT, `loading` INTEGER NOT NULL, PRIMARY KEY(`id`, `tuskyAccountId`), FOREIGN KEY(`statusId`, `tuskyAccountId`) REFERENCES `TimelineStatusEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION, FOREIGN KEY(`reblogAccountId`, `tuskyAccountId`) REFERENCES `TimelineAccountEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION )""" ); database.execSQL( "CREATE INDEX IF NOT EXISTS `index_HomeTimelineEntity_statusId_tuskyAccountId` ON `HomeTimelineEntity` (`statusId`, `tuskyAccountId`)" ); database.execSQL( "CREATE INDEX IF NOT EXISTS `index_HomeTimelineEntity_reblogAccountId_tuskyAccountId` ON `HomeTimelineEntity` (`reblogAccountId`, `tuskyAccountId`)" ); database.execSQL(""" CREATE TABLE IF NOT EXISTS `NotificationReportEntity`( `tuskyAccountId` INTEGER NOT NULL, `serverId` TEXT NOT NULL, `category` TEXT NOT NULL, `statusIds` TEXT, `createdAt` INTEGER NOT NULL, `targetAccountId` TEXT, PRIMARY KEY(`serverId`, `tuskyAccountId`), FOREIGN KEY(`targetAccountId`, `tuskyAccountId`) REFERENCES `TimelineAccountEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION )""" ); database.execSQL( "CREATE INDEX IF NOT EXISTS `index_NotificationReportEntity_targetAccountId_tuskyAccountId` ON `NotificationReportEntity` (`targetAccountId`, `tuskyAccountId`)" ); database.execSQL(""" CREATE TABLE IF NOT EXISTS `NotificationEntity` ( `tuskyAccountId` INTEGER NOT NULL, `type` TEXT, `id` TEXT NOT NULL, `accountId` TEXT, `statusId` TEXT, `reportId` TEXT, `loading` INTEGER NOT NULL, PRIMARY KEY(`id`, `tuskyAccountId`), FOREIGN KEY(`accountId`, `tuskyAccountId`) REFERENCES `TimelineAccountEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION, FOREIGN KEY(`statusId`, `tuskyAccountId`) REFERENCES `TimelineStatusEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION, FOREIGN KEY(`reportId`, `tuskyAccountId`) REFERENCES `NotificationReportEntity`(`serverId`, `tuskyAccountId`) ON UPDATE NO ACTION ON DELETE NO ACTION )""" ); database.execSQL( "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_accountId_tuskyAccountId` ON `NotificationEntity` (`accountId`, `tuskyAccountId`)" ); database.execSQL( "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_statusId_tuskyAccountId` ON `NotificationEntity` (`statusId`, `tuskyAccountId`)" ); database.execSQL( "CREATE INDEX IF NOT EXISTS `index_NotificationEntity_reportId_tuskyAccountId` ON `NotificationEntity` (`reportId`, `tuskyAccountId`)" ); } }; public static final Migration MIGRATION_60_62 = new Migration(60, 62) { @Override public void migrate(@NonNull SupportSQLiteDatabase database) { database.execSQL("ALTER TABLE `AccountEntity` ADD COLUMN `defaultReplyPrivacy` INTEGER NOT NULL DEFAULT 0"); } }; @DeleteColumn(tableName = "AccountEntity", columnName = "notificationsSignUps") @DeleteColumn(tableName = "AccountEntity", columnName = "notificationsReports") static class MIGRATION_66_68 implements AutoMigrationSpec { } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/db/ConversationsDao.kt ================================================ /* Copyright 2018 Conny Duck * * 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 androidx.paging.PagingSource import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import com.keylesspalace.tusky.components.conversation.ConversationEntity @Dao interface ConversationsDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insert(conversations: List) @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insert(conversation: ConversationEntity) @Query("DELETE FROM ConversationEntity WHERE id = :id AND accountId = :accountId") suspend fun delete(id: String, accountId: Long) @Query("SELECT * FROM ConversationEntity WHERE accountId = :accountId ORDER BY `order` ASC") fun conversationsForAccount(accountId: Long): PagingSource @Query("DELETE FROM ConversationEntity WHERE accountId = :accountId") suspend fun deleteForAccount(accountId: Long) } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/db/Converters.kt ================================================ /* Copyright 2018 Conny Duck * * 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 androidx.room.ProvidedTypeConverter import androidx.room.TypeConverter import com.keylesspalace.tusky.TabData import com.keylesspalace.tusky.components.conversation.ConversationAccountEntity import com.keylesspalace.tusky.components.systemnotifications.NotificationChannelData import com.keylesspalace.tusky.createTabDataFromId import com.keylesspalace.tusky.db.entity.DraftAttachment import com.keylesspalace.tusky.entity.AccountWarning import com.keylesspalace.tusky.entity.Attachment 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.Notification import com.keylesspalace.tusky.entity.Poll import com.keylesspalace.tusky.entity.PreviewCard import com.keylesspalace.tusky.entity.RelationshipSeveranceEvent import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.entity.notificationTypeFromString import com.keylesspalace.tusky.settings.DefaultReplyVisibility 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 import kotlin.collections.forEach import org.json.JSONArray @OptIn(ExperimentalStdlibApi::class) @ProvidedTypeConverter @Singleton class Converters @Inject constructor( private val moshi: Moshi ) { @TypeConverter fun jsonToEmojiList(emojiListJson: String?): List { return emojiListJson?.let { moshi.adapter?>().fromJson(it) }.orEmpty() } @TypeConverter fun emojiListToJson(emojiList: List): String { return moshi.adapter>().toJson(emojiList) } @TypeConverter fun visibilityToInt(visibility: Status.Visibility?): Int { return visibility?.int ?: Status.Visibility.UNKNOWN.int } @TypeConverter fun intToVisibility(visibility: Int): Status.Visibility { return Status.Visibility.fromInt(visibility) } @TypeConverter fun defaultReplyVisibilityToInt(visibility: DefaultReplyVisibility?): Int { return visibility?.int ?: DefaultReplyVisibility.MATCH_DEFAULT_POST_VISIBILITY.int } @TypeConverter fun intToDefaultReplyVisibility(visibility: Int): DefaultReplyVisibility { return DefaultReplyVisibility.fromInt(visibility) } @TypeConverter fun stringToTabData(str: String?): List? { return str?.split(";") ?.map { val data = it.split(":") 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") } } } @TypeConverter fun accountToJson(account: ConversationAccountEntity?): String { return moshi.adapter().toJson(account) } @TypeConverter fun jsonToAccount(accountJson: String?): ConversationAccountEntity? { return accountJson?.let { moshi.adapter().fromJson(it) } } @TypeConverter fun accountListToJson(accountList: List): String { return moshi.adapter>().toJson(accountList) } @TypeConverter fun jsonToAccountList(accountListJson: String?): List { return accountListJson?.let { moshi.adapter?>().fromJson(it) }.orEmpty() } @TypeConverter fun attachmentListToJson(attachmentList: List): String { return moshi.adapter>().toJson(attachmentList) } @TypeConverter fun jsonToAttachmentList(attachmentListJson: String?): List { return attachmentListJson?.let { moshi.adapter?>().fromJson(it) }.orEmpty() } @TypeConverter fun mentionListToJson(mentionArray: List): String { return moshi.adapter>().toJson(mentionArray) } @TypeConverter fun jsonToMentionArray(mentionListJson: String?): List { return mentionListJson?.let { moshi.adapter?>().fromJson(it) }.orEmpty() } @TypeConverter fun tagListToJson(tagArray: List?): String { return moshi.adapter?>().toJson(tagArray) } @TypeConverter fun jsonToTagArray(tagListJson: String?): List? { return tagListJson?.let { moshi.adapter?>().fromJson(it) } } @TypeConverter fun dateToLong(date: Date?): Long? { return date?.time } @TypeConverter fun longToDate(date: Long?): Date? { return date?.let { Date(it) } } @TypeConverter fun pollToJson(poll: Poll?): String { return moshi.adapter().toJson(poll) } @TypeConverter fun jsonToPoll(pollJson: String?): Poll? { return pollJson?.let { moshi.adapter().fromJson(it) } } @TypeConverter fun newPollToJson(newPoll: NewPoll?): String { return moshi.adapter().toJson(newPoll) } @TypeConverter fun jsonToNewPoll(newPollJson: String?): NewPoll? { return newPollJson?.let { moshi.adapter().fromJson(it) } } @TypeConverter fun draftAttachmentListToJson(draftAttachments: List): String { return moshi.adapter>().toJson(draftAttachments) } @TypeConverter fun jsonToDraftAttachmentList(draftAttachmentListJson: String?): List { return draftAttachmentListJson?.let { moshi.adapter?>().fromJson(it) }.orEmpty() } @TypeConverter fun filterResultListToJson(filterResults: List?): String { return moshi.adapter?>().toJson(filterResults) } @TypeConverter fun jsonToFilterResultList(filterResultListJson: String?): List? { return filterResultListJson?.let { moshi.adapter?>().fromJson(it) } } @TypeConverter fun cardToJson(card: PreviewCard?): String { return moshi.adapter().toJson(card) } @TypeConverter fun jsonToCard(cardJson: String?): PreviewCard? { return cardJson?.let { moshi.adapter().fromJson(cardJson) } } @TypeConverter fun stringListToJson(list: List?): String? { return moshi.adapter?>().toJson(list) } @TypeConverter fun jsonToStringList(listJson: String?): List? { return listJson?.let { moshi.adapter?>().fromJson(it) } } @TypeConverter fun applicationToJson(application: Status.Application?): String { return moshi.adapter().toJson(application) } @TypeConverter fun jsonToApplication(applicationJson: String?): Status.Application? { return applicationJson?.let { moshi.adapter().fromJson(it) } } @TypeConverter fun notificationChannelDataListToJson(data: Set?): String { val array = JSONArray() data?.forEach { array.put(it.name) } return array.toString() } @TypeConverter fun jsonToNotificationChannelDataList(data: String?): Set { val ret = HashSet() data?.let { val array = JSONArray(data) for (i in 0 until array.length()) { val item = array.getString(i) try { val type = NotificationChannelData.valueOf(item) ret.add(type) } catch (_: IllegalArgumentException) { // ignore, this can happen because we stored individual notification types and not channels before } } } return ret } @TypeConverter fun relationshipSeveranceEventToJson(event: RelationshipSeveranceEvent?): String { return moshi.adapter().toJson(event) } @TypeConverter fun jsonToRelationshipSeveranceEvent(eventJson: String?): RelationshipSeveranceEvent? { return eventJson?.let { moshi.adapter().fromJson(it) } } @TypeConverter fun accountWarningToJson(accountWarning: AccountWarning?): String { return moshi.adapter().toJson(accountWarning) } @TypeConverter fun jsonToAccountWarning(accountWarningJson: String?): AccountWarning? { return accountWarningJson?.let { moshi.adapter().fromJson(it) } } @TypeConverter fun accountWarningToJson(notificationType: Notification.Type): String { return notificationType.name } @TypeConverter fun jsonToNotificationType(notificationTypeJson: String): Notification.Type { return notificationTypeFromString(notificationTypeJson) } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/db/DatabaseCleaner.kt ================================================ /* 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.db import androidx.room.withTransaction import com.keylesspalace.tusky.components.conversation.ConversationEntity import com.keylesspalace.tusky.db.entity.HomeTimelineEntity import com.keylesspalace.tusky.db.entity.NotificationEntity import com.keylesspalace.tusky.db.entity.NotificationPolicyEntity import com.keylesspalace.tusky.db.entity.NotificationReportEntity import com.keylesspalace.tusky.db.entity.TimelineAccountEntity import com.keylesspalace.tusky.db.entity.TimelineStatusEntity import javax.inject.Inject class DatabaseCleaner @Inject constructor( private val db: AppDatabase ) { /** * Cleans the [HomeTimelineEntity], [TimelineStatusEntity], [TimelineAccountEntity], [NotificationEntity] and [NotificationReportEntity] tables from old entries. * Should be regularly run to prevent the database from growing too big. * @param tuskyAccountId id of the account for which to clean tables * @param timelineLimit how many timeline items to keep * @param notificationLimit how many notifications to keep */ suspend fun cleanupOldData( tuskyAccountId: Long, timelineLimit: Int, notificationLimit: Int ) { db.withTransaction { // the order here is important - foreign key constraints must not be violated db.notificationsDao().cleanupNotifications(tuskyAccountId, notificationLimit) db.notificationsDao().cleanupReports(tuskyAccountId) db.timelineDao().cleanupHomeTimeline(tuskyAccountId, timelineLimit) db.timelineStatusDao().cleanupStatuses(tuskyAccountId) db.timelineAccountDao().cleanupAccounts(tuskyAccountId) } } /** * Deletes everything from the [HomeTimelineEntity], [TimelineStatusEntity], [TimelineAccountEntity], [NotificationEntity], * [NotificationReportEntity], [ConversationEntity] and [NotificationPolicyEntity] tables for one user. * Intended to be used when a user logs out. * @param tuskyAccountId id of the account for which to clean tables */ suspend fun cleanupEverything(tuskyAccountId: Long) { db.withTransaction { // the order here is important - foreign key constraints must not be violated db.notificationsDao().removeAllNotifications(tuskyAccountId) db.notificationsDao().removeAllReports(tuskyAccountId) db.timelineDao().removeAllHomeTimelineItems(tuskyAccountId) db.timelineStatusDao().removeAllStatuses(tuskyAccountId) db.timelineAccountDao().removeAllAccounts(tuskyAccountId) db.conversationDao().deleteForAccount(tuskyAccountId) db.notificationPolicyDao().deleteForAccount(tuskyAccountId) } } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/db/DraftsAlert.kt ================================================ /* 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.Lifecycle import androidx.lifecycle.LifecycleCoroutineScope import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.keylesspalace.tusky.R import com.keylesspalace.tusky.components.drafts.DraftsActivity import com.keylesspalace.tusky.db.dao.DraftDao import javax.inject.Inject 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" class DraftsAlert @Inject constructor( db: AppDatabase, private val accountManager: AccountManager ) { // For tracking when a media upload fails in the service private val draftDao: DraftDao = db.draftDao() private var dialog: AlertDialog? = null fun observeInContext(context: T, showAlert: Boolean) where T : Context, T : LifecycleOwner { accountManager.activeAccount?.let { activeAccount -> val coroutineScope = context.lifecycleScope // One activity never sees more then one user id in its lifetime. val activeAccountId = activeAccount.id // 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 { context.repeatOnLifecycle(Lifecycle.State.RESUMED) { val draftsNeedUserAlert = draftDao.draftsNeedUserAlert(activeAccountId) if (showAlert) { draftDao.draftsNeedUserAlert(activeAccountId).collect { count -> Log.d(TAG, "User id $activeAccountId changed: Notification-worthy draft count $count") if (count > 0) { dialog?.cancel() dialog = MaterialAlertDialogBuilder(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) } } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/db/dao/AccountDao.kt ================================================ /* Copyright 2018 Conny Duck * * 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.dao import androidx.room.Dao import androidx.room.Delete import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import com.keylesspalace.tusky.db.entity.AccountEntity import kotlinx.coroutines.flow.Flow @Dao interface AccountDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertOrReplace(account: AccountEntity): Long @Delete suspend fun delete(account: AccountEntity) @Query("SELECT * FROM AccountEntity ORDER BY isActive DESC") fun allAccounts(): Flow> } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/db/dao/DraftDao.kt ================================================ /* Copyright 2020 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.db.dao import androidx.paging.PagingSource import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import com.keylesspalace.tusky.db.entity.DraftEntity import kotlinx.coroutines.flow.Flow @Dao interface DraftDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertOrReplace(draft: DraftEntity) @Query("SELECT * FROM DraftEntity WHERE accountId = :accountId ORDER BY id ASC") fun draftsPagingSource(accountId: Long): PagingSource @Query("SELECT COUNT(*) FROM DraftEntity WHERE accountId = :accountId AND failedToSendNew = 1") fun draftsNeedUserAlert(accountId: Long): Flow @Query( "UPDATE DraftEntity SET failedToSendNew = 0 WHERE accountId = :accountId AND failedToSendNew = 1" ) suspend fun draftsClearNeedUserAlert(accountId: Long) @Query("SELECT * FROM DraftEntity WHERE accountId = :accountId") suspend fun loadDrafts(accountId: Long): List @Query("DELETE FROM DraftEntity WHERE id = :id") suspend fun delete(id: Int) @Query("SELECT * FROM DraftEntity WHERE id = :id") suspend fun find(id: Int): DraftEntity? } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/db/dao/InstanceDao.kt ================================================ /* Copyright 2018 Conny Duck * * 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.dao import androidx.room.Dao import androidx.room.Query import androidx.room.RewriteQueriesToDropUnusedColumns import androidx.room.Upsert import com.keylesspalace.tusky.db.entity.EmojisEntity import com.keylesspalace.tusky.db.entity.InstanceEntity import com.keylesspalace.tusky.db.entity.InstanceInfoEntity @Dao interface InstanceDao { @Upsert(entity = InstanceEntity::class) suspend fun upsert(instance: InstanceInfoEntity) @Upsert(entity = InstanceEntity::class) suspend fun upsert(emojis: EmojisEntity) @RewriteQueriesToDropUnusedColumns @Query("SELECT * FROM InstanceEntity WHERE instance = :instance LIMIT 1") suspend fun getInstanceInfo(instance: String): InstanceInfoEntity? @RewriteQueriesToDropUnusedColumns @Query("SELECT * FROM InstanceEntity WHERE instance = :instance LIMIT 1") suspend fun getEmojiInfo(instance: String): EmojisEntity? @Query("UPDATE InstanceEntity SET filterV2Supported = :filterV2Support WHERE instance = :instance") suspend fun setFilterV2Support(instance: String, filterV2Support: Boolean) @Query("SELECT filterV2Supported FROM InstanceEntity WHERE instance = :instance LIMIT 1") suspend fun getFilterV2Support(instance: String): Boolean } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/db/dao/NotificationPolicyDao.kt ================================================ /* Copyright 2018 Conny Duck * * 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.dao import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy.Companion.REPLACE import androidx.room.Query import com.keylesspalace.tusky.db.entity.NotificationPolicyEntity import kotlinx.coroutines.flow.Flow @Dao interface NotificationPolicyDao { @Query("SELECT * FROM NotificationPolicyEntity WHERE tuskyAccountId = :accountId") fun notificationPolicyForAccount(accountId: Long): Flow @Insert(onConflict = REPLACE) suspend fun update(entity: NotificationPolicyEntity) @Query( "UPDATE NotificationPolicyEntity " + "SET pendingRequestsCount = max(0, pendingRequestsCount - 1)," + "pendingNotificationsCount = max(0, pendingNotificationsCount - :notificationCount) " + "WHERE tuskyAccountId = :accountId" ) suspend fun updateCounts( accountId: Long, notificationCount: Int ) @Query("DELETE FROM NotificationPolicyEntity WHERE tuskyAccountId = :accountId") suspend fun deleteForAccount(accountId: Long) } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/db/dao/NotificationsDao.kt ================================================ /* 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.db.dao import androidx.paging.PagingSource import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy.Companion.REPLACE import androidx.room.Query import com.keylesspalace.tusky.db.entity.NotificationDataEntity import com.keylesspalace.tusky.db.entity.NotificationEntity import com.keylesspalace.tusky.db.entity.NotificationReportEntity @Dao abstract class NotificationsDao { @Insert(onConflict = REPLACE) abstract suspend fun insertNotification(notificationEntity: NotificationEntity): Long @Insert(onConflict = REPLACE) abstract suspend fun insertReport(notificationReportDataEntity: NotificationReportEntity): Long @Query( """ SELECT n.tuskyAccountId, n.type, n.id, n.loading, n.event, n.moderationWarning, a.serverId as 'a_serverId', a.tuskyAccountId as 'a_tuskyAccountId', a.localUsername as 'a_localUsername', a.username as 'a_username', a.displayName as 'a_displayName', a.url as 'a_url', a.avatar as 'a_avatar', a.note as 'a_note', a.emojis as 'a_emojis', a.bot as 'a_bot', s.serverId as 's_serverId', s.url as 's_url', s.tuskyAccountId as 's_tuskyAccountId', s.authorServerId as 's_authorServerId', s.inReplyToId as 's_inReplyToId', s.inReplyToAccountId as 's_inReplyToAccountId', s.content as 's_content', s.createdAt as 's_createdAt', s.editedAt as 's_editedAt', s.emojis as 's_emojis', s.reblogsCount as 's_reblogsCount', s.favouritesCount as 's_favouritesCount', s.repliesCount as 's_repliesCount', s.reblogged as 's_reblogged', s.favourited as 's_favourited', s.bookmarked as 's_bookmarked', s.sensitive as 's_sensitive', s.spoilerText as 's_spoilerText', s.visibility as 's_visibility', s.mentions as 's_mentions', s.tags as 's_tags', s.application as 's_application', s.content as 's_content', s.attachments as 's_attachments', s.poll as 's_poll', s.card as 's_card', s.muted as 's_muted', s.expanded as 's_expanded', s.contentShowing as 's_contentShowing', s.contentCollapsed as 's_contentCollapsed', s.pinned as 's_pinned', s.language as 's_language', s.filtered as 's_filtered', sa.serverId as 'sa_serverId', sa.tuskyAccountId as 'sa_tuskyAccountId', sa.localUsername as 'sa_localUsername', sa.username as 'sa_username', sa.displayName as 'sa_displayName', sa.url as 'sa_url', sa.avatar as 'sa_avatar', sa.note as 'sa_note', sa.emojis as 'sa_emojis', sa.bot as 'sa_bot', r.serverId as 'r_serverId', r.tuskyAccountId as 'r_tuskyAccountId', r.category as 'r_category', r.statusIds as 'r_statusIds', r.createdAt as 'r_createdAt', r.targetAccountId as 'r_targetAccountId', ra.serverId as 'ra_serverId', ra.tuskyAccountId as 'ra_tuskyAccountId', ra.localUsername as 'ra_localUsername', ra.username as 'ra_username', ra.displayName as 'ra_displayName', ra.url as 'ra_url', ra.avatar as 'ra_avatar', ra.note as 'ra_note', ra.emojis as 'ra_emojis', ra.bot as 'ra_bot' FROM NotificationEntity n LEFT JOIN TimelineAccountEntity a ON (n.tuskyAccountId = a.tuskyAccountId AND n.accountId = a.serverId) LEFT JOIN TimelineStatusEntity s ON (n.tuskyAccountId = s.tuskyAccountId AND n.statusId = s.serverId) LEFT JOIN TimelineAccountEntity sa ON (n.tuskyAccountId = sa.tuskyAccountId AND s.authorServerId = sa.serverId) LEFT JOIN NotificationReportEntity r ON (n.tuskyAccountId = r.tuskyAccountId AND n.reportId = r.serverId) LEFT JOIN TimelineAccountEntity ra ON (n.tuskyAccountId = ra.tuskyAccountId AND r.targetAccountId = ra.serverId) WHERE n.tuskyAccountId = :tuskyAccountId ORDER BY LENGTH(n.id) DESC, n.id DESC""" ) abstract fun getNotifications(tuskyAccountId: Long): PagingSource @Query( """DELETE FROM NotificationEntity WHERE tuskyAccountId = :tuskyAccountId AND id = :notificationId""" ) abstract suspend fun delete(tuskyAccountId: Long, notificationId: String): Int @Query( """DELETE FROM NotificationEntity WHERE tuskyAccountId = :tuskyAccountId AND (LENGTH(id) < LENGTH(:maxId) OR LENGTH(id) == LENGTH(:maxId) AND id <= :maxId) AND (LENGTH(id) > LENGTH(:minId) OR LENGTH(id) == LENGTH(:minId) AND id >= :minId) """ ) abstract suspend fun deleteRange(tuskyAccountId: Long, minId: String, maxId: String): Int @Query( """DELETE FROM NotificationEntity WHERE tuskyAccountId = :tuskyAccountId""" ) internal abstract suspend fun removeAllNotifications(tuskyAccountId: Long) /** * Deletes all NotificationReportEntities for Tusky user with id [tuskyAccountId]. * Warning: This can violate foreign key constraints if reports are still referenced in the NotificationEntity table. */ @Query( """DELETE FROM NotificationReportEntity WHERE tuskyAccountId = :tuskyAccountId""" ) internal abstract suspend fun removeAllReports(tuskyAccountId: Long) @Query( """DELETE FROM NotificationEntity WHERE tuskyAccountId = :tuskyAccountId AND statusId = :statusId""" ) abstract suspend fun deleteAllWithStatus(tuskyAccountId: Long, statusId: String) /** * Remove all notifications from user with id [userId] unless they are admin notifications. */ @Query( """DELETE FROM NotificationEntity WHERE tuskyAccountId = :tuskyAccountId AND (accountId = :userId OR statusId IN (SELECT serverId FROM TimelineStatusEntity WHERE tuskyAccountId = :tuskyAccountId AND authorServerId = :userId) ) AND type != "admin.sign_up" AND type != "admin.report" """ ) abstract suspend fun removeAllByUser(tuskyAccountId: Long, userId: String) @Query( """DELETE FROM NotificationEntity WHERE tuskyAccountId = :tuskyAccountId AND statusId IN ( SELECT serverId FROM TimelineStatusEntity WHERE tuskyAccountId = :tuskyAccountId AND authorServerId in ( SELECT serverId FROM TimelineAccountEntity WHERE username LIKE '%@' || :instanceDomain AND tuskyAccountId = :tuskyAccountId) OR accountId IN ( SELECT serverId FROM TimelineAccountEntity WHERE username LIKE '%@' || :instanceDomain AND tuskyAccountId = :tuskyAccountId) )""" ) abstract suspend fun deleteAllFromInstance(tuskyAccountId: Long, instanceDomain: String) @Query("SELECT id FROM NotificationEntity WHERE tuskyAccountId = :accountId ORDER BY LENGTH(id) DESC, id DESC LIMIT 1") abstract suspend fun getTopId(accountId: Long): String? @Query("SELECT id FROM NotificationEntity WHERE tuskyAccountId = :accountId AND type IS NULL ORDER BY LENGTH(id) DESC, id DESC LIMIT 1") abstract suspend fun getTopPlaceholderId(accountId: Long): String? /** * Cleans the NotificationEntity table from old entries. * @param tuskyAccountId id of the account for which to clean tables * @param limit how many timeline items to keep */ @Query( """DELETE FROM NotificationEntity WHERE tuskyAccountId = :tuskyAccountId AND id NOT IN (SELECT id FROM NotificationEntity WHERE tuskyAccountId = :tuskyAccountId ORDER BY LENGTH(id) DESC, id DESC LIMIT :limit) """ ) internal abstract suspend fun cleanupNotifications(tuskyAccountId: Long, limit: Int) /** * Cleans the NotificationReportEntity table from unreferenced entries. * @param tuskyAccountId id of the account for which to clean the table */ @Query( """DELETE FROM NotificationReportEntity WHERE tuskyAccountId = :tuskyAccountId AND serverId NOT IN (SELECT reportId FROM NotificationEntity WHERE tuskyAccountId = :tuskyAccountId and reportId IS NOT NULL)""" ) internal abstract suspend fun cleanupReports(tuskyAccountId: Long) /** * Returns the id directly above [id], or null if [id] is the id of the top item */ @Query( "SELECT id FROM NotificationEntity WHERE tuskyAccountId = :tuskyAccountId AND (LENGTH(:id) < LENGTH(id) OR (LENGTH(:id) = LENGTH(id) AND :id < id)) ORDER BY LENGTH(id) ASC, id ASC LIMIT 1" ) abstract suspend fun getIdAbove(tuskyAccountId: Long, id: String): String? /** * Returns the ID directly below [id], or null if [id] is the ID of the bottom item */ @Query( "SELECT id FROM NotificationEntity WHERE tuskyAccountId = :tuskyAccountId AND (LENGTH(:id) > LENGTH(id) OR (LENGTH(:id) = LENGTH(id) AND :id > id)) ORDER BY LENGTH(id) DESC, id DESC LIMIT 1" ) abstract suspend fun getIdBelow(tuskyAccountId: Long, id: String): String? } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/db/dao/TimelineAccountDao.kt ================================================ /* 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.db.dao import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy.Companion.REPLACE import androidx.room.Query import com.keylesspalace.tusky.db.entity.TimelineAccountEntity @Dao abstract class TimelineAccountDao { @Insert(onConflict = REPLACE) abstract suspend fun insert(timelineAccountEntity: TimelineAccountEntity): Long @Query( """SELECT * FROM TimelineAccountEntity a WHERE a.serverId = :accountId AND a.tuskyAccountId = :tuskyAccountId""" ) internal abstract suspend fun getAccount(tuskyAccountId: Long, accountId: String): TimelineAccountEntity? @Query("DELETE FROM TimelineAccountEntity WHERE tuskyAccountId = :tuskyAccountId") abstract suspend fun removeAllAccounts(tuskyAccountId: Long) /** * Cleans the TimelineAccountEntity table from accounts that are no longer referenced by either TimelineStatusEntity, HomeTimelineEntity or NotificationEntity * @param tuskyAccountId id of the user account for which to clean timeline accounts */ @Query( """DELETE FROM TimelineAccountEntity WHERE tuskyAccountId = :tuskyAccountId AND serverId NOT IN (SELECT authorServerId FROM TimelineStatusEntity WHERE tuskyAccountId = :tuskyAccountId) AND serverId NOT IN (SELECT reblogAccountId FROM HomeTimelineEntity WHERE tuskyAccountId = :tuskyAccountId AND reblogAccountId IS NOT NULL) AND serverId NOT IN (SELECT accountId FROM NotificationEntity WHERE tuskyAccountId = :tuskyAccountId AND accountId IS NOT NULL) AND serverId NOT IN (SELECT targetAccountId FROM NotificationReportEntity WHERE tuskyAccountId = :tuskyAccountId AND targetAccountId IS NOT NULL)""" ) abstract suspend fun cleanupAccounts(tuskyAccountId: Long) } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/db/dao/TimelineDao.kt ================================================ /* 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.db.dao import androidx.paging.PagingSource import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy.Companion.REPLACE import androidx.room.Query import com.keylesspalace.tusky.db.entity.HomeTimelineData import com.keylesspalace.tusky.db.entity.HomeTimelineEntity @Dao abstract class TimelineDao { @Insert(onConflict = REPLACE) abstract suspend fun insertHomeTimelineItem(item: HomeTimelineEntity): Long @Query( """ SELECT h.id, s.serverId, s.url, s.tuskyAccountId, s.authorServerId, s.inReplyToId, s.inReplyToAccountId, s.createdAt, s.editedAt, s.emojis, s.reblogsCount, s.favouritesCount, s.repliesCount, s.reblogged, s.favourited, s.bookmarked, s.sensitive, s.spoilerText, s.visibility, s.mentions, s.tags, s.application, s.content, s.attachments, s.poll, s.card, s.muted, s.expanded, s.contentShowing, s.contentCollapsed, s.pinned, s.language, s.filtered, a.serverId as 'a_serverId', a.tuskyAccountId as 'a_tuskyAccountId', a.localUsername as 'a_localUsername', a.username as 'a_username', a.displayName as 'a_displayName', a.url as 'a_url', a.avatar as 'a_avatar', a.note as 'a_note', a.emojis as 'a_emojis', a.bot as 'a_bot', rb.serverId as 'rb_serverId', rb.tuskyAccountId 'rb_tuskyAccountId', rb.localUsername as 'rb_localUsername', rb.username as 'rb_username', rb.displayName as 'rb_displayName', rb.url as 'rb_url', rb.avatar as 'rb_avatar', rb.note as 'rb_note', rb.emojis as 'rb_emojis', rb.bot as 'rb_bot', replied.serverId as 'replied_serverId', replied.tuskyAccountId 'replied_tuskyAccountId', replied.localUsername as 'replied_localUsername', replied.username as 'replied_username', replied.displayName as 'replied_displayName', replied.url as 'replied_url', replied.avatar as 'replied_avatar', replied.note as 'replied_note', replied.emojis as 'replied_emojis', replied.bot as 'replied_bot', h.loading FROM HomeTimelineEntity h LEFT JOIN TimelineStatusEntity s ON (h.statusId = s.serverId AND s.tuskyAccountId = :tuskyAccountId) LEFT JOIN TimelineAccountEntity a ON (s.authorServerId = a.serverId AND a.tuskyAccountId = :tuskyAccountId) LEFT JOIN TimelineAccountEntity rb ON (h.reblogAccountId = rb.serverId AND rb.tuskyAccountId = :tuskyAccountId) LEFT JOIN TimelineAccountEntity replied ON (s.inReplyToAccountId = replied.serverId AND replied.tuskyAccountId = :tuskyAccountId) WHERE h.tuskyAccountId = :tuskyAccountId ORDER BY LENGTH(h.id) DESC, h.id DESC""" ) abstract fun getHomeTimeline(tuskyAccountId: Long): PagingSource @Query( """DELETE FROM HomeTimelineEntity WHERE tuskyAccountId = :tuskyAccountId AND (LENGTH(id) < LENGTH(:maxId) OR LENGTH(id) == LENGTH(:maxId) AND id <= :maxId) AND (LENGTH(id) > LENGTH(:minId) OR LENGTH(id) == LENGTH(:minId) AND id >= :minId) """ ) abstract suspend fun deleteRange(tuskyAccountId: Long, minId: String, maxId: String): Int /** * Remove all home timeline items that are statuses or reblogs by the user with id [userId], including reblogs from other people. * (e.g. because user was blocked) */ @Query( """DELETE FROM HomeTimelineEntity WHERE tuskyAccountId = :tuskyAccountId AND (statusId IN (SELECT serverId FROM TimelineStatusEntity WHERE tuskyAccountId = :tuskyAccountId AND authorServerId == :userId) OR reblogAccountId == :userId) """ ) abstract suspend fun removeAllByUser(tuskyAccountId: Long, userId: String) /** * Remove all home timeline items that are statuses or reblogs by the user with id [userId], but not reblogs from other users. * (e.g. because user was unfollowed) */ @Query( """DELETE FROM HomeTimelineEntity WHERE tuskyAccountId = :tuskyAccountId AND ((statusId IN (SELECT serverId FROM TimelineStatusEntity WHERE tuskyAccountId = :tuskyAccountId AND authorServerId == :userId) AND reblogAccountId IS NULL) OR reblogAccountId == :userId) """ ) abstract suspend fun removeStatusesAndReblogsByUser(tuskyAccountId: Long, userId: String) @Query("DELETE FROM HomeTimelineEntity WHERE tuskyAccountId = :tuskyAccountId") abstract suspend fun removeAllHomeTimelineItems(tuskyAccountId: Long) @Query( """DELETE FROM HomeTimelineEntity WHERE tuskyAccountId = :tuskyAccountId AND id = :id""" ) abstract suspend fun deleteHomeTimelineItem(tuskyAccountId: Long, id: String) /** * Deletes all hometimeline items that reference the status with it [statusId]. They can be regular statuses or reblogs. */ @Query( """DELETE FROM HomeTimelineEntity WHERE tuskyAccountId = :tuskyAccountId AND statusId = :statusId""" ) abstract suspend fun deleteAllWithStatus(tuskyAccountId: Long, statusId: String) /** * Trims the HomeTimelineEntity table down to [limit] entries by deleting the oldest in case there are more than [limit]. * @param tuskyAccountId id of the account for which to clean the home timeline * @param limit how many timeline items to keep */ @Query( """DELETE FROM HomeTimelineEntity WHERE tuskyAccountId = :tuskyAccountId AND id NOT IN (SELECT id FROM HomeTimelineEntity WHERE tuskyAccountId = :tuskyAccountId ORDER BY LENGTH(id) DESC, id DESC LIMIT :limit) """ ) internal abstract suspend fun cleanupHomeTimeline(tuskyAccountId: Long, limit: Int) @Query( """DELETE FROM HomeTimelineEntity WHERE tuskyAccountId = :tuskyAccountId AND statusId IN ( SELECT serverId FROM TimelineStatusEntity WHERE tuskyAccountId = :tuskyAccountId AND authorServerId in ( SELECT serverId FROM TimelineAccountEntity WHERE username LIKE '%@' || :instanceDomain AND tuskyAccountId = :tuskyAccountId ))""" ) abstract suspend fun deleteAllFromInstance(tuskyAccountId: Long, instanceDomain: String) @Query( "SELECT id FROM HomeTimelineEntity WHERE tuskyAccountId = :tuskyAccountId ORDER BY LENGTH(id) DESC, id DESC LIMIT 1" ) abstract suspend fun getTopId(tuskyAccountId: Long): String? @Query( "SELECT id FROM HomeTimelineEntity WHERE tuskyAccountId = :tuskyAccountId AND statusId IS NULL ORDER BY LENGTH(id) DESC, id DESC LIMIT 1" ) abstract suspend fun getTopPlaceholderId(tuskyAccountId: Long): String? /** * Returns the id directly above [id], or null if [id] is the id of the top item */ @Query( "SELECT id FROM HomeTimelineEntity WHERE tuskyAccountId = :tuskyAccountId AND (LENGTH(:id) < LENGTH(id) OR (LENGTH(:id) = LENGTH(id) AND :id < id)) ORDER BY LENGTH(id) ASC, id ASC LIMIT 1" ) abstract suspend fun getIdAbove(tuskyAccountId: Long, id: String): String? /** * Returns the ID directly below [id], or null if [id] is the ID of the bottom item */ @Query( "SELECT id FROM HomeTimelineEntity WHERE tuskyAccountId = :tuskyAccountId AND (LENGTH(:id) > LENGTH(id) OR (LENGTH(:id) = LENGTH(id) AND :id > id)) ORDER BY LENGTH(id) DESC, id DESC LIMIT 1" ) abstract suspend fun getIdBelow(tuskyAccountId: Long, id: String): String? @Query("SELECT COUNT(*) FROM HomeTimelineEntity WHERE tuskyAccountId = :tuskyAccountId") abstract suspend fun getHomeTimelineItemCount(tuskyAccountId: Long): Int /** Developer tools: Find N most recent status IDs */ @Query( "SELECT id FROM HomeTimelineEntity WHERE tuskyAccountId = :tuskyAccountId ORDER BY LENGTH(id) DESC, id DESC LIMIT :count" ) abstract suspend fun getMostRecentNHomeTimelineIds(tuskyAccountId: Long, count: Int): List /** Developer tools: Convert a home timeline item to a placeholder */ @Query("UPDATE HomeTimelineEntity SET statusId = NULL, reblogAccountId = NULL WHERE id = :serverId") abstract suspend fun convertHomeTimelineItemToPlaceholder(serverId: String) } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/db/dao/TimelineStatusDao.kt ================================================ /* 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.db.dao import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy.Companion.REPLACE import androidx.room.Query import androidx.room.Transaction import androidx.room.TypeConverters import com.keylesspalace.tusky.db.AppDatabase import com.keylesspalace.tusky.db.Converters import com.keylesspalace.tusky.db.entity.TimelineAccountEntity import com.keylesspalace.tusky.db.entity.TimelineStatusEntity import com.keylesspalace.tusky.entity.Attachment import com.keylesspalace.tusky.entity.Emoji import com.keylesspalace.tusky.entity.HashTag import com.keylesspalace.tusky.entity.Poll import com.keylesspalace.tusky.entity.PreviewCard import com.keylesspalace.tusky.entity.Status @Dao abstract class TimelineStatusDao( private val db: AppDatabase ) { @Insert(onConflict = REPLACE) abstract suspend fun insert(timelineStatusEntity: TimelineStatusEntity): Long @Transaction open suspend fun getStatusWithAccount(tuskyAccountId: Long, statusId: String): Pair? { val status = getStatus(tuskyAccountId, statusId) ?: return null val account = db.timelineAccountDao().getAccount(tuskyAccountId, status.authorServerId) ?: return null return status to account } @Query( """ SELECT * FROM TimelineStatusEntity s WHERE s.serverId = :statusId AND s.authorServerId IS NOT NULL AND s.tuskyAccountId = :tuskyAccountId""" ) abstract suspend fun getStatus(tuskyAccountId: Long, statusId: String): TimelineStatusEntity? suspend fun update(tuskyAccountId: Long, status: Status) { update( tuskyAccountId = tuskyAccountId, 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 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 tuskyAccountId = :tuskyAccountId AND serverId = :statusId""" ) @TypeConverters(Converters::class) abstract suspend fun update( tuskyAccountId: 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: PreviewCard?, language: String? ) @Query( """UPDATE TimelineStatusEntity SET bookmarked = :bookmarked WHERE tuskyAccountId = :tuskyAccountId AND serverId = :statusId""" ) abstract suspend fun setBookmarked(tuskyAccountId: Long, statusId: String, bookmarked: Boolean) @Query( """UPDATE TimelineStatusEntity SET reblogged = :reblogged WHERE tuskyAccountId = :tuskyAccountId AND serverId = :statusId""" ) abstract suspend fun setReblogged(tuskyAccountId: Long, statusId: String, reblogged: Boolean) @Query("DELETE FROM TimelineStatusEntity WHERE tuskyAccountId = :tuskyAccountId") abstract suspend fun removeAllStatuses(tuskyAccountId: Long) @Query( """DELETE FROM HomeTimelineEntity WHERE tuskyAccountId = :tuskyAccountId AND id = :id""" ) abstract suspend fun deleteHomeTimelineItem(tuskyAccountId: Long, id: String) /** * Deletes all hometimeline items that reference the status with it [statusId]. They can be regular statuses or reblogs. */ @Query( """DELETE FROM HomeTimelineEntity WHERE tuskyAccountId = :tuskyAccountId AND statusId = :statusId""" ) abstract suspend fun deleteAllWithStatus(tuskyAccountId: Long, statusId: String) /** * Cleans the TimelineStatusEntity table from unreferenced status entries. * @param tuskyAccountId id of the account for which to clean statuses */ @Query( """DELETE FROM TimelineStatusEntity WHERE tuskyAccountId = :tuskyAccountId AND serverId NOT IN (SELECT statusId FROM HomeTimelineEntity WHERE tuskyAccountId = :tuskyAccountId AND statusId IS NOT NULL) AND serverId NOT IN (SELECT statusId FROM NotificationEntity WHERE tuskyAccountId = :tuskyAccountId AND statusId IS NOT NULL)""" ) internal abstract suspend fun cleanupStatuses(tuskyAccountId: Long) @Query( """UPDATE TimelineStatusEntity SET poll = :poll WHERE tuskyAccountId = :tuskyAccountId AND serverId = :statusId""" ) @TypeConverters(Converters::class) abstract suspend fun setVoted(tuskyAccountId: Long, statusId: String, poll: Poll) @Transaction open suspend fun setShowResults(tuskyAccountId: Long, statusId: String) { getStatus(tuskyAccountId, statusId)?.let { status -> status.poll?.let { poll -> setVoted(tuskyAccountId, statusId, poll.copy(voted = true)) } } } @Query( """UPDATE TimelineStatusEntity SET expanded = :expanded WHERE tuskyAccountId = :tuskyAccountId AND serverId = :statusId""" ) abstract suspend fun setExpanded(tuskyAccountId: Long, statusId: String, expanded: Boolean) @Query( """UPDATE TimelineStatusEntity SET contentShowing = :contentShowing WHERE tuskyAccountId = :tuskyAccountId AND serverId = :statusId""" ) abstract suspend fun setContentShowing( tuskyAccountId: Long, statusId: String, contentShowing: Boolean ) @Query( """UPDATE TimelineStatusEntity SET contentCollapsed = :contentCollapsed WHERE tuskyAccountId = :tuskyAccountId AND serverId = :statusId""" ) abstract suspend fun setContentCollapsed( tuskyAccountId: Long, statusId: String, contentCollapsed: Boolean ) @Query( """UPDATE TimelineStatusEntity SET pinned = :pinned WHERE tuskyAccountId = :tuskyAccountId AND serverId = :statusId""" ) abstract suspend fun setPinned(tuskyAccountId: Long, statusId: String, pinned: Boolean) @Query( """DELETE FROM HomeTimelineEntity WHERE tuskyAccountId = :tuskyAccountId AND statusId IN ( SELECT serverId FROM TimelineStatusEntity WHERE tuskyAccountId = :tuskyAccountId AND authorServerId in ( SELECT serverId FROM TimelineAccountEntity WHERE username LIKE '%@' || :instanceDomain AND tuskyAccountId = :tuskyAccountId ))""" ) abstract suspend fun deleteAllFromInstance(tuskyAccountId: Long, instanceDomain: String) @Query( "UPDATE TimelineStatusEntity SET filtered = '[]' WHERE tuskyAccountId = :tuskyAccountId AND serverId = :statusId" ) abstract suspend fun clearWarning(tuskyAccountId: Long, statusId: String): Int @Query( "SELECT id FROM HomeTimelineEntity WHERE tuskyAccountId = :tuskyAccountId ORDER BY LENGTH(id) DESC, id DESC LIMIT 1" ) abstract suspend fun getTopId(tuskyAccountId: Long): String? @Query( "SELECT id FROM HomeTimelineEntity WHERE tuskyAccountId = :tuskyAccountId AND statusId IS NULL ORDER BY LENGTH(id) DESC, id DESC LIMIT 1" ) abstract suspend fun getTopPlaceholderId(tuskyAccountId: Long): String? /** * Returns the id directly above [id], or null if [id] is the id of the top item */ @Query( "SELECT id FROM HomeTimelineEntity WHERE tuskyAccountId = :tuskyAccountId AND (LENGTH(:id) < LENGTH(id) OR (LENGTH(:id) = LENGTH(id) AND :id < id)) ORDER BY LENGTH(id) ASC, id ASC LIMIT 1" ) abstract suspend fun getIdAbove(tuskyAccountId: Long, id: String): String? /** * Returns the ID directly below [id], or null if [id] is the ID of the bottom item */ @Query( "SELECT id FROM HomeTimelineEntity WHERE tuskyAccountId = :tuskyAccountId AND (LENGTH(:id) > LENGTH(id) OR (LENGTH(:id) = LENGTH(id) AND :id > id)) ORDER BY LENGTH(id) DESC, id DESC LIMIT 1" ) abstract suspend fun getIdBelow(tuskyAccountId: Long, id: String): String? /** * Returns the id of the next placeholder after [id], or null if there is no placeholder. */ @Query( "SELECT id FROM HomeTimelineEntity WHERE tuskyAccountId = :tuskyAccountId AND statusId IS NULL AND (LENGTH(:id) > LENGTH(id) OR (LENGTH(:id) = LENGTH(id) AND :id > id)) ORDER BY LENGTH(id) DESC, id DESC LIMIT 1" ) abstract suspend fun getNextPlaceholderIdAfter(tuskyAccountId: Long, id: String): String? @Query("SELECT COUNT(*) FROM HomeTimelineEntity WHERE tuskyAccountId = :tuskyAccountId") abstract suspend fun getHomeTimelineItemCount(tuskyAccountId: Long): Int /** Developer tools: Find N most recent status IDs */ @Query( "SELECT id FROM HomeTimelineEntity WHERE tuskyAccountId = :tuskyAccountId ORDER BY LENGTH(id) DESC, id DESC LIMIT :count" ) abstract suspend fun getMostRecentNStatusIds(tuskyAccountId: Long, count: Int): List /** Developer tools: Convert a home timeline item to a placeholder */ @Query("UPDATE HomeTimelineEntity SET statusId = NULL, reblogAccountId = NULL WHERE id = :serverId") abstract suspend fun convertStatusToPlaceholder(serverId: String) } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/db/entity/AccountEntity.kt ================================================ /* Copyright 2018 Conny Duck * * 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.entity import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.Index import androidx.room.PrimaryKey import androidx.room.TypeConverters import com.keylesspalace.tusky.TabData import com.keylesspalace.tusky.components.systemnotifications.NotificationChannelData import com.keylesspalace.tusky.db.Converters import com.keylesspalace.tusky.defaultTabs import com.keylesspalace.tusky.entity.Emoji import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.settings.DefaultReplyVisibility @Entity( indices = [ Index( value = ["domain", "accountId"], unique = true ) ] ) @TypeConverters(Converters::class) data class AccountEntity( @field:PrimaryKey(autoGenerate = true) val id: Long, val domain: String, val accessToken: String, // nullable for backward compatibility val clientId: String?, // nullable for backward compatibility val clientSecret: String?, val isActive: Boolean, val accountId: String = "", val username: String = "", val displayName: String = "", val profilePictureUrl: String = "", @ColumnInfo(defaultValue = "") val profileHeaderUrl: String = "", val notificationsEnabled: Boolean = true, val notificationsMentioned: Boolean = true, val notificationsFollowed: Boolean = true, val notificationsFollowRequested: Boolean = true, val notificationsReblogged: Boolean = true, val notificationsFavorited: Boolean = true, val notificationsPolls: Boolean = true, val notificationsSubscriptions: Boolean = true, val notificationsUpdates: Boolean = true, @ColumnInfo(defaultValue = "true") val notificationsAdmin: Boolean = true, @ColumnInfo(defaultValue = "true") val notificationsOther: Boolean = true, val notificationSound: Boolean = true, val notificationVibration: Boolean = true, val notificationLight: Boolean = true, val defaultPostPrivacy: Status.Visibility = Status.Visibility.PUBLIC, val defaultReplyPrivacy: DefaultReplyVisibility = DefaultReplyVisibility.MATCH_DEFAULT_POST_VISIBILITY, val defaultMediaSensitivity: Boolean = false, val defaultPostLanguage: String = "", val alwaysShowSensitiveMedia: Boolean = false, /** True if content behind a content warning is shown by default */ @ColumnInfo(defaultValue = "0") val alwaysOpenSpoiler: Boolean = false, /** * True if the "Download media previews" preference is true. This implies * that media previews are shown as well as downloaded. */ val mediaPreviewEnabled: Boolean = true, /** * ID of the last notification the user read on the Notification, list, and should be restored * to view when the user returns to the list. * * May not be the ID of the most recent notification if the user has scrolled down the list. */ val lastNotificationId: String = "0", /** * ID of the most recent Mastodon notification that Tusky has fetched to show as an * Android notification. */ @ColumnInfo(defaultValue = "0") val notificationMarkerId: String = "0", val emojis: List = emptyList(), val tabPreferences: List = defaultTabs(), val notificationsFilter: Set = emptySet(), // Scope cannot be changed without re-login, so store it in case // the scope needs to be changed in the future val oauthScopes: String = "", val unifiedPushUrl: String = "", val pushPubKey: String = "", val pushPrivKey: String = "", val pushAuth: String = "", val pushServerKey: String = "", /** * ID of the status at the top of the visible list in the home timeline when the * user navigated away. */ val lastVisibleHomeTimelineStatusId: String? = null, /** true if the connected Mastodon account is locked (has to manually approve all follow requests **/ @ColumnInfo(defaultValue = "0") val locked: Boolean = false, @ColumnInfo(defaultValue = "0") val hasDirectMessageBadge: Boolean = false, val isShowHomeBoosts: Boolean = true, val isShowHomeReplies: Boolean = true, val isShowHomeSelfBoosts: Boolean = true ) { val identifier: String get() = "$domain:$accountId" val fullName: String get() = "@$username@$domain" fun isPushNotificationsEnabled(): Boolean { return unifiedPushUrl.isNotEmpty() } fun matchesPushSubscription(endpoint: String): Boolean { return unifiedPushUrl == endpoint } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/db/entity/DraftEntity.kt ================================================ /* Copyright 2020 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.db.entity import android.net.Uri import android.os.Parcelable import androidx.core.net.toUri import androidx.room.Entity import androidx.room.PrimaryKey import androidx.room.TypeConverters import com.keylesspalace.tusky.db.Converters 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 @TypeConverters(Converters::class) data class DraftEntity( @PrimaryKey(autoGenerate = true) val id: Int = 0, val accountId: Long, val inReplyToId: String?, val content: String?, val contentWarning: String?, val sensitive: Boolean, val visibility: Status.Visibility, val attachments: List, val poll: NewPoll?, val failedToSend: Boolean, val failedToSendNew: Boolean, val scheduledAt: String?, val language: String?, val statusId: String? ) @JsonClass(generateAdapter = true) @Parcelize data class DraftAttachment( 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 } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/db/entity/HomeTimelineEntity.kt ================================================ /* 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.db.entity import androidx.room.Embedded import androidx.room.Entity import androidx.room.ForeignKey import androidx.room.Index /** * Entity to store an item on the home timeline. Can be a standalone status, a reblog, or a placeholder. */ @Entity( primaryKeys = ["id", "tuskyAccountId"], foreignKeys = ( [ ForeignKey( entity = TimelineStatusEntity::class, parentColumns = ["serverId", "tuskyAccountId"], childColumns = ["statusId", "tuskyAccountId"] ), ForeignKey( entity = TimelineAccountEntity::class, parentColumns = ["serverId", "tuskyAccountId"], childColumns = ["reblogAccountId", "tuskyAccountId"] ) ] ), indices = [ Index("statusId", "tuskyAccountId"), Index("reblogAccountId", "tuskyAccountId"), ] ) data class HomeTimelineEntity( val tuskyAccountId: Long, // the id by which the timeline is sorted val id: String, // the id of the status, null when a placeholder val statusId: String?, // the id of the account who reblogged the status, null if no reblog val reblogAccountId: String?, // only relevant when this is a placeholder val loading: Boolean = false ) /** * Helper class for queries that return HomeTimelineEntity including all references */ data class HomeTimelineData( val id: String, @Embedded val status: TimelineStatusEntity?, @Embedded(prefix = "a_") val account: TimelineAccountEntity?, @Embedded(prefix = "rb_") val reblogAccount: TimelineAccountEntity?, @Embedded(prefix = "replied_") val repliedToAccount: TimelineAccountEntity?, val loading: Boolean ) ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/db/entity/InstanceEntity.kt ================================================ /* Copyright 2018 Conny Duck * * 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.entity import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.PrimaryKey import androidx.room.TypeConverters import com.keylesspalace.tusky.db.Converters import com.keylesspalace.tusky.entity.Emoji @Entity @TypeConverters(Converters::class) data class InstanceEntity( @PrimaryKey val instance: String, val emojiList: List?, val maximumTootCharacters: Int?, val maxPollOptions: Int?, val maxPollOptionLength: Int?, val minPollDuration: Int?, val maxPollDuration: Int?, val charactersReservedPerUrl: Int?, val version: String?, val videoSizeLimit: Int?, val imageSizeLimit: Int?, val imageMatrixLimit: Int?, val maxMediaAttachments: Int?, val maxFields: Int?, val maxFieldNameLength: Int?, val maxFieldValueLength: Int?, val translationEnabled: Boolean?, val mastodonApiVersion: Int?, // ToDo: Remove this again when filter v1 support is dropped @ColumnInfo(defaultValue = "false") val filterV2Supported: Boolean = false ) @TypeConverters(Converters::class) data class EmojisEntity( @PrimaryKey val instance: String, val emojiList: List? ) data class InstanceInfoEntity( @PrimaryKey val instance: String, val maximumTootCharacters: Int?, val maxPollOptions: Int?, val maxPollOptionLength: Int?, val minPollDuration: Int?, val maxPollDuration: Int?, val charactersReservedPerUrl: Int?, val version: String?, val videoSizeLimit: Int?, val imageSizeLimit: Int?, val imageMatrixLimit: Int?, val maxMediaAttachments: Int?, val maxFields: Int?, val maxFieldNameLength: Int?, val maxFieldValueLength: Int?, val translationEnabled: Boolean?, val mastodonApiVersion: Int?, ) ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/db/entity/NotificationEntity.kt ================================================ /* 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.db.entity import androidx.room.Embedded import androidx.room.Entity import androidx.room.ForeignKey import androidx.room.Index import androidx.room.TypeConverters import com.keylesspalace.tusky.db.Converters import com.keylesspalace.tusky.entity.AccountWarning import com.keylesspalace.tusky.entity.Notification import com.keylesspalace.tusky.entity.RelationshipSeveranceEvent import java.util.Date @TypeConverters(Converters::class) data class NotificationDataEntity( // id of the account logged into Tusky this notifications belongs to val tuskyAccountId: Long, // null when placeholder val type: Notification.Type?, val id: String, @Embedded(prefix = "a_") val account: TimelineAccountEntity?, @Embedded(prefix = "s_") val status: TimelineStatusEntity?, @Embedded(prefix = "sa_") val statusAccount: TimelineAccountEntity?, @Embedded(prefix = "r_") val report: NotificationReportEntity?, @Embedded(prefix = "ra_") val reportTargetAccount: TimelineAccountEntity?, val event: RelationshipSeveranceEvent?, val moderationWarning: AccountWarning?, // relevant when it is a placeholder val loading: Boolean = false ) @Entity( primaryKeys = ["id", "tuskyAccountId"], foreignKeys = ( [ ForeignKey( entity = TimelineAccountEntity::class, parentColumns = ["serverId", "tuskyAccountId"], childColumns = ["accountId", "tuskyAccountId"] ), ForeignKey( entity = TimelineStatusEntity::class, parentColumns = ["serverId", "tuskyAccountId"], childColumns = ["statusId", "tuskyAccountId"] ), ForeignKey( entity = NotificationReportEntity::class, parentColumns = ["serverId", "tuskyAccountId"], childColumns = ["reportId", "tuskyAccountId"] ) ] ), indices = [ Index("accountId", "tuskyAccountId"), Index("statusId", "tuskyAccountId"), Index("reportId", "tuskyAccountId"), ] ) @TypeConverters(Converters::class) data class NotificationEntity( // id of the account logged into Tusky this notifications belongs to val tuskyAccountId: Long, // null when placeholder val type: Notification.Type?, val id: String, val accountId: String?, val statusId: String?, val reportId: String?, val event: RelationshipSeveranceEvent?, val moderationWarning: AccountWarning?, // relevant when it is a placeholder val loading: Boolean = false ) @Entity( primaryKeys = ["serverId", "tuskyAccountId"], foreignKeys = ( [ ForeignKey( entity = TimelineAccountEntity::class, parentColumns = ["serverId", "tuskyAccountId"], childColumns = ["targetAccountId", "tuskyAccountId"] ) ] ), indices = [ Index("targetAccountId", "tuskyAccountId"), ] ) @TypeConverters(Converters::class) data class NotificationReportEntity( // id of the account logged into Tusky this report belongs to val tuskyAccountId: Long, val serverId: String, val category: String, val statusIds: List?, val createdAt: Date, val targetAccountId: String? ) ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/db/entity/NotificationPolicyEntity.kt ================================================ package com.keylesspalace.tusky.db.entity import androidx.room.Entity import androidx.room.PrimaryKey @Entity data class NotificationPolicyEntity( @PrimaryKey val tuskyAccountId: Long, val pendingRequestsCount: Int, val pendingNotificationsCount: Int ) ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/db/entity/TimelineAccountEntity.kt ================================================ /* 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.db.entity import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.TypeConverters import com.keylesspalace.tusky.db.Converters import com.keylesspalace.tusky.entity.Emoji @Entity( primaryKeys = ["serverId", "tuskyAccountId"] ) @TypeConverters(Converters::class) data class TimelineAccountEntity( val serverId: String, val tuskyAccountId: Long, val localUsername: String, val username: String, val displayName: String, val url: String, val avatar: String, @ColumnInfo(defaultValue = "") val note: String, val emojis: List, val bot: Boolean ) ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/db/entity/TimelineStatusEntity.kt ================================================ /* 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.db.entity import androidx.room.Entity import androidx.room.ForeignKey import androidx.room.Index import androidx.room.TypeConverters import com.keylesspalace.tusky.db.Converters import com.keylesspalace.tusky.entity.Attachment import com.keylesspalace.tusky.entity.Emoji import com.keylesspalace.tusky.entity.FilterResult import com.keylesspalace.tusky.entity.HashTag import com.keylesspalace.tusky.entity.Poll import com.keylesspalace.tusky.entity.PreviewCard import com.keylesspalace.tusky.entity.Status /** * Entity for caching status data. Used within home timelines and notifications. * The information if a status is a reblog is not stored here but in [HomeTimelineEntity]. */ @Entity( primaryKeys = ["serverId", "tuskyAccountId"], foreignKeys = ( [ ForeignKey( entity = TimelineAccountEntity::class, parentColumns = ["serverId", "tuskyAccountId"], childColumns = ["authorServerId", "tuskyAccountId"] ) ] ), // Avoiding rescanning status table when accounts table changes. Recommended by Room(c). indices = [Index("authorServerId", "tuskyAccountId")] ) @TypeConverters(Converters::class) data class TimelineStatusEntity( // 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 tuskyAccountId: Long, val authorServerId: String, val inReplyToId: String?, val inReplyToAccountId: String?, val content: String, val createdAt: Long, val editedAt: Long?, val emojis: List, val reblogsCount: Int, val favouritesCount: Int, val repliesCount: Int, val reblogged: Boolean, val bookmarked: Boolean, val favourited: Boolean, val sensitive: Boolean, val spoilerText: String, val visibility: Status.Visibility, val attachments: List, val mentions: List, val tags: List, val application: Status.Application?, // if it has a reblogged status, it's id is stored here val poll: Poll?, val muted: Boolean, /** Also used as the "loading" attribute when this TimelineStatusEntity is a placeholder */ val expanded: Boolean, val contentCollapsed: Boolean, val contentShowing: Boolean, val pinned: Boolean, val card: PreviewCard?, val language: String?, val filtered: List ) ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/di/CoroutineScopeModule.kt ================================================ /* * 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.di import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import javax.inject.Qualifier import javax.inject.Singleton import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob /** * Scope for potentially long-running tasks that should outlive the viewmodel that * started them. For example, if the API call to bookmark a status is taking a long * time, that call should not be cancelled because the user has navigated away from * the viewmodel that made the call. * * @see https://developer.android.com/topic/architecture/data-layer#make_an_operation_live_longer_than_the_screen */ @Retention(AnnotationRetention.BINARY) @Qualifier annotation class ApplicationScope @Module @InstallIn(SingletonComponent::class) object CoroutineScopeModule { @ApplicationScope @Provides @Singleton fun providesApplicationScope() = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/di/NetworkModule.kt ================================================ /* Copyright 2018 charlag * * 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.content.SharedPreferences import android.os.Build import android.util.Log import at.connyduck.calladapter.networkresult.NetworkResultCallAdapterFactory import com.keylesspalace.tusky.BuildConfig import com.keylesspalace.tusky.db.AccountManager 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.json.NotificationTypeAdapter import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MediaUploadApi import com.keylesspalace.tusky.network.apiForAccount import com.keylesspalace.tusky.settings.PrefKeys.HTTP_PROXY_ENABLED 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 dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import java.net.IDN import java.net.InetSocketAddress import java.net.Proxy import java.util.Date import java.util.concurrent.TimeUnit import javax.inject.Named 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 /** * Created by charlag on 3/24/18. */ @Module @InstallIn(SingletonComponent::class) object NetworkModule { private const val TAG = "NetworkModule" @Provides @Named("defaultPort") fun providesDefaultPort(): Int { return 443 } @Provides @Named("defaultScheme") fun providesDefaultScheme(): String { return "https://" } @Provides @Singleton fun providesMoshi(): Moshi = Moshi.Builder() .add(GuardedAdapter.ANNOTATION_FACTORY) .add(Date::class.java, Rfc3339DateJsonAdapter()) // 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, NotificationTypeAdapter() ) .add( Status.Visibility::class.java, EnumJsonAdapter.create(Status.Visibility::class.java) .withUnknownFallback(Status.Visibility.UNKNOWN) ) .build() @Provides @Singleton fun providesHttpClient( @ApplicationContext context: Context, preferences: SharedPreferences ): OkHttpClient { val httpProxyEnabled = preferences.getBoolean(HTTP_PROXY_ENABLED, false) val httpServer = preferences.getNonNullString(HTTP_PROXY_SERVER, "") val httpPort = preferences.getNonNullString(HTTP_PROXY_PORT, "-1").toIntOrNull() ?: -1 val cacheSize = 25 * 1024 * 1024L // 25 MiB val builder = OkHttpClient.Builder() .addInterceptor { chain -> /** * Add a custom User-Agent that contains Tusky, Android and OkHttp Version to all requests * Example: * User-Agent: Tusky/1.1.2 Android/5.0.2 OkHttp/4.9.0 * */ val requestWithUserAgent = chain.request().newBuilder() .header( "User-Agent", "Tusky/${BuildConfig.VERSION_NAME} Android/${Build.VERSION.RELEASE} OkHttp/${OkHttp.VERSION}" ) .build() chain.proceed(requestWithUserAgent) } .readTimeout(30, TimeUnit.SECONDS) .writeTimeout(30, TimeUnit.SECONDS) .cache(Cache(context.cacheDir, cacheSize)) if (httpProxyEnabled) { ProxyConfiguration.create(httpServer, httpPort)?.also { conf -> val address = InetSocketAddress.createUnresolved(IDN.toASCII(conf.hostname), conf.port) builder.proxy(Proxy(Proxy.Type.HTTP, address)) } ?: Log.w(TAG, "Invalid proxy configuration: ($httpServer, $httpPort)") } if (BuildConfig.DEBUG) { builder.addInterceptor( HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BASIC } ) } return builder.build() } @Provides @Singleton fun providesRetrofit( httpClient: OkHttpClient, moshi: Moshi ): Retrofit { return Retrofit.Builder() .baseUrl("https://${MastodonApi.PLACEHOLDER_DOMAIN}") .client(httpClient) .addConverterFactory(MoshiConverterFactory.create(moshi).withStreaming()) .addCallAdapterFactory(NetworkResultCallAdapterFactory.create()) .build() } @Provides fun providesMastodonApi( httpClient: OkHttpClient, retrofit: Retrofit, accountManager: AccountManager ): MastodonApi { return apiForAccount(accountManager.activeAccount, httpClient, retrofit) } @Provides fun providesMediaUploadApi( retrofit: Retrofit, okHttpClient: OkHttpClient, accountManager: AccountManager ): MediaUploadApi { val longTimeOutOkHttpClient = okHttpClient.newBuilder() .readTimeout(100, TimeUnit.SECONDS) .writeTimeout(100, TimeUnit.SECONDS) .build() return apiForAccount(accountManager.activeAccount, longTimeOutOkHttpClient, retrofit) } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/di/NotificationManagerModule.kt ================================================ /* Copyright 2025 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.app.NotificationManager import android.content.Context import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent @Module @InstallIn(SingletonComponent::class) object NotificationManagerModule { @Provides fun providesNotificationManager(@ApplicationContext appContext: Context): NotificationManager { return appContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/di/PlayerModule.kt ================================================ /* * 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 dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import okhttp3.OkHttpClient @Module @InstallIn(SingletonComponent::class) @OptIn(UnstableApi::class) object PlayerModule { @Provides fun provideAudioSink(@ApplicationContext context: Context): AudioSink { return DefaultAudioSink.Builder(context) .build() } @Provides fun provideRenderersFactory( @ApplicationContext context: Context, audioSink: AudioSink ): RenderersFactory { return RenderersFactory { eventHandler, videoRendererEventListener, audioRendererEventListener, textRendererOutput, metadataRendererOutput -> arrayOf( MediaCodecVideoRenderer.Builder(context) .setMediaCodecSelector(MediaCodecSelector.DEFAULT) .setAllowedJoiningTimeMs(DefaultRenderersFactory.DEFAULT_ALLOWED_VIDEO_JOINING_TIME_MS) .setEnableDecoderFallback(true) .setEventHandler(eventHandler) .setEventListener(videoRendererEventListener) .setMaxDroppedFramesToNotify(DefaultRenderersFactory.MAX_DROPPED_VIDEO_FRAME_COUNT_TO_NOTIFY) .build(), 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( @ApplicationContext 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( @ApplicationContext 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() } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/di/PreferencesEntryPoint.kt ================================================ package com.keylesspalace.tusky.di import android.content.SharedPreferences import dagger.hilt.EntryPoint import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent @EntryPoint @InstallIn(SingletonComponent::class) interface PreferencesEntryPoint { fun preferences(): SharedPreferences } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/di/StorageModule.kt ================================================ /* Copyright 2018 charlag * * 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.content.SharedPreferences import androidx.preference.PreferenceManager import androidx.room.Room import com.keylesspalace.tusky.db.AppDatabase import com.keylesspalace.tusky.db.Converters import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import javax.inject.Singleton /** * Created by charlag on 3/21/18. */ @Module @InstallIn(SingletonComponent::class) object StorageModule { @Provides fun providesSharedPreferences(@ApplicationContext appContext: Context): SharedPreferences { return PreferenceManager.getDefaultSharedPreferences(appContext) } @Provides @Singleton fun providesDatabase(@ApplicationContext appContext: Context, converters: Converters): AppDatabase { return Room.databaseBuilder(appContext, AppDatabase::class.java, "tuskyDB") .addTypeConverter(converters) .addMigrations( AppDatabase.MIGRATION_2_3, AppDatabase.MIGRATION_3_4, AppDatabase.MIGRATION_4_5, AppDatabase.MIGRATION_5_6, AppDatabase.MIGRATION_6_7, AppDatabase.MIGRATION_7_8, AppDatabase.MIGRATION_8_9, AppDatabase.MIGRATION_9_10, AppDatabase.MIGRATION_10_11, AppDatabase.MIGRATION_11_12, AppDatabase.MIGRATION_12_13, AppDatabase.MIGRATION_10_13, AppDatabase.MIGRATION_13_14, AppDatabase.MIGRATION_14_15, AppDatabase.MIGRATION_15_16, AppDatabase.MIGRATION_16_17, AppDatabase.MIGRATION_17_18, AppDatabase.MIGRATION_18_19, AppDatabase.MIGRATION_19_20, AppDatabase.MIGRATION_20_21, AppDatabase.MIGRATION_21_22, AppDatabase.MIGRATION_22_23, AppDatabase.MIGRATION_23_24, AppDatabase.MIGRATION_24_25, AppDatabase.Migration25_26(appContext.getExternalFilesDir("Tusky")), AppDatabase.MIGRATION_26_27, AppDatabase.MIGRATION_27_28, AppDatabase.MIGRATION_28_29, AppDatabase.MIGRATION_29_30, AppDatabase.MIGRATION_30_31, AppDatabase.MIGRATION_31_32, AppDatabase.MIGRATION_32_33, AppDatabase.MIGRATION_33_34, AppDatabase.MIGRATION_34_35, AppDatabase.MIGRATION_35_36, AppDatabase.MIGRATION_36_37, AppDatabase.MIGRATION_37_38, 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_52_53, AppDatabase.MIGRATION_54_56, AppDatabase.MIGRATION_58_60, AppDatabase.MIGRATION_60_62 ) .build() } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/entity/AccessToken.kt ================================================ /* 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.entity import com.squareup.moshi.Json import com.squareup.moshi.JsonClass @JsonClass(generateAdapter = true) data class AccessToken( @Json(name = "access_token") val accessToken: String ) ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/entity/Account.kt ================================================ /* 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.entity import com.squareup.moshi.Json import com.squareup.moshi.JsonClass import java.util.Date @JsonClass(generateAdapter = true) data class Account( val id: String, @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, @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, // 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 get() = if (displayName.isNullOrEmpty()) { localUsername } else { displayName } val isRemote: Boolean get() = this.username != this.localUsername } @JsonClass(generateAdapter = true) data class AccountSource( 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, @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 ) ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/entity/AccountWarning.kt ================================================ /* Copyright 2025 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.entity import androidx.annotation.StringRes import com.keylesspalace.tusky.R import com.squareup.moshi.Json import com.squareup.moshi.JsonClass @JsonClass(generateAdapter = true) data class AccountWarning( val id: String, val action: Action ) { @JsonClass(generateAdapter = false) enum class Action(@StringRes val text: Int) { @Json(name = "none") NONE(R.string.moderation_warning_action_none), @Json(name = "disable") DISABLE(R.string.moderation_warning_action_disable), @Json(name = "mark_statuses_as_sensitive") MARK_STATUSES_AS_SENSITIVE(R.string.moderation_warning_action_mark_statuses_as_sensitive), @Json(name = "delete_statuses") DELETE_STATUSES(R.string.moderation_warning_action_delete_statuses), @Json(name = "sensitive") SENSITIVE(R.string.moderation_warning_action_sensitive), @Json(name = "silence") SILENCE(R.string.moderation_warning_action_silence), @Json(name = "suspend") SUSPEND(R.string.moderation_warning_action_suspend), } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/entity/Announcement.kt ================================================ /* Copyright 2020 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.entity 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, @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 tags: List, val emojis: List, val reactions: List ) { override fun equals(other: Any?): Boolean { if (this === other) return true if (other !is Announcement) return false 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 = false, val url: String? = null, @Json(name = "static_url") val staticUrl: String? = null ) } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/entity/AppCredentials.kt ================================================ /* 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.entity import com.squareup.moshi.Json import com.squareup.moshi.JsonClass @JsonClass(generateAdapter = true) data class AppCredentials( @Json(name = "client_id") val clientId: String, @Json(name = "client_secret") val clientSecret: String ) ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/entity/Attachment.kt ================================================ /* 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.entity import android.os.Parcelable 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, // can be null for e.g. audio attachments @Json(name = "preview_url") val previewUrl: String? = null, // null when local attachment @Json(name = "remote_url") val remoteUrl: String? = null, val meta: MetaData? = null, val type: Type, val description: String? = null, val blurhash: String? = null ) : Parcelable { /** The url to open for attachments of unknown type */ val unknownUrl: String get() = remoteUrl ?: url @JsonClass(generateAdapter = false) enum class Type { @Json(name = "image") IMAGE, @Json(name = "gifv") GIFV, @Json(name = "video") VIDEO, @Json(name = "audio") AUDIO, UNKNOWN } /** * The meta data of an [Attachment]. */ @JsonClass(generateAdapter = true) @Parcelize data class MetaData( val focus: Focus? = null, val duration: Float? = null, val original: Size? = null, val small: Size? = null ) : Parcelable /** * The Focus entity, used to specify the focal point of an image. * * 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?, val y: Float? ) : Parcelable { fun toMastodonApiString(): String = "$x,$y" } /** * The size of an image, used to specify the width/height. */ @JsonClass(generateAdapter = true) @Parcelize data class Size( val width: Int = 0, val height: Int = 0, val aspect: Double = 0.0 ) : Parcelable } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/entity/Conversation.kt ================================================ /* Copyright 2019 Conny Duck * * 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 Conversation( val id: String, val accounts: List, // 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 ) ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/entity/DeletedStatus.kt ================================================ /* 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.entity import com.squareup.moshi.Json import com.squareup.moshi.JsonClass import java.util.Date @JsonClass(generateAdapter = true) data class DeletedStatus( val text: 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, @Json(name = "media_attachments") val attachments: List, val poll: Poll? = null, @Json(name = "created_at") val createdAt: Date, val language: String? = null ) { val isEmpty: Boolean get() = text == null && attachments.isEmpty() } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/entity/Emoji.kt ================================================ /* Copyright 2018 Conny Duck * * 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 android.os.Parcelable 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, @Json(name = "static_url") val staticUrl: String, @Json(name = "visible_in_picker") val visibleInPicker: Boolean = true, val category: String? ) : Parcelable ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/entity/Error.kt ================================================ /* * 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.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, @Json(name = "error_description") val errorDescription: String? = null ) ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/entity/Filter.kt ================================================ package com.keylesspalace.tusky.entity import android.os.Parcelable 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, @Json(name = "expires_at") val expiresAt: Date? = null, @Json(name = "filter_action") val action: Action, // 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 { @JsonClass(generateAdapter = false) enum class Action(val action: String) { @Json(name = "none") NONE("none"), @Json(name = "blur") BLUR("blur"), @Json(name = "warn") WARN("warn"), @Json(name = "hide") HIDE("hide"); // Retrofit will call toString when sending this class as part of a form-urlencoded body. override fun toString() = action companion object { fun from(action: String): Action = entries.firstOrNull { it.action == action } ?: WARN } } @JsonClass(generateAdapter = false) enum class Kind(val kind: String) { @Json(name = "home") HOME("home"), @Json(name = "notifications") NOTIFICATIONS("notifications"), @Json(name = "public") PUBLIC("public"), @Json(name = "thread") THREAD("thread"), @Json(name = "account") ACCOUNT("account"); // Retrofit will call toString when sending this class as part of a form-urlencoded body. override fun toString() = kind companion object { fun from(kind: String): Kind = entries.firstOrNull { it.kind == kind } ?: PUBLIC } } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/entity/FilterKeyword.kt ================================================ package com.keylesspalace.tusky.entity import android.os.Parcelable 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, @Json(name = "whole_word") val wholeWord: Boolean ) : Parcelable ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/entity/FilterResult.kt ================================================ package com.keylesspalace.tusky.entity import com.squareup.moshi.JsonClass @JsonClass(generateAdapter = true) data class FilterResult( val filter: Filter, // @Json(name = "keyword_matches") val keywordMatches: List? = null, // @Json(name = "status_matches") val statusMatches: List? = null ) ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/entity/FilterV1.kt ================================================ /* 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 import java.util.Date @JsonClass(generateAdapter = true) data class FilterV1( val id: String, val phrase: String, val context: List, @Json(name = "expires_at") val expiresAt: Date? = null, val irreversible: Boolean, @Json(name = "whole_word") val wholeWord: Boolean ) { override fun hashCode(): Int { return id.hashCode() } override fun equals(other: Any?): Boolean { if (other !is FilterV1) { return false } return other.id == id } fun toFilter() = Filter( id = id, title = phrase, context = context.map(Filter.Kind::from), expiresAt = expiresAt, action = Filter.Action.WARN, keywords = listOf( FilterKeyword( id = id, keyword = phrase, wholeWord = wholeWord ) ) ) } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/entity/HashTag.kt ================================================ package com.keylesspalace.tusky.entity import com.squareup.moshi.JsonClass @JsonClass(generateAdapter = true) data class HashTag( val name: String, val url: String, val following: Boolean? = null ) ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/entity/Instance.kt ================================================ package com.keylesspalace.tusky.entity import com.squareup.moshi.Json import com.squareup.moshi.JsonClass @JsonClass(generateAdapter = true) data class Instance( val domain: String, // val title: String, val version: String, // @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, @Json(name = "api_versions") val apiVersions: ApiVersions? = null, ) { @JsonClass(generateAdapter = true) data class Usage(val users: Users) { @JsonClass(generateAdapter = true) data class Users(@Json(name = "active_month") val activeMonth: Int) } @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, // GoToSocial feature @Json(name = "max_profile_fields") val maxProfileFields: 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) @JsonClass(generateAdapter = true) data class ApiVersions(val mastodon: Int? = null) } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/entity/InstanceV1.kt ================================================ /* 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 ) ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/entity/Marker.kt ================================================ package com.keylesspalace.tusky.entity 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( @Json(name = "last_read_id") val lastReadId: String, val version: Int, @Json(name = "updated_at") val updatedAt: Date ) ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/entity/MastoList.kt ================================================ /* 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.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 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 } } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/entity/MediaUploadResult.kt ================================================ 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 ) ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/entity/NewStatus.kt ================================================ /* 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.entity import android.os.Parcelable import com.squareup.moshi.Json import com.squareup.moshi.JsonClass import kotlinx.parcelize.Parcelize @JsonClass(generateAdapter = true) data class NewStatus( val status: String, @Json(name = "spoiler_text") val warningText: String, @Json(name = "in_reply_to_id") val inReplyToId: String? = null, val visibility: String, val sensitive: Boolean, @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, @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? = null, val focus: String? = null, val thumbnail: String? = null ) : Parcelable ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/entity/Notification.kt ================================================ /* 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.entity import com.keylesspalace.tusky.entity.Notification.Type import com.keylesspalace.tusky.entity.Notification.Type.Favourite import com.keylesspalace.tusky.entity.Notification.Type.Follow import com.keylesspalace.tusky.entity.Notification.Type.FollowRequest import com.keylesspalace.tusky.entity.Notification.Type.Mention import com.keylesspalace.tusky.entity.Notification.Type.ModerationWarning import com.keylesspalace.tusky.entity.Notification.Type.Reblog import com.keylesspalace.tusky.entity.Notification.Type.SeveredRelationship import com.keylesspalace.tusky.entity.Notification.Type.SignUp import com.keylesspalace.tusky.entity.Notification.Type.Unknown import com.keylesspalace.tusky.entity.Notification.Type.Update 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? = null, val report: Report? = null, val filtered: Boolean = false, val event: RelationshipSeveranceEvent? = null, @Json(name = "moderation_warning") val moderationWarning: AccountWarning? = null ) { /** From https://docs.joinmastodon.org/entities/Notification/#type */ @JsonClass(generateAdapter = false) sealed class Type(val name: String) { data class Unknown(val unknownName: String) : Type(unknownName) /** Someone mentioned you */ object Mention : Type("mention") /** Someone boosted one of your statuses */ object Reblog : Type("reblog") /** Someone favourited one of your statuses */ object Favourite : Type("favourite") /** Someone followed you */ object Follow : Type("follow") /** Someone requested to follow you */ object FollowRequest : Type("follow_request") /** A poll you have voted in or created has ended */ object Poll : Type("poll") /** Someone you enabled notifications for has posted a status */ object Status : Type("status") /** Someone signed up (optionally sent to admins) */ object SignUp : Type("admin.sign_up") /** A status you interacted with has been updated */ object Update : Type("update") /** A new report has been filed */ object Report : Type("admin.report") /** Some of your follow relationships have been severed as a result of a moderation or block event **/ object SeveredRelationship : Type("severed_relationships") /** moderation_warning = A moderator has taken action against your account or has sent you a warning **/ object ModerationWarning : Type("moderation_warning") // can't use data objects or this wouldn't work override fun toString() = name } // for Pleroma compatibility that uses Mention type fun rewriteToStatusTypeIfNeeded(accountId: String): Notification { if (type == Mention && status != null) { return if (status.mentions.any { it.id == accountId } ) { this } else { copy(type = Type.Status) } } return this } } /** Notification types for UI display (omits UNKNOWN) */ /** this is not in a companion object so it gets initialized earlier, * otherwise it might get initialized when a subclass is loaded, * which leds to crash since those subclasses are referenced here */ val visibleNotificationTypes = listOf(Mention, Reblog, Favourite, Follow, FollowRequest, Type.Poll, Type.Status, SignUp, Update, Type.Report, SeveredRelationship, ModerationWarning) fun notificationTypeFromString(s: String): Type { return visibleNotificationTypes.firstOrNull { it.name == s.lowercase() } ?: Unknown(s) } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/entity/NotificationPolicy.kt ================================================ /* 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.entity import com.squareup.moshi.Json import com.squareup.moshi.JsonClass @JsonClass(generateAdapter = true) data class NotificationPolicy( @Json(name = "for_not_following") val forNotFollowing: State, @Json(name = "for_not_followers") val forNotFollowers: State, @Json(name = "for_new_accounts") val forNewAccounts: State, @Json(name = "for_private_mentions") val forPrivateMentions: State, @Json(name = "for_limited_accounts") val forLimitedAccounts: State, val summary: Summary ) { @JsonClass(generateAdapter = false) enum class State { @Json(name = "accept") ACCEPT, @Json(name = "filter") FILTER, @Json(name = "drop") DROP } @JsonClass(generateAdapter = true) data class Summary( @Json(name = "pending_requests_count") val pendingRequestsCount: Int, @Json(name = "pending_notifications_count") val pendingNotificationsCount: Int ) } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/entity/NotificationRequest.kt ================================================ /* 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.entity import com.squareup.moshi.Json import com.squareup.moshi.JsonClass @JsonClass(generateAdapter = true) data class NotificationRequest( val id: String, val account: Account, @Json(name = "notifications_count") val notificationsCount: Int ) ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/entity/NotificationSubscribeResult.kt ================================================ /* Copyright 2022 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.entity import com.squareup.moshi.Json import com.squareup.moshi.JsonClass @JsonClass(generateAdapter = true) data class NotificationSubscribeResult( val id: Int, val endpoint: String, val alerts: Map, @Json(name = "server_key") val serverKey: String ) ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/entity/Poll.kt ================================================ package com.keylesspalace.tusky.entity import com.squareup.moshi.Json import com.squareup.moshi.JsonClass import java.util.Date @JsonClass(generateAdapter = true) data class Poll( val id: String, @Json(name = "expires_at") val expiresAt: Date? = null, val expired: Boolean, val multiple: Boolean, @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 = 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 ?: 0) + 1) } else { option } } return copy( options = newOptions, votesCount = votesCount + choices.size, votersCount = votersCount?.plus(1), voted = true ) } fun toNewPoll(creationDate: Date) = NewPoll( options.map { it.title }, expiresAt?.let { ((it.time - creationDate.time) / 1000).toInt() + 1 } ?: 3600, multiple ) } @JsonClass(generateAdapter = true) data class PollOption( val title: String, @Json(name = "votes_count") val votesCount: Int? = null ) ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/entity/PreviewCard.kt ================================================ /* 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.entity import com.keylesspalace.tusky.json.Guarded import com.squareup.moshi.Json import com.squareup.moshi.JsonClass import java.util.Date @JsonClass(generateAdapter = true) data class PreviewCard( val url: String, val title: String, val description: String = "", val authors: List = emptyList(), @Json(name = "author_name") val authorName: String? = null, @Json(name = "provider_name") val providerName: String? = null, // sometimes this date is invalid https://github.com/tuskyapp/Tusky/issues/4992 @Json(name = "published_at") @Guarded val publishedAt: Date?, val image: String? = null, val type: 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() override fun equals(other: Any?): Boolean { if (other !is PreviewCard) { return false } return other.url == this.url } companion object { const val TYPE_PHOTO = "photo" } } @JsonClass(generateAdapter = true) data class PreviewCardAuthor( val name: String, val url: String, val account: TimelineAccount? ) ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/entity/Relationship.kt ================================================ /* 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.entity 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, @Json(name = "followed_by") val followedBy: Boolean, val blocking: Boolean, val muting: Boolean, @Json(name = "muting_notifications") val mutingNotifications: Boolean, val requested: 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 GuardedAdapter to ignore the field if it is not a boolean. */ @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 ) ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/entity/RelationshipSeveranceEvent.kt ================================================ /* Copyright 2025 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.entity import com.squareup.moshi.Json import com.squareup.moshi.JsonClass @JsonClass(generateAdapter = true) data class RelationshipSeveranceEvent( val id: String, val type: Type, @Json(name = "target_name") val targetName: String, @Json(name = "followers_count") val followersCount: Int, @Json(name = "following_count") val followingCount: Int ) { @JsonClass(generateAdapter = false) enum class Type { @Json(name = "domain_block") DOMAIN_BLOCK, @Json(name = "user_domain_block") USER_DOMAIN_BLOCK, @Json(name = "account_suspension") ACCOUNT_SUSPENSION, } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/entity/Report.kt ================================================ package com.keylesspalace.tusky.entity 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, @Json(name = "status_ids") val statusIds: List? = null, @Json(name = "created_at") val createdAt: Date, @Json(name = "target_account") val targetAccount: TimelineAccount ) ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/entity/ScheduledStatus.kt ================================================ /* Copyright 2019 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.entity import com.squareup.moshi.Json import com.squareup.moshi.JsonClass @JsonClass(generateAdapter = true) data class ScheduledStatus( val id: String, @Json(name = "scheduled_at") val scheduledAt: String, val params: StatusParams, @Json(name = "media_attachments") val mediaAttachments: List ) // minimal class to avoid json parsing errors with servers that don't support scheduling // https://github.com/tuskyapp/Tusky/issues/4703 @JsonClass(generateAdapter = true) data class ScheduledStatusReply( val id: String, ) ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/entity/SearchResult.kt ================================================ /* 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.entity import com.squareup.moshi.JsonClass @JsonClass(generateAdapter = true) data class SearchResult( val accounts: List, val statuses: List, val hashtags: List ) ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/entity/Status.kt ================================================ /* 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.entity import android.text.SpannableStringBuilder import android.text.style.URLSpan 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, // not present if it's reblog val url: String? = null, val account: TimelineAccount, @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, @Json(name = "created_at") val createdAt: Date, @Json(name = "edited_at") val editedAt: Date? = null, val emojis: List, @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, @Json(name = "spoiler_text") val spoilerText: String, val visibility: Visibility, @Json(name = "media_attachments") val attachments: List, val mentions: List, // Use null to mark the absence of tags because of semantic differences in LinkHelper val tags: List = emptyList(), 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: PreviewCard? = 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 get() = reblog?.id ?: id val actionableStatus: Status get() = reblog ?: this val isReply: Boolean get() = inReplyToId != null @JsonClass(generateAdapter = false) enum class Visibility(val int: Int) { UNKNOWN(0), @Json(name = "public") PUBLIC(1), @Json(name = "unlisted") UNLISTED(2), @Json(name = "private") PRIVATE(3), @Json(name = "direct") DIRECT(4); val stringValue: String get() = when (this) { PUBLIC -> "public" UNLISTED -> "unlisted" PRIVATE -> "private" DIRECT -> "direct" UNKNOWN -> "unknown" } companion object { fun fromInt(int: Int): Visibility { return when (int) { 4 -> DIRECT 3 -> PRIVATE 2 -> UNLISTED 1 -> PUBLIC 0 -> UNKNOWN else -> UNKNOWN } } fun fromStringValue(s: String): Visibility { return when (s) { "public" -> PUBLIC "unlisted" -> UNLISTED "private" -> PRIVATE "direct" -> DIRECT "unknown" -> UNKNOWN else -> UNKNOWN } } } } val isRebloggingAllowed: Boolean get() { return (visibility != Visibility.DIRECT && visibility != Visibility.UNKNOWN) } fun toDeletedStatus(): DeletedStatus { return DeletedStatus( text = getEditableText(), inReplyToId = inReplyToId, spoilerText = spoilerText, visibility = visibility, sensitive = sensitive, attachments = attachments, poll = poll, createdAt = createdAt, language = language ) } private fun getEditableText(): String { val contentSpanned = content.parseAsMastodonHtml() val builder = SpannableStringBuilder(content.parseAsMastodonHtml()) for (span in contentSpanned.getSpans(0, content.length, URLSpan::class.java)) { val url = span.url for ((_, url1, username) in mentions) { if (url == url1) { val start = builder.getSpanStart(span) val end = builder.getSpanEnd(span) if (start >= 0 && end >= 0) { builder.replace(start, end, "@$username") } break } } } return builder.toString() } fun getApplicableFilter(kind: Filter.Kind): Filter? = actionableStatus.filtered?.filter { it.filter.context.contains(kind) }?.maxByOrNull { it.filter.action.ordinal }?.filter fun shouldShowContent(alwayShowSensitiveContent: Boolean, context: Filter.Kind): Boolean = alwayShowSensitiveContent || (!actionableStatus.sensitive && getApplicableFilter(context)?.action != Filter.Action.BLUR) @JsonClass(generateAdapter = true) data class Mention( val id: String, val url: 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? = null ) companion object { const val MAX_MEDIA_ATTACHMENTS = 4 const val MAX_POLL_OPTIONS = 4 } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/entity/StatusContext.kt ================================================ /* 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.entity import com.squareup.moshi.JsonClass @JsonClass(generateAdapter = true) data class StatusContext( val ancestors: List, val descendants: List ) ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/entity/StatusEdit.kt ================================================ package com.keylesspalace.tusky.entity import com.squareup.moshi.Json import com.squareup.moshi.JsonClass import java.util.Date @JsonClass(generateAdapter = true) data class StatusEdit( val content: String, @Json(name = "spoiler_text") val spoilerText: String, val sensitive: Boolean, @Json(name = "created_at") val createdAt: Date, val account: TimelineAccount, val poll: Poll? = null, @Json(name = "media_attachments") val mediaAttachments: List, val emojis: List ) ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/entity/StatusParams.kt ================================================ /* Copyright 2019 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.entity import com.squareup.moshi.Json import com.squareup.moshi.JsonClass @JsonClass(generateAdapter = true) data class StatusParams( val text: String, val sensitive: Boolean? = null, val visibility: Status.Visibility, @Json(name = "spoiler_text") val spoilerText: String? = null, @Json(name = "in_reply_to_id") val inReplyToId: String? = null ) ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/entity/StatusSource.kt ================================================ /* Copyright 2022 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.entity import com.squareup.moshi.Json import com.squareup.moshi.JsonClass @JsonClass(generateAdapter = true) data class StatusSource( val id: String, val text: String, @Json(name = "spoiler_text") val spoilerText: String ) ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/entity/TimelineAccount.kt ================================================ /* 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.entity 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, @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, // optional for backward compatibility val emojis: List = emptyList() ) { val name: String get() = if (displayName.isNullOrEmpty()) { localUsername } else { displayName } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/entity/Translation.kt ================================================ 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 ) ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/entity/TrendingTagsResult.kt ================================================ /* 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.entity import com.squareup.moshi.JsonClass import java.util.Date /** * Mastodon API Documentation: https://docs.joinmastodon.org/methods/trends/#tags * * @param name The name of the hashtag (after the #). The "caturday" in "#caturday". * (@param url The URL to your mastodon instance list for this hashtag.) * @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 ) /** * Mastodon API Documentation: https://docs.joinmastodon.org/methods/trends/#tags * * @param day The day that this was posted in Unix Epoch Seconds. * @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 ) val TrendingTag.start get() = Date(history.last().day.toLong() * 1000L) val TrendingTag.end get() = Date(history.first().day.toLong() * 1000L) ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/fragment/SFragment.kt ================================================ /* 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 android.Manifest import android.app.DownloadManager import android.content.DialogInterface import android.content.Intent import android.os.Build import android.os.Bundle import android.os.Environment import android.util.Log import android.view.MenuItem import android.view.View import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.LayoutRes import androidx.appcompat.widget.PopupMenu import androidx.core.app.ActivityOptionsCompat import androidx.core.content.getSystemService import androidx.core.net.toUri import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope import at.connyduck.calladapter.networkresult.fold import at.connyduck.calladapter.networkresult.onFailure import at.connyduck.sparkbutton.SparkButton import com.google.android.material.dialog.MaterialAlertDialogBuilder 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 import com.keylesspalace.tusky.StatusListActivity.Companion.newHashtagIntent 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.AccountManager import com.keylesspalace.tusky.db.entity.AccountEntity 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.copyToClipboard 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 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 * of that is complicated by how they're coupled with Status and Notification and the corresponding * adapters. I feel like the profile pages and thread viewer, which I haven't made yet, will also * overlap functionality. So, I'm momentarily leaving it and hopefully working on those will clear * up what needs to be where. */ abstract class SFragment(@LayoutRes contentLayoutId: Int) : Fragment(contentLayoutId) { protected abstract fun removeItem(position: Int) protected abstract fun onReblog(reblog: Boolean, position: Int, visibility: Status.Visibility?, button: SparkButton?) /** `null` if translation is not supported on this screen */ protected abstract val onMoreTranslate: ((translate: Boolean, position: Int) -> Unit)? private val bottomSheetActivity: BottomSheetActivity get() = (requireActivity() as? BottomSheetActivity) ?: throw IllegalStateException("Fragment must be attached to a BottomSheetActivity!") @Inject lateinit var mastodonApi: MastodonApi @Inject lateinit var accountManager: AccountManager @Inject lateinit var timelineCases: TimelineCases @Inject lateinit var instanceInfoRepository: InstanceInfoRepository private var pendingMediaDownloads: List? = null private val downloadAllMediaPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted -> if (isGranted) { pendingMediaDownloads?.let { downloadAllMedia(it) } } else { Toast.makeText( context, R.string.error_media_download_permission, Toast.LENGTH_SHORT ).show() } pendingMediaDownloads = null } override fun startActivity(intent: Intent) { requireActivity().startActivityWithSlideInAnimation(intent) } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) pendingMediaDownloads = savedInstanceState?.getStringArrayList(PENDING_MEDIA_DOWNLOADS_STATE_KEY) } override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) pendingMediaDownloads?.let { outState.putStringArrayList(PENDING_MEDIA_DOWNLOADS_STATE_KEY, ArrayList(it)) } } 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) } protected fun viewThread(statusId: String?, statusUrl: String?) { bottomSheetActivity.viewThread(statusId!!, statusUrl) } protected fun viewAccount(accountId: String?) { bottomSheetActivity.viewAccount(accountId!!) } open fun onViewUrl(url: String) { bottomSheetActivity.viewUrl(url, PostLookupFallbackBehavior.OPEN_IN_BROWSER) } protected fun reply(status: Status) { val actionableStatus = status.actionableStatus val account = actionableStatus.account var loggedInUsername: String? = null val activeAccount = accountManager.activeAccount if (activeAccount != null) { loggedInUsername = activeAccount.username } val mentionedUsernames = LinkedHashSet( listOf(account.username) + actionableStatus.mentions.map { it.username } ).apply { remove(loggedInUsername) } val composeOptions = ComposeOptions( inReplyToId = status.actionableId, replyVisibility = actionableStatus.visibility, contentWarning = actionableStatus.spoilerText, mentionedUsernames = mentionedUsernames, replyingStatusAuthor = account.localUsername, replyingStatusContent = actionableStatus.content.parseAsMastodonHtml().toString(), language = actionableStatus.language, kind = ComposeActivity.ComposeKind.NEW ) val intent = startIntent(requireContext(), composeOptions) requireActivity().startActivity(intent) } protected fun more(status: Status, view: View, position: Int, translation: Translation?) { val id = status.actionableId val actionableStatus = status.actionableStatus val accountId = actionableStatus.account.id val accountUsername = actionableStatus.account.username val statusUrl = actionableStatus.url var loggedInAccountId: String? = null val activeAccount = accountManager.activeAccount if (activeAccount != null) { loggedInAccountId = activeAccount.accountId } val popup = PopupMenu(requireContext(), view) // Give a different menu depending on whether this is the user's own toot or not. val statusIsByCurrentUser = loggedInAccountId != null && loggedInAccountId == accountId if (statusIsByCurrentUser) { popup.inflate(R.menu.status_more_for_user) val menu = popup.menu when (actionableStatus.visibility) { Status.Visibility.PUBLIC, Status.Visibility.UNLISTED -> { menu.add( 0, R.id.pin, 1, getString( if (actionableStatus.pinned) R.string.unpin_action else R.string.pin_action ) ) } Status.Visibility.PRIVATE -> { menu.findItem(R.id.status_reblog_private).isVisible = !actionableStatus.reblogged menu.findItem(R.id.status_unreblog_private).isVisible = actionableStatus.reblogged } else -> {} } } else { popup.inflate(R.menu.status_more) 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) val openAsText = (activity as BaseActivity?)?.openAsText if (openAsText == null) { openAsItem.isVisible = false } else { openAsItem.title = openAsText } val muteConversationItem = menu.findItem(R.id.status_mute_conversation) val mutable = statusIsByCurrentUser || accountIsInMentions(activeAccount, status.mentions) muteConversationItem.isVisible = mutable if (mutable) { muteConversationItem.setTitle( if (!status.muted) { R.string.action_mute_conversation } else { R.string.action_unmute_conversation } ) } // translation not there for posts already in your language or non-public 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 && (status.visibility == Status.Visibility.PUBLIC || status.visibility == Status.Visibility.UNLISTED) 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 -> { val statusToShare = status.reblog ?: status val sendIntent = Intent().apply { action = Intent.ACTION_SEND type = "text/plain" putExtra( Intent.EXTRA_TEXT, "${statusToShare.account.username} - ${statusToShare.content.parseAsMastodonHtml()}" ) putExtra(Intent.EXTRA_SUBJECT, statusUrl) } startActivity( Intent.createChooser( sendIntent, resources.getText(R.string.send_post_content_to) ) ) return@setOnMenuItemClickListener true } R.id.post_share_link -> { val sendIntent = Intent().apply { action = Intent.ACTION_SEND putExtra(Intent.EXTRA_TEXT, statusUrl) type = "text/plain" } startActivity( Intent.createChooser( sendIntent, resources.getText(R.string.send_post_link_to) ) ) return@setOnMenuItemClickListener true } R.id.status_copy_link -> { statusUrl?.let { requireActivity().copyToClipboard(it, getString(R.string.url_copied)) } return@setOnMenuItemClickListener true } R.id.status_open_as -> { showOpenAsDialog(statusUrl, item.title) return@setOnMenuItemClickListener true } R.id.status_download_media -> { requestDownloadAllMedia(actionableStatus) 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, Status.Visibility.PRIVATE, null) return@setOnMenuItemClickListener true } R.id.status_reblog_private -> { onReblog(true, position, Status.Visibility.PRIVATE, null) 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 -> { viewLifecycleOwner.lifecycleScope.launch { timelineCases.pin(status.actionableId, !actionableStatus.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) } return@setOnMenuItemClickListener true } R.id.status_translate -> { onMoreTranslate?.invoke(translation == null, position) } } false } popup.show() } private fun onMute(accountId: String, accountUsername: String) { showMuteAccountDialog( this.requireActivity(), accountUsername ) { notifications: Boolean, duration: Int? -> lifecycleScope.launch { timelineCases.mute(accountId, notifications, duration) } } } private fun onBlock(accountId: String, accountUsername: String) { MaterialAlertDialogBuilder(requireContext()) .setMessage(getString(R.string.dialog_block_warning, accountUsername)) .setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int -> lifecycleScope.launch { timelineCases.block(accountId) } } .setNegativeButton(android.R.string.cancel, null) .show() } protected fun viewMedia(urlIndex: Int, attachments: List, view: View?) { val (attachment) = attachments[urlIndex] when (attachment.type) { Attachment.Type.GIFV, Attachment.Type.VIDEO, Attachment.Type.IMAGE, Attachment.Type.AUDIO -> { val intent = newIntent(requireContext(), attachments, urlIndex) if (view != null) { val url = attachment.url view.transitionName = url val options = ActivityOptionsCompat.makeSceneTransitionAnimation( requireActivity(), view, url ) startActivity(intent, options.toBundle()) } else { startActivity(intent) } } Attachment.Type.UNKNOWN -> { requireContext().openLink(attachment.unknownUrl) } } } protected fun viewTag(tag: String) { startActivity(newHashtagIntent(requireContext(), tag)) } private fun openReportPage(accountId: String, accountUsername: String, statusId: String) { startActivity(getIntent(requireContext(), accountId, accountUsername, statusId)) } private fun showConfirmDeleteDialog(id: String, position: Int) { MaterialAlertDialogBuilder(requireActivity()) .setMessage(R.string.dialog_delete_post_warning) .setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int -> viewLifecycleOwner.lifecycleScope.launch { val result = timelineCases.delete(id, true).exceptionOrNull() if (result != null) { Log.w("SFragment", "error deleting status", result) Toast.makeText(requireContext(), R.string.error_generic, Toast.LENGTH_SHORT).show() } // XXX: Removes the item even if there was an error. This is probably not // correct (see similar code in showConfirmEditDialog() which only // removes the item if the timelineCases.delete() call succeeded. // // Either way, this logic should be in the view model. removeItem(position) } } .setNegativeButton(android.R.string.cancel, null) .show() } private fun showConfirmEditDialog(id: String, position: Int, status: Status) { val context = context ?: return MaterialAlertDialogBuilder(context) .setMessage(R.string.dialog_redraft_post_warning) .setPositiveButton(android.R.string.ok) { _: DialogInterface?, _: Int -> viewLifecycleOwner.lifecycleScope.launch { timelineCases.delete(id, false).fold( { deletedStatus -> removeItem(position) val sourceStatus = if (deletedStatus.isEmpty) { status.toDeletedStatus() } else { deletedStatus } val composeOptions = ComposeOptions( content = sourceStatus.text, inReplyToId = sourceStatus.inReplyToId, visibility = sourceStatus.visibility, contentWarning = sourceStatus.spoilerText, mediaAttachments = sourceStatus.attachments, sensitive = sourceStatus.sensitive, modifiedInitialState = true, language = sourceStatus.language, poll = sourceStatus.poll?.toNewPoll(sourceStatus.createdAt), kind = ComposeActivity.ComposeKind.NEW ) startActivity(startIntent(context, composeOptions)) }, { error: Throwable? -> Log.w("SFragment", "error deleting status", error) Toast.makeText(context, R.string.error_generic, Toast.LENGTH_SHORT) .show() } ) } } .setNegativeButton(android.R.string.cancel, null) .show() } private fun editStatus(id: String, status: Status) { viewLifecycleOwner.lifecycleScope.launch { mastodonApi.statusSource(id).fold( { source -> val composeOptions = ComposeOptions( content = source.text, inReplyToId = status.inReplyToId, visibility = status.visibility, contentWarning = source.spoilerText, mediaAttachments = status.attachments, sensitive = status.sensitive, language = status.language, statusId = source.id, poll = status.poll?.toNewPoll(status.createdAt), kind = ComposeActivity.ComposeKind.EDIT_POSTED ) startActivity(startIntent(requireContext(), composeOptions)) }, { Snackbar.make( requireView(), getString(R.string.error_status_source_load), Snackbar.LENGTH_SHORT ).show() } ) } } private fun showOpenAsDialog(statusUrl: String?, dialogTitle: CharSequence?) { if (statusUrl == null) { return } (activity as BaseActivity).apply { showAccountChooserDialog( dialogTitle, false, object : AccountSelectionListener { override fun onAccountSelected(account: AccountEntity) { openAsAccount(statusUrl, account) } } ) } } private fun downloadAllMedia(mediaUrls: List) { Toast.makeText(context, R.string.downloading_media, Toast.LENGTH_SHORT).show() val downloadManager: DownloadManager = requireContext().getSystemService()!! for (url in mediaUrls) { val uri = url.toUri() downloadManager.enqueue( DownloadManager.Request(uri).apply { setDestinationInExternalPublicDir( Environment.DIRECTORY_DOWNLOADS, uri.lastPathSegment ) } ) } } private fun requestDownloadAllMedia(status: Status) { if (status.attachments.isEmpty()) { return } val mediaUrls = status.attachments.map { it.url } if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { pendingMediaDownloads = mediaUrls downloadAllMediaPermissionLauncher.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE) } else { downloadAllMedia(mediaUrls) } } companion object { private const val PENDING_MEDIA_DOWNLOADS_STATE_KEY = "pending_media_downloads" private fun accountIsInMentions( account: AccountEntity?, mentions: List ): Boolean { return mentions.any { mention -> account?.username == mention.username && account.domain == mention.url.toUri().host } } } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/fragment/ViewImageFragment.kt ================================================ /* 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 android.animation.Animator import android.animation.AnimatorListenerAdapter import android.annotation.SuppressLint 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.view.ViewCompat import androidx.core.view.WindowInsetsCompat.Type.displayCutout import androidx.core.view.WindowInsetsCompat.Type.systemBars import androidx.core.view.updatePadding 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.google.android.material.bottomsheet.BottomSheetBehavior import com.keylesspalace.tusky.R import com.keylesspalace.tusky.databinding.FragmentViewImageBinding import com.keylesspalace.tusky.entity.Attachment import com.keylesspalace.tusky.util.getParcelableCompat import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.util.visible 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 { fun onBringUp() fun onDismiss() fun onPhotoTap() } private val binding by viewBinding(FragmentViewImageBinding::bind) private val photoActionsListener: PhotoActionsListener get() = requireActivity() as PhotoActionsListener private var transition: CompletableDeferred? = null private var shouldStartTransition = false // Volatile: Image requests happen on background thread and we want to see updates to it // immediately on another thread. Atomic is an overkill for such thing. @Volatile private var startedTransition = false override fun setupMediaView( url: String, previewUrl: String?, description: String?, showingDescription: Boolean ) { binding.photoView.transitionName = url binding.mediaDescription.text = description binding.captionSheet.visible(showingDescription) startedTransition = false loadImageFromNetwork(url, previewUrl, binding.photoView) } override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { this.transition = CompletableDeferred() return inflater.inflate(R.layout.fragment_view_image, container, false) } @SuppressLint("ClickableViewAccessibility") override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) val arguments = requireArguments() val attachment = arguments.getParcelableCompat(ARG_ATTACHMENT) this.shouldStartTransition = arguments.getBoolean(ARG_START_POSTPONED_TRANSITION) val url: String? var description: String? = null if (attachment != null) { url = attachment.url description = attachment.description } else { url = arguments.getString(ARG_SINGLE_IMAGE_URL) if (url == null) { throw IllegalArgumentException("attachment or image url has to be set") } } val descriptionBottomSheet = BottomSheetBehavior.from(binding.captionSheet) ViewCompat.setOnApplyWindowInsetsListener(binding.root) { _, insets -> val topInsets = insets.getInsets(displayCutout()).top val bottomInsets = insets.getInsets(systemBars()).bottom val mediaDescriptionBottomPadding = requireContext().resources.getDimensionPixelSize(R.dimen.media_description_sheet_bottom_padding) val mediaDescriptionPeekHeight = requireContext().resources.getDimensionPixelSize(R.dimen.media_description_sheet_peek_height) binding.mediaDescription.updatePadding(bottom = mediaDescriptionBottomPadding + bottomInsets) descriptionBottomSheet.setPeekHeight(mediaDescriptionPeekHeight + bottomInsets, false) binding.photoView.updatePadding( top = topInsets, bottom = bottomInsets ) binding.photoView.invalidate() insets.inset(0, topInsets, 0, bottomInsets) } val singleTapDetector = GestureDetector( requireContext(), object : GestureDetector.SimpleOnGestureListener() { override fun onDown(e: MotionEvent) = true override fun onSingleTapConfirmed(e: MotionEvent): Boolean { if (isAdded) { photoActionsListener.onPhotoTap() } return false } } ) binding.photoView.setOnTouchCoordinatesListener(object : OnTouchCoordinatesListener { /** Y coordinate of the last single-finger drag */ var lastDragY: Float? = null 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() } else { view.animate().translationY(0f).scaleX(1f).scaleY(1f).start() } } }) finalizeViewSetup(url, attachment?.previewUrl, description) } override fun onToolbarVisibilityChange(visible: Boolean) { if (view == null) 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) { view ?: return binding.captionSheet.visible(isDescriptionVisible) animation.removeListener(this) } }) .start() } override fun onDestroyView() { transition = null super.onDestroyView() } private fun loadImageFromNetwork(url: String, previewUrl: String?, photoView: ImageView) { val glide = Glide.with(this) // Request image from the any cache glide .load(url) .dontAnimate() .onlyRetrieveFromCache(true) .let { if (previewUrl != null) { it.thumbnail( glide .load(previewUrl) .dontAnimate() .onlyRetrieveFromCache(true) .centerInside() .addListener(ImageRequestListener(true, isThumbnailRequest = true)) ) } else { it } } // Request image from the network on fail load image from cache .error( glide.load(url) .override(Target.SIZE_ORIGINAL, Target.SIZE_ORIGINAL) .centerInside() .addListener(ImageRequestListener(false, isThumbnailRequest = false)) ) .override(Target.SIZE_ORIGINAL, Target.SIZE_ORIGINAL) .centerInside() .addListener(ImageRequestListener(true, isThumbnailRequest = false)) .into(photoView) } /** * We start transition as soon as we think reasonable but we must take care about couple of * things> * - Do not change image in the middle of transition. It messes up the view. * - Do not transition for the views which don't require it. Starting transition from * multiple fragments does weird things * - Do not wait to transition until the image loads from network * * Preview, cached image, network image, x - failed, o - succeeded * P C N - start transition after... * x x x - the cache fails * x x o - the cache fails * x o o - the cache succeeds * o x o - the preview succeeds. Do not start on cache. * o o o - the preview succeeds. Do not start on cache. * * So start transition after the first success or after anything with the cache * * @param isCacheRequest - is this listener for request image from cache or from the network */ private inner class ImageRequestListener( private val isCacheRequest: Boolean, private val isThumbnailRequest: Boolean ) : RequestListener { override fun onLoadFailed( e: GlideException?, model: Any?, target: Target, isFirstResource: Boolean ): Boolean { // If cache for full image failed complete transition if (isCacheRequest && !isThumbnailRequest && shouldStartTransition && !startedTransition ) { photoActionsListener.onBringUp() } // Hide progress bar only on fail request from internet if (!isCacheRequest) binding.progressBar.hide() // We don't want to overwrite preview with null when main image fails to load return !isCacheRequest } @SuppressLint("CheckResult") override fun onResourceReady( resource: Drawable, model: Any, target: Target, dataSource: DataSource, isFirstResource: Boolean ): Boolean { 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 startedTransition = true // post() because load() replaces image with null. Sometimes after we set // the thumbnail. binding.photoView.post { if (isAdded) { target.onResourceReady(resource, null) if (shouldStartTransition) photoActionsListener.onBringUp() } } } else { // 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) } } } return true } } override fun onTransitionEnd() { this.transition?.complete(Unit) } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/fragment/ViewMediaFragment.kt ================================================ /* 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 android.os.Bundle import android.text.TextUtils import androidx.fragment.app.Fragment import com.keylesspalace.tusky.ViewMediaActivity import com.keylesspalace.tusky.entity.Attachment abstract class ViewMediaFragment : Fragment() { private var toolbarVisibilityDisposable: Function0? = null abstract fun setupMediaView( url: String, previewUrl: String?, description: String?, showingDescription: Boolean ) abstract fun onToolbarVisibilityChange(visible: Boolean) protected var showingDescription = false protected var isDescriptionVisible = false companion object { @JvmStatic protected val ARG_START_POSTPONED_TRANSITION = "startPostponedTransition" @JvmStatic protected val ARG_ATTACHMENT = "attach" @JvmStatic protected val ARG_SINGLE_IMAGE_URL = "singleImageUrl" @JvmStatic fun newInstance( attachment: Attachment, shouldStartPostponedTransition: Boolean ): ViewMediaFragment { val arguments = Bundle(2) arguments.putParcelable(ARG_ATTACHMENT, attachment) arguments.putBoolean(ARG_START_POSTPONED_TRANSITION, shouldStartPostponedTransition) val fragment = when (attachment.type) { Attachment.Type.IMAGE -> ViewImageFragment() Attachment.Type.VIDEO, Attachment.Type.GIFV, Attachment.Type.AUDIO -> ViewVideoFragment() else -> ViewImageFragment() // it probably won't show anything, but its better than crashing } fragment.arguments = arguments return fragment } @JvmStatic fun newSingleImageInstance(imageUrl: String): ViewMediaFragment { val arguments = Bundle(2) val fragment = ViewImageFragment() arguments.putString(ARG_SINGLE_IMAGE_URL, imageUrl) arguments.putBoolean(ARG_START_POSTPONED_TRANSITION, true) fragment.arguments = arguments return fragment } } abstract fun onTransitionEnd() protected fun finalizeViewSetup(url: String, previewUrl: String?, description: String?) { val mediaActivity = activity as ViewMediaActivity showingDescription = !TextUtils.isEmpty(description) isDescriptionVisible = showingDescription setupMediaView( url, previewUrl, description, showingDescription && mediaActivity.isToolbarVisible ) toolbarVisibilityDisposable = (activity as ViewMediaActivity) .addToolbarVisibilityListener { isVisible -> onToolbarVisibilityChange(isVisible) } } override fun onDestroyView() { toolbarVisibilityDisposable?.invoke() toolbarVisibilityDisposable = null super.onDestroyView() } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/fragment/ViewVideoFragment.kt ================================================ /* 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 android.animation.Animator import android.animation.AnimatorListenerAdapter import android.annotation.SuppressLint import android.graphics.drawable.Drawable import android.os.Bundle import android.os.Handler import android.os.Looper import android.view.GestureDetector import android.view.Gravity import android.view.LayoutInflater import android.view.MotionEvent import android.view.View import android.view.ViewGroup import android.widget.FrameLayout import android.widget.LinearLayout import androidx.annotation.OptIn import androidx.constraintlayout.widget.ConstraintLayout import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat.Type.systemBars import androidx.core.view.updateLayoutParams import androidx.core.view.updatePadding import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner 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 androidx.media3.ui.PlayerView import com.bumptech.glide.Glide import com.bumptech.glide.request.target.CustomViewTarget 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.entity.Attachment import com.keylesspalace.tusky.util.getParcelableCompat import com.keylesspalace.tusky.util.unsafeLazy import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.util.visible import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject import javax.inject.Provider import kotlin.math.abs @AndroidEntryPoint @OptIn(UnstableApi::class) class ViewVideoFragment : ViewMediaFragment() { interface VideoActionsListener { fun onDismiss() } @Inject lateinit var playerProvider: Provider private val binding by viewBinding(FragmentViewVideoBinding::bind) private val videoActionsListener: VideoActionsListener get() = requireActivity() as VideoActionsListener private val handler = Handler(Looper.getMainLooper()) private val hideToolbar = Runnable { // 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() } private val mediaActivity: ViewMediaActivity get() = requireActivity() as ViewMediaActivity private val isAudio get() = mediaAttachment.type == Attachment.Type.AUDIO private val mediaAttachment: Attachment by unsafeLazy { arguments?.getParcelableCompat(ARG_ATTACHMENT) ?: throw IllegalArgumentException("attachment has to be set") } 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 @SuppressLint("PrivateResource", "MissingInflatedId") override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { val rootView = inflater.inflate(R.layout.fragment_view_video, container, false) // 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) ViewCompat.setOnApplyWindowInsetsListener(binding.mediaDescriptionScrollView) { captionSheet, insets -> val systemBarInsets = insets.getInsets(systemBars()) captionSheet.updatePadding(bottom = systemBarInsets.bottom) binding.videoView.updateLayoutParams { bottomMargin = systemBarInsets.bottom } insets.inset(0, 0, 0, systemBarInsets.bottom) } /** * 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 = GestureDetector( 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 } } val 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 val attachment = mediaAttachment finalizeViewSetup(attachment.url, attachment.previewUrl, attachment.description) // Lifecycle callbacks viewLifecycleOwner.lifecycle.addObserver(object : DefaultLifecycleObserver { override fun onStart(owner: LifecycleOwner) { initializePlayer(mediaPlayerListener) binding.videoView.onResume() } override fun onStop(owner: LifecycleOwner) { // 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(mediaPlayerListener: Player.Listener) { 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 : CustomViewTarget(binding.videoView) { override fun onLoadFailed(errorDrawable: Drawable?) { // Don't do anything } override fun onResourceCleared(placeholder: Drawable?) { view.defaultArtwork = null } override fun onResourceReady( resource: Drawable, transition: Transition? ) { view.defaultArtwork = resource } }.clearOnDetach() ) } } } private fun releasePlayer() { player?.let { savedSeekPosition = it.currentPosition it.release() player = null binding.videoView.player = null } } @SuppressLint("ClickableViewAccessibility") override fun setupMediaView( url: String, previewUrl: String?, description: String?, showingDescription: Boolean ) { binding.mediaDescriptionTextView.text = description binding.mediaDescriptionScrollView.visible(showingDescription) // Ensure the description is visible over the video binding.mediaDescriptionScrollView.elevation = binding.videoView.elevation + 1 binding.videoView.transitionName = url binding.videoView.requestFocus() if (requireArguments().getBoolean(ARG_START_POSTPONED_TRANSITION)) { mediaActivity.onBringUp() } } private fun hideToolbarAfterDelay(delayMilliseconds: Int) { pendingHideToolbar = true handler.postDelayed(hideToolbar, delayMilliseconds.toLong()) } override fun onToolbarVisibilityChange(visible: Boolean) { if (view == null) { return } isDescriptionVisible = showingDescription && visible val alpha = if (isDescriptionVisible) 1.0f else 0.0f if (isDescriptionVisible) { // If to be visible, need to make visible immediately and animate alpha binding.mediaDescriptionScrollView.alpha = 0.0f binding.mediaDescriptionScrollView.visible(isDescriptionVisible) } binding.mediaDescriptionScrollView.animate().alpha(alpha) .setListener(object : AnimatorListenerAdapter() { @SuppressLint("SyntheticAccessor") override fun onAnimationEnd(animation: Animator) { view ?: return binding.mediaDescriptionScrollView.visible(isDescriptionVisible) animation.removeListener(this) } }) .start() // media3 controls bar if (visible) { binding.videoView.showController() } else { 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() { } companion object { private const val TAG = "ViewVideoFragment" private const val TOOLBAR_HIDE_DELAY_MS = 4_000 private const val SEEK_POSITION = "seekPosition" } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/interfaces/AccountActionListener.kt ================================================ /* 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.interfaces 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, accountIdRequestingFollow: String, position: Int) } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/interfaces/AccountSelectionListener.kt ================================================ /* Copyright 2019 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.interfaces import com.keylesspalace.tusky.db.entity.AccountEntity interface AccountSelectionListener { fun onAccountSelected(account: AccountEntity) } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/interfaces/ActionButtonActivity.java ================================================ /* 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.interfaces; import androidx.annotation.Nullable; import com.google.android.material.floatingactionbutton.FloatingActionButton; public interface ActionButtonActivity { /* return the ActionButton of the Activity to hide or show it on scroll */ @Nullable FloatingActionButton getActionButton(); } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/interfaces/HashtagActionListener.kt ================================================ package com.keylesspalace.tusky.interfaces interface HashtagActionListener { fun unfollow(tagName: String, position: Int) fun viewTag(tagName: String) fun copyTagName(tagName: String) } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/interfaces/LinkListener.kt ================================================ /* 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.interfaces interface LinkListener { fun onViewTag(tag: String) fun onViewAccount(id: String) fun onViewUrl(url: String) } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/interfaces/RefreshableFragment.kt ================================================ package com.keylesspalace.tusky.interfaces /** * Created by pandasoft (joelpyska1@gmail.com) on 04/04/2019. */ interface RefreshableFragment { /** * Call this method to refresh fragment content */ fun refreshContent() } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/interfaces/ReselectableFragment.kt ================================================ package com.keylesspalace.tusky.interfaces /** * Created by pandasoft (joelpyska1@gmail.com) on 04/04/2019. */ interface ReselectableFragment { /** * Call this method when tab reselected */ fun onReselect() } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/interfaces/StatusActionListener.kt ================================================ /* 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.interfaces import android.view.View import at.connyduck.sparkbutton.SparkButton import com.keylesspalace.tusky.entity.Status interface StatusActionListener : LinkListener { fun onReply(position: Int) /** * Reblog the post at [position] * @param visibility The visibility to use for the reblog, if the user has already chosen it, null otherwise * @param button Optional button to animate */ fun onReblog(reblog: Boolean, position: Int, visibility: Status.Visibility?, button: SparkButton? = null) /** * Favourite the post at [position] * @param button Optional button to animate */ fun onFavourite(favourite: Boolean, position: Int, button: SparkButton? = null) fun onBookmark(bookmark: Boolean, position: Int) fun onMore(view: View, position: Int) fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) fun onViewThread(position: Int) /** * Open reblog author for the status. * @param position At which position in the list status is located */ fun onOpenReblog(position: Int) fun onExpandedChange(expanded: Boolean, position: Int) fun onContentHiddenChange(isShowing: Boolean, position: Int) fun onLoadMore(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 onContentCollapsedChange(isCollapsed: Boolean, position: Int) /** * called when the reblog count has been clicked * @param position The position of the status in the list. */ fun onShowReblogs(position: Int) {} /** * called when the favourite count has been clicked * @param position The position of the status in the list. */ fun onShowFavs(position: Int) {} fun onVoteInPoll(position: Int, choices: List) fun onShowPollResults(position: Int) fun onShowEdits(position: Int) {} fun clearWarningAction(position: Int) fun onUntranslate(position: Int) } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/json/Guarded.kt ================================================ /* * 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.JsonQualifier @Retention(AnnotationRetention.RUNTIME) @JsonQualifier internal annotation class Guarded ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/json/GuardedAdapter.kt ================================================ /* * 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 android.util.Log import com.squareup.moshi.JsonAdapter 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 { reader.peekJson().use { delegate.fromJson(it) } } catch (e: Exception) { Log.w("GuardedAdapter", "failed to read json", e) null } finally { reader.skipValue() } } 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) } } } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/json/NotificationTypeAdapter.kt ================================================ /* * Copyright 2025 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.keylesspalace.tusky.entity.Notification import com.keylesspalace.tusky.entity.notificationTypeFromString import com.squareup.moshi.JsonAdapter import com.squareup.moshi.JsonReader import com.squareup.moshi.JsonWriter class NotificationTypeAdapter : JsonAdapter() { override fun fromJson(reader: JsonReader): Notification.Type { return notificationTypeFromString(reader.nextString()) } override fun toJson(writer: JsonWriter, value: Notification.Type?) { writer.value(value?.name) } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/network/ApiFactory.kt ================================================ package com.keylesspalace.tusky.network import com.keylesspalace.tusky.db.entity.AccountEntity import okhttp3.OkHttpClient import retrofit2.Retrofit import retrofit2.create /** * Creates an instance of an Api that will only make requests as the provided account. * @param account The account to make requests as. * When null, request without additional DOMAIN_HEADER will fail. * @param httpClient The OkHttpClient to make requests as * @param retrofit The Retrofit instance to derive the api from * @param scheme The scheme to use. Only used in tests. * @param port The port to use. Only used in tests. */ inline fun apiForAccount( account: AccountEntity?, httpClient: OkHttpClient, retrofit: Retrofit, scheme: String = "https://", port: Int? = null ): T { return retrofit.newBuilder() .apply { if (account != null) { baseUrl("$scheme${account.domain}${ if (port == null) "" else ":$port"}") } } .callFactory { originalRequest -> var request = originalRequest val domainHeader = originalRequest.header(MastodonApi.DOMAIN_HEADER) if (domainHeader != null) { request = originalRequest.newBuilder() .url( originalRequest.url.newBuilder().host(domainHeader).build() ) .removeHeader(MastodonApi.DOMAIN_HEADER) .build() } else if (account != null && request.url.host == account.domain) { request = request.newBuilder() .header("Authorization", "Bearer ${account.accessToken}") .build() } if (request.url.host == MastodonApi.PLACEHOLDER_DOMAIN) { FailingCall(request) } else { httpClient.newCall(request) } } .build() .create() } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/network/FailingCall.kt ================================================ /* 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 okhttp3.Call import okhttp3.Callback import okhttp3.Protocol import okhttp3.Request import okhttp3.Response import okhttp3.ResponseBody.Companion.toResponseBody import okio.Timeout class FailingCall(private val request: Request) : Call { private var isExecuted: Boolean = false override fun cancel() { } override fun clone(): Call { return FailingCall(request()) } override fun enqueue(responseCallback: Callback) { isExecuted = true responseCallback.onResponse(this, failingResponse()) } override fun execute(): Response { isExecuted = true return failingResponse() } override fun isCanceled(): Boolean = false override fun isExecuted(): Boolean = isExecuted override fun request(): Request = request override fun timeout(): Timeout { return Timeout.NONE } private fun failingResponse(): Response { return Response.Builder() .request(request) .code(400) .message("Bad Request") .protocol(Protocol.HTTP_1_1) .body("".toResponseBody()) .build() } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/network/FilterModel.kt ================================================ package com.keylesspalace.tusky.network import android.util.Log import at.connyduck.calladapter.networkresult.fold import at.connyduck.calladapter.networkresult.getOrElse import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository import com.keylesspalace.tusky.entity.Filter import com.keylesspalace.tusky.entity.FilterV1 import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.util.isHttpNotFound import com.keylesspalace.tusky.util.parseAsMastodonHtml import java.util.Date import java.util.regex.Pattern import javax.inject.Inject /** * One-stop for status filtering logic using Mastodon's filters. * * 1. You init with [init], this checks which filter version to use and compiles regex pattern if needed. * 2. You call [shouldFilterStatus] to figure out what to display when you load statuses. */ class FilterModel @Inject constructor( private val instanceInfoRepo: InstanceInfoRepository, private val api: MastodonApi ) { private var pattern: Pattern? = null private var v1 = false private lateinit var kind: Filter.Kind /** * @param kind the [Filter.Kind] that should be filtered * @return true when filters v1 have been loaded successfully and the currently shown posts may need to be filtered */ suspend fun init(kind: Filter.Kind): Boolean { this.kind = kind if (instanceInfoRepo.isFilterV2Supported()) { // nothing to do - Instance supports V2 so posts are filtered by the server return false } api.getFilters().fold( { instanceInfoRepo.saveFilterV2Support(true) return false }, { throwable -> if (throwable.isHttpNotFound()) { val filters = api.getFiltersV1().getOrElse { Log.w(TAG, "Failed to fetch filters", it) return false } this.v1 = true val activeFilters = filters.filter { filter -> filter.context.contains(kind.kind) } this.pattern = makeFilter(activeFilters) return activeFilters.isNotEmpty() } else { Log.e(TAG, "Error getting filters", throwable) return false } } ) } fun shouldFilterStatus(status: Status): Filter? { if (v1) { // Patterns are expensive and thread-safe, matchers are neither. val matcher = pattern?.matcher("") ?: return null if (status.poll?.options?.any { matcher.reset(it.title).find() } == true) { return Filter(context = listOf(kind), action = Filter.Action.HIDE) } val spoilerText = status.actionableStatus.spoilerText val attachmentsDescriptions = status.attachments.mapNotNull { it.description } return if ( matcher.reset(status.actionableStatus.content.parseAsMastodonHtml().toString()).find() || (spoilerText.isNotEmpty() && matcher.reset(spoilerText).find()) || (attachmentsDescriptions.isNotEmpty() && matcher.reset(attachmentsDescriptions.joinToString("\n")).find()) ) { return Filter(context = listOf(kind), action = Filter.Action.HIDE) } else { null } } return status.getApplicableFilter(kind) } private fun filterToRegexToken(filter: FilterV1): String? { val phrase = filter.phrase val quotedPhrase = Pattern.quote(phrase) return if (filter.wholeWord && ALPHANUMERIC.matcher(phrase).matches()) { "(^|\\W)$quotedPhrase($|\\W)" } else { quotedPhrase } } private fun makeFilter(filters: List): Pattern? { val now = Date() val nonExpiredFilters = filters.filter { it.expiresAt?.before(now) != true } if (nonExpiredFilters.isEmpty()) return null val tokens = nonExpiredFilters .asSequence() .map { filterToRegexToken(it) } .joinToString("|") return Pattern.compile(tokens, Pattern.CASE_INSENSITIVE) } companion object { private const val TAG = "FilterModel" private val ALPHANUMERIC = Pattern.compile("^\\w+$") } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt ================================================ /* 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 at.connyduck.calladapter.networkresult.NetworkResult import com.keylesspalace.tusky.components.filters.FilterExpiration import com.keylesspalace.tusky.entity.AccessToken import com.keylesspalace.tusky.entity.Account import com.keylesspalace.tusky.entity.Announcement import com.keylesspalace.tusky.entity.AppCredentials import com.keylesspalace.tusky.entity.Attachment import com.keylesspalace.tusky.entity.Conversation import com.keylesspalace.tusky.entity.DeletedStatus import com.keylesspalace.tusky.entity.Emoji import com.keylesspalace.tusky.entity.Filter 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 import com.keylesspalace.tusky.entity.NewStatus import com.keylesspalace.tusky.entity.Notification import com.keylesspalace.tusky.entity.NotificationPolicy import com.keylesspalace.tusky.entity.NotificationRequest import com.keylesspalace.tusky.entity.NotificationSubscribeResult import com.keylesspalace.tusky.entity.Poll import com.keylesspalace.tusky.entity.Relationship import com.keylesspalace.tusky.entity.ScheduledStatus import com.keylesspalace.tusky.entity.ScheduledStatusReply import com.keylesspalace.tusky.entity.SearchResult import com.keylesspalace.tusky.entity.Status 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 okhttp3.MultipartBody import okhttp3.RequestBody import retrofit2.Response import retrofit2.http.Body import retrofit2.http.DELETE import retrofit2.http.Field import retrofit2.http.FieldMap import retrofit2.http.FormUrlEncoded import retrofit2.http.GET import retrofit2.http.HTTP import retrofit2.http.Header import retrofit2.http.Multipart import retrofit2.http.PATCH import retrofit2.http.POST import retrofit2.http.PUT import retrofit2.http.Part import retrofit2.http.PartMap import retrofit2.http.Path import retrofit2.http.Query /** * for documentation of the Mastodon REST API see https://docs.joinmastodon.org/api/ */ @JvmSuppressWildcards interface MastodonApi { companion object { const val ENDPOINT_AUTHORIZE = "oauth/authorize" const val DOMAIN_HEADER = "domain" const val PLACEHOLDER_DOMAIN = "dummy.placeholder" } @GET("/api/v1/custom_emojis") suspend fun getCustomEmojis(): NetworkResult> @GET("api/v1/instance") 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> @GET("api/v1/timelines/home") @Throws(Exception::class) suspend fun homeTimeline( @Query("max_id") maxId: String? = null, @Query("min_id") minId: String? = null, @Query("since_id") sinceId: String? = null, @Query("limit") limit: Int? = null ): Response> @GET("api/v1/timelines/public") suspend fun publicTimeline( @Query("local") local: Boolean? = null, @Query("max_id") maxId: String? = null, @Query("since_id") sinceId: String? = null, @Query("limit") limit: Int? = null ): Response> @GET("api/v1/timelines/tag/{hashtag}") suspend fun hashtagTimeline( @Path("hashtag") hashtag: String, @Query("any[]") any: List?, @Query("local") local: Boolean?, @Query("max_id") maxId: String?, @Query("since_id") sinceId: String?, @Query("limit") limit: Int? ): Response> @GET("api/v1/timelines/list/{listId}") suspend fun listTimeline( @Path("listId") listId: String, @Query("max_id") maxId: String?, @Query("since_id") sinceId: String?, @Query("limit") limit: Int? ): Response> @GET("api/v1/notifications") @Throws(Exception::class) suspend fun notifications( /** Return results older than this ID */ @Query("max_id") maxId: String? = null, /** Return results newer than this ID */ @Query("since_id") sinceId: String? = null, /** Return results immediately newer than this ID */ @Query("min_id") minId: String? = null, /** Maximum number of results to return. Defaults to 15, max is 30 */ @Query("limit") limit: Int? = null, /** Types to excludes from the results */ @Query("exclude_types[]") excludes: Set? = null, /** Return only notifications received from the specified account. */ @Query("account_id") accountId: String? = null ): Response> /** Fetch a single notification */ @GET("api/v1/notifications/{id}") suspend fun notification(@Path("id") id: String): Response @GET("api/v1/markers") suspend fun markersWithAuth( @Header("Authorization") auth: String, @Header(DOMAIN_HEADER) domain: String, @Query("timeline[]") timelines: List ): Map @FormUrlEncoded @POST("api/v1/markers") suspend fun updateMarkersWithAuth( @Header("Authorization") auth: String, @Header(DOMAIN_HEADER) domain: String, @Field("home[last_read_id]") homeLastReadId: String? = null, @Field("notifications[last_read_id]") notificationsLastReadId: String? = null ): NetworkResult @GET("api/v1/notifications") suspend fun notificationsWithAuth( @Header("Authorization") auth: String, @Header(DOMAIN_HEADER) domain: String, /** Return results immediately newer than this ID */ @Query("min_id") minId: String? ): Response> @POST("api/v1/notifications/clear") suspend fun clearNotifications(): NetworkResult @FormUrlEncoded @PUT("api/v1/media/{mediaId}") suspend fun updateMedia( @Path("mediaId") mediaId: String, @Field("description") description: String?, @Field("focus") focus: String? ): NetworkResult @GET("api/v1/media/{mediaId}") suspend fun getMedia(@Path("mediaId") mediaId: String): Response @POST("api/v1/statuses") suspend fun createStatus( @Header("Authorization") auth: String, @Header(DOMAIN_HEADER) domain: String, @Header("Idempotency-Key") idempotencyKey: String, @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 @PUT("api/v1/statuses/{id}") suspend fun editStatus( @Path("id") statusId: String, @Header("Authorization") auth: String, @Header(DOMAIN_HEADER) domain: String, @Header("Idempotency-Key") idempotencyKey: String, @Body editedStatus: NewStatus ): NetworkResult @GET("api/v1/statuses/{id}/source") suspend fun statusSource(@Path("id") statusId: String): NetworkResult @GET("api/v1/statuses/{id}/context") suspend fun statusContext(@Path("id") statusId: String): NetworkResult @GET("api/v1/statuses/{id}/history") suspend fun statusEdits(@Path("id") statusId: String): NetworkResult> @GET("api/v1/statuses/{id}/reblogged_by") suspend fun statusRebloggedBy( @Path("id") statusId: String, @Query("max_id") maxId: String? ): Response> @GET("api/v1/statuses/{id}/favourited_by") suspend fun statusFavouritedBy( @Path("id") statusId: String, @Query("max_id") maxId: String? ): Response> @DELETE("api/v1/statuses/{id}") suspend fun deleteStatus( @Path("id") statusId: String, @Query("delete_media") deleteMedia: Boolean? = null ): NetworkResult @FormUrlEncoded @POST("api/v1/statuses/{id}/reblog") suspend fun reblogStatus(@Path("id") statusId: String, @Field("visibility") visibility: String?): NetworkResult @POST("api/v1/statuses/{id}/unreblog") suspend fun unreblogStatus(@Path("id") statusId: String): NetworkResult @POST("api/v1/statuses/{id}/favourite") suspend fun favouriteStatus(@Path("id") statusId: String): NetworkResult @POST("api/v1/statuses/{id}/unfavourite") suspend fun unfavouriteStatus(@Path("id") statusId: String): NetworkResult @POST("api/v1/statuses/{id}/bookmark") suspend fun bookmarkStatus(@Path("id") statusId: String): NetworkResult @POST("api/v1/statuses/{id}/unbookmark") suspend fun unbookmarkStatus(@Path("id") statusId: String): NetworkResult @POST("api/v1/statuses/{id}/pin") suspend fun pinStatus(@Path("id") statusId: String): NetworkResult @POST("api/v1/statuses/{id}/unpin") suspend fun unpinStatus(@Path("id") statusId: String): NetworkResult @POST("api/v1/statuses/{id}/mute") suspend fun muteConversation(@Path("id") statusId: String): NetworkResult @POST("api/v1/statuses/{id}/unmute") suspend fun unmuteConversation(@Path("id") statusId: String): NetworkResult @GET("api/v1/scheduled_statuses") suspend fun scheduledStatuses( @Query("limit") limit: Int? = null, @Query("max_id") maxId: String? = null ): NetworkResult> @DELETE("api/v1/scheduled_statuses/{id}") suspend fun deleteScheduledStatus( @Path("id") scheduledStatusId: String ): NetworkResult @GET("api/v1/accounts/verify_credentials") suspend fun accountVerifyCredentials( @Header(DOMAIN_HEADER) domain: String? = null, @Header("Authorization") auth: String? = null ): NetworkResult @FormUrlEncoded @PATCH("api/v1/accounts/update_credentials") suspend fun accountUpdateSource( @Field("source[privacy]") privacy: String?, @Field("source[sensitive]") sensitive: Boolean?, @Field("source[language]") language: String? ): NetworkResult @Multipart @PATCH("api/v1/accounts/update_credentials") suspend fun accountUpdateCredentials( @Part(value = "display_name") displayName: RequestBody?, @Part(value = "note") note: RequestBody?, @Part(value = "locked") locked: RequestBody?, @Part avatar: MultipartBody.Part?, @Part header: MultipartBody.Part?, @PartMap fields: Map ): NetworkResult @GET("api/v1/accounts/search") suspend fun searchAccounts( @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 /** * Method to fetch statuses for the specified account. * @param accountId ID for account for which statuses will be requested * @param maxId Only statuses with ID less than maxID will be returned * @param sinceId Only statuses with ID bigger than sinceID will be returned * @param limit Limit returned statuses (current API limits: default - 20, max - 40) * @param excludeReplies only return statuses that are no replies * @param onlyMedia only return statuses that have media attached */ @GET("api/v1/accounts/{id}/statuses") suspend fun accountStatuses( @Path("id") accountId: String, @Query("max_id") maxId: String? = null, @Query("since_id") sinceId: String? = null, @Query("limit") limit: Int? = null, @Query("exclude_replies") excludeReplies: Boolean? = null, @Query("only_media") onlyMedia: Boolean? = null, @Query("pinned") pinned: Boolean? = null ): Response> @GET("api/v1/accounts/{id}/followers") suspend fun accountFollowers( @Path("id") accountId: String, @Query("max_id") maxId: String? ): Response> @GET("api/v1/accounts/{id}/following") suspend fun accountFollowing( @Path("id") accountId: String, @Query("max_id") maxId: String? ): Response> @FormUrlEncoded @POST("api/v1/accounts/{id}/follow") suspend fun followAccount( @Path("id") accountId: String, @Field("reblogs") showReblogs: Boolean? = null, @Field("notify") notify: Boolean? = null ): NetworkResult @POST("api/v1/accounts/{id}/unfollow") suspend fun unfollowAccount(@Path("id") accountId: String): NetworkResult @POST("api/v1/accounts/{id}/block") suspend fun blockAccount(@Path("id") accountId: String): NetworkResult @POST("api/v1/accounts/{id}/unblock") suspend fun unblockAccount(@Path("id") accountId: String): NetworkResult @FormUrlEncoded @POST("api/v1/accounts/{id}/mute") suspend fun muteAccount( @Path("id") accountId: String, @Field("notifications") notifications: Boolean? = null, @Field("duration") duration: Int? = null ): NetworkResult @POST("api/v1/accounts/{id}/unmute") suspend fun unmuteAccount(@Path("id") accountId: String): NetworkResult @GET("api/v1/accounts/relationships") suspend fun relationships( @Query("id[]") accountIds: List ): NetworkResult> @POST("api/v1/pleroma/accounts/{id}/subscribe") suspend fun subscribeAccount(@Path("id") accountId: String): NetworkResult @POST("api/v1/pleroma/accounts/{id}/unsubscribe") suspend fun unsubscribeAccount(@Path("id") accountId: String): NetworkResult @GET("api/v1/blocks") suspend fun blocks(@Query("max_id") maxId: String? = null): Response> @GET("api/v1/mutes") suspend fun mutes(@Query("max_id") maxId: String? = null): Response> @GET("api/v1/domain_blocks") suspend fun domainBlocks( @Query("max_id") maxId: String? = null, @Query("since_id") sinceId: String? = null, @Query("limit") limit: Int? = null ): Response> @FormUrlEncoded @POST("api/v1/domain_blocks") suspend fun blockDomain(@Field("domain") domain: String): NetworkResult @FormUrlEncoded // @DELETE doesn't support fields @HTTP(method = "DELETE", path = "api/v1/domain_blocks", hasBody = true) suspend fun unblockDomain(@Field("domain") domain: String): NetworkResult @GET("api/v1/favourites") suspend fun favourites( @Query("max_id") maxId: String?, @Query("since_id") sinceId: String?, @Query("limit") limit: Int? ): Response> @GET("api/v1/bookmarks") suspend fun bookmarks( @Query("max_id") maxId: String?, @Query("since_id") sinceId: String?, @Query("limit") limit: Int? ): Response> @GET("api/v1/follow_requests") suspend fun followRequests(@Query("max_id") maxId: String?): Response> @POST("api/v1/follow_requests/{id}/authorize") suspend fun authorizeFollowRequest(@Path("id") accountId: String): NetworkResult @POST("api/v1/follow_requests/{id}/reject") suspend fun rejectFollowRequest(@Path("id") accountId: String): NetworkResult @FormUrlEncoded @POST("api/v1/apps") suspend fun authenticateApp( @Header(DOMAIN_HEADER) domain: String, @Field("client_name") clientName: String, @Field("redirect_uris") redirectUris: String, @Field("scopes") scopes: String, @Field("website") website: String ): NetworkResult @FormUrlEncoded @POST("oauth/token") suspend fun fetchOAuthToken( @Header(DOMAIN_HEADER) domain: String, @Field("client_id") clientId: String, @Field("client_secret") clientSecret: String, @Field("redirect_uri") redirectUri: String, @Field("code") code: String, @Field("grant_type") grantType: String ): NetworkResult @FormUrlEncoded @POST("oauth/revoke") suspend fun revokeOAuthToken( @Field("client_id") clientId: String, @Field("client_secret") clientSecret: String, @Field("token") token: String ): NetworkResult @GET("/api/v1/lists") suspend fun getLists(): NetworkResult> @GET("/api/v1/accounts/{id}/lists") suspend fun getListsIncludesAccount( @Path("id") accountId: String ): NetworkResult> @FormUrlEncoded @POST("api/v1/lists") suspend fun createList( @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("exclusive") exclusive: Boolean?, @Field("replies_policy") replyPolicy: String ): NetworkResult @DELETE("api/v1/lists/{listId}") suspend fun deleteList(@Path("listId") listId: String): NetworkResult @GET("api/v1/lists/{listId}/accounts") suspend fun getAccountsInList( @Path("listId") listId: String, @Query("limit") limit: Int ): NetworkResult> @FormUrlEncoded // @DELETE doesn't support fields @HTTP(method = "DELETE", path = "api/v1/lists/{listId}/accounts", hasBody = true) suspend fun deleteAccountFromList( @Path("listId") listId: String, @Field("account_ids[]") accountIds: List ): NetworkResult @FormUrlEncoded @POST("api/v1/lists/{listId}/accounts") suspend fun addAccountToList( @Path("listId") listId: String, @Field("account_ids[]") accountIds: List ): NetworkResult @GET("/api/v1/conversations") suspend fun getConversations( @Query("max_id") maxId: String? = null, @Query("limit") limit: Int? = null ): Response> @DELETE("/api/v1/conversations/{id}") suspend fun deleteConversation(@Path("id") conversationId: String) @FormUrlEncoded @POST("api/v1/filters") suspend fun createFilterV1( @Field("phrase") phrase: String, @Field("context[]") context: List, @Field("irreversible") irreversible: Boolean?, @Field("whole_word") wholeWord: Boolean?, @Field("expires_in") expiresIn: FilterExpiration? ): NetworkResult @FormUrlEncoded @PUT("api/v1/filters/{id}") suspend fun updateFilterV1( @Path("id") id: String, @Field("phrase") phrase: String, @Field("context[]") context: List, @Field("irreversible") irreversible: Boolean?, @Field("whole_word") wholeWord: Boolean?, @Field("expires_in") expiresIn: FilterExpiration? ): NetworkResult @DELETE("api/v1/filters/{id}") suspend fun deleteFilterV1(@Path("id") id: String): NetworkResult @FormUrlEncoded @POST("api/v2/filters") suspend fun createFilter( @Field("title") title: String, @Field("context[]") context: List, @Field("filter_action") filterAction: Filter.Action, @Field("expires_in") expiresIn: FilterExpiration? ): NetworkResult @FormUrlEncoded @PUT("api/v2/filters/{id}") suspend fun updateFilter( @Path("id") id: String, @Field("title") title: String? = null, @Field("context[]") context: List? = null, @Field("filter_action") filterAction: Filter.Action? = null, @Field("expires_in") expires: FilterExpiration? = null ): NetworkResult @DELETE("api/v2/filters/{id}") suspend fun deleteFilter(@Path("id") id: String): NetworkResult @FormUrlEncoded @POST("api/v2/filters/{filterId}/keywords") suspend fun addFilterKeyword( @Path("filterId") filterId: String, @Field("keyword") keyword: String, @Field("whole_word") wholeWord: Boolean ): NetworkResult @FormUrlEncoded @PUT("api/v2/filters/keywords/{keywordId}") suspend fun updateFilterKeyword( @Path("keywordId") keywordId: String, @Field("keyword") keyword: String, @Field("whole_word") wholeWord: Boolean ): NetworkResult @DELETE("api/v2/filters/keywords/{keywordId}") suspend fun deleteFilterKeyword( @Path("keywordId") keywordId: String ): NetworkResult @FormUrlEncoded @POST("api/v1/polls/{id}/votes") suspend fun voteInPoll( @Path("id") id: String, @Field("choices[]") choices: List ): NetworkResult @GET("api/v1/announcements") suspend fun announcements(): NetworkResult> @POST("api/v1/announcements/{id}/dismiss") 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 @DELETE("api/v1/announcements/{id}/reactions/{name}") suspend fun removeAnnouncementReaction( @Path("id") announcementId: String, @Path("name") name: String ): NetworkResult @FormUrlEncoded @POST("api/v1/reports") suspend fun report( @Field("account_id") accountId: String, @Field("status_ids[]") statusIds: List, @Field("comment") comment: String, @Field("forward") isNotifyRemote: Boolean? ): NetworkResult @GET("api/v1/accounts/{id}/statuses") 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? ): NetworkResult> @GET("api/v2/search") suspend fun search( @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 ): NetworkResult @FormUrlEncoded @POST("api/v1/accounts/{id}/note") suspend fun updateAccountNote( @Path("id") accountId: String, @Field("comment") note: String ): NetworkResult @GET("api/v1/push/subscription") suspend fun pushNotificationSubscription( @Header("Authorization") auth: String, @Header(DOMAIN_HEADER) domain: String ): NetworkResult @FormUrlEncoded @POST("api/v1/push/subscription") suspend fun subscribePushNotifications( @Header("Authorization") auth: String, @Header(DOMAIN_HEADER) domain: String, @Field("subscription[endpoint]") endPoint: String, @Field("subscription[keys][p256dh]") keysP256DH: String, @Field("subscription[keys][auth]") keysAuth: String, // The "data[alerts][]" fields to enable / disable notifications // Should be generated dynamically from all the available notification // types defined in [com.keylesspalace.tusky.entities.Notification.Types] @FieldMap data: Map ): NetworkResult @FormUrlEncoded @PUT("api/v1/push/subscription") suspend fun updatePushNotificationSubscription( @Header("Authorization") auth: String, @Header(DOMAIN_HEADER) domain: String, @FieldMap data: Map ): NetworkResult @DELETE("api/v1/push/subscription") suspend fun unsubscribePushNotifications( @Header("Authorization") auth: String, @Header(DOMAIN_HEADER) domain: String ): NetworkResult @GET("api/v1/tags/{name}") suspend fun tag(@Path("name") name: String): NetworkResult @GET("api/v1/followed_tags") suspend fun followedTags( @Query("min_id") minId: String? = null, @Query("since_id") sinceId: String? = null, @Query("max_id") maxId: String? = null, @Query("limit") limit: Int? = null ): Response> @POST("api/v1/tags/{name}/follow") suspend fun followTag(@Path("name") name: String): NetworkResult @POST("api/v1/tags/{name}/unfollow") suspend fun unfollowTag(@Path("name") name: String): NetworkResult @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 @GET("api/v2/notifications/policy") suspend fun notificationPolicy(): NetworkResult @FormUrlEncoded @PATCH("api/v2/notifications/policy") suspend fun updateNotificationPolicy( @Field("for_not_following") forNotFollowing: String?, @Field("for_not_followers") forNotFollowers: String?, @Field("for_new_accounts") forNewAccounts: String?, @Field("for_private_mentions") forPrivateMentions: String?, @Field("for_limited_accounts") forLimitedAccounts: String? ): NetworkResult @GET("api/v1/notifications/requests") suspend fun getNotificationRequests( @Query("max_id") maxId: String? = null, @Query("min_id") minId: String? = null, @Query("since_id") sinceId: String? = null, @Query("limit") limit: Int? = null ): Response> @POST("api/v1/notifications/requests/{id}/accept") suspend fun acceptNotificationRequest(@Path("id") notificationId: String): NetworkResult @POST("api/v1/notifications/requests/{id}/dismiss") suspend fun dismissNotificationRequest(@Path("id") notificationId: String): NetworkResult } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/network/MediaUploadApi.kt ================================================ package com.keylesspalace.tusky.network import com.keylesspalace.tusky.entity.MediaUploadResult import okhttp3.MultipartBody import retrofit2.Response import retrofit2.http.Multipart import retrofit2.http.POST import retrofit2.http.Part /** endpoints defined in this interface will be called with a higher timeout than usual * which is necessary for media uploads to succeed on some servers */ interface MediaUploadApi { @Multipart @POST("api/v2/media") suspend fun uploadMedia( @Part file: MultipartBody.Part, @Part description: MultipartBody.Part? = null, @Part focus: MultipartBody.Part? = null ): Response } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/network/UriRequestBody.kt ================================================ /* * 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) } } } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/pager/ImagePagerAdapter.kt ================================================ package com.keylesspalace.tusky.pager import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentActivity import com.keylesspalace.tusky.ViewMediaAdapter import com.keylesspalace.tusky.entity.Attachment import com.keylesspalace.tusky.fragment.ViewMediaFragment import java.lang.ref.WeakReference class ImagePagerAdapter( activity: FragmentActivity, private val attachments: List, private val initialPosition: Int ) : ViewMediaAdapter(activity) { private var didTransition = false private val fragments = MutableList?>(attachments.size) { null } override fun getItemCount() = attachments.size override fun createFragment(position: Int): Fragment { if (position >= 0 && position < attachments.size) { // Fragment should not wait for or start transition if it already happened but we // instantiate the same fragment again, e.g. open the first photo, scroll to the // forth photo and then back to the first. The first fragment will try to start the // transition and wait until it's over and it will never take place. val fragment = ViewMediaFragment.newInstance( attachment = attachments[position], shouldStartPostponedTransition = !didTransition && position == initialPosition ) fragments[position] = WeakReference(fragment) return fragment } else { throw IllegalStateException() } } override fun onTransitionEnd(position: Int) { this.didTransition = true fragments[position]?.get()?.onTransitionEnd() } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/pager/MainPagerAdapter.kt ================================================ /* 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.pager import androidx.fragment.app.Fragment 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 ) { override fun createFragment(position: Int): Fragment { val tab = tabs[position] return tab.fragment(tab.arguments) } override fun getItemCount() = tabs.size } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/pager/SingleImagePagerAdapter.kt ================================================ package com.keylesspalace.tusky.pager import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentActivity import com.keylesspalace.tusky.ViewMediaAdapter import com.keylesspalace.tusky.fragment.ViewMediaFragment class SingleImagePagerAdapter( activity: FragmentActivity, private val imageUrl: String ) : ViewMediaAdapter(activity) { override fun createFragment(position: Int): Fragment { return if (position == 0) { ViewMediaFragment.newSingleImageInstance(imageUrl) } else { throw IllegalStateException() } } override fun getItemCount() = 1 override fun onTransitionEnd(position: Int) { } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/receiver/NotificationBlockStateBroadcastReceiver.kt ================================================ /* Copyright 2022 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.receiver import android.app.NotificationManager import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.os.Build import com.keylesspalace.tusky.components.systemnotifications.NotificationService import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.di.ApplicationScope import com.keylesspalace.tusky.network.MastodonApi import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @AndroidEntryPoint class NotificationBlockStateBroadcastReceiver : BroadcastReceiver() { @Inject lateinit var mastodonApi: MastodonApi @Inject lateinit var accountManager: AccountManager @Inject lateinit var notificationService: NotificationService @Inject @ApplicationScope lateinit var externalScope: CoroutineScope override fun onReceive(context: Context, intent: Intent) { if (Build.VERSION.SDK_INT < 28) return if (!notificationService.arePushNotificationsAvailable()) return val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager val accountIdentifier = when (intent.action) { NotificationManager.ACTION_NOTIFICATION_CHANNEL_BLOCK_STATE_CHANGED -> { val channelId = intent.getStringExtra(NotificationManager.EXTRA_NOTIFICATION_CHANNEL_ID) nm.getNotificationChannel(channelId).group } NotificationManager.ACTION_NOTIFICATION_CHANNEL_GROUP_BLOCK_STATE_CHANGED -> { intent.getStringExtra(NotificationManager.EXTRA_NOTIFICATION_CHANNEL_GROUP_ID) } else -> null } ?: return accountManager.getAccountByIdentifier(accountIdentifier)?.let { account -> if (account.isPushNotificationsEnabled()) { externalScope.launch { notificationService.updatePushSubscription(account) } } } } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/receiver/SendStatusBroadcastReceiver.kt ================================================ /* Copyright 2018 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.receiver import android.annotation.SuppressLint import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.util.Log import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.app.RemoteInput import com.keylesspalace.tusky.R import com.keylesspalace.tusky.components.systemnotifications.NotificationChannelData import com.keylesspalace.tusky.components.systemnotifications.NotificationService import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.service.SendStatusService import com.keylesspalace.tusky.service.StatusToSend import com.keylesspalace.tusky.util.getSerializableExtraCompat import com.keylesspalace.tusky.util.randomAlphanumericString import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject @AndroidEntryPoint class SendStatusBroadcastReceiver : BroadcastReceiver() { @Inject lateinit var accountManager: AccountManager @SuppressLint("MissingPermission") override fun onReceive(context: Context, intent: Intent) { if (intent.action == NotificationService.REPLY_ACTION) { val serverNotificationId = intent.getStringExtra(NotificationService.KEY_SERVER_NOTIFICATION_ID) val senderId = intent.getLongExtra(NotificationService.KEY_SENDER_ACCOUNT_ID, -1) val senderIdentifier = intent.getStringExtra( NotificationService.KEY_SENDER_ACCOUNT_IDENTIFIER )!! val senderFullName = intent.getStringExtra( NotificationService.KEY_SENDER_ACCOUNT_FULL_NAME ) val citedStatusId = intent.getStringExtra(NotificationService.KEY_CITED_STATUS_ID) val visibility = intent.getSerializableExtraCompat(NotificationService.KEY_VISIBILITY)!! val spoiler = intent.getStringExtra(NotificationService.KEY_SPOILER).orEmpty() val mentions = intent.getStringArrayExtra(NotificationService.KEY_MENTIONS).orEmpty() val account = accountManager.getAccountById(senderId) val notificationManager = NotificationManagerCompat.from(context) val message = getReplyMessage(intent) if (account == null) { Log.w(TAG, "Account \"$senderId\" not found in database. Aborting quick reply!") val notification = NotificationCompat.Builder( context, NotificationChannelData.MENTION.getChannelId(senderIdentifier) ) .setSmallIcon(R.drawable.tusky_notification_icon) .setColor(context.getColor(R.color.tusky_blue)) .setGroup(senderFullName) .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() notificationManager.notify(serverNotificationId, senderId.toInt(), notification) } else { val text = mentions.joinToString(" ", postfix = " ") { "@$it" } + message.toString() val sendIntent = SendStatusService.sendStatusIntent( context, StatusToSend( text = text, warningText = spoiler, visibility = visibility.stringValue, sensitive = false, media = emptyList(), scheduledAt = null, inReplyToId = citedStatusId, poll = null, replyingStatusContent = null, replyingStatusAuthorUsername = null, accountId = account.id, draftId = -1, idempotencyKey = randomAlphanumericString(16), retries = 0, language = null, statusId = null ) ) context.startService(sendIntent) // 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, NotificationChannelData.MENTION.getChannelId(senderIdentifier) ) .setSmallIcon(R.drawable.tusky_notification_icon) .setColor(context.getColor(R.color.notification_color)) .setGroup(senderFullName) .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() notificationManager.notify(serverNotificationId, senderId.toInt(), notification) } } } private fun getReplyMessage(intent: Intent): CharSequence { val remoteInput = RemoteInput.getResultsFromIntent(intent) return remoteInput?.getCharSequence(NotificationService.KEY_REPLY, "") ?: "" } companion object { const val TAG = "SendStatusBroadcastReceiver" } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/receiver/UnifiedPushBroadcastReceiver.kt ================================================ /* Copyright 2022 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.receiver import android.content.Context import android.util.Log import com.keylesspalace.tusky.components.systemnotifications.NotificationService import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.di.ApplicationScope import com.keylesspalace.tusky.network.MastodonApi import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import org.unifiedpush.android.connector.MessagingReceiver @AndroidEntryPoint class UnifiedPushBroadcastReceiver : MessagingReceiver() { @Inject lateinit var accountManager: AccountManager @Inject lateinit var mastodonApi: MastodonApi @Inject lateinit var notificationService: NotificationService @Inject @ApplicationScope lateinit var applicationScope: CoroutineScope override fun onMessage(context: Context, message: ByteArray, instance: String) { Log.d(TAG, "New message received for account $instance: #${message.size}") val account = accountManager.getAccountById(instance.toLong()) account?.let { notificationService.fetchNotificationsOnPushMessage(it) } } override fun onNewEndpoint(context: Context, endpoint: String, instance: String) { Log.d(TAG, "Endpoint available for account $instance: $endpoint") accountManager.getAccountById(instance.toLong())?.let { applicationScope.launch { notificationService.registerPushEndpoint(it, endpoint) } } } override fun onRegistrationFailed(context: Context, instance: String) = Unit override fun onUnregistered(context: Context, instance: String) { 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 // TODO its not: this is the Mastodon side and should be done (unregistered) applicationScope.launch { notificationService.unregisterPushEndpoint(it) } } } companion object { const val TAG = "UnifiedPushBroadcastReceiver" } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/service/SendStatusService.kt ================================================ /* 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 import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent import android.app.Service import android.content.ClipData import android.content.ClipDescription import android.content.Context import android.content.Intent import android.os.Build import android.os.IBinder import android.os.Parcelable import android.util.Log import androidx.annotation.StringRes import androidx.core.app.NotificationCompat import androidx.core.app.ServiceCompat 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.StatusScheduledEvent import com.keylesspalace.tusky.components.compose.MediaUploader import com.keylesspalace.tusky.components.compose.UploadEvent import com.keylesspalace.tusky.components.drafts.DraftHelper import com.keylesspalace.tusky.db.AccountManager 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.ScheduledStatusReply import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.util.getParcelableExtraCompat import com.keylesspalace.tusky.util.unsafeLazy import dagger.hilt.android.AndroidEntryPoint 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 import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.parcelize.Parcelize import retrofit2.HttpException @AndroidEntryPoint class SendStatusService : Service() { @Inject lateinit var mastodonApi: MastodonApi @Inject lateinit var accountManager: AccountManager @Inject lateinit var eventHub: EventHub @Inject lateinit var draftHelper: DraftHelper @Inject lateinit var mediaUploader: MediaUploader private val supervisorJob = SupervisorJob() private val serviceScope = CoroutineScope(Dispatchers.Main + supervisorJob) private val statusesToSend = ConcurrentHashMap() private val sendJobs = ConcurrentHashMap() private val notificationManager by unsafeLazy { getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager } override fun onBind(intent: Intent): IBinder? = null override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int { if (intent.hasExtra(KEY_STATUS)) { val statusToSend: StatusToSend = intent.getParcelableExtraCompat(KEY_STATUS) ?: 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 ) notificationManager.createNotificationChannel(channel) } var notificationText = statusToSend.warningText if (notificationText.isBlank()) { notificationText = statusToSend.text } val builder = NotificationCompat.Builder(this, CHANNEL_ID) .setSmallIcon(R.drawable.tusky_notification_icon) .setContentTitle(getString(R.string.send_post_notification_title)) .setContentText(notificationText) .setProgress(1, 0, true) .setOngoing(true) .setColor(getColor(R.color.notification_color)) .addAction( 0, getString(android.R.string.cancel), cancelSendingIntent(sendingNotificationId) ) if (statusesToSend.isEmpty() || Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_DETACH) startForeground(sendingNotificationId, builder.build()) } else { notificationManager.notify(sendingNotificationId, builder.build()) } statusesToSend[sendingNotificationId] = statusToSend sendStatus(sendingNotificationId--) } 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 // when account == null, user has logged out, cancel sending val account = accountManager.getAccountById(statusToSend.accountId) if (account == null) { statusesToSend.remove(statusId) notificationManager.cancel(statusId) stopSelfWhenDone() return } statusToSend.retries++ sendJobs[statusId] = serviceScope.launch { // first, wait for media uploads to finish val media = statusToSend.media.map { mediaItem -> if (mediaItem.id == null) { when (val uploadState = mediaUploader.getMediaUploadState(mediaItem.localId)) { is UploadEvent.FinishedEvent -> mediaItem.copy(id = uploadState.mediaId, processed = uploadState.processed) is UploadEvent.ErrorEvent -> { Log.w(TAG, "failed uploading media", uploadState.error) failSending(statusId) stopSelfWhenDone() return@launch } } } else { mediaItem } } // then wait until server finished processing the media try { var mediaCheckRetries = 0 while (media.any { mediaItem -> !mediaItem.processed }) { delay(1000L * mediaCheckRetries) media.forEach { mediaItem -> if (!mediaItem.processed) { when (mastodonApi.getMedia(mediaItem.id!!).code()) { 200 -> mediaItem.processed = true // success 206 -> { } // media is still being processed, continue checking else -> { // some kind of server error, retrying probably doesn't make sense failSending(statusId) stopSelfWhenDone() return@launch } } } } mediaCheckRetries++ } } catch (e: Exception) { Log.w(TAG, "failed getting media status", e) retrySending(statusId) return@launch } val isNew = statusToSend.statusId == null if (isNew) { media.forEach { mediaItem -> if (mediaItem.processed && (mediaItem.description != null || mediaItem.focus != null)) { mastodonApi.updateMedia(mediaItem.id!!, mediaItem.description, mediaItem.focus?.toMastodonApiString()) .fold({ }, { throwable -> Log.w(TAG, "failed to update media on status send", throwable) failOrRetry(throwable, statusId) return@launch }) } } } // finally, send the new status val newStatus = NewStatus( status = statusToSend.text, warningText = statusToSend.warningText, inReplyToId = statusToSend.inReplyToId, visibility = statusToSend.visibility, sensitive = statusToSend.sensitive, mediaIds = media.map { it.id!! }, scheduledAt = statusToSend.scheduledAt, poll = statusToSend.poll, language = statusToSend.language, mediaAttributes = media.map { mediaItem -> MediaAttribute( id = mediaItem.id!!, description = mediaItem.description, focus = mediaItem.focus?.toMastodonApiString(), thumbnail = null ) } ) val scheduled = !statusToSend.scheduledAt.isNullOrEmpty() val sendResult = if (isNew) { 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!!, "Bearer " + account.accessToken, account.domain, statusToSend.idempotencyKey, newStatus ) } sendResult.fold({ sentStatus -> statusesToSend.remove(statusId) // If the status was loaded from a draft, delete the draft and associated media files. if (statusToSend.draftId != 0) { draftHelper.deleteDraftAndAttachments(statusToSend.draftId) } mediaUploader.cancelUploadScope(*statusToSend.media.map { it.localId }.toIntArray()) if (scheduled) { eventHub.dispatch(StatusScheduledEvent((sentStatus as ScheduledStatusReply).id)) } else if (!isNew) { eventHub.dispatch(StatusChangedEvent(sentStatus as Status)) } else { eventHub.dispatch(StatusComposedEvent(sentStatus as Status)) } notificationManager.cancel(statusId) }, { throwable -> Log.w(TAG, "failed sending status", throwable) failOrRetry(throwable, statusId) }) stopSelfWhenDone() } } private suspend fun failOrRetry(throwable: Throwable, statusId: Int) { if (throwable is HttpException) { // the server refused to accept, save status & show error message failSending(statusId) } else { // a network problem occurred, let's retry sending the status retrySending(statusId) } } private suspend fun retrySending(statusId: Int) { // when statusToSend == null, sending has been canceled val statusToSend = statusesToSend[statusId] ?: return val backoff = TimeUnit.SECONDS.toMillis( statusToSend.retries.toLong() ).coerceAtMost(MAX_RETRY_INTERVAL) delay(backoff) sendStatus(statusId) } private fun stopSelfWhenDone() { if (statusesToSend.isEmpty()) { ServiceCompat.stopForeground( this@SendStatusService, ServiceCompat.STOP_FOREGROUND_REMOVE ) stopSelf() } } private suspend fun failSending(statusId: Int) { val failedStatus = statusesToSend.remove(statusId) if (failedStatus != null) { mediaUploader.cancelUploadScope(*failedStatus.media.map { it.localId }.toIntArray()) saveStatusToDrafts(failedStatus, failedToSendAlert = true) val notification = buildDraftNotification( R.string.send_post_notification_error_title, R.string.send_post_notification_saved_content, failedStatus.accountId, statusId ) notificationManager.cancel(statusId) notificationManager.notify(errorNotificationId++, notification) } // NOTE only this removes the "Sending..." notification (added with startForeground() above) stopSelfWhenDone() } private fun cancelSending(statusId: Int) = serviceScope.launch { val statusToCancel = statusesToSend.remove(statusId) if (statusToCancel != null) { mediaUploader.cancelUploadScope(*statusToCancel.media.map { it.localId }.toIntArray()) val sendJob = sendJobs.remove(statusId) sendJob?.cancel() saveStatusToDrafts(statusToCancel, failedToSendAlert = false) val notification = buildDraftNotification( R.string.send_post_notification_cancel_title, R.string.send_post_notification_saved_content, statusToCancel.accountId, statusId ) notificationManager.notify(statusId, notification) delay(5000) stopSelfWhenDone() } } private suspend fun saveStatusToDrafts(status: StatusToSend, failedToSendAlert: Boolean) { draftHelper.saveDraft( draftId = status.draftId, accountId = status.accountId, inReplyToId = status.inReplyToId, content = status.text, contentWarning = status.warningText, sensitive = status.sensitive, visibility = Status.Visibility.fromStringValue(status.visibility), mediaUris = status.media.map { it.uri }, mediaDescriptions = status.media.map { it.description }, mediaFocus = status.media.map { it.focus }, poll = status.poll, failedToSend = true, failedToSendAlert = failedToSendAlert, scheduledAt = status.scheduledAt, language = status.language, statusId = status.statusId ) } private fun cancelSendingIntent(statusId: Int): PendingIntent { val intent = Intent(this, SendStatusService::class.java) intent.putExtra(KEY_CANCEL, statusId) return PendingIntent.getService( this, statusId, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, ) } private fun buildDraftNotification( @StringRes title: Int, @StringRes content: Int, accountId: Long, statusId: Int ): Notification { val intent = MainActivity.draftIntent(this, accountId) val pendingIntent = PendingIntent.getActivity( this, statusId, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, ) return NotificationCompat.Builder(this@SendStatusService, CHANNEL_ID) .setSmallIcon(R.drawable.tusky_notification_icon) .setContentTitle(getString(title)) .setContentText(getString(content)) .setColor(getColor(R.color.notification_color)) .setAutoCancel(true) .setOngoing(false) .setContentIntent(pendingIntent) .build() } override fun onDestroy() { super.onDestroy() supervisorJob.cancel() } companion object { private const val TAG = "SendStatusService" private const val KEY_STATUS = "status" private const val KEY_CANCEL = "cancel_id" private const val CHANNEL_ID = "send_toots" private val MAX_RETRY_INTERVAL = TimeUnit.MINUTES.toMillis(1) 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 { val intent = Intent(context, SendStatusService::class.java) intent.putExtra(KEY_STATUS, statusToSend) if (statusToSend.media.isNotEmpty()) { // forward uri permissions intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) val uriClip = ClipData( ClipDescription("Status Media", arrayOf("image/*", "video/*")), ClipData.Item(statusToSend.media[0].uri) ) statusToSend.media .drop(1) .forEach { mediaItem -> uriClip.addItem(ClipData.Item(mediaItem.uri)) } intent.clipData = uriClip } return intent } } } @Parcelize data class StatusToSend( val text: String, val warningText: String, val visibility: String, val sensitive: Boolean, val media: List, val scheduledAt: String?, val inReplyToId: String?, val poll: NewPoll?, val replyingStatusContent: String?, val replyingStatusAuthorUsername: String?, val accountId: Long, val draftId: Int, val idempotencyKey: String, var retries: Int, val language: String?, val statusId: String? ) : Parcelable @Parcelize data class MediaToSend( val localId: Int, // null if media is not yet completely uploaded val id: String?, val uri: String, val description: String?, val focus: Attachment.Focus?, var processed: Boolean ) : Parcelable ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/service/ServiceClient.kt ================================================ /* 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.content.Context import androidx.core.content.ContextCompat import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject class ServiceClient @Inject constructor(@ApplicationContext private val context: Context) { fun sendToot(tootToSend: StatusToSend) { val intent = SendStatusService.sendStatusIntent(context, tootToSend) ContextCompat.startForegroundService(context, intent) } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/service/TuskyTileService.kt ================================================ /* 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.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 */ class TuskyTileService : TileService() { @SuppressLint("StartActivityAndCollapseDeprecated") @Suppress("DEPRECATION") override fun onClick() { 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) } } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/settings/AccountPreferenceDataStore.kt ================================================ package com.keylesspalace.tusky.settings import androidx.preference.PreferenceDataStore import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.PreferenceChangedEvent import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.entity.AccountEntity import com.keylesspalace.tusky.di.ApplicationScope import javax.inject.Inject import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch class AccountPreferenceDataStore @Inject constructor( private val accountManager: AccountManager, private val eventHub: EventHub, @ApplicationScope private val externalScope: CoroutineScope ) : PreferenceDataStore() { private val account: AccountEntity = accountManager.activeAccount!! override fun getBoolean(key: String, defValue: Boolean): Boolean { return when (key) { 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 } } override fun putBoolean(key: String, value: Boolean) { externalScope.launch { accountManager.updateAccount(account) { when (key) { PrefKeys.ALWAYS_SHOW_SENSITIVE_MEDIA -> copy(alwaysShowSensitiveMedia = value) PrefKeys.ALWAYS_OPEN_SPOILER -> copy(alwaysOpenSpoiler = value) PrefKeys.MEDIA_PREVIEW_ENABLED -> copy(mediaPreviewEnabled = value) PrefKeys.TAB_FILTER_HOME_BOOSTS -> copy(isShowHomeBoosts = value) PrefKeys.TAB_FILTER_HOME_REPLIES -> copy(isShowHomeReplies = value) PrefKeys.TAB_SHOW_HOME_SELF_BOOSTS -> copy(isShowHomeSelfBoosts = value) else -> this } } eventHub.dispatch(PreferenceChangedEvent(key)) } } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/settings/DefaultReplyVisibility.kt ================================================ package com.keylesspalace.tusky.settings import com.keylesspalace.tusky.entity.Status enum class DefaultReplyVisibility(val int: Int) { MATCH_DEFAULT_POST_VISIBILITY(0), PUBLIC(1), UNLISTED(2), PRIVATE(3), DIRECT(4); val stringValue: String get() = when (this) { MATCH_DEFAULT_POST_VISIBILITY -> "match_default_post_visibility" PUBLIC -> "public" UNLISTED -> "unlisted" PRIVATE -> "private" DIRECT -> "direct" } fun toVisibilityOr(default: Status.Visibility): Status.Visibility { return when (this) { PUBLIC -> Status.Visibility.PUBLIC UNLISTED -> Status.Visibility.UNLISTED PRIVATE -> Status.Visibility.PRIVATE DIRECT -> Status.Visibility.DIRECT else -> default } } companion object { fun fromInt(int: Int): DefaultReplyVisibility { return when (int) { 4 -> DIRECT 3 -> PRIVATE 2 -> UNLISTED 1 -> PUBLIC else -> MATCH_DEFAULT_POST_VISIBILITY } } fun fromStringValue(s: String): DefaultReplyVisibility { return when (s) { "public" -> PUBLIC "unlisted" -> UNLISTED "private" -> PRIVATE "direct" -> DIRECT else -> MATCH_DEFAULT_POST_VISIBILITY } } } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/settings/ProxyConfiguration.kt ================================================ package com.keylesspalace.tusky.settings import java.net.IDN class ProxyConfiguration private constructor( val hostname: String, val port: Int ) { companion object { fun create(hostname: String, port: Int): ProxyConfiguration? { if (isValidHostname(IDN.toASCII(hostname)) && isValidProxyPort(port)) { return ProxyConfiguration(hostname, port) } return null } fun isValidProxyPort(value: Any): Boolean = when (value) { is String -> if (value == "") { true } else { value.runCatching(String::toInt).map( PROXY_RANGE::contains ).getOrDefault(false) } is Int -> PROXY_RANGE.contains(value) else -> false } fun isValidHostname(hostname: String): Boolean = IP_ADDRESS_REGEX.matches(hostname) || HOSTNAME_REGEX.matches(hostname) const val MIN_PROXY_PORT = 1 const val MAX_PROXY_PORT = 65535 } } 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])$" ) ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/settings/SettingsConstants.kt ================================================ package com.keylesspalace.tusky.settings enum class AppTheme(val value: String) { NIGHT("night"), DAY("day"), BLACK("black"), AUTO("auto"), AUTO_SYSTEM("auto_system"), AUTO_SYSTEM_BLACK("auto_system_black"); companion object { fun stringValues() = entries.map { it.value }.toTypedArray() @JvmField val DEFAULT = AUTO_SYSTEM } } /** * Current preferences schema version. Format is 4-digit year + 2 digit month (zero padded) + 2 * digit day (zero padded) + 2 digit counter (zero padded). * * If you make an incompatible change to the preferences schema you must: * * - Update this value * - Update the code in * [TuskyApplication.upgradeSharedPreferences][com.keylesspalace.tusky.TuskyApplication.upgradeSharedPreferences] * to migrate from the old schema version to the new schema version. * * An incompatible change is: * * - Deleting a preference. The migration should delete the old preference. * - Changing a preference's default value (e.g., from true to false, or from one enum value to * another). The migration should check to see if the user had set an explicit value for * that preference ([SharedPreferences.contains][android.content.SharedPreferences.contains]); * if they hadn't then the migration should set the *old* default value as the preference's * value, so the app behaviour does not unexpectedly change. * - Changing a preference's type (e.g,. from a boolean to an enum). If you do this you may want * to give the preference a different name, but you still need to migrate the user's previous * preference value to the new preference. * - Renaming a preference key. The migration should copy the user's previous value for the * preference under the old key to the value for the new, and delete the old preference. * * A compatible change is: * * - Adding a new preference that does not change the interpretation of an existing preference */ const val SCHEMA_VERSION = 2025032401 /** 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 // each preference a key for it to work. const val SCHEMA_VERSION: String = "schema_version" const val LAST_USED_PUSH_PROVDER = "lastUsedPushProvider" const val APP_THEME = "appTheme" const val LANGUAGE = "language" const val STATUS_TEXT_SIZE = "statusTextSize" const val READING_ORDER = "readingOrder" const val MAIN_NAV_POSITION = "mainNavPosition" const val HIDE_TOP_TOOLBAR = "hideTopToolbar" const val SHOW_NOTIFICATIONS_FILTER = "showNotificationsFilter" const val ABSOLUTE_TIME_VIEW = "absoluteTimeView" const val SHOW_BOT_OVERLAY = "showBotOverlay" const val ANIMATE_GIF_AVATARS = "animateGifAvatars" const val USE_BLURHASH = "useBlurhash" const val SHOW_SELF_USERNAME = "showSelfUsername" const val SHOW_CARDS_IN_TIMELINES = "showCardsInTimelines" const val CONFIRM_REBLOGS = "confirmReblogs" const val CONFIRM_FAVOURITES = "confirmFavourites" const val CONFIRM_FOLLOWS = "confirmFollows" const val ENABLE_SWIPE_FOR_TABS = "enableSwipeForTabs" const val ANIMATE_CUSTOM_EMOJIS = "animateCustomEmojis" const val SHOW_STATS_INLINE = "showStatsInline" const val CUSTOM_TABS = "customTabs" const val WELLBEING_LIMITED_NOTIFICATIONS = "wellbeingModeLimitedNotifications" const val WELLBEING_HIDE_STATS_POSTS = "wellbeingHideStatsPosts" const val WELLBEING_HIDE_STATS_PROFILE = "wellbeingHideStatsProfile" const val HTTP_PROXY_ENABLED = "httpProxyEnabled" const val HTTP_PROXY_SERVER = "httpProxyServer" const val HTTP_PROXY_PORT = "httpProxyPort" const val DEFAULT_POST_PRIVACY = "defaultPostPrivacy" const val DEFAULT_POST_LANGUAGE = "defaultPostLanguage" const val DEFAULT_REPLY_PRIVACY = "defaultReplyPrivacy" const val DEFAULT_MEDIA_SENSITIVITY = "defaultMediaSensitivity" const val MEDIA_PREVIEW_ENABLED = "mediaPreviewEnabled" const val ALWAYS_SHOW_SENSITIVE_MEDIA = "alwaysShowSensitiveMedia" const val ALWAYS_OPEN_SPOILER = "alwaysOpenSpoiler" const val NOTIFICATIONS_ENABLED = "notificationsEnabled" const val NOTIFICATION_ALERT_LIGHT = "notificationAlertLight" const val NOTIFICATION_ALERT_VIBRATE = "notificationAlertVibrate" const val NOTIFICATION_ALERT_SOUND = "notificationAlertSound" 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" /** UI text scaling factor, stored as float, 100 = 100% = no scaling */ const val REBLOG_PRIVACY = "reblogPrivacy" object Deprecated { const val FAB_HIDE = "fabHide" } } ================================================ FILE: app/src/main/java/com/keylesspalace/tusky/settings/SettingsDSL.kt ================================================ package com.keylesspalace.tusky.settings import android.content.Context import android.widget.Button import androidx.activity.result.ActivityResultRegistryOwner import androidx.annotation.StringRes import androidx.core.widget.doAfterTextChanged import androidx.lifecycle.LifecycleOwner import androidx.preference.EditTextPreference import androidx.preference.ListPreference import androidx.preference.Preference import androidx.preference.PreferenceCategory import androidx.preference.PreferenceFragmentCompat import androidx.preference.PreferenceScreen import androidx.preference.SwitchPreferenceCompat import com.keylesspalace.tusky.view.SliderPreference import de.c1710.filemojicompat_ui.views.picker.preference.EmojiPickerPreference class PreferenceParent( val context: Context, val addPref: (pref: Preference) -> Unit ) inline fun PreferenceParent.preference(builder: Preference.() -> Unit): Preference { val pref = Preference(context) builder(pref) addPref(pref) return pref } inline fun PreferenceParent.listPreference(builder: ListPreference.() -> Unit): ListPreference { val pref = ListPreference(context) builder(pref) addPref(pref) return pref } inline fun PreferenceParent.emojiPreference( activity: A, builder: EmojiPickerPreference.() -> Unit ): EmojiPickerPreference where A : Context, A : ActivityResultRegistryOwner, A : LifecycleOwner { val pref = EmojiPickerPreference.get(activity) builder(pref) addPref(pref) return pref } inline fun PreferenceParent.sliderPreference( builder: SliderPreference.() -> Unit ): SliderPreference { val pref = SliderPreference(context) builder(pref) addPref(pref) return pref } inline fun PreferenceParent.switchPreference( builder: SwitchPreferenceCompat.() -> Unit ): SwitchPreferenceCompat { val pref = SwitchPreferenceCompat(context) builder(pref) addPref(pref) return pref } inline fun PreferenceParent.validatedEditTextPreference( errorMessage: String?, crossinline isValid: (a: String) -> Boolean, builder: EditTextPreference.() -> Unit ): EditTextPreference { val pref = EditTextPreference(context) pref.setOnBindEditTextListener { editText -> editText.doAfterTextChanged { editable -> requireNotNull(editable) val btn = editText.rootView.findViewById