Skip to content
Snippets Groups Projects
Commit a62fbdf3 authored by Dariusz Rybicki's avatar Dariusz Rybicki
Browse files

Merge branch 'feature/restore-contacts-from-backup' into 'development'

Restore contacts from backup

See merge request elixxir/elixxir-dapps-sdk-swift!112
parents 5f861966 dcef4f39
Branches
Tags
2 merge requests!112Restore contacts from backup,!102Release 1.0.0
Showing
with 697 additions and 198 deletions
<?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>
......@@ -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: [
......
......@@ -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
......
import Foundation
extension Data {
public func hexString(bytesSeparator: String = " ") -> String {
map { String(format: "%02hhx\(bytesSeparator)", $0) }.joined()
}
}
......@@ -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
......
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,
......
......@@ -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(
......
......@@ -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),
......
......@@ -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,
......
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
}
}
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
......@@ -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)
......
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: {
......
......@@ -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
}
}
......
......@@ -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(_):
......
......@@ -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,6 +38,30 @@ public struct RestoreView: View {
WithViewStore(store, observe: ViewState.init) { viewStore in
NavigationView {
Form {
fileSection(viewStore)
if viewStore.file != nil {
restoreSection(viewStore)
}
}
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button {
viewStore.send(.finished)
} label: {
Text("Cancel")
}
.disabled(viewStore.isImportingFile || viewStore.isRestoring)
}
}
.navigationTitle("Restore")
.onChange(of: viewStore.focusedField) { focusedField = $0 }
.onChange(of: focusedField) { viewStore.send(.set(\.$focusedField, $0)) }
}
.navigationViewStyle(.stack)
}
}
@ViewBuilder func fileSection(_ viewStore: ViewStore<ViewState, RestoreAction>) -> some View {
Section {
if let file = viewStore.file {
HStack(alignment: .bottom) {
......@@ -45,8 +69,7 @@ public struct RestoreView: View {
Spacer()
Text(format(byteCount: file.size))
}
}
} else {
Button {
viewStore.send(.importFileTapped)
} label: {
......@@ -62,16 +85,22 @@ public struct RestoreView: View {
viewStore.send(.fileImport(result.mapError { $0 as NSError }))
}
)
if let failure = viewStore.fileImportFailure {
Text("Error: \(failure)")
.disabled(viewStore.isRestoring)
}
} header: {
Text("File")
}
.disabled(viewStore.isRestoring)
if viewStore.file != nil {
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,
......@@ -81,6 +110,7 @@ public struct RestoreView: View {
.textInputAutocapitalization(.never)
.disableAutocorrection(true)
.focused($focusedField, equals: .passphrase)
.disabled(viewStore.isRestoring)
Button {
viewStore.send(.restoreTapped)
......@@ -93,31 +123,20 @@ public struct RestoreView: View {
}
}
}
if let failure = viewStore.restoreFailure {
Text("Error: \(failure)")
}
} header: {
Text("Backup")
}
.disabled(viewStore.isRestoring)
}
}
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button {
viewStore.send(.finished)
} label: {
Text("Cancel")
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")
}
.navigationTitle("Restore")
.onChange(of: viewStore.focusedField) { focusedField = $0 }
.onChange(of: focusedField) { viewStore.send(.set(\.$focusedField, $0)) }
}
.navigationViewStyle(.stack)
}
}
......@@ -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: ()
))
......
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)
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()
.cancellable(id: DBFetchEffectID.self, cancelInFlight: true)
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:
......
......@@ -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.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 }
store.environment.messenger.myContact.run = { _ in
var contact = Contact.unimplemented("contact-data".data(using: .utf8)!)
contact.getFactsFromContact.run = { _ in [] }
return contact
}
return e2e
}
store.environment.db.run = {
var db: Database = .unimplemented
db.fetchContacts.run = { _ in [] }
return db
store.environment.messenger.isBackupRunning.run = {
isBackupRunning.removeFirst()
}
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?)
}
......@@ -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)!
......
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
}
}
}
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment