diff --git a/.github/workflows/accessibility-tests.yml b/.github/workflows/accessibility-tests.yml index fef1c8f1a..21cc42fca 100644 --- a/.github/workflows/accessibility-tests.yml +++ b/.github/workflows/accessibility-tests.yml @@ -16,7 +16,7 @@ jobs: # than deleted so the file keeps rebasing cleanly. Run locally with # `swift run tools ci accessibility-tests`. if: github.repository == 'element-hq/element-x-ios' - runs-on: macos-26 + runs-on: xcode-27 timeout-minutes: 150 concurrency: @@ -36,7 +36,7 @@ jobs: run: swift run -q tools ci accessibility-tests - name: Archive artifacts - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 if: failure() with: name: Results diff --git a/.github/workflows/compound-ios.yml b/.github/workflows/compound-ios.yml index 7e8d19bd9..b39d5df31 100644 --- a/.github/workflows/compound-ios.yml +++ b/.github/workflows/compound-ios.yml @@ -25,8 +25,8 @@ jobs: # permissions: {} leaves a metadata-only token that cannot read an INTERNAL repo. Gated rather # than deleted so the file keeps rebasing cleanly. if: github.repository == 'element-hq/element-x-ios' - runs-on: macos-26 - timeout-minutes: 15 + runs-on: xcode-27 + timeout-minutes: 25 concurrency: # When running on develop, use the sha to allow all runs of this workflow to run concurrently. @@ -39,8 +39,8 @@ jobs: with: persist-credentials: false - - name: Configure Xcode 26 - run: sudo xcode-select -s /Applications/Xcode_26.5.app + - name: Configure Xcode 27 + run: sudo xcode-select -s /Applications/Xcode_27.app - name: Install sourcery run: brew install sourcery @@ -50,11 +50,11 @@ jobs: run: sourcery --config Tools/Sourcery/PreviewTestsConfig.yml - name: Create simulator - run: xcrun simctl create "Snapshots" "iPhone SE (3rd generation)" "com.apple.CoreSimulator.SimRuntime.iOS-26-5" + run: xcrun simctl create "Snapshots" "iPhone SE (3rd generation)" "com.apple.CoreSimulator.SimRuntime.iOS-27-0" - name: Run tests working-directory: compound-ios - run: set -o pipefail && xcodebuild test -scheme 'Compound' -sdk iphonesimulator -destination 'platform=iOS Simulator,name=Snapshots,OS=26.5' -skipPackagePluginValidation -resultBundlePath Logs/CompoundTests.xcresult | xcbeautify --renderer github-actions + run: set -o pipefail && xcodebuild test -scheme 'Compound' -sdk iphonesimulator -destination 'platform=iOS Simulator,name=Snapshots,OS=27.0' -skipPackagePluginValidation -resultBundlePath Logs/CompoundTests.xcresult | xcbeautify --renderer github-actions - name: Zip artifacts if: failure() # We only care about artefacts if the tests fail @@ -62,7 +62,7 @@ jobs: run: zip -r Logs/CompoundTests.xcresult.zip Logs/CompoundTests.xcresult/ - name: Archive artifacts - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 if: failure() # We only care about artefacts if the tests fail with: name: test-results @@ -71,7 +71,7 @@ jobs: if-no-files-found: ignore - name: Upload coverage to Codecov - uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 + uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1 with: fail_ci_if_error: false token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index ac563cc90..2173e5b41 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -18,7 +18,7 @@ jobs: # HealthChatAppSettingsHook sets allowOtherAccountProviders: false, hiding the change-server UI # these tests drive. Gated rather than deleted so the file keeps rebasing cleanly. if: github.repository == 'element-hq/element-x-ios' - runs-on: macos-26 + runs-on: xcode-27 timeout-minutes: 45 concurrency: @@ -42,7 +42,7 @@ jobs: INTEGRATION_TESTS_PASSWORD: ${{ secrets.INTEGRATION_TESTS_PASSWORD }} - name: Archive artifacts - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 # We only care about artefacts if the tests fail if: failure() with: @@ -56,7 +56,7 @@ jobs: - name: Upload coverage to Codecov # Skip if not successful and in forks if: ${{ success() && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) }} - uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 + uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1 with: report_type: coverage files: test_output/integration-cobertura.xml @@ -68,7 +68,7 @@ jobs: - name: Upload test results to Codecov # Skip if cancelled and in forks if: ${{ !cancelled() && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) }} - uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 + uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1 continue-on-error: true with: report_type: test_results diff --git a/.github/workflows/ui-tests.yml b/.github/workflows/ui-tests.yml index 76fcac3a7..e60d0d49e 100644 --- a/.github/workflows/ui-tests.yml +++ b/.github/workflows/ui-tests.yml @@ -21,7 +21,7 @@ jobs: # UITests snapshot, so it would fail on content instead. Gated rather than deleted so the file # keeps rebasing cleanly. Run locally with `swift run tools ci ui-tests`. if: github.repository == 'element-hq/element-x-ios' - runs-on: macos-26 + runs-on: xcode-27 timeout-minutes: 90 strategy: fail-fast: false @@ -52,7 +52,7 @@ jobs: GITHUB_EVENT_INPUTS_TEST_NAME: ${{ github.event.inputs.test_name }} - name: Archive artifacts - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 # We only care about artefacts if the tests fail if: failure() with: @@ -64,7 +64,7 @@ jobs: - name: Upload coverage to Codecov # Skip if not successful and in forks if: ${{ success() && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) }} - uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 + uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1 with: report_type: coverage files: test_output/ui-cobertura.xml @@ -76,7 +76,7 @@ jobs: - name: Upload test results to Codecov # Skip if cancelled and in forks if: ${{ !cancelled() && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) }} - uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 + uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1 continue-on-error: true with: report_type: test_results diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 861533bf8..fdc47e10f 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -102,7 +102,7 @@ jobs: key: compilation-cache-unit-tests-${{ github.sha }} - name: Archive artifacts - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 # We only care about artefacts if the tests fail if: failure() with: @@ -118,7 +118,7 @@ jobs: # runs without a CODECOV_TOKEN — which fails the job via # fail_ci_if_error. Gate on the upstream repo as post-release.yml does. if: ${{ success() && github.repository == 'element-hq/element-x-ios' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) }} - uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 + uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1 with: report_type: coverage files: test_output/unit-cobertura.xml,test_output/preview-cobertura.xml @@ -132,7 +132,7 @@ jobs: # HealthChat: gated on the upstream repo for the same reason as the # coverage step above — no CODECOV_TOKEN exists in this fork. if: ${{ !cancelled() && github.repository == 'element-hq/element-x-ios' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) }} - uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 + uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1 continue-on-error: true with: report_type: test_results diff --git a/.gitignore b/.gitignore index 2017ba381..ff9ad0067 100644 --- a/.gitignore +++ b/.gitignore @@ -18,22 +18,9 @@ DerivedData/ .swiftpm .netrc -# fastlane -# -# It is recommended to not store the screenshots in the git repo. -# Instead, use fastlane to re-generate the screenshots whenever they are needed. -# For more information about the recommended setup visit: -# https://docs.fastlane.tools/best-practices/source-control/#source-control - -fastlane/report.xml -fastlane/Preview.html -fastlane/screenshots/**/*.png -fastlane/test_output -towncrier.toml.tmp -version_changes.md - build /vendor/ +*/xcshareddata/xcodecloud ## macOS Files .DS_Store @@ -60,3 +47,6 @@ fastlane/README.md # Review working data (per-machine) .claude/review-context/ .claude/review-log.jsonl + +# Local SDK development override +matrix-rust-components-swift diff --git a/CHANGES.md b/CHANGES.md index 9db454963..5e797a2ab 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,66 @@ +## Changes in 26.09.2 (2026-09-23) + +### What's Changed + +🙌 Improvements +* Add an `AccountProvider` type and use it within the authentication flow. by @pixlwave in https://github.com/element-hq/element-x-ios/pull/6103 +* Update the Start Chat button's icon. by @pixlwave in https://github.com/element-hq/element-x-ios/pull/6179 + +🐛 Bugfixes +* Update the read marker colour to match the Figma. by @pixlwave in https://github.com/element-hq/element-x-ios/pull/6146 +* Fix some isolation errors when compiling with Swift 6.4. by @pixlwave in https://github.com/element-hq/element-x-ios/pull/6148 +* Cancel message edit if content remains unchanged by @thepearl in https://github.com/element-hq/element-x-ios/pull/6153 +* Fix HDR UI bugs on iOS 27.0. by @pixlwave in https://github.com/element-hq/element-x-ios/pull/6177 +* Fix the split view layout on iOS 27. by @pixlwave in https://github.com/element-hq/element-x-ios/pull/6180 +* Only make toolbar buttons prominent when tinted. by @pixlwave in https://github.com/element-hq/element-x-ios/pull/6188 + +🗣 Translations +* Translations update by @RiotRobot in https://github.com/element-hq/element-x-ios/pull/6151 +* Translations update by @RiotRobot in https://github.com/element-hq/element-x-ios/pull/6164 + +🚧 In development 🚧 +* Introduce a local mechanism for managing the app's badge count by @stefanceriu in https://github.com/element-hq/element-x-ios/pull/6135 +* Various search tab tweaks by @stefanceriu in https://github.com/element-hq/element-x-ios/pull/6157 +* Add experimental native calls behind a developer flag by @BillCarsonFr in https://github.com/element-hq/element-x-ios/pull/6159 +* Bump element-call-ios to 0.1.0-rc.7 by @BillCarsonFr in https://github.com/element-hq/element-x-ios/pull/6168 +* Add specific prefix for native call logs by @BillCarsonFr in https://github.com/element-hq/element-x-ios/pull/6169 +* Bump element-call native to 0.1.0-rc.8 by @BillCarsonFr in https://github.com/element-hq/element-x-ios/pull/6181 + +Others +* Update Project Dependencies by @renovate[bot] in https://github.com/element-hq/element-x-ios/pull/6136 +* Fix some SwiftLint warnings. by @pixlwave in https://github.com/element-hq/element-x-ios/pull/6149 +* Update zizmorcore/zizmor-action action to v0.6.4 by @renovate[bot] in https://github.com/element-hq/element-x-ios/pull/6155 +* Bump the RustSDK to v26.09.17 by @stefanceriu in https://github.com/element-hq/element-x-ios/pull/6160 +* Update codecov/codecov-action action to v7.1.0 by @renovate[bot] in https://github.com/element-hq/element-x-ios/pull/6176 +* Update dependency siteline/SwiftUI-Introspect to v27 by @renovate[bot] in https://github.com/element-hq/element-x-ios/pull/6175 +* Drop Intel device support from the build scripts and project by @stefanceriu in https://github.com/element-hq/element-x-ios/pull/6171 +* Update the SDK. by @pixlwave in https://github.com/element-hq/element-x-ios/pull/6182 + + +**Full Changelog**: https://github.com/element-hq/element-x-ios/compare/release/26.09.1...release/26.09.2 + +## Changes in 26.09.1 (2026-09-10) + +### What's Changed + +✨ Features +* Add a developer option and selection mode for timeline messages by @Velin92 in https://github.com/element-hq/element-x-ios/pull/6102 + +🐛 Bugfixes +* Limit the custom status input to 30 characters. by @pixlwave in https://github.com/element-hq/element-x-ios/pull/6139 +* Scope dark mode change only to the presenting sheet by @Velin92 in https://github.com/element-hq/element-x-ios/pull/6144 + +🚧 In development 🚧 +* Show message counts on room list notification badges by @stefanceriu in https://github.com/element-hq/element-x-ios/pull/6132 + +Others +* Fix start chat UI test snapshots following copy changes by @stefanceriu in https://github.com/element-hq/element-x-ios/pull/6137 +* Add a couple of points about code style/ordering to the AGENTS.md by @pixlwave in https://github.com/element-hq/element-x-ios/pull/6133 +* Update the SDK. by @pixlwave in https://github.com/element-hq/element-x-ios/pull/6147 + + +**Full Changelog**: https://github.com/element-hq/element-x-ios/compare/release/26.09.0...release/26.09.1 + ## Changes in 26.09.0 (2026-09-08) ### What's Changed diff --git a/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index c8419c5a4..06d2d0c62 100644 --- a/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,13 +1,13 @@ { - "originHash" : "f1791683f9f09408a7746ce0e163941b3ce7e83e84f1a73b2fecc2b5d2f2f4b6", + "originHash" : "fbdb76ae8c78e5a86db4812456fcb4da5795eed4bc8f6715726fec41e87a1e0f", "pins" : [ { "identity" : "compound-design-tokens", "kind" : "remoteSourceControl", "location" : "https://github.com/element-hq/compound-design-tokens", "state" : { - "revision" : "86c15ef71798581d1e0cc39a7f99e598ad572419", - "version" : "11.0.0" + "revision" : "28b921601ac0ff49a572b6f18526d97bd14e5d91", + "version" : "11.3.1" } }, { @@ -15,8 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/devicekit/DeviceKit", "state" : { - "revision" : "56b997e8a61707218f9af09f32b2a1d1806fd792", - "version" : "5.8.0" + "revision" : "19528aa07e1426626ea9fb2c4a161edf43c4f9e8", + "version" : "5.9.0" } }, { @@ -55,13 +55,22 @@ "version" : "1.2.0" } }, + { + "identity" : "element-call-ios", + "kind" : "remoteSourceControl", + "location" : "https://github.com/element-hq/element-call-ios", + "state" : { + "revision" : "d77b4ee326582083cabe53ffbd872a751c3cb989", + "version" : "0.1.0-rc.11" + } + }, { "identity" : "element-call-swift", "kind" : "remoteSourceControl", "location" : "https://github.com/element-hq/element-call-swift", "state" : { - "revision" : "13eac3567461a9825018e10e9f4475f68215dae7", - "version" : "0.24.0" + "revision" : "befb5d37a7859ad38cf83719cbfd4abaffa04d82", + "version" : "0.26.1" } }, { @@ -95,8 +104,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/onevcat/Kingfisher", "state" : { - "revision" : "410984bf301f4fa224fe56277b3f8672cc465c79", - "version" : "8.11.0" + "revision" : "8648fe649c5d62be38721110f835821906e9242a", + "version" : "8.13.0" } }, { @@ -122,8 +131,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/nicklockwood/LRUCache", "state" : { - "revision" : "cb5b2bd0da83ad29c0bec762d39f41c8ad0eaf3e", - "version" : "1.2.1" + "revision" : "f0570e943ea4445d258113ba73f50cb2b7e564ac", + "version" : "1.3.0" } }, { @@ -140,8 +149,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/maplibre/maplibre-gl-native-distribution", "state" : { - "revision" : "5ee345ca5d65238a6fce29bba87816204be7df20", - "version" : "6.28.0" + "revision" : "13e41ab3d77ff5113e7e5d4ee87803b1f81f5683", + "version" : "6.31.0" } }, { @@ -149,8 +158,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/matrix-org/matrix-analytics-events", "state" : { - "revision" : "4b143b14a16a231bd4fc3c6705845f852177abe3", - "version" : "0.37.0" + "revision" : "7d668b3e4be493843805b8a1ff4e13b261a19965", + "version" : "0.38.0" } }, { @@ -158,8 +167,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/element-hq/matrix-rich-text-editor-swift", "state" : { - "revision" : "c0877fb9323d1ec2de56e4fe2da97f73cac36c38", - "version" : "2.42.0" + "revision" : "56077711bf495db926ec3f3efd36ed127fb9ceb1", + "version" : "2.42.1" } }, { @@ -167,8 +176,17 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/element-hq/matrix-rust-components-swift", "state" : { - "revision" : "56ec8d0458a688018bbd9f1f2fd70fae7919f415", - "version" : "26.9.9" + "revision" : "dbdf59e13d0eade7fd9cbd01cfd564693b85b761", + "version" : "26.10.15" + } + }, + { + "identity" : "matrix-rust-rtc", + "kind" : "remoteSourceControl", + "location" : "https://github.com/element-hq/matrix-rust-rtc", + "state" : { + "revision" : "c7a81b6742dc5e571b90b05f5109677384efb933", + "version" : "0.5.0-rc.5" } }, { @@ -194,8 +212,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/PostHog/posthog-ios", "state" : { - "revision" : "c0218386c49115cce6b05a50b41f4f60bc995acb", - "version" : "3.69.6" + "revision" : "d97d9a0babda42ecee026bd0a5a9ea1756349dc1", + "version" : "3.85.3" } }, { @@ -203,8 +221,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/getsentry/sentry-cocoa", "state" : { - "revision" : "cd213f98af160e6497eeff0707bc3f6a205b159c", - "version" : "9.26.0" + "revision" : "8689e780a295dfdc6501ac0dbb502461e16d6551", + "version" : "9.29.2" } }, { @@ -230,8 +248,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-async-algorithms", "state" : { - "revision" : "3da39bbc4e687d4192af7c9cf4eab805745a0b9c", - "version" : "1.1.5" + "revision" : "789dcf1f3d3f00251482f40a432ab7144c181987", + "version" : "1.1.6" } }, { @@ -239,8 +257,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-collections", "state" : { - "revision" : "a0cb0954ecb21e4e31b0070e6ed5674e8556685a", - "version" : "1.6.0" + "revision" : "98ef3c98609a1e31b7e157b5b619579001a789d6", + "version" : "1.7.1" } }, { @@ -275,8 +293,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-snapshot-testing", "state" : { - "revision" : "59a99c458de4d2dee580529b61b4f78dca7b7fa6", - "version" : "1.19.4" + "revision" : "28e5de025e3fd98991791bfbf83023ab3708da03", + "version" : "1.19.6" } }, { @@ -284,8 +302,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/swiftlang/swift-syntax", "state" : { - "revision" : "79e4b74a295b6eb74a8b585e3a39d29e70c1dbd1", - "version" : "603.0.2" + "revision" : "050f1a346fbbac0ca2cfb15a95274f7bd1cf0ccf", + "version" : "604.0.0" } }, { @@ -293,8 +311,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/scinfu/SwiftSoup.git", "state" : { - "revision" : "8d6ad267714cac3ae747cefdd21f7a6665006e1f", - "version" : "2.13.7" + "revision" : "18b80329749eca5ea29fc50211dca5c7eff5bfec", + "version" : "2.13.9" } }, { @@ -320,8 +338,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/siteline/SwiftUI-Introspect", "state" : { - "revision" : "dd0888ef99d2809863b04e46cad52d1dfd8fc840", - "version" : "26.0.2" + "revision" : "a37eca1db7bebc9437ee89508e7b4433ff34adb4", + "version" : "27.0.0" } }, { diff --git a/ElementX/Sources/Application/Settings/AppSettings.swift b/ElementX/Sources/Application/Settings/AppSettings.swift index 0a32e9622..271aff8d3 100644 --- a/ElementX/Sources/Application/Settings/AppSettings.swift +++ b/ElementX/Sources/Application/Settings/AppSettings.swift @@ -15,27 +15,6 @@ import Foundation import Macros import SwiftUI -/// Common settings between app and NSE -nonisolated protocol CommonSettingsProtocol: AnyObject, Sendable { - var lastNotificationBootTime: TimeInterval? { get set } - var selectedNotificationTone: NotificationTone? { get set } - - var logLevel: LogLevel { get } - var traceLogPacks: Set { get } - var bugReportRageshakeURL: RemotePreference { get } - var contentScannerURL: RemotePreference { get } - var forceDisableE2EE: RemotePreference { get } - var mapTilerConfiguration: RemotePreference { get } - - var enableOnlySignedDeviceIsolationMode: Bool { get } - var threadsEnabled: Bool { get } - var hideQuietNotificationAlerts: Bool { get } - - var voipPusherAppID: String { get } - var pushKitDeviceToken: String? { get } - var voipPushGatewayNotifyURL: String? { get } -} - nonisolated enum AppBuildType { case debug case nightly @@ -72,15 +51,10 @@ final nonisolated class AppSettings: @unchecked Sendable { store.reset() } - func resetSessionSpecificSettings() { - MXLog.warning("Resetting the user session specific AppSettings.") - resetHasRunIdentityConfirmationOnboarding() - } - // MARK: - Hooks // swiftlint:disable:next function_parameter_count - func override(accountProviders: [String], + func override(accountProviders: [AccountProvider], allowOtherAccountProviders: Bool, hideBrandChrome: Bool, pushGatewayBaseURL: URL, @@ -141,11 +115,15 @@ final nonisolated class AppSettings: @unchecked Sendable { @UserPreference(defaultValue: true) var hasSeenNewSoundBanner: Bool + /// Defaults to `true` so new users never see it. Developer options arms it for now, an upgrade migration will at launch. + @UserPreference(defaultValue: true) + var hasSeenMultiAccountAnnouncement: Bool + /// The initial set of account providers shown to the user in the authentication flow. /// /// Account provider is the friendly term for the server name. It should not contain an `https` prefix and should /// match the last part of the user ID. For example `example.com` and not `https://matrix.example.com`. - private(set) var accountProviders = ["matrix.org"] + private(set) var accountProviders: [AccountProvider] = [.managed(serverName: "matrix.org", baseURL: "https://matrix-client.matrix.org")] /// Whether or not the user is allowed to manually enter their own account provider or must select from one of `defaultAccountProviders`. private(set) var allowOtherAccountProviders = true /// Whether the components surrounding the app brand/logo should be hidden or not @@ -153,6 +131,8 @@ final nonisolated class AppSettings: @unchecked Sendable { /// The task identifier used for background app refresh. Also used in main target's the Info.plist let backgroundAppRefreshTaskIdentifier = "io.element.elementx.background.refresh" + /// The task identifier used for the search index backfill. Also used in main target's the Info.plist + let searchBackfillTaskIdentifier = "io.element.elementx.background.search-backfill" /// A URL where users can go read more about the app. private(set) var websiteURL: URL = "https://element.io" @@ -190,8 +170,12 @@ final nonisolated class AppSettings: @unchecked Sendable { @UserPreference(key: "previousServers", defaultValue: []) var previousServers: [String] - var defaultServer: String { - previousServers.first ?? accountProviders[0] + var defaultAccountProvider: AccountProvider { + if allowOtherAccountProviders { + previousServers.first.map { .generic($0) } ?? accountProviders[0] + } else { + accountProviders[0] + } } // MARK: - Security @@ -270,12 +254,6 @@ final nonisolated class AppSettings: @unchecked Sendable { @UserPreference var voipPushGatewayNotifyURL: String? - @UserPreference(defaultValue: true) - var enableNotifications: Bool - - @UserPreference(defaultValue: true) - var enableInAppNotifications: Bool - @UserPreference(defaultValue: false) var hideQuietNotificationAlerts: Bool @@ -287,9 +265,9 @@ final nonisolated class AppSettings: @unchecked Sendable { @UserPreference var lastNotificationBootTime: TimeInterval? - /// The sound played when delivering noisy notifications. If nil, use the ElementX default - @UserPreference - var selectedNotificationTone: NotificationTone? + /// The app icon badge value the app last computed from the SDK's unread notification counts. + @UserPreference(defaultValue: 0) + var lastKnownBadgeCount: Int // MARK: - Logging @@ -348,9 +326,6 @@ final nonisolated class AppSettings: @unchecked Sendable { @UserPreference(defaultValue: false) var hasRunNotificationPermissionsOnboarding: Bool - @UserPreference(defaultValue: false) - var hasRunIdentityConfirmationOnboarding: Bool - @UserPreference(defaultValue: false) var hasRequestedLocationAlwaysLocationAuthorization: Bool @@ -368,14 +343,6 @@ final nonisolated class AppSettings: @unchecked Sendable { @UserPreference(defaultValue: false) var liveLocationDisclaimerDisplayed: Bool - // MARK: - Home Screen - - @UserPreference(defaultValue: RoomListActivityVisibility.current) - var roomListActivityVisibility: RoomListActivityVisibility - - @UserPreference(defaultValue: false) - var roomListNotificationCountEnabled: Bool - // MARK: - Room Screen @UserPreference(defaultValue: AppBuildType.current == .debug) @@ -422,11 +389,6 @@ final nonisolated class AppSettings: @unchecked Sendable { /// The MapTiler configuration used to build map URLs, which defaults to the bundled one. private(set) var mapTilerConfiguration = RemotePreference(AppSettings.bundledMapTilerConfiguration) - // MARK: - Presence - - @UserPreference(defaultValue: true) - var sharePresence: Bool - // MARK: - Feature Flags /// Others @@ -452,7 +414,7 @@ final nonisolated class AppSettings: @unchecked Sendable { @UserPreference(defaultValue: false) var messageMultiSelectEnabled: Bool - @UserPreference(defaultValue: ProcessInfo().isiOSAppOnMac) + @UserPreference(key: "globalSearchEnabledV2", defaultValue: true) var globalSearchEnabled: Bool @UserPreference(defaultValue: false) @@ -472,22 +434,32 @@ final nonisolated class AppSettings: @unchecked Sendable { @UserPreference(defaultValue: false) var linkNewDeviceEnabled: Bool - @UserPreference(defaultValue: false) - var automaticBackPaginationEnabled: Bool - @UserPreference(key: "clientPausingAndResumingEnabledV2", defaultValue: false, volatile: true) var clientPausingAndResumingEnabled: Bool @UserPreference(defaultValue: AppBuildType.current != .release) var developerOptionsEnabled: Bool + /// Runs calls through the native matrix-rust-rtc stack instead of the Element Call web view. + @UserPreference(defaultValue: false) + var nativeCallEnabled: Bool + + @UserPreference(defaultValue: false) + var multiAccountEnabled: Bool + + /// Lets the add account flow start once `multiAccountEnabled` shows its entry points. Not a user preference, so testers + /// can't add a second account while signing out of one isn't safe yet. Only set it to `true` locally, for development. + let addAccountFlowEnabled = false + init(store: UserDefaultsProtocol) { self.store = store } + func userSettings(for userID: String) -> UserSettings { + UserSettings(appSettings: self, accountSettings: .init(userID: userID, store: store)) + } + static func volatile() -> AppSettings { AppSettings(store: VolatileUserDefaults()) } } - -nonisolated extension AppSettings: CommonSettingsProtocol { } diff --git a/ElementX/Sources/FlowCoordinators/SettingsFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/SettingsFlowCoordinator.swift index bd6c72237..f61e3674b 100644 --- a/ElementX/Sources/FlowCoordinators/SettingsFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/SettingsFlowCoordinator.swift @@ -23,6 +23,10 @@ class SettingsFlowCoordinator: FlowCoordinatorProtocol { private let navigationStackCoordinator: NavigationStackCoordinator private let flowParameters: CommonFlowParameters + private var userSettings: UserSettings { + flowParameters.userSession.userSettings + } + // periphery:ignore - retaining purpose private var appLockSetupFlowCoordinator: AppLockSetupFlowCoordinator? private var bugReportFlowCoordinator: BugReportFlowCoordinator? @@ -73,8 +77,6 @@ class SettingsFlowCoordinator: FlowCoordinatorProtocol { private func presentSettingsScreen(animated: Bool) { let settingsScreenCoordinator = SettingsScreenCoordinator(parameters: .init(userSession: flowParameters.userSession, - appSettings: flowParameters.appSettings, - appHooks: flowParameters.appHooks, isBugReportServiceEnabled: flowParameters.bugReportService.isEnabled, isInSecondaryWindow: isInSecondaryWindow, userIndicatorController: flowParameters.userIndicatorController)) @@ -86,42 +88,52 @@ class SettingsFlowCoordinator: FlowCoordinatorProtocol { switch action { case .dismiss: actionsSubject.send(.dismiss) - case .logout: - actionsSubject.send(.runLogoutFlow) - case .secureBackup: - startEncryptionSettingsFlow() + case .userDetails: presentUserDetailsEditScreen() case let .userStatusEmojiPicker(continuation): presentEmojiPicker(emojiPickerContinuation: continuation) - case .linkNewDevice: - startLinkNewDeviceFlow() + + case .addAccount: + break // Nothing to present until the add account flow exists. + case let .manageAccount(url): presentAccountManagementURL(url) - case .analytics: - presentAnalyticsScreen() - case .appLock: - presentAppLockSetupFlow() + case .linkNewDevice: + startLinkNewDeviceFlow() + case .notifications: + presentNotificationSettings() + case .secureBackup: + startEncryptionSettingsFlow() + case .moderationAndSafety: + presentModerationAndSafetySettings() case .bugReport: bugReportFlowCoordinator = BugReportFlowCoordinator(parameters: .init(presentationMode: .push(navigationStackCoordinator), userIndicatorController: flowParameters.userIndicatorController, bugReportService: flowParameters.bugReportService, userSession: flowParameters.userSession)) bugReportFlowCoordinator?.start() - case .about: - presentLegalInformationScreen() - case .blockedUsers: - presentBlockedUsersScreen() - case .notifications: - presentNotificationSettings() - case .advancedSettings: - presentAdvancedSettings() - case .labs: - presentLabs() - case .developerOptions: - presentDeveloperOptions() + + case .logout: + actionsSubject.send(.runLogoutFlow) case .deactivateAccount: presentDeactivateAccount() + + case .mediaUploadQuality: + presentMediaUploadQualitySettings() + case .appLock: + presentAppLockSetupFlow() + case .locationSharing: + presentLocationSharingSettings() + case .analytics: + presentAnalyticsScreen() + case .labs: + presentLabs() + case .about: + presentLegalInformationScreen() + + case .developerOptions: + presentDeveloperOptions() } } .store(in: &cancellables) @@ -130,7 +142,7 @@ class SettingsFlowCoordinator: FlowCoordinatorProtocol { } private func presentLabs() { - let coordinator = LabsScreenCoordinator(parameters: .init(appSettings: flowParameters.appSettings)) + let coordinator = LabsScreenCoordinator(parameters: .init(userSettings: userSettings)) coordinator.actions .sink { [weak self] action in switch action { @@ -145,8 +157,6 @@ class SettingsFlowCoordinator: FlowCoordinatorProtocol { private func startEncryptionSettingsFlow() { let coordinator = EncryptionSettingsFlowCoordinator(parameters: .init(userSession: flowParameters.userSession, - appSettings: flowParameters.appSettings, - appHooks: flowParameters.appHooks, userIndicatorController: flowParameters.userIndicatorController, navigationStackCoordinator: navigationStackCoordinator)) coordinator.actionsPublisher.sink { [weak self] action in @@ -183,7 +193,7 @@ class SettingsFlowCoordinator: FlowCoordinatorProtocol { let coordinator = flowParameters.appHooks.userDetailsEditScreenHook .makeCoordinator(parameters: .init(orientationManager: flowParameters.windowManager, userSession: flowParameters.userSession, - mediaUploadingPreprocessor: MediaUploadingPreprocessor(appSettings: flowParameters.appSettings), + mediaUploadingPreprocessor: MediaUploadingPreprocessor(userSettings: userSettings), navigationStackCoordinator: navigationStackCoordinator, userIndicatorController: flowParameters.userIndicatorController)) coordinator.actions @@ -227,7 +237,7 @@ class SettingsFlowCoordinator: FlowCoordinatorProtocol { } private func presentAnalyticsScreen() { - let coordinator = AnalyticsSettingsScreenCoordinator(parameters: .init(appSettings: flowParameters.appSettings, + let coordinator = AnalyticsSettingsScreenCoordinator(parameters: .init(userSettings: userSettings, analytics: flowParameters.analytics)) navigationStackCoordinator.push(coordinator) } @@ -253,11 +263,11 @@ class SettingsFlowCoordinator: FlowCoordinatorProtocol { } private func presentLegalInformationScreen() { - navigationStackCoordinator.push(LegalInformationScreenCoordinator(appSettings: flowParameters.appSettings)) + navigationStackCoordinator.push(LegalInformationScreenCoordinator(userSettings: userSettings)) } private func presentBlockedUsersScreen() { - let coordinator = BlockedUsersScreenCoordinator(parameters: .init(hideProfiles: flowParameters.appSettings.hideIgnoredUserProfiles, + let coordinator = BlockedUsersScreenCoordinator(parameters: .init(hideProfiles: userSettings.app.hideIgnoredUserProfiles, userSession: flowParameters.userSession, userIndicatorController: flowParameters.userIndicatorController)) navigationStackCoordinator.push(coordinator) @@ -268,22 +278,44 @@ class SettingsFlowCoordinator: FlowCoordinatorProtocol { userSession: flowParameters.userSession, userNotificationCenter: UNUserNotificationCenter.current(), userIndicatorController: flowParameters.userIndicatorController, - isModallyPresented: false, - appSettings: flowParameters.appSettings) + isModallyPresented: false) let coordinator = NotificationSettingsScreenCoordinator(parameters: notificationParameters) navigationStackCoordinator.push(coordinator) } - private func presentAdvancedSettings() { - let coordinator = AdvancedSettingsScreenCoordinator(parameters: .init(appSettings: flowParameters.appSettings, - analytics: flowParameters.analytics, - clientProxy: flowParameters.userSession.clientProxy, - userIndicatorController: flowParameters.userIndicatorController)) + private func presentModerationAndSafetySettings() { + let parameters = ModerationAndSafetySettingsScreenCoordinatorParameters(userSession: flowParameters.userSession, + userIndicatorController: flowParameters.userIndicatorController) + let coordinator = ModerationAndSafetySettingsScreenCoordinator(parameters: parameters) + coordinator.actionsPublisher + .sink { [weak self] action in + guard let self else { return } + + switch action { + case .blockedUsers: + presentBlockedUsersScreen() + } + } + .store(in: &cancellables) + + navigationStackCoordinator.push(coordinator) + } + + private func presentMediaUploadQualitySettings() { + let parameters = MediaUploadQualitySettingsScreenCoordinatorParameters(userSettings: userSettings, + analytics: flowParameters.analytics) + let coordinator = MediaUploadQualitySettingsScreenCoordinator(parameters: parameters) + navigationStackCoordinator.push(coordinator) + } + + private func presentLocationSharingSettings() { + let parameters = LocationSharingSettingsScreenCoordinatorParameters(userSettings: userSettings) + let coordinator = LocationSharingSettingsScreenCoordinator(parameters: parameters) navigationStackCoordinator.push(coordinator) } private func presentDeveloperOptions() { - let coordinator = DeveloperOptionsScreenCoordinator(appSettings: flowParameters.appSettings, + let coordinator = DeveloperOptionsScreenCoordinator(appSettings: userSettings.app, appHooks: flowParameters.appHooks, clientProxy: flowParameters.userSession.clientProxy) @@ -329,7 +361,7 @@ class SettingsFlowCoordinator: FlowCoordinatorProtocol { accountSettingsPresenter = OAuthAccountSettingsPresenter(accountURL: url, presentationAnchor: flowParameters.windowManager.mainWindow, appMediator: flowParameters.appMediator, - appSettings: flowParameters.appSettings, + userSettings: userSettings, appHooks: flowParameters.appHooks, continuation: continuation) accountSettingsPresenter?.start() diff --git a/ElementX/Sources/Screens/Authentication/StartScreen/AuthenticationStartScreenViewModel.swift b/ElementX/Sources/Screens/Authentication/StartScreen/AuthenticationStartScreenViewModel.swift index c366b94bc..0801a3481 100644 --- a/ElementX/Sources/Screens/Authentication/StartScreen/AuthenticationStartScreenViewModel.swift +++ b/ElementX/Sources/Screens/Authentication/StartScreen/AuthenticationStartScreenViewModel.swift @@ -40,27 +40,30 @@ class AuthenticationStartScreenViewModel: AuthenticationStartScreenViewModelType canReportProblem = isBugReportServiceEnabled let isQRCodeScanningSupported = !ProcessInfo.processInfo.isiOSAppOnMac - let classicAppAccountProvider = authenticationService.classicAppAccount?.serverName - let isClassicAppAccountAllowed = classicAppAccountProvider.map { appSettings.accountProviders.contains($0) } ?? false + let isClassicAppAccountAllowed = if let classicAppAccount = authenticationService.classicAppAccount { + appSettings.accountProviders.contains(serverName: classicAppAccount.serverName, orBaseURL: classicAppAccount.homeserverURL) + } else { + false + } let initialViewState = if !appSettings.allowOtherAccountProviders { // We don't show the create account button when custom providers are disallowed. // The assumption here being that if you're running a custom app, your users will already be created. - AuthenticationStartScreenViewState(serverName: appSettings.accountProviders.count == 1 ? appSettings.accountProviders[0] : nil, + AuthenticationStartScreenViewState(serverNameOrBaseURL: appSettings.accountProviders.count == 1 ? appSettings.accountProviders[0].serverNameOrBaseURL : nil, showCreateAccountButton: false, showQRCodeLoginButton: isQRCodeScanningSupported, classicAppMode: isClassicAppAccountAllowed ? authenticationService.classicAppAccount.map { .welcomeBack($0) } : nil, hideBrandChrome: appSettings.hideBrandChrome) } else if let provisioningParameters { // We only show the "Sign in to …" button when using a provisioning link. - AuthenticationStartScreenViewState(serverName: provisioningParameters.accountProvider, + AuthenticationStartScreenViewState(serverNameOrBaseURL: provisioningParameters.accountProvider, showCreateAccountButton: false, showQRCodeLoginButton: false, classicAppMode: nil, hideBrandChrome: appSettings.hideBrandChrome) } else { // The default configuration. - AuthenticationStartScreenViewState(serverName: nil, + AuthenticationStartScreenViewState(serverNameOrBaseURL: nil, showCreateAccountButton: appSettings.showCreateAccountButton, showQRCodeLoginButton: isQRCodeScanningSupported, classicAppMode: authenticationService.classicAppAccount.map { .welcomeBack($0) }, @@ -116,21 +119,21 @@ class AuthenticationStartScreenViewModel: AuthenticationStartScreenViewModelType // bundle id as the legacy app, so this update *replaces* it — there is no Classic app left to // open, and the sheet's only button resolves to nothing. Fall through to a normal sign-in // instead: the user re-verifies and loses only history that by definition has no backup. - await configureAccountProvider(classicAppAccount.serverName, - loginHint: "mxid:\(classicAppAccount.userID)", - fallbackHomeserverURL: classicAppAccount.homeserverURL) - } else if let serverName = state.serverName { - await configureAccountProvider(serverName, loginHint: provisioningParameters?.loginHint) + await loginDirectly(using: classicAppAccount.serverName, + loginHint: "mxid:\(classicAppAccount.userID)", + fallbackHomeserverURL: classicAppAccount.homeserverURL) + } else if let serverNameOrBaseURL = state.serverNameOrBaseURL { + await loginDirectly(using: serverNameOrBaseURL, loginHint: provisioningParameters?.loginHint) } else { actionsSubject.send(.login) // No need to configure anything here, continue the flow. } } - private func configureAccountProvider(_ accountProvider: String, loginHint: String? = nil, fallbackHomeserverURL: URL? = nil) async { + private func loginDirectly(using serverNameOrBaseURL: String, loginHint: String? = nil, fallbackHomeserverURL: URL? = nil) async { startLoading() defer { stopLoading() } - if case .failure = await authenticationService.configure(for: accountProvider, flow: .login) { + if case .failure = await authenticationService.configure(for: serverNameOrBaseURL, flow: .login) { // Try the fallback URL before showing an error. if let fallbackHomeserverURL, case .success = await authenticationService.configure(for: fallbackHomeserverURL.absoluteString, flow: .login) { diff --git a/ElementX/Sources/Screens/Settings/SettingsScreen/SettingsScreenCoordinator.swift b/ElementX/Sources/Screens/Settings/SettingsScreen/SettingsScreenCoordinator.swift index acd203828..0f0b99669 100644 --- a/ElementX/Sources/Screens/Settings/SettingsScreen/SettingsScreenCoordinator.swift +++ b/ElementX/Sources/Screens/Settings/SettingsScreen/SettingsScreenCoordinator.swift @@ -11,8 +11,6 @@ import SwiftUI struct SettingsScreenCoordinatorParameters { let userSession: UserSessionProtocol - let appSettings: AppSettings - let appHooks: AppHooks let isBugReportServiceEnabled: Bool let isInSecondaryWindow: Bool let userIndicatorController: UserIndicatorControllerProtocol @@ -20,22 +18,30 @@ struct SettingsScreenCoordinatorParameters { enum SettingsScreenCoordinatorAction { case dismiss - case logout - case secureBackup + case userDetails case userStatusEmojiPicker(EmojiPickerScreenContinuation) - case analytics - case appLock - case bugReport - case about - case blockedUsers - case linkNewDevice + + case addAccount + case manageAccount(url: URL) + case linkNewDevice case notifications - case advancedSettings - case labs - case developerOptions + case secureBackup + case moderationAndSafety + case bugReport + + case logout case deactivateAccount + + case mediaUploadQuality + case appLock + case locationSharing + case analytics + case labs + case about + + case developerOptions } final class SettingsScreenCoordinator: CoordinatorProtocol { @@ -52,8 +58,6 @@ final class SettingsScreenCoordinator: CoordinatorProtocol { init(parameters: SettingsScreenCoordinatorParameters) { viewModel = SettingsScreenViewModel(userSession: parameters.userSession, - appSettings: parameters.appSettings, - appHooks: parameters.appHooks, isBugReportServiceEnabled: parameters.isBugReportServiceEnabled, isInSecondaryWindow: parameters.isInSecondaryWindow, userIndicatorController: parameters.userIndicatorController) @@ -65,38 +69,48 @@ final class SettingsScreenCoordinator: CoordinatorProtocol { switch action { case .close: actionsSubject.send(.dismiss) + case .userDetails: actionsSubject.send(.userDetails) case let .userStatusEmojiPicker(continuation): actionsSubject.send(.userStatusEmojiPicker(continuation)) - case .linkNewDevice: - actionsSubject.send(.linkNewDevice) + + case .addAccount: + actionsSubject.send(.addAccount) + case let .manageAccount(url): actionsSubject.send(.manageAccount(url: url)) - case .analytics: - actionsSubject.send(.analytics) - case .appLock: - actionsSubject.send(.appLock) - case .reportBug: - actionsSubject.send(.bugReport) - case .about: - actionsSubject.send(.about) - case .blockedUsers: - actionsSubject.send(.blockedUsers) - case .secureBackup: - actionsSubject.send(.secureBackup) + case .linkNewDevice: + actionsSubject.send(.linkNewDevice) case .notifications: actionsSubject.send(.notifications) - case .advancedSettings: - actionsSubject.send(.advancedSettings) - case .labs: - actionsSubject.send(.labs) - case .developerOptions: - actionsSubject.send(.developerOptions) + case .secureBackup: + actionsSubject.send(.secureBackup) + case .moderationAndSafety: + actionsSubject.send(.moderationAndSafety) + case .reportBug: + actionsSubject.send(.bugReport) + case .logout: actionsSubject.send(.logout) case .deactivateAccount: actionsSubject.send(.deactivateAccount) + + case .mediaUploadQuality: + actionsSubject.send(.mediaUploadQuality) + case .appLock: + actionsSubject.send(.appLock) + case .locationSharing: + actionsSubject.send(.locationSharing) + case .analytics: + actionsSubject.send(.analytics) + case .labs: + actionsSubject.send(.labs) + case .about: + actionsSubject.send(.about) + + case .developerOptions: + actionsSubject.send(.developerOptions) } } .store(in: &cancellables) diff --git a/Enterprise b/Enterprise new file mode 160000 index 000000000..8b65ca7f1 --- /dev/null +++ b/Enterprise @@ -0,0 +1 @@ +Subproject commit 8b65ca7f1334e3d696fe01f2ad071cc5d9db9326 diff --git a/UnitTests/Sources/ElementCallServiceTests.swift b/UnitTests/Sources/ElementCallServiceTests.swift index ff6a00801..80e5cd866 100644 --- a/UnitTests/Sources/ElementCallServiceTests.swift +++ b/UnitTests/Sources/ElementCallServiceTests.swift @@ -6,36 +6,46 @@ // import CallKit +import ElementCall @testable import ElementX import PushKit import Testing @MainActor final class ElementCallServiceTests { + private var appSettings: AppSettings! private var callProvider: CXProviderMock! + private var callController: CXCallControllerMock! private var currentDate: Date! private var clock: ManualClock! private var pushRegistry: PKPushRegistry! private var service: ElementCallService! + private var userSession: UserSessionMock! init() { + appSettings = AppSettings.volatile() pushRegistry = PKPushRegistry(queue: nil) callProvider = CXProviderMock(.init()) + callController = CXCallControllerMock(.init()) currentDate = Date() clock = ManualClock() let dateProvider: () -> Date = { self.currentDate } - service = ElementCallService(callProvider: callProvider, - timeProvider: TimeProvider(clock: clock, now: dateProvider), - appSettings: AppSettings.volatile()) + service = ElementCallService(appSettings: appSettings, + callProvider: callProvider, + callController: callController, + timeProvider: TimeProvider(clock: clock, now: dateProvider)) } isolated deinit { + appSettings = nil callProvider = nil + callController = nil currentDate = nil clock = nil pushRegistry = nil + userSession = nil } @Test @@ -199,7 +209,7 @@ final class ElementCallServiceTests { } @Test - func setupCallSessionCancelsPendingUnansweredTimeout() async { + func setupCallSessionCancelsPendingUnansweredTimeout() async throws { // Schedule the 60s unanswered timer via an incoming push await waitForConfirmation { confirmation in let payload = PKPushPayloadMock().updatingExpiration(currentDate, lifetime: 60) @@ -213,22 +223,20 @@ final class ElementCallServiceTests { // Simulate the answer flow handing off to setupCallSession, which must cancel // the pending endUnansweredCallTask as part of clearing the incoming state. - await service.setupCallSession(roomID: "!room:example.com", roomDisplayName: "welcome") + await service.setupCallSession(roomID: "!room:example.com", roomDisplayName: "welcome", isVideo: true) - var unansweredFired = false + let (endedCalls, endedCallsContinuation) = AsyncStream.makeStream() callProvider.reportCallWithEndedAtReasonClosure = { _, _, reason in - if reason == .unanswered { - unansweredFired = true - } + endedCallsContinuation.yield(reason) } + let deferredUnansweredCall = deferFailure(endedCalls, + timeout: .milliseconds(100), + message: "endUnansweredCallTask should have been cancelled by setupCallSession") { $0 == .unanswered } // Advance past what would have been the 60s unanswered timeout clock.advance(by: .seconds(120)) - for _ in 0..<3 { - await Task.yield() - } - #expect(!unansweredFired, "endUnansweredCallTask should have been cancelled by setupCallSession") + try await deferredUnansweredCall.fulfill() } @Test @@ -246,7 +254,7 @@ final class ElementCallServiceTests { @Test func duplicateRoomPushReportsCallAsHandled() async { // A duplicate push for an ongoing call is reported as handled, leaving the ongoing call alone. - await service.setupCallSession(roomID: "!room:example.com", roomDisplayName: "welcome") + await service.setupCallSession(roomID: "!room:example.com", roomDisplayName: "welcome", isVideo: true) let pushPayload = PKPushPayloadMock().updatingExpiration(currentDate, lifetime: 30) await expectImmediatelyEndedCallReported(forPayload: pushPayload, expectedReason: .answeredElsewhere) @@ -257,6 +265,271 @@ final class ElementCallServiceTests { #expect(service.ongoingCallRoomIDPublisher.value == "!room:example.com") } + @Test + func webViewCallSessionIsNotTrackedByCallKit() async { + // CallKit tracking a call gives this process exclusive media access, which starves the web + // view. So a web view call is only ever tracked on the app side. + await service.setupCallSession(roomID: "!room:example.com", roomDisplayName: "welcome", isVideo: true) + service.reportCallSessionConnected(roomID: "!room:example.com") + + #expect(service.ongoingCallRoomIDPublisher.value == "!room:example.com") + #expect(!callProvider.reportCallWithUpdatedCalled) + #expect(!callProvider.reportOutgoingCallWithConnectedAtCalled) + } + + @Test + func endingAReplacedCallLeavesTheCallThatReplacedItUp() async throws { + enableNativeCalls() + + // Leaving a call to start one in another room requests an end call action for the first, + // then starts the second before the system performs it. + await service.setupCallSession(roomID: "!first:example.com", roomDisplayName: "first", isVideo: true) + let firstCallID = try #require(callProvider.reportCallWithUpdatedReceivedArguments?.uuid) + + service.tearDownCallSession(roomID: "!first:example.com") + await service.setupCallSession(roomID: "!second:example.com", roomDisplayName: "second", isVideo: true) + + var endedRooms: [String] = [] + let cancellable = service.actions.sink { action in + if case .endCall(let roomID) = action { + endedRooms.append(roomID) + } + } + defer { cancellable.cancel() } + + service.endCall(withCallKitID: firstCallID) + + #expect(endedRooms.isEmpty, "The first call's end action must not hang up the second call") + #expect(service.ongoingCallRoomIDPublisher.value == "!second:example.com") + + // And the second call still ends when it's the one the system means. + let secondCallID = try #require(callProvider.reportCallWithUpdatedReceivedArguments?.uuid) + service.endCall(withCallKitID: secondCallID) + + #expect(endedRooms == ["!second:example.com"]) + #expect(service.ongoingCallRoomIDPublisher.value == nil) + } + + /// A declined or unanswered call is the system's to log as such: reported with its reason, and + /// never as an end call action, which would log it as one we hung up. + @Test + func aCallThatEndedWithoutUsIsReportedRatherThanEnded() async throws { + enableNativeCalls() + await service.setupCallSession(roomID: "!room:example.com", roomDisplayName: "welcome", isVideo: true) + let callID = try #require(callProvider.reportCallWithUpdatedReceivedArguments?.uuid) + let requestsBeforeTheEnd = callController.requestCompletionCallsCount + + service.reportCallSessionEnded(roomID: "!other:example.com", reason: .unanswered) + #expect(!callProvider.reportCallWithEndedAtReasonCalled, "Another room's end is not this call's") + + service.reportCallSessionEnded(roomID: "!room:example.com", reason: .unanswered) + + #expect(callProvider.reportCallWithEndedAtReasonReceivedArguments?.uuid == callID) + #expect(callProvider.reportCallWithEndedAtReasonReceivedArguments?.reason == .unanswered) + #expect(callController.requestCompletionCallsCount == requestsBeforeTheEnd) + #expect(service.ongoingCallRoomIDPublisher.value == nil) + } + + @Test + func tearingDownAnotherRoomLeavesTheOngoingCallUp() async { + // A call screen that is dismissed after another room's call replaced it must not take the + // new call down with it. + await service.setupCallSession(roomID: "!room:example.com", roomDisplayName: "welcome", isVideo: true) + + service.tearDownCallSession(roomID: "!other:example.com") + #expect(service.ongoingCallRoomIDPublisher.value == "!room:example.com") + + service.tearDownCallSession(roomID: "!room:example.com") + #expect(service.ongoingCallRoomIDPublisher.value == nil) + } + + // MARK: - Native calls + + @Test + func nativeModeReportsVoiceCallsWithoutVideo() async { + appSettings.nativeCallEnabled = true + + await reportIncomingPush(isVoiceCall: true) + + #expect(callProvider.reportNewIncomingCallWithUpdateCompletionReceivedArguments?.update.hasVideo == false) + } + + @Test + func nativeModeStillReportsVideoCallsWithVideo() async { + appSettings.nativeCallEnabled = true + + await reportIncomingPush(isVoiceCall: false) + + #expect(callProvider.reportNewIncomingCallWithUpdateCompletionReceivedArguments?.update.hasVideo == true) + } + + @Test + func answeringInNativeModeKeepsTheCallUp() async throws { + appSettings.nativeCallEnabled = true + await reportIncomingPush(isVoiceCall: false) + await clock.waitForScheduledSleep() + + try await answerReportedCall() + + // The webview path ends the call a second after answering so that `setupCallSession` can + // start a new one. The native stack takes this one over instead, so it has to survive. + #expect(!callProvider.reportCallWithEndedAtReasonCalled) + } + + @Test + func answeredNativeCallEndsWhenNothingTakesItOver() async throws { + appSettings.nativeCallEnabled = true + await reportIncomingPush(isVoiceCall: false) + await clock.waitForScheduledSleep() + + let answeredUUID = try await answerReportedCall() + + let (endedCalls, endedCallsContinuation) = AsyncStream<(UUID, CXCallEndedReason)>.makeStream() + callProvider.reportCallWithEndedAtReasonClosure = { uuid, _, reason in + endedCallsContinuation.yield((uuid, reason)) + } + let deferredEndedCall = deferFulfillment(endedCalls) { _ in true } + + // Answering swaps the unanswered timer for the watchdog that catches a hand-off that never + // happens — the room isn't joined, there's no transport, a call is already running. + #expect(await clock.waitForScheduledSleep() == .seconds(30)) + clock.advance(by: .seconds(30)) + + let (uuid, reason) = try await deferredEndedCall.fulfill() + #expect(uuid == answeredUUID) + #expect(reason == .failed) + } + + @Test + func nativeCallStackTakesOverTheAnsweredCall() async throws { + enableNativeCalls() + await reportIncomingPush(isVoiceCall: false) + await clock.waitForScheduledSleep() + + try await answerReportedCall() + + // Answering starts the watchdog, so wait for it before asserting that the hand-over stops it. + await clock.waitForScheduledSleep() + + await service.setupCallSession(roomID: "!room:example.com", roomDisplayName: "welcome", isVideo: true) + + // Taking the answered call over means keeping its identifier rather than reporting a new + // outgoing call, which is what the update below would be part of. + #expect(!callProvider.reportCallWithUpdatedCalled) + #expect(service.ongoingCallRoomIDPublisher.value == "!room:example.com") + + // And the watchdog must be gone, otherwise it ends the call that was just taken over. + #expect(!clock.hasPendingSleep, "The watchdog must not outlive the hand-over") + } + + @Test + func nativeCallStackForAnotherRoomReportsANewCall() async throws { + enableNativeCalls() + await reportIncomingPush(isVoiceCall: false) + await clock.waitForScheduledSleep() + + let answeredUUID = try await answerReportedCall() + + await service.setupCallSession(roomID: "!other:example.com", roomDisplayName: "other", isVideo: true) + + let update = try #require(callProvider.reportCallWithUpdatedReceivedArguments) + #expect(update.uuid != answeredUUID) + #expect(update.update.localizedCallerName == "other") + #expect(service.ongoingCallRoomIDPublisher.value == "!other:example.com") + + // Unlike a web view call, a native one is tracked by CallKit, so it gets the connected report too. + service.reportCallSessionConnected(roomID: "!other:example.com") + #expect(callProvider.reportOutgoingCallWithConnectedAtReceivedArguments?.uuid == update.uuid) + } + + @Test + func nativeCallStackNeedsBothTheSettingAndASession() { + #expect(service.nativeCallController == nil) + + appSettings.nativeCallEnabled = true + #expect(service.nativeCallController == nil, "The setting alone has no transport to run a call over") + + service.setUserSession(nativeUserSession()) + #expect(service.nativeCallController != nil) + + service.setUserSession(nil) + #expect(service.nativeCallController == nil, "Signing out has to release the stack's to-device subscription") + } + + @Test + func turningTheSettingOnMidSessionBuildsTheStack() { + service.setUserSession(nativeUserSession()) + #expect(service.nativeCallController == nil) + + // The transport is built on demand, so the setting takes effect without an app restart. + appSettings.nativeCallEnabled = true + #expect(service.nativeCallController != nil) + } + + @Test + func aNewSessionGetsANewStack() { + appSettings.nativeCallEnabled = true + service.setUserSession(nativeUserSession()) + let firstController = service.nativeCallController + + // A soft logout or a cache clear brings a new session without the sign out that drops this + // one, and the stack is built against the client proxy it was given. + service.setUserSession(nativeUserSession()) + + #expect(service.nativeCallController !== firstController) + } + + @Test + func callsFallBackToTheWebViewWithoutAStack() { + let roomProxy = JoinedRoomProxyMock(.init(id: "!room:example.com")) + + #expect(!service.handleNativeCallRequest(roomProxy: roomProxy, isVoiceCall: false)) + + appSettings.nativeCallEnabled = true + service.setUserSession(nativeUserSession()) + + #expect(service.handleNativeCallRequest(roomProxy: roomProxy, isVoiceCall: false)) + } + + /// Builds a session whose client proxy can serve the native call stack, and holds on to it: the + /// service only keeps a weak reference, as the app owns the session. + private func nativeUserSession() -> UserSessionMock { + let clientProxy = ClientProxyMock(.init()) + clientProxy.makeNativeCallTransportReturnValue = ElementCallFakeTransport() + let session = UserSessionMock(.init(clientProxy: clientProxy)) + userSession = session + return session + } + + /// Puts the service in native mode, with the stack the CallKit paths check for. + private func enableNativeCalls() { + appSettings.nativeCallEnabled = true + service.setUserSession(nativeUserSession()) + } + + /// Delivers a VoIP push and waits for it to be reported, returning once the call is ringing. + private func reportIncomingPush(isVoiceCall: Bool) async { + await waitForConfirmation { confirmation in + let payload = PKPushPayloadMock().updatingExpiration(currentDate, lifetime: 60) + .updateIsVoice(isVoiceCall) + + service.pushRegistry(pushRegistry, didReceiveIncomingPushWith: payload, for: .voIP) { + confirmation() + } + } + } + + /// Answers the call the provider last reported, returning its identifier. + @discardableResult + private func answerReportedCall() async throws -> UUID { + let uuid = try #require(callProvider.reportNewIncomingCallWithUpdateCompletionReceivedArguments?.uuid) + let provider = CXProvider(configuration: CXProviderConfiguration()) + + service.provider(provider, perform: CXAnswerCallAction(call: uuid)) + + return uuid + } + private func expectImmediatelyEndedCallReported(forPayload payload: PKPushPayloadMock, expectedReason: CXCallEndedReason) async { let baselineNewIncomingCount = callProvider.reportNewIncomingCallWithUpdateCompletionCallsCount diff --git a/UnitTests/Sources/NotificationContentBuilderTests.swift b/UnitTests/Sources/NotificationContentBuilderTests.swift index d7278a459..59ed63986 100644 --- a/UnitTests/Sources/NotificationContentBuilderTests.swift +++ b/UnitTests/Sources/NotificationContentBuilderTests.swift @@ -23,7 +23,6 @@ nonisolated struct NotificationContentBuilderTests { style: .plain) mediaProvider = MediaProviderMock(.init()) notificationContentBuilder = await NotificationContentBuilder(messageEventStringBuilder: stringBuilder, - notificationSoundName: UNNotificationSoundName("message.caf"), userSession: NSEUserSessionMock(.init())) } @@ -226,6 +225,7 @@ nonisolated struct NotificationContentBuilderTests { mutating func liveLocationStartNotification() async { let event = TimelineEventSDKMock() event.eventIdReturnValue = UUID().uuidString + event.timestampReturnValue = 0 event.contentReturnValue = .state(content: .beaconInfo) let notificationItem = NotificationItemProxyMock(.init(event: .timeline(event: event), @@ -253,6 +253,7 @@ nonisolated struct NotificationContentBuilderTests { mutating func otherStateEventNotification() async { let event = TimelineEventSDKMock() event.eventIdReturnValue = UUID().uuidString + event.timestampReturnValue = 0 event.contentReturnValue = .state(content: .roomName) let notificationItem = NotificationItemProxyMock(.init(event: .timeline(event: event), diff --git a/UnitTests/Sources/SettingsScreenViewModelTests.swift b/UnitTests/Sources/SettingsScreenViewModelTests.swift index 7e0bb689a..16da6c2ce 100644 --- a/UnitTests/Sources/SettingsScreenViewModelTests.swift +++ b/UnitTests/Sources/SettingsScreenViewModelTests.swift @@ -172,13 +172,37 @@ struct SettingsScreenViewModelTests { try await deferred.fulfill() } + // MARK: - Add account + + @Test + mutating func addAccountButtonFollowsTheFeatureFlag() async throws { + let userSettings = UserSettings.mock() + setupViewModel(userSettings: userSettings) + #expect(!context.viewState.showAddAccountButton) + + var deferred = deferFulfillment(context.observe(\.viewState.showAddAccountButton)) { $0 } + userSettings.app.multiAccountEnabled = true + try await deferred.fulfill() + + deferred = deferFulfillment(context.observe(\.viewState.showAddAccountButton)) { !$0 } + userSettings.app.multiAccountEnabled = false + try await deferred.fulfill() + } + + @Test + mutating func addAccount() async throws { + setupViewModel() + + let deferred = deferFulfillment(viewModel.actions) { $0.isAddAccount } + context.send(viewAction: .addAccount) + try await deferred.fulfill() + } + // MARK: - Helpers - private mutating func setupViewModel(status: UserStatus = .init()) { + private mutating func setupViewModel(status: UserStatus = .init(), userSettings: UserSettings = .mock()) { clientProxy = ClientProxyMock(.init(userID: "", status: status)) - viewModel = SettingsScreenViewModel(userSession: UserSessionMock(.init(clientProxy: clientProxy)), - appSettings: AppSettings.volatile(), - appHooks: AppHooks(), + viewModel = SettingsScreenViewModel(userSession: UserSessionMock(.init(clientProxy: clientProxy, userSettings: userSettings)), isBugReportServiceEnabled: true, isInSecondaryWindow: false, userIndicatorController: UserIndicatorControllerMock()) @@ -227,4 +251,11 @@ private extension SettingsScreenViewModelAction { default: false } } + + var isAddAccount: Bool { + switch self { + case .addAccount: true + default: false + } + } } diff --git a/compound-ios/Inspector/Inspector.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/compound-ios/Inspector/Inspector.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 15a19435e..c84ee1fca 100644 --- a/compound-ios/Inspector/Inspector.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/compound-ios/Inspector/Inspector.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,13 +1,13 @@ { - "originHash" : "7a6117b3894e217e5bc0fe7497b0b868c49d51f41e0d8be554dc766cb9566ec9", + "originHash" : "43fad9d39aa61aa7add5e5768908e9ed7948ca1b8e8b61cf9b7b585f4e8fd1a7", "pins" : [ { "identity" : "compound-design-tokens", "kind" : "remoteSourceControl", "location" : "https://github.com/element-hq/compound-design-tokens", "state" : { - "revision" : "86c15ef71798581d1e0cc39a7f99e598ad572419", - "version" : "11.0.0" + "revision" : "28b921601ac0ff49a572b6f18526d97bd14e5d91", + "version" : "11.3.1" } }, { @@ -33,8 +33,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-snapshot-testing", "state" : { - "revision" : "59a99c458de4d2dee580529b61b4f78dca7b7fa6", - "version" : "1.19.4" + "revision" : "28e5de025e3fd98991791bfbf83023ab3708da03", + "version" : "1.19.6" } }, { @@ -42,8 +42,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/swiftlang/swift-syntax", "state" : { - "revision" : "79e4b74a295b6eb74a8b585e3a39d29e70c1dbd1", - "version" : "603.0.2" + "revision" : "050f1a346fbbac0ca2cfb15a95274f7bd1cf0ccf", + "version" : "604.0.0" } }, { @@ -51,8 +51,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/siteline/SwiftUI-Introspect", "state" : { - "revision" : "dd0888ef99d2809863b04e46cad52d1dfd8fc840", - "version" : "26.0.2" + "revision" : "a37eca1db7bebc9437ee89508e7b4433ff34adb4", + "version" : "27.0.0" } }, { diff --git a/compound-ios/Package.resolved b/compound-ios/Package.resolved index 15a19435e..c84ee1fca 100644 --- a/compound-ios/Package.resolved +++ b/compound-ios/Package.resolved @@ -1,13 +1,13 @@ { - "originHash" : "7a6117b3894e217e5bc0fe7497b0b868c49d51f41e0d8be554dc766cb9566ec9", + "originHash" : "43fad9d39aa61aa7add5e5768908e9ed7948ca1b8e8b61cf9b7b585f4e8fd1a7", "pins" : [ { "identity" : "compound-design-tokens", "kind" : "remoteSourceControl", "location" : "https://github.com/element-hq/compound-design-tokens", "state" : { - "revision" : "86c15ef71798581d1e0cc39a7f99e598ad572419", - "version" : "11.0.0" + "revision" : "28b921601ac0ff49a572b6f18526d97bd14e5d91", + "version" : "11.3.1" } }, { @@ -33,8 +33,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-snapshot-testing", "state" : { - "revision" : "59a99c458de4d2dee580529b61b4f78dca7b7fa6", - "version" : "1.19.4" + "revision" : "28e5de025e3fd98991791bfbf83023ab3708da03", + "version" : "1.19.6" } }, { @@ -42,8 +42,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/swiftlang/swift-syntax", "state" : { - "revision" : "79e4b74a295b6eb74a8b585e3a39d29e70c1dbd1", - "version" : "603.0.2" + "revision" : "050f1a346fbbac0ca2cfb15a95274f7bd1cf0ccf", + "version" : "604.0.0" } }, { @@ -51,8 +51,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/siteline/SwiftUI-Introspect", "state" : { - "revision" : "dd0888ef99d2809863b04e46cad52d1dfd8fc840", - "version" : "26.0.2" + "revision" : "a37eca1db7bebc9437ee89508e7b4433ff34adb4", + "version" : "27.0.0" } }, { diff --git a/compound-ios/Package.swift b/compound-ios/Package.swift index 81bb70095..f14c32c3d 100644 --- a/compound-ios/Package.swift +++ b/compound-ios/Package.swift @@ -9,11 +9,11 @@ let package = Package( .library(name: "Compound", targets: ["Compound"]) ], dependencies: [ - .package(url: "https://github.com/element-hq/compound-design-tokens", exact: "11.0.0"), + .package(url: "https://github.com/element-hq/compound-design-tokens", exact: "11.3.1"), // .package(path: "../../compound-design-tokens"), - .package(url: "https://github.com/siteline/SwiftUI-Introspect", exact: "26.0.2"), + .package(url: "https://github.com/siteline/SwiftUI-Introspect", exact: "27.0.0"), .package(url: "https://github.com/SFSafeSymbols/SFSafeSymbols", exact: "7.0.0"), - .package(url: "https://github.com/pointfreeco/swift-snapshot-testing", exact: "1.19.4") + .package(url: "https://github.com/pointfreeco/swift-snapshot-testing", exact: "1.19.6") ], targets: [ .target(