Security and privacy part 2 (#3637)

* handling the history visibility flag

* better logic to handle visibility

* better handling of the visibility options state

* added some copies, and the public room directory

visibility state

* completed the UI

added also the preview tests

* improved the handling of the directory visibility

* added the space users case

and improved handling of the access -> vsibility reaction. Also added a simple error handling for the public directory toggle

* added the edit room address view

but is missing its full implementation

* implement the UI for the edit room address screen

* implemented error checking

when editing the address

* updated preview tests and improved code

* typo fix

* Fix various issues after rebasing.

* Fix build errors and broken snapshot tests

* Adopt latest room privacy and canonical alias setting APIs

* Add support for creating and editing the room's alias.

* Add support for saving room privacy setting changes.

* Fix room alias screen snapshot tests following recent changes.

---------

Co-authored-by: Stefan Ceriu <stefanc@matrix.org>
This commit is contained in:
Mauro
2025-01-15 10:50:08 +01:00
committed by GitHub
parent cfb01d61a0
commit e3ad925285
61 changed files with 2216 additions and 136 deletions
@@ -10,16 +10,15 @@ import SwiftUI
struct SecurityAndPrivacyScreenCoordinatorParameters {
let roomProxy: JoinedRoomProxyProtocol
let clientProxy: ClientProxyProtocol
let userIndicatorController: UserIndicatorControllerProtocol
}
enum SecurityAndPrivacyScreenCoordinatorAction {
case done
// Consider adding CustomStringConvertible conformance if the actions contain PII
case displayEditAddressScreen
}
final class SecurityAndPrivacyScreenCoordinator: CoordinatorProtocol {
private let parameters: SecurityAndPrivacyScreenCoordinatorParameters
private let viewModel: SecurityAndPrivacyScreenViewModelProtocol
private var cancellables = Set<AnyCancellable>()
@@ -30,9 +29,9 @@ final class SecurityAndPrivacyScreenCoordinator: CoordinatorProtocol {
}
init(parameters: SecurityAndPrivacyScreenCoordinatorParameters) {
self.parameters = parameters
viewModel = SecurityAndPrivacyScreenViewModel(roomProxy: parameters.roomProxy)
viewModel = SecurityAndPrivacyScreenViewModel(roomProxy: parameters.roomProxy,
clientProxy: parameters.clientProxy,
userIndicatorController: parameters.userIndicatorController)
}
func start() {
@@ -41,8 +40,8 @@ final class SecurityAndPrivacyScreenCoordinator: CoordinatorProtocol {
guard let self else { return }
switch action {
case .done:
actionsSubject.send(.done)
case .displayEditAddressScreen:
actionsSubject.send(.displayEditAddressScreen)
}
}
.store(in: &cancellables)
@@ -8,21 +8,44 @@
import Foundation
enum SecurityAndPrivacyScreenViewModelAction {
case done
case displayEditAddressScreen
}
struct SecurityAndPrivacyScreenViewState: BindableState {
var bindings: SecurityAndPrivacyScreenViewStateBindings
let serverName: String
var currentSettings: SecurityAndPrivacySettings
var bindings: SecurityAndPrivacyScreenViewStateBindings
var canonicalAlias: String?
var hasChanges: Bool {
private var hasChanges: Bool {
currentSettings != bindings.desiredSettings
}
init(accessType: SecurityAndPrivacyRoomAccessType,
isEncryptionEnabled: Bool) {
let settings = SecurityAndPrivacySettings(accessType: accessType, isEncryptionEnabled: isEncryptionEnabled)
var isSaveDisabled: Bool {
!hasChanges ||
(currentSettings.isVisibileInRoomDirectory == nil &&
bindings.desiredSettings.accessType != .inviteOnly &&
canonicalAlias != nil)
}
var availableVisibilityOptions: [SecurityAndPrivacyHistoryVisibility] {
var options = [SecurityAndPrivacyHistoryVisibility.sinceSelection]
if !bindings.desiredSettings.isEncryptionEnabled, bindings.desiredSettings.accessType == .anyone {
options.append(.anyone)
} else {
options.append(.sinceInvite)
}
return options
}
init(serverName: String,
accessType: SecurityAndPrivacyRoomAccessType,
isEncryptionEnabled: Bool,
historyVisibility: SecurityAndPrivacyHistoryVisibility) {
self.serverName = serverName
let settings = SecurityAndPrivacySettings(accessType: accessType,
isEncryptionEnabled: isEncryptionEnabled,
historyVisibility: historyVisibility)
currentSettings = settings
bindings = SecurityAndPrivacyScreenViewStateBindings(desiredSettings: settings)
}
@@ -36,12 +59,15 @@ struct SecurityAndPrivacyScreenViewStateBindings {
struct SecurityAndPrivacySettings: Equatable {
var accessType: SecurityAndPrivacyRoomAccessType
var isEncryptionEnabled: Bool
var historyVisibility: SecurityAndPrivacyHistoryVisibility
var isVisibileInRoomDirectory: Bool?
}
enum SecurityAndPrivacyRoomAccessType {
case inviteOnly
case askToJoin
case anyone
case spaceUsers
}
enum SecurityAndPrivacyAlertType {
@@ -51,4 +77,20 @@ enum SecurityAndPrivacyAlertType {
enum SecurityAndPrivacyScreenViewAction {
case save
case tryUpdatingEncryption(Bool)
case editAddress
}
enum SecurityAndPrivacyHistoryVisibility {
case sinceSelection
case sinceInvite
case anyone
var fallbackOption: Self {
switch self {
case .sinceInvite, .sinceSelection:
return .sinceSelection
case .anyone:
return .sinceInvite
}
}
}
@@ -6,22 +6,34 @@
//
import Combine
import MatrixRustSDK
import SwiftUI
typealias SecurityAndPrivacyScreenViewModelType = StateStoreViewModel<SecurityAndPrivacyScreenViewState, SecurityAndPrivacyScreenViewAction>
class SecurityAndPrivacyScreenViewModel: SecurityAndPrivacyScreenViewModelType, SecurityAndPrivacyScreenViewModelProtocol {
private let roomProxy: JoinedRoomProxyProtocol
private let clientProxy: ClientProxyProtocol
private let userIndicatorController: UserIndicatorControllerProtocol
private let actionsSubject: PassthroughSubject<SecurityAndPrivacyScreenViewModelAction, Never> = .init()
var actionsPublisher: AnyPublisher<SecurityAndPrivacyScreenViewModelAction, Never> {
actionsSubject.eraseToAnyPublisher()
}
init(roomProxy: JoinedRoomProxyProtocol) {
init(roomProxy: JoinedRoomProxyProtocol,
clientProxy: ClientProxyProtocol,
userIndicatorController: UserIndicatorControllerProtocol) {
self.roomProxy = roomProxy
super.init(initialViewState: SecurityAndPrivacyScreenViewState(accessType: roomProxy.infoPublisher.value.roomAccessType,
isEncryptionEnabled: roomProxy.isEncrypted))
self.clientProxy = clientProxy
self.userIndicatorController = userIndicatorController
super.init(initialViewState: SecurityAndPrivacyScreenViewState(serverName: clientProxy.userIDServerName ?? "",
accessType: roomProxy.infoPublisher.value.joinRule.toSecurityAndPrivacyRoomAccessType,
isEncryptionEnabled: roomProxy.isEncrypted,
historyVisibility: roomProxy.infoPublisher.value.historyVisibility.toSecurityAndPrivacyHistoryVisibility))
setupRoomDirectoryVisibility()
setupSubscriptions()
}
// MARK: - Public
@@ -31,32 +43,184 @@ class SecurityAndPrivacyScreenViewModel: SecurityAndPrivacyScreenViewModelType,
switch viewAction {
case .save:
actionsSubject.send(.done)
Task {
await saveDesiredSettings()
}
case .tryUpdatingEncryption(let updatedValue):
if updatedValue {
state.bindings.alertInfo = .init(id: .enableEncryption,
title: L10n.screenSecurityAndPrivacyEnableEncryptionAlertTitle,
message: L10n.screenSecurityAndPrivacyEnableEncryptionAlertDescription,
primaryButton: .init(title: L10n.screenSecurityAndPrivacyEnableEncryptionAlertConfirmButtonTitle) { [weak self] in
self?.state.bindings.desiredSettings.isEncryptionEnabled = true
},
primaryButton: .init(title: L10n.screenSecurityAndPrivacyEnableEncryptionAlertConfirmButtonTitle) { [weak self] in self?.state.bindings.desiredSettings.isEncryptionEnabled = true },
secondaryButton: .init(title: L10n.actionCancel, role: .cancel, action: nil))
} else {
state.bindings.desiredSettings.isEncryptionEnabled = false
}
case .editAddress:
actionsSubject.send(.displayEditAddressScreen)
}
}
// MARK: - Private
private func setupSubscriptions() {
context.$viewState
.map(\.availableVisibilityOptions)
.removeDuplicates()
// To allow the view to update properly
.receive(on: DispatchQueue.main)
// When the available options changes always default to `sinceSelection` if the currently selected option is not available
.sink { [weak self] availableVisibilityOptions in
guard let self else { return }
let desiredHistoryVisibility = state.bindings.desiredSettings.historyVisibility
if !availableVisibilityOptions.contains(desiredHistoryVisibility) {
state.bindings.desiredSettings.historyVisibility = desiredHistoryVisibility.fallbackOption
}
}
.store(in: &cancellables)
roomProxy.infoPublisher
.map(\.canonicalAlias)
.removeDuplicates()
.receive(on: DispatchQueue.main)
.weakAssign(to: \.state.canonicalAlias, on: self)
.store(in: &cancellables)
}
private func setupRoomDirectoryVisibility() {
Task {
switch await roomProxy.isVisibleInRoomDirectory() {
case .success(let value):
state.bindings.desiredSettings.isVisibileInRoomDirectory = value
state.currentSettings.isVisibileInRoomDirectory = value
case .failure:
userIndicatorController.submitIndicator(.init(title: L10n.errorUnknown))
state.bindings.desiredSettings.isVisibileInRoomDirectory = false
state.currentSettings.isVisibileInRoomDirectory = false
}
}
}
private func saveDesiredSettings() async {
showLoadingIndicator()
defer {
hideLoadingIndicator()
}
if state.currentSettings.isEncryptionEnabled != state.bindings.desiredSettings.isEncryptionEnabled {
switch await roomProxy.enableEncryption() {
case .success:
state.currentSettings.isEncryptionEnabled = state.bindings.desiredSettings.isEncryptionEnabled
case .failure:
userIndicatorController.submitIndicator(.init(title: L10n.errorUnknown))
}
}
if state.currentSettings.historyVisibility != state.bindings.desiredSettings.historyVisibility {
switch await roomProxy.updateHistoryVisibility(state.bindings.desiredSettings.historyVisibility.toRoomHistoryVisibility) {
case .success:
state.currentSettings.historyVisibility = state.bindings.desiredSettings.historyVisibility
case .failure:
userIndicatorController.submitIndicator(.init(title: L10n.errorUnknown))
}
}
if state.currentSettings.accessType != state.bindings.desiredSettings.accessType {
// When a user changes join rules to something other than knock or public,
// the room should be automatically made invisible (private) in the room directory.
if state.currentSettings.accessType != .askToJoin, state.currentSettings.accessType != .anyone {
state.bindings.desiredSettings.isVisibileInRoomDirectory = false
}
switch await roomProxy.updateJoinRule(state.bindings.desiredSettings.accessType.toJoinRule) {
case .success:
state.currentSettings.accessType = state.bindings.desiredSettings.accessType
case .failure:
userIndicatorController.submitIndicator(.init(title: L10n.errorUnknown))
}
}
if state.currentSettings.isVisibileInRoomDirectory != state.bindings.desiredSettings.isVisibileInRoomDirectory {
let visibility: RoomVisibility = state.bindings.desiredSettings.isVisibileInRoomDirectory == true ? .public : .private
switch await roomProxy.updateRoomDirectoryVisibility(visibility) {
case .success:
state.currentSettings.isVisibileInRoomDirectory = state.bindings.desiredSettings.isVisibileInRoomDirectory
case .failure:
userIndicatorController.submitIndicator(.init(title: L10n.errorUnknown))
}
}
}
private static let loadingIndicatorIdentifier = "\(EditRoomAddressScreenViewModel.self)-Loading"
private func showLoadingIndicator() {
userIndicatorController.submitIndicator(UserIndicator(id: Self.loadingIndicatorIdentifier,
type: .modal,
title: L10n.commonLoading,
persistent: true))
}
private func hideLoadingIndicator() {
userIndicatorController.retractIndicatorWithId(Self.loadingIndicatorIdentifier)
}
}
private extension SecurityAndPrivacyRoomAccessType {
var toJoinRule: JoinRule {
switch self {
case .inviteOnly:
.invite
case .askToJoin:
.knock
case .anyone:
.public
case .spaceUsers:
fatalError("The user shouldn't be able to select this rule")
}
}
}
private extension RoomInfoProxy {
var roomAccessType: SecurityAndPrivacyRoomAccessType {
switch joinRule {
case .invite, .restricted:
private extension Optional where Wrapped == JoinRule {
var toSecurityAndPrivacyRoomAccessType: SecurityAndPrivacyRoomAccessType {
switch self {
case .none, .public:
return .anyone
case .invite:
return .inviteOnly
case .knock, .knockRestricted:
return .askToJoin
case .restricted:
return .spaceUsers
default:
return .inviteOnly
}
}
}
private extension RoomHistoryVisibility {
var toSecurityAndPrivacyHistoryVisibility: SecurityAndPrivacyHistoryVisibility {
switch self {
case .joined, .invited:
return .sinceInvite
case .shared, .custom:
return .sinceSelection
case .worldReadable:
return .anyone
}
}
}
private extension SecurityAndPrivacyHistoryVisibility {
var toRoomHistoryVisibility: RoomHistoryVisibility {
switch self {
case .sinceSelection:
return .shared
case .sinceInvite:
return .invited
case .anyone:
return .worldReadable
}
}
}
@@ -14,7 +14,18 @@ struct SecurityAndPrivacyScreen: View {
var body: some View {
Form {
roomAccessSection
if context.desiredSettings.accessType != .inviteOnly {
if let canonicalAlias = context.viewState.canonicalAlias {
visibilitySection
addressSection(canonicalAlias: canonicalAlias)
roomDirectoryVisibilitySection
} else {
publishingSection
addAddressSection
}
}
encryptionSection
historySection
}
.compoundList()
.navigationBarTitleDisplayMode(.inline)
@@ -34,6 +45,14 @@ struct SecurityAndPrivacyScreen: View {
ListRow(label: .plain(title: L10n.screenSecurityAndPrivacyRoomAccessAnyoneOptionTitle,
description: L10n.screenSecurityAndPrivacyRoomAccessAnyoneOptionDescription),
kind: .selection(isSelected: context.desiredSettings.accessType == .anyone) { context.desiredSettings.accessType = .anyone })
if context.viewState.currentSettings.accessType == .spaceUsers {
ListRow(label: .plain(title: L10n.screenSecurityAndPrivacyRoomAccessSpaceMembersOptionTitle,
description: L10n.screenSecurityAndPrivacyRoomAccessSpaceMembersOptionDescription),
kind: .selection(isSelected: context.desiredSettings.accessType == .spaceUsers) { })
// This is not supported so it will always be disabled and has no handler
.disabled(true)
}
} header: {
Text(L10n.screenSecurityAndPrivacyRoomAccessSectionHeader)
.compoundListSectionHeader()
@@ -42,7 +61,7 @@ struct SecurityAndPrivacyScreen: View {
@ViewBuilder
private var encryptionSection: some View {
let encryptionBinding = Binding<Bool>(get: {
let binding = Binding<Bool>(get: {
context.desiredSettings.isEncryptionEnabled
}, set: { newValue in
context.send(viewAction: .tryUpdatingEncryption(newValue))
@@ -50,7 +69,7 @@ struct SecurityAndPrivacyScreen: View {
Section {
ListRow(label: .plain(title: L10n.screenSecurityAndPrivacyEncryptionToggleTitle),
kind: .toggle(encryptionBinding))
kind: .toggle(binding))
// We don't allow editing the encryption state if the current setting on the server is `enabled`
.disabled(context.viewState.currentSettings.isEncryptionEnabled)
} header: {
@@ -62,26 +81,153 @@ struct SecurityAndPrivacyScreen: View {
}
}
private var historySection: some View {
Section {
ForEach(context.viewState.availableVisibilityOptions, id: \.self) { option in
switch option {
case .sinceSelection:
ListRow(label: .plain(title: L10n.screenSecurityAndPrivacyRoomHistorySinceSelectingOptionTitle),
kind: .selection(isSelected: context.desiredSettings.historyVisibility == .sinceSelection) { context.desiredSettings.historyVisibility = .sinceSelection })
case .anyone:
ListRow(label: .plain(title: L10n.screenSecurityAndPrivacyRoomHistoryAnyoneOptionTitle),
kind: .selection(isSelected: context.desiredSettings.historyVisibility == .anyone) { context.desiredSettings.historyVisibility = .anyone })
case .sinceInvite:
ListRow(label: .plain(title: L10n.screenSecurityAndPrivacyRoomHistorySinceInviteOptionTitle),
kind: .selection(isSelected: context.desiredSettings.historyVisibility == .sinceInvite) { context.desiredSettings.historyVisibility = .sinceInvite })
}
}
} header: {
Text(L10n.screenSecurityAndPrivacyRoomHistorySectionHeader)
.compoundListSectionHeader()
}
}
private var visibilitySection: some View {
Section {
EmptyView()
} header: {
Text(L10n.screenSecurityAndPrivacyRoomVisibilitySectionHeader)
.compoundListSectionHeader()
} footer: {
Text(L10n.screenSecurityAndPrivacyRoomVisibilitySectionFooter(context.viewState.serverName))
.compoundListSectionFooter()
}
}
private func addressSection(canonicalAlias: String) -> some View {
Section {
ListRow(label: .plain(title: canonicalAlias), kind: .navigationLink { context.send(viewAction: .editAddress) })
} header: {
Text(L10n.screenSecurityAndPrivacyRoomAddressSectionHeader)
.compoundListSectionHeader()
} footer: {
Text(L10n.screenSecurityAndPrivacyRoomAddressSectionFooter)
.compoundListSectionFooter()
}
}
private var publishingSection: some View {
Section {
EmptyView()
} header: {
Text(L10n.screenSecurityAndPrivacyRoomPublishingSectionHeader)
.compoundListSectionHeader()
} footer: {
Text(L10n.screenSecurityAndPrivacyRoomPublishingSectionFooter)
.compoundListSectionFooter()
}
}
private var addAddressSection: some View {
Section {
ListRow(kind: .custom {
Button(L10n.screenSecurityAndPrivacyAddRoomAddressAction) { context.send(viewAction: .editAddress) }
.foregroundColor(.compound.textActionAccent)
.padding(ListRowPadding.insets)
})
}
}
@ViewBuilder
private var roomDirectoryVisibilitySection: some View {
let binding = Binding<Bool>(get: {
context.desiredSettings.isVisibileInRoomDirectory ?? false
}, set: { newValue in
context.desiredSettings.isVisibileInRoomDirectory = newValue
})
Section {
ListRow(label: .plain(title: L10n.screenSecurityAndPrivacyRoomDirectoryVisibilityToggleTitle),
details: .isWaiting(context.desiredSettings.isVisibileInRoomDirectory == nil),
kind: context.desiredSettings.isVisibileInRoomDirectory == nil ? .label : .toggle(binding))
} footer: {
Text(L10n.screenSecurityAndPrivacyRoomDirectoryVisibilitySectionFooter(context.viewState.serverName))
.compoundListSectionFooter()
}
}
@ToolbarContentBuilder
var toolbar: some ToolbarContent {
private var toolbar: some ToolbarContent {
ToolbarItem(placement: .confirmationAction) {
Button(L10n.actionSave) {
context.send(viewAction: .save)
}
.disabled(!context.viewState.hasChanges)
.disabled(context.viewState.isSaveDisabled)
}
}
}
// MARK: - Previews
// TODO: Add back TestablePreview, this is WIP so running preview tests for it is not necessary
struct SecurityAndPrivacyScreen_Previews: PreviewProvider {
static let inviteOnlyViewModel = SecurityAndPrivacyScreenViewModel(roomProxy: JoinedRoomProxyMock(.init(joinRule: .invite)))
struct SecurityAndPrivacyScreen_Previews: PreviewProvider, TestablePreview {
static let inviteOnlyViewModel = SecurityAndPrivacyScreenViewModel(roomProxy: JoinedRoomProxyMock(.init(joinRule: .invite)),
clientProxy: ClientProxyMock(.init()),
userIndicatorController: UserIndicatorControllerMock())
static let publicViewModel = SecurityAndPrivacyScreenViewModel(roomProxy: JoinedRoomProxyMock(.init(isEncrypted: false,
canonicalAlias: "#room:matrix.org",
joinRule: .public,
isVisibleInPublicDirectory: true)),
clientProxy: ClientProxyMock(.init(userIDServerName: "matrix.org")),
userIndicatorController: UserIndicatorControllerMock())
static let publicNoAddressViewModel = SecurityAndPrivacyScreenViewModel(roomProxy: JoinedRoomProxyMock(.init(isEncrypted: false,
joinRule: .public)),
clientProxy: ClientProxyMock(.init(userIDServerName: "matrix.org")),
userIndicatorController: UserIndicatorControllerMock())
static let restrictedViewModel = SecurityAndPrivacyScreenViewModel(roomProxy: JoinedRoomProxyMock(.init(isEncrypted: false,
canonicalAlias: "#room:matrix.org",
joinRule: .restricted(rules: []),
isVisibleInPublicDirectory: true)),
clientProxy: ClientProxyMock(.init(userIDServerName: "matrix.org")),
userIndicatorController: UserIndicatorControllerMock())
static var previews: some View {
NavigationStack {
SecurityAndPrivacyScreen(context: inviteOnlyViewModel.context)
}
.previewDisplayName("Private invite only room")
NavigationStack {
SecurityAndPrivacyScreen(context: publicViewModel.context)
}
.snapshotPreferences(expect: publicViewModel.context.$viewState.map { state in
state.currentSettings.isVisibileInRoomDirectory == true
})
.previewDisplayName("Public room")
NavigationStack {
SecurityAndPrivacyScreen(context: publicNoAddressViewModel.context)
}
.previewDisplayName("Public room without address")
NavigationStack {
SecurityAndPrivacyScreen(context: restrictedViewModel.context)
}
.snapshotPreferences(expect: restrictedViewModel.context.$viewState.map { state in
state.currentSettings.isVisibileInRoomDirectory == true
})
.previewDisplayName("Restricted room")
}
}