refactor: Ongoing work to remove the `activeAccount` idiom (#964)

Continue the work to remove the "activeAccount" idiom.

- Uses a new PachliAccount type through most of the app. This holds
information that was previously accessed separately (e.g., content
filters, lists) in one place. The information is loaded when the app
launches or the active account switches.

- Fetching data when the account is switched / loaded simplifies error
handling, as more code can now assume the data has already been loaded.
If it hasn't the code path is simply unreachable.

- This opens up the possibility of "acting as one account while logged
in as another". E.g., have two accounts, and be logged in to one account
and boost a post you've seen from your other account.

- Add a database migration to populate existing accounts with default
data when the user updates the app.

- Refactor code that used those list and filter repositories to get the
data from the PachliAccount instead. New local and remote data sources
are implemented, and the list and filter repositories mediate between
those sources.

- Start a ViewModel for MainActivity, which includes:
  - Sending user actions as UiAction objects
  - Providing a flow of uiState for MainActivity to react to
  - Remove most uses of SharedPreferencesRepository from MainActivity
  - Show messages about errors that occur when logging in

- Refactor intent routing in MainActivity to make the logic clearer.

- Add new `core.data` types to push more `core.network` types out of the
UI code
  - `core.data.model.MastodonList` for `core.network.model.MastoList`
  - `core.data.model.Server` for `core.network.model.Server`

- Continue the work to send the Pachli account ID to the code that uses
it.
  - Most view models now get the account ID via assisted injection.
- QueuedMedia now includes the AccountEntity so it can operate with any
account. Modify the `uploadMedia` API call to include explicit
authentication details.

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
This commit is contained in:
Nik Clayton 2024-11-13 11:45:16 +01:00 committed by GitHub
parent 6c178b5e8b
commit 710e209e34
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
195 changed files with 8051 additions and 3444 deletions

View File

@ -712,7 +712,7 @@
errorLine2=" ^">
<location
file="src/main/res/values-nb-rNO/strings.xml"
line="629"
line="626"
column="43"/>
</issue>
@ -723,7 +723,7 @@
errorLine2=" ^">
<location
file="src/main/res/values-nb-rNO/strings.xml"
line="721"
line="718"
column="51"/>
</issue>
@ -734,7 +734,7 @@
errorLine2=" ^">
<location
file="src/main/res/values-nb-rNO/strings.xml"
line="745"
line="742"
column="68"/>
</issue>
@ -800,7 +800,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="444"
line="442"
column="5"/>
</issue>
@ -811,7 +811,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="617"
line="614"
column="5"/>
</issue>
@ -1383,7 +1383,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="410"
line="408"
column="13"/>
</issue>
@ -1394,7 +1394,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="413"
line="411"
column="13"/>
</issue>
@ -1405,7 +1405,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="414"
line="412"
column="13"/>
</issue>
@ -1416,7 +1416,7 @@
errorLine2=" ~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="415"
line="413"
column="13"/>
</issue>
@ -1427,7 +1427,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="417"
line="415"
column="13"/>
</issue>
@ -1438,7 +1438,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="429"
line="427"
column="13"/>
</issue>
@ -1449,7 +1449,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="430"
line="428"
column="13"/>
</issue>
@ -1460,7 +1460,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="482"
line="480"
column="13"/>
</issue>
@ -1471,7 +1471,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="483"
line="481"
column="13"/>
</issue>
@ -1482,7 +1482,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="494"
line="491"
column="13"/>
</issue>
@ -1493,7 +1493,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="495"
line="492"
column="13"/>
</issue>
@ -1504,7 +1504,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="496"
line="493"
column="13"/>
</issue>
@ -1515,7 +1515,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="499"
line="496"
column="13"/>
</issue>
@ -1526,7 +1526,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="537"
line="534"
column="13"/>
</issue>
@ -1537,7 +1537,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="579"
line="576"
column="13"/>
</issue>
@ -1548,7 +1548,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="585"
line="582"
column="13"/>
</issue>
@ -1559,7 +1559,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="609"
line="606"
column="13"/>
</issue>
@ -1570,7 +1570,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="635"
line="632"
column="13"/>
</issue>
@ -1687,7 +1687,7 @@
errorLine2=" ~~~~~~~~">
<location
file="src/main/res/layout/activity_compose.xml"
line="368"
line="369"
column="10"/>
</issue>
@ -2541,12 +2541,12 @@
<issue
id="ReportShortcutUsage"
message="Calling this method indicates use of dynamic shortcuts, but there are no calls to methods that track shortcut usage, such as `pushDynamicShortcut` or `reportShortcutUsed`. Calling these methods is recommended, as they track shortcut usage and allow launchers to adjust which shortcuts appear based on activation history. Please see https://developer.android.com/develop/ui/views/launch/shortcuts/managing-shortcuts#track-usage"
errorLine1=" ShortcutManagerCompat.setDynamicShortcuts(context, shortcuts)"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
errorLine1=" ShortcutManagerCompat.setDynamicShortcuts(context, shortcuts)"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/app/pachli/util/ShareShortcutHelper.kt"
line="96"
column="5"/>
file="src/main/java/app/pachli/util/UpdateShortCutsUseCase.kt"
line="106"
column="9"/>
</issue>
<issue

View File

@ -39,6 +39,7 @@
# keep members of our model classes, they are used in json de/serialization
-keepclassmembers class app.pachli.core.network.model.** { *; }
-keepclassmembers class app.pachli.core.model.** { *; }
-keep public enum app.pachli.core.network.model.*$** {
**[] $VALUES;

File diff suppressed because it is too large Load Diff

View File

@ -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 <http://www.gnu.org/licenses>.
*/
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<out String>? = 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<AccountEntity>,
val canSchedulePost: Boolean,
val tabAlignment: TabAlignment,
val tabContents: TabContents,
) {
companion object {
fun make(prefs: SharedPreferencesRepository, accounts: List<AccountEntity>, 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<Long?>(null)
val pachliAccountFlow = pachliAccountIdFlow.filterNotNull().flatMapLatest { accountId ->
accountManager.getPachliAccountFlow(accountId)
.filterNotNull()
}.shareIn(viewModelScope, SharingStarted.WhileSubscribed(5000), replay = 1)
private val uiAction = MutableSharedFlow<UiAction>()
val accept: (UiAction) -> Unit = { action -> viewModelScope.launch { uiAction.emit(action) } }
private val _uiResult = Channel<Result<UiSuccess, UiError>>()
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<UiSuccess.SetActiveAccount, UiError.SetActiveAccount> {
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<UiSuccess.RefreshAccount, UiError.RefreshAccount> {
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)
}
}

View File

@ -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<MastoList>(this, android.R.layout.simple_list_item_1) {
val adapter = object : ArrayAdapter<MastodonList>(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 })
}
}
}

