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

Implement example backup feature

parent 5375dc39
No related branches found
No related tags found
2 merge requests!110Backup improvements & example,!102Release 1.0.0
......@@ -117,8 +117,10 @@ 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
),
......
......@@ -154,7 +154,13 @@ extension AppEnvironment {
)
},
backup: {
BackupEnvironment()
BackupEnvironment(
messenger: messenger,
db: dbManager.getDB,
backupStorage: backupStorage,
mainQueue: mainQueue,
bgQueue: bgQueue
)
}
)
}
......
import ComposableArchitecture
extension AlertState where Action == BackupAction {
public static func error(_ error: Error) -> AlertState<BackupAction> {
AlertState(
title: TextState("Error"),
message: TextState(error.localizedDescription)
)
}
}
import AppCore
import Combine
import ComposableArchitecture
import XCTestDynamicOverlay
import Foundation
import XXClient
import XXMessengerClient
import XXModels
public struct BackupState: Equatable {
public init() {}
public enum Error: String, Swift.Error, Equatable {
case dbContactNotFound
case dbContactUsernameMissing
}
public enum BackupAction: Equatable {
case start
public init(
isRunning: Bool = false,
isStarting: Bool = false,
isResuming: Bool = false,
isStopping: Bool = false,
backup: BackupStorage.Backup? = nil,
alert: AlertState<BackupAction>? = nil,
passphrase: String = "",
isExporting: Bool = false,
exportData: Data? = nil
) {
self.isRunning = isRunning
self.isStarting = isStarting
self.isResuming = isResuming
self.isStopping = isStopping
self.backup = backup
self.alert = alert
self.passphrase = passphrase
self.isExporting = isExporting
self.exportData = exportData
}
public var isRunning: Bool
public var isStarting: Bool
public var isResuming: Bool
public var isStopping: Bool
public var backup: BackupStorage.Backup?
public var alert: AlertState<BackupAction>?
@BindableState public var passphrase: String
@BindableState public var isExporting: Bool
public var exportData: Data?
}
public enum BackupAction: Equatable, BindableAction {
case task
case cancelTask
case startTapped
case resumeTapped
case stopTapped
case exportTapped
case alertDismissed
case backupUpdated(BackupStorage.Backup?)
case didStart(failure: NSError?)
case didResume(failure: NSError?)
case didStop(failure: NSError?)
case didExport(failure: NSError?)
case binding(BindingAction<BackupState>)
}
public struct BackupEnvironment {
public init() {}
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>
}
#if DEBUG
extension BackupEnvironment {
public static let unimplemented = BackupEnvironment()
public static let unimplemented = BackupEnvironment(
messenger: .unimplemented,
db: .unimplemented,
backupStorage: .unimplemented,
mainQueue: .unimplemented,
bgQueue: .unimplemented
)
}
#endif
public let backupReducer = Reducer<BackupState, BackupAction, BackupEnvironment>
{ state, action, env in
enum TaskEffectId {}
switch action {
case .start:
case .task:
state.isRunning = env.messenger.isBackupRunning()
return Effect.run { subscriber in
let cancellable = env.backupStorage.observe { backup in
subscriber.send(.backupUpdated(backup))
}
return AnyCancellable { cancellable.cancel() }
}
.subscribe(on: env.bgQueue)
.receive(on: env.mainQueue)
.eraseToEffect()
.cancellable(id: TaskEffectId.self, cancelInFlight: true)
case .cancelTask:
return .cancel(id: TaskEffectId.self)
case .startTapped:
state.isStarting = true
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
}
try env.messenger.startBackup(
password: state.passphrase,
params: BackupParams(username: username)
)
subscriber.send(.didStart(failure: nil))
} catch {
subscriber.send(.didStart(failure: error as NSError))
}
subscriber.send(completion: .finished)
return AnyCancellable {}
}
.subscribe(on: env.bgQueue)
.receive(on: env.mainQueue)
.eraseToEffect()
case .resumeTapped:
state.isResuming = true
return Effect.run { subscriber in
do {
try env.messenger.resumeBackup()
subscriber.send(.didResume(failure: nil))
} catch {
subscriber.send(.didResume(failure: error as NSError))
}
subscriber.send(completion: .finished)
return AnyCancellable {}
}
.subscribe(on: env.bgQueue)
.receive(on: env.mainQueue)
.eraseToEffect()
case .stopTapped:
state.isStopping = true
return Effect.run { subscriber in
do {
try env.messenger.stopBackup()
env.backupStorage.remove()
subscriber.send(.didStop(failure: nil))
} catch {
subscriber.send(.didStop(failure: error as NSError))
}
subscriber.send(completion: .finished)
return AnyCancellable {}
}
.subscribe(on: env.bgQueue)
.receive(on: env.mainQueue)
.eraseToEffect()
case .exportTapped:
state.isExporting = true
state.exportData = state.backup?.data
return .none
case .alertDismissed:
state.alert = nil
return .none
case .backupUpdated(let backup):
state.backup = backup
return .none
case .didStart(let failure):
state.isRunning = env.messenger.isBackupRunning()
state.isStarting = false
if let failure {
state.alert = .error(failure)
} else {
state.passphrase = ""
}
return .none
case .didResume(let failure):
state.isRunning = env.messenger.isBackupRunning()
state.isResuming = false
if let failure {
state.alert = .error(failure)
}
return .none
case .didStop(let failure):
state.isRunning = env.messenger.isBackupRunning()
state.isStopping = false
if let failure {
state.alert = .error(failure)
}
return .none
case .didExport(let failure):
state.isExporting = false
state.exportData = nil
if let failure {
state.alert = .error(failure)
}
return .none
case .binding(_):
return .none
}
}
.binding()
import ComposableArchitecture
import SwiftUI
import UniformTypeIdentifiers
public struct BackupView: View {
public init(store: Store<BackupState, BackupAction>) {
......@@ -9,19 +10,219 @@ public struct BackupView: View {
let store: Store<BackupState, BackupAction>
struct ViewState: Equatable {
init(state: BackupState) {}
struct Backup: Equatable {
var date: Date
var size: Int
}
init(state: BackupState) {
isRunning = state.isRunning
isStarting = state.isStarting
isResuming = state.isResuming
isStopping = state.isStopping
backup = state.backup.map { backup in
Backup(date: backup.date, size: backup.data.count)
}
passphrase = state.passphrase
isExporting = state.isExporting
exportData = state.exportData
}
var isRunning: Bool
var isStarting: Bool
var isResuming: Bool
var isStopping: Bool
var isLoading: Bool { isStarting || isResuming || isStopping }
var backup: Backup?
var passphrase: String
var isExporting: Bool
var exportData: Data?
}
public var body: some View {
WithViewStore(store, observe: ViewState.init) { viewStore in
Form {
Group {
if !viewStore.isRunning {
newBackupSection(viewStore)
}
if viewStore.isRunning || viewStore.backup != nil {
backupSection(viewStore)
}
}
.disabled(viewStore.isLoading)
.alert(
store.scope(state: \.alert),
dismiss: .alertDismissed
)
}
.navigationTitle("Backup")
.task {
viewStore.send(.start)
await viewStore.send(.task).finish()
}
}
}
@ViewBuilder func newBackupSection(
_ viewStore: ViewStore<ViewState, BackupAction>
) -> some View {
Section {
SecureField(
text: viewStore.binding(
get: \.passphrase,
send: { .set(\.$passphrase, $0) }
),
prompt: Text("Backup passphrase"),
label: { Text("Backup passphrase") }
)
Button {
viewStore.send(.startTapped)
} label: {
HStack {
Text("Start")
Spacer()
if viewStore.isStarting {
ProgressView()
} else {
Image(systemName: "play.fill")
}
}
}
} header: {
Text("New backup")
}
}
@ViewBuilder func backupSection(
_ viewStore: ViewStore<ViewState, BackupAction>
) -> some View {
Section {
backupView(viewStore)
stopView(viewStore)
resumeView(viewStore)
} header: {
Text("Backup")
}
}
@ViewBuilder func backupView(
_ viewStore: ViewStore<ViewState, BackupAction>
) -> some View {
if let backup = viewStore.backup {
HStack {
Text("Date")
Spacer()
Text(backup.date.formatted())
}
HStack {
Text("Size")
Spacer()
Text(format(bytesCount: backup.size))
}
Button {
viewStore.send(.exportTapped)
} label: {
HStack {
Text("Export")
Spacer()
if viewStore.isExporting {
ProgressView()
} else {
Image(systemName: "square.and.arrow.up")
}
}
}
.disabled(viewStore.isExporting)
.fileExporter(
isPresented: viewStore.binding(
get: \.isExporting,
send: { .set(\.$isExporting, $0) }
),
document: viewStore.exportData.map(ExportedDocument.init(data:)),
contentType: .data,
defaultFilename: "backup.xxm",
onCompletion: { result in
switch result {
case .success(_):
viewStore.send(.didExport(failure: nil))
case .failure(let error):
viewStore.send(.didExport(failure: error as NSError?))
}
}
)
} else {
Text("No backup")
}
}
@ViewBuilder func stopView(
_ viewStore: ViewStore<ViewState, BackupAction>
) -> some View {
if viewStore.isRunning {
Button {
viewStore.send(.stopTapped)
} label: {
HStack {
Text("Stop")
Spacer()
if viewStore.isStopping {
ProgressView()
} else {
Image(systemName: "stop.fill")
}
}
}
}
}
@ViewBuilder func resumeView(
_ viewStore: ViewStore<ViewState, BackupAction>
) -> some View {
if !viewStore.isRunning, viewStore.backup != nil {
Button {
viewStore.send(.resumeTapped)
} label: {
HStack {
Text("Resume")
Spacer()
if viewStore.isResuming {
ProgressView()
} else {
Image(systemName: "playpause.fill")
}
}
}
}
}
func format(bytesCount bytes: Int) -> String {
let formatter = ByteCountFormatter()
formatter.allowedUnits = [.useMB, .useKB]
formatter.countStyle = .binary
return formatter.string(fromByteCount: Int64(bytes))
}
}
private struct ExportedDocument: FileDocument {
enum Error: Swift.Error {
case notAvailable
}
static var readableContentTypes: [UTType] = []
static var writableContentTypes: [UTType] = [.data]
var data: Data
init(data: Data) {
self.data = data
}
init(configuration: ReadConfiguration) throws {
throw Error.notAvailable
}
func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper {
FileWrapper(regularFileWithContents: data)
}
}
......
import ComposableArchitecture
import XCTest
import XXClient
import XXMessengerClient
import XXModels
@testable import BackupFeature
final class BackupFeatureTests: XCTestCase {
func testStart() {
func testTask() {
var isBackupRunning: [Bool] = [false]
var observers: [UUID: BackupStorage.Observer] = [:]
let store = TestStore(
initialState: BackupState(),
reducer: backupReducer,
environment: .unimplemented
)
store.environment.mainQueue = .immediate
store.environment.bgQueue = .immediate
store.environment.messenger.isBackupRunning.run = {
isBackupRunning.removeFirst()
}
store.environment.backupStorage.observe = {
let id = UUID()
observers[id] = $0
return Cancellable { observers[id] = nil }
}
store.send(.task)
XCTAssertNoDifference(observers.count, 1)
let backup = BackupStorage.Backup(
date: .init(timeIntervalSince1970: 1),
data: "backup".data(using: .utf8)!
)
observers.values.forEach { $0(backup) }
store.receive(.backupUpdated(backup)) {
$0.backup = backup
}
observers.values.forEach { $0(nil) }
store.receive(.backupUpdated(nil)) {
$0.backup = nil
}
store.send(.cancelTask)
XCTAssertNoDifference(observers.count, 0)
}
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 passphrase = "backup-password"
let store = TestStore(
initialState: BackupState(),
reducer: backupReducer,
environment: .unimplemented
)
store.environment.mainQueue = .immediate
store.environment.bgQueue = .immediate
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(\.$passphrase, passphrase)) {
$0.passphrase = passphrase
}
XCTAssertNoDifference(actions, [])
actions = []
store.send(.startTapped) {
$0.isStarting = true
}
XCTAssertNoDifference(actions, [
.didStartBackup(
passphrase: passphrase,
params: .init(username: dbContact.username!)
)
])
store.receive(.didStart(failure: nil)) {
$0.isRunning = true
$0.isStarting = false
$0.passphrase = ""
}
}
func testStartBackupWithoutDbContact() {
var isBackupRunning: [Bool] = [false]
let contactID = "contact-id".data(using: .utf8)!
let store = TestStore(
initialState: BackupState(
passphrase: "1234"
),
reducer: backupReducer,
environment: .unimplemented
)
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 }
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
store.receive(.didStart(failure: failure as NSError)) {
$0.isRunning = false
$0.isStarting = false
$0.alert = .error(failure)
}
}
func testStartBackupWithoutDbContactUsername() {
var isBackupRunning: [Bool] = [false]
let contactID = "contact-id".data(using: .utf8)!
let dbContact = XXModels.Contact(
id: contactID,
username: nil
)
let store = TestStore(
initialState: BackupState(
passphrase: "1234"
),
reducer: backupReducer,
environment: .unimplemented
)
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 }
return contact
}
return e2e
}
store.environment.db.run = {
var db: Database = .unimplemented
db.fetchContacts.run = { _ in [dbContact] }
return db
}
store.send(.start)
store.send(.startTapped) {
$0.isStarting = true
}
let failure = BackupState.Error.dbContactUsernameMissing
store.receive(.didStart(failure: failure as NSError)) {
$0.isRunning = false
$0.isStarting = false
$0.alert = .error(failure)
}
}
func testStartBackupFailure() {
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(
passphrase: "1234"
),
reducer: backupReducer,
environment: .unimplemented
)
store.environment.mainQueue = .immediate
store.environment.bgQueue = .immediate
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
}
store.receive(.didStart(failure: failure as NSError)) {
$0.isRunning = false
$0.isStarting = false
$0.alert = .error(failure)
}
}
func testResumeBackup() {
var actions: [Action]!
var isBackupRunning: [Bool] = [true]
let store = TestStore(
initialState: BackupState(),
reducer: backupReducer,
environment: .unimplemented
)
store.environment.mainQueue = .immediate
store.environment.bgQueue = .immediate
store.environment.messenger.resumeBackup.run = {
actions.append(.didResumeBackup)
}
store.environment.messenger.isBackupRunning.run = {
isBackupRunning.removeFirst()
}
actions = []
store.send(.resumeTapped) {
$0.isResuming = true
}
XCTAssertNoDifference(actions, [.didResumeBackup])
actions = []
store.receive(.didResume(failure: nil)) {
$0.isRunning = true
$0.isResuming = false
}
XCTAssertNoDifference(actions, [])
}
func testResumeBackupFailure() {
struct Failure: Error {}
let failure = Failure()
var isBackupRunning: [Bool] = [false]
let store = TestStore(
initialState: BackupState(),
reducer: backupReducer,
environment: .unimplemented
)
store.environment.mainQueue = .immediate
store.environment.bgQueue = .immediate
store.environment.messenger.resumeBackup.run = {
throw failure
}
store.environment.messenger.isBackupRunning.run = {
isBackupRunning.removeFirst()
}
store.send(.resumeTapped) {
$0.isResuming = true
}
store.receive(.didResume(failure: failure as NSError)) {
$0.isRunning = false
$0.isResuming = false
$0.alert = .error(failure)
}
}
func testStopBackup() {
var actions: [Action]!
var isBackupRunning: [Bool] = [false]
let store = TestStore(
initialState: BackupState(),
reducer: backupReducer,
environment: .unimplemented
)
store.environment.mainQueue = .immediate
store.environment.bgQueue = .immediate
store.environment.messenger.stopBackup.run = {
actions.append(.didStopBackup)
}
store.environment.messenger.isBackupRunning.run = {
isBackupRunning.removeFirst()
}
store.environment.backupStorage.remove = {
actions.append(.didRemoveBackup)
}
actions = []
store.send(.stopTapped) {
$0.isStopping = true
}
XCTAssertNoDifference(actions, [
.didStopBackup,
.didRemoveBackup,
])
actions = []
store.receive(.didStop(failure: nil)) {
$0.isRunning = false
$0.isStopping = false
}
XCTAssertNoDifference(actions, [])
}
func testStopBackupFailure() {
struct Failure: Error {}
let failure = Failure()
var isBackupRunning: [Bool] = [true]
let store = TestStore(
initialState: BackupState(),
reducer: backupReducer,
environment: .unimplemented
)
store.environment.mainQueue = .immediate
store.environment.bgQueue = .immediate
store.environment.messenger.stopBackup.run = {
throw failure
}
store.environment.messenger.isBackupRunning.run = {
isBackupRunning.removeFirst()
}
store.send(.stopTapped) {
$0.isStopping = true
}
store.receive(.didStop(failure: failure as NSError)) {
$0.isRunning = true
$0.isStopping = false
$0.alert = .error(failure)
}
}
func testAlertDismissed() {
let store = TestStore(
initialState: BackupState(
alert: .error(NSError(domain: "test", code: 0))
),
reducer: backupReducer,
environment: .unimplemented
)
store.send(.alertDismissed) {
$0.alert = nil
}
}
func testExportBackup() {
let backupData = "backup-data".data(using: .utf8)!
let store = TestStore(
initialState: BackupState(
backup: .init(
date: Date(),
data: backupData
)
),
reducer: backupReducer,
environment: .unimplemented
)
store.send(.exportTapped) {
$0.isExporting = true
$0.exportData = backupData
}
store.send(.didExport(failure: nil)) {
$0.isExporting = false
$0.exportData = nil
}
store.send(.exportTapped) {
$0.isExporting = true
$0.exportData = backupData
}
let failure = NSError(domain: "test", code: 0)
store.send(.didExport(failure: failure)) {
$0.isExporting = false
$0.exportData = nil
$0.alert = .error(failure)
}
}
}
private enum Action: Equatable {
case didRegisterObserver
case didStartBackup(passphrase: String, params: BackupParams)
case didResumeBackup
case didStopBackup
case didRemoveBackup
case didFetchContacts(XXModels.Contact.Query)
}
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment