Browse Source

refactor(ios): new share intent implementation

Andelf 2 years ago
parent
commit
016fd2b519

+ 8 - 4
ios/App/App.xcodeproj/project.pbxproj

@@ -27,6 +27,7 @@
 		D3D62A0C275C928F0003FBDC /* FileContainer.m in Sources */ = {isa = PBXBuildFile; fileRef = D3D62A0B275C928F0003FBDC /* FileContainer.m */; };
 		FE647FF427BDFEDE00F3206B /* FsWatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE647FF327BDFEDE00F3206B /* FsWatcher.swift */; };
 		FE647FF627BDFEF500F3206B /* FsWatcher.m in Sources */ = {isa = PBXBuildFile; fileRef = FE647FF527BDFEF500F3206B /* FsWatcher.m */; };
+		FE96D6102A1B811A001ECE32 /* SharedData.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE96D60F2A1B811A001ECE32 /* SharedData.swift */; };
 /* End PBXBuildFile section */
 
 /* Begin PBXContainerItemProxy section */
@@ -83,6 +84,7 @@
 		DE5650F4AD4E2242AB9C012D /* Pods-Logseq.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Logseq.debug.xcconfig"; path = "Target Support Files/Pods-Logseq/Pods-Logseq.debug.xcconfig"; sourceTree = "<group>"; };
 		FE647FF327BDFEDE00F3206B /* FsWatcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FsWatcher.swift; sourceTree = "<group>"; };
 		FE647FF527BDFEF500F3206B /* FsWatcher.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FsWatcher.m; sourceTree = "<group>"; };
+		FE96D60F2A1B811A001ECE32 /* SharedData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedData.swift; sourceTree = "<group>"; };
 /* End PBXFileReference section */
 
 /* Begin PBXFrameworksBuildPhase section */
@@ -156,6 +158,7 @@
 			children = (
 				5FFF7D7927E4E70700B00DA8 /* ShareViewController.entitlements */,
 				5FFF7D6C27E343FA00B00DA8 /* ShareViewController.swift */,
+				FE96D60F2A1B811A001ECE32 /* SharedData.swift */,
 				5FFF7D6E27E343FA00B00DA8 /* MainInterface.storyboard */,
 				5FFF7D7127E343FA00B00DA8 /* Info.plist */,
 			);
@@ -345,6 +348,7 @@
 			isa = PBXSourcesBuildPhase;
 			buildActionMask = 2147483647;
 			files = (
+				FE96D6102A1B811A001ECE32 /* SharedData.swift in Sources */,
 				5FFF7D6D27E343FA00B00DA8 /* ShareViewController.swift in Sources */,
 			);
 			runOnlyForDeploymentPostprocessing = 0;
@@ -438,7 +442,7 @@
 				GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
 				GCC_WARN_UNUSED_FUNCTION = YES;
 				GCC_WARN_UNUSED_VARIABLE = YES;
-				IPHONEOS_DEPLOYMENT_TARGET = 13.0;
+				IPHONEOS_DEPLOYMENT_TARGET = 14.0;
 				MTL_ENABLE_DEBUG_INFO = YES;
 				ONLY_ACTIVE_ARCH = YES;
 				SDKROOT = iphoneos;
@@ -492,7 +496,7 @@
 				GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
 				GCC_WARN_UNUSED_FUNCTION = YES;
 				GCC_WARN_UNUSED_VARIABLE = YES;
-				IPHONEOS_DEPLOYMENT_TARGET = 13.0;
+				IPHONEOS_DEPLOYMENT_TARGET = 14.0;
 				MTL_ENABLE_DEBUG_INFO = NO;
 				SDKROOT = iphoneos;
 				SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule";
@@ -565,7 +569,7 @@
 				INFOPLIST_FILE = ShareViewController/Info.plist;
 				INFOPLIST_KEY_CFBundleDisplayName = ShareViewController;
 				INFOPLIST_KEY_NSHumanReadableCopyright = "";
-				IPHONEOS_DEPLOYMENT_TARGET = 13.0;
+				IPHONEOS_DEPLOYMENT_TARGET = 14.0;
 				LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
 				MARKETING_VERSION = 0.9.6;
 				MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
