@goliapkg/sentori-expo 5.0.0 → 7.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/app.plugin.js CHANGED
@@ -4,36 +4,252 @@
4
4
  * `@goliapkg/sentori-react-native` already exposes
5
5
  * `expo-module.config.json` + iOS podspec + Android build.gradle, so
6
6
  * Expo Modules autolinking handles the native side without any
7
- * additional config-plugins work. The Config Plugin entry exists
8
- * mainly as a marker so users can drop:
7
+ * additional config-plugins work for error / span / replay capture.
9
8
  *
10
- * {
11
- * "expo": {
12
- * "plugins": ["@goliapkg/sentori-expo"]
13
- * }
14
- * }
9
+ * v2.11 — extends the plugin to also wire **push notifications** for
10
+ * apps that opt in. When the host adds `@goliapkg/sentori-expo` to
11
+ * its `app.json` plugins array, prebuild auto-injects:
15
12
  *
16
- * into their app.json without breaking the build, and so we can
17
- * extend it later (e.g. SDK-version metadata in Info.plist /
18
- * AndroidManifest, native crash-handler opt-ins) without changing
19
- * the user-facing wiring.
13
+ * iOS:
14
+ * - Info.plist: UIBackgroundModes ⊇ [remote-notification]
15
+ * - Entitlements: aps-environment = 'production' (Xcode flips to
16
+ * 'development' for debug signing automatically)
17
+ *
18
+ * Android:
19
+ * - AndroidManifest.xml: <uses-permission POST_NOTIFICATIONS>
20
+ * - Root build.gradle: classpath com.google.gms:google-services
21
+ * - App build.gradle: apply google-services + firebase-bom +
22
+ * firebase-messaging
23
+ * - Copies google-services.json from `props.googleServicesFile`
24
+ * (defaults to `./google-services.json` at the host root) to
25
+ * `android/app/google-services.json` on prebuild.
26
+ *
27
+ * Opt out per platform with `{ ios: false }` / `{ android: false }`.
28
+ * Opt out entirely by not including the plugin in `app.json`.
20
29
  *
21
30
  * The plugin is intentionally CommonJS — Expo's plugin loader uses
22
31
  * `require()`.
23
32
  */
24
- const { withInfoPlist } = require('@expo/config-plugins')
33
+ const fs = require('fs')
34
+ const path = require('path')
35
+ const {
36
+ withInfoPlist,
37
+ withEntitlementsPlist,
38
+ withAndroidManifest,
39
+ withProjectBuildGradle,
40
+ withAppBuildGradle,
41
+ withDangerousMod,
42
+ AndroidConfig,
43
+ withPlugins,
44
+ } = require('@expo/config-plugins')
25
45
 
26
46
  const SENTORI_VERSION_KEY = 'SentoriSdkVersion'
47
+ const FIREBASE_BOM_VERSION = '33.5.1'
48
+ const GOOGLE_SERVICES_VERSION = '4.4.2'
49
+
50
+ // ── Existing marker (Sentori SDK version surface) ──────────────────
27
51
 
28
52
  /**
29
53
  * @param {import('@expo/config-plugins').ExpoConfig} config
30
- * @param {{ sdkVersion?: string }} [props]
54
+ * @param {{ sdkVersion?: string }} props
31
55
  */