View File

@ -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,

View File

@ -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))
}
}

View File

@ -584,6 +584,7 @@ abstract class StatusBaseViewHolder<T : IStatusViewData> protected constructor(
}
protected fun setupButtons(
pachliAccountId: Long,
viewData: T,
listener: StatusActionListener<T>,
accountId: String,
@ -593,7 +594,7 @@ abstract class StatusBaseViewHolder<T : IStatusViewData> 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<T : IStatusViewData> protected constructor(
listener,
)
setupButtons(
pachliAccountId,
viewData,
listener,
actionable.account.id,

View File

@ -90,8 +90,7 @@ open class StatusViewHolder<T : IStatusViewData>(
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()
}

View File

@ -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<Timeline>) : 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

View File

@ -968,7 +968,7 @@ class AccountActivity :
kind = ComposeOptions.ComposeKind.NEW,
)
}
val intent = ComposeActivityIntent(this, options)
val intent = ComposeActivityIntent(this, intent.pachliAccountId, options)
startActivity(intent)
}
}

View File

@ -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<Resource<List<Announcement>>>()
@ -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)) }
}
}

View File

@ -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<ComposeViewModel.Factory> { 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,

View File

@ -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<Boolean> =
MutableStateFlow(accountManager.activeAccount?.defaultMediaSensitivity ?: false)
val markMediaAsSensitive = _markMediaAsSensitive.asStateFlow()
private val _markMediaAsSensitive: MutableStateFlow<Boolean?> = MutableStateFlow(composeOptions?.sensitive)
val markMediaAsSensitive = accountFlow.combine(_markMediaAsSensitive) { account, sens ->
sens ?: account.entity.defaultMediaSensitivity
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
private val _statusVisibility: MutableStateFlow<Status.Visibility> = MutableStateFlow(Status.Visibility.UNKNOWN)
val statusVisibility = _statusVisibility.asStateFlow()
@ -140,7 +158,7 @@ class ComposeViewModel @Inject constructor(
val showContentWarning = _showContentWarning.asStateFlow()
private val _poll: MutableStateFlow<NewPoll?> = MutableStateFlow(null)
val poll = _poll.asStateFlow()
private val _scheduledAt: MutableStateFlow<Date?> = MutableStateFlow(null)
private val _scheduledAt: MutableStateFlow<Date?> = MutableStateFlow(composeOptions?.scheduledAt)
val scheduledAt = _scheduledAt.asStateFlow()
private val _media: MutableStateFlow<List<QueuedMedia>> = 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
}
}

View File

@ -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) {

View File

@ -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))

View File

@ -83,6 +83,7 @@ class ConversationViewHolder internal constructor(
hideSensitiveMediaWarning()
}
setupButtons(
pachliAccountId,
viewData,
listener,
account.id,

View File

@ -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<Int>) {

View File

@ -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) {

View File

@ -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<ContentFiltersViewModel.Factory> { 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) {

View File

@ -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<ContentFilter>, val loadingState: LoadingState)
private val operationCounter = OperationCounter()
val operationCount = operationCounter.count
val state: Flow<State> 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
}
}

View File