@@ -592,7 +596,7 @@
 				INFOPLIST_FILE = ShareViewController/Info.plist;
 				INFOPLIST_KEY_CFBundleDisplayName = ShareViewController;
 				INFOPLIST_KEY_NSHumanReadableCopyright = "";
-				IPHONEOS_DEPLOYMENT_TARGET = 13.0;
+				IPHONEOS_DEPLOYMENT_TARGET = 14.0;
 				LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
 				MARKETING_VERSION = 0.9.6;
 				MTL_FAST_MATH = YES;

+ 4 - 2
ios/App/ShareViewController/Info.plist

@@ -8,6 +8,8 @@
 		<dict>
 			<key>NSExtensionActivationRule</key>
 			<dict>
+				<key>NSExtensionActivationDictionaryVersion</key>
+				<integer>2</integer>
 				<key>NSExtensionActivationSupportsFileWithMaxCount</key>
 				<integer>5</integer>
 				<key>NSExtensionActivationSupportsImageWithMaxCount</key>
@@ -17,9 +19,9 @@
 				<key>NSExtensionActivationSupportsText</key>
 				<true/>
 				<key>NSExtensionActivationSupportsWebPageWithMaxCount</key>
-				<integer>1</integer>
+				<integer>3</integer>
 				<key>NSExtensionActivationSupportsWebURLWithMaxCount</key>
-				<integer>1</integer>
+				<integer>3</integer>
 				<key>NSExtensionActivationUsesStrictMatching</key>
 				<false/>
 			</dict>

+ 117 - 111
ios/App/ShareViewController/ShareViewController.swift

@@ -9,182 +9,180 @@
 import MobileCoreServices
 import Social
 import UIKit
-
-class ShareItem {
-    public var title: String?
-    public var type: String?
-    public var url: String?
-}
+import UniformTypeIdentifiers
 
 class ShareViewController: UIViewController {
-    
-    private var shareItems: [ShareItem] = []
-    
+
+    private var sharedData: SharedData = SharedData.init(resources: [])
+
     var groupContainerUrl: URL? {
         return FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.com.logseq.logseq")
     }
-    
+
     override public func viewDidAppear(_ animated: Bool) {
-       super.viewDidAppear(animated)
-       self.extensionContext!.completeRequest(returningItems: [], completionHandler: nil)
+        super.viewDidAppear(animated)
+        DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
+            self.extensionContext!.completeRequest(returningItems: [], completionHandler: nil)
+        }
     }
-    
+
     private func sendData() {
-        let queryItems = shareItems.map {
+        let encoder: JSONEncoder = JSONEncoder()
+        let data = try? encoder.encode(self.sharedData)
+        let queryPayload = String(decoding: data!, as: UTF8.self)
+
+        let queryItems =
             [
                 URLQueryItem(
-                    name: "title",
-                    value: $0.title?.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) ?? ""),
-                URLQueryItem(name: "description", value: ""),
-                URLQueryItem(
-                    name: "type",
-                    value: $0.type?.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) ?? ""),
-                URLQueryItem(
-                    name: "url",
-                    value: $0.url?.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) ?? ""),
+                    name: "payload",
+                    value: queryPayload.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) ?? ""),
             ]
-        }.flatMap({ $0 })
         var urlComps = URLComponents(string: "logseq://shared?")!
         urlComps.queryItems = queryItems
         openURL(urlComps.url!)
     }
-    
-    fileprivate func createSharedFileUrl(_ url: URL?) -> String {
-        
-        let copyFileUrl = groupContainerUrl!.absoluteString.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)! + "/" + url!
+
+    fileprivate func createSharedFileUrl(_ url: URL?) -> URL? {
+        let tempFilename = url!
             .lastPathComponent.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)!
-        try? Data(contentsOf: url!).write(to: URL(string: copyFileUrl)!)
-        
+        let copyFileUrl = groupContainerUrl!.appendingPathComponent(tempFilename)
+        try? Data(contentsOf: url!).write(to: copyFileUrl)
         return copyFileUrl
     }
