feat: custom server url in application (#3996)

* chore:test

* chore: update ui

* feat: set appflowy cloud url

* chore: add self host docs

* fix: save user

* fix: sign out when authenticator not match

* fix: sign out when authenticator not match

* fix: db lock

* chore: remove unuse env file

* test: disable supabase cloud test

* test: disable supabase cloud test

* chore: fix save
This commit is contained in:
Nathan.fooo 2023-11-24 11:54:47 +08:00 committed by GitHub
parent e18e031710
commit 1fad713477
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
63 changed files with 1758 additions and 721 deletions

View File

@ -1,40 +0,0 @@
# Copy the 'dev.env' file to '.env':
# Use the command 'cp dev.env .env' to create a copy of 'dev.env' named '.env'.
# After copying, update the '.env' file with the necessary environment parameters.
# Generate the 'env.dart' from this '.env' file:
# Option 1: Use the "Generate Env File" task in VSCode.
# Option 2: Execute the commands in the appflowy_flutter directory:
# cd appflowy_flutter
# dart run build_runner clean && dart run build_runner build --delete-conflicting-outputs
# Note on Configuration Priority:
# If both Supabase config and AppFlowy cloud config are provided in the '.env' file,
# the AppFlowy cloud config will be prioritized and the Supabase config ignored.
# Ensure only one of these configurations is active at any given time.
# Cloud Type Configuration
# Use this configuration file to specify the cloud type and its associated settings. The available cloud types are:
# Local: 0
# Supabase: 1
# AppFlowy Cloud: 2
# By default, it's set to Local.
CLOUD_TYPE=0
# Supabase Configuration
# If using Supabase (CLOUD_TYPE=1), provide the following details:
SUPABASE_URL=
SUPABASE_ANON_KEY=
# AppFlowy Cloud Configuration
# If using Supabase (CLOUD_TYPE=2), provide the following details:
#
# When using localhost for development. you can use the following settings:
# APPFLOWY_CLOUD_BASE_URL=http://localhost:8000
# APPFLOWY_CLOUD_WS_BASE_URL=ws://localhost:8000/ws
# APPFLOWY_CLOUD_GOTRUE_URL=http://localhost:9998
APPFLOWY_CLOUD_BASE_URL=
APPFLOWY_CLOUD_WS_BASE_URL=
APPFLOWY_CLOUD_GOTRUE_URL=

View File

@ -1,8 +1,6 @@
import 'package:appflowy/env/env.dart';
import 'package:integration_test/integration_test.dart';
import 'appearance_settings_test.dart' as appearance_test_runner;
import 'auth/auth_test.dart' as auth_test_runner;
import 'board/board_test_runner.dart' as board_test_runner;
import 'database_calendar_test.dart' as database_calendar_test;
import 'database_cell_test.dart' as database_cell_test;
@ -32,7 +30,7 @@ import 'tabs_test.dart' as tabs_test;
/// If flutter/flutter#101031 is resolved, this file can be removed completely.
/// Once removed, the integration_test.yaml must be updated to exclude this as
/// as the test target.
void main() {
Future<void> main() async {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
// This test must be run first, otherwise the CI will fail.
@ -76,9 +74,10 @@ void main() {
// User settings
settings_test_runner.main();
if (isCloudEnabled) {
auth_test_runner.main();
}
// final cloudType = await getCloudType();
// if (cloudType == CloudType.supabase) {
// auth_test_runner.main();
// }
// board_test.main();
// empty_document_test.main();

View File

@ -1,5 +1,5 @@
import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/widgets.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/setting_cloud_view.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/setting_supabase_cloud.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
@ -37,7 +37,7 @@ extension AppFlowyAuthTest on WidgetTester {
void assertEnableSyncSwitchValue(bool value) {
assertSwitchValue(
find.descendant(
of: find.byType(EnableSync),
of: find.byType(SupabaseEnableSync),
matching: find.byWidgetPredicate((widget) => widget is Switch),
),
value,
@ -55,7 +55,7 @@ extension AppFlowyAuthTest on WidgetTester {
Future<void> toggleEnableSync() async {
final finder = find.descendant(
of: find.byType(EnableSync),
of: find.byType(SupabaseEnableSync),
matching: find.byWidgetPredicate((widget) => widget is Switch),
);

View File

@ -83,7 +83,7 @@ extension AppFlowyTestBase on WidgetTester {
}
Future<void> waitUntilSignInPageShow() async {
if (isCloudEnabled) {
if (isAuthEnabled) {
final finder = find.byType(SignInAnonymousButton);
await pumpUntilFound(finder);
expect(finder, findsOneWidget);

View File

@ -303,7 +303,7 @@ extension CommonOperations on WidgetTester {
KVKeys.showRenameDialogWhenCreatingNewFile,
(value) => bool.parse(value),
);
final showRenameDialog = settingsOrFailure.fold((l) => false, (r) => r);
final showRenameDialog = settingsOrFailure.fold(() => false, (r) => r);
if (showRenameDialog) {
await tapOKButton();
}

View File

@ -1,13 +1,10 @@
import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/protobuf/flowy-config/entities.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:dartz/dartz.dart';
import 'package:shared_preferences/shared_preferences.dart';
abstract class KeyValueStorage {
Future<void> set(String key, String value);
Future<Either<FlowyError, String>> get(String key);
Future<Either<FlowyError, T>> getWithFormat<T>(
Future<Option<String>> get(String key);
Future<Option<T>> getWithFormat<T>(
String key,
T Function(String value) formatter,
);
@ -20,25 +17,25 @@ class DartKeyValue implements KeyValueStorage {
SharedPreferences get sharedPreferences => _sharedPreferences!;
@override
Future<Either<FlowyError, String>> get(String key) async {
Future<Option<String>> get(String key) async {
await _initSharedPreferencesIfNeeded();
final value = sharedPreferences.getString(key);
if (value != null) {
return Right(value);
return Some(value);
}
return Left(FlowyError());
return none();
}
@override
Future<Either<FlowyError, T>> getWithFormat<T>(
Future<Option<T>> getWithFormat<T>(
String key,
T Function(String value) formatter,
) async {
final value = await get(key);
return value.fold(
(l) => left(l),
(r) => right(formatter(r)),
() => none(),
(s) => Some(formatter(s)),
);
}
@ -67,38 +64,3 @@ class DartKeyValue implements KeyValueStorage {
_sharedPreferences ??= await SharedPreferences.getInstance();
}
}
/// Key-value store
/// The data is stored in the local storage of the device.
class RustKeyValue {
static Future<void> set(String key, String value) async {
await ConfigEventSetKeyValue(
KeyValuePB.create()
..key = key
..value = value,
).send();
}
static Future<Either<FlowyError, String>> get(String key) async {
final payload = KeyPB.create()..key = key;
final response = await ConfigEventGetKeyValue(payload).send();
return response.swap().map((r) => r.value);
}
static Future<Either<FlowyError, T>> getWithFormat<T>(
String key,
T Function(String value) formatter,
) async {
final value = await get(key);
return value.fold(
(l) => left(l),
(r) => right(formatter(r)),
);
}
static Future<void> remove(String key) async {
await ConfigEventRemoveKeyValue(
KeyPB.create()..key = key,
).send();
}
}

View File

@ -42,4 +42,9 @@ class KVKeys {
/// The value is a boolean string.
static const String showRenameDialogWhenCreatingNewFile =
'showRenameDialogWhenCreatingNewFile';
static const String kCloudType = 'kCloudType';
static const String kAppflowyCloudBaseURL = 'kAppFlowyCloudBaseURL';
static const String kSupabaseURL = 'kSupbaseURL';
static const String kSupabaseAnonKey = 'kSupabaseAnonKey';
}

View File

@ -49,6 +49,10 @@ class SupabaseConfiguration {
anon_key: '',
);
}
bool get isValid {
return url.isNotEmpty && anon_key.isNotEmpty;
}
}
@JsonSerializable()
@ -75,4 +79,10 @@ class AppFlowyCloudConfiguration {
gotrue_url: '',
);
}
bool get isValid {
return base_url.isNotEmpty &&
ws_base_url.isNotEmpty &&
gotrue_url.isNotEmpty;
}
}

View File

@ -1,76 +1,104 @@
// lib/env/env.dart
import 'package:appflowy/core/config/kv.dart';
import 'package:appflowy/core/config/kv_keys.dart';
import 'package:appflowy/env/backend_env.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy_backend/log.dart';
import 'package:envied/envied.dart';
import 'package:dartz/dartz.dart';
part 'env.g.dart';
/// The environment variables are defined in `.env` file that is located in the
/// appflowy_flutter.
/// Run `dart run build_runner build --delete-conflicting-outputs`
/// to generate the keys from the env file.
/// Sets the cloud type for the application.
///
/// If you want to regenerate the keys, you need to run `dart run
/// build_runner clean` before running `dart run build_runner build
/// --delete-conflicting-outputs`.
/// Follow the guide on https://supabase.com/docs/guides/auth/social-login/auth-google to setup the auth provider.
/// This method updates the cloud type setting in the key-value storage
/// using the [KeyValueStorage] service. The cloud type is identified
/// by the [CloudType] enum.
///
@Envied(path: '.env')
abstract class Env {
@EnviedField(
obfuscate: true,
varName: 'CLOUD_TYPE',
defaultValue: '0',
)
static final int cloudType = _Env.cloudType;
/// AppFlowy Cloud Configuration
@EnviedField(
obfuscate: true,
varName: 'APPFLOWY_CLOUD_BASE_URL',
defaultValue: '',
)
static final String afCloudBaseUrl = _Env.afCloudBaseUrl;
@EnviedField(
obfuscate: true,
varName: 'APPFLOWY_CLOUD_WS_BASE_URL',
defaultValue: '',
)
static final String afCloudWSBaseUrl = _Env.afCloudWSBaseUrl;
@EnviedField(
obfuscate: true,
varName: 'APPFLOWY_CLOUD_GOTRUE_URL',
defaultValue: '',
)
static final String afCloudGoTrueUrl = _Env.afCloudGoTrueUrl;
// Supabase Configuration:
@EnviedField(
obfuscate: true,
varName: 'SUPABASE_URL',
defaultValue: '',
)
static final String supabaseUrl = _Env.supabaseUrl;
@EnviedField(
obfuscate: true,
varName: 'SUPABASE_ANON_KEY',
defaultValue: '',
)
static final String supabaseAnonKey = _Env.supabaseAnonKey;
/// [ty] - The type of cloud to be set. It must be one of the values from
/// [CloudType] enum. The corresponding integer value of the enum is stored:
/// - `CloudType.local` is stored as "0".
/// - `CloudType.supabase` is stored as "1".
/// - `CloudType.appflowyCloud` is stored as "2".
Future<void> setCloudType(CloudType ty) async {
switch (ty) {
case CloudType.local:
getIt<KeyValueStorage>().set(KVKeys.kCloudType, 0.toString());
break;
case CloudType.supabase:
getIt<KeyValueStorage>().set(KVKeys.kCloudType, 1.toString());
break;
case CloudType.appflowyCloud:
getIt<KeyValueStorage>().set(KVKeys.kCloudType, 2.toString());
break;
}
}
bool get isCloudEnabled {
/// Retrieves the currently set cloud type.
///
/// This method fetches the cloud type setting from the key-value storage
/// using the [KeyValueStorage] service and returns the corresponding
/// [CloudType] enum value.
///
/// Returns:
/// A Future that resolves to a [CloudType] enum value representing the
/// currently set cloud type. The default return value is `CloudType.local`
/// if no valid setting is found.
///
Future<CloudType> getCloudType() async {
final value = await getIt<KeyValueStorage>().get(KVKeys.kCloudType);
return value.fold(() => CloudType.local, (s) {
switch (s) {
case "0":
return CloudType.local;
case "1":
return CloudType.supabase;
case "2":
return CloudType.appflowyCloud;
default:
return CloudType.local;
}
});
}
/// Determines whether authentication is enabled.
///
/// This getter evaluates if authentication should be enabled based on the
/// current integration mode and cloud type settings.
///
/// Returns:
/// A boolean value indicating whether authentication is enabled. It returns
/// `true` if the application is in release or develop mode, and the cloud type
/// is not set to `CloudType.local`. Additionally, it checks if either the
/// AppFlowy Cloud or Supabase configuration is valid.
/// Returns `false` otherwise.
bool get isAuthEnabled {
// Only enable supabase in release and develop mode.
if (integrationMode().isRelease || integrationMode().isDevelop) {
return currentCloudType().isEnabled;
final env = getIt<AppFlowyCloudSharedEnv>();
if (env.cloudType == CloudType.local) {
return false;
}
if (env.cloudType == CloudType.supabase) {
return env.supabaseConfig.isValid;
}
if (env.cloudType == CloudType.appflowyCloud) {
return env.appflowyCloudConfig.isValid;
}
return false;
} else {
return false;
}
}
/// Checks if Supabase is enabled.
///
/// This getter evaluates if Supabase should be enabled based on the
/// current integration mode and cloud type setting.
///
/// Returns:
/// A boolean value indicating whether Supabase is enabled. It returns `true`
/// if the application is in release or develop mode and the current cloud type
/// is `CloudType.supabase`. Otherwise, it returns `false`.
bool get isSupabaseEnabled {
// Only enable supabase in release and develop mode.
if (integrationMode().isRelease || integrationMode().isDevelop) {
@ -80,6 +108,15 @@ bool get isSupabaseEnabled {
}
}
/// Determines if AppFlowy Cloud is enabled.
///
/// This getter assesses if AppFlowy Cloud should be enabled based on the
/// current integration mode and cloud type setting.
///
/// Returns:
/// A boolean value indicating whether AppFlowy Cloud is enabled. It returns
/// `true` if the application is in release or develop mode and the current
/// cloud type is `CloudType.appflowyCloud`. Otherwise, it returns `false`.
bool get isAppFlowyCloudEnabled {
// Only enable appflowy cloud in release and develop mode.
if (integrationMode().isRelease || integrationMode().isDevelop) {
@ -90,40 +127,125 @@ bool get isAppFlowyCloudEnabled {
}
enum CloudType {
unknown,
local,
supabase,
appflowyCloud;
bool get isEnabled => this != CloudType.unknown;
bool get isEnabled => this != CloudType.local;
int get value {
switch (this) {
case CloudType.local:
return 0;
case CloudType.supabase:
return 1;
case CloudType.appflowyCloud:
return 2;
}
}
}
CloudType currentCloudType() {
final value = Env.cloudType;
if (value == 1) {
if (Env.supabaseUrl.isEmpty || Env.supabaseAnonKey.isEmpty) {
Log.error(
"Supabase is not configured correctly. The values are: "
"url: ${Env.supabaseUrl}, anonKey: ${Env.supabaseAnonKey}",
);
return CloudType.unknown;
} else {
return CloudType.supabase;
}
}
if (value == 2) {
if (Env.afCloudBaseUrl.isEmpty ||
Env.afCloudWSBaseUrl.isEmpty ||
Env.afCloudGoTrueUrl.isEmpty) {
Log.error(
"AppFlowy cloud is not configured correctly. The values are: "
"baseUrl: ${Env.afCloudBaseUrl}, wsBaseUrl: ${Env.afCloudWSBaseUrl}, gotrueUrl: ${Env.afCloudGoTrueUrl}",
);
return CloudType.unknown;
} else {
return CloudType.appflowyCloud;
}
}
return CloudType.unknown;
return getIt<AppFlowyCloudSharedEnv>().cloudType;
}
Future<void> setAppFlowyCloudBaseUrl(Option<String> url) async {
await url.fold(
() => getIt<KeyValueStorage>().remove(KVKeys.kAppflowyCloudBaseURL),
(s) => getIt<KeyValueStorage>().set(KVKeys.kAppflowyCloudBaseURL, s),
);
}
/// Use getIt<AppFlowyCloudSharedEnv>() to get the shared environment.
class AppFlowyCloudSharedEnv {
final CloudType cloudType;
final AppFlowyCloudConfiguration appflowyCloudConfig;
final SupabaseConfiguration supabaseConfig;
AppFlowyCloudSharedEnv({
required this.cloudType,
required this.appflowyCloudConfig,
required this.supabaseConfig,
});
}
Future<AppFlowyCloudConfiguration> getAppFlowyCloudConfig() async {
return AppFlowyCloudConfiguration(
base_url: await getAppFlowyCloudUrl(),
ws_base_url: await _getAppFlowyCloudWSUrl(),
gotrue_url: await _getAppFlowyCloudGotrueUrl(),
);
}
Future<String> getAppFlowyCloudUrl() async {
final result =
await getIt<KeyValueStorage>().get(KVKeys.kAppflowyCloudBaseURL);
return result.fold(
() => "",
(url) => url,
);
}
Future<String> _getAppFlowyCloudWSUrl() async {
try {
final serverUrl = await getAppFlowyCloudUrl();
final uri = Uri.parse(serverUrl);
// Construct the WebSocket URL directly from the parsed URI.
final wsScheme = uri.isScheme('HTTPS') ? 'wss' : 'ws';
final wsUrl = Uri(scheme: wsScheme, host: uri.host, path: '/ws');
return wsUrl.toString();
} catch (e) {
Log.error("Failed to get WebSocket URL: $e");
return "";
}
}
Future<String> _getAppFlowyCloudGotrueUrl() async {
final serverUrl = await getAppFlowyCloudUrl();
return "$serverUrl/gotrue";
}
Future<void> setSupbaseServer(
Option<String> url,
Option<String> anonKey,
) async {
assert(
(url.isSome() && anonKey.isSome()) || (url.isNone() && anonKey.isNone()),
"Either both Supabase URL and anon key must be set, or both should be unset",
);
await url.fold(
() => getIt<KeyValueStorage>().remove(KVKeys.kSupabaseURL),
(s) => getIt<KeyValueStorage>().set(KVKeys.kSupabaseURL, s),
);
await anonKey.fold(
() => getIt<KeyValueStorage>().remove(KVKeys.kSupabaseAnonKey),
(s) => getIt<KeyValueStorage>().set(KVKeys.kSupabaseAnonKey, s),
);
}
Future<SupabaseConfiguration> getSupabaseCloudConfig() async {
final url = await _getSupbaseUrl();
final anonKey = await _getSupabaseAnonKey();
return SupabaseConfiguration(
url: url,
anon_key: anonKey,
);
}
Future<String> _getSupbaseUrl() async {
final result = await getIt<KeyValueStorage>().get(KVKeys.kSupabaseURL);
return result.fold(
() => "",
(url) => url,
);
}
Future<String> _getSupabaseAnonKey() async {
final result = await getIt<KeyValueStorage>().get(KVKeys.kSupabaseAnonKey);
return result.fold(
() => "",
(url) => url,
);
}

View File

@ -33,7 +33,7 @@ class PersonalInfoSettingGroup extends StatelessWidget {
settingItemList: [
MobileSettingItem(
name: userName,
subtitle: isCloudEnabled
subtitle: isAuthEnabled
? Text(
userProfile.email,
style: theme.textTheme.bodyMedium?.copyWith(

View File

@ -43,6 +43,10 @@ class DependencyResolver {
GetIt getIt,
IntegrationMode mode,
) async {
// getIt.registerFactory<KeyValueStorage>(() => RustKeyValue());
getIt.registerFactory<KeyValueStorage>(() => DartKeyValue());
await _resolveCloudDeps(getIt);
_resolveUserDeps(getIt, mode);
_resolveHomeDeps(getIt);
_resolveFolderDeps(getIt);
@ -52,12 +56,23 @@ class DependencyResolver {
}
}
Future<void> _resolveCloudDeps(GetIt getIt) async {
final cloudType = await getCloudType();
final appflowyCloudConfig = await getAppFlowyCloudConfig();
final supabaseCloudConfig = await getSupabaseCloudConfig();
getIt.registerFactory<AppFlowyCloudSharedEnv>(() {
return AppFlowyCloudSharedEnv(
cloudType: cloudType,
appflowyCloudConfig: appflowyCloudConfig,
supabaseConfig: supabaseCloudConfig,
);
});
}
void _resolveCommonService(
GetIt getIt,
IntegrationMode mode,
) async {
// getIt.registerFactory<KeyValueStorage>(() => RustKeyValue());
getIt.registerFactory<KeyValueStorage>(() => DartKeyValue());
getIt.registerFactory<FilePickerService>(() => FilePicker());
if (mode.isTest) {
getIt.registerFactory<ApplicationDataStorage>(
@ -115,7 +130,7 @@ void _resolveCommonService(
void _resolveUserDeps(GetIt getIt, IntegrationMode mode) {
switch (currentCloudType()) {
case CloudType.unknown:
case CloudType.local:
getIt.registerFactory<AuthService>(
() => BackendAuthService(
AuthTypePB.Local,

View File

@ -45,7 +45,7 @@ class FlowyRunner {
await getIt.reset();
// Specify the env
initGetIt(getIt, mode, f, config);
await initGetIt(getIt, mode, f, config);
final applicationDataDirectory =
await getIt<ApplicationDataStorage>().getPath().then(

View File

@ -50,7 +50,7 @@ class WindowSizeManager {
Future<Offset?> getPosition() async {
final position = await getIt<KeyValueStorage>().get(KVKeys.windowPosition);
return position.fold(
(l) => null,
() => null,
(r) {
final offset = json.decode(r);
return Offset(offset[dx], offset[dy]);

View File

@ -45,41 +45,15 @@ AppFlowyConfiguration _getAppFlowyConfiguration(
String originAppPath,
String deviceId,
) {
if (isCloudEnabled) {
final supabaseConfig = SupabaseConfiguration(
url: Env.supabaseUrl,
anon_key: Env.supabaseAnonKey,
);
final appflowyCloudConfig = AppFlowyCloudConfiguration(
base_url: Env.afCloudBaseUrl,
ws_base_url: Env.afCloudWSBaseUrl,
gotrue_url: Env.afCloudGoTrueUrl,
);
return AppFlowyConfiguration(
custom_app_path: customAppPath,
origin_app_path: originAppPath,
device_id: deviceId,
cloud_type: Env.cloudType,
supabase_config: supabaseConfig,
appflowy_cloud_config: appflowyCloudConfig,
);
} else {
// Use the default configuration if the cloud feature is disabled
final supabaseConfig = SupabaseConfiguration.defaultConfig();
final appflowyCloudConfig = AppFlowyCloudConfiguration.defaultConfig();
return AppFlowyConfiguration(
custom_app_path: customAppPath,
origin_app_path: originAppPath,
device_id: deviceId,
// 0 means the cloud type is local
cloud_type: 0,
supabase_config: supabaseConfig,
appflowy_cloud_config: appflowyCloudConfig,
);
}
final env = getIt<AppFlowyCloudSharedEnv>();
return AppFlowyConfiguration(
custom_app_path: customAppPath,
origin_app_path: originAppPath,
device_id: deviceId,
cloud_type: env.cloudType.value,
supabase_config: env.supabaseConfig,
appflowy_cloud_config: env.appflowyCloudConfig,
);
}
/// The default directory to store the user data. The directory can be

View File

@ -36,8 +36,8 @@ class InitSupabaseTask extends LaunchTask {
supabase?.dispose();
supabase = null;
final initializedSupabase = await Supabase.initialize(
url: Env.supabaseUrl,
anonKey: Env.supabaseAnonKey,
url: getIt<AppFlowyCloudSharedEnv>().supabaseConfig.url,
anonKey: getIt<AppFlowyCloudSharedEnv>().supabaseConfig.anon_key,
debug: kDebugMode,
localStorage: const SupabaseLocalStorage(),
);

View File

@ -68,6 +68,7 @@ class AFCloudAuthService implements AuthService {
final completer = Completer<Either<FlowyError, UserProfilePB>>();
_deeplinkSubscription = _appLinks.uriLinkStream.listen(
(Uri? uri) async {
Log.info('onDeepLink: ${uri.toString()}');
await _handleUri(uri, completer);
},
onError: (Object err, StackTrace stackTrace) {
@ -108,6 +109,9 @@ class AFCloudAuthService implements AuthService {
.then((value) => value.swap());
_deeplinkSubscription?.cancel();
completer.complete(result);
} else {
Log.error('onDeepLinkError: Unexpect deep link: ${uri.toString()}');
completer.complete(left(AuthError.signInWithOauthError));
}
} else {
Log.error('onDeepLinkError: Unexpect empty deep link callback');

View File

@ -1,4 +1,5 @@
import 'package:appflowy/core/frameless_window.dart';
import 'package:appflowy/env/env.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/widgets.dart';
import 'package:appflowy/user/presentation/widgets/widgets.dart';
@ -46,9 +47,12 @@ class DesktopSignInScreen extends StatelessWidget {
// third-party sign in.
const VSpace(20),
const _OrDivider(),
const VSpace(10),
const ThirdPartySignInButtons(),
if (isAuthEnabled) ...[
const _OrDivider(),
const VSpace(10),
const ThirdPartySignInButtons(),
],
const VSpace(20),
// loading status
const VSpace(indicatorMinHeight),

View File

@ -1,3 +1,4 @@
import 'package:appflowy/env/env.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/widgets.dart';
@ -85,7 +86,7 @@ class MobileSignInScreen extends StatelessWidget {
],
),
const VSpace(spacing),
const ThirdPartySignInButtons(),
if (isAuthEnabled) const ThirdPartySignInButtons(),
const VSpace(spacing),
],
),

View File

@ -67,7 +67,7 @@ class _SkipLogInScreenState extends State<SkipLogInScreen> {
),
const VSpace(32),
SizedBox(
width: size.width * 0.5,
width: size.width * 0.7,
child: FolderWidget(
createFolderCallback: () async {
_didCustomizeFolder = true;

View File

@ -95,10 +95,10 @@ class SplashScreen extends StatelessWidget {
void _handleUnauthenticated(BuildContext context, Unauthenticated result) {
Log.trace(
'_handleUnauthenticated -> cloud is enabled: $isCloudEnabled',
'_handleUnauthenticated -> cloud is enabled: $isAuthEnabled',
);
// replace Splash screen as root page
if (isCloudEnabled) {
if (isAuthEnabled) {
context.go(SignInScreen.routeName);
} else {
// if the env is not configured, we will skip to the 'skip login screen'.

View File

@ -0,0 +1,91 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/workspace/application/settings/cloud_setting_listener.dart';
import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-user/user_setting.pb.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:dartz/dartz.dart';
part 'appflowy_cloud_setting_bloc.freezed.dart';
class AppFlowyCloudSettingBloc
extends Bloc<AppFlowyCloudSettingEvent, AppFlowyCloudSettingState> {
final UserCloudConfigListener _listener;
AppFlowyCloudSettingBloc(CloudSettingPB setting)
: _listener = UserCloudConfigListener(),
super(AppFlowyCloudSettingState.initial(setting)) {
on<AppFlowyCloudSettingEvent>((event, emit) async {
await event.when(
initial: () async {
_listener.start(
onSettingChanged: (result) {
if (isClosed) {
return;
}
result.fold(
(setting) =>
add(AppFlowyCloudSettingEvent.didReceiveSetting(setting)),
(error) => Log.error(error),
);
},
);
},
enableSync: (isEnable) async {
final config = UpdateCloudConfigPB.create()..enableSync = isEnable;
await UserEventSetCloudConfig(config).send();
},
didReceiveSetting: (CloudSettingPB setting) {
emit(
state.copyWith(
setting: setting,
),
);
},
);
});
}
@override
Future<void> close() async {
_listener.stop();
return super.close();
}
}
@freezed
class AppFlowyCloudSettingEvent with _$AppFlowyCloudSettingEvent {
const factory AppFlowyCloudSettingEvent.initial() = _Initial;
const factory AppFlowyCloudSettingEvent.enableSync(bool isEnable) =
_EnableSync;
const factory AppFlowyCloudSettingEvent.didReceiveSetting(
CloudSettingPB setting,
) = _DidUpdateSetting;
}
@freezed
class AppFlowyCloudSettingState with _$AppFlowyCloudSettingState {
const factory AppFlowyCloudSettingState({
required CloudSettingPB setting,
}) = _AppFlowyCloudSettingState;
factory AppFlowyCloudSettingState.initial(CloudSettingPB setting) =>
AppFlowyCloudSettingState(
setting: setting,
);
}
Either<String, ()> validateUrl(String url) {
try {
// Use Uri.parse to validate the url.
final uri = Uri.parse(url);
if (uri.isScheme('HTTP') || uri.isScheme('HTTPS')) {
return right(());
} else {
return left(LocaleKeys.settings_menu_invalidCloudURLScheme.tr());
}
} catch (e) {
return left(e.toString());
}
}

View File

@ -0,0 +1,95 @@
import 'package:appflowy/env/backend_env.dart';
import 'package:appflowy/env/env.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:dartz/dartz.dart';
part 'appflowy_cloud_urls_bloc.freezed.dart';
class AppFlowyCloudURLsBloc
extends Bloc<AppFlowyCloudURLsEvent, AppFlowyCloudURLsState> {
AppFlowyCloudURLsBloc() : super(AppFlowyCloudURLsState.initial()) {
on<AppFlowyCloudURLsEvent>((event, emit) async {
await event.when(
initial: () async {},
updateServerUrl: (url) {
emit(state.copyWith(updatedServerUrl: url));
},
confirmUpdate: () async {
if (state.updatedServerUrl.isEmpty) {
emit(
state.copyWith(
updatedServerUrl: "",
urlError: none(),
restartApp: true,
),
);
await setAppFlowyCloudBaseUrl(none());
} else {
validateUrl(state.updatedServerUrl).fold(
(error) => emit(state.copyWith(urlError: Some(error))),
(_) async {
if (state.config.base_url != state.updatedServerUrl) {
await setAppFlowyCloudBaseUrl(Some(state.updatedServerUrl));
add(const AppFlowyCloudURLsEvent.didSaveConfig());
}
},
);
}
},
didSaveConfig: () {
emit(
state.copyWith(
urlError: none(),
restartApp: true,
),
);
},
);
});
}
}
@freezed
class AppFlowyCloudURLsEvent with _$AppFlowyCloudURLsEvent {
const factory AppFlowyCloudURLsEvent.initial() = _Initial;
const factory AppFlowyCloudURLsEvent.updateServerUrl(String text) =
_ServerUrl;
const factory AppFlowyCloudURLsEvent.confirmUpdate() = _UpdateConfig;
const factory AppFlowyCloudURLsEvent.didSaveConfig() = _DidSaveConfig;
}
@freezed
class AppFlowyCloudURLsState with _$AppFlowyCloudURLsState {
const factory AppFlowyCloudURLsState({
required AppFlowyCloudConfiguration config,
required String updatedServerUrl,
required Option<String> urlError,
required bool restartApp,
}) = _AppFlowyCloudURLsState;
factory AppFlowyCloudURLsState.initial() => AppFlowyCloudURLsState(
config: getIt<AppFlowyCloudSharedEnv>().appflowyCloudConfig,
urlError: none(),
updatedServerUrl:
getIt<AppFlowyCloudSharedEnv>().appflowyCloudConfig.base_url,
restartApp: false,
);
}
Either<String, ()> validateUrl(String url) {
try {
// Use Uri.parse to validate the url.
final uri = Uri.parse(url);
if (uri.isScheme('HTTP') || uri.isScheme('HTTPS')) {
return right(());
} else {
return left(LocaleKeys.settings_menu_invalidCloudURLScheme.tr());
}
} catch (e) {
return left(e.toString());
}
}

View File

@ -65,7 +65,7 @@ class ApplicationDataStorage {
final response = await getIt<KeyValueStorage>().get(KVKeys.pathLocation);
String path = await response.fold(
(error) async {
() async {
// return the default path if the path is not set
final directory = await appFlowyApplicationDataDirectory();
return directory.path;

View File

@ -0,0 +1,38 @@
import 'package:appflowy/env/env.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'cloud_setting_bloc.freezed.dart';
class CloudSettingBloc extends Bloc<CloudSettingEvent, CloudSettingState> {
CloudSettingBloc(CloudType cloudType)
: super(CloudSettingState.initial(cloudType)) {
on<CloudSettingEvent>((event, emit) async {
await event.when(
initial: () async {},
updateCloudType: (CloudType newCloudType) async {
await setCloudType(newCloudType);
emit(state.copyWith(cloudType: newCloudType));
},
);
});
}
}
@freezed
class CloudSettingEvent with _$CloudSettingEvent {
const factory CloudSettingEvent.initial() = _Initial;
const factory CloudSettingEvent.updateCloudType(CloudType newCloudType) =
_UpdateCloudType;
}
@freezed
class CloudSettingState with _$CloudSettingState {
const factory CloudSettingState({
required CloudType cloudType,
}) = _CloudSettingState;
factory CloudSettingState.initial(CloudType cloudType) => CloudSettingState(
cloudType: cloudType,
);
}

View File

@ -10,21 +10,18 @@ import 'package:dartz/dartz.dart';
import '../../../core/notification/user_notification.dart';
class UserCloudConfigListener {
final String userId;
StreamSubscription<SubscribeObject>? _subscription;
void Function(Either<CloudSettingPB, FlowyError>)? _onSettingChanged;
UserNotificationParser? _userParser;
UserCloudConfigListener({
required this.userId,
});
UserCloudConfigListener();
void start({
void Function(Either<CloudSettingPB, FlowyError>)? onSettingChanged,
}) {
_onSettingChanged = onSettingChanged;
_userParser = UserNotificationParser(
id: userId,
id: 'user_cloud_config',
callback: _userNotificationCallback,
);
_subscription = RustStreamReceiver.listen((observable) {

View File

@ -22,7 +22,7 @@ class CreateFileSettingsCubit extends Cubit<bool> {
(value) => bool.parse(value),
);
settingsOrFailure.fold(
(_) => emit(false),
() => emit(false),
(settings) => emit(settings),
);
}

View File

@ -1,86 +0,0 @@
import 'package:appflowy/plugins/database_view/application/defines.dart';
import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:dartz/dartz.dart';
import 'cloud_setting_listener.dart';
part 'setting_supabase_bloc.freezed.dart';
class CloudSettingBloc extends Bloc<CloudSettingEvent, CloudSettingState> {
final UserCloudConfigListener _listener;
CloudSettingBloc({
required String userId,
required CloudSettingPB config,
}) : _listener = UserCloudConfigListener(userId: userId),
super(CloudSettingState.initial(config)) {
on<CloudSettingEvent>((event, emit) async {
await event.when(
initial: () async {
_listener.start(
onSettingChanged: (result) {
if (isClosed) {
return;
}
result.fold(
(config) => add(CloudSettingEvent.didReceiveConfig(config)),
(error) => Log.error(error),
);
},
);
},
enableSync: (bool enable) async {
final update = UpdateCloudConfigPB.create()..enableSync = enable;
updateCloudConfig(update);
},
didReceiveConfig: (CloudSettingPB config) {
emit(
state.copyWith(
config: config,
loadingState: LoadingState.finish(left(unit)),
),
);
},
enableEncrypt: (bool enable) {
final update = UpdateCloudConfigPB.create()..enableEncrypt = enable;
updateCloudConfig(update);
emit(state.copyWith(loadingState: const LoadingState.loading()));
},
);
});
}
Future<void> updateCloudConfig(UpdateCloudConfigPB config) async {
await UserEventSetCloudConfig(config).send();
}
}
@freezed
class CloudSettingEvent with _$CloudSettingEvent {
const factory CloudSettingEvent.initial() = _Initial;
const factory CloudSettingEvent.didReceiveConfig(
CloudSettingPB config,
) = _DidSyncSupabaseConfig;
const factory CloudSettingEvent.enableSync(bool enable) = _EnableSync;
const factory CloudSettingEvent.enableEncrypt(bool enable) = _EnableEncrypt;
}
@freezed
class CloudSettingState with _$CloudSettingState {
const factory CloudSettingState({
required CloudSettingPB config,
required Either<Unit, String> successOrFailure,
required LoadingState loadingState,
}) = _CloudSettingState;
factory CloudSettingState.initial(CloudSettingPB config) => CloudSettingState(
config: config,
successOrFailure: left(unit),
loadingState: LoadingState.finish(left(unit)),
);
}

View File

@ -0,0 +1,97 @@
import 'package:appflowy/env/backend_env.dart';
import 'package:appflowy/env/env.dart';
import 'package:appflowy/plugins/database_view/application/defines.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:dartz/dartz.dart';
import 'cloud_setting_listener.dart';
part 'supabase_cloud_setting_bloc.freezed.dart';
class SupabaseCloudSettingBloc
extends Bloc<SupabaseCloudSettingEvent, SupabaseCloudSettingState> {
final UserCloudConfigListener _listener;
SupabaseCloudSettingBloc({
required CloudSettingPB setting,
}) : _listener = UserCloudConfigListener(),
super(SupabaseCloudSettingState.initial(setting)) {
on<SupabaseCloudSettingEvent>((event, emit) async {
await event.when(
initial: () async {
_listener.start(
onSettingChanged: (result) {
if (isClosed) {
return;
}
result.fold(
(setting) =>
add(SupabaseCloudSettingEvent.didReceiveSetting(setting)),
(error) => Log.error(error),
);
},
);
},
enableSync: (bool enable) async {
final update = UpdateCloudConfigPB.create()..enableSync = enable;
updateCloudConfig(update);
},
didReceiveSetting: (CloudSettingPB setting) {
emit(
state.copyWith(
setting: setting,
loadingState: LoadingState.finish(left(unit)),
),
);
},
enableEncrypt: (bool enable) {
final update = UpdateCloudConfigPB.create()..enableEncrypt = enable;
updateCloudConfig(update);
emit(state.copyWith(loadingState: const LoadingState.loading()));
},
);
});
}
Future<void> updateCloudConfig(UpdateCloudConfigPB setting) async {
await UserEventSetCloudConfig(setting).send();
}
@override
Future<void> close() async {
_listener.stop();
return super.close();
}
}
@freezed
class SupabaseCloudSettingEvent with _$SupabaseCloudSettingEvent {
const factory SupabaseCloudSettingEvent.initial() = _Initial;
const factory SupabaseCloudSettingEvent.didReceiveSetting(
CloudSettingPB setting,
) = _DidSyncSupabaseConfig;
const factory SupabaseCloudSettingEvent.enableSync(bool enable) = _EnableSync;
const factory SupabaseCloudSettingEvent.enableEncrypt(bool enable) =
_EnableEncrypt;
}
@freezed
class SupabaseCloudSettingState with _$SupabaseCloudSettingState {
const factory SupabaseCloudSettingState({
required LoadingState loadingState,
required SupabaseConfiguration config,
required CloudSettingPB setting,
}) = _SupabaseCloudSettingState;
factory SupabaseCloudSettingState.initial(CloudSettingPB setting) =>
SupabaseCloudSettingState(
loadingState: LoadingState.finish(left(unit)),
setting: setting,
config: getIt<AppFlowyCloudSharedEnv>().supabaseConfig,
);
}

View File

@ -0,0 +1,115 @@
import 'package:appflowy/env/backend_env.dart';
import 'package:appflowy/env/env.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:dartz/dartz.dart';
import 'appflowy_cloud_setting_bloc.dart';
part 'supabase_cloud_urls_bloc.freezed.dart';
class SupabaseCloudURLsBloc
extends Bloc<SupabaseCloudURLsEvent, SupabaseCloudURLsState> {
SupabaseCloudURLsBloc() : super(SupabaseCloudURLsState.initial()) {
on<SupabaseCloudURLsEvent>((event, emit) async {
await event.when(
updateUrl: (String url) {
emit(state.copyWith(updatedUrl: url));
},
updateAnonKey: (String anonKey) {
emit(state.copyWith(upatedAnonKey: anonKey));
},
confirmUpdate: () async {
if (state.updatedUrl.isEmpty) {
emit(
state.copyWith(
urlError: none(),
anonKeyError: none(),
restartApp: true,
),
);
await setSupbaseServer(none(), none());
} else {
// The anon key can't be empty if the url is not empty.
if (state.upatedAnonKey.isEmpty) {
emit(
state.copyWith(
urlError: none(),
anonKeyError: some(
LocaleKeys.settings_menu_cloudSupabaseAnonKeyCanNotBeEmpty
.tr(),
),
restartApp: false,
),
);
return;
}
validateUrl(state.updatedUrl).fold(
(error) => emit(state.copyWith(urlError: Some(error))),
(_) async {
await setSupbaseServer(
Some(state.updatedUrl),
Some(state.upatedAnonKey),
);
add(const SupabaseCloudURLsEvent.didSaveConfig());
},
);
}
},
didSaveConfig: () {
emit(
state.copyWith(
urlError: none(),
anonKeyError: none(),
restartApp: true,
),
);
},
);
});
}
Future<void> updateCloudConfig(UpdateCloudConfigPB setting) async {
await UserEventSetCloudConfig(setting).send();
}
}
@freezed
class SupabaseCloudURLsEvent with _$SupabaseCloudURLsEvent {
const factory SupabaseCloudURLsEvent.updateUrl(String text) = _UpdateUrl;
const factory SupabaseCloudURLsEvent.updateAnonKey(String text) =
_UpdateAnonKey;
const factory SupabaseCloudURLsEvent.confirmUpdate() = _UpdateConfig;
const factory SupabaseCloudURLsEvent.didSaveConfig() = _DidSaveConfig;
}
@freezed
class SupabaseCloudURLsState with _$SupabaseCloudURLsState {
const factory SupabaseCloudURLsState({
required SupabaseConfiguration config,
required String updatedUrl,
required String upatedAnonKey,
required Option<String> urlError,
required Option<String> anonKeyError,
required bool restartApp,
}) = _SupabaseCloudURLsState;
factory SupabaseCloudURLsState.initial() {
final config = getIt<AppFlowyCloudSharedEnv>().supabaseConfig;
return SupabaseCloudURLsState(
updatedUrl: config.url,
upatedAnonKey: config.anon_key,
urlError: none(),
anonKeyError: none(),
restartApp: false,
config: config,
);
}
}

View File

@ -35,7 +35,7 @@ class FolderBloc extends Bloc<FolderEvent, FolderState> {
Future<void> _setFolderExpandStatus(bool isExpanded) async {
final result = await getIt<KeyValueStorage>().get(KVKeys.expandedViews);
final map = result.fold(
(l) => {},
() => {},
(r) => jsonDecode(r),
);
if (isExpanded) {
@ -50,7 +50,7 @@ class FolderBloc extends Bloc<FolderEvent, FolderState> {
Future<bool> _getFolderExpandStatus() async {
return getIt<KeyValueStorage>().get(KVKeys.expandedViews).then((result) {
return result.fold((l) => true, (r) {
return result.fold(() => true, (r) {
final map = jsonDecode(r);
return map[state.type.name] ?? true;
});

View File

@ -179,7 +179,7 @@ class ViewBloc extends Bloc<ViewEvent, ViewState> {
Future<void> _setViewIsExpanded(ViewPB view, bool isExpanded) async {
final result = await getIt<KeyValueStorage>().get(KVKeys.expandedViews);
final map = result.fold(
(l) => {},
() => {},
(r) => jsonDecode(r),
);
if (isExpanded) {
@ -192,7 +192,7 @@ class ViewBloc extends Bloc<ViewEvent, ViewState> {
Future<bool> _getViewIsExpanded(ViewPB view) {
return getIt<KeyValueStorage>().get(KVKeys.expandedViews).then((result) {
return result.fold((l) => false, (r) {
return result.fold(() => false, (r) {
final map = jsonDecode(r);
return map[view.id] ?? false;
});

View File

@ -21,7 +21,7 @@ Future<void> createViewAndShowRenameDialogIfNeeded(
KVKeys.showRenameDialogWhenCreatingNewFile,
(value) => bool.parse(value),
);
final showRenameDialog = value.fold((l) => false, (r) => r);
final showRenameDialog = value.fold(() => false, (r) => r);
if (context.mounted && showRenameDialog) {
NavigatorTextFieldDialog(
title: dialogTitle,

View File

@ -89,7 +89,7 @@ class SidebarUser extends StatelessWidget {
Log.warn("Can't pop dialog context");
}
},
didOpenUser: () async {
restartApp: () async {
// Pop the dialog using the dialog context
Navigator.of(dialogContext).pop();
await runAppFlowy();

View File

@ -1,7 +1,6 @@
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/settings_notifications_view.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/setting_cloud_view.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/settings_appearance_view.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/settings_customize_shortcuts_view.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/settings_file_system_view.dart';
@ -14,6 +13,7 @@ import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'widgets/setting_cloud.dart';
const _dialogHorizontalPadding = EdgeInsets.symmetric(horizontal: 12);
const _contentInsetPadding = EdgeInsets.fromLTRB(0.0, 12.0, 0.0, 16.0);
@ -21,13 +21,13 @@ const _contentInsetPadding = EdgeInsets.fromLTRB(0.0, 12.0, 0.0, 16.0);
class SettingsDialog extends StatelessWidget {
final VoidCallback dismissDialog;
final VoidCallback didLogout;
final VoidCallback didOpenUser;
final VoidCallback restartApp;
final UserProfilePB user;
SettingsDialog(
this.user, {
required this.dismissDialog,
required this.didLogout,
required this.didOpenUser,
required this.restartApp,
Key? key,
}) : super(key: ValueKey(user.id));
@ -100,12 +100,14 @@ class SettingsDialog extends StatelessWidget {
user,
didLogin: () => dismissDialog(),
didLogout: didLogout,
didOpenUser: didOpenUser,
didOpenUser: restartApp,
);
case SettingsPage.notifications:
return const SettingsNotificationsView();
case SettingsPage.cloud:
return SettingCloudView(userId: user.id.toString());
return SettingCloud(
didResetServerUrl: () => restartApp(),
);
case SettingsPage.shortcuts:
return const SettingsCustomizeShortcutsWrapper();
default:

View File

@ -0,0 +1,259 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/workspace/application/settings/appflowy_cloud_setting_bloc.dart';
import 'package:appflowy/workspace/application/settings/appflowy_cloud_urls_bloc.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/user_setting.pb.dart';
import 'package:dartz/dartz.dart' show Either;
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/size.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_infra_ui/widget/error_page.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:url_launcher/url_launcher.dart';
class SettingAppFlowyCloudView extends StatelessWidget {
final VoidCallback didResetServerUrl;
const SettingAppFlowyCloudView({required this.didResetServerUrl, super.key});
@override
Widget build(BuildContext context) {
return FutureBuilder<Either<CloudSettingPB, FlowyError>>(
future: UserEventGetCloudConfig().send(),
builder: (context, snapshot) {
if (snapshot.data != null &&
snapshot.connectionState == ConnectionState.done) {
return snapshot.data!.fold(
(setting) => _renderContent(setting),
(err) => FlowyErrorPage.message(err.toString(), howToFix: ""),
);
} else {
return const Center(
child: CircularProgressIndicator(),
);
}
},
);
}
BlocProvider<AppFlowyCloudSettingBloc> _renderContent(
CloudSettingPB setting,
) {
return BlocProvider(
create: (context) => AppFlowyCloudSettingBloc(setting)
..add(const AppFlowyCloudSettingEvent.initial()),
child: Column(
children: [
const AppFlowyCloudEnableSync(),
const VSpace(40),
AppFlowyCloudURLs(didUpdateUrls: () => didResetServerUrl()),
],
),
);
}
}
class AppFlowyCloudURLs extends StatelessWidget {
final VoidCallback didUpdateUrls;
const AppFlowyCloudURLs({
required this.didUpdateUrls,
super.key,
});
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) =>
AppFlowyCloudURLsBloc()..add(const AppFlowyCloudURLsEvent.initial()),
child: BlocListener<AppFlowyCloudURLsBloc, AppFlowyCloudURLsState>(
listener: (context, state) {
if (state.restartApp) {
didUpdateUrls();
}
},
child: BlocBuilder<AppFlowyCloudURLsBloc, AppFlowyCloudURLsState>(
builder: (context, state) {
return Column(
children: [
const AppFlowySelfhostTip(),
CloudURLInput(
title: LocaleKeys.settings_menu_cloudURL.tr(),
url: state.config.base_url,
hint: LocaleKeys.settings_menu_cloudURLHint.tr(),
onChanged: (text) {
context.read<AppFlowyCloudURLsBloc>().add(
AppFlowyCloudURLsEvent.updateServerUrl(
text,
),
);
},
),
const VSpace(20),
FlowyButton(
isSelected: true,
useIntrinsicWidth: true,
margin: const EdgeInsets.symmetric(
horizontal: 30,
vertical: 10,
),
text: FlowyText(
LocaleKeys.settings_menu_restartApp.tr(),
),
onTap: () {
NavigatorAlertDialog(
title: LocaleKeys.settings_menu_restartAppTip.tr(),
confirm: () => context.read<AppFlowyCloudURLsBloc>().add(
const AppFlowyCloudURLsEvent.confirmUpdate(),
),
).show(context);
},
),
],
);
},
),
),
);
}
}
class AppFlowySelfhostTip extends StatelessWidget {
final url =
"https://docs.appflowy.io/docs/guides/appflowy/self-hosting-appflowy#build-appflowy-with-a-self-hosted-server";
const AppFlowySelfhostTip({super.key});
@override
Widget build(BuildContext context) {
return Opacity(
opacity: 0.6,
child: RichText(
text: TextSpan(
children: <TextSpan>[
TextSpan(
text: LocaleKeys.settings_menu_selfHostStart.tr(),
style: Theme.of(context).textTheme.bodySmall!,
),
TextSpan(
text: " ${LocaleKeys.settings_menu_selfHostContent.tr()} ",
style: Theme.of(context).textTheme.bodyMedium!.copyWith(
fontSize: FontSizes.s14,
color: Theme.of(context).colorScheme.primary,
decoration: TextDecoration.underline,
),
recognizer: TapGestureRecognizer()..onTap = () => _launchURL(),
),
TextSpan(
text: LocaleKeys.settings_menu_selfHostEnd.tr(),
style: Theme.of(context).textTheme.bodySmall!,
),
],
),
),
);
}
Future<void> _launchURL() async {
final uri = Uri.parse(url);
if (await canLaunchUrl(uri)) {
await launchUrl(uri);
} else {
Log.error("Could not launch $url");
}
}
}
@visibleForTesting
class CloudURLInput extends StatefulWidget {
final String title;
final String url;
final String hint;
final Function(String) onChanged;
const CloudURLInput({
required this.title,
required this.url,
required this.hint,
required this.onChanged,
Key? key,
}) : super(key: key);
@override
CloudURLInputState createState() => CloudURLInputState();
}
class CloudURLInputState extends State<CloudURLInput> {
late TextEditingController _controller;
@override
void initState() {
super.initState();
_controller = TextEditingController(text: widget.url);
}
@override
Widget build(BuildContext context) {
return TextField(
controller: _controller,
style: const TextStyle(fontSize: 12.0),
decoration: InputDecoration(
contentPadding: const EdgeInsets.symmetric(vertical: 6),
labelText: widget.title,
labelStyle: Theme.of(context)
.textTheme
.titleMedium!
.copyWith(fontWeight: FontWeight.w400, fontSize: 16),
enabledBorder: UnderlineInputBorder(
borderSide:
BorderSide(color: Theme.of(context).colorScheme.onBackground),
),
focusedBorder: UnderlineInputBorder(
borderSide: BorderSide(color: Theme.of(context).colorScheme.primary),
),
hintText: widget.hint,
errorText: context
.read<AppFlowyCloudURLsBloc>()
.state
.urlError
.fold(() => null, (error) => error),
),
onChanged: widget.onChanged,
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
}
class AppFlowyCloudEnableSync extends StatelessWidget {
const AppFlowyCloudEnableSync({super.key});
@override
Widget build(BuildContext context) {
return BlocBuilder<AppFlowyCloudSettingBloc, AppFlowyCloudSettingState>(
builder: (context, state) {
return Row(
children: [
FlowyText.medium(LocaleKeys.settings_menu_enableSync.tr()),
const Spacer(),
Switch(
onChanged: (bool value) {
context.read<AppFlowyCloudSettingBloc>().add(
AppFlowyCloudSettingEvent.enableSync(value),
);
},
value: state.setting.enableSync,
),
],
);
},
);
}
}

View File

@ -0,0 +1,166 @@
import 'package:appflowy/env/env.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/workspace/application/settings/cloud_setting_bloc.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/setting_local_cloud.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'setting_appflowy_cloud.dart';
import 'setting_supabase_cloud.dart';
class SettingCloud extends StatelessWidget {
final VoidCallback didResetServerUrl;
const SettingCloud({required this.didResetServerUrl, super.key});
@override
Widget build(BuildContext context) {
return FutureBuilder(
future: getCloudType(),
builder: (BuildContext context, AsyncSnapshot<CloudType> snapshot) {
if (snapshot.hasData) {
final cloudType = snapshot.data!;
return BlocProvider(
create: (context) => CloudSettingBloc(cloudType),
child: BlocBuilder<CloudSettingBloc, CloudSettingState>(
builder: (context, state) {
return Column(
children: [
Row(
children: [
Expanded(
child: FlowyText.medium(
LocaleKeys.settings_menu_cloudServerType.tr(),
),
),
Tooltip(
message:
LocaleKeys.settings_menu_cloudServerTypeTip.tr(),
child: CloudTypeSwitcher(
cloudType: state.cloudType,
onSelected: (newCloudType) {
context.read<CloudSettingBloc>().add(
CloudSettingEvent.updateCloudType(
newCloudType,
),
);
},
),
),
],
),
_viewFromCloudType(state.cloudType),
],
);
},
),
);
} else {
return const Center(
child: CircularProgressIndicator(),
);
}
},
);
}
Widget _viewFromCloudType(CloudType cloudType) {
switch (cloudType) {
case CloudType.local:
return SettingLocalCloud(didResetServerUrl: didResetServerUrl);
case CloudType.supabase:
return SettingSupabaseCloudView(
didResetServerUrl: didResetServerUrl,
);
case CloudType.appflowyCloud:
return SettingAppFlowyCloudView(
didResetServerUrl: didResetServerUrl,
);
}
}
}
class CloudTypeSwitcher extends StatelessWidget {
final CloudType cloudType;
final Function(CloudType) onSelected;
const CloudTypeSwitcher({
required this.cloudType,
required this.onSelected,
super.key,
});
@override
Widget build(BuildContext context) {
return AppFlowyPopover(
direction: PopoverDirection.bottomWithRightAligned,
child: FlowyTextButton(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 6),
titleFromCloudType(cloudType),
fontColor: Theme.of(context).colorScheme.onBackground,
fillColor: Colors.transparent,
onPressed: () {},
),
popupBuilder: (BuildContext context) {
return ListView.builder(
shrinkWrap: true,
itemBuilder: (context, index) {
return CloudTypeItem(
cloudType: CloudType.values[index],
currentCloudtype: cloudType,
onSelected: onSelected,
);
},
itemCount: CloudType.values.length,
);
},
);
}
}
class CloudTypeItem extends StatelessWidget {
final CloudType cloudType;
final CloudType currentCloudtype;
final Function(CloudType) onSelected;
const CloudTypeItem({
required this.cloudType,
required this.currentCloudtype,
required this.onSelected,
super.key,
});
@override
Widget build(BuildContext context) {
return SizedBox(
height: 32,
child: FlowyButton(
text: FlowyText.medium(
titleFromCloudType(cloudType),
),
rightIcon: currentCloudtype == cloudType
? const FlowySvg(FlowySvgs.check_s)
: null,
onTap: () {
if (currentCloudtype != cloudType) {
onSelected(cloudType);
}
PopoverContainer.of(context).close();
},
),
);
}
}
String titleFromCloudType(CloudType cloudType) {
switch (cloudType) {
case CloudType.local:
return LocaleKeys.settings_menu_cloudLocal.tr();
case CloudType.supabase:
return LocaleKeys.settings_menu_cloudSupabase.tr();
case CloudType.appflowyCloud:
return LocaleKeys.settings_menu_cloudAppFlowy.tr();
}
}

View File

@ -1,196 +0,0 @@
import 'package:appflowy/env/env.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/workspace/application/settings/setting_supabase_bloc.dart';
import 'package:appflowy/workspace/presentation/home/toast.dart';
import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/user_setting.pb.dart';
import 'package:dartz/dartz.dart' show Either;
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/size.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_infra_ui/widget/error_page.dart';
import 'package:flowy_infra_ui/widget/flowy_tooltip.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class SettingCloudView extends StatelessWidget {
final String userId;
const SettingCloudView({required this.userId, super.key});
@override
Widget build(BuildContext context) {
return FutureBuilder<Either<CloudSettingPB, FlowyError>>(
future: UserEventGetCloudConfig().send(),
builder: (context, snapshot) {
if (snapshot.data != null &&
snapshot.connectionState == ConnectionState.done) {
return snapshot.data!.fold(
(config) {
return BlocProvider(
create: (context) => CloudSettingBloc(
userId: userId,
config: config,
)..add(const CloudSettingEvent.initial()),
child: BlocBuilder<CloudSettingBloc, CloudSettingState>(
builder: (context, state) {
return Column(
children: [
const EnableSync(),
// Currently the appflowy cloud is not support end-to-end encryption.
if (!isAppFlowyCloudEnabled) const EnableEncrypt(),
if (isAppFlowyCloudEnabled)
AppFlowyCloudInformationWidget(
url: state.config.serverUrl,
),
],
);
},
),
);
},
(err) {
return FlowyErrorPage.message(err.toString(), howToFix: "");
},
);
} else {
return const Center(
child: CircularProgressIndicator(),
);
}
},
);
}
}
class AppFlowyCloudInformationWidget extends StatelessWidget {
final String url;
const AppFlowyCloudInformationWidget({required this.url, super.key});
@override
Widget build(BuildContext context) {
return Column(
children: [
Row(
children: [
Expanded(
// Wrap the Opacity widget with Expanded
child: Opacity(
opacity: 0.6,
child: FlowyText(
"${LocaleKeys.settings_menu_cloudURL.tr()}: $url",
maxLines: null, // Allow the text to wrap
),
),
),
],
),
],
);
}
}
class EnableEncrypt extends StatelessWidget {
const EnableEncrypt({super.key});
@override
Widget build(BuildContext context) {
return BlocBuilder<CloudSettingBloc, CloudSettingState>(
builder: (context, state) {
final indicator = state.loadingState.when(
loading: () => const CircularProgressIndicator.adaptive(),
finish: (successOrFail) => const SizedBox.shrink(),
);
return Column(
children: [
Row(
children: [
FlowyText.medium(LocaleKeys.settings_menu_enableEncrypt.tr()),
const Spacer(),
indicator,
const HSpace(3),
Switch(
onChanged: state.config.enableEncrypt
? null
: (bool value) {
context
.read<CloudSettingBloc>()
.add(CloudSettingEvent.enableEncrypt(value));
},
value: state.config.enableEncrypt,
),
],
),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.start,
children: [
IntrinsicHeight(
child: Opacity(
opacity: 0.6,
child: FlowyText.medium(
LocaleKeys.settings_menu_enableEncryptPrompt.tr(),
maxLines: 13,
),
),
),
const VSpace(6),
SizedBox(
height: 40,
child: FlowyTooltip(
message: LocaleKeys.settings_menu_clickToCopySecret.tr(),
child: FlowyButton(
disable: !(state.config.enableEncrypt),
decoration: BoxDecoration(
borderRadius: Corners.s5Border,
border: Border.all(
color: Theme.of(context).colorScheme.secondary,
),
),
text: FlowyText.medium(state.config.encryptSecret),
onTap: () async {
await Clipboard.setData(
ClipboardData(text: state.config.encryptSecret),
);
showMessageToast(LocaleKeys.message_copy_success.tr());
},
),
),
),
],
),
],
);
},
);
}
}
class EnableSync extends StatelessWidget {
const EnableSync({super.key});
@override
Widget build(BuildContext context) {
return BlocBuilder<CloudSettingBloc, CloudSettingState>(
builder: (context, state) {
return Row(
children: [
FlowyText.medium(LocaleKeys.settings_menu_enableSync.tr()),
const Spacer(),
Switch(
onChanged: (bool value) {
context.read<CloudSettingBloc>().add(
CloudSettingEvent.enableSync(value),
);
},
value: state.config.enableSync,
),
],
);
},
);
}
}

View File

@ -0,0 +1,40 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/style_widget/button.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flutter/material.dart';
class SettingLocalCloud extends StatelessWidget {
final VoidCallback didResetServerUrl;
const SettingLocalCloud({
required this.didResetServerUrl,
super.key,
});
@override
Widget build(BuildContext context) {
return Row(
children: [
FlowyButton(
isSelected: true,
useIntrinsicWidth: true,
margin: const EdgeInsets.symmetric(
horizontal: 30,
vertical: 10,
),
text: FlowyText(
LocaleKeys.settings_menu_restartApp.tr(),
),
onTap: () {
NavigatorAlertDialog(
title: LocaleKeys.settings_menu_restartAppTip.tr(),
confirm: didResetServerUrl,
).show(context);
},
),
const Spacer(),
],
);
}
}

View File

@ -0,0 +1,356 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/workspace/application/settings/supabase_cloud_setting_bloc.dart';
import 'package:appflowy/workspace/application/settings/supabase_cloud_urls_bloc.dart';
import 'package:appflowy/workspace/presentation/home/toast.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/user_setting.pb.dart';
import 'package:dartz/dartz.dart' show Either;
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/size.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_infra_ui/widget/error_page.dart';
import 'package:flowy_infra_ui/widget/flowy_tooltip.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:url_launcher/url_launcher.dart';
class SettingSupabaseCloudView extends StatelessWidget {
final VoidCallback didResetServerUrl;
const SettingSupabaseCloudView({required this.didResetServerUrl, super.key});
@override
Widget build(BuildContext context) {
return FutureBuilder<Either<CloudSettingPB, FlowyError>>(
future: UserEventGetCloudConfig().send(),
builder: (context, snapshot) {
if (snapshot.data != null &&
snapshot.connectionState == ConnectionState.done) {
return snapshot.data!.fold(
(setting) {
return BlocProvider(
create: (context) => SupabaseCloudSettingBloc(
setting: setting,
)..add(const SupabaseCloudSettingEvent.initial()),
child: Column(
children: [
BlocBuilder<SupabaseCloudSettingBloc,
SupabaseCloudSettingState>(
builder: (context, state) {
return const Column(
children: [
SupabaseEnableSync(),
EnableEncrypt(),
],
);
},
),
const VSpace(40),
const SupabaseSelfhostTip(),
SupabaseCloudURLs(
didUpdateUrls: didResetServerUrl,
),
],
),
);
},
(err) {
return FlowyErrorPage.message(err.toString(), howToFix: "");
},
);
} else {
return const Center(
child: CircularProgressIndicator(),
);
}
},
);
}
}
class SupabaseCloudURLs extends StatelessWidget {
final VoidCallback didUpdateUrls;
const SupabaseCloudURLs({
required this.didUpdateUrls,
super.key,
});
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => SupabaseCloudURLsBloc(),
child: BlocListener<SupabaseCloudURLsBloc, SupabaseCloudURLsState>(
listener: (context, state) {
if (state.restartApp) {
didUpdateUrls();
}
},
child: BlocBuilder<SupabaseCloudURLsBloc, SupabaseCloudURLsState>(
builder: (context, state) {
return Column(
children: [
SupabaseInput(
title: LocaleKeys.settings_menu_cloudSupabaseUrl.tr(),
url: state.config.url,
hint: LocaleKeys.settings_menu_cloudURLHint.tr(),
onChanged: (text) {
context
.read<SupabaseCloudURLsBloc>()
.add(SupabaseCloudURLsEvent.updateUrl(text));
},
error: state.urlError.fold(() => null, (a) => a),
),
SupabaseInput(
title: LocaleKeys.settings_menu_cloudSupabaseAnonKey.tr(),
url: state.config.anon_key,
hint: LocaleKeys.settings_menu_cloudURLHint.tr(),
onChanged: (text) {
context
.read<SupabaseCloudURLsBloc>()
.add(SupabaseCloudURLsEvent.updateAnonKey(text));
},
error: state.anonKeyError.fold(() => null, (a) => a),
),
const VSpace(20),
FlowyButton(
isSelected: true,
useIntrinsicWidth: true,
margin: const EdgeInsets.symmetric(
horizontal: 30,
vertical: 10,
),
text: FlowyText(
LocaleKeys.settings_menu_restartApp.tr(),
),
onTap: () {
NavigatorAlertDialog(
title: LocaleKeys.settings_menu_restartAppTip.tr(),
confirm: () => context
.read<SupabaseCloudURLsBloc>()
.add(const SupabaseCloudURLsEvent.confirmUpdate()),
).show(context);
},
),
],
);
},
),
),
);
}
}
class EnableEncrypt extends StatelessWidget {
const EnableEncrypt({super.key});
@override
Widget build(BuildContext context) {
return BlocBuilder<SupabaseCloudSettingBloc, SupabaseCloudSettingState>(
builder: (context, state) {
final indicator = state.loadingState.when(
loading: () => const CircularProgressIndicator.adaptive(),
finish: (successOrFail) => const SizedBox.shrink(),
);
return Column(
children: [
Row(
children: [
FlowyText.medium(LocaleKeys.settings_menu_enableEncrypt.tr()),
const Spacer(),
indicator,
const HSpace(3),
Switch(
onChanged: state.setting.enableEncrypt
? null
: (bool value) {
context.read<SupabaseCloudSettingBloc>().add(
SupabaseCloudSettingEvent.enableEncrypt(value),
);
},
value: state.setting.enableEncrypt,
),
],
),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.start,
children: [
IntrinsicHeight(
child: Opacity(
opacity: 0.6,
child: FlowyText.medium(
LocaleKeys.settings_menu_enableEncryptPrompt.tr(),
maxLines: 13,
),
),
),
const VSpace(6),
SizedBox(
height: 40,
child: FlowyTooltip(
message: LocaleKeys.settings_menu_clickToCopySecret.tr(),
child: FlowyButton(
disable: !(state.setting.enableEncrypt),
decoration: BoxDecoration(
borderRadius: Corners.s5Border,
border: Border.all(
color: Theme.of(context).colorScheme.secondary,
),
),
text: FlowyText.medium(state.setting.encryptSecret),
onTap: () async {
await Clipboard.setData(
ClipboardData(text: state.setting.encryptSecret),
);
showMessageToast(LocaleKeys.message_copy_success.tr());
},
),
),
),
],
),
],
);
},
);
}
}
class SupabaseEnableSync extends StatelessWidget {
const SupabaseEnableSync({super.key});
@override
Widget build(BuildContext context) {
return BlocBuilder<SupabaseCloudSettingBloc, SupabaseCloudSettingState>(
builder: (context, state) {
return Row(
children: [
FlowyText.medium(LocaleKeys.settings_menu_enableSync.tr()),
const Spacer(),
Switch(
onChanged: (bool value) {
context.read<SupabaseCloudSettingBloc>().add(
SupabaseCloudSettingEvent.enableSync(value),
);
},
value: state.setting.enableSync,
),
],
);
},
);
}
}
@visibleForTesting
class SupabaseInput extends StatefulWidget {
final String title;
final String url;
final String hint;
final String? error;
final Function(String) onChanged;
const SupabaseInput({
required this.title,
required this.url,
required this.hint,
required this.onChanged,
required this.error,
Key? key,
}) : super(key: key);
@override
SupabaseInputState createState() => SupabaseInputState();
}
class SupabaseInputState extends State<SupabaseInput> {
late TextEditingController _controller;
@override
void initState() {
super.initState();
_controller = TextEditingController(text: widget.url);
}
@override
Widget build(BuildContext context) {
return TextField(
controller: _controller,
style: const TextStyle(fontSize: 12.0),
decoration: InputDecoration(
contentPadding: const EdgeInsets.symmetric(vertical: 6),
labelText: widget.title,
labelStyle: Theme.of(context)
.textTheme
.titleMedium!
.copyWith(fontWeight: FontWeight.w400, fontSize: 16),
enabledBorder: UnderlineInputBorder(
borderSide:
BorderSide(color: Theme.of(context).colorScheme.onBackground),
),
focusedBorder: UnderlineInputBorder(
borderSide: BorderSide(color: Theme.of(context).colorScheme.primary),
),
hintText: widget.hint,
errorText: widget.error,
),
onChanged: widget.onChanged,
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
}
class SupabaseSelfhostTip extends StatelessWidget {
final url =
"https://docs.appflowy.io/docs/guides/appflowy/self-hosting-appflowy-using-supabase";
const SupabaseSelfhostTip({super.key});
@override
Widget build(BuildContext context) {
return Opacity(
opacity: 0.6,
child: RichText(
text: TextSpan(
children: <TextSpan>[
TextSpan(
text: LocaleKeys.settings_menu_selfHostStart.tr(),
style: Theme.of(context).textTheme.bodySmall!,
),
TextSpan(
text: " ${LocaleKeys.settings_menu_selfHostContent.tr()} ",
style: Theme.of(context).textTheme.bodyMedium!.copyWith(
fontSize: FontSizes.s14,
color: Theme.of(context).colorScheme.primary,
decoration: TextDecoration.underline,
),
recognizer: TapGestureRecognizer()..onTap = () => _launchURL(),
),
TextSpan(
text: LocaleKeys.settings_menu_selfHostEnd.tr(),
style: Theme.of(context).textTheme.bodySmall!,
),
],
),
),
);
}
Future<void> _launchURL() async {
final uri = Uri.parse(url);
if (await canLaunchUrl(uri)) {
await launchUrl(uri);
} else {
Log.error("Could not launch $url");
}
}
}

View File

@ -1,3 +1,4 @@
import 'package:appflowy/env/env.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/user/application/sign_in_bloc.dart';
@ -55,7 +56,7 @@ class SettingThirdPartyLogin extends StatelessWidget {
const VSpace(6),
promptMessage,
const VSpace(6),
const ThirdPartySignInButtons(),
if (isAuthEnabled) const ThirdPartySignInButtons(),
const VSpace(6),
],
);

View File

@ -1,11 +1,8 @@
import 'package:appflowy/env/env.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/workspace/application/settings/settings_dialog_bloc.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/settings_menu_element.dart';
import 'package:appflowy_backend/protobuf/flowy-user/auth.pbenum.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class SettingsMenu extends StatelessWidget {
const SettingsMenu({
@ -19,9 +16,6 @@ class SettingsMenu extends StatelessWidget {
@override
Widget build(BuildContext context) {
final bool showSyncSetting = isCloudEnabled &&
context.read<SettingsDialogBloc>().state.userProfile.authType !=
AuthTypePB.Local;
return Column(
children: [
SettingsMenuElement(
@ -63,17 +57,14 @@ class SettingsMenu extends StatelessWidget {
icon: Icons.notifications_outlined,
changeSelectedPage: changeSelectedPage,
),
// Only show supabase setting if supabase is enabled and the current auth type is not local
if (showSyncSetting) ...[
const SizedBox(height: 10),
SettingsMenuElement(
page: SettingsPage.cloud,
selectedPage: currentPage,
label: LocaleKeys.settings_menu_cloudSetting.tr(),
icon: Icons.sync,
changeSelectedPage: changeSelectedPage,
),
],
const SizedBox(height: 10),
SettingsMenuElement(
page: SettingsPage.cloud,
selectedPage: currentPage,
label: LocaleKeys.settings_menu_cloudSetting.tr(),
icon: Icons.sync,
changeSelectedPage: changeSelectedPage,
),
const SizedBox(height: 10),
SettingsMenuElement(
page: SettingsPage.shortcuts,

View File

@ -54,7 +54,7 @@ class SettingsUserView extends StatelessWidget {
mainAxisSize: MainAxisSize.min,
children: [
_buildUserIconSetting(context),
if (isCloudEnabled && user.authType != AuthTypePB.Local) ...[
if (isAuthEnabled && user.authType != AuthTypePB.Local) ...[
const VSpace(12),
UserEmailInput(user.email),
],
@ -190,7 +190,7 @@ class SettingsUserView extends StatelessWidget {
BuildContext context,
SettingsUserState state,
) {
if (!isCloudEnabled) {
if (!isAuthEnabled) {
return const SizedBox.shrink();
}

View File

@ -2148,6 +2148,7 @@ dependencies = [
"client-api",
"collab-database",
"collab-document",
"collab-persistence",
"fancy-regex 0.11.0",
"flowy-codegen",
"flowy-derive",

View File

@ -266,9 +266,27 @@
"selfEncryptionLogoutPrompt": "Are you sure you want to log out? Please ensure you have copied the encryption secret",
"syncSetting": "Sync Setting",
"cloudSetting": "Cloud Setting",
"cloudURL": "Server URL",
"enableSync": "Enable sync",
"enableEncrypt": "Encrypt data",
"cloudURL": "Base URL",
"invalidCloudURLScheme": "Invalid Scheme",
"cloudServerType": "Cloud server",
"cloudServerTypeTip": "Please note that it might log out your current account after switching the cloud server",
"cloudLocal": "Local",
"cloudSupabase": "Supabase",
"cloudSupabaseUrl": "Supabase URL",
"cloudSupabaseAnonKey": "Supabase anon key",
"cloudSupabaseAnonKeyCanNotBeEmpty": "The anon key can't be empty if the supabase url is not empty",
"cloudAppFlowy": "AppFlowy Cloud",
"clickToCopy": "Click to copy",
"selfHostStart": "If you don't have a server, please refer to the",
"selfHostContent": "document",
"selfHostEnd": "for guidance on how to self-host your own server",
"cloudURLHint": "Input the base URL of your server",
"cloudWSURL": "Websocket URL",
"cloudWSURLHint": "Input the websocket address of your server",
"restartApp": "Restart",
"restartAppTip": "Restart the application for the changes to take effect. Please note that this might log out your current account",
"enableEncryptPrompt": "Activate encryption to secure your data with this secret. Store it safely; once enabled, it can't be turned off. If lost, your data becomes irretrievable. Click to copy",
"inputEncryptPrompt": "Please enter your encryption secret for",
"clickToCopySecret": "Click to copy secret",

View File

@ -1952,6 +1952,7 @@ dependencies = [
"client-api",
"collab-database",
"collab-document",
"collab-persistence",
"fancy-regex 0.11.0",
"flowy-codegen",
"flowy-derive",

View File

@ -25,13 +25,7 @@ impl AppFlowyDartConfiguration {
pub fn write_env_from(env_str: &str) {
let configuration = Self::from_str(env_str);
configuration.cloud_type.write_env();
let is_valid = configuration.appflowy_cloud_config.write_env().is_ok();
// Note on Configuration Priority:
// If both Supabase config and AppFlowy cloud config are provided in the '.env' file,
// the AppFlowy cloud config will be prioritized and the Supabase config ignored.
// Ensure only one of these configurations is active at any given time.
if !is_valid {
let _ = configuration.supabase_config.write_env();
}
configuration.appflowy_cloud_config.write_env();
configuration.supabase_config.write_env();
}
}

View File

@ -55,14 +55,8 @@ pub extern "C" fn init_sdk(data: *mut c_char) -> i64 {
let configuration = AppFlowyDartConfiguration::from_str(serde_str);
configuration.cloud_type.write_env();
let is_valid = configuration.appflowy_cloud_config.write_env().is_ok();
// Note on Configuration Priority:
// If both Supabase config and AppFlowy cloud config are provided in the '.env' file,
// the AppFlowy cloud config will be prioritized and the Supabase config ignored.
// Ensure only one of these configurations is active at any given time.
if !is_valid {
let _ = configuration.supabase_config.write_env();
}
configuration.appflowy_cloud_config.write_env();
configuration.supabase_config.write_env();
let log_crates = vec!["flowy-ffi".to_string()];
let config = AppFlowyCoreConfig::new(

View File

@ -213,7 +213,7 @@ impl AFCloudTest {
test.set_auth_type(AuthTypePB::AFCloud);
test
.server_provider
.set_authenticator(Authenticator::AFCloud);
.set_authenticator(Authenticator::AppFlowyCloud);
Some(Self { inner: test })
}

View File

@ -35,6 +35,7 @@ impl fmt::Debug for AppFlowyCoreConfig {
if let Some(config) = &self.cloud_config {
debug.field("base_url", &config.base_url);
debug.field("ws_url", &config.ws_base_url);
debug.field("gotrue_url", &config.gotrue_url);
}
debug.finish()
}
@ -43,8 +44,12 @@ impl fmt::Debug for AppFlowyCoreConfig {
fn migrate_local_version_data_folder(root: &str, url: &str) -> String {
// Isolate the user data folder by using the base url of AppFlowy cloud. This is to avoid
// the user data folder being shared by different AppFlowy cloud.
let server_base64 = URL_SAFE_ENGINE.encode(url);
let storage_path = format!("{}_{}", root, server_base64);
let storage_path = if !url.is_empty() {
let server_base64 = URL_SAFE_ENGINE.encode(url);
format!("{}_{}", root, server_base64)
} else {
root.to_string()
};
// Copy the user data folder from the root path to the isolated path
// The root path without any suffix is the created by the local version AppFlowy

View File

@ -14,7 +14,6 @@ use flowy_server_config::af_cloud_config::AFCloudConfiguration;
use flowy_server_config::supabase_config::SupabaseConfiguration;
use flowy_sqlite::kv::StorePreferences;
use flowy_user::services::database::{get_user_profile, get_user_workspace, open_user_db};
use flowy_user_deps::cloud::UserCloudService;
use flowy_user_deps::entities::*;
use crate::AppFlowyCoreConfig;
@ -56,8 +55,6 @@ pub struct ServerProvider {
providers: RwLock<HashMap<ServerType, Arc<dyn AppFlowyServer>>>,
pub(crate) encryption: RwLock<Arc<dyn AppFlowyEncryption>>,
pub(crate) store_preferences: Weak<StorePreferences>,
pub(crate) cache_user_service: RwLock<HashMap<ServerType, Arc<dyn UserCloudService>>>,
pub(crate) enable_sync: RwLock<bool>,
pub(crate) uid: Arc<RwLock<Option<i64>>>,
}
@ -76,7 +73,6 @@ impl ServerProvider {
enable_sync: RwLock::new(true),
encryption: RwLock::new(Arc::new(encryption)),
store_preferences,
cache_user_service: Default::default(),
uid: Default::default(),
}
}
@ -148,7 +144,7 @@ impl From<Authenticator> for ServerType {
fn from(auth_provider: Authenticator) -> Self {
match auth_provider {
Authenticator::Local => ServerType::Local,
Authenticator::AFCloud => ServerType::AFCloud,
Authenticator::AppFlowyCloud => ServerType::AFCloud,
Authenticator::Supabase => ServerType::Supabase,
}
}
@ -158,7 +154,7 @@ impl From<ServerType> for Authenticator {
fn from(ty: ServerType) -> Self {
match ty {
ServerType::Local => Authenticator::Local,
ServerType::AFCloud => Authenticator::AFCloud,
ServerType::AFCloud => Authenticator::AppFlowyCloud,
ServerType::Supabase => Authenticator::Supabase,
}
}

View File

@ -113,16 +113,8 @@ impl UserCloudServiceProvider for ServerProvider {
/// Returns the [UserCloudService] base on the current [ServerType].
/// Creates a new [AppFlowyServer] if it doesn't exist.
fn get_user_service(&self) -> Result<Arc<dyn UserCloudService>, FlowyError> {
if let Some(user_service) = self.cache_user_service.read().get(&self.get_server_type()) {
return Ok(user_service.clone());
}
let server_type = self.get_server_type();
let user_service = self.get_server(&server_type)?.user_service();
self
.cache_user_service
.write()
.insert(server_type, user_service.clone());
Ok(user_service)
}

View File

@ -27,6 +27,7 @@ r2d2 = { version = "0.8", optional = true }
url = { version = "2.2", optional = true }
collab-database = { version = "0.1.0", optional = true }
collab-document = { version = "0.1.0", optional = true }
collab-persistence = { version = "0.1.0", optional = true }
tokio-postgres = { version = "0.7.8", optional = true }
client-api = { version = "0.1.0", optional = true }
@ -36,7 +37,7 @@ impl_from_dispatch_error = ["lib-dispatch"]
impl_from_serde = []
impl_from_reqwest = ["reqwest"]
impl_from_sqlite = ["flowy-sqlite", "r2d2"]
impl_from_collab = ["collab-database", "collab-document", "impl_from_reqwest"]
impl_from_collab = ["collab-database", "collab-document", "impl_from_reqwest", "collab-persistence"]
impl_from_postgres = ["tokio-postgres"]
impl_from_url = ["url"]
impl_from_appflowy_cloud = ["client-api"]

View File

@ -1,8 +1,18 @@
use collab_database::error::DatabaseError;
use collab_document::error::DocumentError;
use collab_persistence::PersistenceError;
use crate::FlowyError;
use crate::{ErrorCode, FlowyError};
impl From<PersistenceError> for FlowyError {
fn from(err: PersistenceError) -> Self {
match err {
PersistenceError::RocksdbCorruption(_) => FlowyError::new(ErrorCode::RocksdbCorruption, err),
PersistenceError::RocksdbIOError(_) => FlowyError::new(ErrorCode::RocksdbIOError, err),
_ => FlowyError::new(ErrorCode::RocksdbInternal, err),
}
}
}
impl From<DatabaseError> for FlowyError {
fn from(error: DatabaseError) -> Self {
FlowyError::internal().with_context(error)

View File

@ -2,7 +2,7 @@ use std::fmt::Display;
use serde::{Deserialize, Serialize};
use flowy_error::{ErrorCode, FlowyError, FlowyResult};
use flowy_error::{ErrorCode, FlowyError};
pub const APPFLOWY_CLOUD_BASE_URL: &str = "APPFLOWY_CLOUD_ENV_APPFLOWY_CLOUD_BASE_URL";
pub const APPFLOWY_CLOUD_WS_BASE_URL: &str = "APPFLOWY_CLOUD_ENV_APPFLOWY_CLOUD_WS_BASE_URL";
@ -60,25 +60,10 @@ impl AFCloudConfiguration {
})
}
pub fn validate(&self) -> Result<(), FlowyError> {
if self.base_url.is_empty() || self.ws_base_url.is_empty() || self.gotrue_url.is_empty() {
return Err(FlowyError::new(
ErrorCode::InvalidAuthConfig,
format!(
"Invalid APPFLOWY_CLOUD_BASE_URL: {}, APPFLOWY_CLOUD_WS_BASE_URL: {}, APPFLOWY_CLOUD_GOTRUE_URL: {}",
self.base_url, self.ws_base_url, self.gotrue_url,
)),
);
}
Ok(())
}
/// Write the configuration to the environment variables.
pub fn write_env(&self) -> FlowyResult<()> {
self.validate()?;
pub fn write_env(&self) {
std::env::set_var(APPFLOWY_CLOUD_BASE_URL, &self.base_url);
std::env::set_var(APPFLOWY_CLOUD_WS_BASE_URL, &self.ws_base_url);
std::env::set_var(APPFLOWY_CLOUD_GOTRUE_URL, &self.gotrue_url);
Ok(())
}
}

View File

@ -1,6 +1,6 @@
use serde::{Deserialize, Serialize};
use flowy_error::{ErrorCode, FlowyError, FlowyResult};
use flowy_error::{ErrorCode, FlowyError};
pub const SUPABASE_URL: &str = "APPFLOWY_CLOUD_ENV_SUPABASE_URL";
pub const SUPABASE_ANON_KEY: &str = "APPFLOWY_CLOUD_ENV_SUPABASE_ANON_KEY";
@ -33,21 +33,9 @@ impl SupabaseConfiguration {
Ok(Self { url, anon_key })
}
pub fn validate(&self) -> Result<(), FlowyError> {
if self.url.is_empty() || self.anon_key.is_empty() {
return Err(FlowyError::new(
ErrorCode::InvalidAuthConfig,
"Missing SUPABASE_URL or SUPABASE_ANON_KEY",
));
}
Ok(())
}
/// Write the configuration to the environment variables.
pub fn write_env(&self) -> FlowyResult<()> {
self.validate()?;
pub fn write_env(&self) {
std::env::set_var(SUPABASE_URL, &self.url);
std::env::set_var(SUPABASE_ANON_KEY, &self.anon_key);
Ok(())
}
}

View File

@ -57,7 +57,7 @@ pub fn user_profile_from_af_profile(
openai_key: openai_key.unwrap_or_default(),
stability_ai_key: stability_ai_key.unwrap_or_default(),
workspace_id: profile.latest_workspace_id.to_string(),
authenticator: Authenticator::AFCloud,
authenticator: Authenticator::AppFlowyCloud,
encryption_type,
uid: profile.uid,
updated_at: profile.updated_at,

View File

@ -327,7 +327,7 @@ pub enum Authenticator {
Local = 0,
/// Currently not supported. It will be supported in the future when the
/// [AppFlowy-Server](https://github.com/AppFlowy-IO/AppFlowy-Server) ready.
AFCloud = 1,
AppFlowyCloud = 1,
/// It uses Supabase as the backend.
Supabase = 2,
}
@ -348,7 +348,7 @@ impl From<i32> for Authenticator {
fn from(value: i32) -> Self {
match value {
0 => Authenticator::Local,
1 => Authenticator::AFCloud,
1 => Authenticator::AppFlowyCloud,
2 => Authenticator::Supabase,
_ => Authenticator::Local,
}

View File

@ -244,3 +244,15 @@ impl std::default::Default for NotificationSettingsPB {
}
}
}
#[derive(Default, ProtoBuf)]
pub struct AppFlowyCloudSettingPB {
#[pb(index = 1)]
pub base_url: bool,
#[pb(index = 2)]
pub ws_addr: bool,
#[pb(index = 3)]
pub gotrue_url: String,
}

View File

@ -2,6 +2,7 @@ use std::sync::Weak;
use std::{convert::TryInto, sync::Arc};
use serde_json::Value;
use tracing::event;
use flowy_error::{ErrorCode, FlowyError, FlowyResult};
use flowy_sqlite::kv::StorePreferences;
@ -86,7 +87,7 @@ pub async fn get_user_profile_handler(
) -> DataResult<UserProfilePB, FlowyError> {
let manager = upgrade_manager(manager)?;
let uid = manager.get_session()?.user_id;
let mut user_profile = manager.get_user_profile(uid).await?;
let mut user_profile = manager.get_user_profile_from_disk(uid).await?;
let weak_manager = Arc::downgrade(&manager);
let cloned_user_profile = user_profile.clone();
@ -285,6 +286,7 @@ pub async fn sign_in_with_provider_handler(
let manager = upgrade_manager(manager)?;
tracing::debug!("Sign in with provider: {:?}", data.provider.as_str());
let sign_in_url = manager.generate_oauth_url(data.provider.as_str()).await?;
event!(tracing::Level::DEBUG, "Sign in url: {}", sign_in_url);
data_result_ok(OauthProviderDataPB {
oauth_url: sign_in_url,
})
@ -332,7 +334,7 @@ pub async fn check_encrypt_secret_handler(
) -> DataResult<UserEncryptionConfigurationPB, FlowyError> {
let manager = upgrade_manager(manager)?;
let uid = manager.get_session()?.user_id;
let profile = manager.get_user_profile(uid).await?;
let profile = manager.get_user_profile_from_disk(uid).await?;
let is_need_secret = match profile.encryption_type {
EncryptionType::NoEncryption => false,
@ -402,7 +404,8 @@ pub async fn set_cloud_config_handler(
};
send_notification(
&session.user_id.to_string(),
// Don't change this key. it's also used in the frontend
"user_cloud_config",
UserNotification::DidUpdateCloudConfig,
)
.payload(payload)

View File

@ -16,6 +16,7 @@ use tracing::{debug, error, event, info, instrument};
use collab_integrate::collab_builder::AppFlowyCollabBuilder;
use collab_integrate::RocksCollabDB;
use flowy_error::{internal_error, ErrorCode, FlowyResult};
use flowy_server_config::AuthenticatorType;
use flowy_sqlite::kv::StorePreferences;
use flowy_sqlite::schema::user_table;
use flowy_sqlite::ConnectionPool;
@ -157,7 +158,27 @@ impl UserManager {
*self.collab_interact.write().await = Arc::new(collab_interact);
if let Ok(session) = self.get_session() {
let user = self.get_user_profile(session.user_id).await?;
let user = self.get_user_profile_from_disk(session.user_id).await?;
// Get the current authenticator from the environment variable
let current_authenticator = match AuthenticatorType::from_env() {
AuthenticatorType::Local => Authenticator::Local,
AuthenticatorType::Supabase => Authenticator::Supabase,
AuthenticatorType::AppFlowyCloud => Authenticator::AppFlowyCloud,
};
// If the current authenticator is different from the authenticator in the session and it's
// not a local authenticator, we need to sign out the user.
if user.authenticator != Authenticator::Local && user.authenticator != current_authenticator {
event!(
tracing::Level::INFO,
"Authenticator changed from {:?} to {:?}",
user.authenticator,
current_authenticator
);
self.sign_out().await?;
return Ok(());
}
event!(
tracing::Level::INFO,
@ -201,9 +222,7 @@ impl UserManager {
// Do the user data migration if needed
event!(tracing::Level::INFO, "Prepare user data migration");
match (
self
.database
.get_collab_db(session.user_id, &self.user_config.device_id),
self.database.get_collab_db(session.user_id),
self.database.get_pool(session.user_id),
) {
(Ok(collab_db), Ok(sqlite_pool)) => {
@ -257,7 +276,7 @@ impl UserManager {
pub fn get_collab_db(&self, uid: i64) -> Result<Weak<RocksCollabDB>, FlowyError> {
self
.database
.get_collab_db(uid, "")
.get_collab_db(uid)
.map(|collab_db| Arc::downgrade(&collab_db))
}
@ -477,7 +496,7 @@ impl UserManager {
let session = self.get_session()?;
upsert_user_profile_change(session.user_id, self.db_pool(session.user_id)?, changeset)?;
let profile = self.get_user_profile(session.user_id).await?;
let profile = self.get_user_profile_from_disk(session.user_id).await?;
self
.update_user(session.user_id, profile.token, params)
.await?;
@ -507,7 +526,7 @@ impl UserManager {
}
/// Fetches the user profile for the given user ID.
pub async fn get_user_profile(&self, uid: i64) -> Result<UserProfile, FlowyError> {
pub async fn get_user_profile_from_disk(&self, uid: i64) -> Result<UserProfile, FlowyError> {
let user: UserProfile = user_table::dsl::user_table
.filter(user_table::id.eq(&uid.to_string()))
.first::<UserTable>(&*(self.db_connection(uid)?))
@ -524,14 +543,18 @@ impl UserManager {
#[tracing::instrument(level = "info", skip_all, err)]
pub async fn refresh_user_profile(&self, old_user_profile: &UserProfile) -> FlowyResult<()> {
let now = chrono::Utc::now().timestamp();
// If the user is a local user, no need to refresh the user profile
if old_user_profile.authenticator.is_local() {
return Ok(());
}
let now = chrono::Utc::now().timestamp();
// Add debounce to avoid too many requests
if now - self.refresh_user_profile_since.load(Ordering::SeqCst) < 5 {
return Ok(());
}
self.refresh_user_profile_since.store(now, Ordering::SeqCst);
let uid = old_user_profile.uid;
let result: Result<UserProfile, FlowyError> = self
.cloud_services
@ -541,27 +564,6 @@ impl UserManager {
match result {
Ok(new_user_profile) => {
// If the authentication type has changed, it indicates that the user has signed in
// using a different release package but is sharing the same data folder.
// In such cases, notify the frontend to log out.
if old_user_profile.authenticator != Authenticator::Local
&& new_user_profile.authenticator != old_user_profile.authenticator
{
event!(
tracing::Level::INFO,
"User login with different authenticator: {:?} -> {:?}",
old_user_profile.authenticator,
new_user_profile.authenticator
);
send_auth_state_notification(AuthStateChangedPB {
state: AuthStatePB::InvalidAuth,
message: "User login with different cloud".to_string(),
})
.send();
return Ok(());
}
// If the user profile is updated, save the new user profile
if new_user_profile.updated_at > old_user_profile.updated_at {
validate_encryption_sign(old_user_profile, &new_user_profile.encryption_type.sign());
@ -578,6 +580,15 @@ impl UserManager {
tracing::Level::ERROR,
"User is unauthorized, sign out the user"
);
self.add_historical_user(
uid,
&self.user_config.device_id,
old_user_profile.name.clone(),
&old_user_profile.authenticator,
self.user_dir(uid),
);
self.sign_out().await?;
send_auth_state_notification(AuthStateChangedPB {
state: AuthStatePB::InvalidAuth,
@ -592,7 +603,7 @@ impl UserManager {
#[instrument(level = "info", skip_all)]
pub fn user_dir(&self, uid: i64) -> String {
self.user_paths.user_dir(uid)
self.user_paths.user_data_dir(uid)
}
pub fn user_setting(&self) -> Result<UserSettingPB, FlowyError> {
@ -713,7 +724,9 @@ impl UserManager {
&self,
oauth_provider: &str,
) -> Result<String, FlowyError> {
self.update_authenticator(&Authenticator::AFCloud).await;
self
.update_authenticator(&Authenticator::AppFlowyCloud)
.await;
let auth_service = self.cloud_services.get_user_service()?;
let url = auth_service
.generate_oauth_url_with_provider(oauth_provider)
@ -757,7 +770,7 @@ impl UserManager {
let session = self.get_session()?;
if session.user_id == user_update.uid {
debug!("Receive user update: {:?}", user_update);
let user_profile = self.get_user_profile(user_update.uid).await?;
let user_profile = self.get_user_profile_from_disk(user_update.uid).await?;
if !validate_encryption_sign(&user_profile, &user_update.encryption_sign) {
return Ok(());
}
@ -778,12 +791,8 @@ impl UserManager {
old_user: &MigrationUser,
new_user: &MigrationUser,
) -> Result<(), FlowyError> {
let old_collab_db = self
.database
.get_collab_db(old_user.session.user_id, &self.user_config.device_id)?;
let new_collab_db = self
.database
.get_collab_db(new_user.session.user_id, &self.user_config.device_id)?;
let old_collab_db = self.database.get_collab_db(old_user.session.user_id)?;
let new_collab_db = self.database.get_collab_db(new_user.session.user_id)?;
migration_anon_user_on_sign_up(old_user, &old_collab_db, new_user, &new_collab_db)?;
if let Err(err) = sync_user_data_to_cloud(
@ -859,24 +868,26 @@ impl UserPaths {
fn new(root: String) -> Self {
Self { root }
}
fn user_dir(&self, uid: i64) -> String {
/// Returns the path to the user's data directory.
fn user_data_dir(&self, uid: i64) -> String {
format!("{}/{}", self.root, uid)
}
}
impl UserDBPath for UserPaths {
fn user_db_path(&self, uid: i64) -> PathBuf {
PathBuf::from(self.user_dir(uid))
PathBuf::from(self.user_data_dir(uid))
}
fn collab_db_path(&self, uid: i64) -> PathBuf {
let mut path = PathBuf::from(self.user_dir(uid));
let mut path = PathBuf::from(self.user_data_dir(uid));
path.push("collab_db");
path
}
fn collab_db_history(&self, uid: i64, create_if_not_exist: bool) -> std::io::Result<PathBuf> {
let path = PathBuf::from(self.user_dir(uid)).join("collab_db_history");
let path = PathBuf::from(self.user_data_dir(uid)).join("collab_db_history");
if !path.exists() && create_if_not_exist {
fs::create_dir_all(&path)?;
}

View File

@ -7,7 +7,7 @@ use parking_lot::RwLock;
use tracing::{error, event, info, instrument};
use collab_integrate::{PersistenceError, RocksCollabDB, YrsDocAction};
use flowy_error::{ErrorCode, FlowyError};
use flowy_error::FlowyError;
use flowy_sqlite::schema::user_workspace_table;
use flowy_sqlite::ConnectionPool;
use flowy_sqlite::{
@ -50,40 +50,47 @@ impl UserDB {
/// attempts to restore the database from the latest backup.
/// - If the CollabDB does not exist, it immediately attempts to restore from the latest backup.
///
#[instrument(level = "debug", skip_all)]
pub fn backup_or_restore(&self, uid: i64, workspace_id: &str) {
// Obtain the path for the collaboration database.
let collab_db_path = self.paths.collab_db_path(uid);
// Obtain the history folder path, proceed if successful.
if let Ok(history_folder) = self.paths.collab_db_history(uid, true) {
// Initialize the backup utility for the collaboration database.
let zip_backup = CollabDBZipBackup::new(collab_db_path.clone(), history_folder);
if collab_db_path.exists() {
// Validate the existing collaboration database.
let is_ok = validate_collab_db(&collab_db_path, uid, workspace_id);
let zip_backup = CollabDBZipBackup::new(collab_db_path, history_folder);
if is_ok {
// If the database opens successfully, it attempts to back it up in the background.
// If database is valid, update the shared map and initiate backup.
// Asynchronous backup operation.
af_spawn(async move {
let _ = tokio::task::spawn_blocking(move || {
if let Err(err) = zip_backup.backup() {
error!("backup collab db failed, {:?}", err);
}
})
.await;
if let Err(err) = tokio::task::spawn_blocking(move || zip_backup.backup()).await {
error!("Backup of collab db failed: {:?}", err);
}
});
} else if let Err(err) = zip_backup.restore_latest_backup() {
error!("restore collab db failed, {:?}", err);
}
} else {
let zip_backup = CollabDBZipBackup::new(collab_db_path, history_folder);
if let Err(err) = zip_backup.restore_latest_backup() {
error!("restore collab db failed, {:?}", err);
// If validation fails, attempt to restore from the latest backup.
error!("Restoring collab db failed: {:?}", err);
}
} else if let Err(err) = zip_backup.restore_latest_backup() {
// If collab database does not exist, attempt to restore from the latest backup.
error!("Restoring collab db failed: {:?}", err);
}
}
}
#[instrument(level = "debug", skip_all)]
pub fn restore_if_need(&self, uid: i64, workspace_id: &str) {
if let Ok(history_folder) = self.paths.collab_db_history(uid, false) {
let collab_db_path = self.paths.collab_db_path(uid);
let is_ok = validate_collab_db(&collab_db_path, uid, workspace_id);
let zip_backup = CollabDBZipBackup::new(collab_db_path, history_folder);
if !is_ok {
let zip_backup = CollabDBZipBackup::new(collab_db_path, history_folder);
if let Err(err) = zip_backup.restore_latest_backup() {
error!("restore collab db failed, {:?}", err);
}
@ -118,15 +125,11 @@ impl UserDB {
Ok(pool)
}
pub(crate) fn get_collab_db(
&self,
user_id: i64,
_device_id: &str,
) -> Result<Arc<RocksCollabDB>, FlowyError> {
pub(crate) fn get_collab_db(&self, user_id: i64) -> Result<Arc<RocksCollabDB>, FlowyError> {
let collab_db = open_collab_db(
self.paths.user_db_path(user_id),
// self.paths.user_db_path(user_id),
self.paths.collab_db_path(user_id),
self.paths.collab_db_history(user_id, false).ok(),
// self.paths.collab_db_history(user_id, false).ok(),
user_id,
)?;
Ok(collab_db)
@ -175,31 +178,24 @@ pub fn get_user_workspace(
/// Open a collab db for the user. If the db is already opened, return the opened db.
///
fn open_collab_db(
_collab_backup_db_path: impl AsRef<Path>,
collab_db_path: impl AsRef<Path>,
_collab_db_history: Option<PathBuf>,
uid: i64,
) -> Result<Arc<RocksCollabDB>, FlowyError> {
) -> Result<Arc<RocksCollabDB>, PersistenceError> {
if let Some(collab_db) = COLLAB_DB_MAP.read().get(&uid) {
return Ok(collab_db.clone());
}
let mut write_guard = COLLAB_DB_MAP.write();
info!(
"open collab db for user {} at path: {:?}",
uid,
collab_db_path.as_ref()
);
let db = match RocksCollabDB::open(&collab_db_path) {
Ok(db) => Ok(db),
Err(err) => {
tracing::error!("open collab db error, {:?}", err);
match err {
PersistenceError::RocksdbCorruption(_) => {
// try restore from the backup db
Err(FlowyError::new(ErrorCode::RocksdbCorruption, err))
},
PersistenceError::RocksdbIOError(_) => {
//
Err(FlowyError::new(ErrorCode::RocksdbIOError, err))
},
_ => Err(FlowyError::new(ErrorCode::RocksdbInternal, err)),
}
error!("open collab db error, {:?}", err);
Err(err)
},
}?;
@ -337,12 +333,17 @@ pub(crate) fn validate_collab_db(
// Attempt to open the collaboration database using the workspace_id. The workspace_id must already
// exist in the collab database. If it does not, it may be indicative of corruption in the collab database
// due to other factors.
let result = RocksCollabDB::open(&collab_db_path).map(|db| {
let read_txn = db.read_txn();
read_txn.is_exist(uid, workspace_id)
});
match result {
Ok(is_ok) => is_ok,
info!(
"open collab db to validate data integration for user {} at path: {:?}",
uid,
collab_db_path.as_ref()
);
match open_collab_db(&collab_db_path, uid) {
Ok(db) => {
let read_txn = db.read_txn();
read_txn.is_exist(uid, workspace_id)
},
// return false if the error is not related to corruption
Err(err) => !matches!(
err,

View File

@ -155,7 +155,7 @@ impl From<AuthTypePB> for Authenticator {
match pb {
AuthTypePB::Supabase => Authenticator::Supabase,
AuthTypePB::Local => Authenticator::Local,
AuthTypePB::AFCloud => Authenticator::AFCloud,
AuthTypePB::AFCloud => Authenticator::AppFlowyCloud,
}
}
}
@ -165,7 +165,7 @@ impl From<Authenticator> for AuthTypePB {
match auth_type {
Authenticator::Supabase => AuthTypePB::Supabase,
Authenticator::Local => AuthTypePB::Local,
Authenticator::AFCloud => AuthTypePB::AFCloud,
Authenticator::AppFlowyCloud => AuthTypePB::AFCloud,
}
}
}

View File

@ -17,7 +17,10 @@ impl UserManager {
// Only migrate the data if the user is login in as a guest and sign up as a new user if the current
// auth type is not [AuthType::Local].
let session = self.get_session().ok()?;
let user_profile = self.get_user_profile(session.user_id).await.ok()?;
let user_profile = self
.get_user_profile_from_disk(session.user_id)
.await
.ok()?;
if user_profile.authenticator == Authenticator::Local && !auth_type.is_local() {
Some(MigrationUser {
user_profile,
@ -44,7 +47,7 @@ impl UserManager {
uid: i64,
device_id: &str,
user_name: String,
auth_type: &Authenticator,
authenticator: &Authenticator,
storage_path: String,
) {
let mut logger_users = self
@ -54,7 +57,7 @@ impl UserManager {
logger_users.add_user(HistoricalUser {
user_id: uid,
user_name,
auth_type: auth_type.clone(),
auth_type: authenticator.clone(),
sign_in_timestamp: timestamp(),
storage_path,
device_id: device_id.to_string(),