test: add document sync test on appflowy cloud (#4163)

* test: add document sync test on appflowy cloud

* chore: add runner

* test: Stream has already been listened to.

* fix: using singleton subscription

* fix: using singleton subscription
This commit is contained in:
Nathan.fooo
2023-12-21 08:12:40 +08:00
committed by GitHub
parent f5a9f2bf4d
commit 6ecc3c9076
29 changed files with 560 additions and 381 deletions

View File

@ -217,20 +217,93 @@ jobs:
fi fi
shell: bash shell: bash
# disable coverage for now as it's not working cloud_integration_test:
# - name: Upload coverage to Codecov needs: [ prepare ]
# uses: Wandalen/wretry.action@v1.0.36 strategy:
# with: fail-fast: false
# action: codecov/codecov-action@v3 matrix:
# with: | os: [ ubuntu-latest ]
# name: appflowy include:
# flags: appflowy_flutter_unit_test - os: ubuntu-latest
# fail_ci_if_error: true flutter_profile: development-linux-x86_64
# verbose: true target: x86_64-unknown-linux-gnu
# os: ${{ matrix.os }} runs-on: ${{ matrix.os }}
# token: ${{ secrets.CODECOV_TOKEN }}
# attempt_limit: 20 steps:
# attempt_delay: 10000 - name: Checkout appflowy cloud code
uses: actions/checkout@v3
with:
repository: AppFlowy-IO/AppFlowy-Cloud
path: AppFlowy-Cloud
depth: 1
- name: Prepare appflowy cloud env
working-directory: AppFlowy-Cloud
run: |
# log level
cp dev.env .env
sed -i 's|RUST_LOG=.*|RUST_LOG=trace|' .env
sed -i 's/GOTRUE_SMTP_USER=.*/GOTRUE_SMTP_USER=${{ secrets.INTEGRATION_TEST_GOTRUE_SMTP_USER }}/' .env
sed -i 's/GOTRUE_SMTP_PASS=.*/GOTRUE_SMTP_PASS=${{ secrets.INTEGRATION_TEST_GOTRUE_SMTP_PASS }}/' .env
sed -i 's/GOTRUE_SMTP_ADMIN_EMAIL=.*/GOTRUE_SMTP_ADMIN_EMAIL=${{ secrets.INTEGRATION_TEST_GOTRUE_SMTP_ADMIN_EMAIL }}/' .env
sed -i 's/GOTRUE_EXTERNAL_GOOGLE_ENABLED=.*/GOTRUE_EXTERNAL_GOOGLE_ENABLED=true/' .env
sed -i 's|API_EXTERNAL_URL=.*|API_EXTERNAL_URL=http://localhost|' .env
cat .env
- name: Run Docker-Compose
working-directory: AppFlowy-Cloud
run: |
docker compose up -d
- name: Checkout source code
uses: actions/checkout@v2
- name: Install flutter
id: flutter
uses: subosito/flutter-action@v2
with:
channel: "stable"
flutter-version: ${{ env.FLUTTER_VERSION }}
- uses: taiki-e/install-action@v2
with:
tool: cargo-make@${{ env.CARGO_MAKE_VERSION }}
- name: Install prerequisites
working-directory: frontend
run: |
sudo wget -qO /etc/apt/trusted.gpg.d/dart_linux_signing_key.asc https://dl-ssl.google.com/linux/linux_signing_key.pub
sudo wget -qO /etc/apt/sources.list.d/dart_stable.list https://storage.googleapis.com/download.dartlang.org/linux/debian/dart_stable.list
sudo apt-get update
sudo apt-get install -y dart curl build-essential libssl-dev clang cmake ninja-build pkg-config libgtk-3-dev keybinder-3.0 libnotify-dev
shell: bash
- name: Enable Flutter Desktop
run: |
flutter config --enable-linux-desktop
shell: bash
- uses: actions/download-artifact@v3
with:
name: ${{ github.run_id }}-${{ matrix.os }}
- name: Uncompressed appflowy_flutter
run: |
tar -xf appflowy_flutter.tar.gz
ls -al
- name: Run flutter pub get
working-directory: frontend
run: cargo make pub_get
- name: Run Flutter integration tests
working-directory: frontend/appflowy_flutter
run: |
export DISPLAY=:99
sudo Xvfb -ac :99 -screen 0 1280x1024x24 > /dev/null 2>&1 &
sudo apt-get install network-manager
flutter test integration_test/cloud/cloud_runner.dart -d Linux --coverage
shell: bash
integration_test: integration_test:
needs: [prepare] needs: [prepare]
@ -238,14 +311,11 @@ jobs:
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
os: [ubuntu-latest, windows-latest] os: [ubuntu-latest]
include: include:
- os: ubuntu-latest - os: ubuntu-latest
flutter_profile: development-linux-x86_64 flutter_profile: development-linux-x86_64
target: x86_64-unknown-linux-gnu target: x86_64-unknown-linux-gnu
- os: windows-latest
flutter_profile: development-windows-x86
target: x86_64-pc-windows-msvc
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
steps: steps:
@ -320,103 +390,6 @@ jobs:
flutter test integration_test/runner.dart -d Windows --coverage flutter test integration_test/runner.dart -d Windows --coverage
fi fi
shell: bash shell: bash
cloud_integration_test:
needs: [prepare]
# if: github.event_name != 'pull_request'
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest]
include:
- os: ubuntu-latest
flutter_profile: development-linux-x86_64
target: x86_64-unknown-linux-gnu
runs-on: ${{ matrix.os }}
steps:
- name: Checkout appflowy cloud code
uses: actions/checkout@v3
with:
repository: AppFlowy-IO/AppFlowy-Cloud
path: AppFlowy-Cloud
depth: 1
- name: Prepare appflowy cloud env
working-directory: AppFlowy-Cloud
run: |
# log level
cp dev.env .env
sed -i 's|RUST_LOG=.*|RUST_LOG=trace|' .env
sed -i 's/GOTRUE_SMTP_USER=.*/GOTRUE_SMTP_USER=${{ secrets.INTEGRATION_TEST_GOTRUE_SMTP_USER }}/' .env
sed -i 's/GOTRUE_SMTP_PASS=.*/GOTRUE_SMTP_PASS=${{ secrets.INTEGRATION_TEST_GOTRUE_SMTP_PASS }}/' .env
sed -i 's/GOTRUE_SMTP_ADMIN_EMAIL=.*/GOTRUE_SMTP_ADMIN_EMAIL=${{ secrets.INTEGRATION_TEST_GOTRUE_SMTP_ADMIN_EMAIL }}/' .env
sed -i 's/GOTRUE_EXTERNAL_GOOGLE_ENABLED=.*/GOTRUE_EXTERNAL_GOOGLE_ENABLED=true/' .env
sed -i 's|API_EXTERNAL_URL=.*|API_EXTERNAL_URL=http://localhost|' .env
cat .env
- name: Run Docker-Compose
working-directory: AppFlowy-Cloud
run: |
docker compose up -d
- name: Checkout source code
uses: actions/checkout@v2
- name: Install flutter
id: flutter
uses: subosito/flutter-action@v2
with:
channel: "stable"
flutter-version: ${{ env.FLUTTER_VERSION }}
- uses: taiki-e/install-action@v2
with:
tool: cargo-make@${{ env.CARGO_MAKE_VERSION }}
- name: Install prerequisites
working-directory: frontend
run: |
sudo wget -qO /etc/apt/trusted.gpg.d/dart_linux_signing_key.asc https://dl-ssl.google.com/linux/linux_signing_key.pub
sudo wget -qO /etc/apt/sources.list.d/dart_stable.list https://storage.googleapis.com/download.dartlang.org/linux/debian/dart_stable.list
sudo apt-get update
sudo apt-get install -y dart curl build-essential libssl-dev clang cmake ninja-build pkg-config libgtk-3-dev keybinder-3.0 libnotify-dev
shell: bash
- name: Enable Flutter Desktop
run: |
flutter config --enable-linux-desktop
shell: bash
- uses: actions/download-artifact@v3
with:
name: ${{ github.run_id }}-${{ matrix.os }}
- name: Uncompressed appflowy_flutter
run: |
tar -xf appflowy_flutter.tar.gz
ls -al
- name: Enable localhost env
working-directory: frontend/appflowy_flutter
run: |
echo 'APPFLOWY_CLOUD_URL=http://localhost' >> .env
dart run build_runner clean && dart run build_runner build --delete-conflicting-outputs
- name: Run flutter pub get
working-directory: frontend
run: cargo make pub_get
- name: Run Flutter integration tests
working-directory: frontend/appflowy_flutter
run: |
export DISPLAY=:99
sudo Xvfb -ac :99 -screen 0 1280x1024x24 > /dev/null 2>&1 &
sudo apt-get install network-manager
flutter test integration_test/cloud_runner.dart -d Linux --coverage
shell: bash
build: build:
needs: [prepare] needs: [prepare]
if: github.event.pull_request.draft != true if: github.event.pull_request.draft != true