-    
-    func saveScreenshot(_ image: UIImage) -> String {
-        
+
+    // Screenshots, shared images from some system App are passed as UIImage
+    func saveUIImage(_ image: UIImage) -> URL? {
         let dateFormatter = DateFormatter()
         dateFormatter.dateFormat = "yyyy-MM-dd-HH-mm-ss"
-        
-        let copyFileUrl = groupContainerUrl!.absoluteString.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)!
-        + dateFormatter.string(from: Date()) + ".png"
-        
+        let filename = dateFormatter.string(from: Date()) + ".png"
+
+        let copyFileUrl = groupContainerUrl!.appendingPathComponent(filename)
+
         do {
-            try image.pngData()?.write(to: URL(string: copyFileUrl)!)
+            try image.pngData()?.write(to: copyFileUrl)
             return copyFileUrl
         } catch {
             print(error.localizedDescription)
-            return ""
+            return nil
         }
     }
-    
+
+    // Can be a path or a web URL
     fileprivate func handleTypeUrl(_ attachment: NSItemProvider)
-    async throws -> ShareItem
+    async throws -> SharedResource
     {
         let results = try await attachment.loadItem(forTypeIdentifier: kUTTypeURL as String, options: nil)
         let url = results as! URL?
-        let shareItem: ShareItem = ShareItem()
-        
+
+        var res = SharedResource()
+
         if url!.isFileURL {
-            shareItem.title = url!.lastPathComponent
-            shareItem.type = "application/" + url!.pathExtension.lowercased()
-            shareItem.url = createSharedFileUrl(url)
+            res.name = url!.lastPathComponent
+            res.ext = url!.pathExtension
+            res.type = url!.pathExtensionAsMimeType()
+            res.url = createSharedFileUrl(url)
         } else {
-            shareItem.title = url!.absoluteString
-            shareItem.url = url!.absoluteString
-            shareItem.type = "text/plain"
+            res.name = url!.absoluteString
+            res.type = "text/plain"
         }
-        
-        return shareItem
+
+        return res
     }
-    
+
     fileprivate func handleTypeText(_ attachment: NSItemProvider)
-    async throws -> ShareItem
+    async throws -> SharedResource?
     {
-        let results = try await attachment.loadItem(forTypeIdentifier: kUTTypeText as String, options: nil)
-        let shareItem: ShareItem = ShareItem()
-        let text = results as! String
-        shareItem.title = text
-        shareItem.type = "text/plain"
-        
-        return shareItem
+        let item = try await attachment.loadItem(forTypeIdentifier: kUTTypeText as String, options: nil)
+        self.sharedData.text = item as? String
+        return nil
     }
-    
+
     fileprivate func handleTypeMovie(_ attachment: NSItemProvider)
-    async throws -> ShareItem
+    async throws -> SharedResource
     {
         let results = try await attachment.loadItem(forTypeIdentifier: kUTTypeMovie as String, options: nil)
-        let shareItem: ShareItem = ShareItem()
-        
+
         let url = results as! URL?
-        shareItem.title = url!.lastPathComponent
-        shareItem.type = "video/" + url!.pathExtension.lowercased()
-        shareItem.url = createSharedFileUrl(url)
-        
-        return shareItem
+
+        let name = url!.lastPathComponent
+        let ext = url!.pathExtension.lowercased()
+        let type = url!.pathExtensionAsMimeType()
+        let sharedUrl = createSharedFileUrl(url)
+
+        let res = SharedResource(name: name, ext: ext, type: type, url: sharedUrl)
+
+        return res
     }
-    
+
     fileprivate func handleTypeImage(_ attachment: NSItemProvider)
-    async throws -> ShareItem
+    async throws -> SharedResource
     {
         let data = try await attachment.loadItem(forTypeIdentifier: kUTTypeImage as String, options: nil)
-        
-        let shareItem: ShareItem = ShareItem()
+
+        var res = SharedResource()
+
         switch data {
         case let image as UIImage:
-            shareItem.title = "screenshot"
-            shareItem.type = "image/png"
-            shareItem.url = self.saveScreenshot(image)
+            res.url = self.saveUIImage(image)
+            res.ext = "png"
+            res.name = res.url?.lastPathComponent
+            res.type = res.url?.pathExtensionAsMimeType()
         case let url as URL:
-            shareItem.title = url.lastPathComponent
-            shareItem.type = "image/" + url.pathExtension.lowercased()
-            shareItem.url = self.createSharedFileUrl(url)
+            res.name = url.lastPathComponent
+            res.ext = url.pathExtension.lowercased()
+            res.type = url.pathExtensionAsMimeType()
+            res.url = self.createSharedFileUrl(url)
         default:
             print("Unexpected image data:", type(of: data))
         }
-        
-        return shareItem
+
+        return res
     }