32
- const withSentori = (config, props = {}) => {
56
+ const withSentoriVersion = (config, props = {}) => {
33
57
  return withInfoPlist(config, (cfg) => {
34
58
  cfg.modResults[SENTORI_VERSION_KEY] = props.sdkVersion || '0.1.0'
35
59
  return cfg
36
60
  })
37
61
  }
38
62
 
63
+ // ── v2.11 iOS push ─────────────────────────────────────────────────
64
+
65
+ /**
66
+ * @param {import('@expo/config-plugins').ExpoConfig} config
67
+ */
68
+ const withSentoriPushIos = (config) => {
69
+ config = withInfoPlist(config, (cfg) => {
70
+ const modes = Array.isArray(cfg.modResults.UIBackgroundModes)
71
+ ? cfg.modResults.UIBackgroundModes
72
+ : []
73
+ if (!modes.includes('remote-notification')) {
74
+ modes.push('remote-notification')
75
+ }
76
+ cfg.modResults.UIBackgroundModes = modes
77
+ return cfg
78
+ })
79
+ config = withEntitlementsPlist(config, (cfg) => {
80
+ if (!cfg.modResults['aps-environment']) {
81
+ // Xcode automatically swaps to 'development' when the build is
82
+ // signed with a development provisioning profile, so this
83
+ // default is correct for both flavors.
84
+ cfg.modResults['aps-environment'] = 'production'
85
+ }
86
+ return cfg
87
+ })
88
+ return config
89
+ }
90
+
91
+ // ── v2.11 Android push ─────────────────────────────────────────────
92
+
93
+ /**
94
+ * @param {import('@expo/config-plugins').ExpoConfig} config
95
+ */
96
+ const withSentoriPushAndroidManifest = (config) => {
97
+ return withAndroidManifest(config, (cfg) => {
98
+ const manifest = cfg.modResults.manifest
99
+ AndroidConfig.Permissions.addPermission(
100
+ manifest,
101
+ 'android.permission.POST_NOTIFICATIONS'
102
+ )
103
+ return cfg
104
+ })
105
+ }
106
+
107
+ /**
108
+ * @param {import('@expo/config-plugins').ExpoConfig} config
109
+ */
110
+ const withSentoriPushAndroidGradle = (config) => {
111
+ // Root build.gradle: add google-services classpath.
112
+ config = withProjectBuildGradle(config, (cfg) => {
113
+ if (cfg.modResults.language === 'groovy') {
114
+ const classpath = `classpath('com.google.gms:google-services:${GOOGLE_SERVICES_VERSION}')`
115
+ if (!cfg.modResults.contents.includes('com.google.gms:google-services')) {
116
+ cfg.modResults.contents = cfg.modResults.contents.replace(
117
+ /(dependencies\s*\{)/,
118
+ `$1\n ${classpath}`
119
+ )
120
+ }
121
+ }
122
+ return cfg
123
+ })
124
+ // App build.gradle: apply plugin + firebase deps.
125
+ config = withAppBuildGradle(config, (cfg) => {
126
+ if (cfg.modResults.language !== 'groovy') return cfg
127
+ let contents = cfg.modResults.contents
128
+ if (!contents.includes('com.google.gms.google-services')) {
129
+ contents += `\napply plugin: 'com.google.gms.google-services'\n`
130
+ }
131
+ if (!contents.includes('firebase-bom')) {
132
+ contents = contents.replace(
133
+ /(dependencies\s*\{)/,
134
+ `$1\n implementation platform('com.google.firebase:firebase-bom:${FIREBASE_BOM_VERSION}')\n implementation 'com.google.firebase:firebase-messaging'`
135
+ )
136
+ }
137
+ cfg.modResults.contents = contents
138
+ return cfg
139
+ })
140
+ return config
141
+ }
142
+
143
+ /**
144
+ * @param {import('@expo/config-plugins').ExpoConfig} config
145
+ * @param {{ googleServicesFile?: string }} props
146
+ */
147
+ const withSentoriGoogleServicesJson = (config, props = {}) => {
148
+ return withDangerousMod(config, [
149
+ 'android',
150
+ async (cfg) => {
151
+ const srcRel = props.googleServicesFile || './google-services.json'
152
+ const projectRoot = cfg.modRequest.projectRoot
153
+ const src = path.isAbsolute(srcRel) ? srcRel : path.join(projectRoot, srcRel)
154
+ if (!fs.existsSync(src)) {
155
+ // Don't fail the build; warn so the operator notices.
156
+ // eslint-disable-next-line no-console
157
+ console.warn(
158
+ `[sentori-expo] google-services.json not found at ${src}; skipping copy. Push will work once the file is added + prebuild re-runs.`
159
+ )
160
+ return cfg
161
+ }
162
+ const platformRoot = cfg.modRequest.platformProjectRoot
163
+ const dest = path.join(platformRoot, 'app', 'google-services.json')
164
+ fs.mkdirSync(path.dirname(dest), { recursive: true })
165
+ fs.copyFileSync(src, dest)
166
+ return cfg
167
+ },
168
+ ])
169
+ }
170
+
171
+ // ── v2.28 iOS Notification Service Extension scaffolding ──────────
172
+ //
173
+ // Rich-media notifications (images / future video) require an NSE
174
+ // target on the iOS app. The Sentori NSE template downloads the URL
175
+ // at `userInfo.sentori_attachment_url` and attaches it before iOS
176
+ // displays the notification. APNs server side sets this key when
177
+ // `richMedia.imageUrl` is on the send (v2.28+).
178
+ //
179
+ // v2.28 ships the source files via withDangerousMod. Adding the NSE
180
+ // **target** to the Xcode project is a one-time manual step (5 clicks
181
+ // in Xcode → File → New → Target → Notification Service Extension,
182
+ // then drag in our Swift file). The recipe walks the developer through
183
+ // it. v2.28.1 will auto-inject the target via withXcodeProject.
184
+ //
185
+ // Opt out with `{ ios: false }` (which also drops the rest of the iOS
186
+ // push wiring) or with `{ nse: false }` for just this template.
187
+
188
+ /**
189
+ * @param {import('@expo/config-plugins').ExpoConfig} config
190
+ */
191
+ const withSentoriNSE = (config) => {
192
+ return withDangerousMod(config, [
193
+ 'ios',
194
+ async (cfg) => {
195
+ const platformRoot = cfg.modRequest.platformProjectRoot
196
+ const destDir = path.join(platformRoot, 'SentoriNSE')
197
+ const templateDir = path.join(__dirname, 'templates', 'ios-nse')
198
+ const swiftSrc = path.join(templateDir, 'SentoriNotificationServiceExtension.swift')
199
+ const plistSrc = path.join(templateDir, 'SentoriNSE-Info.plist')
200
+ if (!fs.existsSync(swiftSrc) || !fs.existsSync(plistSrc)) {
201
+ // eslint-disable-next-line no-console
202
+ console.warn(
203
+ '[sentori-expo] NSE templates missing; skipping. Reinstall the package to restore.'
204
+ )
205
+ return cfg
206
+ }
207
+ fs.mkdirSync(destDir, { recursive: true })
208
+ fs.copyFileSync(
209
+ swiftSrc,
210
+ path.join(destDir, 'SentoriNotificationServiceExtension.swift')
211
+ )
212
+ fs.copyFileSync(plistSrc, path.join(destDir, 'SentoriNSE-Info.plist'))
213
+ // One-time guidance for first-time setup. Idempotent — appears
214
+ // on every prebuild until the target exists.
215
+ const pbxproj = path.join(platformRoot, cfg.modRequest.projectName + '.xcodeproj', 'project.pbxproj')
216
+ if (fs.existsSync(pbxproj)) {
217
+ const proj = fs.readFileSync(pbxproj, 'utf8')
218
+ if (!proj.includes('SentoriNSE')) {
219
+ // eslint-disable-next-line no-console
220
+ console.log(
221
+ '\n[sentori-expo] iOS NSE template files copied to ios/SentoriNSE/.\n' +
222
+ ' For rich-media (image) notifications to render, add a\n' +
223
+ ' Notification Service Extension target via Xcode and link\n' +
224
+ ' these files. Detailed steps in the recipe.\n'
225
+ )
226
+ }
227
+ }
228
+ return cfg
229
+ },
230
+ ])
231
+ }
232
+
233
+ // ── Composer ───────────────────────────────────────────────────────
234
+
235
+ /**
236
+ * @param {import('@expo/config-plugins').ExpoConfig} config
237
+ * @param {{ sdkVersion?: string, ios?: boolean, android?: boolean, nse?: boolean, googleServicesFile?: string }} [props]
238
+ */
239
+ const withSentori = (config, props = {}) => {
240
+ const plugins = [[withSentoriVersion, props]]
241
+ if (props.ios !== false) {
242
+ plugins.push([withSentoriPushIos, props])
243
+ if (props.nse !== false) plugins.push([withSentoriNSE, props])
244
+ }
245
+ if (props.android !== false) {
246
+ plugins.push(
247
+ [withSentoriPushAndroidManifest, props],
248
+ [withSentoriPushAndroidGradle, props],
249
+ [withSentoriGoogleServicesJson, props]
250
+ )
251
+ }
252
+ return withPlugins(config, plugins)
253
+ }
254
+
39
255
  module.exports = withSentori
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goliapkg/sentori-expo",
3
- "version": "5.0.0",
3
+ "version": "7.0.0",
4
4
  "description": "Expo adapter for Sentori — Config Plugin marker, expo-application auto-config, EAS post-build helper. Built on @goliapkg/sentori-react-native.",
5
5
  "license": "Apache-2.0 OR MIT",
6
6
  "author": "GOLIA K.K. <takagi@golia.jp> (https://golia.jp)",
@@ -36,6 +36,7 @@
36
36
  "src/",
37
37
  "app.plugin.js",
38
38
  "scripts/",
39
+ "templates/",
39
40
  "README.md"
40
41
  ],
41
42
  "scripts": {
@@ -45,10 +46,10 @@
45
46
  "prepack": "bun run build"
46
47
  },
47
48
  "peerDependencies": {
48
- "@goliapkg/sentori-react-native": ">=2.2.0",
49
- "expo": ">=50",
50
- "expo-application": ">=5",
51
- "react-native": ">=0.74"
49
+ "@goliapkg/sentori-react-native": ">=3.1.0",
50
+ "expo": ">=55.0.0 <57.0.0",
51
+ "expo-application": ">=55.0.0 <57.0.0",
52
+ "react-native": ">=0.81.0"
52
53
  },
53
54
  "peerDependenciesMeta": {
54
55
  "expo-application": {
@@ -56,7 +57,7 @@
56
57
  }
57
58
  },
58
59
  "dependencies": {
59
- "@expo/config-plugins": "^9 || ^10"
60
+ "@expo/config-plugins": ">=55.0.0 <57.0.0"
60
61
  },
61
62
  "devDependencies": {
62
63
  "@goliapkg/sentori-react-native": "workspace:*",
@@ -0,0 +1,31 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+ <plist version="1.0">
4
+ <dict>
5
+ <key>CFBundleDevelopmentRegion</key>
6
+ <string>$(DEVELOPMENT_LANGUAGE)</string>
7
+ <key>CFBundleDisplayName</key>
8
+ <string>SentoriNSE</string>
9
+ <key>CFBundleExecutable</key>
10
+ <string>$(EXECUTABLE_NAME)</string>
11
+ <key>CFBundleIdentifier</key>
12
+ <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
13
+ <key>CFBundleInfoDictionaryVersion</key>
14
+ <string>6.0</string>
15
+ <key>CFBundleName</key>
16
+ <string>$(PRODUCT_NAME)</string>
17
+ <key>CFBundlePackageType</key>
18
+ <string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
19
+ <key>CFBundleShortVersionString</key>
20
+ <string>1.0</string>
21
+ <key>CFBundleVersion</key>
22
+ <string>1</string>
23
+ <key>NSExtension</key>
24
+ <dict>
25
+ <key>NSExtensionPointIdentifier</key>
26
+ <string>com.apple.usernotifications.service</string>
27
+ <key>NSExtensionPrincipalClass</key>
28
+ <string>$(PRODUCT_MODULE_NAME).SentoriNotificationServiceExtension</string>
29
+ </dict>
30
+ </dict>
31
+ </plist>
@@ -0,0 +1,77 @@
1
+ // Sentori Notification Service Extension — v2.28.
2
+ //
3
+ // When an APNs payload arrives with `mutable-content: 1` AND a custom
4
+ // `sentori_attachment_url` field, this extension downloads the image
5
+ // and attaches it to the notification before iOS displays it.
6
+ //
7
+ // Lifecycle:
8
+ // - serviceExtensionTimeWillExpire is called ~30 s after delivery.
9
+ // We fall back to the unaltered notification at that point.
10
+ // - All work runs off the main thread; the OS gives this extension
11
+ // up to ~30 s with a hard CPU budget. The download is on a
12
+ // background URLSession with 5 s timeout so a slow CDN does not
13
+ // burn the budget.
14
+ //
15
+ // This file is written by `@goliapkg/sentori-expo` v2.28+'s
16
+ // `withSentoriNSE` config plugin into `ios/SentoriNSE/` on every
17
+ // `expo prebuild`. The one-time Xcode target wiring is documented in
18
+ // the recipe (auto-injection lands in v2.28.1).
19
+
20
+ import UserNotifications
21
+
22
+ final class SentoriNotificationServiceExtension: UNNotificationServiceExtension {
23
+ private var contentHandler: ((UNNotificationContent) -> Void)?
24
+ private var bestAttempt: UNMutableNotificationContent?
25
+
26
+ override func didReceive(
27
+ _ request: UNNotificationRequest,
28
+ withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void
29
+ ) {
30
+ self.contentHandler = contentHandler
31
+ self.bestAttempt = (request.content.mutableCopy() as? UNMutableNotificationContent)
32
+ guard let bestAttempt = self.bestAttempt else {
33
+ contentHandler(request.content)
34
+ return
35
+ }
36
+
37
+ // Extract the Sentori-reserved attachment URL. v2.28 server
38
+ // emits this as a top-level custom-data key when the send's
39
+ // `richMedia.imageUrl` is set.
40
+ guard
41
+ let raw = bestAttempt.userInfo["sentori_attachment_url"] as? String,
42
+ let url = URL(string: raw)
43
+ else {
44
+ contentHandler(bestAttempt)
45
+ return
46
+ }
47
+
48
+ // Bounded-time download to a temp file. URLSession.shared
49
+ // honours the request's timeout; we set it to 5 s so a stalled
50
+ // CDN does not eat the ~30 s extension budget.
51
+ var request = URLRequest(url: url)
52
+ request.timeoutInterval = 5
53
+ let task = URLSession.shared.downloadTask(with: request) { tempURL, _, _ in
54
+ defer { contentHandler(bestAttempt) }
55
+ guard let tempURL = tempURL else { return }
56
+ // Move to a guessed-extension destination so iOS picks a
57
+ // sensible content type.
58
+ let ext = url.pathExtension.isEmpty ? "img" : url.pathExtension
59
+ let dest = tempURL.deletingPathExtension().appendingPathExtension(ext)
60
+ try? FileManager.default.moveItem(at: tempURL, to: dest)
61
+ if let attachment = try? UNNotificationAttachment(
62
+ identifier: "sentori-image",
63
+ url: dest,
64
+ options: nil
65
+ ) {
66
+ bestAttempt.attachments = [attachment]
67
+ }
68
+ }
69
+ task.resume()
70
+ }
71
+
72
+ override func serviceExtensionTimeWillExpire() {
73
+ if let contentHandler = contentHandler, let bestAttempt = bestAttempt {
74
+ contentHandler(bestAttempt)
75
+ }
76
+ }
77
+ }