View File

@ -1,5 +0,0 @@
SUPABASE_URL=
SUPABASE_ANON_KEY=
APPFLOWY_CLOUD_URL=

View File

@ -80,54 +80,5 @@ void main() {
await tester.toggleEnableSync(AppFlowyCloudEnableSync); await tester.toggleEnableSync(AppFlowyCloudEnableSync);
tester.assertAppFlowyCloudEnableSyncSwitchValue(true); tester.assertAppFlowyCloudEnableSyncSwitchValue(true);
}); });
// testWidgets('custom folder sign in', (tester) async {
// const userA = 'UserA';
// final userAEmail = "${uuid()}@appflowy.io";
// final initialPath = p.join(userA, appFlowyDataFolder);
// final context = await tester.initializeAppFlowy(
// cloudType: AuthenticatorType.appflowyCloud,
// pathExtension: initialPath,
// );
// getIt.registerFactory<AuthService>(
// () => AppFlowyCloudMockAuthService(
// email: userAEmail,
// ),
// );
// // remove the last extension
// final rootPath = context.applicationDataDirectory.replaceFirst(
// initialPath,
// '',
// );
// await tester.tapGoogleLoginInButton();
// // Open the setting page and sign out
// await tester.openSettings();
// await tester.openSettingsPage(SettingsPage.user);
// await tester.enterUserName(userA);
// await tester.openSettingsPage(SettingsPage.files);
// await tester.pumpAndSettle();
// // mock the file_picker result
// await mockGetDirectoryPath(
// p.join(rootPath, "random_folder"),
// );
// // after selecting the folder, an annoymous user should be signed in
// await tester.tapCustomLocationButton();
// tester.expectToSeeHomePage();
// await tester.pumpAndSettle();
// // Login as userA in custom folder
// await tester.openSettings();
// await tester.openSettingsPage(SettingsPage.user);
// await tester.tapGoogleLoginInButton();
// await tester.pumpAndSettle(const Duration(seconds: 1));
// tester.expectToSeeHomePage();
// // UserA should be displayed
// tester.expectToSeeUserName(userA);
// });
}); });
} }

View File

@ -0,0 +1,14 @@
import 'empty_test.dart' as empty_test;
import 'appflowy_cloud_auth_test.dart' as appflowy_cloud_auth_test;
import 'document_sync_test.dart' as document_sync_test;
import 'user_setting_sync_test.dart' as user_sync_test;
Future<void> main() async {
empty_test.main();
appflowy_cloud_auth_test.main();
document_sync_test.main();
user_sync_test.main();
}

View File

