From 2a560dea8d9a0b345cbcdfbb82c76100e932d455 Mon Sep 17 00:00:00 2001 From: Michal Fousek Date: Wed, 1 Mar 2023 09:52:50 +0100 Subject: [PATCH] [#575] Add support for sending feedback (#588) Closes #575 - Added `SupportDataGenerator` object which generates data sent with support email. Format of the data is based on what Android has. - Support button is moved to settings from profile. - Support button opens mail composer. --- secant.xcodeproj/project.pbxproj | 38 ++++ .../SupportDataGenerator.swift | 186 ++++++++++++++++++ .../SupportDataGeneratorInterface.swift | 19 ++ .../SupportDataGeneratorLiveKey.swift | 14 ++ .../SupportDataGeneratorTestKey.swift | 21 ++ secant/Features/Profile/ProfileView.swift | 8 - secant/Features/Settings/SettingsStore.swift | 39 +++- secant/Features/Settings/SettingsView.swift | 21 ++ .../Settings/UIKitBridge/UIMailDialog.swift | 73 +++++++ secantTests/SettingsTests/SettingsTests.swift | 24 +-- 10 files changed, 418 insertions(+), 25 deletions(-) create mode 100644 secant/Dependencies/SupportDataGenerator/SupportDataGenerator.swift create mode 100644 secant/Dependencies/SupportDataGenerator/SupportDataGeneratorInterface.swift create mode 100644 secant/Dependencies/SupportDataGenerator/SupportDataGeneratorLiveKey.swift create mode 100644 secant/Dependencies/SupportDataGenerator/SupportDataGeneratorTestKey.swift create mode 100644 secant/Features/Settings/UIKitBridge/UIMailDialog.swift diff --git a/secant.xcodeproj/project.pbxproj b/secant.xcodeproj/project.pbxproj index b70ec25..14221f4 100644 --- a/secant.xcodeproj/project.pbxproj +++ b/secant.xcodeproj/project.pbxproj @@ -320,6 +320,16 @@ 3448CB3228E47666006ADEDB /* NotEnoughFreeSpaceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3448CB3128E47666006ADEDB /* NotEnoughFreeSpaceView.swift */; }; 3448CB3728E485CB006ADEDB /* NotEnoughFeeSpaceSnapshots.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3448CB3628E485CB006ADEDB /* NotEnoughFeeSpaceSnapshots.swift */; }; 346715A528E2027D0035F7C4 /* CheckCircleStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 346715A428E2027D0035F7C4 /* CheckCircleStore.swift */; }; + 3467319529AE265300974482 /* SupportDataGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3467319429AE265300974482 /* SupportDataGenerator.swift */; }; + 3467319629AE265300974482 /* SupportDataGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3467319429AE265300974482 /* SupportDataGenerator.swift */; }; + 3467319929AE374300974482 /* SupportDataGeneratorInterface.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3467319829AE374300974482 /* SupportDataGeneratorInterface.swift */; }; + 3467319A29AE374300974482 /* SupportDataGeneratorInterface.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3467319829AE374300974482 /* SupportDataGeneratorInterface.swift */; }; + 3467319C29AE374A00974482 /* SupportDataGeneratorLiveKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3467319B29AE374A00974482 /* SupportDataGeneratorLiveKey.swift */; }; + 3467319D29AE374A00974482 /* SupportDataGeneratorLiveKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3467319B29AE374A00974482 /* SupportDataGeneratorLiveKey.swift */; }; + 3467319F29AE375000974482 /* SupportDataGeneratorTestKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3467319E29AE375000974482 /* SupportDataGeneratorTestKey.swift */; }; + 346731A029AE375000974482 /* SupportDataGeneratorTestKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3467319E29AE375000974482 /* SupportDataGeneratorTestKey.swift */; }; + 346731A229AE3A5100974482 /* UIMailDialog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 346731A129AE3A5100974482 /* UIMailDialog.swift */; }; + 346731A329AE3A5100974482 /* UIMailDialog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 346731A129AE3A5100974482 /* UIMailDialog.swift */; }; 3469F18229ACD70500A07146 /* OnboardingFlowFeatureFlagTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3469F18129ACD70500A07146 /* OnboardingFlowFeatureFlagTests.swift */; }; 346D41E428DF0B8600963F36 /* CheckCircle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 346D41E328DF0B8600963F36 /* CheckCircle.swift */; }; 34BF09092927C98000222134 /* Memo+toString.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34BF09082927C98000222134 /* Memo+toString.swift */; }; @@ -645,6 +655,11 @@ 3448CB3128E47666006ADEDB /* NotEnoughFreeSpaceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotEnoughFreeSpaceView.swift; sourceTree = ""; }; 3448CB3628E485CB006ADEDB /* NotEnoughFeeSpaceSnapshots.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotEnoughFeeSpaceSnapshots.swift; sourceTree = ""; }; 346715A428E2027D0035F7C4 /* CheckCircleStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckCircleStore.swift; sourceTree = ""; }; + 3467319429AE265300974482 /* SupportDataGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SupportDataGenerator.swift; sourceTree = ""; }; + 3467319829AE374300974482 /* SupportDataGeneratorInterface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SupportDataGeneratorInterface.swift; sourceTree = ""; }; + 3467319B29AE374A00974482 /* SupportDataGeneratorLiveKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SupportDataGeneratorLiveKey.swift; sourceTree = ""; }; + 3467319E29AE375000974482 /* SupportDataGeneratorTestKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SupportDataGeneratorTestKey.swift; sourceTree = ""; }; + 346731A129AE3A5100974482 /* UIMailDialog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIMailDialog.swift; sourceTree = ""; }; 3469F18129ACD70500A07146 /* OnboardingFlowFeatureFlagTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingFlowFeatureFlagTests.swift; sourceTree = ""; }; 346D41E328DF0B8600963F36 /* CheckCircle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckCircle.swift; sourceTree = ""; }; 34BF09082927C98000222134 /* Memo+toString.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Memo+toString.swift"; sourceTree = ""; }; @@ -1173,6 +1188,17 @@ path = SendSnapshotTests; sourceTree = ""; }; + 3467319729AE36F000974482 /* SupportDataGenerator */ = { + isa = PBXGroup; + children = ( + 3467319429AE265300974482 /* SupportDataGenerator.swift */, + 3467319829AE374300974482 /* SupportDataGeneratorInterface.swift */, + 3467319B29AE374A00974482 /* SupportDataGeneratorLiveKey.swift */, + 3467319E29AE375000974482 /* SupportDataGeneratorTestKey.swift */, + ); + path = SupportDataGenerator; + sourceTree = ""; + }; 346D41E228DF0B0900963F36 /* CheckCircle */ = { isa = PBXGroup; children = ( @@ -1464,6 +1490,7 @@ 9E612C6D2987A96500D09B09 /* UIKitBridge */ = { isa = PBXGroup; children = ( + 346731A129AE3A5100974482 /* UIMailDialog.swift */, 9E612C6E2987A9B100D09B09 /* UIShareDialog.swift */, ); path = UIKitBridge; @@ -1649,6 +1676,7 @@ 9EB863A329239D95003D0F8B /* RecoveryPhraseRandomizer */, 9EB863B62923C539003D0F8B /* SDKSynchronizer */, 9EB863B32923C465003D0F8B /* SecItem */, + 3467319729AE36F000974482 /* SupportDataGenerator */, 9EB8639E29239891003D0F8B /* URIParser */, 9E153A7129216EBD00112F41 /* UserDefaults */, 9EB863B72923C55A003D0F8B /* UserPreferencesStorage */, @@ -2557,6 +2585,7 @@ 0D26AEA5299E8196005260EE /* TransactionSendingView.swift in Sources */, 0D26AEA6299E8196005260EE /* WalletEventsFlowView.swift in Sources */, 0D26AEA7299E8196005260EE /* CaptureDeviceLiveKey.swift in Sources */, + 3467319A29AE374300974482 /* SupportDataGeneratorInterface.swift in Sources */, 0D26AEA8299E8196005260EE /* AudioServicesInterface.swift in Sources */, 0D26AEA9299E8196005260EE /* NotificationCenterTest.swift in Sources */, 0D26AEAA299E8196005260EE /* CrashReporterLiveKey.swift in Sources */, @@ -2605,15 +2634,18 @@ 0D26AED3299E8196005260EE /* SyncStatusSnapshot.swift in Sources */, 0D26AED4299E8196005260EE /* SecantButtonStyles.swift in Sources */, 0D26AED5299E8196005260EE /* RecoveryPhraseBackupFailedView.swift in Sources */, + 346731A029AE375000974482 /* SupportDataGeneratorTestKey.swift in Sources */, 0D26AED6299E8196005260EE /* UserPreferencesStorageInterface.swift in Sources */, 0D26AED7299E8196005260EE /* DiskSpaceCheckerMocks.swift in Sources */, 0D26AED8299E8196005260EE /* DropDelegate.swift in Sources */, 0D26AED9299E8196005260EE /* LocalAuthenticationLiveKey.swift in Sources */, 0D26AEDA299E8196005260EE /* ImportSeedEditor.swift in Sources */, 0D26AEDB299E8196005260EE /* ProfileStore.swift in Sources */, + 3467319629AE265300974482 /* SupportDataGenerator.swift in Sources */, 0D26AEDC299E8196005260EE /* CheckCircle.swift in Sources */, 0D26AEDD299E8196005260EE /* LogStore.swift in Sources */, 0D26AEDE299E8196005260EE /* RecoveryPhraseRandomizer.swift in Sources */, + 3467319D29AE374A00974482 /* SupportDataGeneratorLiveKey.swift in Sources */, 0D26AEDF299E8196005260EE /* FileManagerTestKey.swift in Sources */, 0D26AEE0299E8196005260EE /* SecItemLive.swift in Sources */, 0D26AEE1299E8196005260EE /* CircularFrameBadge.swift in Sources */, @@ -2733,6 +2765,7 @@ 0D26AF52299E8196005260EE /* LogsHandlerTest.swift in Sources */, 0D26AF53299E8196005260EE /* TextFieldFooter.swift in Sources */, 0D26AF54299E8196005260EE /* CrashReportingInterface.swift in Sources */, + 346731A329AE3A5100974482 /* UIMailDialog.swift in Sources */, 0D26AF55299E8196005260EE /* ProfileView.swift in Sources */, 0D26AF56299E8196005260EE /* ScanStore.swift in Sources */, 0D26AF57299E8196005260EE /* NumberFormatterTestKey.swift in Sources */, @@ -2781,6 +2814,7 @@ 34DA414728E4385800F8CC61 /* TransactionSendingView.swift in Sources */, F96B41E9273B501F0021B49A /* WalletEventsFlowView.swift in Sources */, 9EBDF96E291ECED4000A1A05 /* CaptureDeviceLiveKey.swift in Sources */, + 3467319929AE374300974482 /* SupportDataGeneratorInterface.swift in Sources */, 9EBDF968291ECDA2000A1A05 /* AudioServicesInterface.swift in Sources */, 9EB863BD2923C704003D0F8B /* NotificationCenterTest.swift in Sources */, 0D26103E298C3FA600CC9DE9 /* CrashReporterLiveKey.swift in Sources */, @@ -2829,15 +2863,18 @@ 9E66122C2877188700C75B70 /* SyncStatusSnapshot.swift in Sources */, 9E4DC6E227C4C6B700E657F4 /* SecantButtonStyles.swift in Sources */, 0DDB6A5127737D4A0012A410 /* RecoveryPhraseBackupFailedView.swift in Sources */, + 3467319F29AE375000974482 /* SupportDataGeneratorTestKey.swift in Sources */, 0D63170029919970007D873F /* UserPreferencesStorageInterface.swift in Sources */, 9EBDF94D291D773A000A1A05 /* DiskSpaceCheckerMocks.swift in Sources */, 0D6D628B276A528E002FB4CC /* DropDelegate.swift in Sources */, 9EBDF986291F91EF000A1A05 /* LocalAuthenticationLiveKey.swift in Sources */, 9E2DF99D27CF704D00649636 /* ImportSeedEditor.swift in Sources */, F9971A5327680DD000A2DB75 /* ProfileStore.swift in Sources */, + 3467319529AE265300974482 /* SupportDataGenerator.swift in Sources */, 346D41E428DF0B8600963F36 /* CheckCircle.swift in Sources */, 9E0F5745297EBA1B005304FA /* LogStore.swift in Sources */, 9EB863AA29239EB2003D0F8B /* RecoveryPhraseRandomizer.swift in Sources */, + 3467319C29AE374A00974482 /* SupportDataGeneratorLiveKey.swift in Sources */, 9EB863C52923C8AF003D0F8B /* FileManagerTestKey.swift in Sources */, 9EB863BF2923C72C003D0F8B /* SecItemLive.swift in Sources */, 669FDAEB272C23C2007B9422 /* CircularFrameBadge.swift in Sources */, @@ -2957,6 +2994,7 @@ 9E612C7629880FC900D09B09 /* LogsHandlerTest.swift in Sources */, 2EDA07A227EDE1AE00D6F09B /* TextFieldFooter.swift in Sources */, 0D26103C298C3E4800CC9DE9 /* CrashReportingInterface.swift in Sources */, + 346731A229AE3A5100974482 /* UIMailDialog.swift in Sources */, F9971A5427680DD000A2DB75 /* ProfileView.swift in Sources */, F9971A6027680DF600A2DB75 /* ScanStore.swift in Sources */, 9EB863952922D036003D0F8B /* NumberFormatterTestKey.swift in Sources */, diff --git a/secant/Dependencies/SupportDataGenerator/SupportDataGenerator.swift b/secant/Dependencies/SupportDataGenerator/SupportDataGenerator.swift new file mode 100644 index 0000000..401fa0c --- /dev/null +++ b/secant/Dependencies/SupportDataGenerator/SupportDataGenerator.swift @@ -0,0 +1,186 @@ +// +// SupportDataGenerator.swift +// secant +// +// Created by Michal Fousek on 28.02.2023. +// + +import AVFoundation +import Foundation +import LocalAuthentication +import UIKit + +struct SupportData: Equatable { + let toAddress: String + let subject: String + let message: String +} + +enum SupportDataGenerator { + static func generate() -> SupportData { + let items: [SupportDataGeneratorItem] = [ + TimeItem(), + AppVersionItem(), + SystemVersionItem(), + DeviceModelItem(), + LocaleItem(), + FreeDiskSpaceItem(), + PermissionsItems() + ] + + let message = items + .map { $0.generate() } + .flatMap { $0 } + .map { "\($0.0): \($0.1)" } + .joined(separator: "\n") + + return SupportData(toAddress: "support@electriccoin.co", subject: "sECCant", message: message) + } +} + +private protocol SupportDataGeneratorItem { + func generate() -> [(String, String)] +} + +private struct TimeItem: SupportDataGeneratorItem { + private enum Constants { + static let timeKey = "Current time" + } + + let dateFormatter: DateFormatter + + init() { + dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy-MM-dd hh:mm:ss a ZZZZ" + dateFormatter.locale = Locale(identifier: "en_US") + } + + func generate() -> [(String, String)] { + return [(Constants.timeKey, dateFormatter.string(from: Date()))] + } +} + +private struct AppVersionItem: SupportDataGeneratorItem { + private enum Constants { + static let bundleIdentifierKey = "App identifier" + static let versionKey = "App version" + static let unknownVersion = "Unknown" + } + + func generate() -> [(String, String)] { + let bundle = Bundle.main + guard let infoDict = bundle.infoDictionary else { return [(Constants.versionKey, Constants.unknownVersion)] } + + var data: [(String, String)] = [] + if let bundleIdentifier = bundle.bundleIdentifier { + data.append((Constants.bundleIdentifierKey, bundleIdentifier)) + } + + if let build = infoDict["CFBundleVersion"] as? String, let version = infoDict["CFBundleShortVersionString"] as? String { + data.append((Constants.versionKey, "\(version) (\(build))")) + } else { + data.append((Constants.versionKey, Constants.unknownVersion)) + } + + return data + } +} + +private struct SystemVersionItem: SupportDataGeneratorItem { + private enum Constants { + static let systemVersionKey = "iOS version" + } + + func generate() -> [(String, String)] { + return [(Constants.systemVersionKey, UIDevice.current.systemVersion)] + } +} + +private struct DeviceModelItem: SupportDataGeneratorItem { + private enum Constants { + static let deviceModelKey = "Device" + static let unknownDevice = "unknown" + } + + func generate() -> [(String, String)] { + var systemInfo = utsname() + uname(&systemInfo) + var readModel: String? + withUnsafePointer(to: &systemInfo.machine.0) { charPointer in + readModel = String(cString: charPointer, encoding: .ascii) + } + + let model = readModel ?? Constants.unknownDevice + return [(Constants.deviceModelKey, model)] + } +} + +private struct LocaleItem: SupportDataGeneratorItem { + private enum Constants { + static let localKey = "Locale" + static let groupingSeparatorKey = "Currency grouping separato" + static let decimalSeparatorKey = "Currency decimal separator" + static let unknownSeparator = "unknown" + } + + func generate() -> [(String, String)] { + let locale = Locale.current + + return [ + (Constants.localKey, locale.identifier), + (Constants.groupingSeparatorKey, locale.groupingSeparator ?? Constants.unknownSeparator), + (Constants.decimalSeparatorKey, locale.decimalSeparator ?? Constants.unknownSeparator) + ] + } +} + +private struct FreeDiskSpaceItem: SupportDataGeneratorItem { + private enum Constants { + static let freeDiskSpaceKey = "Usable storage" + static let freeDiskSpaceUnknown = "unknown" + } + + func generate() -> [(String, String)] { + let freeDiskSpace: String + + let fileURL = URL(fileURLWithPath: NSHomeDirectory()) + do { + let values = try fileURL.resourceValues(forKeys: [.volumeAvailableCapacityForImportantUsageKey]) + if let freeSpace = values.volumeAvailableCapacityForImportantUsage { + freeDiskSpace = "\(freeSpace / 1024 / 1024) MB" + } else { + freeDiskSpace = Constants.freeDiskSpaceUnknown + } + } catch { + LoggerProxy.debug("Can't get free disk space: \(error)") + freeDiskSpace = Constants.freeDiskSpaceUnknown + } + + return [(Constants.freeDiskSpaceKey, freeDiskSpace)] + } +} + +private struct PermissionsItems: SupportDataGeneratorItem { + private enum Constants { + static let permissionsKey = "Permissions" + static let cameraPermKey = "Camera access" + static let faceIDAvailable = "FaceID available" + static let touchIDAvailable = "TouchID available" + static let yesText = "yes" + static let noText = "no" + } + + func generate() -> [(String, String)] { + let cameraAuthorized = AVCaptureDevice.authorizationStatus(for: .video) == .authorized + + let bioAuthContext = LAContext() + let biometricAuthAvailable = bioAuthContext.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: nil) + + return [ + (Constants.permissionsKey, ""), + (Constants.cameraPermKey, cameraAuthorized ? Constants.yesText : Constants.noText), + (Constants.faceIDAvailable, biometricAuthAvailable && bioAuthContext.biometryType == .faceID ? Constants.yesText : Constants.noText), + (Constants.touchIDAvailable, biometricAuthAvailable && bioAuthContext.biometryType == .touchID ? Constants.yesText : Constants.noText) + ] + } +} diff --git a/secant/Dependencies/SupportDataGenerator/SupportDataGeneratorInterface.swift b/secant/Dependencies/SupportDataGenerator/SupportDataGeneratorInterface.swift new file mode 100644 index 0000000..1c2f37b --- /dev/null +++ b/secant/Dependencies/SupportDataGenerator/SupportDataGeneratorInterface.swift @@ -0,0 +1,19 @@ +// +// SupportDataGeneratorInterface.swift +// secant +// +// Created by Michal Fousek on 28.02.2023. +// + +import ComposableArchitecture + +extension DependencyValues { + var supportDataGenerator: SupportDataGeneratorClient { + get { self[SupportDataGeneratorClient.self] } + set { self[SupportDataGeneratorClient.self] = newValue } + } +} + +struct SupportDataGeneratorClient { + let generate: () -> SupportData +} diff --git a/secant/Dependencies/SupportDataGenerator/SupportDataGeneratorLiveKey.swift b/secant/Dependencies/SupportDataGenerator/SupportDataGeneratorLiveKey.swift new file mode 100644 index 0000000..d6551d6 --- /dev/null +++ b/secant/Dependencies/SupportDataGenerator/SupportDataGeneratorLiveKey.swift @@ -0,0 +1,14 @@ +// +// SupportDataGeneratorLiveKey.swift +// secant +// +// Created by Michal Fousek on 28.02.2023. +// + +import ComposableArchitecture + +extension SupportDataGeneratorClient: DependencyKey { + static let liveValue = Self( + generate: { SupportDataGenerator.generate() } + ) +} diff --git a/secant/Dependencies/SupportDataGenerator/SupportDataGeneratorTestKey.swift b/secant/Dependencies/SupportDataGenerator/SupportDataGeneratorTestKey.swift new file mode 100644 index 0000000..e096e9e --- /dev/null +++ b/secant/Dependencies/SupportDataGenerator/SupportDataGeneratorTestKey.swift @@ -0,0 +1,21 @@ +// +// SupportDataGeneratorTestKey.swift +// secant +// +// Created by Michal Fousek on 28.02.2023. +// + +import ComposableArchitecture +import XCTestDynamicOverlay + +extension SupportDataGeneratorClient: TestDependencyKey { + static let testValue = Self( + generate: XCTUnimplemented("\(Self.self).generate") + ) +} + +extension SupportDataGeneratorClient { + static let noOp = Self( + generate: { SupportData(toAddress: "", subject: "", message: "") } + ) +} diff --git a/secant/Features/Profile/ProfileView.swift b/secant/Features/Profile/ProfileView.swift index a855231..4ce4795 100644 --- a/secant/Features/Profile/ProfileView.swift +++ b/secant/Features/Profile/ProfileView.swift @@ -37,14 +37,6 @@ struct ProfileView: View { .frame(height: 50) .padding(EdgeInsets(top: 30, leading: 30, bottom: 20, trailing: 30)) - Button( - action: { }, - label: { Text("Support") } - ) - .primaryButtonStyle - .frame(height: 50) - .padding(EdgeInsets(top: 0, leading: 30, bottom: 20, trailing: 30)) - Spacer() HStack { diff --git a/secant/Features/Settings/SettingsStore.swift b/secant/Features/Settings/SettingsStore.swift index d85909b..6163de8 100644 --- a/secant/Features/Settings/SettingsStore.swift +++ b/secant/Features/Settings/SettingsStore.swift @@ -1,4 +1,5 @@ import ComposableArchitecture +import MessageUI import SwiftUI typealias SettingsStore = Store @@ -10,13 +11,14 @@ struct SettingsReducer: ReducerProtocol { case backupPhrase } + @BindingState var alert: AlertState? var destination: Destination? var exportLogsDisabled = false + @BindingState var isCrashReportingOn: Bool var isSharingLogs = false var phraseDisplayState: RecoveryPhraseDisplayReducer.State var rescanDialog: ConfirmationDialogState? - - @BindingState var isCrashReportingOn: Bool + var supportData: SupportData? var tempSDKDir: URL { let tempDir = FileManager.default.temporaryDirectory @@ -42,6 +44,7 @@ struct SettingsReducer: ReducerProtocol { case backupWalletAccessRequest case binding(BindingAction) case cancelRescan + case dismissAlert case exportLogs case fullRescan case logsExported @@ -50,8 +53,10 @@ struct SettingsReducer: ReducerProtocol { case phraseDisplay(RecoveryPhraseDisplayReducer.Action) case quickRescan case rescanBlockchain - case updateDestination(SettingsReducer.State.Destination?) + case sendSupportMail + case sendSupportMailFinished case testCrashReporter // this will crash the app if live. + case updateDestination(SettingsReducer.State.Destination?) } @Dependency(\.localAuthentication) var localAuthentication @@ -101,6 +106,10 @@ struct SettingsReducer: ReducerProtocol { case .cancelRescan, .quickRescan, .fullRescan: state.rescanDialog = nil return .none + + case .dismissAlert: + state.alert = nil + return .none case .exportLogs: state.exportLogsDisabled = true @@ -148,6 +157,26 @@ struct SettingsReducer: ReducerProtocol { case .binding: return .none + + case .sendSupportMail: + if MFMailComposeViewController.canSendMail() { + state.supportData = SupportDataGenerator.generate() + } else { + state.alert = AlertState( + title: TextState("Can't send email"), + message: TextState(""" + It looks like that you don't have any email account configured on your device. Therefore it's not possible to send a support \ + email. + """), + dismissButton: .default(TextState("Ok"), action: .send(.sendSupportMailFinished)) + ) + } + + return .none + + case .sendSupportMailFinished: + state.supportData = nil + return .none } } @@ -190,10 +219,10 @@ extension SettingsStore { extension SettingsReducer.State { static let placeholder = SettingsReducer.State( + isCrashReportingOn: true, phraseDisplayState: RecoveryPhraseDisplayReducer.State( phrase: .placeholder - ), - isCrashReportingOn: true + ) ) } diff --git a/secant/Features/Settings/SettingsView.swift b/secant/Features/Settings/SettingsView.swift index fd1b8a9..d0f0340 100644 --- a/secant/Features/Settings/SettingsView.swift +++ b/secant/Features/Settings/SettingsView.swift @@ -48,6 +48,14 @@ struct SettingsView: View { ) .primaryButtonStyle .frame(height: 50) + + Button( + action: { viewStore.send(.sendSupportMail) }, + label: { Text("Send us feedback!") } + ) + .primaryButtonStyle + .frame(height: 50) + Spacer() } .padding(.horizontal, 30) @@ -64,6 +72,7 @@ struct SettingsView: View { } ) .onAppear { viewStore.send(.onAppear) } + .alert(self.store.scope(state: \.alert), dismiss: .dismissAlert) if viewStore.isSharingLogs { UIShareDialogView( @@ -75,6 +84,18 @@ struct SettingsView: View { // so frame is set to 0 to not break SwiftUIs layout .frame(width: 0, height: 0) } + + if let supportData = viewStore.supportData { + UIMailDialogView( + supportData: supportData, + completion: { + viewStore.send(.sendSupportMailFinished) + } + ) + // UIMailDialogView only wraps MFMailComposeViewController presentation + // so frame is set to 0 to not break SwiftUIs layout + .frame(width: 0, height: 0) + } } } } diff --git a/secant/Features/Settings/UIKitBridge/UIMailDialog.swift b/secant/Features/Settings/UIKitBridge/UIMailDialog.swift new file mode 100644 index 0000000..6093095 --- /dev/null +++ b/secant/Features/Settings/UIKitBridge/UIMailDialog.swift @@ -0,0 +1,73 @@ +// +// UIMailDialog.swift +// secant +// +// Created by Michal Fousek on 28.02.2023. +// + +import Foundation +import MessageUI +import UIKit +import SwiftUI + +class UIMailDialog: UIView { + var completion: (() -> Void)? + + required init?(coder aDecoder: NSCoder) { + super.init(coder: aDecoder) + } + + override init(frame: CGRect) { + super.init(frame: frame) + } +} + +extension UIMailDialog { + func doInitialSetup(supportData: SupportData, completion: @escaping () -> Void) { + self.completion = completion + DispatchQueue.main.async { + let mailVC = MFMailComposeViewController() + mailVC.mailComposeDelegate = self + + // Configure the fields of the interface. + mailVC.setToRecipients([supportData.toAddress]) + mailVC.setSubject(supportData.subject) + mailVC.setMessageBody("\n\n\(supportData.message)", isHTML: false) + + let rootVC = UIApplication.shared.connectedScenes + .map { $0 as? UIWindowScene } + .compactMap { $0 } + .first?.windows.first?.rootViewController + + rootVC?.present( + mailVC, + animated: true, + completion: nil + ) + } + } +} + +extension UIMailDialog: MFMailComposeViewControllerDelegate { + func mailComposeController(_ controller: MFMailComposeViewController, didFinishWith result: MFMailComposeResult, error: Error?) { + controller.dismiss(animated: true, completion: completion) + } +} + +struct UIMailDialogView: UIViewRepresentable { + let supportData: SupportData + let completion: () -> Void + + func makeUIView(context: UIViewRepresentableContext) -> UIMailDialog { + let view = UIMailDialog() + view.doInitialSetup(supportData: supportData, completion: completion) + return view + } + + func updateUIView(_ uiView: UIMailDialog, context: UIViewRepresentableContext) { + // We can leave it empty here because the view is just handler how to bridge UIKit's UIActivityViewController + // presentation into SwiftUI. The view itself is not visible, only instantiated, therefore no updates needed. + } + + typealias UIViewType = UIMailDialog +} diff --git a/secantTests/SettingsTests/SettingsTests.swift b/secantTests/SettingsTests/SettingsTests.swift index 933eb06..2570ace 100644 --- a/secantTests/SettingsTests/SettingsTests.swift +++ b/secantTests/SettingsTests/SettingsTests.swift @@ -46,8 +46,8 @@ class SettingsTests: XCTestCase { let store = TestStore( initialState: SettingsReducer.State( - phraseDisplayState: RecoveryPhraseDisplayReducer.State(phrase: nil), - isCrashReportingOn: false + isCrashReportingOn: false, + phraseDisplayState: RecoveryPhraseDisplayReducer.State(phrase: nil) ), reducer: SettingsReducer() ) { dependencies in @@ -103,6 +103,7 @@ class SettingsTests: XCTestCase { let store = TestStore( initialState: SettingsReducer.State( destination: nil, + isCrashReportingOn: false, phraseDisplayState: .init(), rescanDialog: .init( title: TextState("Rescan"), @@ -112,8 +113,7 @@ class SettingsTests: XCTestCase { .default(TextState("Full rescan"), action: .send(.fullRescan)), .cancel(TextState("Cancel")) ] - ), - isCrashReportingOn: false + ) ), reducer: SettingsReducer() ) @@ -127,6 +127,7 @@ class SettingsTests: XCTestCase { let store = TestStore( initialState: SettingsReducer.State( destination: nil, + isCrashReportingOn: false, phraseDisplayState: .init(), rescanDialog: .init( title: TextState("Rescan"), @@ -136,8 +137,7 @@ class SettingsTests: XCTestCase { .default(TextState("Full rescan"), action: .send(.fullRescan)), .cancel(TextState("Cancel")) ] - ), - isCrashReportingOn: false + ) ), reducer: SettingsReducer() ) @@ -151,6 +151,7 @@ class SettingsTests: XCTestCase { let store = TestStore( initialState: SettingsReducer.State( destination: nil, + isCrashReportingOn: false, phraseDisplayState: .init(), rescanDialog: .init( title: TextState("Rescan"), @@ -160,8 +161,7 @@ class SettingsTests: XCTestCase { .default(TextState("Full rescan"), action: .send(.fullRescan)), .cancel(TextState("Cancel")) ] - ), - isCrashReportingOn: false + ) ), reducer: SettingsReducer() ) @@ -175,6 +175,7 @@ class SettingsTests: XCTestCase { let store = TestStore( initialState: SettingsReducer.State( destination: nil, + isCrashReportingOn: false, phraseDisplayState: .init(), rescanDialog: .init( title: TextState("Rescan"), @@ -184,8 +185,7 @@ class SettingsTests: XCTestCase { .default(TextState("Full rescan"), action: .send(.fullRescan)), .cancel(TextState("Cancel")) ] - ), - isCrashReportingOn: false + ) ), reducer: SettingsReducer() ) @@ -206,6 +206,7 @@ class SettingsTests: XCTestCase { let store = TestStore( initialState: SettingsReducer.State( destination: nil, + isCrashReportingOn: false, isSharingLogs: true, phraseDisplayState: .init(), rescanDialog: .init( @@ -216,8 +217,7 @@ class SettingsTests: XCTestCase { .default(TextState("Full rescan"), action: .send(.fullRescan)), .cancel(TextState("Cancel")) ] - ), - isCrashReportingOn: false + ) ), reducer: SettingsReducer() )