From 710e209e3449026c0158809b0bbeac87ccd7afb7 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Wed, 13 Nov 2024 11:45:16 +0100 Subject: [PATCH] refactor: Ongoing work to remove the `activeAccount` idiom (#964) Continue the work to remove the "activeAccount" idiom. - Uses a new PachliAccount type through most of the app. This holds information that was previously accessed separately (e.g., content filters, lists) in one place. The information is loaded when the app launches or the active account switches. - Fetching data when the account is switched / loaded simplifies error handling, as more code can now assume the data has already been loaded. If it hasn't the code path is simply unreachable. - This opens up the possibility of "acting as one account while logged in as another". E.g., have two accounts, and be logged in to one account and boost a post you've seen from your other account. - Add a database migration to populate existing accounts with default data when the user updates the app. - Refactor code that used those list and filter repositories to get the data from the PachliAccount instead. New local and remote data sources are implemented, and the list and filter repositories mediate between those sources. - Start a ViewModel for MainActivity, which includes: - Sending user actions as UiAction objects - Providing a flow of uiState for MainActivity to react to - Remove most uses of SharedPreferencesRepository from MainActivity - Show messages about errors that occur when logging in - Refactor intent routing in MainActivity to make the logic clearer. - Add new `core.data` types to push more `core.network` types out of the UI code - `core.data.model.MastodonList` for `core.network.model.MastoList` - `core.data.model.Server` for `core.network.model.Server` - Continue the work to send the Pachli account ID to the code that uses it. - Most view models now get the account ID via assisted injection. - QueuedMedia now includes the AccountEntity so it can operate with any account. Modify the `uploadMedia` API call to include explicit authentication details. --------- Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- app/lint-baseline.xml | 58 +- app/proguard-rules.pro | 1 + app/src/main/java/app/pachli/MainActivity.kt | 915 ++++++----- app/src/main/java/app/pachli/MainViewModel.kt | 242 +++ .../java/app/pachli/TabPreferenceActivity.kt | 52 +- app/src/main/java/app/pachli/TabViewData.kt | 10 +- .../main/java/app/pachli/TimelineActivity.kt | 51 +- .../pachli/adapter/StatusBaseViewHolder.kt | 4 +- .../app/pachli/adapter/StatusViewHolder.kt | 3 +- .../main/java/app/pachli/appstore/Events.kt | 3 - .../components/account/AccountActivity.kt | 2 +- .../announcements/AnnouncementsViewModel.kt | 44 +- .../components/compose/ComposeActivity.kt | 173 +- .../components/compose/ComposeViewModel.kt | 125 +- .../components/compose/MediaUploader.kt | 8 +- .../compose/dialog/AddPollDialog.kt | 2 +- .../conversation/ConversationViewHolder.kt | 1 + .../conversation/ConversationsFragment.kt | 4 +- .../components/drafts/DraftsActivity.kt | 5 +- .../filters/ContentFiltersActivity.kt | 73 +- .../filters/ContentFiltersViewModel.kt | 82 +- .../filters/EditContentFilterActivity.kt | 2 + .../filters/EditContentFilterViewModel.kt | 39 +- .../notifications/NotificationFetcher.kt | 18 +- .../notifications/NotificationHelper.kt | 34 +- .../notifications/NotificationsFragment.kt | 65 +- .../NotificationsPagingAdapter.kt | 4 - .../notifications/NotificationsViewModel.kt | 108 +- .../notifications/PushNotificationHelper.kt | 23 +- .../notifications/StatusViewHolder.kt | 6 +- .../preference/AccountPreferencesFragment.kt | 28 +- .../NotificationPreferencesFragment.kt | 35 +- .../preference/PreferencesActivity.kt | 25 +- .../scheduled/ScheduledStatusActivity.kt | 2 + .../components/search/SearchActivity.kt | 2 +- .../components/search/SearchViewModel.kt | 17 +- .../fragments/SearchStatusesFragment.kt | 18 +- .../timeline/CachedTimelineRepository.kt | 78 +- .../timeline/NetworkTimelineRepository.kt | 12 +- .../components/timeline/TimelineFragment.kt | 4 +- .../viewmodel/CachedTimelineViewModel.kt | 18 +- .../NetworkTimelineRemoteMediator.kt | 9 +- .../viewmodel/NetworkTimelineViewModel.kt | 17 +- .../timeline/viewmodel/TimelineViewModel.kt | 50 +- .../components/trending/TrendingActivity.kt | 5 +- .../viewmodel/TrendingTagsViewModel.kt | 33 +- .../viewthread/ViewThreadFragment.kt | 4 +- .../viewthread/ViewThreadViewModel.kt | 74 +- .../java/app/pachli/fragment/SFragment.kt | 8 +- .../pachli/interfaces/StatusActionListener.kt | 2 +- .../receiver/SendStatusBroadcastReceiver.kt | 1 + .../app/pachli/service/PachliTileService.kt | 4 +- .../app/pachli/service/SendStatusService.kt | 10 +- .../java/app/pachli/usecase/LogoutUseCase.kt | 71 +- .../util/ListStatusAccessibilityDelegate.kt | 2 +- .../app/pachli/util/ShareShortcutHelper.kt | 101 -- .../app/pachli/util/UpdateShortCutsUseCase.kt | 108 ++ .../pachli/viewdata/NotificationViewData.kt | 13 + .../pachli/viewmodel/EditProfileViewModel.kt | 18 +- .../app/pachli/worker/NotificationWorker.kt | 2 + app/src/main/res/layout/activity_compose.xml | 3 +- .../res/layout/activity_content_filters.xml | 14 +- app/src/main/res/values-ar/strings.xml | 3 +- app/src/main/res/values-be/strings.xml | 3 +- app/src/main/res/values-cy/strings.xml | 3 +- app/src/main/res/values-de/strings.xml | 3 +- app/src/main/res/values-es/strings.xml | 5 +- app/src/main/res/values-fa/strings.xml | 3 +- app/src/main/res/values-fi/strings.xml | 5 +- app/src/main/res/values-fr/strings.xml | 3 +- app/src/main/res/values-gd/strings.xml | 3 +- app/src/main/res/values-gl/strings.xml | 5 +- app/src/main/res/values-hu/strings.xml | 3 +- app/src/main/res/values-is/strings.xml | 3 +- app/src/main/res/values-it/strings.xml | 3 +- app/src/main/res/values-ja/strings.xml | 3 +- app/src/main/res/values-nb-rNO/strings.xml | 5 +- app/src/main/res/values-nl/strings.xml | 3 +- app/src/main/res/values-oc/strings.xml | 3 +- app/src/main/res/values-pt-rBR/strings.xml | 3 +- app/src/main/res/values-sv/strings.xml | 3 +- app/src/main/res/values-tr/strings.xml | 3 +- app/src/main/res/values-uk/strings.xml | 3 +- app/src/main/res/values-vi/strings.xml | 3 +- app/src/main/res/values-zh-rCN/strings.xml | 3 +- app/src/main/res/values/strings.xml | 6 +- .../test/java/app/pachli/MainActivityTest.kt | 16 +- .../components/compose/ComposeActivityTest.kt | 321 +++- .../NotificationsViewModelTestBase.kt | 173 +- ...icationsViewModelTestClearNotifications.kt | 2 + ...NotificationsViewModelTestContentFilter.kt | 35 +- ...nsViewModelTestNotificationFilterAction.kt | 2 + ...ationsViewModelTestStatusDisplayOptions.kt | 2 + ...icationsViewModelTestStatusFilterAction.kt | 2 + .../NotificationsViewModelTestUiState.kt | 6 +- .../NotificationsViewModelTestVisibleId.kt | 17 +- .../CachedTimelineViewModelTestBase.kt | 50 +- .../CachedTimelineViewModelTestVisibleId.kt | 27 +- .../NetworkTimelineRemoteMediatorTest.kt | 34 +- .../NetworkTimelineViewModelTestBase.kt | 48 +- .../viewthread/ViewThreadViewModelTest.kt | 288 ++-- .../java/app/pachli/util/LocaleUtilsTest.kt | 4 +- .../StatusDisplayOptionsRepositoryTest.kt | 98 +- .../kotlin/AndroidRoomConventionPlugin.kt | 18 +- core/activity/build.gradle.kts | 4 +- .../app/pachli/core/activity/BaseActivity.kt | 42 +- core/data/build.gradle.kts | 10 +- core/data/lint-baseline.xml | 2 +- .../app/pachli/core/data/di/DataModule.kt | 11 +- .../pachli/core/data/model/ContentFilter.kt | 6 +- .../pachli/core/data/model/InstanceInfo.kt | 26 +- .../pachli/core/data/model/MastodonList.kt | 60 + .../app/pachli/core/data/model}/Server.kt | 44 +- .../core/data/repository/AccountManager.kt | 887 +++++++--- .../repository/AccountPreferenceDataStore.kt | 22 +- .../repository/ContentFiltersRepository.kt | 326 +--- .../data/repository/InstanceInfoRepository.kt | 119 +- .../core/data/repository/ListsRepository.kt | 96 +- .../pachli/core/data/repository/Loadable.kt | 31 + .../data/repository/NetworkListsRepository.kt | 167 -- .../OfflineFirstContentFiltersRepository.kt | 156 ++ .../repository/OfflineFirstListRepository.kt | 96 ++ .../core/data/repository/PachliAccount.kt | 71 + .../core/data/repository/ServerRepository.kt | 52 +- .../StatusDisplayOptionsRepository.kt | 12 +- .../source/ContentFiltersLocalDataSource.kt | 100 ++ .../source/ContentFiltersRemoteDataSource.kt | 196 +++ .../core/data/source/ListsLocalDataSource.kt | 47 + .../core/data/source/ListsRemoteDataSource.kt | 66 + core/data/src/main/res/values/strings.xml | 4 + .../app/pachli/core/data/model}/ServerTest.kt | 4 +- .../repository/InstanceInfoRepositoryTest.kt | 89 +- .../BaseContentFiltersRepositoryTest.kt | 270 +++- .../ContentFiltersRepositoryTestCreate.kt | 237 ++- .../ContentFiltersRepositoryTestDelete.kt | 102 +- .../ContentFiltersRepositoryTestFlow.kt | 219 --- .../ContentFiltersRepositoryTestReload.kt | 61 +- .../ContentFiltersRepositoryTestUpdate.kt | 121 +- .../src/test/resources/robolectric.properties | 20 + .../src/test/resources/server-versions.json5 | 0 core/database/build.gradle.kts | 5 + core/database/lint-baseline.xml | 2 +- .../9.json | 1438 +++++++++++++++++ .../app/pachli/core/database/AppDatabase.kt | 80 +- .../app/pachli/core/database/Converters.kt | 33 + .../pachli/core/database/dao/AccountDao.kt | 384 ++++- .../core/database/dao/AnnouncementsDao.kt | 53 + .../core/database/dao/ContentFiltersDao.kt | 51 + .../pachli/core/database/dao/InstanceDao.kt | 21 +- .../app/pachli/core/database/dao/ListsDao.kt | 75 + .../pachli/core/database/dao/RemoteKeyDao.kt | 3 +- .../pachli/core/database/dao/TimelineDao.kt | 4 +- .../pachli/core/database/di/DatabaseModule.kt | 9 + .../core/database/model/AccountEntity.kt | 116 +- .../core/database/model/AnnouncementEntity.kt | 51 + .../database/model/ContentFiltersEntity.kt | 46 + .../core/database/model/InstanceEntity.kt | 127 +- .../core/database/model/MastodonListEntity.kt | 65 + .../core/database/model/PachliAccount.kt | 66 + .../core/database/model/ServerEntity.kt | 52 + .../core/database/dao/TimelineDaoTest.kt | 3 +- core/domain/build.gradle.kts | 4 + .../app/pachli/core/model/ServerOperation.kt | 25 + .../app/pachli/core/navigation/Navigation.kt | 229 ++- core/network-test/build.gradle.kts | 1 + .../network/di/test/FakeMastodonApiModule.kt | 38 + .../core/network/di/test/FakeNetworkModule.kt | 2 + .../pachli/core/network/di/NetworkModule.kt | 2 + .../pachli/core/network/model/Announcement.kt | 4 +- .../app/pachli/core/network/model/FilterV1.kt | 8 - .../pachli/core/network/model/InstanceV1.kt | 2 +- .../core/network/model/MediaUploadApi.kt | 6 +- .../core/network/model/nodeinfo/NodeInfo.kt | 9 +- .../retrofit/InstanceSwitchAuthInterceptor.kt | 2 +- .../core/network/retrofit/MastodonApi.kt | 14 +- .../core/network/retrofit/NodeInfoApi.kt | 6 +- .../src/main/res/values-en-rGB/strings.xml | 2 +- core/testing/build.gradle.kts | 8 + .../core/testing/fakes}/FakeDatabaseModule.kt | 18 +- .../testing/rules/LazyActivityScenarioRule.kt | 10 +- feature/lists/build.gradle.kts | 3 + feature/lists/lint-baseline.xml | 2 +- .../feature/lists/AccountsInListFragment.kt | 13 +- .../feature/lists/AccountsInListViewModel.kt | 12 +- .../app/pachli/feature/lists/ListsActivity.kt | 83 +- .../feature/lists/ListsForAccountFragment.kt | 7 +- .../feature/lists/ListsForAccountViewModel.kt | 48 +- .../pachli/feature/lists/ListsViewModel.kt | 51 +- feature/lists/src/main/res/values/strings.xml | 2 +- feature/login/lint-baseline.xml | 6 +- .../app/pachli/feature/login/LoginActivity.kt | 106 +- .../pachli/feature/login/LoginViewModel.kt | 67 + .../feature/login/LoginWebViewViewModel.kt | 11 +- .../app/pachli/feature/login/UiAction.kt | 62 + gradle/libs.versions.toml | 1 + 195 files changed, 8051 insertions(+), 3444 deletions(-) create mode 100644 app/src/main/java/app/pachli/MainViewModel.kt delete mode 100644 app/src/main/java/app/pachli/util/ShareShortcutHelper.kt create mode 100644 app/src/main/java/app/pachli/util/UpdateShortCutsUseCase.kt create mode 100644 core/data/src/main/kotlin/app/pachli/core/data/model/MastodonList.kt rename core/{network/src/main/kotlin/app/pachli/core/network => data/src/main/kotlin/app/pachli/core/data/model}/Server.kt (92%) create mode 100644 core/data/src/main/kotlin/app/pachli/core/data/repository/Loadable.kt delete mode 100644 core/data/src/main/kotlin/app/pachli/core/data/repository/NetworkListsRepository.kt create mode 100644 core/data/src/main/kotlin/app/pachli/core/data/repository/OfflineFirstContentFiltersRepository.kt create mode 100644 core/data/src/main/kotlin/app/pachli/core/data/repository/OfflineFirstListRepository.kt create mode 100644 core/data/src/main/kotlin/app/pachli/core/data/repository/PachliAccount.kt create mode 100644 core/data/src/main/kotlin/app/pachli/core/data/source/ContentFiltersLocalDataSource.kt create mode 100644 core/data/src/main/kotlin/app/pachli/core/data/source/ContentFiltersRemoteDataSource.kt create mode 100644 core/data/src/main/kotlin/app/pachli/core/data/source/ListsLocalDataSource.kt create mode 100644 core/data/src/main/kotlin/app/pachli/core/data/source/ListsRemoteDataSource.kt rename core/{network/src/test/kotlin/app/pachli/core/network => data/src/test/kotlin/app/pachli/core/data/model}/ServerTest.kt (99%) delete mode 100644 core/data/src/test/kotlin/app/pachli/core/data/repository/filtersRepository/ContentFiltersRepositoryTestFlow.kt create mode 100644 core/data/src/test/resources/robolectric.properties rename core/{network => data}/src/test/resources/server-versions.json5 (100%) create mode 100644 core/database/schemas/app.pachli.core.database.AppDatabase/9.json create mode 100644 core/database/src/main/kotlin/app/pachli/core/database/dao/AnnouncementsDao.kt create mode 100644 core/database/src/main/kotlin/app/pachli/core/database/dao/ContentFiltersDao.kt create mode 100644 core/database/src/main/kotlin/app/pachli/core/database/dao/ListsDao.kt create mode 100644 core/database/src/main/kotlin/app/pachli/core/database/model/AnnouncementEntity.kt create mode 100644 core/database/src/main/kotlin/app/pachli/core/database/model/ContentFiltersEntity.kt create mode 100644 core/database/src/main/kotlin/app/pachli/core/database/model/MastodonListEntity.kt create mode 100644 core/database/src/main/kotlin/app/pachli/core/database/model/PachliAccount.kt create mode 100644 core/database/src/main/kotlin/app/pachli/core/database/model/ServerEntity.kt rename core/{database/src/test/kotlin/app/pachli/core/database/di => testing/src/main/kotlin/app/pachli/core/testing/fakes}/FakeDatabaseModule.kt (80%) create mode 100644 feature/login/src/main/kotlin/app/pachli/feature/login/LoginViewModel.kt create mode 100644 feature/login/src/main/kotlin/app/pachli/feature/login/UiAction.kt diff --git a/app/lint-baseline.xml b/app/lint-baseline.xml index 6b78431fa..3357de646 100644 --- a/app/lint-baseline.xml +++ b/app/lint-baseline.xml @@ -712,7 +712,7 @@ errorLine2=" ^"> @@ -723,7 +723,7 @@ errorLine2=" ^"> @@ -734,7 +734,7 @@ errorLine2=" ^"> @@ -800,7 +800,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -811,7 +811,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1383,7 +1383,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -1394,7 +1394,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1405,7 +1405,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -1416,7 +1416,7 @@ errorLine2=" ~~~~~~~~~~~~"> @@ -1427,7 +1427,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~"> @@ -1438,7 +1438,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -1449,7 +1449,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~"> @@ -1460,7 +1460,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1471,7 +1471,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1482,7 +1482,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~"> @@ -1493,7 +1493,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1504,7 +1504,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1515,7 +1515,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1526,7 +1526,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -1537,7 +1537,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1548,7 +1548,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1559,7 +1559,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1570,7 +1570,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1687,7 +1687,7 @@ errorLine2=" ~~~~~~~~"> @@ -2541,12 +2541,12 @@ + errorLine1=" ShortcutManagerCompat.setDynamicShortcuts(context, shortcuts)" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="src/main/java/app/pachli/util/UpdateShortCutsUseCase.kt" + line="106" + column="9"/> () + + private var pachliAccountId by Delegates.notNull() + @SuppressLint("RestrictedApi") override fun onCreate(savedInstanceState: Bundle?) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { @@ -253,77 +262,91 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { } super.onCreate(savedInstanceState) - // will be redirected to LoginActivity by BaseActivity - val activeAccount = accountManager.activeAccount ?: return - var showNotificationTab = false // check for savedInstanceState in order to not handle intent events more than once if (intent != null && savedInstanceState == null) { - // Cancel the notification that opened this activity (if opened from a notification). - val notificationId = MainActivityIntent.getNotificationId(intent) - if (notificationId != -1) { - val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager - notificationManager.cancel(MainActivityIntent.getNotificationTag(intent), notificationId) - } - - /** there are two possibilities the accountId can be passed to MainActivity: - * - from our code as Long Intent Extra PACHLI_ACCOUNT_ID - * - from share shortcuts as String 'android.intent.extra.shortcut.ID' - */ - var pachliAccountId = intent.pachliAccountId - if (pachliAccountId == -1L) { - val accountIdString = intent.getStringExtra(ShortcutManagerCompat.EXTRA_SHORTCUT_ID) - if (accountIdString != null) { - pachliAccountId = accountIdString.toLong() - } - } - val accountSwitchRequested = pachliAccountId != -1L - if (accountSwitchRequested && pachliAccountId != activeAccount.id) { - accountManager.setActiveAccount(pachliAccountId) - } - - val openDrafts = MainActivityIntent.getOpenDrafts(intent) - - // Sharing to Pachli from an external app. - if (canHandleMimeType(intent.type) || MainActivityIntent.hasComposeOptions(intent)) { - if (accountSwitchRequested) { - // The correct account is already active - forwardToComposeActivityAndExit(intent) - } else { - // No account was provided, show the chooser + val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager + when (val payload = MainActivityIntent.payload(intent)) { + is Payload.QuickTile -> { 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 - forwardToComposeActivityAndExit(intent) - } else { - // A different account was requested, restart the activity, - // forwarding this intent to the restarted activity. - changeAccountAndRestart(requestedId, intent) - } + launchComposeActivityAndExit(requestedId) } }, ) + return } - } else if (openDrafts) { - val intent = DraftsActivityIntent(this, intent.pachliAccountId) - startActivity(intent) - } else if (accountSwitchRequested && MainActivityIntent.hasNotificationType(intent)) { - // user clicked a notification, show follow requests for type FOLLOW_REQUEST, - // otherwise show notification tab - if (MainActivityIntent.getNotificationType(intent) == Notification.Type.FOLLOW_REQUEST) { - val intent = AccountListActivityIntent(this, intent.pachliAccountId, AccountListActivityIntent.Kind.FOLLOW_REQUESTS) - startActivityWithDefaultTransition(intent) - } else { - showNotificationTab = true + + is Payload.NotificationCompose -> { + notificationManager.cancel(payload.notificationTag, payload.notificationId) + launchComposeActivityAndExit( + intent.pachliAccountId, + payload.composeOptions, + ) + return + } + + is Payload.Notification -> { + notificationManager.cancel(payload.notificationTag, payload.notificationId) + viewModel.accept(FallibleUiAction.SetActiveAccount(intent.pachliAccountId)) + when (payload.notificationType) { + Notification.Type.FOLLOW_REQUEST -> { + val intent = AccountListActivityIntent(this, intent.pachliAccountId, AccountListActivityIntent.Kind.FOLLOW_REQUESTS) + startActivityWithDefaultTransition(intent) + } + + else -> { + showNotificationTab = true + } + } + } + + Payload.OpenDrafts -> { + viewModel.accept(FallibleUiAction.SetActiveAccount(intent.pachliAccountId)) + startActivity(DraftsActivityIntent(this, intent.pachliAccountId)) + } + + // Handled in [onPostCreate]. + is Payload.Redirect -> {} + + is Payload.Shortcut -> { + launchComposeActivityAndExit(intent.pachliAccountId) + // Alternate behaviour -- switch to this account instead. +// startActivity( +// MainActivityIntent(this, intent.pachliAccountId).apply { +// flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK +// }, +// ) + return + } + + null -> { + // If the intent contains data to share choose the account to share from + // and start the composer. + if (canHandleMimeType(intent.type)) { + // Determine the account to use. + showAccountChooserDialog( + getString(R.string.action_share_as), + true, + object : AccountSelectionListener { + override fun onAccountSelected(account: AccountEntity) { + val requestedId = account.id + forwardToComposeActivityAndExit(requestedId, intent) + } + }, + ) + } } } } + + viewModel.accept(FallibleUiAction.SetActiveAccount(intent.pachliAccountId)) + window.statusBarColor = Color.TRANSPARENT // don't draw a status bar, the DrawerLayout and the MaterialDrawerLayout have their own setContentView(binding.root) @@ -331,9 +354,9 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { // Determine which of the three toolbars should be the supportActionBar (which hosts // the options menu). - val hideTopToolbar = sharedPreferencesRepository.hideTopToolbar + val hideTopToolbar = viewModel.uiState.value.hideTopToolbar if (hideTopToolbar) { - when (sharedPreferencesRepository.mainNavigationPosition) { + when (viewModel.uiState.value.mainNavigationPosition) { MainNavigationPosition.TOP -> setSupportActionBar(binding.topNav) MainNavigationPosition.BOTTOM -> setSupportActionBar(binding.bottomNav) } @@ -345,24 +368,10 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { binding.mainToolbar.show() } - loadDrawerAvatar(activeAccount.profilePictureUrl, true) - addMenuProvider(this) binding.viewPager.reduceSwipeSensitivity() - setupDrawer( - intent.pachliAccountId, - savedInstanceState, - addSearchButton = hideTopToolbar, - ) - - /* Fetch user info while we're doing other things. This has to be done after setting up the - * drawer, though, because its callback touches the header in the drawer. */ - fetchUserInfo() - - fetchAnnouncements() - // 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 @@ -370,52 +379,90 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { tabAdapter = MainPagerAdapter(emptyList(), this) binding.viewPager.adapter = tabAdapter - setupTabs(showNotificationTab) + // Process different parts of the account flow depending on what's changed + val account = viewModel.pachliAccountFlow.filterNotNull() lifecycleScope.launch { - eventHub.events.collect { event -> - when (event) { - is ProfileEditedEvent -> onFetchUserInfoSuccess(event.newProfileData) - is MainTabsChangedEvent -> { - refreshMainDrawerItems( - intent.pachliAccountId, - addSearchButton = hideTopToolbar, - ) + repeatOnLifecycle(Lifecycle.State.CREATED) { + // TODO: Continue to call this, as it sets properties in NotificationConfig + androidNotificationsAreEnabled(this@MainActivity) + enableAllNotifications(this@MainActivity) + } + } - setupTabs(false) + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + // One-off setup independent of the UI state. + val initialAccount = viewModel.pachliAccountFlow.filterNotNull().first() + createNotificationChannelsForAccount(initialAccount.entity, this@MainActivity) + + bindMainDrawer(initialAccount) + bindMainDrawerItems(initialAccount, savedInstanceState) + + // Process the UI state. This has to happen *after* the main drawer has + // been configured. + launch { + viewModel.uiState.collect { uiState -> + bindMainDrawerSearch(this@MainActivity, initialAccount.id, uiState.hideTopToolbar) + bindMainDrawerProfileHeader(uiState) + bindMainDrawerScheduledPosts(this@MainActivity, initialAccount.id, uiState.canSchedulePost) } + } - is AnnouncementReadEvent -> { - unreadAnnouncementsCount-- - updateAnnouncementsBadge() + launch { + viewModel.uiState.distinctUntilChangedBy { it.accounts }.collect { + updateShortCuts(it.accounts) + } + } + + // Process changes to the account's header picture. + launch { + account.distinctUntilChangedBy { it.entity.profileHeaderPictureUrl }.collectLatest { + header.headerBackground = ImageHolder(it.entity.profileHeaderPictureUrl) } } } } + // Process changes to the account's lists. lifecycleScope.launch { - listsRepository.lists.collect { result -> - result.onSuccess { lists -> - // Update the list of lists in the main drawer - refreshMainDrawerItems(intent.pachliAccountId, addSearchButton = hideTopToolbar) - - // Any lists in tabs might have changed titles, update those - if (lists is Lists.Loaded && tabAdapter.tabs.any { it.timeline is Timeline.UserList }) { - setupTabs(false) - } + repeatOnLifecycle(Lifecycle.State.RESUMED) { + account.distinctUntilChangedBy { it.lists }.collectLatest { account -> + bindMainDrawerLists(account.id, account.lists) } + } + } - result.onFailure { - Snackbar.make(binding.root, R.string.error_list_load, Snackbar.LENGTH_INDEFINITE) - .setAction(app.pachli.core.ui.R.string.action_retry) { listsRepository.refresh() } - .show() + // Process changes to the account's profile picture. + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.RESUMED) { + account.distinctUntilChangedBy { it.entity.profilePictureUrl }.collectLatest { + bindDrawerAvatar(it.entity.profilePictureUrl, false) + } + } + } + + // Process changes to the account's tab preferences. + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.RESUMED) { + account.distinctUntilChangedBy { it.entity.tabPreferences }.collectLatest { + bindTabs(it.entity, showNotificationTab) + showNotificationTab = false } } } lifecycleScope.launch { - serverRepository.flow.collect { - refreshMainDrawerItems(intent.pachliAccountId, addSearchButton = hideTopToolbar) + repeatOnLifecycle(Lifecycle.State.RESUMED) { + account.distinctUntilChangedBy { it.announcements }.collectLatest { + bindMainDrawerAnnouncements(it.announcements) + } + } + } + + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.RESUMED) { + viewModel.uiResult.collect(::bindUiResult) } } @@ -443,7 +490,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { override fun onPrepareMenu(menu: Menu) { super.onPrepareMenu(menu) - menu.findItem(R.id.action_remove_tab).isVisible = tabAdapter.tabs[binding.viewPager.currentItem].timeline != Timeline.Home + menu.findItem(R.id.action_remove_tab).isVisible = tabAdapter.tabs.getOrNull(binding.viewPager.currentItem)?.timeline != Timeline.Home // 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 @@ -454,22 +501,16 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { super.onMenuItemSelected(menuItem) return when (menuItem.itemId) { R.id.action_search -> { - startActivity(SearchActivityIntent(this@MainActivity, intent.pachliAccountId)) + startActivity(SearchActivityIntent(this@MainActivity, pachliAccountId)) true } R.id.action_remove_tab -> { val timeline = tabAdapter.tabs[binding.viewPager.currentItem].timeline - accountManager.activeAccount?.let { - lifecycleScope.launch(Dispatchers.IO) { - it.tabPreferences = it.tabPreferences.filterNot { it == timeline } - accountManager.saveAccount(it) - eventHub.dispatch(MainTabsChangedEvent(it.tabPreferences)) - } - } + viewModel.accept(InfallibleUiAction.TabRemoveTimeline(timeline)) true } R.id.action_tab_preferences -> { - startActivity(TabPreferenceActivityIntent(this, intent.pachliAccountId)) + startActivity(TabPreferenceActivityIntent(this, pachliAccountId)) true } else -> super.onOptionsItemSelected(menuItem) @@ -481,8 +522,9 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { val currentEmojiPack = sharedPreferencesRepository.getString(EMOJI_PREFERENCE, "") if (currentEmojiPack != selectedEmojiPack) { Timber.d( - "onResume: EmojiPack has been changed from %s to %s" - .format(selectedEmojiPack, currentEmojiPack), + "onResume: EmojiPack has been changed from %s to %s", + selectedEmojiPack, + currentEmojiPack, ) selectedEmojiPack = currentEmojiPack recreate() @@ -511,7 +553,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { } KeyEvent.KEYCODE_SEARCH -> { startActivityWithDefaultTransition( - SearchActivityIntent(this, intent.pachliAccountId), + SearchActivityIntent(this, pachliAccountId), ) return true } @@ -521,7 +563,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { when (keyCode) { KeyEvent.KEYCODE_N -> { // open compose activity by pressing SHIFT + N (or CTRL + N) - val composeIntent = ComposeActivityIntent(applicationContext) + val composeIntent = ComposeActivityIntent(applicationContext, pachliAccountId) startActivity(composeIntent) return true } @@ -533,51 +575,152 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { public override fun onPostCreate(savedInstanceState: Bundle?) { super.onPostCreate(savedInstanceState) - if (intent != null) { - val redirectUrl = MainActivityIntent.getRedirectUrl(intent) - if (redirectUrl != null) { - viewUrl(intent.pachliAccountId, redirectUrl, PostLookupFallbackBehavior.DISPLAY_ERROR) - } + val payload = MainActivityIntent.payload(intent) + if (payload is Payload.Redirect) { + viewUrl(intent.pachliAccountId, payload.url, PostLookupFallbackBehavior.DISPLAY_ERROR) } } - private fun forwardToComposeActivityAndExit(intent: Intent) { - val composeOptions = ComposeActivityIntent.getOptions(intent) - - val composeIntent = if (composeOptions != null) { - ComposeActivityIntent(this, composeOptions) - } else { - ComposeActivityIntent(this).apply { - action = intent.action - type = intent.type - putExtras(intent) + private fun launchComposeActivityAndExit(pachliAccountId: Long, composeOptions: ComposeActivityIntent.ComposeOptions? = null) { + startActivity( + ComposeActivityIntent(this, pachliAccountId, composeOptions).apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK - } - } - startActivity(composeIntent) - - // Recreate the activity to ensure it is using the correct active account - // (which may have changed while processing the compose intent) and so - // the user returns to the timeline when they finish ComposeActivity. - recreate() + }, + ) + finish() } - private fun setupDrawer( - activeAccountId: Long, - savedInstanceState: Bundle?, - addSearchButton: Boolean, - ) { - val drawerOpenClickListener = View.OnClickListener { binding.mainDrawerLayout.open() } + private fun forwardToComposeActivityAndExit(pachliAccountId: Long, intent: Intent, composeOptions: ComposeActivityIntent.ComposeOptions? = null) { + val composeIntent = ComposeActivityIntent(this, pachliAccountId, composeOptions).apply { + action = intent.action + type = intent.type + putExtras(intent) + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + } + composeIntent.pachliAccountId = pachliAccountId + startActivity(composeIntent) + finish() + } + /** Act on the result of UI actions. */ + private suspend fun bindUiResult(uiResult: Result) { + uiResult.onFailure { uiError -> + when (uiError) { + is UiError.SetActiveAccount -> { + // Logging in failed. Show a dialog explaining what's happened. + val builder = AlertDialog.Builder(this@MainActivity) + .setMessage(uiError.fmt(this)) + .create() + + when (uiError.cause) { + is SetActiveAccountError.AccountDoesNotExist -> { + // Special case AccountDoesNotExist, as that should never happen. If it does + // there's nothing to do except try and switch back to the previous account. + val button = builder.await(android.R.string.ok) + if (button == AlertDialog.BUTTON_POSITIVE && uiError.cause.fallbackAccount != null) { + viewModel.accept(FallibleUiAction.SetActiveAccount(uiError.cause.fallbackAccount!!.id)) + } + return + } + + is SetActiveAccountError.Api -> when (uiError.cause.apiError) { + // Special case invalid tokens. The user can be prompted to relogin. Cancelling + // switches to the fallback account, or finishes if there is none. + is ClientError.Unauthorized -> { + builder.setTitle(uiError.cause.wantedAccount.fullName) + + val button = builder.await(R.string.action_relogin, android.R.string.cancel) + when (button) { + AlertDialog.BUTTON_POSITIVE -> { + startActivityWithTransition( + LoginActivityIntent( + this@MainActivity, + LoginMode.Reauthenticate(uiError.cause.wantedAccount.domain), + ), + TransitionKind.EXPLODE, + ) + finish() + } + + AlertDialog.BUTTON_NEGATIVE -> { + uiError.cause.fallbackAccount?.run { + viewModel.accept(FallibleUiAction.SetActiveAccount(id)) + } ?: finish() + } + } + } + + // Other API errors are retryable. + else -> { + builder.setTitle(uiError.cause.wantedAccount.fullName) + val button = builder.await(app.pachli.core.ui.R.string.action_retry, android.R.string.cancel) + when (button) { + AlertDialog.BUTTON_POSITIVE -> viewModel.accept(uiError.action) + else -> { + uiError.cause.fallbackAccount?.run { + viewModel.accept(FallibleUiAction.SetActiveAccount(id)) + } ?: finish() + } + } + } + } + + // Other errors are retryable. + is SetActiveAccountError.Unexpected -> { + builder.setTitle(uiError.cause.wantedAccount.fullName) + val button = builder.await(app.pachli.core.ui.R.string.action_retry, android.R.string.cancel) + when (button) { + AlertDialog.BUTTON_POSITIVE -> viewModel.accept(uiError.action) + else -> { + uiError.cause.fallbackAccount?.run { + viewModel.accept(FallibleUiAction.SetActiveAccount(id)) + } ?: finish() + } + } + } + } + } + + is UiError.RefreshAccount -> { + // Dialog that explains refreshing failed, with retry option. + val button = AlertDialog.Builder(this@MainActivity) + .setTitle(uiError.action.accountEntity.fullName) + .setMessage(uiError.fmt(this)) + .create() + .await(app.pachli.core.ui.R.string.action_retry, android.R.string.cancel) + if (button == AlertDialog.BUTTON_POSITIVE) viewModel.accept(uiError.action) + } + } + } + uiResult.onSuccess { uiSuccess -> + when (uiSuccess) { + is UiSuccess.RefreshAccount -> { + /* do nothing */ + } + + is UiSuccess.SetActiveAccount -> pachliAccountId = uiSuccess.accountEntity.id + } + } + } + + /** + * Initialises the main drawer and header properties. + * + * See [bindMainDrawerProfileHeader] for setting the header contents. + */ + private fun bindMainDrawer(pachliAccount: PachliAccount) { + // Clicking on navigation elements opens the drawer. + val drawerOpenClickListener = View.OnClickListener { binding.mainDrawerLayout.open() } binding.mainToolbar.setNavigationOnClickListener(drawerOpenClickListener) binding.topNav.setNavigationOnClickListener(drawerOpenClickListener) binding.bottomNav.setNavigationOnClickListener(drawerOpenClickListener) + // Header should allow user to add new accounts. header = AccountHeaderView(this).apply { headerBackgroundScaleType = ImageView.ScaleType.CENTER_CROP currentHiddenInList = true onAccountHeaderListener = { _: View?, profile: IProfile, current: Boolean -> - onAccountHeaderClick(profile, current) + onAccountHeaderClick(pachliAccount, profile, current) false } addProfile( @@ -599,21 +742,13 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { // Account header background and text colours are not styleable, so set them here header.accountHeaderBackground.setBackgroundColor( - MaterialColors.getColor( - header, - com.google.android.material.R.attr.colorSecondaryContainer, - ), + MaterialColors.getColor(header, com.google.android.material.R.attr.colorSecondaryContainer), ) val headerTextColor = MaterialColors.getColor(header, com.google.android.material.R.attr.colorOnSecondaryContainer) header.currentProfileName.setTextColor(headerTextColor) header.currentProfileEmail.setTextColor(headerTextColor) - DrawerImageLoader.init(MainDrawerImageLoader(glide, sharedPreferencesRepository.animateAvatars)) - - binding.mainDrawer.apply { - refreshMainDrawerItems(activeAccountId, addSearchButton) - setSavedInstance(savedInstanceState) - } + DrawerImageLoader.init(MainDrawerImageLoader(glide, viewModel.uiState.value.animateAvatars)) binding.mainDrawerLayout.addDrawerListener(object : DrawerListener { override fun onDrawerSlide(drawerView: View, slideOffset: Float) { } @@ -630,32 +765,124 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { }) } - private fun refreshMainDrawerItems(pachliAccountId: Long, addSearchButton: Boolean) { - val (listsDrawerItems, listsSectionTitle) = listsRepository.lists.value.get()?.let { result -> - when (result) { - Lists.Loading -> Pair(emptyList(), R.string.title_lists_loading) - is Lists.Loaded -> Pair( - result.lists.sortedWith(compareByListTitle) - .map { list -> - primaryDrawerItem { - nameText = list.title - iconicsIcon = GoogleMaterial.Icon.gmd_list - onClick = { - startActivityWithDefaultTransition( - TimelineActivityIntent.list( - this@MainActivity, - pachliAccountId, - list.id, - list.title, - ), - ) - } - } - }, - app.pachli.feature.lists.R.string.title_lists, - ) - } - } ?: Pair(emptyList(), R.string.title_lists_failed) + /** + * Binds the "search" menu item in the main drawer. + * + * @param context + * @param pachliAccountId + * @param showSearchItem True if a "Search" menu item should be added to the list + * (because the top toolbar is hidden), false if any existing search item should be + * removed. + */ + private fun bindMainDrawerSearch(context: Context, pachliAccountId: Long, showSearchItem: Boolean) { + val searchItemPosition = binding.mainDrawer.getPosition(DRAWER_ITEM_SEARCH) + val showing = searchItemPosition != -1 + + // If it's showing state and desired showing state are the same there's nothing + // to do. + if (showing == showSearchItem) return + + // Showing and not wanted, remove it. + if (!showSearchItem) { + binding.mainDrawer.removeItemByPosition(searchItemPosition) + return + } + + // Add a "Search" menu item. + binding.mainDrawer.addItemAtPosition( + 4, + primaryDrawerItem { + identifier = DRAWER_ITEM_SEARCH + nameRes = R.string.action_search + iconicsIcon = GoogleMaterial.Icon.gmd_search + onClick = { + startActivityWithDefaultTransition( + SearchActivityIntent(context, pachliAccountId), + ) + } + }, + ) + updateMainDrawerTypeface( + EmbeddedFontFamily.from(sharedPreferencesRepository.getString(FONT_FAMILY, "default")), + ) + } + + /** + * Binds the "Scheduled posts" menu item in the main drawer. + * + * @param context + * @param pachliAccountId + * @param showSchedulePosts True if a "Scheduled posts" menu item should be added + * to the list, false if any existing item should be removed. + */ + private fun bindMainDrawerScheduledPosts(context: Context, pachliAccountId: Long, showSchedulePosts: Boolean) { + val existingPosition = binding.mainDrawer.getPosition(DRAWER_ITEM_SCHEDULED_POSTS) + val showing = existingPosition != -1 + + if (showing == showSchedulePosts) return + + if (!showSchedulePosts) { + binding.mainDrawer.removeItemByPosition(existingPosition) + return + } + + // Add the "Scheduled posts" item immediately after "Drafts" + binding.mainDrawer.addItemAtPosition( + binding.mainDrawer.getPosition(DRAWER_ITEM_DRAFTS) + 1, + primaryDrawerItem { + identifier = DRAWER_ITEM_SCHEDULED_POSTS + nameRes = R.string.action_access_scheduled_posts + iconRes = R.drawable.ic_access_time + onClick = { + startActivityWithDefaultTransition( + ScheduledStatusActivityIntent(context, pachliAccountId), + ) + } + }, + ) + + updateMainDrawerTypeface( + EmbeddedFontFamily.from(sharedPreferencesRepository.getString(FONT_FAMILY, "default")), + ) + } + + /** Binds [lists] to the "Lists" section in the main drawer. */ + private fun bindMainDrawerLists(pachliAccountId: Long, lists: List) { + binding.mainDrawer.removeItems(*listDrawerItems.toTypedArray()) + + listDrawerItems.clear() + lists.forEach { list -> + listDrawerItems.add( + primaryDrawerItem { + nameText = list.title + iconicsIcon = GoogleMaterial.Icon.gmd_list + onClick = { + startActivityWithDefaultTransition( + TimelineActivityIntent.list( + this@MainActivity, + pachliAccountId, + list.listId, + list.title, + ), + ) + } + }, + ) + } + val headerPosition = binding.mainDrawer.getPosition(DRAWER_ITEM_LISTS) + binding.mainDrawer.addItemsAtPosition(headerPosition + 1, *listDrawerItems.toTypedArray()) + updateMainDrawerTypeface( + EmbeddedFontFamily.from(sharedPreferencesRepository.getString(FONT_FAMILY, "default")), + ) + } + + /** + * Binds the normal drawer items. + * + * See [bindMainDrawerLists] and [bindMainDrawerSearch]. + */ + private fun bindMainDrawerItems(pachliAccount: PachliAccount, savedInstanceState: Bundle?) { + val pachliAccountId = pachliAccount.id binding.mainDrawer.apply { itemAdapter.clear() @@ -752,9 +979,8 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { }, SectionDrawerItem().apply { identifier = DRAWER_ITEM_LISTS - nameRes = listsSectionTitle + nameRes = app.pachli.feature.lists.R.string.title_lists }, - *listsDrawerItems.toTypedArray(), primaryDrawerItem { nameRes = R.string.manage_lists iconicsIcon = GoogleMaterial.Icon.gmd_settings @@ -829,41 +1055,11 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { secondaryDrawerItem { nameRes = R.string.action_logout iconRes = R.drawable.ic_logout - onClick = ::logout + onClick = { logout(pachliAccount) } }, ) - if (addSearchButton) { - binding.mainDrawer.addItemsAtPosition( - 4, - primaryDrawerItem { - nameRes = R.string.action_search - iconicsIcon = GoogleMaterial.Icon.gmd_search - onClick = { - startActivityWithDefaultTransition( - SearchActivityIntent(context, pachliAccountId), - ) - } - }, - ) - } - } - - // If the server supports scheduled posts then add a "Scheduled posts" item - // after the "Drafts" item. - if (serverRepository.flow.replayCache.lastOrNull()?.get()?.can(ServerOperation.ORG_JOINMASTODON_STATUSES_SCHEDULED, ">= 1.0.0".toConstraint()) == true) { - binding.mainDrawer.addItemAtPosition( - binding.mainDrawer.getPosition(DRAWER_ITEM_DRAFTS) + 1, - primaryDrawerItem { - nameRes = R.string.action_access_scheduled_posts - iconRes = R.drawable.ic_access_time - onClick = { - startActivityWithDefaultTransition( - ScheduledStatusActivityIntent(binding.mainDrawer.context, pachliAccountId), - ) - } - }, - ) + setSavedInstance(savedInstanceState) } if (BuildConfig.DEBUG) { @@ -902,13 +1098,13 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { 0 -> { Timber.d("Clearing home timeline cache") lifecycleScope.launch { - developerToolsUseCase.clearHomeTimelineCache(intent.pachliAccountId) + developerToolsUseCase.clearHomeTimelineCache(pachliAccountId) } } 1 -> { Timber.d("Removing most recent 40 statuses") lifecycleScope.launch { - developerToolsUseCase.deleteFirstKStatuses(intent.pachliAccountId, 40) + developerToolsUseCase.deleteFirstKStatuses(pachliAccountId, 40) } } } @@ -917,6 +1113,8 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { } /** + * Sets the correct typeface for everything in the drawer. + * * The drawer library forces the `android:fontFamily` attribute, overriding the value in the * theme. Force-ably set the typeface for everything in the drawer if using a non-default font. */ @@ -935,7 +1133,15 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { super.onSaveInstanceState(binding.mainDrawer.saveInstanceState(outState)) } - private fun setupTabs(selectNotificationTab: Boolean) { + /** + * Binds the [account]'s tab preferences to the UI. + * + * Chooses the active tab based on the previously active tab and [selectNotificationTab]. + * + * @param account + * @param selectNotificationTab True if the "Notification" tab should be made active. + */ + private fun bindTabs(account: AccountEntity, selectNotificationTab: Boolean) { val activeTabLayout = when (sharedPreferencesRepository.mainNavigationPosition) { MainNavigationPosition.TOP -> { binding.bottomNav.hide() @@ -954,21 +1160,19 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { } } - activeTabLayout.alignment = when (sharedPreferencesRepository.tabAlignment) { + activeTabLayout.alignment = when (viewModel.uiState.value.tabAlignment) { TabAlignment.START -> AlignableTabLayoutAlignment.START TabAlignment.JUSTIFY_IF_POSSIBLE -> AlignableTabLayoutAlignment.JUSTIFY_IF_POSSIBLE TabAlignment.END -> AlignableTabLayoutAlignment.END } - val tabContents = sharedPreferencesRepository.tabContents + val tabContents = viewModel.uiState.value.tabContents activeTabLayout.isInlineLabel = tabContents == TabContents.ICON_TEXT_INLINE // Save the previous tab so it can be restored later val previousTabIndex = binding.viewPager.currentItem val previousTab = tabAdapter.tabs.getOrNull(previousTabIndex) - val tabs = accountManager.activeAccount?.let { account -> - account.tabPreferences.map { TabViewData.from(account.id, it) } - }.orEmpty() + val tabs = account.tabPreferences.map { TabViewData.from(account.id, it) } // Detach any existing mediator before changing tab contents and attaching a new mediator tabLayoutMediator?.detach() @@ -1015,7 +1219,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { val pageMargin = resources.getDimensionPixelSize(DR.dimen.tab_page_margin) binding.viewPager.setPageTransformer(MarginPageTransformer(pageMargin)) - binding.viewPager.isUserInputEnabled = sharedPreferencesRepository.enableTabSwipe + binding.viewPager.isUserInputEnabled = viewModel.uiState.value.enableTabSwipe onTabSelectedListener?.let { activeTabLayout.removeOnTabSelectedListener(it) @@ -1048,46 +1252,48 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { } refreshComposeButtonState(tabs[position]) - - updateProfiles() } private fun refreshComposeButtonState(tabViewData: TabViewData) { tabViewData.composeIntent?.let { intent -> binding.composeButton.setOnClickListener { - startActivity(intent(applicationContext)) + startActivity(intent(applicationContext, pachliAccountId)) } binding.composeButton.show() } ?: binding.composeButton.hide() } - private fun onAccountHeaderClick(profile: IProfile, current: Boolean) { - val activeAccount = accountManager.activeAccount - - // open profile when active image was clicked - if (current && activeAccount != null) { - val intent = AccountActivityIntent(this, activeAccount.id, activeAccount.accountId) - startActivityWithDefaultTransition(intent) - return - } - // open LoginActivity to add new account - if (profile.identifier == DRAWER_ITEM_ADD_ACCOUNT) { - startActivityWithDefaultTransition( - LoginActivityIntent(this, LoginMode.ADDITIONAL_LOGIN), + /** + * Handles clicks on profile avatars in the main drawer header. + * + * Either: + * - Opens the user's account if they clicked on their profile. + * - Starts LoginActivity to add a new account. + * - Switch account. + * + * @param pachliAccount + * @param profile + * @param current True if the clicked avatar is the currently logged in account + */ + private fun onAccountHeaderClick(pachliAccount: PachliAccount, profile: IProfile, current: Boolean) { + when { + current -> startActivityWithDefaultTransition( + AccountActivityIntent(this, pachliAccount.id, pachliAccount.entity.accountId), ) - return + + profile.identifier == DRAWER_ITEM_ADD_ACCOUNT -> startActivityWithDefaultTransition( + LoginActivityIntent(this, LoginMode.AdditionalLogin), + ) + + else -> changeAccountAndRestart(profile.identifier) } - // change Account - changeAccountAndRestart(profile.identifier, null) - return } /** * Relaunches MainActivity, switched to the account identified by [accountId]. */ - private fun changeAccountAndRestart(accountId: Long, forward: Intent?) { + private fun changeAccountAndRestart(accountId: Long, forward: Intent? = null) { cacheUpdater.stop() - accountManager.setActiveAccount(accountId) val intent = MainActivityIntent(this, accountId) intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK if (forward != null) { @@ -1095,71 +1301,45 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { intent.action = forward.action intent.putExtras(forward) } + intent.pachliAccountId = accountId startActivityWithTransition(intent, TransitionKind.EXPLODE) finish() } - private fun logout() { - accountManager.activeAccount?.let { activeAccount -> - AlertDialog.Builder(this) + private fun logout(pachliAccount: PachliAccount) { + lifecycleScope.launch { + val button = AlertDialog.Builder(this@MainActivity) .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() + .setMessage(getString(R.string.action_logout_confirm, pachliAccount.entity.fullName)) + .create() + .await(android.R.string.ok, android.R.string.cancel) - lifecycleScope.launch { - val nextAccount = logout.invoke() - val intent = nextAccount?.let { - MainActivityIntent(this@MainActivity, it.id) - } ?: LoginActivityIntent(this@MainActivity, LoginMode.DEFAULT) - startActivity(intent) - finish() - } - } - .setNegativeButton(android.R.string.cancel, null) - .show() - } - } - - private fun fetchUserInfo() = lifecycleScope.launch { - mastodonApi.accountVerifyCredentials().fold( - { userInfo -> - onFetchUserInfoSuccess(userInfo) - }, - { throwable -> - Timber.e(throwable, "Failed to fetch user info.") - }, - ) - } - - private fun onFetchUserInfoSuccess(me: Account) { - header.headerBackground = ImageHolder(me.header) - - loadDrawerAvatar(me.avatar, false) - - accountManager.updateActiveAccount(me) - createNotificationChannelsForAccount(accountManager.activeAccount!!, this) - - // Setup notifications - // TODO: Continue to call this, as it sets properties in NotificationConfig - androidNotificationsAreEnabled(this) - lifecycleScope.launch { enableAllNotifications(this@MainActivity) } - - updateProfiles() - - externalScope.launch { - updateShortcuts(applicationContext, accountManager) + if (button == AlertDialog.BUTTON_POSITIVE) { + binding.mainDrawerLayout.close() + binding.appBar.hide() + binding.viewPager.hide() + binding.progressBar.show() + binding.bottomNav.hide() + binding.composeButton.hide() + val nextAccount = logout.invoke(pachliAccount.entity).get() + val intent = nextAccount?.let { MainActivityIntent(this@MainActivity, it.id) } + ?: LoginActivityIntent(this@MainActivity, LoginMode.Default) + startActivity(intent) + finish() + } } } + /** + * Binds the user's avatar image to the avatar view in the appropriate toolbar. + * + * @param avatarUrl URL for the image to load + * @param showPlaceholder True if a placeholder image should be shown while loading + */ @SuppressLint("CheckResult") - private fun loadDrawerAvatar(avatarUrl: String, showPlaceholder: Boolean) { - val hideTopToolbar = sharedPreferencesRepository.hideTopToolbar - val animateAvatars = sharedPreferencesRepository.animateAvatars + private fun bindDrawerAvatar(avatarUrl: String, showPlaceholder: Boolean) { + val hideTopToolbar = viewModel.uiState.value.hideTopToolbar + val animateAvatars = viewModel.uiState.value.animateAvatars val activeToolbar = if (hideTopToolbar) { when (sharedPreferencesRepository.mainNavigationPosition) { @@ -1173,27 +1353,17 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { val navIconSize = resources.getDimensionPixelSize(DR.dimen.avatar_toolbar_nav_icon_size) if (animateAvatars) { - glide.asDrawable().load(avatarUrl).transform( - RoundedCorners( - resources.getDimensionPixelSize( - DR.dimen.avatar_radius_36dp, - ), - ), - ) + glide.asDrawable().load(avatarUrl).transform(RoundedCorners(resources.getDimensionPixelSize(DR.dimen.avatar_radius_36dp))) .apply { if (showPlaceholder) placeholder(DR.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?, - ) { + override fun onResourceReady(resource: Drawable, transition: Transition?) { if (resource is Animatable) resource.start() activeToolbar.navigationIcon = FixedSizeDrawable(resource, navIconSize, navIconSize) } @@ -1207,11 +1377,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { ) } else { glide.asBitmap().load(avatarUrl).transform( - RoundedCorners( - resources.getDimensionPixelSize( - DR.dimen.avatar_radius_36dp, - ), - ), + RoundedCorners(resources.getDimensionPixelSize(DR.dimen.avatar_radius_36dp)), ) .apply { if (showPlaceholder) placeholder(DR.drawable.avatar_default) } .into( @@ -1222,10 +1388,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { } } - override fun onResourceReady( - resource: Bitmap, - transition: Transition?, - ) { + override fun onResourceReady(resource: Bitmap, transition: Transition?) { activeToolbar.navigationIcon = FixedSizeDrawable( BitmapDrawable(resources, resource), navIconSize, @@ -1243,38 +1406,31 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { } } - private fun fetchAnnouncements() { - lifecycleScope.launch { - mastodonApi.listAnnouncements(false) - .fold( - { announcements -> - unreadAnnouncementsCount = announcements.count { !it.read } - updateAnnouncementsBadge() - }, - { throwable -> - Timber.w(throwable, "Failed to fetch announcements.") - }, - ) - } + /** + * Binds the server's announcements to the main drawer. + * + * Shows/clears a badge showing the number of unread announcements. + */ + private fun bindMainDrawerAnnouncements(announcements: List) { + val unread = announcements.count { !it.read } + binding.mainDrawer.updateBadge(DRAWER_ITEM_ANNOUNCEMENTS, StringHolder(if (unread <= 0) null else unread.toString())) } - private fun updateAnnouncementsBadge() { - binding.mainDrawer.updateBadge(DRAWER_ITEM_ANNOUNCEMENTS, StringHolder(if (unreadAnnouncementsCount <= 0) null else unreadAnnouncementsCount.toString())) - } - - private fun updateProfiles() { - val animateEmojis = sharedPreferencesRepository.animateEmojis - val profiles: MutableList = - accountManager.getAllAccountsOrderedByActive().map { acc -> - ProfileDrawerItem().apply { - isSelected = acc.isActive - nameText = acc.displayName.emojify(acc.emojis, header, animateEmojis) - iconUrl = acc.profilePictureUrl - isNameShown = true - identifier = acc.id - descriptionText = acc.fullName - } - }.toMutableList() + /** + * Sets the profile information in the main drawer header. + */ + private fun bindMainDrawerProfileHeader(uiState: UiState) { + val animateEmojis = uiState.animateEmojis + val profiles: MutableList = uiState.accounts.map { acc -> + ProfileDrawerItem().apply { + isSelected = acc.isActive + 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()) { @@ -1285,23 +1441,30 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { } header.clear() header.profiles = profiles - header.setActiveProfile(accountManager.activeAccount!!.id) - binding.mainToolbar.subtitle = if (accountManager.shouldDisplaySelfUsername()) { - accountManager.activeAccount!!.fullName + val activeAccount = uiState.accounts.firstOrNull { it.isActive } ?: return + header.setActiveProfile(activeAccount.id) + binding.mainToolbar.subtitle = if (uiState.displaySelfUsername) { + activeAccount.fullName } else { null } } companion object { - private const val DRAWER_ITEM_ADD_ACCOUNT: Long = -13 - private const val DRAWER_ITEM_ANNOUNCEMENTS: Long = 14 + private const val DRAWER_ITEM_ADD_ACCOUNT = -13L + private const val DRAWER_ITEM_ANNOUNCEMENTS = 14L /** Drawer identifier for the "Lists" section header. */ - private const val DRAWER_ITEM_LISTS: Long = 15 + private const val DRAWER_ITEM_LISTS = 15L /** Drawer identifier for the "Drafts" item. */ private const val DRAWER_ITEM_DRAFTS = 16L + + /** Drawer identifier for the "Search" item. */ + private const val DRAWER_ITEM_SEARCH = 17L + + /** Drawer identifier for the "Scheduled posts" item. */ + private const val DRAWER_ITEM_SCHEDULED_POSTS = 18L } } diff --git a/app/src/main/java/app/pachli/MainViewModel.kt b/app/src/main/java/app/pachli/MainViewModel.kt new file mode 100644 index 000000000..8f0b40cc7 --- /dev/null +++ b/app/src/main/java/app/pachli/MainViewModel.kt @@ -0,0 +1,242 @@ +/* + * Copyright 2024 Pachli Association + * + * This file is a part of Pachli. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Pachli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Pachli; if not, + * see . + */ + +package app.pachli + +import androidx.annotation.StringRes +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import app.pachli.core.common.PachliError +import app.pachli.core.data.model.Server +import app.pachli.core.data.repository.AccountManager +import app.pachli.core.data.repository.RefreshAccountError +import app.pachli.core.data.repository.SetActiveAccountError +import app.pachli.core.database.model.AccountEntity +import app.pachli.core.model.ServerOperation +import app.pachli.core.model.Timeline +import app.pachli.core.preferences.MainNavigationPosition +import app.pachli.core.preferences.PrefKeys +import app.pachli.core.preferences.SharedPreferencesRepository +import app.pachli.core.preferences.ShowSelfUsername +import app.pachli.core.preferences.TabAlignment +import app.pachli.core.preferences.TabContents +import com.github.michaelbull.result.Result +import com.github.michaelbull.result.mapEither +import com.github.michaelbull.result.onSuccess +import dagger.hilt.android.lifecycle.HiltViewModel +import io.github.z4kn4fein.semver.constraints.toConstraint +import javax.inject.Inject +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.shareIn +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +/** Actions the user can take from the UI. */ +internal sealed interface UiAction + +internal sealed interface FallibleUiAction : UiAction { + data class SetActiveAccount(val pachliAccountId: Long) : FallibleUiAction + data class RefreshAccount(val accountEntity: AccountEntity) : FallibleUiAction +} + +internal sealed interface InfallibleUiAction : UiAction { + /** Remove [timeline] from the active account's tabs. */ + data class TabRemoveTimeline(val timeline: Timeline) : InfallibleUiAction +} + +/** Actions that succeeded. */ +internal sealed interface UiSuccess { + val action: FallibleUiAction + + data class SetActiveAccount( + override val action: FallibleUiAction.SetActiveAccount, + val accountEntity: AccountEntity, + ) : UiSuccess + + data class RefreshAccount( + override val action: FallibleUiAction.RefreshAccount, + ) : UiSuccess +} + +/** Actions that failed. */ +internal sealed class UiError( + @StringRes override val resourceId: Int, + open val action: UiAction, + override val cause: PachliError, + override val formatArgs: Array? = null, +) : PachliError { + data class SetActiveAccount( + override val action: FallibleUiAction.SetActiveAccount, + override val cause: SetActiveAccountError, + ) : UiError(R.string.main_viewmodel_error_set_active_account, action, cause) + + data class RefreshAccount( + override val action: FallibleUiAction.RefreshAccount, + override val cause: RefreshAccountError, + ) : UiError(R.string.main_viewmodel_error_refresh_account, action, cause) +} + +/** + * @param animateAvatars See [SharedPreferencesRepository.animateAvatars]. + * @param animateEmojis See [SharedPreferencesRepository.animateEmojis]. + * @param enableTabSwipe See [SharedPreferencesRepository.enableTabSwipe]. + * @param hideTopToolbar See [SharedPreferencesRepository.hideTopToolbar]. + * @param mainNavigationPosition See [SharedPreferencesRepository.mainNavigationPosition]. + * @param displaySelfUsername See [ShowSelfUsername]. + * @param accounts Unordered list of available accounts. + * @param canSchedulePost True if the account can schedule posts + */ +data class UiState( + val animateAvatars: Boolean, + val animateEmojis: Boolean, + val enableTabSwipe: Boolean, + val hideTopToolbar: Boolean, + val mainNavigationPosition: MainNavigationPosition, + val displaySelfUsername: Boolean, + val accounts: List, + val canSchedulePost: Boolean, + val tabAlignment: TabAlignment, + val tabContents: TabContents, +) { + companion object { + fun make(prefs: SharedPreferencesRepository, accounts: List, server: Server?) = UiState( + animateAvatars = prefs.animateAvatars, + animateEmojis = prefs.animateEmojis, + enableTabSwipe = prefs.enableTabSwipe, + hideTopToolbar = prefs.hideTopToolbar, + mainNavigationPosition = prefs.mainNavigationPosition, + displaySelfUsername = when (prefs.showSelfUsername) { + ShowSelfUsername.ALWAYS -> true + ShowSelfUsername.DISAMBIGUATE -> accounts.size > 1 + ShowSelfUsername.NEVER -> false + }, + accounts = accounts, + canSchedulePost = server?.can(ServerOperation.ORG_JOINMASTODON_STATUSES_SCHEDULED, ">= 1.0.0".toConstraint()) == true, + tabAlignment = prefs.tabAlignment, + tabContents = prefs.tabContents, + ) + } +} + +@HiltViewModel() +internal class MainViewModel @Inject constructor( + private val accountManager: AccountManager, + private val sharedPreferencesRepository: SharedPreferencesRepository, +) : ViewModel() { + /** + * Flow of Pachli Account IDs, the most recent entry in the flow is the active account. + * + * Initially null, the activity sets this by sending [FallibleUiAction.SetActiveAccount]. + */ + private val pachliAccountIdFlow = MutableStateFlow(null) + + val pachliAccountFlow = pachliAccountIdFlow.filterNotNull().flatMapLatest { accountId -> + accountManager.getPachliAccountFlow(accountId) + .filterNotNull() + }.shareIn(viewModelScope, SharingStarted.WhileSubscribed(5000), replay = 1) + + private val uiAction = MutableSharedFlow() + + val accept: (UiAction) -> Unit = { action -> viewModelScope.launch { uiAction.emit(action) } } + + private val _uiResult = Channel>() + val uiResult = _uiResult.receiveAsFlow() + + private val watchedPrefs = setOf( + PrefKeys.ANIMATE_GIF_AVATARS, + PrefKeys.ANIMATE_CUSTOM_EMOJIS, + PrefKeys.ENABLE_SWIPE_FOR_TABS, + PrefKeys.HIDE_TOP_TOOLBAR, + PrefKeys.MAIN_NAV_POSITION, + PrefKeys.SHOW_SELF_USERNAME, + PrefKeys.TAB_ALIGNMENT, + PrefKeys.TAB_CONTENTS, + ) + + val uiState = + combine( + sharedPreferencesRepository.changes.filter { watchedPrefs.contains(it) }.onStart { emit(null) }, + accountManager.accountsFlow, + pachliAccountFlow, + ) { _, accounts, pachliAccount -> + UiState.make( + sharedPreferencesRepository, + accounts, + pachliAccount.server, + ) + } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = UiState.make(sharedPreferencesRepository, accountManager.accounts, null), + ) + + init { + viewModelScope.launch { uiAction.collect { launch { onUiAction(it) } } } + } + + private suspend fun onUiAction(uiAction: UiAction) { + if (uiAction is InfallibleUiAction) { + when (uiAction) { + is InfallibleUiAction.TabRemoveTimeline -> onTabRemoveTimeline(uiAction.timeline) + } + } + + if (uiAction is FallibleUiAction) { + val result = when (uiAction) { + is FallibleUiAction.SetActiveAccount -> onSetActiveAccount(uiAction) + is FallibleUiAction.RefreshAccount -> onRefreshAccount(uiAction) + } + _uiResult.send(result) + } + } + + private suspend fun onSetActiveAccount(action: FallibleUiAction.SetActiveAccount): Result { + return accountManager.setActiveAccount(action.pachliAccountId) + .mapEither( + { UiSuccess.SetActiveAccount(action, it) }, + { UiError.SetActiveAccount(action, it) }, + ) + .onSuccess { + pachliAccountIdFlow.value = it.accountEntity.id + uiAction.emit(FallibleUiAction.RefreshAccount(it.accountEntity)) + } + } + + private suspend fun onRefreshAccount(action: FallibleUiAction.RefreshAccount): Result { + return accountManager.refresh(action.accountEntity) + .mapEither( + { UiSuccess.RefreshAccount(action) }, + { UiError.RefreshAccount(action, it) }, + ) + } + + private suspend fun onTabRemoveTimeline(timeline: Timeline) { + val active = pachliAccountFlow.replayCache.last().entity + val tabPreferences = active.tabPreferences.filterNot { it == timeline } + accountManager.setTabPreferences(active.id, tabPreferences) + } +} diff --git a/app/src/main/java/app/pachli/TabPreferenceActivity.kt b/app/src/main/java/app/pachli/TabPreferenceActivity.kt index ad2f0aa89..ff5aa376c 100644 --- a/app/src/main/java/app/pachli/TabPreferenceActivity.kt +++ b/app/src/main/java/app/pachli/TabPreferenceActivity.kt @@ -37,42 +37,33 @@ import androidx.transition.TransitionManager import app.pachli.adapter.ItemInteractionListener import app.pachli.adapter.TabAdapter import app.pachli.appstore.EventHub -import app.pachli.appstore.MainTabsChangedEvent import app.pachli.core.activity.BaseActivity import app.pachli.core.common.extensions.show import app.pachli.core.common.extensions.viewBinding import app.pachli.core.common.extensions.visible import app.pachli.core.common.util.unsafeLazy -import app.pachli.core.data.repository.Lists +import app.pachli.core.data.model.MastodonList import app.pachli.core.data.repository.ListsRepository import app.pachli.core.data.repository.ListsRepository.Companion.compareByListTitle import app.pachli.core.designsystem.R as DR import app.pachli.core.model.Timeline import app.pachli.core.navigation.ListsActivityIntent import app.pachli.core.navigation.pachliAccountId -import app.pachli.core.network.model.MastoList -import app.pachli.core.network.retrofit.MastodonApi import app.pachli.databinding.ActivityTabPreferenceBinding import app.pachli.databinding.DialogSelectListBinding import at.connyduck.sparkbutton.helpers.Utils -import com.github.michaelbull.result.onFailure -import com.github.michaelbull.result.onSuccess import com.google.android.material.divider.MaterialDividerItemDecoration -import com.google.android.material.snackbar.Snackbar import com.google.android.material.transition.MaterialArcMotion import com.google.android.material.transition.MaterialContainerTransform import dagger.hilt.android.AndroidEntryPoint import java.util.regex.Pattern import javax.inject.Inject import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch @AndroidEntryPoint class TabPreferenceActivity : BaseActivity(), ItemInteractionListener { - - @Inject - lateinit var mastodonApi: MastodonApi - @Inject lateinit var eventHub: EventHub @@ -86,8 +77,6 @@ class TabPreferenceActivity : BaseActivity(), ItemInteractionListener { private lateinit var touchHelper: ItemTouchHelper private lateinit var addTabAdapter: TabAdapter - private var tabsChanged = false - private val selectedItemElevation by unsafeLazy { resources.getDimension(DR.dimen.selected_drag_item_elevation) } private val hashtagRegex by unsafeLazy { Pattern.compile("([\\w_]*[\\p{Alpha}_][\\w_]*)", Pattern.CASE_INSENSITIVE) } @@ -281,7 +270,7 @@ class TabPreferenceActivity : BaseActivity(), ItemInteractionListener { } private fun showSelectListDialog() { - val adapter = object : ArrayAdapter(this, android.R.layout.simple_list_item_1) { + val adapter = object : ArrayAdapter(this, android.R.layout.simple_list_item_1) { override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { val view = super.getView(position, convertView, parent) getItem(position)?.let { item -> (view as TextView).text = item.title } @@ -300,7 +289,7 @@ class TabPreferenceActivity : BaseActivity(), ItemInteractionListener { adapter.getItem(position)?.let { item -> val newTab = TabViewData.from( intent.pachliAccountId, - Timeline.UserList(item.id, item.title), + Timeline.UserList(item.listId, item.title), ) currentTabs.add(newTab) currentTabsAdapter.notifyItemInserted(currentTabs.size - 1) @@ -324,21 +313,11 @@ class TabPreferenceActivity : BaseActivity(), ItemInteractionListener { dialog.show() lifecycleScope.launch { - listsRepository.lists.collect { result -> - result.onSuccess { lists -> - if (lists is Lists.Loaded) { - selectListBinding.progressBar.hide() - adapter.clear() - adapter.addAll(lists.lists.sortedWith(compareByListTitle)) - if (lists.lists.isEmpty()) selectListBinding.noLists.show() - } - } - - result.onFailure { - selectListBinding.progressBar.hide() - dialog.dismiss() - Snackbar.make(binding.root, R.string.error_list_load, Snackbar.LENGTH_LONG).show() - } + listsRepository.getLists(intent.pachliAccountId).collectLatest { lists -> + selectListBinding.progressBar.hide() + adapter.clear() + adapter.addAll(lists.sortedWith(compareByListTitle)) + if (lists.isEmpty()) selectListBinding.noLists.show() } } } @@ -410,18 +389,7 @@ class TabPreferenceActivity : BaseActivity(), ItemInteractionListener { private fun saveTabs() { accountManager.activeAccount?.let { lifecycleScope.launch(Dispatchers.IO) { - it.tabPreferences = currentTabs.map { it.timeline } - accountManager.saveAccount(it) - } - } - tabsChanged = true - } - - override fun onPause() { - super.onPause() - if (tabsChanged) { - lifecycleScope.launch { - eventHub.dispatch(MainTabsChangedEvent(currentTabs.map { it.timeline })) + accountManager.setTabPreferences(it.id, currentTabs.map { it.timeline }) } } } diff --git a/app/src/main/java/app/pachli/TabViewData.kt b/app/src/main/java/app/pachli/TabViewData.kt index 7fbdab815..3690b17fe 100644 --- a/app/src/main/java/app/pachli/TabViewData.kt +++ b/app/src/main/java/app/pachli/TabViewData.kt @@ -50,7 +50,7 @@ data class TabViewData( @DrawableRes val icon: Int, val fragment: () -> Fragment, val title: (Context) -> String = { context -> context.getString(text) }, - val composeIntent: ((Context) -> Intent)? = { context -> ComposeActivityIntent(context) }, + val composeIntent: ((Context, Long) -> Intent)? = { context, pachliAccountId -> ComposeActivityIntent(context, pachliAccountId) }, ) { override fun equals(other: Any?): Boolean { if (this === other) return true @@ -94,9 +94,10 @@ data class TabViewData( text = R.string.title_direct_messages, icon = R.drawable.ic_reblog_direct_24dp, fragment = { ConversationsFragment.newInstance(pachliAccountId) }, - ) { + ) { context, pachliAccountId -> ComposeActivityIntent( - it, + context, + pachliAccountId, ComposeActivityIntent.ComposeOptions(visibility = Status.Visibility.PRIVATE), ) } @@ -129,10 +130,11 @@ data class TabViewData( context.getString(R.string.title_tag, it) } }, - ) { context -> + ) { context, pachliAccountId -> val tag = timeline.tags.first() ComposeActivityIntent( context, + pachliAccountId, ComposeActivityIntent.ComposeOptions( content = getString(context, R.string.title_tag_with_initial_position).format(tag), initialCursorPosition = ComposeActivityIntent.ComposeOptions.InitialCursorPosition.START, diff --git a/app/src/main/java/app/pachli/TimelineActivity.kt b/app/src/main/java/app/pachli/TimelineActivity.kt index a7ae620a8..70ef8d5d6 100644 --- a/app/src/main/java/app/pachli/TimelineActivity.kt +++ b/app/src/main/java/app/pachli/TimelineActivity.kt @@ -26,13 +26,13 @@ import androidx.core.view.MenuProvider import androidx.fragment.app.commit import androidx.lifecycle.lifecycleScope import app.pachli.appstore.EventHub -import app.pachli.appstore.MainTabsChangedEvent import app.pachli.core.activity.BottomSheetActivity import app.pachli.core.common.extensions.viewBinding import app.pachli.core.common.util.unsafeLazy import app.pachli.core.data.repository.ContentFilterEdit -import app.pachli.core.data.repository.ContentFiltersError import app.pachli.core.data.repository.ContentFiltersRepository +import app.pachli.core.data.repository.canFilterV1 +import app.pachli.core.data.repository.canFilterV2 import app.pachli.core.model.ContentFilter import app.pachli.core.model.FilterAction import app.pachli.core.model.FilterContext @@ -53,6 +53,8 @@ import com.google.android.material.snackbar.Snackbar import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.distinctUntilChangedBy +import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.launch import timber.log.Timber @@ -116,7 +118,14 @@ class TimelineActivity : BottomSheetActivity(), AppBarLayoutHost, ActionButtonAc } viewData.composeIntent?.let { intent -> - binding.composeButton.setOnClickListener { startActivity(intent(this@TimelineActivity)) } + binding.composeButton.setOnClickListener { + startActivity( + intent( + this@TimelineActivity, + this.intent.pachliAccountId, + ), + ) + } binding.composeButton.show() } ?: binding.composeButton.hide() } @@ -185,9 +194,7 @@ class TimelineActivity : BottomSheetActivity(), AppBarLayoutHost, ActionButtonAc private fun addToTab() { accountManager.activeAccount?.let { lifecycleScope.launch(Dispatchers.IO) { - it.tabPreferences += timeline - accountManager.saveAccount(it) - eventHub.dispatch(MainTabsChangedEvent(it.tabPreferences)) + accountManager.setTabPreferences(it.id, it.tabPreferences + timeline) } } } @@ -243,23 +250,23 @@ class TimelineActivity : BottomSheetActivity(), AppBarLayoutHost, ActionButtonAc unmuteTagItem?.isVisible = false lifecycleScope.launch { - contentFiltersRepository.contentFilters.collect { result -> - result.onSuccess { filters -> - mutedContentFilter = filters?.contentFilters?.firstOrNull { filter -> - filter.contexts.contains(FilterContext.HOME) && - filter.keywords.any { it.keyword == tagWithHash } - } - updateTagMuteState(mutedContentFilter != null) - } - result.onFailure { error -> - // If the server can't filter then it's impossible to mute hashtags, - // so disable the functionality. - if (error is ContentFiltersError.ServerDoesNotFilter) { + accountManager.getPachliAccountFlow(intent.pachliAccountId) + .filterNotNull() + .distinctUntilChangedBy { it.contentFilters } + .collect { account -> + if (account.server.canFilterV2() || account.server.canFilterV1()) { + mutedContentFilter = account.contentFilters.contentFilters.firstOrNull { filter -> + filter.contexts.contains(FilterContext.HOME) && + filter.keywords.any { it.keyword == tagWithHash } + } + updateTagMuteState(mutedContentFilter != null) + } else { + // If the server can't filter then it's impossible to mute hashtags, + // so disable the functionality. muteTagItem?.isVisible = false unmuteTagItem?.isVisible = false } } - } } } @@ -292,7 +299,7 @@ class TimelineActivity : BottomSheetActivity(), AppBarLayoutHost, ActionButtonAc ), ) - contentFiltersRepository.createContentFilter(newContentFilter) + contentFiltersRepository.createContentFilter(intent.pachliAccountId, newContentFilter) .onSuccess { mutedContentFilter = it updateTagMuteState(true) @@ -312,9 +319,9 @@ class TimelineActivity : BottomSheetActivity(), AppBarLayoutHost, ActionButtonAc val result = mutedContentFilter?.let { filter -> val newContexts = filter.contexts.filter { it != FilterContext.HOME } if (newContexts.isEmpty()) { - contentFiltersRepository.deleteContentFilter(filter.id) + contentFiltersRepository.deleteContentFilter(intent.pachliAccountId, filter.id) } else { - contentFiltersRepository.updateContentFilter(filter, ContentFilterEdit(filter.id, contexts = newContexts)) + contentFiltersRepository.updateContentFilter(intent.pachliAccountId, filter, ContentFilterEdit(filter.id, contexts = newContexts)) } } diff --git a/app/src/main/java/app/pachli/adapter/StatusBaseViewHolder.kt b/app/src/main/java/app/pachli/adapter/StatusBaseViewHolder.kt index f049772f7..824fd8ae4 100644 --- a/app/src/main/java/app/pachli/adapter/StatusBaseViewHolder.kt +++ b/app/src/main/java/app/pachli/adapter/StatusBaseViewHolder.kt @@ -584,6 +584,7 @@ abstract class StatusBaseViewHolder protected constructor( } protected fun setupButtons( + pachliAccountId: Long, viewData: T, listener: StatusActionListener, accountId: String, @@ -593,7 +594,7 @@ abstract class StatusBaseViewHolder protected constructor( avatar.setOnClickListener(profileButtonClickListener) displayName.setOnClickListener(profileButtonClickListener) replyButton.setOnClickListener { - listener.onReply(viewData) + listener.onReply(pachliAccountId, viewData) } reblogButton?.setEventListener { _: SparkButton?, buttonState: Boolean -> // return true to play animation @@ -744,6 +745,7 @@ abstract class StatusBaseViewHolder protected constructor( listener, ) setupButtons( + pachliAccountId, viewData, listener, actionable.account.id, diff --git a/app/src/main/java/app/pachli/adapter/StatusViewHolder.kt b/app/src/main/java/app/pachli/adapter/StatusViewHolder.kt index 91d43cb11..2bd27eed3 100644 --- a/app/src/main/java/app/pachli/adapter/StatusViewHolder.kt +++ b/app/src/main/java/app/pachli/adapter/StatusViewHolder.kt @@ -90,8 +90,7 @@ open class StatusViewHolder( protected fun setPollInfo(ownPoll: Boolean) = with(binding) { statusInfo.setText(if (ownPoll) R.string.poll_ended_created else R.string.poll_ended_voted) statusInfo.setCompoundDrawablesRelativeWithIntrinsicBounds(R.drawable.ic_poll_24dp, 0, 0, 0) - statusInfo.compoundDrawablePadding = - Utils.dpToPx(context, 10) + statusInfo.compoundDrawablePadding = Utils.dpToPx(context, 10) statusInfo.setPaddingRelative(Utils.dpToPx(context, 28), 0, 0, 0) statusInfo.show() } diff --git a/app/src/main/java/app/pachli/appstore/Events.kt b/app/src/main/java/app/pachli/appstore/Events.kt index 3568f6af0..180c6cb12 100644 --- a/app/src/main/java/app/pachli/appstore/Events.kt +++ b/app/src/main/java/app/pachli/appstore/Events.kt @@ -1,6 +1,5 @@ package app.pachli.appstore -import app.pachli.core.model.Timeline import app.pachli.core.network.model.Account import app.pachli.core.network.model.Poll import app.pachli.core.network.model.Status @@ -20,8 +19,6 @@ data class StatusComposedEvent(val status: Status) : Event data object StatusScheduledEvent : Event data class StatusEditedEvent(val originalId: String, val status: Status) : Event data class ProfileEditedEvent(val newProfileData: Account) : Event -data class MainTabsChangedEvent(val newTabs: List) : Event data class PollVoteEvent(val statusId: String, val poll: Poll) : Event data class DomainMuteEvent(val instance: String) : Event -data class AnnouncementReadEvent(val announcementId: String) : Event data class PinEvent(val statusId: String, val pinned: Boolean) : Event diff --git a/app/src/main/java/app/pachli/components/account/AccountActivity.kt b/app/src/main/java/app/pachli/components/account/AccountActivity.kt index cee99f73a..777f9d9b5 100644 --- a/app/src/main/java/app/pachli/components/account/AccountActivity.kt +++ b/app/src/main/java/app/pachli/components/account/AccountActivity.kt @@ -968,7 +968,7 @@ class AccountActivity : kind = ComposeOptions.ComposeKind.NEW, ) } - val intent = ComposeActivityIntent(this, options) + val intent = ComposeActivityIntent(this, intent.pachliAccountId, options) startActivity(intent) } } diff --git a/app/src/main/java/app/pachli/components/announcements/AnnouncementsViewModel.kt b/app/src/main/java/app/pachli/components/announcements/AnnouncementsViewModel.kt index 4c59bb1c1..850cef468 100644 --- a/app/src/main/java/app/pachli/components/announcements/AnnouncementsViewModel.kt +++ b/app/src/main/java/app/pachli/components/announcements/AnnouncementsViewModel.kt @@ -20,8 +20,7 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import app.pachli.appstore.AnnouncementReadEvent -import app.pachli.appstore.EventHub +import app.pachli.core.data.repository.AccountManager import app.pachli.core.data.repository.InstanceInfoRepository import app.pachli.core.network.model.Announcement import app.pachli.core.network.model.Emoji @@ -31,6 +30,8 @@ import app.pachli.util.Loading import app.pachli.util.Resource import app.pachli.util.Success import at.connyduck.calladapter.networkresult.fold +import com.github.michaelbull.result.onFailure +import com.github.michaelbull.result.onSuccess import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.launch @@ -38,9 +39,9 @@ import timber.log.Timber @HiltViewModel class AnnouncementsViewModel @Inject constructor( + private val accountManager: AccountManager, private val instanceInfoRepo: InstanceInfoRepository, private val mastodonApi: MastodonApi, - private val eventHub: EventHub, ) : ViewModel() { private val announcementsMutable = MutableLiveData>>() @@ -59,29 +60,20 @@ class AnnouncementsViewModel @Inject constructor( viewModelScope.launch { announcementsMutable.postValue(Loading()) mastodonApi.listAnnouncements() - .fold( - { - announcementsMutable.postValue(Success(it)) - it.filter { announcement -> !announcement.read } - .forEach { announcement -> - mastodonApi.dismissAnnouncement(announcement.id) - .fold( - { - eventHub.dispatch(AnnouncementReadEvent(announcement.id)) - }, - { throwable -> - Timber.d( - "Failed to mark announcement as read.", - throwable, - ) - }, - ) - } - }, - { - announcementsMutable.postValue(Error(cause = it)) - }, - ) + .onSuccess { + announcementsMutable.postValue(Success(it.body)) + it.body.filter { announcement -> !announcement.read } + .forEach { announcement -> + mastodonApi.dismissAnnouncement(announcement.id) + .onSuccess { + accountManager.deleteAnnouncement(accountManager.activeAccount!!.id, announcement.id) + } + .onFailure { throwable -> + Timber.d("Failed to mark announcement as read.", throwable) + } + } + } + .onFailure { announcementsMutable.postValue(Error(cause = it.throwable)) } } } diff --git a/app/src/main/java/app/pachli/components/compose/ComposeActivity.kt b/app/src/main/java/app/pachli/components/compose/ComposeActivity.kt index ec6bf31af..abbac0a92 100644 --- a/app/src/main/java/app/pachli/components/compose/ComposeActivity.kt +++ b/app/src/main/java/app/pachli/components/compose/ComposeActivity.kt @@ -87,6 +87,7 @@ import app.pachli.core.designsystem.R as DR import app.pachli.core.navigation.ComposeActivityIntent import app.pachli.core.navigation.ComposeActivityIntent.ComposeOptions import app.pachli.core.navigation.ComposeActivityIntent.ComposeOptions.InitialCursorPosition +import app.pachli.core.navigation.pachliAccountId import app.pachli.core.network.model.Attachment import app.pachli.core.network.model.Emoji import app.pachli.core.network.model.Status @@ -121,6 +122,7 @@ import com.mikepenz.iconics.IconicsSize import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial import com.mikepenz.iconics.utils.sizeDp import dagger.hilt.android.AndroidEntryPoint +import dagger.hilt.android.lifecycle.withCreationCallback import java.io.File import java.io.IOException import java.util.Date @@ -130,6 +132,7 @@ import kotlin.math.max import kotlin.math.min import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch import timber.log.Timber @@ -153,16 +156,22 @@ class ComposeActivity : 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 maximumTootCharacters = DEFAULT_CHARACTER_LIMIT @VisibleForTesting - val viewModel: ComposeViewModel by viewModels() + val viewModel: ComposeViewModel by viewModels( + extrasProducer = { + defaultViewModelCreationExtras.withCreationCallback { factory -> + factory.create( + intent.pachliAccountId, + ComposeActivityIntent.getComposeOptions(intent), + ) + } + }, + ) private val binding by viewBinding(ActivityComposeBinding::inflate) @@ -242,8 +251,6 @@ class ComposeActivity : public override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - activeAccount = accountManager.activeAccount ?: return - if (sharedPreferencesRepository.appTheme == AppTheme.BLACK) { setTheme(DR.style.AppDialogActivityBlackTheme) } @@ -251,7 +258,8 @@ class ComposeActivity : setupActionBar() - setupAvatar(activeAccount) + val composeOptions: ComposeOptions? = ComposeActivityIntent.getComposeOptions(intent) + val mediaAdapter = MediaPreviewAdapter( this, onAddCaption = { item -> @@ -266,64 +274,71 @@ class ComposeActivity : 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? = ComposeActivityIntent.getOptions(intent) - viewModel.setup(composeOptions) + lifecycleScope.launch { + viewModel.accountFlow.distinctUntilChanged().collect { account -> + setupAvatar(account.entity) - setupButtons() - subscribeToUpdates(mediaAdapter) + if (viewModel.displaySelfUsername) { + binding.composeUsernameView.text = getString( + R.string.compose_active_account_description, + account.entity.fullName, + ) + binding.composeUsernameView.show() + } else { + binding.composeUsernameView.hide() + } - if (accountManager.shouldDisplaySelfUsername()) { - binding.composeUsernameView.text = getString( - R.string.compose_active_account_description, - activeAccount.fullName, - ) - binding.composeUsernameView.show() - } else { - binding.composeUsernameView.hide() - } + viewModel.setup(account) - setupReplyViews(composeOptions?.replyingStatusAuthor, composeOptions?.replyingStatusContent) - val statusContent = composeOptions?.content - if (!statusContent.isNullOrEmpty()) { - binding.composeEditField.setText(statusContent) - } + setupLanguageSpinner(getInitialLanguages(composeOptions?.language, account.entity)) - composeOptions?.scheduledAt?.let { - binding.composeScheduleView.setDateTime(it) - } + setupButtons(account.id) - setupLanguageSpinner(getInitialLanguages(composeOptions?.language, activeAccount)) - setupComposeField(sharedPreferencesRepository, viewModel.initialContent, composeOptions) - setupContentWarningField(composeOptions?.contentWarning) - setupPollView() - applyShareIntent(intent, savedInstanceState) + if (savedInstanceState != null) { + setupComposeField(sharedPreferencesRepository, null, composeOptions) + } else { + setupComposeField(sharedPreferencesRepository, viewModel.initialContent, composeOptions) + } - /* Finally, overwrite state with data from saved instance state. */ - savedInstanceState?.let { - photoUploadUri = BundleCompat.getParcelable(it, KEY_PHOTO_UPLOAD_URI, Uri::class.java) + subscribeToUpdates(mediaAdapter) - (it.getSerializable(KEY_VISIBILITY) as Status.Visibility).apply { - setStatusVisibility(this) + binding.composeMediaPreviewBar.layoutManager = + LinearLayoutManager(this@ComposeActivity, LinearLayoutManager.HORIZONTAL, false) + binding.composeMediaPreviewBar.adapter = mediaAdapter + binding.composeMediaPreviewBar.itemAnimator = null + + setupReplyViews(viewModel.replyingStatusAuthor, viewModel.replyingStatusContent) + + composeOptions?.scheduledAt?.let { + binding.composeScheduleView.setDateTime(it) + } + + setupContentWarningField(composeOptions?.contentWarning) + setupPollView() + applyShareIntent(intent, savedInstanceState) + + /* Finally, overwrite state with data from saved instance state. */ + savedInstanceState?.let { + photoUploadUri = BundleCompat.getParcelable(it, KEY_PHOTO_UPLOAD_URI, Uri::class.java) + + (it.getSerializable(KEY_VISIBILITY) as Status.Visibility).apply { + setStatusVisibility(this) + } + + it.getBoolean(KEY_CONTENT_WARNING_VISIBLE).apply { + viewModel.showContentWarningChanged(this) + } + + (it.getSerializable(KEY_SCHEDULED_TIME) as? Date)?.let { time -> + viewModel.updateScheduledAt(time) + } + } + + binding.composeEditField.post { + binding.composeEditField.requestFocus() + } } - - it.getBoolean(KEY_CONTENT_WARNING_VISIBLE).apply { - viewModel.showContentWarningChanged(this) - } - - (it.getSerializable(KEY_SCHEDULED_TIME) as? Date)?.let { time -> - viewModel.updateScheduledAt(time) - } - } - - binding.composeEditField.post { - binding.composeEditField.requestFocus() } } @@ -372,8 +387,8 @@ class ComposeActivity : private fun setupReplyViews(replyingStatusAuthor: String?, replyingStatusContent: String?) { if (replyingStatusAuthor != null) { - binding.composeReplyView.show() binding.composeReplyView.text = getString(R.string.replying_to, replyingStatusAuthor) + binding.composeReplyView.show() val arrowDownIcon = IconicsDrawable(this, GoogleMaterial.Icon.gmd_arrow_drop_down).apply { sizeDp = 12 } setDrawableTint(this, arrowDownIcon, android.R.attr.textColorTertiary) @@ -432,13 +447,15 @@ class ComposeActivity : viewModel.onContentChanged(editable) } - binding.composeEditField.setText(startingText) + startingText?.let { + binding.composeEditField.setText(it) - when (composeOptions?.initialCursorPosition ?: InitialCursorPosition.END) { - InitialCursorPosition.START -> binding.composeEditField.setSelection(0) - InitialCursorPosition.END -> binding.composeEditField.setSelection( - binding.composeEditField.length(), - ) + when (composeOptions?.initialCursorPosition ?: InitialCursorPosition.END) { + InitialCursorPosition.START -> binding.composeEditField.setSelection(0) + InitialCursorPosition.END -> binding.composeEditField.setSelection( + binding.composeEditField.length(), + ) + } } // work around Android platform bug -> https://issuetracker.google.com/issues/67102093 @@ -545,7 +562,7 @@ class ComposeActivity : bottomSheetStates.any { it != BottomSheetBehavior.STATE_HIDDEN } } - private fun setupButtons() { + private fun setupButtons(pachliAccountId: Long) { binding.composeOptionsBottomSheet.listener = this composeOptionsBehavior = BottomSheetBehavior.from(binding.composeOptionsBottomSheet) @@ -567,7 +584,7 @@ class ComposeActivity : enableButton(binding.composeEmojiButton, clickable = false, colorActive = false) // Setup the interface buttons. - binding.composeTootButton.setOnClickListener { onSendClicked() } + binding.composeTootButton.setOnClickListener { onSendClicked(pachliAccountId) } binding.composeAddMediaButton.setOnClickListener { openPickDialog() } binding.composeToggleVisibilityButton.setOnClickListener { showComposeOptions() } binding.composeContentWarningButton.setOnClickListener { onContentWarningChanged() } @@ -627,21 +644,21 @@ class ComposeActivity : } } - private fun setupAvatar(activeAccount: AccountEntity) { + private fun setupAvatar(account: AccountEntity) { val actionBarSizeAttr = intArrayOf(androidx.appcompat.R.attr.actionBarSize) val avatarSize = obtainStyledAttributes(null, actionBarSizeAttr).use { a -> a.getDimensionPixelSize(0, 1) } loadAvatar( - activeAccount.profilePictureUrl, + account.profilePictureUrl, binding.composeAvatar, avatarSize / 8, sharedPreferencesRepository.animateAvatars, ) binding.composeAvatar.contentDescription = getString( R.string.compose_active_account_description, - activeAccount.fullName, + account.fullName, ) } @@ -968,11 +985,11 @@ class ComposeActivity : return binding.composeScheduleView.verifyScheduledTime(viewModel.scheduledAt.value) } - private fun onSendClicked() = lifecycleScope.launch { + private fun onSendClicked(pachliAccountId: Long) = lifecycleScope.launch { if (viewModel.confirmStatusLanguage) confirmStatusLanguage() if (verifyScheduledTime()) { - sendStatus() + sendStatus(pachliAccountId) } else { showScheduleView() } @@ -1074,7 +1091,7 @@ class ComposeActivity : return contentInfo } - private fun sendStatus() { + private fun sendStatus(pachliAccountId: Long) { enableButtons(false, viewModel.editing) val contentText = binding.composeEditField.text.toString() var spoilerText = "" @@ -1087,7 +1104,7 @@ class ComposeActivity : enableButtons(true, viewModel.editing) } else if (statusLength <= maximumTootCharacters) { lifecycleScope.launch { - viewModel.sendStatus(contentText, spoilerText, activeAccount.id) + viewModel.sendStatus(contentText, spoilerText, pachliAccountId) deleteDraftAndFinish() } } else { @@ -1200,7 +1217,16 @@ class ComposeActivity : } } + /** + * Shows/hides the content warning area depending on [show]. + * + * Adjusts the colours of the content warning button to reflect the state. + */ private fun showContentWarning(show: Boolean) { + // Skip any animations if the current visibility matches the intended visibility. This + // prevents a visual oddity where the compose editor animates in to view when first + // opening the activity. + if (binding.composeContentWarningBar.isVisible == show) return TransitionManager.beginDelayedTransition(binding.composeContentWarningBar.parent as ViewGroup) @ColorInt val color = if (show) { binding.composeContentWarningBar.show() @@ -1230,7 +1256,7 @@ class ComposeActivity : if (event.isCtrlPressed) { if (keyCode == KeyEvent.KEYCODE_ENTER) { // send toot by pressing CTRL + ENTER - this.onSendClicked() + this.onSendClicked(intent.pachliAccountId) return true } } @@ -1382,6 +1408,7 @@ class ComposeActivity : /** Media queued for upload. */ data class QueuedMedia( + val account: AccountEntity, val localId: Int, val uri: Uri, val type: Type, diff --git a/app/src/main/java/app/pachli/components/compose/ComposeViewModel.kt b/app/src/main/java/app/pachli/components/compose/ComposeViewModel.kt index 87a862eaf..63827e448 100644 --- a/app/src/main/java/app/pachli/components/compose/ComposeViewModel.kt +++ b/app/src/main/java/app/pachli/components/compose/ComposeViewModel.kt @@ -36,7 +36,9 @@ import app.pachli.core.common.string.mastodonLength import app.pachli.core.common.string.randomAlphanumericString import app.pachli.core.data.repository.AccountManager import app.pachli.core.data.repository.InstanceInfoRepository +import app.pachli.core.data.repository.PachliAccount import app.pachli.core.data.repository.ServerRepository +import app.pachli.core.database.model.AccountEntity import app.pachli.core.model.ServerOperation import app.pachli.core.navigation.ComposeActivityIntent.ComposeOptions import app.pachli.core.navigation.ComposeActivityIntent.ComposeOptions.ComposeKind @@ -45,6 +47,7 @@ import app.pachli.core.network.model.NewPoll import app.pachli.core.network.model.Status import app.pachli.core.network.retrofit.MastodonApi import app.pachli.core.preferences.SharedPreferencesRepository +import app.pachli.core.preferences.ShowSelfUsername import app.pachli.core.ui.MentionSpan import app.pachli.service.MediaToSend import app.pachli.service.ServiceClient @@ -60,23 +63,30 @@ import com.github.michaelbull.result.mapBoth import com.github.michaelbull.result.mapError import com.github.michaelbull.result.onFailure import com.github.michaelbull.result.onSuccess +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import io.github.z4kn4fein.semver.constraints.toConstraint import java.util.Date -import javax.inject.Inject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import timber.log.Timber -@HiltViewModel -class ComposeViewModel @Inject constructor( +@HiltViewModel(assistedFactory = ComposeViewModel.Factory::class) +class ComposeViewModel @AssistedInject constructor( + @Assisted private val pachliAccountId: Long, + @Assisted private val composeOptions: ComposeOptions?, private val api: MastodonApi, private val accountManager: AccountManager, private val mediaUploader: MediaUploader, @@ -86,13 +96,16 @@ class ComposeViewModel @Inject constructor( serverRepository: ServerRepository, private val sharedPreferencesRepository: SharedPreferencesRepository, ) : ViewModel() { + /** The account being used to compose the status. */ + val accountFlow = accountManager.getPachliAccountFlow(pachliAccountId) + .filterNotNull() + .shareIn(viewModelScope, SharingStarted.WhileSubscribed(5000), replay = 1) + + private lateinit var pachliAccount: PachliAccount /** The current content */ private var content: Editable = Editable.Factory.getInstance().newEditable("") - /** The current content warning */ - private var contentWarning: String = "" - /** * The effective content warning. Either the real content warning, or the empty string * if the content warning has been hidden @@ -100,39 +113,44 @@ class ComposeViewModel @Inject constructor( private val effectiveContentWarning get() = if (showContentWarning.value) contentWarning else "" - private var replyingStatusAuthor: String? = null - private var replyingStatusContent: String? = null + val replyingStatusAuthor: String? = composeOptions?.replyingStatusAuthor + val replyingStatusContent: String? = composeOptions?.replyingStatusContent /** The initial content for this status, before any edits */ - internal var initialContent: String = "" + internal var initialContent: String = composeOptions?.content.orEmpty() /** The initial content warning for this status, before any edits */ - private var initialContentWarning: String = "" + private val initialContentWarning: String = composeOptions?.contentWarning.orEmpty() + + /** The current content warning */ + private var contentWarning: String = initialContentWarning /** The initial language for this status, before any changes */ - private var initialLanguage: String? = null + private val initialLanguage: String? = composeOptions?.language /** The current language for this status. */ - internal var language: String? = null + internal var language: String? = initialLanguage /** If editing a draft then the ID of the draft, otherwise 0 */ - private var draftId: Int = 0 - private var scheduledTootId: String? = null - private var inReplyToId: String? = null - private var originalStatusId: String? = null + private val draftId = composeOptions?.draftId ?: 0 + private val scheduledTootId: String? = composeOptions?.scheduledTootId + private val inReplyToId: String? = composeOptions?.inReplyToId + private val originalStatusId: String? = composeOptions?.statusId private var startingVisibility: Status.Visibility = Status.Visibility.UNKNOWN private var contentWarningStateChanged: Boolean = false - private var modifiedInitialState: Boolean = false + private val modifiedInitialState: Boolean = composeOptions?.modifiedInitialState == true private var scheduledTimeChanged: Boolean = false val instanceInfo = instanceInfoRepo.instanceInfo val emojis = instanceInfoRepo.emojis - private val _markMediaAsSensitive: MutableStateFlow = - MutableStateFlow(accountManager.activeAccount?.defaultMediaSensitivity ?: false) - val markMediaAsSensitive = _markMediaAsSensitive.asStateFlow() + private val _markMediaAsSensitive: MutableStateFlow = MutableStateFlow(composeOptions?.sensitive) + val markMediaAsSensitive = accountFlow.combine(_markMediaAsSensitive) { account, sens -> + sens ?: account.entity.defaultMediaSensitivity + } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) private val _statusVisibility: MutableStateFlow = MutableStateFlow(Status.Visibility.UNKNOWN) val statusVisibility = _statusVisibility.asStateFlow() @@ -140,7 +158,7 @@ class ComposeViewModel @Inject constructor( val showContentWarning = _showContentWarning.asStateFlow() private val _poll: MutableStateFlow = MutableStateFlow(null) val poll = _poll.asStateFlow() - private val _scheduledAt: MutableStateFlow = MutableStateFlow(null) + private val _scheduledAt: MutableStateFlow = MutableStateFlow(composeOptions?.scheduledAt) val scheduledAt = _scheduledAt.asStateFlow() private val _media: MutableStateFlow> = MutableStateFlow(emptyList()) @@ -166,11 +184,19 @@ class ComposeViewModel @Inject constructor( sharedPreferencesRepository.confirmStatusLanguage = value } - private lateinit var composeKind: ComposeKind + private val composeKind = composeOptions?.kind ?: ComposeKind.NEW // Used in ComposeActivity to pass state to result function when cropImage contract inflight var cropImageItemOld: QueuedMedia? = null + // TODO: Copied from MainViewModel. Probably belongs back in AccountManager + val displaySelfUsername: Boolean + get() = when (sharedPreferencesRepository.showSelfUsername) { + ShowSelfUsername.ALWAYS -> true + ShowSelfUsername.DISAMBIGUATE -> accountManager.accountsFlow.value.size > 1 + ShowSelfUsername.NEVER -> false + } + private var setupComplete = false /** Errors preparing media for upload. */ @@ -220,6 +246,7 @@ class ComposeViewModel @Inject constructor( _media.update { mediaList -> val mediaItem = QueuedMedia( + account = pachliAccount.entity, localId = mediaUploader.getNewLocalMediaId(), uri = uri, type = type, @@ -253,9 +280,10 @@ class ComposeViewModel @Inject constructor( return mediaItem } - private fun addUploadedMedia(id: String, type: QueuedMedia.Type, uri: Uri, description: String?, focus: Attachment.Focus?) { + private fun addUploadedMedia(account: AccountEntity, id: String, type: QueuedMedia.Type, uri: Uri, description: String?, focus: Attachment.Focus?) { _media.update { mediaList -> val mediaItem = QueuedMedia( + account = account, localId = mediaUploader.getNewLocalMediaId(), uri = uri, type = type, @@ -393,11 +421,11 @@ class ComposeViewModel @Inject constructor( draftHelper.saveDraft( draftId = draftId, - pachliAccountId = accountManager.activeAccount?.id!!, + pachliAccountId = pachliAccountId, inReplyToId = inReplyToId, content = content, contentWarning = contentWarning, - sensitive = _markMediaAsSensitive.value, + sensitive = markMediaAsSensitive.value, visibility = statusVisibility.value, mediaUris = mediaUris, mediaDescriptions = mediaDescriptions, @@ -438,7 +466,7 @@ class ComposeViewModel @Inject constructor( text = content, warningText = spoilerText, visibility = statusVisibility.value.serverString(), - sensitive = attachedMedia.isNotEmpty() && (_markMediaAsSensitive.value || showContentWarning.value), + sensitive = attachedMedia.isNotEmpty() && (markMediaAsSensitive.value || showContentWarning.value), media = attachedMedia, scheduledAt = scheduledAt.value, inReplyToId = inReplyToId, @@ -552,30 +580,22 @@ class ComposeViewModel @Inject constructor( } } - fun setup(composeOptions: ComposeOptions?) { + fun setup(account: PachliAccount) { if (setupComplete) { return } - composeKind = composeOptions?.kind ?: ComposeKind.NEW + pachliAccount = account - val preferredVisibility = accountManager.activeAccount!!.defaultPostPrivacy + val preferredVisibility = account.entity.defaultPostPrivacy val replyVisibility = composeOptions?.replyVisibility ?: Status.Visibility.UNKNOWN startingVisibility = Status.Visibility.getOrUnknown( preferredVisibility.ordinal.coerceAtLeast(replyVisibility.ordinal), ) - inReplyToId = composeOptions?.inReplyToId - - modifiedInitialState = composeOptions?.modifiedInitialState == true - - val contentWarning = composeOptions?.contentWarning - if (contentWarning != null) { - initialContentWarning = contentWarning - } if (!contentWarningStateChanged) { - _showContentWarning.value = !contentWarning.isNullOrBlank() + _showContentWarning.value = contentWarning.isNotBlank() } // recreate media list @@ -595,17 +615,10 @@ class ComposeViewModel @Inject constructor( 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) + addUploadedMedia(account.entity, a.id, mediaType, a.url.toUri(), a.description, a.meta?.focus) } } - draftId = composeOptions?.draftId ?: 0 - scheduledTootId = composeOptions?.scheduledTootId - originalStatusId = composeOptions?.statusId - initialContent = composeOptions?.content ?: "" - initialLanguage = composeOptions?.language - language = initialLanguage - val tootVisibility = composeOptions?.visibility ?: Status.Visibility.UNKNOWN if (tootVisibility != Status.Visibility.UNKNOWN) { startingVisibility = tootVisibility @@ -622,16 +635,10 @@ class ComposeViewModel @Inject constructor( initialContent = builder.toString() } - _scheduledAt.value = composeOptions?.scheduledAt - - composeOptions?.sensitive?.let { _markMediaAsSensitive.value = it } - val poll = composeOptions?.poll - if (poll != null && composeOptions.mediaAttachments.isNullOrEmpty()) { + if (poll != null && composeOptions?.mediaAttachments.isNullOrEmpty()) { _poll.value = poll } - replyingStatusContent = composeOptions?.replyingStatusContent - replyingStatusAuthor = composeOptions?.replyingStatusAuthor updateCloseConfirmation() setupComplete = true @@ -715,4 +722,16 @@ class ComposeViewModel @Inject constructor( return length } } + + @AssistedFactory + interface Factory { + /** + * Creates [ComposeViewModel] with [pachliAccountId] as the active account and + * active [composeOptions]. + */ + fun create( + pachliAccountId: Long, + composeOptions: ComposeOptions?, + ): ComposeViewModel + } } diff --git a/app/src/main/java/app/pachli/components/compose/MediaUploader.kt b/app/src/main/java/app/pachli/components/compose/MediaUploader.kt index d5188b59b..aa7ba5468 100644 --- a/app/src/main/java/app/pachli/components/compose/MediaUploader.kt +++ b/app/src/main/java/app/pachli/components/compose/MediaUploader.kt @@ -435,7 +435,13 @@ class MediaUploader @Inject constructor( MultipartBody.Part.createFormData("focus", "${it.x},${it.y}") } - val uploadResult = mediaUploadApi.uploadMedia(body, description, focus) + val uploadResult = mediaUploadApi.uploadMediaWithAuth( + media.account.authHeader, + media.account.domain, + body, + description, + focus, + ) .mapEither( { if (it.code == 200) { diff --git a/app/src/main/java/app/pachli/components/compose/dialog/AddPollDialog.kt b/app/src/main/java/app/pachli/components/compose/dialog/AddPollDialog.kt index 65c05ddbc..cd741e54a 100644 --- a/app/src/main/java/app/pachli/components/compose/dialog/AddPollDialog.kt +++ b/app/src/main/java/app/pachli/components/compose/dialog/AddPollDialog.kt @@ -33,7 +33,7 @@ fun showAddPollDialog( maxOptionCount: Int, maxOptionLength: Int, minDuration: Int, - maxDuration: Int, + maxDuration: Long, onUpdatePoll: (NewPoll) -> Unit, ) { val binding = DialogAddPollBinding.inflate(LayoutInflater.from(context)) diff --git a/app/src/main/java/app/pachli/components/conversation/ConversationViewHolder.kt b/app/src/main/java/app/pachli/components/conversation/ConversationViewHolder.kt index aa9aa0c00..11cc80fab 100644 --- a/app/src/main/java/app/pachli/components/conversation/ConversationViewHolder.kt +++ b/app/src/main/java/app/pachli/components/conversation/ConversationViewHolder.kt @@ -83,6 +83,7 @@ class ConversationViewHolder internal constructor( hideSensitiveMediaWarning() } setupButtons( + pachliAccountId, viewData, listener, account.id, diff --git a/app/src/main/java/app/pachli/components/conversation/ConversationsFragment.kt b/app/src/main/java/app/pachli/components/conversation/ConversationsFragment.kt index e4dceb47a..3ff889fa8 100644 --- a/app/src/main/java/app/pachli/components/conversation/ConversationsFragment.kt +++ b/app/src/main/java/app/pachli/components/conversation/ConversationsFragment.kt @@ -348,8 +348,8 @@ class ConversationsFragment : // not needed } - override fun onReply(viewData: ConversationViewData) { - reply(viewData.lastStatus.actionable) + override fun onReply(pachliAccountId: Long, viewData: ConversationViewData) { + reply(pachliAccountId, viewData.lastStatus.actionable) } override fun onVoteInPoll(viewData: ConversationViewData, poll: Poll, choices: List) { diff --git a/app/src/main/java/app/pachli/components/drafts/DraftsActivity.kt b/app/src/main/java/app/pachli/components/drafts/DraftsActivity.kt index 809656127..99009486b 100644 --- a/app/src/main/java/app/pachli/components/drafts/DraftsActivity.kt +++ b/app/src/main/java/app/pachli/components/drafts/DraftsActivity.kt @@ -29,6 +29,7 @@ import app.pachli.core.common.extensions.visible import app.pachli.core.database.model.DraftEntity import app.pachli.core.navigation.ComposeActivityIntent import app.pachli.core.navigation.ComposeActivityIntent.ComposeOptions +import app.pachli.core.navigation.pachliAccountId import app.pachli.core.network.parseAsMastodonHtml import app.pachli.core.ui.BackgroundMessage import app.pachli.databinding.ActivityDraftsBinding @@ -126,7 +127,7 @@ class DraftsActivity : BaseActivity(), DraftActionListener { bottomSheet.state = BottomSheetBehavior.STATE_HIDDEN - startActivity(ComposeActivityIntent(context, composeOptions)) + startActivity(ComposeActivityIntent(context, intent.pachliAccountId, composeOptions)) }, { throwable -> bottomSheet.state = BottomSheetBehavior.STATE_HIDDEN @@ -162,7 +163,7 @@ class DraftsActivity : BaseActivity(), DraftActionListener { kind = ComposeOptions.ComposeKind.EDIT_DRAFT, ) - startActivity(ComposeActivityIntent(this, composeOptions)) + startActivity(ComposeActivityIntent(this, intent.pachliAccountId, composeOptions)) } override fun onDeleteDraft(draft: DraftEntity) { diff --git a/app/src/main/java/app/pachli/components/filters/ContentFiltersActivity.kt b/app/src/main/java/app/pachli/components/filters/ContentFiltersActivity.kt index 16c46b1fc..a1fa041f2 100644 --- a/app/src/main/java/app/pachli/components/filters/ContentFiltersActivity.kt +++ b/app/src/main/java/app/pachli/components/filters/ContentFiltersActivity.kt @@ -3,7 +3,9 @@ package app.pachli.components.filters import android.content.DialogInterface.BUTTON_POSITIVE import android.os.Bundle import androidx.activity.viewModels +import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import app.pachli.R import app.pachli.core.activity.BaseActivity import app.pachli.core.activity.extensions.TransitionKind @@ -11,7 +13,6 @@ import app.pachli.core.activity.extensions.startActivityWithTransition import app.pachli.core.common.extensions.hide import app.pachli.core.common.extensions.show import app.pachli.core.common.extensions.viewBinding -import app.pachli.core.common.extensions.visible import app.pachli.core.model.ContentFilter import app.pachli.core.navigation.EditContentFilterActivityIntent import app.pachli.core.navigation.pachliAccountId @@ -19,13 +20,20 @@ import app.pachli.core.ui.BackgroundMessage import app.pachli.databinding.ActivityContentFiltersBinding import com.google.android.material.color.MaterialColors import dagger.hilt.android.AndroidEntryPoint +import dagger.hilt.android.lifecycle.withCreationCallback +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch @AndroidEntryPoint class ContentFiltersActivity : BaseActivity(), ContentFiltersListener { - private val binding by viewBinding(ActivityContentFiltersBinding::inflate) - private val viewModel: ContentFiltersViewModel by viewModels() + private val viewModel: ContentFiltersViewModel by viewModels( + extrasProducer = { + defaultViewModelCreationExtras.withCreationCallback { factory -> + factory.create(intent.pachliAccountId) + } + }, + ) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -42,58 +50,39 @@ class ContentFiltersActivity : BaseActivity(), ContentFiltersListener { launchEditContentFilterActivity() } - binding.swipeRefreshLayout.setOnRefreshListener { loadFilters() } + binding.swipeRefreshLayout.setOnRefreshListener { + binding.swipeRefreshLayout.isRefreshing = false + viewModel.refreshContentFilters() + } + binding.swipeRefreshLayout.setColorSchemeColors(MaterialColors.getColor(binding.root, androidx.appcompat.R.attr.colorPrimary)) binding.includedToolbar.appbar.setLiftOnScrollTargetView(binding.filtersList) setTitle(R.string.pref_title_content_filters) + + bind() } - override fun onResume() { - super.onResume() - loadFilters() - observeViewModel() - } - - private fun observeViewModel() { + private fun bind() { lifecycleScope.launch { - viewModel.state.collect { state -> - binding.progressBar.visible(state.loadingState == ContentFiltersViewModel.LoadingState.LOADING) - binding.swipeRefreshLayout.isRefreshing = state.loadingState == ContentFiltersViewModel.LoadingState.LOADING - binding.addFilterButton.visible(state.loadingState == ContentFiltersViewModel.LoadingState.LOADED) - - when (state.loadingState) { - ContentFiltersViewModel.LoadingState.INITIAL, ContentFiltersViewModel.LoadingState.LOADING -> binding.messageView.hide() - ContentFiltersViewModel.LoadingState.ERROR_NETWORK -> { - binding.messageView.setup(BackgroundMessage.Network()) { - loadFilters() - } + repeatOnLifecycle(Lifecycle.State.RESUMED) { + viewModel.contentFilters.collect { contentFilters -> + binding.filtersList.adapter = FiltersAdapter(this@ContentFiltersActivity, contentFilters.contentFilters) + if (contentFilters.contentFilters.isEmpty()) { + binding.messageView.setup(BackgroundMessage.Empty()) binding.messageView.show() - } - - ContentFiltersViewModel.LoadingState.ERROR_OTHER -> { - binding.messageView.setup(BackgroundMessage.GenericError()) { - loadFilters() - } - binding.messageView.show() - } - - ContentFiltersViewModel.LoadingState.LOADED -> { - binding.filtersList.adapter = FiltersAdapter(this@ContentFiltersActivity, state.contentFilters) - if (state.contentFilters.isEmpty()) { - binding.messageView.setup(BackgroundMessage.Empty()) - binding.messageView.show() - } else { - binding.messageView.hide() - } + } else { + binding.messageView.hide() } } } } - } - private fun loadFilters() { - viewModel.load() + lifecycleScope.launch { + viewModel.operationCount.collectLatest { + if (it == 0) binding.progressIndicator.hide() else binding.progressIndicator.show() + } + } } private fun launchEditContentFilterActivity(contentFilter: ContentFilter? = null) { diff --git a/app/src/main/java/app/pachli/components/filters/ContentFiltersViewModel.kt b/app/src/main/java/app/pachli/components/filters/ContentFiltersViewModel.kt index 0e598b5c4..ae3faa926 100644 --- a/app/src/main/java/app/pachli/components/filters/ContentFiltersViewModel.kt +++ b/app/src/main/java/app/pachli/components/filters/ContentFiltersViewModel.kt @@ -3,64 +3,66 @@ package app.pachli.components.filters import android.view.View import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import app.pachli.core.data.repository.AccountManager +import app.pachli.core.data.repository.ContentFilters import app.pachli.core.data.repository.ContentFiltersRepository import app.pachli.core.model.ContentFilter +import app.pachli.core.model.ContentFilterVersion +import app.pachli.core.ui.OperationCounter import com.github.michaelbull.result.onFailure -import com.github.michaelbull.result.onSuccess import com.google.android.material.snackbar.Snackbar +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel -import javax.inject.Inject -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.update +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.distinctUntilChangedBy +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -@HiltViewModel -class ContentFiltersViewModel @Inject constructor( +@HiltViewModel(assistedFactory = ContentFiltersViewModel.Factory::class) +class ContentFiltersViewModel @AssistedInject constructor( + private val accountManager: AccountManager, private val contentFiltersRepository: ContentFiltersRepository, + @Assisted val pachliAccountId: Long, ) : ViewModel() { - enum class LoadingState { - INITIAL, - LOADING, - LOADED, - ERROR_NETWORK, - ERROR_OTHER, + val contentFilters = flow { + accountManager.getPachliAccountFlow(pachliAccountId).filterNotNull() + .distinctUntilChangedBy { it.contentFilters } + .collect { emit(it.contentFilters) } } + .stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5000), + ContentFilters(contentFilters = emptyList(), version = ContentFilterVersion.V1), + ) - data class State(val contentFilters: List, val loadingState: LoadingState) + private val operationCounter = OperationCounter() + val operationCount = operationCounter.count - val state: Flow get() = _state - private val _state = MutableStateFlow(State(emptyList(), LoadingState.INITIAL)) - - fun load() { - this@ContentFiltersViewModel._state.value = _state.value.copy(loadingState = LoadingState.LOADING) - - viewModelScope.launch { - contentFiltersRepository.contentFilters.collect { result -> - result.onSuccess { filters -> - this@ContentFiltersViewModel._state.update { State(filters?.contentFilters.orEmpty(), LoadingState.LOADED) } - } - .onFailure { - // TODO: There's an ERROR_NETWORK state to maybe consider here. Or get rid of - // that and do proper error handling. - this@ContentFiltersViewModel._state.update { - it.copy(loadingState = LoadingState.ERROR_OTHER) - } - } - } + fun refreshContentFilters() = viewModelScope.launch { + operationCounter { + contentFiltersRepository.refresh(pachliAccountId) } } fun deleteContentFilter(contentFilter: ContentFilter, parent: View) { viewModelScope.launch { - contentFiltersRepository.deleteContentFilter(contentFilter.id) - .onSuccess { - this@ContentFiltersViewModel._state.value = State(this@ContentFiltersViewModel._state.value.contentFilters.filter { it.id != contentFilter.id }, LoadingState.LOADED) - } - .onFailure { - Snackbar.make(parent, "Error deleting filter '${contentFilter.title}'", Snackbar.LENGTH_SHORT).show() - } + operationCounter { + contentFiltersRepository.deleteContentFilter(pachliAccountId, contentFilter.id) + .onFailure { + Snackbar.make(parent, "Error deleting filter '${contentFilter.title}'", Snackbar.LENGTH_SHORT).show() + } + } } } + + @AssistedFactory + interface Factory { + /** Creates [ContentFiltersViewModel] with [pachliAccountId] as the active account. */ + fun create(pachliAccountId: Long): ContentFiltersViewModel + } } diff --git a/app/src/main/java/app/pachli/components/filters/EditContentFilterActivity.kt b/app/src/main/java/app/pachli/components/filters/EditContentFilterActivity.kt index 3e41b69a0..1d831d787 100644 --- a/app/src/main/java/app/pachli/components/filters/EditContentFilterActivity.kt +++ b/app/src/main/java/app/pachli/components/filters/EditContentFilterActivity.kt @@ -26,6 +26,7 @@ import app.pachli.core.model.FilterAction import app.pachli.core.model.FilterContext import app.pachli.core.model.FilterKeyword import app.pachli.core.navigation.EditContentFilterActivityIntent +import app.pachli.core.navigation.pachliAccountId import app.pachli.core.ui.extensions.await import app.pachli.databinding.ActivityEditContentFilterBinding import app.pachli.databinding.DialogFilterBinding @@ -55,6 +56,7 @@ class EditContentFilterActivity : BaseActivity() { extrasProducer = { defaultViewModelCreationExtras.withCreationCallback { factory -> factory.create( + intent.pachliAccountId, EditContentFilterActivityIntent.getContentFilter(intent), EditContentFilterActivityIntent.getContentFilterId(intent), ) diff --git a/app/src/main/java/app/pachli/components/filters/EditContentFilterViewModel.kt b/app/src/main/java/app/pachli/components/filters/EditContentFilterViewModel.kt index 6b8dc7568..3e69d1e0b 100644 --- a/app/src/main/java/app/pachli/components/filters/EditContentFilterViewModel.kt +++ b/app/src/main/java/app/pachli/components/filters/EditContentFilterViewModel.kt @@ -37,7 +37,6 @@ import com.github.michaelbull.result.Ok import com.github.michaelbull.result.Result import com.github.michaelbull.result.get import com.github.michaelbull.result.map -import com.github.michaelbull.result.mapEither import com.github.michaelbull.result.mapError import com.github.michaelbull.result.onFailure import com.github.michaelbull.result.onSuccess @@ -205,12 +204,14 @@ enum class UiMode { * is initialised, and [uiMode] is [UiMode.CREATE]. * * @param contentFiltersRepository + * @param pachliAccountId ID of the account owning the filters * @param contentFilter Filter to show * @param contentFilterId ID of filter to fetch and show */ @HiltViewModel(assistedFactory = EditContentFilterViewModel.Factory::class) class EditContentFilterViewModel @AssistedInject constructor( - val contentFiltersRepository: ContentFiltersRepository, + private val contentFiltersRepository: ContentFiltersRepository, + @Assisted val pachliAccountId: Long, @Assisted val contentFilter: ContentFilter?, @Assisted val contentFilterId: String?, ) : ViewModel() { @@ -242,13 +243,8 @@ class EditContentFilterViewModel @AssistedInject constructor( emit( contentFilterId?.let { - contentFiltersRepository.getContentFilter(contentFilterId) - .onSuccess { - originalContentFilter = it - }.mapEither( - { ContentFilterViewData.from(it) }, - { UiError.GetContentFilterError(contentFilterId, it) }, - ) + originalContentFilter = contentFiltersRepository.getContentFilter(pachliAccountId, contentFilterId) + originalContentFilter?.let { Ok(ContentFilterViewData.from(it)) } ?: Ok(ContentFilterViewData()) } ?: Ok(ContentFilterViewData()), ) }.onEach { it.onSuccess { it?.let { onChange(it) } } } @@ -262,13 +258,12 @@ class EditContentFilterViewModel @AssistedInject constructor( fun reload() = viewModelScope.launch { contentFilterId ?: return@launch _contentFilterViewData.emit(Ok(ContentFilterViewData())) + originalContentFilter = contentFiltersRepository.getContentFilter(pachliAccountId, contentFilterId) + _contentFilterViewData.emit( - contentFiltersRepository.getContentFilter(contentFilterId) - .onSuccess { originalContentFilter = it } - .mapEither( - { ContentFilterViewData.from(it) }, - { UiError.GetContentFilterError(contentFilterId, it) }, - ), + originalContentFilter?.let { + Ok(ContentFilterViewData.from(it)) + } ?: Ok(ContentFilterViewData()), ) } @@ -384,13 +379,13 @@ class EditContentFilterViewModel @AssistedInject constructor( /** Create a new filter from [contentFilterViewData]. */ private suspend fun createContentFilter(contentFilterViewData: ContentFilterViewData): Result { - return contentFiltersRepository.createContentFilter(NewContentFilter.from(contentFilterViewData)) + return contentFiltersRepository.createContentFilter(pachliAccountId, NewContentFilter.from(contentFilterViewData)) .mapError { UiError.SaveContentFilterError(it) } } /** Persists the changes to [contentFilterViewData]. */ private suspend fun updateContentFilter(contentFilterViewData: ContentFilterViewData): Result { - return contentFiltersRepository.updateContentFilter(originalContentFilter!!, contentFilterViewData.diff(originalContentFilter!!)) + return contentFiltersRepository.updateContentFilter(pachliAccountId, originalContentFilter!!, contentFilterViewData.diff(originalContentFilter!!)) .mapError { UiError.SaveContentFilterError(it) } } @@ -399,7 +394,7 @@ class EditContentFilterViewModel @AssistedInject constructor( val filterViewData = contentFilterViewData.value.get() ?: return@launch // TODO: Check for non-null, or have a type that makes this impossible. - contentFiltersRepository.deleteContentFilter(filterViewData.id!!) + contentFiltersRepository.deleteContentFilter(pachliAccountId, filterViewData.id!!) .onSuccess { _uiResult.send(Ok(UiSuccess.DeleteFilter)) } .onFailure { _uiResult.send(Err(UiError.DeleteContentFilterError(it))) } } @@ -407,12 +402,16 @@ class EditContentFilterViewModel @AssistedInject constructor( @AssistedFactory interface Factory { /** - * Creates [EditContentFilterViewModel], passing optional [contentFilter] and + * Creates [EditContentFilterViewModel] for [pachliAccountId], passing optional [contentFilter] and * [contentFilterId] parameters. * * @see EditContentFilterViewModel */ - fun create(contentFilter: ContentFilter?, contentFilterId: String?): EditContentFilterViewModel + fun create( + pachliAccountId: Long, + contentFilter: ContentFilter?, + contentFilterId: String?, + ): EditContentFilterViewModel } companion object { diff --git a/app/src/main/java/app/pachli/components/notifications/NotificationFetcher.kt b/app/src/main/java/app/pachli/components/notifications/NotificationFetcher.kt index 43958b37e..f0c2b789f 100644 --- a/app/src/main/java/app/pachli/components/notifications/NotificationFetcher.kt +++ b/app/src/main/java/app/pachli/components/notifications/NotificationFetcher.kt @@ -61,7 +61,7 @@ class NotificationFetcher @Inject constructor( val accounts = buildList { if (pachliAccountId == NotificationWorker.ALL_ACCOUNTS) { - addAll(accountManager.getAllAccountsOrderedByActive()) + addAll(accountManager.accountsOrderedByActive) } else { accountManager.getAccountById(pachliAccountId)?.let { add(it) } } @@ -131,8 +131,6 @@ class NotificationFetcher @Inject constructor( notificationManager, account, ) - - accountManager.saveAccount(account) } catch (e: Exception) { Timber.e(e, "Error while fetching notifications") } @@ -160,7 +158,6 @@ class NotificationFetcher @Inject constructor( */ private suspend fun fetchNewNotifications(account: AccountEntity): List { Timber.d("fetchNewNotifications(%s)", account.fullName) - val authHeader = "Bearer ${account.accessToken}" // Figure out where to read from. Choose the most recent notification ID from: // @@ -168,7 +165,7 @@ class NotificationFetcher @Inject constructor( // - account.notificationMarkerId // - account.lastNotificationId Timber.d("getting notification marker for %s", account.fullName) - val remoteMarkerId = fetchMarker(authHeader, account)?.lastReadId ?: "0" + val remoteMarkerId = fetchMarker(account)?.lastReadId ?: "0" val localMarkerId = account.notificationMarkerId val markerId = if (remoteMarkerId.isLessThan(localMarkerId)) localMarkerId else remoteMarkerId val readingPosition = account.lastNotificationId @@ -186,7 +183,7 @@ class NotificationFetcher @Inject constructor( val now = Instant.now() Timber.d("Fetching notifications from server") mastodonApi.notificationsWithAuth( - authHeader, + account.authHeader, account.domain, minId = minId, ).onSuccess { response -> @@ -222,21 +219,20 @@ class NotificationFetcher @Inject constructor( val newMarkerId = notifications.first().id Timber.d("updating notification marker for %s to: %s", account.fullName, newMarkerId) mastodonApi.updateMarkersWithAuth( - auth = authHeader, + auth = account.authHeader, domain = account.domain, notificationsLastReadId = newMarkerId, ) - account.notificationMarkerId = newMarkerId - accountManager.saveAccount(account) + accountManager.setNotificationMarkerId(account.id, newMarkerId) } return notifications } - private suspend fun fetchMarker(authHeader: String, account: AccountEntity): Marker? { + private suspend fun fetchMarker(account: AccountEntity): Marker? { return try { val allMarkers = mastodonApi.markersWithAuth( - authHeader, + account.authHeader, account.domain, listOf("notifications"), ) diff --git a/app/src/main/java/app/pachli/components/notifications/NotificationHelper.kt b/app/src/main/java/app/pachli/components/notifications/NotificationHelper.kt index 091651795..50d23effd 100644 --- a/app/src/main/java/app/pachli/components/notifications/NotificationHelper.kt +++ b/app/src/main/java/app/pachli/components/notifications/NotificationHelper.kt @@ -114,8 +114,8 @@ private const val EXTRA_NOTIFICATION_TYPE = * Takes a given Mastodon notification and creates a new Android notification or updates the * existing Android notification. * - * The Android notification has it's tag set to the Mastodon notification ID, and it's ID set - * to the ID of the account that received the notification. + * The Android notification tag is the Mastodon notification ID, and the notification ID + * is the ID of the account that received the notification. * * @param context to access application preferences and services * @param mastodonNotification a new Mastodon notification @@ -129,8 +129,7 @@ fun makeNotification( account: AccountEntity, isFirstOfBatch: Boolean, ): android.app.Notification { - var notif = mastodonNotification - notif = notif.rewriteToStatusTypeIfNeeded(account.accountId) + val notif = mastodonNotification.rewriteToStatusTypeIfNeeded(account.accountId) val mastodonNotificationId = notif.id val accountId = account.id.toInt() @@ -146,7 +145,7 @@ fun makeNotification( // Create the notification -- either create a new one, or use the existing one. val builder = existingAndroidNotification?.let { NotificationCompat.Builder(context, it) - } ?: newAndroidNotification(context, notif, account) + } ?: newAndroidNotification(context, notificationId, notif, account) builder .setContentTitle(titleForType(context, notif, account)) @@ -292,10 +291,12 @@ fun updateSummaryNotifications( // All notifications in this group have the same type, so get it from the first. val notificationType = members[0].notification.extras.getEnum(EXTRA_NOTIFICATION_TYPE) - val summaryResultIntent = MainActivityIntent.openNotification( + val summaryResultIntent = MainActivityIntent.fromNotification( context, accountId.toLong(), - notificationType, + -1, + null, + type = notificationType, ) val summaryStackBuilder = TaskStackBuilder.create(context) summaryStackBuilder.addParentStack(MainActivity::class.java) @@ -344,10 +345,17 @@ fun updateSummaryNotifications( private fun newAndroidNotification( context: Context, + notificationId: Int, body: Notification, account: AccountEntity, ): NotificationCompat.Builder { - val eventResultIntent = MainActivityIntent.openNotification(context, account.id, body.type) + val eventResultIntent = MainActivityIntent.fromNotification( + context, + account.id, + notificationId, + body.id, + body.type, + ) val eventStackBuilder = TaskStackBuilder.create(context) eventStackBuilder.addParentStack(MainActivity::class.java) eventStackBuilder.addNextIntent(eventResultIntent) @@ -432,12 +440,12 @@ private fun getStatusComposeIntent( language = language, kind = ComposeOptions.ComposeKind.NEW, ) - val composeIntent = MainActivityIntent.openCompose( + val composeIntent = MainActivityIntent.fromNotificationCompose( context, - composeOptions, account.id, - body.id, + composeOptions, account.id.toInt(), + body.id, ) return PendingIntent.getActivity( context.applicationContext, @@ -597,8 +605,8 @@ fun disablePullNotifications(context: Context) { NotificationConfig.notificationMethod = NotificationConfig.Method.Unknown } -fun clearNotificationsForAccount(context: Context, account: AccountEntity) { - val accountId = account.id.toInt() +fun clearNotificationsForAccount(context: Context, pachliAccountId: Long) { + val accountId = pachliAccountId.toInt() val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager for (androidNotification in notificationManager.activeNotifications) { diff --git a/app/src/main/java/app/pachli/components/notifications/NotificationsFragment.kt b/app/src/main/java/app/pachli/components/notifications/NotificationsFragment.kt index 8fa642f35..ae0f3202b 100644 --- a/app/src/main/java/app/pachli/components/notifications/NotificationsFragment.kt +++ b/app/src/main/java/app/pachli/components/notifications/NotificationsFragment.kt @@ -82,6 +82,7 @@ import com.google.android.material.snackbar.Snackbar import com.mikepenz.iconics.IconicsSize import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial import dagger.hilt.android.AndroidEntryPoint +import dagger.hilt.android.lifecycle.withCreationCallback import kotlin.properties.Delegates import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collect @@ -104,7 +105,13 @@ class NotificationsFragment : MenuProvider, ReselectableFragment { - private val viewModel: NotificationsViewModel by viewModels() + private val viewModel: NotificationsViewModel by viewModels( + extrasProducer = { + defaultViewModelCreationExtras.withCreationCallback { factory -> + factory.create(requireArguments().getLong(ARG_PACHLI_ACCOUNT_ID)) + } + }, + ) private val binding by viewBinding(FragmentTimelineNotificationsBinding::bind) @@ -116,6 +123,16 @@ class NotificationsFragment : override var pachliAccountId by Delegates.notNull() + // Update post timestamps + private val updateTimestampFlow = flow { + while (true) { + delay(60000) + emit(Unit) + } + }.onEach { + adapter.notifyItemRangeChanged(0, adapter.itemCount, listOf(StatusBaseViewHolder.Key.KEY_CREATED)) + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -124,10 +141,9 @@ class NotificationsFragment : adapter = NotificationsPagingAdapter( notificationDiffCallback, pachliAccountId, - accountId = viewModel.account.accountId, - statusActionListener = this, - notificationActionListener = this, - accountActionListener = this, + statusActionListener = this@NotificationsFragment, + notificationActionListener = this@NotificationsFragment, + accountActionListener = this@NotificationsFragment, statusDisplayOptions = viewModel.statusDisplayOptions.value, ) } @@ -181,7 +197,7 @@ class NotificationsFragment : // reading position is always restorable. layoutManager.findFirstVisibleItemPosition().takeIf { it != NO_POSITION }?.let { position -> adapter.snapshot().getOrNull(position)?.id?.let { id -> - viewModel.accept(InfallibleUiAction.SaveVisibleId(visibleId = id)) + viewModel.accept(InfallibleUiAction.SaveVisibleId(pachliAccountId, visibleId = id)) } } } @@ -211,16 +227,6 @@ class NotificationsFragment : (binding.recyclerView.itemAnimator as SimpleItemAnimator?)!!.supportsChangeAnimations = false - // Update post timestamps - val updateTimestampFlow = flow { - while (true) { - delay(60000) - emit(Unit) - } - }.onEach { - adapter.notifyItemRangeChanged(0, adapter.itemCount, listOf(StatusBaseViewHolder.Key.KEY_CREATED)) - } - viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { launch { @@ -317,10 +323,13 @@ class NotificationsFragment : val status = when (it) { is StatusActionSuccess.Bookmark -> statusViewData.status.copy(bookmarked = it.action.state) + is StatusActionSuccess.Favourite -> statusViewData.status.copy(favourited = it.action.state) + is StatusActionSuccess.Reblog -> statusViewData.status.copy(reblogged = it.action.state) + is StatusActionSuccess.VoteInPoll -> statusViewData.status.copy( poll = it.action.poll.votedCopy(it.action.choices), @@ -340,6 +349,7 @@ class NotificationsFragment : when (it) { is UiSuccess.Block, is UiSuccess.Mute, is UiSuccess.MuteConversation -> adapter.refresh() + else -> { /* nothing to do */ } @@ -413,7 +423,10 @@ class NotificationsFragment : } peeked = true } - else -> { /* nothing to do */ } + + else -> { + /* nothing to do */ + } } } } @@ -430,11 +443,15 @@ class NotificationsFragment : binding.progressBar.show() } } + UserRefreshState.COMPLETE, UserRefreshState.ERROR -> { binding.progressBar.hide() binding.swipeRefreshLayout.isRefreshing = false } - else -> { /* nothing to do */ } + + else -> { + /* nothing to do */ + } } } } @@ -499,7 +516,7 @@ class NotificationsFragment : override fun onRefresh() { binding.progressBar.isVisible = false adapter.refresh() - clearNotificationsForAccount(requireContext(), viewModel.account) + clearNotificationsForAccount(requireContext(), pachliAccountId) } override fun onPause() { @@ -509,7 +526,7 @@ class NotificationsFragment : val position = layoutManager.findFirstVisibleItemPosition() if (position >= 0) { adapter.snapshot().getOrNull(position)?.id?.let { id -> - viewModel.accept(InfallibleUiAction.SaveVisibleId(visibleId = id)) + viewModel.accept(InfallibleUiAction.SaveVisibleId(pachliAccountId, visibleId = id)) } } } @@ -524,11 +541,11 @@ class NotificationsFragment : adapter.notifyItemRangeChanged(0, adapter.itemCount) } - clearNotificationsForAccount(requireContext(), viewModel.account) + clearNotificationsForAccount(requireContext(), pachliAccountId) } - override fun onReply(viewData: NotificationViewData) { - super.reply(viewData.statusViewData!!.actionable) + override fun onReply(pachliAccountId: Long, viewData: NotificationViewData) { + super.reply(pachliAccountId, viewData.statusViewData!!.actionable) } override fun onReblog(viewData: NotificationViewData, reblog: Boolean) { @@ -634,7 +651,7 @@ class NotificationsFragment : private fun showFilterDialog() { FilterDialogFragment(viewModel.uiState.value.activeFilter) { filter -> if (viewModel.uiState.value.activeFilter != filter) { - viewModel.accept(InfallibleUiAction.ApplyFilter(filter)) + viewModel.accept(InfallibleUiAction.ApplyFilter(pachliAccountId, filter)) } }.show(parentFragmentManager, "dialogFilter") } diff --git a/app/src/main/java/app/pachli/components/notifications/NotificationsPagingAdapter.kt b/app/src/main/java/app/pachli/components/notifications/NotificationsPagingAdapter.kt index 298394801..2800dce6b 100644 --- a/app/src/main/java/app/pachli/components/notifications/NotificationsPagingAdapter.kt +++ b/app/src/main/java/app/pachli/components/notifications/NotificationsPagingAdapter.kt @@ -112,8 +112,6 @@ interface NotificationActionListener { class NotificationsPagingAdapter( diffCallback: DiffUtil.ItemCallback, private val pachliAccountId: Long, - /** ID of the the account that notifications are being displayed for */ - private val accountId: String, private val statusActionListener: StatusActionListener, private val notificationActionListener: NotificationActionListener, private val accountActionListener: AccountActionListener, @@ -150,14 +148,12 @@ class NotificationsPagingAdapter( StatusViewHolder( ItemStatusBinding.inflate(inflater, parent, false), statusActionListener, - accountId, ) } NotificationViewKind.STATUS_FILTERED -> { FilterableStatusViewHolder( ItemStatusWrapperBinding.inflate(inflater, parent, false), statusActionListener, - accountId, ) } NotificationViewKind.NOTIFICATION -> { diff --git a/app/src/main/java/app/pachli/components/notifications/NotificationsViewModel.kt b/app/src/main/java/app/pachli/components/notifications/NotificationsViewModel.kt index ccf90bd79..d5af6f61e 100644 --- a/app/src/main/java/app/pachli/components/notifications/NotificationsViewModel.kt +++ b/app/src/main/java/app/pachli/components/notifications/NotificationsViewModel.kt @@ -17,7 +17,6 @@ package app.pachli.components.notifications -import android.content.Context import androidx.annotation.StringRes import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -32,8 +31,8 @@ import app.pachli.appstore.MuteConversationEvent import app.pachli.appstore.MuteEvent import app.pachli.core.common.extensions.throttleFirst import app.pachli.core.data.repository.AccountManager -import app.pachli.core.data.repository.ContentFiltersRepository import app.pachli.core.data.repository.StatusDisplayOptionsRepository +import app.pachli.core.database.model.AccountEntity import app.pachli.core.model.ContentFilterVersion import app.pachli.core.model.FilterAction import app.pachli.core.model.FilterContext @@ -49,11 +48,10 @@ import app.pachli.util.serialize import app.pachli.viewdata.NotificationViewData import app.pachli.viewdata.StatusViewData import at.connyduck.calladapter.networkresult.getOrThrow -import com.github.michaelbull.result.onFailure -import com.github.michaelbull.result.onSuccess +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel -import dagger.hilt.android.qualifiers.ApplicationContext -import javax.inject.Inject import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow @@ -64,14 +62,16 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.getAndUpdate import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import retrofit2.HttpException @@ -119,7 +119,10 @@ sealed interface InfallibleUiAction : UiAction { // This saves the list to the local database, which triggers a refresh of the data. // Saving the data can't fail, which is why this is infallible. Refreshing the // data may fail, but that's handled by the paging system / adapter refresh logic. - data class ApplyFilter(val filter: Set) : InfallibleUiAction + data class ApplyFilter( + val pachliAccountId: Long, + val filter: Set, + ) : InfallibleUiAction /** * User is leaving the fragment, save the ID of the visible notification. @@ -127,7 +130,10 @@ sealed interface InfallibleUiAction : UiAction { * Infallible because if it fails there's nowhere to show the error, and nothing the user * can do. */ - data class SaveVisibleId(val visibleId: String) : InfallibleUiAction + data class SaveVisibleId( + val pachliAccountId: Long, + val visibleId: String, + ) : InfallibleUiAction /** Ignore the saved reading position, load the page with the newest items */ // Resets the account's `lastNotificationId`, which can't fail, which is why this is @@ -306,21 +312,23 @@ sealed interface UiError { } @OptIn(ExperimentalCoroutinesApi::class) -@HiltViewModel -class NotificationsViewModel @Inject constructor( - // TODO: Context is required because handling filter errors needs to - // format a resource string. As soon as that is removed this can be removed. - @ApplicationContext private val context: Context, +@HiltViewModel(assistedFactory = NotificationsViewModel.Factory::class) +class NotificationsViewModel @AssistedInject constructor( private val repository: NotificationsRepository, private val accountManager: AccountManager, private val timelineCases: TimelineCases, private val eventHub: EventHub, - private val contentFiltersRepository: ContentFiltersRepository, statusDisplayOptionsRepository: StatusDisplayOptionsRepository, private val sharedPreferencesRepository: SharedPreferencesRepository, + @Assisted val pachliAccountId: Long, ) : ViewModel() { + val accountFlow = accountManager.getPachliAccountFlow(pachliAccountId) + .filterNotNull() + .shareIn(viewModelScope, SharingStarted.WhileSubscribed(5000), replay = 1) + /** The account to display notifications for */ - val account = accountManager.activeAccount!! + val account: AccountEntity + get() = accountFlow.replayCache.first().entity val uiState: StateFlow @@ -362,23 +370,17 @@ class NotificationsViewModel @Inject constructor( init { // Handle changes to notification filters - val notificationFilter = uiAction - .filterIsInstance() - .distinctUntilChanged() - // Save each change back to the active account - .onEach { action -> - Timber.d("notificationFilter: %s", action) - account.notificationsFilter = serialize(action.filter) - accountManager.saveAccount(account) - } - // Load the initial filter from the active account - .onStart { - emit( - InfallibleUiAction.ApplyFilter( - filter = deserialize(account.notificationsFilter), - ), - ) - } + viewModelScope.launch { + uiAction + .filterIsInstance() + .distinctUntilChanged() + .collectLatest { action -> + accountManager.setNotificationsFilter( + action.pachliAccountId, + serialize(action.filter), + ) + } + } // Reset the last notification ID to "0" to fetch the newest notifications, and // increment `reload` to trigger creation of a new PagingSource. @@ -386,8 +388,7 @@ class NotificationsViewModel @Inject constructor( uiAction .filterIsInstance() .collectLatest { - account.lastNotificationId = "0" - accountManager.saveAccount(account) + accountManager.setLastNotificationId(account.id, "0") reload.getAndUpdate { it + 1 } repository.invalidate() } @@ -400,8 +401,7 @@ class NotificationsViewModel @Inject constructor( .distinctUntilChanged() .collectLatest { action -> Timber.d("Saving visible ID: %s, active account = %d", action.visibleId, account.id) - account.lastNotificationId = action.visibleId - accountManager.saveAccount(account) + accountManager.setLastNotificationId(account.id, action.visibleId) } } @@ -480,18 +480,14 @@ class NotificationsViewModel @Inject constructor( // Fetch the status filters viewModelScope.launch { - contentFiltersRepository.contentFilters.collect { filters -> - filters.onSuccess { - contentFilterModel = when (it?.version) { + accountManager.activePachliAccountFlow + .distinctUntilChangedBy { it.contentFilters } + .collect { account -> + contentFilterModel = when (account.contentFilters.version) { ContentFilterVersion.V2 -> ContentFilterModel(FilterContext.NOTIFICATIONS) - ContentFilterVersion.V1 -> ContentFilterModel(FilterContext.NOTIFICATIONS, it.contentFilters) - else -> null + ContentFilterVersion.V1 -> ContentFilterModel(FilterContext.NOTIFICATIONS, account.contentFilters.contentFilters) } - reload.getAndUpdate { it + 1 } - }.onFailure { - _uiErrorChannel.send(UiError.GetFilters(RuntimeException(it.fmt(context)))) } - } } // Handle events that should refresh the list @@ -505,16 +501,16 @@ class NotificationsViewModel @Inject constructor( } } - // Re-fetch notifications if either of `notificationFilter` or `reload` flows have + // Re-fetch notifications if either of `notificationsFilter` or `reload` flows have // new items. - pagingData = combine(notificationFilter, reload) { action, _ -> action } - .flatMapLatest { action -> - getNotifications(filters = action.filter, initialKey = getInitialKey()) + pagingData = combine(accountFlow.distinctUntilChangedBy { it.entity.notificationsFilter }, reload) { account, _ -> account } + .flatMapLatest { account -> + getNotifications(account.entity.accountId, filters = deserialize(account.entity.notificationsFilter), initialKey = getInitialKey()) }.cachedIn(viewModelScope) - uiState = combine(notificationFilter, getUiPrefs()) { filter, _ -> + uiState = combine(accountFlow.distinctUntilChangedBy { it.entity.notificationsFilter }, getUiPrefs()) { account, _ -> UiState( - activeFilter = filter.filter, + activeFilter = deserialize(account.entity.notificationsFilter), showFabWhileScrolling = !sharedPreferencesRepository.getBoolean(PrefKeys.FAB_HIDE, false), tabTapBehaviour = sharedPreferencesRepository.tabTapBehaviour, ) @@ -526,6 +522,7 @@ class NotificationsViewModel @Inject constructor( } private fun getNotifications( + accountId: String, filters: Set, initialKey: String? = null, ): Flow> { @@ -541,6 +538,7 @@ class NotificationsViewModel @Inject constructor( isExpanded = statusDisplayOptions.value.openSpoiler, isCollapsed = true, filterAction = filterAction, + isAboutSelf = notification.account.id == accountId, ) }.filter { it.statusViewData?.filterAction != FilterAction.HIDE @@ -565,4 +563,10 @@ class NotificationsViewModel @Inject constructor( private fun getUiPrefs() = sharedPreferencesRepository.changes .filter { UiPrefs.prefKeys.contains(it) } .onStart { emit(null) } + + @AssistedFactory + interface Factory { + /** Creates [NotificationsViewModel] with [pachliAccountId] as the active account. */ + fun create(pachliAccountId: Long): NotificationsViewModel + } } diff --git a/app/src/main/java/app/pachli/components/notifications/PushNotificationHelper.kt b/app/src/main/java/app/pachli/components/notifications/PushNotificationHelper.kt index 1a3d22012..63937a29c 100644 --- a/app/src/main/java/app/pachli/components/notifications/PushNotificationHelper.kt +++ b/app/src/main/java/app/pachli/components/notifications/PushNotificationHelper.kt @@ -208,12 +208,7 @@ suspend fun disablePushNotificationsForAccount(context: Context, api: MastodonAp if (account.notificationMethod != AccountNotificationMethod.PUSH) return // Clear the push notification from the account. - account.unifiedPushUrl = "" - account.pushServerKey = "" - account.pushAuth = "" - account.pushPrivKey = "" - account.pushPubKey = "" - accountManager.saveAccount(account) + accountManager.clearPushNotificationData(account.id) NotificationConfig.notificationMethodAccount[account.fullName] = NotificationConfig.Method.Pull // Try and unregister the endpoint from the server. Nothing we can do if this fails, and no @@ -273,7 +268,7 @@ suspend fun registerUnifiedPushEndpoint( val auth = CryptoUtil.secureRandomBytesEncoded(16) api.subscribePushNotifications( - "Bearer ${account.accessToken}", + account.authHeader, account.domain, endpoint, keyPair.pubkey, @@ -286,12 +281,14 @@ suspend fun registerUnifiedPushEndpoint( }.onSuccess { Timber.d("UnifiedPush registration succeeded for account %d", account.id) - account.pushPubKey = keyPair.pubkey - account.pushPrivKey = keyPair.privKey - account.pushAuth = auth - account.pushServerKey = it.body.serverKey - account.unifiedPushUrl = endpoint - accountManager.saveAccount(account) + accountManager.setPushNotificationData( + account.id, + unifiedPushUrl = endpoint, + pushServerKey = it.body.serverKey, + pushAuth = auth, + pushPrivKey = keyPair.privKey, + pushPubKey = keyPair.pubkey, + ) NotificationConfig.notificationMethodAccount[account.fullName] = NotificationConfig.Method.Push } diff --git a/app/src/main/java/app/pachli/components/notifications/StatusViewHolder.kt b/app/src/main/java/app/pachli/components/notifications/StatusViewHolder.kt index ed4d48100..51c904538 100644 --- a/app/src/main/java/app/pachli/components/notifications/StatusViewHolder.kt +++ b/app/src/main/java/app/pachli/components/notifications/StatusViewHolder.kt @@ -29,7 +29,6 @@ import app.pachli.viewdata.NotificationViewData internal class StatusViewHolder( binding: ItemStatusBinding, private val statusActionListener: StatusActionListener, - private val accountId: String, ) : NotificationsPagingAdapter.ViewHolder, StatusViewHolder(binding) { override fun bind( @@ -56,7 +55,7 @@ internal class StatusViewHolder( ) } if (viewData.type == Notification.Type.POLL) { - setPollInfo(accountId == viewData.account.id) + setPollInfo(viewData.isAboutSelf) } else { hideStatusInfo() } @@ -66,7 +65,6 @@ internal class StatusViewHolder( class FilterableStatusViewHolder( binding: ItemStatusWrapperBinding, private val statusActionListener: StatusActionListener, - private val accountId: String, ) : NotificationsPagingAdapter.ViewHolder, FilterableStatusViewHolder(binding) { // Note: Identical to bind() in StatusViewHolder above override fun bind( @@ -93,7 +91,7 @@ class FilterableStatusViewHolder( ) } if (viewData.type == Notification.Type.POLL) { - setPollInfo(accountId == viewData.account.id) + setPollInfo(viewData.isAboutSelf) } else { hideStatusInfo() } diff --git a/app/src/main/java/app/pachli/components/preference/AccountPreferencesFragment.kt b/app/src/main/java/app/pachli/components/preference/AccountPreferencesFragment.kt index b072bec56..2f26f55af 100644 --- a/app/src/main/java/app/pachli/components/preference/AccountPreferencesFragment.kt +++ b/app/src/main/java/app/pachli/components/preference/AccountPreferencesFragment.kt @@ -36,6 +36,8 @@ import app.pachli.core.common.util.unsafeLazy import app.pachli.core.data.repository.AccountManager import app.pachli.core.data.repository.AccountPreferenceDataStore import app.pachli.core.data.repository.ContentFiltersRepository +import app.pachli.core.data.repository.canFilterV1 +import app.pachli.core.data.repository.canFilterV2 import app.pachli.core.designsystem.R as DR import app.pachli.core.navigation.AccountListActivityIntent import app.pachli.core.navigation.ContentFiltersActivityIntent @@ -60,13 +62,13 @@ import app.pachli.util.getInitialLanguages import app.pachli.util.getLocaleList import app.pachli.util.getPachliDisplayName import app.pachli.util.iconRes -import com.github.michaelbull.result.Ok import com.google.android.material.snackbar.Snackbar import com.mikepenz.iconics.IconicsDrawable import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject import kotlin.properties.Delegates +import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.launch import retrofit2.Call import retrofit2.Callback @@ -110,12 +112,14 @@ class AccountPreferencesFragment : PreferenceFragmentCompat() { viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.RESUMED) { // Enable/disable the filter preference based on info from - // FiltersRespository. filterPreferences is safe to access here, + // the server. filterPreferences is safe to access here, // it was populated in onCreatePreferences, called by onCreate // before onViewCreated is called. - contentFiltersRepository.contentFilters.collect { filters -> - filterPreference.isEnabled = filters is Ok - } + accountManager.activePachliAccountFlow + .distinctUntilChangedBy { it.server } + .collect { account -> + filterPreference.isEnabled = account.server.canFilterV2() || account.server.canFilterV1() + } } } return super.onViewCreated(view, savedInstanceState) @@ -188,7 +192,7 @@ class AccountPreferencesFragment : PreferenceFragmentCompat() { setTitle(R.string.title_migration_relogin) setIcon(R.drawable.ic_logout) setOnPreferenceClickListener { - val intent = LoginActivityIntent(context, LoginMode.MIGRATION) + val intent = LoginActivityIntent(context, LoginMode.Reauthenticate(accountManager.activeAccount!!.domain)) activity?.startActivityWithTransition(intent, TransitionKind.EXPLODE) true } @@ -326,11 +330,13 @@ class AccountPreferencesFragment : PreferenceFragmentCompat() { val account = response.body() if (response.isSuccessful && account != null) { accountManager.activeAccount?.let { - it.defaultPostPrivacy = account.source?.privacy - ?: Status.Visibility.PUBLIC - it.defaultMediaSensitivity = account.source?.sensitive ?: false - it.defaultPostLanguage = language.orEmpty() - accountManager.saveAccount(it) + accountManager.setDefaultPostPrivacy( + it.id, + account.source?.privacy + ?: Status.Visibility.PUBLIC, + ) + accountManager.setDefaultMediaSensitivity(it.id, account.source?.sensitive ?: false) + accountManager.setDefaultPostLanguage(it.id, language.orEmpty()) } } else { Timber.e("failed updating settings on server") diff --git a/app/src/main/java/app/pachli/components/preference/NotificationPreferencesFragment.kt b/app/src/main/java/app/pachli/components/preference/NotificationPreferencesFragment.kt index 53aad88f7..2ed233927 100644 --- a/app/src/main/java/app/pachli/components/preference/NotificationPreferencesFragment.kt +++ b/app/src/main/java/app/pachli/components/preference/NotificationPreferencesFragment.kt @@ -23,7 +23,6 @@ import app.pachli.components.notifications.disablePullNotifications import app.pachli.components.notifications.domain.AndroidNotificationsAreEnabledUseCase import app.pachli.components.notifications.enablePullNotifications import app.pachli.core.data.repository.AccountManager -import app.pachli.core.database.model.AccountEntity import app.pachli.core.preferences.PrefKeys import app.pachli.settings.makePreferenceScreen import app.pachli.settings.preferenceCategory @@ -33,7 +32,6 @@ import javax.inject.Inject @AndroidEntryPoint class NotificationPreferencesFragment : PreferenceFragmentCompat() { - @Inject lateinit var accountManager: AccountManager @@ -50,7 +48,7 @@ class NotificationPreferencesFragment : PreferenceFragmentCompat() { isIconSpaceReserved = false isChecked = activeAccount.notificationsEnabled setOnPreferenceChangeListener { _, newValue -> - updateAccount { it.notificationsEnabled = newValue as Boolean } + accountManager.setNotificationsEnabled(activeAccount.id, newValue as Boolean) if (androidNotificationsAreEnabled(context)) { enablePullNotifications(context) } else { @@ -70,7 +68,7 @@ class NotificationPreferencesFragment : PreferenceFragmentCompat() { isIconSpaceReserved = false isChecked = activeAccount.notificationsFollowed setOnPreferenceChangeListener { _, newValue -> - updateAccount { it.notificationsFollowed = newValue as Boolean } + accountManager.setNotificationsFollowed(activeAccount.id, newValue as Boolean) true } } @@ -81,7 +79,7 @@ class NotificationPreferencesFragment : PreferenceFragmentCompat() { isIconSpaceReserved = false isChecked = activeAccount.notificationsFollowRequested setOnPreferenceChangeListener { _, newValue -> - updateAccount { it.notificationsFollowRequested = newValue as Boolean } + accountManager.setNotificationsFollowRequested(activeAccount.id, newValue as Boolean) true } } @@ -92,7 +90,7 @@ class NotificationPreferencesFragment : PreferenceFragmentCompat() { isIconSpaceReserved = false isChecked = activeAccount.notificationsReblogged setOnPreferenceChangeListener { _, newValue -> - updateAccount { it.notificationsReblogged = newValue as Boolean } + accountManager.setNotificationsReblogged(activeAccount.id, newValue as Boolean) true } } @@ -103,7 +101,7 @@ class NotificationPreferencesFragment : PreferenceFragmentCompat() { isIconSpaceReserved = false isChecked = activeAccount.notificationsFavorited setOnPreferenceChangeListener { _, newValue -> - updateAccount { it.notificationsFavorited = newValue as Boolean } + accountManager.setNotificationsFavorited(activeAccount.id, newValue as Boolean) true } } @@ -114,7 +112,7 @@ class NotificationPreferencesFragment : PreferenceFragmentCompat() { isIconSpaceReserved = false isChecked = activeAccount.notificationsPolls setOnPreferenceChangeListener { _, newValue -> - updateAccount { it.notificationsPolls = newValue as Boolean } + accountManager.setNotificationsPolls(activeAccount.id, newValue as Boolean) true } } @@ -125,7 +123,7 @@ class NotificationPreferencesFragment : PreferenceFragmentCompat() { isIconSpaceReserved = false isChecked = activeAccount.notificationsSubscriptions setOnPreferenceChangeListener { _, newValue -> - updateAccount { it.notificationsSubscriptions = newValue as Boolean } + accountManager.setNotificationsSubscriptions(activeAccount.id, newValue as Boolean) true } } @@ -136,7 +134,7 @@ class NotificationPreferencesFragment : PreferenceFragmentCompat() { isIconSpaceReserved = false isChecked = activeAccount.notificationsSignUps setOnPreferenceChangeListener { _, newValue -> - updateAccount { it.notificationsSignUps = newValue as Boolean } + accountManager.setNotificationsSignUps(activeAccount.id, newValue as Boolean) true } } @@ -147,7 +145,7 @@ class NotificationPreferencesFragment : PreferenceFragmentCompat() { isIconSpaceReserved = false isChecked = activeAccount.notificationsUpdates setOnPreferenceChangeListener { _, newValue -> - updateAccount { it.notificationsUpdates = newValue as Boolean } + accountManager.setNotificationsUpdates(activeAccount.id, newValue as Boolean) true } } @@ -158,7 +156,7 @@ class NotificationPreferencesFragment : PreferenceFragmentCompat() { isIconSpaceReserved = false isChecked = activeAccount.notificationsReports setOnPreferenceChangeListener { _, newValue -> - updateAccount { it.notificationsReports = newValue as Boolean } + accountManager.setNotificationsReports(activeAccount.id, newValue as Boolean) true } } @@ -174,7 +172,7 @@ class NotificationPreferencesFragment : PreferenceFragmentCompat() { isIconSpaceReserved = false isChecked = activeAccount.notificationSound setOnPreferenceChangeListener { _, newValue -> - updateAccount { it.notificationSound = newValue as Boolean } + accountManager.setNotificationSound(activeAccount.id, newValue as Boolean) true } } @@ -185,7 +183,7 @@ class NotificationPreferencesFragment : PreferenceFragmentCompat() { isIconSpaceReserved = false isChecked = activeAccount.notificationVibration setOnPreferenceChangeListener { _, newValue -> - updateAccount { it.notificationVibration = newValue as Boolean } + accountManager.setNotificationVibration(activeAccount.id, newValue as Boolean) true } } @@ -196,7 +194,7 @@ class NotificationPreferencesFragment : PreferenceFragmentCompat() { isIconSpaceReserved = false isChecked = activeAccount.notificationLight setOnPreferenceChangeListener { _, newValue -> - updateAccount { it.notificationLight = newValue as Boolean } + accountManager.setNotificationLight(activeAccount.id, newValue as Boolean) true } } @@ -204,13 +202,6 @@ class NotificationPreferencesFragment : PreferenceFragmentCompat() { } } - private inline fun updateAccount(changer: (AccountEntity) -> Unit) { - accountManager.activeAccount?.let { account -> - changer(account) - accountManager.saveAccount(account) - } - } - override fun onResume() { super.onResume() requireActivity().setTitle(R.string.pref_title_edit_notification_settings) diff --git a/app/src/main/java/app/pachli/components/preference/PreferencesActivity.kt b/app/src/main/java/app/pachli/components/preference/PreferencesActivity.kt index 8692a93fb..4eb6df33c 100644 --- a/app/src/main/java/app/pachli/components/preference/PreferencesActivity.kt +++ b/app/src/main/java/app/pachli/components/preference/PreferencesActivity.kt @@ -79,20 +79,22 @@ class PreferencesActivity : setDisplayShowHomeEnabled(true) } - val preferenceType = PreferencesActivityIntent.getPreferenceType(intent) + if (savedInstanceState == null) { + val preferenceType = PreferencesActivityIntent.getPreferenceType(intent) - val fragmentTag = "preference_fragment_$preferenceType" + val fragmentTag = "preference_fragment_$preferenceType" - val fragment: Fragment = supportFragmentManager.findFragmentByTag(fragmentTag) - ?: when (preferenceType) { - PreferenceScreen.GENERAL -> PreferencesFragment.newInstance() - PreferenceScreen.ACCOUNT -> AccountPreferencesFragment.newInstance(intent.pachliAccountId) - PreferenceScreen.NOTIFICATION -> NotificationPreferencesFragment.newInstance() - else -> throw IllegalArgumentException("preferenceType not known") + val fragment: Fragment = supportFragmentManager.findFragmentByTag(fragmentTag) + ?: when (preferenceType) { + PreferenceScreen.GENERAL -> PreferencesFragment.newInstance() + PreferenceScreen.ACCOUNT -> AccountPreferencesFragment.newInstance(intent.pachliAccountId) + PreferenceScreen.NOTIFICATION -> NotificationPreferencesFragment.newInstance() + else -> throw IllegalArgumentException("preferenceType not known") + } + + supportFragmentManager.commit { + replace(R.id.fragment_container, fragment, fragmentTag) } - - supportFragmentManager.commit { - replace(R.id.fragment_container, fragment, fragmentTag) } onBackPressedDispatcher.addCallback(this, restartActivitiesOnBackPressedCallback) @@ -148,6 +150,7 @@ class PreferencesActivity : TransitionKind.SLIDE_FROM_END.closeExit, ) replace(R.id.fragment_container, fragment) + setReorderingAllowed(true) addToBackStack(null) } return true diff --git a/app/src/main/java/app/pachli/components/scheduled/ScheduledStatusActivity.kt b/app/src/main/java/app/pachli/components/scheduled/ScheduledStatusActivity.kt index 3989cd117..c87da1271 100644 --- a/app/src/main/java/app/pachli/components/scheduled/ScheduledStatusActivity.kt +++ b/app/src/main/java/app/pachli/components/scheduled/ScheduledStatusActivity.kt @@ -35,6 +35,7 @@ import app.pachli.core.common.extensions.show import app.pachli.core.common.extensions.viewBinding import app.pachli.core.navigation.ComposeActivityIntent import app.pachli.core.navigation.ComposeActivityIntent.ComposeOptions +import app.pachli.core.navigation.pachliAccountId import app.pachli.core.network.model.ScheduledStatus import app.pachli.core.ui.BackgroundMessage import app.pachli.databinding.ActivityScheduledStatusBinding @@ -155,6 +156,7 @@ class ScheduledStatusActivity : override fun edit(item: ScheduledStatus) { val intent = ComposeActivityIntent( this, + intent.pachliAccountId, ComposeOptions( scheduledTootId = item.id, content = item.params.text, diff --git a/app/src/main/java/app/pachli/components/search/SearchActivity.kt b/app/src/main/java/app/pachli/components/search/SearchActivity.kt index af36e6759..ff9a653f4 100644 --- a/app/src/main/java/app/pachli/components/search/SearchActivity.kt +++ b/app/src/main/java/app/pachli/components/search/SearchActivity.kt @@ -74,6 +74,7 @@ import app.pachli.core.common.extensions.show import app.pachli.core.common.extensions.toggleVisibility import app.pachli.core.common.extensions.viewBinding import app.pachli.core.common.extensions.visible +import app.pachli.core.data.model.Server import app.pachli.core.model.ServerOperation.ORG_JOINMASTODON_SEARCH_QUERY_BY_DATE import app.pachli.core.model.ServerOperation.ORG_JOINMASTODON_SEARCH_QUERY_FROM import app.pachli.core.model.ServerOperation.ORG_JOINMASTODON_SEARCH_QUERY_HAS_AUDIO @@ -89,7 +90,6 @@ import app.pachli.core.model.ServerOperation.ORG_JOINMASTODON_SEARCH_QUERY_IS_RE import app.pachli.core.model.ServerOperation.ORG_JOINMASTODON_SEARCH_QUERY_IS_SENSITIVE import app.pachli.core.model.ServerOperation.ORG_JOINMASTODON_SEARCH_QUERY_LANGUAGE import app.pachli.core.navigation.pachliAccountId -import app.pachli.core.network.Server import app.pachli.core.ui.extensions.await import app.pachli.core.ui.extensions.awaitSingleChoiceItem import app.pachli.core.ui.extensions.reduceSwipeSensitivity diff --git a/app/src/main/java/app/pachli/components/search/SearchViewModel.kt b/app/src/main/java/app/pachli/components/search/SearchViewModel.kt index 1f9c55eb3..6da1b26bd 100644 --- a/app/src/main/java/app/pachli/components/search/SearchViewModel.kt +++ b/app/src/main/java/app/pachli/components/search/SearchViewModel.kt @@ -34,6 +34,7 @@ import app.pachli.components.search.SearchOperator.LanguageOperator import app.pachli.components.search.SearchOperator.WhereOperator import app.pachli.components.search.adapter.SearchPagingSourceFactory import app.pachli.core.data.repository.AccountManager +import app.pachli.core.data.repository.Loadable import app.pachli.core.data.repository.ServerRepository import app.pachli.core.database.model.AccountEntity import app.pachli.core.model.ServerOperation.ORG_JOINMASTODON_SEARCH_QUERY_BY_DATE @@ -70,6 +71,7 @@ import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update @@ -120,13 +122,14 @@ class SearchViewModel @Inject constructor( */ val operatorViewData = _operatorViewData.asStateFlow() - val locales = accountManager.activeAccountFlow.map { - getLocaleList(getInitialLanguages(activeAccount = it)) - }.stateIn( - viewModelScope, - SharingStarted.WhileSubscribed(5000), - getLocaleList(getInitialLanguages()), - ) + val locales = accountManager.activeAccountFlow + .filterIsInstance>() + .map { getLocaleList(getInitialLanguages(activeAccount = it.data)) } + .stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5000), + getLocaleList(getInitialLanguages()), + ) val server = serverRepository.flow.stateIn( viewModelScope, diff --git a/app/src/main/java/app/pachli/components/search/fragments/SearchStatusesFragment.kt b/app/src/main/java/app/pachli/components/search/fragments/SearchStatusesFragment.kt index a445ce68a..50c0e8dc6 100644 --- a/app/src/main/java/app/pachli/components/search/fragments/SearchStatusesFragment.kt +++ b/app/src/main/java/app/pachli/components/search/fragments/SearchStatusesFragment.kt @@ -92,8 +92,8 @@ class SearchStatusesFragment : SearchFragment(), StatusActionLis viewModel.contentHiddenChange(viewData, isShowing) } - override fun onReply(viewData: StatusViewData) { - reply(viewData) + override fun onReply(pachliAccountId: Long, viewData: StatusViewData) { + reply(pachliAccountId, viewData) } override fun onFavourite(viewData: StatusViewData, favourite: Boolean) { @@ -173,7 +173,7 @@ class SearchStatusesFragment : SearchFragment(), StatusActionLis ) } - private fun reply(status: StatusViewData) { + private fun reply(pachliAccountId: Long, status: StatusViewData) { val actionableStatus = status.actionable val mentionedUsernames = actionableStatus.mentions.map { it.username } .toMutableSet() @@ -184,6 +184,7 @@ class SearchStatusesFragment : SearchFragment(), StatusActionLis val intent = ComposeActivityIntent( requireContext(), + pachliAccountId, ComposeOptions( inReplyToId = status.actionableId, replyVisibility = actionableStatus.visibility, @@ -321,11 +322,11 @@ class SearchStatusesFragment : SearchFragment(), StatusActionLis return@setOnMenuItemClickListener true } R.id.status_delete_and_redraft -> { - showConfirmEditDialog(statusViewData) + showConfirmEditDialog(pachliAccountId, statusViewData) return@setOnMenuItemClickListener true } R.id.status_edit -> { - editStatus(id, status) + editStatus(pachliAccountId, id, status) return@setOnMenuItemClickListener true } R.id.pin -> { @@ -414,7 +415,7 @@ class SearchStatusesFragment : SearchFragment(), StatusActionLis } // TODO: Identical to the same function in SFragment.kt - private fun showConfirmEditDialog(statusViewData: StatusViewData) { + private fun showConfirmEditDialog(pachliAccountId: Long, statusViewData: StatusViewData) { activity?.let { AlertDialog.Builder(it) .setMessage(R.string.dialog_redraft_post_warning) @@ -432,6 +433,7 @@ class SearchStatusesFragment : SearchFragment(), StatusActionLis val intent = ComposeActivityIntent( requireContext(), + pachliAccountId, ComposeOptions( content = redraftStatus.text.orEmpty(), inReplyToId = redraftStatus.inReplyToId, @@ -458,7 +460,7 @@ class SearchStatusesFragment : SearchFragment(), StatusActionLis } } - private fun editStatus(id: String, status: Status) { + private fun editStatus(pachliAccountId: Long, id: String, status: Status) { lifecycleScope.launch { mastodonApi.statusSource(id).fold( { source -> @@ -474,7 +476,7 @@ class SearchStatusesFragment : SearchFragment(), StatusActionLis poll = status.poll?.toNewPoll(status.createdAt), kind = ComposeOptions.ComposeKind.EDIT_POSTED, ) - startActivity(ComposeActivityIntent(requireContext(), composeOptions)) + startActivity(ComposeActivityIntent(requireContext(), pachliAccountId, composeOptions)) }, { Snackbar.make( diff --git a/app/src/main/java/app/pachli/components/timeline/CachedTimelineRepository.kt b/app/src/main/java/app/pachli/components/timeline/CachedTimelineRepository.kt index dd29476b0..0bf1c530d 100644 --- a/app/src/main/java/app/pachli/components/timeline/CachedTimelineRepository.kt +++ b/app/src/main/java/app/pachli/components/timeline/CachedTimelineRepository.kt @@ -24,11 +24,11 @@ import androidx.paging.PagingConfig import androidx.paging.PagingData import app.pachli.components.timeline.viewmodel.CachedTimelineRemoteMediator import app.pachli.core.common.di.ApplicationScope -import app.pachli.core.data.repository.AccountManager import app.pachli.core.database.dao.RemoteKeyDao import app.pachli.core.database.dao.TimelineDao import app.pachli.core.database.dao.TranslatedStatusDao import app.pachli.core.database.di.TransactionProvider +import app.pachli.core.database.model.AccountEntity import app.pachli.core.database.model.StatusViewDataEntity import app.pachli.core.database.model.TimelineStatusWithAccount import app.pachli.core.database.model.TranslatedStatusEntity @@ -36,7 +36,6 @@ import app.pachli.core.database.model.TranslationState import app.pachli.core.model.Timeline import app.pachli.core.network.model.Translation import app.pachli.core.network.retrofit.MastodonApi -import app.pachli.util.EmptyPagingSource import app.pachli.viewdata.StatusViewData import at.connyduck.calladapter.networkresult.NetworkResult import at.connyduck.calladapter.networkresult.fold @@ -46,7 +45,6 @@ import javax.inject.Singleton import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.launch import timber.log.Timber @@ -61,7 +59,6 @@ import timber.log.Timber @Singleton class CachedTimelineRepository @Inject constructor( private val mastodonApi: MastodonApi, - private val accountManager: AccountManager, private val transactionProvider: TransactionProvider, val timelineDao: TimelineDao, private val remoteKeyDao: RemoteKeyDao, @@ -71,58 +68,51 @@ class CachedTimelineRepository @Inject constructor( ) { private var factory: InvalidatingPagingSourceFactory? = null - private var activeAccount = accountManager.activeAccount - /** @return flow of Mastodon [TimelineStatusWithAccount], loaded in [pageSize] increments */ @OptIn(ExperimentalPagingApi::class, ExperimentalCoroutinesApi::class) fun getStatusStream( + account: AccountEntity, kind: Timeline, pageSize: Int = PAGE_SIZE, initialKey: String? = null, ): Flow> { Timber.d("getStatusStream(): key: %s", initialKey) - return accountManager.activeAccountFlow.flatMapLatest { - activeAccount = it + Timber.d("getStatusStream, account is %s", account.fullName) - factory = InvalidatingPagingSourceFactory { - activeAccount?.let { timelineDao.getStatuses(it.id) } ?: EmptyPagingSource() - } + factory = InvalidatingPagingSourceFactory { timelineDao.getStatuses(account.id) } - val row = initialKey?.let { key -> - // Room is row-keyed (by Int), not item-keyed, so the status ID string that was - // passed as `initialKey` won't work. - // - // Instead, get all the status IDs for this account, in timeline order, and find the - // row index that contains the status. The row index is the correct initialKey. - activeAccount?.let { account -> - timelineDao.getStatusRowNumber(account.id) - .indexOfFirst { it == key }.takeIf { it != -1 } - } - } - - Timber.d("initialKey: %s is row: %d", initialKey, row) - - Pager( - config = PagingConfig( - pageSize = pageSize, - jumpThreshold = PAGE_SIZE * 3, - enablePlaceholders = true, - ), - initialKey = row, - remoteMediator = CachedTimelineRemoteMediator( - initialKey, - mastodonApi, - activeAccount!!.id, - factory!!, - transactionProvider, - timelineDao, - remoteKeyDao, - moshi, - ), - pagingSourceFactory = factory!!, - ).flow + val row = initialKey?.let { key -> + // Room is row-keyed (by Int), not item-keyed, so the status ID string that was + // passed as `initialKey` won't work. + // + // Instead, get all the status IDs for this account, in timeline order, and find the + // row index that contains the status. The row index is the correct initialKey. + timelineDao.getStatusRowNumber(account.id) + .indexOfFirst { it == key }.takeIf { it != -1 } } + + Timber.d("initialKey: %s is row: %d", initialKey, row) + + return Pager( + config = PagingConfig( + pageSize = pageSize, + jumpThreshold = PAGE_SIZE * 3, + enablePlaceholders = true, + ), + initialKey = row, + remoteMediator = CachedTimelineRemoteMediator( + initialKey, + mastodonApi, + account.id, + factory!!, + transactionProvider, + timelineDao, + remoteKeyDao, + moshi, + ), + pagingSourceFactory = factory!!, + ).flow } /** Invalidate the active paging source, see [androidx.paging.PagingSource.invalidate] */ diff --git a/app/src/main/java/app/pachli/components/timeline/NetworkTimelineRepository.kt b/app/src/main/java/app/pachli/components/timeline/NetworkTimelineRepository.kt index f86f3ebc4..c1aae7f9d 100644 --- a/app/src/main/java/app/pachli/components/timeline/NetworkTimelineRepository.kt +++ b/app/src/main/java/app/pachli/components/timeline/NetworkTimelineRepository.kt @@ -26,13 +26,12 @@ import androidx.paging.PagingSource import app.pachli.components.timeline.viewmodel.NetworkTimelinePagingSource import app.pachli.components.timeline.viewmodel.NetworkTimelineRemoteMediator import app.pachli.components.timeline.viewmodel.PageCache -import app.pachli.core.data.repository.AccountManager +import app.pachli.core.database.model.AccountEntity import app.pachli.core.model.Timeline import app.pachli.core.network.model.Status import app.pachli.core.network.retrofit.MastodonApi import app.pachli.core.ui.getDomain import javax.inject.Inject -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import timber.log.Timber @@ -71,16 +70,18 @@ import timber.log.Timber /** Timeline repository where the timeline information is backed by an in-memory cache. */ class NetworkTimelineRepository @Inject constructor( private val mastodonApi: MastodonApi, - private val accountManager: AccountManager, ) { private val pageCache = PageCache() private var factory: InvalidatingPagingSourceFactory? = null + // TODO: This should use assisted injection, and inject the account. + private var activeAccount: AccountEntity? = null + /** @return flow of Mastodon [Status], loaded in [pageSize] increments */ @OptIn(ExperimentalPagingApi::class) fun getStatusStream( - viewModelScope: CoroutineScope, + account: AccountEntity, kind: Timeline, pageSize: Int = PAGE_SIZE, initialKey: String? = null, @@ -94,9 +95,8 @@ class NetworkTimelineRepository @Inject constructor( return Pager( config = PagingConfig(pageSize = pageSize), remoteMediator = NetworkTimelineRemoteMediator( - viewModelScope, mastodonApi, - accountManager, + account, factory!!, pageCache, kind, diff --git a/app/src/main/java/app/pachli/components/timeline/TimelineFragment.kt b/app/src/main/java/app/pachli/components/timeline/TimelineFragment.kt index 5dd51132d..e8f197b03 100644 --- a/app/src/main/java/app/pachli/components/timeline/TimelineFragment.kt +++ b/app/src/main/java/app/pachli/components/timeline/TimelineFragment.kt @@ -630,8 +630,8 @@ class TimelineFragment : adapter.refresh() } - override fun onReply(viewData: StatusViewData) { - super.reply(viewData.actionable) + override fun onReply(pachliAccountId: Long, viewData: StatusViewData) { + super.reply(pachliAccountId, viewData.actionable) } override fun onReblog(viewData: StatusViewData, reblog: Boolean) { diff --git a/app/src/main/java/app/pachli/components/timeline/viewmodel/CachedTimelineViewModel.kt b/app/src/main/java/app/pachli/components/timeline/viewmodel/CachedTimelineViewModel.kt index da9054dab..21a1ec29f 100644 --- a/app/src/main/java/app/pachli/components/timeline/viewmodel/CachedTimelineViewModel.kt +++ b/app/src/main/java/app/pachli/components/timeline/viewmodel/CachedTimelineViewModel.kt @@ -17,7 +17,6 @@ package app.pachli.components.timeline.viewmodel -import android.content.Context import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import androidx.paging.PagingData @@ -31,8 +30,8 @@ import app.pachli.appstore.PinEvent import app.pachli.appstore.ReblogEvent import app.pachli.components.timeline.CachedTimelineRepository import app.pachli.core.data.repository.AccountManager -import app.pachli.core.data.repository.ContentFiltersRepository import app.pachli.core.data.repository.StatusDisplayOptionsRepository +import app.pachli.core.database.model.AccountEntity import app.pachli.core.model.FilterAction import app.pachli.core.network.model.Poll import app.pachli.core.preferences.SharedPreferencesRepository @@ -40,7 +39,6 @@ import app.pachli.usecase.TimelineCases import app.pachli.viewdata.StatusViewData import com.squareup.moshi.Moshi import dagger.hilt.android.lifecycle.HiltViewModel -import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow @@ -55,43 +53,39 @@ import timber.log.Timber @OptIn(ExperimentalCoroutinesApi::class) @HiltViewModel class CachedTimelineViewModel @Inject constructor( - @ApplicationContext context: Context, savedStateHandle: SavedStateHandle, private val repository: CachedTimelineRepository, timelineCases: TimelineCases, eventHub: EventHub, - contentFiltersRepository: ContentFiltersRepository, accountManager: AccountManager, statusDisplayOptionsRepository: StatusDisplayOptionsRepository, sharedPreferencesRepository: SharedPreferencesRepository, private val moshi: Moshi, ) : TimelineViewModel( - context, savedStateHandle, timelineCases, eventHub, - contentFiltersRepository, accountManager, statusDisplayOptionsRepository, sharedPreferencesRepository, ) { - override var statuses: Flow> init { readingPositionId = activeAccount.lastVisibleHomeTimelineStatusId - statuses = reload.flatMapLatest { - getStatuses(initialKey = getInitialKey()) + statuses = refreshFlow.flatMapLatest { + getStatuses(it.second, initialKey = getInitialKey()) }.cachedIn(viewModelScope) } - /** @return Flow of statuses that make up the timeline of [timeline] */ + /** @return Flow of statuses that make up the timeline of [timeline] for [account]. */ private fun getStatuses( + account: AccountEntity, initialKey: String? = null, ): Flow> { Timber.d("getStatuses: kind: %s, initialKey: %s", timeline, initialKey) - return repository.getStatusStream(kind = timeline, initialKey = initialKey) + return repository.getStatusStream(account, kind = timeline, initialKey = initialKey) .map { pagingData -> pagingData .map { diff --git a/app/src/main/java/app/pachli/components/timeline/viewmodel/NetworkTimelineRemoteMediator.kt b/app/src/main/java/app/pachli/components/timeline/viewmodel/NetworkTimelineRemoteMediator.kt index b33c0bcdc..110cb137c 100644 --- a/app/src/main/java/app/pachli/components/timeline/viewmodel/NetworkTimelineRemoteMediator.kt +++ b/app/src/main/java/app/pachli/components/timeline/viewmodel/NetworkTimelineRemoteMediator.kt @@ -23,12 +23,11 @@ import androidx.paging.LoadType import androidx.paging.PagingState import androidx.paging.RemoteMediator import app.pachli.BuildConfig -import app.pachli.core.data.repository.AccountManager +import app.pachli.core.database.model.AccountEntity import app.pachli.core.model.Timeline import app.pachli.core.network.model.Status import app.pachli.core.network.retrofit.MastodonApi import java.io.IOException -import kotlinx.coroutines.CoroutineScope import retrofit2.HttpException import retrofit2.Response import timber.log.Timber @@ -36,16 +35,12 @@ import timber.log.Timber /** Remote mediator for accessing timelines that are not backed by the database. */ @OptIn(ExperimentalPagingApi::class) class NetworkTimelineRemoteMediator( - private val viewModelScope: CoroutineScope, private val api: MastodonApi, - accountManager: AccountManager, + private val activeAccount: AccountEntity, private val factory: InvalidatingPagingSourceFactory, private val pageCache: PageCache, private val timeline: Timeline, ) : RemoteMediator() { - - private val activeAccount = accountManager.activeAccount!! - override suspend fun load(loadType: LoadType, state: PagingState): MediatorResult { if (!activeAccount.isLoggedIn()) { return MediatorResult.Success(endOfPaginationReached = true) diff --git a/app/src/main/java/app/pachli/components/timeline/viewmodel/NetworkTimelineViewModel.kt b/app/src/main/java/app/pachli/components/timeline/viewmodel/NetworkTimelineViewModel.kt index 7f3d0fa84..efba50a06 100644 --- a/app/src/main/java/app/pachli/components/timeline/viewmodel/NetworkTimelineViewModel.kt +++ b/app/src/main/java/app/pachli/components/timeline/viewmodel/NetworkTimelineViewModel.kt @@ -17,7 +17,6 @@ package app.pachli.components.timeline.viewmodel -import android.content.Context import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import androidx.paging.PagingData @@ -31,15 +30,14 @@ import app.pachli.appstore.PinEvent import app.pachli.appstore.ReblogEvent import app.pachli.components.timeline.NetworkTimelineRepository import app.pachli.core.data.repository.AccountManager -import app.pachli.core.data.repository.ContentFiltersRepository import app.pachli.core.data.repository.StatusDisplayOptionsRepository +import app.pachli.core.database.model.AccountEntity import app.pachli.core.model.FilterAction import app.pachli.core.network.model.Poll import app.pachli.core.preferences.SharedPreferencesRepository import app.pachli.usecase.TimelineCases import app.pachli.viewdata.StatusViewData import dagger.hilt.android.lifecycle.HiltViewModel -import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow @@ -54,21 +52,17 @@ import timber.log.Timber @OptIn(ExperimentalCoroutinesApi::class) @HiltViewModel class NetworkTimelineViewModel @Inject constructor( - @ApplicationContext context: Context, savedStateHandle: SavedStateHandle, private val repository: NetworkTimelineRepository, timelineCases: TimelineCases, eventHub: EventHub, - contentFiltersRepository: ContentFiltersRepository, accountManager: AccountManager, statusDisplayOptionsRepository: StatusDisplayOptionsRepository, sharedPreferencesRepository: SharedPreferencesRepository, ) : TimelineViewModel( - context, savedStateHandle, timelineCases, eventHub, - contentFiltersRepository, accountManager, statusDisplayOptionsRepository, sharedPreferencesRepository, @@ -78,18 +72,19 @@ class NetworkTimelineViewModel @Inject constructor( override var statuses: Flow> init { - statuses = reload + statuses = refreshFlow .flatMapLatest { - getStatuses(initialKey = getInitialKey()) + getStatuses(it.second, initialKey = getInitialKey()) }.cachedIn(viewModelScope) } - /** @return Flow of statuses that make up the timeline of [timeline] */ + /** @return Flow of statuses that make up the timeline of [timeline] for [account]. */ private fun getStatuses( + account: AccountEntity, initialKey: String? = null, ): Flow> { Timber.d("getStatuses: kind: %s, initialKey: %s", timeline, initialKey) - return repository.getStatusStream(viewModelScope, kind = timeline, initialKey = initialKey) + return repository.getStatusStream(account, kind = timeline, initialKey = initialKey) .map { pagingData -> pagingData.map { modifiedViewData[it.id] ?: StatusViewData.from( diff --git a/app/src/main/java/app/pachli/components/timeline/viewmodel/TimelineViewModel.kt b/app/src/main/java/app/pachli/components/timeline/viewmodel/TimelineViewModel.kt index 14b1cac02..a8f3f40e6 100644 --- a/app/src/main/java/app/pachli/components/timeline/viewmodel/TimelineViewModel.kt +++ b/app/src/main/java/app/pachli/components/timeline/viewmodel/TimelineViewModel.kt @@ -17,7 +17,6 @@ package app.pachli.components.timeline.viewmodel -import android.content.Context import androidx.annotation.CallSuper import androidx.annotation.StringRes import androidx.annotation.VisibleForTesting @@ -43,8 +42,9 @@ import app.pachli.appstore.StatusEditedEvent import app.pachli.appstore.UnfollowEvent import app.pachli.core.common.extensions.throttleFirst import app.pachli.core.data.repository.AccountManager -import app.pachli.core.data.repository.ContentFiltersRepository +import app.pachli.core.data.repository.Loadable import app.pachli.core.data.repository.StatusDisplayOptionsRepository +import app.pachli.core.database.model.AccountEntity import app.pachli.core.model.ContentFilterVersion import app.pachli.core.model.FilterAction import app.pachli.core.model.FilterContext @@ -58,9 +58,6 @@ import app.pachli.network.ContentFilterModel import app.pachli.usecase.TimelineCases import app.pachli.viewdata.StatusViewData import at.connyduck.calladapter.networkresult.getOrThrow -import com.github.michaelbull.result.onFailure -import com.github.michaelbull.result.onSuccess -import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow @@ -68,7 +65,9 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.filterNotNull @@ -270,13 +269,9 @@ sealed interface UiError { } abstract class TimelineViewModel( - // TODO: Context is required because handling filter errors needs to - // format a resource string. As soon as that is removed this can be removed. - @ApplicationContext private val context: Context, savedStateHandle: SavedStateHandle, private val timelineCases: TimelineCases, private val eventHub: EventHub, - private val contentFiltersRepository: ContentFiltersRepository, protected val accountManager: AccountManager, statusDisplayOptionsRepository: StatusDisplayOptionsRepository, private val sharedPreferencesRepository: SharedPreferencesRepository, @@ -323,7 +318,17 @@ abstract class TimelineViewModel( private var filterRemoveReblogs = false private var filterRemoveSelfReblogs = false - protected val activeAccount = accountManager.activeAccount!! + protected val activeAccount: AccountEntity + get() { + return accountManager.activeAccount!! + } + + protected val refreshFlow = reload.combine( + accountManager.activeAccountFlow + .filterIsInstance>() + .filter { it.data != null } + .distinctUntilChangedBy { it.data?.id!! }, + ) { refresh, account -> Pair(refresh, account.data!!) } /** The ID of the status to which the user's reading position should be restored */ // Not part of the UiState as it's only used once in the lifespan of the fragment. @@ -336,21 +341,18 @@ abstract class TimelineViewModel( init { viewModelScope.launch { FilterContext.from(timeline)?.let { filterContext -> - contentFiltersRepository.contentFilters.fold(false) { reload, filters -> - filters.onSuccess { - contentFilterModel = when (it?.version) { + accountManager.activePachliAccountFlow + .distinctUntilChangedBy { it.contentFilters } + .fold(false) { reload, account -> + contentFilterModel = when (account.contentFilters.version) { ContentFilterVersion.V2 -> ContentFilterModel(filterContext) - ContentFilterVersion.V1 -> ContentFilterModel(filterContext, it.contentFilters) - else -> null + ContentFilterVersion.V1 -> ContentFilterModel(filterContext, account.contentFilters.contentFilters) } if (reload) { - reloadKeepingReadingPosition(activeAccount.id) + reloadKeepingReadingPosition(account.id) } - }.onFailure { - _uiErrorChannel.send(UiError.GetFilters(RuntimeException(it.fmt(context)))) + true } - true - } } } @@ -452,9 +454,8 @@ abstract class TimelineViewModel( .filterIsInstance() .distinctUntilChanged() .collectLatest { action -> - Timber.d("Saving Home timeline position at: %s", action.visibleId) - activeAccount.lastVisibleHomeTimelineStatusId = action.visibleId - accountManager.saveAccount(activeAccount) + Timber.d("setLastVisibleHomeTimelineStatusId: %d, %s", activeAccount.id, action.visibleId) + accountManager.setLastVisibleHomeTimelineStatusId(activeAccount.id, action.visibleId) readingPositionId = action.visibleId } } @@ -466,8 +467,7 @@ abstract class TimelineViewModel( .filterIsInstance() .collectLatest { if (timeline == Timeline.Home) { - activeAccount.lastVisibleHomeTimelineStatusId = null - accountManager.saveAccount(activeAccount) + accountManager.setLastVisibleHomeTimelineStatusId(activeAccount.id, null) } Timber.d("Reload because InfallibleUiAction.LoadNewest") reloadFromNewest(activeAccount.id) diff --git a/app/src/main/java/app/pachli/components/trending/TrendingActivity.kt b/app/src/main/java/app/pachli/components/trending/TrendingActivity.kt index 0208e8eee..62e6494df 100644 --- a/app/src/main/java/app/pachli/components/trending/TrendingActivity.kt +++ b/app/src/main/java/app/pachli/components/trending/TrendingActivity.kt @@ -28,7 +28,6 @@ import androidx.lifecycle.lifecycleScope import app.pachli.R import app.pachli.TabViewData import app.pachli.appstore.EventHub -import app.pachli.appstore.MainTabsChangedEvent import app.pachli.core.activity.BottomSheetActivity import app.pachli.core.activity.ReselectableFragment import app.pachli.core.common.extensions.viewBinding @@ -136,9 +135,7 @@ class TrendingActivity : BottomSheetActivity(), AppBarLayoutHost, MenuProvider { val timeline = tabViewData.timeline accountManager.activeAccount?.let { lifecycleScope.launch(Dispatchers.IO) { - it.tabPreferences += timeline - accountManager.saveAccount(it) - eventHub.dispatch(MainTabsChangedEvent(it.tabPreferences)) + accountManager.setTabPreferences(it.id, it.tabPreferences + timeline) } } Toast.makeText(this, getString(R.string.action_add_to_tab_success, tabViewData.title(this)), Toast.LENGTH_LONG).show() diff --git a/app/src/main/java/app/pachli/components/trending/viewmodel/TrendingTagsViewModel.kt b/app/src/main/java/app/pachli/components/trending/viewmodel/TrendingTagsViewModel.kt index af425af32..ddf797ecd 100644 --- a/app/src/main/java/app/pachli/components/trending/viewmodel/TrendingTagsViewModel.kt +++ b/app/src/main/java/app/pachli/components/trending/viewmodel/TrendingTagsViewModel.kt @@ -18,7 +18,7 @@ package app.pachli.components.trending.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import app.pachli.core.data.repository.ContentFiltersRepository +import app.pachli.core.data.repository.AccountManager import app.pachli.core.model.FilterContext import app.pachli.core.network.model.TrendingTag import app.pachli.core.network.model.end @@ -26,19 +26,24 @@ import app.pachli.core.network.model.start import app.pachli.core.network.retrofit.MastodonApi import app.pachli.viewdata.TrendingViewData import at.connyduck.calladapter.networkresult.fold -import com.github.michaelbull.result.get import dagger.hilt.android.lifecycle.HiltViewModel import java.io.IOException import javax.inject.Inject import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.distinctUntilChangedBy +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.launch import timber.log.Timber @HiltViewModel class TrendingTagsViewModel @Inject constructor( private val mastodonApi: MastodonApi, - private val contentFiltersRepository: ContentFiltersRepository, + private val accountManager: AccountManager, ) : ViewModel() { enum class LoadingState { INITIAL, @@ -57,9 +62,18 @@ class TrendingTagsViewModel @Inject constructor( val uiState: Flow get() = _uiState private val _uiState = MutableStateFlow(TrendingTagsUiState(listOf(), LoadingState.INITIAL)) + private val contentFilters = flow { + accountManager.activePachliAccountFlow.filterNotNull() + .distinctUntilChangedBy { it.contentFilters } + .map { it.contentFilters } + .collect(::emit) + } + .shareIn(viewModelScope, SharingStarted.WhileSubscribed(5000), replay = 1) + init { - invalidate() - viewModelScope.launch { contentFiltersRepository.contentFilters.collect { invalidate() } } + viewModelScope.launch { + contentFilters.collect { invalidate() } + } } /** @@ -74,21 +88,22 @@ class TrendingTagsViewModel @Inject constructor( _uiState.value = TrendingTagsUiState(emptyList(), LoadingState.LOADING) } + val contentFilters = contentFilters.replayCache.last() + mastodonApi.trendingTags(limit = LIMIT_TRENDING_HASHTAGS).fold( { tagResponse -> - val firstTag = tagResponse.firstOrNull() _uiState.value = if (firstTag == null) { TrendingTagsUiState(emptyList(), LoadingState.LOADED) } else { - val homeFilters = contentFiltersRepository.contentFilters.value.get()?.contentFilters?.filter { filter -> + val homeFilters = contentFilters.contentFilters.filter { filter -> filter.contexts.contains(FilterContext.HOME) } val tags = tagResponse .filter { tag -> - homeFilters?.none { filter -> + homeFilters.none { filter -> filter.keywords.any { keyword -> keyword.keyword.equals(tag.name, ignoreCase = true) } - } ?: false + } } .sortedByDescending { tag -> tag.history.sumOf { it.uses.toLongOrNull() ?: 0 } } .toTrendingViewDataTag() diff --git a/app/src/main/java/app/pachli/components/viewthread/ViewThreadFragment.kt b/app/src/main/java/app/pachli/components/viewthread/ViewThreadFragment.kt index db4003dec..5cae8f3b8 100644 --- a/app/src/main/java/app/pachli/components/viewthread/ViewThreadFragment.kt +++ b/app/src/main/java/app/pachli/components/viewthread/ViewThreadFragment.kt @@ -279,8 +279,8 @@ class ViewThreadFragment : viewModel.refresh(thisThreadsStatusId) } - override fun onReply(viewData: StatusViewData) { - super.reply(viewData.actionable) + override fun onReply(pachliAccountId: Long, viewData: StatusViewData) { + super.reply(pachliAccountId, viewData.actionable) } override fun onReblog(viewData: StatusViewData, reblog: Boolean) { diff --git a/app/src/main/java/app/pachli/components/viewthread/ViewThreadViewModel.kt b/app/src/main/java/app/pachli/components/viewthread/ViewThreadViewModel.kt index 84560dcfb..65b3843aa 100644 --- a/app/src/main/java/app/pachli/components/viewthread/ViewThreadViewModel.kt +++ b/app/src/main/java/app/pachli/components/viewthread/ViewThreadViewModel.kt @@ -30,7 +30,7 @@ import app.pachli.appstore.StatusEditedEvent import app.pachli.components.timeline.CachedTimelineRepository import app.pachli.components.timeline.util.ifExpected import app.pachli.core.data.repository.AccountManager -import app.pachli.core.data.repository.ContentFiltersRepository +import app.pachli.core.data.repository.Loadable import app.pachli.core.data.repository.StatusDisplayOptionsRepository import app.pachli.core.database.dao.TimelineDao import app.pachli.core.database.model.AccountEntity @@ -48,8 +48,6 @@ import app.pachli.viewdata.StatusViewData import at.connyduck.calladapter.networkresult.fold import at.connyduck.calladapter.networkresult.getOrElse import at.connyduck.calladapter.networkresult.getOrThrow -import com.github.michaelbull.result.onFailure -import com.github.michaelbull.result.onSuccess import com.squareup.moshi.Moshi import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject @@ -59,6 +57,10 @@ import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.distinctUntilChangedBy +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import retrofit2.HttpException @@ -69,14 +71,12 @@ class ViewThreadViewModel @Inject constructor( private val api: MastodonApi, private val timelineCases: TimelineCases, eventHub: EventHub, - accountManager: AccountManager, + private val accountManager: AccountManager, private val timelineDao: TimelineDao, private val moshi: Moshi, private val repository: CachedTimelineRepository, statusDisplayOptionsRepository: StatusDisplayOptionsRepository, - private val contentFiltersRepository: ContentFiltersRepository, ) : ViewModel() { - private val _uiState: MutableStateFlow = MutableStateFlow(ThreadUiState.Loading) val uiState: Flow get() = _uiState @@ -89,9 +89,10 @@ class ViewThreadViewModel @Inject constructor( val statusDisplayOptions = statusDisplayOptionsRepository.flow - val activeAccount: AccountEntity = accountManager.activeAccount!! - private val alwaysShowSensitiveMedia: Boolean = activeAccount.alwaysShowSensitiveMedia - private val alwaysOpenSpoiler: Boolean = activeAccount.alwaysOpenSpoiler + val activeAccount: AccountEntity + get() { + return accountManager.activeAccount!! + } private var contentFilterModel: ContentFilterModel? = null @@ -113,28 +114,27 @@ class ViewThreadViewModel @Inject constructor( } viewModelScope.launch { - contentFiltersRepository.contentFilters.collect { filters -> - filters.onSuccess { - contentFilterModel = when (it?.version) { - ContentFilterVersion.V2 -> ContentFilterModel(FilterContext.THREAD) - ContentFilterVersion.V1 -> ContentFilterModel(FilterContext.THREAD, it.contentFilters) - else -> null + accountManager.activePachliAccountFlow + .distinctUntilChangedBy { it.contentFilters } + .collect { account -> + contentFilterModel = when (account.contentFilters.version) { + ContentFilterVersion.V2 -> ContentFilterModel(FilterContext.NOTIFICATIONS) + ContentFilterVersion.V1 -> ContentFilterModel(FilterContext.NOTIFICATIONS, account.contentFilters.contentFilters) } updateStatuses() } - .onFailure { - // TODO: Deliberately don't emit to _errors here -- at the moment - // ViewThreadFragment shows a generic error to the user, and that - // would confuse them when the rest of the thread is loading OK. - } - } } } fun loadThread(id: String) { - _uiState.value = ThreadUiState.Loading - viewModelScope.launch { + _uiState.value = ThreadUiState.Loading + + val account = accountManager.activeAccountFlow + .filterIsInstance>() + .filter { it.data != null } + .first().data!! + Timber.d("Finding status with: %s", id) val contextCall = async { api.statusContext(id) } val timelineStatusWithAccount = timelineDao.getStatus(id) @@ -150,8 +150,8 @@ class ViewThreadViewModel @Inject constructor( if (status.actionableId == id) { StatusViewData.from( status = status.actionableStatus, - isExpanded = timelineStatusWithAccount.viewData?.expanded ?: alwaysOpenSpoiler, - isShowingContent = timelineStatusWithAccount.viewData?.contentShowing ?: (alwaysShowSensitiveMedia || !status.actionableStatus.sensitive), + isExpanded = timelineStatusWithAccount.viewData?.expanded ?: account.alwaysOpenSpoiler, + isShowingContent = timelineStatusWithAccount.viewData?.contentShowing ?: (account.alwaysShowSensitiveMedia || !status.actionableStatus.sensitive), isCollapsed = timelineStatusWithAccount.viewData?.contentCollapsed ?: true, isDetailed = true, translationState = timelineStatusWithAccount.viewData?.translationState ?: TranslationState.SHOW_ORIGINAL, @@ -161,8 +161,8 @@ class ViewThreadViewModel @Inject constructor( StatusViewData.from( timelineStatusWithAccount, moshi, - isExpanded = alwaysOpenSpoiler, - isShowingContent = (alwaysShowSensitiveMedia || !status.actionableStatus.sensitive), + isExpanded = account.alwaysOpenSpoiler, + isShowingContent = (account.alwaysShowSensitiveMedia || !status.actionableStatus.sensitive), isDetailed = true, translationState = TranslationState.SHOW_ORIGINAL, ) @@ -173,7 +173,7 @@ class ViewThreadViewModel @Inject constructor( _uiState.value = ThreadUiState.Error(exception) return@launch } - StatusViewData.fromStatusAndUiState(result, isDetailed = true) + StatusViewData.fromStatusAndUiState(account, result, isDetailed = true) } _uiState.value = ThreadUiState.LoadingThread( @@ -210,8 +210,8 @@ class ViewThreadViewModel @Inject constructor( val svd = cachedViewData[status.id] StatusViewData.from( status, - isShowingContent = svd?.contentShowing ?: (alwaysShowSensitiveMedia || !status.actionableStatus.sensitive), - isExpanded = svd?.expanded ?: alwaysOpenSpoiler, + isShowingContent = svd?.contentShowing ?: (account.alwaysShowSensitiveMedia || !status.actionableStatus.sensitive), + isExpanded = svd?.expanded ?: account.alwaysOpenSpoiler, isCollapsed = svd?.contentCollapsed ?: true, isDetailed = false, translationState = svd?.translationState ?: TranslationState.SHOW_ORIGINAL, @@ -222,8 +222,8 @@ class ViewThreadViewModel @Inject constructor( val svd = cachedViewData[status.id] StatusViewData.from( status, - isShowingContent = svd?.contentShowing ?: (alwaysShowSensitiveMedia || !status.actionableStatus.sensitive), - isExpanded = svd?.expanded ?: alwaysOpenSpoiler, + isShowingContent = svd?.contentShowing ?: (account.alwaysShowSensitiveMedia || !status.actionableStatus.sensitive), + isExpanded = svd?.expanded ?: account.alwaysOpenSpoiler, isCollapsed = svd?.contentCollapsed ?: true, isDetailed = false, translationState = svd?.translationState ?: TranslationState.SHOW_ORIGINAL, @@ -403,7 +403,7 @@ class ViewThreadViewModel @Inject constructor( 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) + - StatusViewData.fromStatusAndUiState(eventStatus) + + StatusViewData.fromStatusAndUiState(activeAccount, eventStatus) + statuses.subList(repliedIndex + 1, statuses.size) uiState.copy(statusViewData = newStatuses) } else { @@ -417,7 +417,7 @@ class ViewThreadViewModel @Inject constructor( uiState.copy( statusViewData = uiState.statusViewData.map { status -> if (status.actionableId == event.originalId) { - StatusViewData.fromStatusAndUiState(event.status) + StatusViewData.fromStatusAndUiState(activeAccount, event.status) } else { status } @@ -562,12 +562,12 @@ class ViewThreadViewModel @Inject constructor( * Creates a [StatusViewData] from `status`, copying over the viewdata state from the same * status in _uiState (if that status exists). */ - private fun StatusViewData.Companion.fromStatusAndUiState(status: Status, isDetailed: Boolean = false): StatusViewData { + private fun StatusViewData.Companion.fromStatusAndUiState(account: AccountEntity, status: Status, isDetailed: Boolean = false): StatusViewData { val oldStatus = (_uiState.value as? ThreadUiState.Success)?.statusViewData?.find { it.id == status.id } return from( status, - isShowingContent = oldStatus?.isShowingContent ?: (alwaysShowSensitiveMedia || !status.actionableStatus.sensitive), - isExpanded = oldStatus?.isExpanded ?: alwaysOpenSpoiler, + isShowingContent = oldStatus?.isShowingContent ?: (account.alwaysShowSensitiveMedia || !status.actionableStatus.sensitive), + isExpanded = oldStatus?.isExpanded ?: account.alwaysOpenSpoiler, isCollapsed = oldStatus?.isCollapsed ?: !isDetailed, isDetailed = oldStatus?.isDetailed ?: isDetailed, ) diff --git a/app/src/main/java/app/pachli/fragment/SFragment.kt b/app/src/main/java/app/pachli/fragment/SFragment.kt index c0cdf3932..7cce461d7 100644 --- a/app/src/main/java/app/pachli/fragment/SFragment.kt +++ b/app/src/main/java/app/pachli/fragment/SFragment.kt @@ -170,7 +170,7 @@ abstract class SFragment : Fragment(), StatusActionListener bottomSheetActivity.viewUrl(pachliAccountId, url, PostLookupFallbackBehavior.OPEN_IN_BROWSER) } - protected fun reply(status: Status) { + protected fun reply(pachliAccountId: Long, status: Status) { val actionableStatus = status.actionableStatus val account = actionableStatus.account var loggedInUsername: String? = null @@ -193,7 +193,7 @@ abstract class SFragment : Fragment(), StatusActionListener kind = ComposeOptions.ComposeKind.NEW, ) - val intent = ComposeActivityIntent(requireContext(), composeOptions) + val intent = ComposeActivityIntent(requireContext(), pachliAccountId, composeOptions) requireActivity().startActivity(intent) } @@ -489,7 +489,7 @@ abstract class SFragment : Fragment(), StatusActionListener poll = sourceStatus.poll?.toNewPoll(sourceStatus.createdAt), kind = ComposeOptions.ComposeKind.NEW, ) - startActivity(ComposeActivityIntent(requireContext(), composeOptions)) + startActivity(ComposeActivityIntent(requireContext(), pachliAccountId, composeOptions)) }, { error: Throwable? -> Timber.w(error, "error deleting status") @@ -519,7 +519,7 @@ abstract class SFragment : Fragment(), StatusActionListener poll = status.poll?.toNewPoll(status.createdAt), kind = ComposeOptions.ComposeKind.EDIT_POSTED, ) - startActivity(ComposeActivityIntent(requireContext(), composeOptions)) + startActivity(ComposeActivityIntent(requireContext(), pachliAccountId, composeOptions)) }, { Snackbar.make( diff --git a/app/src/main/java/app/pachli/interfaces/StatusActionListener.kt b/app/src/main/java/app/pachli/interfaces/StatusActionListener.kt index 48926bf30..a19326c4c 100644 --- a/app/src/main/java/app/pachli/interfaces/StatusActionListener.kt +++ b/app/src/main/java/app/pachli/interfaces/StatusActionListener.kt @@ -24,7 +24,7 @@ import app.pachli.core.ui.LinkListener import app.pachli.viewdata.IStatusViewData interface StatusActionListener : LinkListener { - fun onReply(viewData: T) + fun onReply(pachliAccountId: Long, viewData: T) fun onReblog(viewData: T, reblog: Boolean) fun onFavourite(viewData: T, favourite: Boolean) fun onBookmark(viewData: T, bookmark: Boolean) diff --git a/app/src/main/java/app/pachli/receiver/SendStatusBroadcastReceiver.kt b/app/src/main/java/app/pachli/receiver/SendStatusBroadcastReceiver.kt index 5b08c17c0..8d2b8e82a 100644 --- a/app/src/main/java/app/pachli/receiver/SendStatusBroadcastReceiver.kt +++ b/app/src/main/java/app/pachli/receiver/SendStatusBroadcastReceiver.kt @@ -54,6 +54,7 @@ class SendStatusBroadcastReceiver : BroadcastReceiver() { lateinit var accountManager: AccountManager override fun onReceive(context: Context, intent: Intent) { + // The user has used the "quick reply" feature on a notification. if (intent.action == REPLY_ACTION) { val notificationId = intent.getIntExtra(KEY_NOTIFICATION_ID, -1) val senderId = intent.getLongExtra(KEY_SENDER_ACCOUNT_ID, -1) diff --git a/app/src/main/java/app/pachli/service/PachliTileService.kt b/app/src/main/java/app/pachli/service/PachliTileService.kt index ac6101bb4..ee7b535dc 100644 --- a/app/src/main/java/app/pachli/service/PachliTileService.kt +++ b/app/src/main/java/app/pachli/service/PachliTileService.kt @@ -20,7 +20,6 @@ import android.annotation.TargetApi import android.app.PendingIntent import android.os.Build import android.service.quicksettings.TileService -import app.pachli.core.navigation.ComposeActivityIntent.ComposeOptions import app.pachli.core.navigation.MainActivityIntent /** @@ -30,8 +29,7 @@ import app.pachli.core.navigation.MainActivityIntent @TargetApi(24) class PachliTileService : TileService() { override fun onClick() { - // XXX: -1L here needs handling properly. - val intent = MainActivityIntent.openCompose(this, ComposeOptions(), -1L) + val intent = MainActivityIntent.fromQuickTile(this) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { val pendingIntent = PendingIntent.getActivity(this, 0, intent, PendingIntent.FLAG_IMMUTABLE) startActivityAndCollapse(pendingIntent) diff --git a/app/src/main/java/app/pachli/service/SendStatusService.kt b/app/src/main/java/app/pachli/service/SendStatusService.kt index b0b9ab118..1cf2c8b82 100644 --- a/app/src/main/java/app/pachli/service/SendStatusService.kt +++ b/app/src/main/java/app/pachli/service/SendStatusService.kt @@ -223,14 +223,14 @@ class SendStatusService : Service() { val sendResult = if (isNew) { if (newStatus.scheduledAt == null) { mastodonApi.createStatus( - "Bearer " + account.accessToken, + account.authHeader, account.domain, statusToSend.idempotencyKey, newStatus, ) } else { mastodonApi.createScheduledStatus( - "Bearer " + account.accessToken, + account.authHeader, account.domain, statusToSend.idempotencyKey, newStatus, @@ -239,7 +239,7 @@ class SendStatusService : Service() { } else { mastodonApi.editStatus( statusToSend.statusId!!, - "Bearer " + account.accessToken, + account.authHeader, account.domain, statusToSend.idempotencyKey, newStatus, @@ -402,10 +402,10 @@ class SendStatusService : Service() { private fun buildDraftNotification( @StringRes title: Int, @StringRes content: Int, - accountId: Long, + pachliAccountId: Long, statusId: Int, ): Notification { - val intent = MainActivityIntent.openDrafts(this, accountId) + val intent = MainActivityIntent.fromDraftsNotification(this, pachliAccountId) val pendingIntent = PendingIntent.getActivity( this, diff --git a/app/src/main/java/app/pachli/usecase/LogoutUseCase.kt b/app/src/main/java/app/pachli/usecase/LogoutUseCase.kt index 30cb8c246..1d4210f97 100644 --- a/app/src/main/java/app/pachli/usecase/LogoutUseCase.kt +++ b/app/src/main/java/app/pachli/usecase/LogoutUseCase.kt @@ -1,19 +1,22 @@ package app.pachli.usecase import android.content.Context +import androidx.core.content.pm.ShortcutManagerCompat import app.pachli.components.drafts.DraftHelper import app.pachli.components.notifications.deleteNotificationChannelsForAccount import app.pachli.components.notifications.disablePushNotificationsForAccount import app.pachli.core.data.repository.AccountManager +import app.pachli.core.data.repository.LogoutError import app.pachli.core.database.dao.ConversationsDao import app.pachli.core.database.dao.RemoteKeyDao import app.pachli.core.database.dao.TimelineDao import app.pachli.core.database.model.AccountEntity import app.pachli.core.network.retrofit.MastodonApi -import app.pachli.util.removeShortcut +import com.github.michaelbull.result.Err +import com.github.michaelbull.result.Result +import com.github.michaelbull.result.onFailure import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject -import timber.log.Timber class LogoutUseCase @Inject constructor( @ApplicationContext private val context: Context, @@ -25,51 +28,39 @@ class LogoutUseCase @Inject constructor( private val draftHelper: DraftHelper, ) { /** - * Logs the current account out and clears all caches associated with it + * Logs the current account out and clears all caches associated with it. The next + * account is automatically made active. * - * @return The [AccountEntity] that should be logged in next, null if there are no - * other accounts to log in to. + * @return [Result] of the [AccountEntity] that is now active, null if there are no + * other accounts to log in to. Or the error that occurred during logout. */ - suspend operator fun invoke(): AccountEntity? { - accountManager.activeAccount?.let { activeAccount -> + suspend operator fun invoke(account: AccountEntity): Result { + disablePushNotificationsForAccount(context, api, accountManager, account) - // invalidate the oauth token, if we have the client id & secret - // (could be missing if user logged in with a previous version of the app) - val clientId = activeAccount.clientId - val clientSecret = activeAccount.clientSecret - if (clientId != null && clientSecret != null) { - try { - api.revokeOAuthToken( - clientId = clientId, - clientSecret = clientSecret, - token = activeAccount.accessToken, - ) - } catch (e: Exception) { - Timber.e(e, "Could not revoke OAuth token, continuing") - } - } + api.revokeOAuthToken( + clientId = account.clientId, + clientSecret = account.clientSecret, + token = account.accessToken, + ) + .onFailure { return Err(LogoutError.Api(it)) } - // disable push notifications - disablePushNotificationsForAccount(context, api, accountManager, activeAccount) + // clear notification channels + deleteNotificationChannelsForAccount(account, context) - // clear notification channels - deleteNotificationChannelsForAccount(activeAccount, context) + val nextAccount = accountManager.logActiveAccountOut() + .onFailure { return Err(it) } - // remove account from local AccountManager - val nextAccount = accountManager.logActiveAccountOut() + // Clear the database. + // TODO: This should be handled with foreign key constraints. + timelineDao.removeAll(account.id) + timelineDao.removeAllStatusViewData(account.id) + remoteKeyDao.delete(account.id) + conversationsDao.deleteForAccount(account.id) + draftHelper.deleteAllDraftsAndAttachmentsForAccount(account.id) - // clear the database - this could trigger network calls so do it last when all tokens are gone - timelineDao.removeAll(activeAccount.id) - timelineDao.removeAllStatusViewData(activeAccount.id) - remoteKeyDao.delete(activeAccount.id) - conversationsDao.deleteForAccount(activeAccount.id) - draftHelper.deleteAllDraftsAndAttachmentsForAccount(activeAccount.id) + // remove shortcut associated with the account + ShortcutManagerCompat.removeDynamicShortcuts(context, listOf(account.id.toString())) - // remove shortcut associated with the account - removeShortcut(context, activeAccount) - - return nextAccount - } - return null + return nextAccount } } diff --git a/app/src/main/java/app/pachli/util/ListStatusAccessibilityDelegate.kt b/app/src/main/java/app/pachli/util/ListStatusAccessibilityDelegate.kt index 1be1629cf..0ec9aa6b6 100644 --- a/app/src/main/java/app/pachli/util/ListStatusAccessibilityDelegate.kt +++ b/app/src/main/java/app/pachli/util/ListStatusAccessibilityDelegate.kt @@ -133,7 +133,7 @@ class ListStatusAccessibilityDelegate( when (action) { app.pachli.core.ui.R.id.action_reply -> { interrupt() - statusActionListener.onReply(status) + statusActionListener.onReply(pachliAccountId, status) } app.pachli.core.ui.R.id.action_favourite -> statusActionListener.onFavourite(status, true) app.pachli.core.ui.R.id.action_unfavourite -> statusActionListener.onFavourite(status, false) diff --git a/app/src/main/java/app/pachli/util/ShareShortcutHelper.kt b/app/src/main/java/app/pachli/util/ShareShortcutHelper.kt deleted file mode 100644 index 33180ee67..000000000 --- a/app/src/main/java/app/pachli/util/ShareShortcutHelper.kt +++ /dev/null @@ -1,101 +0,0 @@ -/* Copyright 2019 Tusky Contributors - * - * This file is a part of Pachli. - * - * This program is free software; you can redistribute it and/or modify it under the terms of the - * GNU General Public License as published by the Free Software Foundation; either version 3 of the - * License, or (at your option) any later version. - * - * Pachli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even - * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General - * Public License for more details. - * - * You should have received a copy of the GNU General Public License along with Pachli; if not, - * see . - */ - -package app.pachli.util - -import android.content.Context -import android.content.Intent -import android.graphics.Bitmap -import android.graphics.Canvas -import android.text.TextUtils -import androidx.appcompat.content.res.AppCompatResources -import androidx.core.app.Person -import androidx.core.content.pm.ShortcutInfoCompat -import androidx.core.content.pm.ShortcutManagerCompat -import androidx.core.graphics.drawable.IconCompat -import app.pachli.core.data.repository.AccountManager -import app.pachli.core.database.model.AccountEntity -import app.pachli.core.designsystem.R as DR -import app.pachli.core.navigation.MainActivityIntent -import com.bumptech.glide.Glide -import java.util.concurrent.ExecutionException -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext - -suspend fun updateShortcuts(context: Context, accountManager: AccountManager) = withContext(Dispatchers.IO) { - val innerSize = context.resources.getDimensionPixelSize(DR.dimen.adaptive_bitmap_inner_size) - val outerSize = context.resources.getDimensionPixelSize(DR.dimen.adaptive_bitmap_outer_size) - - val maxShortcuts = ShortcutManagerCompat.getMaxShortcutCountPerActivity(context) - - val shortcuts = accountManager.getAllAccountsOrderedByActive().take(maxShortcuts).mapNotNull { account -> - val drawable = try { - if (TextUtils.isEmpty(account.profilePictureUrl)) { - AppCompatResources.getDrawable(context, DR.drawable.avatar_default) - } else { - Glide.with(context) - .asDrawable() - .load(account.profilePictureUrl) - .error(DR.drawable.avatar_default) - .submit(innerSize, innerSize) - .get() - } - } catch (e: ExecutionException) { - // The `.error` handler isn't always used. For example, Glide throws - // ExecutionException if the URL does not point at an image. Fallback to - // the default avatar (https://github.com/bumptech/glide/issues/4672). - AppCompatResources.getDrawable(context, DR.drawable.avatar_default) - } ?: return@mapNotNull null - - // inset the loaded bitmap inside a 108dp transparent canvas so it looks good as adaptive icon - val outBmp = Bitmap.createBitmap(outerSize, outerSize, Bitmap.Config.ARGB_8888) - - val canvas = Canvas(outBmp) - val border = (outerSize - innerSize) / 2 - drawable.setBounds(border, border, border + innerSize, border + innerSize) - drawable.draw(canvas) - - val icon = IconCompat.createWithAdaptiveBitmap(outBmp) - - val person = Person.Builder() - .setIcon(icon) - .setName(account.displayName) - .setKey(account.identifier) - .build() - - // This intent will be sent when the user clicks on one of the launcher shortcuts. Intent from share sheet will be different - val intent = MainActivityIntent(context, account.id).apply { - action = Intent.ACTION_SEND - type = "text/plain" - putExtra(ShortcutManagerCompat.EXTRA_SHORTCUT_ID, account.id.toString()) - } - - ShortcutInfoCompat.Builder(context, account.id.toString()) - .setIntent(intent) - .setCategories(setOf("app.pachli.Share")) - .setShortLabel(account.displayName) - .setPerson(person) - .setLongLived(true) - .setIcon(icon) - .build() - } - - ShortcutManagerCompat.setDynamicShortcuts(context, shortcuts) -} - -fun removeShortcut(context: Context, account: AccountEntity) { - ShortcutManagerCompat.removeDynamicShortcuts(context, listOf(account.id.toString())) -} diff --git a/app/src/main/java/app/pachli/util/UpdateShortCutsUseCase.kt b/app/src/main/java/app/pachli/util/UpdateShortCutsUseCase.kt new file mode 100644 index 000000000..4dd1d2d2e --- /dev/null +++ b/app/src/main/java/app/pachli/util/UpdateShortCutsUseCase.kt @@ -0,0 +1,108 @@ +/* Copyright 2019 Tusky Contributors + * + * This file is a part of Pachli. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Pachli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Pachli; if not, + * see . + */ + +package app.pachli.util + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Canvas +import android.text.TextUtils +import androidx.appcompat.content.res.AppCompatResources +import androidx.core.app.Person +import androidx.core.content.pm.ShortcutInfoCompat +import androidx.core.content.pm.ShortcutManagerCompat +import androidx.core.graphics.drawable.IconCompat +import app.pachli.core.database.model.AccountEntity +import app.pachli.core.designsystem.R as DR +import app.pachli.core.navigation.MainActivityIntent +import com.bumptech.glide.Glide +import dagger.hilt.android.qualifiers.ApplicationContext +import java.util.concurrent.ExecutionException +import javax.inject.Inject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class UpdateShortCutsUseCase @Inject constructor( + @ApplicationContext val context: Context, +) { + /** + * Updates shortcuts to reflect [accounts]. + * + * The first [N][ShortcutManagerCompat.getMaxShortcutCountPerActivity] accounts + * are converted to shortcuts which launch [app.pachli.MainActivity]. The + * active account is always included. + */ + suspend operator fun invoke(accounts: List) = withContext(Dispatchers.IO) { + val innerSize = context.resources.getDimensionPixelSize(DR.dimen.adaptive_bitmap_inner_size) + val outerSize = context.resources.getDimensionPixelSize(DR.dimen.adaptive_bitmap_outer_size) + + val maxShortcuts = ShortcutManagerCompat.getMaxShortcutCountPerActivity(context) + + val shortcuts = accounts + .sortedBy { it.isActive } + .take(maxShortcuts) + .mapNotNull { account -> + val drawable = try { + if (TextUtils.isEmpty(account.profilePictureUrl)) { + AppCompatResources.getDrawable(context, DR.drawable.avatar_default) + } else { + Glide.with(context) + .asDrawable() + .load(account.profilePictureUrl) + .error(DR.drawable.avatar_default) + .submit(innerSize, innerSize) + .get() + } + } catch (e: ExecutionException) { + // The `.error` handler isn't always used. For example, Glide throws + // ExecutionException if the URL does not point at an image. Fallback to + // the default avatar (https://github.com/bumptech/glide/issues/4672). + AppCompatResources.getDrawable(context, DR.drawable.avatar_default) + } ?: return@mapNotNull null + + // inset the loaded bitmap inside a 108dp transparent canvas so it looks good as adaptive icon + val outBmp = Bitmap.createBitmap(outerSize, outerSize, Bitmap.Config.ARGB_8888) + + val canvas = Canvas(outBmp) + val border = (outerSize - innerSize) / 2 + drawable.setBounds(border, border, border + innerSize, border + innerSize) + drawable.draw(canvas) + + val icon = IconCompat.createWithAdaptiveBitmap(outBmp) + + val person = Person.Builder() + .setIcon(icon) + .setName(account.displayName) + .setKey(account.identifier) + .build() + + // This intent will be sent when the user clicks on one of the launcher shortcuts. + // Intent from share sheet will be different + val intent = MainActivityIntent.fromShortcut(context, account.id) + + ShortcutInfoCompat.Builder(context, account.id.toString()) + .setIntent(intent) + .setCategories(setOf("app.pachli.Share")) + .setShortLabel(account.displayName.ifBlank { account.fullName }) + .setPerson(person) + .setLongLived(true) + .setIcon(icon) + .build() + } + + ShortcutManagerCompat.setDynamicShortcuts(context, shortcuts) + } +} diff --git a/app/src/main/java/app/pachli/viewdata/NotificationViewData.kt b/app/src/main/java/app/pachli/viewdata/NotificationViewData.kt index 9827934dc..cc64787e9 100644 --- a/app/src/main/java/app/pachli/viewdata/NotificationViewData.kt +++ b/app/src/main/java/app/pachli/viewdata/NotificationViewData.kt @@ -34,6 +34,16 @@ import app.pachli.core.network.model.TimelineAccount * about boosting a status, the boosted status is also shown). However, not all * notifications are related to statuses (e.g., a "Someone has followed you" * notification) so `statusViewData` is nullable. + * + * @param type + * @param id + * @param account + * @param statusViewData + * @param report + * @param relationshipSeveranceEvent + * @param isAboutSelf True if this notification relates to something the user + * posted (e.g., it's a boost, favourite, or poll ending), false otherwise + * (e.g., it's a mention). */ data class NotificationViewData( val type: Notification.Type, @@ -42,6 +52,7 @@ data class NotificationViewData( var statusViewData: StatusViewData?, val report: Report?, val relationshipSeveranceEvent: RelationshipSeveranceEvent?, + val isAboutSelf: Boolean, ) : IStatusViewData { companion object { fun from( @@ -50,6 +61,7 @@ data class NotificationViewData( isExpanded: Boolean, isCollapsed: Boolean, filterAction: FilterAction, + isAboutSelf: Boolean, ) = NotificationViewData( notification.type, notification.id, @@ -65,6 +77,7 @@ data class NotificationViewData( }, notification.report, notification.relationshipSeveranceEvent, + isAboutSelf, ) } diff --git a/app/src/main/java/app/pachli/viewmodel/EditProfileViewModel.kt b/app/src/main/java/app/pachli/viewmodel/EditProfileViewModel.kt index 8b15ecb13..d2022039f 100644 --- a/app/src/main/java/app/pachli/viewmodel/EditProfileViewModel.kt +++ b/app/src/main/java/app/pachli/viewmodel/EditProfileViewModel.kt @@ -25,6 +25,7 @@ import androidx.lifecycle.viewModelScope import app.pachli.appstore.EventHub import app.pachli.appstore.ProfileEditedEvent import app.pachli.core.common.string.randomAlphanumericString +import app.pachli.core.data.repository.AccountManager import app.pachli.core.data.repository.InstanceInfoRepository import app.pachli.core.network.model.Account import app.pachli.core.network.model.StringField @@ -34,6 +35,8 @@ import app.pachli.util.Loading import app.pachli.util.Resource import app.pachli.util.Success import at.connyduck.calladapter.networkresult.fold +import com.github.michaelbull.result.onFailure +import com.github.michaelbull.result.onSuccess import dagger.hilt.android.lifecycle.HiltViewModel import java.io.File import javax.inject.Inject @@ -61,6 +64,7 @@ class EditProfileViewModel @Inject constructor( private val mastodonApi: MastodonApi, private val eventHub: EventHub, private val application: Application, + private val accountManager: AccountManager, instanceInfoRepo: InstanceInfoRepository, ) : ViewModel() { @@ -86,13 +90,12 @@ class EditProfileViewModel @Inject constructor( if (profileData.value == null || profileData.value is Error) { profileData.postValue(Loading()) - mastodonApi.accountVerifyCredentials().fold( - { profile -> - apiProfileAccount = profile - profileData.postValue(Success(profile)) - }, - { profileData.postValue(Error()) }, - ) + mastodonApi.accountVerifyCredentials() + .onSuccess { profile -> + apiProfileAccount = profile.body + profileData.postValue(Success(profile.body)) + } + .onFailure { profileData.postValue(Error()) } } } @@ -149,6 +152,7 @@ class EditProfileViewModel @Inject constructor( diff.field4?.second?.toRequestBody(MultipartBody.FORM), ).fold( { newAccountData -> + accountManager.updateAccount(pachliAccountId, newAccountData) saveData.postValue(Success()) eventHub.dispatch(ProfileEditedEvent(newAccountData)) }, diff --git a/app/src/main/java/app/pachli/worker/NotificationWorker.kt b/app/src/main/java/app/pachli/worker/NotificationWorker.kt index d8f9f0ebb..b6ad6b5df 100644 --- a/app/src/main/java/app/pachli/worker/NotificationWorker.kt +++ b/app/src/main/java/app/pachli/worker/NotificationWorker.kt @@ -53,6 +53,8 @@ class NotificationWorker @AssistedInject constructor( companion object { private const val ACCOUNT_ID = "accountId" + + /** Notifications for all accounts should be fetched. */ const val ALL_ACCOUNTS = -1L fun data(accountId: Long) = Data.Builder().putLong(ACCOUNT_ID, accountId).build() diff --git a/app/src/main/res/layout/activity_compose.xml b/app/src/main/res/layout/activity_compose.xml index afad12f19..d5818785a 100644 --- a/app/src/main/res/layout/activity_compose.xml +++ b/app/src/main/res/layout/activity_compose.xml @@ -131,7 +131,8 @@ android:id="@+id/composeContentWarningBar" android:layout_width="match_parent" android:layout_height="wrap_content" - android:orientation="vertical"> + android:orientation="vertical" + android:visibility="gone"> + + - - عائلة الخطوط إشعارات عندما يعمل Pachli (باكلي) في الخلفية إظهار على أي حال - خطأ في تحميل القوائم ترجمة ليس لديك قوائم بعد الملفات التعريفية @@ -625,4 +624,4 @@ خادمك الخاص لا يدعم عوامل التصفية تحميل أحدث المنشورات (%1$s :تم تحديثه) - \ No newline at end of file + diff --git a/app/src/main/res/values-be/strings.xml b/app/src/main/res/values-be/strings.xml index 809012060..5dfdfc45a 100644 --- a/app/src/main/res/values-be/strings.xml +++ b/app/src/main/res/values-be/strings.xml @@ -543,7 +543,6 @@ Відарыс Яшчэ няма спісаў Кіраванне спісамі - Памылка загрузкі спісаў Усяго выкарыстана Усяго ўліковых запісаў %1$d людзей кажуць пра хэштэг %2$s @@ -565,4 +564,4 @@ %s (цэлае слова) Дадаць ключавое слова Змяніць ключавое слова - \ No newline at end of file + diff --git a/app/src/main/res/values-cy/strings.xml b/app/src/main/res/values-cy/strings.xml index 50d3d2b8d..46eb2d318 100644 --- a/app/src/main/res/values-cy/strings.xml +++ b/app/src/main/res/values-cy/strings.xml @@ -599,7 +599,6 @@ Delwedd Nid oes gennych restrau, eto Rheoli rhestrau - Gwall wrth lwytho rhestrau Hwn yw\'ch ffrwd cartref. Mae\'n dangos negeseuon diweddar y cyfrifon rydych yn eu dilyn. \n \nI archwilio cyfrifon gallwch un ai eu darganfod o fewn un o\'r llinellau amser eraill. Er enghraifft, mae llinell amser eich enghraifft chi [iconics gmd_group]. Neu gallwch eu chwilio yn ôl eu henw [iconics gmd_search]; er enghraifft, chwilio am Pachli i ganfod ein cyfrif Mastodon. Dangos ystadegau negeseuon mewn llinell amser Maint testun rhyngwyneb @@ -615,4 +614,4 @@ Methodd chwarae: %s Dileu Dileu\'r hidlydd \'%1$s\'\? - \ No newline at end of file + diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 68d9298d4..8a4f9fb1b 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -357,7 +357,6 @@ Liste auswählen Du hast noch keine Listen Listen verwalten - Fehler beim Laden der Listen Liste Du hast keine Entwürfe. Du hast keine geplanten Beiträge. @@ -602,4 +601,4 @@ %1$s %2$d %1$s %2$s (Aktualisiert: %1$s) - \ No newline at end of file + diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index a861784d7..15a34255c 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -545,7 +545,6 @@ Esta es tu cronología de inicio. Muestra las publicaciones recientes de las cuentas que sigues. \n \nPara encontrar cuentas, puedes mirar en alguna de las otras cronologías; por ejemplo, la cronología local de tu instancia [iconics gmd_group]. O puedes buscarlas por nombre [iconics gmd_search]; por ejemplo, busca Pachli para encontrar nuestra cuenta de Mastodon. Fallo al añadir a marcadores: %1$s Gestionar listas - Error al cargar listas Fallo al favoritear publicación: %1$s Fallo al limpiar notificaciones: %s Fallo al aceptar solicitud de seguimiento: %s @@ -635,8 +634,6 @@ Notificaciones de relaciones rotas Mostrar votos Gestionar listas - Listas - cargando… - Listas - falló en cargar Por lo menos se necesita una palabra clave o frase Por lo menos se necesita un contexto para el filtro Se necesita el título @@ -789,4 +786,4 @@ Ningún participante más Revisar idioma de la publicación antes de publicar Publicar como está (%1$s) y no volver a preguntar - \ No newline at end of file + diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml index 269f8fece..7ed48dbe9 100644 --- a/app/src/main/res/values-fa/strings.xml +++ b/app/src/main/res/values-fa/strings.xml @@ -557,7 +557,6 @@ تصویر هنوز هیچ سیاهه‌ای ندارید مدیریت سیاهه‌ها - خطا در بار کردن سیاهه‌ها بار کردن جدیدترین آگاهی‌ها حذف پیش‌نویس؟ کارسازتان می‌داند که این فرسته ویرایش شده؛ ولی رونوشتی از ویرایش‌ها ندارد. پس نمی‌توانند نشانتان داده شوند. @@ -571,4 +570,4 @@ پخش شکست خورد: %s حذف «%1$s» حذف پالایهٔ ؟ - \ No newline at end of file + diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml index a2b0cf372..5db9b42c8 100644 --- a/app/src/main/res/values-fi/strings.xml +++ b/app/src/main/res/values-fi/strings.xml @@ -568,7 +568,6 @@ Valitse lista Loputon Haku epäonnistui - Listojen lataaminen epäonnistui Raportti käyttäjästä @%s lähetetty Kirjoita julkaisu @@ -601,8 +600,6 @@ Seuraava ajoitettu tarkistus: %1$s Ei päivityksiä tarjolla Lataaminen epäonnistui %1$s: %2$d%3$s - Listat - ladataan… - Listat - lataaminen epäonnistui Hallinnoi listoja Näytä äänet Katkaistut yhteydet @@ -774,4 +771,4 @@ Tarkasta julkaisun kieli ennen julkaisemista Kopioi kohde Teksti kopioitu - \ No newline at end of file + diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 972c98aed..ef8b8ed32 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -548,7 +548,6 @@ Modifié Vous n\'avez pas encore de liste Gérer les listes - Erreur de chargement des listes Règle enfreinte Le texte d\'origine du statut n\'a pas pu être chargé. Échec du nettoyage des notifications : %s @@ -606,4 +605,4 @@ Récupération des notifications … Maintenance du cache … %1$s %2$s - \ No newline at end of file + diff --git a/app/src/main/res/values-gd/strings.xml b/app/src/main/res/values-gd/strings.xml index 02f369c87..40402edc2 100644 --- a/app/src/main/res/values-gd/strings.xml +++ b/app/src/main/res/values-gd/strings.xml @@ -551,7 +551,6 @@ Chaidh iarrtas leantainn a bhacadh Stiùirich na liostaichean Chan eil liosta agad fhathast - Mearachd a’ luchdadh nan liostaichean Seall e co-dhiù Criathraichte: <b>%1$s</b> Pròifilean @@ -583,4 +582,4 @@ Brathan nuair a bhios Pachli ag obair sa chùlaibh A’ faighinn nam brathan… Obair-ghlèidhidh air an tasgadan… - \ No newline at end of file + diff --git a/app/src/main/res/values-gl/strings.xml b/app/src/main/res/values-gl/strings.xml index d9002450d..9bd711e2a 100644 --- a/app/src/main/res/values-gl/strings.xml +++ b/app/src/main/res/values-gl/strings.xml @@ -550,7 +550,6 @@ Editar palabra Aínda non tes listas Xestionar listas - Erro ao cargar as listas O teu servidor sabe que a publicación foi editada, pero non ten unha copia das edición, polo que non pode mostrarchas. \n \nÉ un problema coñecido en Mastodon. @@ -587,8 +586,6 @@ Cancelos Ligazóns Publicacións - Listas - cargando… - Listas - fallou a carga Xestionar listas (Actualizada: %1$s) O servidor non é compatible cos filtros @@ -768,4 +765,4 @@ ✔ hai %1$s @ %2$s ✖ hai %1$s @ %2$s A conta non ten o método «push». Pechar a sesión e volver acceder podería arranxalo. - \ No newline at end of file + diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index 4b5833830..e9abd7f87 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -557,7 +557,6 @@ Kép Még nincsenek listáid Listák kezelése - Hiba a listák betöltése során UI betűméret Háttértevékenység Értesítések, amikor a Pachli a háttérben működik @@ -568,4 +567,4 @@ A kiszolgálód tudja, hogy ezt a bejegyzést szerkesztették, de erről nincs másolata, így ezt nem tudjuk neked megmutatni. \n \nEz egy Mastodon hiba #25398. - \ No newline at end of file + diff --git a/app/src/main/res/values-is/strings.xml b/app/src/main/res/values-is/strings.xml index 6d3f4a8dc..ff30cf9c9 100644 --- a/app/src/main/res/values-is/strings.xml +++ b/app/src/main/res/values-is/strings.xml @@ -549,7 +549,6 @@ Mynd Þú ert ekki með neina lista Sýsla með lista - Villa við að hlaða inn listum Þetta er tímalínan þín. Hún sýnir nýlegar færslur þeirra sem þú fylgist með. \n \nTil að skoða hvað aðrir eru að gera getur þú til dæmis uppgötvað viðkomandi í einni af hinum tímalínunum. Til dæmis á staðværu tímalínu netþjónsins þíns [iconics gmd_group]. Eða að þú leitar að þeim eftir nafni [iconics gmd_search]; til dæmis geturðu leitað að Pachli til að finna Mastodon-aðganginn okkar. Textastærð viðmóts Bakgrunnsvirkni @@ -561,4 +560,4 @@ Þjónninn þinn veit að þessari færslu hefur verið breytt, en er hins vegar ekki með afrit af breytingunum, þannig að ekki er hægt að sýna þér þær. \n \nÞetta er Mastodon verkbeiðni #25398. - \ No newline at end of file + diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index da6735a0a..a13f9b01a 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -573,7 +573,6 @@ Richiesta di follow bloccata Non hai ancora alcuna lista Gestisci liste - Errore nel caricamento delle liste Mostra comunque Filtrato: <b>%1$s</b> Profili @@ -610,4 +609,4 @@ Post Una volta per versione Un aggiornamento è disponibile - \ No newline at end of file + diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index fc7c9333e..af22a1363 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -550,7 +550,6 @@ %s: %s 最新の通知を読み込む 下書きを削除しますか? - リストを読み込む際のエラー あなたのサーバーは、この投稿が変更されたことを把握していますが、編集履歴のコピーを備えていないので、表示できません。 \n \nこれはMastodonのissue #25398です。 @@ -594,4 +593,4 @@ 翻訳 アップデート可能です 翻訳を元に戻す - \ No newline at end of file + diff --git a/app/src/main/res/values-nb-rNO/strings.xml b/app/src/main/res/values-nb-rNO/strings.xml index 52b3fe0fd..961f0564a 100644 --- a/app/src/main/res/values-nb-rNO/strings.xml +++ b/app/src/main/res/values-nb-rNO/strings.xml @@ -530,7 +530,6 @@ Fortsett å redigere Du har ulagrede endringer. Du har ingen lister, enda - Feil under lading av lister Forvalte lister Deling av innlegget feilet: %1$s Favorisering av innlegg feilet: %1$s @@ -572,14 +571,12 @@ Håndter faner Foreslåtte kontoer #%s skjult - Lister - kunne ikke lastes inn Håndter lister %1$s %2$s Trendende lenker Emneknagger Lenker En moderator suspenderte instansen - Lister - laster inn… Vis stemminger Kontroller språket til innlegget Språket til innlegget er %1$s men det kan skje at du har skrevet innlegget på %2$s. @@ -770,4 +767,4 @@ Alle innlegg Dine egne innlegg, fremhevinger, favoritmerker, bokmerker, og innlegg som @nevner deg Fødererte innlegg - \ No newline at end of file + diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 43cb72659..d88814fc0 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -522,7 +522,6 @@ Onbekend Wissen meldingen mislukt: %s Volgverzoek geaccepteerd - Fout bij laden lijsten Je hebt nog geen lijsten Lijsten beheren Profielen @@ -607,4 +606,4 @@ Er zijn geen updates beschikbaar Volgende geplande controle: %1$s Je server ondersteund geen filters - \ No newline at end of file + diff --git a/app/src/main/res/values-oc/strings.xml b/app/src/main/res/values-oc/strings.xml index 34d1d79d6..cf787dbfb 100644 --- a/app/src/main/res/values-oc/strings.xml +++ b/app/src/main/res/values-oc/strings.xml @@ -550,7 +550,6 @@ Imatge Avètz pas encara de lista Gerir las listas - Error en cargant las litas Fracàs de la mes en favorit : %1$s Fracàs en partejant : %1$s Fracàs del vòt : %1$s @@ -568,4 +567,4 @@ Notificacions quand Tuska s’executa en rèireplan Recuperacion de las notificacions… Manteniment del cache… - \ No newline at end of file + diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index a6c82c47e..d764d5228 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -587,7 +587,6 @@ Notificações quando Toots com os quais interagiu são editados Notificações quando Pachli está trabalhando em segundo plano Mostrar mesmo assim - Erro ao carregar as listas Falha ao aceitar a solicitação de seguir: %s Pedido para seguir bloqueado %s se inscreveu @@ -613,4 +612,4 @@ Carregar notificações mais recentes Descartar mudanças Tamanho do texto da UI - \ No newline at end of file + diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index 65396d920..8942bd951 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -565,7 +565,6 @@ Att bokmärka inlägg misslyckades: %1$s Rensing av aviseringar misslyckades: %s Att favoritmarkera inlägg misslyckades: %1$s - Fel vid laddning av listor Avacceptera följarförgrågan misslyckades: %s Filtrera sammanhang Populära hashtaggar @@ -610,4 +609,4 @@ Ångra översättning Kunde inte hämta serverinformation för %1$s: %2$s Din server stöder inte filter - \ No newline at end of file + diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 28088e543..a5d659f69 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -533,7 +533,6 @@ Ek kimlik doğrulama yöntemlerini destekleyebilir ancak desteklenen bir tarayıcı gerektirir. Henüz hiç listen yok Listeleri yönet - Listeler yüklenirken hata oluştu Hesaba bağlantı paylaş Hesap adını paylaş Kullanıcı adı kopyalandı @@ -569,4 +568,4 @@ \n \nBu Mastodon sorununu #25398. Oynatma başarısız oldu: %s - \ No newline at end of file + diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 784447f0c..1474eb358 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -570,7 +570,6 @@ Це ваша головна стрічка. Вона показує останні дописи облікових записів, за якими ви стежите. \n \nЩоб переглянути облікові записи, ви можете знайти їх в одній з інших стрічок. Наприклад, на локальній стрічці вашого сервера [iconics gmd_group]. Або ви можете шукати їх за іменами [iconics gmd_search]; наприклад, шукайте Pachli, щоб знайти наш обліковий запис Mastodon. Опис Зображення - Помилка завантаження списків У вас ще немає списків Керувати списками Розмір шрифту інтерфейсу @@ -583,4 +582,4 @@ Ваш сервер знає, що цей допис було змінено, але не має копії редагувань, тому вони не можуть бути вам показані. \n \nЦе помилка #25398 у Mastodon. - \ No newline at end of file + diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index b3bdc8bbc..66d9fc518 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -539,7 +539,6 @@ Mô tả Hình ảnh Quản lý danh sách - Xảy ra lỗi khi tải danh sách Bạn chưa có danh sách Tải những thông báo mới nhất Xóa bản nháp\? @@ -554,4 +553,4 @@ Không thể phát: %s Xóa bộ lọc \'%1$s\'\? Xóa - \ No newline at end of file + diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 2cc9d4647..c91628b9d 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -553,7 +553,6 @@ 图片 描述 管理列表 - 加载列表出错 你还没有列表 加载最新通知 删除草稿? @@ -568,4 +567,4 @@ 播放失败了:%s 删除筛选器\'%1$s\'吗? 删除 - \ No newline at end of file + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1d65488e9..720748622 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -379,8 +379,6 @@ %s (%s) Add Account Add new Mastodon Account - Lists - loading… - Lists - failed to load Manage lists Posting as %1$s @@ -487,7 +485,6 @@ Select list You have no lists, yet Manage lists - Error loading lists List Delete notifications Filter notifications @@ -854,4 +851,7 @@ The upload will be retried when you send the post. If it fails again the post will be saved in your drafts.\n\nThe error was: %1$s Modify attachment + Trying to log in failed with the following error:\n\n%1$s + Refreshing account failed with the following error:\n\n%1$s\n\nYou can continue, but your lists and filters may be incomplete. + Re-login diff --git a/app/src/test/java/app/pachli/MainActivityTest.kt b/app/src/test/java/app/pachli/MainActivityTest.kt index fae624dcb..8112f5832 100644 --- a/app/src/test/java/app/pachli/MainActivityTest.kt +++ b/app/src/test/java/app/pachli/MainActivityTest.kt @@ -39,8 +39,8 @@ import app.pachli.core.network.model.Notification import app.pachli.core.network.model.TimelineAccount import app.pachli.core.network.retrofit.MastodonApi import app.pachli.core.testing.rules.lazyActivityScenarioRule +import app.pachli.core.testing.success import app.pachli.db.DraftsAlert -import at.connyduck.calladapter.networkresult.NetworkResult import dagger.hilt.android.testing.BindValue import dagger.hilt.android.testing.CustomTestApplication import dagger.hilt.android.testing.HiltAndroidRule @@ -48,6 +48,7 @@ import dagger.hilt.android.testing.HiltAndroidTest import java.time.Instant import java.util.Date import javax.inject.Inject +import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull import org.junit.Before @@ -112,22 +113,21 @@ class MainActivityTest { val draftsAlert: DraftsAlert = mock() @Before - fun setup() { + fun setup() = runTest { hilt.inject() reset(mastodonApi) mastodonApi.stub { - onBlocking { accountVerifyCredentials() } doReturn NetworkResult.success(account) - onBlocking { listAnnouncements(false) } doReturn NetworkResult.success(emptyList()) + onBlocking { accountVerifyCredentials() } doReturn success(account) + onBlocking { listAnnouncements(false) } doReturn success(emptyList()) } - accountManager.addAccount( + accountManager.verifyAndAddAccount( accessToken = "token", domain = "domain.example", clientId = "id", clientSecret = "secret", oauthScopes = "scopes", - newAccount = account, ) WorkManagerTestInitHelper.initializeTestWorkManager( @@ -152,7 +152,7 @@ class MainActivityTest { Notification.Type.FOLLOW, ) rule.launch(intent) - rule.getScenario().onActivity { + rule.scenario.onActivity { val currentTab = it.findViewById(R.id.viewPager).currentItem val notificationTab = defaultTabs().indexOfFirst { it is Timeline.Notifications } assertEquals(currentTab, notificationTab) @@ -169,7 +169,7 @@ class MainActivityTest { ) rule.launch(intent) - rule.getScenario().onActivity { + rule.scenario.onActivity { val nextActivity = shadowOf(it).peekNextStartedActivity() assertNotNull(nextActivity) assertEquals( diff --git a/app/src/test/java/app/pachli/components/compose/ComposeActivityTest.kt b/app/src/test/java/app/pachli/components/compose/ComposeActivityTest.kt index 8175d89dd..09b381167 100644 --- a/app/src/test/java/app/pachli/components/compose/ComposeActivityTest.kt +++ b/app/src/test/java/app/pachli/components/compose/ComposeActivityTest.kt @@ -32,9 +32,18 @@ import app.pachli.core.network.model.Account import app.pachli.core.network.model.InstanceConfiguration import app.pachli.core.network.model.InstanceV1 import app.pachli.core.network.model.SearchResult +import app.pachli.core.network.model.nodeinfo.UnvalidatedJrd +import app.pachli.core.network.model.nodeinfo.UnvalidatedNodeInfo import app.pachli.core.network.retrofit.MastodonApi +import app.pachli.core.network.retrofit.NodeInfoApi +import app.pachli.core.testing.failure +import app.pachli.core.testing.rules.MainCoroutineRule import app.pachli.core.testing.rules.lazyActivityScenarioRule +import app.pachli.core.testing.success import at.connyduck.calladapter.networkresult.NetworkResult +import com.github.michaelbull.result.andThen +import com.github.michaelbull.result.get +import com.github.michaelbull.result.onSuccess import dagger.hilt.android.testing.CustomTestApplication import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest @@ -42,6 +51,9 @@ import java.time.Instant import java.util.Date import java.util.Locale import javax.inject.Inject +import kotlin.properties.Delegates +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -71,7 +83,12 @@ class ComposeActivityTest { @get:Rule(order = 0) var hilt = HiltAndroidRule(this) + val dispatcher = StandardTestDispatcher() + @get:Rule(order = 1) + val mainCoroutineRule = MainCoroutineRule(dispatcher) + + @get:Rule(order = 2) var rule = lazyActivityScenarioRule( launchActivity = false, ) @@ -81,67 +98,126 @@ class ComposeActivityTest { @Inject lateinit var mastodonApi: MastodonApi + @Inject + lateinit var nodeInfoApi: NodeInfoApi + @Inject lateinit var accountManager: AccountManager @Inject lateinit var instanceInfoRepository: InstanceInfoRepository + private var pachliAccountId by Delegates.notNull() + + val account = Account( + id = "1", + localUsername = "username", + username = "username@domain.example", + displayName = "Display Name", + createdAt = Date.from(Instant.now()), + note = "", + url = "", + avatar = "", + header = "", + ) + @Before - fun setup() { + fun setup() = runTest { hilt.inject() getInstanceCallback = null reset(mastodonApi) mastodonApi.stub { - onBlocking { getCustomEmojis() } doReturn NetworkResult.success(emptyList()) + onBlocking { accountVerifyCredentials(anyOrNull(), anyOrNull()) } doReturn success(account) + onBlocking { getCustomEmojis() } doReturn success(emptyList()) + onBlocking { getInstanceV2() } doReturn failure() onBlocking { getInstanceV1() } doAnswer { getInstanceCallback?.invoke().let { instance -> if (instance == null) { - NetworkResult.failure(Throwable()) + failure() } else { - NetworkResult.success(instance) + success(instance) } } } onBlocking { search(any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) } doReturn NetworkResult.success( SearchResult(emptyList(), emptyList(), emptyList()), ) + onBlocking { getLists() } doReturn success(emptyList()) + onBlocking { listAnnouncements(any()) } doReturn success(emptyList()) + onBlocking { getContentFiltersV1() } doReturn success(emptyList()) } - accountManager.addAccount( + reset(nodeInfoApi) + nodeInfoApi.stub { + onBlocking { nodeInfoJrd() } doReturn success( + UnvalidatedJrd( + listOf( + UnvalidatedJrd.Link( + "http://nodeinfo.diaspora.software/ns/schema/2.1", + "https://example.com", + ), + ), + ), + ) + onBlocking { nodeInfo(any()) } doReturn success( + UnvalidatedNodeInfo(UnvalidatedNodeInfo.Software("mastodon", "4.2.0")), + ) + } + + pachliAccountId = accountManager.verifyAndAddAccount( accessToken = "token", domain = "domain.example", clientId = "id", clientSecret = "secret", oauthScopes = "scopes", - newAccount = Account( - id = "1", - localUsername = "username", - username = "username@domain.example", - displayName = "Display Name", - createdAt = Date.from(Instant.now()), - note = "", - url = "", - avatar = "", - header = "", - ), ) + .andThen { accountManager.setActiveAccount(it) } + .onSuccess { accountManager.refresh(it) } + .get()!!.id } + /** + * When tests do something like this (lines marked "->") + * + * fun whenBackButtonPressedNotEmpty_notFinish() = runTest { + * rule.launch(intent()) + * -> dispatcher.scheduler.advanceUntilIdle() + * -> accountManager.getPachliAccountFlow(pachliAccountId).first() + * + * rule.scenario.onActivity { + * -> dispatcher.scheduler.advanceUntilIdle() + * insertSomeTextInContent(it) + * clickBack(it) + * assertFalse(it.isFinishing) + * } + * } + * + * it's because there's (currently) no easy way for the test to determine + * that ComposeActivity has finished setting up the UI / loading data from + * AccountManager and is ready to receive input. + * + * TODO: Fix this bug by rewriting ComposeViewModel to drive the UI + * state of ComposeActivity, and waiting for ComposeViewModel to be + * ready in tests. + */ + @Test fun whenCloseButtonPressedAndEmpty_finish() { rule.launch() - rule.getScenario().onActivity { + rule.scenario.onActivity { clickUp(it) assertTrue(it.isFinishing) } } @Test - fun whenCloseButtonPressedNotEmpty_notFinish() { + fun whenCloseButtonPressedNotEmpty_notFinish() = runTest { rule.launch() - rule.getScenario().onActivity { + dispatcher.scheduler.advanceUntilIdle() + accountManager.getPachliAccountFlow(pachliAccountId).first() + rule.scenario.onActivity { + dispatcher.scheduler.advanceUntilIdle() insertSomeTextInContent(it) clickUp(it) assertFalse(it.isFinishing) @@ -150,9 +226,12 @@ class ComposeActivityTest { } @Test - fun whenModifiedInitialState_andCloseButtonPressed_notFinish() { + fun whenModifiedInitialState_andCloseButtonPressed_notFinish() = runTest { rule.launch(intent(ComposeOptions(modifiedInitialState = true))) - rule.getScenario().onActivity { + dispatcher.scheduler.advanceUntilIdle() + accountManager.getPachliAccountFlow(pachliAccountId).first() + rule.scenario.onActivity { + dispatcher.scheduler.advanceUntilIdle() clickUp(it) assertFalse(it.isFinishing) } @@ -161,27 +240,33 @@ class ComposeActivityTest { @Test fun whenBackButtonPressedAndEmpty_finish() { rule.launch() - rule.getScenario().onActivity { + rule.scenario.onActivity { clickBack(it) assertTrue(it.isFinishing) } } @Test - fun whenBackButtonPressedNotEmpty_notFinish() { - rule.launch() - rule.getScenario().onActivity { + fun whenBackButtonPressedNotEmpty_notFinish() = runTest { + rule.launch(intent()) + dispatcher.scheduler.advanceUntilIdle() + accountManager.getPachliAccountFlow(pachliAccountId).first() + + rule.scenario.onActivity { + dispatcher.scheduler.advanceUntilIdle() insertSomeTextInContent(it) clickBack(it) assertFalse(it.isFinishing) - // We would like to check for dialog but Robolectric doesn't work with AppCompat v7 yet } } @Test - fun whenModifiedInitialState_andBackButtonPressed_notFinish() { + fun whenModifiedInitialState_andBackButtonPressed_notFinish() = runTest { rule.launch(intent(ComposeOptions(modifiedInitialState = true))) - rule.getScenario().onActivity { + dispatcher.scheduler.advanceUntilIdle() + accountManager.getPachliAccountFlow(pachliAccountId).first() + rule.scenario.onActivity { + dispatcher.scheduler.advanceUntilIdle() clickBack(it) assertFalse(it.isFinishing) } @@ -191,7 +276,7 @@ class ComposeActivityTest { fun whenMaximumTootCharsIsNull_defaultLimitIsUsed() = runTest { getInstanceCallback = { getInstanceWithCustomConfiguration(null) } rule.launch() - rule.getScenario().onActivity { + rule.scenario.onActivity { assertEquals(DEFAULT_CHARACTER_LIMIT, it.maximumTootCharacters) } } @@ -203,7 +288,10 @@ class ComposeActivityTest { instanceInfoRepository.reload(accountManager.activeAccount) rule.launch() - rule.getScenario().onActivity { + dispatcher.scheduler.advanceUntilIdle() + accountManager.getPachliAccountFlow(pachliAccountId).first() + rule.scenario.onActivity { + dispatcher.scheduler.advanceUntilIdle() assertEquals(customMaximum, it.maximumTootCharacters) } } @@ -215,7 +303,10 @@ class ComposeActivityTest { instanceInfoRepository.reload(accountManager.activeAccount) rule.launch() - rule.getScenario().onActivity { + dispatcher.scheduler.advanceUntilIdle() + accountManager.getPachliAccountFlow(pachliAccountId).first() + rule.scenario.onActivity { + dispatcher.scheduler.advanceUntilIdle() assertEquals(customMaximum, it.maximumTootCharacters) } } @@ -227,7 +318,10 @@ class ComposeActivityTest { instanceInfoRepository.reload(accountManager.activeAccount) rule.launch() - rule.getScenario().onActivity { + dispatcher.scheduler.advanceUntilIdle() + accountManager.getPachliAccountFlow(pachliAccountId).first() + rule.scenario.onActivity { + dispatcher.scheduler.advanceUntilIdle() assertEquals(customMaximum, it.maximumTootCharacters) } } @@ -239,67 +333,88 @@ class ComposeActivityTest { instanceInfoRepository.reload(accountManager.activeAccount) rule.launch() - rule.getScenario().onActivity { + dispatcher.scheduler.advanceUntilIdle() + accountManager.getPachliAccountFlow(pachliAccountId).first() + rule.scenario.onActivity { + dispatcher.scheduler.advanceUntilIdle() assertEquals(customMaximum * 2, it.maximumTootCharacters) } } @Test - fun whenTextContainsNoUrl_everyCharacterIsCounted() { + fun whenTextContainsNoUrl_everyCharacterIsCounted() = runTest { val content = "This is test content please ignore thx " rule.launch() - rule.getScenario().onActivity { + dispatcher.scheduler.advanceUntilIdle() + accountManager.getPachliAccountFlow(pachliAccountId).first() + rule.scenario.onActivity { + dispatcher.scheduler.advanceUntilIdle() insertSomeTextInContent(it, content) assertEquals(content.length, it.viewModel.statusLength.value) } } @Test - fun whenTextContainsEmoji_emojisAreCountedAsOneCharacter() { + fun whenTextContainsEmoji_emojisAreCountedAsOneCharacter() = runTest { val content = "Test 😜" rule.launch() - rule.getScenario().onActivity { + dispatcher.scheduler.advanceUntilIdle() + accountManager.getPachliAccountFlow(pachliAccountId).first() + rule.scenario.onActivity { + dispatcher.scheduler.advanceUntilIdle() insertSomeTextInContent(it, content) assertEquals(6, it.viewModel.statusLength.value) } } @Test - fun whenTextContainsConesecutiveEmoji_emojisAreCountedAsSeparateCharacters() { + fun whenTextContainsConesecutiveEmoji_emojisAreCountedAsSeparateCharacters() = runTest { val content = "Test 😜😜" rule.launch() - rule.getScenario().onActivity { + dispatcher.scheduler.advanceUntilIdle() + accountManager.getPachliAccountFlow(pachliAccountId).first() + rule.scenario.onActivity { + dispatcher.scheduler.advanceUntilIdle() insertSomeTextInContent(it, content) assertEquals(7, it.viewModel.statusLength.value) } } @Test - fun whenTextContainsUrlWithEmoji_ellipsizedUrlIsCountedCorrectly() { + fun whenTextContainsUrlWithEmoji_ellipsizedUrlIsCountedCorrectly() = runTest { val content = "https://🤪.com" rule.launch() - rule.getScenario().onActivity { + dispatcher.scheduler.advanceUntilIdle() + accountManager.getPachliAccountFlow(pachliAccountId).first() + rule.scenario.onActivity { + dispatcher.scheduler.advanceUntilIdle() insertSomeTextInContent(it, content) assertEquals(DEFAULT_CHARACTERS_RESERVED_PER_URL, it.viewModel.statusLength.value) } } @Test - fun whenTextContainsNonEnglishCharacters_lengthIsCountedCorrectly() { + fun whenTextContainsNonEnglishCharacters_lengthIsCountedCorrectly() = runTest { val content = "こんにちは. General Kenobi" // "Hello there. General Kenobi" rule.launch() - rule.getScenario().onActivity { + dispatcher.scheduler.advanceUntilIdle() + accountManager.getPachliAccountFlow(pachliAccountId).first() + rule.scenario.onActivity { + dispatcher.scheduler.advanceUntilIdle() insertSomeTextInContent(it, content) assertEquals(21, it.viewModel.statusLength.value) } } @Test - fun whenTextContainsUrl_onlyEllipsizedURLIsCounted() { + fun whenTextContainsUrl_onlyEllipsizedURLIsCounted() = runTest { val url = "https://www.google.dk/search?biw=1920&bih=990&tbm=isch&sa=1&ei=bmDrWuOoKMv6kwWOkIaoDQ&q=indiana+jones+i+hate+snakes+animated&oq=indiana+jones+i+hate+snakes+animated&gs_l=psy-ab.3...54174.55443.0.55553.9.7.0.0.0.0.255.333.1j0j1.2.0....0...1c.1.64.psy-ab..7.0.0....0.40G-kcDkC6A#imgdii=PSp15hQjN1JqvM:&imgrc=H0hyE2JW5wrpBM%3A" val additionalContent = "Check out this @image #search result: " rule.launch() - rule.getScenario().onActivity { + dispatcher.scheduler.advanceUntilIdle() + accountManager.getPachliAccountFlow(pachliAccountId).first() + rule.scenario.onActivity { + dispatcher.scheduler.advanceUntilIdle() insertSomeTextInContent(it, additionalContent + url) assertEquals( additionalContent.length + DEFAULT_CHARACTERS_RESERVED_PER_URL, @@ -309,12 +424,15 @@ class ComposeActivityTest { } @Test - fun whenTextContainsShortUrls_allUrlsGetEllipsized() { + fun whenTextContainsShortUrls_allUrlsGetEllipsized() = runTest { val shortUrl = "https://pachli.app" val url = "https://www.google.dk/search?biw=1920&bih=990&tbm=isch&sa=1&ei=bmDrWuOoKMv6kwWOkIaoDQ&q=indiana+jones+i+hate+snakes+animated&oq=indiana+jones+i+hate+snakes+animated&gs_l=psy-ab.3...54174.55443.0.55553.9.7.0.0.0.0.255.333.1j0j1.2.0....0...1c.1.64.psy-ab..7.0.0....0.40G-kcDkC6A#imgdii=PSp15hQjN1JqvM:&imgrc=H0hyE2JW5wrpBM%3A" val additionalContent = " Check out this @image #search result: " rule.launch() - rule.getScenario().onActivity { + dispatcher.scheduler.advanceUntilIdle() + accountManager.getPachliAccountFlow(pachliAccountId).first() + rule.scenario.onActivity { + dispatcher.scheduler.advanceUntilIdle() insertSomeTextInContent(it, shortUrl + additionalContent + url) assertEquals( additionalContent.length + (DEFAULT_CHARACTERS_RESERVED_PER_URL * 2), @@ -324,11 +442,14 @@ class ComposeActivityTest { } @Test - fun whenTextContainsMultipleURLs_allURLsGetEllipsized() { + fun whenTextContainsMultipleURLs_allURLsGetEllipsized() = runTest { val url = "https://www.google.dk/search?biw=1920&bih=990&tbm=isch&sa=1&ei=bmDrWuOoKMv6kwWOkIaoDQ&q=indiana+jones+i+hate+snakes+animated&oq=indiana+jones+i+hate+snakes+animated&gs_l=psy-ab.3...54174.55443.0.55553.9.7.0.0.0.0.255.333.1j0j1.2.0....0...1c.1.64.psy-ab..7.0.0....0.40G-kcDkC6A#imgdii=PSp15hQjN1JqvM:&imgrc=H0hyE2JW5wrpBM%3A" val additionalContent = " Check out this @image #search result: " rule.launch() - rule.getScenario().onActivity { + dispatcher.scheduler.advanceUntilIdle() + accountManager.getPachliAccountFlow(pachliAccountId).first() + rule.scenario.onActivity { + dispatcher.scheduler.advanceUntilIdle() insertSomeTextInContent(it, url + additionalContent + url) assertEquals( additionalContent.length + (DEFAULT_CHARACTERS_RESERVED_PER_URL * 2), @@ -346,7 +467,10 @@ class ComposeActivityTest { instanceInfoRepository.reload(accountManager.activeAccount) rule.launch() - rule.getScenario().onActivity { + dispatcher.scheduler.advanceUntilIdle() + accountManager.getPachliAccountFlow(pachliAccountId).first() + rule.scenario.onActivity { + dispatcher.scheduler.advanceUntilIdle() insertSomeTextInContent(it, additionalContent + url) assertEquals( additionalContent.length + customUrlLength, @@ -365,7 +489,10 @@ class ComposeActivityTest { instanceInfoRepository.reload(accountManager.activeAccount) rule.launch() - rule.getScenario().onActivity { + dispatcher.scheduler.advanceUntilIdle() + accountManager.getPachliAccountFlow(pachliAccountId).first() + rule.scenario.onActivity { + dispatcher.scheduler.advanceUntilIdle() insertSomeTextInContent(it, shortUrl + additionalContent + url) assertEquals( additionalContent.length + (customUrlLength * 2), @@ -383,7 +510,10 @@ class ComposeActivityTest { instanceInfoRepository.reload(accountManager.activeAccount) rule.launch() - rule.getScenario().onActivity { + dispatcher.scheduler.advanceUntilIdle() + accountManager.getPachliAccountFlow(pachliAccountId).first() + rule.scenario.onActivity { + dispatcher.scheduler.advanceUntilIdle() insertSomeTextInContent(it, url + additionalContent + url) assertEquals( additionalContent.length + (customUrlLength * 2), @@ -393,9 +523,12 @@ class ComposeActivityTest { } @Test - fun whenSelectionIsEmpty_specialTextIsInsertedAtCaret() { + fun whenSelectionIsEmpty_specialTextIsInsertedAtCaret() = runTest { rule.launch() - rule.getScenario().onActivity { + dispatcher.scheduler.advanceUntilIdle() + accountManager.getPachliAccountFlow(pachliAccountId).first() + rule.scenario.onActivity { + dispatcher.scheduler.advanceUntilIdle() val editor = it.findViewById(R.id.composeEditField) val insertText = "#" editor.setText("Some text") @@ -418,9 +551,12 @@ class ComposeActivityTest { } @Test - fun whenSelectionDoesNotIncludeWordBreak_noSpecialTextIsInserted() { + fun whenSelectionDoesNotIncludeWordBreak_noSpecialTextIsInserted() = runTest { rule.launch() - rule.getScenario().onActivity { + dispatcher.scheduler.advanceUntilIdle() + accountManager.getPachliAccountFlow(pachliAccountId).first() + rule.scenario.onActivity { + dispatcher.scheduler.advanceUntilIdle() val editor = it.findViewById(R.id.composeEditField) val insertText = "#" val originalText = "Some text" @@ -438,9 +574,12 @@ class ComposeActivityTest { } @Test - fun whenSelectionIncludesWordBreaks_startsOfAllWordsArePrepended() { + fun whenSelectionIncludesWordBreaks_startsOfAllWordsArePrepended() = runTest { rule.launch() - rule.getScenario().onActivity { + dispatcher.scheduler.advanceUntilIdle() + accountManager.getPachliAccountFlow(pachliAccountId).first() + rule.scenario.onActivity { + dispatcher.scheduler.advanceUntilIdle() val editor = it.findViewById(R.id.composeEditField) val insertText = "#" val originalText = "one two three four" @@ -461,9 +600,12 @@ class ComposeActivityTest { } @Test - fun whenSelectionIncludesEnd_textIsNotAppended() { + fun whenSelectionIncludesEnd_textIsNotAppended() = runTest { rule.launch() - rule.getScenario().onActivity { + dispatcher.scheduler.advanceUntilIdle() + accountManager.getPachliAccountFlow(pachliAccountId).first() + rule.scenario.onActivity { + dispatcher.scheduler.advanceUntilIdle() val editor = it.findViewById(R.id.composeEditField) val insertText = "#" val originalText = "Some text" @@ -481,9 +623,12 @@ class ComposeActivityTest { } @Test - fun whenSelectionIncludesStartAndStartIsAWord_textIsPrepended() { + fun whenSelectionIncludesStartAndStartIsAWord_textIsPrepended() = runTest { rule.launch() - rule.getScenario().onActivity { + dispatcher.scheduler.advanceUntilIdle() + accountManager.getPachliAccountFlow(pachliAccountId).first() + rule.scenario.onActivity { + dispatcher.scheduler.advanceUntilIdle() val editor = it.findViewById(R.id.composeEditField) val insertText = "#" val originalText = "Some text" @@ -503,9 +648,12 @@ class ComposeActivityTest { } @Test - fun whenSelectionIncludesStartAndStartIsNotAWord_textIsNotPrepended() { + fun whenSelectionIncludesStartAndStartIsNotAWord_textIsNotPrepended() = runTest { rule.launch() - rule.getScenario().onActivity { + dispatcher.scheduler.advanceUntilIdle() + accountManager.getPachliAccountFlow(pachliAccountId).first() + rule.scenario.onActivity { + dispatcher.scheduler.advanceUntilIdle() val editor = it.findViewById(R.id.composeEditField) val insertText = "#" val originalText = " Some text" @@ -523,9 +671,12 @@ class ComposeActivityTest { } @Test - fun whenSelectionBeginsAtWordStart_textIsPrepended() { + fun whenSelectionBeginsAtWordStart_textIsPrepended() = runTest { rule.launch() - rule.getScenario().onActivity { + dispatcher.scheduler.advanceUntilIdle() + accountManager.getPachliAccountFlow(pachliAccountId).first() + rule.scenario.onActivity { + dispatcher.scheduler.advanceUntilIdle() val editor = it.findViewById(R.id.composeEditField) val insertText = "#" val originalText = "Some text" @@ -545,9 +696,12 @@ class ComposeActivityTest { } @Test - fun whenSelectionEndsAtWordStart_textIsAppended() { + fun whenSelectionEndsAtWordStart_textIsAppended() = runTest { rule.launch() - rule.getScenario().onActivity { + dispatcher.scheduler.advanceUntilIdle() + accountManager.getPachliAccountFlow(pachliAccountId).first() + rule.scenario.onActivity { + dispatcher.scheduler.advanceUntilIdle() val editor = it.findViewById(R.id.composeEditField) val insertText = "#" val originalText = "Some text" @@ -567,42 +721,55 @@ class ComposeActivityTest { } @Test - fun whenNoLanguageIsGiven_defaultLanguageIsSelected() { + fun whenNoLanguageIsGiven_defaultLanguageIsSelected() = runTest { rule.launch() - rule.getScenario().onActivity { + dispatcher.scheduler.advanceUntilIdle() + accountManager.getPachliAccountFlow(pachliAccountId).first() + rule.scenario.onActivity { + dispatcher.scheduler.advanceUntilIdle() assertEquals(Locale.getDefault().language, it.selectedLanguage) } } @Test - fun languageGivenInComposeOptionsIsRespected() { + fun languageGivenInComposeOptionsIsRespected() = runTest { rule.launch(intent(ComposeOptions(language = "no"))) - rule.getScenario().onActivity { + dispatcher.scheduler.advanceUntilIdle() + accountManager.getPachliAccountFlow(pachliAccountId).first() + rule.scenario.onActivity { + dispatcher.scheduler.advanceUntilIdle() assertEquals("no", it.selectedLanguage) } } @Test - fun modernLanguageCodeIsUsed() { + fun modernLanguageCodeIsUsed() = runTest { // https://github.com/tuskyapp/Tusky/issues/2903 // "ji" was deprecated in favor of "yi" rule.launch(intent(ComposeOptions(language = "ji"))) - rule.getScenario().onActivity { + dispatcher.scheduler.advanceUntilIdle() + accountManager.getPachliAccountFlow(pachliAccountId).first() + rule.scenario.onActivity { + dispatcher.scheduler.advanceUntilIdle() assertEquals("yi", it.selectedLanguage) } } @Test - fun unknownLanguageGivenInComposeOptionsIsRespected() { + fun unknownLanguageGivenInComposeOptionsIsRespected() = runTest { rule.launch(intent(ComposeOptions(language = "zzz"))) - rule.getScenario().onActivity { + dispatcher.scheduler.advanceUntilIdle() + accountManager.getPachliAccountFlow(pachliAccountId).first() + rule.scenario.onActivity { + dispatcher.scheduler.advanceUntilIdle() assertEquals("zzz", it.selectedLanguage) } } /** Returns an intent to launch [ComposeActivity] with the given options */ - private fun intent(composeOptions: ComposeOptions) = ComposeActivityIntent( + private fun intent(composeOptions: ComposeOptions? = null) = ComposeActivityIntent( ApplicationProvider.getApplicationContext(), + pachliAccountId, composeOptions, ) diff --git a/app/src/test/java/app/pachli/components/notifications/NotificationsViewModelTestBase.kt b/app/src/test/java/app/pachli/components/notifications/NotificationsViewModelTestBase.kt index 6e452792f..407dce5b2 100644 --- a/app/src/test/java/app/pachli/components/notifications/NotificationsViewModelTestBase.kt +++ b/app/src/test/java/app/pachli/components/notifications/NotificationsViewModelTestBase.kt @@ -18,51 +18,91 @@ package app.pachli.components.notifications import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.platform.app.InstrumentationRegistry +import app.pachli.PachliApplication import app.pachli.appstore.EventHub import app.pachli.core.data.repository.AccountManager import app.pachli.core.data.repository.AccountPreferenceDataStore -import app.pachli.core.data.repository.ContentFilters -import app.pachli.core.data.repository.ContentFiltersError import app.pachli.core.data.repository.ContentFiltersRepository -import app.pachli.core.data.repository.ServerRepository import app.pachli.core.data.repository.StatusDisplayOptionsRepository -import app.pachli.core.database.model.AccountEntity +import app.pachli.core.database.dao.AccountDao +import app.pachli.core.network.di.test.DEFAULT_INSTANCE_V2 +import app.pachli.core.network.model.Account import app.pachli.core.network.model.nodeinfo.UnvalidatedJrd import app.pachli.core.network.model.nodeinfo.UnvalidatedNodeInfo import app.pachli.core.network.retrofit.MastodonApi import app.pachli.core.network.retrofit.NodeInfoApi import app.pachli.core.preferences.SharedPreferencesRepository -import app.pachli.core.testing.fakes.InMemorySharedPreferences +import app.pachli.core.testing.failure import app.pachli.core.testing.rules.MainCoroutineRule +import app.pachli.core.testing.success import app.pachli.usecase.TimelineCases -import at.connyduck.calladapter.networkresult.NetworkResult -import com.github.michaelbull.result.Ok -import com.github.michaelbull.result.Result -import kotlinx.coroutines.flow.MutableStateFlow +import app.pachli.util.HiltTestApplication_Application +import com.github.michaelbull.result.andThen +import com.github.michaelbull.result.get +import com.github.michaelbull.result.onSuccess +import dagger.hilt.android.testing.CustomTestApplication +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import java.time.Instant +import java.util.Date +import javax.inject.Inject +import kotlin.properties.Delegates import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest import okhttp3.ResponseBody import okhttp3.ResponseBody.Companion.toResponseBody import org.junit.Before import org.junit.Rule import org.junit.runner.RunWith import org.mockito.kotlin.any -import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock -import org.mockito.kotlin.whenever +import org.mockito.kotlin.reset +import org.mockito.kotlin.stub +import org.robolectric.annotation.Config import retrofit2.HttpException import retrofit2.Response +open class PachliHiltApplication : PachliApplication() + +@CustomTestApplication(PachliHiltApplication::class) +interface HiltTestApplication + +@HiltAndroidTest +@Config(application = HiltTestApplication_Application::class) @RunWith(AndroidJUnit4::class) abstract class NotificationsViewModelTestBase { - protected lateinit var notificationsRepository: NotificationsRepository - protected lateinit var sharedPreferencesRepository: SharedPreferencesRepository - protected lateinit var accountManager: AccountManager + @get:Rule(order = 0) + var hilt = HiltAndroidRule(this) + + @get:Rule(order = 1) + val mainCoroutineRule = MainCoroutineRule() + + @Inject + lateinit var accountManager: AccountManager + + @Inject + lateinit var mastodonApi: MastodonApi + + @Inject + lateinit var nodeInfoApi: NodeInfoApi + + @Inject + lateinit var sharedPreferencesRepository: SharedPreferencesRepository + + @Inject + lateinit var contentFiltersRepository: ContentFiltersRepository + + @Inject + lateinit var statusDisplayOptionsRepository: StatusDisplayOptionsRepository + + @Inject + lateinit var accountDao: AccountDao + + protected val notificationsRepository: NotificationsRepository = mock() protected lateinit var timelineCases: TimelineCases protected lateinit var viewModel: NotificationsViewModel - private lateinit var statusDisplayOptionsRepository: StatusDisplayOptionsRepository - private lateinit var contentFiltersRepository: ContentFiltersRepository private val eventHub = EventHub() @@ -77,53 +117,39 @@ abstract class NotificationsViewModelTestBase { /** Exception to throw when testing errors */ protected val httpException = HttpException(emptyError) - @get:Rule - val mainCoroutineRule = MainCoroutineRule() + private val account = Account( + id = "1", + localUsername = "username", + username = "username@domain.example", + displayName = "Display Name", + createdAt = Date.from(Instant.now()), + note = "", + url = "", + avatar = "", + header = "", + ) + + protected var pachliAccountId by Delegates.notNull() @Before - fun setup() { - notificationsRepository = mock() + fun setup() = runTest { + hilt.inject() - val defaultAccount = AccountEntity( - id = 1, - domain = "mastodon.test", - accessToken = "fakeToken", - clientId = "fakeId", - clientSecret = "fakeSecret", - isActive = true, - notificationsFilter = "['follow']", - ) + reset(notificationsRepository) - val activeAccountFlow = MutableStateFlow(defaultAccount) - - accountManager = mock { - on { activeAccount } doReturn defaultAccount - whenever(it.activeAccountFlow).thenReturn(activeAccountFlow) + reset(mastodonApi) + mastodonApi.stub { + onBlocking { accountVerifyCredentials(anyOrNull(), anyOrNull()) } doReturn success(account) + onBlocking { getInstanceV2(anyOrNull()) } doReturn success(DEFAULT_INSTANCE_V2) + onBlocking { getLists() } doReturn success(emptyList()) + onBlocking { getCustomEmojis() } doReturn failure() + onBlocking { getContentFilters() } doReturn success(emptyList()) + onBlocking { listAnnouncements(anyOrNull()) } doReturn success(emptyList()) } - accountPreferenceDataStore = AccountPreferenceDataStore( - accountManager, - TestScope(), - ) - - timelineCases = mock() - - contentFiltersRepository = mock { - whenever(it.contentFilters).thenReturn(MutableStateFlow>(Ok(null))) - } - - sharedPreferencesRepository = SharedPreferencesRepository( - InMemorySharedPreferences(), - TestScope(), - ) - - val mastodonApi: MastodonApi = mock { - onBlocking { getInstanceV2() } doAnswer { null } - onBlocking { getInstanceV1() } doAnswer { null } - } - - val nodeInfoApi: NodeInfoApi = mock { - onBlocking { nodeInfoJrd() } doReturn NetworkResult.success( + reset(nodeInfoApi) + nodeInfoApi.stub { + onBlocking { nodeInfoJrd() } doReturn success( UnvalidatedJrd( listOf( UnvalidatedJrd.Link( @@ -133,35 +159,40 @@ abstract class NotificationsViewModelTestBase { ), ), ) - onBlocking { nodeInfo(any()) } doReturn NetworkResult.success( + onBlocking { nodeInfo(any()) } doReturn success( UnvalidatedNodeInfo(UnvalidatedNodeInfo.Software("mastodon", "4.2.0")), ) } - val serverRepository = ServerRepository( - mastodonApi, - nodeInfoApi, + pachliAccountId = accountManager.verifyAndAddAccount( + accessToken = "token", + domain = "domain.example", + clientId = "id", + clientSecret = "secret", + oauthScopes = "scopes", + ) + .andThen { + accountManager.setNotificationsFilter(it, "['follow']") + accountManager.setActiveAccount(it) + } + .onSuccess { accountManager.refresh(it) } + .get()!!.id + + accountPreferenceDataStore = AccountPreferenceDataStore( accountManager, TestScope(), ) - statusDisplayOptionsRepository = StatusDisplayOptionsRepository( - sharedPreferencesRepository, - serverRepository, - accountManager, - accountPreferenceDataStore, - TestScope(), - ) + timelineCases = mock() viewModel = NotificationsViewModel( - InstrumentationRegistry.getInstrumentation().targetContext, notificationsRepository, accountManager, timelineCases, eventHub, - contentFiltersRepository, statusDisplayOptionsRepository, sharedPreferencesRepository, + pachliAccountId, ) } } diff --git a/app/src/test/java/app/pachli/components/notifications/NotificationsViewModelTestClearNotifications.kt b/app/src/test/java/app/pachli/components/notifications/NotificationsViewModelTestClearNotifications.kt index 1704158a4..35374c77e 100644 --- a/app/src/test/java/app/pachli/components/notifications/NotificationsViewModelTestClearNotifications.kt +++ b/app/src/test/java/app/pachli/components/notifications/NotificationsViewModelTestClearNotifications.kt @@ -19,6 +19,7 @@ package app.pachli.components.notifications import app.cash.turbine.test import com.google.common.truth.Truth.assertThat +import dagger.hilt.android.testing.HiltAndroidTest import kotlinx.coroutines.test.runTest import org.junit.Test import org.mockito.kotlin.doReturn @@ -33,6 +34,7 @@ import org.mockito.kotlin.verify * This is only tested in the success case; if it passed there it must also * have passed in the error case. */ +@HiltAndroidTest class NotificationsViewModelTestClearNotifications : NotificationsViewModelTestBase() { @Test fun `clearing notifications succeeds && invalidate the repository`() = runTest { diff --git a/app/src/test/java/app/pachli/components/notifications/NotificationsViewModelTestContentFilter.kt b/app/src/test/java/app/pachli/components/notifications/NotificationsViewModelTestContentFilter.kt index 1c7fc0200..312e657ce 100644 --- a/app/src/test/java/app/pachli/components/notifications/NotificationsViewModelTestContentFilter.kt +++ b/app/src/test/java/app/pachli/components/notifications/NotificationsViewModelTestContentFilter.kt @@ -18,46 +18,35 @@ package app.pachli.components.notifications import app.cash.turbine.test -import app.pachli.core.database.model.AccountEntity import app.pachli.core.network.model.Notification import com.google.common.truth.Truth.assertThat +import dagger.hilt.android.testing.HiltAndroidTest import kotlinx.coroutines.test.runTest import org.junit.Test -import org.mockito.kotlin.argumentCaptor -import org.mockito.kotlin.verify /** * Verify that [ApplyFilter] is handled correctly on receipt: - * - * - Is the [UiState] updated correctly? - * - Are the correct [AccountManager] functions called, with the correct arguments? */ +@HiltAndroidTest class NotificationsViewModelTestContentFilter : NotificationsViewModelTestBase() { - @Test - fun `should load initial filter from active account`() = runTest { - viewModel.uiState.test { - assertThat(awaitItem().activeFilter) - .containsExactlyElementsIn(setOf(Notification.Type.FOLLOW)) - } - } - @Test fun `should save filter to active account && update state`() = runTest { viewModel.uiState.test { + // Given + // - Initial filter is from the active account + // (skip the first item, the default state) + awaitItem() + assertThat(awaitItem().activeFilter) + .containsExactlyElementsIn(setOf(Notification.Type.FOLLOW)) + // When - viewModel.accept(InfallibleUiAction.ApplyFilter(setOf(Notification.Type.REBLOG))) + // - Updating the filter + viewModel.accept(InfallibleUiAction.ApplyFilter(pachliAccountId, setOf(Notification.Type.REBLOG))) // Then - // - filter saved to active account - argumentCaptor().apply { - verify(accountManager).saveAccount(capture()) - assertThat(this.lastValue.notificationsFilter) - .isEqualTo("[\"reblog\"]") - } - // - filter updated in uiState - assertThat(expectMostRecentItem().activeFilter) + assertThat(awaitItem().activeFilter) .containsExactlyElementsIn(setOf(Notification.Type.REBLOG)) } } diff --git a/app/src/test/java/app/pachli/components/notifications/NotificationsViewModelTestNotificationFilterAction.kt b/app/src/test/java/app/pachli/components/notifications/NotificationsViewModelTestNotificationFilterAction.kt index 5b7add7ed..9d1eb6157 100644 --- a/app/src/test/java/app/pachli/components/notifications/NotificationsViewModelTestNotificationFilterAction.kt +++ b/app/src/test/java/app/pachli/components/notifications/NotificationsViewModelTestNotificationFilterAction.kt @@ -21,6 +21,7 @@ import app.cash.turbine.test import app.pachli.core.network.model.Relationship import at.connyduck.calladapter.networkresult.NetworkResult import com.google.common.truth.Truth.assertThat +import dagger.hilt.android.testing.HiltAndroidTest import kotlinx.coroutines.test.runTest import org.junit.Test import org.mockito.kotlin.any @@ -38,6 +39,7 @@ import org.mockito.kotlin.verify * This is only tested in the success case; if it passed there it must also * have passed in the error case. */ +@HiltAndroidTest class NotificationsViewModelTestNotificationFilterAction : NotificationsViewModelTestBase() { /** Dummy relationship */ private val relationship = Relationship( diff --git a/app/src/test/java/app/pachli/components/notifications/NotificationsViewModelTestStatusDisplayOptions.kt b/app/src/test/java/app/pachli/components/notifications/NotificationsViewModelTestStatusDisplayOptions.kt index eaafbcbfa..e43ee541c 100644 --- a/app/src/test/java/app/pachli/components/notifications/NotificationsViewModelTestStatusDisplayOptions.kt +++ b/app/src/test/java/app/pachli/components/notifications/NotificationsViewModelTestStatusDisplayOptions.kt @@ -22,6 +22,7 @@ import app.cash.turbine.test import app.pachli.core.data.model.StatusDisplayOptions import app.pachli.core.preferences.PrefKeys import com.google.common.truth.Truth.assertThat +import dagger.hilt.android.testing.HiltAndroidTest import kotlinx.coroutines.test.runTest import org.junit.Test @@ -31,6 +32,7 @@ import org.junit.Test * - Is the initial value taken from values in sharedPreferences and account? * - Is the correct update emitted when a relevant preference changes? */ +@HiltAndroidTest class NotificationsViewModelTestStatusDisplayOptions : NotificationsViewModelTestBase() { private val defaultStatusDisplayOptions = StatusDisplayOptions() diff --git a/app/src/test/java/app/pachli/components/notifications/NotificationsViewModelTestStatusFilterAction.kt b/app/src/test/java/app/pachli/components/notifications/NotificationsViewModelTestStatusFilterAction.kt index 7e4f087d4..f5914f3ba 100644 --- a/app/src/test/java/app/pachli/components/notifications/NotificationsViewModelTestStatusFilterAction.kt +++ b/app/src/test/java/app/pachli/components/notifications/NotificationsViewModelTestStatusFilterAction.kt @@ -23,6 +23,7 @@ import app.pachli.core.database.model.TranslationState import app.pachli.viewdata.StatusViewData import at.connyduck.calladapter.networkresult.NetworkResult import com.google.common.truth.Truth.assertThat +import dagger.hilt.android.testing.HiltAndroidTest import kotlinx.coroutines.test.runTest import org.junit.Test import org.mockito.kotlin.any @@ -40,6 +41,7 @@ import org.mockito.kotlin.verify * This is only tested in the success case; if it passed there it must also * have passed in the error case. */ +@HiltAndroidTest class NotificationsViewModelTestStatusFilterAction : NotificationsViewModelTestBase() { private val status = mockStatus(pollOptions = listOf("Choice 1", "Choice 2", "Choice 3")) private val statusViewData = StatusViewData( diff --git a/app/src/test/java/app/pachli/components/notifications/NotificationsViewModelTestUiState.kt b/app/src/test/java/app/pachli/components/notifications/NotificationsViewModelTestUiState.kt index 6467c04af..77c89036b 100644 --- a/app/src/test/java/app/pachli/components/notifications/NotificationsViewModelTestUiState.kt +++ b/app/src/test/java/app/pachli/components/notifications/NotificationsViewModelTestUiState.kt @@ -23,6 +23,7 @@ import app.pachli.core.network.model.Notification import app.pachli.core.preferences.PrefKeys import app.pachli.core.preferences.TabTapBehaviour import com.google.common.truth.Truth.assertThat +import dagger.hilt.android.testing.HiltAndroidTest import kotlinx.coroutines.test.runTest import org.junit.Test @@ -32,6 +33,7 @@ import org.junit.Test * - Is the initial value taken from values in sharedPreferences and account? * - Is the correct update emitted when a relevant preference changes? */ +@HiltAndroidTest class NotificationsViewModelTestUiState : NotificationsViewModelTestBase() { private val initialUiState = UiState( @@ -43,7 +45,9 @@ class NotificationsViewModelTestUiState : NotificationsViewModelTestBase() { @Test fun `should load initial filter from active account`() = runTest { viewModel.uiState.test { - assertThat(expectMostRecentItem()).isEqualTo(initialUiState) + // skip initial empty UiState + awaitItem() + assertThat(awaitItem()).isEqualTo(initialUiState) } } diff --git a/app/src/test/java/app/pachli/components/notifications/NotificationsViewModelTestVisibleId.kt b/app/src/test/java/app/pachli/components/notifications/NotificationsViewModelTestVisibleId.kt index 85c95b4ad..0e8a8d53e 100644 --- a/app/src/test/java/app/pachli/components/notifications/NotificationsViewModelTestVisibleId.kt +++ b/app/src/test/java/app/pachli/components/notifications/NotificationsViewModelTestVisibleId.kt @@ -17,25 +17,26 @@ package app.pachli.components.notifications -import app.pachli.core.database.model.AccountEntity +import app.cash.turbine.test import com.google.common.truth.Truth.assertThat +import dagger.hilt.android.testing.HiltAndroidTest import kotlinx.coroutines.test.runTest import org.junit.Test -import org.mockito.kotlin.argumentCaptor -import org.mockito.kotlin.verify +@HiltAndroidTest class NotificationsViewModelTestVisibleId : NotificationsViewModelTestBase() { @Test fun `should save notification ID to active account`() = runTest { - argumentCaptor().apply { + viewModel.accountFlow.test { + // Given + assertThat(awaitItem().entity.lastNotificationId).isEqualTo("0") + // When - viewModel.accept(InfallibleUiAction.SaveVisibleId("1234")) + viewModel.accept(InfallibleUiAction.SaveVisibleId(pachliAccountId, "1234")) // Then - verify(accountManager).saveAccount(capture()) - assertThat(this.lastValue.lastNotificationId) - .isEqualTo("1234") + assertThat(awaitItem().entity.lastNotificationId).isEqualTo("1234") } } } diff --git a/app/src/test/java/app/pachli/components/timeline/CachedTimelineViewModelTestBase.kt b/app/src/test/java/app/pachli/components/timeline/CachedTimelineViewModelTestBase.kt index 17f461b7f..736bf3189 100644 --- a/app/src/test/java/app/pachli/components/timeline/CachedTimelineViewModelTestBase.kt +++ b/app/src/test/java/app/pachli/components/timeline/CachedTimelineViewModelTestBase.kt @@ -19,7 +19,6 @@ package app.pachli.components.timeline import androidx.lifecycle.SavedStateHandle import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.platform.app.InstrumentationRegistry import app.pachli.PachliApplication import app.pachli.appstore.EventHub import app.pachli.components.timeline.viewmodel.CachedTimelineViewModel @@ -28,16 +27,19 @@ import app.pachli.core.data.repository.AccountManager import app.pachli.core.data.repository.ContentFiltersRepository import app.pachli.core.data.repository.StatusDisplayOptionsRepository import app.pachli.core.model.Timeline +import app.pachli.core.network.di.test.DEFAULT_INSTANCE_V2 import app.pachli.core.network.model.Account import app.pachli.core.network.model.nodeinfo.UnvalidatedJrd import app.pachli.core.network.model.nodeinfo.UnvalidatedNodeInfo import app.pachli.core.network.retrofit.MastodonApi import app.pachli.core.network.retrofit.NodeInfoApi import app.pachli.core.preferences.SharedPreferencesRepository +import app.pachli.core.testing.failure import app.pachli.core.testing.rules.MainCoroutineRule import app.pachli.core.testing.success import app.pachli.usecase.TimelineCases -import at.connyduck.calladapter.networkresult.NetworkResult +import com.github.michaelbull.result.andThen +import com.github.michaelbull.result.onSuccess import com.squareup.moshi.Moshi import dagger.hilt.android.testing.CustomTestApplication import dagger.hilt.android.testing.HiltAndroidRule @@ -45,12 +47,14 @@ import dagger.hilt.android.testing.HiltAndroidTest import java.time.Instant import java.util.Date import javax.inject.Inject +import kotlinx.coroutines.test.runTest import okhttp3.ResponseBody import okhttp3.ResponseBody.Companion.toResponseBody import org.junit.Before import org.junit.Rule import org.junit.runner.RunWith import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock import org.mockito.kotlin.reset @@ -109,19 +113,36 @@ abstract class CachedTimelineViewModelTestBase { /** Exception to throw when testing errors */ protected val httpException = HttpException(emptyError) + val account = Account( + id = "1", + localUsername = "username", + username = "username@domain.example", + displayName = "Display Name", + createdAt = Date.from(Instant.now()), + note = "", + url = "", + avatar = "", + header = "", + ) + @Before - fun setup() { + fun setup() = runTest { hilt.inject() reset(mastodonApi) mastodonApi.stub { - onBlocking { getCustomEmojis() } doReturn NetworkResult.failure(Exception()) + onBlocking { accountVerifyCredentials(anyOrNull(), anyOrNull()) } doReturn success(account) + onBlocking { getInstanceV2() } doReturn success(DEFAULT_INSTANCE_V2) + onBlocking { getLists() } doReturn success(emptyList()) + onBlocking { getCustomEmojis() } doReturn failure() onBlocking { getContentFilters() } doReturn success(emptyList()) + onBlocking { listAnnouncements(any()) } doReturn success(emptyList()) + onBlocking { getContentFiltersV1() } doReturn success(emptyList()) } reset(nodeInfoApi) nodeInfoApi.stub { - onBlocking { nodeInfoJrd() } doReturn NetworkResult.success( + onBlocking { nodeInfoJrd() } doReturn success( UnvalidatedJrd( listOf( UnvalidatedJrd.Link( @@ -131,39 +152,28 @@ abstract class CachedTimelineViewModelTestBase { ), ), ) - onBlocking { nodeInfo(any()) } doReturn NetworkResult.success( + onBlocking { nodeInfo(any()) } doReturn success( UnvalidatedNodeInfo(UnvalidatedNodeInfo.Software("mastodon", "4.2.0")), ) } - accountManager.addAccount( + accountManager.verifyAndAddAccount( accessToken = "token", domain = "domain.example", clientId = "id", clientSecret = "secret", oauthScopes = "scopes", - newAccount = Account( - id = "1", - localUsername = "username", - username = "username@domain.example", - displayName = "Display Name", - createdAt = Date.from(Instant.now()), - note = "", - url = "", - avatar = "", - header = "", - ), ) + .andThen { accountManager.setActiveAccount(it) } + .onSuccess { accountManager.refresh(it) } timelineCases = mock() viewModel = CachedTimelineViewModel( - InstrumentationRegistry.getInstrumentation().targetContext, SavedStateHandle(mapOf(TimelineViewModel.TIMELINE_TAG to Timeline.Home)), cachedTimelineRepository, timelineCases, eventHub, - contentFiltersRepository, accountManager, statusDisplayOptionsRepository, sharedPreferencesRepository, diff --git a/app/src/test/java/app/pachli/components/timeline/CachedTimelineViewModelTestVisibleId.kt b/app/src/test/java/app/pachli/components/timeline/CachedTimelineViewModelTestVisibleId.kt index f25de5aa0..a3511af14 100644 --- a/app/src/test/java/app/pachli/components/timeline/CachedTimelineViewModelTestVisibleId.kt +++ b/app/src/test/java/app/pachli/components/timeline/CachedTimelineViewModelTestVisibleId.kt @@ -17,10 +17,16 @@ package app.pachli.components.timeline +import app.cash.turbine.test import app.pachli.components.timeline.viewmodel.InfallibleUiAction +import app.pachli.core.data.repository.Loadable +import app.pachli.core.database.model.AccountEntity import app.pachli.core.model.Timeline import com.google.common.truth.Truth.assertThat import dagger.hilt.android.testing.HiltAndroidTest +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.map import kotlinx.coroutines.test.runTest import org.junit.Test @@ -29,16 +35,21 @@ class CachedTimelineViewModelTestVisibleId : CachedTimelineViewModelTestBase() { @Test fun `should save status ID to active account`() = runTest { - // Given - assertThat(accountManager.activeAccount?.lastVisibleHomeTimelineStatusId) - .isNull() assertThat(viewModel.timeline).isEqualTo(Timeline.Home) - // When - viewModel.accept(InfallibleUiAction.SaveVisibleId("1234")) + accountManager + .activeAccountFlow.filterIsInstance>() + .filter { it.data != null } + .map { it.data } + .test { + // Given + assertThat(expectMostRecentItem()!!.lastVisibleHomeTimelineStatusId).isNull() - // Then - assertThat(accountManager.activeAccount?.lastVisibleHomeTimelineStatusId) - .isEqualTo("1234") + // When + viewModel.accept(InfallibleUiAction.SaveVisibleId("1234")) + + // Then + assertThat(awaitItem()!!.lastVisibleHomeTimelineStatusId).isEqualTo("1234") + } } } diff --git a/app/src/test/java/app/pachli/components/timeline/NetworkTimelineRemoteMediatorTest.kt b/app/src/test/java/app/pachli/components/timeline/NetworkTimelineRemoteMediatorTest.kt index 75df4f118..560ae6cae 100644 --- a/app/src/test/java/app/pachli/components/timeline/NetworkTimelineRemoteMediatorTest.kt +++ b/app/src/test/java/app/pachli/components/timeline/NetworkTimelineRemoteMediatorTest.kt @@ -28,7 +28,6 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import app.pachli.components.timeline.viewmodel.NetworkTimelineRemoteMediator import app.pachli.components.timeline.viewmodel.Page import app.pachli.components.timeline.viewmodel.PageCache -import app.pachli.core.data.repository.AccountManager import app.pachli.core.database.model.AccountEntity import app.pachli.core.model.Timeline import app.pachli.core.network.model.Status @@ -51,16 +50,14 @@ import retrofit2.Response @Config(sdk = [29]) @RunWith(AndroidJUnit4::class) class NetworkTimelineRemoteMediatorTest { - private val accountManager: AccountManager = mock { - on { activeAccount } doReturn AccountEntity( - id = 1, - domain = "mastodon.example", - accessToken = "token", - clientId = "id", - clientSecret = "secret", - isActive = true, - ) - } + private val activeAccount = AccountEntity( + id = 1, + domain = "mastodon.example", + accessToken = "token", + clientId = "id", + clientSecret = "secret", + isActive = true, + ) private lateinit var pagingSourceFactory: InvalidatingPagingSourceFactory @@ -74,9 +71,8 @@ class NetworkTimelineRemoteMediatorTest { fun `should return error when network call returns error code`() = runTest { // Given val remoteMediator = NetworkTimelineRemoteMediator( - viewModelScope = this, api = mock(defaultAnswer = { Response.error(500, "".toResponseBody()) }), - accountManager = accountManager, + activeAccount = activeAccount, factory = pagingSourceFactory, pageCache = PageCache(), timeline = Timeline.Home, @@ -96,9 +92,8 @@ class NetworkTimelineRemoteMediatorTest { fun `should return error when network call fails`() = runTest { // Given val remoteMediator = NetworkTimelineRemoteMediator( - viewModelScope = this, api = mock(defaultAnswer = { throw IOException() }), - accountManager, + activeAccount = activeAccount, factory = pagingSourceFactory, pageCache = PageCache(), timeline = Timeline.Home, @@ -118,7 +113,6 @@ class NetworkTimelineRemoteMediatorTest { // Given val pages = PageCache() val remoteMediator = NetworkTimelineRemoteMediator( - viewModelScope = this, api = mock { onBlocking { homeTimeline(maxId = anyOrNull(), minId = anyOrNull(), limit = anyOrNull(), sinceId = anyOrNull()) } doReturn Response.success( listOf(mockStatus("7"), mockStatus("6"), mockStatus("5")), @@ -128,7 +122,7 @@ class NetworkTimelineRemoteMediatorTest { ), ) }, - accountManager = accountManager, + activeAccount = activeAccount, factory = pagingSourceFactory, pageCache = pages, timeline = Timeline.Home, @@ -183,7 +177,6 @@ class NetworkTimelineRemoteMediatorTest { } val remoteMediator = NetworkTimelineRemoteMediator( - viewModelScope = this, api = mock { onBlocking { homeTimeline(maxId = anyOrNull(), minId = anyOrNull(), limit = anyOrNull(), sinceId = anyOrNull()) } doReturn Response.success( listOf(mockStatus("10"), mockStatus("9"), mockStatus("8")), @@ -193,7 +186,7 @@ class NetworkTimelineRemoteMediatorTest { ), ) }, - accountManager = accountManager, + activeAccount = activeAccount, factory = pagingSourceFactory, pageCache = pages, timeline = Timeline.Home, @@ -256,7 +249,6 @@ class NetworkTimelineRemoteMediatorTest { } val remoteMediator = NetworkTimelineRemoteMediator( - viewModelScope = this, api = mock { onBlocking { homeTimeline(maxId = anyOrNull(), minId = anyOrNull(), limit = anyOrNull(), sinceId = anyOrNull()) } doReturn Response.success( listOf(mockStatus("4"), mockStatus("3"), mockStatus("2")), @@ -266,7 +258,7 @@ class NetworkTimelineRemoteMediatorTest { ), ) }, - accountManager = accountManager, + activeAccount = activeAccount, factory = pagingSourceFactory, pageCache = pages, timeline = Timeline.Home, diff --git a/app/src/test/java/app/pachli/components/timeline/NetworkTimelineViewModelTestBase.kt b/app/src/test/java/app/pachli/components/timeline/NetworkTimelineViewModelTestBase.kt index 3678d1996..865b7fa27 100644 --- a/app/src/test/java/app/pachli/components/timeline/NetworkTimelineViewModelTestBase.kt +++ b/app/src/test/java/app/pachli/components/timeline/NetworkTimelineViewModelTestBase.kt @@ -19,7 +19,6 @@ package app.pachli.components.timeline import androidx.lifecycle.SavedStateHandle import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.platform.app.InstrumentationRegistry import app.pachli.appstore.EventHub import app.pachli.components.timeline.viewmodel.NetworkTimelineViewModel import app.pachli.components.timeline.viewmodel.TimelineViewModel @@ -27,28 +26,33 @@ import app.pachli.core.data.repository.AccountManager import app.pachli.core.data.repository.ContentFiltersRepository import app.pachli.core.data.repository.StatusDisplayOptionsRepository import app.pachli.core.model.Timeline +import app.pachli.core.network.di.test.DEFAULT_INSTANCE_V2 import app.pachli.core.network.model.Account import app.pachli.core.network.model.nodeinfo.UnvalidatedJrd import app.pachli.core.network.model.nodeinfo.UnvalidatedNodeInfo import app.pachli.core.network.retrofit.MastodonApi import app.pachli.core.network.retrofit.NodeInfoApi import app.pachli.core.preferences.SharedPreferencesRepository +import app.pachli.core.testing.failure import app.pachli.core.testing.rules.MainCoroutineRule import app.pachli.core.testing.success import app.pachli.usecase.TimelineCases import app.pachli.util.HiltTestApplication_Application -import at.connyduck.calladapter.networkresult.NetworkResult +import com.github.michaelbull.result.andThen +import com.github.michaelbull.result.onSuccess import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest import java.time.Instant import java.util.Date import javax.inject.Inject +import kotlinx.coroutines.test.runTest import okhttp3.ResponseBody import okhttp3.ResponseBody.Companion.toResponseBody import org.junit.Before import org.junit.Rule import org.junit.runner.RunWith import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock import org.mockito.kotlin.reset @@ -99,19 +103,34 @@ abstract class NetworkTimelineViewModelTestBase { /** Exception to throw when testing errors */ protected val httpException = HttpException(emptyError) + private val account = Account( + id = "1", + localUsername = "username", + username = "username@domain.example", + displayName = "Display Name", + createdAt = Date.from(Instant.now()), + note = "", + url = "", + avatar = "", + header = "", + ) + @Before - fun setup() { + fun setup() = runTest { hilt.inject() reset(mastodonApi) mastodonApi.stub { - onBlocking { getCustomEmojis() } doReturn NetworkResult.failure(Exception()) + onBlocking { accountVerifyCredentials(anyOrNull(), anyOrNull()) } doReturn success(account) + onBlocking { getInstanceV2(anyOrNull()) } doReturn success(DEFAULT_INSTANCE_V2) + onBlocking { getCustomEmojis() } doReturn failure() onBlocking { getContentFilters() } doReturn success(emptyList()) + onBlocking { listAnnouncements(anyOrNull()) } doReturn success(emptyList()) } reset(nodeInfoApi) nodeInfoApi.stub { - onBlocking { nodeInfoJrd() } doReturn NetworkResult.success( + onBlocking { nodeInfoJrd() } doReturn success( UnvalidatedJrd( listOf( UnvalidatedJrd.Link( @@ -121,39 +140,28 @@ abstract class NetworkTimelineViewModelTestBase { ), ), ) - onBlocking { nodeInfo(any()) } doReturn NetworkResult.success( + onBlocking { nodeInfo(any()) } doReturn success( UnvalidatedNodeInfo(UnvalidatedNodeInfo.Software("mastodon", "4.2.0")), ) } - accountManager.addAccount( + accountManager.verifyAndAddAccount( accessToken = "token", domain = "domain.example", clientId = "id", clientSecret = "secret", oauthScopes = "scopes", - newAccount = Account( - id = "1", - localUsername = "username", - username = "username@domain.example", - displayName = "Display Name", - createdAt = Date.from(Instant.now()), - note = "", - url = "", - avatar = "", - header = "", - ), ) + .andThen { accountManager.setActiveAccount(it) } + .onSuccess { accountManager.refresh(it) } timelineCases = mock() viewModel = NetworkTimelineViewModel( - InstrumentationRegistry.getInstrumentation().targetContext, SavedStateHandle(mapOf(TimelineViewModel.TIMELINE_TAG to Timeline.Bookmarks)), networkTimelineRepository, timelineCases, eventHub, - contentFiltersRepository, accountManager, statusDisplayOptionsRepository, sharedPreferencesRepository, diff --git a/app/src/test/java/app/pachli/components/viewthread/ViewThreadViewModelTest.kt b/app/src/test/java/app/pachli/components/viewthread/ViewThreadViewModelTest.kt index a9fd74052..b03b05c84 100644 --- a/app/src/test/java/app/pachli/components/viewthread/ViewThreadViewModelTest.kt +++ b/app/src/test/java/app/pachli/components/viewthread/ViewThreadViewModelTest.kt @@ -1,7 +1,7 @@ package app.pachli.components.viewthread -import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.test.ext.junit.runners.AndroidJUnit4 +import app.cash.turbine.test import app.pachli.PachliApplication import app.pachli.appstore.BookmarkEvent import app.pachli.appstore.EventHub @@ -12,12 +12,9 @@ import app.pachli.components.timeline.CachedTimelineRepository import app.pachli.components.timeline.mockStatus import app.pachli.components.timeline.mockStatusViewData import app.pachli.core.data.repository.AccountManager -import app.pachli.core.data.repository.ContentFilters -import app.pachli.core.data.repository.ContentFiltersError -import app.pachli.core.data.repository.ContentFiltersRepository import app.pachli.core.data.repository.StatusDisplayOptionsRepository import app.pachli.core.database.dao.TimelineDao -import app.pachli.core.database.model.AccountEntity +import app.pachli.core.network.di.test.DEFAULT_INSTANCE_V2 import app.pachli.core.network.model.Account import app.pachli.core.network.model.StatusContext import app.pachli.core.network.model.nodeinfo.UnvalidatedJrd @@ -25,12 +22,14 @@ import app.pachli.core.network.model.nodeinfo.UnvalidatedNodeInfo import app.pachli.core.network.retrofit.MastodonApi import app.pachli.core.network.retrofit.NodeInfoApi import app.pachli.core.preferences.SharedPreferencesRepository +import app.pachli.core.testing.failure +import app.pachli.core.testing.rules.MainCoroutineRule +import app.pachli.core.testing.success import app.pachli.usecase.TimelineCases import at.connyduck.calladapter.networkresult.NetworkResult -import com.github.michaelbull.result.Ok -import com.github.michaelbull.result.Result +import com.github.michaelbull.result.andThen +import com.github.michaelbull.result.onSuccess import com.squareup.moshi.Moshi -import dagger.hilt.android.testing.BindValue import dagger.hilt.android.testing.CustomTestApplication import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest @@ -38,9 +37,7 @@ import java.io.IOException import java.time.Instant import java.util.Date import javax.inject.Inject -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Before import org.junit.Rule @@ -48,11 +45,11 @@ import org.junit.Test import org.junit.runner.RunWith import org.mockito.ArgumentMatchers.anyLong import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock import org.mockito.kotlin.reset import org.mockito.kotlin.stub -import org.mockito.kotlin.whenever import org.robolectric.annotation.Config open class PachliHiltApplication : PachliApplication() @@ -67,33 +64,8 @@ class ViewThreadViewModelTest { @get:Rule(order = 0) var hilt = HiltAndroidRule(this) - /** - * Execute each task synchronously. - * - * If you do not do this, and you have code like this under test: - * - * ``` - * fun someFunc() = viewModelScope.launch { - * _uiState.value = "initial value" - * // ... - * call_a_suspend_fun() - * // ... - * _uiState.value = "new value" - * } - * ``` - * - * and a test like: - * - * ``` - * someFunc() - * assertEquals("new value", viewModel.uiState.value) - * ``` - * - * The test will fail, because someFunc() yields at the `call_a_suspend_func()` point, - * and control returns to the test before `_uiState.value` has been changed. - */ @get:Rule(order = 1) - val instantTaskRule = InstantTaskExecutorRule() + val instantTaskRule = MainCoroutineRule() @Inject lateinit var accountManager: AccountManager @@ -119,9 +91,6 @@ class ViewThreadViewModelTest { @Inject lateinit var moshi: Moshi - @BindValue @JvmField - val contentFiltersRepository: ContentFiltersRepository = mock() - @Inject lateinit var statusDisplayOptionsRepository: StatusDisplayOptionsRepository @@ -129,18 +98,35 @@ class ViewThreadViewModelTest { private val threadId = "1234" + private val account = Account( + id = "1", + localUsername = "username", + username = "username@domain.example", + displayName = "Display Name", + createdAt = Date.from(Instant.now()), + note = "", + url = "", + avatar = "", + header = "", + ) + @Before - fun setup() { + fun setup() = runTest { hilt.inject() - reset(contentFiltersRepository) - contentFiltersRepository.stub { - whenever(it.contentFilters).thenReturn(MutableStateFlow>(Ok(null))) + reset(mastodonApi) + mastodonApi.stub { + onBlocking { accountVerifyCredentials(anyOrNull(), anyOrNull()) } doReturn success(account) + onBlocking { getInstanceV2(anyOrNull()) } doReturn success(DEFAULT_INSTANCE_V2) + onBlocking { getCustomEmojis() } doReturn failure() + onBlocking { listAnnouncements(any()) } doReturn success(emptyList()) + onBlocking { getLists() } doReturn success(emptyList()) + onBlocking { getContentFilters() } doReturn success(emptyList()) } reset(nodeInfoApi) nodeInfoApi.stub { - onBlocking { nodeInfoJrd() } doReturn NetworkResult.success( + onBlocking { nodeInfoJrd() } doReturn success( UnvalidatedJrd( listOf( UnvalidatedJrd.Link( @@ -150,38 +136,20 @@ class ViewThreadViewModelTest { ), ), ) - onBlocking { nodeInfo(any()) } doReturn NetworkResult.success( + onBlocking { nodeInfo(any()) } doReturn success( UnvalidatedNodeInfo(UnvalidatedNodeInfo.Software("mastodon", "4.2.0")), ) } - val defaultAccount = AccountEntity( - id = 1, - domain = "mastodon.test", - accessToken = "fakeToken", - clientId = "fakeId", - clientSecret = "fakeSecret", - isActive = true, - ) - - accountManager.addAccount( + accountManager.verifyAndAddAccount( accessToken = "token", domain = "domain.example", clientId = "id", clientSecret = "secret", oauthScopes = "scopes", - newAccount = Account( - id = "1", - localUsername = "username", - username = "username@domain.example", - displayName = "Display Name", - createdAt = Date.from(Instant.now()), - note = "", - url = "", - avatar = "", - header = "", - ), ) + .andThen { accountManager.setActiveAccount(it) } + .onSuccess { accountManager.refresh(it) } val cachedTimelineRepository: CachedTimelineRepository = mock { onBlocking { getStatusViewData(anyLong(), any()) } doReturn emptyMap() @@ -197,17 +165,21 @@ class ViewThreadViewModelTest { moshi, cachedTimelineRepository, statusDisplayOptionsRepository, - contentFiltersRepository, ) } @Test - fun `should emit status and context when both load`() { + fun `should emit status and context when both load`() = runTest { mockSuccessResponses() - viewModel.loadThread(threadId) + viewModel.uiState.test { + viewModel.loadThread(threadId) + var item: ThreadUiState + + do { + item = awaitItem() + } while (item is ThreadUiState.Loading) - runBlocking { assertEquals( ThreadUiState.Success( statusViewData = listOf( @@ -229,21 +201,26 @@ class ViewThreadViewModelTest { detailedStatusPosition = 1, revealButton = RevealButtonState.REVEAL, ), - viewModel.uiState.first(), + item, ) } } @Test - fun `should emit status even if context fails to load`() { + fun `should emit status even if context fails to load`() = runTest { mastodonApi.stub { onBlocking { status(threadId) } doReturn NetworkResult.success(mockStatus(id = "2", inReplyToId = "1", inReplyToAccountId = "1")) onBlocking { statusContext(threadId) } doReturn NetworkResult.failure(IOException()) } - viewModel.loadThread(threadId) + viewModel.uiState.test { + viewModel.loadThread(threadId) + var item: ThreadUiState + + do { + item = awaitItem() + } while (item is ThreadUiState.Loading) - runBlocking { assertEquals( ThreadUiState.Success( statusViewData = listOf( @@ -257,30 +234,35 @@ class ViewThreadViewModelTest { detailedStatusPosition = 0, revealButton = RevealButtonState.NO_BUTTON, ), - viewModel.uiState.first(), + item, ) } } @Test - fun `should emit error when status and context fail to load`() { + fun `should emit error when status and context fail to load`() = runTest { mastodonApi.stub { onBlocking { status(threadId) } doReturn NetworkResult.failure(IOException()) onBlocking { statusContext(threadId) } doReturn NetworkResult.failure(IOException()) } - viewModel.loadThread(threadId) + viewModel.uiState.test { + viewModel.loadThread(threadId) + var item: ThreadUiState + + do { + item = awaitItem() + } while (item is ThreadUiState.Loading) - runBlocking { assertEquals( ThreadUiState.Error::class.java, - viewModel.uiState.first().javaClass, + item.javaClass, ) } } @Test - fun `should emit error when status fails to load`() { + fun `should emit error when status fails to load`() = runTest { mastodonApi.stub { onBlocking { status(threadId) } doReturn NetworkResult.failure(IOException()) onBlocking { statusContext(threadId) } doReturn NetworkResult.success( @@ -291,24 +273,31 @@ class ViewThreadViewModelTest { ) } - viewModel.loadThread(threadId) + viewModel.uiState.test { + viewModel.loadThread(threadId) + var item: ThreadUiState + + do { + item = awaitItem() + } while (item is ThreadUiState.Loading) - runBlocking { assertEquals( ThreadUiState.Error::class.java, - viewModel.uiState.first().javaClass, + item.javaClass, ) } } @Test - fun `should update state when reveal button is toggled`() { - mockSuccessResponses() + fun `should update state when reveal button is toggled`() = runTest { + viewModel.uiState.test { + mockSuccessResponses() - viewModel.loadThread(threadId) - viewModel.toggleRevealButton() + viewModel.loadThread(threadId) + while (awaitItem() !is ThreadUiState.Success) { + } + viewModel.toggleRevealButton() - runBlocking { assertEquals( ThreadUiState.Success( statusViewData = listOf( @@ -332,20 +321,21 @@ class ViewThreadViewModelTest { detailedStatusPosition = 1, revealButton = RevealButtonState.HIDE, ), - viewModel.uiState.first(), + expectMostRecentItem(), ) } } @Test - fun `should handle favorite event`() { - mockSuccessResponses() + fun `should handle favorite event`() = runTest { + viewModel.uiState.test { + mockSuccessResponses() - viewModel.loadThread(threadId) + viewModel.loadThread(threadId) + while (awaitItem() !is ThreadUiState.Success) { + } - runBlocking { eventHub.dispatch(FavoriteEvent(statusId = "1", false)) - assertEquals( ThreadUiState.Success( statusViewData = listOf( @@ -367,18 +357,19 @@ class ViewThreadViewModelTest { detailedStatusPosition = 1, revealButton = RevealButtonState.REVEAL, ), - viewModel.uiState.first(), + expectMostRecentItem(), ) } } @Test - fun `should handle reblog event`() { - mockSuccessResponses() + fun `should handle reblog event`() = runTest { + viewModel.uiState.test { + mockSuccessResponses() - viewModel.loadThread(threadId) - - runBlocking { + viewModel.loadThread(threadId) + while (awaitItem() !is ThreadUiState.Success) { + } eventHub.dispatch(ReblogEvent(statusId = "2", true)) assertEquals( @@ -403,18 +394,19 @@ class ViewThreadViewModelTest { detailedStatusPosition = 1, revealButton = RevealButtonState.REVEAL, ), - viewModel.uiState.first(), + expectMostRecentItem(), ) } } @Test - fun `should handle bookmark event`() { - mockSuccessResponses() + fun `should handle bookmark event`() = runTest { + viewModel.uiState.test { + mockSuccessResponses() - viewModel.loadThread(threadId) - - runBlocking { + viewModel.loadThread(threadId) + while (awaitItem() !is ThreadUiState.Success) { + } eventHub.dispatch(BookmarkEvent(statusId = "3", false)) assertEquals( @@ -439,20 +431,21 @@ class ViewThreadViewModelTest { detailedStatusPosition = 1, revealButton = RevealButtonState.REVEAL, ), - viewModel.uiState.first(), + expectMostRecentItem(), ) } } @Test - fun `should remove status`() { - mockSuccessResponses() + fun `should remove status`() = runTest { + viewModel.uiState.test { + mockSuccessResponses() - viewModel.loadThread(threadId) + viewModel.loadThread(threadId) + while (awaitItem() !is ThreadUiState.Success) { + } + viewModel.removeStatus(mockStatusViewData(id = "3", inReplyToId = "2", inReplyToAccountId = "1", spoilerText = "Test")) - viewModel.removeStatus(mockStatusViewData(id = "3", inReplyToId = "2", inReplyToAccountId = "1", spoilerText = "Test")) - - runBlocking { assertEquals( ThreadUiState.Success( statusViewData = listOf( @@ -468,23 +461,24 @@ class ViewThreadViewModelTest { detailedStatusPosition = 1, revealButton = RevealButtonState.REVEAL, ), - viewModel.uiState.first(), + expectMostRecentItem(), ) } } @Test - fun `should change status expanded state`() { - mockSuccessResponses() + fun `should change status expanded state`() = runTest { + viewModel.uiState.test { + mockSuccessResponses() - viewModel.loadThread(threadId) + viewModel.loadThread(threadId) + while (awaitItem() !is ThreadUiState.Success) { + } + viewModel.changeExpanded( + true, + mockStatusViewData(id = "2", inReplyToId = "1", inReplyToAccountId = "1", isDetailed = true, spoilerText = "Test"), + ) - viewModel.changeExpanded( - true, - mockStatusViewData(id = "2", inReplyToId = "1", inReplyToAccountId = "1", isDetailed = true, spoilerText = "Test"), - ) - - runBlocking { assertEquals( ThreadUiState.Success( statusViewData = listOf( @@ -507,23 +501,24 @@ class ViewThreadViewModelTest { detailedStatusPosition = 1, revealButton = RevealButtonState.REVEAL, ), - viewModel.uiState.first(), + expectMostRecentItem(), ) } } @Test - fun `should change content collapsed state`() { - mockSuccessResponses() + fun `should change content collapsed state`() = runTest { + viewModel.uiState.test { + mockSuccessResponses() - viewModel.loadThread(threadId) + viewModel.loadThread(threadId) + while (awaitItem() !is ThreadUiState.Success) { + } + viewModel.changeContentCollapsed( + true, + mockStatusViewData(id = "2", inReplyToId = "1", inReplyToAccountId = "1", isDetailed = true, spoilerText = "Test"), + ) - viewModel.changeContentCollapsed( - true, - mockStatusViewData(id = "2", inReplyToId = "1", inReplyToAccountId = "1", isDetailed = true, spoilerText = "Test"), - ) - - runBlocking { assertEquals( ThreadUiState.Success( statusViewData = listOf( @@ -546,23 +541,24 @@ class ViewThreadViewModelTest { detailedStatusPosition = 1, revealButton = RevealButtonState.REVEAL, ), - viewModel.uiState.first(), + expectMostRecentItem(), ) } } @Test - fun `should change content showing state`() { - mockSuccessResponses() + fun `should change content showing state`() = runTest { + viewModel.uiState.test { + mockSuccessResponses() - viewModel.loadThread(threadId) + viewModel.loadThread(threadId) + while (awaitItem() !is ThreadUiState.Success) { + } + viewModel.changeContentShowing( + true, + mockStatusViewData(id = "2", inReplyToId = "1", inReplyToAccountId = "1", isDetailed = true, spoilerText = "Test"), + ) - viewModel.changeContentShowing( - true, - mockStatusViewData(id = "2", inReplyToId = "1", inReplyToAccountId = "1", isDetailed = true, spoilerText = "Test"), - ) - - runBlocking { assertEquals( ThreadUiState.Success( statusViewData = listOf( @@ -585,7 +581,7 @@ class ViewThreadViewModelTest { detailedStatusPosition = 1, revealButton = RevealButtonState.REVEAL, ), - viewModel.uiState.first(), + expectMostRecentItem(), ) } } diff --git a/app/src/test/java/app/pachli/util/LocaleUtilsTest.kt b/app/src/test/java/app/pachli/util/LocaleUtilsTest.kt index 59aca8928..4800c79bd 100644 --- a/app/src/test/java/app/pachli/util/LocaleUtilsTest.kt +++ b/app/src/test/java/app/pachli/util/LocaleUtilsTest.kt @@ -68,8 +68,8 @@ class LocaleUtilsTest { id = 0, domain = "foo.bar", accessToken = "", - clientId = null, - clientSecret = null, + clientId = "", + clientSecret = "", isActive = true, defaultPostLanguage = configuredLanguages[1].orEmpty(), ), diff --git a/app/src/test/java/app/pachli/util/StatusDisplayOptionsRepositoryTest.kt b/app/src/test/java/app/pachli/util/StatusDisplayOptionsRepositoryTest.kt index 43ac75377..1e4a5369d 100644 --- a/app/src/test/java/app/pachli/util/StatusDisplayOptionsRepositoryTest.kt +++ b/app/src/test/java/app/pachli/util/StatusDisplayOptionsRepositoryTest.kt @@ -25,6 +25,7 @@ import app.pachli.core.data.model.StatusDisplayOptions import app.pachli.core.data.repository.AccountManager import app.pachli.core.data.repository.AccountPreferenceDataStore import app.pachli.core.data.repository.StatusDisplayOptionsRepository +import app.pachli.core.network.di.test.DEFAULT_INSTANCE_V2 import app.pachli.core.network.model.Account import app.pachli.core.network.model.nodeinfo.UnvalidatedJrd import app.pachli.core.network.model.nodeinfo.UnvalidatedNodeInfo @@ -32,8 +33,11 @@ import app.pachli.core.network.retrofit.MastodonApi import app.pachli.core.network.retrofit.NodeInfoApi import app.pachli.core.preferences.PrefKeys import app.pachli.core.preferences.SharedPreferencesRepository +import app.pachli.core.testing.failure import app.pachli.core.testing.rules.MainCoroutineRule -import at.connyduck.calladapter.networkresult.NetworkResult +import app.pachli.core.testing.success +import com.github.michaelbull.result.andThen +import com.github.michaelbull.result.onSuccess import com.google.common.truth.Truth.assertThat import dagger.hilt.android.testing.CustomTestApplication import dagger.hilt.android.testing.HiltAndroidRule @@ -49,6 +53,7 @@ import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.doReturn import org.mockito.kotlin.reset import org.mockito.kotlin.stub @@ -90,13 +95,35 @@ class StatusDisplayOptionsRepositoryTest { private val defaultStatusDisplayOptions = StatusDisplayOptions() + private val account = Account( + id = "1", + localUsername = "username", + username = "username@domain.example", + displayName = "Display Name", + createdAt = Date.from(Instant.now()), + note = "", + url = "", + avatar = "", + header = "", + ) + @Before - fun setup() { + fun setup() = runTest { hilt.inject() + reset(mastodonApi) + mastodonApi.stub { + onBlocking { accountVerifyCredentials(anyOrNull(), anyOrNull()) } doReturn success(account) + onBlocking { getInstanceV2(anyOrNull()) } doReturn success(DEFAULT_INSTANCE_V2) + onBlocking { getLists() } doReturn success(emptyList()) + onBlocking { getCustomEmojis() } doReturn failure() + onBlocking { getContentFilters() } doReturn success(emptyList()) + onBlocking { listAnnouncements(anyOrNull()) } doReturn success(emptyList()) + } + reset(nodeInfoApi) nodeInfoApi.stub { - onBlocking { nodeInfoJrd() } doReturn NetworkResult.success( + onBlocking { nodeInfoJrd() } doReturn success( UnvalidatedJrd( listOf( UnvalidatedJrd.Link( @@ -106,29 +133,20 @@ class StatusDisplayOptionsRepositoryTest { ), ), ) - onBlocking { nodeInfo(any()) } doReturn NetworkResult.success( + onBlocking { nodeInfo(any()) } doReturn success( UnvalidatedNodeInfo(UnvalidatedNodeInfo.Software("mastodon", "4.2.0")), ) } - accountManager.addAccount( + accountManager.verifyAndAddAccount( accessToken = "token", domain = "domain.example", clientId = "id", clientSecret = "secret", oauthScopes = "scopes", - newAccount = Account( - id = "1", - localUsername = "username", - username = "username@domain.example", - displayName = "Display Name", - createdAt = Date.from(Instant.now()), - note = "", - url = "", - avatar = "", - header = "", - ), ) + .andThen { accountManager.setActiveAccount(it) } + .onSuccess { accountManager.refresh(it) } } @Test @@ -152,16 +170,15 @@ class StatusDisplayOptionsRepositoryTest { @Test fun `changing account preference emits correct value`() = runTest { - // Given - openSpoiler is an account-level preference - val initial = statusDisplayOptionsRepository.flow.value.openSpoiler - - // When - accountPreferenceDataStore.putBoolean(PrefKeys.ALWAYS_OPEN_SPOILER, !initial) - - // Then statusDisplayOptionsRepository.flow.test { - advanceUntilIdle() - assertThat(expectMostRecentItem().openSpoiler).isEqualTo(!initial) + // Given - openSpoiler is an account-level preference + val initial = awaitItem().openSpoiler + + // When + accountPreferenceDataStore.putBoolean(PrefKeys.ALWAYS_OPEN_SPOILER, !initial) + + // Then + assertThat(awaitItem().openSpoiler).isEqualTo(!initial) } } @@ -175,25 +192,32 @@ class StatusDisplayOptionsRepositoryTest { assertThat(awaitItem().openSpoiler).isEqualTo(!initial) } + val account = Account( + id = "2", + localUsername = "username2", + username = "username2@domain2.example", + displayName = "Display Name", + createdAt = Date.from(Instant.now()), + note = "", + url = "", + avatar = "", + header = "", + ) + + mastodonApi.stub { + onBlocking { accountVerifyCredentials() } doReturn success(account) + } + // When -- addAccount changes the active account - accountManager.addAccount( + accountManager.verifyAndAddAccount( accessToken = "token", domain = "domain2.example", clientId = "id", clientSecret = "secret", oauthScopes = "scopes", - newAccount = Account( - id = "2", - localUsername = "username2", - username = "username2@domain2.example", - displayName = "Display Name", - createdAt = Date.from(Instant.now()), - note = "", - url = "", - avatar = "", - header = "", - ), ) + .andThen { accountManager.setActiveAccount(it) } + .onSuccess { accountManager.refresh(it) } // Then -- openSpoiler should be reset to the default statusDisplayOptionsRepository.flow.test { diff --git a/build-logic/convention/src/main/kotlin/AndroidRoomConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidRoomConventionPlugin.kt index ad71db657..92066330d 100644 --- a/build-logic/convention/src/main/kotlin/AndroidRoomConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/AndroidRoomConventionPlugin.kt @@ -17,6 +17,7 @@ import app.pachli.libs import com.google.devtools.ksp.gradle.KspExtension +import java.io.File import org.gradle.api.Plugin import org.gradle.api.Project import org.gradle.api.tasks.InputDirectory @@ -25,19 +26,22 @@ import org.gradle.api.tasks.PathSensitivity import org.gradle.kotlin.dsl.configure import org.gradle.kotlin.dsl.dependencies import org.gradle.process.CommandLineArgumentProvider -import java.io.File class AndroidRoomConventionPlugin : Plugin { override fun apply(target: Project) { with(target) { pluginManager.apply("com.google.devtools.ksp") - extensions.configure { - // The schemas directory contains a schema file for each version of the Room database. - // This is required to enable Room auto migrations. - // See https://developer.android.com/reference/kotlin/androidx/room/AutoMigration. - arg(RoomSchemaArgProvider(File(projectDir, "schemas"))) - arg("room.incremental", "true") + // Don't save schemas when applied to :core:testing, as that module uses + // transient, in-memory databases. + if (target.path != ":core:testing") { + extensions.configure { + // The schemas directory contains a schema file for each version of the Room database. + // This is required to enable Room auto migrations. + // See https://developer.android.com/reference/kotlin/androidx/room/AutoMigration. + arg(RoomSchemaArgProvider(File(projectDir, "schemas"))) + arg("room.incremental", "true") + } } dependencies { diff --git a/core/activity/build.gradle.kts b/core/activity/build.gradle.kts index f88b652c1..83c939299 100644 --- a/core/activity/build.gradle.kts +++ b/core/activity/build.gradle.kts @@ -31,9 +31,8 @@ android { dependencies { // BaseActivity exposes AccountManager as an injected property - api(projects.core.database) - api(projects.core.data) + implementation(projects.core.common) implementation(projects.core.designsystem) implementation(projects.core.navigation) @@ -47,6 +46,7 @@ dependencies { // Loading avatars implementation(libs.bundles.glide) + implementation(projects.core.database) // Crash reporting in orange (Pachli Current) builds only orangeImplementation(libs.bundles.acra) diff --git a/core/activity/src/main/kotlin/app/pachli/core/activity/BaseActivity.kt b/core/activity/src/main/kotlin/app/pachli/core/activity/BaseActivity.kt index e4c20410f..0133ce773 100644 --- a/core/activity/src/main/kotlin/app/pachli/core/activity/BaseActivity.kt +++ b/core/activity/src/main/kotlin/app/pachli/core/activity/BaseActivity.kt @@ -36,10 +36,12 @@ import androidx.appcompat.app.AppCompatActivity import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat import androidx.core.view.MenuProvider +import androidx.lifecycle.lifecycleScope import app.pachli.core.activity.extensions.canOverrideActivityTransitions import app.pachli.core.activity.extensions.getTransitionKind import app.pachli.core.activity.extensions.startActivityWithDefaultTransition import app.pachli.core.data.repository.AccountManager +import app.pachli.core.data.repository.Loadable import app.pachli.core.database.model.AccountEntity import app.pachli.core.designsystem.EmbeddedFontFamily import app.pachli.core.designsystem.R as DR @@ -57,6 +59,7 @@ import dagger.hilt.android.EntryPointAccessors.fromApplication import dagger.hilt.components.SingletonComponent import javax.inject.Inject import kotlin.properties.Delegates +import kotlinx.coroutines.launch import timber.log.Timber @AndroidEntryPoint @@ -194,12 +197,24 @@ abstract class BaseActivity : AppCompatActivity(), MenuProvider { } private fun redirectIfNotLoggedIn() { - val account = accountManager.activeAccount - if (account == null) { - val intent = LoginActivityIntent(this) - intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK) - startActivityWithDefaultTransition(intent) - finish() + lifecycleScope.launch { + accountManager.activeAccountFlow.collect { + when (it) { + is Loadable.Loading -> { + /* wait for account to become available */ + } + + is Loadable.Loaded -> { + val account = it.data + if (account == null) { + val intent = LoginActivityIntent(this@BaseActivity) + intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK) + startActivityWithDefaultTransition(intent) + finish() + } + } + } + } } } @@ -219,8 +234,8 @@ abstract class BaseActivity : AppCompatActivity(), MenuProvider { showActiveAccount: Boolean, listener: AccountSelectionListener, ) { - val accounts = accountManager.getAllAccountsOrderedByActive().toMutableList() - val activeAccount = accountManager.activeAccount!! + val accounts = accountManager.accountsOrderedByActive.toMutableList() + val activeAccount = accounts.first() when (accounts.size) { 1 -> { listener.onAccountSelected(activeAccount) @@ -259,25 +274,24 @@ abstract class BaseActivity : AppCompatActivity(), MenuProvider { val openAsText: String? get() { - val accounts = accountManager.getAllAccountsOrderedByActive() + val accounts = accountManager.accountsOrderedByActive return when (accounts.size) { 0, 1 -> null 2 -> { for (account in accounts) { if (account !== accountManager.activeAccount) { - return String.format(getString(R.string.action_open_as), account.fullName) + return getString(R.string.action_open_as, account.fullName) } } null } - else -> String.format(getString(R.string.action_open_as), "…") + + else -> getString(R.string.action_open_as, "…") } } fun openAsAccount(url: String, account: AccountEntity) { - accountManager.setActiveAccount(account.id) - val intent = MainActivityIntent.redirect(this, account.id, url) - startActivity(intent) + startActivity(MainActivityIntent.redirect(this, account.id, url)) finish() } diff --git a/core/data/build.gradle.kts b/core/data/build.gradle.kts index e7d38c33e..4a8ea371e 100644 --- a/core/data/build.gradle.kts +++ b/core/data/build.gradle.kts @@ -30,8 +30,10 @@ android { } dependencies { + // TODO: AccountManager currently exposes AccountEntity which must be re-exported. + api(projects.core.database) + implementation(projects.core.common) - implementation(projects.core.database) implementation(projects.core.model) implementation(projects.core.network) implementation(projects.core.preferences) @@ -44,4 +46,10 @@ dependencies { testImplementation(projects.core.networkTest) testImplementation(libs.bundles.mockito) + + testImplementation(libs.moshi) + testImplementation(libs.moshi.adapters) + ksp(libs.moshi.codegen) + + testImplementation(projects.core.networkTest) } diff --git a/core/data/lint-baseline.xml b/core/data/lint-baseline.xml index f32fed49a..795f3ee86 100644 --- a/core/data/lint-baseline.xml +++ b/core/data/lint-baseline.xml @@ -1,4 +1,4 @@ - + diff --git a/core/data/src/main/kotlin/app/pachli/core/data/di/DataModule.kt b/core/data/src/main/kotlin/app/pachli/core/data/di/DataModule.kt index 10a4b3882..d1e425250 100644 --- a/core/data/src/main/kotlin/app/pachli/core/data/di/DataModule.kt +++ b/core/data/src/main/kotlin/app/pachli/core/data/di/DataModule.kt @@ -17,9 +17,11 @@ package app.pachli.core.data.di +import app.pachli.core.data.repository.ContentFiltersRepository import app.pachli.core.data.repository.ListsRepository -import app.pachli.core.data.repository.NetworkListsRepository import app.pachli.core.data.repository.NetworkSuggestionsRepository +import app.pachli.core.data.repository.OfflineFirstContentFiltersRepository +import app.pachli.core.data.repository.OfflineFirstListRepository import app.pachli.core.data.repository.SuggestionsRepository import dagger.Binds import dagger.Module @@ -29,9 +31,14 @@ import dagger.hilt.components.SingletonComponent @InstallIn(SingletonComponent::class) @Module abstract class DataModule { + @Binds + internal abstract fun bindsContentFiltersRepository( + contentFiltersRepository: OfflineFirstContentFiltersRepository, + ): ContentFiltersRepository + @Binds internal abstract fun bindsListsRepository( - listsRepository: NetworkListsRepository, + listsRepository: OfflineFirstListRepository, ): ListsRepository @Binds diff --git a/core/data/src/main/kotlin/app/pachli/core/data/model/ContentFilter.kt b/core/data/src/main/kotlin/app/pachli/core/data/model/ContentFilter.kt index a84e60ad8..1d5c3fe09 100644 --- a/core/data/src/main/kotlin/app/pachli/core/data/model/ContentFilter.kt +++ b/core/data/src/main/kotlin/app/pachli/core/data/model/ContentFilter.kt @@ -75,8 +75,8 @@ fun FilterKeyword.Companion.from(networkKeyword: NetworkFilterKeyword) = * [v1 Mastodon filter][app.pachli.core.network.model.Filter]. * * There are some restrictions imposed by the v1 filter; - * - it can only have a single entry in the [keywords] list - * - the [title] is identical to the keyword + * - it can only have a single entry in the [ContentFilter.keywords] list + * - the [ContentFilter.title] is identical to the keyword */ fun ContentFilter.Companion.from(filter: NetworkFilterV1) = ContentFilter( id = filter.id, @@ -86,7 +86,7 @@ fun ContentFilter.Companion.from(filter: NetworkFilterV1) = ContentFilter( filterAction = WARN, keywords = listOf( FilterKeyword( - id = filter.id, + id = "0", keyword = filter.phrase, wholeWord = filter.wholeWord, ), diff --git a/core/data/src/main/kotlin/app/pachli/core/data/model/InstanceInfo.kt b/core/data/src/main/kotlin/app/pachli/core/data/model/InstanceInfo.kt index 5908a7b67..cc125240f 100644 --- a/core/data/src/main/kotlin/app/pachli/core/data/model/InstanceInfo.kt +++ b/core/data/src/main/kotlin/app/pachli/core/data/model/InstanceInfo.kt @@ -19,12 +19,15 @@ package app.pachli.core.data.model import app.pachli.core.common.extensions.MiB +// Know that these fields are all used somewhere +// +// Server.Kt also uses v2.configuration.translation.enabled data class InstanceInfo( val maxChars: Int = DEFAULT_CHARACTER_LIMIT, val pollMaxOptions: Int = DEFAULT_MAX_OPTION_COUNT, val pollMaxLength: Int = DEFAULT_MAX_OPTION_LENGTH, val pollMinDuration: Int = DEFAULT_MIN_POLL_DURATION, - val pollMaxDuration: Int = DEFAULT_MAX_POLL_DURATION, + val pollMaxDuration: Long = DEFAULT_MAX_POLL_DURATION, val charactersReservedPerUrl: Int = DEFAULT_CHARACTERS_RESERVED_PER_URL, val videoSizeLimit: Long = DEFAULT_VIDEO_SIZE_LIMIT, val imageSizeLimit: Long = DEFAULT_IMAGE_SIZE_LIMIT, @@ -40,7 +43,7 @@ data class InstanceInfo( const val DEFAULT_MAX_OPTION_COUNT = 4 const val DEFAULT_MAX_OPTION_LENGTH = 50 const val DEFAULT_MIN_POLL_DURATION = 300 - const val DEFAULT_MAX_POLL_DURATION = 604800 + const val DEFAULT_MAX_POLL_DURATION = 604800L val DEFAULT_VIDEO_SIZE_LIMIT = 40L.MiB val DEFAULT_IMAGE_SIZE_LIMIT = 10L.MiB @@ -51,5 +54,24 @@ data class InstanceInfo( const val DEFAULT_MAX_MEDIA_ATTACHMENTS = 4 const val DEFAULT_MAX_ACCOUNT_FIELDS = 4 + + fun from(info: app.pachli.core.database.model.InstanceInfoEntity): InstanceInfo { + return InstanceInfo( + maxChars = info.maxPostCharacters ?: DEFAULT_CHARACTER_LIMIT, + pollMaxOptions = info.maxPollOptions ?: DEFAULT_MAX_OPTION_COUNT, + pollMaxLength = info.maxPollOptionLength ?: DEFAULT_MAX_OPTION_COUNT, + pollMinDuration = info.minPollDuration ?: DEFAULT_MIN_POLL_DURATION, + pollMaxDuration = info.maxPollDuration ?: DEFAULT_MAX_POLL_DURATION, + charactersReservedPerUrl = info.charactersReservedPerUrl ?: DEFAULT_CHARACTERS_RESERVED_PER_URL, + videoSizeLimit = info.videoSizeLimit ?: DEFAULT_VIDEO_SIZE_LIMIT, + imageSizeLimit = info.imageSizeLimit ?: DEFAULT_IMAGE_SIZE_LIMIT, + imageMatrixLimit = info.imageMatrixLimit ?: DEFAULT_IMAGE_MATRIX_LIMIT, + maxMediaAttachments = info.maxMediaAttachments ?: DEFAULT_MAX_MEDIA_ATTACHMENTS, + maxFields = info.maxFields ?: DEFAULT_MAX_ACCOUNT_FIELDS, + maxFieldNameLength = info.maxFieldNameLength, + maxFieldValueLength = info.maxFieldValueLength, + version = info.version ?: "(Pachli defaults)", + ) + } } } diff --git a/core/data/src/main/kotlin/app/pachli/core/data/model/MastodonList.kt b/core/data/src/main/kotlin/app/pachli/core/data/model/MastodonList.kt new file mode 100644 index 000000000..b0963422f --- /dev/null +++ b/core/data/src/main/kotlin/app/pachli/core/data/model/MastodonList.kt @@ -0,0 +1,60 @@ +/* + * Copyright 2024 Pachli Association + * + * This file is a part of Pachli. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Pachli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Pachli; if not, + * see . + */ + +package app.pachli.core.data.model + +import app.pachli.core.database.model.MastodonListEntity +import app.pachli.core.network.model.MastoList +import app.pachli.core.network.model.UserListRepliesPolicy + +data class MastodonList( + val accountId: Long, + val listId: String, + val title: String, + val repliesPolicy: UserListRepliesPolicy, + val exclusive: Boolean, +) { + fun entity() = MastodonListEntity( + accountId = accountId, + listId = listId, + title = title, + repliesPolicy = repliesPolicy, + exclusive = exclusive, + ) + companion object { + fun from(entity: MastodonListEntity) = MastodonList( + accountId = entity.accountId, + listId = entity.listId, + title = entity.title, + repliesPolicy = entity.repliesPolicy, + exclusive = entity.exclusive, + ) + + fun from(entities: List) = entities.map { from(it) } + + fun make(pachliAccountId: Long, networkList: MastoList) = MastodonList( + accountId = pachliAccountId, + listId = networkList.id, + title = networkList.title, + repliesPolicy = networkList.repliesPolicy, + exclusive = networkList.exclusive ?: false, + ) + + fun make(pachliAccountId: Long, networkLists: List) = + networkLists.map { make(pachliAccountId, it) } + } +} diff --git a/core/network/src/main/kotlin/app/pachli/core/network/Server.kt b/core/data/src/main/kotlin/app/pachli/core/data/model/Server.kt similarity index 92% rename from core/network/src/main/kotlin/app/pachli/core/network/Server.kt rename to core/data/src/main/kotlin/app/pachli/core/data/model/Server.kt index 23f3b1ec1..b2d3394da 100644 --- a/core/network/src/main/kotlin/app/pachli/core/network/Server.kt +++ b/core/data/src/main/kotlin/app/pachli/core/data/model/Server.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023 Pachli Association + * Copyright 2024 Pachli Association * * This file is a part of Pachli. * @@ -15,12 +15,16 @@ * see . */ -package app.pachli.core.network +package app.pachli.core.data.model import androidx.annotation.VisibleForTesting import androidx.annotation.VisibleForTesting.Companion.PRIVATE import app.pachli.core.common.PachliError +import app.pachli.core.data.model.Server.Error.UnparseableVersion +import app.pachli.core.database.model.InstanceInfoEntity +import app.pachli.core.database.model.ServerEntity import app.pachli.core.model.NodeInfo +import app.pachli.core.model.ServerCapabilities import app.pachli.core.model.ServerKind import app.pachli.core.model.ServerKind.AKKOMA import app.pachli.core.model.ServerKind.FEDIBIRD @@ -54,7 +58,7 @@ import app.pachli.core.model.ServerOperation.ORG_JOINMASTODON_SEARCH_QUERY_IS_SE import app.pachli.core.model.ServerOperation.ORG_JOINMASTODON_SEARCH_QUERY_LANGUAGE import app.pachli.core.model.ServerOperation.ORG_JOINMASTODON_STATUSES_SCHEDULED import app.pachli.core.model.ServerOperation.ORG_JOINMASTODON_STATUSES_TRANSLATE -import app.pachli.core.network.Server.Error.UnparseableVersion +import app.pachli.core.network.R import app.pachli.core.network.model.InstanceV1 import app.pachli.core.network.model.InstanceV2 import com.github.michaelbull.result.Ok @@ -75,7 +79,7 @@ import kotlin.collections.set data class Server( val kind: ServerKind, val version: Version, - private val capabilities: Map = emptyMap(), + val capabilities: ServerCapabilities = emptyMap(), ) { /** * @return true if the server supports the given operation at the given minimum version @@ -121,6 +125,38 @@ data class Server( Server(serverKind, version, capabilities) } + /** + * Constructs a capabilities map from its [NodeInfo] and [InstanceInfoEntity] details. + */ + fun from(software: NodeInfo.Software, instanceInfoEntity: InstanceInfoEntity): Result = binding { + val serverKind = ServerKind.from(software) + val version = parseVersionString(serverKind, software.version).bind() + val capabilities = capabilitiesFromServerVersion(serverKind, version) + + when (serverKind) { + GLITCH, HOMETOWN, MASTODON -> { + if (instanceInfoEntity.enabledTranslation) { + capabilities[ORG_JOINMASTODON_STATUSES_TRANSLATE] = when { + version >= "4.2.0".toVersion() -> "1.1.0".toVersion() + else -> "1.0.0".toVersion() + } + } + } + + else -> { + /* Nothing to do */ + } + } + + Server(serverKind, version, capabilities) + } + + fun from(entity: ServerEntity) = Server( + kind = entity.serverKind, + version = entity.version, + capabilities = entity.capabilities, + ) + /** * Parse a [version] string from the given [serverKind] in to a [Version]. */ diff --git a/core/data/src/main/kotlin/app/pachli/core/data/repository/AccountManager.kt b/core/data/src/main/kotlin/app/pachli/core/data/repository/AccountManager.kt index d300a7eaa..aa8b3975d 100644 --- a/core/data/src/main/kotlin/app/pachli/core/data/repository/AccountManager.kt +++ b/core/data/src/main/kotlin/app/pachli/core/data/repository/AccountManager.kt @@ -1,5 +1,5 @@ /* - * Copyright 2018 Conny Duck + * Copyright 2024 Pachli Association * * This file is a part of Pachli. * @@ -17,231 +17,226 @@ package app.pachli.core.data.repository +import app.pachli.core.common.PachliError import app.pachli.core.common.di.ApplicationScope +import app.pachli.core.data.R +import app.pachli.core.data.model.MastodonList +import app.pachli.core.data.model.Server +import app.pachli.core.data.repository.LogoutError.NoActiveAccount +import app.pachli.core.data.repository.ServerRepository.Error.GetNodeInfo +import app.pachli.core.data.repository.ServerRepository.Error.GetWellKnownNodeInfo +import app.pachli.core.data.repository.ServerRepository.Error.UnsupportedSchema +import app.pachli.core.data.repository.ServerRepository.Error.ValidateNodeInfo import app.pachli.core.database.dao.AccountDao -import app.pachli.core.database.dao.RemoteKeyDao +import app.pachli.core.database.dao.AnnouncementsDao +import app.pachli.core.database.dao.InstanceDao +import app.pachli.core.database.di.TransactionProvider import app.pachli.core.database.model.AccountEntity +import app.pachli.core.database.model.AnnouncementEntity +import app.pachli.core.database.model.EmojisEntity +import app.pachli.core.database.model.InstanceInfoEntity +import app.pachli.core.database.model.ServerEntity +import app.pachli.core.model.NodeInfo +import app.pachli.core.model.Timeline import app.pachli.core.network.model.Account import app.pachli.core.network.model.Status import app.pachli.core.network.retrofit.InstanceSwitchAuthInterceptor -import app.pachli.core.preferences.SharedPreferencesRepository -import app.pachli.core.preferences.ShowSelfUsername +import app.pachli.core.network.retrofit.MastodonApi +import app.pachli.core.network.retrofit.NodeInfoApi +import app.pachli.core.network.retrofit.apiresult.ApiError +import com.github.michaelbull.result.Err +import com.github.michaelbull.result.Ok +import com.github.michaelbull.result.Result +import com.github.michaelbull.result.coroutines.binding.binding +import com.github.michaelbull.result.getOrElse +import com.github.michaelbull.result.map +import com.github.michaelbull.result.mapBoth +import com.github.michaelbull.result.mapError +import com.github.michaelbull.result.onSuccess +import com.github.michaelbull.result.orElse import java.util.Locale import javax.inject.Inject import javax.inject.Singleton import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.async +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import timber.log.Timber +// Note to self: This is doing dual duty as a repository and as a collection of +// use cases, and should be refactored along those lines. + +/** Errors that can occur when setting the active account. */ +sealed interface SetActiveAccountError : PachliError { + /** + * Suggested account to fallback to on failure. + * + * The caller may want to present this as an option to the user if logging + * in fails. + * + * May be null if there was no previous active account. + */ + val fallbackAccount: AccountEntity? + + /** + * The requested account could not be found in the local database. Should + * never happen. + * + * @param fallbackAccount See [SetActiveAccountError.fallbackAccount] + * @param accountId ID of the account that could not be found. + */ + data class AccountDoesNotExist( + override val fallbackAccount: AccountEntity?, + val accountId: Long, + ) : SetActiveAccountError { + override val resourceId = R.string.account_manager_error_account_does_not_exist + override val formatArgs = null + override val cause = null + } + + /** + * An API error occurred while logging in. + * + * @param fallbackAccount See [SetActiveAccountError.fallbackAccount] + * @param wantedAccount The account entity that could not be made active. + */ + data class Api( + override val fallbackAccount: AccountEntity?, + val wantedAccount: AccountEntity, + val apiError: ApiError, + ) : SetActiveAccountError, PachliError by apiError + + /** + * Catch-all for unexpected exceptions when logging in. + * + * @param fallbackAccount See [SetActiveAccountError.fallbackAccount] + * @param wantedAccount The account entity that could not be made active. + * @param throwable Throwable that caused the error + */ + data class Unexpected( + override val fallbackAccount: AccountEntity?, + val wantedAccount: AccountEntity, + val throwable: Throwable, + ) : SetActiveAccountError { + override val resourceId = R.string.account_manager_error_unexpected + override val formatArgs: Array = arrayOf(throwable.localizedMessage ?: "unknown") + override val cause = null + } +} + +sealed interface RefreshAccountError : PachliError { + @JvmInline + value class General(private val pachliError: PachliError) : RefreshAccountError, PachliError by pachliError +} + +/** Errors that can occur logging out. */ +sealed interface LogoutError : PachliError { + data object NoActiveAccount : LogoutError { + override val resourceId = R.string.account_manager_error_no_active_account + override val formatArgs = null + override val cause = null + } + + /** An API call failed during the logout process. */ + @JvmInline + value class Api(private val apiError: ApiError) : LogoutError, PachliError by apiError + + @JvmInline + value class SetActiveAccount(private val error: SetActiveAccountError) : LogoutError, PachliError by error +} + @Singleton class AccountManager @Inject constructor( + private val transactionProvider: TransactionProvider, + private val mastodonApi: MastodonApi, + private val nodeInfoApi: NodeInfoApi, private val accountDao: AccountDao, - private val remoteKeyDao: RemoteKeyDao, - private val sharedPreferencesRepository: SharedPreferencesRepository, + private val instanceDao: InstanceDao, + private val contentFiltersRepository: ContentFiltersRepository, + private val listsRepository: ListsRepository, + private val announcementsDao: AnnouncementsDao, private val instanceSwitchAuthInterceptor: InstanceSwitchAuthInterceptor, @ApplicationScope private val externalScope: CoroutineScope, ) { - private val _activeAccountFlow = MutableStateFlow(null) - val activeAccountFlow = _activeAccountFlow.asStateFlow() + @Deprecated("Caller should use getPachliAccountFlow with a specific account ID") + val activeAccountFlow: StateFlow> = + accountDao.getActiveAccountFlow() + .distinctUntilChanged() + .map { Loadable.Loaded(it) } + .stateIn(externalScope, SharingStarted.Eagerly, Loadable.Loading()) - @Volatile - var activeAccount: AccountEntity? = null - private set(value) { - field = value - instanceSwitchAuthInterceptor.credentials = value?.let { - InstanceSwitchAuthInterceptor.Credentials( - accessToken = it.accessToken, - domain = it.domain, - ) + /** The active account, or null if there is no active account. */ + @Deprecated("Caller should use getPachliAccountFlow with a specific account ID") + val activeAccount: AccountEntity? + get() { + return when (val loadable = activeAccountFlow.value) { + is Loadable.Loading -> null + is Loadable.Loaded -> loadable.data } - externalScope.launch { _activeAccountFlow.emit(value) } } - var accounts: MutableList = mutableListOf() - private set + /** All logged in accounts. */ + val accountsFlow = accountDao.loadAllFlow().stateIn(externalScope, SharingStarted.Eagerly, emptyList()) + + val accounts: List + get() = accountsFlow.value + + private val accountsOrderedByActiveFlow = accountDao.getAccountsOrderedByActive() + .stateIn(externalScope, SharingStarted.Eagerly, emptyList()) + + val accountsOrderedByActive: List + get() = accountsOrderedByActiveFlow.value + + @Deprecated("Caller should use getPachliAccountFlow with a specific account ID") + val activePachliAccountFlow = accountDao.getActivePachliAccountFlow() + .filterNotNull() + .map { PachliAccount.make(it) } init { - accounts = accountDao.loadAll().toMutableList() + // Ensure InstanceSwitchAuthInterceptor is initially set with the credentials of + // the active account, otherwise network requests that happen after a resume + // (if setActiveAccount is not called) have no credentials. + externalScope.launch { + accountDao.loadAll().firstOrNull { it.isActive }?.let { + instanceSwitchAuthInterceptor.credentials = + InstanceSwitchAuthInterceptor.Credentials( + accessToken = it.accessToken, + domain = it.domain, + ) + } + } - activeAccount = accounts.find { acc -> acc.isActive } - ?: accounts.firstOrNull()?.also { acc -> acc.isActive = true } + externalScope.launch { + listsRepository.getListsFlow().collect { lists -> + val listsById = lists.groupBy { it.accountId } + listsById.forEach { (pachliAccountId, group) -> + newTabPreferences(pachliAccountId, group)?.let { + setTabPreferences(pachliAccountId, it) + } + } + } + } } - /** - * 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 - */ - fun addAccount( - accessToken: String, - domain: String, - clientId: String, - clientSecret: String, - oauthScopes: String, - newAccount: Account, - ): Long { - activeAccount?.let { - it.isActive = false - Timber.d("addAccount: saving account with id %d", it.id) - - accountDao.insertOrReplace(it) - } - // check if this is a relogin with an existing account, if yes update it, otherwise create a new one - val existingAccountIndex = accounts.indexOfFirst { account -> - domain == account.domain && newAccount.id == account.accountId - } - val newAccountEntity = if (existingAccountIndex != -1) { - accounts[existingAccountIndex].copy( - accessToken = accessToken, - clientId = clientId, - clientSecret = clientSecret, - oauthScopes = oauthScopes, - isActive = true, - ).also { accounts[existingAccountIndex] = it } + fun getPachliAccountFlow(pachliAccountId: Long): Flow { + val accountFlow = if (pachliAccountId == -1L) { + accountDao.getActiveAccountId().flatMapLatest { + accountDao.getPachliAccountFlow(it) + } } else { - val maxAccountId = accounts.maxByOrNull { it.id }?.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, - ).also { accounts.add(it) } + accountDao.getPachliAccountFlow(pachliAccountId) } - activeAccount = newAccountEntity - updateActiveAccount(newAccount) - return newAccountEntity.id - } - - /** - * Saves an already known account to the database. - * New accounts must be created with [addAccount] - * @param account the account to save - */ - fun saveAccount(account: AccountEntity) { - if (account.id == 0L) { - Timber.e("Trying to save account with ID = 0, ignoring") - return - } - - // Work around saveAccount() being called after account deletion - // For example: - // - Have two accounts, A and B, signed in with A, looking at home timeline for A - // - Log out of A. This triggers deletion of account A from the database - // - Shortly afterwards the timeline activity/fragment ends, and it tries to save - // the visible ID back to the database, which creates the AccountEntity record - // that was just deleted, but in a partial state. - if (accounts.find { it.id == account.id } == null) { - Timber.e("Trying to save account with ID = %d which does not exist, ignoring", account.id) - return - } - - Timber.d("saveAccount: saving account with id %d", account.id) - accountDao.insertOrReplace(account) - } - - /** - * Logs the current account out by deleting all data of the account. - * @return the new active account, or null if no other account was found - */ - fun logActiveAccountOut(): AccountEntity? { - return activeAccount?.let { account -> - - account.logout() - - accounts.remove(account) - accountDao.delete(account) - remoteKeyDao.delete(account.id) - - if (accounts.size > 0) { - accounts[0].isActive = true - activeAccount = accounts[0] - Timber.d("logActiveAccountOut: saving account with id %d", accounts[0].id) - accountDao.insertOrReplace(accounts[0]) - } else { - activeAccount = null - } - activeAccount - } - } - - /** - * updates the current account with new information from the mastodon api - * and saves it in the database - * @param account the [Account] object returned from the api - */ - fun updateActiveAccount(account: Account) { - activeAccount?.let { - it.accountId = account.id - it.username = account.username - it.displayName = account.name - it.profilePictureUrl = account.avatar - it.defaultPostPrivacy = account.source?.privacy ?: Status.Visibility.PUBLIC - it.defaultPostLanguage = account.source?.language.orEmpty() - it.defaultMediaSensitivity = account.source?.sensitive ?: false - it.emojis = account.emojis.orEmpty() - it.locked = account.locked - - Timber.d("updateActiveAccount: saving account with id %d", it.id) - accountDao.insertOrReplace(it) - } - } - - /** - * changes the active account - * @param accountId the database id of the new active account - */ - fun setActiveAccount(accountId: Long) { - val newActiveAccount = accounts.find { (id) -> - id == accountId - } ?: return // invalid accountId passed, do nothing - - activeAccount?.let { - Timber.d("setActiveAccount: saving account with id %d", it.id) - it.isActive = false - saveAccount(it) - } - - activeAccount = newActiveAccount - - activeAccount?.let { - it.isActive = true - accountDao.insertOrReplace(it) - } - } - - /** - * @return an immutable list of all accounts in the database with the active account first - */ - fun getAllAccountsOrderedByActive(): List { - val accountsCopy = accounts.toMutableList() - accountsCopy.sortWith { l, r -> - when { - l.isActive && !r.isActive -> -1 - r.isActive && !l.isActive -> 1 - else -> 0 - } - } - - return accountsCopy - } - - /** - * @return True if at least one account has Android notifications enabled - */ - fun areAndroidNotificationsEnabled(): Boolean { - return accounts.any { it.notificationsEnabled } + return accountFlow.map { it?.let { PachliAccount.make(it) } } } /** @@ -249,6 +244,8 @@ class AccountManager @Inject constructor( * @param accountId the id of the account * @return the requested account or null if it was not found */ + // TODO: Should be `suspend`, accessed through a ViewModel, but not all the + // calling code has been converted yet. fun getAccountById(accountId: Long): AccountEntity? { return accounts.find { (id) -> id == accountId @@ -256,13 +253,501 @@ class AccountManager @Inject constructor( } /** - * @return true if the name of the currently-selected account should be displayed in UIs + * Verifies the account has valid credentials according to the remote server + * and adds it to the local database if it does. + * + * Does not make 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 */ - fun shouldDisplaySelfUsername(): Boolean { - return when (sharedPreferencesRepository.showSelfUsername) { - ShowSelfUsername.ALWAYS -> true - ShowSelfUsername.DISAMBIGUATE -> accounts.size > 1 - ShowSelfUsername.NEVER -> false + suspend fun verifyAndAddAccount( + accessToken: String, + domain: String, + clientId: String, + clientSecret: String, + oauthScopes: String, + ): Result { + val networkAccount = mastodonApi.accountVerifyCredentials( + domain = domain, + auth = "Bearer $accessToken", + ).getOrElse { return Err(it) }.body + + return transactionProvider { + val existingAccount = accountDao.getAccountByIdAndDomain( + networkAccount.id, + domain, + ) + + val newAccount = existingAccount?.copy( + accessToken = accessToken, + clientId = clientId, + clientSecret = clientSecret, + oauthScopes = oauthScopes, + ) ?: AccountEntity( + id = 0L, + domain = domain.lowercase(Locale.ROOT), + accessToken = accessToken, + clientId = clientId, + clientSecret = clientSecret, + oauthScopes = oauthScopes, + isActive = true, + accountId = networkAccount.id, + ) + + Timber.d("addAccount: upsert account id: %d, isActive: %s", newAccount.id, newAccount.isActive) + val newId = accountDao.upsert(newAccount) + return@transactionProvider Ok(newId) } } + + suspend fun clearPushNotificationData(accountId: Long) { + setPushNotificationData(accountId, "", "", "", "", "") + } + + /** + * Logs out the active account by deleting all data of the account, sets the + * next active account, and returns the active account. + * + * @return The new active account, or null if there are no more accounts (the + * user logged out of the last account). + */ + suspend fun logActiveAccountOut(): Result { + return transactionProvider { + val activeAccount = accountDao.getActiveAccount() ?: return@transactionProvider Err(NoActiveAccount) + Timber.d("logout: Logging out %d", activeAccount.id) + + // Deleting credentials so they cannot be used again + accountDao.clearLoginCredentials(activeAccount.id) + + accountDao.delete(activeAccount) + + val accounts = accountDao.loadAll() + + val newActiveAccount = accounts.firstOrNull() ?: return@transactionProvider Ok(null) + + // Have to set the active account here. If you don't there's a brief + // period where there's no active account, and BaseActivity.redirectIfNotLoggedIn + // sends the user to the log in screen. + return@transactionProvider setActiveAccount(newActiveAccount.id) + .mapError { LogoutError.SetActiveAccount(it) } + } + } + + /** + * Changes the active account. + * + * Does not refresh the account, call [refresh] for that. + * + * @param accountId the database id of the new active account + * @return The account entity for the new active account, or an error. + */ + suspend fun setActiveAccount(accountId: Long): Result { + /** Wrapper to pass an API error out of the transaction. */ + data class ApiErrorException(val apiError: ApiError) : Exception() + + /** Account to fallback to if switching fails. */ + var fallbackAccount: AccountEntity? = null + + /** Account we're trying to switch to. */ + var accountEntity: AccountEntity? = null + + return try { + transactionProvider { + Timber.d("setActiveAcccount(%d)", accountId) + + // Handle "-1" as the accountId. + val previousActiveAccount = accountDao.getActiveAccount() + val newActiveAccount = if (accountId == -1L) { + previousActiveAccount + } else { + accountDao.getAccountById(accountId) + } + + // Fall back to either the previous account (if known), or the first account + // that isn't this one. + fallbackAccount = previousActiveAccount + ?: accountDao.loadAll().firstOrNull { it.id != accountId } + + if (newActiveAccount == null) { + Timber.d("Account %d not in database", accountId) + return@transactionProvider Err( + SetActiveAccountError.AccountDoesNotExist( + fallbackAccount, + accountId, + ), + ) + } + + accountEntity = newActiveAccount + + // Fetch data from the API, updating the account as necessary. + // If this fails an exception is thrown to cancel the transaction. + // + // Note: Can't adjust InstanceSwitchAuthInterceptor before this, + // because if this call fails the change would need to be undone as + // part of cancelling the transaction. That's why it's modified at the + // very end of this block. + val account = mastodonApi.accountVerifyCredentials( + domain = newActiveAccount.domain, + auth = newActiveAccount.authHeader, + ).getOrElse { throw ApiErrorException(it) }.body + + accountDao.clearActiveAccount() + + val finalAccount = newActiveAccount.copy( + isActive = true, + accountId = account.id, + username = account.username, + displayName = account.name, + profilePictureUrl = account.avatar, + profileHeaderPictureUrl = account.header, + defaultPostPrivacy = account.source?.privacy ?: Status.Visibility.PUBLIC, + defaultPostLanguage = account.source?.language.orEmpty(), + defaultMediaSensitivity = account.source?.sensitive ?: false, + emojis = account.emojis.orEmpty(), + locked = account.locked, + ) + + Timber.d("setActiveAccount: saving id: %d, isActive: %s", finalAccount.id, finalAccount.isActive) + accountDao.update(finalAccount) + + // Now safe to update InstanceSwitchAuthInterceptor. + Timber.d("Updating instanceSwitchAuthInterceptor with credentials for %s", newActiveAccount.fullName) + instanceSwitchAuthInterceptor.credentials = + InstanceSwitchAuthInterceptor.Credentials( + accessToken = newActiveAccount.accessToken, + domain = newActiveAccount.domain, + ) + + return@transactionProvider Ok(finalAccount) + } + } catch (e: ApiErrorException) { + Err(SetActiveAccountError.Api(fallbackAccount, accountEntity!!, e.apiError)) + } catch (e: Throwable) { + currentCoroutineContext().ensureActive() + Err(SetActiveAccountError.Unexpected(fallbackAccount, accountEntity!!, e)) + } + } + + /** + * Refreshes the local data for [account] from remote sources. + * + * @return Unit if the refresh completed successfully, or the error. + */ + // TODO: Protect this with a mutex? + suspend fun refresh(account: AccountEntity): Result = binding { + // Kick off network fetches that can happen in parallel because they do not + // depend on one another. + val deferNodeInfo = externalScope.async { + fetchNodeInfo().mapError { RefreshAccountError.General(it) } + } + + val deferInstanceInfo = externalScope.async { + fetchInstanceInfo(account.domain) + .mapError { RefreshAccountError.General(it) } + .onSuccess { instanceDao.upsert(it) } + } + + val deferEmojis = externalScope.async { + mastodonApi.getCustomEmojis() + .mapError { RefreshAccountError.General(it) } + .onSuccess { instanceDao.upsert(EmojisEntity(accountId = account.id, emojiList = it.body)) } + } + + val deferAnnouncements = externalScope.async { + mastodonApi.listAnnouncements(false) + .mapError { RefreshAccountError.General(it) } + .map { + it.body.map { + AnnouncementEntity( + accountId = account.id, + announcementId = it.id, + announcement = it, + ) + } + } + .onSuccess { + transactionProvider { + announcementsDao.deleteAllForAccount(account.id) + announcementsDao.upsert(it) + } + } + } + + val nodeInfo = deferNodeInfo.await().bind() + val instanceInfo = deferInstanceInfo.await().bind() + + // Create the server info so it can used for both server capabilities and filters. + // + // Can't use ServerRespository here because it depends on AccountManager. + // TODO: Break that dependency, re-write ServerRepository to be offline-first. + Server.from(nodeInfo.software, instanceInfo) + .mapError { RefreshAccountError.General(it) } + .onSuccess { + instanceDao.upsert( + ServerEntity( + accountId = account.id, + serverKind = it.kind, + version = it.version, + capabilities = it.capabilities, + ), + ) + } + .bind() + + externalScope.async { contentFiltersRepository.refresh(account.id) }.await() + .mapError { RefreshAccountError.General(it) }.bind() + + deferEmojis.await().bind() + + externalScope.async { listsRepository.refresh(account.id) }.await() + .mapError { RefreshAccountError.General(it) }.bind() + + // Ignore errors when fetching announcements, they're non-fatal. + // TODO: Add a capability for announcements. + deferAnnouncements.await().orElse { Ok(emptyList()) }.bind() + } + + /** + * Updates [pachliAccountId] with data from [newAccount]. + * + * Updates the values: + * + * - [displayName][AccountEntity.displayName] + * - [profilePictureUrl][AccountEntity.profilePictureUrl] + * - [profileHeaderPictureUrl][AccountEntity.profileHeaderPictureUrl] + * - [locked][AccountEntity.locked] + */ + suspend fun updateAccount(pachliAccountId: Long, newAccount: Account) { + transactionProvider { + val existingAccount = accountDao.getAccountById(pachliAccountId) ?: return@transactionProvider + val updatedAccount = existingAccount.copy( + displayName = newAccount.displayName ?: existingAccount.displayName, + profilePictureUrl = newAccount.avatar, + profileHeaderPictureUrl = newAccount.header, + locked = newAccount.locked, + ) + accountDao.upsert(updatedAccount) + } + } + + /** + * Determines the user's tab preferences when lists are loaded. + * + * The user may have added one or more lists to tabs. If they have then: + * + * - A list-in-a-tab might have been deleted + * - A list-in-a-tab might have been renamed + * + * Handle both of those scenarios. + * + * @param pachliAccountId The account to check + * @param lists The account's latest lists + * @return A list of new tab preferences for [pachliAccountId], or null if there are no changes. + */ + private suspend fun newTabPreferences(pachliAccountId: Long, lists: List): List? { + val map = lists.associateBy { it.listId } + val account = accountDao.getAccountById(pachliAccountId) ?: return null + val oldTabPreferences = account.tabPreferences + var changed = false + val newTabPreferences = buildList { + for (oldPref in oldTabPreferences) { + if (oldPref !is Timeline.UserList) { + add(oldPref) + continue + } + + // List has been deleted? Don't add this pref, + // record there's been a change, and move on to the + // next one. + if (oldPref.listId !in map) { + changed = true + continue + } + + // Title changed? Update the title in the pref and + // add it. + if (oldPref.title != map[oldPref.listId]?.title) { + changed = true + add(oldPref.copy(title = map[oldPref.listId]?.title!!)) + continue + } + + add(oldPref) + } + } + return if (changed) newTabPreferences else null + } + + // Based on ServerRepository.getServer(). This can be removed when AccountManager + // can use ServerRepository directly. + private suspend fun fetchNodeInfo(): Result = binding { + // Fetch the /.well-known/nodeinfo document + val nodeInfoJrd = nodeInfoApi.nodeInfoJrd() + .mapError { GetWellKnownNodeInfo(it) }.bind().body + + // Find a link to a schema we can parse, prefering newer schema versions + var nodeInfoUrlResult: Result = Err(UnsupportedSchema) + for (link in nodeInfoJrd.links.sortedByDescending { it.rel }) { + if (SCHEMAS.contains(link.rel)) { + nodeInfoUrlResult = Ok(link.href) + break + } + } + + val nodeInfoUrl = nodeInfoUrlResult.bind() + + Timber.d("Loading node info from %s", nodeInfoUrl) + val nodeInfo = nodeInfoApi.nodeInfo(nodeInfoUrl).mapBoth( + { it.body.validate().mapError { ValidateNodeInfo(nodeInfoUrl, it) } }, + { Err(GetNodeInfo(nodeInfoUrl, it)) }, + ).bind() + + return@binding nodeInfo + } + + // TODO: Maybe rename InstanceInfoEntity to ServerLimits or something like that, since that's + // what it records. + private suspend fun fetchInstanceInfo(domain: String): Result { + // TODO: InstanceInfoEntity needs to gain support for recording translation + return mastodonApi.getInstanceV2() + .map { InstanceInfoEntity.make(domain, it.body) } + .orElse { + mastodonApi.getInstanceV1().map { InstanceInfoEntity.make(domain, it.body) } + } + } + + /** + * @return True if at least one account has Android notifications enabled + */ + // TODO: Should be `suspend`, accessed through a ViewModel, but not all the + // calling code has been converted yet. + fun areAndroidNotificationsEnabled(): Boolean { + return accounts.any { it.notificationsEnabled } + } + + suspend fun setAlwaysShowSensitiveMedia(accountId: Long, value: Boolean) { + accountDao.setAlwaysShowSensitiveMedia(accountId, value) + } + + suspend fun setAlwaysOpenSpoiler(accountId: Long, value: Boolean) { + accountDao.setAlwaysOpenSpoiler(accountId, value) + } + + suspend fun setMediaPreviewEnabled(accountId: Long, value: Boolean) { + accountDao.setMediaPreviewEnabled(accountId, value) + } + + suspend fun setTabPreferences(accountId: Long, value: List) { + Timber.d("setTabPreferences: %d, %s", accountId, value) + accountDao.setTabPreferences(accountId, value) + } + + suspend fun setNotificationMarkerId(accountId: Long, value: String) { + accountDao.setNotificationMarkerId(accountId, value) + } + + suspend fun setNotificationsFilter(accountId: Long, value: String) { + accountDao.setNotificationsFilter(accountId, value) + } + + suspend fun setLastNotificationId(accountId: Long, value: String) { + accountDao.setLastNotificationId(accountId, value) + } + + suspend fun setPushNotificationData( + accountId: Long, + unifiedPushUrl: String, + pushServerKey: String, + pushAuth: String, + pushPrivKey: String, + pushPubKey: String, + ) { + accountDao.setPushNotificationData( + accountId, + unifiedPushUrl, + pushServerKey, + pushAuth, + pushPrivKey, + pushPubKey, + ) + } + + fun setDefaultPostPrivacy(accountId: Long, value: Status.Visibility) { + accountDao.setDefaultPostPrivacy(accountId, value) + } + + fun setDefaultMediaSensitivity(accountId: Long, value: Boolean) { + accountDao.setDefaultMediaSensitivity(accountId, value) + } + + fun setDefaultPostLanguage(accountId: Long, value: String) { + accountDao.setDefaultPostLanguage(accountId, value) + } + + fun setNotificationsEnabled(accountId: Long, value: Boolean) { + accountDao.setNotificationsEnabled(accountId, value) + } + + fun setNotificationsFollowed(accountId: Long, value: Boolean) { + accountDao.setNotificationsFollowed(accountId, value) + } + + fun setNotificationsFollowRequested(accountId: Long, value: Boolean) { + accountDao.setNotificationsFollowRequested(accountId, value) + } + + fun setNotificationsReblogged(accountId: Long, value: Boolean) { + accountDao.setNotificationsReblogged(accountId, value) + } + + fun setNotificationsFavorited(accountId: Long, value: Boolean) { + accountDao.setNotificationsFavorited(accountId, value) + } + + fun setNotificationsPolls(accountId: Long, value: Boolean) { + accountDao.setNotificationsPolls(accountId, value) + } + + fun setNotificationsSubscriptions(accountId: Long, value: Boolean) { + accountDao.setNotificationsSubscriptions(accountId, value) + } + + fun setNotificationsSignUps(accountId: Long, value: Boolean) { + accountDao.setNotificationsSignUps(accountId, value) + } + + fun setNotificationsUpdates(accountId: Long, value: Boolean) { + accountDao.setNotificationsUpdates(accountId, value) + } + + fun setNotificationsReports(accountId: Long, value: Boolean) { + accountDao.setNotificationsReports(accountId, value) + } + + fun setNotificationSound(accountId: Long, value: Boolean) { + accountDao.setNotificationSound(accountId, value) + } + + fun setNotificationVibration(accountId: Long, value: Boolean) { + accountDao.setNotificationVibration(accountId, value) + } + + fun setNotificationLight(accountId: Long, value: Boolean) { + accountDao.setNotificationLight(accountId, value) + } + + suspend fun setLastVisibleHomeTimelineStatusId(accountId: Long, value: String?) { + Timber.d("setLastVisibleHomeTimelineStatusId: %d, %s", accountId, value) + accountDao.setLastVisibleHomeTimelineStatusId(accountId, value) + } + + // -- Announcements + suspend fun deleteAnnouncement(accountId: Long, announcementId: String) { + announcementsDao.deleteForAccount(accountId, announcementId) + } } diff --git a/core/data/src/main/kotlin/app/pachli/core/data/repository/AccountPreferenceDataStore.kt b/core/data/src/main/kotlin/app/pachli/core/data/repository/AccountPreferenceDataStore.kt index ef86e3542..2d5afd21d 100644 --- a/core/data/src/main/kotlin/app/pachli/core/data/repository/AccountPreferenceDataStore.kt +++ b/core/data/src/main/kotlin/app/pachli/core/data/repository/AccountPreferenceDataStore.kt @@ -33,10 +33,12 @@ class AccountPreferenceDataStore @Inject constructor( val changes = MutableSharedFlow>() override fun getBoolean(key: String, defValue: Boolean): Boolean { + val account = accountManager.activeAccount ?: return defValue + return when (key) { - PrefKeys.ALWAYS_SHOW_SENSITIVE_MEDIA -> accountManager.activeAccount!!.alwaysShowSensitiveMedia - PrefKeys.ALWAYS_OPEN_SPOILER -> accountManager.activeAccount!!.alwaysOpenSpoiler - PrefKeys.MEDIA_PREVIEW_ENABLED -> accountManager.activeAccount!!.mediaPreviewEnabled + PrefKeys.ALWAYS_SHOW_SENSITIVE_MEDIA -> account.alwaysShowSensitiveMedia + PrefKeys.ALWAYS_OPEN_SPOILER -> account.alwaysOpenSpoiler + PrefKeys.MEDIA_PREVIEW_ENABLED -> account.mediaPreviewEnabled else -> defValue } } @@ -44,15 +46,13 @@ class AccountPreferenceDataStore @Inject constructor( override fun putBoolean(key: String, value: Boolean) { val account = accountManager.activeAccount!! - when (key) { - PrefKeys.ALWAYS_SHOW_SENSITIVE_MEDIA -> account.alwaysShowSensitiveMedia = value - PrefKeys.ALWAYS_OPEN_SPOILER -> account.alwaysOpenSpoiler = value - PrefKeys.MEDIA_PREVIEW_ENABLED -> account.mediaPreviewEnabled = value - } - - accountManager.saveAccount(account) - externalScope.launch { + when (key) { + PrefKeys.ALWAYS_SHOW_SENSITIVE_MEDIA -> accountManager.setAlwaysShowSensitiveMedia(account.id, value) + PrefKeys.ALWAYS_OPEN_SPOILER -> accountManager.setAlwaysOpenSpoiler(account.id, value) + PrefKeys.MEDIA_PREVIEW_ENABLED -> accountManager.setMediaPreviewEnabled(account.id, value) + } + changes.emit(Pair(key, value)) } } diff --git a/core/data/src/main/kotlin/app/pachli/core/data/repository/ContentFiltersRepository.kt b/core/data/src/main/kotlin/app/pachli/core/data/repository/ContentFiltersRepository.kt index 17be2e81f..52b0a7820 100644 --- a/core/data/src/main/kotlin/app/pachli/core/data/repository/ContentFiltersRepository.kt +++ b/core/data/src/main/kotlin/app/pachli/core/data/repository/ContentFiltersRepository.kt @@ -19,68 +19,12 @@ package app.pachli.core.data.repository import androidx.annotation.VisibleForTesting import app.pachli.core.common.PachliError -import app.pachli.core.common.di.ApplicationScope import app.pachli.core.data.R -import app.pachli.core.data.model.from -import app.pachli.core.data.repository.ContentFiltersError.CreateContentFilterError -import app.pachli.core.data.repository.ContentFiltersError.DeleteContentFilterError -import app.pachli.core.data.repository.ContentFiltersError.GetContentFilterError -import app.pachli.core.data.repository.ContentFiltersError.GetContentFiltersError -import app.pachli.core.data.repository.ContentFiltersError.ServerDoesNotFilter -import app.pachli.core.data.repository.ContentFiltersError.ServerRepositoryError -import app.pachli.core.data.repository.ContentFiltersError.UpdateContentFilterError import app.pachli.core.model.ContentFilter -import app.pachli.core.model.ContentFilterVersion -import app.pachli.core.model.FilterAction -import app.pachli.core.model.FilterContext -import app.pachli.core.model.FilterKeyword import app.pachli.core.model.NewContentFilter -import app.pachli.core.model.ServerOperation.ORG_JOINMASTODON_FILTERS_CLIENT -import app.pachli.core.model.ServerOperation.ORG_JOINMASTODON_FILTERS_SERVER -import app.pachli.core.network.Server -import app.pachli.core.network.model.FilterAction as NetworkFilterAction -import app.pachli.core.network.model.FilterContext as NetworkFilterContext -import app.pachli.core.network.retrofit.MastodonApi -import com.github.michaelbull.result.Err -import com.github.michaelbull.result.Ok +import app.pachli.core.network.retrofit.apiresult.ApiResponse import com.github.michaelbull.result.Result -import com.github.michaelbull.result.andThen -import com.github.michaelbull.result.coroutines.binding.binding -import com.github.michaelbull.result.map -import com.github.michaelbull.result.mapError -import com.github.michaelbull.result.mapResult -import io.github.z4kn4fein.semver.constraints.toConstraint -import javax.inject.Inject -import javax.inject.Singleton -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.async -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.stateIn - -/** - * Represents a collection of edits to make to an existing content filter. - * - * @param id ID of the content filter to be changed - * @param title New title, null if the title should not be changed - * @param contexts New contexts, null if the contexts should not be changed - * @param expiresIn New expiresIn, -1 if the expiry time should not be changed - * @param filterAction New action, null if the action should not be changed - * @param keywordsToAdd One or more keywords to add to the content filter, null if none to add - * @param keywordsToDelete One or more keywords to delete from the content filter, null if none to delete - * @param keywordsToModify One or more keywords to modify in the content filter, null if none to modify - */ -data class ContentFilterEdit( - val id: String, - val title: String? = null, - val contexts: Collection? = null, - val expiresIn: Int = -1, - val filterAction: FilterAction? = null, - val keywordsToAdd: List? = null, - val keywordsToDelete: List? = null, - val keywordsToModify: List? = null, -) +import kotlinx.coroutines.flow.Flow /** Errors that can be returned from this repository. */ sealed interface ContentFiltersError : PachliError { @@ -117,239 +61,69 @@ sealed interface ContentFiltersError : PachliError { value class DeleteContentFilterError(private val error: PachliError) : ContentFiltersError, PachliError by error } -// Hack, so that FilterModel can know whether this is V1 or V2 content filters. -// See usage in: -// - TimelineViewModel.getFilters() -// - NotificationsViewModel.getFilters() -// Need to think about a better way to do this. -data class ContentFilters( - val contentFilters: List, - val version: ContentFilterVersion, -) +interface ContentFiltersRepository { + /** @return Known content filters for [pachliAccountId]. */ + suspend fun getContentFilters(pachliAccountId: Long): ContentFilters -/** Repository for filter information */ -@Singleton -class ContentFiltersRepository @Inject constructor( - @ApplicationScope private val externalScope: CoroutineScope, - private val mastodonApi: MastodonApi, - serverRepository: ServerRepository, -) { - /** Flow where emissions trigger fresh loads from the server. */ - private val reload = MutableSharedFlow(replay = 1).apply { tryEmit(Unit) } - - private lateinit var server: Result + /** @return Flow of known content filters for [pachliAccountId]. */ + fun getContentFiltersFlow( + pachliAccountId: Long, + ): Flow /** - * Flow of filters from the server. Updates when: - * - * - A new value is emitted to [reload] - * - The active server changes - * - * The [Ok] value is either `null` if the filters have not yet been loaded, or - * the most recent loaded filters. + * @return The content filter with [contentFilterId] in [pachliAccountId] or + * null if no such content filter exists. */ - val contentFilters = reload.combine(serverRepository.flow) { _, server -> - this.server = server.mapError { ServerRepositoryError(it) } - server - .mapError { GetContentFiltersError(it) } - .andThen { getContentFilters(it) } - } - .stateIn(externalScope, SharingStarted.Lazily, Ok(null)) - - suspend fun reload() = reload.emit(Unit) - - /** Get a specific content filter from the server, by [filterId]. */ - suspend fun getContentFilter(filterId: String): Result = binding { - val server = server.bind() - - when { - server.canFilterV2() -> mastodonApi.getFilter(filterId).map { ContentFilter.from(it.body) } - server.canFilterV1() -> mastodonApi.getFilterV1(filterId).map { ContentFilter.from(it.body) } - else -> Err(ServerDoesNotFilter) - }.mapError { GetContentFilterError(it) }.bind() - } - - /** Get the current set of content filters. */ - private suspend fun getContentFilters(server: Server): Result = binding { - when { - server.canFilterV2() -> mastodonApi.getContentFilters().map { - ContentFilters( - contentFilters = it.body.map { ContentFilter.from(it) }, - version = ContentFilterVersion.V2, - ) - } - - server.canFilterV1() -> mastodonApi.getContentFiltersV1().map { - ContentFilters( - contentFilters = it.body.map { ContentFilter.from(it) }, - version = ContentFilterVersion.V1, - ) - } - else -> Err(ServerDoesNotFilter) - }.mapError { GetContentFiltersError(it) }.bind() - } + suspend fun getContentFilter( + pachliAccountId: Long, + contentFilterId: String, + ): ContentFilter? /** - * Creates the filter in [filter]. + * Refreshes the list of content filters for [pachliAccountId] and emits into + * the flow returned by [getContentFiltersFlow]. * - * Reloads filters whether or not an error occured. - * - * @return The newly created [ContentFilter], or a [ContentFiltersError]. + * @return The latest set of content filters, or an error. */ - suspend fun createContentFilter(filter: NewContentFilter): Result = binding { - val server = server.bind() - - val expiresInSeconds = when (val expiresIn = filter.expiresIn) { - 0 -> "" - else -> expiresIn.toString() - } - - externalScope.async { - when { - server.canFilterV2() -> { - mastodonApi.createFilter(filter).map { - ContentFilter.from(it.body) - } - } - - server.canFilterV1() -> { - val networkContexts = - filter.contexts.map { NetworkFilterContext.from(it) }.toSet() - filter.toNewContentFilterV1().mapResult { - mastodonApi.createFilterV1( - phrase = it.phrase, - context = networkContexts, - irreversible = it.irreversible, - wholeWord = it.wholeWord, - expiresInSeconds = expiresInSeconds, - ) - }.map { - ContentFilter.from(it.last().body) - } - } - - else -> Err(ServerDoesNotFilter) - }.mapError { CreateContentFilterError(it) } - .also { reload.emit(Unit) } - }.await().bind() - } + suspend fun refresh( + pachliAccountId: Long, + ): Result /** - * Updates [originalContentFilter] on the server by applying the changes in - * [contentFilterEdit]. + * Creates a new content filter. * - * Reloads filters whether or not an error occured. + * @param pachliAccountId Account the new content filter is saved to. + * @param filter The new content filter. + * @return The newly created filter, or an error. */ - suspend fun updateContentFilter(originalContentFilter: ContentFilter, contentFilterEdit: ContentFilterEdit): Result = binding { - val server = server.bind() - - // Modify - val expiresInSeconds = when (val expiresIn = contentFilterEdit.expiresIn) { - -1 -> null - 0 -> "" - else -> expiresIn.toString() - } - - externalScope.async { - when { - server.canFilterV2() -> { - // Retrofit can't send a form where there are multiple parameters - // with the same ID (https://github.com/square/retrofit/issues/1324) - // so it's not possible to update keywords - - if (contentFilterEdit.title != null || - contentFilterEdit.contexts != null || - contentFilterEdit.filterAction != null || - expiresInSeconds != null - ) { - val networkContexts = contentFilterEdit.contexts?.map { - NetworkFilterContext.from(it) - }?.toSet() - val networkAction = contentFilterEdit.filterAction?.let { - NetworkFilterAction.from(it) - } - - mastodonApi.updateFilter( - id = contentFilterEdit.id, - title = contentFilterEdit.title, - contexts = networkContexts, - filterAction = networkAction, - expiresInSeconds = expiresInSeconds, - ) - } else { - Ok(originalContentFilter) - } - .andThen { - contentFilterEdit.keywordsToDelete.orEmpty().mapResult { - mastodonApi.deleteFilterKeyword(it.id) - } - } - .andThen { - contentFilterEdit.keywordsToModify.orEmpty().mapResult { - mastodonApi.updateFilterKeyword( - it.id, - it.keyword, - it.wholeWord, - ) - } - } - .andThen { - contentFilterEdit.keywordsToAdd.orEmpty().mapResult { - mastodonApi.addFilterKeyword( - contentFilterEdit.id, - it.keyword, - it.wholeWord, - ) - } - } - .andThen { - mastodonApi.getFilter(originalContentFilter.id) - } - .map { ContentFilter.from(it.body) } - } - server.canFilterV1() -> { - val networkContexts = contentFilterEdit.contexts?.map { - NetworkFilterContext.from(it) - }?.toSet() ?: originalContentFilter.contexts.map { - NetworkFilterContext.from( - it, - ) - } - mastodonApi.updateFilterV1( - id = contentFilterEdit.id, - phrase = contentFilterEdit.keywordsToModify?.firstOrNull()?.keyword ?: originalContentFilter.keywords.first().keyword, - wholeWord = contentFilterEdit.keywordsToModify?.firstOrNull()?.wholeWord, - contexts = networkContexts, - irreversible = false, - expiresInSeconds = expiresInSeconds, - ).map { ContentFilter.from(it.body) } - } - else -> { - Err(ServerDoesNotFilter) - } - }.mapError { UpdateContentFilterError(it) } - .also { reload() } - }.await().bind() - } + suspend fun createContentFilter( + pachliAccountId: Long, + filter: NewContentFilter, + ): Result /** - * Deletes the content filter identified by [filterId] from the server. + * Updates an existing content filter. * - * Reloads content filters whether or not an error occured. + * @param pachliAccountId Account that owns the content filter to update. + * @param originalContentFilter + * @param contentFilterEdit + * @return */ - suspend fun deleteContentFilter(filterId: String): Result = binding { - val server = server.bind() + suspend fun updateContentFilter( + pachliAccountId: Long, + originalContentFilter: ContentFilter, + contentFilterEdit: ContentFilterEdit, + ): Result - externalScope.async { - when { - server.canFilterV2() -> mastodonApi.deleteFilter(filterId) - server.canFilterV1() -> mastodonApi.deleteFilterV1(filterId) - else -> Err(ServerDoesNotFilter) - }.mapError { DeleteContentFilterError(it) } - .also { reload() } - }.await().bind() - } + /** + * Deletes an existing content filter. + * + * @param pachliAccountId Account that owns the content filters. + * @param contentFilterId ID of the content filter to delete + * @return Unit, or an error. + */ + suspend fun deleteContentFilter( + pachliAccountId: Long, + contentFilterId: String, + ): Result, ContentFiltersError> } - -private fun Server.canFilterV1() = this.can(ORG_JOINMASTODON_FILTERS_CLIENT, ">=1.0.0".toConstraint()) -private fun Server.canFilterV2() = this.can(ORG_JOINMASTODON_FILTERS_SERVER, ">=1.0.0".toConstraint()) diff --git a/core/data/src/main/kotlin/app/pachli/core/data/repository/InstanceInfoRepository.kt b/core/data/src/main/kotlin/app/pachli/core/data/repository/InstanceInfoRepository.kt index 750703768..b5208e646 100644 --- a/core/data/src/main/kotlin/app/pachli/core/data/repository/InstanceInfoRepository.kt +++ b/core/data/src/main/kotlin/app/pachli/core/data/repository/InstanceInfoRepository.kt @@ -20,32 +20,23 @@ package app.pachli.core.data.repository import androidx.annotation.VisibleForTesting import app.pachli.core.common.di.ApplicationScope import app.pachli.core.data.model.InstanceInfo -import app.pachli.core.data.model.InstanceInfo.Companion.DEFAULT_CHARACTERS_RESERVED_PER_URL import app.pachli.core.data.model.InstanceInfo.Companion.DEFAULT_CHARACTER_LIMIT -import app.pachli.core.data.model.InstanceInfo.Companion.DEFAULT_IMAGE_MATRIX_LIMIT -import app.pachli.core.data.model.InstanceInfo.Companion.DEFAULT_IMAGE_SIZE_LIMIT -import app.pachli.core.data.model.InstanceInfo.Companion.DEFAULT_MAX_ACCOUNT_FIELDS -import app.pachli.core.data.model.InstanceInfo.Companion.DEFAULT_MAX_MEDIA_ATTACHMENTS -import app.pachli.core.data.model.InstanceInfo.Companion.DEFAULT_MAX_OPTION_COUNT -import app.pachli.core.data.model.InstanceInfo.Companion.DEFAULT_MAX_OPTION_LENGTH -import app.pachli.core.data.model.InstanceInfo.Companion.DEFAULT_MAX_POLL_DURATION -import app.pachli.core.data.model.InstanceInfo.Companion.DEFAULT_MIN_POLL_DURATION -import app.pachli.core.data.model.InstanceInfo.Companion.DEFAULT_VIDEO_SIZE_LIMIT import app.pachli.core.database.dao.InstanceDao import app.pachli.core.database.model.AccountEntity import app.pachli.core.database.model.EmojisEntity import app.pachli.core.database.model.InstanceInfoEntity import app.pachli.core.network.model.Emoji import app.pachli.core.network.retrofit.MastodonApi -import at.connyduck.calladapter.networkresult.fold -import at.connyduck.calladapter.networkresult.getOrElse -import at.connyduck.calladapter.networkresult.onSuccess +import com.github.michaelbull.result.mapBoth +import com.github.michaelbull.result.onSuccess import javax.inject.Inject import javax.inject.Singleton import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.distinctUntilChangedBy +import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import timber.log.Timber @@ -84,9 +75,12 @@ class InstanceInfoRepository @Inject constructor( init { externalScope.launch { - accountManager.activeAccountFlow.collect { account -> - reload(account) - } + accountManager.activeAccountFlow + .filterIsInstance>() + .distinctUntilChangedBy { it.data?.id } + .collect { loadable -> + reload(loadable.data) + } } } @@ -105,7 +99,7 @@ class InstanceInfoRepository @Inject constructor( Timber.d("Fetching instance info for %s", account.domain) _instanceInfo.value = getInstanceInfo(account.domain) - _emojis.value = getEmojis(account.domain) + _emojis.value = getEmojis(account.id) } /** @@ -113,13 +107,17 @@ class InstanceInfoRepository @Inject constructor( * 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. */ - private suspend fun getEmojis(domain: String): List = withContext(Dispatchers.IO) { - api.getCustomEmojis() - .onSuccess { emojiList -> instanceDao.upsert(EmojisEntity(domain, emojiList)) } - .getOrElse { throwable -> - Timber.w(throwable, "failed to load custom emojis, falling back to cache") - instanceDao.getEmojiInfo(domain)?.emojiList.orEmpty() - } + private suspend fun getEmojis(accountId: Long): List = withContext(Dispatchers.IO) { + return@withContext api.getCustomEmojis().mapBoth( + { emojiList -> + instanceDao.upsert(EmojisEntity(accountId, emojiList.body)) + emojiList.body + }, + { error -> + Timber.w(error.throwable, "failed to load custom emojis, falling back to cache") + instanceDao.getEmojiInfo(accountId)?.emojiList.orEmpty() + }, + ) } /** @@ -128,56 +126,27 @@ class InstanceInfoRepository @Inject constructor( * Never throws, returns defaults of vanilla Mastodon in case of error. */ private suspend fun getInstanceInfo(domain: String): InstanceInfo { - return api.getInstanceV1() - .fold( - { instance -> - val instanceEntity = InstanceInfoEntity( - instance = domain, - maximumTootCharacters = instance.configuration.statuses.maxCharacters ?: instance.maxTootChars ?: DEFAULT_CHARACTER_LIMIT, - maxPollOptions = instance.configuration.polls.maxOptions, - maxPollOptionLength = instance.configuration.polls.maxCharactersPerOption, - minPollDuration = instance.configuration.polls.minExpiration, - maxPollDuration = instance.configuration.polls.maxExpiration, - charactersReservedPerUrl = instance.configuration.statuses.charactersReservedPerUrl, - version = instance.version, - videoSizeLimit = instance.configuration.mediaAttachments.videoSizeLimit, - imageSizeLimit = instance.configuration.mediaAttachments.imageSizeLimit, - imageMatrixLimit = instance.configuration.mediaAttachments.imageMatrixLimit, - maxMediaAttachments = instance.configuration.statuses.maxMediaAttachments, - maxFields = instance.pleroma?.metadata?.fieldLimits?.maxFields, - maxFieldNameLength = instance.pleroma?.metadata?.fieldLimits?.nameLength, - maxFieldValueLength = instance.pleroma?.metadata?.fieldLimits?.valueLength, - ) - try { - instanceDao.upsert(instanceEntity) - } catch (_: Exception) { } - instanceEntity - }, - { throwable -> - Timber.w(throwable, "failed to instance, falling back to cache and default values") - try { - instanceDao.getInstanceInfo(domain) - } catch (_: Exception) { - null - } - }, - ).let { instanceInfo: InstanceInfoEntity? -> - InstanceInfo( - maxChars = instanceInfo?.maximumTootCharacters ?: DEFAULT_CHARACTER_LIMIT, - pollMaxOptions = instanceInfo?.maxPollOptions ?: DEFAULT_MAX_OPTION_COUNT, - pollMaxLength = instanceInfo?.maxPollOptionLength ?: DEFAULT_MAX_OPTION_LENGTH, - pollMinDuration = instanceInfo?.minPollDuration ?: DEFAULT_MIN_POLL_DURATION, - pollMaxDuration = instanceInfo?.maxPollDuration ?: DEFAULT_MAX_POLL_DURATION, - charactersReservedPerUrl = instanceInfo?.charactersReservedPerUrl ?: DEFAULT_CHARACTERS_RESERVED_PER_URL, - videoSizeLimit = instanceInfo?.videoSizeLimit ?: DEFAULT_VIDEO_SIZE_LIMIT, - imageSizeLimit = instanceInfo?.imageSizeLimit ?: DEFAULT_IMAGE_SIZE_LIMIT, - imageMatrixLimit = instanceInfo?.imageMatrixLimit ?: DEFAULT_IMAGE_MATRIX_LIMIT, - maxMediaAttachments = instanceInfo?.maxMediaAttachments ?: DEFAULT_MAX_MEDIA_ATTACHMENTS, - maxFields = instanceInfo?.maxFields ?: DEFAULT_MAX_ACCOUNT_FIELDS, - maxFieldNameLength = instanceInfo?.maxFieldNameLength, - maxFieldValueLength = instanceInfo?.maxFieldValueLength, - version = instanceInfo?.version, - ) - } + api.getInstanceV1().onSuccess { result -> + val instance = result.body + val instanceEntity = InstanceInfoEntity( + instance = domain, + maxPostCharacters = instance.configuration.statuses.maxCharacters ?: instance.maxTootChars ?: DEFAULT_CHARACTER_LIMIT, + maxPollOptions = instance.configuration.polls.maxOptions, + maxPollOptionLength = instance.configuration.polls.maxCharactersPerOption, + minPollDuration = instance.configuration.polls.minExpiration, + maxPollDuration = instance.configuration.polls.maxExpiration, + charactersReservedPerUrl = instance.configuration.statuses.charactersReservedPerUrl, + version = instance.version, + videoSizeLimit = instance.configuration.mediaAttachments.videoSizeLimit, + imageSizeLimit = instance.configuration.mediaAttachments.imageSizeLimit, + imageMatrixLimit = instance.configuration.mediaAttachments.imageMatrixLimit, + maxMediaAttachments = instance.configuration.statuses.maxMediaAttachments, + maxFields = instance.pleroma?.metadata?.fieldLimits?.maxFields, + maxFieldNameLength = instance.pleroma?.metadata?.fieldLimits?.nameLength, + maxFieldValueLength = instance.pleroma?.metadata?.fieldLimits?.valueLength, + ) + instanceDao.upsert(instanceEntity) + } + return instanceDao.getInstanceInfo(domain)?.let { InstanceInfo.from(it) } ?: InstanceInfo() } } diff --git a/core/data/src/main/kotlin/app/pachli/core/data/repository/ListsRepository.kt b/core/data/src/main/kotlin/app/pachli/core/data/repository/ListsRepository.kt index e83f8c9d5..d4c42b6b9 100644 --- a/core/data/src/main/kotlin/app/pachli/core/data/repository/ListsRepository.kt +++ b/core/data/src/main/kotlin/app/pachli/core/data/repository/ListsRepository.kt @@ -18,18 +18,13 @@ package app.pachli.core.data.repository import app.pachli.core.common.PachliError -import app.pachli.core.network.model.MastoList +import app.pachli.core.data.model.MastodonList import app.pachli.core.network.model.TimelineAccount import app.pachli.core.network.model.UserListRepliesPolicy import app.pachli.core.network.retrofit.apiresult.ApiError import com.github.michaelbull.result.Result import java.text.Collator -import kotlinx.coroutines.flow.StateFlow - -sealed interface Lists { - data object Loading : Lists - data class Loaded(val lists: List) : Lists -} +import kotlinx.coroutines.flow.Flow /** Marker for errors that include the ID of the list */ interface HasListId { @@ -60,78 +55,113 @@ interface ListsError : PachliError { } interface ListsRepository { - val lists: StateFlow> + /** @return Known lists for [pachliAccountId]. */ + fun getLists(pachliAccountId: Long): Flow> - /** Make an API call to refresh [lists] */ - fun refresh() + /** @return All known lists for all accounts. */ + fun getListsFlow(): Flow> /** - * Create a new list + * Refresh lists for [pachliAccountId]. * - * @param title The new lists title - * @param exclusive True if the list is exclusive - * @return Details of the new list if successfuly, or an error + * @return Latests lists, or an error. */ - suspend fun createList(title: String, exclusive: Boolean, repliesPolicy: UserListRepliesPolicy): Result + suspend fun refresh(pachliAccountId: Long): Result, ListsError.Retrieve> /** - * Edit an existing list. + * Creates a new list * - * @param listId ID of the list to edit - * @param title New title of the list - * @param exclusive New exclusive vale for the list - * @return Amended list, or an error + * @param pachliAccountId Account that will own the new list. + * @param title Title for the new list. + * @param exclusive True if the new list is exclusive. + * @return Details of the new list if successfuly, or an error. */ - suspend fun editList(listId: String, title: String, exclusive: Boolean, repliesPolicy: UserListRepliesPolicy): Result + suspend fun createList( + pachliAccountId: Long, + title: String, + exclusive: Boolean, + repliesPolicy: UserListRepliesPolicy, + ): Result /** - * Delete an existing list + * Updates an existing list. * - * @param listId ID of the list to delete + * @param pachliAccountId Account that owns the list to update. + * @param listId ID of the list to update. + * @param title New title of the list. + * @param exclusive New exclusive value for the list. + * @return Amended list, or an error. + */ + suspend fun updateList( + pachliAccountId: Long, + listId: String, + title: String, + exclusive: Boolean, + repliesPolicy: UserListRepliesPolicy, + ): Result + + /** + * Deletes an existing list + * + * @param list The list to delete * @return A successful result, or an error */ - suspend fun deleteList(listId: String): Result + suspend fun deleteList(list: MastodonList): Result /** - * Fetch the lists with [accountId] as a member + * Fetches the lists with [accountId] as a member * * @param accountId ID of the account to search for * @result List of Mastodon lists the account is a member of, or an error */ - suspend fun getListsWithAccount(accountId: String): Result, ListsError.GetListsWithAccount> + suspend fun getListsWithAccount( + pachliAccountId: Long, + accountId: String, + ): Result, ListsError.GetListsWithAccount> /** - * Fetch the members of a list + * Fetches the members of a list * * @param listId ID of the list to fetch membership for * @return List of [TimelineAccount] that are members of the list, or an error */ - suspend fun getAccountsInList(listId: String): Result, ListsError.GetAccounts> + suspend fun getAccountsInList( + pachliAccountId: Long, + listId: String, + ): Result, ListsError.GetAccounts> /** - * Add one or more accounts to a list + * Adds one or more accounts to a list * * @param listId ID of the list to add accounts to * @param accountIds IDs of the accounts to add * @return A successful result, or an error */ - suspend fun addAccountsToList(listId: String, accountIds: List): Result + suspend fun addAccountsToList( + pachliAccountId: Long, + listId: String, + accountIds: List, + ): Result /** - * Remove one or more accounts from a list + * Removes one or more accounts from a list * * @param listId ID of the list to remove accounts from * @param accountIds IDs of the accounts to remove * @return A successful result, or an error */ - suspend fun deleteAccountsFromList(listId: String, accountIds: List): Result + suspend fun deleteAccountsFromList( + pachliAccountId: Long, + listId: String, + accountIds: List, + ): Result companion object { /** * Locale-aware comparator for lists. Case-insenstive comparison by * the list's title. */ - val compareByListTitle: Comparator = compareBy( + val compareByListTitle: Comparator = compareBy( Collator.getInstance().apply { strength = Collator.SECONDARY }, ) { it.title } } diff --git a/core/data/src/main/kotlin/app/pachli/core/data/repository/Loadable.kt b/core/data/src/main/kotlin/app/pachli/core/data/repository/Loadable.kt new file mode 100644 index 000000000..2bc8a96c0 --- /dev/null +++ b/core/data/src/main/kotlin/app/pachli/core/data/repository/Loadable.kt @@ -0,0 +1,31 @@ +/* + * Copyright 2024 Pachli Association + * + * This file is a part of Pachli. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Pachli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Pachli; if not, + * see . + */ + +package app.pachli.core.data.repository + +/** + * Generic interface for loadable content. + * + * Does not represent a failure state, use [Result][Result] + * for that. + */ +// Note: Experimental for the moment. +sealed interface Loadable { + /** Data is loading from a remote source. */ + class Loading() : Loadable + data class Loaded(val data: T) : Loadable +} diff --git a/core/data/src/main/kotlin/app/pachli/core/data/repository/NetworkListsRepository.kt b/core/data/src/main/kotlin/app/pachli/core/data/repository/NetworkListsRepository.kt deleted file mode 100644 index ef08c33dc..000000000 --- a/core/data/src/main/kotlin/app/pachli/core/data/repository/NetworkListsRepository.kt +++ /dev/null @@ -1,167 +0,0 @@ -/* - * Copyright 2024 Pachli Association - * - * This file is a part of Pachli. - * - * This program is free software; you can redistribute it and/or modify it under the terms of the - * GNU General Public License as published by the Free Software Foundation; either version 3 of the - * License, or (at your option) any later version. - * - * Pachli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even - * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General - * Public License for more details. - * - * You should have received a copy of the GNU General Public License along with Pachli; if not, - * see . - */ - -package app.pachli.core.data.repository - -import app.pachli.core.common.di.ApplicationScope -import app.pachli.core.data.repository.ListsError.Create -import app.pachli.core.data.repository.ListsError.Delete -import app.pachli.core.data.repository.ListsError.GetListsWithAccount -import app.pachli.core.data.repository.ListsError.Retrieve -import app.pachli.core.data.repository.ListsError.Update -import app.pachli.core.model.Timeline -import app.pachli.core.network.model.MastoList -import app.pachli.core.network.model.TimelineAccount -import app.pachli.core.network.model.UserListRepliesPolicy -import app.pachli.core.network.retrofit.MastodonApi -import com.github.michaelbull.result.Ok -import com.github.michaelbull.result.Result -import com.github.michaelbull.result.coroutines.binding.binding -import com.github.michaelbull.result.mapEither -import com.github.michaelbull.result.mapError -import javax.inject.Inject -import javax.inject.Singleton -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.async -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch - -@Singleton -class NetworkListsRepository @Inject constructor( - @ApplicationScope private val externalScope: CoroutineScope, - private val api: MastodonApi, - private val accountManager: AccountManager, -) : ListsRepository { - private val _lists = MutableStateFlow>(Ok(Lists.Loading)) - override val lists: StateFlow> get() = _lists.asStateFlow() - - init { - externalScope.launch { accountManager.activeAccountFlow.collect { refresh() } } - } - - override fun refresh() { - externalScope.launch { - _lists.value = Ok(Lists.Loading) - _lists.value = api.getLists() - .mapEither( - { - updateTabPreferences(it.body.associateBy { it.id }) - Lists.Loaded(it.body) - }, - { Retrieve(it) }, - ) - } - } - - /** - * Updates the user's tab preferences when lists are loaded. - * - * The user may have added one or more lists to tabs. If they have then: - * - * - A list-in-a-tab might have been deleted - * - A list-in-a-tab might have been renamed - * - * Handle both of those scenarios. - * - * @param lists Map of listId -> [MastoList] - */ - private fun updateTabPreferences(lists: Map) { - val account = accountManager.activeAccount ?: return - val oldTabPreferences = account.tabPreferences - var changed = false - val newTabPreferences = buildList { - for (oldPref in oldTabPreferences) { - if (oldPref !is Timeline.UserList) { - add(oldPref) - continue - } - - // List has been deleted? Don't add this pref, - // record there's been a change, and move on to the - // next one. - if (oldPref.listId !in lists) { - changed = true - continue - } - - // Title changed? Update the title in the pref and - // add it. - if (oldPref.title != lists[oldPref.listId]?.title) { - changed = true - add( - oldPref.copy( - title = lists[oldPref.listId]?.title!!, - ), - ) - continue - } - - add(oldPref) - } - } - if (changed) { - account.tabPreferences = newTabPreferences - accountManager.saveAccount(account) - } - } - - override suspend fun createList(title: String, exclusive: Boolean, repliesPolicy: UserListRepliesPolicy): Result = binding { - externalScope.async { - api.createList(title, exclusive, repliesPolicy).mapError { Create(it) }.bind().run { - refresh() - body - } - }.await() - } - - override suspend fun editList(listId: String, title: String, exclusive: Boolean, repliesPolicy: UserListRepliesPolicy): Result = binding { - externalScope.async { - api.updateList(listId, title, exclusive, repliesPolicy).mapError { Update(it) }.bind().run { - refresh() - body - } - }.await() - } - - override suspend fun deleteList(listId: String): Result = binding { - externalScope.async { - api.deleteList(listId).mapError { Delete(it) }.bind().run { refresh() } - }.await() - } - - override suspend fun getListsWithAccount(accountId: String): Result, GetListsWithAccount> = binding { - api.getListsIncludesAccount(accountId).mapError { GetListsWithAccount(accountId, it) }.bind().body - } - - override suspend fun getAccountsInList(listId: String): Result, ListsError.GetAccounts> = binding { - api.getAccountsInList(listId, 0).mapError { ListsError.GetAccounts(listId, it) }.bind().body - } - - override suspend fun addAccountsToList(listId: String, accountIds: List): Result = binding { - externalScope.async { - api.addAccountToList(listId, accountIds).mapError { ListsError.AddAccounts(listId, it) }.bind() - }.await() - } - - override suspend fun deleteAccountsFromList(listId: String, accountIds: List): Result = binding { - externalScope.async { - api.deleteAccountFromList(listId, accountIds).mapError { ListsError.DeleteAccounts(listId, it) }.bind() - }.await() - } -} diff --git a/core/data/src/main/kotlin/app/pachli/core/data/repository/OfflineFirstContentFiltersRepository.kt b/core/data/src/main/kotlin/app/pachli/core/data/repository/OfflineFirstContentFiltersRepository.kt new file mode 100644 index 000000000..9ac81d61c --- /dev/null +++ b/core/data/src/main/kotlin/app/pachli/core/data/repository/OfflineFirstContentFiltersRepository.kt @@ -0,0 +1,156 @@ +/* + * Copyright 2024 Pachli Association + * + * This file is a part of Pachli. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Pachli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Pachli; if not, + * see . + */ + +package app.pachli.core.data.repository + +import app.pachli.core.common.di.ApplicationScope +import app.pachli.core.data.model.Server +import app.pachli.core.data.repository.ContentFiltersError.ServerDoesNotFilter +import app.pachli.core.data.source.ContentFiltersLocalDataSource +import app.pachli.core.data.source.ContentFiltersRemoteDataSource +import app.pachli.core.database.dao.InstanceDao +import app.pachli.core.database.model.ContentFiltersEntity +import app.pachli.core.model.ContentFilter +import app.pachli.core.model.ContentFilterVersion +import app.pachli.core.model.FilterAction +import app.pachli.core.model.FilterContext +import app.pachli.core.model.FilterKeyword +import app.pachli.core.model.NewContentFilter +import app.pachli.core.model.ServerOperation.ORG_JOINMASTODON_FILTERS_CLIENT +import app.pachli.core.model.ServerOperation.ORG_JOINMASTODON_FILTERS_SERVER +import app.pachli.core.network.retrofit.apiresult.ApiResponse +import com.github.michaelbull.result.Err +import com.github.michaelbull.result.Result +import com.github.michaelbull.result.onSuccess +import io.github.z4kn4fein.semver.constraints.toConstraint +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.map + +/** + * Represents a collection of edits to make to an existing content filter. + * + * @param id ID of the content filter to be changed + * @param title New title, null if the title should not be changed + * @param contexts New contexts, null if the contexts should not be changed + * @param expiresIn New expiresIn, -1 if the expiry time should not be changed + * @param filterAction New action, null if the action should not be changed + * @param keywordsToAdd One or more keywords to add to the content filter, null if none to add + * @param keywordsToDelete One or more keywords to delete from the content filter, null if none to delete + * @param keywordsToModify One or more keywords to modify in the content filter, null if none to modify + */ +data class ContentFilterEdit( + val id: String, + val title: String? = null, + val contexts: Collection? = null, + val expiresIn: Int = -1, + val filterAction: FilterAction? = null, + val keywordsToAdd: List? = null, + val keywordsToDelete: List? = null, + val keywordsToModify: List? = null, +) + +// Hack, so that FilterModel can know whether this is V1 or V2 content filters. +// See usage in: +// - TimelineViewModel.getFilters() +// - NotificationsViewModel.getFilters() +// Need to think about a better way to do this. +data class ContentFilters( + val contentFilters: List, + val version: ContentFilterVersion, +) { + companion object { + val EMPTY = ContentFilters( + contentFilters = emptyList(), + version = ContentFilterVersion.V2, + ) + + fun from(entity: ContentFiltersEntity?) = entity?.let { + ContentFilters( + contentFilters = it.contentFilters, + version = it.version, + ) + } ?: EMPTY + } +} + +/** + * Repository for filters that caches information locally. + * + * - Methods that query data always return from the cache. + * - Methods that update data update the remote server first, and cache + * successful responses. + * - Call [refresh] to update the local cache. + */ +@Singleton +class OfflineFirstContentFiltersRepository @Inject constructor( + @ApplicationScope private val externalScope: CoroutineScope, + private val localDataSource: ContentFiltersLocalDataSource, + private val remoteDataSource: ContentFiltersRemoteDataSource, + private val instanceDao: InstanceDao, +) : ContentFiltersRepository { + override suspend fun getContentFilter(pachliAccountId: Long, contentFilterId: String) = + localDataSource.getContentFilter(pachliAccountId, contentFilterId) + + override suspend fun refresh(pachliAccountId: Long): Result = externalScope.async { + val server = instanceDao.getServer(pachliAccountId)?.let { Server.from(it) } ?: return@async Err(ServerDoesNotFilter) + + remoteDataSource.getContentFilters(pachliAccountId, server) + .onSuccess { + val entity = ContentFiltersEntity( + accountId = pachliAccountId, + contentFilters = it.contentFilters, + version = it.version, + ) + localDataSource.replace(entity) + } + }.await() + + override suspend fun getContentFilters(pachliAccountId: Long) = + ContentFilters.from(localDataSource.getContentFilters(pachliAccountId)) + + override fun getContentFiltersFlow(pachliAccountId: Long) = + localDataSource.getContentFiltersFlow(pachliAccountId).map { ContentFilters.from(it) } + + override suspend fun createContentFilter(pachliAccountId: Long, filter: NewContentFilter): Result = externalScope.async { + // TODO: Return better error if server data not cached + val server = instanceDao.getServer(pachliAccountId)?.let { Server.from(it) } + ?: return@async Err(ServerDoesNotFilter) + remoteDataSource.createContentFilter(pachliAccountId, server, filter) + .onSuccess { localDataSource.saveContentFilter(pachliAccountId, it) } + }.await() + + override suspend fun updateContentFilter(pachliAccountId: Long, originalContentFilter: ContentFilter, contentFilterEdit: ContentFilterEdit): Result = externalScope.async { + val server = instanceDao.getServer(pachliAccountId)?.let { Server.from(it) } + ?: return@async Err(ServerDoesNotFilter) + remoteDataSource.updateContentFilter(server, originalContentFilter, contentFilterEdit) + .onSuccess { localDataSource.updateContentFilter(pachliAccountId, it) } + }.await() + + override suspend fun deleteContentFilter(pachliAccountId: Long, contentFilterId: String): Result, ContentFiltersError> = externalScope.async { + // TODO: Return better error if server data not cached + val server = instanceDao.getServer(pachliAccountId)?.let { Server.from(it) } + ?: return@async Err(ServerDoesNotFilter) + remoteDataSource.deleteContentFilter(pachliAccountId, server, contentFilterId) + .onSuccess { localDataSource.deleteContentFilter(pachliAccountId, contentFilterId) } + }.await() +} + +fun Server.canFilterV1() = this.can(ORG_JOINMASTODON_FILTERS_CLIENT, ">=1.0.0".toConstraint()) +fun Server.canFilterV2() = this.can(ORG_JOINMASTODON_FILTERS_SERVER, ">=1.0.0".toConstraint()) diff --git a/core/data/src/main/kotlin/app/pachli/core/data/repository/OfflineFirstListRepository.kt b/core/data/src/main/kotlin/app/pachli/core/data/repository/OfflineFirstListRepository.kt new file mode 100644 index 000000000..5e2e24970 --- /dev/null +++ b/core/data/src/main/kotlin/app/pachli/core/data/repository/OfflineFirstListRepository.kt @@ -0,0 +1,96 @@ +/* + * Copyright 2024 Pachli Association + * + * This file is a part of Pachli. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Pachli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Pachli; if not, + * see . + */ + +package app.pachli.core.data.repository + +import app.pachli.core.common.di.ApplicationScope +import app.pachli.core.data.model.MastodonList +import app.pachli.core.data.source.ListsLocalDataSource +import app.pachli.core.data.source.ListsRemoteDataSource +import app.pachli.core.database.model.MastodonListEntity +import app.pachli.core.network.model.UserListRepliesPolicy +import com.github.michaelbull.result.Result +import com.github.michaelbull.result.map +import com.github.michaelbull.result.onSuccess +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.map + +/** + * Repository for lists that caches information locally. + * + * - Methods that query list data always return from the cache. + * - Methods that query list membership always query the remote server. + * - Methods that update data update the remote server first, and cache + * successful responses. + * - Call [refresh] to update the local cache. + */ +@Singleton +internal class OfflineFirstListRepository @Inject constructor( + @ApplicationScope private val externalScope: CoroutineScope, + private val localDataSource: ListsLocalDataSource, + private val remoteDataSource: ListsRemoteDataSource, +) : ListsRepository { + override suspend fun refresh(pachliAccountId: Long): Result, ListsError.Retrieve> = externalScope.async { + remoteDataSource.getLists().map { MastodonListEntity.make(pachliAccountId, it) } + .onSuccess { localDataSource.replace(pachliAccountId, it) } + .map { MastodonList.from(it) } + }.await() + + override fun getLists(pachliAccountId: Long) = localDataSource.getLists(pachliAccountId).map { + MastodonList.from(it) + } + + override fun getListsFlow() = localDataSource.getAllLists().map { MastodonList.from(it) } + + override suspend fun createList(pachliAccountId: Long, title: String, exclusive: Boolean, repliesPolicy: UserListRepliesPolicy) = externalScope.async { + remoteDataSource.createList(pachliAccountId, title, exclusive, repliesPolicy) + .map { MastodonListEntity.make(pachliAccountId, it) } + .onSuccess { localDataSource.saveList(it) } + .map { MastodonList.from(it) } + }.await() + + override suspend fun updateList(pachliAccountId: Long, listId: String, title: String, exclusive: Boolean, repliesPolicy: UserListRepliesPolicy) = externalScope.async { + remoteDataSource.updateList(pachliAccountId, listId, title, exclusive, repliesPolicy) + .map { MastodonListEntity.make(pachliAccountId, it) } + .onSuccess { localDataSource.updateList(it) } + .map { MastodonList.from(it) } + }.await() + + override suspend fun deleteList(list: MastodonList) = externalScope.async { + remoteDataSource.deleteList(list.accountId, list.listId) + .onSuccess { localDataSource.deleteList(list.entity()) } + .map { } + }.await() + + override suspend fun getListsWithAccount(pachliAccountId: Long, accountId: String) = + remoteDataSource.getListsWithAccount(pachliAccountId, accountId) + .map { MastodonList.make(pachliAccountId, it) } + + override suspend fun getAccountsInList(pachliAccountId: Long, listId: String) = + remoteDataSource.getAccountsInList(pachliAccountId, listId) + + override suspend fun addAccountsToList(pachliAccountId: Long, listId: String, accountIds: List): Result = externalScope.async { + remoteDataSource.addAccountsToList(pachliAccountId, listId, accountIds).map { } + }.await() + + override suspend fun deleteAccountsFromList(pachliAccountId: Long, listId: String, accountIds: List): Result = externalScope.async { + remoteDataSource.deleteAccountsFromList(pachliAccountId, listId, accountIds).map { } + }.await() +} diff --git a/core/data/src/main/kotlin/app/pachli/core/data/repository/PachliAccount.kt b/core/data/src/main/kotlin/app/pachli/core/data/repository/PachliAccount.kt new file mode 100644 index 000000000..535f58cad --- /dev/null +++ b/core/data/src/main/kotlin/app/pachli/core/data/repository/PachliAccount.kt @@ -0,0 +1,71 @@ +/* + * Copyright 2024 Pachli Association + * + * This file is a part of Pachli. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Pachli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Pachli; if not, + * see . + */ + +package app.pachli.core.data.repository + +import app.pachli.core.data.model.InstanceInfo +import app.pachli.core.data.model.MastodonList +import app.pachli.core.data.model.Server +import app.pachli.core.database.model.AccountEntity +import app.pachli.core.model.ServerKind +import app.pachli.core.network.model.Announcement +import app.pachli.core.network.model.Emoji +import io.github.z4kn4fein.semver.Version + +/** + * A single Pachli account with all the information associated with it. + * + * @param id Account's unique local database ID. + * @param entity [AccountEntity] from the local database. + * @param instanceInfo Details about the account's server's instance info. + * @param lists Account's lists. + * @param emojis Account's emojis. + * @param server Details about the account's server. + * @param contentFilters Account's content filters. + * @param announcements Announcements from the account's server. + */ +// TODO: Still not sure if it's better to have one class that contains everything, +// or provide dedicated functions that return specific flows for the different +// things, parameterised by the account ID. +data class PachliAccount( + val id: Long, + // TODO: Should be a core.data type + val entity: AccountEntity, + val instanceInfo: InstanceInfo, + val lists: List, + val emojis: List, + val server: Server, + val contentFilters: ContentFilters, + val announcements: List, +) { + companion object { + fun make( + account: app.pachli.core.database.model.PachliAccount, + ): PachliAccount { + return PachliAccount( + id = account.account.id, + entity = account.account, + instanceInfo = account.instanceInfo?.let { InstanceInfo.from(it) } ?: InstanceInfo(), + lists = account.lists.orEmpty().map { MastodonList.from(it) }, + emojis = account.emojis?.emojiList.orEmpty(), + server = account.server?.let { Server.from(it) } ?: Server(ServerKind.MASTODON, Version(4, 0, 0)), + contentFilters = account.contentFilters?.let { ContentFilters.from(it) } ?: ContentFilters.EMPTY, + announcements = account.announcements.orEmpty().map { it.announcement }, + ) + } + } +} diff --git a/core/data/src/main/kotlin/app/pachli/core/data/repository/ServerRepository.kt b/core/data/src/main/kotlin/app/pachli/core/data/repository/ServerRepository.kt index ea013d4b2..197d7dae0 100644 --- a/core/data/src/main/kotlin/app/pachli/core/data/repository/ServerRepository.kt +++ b/core/data/src/main/kotlin/app/pachli/core/data/repository/ServerRepository.kt @@ -21,21 +21,22 @@ import androidx.annotation.StringRes import app.pachli.core.common.PachliError import app.pachli.core.common.di.ApplicationScope import app.pachli.core.data.R +import app.pachli.core.data.model.Server import app.pachli.core.data.repository.ServerRepository.Error.Capabilities import app.pachli.core.data.repository.ServerRepository.Error.GetInstanceInfoV1 import app.pachli.core.data.repository.ServerRepository.Error.GetNodeInfo import app.pachli.core.data.repository.ServerRepository.Error.GetWellKnownNodeInfo import app.pachli.core.data.repository.ServerRepository.Error.UnsupportedSchema import app.pachli.core.data.repository.ServerRepository.Error.ValidateNodeInfo -import app.pachli.core.network.Server +import app.pachli.core.database.model.AccountEntity import app.pachli.core.network.model.nodeinfo.UnvalidatedNodeInfo import app.pachli.core.network.retrofit.MastodonApi import app.pachli.core.network.retrofit.NodeInfoApi -import at.connyduck.calladapter.networkresult.fold import com.github.michaelbull.result.Err import com.github.michaelbull.result.Ok import com.github.michaelbull.result.Result import com.github.michaelbull.result.coroutines.binding.binding +import com.github.michaelbull.result.mapBoth import com.github.michaelbull.result.mapError import javax.inject.Inject import javax.inject.Singleton @@ -43,6 +44,8 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChangedBy +import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.launch import timber.log.Timber @@ -52,7 +55,7 @@ import timber.log.Timber * * See https://nodeinfo.diaspora.software/schema.html. */ -private val SCHEMAS = listOf( +val SCHEMAS = listOf( "http://nodeinfo.diaspora.software/ns/schema/2.1", "http://nodeinfo.diaspora.software/ns/schema/2.0", "http://nodeinfo.diaspora.software/ns/schema/1.1", @@ -63,14 +66,18 @@ private val SCHEMAS = listOf( class ServerRepository @Inject constructor( private val mastodonApi: MastodonApi, private val nodeInfoApi: NodeInfoApi, - private val accountManager: AccountManager, + accountManager: AccountManager, @ApplicationScope private val externalScope: CoroutineScope, ) { private val reload = MutableSharedFlow(replay = 1).apply { tryEmit(Unit) } // SharedFlow, **not** StateFlow, to ensure a new value is emitted even if the // user switches between accounts that are on the same server. - val flow = reload.combine(accountManager.activeAccountFlow) { _, _ -> getServer() } + val flow = reload.combine( + accountManager.activeAccountFlow + .filterIsInstance>() + .distinctUntilChangedBy { it.data?.id }, + ) { _, _ -> getServer() } .shareIn(externalScope, SharingStarted.Lazily, replay = 1) fun reload() = externalScope.launch { reload.emit(Unit) } @@ -81,10 +88,8 @@ class ServerRepository @Inject constructor( */ private suspend fun getServer(): Result = binding { // Fetch the /.well-known/nodeinfo document - val nodeInfoJrd = nodeInfoApi.nodeInfoJrd().fold( - { Ok(it) }, - { Err(GetWellKnownNodeInfo(it)) }, - ).bind() + val nodeInfoJrd = nodeInfoApi.nodeInfoJrd() + .mapError { GetWellKnownNodeInfo(it) }.bind().body // Find a link to a schema we can parse, prefering newer schema versions var nodeInfoUrlResult: Result = Err(UnsupportedSchema) @@ -98,17 +103,17 @@ class ServerRepository @Inject constructor( val nodeInfoUrl = nodeInfoUrlResult.bind() Timber.d("Loading node info from %s", nodeInfoUrl) - val nodeInfo = nodeInfoApi.nodeInfo(nodeInfoUrl).fold( - { it.validate().mapError { ValidateNodeInfo(nodeInfoUrl, it) } }, + val nodeInfo = nodeInfoApi.nodeInfo(nodeInfoUrl).mapBoth( + { it.body.validate().mapError { ValidateNodeInfo(nodeInfoUrl, it) } }, { Err(GetNodeInfo(nodeInfoUrl, it)) }, ).bind() - mastodonApi.getInstanceV2().fold( - { Server.from(nodeInfo.software, it).mapError(::Capabilities) }, - { throwable -> - Timber.e(throwable, "Couldn't process /api/v2/instance result") - mastodonApi.getInstanceV1().fold( - { Server.from(nodeInfo.software, it).mapError(::Capabilities) }, + mastodonApi.getInstanceV2().mapBoth( + { Server.from(nodeInfo.software, it.body).mapError(::Capabilities) }, + { error -> + Timber.e(error.throwable, "Couldn't process /api/v2/instance result") + mastodonApi.getInstanceV1().mapBoth( + { Server.from(nodeInfo.software, it.body).mapError(::Capabilities) }, { Err(GetInstanceInfoV1(it)) }, ) }, @@ -121,34 +126,29 @@ class ServerRepository @Inject constructor( override val cause: PachliError? = null, ) : PachliError { - data class GetWellKnownNodeInfo(val throwable: Throwable) : Error( + data class GetWellKnownNodeInfo(override val cause: PachliError) : Error( R.string.server_repository_error_get_well_known_node_info, - throwable.localizedMessage?.let { arrayOf(it) }, ) data object UnsupportedSchema : Error( R.string.server_repository_error_unsupported_schema, ) - data class GetNodeInfo(val url: String, val throwable: Throwable) : Error( + data class GetNodeInfo(val url: String, override val cause: PachliError) : Error( R.string.server_repository_error_get_node_info, - arrayOf(url, throwable.localizedMessage ?: ""), ) data class ValidateNodeInfo(val url: String, val error: UnvalidatedNodeInfo.Error) : Error( R.string.server_repository_error_validate_node_info, arrayOf(url), - cause = error, ) - data class GetInstanceInfoV1(val throwable: Throwable) : Error( + data class GetInstanceInfoV1(override val cause: PachliError) : Error( R.string.server_repository_error_get_instance_info, - throwable.localizedMessage?.let { arrayOf(it) }, ) - data class Capabilities(val error: Server.Error) : Error( + data class Capabilities(override val cause: Server.Error) : Error( R.string.server_repository_error_capabilities, - cause = error, ) } } diff --git a/core/data/src/main/kotlin/app/pachli/core/data/repository/StatusDisplayOptionsRepository.kt b/core/data/src/main/kotlin/app/pachli/core/data/repository/StatusDisplayOptionsRepository.kt index b5ff59a88..b8dd3ed96 100644 --- a/core/data/src/main/kotlin/app/pachli/core/data/repository/StatusDisplayOptionsRepository.kt +++ b/core/data/src/main/kotlin/app/pachli/core/data/repository/StatusDisplayOptionsRepository.kt @@ -91,7 +91,7 @@ class StatusDisplayOptionsRepository @Inject constructor( animateAvatars = sharedPreferencesRepository.getBoolean(key, default.animateAvatars), ) PrefKeys.MEDIA_PREVIEW_ENABLED -> prev.copy( - mediaPreviewEnabled = accountManager.activeAccountFlow.value?.mediaPreviewEnabled ?: default.mediaPreviewEnabled, + mediaPreviewEnabled = accountManager.activeAccount?.mediaPreviewEnabled ?: default.mediaPreviewEnabled, ) PrefKeys.ABSOLUTE_TIME_VIEW -> prev.copy( useAbsoluteTime = sharedPreferencesRepository.getBoolean(key, default.useAbsoluteTime), @@ -118,10 +118,10 @@ class StatusDisplayOptionsRepository @Inject constructor( animateEmojis = sharedPreferencesRepository.getBoolean(key, default.animateEmojis), ) PrefKeys.ALWAYS_SHOW_SENSITIVE_MEDIA -> prev.copy( - showSensitiveMedia = accountManager.activeAccountFlow.value?.alwaysShowSensitiveMedia ?: default.showSensitiveMedia, + showSensitiveMedia = accountManager.activeAccount?.alwaysShowSensitiveMedia ?: default.showSensitiveMedia, ) PrefKeys.ALWAYS_OPEN_SPOILER -> prev.copy( - openSpoiler = accountManager.activeAccountFlow.value?.alwaysOpenSpoiler ?: default.openSpoiler, + openSpoiler = accountManager.activeAccount?.alwaysOpenSpoiler ?: default.openSpoiler, ) PrefKeys.SHOW_STATS_INLINE -> prev.copy( showStatsInline = sharedPreferencesRepository.getBoolean(key, default.showStatsInline), @@ -136,8 +136,10 @@ class StatusDisplayOptionsRepository @Inject constructor( externalScope.launch { accountManager.activeAccountFlow.collect { - Timber.d("Updating because active account changed") - _flow.emit(initialStatusDisplayOptions(it)) + if (it is Loadable.Loaded) { + Timber.d("Updating because active account changed") + _flow.emit(initialStatusDisplayOptions(it.data)) + } } } diff --git a/core/data/src/main/kotlin/app/pachli/core/data/source/ContentFiltersLocalDataSource.kt b/core/data/src/main/kotlin/app/pachli/core/data/source/ContentFiltersLocalDataSource.kt new file mode 100644 index 000000000..aba27c70b --- /dev/null +++ b/core/data/src/main/kotlin/app/pachli/core/data/source/ContentFiltersLocalDataSource.kt @@ -0,0 +1,100 @@ +/* + * Copyright 2024 Pachli Association + * + * This file is a part of Pachli. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Pachli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Pachli; if not, + * see . + */ + +package app.pachli.core.data.source + +import app.pachli.core.database.dao.ContentFiltersDao +import app.pachli.core.database.di.TransactionProvider +import app.pachli.core.database.model.ContentFiltersEntity +import app.pachli.core.model.ContentFilter +import javax.inject.Inject + +/** + * Local data source for content filters that exclusively reads/writes to + * the database. + */ +class ContentFiltersLocalDataSource @Inject constructor( + private val transactionProvider: TransactionProvider, + private val contentFiltersDao: ContentFiltersDao, +) { + /** + * Replaces all content filters for an account with [contentFilters]. + */ + suspend fun replace(contentFilters: ContentFiltersEntity) = contentFiltersDao.upsert(contentFilters) + + /** + * Gets the content filter in [pachliAccountId] with [contentFilterId]. + * + * @return The content filter, or null if no filter exists with [contentFilterId]. + */ + suspend fun getContentFilter(pachliAccountId: Long, contentFilterId: String) = + contentFiltersDao.getByAccount(pachliAccountId)?.contentFilters?.find { it.id == contentFilterId } + + /** + * Gets all content filters in [pachliAccountId]. + * + * @return The content filter, or null if no filters exist. + */ + suspend fun getContentFilters(pachliAccountId: Long) = contentFiltersDao.getByAccount(pachliAccountId) + + /** + * @return Flow of content filters for [pachliAccountId]. + */ + fun getContentFiltersFlow(pachliAccountId: Long) = contentFiltersDao.flowByAccount(pachliAccountId) + + /** + * Saves [contentFilter] to [pachliAccountId]. + */ + suspend fun saveContentFilter(pachliAccountId: Long, contentFilter: ContentFilter) { + transactionProvider { + val contentFilters = contentFiltersDao.getByAccount(pachliAccountId) ?: return@transactionProvider + val newContentFilters = contentFilters.copy( + contentFilters = contentFilters.contentFilters + contentFilter, + ) + contentFiltersDao.upsert(newContentFilters) + } + } + + /** + * Updates the content filters in [pachliAccountId], the existing content filter with + * [contentFilter.id][ContentFilter.id] is replaced with [contentFilter]. + */ + suspend fun updateContentFilter(pachliAccountId: Long, contentFilter: ContentFilter) { + transactionProvider { + val contentFilters = contentFiltersDao.getByAccount(pachliAccountId) ?: return@transactionProvider + val newContentFilters = contentFilters.copy( + contentFilters = contentFilters.contentFilters.map { + if (it.id != contentFilter.id) it else contentFilter + }, + ) + contentFiltersDao.upsert(newContentFilters) + } + } + + /** + * Deletes the content filter with [contentFilterId] from [pachliAccountId]. + */ + suspend fun deleteContentFilter(pachliAccountId: Long, contentFilterId: String) { + transactionProvider { + val contentFilters = contentFiltersDao.getByAccount(pachliAccountId) ?: return@transactionProvider + val newContentFilters = contentFilters.copy( + contentFilters = contentFilters.contentFilters.filterNot { it.id == contentFilterId }, + ) + contentFiltersDao.upsert(newContentFilters) + } + } +} diff --git a/core/data/src/main/kotlin/app/pachli/core/data/source/ContentFiltersRemoteDataSource.kt b/core/data/src/main/kotlin/app/pachli/core/data/source/ContentFiltersRemoteDataSource.kt new file mode 100644 index 000000000..3bbd50f84 --- /dev/null +++ b/core/data/src/main/kotlin/app/pachli/core/data/source/ContentFiltersRemoteDataSource.kt @@ -0,0 +1,196 @@ +/* + * Copyright 2024 Pachli Association + * + * This file is a part of Pachli. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Pachli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Pachli; if not, + * see . + */ + +package app.pachli.core.data.source + +import app.pachli.core.data.model.Server +import app.pachli.core.data.model.from +import app.pachli.core.data.repository.ContentFilterEdit +import app.pachli.core.data.repository.ContentFilters +import app.pachli.core.data.repository.ContentFiltersError +import app.pachli.core.data.repository.ContentFiltersError.CreateContentFilterError +import app.pachli.core.data.repository.ContentFiltersError.DeleteContentFilterError +import app.pachli.core.data.repository.ContentFiltersError.GetContentFiltersError +import app.pachli.core.data.repository.ContentFiltersError.ServerDoesNotFilter +import app.pachli.core.data.repository.ContentFiltersError.UpdateContentFilterError +import app.pachli.core.data.repository.canFilterV1 +import app.pachli.core.data.repository.canFilterV2 +import app.pachli.core.model.ContentFilter +import app.pachli.core.model.ContentFilterVersion +import app.pachli.core.model.NewContentFilter +import app.pachli.core.network.model.FilterAction +import app.pachli.core.network.model.FilterContext +import app.pachli.core.network.retrofit.MastodonApi +import com.github.michaelbull.result.Err +import com.github.michaelbull.result.Ok +import com.github.michaelbull.result.Result +import com.github.michaelbull.result.andThen +import com.github.michaelbull.result.coroutines.binding.binding +import com.github.michaelbull.result.map +import com.github.michaelbull.result.mapError +import com.github.michaelbull.result.mapResult +import javax.inject.Inject + +class ContentFiltersRemoteDataSource @Inject constructor( + private val mastodonApi: MastodonApi, +) { + suspend fun getContentFilters(pachliAccountId: Long, server: Server) = when { + server.canFilterV2() -> mastodonApi.getContentFilters().map { + ContentFilters( + contentFilters = it.body.map { ContentFilter.from(it) }, + version = ContentFilterVersion.V2, + ) + } + server.canFilterV1() -> mastodonApi.getContentFiltersV1().map { + ContentFilters( + contentFilters = it.body.map { ContentFilter.from(it) }, + version = ContentFilterVersion.V1, + ) + } + else -> Err(ServerDoesNotFilter) + }.mapError { GetContentFiltersError(it) } + + /** + * @return Depends on whether the server supports V1 or V2 filters. If it supports + * V2 filters the return value is the entire content filter. If it supports V1 + * filters then multiple content filters may have been created (one per keyword) + * and the return value is the last content filter created. + */ + suspend fun createContentFilter(pachliAccountId: Long, server: Server, filter: NewContentFilter) = binding { + val expiresInSeconds = when (val expiresIn = filter.expiresIn) { + 0 -> "" + else -> expiresIn.toString() + } + + when { + server.canFilterV2() -> { + mastodonApi.createFilter(filter).map { ContentFilter.from(it.body) } + } + + server.canFilterV1() -> { + val networkContexts = + filter.contexts.map { FilterContext.from(it) }.toSet() + filter.toNewContentFilterV1().mapResult { + mastodonApi.createFilterV1( + phrase = it.phrase, + context = networkContexts, + irreversible = it.irreversible, + wholeWord = it.wholeWord, + expiresInSeconds = expiresInSeconds, + ) + }.map { ContentFilter.from(it.last().body) } + } + + else -> Err(ServerDoesNotFilter) + }.mapError { CreateContentFilterError(it) }.bind() + } + + suspend fun deleteContentFilter(pachliAccountId: Long, server: Server, contentFilterId: String) = binding { + when { + server.canFilterV2() -> mastodonApi.deleteFilter(contentFilterId) + server.canFilterV1() -> mastodonApi.deleteFilterV1(contentFilterId) + else -> Err(ServerDoesNotFilter) + }.mapError { DeleteContentFilterError(it) }.bind() + } + + suspend fun updateContentFilter(server: Server, originalContentFilter: ContentFilter, contentFilterEdit: ContentFilterEdit): Result = binding { + // Modify + val expiresInSeconds = when (val expiresIn = contentFilterEdit.expiresIn) { + -1 -> null + 0 -> "" + else -> expiresIn.toString() + } + + when { + server.canFilterV2() -> { + // Retrofit can't send a form where there are multiple parameters + // with the same ID (https://github.com/square/retrofit/issues/1324) + // so it's not possible to update keywords + + if (contentFilterEdit.title != null || + contentFilterEdit.contexts != null || + contentFilterEdit.filterAction != null || + expiresInSeconds != null + ) { + val networkContexts = contentFilterEdit.contexts?.map { + FilterContext.from(it) + }?.toSet() + val networkAction = contentFilterEdit.filterAction?.let { + FilterAction.from(it) + } + + mastodonApi.updateFilter( + id = contentFilterEdit.id, + title = contentFilterEdit.title, + contexts = networkContexts, + filterAction = networkAction, + expiresInSeconds = expiresInSeconds, + ) + } else { + Ok(originalContentFilter) + } + .andThen { + contentFilterEdit.keywordsToDelete.orEmpty().mapResult { + mastodonApi.deleteFilterKeyword(it.id) + } + } + .andThen { + contentFilterEdit.keywordsToModify.orEmpty().mapResult { + mastodonApi.updateFilterKeyword( + it.id, + it.keyword, + it.wholeWord, + ) + } + } + .andThen { + contentFilterEdit.keywordsToAdd.orEmpty().mapResult { + mastodonApi.addFilterKeyword( + contentFilterEdit.id, + it.keyword, + it.wholeWord, + ) + } + } + .andThen { + mastodonApi.getFilter(originalContentFilter.id) + } + .map { ContentFilter.from(it.body) } + } + server.canFilterV1() -> { + val networkContexts = contentFilterEdit.contexts?.map { + FilterContext.from(it) + }?.toSet() ?: originalContentFilter.contexts.map { + FilterContext.from( + it, + ) + } + mastodonApi.updateFilterV1( + id = contentFilterEdit.id, + phrase = contentFilterEdit.keywordsToModify?.firstOrNull()?.keyword ?: originalContentFilter.keywords.first().keyword, + wholeWord = contentFilterEdit.keywordsToModify?.firstOrNull()?.wholeWord, + contexts = networkContexts, + irreversible = false, + expiresInSeconds = expiresInSeconds, + ).map { ContentFilter.from(it.body) } + } + else -> { + Err(ServerDoesNotFilter) + } + }.mapError { UpdateContentFilterError(it) }.bind() + } +} diff --git a/core/data/src/main/kotlin/app/pachli/core/data/source/ListsLocalDataSource.kt b/core/data/src/main/kotlin/app/pachli/core/data/source/ListsLocalDataSource.kt new file mode 100644 index 000000000..20e50aef0 --- /dev/null +++ b/core/data/src/main/kotlin/app/pachli/core/data/source/ListsLocalDataSource.kt @@ -0,0 +1,47 @@ +/* + * Copyright 2024 Pachli Association + * + * This file is a part of Pachli. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Pachli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Pachli; if not, + * see . + */ + +package app.pachli.core.data.source + +import app.pachli.core.database.dao.ListsDao +import app.pachli.core.database.di.TransactionProvider +import app.pachli.core.database.model.MastodonListEntity +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class ListsLocalDataSource @Inject constructor( + private val transactionProvider: TransactionProvider, + private val listsDao: ListsDao, +) { + suspend fun replace(pachliAccountId: Long, lists: List) { + transactionProvider { + listsDao.deleteAllForAccount(pachliAccountId) + listsDao.upsert(lists) + } + } + + fun getLists(pachliAccountId: Long) = listsDao.flowByAccount(pachliAccountId) + + fun getAllLists() = listsDao.flowAll() + + suspend fun saveList(list: MastodonListEntity) = listsDao.upsert(list) + + suspend fun updateList(list: MastodonListEntity) = listsDao.upsert(list) + + suspend fun deleteList(list: MastodonListEntity) = listsDao.deleteForAccount(list.accountId, list.listId) +} diff --git a/core/data/src/main/kotlin/app/pachli/core/data/source/ListsRemoteDataSource.kt b/core/data/src/main/kotlin/app/pachli/core/data/source/ListsRemoteDataSource.kt new file mode 100644 index 000000000..d80be37e4 --- /dev/null +++ b/core/data/src/main/kotlin/app/pachli/core/data/source/ListsRemoteDataSource.kt @@ -0,0 +1,66 @@ +/* + * Copyright 2024 Pachli Association + * + * This file is a part of Pachli. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Pachli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Pachli; if not, + * see . + */ + +package app.pachli.core.data.source + +import app.pachli.core.data.repository.ListsError +import app.pachli.core.data.repository.ListsError.GetListsWithAccount +import app.pachli.core.network.model.UserListRepliesPolicy +import app.pachli.core.network.retrofit.MastodonApi +import com.github.michaelbull.result.mapEither +import com.github.michaelbull.result.mapError +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class ListsRemoteDataSource @Inject constructor( + private val mastodonApi: MastodonApi, +) { + suspend fun getLists() = mastodonApi.getLists() + .mapEither({ it.body }, { ListsError.Retrieve(it) }) + + suspend fun createList(pachliAccountId: Long, title: String, exclusive: Boolean, repliesPolicy: UserListRepliesPolicy) = + mastodonApi.createList(title, exclusive, repliesPolicy) + .mapEither({ it.body }, { ListsError.Create(it) }) + + suspend fun updateList(pachliAccountId: Long, listId: String, title: String, exclusive: Boolean, repliesPolicy: UserListRepliesPolicy) = + mastodonApi.updateList(listId, title, exclusive, repliesPolicy) + .mapEither({ it.body }, { ListsError.Update(it) }) + + suspend fun deleteList(pachliAccountId: Long, listId: String) = + mastodonApi.deleteList(listId) + .mapError { ListsError.Delete(it) } + + /** + * @return Lists owned by [pachliAccountId] that contain [accountId]. + */ + suspend fun getListsWithAccount(pachliAccountId: Long, accountId: String) = + mastodonApi.getListsIncludesAccount(accountId) + .mapEither({ it.body }, { GetListsWithAccount(accountId, it) }) + + suspend fun getAccountsInList(pachliAccountId: Long, listId: String) = + mastodonApi.getAccountsInList(listId, 0) + .mapEither({ it.body }, { ListsError.GetAccounts(listId, it) }) + + suspend fun addAccountsToList(pachliAccountId: Long, listId: String, accountIds: List) = + mastodonApi.addAccountToList(listId, accountIds) + .mapError { ListsError.AddAccounts(listId, it) } + + suspend fun deleteAccountsFromList(pachliAccountId: Long, listId: String, accountIds: List) = + mastodonApi.deleteAccountFromList(listId, accountIds) + .mapError { ListsError.DeleteAccounts(listId, it) } +} diff --git a/core/data/src/main/res/values/strings.xml b/core/data/src/main/res/values/strings.xml index 5446da936..7ddb0befd 100644 --- a/core/data/src/main/res/values/strings.xml +++ b/core/data/src/main/res/values/strings.xml @@ -7,4 +7,8 @@ fetching /api/v1/instance failed: %1$s parsing server capabilities failed: %1$s Server does not support filters + + Account does not exist in local database. This should never happen. If you see this message please send a bug report. + no account is marked active + unexpected error: %1$s diff --git a/core/network/src/test/kotlin/app/pachli/core/network/ServerTest.kt b/core/data/src/test/kotlin/app/pachli/core/data/model/ServerTest.kt similarity index 99% rename from core/network/src/test/kotlin/app/pachli/core/network/ServerTest.kt rename to core/data/src/test/kotlin/app/pachli/core/data/model/ServerTest.kt index 04f7154cc..efe848621 100644 --- a/core/network/src/test/kotlin/app/pachli/core/network/ServerTest.kt +++ b/core/data/src/test/kotlin/app/pachli/core/data/model/ServerTest.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023 Pachli Association + * Copyright 2024 Pachli Association * * This file is a part of Pachli. * @@ -15,7 +15,7 @@ * see . */ -package app.pachli.core.network +package app.pachli.core.data.model import app.pachli.core.model.NodeInfo import app.pachli.core.model.ServerKind diff --git a/core/data/src/test/kotlin/app/pachli/core/data/repository/InstanceInfoRepositoryTest.kt b/core/data/src/test/kotlin/app/pachli/core/data/repository/InstanceInfoRepositoryTest.kt index 0c41868cc..834422260 100644 --- a/core/data/src/test/kotlin/app/pachli/core/data/repository/InstanceInfoRepositoryTest.kt +++ b/core/data/src/test/kotlin/app/pachli/core/data/repository/InstanceInfoRepositoryTest.kt @@ -21,12 +21,19 @@ import android.app.Application import androidx.test.ext.junit.runners.AndroidJUnit4 import app.cash.turbine.test import app.pachli.core.data.model.InstanceInfo.Companion.DEFAULT_CHARACTER_LIMIT +import app.pachli.core.database.AppDatabase import app.pachli.core.network.model.Account import app.pachli.core.network.model.InstanceConfiguration import app.pachli.core.network.model.InstanceV1 +import app.pachli.core.network.model.nodeinfo.UnvalidatedJrd +import app.pachli.core.network.model.nodeinfo.UnvalidatedNodeInfo import app.pachli.core.network.retrofit.MastodonApi +import app.pachli.core.network.retrofit.NodeInfoApi +import app.pachli.core.testing.failure import app.pachli.core.testing.rules.MainCoroutineRule -import at.connyduck.calladapter.networkresult.NetworkResult +import app.pachli.core.testing.success +import com.github.michaelbull.result.andThen +import com.github.michaelbull.result.onSuccess import dagger.hilt.android.testing.CustomTestApplication import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest @@ -36,11 +43,14 @@ import javax.inject.Inject import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest +import org.junit.After import org.junit.Assert.assertEquals import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.doAnswer import org.mockito.kotlin.doReturn import org.mockito.kotlin.reset @@ -69,53 +79,84 @@ class InstanceInfoRepositoryTest { @Inject lateinit var mastodonApi: MastodonApi + @Inject + lateinit var nodeInfoApi: NodeInfoApi + @Inject lateinit var instanceInfoRepository: InstanceInfoRepository + @Inject + lateinit var appDatabase: AppDatabase + /** * Tests set this to return a customised fake [InstanceV1]. * * After setting this tests must call [InstanceInfoRepository.reload] so * the repository re-fetches the data. */ - private var instanceResponseCallback: (() -> InstanceV1)? = null + private var instanceResponseCallback: (() -> InstanceV1) = { getInstanceWithCustomConfiguration() } + + private val account = Account( + id = "1", + localUsername = "username", + username = "username@domain.example", + displayName = "Display Name", + createdAt = Date.from(Instant.now()), + note = "", + url = "", + avatar = "", + header = "", + ) @Before - fun setup() { + fun setup() = runTest { hilt.inject() reset(mastodonApi) mastodonApi.stub { - onBlocking { getCustomEmojis() } doReturn NetworkResult.success(emptyList()) - onBlocking { getInstanceV1() } doAnswer { - instanceResponseCallback?.invoke().let { instance -> - if (instance == null) { - NetworkResult.failure(Throwable()) - } else { - NetworkResult.success(instance) - } - } + onBlocking { accountVerifyCredentials(anyOrNull(), anyOrNull()) } doReturn success(account) + onBlocking { getCustomEmojis() } doReturn success(emptyList()) + onBlocking { getInstanceV2() } doReturn failure() + onBlocking { getInstanceV1(anyOrNull()) } doAnswer { + instanceResponseCallback.invoke().let { success(it) } } + onBlocking { getLists() } doReturn success(emptyList()) + onBlocking { listAnnouncements(any()) } doReturn success(emptyList()) + onBlocking { getContentFilters() } doReturn success(emptyList()) + onBlocking { getContentFiltersV1() } doReturn success(emptyList()) } - accountManager.addAccount( + reset(nodeInfoApi) + nodeInfoApi.stub { + onBlocking { nodeInfoJrd() } doReturn success( + UnvalidatedJrd( + listOf( + UnvalidatedJrd.Link( + "http://nodeinfo.diaspora.software/ns/schema/2.1", + "https://example.com", + ), + ), + ), + ) + onBlocking { nodeInfo(any()) } doReturn success( + UnvalidatedNodeInfo(UnvalidatedNodeInfo.Software("mastodon", "4.2.0")), + ) + } + + accountManager.verifyAndAddAccount( accessToken = "token", domain = "domain.example", clientId = "id", clientSecret = "secret", oauthScopes = "scopes", - newAccount = Account( - id = "1", - localUsername = "username", - username = "username@domain.example", - displayName = "Display Name", - createdAt = Date.from(Instant.now()), - note = "", - url = "", - avatar = "", - header = "", - ), ) + .andThen { accountManager.setActiveAccount(it) } + .onSuccess { accountManager.refresh(it) } + } + + @After + fun tearDown() { + appDatabase.close() } @Test diff --git a/core/data/src/test/kotlin/app/pachli/core/data/repository/filtersRepository/BaseContentFiltersRepositoryTest.kt b/core/data/src/test/kotlin/app/pachli/core/data/repository/filtersRepository/BaseContentFiltersRepositoryTest.kt index 2a0fb61e4..cd5b861ce 100644 --- a/core/data/src/test/kotlin/app/pachli/core/data/repository/filtersRepository/BaseContentFiltersRepositoryTest.kt +++ b/core/data/src/test/kotlin/app/pachli/core/data/repository/filtersRepository/BaseContentFiltersRepositoryTest.kt @@ -19,29 +19,48 @@ package app.pachli.core.data.repository.filtersRepository import android.app.Application import androidx.test.ext.junit.runners.AndroidJUnit4 -import app.pachli.core.data.repository.ContentFiltersRepository +import app.pachli.core.data.repository.AccountManager import app.pachli.core.data.repository.HiltTestApplication_Application -import app.pachli.core.data.repository.ServerRepository -import app.pachli.core.model.ServerKind -import app.pachli.core.model.ServerOperation -import app.pachli.core.network.Server +import app.pachli.core.data.repository.OfflineFirstContentFiltersRepository +import app.pachli.core.data.source.ContentFiltersLocalDataSource +import app.pachli.core.data.source.ContentFiltersRemoteDataSource +import app.pachli.core.database.AppDatabase +import app.pachli.core.database.dao.ContentFiltersDao +import app.pachli.core.database.dao.InstanceDao +import app.pachli.core.network.di.test.DEFAULT_INSTANCE_V2 +import app.pachli.core.network.model.Account +import app.pachli.core.network.model.Filter +import app.pachli.core.network.model.FilterAction +import app.pachli.core.network.model.FilterContext +import app.pachli.core.network.model.FilterKeyword as NetworkFilterKeyword +import app.pachli.core.network.model.FilterV1 +import app.pachli.core.network.model.InstanceV1 +import app.pachli.core.network.model.nodeinfo.UnvalidatedJrd +import app.pachli.core.network.model.nodeinfo.UnvalidatedNodeInfo import app.pachli.core.network.retrofit.MastodonApi +import app.pachli.core.network.retrofit.NodeInfoApi +import app.pachli.core.testing.failure import app.pachli.core.testing.rules.MainCoroutineRule -import com.github.michaelbull.result.Ok +import app.pachli.core.testing.success +import com.github.michaelbull.result.andThen +import com.github.michaelbull.result.onSuccess import dagger.hilt.android.testing.CustomTestApplication import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest -import io.github.z4kn4fein.semver.Version -import io.github.z4kn4fein.semver.toVersion +import java.time.Instant +import java.util.Date import javax.inject.Inject -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.After import org.junit.Before import org.junit.Rule import org.junit.runner.RunWith -import org.mockito.kotlin.mock +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.doReturn import org.mockito.kotlin.reset -import org.mockito.kotlin.whenever +import org.mockito.kotlin.stub import org.robolectric.annotation.Config open class PachliHiltApplication : Application() @@ -62,41 +81,222 @@ abstract class BaseContentFiltersRepositoryTest { @Inject lateinit var mastodonApi: MastodonApi - protected lateinit var contentFiltersRepository: ContentFiltersRepository + @Inject + lateinit var nodeInfoApi: NodeInfoApi - val serverFlow = MutableStateFlow(Ok(SERVER_V2)) + @Inject + lateinit var appDatabase: AppDatabase - private val serverRepository: ServerRepository = mock { - whenever(it.flow).thenReturn(serverFlow) - } + @Inject + lateinit var localDataSource: ContentFiltersLocalDataSource + @Inject + lateinit var remoteDataSource: ContentFiltersRemoteDataSource + + @Inject + lateinit var accountManager: AccountManager + + @Inject + lateinit var instanceDao: InstanceDao + + @Inject + lateinit var contentFiltersDao: ContentFiltersDao + + protected lateinit var contentFiltersRepository: OfflineFirstContentFiltersRepository + + /** Filters that should be returned by mastodonApi.getContentFilters(). */ + protected val networkFilters = mutableListOf() + + /** Filters that should be returned by mastodonApi.getContentFiltersV1(). */ + protected val networkFiltersV1 = mutableListOf() + + protected var pachliAccountId = 0L + + protected val account = Account( + id = "1", + localUsername = "username", + username = "username@domain.example", + displayName = "Display Name", + createdAt = Date.from(Instant.now()), + note = "", + url = "", + avatar = "", + header = "", + ) +} + +abstract class V2Test : BaseContentFiltersRepositoryTest() { @Before - fun setup() { + fun setup() = runTest { hilt.inject() reset(mastodonApi) + mastodonApi.stub { + // API calls when registering an account + onBlocking { accountVerifyCredentials(anyOrNull(), anyOrNull()) } doReturn success(account) + onBlocking { getInstanceV2() } doReturn success(DEFAULT_INSTANCE_V2) + onBlocking { getLists() } doReturn success(emptyList()) + onBlocking { getCustomEmojis() } doReturn success(emptyList()) + onBlocking { listAnnouncements(any()) } doReturn success(emptyList()) + onBlocking { getContentFilters() } doReturn success(networkFilters) + } - contentFiltersRepository = ContentFiltersRepository( + networkFilters.clear() + + reset(nodeInfoApi) + nodeInfoApi.stub { + onBlocking { nodeInfoJrd() } doReturn success( + UnvalidatedJrd( + listOf( + UnvalidatedJrd.Link( + "http://nodeinfo.diaspora.software/ns/schema/2.1", + "https://example.com", + ), + ), + ), + ) + onBlocking { nodeInfo(any()) } doReturn success( + UnvalidatedNodeInfo(UnvalidatedNodeInfo.Software("mastodon", "4.2.0")), + ) + } + + accountManager.verifyAndAddAccount( + accessToken = "token", + domain = "domain.example", + clientId = "id", + clientSecret = "secret", + oauthScopes = "scopes", + ) + .andThen { accountManager.setActiveAccount(it) } + .onSuccess { + pachliAccountId = it.id + accountManager.refresh(it) + } + + contentFiltersRepository = OfflineFirstContentFiltersRepository( TestScope(), - mastodonApi, - serverRepository, + localDataSource, + remoteDataSource, + instanceDao, ) } - companion object { - val SERVER_V2 = Server( - kind = ServerKind.MASTODON, - version = Version(4, 2, 0), - capabilities = mapOf( - Pair(ServerOperation.ORG_JOINMASTODON_FILTERS_SERVER, "1.0.0".toVersion(true)), - ), - ) - val SERVER_V1 = Server( - kind = ServerKind.MASTODON, - version = Version(4, 2, 0), - capabilities = mapOf( - Pair(ServerOperation.ORG_JOINMASTODON_FILTERS_CLIENT, "1.1.0".toVersion(true)), - ), - ) + @After + fun tearDown() { + appDatabase.close() } } + +abstract class V1Test : BaseContentFiltersRepositoryTest() { + private val instanceV1 = InstanceV1( + uri = "https://example.com", + version = "4.3.0", + ) + + @Before + fun setup() = runTest { + hilt.inject() + + reset(mastodonApi) + mastodonApi.stub { + // API calls when registering an account + onBlocking { accountVerifyCredentials(anyOrNull(), anyOrNull()) } doReturn success(account) + onBlocking { getInstanceV2() } doReturn failure() + onBlocking { getInstanceV1() } doReturn success(instanceV1) + onBlocking { getLists() } doReturn success(emptyList()) + onBlocking { getCustomEmojis() } doReturn success(emptyList()) + onBlocking { listAnnouncements(any()) } doReturn success(emptyList()) + onBlocking { getContentFiltersV1() } doReturn success(networkFiltersV1) + } + + reset(nodeInfoApi) + nodeInfoApi.stub { + onBlocking { nodeInfoJrd() } doReturn success( + UnvalidatedJrd( + listOf( + UnvalidatedJrd.Link( + "http://nodeinfo.diaspora.software/ns/schema/2.1", + "https://example.com", + ), + ), + ), + ) + onBlocking { nodeInfo(any()) } doReturn success( + UnvalidatedNodeInfo(UnvalidatedNodeInfo.Software("mastodon", "3.9.0")), + ) + } + + accountManager.verifyAndAddAccount( + accessToken = "token", + domain = "domain.example", + clientId = "id", + clientSecret = "secret", + oauthScopes = "scopes", + ) + .andThen { accountManager.setActiveAccount(it) } + .onSuccess { + pachliAccountId = it.id + accountManager.refresh(it) + } + + contentFiltersRepository = OfflineFirstContentFiltersRepository( + TestScope(), + localDataSource, + remoteDataSource, + instanceDao, + ) + } + + @After + fun tearDown() { + appDatabase.close() + } +} + +/** + * Helper function to add a filter to + * [BaseContentFiltersRepositoryTest.networkFilters]. + */ +fun MutableList.addNetworkFilter( + title: String, + contexts: Set = setOf(FilterContext.HOME), + action: FilterAction = FilterAction.WARN, + keywords: List = listOf("keyword"), +) { + add( + Filter( + id = this.size.toString(), + title = title, + contexts = contexts, + filterAction = action, + keywords = keywords.mapIndexed { index, s -> + NetworkFilterKeyword( + id = index.toString(), + keyword = s, + wholeWord = false, + ) + }, + ), + ) +} + +/** + * Helper function to add a filter to + * [BaseContentFiltersRepositoryTest.networkFiltersV1]. + */ +fun MutableList.addNetworkFilter( + phrase: String, + contexts: Set = setOf(FilterContext.HOME), + irreversible: Boolean = false, +) { + add( + FilterV1( + id = this.size.toString(), + phrase = phrase, + contexts = contexts, + irreversible = irreversible, + wholeWord = false, + expiresAt = null, + ), + ) +} diff --git a/core/data/src/test/kotlin/app/pachli/core/data/repository/filtersRepository/ContentFiltersRepositoryTestCreate.kt b/core/data/src/test/kotlin/app/pachli/core/data/repository/filtersRepository/ContentFiltersRepositoryTestCreate.kt index b35c86e20..28271b713 100644 --- a/core/data/src/test/kotlin/app/pachli/core/data/repository/filtersRepository/ContentFiltersRepositoryTestCreate.kt +++ b/core/data/src/test/kotlin/app/pachli/core/data/repository/filtersRepository/ContentFiltersRepositoryTestCreate.kt @@ -18,155 +18,198 @@ package app.pachli.core.data.repository.filtersRepository import app.cash.turbine.test +import app.pachli.core.data.repository.ContentFilters +import app.pachli.core.database.model.ContentFiltersEntity +import app.pachli.core.model.ContentFilter +import app.pachli.core.model.ContentFilterVersion import app.pachli.core.model.FilterAction import app.pachli.core.model.FilterContext +import app.pachli.core.model.FilterKeyword import app.pachli.core.model.NewContentFilter import app.pachli.core.model.NewContentFilterKeyword import app.pachli.core.network.model.Filter as NetworkFilter import app.pachli.core.network.model.FilterAction as NetworkFilterAction import app.pachli.core.network.model.FilterContext as NetworkFilterContext +import app.pachli.core.network.model.FilterKeyword as NetworkFilterKeyword import app.pachli.core.network.model.FilterV1 as NetworkFilterV1 import app.pachli.core.testing.success -import com.github.michaelbull.result.Ok +import com.github.michaelbull.result.get +import com.google.common.truth.Truth.assertThat import dagger.hilt.android.testing.HiltAndroidTest import java.util.Date -import kotlinx.coroutines.flow.update import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.junit.Test import org.mockito.kotlin.any import org.mockito.kotlin.doAnswer -import org.mockito.kotlin.doReturn import org.mockito.kotlin.stub import org.mockito.kotlin.times import org.mockito.kotlin.verify -@HiltAndroidTest -class ContentFiltersRepositoryTestCreate : BaseContentFiltersRepositoryTest() { - private val filterWithTwoKeywords = NewContentFilter( - title = "new filter", - contexts = setOf(FilterContext.HOME), - expiresIn = 300, - filterAction = FilterAction.WARN, - keywords = listOf( - NewContentFilterKeyword(keyword = "first", wholeWord = false), - NewContentFilterKeyword(keyword = "second", wholeWord = true), - ), - ) +/** + * Filter to use for testing. + * + * Has multiple keywords to ensure they are handled correctly. + */ +private val filterWithTwoKeywords = NewContentFilter( + title = "new filter", + contexts = setOf(FilterContext.HOME), + expiresIn = 300, + filterAction = FilterAction.WARN, + keywords = listOf( + NewContentFilterKeyword(keyword = "first", wholeWord = false), + NewContentFilterKeyword(keyword = "second", wholeWord = true), + ), +) +@HiltAndroidTest +class ContentFiltersRepositoryTestCreate : V2Test() { @Test - fun `creating v2 filter should send correct requests`() = runTest { + fun `creating v2 filter should have correct result`() = runTest { + /** Record the derived filter expiry time for comparison testing. */ + var expiresAt = Date() + mastodonApi.stub { - onBlocking { getContentFilters() } doReturn success(emptyList()) onBlocking { createFilter(any()) } doAnswer { call -> + val newContentFilter = call.getArgument(0) + val expiresIn = newContentFilter.expiresIn + expiresAt = Date(System.currentTimeMillis() + (expiresIn * 1000)) + success( NetworkFilter( id = "1", - title = call.getArgument(0).title, - contexts = call.getArgument(0).contexts.map { - NetworkFilterContext.from(it) - }.toSet(), - filterAction = NetworkFilterAction.from( - call.getArgument( - 0, - ).filterAction, - ), - expiresAt = Date( - System.currentTimeMillis() + ( - call.getArgument( - 0, - ).expiresIn * 1000 - ), - ), - keywords = emptyList(), + title = newContentFilter.title, + contexts = newContentFilter.contexts.map { NetworkFilterContext.from(it) }.toSet(), + filterAction = NetworkFilterAction.from(newContentFilter.filterAction), + expiresAt = expiresAt, + keywords = newContentFilter.keywords.mapIndexed { index, kw -> + NetworkFilterKeyword( + id = index.toString(), + keyword = kw.keyword, + wholeWord = kw.wholeWord, + ) + }, ), ) } } - contentFiltersRepository.contentFilters.test { + contentFiltersRepository.getContentFiltersFlow(pachliAccountId).test { + // Confirm there are no filters. advanceUntilIdle() + assertThat(awaitItem()).isEqualTo( + ContentFilters( + contentFilters = emptyList(), + version = ContentFilterVersion.V2, + ), + ) - contentFiltersRepository.createContentFilter(filterWithTwoKeywords) + // Create a new filter. + val result = contentFiltersRepository.createContentFilter(pachliAccountId, filterWithTwoKeywords) advanceUntilIdle() + val expected = ContentFilter( + id = "1", + title = filterWithTwoKeywords.title, + contexts = filterWithTwoKeywords.contexts, + expiresAt = expiresAt, + filterAction = filterWithTwoKeywords.filterAction, + keywords = filterWithTwoKeywords.keywords.mapIndexed { index, newContentFilterKeyword -> + FilterKeyword( + id = index.toString(), + keyword = newContentFilterKeyword.keyword, + wholeWord = newContentFilterKeyword.wholeWord, + ) + }, + ) + + // createContentFilter should return the expected new filter + assertThat(result.get()).isEqualTo(expected) // createFilter should have been called once, with the correct arguments. verify(mastodonApi, times(1)).createFilter(filterWithTwoKeywords) - // Filters should have been refreshed - verify(mastodonApi, times(2)).getContentFilters() - - cancelAndConsumeRemainingEvents() - } - } - - // Test that "expiresIn = 0" in newFilter is converted to "". - @Test - fun `expiresIn of 0 is converted to empty string`() = runTest { - mastodonApi.stub { - onBlocking { getContentFilters() } doReturn success(emptyList()) - onBlocking { createFilter(any()) } doAnswer { call -> - success( - NetworkFilter( - id = "1", - title = call.getArgument(0).title, - contexts = call.getArgument(0).contexts.map { - NetworkFilterContext.from(it) - }.toSet(), - filterAction = NetworkFilterAction.from( - call.getArgument( - 0, - ).filterAction, - ), - expiresAt = null, - keywords = emptyList(), - ), - ) - } - } - - // The v2 filter creation test covers most things, this just verifies that - // createFilter converts a "0" expiresIn to the empty string. - contentFiltersRepository.contentFilters.test { - advanceUntilIdle() - verify(mastodonApi, times(1)).getContentFilters() - - val filterWithZeroExpiry = filterWithTwoKeywords.copy(expiresIn = 0) - contentFiltersRepository.createContentFilter(filterWithZeroExpiry) - advanceUntilIdle() - - verify(mastodonApi, times(1)).createFilter(filterWithZeroExpiry) + // Database should contain the expected filters. + val entity = contentFiltersDao.getByAccount(pachliAccountId) + assertThat(entity).isEqualTo( + ContentFiltersEntity( + accountId = pachliAccountId, + contentFilters = listOf(expected), + version = ContentFilterVersion.V2, + ), + ) + + // The flow should have emitted a new set of filters that includes the one just added. + val filters = awaitItem() + assertThat(filters).isEqualTo( + ContentFilters( + contentFilters = listOf(expected), + version = ContentFilterVersion.V2, + ), + ) cancelAndConsumeRemainingEvents() } } +} +@HiltAndroidTest +class ContentFiltersRepositoryTestCreateV1 : V1Test() { @Test fun `creating v1 filter should create one filter per keyword`() = runTest { + /** Record the derived filter expiry time for comparison testing. */ + var expiresAt = Date() + + /** Next ID to use when creating network filters. */ + var nextFilterId = 1 + + // Initialise with no existing filters, and API stubs for creating V1 filters. mastodonApi.stub { - onBlocking { getContentFiltersV1() } doReturn success(emptyList()) onBlocking { createFilterV1(any(), any(), any(), any(), any()) } doAnswer { call -> + val expiresIn = call.getArgument(4).toInt() + expiresAt = Date(System.currentTimeMillis() + (expiresIn * 1000)) + success( NetworkFilterV1( - id = "1", + id = (nextFilterId++).toString(), phrase = call.getArgument(0), contexts = call.getArgument(1), irreversible = call.getArgument(2), wholeWord = call.getArgument(3), - expiresAt = Date(System.currentTimeMillis() + (call.getArgument(4).toInt() * 1000)), + expiresAt = expiresAt, ), ) } } - serverFlow.update { Ok(SERVER_V1) } - - contentFiltersRepository.contentFilters.test { + contentFiltersRepository.getContentFiltersFlow(pachliAccountId).test { + // Confirm there are no filters advanceUntilIdle() - verify(mastodonApi, times(1)).getContentFiltersV1() + assertThat(awaitItem()).isEqualTo( + ContentFilters.EMPTY.copy( + version = ContentFilterVersion.V1, + ), + ) - contentFiltersRepository.createContentFilter(filterWithTwoKeywords) + // Create a new filter. + val result = contentFiltersRepository.createContentFilter(pachliAccountId, filterWithTwoKeywords) advanceUntilIdle() + val expected = ContentFilter( + id = "2", + title = filterWithTwoKeywords.keywords[1].keyword, + contexts = filterWithTwoKeywords.contexts, + expiresAt = expiresAt, + filterAction = filterWithTwoKeywords.filterAction, + keywords = listOf( + FilterKeyword( + id = "0", + keyword = filterWithTwoKeywords.keywords[1].keyword, + wholeWord = filterWithTwoKeywords.keywords[1].wholeWord, + ), + ), + ) + + // createContentFilter should return the expected new filter + assertThat(result.get()).isEqualTo(expected) // createFilterV1 should have been called twice, once for each keyword filterWithTwoKeywords.keywords.forEach { keyword -> @@ -181,8 +224,24 @@ class ContentFiltersRepositoryTestCreate : BaseContentFiltersRepositoryTest() { ) } - // Filters should have been refreshed - verify(mastodonApi, times(2)).getContentFiltersV1() + // Database should contain the expected filters. + val entity = contentFiltersDao.getByAccount(pachliAccountId) + assertThat(entity).isEqualTo( + ContentFiltersEntity( + accountId = pachliAccountId, + contentFilters = listOf(expected), + version = ContentFilterVersion.V1, + ), + ) + + // The flow should have emitted a new set of filters that includes the one just added. + val filters = awaitItem() + assertThat(filters).isEqualTo( + ContentFilters( + contentFilters = listOf(expected), + version = ContentFilterVersion.V1, + ), + ) cancelAndConsumeRemainingEvents() } diff --git a/core/data/src/test/kotlin/app/pachli/core/data/repository/filtersRepository/ContentFiltersRepositoryTestDelete.kt b/core/data/src/test/kotlin/app/pachli/core/data/repository/filtersRepository/ContentFiltersRepositoryTestDelete.kt index 19599ca06..0c68d29bf 100644 --- a/core/data/src/test/kotlin/app/pachli/core/data/repository/filtersRepository/ContentFiltersRepositoryTestDelete.kt +++ b/core/data/src/test/kotlin/app/pachli/core/data/repository/filtersRepository/ContentFiltersRepositoryTestDelete.kt @@ -18,60 +18,96 @@ package app.pachli.core.data.repository.filtersRepository import app.cash.turbine.test +import app.pachli.core.data.model.from +import app.pachli.core.data.repository.ContentFilters +import app.pachli.core.model.ContentFilter +import app.pachli.core.model.ContentFilterVersion import app.pachli.core.testing.success -import com.github.michaelbull.result.Ok +import com.google.common.truth.Truth.assertThat import dagger.hilt.android.testing.HiltAndroidTest -import kotlinx.coroutines.flow.update import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.junit.Test -import org.mockito.kotlin.any +import org.mockito.ArgumentMatchers.anyString import org.mockito.kotlin.doReturn import org.mockito.kotlin.stub import org.mockito.kotlin.times import org.mockito.kotlin.verify @HiltAndroidTest -class ContentFiltersRepositoryTestDelete : BaseContentFiltersRepositoryTest() { +class ContentFiltersRepositoryTestV2Delete : V2Test() { @Test - fun `delete on v2 server should call delete and refresh`() = runTest { + fun `delete on v2 server should call delete`() = runTest { mastodonApi.stub { - onBlocking { getContentFilters() } doReturn success(emptyList()) - onBlocking { deleteFilter(any()) } doReturn success(Unit) + onBlocking { deleteFilter(anyString()) } doReturn success(Unit) } - contentFiltersRepository.contentFilters.test { - advanceUntilIdle() - verify(mastodonApi).getContentFilters() + // Configure API with a single filter. + networkFilters.addNetworkFilter("Test filter") + contentFiltersRepository.refresh(pachliAccountId) - contentFiltersRepository.deleteContentFilter("1") + contentFiltersRepository.getContentFiltersFlow(pachliAccountId).test { + // Confirm the flow contains the expected single filter advanceUntilIdle() - verify(mastodonApi, times(1)).deleteFilter("1") - verify(mastodonApi, times(2)).getContentFilters() + // Confirm flow now contains the new filters. + assertThat(awaitItem()).isEqualTo( + ContentFilters( + contentFilters = networkFilters.map { ContentFilter.from(it) }, + version = ContentFilterVersion.V2, + ), + ) - cancelAndConsumeRemainingEvents() - } - } - - @Test - fun `delete on v1 server should call delete and refresh`() = runTest { - mastodonApi.stub { - onBlocking { getContentFiltersV1() } doReturn success(emptyList()) - onBlocking { deleteFilterV1(any()) } doReturn success(Unit) - } - - serverFlow.update { Ok(SERVER_V1) } - - contentFiltersRepository.contentFilters.test { - advanceUntilIdle() - verify(mastodonApi).getContentFiltersV1() - - contentFiltersRepository.deleteContentFilter("1") + // Delete the filter. + contentFiltersRepository.deleteContentFilter(pachliAccountId, "0") advanceUntilIdle() - verify(mastodonApi, times(1)).deleteFilterV1("1") - verify(mastodonApi, times(2)).getContentFiltersV1() + // Confirm the flow contains no filters. + assertThat(awaitItem()).isEqualTo(ContentFilters.EMPTY) + + verify(mastodonApi, times(1)).deleteFilter("0") + + cancelAndConsumeRemainingEvents() + } + } +} + +@HiltAndroidTest +class ContentFiltersRepositoryTestV1Delete : V1Test() { + @Test + fun `delete on v1 server should call deleteFilterV1`() = runTest { + mastodonApi.stub { + onBlocking { deleteFilterV1(anyString()) } doReturn success(Unit) + } + + // Configure API with a single filter. + networkFiltersV1.addNetworkFilter("Test filter") + contentFiltersRepository.refresh(pachliAccountId) + + contentFiltersRepository.getContentFiltersFlow(pachliAccountId).test { + // Confirm the flow contains the expected single filter + advanceUntilIdle() + + // Confirm flow now contains the new filters. + assertThat(awaitItem()).isEqualTo( + ContentFilters( + contentFilters = networkFiltersV1.map { ContentFilter.from(it) }, + version = ContentFilterVersion.V1, + ), + ) + + // Delete the filter. + contentFiltersRepository.deleteContentFilter(pachliAccountId, "0") + advanceUntilIdle() + + // Confirm the flow contains no filters. + assertThat(awaitItem()).isEqualTo( + ContentFilters.EMPTY.copy( + version = ContentFilterVersion.V1, + ), + ) + + verify(mastodonApi, times(1)).deleteFilterV1("0") cancelAndConsumeRemainingEvents() } diff --git a/core/data/src/test/kotlin/app/pachli/core/data/repository/filtersRepository/ContentFiltersRepositoryTestFlow.kt b/core/data/src/test/kotlin/app/pachli/core/data/repository/filtersRepository/ContentFiltersRepositoryTestFlow.kt deleted file mode 100644 index 44e1c44e9..000000000 --- a/core/data/src/test/kotlin/app/pachli/core/data/repository/filtersRepository/ContentFiltersRepositoryTestFlow.kt +++ /dev/null @@ -1,219 +0,0 @@ -/* - * Copyright 2024 Pachli Association - * - * This file is a part of Pachli. - * - * This program is free software; you can redistribute it and/or modify it under the terms of the - * GNU General Public License as published by the Free Software Foundation; either version 3 of the - * License, or (at your option) any later version. - * - * Pachli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even - * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General - * Public License for more details. - * - * You should have received a copy of the GNU General Public License along with Pachli; if not, - * see . - */ - -package app.pachli.core.data.repository.filtersRepository - -import app.cash.turbine.test -import app.pachli.core.data.repository.ContentFilters -import app.pachli.core.data.repository.ContentFiltersError -import app.pachli.core.model.ContentFilter -import app.pachli.core.model.ContentFilterVersion -import app.pachli.core.model.FilterAction -import app.pachli.core.model.FilterContext -import app.pachli.core.model.FilterKeyword -import app.pachli.core.network.model.Filter as NetworkFilter -import app.pachli.core.network.model.FilterAction as NetworkFilterAction -import app.pachli.core.network.model.FilterContext as NetworkFilterContext -import app.pachli.core.network.model.FilterKeyword as NetworkFilterKeyword -import app.pachli.core.network.model.FilterV1 as NetworkFilterV1 -import app.pachli.core.network.retrofit.apiresult.ClientError -import app.pachli.core.testing.failure -import app.pachli.core.testing.success -import com.github.michaelbull.result.Ok -import com.github.michaelbull.result.get -import com.github.michaelbull.result.getError -import com.google.common.truth.Truth.assertThat -import dagger.hilt.android.testing.HiltAndroidTest -import java.util.Date -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.test.advanceUntilIdle -import kotlinx.coroutines.test.runTest -import org.junit.Test -import org.mockito.kotlin.doReturn -import org.mockito.kotlin.stub - -@HiltAndroidTest -class ContentFiltersRepositoryTestFlow : BaseContentFiltersRepositoryTest() { - @Test - fun `filters flow returns empty list when there are no v2 filters`() = runTest { - mastodonApi.stub { - onBlocking { getContentFiltersV1() } doReturn failure(body = "v1 should not be called") - onBlocking { getContentFilters() } doReturn success(emptyList()) - } - - contentFiltersRepository.contentFilters.test { - advanceUntilIdle() - val item = expectMostRecentItem() - val filters = item.get() - assertThat(filters).isEqualTo( - ContentFilters( - version = ContentFilterVersion.V2, - contentFilters = emptyList(), - ), - ) - } - } - - @Test - fun `filters flow contains initial set of v2 filters`() = runTest { - val expiresAt = Date() - - mastodonApi.stub { - onBlocking { getContentFiltersV1() } doReturn failure(body = "v1 should not be called") - onBlocking { getContentFilters() } doReturn success( - listOf( - NetworkFilter( - id = "1", - title = "test filter", - contexts = setOf(NetworkFilterContext.HOME), - filterAction = NetworkFilterAction.WARN, - expiresAt = expiresAt, - keywords = listOf( - NetworkFilterKeyword( - id = "1", - keyword = "foo", - wholeWord = true, - ), - ), - ), - ), - ) - } - - contentFiltersRepository.contentFilters.test { - advanceUntilIdle() - val item = expectMostRecentItem() - val filters = item.get() - assertThat(filters).isEqualTo( - ContentFilters( - version = ContentFilterVersion.V2, - contentFilters = listOf( - ContentFilter( - id = "1", - title = "test filter", - contexts = setOf(FilterContext.HOME), - filterAction = FilterAction.WARN, - expiresAt = expiresAt, - keywords = listOf( - FilterKeyword(id = "1", keyword = "foo", wholeWord = true), - ), - ), - ), - ), - ) - } - } - - @Test - fun `filters flow returns empty list when there are no v1 filters`() = runTest { - mastodonApi.stub { - onBlocking { getContentFilters() } doReturn failure(body = "v2 should not be called") - onBlocking { getContentFiltersV1() } doReturn success(emptyList()) - } - serverFlow.update { Ok(SERVER_V1) } - - contentFiltersRepository.contentFilters.test { - advanceUntilIdle() - val item = expectMostRecentItem() - val filters = item.get() - assertThat(filters).isEqualTo( - ContentFilters( - version = ContentFilterVersion.V1, - contentFilters = emptyList(), - ), - ) - } - } - - @Test - fun `filters flow contains initial set of v1 filters`() = runTest { - val expiresAt = Date() - - mastodonApi.stub { - onBlocking { getContentFilters() } doReturn failure(body = "v2 should not be called") - onBlocking { getContentFiltersV1() } doReturn success( - listOf( - NetworkFilterV1( - id = "1", - phrase = "some_phrase", - contexts = setOf(NetworkFilterContext.HOME), - expiresAt = expiresAt, - irreversible = true, - wholeWord = true, - ), - ), - ) - } - - serverFlow.update { Ok(SERVER_V1) } - - contentFiltersRepository.contentFilters.test { - advanceUntilIdle() - val item = expectMostRecentItem() - val filters = item.get() - assertThat(filters).isEqualTo( - ContentFilters( - version = ContentFilterVersion.V1, - contentFilters = listOf( - ContentFilter( - id = "1", - title = "some_phrase", - contexts = setOf(FilterContext.HOME), - filterAction = FilterAction.WARN, - expiresAt = expiresAt, - keywords = listOf( - FilterKeyword(id = "1", keyword = "some_phrase", wholeWord = true), - ), - ), - ), - ), - ) - } - } - - @Test - fun `HTTP 404 for v2 filters returns correct error type`() = runTest { - mastodonApi.stub { - onBlocking { getContentFilters() } doReturn failure(body = "{\"error\": \"error message\"}") - } - - contentFiltersRepository.contentFilters.test { - advanceUntilIdle() - val item = expectMostRecentItem() - val error = item.getError() as? ContentFiltersError.GetContentFiltersError - assertThat(error?.error).isInstanceOf(ClientError.NotFound::class.java) - assertThat(error?.error?.formatArgs).isEqualTo(arrayOf("error message")) - } - } - - @Test - fun `HTTP 404 for v1 filters returns correct error type`() = runTest { - mastodonApi.stub { - onBlocking { getContentFiltersV1() } doReturn failure(body = "{\"error\": \"error message\"}") - } - - serverFlow.update { Ok(SERVER_V1) } - - contentFiltersRepository.contentFilters.test { - advanceUntilIdle() - val item = expectMostRecentItem() - val error = item.getError() as? ContentFiltersError.GetContentFiltersError - assertThat(error?.error).isInstanceOf(ClientError.NotFound::class.java) - assertThat(error?.error?.formatArgs).isEqualTo(arrayOf("error message")) - } - } -} diff --git a/core/data/src/test/kotlin/app/pachli/core/data/repository/filtersRepository/ContentFiltersRepositoryTestReload.kt b/core/data/src/test/kotlin/app/pachli/core/data/repository/filtersRepository/ContentFiltersRepositoryTestReload.kt index e353e4b11..ee6e97834 100644 --- a/core/data/src/test/kotlin/app/pachli/core/data/repository/filtersRepository/ContentFiltersRepositoryTestReload.kt +++ b/core/data/src/test/kotlin/app/pachli/core/data/repository/filtersRepository/ContentFiltersRepositoryTestReload.kt @@ -18,62 +18,49 @@ package app.pachli.core.data.repository.filtersRepository import app.cash.turbine.test -import app.pachli.core.testing.failure -import app.pachli.core.testing.success -import com.github.michaelbull.result.Ok +import app.pachli.core.data.model.from +import app.pachli.core.data.repository.ContentFilters +import app.pachli.core.model.ContentFilter +import app.pachli.core.model.ContentFilterVersion +import com.google.common.truth.Truth.assertThat import dagger.hilt.android.testing.HiltAndroidTest -import kotlinx.coroutines.flow.update import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.junit.Test -import org.mockito.kotlin.doReturn import org.mockito.kotlin.never -import org.mockito.kotlin.stub import org.mockito.kotlin.times import org.mockito.kotlin.verify @HiltAndroidTest -class ContentFiltersRepositoryTestReload : BaseContentFiltersRepositoryTest() { +class ContentFiltersRepositoryTestReload : V2Test() { @Test - fun `reload should trigger a network request`() = runTest { - mastodonApi.stub { - onBlocking { getContentFiltersV1() } doReturn failure(body = "v1 should not be called") - onBlocking { getContentFilters() } doReturn success(emptyList()) - } + fun `reload should trigger a network request and update flow`() = runTest { + networkFilters.addNetworkFilter("Test filter") - contentFiltersRepository.contentFilters.test { + contentFiltersRepository.getContentFiltersFlow(pachliAccountId).test { + // Confirm there are no filters at start. advanceUntilIdle() - verify(mastodonApi).getContentFilters() + assertThat(awaitItem()).isEqualTo(ContentFilters.EMPTY) - contentFiltersRepository.reload() + contentFiltersRepository.refresh(pachliAccountId) advanceUntilIdle() + // Confirm flow now contains the new filters. + assertThat(awaitItem()).isEqualTo( + ContentFilters( + contentFilters = networkFilters.map { ContentFilter.from(it) }, + version = ContentFilterVersion.V2, + ), + ) + + // getContentFilters() Should be called twice. Once when the account + // was added, and a second time just now when refresh() was called. verify(mastodonApi, times(2)).getContentFilters() + + // V1 version should never be called, as this is a V2 server. verify(mastodonApi, never()).getContentFiltersV1() cancelAndConsumeRemainingEvents() } } - - @Test - fun `changing server should trigger a network request`() = runTest { - mastodonApi.stub { - onBlocking { getContentFiltersV1() } doReturn success(emptyList()) - onBlocking { getContentFilters() } doReturn success(emptyList()) - } - - contentFiltersRepository.contentFilters.test { - advanceUntilIdle() - verify(mastodonApi, times(1)).getContentFilters() - verify(mastodonApi, never()).getContentFiltersV1() - - serverFlow.update { Ok(SERVER_V1) } - advanceUntilIdle() - - verify(mastodonApi, times(1)).getContentFilters() - verify(mastodonApi, times(1)).getContentFiltersV1() - - cancelAndConsumeRemainingEvents() - } - } } diff --git a/core/data/src/test/kotlin/app/pachli/core/data/repository/filtersRepository/ContentFiltersRepositoryTestUpdate.kt b/core/data/src/test/kotlin/app/pachli/core/data/repository/filtersRepository/ContentFiltersRepositoryTestUpdate.kt index f1063b515..0be058364 100644 --- a/core/data/src/test/kotlin/app/pachli/core/data/repository/filtersRepository/ContentFiltersRepositoryTestUpdate.kt +++ b/core/data/src/test/kotlin/app/pachli/core/data/repository/filtersRepository/ContentFiltersRepositoryTestUpdate.kt @@ -20,18 +20,21 @@ package app.pachli.core.data.repository.filtersRepository import app.cash.turbine.test import app.pachli.core.data.model.from import app.pachli.core.data.repository.ContentFilterEdit +import app.pachli.core.data.repository.ContentFilters import app.pachli.core.model.ContentFilter +import app.pachli.core.model.ContentFilterVersion import app.pachli.core.model.FilterKeyword -import app.pachli.core.network.model.Filter as NetworkFilter import app.pachli.core.network.model.FilterAction as NetworkFilterAction import app.pachli.core.network.model.FilterContext as NetworkFilterContext import app.pachli.core.network.model.FilterKeyword as NetworkFilterKeyword import app.pachli.core.testing.success +import com.google.common.truth.Truth.assertThat import dagger.hilt.android.testing.HiltAndroidTest import java.util.Date import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.junit.Test +import org.mockito.ArgumentMatchers.anyString import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.doAnswer @@ -46,33 +49,27 @@ import org.mockito.kotlin.verify * creation of the [ContentFilterEdit] is tested in FilterViewDataTest.kt */ @HiltAndroidTest -class ContentFiltersRepositoryTestUpdate : BaseContentFiltersRepositoryTest() { - private val originalNetworkFilter = NetworkFilter( - id = "1", - title = "original filter", - contexts = setOf(NetworkFilterContext.HOME), - expiresAt = null, - filterAction = NetworkFilterAction.WARN, - keywords = listOf( - NetworkFilterKeyword(id = "1", keyword = "first", wholeWord = false), - NetworkFilterKeyword(id = "2", keyword = "second", wholeWord = true), - NetworkFilterKeyword(id = "3", keyword = "three", wholeWord = true), - NetworkFilterKeyword(id = "4", keyword = "four", wholeWord = true), - ), - ) - - private val originalContentFilter = ContentFilter.from(originalNetworkFilter) - +class ContentFiltersRepositoryTestUpdate : V2Test() { @Test fun `v2 update with no keyword changes should only call updateFilter once`() = runTest { + // Configure API with a single filter. + networkFilters.addNetworkFilter("Test filter") + contentFiltersRepository.refresh(pachliAccountId) + + val updatedTitle = "New title" + mastodonApi.stub { - onBlocking { getContentFilters() } doReturn success(emptyList()) + // Takes the original network filter and applies the update, returning the updated + // filter. onBlocking { updateFilter(any(), anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) } doAnswer { call -> + val id = call.getArgument(0) + val originalNetworkFilter = networkFilters.find { it.id == id }!! + success( originalNetworkFilter.copy( - title = call.getArgument(1) ?: originalContentFilter.title, - contexts = call.getArgument(2) ?: originalContentFilter.contexts.map { NetworkFilterContext.from(it) }.toSet(), - filterAction = call.getArgument(3) ?: NetworkFilterAction.from(originalContentFilter.filterAction), + title = call.getArgument(1) ?: originalNetworkFilter.title, + contexts = call.getArgument(2) ?: originalNetworkFilter.contexts, + filterAction = call.getArgument(3) ?: originalNetworkFilter.filterAction, expiresAt = call.getArgument(4)?.let { when (it) { "" -> null @@ -82,18 +79,38 @@ class ContentFiltersRepositoryTestUpdate : BaseContentFiltersRepositoryTest() { ), ) } - onBlocking { getFilter(originalNetworkFilter.id) } doReturn success(originalNetworkFilter) + // Returns a copy of the filter with the modified title. + onBlocking { getFilter(anyString()) } doAnswer { call -> + val id = call.getArgument(0) + success(networkFilters.first { it.id == id }.copy(title = updatedTitle)) + } } - val update = ContentFilterEdit(id = originalContentFilter.id, title = "new title") - - contentFiltersRepository.contentFilters.test { + contentFiltersRepository.getContentFiltersFlow(pachliAccountId).test { + // Confirm the initial filters are `networkFilters`. */ advanceUntilIdle() - verify(mastodonApi, times(1)).getContentFilters() + val contentFilters = awaitItem() + assertThat(contentFilters).isEqualTo( + ContentFilters( + contentFilters = networkFilters.map { ContentFilter.from(it) }, + version = ContentFilterVersion.V2, + ), + ) - contentFiltersRepository.updateContentFilter(originalContentFilter, update) + // Update the first filter. + val originalContentFilter = contentFilters.contentFilters.first() + val update = ContentFilterEdit(id = originalContentFilter.id, title = updatedTitle) + contentFiltersRepository.updateContentFilter( + pachliAccountId, + originalContentFilter, + update, + ) advanceUntilIdle() + // Confirm the first filter has the new title. + val newContentFilter = awaitItem().contentFilters.first() + assertThat(newContentFilter.title).isEqualTo("New title") + verify(mastodonApi, times(1)).updateFilter( id = update.id, title = update.title, @@ -112,8 +129,14 @@ class ContentFiltersRepositoryTestUpdate : BaseContentFiltersRepositoryTest() { @Test fun `v2 update with keyword changes should call updateFilter and the keyword methods`() = runTest { + // Configure API with a single filter. + networkFilters.addNetworkFilter( + "Test filter", + keywords = listOf("keyword one", "keyword two", "keyword three", "keyword four"), + ) + contentFiltersRepository.refresh(pachliAccountId) + mastodonApi.stub { - onBlocking { getContentFilters() } doReturn success(emptyList()) onBlocking { deleteFilterKeyword(any()) } doReturn success(Unit) onBlocking { updateFilterKeyword(any(), any(), any()) } doAnswer { call -> success(NetworkFilterKeyword(call.getArgument(0), call.getArgument(1), call.getArgument(2))) @@ -121,25 +144,35 @@ class ContentFiltersRepositoryTestUpdate : BaseContentFiltersRepositoryTest() { onBlocking { addFilterKeyword(any(), any(), any()) } doAnswer { call -> success(NetworkFilterKeyword("x", call.getArgument(1), call.getArgument(2))) } - onBlocking { getFilter(any()) } doReturn success(originalNetworkFilter) + // Return the unmodified filter. The actual network calls are checked, so this + // simplifies the test by not needing to recreate the modified filter. + onBlocking { getFilter(any()) } doReturn success(networkFilters.first()) } - val keywordToAdd = FilterKeyword(id = "", keyword = "new keyword", wholeWord = false) - val keywordToDelete = originalContentFilter.keywords[1] - val keywordToModify = originalContentFilter.keywords[0].copy(keyword = "new keyword") - - val update = ContentFilterEdit( - id = originalContentFilter.id, - keywordsToAdd = listOf(keywordToAdd), - keywordsToDelete = listOf(keywordToDelete), - keywordsToModify = listOf(keywordToModify), - ) - - contentFiltersRepository.contentFilters.test { + contentFiltersRepository.getContentFiltersFlow(pachliAccountId).test { + // Confirm the initial filters are `networkFilters`. */ advanceUntilIdle() - verify(mastodonApi, times(1)).getContentFilters() + val contentFilters = awaitItem() + assertThat(contentFilters).isEqualTo( + ContentFilters( + contentFilters = networkFilters.map { ContentFilter.from(it) }, + version = ContentFilterVersion.V2, + ), + ) - contentFiltersRepository.updateContentFilter(originalContentFilter, update) + // Update the first filter. + val originalContentFilter = contentFilters.contentFilters.first() + val keywordToAdd = FilterKeyword(id = "", keyword = "new keyword", wholeWord = false) + val keywordToDelete = originalContentFilter.keywords[1] + val keywordToModify = originalContentFilter.keywords[0].copy(keyword = "new keyword") + + val update = ContentFilterEdit( + id = originalContentFilter.id, + keywordsToAdd = listOf(keywordToAdd), + keywordsToDelete = listOf(keywordToDelete), + keywordsToModify = listOf(keywordToModify), + ) + contentFiltersRepository.updateContentFilter(pachliAccountId, originalContentFilter, update) advanceUntilIdle() // updateFilter() call should be skipped, as only the keywords have changed. diff --git a/core/data/src/test/resources/robolectric.properties b/core/data/src/test/resources/robolectric.properties new file mode 100644 index 000000000..69da0b97b --- /dev/null +++ b/core/data/src/test/resources/robolectric.properties @@ -0,0 +1,20 @@ +# +# Copyright 2023 Pachli Association +# +# This file is a part of Pachli. +# +# This program is free software; you can redistribute it and/or modify it under the terms of the +# GNU General Public License as published by the Free Software Foundation; either version 3 of the +# License, or (at your option) any later version. +# +# Pachli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even +# the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General +# Public License for more details. +# +# You should have received a copy of the GNU General Public License along with Pachli; if not, +# see . +# + +# Robolectric does not support SDK 34 yet +# https://github.com/robolectric/robolectric/issues/8404 +sdk=33 diff --git a/core/network/src/test/resources/server-versions.json5 b/core/data/src/test/resources/server-versions.json5 similarity index 100% rename from core/network/src/test/resources/server-versions.json5 rename to core/data/src/test/resources/server-versions.json5 diff --git a/core/database/build.gradle.kts b/core/database/build.gradle.kts index 9397d8fbb..9740d131d 100644 --- a/core/database/build.gradle.kts +++ b/core/database/build.gradle.kts @@ -43,4 +43,9 @@ dependencies { implementation(libs.moshix.sealed.runtime) ksp(libs.moshix.sealed.codegen) + + // ServerRepository + implementation(libs.semver)?.because("Converters has to convert Version") + + testImplementation(projects.core.testing) } diff --git a/core/database/lint-baseline.xml b/core/database/lint-baseline.xml index f32fed49a..98cd24e06 100644 --- a/core/database/lint-baseline.xml +++ b/core/database/lint-baseline.xml @@ -1,4 +1,4 @@ - + diff --git a/core/database/schemas/app.pachli.core.database.AppDatabase/9.json b/core/database/schemas/app.pachli.core.database.AppDatabase/9.json new file mode 100644 index 000000000..c04e37dda --- /dev/null +++ b/core/database/schemas/app.pachli.core.database.AppDatabase/9.json @@ -0,0 +1,1438 @@ +{ + "formatVersion": 1, + "database": { + "version": 9, + "identityHash": "0ebc4ca3fcd13edd0458661de5686960", + "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` INTEGER, `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": "INTEGER", + "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 NOT NULL, `clientSecret` TEXT NOT NULL, `isActive` INTEGER NOT NULL, `accountId` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `profilePictureUrl` TEXT NOT NULL, `profileHeaderPictureUrl` 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, `notificationsSeveredRelationships` INTEGER NOT NULL DEFAULT true, `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": true + }, + { + "fieldPath": "clientSecret", + "columnName": "clientSecret", + "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": "profileHeaderPictureUrl", + "columnName": "profileHeaderPictureUrl", + "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": "notificationsSeveredRelationships", + "columnName": "notificationsSeveredRelationships", + "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": "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": "InstanceInfoEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`instance` TEXT NOT NULL, `maxPostCharacters` 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, `enabledTranslation` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`instance`))", + "fields": [ + { + "fieldPath": "instance", + "columnName": "instance", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "maxPostCharacters", + "columnName": "maxPostCharacters", + "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": "enabledTranslation", + "columnName": "enabledTranslation", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "instance" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "EmojisEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `emojiList` TEXT NOT NULL, PRIMARY KEY(`accountId`), FOREIGN KEY(`accountId`) REFERENCES `AccountEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "emojiList", + "columnName": "emojiList", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "accountId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "accountId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "TimelineStatusEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `url` TEXT, `timelineUserId` INTEGER NOT NULL, `authorServerId` TEXT NOT NULL, `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, `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 DEFERRABLE INITIALLY DEFERRED)", + "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": true + }, + { + "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": "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": [] + }, + { + "tableName": "RemoteKeyEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `timelineId` TEXT NOT NULL, `kind` TEXT NOT NULL, `key` TEXT, PRIMARY KEY(`accountId`, `timelineId`, `kind`))", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timelineId", + "columnName": "timelineId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "accountId", + "timelineId", + "kind" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "StatusViewDataEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `timelineUserId` INTEGER NOT NULL, `expanded` INTEGER NOT NULL, `contentShowing` INTEGER NOT NULL, `contentCollapsed` INTEGER NOT NULL, `translationState` TEXT NOT NULL DEFAULT 'SHOW_ORIGINAL', PRIMARY KEY(`serverId`, `timelineUserId`))", + "fields": [ + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timelineUserId", + "columnName": "timelineUserId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "expanded", + "columnName": "expanded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentShowing", + "columnName": "contentShowing", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contentCollapsed", + "columnName": "contentCollapsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "translationState", + "columnName": "translationState", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'SHOW_ORIGINAL'" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "timelineUserId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "TranslatedStatusEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`serverId` TEXT NOT NULL, `timelineUserId` INTEGER NOT NULL, `content` TEXT NOT NULL, `spoilerText` TEXT NOT NULL, `poll` TEXT, `attachments` TEXT NOT NULL, `provider` 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": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "spoilerText", + "columnName": "spoilerText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "poll", + "columnName": "poll", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "provider", + "columnName": "provider", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "serverId", + "timelineUserId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "LogEntryEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `instant` INTEGER NOT NULL, `priority` INTEGER, `tag` TEXT, `message` TEXT NOT NULL, `t` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "instant", + "columnName": "instant", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "priority", + "columnName": "priority", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "tag", + "columnName": "tag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "message", + "columnName": "message", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "t", + "columnName": "t", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "MastodonListEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `listId` TEXT NOT NULL, `title` TEXT NOT NULL, `repliesPolicy` TEXT NOT NULL, `exclusive` INTEGER NOT NULL, PRIMARY KEY(`accountId`, `listId`), FOREIGN KEY(`accountId`) REFERENCES `AccountEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "listId", + "columnName": "listId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "repliesPolicy", + "columnName": "repliesPolicy", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "exclusive", + "columnName": "exclusive", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "accountId", + "listId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "accountId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "ServerEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `serverKind` TEXT NOT NULL, `version` TEXT NOT NULL, `capabilities` TEXT NOT NULL, PRIMARY KEY(`accountId`), FOREIGN KEY(`accountId`) REFERENCES `AccountEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serverKind", + "columnName": "serverKind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "capabilities", + "columnName": "capabilities", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "accountId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "accountId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "ContentFiltersEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `version` TEXT NOT NULL, `contentFilters` TEXT NOT NULL, PRIMARY KEY(`accountId`), FOREIGN KEY(`accountId`) REFERENCES `AccountEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contentFilters", + "columnName": "contentFilters", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "accountId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "accountId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "AnnouncementEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` INTEGER NOT NULL, `announcementId` TEXT NOT NULL, `announcement` TEXT NOT NULL, PRIMARY KEY(`accountId`), FOREIGN KEY(`accountId`) REFERENCES `AccountEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "announcementId", + "columnName": "announcementId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "announcement", + "columnName": "announcement", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "accountId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "AccountEntity", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "accountId" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "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, '0ebc4ca3fcd13edd0458661de5686960')" + ] + } +} \ No newline at end of file diff --git a/core/database/src/main/kotlin/app/pachli/core/database/AppDatabase.kt b/core/database/src/main/kotlin/app/pachli/core/database/AppDatabase.kt index 9360d9265..c88a38cb3 100644 --- a/core/database/src/main/kotlin/app/pachli/core/database/AppDatabase.kt +++ b/core/database/src/main/kotlin/app/pachli/core/database/AppDatabase.kt @@ -20,32 +20,43 @@ package app.pachli.core.database import android.annotation.SuppressLint import android.content.ContentValues import android.database.sqlite.SQLiteDatabase.CONFLICT_ABORT +import android.database.sqlite.SQLiteDatabase.CONFLICT_IGNORE import androidx.core.database.getStringOrNull import androidx.room.AutoMigration import androidx.room.Database import androidx.room.DeleteColumn import androidx.room.RenameColumn +import androidx.room.RenameTable import androidx.room.RoomDatabase import androidx.room.migration.AutoMigrationSpec import androidx.sqlite.db.SupportSQLiteDatabase import app.pachli.core.database.dao.AccountDao +import app.pachli.core.database.dao.AnnouncementsDao +import app.pachli.core.database.dao.ContentFiltersDao import app.pachli.core.database.dao.ConversationsDao import app.pachli.core.database.dao.DraftDao import app.pachli.core.database.dao.InstanceDao +import app.pachli.core.database.dao.ListsDao import app.pachli.core.database.dao.LogEntryDao import app.pachli.core.database.dao.RemoteKeyDao import app.pachli.core.database.dao.TimelineDao import app.pachli.core.database.dao.TranslatedStatusDao import app.pachli.core.database.model.AccountEntity +import app.pachli.core.database.model.AnnouncementEntity +import app.pachli.core.database.model.ContentFiltersEntity import app.pachli.core.database.model.ConversationEntity import app.pachli.core.database.model.DraftEntity -import app.pachli.core.database.model.InstanceEntity +import app.pachli.core.database.model.EmojisEntity +import app.pachli.core.database.model.InstanceInfoEntity import app.pachli.core.database.model.LogEntryEntity +import app.pachli.core.database.model.MastodonListEntity import app.pachli.core.database.model.RemoteKeyEntity +import app.pachli.core.database.model.ServerEntity import app.pachli.core.database.model.StatusViewDataEntity import app.pachli.core.database.model.TimelineAccountEntity import app.pachli.core.database.model.TimelineStatusEntity import app.pachli.core.database.model.TranslatedStatusEntity +import app.pachli.core.model.ContentFilterVersion import java.text.SimpleDateFormat import java.util.Locale import java.util.TimeZone @@ -55,7 +66,8 @@ import java.util.TimeZone entities = [ DraftEntity::class, AccountEntity::class, - InstanceEntity::class, + InstanceInfoEntity::class, + EmojisEntity::class, TimelineStatusEntity::class, TimelineAccountEntity::class, ConversationEntity::class, @@ -63,8 +75,12 @@ import java.util.TimeZone StatusViewDataEntity::class, TranslatedStatusEntity::class, LogEntryEntity::class, + MastodonListEntity::class, + ServerEntity::class, + ContentFiltersEntity::class, + AnnouncementEntity::class, ], - version = 8, + version = 9, autoMigrations = [ AutoMigration(from = 1, to = 2, spec = AppDatabase.MIGRATE_1_2::class), AutoMigration(from = 2, to = 3), @@ -73,6 +89,7 @@ import java.util.TimeZone AutoMigration(from = 5, to = 6), AutoMigration(from = 6, to = 7, spec = AppDatabase.MIGRATE_6_7::class), AutoMigration(from = 7, to = 8, spec = AppDatabase.MIGRATE_7_8::class), + AutoMigration(from = 8, to = 9, spec = AppDatabase.MIGRATE_8_9::class), ], ) abstract class AppDatabase : RoomDatabase() { @@ -84,6 +101,9 @@ abstract class AppDatabase : RoomDatabase() { abstract fun remoteKeyDao(): RemoteKeyDao abstract fun translatedStatusDao(): TranslatedStatusDao abstract fun logEntryDao(): LogEntryDao + abstract fun contentFiltersDao(): ContentFiltersDao + abstract fun listsDao(): ListsDao + abstract fun announcementsDao(): AnnouncementsDao @DeleteColumn("TimelineStatusEntity", "expanded") @DeleteColumn("TimelineStatusEntity", "contentCollapsed") @@ -139,4 +159,58 @@ abstract class AppDatabase : RoomDatabase() { @DeleteColumn("DraftEntity", "scheduledAt") @RenameColumn("DraftEntity", "scheduledAtLong", "scheduledAt") class MIGRATE_7_8 : AutoMigrationSpec + + /** + * Populates new tables with default data for existing accounts. + * + * Sets up: + * + * - InstanceInfoEntity + * - ServerEntity + * - ContentFiltersEntity + */ + @DeleteColumn("InstanceEntity", "emojiList") + @RenameColumn( + "InstanceEntity", + fromColumnName = "maximumTootCharacters", + toColumnName = "maxPostCharacters", + ) + @RenameTable(fromTableName = "InstanceEntity", toTableName = "InstanceInfoEntity") + class MIGRATE_8_9 : AutoMigrationSpec { + override fun onPostMigrate(db: SupportSQLiteDatabase) { + db.beginTransaction() + + val accountCursor = db.query("SELECT id, domain FROM AccountEntity") + with(accountCursor) { + while (moveToNext()) { + val accountId = getLong(0) + val domain = getString(1) + + val instanceInfoEntityValues = ContentValues().apply { + put("instance", domain) + put("enabledTranslation", 0) + } + db.insert("InstanceInfoEntity", CONFLICT_IGNORE, instanceInfoEntityValues) + + val serverEntityValues = ContentValues().apply { + put("accountId", accountId) + put("serverKind", "UNKNOWN") + put("version", "0.0.1") + put("capabilities", "{}") + } + db.insert("ServerEntity", CONFLICT_IGNORE, serverEntityValues) + + val contentFiltersEntityValues = ContentValues().apply { + put("accountId", accountId) + put("version", ContentFilterVersion.V1.name) + put("contentFilters", "[]") + } + db.insert("ContentFiltersEntity", CONFLICT_IGNORE, contentFiltersEntityValues) + } + } + + db.setTransactionSuccessful() + db.endTransaction() + } + } } diff --git a/core/database/src/main/kotlin/app/pachli/core/database/Converters.kt b/core/database/src/main/kotlin/app/pachli/core/database/Converters.kt index 2712f7c8a..4cbfe2538 100644 --- a/core/database/src/main/kotlin/app/pachli/core/database/Converters.kt +++ b/core/database/src/main/kotlin/app/pachli/core/database/Converters.kt @@ -20,7 +20,10 @@ import androidx.room.ProvidedTypeConverter import androidx.room.TypeConverter import app.pachli.core.database.model.ConversationAccountEntity import app.pachli.core.database.model.DraftAttachment +import app.pachli.core.model.ContentFilter +import app.pachli.core.model.ServerOperation import app.pachli.core.model.Timeline +import app.pachli.core.network.model.Announcement import app.pachli.core.network.model.Attachment import app.pachli.core.network.model.Emoji import app.pachli.core.network.model.FilterResult @@ -32,6 +35,7 @@ import app.pachli.core.network.model.TranslatedAttachment import app.pachli.core.network.model.TranslatedPoll import com.squareup.moshi.Moshi import com.squareup.moshi.adapter +import io.github.z4kn4fein.semver.Version import java.net.URLDecoder import java.time.Instant import java.util.Date @@ -241,4 +245,33 @@ class Converters @Inject constructor( @TypeConverter fun stringToThrowable(s: String) = Throwable(message = s) + + @TypeConverter + fun capabilitiesMapToJson(capabilities: Map): String { + return moshi.adapter>().toJson(capabilities) + } + + @TypeConverter + fun jsonToCapabiltiesMap(capabilitiesJson: String?): Map? { + return capabilitiesJson?.let { moshi.adapter>().fromJson(it) } + } + + @TypeConverter + fun contentFiltersToJson(contentFilters: List): String = + moshi.adapter>().toJson(contentFilters) + + @TypeConverter + fun jsonToContentFilters(s: String?) = s?.let { moshi.adapter>().fromJson(it) } + + @TypeConverter + fun versionToString(version: Version): String = version.toString() + + @TypeConverter + fun stringToVersion(s: String?) = s?.let { Version.parse(it) } + + @TypeConverter + fun announcementToJson(announcement: Announcement): String = moshi.adapter().toJson(announcement) + + @TypeConverter + fun jsonToAnnouncement(s: String?) = s?.let { moshi.adapter().fromJson(it) } } diff --git a/core/database/src/main/kotlin/app/pachli/core/database/dao/AccountDao.kt b/core/database/src/main/kotlin/app/pachli/core/database/dao/AccountDao.kt index 6f63924c7..6e49cddec 100644 --- a/core/database/src/main/kotlin/app/pachli/core/database/dao/AccountDao.kt +++ b/core/database/src/main/kotlin/app/pachli/core/database/dao/AccountDao.kt @@ -19,19 +19,391 @@ package app.pachli.core.database.dao import androidx.room.Dao import androidx.room.Delete -import androidx.room.Insert -import androidx.room.OnConflictStrategy import androidx.room.Query +import androidx.room.Transaction +import androidx.room.TypeConverters +import androidx.room.Update +import androidx.room.Upsert +import app.pachli.core.database.Converters import app.pachli.core.database.model.AccountEntity +import app.pachli.core.database.model.PachliAccount +import app.pachli.core.model.Timeline +import app.pachli.core.network.model.Status +import kotlinx.coroutines.flow.Flow @Dao +@TypeConverters(Converters::class) interface AccountDao { - @Insert(onConflict = OnConflictStrategy.REPLACE) - fun insertOrReplace(account: AccountEntity): Long + @Transaction + @Query( + """ + SELECT * + FROM AccountEntity + WHERE id = :accountId + """, + ) + suspend fun getPachliAccount(accountId: Long): PachliAccount? + + @Transaction + @Query( + """ + SELECT * + FROM AccountEntity + WHERE id = :accountId + """, + ) + fun getPachliAccountFlow(accountId: Long): Flow + + @Transaction + @Query( + """ + SELECT * + FROM AccountEntity + WHERE isActive = 1 + """, + ) + fun getActivePachliAccountFlow(): Flow + + @Update + suspend fun update(account: AccountEntity) + + @Upsert + suspend fun upsert(account: AccountEntity): Long @Delete - fun delete(account: AccountEntity) + suspend fun delete(account: AccountEntity) @Query("SELECT * FROM AccountEntity ORDER BY id ASC") - fun loadAll(): List + fun loadAllFlow(): Flow> + + @Query("SELECT * FROM AccountEntity ORDER BY id ASC") + suspend fun loadAll(): List + + @Query("SELECT id FROM AccountEntity WHERE isActive = 1") + fun getActiveAccountId(): Flow + + @Query( + """ + SELECT * + FROM AccountEntity + ORDER BY isActive, id ASC + """, + ) + fun getAccountsOrderedByActive(): Flow> + + @Query( + """ + SELECT * + FROM AccountEntity + WHERE isActive = 1 + """, + ) + fun getActiveAccountFlow(): Flow + + @Query( + """ + SELECT * + FROM AccountEntity + WHERE isActive = 1 + """, + ) + suspend fun getActiveAccount(): AccountEntity? + + @Query( + """ + UPDATE AccountEntity + SET isActive = 0 + """, + ) + suspend fun clearActiveAccount() + + @Query( + """ + SELECT * + FROM AccountEntity + WHERE id = :id + """, + ) + suspend fun getAccountById(id: Long): AccountEntity? + + @Query( + """ + SELECT * + FROM AccountEntity + WHERE domain = :domain AND accountId = :accountId + """, + ) + suspend fun getAccountByIdAndDomain(accountId: String, domain: String): AccountEntity? + + @Query( + """ + SELECT COUNT(id) + FROM AccountEntity + WHERE notificationsEnabled = 1 + """, + ) + suspend fun countAccountsWithNotificationsEnabled(): Int + + @Query( + """ + UPDATE AccountEntity + SET unifiedPushUrl = :unifiedPushUrl, + pushServerKey = :pushServerKey, + pushAuth = :pushAuth, + pushPrivKey = :pushPrivKey, + pushPubKey = :pushPubKey + WHERE id = :accountId + """, + ) + suspend fun setPushNotificationData( + accountId: Long, + unifiedPushUrl: String, + pushServerKey: String, + pushAuth: String, + pushPrivKey: String, + pushPubKey: String, + ) + + @Query( + """ + UPDATE AccountEntity + SET accessToken = "", + clientId = "", + clientSecret = "" + WHERE id = :accountId + """, + ) + suspend fun clearLoginCredentials(accountId: Long) + + @Query( + """ + UPDATE AccountEntity + SET alwaysShowSensitiveMedia = :value + WHERE id = :accountId + """, + ) + suspend fun setAlwaysShowSensitiveMedia(accountId: Long, value: Boolean) + + @Query( + """ + UPDATE AccountEntity + SET alwaysOpenSpoiler = :value + WHERE id = :accountId + """, + ) + suspend fun setAlwaysOpenSpoiler(accountId: Long, value: Boolean) + + @Query( + """ + UPDATE AccountEntity + SET mediaPreviewEnabled = :value + WHERE id = :accountId + """, + ) + suspend fun setMediaPreviewEnabled(accountId: Long, value: Boolean) + + @Query( + """ + UPDATE AccountEntity + SET tabPreferences = :value + WHERE id = :accountId + """, + ) + suspend fun setTabPreferences(accountId: Long, value: List) + + @Query( + """ + UPDATE AccountEntity + SET notificationMarkerId = :value + WHERE id = :accountId + """, + ) + suspend fun setNotificationMarkerId(accountId: Long, value: String) + + @Query( + """ + UPDATE AccountEntity + SET notificationsFilter = :value + WHERE id = :accountId + """, + ) + suspend fun setNotificationsFilter(accountId: Long, value: String) + + @Query( + """ + UPDATE AccountEntity + SET lastNotificationId = :value + WHERE id = :accountId + """, + ) + suspend fun setLastNotificationId(accountId: Long, value: String) + + // TODO: Should be suspend + @Query( + """ + UPDATE AccountEntity + SET defaultPostPrivacy = :value + WHERE id = :accountId + """, + ) + fun setDefaultPostPrivacy(accountId: Long, value: Status.Visibility) + + // TODO: Should be suspend + @Query( + """ + UPDATE AccountEntity + SET defaultMediaSensitivity = :value + WHERE id = :accountId + """, + ) + fun setDefaultMediaSensitivity(accountId: Long, value: Boolean) + + // TODO: Should be suspend + @Query( + """ + UPDATE AccountEntity + SET defaultPostLanguage = :value + WHERE id = :accountId + """, + ) + fun setDefaultPostLanguage(accountId: Long, value: String) + + // TODO: Should be suspend + @Query( + """ + UPDATE AccountEntity + SET notificationsEnabled = :value + WHERE id = :accountId + """, + ) + fun setNotificationsEnabled(accountId: Long, value: Boolean) + + // TODO: Should be suspend + @Query( + """ + UPDATE AccountEntity + SET notificationsFollowed = :value + WHERE id = :accountId + """, + ) + fun setNotificationsFollowed(accountId: Long, value: Boolean) + + // TODO: Should be suspend + @Query( + """ + UPDATE AccountEntity + SET notificationsFollowRequested = :value + WHERE id = :accountId + """, + ) + fun setNotificationsFollowRequested(accountId: Long, value: Boolean) + + // TODO: Should be suspend + @Query( + """ + UPDATE AccountEntity + SET notificationsReblogged = :value + WHERE id = :accountId + """, + ) + fun setNotificationsReblogged(accountId: Long, value: Boolean) + + // TODO: Should be suspend + @Query( + """ + UPDATE AccountEntity + SET notificationsFavorited = :value + WHERE id = :accountId + """, + ) + fun setNotificationsFavorited(accountId: Long, value: Boolean) + + // TODO: Should be suspend + @Query( + """ + UPDATE AccountEntity + SET notificationsPolls = :value + WHERE id = :accountId + """, + ) + fun setNotificationsPolls(accountId: Long, value: Boolean) + + // TODO: Should be suspend + @Query( + """ + UPDATE AccountEntity + SET notificationsSubscriptions = :value + WHERE id = :accountId + """, + ) + fun setNotificationsSubscriptions(accountId: Long, value: Boolean) + + // TODO: Should be suspend + @Query( + """ + UPDATE AccountEntity + SET notificationsSignUps = :value + WHERE id = :accountId + """, + ) + fun setNotificationsSignUps(accountId: Long, value: Boolean) + + // TODO: Should be suspend + @Query( + """ + UPDATE AccountEntity + SET notificationsUpdates = :value + WHERE id = :accountId + """, + ) + fun setNotificationsUpdates(accountId: Long, value: Boolean) + + // TODO: Should be suspend + @Query( + """ + UPDATE AccountEntity + SET notificationsReports = :value + WHERE id = :accountId + """, + ) + fun setNotificationsReports(accountId: Long, value: Boolean) + + // TODO: Should be suspend + @Query( + """ + UPDATE AccountEntity + SET notificationSound = :value + WHERE id = :accountId + """, + ) + fun setNotificationSound(accountId: Long, value: Boolean) + + // TODO: Should be suspend + @Query( + """ + UPDATE AccountEntity + SET notificationVibration = :value + WHERE id = :accountId + """, + ) + fun setNotificationVibration(accountId: Long, value: Boolean) + + // TODO: Should be suspend + @Query( + """ + UPDATE AccountEntity + SET notificationLight = :value + WHERE id = :accountId + """, + ) + fun setNotificationLight(accountId: Long, value: Boolean) + + @Query( + """ + UPDATE AccountEntity + SET lastVisibleHomeTimelineStatusId = :value + WHERE id = :accountId + """, + ) + suspend fun setLastVisibleHomeTimelineStatusId(accountId: Long, value: String?) } diff --git a/core/database/src/main/kotlin/app/pachli/core/database/dao/AnnouncementsDao.kt b/core/database/src/main/kotlin/app/pachli/core/database/dao/AnnouncementsDao.kt new file mode 100644 index 000000000..8b7ffedac --- /dev/null +++ b/core/database/src/main/kotlin/app/pachli/core/database/dao/AnnouncementsDao.kt @@ -0,0 +1,53 @@ +/* + * Copyright 2024 Pachli Association + * + * This file is a part of Pachli. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Pachli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Pachli; if not, + * see . + */ + +package app.pachli.core.database.dao + +import androidx.room.Dao +import androidx.room.Query +import androidx.room.TypeConverters +import androidx.room.Upsert +import app.pachli.core.database.Converters +import app.pachli.core.database.model.AnnouncementEntity + +@Dao +@TypeConverters(Converters::class) +interface AnnouncementsDao { + @Query( + """ + DELETE + FROM AnnouncementEntity + WHERE accountId = :accountId + """, + ) + suspend fun deleteAllForAccount(accountId: Long) + + @Upsert + suspend fun upsert(announcement: AnnouncementEntity) + + @Upsert + suspend fun upsert(announcements: List) + + @Query( + """ + DELETE + FROM AnnouncementEntity + WHERE accountId = :pachliAccountId AND announcementId = :announcementId + """, + ) + suspend fun deleteForAccount(pachliAccountId: Long, announcementId: String) +} diff --git a/core/database/src/main/kotlin/app/pachli/core/database/dao/ContentFiltersDao.kt b/core/database/src/main/kotlin/app/pachli/core/database/dao/ContentFiltersDao.kt new file mode 100644 index 000000000..9312e1f53 --- /dev/null +++ b/core/database/src/main/kotlin/app/pachli/core/database/dao/ContentFiltersDao.kt @@ -0,0 +1,51 @@ +/* + * Copyright 2024 Pachli Association + * + * This file is a part of Pachli. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Pachli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Pachli; if not, + * see . + */ + +package app.pachli.core.database.dao + +import androidx.room.Dao +import androidx.room.Query +import androidx.room.TypeConverters +import androidx.room.Upsert +import app.pachli.core.database.Converters +import app.pachli.core.database.model.ContentFiltersEntity +import kotlinx.coroutines.flow.Flow + +@Dao +@TypeConverters(Converters::class) +interface ContentFiltersDao { + @Query( + """ + SELECT * + FROM ContentFiltersEntity + WHERE accountId = :pachliAccountId + """, + ) + suspend fun getByAccount(pachliAccountId: Long): ContentFiltersEntity? + + @Query( + """ + SELECT * + FROM ContentFiltersEntity + WHERE accountId = :pachliAccountId + """, + ) + fun flowByAccount(pachliAccountId: Long): Flow + + @Upsert + suspend fun upsert(contentFiltersEntity: ContentFiltersEntity) +} diff --git a/core/database/src/main/kotlin/app/pachli/core/database/dao/InstanceDao.kt b/core/database/src/main/kotlin/app/pachli/core/database/dao/InstanceDao.kt index d451d3bc5..36c7bcb91 100644 --- a/core/database/src/main/kotlin/app/pachli/core/database/dao/InstanceDao.kt +++ b/core/database/src/main/kotlin/app/pachli/core/database/dao/InstanceDao.kt @@ -20,25 +20,32 @@ package app.pachli.core.database.dao import androidx.room.Dao import androidx.room.Query import androidx.room.RewriteQueriesToDropUnusedColumns +import androidx.room.Transaction import androidx.room.Upsert import app.pachli.core.database.model.EmojisEntity -import app.pachli.core.database.model.InstanceEntity import app.pachli.core.database.model.InstanceInfoEntity +import app.pachli.core.database.model.ServerEntity @Dao interface InstanceDao { - @Upsert(entity = InstanceEntity::class) + @Upsert suspend fun upsert(instance: InstanceInfoEntity) - @Upsert(entity = InstanceEntity::class) + @Upsert suspend fun upsert(emojis: EmojisEntity) - @RewriteQueriesToDropUnusedColumns - @Query("SELECT * FROM InstanceEntity WHERE instance = :instance LIMIT 1") + @Upsert + suspend fun upsert(serverEntity: ServerEntity) + + @Transaction + @Query("SELECT * FROM InstanceInfoEntity WHERE instance = :instance LIMIT 1") suspend fun getInstanceInfo(instance: String): InstanceInfoEntity? + @Query("SELECT * FROM ServerEntity WHERE accountId = :pachliAccountId") + suspend fun getServer(pachliAccountId: Long): ServerEntity? + @RewriteQueriesToDropUnusedColumns - @Query("SELECT * FROM InstanceEntity WHERE instance = :instance LIMIT 1") - suspend fun getEmojiInfo(instance: String): EmojisEntity? + @Query("SELECT * FROM EmojisEntity WHERE accountId = :pachliAccountId") + suspend fun getEmojiInfo(pachliAccountId: Long): EmojisEntity? } diff --git a/core/database/src/main/kotlin/app/pachli/core/database/dao/ListsDao.kt b/core/database/src/main/kotlin/app/pachli/core/database/dao/ListsDao.kt new file mode 100644 index 000000000..75172cd23 --- /dev/null +++ b/core/database/src/main/kotlin/app/pachli/core/database/dao/ListsDao.kt @@ -0,0 +1,75 @@ +/* + * Copyright 2024 Pachli Association + * + * This file is a part of Pachli. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Pachli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Pachli; if not, + * see . + */ + +package app.pachli.core.database.dao + +import androidx.room.Dao +import androidx.room.Query +import androidx.room.TypeConverters +import androidx.room.Upsert +import app.pachli.core.database.Converters +import app.pachli.core.database.model.MastodonListEntity +import kotlinx.coroutines.flow.Flow + +@Dao +@TypeConverters(Converters::class) +interface ListsDao { + @Query( + """ + DELETE + FROM MastodonListEntity + WHERE accountId = :pachliAccountId + """, + ) + suspend fun deleteAllForAccount(pachliAccountId: Long) + + @Query( + """ + SELECT * + FROM MastodonListEntity + WHERE accountId = :pachliAccountId + """, + ) + fun flowByAccount(pachliAccountId: Long): Flow> + + @Query( + """ + SELECT * + FROM MastodonListEntity + WHERE accountId = :pachliAccountId + """, + ) + suspend fun get(pachliAccountId: Long): List + + @Query("SELECT * FROM MastodonListEntity") + fun flowAll(): Flow> + + @Upsert + suspend fun upsert(list: MastodonListEntity) + + @Upsert + suspend fun upsert(lists: List) + + @Query( + """ + DELETE + FROM MastodonListEntity + WHERE accountId = :pachliAccountId AND listId = :listId + """, + ) + suspend fun deleteForAccount(pachliAccountId: Long, listId: String) +} diff --git a/core/database/src/main/kotlin/app/pachli/core/database/dao/RemoteKeyDao.kt b/core/database/src/main/kotlin/app/pachli/core/database/dao/RemoteKeyDao.kt index 41fe2f1c4..fc88b2f14 100644 --- a/core/database/src/main/kotlin/app/pachli/core/database/dao/RemoteKeyDao.kt +++ b/core/database/src/main/kotlin/app/pachli/core/database/dao/RemoteKeyDao.kt @@ -33,7 +33,6 @@ interface RemoteKeyDao { @Query("SELECT * FROM RemoteKeyEntity WHERE accountId = :accountId AND timelineId = :timelineId AND kind = :kind") suspend fun remoteKeyForKind(accountId: Long, timelineId: String, kind: RemoteKeyKind): RemoteKeyEntity? - // TODO: This can be marked suspend when AccountManager.logActiveAccountOut() has a coroutine scope @Query("DELETE FROM RemoteKeyEntity WHERE accountId = :accountId") - fun delete(accountId: Long) + suspend fun delete(accountId: Long) } diff --git a/core/database/src/main/kotlin/app/pachli/core/database/dao/TimelineDao.kt b/core/database/src/main/kotlin/app/pachli/core/database/dao/TimelineDao.kt index 0fd239d31..8a4a8d08e 100644 --- a/core/database/src/main/kotlin/app/pachli/core/database/dao/TimelineDao.kt +++ b/core/database/src/main/kotlin/app/pachli/core/database/dao/TimelineDao.kt @@ -23,6 +23,7 @@ import androidx.room.Insert import androidx.room.MapColumn import androidx.room.OnConflictStrategy.Companion.REPLACE import androidx.room.Query +import androidx.room.Transaction import androidx.room.Upsert import app.pachli.core.database.model.StatusViewDataEntity import app.pachli.core.database.model.TimelineAccountEntity @@ -188,7 +189,8 @@ AND serverId = :statusId""", * @param accountId id of the account for which to clean tables * @param limit how many statuses to keep */ - suspend fun cleanup(accountId: Long, limit: Int) { + @Transaction + open suspend fun cleanup(accountId: Long, limit: Int) { cleanupStatuses(accountId, limit) cleanupAccounts(accountId) cleanupStatusViewData(accountId, limit) diff --git a/core/database/src/main/kotlin/app/pachli/core/database/di/DatabaseModule.kt b/core/database/src/main/kotlin/app/pachli/core/database/di/DatabaseModule.kt index 20e4d280e..24a15ae2d 100644 --- a/core/database/src/main/kotlin/app/pachli/core/database/di/DatabaseModule.kt +++ b/core/database/src/main/kotlin/app/pachli/core/database/di/DatabaseModule.kt @@ -71,6 +71,15 @@ object DatabaseModule { @Provides fun providesLogEntryDao(appDatabase: AppDatabase) = appDatabase.logEntryDao() + + @Provides + fun providesContentFiltersDao(appDatabase: AppDatabase) = appDatabase.contentFiltersDao() + + @Provides + fun providesListsDao(appDatabase: AppDatabase) = appDatabase.listsDao() + + @Provides + fun providesAnnouncementsDao(appDatabase: AppDatabase) = appDatabase.announcementsDao() } /** diff --git a/core/database/src/main/kotlin/app/pachli/core/database/model/AccountEntity.kt b/core/database/src/main/kotlin/app/pachli/core/database/model/AccountEntity.kt index 5883b7b3f..85eb9daca 100644 --- a/core/database/src/main/kotlin/app/pachli/core/database/model/AccountEntity.kt +++ b/core/database/src/main/kotlin/app/pachli/core/database/model/AccountEntity.kt @@ -39,81 +39,81 @@ import app.pachli.core.network.model.Status data class AccountEntity( @field:PrimaryKey(autoGenerate = true) var id: Long, val domain: String, - var accessToken: String, - // nullable for backward compatibility - var clientId: String?, - // nullable for backward compatibility - var clientSecret: String?, - var isActive: Boolean, + val accessToken: String, + val clientId: String, + val clientSecret: String, + val isActive: Boolean, /** Account's remote (server) ID. */ - var accountId: String = "", + val accountId: String = "", /** User's local name, without the leading `@` or the `@domain` portion */ - var username: String = "", - var displayName: String = "", - var profilePictureUrl: String = "", + val username: String = "", + val displayName: String = "", + val profilePictureUrl: String = "", + @ColumnInfo(defaultValue = "") + val profileHeaderPictureUrl: String = "", /** User wants Android notifications enabled for this account */ - var notificationsEnabled: Boolean = true, - var notificationsMentioned: Boolean = true, - var notificationsFollowed: Boolean = true, - var notificationsFollowRequested: Boolean = false, - var notificationsReblogged: Boolean = true, - var notificationsFavorited: Boolean = true, - var notificationsPolls: Boolean = true, - var notificationsSubscriptions: Boolean = true, - var notificationsSignUps: Boolean = true, - var notificationsUpdates: Boolean = true, - var notificationsReports: Boolean = true, + val notificationsEnabled: Boolean = true, + val notificationsMentioned: Boolean = true, + val notificationsFollowed: Boolean = true, + val notificationsFollowRequested: Boolean = false, + val notificationsReblogged: Boolean = true, + val notificationsFavorited: Boolean = true, + val notificationsPolls: Boolean = true, + val notificationsSubscriptions: Boolean = true, + val notificationsSignUps: Boolean = true, + val notificationsUpdates: Boolean = true, + val notificationsReports: Boolean = true, @ColumnInfo(defaultValue = "true") - var notificationsSeveredRelationships: Boolean = true, - var notificationSound: Boolean = true, - var notificationVibration: Boolean = true, - var notificationLight: Boolean = true, - var defaultPostPrivacy: Status.Visibility = Status.Visibility.PUBLIC, - var defaultMediaSensitivity: Boolean = false, - var defaultPostLanguage: String = "", - var alwaysShowSensitiveMedia: Boolean = false, + val notificationsSeveredRelationships: Boolean = true, + val notificationSound: Boolean = true, + val notificationVibration: Boolean = true, + val notificationLight: Boolean = true, + val defaultPostPrivacy: Status.Visibility = Status.Visibility.PUBLIC, + val defaultMediaSensitivity: Boolean = false, + val defaultPostLanguage: String = "", + val alwaysShowSensitiveMedia: Boolean = false, /** True if content behind a content warning is shown by default */ - var alwaysOpenSpoiler: Boolean = false, + val alwaysOpenSpoiler: Boolean = false, /** * True if the "Download media previews" preference is true. This implies * that media previews are shown as well as downloaded. */ - var mediaPreviewEnabled: Boolean = true, + 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. */ - var lastNotificationId: String = "0", + val lastNotificationId: String = "0", /** - * ID of the most recent Mastodon notification that Tusky has fetched to show as an + * ID of the most recent Mastodon notification that Pachli has fetched to show as an * Android notification. */ @ColumnInfo(defaultValue = "0") - var notificationMarkerId: String = "0", - var emojis: List = emptyList(), - var tabPreferences: List = defaultTabs(), - var notificationsFilter: String = "[\"follow_request\"]", + val notificationMarkerId: String = "0", + val emojis: List = emptyList(), + val tabPreferences: List = defaultTabs(), + val notificationsFilter: String = "[\"follow_request\"]", // Scope cannot be changed without re-login, so store it in case // the scope needs to be changed in the future - var oauthScopes: String = "", - var unifiedPushUrl: String = "", - var pushPubKey: String = "", - var pushPrivKey: String = "", - var pushAuth: String = "", - var pushServerKey: String = "", + 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. */ - var lastVisibleHomeTimelineStatusId: String? = null, + val lastVisibleHomeTimelineStatusId: String? = null, /** true if the connected Mastodon account is locked (has to manually approve all follow requests **/ @ColumnInfo(defaultValue = "0") - var locked: Boolean = false, + val locked: Boolean = false, ) { val identifier: String @@ -127,31 +127,11 @@ data class AccountEntity( val unifiedPushInstance: String get() = id.toString() - fun logout() { - // deleting credentials so they cannot be used again - accessToken = "" - clientId = null - clientSecret = null - } - fun isLoggedIn() = accessToken.isNotEmpty() - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - - other as AccountEntity - - if (id == other.id) return true - return domain == other.domain && accountId == other.accountId - } - - override fun hashCode(): Int { - var result = id.hashCode() - result = 31 * result + domain.hashCode() - result = 31 * result + accountId.hashCode() - return result - } + /** Value of the "Authorization" header for this account ("Bearer $accessToken"). */ + val authHeader: String + get() = "Bearer $accessToken" } fun defaultTabs() = listOf( diff --git a/core/database/src/main/kotlin/app/pachli/core/database/model/AnnouncementEntity.kt b/core/database/src/main/kotlin/app/pachli/core/database/model/AnnouncementEntity.kt new file mode 100644 index 000000000..9fb1b7c7d --- /dev/null +++ b/core/database/src/main/kotlin/app/pachli/core/database/model/AnnouncementEntity.kt @@ -0,0 +1,51 @@ +/* + * Copyright 2024 Pachli Association + * + * This file is a part of Pachli. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Pachli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Pachli; if not, + * see . + */ + +package app.pachli.core.database.model + +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.TypeConverters +import app.pachli.core.database.Converters +import app.pachli.core.network.model.Announcement + +/** + * Represents the announcements sent to an account. + * + * Although announcements are also associated with a server, the user may have + * multiple accounts on the same server with announcements that are in different + * "read" states. So each announcement is associated with exactly one + * [AccountEntity] through the [accountId] property. + */ +@Entity( + primaryKeys = ["accountId"], + foreignKeys = [ + ForeignKey( + entity = AccountEntity::class, + parentColumns = arrayOf("id"), + childColumns = arrayOf("accountId"), + onDelete = ForeignKey.CASCADE, + deferred = true, + ), + ], +) +@TypeConverters(Converters::class) +data class AnnouncementEntity( + val accountId: Long, + val announcementId: String, + val announcement: Announcement, +) diff --git a/core/database/src/main/kotlin/app/pachli/core/database/model/ContentFiltersEntity.kt b/core/database/src/main/kotlin/app/pachli/core/database/model/ContentFiltersEntity.kt new file mode 100644 index 000000000..b16c5356d --- /dev/null +++ b/core/database/src/main/kotlin/app/pachli/core/database/model/ContentFiltersEntity.kt @@ -0,0 +1,46 @@ +/* + * Copyright 2024 Pachli Association + * + * This file is a part of Pachli. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Pachli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Pachli; if not, + * see . + */ + +package app.pachli.core.database.model + +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.TypeConverters +import app.pachli.core.database.Converters +import app.pachli.core.model.ContentFilter +import app.pachli.core.model.ContentFilterVersion + +// TODO: Redo this. Would be better as one ContentFilter per row, + +@Entity( + primaryKeys = ["accountId"], + foreignKeys = [ + ForeignKey( + entity = AccountEntity::class, + parentColumns = arrayOf("id"), + childColumns = arrayOf("accountId"), + onDelete = ForeignKey.CASCADE, + deferred = true, + ), + ], +) +@TypeConverters(Converters::class) +data class ContentFiltersEntity( + val accountId: Long, + val version: ContentFilterVersion, + val contentFilters: List, +) diff --git a/core/database/src/main/kotlin/app/pachli/core/database/model/InstanceEntity.kt b/core/database/src/main/kotlin/app/pachli/core/database/model/InstanceEntity.kt index c993467fb..580147fb1 100644 --- a/core/database/src/main/kotlin/app/pachli/core/database/model/InstanceEntity.kt +++ b/core/database/src/main/kotlin/app/pachli/core/database/model/InstanceEntity.kt @@ -17,22 +17,26 @@ package app.pachli.core.database.model +import androidx.room.ColumnInfo import androidx.room.Entity +import androidx.room.ForeignKey import androidx.room.PrimaryKey import androidx.room.TypeConverters +import app.pachli.core.common.extensions.MiB import app.pachli.core.database.Converters import app.pachli.core.network.model.Emoji +import app.pachli.core.network.model.InstanceV1 +import app.pachli.core.network.model.InstanceV2 @Entity @TypeConverters(Converters::class) -data class InstanceEntity( +data class InstanceInfoEntity( @PrimaryKey val instance: String, - val emojiList: List?, - val maximumTootCharacters: Int?, + val maxPostCharacters: Int?, val maxPollOptions: Int?, val maxPollOptionLength: Int?, val minPollDuration: Int?, - val maxPollDuration: Int?, + val maxPollDuration: Long?, val charactersReservedPerUrl: Int?, val version: String?, val videoSizeLimit: Long?, @@ -42,28 +46,101 @@ data class InstanceEntity( val maxFields: Int?, val maxFieldNameLength: Int?, val maxFieldValueLength: Int?, -) + @ColumnInfo(defaultValue = "0") + val enabledTranslation: Boolean = false, +) { + companion object { + private 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 = 604800L + private val DEFAULT_VIDEO_SIZE_LIMIT = 40L.MiB + private val DEFAULT_IMAGE_SIZE_LIMIT = 10L.MiB + private const val DEFAULT_IMAGE_MATRIX_LIMIT = 4096 * 4096 + + // Mastodon only counts URLs as this long in terms of status character limits + private const val DEFAULT_CHARACTERS_RESERVED_PER_URL = 23 + + private const val DEFAULT_MAX_MEDIA_ATTACHMENTS = 4 + private const val DEFAULT_MAX_ACCOUNT_FIELDS = 4 + + fun defaultForDomain(domain: String) = InstanceInfoEntity( + instance = domain, + maxPostCharacters = DEFAULT_CHARACTER_LIMIT, + maxPollOptions = DEFAULT_MAX_OPTION_COUNT, + maxPollOptionLength = DEFAULT_MAX_OPTION_LENGTH, + minPollDuration = DEFAULT_MIN_POLL_DURATION, + maxPollDuration = DEFAULT_MAX_POLL_DURATION, + charactersReservedPerUrl = DEFAULT_CHARACTERS_RESERVED_PER_URL, + videoSizeLimit = DEFAULT_VIDEO_SIZE_LIMIT, + imageSizeLimit = DEFAULT_IMAGE_SIZE_LIMIT, + imageMatrixLimit = DEFAULT_IMAGE_MATRIX_LIMIT, + maxMediaAttachments = DEFAULT_MAX_MEDIA_ATTACHMENTS, + maxFields = DEFAULT_MAX_ACCOUNT_FIELDS, + maxFieldNameLength = null, + maxFieldValueLength = null, + version = "(Pachli defaults)", + ) + + fun make(domain: String, instance: InstanceV1): InstanceInfoEntity { + return InstanceInfoEntity( + instance = domain, + maxPostCharacters = instance.configuration.statuses.maxCharacters ?: instance.maxTootChars ?: DEFAULT_CHARACTER_LIMIT, + maxPollOptions = instance.configuration.polls.maxOptions, + maxPollOptionLength = instance.configuration.polls.maxCharactersPerOption, + minPollDuration = instance.configuration.polls.minExpiration, + maxPollDuration = instance.configuration.polls.maxExpiration.toLong(), + charactersReservedPerUrl = instance.configuration.statuses.charactersReservedPerUrl, + version = instance.version, + videoSizeLimit = instance.configuration.mediaAttachments.videoSizeLimit, + imageSizeLimit = instance.configuration.mediaAttachments.imageSizeLimit, + imageMatrixLimit = instance.configuration.mediaAttachments.imageMatrixLimit, + maxMediaAttachments = instance.configuration.statuses.maxMediaAttachments, + maxFields = instance.pleroma?.metadata?.fieldLimits?.maxFields, + maxFieldNameLength = instance.pleroma?.metadata?.fieldLimits?.nameLength, + maxFieldValueLength = instance.pleroma?.metadata?.fieldLimits?.valueLength, + ) + } + + fun make(domain: String, instance: InstanceV2): InstanceInfoEntity { + return InstanceInfoEntity( + instance = domain, + maxPostCharacters = instance.configuration.statuses.maxCharacters, + maxPollOptions = instance.configuration.polls.maxOptions, + maxPollOptionLength = instance.configuration.polls.maxCharactersPerOption, + minPollDuration = instance.configuration.polls.minExpiration, + maxPollDuration = instance.configuration.polls.maxExpiration, + charactersReservedPerUrl = instance.configuration.statuses.charactersReservedPerUrl, + version = instance.version, + videoSizeLimit = instance.configuration.mediaAttachments.videoSizeLimit, + imageSizeLimit = instance.configuration.mediaAttachments.imageSizeLimit, + imageMatrixLimit = instance.configuration.mediaAttachments.imageMatrixLimit, + maxMediaAttachments = instance.configuration.statuses.maxMediaAttachments, + maxFields = DEFAULT_MAX_ACCOUNT_FIELDS, + maxFieldNameLength = null, + maxFieldValueLength = null, + enabledTranslation = instance.configuration.translation.enabled, + ) + } + } +} + +@Entity( + primaryKeys = ["accountId"], + foreignKeys = [ + ForeignKey( + entity = AccountEntity::class, + parentColumns = arrayOf("id"), + childColumns = arrayOf("accountId"), + onDelete = ForeignKey.CASCADE, + deferred = true, + ), + ], +) @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: Long, - val imageSizeLimit: Long, - val imageMatrixLimit: Int, - val maxMediaAttachments: Int, - val maxFields: Int?, - val maxFieldNameLength: Int?, - val maxFieldValueLength: Int?, + val accountId: Long, + val emojiList: List, ) diff --git a/core/database/src/main/kotlin/app/pachli/core/database/model/MastodonListEntity.kt b/core/database/src/main/kotlin/app/pachli/core/database/model/MastodonListEntity.kt new file mode 100644 index 000000000..70533a01c --- /dev/null +++ b/core/database/src/main/kotlin/app/pachli/core/database/model/MastodonListEntity.kt @@ -0,0 +1,65 @@ +/* + * Copyright 2024 Pachli Association + * + * This file is a part of Pachli. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Pachli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Pachli; if not, + * see . + */ + +package app.pachli.core.database.model + +import androidx.room.Entity +import androidx.room.ForeignKey +import app.pachli.core.network.model.MastoList +import app.pachli.core.network.model.UserListRepliesPolicy + +/** + * Represents a Mastodon list definition. + * + * Does not include details about the lists's membership. + * + * Each list is associated with exactly one [AccountEntity] through the [accountId] + * property. + */ +@Entity( + primaryKeys = ["accountId", "listId"], + foreignKeys = [ + ForeignKey( + entity = AccountEntity::class, + parentColumns = arrayOf("id"), + childColumns = arrayOf("accountId"), + onDelete = ForeignKey.CASCADE, + deferred = true, + ), + ], +) +data class MastodonListEntity( + val accountId: Long, + val listId: String, + val title: String, + val repliesPolicy: UserListRepliesPolicy, + val exclusive: Boolean, +) { + companion object { + fun make(pachliAccountId: Long, networkList: MastoList) = MastodonListEntity( + pachliAccountId, + networkList.id, + networkList.title, + networkList.repliesPolicy, + networkList.exclusive ?: false, + ) + + fun make(pachliAccountId: Long, networkLists: List) = networkLists.map { + make(pachliAccountId, it) + } + } +} diff --git a/core/database/src/main/kotlin/app/pachli/core/database/model/PachliAccount.kt b/core/database/src/main/kotlin/app/pachli/core/database/model/PachliAccount.kt new file mode 100644 index 000000000..5c95d28b5 --- /dev/null +++ b/core/database/src/main/kotlin/app/pachli/core/database/model/PachliAccount.kt @@ -0,0 +1,66 @@ +/* + * Copyright 2024 Pachli Association + * + * This file is a part of Pachli. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Pachli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Pachli; if not, + * see . + */ + +package app.pachli.core.database.model + +import androidx.room.Embedded +import androidx.room.Relation + +/** + * Represents a complete Pachli account. + * + * Joins the different tables that make up the account data. + */ +data class PachliAccount( + @Embedded val account: AccountEntity, + + @Relation( + parentColumn = "domain", + entityColumn = "instance", + ) + val instanceInfo: InstanceInfoEntity?, + + @Relation( + parentColumn = "id", + entityColumn = "accountId", + ) + val lists: List?, + + @Relation( + parentColumn = "id", + entityColumn = "accountId", + ) + val emojis: EmojisEntity?, + + @Relation( + parentColumn = "id", + entityColumn = "accountId", + ) + val server: ServerEntity?, + + @Relation( + parentColumn = "id", + entityColumn = "accountId", + ) + val contentFilters: ContentFiltersEntity?, + + @Relation( + parentColumn = "id", + entityColumn = "accountId", + ) + val announcements: List?, +) diff --git a/core/database/src/main/kotlin/app/pachli/core/database/model/ServerEntity.kt b/core/database/src/main/kotlin/app/pachli/core/database/model/ServerEntity.kt new file mode 100644 index 000000000..f6fd501d9 --- /dev/null +++ b/core/database/src/main/kotlin/app/pachli/core/database/model/ServerEntity.kt @@ -0,0 +1,52 @@ +/* + * Copyright 2024 Pachli Association + * + * This file is a part of Pachli. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Pachli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Pachli; if not, + * see . + */ + +package app.pachli.core.database.model + +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.TypeConverters +import app.pachli.core.database.Converters +import app.pachli.core.model.ServerKind +import app.pachli.core.model.ServerOperation +import io.github.z4kn4fein.semver.Version + +/** + * Represents a Mastodon server's capabilities. + * + * Each server is associated with exactly one [AccountEntity] through the [accountId] + * property. + */ +@Entity( + primaryKeys = ["accountId"], + foreignKeys = [ + ForeignKey( + entity = AccountEntity::class, + parentColumns = arrayOf("id"), + childColumns = arrayOf("accountId"), + onDelete = ForeignKey.CASCADE, + deferred = true, + ), + ], +) +@TypeConverters(Converters::class) +data class ServerEntity( + val accountId: Long, + val serverKind: ServerKind, + val version: Version, + val capabilities: Map, +) diff --git a/core/database/src/test/kotlin/app/pachli/core/database/dao/TimelineDaoTest.kt b/core/database/src/test/kotlin/app/pachli/core/database/dao/TimelineDaoTest.kt index becbac32b..ae47041a5 100644 --- a/core/database/src/test/kotlin/app/pachli/core/database/dao/TimelineDaoTest.kt +++ b/core/database/src/test/kotlin/app/pachli/core/database/dao/TimelineDaoTest.kt @@ -28,6 +28,7 @@ import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest import javax.inject.Inject import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Assert.assertEquals import org.junit.Before @@ -83,7 +84,7 @@ class TimelineDaoTest { } @Test - fun cleanup() = runBlocking { + fun cleanup() = runTest { val statusesBeforeCleanup = listOf( makeStatus(statusId = 100), makeStatus(statusId = 10, authorServerId = "3"), diff --git a/core/domain/build.gradle.kts b/core/domain/build.gradle.kts index 392dfb6c3..0592f6c24 100644 --- a/core/domain/build.gradle.kts +++ b/core/domain/build.gradle.kts @@ -29,6 +29,10 @@ android { } dependencies { + implementation(projects.core.common) implementation(projects.core.data) + implementation(projects.core.model) + implementation(projects.core.network) + ?.because("Depends on UserListRepliesPolicy type") implementation(projects.core.preferences) } diff --git a/core/model/src/main/kotlin/app/pachli/core/model/ServerOperation.kt b/core/model/src/main/kotlin/app/pachli/core/model/ServerOperation.kt index aa14def7c..036441312 100644 --- a/core/model/src/main/kotlin/app/pachli/core/model/ServerOperation.kt +++ b/core/model/src/main/kotlin/app/pachli/core/model/ServerOperation.kt @@ -17,7 +17,32 @@ package app.pachli.core.model +import com.squareup.moshi.FromJson +import com.squareup.moshi.ToJson import io.github.z4kn4fein.semver.Version +import io.github.z4kn4fein.semver.constraints.Constraint +import io.github.z4kn4fein.semver.satisfies + +typealias ServerCapabilities = Map + +/** + * @return true if the server supports the given operation at the given minimum version + * level, false otherwise. + */ +fun ServerCapabilities.can(operation: ServerOperation, constraint: Constraint) = this[operation]?.let { version -> + version satisfies constraint +} ?: false + +/** + * Serializes [Version] to/from JSON using its String form. + */ +class VersionAdapter { + @ToJson + fun toJson(version: Version) = version.toString() + + @FromJson + fun fromJson(s: String) = Version.parse(s) +} /** * Identifiers for operations that the server may or may not support. diff --git a/core/navigation/src/main/kotlin/app/pachli/core/navigation/Navigation.kt b/core/navigation/src/main/kotlin/app/pachli/core/navigation/Navigation.kt index c0a75ea8f..b3576d11e 100644 --- a/core/navigation/src/main/kotlin/app/pachli/core/navigation/Navigation.kt +++ b/core/navigation/src/main/kotlin/app/pachli/core/navigation/Navigation.kt @@ -21,10 +21,10 @@ import android.content.Context import android.content.Intent import android.os.Parcelable import androidx.core.content.IntentCompat -import androidx.core.content.pm.ShortcutManagerCompat import app.pachli.core.database.model.DraftAttachment import app.pachli.core.model.ContentFilter import app.pachli.core.model.Timeline +import app.pachli.core.navigation.ComposeActivityIntent.ComposeOptions import app.pachli.core.navigation.LoginActivityIntent.LoginMode import app.pachli.core.navigation.TimelineActivityIntent.Companion.bookmarks import app.pachli.core.navigation.TimelineActivityIntent.Companion.conversations @@ -42,6 +42,7 @@ import java.util.Date import kotlinx.parcelize.Parcelize private const val EXTRA_PACHLI_ACCOUNT_ID = "app.pachli.EXTRA_PACHLI_ACCOUNT_ID" +const val PACHLI_ACCOUNT_ID_ACTIVE = -1L /** * The Pachli Account ID passed to this intent. This is the @@ -49,7 +50,7 @@ private const val EXTRA_PACHLI_ACCOUNT_ID = "app.pachli.EXTRA_PACHLI_ACCOUNT_ID" * "active" for the purposes of this activity. */ var Intent.pachliAccountId: Long - get() = getLongExtra(EXTRA_PACHLI_ACCOUNT_ID, -1L) + get() = getLongExtra(EXTRA_PACHLI_ACCOUNT_ID, PACHLI_ACCOUNT_ID_ACTIVE) set(value) { putExtra(EXTRA_PACHLI_ACCOUNT_ID, value) return @@ -129,9 +130,11 @@ class AccountListActivityIntent(context: Context, pachliAccountId: Long, kind: K /** * @param context + * @param pachliAccountId + * @param composeOptions * @see [app.pachli.components.compose.ComposeActivity] */ -class ComposeActivityIntent(context: Context) : Intent() { +class ComposeActivityIntent(context: Context, pachliAccountId: Long, composeOptions: ComposeOptions? = null) : Intent() { @Parcelize data class ComposeOptions( val scheduledTootId: String? = null, @@ -190,22 +193,16 @@ class ComposeActivityIntent(context: Context) : Intent() { init { setClassName(context, QuadrantConstants.COMPOSE_ACTIVITY) - } + this.pachliAccountId = pachliAccountId - /** - * @param context - * @param options Configure the initial state of the activity - * @see [app.pachli.components.compose.ComposeActivity] - */ - constructor(context: Context, options: ComposeOptions) : this(context) { - putExtra(EXTRA_COMPOSE_OPTIONS, options) + composeOptions?.let { putExtra(EXTRA_COMPOSE_OPTIONS, it) } } companion object { private const val EXTRA_COMPOSE_OPTIONS = "app.pachli.EXTRA_COMPOSE_OPTIONS" /** @return the [ComposeOptions] passed in this intent, or null */ - fun getOptions(intent: Intent) = IntentCompat.getParcelableExtra(intent, EXTRA_COMPOSE_OPTIONS, ComposeOptions::class.java) + fun getComposeOptions(intent: Intent) = IntentCompat.getParcelableExtra(intent, EXTRA_COMPOSE_OPTIONS, ComposeOptions::class.java) } } @@ -265,16 +262,19 @@ class EditContentFilterActivityIntent(context: Context, pachliAccountId: Long) : * @param loginMode See [LoginMode] * @see [app.pachli.feature.login.LoginActivity] */ -class LoginActivityIntent(context: Context, loginMode: LoginMode = LoginMode.DEFAULT) : Intent() { +class LoginActivityIntent(context: Context, loginMode: LoginMode = LoginMode.Default) : Intent() { /** How to log in */ - enum class LoginMode { - DEFAULT, + sealed interface LoginMode : Parcelable { + @Parcelize + data object Default : LoginMode /** Already logged in, log in with an additional account */ - ADDITIONAL_LOGIN, + @Parcelize + data object AdditionalLogin : LoginMode - /** Update the OAuth scope granted to the client */ - MIGRATION, + /** Allow the user to reauthenticate the account (at [domain]). */ + @Parcelize + data class Reauthenticate(val domain: String) : LoginMode } init { @@ -286,7 +286,7 @@ class LoginActivityIntent(context: Context, loginMode: LoginMode = LoginMode.DEF private const val EXTRA_LOGIN_MODE = "app.pachli.EXTRA_LOGIN_MODE" /** @return the `loginMode` passed to this intent */ - fun getLoginMode(intent: Intent) = intent.getSerializableExtra(EXTRA_LOGIN_MODE)!! as LoginMode + fun getLoginMode(intent: Intent) = IntentCompat.getParcelableExtra(intent, EXTRA_LOGIN_MODE, LoginMode::class.java) } } @@ -296,62 +296,157 @@ class MainActivityIntent(context: Context, pachliAccountId: Long) : Intent() { this.pachliAccountId = pachliAccountId } - companion object { - private const val EXTRA_NOTIFICATION_TYPE = "app.pachli.EXTRA_NOTIFICATION_TYPE" - private const val EXTRA_COMPOSE_OPTIONS = "app.pachli.EXTRA_COMPOSE_OPTIONS" - private const val EXTRA_NOTIFICATION_TAG = "app.pachli.EXTRA_NOTIFICATION_TAG" - private const val EXTRA_NOTIFICATION_ID = "app.pachli.EXTRA_NOTIFICATION_ID" - private const val EXTRA_REDIRECT_URL = "app.pachli.EXTRA_REDIRECT_URL" - private const val EXTRA_OPEN_DRAFTS = "app.pachli.EXTRA_OPEN_DRAFTS" - - fun hasComposeOptions(intent: Intent) = intent.hasExtra(EXTRA_COMPOSE_OPTIONS) - fun hasNotificationType(intent: Intent) = intent.hasExtra(EXTRA_NOTIFICATION_TYPE) - - fun getNotificationType(intent: Intent) = intent.getSerializableExtra(EXTRA_NOTIFICATION_TYPE) as Notification.Type - fun getNotificationTag(intent: Intent) = intent.getStringExtra(EXTRA_NOTIFICATION_TAG) - fun getNotificationId(intent: Intent) = intent.getIntExtra(EXTRA_NOTIFICATION_ID, -1) - fun getRedirectUrl(intent: Intent) = intent.getStringExtra(EXTRA_REDIRECT_URL) - fun getOpenDrafts(intent: Intent) = intent.getBooleanExtra(EXTRA_OPEN_DRAFTS, false) + /** Describes where the activity was started from. */ + sealed interface Payload : Parcelable { + /** + * Started from the "Compose post" quick tile. This cannot include + * information about the account to start with. See + * [PachliTileService][app.pachli.service.PachliTileService]. + */ + @Parcelize + data object QuickTile : Payload /** + * Started from the "Compose" button in a notification. * + * @param composeOptions + * @param notificationId Notification's ID + * @param notificationTag Notification's tag (Mastodon notification ID) */ - fun withShortCut(context: Context, pachliAccountId: Long) = MainActivityIntent(context, pachliAccountId).apply { - action = ACTION_SEND - type = "text/plain" - putExtra(ShortcutManagerCompat.EXTRA_SHORTCUT_ID, pachliAccountId.toString()) + @Parcelize + data class NotificationCompose( + val composeOptions: ComposeOptions, + val notificationId: Int, + val notificationTag: String?, + ) : Payload + + /** + * Started from a shortcut. + */ + @Parcelize + data object Shortcut : Payload + + /** + * Started by tapping on a notification body. + * + * @param notificationId Notification's ID + * @param notificationTag Notification's tag (Mastodon notification ID) + * @param notificationType Notification's type + */ + @Parcelize + data class Notification( + val notificationId: Int, + val notificationTag: String?, + val notificationType: app.pachli.core.network.model.Notification.Type, + ) : Payload + + /** + * Started to open drafts (e.g., after a post failed to send). + */ + @Parcelize + data object OpenDrafts : Payload + + /** + * Started to redirect to [url]. + */ + @Parcelize + data class Redirect(val url: String) : Payload + } + + companion object { + private const val EXTRA_PAYLOAD = "app.pachli.EXTRA_PAYLOAD" + + // Shortcuts use PersistableBundles which can't serialize a Payload. This + // extra is used as a marker that the payload is a shortcut. + private const val EXTRA_PAYLOAD_SHORTCUT = "app.pachli.EXTRA_PAYLOAD_SHORTCUT" + + /** @return The [Payload] in [intent]. May be null if the intent has no payload. */ + fun payload(intent: Intent): Payload? { + val payload = IntentCompat.getParcelableExtra( + intent, + EXTRA_PAYLOAD, + Payload::class.java, + ) + + if (payload != null) return payload + + if (intent.getBooleanExtra(EXTRA_PAYLOAD_SHORTCUT, false)) return Payload.Shortcut + + return null } /** - * Switches the active account to the accountId and takes the user to the correct place - * according to the notification they clicked + * Open MainActivity from a tap on a quick tile. There is a single quick tile + * irrespective of how many accounts are logged in, so use -1L as the account ID. */ - fun openNotification( + fun fromQuickTile(context: Context) = MainActivityIntent(context, -1L).apply { + putExtra(EXTRA_PAYLOAD, Payload.QuickTile).apply { + flags = FLAG_ACTIVITY_NEW_TASK + } + } + + /** + * Open MainActivity from a tap on a shortcut. + */ + fun fromShortcut(context: Context, pachliAccountId: Long) = MainActivityIntent(context, pachliAccountId).apply { + action = ACTION_MAIN + putExtra(EXTRA_PAYLOAD_SHORTCUT, true) + } + + /** + * Open MainActivity from a notification's "Compose" button. + * + * @param context + * @param pachliAccountId + * @param composeOptions + * @param notificationId Notification's ID + * @param notificationTag Notification's tag (Mastodon notification ID) + */ + fun fromNotificationCompose( context: Context, pachliAccountId: Long, + composeOptions: ComposeOptions, + notificationId: Int, + notificationTag: String, + ) = + MainActivityIntent(context, pachliAccountId).apply { + putExtra( + EXTRA_PAYLOAD, + Payload.NotificationCompose( + composeOptions, + notificationId, + notificationTag, + ), + ) + } + + /** + * Switches the active account to [pachliAccountId] and takes the user to the correct place + * according to the notification they clicked. + * + * @param context + * @param pachliAccountId + * @param notificationId Notification's ID. May be -1 if this is from a summary + * notification. + * @param notificationTag Notification's tag (Mastodon notification ID). May be null + * if this is from a summary notification. + * @param type + */ + fun fromNotification( + context: Context, + pachliAccountId: Long, + notificationId: Int, + notificationTag: String?, type: Notification.Type, ) = MainActivityIntent(context, pachliAccountId).apply { - putExtra(EXTRA_NOTIFICATION_TYPE, type) - } - - /** - * Switches the active account to the accountId and then opens ComposeActivity with the provided options - * @param pachliAccountId the id of the Pachli account to open the screen with. - * @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 - */ - fun openCompose( - context: Context, - options: ComposeActivityIntent.ComposeOptions, - pachliAccountId: Long, - notificationTag: String? = null, - notificationId: Int = -1, - ) = MainActivityIntent(context, pachliAccountId).apply { - action = ACTION_SEND - putExtra(EXTRA_COMPOSE_OPTIONS, options) - putExtra(EXTRA_NOTIFICATION_TAG, notificationTag) - putExtra(EXTRA_NOTIFICATION_ID, notificationId) - flags = FLAG_ACTIVITY_NEW_TASK or FLAG_ACTIVITY_CLEAR_TASK + putExtra( + EXTRA_PAYLOAD, + Payload.Notification( + notificationId, + notificationTag, + type, + ), + ) } /** @@ -363,15 +458,15 @@ class MainActivityIntent(context: Context, pachliAccountId: Long) : Intent() { pachliAccountId: Long, url: String, ) = MainActivityIntent(context, pachliAccountId).apply { - putExtra(EXTRA_REDIRECT_URL, url) + putExtra(EXTRA_PAYLOAD, Payload.Redirect(url)) flags = FLAG_ACTIVITY_NEW_TASK or FLAG_ACTIVITY_CLEAR_TASK } /** - * switches the active account to the provided accountId and then opens drafts + * Switches the active account to the provided accountId and then opens drafts */ - fun openDrafts(context: Context, pachliAccountId: Long) = MainActivityIntent(context, pachliAccountId).apply { - putExtra(EXTRA_OPEN_DRAFTS, true) + fun fromDraftsNotification(context: Context, pachliAccountId: Long) = MainActivityIntent(context, pachliAccountId).apply { + putExtra(EXTRA_PAYLOAD, Payload.OpenDrafts) } } } diff --git a/core/network-test/build.gradle.kts b/core/network-test/build.gradle.kts index 1856627cc..76ccef89b 100644 --- a/core/network-test/build.gradle.kts +++ b/core/network-test/build.gradle.kts @@ -30,6 +30,7 @@ android { } dependencies { + implementation(projects.core.model) api(projects.core.network) implementation(libs.hilt.android.testing) diff --git a/core/network-test/src/main/kotlin/app/pachli/core/network/di/test/FakeMastodonApiModule.kt b/core/network-test/src/main/kotlin/app/pachli/core/network/di/test/FakeMastodonApiModule.kt index b6993becd..15b9addfc 100644 --- a/core/network-test/src/main/kotlin/app/pachli/core/network/di/test/FakeMastodonApiModule.kt +++ b/core/network-test/src/main/kotlin/app/pachli/core/network/di/test/FakeMastodonApiModule.kt @@ -18,6 +18,16 @@ package app.pachli.core.network.di.test import app.pachli.core.network.di.MastodonApiModule +import app.pachli.core.network.model.Configuration +import app.pachli.core.network.model.Contact +import app.pachli.core.network.model.InstanceV2 +import app.pachli.core.network.model.InstanceV2Polls +import app.pachli.core.network.model.InstanceV2Statuses +import app.pachli.core.network.model.MediaAttachments +import app.pachli.core.network.model.Registrations +import app.pachli.core.network.model.Thumbnail +import app.pachli.core.network.model.Usage +import app.pachli.core.network.model.Users import app.pachli.core.network.retrofit.MastodonApi import dagger.Module import dagger.Provides @@ -113,3 +123,31 @@ object ThrowingAnswer : Answer { throw AssertionError(message) } } + +/** + * An [InstanceV2] tests can use as the return value from [MastodonApi.getInstanceV2]. + */ +val DEFAULT_INSTANCE_V2 = InstanceV2( + domain = "domain.example", + title = "Test server", + version = "4.3.0", + description = "Test description", + usage = Usage(users = Users()), + thumbnail = Thumbnail( + url = "https://example.com/thumbnail", + blurhash = null, + versions = null, + ), + languages = emptyList(), + configuration = Configuration( + statuses = InstanceV2Statuses(), + mediaAttachments = MediaAttachments(), + polls = InstanceV2Polls(), + ), + registrations = Registrations( + enabled = false, + approvalRequired = false, + message = null, + ), + contact = Contact(), +) diff --git a/core/network-test/src/main/kotlin/app/pachli/core/network/di/test/FakeNetworkModule.kt b/core/network-test/src/main/kotlin/app/pachli/core/network/di/test/FakeNetworkModule.kt index 44a119e45..ed2ebb0ab 100644 --- a/core/network-test/src/main/kotlin/app/pachli/core/network/di/test/FakeNetworkModule.kt +++ b/core/network-test/src/main/kotlin/app/pachli/core/network/di/test/FakeNetworkModule.kt @@ -17,6 +17,7 @@ package app.pachli.core.network.di.test +import app.pachli.core.model.VersionAdapter import app.pachli.core.network.di.NetworkModule import app.pachli.core.network.json.Guarded import app.pachli.core.network.model.MediaUploadApi @@ -40,6 +41,7 @@ object FakeNetworkModule { @Provides @Singleton fun providesMoshi(): Moshi = Moshi.Builder() + .add(VersionAdapter()) .add(Date::class.java, Rfc3339DateJsonAdapter()) .add(Guarded.Factory()) .build() diff --git a/core/network/src/main/kotlin/app/pachli/core/network/di/NetworkModule.kt b/core/network/src/main/kotlin/app/pachli/core/network/di/NetworkModule.kt index 0c968a5e4..3994404b0 100644 --- a/core/network/src/main/kotlin/app/pachli/core/network/di/NetworkModule.kt +++ b/core/network/src/main/kotlin/app/pachli/core/network/di/NetworkModule.kt @@ -20,6 +20,7 @@ package app.pachli.core.network.di import android.content.Context import android.os.Build import app.pachli.core.common.util.versionName +import app.pachli.core.model.VersionAdapter import app.pachli.core.network.BuildConfig import app.pachli.core.network.json.BooleanIfNull import app.pachli.core.network.json.DefaultIfNull @@ -69,6 +70,7 @@ object NetworkModule { @Singleton fun providesMoshi(): Moshi = Moshi.Builder() .add(Date::class.java, Rfc3339DateJsonAdapter()) + .add(VersionAdapter()) .add(Guarded.Factory()) .add(HasDefault.Factory()) .add(DefaultIfNull.Factory()) diff --git a/core/network/src/main/kotlin/app/pachli/core/network/model/Announcement.kt b/core/network/src/main/kotlin/app/pachli/core/network/model/Announcement.kt index 210b2e158..92bac7d3b 100644 --- a/core/network/src/main/kotlin/app/pachli/core/network/model/Announcement.kt +++ b/core/network/src/main/kotlin/app/pachli/core/network/model/Announcement.kt @@ -24,8 +24,8 @@ import java.util.Date data class Announcement( val id: String, val content: String, - @Json(name = "starts_at") val startsAt: Date?, - @Json(name = "ends_at") val endsAt: Date?, + @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, diff --git a/core/network/src/main/kotlin/app/pachli/core/network/model/FilterV1.kt b/core/network/src/main/kotlin/app/pachli/core/network/model/FilterV1.kt index 127cf8d2e..786b9c276 100644 --- a/core/network/src/main/kotlin/app/pachli/core/network/model/FilterV1.kt +++ b/core/network/src/main/kotlin/app/pachli/core/network/model/FilterV1.kt @@ -41,11 +41,3 @@ data class FilterV1( return filter?.id.equals(id) } } - -data class NewContentFilterV1( - val phrase: String, - val contexts: Set, - val expiresIn: Int, - val irreversible: Boolean, - val wholeWord: Boolean, -) diff --git a/core/network/src/main/kotlin/app/pachli/core/network/model/InstanceV1.kt b/core/network/src/main/kotlin/app/pachli/core/network/model/InstanceV1.kt index e812e74ae..afe76562e 100644 --- a/core/network/src/main/kotlin/app/pachli/core/network/model/InstanceV1.kt +++ b/core/network/src/main/kotlin/app/pachli/core/network/model/InstanceV1.kt @@ -61,7 +61,7 @@ data class PollConfiguration( @Json(name = "max_option_chars") val maxOptionChars: Int = 50, @Json(name = "max_characters_per_option") val maxCharactersPerOption: Int = 50, @Json(name = "min_expiration") val minExpiration: Int = 300, - @Json(name = "max_expiration") val maxExpiration: Int = 604800, + @Json(name = "max_expiration") val maxExpiration: Long = 604800, ) @JsonClass(generateAdapter = true) diff --git a/core/network/src/main/kotlin/app/pachli/core/network/model/MediaUploadApi.kt b/core/network/src/main/kotlin/app/pachli/core/network/model/MediaUploadApi.kt index 9c0e87e95..12977d19a 100644 --- a/core/network/src/main/kotlin/app/pachli/core/network/model/MediaUploadApi.kt +++ b/core/network/src/main/kotlin/app/pachli/core/network/model/MediaUploadApi.kt @@ -17,8 +17,10 @@ package app.pachli.core.network.model +import app.pachli.core.network.retrofit.MastodonApi.Companion.DOMAIN_HEADER import app.pachli.core.network.retrofit.apiresult.ApiResult import okhttp3.MultipartBody +import retrofit2.http.Header import retrofit2.http.Multipart import retrofit2.http.POST import retrofit2.http.Part @@ -29,7 +31,9 @@ import retrofit2.http.Part interface MediaUploadApi { @Multipart @POST("api/v2/media") - suspend fun uploadMedia( + suspend fun uploadMediaWithAuth( + @Header("Authorization") auth: String, + @Header(DOMAIN_HEADER) domain: String, @Part file: MultipartBody.Part, @Part description: MultipartBody.Part? = null, @Part focus: MultipartBody.Part? = null, diff --git a/core/network/src/main/kotlin/app/pachli/core/network/model/nodeinfo/NodeInfo.kt b/core/network/src/main/kotlin/app/pachli/core/network/model/nodeinfo/NodeInfo.kt index f7a10d35d..f318bc8bd 100644 --- a/core/network/src/main/kotlin/app/pachli/core/network/model/nodeinfo/NodeInfo.kt +++ b/core/network/src/main/kotlin/app/pachli/core/network/model/nodeinfo/NodeInfo.kt @@ -53,14 +53,7 @@ data class UnvalidatedNodeInfo(val software: Software?) { software == null -> Err(Error.NoSoftwareBlock) software.name.isNullOrBlank() -> Err(Error.NoSoftwareName) software.version.isNullOrBlank() -> Err(Error.NoSoftwareVersion) - else -> Ok( - NodeInfo( - NodeInfo.Software( - software.name, - software.version, - ), - ), - ) + else -> Ok(NodeInfo(NodeInfo.Software(software.name, software.version))) } } diff --git a/core/network/src/main/kotlin/app/pachli/core/network/retrofit/InstanceSwitchAuthInterceptor.kt b/core/network/src/main/kotlin/app/pachli/core/network/retrofit/InstanceSwitchAuthInterceptor.kt index 366adf0c9..53f9bf3a6 100644 --- a/core/network/src/main/kotlin/app/pachli/core/network/retrofit/InstanceSwitchAuthInterceptor.kt +++ b/core/network/src/main/kotlin/app/pachli/core/network/retrofit/InstanceSwitchAuthInterceptor.kt @@ -70,7 +70,7 @@ class InstanceSwitchAuthInterceptor @Inject constructor() : Interceptor { .code(400) .message("Bad Request") .protocol(Protocol.HTTP_2) - .body("".toResponseBody("text/plain".toMediaType())) + .body("InstanceSwitchAuthInterceptor failure".toResponseBody("text/plain".toMediaType())) .request(chain.request()) .build() } diff --git a/core/network/src/main/kotlin/app/pachli/core/network/retrofit/MastodonApi.kt b/core/network/src/main/kotlin/app/pachli/core/network/retrofit/MastodonApi.kt index 8ac6510eb..bfb6edceb 100644 --- a/core/network/src/main/kotlin/app/pachli/core/network/retrofit/MastodonApi.kt +++ b/core/network/src/main/kotlin/app/pachli/core/network/retrofit/MastodonApi.kt @@ -90,13 +90,13 @@ interface MastodonApi { } @GET("/api/v1/custom_emojis") - suspend fun getCustomEmojis(): NetworkResult> + suspend fun getCustomEmojis(): ApiResult> @GET("api/v1/instance") - suspend fun getInstanceV1(@Header(DOMAIN_HEADER) domain: String? = null): NetworkResult + suspend fun getInstanceV1(@Header(DOMAIN_HEADER) domain: String? = null): ApiResult @GET("api/v2/instance") - suspend fun getInstanceV2(@Header(DOMAIN_HEADER) domain: String? = null): NetworkResult + suspend fun getInstanceV2(@Header(DOMAIN_HEADER) domain: String? = null): ApiResult @GET("api/v1/filters") suspend fun getContentFiltersV1(): ApiResult> @@ -342,7 +342,7 @@ interface MastodonApi { suspend fun accountVerifyCredentials( @Header(DOMAIN_HEADER) domain: String? = null, @Header("Authorization") auth: String? = null, - ): NetworkResult + ): ApiResult @FormUrlEncoded @PATCH("api/v1/accounts/update_credentials") @@ -556,7 +556,7 @@ interface MastodonApi { @Field("client_id") clientId: String, @Field("client_secret") clientSecret: String, @Field("token") token: String, - ): NetworkResult + ): ApiResult @GET("/api/v1/lists") suspend fun getLists(): ApiResult> @@ -701,12 +701,12 @@ interface MastodonApi { @GET("api/v1/announcements") suspend fun listAnnouncements( @Query("with_dismissed") withDismissed: Boolean = true, - ): NetworkResult> + ): ApiResult> @POST("api/v1/announcements/{id}/dismiss") suspend fun dismissAnnouncement( @Path("id") announcementId: String, - ): NetworkResult + ): ApiResult @PUT("api/v1/announcements/{id}/reactions/{name}") suspend fun addAnnouncementReaction( diff --git a/core/network/src/main/kotlin/app/pachli/core/network/retrofit/NodeInfoApi.kt b/core/network/src/main/kotlin/app/pachli/core/network/retrofit/NodeInfoApi.kt index a2e11e85f..0369b8793 100644 --- a/core/network/src/main/kotlin/app/pachli/core/network/retrofit/NodeInfoApi.kt +++ b/core/network/src/main/kotlin/app/pachli/core/network/retrofit/NodeInfoApi.kt @@ -19,7 +19,7 @@ package app.pachli.core.network.retrofit import app.pachli.core.network.model.nodeinfo.UnvalidatedJrd import app.pachli.core.network.model.nodeinfo.UnvalidatedNodeInfo -import at.connyduck.calladapter.networkresult.NetworkResult +import app.pachli.core.network.retrofit.apiresult.ApiResult import retrofit2.http.GET import retrofit2.http.Url @@ -28,11 +28,11 @@ interface NodeInfoApi { * Instance info from the Nodeinfo .well_known (https://nodeinfo.diaspora.software/protocol.html) endpoint */ @GET("/.well-known/nodeinfo") - suspend fun nodeInfoJrd(): NetworkResult + suspend fun nodeInfoJrd(): ApiResult /** * Instance info from NodeInfo (https://nodeinfo.diaspora.software/schema.html) endpoint */ @GET - suspend fun nodeInfo(@Url nodeInfoUrl: String): NetworkResult + suspend fun nodeInfo(@Url nodeInfoUrl: String): ApiResult } diff --git a/core/network/src/main/res/values-en-rGB/strings.xml b/core/network/src/main/res/values-en-rGB/strings.xml index 130cc3225..2b9f6755d 100644 --- a/core/network/src/main/res/values-en-rGB/strings.xml +++ b/core/network/src/main/res/values-en-rGB/strings.xml @@ -1,5 +1,5 @@ - An error occurred: %s + %1$s Your server does not support this feature: %1$s \ No newline at end of file diff --git a/core/testing/build.gradle.kts b/core/testing/build.gradle.kts index 3baee5baa..4748f2440 100644 --- a/core/testing/build.gradle.kts +++ b/core/testing/build.gradle.kts @@ -18,6 +18,7 @@ plugins { alias(libs.plugins.pachli.android.library) alias(libs.plugins.pachli.android.hilt) + alias(libs.plugins.pachli.android.room) } android { @@ -30,12 +31,19 @@ android { dependencies { implementation(projects.core.common) + implementation(projects.core.database) implementation(projects.core.network) + implementation(libs.hilt.android.testing) + + implementation(libs.moshi) + implementation(libs.moshi.adapters) + api(libs.kotlinx.coroutines.test) api(libs.androidx.test.junit) api(libs.androidx.core.testing) api(libs.androidx.test.core.ktx) + api(libs.bundles.room)?.because("Allows calls to RoomDatabase.close() in tests.") api(libs.robolectric) api(libs.truth) api(libs.turbine) diff --git a/core/database/src/test/kotlin/app/pachli/core/database/di/FakeDatabaseModule.kt b/core/testing/src/main/kotlin/app/pachli/core/testing/fakes/FakeDatabaseModule.kt similarity index 80% rename from core/database/src/test/kotlin/app/pachli/core/database/di/FakeDatabaseModule.kt rename to core/testing/src/main/kotlin/app/pachli/core/testing/fakes/FakeDatabaseModule.kt index f14f2b8c7..07ab99bd0 100644 --- a/core/database/src/test/kotlin/app/pachli/core/database/di/FakeDatabaseModule.kt +++ b/core/testing/src/main/kotlin/app/pachli/core/testing/fakes/FakeDatabaseModule.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023 Pachli Association + * Copyright 2024 Pachli Association * * This file is a part of Pachli. * @@ -15,12 +15,14 @@ * see . */ -package app.pachli.core.database.di +package app.pachli.core.testing.fakes import androidx.room.Room import androidx.test.platform.app.InstrumentationRegistry import app.pachli.core.database.AppDatabase import app.pachli.core.database.Converters +import app.pachli.core.database.di.DatabaseModule +import app.pachli.core.database.di.TransactionProvider import com.squareup.moshi.Moshi import dagger.Module import dagger.Provides @@ -68,4 +70,16 @@ object FakeDatabaseModule { @Provides fun providesTranslatedStatusDao(appDatabase: AppDatabase) = appDatabase.translatedStatusDao() + + @Provides + fun providesLogEntryDao(appDatabase: AppDatabase) = appDatabase.logEntryDao() + + @Provides + fun providesContentFiltersDao(appDatabase: AppDatabase) = appDatabase.contentFiltersDao() + + @Provides + fun providesListsDao(appDatabase: AppDatabase) = appDatabase.listsDao() + + @Provides + fun providesAnnouncementsDao(appDatabase: AppDatabase) = appDatabase.announcementsDao() } diff --git a/core/testing/src/main/kotlin/app/pachli/core/testing/rules/LazyActivityScenarioRule.kt b/core/testing/src/main/kotlin/app/pachli/core/testing/rules/LazyActivityScenarioRule.kt index f2f9d771e..4a79f5910 100644 --- a/core/testing/src/main/kotlin/app/pachli/core/testing/rules/LazyActivityScenarioRule.kt +++ b/core/testing/src/main/kotlin/app/pachli/core/testing/rules/LazyActivityScenarioRule.kt @@ -51,7 +51,9 @@ class LazyActivityScenarioRule : ExternalResource { private var scenarioSupplier: () -> ActivityScenario - private var scenario: ActivityScenario? = null + private var _scenario: ActivityScenario? = null + + val scenario get() = checkNotNull(_scenario) private var scenarioLaunched: Boolean = false @@ -62,18 +64,16 @@ class LazyActivityScenarioRule : ExternalResource { } override fun after() { - scenario?.close() + _scenario?.close() } fun launch(newIntent: Intent? = null) { if (scenarioLaunched) throw IllegalStateException("Scenario has already been launched!") newIntent?.let { scenarioSupplier = { ActivityScenario.launch(it) } } - scenario = scenarioSupplier() + _scenario = scenarioSupplier() scenarioLaunched = true } - - fun getScenario(): ActivityScenario = checkNotNull(scenario) } inline fun lazyActivityScenarioRule(launchActivity: Boolean = true, noinline intentSupplier: () -> Intent): LazyActivityScenarioRule = diff --git a/feature/lists/build.gradle.kts b/feature/lists/build.gradle.kts index 1e11ddd17..077421fdb 100644 --- a/feature/lists/build.gradle.kts +++ b/feature/lists/build.gradle.kts @@ -18,8 +18,11 @@ dependencies { implementation(projects.core.common) implementation(projects.core.data) implementation(projects.core.designsystem) + implementation(projects.core.domain) implementation(projects.core.navigation) implementation(projects.core.network) + ?.because("AccountsInListFragment uses network.model.TimelineAccount and ApiError") + implementation(projects.core.ui) // TODO: These dependencies are required by BottomSheetActivity, diff --git a/feature/lists/lint-baseline.xml b/feature/lists/lint-baseline.xml index a9a77a6e1..84beb7626 100644 --- a/feature/lists/lint-baseline.xml +++ b/feature/lists/lint-baseline.xml @@ -1,5 +1,5 @@ - + { factory -> - factory.create(requireArguments().getString(ARG_LIST_ID)!!) + factory.create( + requireArguments().getLong(ARG_PACHLI_ACCOUNT_ID), + requireArguments().getString(ARG_LIST_ID)!!, + ) } }, ) @@ -91,10 +95,13 @@ class AccountsInListFragment : DialogFragment() { private val animateAvatar by unsafeLazy { sharedPreferencesRepository.animateAvatars } private val animateEmojis by unsafeLazy { sharedPreferencesRepository.animateEmojis } + private var pachliAccountId by Delegates.notNull() + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setStyle(STYLE_NORMAL, DR.style.AppDialogFragmentStyle) val args = requireArguments() + pachliAccountId = args.getLong(ARG_PACHLI_ACCOUNT_ID) listName = args.getString(ARG_LIST_NAME)!! } @@ -298,12 +305,14 @@ class AccountsInListFragment : DialogFragment() { } companion object { + private const val ARG_PACHLI_ACCOUNT_ID = "app.pachli.ARG_PACHLI_ACCOUNT_ID" private const val ARG_LIST_ID = "app.pachli.ARG_LIST_ID" private const val ARG_LIST_NAME = "app.pachli.ARG_LIST_NAME" @JvmStatic - fun newInstance(listId: String, listName: String): AccountsInListFragment { + fun newInstance(pachliAccountId: Long, listId: String, listName: String): AccountsInListFragment { val args = Bundle().apply { + putLong(ARG_PACHLI_ACCOUNT_ID, pachliAccountId) putString(ARG_LIST_ID, listId) putString(ARG_LIST_NAME, listName) } diff --git a/feature/lists/src/main/kotlin/app/pachli/feature/lists/AccountsInListViewModel.kt b/feature/lists/src/main/kotlin/app/pachli/feature/lists/AccountsInListViewModel.kt index 7939022b4..49c616e5f 100644 --- a/feature/lists/src/main/kotlin/app/pachli/feature/lists/AccountsInListViewModel.kt +++ b/feature/lists/src/main/kotlin/app/pachli/feature/lists/AccountsInListViewModel.kt @@ -61,6 +61,7 @@ sealed interface Accounts { class AccountsInListViewModel @AssistedInject constructor( private val api: MastodonApi, private val listsRepository: ListsRepository, + @Assisted val pachliAccountId: Long, @Assisted val listId: String, ) : ViewModel() { @@ -81,7 +82,7 @@ class AccountsInListViewModel @AssistedInject constructor( fun refresh() = viewModelScope.launch { _accountsInList.value = Ok(Accounts.Loading) - _accountsInList.value = listsRepository.getAccountsInList(listId) + _accountsInList.value = listsRepository.getAccountsInList(pachliAccountId, listId) .mapEither( { Accounts.Loaded(it) }, { FlowError.GetAccounts(it) }, @@ -92,7 +93,7 @@ class AccountsInListViewModel @AssistedInject constructor( * Add [account] to [listId], refreshing on success, sending [Error.AddAccounts] on failure */ fun addAccountToList(account: TimelineAccount) = viewModelScope.launch { - listsRepository.addAccountsToList(listId, listOf(account.id)) + listsRepository.addAccountsToList(pachliAccountId, listId, listOf(account.id)) .onSuccess { refresh() } .onFailure { Timber.e("Failed to add account to list: %s", account.username) @@ -105,7 +106,7 @@ class AccountsInListViewModel @AssistedInject constructor( * [Error.DeleteAccounts] on failure */ fun deleteAccountFromList(accountId: String) = viewModelScope.launch { - listsRepository.deleteAccountsFromList(listId, listOf(accountId)) + listsRepository.deleteAccountsFromList(pachliAccountId, listId, listOf(accountId)) .onSuccess { refresh() } .onFailure { Timber.e("Failed to remove account from list: %s", accountId) @@ -128,7 +129,10 @@ class AccountsInListViewModel @AssistedInject constructor( /** Create [AccountsInListViewModel] injecting [listId] */ @AssistedFactory interface Factory { - fun create(listId: String): AccountsInListViewModel + fun create( + pachliAccountId: Long, + listId: String, + ): AccountsInListViewModel } /** diff --git a/feature/lists/src/main/kotlin/app/pachli/feature/lists/ListsActivity.kt b/feature/lists/src/main/kotlin/app/pachli/feature/lists/ListsActivity.kt index cbadbe56d..a1a892833 100644 --- a/feature/lists/src/main/kotlin/app/pachli/feature/lists/ListsActivity.kt +++ b/feature/lists/src/main/kotlin/app/pachli/feature/lists/ListsActivity.kt @@ -43,20 +43,15 @@ import app.pachli.core.activity.extensions.startActivityWithDefaultTransition import app.pachli.core.common.extensions.hide import app.pachli.core.common.extensions.show import app.pachli.core.common.extensions.viewBinding -import app.pachli.core.data.repository.Lists -import app.pachli.core.data.repository.ListsError +import app.pachli.core.data.model.MastodonList import app.pachli.core.data.repository.ListsRepository.Companion.compareByListTitle import app.pachli.core.navigation.TimelineActivityIntent import app.pachli.core.navigation.pachliAccountId -import app.pachli.core.network.model.MastoList import app.pachli.core.network.model.UserListRepliesPolicy import app.pachli.core.ui.BackgroundMessage import app.pachli.core.ui.extensions.await import app.pachli.feature.lists.databinding.ActivityListsBinding import app.pachli.feature.lists.databinding.DialogListBinding -import com.github.michaelbull.result.Result -import com.github.michaelbull.result.onFailure -import com.github.michaelbull.result.onSuccess import com.google.android.material.color.MaterialColors import com.google.android.material.divider.MaterialDividerItemDecoration import com.google.android.material.snackbar.Snackbar @@ -65,6 +60,7 @@ import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial import com.mikepenz.iconics.utils.colorInt import com.mikepenz.iconics.utils.sizeDp import dagger.hilt.android.AndroidEntryPoint +import dagger.hilt.android.lifecycle.withCreationCallback import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch @@ -73,7 +69,13 @@ import kotlinx.coroutines.launch */ @AndroidEntryPoint class ListsActivity : BaseActivity(), MenuProvider { - private val viewModel: ListsViewModel by viewModels() + private val viewModel: ListsViewModel by viewModels( + extrasProducer = { + defaultViewModelCreationExtras.withCreationCallback { factory -> + factory.create(intent.pachliAccountId) + } + }, + ) private val binding by viewBinding(ActivityListsBinding::inflate) @@ -145,7 +147,7 @@ class ListsActivity : BaseActivity(), MenuProvider { viewModel.refresh() } - private suspend fun showListNameDialog(list: MastoList?) { + private suspend fun showListNameDialog(list: MastodonList?) { val builder = AlertDialog.Builder(this) val binding = DialogListBinding.inflate(LayoutInflater.from(builder.context)) val dialog = builder.setView(binding.root).create() @@ -181,51 +183,32 @@ class ListsActivity : BaseActivity(), MenuProvider { if (result == AlertDialog.BUTTON_POSITIVE) { onPickedDialogName( binding.nameText.text.toString(), - list?.id, + list?.listId, binding.exclusiveCheckbox.isChecked, - UserListRepliesPolicy.Companion.from(binding.repliesPolicyGroup.checkedRadioButtonId), + UserListRepliesPolicy.from(binding.repliesPolicyGroup.checkedRadioButtonId), ) } } - private suspend fun showListDeleteDialog(list: MastoList) { + private suspend fun showListDeleteDialog(list: MastodonList) { val result = AlertDialog.Builder(this) .setMessage(getString(R.string.dialog_delete_list_warning, list.title)) .create() .await(R.string.action_delete_list, android.R.string.cancel) - if (result == AlertDialog.BUTTON_POSITIVE) viewModel.deleteList(list.id, list.title) + if (result == AlertDialog.BUTTON_POSITIVE) viewModel.deleteList(list) } - private fun bind(state: Result) { - state.onFailure { + private fun bind(lists: List) { + adapter.submitList(lists.sortedWith(compareByListTitle)) + binding.swipeRefreshLayout.isRefreshing = false + if (lists.isEmpty()) { binding.listsRecycler.hide() binding.messageView.show() - binding.swipeRefreshLayout.isRefreshing = false - - binding.messageView.setup(it) { viewModel.refresh() } - } - - state.onSuccess { lists -> - when (lists) { - is Lists.Loaded -> { - adapter.submitList(lists.lists.sortedWith(compareByListTitle)) - binding.swipeRefreshLayout.isRefreshing = false - if (lists.lists.isEmpty()) { - binding.listsRecycler.hide() - binding.messageView.show() - binding.messageView.setup(BackgroundMessage.Empty()) - } else { - binding.listsRecycler.show() - binding.messageView.hide() - } - } - - Lists.Loading -> { - binding.messageView.hide() - binding.swipeRefreshLayout.isRefreshing = true - } - } + binding.messageView.setup(BackgroundMessage.Empty()) + } else { + binding.listsRecycler.show() + binding.messageView.hide() } } @@ -243,11 +226,15 @@ class ListsActivity : BaseActivity(), MenuProvider { ) } - private fun openListSettings(list: MastoList) { - AccountsInListFragment.newInstance(list.id, list.title).show(supportFragmentManager, null) + private fun openListSettings(list: MastodonList) { + AccountsInListFragment.newInstance( + intent.pachliAccountId, + list.listId, + list.title, + ).show(supportFragmentManager, null) } - private fun onMore(list: MastoList, view: View) { + private fun onMore(list: MastodonList, view: View) { PopupMenu(view.context, view).apply { inflate(R.menu.list_actions) setOnMenuItemClickListener { item -> @@ -263,18 +250,18 @@ class ListsActivity : BaseActivity(), MenuProvider { } } - private object ListsDiffer : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: MastoList, newItem: MastoList): Boolean { - return oldItem.id == newItem.id + private object ListsDiffer : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: MastodonList, newItem: MastodonList): Boolean { + return oldItem.listId == newItem.listId } - override fun areContentsTheSame(oldItem: MastoList, newItem: MastoList): Boolean { + override fun areContentsTheSame(oldItem: MastodonList, newItem: MastodonList): Boolean { return oldItem == newItem } } private inner class ListsAdapter : - ListAdapter(ListsDiffer) { + ListAdapter(ListsDiffer) { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ListViewHolder { return LayoutInflater.from(parent.context).inflate(R.layout.item_list, parent, false) @@ -309,7 +296,7 @@ class ListsActivity : BaseActivity(), MenuProvider { override fun onClick(v: View) { if (v == itemView) { val list = getItem(bindingAdapterPosition) - onListSelected(list.id, list.title) + onListSelected(list.listId, list.title) } else { onMore(getItem(bindingAdapterPosition), v) } diff --git a/feature/lists/src/main/kotlin/app/pachli/feature/lists/ListsForAccountFragment.kt b/feature/lists/src/main/kotlin/app/pachli/feature/lists/ListsForAccountFragment.kt index 0cf0920fe..f0e0e30fd 100644 --- a/feature/lists/src/main/kotlin/app/pachli/feature/lists/ListsForAccountFragment.kt +++ b/feature/lists/src/main/kotlin/app/pachli/feature/lists/ListsForAccountFragment.kt @@ -57,6 +57,7 @@ class ListsForAccountFragment : DialogFragment() { extrasProducer = { defaultViewModelCreationExtras.withCreationCallback { factory -> factory.create( + requireArguments().getLong(ARG_PACHLI_ACCOUNT_ID), requireArguments().getString(ARG_ACCOUNT_ID)!!, ) } @@ -169,7 +170,7 @@ class ListsForAccountFragment : DialogFragment() { oldItem: ListWithMembership, newItem: ListWithMembership, ): Boolean { - return oldItem.list.id == newItem.list.id + return oldItem.list.listId == newItem.list.listId } override fun areContentsTheSame( @@ -195,9 +196,9 @@ class ListsForAccountFragment : DialogFragment() { if (isChecked == item.isMember) return@setOnCheckedChangeListener if (isChecked) { - viewModel.addAccountToList(item.list.id) + viewModel.addAccountToList(item.list.listId) } else { - viewModel.deleteAccountFromList(item.list.id) + viewModel.deleteAccountFromList(item.list.listId) } } return holder diff --git a/feature/lists/src/main/kotlin/app/pachli/feature/lists/ListsForAccountViewModel.kt b/feature/lists/src/main/kotlin/app/pachli/feature/lists/ListsForAccountViewModel.kt index ef6f1be16..db93953b9 100644 --- a/feature/lists/src/main/kotlin/app/pachli/feature/lists/ListsForAccountViewModel.kt +++ b/feature/lists/src/main/kotlin/app/pachli/feature/lists/ListsForAccountViewModel.kt @@ -19,15 +19,14 @@ package app.pachli.feature.lists import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import app.pachli.core.data.model.MastodonList import app.pachli.core.data.repository.HasListId -import app.pachli.core.data.repository.Lists import app.pachli.core.data.repository.ListsError import app.pachli.core.data.repository.ListsRepository import app.pachli.core.network.model.MastoList -import com.github.michaelbull.result.Err import com.github.michaelbull.result.Ok import com.github.michaelbull.result.Result -import com.github.michaelbull.result.getOrElse +import com.github.michaelbull.result.mapEither import com.github.michaelbull.result.onFailure import dagger.assisted.Assisted import dagger.assisted.AssistedFactory @@ -54,7 +53,7 @@ sealed interface ListsWithMembership { * @property isMember True if this list contains [ListsForAccountViewModel.accountId] */ data class ListWithMembership( - val list: MastoList, + val list: MastodonList, val isMember: Boolean, ) @@ -62,6 +61,7 @@ data class ListWithMembership( class ListsForAccountViewModel @AssistedInject constructor( private val listsRepository: ListsRepository, @Assisted val accountId: String, + @Assisted val pachliAccountId: Long, ) : ViewModel() { private val _listsWithMembership = MutableStateFlow>(Ok(ListsWithMembership.Loading)) val listsWithMembership = _listsWithMembership.asStateFlow() @@ -81,29 +81,19 @@ class ListsForAccountViewModel @AssistedInject constructor( */ fun refresh() = viewModelScope.launch { _listsWithMembership.value = Ok(ListsWithMembership.Loading) - listsRepository.lists.collect { result -> - val lists = result.getOrElse { - _listsWithMembership.value = Err(Error.Retrieve(it)) - return@collect - } - - if (lists !is Lists.Loaded) return@collect - + listsRepository.getLists(pachliAccountId).collect { lists -> _listsWithMembership.value = with(listsWithMembershipMap) { - val memberLists = listsRepository.getListsWithAccount(accountId) - .getOrElse { return@with Err(Error.GetListsWithAccount(it)) } - - clear() - - memberLists.forEach { list -> - put(list.id, ListWithMembership(list, true)) - } - - lists.lists.forEach { list -> - putIfAbsent(list.id, ListWithMembership(list, false)) - } - - Ok(ListsWithMembership.Loaded(listsWithMembershipMap.toImmutableMap())) + listsRepository.getListsWithAccount(pachliAccountId, accountId).mapEither( + { memberLists -> + clear() + memberLists.forEach { list -> put(list.listId, ListWithMembership(list, true)) } + lists.forEach { list -> + putIfAbsent(list.listId, ListWithMembership(list, false)) + } + ListsWithMembership.Loaded(listsWithMembershipMap.toImmutableMap()) + }, + { Error.GetListsWithAccount(it) }, + ) } } } @@ -119,7 +109,7 @@ class ListsForAccountViewModel @AssistedInject constructor( _listsWithMembership.value = Ok(ListsWithMembership.Loaded(listsWithMembershipMap.toImmutableMap())) - listsRepository.addAccountsToList(listId, listOf(accountId)).onFailure { error -> + listsRepository.addAccountsToList(pachliAccountId, listId, listOf(accountId)).onFailure { error -> // Undo the optimistic update listsWithMembershipMap[listId]?.let { listsWithMembershipMap[listId] = it.copy(isMember = false) @@ -141,7 +131,7 @@ class ListsForAccountViewModel @AssistedInject constructor( } _listsWithMembership.value = Ok(ListsWithMembership.Loaded(listsWithMembershipMap.toImmutableMap())) - listsRepository.deleteAccountsFromList(listId, listOf(accountId)).onFailure { error -> + listsRepository.deleteAccountsFromList(pachliAccountId, listId, listOf(accountId)).onFailure { error -> // Undo the optimistic update listsWithMembershipMap[listId]?.let { listsWithMembershipMap[listId] = it.copy(isMember = true) @@ -156,7 +146,7 @@ class ListsForAccountViewModel @AssistedInject constructor( /** Create [ListsForAccountViewModel] injecting [accountId] */ @AssistedFactory interface Factory { - fun create(accountId: String): ListsForAccountViewModel + fun create(activeAccountId: Long, accountId: String): ListsForAccountViewModel } /** diff --git a/feature/lists/src/main/kotlin/app/pachli/feature/lists/ListsViewModel.kt b/feature/lists/src/main/kotlin/app/pachli/feature/lists/ListsViewModel.kt index cc26b3b7e..c2bd250d6 100644 --- a/feature/lists/src/main/kotlin/app/pachli/feature/lists/ListsViewModel.kt +++ b/feature/lists/src/main/kotlin/app/pachli/feature/lists/ListsViewModel.kt @@ -21,15 +21,20 @@ import androidx.annotation.StringRes import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import app.pachli.core.common.string.unicodeWrap +import app.pachli.core.data.model.MastodonList import app.pachli.core.data.repository.ListsError import app.pachli.core.data.repository.ListsRepository import app.pachli.core.network.model.UserListRepliesPolicy import app.pachli.core.ui.OperationCounter import com.github.michaelbull.result.onFailure +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel -import javax.inject.Inject import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.launch sealed class Error( @@ -37,7 +42,6 @@ sealed class Error( override val formatArgs: Array, override val cause: ListsError? = null, ) : ListsError { - data class Create(val title: String, override val cause: ListsError.Create) : Error(R.string.error_create_list_fmt, arrayOf(title.unicodeWrap()), cause) @@ -48,9 +52,10 @@ sealed class Error( Error(R.string.error_rename_list_fmt, arrayOf(title.unicodeWrap()), cause) } -@HiltViewModel -internal class ListsViewModel @Inject constructor( +@HiltViewModel(assistedFactory = ListsViewModel.Factory::class) +internal class ListsViewModel @AssistedInject constructor( private val listsRepository: ListsRepository, + @Assisted val pachliAccountId: Long, ) : ViewModel() { private val _errors = Channel() val errors = _errors.receiveAsFlow() @@ -58,39 +63,41 @@ internal class ListsViewModel @Inject constructor( private val operationCounter = OperationCounter() val operationCount = operationCounter.count - val lists = listsRepository.lists - - init { - viewModelScope.launch { - operationCounter { listsRepository.refresh() } - } - } + // Not a stateflow, as that makes updates distinct. A refresh that returns + // no changes is not distinct, and that prevents the refresh spinner from + // disappearing when the user refreshes. + val lists = listsRepository.getLists(pachliAccountId) + .shareIn(viewModelScope, SharingStarted.WhileSubscribed(5000), replay = 1) fun refresh() = viewModelScope.launch { - operationCounter { listsRepository.refresh() } + operationCounter { listsRepository.refresh(pachliAccountId) } } fun createNewList(title: String, exclusive: Boolean, repliesPolicy: UserListRepliesPolicy) = viewModelScope.launch { operationCounter { - listsRepository.createList(title, exclusive, repliesPolicy).onFailure { - _errors.send(Error.Create(title, it)) - } + listsRepository.createList(pachliAccountId, title, exclusive, repliesPolicy) + .onFailure { _errors.send(Error.Create(title, it)) } } } fun updateList(listId: String, title: String, exclusive: Boolean, repliesPolicy: UserListRepliesPolicy) = viewModelScope.launch { operationCounter { - listsRepository.editList(listId, title, exclusive, repliesPolicy).onFailure { - _errors.send(Error.Update(title, it)) - } + listsRepository.updateList(pachliAccountId, listId, title, exclusive, repliesPolicy) + .onFailure { _errors.send(Error.Update(title, it)) } } } - fun deleteList(listId: String, title: String) = viewModelScope.launch { + fun deleteList(list: MastodonList) = viewModelScope.launch { operationCounter { - listsRepository.deleteList(listId).onFailure { - _errors.send(Error.Delete(title, it)) - } + listsRepository.deleteList(list).onFailure { _errors.send(Error.Delete(list.title, it)) } } } + + @AssistedFactory + interface Factory { + /** + * Creates [ListsViewModel] with [pachliAccountId] as the active account. + */ + fun create(pachliAccountId: Long): ListsViewModel + } } diff --git a/feature/lists/src/main/res/values/strings.xml b/feature/lists/src/main/res/values/strings.xml index b0cafaa48..c03786581 100644 --- a/feature/lists/src/main/res/values/strings.xml +++ b/feature/lists/src/main/res/values/strings.xml @@ -12,7 +12,7 @@ Could not update list \"%1$s\": %2$s Update the list Create a list - Do you really want to delete the list %s? + Delete the list \"%s\"? Remove account from the list Add account to the list Delete the list diff --git a/feature/login/lint-baseline.xml b/feature/login/lint-baseline.xml index 508561575..1cceab4f6 100644 --- a/feature/login/lint-baseline.xml +++ b/feature/login/lint-baseline.xml @@ -1,5 +1,5 @@ - + + binding.domainEditText.setText(domain) binding.domainEditText.isEnabled = false } @@ -124,8 +133,44 @@ class LoginActivity : BaseActivity() { } setSupportActionBar(binding.toolbar) - supportActionBar?.setDisplayHomeAsUpEnabled(isAdditionalLogin() || isAccountMigration()) + supportActionBar?.setDisplayHomeAsUpEnabled(isAdditionalLogin() || authenticationDomain != null) supportActionBar?.setDisplayShowTitleEnabled(false) + + bind() + } + + /** Binds data to the UI. */ + private fun bind() { + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.RESUMED) { + launch { viewModel.uiResult.collect(::bindUiResult) } + } + } + } + + /** Act on the result of UI actions. */ + private fun bindUiResult(uiResult: Result) { + uiResult.onFailure { uiError -> + when (uiError) { + is UiError.VerifyAndAddAccount -> { + setLoading(false) + binding.domainTextInputLayout.error = uiError.fmt(this) + Timber.e(uiError.fmt(this)) + } + } + } + + uiResult.onSuccess { uiSuccess -> + when (uiSuccess) { + is UiSuccess.VerifyAndAddAccount -> { + val intent = MainActivityIntent(this, uiSuccess.accountId) + intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + startActivityWithTransition(intent, TransitionKind.EXPLODE) + finishAffinity() + setCloseTransition(TransitionKind.EXPLODE) + } + } + } } override fun requiresLogin(): Boolean { @@ -290,7 +335,15 @@ class LoginActivity : BaseActivity() { "authorization_code", ).fold( { accessToken -> - fetchAccountDetails(accessToken, domain, clientId, clientSecret) + viewModel.accept( + FallibleUiAction.VerifyAndAddAccount( + accessToken, + domain, + clientId, + clientSecret, + OAUTH_SCOPES, + ), + ) }, { e -> setLoading(false) @@ -301,41 +354,6 @@ class LoginActivity : BaseActivity() { ) } - private suspend fun fetchAccountDetails( - accessToken: AccessToken, - domain: String, - clientId: String, - clientSecret: String, - ) { - mastodonApi.accountVerifyCredentials( - domain = domain, - auth = "Bearer ${accessToken.accessToken}", - ).fold( - { newAccount -> - val pachliAccountId = accountManager.addAccount( - accessToken = accessToken.accessToken, - domain = domain, - clientId = clientId, - clientSecret = clientSecret, - oauthScopes = OAUTH_SCOPES, - newAccount = newAccount, - ) - - val intent = MainActivityIntent(this, pachliAccountId) - intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK - startActivityWithTransition(intent, TransitionKind.EXPLODE) - finishAffinity() - setCloseTransition(TransitionKind.EXPLODE) - }, - { e -> - setLoading(false) - binding.domainTextInputLayout.error = - getString(R.string.error_loading_account_details) - Timber.e(e, getString(R.string.error_loading_account_details)) - }, - ) - } - private fun setLoading(loadingState: Boolean) { if (loadingState) { binding.loginLoadingLayout.visibility = View.VISIBLE @@ -348,11 +366,11 @@ class LoginActivity : BaseActivity() { } private fun isAdditionalLogin(): Boolean { - return LoginActivityIntent.getLoginMode(intent) == LoginActivityIntent.LoginMode.ADDITIONAL_LOGIN + return LoginActivityIntent.getLoginMode(intent) is LoginActivityIntent.LoginMode.AdditionalLogin } - private fun isAccountMigration(): Boolean { - return LoginActivityIntent.getLoginMode(intent) == LoginActivityIntent.LoginMode.MIGRATION + private fun authenticationDomain(): String? { + return (LoginActivityIntent.getLoginMode(intent) as? LoginActivityIntent.LoginMode.Reauthenticate)?.domain } companion object { diff --git a/feature/login/src/main/kotlin/app/pachli/feature/login/LoginViewModel.kt b/feature/login/src/main/kotlin/app/pachli/feature/login/LoginViewModel.kt new file mode 100644 index 000000000..55eade42a --- /dev/null +++ b/feature/login/src/main/kotlin/app/pachli/feature/login/LoginViewModel.kt @@ -0,0 +1,67 @@ +/* + * Copyright 2024 Pachli Association + * + * This file is a part of Pachli. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Pachli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Pachli; if not, + * see . + */ + +package app.pachli.feature.login + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import app.pachli.core.data.repository.AccountManager +import com.github.michaelbull.result.Result +import com.github.michaelbull.result.mapEither +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch + +@HiltViewModel +internal class LoginViewModel @Inject constructor( + private val accountManager: AccountManager, +) : ViewModel() { + private val uiAction = MutableSharedFlow() + val accept: (UiAction) -> Unit = { action -> viewModelScope.launch { uiAction.emit(action) } } + + private val _uiResult = Channel>() + val uiResult = _uiResult.receiveAsFlow() + + init { + viewModelScope.launch { uiAction.collect { launch { onUiAction(it) } } } + } + + /** Processes actions received from the UI. */ + private suspend fun onUiAction(uiAction: UiAction) { + val result = when (uiAction) { + is FallibleUiAction.VerifyAndAddAccount -> verifyAndAddAccount(uiAction) + } + + _uiResult.send(result) + } + + private suspend fun verifyAndAddAccount(uiAction: FallibleUiAction.VerifyAndAddAccount): Result { + return accountManager.verifyAndAddAccount( + uiAction.accessToken.accessToken, + uiAction.domain, + uiAction.clientId, + uiAction.clientSecret, + uiAction.oAuthScopes, + ).mapEither( + { UiSuccess.VerifyAndAddAccount(uiAction, it) }, + { UiError.VerifyAndAddAccount(uiAction, it) }, + ) + } +} diff --git a/feature/login/src/main/kotlin/app/pachli/feature/login/LoginWebViewViewModel.kt b/feature/login/src/main/kotlin/app/pachli/feature/login/LoginWebViewViewModel.kt index dca5d4547..d8152770d 100644 --- a/feature/login/src/main/kotlin/app/pachli/feature/login/LoginWebViewViewModel.kt +++ b/feature/login/src/main/kotlin/app/pachli/feature/login/LoginWebViewViewModel.kt @@ -20,7 +20,7 @@ package app.pachli.feature.login import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import app.pachli.core.network.retrofit.MastodonApi -import at.connyduck.calladapter.networkresult.fold +import com.github.michaelbull.result.mapBoth import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow @@ -40,11 +40,10 @@ class LoginWebViewViewModel @Inject constructor( if (this.domain == null) { this.domain = domain viewModelScope.launch { - api.getInstanceV1(domain).fold({ instance -> - instanceRules.value = instance.rules.map { rule -> rule.text } - }, { throwable -> - Timber.w(throwable, "failed to load instance info") - }) + api.getInstanceV1(domain).mapBoth( + { instanceRules.value = it.body.rules.map { rule -> rule.text } }, + { Timber.w(it.throwable, "failed to load instance info") }, + ) } } } diff --git a/feature/login/src/main/kotlin/app/pachli/feature/login/UiAction.kt b/feature/login/src/main/kotlin/app/pachli/feature/login/UiAction.kt new file mode 100644 index 000000000..a680bf911 --- /dev/null +++ b/feature/login/src/main/kotlin/app/pachli/feature/login/UiAction.kt @@ -0,0 +1,62 @@ +/* + * Copyright 2024 Pachli Association + * + * This file is a part of Pachli. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Pachli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Pachli; if not, + * see . + */ + +package app.pachli.feature.login + +import androidx.annotation.StringRes +import app.pachli.core.common.PachliError +import app.pachli.core.network.model.AccessToken +import app.pachli.core.network.retrofit.apiresult.ApiError + +/** Actions the user can take from the UI. */ +internal sealed interface UiAction + +internal sealed interface FallibleUiAction : UiAction { + /** Verify the account has working credentials and add to local database. */ + data class VerifyAndAddAccount( + val accessToken: AccessToken, + val domain: String, + val clientId: String, + val clientSecret: String, + val oAuthScopes: String, + ) : FallibleUiAction +} + +/** Actions that succeeded. */ +internal sealed interface UiSuccess { + val action: FallibleUiAction + + /** @see [FallibleUiAction.VerifyAndAddAccount]. */ + data class VerifyAndAddAccount( + override val action: FallibleUiAction.VerifyAndAddAccount, + val accountId: Long, + ) : UiSuccess +} + +/** Actions that failed. */ +internal sealed class UiError( + @StringRes override val resourceId: Int, + open val action: UiAction, + override val cause: PachliError, + override val formatArgs: Array? = null, +) : PachliError { + /** @see [FallibleUiAction.VerifyAndAddAccount]. */ + data class VerifyAndAddAccount( + override val action: FallibleUiAction.VerifyAndAddAccount, + override val cause: ApiError, + ) : UiError(R.string.error_loading_account_details, action, cause) +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c63cb184a..0e1147fe0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -142,6 +142,7 @@ androidx-media3-datasource-okhttp = { module = "androidx.media3:media3-datasourc androidx-media3-ui = { module = "androidx.media3:media3-ui", version.ref = "androidx-media3" } androidx-paging-runtime-ktx = { module = "androidx.paging:paging-runtime-ktx", version.ref = "androidx-paging" } androidx-preference-ktx = { module = "androidx.preference:preference-ktx", version.ref = "androidx-preference" } +androidx-room-common = { group = "androidx.room", name = "room-common", version.ref = "androidx-room" } androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "androidx-room" } androidx-room-paging = { module = "androidx.room:room-paging", version.ref = "androidx-room" } androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "androidx-room" }