A framework for building convergent cross-platform Nextcloud clients using Flutter.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

317 lines
10 KiB

import 'dart:async';
import 'dart:convert';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:meta/meta.dart';
import 'package:neon/src/bloc/bloc.dart';
import 'package:neon/src/blocs/apps.dart';
import 'package:neon/src/blocs/capabilities.dart';
import 'package:neon/src/blocs/unified_search.dart';
import 'package:neon/src/blocs/user_details.dart';
import 'package:neon/src/blocs/user_statuses.dart';
import 'package:neon/src/models/account.dart';
import 'package:neon/src/models/account_cache.dart';
import 'package:neon/src/models/app_implementation.dart';
import 'package:neon/src/settings/models/storage.dart';
import 'package:neon/src/utils/account_options.dart';
import 'package:neon/src/utils/global_options.dart';
import 'package:nextcloud/core.dart' as core;
import 'package:rxdart/rxdart.dart';
const _keyAccounts = 'accounts';
@internal
abstract interface class AccountsBlocEvents {
/// Logs in the given [account].
///
/// It will also call [setActiveAccount] when no other accounts are registered in [AccountsBlocStates.accounts].
void addAccount(final Account account);
/// Logs out the given [account].
///
/// If [account] is the current [AccountsBlocStates.activeAccount] it will automatically activate the first one in [AccountsBlocStates.accounts].
/// It is not defined whether listeners of [AccountsBlocStates.accounts] or [AccountsBlocStates.activeAccount] are informed first.
void removeAccount(final Account account);
/// Updates the given [account].
///
/// It triggers an event in both [AccountsBlocStates.accounts] and [AccountsBlocStates.activeAccount] to inform all listeners.
void updateAccount(final Account account);
/// Sets the active [account].
///
/// It triggers an event in [AccountsBlocStates.activeAccount] to inform all listeners.
void setActiveAccount(final Account account);
}
@internal
abstract interface class AccountsBlocStates {
/// All registered accounts.
///
/// An empty value represents a state where no account is logged in.
BehaviorSubject<List<Account>> get accounts;
/// Currently active account.
///
/// It should be ensured to only emit an event when it's value changes.
/// A null value represents a state where no user is logged in.
BehaviorSubject<Account?> get activeAccount;
}
class AccountsBloc extends Bloc implements AccountsBlocEvents, AccountsBlocStates {
AccountsBloc(
this._globalOptions,
this._allAppImplementations,
) {
const lastUsedStorage = SingleValueStorage(StorageKeys.lastUsedAccount);
accounts
..add(loadAccounts())
..listen((final as) async {
_globalOptions.updateAccounts(as);
await saveAccounts(as);
});
activeAccount.listen((final aa) async {
if (aa != null) {
await lastUsedStorage.setString(aa.id);
} else {
await lastUsedStorage.remove();
}
});
final as = accounts.value;
if (_globalOptions.rememberLastUsedAccount.value && lastUsedStorage.hasValue()) {
final lastUsedAccountID = lastUsedStorage.getString();
if (lastUsedAccountID != null) {
final aa = as.tryFind(lastUsedAccountID);
if (aa != null) {
setActiveAccount(aa);
}
}
}
final account = as.tryFind(_globalOptions.initialAccount.value);
if (activeAccount.valueOrNull == null) {
if (account != null) {
setActiveAccount(account);
} else if (as.isNotEmpty) {
setActiveAccount(as.first);
}
}
accounts.listen((final accounts) {
_accountsOptions.pruneAgainst(accounts);
_appsBlocs.pruneAgainst(accounts);
_capabilitiesBlocs.pruneAgainst(accounts);
_userDetailsBlocs.pruneAgainst(accounts);
_userStatusesBlocs.pruneAgainst(accounts);
_unifiedSearchBlocs.pruneAgainst(accounts);
for (final app in _allAppImplementations) {
app.blocsCache.pruneAgainst(accounts);
}
});
}
final GlobalOptions _globalOptions;
final Iterable<AppImplementation> _allAppImplementations;
final _accountsOptions = AccountCache<AccountSpecificOptions>();
final _appsBlocs = AccountCache<AppsBloc>();
final _capabilitiesBlocs = AccountCache<CapabilitiesBloc>();
final _userDetailsBlocs = AccountCache<UserDetailsBloc>();
final _userStatusesBlocs = AccountCache<UserStatusesBloc>();
final _unifiedSearchBlocs = AccountCache<UnifiedSearchBloc>();
@override
void dispose() {
unawaited(activeAccount.close());
unawaited(accounts.close());
_appsBlocs.dispose();
_capabilitiesBlocs.dispose();
_userDetailsBlocs.dispose();
_userStatusesBlocs.dispose();
_unifiedSearchBlocs.dispose();
_accountsOptions.dispose();
}
@override
BehaviorSubject<List<Account>> accounts = BehaviorSubject<List<Account>>.seeded([]);
@override
BehaviorSubject<Account?> activeAccount = BehaviorSubject();
@override
void addAccount(final Account account) {
if (activeAccount.valueOrNull == null) {
setActiveAccount(account);
}
accounts.add(accounts.value..add(account));
}
@override
void removeAccount(final Account account) {
accounts.add(accounts.value..removeWhere((final a) => a.id == account.id));
final as = accounts.value;
final aa = activeAccount.valueOrNull;
if (aa?.id == account.id) {
if (as.firstOrNull != null) {
setActiveAccount(as.first);
} else {
activeAccount.add(null);
}
}
unawaited(() async {
try {
await account.client.core.appPassword.deleteAppPassword();
} catch (e, s) {
debugPrint(e.toString());
debugPrint(s.toString());
}
}());
}
@override
void setActiveAccount(final Account account) {
if (activeAccount.valueOrNull != account) {
activeAccount.add(account);
}
}
@override
void updateAccount(final Account account) {
final as = accounts.value;
final index = as.indexWhere((final a) => a.id == account.id);
if (index == -1) {
// TODO: Figure out how we can remove the old account without potentially race conditioning
as.add(account);
} else {
as.replaceRange(
index,
index + 1,
[account],
);
}
accounts.add(as);
setActiveAccount(account);
}
/// The currently active account.
///
/// Equivalent to activeAccount.valueOrNull but throws a [StateError] when no user is logged in.
@visibleForTesting
Account get aa {
final aa = activeAccount.valueOrNull;
if (aa == null) {
throw StateError('No user is logged in.');
}
return aa;
}
/// Whether accounts are logged in.
bool get hasAccounts => activeAccount.valueOrNull != null;
/// The options for the [activeAccount].
///
/// Convenience method for [getOptionsFor] with the currently active account.
AccountSpecificOptions get activeOptions => getOptionsFor(aa);
/// The options for the specified [account].
///
/// Use [activeOptions] to get them for the [activeAccount].
AccountSpecificOptions getOptionsFor(final Account account) => _accountsOptions[account] ??= AccountSpecificOptions(
AppStorage(StorageKeys.accounts, account.id),
getAppsBlocFor(account),
);
/// The appsBloc for the [activeAccount].
///
/// Convenience method for [getAppsBlocFor] with the currently active account.
AppsBloc get activeAppsBloc => getAppsBlocFor(aa);
/// The appsBloc for the specified [account].
///
/// Use [activeAppsBloc] to get them for the [activeAccount].
AppsBloc getAppsBlocFor(final Account account) => _appsBlocs[account] ??= AppsBloc(
getCapabilitiesBlocFor(account),
this,
account,
_allAppImplementations,
);
/// The capabilitiesBloc for the [activeAccount].
///
/// Convenience method for [getCapabilitiesBlocFor] with the currently active account.
CapabilitiesBloc get activeCapabilitiesBloc => getCapabilitiesBlocFor(aa);
/// The capabilitiesBloc for the specified [account].
///
/// Use [activeCapabilitiesBloc] to get them for the [activeAccount].
CapabilitiesBloc getCapabilitiesBlocFor(final Account account) =>
_capabilitiesBlocs[account] ??= CapabilitiesBloc(account);
/// The userDetailsBloc for the [activeAccount].
///
/// Convenience method for [getUserDetailsBlocFor] with the currently active account.
UserDetailsBloc get activeUerDetailsBloc => getUserDetailsBlocFor(aa);
/// The userDetailsBloc for the specified [account].
///
/// Use [activeUerDetailsBloc] to get them for the [activeAccount].
UserDetailsBloc getUserDetailsBlocFor(final Account account) =>
_userDetailsBlocs[account] ??= UserDetailsBloc(account);
/// The userStatusBloc for the [activeAccount].
///
/// Convenience method for [getUserStatusesBlocFor] with the currently active account.
UserStatusesBloc get activeUserStatusesBloc => getUserStatusesBlocFor(aa);
/// The userStatusBloc for the specified [account].
///
/// Use [activeUserStatusesBloc] to get them for the [activeAccount].
UserStatusesBloc getUserStatusesBlocFor(final Account account) =>
_userStatusesBlocs[account] ??= UserStatusesBloc(account);
/// The UnifiedSearchBloc for the [activeAccount].
///
/// Convenience method for [getUnifiedSearchBlocFor] with the currently active account.
UnifiedSearchBloc get activeUnifiedSearchBloc => getUnifiedSearchBlocFor(aa);
/// The UnifiedSearchBloc for the specified [account].
///
/// Use [activeUnifiedSearchBloc] to get them for the [activeAccount].
UnifiedSearchBloc getUnifiedSearchBlocFor(final Account account) =>
_unifiedSearchBlocs[account] ??= UnifiedSearchBloc(
getAppsBlocFor(account),
account,
);
}
/// Gets a list of logged in accounts from storage.
///
/// It is not checked whether the stored information is still valid.
List<Account> loadAccounts() {
const storage = AppStorage(StorageKeys.accounts);
if (storage.containsKey(_keyAccounts)) {
return storage
.getStringList(_keyAccounts)!
.map((final a) => Account.fromJson(json.decode(a) as Map<String, dynamic>))
.toList();
}
return [];
}
/// Saves the given [accounts] to the storage.
Future<void> saveAccounts(final List<Account> accounts) async {
const storage = AppStorage(StorageKeys.accounts);
final values = accounts.map((final a) => json.encode(a.toJson())).toList();
await storage.setStringList(_keyAccounts, values);
}