From ed075a35b6ba5b64d335322d19cb1ae64012e501 Mon Sep 17 00:00:00 2001 From: TheOneWithTheBraid Date: Thu, 29 Dec 2022 15:02:29 +0100 Subject: [PATCH] chore: add integration tests Signed-off-by: TheOneWithTheBraid --- .gitlab-ci.yml | 84 ++++---- integration_test/app_test.dart | 126 +++++++++--- .../extensions/default_flows.dart | 182 ++++++++++++++++++ integration_test/extensions/wait_for.dart | 49 +++++ integration_test/users.dart | 32 ++- .../chat_list/client_chooser_button.dart | 9 +- lib/pages/connect/connect_page_view.dart | 1 + lib/pages/login/login.dart | 32 +-- lib/pages/login/login_view.dart | 6 +- lib/pages/settings/settings_view.dart | 1 + lib/widgets/content_banner.dart | 3 + lib/widgets/matrix.dart | 8 +- lib/widgets/profile_bottom_sheet.dart | 8 + lib/widgets/theme_builder.dart | 18 +- scripts/integration-prepare-alpine.sh | 2 - scripts/integration-prepare-homeserver.sh | 25 +++ scripts/integration-prepare-host.sh | 6 + scripts/integration-start-avd.sh | 2 +- 18 files changed, 491 insertions(+), 103 deletions(-) create mode 100644 integration_test/extensions/default_flows.dart create mode 100644 integration_test/extensions/wait_for.dart delete mode 100755 scripts/integration-prepare-alpine.sh create mode 100755 scripts/integration-prepare-host.sh diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 0f0e58e1..a1c1a716 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,7 +1,9 @@ variables: FLUTTER_VERSION: 3.3.9 -image: cirrusci/flutter:${FLUTTER_VERSION} +image: + name: cirrusci/flutter:${FLUTTER_VERSION} + pull_policy: if-not-present .shared_windows_runners: tags: @@ -16,7 +18,7 @@ stages: code_analyze: stage: test - script: [./scripts/code_analyze.sh] + script: [ ./scripts/code_analyze.sh ] artifacts: reports: codequality: code-quality-report.json @@ -26,13 +28,13 @@ code_analyze: widget_test: stage: test - script: [flutter test] + script: [ flutter test ] tags: - docker - famedly # the basic integration test configuration testing FLOSS builds on Synapse -.integration_test: +integration_test: image: registry.gitlab.com/famedly/company/frontend/flutter-dockerimages/integration/stable:${FLUTTER_VERSION} stage: test services: @@ -49,15 +51,13 @@ widget_test: FF_NETWORK_PER_BUILD: "true" # Tell docker CLI how to talk to Docker daemon. DOCKER_HOST: tcp://docker:2375/ - # Use the overlayfs driver for improved performance. - DOCKER_DRIVER: overlay2 + # Use the btrfs driver for improved performance. + DOCKER_DRIVER: btrfs # Disable TLS since we're running inside local network. DOCKER_TLS_CERTDIR: "" - HOMESERVER: "docker" + HOMESERVER: docker before_script: - # start AVD and keep running in background - - scripts/integration-start-avd.sh & - - scripts/integration-prepare-alpine.sh + - scripts/integration-prepare-host.sh # create test user environment variables - source scripts/integration-create-environment-variables.sh # create Synapse instance @@ -65,31 +65,49 @@ widget_test: # properly set the homeserver IP and create test users - scripts/integration-prepare-homeserver.sh script: + # start AVD and keep running in background + - scripts/integration-start-avd.sh & - flutter pub get - - flutter test integration_test - timeout: 20m + - scrcpy --no-display --record video.mkv & + - flutter test integration_test --dart-define=HOMESERVER=$HOMESERVER --dart-define=USER1_NAME=$USER1_NAME --dart-define=USER2_NAME=$USER2_NAME --dart-define=USER1_PW=$USER1_PW --dart-define=USER2_PW=$USER2_PW || ( sleep 10 && exit 1 ) + after_script: + - ffmpeg -i video.mkv -vf scale=iw/2:-2 -crf 40 -b:v 2000k -preset fast video.mp4 || true + timeout: 30m + retry: 2 + artifacts: + when: always + paths: + - video.mp4 tags: - docker - famedly # integration tests for Linux builds +### disabled because of Linux headless issues .integration_test_linux: - extends: .integration_test + image: cirrusci/flutter:${FLUTTER_VERSION} + extends: integration_test script: - - apk add cmake ninja gtk+3.0-dev clang pkgconf xz-dev libsecret-dev jsoncpp-dev + - apt-get update + - apt-get install -y clang cmake ninja-build pkg-config libgtk-3-dev liblzma-dev libsecret-1-dev libjsoncpp-dev - flutter pub get - - flutter test integration_test -d linux + - flutter test integration_test -d linux --dart-define=HOMESERVER=$HOMESERVER --dart-define=USER1_NAME=$USER1_NAME --dart-define=USER2_NAME=$USER2_NAME --dart-define=USER1_PW=$USER1_PW --dart-define=USER2_PW=$USER2_PW || ( sleep 10 && exit 1 ) + after_script: [ ] + artifacts: # extending the default tests to test the Google-flavored builds -.integration_test_proprietary: - extends: .integration_test +integration_test_proprietary: + extends: integration_test script: + # start AVD and keep running in background + - scripts/integration-start-avd.sh & - git apply ./scripts/enable-android-google-services.patch - flutter pub get - - flutter test integration_test + - scrcpy --no-display --record video.mkv & + - flutter test integration_test --dart-define=HOMESERVER=$HOMESERVER --dart-define=USER1_NAME=$USER1_NAME --dart-define=USER2_NAME=$USER2_NAME --dart-define=USER1_PW=$USER1_PW --dart-define=USER2_PW=$USER2_PW || ( sleep 10 && exit 1 ) -.release_mode_launches: +release_mode_launches: parallel: matrix: - FLAVOR: @@ -99,9 +117,9 @@ widget_test: stage: test before_script: - | - if [ "$FLAVOR" == "proprietary" ]; then - git apply ./scripts/enable-android-google-services.patch - fi + if [ "$FLAVOR" == "proprietary" ]; then + git apply ./scripts/enable-android-google-services.patch + fi script: # start AVD and keep running in background - scripts/integration-start-avd.sh & @@ -115,8 +133,8 @@ widget_test: build_web: stage: build before_script: - [sudo apt update && sudo apt install curl -y, ./scripts/prepare-web.sh] - script: [./scripts/build-web.sh] + [ sudo apt update && sudo apt install curl -y, ./scripts/prepare-web.sh ] + script: [ ./scripts/build-web.sh ] artifacts: paths: - build/web/ @@ -166,7 +184,7 @@ build_windows: build_android_debug: stage: build - script: [./scripts/build-android-debug.sh] + script: [ ./scripts/build-android-debug.sh ] artifacts: when: on_success paths: @@ -183,7 +201,7 @@ build_android_apk: before_script: - git apply ./scripts/enable-android-google-services.patch - ./scripts/prepare-android-release.sh - script: [./scripts/build-android-apk.sh] + script: [ ./scripts/build-android-apk.sh ] artifacts: when: on_success paths: @@ -200,7 +218,7 @@ deploy_playstore_internal: before_script: - git apply ./scripts/enable-android-google-services.patch - ./scripts/prepare-android-release.sh - script: [./scripts/release-playstore-beta.sh] + script: [ ./scripts/release-playstore-beta.sh ] artifacts: when: on_success paths: @@ -267,7 +285,7 @@ build_linux_x86: [ sudo apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install keyboard-configuration -y && sudo apt-get install curl clang cmake ninja-build pkg-config libgtk-3-dev libblkid-dev liblzma-dev libjsoncpp-dev cmake-data libsecret-1-dev libsecret-1-0 librhash0 -y, ] - script: [./scripts/build-linux.sh] + script: [ ./scripts/build-linux.sh ] tags: - docker - famedly @@ -278,9 +296,9 @@ build_linux_x86: build_linux_arm64: stage: build - before_script: [flutter upgrade] - script: [./scripts/build-linux.sh] - tags: [docker_arm64] + before_script: [ flutter upgrade ] + script: [ ./scripts/build-linux.sh ] + tags: [ docker_arm64 ] only: - main - tags @@ -292,7 +310,7 @@ build_linux_arm64: update_dependencies: stage: build - needs: [] + needs: [ ] tags: - docker only: @@ -374,7 +392,7 @@ deploy_playstore: before_script: - git apply ./scripts/enable-android-google-services.patch - ./scripts/prepare-android-release.sh - script: [./scripts/release-playstore.sh] + script: [ ./scripts/release-playstore.sh ] resource_group: playstore_release only: - tags diff --git a/integration_test/app_test.dart b/integration_test/app_test.dart index 7690b874..0ebfb57c 100644 --- a/integration_test/app_test.dart +++ b/integration_test/app_test.dart @@ -1,49 +1,117 @@ -import 'dart:developer'; +import 'package:fluffychat/config/setting_keys.dart'; +import 'package:fluffychat/pages/chat/chat_view.dart'; +import 'package:fluffychat/pages/chat_list/chat_list_body.dart'; +import 'package:fluffychat/pages/chat_list/search_title.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:hive_flutter/hive_flutter.dart'; import 'package:integration_test/integration_test.dart'; import 'package:fluffychat/main.dart' as app; +import 'package:shared_preferences/shared_preferences.dart'; +import 'extensions/default_flows.dart'; +import 'extensions/wait_for.dart'; import 'users.dart'; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - group('Integration Test', () { - testWidgets('Test if the app starts', (WidgetTester tester) async { - app.main(); - await tester.pumpAndSettle(); + group( + 'Integration Test', + () { + setUpAll( + () async { + // this random dialog popping up is super hard to cover in tests + SharedPreferences.setMockInitialValues({ + SettingKeys.showNoGoogle: false, + }); + try { + Hive.deleteFromDisk(); + Hive.initFlutter(); + } catch (_) {} + }, + ); - await Future.delayed(const Duration(seconds: 10)); + testWidgets( + 'Start app, login and logout', + (WidgetTester tester) async { + app.main(); + await tester.ensureAppStartedHomescreen(); + await tester.ensureLoggedOut(); + }, + ); - await tester.pumpAndSettle(); + testWidgets( + 'Login again', + (WidgetTester tester) async { + app.main(); + await tester.ensureAppStartedHomescreen(); + }, + ); - expect(find.text('Connect'), findsOneWidget); + testWidgets( + 'Start chat and send message', + (WidgetTester tester) async { + app.main(); + await tester.ensureAppStartedHomescreen(); + await tester.waitFor(find.byType(TextField)); + await tester.enterText(find.byType(TextField), Users.user2.name); + await tester.pumpAndSettle(); - final input = find.byType(TextField); + await tester.scrollUntilVisible( + find.text('Chats'), + 500, + scrollable: find.descendant( + of: find.byType(ChatListViewBody), + matching: find.byType(Scrollable), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Chats')); + await tester.pumpAndSettle(); + await tester.waitFor(find.byType(SearchTitle)); + await tester.pumpAndSettle(); - expect(input, findsOneWidget); + await tester.scrollUntilVisible( + find.text(Users.user2.name).first, + 500, + scrollable: find.descendant( + of: find.byType(ChatListViewBody), + matching: find.byType(Scrollable), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text(Users.user2.name).first); - await tester.enterText(input, homeserver); - await tester.testTextInput.receiveAction(TextInputAction.done); - await tester.pumpAndSettle(); + try { + await tester.waitFor( + find.byType(ChatView), + timeout: const Duration(seconds: 5), + ); + } catch (_) { + // in case the homeserver sends the username as search result + if (find.byIcon(Icons.send_outlined).evaluate().isNotEmpty) { + await tester.tap(find.byIcon(Icons.send_outlined)); + await tester.pumpAndSettle(); + } + } - // in case registration is allowed - try { - await tester.tap(find.text('Login')); - await tester.pumpAndSettle(); - } catch (e) { - log('Registration is not allowed. Proceeding with login...'); - } - await tester.pumpAndSettle(); - - final inputs = find.byType(TextField); - - await tester.enterText(inputs.first, Users.user1.name); - await tester.enterText(inputs.last, Users.user1.password); - await tester.testTextInput.receiveAction(TextInputAction.done); - }); - }); + await tester.waitFor(find.byType(ChatView)); + await tester.enterText(find.byType(TextField).last, 'Test'); + await tester.pumpAndSettle(); + try { + await tester.waitFor(find.byIcon(Icons.send_outlined)); + await tester.tap(find.byIcon(Icons.send_outlined)); + } catch (_) { + await tester.testTextInput.receiveAction(TextInputAction.done); + } + await tester.pumpAndSettle(); + await tester.waitFor(find.text('Test')); + await tester.pumpAndSettle(); + }, + ); + }, + ); } diff --git a/integration_test/extensions/default_flows.dart b/integration_test/extensions/default_flows.dart new file mode 100644 index 00000000..b61f6aff --- /dev/null +++ b/integration_test/extensions/default_flows.dart @@ -0,0 +1,182 @@ +import 'dart:developer'; + +import 'package:fluffychat/pages/chat_list/chat_list_body.dart'; +import 'package:fluffychat/pages/homeserver_picker/homeserver_picker.dart'; +import 'package:fluffychat/pages/settings_account/settings_account_view.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../users.dart'; +import 'wait_for.dart'; + +extension DefaultFlowExtensions on WidgetTester { + Future login() async { + final tester = this; + + await tester.pumpAndSettle(); + + await tester.waitFor(find.text('Let\'s start')); + + expect(find.text('Let\'s start'), findsOneWidget); + + final input = find.byType(TextField); + + expect(input, findsOneWidget); + + // getting the placeholder in place + await tester.tap(find.byIcon(Icons.search)); + await tester.pumpAndSettle(); + await tester.enterText(input, homeserver); + await tester.pumpAndSettle(); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pumpAndSettle(); + + // in case registration is allowed + // try { + await Future.delayed(const Duration(milliseconds: 50)); + + await tester.scrollUntilVisible( + find.text('Login'), + 500, + scrollable: find.descendant( + of: find.byKey(const Key('ConnectPageListView')), + matching: find.byType(Scrollable).first, + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Login')); + await tester.pumpAndSettle(); + /*} catch (e) { + log('Registration is not allowed. Proceeding with login...'); + }*/ + await tester.pumpAndSettle(); + + await Future.delayed(const Duration(milliseconds: 50)); + + final inputs = find.byType(TextField); + + await tester.enterText(inputs.first, Users.user1.name); + await tester.enterText(inputs.last, Users.user1.password); + await tester.pumpAndSettle(); + await tester.testTextInput.receiveAction(TextInputAction.done); + + try { + // pumpAndSettle does not work in here as setState is called + // asynchronously + await tester.waitFor( + find.byType(LinearProgressIndicator), + timeout: const Duration(milliseconds: 1500), + skipPumpAndSettle: true, + ); + } catch (_) { + // in case the input action does not work on the desired platform + if (find.text('Login').evaluate().isNotEmpty) { + await tester.tap(find.text('Login')); + } + } + + try { + await tester.pumpAndSettle(); + } catch (_) { + // may fail because of ongoing animation below dialog + } + + await tester.waitFor( + find.byType(ChatListViewBody), + skipPumpAndSettle: true, + ); + } + + /// ensure PushProvider check passes + Future acceptPushWarning() async { + final tester = this; + + final matcher = find.maybeUppercaseText('Do not show again'); + + try { + await tester.waitFor(matcher, timeout: const Duration(seconds: 5)); + + // the FCM push error dialog to be handled... + await tester.tap(matcher); + await tester.pumpAndSettle(); + } catch (_) {} + } + + Future ensureLoggedOut() async { + final tester = this; + await tester.pumpAndSettle(); + if (find.byType(ChatListViewBody).evaluate().isNotEmpty) { + await tester.tap(find.byTooltip('Show menu')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Settings')); + await tester.pumpAndSettle(); + await tester.scrollUntilVisible( + find.text('Account'), + 500, + scrollable: find.descendant( + of: find.byKey(const Key('SettingsListViewContent')), + matching: find.byType(Scrollable), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Account')); + await tester.pumpAndSettle(); + await tester.scrollUntilVisible( + find.text('Logout'), + 500, + scrollable: find.descendant( + of: find.byType(SettingsAccountView), + matching: find.byType(Scrollable), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Logout')); + await tester.pumpAndSettle(); + await tester.tap(find.maybeUppercaseText('Yes')); + await tester.pumpAndSettle(); + } + } + + Future ensureAppStartedHomescreen({ + Duration timeout = const Duration(seconds: 20), + }) async { + final tester = this; + await tester.pumpAndSettle(); + + final homeserverPickerFinder = find.byType(HomeserverPicker); + final chatListFinder = find.byType(ChatListViewBody); + + final end = DateTime.now().add(timeout); + + log( + 'Waiting for HomeserverPicker or ChatListViewBody...', + name: 'Test Runner', + ); + do { + if (DateTime.now().isAfter(end)) { + throw Exception( + 'Timed out waiting for HomeserverPicker or ChatListViewBody'); + } + + await pumpAndSettle(); + await Future.delayed(const Duration(milliseconds: 100)); + } while (homeserverPickerFinder.evaluate().isEmpty && + chatListFinder.evaluate().isEmpty); + + if (homeserverPickerFinder.evaluate().isNotEmpty) { + log( + 'Found HomeserverPicker, performing login.', + name: 'Test Runner', + ); + await tester.login(); + } else { + log( + 'Found ChatListViewBody, skipping login.', + name: 'Test Runner', + ); + } + + await tester.acceptPushWarning(); + } +} diff --git a/integration_test/extensions/wait_for.dart b/integration_test/extensions/wait_for.dart new file mode 100644 index 00000000..cfd9d649 --- /dev/null +++ b/integration_test/extensions/wait_for.dart @@ -0,0 +1,49 @@ +import 'package:flutter_test/flutter_test.dart'; + +/// Workaround for https://github.com/flutter/flutter/issues/88765 +extension WaitForExtension on WidgetTester { + Future waitFor( + Finder finder, { + Duration timeout = const Duration(seconds: 20), + bool skipPumpAndSettle = false, + }) async { + final end = DateTime.now().add(timeout); + + do { + if (DateTime.now().isAfter(end)) { + throw Exception('Timed out waiting for $finder'); + } + + if (!skipPumpAndSettle) { + await pumpAndSettle(); + } + await Future.delayed(const Duration(milliseconds: 100)); + } while (finder.evaluate().isEmpty); + } +} + +extension MaybeUppercaseFinder on CommonFinders { + /// On Android some button labels are in uppercase while on iOS they + /// are not. This method tries both. + Finder maybeUppercaseText( + String text, { + bool findRichText = false, + bool skipOffstage = true, + }) { + try { + final finder = find.text( + text.toUpperCase(), + findRichText: findRichText, + skipOffstage: skipOffstage, + ); + expect(finder, findsOneWidget); + return finder; + } catch (_) { + return find.text( + text, + findRichText: findRichText, + skipOffstage: skipOffstage, + ); + } + } +} diff --git a/integration_test/users.dart b/integration_test/users.dart index c79e3d0f..8af999e9 100644 --- a/integration_test/users.dart +++ b/integration_test/users.dart @@ -1,15 +1,25 @@ -import 'dart:io'; - abstract class Users { const Users._(); - static final user1 = User( - Platform.environment['USER1_NAME'] ?? 'alice', - Platform.environment['USER1_PW'] ?? 'AliceInWonderland', + static const user1 = User( + String.fromEnvironment( + 'USER1_NAME', + defaultValue: 'alice', + ), + String.fromEnvironment( + 'USER1_PW', + defaultValue: 'AliceInWonderland', + ), ); - static final user2 = User( - Platform.environment['USER2_NAME'] ?? 'bob', - Platform.environment['USER2_PW'] ?? 'JoWirSchaffenDas', + static const user2 = User( + String.fromEnvironment( + 'USER2_NAME', + defaultValue: 'bob', + ), + String.fromEnvironment( + 'USER2_PW', + defaultValue: 'JoWirSchaffenDas', + ), ); } @@ -20,5 +30,7 @@ class User { const User(this.name, this.password); } -final homeserver = - 'http://${Platform.environment['HOMESERVER'] ?? 'localhost'}'; +const homeserver = 'http://${const String.fromEnvironment( + 'HOMESERVER', + defaultValue: 'localhost', +)}'; diff --git a/lib/pages/chat_list/client_chooser_button.dart b/lib/pages/chat_list/client_chooser_button.dart index 09303118..fac07a11 100644 --- a/lib/pages/chat_list/client_chooser_button.dart +++ b/lib/pages/chat_list/client_chooser_button.dart @@ -104,8 +104,13 @@ class ClientChooserButton extends StatelessWidget { .map( (client) => PopupMenuItem( value: client, - child: FutureBuilder( - future: client!.fetchOwnProfile(), + child: FutureBuilder( + // analyzer does not understand this type cast for error + // handling + // + // ignore: unnecessary_cast + future: (client!.fetchOwnProfile() as Future) + .onError((e, s) => null), builder: (context, snapshot) => Row( children: [ Avatar( diff --git a/lib/pages/connect/connect_page_view.dart b/lib/pages/connect/connect_page_view.dart index 8bc928ec..6e549def 100644 --- a/lib/pages/connect/connect_page_view.dart +++ b/lib/pages/connect/connect_page_view.dart @@ -28,6 +28,7 @@ class ConnectPageView extends StatelessWidget { ), ), body: ListView( + key: const Key('ConnectPageListView'), children: [ if (Matrix.of(context).loginRegistrationSupported ?? false) ...[ Padding( diff --git a/lib/pages/login/login.dart b/lib/pages/login/login.dart index 126b3a66..8dad28c4 100644 --- a/lib/pages/login/login.dart +++ b/lib/pages/login/login.dart @@ -8,6 +8,7 @@ import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:future_loading_dialog/future_loading_dialog.dart'; import 'package:matrix/matrix.dart'; +import 'package:fluffychat/utils/localized_exception_extension.dart'; import 'package:fluffychat/widgets/matrix.dart'; import '../../utils/platform_infos.dart'; import 'login_view.dart'; @@ -30,7 +31,7 @@ class LoginController extends State { void toggleShowPassword() => setState(() => showPassword = !loading && !showPassword); - void login([_]) async { + void login() async { final matrix = Matrix.of(context); if (usernameController.text.isEmpty) { setState(() => usernameError = L10n.of(context)!.pleaseEnterYourUsername); @@ -48,6 +49,9 @@ class LoginController extends State { } setState(() => loading = true); + + _coolDown?.cancel(); + try { final username = usernameController.text; AuthenticationIdentifier identifier; @@ -97,8 +101,8 @@ class LoginController extends State { void _checkWellKnown(String userId) async { if (mounted) setState(() => usernameError = null); if (!userId.isValidMatrixId) return; + final oldHomeserver = Matrix.of(context).getLoginClient().homeserver; try { - final oldHomeserver = Matrix.of(context).getLoginClient().homeserver; var newDomain = Uri.https(userId.domain!, ''); Matrix.of(context).getLoginClient().homeserver = newDomain; DiscoveryInformation? wellKnownInformation; @@ -112,14 +116,11 @@ class LoginController extends State { // do nothing, newDomain is already set to a reasonable fallback } if (newDomain != oldHomeserver) { - await showFutureLoadingDialog( - context: context, - // do nothing if we error, we'll handle it below - future: () => Matrix.of(context) - .getLoginClient() - .checkHomeserver(newDomain) - .catchError((e) {}), - ); + Matrix.of(context) + .getLoginClient() + .checkHomeserver(newDomain) + .catchError((e) {}); + if (Matrix.of(context).getLoginClient().homeserver == null) { Matrix.of(context).getLoginClient().homeserver = oldHomeserver; // okay, the server we checked does not appear to be a matrix server @@ -140,15 +141,18 @@ class LoginController extends State { return; } } - if (mounted) setState(() => usernameError = null); + usernameError = null; + if (mounted) setState(() {}); } else { + Matrix.of(context).getLoginClient().homeserver = oldHomeserver; if (mounted) { - setState(() => - Matrix.of(context).getLoginClient().homeserver = oldHomeserver); + setState(() {}); } } } catch (e) { - if (mounted) setState(() => usernameError = e.toString()); + Matrix.of(context).getLoginClient().homeserver = oldHomeserver; + usernameError = e.toLocalizedString(context); + if (mounted) setState(() {}); } } diff --git a/lib/pages/login/login_view.dart b/lib/pages/login/login_view.dart index 158b4912..1644b84d 100644 --- a/lib/pages/login/login_view.dart +++ b/lib/pages/login/login_view.dart @@ -60,7 +60,7 @@ class LoginView extends StatelessWidget { controller: controller.passwordController, textInputAction: TextInputAction.go, obscureText: !controller.showPassword, - onSubmitted: controller.login, + onSubmitted: (_) => controller.login(), decoration: InputDecoration( prefixIcon: const Icon(Icons.lock_outlined), errorText: controller.passwordError, @@ -87,9 +87,7 @@ class LoginView extends StatelessWidget { backgroundColor: Theme.of(context).colorScheme.primary, foregroundColor: Theme.of(context).colorScheme.onPrimary, ), - onPressed: controller.loading - ? null - : () => controller.login(context), + onPressed: controller.loading ? null : controller.login, icon: const Icon(Icons.login_outlined), label: controller.loading ? const LinearProgressIndicator() diff --git a/lib/pages/settings/settings_view.dart b/lib/pages/settings/settings_view.dart index 6ca7d681..911fd6c4 100644 --- a/lib/pages/settings/settings_view.dart +++ b/lib/pages/settings/settings_view.dart @@ -38,6 +38,7 @@ class SettingsView extends StatelessWidget { body: ListTileTheme( iconColor: Theme.of(context).colorScheme.onBackground, child: ListView( + key: const Key('SettingsListViewContent'), children: [ AnimatedContainer( height: controller.showChatBackupBanner ? 54 : 0, diff --git a/lib/widgets/content_banner.dart b/lib/widgets/content_banner.dart index 5cbf086b..a3547c98 100644 --- a/lib/widgets/content_banner.dart +++ b/lib/widgets/content_banner.dart @@ -11,6 +11,7 @@ class ContentBanner extends StatelessWidget { final void Function()? onEdit; final Client? client; final double opacity; + final WidgetBuilder? placeholder; const ContentBanner( {this.mxContent, @@ -19,6 +20,7 @@ class ContentBanner extends StatelessWidget { this.onEdit, this.client, this.opacity = 0.75, + this.placeholder, Key? key}) : super(key: key); @@ -54,6 +56,7 @@ class ContentBanner extends StatelessWidget { uri: mxContent, animated: true, fit: BoxFit.cover, + placeholder: placeholder, height: 400, width: 800, ), diff --git a/lib/widgets/matrix.dart b/lib/widgets/matrix.dart index 46ec31e7..70632a8e 100644 --- a/lib/widgets/matrix.dart +++ b/lib/widgets/matrix.dart @@ -327,15 +327,15 @@ class MatrixState extends State with WidgetsBindingObserver { ); if (state != LoginState.loggedIn) { - widget.router!.currentState!.to( + widget.router?.currentState?.to( '/rooms', - queryParameters: widget.router!.currentState!.queryParameters, + queryParameters: widget.router?.currentState?.queryParameters ?? {}, ); } } else { - widget.router!.currentState!.to( + widget.router?.currentState?.to( state == LoginState.loggedIn ? '/rooms' : '/home', - queryParameters: widget.router!.currentState!.queryParameters, + queryParameters: widget.router?.currentState?.queryParameters ?? {}, ); } }); diff --git a/lib/widgets/profile_bottom_sheet.dart b/lib/widgets/profile_bottom_sheet.dart index 6ab03a49..53e57dd9 100644 --- a/lib/widgets/profile_bottom_sheet.dart +++ b/lib/widgets/profile_bottom_sheet.dart @@ -15,6 +15,7 @@ import '../utils/localized_exception_extension.dart'; class ProfileBottomSheet extends StatelessWidget { final String userId; final BuildContext outerContext; + const ProfileBottomSheet({ required this.userId, required this.outerContext, @@ -78,6 +79,13 @@ class ProfileBottomSheet extends StatelessWidget { mxContent: profile.avatarUrl, defaultIcon: Icons.account_circle_outlined, client: Matrix.of(context).client, + placeholder: (context) => Center( + child: Text( + userId.localpart ?? userId, + style: + Theme.of(context).textTheme.headline3, + ), + ), ), ), ListTile( diff --git a/lib/widgets/theme_builder.dart b/lib/widgets/theme_builder.dart index 5b37447d..0a400d63 100644 --- a/lib/widgets/theme_builder.dart +++ b/lib/widgets/theme_builder.dart @@ -6,6 +6,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'package:system_theme/system_theme.dart'; import 'package:fluffychat/config/app_config.dart'; +import 'package:fluffychat/utils/platform_infos.dart'; class ThemeBuilder extends StatefulWidget { final Widget Function( @@ -34,6 +35,7 @@ class ThemeController extends State { Color? _primaryColor; ThemeMode get themeMode => _themeMode ?? ThemeMode.system; + Color? get primaryColor => _primaryColor; static ThemeController of(BuildContext context) => @@ -87,10 +89,18 @@ class ThemeController extends State { super.initState(); } - Color? get systemAccentColor { - final color = SystemTheme.accentColor.accent; - if (color == kDefaultSystemAccentColor) return AppConfig.chatColor; - return color; + Color get systemAccentColor { + if (PlatformInfos.isLinux) return AppConfig.chatColor; + try { + // a bad plugin implementation + // https://github.com/bdlukaa/system_theme/issues/10 + final accentColor = SystemTheme.accentColor; + final color = accentColor.accent; + if (color == kDefaultSystemAccentColor) return AppConfig.chatColor; + return color; + } catch (_) { + return AppConfig.chatColor; + } } @override diff --git a/scripts/integration-prepare-alpine.sh b/scripts/integration-prepare-alpine.sh deleted file mode 100755 index f4a57b6d..00000000 --- a/scripts/integration-prepare-alpine.sh +++ /dev/null @@ -1,2 +0,0 @@ -#!/usr/bin/env bash -apk update && apk add docker drill grep \ No newline at end of file diff --git a/scripts/integration-prepare-homeserver.sh b/scripts/integration-prepare-homeserver.sh index 4170294b..6bbee117 100755 --- a/scripts/integration-prepare-homeserver.sh +++ b/scripts/integration-prepare-homeserver.sh @@ -33,3 +33,28 @@ echo "Homeserver is up." curl -fS --retry 3 -XPOST -d "{\"username\":\"$USER1_NAME\", \"password\":\"$USER1_PW\", \"inhibit_login\":true, \"auth\": {\"type\":\"m.login.dummy\"}}" "http://$HOMESERVER/_matrix/client/r0/register" curl -fS --retry 3 -XPOST -d "{\"username\":\"$USER2_NAME\", \"password\":\"$USER2_PW\", \"inhibit_login\":true, \"auth\": {\"type\":\"m.login.dummy\"}}" "http://$HOMESERVER/_matrix/client/r0/register" + +usertoken1=$(curl -fS --retry 3 "http://$HOMESERVER/_matrix/client/r0/login" -H "Content-Type: application/json" -d "{\"type\": \"m.login.password\", \"identifier\": {\"type\": \"m.id.user\",\"user\": \"$USER1_NAME\"},\"password\":\"$USER1_PW\"}" | jq -r '.access_token') +usertoken2=$(curl -fS --retry 3 "http://$HOMESERVER/_matrix/client/r0/login" -H "Content-Type: application/json" -d "{\"type\": \"m.login.password\", \"identifier\": {\"type\": \"m.id.user\",\"user\": \"$USER2_NAME\"},\"password\":\"$USER2_PW\"}" | jq -r '.access_token') + + +# get usernames' mxids +mxid1=$(curl -fS --retry 3 "http://$HOMESERVER/_matrix/client/r0/account/whoami" -H "Authorization: Bearer $usertoken1" | jq -r .user_id) +mxid2=$(curl -fS --retry 3 "http://$HOMESERVER/_matrix/client/r0/account/whoami" -H "Authorization: Bearer $usertoken2" | jq -r .user_id) + +# setting the display name to username +curl -fS --retry 3 -XPUT -d "{\"displayname\":\"$USER1_NAME\"}" "http://$HOMESERVER/_matrix/client/v3/profile/$mxid1/displayname" -H "Authorization: Bearer $usertoken1" +curl -fS --retry 3 -XPUT -d "{\"displayname\":\"$USER2_NAME\"}" "http://$HOMESERVER/_matrix/client/v3/profile/$mxid2/displayname" -H "Authorization: Bearer $usertoken2" + +echo "Set display names" + +# create new room to invite user too +roomID=$(curl --retry 3 --silent --fail -XPOST -d "{\"name\":\"$USER2_NAME\", \"is_direct\": true}" "http://$HOMESERVER/_matrix/client/r0/createRoom?access_token=$usertoken2" | jq -r '.room_id') +echo "Created room '$roomID'" + +# send message in created room +curl --retry 3 --fail --silent -XPOST -d '{"msgtype":"m.text", "body":"joined room successfully"}' "http://$HOMESERVER/_matrix/client/r0/rooms/$roomID/send/m.room.message?access_token=$usertoken2" +echo "Sent message" + +curl -fS --retry 3 -XPOST -d "{\"user_id\":\"$mxid1\"}" "http://$HOMESERVER/_matrix/client/r0/rooms/$roomID/invite?access_token=$usertoken2" +echo "Invited $USER1_NAME" diff --git a/scripts/integration-prepare-host.sh b/scripts/integration-prepare-host.sh new file mode 100755 index 00000000..f110aab7 --- /dev/null +++ b/scripts/integration-prepare-host.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +if ! command -v apk &>/dev/null; then + apt update && apt install -y -qq docker.io ldnsutils grep scrcpy ffmpeg +else + apk update && apk add docker drill grep scrcpy ffmpeg +fi diff --git a/scripts/integration-start-avd.sh b/scripts/integration-start-avd.sh index 6dd188f6..fb608e44 100755 --- a/scripts/integration-start-avd.sh +++ b/scripts/integration-start-avd.sh @@ -1,4 +1,4 @@ #!/usr/bin/env bash chmod 777 -R /dev/kvm adb start-server -emulator -avd test -no-audio -no-boot-anim -no-window -accel on -gpu swiftshader_indirect \ No newline at end of file +emulator -avd test -wipe-data -no-audio -no-boot-anim -no-window -accel on -gpu swiftshader_indirect