@ -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<EditContentFilterViewModel.Factory> { factory ->
factory.create(
intent.pachliAccountId,
EditContentFilterActivityIntent.getContentFilter(intent),
EditContentFilterActivityIntent.getContentFilterId(intent),
)

View File

@ -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<ContentFilter, UiError> {
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<ContentFilter, UiError> {
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 {

View File

@ -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<Notification> {
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"),
)

View File

@ -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<Notification.Type>(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) {

View File

@ -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<NotificationsViewModel.Factory> { 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<Long>()
// 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")
}

View File

@ -112,8 +112,6 @@ interface NotificationActionListener {
class NotificationsPagingAdapter(
diffCallback: DiffUtil.ItemCallback<NotificationViewData>,
private val pachliAccountId: Long,
/** ID of the the account that notifications are being displayed for */
private val accountId: String,
private val statusActionListener: StatusActionListener<NotificationViewData>,
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 -> {

View File

@ -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<Notification.Type>) : InfallibleUiAction
data class ApplyFilter(
val pachliAccountId: Long,
val filter: Set<Notification.Type>,
) : 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<UiState>
@ -362,23 +370,17 @@ class NotificationsViewModel @Inject constructor(
init {
// Handle changes to notification filters
val notificationFilter = uiAction
.filterIsInstance<InfallibleUiAction.ApplyFilter>()
.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<InfallibleUiAction.ApplyFilter>()
.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<InfallibleUiAction.LoadNewest>()
.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<Notification.Type>,
initialKey: String? = null,
): Flow<PagingData<NotificationViewData>> {
@ -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
}
}

View File

@ -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
}

View File

@ -29,7 +29,6 @@ import app.pachli.viewdata.NotificationViewData
internal class StatusViewHolder(
binding: ItemStatusBinding,
private val statusActionListener: StatusActionListener<NotificationViewData>,
private val accountId: String,
) : NotificationsPagingAdapter.ViewHolder, StatusViewHolder<NotificationViewData>(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<NotificationViewData>,
private val accountId: String,
) : NotificationsPagingAdapter.ViewHolder, FilterableStatusViewHolder<NotificationViewData>(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()
}

View File

@ -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")

View File

@ -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)

View File

@ -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

View File

@ -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,

View File

@ -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

View File

@ -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<Loadable.Loaded<AccountEntity?>>()
.map { getLocaleList(getInitialLanguages(activeAccount = it.data)) }
.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(5000),
getLocaleList(getInitialLanguages()),
)
val server = serverRepository.flow.stateIn(
viewModelScope,

View File

@ -92,8 +92,8 @@ class SearchStatusesFragment : SearchFragment<StatusViewData>(), 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<StatusViewData>(), 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<StatusViewData>(), StatusActionLis
val intent = ComposeActivityIntent(
requireContext(),
pachliAccountId,
ComposeOptions(
inReplyToId = status.actionableId,
replyVisibility = actionableStatus.visibility,
@ -321,11 +322,11 @@ class SearchStatusesFragment : SearchFragment<StatusViewData>(), 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<StatusViewData>(), 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<StatusViewData>(), StatusActionLis
val intent = ComposeActivityIntent(
requireContext(),
pachliAccountId,
ComposeOptions(
content = redraftStatus.text.orEmpty(),
inReplyToId = redraftStatus.inReplyToId,
@ -458,7 +460,7 @@ class SearchStatusesFragment : SearchFragment<StatusViewData>(), 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<StatusViewData>(), StatusActionLis
poll = status.poll?.toNewPoll(status.createdAt),
kind = ComposeOptions.ComposeKind.EDIT_POSTED,
)
startActivity(ComposeActivityIntent(requireContext(), composeOptions))
startActivity(ComposeActivityIntent(requireContext(), pachliAccountId, composeOptions))
},
{
Snackbar.make(

View File

@ -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<Int, TimelineStatusWithAccount>? = 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<PagingData<TimelineStatusWithAccount>> {
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] */

View File

@ -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<String, Status>? = 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,

View File

@ -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) {

View File

@ -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<PagingData<StatusViewData>>
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<PagingData<StatusViewData>> {
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 {

View File

@ -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<String, Status>,
private val pageCache: PageCache,
private val timeline: Timeline,
) : RemoteMediator<String, Status>() {
private val activeAccount = accountManager.activeAccount!!
override suspend fun load(loadType: LoadType, state: PagingState<String, Status>): MediatorResult {
if (!activeAccount.isLoggedIn()) {
return MediatorResult.Success(endOfPaginationReached = true)

View File

@ -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<PagingData<StatusViewData>>
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<PagingData<StatusViewData>> {
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(

View File

@ -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<Loadable.Loaded<AccountEntity?>>()
.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<InfallibleUiAction.SaveVisibleId>()
.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<InfallibleUiAction.LoadNewest>()
.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)

View File

@ -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()

View File

@ -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<TrendingTagsUiState> 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()

View File

@ -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) {

View File

@ -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<ThreadUiState> = MutableStateFlow(ThreadUiState.Loading)
val uiState: Flow<ThreadUiState>
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<Loadable.Loaded<AccountEntity?>>()
.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,
)

View File

@ -170,7 +170,7 @@ abstract class SFragment<T : IStatusViewData> : 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<T : IStatusViewData> : 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<T : IStatusViewData> : 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<T : IStatusViewData> : Fragment(), StatusActionListener
poll = status.poll?.toNewPoll(status.createdAt),
kind = ComposeOptions.ComposeKind.EDIT_POSTED,
)
startActivity(ComposeActivityIntent(requireContext(), composeOptions))
startActivity(ComposeActivityIntent(requireContext(), pachliAccountId, composeOptions))
},
{
Snackbar.make(

View File

@ -24,7 +24,7 @@ import app.pachli.core.ui.LinkListener
import app.pachli.viewdata.IStatusViewData
interface StatusActionListener<T : IStatusViewData> : 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)

View File

@ -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)

View File

@ -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)

View File

@ -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,

View File

@ -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<AccountEntity?, LogoutError> {
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
}
}

View File

@ -133,7 +133,7 @@ class ListStatusAccessibilityDelegate<T : IStatusViewData>(
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)

View File

@ -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 <http://www.gnu.org/licenses>.
*/
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()))
}

View File

@ -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 <http://www.gnu.org/licenses>.
*/
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<AccountEntity>) = 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)
}
}

View File

@ -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,
)
}

