chore(healthchat): resolve the rebase onto upstream 26.10.3

This commit is contained in:
Sedat Qaja
2026-10-19 09:18:06 +02:00
parent 323970aa93
commit 1b34a71857
19 changed files with 656 additions and 258 deletions
+2 -2
View File
@@ -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
+8 -8
View File
@@ -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 }}
+4 -4
View File
@@ -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
+4 -4
View File
@@ -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
+3 -3
View File
@@ -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
+4 -14
View File
@@ -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
+63
View File
@@ -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
@@ -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"
}
},
{
@@ -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<TraceLogPack> { get }
var bugReportRageshakeURL: RemotePreference<RageshakeConfiguration> { get }
var contentScannerURL: RemotePreference<URL?> { get }
var forceDisableE2EE: RemotePreference<Bool> { get }
var mapTilerConfiguration: RemotePreference<MapTilerConfiguration> { 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 { }
@@ -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()
@@ -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,
await loginDirectly(using: classicAppAccount.serverName,
loginHint: "mxid:\(classicAppAccount.userID)",
fallbackHomeserverURL: classicAppAccount.homeserverURL)
} else if let serverName = state.serverName {
await configureAccountProvider(serverName, loginHint: provisioningParameters?.loginHint)
} 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) {
@@ -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)
Submodule
+1
Submodule Enterprise added at 8b65ca7f13
+287 -14
View File
@@ -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<CXCallEndedReason>.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
@@ -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),
@@ -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
}
}
}
@@ -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"
}
},
{
+9 -9
View File
@@ -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"
}
},
{
+3 -3
View File
@@ -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(