From cd63f81292f850d64174c3eb14e9085f64db1ae3 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Sat, 24 Dec 2022 00:22:12 +0300 Subject: [PATCH] ios: Animated images (GIF) support (#1636) * ios: Animated images (GIF) support * Moved from String path to UIImage param * Aspect ratio * Image frame * gif image size * refactor * refactor * fix fullscreen scroll animation * rename UploadContent -> AnyImage * refactor, allow using gifs in profiles * rename back Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- apps/ios/Shared/Model/SimpleXAPI.swift | 2 +- .../Chat/ChatItem/AnimatedImageView.swift | 63 ++++++++++++++ .../Views/Chat/ChatItem/CIImageView.swift | 16 ++-- .../Chat/ChatItem/FullScreenImageView.swift | 12 ++- apps/ios/Shared/Views/Chat/ChatView.swift | 13 +-- .../Chat/ComposeMessage/ComposeView.swift | 60 +++++++++++-- .../ComposeMessage/NativeTextEditor.swift | 85 +++++++++++++++---- .../Chat/ComposeMessage/SendMessageView.swift | 8 +- .../Shared/Views/Helpers/ImagePicker.swift | 42 +++++---- apps/ios/Shared/Views/TerminalView.swift | 2 +- apps/ios/SimpleX.xcodeproj/project.pbxproj | 32 +++++++ .../xcshareddata/swiftpm/Package.resolved | 9 ++ apps/ios/SimpleXChat/FileUtils.swift | 22 ++++- 13 files changed, 308 insertions(+), 58 deletions(-) create mode 100644 apps/ios/Shared/Views/Chat/ChatItem/AnimatedImageView.swift diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 9076856ba2..8cf06ba065 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -1006,7 +1006,7 @@ func processReceivedMsg(_ res: ChatResponse) async { m.addChatItem(cInfo, cItem) if let file = cItem.file, let mc = cItem.content.msgContent, - file.fileSize <= MAX_IMAGE_SIZE { + file.fileSize <= MAX_IMAGE_SIZE_AUTO_RCV { let acceptImages = UserDefaults.standard.bool(forKey: DEFAULT_PRIVACY_ACCEPT_IMAGES) if (mc.isImage && acceptImages) || (mc.isVoice && ((file.fileSize > MAX_VOICE_MESSAGE_SIZE_INLINE_SEND && acceptImages) || cInfo.chatType == .group)) { diff --git a/apps/ios/Shared/Views/Chat/ChatItem/AnimatedImageView.swift b/apps/ios/Shared/Views/Chat/ChatItem/AnimatedImageView.swift new file mode 100644 index 0000000000..bcdeb7fd9c --- /dev/null +++ b/apps/ios/Shared/Views/Chat/ChatItem/AnimatedImageView.swift @@ -0,0 +1,63 @@ +// +// Created by Avently on 19.12.2022. +// Copyright (c) 2022 SimpleX Chat. All rights reserved. +// + +import UIKit +import SwiftUI + +class AnimatedImageView: UIView { + var image: UIImage? = nil + var imageView: UIImageView? = nil + + override init(frame: CGRect) { + super.init(frame: frame) + } + + required init?(coder: NSCoder) { + fatalError("Not implemented") + } + + convenience init(image: UIImage) { + self.init() + self.image = image + imageView = UIImageView(gifImage: image) + imageView!.contentMode = .scaleAspectFit + self.addSubview(imageView!) + } + + override func layoutSubviews() { + super.layoutSubviews() + imageView!.frame = bounds + } + + func updateImage(_ image: UIImage) { + if let subview = self.subviews.first as? UIImageView { + if image.imageData != subview.gifImage?.imageData { + imageView = UIImageView(gifImage: image) + imageView!.contentMode = .scaleAspectFit + self.addSubview(imageView!) + subview.removeFromSuperview() + } + } + imageView!.frame = bounds + self.layoutSubviews() + } +} + +struct SwiftyGif: UIViewRepresentable { + private let image: UIImage + + init(image: UIImage) { + self.image = image + } + + func makeUIView(context: Context) -> AnimatedImageView { + AnimatedImageView(image: image) + } + + func updateUIView(_ imageView: AnimatedImageView, context: Context) { + imageView.updateImage(image) + imageView.imageView!.startAnimatingGif() + } +} diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift index 0e05d6599f..a8d0fd65c0 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift @@ -55,13 +55,19 @@ struct CIImageView: View { } private func imageView(_ img: UIImage) -> some View { - let w = img.size.width > img.size.height ? .infinity : maxWidth * 0.75 + let w = img.size.width <= img.size.height ? maxWidth * 0.75 : img.imageData == nil ? .infinity : maxWidth DispatchQueue.main.async { imgWidth = w } return ZStack(alignment: .topTrailing) { - Image(uiImage: img) - .resizable() - .scaledToFit() - .frame(maxWidth: w) + if img.imageData == nil { + Image(uiImage: img) + .resizable() + .scaledToFit() + .frame(maxWidth: w) + } else { + SwiftyGif(image: img) + .frame(width: w, height: w * img.size.height / img.size.width) + .scaledToFit() + } loadingIndicator() } } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FullScreenImageView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FullScreenImageView.swift index 7ada35f592..1be2a45bac 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/FullScreenImageView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/FullScreenImageView.swift @@ -8,6 +8,7 @@ import SwiftUI import SimpleXChat +import SwiftyGif struct FullScreenImageView: View { @EnvironmentObject var m: ChatModel @@ -77,9 +78,14 @@ struct FullScreenImageView: View { private func imageView(_ img: UIImage) -> some View { ZStack { Color.black - Image(uiImage: img) - .resizable() - .scaledToFit() + if img.imageData == nil { + Image(uiImage: img) + .resizable() + .scaledToFit() + } else { + SwiftyGif(image: img) + .scaledToFit() + } } } diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index c90da11094..2512a063c6 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -8,6 +8,7 @@ import SwiftUI import SimpleXChat +import SwiftyGif private let memberImageSize: CGFloat = 34 @@ -465,13 +466,15 @@ struct ChatView: View { } menu.append(shareUIAction()) menu.append(copyUIAction()) - if let filePath = getLoadedFilePath(ci.file) { - if case .image = ci.content.msgContent, let image = UIImage(contentsOfFile: filePath) { - menu.append(saveImageAction(image)) - } else { + if case .image = ci.content.msgContent, let image = getLoadedImage(ci.file) { + if image.imageData != nil, let filePath = getLoadedFilePath(ci.file) { + menu.append(saveFileAction(filePath)) + } else { + menu.append(saveImageAction(image)) + } + } else if case .file = ci.content.msgContent, let filePath = getLoadedFilePath(ci.file) { menu.append(saveFileAction(filePath)) } - } if ci.meta.editable && !mc.isVoice { menu.append(editAction()) } diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift index f2191d5e15..e44147d448 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift @@ -8,6 +8,8 @@ import SwiftUI import SimpleXChat +import SwiftyGif +import PhotosUI enum ComposePreview { case noPreview @@ -169,6 +171,37 @@ func chatItemPreview(chatItem: ChatItem) -> ComposePreview { return chatItemPreview } +enum UploadContent: Equatable { + case simpleImage(image: UIImage) + case animatedImage(image: UIImage) + + var uiImage: UIImage { + switch self { + case let .simpleImage(image): return image + case let .animatedImage(image): return image + } + } + + static func loadFromURL(url: URL) -> UploadContent? { + do { + let data = try Data(contentsOf: url) + if let image = UIImage(data: data) { + try image.setGifFromData(data, levelOfIntegrity: 1.0) + logger.log("UploadContent: added animated image") + return .animatedImage(image: image) + } else { return nil } + } catch { + do { + if let image = try UIImage(data: Data(contentsOf: url)) { + logger.log("UploadContent: added simple image") + return .simpleImage(image: image) + } + } catch {} + } + return nil + } +} + struct ComposeView: View { @EnvironmentObject var chatModel: ChatModel @ObservedObject var chat: Chat @@ -183,7 +216,7 @@ struct ComposeView: View { @State private var showChooseSource = false @State private var showImagePicker = false @State private var showTakePhoto = false - @State var chosenImages: [UIImage] = [] + @State var chosenImages: [UploadContent] = [] @State private var showFileImporter = false @State var chosenFile: URL? = nil @@ -231,7 +264,7 @@ struct ComposeView: View { }, finishVoiceMessageRecording: finishVoiceMessageRecording, allowVoiceMessagesToContact: allowVoiceMessagesToContact, - onImageAdded: { image in chosenImages = [image] }, + onImagesAdded: { images in if !images.isEmpty { chosenImages = images }}, keyboardVisible: $keyboardVisible ) .padding(.trailing, 12) @@ -256,7 +289,15 @@ struct ComposeView: View { } if UIPasteboard.general.hasImages { Button("Paste image") { - chosenImages = imageList(UIPasteboard.general.image) + UIPasteboard.general.itemProviders.forEach { p in + if p.hasItemConformingToTypeIdentifier(UTType.data.identifier) { + p.loadFileRepresentation(forTypeIdentifier: UTType.data.identifier) { url, error in + if let url = url, let image = UploadContent.loadFromURL(url: url) { + chosenImages.append(image) + } + } + } + } } } Button("Choose file") { @@ -283,7 +324,7 @@ struct ComposeView: View { Task { var imgs: [String] = [] for image in images { - if let img = resizeImageToStrSize(image, maxDataSize: 14000) { + if let img = resizeImageToStrSize(image.uiImage, maxDataSize: 14000) { imgs.append(img) await MainActor.run { composeState = composeState.copy(preview: .imagePreviews(imagePreviews: imgs)) @@ -483,12 +524,12 @@ struct ComposeView: View { case let .imagePreviews(imagePreviews: images): let last = min(chosenImages.count, images.count) - 1 for i in 0.. String? { + switch img { + case let .simpleImage(image): return saveImage(image) + case let .animatedImage(image): return saveAnimImage(image) + } + } } private func startVoiceMessageRecording() async { diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift index 23e3e3f06a..f2158c4cc5 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift @@ -7,6 +7,9 @@ // import SwiftUI +import SwiftyGif +import SimpleXChat +import PhotosUI struct NativeTextEditor: UIViewRepresentable { @Binding var text: String @@ -14,7 +17,7 @@ struct NativeTextEditor: UIViewRepresentable { let font: UIFont @FocusState.Binding var focused: Bool let alignment: TextAlignment - let onImageAdded: (UIImage) -> Void + let onImagesAdded: ([UploadContent]) -> Void func makeUIView(context: Context) -> UITextView { let field = CustomUITextField() @@ -23,10 +26,10 @@ struct NativeTextEditor: UIViewRepresentable { field.font = font field.textAlignment = alignment == .leading ? .left : .right field.autocapitalizationType = .sentences - field.setOnTextChangedListener { newText, image in + field.setOnTextChangedListener { newText, images in text = newText - if let image = image { - onImageAdded(image) + if !images.isEmpty { + onImagesAdded(images) } } field.setOnFocusChangedListener { focused = $0 } @@ -43,33 +46,85 @@ struct NativeTextEditor: UIViewRepresentable { } private class CustomUITextField: UITextView, UITextViewDelegate { - var onTextChanged: (String, UIImage?) -> Void = { newText, image in } + var onTextChanged: (String, [UploadContent]) -> Void = { newText, image in } var onFocusChanged: (Bool) -> Void = { focused in } - func setOnTextChangedListener(onTextChanged: @escaping (String, UIImage?) -> Void) { + func setOnTextChangedListener(onTextChanged: @escaping (String, [UploadContent]) -> Void) { self.onTextChanged = onTextChanged } func setOnFocusChangedListener(onFocusChanged: @escaping (Bool) -> Void) { self.onFocusChanged = onFocusChanged } - + + func textView(_ textView: UITextView, editMenuForTextIn range: NSRange, suggestedActions: [UIMenuElement]) -> UIMenu? { + if !UIPasteboard.general.hasImages { return UIMenu(children: suggestedActions)} + return UIMenu(children: suggestedActions.map { elem in + if let elem = elem as? UIMenu { + var actions = elem.children + // Replacing Paste action since it allows to paste animated images too + let pasteIndex = elem.children.firstIndex { elem in elem.debugDescription.contains("Action: paste:")} + if let pasteIndex = pasteIndex { + let paste = actions[pasteIndex] + actions.remove(at: pasteIndex) + let newPaste = UIAction(title: paste.title, image: paste.image) { action in + var images: [UploadContent] = [] + var totalImages = 0 + var processed = 0 + UIPasteboard.general.itemProviders.forEach { p in + if p.hasItemConformingToTypeIdentifier(UTType.data.identifier) { + totalImages += 1 + p.loadFileRepresentation(forTypeIdentifier: UTType.data.identifier) { url, error in + processed += 1 + if let url = url, let image = UploadContent.loadFromURL(url: url) { + images.append(image) + DispatchQueue.main.sync { + self.onTextChanged(textView.text, images) + } + } + // No images were added, just paste a text then + if processed == totalImages && images.isEmpty { + textView.paste(UIPasteboard.general.string) + } + } + } + } + } + actions.insert(newPaste, at: 0) + } + return UIMenu(title: elem.title, subtitle: elem.subtitle, image: elem.image, identifier: elem.identifier, options: elem.options, children: actions) + } else { + return elem + } + }) + } + func textViewDidChange(_ textView: UITextView) { - var image: UIImage? = nil + var images: [UploadContent] = [] + var rangeDiff = 0 + let newAttributedText = NSMutableAttributedString(attributedString: textView.attributedText) textView.attributedText.enumerateAttribute( NSAttributedString.Key.attachment, in: NSRange(location: 0, length: textView.attributedText.length), options: [], using: { value, range, _ in - if let attachment = (value as? NSTextAttachment)?.image { - image = attachment - let newText = NSMutableAttributedString(attributedString: textView.attributedText) - newText.replaceCharacters(in: range, with: "") - textView.attributedText = newText + if let attachment = (value as? NSTextAttachment)?.fileWrapper?.regularFileContents { + do { + images.append(.animatedImage(image: try UIImage(gifData: attachment))) + } catch { + if let img = (value as? NSTextAttachment)?.image { + images.append(.simpleImage(image: img)) + } + } + newAttributedText.replaceCharacters(in: NSMakeRange(range.location - rangeDiff, range.length), with: "") + rangeDiff += range.length } } ) - onTextChanged(textView.text, image) + if textView.attributedText != newAttributedText { + textView.attributedText = newAttributedText + } + onTextChanged(textView.text, images) } func textViewDidBeginEditing(_ textView: UITextView) { @@ -90,7 +145,7 @@ struct NativeTextEditor_Previews: PreviewProvider{ font: UIFont.preferredFont(forTextStyle: .body), focused: $keyboardVisible, alignment: TextAlignment.leading, - onImageAdded: { _ in } + onImagesAdded: { _ in } ) } } diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift index 53a44405dc..1b2cae4709 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift @@ -20,7 +20,7 @@ struct SendMessageView: View { var startVoiceMessageRecording: (() -> Void)? = nil var finishVoiceMessageRecording: (() -> Void)? = nil var allowVoiceMessagesToContact: (() -> Void)? = nil - var onImageAdded: (UIImage) -> Void + var onImagesAdded: ([UploadContent]) -> Void @State private var holdingVMR = false @Namespace var namespace @FocusState.Binding var keyboardVisible: Bool @@ -66,7 +66,7 @@ struct SendMessageView: View { font: teUiFont, focused: $keyboardVisible, alignment: alignment, - onImageAdded: onImageAdded + onImagesAdded: onImagesAdded ) .allowsTightening(false) .frame(height: teHeight) @@ -314,7 +314,7 @@ struct SendMessageView_Previews: PreviewProvider { SendMessageView( composeState: $composeStateNew, sendMessage: {}, - onImageAdded: { _ in }, + onImagesAdded: { _ in }, keyboardVisible: $keyboardVisible ) } @@ -324,7 +324,7 @@ struct SendMessageView_Previews: PreviewProvider { SendMessageView( composeState: $composeStateEditing, sendMessage: {}, - onImageAdded: { _ in }, + onImagesAdded: { _ in }, keyboardVisible: $keyboardVisible ) } diff --git a/apps/ios/Shared/Views/Helpers/ImagePicker.swift b/apps/ios/Shared/Views/Helpers/ImagePicker.swift index 194a0a306d..8afe4fc372 100644 --- a/apps/ios/Shared/Views/Helpers/ImagePicker.swift +++ b/apps/ios/Shared/Views/Helpers/ImagePicker.swift @@ -8,28 +8,34 @@ import SwiftUI import PhotosUI +import SwiftyGif +import SimpleXChat struct LibraryImagePicker: View { @Binding var image: UIImage? var didFinishPicking: (_ didSelectItems: Bool) -> Void - @State var images: [UIImage] = [] + @State var images: [UploadContent] = [] var body: some View { LibraryImageListPicker(images: $images, selectionLimit: 1, didFinishPicking: didFinishPicking) - .onChange(of: images) { image = $0.first } + .onChange(of: images) { _ in + if let img = images.first { + image = img.uiImage + } + } } } struct LibraryImageListPicker: UIViewControllerRepresentable { typealias UIViewControllerType = PHPickerViewController - @Binding var images: [UIImage] + @Binding var images: [UploadContent] var selectionLimit: Int var didFinishPicking: (_ didSelectItems: Bool) -> Void class Coordinator: PHPickerViewControllerDelegate { let parent: LibraryImageListPicker let dispatchQueue = DispatchQueue(label: "chat.simplex.app.LibraryImageListPicker") - var images: [UIImage] = [] + var images: [UploadContent] = [] var imageCount: Int = 0 init(_ parent: LibraryImageListPicker) { @@ -48,7 +54,11 @@ struct LibraryImageListPicker: UIViewControllerRepresentable { for result in results { logger.log("LibraryImageListPicker result") let p = result.itemProvider - if p.canLoadObject(ofClass: UIImage.self) { + if p.hasItemConformingToTypeIdentifier(UTType.data.identifier) { + p.loadFileRepresentation(forTypeIdentifier: UTType.data.identifier) { url, error in + self.loadImage(object: url, error: error) + } + } else if p.canLoadObject(ofClass: UIImage.self) { p.loadObject(ofClass: UIImage.self) { image, error in DispatchQueue.main.async { self.loadImage(object: image, error: error) @@ -72,8 +82,10 @@ struct LibraryImageListPicker: UIViewControllerRepresentable { if let error = error { logger.error("LibraryImageListPicker: couldn't load image with error: \(error.localizedDescription)") } else if let image = object as? UIImage { - images.append(image) + images.append(.simpleImage(image: image)) logger.log("LibraryImageListPicker: added image") + } else if let url = object as? URL, let image = UploadContent.loadFromURL(url: url) { + images.append(image) } dispatchQueue.sync { self.imageCount -= 1 @@ -105,20 +117,18 @@ struct LibraryImageListPicker: UIViewControllerRepresentable { } struct CameraImageListPicker: View { - @Binding var images: [UIImage] + @Binding var images: [UploadContent] @State var image: UIImage? var body: some View { CameraImagePicker(image: $image) - .onChange(of: image) { images = imageList($0) } - } -} - -func imageList(_ img: UIImage?) -> [UIImage] { - if let img = img { - return [img] - } else { - return [] + .onChange(of: image) { img in + if let img = img { + images = [UploadContent.simpleImage(image: img)] + } else { + images = [] + } + } } } diff --git a/apps/ios/Shared/Views/TerminalView.swift b/apps/ios/Shared/Views/TerminalView.swift index 5add9b77ba..f50b87a695 100644 --- a/apps/ios/Shared/Views/TerminalView.swift +++ b/apps/ios/Shared/Views/TerminalView.swift @@ -87,7 +87,7 @@ struct TerminalView: View { composeState: $composeState, sendMessage: sendMessage, showVoiceMessageButton: false, - onImageAdded: { _ in }, + onImagesAdded: { _ in }, keyboardVisible: $keyboardVisible ) .padding(.horizontal, 12) diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 624a88981a..5bb5e263dc 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 1841594C978674A7B42EF0C0 /* AnimatedImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1841511920742C6E152E469F /* AnimatedImageView.swift */; }; 3C714777281C081000CB4D4B /* WebRTCView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C714776281C081000CB4D4B /* WebRTCView.swift */; }; 3C71477A281C0F6800CB4D4B /* www in Resources */ = {isa = PBXBuildFile; fileRef = 3C714779281C0F6800CB4D4B /* www */; }; 3C8C548928133C84000A3EC7 /* PasteToConnectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C8C548828133C84000A3EC7 /* PasteToConnectView.swift */; }; @@ -157,6 +158,8 @@ 64E972072881BB22008DBC02 /* CIGroupInvitationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64E972062881BB22008DBC02 /* CIGroupInvitationView.swift */; }; 64F1CC3B28B39D8600CD1FB1 /* IncognitoHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64F1CC3A28B39D8600CD1FB1 /* IncognitoHelp.swift */; }; D72A9088294BD7A70047C86D /* NativeTextEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72A9087294BD7A70047C86D /* NativeTextEditor.swift */; }; + D77B92DC2952372200A5A1CC /* SwiftyGif in Frameworks */ = {isa = PBXBuildFile; productRef = D77B92DB2952372200A5A1CC /* SwiftyGif */; }; + D77B92DE29523E1700A5A1CC /* SwiftyGif in Frameworks */ = {isa = PBXBuildFile; productRef = D77B92DD29523E1700A5A1CC /* SwiftyGif */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -216,6 +219,7 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 1841511920742C6E152E469F /* AnimatedImageView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AnimatedImageView.swift; sourceTree = ""; }; 3C714776281C081000CB4D4B /* WebRTCView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebRTCView.swift; sourceTree = ""; }; 3C714779281C0F6800CB4D4B /* www */ = {isa = PBXFileReference; lastKnownFileType = folder; name = www; path = ../android/app/src/main/assets/www; sourceTree = ""; }; 3C8C548828133C84000A3EC7 /* PasteToConnectView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasteToConnectView.swift; sourceTree = ""; }; @@ -382,6 +386,7 @@ buildActionMask = 2147483647; files = ( 5CE2BA702845308900EC33A6 /* SimpleXChat.framework in Frameworks */, + D77B92DC2952372200A5A1CC /* SwiftyGif in Frameworks */, 646BB38C283BEEB9001CE359 /* LocalAuthentication.framework in Frameworks */, 5C8F01CD27A6F0D8007D2C8D /* CodeScanner in Frameworks */, ); @@ -408,6 +413,8 @@ files = ( 5C70311F2955080A00150A12 /* libHSsimplex-chat-4.4.0-AHPp9UIBWT5C2IlT3cD6QO.a in Frameworks */, 5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */, + D77B92DE29523E1700A5A1CC /* SwiftyGif in Frameworks */, + 5CBE6C2E2948E5B9002D9531 /* libgmp.a in Frameworks */, 5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */, 5C70311D2955080A00150A12 /* libgmp.a in Frameworks */, 5C70311E2955080A00150A12 /* libffi.a in Frameworks */, @@ -693,6 +700,7 @@ 5C7031152953C97F00150A12 /* CIFeaturePreferenceView.swift */, 644EFFE1292D089800525D5B /* FramedCIVoiceView.swift */, 644EFFE32937BE9700525D5B /* MarkedDeletedItemView.swift */, + 1841511920742C6E152E469F /* AnimatedImageView.swift */, ); path = ChatItem; sourceTree = ""; @@ -771,6 +779,7 @@ name = "SimpleX (iOS)"; packageProductDependencies = ( 5C8F01CC27A6F0D8007D2C8D /* CodeScanner */, + D77B92DB2952372200A5A1CC /* SwiftyGif */, ); productName = "SimpleX (iOS)"; productReference = 5CA059CA279559F40002BEB4 /* SimpleX.app */; @@ -826,6 +835,9 @@ dependencies = ( ); name = SimpleXChat; + packageProductDependencies = ( + D77B92DD29523E1700A5A1CC /* SwiftyGif */, + ); productName = SimpleXChat; productReference = 5CE2BA682845308900EC33A6 /* SimpleXChat.framework */; productType = "com.apple.product-type.framework"; @@ -872,6 +884,7 @@ mainGroup = 5CA059BD279559F40002BEB4; packageReferences = ( 5C8F01CB27A6F0D8007D2C8D /* XCRemoteSwiftPackageReference "CodeScanner" */, + D77B92DA2952372200A5A1CC /* XCRemoteSwiftPackageReference "SwiftyGif" */, ); productRefGroup = 5CA059CB279559F40002BEB4 /* Products */; projectDirPath = ""; @@ -1040,6 +1053,7 @@ 5C9C2DA7289957AE00CC63B1 /* AdvancedNetworkSettings.swift in Sources */, 5CADE79A29211BB900072E13 /* PreferencesView.swift in Sources */, 644EFFE42937BE9700525D5B /* MarkedDeletedItemView.swift in Sources */, + 1841594C978674A7B42EF0C0 /* AnimatedImageView.swift in Sources */, 5C7031162953C97F00150A12 /* CIFeaturePreferenceView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -1602,6 +1616,14 @@ minimumVersion = 2.0.0; }; }; + D77B92DA2952372200A5A1CC /* XCRemoteSwiftPackageReference "SwiftyGif" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/kirualex/SwiftyGif"; + requirement = { + branch = master; + kind = branch; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -1610,6 +1632,16 @@ package = 5C8F01CB27A6F0D8007D2C8D /* XCRemoteSwiftPackageReference "CodeScanner" */; productName = CodeScanner; }; + D77B92DB2952372200A5A1CC /* SwiftyGif */ = { + isa = XCSwiftPackageProductDependency; + package = D77B92DA2952372200A5A1CC /* XCRemoteSwiftPackageReference "SwiftyGif" */; + productName = SwiftyGif; + }; + D77B92DD29523E1700A5A1CC /* SwiftyGif */ = { + isa = XCSwiftPackageProductDependency; + package = D77B92DA2952372200A5A1CC /* XCRemoteSwiftPackageReference "SwiftyGif" */; + productName = SwiftyGif; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 5CA059BE279559F40002BEB4 /* Project object */; diff --git a/apps/ios/SimpleX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/apps/ios/SimpleX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 3f1d1790ff..fae90a0391 100644 --- a/apps/ios/SimpleX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/apps/ios/SimpleX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -8,6 +8,15 @@ "revision" : "c27a66149b7483fe42e2ec6aad61d5c3fffe522d", "version" : "2.1.1" } + }, + { + "identity" : "swiftygif", + "kind" : "remoteSourceControl", + "location" : "https://github.com/kirualex/SwiftyGif", + "state" : { + "branch" : "master", + "revision" : "4a6f5bad863c5365b192f8441f62c713ecff62bd" + } } ], "version" : 2 diff --git a/apps/ios/SimpleXChat/FileUtils.swift b/apps/ios/SimpleXChat/FileUtils.swift index 9aece2e138..99722b28b3 100644 --- a/apps/ios/SimpleXChat/FileUtils.swift +++ b/apps/ios/SimpleXChat/FileUtils.swift @@ -9,12 +9,15 @@ import Foundation import SwiftUI import OSLog +import SwiftyGif let logger = Logger() // maximum image file size to be auto-accepted public let MAX_IMAGE_SIZE: Int64 = 236700 +public let MAX_IMAGE_SIZE_AUTO_RCV: Int64 = MAX_IMAGE_SIZE * 2 + public let MAX_FILE_SIZE: Int64 = 8000000 public let MAX_VOICE_MESSAGE_LENGTH = TimeInterval(30) @@ -182,8 +185,17 @@ public func getLoadedFilePath(_ file: CIFile?) -> String? { } public func getLoadedImage(_ file: CIFile?) -> UIImage? { - if let filePath = getLoadedFilePath(file) { - return UIImage(contentsOfFile: filePath) + let loadedFilePath = getLoadedFilePath(file) + if let loadedFilePath = loadedFilePath, let fileName = file?.filePath { + let filePath = getAppFilePath(fileName) + do { + let data = try Data(contentsOf: filePath) + let img = UIImage(data: data) + try img?.setGifFromData(data, levelOfIntegrity: 1.0) + return img + } catch { + return UIImage(contentsOfFile: loadedFilePath) + } } return nil } @@ -216,6 +228,12 @@ public func saveImage(_ uiImage: UIImage) -> String? { return nil } +public func saveAnimImage(_ image: UIImage) -> String? { + let fileName = generateNewFileName("IMG", "gif") + guard let imageData = image.imageData else { return nil } + return saveFile(imageData, fileName) +} + public func generateNewFileName(_ prefix: String, _ ext: String) -> String { let timestamp = Date().getFormattedDate("yyyyMMdd_HHmmss") let fileName = uniqueCombine("\(prefix)_\(timestamp).\(ext)")