From 23f97df1f1b1d410360edde649823a88e9f8e963 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Ku=C3=9Fowski?= Date: Fri, 24 Oct 2025 15:43:41 +0200 Subject: [PATCH] build: Add maestro based integration tests --- .github/workflows/integrate.yaml | 144 +++++++++++- integration_test/app_test.dart | 173 -------------- integration_test/data/integration_users.env | 5 + .../extensions/default_flows.dart | 165 ------------- integration_test/extensions/wait_for.dart | 49 ---- integration_test/login.yaml | 31 +++ integration_test/users.dart | 36 --- lib/main.dart | 4 + lib/pages/bootstrap/bootstrap_dialog.dart | 52 +++-- .../chat_list/client_chooser_button.dart | 29 ++- lib/pages/settings/settings.dart | 1 + lib/pages/sign_in/sign_in_page.dart | 221 +++++++++--------- .../view_model/sign_in_view_model.dart | 13 +- ...ntegration-create-environment-variables.sh | 6 - scripts/integration-prepare-homeserver.sh | 57 +---- 15 files changed, 353 insertions(+), 633 deletions(-) delete mode 100644 integration_test/app_test.dart create mode 100644 integration_test/data/integration_users.env delete mode 100644 integration_test/extensions/default_flows.dart delete mode 100644 integration_test/extensions/wait_for.dart create mode 100644 integration_test/login.yaml delete mode 100644 integration_test/users.dart delete mode 100755 scripts/integration-create-environment-variables.sh diff --git a/.github/workflows/integrate.yaml b/.github/workflows/integrate.yaml index e3172f769..6d0d25fa4 100644 --- a/.github/workflows/integrate.yaml +++ b/.github/workflows/integrate.yaml @@ -44,9 +44,11 @@ jobs: - run: flutter test build_debug_apk: + needs: [ code_tests ] runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 + - uses: ./.github/actions/free_up_space - run: cat .github/workflows/versions.env >> $GITHUB_ENV - uses: actions/setup-java@v5 with: @@ -56,12 +58,27 @@ jobs: with: flutter-version: ${{ env.FLUTTER_VERSION }} cache: true - - uses: ./.github/actions/free_up_space - uses: moonrepo/setup-rust@v1 - - run: flutter pub get - - run: flutter build apk --debug --target-platform android-arm64 + with: + cache: true + - name: Cache Gradle + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: gradle-${{ runner.os }}- + - run: ./scripts/add-firebase-messaging.sh + - run: flutter build apk --debug --target-platform android-x64 + - name: Upload Debug APK + uses: actions/upload-artifact@v5 + with: + name: debug-apk-x64 + path: build/app/outputs/flutter-apk/app-debug.apk build_debug_web: + needs: [ code_tests ] runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 @@ -75,9 +92,15 @@ jobs: - run: flutter pub get - name: Prepare web run: ./scripts/prepare-web.sh - - run: flutter build web + - run: flutter build web --dart-define=WITH_SEMANTICS=true + - name: Upload Web Build + uses: actions/upload-artifact@v5 + with: + name: Web Build + path: build/web build_debug_linux: + needs: [ code_tests ] strategy: matrix: arch: [ x64, arm64 ] @@ -96,6 +119,7 @@ jobs: - run: ./flutter/bin/flutter build linux --target-platform linux-${{ matrix.arch }} build_debug_ios: + needs: [ code_tests ] runs-on: macos-15 steps: - uses: actions/checkout@v6 @@ -114,3 +138,115 @@ jobs: sed -i '' 's,//,,g' lib/utils/background_push.dart - run: flutter pub get - run: flutter build ios --no-codesign + + integration_test: + runs-on: ubuntu-latest + timeout-minutes: 60 + needs: [ build_debug_apk ] + strategy: + matrix: + api-level: [34] + env: + ANDROID_USER_HOME: /home/runner/.android + ANDROID_EMULATOR_HOME: /home/runner/.android + ANDROID_AVD_HOME: /home/runner/.android/avd + AVD_CONFIG_PATH: "~/.android/avd/test.avd/config.ini" + steps: + - uses: actions/checkout@v6 + - uses: actions/download-artifact@v7 + with: + name: debug-apk-x64 + path: . + - uses: ./.github/actions/free_up_space + # https://github.blog/changelog/2023-02-23-hardware-accelerated-android-virtualization-on-actions-windows-and-linux-larger-hosted-runners/ + - name: Enable KVM group perms + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + - name: AVD cache + uses: actions/cache@v4 + id: avd-cache + with: + path: ~/.android/* + key: avd-${{ matrix.api-level }}-integration_docker + - name: create AVD and generate snapshot for caching + if: steps.avd-cache.outputs.cache-hit != 'true' + uses: reactivecircus/android-emulator-runner@d94c3fbe4fe6a29e4a5ba47c12fb47677c73656b + with: + api-level: ${{ matrix.api-level }} + target: google_apis + arch: x86_64 + cores: 16 + ndk: 28.2.13676358 + force-avd-creation: false + disk-size: 4096M + ram-size: 4096M + sdcard-path-or-size: 4096M + emulator-options: -no-window -wipe-data -accel on -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + script: | + cat ${{ env.AVD_CONFIG_PATH }} + + sed -i.bak 's/hw.lcd.density = .*/hw.lcd.density=420/' ${{ env.AVD_CONFIG_PATH }} + sed -i.bak 's/hw.lcd.height = .*/hw.lcd.height=1920/' ${{ env.AVD_CONFIG_PATH }} + sed -i.bak 's/hw.lcd.width = .*/hw.lcd.width=1080/' ${{ env.AVD_CONFIG_PATH }} + + if ! grep -q "hw.lcd.density" ${{ env.AVD_CONFIG_PATH }} && echo "hw.lcd.density = 420" >> ${{ env.AVD_CONFIG_PATH }}; then :; fi + if ! grep -q "hw.lcd.height" ${{ env.AVD_CONFIG_PATH }} && echo "hw.lcd.height = 1920" >> ${{ env.AVD_CONFIG_PATH }}; then :; fi + if ! grep -q "hw.lcd.width" ${{ env.AVD_CONFIG_PATH }} && echo "hw.lcd.width = 1080" >> ${{ env.AVD_CONFIG_PATH }}; then :; fi + + echo "Emulator settings (${{ env.AVD_CONFIG_PATH }})" + cat ${{ env.AVD_CONFIG_PATH }} + echo "Generated AVD snapshot for caching." + - uses: subosito/flutter-action@v2 + with: + flutter-version: ${{ env.FLUTTER_VERSION }} + cache: true + - uses: remarkablemark/setup-maestro-cli@v1 + - name: Load integration test env + run: cat integration_test/data/integration_users.env >> $GITHUB_ENV + - name: Prepare Homeserver + run: | + docker run -d --name synapse --tmpfs /data \ + --volume="$(pwd)/integration_test/synapse/data/homeserver.yaml":/data/homeserver.yaml:rw \ + --volume="$(pwd)/integration_test/synapse/data/localhost.log.config":/data/localhost.log.config:rw \ + -p 80:80 matrixdotorg/synapse:latest + while ! curl -XGET "http://$HOMESERVER/_matrix/client/v3/login" >/dev/null 2>/dev/null; do + echo "Waiting for homeserver to be available... (GET http://$HOMESERVER/_matrix/client/v3/login)" + sleep 2 + done + + echo "Homeserver is online!" + + # create users + 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" + + - name: Integration tests + id: integration_tests + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: ${{ matrix.api-level }} + target: google_apis + arch: x86_64 + cores: 16 + ndk: 28.2.13676358 + force-avd-creation: false + disk-size: 4096M + ram-size: 4096M + sdcard-path-or-size: 4096M + emulator-options: -no-snapshot-save -no-window -wipe-data -accel on -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + script: | + flutter run --use-application-binary=$PWD/app-debug.apk > flutter_logs.txt 2>&1 & + FLUTTER_PID=$! + sleep 10 + maestro test integration_test/login.yaml --env HOMESERVER=10.0.2.2 --env USER1_NAME=${USER1_NAME} --env USER1_PW=${USER1_PW} + kill $FLUTTER_PID 2>/dev/null || true + cp flutter_logs.txt ~/.maestro/tests/ + - name: Upload Flutter and Maestro logs + if: failure() + uses: actions/upload-artifact@v5 + with: + name: maestro-logs + path: ~/.maestro/tests + if-no-files-found: ignore \ No newline at end of file diff --git a/integration_test/app_test.dart b/integration_test/app_test.dart deleted file mode 100644 index cb5db3cc3..000000000 --- a/integration_test/app_test.dart +++ /dev/null @@ -1,173 +0,0 @@ -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:fluffychat/pages/invitation_selection/invitation_selection_view.dart'; - -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.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', () { - setUpAll(() { - // this random dialog popping up is super hard to cover in tests - SharedPreferences.setMockInitialValues({ - 'chat.fluffy.show_no_google': false, - }); - }); - - testWidgets('Start app, login and logout', (WidgetTester tester) async { - app.main(); - await tester.ensureAppStartedHomescreen(); - await tester.ensureLoggedOut(); - }); - - testWidgets('Login again', (WidgetTester tester) async { - app.main(); - await tester.ensureAppStartedHomescreen(); - }); - - 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(); - - await tester.scrollUntilVisible( - find.text('Chats').first, - 500, - scrollable: find - .descendant( - of: find.byType(ChatListViewBody), - matching: find.byType(Scrollable), - ) - .first, - ); - await tester.pumpAndSettle(); - await tester.tap(find.text('Chats')); - await tester.pumpAndSettle(); - await tester.waitFor(find.byType(SearchTitle)); - await tester.pumpAndSettle(); - - await tester.scrollUntilVisible( - find.text(Users.user2.name).first, - 500, - scrollable: find - .descendant( - of: find.byType(ChatListViewBody), - matching: find.byType(Scrollable), - ) - .first, - ); - await tester.pumpAndSettle(); - await tester.tap(find.text(Users.user2.name).first); - - 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(); - } - } - - 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(); - }); - - testWidgets('Spaces', (tester) async { - app.main(); - await tester.ensureAppStartedHomescreen(); - - await tester.waitFor(find.byTooltip('Show menu')); - await tester.tap(find.byTooltip('Show menu')); - await tester.pumpAndSettle(); - - await tester.waitFor(find.byIcon(Icons.workspaces_outlined)); - await tester.tap(find.byIcon(Icons.workspaces_outlined)); - await tester.pumpAndSettle(); - - await tester.waitFor(find.byType(TextField)); - await tester.enterText(find.byType(TextField).last, 'Test Space'); - await tester.pumpAndSettle(); - - await tester.testTextInput.receiveAction(TextInputAction.done); - await tester.pumpAndSettle(); - - await tester.waitFor(find.text('Invite contact')); - - await tester.tap(find.text('Invite contact')); - await tester.pumpAndSettle(); - - await tester.waitFor( - find.descendant( - of: find.byType(InvitationSelectionView), - matching: find.byType(TextField), - ), - ); - await tester.enterText( - find.descendant( - of: find.byType(InvitationSelectionView), - matching: find.byType(TextField), - ), - Users.user2.name, - ); - - await Future.delayed(const Duration(milliseconds: 250)); - await tester.testTextInput.receiveAction(TextInputAction.done); - - await Future.delayed(const Duration(milliseconds: 1000)); - await tester.pumpAndSettle(); - - await tester.tap( - find - .descendant( - of: find.descendant( - of: find.byType(InvitationSelectionView), - matching: find.byType(ListTile), - ), - matching: find.text(Users.user2.name), - ) - .last, - ); - await tester.pumpAndSettle(); - - await tester.waitFor(find.maybeUppercaseText('Yes')); - await tester.tap(find.maybeUppercaseText('Yes')); - await tester.pumpAndSettle(); - - await tester.tap(find.byTooltip('Back')); - await tester.pumpAndSettle(); - - await tester.waitFor(find.text('Load 2 more participants')); - await tester.tap(find.text('Load 2 more participants')); - await tester.pumpAndSettle(); - - expect(find.text(Users.user2.name), findsOneWidget); - }); - }); -} diff --git a/integration_test/data/integration_users.env b/integration_test/data/integration_users.env new file mode 100644 index 000000000..455ce8471 --- /dev/null +++ b/integration_test/data/integration_users.env @@ -0,0 +1,5 @@ +HOMESERVER=localhost +USER1_NAME=alice +USER1_PW=AliceInWonderland +USER2_NAME=bob +USER2_PW=JoWirSchaffenDas \ No newline at end of file diff --git a/integration_test/extensions/default_flows.dart b/integration_test/extensions/default_flows.dart deleted file mode 100644 index 051d14b89..000000000 --- a/integration_test/extensions/default_flows.dart +++ /dev/null @@ -1,165 +0,0 @@ -import 'dart:developer'; - -import 'package:fluffychat/pages/chat_list/chat_list_body.dart'; -import 'package:fluffychat/pages/intro/intro_page.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('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(IntroPage); - 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 deleted file mode 100644 index cfd9d649c..000000000 --- a/integration_test/extensions/wait_for.dart +++ /dev/null @@ -1,49 +0,0 @@ -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/login.yaml b/integration_test/login.yaml new file mode 100644 index 000000000..d32c255a1 --- /dev/null +++ b/integration_test/login.yaml @@ -0,0 +1,31 @@ +appId: chat.fluffy.fluffychat +--- +- assertVisible: "Sign in" +- tapOn: "Sign in" +- tapOn: "Search or enter homeserver address" +- inputText: "http://${HOMESERVER}" +- pressKey: "back" +- tapOn: + id: "homeserver_tile_0" +- tapOn: + id: "connect_to_homeserver_button" +- assertVisible: "Log in to http://${HOMESERVER}" +- inputText: "${USER1_NAME}" +- tapOn: "Password" +- inputText: "${USER1_PW}" +- tapOn: "Login" # Click the login button +- tapOn: + id: "store_in_secure_storage" +- tapOn: "Next" +- tapOn: + text: "Close" + index: 1 +- assertVisible: "Push notifications not available" +- tapOn: "Do not show again" +- tapOn: + id: "accounts_and_settings" # Open the popup menu +- tapOn: "Settings" +- scrollUntilVisible: + element: "Logout" +- tapOn: "Logout" +- tapOn: "Logout" # Confirm logout dialog \ No newline at end of file diff --git a/integration_test/users.dart b/integration_test/users.dart deleted file mode 100644 index 617bcb098..000000000 --- a/integration_test/users.dart +++ /dev/null @@ -1,36 +0,0 @@ -abstract class Users { - const Users._(); - - static const user1 = User( - String.fromEnvironment( - 'USER1_NAME', - defaultValue: 'alice', - ), - String.fromEnvironment( - 'USER1_PW', - defaultValue: 'AliceInWonderland', - ), - ); - static const user2 = User( - String.fromEnvironment( - 'USER2_NAME', - defaultValue: 'bob', - ), - String.fromEnvironment( - 'USER2_PW', - defaultValue: 'JoWirSchaffenDas', - ), - ); -} - -class User { - final String name; - final String password; - - const User(this.name, this.password); -} - -const homeserver = 'http://${String.fromEnvironment( - 'HOMESERVER', - defaultValue: 'localhost', -)}'; diff --git a/lib/main.dart b/lib/main.dart index 62355e518..12bd01e1e 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -3,6 +3,7 @@ import 'dart:ui'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/semantics.dart'; import 'package:collection/collection.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; @@ -102,6 +103,9 @@ Future startGui(List clients, SharedPreferences store) async { await firstClient?.accountDataLoading; runApp(FluffyChatApp(clients: clients, pincode: pin, store: store)); + if (const String.fromEnvironment('WITH_SEMANTICS') == 'true') { + SemanticsBinding.instance.ensureSemantics(); + } } /// Watches the lifecycle changes to start the application when it diff --git a/lib/pages/bootstrap/bootstrap_dialog.dart b/lib/pages/bootstrap/bootstrap_dialog.dart index e565d42d3..88d30813f 100644 --- a/lib/pages/bootstrap/bootstrap_dialog.dart +++ b/lib/pages/bootstrap/bootstrap_dialog.dart @@ -204,31 +204,39 @@ class BootstrapDialogState extends State { ), const SizedBox(height: 16), if (_supportsSecureStorage) - CheckboxListTile.adaptive( - contentPadding: const EdgeInsets.symmetric(horizontal: 8.0), - value: _storeInSecureStorage, - activeColor: theme.colorScheme.primary, - onChanged: (b) { - setState(() { - _storeInSecureStorage = b; - }); - }, - title: Text(_getSecureStorageLocalizedName()), - subtitle: Text( - L10n.of(context).storeInSecureStorageDescription, + Semantics( + identifier: 'store_in_secure_storage', + child: CheckboxListTile.adaptive( + contentPadding: const EdgeInsets.symmetric( + horizontal: 8.0, + ), + value: _storeInSecureStorage, + activeColor: theme.colorScheme.primary, + onChanged: (b) { + setState(() { + _storeInSecureStorage = b; + }); + }, + title: Text(_getSecureStorageLocalizedName()), + subtitle: Text( + L10n.of(context).storeInSecureStorageDescription, + ), ), ), const SizedBox(height: 16), - CheckboxListTile.adaptive( - contentPadding: const EdgeInsets.symmetric(horizontal: 8.0), - value: _recoveryKeyCopied, - activeColor: theme.colorScheme.primary, - onChanged: (b) { - FluffyShare.share(key!, context); - setState(() => _recoveryKeyCopied = true); - }, - title: Text(L10n.of(context).copyToClipboard), - subtitle: Text(L10n.of(context).saveKeyManuallyDescription), + Semantics( + identifier: 'copy_to_clipboard', + child: CheckboxListTile.adaptive( + contentPadding: const EdgeInsets.symmetric(horizontal: 8.0), + value: _recoveryKeyCopied, + activeColor: theme.colorScheme.primary, + onChanged: (b) { + FluffyShare.share(key!, context); + setState(() => _recoveryKeyCopied = true); + }, + title: Text(L10n.of(context).copyToClipboard), + subtitle: Text(L10n.of(context).saveKeyManuallyDescription), + ), ), const SizedBox(height: 16), ElevatedButton.icon( diff --git a/lib/pages/chat_list/client_chooser_button.dart b/lib/pages/chat_list/client_chooser_button.dart index d6431ad7d..b4dca19dc 100644 --- a/lib/pages/chat_list/client_chooser_button.dart +++ b/lib/pages/chat_list/client_chooser_button.dart @@ -175,18 +175,23 @@ class ClientChooserButton extends StatelessWidget { clipBehavior: Clip.hardEdge, borderRadius: BorderRadius.circular(99), color: Colors.transparent, - child: PopupMenuButton( - popUpAnimationStyle: FluffyThemes.isColumnMode(context) - ? AnimationStyle.noAnimation - : null, // https://github.com/flutter/flutter/issues/167180 - onSelected: (o) => _clientSelected(o, context), - itemBuilder: _bundleMenuItems, - child: Center( - child: Avatar( - mxContent: snapshot.data?.avatarUrl, - name: - snapshot.data?.displayName ?? matrix.client.userID?.localpart, - size: 32, + child: Semantics( + identifier: 'accounts_and_settings', + child: PopupMenuButton( + tooltip: 'Accounts and settings', + popUpAnimationStyle: FluffyThemes.isColumnMode(context) + ? AnimationStyle.noAnimation + : null, // https://github.com/flutter/flutter/issues/167180 + onSelected: (o) => _clientSelected(o, context), + itemBuilder: _bundleMenuItems, + child: Center( + child: Avatar( + mxContent: snapshot.data?.avatarUrl, + name: + snapshot.data?.displayName ?? + matrix.client.userID?.localpart, + size: 32, + ), ), ), ), diff --git a/lib/pages/settings/settings.dart b/lib/pages/settings/settings.dart index 4f5be9a88..2611af782 100644 --- a/lib/pages/settings/settings.dart +++ b/lib/pages/settings/settings.dart @@ -79,6 +79,7 @@ class SettingsController extends State { context: context, future: () => matrix.client.logout(), ); + context.go('/'); } Future setAvatarAction() async { diff --git a/lib/pages/sign_in/sign_in_page.dart b/lib/pages/sign_in/sign_in_page.dart index ff7909716..c5b499225 100644 --- a/lib/pages/sign_in/sign_in_page.dart +++ b/lib/pages/sign_in/sign_in_page.dart @@ -96,106 +96,112 @@ class SignInPage extends StatelessWidget { itemBuilder: (context, i) { final server = publicHomeservers[i]; final website = server.website; - return RadioListTile.adaptive( - value: server, - enabled: - state.loginLoading.connectionState != - ConnectionState.waiting, - radioScaleFactor: - FluffyThemes.isColumnMode(context) || - { - TargetPlatform.iOS, - TargetPlatform.macOS, - }.contains(theme.platform) - ? 2 - : 1, - title: Row( - children: [ - Expanded( - child: Text(server.name ?? 'Unknown'), - ), - if (website != null) - SizedBox.square( - dimension: 32, - child: IconButton( - icon: const Icon( - Icons.open_in_new_outlined, - size: 16, + return Semantics( + identifier: 'homeserver_tile_$i', + child: RadioListTile.adaptive( + value: server, + enabled: + state.loginLoading.connectionState != + ConnectionState.waiting, + radioScaleFactor: + FluffyThemes.isColumnMode(context) || + { + TargetPlatform.iOS, + TargetPlatform.macOS, + }.contains(theme.platform) + ? 2 + : 1, + title: Row( + children: [ + Expanded( + child: Text(server.name ?? 'Unknown'), + ), + if (website != null) + SizedBox.square( + dimension: 32, + child: IconButton( + icon: const Icon( + Icons.open_in_new_outlined, + size: 16, + ), + onPressed: () => + launchUrlString(website), ), - onPressed: () => - launchUrlString(website), ), - ), - ], - ), - subtitle: Column( - spacing: 4.0, - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - if (server.features?.isNotEmpty == true) - Wrap( - spacing: 4.0, - runSpacing: 4.0, - children: [ - ...?server.languages?.map( - (language) => Material( - borderRadius: BorderRadius.circular( - AppConfig.borderRadius, - ), - color: theme - .colorScheme - .tertiaryContainer, - child: Padding( - padding: - const EdgeInsets.symmetric( - horizontal: 6.0, - vertical: 3.0, + ], + ), + subtitle: Column( + spacing: 4.0, + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (server.features?.isNotEmpty == true) + Wrap( + spacing: 4.0, + runSpacing: 4.0, + children: [ + ...?server.languages?.map( + (language) => Material( + borderRadius: + BorderRadius.circular( + AppConfig.borderRadius, + ), + color: theme + .colorScheme + .tertiaryContainer, + child: Padding( + padding: + const EdgeInsets.symmetric( + horizontal: 6.0, + vertical: 3.0, + ), + child: Text( + language, + style: TextStyle( + fontSize: 10, + color: theme + .colorScheme + .onTertiaryContainer, ), - child: Text( - language, - style: TextStyle( - fontSize: 10, - color: theme - .colorScheme - .onTertiaryContainer, ), ), ), ), - ), - ...server.features!.map( - (feature) => Material( - borderRadius: BorderRadius.circular( - AppConfig.borderRadius, - ), - color: theme - .colorScheme - .secondaryContainer, - child: Padding( - padding: - const EdgeInsets.symmetric( - horizontal: 6.0, - vertical: 3.0, + ...server.features!.map( + (feature) => Material( + borderRadius: + BorderRadius.circular( + AppConfig.borderRadius, + ), + color: theme + .colorScheme + .secondaryContainer, + child: Padding( + padding: + const EdgeInsets.symmetric( + horizontal: 6.0, + vertical: 3.0, + ), + child: Text( + feature, + style: TextStyle( + fontSize: 10, + color: theme + .colorScheme + .onSecondaryContainer, ), - child: Text( - feature, - style: TextStyle( - fontSize: 10, - color: theme - .colorScheme - .onSecondaryContainer, ), ), ), ), - ), - ], + ], + ), + Text( + server.description ?? + 'A matrix homeserver', ), - Text( - server.description ?? 'A matrix homeserver', - ), - ], + ], + ), ), ); }, @@ -219,26 +225,29 @@ class SignInPage extends StatelessWidget { child: Padding( padding: const EdgeInsets.all(16.0), child: SafeArea( - child: ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: theme.colorScheme.primary, - foregroundColor: theme.colorScheme.onPrimary, + child: Semantics( + identifier: 'connect_to_homeserver_button', + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: theme.colorScheme.primary, + foregroundColor: theme.colorScheme.onPrimary, + ), + onPressed: + state.loginLoading.connectionState == + ConnectionState.waiting + ? null + : () => connectToHomeserverFlow( + selectedHomserver, + context, + viewModel.setLoginLoading, + signUp, + ), + child: + state.loginLoading.connectionState == + ConnectionState.waiting + ? const CircularProgressIndicator.adaptive() + : Text(L10n.of(context).continueText), ), - onPressed: - state.loginLoading.connectionState == - ConnectionState.waiting - ? null - : () => connectToHomeserverFlow( - selectedHomserver, - context, - viewModel.setLoginLoading, - signUp, - ), - child: - state.loginLoading.connectionState == - ConnectionState.waiting - ? const CircularProgressIndicator.adaptive() - : Text(L10n.of(context).continueText), ), ), ), diff --git a/lib/pages/sign_in/view_model/sign_in_view_model.dart b/lib/pages/sign_in/view_model/sign_in_view_model.dart index 6d9bcc87a..cb4a41ae8 100644 --- a/lib/pages/sign_in/view_model/sign_in_view_model.dart +++ b/lib/pages/sign_in/view_model/sign_in_view_model.dart @@ -39,14 +39,13 @@ class SignInViewModel extends ValueNotifier { ) .toList() ?? []; - final splitted = filterText.split('.'); - if (splitted.length >= 2 && !splitted.any((part) => part.isEmpty)) { - if (!filteredPublicHomeservers.any( - (homeserver) => homeserver.name == filterText, - )) { - filteredPublicHomeservers.add(PublicHomeserverData(name: filterText)); - } + if (Uri.tryParse(filterText) != null && + !filteredPublicHomeservers.any( + (homeserver) => homeserver.name == filterText, + )) { + filteredPublicHomeservers.add(PublicHomeserverData(name: filterText)); } + value = value.copyWith( filteredPublicHomeservers: filteredPublicHomeservers, ); diff --git a/scripts/integration-create-environment-variables.sh b/scripts/integration-create-environment-variables.sh deleted file mode 100755 index d2b3a8fe1..000000000 --- a/scripts/integration-create-environment-variables.sh +++ /dev/null @@ -1,6 +0,0 @@ -#!/usr/bin/env bash - -export USER1_NAME="alice" -export USER1_PW="AliceInWonderland" -export USER2_NAME="bob" -export USER2_PW="JoWirSchaffenDas" diff --git a/scripts/integration-prepare-homeserver.sh b/scripts/integration-prepare-homeserver.sh index 6bbee117b..61c43e07f 100755 --- a/scripts/integration-prepare-homeserver.sh +++ b/scripts/integration-prepare-homeserver.sh @@ -1,60 +1,11 @@ #!/usr/bin/env bash -if [ -z $HOMESERVER ]; then - echo "Please ensure HOMESERVER environment variable is set to the IP or hostname of the homeserver." - exit 1 -fi -if [ -z $USER1_NAME ]; then - echo "Please ensure USER1_NAME environment variable is set to first user name." - exit 1 -fi -if [ -z $USER1_PW ]; then - echo "Please ensure USER1_PW environment variable is set to first user password." - exit 1 -fi -if [ -z $USER2_NAME ]; then - echo "Please ensure USER2_NAME environment variable is set to second user name." - exit 1 -fi -if [ -z $USER2_PW ]; then - echo "Please ensure USER2_PW environment variable is set to second user password." - exit 1 -fi -echo "Waiting for homeserver to be available... (GET http://$HOMESERVER/_matrix/client/v3/login)" - -while ! curl -XGET "http://$HOMESERVER/_matrix/client/v3/login" >/dev/null 2>/dev/null; do +while ! curl -XGET "http://localhost/_matrix/client/v3/login" >/dev/null 2>/dev/null; do + echo "Waiting for homeserver to be available... (GET http://localhost/_matrix/client/v3/login)" sleep 2 done -echo "Homeserver is up." - # create users - -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" +curl -fS --retry 3 -XPOST -d "{\"username\":\"$USER1_NAME\", \"password\":\"$USER1_PW\", \"inhibit_login\":true, \"auth\": {\"type\":\"m.login.dummy\"}}" "http://localhost/_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://localhost/_matrix/client/r0/register"