@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
|
|
8
|
-
* mainly as a marker so users can drop:
|
|
7
|
+
* additional config-plugins work for error / span / replay capture.
|
|
9
8
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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
|
|
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 }}
|
|
54
|
+
* @param {{ sdkVersion?: string }} props
|
|
31
55
|
*/
|
|
32
|
-
const
|
|
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": "
|
|
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": ">=
|
|
49
|
-
"expo": ">=
|
|
50
|
-
"expo-application": ">=
|
|
51
|
-
"react-native": ">=0.
|
|
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": "
|
|
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
|
+
}
|