View File

@ -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))
},

View File

@ -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()

View File

@ -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">
<androidx.emoji2.widget.EmojiEditText
android:id="@+id/composeContentWarningField"

View File

@ -10,6 +10,14 @@
android:id="@+id/includedToolbar"
layout="@layout/toolbar_basic" />
<com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/progressIndicator"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:visibility="gone"
android:indeterminate="true"
android:contentDescription="" />
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/swipeRefreshLayout"
android:layout_marginTop="?attr/actionBarSize"
@ -35,12 +43,6 @@
</FrameLayout>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center" />
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/addFilterButton"
android:layout_width="wrap_content"

View File

@ -605,7 +605,6 @@
<string name="pref_title_font_family">عائلة الخطوط</string>
<string name="notification_listenable_worker_description">إشعارات عندما يعمل Pachli (باكلي) في الخلفية</string>
<string name="status_filtered_show_anyway">إظهار على أي حال</string>
<string name="error_list_load">خطأ في تحميل القوائم</string>
<string name="action_translate">ترجمة</string>
<string name="select_list_empty">ليس لديك قوائم بعد</string>
<string name="pref_title_account_filter_keywords">الملفات التعريفية</string>
@ -625,4 +624,4 @@
<string name="pref_summary_content_filters">خادمك الخاص لا يدعم عوامل التصفية</string>
<string name="load_newest_statuses">تحميل أحدث المنشورات</string>
<string name="announcement_date_updated">(%1$s :تم تحديثه)</string>
</resources>
</resources>

View File

@ -543,7 +543,6 @@
<string name="post_media_image">Відарыс</string>
<string name="select_list_empty">Яшчэ няма спісаў</string>
<string name="select_list_manage">Кіраванне спісамі</string>
<string name="error_list_load">Памылка загрузкі спісаў</string>
<string name="total_usage">Усяго выкарыстана</string>
<string name="total_accounts">Усяго ўліковых запісаў</string>
<string name="accessibility_talking_about_tag">%1$d людзей кажуць пра хэштэг %2$s</string>
@ -565,4 +564,4 @@
<string name="filter_keyword_display_format">%s (цэлае слова)</string>
<string name="filter_keyword_addition_title">Дадаць ключавое слова</string>
<string name="filter_edit_keyword_title">Змяніць ключавое слова</string>
</resources>
</resources>

View File

@ -599,7 +599,6 @@
<string name="post_media_image">Delwedd</string>
<string name="select_list_empty">Nid oes gennych restrau, eto</string>
<string name="select_list_manage">Rheoli rhestrau</string>
<string name="error_list_load">Gwall wrth lwytho rhestrau</string>
<string name="help_empty_home">Hwn yw\'ch <b>ffrwd cartref</b>. 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.</string>
<string name="pref_title_show_stat_inline">Dangos ystadegau negeseuon mewn llinell amser</string>
<string name="pref_ui_text_size">Maint testun rhyngwyneb</string>
@ -615,4 +614,4 @@
<string name="error_media_playback">Methodd chwarae: %s</string>
<string name="dialog_delete_filter_positive_action">Dileu</string>
<string name="dialog_delete_filter_text">Dileu\'r hidlydd \'%1$s\'\?</string>
</resources>
</resources>

View File

@ -357,7 +357,6 @@
<string name="select_list_title">Liste auswählen</string>
<string name="select_list_empty">Du hast noch keine Listen</string>
<string name="select_list_manage">Listen verwalten</string>
<string name="error_list_load">Fehler beim Laden der Listen</string>
<string name="list">Liste</string>
<string name="no_drafts">Du hast keine Entwürfe.</string>
<string name="no_scheduled_posts">Du hast keine geplanten Beiträge.</string>
@ -602,4 +601,4 @@
<string name="reaction_name_and_count">%1$s %2$d</string>
<string name="announcement_date">%1$s %2$s</string>
<string name="announcement_date_updated">(Aktualisiert: %1$s)</string>
</resources>
</resources>

View File

