Move any visible account settings into AccountSettings. (#6308)

* Remove the now unused setting to disable in-app notifications.

* Move visible account settings into AccountSettings.

Have ignored enableNotifications for now as that will require reworking the NotificationManager.

* Allow different sounds to be selected for each account.

* Move defaultNotificationTone into AccountSettings.
This commit is contained in:
Doug
2026-10-13 15:21:13 +01:00
committed by GitHub
parent 011087baf5
commit ac29354b08
37 changed files with 240 additions and 151 deletions
+3 -3
View File
@@ -28,9 +28,6 @@ final nonisolated class AppHooks: AppHooksProtocol {
@AppHook(default: DefaultOAuthPresenterHook())
var oAuthPresenterHook: OAuthPresenterHookProtocol
@AppHook(default: DefaultUserSessionHook())
var userSessionHook: UserSessionHookProtocol
@AppHook(default: DefaultRoomScreenHook())
var roomScreenHook: RoomScreenHookProtocol
@@ -64,6 +61,9 @@ final nonisolated class AppHooks: AppHooksProtocol {
@AppHook(default: DefaultRemoteSettingsHook())
var remoteSettingsHook: RemoteSettingsHookProtocol
@AppHook(default: DefaultUserSessionHook())
var userSessionHook: UserSessionHookProtocol
}
nonisolated protocol AppHooksProtocol: Sendable {
@@ -8,9 +8,19 @@
import MatrixRustSDK
nonisolated protocol UserSessionHookProtocol: Sendable {
#if IS_MAIN_APP
func configure(with userSession: UserSessionProtocol?) async
#endif
func configure(_ userSettings: UserSettings, forHomeserver homeserver: String) -> UserSettings
}
nonisolated struct DefaultUserSessionHook: UserSessionHookProtocol {
#if IS_MAIN_APP
func configure(with userSession: UserSessionProtocol?) async { }
#endif
func configure(_ userSettings: UserSettings, forHomeserver homeserver: String) -> UserSettings {
userSettings
}
}
@@ -467,10 +467,28 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg
MXLog.info("Migrating to version 25.07.4, log files have been moved.")
}
if oldVersion < Version(26, 10, 2) {
MXLog.info("Migrating to version 26.10.2, moving notification sounds.")
let soundsDirectory = URL.libraryDirectory.appending(component: "Sounds", directoryHint: .isDirectory)
// Sounds are now referenced directly, the duplicate file can go.
try? FileManager.default.removeItem(at: soundsDirectory.appending(component: "currentAlert.caf"))
let oldCustomTonesLocation = soundsDirectory.appending(component: "AvailableSounds", directoryHint: .isDirectory)
if FileManager.default.fileExists(atPath: oldCustomTonesLocation.path(percentEncoded: false)) {
do {
try FileManager.default.moveItem(at: oldCustomTonesLocation, to: URL.customTonesDirectory)
} catch {
MXLog.error("Failed moving custom notification sounds: \(error)")
}
}
}
// Store the old version to run additional migrations on the user session once it has been set up.
userSessionMigrationsOldVersion = oldVersion
}
// swiftlint:disable:next cyclomatic_complexity
private func performUserSessionMigrations(_ userSession: UserSessionProtocol) async {
guard let oldVersion = userSessionMigrationsOldVersion else { return }
@@ -515,6 +533,23 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg
userSession.userSettings.account.migrateAppSettingsValue(\.searchBreadcrumbsKey)
}
if oldVersion < Version(26, 10, 2) {
MXLog.info("Migrating to version 26.10.2, moving visible account settings.")
userSession.userSettings.account.migrateAppSettingsValue(\.showAllRoomListActivityKey)
userSession.userSettings.account.migrateAppSettingsValue(\.selectedNotificationToneKey)
userSession.userSettings.account.migrateAppSettingsValue(\.sharePresenceKey)
// Make sure system notification sounds are available in the new directory.
if let selectedNotificationTone = userSession.userSettings.account.selectedNotificationTone {
do {
MXLog.info("Copying the selected system notification sound.")
try NotificationToneManager.copySystemToneIfNeeded(selectedNotificationTone)
} catch {
MXLog.error("Failed migrating the selected notification sound: \(error)")
}
}
}
userSessionMigrationsOldVersion = nil
}
@@ -14,23 +14,34 @@ final nonisolated class AccountSettings: @unchecked Sendable {
private let store: AccountUserDefaults
private let userID: String
// MARK: Accounts
// MARK: Session Activity
/// When this account was last selected, used to order the accounts most recently selected first.
@UserPreference
var lastSelectedDate: Date?
// MARK: Session
@UserPreference(defaultValue: false)
var hasRunIdentityConfirmationOnboarding: Bool
// MARK: Search
/// The queries the user searched for and the rooms they opened from the results, most recent first.
@UserPreference(defaultValue: [SearchBreadcrumb]())
var searchBreadcrumbs: [SearchBreadcrumb]
// MARK: Visible Preferences
@UserPreference(defaultValue: true)
var showAllRoomListActivity: Bool
/// The tone played when the user hasn't selected one, which the homeserver can configure remotely.
let defaultNotificationTone: RemotePreference<NotificationTone> = .init(.default)
/// The sound played when delivering noisy notifications. If nil, use ``defaultNotificationTone``.
@UserPreference
var selectedNotificationTone: NotificationTone?
@UserPreference(defaultValue: true)
var sharePresence: Bool
init(userID: String, store: UserDefaultsProtocol) {
self.store = .init(userID: userID, store: store)
self.userID = userID
@@ -235,9 +235,6 @@ final nonisolated class AppSettings: @unchecked Sendable {
@UserPreference(defaultValue: true)
var enableNotifications: Bool
@UserPreference(defaultValue: true)
var enableInAppNotifications: Bool
@UserPreference(defaultValue: false)
var hideQuietNotificationAlerts: Bool
@@ -253,10 +250,6 @@ final nonisolated class AppSettings: @unchecked Sendable {
@UserPreference(defaultValue: 0)
var lastKnownBadgeCount: Int
/// The sound played when delivering noisy notifications. If nil, use the ElementX default
@UserPreference
var selectedNotificationTone: NotificationTone?
// MARK: - Logging
@UserPreference(defaultValue: LogLevel.info)
@@ -331,11 +324,6 @@ final nonisolated class AppSettings: @unchecked Sendable {
@UserPreference(defaultValue: false)
var liveLocationDisclaimerDisplayed: Bool
// MARK: - Home Screen
@UserPreference(defaultValue: true)
var showAllRoomListActivity: Bool
// MARK: - Room Screen
@UserPreference(defaultValue: AppBuildType.current == .debug)
@@ -382,11 +370,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
@@ -8692,6 +8692,11 @@ nonisolated class MediaProviderMock: MediaProviderProtocol, @unchecked Sendable
}
}
nonisolated class NSEUserSessionMock: NSEUserSessionProtocol, @unchecked Sendable {
var userSettings: UserSettings {
get { return underlyingUserSettings }
set(value) { underlyingUserSettings = value }
}
nonisolated(unsafe) var underlyingUserSettings: UserSettings!
nonisolated(unsafe) var inviteAvatarsVisibilityCallsCount = 0
var inviteAvatarsVisibilityCalled: Bool {
return inviteAvatarsVisibilityCallsCount > 0
@@ -8,6 +8,7 @@
import MatrixRustSDK
struct NSEUserSessionMockConfiguration {
var userSettings = UserSettings.mock()
var inviteAvatarsVisibility = InviteAvatars.on
var mediaPreviewVisibility = MediaPreviews.on
var threadsEnabled = true
@@ -17,6 +18,7 @@ extension NSEUserSessionMock {
convenience init(_ configuration: NSEUserSessionMockConfiguration) {
self.init()
underlyingUserSettings = configuration.userSettings
underlyingInviteAvatarsVisibility = configuration.inviteAvatarsVisibility
underlyingMediaPreviewVisibility = configuration.mediaPreviewVisibility
threadsEnabled = configuration.threadsEnabled
@@ -103,6 +103,16 @@ nonisolated extension URL {
return systemRoot.appending(components: "System", "Library", "Audio", "UISounds", directoryHint: .isDirectory)
}()
/// The directory holding notification tones imported by the user, inside the Library/Sounds directory searched by the system.
static var customTonesDirectory: URL {
libraryDirectory.appending(components: "Sounds", "Custom", directoryHint: .isDirectory)
}
/// The directory holding copies of selected system tones, inside the Library/Sounds directory searched by the system.
static var systemTonesDirectory: URL {
libraryDirectory.appending(components: "Sounds", "System", directoryHint: .isDirectory)
}
var globalProxy: String? {
guard let proxySettings = CFNetworkCopySystemProxySettings()?.takeRetainedValue() else {
MXLog.error("Failed retrieving proxy settings")
@@ -115,7 +115,7 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol
.weakAssign(to: \.state.selectedRoomID, on: self)
.store(in: &cancellables)
userSettings.app.showAllRoomListActivityPublisher
userSettings.account.showAllRoomListActivityPublisher
.removeDuplicates()
.sink { [weak self] _ in
self?.updateRooms()
@@ -277,7 +277,7 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol
case .success:
analyticsService.trackInteraction(name: .MobileRoomListRoomContextMenuUnreadToggle)
if case .failure(let error) = await roomProxy.markAsRead(receiptType: userSettings.app.sharePresence ? .read : .readPrivate) {
if case .failure(let error) = await roomProxy.markAsRead(receiptType: userSettings.account.sharePresence ? .read : .readPrivate) {
MXLog.error("Failed marking room \(roomIdentifier) as read with error: \(error)")
}
case .failure(let error):
@@ -395,7 +395,7 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol
for summary in roomSummaryProvider.roomListPublisher.value {
let room = HomeScreenRoom(summary: summary,
showAllActivity: userSettings.app.showAllRoomListActivity,
showAllActivity: userSettings.account.showAllRoomListActivity,
seenInvites: seenInvites)
rooms.append(room)
}
@@ -127,7 +127,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
Task {
// When navigating away from the room, we need to mark the room as both read
// and fully read for Synapse to clear this room from the app's badge count.
_ = await roomProxy.markAsRead(receiptType: userSettings.app.sharePresence ? .read : .readPrivate)
_ = await roomProxy.markAsRead(receiptType: userSettings.account.sharePresence ? .read : .readPrivate)
_ = await roomProxy.markAsRead(receiptType: .fullyRead)
}
// Work around QLPreviewController dismissal issues, see the InteractiveQuickLookModifier.
@@ -32,8 +32,8 @@ struct ModerationAndSafetySettingsScreenViewStateBindings {
}
var sharePresence: Bool {
get { userSettings.app.sharePresence }
set { userSettings.app.sharePresence = newValue }
get { userSettings.account.sharePresence }
set { userSettings.account.sharePresence = newValue }
}
}
@@ -39,10 +39,10 @@ class NotificationSettingsScreenViewModel: NotificationSettingsScreenViewModelTy
self.notificationToneManager = notificationToneManager
let bindings = NotificationSettingsScreenViewStateBindings(enableNotifications: userSettings.app.enableNotifications,
showAllRoomListActivity: userSettings.app.showAllRoomListActivity)
showAllRoomListActivity: userSettings.account.showAllRoomListActivity)
super.init(initialViewState: NotificationSettingsScreenViewState(bindings: bindings,
isModallyPresented: isModallyPresented,
selectedAlertTone: userSettings.app.selectedNotificationTone ?? NotificationToneManager.defaultElementXMessageTone,
selectedAlertTone: userSettings.account.selectedNotificationTone ?? userSettings.account.defaultNotificationTone.publisher.value,
availableCustomTones: notificationToneManager.customTones(),
// macos lacks default sounds and its sandbox Sounds directory is immutable
customToneSelectionEnabled: !ProcessInfo.processInfo.isiOSAppOnMac))
@@ -52,8 +52,8 @@ class NotificationSettingsScreenViewModel: NotificationSettingsScreenViewModelTy
.weakAssign(to: \.state.bindings.enableNotifications, on: self)
.store(in: &cancellables)
userSettings.app.selectedNotificationTonePublisher
.map { $0 ?? NotificationToneManager.defaultElementXMessageTone }
userSettings.account.selectedNotificationTonePublisher
.map { $0 ?? userSettings.account.defaultNotificationTone.publisher.value }
.weakAssign(to: \.state.selectedAlertTone, on: self)
.store(in: &cancellables)
@@ -104,7 +104,7 @@ class NotificationSettingsScreenViewModel: NotificationSettingsScreenViewModelTy
}
Task { await enableInvitations(state.bindings.invitationsEnabled) }
case .showAllRoomListActivityChanged:
userSettings.app.showAllRoomListActivity = state.bindings.showAllRoomListActivity
userSettings.account.showAllRoomListActivity = state.bindings.showAllRoomListActivity
case .close:
actionsSubject.send(.close)
case .fixConfigurationMismatchTapped:
@@ -302,7 +302,7 @@ class NotificationSettingsScreenViewModel: NotificationSettingsScreenViewModelTy
try notificationToneManager.deleteCustomTone(tone)
if tone == state.selectedAlertTone {
userSettings.app.selectedNotificationTone = nil
userSettings.account.selectedNotificationTone = nil
}
} catch {
MXLog.error("Error deleting alert tone \(tone.label): \(error)")
@@ -272,7 +272,7 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
case .voiceMessage(let voiceMessageAction):
processVoiceMessageAction(voiceMessageAction)
case .contentChanged(let isEmpty):
guard userSettings.app.sharePresence else {
guard userSettings.account.sharePresence else {
return
}
@@ -508,7 +508,7 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
roomProxy.typingMembersPublisher
.receive(on: DispatchQueue.main)
.filter { [weak self] _ in self?.userSettings.app.sharePresence ?? false }
.filter { [weak self] _ in self?.userSettings.account.sharePresence ?? false }
.weakAssign(to: \.state.typingMembers, on: self)
.store(in: &cancellables)
@@ -572,7 +572,7 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
}
private func setupUserSettingsSubscriptions() {
userSettings.app.sharePresencePublisher
userSettings.account.sharePresencePublisher
.weakAssign(to: \.state.showReadReceipts, on: self)
.store(in: &cancellables)
@@ -43,6 +43,9 @@ nonisolated struct NotificationTone: Hashable, Comparable, Codable {
case appLibrary
}
/// The default bundled message tone.
static let `default`: NotificationTone = .createBundledSound(label: L10n.screenNotificationSettingsSoundElementDefault, filename: "message.caf")
/// Creates a tone backed by a file in the system sounds directory.
static func createSystemSound(label: String?, filename: String, systemSoundsSubdirectory: [String] = []) -> NotificationTone {
NotificationTone(labelOverride: label, storageLocationRoot: .system, relativePath: systemSoundsSubdirectory + [filename])
@@ -31,26 +31,40 @@ nonisolated struct NotificationToneManager: NotificationToneManagerProtocol {
private let userSettings: UserSettings
/// The default Element X bundled message tone.
static let defaultElementXMessageTone: NotificationTone = .createBundledSound(label: L10n.screenNotificationSettingsSoundElementDefault,
filename: "message.caf")
/// All default tones (system + Element X), sorted by name.
static let allDefaultAlerts: [NotificationTone] = (defaultSystemAlerts + defaultElementXAlerts).sorted()
static let allDefaultAlerts: [NotificationTone] = (defaultSystemAlerts + defaultBundledAlerts).sorted()
/// Filename of the active tone file used by the notification service.
static let selectedToneFilename = "currentAlert.caf"
/// The name the notification service uses to find the tone's file in the app bundle or Library/Sounds.
///
/// Note: Names with a subdirectory aren't documented for `UNNotificationSound(named:)` but do work.
static func soundName(for tone: NotificationTone) -> String {
switch tone.storageLocationRoot {
case .appBundle:
tone.filename
case .appLibrary:
"\(URL.customTonesDirectory.lastPathComponent)/\(tone.filename)"
case .system:
"\(URL.systemTonesDirectory.lastPathComponent)/\(tone.filename)"
}
}
/// Directory where user-imported custom tones are stored.
nonisolated static let libraryLocation = URL.libraryDirectory.appending(components: "Sounds", "AvailableSounds", directoryHint: .isDirectory)
/// Copies a system tone into Library/Sounds, as the system sounds directory isn't searched for notification sounds.
static func copySystemToneIfNeeded(_ tone: NotificationTone) throws {
guard tone.storageLocationRoot == .system else { return }
let copyLocation = URL.systemTonesDirectory.appending(component: tone.filename)
guard (try? copyLocation.checkResourceIsReachable()) != true else { return }
try FileManager.default.createDirectory(at: URL.systemTonesDirectory, withIntermediateDirectories: true)
try FileManager.default.copyItem(at: toneLocation(for: tone), to: copyLocation)
}
/// Creates the manager and ensures required library directories exist.
init(userSettings: UserSettings) {
self.userSettings = userSettings
do {
try FileManager.default.createDirectory(at: NotificationToneManager.libraryLocation, withIntermediateDirectories: true)
try FileManager.default.createDirectory(at: Self.selectedToneLocation.deletingLastPathComponent(), withIntermediateDirectories: true)
try FileManager.default.createDirectory(at: URL.customTonesDirectory, withIntermediateDirectories: true)
} catch {
// Don't crash on a recoverable file system error. The underlying problem (directory creation
// failing on launch, e.g. a background launch before the container is writable) is acknowledged
@@ -60,29 +74,17 @@ nonisolated struct NotificationToneManager: NotificationToneManagerProtocol {
}
/// Sets the given tone as the active notification alert tone.
///
/// Copies the tone's audio file to `selectedToneLocation` and persists the selection in app settings.
func setSelectedTone(_ alertTone: NotificationTone) throws -> URL {
do {
try? FileManager.default.removeItem(at: Self.selectedToneLocation)
let toneLocation = Self.toneLocation(for: alertTone)
try FileManager.default.copyItem(at: toneLocation, to: Self.selectedToneLocation)
userSettings.app.selectedNotificationTone = alertTone
return Self.selectedToneLocation
} catch {
if (try? Self.selectedToneLocation.checkResourceIsReachable()) != true {
// make sure the selected tone is reset if there's no custom tone present
userSettings.app.selectedNotificationTone = nil
}
throw error
}
try Self.copySystemToneIfNeeded(alertTone)
userSettings.account.selectedNotificationTone = alertTone
return Self.toneLocation(for: alertTone)
}
/// Returns all user-imported CAF tones from the library directory, sorted by name.
func customTones() -> [NotificationTone] {
let availableFiles = try? FileManager
.default
.contentsOfDirectory(at: NotificationToneManager.libraryLocation, includingPropertiesForKeys: nil)
.contentsOfDirectory(at: URL.customTonesDirectory, includingPropertiesForKeys: nil)
return (availableFiles ?? [])
.compactMap {
@@ -100,7 +102,7 @@ nonisolated struct NotificationToneManager: NotificationToneManagerProtocol {
@discardableResult
func addNewToneToLibrary(from sourceURL: URL) throws -> URL {
let baseName = sourceURL.deletingPathExtension().lastPathComponent
let outputURL = NotificationToneManager.libraryLocation.appending(component: baseName).appendingPathExtension("caf")
let outputURL = URL.customTonesDirectory.appending(component: baseName).appendingPathExtension("caf")
guard (try? outputURL.checkResourceIsReachable()) != true else {
throw ManagerError.fileAlreadyExists
@@ -119,7 +121,7 @@ nonisolated struct NotificationToneManager: NotificationToneManagerProtocol {
/// - Throws: `DeletionError.notACustomTone` if the tone is not stored in the library directory.
func deleteCustomTone(_ alertTone: NotificationTone) throws {
let toneLocation = Self.toneLocation(for: alertTone)
guard toneLocation.deletingLastPathComponent() == NotificationToneManager.libraryLocation else {
guard toneLocation.deletingLastPathComponent() == URL.customTonesDirectory else {
throw ManagerError.notACustomTone
}
@@ -172,9 +174,6 @@ nonisolated struct NotificationToneManager: NotificationToneManagerProtocol {
MXLog.info("Converted \(sourceURL.path(percentEncoded: false)) to caf")
}
/// File URL of the active tone copied/linked for use by the system.
private static let selectedToneLocation = NotificationToneManager.libraryLocation.deletingLastPathComponent().appending(component: selectedToneFilename)
/// Pre-defined iOS system tones available for selection, sorted by name.
private static let defaultSystemAlerts: [NotificationTone] = [
.createSystemSound(label: L10n.screenNotificationSettingsSoundSystemTriToneIos,
@@ -256,9 +255,9 @@ nonisolated struct NotificationToneManager: NotificationToneManagerProtocol {
}
.sorted()
/// Element X bundled tones available for selection, sorted by name.
private static let defaultElementXAlerts: [NotificationTone] = [
defaultElementXMessageTone,
/// Bundled tones available for selection, sorted by name.
private static let defaultBundledAlerts: [NotificationTone] = [
.default,
.createBundledSound(label: L10n.screenNotificationSettingsSoundElementFade,
filename: "sound_01.caf")
].sorted()
@@ -280,7 +279,7 @@ nonisolated struct NotificationToneManager: NotificationToneManagerProtocol {
case .appBundle:
root = Self.bundledLocation
case .appLibrary:
root = Self.libraryLocation
root = URL.customTonesDirectory
}
return tone.relativePath.reduce(root) {
@@ -235,9 +235,6 @@ final class NotificationManager: NSObject, NotificationManagerProtocol {
extension NotificationManager: UNUserNotificationCenterDelegate {
func userNotificationCenter(_ center: UNUserNotificationCenter,
willPresent notification: UNNotification) async -> UNNotificationPresentationOptions {
guard appSettings.enableInAppNotifications else {
return []
}
guard let delegate else {
return [.badge, .sound, .list, .banner]
}
@@ -41,7 +41,7 @@ final class PresenceService {
}
private var desiredPresence: ClientProxyPresence {
switch (isForegroundActive, userSettings.app.sharePresence) {
switch (isForegroundActive, userSettings.account.sharePresence) {
case (true, true):
.online
case (true, false):
@@ -68,7 +68,7 @@ final class PresenceService {
}
private func observeSharePresence() {
userSettings.app.sharePresencePublisher
userSettings.account.sharePresencePublisher
.removeDuplicates()
.sink { [weak self] _ in
self?.reportCurrentState()
@@ -128,7 +128,7 @@ class TimelineController: TimelineControllerProtocol {
}
func sendReadReceipt(for itemID: TimelineItemIdentifier) async {
let receiptType: MatrixRustSDK.ReceiptType = userSettings.app.sharePresence ? .read : .readPrivate
let receiptType: MatrixRustSDK.ReceiptType = userSettings.account.sharePresence ? .read : .readPrivate
guard let eventID = itemID.eventID else {
return
@@ -57,7 +57,8 @@ class UserSessionStore: UserSessionStoreProtocol {
return .failure(.unknownAccount)
}
let userSettings = appSettings.userSettings(for: credentials.userID)
let userSettings = appHooks.userSessionHook.configure(appSettings.userSettings(for: credentials.userID),
forHomeserver: credentials.restorationToken.session.homeserverUrl)
switch await restorePreviousLogin(credentials, userSettings: userSettings) {
case .success(let clientProxy):
@@ -81,7 +82,7 @@ class UserSessionStore: UserSessionStoreProtocol {
do {
let session = try client.session()
let userID = try client.userId()
let userSettings = appSettings.userSettings(for: userID)
let userSettings = appHooks.userSessionHook.configure(appSettings.userSettings(for: userID), forHomeserver: client.homeserver())
let clientProxy = try await setupProxyForClient(client, userSettings: userSettings)
keychainController.setRestorationToken(RestorationToken(session: session,