1 // Copyright 2023-present 650 Industries. All rights reserved.
2 
3 import ExpoModulesCore
4 import CoreSpotlight
5 import MobileCoreServices
6 
7 struct MetadataOptions: Record {
8   @Field
9   // swiftlint:disable:next implicitly_unwrapped_optional
10   var activityType: String!
11   @Field
12   // swiftlint:disable:next implicitly_unwrapped_optional
13   var id: String!
14   @Field
15   var isEligibleForHandoff: Bool = true
16   @Field
17   var isEligibleForPrediction: Bool = true
18   @Field
19   var isEligibleForPublicIndexing: Bool = false
20   @Field
21   var isEligibleForSearch: Bool = true
22   @Field
23   var title: String?
24   @Field
25   var webpageURL: URL?
26   @Field
27   var imageUrl: URL?
28   @Field
29   var keywords: [String]?
30   @Field
31   var dateModified: Date?
32   @Field
33   var userInfo: [String: AnyHashable]?
34   @Field
35   var description: String?
36 }
37 
38 // swiftlint:disable:next force_unwrapping
39 let indexRouteTag = Bundle.main.bundleIdentifier! + ".expo.index_route"
40 
41 var launchedActivity: NSUserActivity?
42 
43 internal class InvalidSchemeException: Exception {
44   override var reason: String {
45     "Scheme file:// is not allowed for location origin (webpageUrl in NSUserActivity)"
46   }
47 }
48 
49 public class ExpoHeadModule: Module {
50   private var activities = Set<NSUserActivity>()
51 
52   public required init(appContext: AppContext) {
53     super.init(appContext: appContext)
54   }
55 
56   // Each module class must implement the definition function. The definition consists of components
57   // that describes the module's functionality and behavior.
58   // See https://docs.expo.dev/modules/module-api for more details about available components.
definitionnull59   public func definition() -> ModuleDefinition {
60     // Sets the name of the module that JavaScript code will use to refer to the module.
61     // Takes a string as an argument. Can be inferred from module's class name, but it's
62     // recommended to set it explicitly for clarity.
63     // The module will be accessible from `requireNativeModule('ExpoHead')` in JavaScript.
64     Name("ExpoHead")
65 
66     Constants([
67       "activities": [
68         "INDEXED_ROUTE": indexRouteTag
69       ]
70     ])
71 
72     Function("getLaunchActivity") { () -> [String: Any]? in
73       if let activity = launchedActivity {
74         return [
75           "activityType": activity.activityType,
76           "description": activity.contentAttributeSet?.contentDescription,
77           "id": activity.persistentIdentifier,
78           "isEligibleForHandoff": activity.isEligibleForHandoff,
79           "isEligibleForPrediction": activity.isEligibleForPrediction,
80           "isEligibleForPublicIndexing": activity.isEligibleForPublicIndexing,
81           "isEligibleForSearch": activity.isEligibleForSearch,
82           "title": activity.title,
83           "webpageURL": activity.webpageURL,
84           "imageUrl": activity.contentAttributeSet?.thumbnailURL,
85           "keywords": activity.keywords,
86           "dateModified": activity.contentAttributeSet?.metadataModificationDate,
87           "userInfo": activity.userInfo
88         ]
89       }
90       return nil
91     }
92 
93     Function("createActivity") { (value: MetadataOptions) in
94       if let webpageUrl = value.webpageURL {
95         if webpageUrl.absoluteString.starts(with: "file://") == true {
96           throw Exception(name: "Invalid webpageUrl", description: "Scheme file:// is not allowed for location origin (webpageUrl in NSUserActivity). URL: \(webpageUrl.absoluteString)")
97         }
98       }
99 
100       let activity = createOrUpdateActivity(value: value)
101       activity.becomeCurrent()
102     }
103 
104     AsyncFunction("clearActivitiesAsync") { (ids: [String], promise: Promise) in
105       ids.forEach { id in
106         self.revokeActivity(id: id)
107       }
108 
109       CSSearchableIndex.default().deleteSearchableItems(withIdentifiers: ids, completionHandler: { error in
110         if error != nil {
111           // swiftlint:disable:next force_cast
112           promise.reject(error as! Exception)
113         } else {
114           promise.resolve()
115         }
116       })
117     }
118 
119     Function("suspendActivity") { (id: String) in
120       let activity = self.activities.first(where: { $0.persistentIdentifier == id })
121       activity?.resignCurrent()
122     }
123 
124     Function("revokeActivity") { (id: String) in
125       self.revokeActivity(id: id)
126     }
127   }
128 
createOrUpdateActivitynull129   func createOrUpdateActivity(value: MetadataOptions) -> NSUserActivity {
130     let att = CSSearchableItemAttributeSet(itemContentType: kUTTypeText as String)
131     let existing = self.activities.first(where: { $0.persistentIdentifier == value.id })
132     let activity = existing ?? NSUserActivity(activityType: value.activityType)
133 
134     if existing == nil {
135       self.activities.insert(activity)
136     }
137 
138     activity.targetContentIdentifier = value.id
139     activity.persistentIdentifier = value.id
140     activity.isEligibleForHandoff = value.isEligibleForHandoff
141     activity.isEligibleForPrediction = value.isEligibleForPrediction
142     activity.isEligibleForPublicIndexing = value.isEligibleForPublicIndexing
143     activity.isEligibleForSearch = value.isEligibleForSearch
144     activity.title = value.title
145 
146     if let keywords = value.keywords {
147       activity.keywords = Set(keywords)
148     }
149 
150     activity.userInfo = value.userInfo
151 
152     if value.webpageURL != nil {
153       // If you’re using all three APIs, it works well to use the URL of the relevant webpage as the value
154       // for uniqueIdentifier, relatedUniqueIdentifier, and webpageURL.
155       // https://developer.apple.com/library/archive/documentation/General/Conceptual/AppSearch/CombiningAPIs.html
156       activity.webpageURL = value.webpageURL
157       att.relatedUniqueIdentifier = value.webpageURL?.absoluteString
158     }
159 
160     att.title = value.title
161     // Make all indexed routes deletable
162     att.domainIdentifier = indexRouteTag
163 
164     if let localUrl = value.imageUrl?.path {
165       att.thumbnailURL = value.imageUrl
166     }
167 
168     if let description = value.description {
169       att.contentDescription = description
170     }
171 
172     activity.contentAttributeSet = att
173 
174     return activity
175   }
176 
177   @discardableResult
revokeActivitynull178   func revokeActivity(id: String) -> NSUserActivity? {
179     let activity = self.activities.first(where: { $0.persistentIdentifier == id })
180     activity?.invalidate()
181     if let activity = activity {
182       self.activities.remove(activity)
183     }
184     return activity
185   }
186 }
187