@ -0,0 +1,77 @@
// ignore_for_file: unused_import
import 'dart:io';
import 'package:appflowy/env/cloud_env.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/user/application/auth/af_cloud_mock_auth_service.dart';
import 'package:appflowy/user/application/auth/auth_service.dart';
import 'package:appflowy/workspace/application/settings/prelude.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/setting_appflowy_cloud.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/settings_user_view.dart';
import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/uuid.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:path/path.dart' as p;
import 'package:integration_test/integration_test.dart';
import '../util/dir.dart';
import '../util/mock/mock_file_picker.dart';
import '../util/util.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
const pageName = 'Sample';
final email = '${uuid()}@appflowy.io';
// The test will create a new document called Sample, and sync it to the server.
// Then the test will logout the user, and login with the same user. The data will
// be synced from the server.
group('appflowy cloud document', () {
testWidgets('sync local docuemnt to server', (tester) async {
await tester.initializeAppFlowy(
cloudType: AuthenticatorType.appflowyCloud,
email: email,
);
await tester.tapGoogleLoginInButton();
tester.expectToSeeHomePage();
// create a new document called Sample
await tester.createNewPageWithName(
name: pageName,
layout: ViewLayoutPB.Document,
);
// focus on the editor
await tester.editor.tapLineOfEditorAt(0);
await tester.ime.insertText('hello world');
await tester.pumpAndSettle();
expect(find.text('hello world', findRichText: true), findsOneWidget);
await tester.openSettings();
await tester.openSettingsPage(SettingsPage.user);
await tester.tapButton(find.byType(SettingLogoutButton));
tester.expectToSeeText(LocaleKeys.button_ok.tr());
});
testWidgets('sync doc from server', (tester) async {
await tester.initializeAppFlowy(
cloudType: AuthenticatorType.appflowyCloud,
email: email,
);
await tester.tapGoogleLoginInButton();
tester.expectToSeeHomePage();
await tester.pumpAndSettle();
// The document will be synced from the server
await tester.openPage(
pageName,
);
await tester.pumpAndSettle();
expect(find.text('hello world', findRichText: true), findsOneWidget);
});
});
}

View File

@ -0,0 +1,18 @@
import 'package:appflowy/env/cloud_env.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import '../util/util.dart';
// This test is meaningless, just for preventing the CI from failing.
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('Empty', () {
testWidgets('set appflowy cloud', (tester) async {
await tester.initializeAppFlowy(
cloudType: AuthenticatorType.appflowyCloud,
);
});
});
}

View File

@ -0,0 +1,100 @@
// ignore_for_file: unused_import
import 'dart:io';
import 'package:appflowy/env/cloud_env.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/user/application/auth/af_cloud_mock_auth_service.dart';
import 'package:appflowy/user/application/auth/auth_service.dart';
import 'package:appflowy/workspace/application/settings/prelude.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/setting_appflowy_cloud.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/settings_user_view.dart';
import 'package:appflowy/workspace/presentation/widgets/user_avatar.dart';
import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/uuid.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:path/path.dart' as p;
import 'package:integration_test/integration_test.dart';
import '../util/database_test_op.dart';
import '../util/dir.dart';
import '../util/emoji.dart';
import '../util/mock/mock_file_picker.dart';
import '../util/util.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
final email = '${uuid()}@appflowy.io';
const name = 'nathan';
group('appflowy cloud setting', () {
testWidgets('sync user name and icon to server', (tester) async {
await tester.initializeAppFlowy(
cloudType: AuthenticatorType.appflowyCloud,
email: email,
);
await tester.tapGoogleLoginInButton();
tester.expectToSeeHomePage();
await tester.openSettings();
await tester.openSettingsPage(SettingsPage.user);
final userAvatarFinder = find.descendant(
of: find.byType(SettingsUserView),
matching: find.byType(UserAvatar),
);
// Open icon picker dialog and select emoji
await tester.tap(userAvatarFinder);
await tester.pumpAndSettle();
await tester.tapEmoji('😁');
await tester.pumpAndSettle();
final UserAvatar userAvatar =
tester.widget(userAvatarFinder) as UserAvatar;
expect(userAvatar.iconUrl, '😁');
// enter user name
final userNameFinder = find.descendant(
of: find.byType(SettingsUserView),
matching: find.byType(UserNameInput),
);
await tester.enterText(userNameFinder, name);
await tester.pumpAndSettle();
await tester.tapEscButton();
// wait 2 seconds for the sync to finish
await tester.pumpAndSettle(const Duration(seconds: 2));
});
});
testWidgets('get user icon and name from server', (tester) async {
await tester.initializeAppFlowy(
cloudType: AuthenticatorType.appflowyCloud,
email: email,
);
await tester.tapGoogleLoginInButton();
tester.expectToSeeHomePage();
await tester.pumpAndSettle();
await tester.openSettings();
await tester.openSettingsPage(SettingsPage.user);
// verify icon
final userAvatarFinder = find.descendant(
of: find.byType(SettingsUserView),
matching: find.byType(UserAvatar),
);
final UserAvatar userAvatar = tester.widget(userAvatarFinder) as UserAvatar;
expect(userAvatar.iconUrl, '😁');
// verify name
final userNameFinder = find.descendant(
of: find.byType(SettingsUserView),
matching: find.byType(UserNameInput),
);
final UserNameInput userNameInput =
tester.widget(userNameFinder) as UserNameInput;
expect(userNameInput.name, name);
});
}

View File

@ -1,13 +0,0 @@
import 'package:integration_test/integration_test.dart';
import 'auth/appflowy_cloud_auth_test.dart' as appflowy_cloud_auth_test;
import 'empty_test.dart' as first_test;
Future<void> main() async {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
// This test must be run first, otherwise the CI will fail.
first_test.main();
appflowy_cloud_auth_test.main();
}

View File

@ -1,13 +1,11 @@
import 'package:integration_test/integration_test.dart'; import 'package:integration_test/integration_test.dart';
import 'notifications_settings_test.dart' as notifications_settings_test; import 'notifications_settings_test.dart' as notifications_settings_test;
import 'user_icon_test.dart' as user_icon_test;
import 'user_language_test.dart' as user_language_test; import 'user_language_test.dart' as user_language_test;
void main() { void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized(); IntegrationTestWidgetsFlutterBinding.ensureInitialized();
notifications_settings_test.main(); notifications_settings_test.main();
user_icon_test.main();
user_language_test.main(); user_language_test.main();
} }