@ -545,7 +545,6 @@
<string name="help_empty_home">Esta es tu <b> cronología de inicio</b>. 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.</string>
<string name="ui_error_bookmark_fmt">Fallo al añadir a marcadores: %1$s</string>
<string name="select_list_manage">Gestionar listas</string>
<string name="error_list_load">Error al cargar listas</string>
<string name="ui_error_favourite_fmt">Fallo al favoritear publicación: %1$s</string>
<string name="ui_error_clear_notifications">Fallo al limpiar notificaciones: %s</string>
<string name="ui_error_accept_follow_request">Fallo al aceptar solicitud de seguimiento: %s</string>
@ -635,8 +634,6 @@
<string name="notification_severed_relationships_description">Notificaciones de relaciones rotas</string>
<string name="poll_show_votes">Mostrar votos</string>
<string name="manage_lists">Gestionar listas</string>
<string name="title_lists_loading">Listas - cargando…</string>
<string name="title_lists_failed">Listas - falló en cargar</string>
<string name="error_filter_missing_keyword">Por lo menos se necesita una palabra clave o frase</string>
<string name="error_filter_missing_context">Por lo menos se necesita un contexto para el filtro</string>
<string name="error_filter_missing_title">Se necesita el título</string>
@ -789,4 +786,4 @@
<string name="conversation_0_recipients">Ningún participante más</string>
<string name="pref_title_confirm_status_language">Revisar idioma de la publicación antes de publicar</string>
<string name="compose_warn_language_dialog_accept_and_dont_ask_fmt">Publicar como está (%1$s) y no volver a preguntar</string>
</resources>
</resources>

View File

@ -557,7 +557,6 @@
<string name="post_media_image">تصویر</string>
<string name="select_list_empty">هنوز هیچ سیاهه‌ای ندارید</string>
<string name="select_list_manage">مدیریت سیاهه‌ها</string>
<string name="error_list_load">خطا در بار کردن سیاهه‌ها</string>
<string name="load_newest_notifications">بار کردن جدیدترین آگاهی‌ها</string>
<string name="compose_delete_draft">حذف پیش‌نویس؟</string>
<string name="error_missing_edits">کارسازتان می‌داند که این فرسته ویرایش شده؛ ولی رونوشتی از ویرایش‌ها ندارد. پس نمی‌توانند نشانتان داده شوند.
@ -571,4 +570,4 @@
<string name="error_media_playback">پخش شکست خورد: %s</string>
<string name="dialog_delete_filter_positive_action">حذف</string>
<string name="dialog_delete_filter_text">«%1$s» حذف پالایهٔ ؟</string>
</resources>
</resources>

View File

@ -568,7 +568,6 @@
<string name="select_list_title">Valitse lista</string>
<string name="duration_indefinite">Loputon</string>
<string name="failed_search">Haku epäonnistui</string>
<string name="error_list_load">Listojen lataaminen epäonnistui</string>
<string name="report_sent_success">Raportti käyttäjästä @%s lähetetty</string>
<string name="compose_shortcut_long_label">Kirjoita julkaisu</string>
<plurals name="poll_info_people">
@ -601,8 +600,6 @@
<string name="pref_update_next_scheduled_check">Seuraava ajoitettu tarkistus: %1$s</string>
<string name="pref_update_check_no_updates">Ei päivityksiä tarjolla</string>
<string name="error_media_download">Lataaminen epäonnistui %1$s: %2$d%3$s</string>
<string name="title_lists_loading">Listat - ladataan…</string>
<string name="title_lists_failed">Listat - lataaminen epäonnistui</string>
<string name="manage_lists">Hallinnoi listoja</string>
<string name="poll_show_votes">Näytä äänet</string>
<string name="notification_severed_relationships_name">Katkaistut yhteydet</string>
@ -774,4 +771,4 @@
<string name="pref_title_confirm_status_language">Tarkasta julkaisun kieli ennen julkaisemista</string>
<string name="action_copy_item">Kopioi kohde</string>
<string name="item_copied">Teksti kopioitu</string>
</resources>
</resources>

View File

@ -548,7 +548,6 @@
<string name="description_post_edited">Modifié</string>
<string name="select_list_empty">Vous n\'avez pas encore de liste</string>
<string name="select_list_manage">Gérer les listes</string>
<string name="error_list_load">Erreur de chargement des listes</string>
<string name="report_category_violation">Règle enfreinte</string>
<string name="error_status_source_load">Le texte d\'origine du statut n\'a pas pu être chargé.</string>
<string name="ui_error_clear_notifications">Échec du nettoyage des notifications : %s</string>
@ -606,4 +605,4 @@
<string name="notification_notification_worker">Récupération des notifications </string>
<string name="notification_prune_cache">Maintenance du cache </string>
<string name="announcement_date">%1$s %2$s</string>
</resources>
</resources>

View File

@ -551,7 +551,6 @@
<string name="ui_success_rejected_follow_request">Chaidh iarrtas leantainn a bhacadh</string>
<string name="select_list_manage">Stiùirich na liostaichean</string>
<string name="select_list_empty">Chan eil liosta agad fhathast</string>
<string name="error_list_load">Mearachd a luchdadh nan liostaichean</string>
<string name="status_filtered_show_anyway">Seall e co-dhiù</string>
<string name="status_filter_placeholder_label_format">Criathraichte: &lt;b&gt;%1$s&lt;/b&gt;</string>
<string name="pref_title_account_filter_keywords">Pròifilean</string>
@ -583,4 +582,4 @@
<string name="notification_listenable_worker_description">Brathan nuair a bhios Pachli ag obair sa chùlaibh</string>
<string name="notification_notification_worker">A faighinn nam brathan…</string>
<string name="notification_prune_cache">Obair-ghlèidhidh air an tasgadan…</string>
</resources>
</resources>

View File

