Security and privacy part 1 (#3617)

* added the security and settings button in details

* added content to the view

* added enable encryption alert

* updated preview tests and the UI

* removed wrong plists committed by mistake

* pr suggestions
This commit is contained in:
Mauro
2024-12-13 18:29:17 +01:00
committed by GitHub
parent e5da7ebd2d
commit b11fbc6cce
20 changed files with 398 additions and 16 deletions
@@ -405,6 +405,11 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
return .mediaEventsTimeline(previousState: fromState)
case (.mediaEventsTimeline(let previousState), .dismissMediaEventsTimeline):
return previousState
case (.roomDetails, .presentSecurityAndPrivacyScreen):
return .securityAndPrivacy(previousState: fromState)
case (.securityAndPrivacy(let previousState), .dismissSecurityAndPrivacyScreen):
return previousState
default:
return nil
@@ -571,6 +576,11 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
Task { await self.startMediaEventsTimelineFlow() }
case (.mediaEventsTimeline, .dismissMediaEventsTimeline, .roomDetails):
break
case (.roomDetails, .presentSecurityAndPrivacyScreen, .securityAndPrivacy):
presentSecurityAndPrivacyScreen()
case (.securityAndPrivacy, .dismissSecurityAndPrivacyScreen, .roomDetails):
break
// Child flow
case (_, .startChildFlow(let roomID, let via, let entryPoint), .presentingChild):
@@ -849,6 +859,8 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
stateMachine.tryEvent(.presentKnockRequestsListScreen)
case .presentMediaEventsTimeline:
stateMachine.tryEvent(.presentMediaEventsTimeline)
case .presentSecurityAndPrivacyScreen:
stateMachine.tryEvent(.presentSecurityAndPrivacyScreen)
}
}
.store(in: &cancellables)
@@ -1453,6 +1465,24 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
}
}
private func presentSecurityAndPrivacyScreen() {
let coordinator = SecurityAndPrivacyScreenCoordinator(parameters: .init(roomProxy: roomProxy))
coordinator.actionsPublisher.sink { [weak self] action in
guard let self else { return }
switch action {
case .done:
break
}
}
.store(in: &cancellables)
navigationStackCoordinator.push(coordinator) { [weak self] in
self?.stateMachine.tryEvent(.dismissSecurityAndPrivacyScreen)
}
}
// MARK: - Other flows
private func startChildFlow(for roomID: String, via: [String], entryPoint: RoomFlowCoordinatorEntryPoint) async {
@@ -1604,6 +1634,7 @@ private extension RoomFlowCoordinator {
case resolveSendFailure
case knockRequestsList(previousState: State)
case mediaEventsTimeline(previousState: State)
case securityAndPrivacy(previousState: State)
/// A child flow is in progress.
case presentingChild(childRoomID: String, previousState: State)
@@ -1687,6 +1718,9 @@ private extension RoomFlowCoordinator {
case presentMediaEventsTimeline
case dismissMediaEventsTimeline
case presentSecurityAndPrivacyScreen
case dismissSecurityAndPrivacyScreen
}
}
@@ -31,6 +31,7 @@ enum RoomDetailsScreenCoordinatorAction {
case presentPinnedEventsTimeline
case presentMediaEventsTimeline
case presentKnockingRequestsListScreen
case presentSecurityAndPrivacyScreen
}
final class RoomDetailsScreenCoordinator: CoordinatorProtocol {
@@ -85,6 +86,8 @@ final class RoomDetailsScreenCoordinator: CoordinatorProtocol {
actionsSubject.send(.presentMediaEventsTimeline)
case .displayKnockingRequests:
actionsSubject.send(.presentKnockingRequestsListScreen)
case .displaySecurityAndPrivacy:
actionsSubject.send(.presentSecurityAndPrivacyScreen)
}
}
.store(in: &cancellables)
@@ -24,6 +24,7 @@ enum RoomDetailsScreenViewModelAction {
case displayPinnedEventsTimeline
case displayMediaEventsTimeline
case displayKnockingRequests
case displaySecurityAndPrivacy
}
// MARK: View
@@ -56,6 +57,10 @@ struct RoomDetailsScreenViewState: BindableState {
knockingEnabled && dmRecipient == nil && isKnockableRoom && (canInviteUsers || canKickUsers || canBanUsers)
}
var canSeeSecurityAndPrivacy: Bool {
knockingEnabled && dmRecipient == nil && canEditRolesOrPermissions
}
var mediaBrowserEnabled = false
var canEdit: Bool {
@@ -198,6 +203,7 @@ enum RoomDetailsScreenViewAction {
case processTapPolls
case toggleFavourite(isFavourite: Bool)
case processTapRolesAndPermissions
case processTapSecurityAndPrivacy
case processTapCall
case processTapPinnedEvents
case processTapMediaEvents
@@ -172,6 +172,8 @@ class RoomDetailsScreenViewModel: RoomDetailsScreenViewModelType, RoomDetailsScr
actionsSubject.send(.displayMediaEventsTimeline)
case .processTapRequestsToJoin:
actionsSubject.send(.displayKnockingRequests)
case .processTapSecurityAndPrivacy:
actionsSubject.send(.displaySecurityAndPrivacy)
}
}
@@ -207,6 +207,14 @@ struct RoomDetailsScreen: View {
.onChange(of: context.isFavourite) { _, newValue in
context.send(viewAction: .toggleFavourite(isFavourite: newValue))
}
if context.viewState.canSeeSecurityAndPrivacy {
ListRow(label: .default(title: L10n.screenRoomDetailsSecurityAndPrivacyTitle,
icon: \.lock),
kind: .navigationLink {
context.send(viewAction: .processTapSecurityAndPrivacy)
})
}
}
}
@@ -0,0 +1,54 @@
//
// Copyright 2022-2024 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
//
import Combine
import SwiftUI
struct SecurityAndPrivacyScreenCoordinatorParameters {
let roomProxy: JoinedRoomProxyProtocol
}
enum SecurityAndPrivacyScreenCoordinatorAction {
case done
// Consider adding CustomStringConvertible conformance if the actions contain PII
}
final class SecurityAndPrivacyScreenCoordinator: CoordinatorProtocol {
private let parameters: SecurityAndPrivacyScreenCoordinatorParameters
private let viewModel: SecurityAndPrivacyScreenViewModelProtocol
private var cancellables = Set<AnyCancellable>()
private let actionsSubject: PassthroughSubject<SecurityAndPrivacyScreenCoordinatorAction, Never> = .init()
var actionsPublisher: AnyPublisher<SecurityAndPrivacyScreenCoordinatorAction, Never> {
actionsSubject.eraseToAnyPublisher()
}
init(parameters: SecurityAndPrivacyScreenCoordinatorParameters) {
self.parameters = parameters
viewModel = SecurityAndPrivacyScreenViewModel(roomProxy: parameters.roomProxy)
}
func start() {
viewModel.actionsPublisher.sink { [weak self] action in
MXLog.info("Coordinator: received view model action: \(action)")
guard let self else { return }
switch action {
case .done:
actionsSubject.send(.done)
}
}
.store(in: &cancellables)
}
func toPresentable() -> AnyView {
AnyView(SecurityAndPrivacyScreen(context: viewModel.context))
}
}
@@ -0,0 +1,54 @@
//
// Copyright 2022-2024 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
//
import Foundation
enum SecurityAndPrivacyScreenViewModelAction {
case done
}
struct SecurityAndPrivacyScreenViewState: BindableState {
var bindings: SecurityAndPrivacyScreenViewStateBindings
var currentSettings: SecurityAndPrivacySettings
var hasChanges: Bool {
currentSettings != bindings.desiredSettings
}
init(accessType: SecurityAndPrivacyRoomAccessType,
isEncryptionEnabled: Bool) {
let settings = SecurityAndPrivacySettings(accessType: accessType, isEncryptionEnabled: isEncryptionEnabled)
currentSettings = settings
bindings = SecurityAndPrivacyScreenViewStateBindings(desiredSettings: settings)
}
}
struct SecurityAndPrivacyScreenViewStateBindings {
var desiredSettings: SecurityAndPrivacySettings
var alertInfo: AlertInfo<SecurityAndPrivacyAlertType>?
}
struct SecurityAndPrivacySettings: Equatable {
var accessType: SecurityAndPrivacyRoomAccessType
var isEncryptionEnabled: Bool
}
enum SecurityAndPrivacyRoomAccessType {
case inviteOnly
case askToJoin
case anyone
}
enum SecurityAndPrivacyAlertType {
case enableEncryption
}
enum SecurityAndPrivacyScreenViewAction {
case save
case tryUpdatingEncryption(Bool)
}
@@ -0,0 +1,61 @@
//
// Copyright 2022-2024 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
//
import Combine
import SwiftUI
typealias SecurityAndPrivacyScreenViewModelType = StateStoreViewModel<SecurityAndPrivacyScreenViewState, SecurityAndPrivacyScreenViewAction>
class SecurityAndPrivacyScreenViewModel: SecurityAndPrivacyScreenViewModelType, SecurityAndPrivacyScreenViewModelProtocol {
private let roomProxy: JoinedRoomProxyProtocol
private let actionsSubject: PassthroughSubject<SecurityAndPrivacyScreenViewModelAction, Never> = .init()
var actionsPublisher: AnyPublisher<SecurityAndPrivacyScreenViewModelAction, Never> {
actionsSubject.eraseToAnyPublisher()
}
init(roomProxy: JoinedRoomProxyProtocol) {
self.roomProxy = roomProxy
super.init(initialViewState: SecurityAndPrivacyScreenViewState(accessType: roomProxy.infoPublisher.value.roomAccessType,
isEncryptionEnabled: roomProxy.isEncrypted))
}
// MARK: - Public
override func process(viewAction: SecurityAndPrivacyScreenViewAction) {
MXLog.info("View model: received view action: \(viewAction)")
switch viewAction {
case .save:
actionsSubject.send(.done)
case .tryUpdatingEncryption(let updatedValue):
if updatedValue {
state.bindings.alertInfo = .init(id: .enableEncryption,
title: L10n.screenSecurityAndPrivacyEnableEncryptionAlertTitle,
message: L10n.screenSecurityAndPrivacyEnableEncryptionAlertDescription,
primaryButton: .init(title: L10n.screenSecurityAndPrivacyEnableEncryptionAlertConfirmButtonTitle,
action: { [weak self] in self?.state.bindings.desiredSettings.isEncryptionEnabled = true }),
secondaryButton: .init(title: L10n.actionCancel, role: .cancel, action: nil))
} else {
state.bindings.desiredSettings.isEncryptionEnabled = false
}
}
}
}
private extension RoomInfoProxy {
var roomAccessType: SecurityAndPrivacyRoomAccessType {
switch joinRule {
case .invite, .restricted:
return .inviteOnly
case .knock, .knockRestricted:
return .askToJoin
default:
return .anyone
}
}
}
@@ -0,0 +1,14 @@
//
// Copyright 2022-2024 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
//
import Combine
@MainActor
protocol SecurityAndPrivacyScreenViewModelProtocol {
var actionsPublisher: AnyPublisher<SecurityAndPrivacyScreenViewModelAction, Never> { get }
var context: SecurityAndPrivacyScreenViewModelType.Context { get }
}
@@ -0,0 +1,87 @@
//
// Copyright 2022-2024 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
//
import Compound
import SwiftUI
struct SecurityAndPrivacyScreen: View {
@ObservedObject var context: SecurityAndPrivacyScreenViewModel.Context
var body: some View {
Form {
roomAccessSection
encryptionSection
}
.compoundList()
.navigationBarTitleDisplayMode(.inline)
.navigationTitle(L10n.screenSecurityAndPrivacyTitle)
.toolbar { toolbar }
.alert(item: $context.alertInfo)
}
private var roomAccessSection: some View {
Section {
ListRow(label: .plain(title: L10n.screenSecurityAndPrivacyRoomAccessInviteOnlyOptionTitle,
description: L10n.screenSecurityAndPrivacyRoomAccessInviteOnlyOptionDescription),
kind: .selection(isSelected: context.desiredSettings.accessType == .inviteOnly) { context.desiredSettings.accessType = .inviteOnly })
ListRow(label: .plain(title: L10n.screenSecurityAndPrivacyAskToJoinOptionTitle,
description: L10n.screenSecurityAndPrivacyAskToJoinOptionDescription),
kind: .selection(isSelected: context.desiredSettings.accessType == .askToJoin) { context.desiredSettings.accessType = .askToJoin })
ListRow(label: .plain(title: L10n.screenSecurityAndPrivacyRoomAccessAnyoneOptionTitle,
description: L10n.screenSecurityAndPrivacyRoomAccessAnyoneOptionDescription),
kind: .selection(isSelected: context.desiredSettings.accessType == .anyone) { context.desiredSettings.accessType = .anyone })
} header: {
Text(L10n.screenSecurityAndPrivacyRoomAccessSectionTitle)
.compoundListSectionHeader()
}
}
@ViewBuilder
private var encryptionSection: some View {
let encryptionBinding = Binding<Bool>(get: {
context.desiredSettings.isEncryptionEnabled
}, set: { newValue in
context.send(viewAction: .tryUpdatingEncryption(newValue))
})
Section {
ListRow(label: .plain(title: L10n.screenSecurityAndPrivacyEncryptionToggleTitle),
kind: .toggle(encryptionBinding))
// We don't allow editing the encryption state if the current setting on the server is `enabled`
.disabled(context.viewState.currentSettings.isEncryptionEnabled)
} header: {
Text(L10n.screenSecurityAndPrivacyEncryptionSectionTitle)
.compoundListSectionHeader()
} footer: {
Text(L10n.screenSecurityAndPrivacyEncryptionSectionFooter)
.compoundListSectionFooter()
}
}
@ToolbarContentBuilder
var toolbar: some ToolbarContent {
ToolbarItem(placement: .confirmationAction) {
Button(L10n.actionSave) {
context.send(viewAction: .save)
}
.disabled(!context.viewState.hasChanges)
}
}
}
// 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)))
static var previews: some View {
NavigationStack {
SecurityAndPrivacyScreen(context: inviteOnlyViewModel.context)
}
}
}