View File

@ -1,41 +0,0 @@
import 'package:appflowy/workspace/application/settings/prelude.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/settings_user_view.dart';
import 'package:appflowy/workspace/presentation/widgets/user_avatar.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import '../util/emoji.dart';
import '../util/util.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('Settings: user icon tests', () {
testWidgets('select icon, select default option', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
tester.expectToSeeHomePage();
await tester.openSettings();
await tester.openSettingsPage(SettingsPage.user);
final userAvatarFinder = find.descendant(
of: find.byType(SettingsUserView),
matching: find.byType(UserAvatar),
);
// Open icon picker dialog
await tester.tap(userAvatarFinder);
await tester.pumpAndSettle();
// Select first option that isn't default
await tester.tapEmoji('😁');
await tester.pumpAndSettle();
final UserAvatar userAvatar =
tester.widget(userAvatarFinder) as UserAvatar;
expect(userAvatar.iconUrl, '😁');
});
});
}

View File

@ -35,7 +35,7 @@ extension AppFlowyTestBase on WidgetTester {
String? pathExtension, String? pathExtension,
Size windowsSize = const Size(1600, 1200), Size windowsSize = const Size(1600, 1200),
AuthenticatorType? cloudType, AuthenticatorType? cloudType,
String? userEmail, String? email,
}) async { }) async {
binding.setSurfaceSize(windowsSize); binding.setSurfaceSize(windowsSize);
@ -71,6 +71,7 @@ extension AppFlowyTestBase on WidgetTester {
if (cloudType != null) { if (cloudType != null) {
switch (cloudType) { switch (cloudType) {
case AuthenticatorType.local: case AuthenticatorType.local:
await useLocal();
break; break;
case AuthenticatorType.supabase: case AuthenticatorType.supabase:
await useSupabaseCloud(); await useSupabaseCloud();
@ -83,7 +84,7 @@ extension AppFlowyTestBase on WidgetTester {
await useAppFlowyCloud(); await useAppFlowyCloud();
getIt.unregister<AuthService>(); getIt.unregister<AuthService>();
getIt.registerFactory<AuthService>( getIt.registerFactory<AuthService>(
() => AppFlowyCloudMockAuthService(email: userEmail), () => AppFlowyCloudMockAuthService(email: email),
); );
break; break;
} }
@ -256,6 +257,10 @@ extension AppFlowyFinderTestBase on CommonFinders {
} }
} }
Future<void> useLocal() async {
await setAuthenticatorType(AuthenticatorType.local);
}
Future<void> useSupabaseCloud() async { Future<void> useSupabaseCloud() async {
await setAuthenticatorType(AuthenticatorType.supabase); await setAuthenticatorType(AuthenticatorType.supabase);
await setSupbaseServer( await setSupbaseServer(
@ -266,5 +271,5 @@ Future<void> useSupabaseCloud() async {
Future<void> useAppFlowyCloud() async { Future<void> useAppFlowyCloud() async {
await setAuthenticatorType(AuthenticatorType.appflowyCloud); await setAuthenticatorType(AuthenticatorType.appflowyCloud);
// await setAppFlowyCloudUrl(Some(TestEnv.afCloudUrl)); await setAppFlowyCloudUrl(Some(TestEnv.afCloudUrl));
} }

View File

@ -0,0 +1,30 @@
import 'dart:io';
import 'package:path/path.dart' as p;
Future<void> deleteDirectoriesWithSameBaseNameAsPrefix(
String path,
) async {
final dir = Directory(path);
final prefix = p.basename(dir.path);
final parentDir = dir.parent;
// Check if the directory exists
if (!await parentDir.exists()) {
// ignore: avoid_print
print('Directory does not exist');
return;
}
// List all entities in the directory
await for (final entity in parentDir.list()) {
// Check if the entity is a directory and starts with the specified prefix
if (entity is Directory && p.basename(entity.path).startsWith(prefix)) {
try {
await entity.delete(recursive: true);
} catch (e) {
// ignore: avoid_print
print('Failed to delete directory: ${entity.path}, Error: $e');
}
}
}
}

View File

@ -70,10 +70,6 @@ Future<AuthenticatorType> getAuthenticatorType() async {
/// AppFlowy Cloud or Supabase configuration is valid. /// AppFlowy Cloud or Supabase configuration is valid.
/// Returns `false` otherwise. /// Returns `false` otherwise.
bool get isAuthEnabled { bool get isAuthEnabled {
// Only enable supabase in release and develop mode.
if (integrationMode().isRelease ||
integrationMode().isDevelop ||
integrationMode().isIntegrationTest) {
final env = getIt<AppFlowyCloudSharedEnv>(); final env = getIt<AppFlowyCloudSharedEnv>();
if (env.authenticatorType == AuthenticatorType.supabase) { if (env.authenticatorType == AuthenticatorType.supabase) {
return env.supabaseConfig.isValid; return env.supabaseConfig.isValid;
@ -84,9 +80,6 @@ bool get isAuthEnabled {
} }
return false; return false;
} else {
return false;
}
} }
/// Checks if Supabase is enabled. /// Checks if Supabase is enabled.
@ -99,34 +92,12 @@ bool get isAuthEnabled {
/// if the application is in release or develop mode and the current cloud type /// if the application is in release or develop mode and the current cloud type
/// is `CloudType.supabase`. Otherwise, it returns `false`. /// is `CloudType.supabase`. Otherwise, it returns `false`.
bool get isSupabaseEnabled { bool get isSupabaseEnabled {
// Only enable supabase in release and develop mode.
if (integrationMode().isRelease ||
integrationMode().isDevelop ||
integrationMode().isIntegrationTest) {
return currentCloudType() == AuthenticatorType.supabase; return currentCloudType() == AuthenticatorType.supabase;
} else {
return false;
}
} }
/// Determines if AppFlowy Cloud is enabled. /// 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 { bool get isAppFlowyCloudEnabled {
// Only enable appflowy cloud in release and develop mode.
if (integrationMode().isRelease ||
integrationMode().isDevelop ||
integrationMode().isIntegrationTest) {
return currentCloudType() == AuthenticatorType.appflowyCloud; return currentCloudType() == AuthenticatorType.appflowyCloud;
} else {
return false;
}
} }
enum AuthenticatorType { enum AuthenticatorType {

View File

@ -1,4 +1,6 @@
// lib/env/env.dart // lib/env/env.dart
// ignore_for_file: prefer_const_declarations
import 'package:envied/envied.dart'; import 'package:envied/envied.dart';
part 'cloud_env_test.g.dart'; part 'cloud_env_test.g.dart';
@ -9,21 +11,21 @@ part 'cloud_env_test.g.dart';
abstract class TestEnv { abstract class TestEnv {
/// AppFlowy Cloud Configuration /// AppFlowy Cloud Configuration
@EnviedField( @EnviedField(
obfuscate: true, obfuscate: false,
varName: 'APPFLOWY_CLOUD_URL', varName: 'APPFLOWY_CLOUD_URL',
defaultValue: '', defaultValue: 'http://localhost',
) )
static final String afCloudUrl = _TestEnv.afCloudUrl; static final String afCloudUrl = _TestEnv.afCloudUrl;
// Supabase Configuration: // Supabase Configuration:
@EnviedField( @EnviedField(
obfuscate: true, obfuscate: false,
varName: 'SUPABASE_URL', varName: 'SUPABASE_URL',
defaultValue: '', defaultValue: '',
) )
static final String supabaseUrl = _TestEnv.supabaseUrl; static final String supabaseUrl = _TestEnv.supabaseUrl;
@EnviedField( @EnviedField(
obfuscate: true, obfuscate: false,
varName: 'SUPABASE_ANON_KEY', varName: 'SUPABASE_ANON_KEY',
defaultValue: '', defaultValue: '',
) )

View File

@ -7,6 +7,7 @@ import 'package:appflowy/plugins/document/application/document_data_pb_extension
import 'package:appflowy/plugins/document/application/editor_transaction_adapter.dart'; import 'package:appflowy/plugins/document/application/editor_transaction_adapter.dart';
import 'package:appflowy/plugins/trash/application/trash_service.dart'; import 'package:appflowy/plugins/trash/application/trash_service.dart';
import 'package:appflowy/workspace/application/doc/doc_listener.dart'; import 'package:appflowy/workspace/application/doc/doc_listener.dart';
import 'package:appflowy/workspace/application/doc/sync_state_listener.dart';
import 'package:appflowy/workspace/application/view/view_listener.dart'; import 'package:appflowy/workspace/application/view/view_listener.dart';
import 'package:appflowy_backend/protobuf/flowy-document2/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-document2/protobuf.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
@ -30,6 +31,7 @@ class DocumentBloc extends Bloc<DocumentEvent, DocumentState> {
DocumentBloc({ DocumentBloc({
required this.view, required this.view,
}) : _documentListener = DocumentListener(id: view.id), }) : _documentListener = DocumentListener(id: view.id),
_syncStateListener = DocumentSyncStateListener(id: view.id),
_viewListener = ViewListener(viewId: view.id), _viewListener = ViewListener(viewId: view.id),
super(DocumentState.initial()) { super(DocumentState.initial()) {
on<DocumentEvent>(_onDocumentEvent); on<DocumentEvent>(_onDocumentEvent);
@ -38,6 +40,7 @@ class DocumentBloc extends Bloc<DocumentEvent, DocumentState> {
final ViewPB view; final ViewPB view;
final DocumentListener _documentListener; final DocumentListener _documentListener;
final DocumentSyncStateListener _syncStateListener;
final ViewListener _viewListener; final ViewListener _viewListener;
final DocumentService _documentService = DocumentService(); final DocumentService _documentService = DocumentService();
@ -53,6 +56,7 @@ class DocumentBloc extends Bloc<DocumentEvent, DocumentState> {
@override @override
Future<void> close() async { Future<void> close() async {
await _documentListener.stop(); await _documentListener.stop();
await _syncStateListener.stop();
await _viewListener.stop(); await _viewListener.stop();
await _subscription?.cancel(); await _subscription?.cancel();
await _documentService.closeDocument(view: view); await _documentService.closeDocument(view: view);
@ -64,8 +68,8 @@ class DocumentBloc extends Bloc<DocumentEvent, DocumentState> {
DocumentEvent event, DocumentEvent event,
Emitter<DocumentState> emit, Emitter<DocumentState> emit,
) async { ) async {
await event.map( await event.when(
initial: (Initial value) async { initial: () async {
final editorState = await _fetchDocumentState(); final editorState = await _fetchDocumentState();
_onViewChanged(); _onViewChanged();
_onDocumentChanged(); _onDocumentChanged();
@ -86,22 +90,25 @@ class DocumentBloc extends Bloc<DocumentEvent, DocumentState> {
), ),
); );
}, },
moveToTrash: (MoveToTrash value) async { moveToTrash: () async {
emit(state.copyWith(isDeleted: true)); emit(state.copyWith(isDeleted: true));
}, },
restore: (Restore value) async { restore: () async {
emit(state.copyWith(isDeleted: false)); emit(state.copyWith(isDeleted: false));
}, },
deletePermanently: (DeletePermanently value) async { deletePermanently: () async {
final result = await _trashService.deleteViews([view.id]); final result = await _trashService.deleteViews([view.id]);
final forceClose = result.fold((l) => true, (r) => false); final forceClose = result.fold((l) => true, (r) => false);
emit(state.copyWith(forceClose: forceClose)); emit(state.copyWith(forceClose: forceClose));
}, },
restorePage: (RestorePage value) async { restorePage: () async {
final result = await _trashService.putback(view.id); final result = await _trashService.putback(view.id);
final isDeleted = result.fold((l) => false, (r) => true); final isDeleted = result.fold((l) => false, (r) => true);
emit(state.copyWith(isDeleted: isDeleted)); emit(state.copyWith(isDeleted: isDeleted));
}, },
syncStateChanged: (isSyncing) {
emit(state.copyWith(isSyncing: isSyncing));
},
); );
} }
@ -124,6 +131,14 @@ class DocumentBloc extends Bloc<DocumentEvent, DocumentState> {
_documentListener.start( _documentListener.start(
didReceiveUpdate: syncDocumentDataPB, didReceiveUpdate: syncDocumentDataPB,
); );
_syncStateListener.start(
didReceiveSyncState: (syncState) {
if (!isClosed) {
add(DocumentEvent.syncStateChanged(syncState.isSyncing));
}
},
);
} }
/// Fetch document /// Fetch document
@ -235,6 +250,8 @@ class DocumentEvent with _$DocumentEvent {
const factory DocumentEvent.restore() = Restore; const factory DocumentEvent.restore() = Restore;
const factory DocumentEvent.restorePage() = RestorePage; const factory DocumentEvent.restorePage() = RestorePage;
const factory DocumentEvent.deletePermanently() = DeletePermanently; const factory DocumentEvent.deletePermanently() = DeletePermanently;
const factory DocumentEvent.syncStateChanged(bool isSyncing) =
syncStateChanged;
} }
@freezed @freezed
@ -243,6 +260,7 @@ class DocumentState with _$DocumentState {
required bool isDeleted, required bool isDeleted,
required bool forceClose, required bool forceClose,
required bool isLoading, required bool isLoading,
required bool isSyncing,
bool? isDocumentEmpty, bool? isDocumentEmpty,
UserProfilePB? userProfilePB, UserProfilePB? userProfilePB,
EditorState? editorState, EditorState? editorState,
@ -257,5 +275,6 @@ class DocumentState with _$DocumentState {
editorState: null, editorState: null,
error: null, error: null,
isLoading: true, isLoading: true,
isSyncing: false,
); );
} }

View File

@ -111,6 +111,10 @@ class _DocumentPageState extends State<DocumentPage> {
return Column( return Column(
children: [ children: [
// Only show the indicator in integration test mode
// if (FlowyRunner.currentMode.isIntegrationTest)
// const DocumentSyncIndicator(),
if (state.isDeleted) _buildBanner(context), if (state.isDeleted) _buildBanner(context),
Expanded(child: appflowyEditorPage), Expanded(child: appflowyEditorPage),
], ],
@ -177,3 +181,20 @@ class _DocumentPageState extends State<DocumentPage> {
} }
} }
} }
class DocumentSyncIndicator extends StatelessWidget {
const DocumentSyncIndicator({super.key});
@override
Widget build(BuildContext context) {
return BlocBuilder<DocumentBloc, DocumentState>(
builder: (context, state) {
if (state.isSyncing) {
return const SizedBox(height: 1, child: LinearProgressIndicator());
} else {
return const SizedBox(height: 1);
}
},
);
}
}

View File

@ -49,7 +49,6 @@ class DependencyResolver {
_resolveUserDeps(getIt, mode); _resolveUserDeps(getIt, mode);
_resolveHomeDeps(getIt); _resolveHomeDeps(getIt);
_resolveFolderDeps(getIt); _resolveFolderDeps(getIt);
_resolveDocDeps(getIt);
_resolveCommonService(getIt, mode); _resolveCommonService(getIt, mode);
} }
} }
@ -219,10 +218,3 @@ void _resolveFolderDeps(GetIt getIt) {
); );
getIt.registerFactory<FavoriteBloc>(() => FavoriteBloc()); getIt.registerFactory<FavoriteBloc>(() => FavoriteBloc());
} }
void _resolveDocDeps(GetIt getIt) {
// Doc
getIt.registerFactoryParam<DocumentBloc, ViewPB, void>(
(view, _) => DocumentBloc(view: view),
);
}

View File

@ -13,6 +13,7 @@ import 'package:appflowy/user/application/user_auth_listener.dart';
import 'package:appflowy/workspace/presentation/home/toast.dart'; import 'package:appflowy/workspace/presentation/home/toast.dart';
import 'package:appflowy_backend/dispatch/dispatch.dart'; import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/log.dart'; import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-error/code.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
import 'package:dartz/dartz.dart'; import 'package:dartz/dartz.dart';
@ -21,16 +22,14 @@ import 'package:url_protocol/url_protocol.dart';
class AppFlowyCloudDeepLink { class AppFlowyCloudDeepLink {
final _appLinks = AppLinks(); final _appLinks = AppLinks();
StreamSubscription<Uri?>? _deeplinkSubscription; // The AppLinks is a singleton, so we need to cancel the previous subscription
final ValueNotifier<DeepLinkResult?> stateNotifier = ValueNotifier(null); // before creating a new one.
static StreamSubscription<Uri?>? _deeplinkSubscription;
ValueNotifier<DeepLinkResult?>? _stateNotifier = ValueNotifier(null);
Completer<Either<FlowyError, UserProfilePB>>? _completer; Completer<Either<FlowyError, UserProfilePB>>? _completer;
AppFlowyCloudDeepLink() { AppFlowyCloudDeepLink() {
if (Platform.isWindows) { if (_deeplinkSubscription == null) {
// register deep link for Windows
registerProtocolHandler(appflowyDeepLinkSchema);
}
_deeplinkSubscription = _appLinks.uriLinkStream.listen( _deeplinkSubscription = _appLinks.uriLinkStream.listen(
(Uri? uri) async { (Uri? uri) async {
Log.info('onDeepLink: ${uri.toString()}'); Log.info('onDeepLink: ${uri.toString()}');
@ -39,12 +38,22 @@ class AppFlowyCloudDeepLink {
onError: (Object err, StackTrace stackTrace) { onError: (Object err, StackTrace stackTrace) {
Log.error('on deeplink stream error: ${err.toString()}', stackTrace); Log.error('on deeplink stream error: ${err.toString()}', stackTrace);
_deeplinkSubscription?.cancel(); _deeplinkSubscription?.cancel();
_deeplinkSubscription = null;
}, },
); );
if (Platform.isWindows) {
// register deep link for Windows
registerProtocolHandler(appflowyDeepLinkSchema);
}
} else {
_deeplinkSubscription?.resume();
}
} }
Future<void> dispose() async { Future<void> dispose() async {
await _deeplinkSubscription?.cancel(); _deeplinkSubscription?.pause();
_stateNotifier?.dispose();
_stateNotifier = null;
} }
void resigerCompleter( void resigerCompleter(
@ -57,27 +66,25 @@ class AppFlowyCloudDeepLink {
ValueChanged<DeepLinkResult> listener, ValueChanged<DeepLinkResult> listener,
) { ) {
void listenerFn() { void listenerFn() {
if (stateNotifier.value != null) { if (_stateNotifier?.value != null) {
listener(stateNotifier.value!); listener(_stateNotifier!.value!);
} }
} }
stateNotifier.addListener(listenerFn); _stateNotifier?.addListener(listenerFn);
return listenerFn; return listenerFn;
} }
void unsubscribeDeepLinkLoadingState( void unsubscribeDeepLinkLoadingState(VoidCallback listener) =>
VoidCallback listener, _stateNotifier?.removeListener(listener);
) {
stateNotifier.removeListener(listener);
}
Future<void> _handleUri( Future<void> _handleUri(
Uri? uri, Uri? uri,
) async { ) async {
stateNotifier.value = DeepLinkResult(state: DeepLinkState.none); _stateNotifier?.value = DeepLinkResult(state: DeepLinkState.none);
if (uri != null) { if (uri != null) {
if (_isAuthCallbackDeeplink(uri)) { _isAuthCallbackDeeplink(uri).fold(
(_) async {
final deviceId = await getDeviceId(); final deviceId = await getDeviceId();
final payload = OauthSignInPB( final payload = OauthSignInPB(
authType: AuthTypePB.AFCloud, authType: AuthTypePB.AFCloud,
@ -86,12 +93,12 @@ class AppFlowyCloudDeepLink {
AuthServiceMapKeys.deviceId: deviceId, AuthServiceMapKeys.deviceId: deviceId,
}, },
); );
stateNotifier.value = DeepLinkResult(state: DeepLinkState.loading); _stateNotifier?.value = DeepLinkResult(state: DeepLinkState.loading);
final result = await UserEventOauthSignIn(payload) final result = await UserEventOauthSignIn(payload)
.send() .send()
.then((value) => value.swap()); .then((value) => value.swap());
stateNotifier.value = DeepLinkResult( _stateNotifier?.value = DeepLinkResult(
state: DeepLinkState.finish, state: DeepLinkState.finish,
result: result, result: result,
); );
@ -108,8 +115,7 @@ class AppFlowyCloudDeepLink {
); );
} }
}, },
(err) async { (_) async {
Log.error(err);
await runAppFlowy(); await runAppFlowy();
}, },
); );
@ -117,11 +123,23 @@ class AppFlowyCloudDeepLink {
_completer?.complete(result); _completer?.complete(result);
_completer = null; _completer = null;
} }
},
(err) {
Log.error('onDeepLinkError: Unexpect deep link: $err');
if (_completer == null) {
final context = AppGlobals.rootNavKey.currentState?.context;
if (context != null) {
showSnackBarMessage(
context,
err.msg,
);
}
} else { } else {
Log.error('onDeepLinkError: Unexpect deep link: ${uri.toString()}'); _completer?.complete(left(err));
_completer?.complete(left(AuthError.signInWithOauthError));
_completer = null; _completer = null;
} }
},
);
} else { } else {
Log.error('onDeepLinkError: Unexpect empty deep link callback'); Log.error('onDeepLinkError: Unexpect empty deep link callback');
_completer?.complete(left(AuthError.emptyDeeplink)); _completer?.complete(left(AuthError.emptyDeeplink));
@ -129,8 +147,16 @@ class AppFlowyCloudDeepLink {
} }
} }
bool _isAuthCallbackDeeplink(Uri uri) { Either<(), FlowyError> _isAuthCallbackDeeplink(Uri uri) {
return (uri.fragment.contains('access_token')); if (uri.fragment.contains('access_token')) {
return left(());
}
return right(
FlowyError.create()
..code = ErrorCode.MissingAuthField
..msg = uri.path,
);
} }
} }

