Move all database queries off the ui thread & add a ViewModel for MainActivity (#4786)

- Move all database queries off the ui thread - this is a massive
performance improvement
- ViewModel for MainActivity - this makes MainActivity smaller and
network requests won't be retried when rotating the screen
- removes the Push Notification Migration feature. We had it long
enough, all users who want push notifications should be migrated by now
- AccountEntity is now immutable
- converted BaseActivity to Kotlin
- The header image of Accounts is now cached as well
This commit is contained in:
Konrad Pozniak 2025-01-17 12:35:35 +01:00 committed by GitHub
commit 9e597800c9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
47 changed files with 2421 additions and 1127 deletions

View file

@ -1,294 +0,0 @@
/* Copyright 2017 Andrew Dawson
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky;
import android.app.ActivityManager;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.content.res.Configuration;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.Color;
import android.os.Bundle;
import android.util.Log;
import android.view.MenuItem;
import android.view.View;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.StringRes;
import androidx.appcompat.app.AppCompatActivity;
import androidx.lifecycle.ViewModelProvider;
import com.google.android.material.color.MaterialColors;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import com.google.android.material.snackbar.Snackbar;
import com.keylesspalace.tusky.adapter.AccountSelectionAdapter;
import com.keylesspalace.tusky.components.login.LoginActivity;
import com.keylesspalace.tusky.db.entity.AccountEntity;
import com.keylesspalace.tusky.db.AccountManager;
import com.keylesspalace.tusky.di.PreferencesEntryPoint;
import com.keylesspalace.tusky.interfaces.AccountSelectionListener;
import com.keylesspalace.tusky.settings.AppTheme;
import com.keylesspalace.tusky.settings.PrefKeys;
import com.keylesspalace.tusky.util.ActivityConstants;
import com.keylesspalace.tusky.util.ActivityExtensions;
import com.keylesspalace.tusky.util.ThemeUtils;
import java.util.List;
import javax.inject.Inject;
import static com.keylesspalace.tusky.settings.PrefKeys.APP_THEME;
import dagger.hilt.EntryPoints;
/**
* All activities inheriting from BaseActivity must be annotated with @AndroidEntryPoint
*/
public abstract class BaseActivity extends AppCompatActivity {
public static final String OPEN_WITH_SLIDE_IN = "OPEN_WITH_SLIDE_IN";
private static final String TAG = "BaseActivity";
@Inject
@NonNull
public AccountManager accountManager;
@Inject
@NonNull
public SharedPreferences preferences;
/**
* Allows overriding the default ViewModelProvider.Factory for testing purposes.
*/
@Nullable
public ViewModelProvider.Factory viewModelProviderFactory = null;
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
if (activityTransitionWasRequested()) {
ActivityExtensions.overrideActivityTransitionCompat(
this,
ActivityConstants.OVERRIDE_TRANSITION_OPEN,
R.anim.activity_open_enter,
R.anim.activity_open_exit
);
ActivityExtensions.overrideActivityTransitionCompat(
this,
ActivityConstants.OVERRIDE_TRANSITION_CLOSE,
R.anim.activity_close_enter,
R.anim.activity_close_exit
);
}
/* There isn't presently a way to globally change the theme of a whole application at
* runtime, just individual activities. So, each activity has to set its theme before any
* views are created. */
String theme = preferences.getString(APP_THEME, AppTheme.DEFAULT.getValue());
Log.d("activeTheme", theme);
if (ThemeUtils.isBlack(getResources().getConfiguration(), theme)) {
setTheme(R.style.TuskyBlackTheme);
} else if (this instanceof MainActivity) {
// Replace the SplashTheme of MainActivity
setTheme(R.style.TuskyTheme);
}
/* set the taskdescription programmatically, the theme would turn it blue */
String appName = getString(R.string.app_name);
Bitmap appIcon = BitmapFactory.decodeResource(getResources(), R.mipmap.ic_launcher);
int recentsBackgroundColor = MaterialColors.getColor(this, com.google.android.material.R.attr.colorSurface, Color.BLACK);
setTaskDescription(new ActivityManager.TaskDescription(appName, appIcon, recentsBackgroundColor));
int style = textStyle(preferences.getString(PrefKeys.STATUS_TEXT_SIZE, "medium"));
getTheme().applyStyle(style, true);
if(requiresLogin()) {
redirectIfNotLoggedIn();
}
}
private boolean activityTransitionWasRequested() {
return getIntent().getBooleanExtra(OPEN_WITH_SLIDE_IN, false);
}
@Override
protected void attachBaseContext(Context newBase) {
// injected preferences not yet available at this point of the lifecycle
SharedPreferences preferences = EntryPoints.get(newBase.getApplicationContext(), PreferencesEntryPoint.class).preferences();
// Scale text in the UI from PrefKeys.UI_TEXT_SCALE_RATIO
float uiScaleRatio = preferences.getFloat(PrefKeys.UI_TEXT_SCALE_RATIO, 100F);
Configuration configuration = newBase.getResources().getConfiguration();
// Adjust `fontScale` in the configuration.
//
// You can't repeatedly adjust the `fontScale` in `newBase` because that will contain the
// result of previous adjustments. E.g., going from 100% to 80% to 100% does not return
// you to the original 100%, it leaves it at 80%.
//
// Instead, calculate the new scale from the application context. This is unaffected by
// changes to the base context. It does contain contain any changes to the font scale from
// "Settings > Display > Font size" in the device settings, so scaling performed here
// is in addition to any scaling in the device settings.
Configuration appConfiguration = newBase.getApplicationContext().getResources().getConfiguration();
// This only adjusts the fonts, anything measured in `dp` is unaffected by this.
// You can try to adjust `densityDpi` as shown in the commented out code below. This
// works, to a point. However, dialogs do not react well to this. Beyond a certain
// scale (~ 120%) the right hand edge of the dialog will clip off the right of the
// screen.
//
// So for now, just adjust the font scale
//
// val displayMetrics = appContext.resources.displayMetrics
// configuration.densityDpi = ((displayMetrics.densityDpi * uiScaleRatio).toInt())
configuration.fontScale = appConfiguration.fontScale * uiScaleRatio / 100F;
Context fontScaleContext = newBase.createConfigurationContext(configuration);
super.attachBaseContext(fontScaleContext);
}
@NonNull
@Override
public ViewModelProvider.Factory getDefaultViewModelProviderFactory() {
final ViewModelProvider.Factory factory = viewModelProviderFactory;
return (factory != null) ? factory : super.getDefaultViewModelProviderFactory();
}
protected boolean requiresLogin() {
return true;
}
private static int textStyle(String name) {
int style;
switch (name) {
case "smallest":
style = R.style.TextSizeSmallest;
break;
case "small":
style = R.style.TextSizeSmall;
break;
case "medium":
default:
style = R.style.TextSizeMedium;
break;
case "large":
style = R.style.TextSizeLarge;
break;
case "largest":
style = R.style.TextSizeLargest;
break;
}
return style;
}
@Override
public boolean onOptionsItemSelected(@NonNull MenuItem item) {
if (item.getItemId() == android.R.id.home) {
getOnBackPressedDispatcher().onBackPressed();
return true;
}
return super.onOptionsItemSelected(item);
}
protected void redirectIfNotLoggedIn() {
AccountEntity account = accountManager.getActiveAccount();
if (account == null) {
Intent intent = LoginActivity.getIntent(this, LoginActivity.MODE_DEFAULT);
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_NEW_TASK);
startActivity(intent);
finish();
}
}
protected void showErrorDialog(@Nullable View anyView, @StringRes int descriptionId, @StringRes int actionId, @Nullable View.OnClickListener listener) {
if (anyView != null) {
Snackbar bar = Snackbar.make(anyView, getString(descriptionId), Snackbar.LENGTH_SHORT);
bar.setAction(actionId, listener);
bar.show();
}
}
public void showAccountChooserDialog(@Nullable CharSequence dialogTitle, boolean showActiveAccount, @NonNull AccountSelectionListener listener) {
List<AccountEntity> accounts = accountManager.getAllAccountsOrderedByActive();
AccountEntity activeAccount = accountManager.getActiveAccount();
switch(accounts.size()) {
case 1:
listener.onAccountSelected(activeAccount);
return;
case 2:
if (!showActiveAccount) {
for (AccountEntity account : accounts) {
if (activeAccount != account) {
listener.onAccountSelected(account);
return;
}
}
}
break;
}
if (!showActiveAccount && activeAccount != null) {
accounts.remove(activeAccount);
}
AccountSelectionAdapter adapter = new AccountSelectionAdapter(
this,
preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false),
preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false)
);
adapter.addAll(accounts);
new MaterialAlertDialogBuilder(this)
.setTitle(dialogTitle)
.setAdapter(adapter, (dialogInterface, index) -> listener.onAccountSelected(accounts.get(index)))
.show();
}
public @Nullable String getOpenAsText() {
List<AccountEntity> accounts = accountManager.getAllAccountsOrderedByActive();
switch (accounts.size()) {
case 0:
case 1:
return null;
case 2:
for (AccountEntity account : accounts) {
if (account != accountManager.getActiveAccount()) {
return String.format(getString(R.string.action_open_as), account.getFullName());
}
}
return null;
default:
return String.format(getString(R.string.action_open_as), "");
}
}
public void openAsAccount(@NonNull String url, @NonNull AccountEntity account) {
accountManager.setActiveAccount(account.getId());
Intent intent = MainActivity.redirectIntent(this, account.getId(), url);
startActivity(intent);
finish();
}
}

View file

@ -0,0 +1,259 @@
/* Copyright 2024 Tusky Contributors
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky
import android.app.ActivityManager.TaskDescription
import android.content.Context
import android.content.DialogInterface
import android.content.Intent
import android.content.SharedPreferences
import android.graphics.BitmapFactory
import android.graphics.Color
import android.os.Bundle
import android.view.MenuItem
import androidx.annotation.StyleRes
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.ViewModelProvider.Factory
import androidx.lifecycle.lifecycleScope
import com.google.android.material.color.MaterialColors
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.keylesspalace.tusky.MainActivity.Companion.redirectIntent
import com.keylesspalace.tusky.adapter.AccountSelectionAdapter
import com.keylesspalace.tusky.components.login.LoginActivity
import com.keylesspalace.tusky.components.login.LoginActivity.Companion.getIntent
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.entity.AccountEntity
import com.keylesspalace.tusky.di.PreferencesEntryPoint
import com.keylesspalace.tusky.interfaces.AccountSelectionListener
import com.keylesspalace.tusky.settings.AppTheme
import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.util.ActivityConstants
import com.keylesspalace.tusky.util.isBlack
import com.keylesspalace.tusky.util.overrideActivityTransitionCompat
import dagger.hilt.EntryPoints
import javax.inject.Inject
import kotlinx.coroutines.launch
/**
* All activities inheriting from BaseActivity must be annotated with @AndroidEntryPoint
*/
abstract class BaseActivity : AppCompatActivity() {
@Inject
lateinit var accountManager: AccountManager
@Inject
lateinit var preferences: SharedPreferences
/**
* Allows overriding the default ViewModelProvider.Factory for testing purposes.
*/
var viewModelProviderFactory: Factory? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (activityTransitionWasRequested()) {
overrideActivityTransitionCompat(
ActivityConstants.OVERRIDE_TRANSITION_OPEN,
R.anim.activity_open_enter,
R.anim.activity_open_exit
)
overrideActivityTransitionCompat(
ActivityConstants.OVERRIDE_TRANSITION_CLOSE,
R.anim.activity_close_enter,
R.anim.activity_close_exit
)
}
/* There isn't presently a way to globally change the theme of a whole application at
* runtime, just individual activities. So, each activity has to set its theme before any
* views are created. */
val theme = preferences.getString(PrefKeys.APP_THEME, AppTheme.DEFAULT.value)
if (isBlack(resources.configuration, theme)) {
setTheme(R.style.TuskyBlackTheme)
} else if (this is MainActivity) {
// Replace the SplashTheme of MainActivity
setTheme(R.style.TuskyTheme)
}
/* set the taskdescription programmatically, the theme would turn it blue */
val appName = getString(R.string.app_name)
val appIcon = BitmapFactory.decodeResource(resources, R.mipmap.ic_launcher)
val recentsBackgroundColor = MaterialColors.getColor(
this,
com.google.android.material.R.attr.colorSurface,
Color.BLACK
)
setTaskDescription(TaskDescription(appName, appIcon, recentsBackgroundColor))
val style = textStyle(preferences.getString(PrefKeys.STATUS_TEXT_SIZE, "medium"))
getTheme().applyStyle(style, true)
if (requiresLogin()) {
redirectIfNotLoggedIn()
}
}
private fun activityTransitionWasRequested(): Boolean {
return intent.getBooleanExtra(OPEN_WITH_SLIDE_IN, false)
}
override fun attachBaseContext(newBase: Context) {
// injected preferences not yet available at this point of the lifecycle
val preferences =
EntryPoints.get(newBase.applicationContext, PreferencesEntryPoint::class.java)
.preferences()
// Scale text in the UI from PrefKeys.UI_TEXT_SCALE_RATIO
val uiScaleRatio = preferences.getFloat(PrefKeys.UI_TEXT_SCALE_RATIO, 100f)
val configuration = newBase.resources.configuration
// Adjust `fontScale` in the configuration.
//
// You can't repeatedly adjust the `fontScale` in `newBase` because that will contain the
// result of previous adjustments. E.g., going from 100% to 80% to 100% does not return
// you to the original 100%, it leaves it at 80%.
//
// Instead, calculate the new scale from the application context. This is unaffected by
// changes to the base context. It does contain contain any changes to the font scale from
// "Settings > Display > Font size" in the device settings, so scaling performed here
// is in addition to any scaling in the device settings.
val appConfiguration = newBase.applicationContext.resources.configuration
// This only adjusts the fonts, anything measured in `dp` is unaffected by this.
// You can try to adjust `densityDpi` as shown in the commented out code below. This
// works, to a point. However, dialogs do not react well to this. Beyond a certain
// scale (~ 120%) the right hand edge of the dialog will clip off the right of the
// screen.
//
// So for now, just adjust the font scale
//
// val displayMetrics = appContext.resources.displayMetrics
// configuration.densityDpi = ((displayMetrics.densityDpi * uiScaleRatio).toInt())
configuration.fontScale = appConfiguration.fontScale * uiScaleRatio / 100f
val fontScaleContext = newBase.createConfigurationContext(configuration)
super.attachBaseContext(fontScaleContext)
}
override val defaultViewModelProviderFactory: Factory
get() = viewModelProviderFactory ?: super.defaultViewModelProviderFactory
protected open fun requiresLogin(): Boolean = true
override fun onOptionsItemSelected(item: MenuItem): Boolean {
if (item.itemId == android.R.id.home) {
onBackPressedDispatcher.onBackPressed()
return true
}
return super.onOptionsItemSelected(item)
}
private fun redirectIfNotLoggedIn() {
val currentAccounts = accountManager.accounts
if (currentAccounts.isEmpty()) {
val intent = getIntent(this@BaseActivity, LoginActivity.MODE_DEFAULT)
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK)
startActivity(intent)
finish()
}
}
fun showAccountChooserDialog(
dialogTitle: CharSequence?,
showActiveAccount: Boolean,
listener: AccountSelectionListener
) {
val accounts = accountManager.accounts.toMutableList()
val activeAccount = accountManager.activeAccount
when (accounts.size) {
1 -> {
listener.onAccountSelected(activeAccount!!)
return
}
2 -> if (!showActiveAccount) {
for (account in accounts) {
if (activeAccount !== account) {
listener.onAccountSelected(account)
return
}
}
}
}
if (!showActiveAccount && activeAccount != null) {
accounts.remove(activeAccount)
}
val adapter = AccountSelectionAdapter(
this,
preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false),
preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false)
)
adapter.addAll(accounts)
MaterialAlertDialogBuilder(this)
.setTitle(dialogTitle)
.setAdapter(adapter) { _: DialogInterface?, index: Int ->
listener.onAccountSelected(accounts[index])
}
.show()
}
val openAsText: String?
get() {
val accounts = accountManager.accounts
when (accounts.size) {
0, 1 -> return null
2 -> {
for (account in accounts) {
if (account !== accountManager.activeAccount) {
return getString(R.string.action_open_as, account.fullName)
}
}
return null
}
else -> return getString(R.string.action_open_as, "")
}
}
fun openAsAccount(url: String, account: AccountEntity) {
lifecycleScope.launch {
accountManager.setActiveAccount(account.id)
val intent = redirectIntent(this@BaseActivity, account.id, url)
startActivity(intent)
finish()
}
}
companion object {
const val OPEN_WITH_SLIDE_IN = "OPEN_WITH_SLIDE_IN"
@StyleRes
private fun textStyle(name: String?): Int = when (name) {
"smallest" -> R.style.TextSizeSmallest
"small" -> R.style.TextSizeSmall
"medium" -> R.style.TextSizeMedium
"large" -> R.style.TextSizeLarge
"largest" -> R.style.TextSizeLargest
else -> R.style.TextSizeMedium
}
}
}

