mirror of
https://github.com/lux-tel/session-ios.git
synced 2026-10-30 20:38:32 +00:00
624 lines
28 KiB
Swift
624 lines
28 KiB
Swift
// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved.
|
|
//
|
|
// stringlint:disable
|
|
|
|
import Foundation
|
|
import Combine
|
|
import GRDB
|
|
import DifferenceKit
|
|
import SessionUIKit
|
|
import SessionNetworkingKit
|
|
import SessionMessagingKit
|
|
import SessionUtilitiesKit
|
|
|
|
class DeveloperSettingsFileServerViewModel: SessionTableViewModel, NavigatableStateHolder, ObservableTableSource {
|
|
public let dependencies: Dependencies
|
|
public let navigatableState: NavigatableState = NavigatableState()
|
|
public let state: TableDataState<Section, TableItem> = TableDataState()
|
|
public let observableState: ObservableTableSourceState<Section, TableItem> = ObservableTableSourceState()
|
|
|
|
private var shareDownloadedFileUrl: String?
|
|
private var shareDownloadedFileMimetype: String?
|
|
private var updatedCustomServerUrl: String?
|
|
private var updatedCustomServerPubkey: String?
|
|
|
|
/// This value is the current state of the view
|
|
@MainActor @Published private(set) var internalState: State
|
|
private var observationTask: Task<Void, Never>?
|
|
|
|
// MARK: - Initialization
|
|
|
|
@MainActor init(using dependencies: Dependencies) {
|
|
self.dependencies = dependencies
|
|
self.internalState = State.initialState(using: dependencies)
|
|
|
|
/// Bind the state
|
|
self.observationTask = ObservationBuilder
|
|
.initialValue(self.internalState)
|
|
.debounce(for: .never)
|
|
.using(dependencies: dependencies)
|
|
.query(DeveloperSettingsFileServerViewModel.queryState)
|
|
.assign { [weak self] updatedState in
|
|
guard let self = self else { return }
|
|
|
|
// FIXME: To slightly reduce the size of the changes this new observation mechanism is currently wired into the old SessionTableViewController observation mechanism, we should refactor it so everything uses the new mechanism
|
|
let oldState: State = self.internalState
|
|
self.internalState = updatedState
|
|
self.pendingTableDataSubject.send(updatedState.sections(viewModel: self, previousState: oldState))
|
|
}
|
|
}
|
|
|
|
// MARK: - Config
|
|
|
|
public enum Section: SessionTableSection {
|
|
case general
|
|
|
|
var title: String? {
|
|
switch self {
|
|
case .general: return nil
|
|
}
|
|
}
|
|
|
|
var style: SessionTableSectionStyle {
|
|
switch self {
|
|
case .general: return .padding
|
|
}
|
|
}
|
|
}
|
|
|
|
public enum TableItem: Hashable, Differentiable, CaseIterable {
|
|
case shortenFileTTL
|
|
case deterministicAttachmentEncryption
|
|
case shareDownloadedFile
|
|
case customFileServerUrl
|
|
case customFileServerPubkey
|
|
|
|
// MARK: - Conformance
|
|
|
|
public typealias DifferenceIdentifier = String
|
|
|
|
public var differenceIdentifier: String {
|
|
switch self {
|
|
case .shortenFileTTL: return "shortenFileTTL"
|
|
case .deterministicAttachmentEncryption: return "deterministicAttachmentEncryption"
|
|
case .shareDownloadedFile: return "shareDownloadedFile"
|
|
case .customFileServerUrl: return "customFileServerUrl"
|
|
case .customFileServerPubkey: return "customFileServerPubkey"
|
|
}
|
|
}
|
|
|
|
public func isContentEqual(to source: TableItem) -> Bool {
|
|
self.differenceIdentifier == source.differenceIdentifier
|
|
}
|
|
|
|
public static var allCases: [TableItem] {
|
|
var result: [TableItem] = []
|
|
switch TableItem.shortenFileTTL {
|
|
case .shortenFileTTL: result.append(.shortenFileTTL); fallthrough
|
|
case .deterministicAttachmentEncryption: result.append(.deterministicAttachmentEncryption); fallthrough
|
|
case .shareDownloadedFile: result.append(.shareDownloadedFile); fallthrough
|
|
case .customFileServerUrl: result.append(.customFileServerUrl); fallthrough
|
|
case .customFileServerPubkey: result.append(.customFileServerPubkey)
|
|
}
|
|
|
|
return result
|
|
}
|
|
}
|
|
|
|
// MARK: - Content
|
|
|
|
public struct State: Equatable, ObservableKeyProvider {
|
|
struct Info: Equatable, Hashable {
|
|
let shortenFileTTL: Bool
|
|
let deterministicAttachmentEncryption: Bool
|
|
let customFileServer: Network.FileServer.Custom
|
|
|
|
public func with(
|
|
shortenFileTTL: Bool? = nil,
|
|
deterministicAttachmentEncryption: Bool? = nil,
|
|
customFileServer: Network.FileServer.Custom? = nil
|
|
) -> Info {
|
|
return Info(
|
|
shortenFileTTL: (shortenFileTTL ?? self.shortenFileTTL),
|
|
deterministicAttachmentEncryption: (deterministicAttachmentEncryption ?? self.deterministicAttachmentEncryption),
|
|
customFileServer: (customFileServer ?? self.customFileServer)
|
|
)
|
|
}
|
|
}
|
|
|
|
let initialState: Info
|
|
let pendingState: Info
|
|
|
|
@MainActor public func sections(viewModel: DeveloperSettingsFileServerViewModel, previousState: State) -> [SectionModel] {
|
|
DeveloperSettingsFileServerViewModel.sections(
|
|
state: self,
|
|
previousState: previousState,
|
|
viewModel: viewModel
|
|
)
|
|
}
|
|
|
|
public let observedKeys: Set<ObservableKey> = [
|
|
.updateScreen(DeveloperSettingsFileServerViewModel.self),
|
|
.feature(.shortenFileTTL),
|
|
.feature(.deterministicAttachmentEncryption),
|
|
.feature(.customFileServer)
|
|
]
|
|
|
|
static func initialState(using dependencies: Dependencies) -> State {
|
|
let initialInfo: Info = Info(
|
|
shortenFileTTL: dependencies[feature: .shortenFileTTL],
|
|
deterministicAttachmentEncryption: dependencies[feature: .deterministicAttachmentEncryption],
|
|
customFileServer: dependencies[feature: .customFileServer]
|
|
)
|
|
|
|
return State(
|
|
initialState: initialInfo,
|
|
pendingState: initialInfo
|
|
)
|
|
}
|
|
}
|
|
|
|
let title: String = "Developer File Server Settings"
|
|
|
|
lazy var footerButtonInfo: AnyPublisher<SessionButton.Info?, Never> = $internalState
|
|
.map { [weak self] state -> SessionButton.Info? in
|
|
return SessionButton.Info(
|
|
style: .bordered,
|
|
title: "set".localized(),
|
|
isEnabled: {
|
|
guard state.initialState != state.pendingState else { return false }
|
|
|
|
return (
|
|
state.pendingState.customFileServer.isEmpty ||
|
|
state.pendingState.customFileServer.isValid
|
|
)
|
|
}(),
|
|
accessibility: Accessibility(
|
|
identifier: "Set button",
|
|
label: "Set button"
|
|
),
|
|
minWidth: 110,
|
|
onTap: { [weak self] in
|
|
Task { [weak self] in
|
|
await self?.saveChanges()
|
|
}
|
|
}
|
|
)
|
|
}
|
|
.eraseToAnyPublisher()
|
|
|
|
@Sendable private static func queryState(
|
|
previousState: State,
|
|
events: [ObservedEvent],
|
|
isInitialQuery: Bool,
|
|
using dependencies: Dependencies
|
|
) async -> State {
|
|
return State(
|
|
initialState: previousState.initialState,
|
|
pendingState: (events.first?.value as? State.Info ?? previousState.pendingState)
|
|
)
|
|
}
|
|
|
|
private static func sections(
|
|
state: State,
|
|
previousState: State,
|
|
viewModel: DeveloperSettingsFileServerViewModel
|
|
) -> [SectionModel] {
|
|
let general: SectionModel = SectionModel(
|
|
model: .general,
|
|
elements: [
|
|
SessionCell.Info(
|
|
id: .shortenFileTTL,
|
|
title: "Shorten File TTL",
|
|
subtitle: "Set the TTL for files in the cache to 1 minute",
|
|
trailingAccessory: .toggle(
|
|
state.pendingState.shortenFileTTL,
|
|
oldValue: previousState.pendingState.shortenFileTTL
|
|
),
|
|
onTap: { [dependencies = viewModel.dependencies] in
|
|
dependencies.notifyAsync(
|
|
priority: .immediate,
|
|
key: .updateScreen(DeveloperSettingsFileServerViewModel.self),
|
|
value: state.pendingState.with(
|
|
shortenFileTTL: !state.pendingState.shortenFileTTL
|
|
)
|
|
)
|
|
}
|
|
),
|
|
SessionCell.Info(
|
|
id: .deterministicAttachmentEncryption,
|
|
title: "Deterministic Attachment Encryption",
|
|
subtitle: """
|
|
Controls whether the new deterministic encryption should be used for attachment and display pictures
|
|
|
|
<warn>Warning: Old clients won't be able to decrypt attachments sent while this is enabled</warn>
|
|
""",
|
|
trailingAccessory: .toggle(
|
|
state.pendingState.deterministicAttachmentEncryption,
|
|
oldValue: previousState.pendingState.deterministicAttachmentEncryption
|
|
),
|
|
onTap: { [dependencies = viewModel.dependencies] in
|
|
dependencies.notifyAsync(
|
|
priority: .immediate,
|
|
key: .updateScreen(DeveloperSettingsFileServerViewModel.self),
|
|
value: state.pendingState.with(
|
|
deterministicAttachmentEncryption: !state.pendingState.deterministicAttachmentEncryption
|
|
)
|
|
)
|
|
}
|
|
),
|
|
SessionCell.Info(
|
|
id: .shareDownloadedFile,
|
|
title: "Share Downloaded File",
|
|
subtitle: """
|
|
Share the downloaded file of a given URL if it exists via the native share sheet
|
|
""",
|
|
trailingAccessory: .icon(.share),
|
|
onTap: { [weak viewModel] in
|
|
viewModel?.showShareFileModal()
|
|
}
|
|
),
|
|
SessionCell.Info(
|
|
id: .customFileServerUrl,
|
|
title: "Custom File Server URL",
|
|
subtitle: """
|
|
The URL to use instead of the default File Server for uploading files
|
|
|
|
<b>Current:</b> <span>\(state.pendingState.customFileServer.url.isEmpty ? "Default" : state.pendingState.customFileServer.url)</span>
|
|
""",
|
|
trailingAccessory: .icon(.squarePen),
|
|
onTap: { [weak viewModel] in
|
|
viewModel?.showServerUrlModal(pendingState: state.pendingState)
|
|
}
|
|
),
|
|
SessionCell.Info(
|
|
id: .customFileServerPubkey,
|
|
title: "Custom File Server Public Key",
|
|
subtitle: """
|
|
The public key to use for the above custom File Server (if empty then the pubkey for the default file server will be used)
|
|
|
|
<b>Current:</b> <span>\(state.pendingState.customFileServer.pubkey.isEmpty ? "Default" : state.pendingState.customFileServer.pubkey)</span>
|
|
""",
|
|
trailingAccessory: .icon(.squarePen),
|
|
onTap: { [weak viewModel] in
|
|
viewModel?.showServerPubkeyModal(pendingState: state.pendingState)
|
|
}
|
|
)
|
|
]
|
|
)
|
|
|
|
return [general]
|
|
}
|
|
|
|
// MARK: - Internal Functions
|
|
|
|
private func showShareFileModal() {
|
|
func originalPath(for value: String) -> String? {
|
|
let maybeAttachmentPath: String? = try? dependencies[singleton: .attachmentManager].path(for: value)
|
|
let maybeDisplayPicPath: String? = try? dependencies[singleton: .displayPictureManager].path(for: value)
|
|
|
|
if
|
|
let path: String = maybeAttachmentPath,
|
|
dependencies[singleton: .fileManager].fileExists(atPath: path)
|
|
{
|
|
return path
|
|
}
|
|
|
|
if
|
|
let path: String = maybeDisplayPicPath,
|
|
dependencies[singleton: .fileManager].fileExists(atPath: path)
|
|
{
|
|
return path
|
|
}
|
|
|
|
return nil
|
|
}
|
|
func showFileMissingError() {
|
|
let modal: ConfirmationModal = ConfirmationModal(
|
|
info: ConfirmationModal.Info(
|
|
title: "theError".localized(),
|
|
body: .text("There doesn't appear to be a downloaded file for that url."),
|
|
cancelTitle: "okay".localized(),
|
|
cancelStyle: .alert_text
|
|
|
|
)
|
|
)
|
|
self.transitionToScreen(modal, transitionType: .present)
|
|
}
|
|
|
|
self.transitionToScreen(
|
|
ConfirmationModal(
|
|
info: ConfirmationModal.Info(
|
|
title: "Share Downloaded File",
|
|
body: .dualInput(
|
|
explanation: ThemedAttributedString(
|
|
string: "The download url and mime type for the file to share."
|
|
),
|
|
firstInfo: ConfirmationModal.Info.Body.InputInfo(placeholder: "MIME Type (Optional)"),
|
|
secondInfo: ConfirmationModal.Info.Body.InputInfo(placeholder: "Enter URL"),
|
|
onChange: { [weak self] mimeTypeValue, urlValue in
|
|
self?.shareDownloadedFileUrl = urlValue.lowercased()
|
|
self?.shareDownloadedFileMimetype = mimeTypeValue.lowercased()
|
|
}
|
|
),
|
|
confirmTitle: "Copy Path",
|
|
confirmEnabled: .afterChange { [weak self] _ in
|
|
guard let value: String = self?.shareDownloadedFileUrl else { return false }
|
|
|
|
return !value.isEmpty
|
|
},
|
|
cancelTitle: "share".localized(),
|
|
cancelStyle: .alert_text,
|
|
cancelEnabled: .afterChange { [weak self] _ in
|
|
guard let value: String = self?.shareDownloadedFileUrl else { return false }
|
|
|
|
return !value.isEmpty
|
|
},
|
|
hasCloseButton: true,
|
|
dismissOnConfirm: false,
|
|
onConfirm: { [weak self] modal in
|
|
guard let value: String = self?.shareDownloadedFileUrl else { return }
|
|
guard let originalPath: String = originalPath(for: value) else {
|
|
return showFileMissingError()
|
|
}
|
|
|
|
UIPasteboard.general.string = originalPath
|
|
self?.showToast(text: "copied".localized())
|
|
},
|
|
onCancel: { [weak self, dependencies] modal in
|
|
guard let value: String = self?.shareDownloadedFileUrl else { return }
|
|
|
|
modal.dismiss(animated: true) {
|
|
guard
|
|
let originalPath: String = originalPath(for: value),
|
|
let temporaryPath: String = try? dependencies[singleton: .attachmentManager].temporaryPathForOpening(
|
|
originalPath: originalPath,
|
|
mimeType: self?.shareDownloadedFileMimetype?.lowercased(),
|
|
sourceFilename: nil,
|
|
allowInvalidType: true
|
|
)
|
|
else { return showFileMissingError() }
|
|
|
|
/// Create the temporary file
|
|
try? dependencies[singleton: .fileManager].copyItem(
|
|
atPath: originalPath,
|
|
toPath: temporaryPath
|
|
)
|
|
|
|
/// Ensure the temporary file was created successfully
|
|
guard dependencies[singleton: .fileManager].fileExists(atPath: temporaryPath) else {
|
|
return showFileMissingError()
|
|
}
|
|
|
|
let shareVC = UIActivityViewController(activityItems: [ URL(fileURLWithPath: temporaryPath) ], applicationActivities: nil)
|
|
shareVC.completionWithItemsHandler = { [dependencies] activityType, completed, returnedItems, activityError in
|
|
/// Sanity check to make sure we don't unintentionally remove a proper attachment file
|
|
if dependencies[singleton: .fileManager].isLocatedInTemporaryDirectory(temporaryPath) {
|
|
try? dependencies[singleton: .fileManager].removeItem(atPath: temporaryPath)
|
|
}
|
|
}
|
|
self?.transitionToScreen(shareVC, transitionType: .present)
|
|
}
|
|
}
|
|
)
|
|
),
|
|
transitionType: .present
|
|
)
|
|
}
|
|
|
|
private func showServerUrlModal(pendingState: State.Info) {
|
|
self.transitionToScreen(
|
|
ConfirmationModal(
|
|
info: ConfirmationModal.Info(
|
|
title: "Custom File Server URL",
|
|
body: .input(
|
|
explanation: ThemedAttributedString(
|
|
string: "The url for the custom file server."
|
|
),
|
|
info: ConfirmationModal.Info.Body.InputInfo(
|
|
placeholder: "Enter URL",
|
|
initialValue: pendingState.customFileServer.url,
|
|
inputChecker: { text in
|
|
guard URL(string: text) != nil else {
|
|
return "Value must be a valid url (with HTTP or HTTPS)."
|
|
}
|
|
|
|
return nil
|
|
}
|
|
),
|
|
onChange: { [weak self] value in
|
|
self?.updatedCustomServerUrl = value.lowercased()
|
|
}
|
|
),
|
|
confirmTitle: "save".localized(),
|
|
confirmEnabled: .afterChange { [weak self] _ in
|
|
guard
|
|
let value: String = self?.updatedCustomServerUrl,
|
|
let url: URL = URL(string: value)
|
|
else { return false }
|
|
|
|
return (url.scheme != nil && url.host != nil)
|
|
},
|
|
cancelTitle: (pendingState.customFileServer.url.isEmpty ?
|
|
"cancel".localized() :
|
|
"remove".localized()
|
|
),
|
|
cancelStyle: (pendingState.customFileServer.url.isEmpty ? .alert_text : .danger),
|
|
hasCloseButton: !pendingState.customFileServer.url.isEmpty,
|
|
dismissOnConfirm: false,
|
|
onConfirm: { [weak self, dependencies] modal in
|
|
guard
|
|
let value: String = self?.updatedCustomServerUrl,
|
|
URL(string: value) != nil
|
|
else {
|
|
modal.updateContent(
|
|
withError: "Value must be a valid url (with HTTP or HTTPS)."
|
|
)
|
|
return
|
|
}
|
|
|
|
modal.dismiss(animated: true)
|
|
dependencies.notifyAsync(
|
|
priority: .immediate,
|
|
key: .updateScreen(DeveloperSettingsFileServerViewModel.self),
|
|
value: pendingState.with(
|
|
customFileServer: pendingState.customFileServer.with(
|
|
url: value
|
|
)
|
|
)
|
|
)
|
|
},
|
|
onCancel: { [dependencies] modal in
|
|
modal.dismiss(animated: true)
|
|
|
|
guard !pendingState.customFileServer.url.isEmpty else { return }
|
|
|
|
dependencies.notifyAsync(
|
|
priority: .immediate,
|
|
key: .updateScreen(DeveloperSettingsFileServerViewModel.self),
|
|
value: pendingState.with(
|
|
customFileServer: pendingState.customFileServer.with(url: "")
|
|
)
|
|
)
|
|
}
|
|
)
|
|
),
|
|
transitionType: .present
|
|
)
|
|
}
|
|
|
|
private func showServerPubkeyModal(pendingState: State.Info) {
|
|
self.transitionToScreen(
|
|
ConfirmationModal(
|
|
info: ConfirmationModal.Info(
|
|
title: "Custom File Server Pubkey",
|
|
body: .input(
|
|
explanation: ThemedAttributedString(
|
|
string: """
|
|
The public key for the custom file server.
|
|
|
|
This is 64 character hexadecimal value.
|
|
"""
|
|
),
|
|
info: ConfirmationModal.Info.Body.InputInfo(
|
|
placeholder: "Enter Pubkey",
|
|
initialValue: pendingState.customFileServer.pubkey,
|
|
inputChecker: { text in
|
|
guard text.count <= 64 else {
|
|
return "Value must be a 64 character hexadecimal string."
|
|
}
|
|
|
|
return nil
|
|
}
|
|
),
|
|
onChange: { [weak self] value in
|
|
self?.updatedCustomServerPubkey = value
|
|
}
|
|
),
|
|
confirmTitle: "save".localized(),
|
|
confirmEnabled: .afterChange { [weak self] _ in
|
|
guard let value: String = self?.updatedCustomServerPubkey else {
|
|
return false
|
|
}
|
|
|
|
return (
|
|
Hex.isValid(value) &&
|
|
value.trimmingCharacters(in: .whitespacesAndNewlines).count == 64
|
|
)
|
|
},
|
|
cancelTitle: (pendingState.customFileServer.pubkey.isEmpty ?
|
|
"cancel".localized() :
|
|
"remove".localized()
|
|
),
|
|
cancelStyle: (pendingState.customFileServer.pubkey.isEmpty ? .alert_text : .danger),
|
|
hasCloseButton: !pendingState.customFileServer.pubkey.isEmpty,
|
|
dismissOnConfirm: false,
|
|
onConfirm: { [weak self, dependencies] modal in
|
|
guard
|
|
let value: String = self?.updatedCustomServerPubkey,
|
|
Hex.isValid(value),
|
|
value.trimmingCharacters(in: .whitespacesAndNewlines).count == 64
|
|
else {
|
|
modal.updateContent(
|
|
withError: "Value must be a 64 character hexadecimal string."
|
|
)
|
|
return
|
|
}
|
|
|
|
modal.dismiss(animated: true)
|
|
dependencies.notifyAsync(
|
|
priority: .immediate,
|
|
key: .updateScreen(DeveloperSettingsFileServerViewModel.self),
|
|
value: pendingState.with(
|
|
customFileServer: pendingState.customFileServer.with(
|
|
pubkey: value
|
|
)
|
|
)
|
|
)
|
|
},
|
|
onCancel: { [dependencies] modal in
|
|
modal.dismiss(animated: true)
|
|
|
|
guard !pendingState.customFileServer.pubkey.isEmpty else { return }
|
|
|
|
dependencies.notifyAsync(
|
|
priority: .immediate,
|
|
key: .updateScreen(DeveloperSettingsFileServerViewModel.self),
|
|
value: pendingState.with(
|
|
customFileServer: pendingState.customFileServer.with(pubkey: "")
|
|
)
|
|
)
|
|
}
|
|
)
|
|
),
|
|
transitionType: .present
|
|
)
|
|
}
|
|
|
|
// MARK: - Reverting
|
|
|
|
public static func disableDeveloperMode(using dependencies: Dependencies) {
|
|
let features: [FeatureConfig<Bool>] = [
|
|
.shortenFileTTL,
|
|
.deterministicAttachmentEncryption
|
|
]
|
|
|
|
features.forEach { feature in
|
|
guard dependencies.hasSet(feature: feature) else { return }
|
|
|
|
dependencies.reset(feature: feature)
|
|
}
|
|
|
|
if dependencies.hasSet(feature: .customFileServer) {
|
|
dependencies.reset(feature: .customFileServer)
|
|
}
|
|
}
|
|
|
|
// MARK: - Saving
|
|
|
|
@MainActor private func saveChanges(hasConfirmed: Bool = false) async {
|
|
guard internalState.initialState != internalState.pendingState else { return }
|
|
|
|
if internalState.initialState.shortenFileTTL != internalState.pendingState.shortenFileTTL {
|
|
dependencies.set(feature: .shortenFileTTL, to: internalState.pendingState.shortenFileTTL)
|
|
}
|
|
|
|
if internalState.initialState.deterministicAttachmentEncryption != internalState.pendingState.deterministicAttachmentEncryption {
|
|
dependencies.set(
|
|
feature: .deterministicAttachmentEncryption,
|
|
to: internalState.pendingState.deterministicAttachmentEncryption
|
|
)
|
|
}
|
|
|
|
if internalState.initialState.customFileServer != internalState.pendingState.customFileServer {
|
|
dependencies.set(
|
|
feature: .customFileServer,
|
|
to: internalState.pendingState.customFileServer
|
|
)
|
|
}
|
|
|
|
/// Changes have been saved so we can dismiss the screen
|
|
self.dismissScreen()
|
|
}
|
|
}
|