From 6865515f4368b40528b490b791da62c32a60c76a Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin Date: Sun, 28 Jul 2024 17:54:58 +0100 Subject: [PATCH] ios: share extension (#4466) * ios: share extension (#4414) * ios: add share extension target * ios: Add UI * ios: send file from share-sheet * image utils * ShareError * error handling; ui-cleanup * progress bar; completion for direct chat * cleanup * cleanup * ios: unify filter and sort between forward and share sheets * ios: match share sheet styling with the main app * ios: fix text input stroke width * ios: align compose views * more of the same... * ShareAPI * remove combine * minor * Better error descriptions --------- Co-authored-by: Evgeny Poberezkin * ios: enable file sending workers in share extension (#4474) * ios: align compose background, row height and fallback images for share-sheet (#4467) Co-authored-by: Evgeny Poberezkin * ios: coordinate database access between share extension, the app and notifications extension (#4472) * ios: database management proposal * Add SEState * Global event loop * minor * reset state * use apiCreateItem for local chats * simplify waiting for suspension * loading bar * Dismiss share sheet with error --------- Co-authored-by: Evgeny Poberezkin * send image message (#4481) Co-authored-by: Evgeny Poberezkin * ios: improve share extension completion handling (#4486) * improve completion handling * minor * show only spinner for group send * rework event loop, errorAlert * group chat timeout loading bar * state machine WIP * event loop actor * alert * errors text * default * file error --------- Co-authored-by: Evgeny Poberezkin * ios: add remaining share types; process attachment in background on launch (#4510) * add remaining share types; process attachment in background on launch * cleanup diff * revert `makeVideoQualityLower` * reduce diff * reduce diff * iOS15 support * process events when sharing link and text * cleanup * remove video file on failure * cleanup CompletionHandler --------- Co-authored-by: Evgeny Poberezkin * ios: share extension - additional alerts and media previews (#4521) * add remaining share types; process attachment in background on launch * cleanup diff * revert `makeVideoQualityLower` * reduce diff * reduce diff * iOS15 support * process events when sharing link and text * cleanup * remove video file on failure * cleanup CompletionHandler * media previews * network timeout alert * revert framework compiler optimisation flag * suspend chat after sheet dismiss * activate chat * update * fix search * sendMessageColor, file preview, chat deselect, simplify error action * cleanup * interupt database closing when sheet is reopened quickly * cleanup redundant alert check * restore package * refactor previews, remove link preview * show link preview when becomes available * comment * dont fail on invalid image * suspend --------- Co-authored-by: Evgeny Poberezkin * ios: descriptive database errors (#4527) * ios: set share extension as inactive when suspending chat --------- Co-authored-by: Arturs Krumins --- apps/ios/Shared/Model/ChatModel.swift | 2 +- apps/ios/Shared/Model/SimpleXAPI.swift | 56 -- apps/ios/Shared/Model/SuspendChat.swift | 12 + apps/ios/Shared/Views/Chat/ChatInfoView.swift | 2 +- .../Chat/ChatItem/CIRcvDecryptionError.swift | 4 +- .../Views/Chat/ChatItemForwardingView.swift | 40 +- .../Chat/ComposeMessage/ComposeLinkView.swift | 29 - .../Chat/ComposeMessage/SendMessageView.swift | 3 +- .../Chat/Group/AddGroupMembersView.swift | 4 +- .../Views/Chat/Group/GroupChatInfoView.swift | 4 +- .../Views/Chat/Group/GroupLinkView.swift | 4 +- .../Chat/Group/GroupMemberInfoView.swift | 4 +- .../ChatList/ContactConnectionInfo.swift | 4 +- .../Views/Database/DatabaseErrorView.swift | 11 +- .../Shared/Views/Helpers/ChatInfoImage.swift | 10 +- .../ios/Shared/Views/Helpers/VideoUtils.swift | 26 - .../Views/Migration/MigrateToDevice.swift | 2 +- .../RemoteAccess/ConnectDesktopView.swift | 4 +- .../UserSettings/ProtocolServerView.swift | 4 - .../Views/UserSettings/UserAddressView.swift | 4 +- .../Views/UserSettings/UserProfilesView.swift | 4 +- .../ios/SimpleX NSE/NotificationService.swift | 10 + apps/ios/SimpleX SE/Info.plist | 35 ++ apps/ios/SimpleX SE/SEChatState.swift | 39 ++ apps/ios/SimpleX SE/ShareAPI.swift | 115 ++++ apps/ios/SimpleX SE/ShareModel.swift | 500 ++++++++++++++++++ apps/ios/SimpleX SE/ShareView.swift | 180 +++++++ apps/ios/SimpleX SE/ShareViewController.swift | 46 ++ apps/ios/SimpleX SE/SimpleX SE.entitlements | 14 + apps/ios/SimpleX.xcodeproj/project.pbxproj | 200 ++++++- .../xcschemes/SimpleX SE.xcscheme | 97 ++++ apps/ios/SimpleXChat/API.swift | 6 +- apps/ios/SimpleXChat/AppGroup.swift | 12 + apps/ios/SimpleXChat/ChatTypes.swift | 2 +- apps/ios/SimpleXChat/ChatUtils.swift | 62 +++ apps/ios/SimpleXChat/ErrorAlert.swift | 159 ++++++ apps/ios/SimpleXChat/ImageUtils.swift | 80 +++ .../SimpleXChat/SharedFileSubscriber.swift | 22 + apps/ios/SimpleXChat/hs_init.c | 16 + apps/ios/SimpleXChat/hs_init.h | 2 + 40 files changed, 1626 insertions(+), 204 deletions(-) delete mode 100644 apps/ios/Shared/Views/Helpers/VideoUtils.swift create mode 100644 apps/ios/SimpleX SE/Info.plist create mode 100644 apps/ios/SimpleX SE/SEChatState.swift create mode 100644 apps/ios/SimpleX SE/ShareAPI.swift create mode 100644 apps/ios/SimpleX SE/ShareModel.swift create mode 100644 apps/ios/SimpleX SE/ShareView.swift create mode 100644 apps/ios/SimpleX SE/ShareViewController.swift create mode 100644 apps/ios/SimpleX SE/SimpleX SE.entitlements create mode 100644 apps/ios/SimpleX.xcodeproj/xcshareddata/xcschemes/SimpleX SE.xcscheme create mode 100644 apps/ios/SimpleXChat/ChatUtils.swift create mode 100644 apps/ios/SimpleXChat/ErrorAlert.swift diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index a9d94be217..3d5f238122 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -778,7 +778,7 @@ struct UnreadChatItemCounts: Equatable { var unreadBelow: Int } -final class Chat: ObservableObject, Identifiable { +final class Chat: ObservableObject, Identifiable, ChatLike { @Published var chatInfo: ChatInfo @Published var chatItems: [ChatItem] @Published var chatStats: ChatStats diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 3ffbe9a00d..fb727b494e 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -1091,62 +1091,6 @@ func deleteRemoteCtrl(_ rcId: Int64) async throws { try await sendCommandOkResp(.deleteRemoteCtrl(remoteCtrlId: rcId)) } -struct ErrorAlert { - var title: LocalizedStringKey - var message: LocalizedStringKey -} - -func getNetworkErrorAlert(_ r: ChatResponse) -> ErrorAlert? { - switch r { - case let .chatCmdError(_, .errorAgent(.BROKER(addr, .TIMEOUT))): - return ErrorAlert(title: "Connection timeout", message: "Please check your network connection with \(serverHostname(addr)) and try again.") - case let .chatCmdError(_, .errorAgent(.BROKER(addr, .NETWORK))): - return ErrorAlert(title: "Connection error", message: "Please check your network connection with \(serverHostname(addr)) and try again.") - case let .chatCmdError(_, .errorAgent(.BROKER(addr, .HOST))): - return ErrorAlert(title: "Connection error", message: "Server address is incompatible with network settings: \(serverHostname(addr)).") - case let .chatCmdError(_, .errorAgent(.BROKER(addr, .TRANSPORT(.version)))): - return ErrorAlert(title: "Connection error", message: "Server version is incompatible with your app: \(serverHostname(addr)).") - case let .chatCmdError(_, .errorAgent(.SMP(serverAddress, .PROXY(proxyErr)))): - return smpProxyErrorAlert(proxyErr, serverAddress) - case let .chatCmdError(_, .errorAgent(.PROXY(proxyServer, relayServer, .protocolError(.PROXY(proxyErr))))): - return proxyDestinationErrorAlert(proxyErr, proxyServer, relayServer) - default: - return nil - } -} - -private func smpProxyErrorAlert(_ proxyErr: ProxyError, _ srvAddr: String) -> ErrorAlert? { - switch proxyErr { - case .BROKER(brokerErr: .TIMEOUT): - return ErrorAlert(title: "Private routing error", message: "Error connecting to forwarding server \(serverHostname(srvAddr)). Please try later.") - case .BROKER(brokerErr: .NETWORK): - return ErrorAlert(title: "Private routing error", message: "Error connecting to forwarding server \(serverHostname(srvAddr)). Please try later.") - case .BROKER(brokerErr: .HOST): - return ErrorAlert(title: "Private routing error", message: "Forwarding server address is incompatible with network settings: \(serverHostname(srvAddr)).") - case .BROKER(brokerErr: .TRANSPORT(.version)): - return ErrorAlert(title: "Private routing error", message: "Forwarding server version is incompatible with network settings: \(serverHostname(srvAddr)).") - default: - return nil - } -} - -private func proxyDestinationErrorAlert(_ proxyErr: ProxyError, _ proxyServer: String, _ relayServer: String) -> ErrorAlert? { - switch proxyErr { - case .BROKER(brokerErr: .TIMEOUT): - return ErrorAlert(title: "Private routing error", message: "Forwarding server \(serverHostname(proxyServer)) failed to connect to destination server \(serverHostname(relayServer)). Please try later.") - case .BROKER(brokerErr: .NETWORK): - return ErrorAlert(title: "Private routing error", message: "Forwarding server \(serverHostname(proxyServer)) failed to connect to destination server \(serverHostname(relayServer)). Please try later.") - case .NO_SESSION: - return ErrorAlert(title: "Private routing error", message: "Forwarding server \(serverHostname(proxyServer)) failed to connect to destination server \(serverHostname(relayServer)). Please try later.") - case .BROKER(brokerErr: .HOST): - return ErrorAlert(title: "Private routing error", message: "Destination server address of \(serverHostname(relayServer)) is incompatible with forwarding server \(serverHostname(proxyServer)) settings.") - case .BROKER(brokerErr: .TRANSPORT(.version)): - return ErrorAlert(title: "Private routing error", message: "Destination server version of \(serverHostname(relayServer)) is incompatible with forwarding server \(serverHostname(proxyServer)).") - default: - return nil - } -} - func networkErrorAlert(_ r: ChatResponse) -> Alert? { if let alert = getNetworkErrorAlert(r) { return mkAlert(title: alert.title, message: alert.message) diff --git a/apps/ios/Shared/Model/SuspendChat.swift b/apps/ios/Shared/Model/SuspendChat.swift index 4494adc0e8..92bcdcac53 100644 --- a/apps/ios/Shared/Model/SuspendChat.swift +++ b/apps/ios/Shared/Model/SuspendChat.swift @@ -36,6 +36,18 @@ private func _suspendChat(timeout: Int) { } } +let seSubscriber = seMessageSubscriber { + switch $0 { + case let .state(state): + switch state { + case .inactive: + if AppChatState.shared.value.inactive { activateChat() } + case .sendingMessage: + if AppChatState.shared.value.canSuspend { suspendChat() } + } + } +} + func suspendChat() { suspendLockQueue.sync { _suspendChat(timeout: appSuspendTimeout) diff --git a/apps/ios/Shared/Views/Chat/ChatInfoView.swift b/apps/ios/Shared/Views/Chat/ChatInfoView.swift index f83fca8e2e..4d45db4700 100644 --- a/apps/ios/Shared/Views/Chat/ChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatInfoView.swift @@ -112,7 +112,7 @@ struct ChatInfoView: View { case abortSwitchAddressAlert case syncConnectionForceAlert case queueInfo(info: String) - case error(title: LocalizedStringKey, error: LocalizedStringKey = "") + case error(title: LocalizedStringKey, error: LocalizedStringKey?) var id: String { switch self { diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift index 7023449e9f..1f2e16448d 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift @@ -27,7 +27,7 @@ struct CIRcvDecryptionError: View { case syncNotSupportedContactAlert case syncNotSupportedMemberAlert case decryptionErrorAlert - case error(title: LocalizedStringKey, error: LocalizedStringKey) + case error(title: LocalizedStringKey, error: LocalizedStringKey?) var id: String { switch self { @@ -62,7 +62,7 @@ struct CIRcvDecryptionError: View { case .syncNotSupportedContactAlert: return Alert(title: Text("Fix not supported by contact"), message: message()) case .syncNotSupportedMemberAlert: return Alert(title: Text("Fix not supported by group member"), message: message()) case .decryptionErrorAlert: return Alert(title: Text("Decryption error"), message: message()) - case let .error(title, error): return Alert(title: Text(title), message: Text(error)) + case let .error(title, error): return mkAlert(title: title, message: error) } } } diff --git a/apps/ios/Shared/Views/Chat/ChatItemForwardingView.swift b/apps/ios/Shared/Views/Chat/ChatItemForwardingView.swift index 1814419623..0b7de32a88 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemForwardingView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemForwardingView.swift @@ -22,7 +22,7 @@ struct ChatItemForwardingView: View { @FocusState private var searchFocused @State private var alert: SomeAlert? @State private var hasSimplexLink_: Bool? - private let chatsToForwardTo = filterChatsToForwardTo() + private let chatsToForwardTo = filterChatsToForwardTo(chats: ChatModel.shared.chats) var body: some View { NavigationView { @@ -67,22 +67,6 @@ struct ChatItemForwardingView: View { } } - private func foundChat(_ chat: Chat, _ searchStr: String) -> Bool { - let cInfo = chat.chatInfo - return switch cInfo { - case let .direct(contact): - viewNameContains(cInfo, searchStr) || - contact.profile.displayName.localizedLowercase.contains(searchStr) || - contact.fullName.localizedLowercase.contains(searchStr) - default: - viewNameContains(cInfo, searchStr) - } - - func viewNameContains(_ cInfo: ChatInfo, _ s: String) -> Bool { - cInfo.chatViewName.localizedLowercase.contains(s) - } - } - private func prohibitedByPref(_ chat: Chat) -> Bool { // preference checks should match checks in compose view let simplexLinkProhibited = hasSimplexLink && !chat.groupFeatureEnabled(.simplexLinks) @@ -162,27 +146,6 @@ struct ChatItemForwardingView: View { } } -private func filterChatsToForwardTo() -> [Chat] { - var filteredChats = ChatModel.shared.chats.filter { c in - c.chatInfo.chatType != .local && canForwardToChat(c) - } - if let privateNotes = ChatModel.shared.chats.first(where: { $0.chatInfo.chatType == .local }) { - filteredChats.insert(privateNotes, at: 0) - } - return filteredChats -} - -private func canForwardToChat(_ chat: Chat) -> Bool { - switch chat.chatInfo { - case let .direct(contact): contact.sendMsgEnabled && !contact.nextSendGrpInv - case let .group(groupInfo): groupInfo.sendMsgEnabled - case let .local(noteFolder): noteFolder.sendMsgEnabled - case .contactRequest: false - case .contactConnection: false - case .invalidJSON: false - } -} - #Preview { ChatItemForwardingView( ci: ChatItem.getSample(1, .directSnd, .now, "hello"), @@ -190,3 +153,4 @@ private func canForwardToChat(_ chat: Chat) -> Bool { composeState: Binding.constant(ComposeState(message: "hello")) ).environmentObject(CurrentColors.toAppTheme()) } + diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeLinkView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeLinkView.swift index 0fb48033d5..66cb9edcf8 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeLinkView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeLinkView.swift @@ -10,35 +10,6 @@ import SwiftUI import LinkPresentation import SimpleXChat -func getLinkPreview(url: URL, cb: @escaping (LinkPreview?) -> Void) { - logger.debug("getLinkMetadata: fetching URL preview") - LPMetadataProvider().startFetchingMetadata(for: url){ metadata, error in - if let e = error { - logger.error("Error retrieving link metadata: \(e.localizedDescription)") - } - if let metadata = metadata, - let imageProvider = metadata.imageProvider, - imageProvider.canLoadObject(ofClass: UIImage.self) { - imageProvider.loadObject(ofClass: UIImage.self){ object, error in - var linkPreview: LinkPreview? = nil - if let error = error { - logger.error("Couldn't load image preview from link metadata with error: \(error.localizedDescription)") - } else { - if let image = object as? UIImage, - let resized = resizeImageToStrSize(image, maxDataSize: 14000), - let title = metadata.title, - let uri = metadata.originalURL { - linkPreview = LinkPreview(uri: uri, title: title, image: resized) - } - } - cb(linkPreview) - } - } else { - cb(nil) - } - } -} - struct ComposeLinkView: View { @EnvironmentObject var theme: AppTheme let linkPreview: LinkPreview? diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift index a720c3aaaf..a776ebf0dd 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift @@ -67,7 +67,6 @@ struct SendMessageView: View { .fixedSize(horizontal: false, vertical: true) } } - if progressByTimeout { ProgressView() .scaleEffect(1.4) @@ -87,7 +86,7 @@ struct SendMessageView: View { .padding(.vertical, 1) .background(theme.colors.background) .clipShape(composeShape) - .overlay(composeShape.strokeBorder(.secondary, lineWidth: 0.3, antialiased: true)) + .overlay(composeShape.strokeBorder(.secondary, lineWidth: 0.5).opacity(0.7)) } .onChange(of: composeState.message, perform: { text in updateFont(text) }) .onChange(of: composeState.inProgress) { inProgress in diff --git a/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift b/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift index 49239c8fa5..dc867b026f 100644 --- a/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift +++ b/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift @@ -35,7 +35,7 @@ struct AddGroupMembersViewCommon: View { private enum AddGroupMembersAlert: Identifiable { case prohibitedToInviteIncognito - case error(title: LocalizedStringKey, error: LocalizedStringKey = "") + case error(title: LocalizedStringKey, error: LocalizedStringKey?) var id: String { switch self { @@ -122,7 +122,7 @@ struct AddGroupMembersViewCommon: View { message: Text("You're trying to invite contact with whom you've shared an incognito profile to the group in which you're using your main profile") ) case let .error(title, error): - return Alert(title: Text(title), message: Text(error)) + return mkAlert(title: title, message: error) } } .onChange(of: selectedContacts) { _ in diff --git a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift index 59a21d2330..f89009f93f 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift @@ -40,7 +40,7 @@ struct GroupChatInfoView: View { case blockForAllAlert(mem: GroupMember) case unblockForAllAlert(mem: GroupMember) case removeMemberAlert(mem: GroupMember) - case error(title: LocalizedStringKey, error: LocalizedStringKey) + case error(title: LocalizedStringKey, error: LocalizedStringKey?) var id: String { switch self { @@ -158,7 +158,7 @@ struct GroupChatInfoView: View { case let .blockForAllAlert(mem): return blockForAllAlert(groupInfo, mem) case let .unblockForAllAlert(mem): return unblockForAllAlert(groupInfo, mem) case let .removeMemberAlert(mem): return removeMemberAlert(mem) - case let .error(title, error): return Alert(title: Text(title), message: Text(error)) + case let .error(title, error): return mkAlert(title: title, message: error) } } .onAppear { diff --git a/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift b/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift index adf5f998a4..93a8be04f4 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift @@ -22,7 +22,7 @@ struct GroupLinkView: View { private enum GroupLinkAlert: Identifiable { case deleteLink - case error(title: LocalizedStringKey, error: LocalizedStringKey = "") + case error(title: LocalizedStringKey, error: LocalizedStringKey?) var id: String { switch self { @@ -113,7 +113,7 @@ struct GroupLinkView: View { }, secondaryButton: .cancel() ) case let .error(title, error): - return Alert(title: Text(title), message: Text(error)) + return mkAlert(title: title, message: error) } } .onChange(of: groupLinkMemberRole) { _ in diff --git a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift index 3e4c3c9f6e..12b5bd5a98 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift @@ -37,7 +37,7 @@ struct GroupMemberInfoView: View { case syncConnectionForceAlert case planAndConnectAlert(alert: PlanAndConnectAlert) case queueInfo(info: String) - case error(title: LocalizedStringKey, error: LocalizedStringKey) + case error(title: LocalizedStringKey, error: LocalizedStringKey?) var id: String { switch self { @@ -237,7 +237,7 @@ struct GroupMemberInfoView: View { case .syncConnectionForceAlert: return syncConnectionForceAlert({ syncMemberConnection(force: true) }) case let .planAndConnectAlert(alert): return planAndConnectAlert(alert, dismiss: true) case let .queueInfo(info): return queueInfoAlert(info) - case let .error(title, error): return Alert(title: Text(title), message: Text(error)) + case let .error(title, error): return mkAlert(title: title, message: error) } } .actionSheet(item: $sheet) { s in planAndConnectActionSheet(s, dismiss: true) } diff --git a/apps/ios/Shared/Views/ChatList/ContactConnectionInfo.swift b/apps/ios/Shared/Views/ChatList/ContactConnectionInfo.swift index b7e641a338..0f64b632dc 100644 --- a/apps/ios/Shared/Views/ChatList/ContactConnectionInfo.swift +++ b/apps/ios/Shared/Views/ChatList/ContactConnectionInfo.swift @@ -21,7 +21,7 @@ struct ContactConnectionInfo: View { enum CCInfoAlert: Identifiable { case deleteInvitationAlert - case error(title: LocalizedStringKey, error: LocalizedStringKey) + case error(title: LocalizedStringKey, error: LocalizedStringKey?) var id: String { switch self { @@ -102,7 +102,7 @@ struct ContactConnectionInfo: View { } success: { dismiss() } - case let .error(title, error): return Alert(title: Text(title), message: Text(error)) + case let .error(title, error): return mkAlert(title: title, message: error) } } .onAppear { diff --git a/apps/ios/Shared/Views/Database/DatabaseErrorView.swift b/apps/ios/Shared/Views/Database/DatabaseErrorView.swift index f8d282a6d1..9d71e2a788 100644 --- a/apps/ios/Shared/Views/Database/DatabaseErrorView.swift +++ b/apps/ios/Shared/Views/Database/DatabaseErrorView.swift @@ -64,7 +64,7 @@ struct DatabaseErrorView: View { case let .migrationError(mtrError): titleText("Incompatible database version") fileNameText(dbFile) - Text("Error: ") + Text(DatabaseErrorView.mtrErrorDescription(mtrError)) + Text("Error: ") + Text(mtrErrorDescription(mtrError)) } case let .errorSQL(dbFile, migrationSQLError): titleText("Database error") @@ -105,15 +105,6 @@ struct DatabaseErrorView: View { Text("Migrations: \(ms.joined(separator: ", "))") } - static func mtrErrorDescription(_ err: MTRError) -> LocalizedStringKey { - switch err { - case let .noDown(dbMigrations): - return "database version is newer than the app, but no down migration for: \(dbMigrations.joined(separator: ", "))" - case let .different(appMigration, dbMigration): - return "different migration in the app/database: \(appMigration) / \(dbMigration)" - } - } - private func databaseKeyField(onSubmit: @escaping () -> Void) -> some View { PassphraseField(key: $dbKey, placeholder: "Enter passphrase…", valid: validKey(dbKey), onSubmit: onSubmit) } diff --git a/apps/ios/Shared/Views/Helpers/ChatInfoImage.swift b/apps/ios/Shared/Views/Helpers/ChatInfoImage.swift index 844b5ab4d3..40d62e009b 100644 --- a/apps/ios/Shared/Views/Helpers/ChatInfoImage.swift +++ b/apps/ios/Shared/Views/Helpers/ChatInfoImage.swift @@ -16,18 +16,10 @@ struct ChatInfoImage: View { var color = Color(uiColor: .tertiarySystemGroupedBackground) var body: some View { - var iconName: String - switch chat.chatInfo { - case .direct: iconName = "person.crop.circle.fill" - case .group: iconName = "person.2.circle.fill" - case .local: iconName = "folder.circle.fill" - case .contactRequest: iconName = "person.crop.circle.fill" - default: iconName = "circle.fill" - } let iconColor = if case .local = chat.chatInfo { theme.appColors.primaryVariant2 } else { color } return ProfileImage( imageStr: chat.chatInfo.image, - iconName: iconName, + iconName: chatIconName(chat.chatInfo), size: size, color: iconColor ) diff --git a/apps/ios/Shared/Views/Helpers/VideoUtils.swift b/apps/ios/Shared/Views/Helpers/VideoUtils.swift deleted file mode 100644 index e13893de6e..0000000000 --- a/apps/ios/Shared/Views/Helpers/VideoUtils.swift +++ /dev/null @@ -1,26 +0,0 @@ -// -// VideoUtils.swift -// SimpleX (iOS) -// -// Created by Avently on 25.12.2023. -// Copyright © 2023 SimpleX Chat. All rights reserved. -// - -import AVFoundation -import Foundation -import SimpleXChat - -func makeVideoQualityLower(_ input: URL, outputUrl: URL) async -> Bool { - let asset: AVURLAsset = AVURLAsset(url: input, options: nil) - if let s = AVAssetExportSession(asset: asset, presetName: AVAssetExportPreset640x480) { - s.outputURL = outputUrl - s.outputFileType = .mp4 - s.metadataItemFilter = AVMetadataItemFilter.forSharing() - await s.export() - if let err = s.error { - logger.error("Failed to export video with error: \(err)") - } - return s.status == .completed - } - return false -} diff --git a/apps/ios/Shared/Views/Migration/MigrateToDevice.swift b/apps/ios/Shared/Views/Migration/MigrateToDevice.swift index 107785e336..67ea1008cd 100644 --- a/apps/ios/Shared/Views/Migration/MigrateToDevice.swift +++ b/apps/ios/Shared/Views/Migration/MigrateToDevice.swift @@ -331,7 +331,7 @@ struct MigrateToDevice: View { case let .migrationError(mtrError): ("Incompatible database version", nil, - "\(NSLocalizedString("Error: ", comment: "")) \(DatabaseErrorView.mtrErrorDescription(mtrError))", + "\(NSLocalizedString("Error: ", comment: "")) \(mtrErrorDescription(mtrError))", nil) } default: ("Error", nil, "Unknown error", nil) diff --git a/apps/ios/Shared/Views/RemoteAccess/ConnectDesktopView.swift b/apps/ios/Shared/Views/RemoteAccess/ConnectDesktopView.swift index 30d200b6e3..be063334d3 100644 --- a/apps/ios/Shared/Views/RemoteAccess/ConnectDesktopView.swift +++ b/apps/ios/Shared/Views/RemoteAccess/ConnectDesktopView.swift @@ -37,7 +37,7 @@ struct ConnectDesktopView: View { case badInvitationError case badVersionError(version: String?) case desktopDisconnectedError - case error(title: LocalizedStringKey, error: LocalizedStringKey = "") + case error(title: LocalizedStringKey, error: LocalizedStringKey?) var id: String { switch self { @@ -160,7 +160,7 @@ struct ConnectDesktopView: View { case .desktopDisconnectedError: Alert(title: Text("Connection terminated")) case let .error(title, error): - Alert(title: Text(title), message: Text(error)) + mkAlert(title: title, message: error) } } .interactiveDismissDisabled(m.activeRemoteCtrl) diff --git a/apps/ios/Shared/Views/UserSettings/ProtocolServerView.swift b/apps/ios/Shared/Views/UserSettings/ProtocolServerView.swift index 6433168810..da29dfac29 100644 --- a/apps/ios/Shared/Views/UserSettings/ProtocolServerView.swift +++ b/apps/ios/Shared/Views/UserSettings/ProtocolServerView.swift @@ -175,10 +175,6 @@ func testServerConnection(server: Binding) async -> ProtocolTestFailu } } -func serverHostname(_ srv: String) -> String { - parseServerAddress(srv)?.hostnames.first ?? srv -} - struct ProtocolServerView_Previews: PreviewProvider { static var previews: some View { ProtocolServerView( diff --git a/apps/ios/Shared/Views/UserSettings/UserAddressView.swift b/apps/ios/Shared/Views/UserSettings/UserAddressView.swift index a22a10cd9c..fa95c51d36 100644 --- a/apps/ios/Shared/Views/UserSettings/UserAddressView.swift +++ b/apps/ios/Shared/Views/UserSettings/UserAddressView.swift @@ -30,7 +30,7 @@ struct UserAddressView: View { case deleteAddress case profileAddress(on: Bool) case shareOnCreate - case error(title: LocalizedStringKey, error: LocalizedStringKey = "") + case error(title: LocalizedStringKey, error: LocalizedStringKey?) var id: String { switch self { @@ -185,7 +185,7 @@ struct UserAddressView: View { }, secondaryButton: .cancel() ) case let .error(title, error): - return Alert(title: Text(title), message: Text(error)) + return mkAlert(title: title, message: error) } } } diff --git a/apps/ios/Shared/Views/UserSettings/UserProfilesView.swift b/apps/ios/Shared/Views/UserSettings/UserProfilesView.swift index 13b9b2b097..160130bccc 100644 --- a/apps/ios/Shared/Views/UserSettings/UserProfilesView.swift +++ b/apps/ios/Shared/Views/UserSettings/UserProfilesView.swift @@ -30,7 +30,7 @@ struct UserProfilesView: View { case hiddenProfilesNotice case muteProfileAlert case activateUserError(error: String) - case error(title: LocalizedStringKey, error: LocalizedStringKey = "") + case error(title: LocalizedStringKey, error: LocalizedStringKey?) var id: String { switch self { @@ -172,7 +172,7 @@ struct UserProfilesView: View { message: Text(err) ) case let .error(title, error): - return Alert(title: Text(title), message: Text(error)) + return mkAlert(title: title, message: error) } } } diff --git a/apps/ios/SimpleX NSE/NotificationService.swift b/apps/ios/SimpleX NSE/NotificationService.swift index 30c74fa120..1a2a27ba9b 100644 --- a/apps/ios/SimpleX NSE/NotificationService.swift +++ b/apps/ios/SimpleX NSE/NotificationService.swift @@ -390,6 +390,16 @@ func appStateSubscriber(onState: @escaping (AppState) -> Void) -> AppSubscriber } } +let seSubscriber = seMessageSubscriber { + switch $0 { + case let .state(state): + if state == .sendingMessage && NSEChatState.shared.value.canSuspend { + logger.debug("NotificationService: seSubscriber app state \(state.rawValue), suspending") + suspendChat(fastNSESuspendSchedule.timeout) + } + } +} + var receiverStarted = false let startLock = DispatchSemaphore(value: 1) let suspendLock = DispatchSemaphore(value: 1) diff --git a/apps/ios/SimpleX SE/Info.plist b/apps/ios/SimpleX SE/Info.plist new file mode 100644 index 0000000000..2ce1f45040 --- /dev/null +++ b/apps/ios/SimpleX SE/Info.plist @@ -0,0 +1,35 @@ + + + + + NSExtension + + NSExtensionAttributes + + NSExtensionActivationRule + + NSExtensionActivationSupportsText + + NSExtensionActivationSupportsAttachmentsWithMinCount + 0 + NSExtensionActivationSupportsAttachmentsWithMaxCount + 1 + NSExtensionActivationSupportsWebPageWithMaxCount + 1 + NSExtensionActivationSupportsWebURLWithMaxCount + 1 + NSExtensionActivationSupportsFileWithMaxCount + 1 + NSExtensionActivationSupportsImageWithMaxCount + 1 + NSExtensionActivationSupportsMovieWithMaxCount + 1 + + + NSExtensionPointIdentifier + com.apple.share-services + NSExtensionPrincipalClass + ShareViewController + + + diff --git a/apps/ios/SimpleX SE/SEChatState.swift b/apps/ios/SimpleX SE/SEChatState.swift new file mode 100644 index 0000000000..581bff894a --- /dev/null +++ b/apps/ios/SimpleX SE/SEChatState.swift @@ -0,0 +1,39 @@ +// +// SEChatState.swift +// SimpleX SE +// +// Created by User on 18/07/2024. +// Copyright © 2024 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +// SEStateGroupDefault must not be used in the share extension directly, only via this singleton +class SEChatState { + static let shared = SEChatState() + private var value_ = seStateGroupDefault.get() + + var value: SEState { + value_ + } + + func set(_ state: SEState) { + seStateGroupDefault.set(state) + sendSEState(state) + value_ = state + } +} + +/// Waits for other processes to set their state to suspended +/// Will wait for maximum of two seconds, since they might not be running +func waitForOtherProcessesToSuspend() async { + let startTime = CFAbsoluteTimeGetCurrent() + while CFAbsoluteTimeGetCurrent() - startTime < 2 { + try? await Task.sleep(nanoseconds: 100 * NSEC_PER_MSEC) + if appStateGroupDefault.get() == .suspended && + nseStateGroupDefault.get() == .suspended { + break + } + } +} diff --git a/apps/ios/SimpleX SE/ShareAPI.swift b/apps/ios/SimpleX SE/ShareAPI.swift new file mode 100644 index 0000000000..47e072ae78 --- /dev/null +++ b/apps/ios/SimpleX SE/ShareAPI.swift @@ -0,0 +1,115 @@ +// +// ShareAPI.swift +// SimpleX SE +// +// Created by User on 15/07/2024. +// Copyright © 2024 SimpleX Chat. All rights reserved. +// + +import OSLog +import Foundation +import SimpleXChat + +let logger = Logger() + +func apiGetActiveUser() throws -> User? { + let r = sendSimpleXCmd(.showActiveUser) + switch r { + case let .activeUser(user): return user + case .chatCmdError(_, .error(.noActiveUser)): return nil + default: throw r + } +} + +func apiStartChat() throws -> Bool { + let r = sendSimpleXCmd(.startChat(mainApp: false, enableSndFiles: true)) + switch r { + case .chatStarted: return true + case .chatRunning: return false + default: throw r + } +} + +func apiSetNetworkConfig(_ cfg: NetCfg) throws { + let r = sendSimpleXCmd(.apiSetNetworkConfig(networkConfig: cfg)) + if case .cmdOk = r { return } + throw r +} + +func apiSetAppFilePaths(filesFolder: String, tempFolder: String, assetsFolder: String) throws { + let r = sendSimpleXCmd(.apiSetAppFilePaths(filesFolder: filesFolder, tempFolder: tempFolder, assetsFolder: assetsFolder)) + if case .cmdOk = r { return } + throw r +} + +func apiSetEncryptLocalFiles(_ enable: Bool) throws { + let r = sendSimpleXCmd(.apiSetEncryptLocalFiles(enable: enable)) + if case .cmdOk = r { return } + throw r +} + +func apiGetChats(userId: User.ID) throws -> Array { + let r = sendSimpleXCmd(.apiGetChats(userId: userId)) + if case let .apiChats(user: _, chats: chats) = r { return chats } + throw r +} + +func apiSendMessage( + chatInfo: ChatInfo, + cryptoFile: CryptoFile?, + msgContent: MsgContent +) throws -> AChatItem { + let r = sendSimpleXCmd( + chatInfo.chatType == .local + ? .apiCreateChatItem( + noteFolderId: chatInfo.apiId, + file: cryptoFile, + msg: msgContent + ) + : .apiSendMessage( + type: chatInfo.chatType, + id: chatInfo.apiId, + file: cryptoFile, + quotedItemId: nil, + msg: msgContent, + live: false, + ttl: nil + ) + ) + if case let .newChatItem(_, chatItem) = r { + return chatItem + } else { + if let filePath = cryptoFile?.filePath { removeFile(filePath) } + throw r + } +} + +func apiActivateChat() throws { + chatReopenStore() + let r = sendSimpleXCmd(.apiActivateChat(restoreChat: false)) + if case .cmdOk = r { return } + throw r +} + +func apiSuspendChat(expired: Bool) { + let r = sendSimpleXCmd(.apiSuspendChat(timeoutMicroseconds: expired ? 0 : 3_000000)) + // Block until `chatSuspended` received or 3 seconds has passed + var suspended = false + if case .cmdOk = r, !expired { + let startTime = CFAbsoluteTimeGetCurrent() + while CFAbsoluteTimeGetCurrent() - startTime < 3 { + switch recvSimpleXMsg(messageTimeout: 3_500000) { + case .chatSuspended: + suspended = false + break + default: continue + } + } + } + if !suspended { + _ = sendSimpleXCmd(.apiSuspendChat(timeoutMicroseconds: 0)) + } + logger.debug("close store") + chatCloseStore() + SEChatState.shared.set(.inactive) +} diff --git a/apps/ios/SimpleX SE/ShareModel.swift b/apps/ios/SimpleX SE/ShareModel.swift new file mode 100644 index 0000000000..35d26dea35 --- /dev/null +++ b/apps/ios/SimpleX SE/ShareModel.swift @@ -0,0 +1,500 @@ +// +// ShareModel.swift +// SimpleX SE +// +// Created by Levitating Pineapple on 09/07/2024. +// Copyright © 2024 SimpleX Chat. All rights reserved. +// + +import UniformTypeIdentifiers +import AVFoundation +import SwiftUI +import SimpleXChat + +/// Maximum size of hex encoded media previews +private let MAX_DATA_SIZE: Int64 = 14000 + +/// Maximum dimension (width or height) of an image, before passed for processing +private let MAX_DOWNSAMPLE_SIZE: Int64 = 2000 + +class ShareModel: ObservableObject { + @Published var sharedContent: SharedContent? + @Published var chats = Array() + @Published var profileImages = Dictionary() + @Published var search = String() + @Published var comment = String() + @Published var selected: ChatData? + @Published var isLoaded = false + @Published var bottomBar: BottomBar = .loadingSpinner + @Published var errorAlert: ErrorAlert? + + enum BottomBar { + case sendButton + case loadingSpinner + case loadingBar(progress: Double) + + var isLoading: Bool { + switch self { + case .sendButton: false + case .loadingSpinner: true + case .loadingBar: true + } + } + } + + var completion: () -> Void = { + fatalError("completion has not been set") + } + + private var itemProvider: NSItemProvider? + + var isSendDisbled: Bool { sharedContent == nil || selected == nil } + + var filteredChats: Array { + search.isEmpty + ? filterChatsToForwardTo(chats: chats) + : filterChatsToForwardTo(chats: chats) + .filter { foundChat($0, search.localizedLowercase) } + } + + func setup(context: NSExtensionContext) { + if let item = context.inputItems.first as? NSExtensionItem, + let itemProvider = item.attachments?.first { + self.itemProvider = itemProvider + self.completion = { + ShareModel.CompletionHandler.isEventLoopEnabled = false + context.completeRequest(returningItems: [item]) { + apiSuspendChat(expired: $0) + } + } + // Init Chat + Task { + if let e = initChat() { + await MainActor.run { errorAlert = e } + } else { + // Load Chats + Task { + switch fetchChats() { + case let .success(chats): + // Decode base64 images on background thread + let profileImages = chats.reduce(into: Dictionary()) { dict, chatData in + if let profileImage = chatData.chatInfo.image, + let uiImage = UIImage(base64Encoded: profileImage) { + dict[chatData.id] = uiImage + } + } + await MainActor.run { + self.chats = chats + self.profileImages = profileImages + withAnimation { isLoaded = true } + } + case let .failure(error): + await MainActor.run { errorAlert = error } + } + } + // Process Attachment + Task { + switch await self.itemProvider!.sharedContent() { + case let .success(chatItemContent): + await MainActor.run { + self.sharedContent = chatItemContent + self.bottomBar = .sendButton + if case let .text(string) = chatItemContent { comment = string } + } + case let .failure(errorAlert): + await MainActor.run { self.errorAlert = errorAlert } + } + } + } + } + } + } + + func send() { + if let sharedContent, let selected { + Task { + await MainActor.run { self.bottomBar = .loadingSpinner } + do { + SEChatState.shared.set(.sendingMessage) + await waitForOtherProcessesToSuspend() + let ci = try apiSendMessage( + chatInfo: selected.chatInfo, + cryptoFile: sharedContent.cryptoFile, + msgContent: sharedContent.msgContent(comment: self.comment) + ) + if selected.chatInfo.chatType == .local { + completion() + } else { + await MainActor.run { self.bottomBar = .loadingBar(progress: .zero) } + if let e = await handleEvents( + isGroupChat: ci.chatInfo.chatType == .group, + isWithoutFile: sharedContent.cryptoFile == nil, + chatItemId: ci.chatItem.id + ) { + await MainActor.run { errorAlert = e } + } else { + completion() + } + } + } catch { + if let e = error as? ErrorAlert { + await MainActor.run { errorAlert = e } + } + } + } + } + } + + private func initChat() -> ErrorAlert? { + do { + if hasChatCtrl() { + try apiActivateChat() + } else { + registerGroupDefaults() + haskell_init_se() + let (_, result) = chatMigrateInit(confirmMigrations: defaultMigrationConfirmation()) + if let e = migrationError(result) { return e } + try apiSetAppFilePaths( + filesFolder: getAppFilesDirectory().path, + tempFolder: getTempFilesDirectory().path, + assetsFolder: getWallpaperDirectory().deletingLastPathComponent().path + ) + let isRunning = try apiStartChat() + logger.log(level: .debug, "chat started, running: \(isRunning)") + } + try apiSetNetworkConfig(getNetCfg()) + try apiSetEncryptLocalFiles(privacyEncryptLocalFilesGroupDefault.get()) + } catch { return ErrorAlert(error) } + return nil + } + + private func migrationError(_ r: DBMigrationResult) -> ErrorAlert? { + let useKeychain = storeDBPassphraseGroupDefault.get() + let storedDBKey = kcDatabasePassword.get() + // This switch duplicates DatabaseErrorView. + // TODO allow entering passphrase and make messages the same as in DatabaseErrorView. + return switch r { + case .errorNotADatabase: + if useKeychain && storedDBKey != nil && storedDBKey != "" { + ErrorAlert( + title: "Wrong database passphrase", + message: "Database passphrase is different from saved in the keychain." + ) + } else { + ErrorAlert( + title: "Encrypted database", + message: "Sharing is not supported when passphrase is not stored in KeyChain." + ) + } + case let .errorMigration(_, migrationError): + switch migrationError { + case .upgrade: + ErrorAlert( + title: "Database upgrade required", + message: "Open the app to upgrade the database." + ) + case .downgrade: + ErrorAlert( + title: "Database downgrade required", + message: "Open the app to downgrade the database." + ) + case let .migrationError(mtrError): + ErrorAlert( + title: "Incompatible database version", + message: mtrErrorDescription(mtrError) + ) + } + case let .errorSQL(_, migrationSQLError): + ErrorAlert( + title: "Database error", + message: "Error: \(migrationSQLError)" + ) + case .errorKeychain: + ErrorAlert( + title: "Keychain error", + message: "Cannot access keychain to save database password" + ) + case .invalidConfirmation: + ErrorAlert("Invalid migration confirmation") + case let .unknown(json): + ErrorAlert( + title: "Database error", + message: "Unknown database error: \(json)" + ) + case .ok: nil + } + } + + private func fetchChats() -> Result, ErrorAlert> { + do { + guard let user = try apiGetActiveUser() else { + return .failure( + ErrorAlert( + title: "No active profile", + message: "Please create a profile in the SimpleX app" + ) + ) + } + return .success(try apiGetChats(userId: user.id)) + } catch { + return .failure(ErrorAlert(error)) + } + } + + actor CompletionHandler { + static var isEventLoopEnabled = false + private var fileCompleted = false + private var messageCompleted = false + + func completeFile() { fileCompleted = true } + + func completeMessage() { messageCompleted = true } + + var isRunning: Bool { + Self.isEventLoopEnabled && !(fileCompleted && messageCompleted) + } + } + + /// Polls and processes chat events + /// Returns when message sending has completed optionally returning and error. + private func handleEvents(isGroupChat: Bool, isWithoutFile: Bool, chatItemId: ChatItem.ID) async -> ErrorAlert? { + func isMessage(for item: AChatItem?) -> Bool { + item.map { $0.chatItem.id == chatItemId } ?? false + } + + CompletionHandler.isEventLoopEnabled = true + let ch = CompletionHandler() + if isWithoutFile { await ch.completeFile() } + var networkTimeout = CFAbsoluteTimeGetCurrent() + while await ch.isRunning { + if CFAbsoluteTimeGetCurrent() - networkTimeout > 30 { + networkTimeout = CFAbsoluteTimeGetCurrent() + await MainActor.run { + self.errorAlert = ErrorAlert(title: "No network connection") { + Button("Keep Trying", role: .cancel) { } + Button("Dismiss Sheet", role: .destructive) { self.completion() } + } + } + } + switch recvSimpleXMsg(messageTimeout: 1_000_000) { + case let .sndFileProgressXFTP(_, ci, _, sentSize, totalSize): + guard isMessage(for: ci) else { continue } + networkTimeout = CFAbsoluteTimeGetCurrent() + await MainActor.run { + withAnimation { + let progress = Double(sentSize) / Double(totalSize) + bottomBar = .loadingBar(progress: progress) + } + } + case let .sndFileCompleteXFTP(_, ci, _): + guard isMessage(for: ci) else { continue } + if isGroupChat { + await MainActor.run { bottomBar = .loadingSpinner } + } + await ch.completeFile() + if await !ch.isRunning { break } + case let .chatItemStatusUpdated(_, ci): + guard isMessage(for: ci) else { continue } + if let (title, message) = ci.chatItem.meta.itemStatus.statusInfo { + // `title` and `message` already localized and interpolated + return ErrorAlert( + title: "\(title)", + message: "\(message)" + ) + } else if case let .sndSent(sndProgress) = ci.chatItem.meta.itemStatus { + switch sndProgress { + case .complete: + await ch.completeMessage() + case .partial: + if isGroupChat { + Task { + try? await Task.sleep(nanoseconds: 5 * NSEC_PER_SEC) + await ch.completeMessage() + } + } + } + } + case let .sndFileError(_, ci, _, errorMessage): + guard isMessage(for: ci) else { continue } + if let ci { cleanupFile(ci) } + return ErrorAlert(title: "File error", message: "\(fileErrorInfo(ci) ?? errorMessage)") + case let .sndFileWarning(_, ci, _, errorMessage): + guard isMessage(for: ci) else { continue } + if let ci { cleanupFile(ci) } + return ErrorAlert(title: "File error", message: "\(fileErrorInfo(ci) ?? errorMessage)") + case let .chatError(_, chatError): + return ErrorAlert(chatError) + case let .chatCmdError(_, chatError): + return ErrorAlert(chatError) + default: continue + } + } + return nil + } + + private func fileErrorInfo(_ ci: AChatItem?) -> String? { + switch ci?.chatItem.file?.fileStatus { + case let .sndError(e): e.errorInfo + case let .sndWarning(e): e.errorInfo + default: nil + } + } +} + +/// Chat Item Content extracted from `NSItemProvider` without the comment +enum SharedContent { + case image(preview: String, cryptoFile: CryptoFile) + case movie(preview: String, duration: Int, cryptoFile: CryptoFile) + case url(preview: LinkPreview) + case text(string: String) + case data(cryptoFile: CryptoFile) + + var cryptoFile: CryptoFile? { + switch self { + case let .image(_, cryptoFile): cryptoFile + case let .movie(_, _, cryptoFile): cryptoFile + case .url: nil + case .text: nil + case let .data(cryptoFile): cryptoFile + } + } + + func msgContent(comment: String) -> MsgContent { + switch self { + case let .image(preview, _): .image(text: comment, image: preview) + case let .movie(preview, duration, _): .video(text: comment, image: preview, duration: duration) + case let .url(preview): .link(text: comment, preview: preview) + case .text: .text(comment) + case .data: .file(comment) + } + } +} + +extension NSItemProvider { + fileprivate func sharedContent() async -> Result { + if let type = firstMatching(of: [.image, .movie, .fileURL, .url, .text]) { + switch type { + // Prepare Image message + case .image: + + // Animated + return if hasItemConformingToTypeIdentifier(UTType.gif.identifier) { + if let url = try? await inPlaceUrl(type: type), + let data = try? Data(contentsOf: url), + let image = UIImage(data: data), + let cryptoFile = saveFile(data, generateNewFileName("IMG", "gif"), encrypted: privacyEncryptLocalFilesGroupDefault.get()), + let preview = resizeImageToStrSize(image, maxDataSize: MAX_DATA_SIZE) { + .success(.image(preview: preview, cryptoFile: cryptoFile)) + } else { .failure(ErrorAlert("Error preparing message")) } + + // Static + } else { + if let url = try? await inPlaceUrl(type: type), + let image = downsampleImage(at: url, to: MAX_DOWNSAMPLE_SIZE), + let cryptoFile = saveImage(image), + let preview = resizeImageToStrSize(image, maxDataSize: MAX_DATA_SIZE) { + .success(.image(preview: preview, cryptoFile: cryptoFile)) + } else { .failure(ErrorAlert("Error preparing message")) } + } + + // Prepare Movie message + case .movie: + if let url = try? await inPlaceUrl(type: type), + let trancodedUrl = await transcodeVideo(from: url), + let (image, duration) = AVAsset(url: trancodedUrl).generatePreview(), + let preview = resizeImageToStrSize(image, maxDataSize: MAX_DATA_SIZE), + let cryptoFile = moveTempFileFromURL(trancodedUrl) { + try? FileManager.default.removeItem(at: trancodedUrl) + return .success(.movie(preview: preview, duration: duration, cryptoFile: cryptoFile)) + } else { return .failure(ErrorAlert("Error preparing message")) } + + // Prepare Data message + case .fileURL: + if let url = try? await inPlaceUrl(type: .data) { + if isFileTooLarge(for: url) { + let sizeString = ByteCountFormatter.string( + fromByteCount: Int64(getMaxFileSize(.xftp)), + countStyle: .binary + ) + return .failure( + ErrorAlert( + title: "Large file!", + message: "Currently maximum supported file size is \(sizeString)." + ) + ) + } + if let file = saveFileFromURL(url) { + return .success(.data(cryptoFile: file)) + } + } + return .failure(ErrorAlert("Error preparing file")) + + // Prepare Link message + case .url: + if let url = try? await loadItem(forTypeIdentifier: type.identifier) as? URL { + let content: SharedContent = +// Option to disable previews needs to be taken into account +// if let linkPreview = await getLinkPreview(for: url) { +// .url(preview: linkPreview) +// } else { + .text(string: url.absoluteString) +// } + return .success(content) + } else { return .failure(ErrorAlert("Error preparing message")) } + + // Prepare Text message + case .text: + return if let text = try? await loadItem(forTypeIdentifier: type.identifier) as? String { + .success(.text(string: text)) + } else { .failure(ErrorAlert("Error preparing message")) } + default: return .failure(ErrorAlert("Unsupported format")) + } + } else { + return .failure(ErrorAlert("Unsupported format")) + } + } + + private func inPlaceUrl(type: UTType) async throws -> URL { + try await withCheckedThrowingContinuation { cont in + let _ = loadInPlaceFileRepresentation(forTypeIdentifier: type.identifier) { url, bool, error in + if let url = url { + cont.resume(returning: url) + } else if let error = error { + cont.resume(throwing: error) + } else { + fatalError("Either `url` or `error` must be present") + } + } + } + } + + private func firstMatching(of types: Array) -> UTType? { + for type in types { + if hasItemConformingToTypeIdentifier(type.identifier) { return type } + } + return nil + } +} + + +fileprivate func transcodeVideo(from input: URL) async -> URL? { + let outputUrl = URL( + fileURLWithPath: generateNewFileName( + getTempFilesDirectory().path + "/" + "video", "mp4", + fullPath: true + ) + ) + if await makeVideoQualityLower(input, outputUrl: outputUrl) { + return outputUrl + } else { + try? FileManager.default.removeItem(at: outputUrl) + return nil + } +} + +fileprivate func isFileTooLarge(for url: URL) -> Bool { + fileSize(url) + .map { $0 > getMaxFileSize(.xftp) } + ?? false +} + diff --git a/apps/ios/SimpleX SE/ShareView.swift b/apps/ios/SimpleX SE/ShareView.swift new file mode 100644 index 0000000000..20e6450b99 --- /dev/null +++ b/apps/ios/SimpleX SE/ShareView.swift @@ -0,0 +1,180 @@ +// +// ShareView.swift +// SimpleX SE +// +// Created by Levitating Pineapple on 09/07/2024. +// Copyright © 2024 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +struct ShareView: View { + @ObservedObject var model: ShareModel + @Environment(\.colorScheme) var colorScheme + + var body: some View { + NavigationView { + ZStack(alignment: .bottom) { + if model.isLoaded { + List(model.filteredChats) { chat in + HStack { + profileImage( + chatInfoId: chat.chatInfo.id, + systemFallback: chatIconName(chat.chatInfo), + size: 30 + ) + Text(chat.chatInfo.displayName) + Spacer() + radioButton(selected: chat == model.selected) + } + .contentShape(Rectangle()) + .onTapGesture { model.selected = model.selected == chat ? nil : chat } + .tag(chat) + } + } else { + ProgressView().frame(maxHeight: .infinity) + } + } + .navigationTitle("Share") + .safeAreaInset(edge: .bottom) { + switch model.bottomBar { + case .sendButton: + compose(isLoading: false) + case .loadingSpinner: + compose(isLoading: true) + case .loadingBar(let progress): + loadingBar(progress: progress) + } + } + } + .searchable( + text: $model.search, + placement: .navigationBarDrawer(displayMode: .always) + ) + .alert($model.errorAlert) { alert in + Button("Ok") { model.completion() } + } + } + + private func compose(isLoading: Bool) -> some View { + VStack(spacing: .zero) { + Divider() + if let content = model.sharedContent { + itemPreview(content) + } + HStack { + Group { + if #available(iOSApplicationExtension 16.0, *) { + TextField("Comment", text: $model.comment, axis: .vertical) + } else { + TextField("Comment", text: $model.comment) + } + } + .contentShape(Rectangle()) + .disabled(isLoading) + .padding(.horizontal, 12) + .padding(.vertical, 4) + Group { + if isLoading { + ProgressView() + } else { + Button(action: model.send) { + Image(systemName: "arrow.up.circle.fill") + .resizable() + } + .disabled(model.isSendDisbled) + } + } + .frame(width: 28, height: 28) + .padding(6) + + } + .background(Color(.systemBackground)) + .clipShape(RoundedRectangle(cornerRadius: 20)) + .overlay( + RoundedRectangle(cornerRadius: 20) + .strokeBorder(.secondary, lineWidth: 0.5).opacity(0.7) + ) + .padding(8) + } + .background(.thinMaterial) + } + + @ViewBuilder private func itemPreview(_ content: SharedContent) -> some View { + switch content { + case let .image(preview, _): imagePreview(preview) + case let .movie(preview, _, _): imagePreview(preview) + case let .url(linkPreview): imagePreview(linkPreview.image) + case let .data(cryptoFile): + previewArea { + Image(systemName: "doc.fill") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 30, height: 30) + .foregroundColor(Color(uiColor: .tertiaryLabel)) + .padding(.leading, 4) + Text(cryptoFile.filePath) + } + case .text: EmptyView() + } + } + + @ViewBuilder private func imagePreview(_ img: String) -> some View { + if let img = UIImage(base64Encoded: img) { + previewArea { + Image(uiImage: img) + .resizable() + .scaledToFit() + .frame(minHeight: 40, maxHeight: 60) + } + } else { + EmptyView() + } + } + + @ViewBuilder private func previewArea(@ViewBuilder content: @escaping () -> V) -> some View { + HStack(alignment: .center, spacing: 8) { + content() + Spacer() + } + .padding(.vertical, 1) + .frame(minHeight: 54) + .background { + switch colorScheme { + case .light: LightColorPaletteApp.sentMessage + case .dark: DarkColorPaletteApp.sentMessage + @unknown default: Color(.tertiarySystemBackground) + } + } + Divider() + } + + private func loadingBar(progress: Double) -> some View { + VStack { + Text("Sending File") + ProgressView(value: progress) + } + .padding() + .background(Material.ultraThin) + } + + private func profileImage(chatInfoId: ChatInfo.ID, systemFallback: String, size: Double) -> some View { + Group { + if let uiImage = model.profileImages[chatInfoId] { + Image(uiImage: uiImage).resizable() + } else { + Image(systemName: systemFallback).resizable() + } + } + .foregroundStyle(Color(.tertiaryLabel)) + .frame(width: size, height: size) + .clipShape(RoundedRectangle(cornerRadius: size * 0.225, style: .continuous)) + } + + private func radioButton(selected: Bool) -> some View { + Image(systemName: selected ? "checkmark.circle.fill" : "circle") + .imageScale(.large) + .foregroundStyle(selected ? Color.accentColor : Color(.tertiaryLabel)) + } +} diff --git a/apps/ios/SimpleX SE/ShareViewController.swift b/apps/ios/SimpleX SE/ShareViewController.swift new file mode 100644 index 0000000000..bf22f44a3b --- /dev/null +++ b/apps/ios/SimpleX SE/ShareViewController.swift @@ -0,0 +1,46 @@ +// +// ShareViewController.swift +// SimpleX SE +// +// Created by Levitating Pineapple on 08/07/2024. +// Copyright © 2024 SimpleX Chat. All rights reserved. +// + +import UIKit +import SwiftUI +import SimpleXChat + +/// Extension Entry point +/// System will create this controller each time share sheet is invoked +/// using `NSExtensionPrincipalClass` in the info.plist +@objc(ShareViewController) +class ShareViewController: UIHostingController { + private let model = ShareModel() + // Assuming iOS continues to only allow single share sheet to be presented at once + static var isVisible: Bool = false + + @objc init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) { + super.init(rootView: ShareView(model: model)) + } + + @available(*, unavailable) + required init?(coder aDecoder: NSCoder) { fatalError() } + + override func viewDidLoad() { + ShareModel.CompletionHandler.isEventLoopEnabled = false + model.setup(context: extensionContext!) + } + + override func viewWillAppear(_ animated: Bool) { + logger.debug("ShareSheet will appear") + super.viewWillAppear(animated) + Self.isVisible = true + } + + override func viewWillDisappear(_ animated: Bool) { + logger.debug("ShareSheet will dissappear") + super.viewWillDisappear(animated) + ShareModel.CompletionHandler.isEventLoopEnabled = false + Self.isVisible = false + } +} diff --git a/apps/ios/SimpleX SE/SimpleX SE.entitlements b/apps/ios/SimpleX SE/SimpleX SE.entitlements new file mode 100644 index 0000000000..51dea2c806 --- /dev/null +++ b/apps/ios/SimpleX SE/SimpleX SE.entitlements @@ -0,0 +1,14 @@ + + + + + com.apple.security.application-groups + + group.chat.simplex.app + + keychain-access-groups + + $(AppIdentifierPrefix)chat.simplex.app + + + diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 0ec33d9899..d72338a4cb 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -183,7 +183,6 @@ 64E972072881BB22008DBC02 /* CIGroupInvitationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64E972062881BB22008DBC02 /* CIGroupInvitationView.swift */; }; 64EEB0F72C353F1C00972D62 /* ServersSummaryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64EEB0F62C353F1C00972D62 /* ServersSummaryView.swift */; }; 64F1CC3B28B39D8600CD1FB1 /* IncognitoHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64F1CC3A28B39D8600CD1FB1 /* IncognitoHelp.swift */; }; - 8C05382E2B39887E006436DC /* VideoUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C05382D2B39887E006436DC /* VideoUtils.swift */; }; 8C69FE7D2B8C7D2700267E38 /* AppSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C69FE7C2B8C7D2700267E38 /* AppSettings.swift */; }; 8C74C3E52C1B900600039E77 /* ThemeTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C7E3CE32C0DEAC400BFF63A /* ThemeTypes.swift */; }; 8C74C3E72C1B901900039E77 /* Color.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C852B072C1086D100BA61E8 /* Color.swift */; }; @@ -199,9 +198,18 @@ 8C9BC2652C240D5200875A27 /* ThemeModeEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C9BC2642C240D5100875A27 /* ThemeModeEditor.swift */; }; 8CC4ED902BD7B8530078AEE8 /* CallAudioDeviceManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CC4ED8F2BD7B8530078AEE8 /* CallAudioDeviceManager.swift */; }; 8CC956EE2BC0041000412A11 /* NetworkObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CC956ED2BC0041000412A11 /* NetworkObserver.swift */; }; + CE1EB0E42C459A660099D896 /* ShareAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE1EB0E32C459A660099D896 /* ShareAPI.swift */; }; + CE2AD9CE2C452A4D00E844E3 /* ChatUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE2AD9CD2C452A4D00E844E3 /* ChatUtils.swift */; }; + CE3097FB2C4C0C9F00180898 /* ErrorAlert.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE3097FA2C4C0C9F00180898 /* ErrorAlert.swift */; }; CE38A29A2C3FCA54005ED185 /* ImageUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CBD2859295711D700EC2CF4 /* ImageUtils.swift */; }; CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */ = {isa = PBXBuildFile; productRef = CE38A29B2C3FCD72005ED185 /* SwiftyGif */; }; CE984D4B2C36C5D500E3AEFF /* ChatItemClipShape.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE984D4A2C36C5D500E3AEFF /* ChatItemClipShape.swift */; }; + CEDE70222C48FD9500233B1F /* SEChatState.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEDE70212C48FD9500233B1F /* SEChatState.swift */; }; + CEE723AA2C3BD3D70009AE93 /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEE723A92C3BD3D70009AE93 /* ShareViewController.swift */; }; + CEE723B12C3BD3D70009AE93 /* SimpleX SE.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = CEE723A72C3BD3D70009AE93 /* SimpleX SE.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + CEE723D02C3C21C90009AE93 /* SimpleXChat.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 5CE2BA682845308900EC33A6 /* SimpleXChat.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + CEE723F02C3D25C70009AE93 /* ShareView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEE723EF2C3D25C70009AE93 /* ShareView.swift */; }; + CEE723F22C3D25ED0009AE93 /* ShareModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEE723F12C3D25ED0009AE93 /* ShareModel.swift */; }; CEEA861D2C2ABCB50084E1EA /* ReverseList.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEEA861C2C2ABCB50084E1EA /* ReverseList.swift */; }; D7197A1829AE89660055C05A /* WebRTC in Frameworks */ = {isa = PBXBuildFile; productRef = D7197A1729AE89660055C05A /* WebRTC */; }; D72A9088294BD7A70047C86D /* NativeTextEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72A9087294BD7A70047C86D /* NativeTextEditor.swift */; }; @@ -241,6 +249,20 @@ remoteGlobalIDString = 5CE2BA672845308900EC33A6; remoteInfo = SimpleXChat; }; + CEE723AF2C3BD3D70009AE93 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 5CA059BE279559F40002BEB4 /* Project object */; + proxyType = 1; + remoteGlobalIDString = CEE723A62C3BD3D70009AE93; + remoteInfo = "SimpleX SE"; + }; + CEE723D12C3C21C90009AE93 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 5CA059BE279559F40002BEB4 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 5CE2BA672845308900EC33A6; + remoteInfo = SimpleXChat; + }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -261,11 +283,23 @@ dstPath = ""; dstSubfolderSpec = 13; files = ( + CEE723B12C3BD3D70009AE93 /* SimpleX SE.appex in Embed App Extensions */, 5CE2BA9D284555F500EC33A6 /* SimpleX NSE.appex in Embed App Extensions */, ); name = "Embed App Extensions"; runOnlyForDeploymentPostprocessing = 0; }; + CEE723D32C3C21C90009AE93 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + CEE723D02C3C21C90009AE93 /* SimpleXChat.framework in Embed Frameworks */, + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ @@ -494,7 +528,6 @@ 64E972062881BB22008DBC02 /* CIGroupInvitationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIGroupInvitationView.swift; sourceTree = ""; }; 64EEB0F62C353F1C00972D62 /* ServersSummaryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServersSummaryView.swift; sourceTree = ""; }; 64F1CC3A28B39D8600CD1FB1 /* IncognitoHelp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IncognitoHelp.swift; sourceTree = ""; }; - 8C05382D2B39887E006436DC /* VideoUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoUtils.swift; sourceTree = ""; }; 8C69FE7C2B8C7D2700267E38 /* AppSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettings.swift; sourceTree = ""; }; 8C74C3EB2C1B92A900039E77 /* Theme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Theme.swift; sourceTree = ""; }; 8C74C3ED2C1B942300039E77 /* ChatWallpaper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatWallpaper.swift; sourceTree = ""; }; @@ -509,7 +542,17 @@ 8C9BC2642C240D5100875A27 /* ThemeModeEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeModeEditor.swift; sourceTree = ""; }; 8CC4ED8F2BD7B8530078AEE8 /* CallAudioDeviceManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallAudioDeviceManager.swift; sourceTree = ""; }; 8CC956ED2BC0041000412A11 /* NetworkObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkObserver.swift; sourceTree = ""; }; + CE1EB0E32C459A660099D896 /* ShareAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareAPI.swift; sourceTree = ""; }; + CE2AD9CD2C452A4D00E844E3 /* ChatUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatUtils.swift; sourceTree = ""; }; + CE3097FA2C4C0C9F00180898 /* ErrorAlert.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorAlert.swift; sourceTree = ""; }; CE984D4A2C36C5D500E3AEFF /* ChatItemClipShape.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemClipShape.swift; sourceTree = ""; }; + CEDE70212C48FD9500233B1F /* SEChatState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SEChatState.swift; sourceTree = ""; }; + CEE723A72C3BD3D70009AE93 /* SimpleX SE.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "SimpleX SE.appex"; sourceTree = BUILT_PRODUCTS_DIR; }; + CEE723A92C3BD3D70009AE93 /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = ""; }; + CEE723AE2C3BD3D70009AE93 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + CEE723D42C3C21F50009AE93 /* SimpleX SE.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "SimpleX SE.entitlements"; sourceTree = ""; }; + CEE723EF2C3D25C70009AE93 /* ShareView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareView.swift; sourceTree = ""; }; + CEE723F12C3D25ED0009AE93 /* ShareModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareModel.swift; sourceTree = ""; }; CEEA861C2C2ABCB50084E1EA /* ReverseList.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReverseList.swift; sourceTree = ""; }; D72A9087294BD7A70047C86D /* NativeTextEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeTextEditor.swift; sourceTree = ""; }; D741547729AF89AF0022400A /* StoreKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = StoreKit.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS16.1.sdk/System/Library/Frameworks/StoreKit.framework; sourceTree = DEVELOPER_DIR; }; @@ -684,7 +727,6 @@ 64466DCB29FFE3E800E3D48D /* MailView.swift */, 64C3B0202A0D359700E19930 /* CustomTimePicker.swift */, 5CEBD7452A5C0A8F00665FE2 /* KeyboardPadding.swift */, - 8C05382D2B39887E006436DC /* VideoUtils.swift */, 8C7F8F0D2C19C0C100D16888 /* ViewModifiers.swift */, 8C74C3ED2C1B942300039E77 /* ChatWallpaper.swift */, 8C9BC2642C240D5100875A27 /* ThemeModeEditor.swift */, @@ -703,6 +745,7 @@ 5C764E5C279C70B7000C6508 /* Libraries */, 5CA059C2279559F40002BEB4 /* Shared */, 5CDCAD462818589900503DA2 /* SimpleX NSE */, + CEE723A82C3BD3D70009AE93 /* SimpleX SE */, 5CA059DA279559F40002BEB4 /* Tests iOS */, 5CE2BA692845308900EC33A6 /* SimpleXChat */, 5CA059CB279559F40002BEB4 /* Products */, @@ -733,6 +776,7 @@ 5CA059D7279559F40002BEB4 /* Tests iOS.xctest */, 5CDCAD452818589900503DA2 /* SimpleX NSE.appex */, 5CE2BA682845308900EC33A6 /* SimpleXChat.framework */, + CEE723A72C3BD3D70009AE93 /* SimpleX SE.appex */, ); name = Products; sourceTree = ""; @@ -856,10 +900,12 @@ 5CDCAD7228188CFF00503DA2 /* ChatTypes.swift */, 5CDCAD7428188D2900503DA2 /* APITypes.swift */, 5C5E5D3C282447AB00B0488A /* CallTypes.swift */, + CE3097FA2C4C0C9F00180898 /* ErrorAlert.swift */, 5C9FD96A27A56D4D0075386C /* JSON.swift */, 5CDCAD7D2818941F00503DA2 /* API.swift */, 5CDCAD80281A7E2700503DA2 /* Notifications.swift */, 5CBD2859295711D700EC2CF4 /* ImageUtils.swift */, + CE2AD9CD2C452A4D00E844E3 /* ChatUtils.swift */, 64DAE1502809D9F5000DA960 /* FileUtils.swift */, 5C9D81182AA7A4F1001D49FD /* CryptoFile.swift */, 5C00168028C4FE760094D739 /* KeyChain.swift */, @@ -974,6 +1020,20 @@ path = Theme; sourceTree = ""; }; + CEE723A82C3BD3D70009AE93 /* SimpleX SE */ = { + isa = PBXGroup; + children = ( + CEE723D42C3C21F50009AE93 /* SimpleX SE.entitlements */, + CEE723AE2C3BD3D70009AE93 /* Info.plist */, + CEDE70212C48FD9500233B1F /* SEChatState.swift */, + CE1EB0E32C459A660099D896 /* ShareAPI.swift */, + CEE723F12C3D25ED0009AE93 /* ShareModel.swift */, + CEE723EF2C3D25C70009AE93 /* ShareView.swift */, + CEE723A92C3BD3D70009AE93 /* ShareViewController.swift */, + ); + path = "SimpleX SE"; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXHeadersBuildPhase section */ @@ -1005,6 +1065,7 @@ dependencies = ( 5CE2BA6F2845308900EC33A6 /* PBXTargetDependency */, 5CE2BA9F284555F500EC33A6 /* PBXTargetDependency */, + CEE723B02C3BD3D70009AE93 /* PBXTargetDependency */, ); name = "SimpleX (iOS)"; packageProductDependencies = ( @@ -1076,6 +1137,24 @@ productReference = 5CE2BA682845308900EC33A6 /* SimpleXChat.framework */; productType = "com.apple.product-type.framework"; }; + CEE723A62C3BD3D70009AE93 /* SimpleX SE */ = { + isa = PBXNativeTarget; + buildConfigurationList = CEE723B42C3BD3D70009AE93 /* Build configuration list for PBXNativeTarget "SimpleX SE" */; + buildPhases = ( + CEE723A32C3BD3D70009AE93 /* Sources */, + CEE723A52C3BD3D70009AE93 /* Resources */, + CEE723D32C3C21C90009AE93 /* Embed Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + CEE723D22C3C21C90009AE93 /* PBXTargetDependency */, + ); + name = "SimpleX SE"; + productName = "SimpleX SE"; + productReference = CEE723A72C3BD3D70009AE93 /* SimpleX SE.appex */; + productType = "com.apple.product-type.app-extension"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -1083,7 +1162,7 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = 1; - LastSwiftUpdateCheck = 1330; + LastSwiftUpdateCheck = 1540; LastUpgradeCheck = 1340; ORGANIZATIONNAME = "SimpleX Chat"; TargetAttributes = { @@ -1103,6 +1182,9 @@ CreatedOnToolsVersion = 13.3; LastSwiftMigration = 1330; }; + CEE723A62C3BD3D70009AE93 = { + CreatedOnToolsVersion = 15.4; + }; }; }; buildConfigurationList = 5CA059C1279559F40002BEB4 /* Build configuration list for PBXProject "SimpleX" */; @@ -1144,6 +1226,7 @@ 5CA059C9279559F40002BEB4 /* SimpleX (iOS) */, 5CA059D6279559F40002BEB4 /* Tests iOS */, 5CDCAD442818589900503DA2 /* SimpleX NSE */, + CEE723A62C3BD3D70009AE93 /* SimpleX SE */, 5CE2BA672845308900EC33A6 /* SimpleXChat */, ); }; @@ -1183,6 +1266,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + CEE723A52C3BD3D70009AE93 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -1299,7 +1389,6 @@ 5C55A91F283AD0E400C4E99E /* CallManager.swift in Sources */, 5CFA59D12864782E00863A68 /* ChatArchiveView.swift in Sources */, 649BCDA22805D6EF00C3A862 /* CIImageView.swift in Sources */, - 8C05382E2B39887E006436DC /* VideoUtils.swift in Sources */, 5CADE79C292131E900072E13 /* ContactPreferencesView.swift in Sources */, 5CB346E52868AA7F001FD2EF /* SuspendChat.swift in Sources */, 5C9C2DA52894777E00CC63B1 /* GroupProfileView.swift in Sources */, @@ -1368,6 +1457,7 @@ buildActionMask = 2147483647; files = ( 5CF937202B24DE8C00E1D781 /* SharedFileSubscriber.swift in Sources */, + CE3097FB2C4C0C9F00180898 /* ErrorAlert.swift in Sources */, 5C00168128C4FE760094D739 /* KeyChain.swift in Sources */, 5CE2BA97284537A800EC33A6 /* dummy.m in Sources */, 5CE2BA922845340900EC33A6 /* FileUtils.swift in Sources */, @@ -1384,10 +1474,23 @@ 8C74C3E52C1B900600039E77 /* ThemeTypes.swift in Sources */, 5CE2BA8D284533A300EC33A6 /* CallTypes.swift in Sources */, 8C74C3E82C1B905B00039E77 /* ChatWallpaperTypes.swift in Sources */, + CE2AD9CE2C452A4D00E844E3 /* ChatUtils.swift in Sources */, 5CE2BA8E284533A300EC33A6 /* API.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; + CEE723A32C3BD3D70009AE93 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + CEDE70222C48FD9500233B1F /* SEChatState.swift in Sources */, + CEE723F02C3D25C70009AE93 /* ShareView.swift in Sources */, + CE1EB0E42C459A660099D896 /* ShareAPI.swift in Sources */, + CEE723F22C3D25ED0009AE93 /* ShareModel.swift in Sources */, + CEE723AA2C3BD3D70009AE93 /* ShareViewController.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -1412,6 +1515,16 @@ target = 5CE2BA672845308900EC33A6 /* SimpleXChat */; targetProxy = 5CE2BAA82845617C00EC33A6 /* PBXContainerItemProxy */; }; + CEE723B02C3BD3D70009AE93 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = CEE723A62C3BD3D70009AE93 /* SimpleX SE */; + targetProxy = CEE723AF2C3BD3D70009AE93 /* PBXContainerItemProxy */; + }; + CEE723D22C3C21C90009AE93 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 5CE2BA672845308900EC33A6 /* SimpleXChat */; + targetProxy = CEE723D12C3C21C90009AE93 /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin PBXVariantGroup section */ @@ -1922,6 +2035,74 @@ }; name = Release; }; + CEE723B22C3BD3D70009AE93 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 5NN7GUYB6T; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "SimpleX SE/Info.plist"; + INFOPLIST_KEY_CFBundleDisplayName = "SimpleX SE"; + INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2024 SimpleX Chat. All rights reserved."; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-SE"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + CEE723B32C3BD3D70009AE93 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 5NN7GUYB6T; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "SimpleX SE/Info.plist"; + INFOPLIST_KEY_CFBundleDisplayName = "SimpleX SE"; + INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2024 SimpleX Chat. All rights reserved."; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-SE"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -1970,6 +2151,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + CEE723B42C3BD3D70009AE93 /* Build configuration list for PBXNativeTarget "SimpleX SE" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + CEE723B22C3BD3D70009AE93 /* Debug */, + CEE723B32C3BD3D70009AE93 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ diff --git a/apps/ios/SimpleX.xcodeproj/xcshareddata/xcschemes/SimpleX SE.xcscheme b/apps/ios/SimpleX.xcodeproj/xcshareddata/xcschemes/SimpleX SE.xcscheme new file mode 100644 index 0000000000..a2639eb263 --- /dev/null +++ b/apps/ios/SimpleX.xcodeproj/xcshareddata/xcschemes/SimpleX SE.xcscheme @@ -0,0 +1,97 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/ios/SimpleXChat/API.swift b/apps/ios/SimpleXChat/API.swift index bc8a413e8f..987f7f3d41 100644 --- a/apps/ios/SimpleXChat/API.swift +++ b/apps/ios/SimpleXChat/API.swift @@ -117,10 +117,10 @@ public func sendSimpleXCmd(_ cmd: ChatCommand, _ ctrl: chat_ctrl? = nil) -> Chat } // in microseconds -let MESSAGE_TIMEOUT: Int32 = 15_000_000 +public let MESSAGE_TIMEOUT: Int32 = 15_000_000 -public func recvSimpleXMsg(_ ctrl: chat_ctrl? = nil) -> ChatResponse? { - if let cjson = chat_recv_msg_wait(ctrl ?? getChatCtrl(), MESSAGE_TIMEOUT) { +public func recvSimpleXMsg(_ ctrl: chat_ctrl? = nil, messageTimeout: Int32 = MESSAGE_TIMEOUT) -> ChatResponse? { + if let cjson = chat_recv_msg_wait(ctrl ?? getChatCtrl(), messageTimeout) { let s = fromCString(cjson) return s == "" ? nil : chatResponse(s) } diff --git a/apps/ios/SimpleXChat/AppGroup.swift b/apps/ios/SimpleXChat/AppGroup.swift index 90ac403999..6f9ad3b68e 100644 --- a/apps/ios/SimpleXChat/AppGroup.swift +++ b/apps/ios/SimpleXChat/AppGroup.swift @@ -13,6 +13,7 @@ public let appSuspendTimeout: Int = 15 // seconds let GROUP_DEFAULT_APP_STATE = "appState" let GROUP_DEFAULT_NSE_STATE = "nseState" +let GROUP_DEFAULT_SE_STATE = "seState" let GROUP_DEFAULT_DB_CONTAINER = "dbContainer" public let GROUP_DEFAULT_CHAT_LAST_START = "chatLastStart" public let GROUP_DEFAULT_CHAT_LAST_BACKGROUND_RUN = "chatLastBackgroundRun" @@ -136,6 +137,11 @@ public enum NSEState: String, Codable { } } +public enum SEState: String, Codable { + case inactive + case sendingMessage +} + public enum DBContainer: String { case documents case group @@ -155,6 +161,12 @@ public let nseStateGroupDefault = EnumDefault( withDefault: .suspended // so that NSE that was never launched does not delay the app from resuming ) +public let seStateGroupDefault = EnumDefault( + defaults: groupDefaults, + forKey: GROUP_DEFAULT_SE_STATE, + withDefault: .inactive +) + // inactive app states do not include "stopped" state public func allowBackgroundRefresh() -> Bool { appStateGroupDefault.get().inactive && nseStateGroupDefault.get().inactive diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 3898d1e4ea..54e8a80332 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -1463,7 +1463,7 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { ) } -public struct ChatData: Decodable, Identifiable, Hashable { +public struct ChatData: Decodable, Identifiable, Hashable, ChatLike { public var chatInfo: ChatInfo public var chatItems: [ChatItem] public var chatStats: ChatStats diff --git a/apps/ios/SimpleXChat/ChatUtils.swift b/apps/ios/SimpleXChat/ChatUtils.swift new file mode 100644 index 0000000000..a37b6babf7 --- /dev/null +++ b/apps/ios/SimpleXChat/ChatUtils.swift @@ -0,0 +1,62 @@ +// +// ChatUtils.swift +// SimpleXChat +// +// Created by Levitating Pineapple on 15/07/2024. +// Copyright © 2024 SimpleX Chat. All rights reserved. +// + +import Foundation + +public protocol ChatLike { + var chatInfo: ChatInfo { get} + var chatItems: [ChatItem] { get } + var chatStats: ChatStats { get } +} + +public func filterChatsToForwardTo(chats: [C]) -> [C] { + var filteredChats = chats.filter { c in + c.chatInfo.chatType != .local && canForwardToChat(c.chatInfo) + } + if let privateNotes = chats.first(where: { $0.chatInfo.chatType == .local }) { + filteredChats.insert(privateNotes, at: 0) + } + return filteredChats +} + +public func foundChat(_ chat: ChatLike, _ searchStr: String) -> Bool { + let cInfo = chat.chatInfo + return switch cInfo { + case let .direct(contact): + viewNameContains(cInfo, searchStr) || + contact.profile.displayName.localizedLowercase.contains(searchStr) || + contact.fullName.localizedLowercase.contains(searchStr) + default: + viewNameContains(cInfo, searchStr) + } + + func viewNameContains(_ cInfo: ChatInfo, _ s: String) -> Bool { + cInfo.chatViewName.localizedLowercase.contains(s) + } +} + +private func canForwardToChat(_ cInfo: ChatInfo) -> Bool { + switch cInfo { + case let .direct(contact): contact.sendMsgEnabled && !contact.nextSendGrpInv + case let .group(groupInfo): groupInfo.sendMsgEnabled + case let .local(noteFolder): noteFolder.sendMsgEnabled + case .contactRequest: false + case .contactConnection: false + case .invalidJSON: false + } +} + +public func chatIconName(_ cInfo: ChatInfo) -> String { + switch cInfo { + case .direct: "person.crop.circle.fill" + case .group: "person.2.circle.fill" + case .local: "folder.circle.fill" + case .contactRequest: "person.crop.circle.fill" + default: "circle.fill" + } +} diff --git a/apps/ios/SimpleXChat/ErrorAlert.swift b/apps/ios/SimpleXChat/ErrorAlert.swift new file mode 100644 index 0000000000..65ed4c6717 --- /dev/null +++ b/apps/ios/SimpleXChat/ErrorAlert.swift @@ -0,0 +1,159 @@ +// +// ErrorAlert.swift +// SimpleXChat +// +// Created by Levitating Pineapple on 20/07/2024. +// Copyright © 2024 SimpleX Chat. All rights reserved. +// + +import SwiftUI + +public struct ErrorAlert: Error { + public let title: LocalizedStringKey + public let message: LocalizedStringKey? + public let actions: Optional<() -> AnyView> + + public init( + title: LocalizedStringKey, + message: LocalizedStringKey? = nil + ) { + self.title = title + self.message = message + self.actions = nil + } + + public init( + title: LocalizedStringKey, + message: LocalizedStringKey? = nil, + @ViewBuilder actions: @escaping () -> A + ) { + self.title = title + self.message = message + self.actions = { AnyView(actions()) } + } + + public init(_ title: LocalizedStringKey) { + self = ErrorAlert(title: title) + } + + public init(_ error: any Error) { + self = if let chatResponse = error as? ChatResponse { + ErrorAlert(chatResponse) + } else { + ErrorAlert(LocalizedStringKey(error.localizedDescription)) + } + } + + public init(_ chatError: ChatError) { + self = ErrorAlert("\(chatErrorString(chatError))") + } + + public init(_ chatResponse: ChatResponse) { + self = if let networkErrorAlert = getNetworkErrorAlert(chatResponse) { + networkErrorAlert + } else { + ErrorAlert("\(responseError(chatResponse))") + } + } +} + +extension LocalizedStringKey: @unchecked Sendable { } + +extension View { + /// Bridges ``ErrorAlert`` to the generic alert API. + /// - Parameters: + /// - errorAlert: Binding to the Error, which is rendered in the alert + /// - actions: View Builder containing action buttons. + /// System defaults to `Ok` dismiss error action, when no actions are provided. + /// System implicitly adds `Cancel` action, if a destructive action is present + /// + /// - Returns: View, which displays ErrorAlert?, when set. + @ViewBuilder public func alert( + _ errorAlert: Binding, + @ViewBuilder actions: (ErrorAlert) -> A = { _ in EmptyView() } + ) -> some View { + if let alert = errorAlert.wrappedValue { + self.alert( + alert.title, + isPresented: Binding( + get: { errorAlert.wrappedValue != nil }, + set: { if !$0 { errorAlert.wrappedValue = nil } } + ), + actions: { + if let actions_ = alert.actions { + actions_() + } else { + actions(alert) + } + }, + message: { + if let message = alert.message { Text(message) } + } + ) + } else { self } + } +} + +public func getNetworkErrorAlert(_ r: ChatResponse) -> ErrorAlert? { + switch r { + case let .chatCmdError(_, .errorAgent(.BROKER(addr, .TIMEOUT))): + return ErrorAlert(title: "Connection timeout", message: "Please check your network connection with \(serverHostname(addr)) and try again.") + case let .chatCmdError(_, .errorAgent(.BROKER(addr, .NETWORK))): + return ErrorAlert(title: "Connection error", message: "Please check your network connection with \(serverHostname(addr)) and try again.") + case let .chatCmdError(_, .errorAgent(.BROKER(addr, .HOST))): + return ErrorAlert(title: "Connection error", message: "Server address is incompatible with network settings: \(serverHostname(addr)).") + case let .chatCmdError(_, .errorAgent(.BROKER(addr, .TRANSPORT(.version)))): + return ErrorAlert(title: "Connection error", message: "Server version is incompatible with your app: \(serverHostname(addr)).") + case let .chatCmdError(_, .errorAgent(.SMP(serverAddress, .PROXY(proxyErr)))): + return smpProxyErrorAlert(proxyErr, serverAddress) + case let .chatCmdError(_, .errorAgent(.PROXY(proxyServer, relayServer, .protocolError(.PROXY(proxyErr))))): + return proxyDestinationErrorAlert(proxyErr, proxyServer, relayServer) + default: + return nil + } +} + +private func smpProxyErrorAlert(_ proxyErr: ProxyError, _ srvAddr: String) -> ErrorAlert? { + switch proxyErr { + case .BROKER(brokerErr: .TIMEOUT): + return ErrorAlert(title: "Private routing error", message: "Error connecting to forwarding server \(serverHostname(srvAddr)). Please try later.") + case .BROKER(brokerErr: .NETWORK): + return ErrorAlert(title: "Private routing error", message: "Error connecting to forwarding server \(serverHostname(srvAddr)). Please try later.") + case .BROKER(brokerErr: .HOST): + return ErrorAlert(title: "Private routing error", message: "Forwarding server address is incompatible with network settings: \(serverHostname(srvAddr)).") + case .BROKER(brokerErr: .TRANSPORT(.version)): + return ErrorAlert(title: "Private routing error", message: "Forwarding server version is incompatible with network settings: \(serverHostname(srvAddr)).") + default: + return nil + } +} + +private func proxyDestinationErrorAlert(_ proxyErr: ProxyError, _ proxyServer: String, _ relayServer: String) -> ErrorAlert? { + switch proxyErr { + case .BROKER(brokerErr: .TIMEOUT): + return ErrorAlert(title: "Private routing error", message: "Forwarding server \(serverHostname(proxyServer)) failed to connect to destination server \(serverHostname(relayServer)). Please try later.") + case .BROKER(brokerErr: .NETWORK): + return ErrorAlert(title: "Private routing error", message: "Forwarding server \(serverHostname(proxyServer)) failed to connect to destination server \(serverHostname(relayServer)). Please try later.") + case .NO_SESSION: + return ErrorAlert(title: "Private routing error", message: "Forwarding server \(serverHostname(proxyServer)) failed to connect to destination server \(serverHostname(relayServer)). Please try later.") + case .BROKER(brokerErr: .HOST): + return ErrorAlert(title: "Private routing error", message: "Destination server address of \(serverHostname(relayServer)) is incompatible with forwarding server \(serverHostname(proxyServer)) settings.") + case .BROKER(brokerErr: .TRANSPORT(.version)): + return ErrorAlert(title: "Private routing error", message: "Destination server version of \(serverHostname(relayServer)) is incompatible with forwarding server \(serverHostname(proxyServer)).") + default: + return nil + } +} + +public func serverHostname(_ srv: String) -> String { + parseServerAddress(srv)?.hostnames.first ?? srv +} + +public func mtrErrorDescription(_ err: MTRError) -> LocalizedStringKey { + switch err { + case let .noDown(dbMigrations): + "database version is newer than the app, but no down migration for: \(dbMigrations.joined(separator: ", "))" + case let .different(appMigration, dbMigration): + "different migration in the app/database: \(appMigration) / \(dbMigration)" + } +} diff --git a/apps/ios/SimpleXChat/ImageUtils.swift b/apps/ios/SimpleXChat/ImageUtils.swift index fd6d951f48..c387c84aaa 100644 --- a/apps/ios/SimpleXChat/ImageUtils.swift +++ b/apps/ios/SimpleXChat/ImageUtils.swift @@ -10,6 +10,7 @@ import Foundation import SwiftUI import AVKit import SwiftyGif +import LinkPresentation public func getLoadedFileSource(_ file: CIFile?) -> CryptoFile? { if let file = file, file.loaded { @@ -158,6 +159,34 @@ public func imageHasAlpha(_ img: UIImage) -> Bool { return false } +/// Reduces image size, while consuming less RAM +/// +/// Used by ShareExtension to downsize large images +/// before passing them to regular image processing pipeline +/// to avoid exceeding 120MB memory +/// +/// - Parameters: +/// - url: Location of the image data +/// - size: Maximum dimension (width or height) +/// - Returns: Downsampled image or `nil`, if the image can't be located +public func downsampleImage(at url: URL, to size: Int64) -> UIImage? { + autoreleasepool { + if let source = CGImageSourceCreateWithURL(url as CFURL, nil) { + CGImageSourceCreateThumbnailAtIndex( + source, + Int.zero, + [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceShouldCacheImmediately: true, + kCGImageSourceCreateThumbnailWithTransform: true, + kCGImageSourceThumbnailMaxPixelSize: String(size) as CFString + ] as CFDictionary + ) + .map { UIImage(cgImage: $0) } + } else { nil } + } +} + public func saveFileFromURL(_ url: URL) -> CryptoFile? { let encrypted = privacyEncryptLocalFilesGroupDefault.get() let savedFile: CryptoFile? @@ -281,6 +310,21 @@ private func dropPrefix(_ s: String, _ prefix: String) -> String { s.hasPrefix(prefix) ? String(s.dropFirst(prefix.count)) : s } +public func makeVideoQualityLower(_ input: URL, outputUrl: URL) async -> Bool { + let asset: AVURLAsset = AVURLAsset(url: input, options: nil) + if let s = AVAssetExportSession(asset: asset, presetName: AVAssetExportPreset640x480) { + s.outputURL = outputUrl + s.outputFileType = .mp4 + s.metadataItemFilter = AVMetadataItemFilter.forSharing() + await s.export() + if let err = s.error { + logger.error("Failed to export video with error: \(err)") + } + return s.status == .completed + } + return false +} + extension AVAsset { public func generatePreview() -> (UIImage, Int)? { let generator = AVAssetImageGenerator(asset: self) @@ -348,3 +392,39 @@ extension UIImage { } } } + +public func getLinkPreview(url: URL, cb: @escaping (LinkPreview?) -> Void) { + logger.debug("getLinkMetadata: fetching URL preview") + LPMetadataProvider().startFetchingMetadata(for: url){ metadata, error in + if let e = error { + logger.error("Error retrieving link metadata: \(e.localizedDescription)") + } + if let metadata = metadata, + let imageProvider = metadata.imageProvider, + imageProvider.canLoadObject(ofClass: UIImage.self) { + imageProvider.loadObject(ofClass: UIImage.self){ object, error in + var linkPreview: LinkPreview? = nil + if let error = error { + logger.error("Couldn't load image preview from link metadata with error: \(error.localizedDescription)") + } else { + if let image = object as? UIImage, + let resized = resizeImageToStrSize(image, maxDataSize: 14000), + let title = metadata.title, + let uri = metadata.originalURL { + linkPreview = LinkPreview(uri: uri, title: title, image: resized) + } + } + cb(linkPreview) + } + } else { + logger.error("Could not load link preview image") + cb(nil) + } + } +} + +public func getLinkPreview(for url: URL) async -> LinkPreview? { + await withCheckedContinuation { cont in + getLinkPreview(url: url) { cont.resume(returning: $0) } + } +} diff --git a/apps/ios/SimpleXChat/SharedFileSubscriber.swift b/apps/ios/SimpleXChat/SharedFileSubscriber.swift index f496e6999e..bf5997f40b 100644 --- a/apps/ios/SimpleXChat/SharedFileSubscriber.swift +++ b/apps/ios/SimpleXChat/SharedFileSubscriber.swift @@ -12,6 +12,8 @@ public typealias AppSubscriber = SharedFileSubscriber> +public typealias SESubscriber = SharedFileSubscriber> + public class SharedFileSubscriber: NSObject, NSFilePresenter { var fileURL: URL public var presentedItemURL: URL? @@ -57,6 +59,8 @@ let appMessagesSharedFile = getGroupContainerDirectory().appendingPathComponent( let nseMessagesSharedFile = getGroupContainerDirectory().appendingPathComponent("chat.simplex.app.SimpleX-NSE.messages", isDirectory: false) +let seMessagesSharedFile = getGroupContainerDirectory().appendingPathComponent("chat.simplex.app.SimpleX-SE.messages", isDirectory: false) + public struct ProcessMessage: Codable { var createdAt: Date = Date.now var message: Message @@ -70,6 +74,10 @@ public enum NSEProcessMessage: Codable { case state(state: NSEState) } +public enum SEProcessMessage: Codable { + case state(state: SEState) +} + public func sendAppProcessMessage(_ message: AppProcessMessage) { SharedFileSubscriber.notify(url: appMessagesSharedFile, message: ProcessMessage(message: message)) } @@ -78,6 +86,10 @@ public func sendNSEProcessMessage(_ message: NSEProcessMessage) { SharedFileSubscriber.notify(url: nseMessagesSharedFile, message: ProcessMessage(message: message)) } +public func sendSEProcessMessage(_ message: SEProcessMessage) { + SharedFileSubscriber.notify(url: seMessagesSharedFile, message: ProcessMessage(message: message)) +} + public func appMessageSubscriber(onMessage: @escaping (AppProcessMessage) -> Void) -> AppSubscriber { SharedFileSubscriber(fileURL: appMessagesSharedFile) { (msg: ProcessMessage) in onMessage(msg.message) @@ -90,6 +102,12 @@ public func nseMessageSubscriber(onMessage: @escaping (NSEProcessMessage) -> Voi } } +public func seMessageSubscriber(onMessage: @escaping (SEProcessMessage) -> Void) -> SESubscriber { + SharedFileSubscriber(fileURL: seMessagesSharedFile) { (msg: ProcessMessage) in + onMessage(msg.message) + } +} + public func sendAppState(_ state: AppState) { sendAppProcessMessage(.state(state: state)) } @@ -97,3 +115,7 @@ public func sendAppState(_ state: AppState) { public func sendNSEState(_ state: NSEState) { sendNSEProcessMessage(.state(state: state)) } + +public func sendSEState(_ state: SEState) { + sendSEProcessMessage(.state(state: state)) +} diff --git a/apps/ios/SimpleXChat/hs_init.c b/apps/ios/SimpleXChat/hs_init.c index adacd57310..4731e7b829 100644 --- a/apps/ios/SimpleXChat/hs_init.c +++ b/apps/ios/SimpleXChat/hs_init.c @@ -39,3 +39,19 @@ void haskell_init_nse(void) { char **pargv = argv; hs_init_with_rtsopts(&argc, &pargv); } + +void haskell_init_se(void) { + int argc = 7; + char *argv[] = { + "simplex", + "+RTS", // requires `hs_init_with_rtsopts` + "-A1m", // chunk size for new allocations + "-H1m", // initial heap size + "-F0.5", // heap growth triggering GC + "-Fd1", // memory return + "-c", // compacting garbage collector + 0 + }; + char **pargv = argv; + hs_init_with_rtsopts(&argc, &pargv); +} diff --git a/apps/ios/SimpleXChat/hs_init.h b/apps/ios/SimpleXChat/hs_init.h index a732fd7113..40be4fc263 100644 --- a/apps/ios/SimpleXChat/hs_init.h +++ b/apps/ios/SimpleXChat/hs_init.h @@ -13,4 +13,6 @@ void haskell_init(void); void haskell_init_nse(void); +void haskell_init_se(void); + #endif /* hs_init_h */