From 2c121b5731b7aa3c439cbae2724b85d6ebcaa32f Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sun, 3 Jul 2022 19:53:07 +0100 Subject: [PATCH] ios: choose notifications mode during onboarding and after DB migration (#773) --- apps/ios/Shared/Model/SimpleXAPI.swift | 6 +- apps/ios/Shared/SimpleXApp.swift | 12 +- .../Shared/Views/ChatList/ChatListView.swift | 2 +- .../Database/MigrateToAppGroupView.swift | 20 ++-- .../Views/Onboarding/CreateProfile.swift | 2 +- .../Views/Onboarding/OnboardingView.swift | 6 +- .../Onboarding/SetNotificationsMode.swift | 112 ++++++++++++++++++ .../Shared/Views/Onboarding/SimpleXInfo.swift | 2 +- .../UserSettings/NotificationsView.swift | 38 +++--- .../Views/UserSettings/SettingsView.swift | 10 +- apps/ios/SimpleX.xcodeproj/project.pbxproj | 8 +- apps/ios/SimpleXChat/APITypes.swift | 2 +- 12 files changed, 175 insertions(+), 45 deletions(-) create mode 100644 apps/ios/Shared/Views/Onboarding/SetNotificationsMode.swift diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index af3ac5d5aa..2d0f9f775a 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -543,8 +543,10 @@ func startChat() throws { registerToken(token: token) } withAnimation { - m.onboardingStage = m.chats.isEmpty - ? .step3_MakeConnection + m.onboardingStage = m.onboardingStage == .step2_CreateProfile + ? .step3_SetNotificationsMode + : m.chats.isEmpty + ? .step4_MakeConnection : .onboardingComplete } } diff --git a/apps/ios/Shared/SimpleXApp.swift b/apps/ios/Shared/SimpleXApp.swift index 587e9762b6..83db2de40a 100644 --- a/apps/ios/Shared/SimpleXApp.swift +++ b/apps/ios/Shared/SimpleXApp.swift @@ -82,19 +82,21 @@ struct SimpleXApp: App { let legacyDatabase = hasLegacyDatabase() if legacyDatabase, case .documents = dbContainerGroupDefault.get() { dbContainerGroupDefault.set(.documents) - switch v3DBMigrationDefault.get() { - case .migrated: () - default: v3DBMigrationDefault.set(.offer) - } + setMigrationState(.offer) logger.debug("SimpleXApp init: using legacy DB in documents folder: \(getAppDatabasePath(), privacy: .public)*.db") } else { dbContainerGroupDefault.set(.group) - v3DBMigrationDefault.set(.ready) + setMigrationState(.ready) logger.debug("SimpleXApp init: using DB in app group container: \(getAppDatabasePath(), privacy: .public)*.db") logger.debug("SimpleXApp init: legacy DB\(legacyDatabase ? "" : " not", privacy: .public) present") } } + private func setMigrationState(_ state: V3DBMigrationState) { + if case .migrated = v3DBMigrationDefault.get() { return } + v3DBMigrationDefault.set(state) + } + private func authenticationExpired() -> Bool { if let enteredBackground = enteredBackground { return ProcessInfo.processInfo.systemUptime - enteredBackground >= 30 diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index e3087ed30a..29850a6e89 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -34,7 +34,7 @@ struct ChatListView: View { } .onChange(of: chatModel.chats.isEmpty) { empty in if !empty { return } - withAnimation { chatModel.onboardingStage = .step3_MakeConnection } + withAnimation { chatModel.onboardingStage = .step4_MakeConnection } } .onChange(of: chatModel.appOpenUrl) { _ in connectViaUrl() } .onAppear() { connectViaUrl() } diff --git a/apps/ios/Shared/Views/Database/MigrateToAppGroupView.swift b/apps/ios/Shared/Views/Database/MigrateToAppGroupView.swift index a53c6a2b40..de99d315ef 100644 --- a/apps/ios/Shared/Views/Database/MigrateToAppGroupView.swift +++ b/apps/ios/Shared/Views/Database/MigrateToAppGroupView.swift @@ -43,13 +43,13 @@ struct MigrateToAppGroupView: View { var body: some View { ZStack(alignment: .topLeading) { - Text("Database migration").font(.largeTitle) + Text("Push notifications").font(.largeTitle) switch chatModel.v3DBMigration { case .offer: VStack(alignment: .leading, spacing: 16) { Text("To support instant push notifications the chat database has to be migrated.") - Text("If you need to use the chat now tap **Skip** below (you will be offered to migrate the database when you restart the app).") + Text("If you need to use the chat now tap **Do it later** below (you will be offered to migrate the database when you restart the app).") } .padding(.top, 56) center { @@ -109,6 +109,7 @@ struct MigrateToAppGroupView: View { do { resetChatCtrl() try initializeChat(start: true) + chatModel.onboardingStage = .step3_SetNotificationsMode setV3DBMigration(.ready) } catch let error { dbContainerGroupDefault.set(.documents) @@ -117,7 +118,7 @@ struct MigrateToAppGroupView: View { } deleteOldArchive() } label: { - Text("Start using chat") + Text("Start chat") .font(.title) .frame(maxWidth: .infinity) } @@ -165,16 +166,16 @@ struct MigrateToAppGroupView: View { fatalError("Failed to start or load chats: \(responseError(error))") } } label: { - Text("Skip and start using chat") + Text("Do it later") .frame(maxWidth: .infinity, alignment: .trailing) } } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomTrailing) } - private func setV3DBMigration(_ value: V3DBMigrationState) { - chatModel.v3DBMigration = value - v3DBMigrationDefault.set(value) + private func setV3DBMigration(_ state: V3DBMigrationState) { + chatModel.v3DBMigration = state + v3DBMigrationDefault.set(state) } func migrateDatabaseToV3() { @@ -242,6 +243,9 @@ func deleteOldArchive() { struct MigrateToGroupView_Previews: PreviewProvider { static var previews: some View { - MigrateToAppGroupView() + let chatModel = ChatModel() + chatModel.v3DBMigration = .migrated + return MigrateToAppGroupView() + .environmentObject(chatModel) } } diff --git a/apps/ios/Shared/Views/Onboarding/CreateProfile.swift b/apps/ios/Shared/Views/Onboarding/CreateProfile.swift index 511ff82b36..8b2fcd627e 100644 --- a/apps/ios/Shared/Views/Onboarding/CreateProfile.swift +++ b/apps/ios/Shared/Views/Onboarding/CreateProfile.swift @@ -96,7 +96,7 @@ struct CreateProfile: View { do { m.currentUser = try apiCreateActiveUser(profile) try startChat() - withAnimation { m.onboardingStage = .step3_MakeConnection } + withAnimation { m.onboardingStage = .step3_SetNotificationsMode } } catch { fatalError("Failed to create user or start chat: \(responseError(error))") diff --git a/apps/ios/Shared/Views/Onboarding/OnboardingView.swift b/apps/ios/Shared/Views/Onboarding/OnboardingView.swift index 04fcbf42e5..0b01893834 100644 --- a/apps/ios/Shared/Views/Onboarding/OnboardingView.swift +++ b/apps/ios/Shared/Views/Onboarding/OnboardingView.swift @@ -15,7 +15,8 @@ struct OnboardingView: View { switch onboarding { case .step1_SimpleXInfo: SimpleXInfo(onboarding: true) case .step2_CreateProfile: CreateProfile() - case .step3_MakeConnection: MakeConnection() + case .step3_SetNotificationsMode: SetNotificationsMode() + case .step4_MakeConnection: MakeConnection() case .onboardingComplete: EmptyView() } } @@ -24,7 +25,8 @@ struct OnboardingView: View { enum OnboardingStage { case step1_SimpleXInfo case step2_CreateProfile - case step3_MakeConnection + case step3_SetNotificationsMode + case step4_MakeConnection case onboardingComplete } diff --git a/apps/ios/Shared/Views/Onboarding/SetNotificationsMode.swift b/apps/ios/Shared/Views/Onboarding/SetNotificationsMode.swift new file mode 100644 index 0000000000..87803512b8 --- /dev/null +++ b/apps/ios/Shared/Views/Onboarding/SetNotificationsMode.swift @@ -0,0 +1,112 @@ +// +// NotificationsModeView.swift +// SimpleX (iOS) +// +// Created by Evgeny on 03/07/2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +struct SetNotificationsMode: View { + @EnvironmentObject var m: ChatModel + @State private var notificationMode = NotificationsMode.instant + @State private var showAlert: NotificationAlert? + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + Text("Push notifications").font(.largeTitle) + + Text("Send notifications:") + ForEach(NotificationsMode.values) { mode in + NtfModeSelector(mode: mode, selection: $notificationMode) + } + + Spacer() + + Button { + if let token = m.deviceToken { + setNotificationsMode(token, notificationMode) + } else { + AlertManager.shared.showAlertMsg(title: "No device token!") + } + m.onboardingStage = m.chats.isEmpty + ? .step4_MakeConnection + : .onboardingComplete + } label: { + if case .off = notificationMode { + Text("Use chat") + } else { + Text("Enable notifications") + } + } + .font(.title) + .frame(maxWidth: .infinity) + } + .padding() + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) + } + } + + private func setNotificationsMode(_ token: DeviceToken, _ mode: NotificationsMode) { + switch mode { + case .off: + m.tokenStatus = .new + m.notificationMode = .off + default: + Task { + do { + let status = try await apiRegisterToken(token: token, notificationMode: mode) + await MainActor.run { + m.tokenStatus = status + m.notificationMode = mode + } + } catch let error { + AlertManager.shared.showAlertMsg( + title: "Error enabling notifications", + message: "\(responseError(error))" + ) + } + } + } + } +} + +struct NtfModeSelector: View { + var mode: NotificationsMode + @Binding var selection: NotificationsMode + @State private var tapped = false + + var body: some View { + ZStack { + VStack(alignment: .leading, spacing: 4) { + Text(mode.label) + .font(.headline) + .foregroundColor(selection == mode ? .accentColor : .secondary) + Text(ntfModeDescription(mode)) + .lineLimit(10) + .font(.subheadline) + } + .padding(12) + } + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color(uiColor: tapped ? .secondarySystemFill : .systemBackground)) + .clipShape(RoundedRectangle(cornerRadius: 18)) + .overlay( + RoundedRectangle(cornerRadius: 18) + .stroke(selection == mode ? Color.accentColor : Color(uiColor: .secondarySystemFill), lineWidth: 2) + ) + ._onButtonGesture { down in + tapped = down + if down { selection = mode } + } perform: {} + } +} + +struct NotificationsModeView_Previews: PreviewProvider { + static var previews: some View { + SetNotificationsMode() + } +} diff --git a/apps/ios/Shared/Views/Onboarding/SimpleXInfo.swift b/apps/ios/Shared/Views/Onboarding/SimpleXInfo.swift index 2d05852640..b53c871c68 100644 --- a/apps/ios/Shared/Views/Onboarding/SimpleXInfo.swift +++ b/apps/ios/Shared/Views/Onboarding/SimpleXInfo.swift @@ -77,7 +77,7 @@ struct OnboardingActionButton: View { if m.currentUser == nil { actionButton("Create your profile", onboarding: .step2_CreateProfile) } else { - actionButton("Make a private connection", onboarding: .step3_MakeConnection) + actionButton("Make a private connection", onboarding: .step4_MakeConnection) } } diff --git a/apps/ios/Shared/Views/UserSettings/NotificationsView.swift b/apps/ios/Shared/Views/UserSettings/NotificationsView.swift index b3752befad..71c6e2a6ce 100644 --- a/apps/ios/Shared/Views/UserSettings/NotificationsView.swift +++ b/apps/ios/Shared/Views/UserSettings/NotificationsView.swift @@ -89,7 +89,7 @@ struct NotificationsView: View { title: Text(ntfModeAlertTitle(mode)), message: Text(ntfModeDescription(mode)), primaryButton: .default(Text(mode == .off ? "Turn off" : "Enable")) { - setNotificationsMode(mode, token) + setNotificationsMode(token, mode) }, secondaryButton: .cancel() { notificationMode = m.notificationMode @@ -108,17 +108,19 @@ struct NotificationsView: View { } } - private func setNotificationsMode(_ mode: NotificationsMode, _ token: DeviceToken) { + private func setNotificationsMode(_ token: DeviceToken, _ mode: NotificationsMode) { Task { switch mode { case .off: do { try await apiDeleteToken(token: token) - m.tokenStatus = .new - notificationMode = .off - m.notificationMode = .off + await MainActor.run { + m.tokenStatus = .new + notificationMode = .off + m.notificationMode = .off + } } catch let error { - DispatchQueue.main.async { + await MainActor.run { let err = responseError(error) logger.error("apiDeleteToken error: \(err)") showAlert = .error(title: "Error deleting token", error: err) @@ -126,16 +128,17 @@ struct NotificationsView: View { } default: do { - do { - m.tokenStatus = try await apiRegisterToken(token: token, notificationMode: mode) + let status = try await apiRegisterToken(token: token, notificationMode: mode) + await MainActor.run { + m.tokenStatus = status notificationMode = mode m.notificationMode = mode - } catch let error { - DispatchQueue.main.async { - let err = responseError(error) - logger.error("apiRegisterToken error: \(err)") - showAlert = .error(title: "Error enabling notifications", error: err) - } + } + } catch let error { + await MainActor.run { + let err = responseError(error) + logger.error("apiRegisterToken error: \(err)") + showAlert = .error(title: "Error enabling notifications", error: err) } } } @@ -145,9 +148,9 @@ struct NotificationsView: View { func ntfModeDescription(_ mode: NotificationsMode) -> LocalizedStringKey { switch mode { - case .off: return "**Maximum privacy**: push notifications are off.\nNo meta-data is shared with SimpleX Chat notification server." - case .periodic: return "**High privacy**: new messages are checked every 20 minutes.\nYour device token is shared with SimpleX Chat notification server, but it cannot see how many connections you have or how many messages you receive." - case .instant: return "**Medium privacy** (recommended): notifications are sent instantly.\nYour device token and notifications are sent to SimpleX Chat notification server, but it cannot access the message content, size or who is it from." + case .off: return "**Most private**: do not use SimpleX Chat notifications server, check messages periodically in the background (depends on how often you use the app)." + case .periodic: return "**More private**: check new messages every 20 minutes. Device token is shared with SimpleX Chat server, but not how many contacts or messages you have." + case .instant: return "**Recommended**: device token and notifications are sent to SimpleX Chat notification server, but not the message content, size or who is it from." } } @@ -171,6 +174,7 @@ struct SelectionListView: View { .contentShape(Rectangle()) .listRowBackground(Color(uiColor: tapped == item ? .secondarySystemFill : .systemBackground)) .onTapGesture { + if selection == item { return } if let f = onSelection { f(item) } else { diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index d99f31b350..40827bdf49 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -200,13 +200,13 @@ struct SettingsView: View { switch (chatModel.tokenStatus) { case .new: icon = "bolt" - color = .primary + color = .secondary case .registered: icon = "bolt.fill" - color = .primary + color = .secondary case .invalid: icon = "bolt.slash" - color = .primary + color = .secondary case .confirmed: icon = "bolt.fill" color = .yellow @@ -215,10 +215,10 @@ struct SettingsView: View { color = .green case .expired: icon = "bolt.slash.fill" - color = .primary + color = .secondary case .none: icon = "bolt" - color = .primary + color = .secondary } return Image(systemName: icon) .padding(.trailing, 9) diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index caa9b66c3b..3e05ad8857 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -54,6 +54,7 @@ 5C8F01CD27A6F0D8007D2C8D /* CodeScanner in Frameworks */ = {isa = PBXBuildFile; productRef = 5C8F01CC27A6F0D8007D2C8D /* CodeScanner */; }; 5C971E1D27AEBEF600C8A3CE /* ChatInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C971E1C27AEBEF600C8A3CE /* ChatInfoView.swift */; }; 5C971E2127AEBF8300C8A3CE /* ChatInfoImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C971E2027AEBF8300C8A3CE /* ChatInfoImage.swift */; }; + 5C9A5BDB2871E05400A5B906 /* SetNotificationsMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C9A5BDA2871E05400A5B906 /* SetNotificationsMode.swift */; }; 5C9D13A3282187BB00AB8B43 /* WebRTC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C9D13A2282187BB00AB8B43 /* WebRTC.swift */; }; 5C9FD96E27A5D6ED0075386C /* SendMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C9FD96D27A5D6ED0075386C /* SendMessageView.swift */; }; 5CA059DC279559F40002BEB4 /* Tests_iOS.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA059DB279559F40002BEB4 /* Tests_iOS.swift */; }; @@ -227,6 +228,7 @@ 5C764E88279CBCB3000C6508 /* ChatModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatModel.swift; sourceTree = ""; }; 5C971E1C27AEBEF600C8A3CE /* ChatInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatInfoView.swift; sourceTree = ""; }; 5C971E2027AEBF8300C8A3CE /* ChatInfoImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatInfoImage.swift; sourceTree = ""; }; + 5C9A5BDA2871E05400A5B906 /* SetNotificationsMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetNotificationsMode.swift; sourceTree = ""; }; 5C9D13A2282187BB00AB8B43 /* WebRTC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebRTC.swift; sourceTree = ""; }; 5C9FD96A27A56D4D0075386C /* JSON.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSON.swift; sourceTree = ""; }; 5C9FD96D27A5D6ED0075386C /* SendMessageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendMessageView.swift; sourceTree = ""; }; @@ -489,9 +491,10 @@ children = ( 5CB0BA8D2827126500B3292C /* OnboardingView.swift */, 5CB0BA8F282713D900B3292C /* SimpleXInfo.swift */, - 5CB0BA91282713FD00B3292C /* CreateProfile.swift */, - 5CB0BA952827143500B3292C /* MakeConnection.swift */, 5CB0BA992827FD8800B3292C /* HowItWorks.swift */, + 5CB0BA91282713FD00B3292C /* CreateProfile.swift */, + 5C9A5BDA2871E05400A5B906 /* SetNotificationsMode.swift */, + 5CB0BA952827143500B3292C /* MakeConnection.swift */, ); path = Onboarding; sourceTree = ""; @@ -827,6 +830,7 @@ 5C35CFCB27B2E91D00FB6C6D /* NtfManager.swift in Sources */, 3C8C548928133C84000A3EC7 /* PasteToConnectView.swift in Sources */, 5C9D13A3282187BB00AB8B43 /* WebRTC.swift in Sources */, + 5C9A5BDB2871E05400A5B906 /* SetNotificationsMode.swift in Sources */, 5CB0BA8E2827126500B3292C /* OnboardingView.swift in Sources */, 5C2E261227A30FEA00F70299 /* TerminalView.swift in Sources */, 5C9FD96E27A5D6ED0075386C /* SendMessageView.swift in Sources */, diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index 5b92fd3e87..d654ea61b3 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -427,7 +427,7 @@ public enum NotificationsMode: String, Decodable, SelectableItem { public var label: LocalizedStringKey { switch self { - case .off: return "Off" + case .off: return "Off (Local)" case .periodic: return "Periodically" case .instant: return "Instantly" }