View File

@ -1,4 +1,5 @@
import 'package:appflowy/env/cloud_env.dart'; import 'package:appflowy/env/cloud_env.dart';
import 'package:appflowy/env/env.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/user/application/auth/auth_service.dart'; import 'package:appflowy/user/application/auth/auth_service.dart';
@ -95,7 +96,7 @@ class SplashScreen extends StatelessWidget {
void _handleUnauthenticated(BuildContext context, Unauthenticated result) { void _handleUnauthenticated(BuildContext context, Unauthenticated result) {
Log.trace( Log.trace(
'_handleUnauthenticated -> cloud is enabled: $isAuthEnabled', "_handleUnauthenticated -> enable custom cloud: ${Env.enableCustomCloud}, appflowy cloud is enabled: $isAppFlowyCloudEnabled, appflowy cloud config: ${getIt<AppFlowyCloudSharedEnv>().appflowyCloudConfig.toJson()}",
); );
// replace Splash screen as root page // replace Splash screen as root page
if (isAuthEnabled || PlatformExtension.isMobile) { if (isAuthEnabled || PlatformExtension.isMobile) {

View File

@ -0,0 +1,57 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:appflowy/core/notification/document_notification.dart';
import 'package:appflowy_backend/protobuf/flowy-document2/protobuf.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-notification/subject.pb.dart';
import 'package:appflowy_backend/rust_stream.dart';
import 'package:dartz/dartz.dart';
class DocumentSyncStateListener {
DocumentSyncStateListener({
required this.id,
});
final String id;
StreamSubscription<SubscribeObject>? _subscription;
DocumentNotificationParser? _parser;
Function(DocumentSyncStatePB syncState)? didReceiveSyncState;
void start({
Function(DocumentSyncStatePB syncState)? didReceiveSyncState,
}) {
this.didReceiveSyncState = didReceiveSyncState;
_parser = DocumentNotificationParser(
id: id,
callback: _callback,
);
_subscription = RustStreamReceiver.listen(
(observable) => _parser?.parse(observable),
);
}
void _callback(
DocumentNotification ty,
Either<Uint8List, FlowyError> result,
) {
switch (ty) {
case DocumentNotification.DidUpdateDocumentSyncState:
result.swap().map(
(r) {
final value = DocumentSyncStatePB.fromBuffer(r);
didReceiveSyncState?.call(value);
},
);
break;
default:
break;
}
}
Future<void> stop() async {
await _subscription?.cancel();
_subscription = null;
}
}

View File

@ -21,7 +21,7 @@ async fn af_cloud_edit_document_test() {
// wait all update are send to the remote // wait all update are send to the remote
let rx = test let rx = test
.notification_sender .notification_sender
.subscribe_with_condition::<DocumentSyncStatePB, _>(&document_id, |pb| pb.is_finish); .subscribe_with_condition::<DocumentSyncStatePB, _>(&document_id, |pb| !pb.is_syncing);
receive_with_timeout(rx, Duration::from_secs(25)) receive_with_timeout(rx, Duration::from_secs(25))
.await .await
.unwrap(); .unwrap();

View File

@ -23,7 +23,7 @@ async fn supabase_document_edit_sync_test() {
// wait all update are send to the remote // wait all update are send to the remote
let rx = test let rx = test
.notification_sender .notification_sender
.subscribe_with_condition::<DocumentSyncStatePB, _>(&document_id, |pb| pb.is_finish); .subscribe_with_condition::<DocumentSyncStatePB, _>(&document_id, |pb| !pb.is_syncing);
receive_with_timeout(rx, Duration::from_secs(30)) receive_with_timeout(rx, Duration::from_secs(30))
.await .await
.unwrap(); .unwrap();
@ -49,7 +49,7 @@ async fn supabase_document_edit_sync_test2() {
// wait all update are send to the remote // wait all update are send to the remote
let rx = test let rx = test
.notification_sender .notification_sender
.subscribe_with_condition::<DocumentSyncStatePB, _>(&document_id, |pb| pb.is_finish); .subscribe_with_condition::<DocumentSyncStatePB, _>(&document_id, |pb| !pb.is_syncing);
receive_with_timeout(rx, Duration::from_secs(30)) receive_with_timeout(rx, Duration::from_secs(30))
.await .await
.unwrap(); .unwrap();

View File

@ -413,16 +413,12 @@ pub struct DocumentSnapshotStatePB {
pub struct DocumentSyncStatePB { pub struct DocumentSyncStatePB {
#[pb(index = 1)] #[pb(index = 1)]
pub is_syncing: bool, pub is_syncing: bool,
#[pb(index = 2)]
pub is_finish: bool,
} }
impl From<SyncState> for DocumentSyncStatePB { impl From<SyncState> for DocumentSyncStatePB {
fn from(value: SyncState) -> Self { fn from(value: SyncState) -> Self {
Self { Self {
is_syncing: value.is_syncing(), is_syncing: value.is_syncing(),
is_finish: value.is_sync_finished(),
} }
} }
} }

View File

@ -10,6 +10,7 @@ use collab_document::YrsDocAction;
use collab_entity::CollabType; use collab_entity::CollabType;
use lru::LruCache; use lru::LruCache;
use parking_lot::Mutex; use parking_lot::Mutex;
use tokio_stream::StreamExt;
use tracing::{event, instrument}; use tracing::{event, instrument};
use collab_integrate::collab_builder::AppFlowyCollabBuilder; use collab_integrate::collab_builder::AppFlowyCollabBuilder;

View File

@ -512,6 +512,7 @@ impl UserManager {
} }
pub async fn prepare_user(&self, session: &Session) { pub async fn prepare_user(&self, session: &Session) {
let _ = self.database.close(session.user_id);
self.set_collab_config(session); self.set_collab_config(session);
// Ensure to backup user data if a cloud drive is used for storage. While using a cloud drive // Ensure to backup user data if a cloud drive is used for storage. While using a cloud drive
// for storing user data is not advised due to potential data corruption risks, in scenarios where // for storing user data is not advised due to potential data corruption risks, in scenarios where

View File

@ -34,15 +34,6 @@ pub struct UserDB {
impl UserDB { impl UserDB {
pub fn new(paths: impl UserDBPath) -> Self { pub fn new(paths: impl UserDBPath) -> Self {
// if let Some(mut db) = DB_MAP.try_write_for(Duration::from_millis(300)) {
// info!("clear sqlite db map");
// db.clear();
// }
//
// if let Some(mut collab_db) = COLLAB_DB_MAP.try_write_for(Duration::from_millis(300)) {
// info!("clear collab db map");
// collab_db.clear();
// }
Self { Self {
paths: Box::new(paths), paths: Box::new(paths),
} }
@ -132,12 +123,7 @@ impl UserDB {
} }
pub(crate) fn get_collab_db(&self, user_id: i64) -> Result<Arc<RocksCollabDB>, FlowyError> { pub(crate) fn get_collab_db(&self, user_id: i64) -> Result<Arc<RocksCollabDB>, FlowyError> {
let collab_db = open_collab_db( let collab_db = open_collab_db(self.paths.collab_db_path(user_id), 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(),
user_id,
)?;
Ok(collab_db) Ok(collab_db)
} }
} }