-    
-    
+
+
     override public func viewDidLoad() {
         super.viewDidLoad()
-        
-        shareItems.removeAll()
-        
-        let extensionItem = extensionContext?.inputItems.first as! NSExtensionItem
+
+        sharedData.empty()
+        let inputItems = extensionContext?.inputItems as! [NSExtensionItem]
         Task {
             try await withThrowingTaskGroup(
-                of: ShareItem.self,
+                of: SharedResource?.self,
                 body: { taskGroup in
-                    
-                    for attachment in extensionItem.attachments! {
-                        if attachment.hasItemConformingToTypeIdentifier(kUTTypeURL as String) {
-                            taskGroup.addTask {
-                                return try await self.handleTypeUrl(attachment)
-                            }
-                        } else if attachment.hasItemConformingToTypeIdentifier(kUTTypeText as String) {
-                            taskGroup.addTask {
-                                return try await self.handleTypeText(attachment)
-                            }
-                        } else if attachment.hasItemConformingToTypeIdentifier(kUTTypeMovie as String) {
-                            taskGroup.addTask {
-                                return try await self.handleTypeMovie(attachment)
-                            }
-                        } else if attachment.hasItemConformingToTypeIdentifier(kUTTypeImage as String) {
-                            taskGroup.addTask {
-                                return try await self.handleTypeImage(attachment)
+                    for extensionItem in inputItems {
+                        for attachment in extensionItem.attachments! {
+                            if attachment.hasItemConformingToTypeIdentifier(kUTTypeURL as String) {
+                                taskGroup.addTask {
+                                    return try await self.handleTypeUrl(attachment)
+                                }
+                            } else if attachment.hasItemConformingToTypeIdentifier(kUTTypeText as String) {
+                                taskGroup.addTask {
+                                    return try await self.handleTypeText(attachment)
+                                }
+                            } else if attachment.hasItemConformingToTypeIdentifier(kUTTypeMovie as String) {
+                                taskGroup.addTask {
+                                    return try await self.handleTypeMovie(attachment)
+                                }
+                            } else if attachment.hasItemConformingToTypeIdentifier(kUTTypeImage as String) {
+                                taskGroup.addTask {
+                                    return try await self.handleTypeImage(attachment)
+                                }
                             }
                         }
                     }
-                    
+
                     for try await item in taskGroup {
-                        self.shareItems.append(item)
+                        if let item = item {
+                            self.sharedData.resources.append(item)
+                        }
                     }
                 })
-            
+
             self.sendData()
-            
+
         }
     }
-    
+
     @discardableResult
     @objc func openURL(_ url: URL) -> Bool {
         var responder: UIResponder? = self
@@ -196,6 +194,14 @@ class ShareViewController: UIViewController {
         }
         return false
     }
-    
+
+
+}
+
+extension URL {
+    func pathExtensionAsMimeType() -> String? {
+        let type = UTType(filenameExtension: self.pathExtension)
+        return type?.preferredMIMEType
+    }
 }
 

+ 25 - 0
ios/App/ShareViewController/SharedData.swift

@@ -0,0 +1,25 @@
+//
+//  SharedData.swift
+//  ShareViewController
+//
+//  Created by Mono Wang on 5/22/23.
+//
+
+import Foundation
+
+public struct SharedResource: Decodable, Encodable {
+    var name: String?
+    var ext: String?
+    var type: String?
+    var url: URL?
+}
+
+public struct SharedData: Decodable, Encodable {
+    var text: String?
+    var resources: [SharedResource]
+    
+    mutating func empty() {
+        text = nil
+        resources = []
+    }
+}

+ 10 - 3
src/main/frontend/mobile/deeplink.cljs

@@ -9,7 +9,8 @@
    [frontend.handler.route :as route-handler]
    [frontend.mobile.intent :as intent]
    [frontend.state :as state]
-   [frontend.util.text :as text-util]))
+   [frontend.util.text :as text-util]
+   [logseq.graph-parser.util :as gp-util]))
 
 (def *link-to-another-graph (atom false))
 