@ -550,7 +550,6 @@
<string name="filter_edit_keyword_title">Editar palabra</string>
<string name="select_list_empty">Aínda non tes listas</string>
<string name="select_list_manage">Xestionar listas</string>
<string name="error_list_load">Erro ao cargar as listas</string>
<string name="error_missing_edits">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 <a href="https://github.com/mastodon/mastodon/issues/25398">problema coñecido</a> en Mastodon.</string>
@ -587,8 +586,6 @@
<string name="title_tab_public_trending_hashtags">Cancelos</string>
<string name="title_tab_public_trending_links">Ligazóns</string>
<string name="title_tab_public_trending_statuses">Publicacións</string>
<string name="title_lists_loading">Listas - cargando…</string>
<string name="title_lists_failed">Listas - fallou a carga</string>
<string name="manage_lists">Xestionar listas</string>
<string name="announcement_date_updated">(Actualizada: %1$s)</string>
<string name="pref_summary_content_filters">O servidor non é compatible cos filtros</string>
@ -768,4 +765,4 @@
<string name="pref_notification_fetch_ok_timestamp_fmt">✔ hai %1$s @ %2$s</string>
<string name="pref_notification_fetch_err_timestamp_fmt">✖ hai %1$s @ %2$s</string>
<string name="pref_notification_fetch_needs_push">A conta non ten o método «push». Pechar a sesión e volver acceder podería arranxalo.</string>
</resources>
</resources>

View File

@ -557,7 +557,6 @@
<string name="post_media_image">Kép</string>
<string name="select_list_empty">Még nincsenek listáid</string>
<string name="select_list_manage">Listák kezelése</string>
<string name="error_list_load">Hiba a listák betöltése során</string>
<string name="pref_ui_text_size">UI betűméret</string>
<string name="notification_listenable_worker_name">Háttértevékenység</string>
<string name="notification_listenable_worker_description">Értesítések, amikor a Pachli a háttérben működik</string>
@ -568,4 +567,4 @@
<string name="error_missing_edits">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 <a href="https://github.com/mastodon/mastodon/issues/25398">Mastodon hiba #25398</a>.</string>
</resources>
</resources>

View File

@ -549,7 +549,6 @@
<string name="post_media_image">Mynd</string>
<string name="select_list_empty">Þú ert ekki með neina lista</string>
<string name="select_list_manage">Sýsla með lista</string>
<string name="error_list_load">Villa við að hlaða inn listum</string>
<string name="help_empty_home">Þetta er <b>tímalínan þín</b>. 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.</string>
<string name="pref_ui_text_size">Textastærð viðmóts</string>
<string name="notification_listenable_worker_name">Bakgrunnsvirkni</string>
@ -561,4 +560,4 @@
<string name="error_missing_edits">Þ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 <a href="https://github.com/mastodon/mastodon/issues/25398">Mastodon verkbeiðni #25398</a>.</string>
</resources>
</resources>

View File

@ -573,7 +573,6 @@
<string name="ui_success_rejected_follow_request">Richiesta di follow bloccata</string>
<string name="select_list_empty">Non hai ancora alcuna lista</string>
<string name="select_list_manage">Gestisci liste</string>
<string name="error_list_load">Errore nel caricamento delle liste</string>
<string name="status_filtered_show_anyway">Mostra comunque</string>
<string name="status_filter_placeholder_label_format">Filtrato: &lt;b&gt;%1$s&lt;/b&gt;</string>
<string name="pref_title_account_filter_keywords">Profili</string>
@ -610,4 +609,4 @@
<string name="title_tab_public_trending_statuses">Post</string>
<string name="pref_update_notification_frequency_once_per_version">Una volta per versione</string>
<string name="update_dialog_title">Un aggiornamento è disponibile</string>
</resources>
</resources>

View File

@ -550,7 +550,6 @@
<string name="filter_description_format">%s: %s</string>
<string name="load_newest_notifications">最新の通知を読み込む</string>
<string name="compose_delete_draft">下書きを削除しますか?</string>
<string name="error_list_load">リストを読み込む際のエラー</string>
<string name="error_missing_edits">あなたのサーバーは、この投稿が変更されたことを把握していますが、編集履歴のコピーを備えていないので、表示できません。
\n
\nこれは<a href="https://github.com/mastodon/mastodon/issues/25398">Mastodonのissue #25398</a>です。</string>
@ -594,4 +593,4 @@
<string name="action_translate">翻訳</string>
<string name="update_dialog_title">アップデート可能です</string>
<string name="action_translate_undo">翻訳を元に戻す</string>
</resources>
</resources>

View File

