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:
parent
d93ec6822b
commit
9e597800c9
47 changed files with 2421 additions and 1127 deletions
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
259
app/src/main/java/com/keylesspalace/tusky/BaseActivity.kt
Normal file
259
app/src/main/java/com/keylesspalace/tusky/BaseActivity.kt
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
188
app/src/main/java/com/keylesspalace/tusky/MainViewModel.kt
Normal file
188
app/src/main/java/com/keylesspalace/tusky/MainViewModel.kt
Normal 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"
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 -> {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = ""
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 ->
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue