mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2025-06-28 12:19:54 +00:00
* core, ui: optionally use short links (#5799) * core: optionally use short links * update test * update simplexmq, short group links * fix query * fix parser for _connect * ios: use short links * shorten links to remove fingerprint and onion hosts from known servers * fix parser * tests * nix * update query plans * update simplexmq, simplex: schema for short links * simplexmq * update ios * fix short links in ios * android: use short links * fix short group links, test short link connection plans * core: fix connection plan to recognize own short links * update simplexmq * space * all tests * relative symlinks in simplexmq to fix windows build * core: improve connection plan for short links (#5825) * core: improve connection plan for short links * improve connection plans * update UI * update simplexmq * ios: add preset server domains to entitlements, add short link paths to .well-known/apple-app-site-association * update simplexmq * fix group short link in iOS, fix simplex:/ scheme saved to database or used for connection plans * update simplexmq * ios: delay opening URI from outside until the app is started * update simplexmq
518 lines
22 KiB
Swift
518 lines
22 KiB
Swift
//
|
|
// ContentView.swift
|
|
// Shared
|
|
//
|
|
// Created by Evgeny Poberezkin on 17/01/2022.
|
|
//
|
|
|
|
import SwiftUI
|
|
import Intents
|
|
import SimpleXChat
|
|
|
|
private enum NoticesSheet: Identifiable {
|
|
case whatsNew(updatedConditions: Bool)
|
|
|
|
var id: String {
|
|
switch self {
|
|
case .whatsNew: return "whatsNew"
|
|
}
|
|
}
|
|
}
|
|
|
|
struct ContentView: View {
|
|
@EnvironmentObject var chatModel: ChatModel
|
|
@ObservedObject var alertManager = AlertManager.shared
|
|
@ObservedObject var callController = CallController.shared
|
|
@ObservedObject var appSheetState = AppSheetState.shared
|
|
@Environment(\.colorScheme) var colorScheme
|
|
@EnvironmentObject var theme: AppTheme
|
|
@EnvironmentObject var sceneDelegate: SceneDelegate
|
|
|
|
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 chatListActionSheet: ChatListActionSheet? = nil
|
|
@State private var chatListUserPickerSheet: UserPickerSheet? = nil
|
|
|
|
private let callTopPadding: CGFloat = 40
|
|
|
|
private enum ChatListActionSheet: Identifiable {
|
|
case planAndConnectSheet(sheet: PlanAndConnectActionSheet)
|
|
|
|
var id: String {
|
|
switch self {
|
|
case let .planAndConnectSheet(sheet): return sheet.id
|
|
}
|
|
}
|
|
}
|
|
|
|
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()
|
|
}
|
|
}
|
|
|
|
@ViewBuilder 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()
|
|
}
|
|
}
|
|
|
|
@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()
|
|
.actionSheet(item: $chatListActionSheet) { sheet in
|
|
switch sheet {
|
|
case let .planAndConnectSheet(sheet): return planAndConnectActionSheet(sheet, dismiss: false)
|
|
}
|
|
}
|
|
} else {
|
|
OnboardingView(onboarding: step)
|
|
}
|
|
}
|
|
}
|
|
|
|
@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()
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder 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()
|
|
}
|
|
}
|
|
}
|
|
|
|
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 {
|
|
requestNtfAuthorization()
|
|
// 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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
prefShowLANotice = true
|
|
connectViaUrl()
|
|
showReRegisterTokenAlert()
|
|
}
|
|
.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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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() {
|
|
NtfManager.shared.requestAuthorization(
|
|
onDeny: {
|
|
if (!notificationAlertShown) {
|
|
notificationAlertShown = true
|
|
alertManager.showAlert(notificationAlert())
|
|
}
|
|
},
|
|
onAuthorized: { notificationAlertShown = false }
|
|
)
|
|
}
|
|
|
|
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()
|
|
)
|
|
}
|
|
|
|
func connectViaUrl() {
|
|
let m = ChatModel.shared
|
|
if let url = m.appOpenUrl {
|
|
m.appOpenUrl = nil
|
|
dismissAllSheets() {
|
|
var path = url.path
|
|
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,
|
|
showAlert: showPlanAndConnectAlert,
|
|
showActionSheet: { chatListActionSheet = .planAndConnectSheet(sheet: $0) },
|
|
dismiss: false,
|
|
incognito: nil
|
|
)
|
|
} 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()
|
|
))
|
|
}
|
|
}
|
|
}
|
|
|
|
private func showPlanAndConnectAlert(_ alert: PlanAndConnectAlert) {
|
|
AlertManager.shared.showAlert(planAndConnectAlert(alert, dismiss: false))
|
|
}
|
|
}
|
|
|
|
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!")
|
|
// }
|
|
//}
|