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