@@ -70,8 +71,14 @@
       (= hostname "shared")
       (let [result (into {} (map (fn [key]
                                    [(keyword key) (.get search-params key)])
-                                 ["title" "url" "type"]))]
-        (intent/handle-result result))
+                                 ["title" "url" "type" "payload"]))]
+        (if (:payload result)
+          (let [raw (gp-util/safe-decode-uri-component (:payload result))
+                payload (-> raw
+                            js/JSON.parse
+                            (js->clj :keywordize-keys true))]
+            (intent/handle-payload payload))
+          (intent/handle-result result)))
 
       :else
       nil)))

+ 86 - 1
src/main/frontend/mobile/intent.cljs

@@ -152,7 +152,92 @@
                         (gp-util/safe-decode-uri-component v)
                         v))])))
 
-(defn handle-result [result]
+(defn- handle-asset-file [url format]
+  (p/let [basename (node-path/basename url)
+          label (-> basename util/node-path.name)
+          path (editor-handler/get-asset-path basename)
+          _file (p/catch
+                 (.copy Filesystem (clj->js {:from url :to path}))
+                 (fn [error]
+                   (log/error :copy-file-error {:error error})))
+          url (util/format "../assets/%s" basename)
+          url-link (editor-handler/get-asset-file-link format url label true)]
+    url-link))
+
+(defn- handle-payload-resource
+  [{:keys [type name ext url] :as resource} format]
+  (if url
+    (cond
+      (contains? (set/union config/doc-formats config/media-formats)
+                 (keyword ext))
+      (handle-asset-file url format)
+
+      :else
+      (notification/show!
+       [:div
+        "Parsing current shared content are not supported. Please report the following codes on "
+        [:a {:href "https://github.com/logseq/logseq/issues/new?labels=from:in-app&template=bug_report.yaml"
+             :target "_blank"} "Github"]
+        ". We will look into it soon."
+        [:pre.code (with-out-str (pprint/pprint resource))]] :warning false))
+
+    (cond
+      (= type "text/plain")
+      name
+
+      :else
+      (notification/show!
+       [:div
+        "Parsing current shared content are not supported. Please report the following codes on "
+        [:a {:href "https://github.com/logseq/logseq/issues/new?labels=from:in-app&template=bug_report.yaml"
+             :target "_blank"} "Github"]
+        ". We will look into it soon."
+        [:pre.code (with-out-str (pprint/pprint resource))]] :warning false))))
+
+(defn handle-payload
+  "Mobile share intent handler v2, use complex payload to support more types of content."
+  [payload]
+  ;; use :text template, use {url} as rich text placeholder
+  (p/let [page (or (state/get-current-page) (string/lower-case (date/journal-name)))
+          format (db/get-page-format page)
+
+          template (get-in (state/get-config)
+                           [:quick-capture-templates :text]
+                           "**{time}** [[quick capture]]: {text} {url}")
+          {:keys [text resources]} payload
+          text (or text "")
+          rich-content (-> (p/all (map (fn [resource]
+                                         (handle-payload-resource resource format))
+                                       resources))
+                           (p/then (partial string/join "\n")))]
+    (when (or (not-empty text) (not-empty rich-content))
+      (let [time (date/get-current-time)
+            date-ref-name (date/today)
+            content (-> template
+                        (string/replace "{time}" time)
+                        (string/replace "{date}" date-ref-name)
+                        (string/replace "{text}" text)
+                        (string/replace "{url}" rich-content))
+            edit-content (state/get-edit-content)
+            edit-content-blank? (string/blank? edit-content)
+            edit-content-include-capture? (and (not-empty edit-content)
+                                               (string/includes? edit-content "[[quick capture]]"))]
+        (if (and (state/editing?) (not edit-content-include-capture?))
+          (if edit-content-blank?
+            (editor-handler/insert content)
+            (editor-handler/insert (str "\n" content)))
+
+          (do
+            (editor-handler/escape-editing)
+            (js/setTimeout #(editor-handler/api-insert-new-block! content {:page page
+                                                                           :edit-block? true
+                                                                           :replace-empty-target? true})
+                           100)))))))
+
+
+(defn handle-result
+  "Mobile share intent handler v1, legacy. Only for Android"
+  [result]
   (let [result (decode-received-result result)]
     (when-let [type (:type result)]
       (cond