@ -530,7 +530,6 @@
<string name="action_continue_edit">Fortsett å redigere</string>
<string name="unsaved_changes">Du har ulagrede endringer.</string>
<string name="select_list_empty">Du har ingen lister, enda</string>
<string name="error_list_load">Feil under lading av lister</string>
<string name="select_list_manage">Forvalte lister</string>
<string name="ui_error_reblog_fmt">Deling av innlegget feilet: %1$s</string>
<string name="ui_error_favourite_fmt">Favorisering av innlegg feilet: %1$s</string>
@ -572,14 +571,12 @@
<string name="action_manage_tabs">Håndter faner</string>
<string name="action_suggestions">Foreslåtte kontoer</string>
<string name="confirmation_hashtag_muted">#%s skjult</string>
<string name="title_lists_failed">Lister - kunne ikke lastes inn</string>
<string name="manage_lists">Håndter lister</string>
<string name="compose_schedule_date_time_fmt">%1$s %2$s</string>
<string name="title_public_trending_links">Trendende lenker</string>
<string name="title_tab_public_trending_hashtags">Emneknagger</string>
<string name="title_tab_public_trending_links">Lenker</string>
<string name="notification_severed_relationships_domain_block_body">En moderator suspenderte instansen</string>
<string name="title_lists_loading">Lister - laster inn…</string>
<string name="poll_show_votes">Vis stemminger</string>
<string name="compose_warn_language_dialog_title">Kontroller språket til innlegget</string>
<string name="compose_warn_language_dialog_fmt">Språket til innlegget er %1$s men det kan skje at du har skrevet innlegget på %2$s.</string>
@ -770,4 +767,4 @@
<string name="search_operator_where_dialog_all">Alle innlegg</string>
<string name="search_operator_where_dialog_library_hint">Dine egne innlegg, fremhevinger, favoritmerker, bokmerker, og innlegg som @nevner deg</string>
<string name="search_operator_where_dialog_public">Fødererte innlegg</string>
</resources>
</resources>

View File

@ -522,7 +522,6 @@
<string name="notification_unknown_name">Onbekend</string>
<string name="ui_error_clear_notifications">Wissen meldingen mislukt: %s</string>
<string name="ui_success_accepted_follow_request">Volgverzoek geaccepteerd</string>
<string name="error_list_load">Fout bij laden lijsten</string>
<string name="select_list_empty">Je hebt nog geen lijsten</string>
<string name="select_list_manage">Lijsten beheren</string>
<string name="pref_title_account_filter_keywords">Profielen</string>
@ -607,4 +606,4 @@
<string name="pref_update_check_no_updates">Er zijn geen updates beschikbaar</string>
<string name="pref_update_next_scheduled_check">Volgende geplande controle: %1$s</string>
<string name="pref_summary_content_filters">Je server ondersteund geen filters</string>
</resources>
</resources>

View File

@ -550,7 +550,6 @@
<string name="post_media_image">Imatge</string>
<string name="select_list_empty">Avètz pas encara de lista</string>
<string name="select_list_manage">Gerir las listas</string>
<string name="error_list_load">Error en cargant las litas</string>
<string name="ui_error_favourite_fmt">Fracàs de la mes en favorit: %1$s</string>
<string name="ui_error_reblog_fmt">Fracàs en partejant: %1$s</string>
<string name="ui_error_vote_fmt">Fracàs del vòt: %1$s</string>
@ -568,4 +567,4 @@
<string name="notification_listenable_worker_description">Notificacions quand Tuska sexecuta en rèireplan</string>
<string name="notification_notification_worker">Recuperacion de las notificacions…</string>
<string name="notification_prune_cache">Manteniment del cache…</string>
</resources>
</resources>

View File

@ -587,7 +587,6 @@
<string name="notification_update_description">Notificações quando Toots com os quais interagiu são editados</string>
<string name="notification_listenable_worker_description">Notificações quando Pachli está trabalhando em segundo plano</string>
<string name="status_filtered_show_anyway">Mostrar mesmo assim</string>
<string name="error_list_load">Erro ao carregar as listas</string>
<string name="ui_error_accept_follow_request">Falha ao aceitar a solicitação de seguir: %s</string>
<string name="ui_success_rejected_follow_request">Pedido para seguir bloqueado</string>
<string name="notification_sign_up_format">%s se inscreveu</string>
@ -613,4 +612,4 @@
<string name="load_newest_notifications">Carregar notificações mais recentes</string>
<string name="action_discard">Descartar mudanças</string>
<string name="pref_ui_text_size">Tamanho do texto da UI</string>
</resources>
</resources>

View File

@ -565,7 +565,6 @@
<string name="ui_error_bookmark_fmt">Att bokmärka inlägg misslyckades: %1$s</string>
<string name="ui_error_clear_notifications">Rensing av aviseringar misslyckades: %s</string>
<string name="ui_error_favourite_fmt">Att favoritmarkera inlägg misslyckades: %1$s</string>
<string name="error_list_load">Fel vid laddning av listor</string>
<string name="ui_error_reject_follow_request">Avacceptera följarförgrågan misslyckades: %s</string>
<string name="label_filter_context">Filtrera sammanhang</string>
<string name="title_public_trending_hashtags">Populära hashtaggar</string>
@ -610,4 +609,4 @@
<string name="action_translate_undo">Ångra översättning</string>
<string name="server_repository_error">Kunde inte hämta serverinformation för %1$s: %2$s</string>
<string name="pref_summary_content_filters">Din server stöder inte filter</string>
</resources>
</resources>

View File

@ -533,7 +533,6 @@
<string name="description_browser_login">Ek kimlik doğrulama yöntemlerini destekleyebilir ancak desteklenen bir tarayıcı gerektirir.</string>
<string name="select_list_empty">Henüz hiç listen yok</string>
<string name="select_list_manage">Listeleri yönet</string>
<string name="error_list_load">Listeler yüklenirken hata oluştu</string>
<string name="action_share_account_link">Hesaba bağlantı paylaş</string>
<string name="action_share_account_username">Hesap adını paylaş</string>
<string name="account_username_copied">Kullanıcı adı kopyalandı</string>
@ -569,4 +568,4 @@
\n
\nBu <a href="https://github.com/mastodon/mastodon/issues/25398">Mastodon sorununu #25398</a>.</string>
<string name="error_media_playback">Oynatma başarısız oldu: %s</string>
</resources>
</resources>

