Moved AccountViewModel to AccountStateManager with a Process Lifecycle (better fit a multi-activities app).

This commit is contained in:
Neil Marietta 2021-04-28 15:04:32 +02:00
parent 97303144f3
commit 488c396fe7
11 changed files with 92 additions and 68 deletions

View File

@ -70,7 +70,7 @@ import ch.protonmail.android.core.NetworkResults;
import ch.protonmail.android.core.ProtonMailApplication;
import ch.protonmail.android.core.QueueNetworkUtil;
import ch.protonmail.android.core.UserManager;
import ch.protonmail.android.feature.account.AccountViewModel;
import ch.protonmail.android.feature.account.AccountStateManager;
import ch.protonmail.android.jobs.organizations.GetOrganizationJob;
import ch.protonmail.android.settings.pin.ValidatePinActivity;
import ch.protonmail.android.utils.AppUtil;
@ -87,8 +87,6 @@ public abstract class BaseActivity extends AppCompatActivity implements INetwork
public static final String EXTRA_IN_APP = "extra_in_app";
public static final int REQUEST_CODE_VALIDATE_PIN = 998;
protected AccountViewModel accountViewModel;
private ProtonMailApplication app;
@Deprecated // Doesn't make sense for this to be injected nor be used to sub-classes, as it can
@ -104,6 +102,8 @@ public abstract class BaseActivity extends AppCompatActivity implements INetwork
// directly, as are aiming to remove this base class
protected UserManager mUserManager;
@Inject
protected AccountStateManager accountStateManager;
@Inject
protected JobManager mJobManager;
@Inject
protected QueueNetworkUtil mNetworkUtil;
@ -168,7 +168,7 @@ public abstract class BaseActivity extends AppCompatActivity implements INetwork
}
}
mCurrentLocale = app.getCurrentLocale();
setupViewModels(this);
accountStateManager.register(this);
buildHtmlProcessor();
setContentView(getLayoutId());
@ -179,12 +179,6 @@ public abstract class BaseActivity extends AppCompatActivity implements INetwork
}
}
private void setupViewModels(ComponentActivity activity) {
ViewModelProvider viewModelProvider = new ViewModelProvider(this);
accountViewModel = viewModelProvider.get(AccountViewModel.class);
accountViewModel.register(activity);
}
@Override
protected void onNewIntent(Intent intent) {
super.onNewIntent(intent);
@ -400,7 +394,7 @@ public abstract class BaseActivity extends AppCompatActivity implements INetwork
Button btnLogout = dialogView.findViewById(R.id.logout);
btnLogout.setOnClickListener(v -> {
accountViewModel.logoutPrimary().invokeOnCompletion(throwable -> {
accountStateManager.logoutPrimary().invokeOnCompletion(throwable -> {
finish();
return null;
});

View File

@ -54,7 +54,7 @@ import ch.protonmail.android.core.UserManager
import ch.protonmail.android.data.local.MessageDatabase
import ch.protonmail.android.domain.entity.Id
import ch.protonmail.android.domain.entity.user.User
import ch.protonmail.android.feature.account.AccountViewModel
import ch.protonmail.android.feature.account.AccountStateManager
import ch.protonmail.android.jobs.FetchMessageCountsJob
import ch.protonmail.android.mapper.LabelUiModelMapper
import ch.protonmail.android.prefs.SecureSharedPreferences
@ -191,12 +191,12 @@ abstract class NavigationActivity :
accountsAdapter.onItemClick = { account ->
if (account is DrawerUserModel.BaseUser) {
accountViewModel.switch(account.id)
accountStateManager.switch(account.id)
}
}
}
protected open fun onAccountSwitched(switch: AccountViewModel.AccountSwitch) {
protected open fun onAccountSwitched(switch: AccountStateManager.AccountSwitch) {
val message = switch.current?.username?.takeIf { switch.previous != null }?.let {
String.format(getString(R.string.signed_in_with), switch.current.username)
}
@ -217,21 +217,20 @@ abstract class NavigationActivity :
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
accountViewModel.state
accountStateManager.state
.flowWithLifecycle(lifecycle, Lifecycle.State.CREATED)
.onEach {
when (it) {
is AccountViewModel.State.Processing,
is AccountViewModel.State.LoginClosed,
is AccountViewModel.State.PrimaryExist -> Unit
is AccountViewModel.State.AccountNeeded -> {
is AccountStateManager.State.Processing,
is AccountStateManager.State.PrimaryExist -> Unit
is AccountStateManager.State.AccountNeeded -> {
startSplashActivity()
finish()
}
}
}.launchIn(lifecycleScope)
accountViewModel.onAccountSwitched()
accountStateManager.onAccountSwitched()
.flowWithLifecycle(lifecycle, Lifecycle.State.CREATED)
.onEach { switch -> onAccountSwitched(switch) }
.launchIn(lifecycleScope)
@ -270,8 +269,8 @@ abstract class NavigationActivity :
// Requested UserId match the current ?
intent.extras?.getString(EXTRA_USER_ID)?.let { extraUserId ->
val requestedUserId = UserId(extraUserId)
if (requestedUserId != accountViewModel.getPrimaryUserIdValue()) {
accountViewModel.switch(requestedUserId)
if (requestedUserId != accountStateManager.getPrimaryUserIdValue()) {
accountStateManager.switch(requestedUserId)
}
}
}
@ -349,7 +348,7 @@ abstract class NavigationActivity :
navigationViewModel.notificationsCounts()
navigationViewModel.notificationsCounterLiveData.observe(this) { counters ->
lifecycleScope.launchWhenCreated {
val accounts = accountViewModel.getSortedAccounts().first().map { account ->
val accounts = accountStateManager.getSortedAccounts().first().map { account ->
val id = Id(account.userId.id)
val user = userManager.getLegacyUserOrNull(id)
account.toDrawerUser(account.isReady(), counters[id] ?: 0, user)
@ -469,7 +468,7 @@ abstract class NavigationActivity :
fun onSignOutSelected() {
fun onLogoutConfirmed(currentUserId: Id) {
accountViewModel.logout(currentUserId)
accountStateManager.logout(currentUserId)
}
lifecycleScope.launch {

View File

@ -24,7 +24,7 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.flowWithLifecycle
import androidx.lifecycle.lifecycleScope
import ch.protonmail.android.R
import ch.protonmail.android.feature.account.AccountViewModel
import ch.protonmail.android.feature.account.AccountStateManager
import ch.protonmail.android.utils.startMailboxActivity
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
@ -38,18 +38,21 @@ class SplashActivity : BaseActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
accountViewModel.state
// Start Login or MailboxActivity.
accountStateManager.state
.flowWithLifecycle(lifecycle, Lifecycle.State.CREATED)
.onEach {
when (it) {
is AccountViewModel.State.Processing -> Unit
is AccountViewModel.State.LoginClosed -> finish()
is AccountViewModel.State.AccountNeeded -> accountViewModel.login()
is AccountViewModel.State.PrimaryExist -> {
is AccountStateManager.State.Processing -> Unit
is AccountStateManager.State.AccountNeeded -> accountStateManager.login()
is AccountStateManager.State.PrimaryExist -> {
startMailboxActivity()
finish()
}
}
}.launchIn(lifecycleScope)
// Finish if Login closed.
accountStateManager.onLoginClosed { finish() }
}
}

View File

@ -134,7 +134,7 @@ import ch.protonmail.android.fcm.FcmTokenManager
import ch.protonmail.android.fcm.MultiUserFcmTokenManager
import ch.protonmail.android.fcm.PMRegistrationWorker
import ch.protonmail.android.fcm.model.FirebaseToken
import ch.protonmail.android.feature.account.AccountViewModel
import ch.protonmail.android.feature.account.AccountStateManager
import ch.protonmail.android.jobs.EmptyFolderJob
import ch.protonmail.android.jobs.FetchByLocationJob
import ch.protonmail.android.jobs.FetchLabelsJob
@ -584,7 +584,7 @@ class MailboxActivity :
}
}
override fun onAccountSwitched(switch: AccountViewModel.AccountSwitch) {
override fun onAccountSwitched(switch: AccountStateManager.AccountSwitch) {
super.onAccountSwitched(switch)
val currentUserId = userManager.currentUserId ?: return
@ -806,7 +806,7 @@ class MailboxActivity :
super.onResume()
if (mailboxViewModel.userId != userManager.currentUserId) {
onAccountSwitched(AccountViewModel.AccountSwitch())
onAccountSwitched(AccountStateManager.AccountSwitch())
}
reloadMessageCounts()

View File

@ -218,7 +218,7 @@ internal class MessageDetailsActivity :
onPositiveButtonClicked = {
lifecycleScope.launchWhenCreated {
val userId = checkNotNull(messageRecipientUserId) { "Username found in extras, but user id" }
accountViewModel.switch(userId)
accountStateManager.switch(userId)
continueSetup()
invalidateOptionsMenu()
}

View File

@ -65,15 +65,15 @@ class AccountManagerActivity : BaseActivity() {
window?.setBarColors(getColor(R.color.new_purple_dark))
accountsAdapter.apply {
onLoginAccount = { userId -> accountViewModel.login() }
onLoginAccount = { userId -> accountStateManager.login() }
onLogoutAccount = { userId -> onLogoutClicked(userId) }
onRemoveAccount = { userId -> onRemoveClicked(userId) }
}
accountsRecyclerView.layoutManager = LinearLayoutManager(this)
accountsRecyclerView.adapter = accountsAdapter
accountViewModel.getSortedAccounts()
.combine(accountViewModel.getPrimaryUserId()) { accounts, primaryUserId -> accounts to primaryUserId }
accountStateManager.getSortedAccounts()
.combine(accountStateManager.getPrimaryUserId()) { accounts, primaryUserId -> accounts to primaryUserId }
.flowWithLifecycle(lifecycle, Lifecycle.State.CREATED)
.onEach { (sortedAccounts, primaryUserId) ->
val accounts = sortedAccounts.map { account ->
@ -87,13 +87,13 @@ class AccountManagerActivity : BaseActivity() {
private fun onRemoveClicked(userId: UserId) {
lifecycleScope.launchWhenCreated {
val account = checkNotNull(accountViewModel.getAccountOrNull(userId))
val account = checkNotNull(accountStateManager.getAccountOrNull(userId))
val username = account.username
showTwoButtonInfoDialog(
titleStringId = R.string.logout,
message = getString(R.string.remove_account_question, username)
) {
accountViewModel.remove(userId)
accountStateManager.remove(userId)
}
}
}
@ -110,7 +110,7 @@ class AccountManagerActivity : BaseActivity() {
}
showTwoButtonInfoDialog(title = title, message = message) {
accountViewModel.logout(userId)
accountStateManager.logout(userId)
}
}
}
@ -129,7 +129,7 @@ class AccountManagerActivity : BaseActivity() {
}
R.id.action_remove_all -> {
showToast(R.string.account_manager_remove_all_accounts)
accountViewModel.removeAll()
accountStateManager.removeAll()
true
}
else -> {

View File

@ -18,6 +18,8 @@
package ch.protonmail.android.di
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@ -32,4 +34,9 @@ object CoreAppModule {
@Provides
@Singleton
fun provideProduct(): Product = Product.Mail
@Provides
@Singleton
@AppProcessLifecycleOwner
fun provideAppProcessLifecycleOwner(): LifecycleOwner = ProcessLifecycleOwner.get()
}

View File

@ -31,6 +31,11 @@ annotation class AlternativeApiPins
@Target(allowedTargets = [AnnotationTarget.VALUE_PARAMETER, AnnotationTarget.FUNCTION])
annotation class AppCoroutineScope
@Qualifier
@Retention(AnnotationRetention.BINARY)
@Target(allowedTargets = [AnnotationTarget.VALUE_PARAMETER, AnnotationTarget.FUNCTION])
annotation class AppProcessLifecycleOwner
@Qualifier
@Retention(AnnotationRetention.BINARY)
@Target(allowedTargets = [AnnotationTarget.VALUE_PARAMETER, AnnotationTarget.FUNCTION])

View File

@ -20,14 +20,14 @@ package ch.protonmail.android.feature.account
import androidx.activity.ComponentActivity
import androidx.core.content.edit
import androidx.hilt.lifecycle.ViewModelInject
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.flowWithLifecycle
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.lifecycleScope
import ch.protonmail.android.api.segments.event.EventManager
import ch.protonmail.android.core.Constants
import ch.protonmail.android.core.PREF_PIN
import ch.protonmail.android.di.AppProcessLifecycleOwner
import ch.protonmail.android.domain.entity.Id
import ch.protonmail.android.feature.user.waitPrimaryKeyPassphraseAvailable
import ch.protonmail.android.usecase.delete.ClearUserData
@ -35,6 +35,7 @@ import ch.protonmail.android.usecase.fetch.LaunchInitialDataFetch
import ch.protonmail.android.utils.AppUtil
import ch.protonmail.libs.core.preferences.clearAll
import com.birbit.android.jobqueue.JobManager
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.filter
@ -45,6 +46,7 @@ import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.scan
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import me.proton.core.account.domain.entity.Account
import me.proton.core.account.domain.entity.AccountType
import me.proton.core.account.domain.entity.isDisabled
@ -56,6 +58,7 @@ import me.proton.core.accountmanager.presentation.onAccountCreateAddressFailed
import me.proton.core.accountmanager.presentation.onAccountCreateAddressNeeded
import me.proton.core.accountmanager.presentation.onAccountDisabled
import me.proton.core.accountmanager.presentation.onAccountReady
import me.proton.core.accountmanager.presentation.onAccountRemoved
import me.proton.core.accountmanager.presentation.onAccountTwoPassModeFailed
import me.proton.core.accountmanager.presentation.onAccountTwoPassModeNeeded
import me.proton.core.accountmanager.presentation.onSessionHumanVerificationNeeded
@ -64,8 +67,12 @@ import me.proton.core.auth.presentation.AuthOrchestrator
import me.proton.core.auth.presentation.onLoginResult
import me.proton.core.domain.entity.UserId
import me.proton.core.user.domain.UserManager
import me.proton.core.util.kotlin.DispatcherProvider
import javax.inject.Inject
import javax.inject.Singleton
class AccountViewModel @ViewModelInject constructor(
@Singleton
class AccountStateManager @Inject constructor(
private val accountManager: AccountManager,
private val userManager: UserManager,
private var authOrchestrator: AuthOrchestrator,
@ -73,33 +80,36 @@ class AccountViewModel @ViewModelInject constructor(
private val jobManager: JobManager,
private val oldUserManager: ch.protonmail.android.core.UserManager,
private val launchInitialDataFetch: LaunchInitialDataFetch,
private val clearUserData: ClearUserData
) : ViewModel() {
private val clearUserData: ClearUserData,
@AppProcessLifecycleOwner
private val lifecycleOwner: LifecycleOwner,
private val dispatchers: DispatcherProvider
) {
private val scope = lifecycleOwner.lifecycleScope
private val lifecycle = lifecycleOwner.lifecycle
private val _state = MutableStateFlow(State.Processing as State)
sealed class State {
object Processing : State()
object LoginClosed : State()
object AccountNeeded : State()
object PrimaryExist : State()
}
val state = _state.asStateFlow()
fun register(context: ComponentActivity) {
authOrchestrator.register(context)
// Handle Account states.
init {
with(authOrchestrator) {
onLoginResult { result -> if (result == null) _state.tryEmit(State.LoginClosed) }
accountManager.observe(context.lifecycle, minActiveState = Lifecycle.State.CREATED)
// Observe all Accounts States (need a registered authOrchestrator, see register).
accountManager.observe(lifecycle, minActiveState = Lifecycle.State.CREATED)
.onSessionHumanVerificationNeeded { startHumanVerificationWorkflow(it) }
.onSessionSecondFactorNeeded { startSecondFactorWorkflow(it) }
.onAccountTwoPassModeNeeded { startTwoPassModeWorkflow(it) }
.onAccountCreateAddressNeeded { startChooseAddressWorkflow(it) }
.onAccountTwoPassModeFailed { accountManager.disableAccount(it.userId) }
.onAccountCreateAddressFailed { accountManager.disableAccount(it.userId) }
.onAccountRemoved { onAccountDisabled(it) }
.onAccountDisabled { onAccountDisabled(it) }
.onAccountReady { onAccountReady(it) }
.disableInitialNotReadyAccounts()
@ -107,13 +117,21 @@ class AccountViewModel @ViewModelInject constructor(
// Raise AccountNeeded on empty/disabled account list.
accountManager.getAccounts()
.flowWithLifecycle(context.lifecycle, Lifecycle.State.CREATED)
.flowWithLifecycle(lifecycle, Lifecycle.State.CREATED)
.onEach { accounts ->
if (accounts.isEmpty() || accounts.all { it.isDisabled() }) {
onAccountNeeded()
_state.tryEmit(State.AccountNeeded)
}
}.launchIn(viewModelScope)
}.launchIn(scope)
}
fun register(context: ComponentActivity) {
authOrchestrator.register(context)
}
fun onLoginClosed(block: () -> Unit) {
authOrchestrator.onLoginResult { result -> if (result == null) block() }
}
fun getAccount(userId: UserId) = accountManager.getAccount(userId)
@ -128,12 +146,12 @@ class AccountViewModel @ViewModelInject constructor(
.sortedByDescending { it.isReady() }
}
fun logout(userId: UserId) = viewModelScope.launch {
fun logout(userId: UserId) = scope.launch {
userManager.lock(userId)
accountManager.disableAccount(userId)
}
fun logoutPrimary() = viewModelScope.launch {
fun logoutPrimary() = scope.launch {
getPrimaryUserId().first()?.let { logout(it) }
}
@ -141,7 +159,7 @@ class AccountViewModel @ViewModelInject constructor(
authOrchestrator.startLoginWorkflow(AccountType.Internal)
}
fun switch(userId: UserId) = viewModelScope.launch {
fun switch(userId: UserId) = scope.launch {
val account = getAccountOrNull(userId) ?: return@launch
when {
account.isDisabled() -> authOrchestrator.startLoginWorkflow(AccountType.Internal) // TODO: Add userId.
@ -149,11 +167,11 @@ class AccountViewModel @ViewModelInject constructor(
}
}
fun remove(userId: UserId) = viewModelScope.launch {
fun remove(userId: UserId) = scope.launch {
accountManager.removeAccount(userId)
}
fun removeAll() = viewModelScope.launch {
fun removeAll() = scope.launch {
accountManager.getAccounts().first().forEach {
accountManager.removeAccount(it.userId)
}
@ -207,14 +225,14 @@ class AccountViewModel @ViewModelInject constructor(
// region Legacy User Management & Cleaning.
private suspend fun onAccountNeeded() {
private suspend fun onAccountNeeded() = withContext(NonCancellable + dispatchers.Io) {
eventManager.clearState()
AppUtil.clearTasks(jobManager)
AppUtil.deletePrefs()
AppUtil.deleteBackupPrefs()
}
private suspend fun onAccountReady(account: Account) {
private suspend fun onAccountReady(account: Account) = withContext(NonCancellable + dispatchers.Io) {
// Only initialize user once.
val userId = Id(account.userId.id)
val prefs = oldUserManager.preferencesFor(userId)
@ -225,8 +243,6 @@ class AccountViewModel @ViewModelInject constructor(
// See DatabaseFactory.usernameForUserId.
putString(Constants.Prefs.PREF_USER_NAME, account.username)
}
// Workaround: Wait getPrimaryUserId != null (see oldUserManager.primaryUserId).
getPrimaryUserId().first { it != null }
// Workaround: Wait the primary key passphrase before proceeding.
userManager.waitPrimaryKeyPassphraseAvailable(account.userId)
// Workaround: Make sure this uninitialized User is fresh.
@ -242,7 +258,7 @@ class AccountViewModel @ViewModelInject constructor(
_state.tryEmit(State.PrimaryExist)
}
private suspend fun onAccountDisabled(account: Account) {
private suspend fun onAccountDisabled(account: Account) = withContext(NonCancellable + dispatchers.Io) {
// Only clear user once.
val userId = Id(account.userId.id)
val prefs = oldUserManager.preferencesFor(userId)

View File

@ -112,7 +112,7 @@ class ChangePinActivity : BaseActivity(),
savePin("")
resetPinAttempts()
}
accountViewModel.logout(mUserManager.requireCurrentUserId()).invokeOnCompletion {
accountStateManager.logout(mUserManager.requireCurrentUserId()).invokeOnCompletion {
val intent = Intent()
setResult(Activity.RESULT_OK, intent)
finish()

View File

@ -200,7 +200,7 @@ class ValidatePinActivity : BaseActivity(),
savePin("")
resetPinAttempts()
}
accountViewModel.logout(mUserManager.requireCurrentUserId()).invokeOnCompletion {
accountStateManager.logout(mUserManager.requireCurrentUserId()).invokeOnCompletion {
val intent = Intent()
setResult(Activity.RESULT_OK, intent)
finish()