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

Implement backup restoration example

parent e92cb521
No related branches found
No related tags found
2 merge requests!102Release 1.0.0,!100Messenger - restore from backup
......@@ -273,6 +273,7 @@ let package = Package(
name: "RestoreFeature",
dependencies: [
.product(name: "ComposableArchitecture", package: "swift-composable-architecture"),
.product(name: "XXMessengerClient", package: "elixxir-dapps-sdk-swift"),
],
swiftSettings: swiftSettings
),
......
......@@ -95,7 +95,12 @@ extension AppEnvironment {
)
},
restore: {
RestoreEnvironment()
RestoreEnvironment(
messenger: messenger,
loadData: .live,
mainQueue: mainQueue,
bgQueue: bgQueue
)
},
home: {
HomeEnvironment(
......
import Combine
import ComposableArchitecture
import Foundation
import XCTestDynamicOverlay
import XXMessengerClient
public struct RestoreState: Equatable {
public init() {}
public enum Field: String, Hashable {
case passphrase
}
public struct File: Equatable {
public init(name: String, data: Data) {
self.name = name
self.data = data
}
public var name: String
public var data: Data
}
public init(
file: File? = nil,
fileImportFailure: String? = nil,
restoreFailure: String? = nil,
focusedField: Field? = nil,
isImportingFile: Bool = false,
passphrase: String = "",
isRestoring: Bool = false
) {
self.file = file
self.fileImportFailure = fileImportFailure
self.restoreFailure = restoreFailure
self.focusedField = focusedField
self.isImportingFile = isImportingFile
self.passphrase = passphrase
self.isRestoring = isRestoring
}
public var file: File?
public var fileImportFailure: String?
public var restoreFailure: String?
@BindableState public var focusedField: Field?
@BindableState public var isImportingFile: Bool
@BindableState public var passphrase: String
@BindableState public var isRestoring: Bool
}
public enum RestoreAction: Equatable {
public enum RestoreAction: Equatable, BindableAction {
case importFileTapped
case fileImport(Result<URL, NSError>)
case restoreTapped
case finished
case failed(NSError)
case binding(BindingAction<RestoreState>)
}
public struct RestoreEnvironment {
public init() {}
public init(
messenger: Messenger,
loadData: URLDataLoader,
mainQueue: AnySchedulerOf<DispatchQueue>,
bgQueue: AnySchedulerOf<DispatchQueue>
) {
self.messenger = messenger
self.loadData = loadData
self.mainQueue = mainQueue
self.bgQueue = bgQueue
}
public var messenger: Messenger
public var loadData: URLDataLoader
public var mainQueue: AnySchedulerOf<DispatchQueue>
public var bgQueue: AnySchedulerOf<DispatchQueue>
}
extension RestoreEnvironment {
public static let unimplemented = RestoreEnvironment()
public static let unimplemented = RestoreEnvironment(
messenger: .unimplemented,
loadData: .unimplemented,
mainQueue: .unimplemented,
bgQueue: .unimplemented
)
}
public let restoreReducer = Reducer<RestoreState, RestoreAction, RestoreEnvironment>.empty
public let restoreReducer = Reducer<RestoreState, RestoreAction, RestoreEnvironment>
{ state, action, env in
switch action {
case .importFileTapped:
state.isImportingFile = true
state.fileImportFailure = nil
return .none
case .fileImport(.success(let url)):
state.isImportingFile = false
do {
state.file = .init(
name: url.lastPathComponent,
data: try env.loadData(url)
)
state.fileImportFailure = nil
} catch {
state.file = nil
state.fileImportFailure = error.localizedDescription
}
return .none
case .fileImport(.failure(let error)):
state.isImportingFile = false
state.file = nil
state.fileImportFailure = error.localizedDescription
return .none
case .restoreTapped:
guard let backupData = state.file?.data, backupData.count > 0 else { return .none }
let backupPassphrase = state.passphrase
state.isRestoring = true
state.restoreFailure = nil
return Effect.result {
do {
_ = try env.messenger.restoreBackup(
backupData: backupData,
backupPassphrase: backupPassphrase
)
return .success(.finished)
} catch {
return .success(.failed(error as NSError))
}
}
.subscribe(on: env.bgQueue)
.receive(on: env.mainQueue)
.eraseToEffect()
case .finished:
state.isRestoring = false
return .none
case .failed(let error):
state.isRestoring = false
state.restoreFailure = error.localizedDescription
return .none
case .binding(_):
return .none
}
}
.binding()
......@@ -7,9 +7,31 @@ public struct RestoreView: View {
}
let store: Store<RestoreState, RestoreAction>
@FocusState var focusedField: RestoreState.Field?
struct ViewState: Equatable {
init(state: RestoreState) {}
struct File: Equatable {
var name: String
var size: Int
}
var file: File?
var isImportingFile: Bool
var passphrase: String
var isRestoring: Bool
var focusedField: RestoreState.Field?
var fileImportFailure: String?
var restoreFailure: String?
init(state: RestoreState) {
file = state.file.map { .init(name: $0.name, size: $0.data.count) }
isImportingFile = state.isImportingFile
passphrase = state.passphrase
isRestoring = state.isRestoring
focusedField = state.focusedField
fileImportFailure = state.fileImportFailure
restoreFailure = state.restoreFailure
}
}
public var body: some View {
......@@ -17,23 +39,94 @@ public struct RestoreView: View {
NavigationView {
Form {
Section {
Text("Not implemented")
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)
Section {
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)
}
}
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button {
viewStore.send(.finished)
} label: {
Text("OK")
.frame(maxWidth: .infinity)
Text("Cancel")
}
.disabled(viewStore.isRestoring)
}
}
.navigationTitle("Restore")
.onChange(of: viewStore.focusedField) { focusedField = $0 }
.onChange(of: focusedField) { viewStore.send(.set(\.$focusedField, $0)) }
}
.navigationViewStyle(.stack)
}
}
func format(byteCount: Int) -> String {
let formatter = ByteCountFormatter()
formatter.allowedUnits = [.useMB, .useKB, .useBytes]
formatter.countStyle = .binary
return formatter.string(fromByteCount: Int64(byteCount))
}
}
#if DEBUG
......
import Foundation
import XCTestDynamicOverlay
public struct URLDataLoader {
public var load: (URL) throws -> Data
public func callAsFunction(_ url: URL) throws -> Data {
try load(url)
}
}
extension URLDataLoader {
public static let live = URLDataLoader { url in
try Data(contentsOf: url)
}
}
extension URLDataLoader {
public static let unimplemented = URLDataLoader(
load: XCTUnimplemented("\(Self.self)")
)
}
import CustomDump
import ComposableArchitecture
import XCTest
@testable import RestoreFeature
import XXMessengerClient
final class RestoreFeatureTests: XCTestCase {
func testFinish() {
func testFileImport() {
let fileURL = URL(string: "file-url")!
var didLoadDataFromURL: [URL] = []
let dataFromURL = "data-from-url".data(using: .utf8)!
let store = TestStore(
initialState: RestoreState(),
reducer: restoreReducer,
environment: .unimplemented
)
store.send(.finished)
store.environment.loadData.load = { url in
didLoadDataFromURL.append(url)
return dataFromURL
}
store.send(.importFileTapped) {
$0.isImportingFile = true
}
store.send(.fileImport(.success(fileURL))) {
$0.isImportingFile = false
$0.file = .init(name: fileURL.lastPathComponent, data: dataFromURL)
$0.fileImportFailure = nil
}
XCTAssertNoDifference(didLoadDataFromURL, [fileURL])
}
func testFileImportFailure() {
struct Failure: Error {}
let failure = Failure()
let store = TestStore(
initialState: RestoreState(
isImportingFile: true
),
reducer: restoreReducer,
environment: .unimplemented
)
store.send(.fileImport(.failure(failure as NSError))) {
$0.isImportingFile = false
$0.file = nil
$0.fileImportFailure = failure.localizedDescription
}
}
func testFileImportLoadingFailure() {
struct Failure: Error {}
let failure = Failure()
let store = TestStore(
initialState: RestoreState(
isImportingFile: true
),
reducer: restoreReducer,
environment: .unimplemented
)
store.environment.loadData.load = { _ in throw failure }
store.send(.fileImport(.success(URL(string: "test")!))) {
$0.isImportingFile = false
$0.file = nil
$0.fileImportFailure = failure.localizedDescription
}
}
func testRestore() {
let backupData = "backup-data".data(using: .utf8)!
let backupPassphrase = "backup-passphrase"
let restoreResult = MessengerRestoreBackup.Result(
restoredParams: BackupParams.init(
username: "",
email: nil,
phone: nil
),
restoredContacts: []
)
var didRestoreWithData: [Data] = []
var didRestoreWithPassphrase: [String] = []
let store = TestStore(
initialState: RestoreState(
file: .init(name: "file-name", data: backupData)
),
reducer: restoreReducer,
environment: .unimplemented
)
store.environment.bgQueue = .immediate
store.environment.mainQueue = .immediate
store.environment.messenger.restoreBackup.run = { data, passphrase in
didRestoreWithData.append(data)
didRestoreWithPassphrase.append(passphrase)
return restoreResult
}
store.send(.set(\.$passphrase, backupPassphrase)) {
$0.passphrase = backupPassphrase
}
store.send(.restoreTapped) {
$0.isRestoring = true
}
XCTAssertNoDifference(didRestoreWithData, [backupData])
XCTAssertNoDifference(didRestoreWithPassphrase, [backupPassphrase])
store.receive(.finished) {
$0.isRestoring = false
}
}
func testRestoreWithoutFile() {
let store = TestStore(
initialState: RestoreState(
file: nil
),
reducer: restoreReducer,
environment: .unimplemented
)
store.send(.restoreTapped)
}
func testRestoreFailure() {
struct Failure: Error {}
let failure = Failure()
let store = TestStore(
initialState: RestoreState(
file: .init(name: "name", data: "data".data(using: .utf8)!)
),
reducer: restoreReducer,
environment: .unimplemented
)
store.environment.bgQueue = .immediate
store.environment.mainQueue = .immediate
store.environment.messenger.restoreBackup.run = { _, _ in throw failure }
store.send(.restoreTapped) {
$0.isRestoring = true
}
store.receive(.failed(failure as NSError)) {
$0.isRestoring = false
$0.restoreFailure = failure.localizedDescription
}
}
}
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment