mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-09-28 06:59:42 +00:00
Follow-up to "Recognise wrong QR code type in every scanner", from an iOS review of that commit. - ScanProtocolServer: a rejected scan now alerts in place instead of dismissing, but the sheet had no way out - move its title into the nav bar and add a Cancel button. Fixes the preview, which needs AppTheme now that the themed background is applied inside the view. - MigrateToDevice: classify the pasted link off the main thread, as the Kotlin paste path already does - pasted text is unbounded and classifying a file link parses its YAML description. Guard the result, as the sheet can be dismissed while classifying. - Relay message named the Chat relay screen but not that the address is only kept once the relay is tested, so following it lost the pasted address to an "Invalid relay name!" alert. The migration message named a label that only exists on desktop. - ContentView: the /r deep link showed the relay alert without the guidance added to the other call site; both literals were identical before. - Document WrongQRCode.swift per apps/ios/CODE.md, and refresh the specs and product docs that quote the changed messages.
532 lines
23 KiB
Swift
532 lines
23 KiB
Swift
//
|
|
// ContentView.swift
|
|
// Shared
|
|
//
|
|
// Created by Evgeny Poberezkin on 17/01/2022.
|
|
//
|
|
// Spec: spec/client/navigation.md
|
|
|
|
import SwiftUI
|
|
import Intents
|
|
import SimpleXChat
|
|
|
|
private enum NoticesSheet: Identifiable {
|
|
case whatsNew(updatedConditions: Bool)
|
|
|
|
var id: String {
|
|
switch self {
|
|
case .whatsNew: return "whatsNew"
|
|
}
|
|
}
|
|
}
|
|
|
|
// Spec: spec/client/navigation.md#ContentView
|
|
struct ContentView: View {
|
|
@EnvironmentObject var chatModel: ChatModel
|
|
@ObservedObject var alertManager = AlertManager.shared
|
|
@ObservedObject var callController = CallController.shared
|
|
// Spec: spec/client/navigation.md#AppSheetState
|
|
@ObservedObject var appSheetState = AppSheetState.shared
|
|
@Environment(\.colorScheme) var colorScheme
|
|
@EnvironmentObject var theme: AppTheme
|
|
@EnvironmentObject var sceneDelegate: SceneDelegate
|
|
|
|
// Spec: spec/client/navigation.md#contentAccessAuthenticationExtended
|
|
var contentAccessAuthenticationExtended: Bool
|
|
|
|
@Environment(\.scenePhase) var scenePhase
|
|
@State private var automaticAuthenticationAttempted = false
|
|
@State private var canConnectViewCall = false
|
|
@State private var lastSuccessfulUnlock: TimeInterval? = nil
|
|
|
|
@AppStorage(DEFAULT_SHOW_LA_NOTICE) private var prefShowLANotice = false
|
|
@AppStorage(DEFAULT_LA_NOTICE_SHOWN) private var prefLANoticeShown = false
|
|
@AppStorage(DEFAULT_PERFORM_LA) private var prefPerformLA = false
|
|
@AppStorage(DEFAULT_PRIVACY_PROTECT_SCREEN) private var protectScreen = false
|
|
@AppStorage(DEFAULT_NOTIFICATION_ALERT_SHOWN) private var notificationAlertShown = false
|
|
@State private var noticesShown = false
|
|
@State private var noticesSheetItem: NoticesSheet? = nil
|
|
@State private var showChooseLAMode = false
|
|
@State private var showSetPasscode = false
|
|
@State private var waitingForOrPassedAuth = true
|
|
@State private var chatListUserPickerSheet: UserPickerSheet? = nil
|
|
|
|
private let callTopPadding: CGFloat = 40
|
|
|
|
private var accessAuthenticated: Bool {
|
|
chatModel.contentViewAccessAuthenticated || contentAccessAuthenticationExtended
|
|
}
|
|
|
|
var body: some View {
|
|
if #available(iOS 16.0, *) {
|
|
allViews()
|
|
.scrollContentBackground(.hidden)
|
|
} else {
|
|
// on iOS 15 scroll view background disabled in SceneDelegate
|
|
allViews()
|
|
}
|
|
}
|
|
|
|
func allViews() -> some View {
|
|
ZStack {
|
|
let showCallArea = chatModel.activeCall != nil && chatModel.activeCall?.callState != .waitCapabilities && chatModel.activeCall?.callState != .invitationAccepted
|
|
// contentView() has to be in a single branch, so that enabling authentication doesn't trigger re-rendering and close settings.
|
|
// i.e. with separate branches like this settings are closed: `if prefPerformLA { ... contentView() ... } else { contentView() }
|
|
if !prefPerformLA || accessAuthenticated {
|
|
contentView()
|
|
.padding(.top, showCallArea ? callTopPadding : 0)
|
|
} else {
|
|
lockButton()
|
|
.padding(.top, showCallArea ? callTopPadding : 0)
|
|
}
|
|
|
|
if showCallArea, let call = chatModel.activeCall {
|
|
VStack {
|
|
activeCallInteractiveArea(call)
|
|
Spacer()
|
|
}
|
|
}
|
|
|
|
if chatModel.showCallView, let call = chatModel.activeCall {
|
|
callView(call)
|
|
}
|
|
|
|
if chatListUserPickerSheet == nil, let la = chatModel.laRequest {
|
|
LocalAuthView(authRequest: la)
|
|
.onDisappear {
|
|
// this flag is separate from accessAuthenticated to show initializationView while we wait for authentication
|
|
waitingForOrPassedAuth = accessAuthenticated
|
|
}
|
|
} else if showSetPasscode {
|
|
SetAppPasscodeView {
|
|
chatModel.contentViewAccessAuthenticated = true
|
|
prefPerformLA = true
|
|
showSetPasscode = false
|
|
privacyLocalAuthModeDefault.set(.passcode)
|
|
alertManager.showAlert(laTurnedOnAlert())
|
|
} cancel: {
|
|
prefPerformLA = false
|
|
showSetPasscode = false
|
|
alertManager.showAlert(laPasscodeNotSetAlert())
|
|
}
|
|
} else if chatModel.chatDbStatus == nil && AppChatState.shared.value != .stopped && waitingForOrPassedAuth {
|
|
initializationView()
|
|
}
|
|
}
|
|
.alert(isPresented: $alertManager.presentAlert) { alertManager.alertView! }
|
|
.confirmationDialog("SimpleX Lock mode", isPresented: $showChooseLAMode, titleVisibility: .visible) {
|
|
Button("System authentication") { initialEnableLA() }
|
|
Button("Passcode entry") { showSetPasscode = true }
|
|
}
|
|
.onChange(of: scenePhase) { phase in
|
|
logger.debug("scenePhase was \(String(describing: scenePhase)), now \(String(describing: phase))")
|
|
switch (phase) {
|
|
case .background:
|
|
// also see .onChange(of: scenePhase) in SimpleXApp: on entering background
|
|
// it remembers enteredBackgroundAuthenticated and sets chatModel.contentViewAccessAuthenticated to false
|
|
automaticAuthenticationAttempted = false
|
|
canConnectViewCall = false
|
|
case .active:
|
|
canConnectViewCall = !prefPerformLA || contentAccessAuthenticationExtended || unlockedRecently()
|
|
|
|
// condition `!chatModel.contentViewAccessAuthenticated` is required for when authentication is enabled in settings or on initial notice
|
|
if prefPerformLA && !chatModel.contentViewAccessAuthenticated {
|
|
if AppChatState.shared.value != .stopped {
|
|
if contentAccessAuthenticationExtended {
|
|
chatModel.contentViewAccessAuthenticated = true
|
|
} else {
|
|
if !automaticAuthenticationAttempted {
|
|
automaticAuthenticationAttempted = true
|
|
// authenticate if call kit call is not in progress
|
|
if !(CallController.useCallKit() && chatModel.showCallView && chatModel.activeCall != nil) {
|
|
authenticateContentViewAccess()
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
// when app is stopped automatic authentication is not attempted
|
|
chatModel.contentViewAccessAuthenticated = contentAccessAuthenticationExtended
|
|
}
|
|
}
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
.onAppear {
|
|
reactOnDarkThemeChanges(systemInDarkThemeCurrently)
|
|
}
|
|
.onChange(of: colorScheme) { scheme in
|
|
// It's needed to update UI colors when iOS wants to make screenshot after going to background,
|
|
// so when a user changes his global theme from dark to light or back, the app will adapt to it
|
|
reactOnDarkThemeChanges(scheme == .dark)
|
|
}
|
|
.onChange(of: theme.name) { _ in
|
|
ThemeManager.adjustWindowStyle()
|
|
}
|
|
}
|
|
|
|
// Spec: spec/client/navigation.md#contentView
|
|
@ViewBuilder private func contentView() -> some View {
|
|
if let status = chatModel.chatDbStatus, status != .ok {
|
|
DatabaseErrorView(status: status)
|
|
} else if !chatModel.v3DBMigration.startChat {
|
|
MigrateToAppGroupView()
|
|
} else if let step = chatModel.onboardingStage {
|
|
if case .onboardingComplete = step,
|
|
chatModel.currentUser != nil {
|
|
mainView()
|
|
} else {
|
|
OnboardingView(onboarding: step)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Spec: spec/client/navigation.md#callView
|
|
@ViewBuilder private func callView(_ call: Call) -> some View {
|
|
if CallController.useCallKit() {
|
|
ActiveCallView(call: call, canConnectCall: Binding.constant(true))
|
|
.onDisappear {
|
|
if prefPerformLA && !accessAuthenticated { authenticateContentViewAccess() }
|
|
}
|
|
} else {
|
|
ActiveCallView(call: call, canConnectCall: $canConnectViewCall)
|
|
if prefPerformLA && !accessAuthenticated {
|
|
Rectangle()
|
|
.fill(colorScheme == .dark ? .black : .white)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
lockButton()
|
|
}
|
|
}
|
|
}
|
|
|
|
// Spec: spec/client/navigation.md#callBanner
|
|
private func activeCallInteractiveArea(_ call: Call) -> some View {
|
|
HStack {
|
|
Text(call.contact.displayName).font(.body).foregroundColor(.white)
|
|
Spacer()
|
|
CallDuration(call: call)
|
|
}
|
|
.padding(.horizontal)
|
|
.frame(height: callTopPadding)
|
|
.background(Color(uiColor: UIColor(red: 47/255, green: 208/255, blue: 88/255, alpha: 1)))
|
|
.onTapGesture {
|
|
chatModel.activeCallViewIsCollapsed = false
|
|
}
|
|
}
|
|
|
|
struct CallDuration: View {
|
|
let call: Call
|
|
@State var text: String = ""
|
|
@State var timer: Timer? = nil
|
|
|
|
var body: some View {
|
|
Text(text).frame(minWidth: text.count <= 5 ? 52 : 77, alignment: .leading).offset(x: 4).font(.body).foregroundColor(.white)
|
|
.onAppear {
|
|
timer = Timer.scheduledTimer(withTimeInterval: 0.3, repeats: true) { timer in
|
|
if let connectedAt = call.connectedAt {
|
|
text = durationText(Int(Date.now.timeIntervalSince1970 - connectedAt.timeIntervalSince1970))
|
|
}
|
|
}
|
|
}
|
|
.onDisappear {
|
|
_ = timer?.invalidate()
|
|
}
|
|
}
|
|
}
|
|
|
|
// Spec: spec/client/navigation.md#lockButton
|
|
private func lockButton() -> some View {
|
|
Button(action: authenticateContentViewAccess) { Label("Unlock", systemImage: "lock") }
|
|
}
|
|
|
|
private func initializationView() -> some View {
|
|
VStack {
|
|
ProgressView().scaleEffect(2)
|
|
Text("Opening app…")
|
|
.padding()
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity )
|
|
.background(
|
|
Rectangle()
|
|
.fill(theme.colors.background)
|
|
)
|
|
}
|
|
|
|
private func mainView() -> some View {
|
|
ZStack(alignment: .top) {
|
|
ChatListView(activeUserPickerSheet: $chatListUserPickerSheet)
|
|
.redacted(reason: appSheetState.redactionReasons(protectScreen))
|
|
.onAppear {
|
|
// Connect only after the notifications prompt is resolved: the system prompt suspends
|
|
// the app (scene .inactive), which kills an in-flight connect and makes
|
|
// getTopViewController() nil. Deferring keeps the URL until the app is active again.
|
|
let openingViaLink = pendingConnectUrl != nil
|
|
requestNtfAuthorization(showDeniedAlert: !openingViaLink) {
|
|
connectViaUrl()
|
|
}
|
|
if !openingViaLink {
|
|
// Local Authentication notice is to be shown on next start after onboarding is complete
|
|
if (!prefLANoticeShown && prefShowLANotice && chatModel.chats.count > 2) {
|
|
prefLANoticeShown = true
|
|
alertManager.showAlert(laNoticeAlert())
|
|
} else if !chatModel.showCallView && CallController.shared.activeCallInvitation == nil {
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
|
|
if !noticesShown {
|
|
let showWhatsNew = shouldShowWhatsNew()
|
|
let showUpdatedConditions = chatModel.conditions.conditionsAction?.showNotice ?? false
|
|
noticesShown = showWhatsNew || showUpdatedConditions
|
|
if showWhatsNew || showUpdatedConditions {
|
|
noticesSheetItem = .whatsNew(updatedConditions: showUpdatedConditions)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
showReRegisterTokenAlert()
|
|
}
|
|
prefShowLANotice = true
|
|
}
|
|
.onChange(of: chatModel.appOpenUrl) { _ in connectViaUrl() }
|
|
.onChange(of: chatModel.reRegisterTknStatus) { _ in showReRegisterTokenAlert() }
|
|
.sheet(item: $noticesSheetItem) { item in
|
|
switch item {
|
|
case let .whatsNew(updatedConditions):
|
|
WhatsNewView(updatedConditions: updatedConditions)
|
|
.modifier(ThemedBackground())
|
|
.if(updatedConditions) { v in
|
|
v.task { await setConditionsNotified_() }
|
|
}
|
|
}
|
|
}
|
|
if chatModel.setDeliveryReceipts {
|
|
SetDeliveryReceiptsView()
|
|
}
|
|
IncomingCallView()
|
|
}
|
|
.onContinueUserActivity("INStartCallIntent", perform: processUserActivity)
|
|
.onContinueUserActivity("INStartAudioCallIntent", perform: processUserActivity)
|
|
.onContinueUserActivity("INStartVideoCallIntent", perform: processUserActivity)
|
|
.onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { userActivity in
|
|
if let url = userActivity.webpageURL {
|
|
logger.debug("onContinueUserActivity.NSUserActivityTypeBrowsingWeb: \(url)")
|
|
chatModel.appOpenUrl = url
|
|
}
|
|
}
|
|
}
|
|
|
|
private func setConditionsNotified_() async {
|
|
do {
|
|
let conditionsId = ChatModel.shared.conditions.currentConditions.conditionsId
|
|
try await setConditionsNotified(conditionsId: conditionsId)
|
|
} catch let error {
|
|
logger.error("setConditionsNotified error: \(responseError(error))")
|
|
}
|
|
}
|
|
|
|
private func processUserActivity(_ activity: NSUserActivity) {
|
|
let intent = activity.interaction?.intent
|
|
if let intent = intent as? INStartCallIntent {
|
|
callToRecentContact(intent.contacts, intent.callCapability == .videoCall ? .video : .audio)
|
|
} else if let intent = intent as? INStartAudioCallIntent {
|
|
callToRecentContact(intent.contacts, .audio)
|
|
} else if let intent = intent as? INStartVideoCallIntent {
|
|
callToRecentContact(intent.contacts, .video)
|
|
}
|
|
}
|
|
|
|
private func callToRecentContact(_ contacts: [INPerson]?, _ mediaType: CallMediaType) {
|
|
logger.debug("callToRecentContact")
|
|
if let contactId = contacts?.first?.personHandle?.value,
|
|
let chat = chatModel.getChat(contactId),
|
|
case let .direct(contact) = chat.chatInfo {
|
|
let activeCall = chatModel.activeCall
|
|
// This line works when a user clicks on a video button in CallKit UI while in call.
|
|
// The app tries to make another call to the same contact and overwite activeCall instance making its state broken
|
|
if let activeCall, contactId == activeCall.contact.id, mediaType == .video, !activeCall.hasVideo {
|
|
Task {
|
|
await chatModel.callCommand.processCommand(.media(source: .camera, enable: true))
|
|
}
|
|
} else if activeCall == nil {
|
|
logger.debug("callToRecentContact: schedule call")
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
|
|
CallController.shared.startCall(contact, mediaType)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Spec: spec/client/navigation.md#unlockedRecently
|
|
private func unlockedRecently() -> Bool {
|
|
if let lastSuccessfulUnlock = lastSuccessfulUnlock {
|
|
return ProcessInfo.processInfo.systemUptime - lastSuccessfulUnlock < 2
|
|
} else {
|
|
return false
|
|
}
|
|
}
|
|
|
|
private func authenticateContentViewAccess() {
|
|
logger.debug("DEBUGGING: authenticateContentViewAccess")
|
|
dismissAllSheets(animated: false) {
|
|
logger.debug("DEBUGGING: authenticateContentViewAccess, in dismissAllSheets callback")
|
|
chatModel.chatId = nil
|
|
|
|
authenticate(reason: NSLocalizedString("Unlock app", comment: "authentication reason"), selfDestruct: true) { laResult in
|
|
logger.debug("DEBUGGING: authenticate callback: \(String(describing: laResult))")
|
|
switch (laResult) {
|
|
case .success:
|
|
chatModel.contentViewAccessAuthenticated = true
|
|
canConnectViewCall = true
|
|
lastSuccessfulUnlock = ProcessInfo.processInfo.systemUptime
|
|
case .failed:
|
|
chatModel.contentViewAccessAuthenticated = false
|
|
if privacyLocalAuthModeDefault.get() == .passcode {
|
|
AlertManager.shared.showAlert(laFailedAlert())
|
|
}
|
|
case .unavailable:
|
|
prefPerformLA = false
|
|
canConnectViewCall = true
|
|
AlertManager.shared.showAlert(laUnavailableTurningOffAlert())
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func requestNtfAuthorization(showDeniedAlert: Bool = true, whenDone: (() -> Void)? = nil) {
|
|
NtfManager.shared.requestAuthorization(
|
|
onDeny: {
|
|
if showDeniedAlert, !notificationAlertShown {
|
|
notificationAlertShown = true
|
|
alertManager.showAlert(notificationAlert())
|
|
}
|
|
},
|
|
onAuthorized: { notificationAlertShown = false },
|
|
whenDone: { if let whenDone { DispatchQueue.main.async(execute: whenDone) } }
|
|
)
|
|
}
|
|
|
|
func laNoticeAlert() -> Alert {
|
|
Alert(
|
|
title: Text("SimpleX Lock"),
|
|
message: Text("To protect your information, turn on SimpleX Lock.\nYou will be prompted to complete authentication before this feature is enabled."),
|
|
primaryButton: .default(Text("Turn on")) { showChooseLAMode = true },
|
|
secondaryButton: .cancel()
|
|
)
|
|
}
|
|
|
|
private func initialEnableLA () {
|
|
privacyLocalAuthModeDefault.set(.system)
|
|
authenticate(reason: NSLocalizedString("Enable SimpleX Lock", comment: "authentication reason")) { laResult in
|
|
switch laResult {
|
|
case .success:
|
|
chatModel.contentViewAccessAuthenticated = true
|
|
prefPerformLA = true
|
|
alertManager.showAlert(laTurnedOnAlert())
|
|
case .failed:
|
|
prefPerformLA = false
|
|
alertManager.showAlert(laFailedAlert())
|
|
case .unavailable:
|
|
prefPerformLA = false
|
|
alertManager.showAlert(laUnavailableInstructionAlert())
|
|
}
|
|
}
|
|
}
|
|
|
|
func notificationAlert() -> Alert {
|
|
Alert(
|
|
title: Text("Notifications are disabled!"),
|
|
message: Text("The app can notify you when you receive messages or contact requests - please open settings to enable."),
|
|
primaryButton: .default(Text("Open Settings")) {
|
|
DispatchQueue.main.async {
|
|
UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!, options: [:], completionHandler: nil)
|
|
}
|
|
},
|
|
secondaryButton: .cancel()
|
|
)
|
|
}
|
|
|
|
// Spec: spec/client/navigation.md#connectViaUrl
|
|
// a URL opened via link that is ready to be connected now (appOpenUrl immediately, or
|
|
// appOpenUrlLater once the app is active — see .onChange(of: scenePhase) in SimpleXApp)
|
|
private var pendingConnectUrl: URL? {
|
|
let m = ChatModel.shared
|
|
if let url = m.appOpenUrl { return url }
|
|
if let url = m.appOpenUrlLater, AppChatState.shared.value == .active, scenePhase == .active { return url }
|
|
return nil
|
|
}
|
|
|
|
func connectViaUrl() {
|
|
let m = ChatModel.shared
|
|
guard let url = pendingConnectUrl else { return }
|
|
if m.appOpenUrl != nil { m.appOpenUrl = nil } else { m.appOpenUrlLater = nil }
|
|
connectViaUrl_(url)
|
|
}
|
|
|
|
func connectViaUrl_(_ url: URL) {
|
|
dismissAllSheets() {
|
|
var path = url.path
|
|
if path == "/r" {
|
|
showAlert(
|
|
NSLocalizedString("Relay address", comment: "alert title"),
|
|
message: NSLocalizedString("This is a chat relay address, it cannot be used to connect. To use it, open Network & servers, Your servers, Add server, then Chat relay, paste the address, then Test relay.", comment: "alert message")
|
|
)
|
|
} else if (path == "/contact" || path == "/invitation" || path == "/a" || path == "/c" || path == "/g" || path == "/i") {
|
|
path.removeFirst()
|
|
let link = url.absoluteString.replacingOccurrences(of: "///\(path)", with: "/\(path)")
|
|
planAndConnect(
|
|
link,
|
|
theme: theme,
|
|
dismiss: false
|
|
)
|
|
} else {
|
|
AlertManager.shared.showAlert(Alert(title: Text("Error: URL is invalid")))
|
|
}
|
|
}
|
|
}
|
|
|
|
func showReRegisterTokenAlert() {
|
|
dismissAllSheets() {
|
|
let m = ChatModel.shared
|
|
if let errorTknStatus = m.reRegisterTknStatus, let token = chatModel.deviceToken {
|
|
chatModel.reRegisterTknStatus = nil
|
|
AlertManager.shared.showAlert(Alert(
|
|
title: Text("Notifications error"),
|
|
message: Text(tokenStatusInfo(errorTknStatus, register: true)),
|
|
primaryButton: .default(Text("Register")) { reRegisterToken(token: token) },
|
|
secondaryButton: .cancel()
|
|
))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
final class AlertManager: ObservableObject {
|
|
static let shared = AlertManager()
|
|
@Published var presentAlert = false
|
|
@Published var alertView: Alert?
|
|
|
|
func showAlert(_ alert: Alert) {
|
|
logger.debug("AlertManager.showAlert")
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
|
|
self.alertView = alert
|
|
self.presentAlert = true
|
|
}
|
|
}
|
|
|
|
func showAlertMsg(title: LocalizedStringKey, message: LocalizedStringKey? = nil) {
|
|
showAlert(mkAlert(title: title, message: message))
|
|
}
|
|
}
|
|
|
|
func mkAlert(title: LocalizedStringKey, message: LocalizedStringKey? = nil) -> Alert {
|
|
if let message = message {
|
|
return Alert(title: Text(title), message: Text(message))
|
|
} else {
|
|
return Alert(title: Text(title))
|
|
}
|
|
}
|
|
|
|
//struct ContentView_Previews: PreviewProvider {
|
|
// static var previews: some View {
|
|
// ContentView(text: "Hello!")
|
|
// }
|
|
//}
|