diff --git a/Examples/xx-messenger/.swiftpm/xcode/xcshareddata/xcschemes/ContactLookupFeature.xcscheme b/Examples/xx-messenger/.swiftpm/xcode/xcshareddata/xcschemes/ContactLookupFeature.xcscheme new file mode 100644 index 0000000000000000000000000000000000000000..e0070c64629dce6e785c4bb8590d0b330d522f5b --- /dev/null +++ b/Examples/xx-messenger/.swiftpm/xcode/xcshareddata/xcschemes/ContactLookupFeature.xcscheme @@ -0,0 +1,78 @@ +<?xml version="1.0" encoding="UTF-8"?> +<Scheme + LastUpgradeVersion = "1400" + version = "1.3"> + <BuildAction + parallelizeBuildables = "YES" + buildImplicitDependencies = "YES"> + <BuildActionEntries> + <BuildActionEntry + buildForTesting = "YES" + buildForRunning = "YES" + buildForProfiling = "YES" + buildForArchiving = "YES" + buildForAnalyzing = "YES"> + <BuildableReference + BuildableIdentifier = "primary" + BlueprintIdentifier = "ContactLookupFeature" + BuildableName = "ContactLookupFeature" + BlueprintName = "ContactLookupFeature" + ReferencedContainer = "container:"> + </BuildableReference> + </BuildActionEntry> + </BuildActionEntries> + </BuildAction> + <TestAction + buildConfiguration = "Debug" + selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" + selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" + shouldUseLaunchSchemeArgsEnv = "YES" + codeCoverageEnabled = "YES"> + <Testables> + <TestableReference + skipped = "NO"> + <BuildableReference + BuildableIdentifier = "primary" + BlueprintIdentifier = "ContactLookupFeatureTests" + BuildableName = "ContactLookupFeatureTests" + BlueprintName = "ContactLookupFeatureTests" + ReferencedContainer = "container:"> + </BuildableReference> + </TestableReference> + </Testables> + </TestAction> + <LaunchAction + buildConfiguration = "Debug" + selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" + selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" + launchStyle = "0" + useCustomWorkingDirectory = "NO" + ignoresPersistentStateOnLaunch = "NO" + debugDocumentVersioning = "YES" + debugServiceExtension = "internal" + allowLocationSimulation = "YES"> + </LaunchAction> + <ProfileAction + buildConfiguration = "Release" + shouldUseLaunchSchemeArgsEnv = "YES" + savedToolIdentifier = "" + useCustomWorkingDirectory = "NO" + debugDocumentVersioning = "YES"> + <MacroExpansion> + <BuildableReference + BuildableIdentifier = "primary" + BlueprintIdentifier = "ContactLookupFeature" + BuildableName = "ContactLookupFeature" + BlueprintName = "ContactLookupFeature" + ReferencedContainer = "container:"> + </BuildableReference> + </MacroExpansion> + </ProfileAction> + <AnalyzeAction + buildConfiguration = "Debug"> + </AnalyzeAction> + <ArchiveAction + buildConfiguration = "Release" + revealArchiveInOrganizer = "YES"> + </ArchiveAction> +</Scheme> diff --git a/Examples/xx-messenger/Package.swift b/Examples/xx-messenger/Package.swift index 4668d629ddf3b295ce57fe54329d28101e94c7ff..d863e016e9f28069ddcc36104793639aef96be33 100644 --- a/Examples/xx-messenger/Package.swift +++ b/Examples/xx-messenger/Package.swift @@ -20,6 +20,7 @@ let package = Package( .library(name: "CheckContactAuthFeature", targets: ["CheckContactAuthFeature"]), .library(name: "ConfirmRequestFeature", targets: ["ConfirmRequestFeature"]), .library(name: "ContactFeature", targets: ["ContactFeature"]), + .library(name: "ContactLookupFeature", targets: ["ContactLookupFeature"]), .library(name: "ContactsFeature", targets: ["ContactsFeature"]), .library(name: "HomeFeature", targets: ["HomeFeature"]), .library(name: "MyContactFeature", targets: ["MyContactFeature"]), @@ -89,6 +90,7 @@ let package = Package( .target(name: "CheckContactAuthFeature"), .target(name: "ConfirmRequestFeature"), .target(name: "ContactFeature"), + .target(name: "ContactLookupFeature"), .target(name: "ContactsFeature"), .target(name: "HomeFeature"), .target(name: "MyContactFeature"), @@ -117,10 +119,8 @@ let package = Package( .target( name: "BackupFeature", dependencies: [ - .target(name: "AppCore"), .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), .product(name: "XXMessengerClient", package: "elixxir-dapps-sdk-swift"), - .product(name: "XXModels", package: "client-ios-db"), ], swiftSettings: swiftSettings ), @@ -191,6 +191,7 @@ let package = Package( .target(name: "ChatFeature"), .target(name: "CheckContactAuthFeature"), .target(name: "ConfirmRequestFeature"), + .target(name: "ContactLookupFeature"), .target(name: "SendRequestFeature"), .target(name: "VerifyContactFeature"), .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), @@ -208,6 +209,24 @@ let package = Package( ], swiftSettings: swiftSettings ), + .target( + name: "ContactLookupFeature", + dependencies: [ + .target(name: "AppCore"), + .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), + .product(name: "XXMessengerClient", package: "elixxir-dapps-sdk-swift"), + .product(name: "XXModels", package: "client-ios-db"), + ], + swiftSettings: swiftSettings + ), + .testTarget( + name: "ContactLookupFeatureTests", + dependencies: [ + .target(name: "ContactLookupFeature"), + .product(name: "CustomDump", package: "swift-custom-dump"), + ], + swiftSettings: swiftSettings + ), .target( name: "ContactsFeature", dependencies: [ diff --git a/Examples/xx-messenger/Project/XXMessenger.xcodeproj/xcshareddata/xcschemes/XXMessenger.xcscheme b/Examples/xx-messenger/Project/XXMessenger.xcodeproj/xcshareddata/xcschemes/XXMessenger.xcscheme index b50f1c107c6e3f6e79c5c4c22e64b4368732de84..42a3cede69290ef2541f14fb20a7c54ad71831b5 100644 --- a/Examples/xx-messenger/Project/XXMessenger.xcodeproj/xcshareddata/xcschemes/XXMessenger.xcscheme +++ b/Examples/xx-messenger/Project/XXMessenger.xcodeproj/xcshareddata/xcschemes/XXMessenger.xcscheme @@ -99,6 +99,16 @@ ReferencedContainer = "container:.."> </BuildableReference> </TestableReference> + <TestableReference + skipped = "NO"> + <BuildableReference + BuildableIdentifier = "primary" + BlueprintIdentifier = "ContactLookupFeatureTests" + BuildableName = "ContactLookupFeatureTests" + BlueprintName = "ContactLookupFeatureTests" + ReferencedContainer = "container:.."> + </BuildableReference> + </TestableReference> <TestableReference skipped = "NO"> <BuildableReference diff --git a/Examples/xx-messenger/Sources/AppCore/SharedUI/Data+hexString.swift b/Examples/xx-messenger/Sources/AppCore/SharedUI/Data+hexString.swift new file mode 100644 index 0000000000000000000000000000000000000000..e8010b95c6fbeea66049228f1129f929559bdf86 --- /dev/null +++ b/Examples/xx-messenger/Sources/AppCore/SharedUI/Data+hexString.swift @@ -0,0 +1,7 @@ +import Foundation + +extension Data { + public func hexString(bytesSeparator: String = " ") -> String { + map { String(format: "%02hhx\(bytesSeparator)", $0) }.joined() + } +} diff --git a/Examples/xx-messenger/Sources/AppFeature/AppEnvironment+Live.swift b/Examples/xx-messenger/Sources/AppFeature/AppEnvironment+Live.swift index 3cf2cb0b065eda90aeff16cf1ce76f14297fd012..ce52387f7624f3d3583f0be4e95571926327b754 100644 --- a/Examples/xx-messenger/Sources/AppFeature/AppEnvironment+Live.swift +++ b/Examples/xx-messenger/Sources/AppFeature/AppEnvironment+Live.swift @@ -4,6 +4,7 @@ import ChatFeature import CheckContactAuthFeature import ConfirmRequestFeature import ContactFeature +import ContactLookupFeature import ContactsFeature import Foundation import HomeFeature @@ -37,6 +38,13 @@ extension AppEnvironment { db: dbManager.getDB, mainQueue: mainQueue, bgQueue: bgQueue, + lookup: { + ContactLookupEnvironment( + messenger: messenger, + mainQueue: mainQueue, + bgQueue: bgQueue + ) + }, sendRequest: { SendRequestEnvironment( messenger: messenger, @@ -156,7 +164,6 @@ extension AppEnvironment { backup: { BackupEnvironment( messenger: messenger, - db: dbManager.getDB, backupStorage: backupStorage, mainQueue: mainQueue, bgQueue: bgQueue diff --git a/Examples/xx-messenger/Sources/BackupFeature/BackupFeature.swift b/Examples/xx-messenger/Sources/BackupFeature/BackupFeature.swift index f672b312a1867790a10ffb54f84cd683084a1b22..8010f17d1d0727c4621841dd124a93dcda0e0b5d 100644 --- a/Examples/xx-messenger/Sources/BackupFeature/BackupFeature.swift +++ b/Examples/xx-messenger/Sources/BackupFeature/BackupFeature.swift @@ -1,15 +1,16 @@ -import AppCore import Combine import ComposableArchitecture import Foundation import XXClient import XXMessengerClient -import XXModels public struct BackupState: Equatable { + public enum Field: String, Hashable { + case passphrase + } + public enum Error: String, Swift.Error, Equatable { - case dbContactNotFound - case dbContactUsernameMissing + case contactUsernameMissing } public init( @@ -19,6 +20,7 @@ public struct BackupState: Equatable { isStopping: Bool = false, backup: BackupStorage.Backup? = nil, alert: AlertState<BackupAction>? = nil, + focusedField: Field? = nil, passphrase: String = "", isExporting: Bool = false, exportData: Data? = nil @@ -29,6 +31,7 @@ public struct BackupState: Equatable { self.isStopping = isStopping self.backup = backup self.alert = alert + self.focusedField = focusedField self.passphrase = passphrase self.isExporting = isExporting self.exportData = exportData @@ -40,6 +43,7 @@ public struct BackupState: Equatable { public var isStopping: Bool public var backup: BackupStorage.Backup? public var alert: AlertState<BackupAction>? + @BindableState public var focusedField: Field? @BindableState public var passphrase: String @BindableState public var isExporting: Bool public var exportData: Data? @@ -64,20 +68,17 @@ public enum BackupAction: Equatable, BindableAction { public struct BackupEnvironment { public init( messenger: Messenger, - db: DBManagerGetDB, backupStorage: BackupStorage, mainQueue: AnySchedulerOf<DispatchQueue>, bgQueue: AnySchedulerOf<DispatchQueue> ) { self.messenger = messenger - self.db = db self.backupStorage = backupStorage self.mainQueue = mainQueue self.bgQueue = bgQueue } public var messenger: Messenger - public var db: DBManagerGetDB public var backupStorage: BackupStorage public var mainQueue: AnySchedulerOf<DispatchQueue> public var bgQueue: AnySchedulerOf<DispatchQueue> @@ -87,7 +88,6 @@ public struct BackupEnvironment { extension BackupEnvironment { public static let unimplemented = BackupEnvironment( messenger: .unimplemented, - db: .unimplemented, backupStorage: .unimplemented, mainQueue: .unimplemented, bgQueue: .unimplemented @@ -119,17 +119,12 @@ public let backupReducer = Reducer<BackupState, BackupAction, BackupEnvironment> case .startTapped: state.isStarting = true + state.focusedField = nil return Effect.run { [state] subscriber in do { - let e2e: E2E = try env.messenger.e2e.tryGet() - let contactID = try e2e.getContact().getId() - let db = try env.db() - let query = XXModels.Contact.Query(id: [contactID]) - guard let contact = try db.fetchContacts(query).first else { - throw BackupState.Error.dbContactNotFound - } - guard let username = contact.username else { - throw BackupState.Error.dbContactUsernameMissing + let contact = try env.messenger.myContact(includeFacts: .types([.username])) + guard let username = try contact.getFact(.username)?.value else { + throw BackupState.Error.contactUsernameMissing } try env.messenger.startBackup( password: state.passphrase, diff --git a/Examples/xx-messenger/Sources/BackupFeature/BackupView.swift b/Examples/xx-messenger/Sources/BackupFeature/BackupView.swift index 54fc6567cd07eac6c5649fa5a35e6b28a91fe3eb..89510b2fbf2c993afa1438fd62408dc925d1c30e 100644 --- a/Examples/xx-messenger/Sources/BackupFeature/BackupView.swift +++ b/Examples/xx-messenger/Sources/BackupFeature/BackupView.swift @@ -8,6 +8,7 @@ public struct BackupView: View { } let store: Store<BackupState, BackupAction> + @FocusState var focusedField: BackupState.Field? struct ViewState: Equatable { struct Backup: Equatable { @@ -23,6 +24,7 @@ public struct BackupView: View { backup = state.backup.map { backup in Backup(date: backup.date, size: backup.data.count) } + focusedField = state.focusedField passphrase = state.passphrase isExporting = state.isExporting exportData = state.exportData @@ -34,6 +36,7 @@ public struct BackupView: View { var isStopping: Bool var isLoading: Bool { isStarting || isResuming || isStopping } var backup: Backup? + var focusedField: BackupState.Field? var passphrase: String var isExporting: Bool var exportData: Data? @@ -57,9 +60,9 @@ public struct BackupView: View { ) } .navigationTitle("Backup") - .task { - await viewStore.send(.task).finish() - } + .task { await viewStore.send(.task).finish() } + .onChange(of: viewStore.focusedField) { focusedField = $0 } + .onChange(of: focusedField) { viewStore.send(.set(\.$focusedField, $0)) } } } @@ -75,6 +78,11 @@ public struct BackupView: View { prompt: Text("Backup passphrase"), label: { Text("Backup passphrase") } ) + .textContentType(.password) + .textInputAutocapitalization(.never) + .disableAutocorrection(true) + .focused($focusedField, equals: .passphrase) + Button { viewStore.send(.startTapped) } label: { @@ -91,6 +99,7 @@ public struct BackupView: View { } header: { Text("New backup") } + .disabled(viewStore.isStarting) } @ViewBuilder func backupSection( diff --git a/Examples/xx-messenger/Sources/ContactFeature/ContactFeature.swift b/Examples/xx-messenger/Sources/ContactFeature/ContactFeature.swift index 993796e4bf4f286a29fdb7319fb39332074c68da..dc166e4ee49bf7d36d410caeb16589cf88be0439 100644 --- a/Examples/xx-messenger/Sources/ContactFeature/ContactFeature.swift +++ b/Examples/xx-messenger/Sources/ContactFeature/ContactFeature.swift @@ -4,6 +4,7 @@ import CheckContactAuthFeature import ComposableArchitecture import ComposablePresentation import ConfirmRequestFeature +import ContactLookupFeature import Foundation import SendRequestFeature import VerifyContactFeature @@ -20,6 +21,7 @@ public struct ContactState: Equatable { importUsername: Bool = true, importEmail: Bool = true, importPhone: Bool = true, + lookup: ContactLookupState? = nil, sendRequest: SendRequestState? = nil, verifyContact: VerifyContactState? = nil, confirmRequest: ConfirmRequestState? = nil, @@ -32,6 +34,7 @@ public struct ContactState: Equatable { self.importUsername = importUsername self.importEmail = importEmail self.importPhone = importPhone + self.lookup = lookup self.sendRequest = sendRequest self.verifyContact = verifyContact self.confirmRequest = confirmRequest @@ -45,6 +48,7 @@ public struct ContactState: Equatable { @BindableState public var importUsername: Bool @BindableState public var importEmail: Bool @BindableState public var importPhone: Bool + public var lookup: ContactLookupState? public var sendRequest: SendRequestState? public var verifyContact: VerifyContactState? public var confirmRequest: ConfirmRequestState? @@ -56,6 +60,9 @@ public enum ContactAction: Equatable, BindableAction { case start case dbContactFetched(XXModels.Contact?) case importFactsTapped + case lookupTapped + case lookupDismissed + case lookup(ContactLookupAction) case sendRequestTapped case sendRequestDismissed case sendRequest(SendRequestAction) @@ -80,6 +87,7 @@ public struct ContactEnvironment { db: DBManagerGetDB, mainQueue: AnySchedulerOf<DispatchQueue>, bgQueue: AnySchedulerOf<DispatchQueue>, + lookup: @escaping () -> ContactLookupEnvironment, sendRequest: @escaping () -> SendRequestEnvironment, verifyContact: @escaping () -> VerifyContactEnvironment, confirmRequest: @escaping () -> ConfirmRequestEnvironment, @@ -90,6 +98,7 @@ public struct ContactEnvironment { self.db = db self.mainQueue = mainQueue self.bgQueue = bgQueue + self.lookup = lookup self.sendRequest = sendRequest self.verifyContact = verifyContact self.confirmRequest = confirmRequest @@ -101,6 +110,7 @@ public struct ContactEnvironment { public var db: DBManagerGetDB public var mainQueue: AnySchedulerOf<DispatchQueue> public var bgQueue: AnySchedulerOf<DispatchQueue> + public var lookup: () -> ContactLookupEnvironment public var sendRequest: () -> SendRequestEnvironment public var verifyContact: () -> VerifyContactEnvironment public var confirmRequest: () -> ConfirmRequestEnvironment @@ -115,6 +125,7 @@ extension ContactEnvironment { db: .unimplemented, mainQueue: .unimplemented, bgQueue: .unimplemented, + lookup: { .unimplemented }, sendRequest: { .unimplemented }, verifyContact: { .unimplemented }, confirmRequest: { .unimplemented }, @@ -163,6 +174,19 @@ public let contactReducer = Reducer<ContactState, ContactAction, ContactEnvironm .receive(on: env.mainQueue) .eraseToEffect() + case .lookupTapped: + state.lookup = ContactLookupState(id: state.id) + return .none + + case .lookupDismissed: + state.lookup = nil + return .none + + case .lookup(.didLookup(let xxContact)): + state.xxContact = xxContact + state.lookup = nil + return .none + case .sendRequestTapped: if let xxContact = state.xxContact { state.sendRequest = SendRequestState(contact: xxContact) @@ -223,11 +247,20 @@ public let contactReducer = Reducer<ContactState, ContactAction, ContactEnvironm state.chat = nil return .none - case .binding(_), .sendRequest(_), .verifyContact(_), .confirmRequest(_), .checkAuth(_), .chat(_): + case .binding(_), .lookup(_), .sendRequest(_), + .verifyContact(_), .confirmRequest(_), + .checkAuth(_), .chat(_): return .none } } .binding() +.presenting( + contactLookupReducer, + state: .keyPath(\.lookup), + id: .notNil(), + action: /ContactAction.lookup, + environment: { $0.lookup() } +) .presenting( sendRequestReducer, state: .keyPath(\.sendRequest), diff --git a/Examples/xx-messenger/Sources/ContactFeature/ContactView.swift b/Examples/xx-messenger/Sources/ContactFeature/ContactView.swift index d775df01eeed7ee6175b00d231149a351be84882..daa84903edacd947fb7173c3196e8ea7f5c1cf0d 100644 --- a/Examples/xx-messenger/Sources/ContactFeature/ContactView.swift +++ b/Examples/xx-messenger/Sources/ContactFeature/ContactView.swift @@ -4,6 +4,7 @@ import CheckContactAuthFeature import ComposableArchitecture import ComposablePresentation import ConfirmRequestFeature +import ContactLookupFeature import SendRequestFeature import SwiftUI import VerifyContactFeature @@ -26,6 +27,11 @@ public struct ContactView: View { var importUsername: Bool var importEmail: Bool var importPhone: Bool + var canLookup: Bool + var canSendRequest: Bool + var canVerifyContact: Bool + var canConfirmRequest: Bool + var canCheckAuthorization: Bool init(state: ContactState) { dbContact = state.dbContact @@ -36,6 +42,11 @@ public struct ContactView: View { importUsername = state.importUsername importEmail = state.importEmail importPhone = state.importPhone + canLookup = state.dbContact?.id != nil + canSendRequest = state.xxContact != nil || state.dbContact?.marshaled != nil + canVerifyContact = state.dbContact?.marshaled != nil + canConfirmRequest = state.dbContact?.marshaled != nil + canCheckAuthorization = state.dbContact?.marshaled != nil } } @@ -100,15 +111,30 @@ public struct ContactView: View { if let dbContact = viewStore.dbContact { Section { + Label(dbContact.id.hexString(), systemImage: "number") + .font(.footnote.monospaced()) Label(dbContact.username ?? "", systemImage: "person") Label(dbContact.email ?? "", systemImage: "envelope") Label(dbContact.phone ?? "", systemImage: "phone") } header: { Text("Contact") } + .textSelection(.enabled) Section { ContactAuthStatusView(dbContact.authStatus) + + Button { + viewStore.send(.lookupTapped) + } label: { + HStack { + Text("Lookup") + Spacer() + Image(systemName: "chevron.forward") + } + } + .disabled(!viewStore.canLookup) + Button { viewStore.send(.sendRequestTapped) } label: { @@ -118,6 +144,8 @@ public struct ContactView: View { Image(systemName: "chevron.forward") } } + .disabled(!viewStore.canSendRequest) + Button { viewStore.send(.verifyContactTapped) } label: { @@ -127,6 +155,8 @@ public struct ContactView: View { Image(systemName: "chevron.forward") } } + .disabled(!viewStore.canVerifyContact) + Button { viewStore.send(.confirmRequestTapped) } label: { @@ -136,6 +166,8 @@ public struct ContactView: View { Image(systemName: "chevron.forward") } } + .disabled(!viewStore.canConfirmRequest) + Button { viewStore.send(.checkAuthTapped) } label: { @@ -145,6 +177,7 @@ public struct ContactView: View { Image(systemName: "chevron.forward") } } + .disabled(!viewStore.canCheckAuthorization) } header: { Text("Auth") } @@ -167,6 +200,15 @@ public struct ContactView: View { } .navigationTitle("Contact") .task { viewStore.send(.start) } + .background(NavigationLinkWithStore( + store.scope( + state: \.lookup, + action: ContactAction.lookup + ), + mapState: replayNonNil(), + onDeactivate: { viewStore.send(.lookupDismissed) }, + destination: ContactLookupView.init(store:) + )) .background(NavigationLinkWithStore( store.scope( state: \.sendRequest, diff --git a/Examples/xx-messenger/Sources/ContactLookupFeature/ContactLookupFeature.swift b/Examples/xx-messenger/Sources/ContactLookupFeature/ContactLookupFeature.swift new file mode 100644 index 0000000000000000000000000000000000000000..0b6f92dd46b351f6d10f78ef4b50961dad3e1fae --- /dev/null +++ b/Examples/xx-messenger/Sources/ContactLookupFeature/ContactLookupFeature.swift @@ -0,0 +1,83 @@ +import ComposableArchitecture +import Foundation +import XCTestDynamicOverlay +import XXClient +import XXMessengerClient + +public struct ContactLookupState: Equatable { + public init( + id: Data, + isLookingUp: Bool = false, + failure: String? = nil + ) { + self.id = id + self.isLookingUp = isLookingUp + self.failure = failure + } + + public var id: Data + public var isLookingUp: Bool + public var failure: String? +} + +public enum ContactLookupAction: Equatable { + case lookupTapped + case didLookup(XXClient.Contact) + case didFail(NSError) +} + +public struct ContactLookupEnvironment { + public init( + messenger: Messenger, + mainQueue: AnySchedulerOf<DispatchQueue>, + bgQueue: AnySchedulerOf<DispatchQueue> + ) { + self.messenger = messenger + self.mainQueue = mainQueue + self.bgQueue = bgQueue + } + + public var messenger: Messenger + public var mainQueue: AnySchedulerOf<DispatchQueue> + public var bgQueue: AnySchedulerOf<DispatchQueue> +} + +#if DEBUG +extension ContactLookupEnvironment { + public static let unimplemented = ContactLookupEnvironment( + messenger: .unimplemented, + mainQueue: .unimplemented, + bgQueue: .unimplemented + ) +} +#endif + +public let contactLookupReducer = Reducer<ContactLookupState, ContactLookupAction, ContactLookupEnvironment> +{ state, action, env in + switch action { + case .lookupTapped: + state.isLookingUp = true + state.failure = nil + return Effect.result { [state] in + do { + let contact = try env.messenger.lookupContact(id: state.id) + return .success(.didLookup(contact)) + } catch { + return .success(.didFail(error as NSError)) + } + } + .subscribe(on: env.bgQueue) + .receive(on: env.mainQueue) + .eraseToEffect() + + case .didLookup(_): + state.isLookingUp = false + state.failure = nil + return .none + + case .didFail(let error): + state.isLookingUp = false + state.failure = error.localizedDescription + return .none + } +} diff --git a/Examples/xx-messenger/Sources/ContactLookupFeature/ContactLookupView.swift b/Examples/xx-messenger/Sources/ContactLookupFeature/ContactLookupView.swift new file mode 100644 index 0000000000000000000000000000000000000000..6ce83eda6ba5dacfcf15c2fca4e8c202087ef42d --- /dev/null +++ b/Examples/xx-messenger/Sources/ContactLookupFeature/ContactLookupView.swift @@ -0,0 +1,76 @@ +import AppCore +import ComposableArchitecture +import SwiftUI + +public struct ContactLookupView: View { + public init(store: Store<ContactLookupState, ContactLookupAction>) { + self.store = store + } + + let store: Store<ContactLookupState, ContactLookupAction> + + struct ViewState: Equatable { + init(state: ContactLookupState) { + id = state.id + isLookingUp = state.isLookingUp + failure = state.failure + } + + var id: Data + var isLookingUp: Bool + var failure: String? + } + + public var body: some View { + WithViewStore(store, observe: ViewState.init) { viewStore in + Form { + Section { + Label(viewStore.id.hexString(), systemImage: "number") + .font(.footnote.monospaced()) + + Button { + viewStore.send(.lookupTapped) + } label: { + HStack { + Text("Lookup") + Spacer() + if viewStore.isLookingUp { + ProgressView() + } else { + Image(systemName: "magnifyingglass") + } + } + } + .disabled(viewStore.isLookingUp) + } header: { + Text("Contact ID") + } + + if let failure = viewStore.failure { + Section { + Text(failure) + } header: { + Text("Error") + } + } + } + .navigationTitle("Lookup") + } + } +} + +#if DEBUG +public struct ContactLookupView_Previews: PreviewProvider { + public static var previews: some View { + NavigationView { + ContactLookupView(store: Store( + initialState: ContactLookupState( + id: "1234".data(using: .utf8)! + ), + reducer: .empty, + environment: () + )) + } + } +} +#endif diff --git a/Examples/xx-messenger/Sources/MyContactFeature/MyContactFeature.swift b/Examples/xx-messenger/Sources/MyContactFeature/MyContactFeature.swift index b25d021257e34cbc8b23cafa4f4ef1c44815dbff..434a1aca3c7cdc74b340c54a22558fa67a90a0eb 100644 --- a/Examples/xx-messenger/Sources/MyContactFeature/MyContactFeature.swift +++ b/Examples/xx-messenger/Sources/MyContactFeature/MyContactFeature.swift @@ -285,6 +285,7 @@ public let myContactReducer = Reducer<MyContactState, MyContactAction, MyContact let contactId = try env.messenger.e2e.tryGet().getContact().getId() if var dbContact = try env.db().fetchContacts(.init(id: [contactId])).first { let facts = try env.messenger.ud.tryGet().getFacts() + dbContact.username = facts.get(.username)?.value dbContact.email = facts.get(.email)?.value dbContact.phone = facts.get(.phone)?.value try env.db().saveContact(dbContact) diff --git a/Examples/xx-messenger/Sources/MyContactFeature/MyContactView.swift b/Examples/xx-messenger/Sources/MyContactFeature/MyContactView.swift index f32af24270f762b6a11f5a43602f013709e14344..d32a6f68848e89ad7eeb4a9bf5f25b543d379f40 100644 --- a/Examples/xx-messenger/Sources/MyContactFeature/MyContactView.swift +++ b/Examples/xx-messenger/Sources/MyContactFeature/MyContactView.swift @@ -1,3 +1,4 @@ +import AppCore import ComposableArchitecture import SwiftUI import XXModels @@ -49,8 +50,17 @@ public struct MyContactView: View { public var body: some View { WithViewStore(store, observe: ViewState.init) { viewStore in Form { + Section { + Text(viewStore.contact?.id.hexString() ?? "") + .font(.footnote.monospaced()) + .textSelection(.enabled) + } header: { + Label("ID", systemImage: "number") + } + Section { Text(viewStore.contact?.username ?? "") + .textSelection(.enabled) } header: { Label("Username", systemImage: "person") } @@ -59,6 +69,7 @@ public struct MyContactView: View { if let contact = viewStore.contact { if let email = contact.email { Text(email) + .textSelection(.enabled) Button(role: .destructive) { viewStore.send(.unregisterEmailTapped) } label: { @@ -135,6 +146,7 @@ public struct MyContactView: View { if let contact = viewStore.contact { if let phone = contact.phone { Text(phone) + .textSelection(.enabled) Button(role: .destructive) { viewStore.send(.unregisterPhoneTapped) } label: { diff --git a/Examples/xx-messenger/Sources/RegisterFeature/RegisterFeature.swift b/Examples/xx-messenger/Sources/RegisterFeature/RegisterFeature.swift index cb43c43056f6d3e53c2b73b4bdfc10189f052b21..9b9754c60eb11b019a7674d6c247698f5b16ef01 100644 --- a/Examples/xx-messenger/Sources/RegisterFeature/RegisterFeature.swift +++ b/Examples/xx-messenger/Sources/RegisterFeature/RegisterFeature.swift @@ -7,6 +7,10 @@ import XXMessengerClient import XXModels public struct RegisterState: Equatable { + public enum Error: Swift.Error, Equatable { + case usernameMismatch(registering: String, registered: String?) + } + public enum Field: String, Hashable { case username } @@ -82,14 +86,22 @@ public let registerReducer = Reducer<RegisterState, RegisterAction, RegisterEnvi do { let db = try env.db() try env.messenger.register(username: username) - var contact = try env.messenger.e2e.tryGet().getContact() - try contact.setFact(.username, username) + let contact = try env.messenger.myContact() + let facts = try contact.getFacts() try db.saveContact(Contact( id: try contact.getId(), marshaled: contact.data, - username: username, + username: facts.get(.username)?.value, + email: facts.get(.email)?.value, + phone: facts.get(.phone)?.value, createdAt: env.now() )) + guard facts.get(.username)?.value == username else { + throw RegisterState.Error.usernameMismatch( + registering: username, + registered: facts.get(.username)?.value + ) + } fulfill(.success(.finished)) } catch { @@ -106,6 +118,7 @@ public let registerReducer = Reducer<RegisterState, RegisterAction, RegisterEnvi return .none case .finished: + state.isRegistering = false return .none } } diff --git a/Examples/xx-messenger/Sources/RestoreFeature/RestoreFeature.swift b/Examples/xx-messenger/Sources/RestoreFeature/RestoreFeature.swift index 66d7df2f0cb2ffc960270caad1aecf6c2da1100a..4d372202180da8adbb37355e78739236b8ff7d6a 100644 --- a/Examples/xx-messenger/Sources/RestoreFeature/RestoreFeature.swift +++ b/Examples/xx-messenger/Sources/RestoreFeature/RestoreFeature.swift @@ -24,7 +24,7 @@ public struct RestoreState: Equatable { public init( file: File? = nil, fileImportFailure: String? = nil, - restoreFailure: String? = nil, + restoreFailures: [String] = [], focusedField: Field? = nil, isImportingFile: Bool = false, passphrase: String = "", @@ -32,7 +32,7 @@ public struct RestoreState: Equatable { ) { self.file = file self.fileImportFailure = fileImportFailure - self.restoreFailure = restoreFailure + self.restoreFailures = restoreFailures self.focusedField = focusedField self.isImportingFile = isImportingFile self.passphrase = passphrase @@ -41,7 +41,7 @@ public struct RestoreState: Equatable { public var file: File? public var fileImportFailure: String? - public var restoreFailure: String? + public var restoreFailures: [String] @BindableState public var focusedField: Field? @BindableState public var isImportingFile: Bool @BindableState public var passphrase: String @@ -53,7 +53,7 @@ public enum RestoreAction: Equatable, BindableAction { case fileImport(Result<URL, NSError>) case restoreTapped case finished - case failed(NSError) + case failed([NSError]) case binding(BindingAction<RestoreState>) } @@ -125,7 +125,7 @@ public let restoreReducer = Reducer<RestoreState, RestoreAction, RestoreEnvironm guard let backupData = state.file?.data, backupData.count > 0 else { return .none } let backupPassphrase = state.passphrase state.isRestoring = true - state.restoreFailure = nil + state.restoreFailures = [] return Effect.result { do { let result = try env.messenger.restoreBackup( @@ -135,15 +135,28 @@ public let restoreReducer = Reducer<RestoreState, RestoreAction, RestoreEnvironm let facts = try env.messenger.ud.tryGet().getFacts() try env.db().saveContact(Contact( id: try env.messenger.e2e.tryGet().getContact().getId(), - username: result.restoredParams.username, + username: facts.get(.username)?.value, email: facts.get(.email)?.value, phone: facts.get(.phone)?.value, createdAt: env.now() )) + try result.restoredContacts.forEach { contactId in + if try env.db().fetchContacts(.init(id: [contactId])).isEmpty { + try env.db().saveContact(Contact( + id: contactId, + createdAt: env.now() + )) + } + } return .success(.finished) } catch { - try? env.messenger.destroy() - return .success(.failed(error as NSError)) + var errors = [error as NSError] + do { + try env.messenger.destroy() + } catch { + errors.append(error as NSError) + } + return .success(.failed(errors)) } } .subscribe(on: env.bgQueue) @@ -154,9 +167,9 @@ public let restoreReducer = Reducer<RestoreState, RestoreAction, RestoreEnvironm state.isRestoring = false return .none - case .failed(let error): + case .failed(let errors): state.isRestoring = false - state.restoreFailure = error.localizedDescription + state.restoreFailures = errors.map(\.localizedDescription) return .none case .binding(_): diff --git a/Examples/xx-messenger/Sources/RestoreFeature/RestoreView.swift b/Examples/xx-messenger/Sources/RestoreFeature/RestoreView.swift index 06f7b91515725dcd81157dd876b3bcba2783a8e1..281f3f061e4a3ff5ccc1913bb45e45a03cff6576 100644 --- a/Examples/xx-messenger/Sources/RestoreFeature/RestoreView.swift +++ b/Examples/xx-messenger/Sources/RestoreFeature/RestoreView.swift @@ -21,7 +21,7 @@ public struct RestoreView: View { var isRestoring: Bool var focusedField: RestoreState.Field? var fileImportFailure: String? - var restoreFailure: String? + var restoreFailures: [String] init(state: RestoreState) { file = state.file.map { .init(name: $0.name, size: $0.data.count) } @@ -30,7 +30,7 @@ public struct RestoreView: View { isRestoring = state.isRestoring focusedField = state.focusedField fileImportFailure = state.fileImportFailure - restoreFailure = state.restoreFailure + restoreFailures = state.restoreFailures } } @@ -38,69 +38,9 @@ public struct RestoreView: View { WithViewStore(store, observe: ViewState.init) { viewStore in NavigationView { Form { - Section { - if let file = viewStore.file { - HStack(alignment: .bottom) { - Text(file.name) - Spacer() - Text(format(byteCount: file.size)) - } - } - - Button { - viewStore.send(.importFileTapped) - } label: { - Text("Import backup file") - } - .fileImporter( - isPresented: viewStore.binding( - get: \.isImportingFile, - send: { .set(\.$isImportingFile, $0) } - ), - allowedContentTypes: [.data], - onCompletion: { result in - viewStore.send(.fileImport(result.mapError { $0 as NSError })) - } - ) - - if let failure = viewStore.fileImportFailure { - Text("Error: \(failure)") - } - } header: { - Text("File") - } - .disabled(viewStore.isRestoring) - + fileSection(viewStore) if viewStore.file != nil { - Section { - SecureField("Passphrase", text: viewStore.binding( - get: \.passphrase, - send: { .set(\.$passphrase, $0) } - )) - .textContentType(.password) - .textInputAutocapitalization(.never) - .disableAutocorrection(true) - .focused($focusedField, equals: .passphrase) - - Button { - viewStore.send(.restoreTapped) - } label: { - HStack { - Text("Restore") - Spacer() - if viewStore.isRestoring { - ProgressView() - } - } - } - - if let failure = viewStore.restoreFailure { - Text("Error: \(failure)") - } - } header: { - Text("Backup") - } - .disabled(viewStore.isRestoring) + restoreSection(viewStore) } } .toolbar { @@ -110,7 +50,7 @@ public struct RestoreView: View { } label: { Text("Cancel") } - .disabled(viewStore.isRestoring) + .disabled(viewStore.isImportingFile || viewStore.isRestoring) } } .navigationTitle("Restore") @@ -121,6 +61,85 @@ public struct RestoreView: View { } } + @ViewBuilder func fileSection(_ viewStore: ViewStore<ViewState, RestoreAction>) -> some View { + Section { + if let file = viewStore.file { + HStack(alignment: .bottom) { + Text(file.name) + Spacer() + Text(format(byteCount: file.size)) + } + } else { + Button { + viewStore.send(.importFileTapped) + } label: { + Text("Import backup file") + } + .fileImporter( + isPresented: viewStore.binding( + get: \.isImportingFile, + send: { .set(\.$isImportingFile, $0) } + ), + allowedContentTypes: [.data], + onCompletion: { result in + viewStore.send(.fileImport(result.mapError { $0 as NSError })) + } + ) + .disabled(viewStore.isRestoring) + } + } header: { + Text("File") + } + + if let failure = viewStore.fileImportFailure { + Section { + Text(failure) + } header: { + Text("Error") + } + } + } + + @ViewBuilder func restoreSection(_ viewStore: ViewStore<ViewState, RestoreAction>) -> some View { + Section { + SecureField("Passphrase", text: viewStore.binding( + get: \.passphrase, + send: { .set(\.$passphrase, $0) } + )) + .textContentType(.password) + .textInputAutocapitalization(.never) + .disableAutocorrection(true) + .focused($focusedField, equals: .passphrase) + .disabled(viewStore.isRestoring) + + Button { + viewStore.send(.restoreTapped) + } label: { + HStack { + Text("Restore") + Spacer() + if viewStore.isRestoring { + ProgressView() + } + } + } + } header: { + Text("Restore") + } + .disabled(viewStore.isRestoring) + + if !viewStore.restoreFailures.isEmpty { + Section { + ForEach(Array(viewStore.restoreFailures.enumerated()), id: \.offset) { _, failure in + Text(failure) + } + .font(.footnote) + } header: { + Text("Error") + } + } + } + func format(byteCount: Int) -> String { let formatter = ByteCountFormatter() formatter.allowedUnits = [.useMB, .useKB, .useBytes] @@ -133,7 +152,19 @@ public struct RestoreView: View { public struct RestoreView_Previews: PreviewProvider { public static var previews: some View { RestoreView(store: Store( - initialState: RestoreState(), + initialState: RestoreState( + file: .init(name: "preview", data: Data()), + fileImportFailure: nil, + restoreFailures: [ + "Preview failure 1", + "Preview failure 2", + "Preview failure 3", + ], + focusedField: nil, + isImportingFile: false, + passphrase: "", + isRestoring: true + ), reducer: .empty, environment: () )) diff --git a/Examples/xx-messenger/Sources/SendRequestFeature/SendRequestFeature.swift b/Examples/xx-messenger/Sources/SendRequestFeature/SendRequestFeature.swift index f2625b91e6a4042624ad68141515ab34ace7c90c..18075179aedc62daff46847052e2bc0071f76b0a 100644 --- a/Examples/xx-messenger/Sources/SendRequestFeature/SendRequestFeature.swift +++ b/Examples/xx-messenger/Sources/SendRequestFeature/SendRequestFeature.swift @@ -1,4 +1,5 @@ import AppCore +import Combine import ComposableArchitecture import Foundation import XCTestDynamicOverlay @@ -40,7 +41,8 @@ public enum SendRequestAction: Equatable, BindableAction { case sendSucceeded case sendFailed(String) case binding(BindingAction<SendRequestState>) - case myContactFetched(XXClient.Contact?) + case myContactFetched(XXClient.Contact) + case myContactFetchFailed(NSError) } public struct SendRequestEnvironment { @@ -75,25 +77,30 @@ extension SendRequestEnvironment { public let sendRequestReducer = Reducer<SendRequestState, SendRequestAction, SendRequestEnvironment> { state, action, env in - enum DBFetchEffectID {} - switch action { case .start: - return Effect - .catching { try env.messenger.e2e.tryGet().getContact().getId() } - .tryMap { try env.db().fetchContactsPublisher(.init(id: [$0])) } - .flatMap { $0 } - .assertNoFailure() - .map(\.first) - .map { $0?.marshaled.map { XXClient.Contact.live($0) } } - .map(SendRequestAction.myContactFetched) - .subscribe(on: env.bgQueue) - .receive(on: env.mainQueue) - .eraseToEffect() - .cancellable(id: DBFetchEffectID.self, cancelInFlight: true) + return Effect.run { subscriber in + do { + let contact = try env.messenger.myContact() + subscriber.send(.myContactFetched(contact)) + } catch { + subscriber.send(.myContactFetchFailed(error as NSError)) + } + subscriber.send(completion: .finished) + return AnyCancellable {} + } + .receive(on: env.mainQueue) + .subscribe(on: env.bgQueue) + .eraseToEffect() case .myContactFetched(let contact): state.myContact = contact + state.failure = nil + return .none + + case .myContactFetchFailed(let failure): + state.myContact = nil + state.failure = failure.localizedDescription return .none case .sendTapped: diff --git a/Examples/xx-messenger/Tests/BackupFeatureTests/BackupFeatureTests.swift b/Examples/xx-messenger/Tests/BackupFeatureTests/BackupFeatureTests.swift index 21afbd5409a40c956cce46cc40726996f741cb70..d0c69eb87e2efea29f635fce84656b04d2e35cba 100644 --- a/Examples/xx-messenger/Tests/BackupFeatureTests/BackupFeatureTests.swift +++ b/Examples/xx-messenger/Tests/BackupFeatureTests/BackupFeatureTests.swift @@ -2,7 +2,6 @@ import ComposableArchitecture import XCTest import XXClient import XXMessengerClient -import XXModels @testable import BackupFeature final class BackupFeatureTests: XCTestCase { @@ -65,11 +64,7 @@ final class BackupFeatureTests: XCTestCase { func testStartBackup() { var actions: [Action]! var isBackupRunning: [Bool] = [true] - let contactID = "contact-id".data(using: .utf8)! - let dbContact = XXModels.Contact( - id: contactID, - username: "db-contact-username" - ) + let username = "test-username" let passphrase = "backup-password" let store = TestStore( @@ -79,28 +74,23 @@ final class BackupFeatureTests: XCTestCase { ) store.environment.mainQueue = .immediate store.environment.bgQueue = .immediate + store.environment.messenger.myContact.run = { includeFacts in + actions.append(.didGetMyContact(includingFacts: includeFacts)) + var contact = Contact.unimplemented("contact-data".data(using: .utf8)!) + contact.getFactsFromContact.run = { _ in [Fact(type: .username, value: username)] } + return contact + } store.environment.messenger.startBackup.run = { passphrase, params in actions.append(.didStartBackup(passphrase: passphrase, params: params)) } store.environment.messenger.isBackupRunning.run = { isBackupRunning.removeFirst() } - store.environment.messenger.e2e.get = { - var e2e: E2E = .unimplemented - e2e.getContact.run = { - var contact: XXClient.Contact = .unimplemented(Data()) - contact.getIdFromContact.run = { _ in contactID } - return contact - } - return e2e - } - store.environment.db.run = { - var db: Database = .unimplemented - db.fetchContacts.run = { _ in return [dbContact] } - return db - } actions = [] + store.send(.set(\.$focusedField, .passphrase)) { + $0.focusedField = .passphrase + } store.send(.set(\.$passphrase, passphrase)) { $0.passphrase = passphrase } @@ -110,12 +100,16 @@ final class BackupFeatureTests: XCTestCase { actions = [] store.send(.startTapped) { $0.isStarting = true + $0.focusedField = nil } XCTAssertNoDifference(actions, [ + .didGetMyContact( + includingFacts: .types([.username]) + ), .didStartBackup( passphrase: passphrase, - params: .init(username: dbContact.username!) + params: .init(username: username) ) ]) @@ -126,9 +120,8 @@ final class BackupFeatureTests: XCTestCase { } } - func testStartBackupWithoutDbContact() { + func testStartBackupWithoutContactUsername() { var isBackupRunning: [Bool] = [false] - let contactID = "contact-id".data(using: .utf8)! let store = TestStore( initialState: BackupState( @@ -139,29 +132,20 @@ final class BackupFeatureTests: XCTestCase { ) store.environment.mainQueue = .immediate store.environment.bgQueue = .immediate + store.environment.messenger.myContact.run = { _ in + var contact = Contact.unimplemented("contact-data".data(using: .utf8)!) + contact.getFactsFromContact.run = { _ in [] } + return contact + } store.environment.messenger.isBackupRunning.run = { isBackupRunning.removeFirst() } - store.environment.messenger.e2e.get = { - var e2e: E2E = .unimplemented - e2e.getContact.run = { - var contact: XXClient.Contact = .unimplemented(Data()) - contact.getIdFromContact.run = { _ in contactID } - return contact - } - return e2e - } - store.environment.db.run = { - var db: Database = .unimplemented - db.fetchContacts.run = { _ in [] } - return db - } store.send(.startTapped) { $0.isStarting = true } - let failure = BackupState.Error.dbContactNotFound + let failure = BackupState.Error.contactUsernameMissing store.receive(.didStart(failure: failure as NSError)) { $0.isRunning = false $0.isStarting = false @@ -169,13 +153,10 @@ final class BackupFeatureTests: XCTestCase { } } - func testStartBackupWithoutDbContactUsername() { + func testStartBackupMyContactFailure() { + struct Failure: Error {} + let failure = Failure() var isBackupRunning: [Bool] = [false] - let contactID = "contact-id".data(using: .utf8)! - let dbContact = XXModels.Contact( - id: contactID, - username: nil - ) let store = TestStore( initialState: BackupState( @@ -186,29 +167,15 @@ final class BackupFeatureTests: XCTestCase { ) store.environment.mainQueue = .immediate store.environment.bgQueue = .immediate + store.environment.messenger.myContact.run = { _ in throw failure } store.environment.messenger.isBackupRunning.run = { isBackupRunning.removeFirst() } - store.environment.messenger.e2e.get = { - var e2e: E2E = .unimplemented - e2e.getContact.run = { - var contact: XXClient.Contact = .unimplemented(Data()) - contact.getIdFromContact.run = { _ in contactID } - return contact - } - return e2e - } - store.environment.db.run = { - var db: Database = .unimplemented - db.fetchContacts.run = { _ in [dbContact] } - return db - } store.send(.startTapped) { $0.isStarting = true } - let failure = BackupState.Error.dbContactUsernameMissing store.receive(.didStart(failure: failure as NSError)) { $0.isRunning = false $0.isStarting = false @@ -216,15 +183,10 @@ final class BackupFeatureTests: XCTestCase { } } - func testStartBackupFailure() { + func testStartBackupStartFailure() { struct Failure: Error {} let failure = Failure() var isBackupRunning: [Bool] = [false] - let contactID = "contact-id".data(using: .utf8)! - let dbContact = XXModels.Contact( - id: contactID, - username: "db-contact-username" - ) let store = TestStore( initialState: BackupState( @@ -235,26 +197,17 @@ final class BackupFeatureTests: XCTestCase { ) store.environment.mainQueue = .immediate store.environment.bgQueue = .immediate + store.environment.messenger.myContact.run = { _ in + var contact = Contact.unimplemented("data".data(using: .utf8)!) + contact.getFactsFromContact.run = { _ in [Fact(type: .username, value: "username")] } + return contact + } store.environment.messenger.startBackup.run = { _, _ in throw failure } store.environment.messenger.isBackupRunning.run = { isBackupRunning.removeFirst() } - store.environment.messenger.e2e.get = { - var e2e: E2E = .unimplemented - e2e.getContact.run = { - var contact: XXClient.Contact = .unimplemented(Data()) - contact.getIdFromContact.run = { _ in contactID } - return contact - } - return e2e - } - store.environment.db.run = { - var db: Database = .unimplemented - db.fetchContacts.run = { _ in return [dbContact] } - return db - } store.send(.startTapped) { $0.isStarting = true @@ -459,5 +412,5 @@ private enum Action: Equatable { case didResumeBackup case didStopBackup case didRemoveBackup - case didFetchContacts(XXModels.Contact.Query) + case didGetMyContact(includingFacts: MessengerMyContact.IncludeFacts?) } diff --git a/Examples/xx-messenger/Tests/ContactFeatureTests/ContactFeatureTests.swift b/Examples/xx-messenger/Tests/ContactFeatureTests/ContactFeatureTests.swift index feeccc98094da76d1f5ebd21122a4b3214afbcb7..c200094f49e9164391cdd20afdc53ed793a9c736 100644 --- a/Examples/xx-messenger/Tests/ContactFeatureTests/ContactFeatureTests.swift +++ b/Examples/xx-messenger/Tests/ContactFeatureTests/ContactFeatureTests.swift @@ -3,6 +3,7 @@ import CheckContactAuthFeature import Combine import ComposableArchitecture import ConfirmRequestFeature +import ContactLookupFeature import CustomDump import SendRequestFeature import VerifyContactFeature @@ -99,6 +100,55 @@ final class ContactFeatureTests: XCTestCase { XCTAssertNoDifference(dbDidSaveContact, [expectedSavedContact]) } + func testLookupTapped() { + let contactId = "contact-id".data(using: .utf8)! + let store = TestStore( + initialState: ContactState( + id: contactId + ), + reducer: contactReducer, + environment: .unimplemented + ) + + store.send(.lookupTapped) { + $0.lookup = ContactLookupState(id: contactId) + } + } + + func testLookupDismissed() { + let contactId = "contact-id".data(using: .utf8)! + let store = TestStore( + initialState: ContactState( + id: contactId, + lookup: ContactLookupState(id: contactId) + ), + reducer: contactReducer, + environment: .unimplemented + ) + + store.send(.lookupDismissed) { + $0.lookup = nil + } + } + + func testLookupDidLookup() { + let contactId = "contact-id".data(using: .utf8)! + let contact = Contact.unimplemented("contact-data".data(using: .utf8)!) + let store = TestStore( + initialState: ContactState( + id: contactId, + lookup: ContactLookupState(id: contactId) + ), + reducer: contactReducer, + environment: .unimplemented + ) + + store.send(.lookup(.didLookup(contact))) { + $0.xxContact = contact + $0.lookup = nil + } + } + func testSendRequestWithDBContact() { var dbContact = XXModels.Contact(id: "contact-id".data(using: .utf8)!) dbContact.marshaled = "contact-data".data(using: .utf8)! diff --git a/Examples/xx-messenger/Tests/ContactLookupFeatureTests/ContactLookupFeatureTests.swift b/Examples/xx-messenger/Tests/ContactLookupFeatureTests/ContactLookupFeatureTests.swift new file mode 100644 index 0000000000000000000000000000000000000000..76dde8d07350e240ccf86acdbef175834851b56f --- /dev/null +++ b/Examples/xx-messenger/Tests/ContactLookupFeatureTests/ContactLookupFeatureTests.swift @@ -0,0 +1,60 @@ +import ComposableArchitecture +import XCTest +import XXClient +@testable import ContactLookupFeature + +final class ContactLookupFeatureTests: XCTestCase { + func testLookup() { + let id: Data = "1234".data(using: .utf8)! + var didLookupId: [Data] = [] + let lookedUpContact = Contact.unimplemented("123data".data(using: .utf8)!) + + let store = TestStore( + initialState: ContactLookupState(id: id), + reducer: contactLookupReducer, + environment: .unimplemented + ) + store.environment.mainQueue = .immediate + store.environment.bgQueue = .immediate + store.environment.messenger.lookupContact.run = { id in + didLookupId.append(id) + return lookedUpContact + } + + store.send(.lookupTapped) { + $0.isLookingUp = true + $0.failure = nil + } + + XCTAssertEqual(didLookupId, [id]) + + store.receive(.didLookup(lookedUpContact)) { + $0.isLookingUp = false + $0.failure = nil + } + } + + func testLookupFailure() { + let id: Data = "1234".data(using: .utf8)! + let failure = NSError(domain: "test", code: 0) + + let store = TestStore( + initialState: ContactLookupState(id: id), + reducer: contactLookupReducer, + environment: .unimplemented + ) + store.environment.mainQueue = .immediate + store.environment.bgQueue = .immediate + store.environment.messenger.lookupContact.run = { _ in throw failure } + + store.send(.lookupTapped) { + $0.isLookingUp = true + $0.failure = nil + } + + store.receive(.didFail(failure)) { + $0.isLookingUp = false + $0.failure = failure.localizedDescription + } + } +} diff --git a/Examples/xx-messenger/Tests/MyContactFeatureTests/MyContactFeatureTests.swift b/Examples/xx-messenger/Tests/MyContactFeatureTests/MyContactFeatureTests.swift index 0e55329610aeb8b51611cc539447364ba9963d75..830e97156363b151fbff21276169d8dfc2095720 100644 --- a/Examples/xx-messenger/Tests/MyContactFeatureTests/MyContactFeatureTests.swift +++ b/Examples/xx-messenger/Tests/MyContactFeatureTests/MyContactFeatureTests.swift @@ -645,6 +645,7 @@ final class MyContactFeatureTests: XCTestCase { func testLoadFactsFromClient() { let contactId = "contact-id".data(using: .utf8)! let dbContact = XXModels.Contact(id: contactId) + let username = "user234" let email = "test@email.com" let phone = "123456789" @@ -672,6 +673,7 @@ final class MyContactFeatureTests: XCTestCase { var ud: UserDiscovery = .unimplemented ud.getFacts.run = { [ + Fact(type: .username, value: username), Fact(type: .email, value: email), Fact(type: .phone, value: phone), ] @@ -697,6 +699,7 @@ final class MyContactFeatureTests: XCTestCase { XCTAssertNoDifference(didFetchContacts, [.init(id: [contactId])]) var expectedSavedContact = dbContact + expectedSavedContact.username = username expectedSavedContact.email = email expectedSavedContact.phone = phone XCTAssertNoDifference(didSaveContact, [expectedSavedContact]) diff --git a/Examples/xx-messenger/Tests/RegisterFeatureTests/RegisterFeatureTests.swift b/Examples/xx-messenger/Tests/RegisterFeatureTests/RegisterFeatureTests.swift index b156335b3de889ad21d9819e483fc5e27ef7c6ed..e0ffc5ef4fe9a3bc5f9d9f91d288025cd1065c85 100644 --- a/Examples/xx-messenger/Tests/RegisterFeatureTests/RegisterFeatureTests.swift +++ b/Examples/xx-messenger/Tests/RegisterFeatureTests/RegisterFeatureTests.swift @@ -8,38 +8,40 @@ import XXModels final class RegisterFeatureTests: XCTestCase { func testRegister() throws { + let now = Date() + let username = "registering-username" + let myContactId = "my-contact-id".data(using: .utf8)! + let myContactData = "my-contact-data".data(using: .utf8)! + let myContactUsername = username + let myContactEmail = "my-contact-email" + let myContactPhone = "my-contact-phone" + let myContactFacts = [ + Fact(type: .username, value: myContactUsername), + Fact(type: .email, value: myContactEmail), + Fact(type: .phone, value: myContactPhone), + ] + + var messengerDidRegisterUsername: [String] = [] + var didGetMyContact: [MessengerMyContact.IncludeFacts?] = [] + var dbDidSaveContact: [XXModels.Contact] = [] + let store = TestStore( initialState: RegisterState(), reducer: registerReducer, environment: .unimplemented ) - - let now = Date() - let mainQueue = DispatchQueue.test - let bgQueue = DispatchQueue.test - var didSetFactsOnContact: [[XXClient.Fact]] = [] - var dbDidSaveContact: [XXModels.Contact] = [] - var messengerDidRegisterUsername: [String] = [] - store.environment.now = { now } - store.environment.mainQueue = mainQueue.eraseToAnyScheduler() - store.environment.bgQueue = bgQueue.eraseToAnyScheduler() + store.environment.mainQueue = .immediate + store.environment.bgQueue = .immediate store.environment.messenger.register.run = { username in messengerDidRegisterUsername.append(username) } - store.environment.messenger.e2e.get = { - var e2e: E2E = .unimplemented - e2e.getContact.run = { - var contact = XXClient.Contact.unimplemented("contact-data".data(using: .utf8)!) - contact.getIdFromContact.run = { _ in "contact-id".data(using: .utf8)! } - contact.getFactsFromContact.run = { _ in [] } - contact.setFactsOnContact.run = { data, facts in - didSetFactsOnContact.append(facts) - return data - } - return contact - } - return e2e + store.environment.messenger.myContact.run = { includeFacts in + didGetMyContact.append(includeFacts) + var contact = XXClient.Contact.unimplemented(myContactData) + contact.getIdFromContact.run = { _ in myContactId } + contact.getFactsFromContact.run = { _ in myContactFacts } + return contact } store.environment.db.run = { var db: Database = .unimplemented @@ -50,33 +52,34 @@ final class RegisterFeatureTests: XCTestCase { return db } - store.send(.set(\.$username, "NewUser")) { - $0.username = "NewUser" + store.send(.set(\.$focusedField, .username)) { + $0.focusedField = .username + } + + store.send(.set(\.$username, myContactUsername)) { + $0.username = myContactUsername } store.send(.registerTapped) { + $0.focusedField = nil $0.isRegistering = true } - XCTAssertNoDifference(messengerDidRegisterUsername, []) - XCTAssertNoDifference(dbDidSaveContact, []) - - bgQueue.advance() - - XCTAssertNoDifference(messengerDidRegisterUsername, ["NewUser"]) - XCTAssertNoDifference(didSetFactsOnContact, [[Fact(type: .username, value: "NewUser")]]) + XCTAssertNoDifference(messengerDidRegisterUsername, [username]) XCTAssertNoDifference(dbDidSaveContact, [ XXModels.Contact( - id: "contact-id".data(using: .utf8)!, - marshaled: "contact-data".data(using: .utf8)!, - username: "NewUser", + id: myContactId, + marshaled: myContactData, + username: myContactUsername, + email: myContactEmail, + phone: myContactPhone, createdAt: now ) ]) - mainQueue.advance() - - store.receive(.finished) + store.receive(.finished) { + $0.isRegistering = false + } } func testGetDbFailure() throws { @@ -139,4 +142,78 @@ final class RegisterFeatureTests: XCTestCase { $0.failure = error.localizedDescription } } + + func testRegisterUsernameMismatchFailure() throws { + let now = Date() + let username = "registering-username" + let myContactId = "my-contact-id".data(using: .utf8)! + let myContactData = "my-contact-data".data(using: .utf8)! + let myContactUsername = "my-contact-username" + let myContactEmail = "my-contact-email" + let myContactPhone = "my-contact-phone" + let myContactFacts = [ + Fact(type: .username, value: myContactUsername), + Fact(type: .email, value: myContactEmail), + Fact(type: .phone, value: myContactPhone), + ] + + var messengerDidRegisterUsername: [String] = [] + var didGetMyContact: [MessengerMyContact.IncludeFacts?] = [] + var dbDidSaveContact: [XXModels.Contact] = [] + + let store = TestStore( + initialState: RegisterState( + username: username + ), + reducer: registerReducer, + environment: .unimplemented + ) + store.environment.now = { now } + store.environment.mainQueue = .immediate + store.environment.bgQueue = .immediate + store.environment.messenger.register.run = { username in + messengerDidRegisterUsername.append(username) + } + store.environment.messenger.myContact.run = { includeFacts in + didGetMyContact.append(includeFacts) + var contact = XXClient.Contact.unimplemented(myContactData) + contact.getIdFromContact.run = { _ in myContactId } + contact.getFactsFromContact.run = { _ in myContactFacts } + return contact + } + store.environment.db.run = { + var db: Database = .unimplemented + db.saveContact.run = { contact in + dbDidSaveContact.append(contact) + return contact + } + return db + } + + store.send(.registerTapped) { + $0.focusedField = nil + $0.isRegistering = true + } + + XCTAssertNoDifference(messengerDidRegisterUsername, [username]) + XCTAssertNoDifference(dbDidSaveContact, [ + XXModels.Contact( + id: myContactId, + marshaled: myContactData, + username: myContactUsername, + email: myContactEmail, + phone: myContactPhone, + createdAt: now + ) + ]) + + let failure = RegisterState.Error.usernameMismatch( + registering: username, + registered: myContactUsername + ) + store.receive(.failed(failure.localizedDescription)) { + $0.isRegistering = false + $0.failure = failure.localizedDescription + } + } } diff --git a/Examples/xx-messenger/Tests/RestoreFeatureTests/RestoreFeatureTests.swift b/Examples/xx-messenger/Tests/RestoreFeatureTests/RestoreFeatureTests.swift index 9b0a7b87928e8568acf78c8f63077920a8c9fd2e..716d7650255b277cad37fb40ad5a73c4aee5bace 100644 --- a/Examples/xx-messenger/Tests/RestoreFeatureTests/RestoreFeatureTests.swift +++ b/Examples/xx-messenger/Tests/RestoreFeatureTests/RestoreFeatureTests.swift @@ -80,12 +80,17 @@ final class RestoreFeatureTests: XCTestCase { let backupData = "backup-data".data(using: .utf8)! let backupPassphrase = "backup-passphrase" let restoredFacts = [ - Fact(type: .email, value: "restored-email"), - Fact(type: .phone, value: "restored-phone"), + Fact(type: .username, value: "restored-fact-username"), + Fact(type: .email, value: "restored-fact-email"), + Fact(type: .phone, value: "restored-fact-phone"), ] let restoreResult = MessengerRestoreBackup.Result( - restoredParams: BackupParams(username: "restored-username"), - restoredContacts: [] + restoredParams: BackupParams(username: "restored-param-username"), + restoredContacts: [ + "contact-1-id".data(using: .utf8)!, + "contact-2-id".data(using: .utf8)!, + "contact-3-id".data(using: .utf8)!, + ] ) let now = Date() let contactId = "contact-id".data(using: .utf8)! @@ -93,6 +98,7 @@ final class RestoreFeatureTests: XCTestCase { var udFacts: [Fact] = [] var didRestoreWithData: [Data] = [] var didRestoreWithPassphrase: [String] = [] + var didFetchContacts: [XXModels.Contact.Query] = [] var didSaveContact: [XXModels.Contact] = [] let store = TestStore( @@ -128,6 +134,10 @@ final class RestoreFeatureTests: XCTestCase { } store.environment.db.run = { var db: Database = .unimplemented + db.fetchContacts.run = { query in + didFetchContacts.append(query) + return [] + } db.saveContact.run = { contact in didSaveContact.append(contact) return contact @@ -145,13 +155,32 @@ final class RestoreFeatureTests: XCTestCase { XCTAssertNoDifference(didRestoreWithData, [backupData]) XCTAssertNoDifference(didRestoreWithPassphrase, [backupPassphrase]) - XCTAssertNoDifference(didSaveContact, [Contact( - id: contactId, - username: restoreResult.restoredParams.username, - email: restoredFacts.get(.email)?.value, - phone: restoredFacts.get(.phone)?.value, - createdAt: now - )]) + XCTAssertNoDifference(didFetchContacts, [ + .init(id: [restoreResult.restoredContacts[0]]), + .init(id: [restoreResult.restoredContacts[1]]), + .init(id: [restoreResult.restoredContacts[2]]), + ]) + XCTAssertNoDifference(didSaveContact, [ + Contact( + id: contactId, + username: restoredFacts.get(.username)?.value, + email: restoredFacts.get(.email)?.value, + phone: restoredFacts.get(.phone)?.value, + createdAt: now + ), + Contact( + id: restoreResult.restoredContacts[0], + createdAt: now + ), + Contact( + id: restoreResult.restoredContacts[1], + createdAt: now + ), + Contact( + id: restoreResult.restoredContacts[2], + createdAt: now + ), + ]) store.receive(.finished) { $0.isRestoring = false @@ -171,10 +200,10 @@ final class RestoreFeatureTests: XCTestCase { } func testRestoreFailure() { - struct Failure: Error {} - let failure = Failure() - - var didDestroyMessenger = 0 + enum Failure: Error { + case restore + case destroy + } let store = TestStore( initialState: RestoreState( @@ -186,18 +215,19 @@ final class RestoreFeatureTests: XCTestCase { store.environment.bgQueue = .immediate store.environment.mainQueue = .immediate - store.environment.messenger.restoreBackup.run = { _, _ in throw failure } - store.environment.messenger.destroy.run = { didDestroyMessenger += 1 } + store.environment.messenger.restoreBackup.run = { _, _ in throw Failure.restore } + store.environment.messenger.destroy.run = { throw Failure.destroy } store.send(.restoreTapped) { $0.isRestoring = true } - XCTAssertEqual(didDestroyMessenger, 1) - - store.receive(.failed(failure as NSError)) { + store.receive(.failed([Failure.restore as NSError, Failure.destroy as NSError])) { $0.isRestoring = false - $0.restoreFailure = failure.localizedDescription + $0.restoreFailures = [ + Failure.restore.localizedDescription, + Failure.destroy.localizedDescription, + ] } } } diff --git a/Examples/xx-messenger/Tests/SendRequestFeatureTests/SendRequestFeatureTests.swift b/Examples/xx-messenger/Tests/SendRequestFeatureTests/SendRequestFeatureTests.swift index 9b15f6cd39884e45f7214875ba586cbb0e2b7712..4c68abab06a609346b47b4fe472d5f377417c2f5 100644 --- a/Examples/xx-messenger/Tests/SendRequestFeatureTests/SendRequestFeatureTests.swift +++ b/Examples/xx-messenger/Tests/SendRequestFeatureTests/SendRequestFeatureTests.swift @@ -3,11 +3,16 @@ import ComposableArchitecture import CustomDump import XCTest import XXClient +import XXMessengerClient import XXModels @testable import SendRequestFeature final class SendRequestFeatureTests: XCTestCase { func testStart() { + let myContact = XXClient.Contact.unimplemented("my-contact-data".data(using: .utf8)!) + + var didGetMyContact: [MessengerMyContact.IncludeFacts?] = [] + let store = TestStore( initialState: SendRequestState( contact: .unimplemented("contact-data".data(using: .utf8)!) @@ -15,47 +20,41 @@ final class SendRequestFeatureTests: XCTestCase { reducer: sendRequestReducer, environment: .unimplemented ) - - var dbDidFetchContacts: [XXModels.Contact.Query] = [] - let dbContactsPublisher = PassthroughSubject<[XXModels.Contact], Error>() - store.environment.mainQueue = .immediate store.environment.bgQueue = .immediate - store.environment.messenger.e2e.get = { - var e2e: E2E = .unimplemented - e2e.getContact.run = { - var contact: XXClient.Contact = .unimplemented("my-contact-data".data(using: .utf8)!) - contact.getIdFromContact.run = { _ in "my-contact-id".data(using: .utf8)! } - return contact - } - return e2e - } - store.environment.db.run = { - var db: Database = .unimplemented - db.fetchContactsPublisher.run = { query in - dbDidFetchContacts.append(query) - return dbContactsPublisher.eraseToAnyPublisher() - } - return db + store.environment.messenger.myContact.run = { includeFacts in + didGetMyContact.append(includeFacts) + return myContact } store.send(.start) - XCTAssertNoDifference(dbDidFetchContacts, [.init(id: ["my-contact-id".data(using: .utf8)!])]) + store.receive(.myContactFetched(myContact)) { + $0.myContact = myContact + } + } - dbContactsPublisher.send([]) + func testMyContactFailure() { + struct Failure: Error {} + let failure = Failure() - store.receive(.myContactFetched(nil)) + let store = TestStore( + initialState: SendRequestState( + contact: .unimplemented("contact-data".data(using: .utf8)!) + ), + reducer: sendRequestReducer, + environment: .unimplemented + ) + store.environment.mainQueue = .immediate + store.environment.bgQueue = .immediate + store.environment.messenger.myContact.run = { _ in throw failure } - var myDbContact = XXModels.Contact(id: "my-contact-id".data(using: .utf8)!) - myDbContact.marshaled = "my-contact-data".data(using: .utf8)! - dbContactsPublisher.send([myDbContact]) + store.send(.start) - store.receive(.myContactFetched(.live("my-contact-data".data(using: .utf8)!))) { - $0.myContact = .live("my-contact-data".data(using: .utf8)!) + store.receive(.myContactFetchFailed(failure as NSError)) { + $0.myContact = nil + $0.failure = failure.localizedDescription } - - dbContactsPublisher.send(completion: .finished) } func testSendRequest() { diff --git a/Sources/XXClient/Callbacks/UdMultiLookupCallback.swift b/Sources/XXClient/Callbacks/UdMultiLookupCallback.swift index 1782c01ef83d2a74feef73f490e422f201c67c9a..cc40b60695912e2b5f76663d214a9592f854f248 100644 --- a/Sources/XXClient/Callbacks/UdMultiLookupCallback.swift +++ b/Sources/XXClient/Callbacks/UdMultiLookupCallback.swift @@ -49,21 +49,21 @@ extension UdMultiLookupCallback { if let err = err { result.errors.append(err as NSError) } - if let contactListJSON = contactListJSON { - do { - result.contacts = try JSONDecoder() - .decode([Data].self, from: contactListJSON) - .map { Contact.live($0) } - } catch { - result.errors.append(error as NSError) + do { + if let data = contactListJSON, + let contactListJSON = try JSONDecoder().decode([Data]?.self, from: data) { + result.contacts = contactListJSON.map { Contact.live($0) } } + } catch { + result.errors.append(error as NSError) } - if let failedIDs = failedIDs { - do { - result.failedIds = try JSONDecoder().decode([Data].self, from: failedIDs) - } catch { - result.errors.append(error as NSError) + do { + if let data = failedIDs, + let failedIDs = try JSONDecoder().decode([Data]?.self, from: data) { + result.failedIds = failedIDs } + } catch { + result.errors.append(error as NSError) } callback.handle(result) }