View file

@ -40,6 +40,7 @@ import android.view.MenuItem.SHOW_AS_ACTION_NEVER
import android.view.View
import android.widget.ImageView
import androidx.activity.OnBackPressedCallback
import androidx.activity.viewModels
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.content.res.AppCompatResources
import androidx.coordinatorlayout.widget.CoordinatorLayout
@ -52,7 +53,6 @@ import androidx.core.view.forEach
import androidx.core.view.isVisible
import androidx.lifecycle.lifecycleScope
import androidx.viewpager2.widget.MarginPageTransformer
import at.connyduck.calladapter.networkresult.fold
import com.bumptech.glide.Glide
import com.bumptech.glide.load.resource.bitmap.RoundedCorners
import com.bumptech.glide.request.target.CustomTarget
@ -64,14 +64,8 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.tabs.TabLayout
import com.google.android.material.tabs.TabLayout.OnTabSelectedListener
import com.google.android.material.tabs.TabLayoutMediator
import com.keylesspalace.tusky.appstore.AnnouncementReadEvent
import com.keylesspalace.tusky.appstore.CacheUpdater
import com.keylesspalace.tusky.appstore.ConversationsLoadingEvent
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.MainTabsChangedEvent
import com.keylesspalace.tusky.appstore.NewNotificationsEvent
import com.keylesspalace.tusky.appstore.NotificationsLoadingEvent
import com.keylesspalace.tusky.appstore.ProfileEditedEvent
import com.keylesspalace.tusky.components.account.AccountActivity
import com.keylesspalace.tusky.components.accountlist.AccountListActivity
import com.keylesspalace.tusky.components.announcements.AnnouncementsActivity
@ -82,29 +76,19 @@ import com.keylesspalace.tusky.components.login.LoginActivity
import com.keylesspalace.tusky.components.preference.PreferencesActivity
import com.keylesspalace.tusky.components.scheduled.ScheduledStatusActivity
import com.keylesspalace.tusky.components.search.SearchActivity
import com.keylesspalace.tusky.components.systemnotifications.NotificationHelper
import com.keylesspalace.tusky.components.systemnotifications.disableAllNotifications
import com.keylesspalace.tusky.components.systemnotifications.enablePushNotificationsWithFallback
import com.keylesspalace.tusky.components.systemnotifications.showMigrationNoticeIfNecessary
import com.keylesspalace.tusky.components.trending.TrendingActivity
import com.keylesspalace.tusky.databinding.ActivityMainBinding
import com.keylesspalace.tusky.db.DraftsAlert
import com.keylesspalace.tusky.db.entity.AccountEntity
import com.keylesspalace.tusky.di.ApplicationScope
import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.entity.Notification
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.interfaces.AccountSelectionListener
import com.keylesspalace.tusky.interfaces.ActionButtonActivity
import com.keylesspalace.tusky.interfaces.FabFragment
import com.keylesspalace.tusky.interfaces.ReselectableFragment
import com.keylesspalace.tusky.pager.MainPagerAdapter
import com.keylesspalace.tusky.settings.PrefKeys
import com.keylesspalace.tusky.usecase.DeveloperToolsUseCase
import com.keylesspalace.tusky.usecase.LogoutUsecase
import com.keylesspalace.tusky.util.ActivityConstants
import com.keylesspalace.tusky.util.ShareShortcutHelper
import com.keylesspalace.tusky.util.deleteStaleCachedMedia
import com.keylesspalace.tusky.util.emojify
import com.keylesspalace.tusky.util.getDimension
import com.keylesspalace.tusky.util.getParcelableExtraCompat
@ -145,8 +129,6 @@ import dagger.hilt.android.AndroidEntryPoint
import dagger.hilt.android.migration.OptionalInject
import de.c1710.filemojicompat_ui.helpers.EMOJI_PREFERENCE
import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@OptionalInject
@ -168,12 +150,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider {
@Inject
lateinit var developerToolsUseCase: DeveloperToolsUseCase
@Inject
lateinit var shareShortcutHelper: ShareShortcutHelper
@Inject
@ApplicationScope
lateinit var externalScope: CoroutineScope
private val viewModel: MainViewModel by viewModels()
private val binding by viewBinding(ActivityMainBinding::inflate)
@ -183,8 +160,6 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider {
private var onTabSelectedListener: OnTabSelectedListener? = null
private var unreadAnnouncementsCount = 0
// We need to know if the emoji pack has been changed
private var selectedEmojiPack: String? = null
@ -231,6 +206,8 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider {
)
}
selectedEmojiPack = preferences.getString(EMOJI_PREFERENCE, "")
var showNotificationTab = false
// check for savedInstanceState in order to not handle intent events more than once
@ -266,8 +243,6 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider {
binding.mainToolbar.show()
}
loadDrawerAvatar(activeAccount.profilePictureUrl, true)
addMenuProvider(this)
binding.viewPager.reduceSwipeSensitivity()
@ -283,88 +258,52 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider {
)
)
/* Fetch user info while we're doing other things. This has to be done after setting up the
* drawer, though, because its callback touches the header in the drawer. */
fetchUserInfo()
lifecycleScope.launch {
viewModel.accounts.collect(::updateProfiles)
}
fetchAnnouncements()
lifecycleScope.launch {
viewModel.unreadAnnouncementsCount.collect(::updateAnnouncementsBadge)
}
// Initialise the tab adapter and set to viewpager. Fragments appear to be leaked if the
// adapter changes over the life of the viewPager (the adapter, not its contents), so set
// the initial list of tabs to empty, and set the full list later in setupTabs(). See
// https://github.com/tuskyapp/Tusky/issues/3251 for details.
tabAdapter = MainPagerAdapter(emptyList(), this)
tabAdapter = MainPagerAdapter(emptyList(), this@MainActivity)
binding.viewPager.adapter = tabAdapter
setupTabs(showNotificationTab)
lifecycleScope.launch {
eventHub.events.collect { event ->
when (event) {
is ProfileEditedEvent -> onFetchUserInfoSuccess(event.newProfileData)
is MainTabsChangedEvent -> {
refreshMainDrawerItems(
addSearchButton = hideTopToolbar,
addTrendingTagsButton = !event.newTabs.hasTab(TRENDING_TAGS),
addTrendingStatusesButton = !event.newTabs.hasTab(TRENDING_STATUSES)
)
setupTabs(false)
}
is AnnouncementReadEvent -> {
unreadAnnouncementsCount--
updateAnnouncementsBadge()
}
is NewNotificationsEvent -> {
directMessageTab?.let {
if (event.accountId == activeAccount.accountId) {
val hasDirectMessageNotification =
event.notifications.any {
it.type == Notification.Type.MENTION && it.status?.visibility == Status.Visibility.DIRECT
}
if (hasDirectMessageNotification) {
showDirectMessageBadge(true)
}
}
}
}
is NotificationsLoadingEvent -> {
if (event.accountId == activeAccount.accountId) {
showDirectMessageBadge(false)
}
}
is ConversationsLoadingEvent -> {
if (event.accountId == activeAccount.accountId) {
showDirectMessageBadge(false)
}
}
}
viewModel.tabs.collect(::setupTabs)
}
if (showNotificationTab) {
val position = viewModel.tabs.value.indexOfFirst { it.id == NOTIFICATIONS }
if (position != -1) {
binding.viewPager.setCurrentItem(position, false)
}
}
externalScope.launch(Dispatchers.IO) {
// Flush old media that was cached for sharing
deleteStaleCachedMedia(applicationContext.getExternalFilesDir("Tusky"))
lifecycleScope.launch {
viewModel.showDirectMessagesBadge.collect { showBadge ->
updateDirectMessageBadge(showBadge)
}
}
selectedEmojiPack = preferences.getString(EMOJI_PREFERENCE, "")
onBackPressedDispatcher.addCallback(this, onBackPressedCallback)
onBackPressedDispatcher.addCallback(this@MainActivity, onBackPressedCallback)
if (
Build.VERSION.SDK_INT >= 33 &&
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
ContextCompat.checkSelfPermission(this@MainActivity, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
) {
ActivityCompat.requestPermissions(
this,
this@MainActivity,
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
1
)
}
// "Post failed" dialog should display in this activity
draftsAlert.observeInContext(this, true)
draftsAlert.observeInContext(this@MainActivity, true)
}
override fun onNewIntent(intent: Intent) {
@ -460,18 +399,8 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider {
return false
}
private fun showDirectMessageBadge(showBadge: Boolean) {
directMessageTab?.let { tab ->
tab.badge?.isVisible = showBadge
// TODO a bit cumbersome (also for resetting)
lifecycleScope.launch(Dispatchers.IO) {
if (activeAccount.hasDirectMessageBadge != showBadge) {
activeAccount.hasDirectMessageBadge = showBadge
accountManager.saveAccount(activeAccount)
}
}
}
private fun updateDirectMessageBadge(showBadge: Boolean) {
directMessageTab?.badge?.isVisible = showBadge
}
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
@ -824,14 +753,14 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider {
isEnabled = true
iconicsIcon = GoogleMaterial.Icon.gmd_developer_mode
onClick = {
buildDeveloperToolsDialog().show()
showDeveloperToolsDialog()
}
}
)
}
}
private fun buildDeveloperToolsDialog(): AlertDialog {
private fun showDeveloperToolsDialog(): AlertDialog {
return MaterialAlertDialogBuilder(this)
.setTitle("Developer Tools")
.setItems(
@ -849,14 +778,14 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider {
}
}
}
.create()
.show()
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(binding.mainDrawer.saveInstanceState(outState))
}
private fun setupTabs(selectNotificationTab: Boolean) {
private fun setupTabs(tabs: List<TabData>) {
val activeTabLayout = if (preferences.getString(PrefKeys.MAIN_NAV_POSITION, "top") == "bottom") {
val actionBarSize = getDimension(this, androidx.appcompat.R.attr.actionBarSize)
val fabMargin = resources.getDimensionPixelSize(R.dimen.fabMargin)
@ -873,8 +802,6 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider {
// Save the previous tab so it can be restored later
val previousTab = tabAdapter.tabs.getOrNull(binding.viewPager.currentItem)
val tabs = activeAccount.tabPreferences
// Detach any existing mediator before changing tab contents and attaching a new mediator
tabLayoutMediator?.detach()
@ -894,16 +821,10 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider {
directMessageTab = tab
}
}.also { it.attach() }
updateDirectMessageBadge(viewModel.showDirectMessagesBadge.value)
// Selected tab is either
// - Notification tab (if appropriate)
// - The previously selected tab (if it hasn't been removed)
// - Left-most tab
val position = if (selectNotificationTab) {
tabs.indexOfFirst { it.id == NOTIFICATIONS }
} else {
previousTab?.let { tabs.indexOfFirst { it == previousTab } }
}.takeIf { it != -1 } ?: 0
val position = previousTab?.let { tabs.indexOfFirst { it == previousTab } }
.takeIf { it != -1 } ?: 0
binding.viewPager.setCurrentItem(position, false)
val pageMargin = resources.getDimensionPixelSize(R.dimen.tab_page_margin)
@ -922,15 +843,8 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider {
binding.mainToolbar.title = tab.contentDescription
refreshComposeButtonState(tabAdapter, tab.position)
if (tab == directMessageTab) {
tab.badge?.isVisible = false
if (activeAccount.hasDirectMessageBadge) {
activeAccount.hasDirectMessageBadge = false
accountManager.saveAccount(activeAccount)
}
viewModel.dismissDirectMessagesBadge()
}
}
@ -939,10 +853,8 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider {
override fun onTabReselected(tab: TabLayout.Tab) {
val fragment = tabAdapter.getFragment(tab.position)
if (fragment is ReselectableFragment) {
(fragment as ReselectableFragment).onReselect()
fragment.onReselect()
}
refreshComposeButtonState(tabAdapter, tab.position)
}
}.also {
activeTabLayout.addOnTabSelectedListener(it)
@ -956,22 +868,6 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider {
) as? ReselectableFragment
)?.onReselect()
}
updateProfiles()
}
private fun refreshComposeButtonState(adapter: MainPagerAdapter, tabPosition: Int) {
adapter.getFragment(tabPosition)?.also { fragment ->
if (fragment is FabFragment) {
if (fragment.isFabVisible()) {
binding.composeButton.show()
} else {
binding.composeButton.hide()
}
} else {
binding.composeButton.show()
}
}
}
private fun handleProfileClick(profile: IProfile, current: Boolean): Boolean {
@ -996,18 +892,18 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider {
private fun changeAccount(
newSelectedId: Long,
forward: Intent?,
) {
) = lifecycleScope.launch {
cacheUpdater.stop()
accountManager.setActiveAccount(newSelectedId)
val intent = Intent(this, MainActivity::class.java)
val intent = Intent(this@MainActivity, MainActivity::class.java)
if (forward != null) {
intent.type = forward.type
intent.action = forward.action
intent.putExtras(forward)
}
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
finish()
startActivity(intent)
finish()
}
private fun logout() {
@ -1036,49 +932,8 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider {
.show()
}
private fun fetchUserInfo() = lifecycleScope.launch {
mastodonApi.accountVerifyCredentials().fold(
{ userInfo ->
onFetchUserInfoSuccess(userInfo)
},
{ throwable ->
Log.e(TAG, "Failed to fetch user info. " + throwable.message)
}
)
}
private fun onFetchUserInfoSuccess(me: Account) {
Glide.with(header.accountHeaderBackground)
.asBitmap()
.load(me.header)
.into(header.accountHeaderBackground)
loadDrawerAvatar(me.avatar, false)
accountManager.updateAccount(activeAccount, me)
NotificationHelper.createNotificationChannelsForAccount(activeAccount, this)
// Setup push notifications
showMigrationNoticeIfNecessary(
this,
binding.mainCoordinatorLayout,
binding.composeButton,
accountManager
)
if (NotificationHelper.areNotificationsEnabled(this, accountManager)) {
lifecycleScope.launch {
enablePushNotificationsWithFallback(this@MainActivity, mastodonApi, accountManager)
}
} else {
disableAllNotifications(this, accountManager)
}
updateProfiles()
shareShortcutHelper.updateShortcuts()
}
@SuppressLint("CheckResult")
private fun loadDrawerAvatar(avatarUrl: String, showPlaceholder: Boolean) {
private fun loadDrawerAvatar(avatarUrl: String, showPlaceholder: Boolean = true) {
val hideTopToolbar = preferences.getBoolean(PrefKeys.HIDE_TOP_TOOLBAR, false)
val animateAvatars = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false)
@ -1164,22 +1019,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider {
}
}
private fun fetchAnnouncements() {
lifecycleScope.launch {
mastodonApi.announcements()
.fold(
{ announcements ->
unreadAnnouncementsCount = announcements.count { !it.read }
updateAnnouncementsBadge()
},
{ throwable ->
Log.w(TAG, "Failed to fetch announcements.", throwable)
}
)
}
}
private fun updateAnnouncementsBadge() {
private fun updateAnnouncementsBadge(unreadAnnouncementsCount: Int) {
binding.mainDrawer.updateBadge(
DRAWER_ITEM_ANNOUNCEMENTS,
StringHolder(
@ -1188,12 +1028,24 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider {
)
}
private fun updateProfiles() {
private fun updateProfiles(accounts: List<AccountViewData>) {
if (accounts.isEmpty()) {
return
}
val activeProfile = accounts.first()
loadDrawerAvatar(activeProfile.profilePictureUrl)
Glide.with(header.accountHeaderBackground)
.asBitmap()
.load(activeProfile.profileHeaderUrl)
.into(header.accountHeaderBackground)
val animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false)
val profiles: MutableList<IProfile> =
accountManager.getAllAccountsOrderedByActive().map { acc ->
accounts.map { acc ->
ProfileDrawerItem().apply {
isSelected = acc.isActive
isSelected = acc == activeProfile
nameText = acc.displayName.emojify(acc.emojis, header, animateEmojis)
iconUrl = acc.profilePictureUrl
isNameShown = true
@ -1211,9 +1063,9 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider {
}
header.clear()
header.profiles = profiles
header.setActiveProfile(activeAccount.id)
header.setActiveProfile(activeProfile.id)
binding.mainToolbar.subtitle = if (accountManager.shouldDisplaySelfUsername()) {
activeAccount.fullName
activeProfile.fullName
} else {
null
}

View file

@ -0,0 +1,188 @@
/* Copyright 2024 Tusky Contributors
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky
import android.content.Context
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import at.connyduck.calladapter.networkresult.fold
import com.keylesspalace.tusky.appstore.AnnouncementReadEvent
import com.keylesspalace.tusky.appstore.ConversationsLoadingEvent
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.NewNotificationsEvent
import com.keylesspalace.tusky.appstore.NotificationsLoadingEvent
import com.keylesspalace.tusky.components.systemnotifications.NotificationHelper
import com.keylesspalace.tusky.components.systemnotifications.disableAllNotifications
import com.keylesspalace.tusky.components.systemnotifications.enablePushNotificationsWithFallback
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.entity.Emoji
import com.keylesspalace.tusky.entity.Notification
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.ShareShortcutHelper
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
@HiltViewModel
class MainViewModel @Inject constructor(
@ApplicationContext private val context: Context,
private val api: MastodonApi,
private val eventHub: EventHub,
private val accountManager: AccountManager,
private val shareShortcutHelper: ShareShortcutHelper
) : ViewModel() {
private val activeAccount = accountManager.activeAccount!!
val accounts: StateFlow<List<AccountViewData>> = accountManager.accountsFlow
.map { accounts ->
accounts.map { account ->
AccountViewData(
id = account.id,
domain = account.domain,
username = account.username,
displayName = account.displayName,
profilePictureUrl = account.profilePictureUrl,
profileHeaderUrl = account.profileHeaderUrl,
emojis = account.emojis
)
}
}
.stateIn(viewModelScope, SharingStarted.Lazily, emptyList())
val tabs: StateFlow<List<TabData>> = accountManager.activeAccount(viewModelScope)
.mapNotNull { account -> account?.tabPreferences }
.stateIn(viewModelScope, SharingStarted.Eagerly, activeAccount.tabPreferences)
private val _unreadAnnouncementsCount = MutableStateFlow(0)
val unreadAnnouncementsCount: StateFlow<Int> = _unreadAnnouncementsCount.asStateFlow()
val showDirectMessagesBadge: StateFlow<Boolean> = accountManager.activeAccount(viewModelScope)
.map { account -> account?.hasDirectMessageBadge == true }
.stateIn(viewModelScope, SharingStarted.Eagerly, false)
init {
loadAccountData()
fetchAnnouncements()
collectEvents()
}
private fun loadAccountData() {
viewModelScope.launch {
api.accountVerifyCredentials().fold(
{ userInfo ->
accountManager.updateAccount(activeAccount, userInfo)
shareShortcutHelper.updateShortcuts()
NotificationHelper.createNotificationChannelsForAccount(activeAccount, context)
if (NotificationHelper.areNotificationsEnabled(context, accountManager)) {
viewModelScope.launch {
enablePushNotificationsWithFallback(context, api, accountManager)
}
} else {
disableAllNotifications(context, accountManager)
}
},
{ throwable ->
Log.w(TAG, "Failed to fetch user info.", throwable)
}
)
}
}
private fun fetchAnnouncements() {
viewModelScope.launch {
api.announcements()
.fold(
{ announcements ->
_unreadAnnouncementsCount.value = announcements.count { !it.read }
},
{ throwable ->
Log.w(TAG, "Failed to fetch announcements.", throwable)
}
)
}
}
private fun collectEvents() {
viewModelScope.launch {
eventHub.events.collect { event ->
when (event) {
is AnnouncementReadEvent -> {
_unreadAnnouncementsCount.value--
}
is NewNotificationsEvent -> {
if (event.accountId == activeAccount.accountId) {
val hasDirectMessageNotification =
event.notifications.any {
it.type == Notification.Type.MENTION && it.status?.visibility == Status.Visibility.DIRECT
}
if (hasDirectMessageNotification) {
accountManager.updateAccount(activeAccount) { copy(hasDirectMessageBadge = true) }
}
}
}
is NotificationsLoadingEvent -> {
if (event.accountId == activeAccount.accountId) {
accountManager.updateAccount(activeAccount) { copy(hasDirectMessageBadge = false) }
}
}
is ConversationsLoadingEvent -> {
if (event.accountId == activeAccount.accountId) {
accountManager.updateAccount(activeAccount) { copy(hasDirectMessageBadge = false) }
}
}
}
}
}
}
fun dismissDirectMessagesBadge() {
viewModelScope.launch {
accountManager.updateAccount(activeAccount) { copy(hasDirectMessageBadge = false) }
}
}
companion object {
private const val TAG = "MainViewModel"
}
}
data class AccountViewData(
val id: Long,
val domain: String,
val username: String,
val displayName: String,
val profilePictureUrl: String,
val profileHeaderUrl: String,
val emojis: List<Emoji>
) {
val fullName: String
get() = "@$username@$domain"
}

View file

@ -60,7 +60,7 @@ data class TabData(
override fun hashCode() = Objects.hash(id, arguments)
}
fun List<TabData>.hasTab(id: String): Boolean = this.find { it.id == id } != null
fun List<TabData>.hasTab(id: String): Boolean = this.any { it.id == id }
fun createTabDataFromId(id: String, arguments: List<String> = emptyList()): TabData {
return when (id) {

View file

@ -30,7 +30,6 @@ import com.google.android.material.transition.MaterialContainerTransform
import com.keylesspalace.tusky.adapter.ItemInteractionListener
import com.keylesspalace.tusky.adapter.TabAdapter
import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.MainTabsChangedEvent
import com.keylesspalace.tusky.components.account.list.ListSelectionFragment
import com.keylesspalace.tusky.databinding.ActivityTabPreferenceBinding
import com.keylesspalace.tusky.entity.MastoList
@ -42,7 +41,6 @@ import com.keylesspalace.tusky.util.visible
import com.keylesspalace.tusky.view.showHashtagPickerDialog
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@AndroidEntryPoint
@ -61,8 +59,6 @@ class TabPreferenceActivity : BaseActivity(), ItemInteractionListener, ListSelec
private lateinit var touchHelper: ItemTouchHelper
private lateinit var addTabAdapter: TabAdapter
private var tabsChanged = false
private val selectedItemElevation by unsafeLazy {
resources.getDimension(R.dimen.selected_drag_item_elevation)
}
@ -315,19 +311,8 @@ class TabPreferenceActivity : BaseActivity(), ItemInteractionListener, ListSelec
private fun saveTabs() {
accountManager.activeAccount?.let {
lifecycleScope.launch(Dispatchers.IO) {
it.tabPreferences = currentTabs
accountManager.saveAccount(it)
}
}
tabsChanged = true
}
override fun onPause() {
super.onPause()
if (tabsChanged) {
lifecycleScope.launch {
eventHub.dispatch(MainTabsChangedEvent(currentTabs))
accountManager.updateAccount(it) { copy(tabPreferences = currentTabs) }
}
}
}

View file

@ -43,6 +43,7 @@ import androidx.lifecycle.lifecycleScope
import androidx.viewpager2.adapter.FragmentStateAdapter
import androidx.viewpager2.widget.ViewPager2
import com.bumptech.glide.Glide
import com.google.android.material.snackbar.Snackbar
import com.keylesspalace.tusky.BuildConfig.APPLICATION_ID
import com.keylesspalace.tusky.components.viewthread.ViewThreadActivity
import com.keylesspalace.tusky.databinding.ActivityViewMediaBinding
@ -91,11 +92,9 @@ class ViewMediaActivity :
if (isGranted) {
downloadMedia()
} else {
showErrorDialog(
binding.toolbar,
R.string.error_media_download_permission,
R.string.action_retry
) { requestDownloadMedia() }
Snackbar.make(binding.toolbar, getString(R.string.error_media_download_permission), Snackbar.LENGTH_SHORT)
.setAction(R.string.action_retry) { requestDownloadMedia() }
.show()
}
}

View file

@ -1,6 +1,5 @@
package com.keylesspalace.tusky.appstore
import com.keylesspalace.tusky.TabData
import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.entity.Notification
import com.keylesspalace.tusky.entity.Poll
@ -15,7 +14,6 @@ data class StatusComposedEvent(val status: Status) : Event
data class StatusScheduledEvent(val scheduledStatusId: String) : Event
data class ProfileEditedEvent(val newProfileData: Account) : Event
data class PreferenceChangedEvent(val preferenceKey: String) : Event
data class MainTabsChangedEvent(val newTabs: List<TabData>) : 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

View file

@ -156,8 +156,6 @@ class MediaUploader @Inject constructor(
var uri = inUri
val mimeType: String?
println("preparing media on thread ${Thread.currentThread().name}")
try {
when (inUri.scheme) {
ContentResolver.SCHEME_CONTENT -> {

View file

@ -5,7 +5,6 @@ import androidx.paging.LoadType
import androidx.paging.PagingState
import androidx.paging.RemoteMediator
import androidx.room.withTransaction
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.util.HttpHeaderLink
@ -15,19 +14,22 @@ import retrofit2.HttpException
class ConversationsRemoteMediator(
private val api: MastodonApi,
private val db: AppDatabase,
accountManager: AccountManager
private val viewModel: ConversationsViewModel
) : RemoteMediator<Int, ConversationEntity>() {
private var nextKey: String? = null
private var order: Int = 0
private val activeAccount = accountManager.activeAccount!!
override suspend fun load(
loadType: LoadType,
state: PagingState<Int, ConversationEntity>
): MediatorResult {
val activeAccount = viewModel.activeAccountFlow.value
if (activeAccount == null) {
return MediatorResult.Success(endOfPaginationReached = true)
}
if (loadType == LoadType.PREPEND) {
return MediatorResult.Success(endOfPaginationReached = true)
}

View file

@ -28,7 +28,6 @@ import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.network.MastodonApi
import com.keylesspalace.tusky.usecase.TimelineCases
import com.keylesspalace.tusky.util.EmptyPagingSource
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.map
@ -38,23 +37,21 @@ import kotlinx.coroutines.launch
class ConversationsViewModel @Inject constructor(
private val timelineCases: TimelineCases,
private val database: AppDatabase,
private val accountManager: AccountManager,
private val api: MastodonApi
private val api: MastodonApi,
accountManager: AccountManager
) : ViewModel() {
val activeAccountFlow = accountManager.activeAccount(viewModelScope)
private val accountId: Long = activeAccountFlow.value!!.id
@OptIn(ExperimentalPagingApi::class)
val conversationFlow = Pager(
config = PagingConfig(
pageSize = 30
),
remoteMediator = ConversationsRemoteMediator(api, database, accountManager),
remoteMediator = ConversationsRemoteMediator(api, database, this),
pagingSourceFactory = {
val activeAccount = accountManager.activeAccount
if (activeAccount == null) {
EmptyPagingSource()
} else {
database.conversationDao().conversationsForAccount(activeAccount.id)
}
database.conversationDao().conversationsForAccount(accountId)
}
)
.flow
@ -67,7 +64,7 @@ class ConversationsViewModel @Inject constructor(
viewModelScope.launch {
timelineCases.favourite(conversation.lastStatus.id, favourite).fold({
val newConversation = conversation.toEntity(
accountId = accountManager.activeAccount!!.id,
accountId = accountId,
favourited = favourite
)
@ -82,7 +79,7 @@ class ConversationsViewModel @Inject constructor(
viewModelScope.launch {
timelineCases.bookmark(conversation.lastStatus.id, bookmark).fold({
val newConversation = conversation.toEntity(
accountId = accountManager.activeAccount!!.id,
accountId = accountId,
bookmarked = bookmark
)
@ -102,7 +99,7 @@ class ConversationsViewModel @Inject constructor(
)
.fold({ poll ->
val newConversation = conversation.toEntity(
accountId = accountManager.activeAccount!!.id,
accountId = accountId,
poll = poll
)
@ -116,7 +113,7 @@ class ConversationsViewModel @Inject constructor(
fun expandHiddenStatus(expanded: Boolean, conversation: ConversationViewData) {
viewModelScope.launch {
val newConversation = conversation.toEntity(
accountId = accountManager.activeAccount!!.id,
accountId = accountId,
expanded = expanded
)
saveConversationToDb(newConversation)
@ -126,7 +123,7 @@ class ConversationsViewModel @Inject constructor(
fun collapseLongStatus(collapsed: Boolean, conversation: ConversationViewData) {
viewModelScope.launch {
val newConversation = conversation.toEntity(
accountId = accountManager.activeAccount!!.id,
accountId = accountId,
collapsed = collapsed
)
saveConversationToDb(newConversation)
@ -136,7 +133,7 @@ class ConversationsViewModel @Inject constructor(
fun showContent(showing: Boolean, conversation: ConversationViewData) {
viewModelScope.launch {
val newConversation = conversation.toEntity(
accountId = accountManager.activeAccount!!.id,
accountId = accountId,
showingHiddenContent = showing
)
saveConversationToDb(newConversation)
@ -150,7 +147,7 @@ class ConversationsViewModel @Inject constructor(
database.conversationDao().delete(
id = conversation.id,
accountId = accountManager.activeAccount!!.id
accountId = accountId
)
} catch (e: Exception) {
Log.w(TAG, "failed to delete conversation", e)
@ -167,7 +164,7 @@ class ConversationsViewModel @Inject constructor(
)
val newConversation = conversation.toEntity(
accountId = accountManager.activeAccount!!.id,
accountId = accountId,
muted = !conversation.lastStatus.status.muted
)

View file

@ -77,17 +77,12 @@ class LoginActivity : BaseActivity() {
if (savedInstanceState == null &&
BuildConfig.CUSTOM_INSTANCE.isNotBlank() &&
!isAdditionalLogin() && !isAccountMigration()
!isAdditionalLogin()
) {
binding.domainEditText.setText(BuildConfig.CUSTOM_INSTANCE)
binding.domainEditText.setSelection(BuildConfig.CUSTOM_INSTANCE.length)
}
if (isAccountMigration()) {
binding.domainEditText.setText(accountManager.activeAccount!!.domain)
binding.domainEditText.isEnabled = false
}
if (BuildConfig.CUSTOM_LOGO_URL.isNotBlank()) {
Glide.with(binding.loginLogo)
.load(BuildConfig.CUSTOM_LOGO_URL)
@ -107,7 +102,7 @@ class LoginActivity : BaseActivity() {
}
setSupportActionBar(binding.toolbar)
supportActionBar?.setDisplayHomeAsUpEnabled(isAdditionalLogin() || isAccountMigration())
supportActionBar?.setDisplayHomeAsUpEnabled(isAdditionalLogin())
supportActionBar?.setDisplayShowTitleEnabled(false)
}
@ -314,10 +309,6 @@ class LoginActivity : BaseActivity() {
return intent.getIntExtra(LOGIN_MODE, MODE_DEFAULT) == MODE_ADDITIONAL_LOGIN
}
private fun isAccountMigration(): Boolean {
return intent.getIntExtra(LOGIN_MODE, MODE_DEFAULT) == MODE_MIGRATION
}
companion object {
private const val TAG = "LoginActivity" // logging tag
private const val OAUTH_SCOPES = "read write follow push"
@ -329,9 +320,6 @@ class LoginActivity : BaseActivity() {
const val MODE_DEFAULT = 0
const val MODE_ADDITIONAL_LOGIN = 1
// "Migration" is used to update the OAuth scope granted to the client
const val MODE_MIGRATION = 2
@JvmStatic
fun getIntent(context: Context, mode: Int): Intent {
val loginIntent = Intent(context, LoginActivity::class.java)

View file

@ -312,9 +312,9 @@ class NotificationsFragment :
// not needed, blocking via the more menu on statuses is handled in SFragment
}
override fun onRespondToFollowRequest(accept: Boolean, id: String, position: Int) {
override fun onRespondToFollowRequest(accept: Boolean, accountIdRequestingFollow: String, position: Int) {
val notification = notificationsAdapter?.peek(position) ?: return
viewModel.respondToFollowRequest(accept, accountId = id, notificationId = notification.id)
viewModel.respondToFollowRequest(accept, accountIdRequestingFollow = accountIdRequestingFollow, notificationId = notification.id)
}
override fun onViewReport(reportId: String) {

View file

@ -26,6 +26,7 @@ import com.keylesspalace.tusky.components.timeline.toEntity
import com.keylesspalace.tusky.components.timeline.util.ifExpected
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.db.entity.AccountEntity
import com.keylesspalace.tusky.db.entity.NotificationDataEntity
import com.keylesspalace.tusky.db.entity.TimelineStatusEntity
import com.keylesspalace.tusky.entity.Notification
@ -35,10 +36,10 @@ import retrofit2.HttpException
@OptIn(ExperimentalPagingApi::class)
class NotificationsRemoteMediator(
private val viewModel: NotificationsViewModel,
private val accountManager: AccountManager,
private val api: MastodonApi,
private val db: AppDatabase,
var excludes: Set<Notification.Type>
private val db: AppDatabase
) : RemoteMediator<Int, NotificationDataEntity>() {
private var initialRefresh = false
@ -46,16 +47,18 @@ class NotificationsRemoteMediator(
private val notificationsDao = db.notificationsDao()
private val accountDao = db.timelineAccountDao()
private val statusDao = db.timelineStatusDao()
private val activeAccount = accountManager.activeAccount!!
override suspend fun load(
loadType: LoadType,
state: PagingState<Int, NotificationDataEntity>
): MediatorResult {
if (!activeAccount.isLoggedIn()) {
val activeAccount = viewModel.activeAccountFlow.value
if (activeAccount == null) {
return MediatorResult.Success(endOfPaginationReached = true)
}
val excludes = viewModel.excludes.value
try {
var dbEmpty = false
@ -79,7 +82,7 @@ class NotificationsRemoteMediator(
val notifications = notificationResponse.body()
if (notificationResponse.isSuccessful && notifications != null) {
db.withTransaction {
replaceNotificationRange(notifications, state)
replaceNotificationRange(notifications, state, activeAccount)
}
}
}
@ -106,7 +109,7 @@ class NotificationsRemoteMediator(
}
db.withTransaction {
val overlappedNotifications = replaceNotificationRange(notifications, state)
val overlappedNotifications = replaceNotificationRange(notifications, state, activeAccount)
/* In case we loaded a whole page and there was no overlap with existing statuses,
we insert a placeholder because there might be even more unknown statuses */
@ -135,7 +138,11 @@ class NotificationsRemoteMediator(
* @param notifications the new notifications
* @return the number of old notifications that have been cleared from the database
*/
private suspend fun replaceNotificationRange(notifications: List<Notification>, state: PagingState<Int, NotificationDataEntity>): Int {
private suspend fun replaceNotificationRange(
notifications: List<Notification>,
state: PagingState<Int, NotificationDataEntity>,
activeAccount: AccountEntity
): Int {
val overlappedNotifications = if (notifications.isNotEmpty()) {
notificationsDao.deleteRange(activeAccount.id, notifications.last().id, notifications.first().id)
} else {
@ -188,16 +195,13 @@ class NotificationsRemoteMediator(
return overlappedNotifications
}
private fun saveNewestNotificationId(notification: Notification) {
val account = accountManager.activeAccount
// make sure the account we are currently working with is still active
if (account == activeAccount) {
private suspend fun saveNewestNotificationId(notification: Notification) {
viewModel.activeAccountFlow.value?.let { activeAccount ->
val lastNotificationId: String = activeAccount.lastNotificationId
val newestNotificationId = notification.id
if (lastNotificationId.isLessThan(newestNotificationId)) {
Log.d(TAG, "saving newest noti id: $lastNotificationId for account ${account.id}")
account.lastNotificationId = newestNotificationId
accountManager.saveAccount(account)
Log.d(TAG, "saving newest noti id: $lastNotificationId for account ${activeAccount.id}")
accountManager.updateAccount(activeAccount) { copy(lastNotificationId = newestNotificationId) }
}
}
}

View file

@ -59,11 +59,13 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import retrofit2.HttpException
@ -79,19 +81,19 @@ class NotificationsViewModel @Inject constructor(
private val notificationPolicyUsecase: NotificationPolicyUsecase
) : ViewModel() {
val activeAccountFlow = accountManager.activeAccount(viewModelScope)
private val accountId: Long = activeAccountFlow.value!!.id
private val refreshTrigger = MutableStateFlow(0L)
private val _excludes = MutableStateFlow(
accountManager.activeAccount?.let { account -> deserialize(account.notificationsFilter) } ?: emptySet()
)
val excludes: StateFlow<Set<Notification.Type>> = _excludes.asStateFlow()
val excludes: StateFlow<Set<Notification.Type>> = activeAccountFlow
.map { account -> deserialize(account?.notificationsFilter ?: "[]") }
.stateIn(viewModelScope, SharingStarted.Eagerly, deserialize(activeAccountFlow.value?.notificationsFilter ?: "[]"))
/** Map from notification id to translation. */
private val translations = MutableStateFlow(mapOf<String, TranslationViewData>())
private val account = accountManager.activeAccount!!
private var remoteMediator = NotificationsRemoteMediator(accountManager, api, db, excludes.value)
private var remoteMediator = NotificationsRemoteMediator(this, accountManager, api, db)
private var readingOrder: ReadingOrder =
ReadingOrder.from(preferences.getString(PrefKeys.READING_ORDER, null))
@ -104,7 +106,7 @@ class NotificationsViewModel @Inject constructor(
),
remoteMediator = remoteMediator,
pagingSourceFactory = {
db.notificationsDao().getNotifications(account.id)
db.notificationsDao().getNotifications(accountId)
}
).flow
.cachedIn(viewModelScope)
@ -149,14 +151,14 @@ class NotificationsViewModel @Inject constructor(
}
fun updateNotificationFilters(newFilters: Set<Notification.Type>) {
if (newFilters != _excludes.value) {
val account = activeAccountFlow.value
if (newFilters != excludes.value && account != null) {
viewModelScope.launch {
account.notificationsFilter = serialize(newFilters)
accountManager.saveAccount(account)
remoteMediator.excludes = newFilters
db.notificationsDao().cleanupNotifications(account.id, 0)
accountManager.updateAccount(account) {
copy(notificationsFilter = serialize(newFilters))
}
db.notificationsDao().cleanupNotifications(accountId, 0)
refreshTrigger.value++
_excludes.value = newFilters
}
}
}
@ -164,8 +166,9 @@ class NotificationsViewModel @Inject constructor(
private fun shouldFilterStatus(notificationViewData: NotificationViewData): Filter.Action {
return when ((notificationViewData as? NotificationViewData.Concrete)?.type) {
Notification.Type.MENTION, Notification.Type.POLL -> {
val account = activeAccountFlow.value
notificationViewData.statusViewData?.let { statusViewData ->
if (statusViewData.status.account.id == account.accountId) {
if (statusViewData.status.account.id == account?.accountId) {
return Filter.Action.NONE
}
statusViewData.filterAction = filterModel.shouldFilterStatus(statusViewData.actionable)
@ -178,23 +181,23 @@ class NotificationsViewModel @Inject constructor(
}
}
fun respondToFollowRequest(accept: Boolean, accountId: String, notificationId: String) {
fun respondToFollowRequest(accept: Boolean, accountIdRequestingFollow: String, notificationId: String) {
viewModelScope.launch {
if (accept) {
api.authorizeFollowRequest(accountId)
api.authorizeFollowRequest(accountIdRequestingFollow)
} else {
api.rejectFollowRequest(accountId)
api.rejectFollowRequest(accountIdRequestingFollow)
}.fold(
onSuccess = {
// since the follow request has been responded, the notification can be deleted. The Ui will update automatically.
db.notificationsDao().delete(account.id, notificationId)
db.notificationsDao().delete(accountId, notificationId)
if (accept) {
// refresh the notifications so the new follow notification will be loaded
refreshTrigger.value++
}
},
onFailure = { t ->
Log.e(TAG, "Failed to to respond to follow request from account id $accountId.", t)
Log.e(TAG, "Failed to to respond to follow request from account id $accountIdRequestingFollow.", t)
}
)
}
@ -239,33 +242,33 @@ class NotificationsViewModel @Inject constructor(
fun changeExpanded(expanded: Boolean, status: StatusViewData.Concrete) {
viewModelScope.launch {
db.timelineStatusDao()
.setExpanded(account.id, status.id, expanded)
.setExpanded(accountId, status.id, expanded)
}
}
fun changeContentShowing(isShowing: Boolean, status: StatusViewData.Concrete) {
viewModelScope.launch {
db.timelineStatusDao()
.setContentShowing(account.id, status.id, isShowing)
.setContentShowing(accountId, status.id, isShowing)
}
}
fun changeContentCollapsed(isCollapsed: Boolean, status: StatusViewData.Concrete) {
viewModelScope.launch {
db.timelineStatusDao()
.setContentCollapsed(account.id, status.id, isCollapsed)
.setContentCollapsed(accountId, status.id, isCollapsed)
}
}
fun remove(notificationId: String) {
viewModelScope.launch {
db.notificationsDao().delete(account.id, notificationId)
db.notificationsDao().delete(accountId, notificationId)
}
}
fun clearWarning(status: StatusViewData.Concrete) {
viewModelScope.launch {
db.timelineStatusDao().clearWarning(account.id, status.actionableId)
db.timelineStatusDao().clearWarning(accountId, status.actionableId)
}
}
@ -273,7 +276,7 @@ class NotificationsViewModel @Inject constructor(
viewModelScope.launch {
api.clearNotifications().fold(
{
db.notificationsDao().cleanupNotifications(account.id, 0)
db.notificationsDao().cleanupNotifications(accountId, 0)
},
{ t ->
Log.w(TAG, "failed to clear notifications", t)
@ -304,13 +307,13 @@ class NotificationsViewModel @Inject constructor(
notificationsDao.insertNotification(
Placeholder(placeholderId, loading = true).toNotificationEntity(
account.id
accountId
)
)
val (idAbovePlaceholder, idBelowPlaceholder) = db.withTransaction {
notificationsDao.getIdAbove(account.id, placeholderId) to
notificationsDao.getIdBelow(account.id, placeholderId)
notificationsDao.getIdAbove(accountId, placeholderId) to
notificationsDao.getIdBelow(accountId, placeholderId)
}
val response = when (readingOrder) {
// Using minId, loads up to LOAD_AT_ONCE statuses with IDs immediately
@ -337,15 +340,20 @@ class NotificationsViewModel @Inject constructor(
return@launch
}
val account = activeAccountFlow.value
if (account == null) {
return@launch
}
val statusDao = db.timelineStatusDao()
val accountDao = db.timelineAccountDao()
db.withTransaction {
notificationsDao.delete(account.id, placeholderId)
notificationsDao.delete(accountId, placeholderId)
val overlappedNotifications = if (notifications.isNotEmpty()) {
notificationsDao.deleteRange(
account.id,
accountId,
notifications.last().id,
notifications.first().id
)
@ -354,18 +362,18 @@ class NotificationsViewModel @Inject constructor(
}
for (notification in notifications) {
accountDao.insert(notification.account.toEntity(account.id))
accountDao.insert(notification.account.toEntity(accountId))
notification.report?.let { report ->
accountDao.insert(report.targetAccount.toEntity(account.id))
notificationsDao.insertReport(report.toEntity(account.id))
accountDao.insert(report.targetAccount.toEntity(accountId))
notificationsDao.insertReport(report.toEntity(accountId))
}
notification.status?.let { status ->
val statusToInsert = status.reblog ?: status
accountDao.insert(statusToInsert.account.toEntity(account.id))
accountDao.insert(statusToInsert.account.toEntity(accountId))
statusDao.insert(
statusToInsert.toEntity(
tuskyAccountId = account.id,
tuskyAccountId = accountId,
expanded = account.alwaysOpenSpoiler,
contentShowing = account.alwaysShowSensitiveMedia || !status.sensitive,
contentCollapsed = true
@ -374,7 +382,7 @@ class NotificationsViewModel @Inject constructor(
}
notificationsDao.insertNotification(
notification.toEntity(
account.id
accountId
)
)
}
@ -393,7 +401,7 @@ class NotificationsViewModel @Inject constructor(
Placeholder(
idToConvert,
loading = false
).toNotificationEntity(account.id)
).toNotificationEntity(accountId)
)
}
}

View file

@ -37,9 +37,7 @@ import com.keylesspalace.tusky.components.accountlist.AccountListActivity
import com.keylesspalace.tusky.components.domainblocks.DomainBlocksActivity
import com.keylesspalace.tusky.components.filters.FiltersActivity
import com.keylesspalace.tusky.components.followedtags.FollowedTagsActivity
import com.keylesspalace.tusky.components.login.LoginActivity
import com.keylesspalace.tusky.components.preference.notificationpolicies.NotificationPoliciesActivity
import com.keylesspalace.tusky.components.systemnotifications.currentAccountNeedsMigration
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.entity.Status
@ -146,18 +144,6 @@ class AccountPreferencesFragment : PreferenceFragmentCompat() {
}
}
if (currentAccountNeedsMigration(accountManager)) {
preference {
setTitle(R.string.title_migration_relogin)
icon = icon(R.drawable.ic_logout)
setOnPreferenceClickListener {
val intent = LoginActivity.getIntent(context, LoginActivity.MODE_MIGRATION)
activity?.startActivityWithSlideInAnimation(intent)
true
}
}
}
preference {
setTitle(R.string.pref_title_timeline_filters)
icon = icon(R.drawable.ic_filter_24dp)
@ -202,10 +188,11 @@ class AccountPreferencesFragment : PreferenceFragmentCompat() {
isPersistent = false // its saved to the account and shouldn't be in shared preferences
setOnPreferenceChangeListener { _, newValue ->
val newVisibility = DefaultReplyVisibility.fromStringValue(newValue as String)
icon = getIconForVisibility(newVisibility.toVisibilityOr(activeAccount.defaultPostPrivacy))
activeAccount.defaultReplyPrivacy = newVisibility
accountManager.saveAccount(activeAccount)
viewLifecycleOwner.lifecycleScope.launch {
accountManager.updateAccount(activeAccount) { copy(defaultReplyPrivacy = newVisibility) }
eventHub.dispatch(PreferenceChangedEvent(key))
}
true
@ -330,11 +317,14 @@ class AccountPreferencesFragment : PreferenceFragmentCompat() {
mastodonApi.accountUpdateSource(visibility, sensitive, language)
.fold({ account: Account ->
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.updateAccount(it) {
copy(
defaultPostPrivacy = account.source?.privacy
?: Status.Visibility.PUBLIC,
defaultMediaSensitivity = account.source?.sensitive == true,
defaultPostLanguage = language.orEmpty()
)
}
}
}, { t ->
Log.e("AccountPreferences", "failed updating settings on server", t)

View file

@ -16,6 +16,7 @@
package com.keylesspalace.tusky.components.preference
import android.os.Bundle
import androidx.lifecycle.lifecycleScope
import androidx.preference.PreferenceFragmentCompat
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.components.systemnotifications.NotificationHelper
@ -27,6 +28,7 @@ import com.keylesspalace.tusky.settings.preferenceCategory
import com.keylesspalace.tusky.settings.switchPreference
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import kotlinx.coroutines.launch
@AndroidEntryPoint
class NotificationPreferencesFragment : PreferenceFragmentCompat() {
@ -44,7 +46,7 @@ class NotificationPreferencesFragment : PreferenceFragmentCompat() {
isIconSpaceReserved = false
isChecked = activeAccount.notificationsEnabled
setOnPreferenceChangeListener { _, newValue ->
updateAccount { it.notificationsEnabled = newValue as Boolean }
updateAccount { copy(notificationsEnabled = newValue as Boolean) }
if (NotificationHelper.areNotificationsEnabled(context, accountManager)) {
NotificationHelper.enablePullNotifications(context)
} else {
@ -64,7 +66,7 @@ class NotificationPreferencesFragment : PreferenceFragmentCompat() {
isIconSpaceReserved = false
isChecked = activeAccount.notificationsFollowed
setOnPreferenceChangeListener { _, newValue ->
updateAccount { it.notificationsFollowed = newValue as Boolean }
updateAccount { copy(notificationsFollowed = newValue as Boolean) }
true
}
}
@ -75,7 +77,7 @@ class NotificationPreferencesFragment : PreferenceFragmentCompat() {
isIconSpaceReserved = false
isChecked = activeAccount.notificationsFollowRequested
setOnPreferenceChangeListener { _, newValue ->
updateAccount { it.notificationsFollowRequested = newValue as Boolean }
updateAccount { copy(notificationsFollowRequested = newValue as Boolean) }
true
}
}
@ -86,7 +88,7 @@ class NotificationPreferencesFragment : PreferenceFragmentCompat() {
isIconSpaceReserved = false
isChecked = activeAccount.notificationsReblogged
setOnPreferenceChangeListener { _, newValue ->
updateAccount { it.notificationsReblogged = newValue as Boolean }
updateAccount { copy(notificationsReblogged = newValue as Boolean) }
true
}
}
@ -97,7 +99,7 @@ class NotificationPreferencesFragment : PreferenceFragmentCompat() {
isIconSpaceReserved = false
isChecked = activeAccount.notificationsFavorited
setOnPreferenceChangeListener { _, newValue ->
updateAccount { it.notificationsFavorited = newValue as Boolean }
updateAccount { copy(notificationsFavorited = newValue as Boolean) }
true
}
}
@ -108,7 +110,7 @@ class NotificationPreferencesFragment : PreferenceFragmentCompat() {
isIconSpaceReserved = false
isChecked = activeAccount.notificationsPolls
setOnPreferenceChangeListener { _, newValue ->
updateAccount { it.notificationsPolls = newValue as Boolean }
updateAccount { copy(notificationsPolls = newValue as Boolean) }
true
}
}
@ -119,7 +121,7 @@ class NotificationPreferencesFragment : PreferenceFragmentCompat() {
isIconSpaceReserved = false
isChecked = activeAccount.notificationsSubscriptions
setOnPreferenceChangeListener { _, newValue ->
updateAccount { it.notificationsSubscriptions = newValue as Boolean }
updateAccount { copy(notificationsSubscriptions = newValue as Boolean) }
true
}
}
@ -130,7 +132,7 @@ class NotificationPreferencesFragment : PreferenceFragmentCompat() {
isIconSpaceReserved = false
isChecked = activeAccount.notificationsSignUps
setOnPreferenceChangeListener { _, newValue ->
updateAccount { it.notificationsSignUps = newValue as Boolean }
updateAccount { copy(notificationsSignUps = newValue as Boolean) }
true
}
}
@ -141,7 +143,7 @@ class NotificationPreferencesFragment : PreferenceFragmentCompat() {
isIconSpaceReserved = false
isChecked = activeAccount.notificationsUpdates
setOnPreferenceChangeListener { _, newValue ->
updateAccount { it.notificationsUpdates = newValue as Boolean }
updateAccount { copy(notificationsUpdates = newValue as Boolean) }
true
}
}
@ -152,7 +154,7 @@ class NotificationPreferencesFragment : PreferenceFragmentCompat() {
isIconSpaceReserved = false
isChecked = activeAccount.notificationsReports
setOnPreferenceChangeListener { _, newValue ->
updateAccount { it.notificationsReports = newValue as Boolean }
updateAccount { copy(notificationsReports = newValue as Boolean) }
true
}
}
@ -168,7 +170,7 @@ class NotificationPreferencesFragment : PreferenceFragmentCompat() {
isIconSpaceReserved = false
isChecked = activeAccount.notificationSound
setOnPreferenceChangeListener { _, newValue ->
updateAccount { it.notificationSound = newValue as Boolean }
updateAccount { copy(notificationSound = newValue as Boolean) }
true
}
}
@ -179,7 +181,7 @@ class NotificationPreferencesFragment : PreferenceFragmentCompat() {
isIconSpaceReserved = false
isChecked = activeAccount.notificationVibration
setOnPreferenceChangeListener { _, newValue ->
updateAccount { it.notificationVibration = newValue as Boolean }
updateAccount { copy(notificationVibration = newValue as Boolean) }
true
}
}
@ -190,7 +192,7 @@ class NotificationPreferencesFragment : PreferenceFragmentCompat() {
isIconSpaceReserved = false
isChecked = activeAccount.notificationLight
setOnPreferenceChangeListener { _, newValue ->
updateAccount { it.notificationLight = newValue as Boolean }
updateAccount { copy(notificationLight = newValue as Boolean) }
true
}
}
@ -198,10 +200,11 @@ class NotificationPreferencesFragment : PreferenceFragmentCompat() {
}
}
private inline fun updateAccount(changer: (AccountEntity) -> Unit) {
accountManager.activeAccount?.let { account ->
changer(account)
accountManager.saveAccount(account)
private fun updateAccount(changer: AccountEntity.() -> AccountEntity) {
viewLifecycleOwner.lifecycleScope.launch {
accountManager.activeAccount?.let { account ->
accountManager.updateAccount(account, changer)
}
}
}

View file

@ -16,6 +16,7 @@
package com.keylesspalace.tusky.components.preference
import android.os.Bundle
import androidx.lifecycle.lifecycleScope
import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
import com.keylesspalace.tusky.R
@ -38,6 +39,7 @@ import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
import dagger.hilt.android.AndroidEntryPoint
import de.c1710.filemojicompat_ui.views.picker.preference.EmojiPickerPreference
import javax.inject.Inject
import kotlinx.coroutines.launch
@AndroidEntryPoint
class PreferencesFragment : PreferenceFragmentCompat() {
@ -257,8 +259,9 @@ class PreferencesFragment : PreferenceFragmentCompat() {
notificationFilter.remove(Notification.Type.REBLOG)
}
account.notificationsFilter = serialize(notificationFilter)
accountManager.saveAccount(account)
lifecycleScope.launch {
accountManager.updateAccount(account) { copy(notificationsFilter = serialize(notificationFilter)) }
}
}
true
}

View file

@ -58,7 +58,7 @@ class NotificationFetcher @Inject constructor(
return
}
for (account in accountManager.getAllAccountsOrderedByActive()) {
for (account in accountManager.accounts) {
if (account.notificationsEnabled) {
try {
val notificationManager = context.getSystemService(
@ -109,8 +109,6 @@ class NotificationFetcher @Inject constructor(
// NOTE having multiple summary notifications this here should still collapse them in only one occurrence
notificationManagerCompat.notify(newNotifications)
accountManager.saveAccount(account)
} catch (e: Exception) {
Log.e(TAG, "Error while fetching notifications", e)
}
@ -196,8 +194,7 @@ class NotificationFetcher @Inject constructor(
domain = account.domain,
notificationsLastReadId = newMarkerId
)
account.notificationMarkerId = newMarkerId
accountManager.saveAccount(account)
accountManager.updateAccount(account) { copy(notificationMarkerId = newMarkerId) }
}
return notifications

View file

@ -21,14 +21,8 @@ import android.app.NotificationManager
import android.content.Context
import android.os.Build
import android.util.Log
import android.view.View
import androidx.preference.PreferenceManager
import at.connyduck.calladapter.networkresult.onFailure
import at.connyduck.calladapter.networkresult.onSuccess
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import com.keylesspalace.tusky.R
import com.keylesspalace.tusky.components.login.LoginActivity
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.entity.AccountEntity
import com.keylesspalace.tusky.entity.Notification
@ -40,59 +34,6 @@ import org.unifiedpush.android.connector.UnifiedPush
private const val TAG = "PushNotificationHelper"
private const val KEY_MIGRATION_NOTICE_DISMISSED = "migration_notice_dismissed"
private fun anyAccountNeedsMigration(accountManager: AccountManager): Boolean =
accountManager.accounts.any(::accountNeedsMigration)
private fun accountNeedsMigration(account: AccountEntity): Boolean =
!account.oauthScopes.contains("push")
fun currentAccountNeedsMigration(accountManager: AccountManager): Boolean =
accountManager.activeAccount?.let(::accountNeedsMigration) ?: false
fun showMigrationNoticeIfNecessary(
context: Context,
parent: View,
anchorView: View?,
accountManager: AccountManager
) {
// No point showing anything if we cannot enable it
if (!isUnifiedPushAvailable(context)) return
if (!anyAccountNeedsMigration(accountManager)) return
val pm = PreferenceManager.getDefaultSharedPreferences(context)
if (pm.getBoolean(KEY_MIGRATION_NOTICE_DISMISSED, false)) return
Snackbar.make(parent, R.string.tips_push_notification_migration, Snackbar.LENGTH_INDEFINITE)
.setAnchorView(anchorView)
.setAction(
R.string.action_details
) { showMigrationExplanationDialog(context, accountManager) }
.show()
}
private fun showMigrationExplanationDialog(context: Context, accountManager: AccountManager) {
MaterialAlertDialogBuilder(context).apply {
if (currentAccountNeedsMigration(accountManager)) {
setMessage(R.string.dialog_push_notification_migration)
setPositiveButton(R.string.title_migration_relogin) { _, _ ->
context.startActivity(
LoginActivity.getIntent(context, LoginActivity.MODE_MIGRATION)
)
}
} else {
setMessage(R.string.dialog_push_notification_migration_other_accounts)
}
setNegativeButton(R.string.action_dismiss) { dialog, _ ->
val pm = PreferenceManager.getDefaultSharedPreferences(context)
pm.edit().putBoolean(KEY_MIGRATION_NOTICE_DISMISSED, true).apply()
dialog.dismiss()
}
show()
}
}
private suspend fun enableUnifiedPushNotificationsForAccount(
context: Context,
api: MastodonApi,
@ -123,18 +64,15 @@ fun disableUnifiedPushNotificationsForAccount(context: Context, account: Account
fun isUnifiedPushNotificationEnabledForAccount(account: AccountEntity): Boolean =
account.unifiedPushUrl.isNotEmpty()
private fun isUnifiedPushAvailable(context: Context): Boolean =
fun isUnifiedPushAvailable(context: Context): Boolean =
UnifiedPush.getDistributors(context).isNotEmpty()
fun canEnablePushNotifications(context: Context, accountManager: AccountManager): Boolean =
isUnifiedPushAvailable(context) && !anyAccountNeedsMigration(accountManager)
suspend fun enablePushNotificationsWithFallback(
context: Context,
api: MastodonApi,
accountManager: AccountManager
) {
if (!canEnablePushNotifications(context, accountManager)) {
if (!isUnifiedPushAvailable(context)) {
// No UP distributors
NotificationHelper.enablePullNotifications(context)
return
@ -208,12 +146,15 @@ suspend fun registerUnifiedPushEndpoint(
}.onSuccess {
Log.d(TAG, "UnifiedPush registration succeeded for account ${account.id}")
account.pushPubKey = keyPair.pubkey
account.pushPrivKey = keyPair.privKey
account.pushAuth = auth
account.pushServerKey = it.serverKey
account.unifiedPushUrl = endpoint
accountManager.saveAccount(account)
accountManager.updateAccount(account) {
copy(
pushPubKey = keyPair.pubkey,
pushPrivKey = keyPair.privKey,
pushAuth = auth,
pushServerKey = it.serverKey,
unifiedPushUrl = endpoint
)
}
}
}
@ -231,9 +172,9 @@ suspend fun updateUnifiedPushSubscription(
buildSubscriptionData(context, account)
).onSuccess {
Log.d(TAG, "UnifiedPush subscription updated for account ${account.id}")
account.pushServerKey = it.serverKey
accountManager.saveAccount(account)
accountManager.updateAccount(account) {
copy(pushServerKey = it.serverKey)
}
}
}
}
@ -251,12 +192,15 @@ suspend fun unregisterUnifiedPushEndpoint(
.onSuccess {
Log.d(TAG, "UnifiedPush unregistration succeeded for account " + account.id)
// Clear the URL in database
account.unifiedPushUrl = ""
account.pushServerKey = ""
account.pushAuth = ""
account.pushPrivKey = ""
account.pushPubKey = ""
accountManager.saveAccount(account)
accountManager.updateAccount(account) {
copy(
pushPubKey = "",
pushPrivKey = "",
pushAuth = "",
pushServerKey = "",
unifiedPushUrl = ""
)
}
}
}
}

View file

@ -24,8 +24,8 @@ import androidx.room.withTransaction
import com.keylesspalace.tusky.components.timeline.Placeholder
import com.keylesspalace.tusky.components.timeline.toEntity
import com.keylesspalace.tusky.components.timeline.util.ifExpected
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.AppDatabase
import com.keylesspalace.tusky.db.entity.AccountEntity
import com.keylesspalace.tusky.db.entity.HomeTimelineData
import com.keylesspalace.tusky.db.entity.HomeTimelineEntity
import com.keylesspalace.tusky.db.entity.TimelineStatusEntity
@ -35,7 +35,7 @@ import retrofit2.HttpException
@OptIn(ExperimentalPagingApi::class)
class CachedTimelineRemoteMediator(
accountManager: AccountManager,
private val viewModel: CachedTimelineViewModel,
private val api: MastodonApi,
private val db: AppDatabase,
) : RemoteMediator<Int, HomeTimelineData>() {
@ -45,13 +45,13 @@ class CachedTimelineRemoteMediator(
private val timelineDao = db.timelineDao()
private val statusDao = db.timelineStatusDao()
private val accountDao = db.timelineAccountDao()
private val activeAccount = accountManager.activeAccount!!
override suspend fun load(
loadType: LoadType,
state: PagingState<Int, HomeTimelineData>
): MediatorResult {
if (!activeAccount.isLoggedIn()) {
val activeAccount = viewModel.activeAccountFlow.value
if (activeAccount == null) {
return MediatorResult.Success(endOfPaginationReached = true)
}
@ -77,7 +77,7 @@ class CachedTimelineRemoteMediator(
val statuses = statusResponse.body()
if (statusResponse.isSuccessful && statuses != null) {
db.withTransaction {
replaceStatusRange(statuses, state)
replaceStatusRange(statuses, state, activeAccount)
}
}
}
@ -104,7 +104,7 @@ class CachedTimelineRemoteMediator(
}
db.withTransaction {
val overlappedStatuses = replaceStatusRange(statuses, state)
val overlappedStatuses = replaceStatusRange(statuses, state, activeAccount)
/* In case we loaded a whole page and there was no overlap with existing statuses,
we insert a placeholder because there might be even more unknown statuses */
@ -135,7 +135,8 @@ class CachedTimelineRemoteMediator(
*/
private suspend fun replaceStatusRange(
statuses: List<Status>,
state: PagingState<Int, HomeTimelineData>
state: PagingState<Int, HomeTimelineData>,
activeAccount: AccountEntity
): Int {
val overlappedStatuses = if (statuses.isNotEmpty()) {
timelineDao.deleteRange(activeAccount.id, statuses.last().id, statuses.first().id)
@ -161,7 +162,7 @@ class CachedTimelineRemoteMediator(
val expanded = oldStatus?.expanded ?: activeAccount.alwaysOpenSpoiler
val contentShowing = oldStatus?.contentShowing ?: (activeAccount.alwaysShowSensitiveMedia || !status.actionableStatus.sensitive)
val contentCollapsed = oldStatus?.contentCollapsed ?: true
val contentCollapsed = oldStatus?.contentCollapsed != false
statusDao.insert(
status.actionableStatus.toEntity(

View file

@ -86,9 +86,9 @@ class CachedTimelineViewModel @Inject constructor(
config = PagingConfig(
pageSize = LOAD_AT_ONCE
),
remoteMediator = CachedTimelineRemoteMediator(accountManager, api, db),
remoteMediator = CachedTimelineRemoteMediator(this, api, db),
pagingSourceFactory = {
db.timelineDao().getHomeTimeline(account.id).also { newPagingSource ->
db.timelineDao().getHomeTimeline(accountId).also { newPagingSource ->
this.currentPagingSource = newPagingSource
}
}
@ -118,27 +118,27 @@ class CachedTimelineViewModel @Inject constructor(
override fun changeExpanded(expanded: Boolean, status: StatusViewData.Concrete) {
viewModelScope.launch {
db.timelineStatusDao()
.setExpanded(account.id, status.actionableId, expanded)
.setExpanded(accountId, status.actionableId, expanded)
}
}
override fun changeContentShowing(isShowing: Boolean, status: StatusViewData.Concrete) {
viewModelScope.launch {
db.timelineStatusDao()
.setContentShowing(account.id, status.actionableId, isShowing)
.setContentShowing(accountId, status.actionableId, isShowing)
}
}
override fun changeContentCollapsed(isCollapsed: Boolean, status: StatusViewData.Concrete) {
viewModelScope.launch {
db.timelineStatusDao()
.setContentCollapsed(account.id, status.actionableId, isCollapsed)
.setContentCollapsed(accountId, status.actionableId, isCollapsed)
}
}
override fun clearWarning(status: StatusViewData.Concrete) {
viewModelScope.launch {
db.timelineStatusDao().clearWarning(account.id, status.actionableId)
db.timelineStatusDao().clearWarning(accountId, status.actionableId)
}
}
@ -154,12 +154,12 @@ class CachedTimelineViewModel @Inject constructor(
val accountDao = db.timelineAccountDao()
timelineDao.insertHomeTimelineItem(
Placeholder(placeholderId, loading = true).toEntity(tuskyAccountId = account.id)
Placeholder(placeholderId, loading = true).toEntity(tuskyAccountId = accountId)
)
val (idAbovePlaceholder, idBelowPlaceholder) = db.withTransaction {
timelineDao.getIdAbove(account.id, placeholderId) to
timelineDao.getIdBelow(account.id, placeholderId)
timelineDao.getIdAbove(accountId, placeholderId) to
timelineDao.getIdBelow(accountId, placeholderId)
}
val response = when (readingOrder) {
// Using minId, loads up to LOAD_AT_ONCE statuses with IDs immediately
@ -184,12 +184,17 @@ class CachedTimelineViewModel @Inject constructor(
return@launch
}
val account = activeAccountFlow.value
if (account == null) {
return@launch
}
db.withTransaction {
timelineDao.deleteHomeTimelineItem(account.id, placeholderId)
timelineDao.deleteHomeTimelineItem(accountId, placeholderId)
val overlappedStatuses = if (statuses.isNotEmpty()) {
timelineDao.deleteRange(
account.id,
accountId,
statuses.last().id,
statuses.first().id
)
@ -198,14 +203,14 @@ class CachedTimelineViewModel @Inject constructor(
}
for (status in statuses) {
accountDao.insert(status.account.toEntity(account.id))
status.reblog?.account?.toEntity(account.id)
accountDao.insert(status.account.toEntity(accountId))
status.reblog?.account?.toEntity(accountId)
?.let { rebloggedAccount ->
accountDao.insert(rebloggedAccount)
}
statusDao.insert(
status.actionableStatus.toEntity(
tuskyAccountId = account.id,
tuskyAccountId = accountId,
expanded = account.alwaysOpenSpoiler,
contentShowing = account.alwaysShowSensitiveMedia || !status.actionableStatus.sensitive,
contentCollapsed = true
@ -213,7 +218,7 @@ class CachedTimelineViewModel @Inject constructor(
)
timelineDao.insertHomeTimelineItem(
HomeTimelineEntity(
tuskyAccountId = account.id,
tuskyAccountId = accountId,
id = status.id,
statusId = status.actionableId,
reblogAccountId = if (status.reblog != null) {
@ -239,7 +244,7 @@ class CachedTimelineViewModel @Inject constructor(
Placeholder(
idToConvert,
loading = false
).toEntity(account.id)
).toEntity(accountId)
)
}
}
@ -252,7 +257,7 @@ class CachedTimelineViewModel @Inject constructor(
}
private suspend fun loadMoreFailed(placeholderId: String, e: Exception) {
Log.w("CachedTimelineVM", "failed loading statuses", e)
Log.w(TAG, "failed loading statuses", e)
val activeAccount = accountManager.activeAccount!!
db.timelineDao()
.insertHomeTimelineItem(Placeholder(placeholderId, loading = false).toEntity(activeAccount.id))
@ -266,16 +271,19 @@ class CachedTimelineViewModel @Inject constructor(
}
override fun saveReadingPosition(statusId: String) {
accountManager.activeAccount?.let { account ->
Log.d(TAG, "Saving position at: $statusId")
account.lastVisibleHomeTimelineStatusId = statusId
accountManager.saveAccount(account)
viewModelScope.launch {
accountManager.activeAccount?.let { account ->
Log.d(TAG, "Saving position at: $statusId")
accountManager.updateAccount(account) {
copy(lastVisibleHomeTimelineStatusId = statusId)
}
}
}
}
override suspend fun invalidate() {
// invalidating when we don't have statuses yet can cause empty timelines because it cancels the network load
if (db.timelineDao().getHomeTimelineItemCount(account.id) > 0) {
if (db.timelineDao().getHomeTimelineItemCount(accountId) > 0) {
currentPagingSource?.invalidate()
}
}

View file

@ -21,7 +21,6 @@ import androidx.paging.LoadType
import androidx.paging.PagingState
import androidx.paging.RemoteMediator
import com.keylesspalace.tusky.components.timeline.util.ifExpected
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.util.HttpHeaderLink
import com.keylesspalace.tusky.util.toViewData
import com.keylesspalace.tusky.viewdata.StatusViewData
@ -29,7 +28,6 @@ import retrofit2.HttpException
@OptIn(ExperimentalPagingApi::class)
class NetworkTimelineRemoteMediator(
private val accountManager: AccountManager,
private val viewModel: NetworkTimelineViewModel
) : RemoteMediator<String, StatusViewData>() {
@ -68,7 +66,7 @@ class NetworkTimelineRemoteMediator(
return MediatorResult.Error(HttpException(statusResponse))
}
val activeAccount = accountManager.activeAccount!!
val activeAccount = viewModel.activeAccountFlow.value!!
val data = statuses.map { status ->
@ -78,7 +76,7 @@ class NetworkTimelineRemoteMediator(
val contentShowing = oldStatus?.isShowingContent ?: (activeAccount.alwaysShowSensitiveMedia || !status.actionableStatus.sensitive)
val expanded = oldStatus?.isExpanded ?: activeAccount.alwaysOpenSpoiler
val contentCollapsed = oldStatus?.isCollapsed ?: true
val contentCollapsed = oldStatus?.isCollapsed != false
status.toViewData(
isShowingContent = contentShowing,

View file

@ -96,7 +96,7 @@ class NetworkTimelineViewModel @Inject constructor(
currentSource = source
}
},
remoteMediator = NetworkTimelineRemoteMediator(accountManager, this)
remoteMediator = NetworkTimelineRemoteMediator(this)
).flow
.map { pagingData ->
pagingData.filter(Dispatchers.Default.asExecutor()) { statusViewData ->

View file

@ -41,12 +41,13 @@ import kotlinx.coroutines.launch
abstract class TimelineViewModel(
protected val timelineCases: TimelineCases,
private val eventHub: EventHub,
protected val accountManager: AccountManager,
val accountManager: AccountManager,
private val sharedPreferences: SharedPreferences,
private val filterModel: FilterModel
) : ViewModel() {
protected val account = accountManager.activeAccount!!
val activeAccountFlow = accountManager.activeAccount(viewModelScope)
protected val accountId: Long = activeAccountFlow.value!!.id
abstract val statuses: Flow<PagingData<StatusViewData>>
@ -69,19 +70,18 @@ abstract class TimelineViewModel(
this.id = id
this.tags = tags
val activeAccount = activeAccountFlow.value!!
if (kind == Kind.HOME) {
// Note the variable is "true if filter" but the underlying preference/settings text is "true if show"
filterRemoveReplies =
!(accountManager.activeAccount?.isShowHomeReplies ?: true)
filterRemoveReblogs =
!(accountManager.activeAccount?.isShowHomeBoosts ?: true)
filterRemoveSelfReblogs =
!(accountManager.activeAccount?.isShowHomeSelfBoosts ?: true)
filterRemoveReplies = !activeAccount.isShowHomeReplies
filterRemoveReblogs = !activeAccount.isShowHomeBoosts
filterRemoveSelfReblogs = !activeAccount.isShowHomeSelfBoosts
}
readingOrder = ReadingOrder.from(sharedPreferences.getString(PrefKeys.READING_ORDER, null))
this.alwaysShowSensitiveMedia = accountManager.activeAccount!!.alwaysShowSensitiveMedia
this.alwaysOpenSpoilers = accountManager.activeAccount!!.alwaysOpenSpoiler
this.alwaysShowSensitiveMedia = activeAccount.alwaysShowSensitiveMedia
this.alwaysOpenSpoilers = activeAccount.alwaysOpenSpoiler
viewModelScope.launch {
eventHub.events
@ -181,7 +181,7 @@ abstract class TimelineViewModel(
protected fun shouldFilterStatus(statusViewData: StatusViewData): Filter.Action {
val status = statusViewData.asStatusOrNull()?.status ?: return Filter.Action.NONE
if (status.actionableStatus.account.id == account.accountId) {
if (status.actionableStatus.account.id == activeAccountFlow.value?.accountId) {
// never filter own posts
return Filter.Action.NONE
}
@ -198,38 +198,43 @@ abstract class TimelineViewModel(
}
private fun onPreferenceChanged(key: String) {
when (key) {
PrefKeys.TAB_FILTER_HOME_REPLIES -> {
val filter = accountManager.activeAccount?.isShowHomeReplies ?: true
val oldRemoveReplies = filterRemoveReplies
filterRemoveReplies = kind == Kind.HOME && !filter
if (oldRemoveReplies != filterRemoveReplies) {
fullReload()
activeAccountFlow.value?.let { activeAccount ->
when (key) {
PrefKeys.TAB_FILTER_HOME_REPLIES -> {
val filter = !activeAccount.isShowHomeReplies
val oldRemoveReplies = filterRemoveReplies
filterRemoveReplies = kind == Kind.HOME && !filter
if (oldRemoveReplies != filterRemoveReplies) {
fullReload()
}
}
}
PrefKeys.TAB_FILTER_HOME_BOOSTS -> {
val filter = accountManager.activeAccount?.isShowHomeBoosts ?: true
val oldRemoveReblogs = filterRemoveReblogs
filterRemoveReblogs = kind == Kind.HOME && !filter
if (oldRemoveReblogs != filterRemoveReblogs) {
fullReload()
PrefKeys.TAB_FILTER_HOME_BOOSTS -> {
val filter = !activeAccount.isShowHomeBoosts
val oldRemoveReblogs = filterRemoveReblogs
filterRemoveReblogs = kind == Kind.HOME && !filter
if (oldRemoveReblogs != filterRemoveReblogs) {
fullReload()
}
}
}
PrefKeys.TAB_SHOW_HOME_SELF_BOOSTS -> {
val filter = accountManager.activeAccount?.isShowHomeSelfBoosts ?: true
val oldRemoveSelfReblogs = filterRemoveSelfReblogs
filterRemoveSelfReblogs = kind == Kind.HOME && !filter
if (oldRemoveSelfReblogs != filterRemoveSelfReblogs) {
fullReload()
PrefKeys.TAB_SHOW_HOME_SELF_BOOSTS -> {
val filter = !activeAccount.isShowHomeSelfBoosts
val oldRemoveSelfReblogs = filterRemoveSelfReblogs
filterRemoveSelfReblogs = kind == Kind.HOME && !filter
if (oldRemoveSelfReblogs != filterRemoveSelfReblogs) {
fullReload()
}
}
PrefKeys.ALWAYS_SHOW_SENSITIVE_MEDIA -> {
// it is ok if only newly loaded statuses are affected, no need to fully refresh
alwaysShowSensitiveMedia = activeAccount.alwaysShowSensitiveMedia
}
PrefKeys.READING_ORDER -> {
readingOrder = ReadingOrder.from(sharedPreferences.getString(PrefKeys.READING_ORDER, null))
}
}
PrefKeys.ALWAYS_SHOW_SENSITIVE_MEDIA -> {
// it is ok if only newly loaded statuses are affected, no need to fully refresh
alwaysShowSensitiveMedia =
accountManager.activeAccount!!.alwaysShowSensitiveMedia
}
PrefKeys.READING_ORDER -> {
readingOrder = ReadingOrder.from(sharedPreferences.getString(PrefKeys.READING_ORDER, null))
}
}
}

View file

@ -17,42 +17,65 @@ package com.keylesspalace.tusky.db
import android.content.SharedPreferences
import android.util.Log
import androidx.room.withTransaction
import com.keylesspalace.tusky.db.dao.AccountDao
import com.keylesspalace.tusky.db.entity.AccountEntity
import com.keylesspalace.tusky.di.ApplicationScope
import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.entity.Status
import com.keylesspalace.tusky.settings.PrefKeys
import java.util.Locale
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.runBlocking
/**
* This class caches the account database and handles all account related operations
* @author ConnyDuck
* This class is the main interface to all account related operations.
*/
private const val TAG = "AccountManager"
@Singleton
class AccountManager @Inject constructor(
db: AppDatabase,
private val preferences: SharedPreferences
private val db: AppDatabase,
private val preferences: SharedPreferences,
@ApplicationScope private val applicationScope: CoroutineScope
) {
@Volatile
var activeAccount: AccountEntity? = null
private set
var accounts: MutableList<AccountEntity> = mutableListOf()
private set
private val accountDao: AccountDao = db.accountDao()
init {
accounts = accountDao.loadAll().toMutableList()
/** A StateFlow that will update everytime an account in the database changes, is added or removed.
* The first account is the currently active one.
*/
val accountsFlow: StateFlow<List<AccountEntity>> = runBlocking {
accountDao.allAccounts()
.stateIn(CoroutineScope(applicationScope.coroutineContext + Dispatchers.IO))
}
activeAccount = accounts.find { acc -> acc.isActive }
?: accounts.firstOrNull()?.also { acc -> acc.isActive = true }
/** A snapshot of all accounts in the database with the active account first */
val accounts: List<AccountEntity>
get() = accountsFlow.value
/** A snapshot currently active account, if there is one */
val activeAccount: AccountEntity?
get() = accounts.firstOrNull()
/** Returns a StateFlow for updates to the currently active account.
* Note that always the same account will be emitted,
* even if it is no longer active and that it will emit null when the account got removed.
* @param scope the [CoroutineScope] this flow will be active in.
*/
fun activeAccount(scope: CoroutineScope): StateFlow<AccountEntity?> {
val activeAccount = activeAccount
return accountsFlow.map { accounts ->
accounts.find { account -> activeAccount?.id == account.id }
}.stateIn(scope, SharingStarted.Lazily, activeAccount)
}
/**
@ -64,34 +87,32 @@ class AccountManager @Inject constructor(
* @param oauthScopes the oauth scopes granted to the account
* @param newAccount the [Account] as returned by the Mastodon Api
*/
fun addAccount(
suspend fun addAccount(
accessToken: String,
domain: String,
clientId: String,
clientSecret: String,
oauthScopes: String,
newAccount: Account
) {
) = db.withTransaction {
activeAccount?.let {
it.isActive = false
Log.d(TAG, "addAccount: saving account with id " + it.id)
accountDao.insertOrReplace(it)
accountDao.insertOrReplace(it.copy(isActive = false))
}
// check if this is a relogin with an existing account, if yes update it, otherwise create a new one
val existingAccountIndex = accounts.indexOfFirst { account ->
val existingAccount = accounts.find { account ->
domain == account.domain && newAccount.id == account.accountId
}
val newAccountEntity = if (existingAccountIndex != -1) {
accounts[existingAccountIndex].copy(
val newAccountEntity = if (existingAccount != null) {
existingAccount.copy(
accessToken = accessToken,
clientId = clientId,
clientSecret = clientSecret,
oauthScopes = oauthScopes,
isActive = true
).also { accounts[existingAccountIndex] = it }
)
} else {
val maxAccountId = accounts.maxByOrNull { it.id }?.id ?: 0
val maxAccountId = accounts.maxOfOrNull { it.id } ?: 0
val newAccountId = maxAccountId + 1
AccountEntity(
id = newAccountId,
@ -102,104 +123,85 @@ class AccountManager @Inject constructor(
oauthScopes = oauthScopes,
isActive = true,
accountId = newAccount.id
).also { accounts.add(it) }
)
}
activeAccount = newAccountEntity
updateAccount(newAccountEntity, newAccount)
}
/**
* Saves an already known account to the database.
* New accounts must be created with [addAccount]
* @param account the account to save
* @param account The account to save
* @param changer make the changes to save here - this is to make sure no stale data gets re-saved to the database
*/
fun saveAccount(account: AccountEntity) {
if (account.id != 0L) {
Log.d(TAG, "saveAccount: saving account with id " + account.id)
accountDao.insertOrReplace(account)
suspend fun updateAccount(account: AccountEntity, changer: AccountEntity.() -> AccountEntity) {
accounts.find { it.id == account.id }?.let { acc ->
Log.d(TAG, "updateAccount: saving account with id " + acc.id)
accountDao.insertOrReplace(changer(acc))
}
}
/**
* Logs an account out by deleting all its data.
* @return the new active account, or null if no other account was found
*/
fun logout(account: AccountEntity): AccountEntity? {
account.logout()
accounts.remove(account)
accountDao.delete(account)
if (accounts.size > 0) {
accounts[0].isActive = true
activeAccount = accounts[0]
Log.d(TAG, "logActiveAccountOut: saving account with id " + accounts[0].id)
accountDao.insertOrReplace(accounts[0])
} else {
activeAccount = null
}
return activeAccount
}
/**
* Updates an account with new information from the Mastodon api
* and saves it in the database.
* @param accountEntity the [AccountEntity] to update
* @param account the [Account] object which the newest data from the api
*/
fun updateAccount(accountEntity: AccountEntity, account: Account) {
accountEntity.accountId = account.id
accountEntity.username = account.username
accountEntity.displayName = account.name
accountEntity.profilePictureUrl = account.avatar
accountEntity.defaultPostPrivacy = account.source?.privacy ?: Status.Visibility.PUBLIC
accountEntity.defaultPostLanguage = account.source?.language.orEmpty()
accountEntity.defaultMediaSensitivity = account.source?.sensitive ?: false
accountEntity.emojis = account.emojis
accountEntity.locked = account.locked
suspend fun updateAccount(accountEntity: AccountEntity, account: Account) {
// make sure no stale data gets re-saved to the database
val accountToUpdate = accounts.find { it.id == accountEntity.id } ?: accountEntity
Log.d(TAG, "updateAccount: saving account with id " + accountEntity.id)
accountDao.insertOrReplace(accountEntity)
val newAccount = accountToUpdate.copy(
accountId = account.id,
username = account.username,
displayName = account.name,
profilePictureUrl = account.avatar,
profileHeaderUrl = account.header,
defaultPostPrivacy = account.source?.privacy ?: Status.Visibility.PUBLIC,
defaultPostLanguage = account.source?.language.orEmpty(),
defaultMediaSensitivity = account.source?.sensitive == true,
emojis = account.emojis,
locked = account.locked
)
Log.d(TAG, "updateAccount: saving account with id " + accountToUpdate.id)
accountDao.insertOrReplace(newAccount)
}
/**
* changes the active account
* Removes an account from the database.
* @return the new active account, or null if no other account was found
*/
suspend fun remove(account: AccountEntity): AccountEntity? = db.withTransaction {
Log.d(TAG, "remove: deleting account with id " + account.id)
accountDao.delete(account)
accounts.find { it.id != account.id }?.let { otherAccount ->
val otherAccountActive = otherAccount.copy(
isActive = true
)
Log.d(TAG, "remove: saving account with id " + otherAccountActive.id)
accountDao.insertOrReplace(otherAccountActive)
otherAccountActive
}
}
/**
* Changes the active account
* @param accountId the database id of the new active account
*/
fun setActiveAccount(accountId: Long) {
suspend fun setActiveAccount(accountId: Long) = db.withTransaction {
Log.d(TAG, "setActiveAccount $accountId")
val newActiveAccount = accounts.find { (id) ->
id == accountId
} ?: return // invalid accountId passed, do nothing
} ?: return@withTransaction // invalid accountId passed, do nothing
activeAccount?.let {
Log.d(TAG, "setActiveAccount: saving account with id " + it.id)
it.isActive = false
saveAccount(it)
accountDao.insertOrReplace(it.copy(isActive = false))
}
activeAccount = newActiveAccount
activeAccount?.let {
it.isActive = true
accountDao.insertOrReplace(it)
}
}
/**
* @return an immutable list of all accounts in the database with the active account first
*/
fun getAllAccountsOrderedByActive(): List<AccountEntity> {
val accountsCopy = accounts.toMutableList()
accountsCopy.sortWith { l, r ->
when {
l.isActive && !r.isActive -> -1
r.isActive && !l.isActive -> 1
else -> 0
}
}
return accountsCopy
accountDao.insertOrReplace(newActiveAccount.copy(isActive = true))
}
/**

View file

@ -62,7 +62,7 @@ import java.io.File;
},
// Note: Starting with version 54, database versions in Tusky are always even.
// This is to reserve odd version numbers for use by forks.
version = 64,
version = 66,
autoMigrations = {
@AutoMigration(from = 48, to = 49),
@AutoMigration(from = 49, to = 50, spec = AppDatabase.MIGRATION_49_50.class),
@ -70,7 +70,8 @@ import java.io.File;
@AutoMigration(from = 51, to = 52),
@AutoMigration(from = 53, to = 54), // hasDirectMessageBadge in AccountEntity
@AutoMigration(from = 56, to = 58), // translationEnabled in InstanceEntity/InstanceInfoEntity
@AutoMigration(from = 62, to = 64) // filterV2Available in InstanceEntity
@AutoMigration(from = 62, to = 64), // filterV2Available in InstanceEntity
@AutoMigration(from = 64, to = 66), // added profileHeaderUrl to AccountEntity
}
)
public abstract class AppDatabase extends RoomDatabase {

View file

@ -38,13 +38,13 @@ import kotlinx.coroutines.launch
private const val TAG = "DraftsAlert"
@Singleton
class DraftsAlert @Inject constructor(db: AppDatabase) {
class DraftsAlert @Inject constructor(
db: AppDatabase,
private val accountManager: AccountManager
) {
// For tracking when a media upload fails in the service
private val draftDao: DraftDao = db.draftDao()
@Inject
lateinit var accountManager: AccountManager
fun <T> observeInContext(context: T, showAlert: Boolean) where T : Context, T : LifecycleOwner {
accountManager.activeAccount?.let { activeAccount ->
val coroutineScope = context.lifecycleScope

View file

@ -21,15 +21,16 @@ import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.keylesspalace.tusky.db.entity.AccountEntity
import kotlinx.coroutines.flow.Flow
@Dao
interface AccountDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insertOrReplace(account: AccountEntity): Long
suspend fun insertOrReplace(account: AccountEntity): Long
@Delete
fun delete(account: AccountEntity)
suspend fun delete(account: AccountEntity)
@Query("SELECT * FROM AccountEntity ORDER BY id ASC")
fun loadAll(): List<AccountEntity>
@Query("SELECT * FROM AccountEntity ORDER BY isActive DESC")
fun allAccounts(): Flow<List<AccountEntity>>
}

View file

@ -37,86 +37,88 @@ import com.keylesspalace.tusky.settings.DefaultReplyVisibility
)
@TypeConverters(Converters::class)
data class AccountEntity(
@field:PrimaryKey(autoGenerate = true) var id: Long,
@field:PrimaryKey(autoGenerate = true) val id: Long,
val domain: String,
var accessToken: String,
val accessToken: String,
// nullable for backward compatibility
var clientId: String?,
val clientId: String?,
// nullable for backward compatibility
var clientSecret: String?,
var isActive: Boolean,
var accountId: String = "",
var username: String = "",
var displayName: String = "",
var profilePictureUrl: String = "",
var notificationsEnabled: Boolean = true,
var notificationsMentioned: Boolean = true,
var notificationsFollowed: Boolean = true,
var notificationsFollowRequested: Boolean = false,
var notificationsReblogged: Boolean = true,
var notificationsFavorited: Boolean = true,
var notificationsPolls: Boolean = true,
var notificationsSubscriptions: Boolean = true,
var notificationsSignUps: Boolean = true,
var notificationsUpdates: Boolean = true,
var notificationsReports: Boolean = true,
var notificationSound: Boolean = true,
var notificationVibration: Boolean = true,
var notificationLight: Boolean = true,
var defaultPostPrivacy: Status.Visibility = Status.Visibility.PUBLIC,
var defaultReplyPrivacy: DefaultReplyVisibility = DefaultReplyVisibility.MATCH_DEFAULT_POST_VISIBILITY,
var defaultMediaSensitivity: Boolean = false,
var defaultPostLanguage: String = "",
var alwaysShowSensitiveMedia: Boolean = false,
val clientSecret: String?,
val isActive: Boolean,
val accountId: String = "",
val username: String = "",
val displayName: String = "",
val profilePictureUrl: String = "",
@ColumnInfo(defaultValue = "") val profileHeaderUrl: String = "",
val notificationsEnabled: Boolean = true,
val notificationsMentioned: Boolean = true,
val notificationsFollowed: Boolean = true,
val notificationsFollowRequested: Boolean = false,
val notificationsReblogged: Boolean = true,
val notificationsFavorited: Boolean = true,
val notificationsPolls: Boolean = true,
val notificationsSubscriptions: Boolean = true,
val notificationsSignUps: Boolean = true,
val notificationsUpdates: Boolean = true,
val notificationsReports: Boolean = true,
val notificationSound: Boolean = true,
val notificationVibration: Boolean = true,
val notificationLight: Boolean = true,
val defaultPostPrivacy: Status.Visibility = Status.Visibility.PUBLIC,
val defaultReplyPrivacy: DefaultReplyVisibility = DefaultReplyVisibility.MATCH_DEFAULT_POST_VISIBILITY,
val defaultMediaSensitivity: Boolean = false,
val defaultPostLanguage: String = "",
val alwaysShowSensitiveMedia: Boolean = false,
/** True if content behind a content warning is shown by default */
var alwaysOpenSpoiler: Boolean = false,
@ColumnInfo(defaultValue = "0")
val alwaysOpenSpoiler: Boolean = false,
/**
* True if the "Download media previews" preference is true. This implies
* that media previews are shown as well as downloaded.
*/
var mediaPreviewEnabled: Boolean = true,
val mediaPreviewEnabled: Boolean = true,
/**
* ID of the last notification the user read on the Notification, list, and should be restored
* to view when the user returns to the list.
*
* May not be the ID of the most recent notification if the user has scrolled down the list.
*/
var lastNotificationId: String = "0",
val lastNotificationId: String = "0",
/**
* ID of the most recent Mastodon notification that Tusky has fetched to show as an
* Android notification.
*/
@ColumnInfo(defaultValue = "0")
var notificationMarkerId: String = "0",
var emojis: List<Emoji> = emptyList(),
var tabPreferences: List<TabData> = defaultTabs(),
var notificationsFilter: String = "[\"follow_request\"]",
val notificationMarkerId: String = "0",
val emojis: List<Emoji> = emptyList(),
val tabPreferences: List<TabData> = defaultTabs(),
val notificationsFilter: String = "[\"follow_request\"]",
// Scope cannot be changed without re-login, so store it in case
// the scope needs to be changed in the future
var oauthScopes: String = "",
var unifiedPushUrl: String = "",
var pushPubKey: String = "",
var pushPrivKey: String = "",
var pushAuth: String = "",
var pushServerKey: String = "",
val oauthScopes: String = "",
val unifiedPushUrl: String = "",
val pushPubKey: String = "",
val pushPrivKey: String = "",
val pushAuth: String = "",
val pushServerKey: String = "",
/**
* ID of the status at the top of the visible list in the home timeline when the
* user navigated away.
*/
var lastVisibleHomeTimelineStatusId: String? = null,
val lastVisibleHomeTimelineStatusId: String? = null,
/** true if the connected Mastodon account is locked (has to manually approve all follow requests **/
@ColumnInfo(defaultValue = "0")
var locked: Boolean = false,
val locked: Boolean = false,
@ColumnInfo(defaultValue = "0")
var hasDirectMessageBadge: Boolean = false,
val hasDirectMessageBadge: Boolean = false,
var isShowHomeBoosts: Boolean = true,
var isShowHomeReplies: Boolean = true,
var isShowHomeSelfBoosts: Boolean = true
val isShowHomeBoosts: Boolean = true,
val isShowHomeReplies: Boolean = true,
val isShowHomeSelfBoosts: Boolean = true
) {
val identifier: String
@ -124,30 +126,4 @@ data class AccountEntity(
val fullName: String
get() = "@$username@$domain"
fun logout() {
// deleting credentials so they cannot be used again
accessToken = ""
clientId = null
clientSecret = null
}
fun isLoggedIn() = accessToken.isNotEmpty()
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as AccountEntity
if (id == other.id) return true
return domain == other.domain && accountId == other.accountId
}
override fun hashCode(): Int {
var result = id.hashCode()
result = 31 * result + domain.hashCode()
result = 31 * result + accountId.hashCode()
return result
}
}

View file

@ -46,7 +46,6 @@ object StorageModule {
fun providesDatabase(@ApplicationContext appContext: Context, converters: Converters): AppDatabase {
return Room.databaseBuilder(appContext, AppDatabase::class.java, "tuskyDB")
.addTypeConverter(converters)
.allowMainThreadQueries()
.addMigrations(
AppDatabase.MIGRATION_2_3, AppDatabase.MIGRATION_3_4, AppDatabase.MIGRATION_4_5,
AppDatabase.MIGRATION_5_6, AppDatabase.MIGRATION_6_7, AppDatabase.MIGRATION_7_8,

View file

@ -18,5 +18,5 @@ interface AccountActionListener {
fun onViewAccount(id: String)
fun onMute(mute: Boolean, id: String, position: Int, notifications: Boolean)
fun onBlock(block: Boolean, id: String, position: Int)
fun onRespondToFollowRequest(accept: Boolean, id: String, position: Int)
fun onRespondToFollowRequest(accept: Boolean, accountIdRequestingFollow: String, position: Int)
}

View file

@ -1,22 +0,0 @@
/*
* Copyright 2023 Tusky Contributors
*
* This file is a part of Tusky.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>.
*/
package com.keylesspalace.tusky.interfaces
interface FabFragment {
fun isFabVisible(): Boolean
}

View file

@ -20,7 +20,7 @@ import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.os.Build
import com.keylesspalace.tusky.components.systemnotifications.canEnablePushNotifications
import com.keylesspalace.tusky.components.systemnotifications.isUnifiedPushAvailable
import com.keylesspalace.tusky.components.systemnotifications.isUnifiedPushNotificationEnabledForAccount
import com.keylesspalace.tusky.components.systemnotifications.updateUnifiedPushSubscription
import com.keylesspalace.tusky.db.AccountManager
@ -45,7 +45,7 @@ class NotificationBlockStateBroadcastReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (Build.VERSION.SDK_INT < 28) return
if (!canEnablePushNotifications(context, accountManager)) return
if (!isUnifiedPushAvailable(context)) return
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager

View file

@ -30,18 +30,18 @@ class AccountPreferenceDataStore @Inject constructor(
}
override fun putBoolean(key: String, value: Boolean) {
when (key) {
PrefKeys.ALWAYS_SHOW_SENSITIVE_MEDIA -> account.alwaysShowSensitiveMedia = value
PrefKeys.ALWAYS_OPEN_SPOILER -> account.alwaysOpenSpoiler = value
PrefKeys.MEDIA_PREVIEW_ENABLED -> account.mediaPreviewEnabled = value
PrefKeys.TAB_FILTER_HOME_BOOSTS -> account.isShowHomeBoosts = value
PrefKeys.TAB_FILTER_HOME_REPLIES -> account.isShowHomeReplies = value
PrefKeys.TAB_SHOW_HOME_SELF_BOOSTS -> account.isShowHomeSelfBoosts = value
}
accountManager.saveAccount(account)
externalScope.launch {
accountManager.updateAccount(account) {
when (key) {
PrefKeys.ALWAYS_SHOW_SENSITIVE_MEDIA -> copy(alwaysShowSensitiveMedia = value)
PrefKeys.ALWAYS_OPEN_SPOILER -> copy(alwaysOpenSpoiler = value)
PrefKeys.MEDIA_PREVIEW_ENABLED -> copy(mediaPreviewEnabled = value)
PrefKeys.TAB_FILTER_HOME_BOOSTS -> copy(isShowHomeBoosts = value)
PrefKeys.TAB_FILTER_HOME_REPLIES -> copy(isShowHomeReplies = value)
PrefKeys.TAB_SHOW_HOME_SELF_BOOSTS -> copy(isShowHomeSelfBoosts = value)
else -> this
}
}
eventHub.dispatch(PreferenceChangedEvent(key))
}
}

View file

@ -50,7 +50,7 @@ class LogoutUsecase @Inject constructor(
NotificationHelper.deleteNotificationChannelsForAccount(account, context)
// remove account from local AccountManager
val otherAccountAvailable = accountManager.logout(account) != null
val otherAccountAvailable = accountManager.remove(account) != null
// clear the database - this could trigger network calls so do it last when all tokens are gone
databaseCleaner.cleanupEverything(account.id)

View file

@ -1,14 +0,0 @@
package com.keylesspalace.tusky.util
import androidx.paging.PagingSource
import androidx.paging.PagingState
class EmptyPagingSource<T : Any> : PagingSource<Int, T>() {
override fun getRefreshKey(state: PagingState<Int, T>): Int? = null
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, T> = LoadResult.Page(
emptyList(),
null,
null
)
}

View file

@ -25,6 +25,7 @@ import com.keylesspalace.tusky.appstore.EventHub
import com.keylesspalace.tusky.appstore.ProfileEditedEvent
import com.keylesspalace.tusky.components.instanceinfo.InstanceInfo
import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.entity.Account
import com.keylesspalace.tusky.entity.StringField
import com.keylesspalace.tusky.network.MastodonApi
@ -65,9 +66,12 @@ class EditProfileViewModel @Inject constructor(
private val mastodonApi: MastodonApi,
private val eventHub: EventHub,
private val application: Application,
private val accountManager: AccountManager,
instanceInfoRepo: InstanceInfoRepository
) : ViewModel() {
private val activeAccount = accountManager.activeAccount!!
private val _profileData = MutableStateFlow(null as Resource<Account>?)
val profileData: StateFlow<Resource<Account>?> = _profileData.asStateFlow()
@ -169,8 +173,9 @@ class EditProfileViewModel @Inject constructor(
diff.field4?.second?.toRequestBody(MultipartBody.FORM)
).fold(
{ newAccountData ->
_saveData.value = Success()
accountManager.updateAccount(activeAccount, newAccountData)
eventHub.dispatch(ProfileEditedEvent(newAccountData))
_saveData.value = Success()
},
{ throwable ->
_saveData.value = Error(errorMessage = throwable.getServerErrorMessage())

View file

@ -29,13 +29,14 @@ import com.keylesspalace.tusky.components.systemnotifications.NotificationHelper
import com.keylesspalace.tusky.components.systemnotifications.NotificationHelper.NOTIFICATION_ID_PRUNE_CACHE
import com.keylesspalace.tusky.db.AccountManager
import com.keylesspalace.tusky.db.DatabaseCleaner
import com.keylesspalace.tusky.util.deleteStaleCachedMedia
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
/** Prune the database cache of old statuses. */
@HiltWorker
class PruneCacheWorker @AssistedInject constructor(
@Assisted appContext: Context,
@Assisted private val appContext: Context,
@Assisted workerParams: WorkerParameters,
private val databaseCleaner: DatabaseCleaner,
private val accountManager: AccountManager
@ -50,6 +51,9 @@ class PruneCacheWorker @AssistedInject constructor(
Log.d(TAG, "Pruning database using account ID: ${account.id}")
databaseCleaner.cleanupOldData(account.id, MAX_HOMETIMELINE_ITEMS_IN_CACHE, MAX_NOTIFICATIONS_IN_CACHE)
}
deleteStaleCachedMedia(appContext.getExternalFilesDir("Tusky"))
return Result.success()
}