View File

@ -570,7 +570,6 @@
<string name="help_empty_home">Це ваша <b>головна стрічка</b>. Вона показує останні дописи облікових записів, за якими ви стежите. \n \nЩоб переглянути облікові записи, ви можете знайти їх в одній з інших стрічок. Наприклад, на локальній стрічці вашого сервера [iconics gmd_group]. Або ви можете шукати їх за іменами [iconics gmd_search]; наприклад, шукайте Pachli, щоб знайти наш обліковий запис Mastodon.</string>
<string name="hint_description">Опис</string>
<string name="post_media_image">Зображення</string>
<string name="error_list_load">Помилка завантаження списків</string>
<string name="select_list_empty">У вас ще немає списків</string>
<string name="select_list_manage">Керувати списками</string>
<string name="pref_ui_text_size">Розмір шрифту інтерфейсу</string>
@ -583,4 +582,4 @@
<string name="error_missing_edits">Ваш сервер знає, що цей допис було змінено, але не має копії редагувань, тому вони не можуть бути вам показані.
\n
\nЦе <a href="https://github.com/mastodon/mastodon/issues/25398">помилка #25398 у Mastodon</a>.</string>
</resources>
</resources>

View File

@ -539,7 +539,6 @@
<string name="hint_description">Mô tả</string>
<string name="post_media_image">Hình ảnh</string>
<string name="select_list_manage">Quản lý danh sách</string>
<string name="error_list_load">Xảy ra lỗi khi tải danh sách</string>
<string name="select_list_empty">Bạn chưa có danh sách</string>
<string name="load_newest_notifications">Tải những thông báo mới nhất</string>
<string name="compose_delete_draft">Xóa bản nháp\?</string>
@ -554,4 +553,4 @@
<string name="error_media_playback">Không thể phát: %s</string>
<string name="dialog_delete_filter_text">Xóa bộ lọc \'%1$s\'\?</string>
<string name="dialog_delete_filter_positive_action">Xóa</string>
</resources>
</resources>

View File

@ -553,7 +553,6 @@
<string name="post_media_image">图片</string>
<string name="hint_description">描述</string>
<string name="select_list_manage">管理列表</string>
<string name="error_list_load">加载列表出错</string>
<string name="select_list_empty">你还没有列表</string>
<string name="load_newest_notifications">加载最新通知</string>
<string name="compose_delete_draft">删除草稿?</string>
@ -568,4 +567,4 @@
<string name="error_media_playback">播放失败了:%s</string>
<string name="dialog_delete_filter_text">删除筛选器\'%1$s\'吗?</string>
<string name="dialog_delete_filter_positive_action">删除</string>
</resources>
</resources>

View File

@ -379,8 +379,6 @@
<string name="filter_expiration_format">%s (%s)</string>
<string name="add_account_name">Add Account</string>
<string name="add_account_description">Add new Mastodon Account</string>
<string name="title_lists_loading">Lists - loading…</string>
<string name="title_lists_failed">Lists - failed to load</string>
<string name="manage_lists">Manage lists</string>
<string name="compose_active_account_description">Posting as %1$s</string>
<plurals name="hint_describe_for_visually_impaired">
@ -487,7 +485,6 @@
<string name="select_list_title">Select list</string>
<string name="select_list_empty">You have no lists, yet</string>
<string name="select_list_manage">Manage lists</string>
<string name="error_list_load">Error loading lists</string>
<string name="list">List</string>
<string name="notifications_clear">Delete notifications</string>
<string name="notifications_apply_filter">Filter notifications</string>
@ -854,4 +851,7 @@
<string name="upload_failed_msg_fmt">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</string>
<string name="upload_failed_modify_attachment">Modify attachment</string>
<string name="main_viewmodel_error_set_active_account">Trying to log in failed with the following error:\n\n%1$s</string>
<string name="main_viewmodel_error_refresh_account">Refreshing account failed with the following error:\n\n%1$s\n\nYou can continue, but your lists and filters may be incomplete.</string>
<string name="action_relogin">Re-login</string>
</resources>

View File

@ -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<ViewPager2>(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(

View File

@ -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<ComposeActivity>(
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<Long>()
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<EditText>(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<EditText>(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<EditText>(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<EditText>(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<EditText>(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<EditText>(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<EditText>(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<EditText>(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,
)

View File

@ -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<Long>()
@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<Result<ContentFilters?, ContentFiltersError.GetContentFiltersError>>(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,
)
}
}

View File

@ -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 {

View File

@ -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<AccountEntity>().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))
}
}

View File

@ -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(

View File

@ -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()

View File

@ -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(

View File

@ -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)
}
}

View File

@ -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<AccountEntity>().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")
}
}
}

View File

@ -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,

View File

@ -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<Loadable.Loaded<AccountEntity?>>()
.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")
}
}
}

View File

@ -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<String, Status>
@ -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<String>(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,

View File

@ -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,

Some files were not shown because too many files have changed in this diff Show More