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

Merge branch 'feature/messenger-example-make-backup' into 'development'

Backup improvements & example

See merge request elixxir/elixxir-dapps-sdk-swift!110
parents 8c37cc6e 0070b7bb
No related branches found
No related tags found
2 merge requests!110Backup improvements & example,!102Release 1.0.0
Showing
with 1331 additions and 35 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 = "BackupFeature"
BuildableName = "BackupFeature"
BlueprintName = "BackupFeature"
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 = "BackupFeatureTests"
BuildableName = "BackupFeatureTests"
BlueprintName = "BackupFeatureTests"
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 = "BackupFeature"
BuildableName = "BackupFeature"
BlueprintName = "BackupFeature"
ReferencedContainer = "container:">
</BuildableReference>
</MacroExpansion>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
......@@ -15,6 +15,7 @@ let package = Package(
products: [
.library(name: "AppCore", targets: ["AppCore"]),
.library(name: "AppFeature", targets: ["AppFeature"]),
.library(name: "BackupFeature", targets: ["BackupFeature"]),
.library(name: "ChatFeature", targets: ["ChatFeature"]),
.library(name: "CheckContactAuthFeature", targets: ["CheckContactAuthFeature"]),
.library(name: "ConfirmRequestFeature", targets: ["ConfirmRequestFeature"]),
......@@ -83,6 +84,7 @@ let package = Package(
name: "AppFeature",
dependencies: [
.target(name: "AppCore"),
.target(name: "BackupFeature"),
.target(name: "ChatFeature"),
.target(name: "CheckContactAuthFeature"),
.target(name: "ConfirmRequestFeature"),
......@@ -112,6 +114,23 @@ let package = Package(
],
swiftSettings: swiftSettings
),
.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
),
.testTarget(
name: "BackupFeatureTests",
dependencies: [
.target(name: "BackupFeature"),
],
swiftSettings: swiftSettings
),
.target(
name: "ChatFeature",
dependencies: [
......@@ -215,6 +234,7 @@ let package = Package(
name: "HomeFeature",
dependencies: [
.target(name: "AppCore"),
.target(name: "BackupFeature"),
.target(name: "ContactsFeature"),
.target(name: "RegisterFeature"),
.target(name: "UserSearchFeature"),
......
......@@ -49,6 +49,16 @@
ReferencedContainer = "container:..">
</BuildableReference>
</TestableReference>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "BackupFeatureTests"
BuildableName = "BackupFeatureTests"
BlueprintName = "BackupFeatureTests"
ReferencedContainer = "container:..">
</BuildableReference>
</TestableReference>
<TestableReference
skipped = "NO">
<BuildableReference
......
import AppCore
import BackupFeature
import ChatFeature
import CheckContactAuthFeature
import ConfirmRequestFeature
......@@ -27,6 +28,7 @@ extension AppEnvironment {
handleConfirm: .live(db: dbManager.getDB),
handleReset: .live(db: dbManager.getDB)
)
let backupStorage = BackupStorage.onDisk()
let mainQueue = DispatchQueue.main.eraseToAnyScheduler()
let bgQueue = DispatchQueue.global(qos: .background).eraseToAnyScheduler()
......@@ -90,6 +92,7 @@ extension AppEnvironment {
messenger: messenger,
db: dbManager.getDB
),
backupStorage: backupStorage,
log: .live(),
mainQueue: mainQueue,
bgQueue: bgQueue,
......@@ -149,6 +152,15 @@ extension AppEnvironment {
bgQueue: bgQueue,
contact: { contactEnvironment }
)
},
backup: {
BackupEnvironment(
messenger: messenger,
db: dbManager.getDB,
backupStorage: backupStorage,
mainQueue: mainQueue,
bgQueue: bgQueue
)
}
)
}
......
......@@ -50,6 +50,7 @@ struct AppEnvironment {
var messenger: Messenger
var authHandler: AuthCallbackHandler
var messageListener: MessageListenerHandler
var backupStorage: BackupStorage
var log: Logger
var mainQueue: AnySchedulerOf<DispatchQueue>
var bgQueue: AnySchedulerOf<DispatchQueue>
......@@ -64,6 +65,7 @@ extension AppEnvironment {
messenger: .unimplemented,
authHandler: .unimplemented,
messageListener: .unimplemented,
backupStorage: .unimplemented,
log: .unimplemented,
mainQueue: .unimplemented,
bgQueue: .unimplemented,
......@@ -94,6 +96,9 @@ let appReducer = Reducer<AppState, AppAction, AppEnvironment>
cancellables.append(env.messageListener(onError: { error in
env.log(.error(error as NSError))
}))
cancellables.append(env.messenger.registerBackupCallback(.init { data in
try? env.backupStorage.store(data)
}))
let isLoaded = env.messenger.isLoaded()
let isCreated = env.messenger.isCreated()
......
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 Foundation
import XXClient
import XXMessengerClient
import XXModels
public struct BackupState: Equatable {
public enum Error: String, Swift.Error, Equatable {
case dbContactNotFound
case dbContactUsernameMissing
}
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(
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(
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 .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()
try 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>) {
self.store = store
}
let store: Store<BackupState, BackupAction>
struct ViewState: Equatable {
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 || viewStore.backup != nil {
backupSection(viewStore)
}
if !viewStore.isRunning {
newBackupSection(viewStore)
}
}
.disabled(viewStore.isLoading)
.alert(
store.scope(state: \.alert),
dismiss: .alertDismissed
)
}
.navigationTitle("Backup")
.task {
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)
}
}
#if DEBUG
public struct BackupView_Previews: PreviewProvider {
public static var previews: some View {
NavigationView {
BackupView(store: Store(
initialState: BackupState(),
reducer: .empty,
environment: ()
))
}
}
}
#endif
import AppCore
import BackupFeature
import Combine
import ComposableArchitecture
import ComposablePresentation
......@@ -20,7 +21,8 @@ public struct HomeState: Equatable {
alert: AlertState<HomeAction>? = nil,
register: RegisterState? = nil,
contacts: ContactsState? = nil,
userSearch: UserSearchState? = nil
userSearch: UserSearchState? = nil,
backup: BackupState? = nil
) {
self.failure = failure
self.isNetworkHealthy = isNetworkHealthy
......@@ -29,6 +31,7 @@ public struct HomeState: Equatable {
self.register = register
self.contacts = contacts
self.userSearch = userSearch
self.backup = backup
}
public var failure: String?
......@@ -39,6 +42,7 @@ public struct HomeState: Equatable {
public var register: RegisterState?
public var contacts: ContactsState?
public var userSearch: UserSearchState?
public var backup: BackupState?
}
public enum HomeAction: Equatable {
......@@ -72,9 +76,12 @@ public enum HomeAction: Equatable {
case didDismissUserSearch
case contactsButtonTapped
case didDismissContacts
case backupButtonTapped
case didDismissBackup
case register(RegisterAction)
case contacts(ContactsAction)
case userSearch(UserSearchAction)
case backup(BackupAction)
}
public struct HomeEnvironment {
......@@ -85,7 +92,8 @@ public struct HomeEnvironment {
bgQueue: AnySchedulerOf<DispatchQueue>,
register: @escaping () -> RegisterEnvironment,
contacts: @escaping () -> ContactsEnvironment,
userSearch: @escaping () -> UserSearchEnvironment
userSearch: @escaping () -> UserSearchEnvironment,
backup: @escaping () -> BackupEnvironment
) {
self.messenger = messenger
self.dbManager = dbManager
......@@ -94,6 +102,7 @@ public struct HomeEnvironment {
self.register = register
self.contacts = contacts
self.userSearch = userSearch
self.backup = backup
}
public var messenger: Messenger
......@@ -103,6 +112,7 @@ public struct HomeEnvironment {
public var register: () -> RegisterEnvironment
public var contacts: () -> ContactsEnvironment
public var userSearch: () -> UserSearchEnvironment
public var backup: () -> BackupEnvironment
}
extension HomeEnvironment {
......@@ -113,7 +123,8 @@ extension HomeEnvironment {
bgQueue: .unimplemented,
register: { .unimplemented },
contacts: { .unimplemented },
userSearch: { .unimplemented }
userSearch: { .unimplemented },
backup: { .unimplemented }
)
}
......@@ -145,6 +156,10 @@ public let homeReducer = Reducer<HomeState, HomeAction, HomeEnvironment>
try env.messenger.logIn()
}
if !env.messenger.isBackupRunning() {
try? env.messenger.resumeBackup()
}
return .success(.messenger(.didStartRegistered))
} catch {
return .success(.messenger(.failure(error as NSError)))
......@@ -267,7 +282,15 @@ public let homeReducer = Reducer<HomeState, HomeAction, HomeEnvironment>
state.register = nil
return Effect(value: .messenger(.start))
case .register(_), .contacts(_), .userSearch(_):
case .backupButtonTapped:
state.backup = BackupState()
return .none
case .didDismissBackup:
state.backup = nil
return .none
case .register(_), .contacts(_), .userSearch(_), .backup(_):
return .none
}
}
......@@ -292,3 +315,10 @@ public let homeReducer = Reducer<HomeState, HomeAction, HomeEnvironment>
action: /HomeAction.userSearch,
environment: { $0.userSearch() }
)
.presenting(
backupReducer,
state: .keyPath(\.backup),
id: .notNil(),
action: /HomeAction.backup,
environment: { $0.backup() }
)
import BackupFeature
import ComposableArchitecture
import ComposablePresentation
import ContactsFeature
......@@ -111,6 +112,16 @@ public struct HomeView: View {
}
Section {
Button {
viewStore.send(.backupButtonTapped)
} label: {
HStack {
Text("Backup")
Spacer()
Image(systemName: "chevron.forward")
}
}
Button(role: .destructive) {
viewStore.send(.deleteAccount(.buttonTapped))
} label: {
......@@ -152,6 +163,16 @@ public struct HomeView: View {
},
destination: UserSearchView.init(store:)
))
.background(NavigationLinkWithStore(
store.scope(
state: \.backup,
action: HomeAction.backup
),
onDeactivate: {
viewStore.send(.didDismissBackup)
},
destination: BackupView.init(store:)
))
}
.navigationViewStyle(.stack)
.task { viewStore.send(.messenger(.start)) }
......
......@@ -10,7 +10,7 @@ import XXClient
final class AppFeatureTests: XCTestCase {
func testStartWithoutMessengerCreated() {
var actions: [Action] = []
var actions: [Action]!
let store = TestStore(
initialState: AppState(),
......@@ -34,24 +34,29 @@ final class AppFeatureTests: XCTestCase {
actions.append(.didStartMessageListener)
return Cancellable {}
}
store.environment.messenger.registerBackupCallback.run = { _ in
actions.append(.didRegisterBackupCallback)
return Cancellable {}
}
actions = []
store.send(.start)
store.receive(.set(\.$screen, .welcome(WelcomeState()))) {
$0.screen = .welcome(WelcomeState())
}
XCTAssertNoDifference(actions, [
.didMakeDB,
.didStartAuthHandler,
.didStartMessageListener,
.didRegisterBackupCallback,
])
store.send(.stop)
}
func testStartWithMessengerCreated() {
var actions: [Action] = []
var actions: [Action]!
let store = TestStore(
initialState: AppState(),
......@@ -78,17 +83,22 @@ final class AppFeatureTests: XCTestCase {
actions.append(.didStartMessageListener)
return Cancellable {}
}
store.environment.messenger.registerBackupCallback.run = { _ in
actions.append(.didRegisterBackupCallback)
return Cancellable {}
}
actions = []
store.send(.start)
store.receive(.set(\.$screen, .home(HomeState()))) {
$0.screen = .home(HomeState())
}
XCTAssertNoDifference(actions, [
.didMakeDB,
.didStartAuthHandler,
.didStartMessageListener,
.didRegisterBackupCallback,
.didLoadMessenger,
])
......@@ -96,7 +106,7 @@ final class AppFeatureTests: XCTestCase {
}
func testWelcomeFinished() {
var actions: [Action] = []
var actions: [Action]!
let store = TestStore(
initialState: AppState(
......@@ -122,7 +132,12 @@ final class AppFeatureTests: XCTestCase {
actions.append(.didStartMessageListener)
return Cancellable {}
}
store.environment.messenger.registerBackupCallback.run = { _ in
actions.append(.didRegisterBackupCallback)
return Cancellable {}
}
actions = []
store.send(.welcome(.finished)) {
$0.screen = .loading
}
......@@ -130,10 +145,10 @@ final class AppFeatureTests: XCTestCase {
store.receive(.set(\.$screen, .home(HomeState()))) {
$0.screen = .home(HomeState())
}
XCTAssertNoDifference(actions, [
.didStartAuthHandler,
.didStartMessageListener,
.didRegisterBackupCallback,
.didLoadMessenger,
])
......@@ -141,7 +156,7 @@ final class AppFeatureTests: XCTestCase {
}
func testRestoreFinished() {
var actions: [Action] = []
var actions: [Action]!
let store = TestStore(
initialState: AppState(
......@@ -167,7 +182,12 @@ final class AppFeatureTests: XCTestCase {
actions.append(.didStartMessageListener)
return Cancellable {}
}
store.environment.messenger.registerBackupCallback.run = { _ in
actions.append(.didRegisterBackupCallback)
return Cancellable {}
}
actions = []
store.send(.restore(.finished)) {
$0.screen = .loading
}
......@@ -175,10 +195,10 @@ final class AppFeatureTests: XCTestCase {
store.receive(.set(\.$screen, .home(HomeState()))) {
$0.screen = .home(HomeState())
}
XCTAssertNoDifference(actions, [
.didStartAuthHandler,
.didStartMessageListener,
.didRegisterBackupCallback,
.didLoadMessenger,
])
......@@ -186,7 +206,7 @@ final class AppFeatureTests: XCTestCase {
}
func testHomeDidDeleteAccount() {
var actions: [Action] = []
var actions: [Action]!
let store = TestStore(
initialState: AppState(
......@@ -209,7 +229,12 @@ final class AppFeatureTests: XCTestCase {
actions.append(.didStartMessageListener)
return Cancellable {}
}
store.environment.messenger.registerBackupCallback.run = { _ in
actions.append(.didRegisterBackupCallback)
return Cancellable {}
}
actions = []
store.send(.home(.deleteAccount(.success))) {
$0.screen = .loading
}
......@@ -217,10 +242,10 @@ final class AppFeatureTests: XCTestCase {
store.receive(.set(\.$screen, .welcome(WelcomeState()))) {
$0.screen = .welcome(WelcomeState())
}
XCTAssertNoDifference(actions, [
.didStartAuthHandler,
.didStartMessageListener,
.didRegisterBackupCallback,
])
store.send(.stop)
......@@ -284,7 +309,7 @@ final class AppFeatureTests: XCTestCase {
struct Failure: Error {}
let error = Failure()
var actions: [Action] = []
var actions: [Action]!
let store = TestStore(
initialState: AppState(),
......@@ -306,7 +331,12 @@ final class AppFeatureTests: XCTestCase {
actions.append(.didStartMessageListener)
return Cancellable {}
}
store.environment.messenger.registerBackupCallback.run = { _ in
actions.append(.didRegisterBackupCallback)
return Cancellable {}
}
actions = []
store.send(.start)
store.receive(.set(\.$screen, .failure(error.localizedDescription))) {
......@@ -316,15 +346,17 @@ final class AppFeatureTests: XCTestCase {
XCTAssertNoDifference(actions, [
.didStartAuthHandler,
.didStartMessageListener,
.didRegisterBackupCallback,
])
store.send(.stop)
}
func testStartHandlersAndListeners() {
var actions: [Action] = []
var actions: [Action]!
var authHandlerOnError: [AuthCallbackHandler.OnError] = []
var messageListenerOnError: [MessageListenerHandler.OnError] = []
var backupCallback: [UpdateBackupFunc] = []
let store = TestStore(
initialState: AppState(),
......@@ -351,22 +383,33 @@ final class AppFeatureTests: XCTestCase {
actions.append(.didCancelMessageListener)
}
}
store.environment.messenger.registerBackupCallback.run = { callback in
backupCallback.append(callback)
actions.append(.didRegisterBackupCallback)
return Cancellable {
actions.append(.didCancelBackupCallback)
}
}
store.environment.log.run = { msg, _, _, _ in
actions.append(.didLog(msg))
}
store.environment.backupStorage.store = { data in
actions.append(.didStoreBackup(data))
}
actions = []
store.send(.start)
store.receive(.set(\.$screen, .home(HomeState()))) {
$0.screen = .home(HomeState())
}
XCTAssertNoDifference(actions, [
.didStartAuthHandler,
.didStartMessageListener,
.didRegisterBackupCallback,
])
actions = []
actions = []
store.send(.start) {
$0.screen = .loading
}
......@@ -374,15 +417,16 @@ final class AppFeatureTests: XCTestCase {
store.receive(.set(\.$screen, .home(HomeState()))) {
$0.screen = .home(HomeState())
}
XCTAssertNoDifference(actions, [
.didCancelAuthHandler,
.didCancelMessageListener,
.didCancelBackupCallback,
.didStartAuthHandler,
.didStartMessageListener,
.didRegisterBackupCallback,
])
actions = []
actions = []
struct AuthError: Error {}
let authError = AuthError()
authHandlerOnError.first?(authError)
......@@ -390,8 +434,8 @@ final class AppFeatureTests: XCTestCase {
XCTAssertNoDifference(actions, [
.didLog(.error(authError as NSError))
])
actions = []
actions = []
struct MessageError: Error {}
let messageError = MessageError()
messageListenerOnError.first?(messageError)
......@@ -399,13 +443,22 @@ final class AppFeatureTests: XCTestCase {
XCTAssertNoDifference(actions, [
.didLog(.error(messageError as NSError))
])
actions = []
let backupData = "backup".data(using: .utf8)!
backupCallback.first?.handle(backupData)
XCTAssertNoDifference(actions, [
.didStoreBackup(backupData),
])
actions = []
store.send(.stop)
XCTAssertNoDifference(actions, [
.didCancelAuthHandler,
.didCancelMessageListener,
.didCancelBackupCallback,
])
}
}
......@@ -414,8 +467,11 @@ private enum Action: Equatable {
case didMakeDB
case didStartAuthHandler
case didStartMessageListener
case didRegisterBackupCallback
case didLoadMessenger
case didCancelAuthHandler
case didCancelMessageListener
case didCancelBackupCallback
case didLog(Logger.Message)
case didStoreBackup(Data)
}
import ComposableArchitecture
import XCTest
import XXClient
import XXMessengerClient
import XXModels
@testable import BackupFeature
final class BackupFeatureTests: XCTestCase {
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(.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)
}
import AppCore
import BackupFeature
import ComposableArchitecture
import ContactsFeature
import CustomDump
......@@ -55,6 +56,7 @@ final class HomeFeatureTests: XCTestCase {
var messengerDidConnect = 0
var messengerDidListenForMessages = 0
var messengerDidLogIn = 0
var messengerDidResumeBackup = 0
store.environment.bgQueue = .immediate
store.environment.mainQueue = .immediate
......@@ -66,6 +68,8 @@ final class HomeFeatureTests: XCTestCase {
store.environment.messenger.isLoggedIn.run = { false }
store.environment.messenger.isRegistered.run = { true }
store.environment.messenger.logIn.run = { messengerDidLogIn += 1 }
store.environment.messenger.isBackupRunning.run = { false }
store.environment.messenger.resumeBackup.run = { messengerDidResumeBackup += 1 }
store.environment.messenger.cMix.get = {
var cMix: CMix = .unimplemented
cMix.addHealthCallback.run = { _ in Cancellable {} }
......@@ -82,6 +86,7 @@ final class HomeFeatureTests: XCTestCase {
XCTAssertNoDifference(messengerDidConnect, 1)
XCTAssertNoDifference(messengerDidListenForMessages, 1)
XCTAssertNoDifference(messengerDidLogIn, 1)
XCTAssertNoDifference(messengerDidResumeBackup, 1)
store.receive(.networkMonitor(.stop))
store.receive(.messenger(.didStartRegistered))
......@@ -110,6 +115,7 @@ final class HomeFeatureTests: XCTestCase {
store.environment.messenger.isLoggedIn.run = { false }
store.environment.messenger.isRegistered.run = { true }
store.environment.messenger.logIn.run = { messengerDidLogIn += 1 }
store.environment.messenger.isBackupRunning.run = { true }
store.environment.messenger.cMix.get = {
var cMix: CMix = .unimplemented
cMix.addHealthCallback.run = { _ in Cancellable {} }
......@@ -504,4 +510,30 @@ final class HomeFeatureTests: XCTestCase {
$0.contacts = nil
}
}
func testBackupButtonTapped() {
let store = TestStore(
initialState: HomeState(),
reducer: homeReducer,
environment: .unimplemented
)
store.send(.backupButtonTapped) {
$0.backup = BackupState()
}
}
func testDidDismissBackup() {
let store = TestStore(
initialState: HomeState(
backup: BackupState()
),
reducer: homeReducer,
environment: .unimplemented
)
store.send(.didDismissBackup) {
$0.backup = nil
}
}
}
......@@ -16,7 +16,7 @@ extension MessengerCreate {
let password = env.generateSecret()
try env.passwordStorage.save(password)
let storageDir = env.storageDir
try env.fileManager.removeDirectory(storageDir)
try env.fileManager.removeItem(storageDir)
try env.fileManager.createDirectory(storageDir)
try env.newCMix(
ndfJSON: String(data: ndfData, encoding: .utf8)!,
......
......@@ -23,7 +23,7 @@ extension MessengerDestroy {
env.e2e.set(nil)
env.cMix.set(nil)
env.isListeningForMessages.set(false)
try env.fileManager.removeDirectory(env.storageDir)
try env.fileManager.removeItem(env.storageDir)
try env.passwordStorage.remove()
}
}
......
......@@ -19,6 +19,6 @@ extension MessengerIsBackupRunning {
extension MessengerIsBackupRunning {
public static let unimplemented = MessengerIsBackupRunning(
run: XCTUnimplemented("\(Self.self)")
run: XCTUnimplemented("\(Self.self)", placeholder: false)
)
}
......@@ -33,7 +33,7 @@ extension MessengerRestoreBackup {
let ndfData = try env.downloadNDF(env.ndfEnvironment)
let password = env.generateSecret()
try env.passwordStorage.save(password)
try env.fileManager.removeDirectory(storageDir)
try env.fileManager.removeItem(storageDir)
try env.fileManager.createDirectory(storageDir)
let report = try env.newCMixFromBackup(
ndfJSON: String(data: ndfData, encoding: .utf8)!,
......
import Foundation
import XCTestDynamicOverlay
import XXClient
......@@ -33,24 +34,26 @@ extension MessengerStartBackup {
let paramsData = try params.encode()
let paramsString = String(data: paramsData, encoding: .utf8)!
var didAddParams = false
func addParams() {
guard let backup = env.backup() else { return }
backup.addJSON(paramsString)
didAddParams = true
}
var semaphore: DispatchSemaphore? = .init(value: 0)
let backup = try env.initializeBackup(
e2eId: e2e.getId(),
udId: ud.getId(),
password: password,
callback: .init { data in
semaphore?.wait()
if !didAddParams {
addParams()
if let backup = env.backup() {
backup.addJSON(paramsString)
didAddParams = true
}
} else {
env.backupCallbacks.registered().handle(data)
}
}
)
env.backup.set(backup)
semaphore?.signal()
semaphore = nil
}
}
}
......
import Foundation
import XCTestDynamicOverlay
import XXClient
public struct BackupStorage {
public struct Backup: Equatable {
public init(
date: Date,
data: Data
) {
self.date = date
self.data = data
}
public var date: Date
public var data: Data
}
public typealias Observer = (Backup?) -> Void
public var store: (Data) throws -> Void
public var observe: (@escaping Observer) -> Cancellable
public var remove: () throws -> Void
}
extension BackupStorage {
public static func onDisk(
now: @escaping () -> Date = Date.init,
fileManager: MessengerFileManager = .live(),
path: String = FileManager.default
.urls(for: .applicationSupportDirectory, in: .userDomainMask)
.first!
.appendingPathComponent("backup.xxm")
.path
) -> BackupStorage {
var observers: [UUID: Observer] = [:]
var backup: Backup?
func notifyObservers() {
observers.values.forEach { $0(backup) }
}
if let fileData = try? fileManager.loadFile(path),
let fileDate = try? fileManager.modifiedTime(path) {
backup = Backup(date: fileDate, data: fileData)
}
return BackupStorage(
store: { data in
let newBackup = Backup(
date: now(),
data: data
)
backup = newBackup
notifyObservers()
try fileManager.saveFile(path, newBackup.data)
},
observe: { observer in
let id = UUID()
observers[id] = observer
defer { observers[id]?(backup) }
return Cancellable {
observers[id] = nil
}
},
remove: {
backup = nil
notifyObservers()
try fileManager.removeItem(path)
}
)
}
}
extension BackupStorage {
public static let unimplemented = BackupStorage(
store: XCTUnimplemented("\(Self.self).store"),
observe: XCTUnimplemented("\(Self.self).observe", placeholder: Cancellable {}),
remove: XCTUnimplemented("\(Self.self).remove")
)
}
......@@ -3,8 +3,11 @@ import XCTestDynamicOverlay
public struct MessengerFileManager {
public var isDirectoryEmpty: (String) -> Bool
public var removeDirectory: (String) throws -> Void
public var removeItem: (String) throws -> Void
public var createDirectory: (String) throws -> Void
public var saveFile: (String, Data) throws -> Void
public var loadFile: (String) throws -> Data?
public var modifiedTime: (String) throws -> Date?
}
extension MessengerFileManager {
......@@ -16,7 +19,7 @@ extension MessengerFileManager {
let contents = try? fileManager.contentsOfDirectory(atPath: path)
return contents?.isEmpty ?? true
},
removeDirectory: { path in
removeItem: { path in
if fileManager.fileExists(atPath: path) {
try fileManager.removeItem(atPath: path)
}
......@@ -26,6 +29,16 @@ extension MessengerFileManager {
atPath: path,
withIntermediateDirectories: true
)
},
saveFile: { path, data in
try data.write(to: URL(fileURLWithPath: path))
},
loadFile: { path in
try Data(contentsOf: URL(fileURLWithPath: path))
},
modifiedTime: { path in
let attributes = try fileManager.attributesOfItem(atPath: path)
return attributes[.modificationDate] as? Date
}
)
}
......@@ -34,7 +47,10 @@ extension MessengerFileManager {
extension MessengerFileManager {
public static let unimplemented = MessengerFileManager(
isDirectoryEmpty: XCTUnimplemented("\(Self.self).isDirectoryEmpty", placeholder: false),
removeDirectory: XCTUnimplemented("\(Self.self).removeDirectory"),
createDirectory: XCTUnimplemented("\(Self.self).createDirectory")
removeItem: XCTUnimplemented("\(Self.self).removeItem"),
createDirectory: XCTUnimplemented("\(Self.self).createDirectory"),
saveFile: XCTUnimplemented("\(Self.self).saveFile"),
loadFile: XCTUnimplemented("\(Self.self).loadFile"),
modifiedTime: XCTUnimplemented("\(Self.self).modifiedTime")
)
}
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment