@otaupdate/react-native 1.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.
Files changed (57) hide show
  1. package/README.md +321 -0
  2. package/android/build.gradle +70 -0
  3. package/android/src/expo/java/com/otaupdate/OtaUpdateExpoPackage.kt +37 -0
  4. package/android/src/main/AndroidManifest.xml +3 -0
  5. package/android/src/main/java/com/otaupdate/OtaUpdate.kt +98 -0
  6. package/android/src/main/java/com/otaupdate/OtaUpdateInstaller.kt +211 -0
  7. package/android/src/main/java/com/otaupdate/OtaUpdateModule.kt +278 -0
  8. package/android/src/main/java/com/otaupdate/OtaUpdatePackage.kt +15 -0
  9. package/android/src/main/java/com/otaupdate/OtaUpdateStore.kt +268 -0
  10. package/app.plugin.js +3 -0
  11. package/expo-module.config.json +6 -0
  12. package/ios/OtaUpdate.h +46 -0
  13. package/ios/OtaUpdate.m +302 -0
  14. package/ios/OtaUpdateInstaller.h +25 -0
  15. package/ios/OtaUpdateInstaller.m +278 -0
  16. package/ios/OtaUpdateStore.h +69 -0
  17. package/ios/OtaUpdateStore.m +283 -0
  18. package/lib/OtaUpdate.d.ts +28 -0
  19. package/lib/OtaUpdate.d.ts.map +1 -0
  20. package/lib/OtaUpdate.js +254 -0
  21. package/lib/OtaUpdate.js.map +1 -0
  22. package/lib/api.d.ts +36 -0
  23. package/lib/api.d.ts.map +1 -0
  24. package/lib/api.js +89 -0
  25. package/lib/api.js.map +1 -0
  26. package/lib/index.d.ts +24 -0
  27. package/lib/index.d.ts.map +1 -0
  28. package/lib/index.js +37 -0
  29. package/lib/index.js.map +1 -0
  30. package/lib/native.d.ts +34 -0
  31. package/lib/native.d.ts.map +1 -0
  32. package/lib/native.js +34 -0
  33. package/lib/native.js.map +1 -0
  34. package/lib/types.d.ts +112 -0
  35. package/lib/types.d.ts.map +1 -0
  36. package/lib/types.js +27 -0
  37. package/lib/types.js.map +1 -0
  38. package/lib/useOtaUpdate.d.ts +17 -0
  39. package/lib/useOtaUpdate.d.ts.map +1 -0
  40. package/lib/useOtaUpdate.js +81 -0
  41. package/lib/useOtaUpdate.js.map +1 -0
  42. package/lib/withOtaUpdate.d.ts +10 -0
  43. package/lib/withOtaUpdate.d.ts.map +1 -0
  44. package/lib/withOtaUpdate.js +21 -0
  45. package/lib/withOtaUpdate.js.map +1 -0
  46. package/package.json +54 -0
  47. package/plugin/build/index.d.ts +11 -0
  48. package/plugin/build/index.js +97 -0
  49. package/react-native-ota-update.podspec +43 -0
  50. package/react-native.config.js +21 -0
  51. package/src/OtaUpdate.ts +293 -0
  52. package/src/api.ts +122 -0
  53. package/src/index.ts +55 -0
  54. package/src/native.ts +64 -0
  55. package/src/types.ts +125 -0
  56. package/src/useOtaUpdate.ts +96 -0
  57. package/src/withOtaUpdate.tsx +22 -0
@@ -0,0 +1,211 @@
1
+ package com.otaupdate
2
+
3
+ import android.util.Log
4
+ import java.io.File
5
+ import java.io.FileOutputStream
6
+ import java.net.HttpURLConnection
7
+ import java.net.URL
8
+ import java.security.MessageDigest
9
+ import java.util.zip.ZipEntry
10
+ import java.util.zip.ZipInputStream
11
+
12
+ /** Thrown for any recoverable download/verify/unzip failure. */
13
+ class OtaInstallException(message: String, cause: Throwable? = null) : Exception(message, cause)
14
+
15
+ data class InstalledPackage(val bundlePath: String, val packageHash: String, val size: Long)
16
+
17
+ /**
18
+ * Downloads a release zip, verifies its SHA-256 against the value the server
19
+ * published, and unzips it into the store. Verification happens before
20
+ * anything is unpacked, so a corrupted or tampered payload never reaches disk
21
+ * as a runnable bundle.
22
+ */
23
+ object OtaUpdateInstaller {
24
+
25
+ private const val BUFFER_SIZE = 8 * 1024
26
+ private const val CONNECT_TIMEOUT_MS = 15_000
27
+ private const val READ_TIMEOUT_MS = 60_000
28
+
29
+ /**
30
+ * Decompression bomb guards. A verified SHA-256 proves the archive is the one
31
+ * the server published — it says nothing about how much it expands to. A
32
+ * hostile or corrupt package could otherwise fill the device.
33
+ */
34
+ private const val MAX_COMPRESSION_RATIO = 120L
35
+ private const val MAX_TOTAL_EXTRACTED_BYTES = 600L * 1024 * 1024
36
+ private const val MAX_ENTRIES = 20_000
37
+
38
+ /** Filenames React Native will accept as the JS bundle. */
39
+ private val BUNDLE_CANDIDATES = listOf("index.android.bundle", "main.jsbundle", "index.bundle")
40
+
41
+ fun install(
42
+ store: OtaUpdateStore,
43
+ downloadUrl: String,
44
+ expectedHash: String,
45
+ onProgress: (received: Long, total: Long) -> Unit,
46
+ ): InstalledPackage {
47
+ if (store.hasFailed(expectedHash)) {
48
+ throw OtaInstallException(
49
+ "Refusing to reinstall $expectedHash — a previous attempt failed to boot",
50
+ )
51
+ }
52
+
53
+ val targetDir = store.packageDir(expectedHash)
54
+ // Already downloaded and verified on an earlier attempt.
55
+ resolveBundle(targetDir)?.let { existing ->
56
+ return InstalledPackage(existing.absolutePath, expectedHash, dirSize(targetDir))
57
+ }
58
+
59
+ val tempZip = File.createTempFile("ota-", ".zip", store.packageDir("").parentFile)
60
+ try {
61
+ val actualHash = download(downloadUrl, tempZip, onProgress)
62
+ if (!actualHash.equals(expectedHash, ignoreCase = true)) {
63
+ throw OtaInstallException(
64
+ "Bundle integrity check failed: expected $expectedHash but downloaded $actualHash",
65
+ )
66
+ }
67
+
68
+ targetDir.deleteRecursively()
69
+ targetDir.mkdirs()
70
+ unzip(tempZip, targetDir, tempZip.length())
71
+
72
+ val bundle = resolveBundle(targetDir)
73
+ ?: throw OtaInstallException(
74
+ "The release package contains no JS bundle (looked for ${BUNDLE_CANDIDATES.joinToString(", ")})",
75
+ )
76
+
77
+ return InstalledPackage(bundle.absolutePath, expectedHash, dirSize(targetDir))
78
+ } catch (e: Exception) {
79
+ targetDir.deleteRecursively()
80
+ throw if (e is OtaInstallException) e else OtaInstallException(e.message ?: "Update failed", e)
81
+ } finally {
82
+ tempZip.delete()
83
+ }
84
+ }
85
+
86
+ /** Streams to disk while hashing, so the payload is never held in memory. */
87
+ private fun download(
88
+ url: String,
89
+ destination: File,
90
+ onProgress: (received: Long, total: Long) -> Unit,
91
+ ): String {
92
+ val connection = (URL(url).openConnection() as HttpURLConnection).apply {
93
+ connectTimeout = CONNECT_TIMEOUT_MS
94
+ readTimeout = READ_TIMEOUT_MS
95
+ instanceFollowRedirects = true
96
+ requestMethod = "GET"
97
+ }
98
+
99
+ try {
100
+ val status = connection.responseCode
101
+ if (status !in 200..299) {
102
+ throw OtaInstallException("Bundle download failed with HTTP $status")
103
+ }
104
+
105
+ val total = connection.contentLengthLong.takeIf { it > 0 } ?: -1L
106
+ val digest = MessageDigest.getInstance("SHA-256")
107
+ var received = 0L
108
+ var lastReported = 0L
109
+
110
+ connection.inputStream.use { input ->
111
+ FileOutputStream(destination).use { output ->
112
+ val buffer = ByteArray(BUFFER_SIZE)
113
+ while (true) {
114
+ val read = input.read(buffer)
115
+ if (read == -1) break
116
+ digest.update(buffer, 0, read)
117
+ output.write(buffer, 0, read)
118
+ received += read
119
+ // Throttle to ~1% steps; the bridge cannot keep up with per-chunk.
120
+ if (total <= 0 || received - lastReported >= total / 100 || received == total) {
121
+ lastReported = received
122
+ onProgress(received, total)
123
+ }
124
+ }
125
+ }
126
+ }
127
+
128
+ return digest.digest().joinToString("") { "%02x".format(it) }
129
+ } finally {
130
+ connection.disconnect()
131
+ }
132
+ }
133
+
134
+ private fun unzip(zip: File, targetDir: File, archiveBytes: Long) {
135
+ // Cap on total output, derived from the archive's own size. Streaming
136
+ // enforcement is required: an entry's declared size is attacker-controlled,
137
+ // so the only trustworthy number is what we have actually written.
138
+ val ceiling = minOf(
139
+ MAX_TOTAL_EXTRACTED_BYTES,
140
+ maxOf(archiveBytes, 1L) * MAX_COMPRESSION_RATIO,
141
+ )
142
+ var totalWritten = 0L
143
+ var entries = 0
144
+
145
+ ZipInputStream(zip.inputStream().buffered()).use { input ->
146
+ var entry: ZipEntry? = input.nextEntry
147
+ while (entry != null) {
148
+ if (++entries > MAX_ENTRIES) {
149
+ throw OtaInstallException("Release package has too many entries (> $MAX_ENTRIES)")
150
+ }
151
+
152
+ val outFile = File(targetDir, entry.name)
153
+ // Zip-slip guard: an entry name like ../../x would otherwise write
154
+ // outside the package directory.
155
+ if (!outFile.canonicalPath.startsWith(targetDir.canonicalPath + File.separator)) {
156
+ throw OtaInstallException("Refusing to extract unsafe zip entry: ${entry.name}")
157
+ }
158
+
159
+ if (entry.isDirectory) {
160
+ outFile.mkdirs()
161
+ } else {
162
+ outFile.parentFile?.mkdirs()
163
+ FileOutputStream(outFile).use { output ->
164
+ val buffer = ByteArray(BUFFER_SIZE)
165
+ while (true) {
166
+ val read = input.read(buffer)
167
+ if (read == -1) break
168
+ totalWritten += read
169
+ if (totalWritten > ceiling) {
170
+ throw OtaInstallException(
171
+ "Release package expands beyond the safe limit (> $ceiling bytes) — refusing to extract",
172
+ )
173
+ }
174
+ output.write(buffer, 0, read)
175
+ }
176
+ }
177
+ }
178
+ input.closeEntry()
179
+ entry = input.nextEntry
180
+ }
181
+ }
182
+ }
183
+
184
+ /**
185
+ * Finds the JS bundle inside an extracted package. Tolerates archives that
186
+ * wrap everything in a single top-level folder, which is what most zip tools
187
+ * produce by default.
188
+ */
189
+ private fun resolveBundle(dir: File): File? {
190
+ if (!dir.isDirectory) return null
191
+
192
+ for (name in BUNDLE_CANDIDATES) {
193
+ val direct = File(dir, name)
194
+ if (direct.isFile) return direct
195
+ }
196
+
197
+ val children = dir.listFiles() ?: return null
198
+ val onlyDir = children.singleOrNull()?.takeIf { it.isDirectory }
199
+ if (onlyDir != null) {
200
+ resolveBundle(onlyDir)?.let { return it }
201
+ }
202
+
203
+ return dir.walkTopDown()
204
+ .maxDepth(4)
205
+ .firstOrNull { it.isFile && (it.name.endsWith(".bundle") || it.name.endsWith(".jsbundle")) }
206
+ ?.also { Log.i(OtaUpdateStore.TAG, "using bundle ${it.name} found at depth") }
207
+ }
208
+
209
+ private fun dirSize(dir: File): Long =
210
+ dir.walkTopDown().filter { it.isFile }.sumOf { it.length() }
211
+ }
@@ -0,0 +1,278 @@
1
+ package com.otaupdate
2
+
3
+ import android.os.Handler
4
+ import android.os.Looper
5
+ import android.util.Log
6
+ import com.facebook.react.ReactApplication
7
+ import com.facebook.react.bridge.Arguments
8
+ import com.facebook.react.bridge.LifecycleEventListener
9
+ import com.facebook.react.bridge.Promise
10
+ import com.facebook.react.bridge.ReactApplicationContext
11
+ import com.facebook.react.bridge.ReactContextBaseJavaModule
12
+ import com.facebook.react.bridge.ReactMethod
13
+ import com.facebook.react.bridge.ReadableMap
14
+ import com.facebook.react.bridge.WritableMap
15
+ import com.facebook.react.modules.core.DeviceEventManagerModule
16
+ import java.util.concurrent.Executors
17
+
18
+ class OtaUpdateModule(private val reactContext: ReactApplicationContext) :
19
+ ReactContextBaseJavaModule(reactContext), LifecycleEventListener {
20
+
21
+ companion object {
22
+ const val NAME = "OtaUpdate"
23
+ private const val PROGRESS_EVENT = "OtaUpdateDownloadProgress"
24
+
25
+ const val INSTALL_MODE_ON_NEXT_RESTART = 0
26
+ const val INSTALL_MODE_ON_NEXT_RESUME = 1
27
+ const val INSTALL_MODE_IMMEDIATE = 2
28
+ }
29
+
30
+ private val executor = Executors.newSingleThreadExecutor()
31
+ private val store: OtaUpdateStore by lazy { OtaUpdate.store(reactContext) }
32
+
33
+ /** Set when an ON_NEXT_RESUME install is waiting for the app to background. */
34
+ private var restartOnResume = false
35
+ private var minimumBackgroundDurationMs = 0L
36
+ private var backgroundedAt = 0L
37
+
38
+ init {
39
+ reactContext.addLifecycleEventListener(this)
40
+ }
41
+
42
+ override fun getName(): String = NAME
43
+
44
+ override fun getConstants(): Map<String, Any> = mapOf(
45
+ "INSTALL_MODE_ON_NEXT_RESTART" to INSTALL_MODE_ON_NEXT_RESTART,
46
+ "INSTALL_MODE_ON_NEXT_RESUME" to INSTALL_MODE_ON_NEXT_RESUME,
47
+ "INSTALL_MODE_IMMEDIATE" to INSTALL_MODE_IMMEDIATE,
48
+ )
49
+
50
+ // NativeEventEmitter requires these to exist, even though the native side
51
+ // does the subscribing itself.
52
+ @ReactMethod fun addListener(eventName: String) = Unit
53
+
54
+ @ReactMethod fun removeListeners(count: Int) = Unit
55
+
56
+ // --- Configuration --------------------------------------------------------
57
+
58
+ @ReactMethod
59
+ fun getConfiguration(promise: Promise) {
60
+ try {
61
+ val map = Arguments.createMap().apply {
62
+ putString("deploymentKey", OtaUpdate.deploymentKey(reactContext))
63
+ putString("serverUrl", OtaUpdate.serverUrl(reactContext))
64
+ putString("appVersion", OtaUpdate.appVersion(reactContext))
65
+ putString("clientUniqueId", store.clientUniqueId())
66
+ putString("packageHash", store.currentHash)
67
+ putString("label", store.packageInfo(store.currentHash)?.optString("label"))
68
+ // Read from the native runtime, not settable from JS — this is what
69
+ // makes backend bundle-identifier enforcement meaningful.
70
+ putString("bundleIdentifier", reactContext.packageName)
71
+ }
72
+ promise.resolve(map)
73
+ } catch (e: Exception) {
74
+ promise.reject("ota_config_error", e.message, e)
75
+ }
76
+ }
77
+
78
+ @ReactMethod
79
+ fun getCurrentPackage(promise: Promise) {
80
+ try {
81
+ val hash = store.currentHash
82
+ val info = store.packageInfo(hash)
83
+ if (hash == null || info == null) {
84
+ promise.resolve(null)
85
+ return
86
+ }
87
+ val map = Arguments.createMap().apply {
88
+ putString("packageHash", hash)
89
+ putString("label", info.optString("label"))
90
+ putString("description", info.optString("description").takeIf { it.isNotEmpty() })
91
+ putBoolean("isMandatory", info.optBoolean("isMandatory"))
92
+ putString("bundlePath", info.optString("bundlePath"))
93
+ putDouble("size", info.optLong("size").toDouble())
94
+ putString("appVersion", OtaUpdate.appVersion(reactContext))
95
+ putBoolean("isPending", store.isPending())
96
+ putBoolean("isFirstRun", OtaUpdateStore.isFirstRunOfUpdate())
97
+ }
98
+ promise.resolve(map)
99
+ } catch (e: Exception) {
100
+ promise.reject("ota_current_package_error", e.message, e)
101
+ }
102
+ }
103
+
104
+ @ReactMethod
105
+ fun isFirstRun(packageHash: String, promise: Promise) {
106
+ promise.resolve(OtaUpdateStore.isFirstRunOfUpdate() && store.currentHash == packageHash)
107
+ }
108
+
109
+ // --- Download -------------------------------------------------------------
110
+
111
+ @ReactMethod
112
+ fun downloadUpdate(update: ReadableMap, promise: Promise) {
113
+ val downloadUrl = update.getString("downloadUrl")
114
+ val packageHash = update.getString("packageHash")
115
+ val label = update.getString("label") ?: ""
116
+
117
+ if (downloadUrl.isNullOrEmpty() || packageHash.isNullOrEmpty()) {
118
+ promise.reject("ota_invalid_update", "downloadUrl and packageHash are required")
119
+ return
120
+ }
121
+
122
+ // Networking and unzipping must stay off the UI and JS threads.
123
+ executor.execute {
124
+ try {
125
+ val installed = OtaUpdateInstaller.install(store, downloadUrl, packageHash) { received, total ->
126
+ emitProgress(received, total)
127
+ }
128
+
129
+ store.recordPackage(
130
+ hash = packageHash,
131
+ label = label,
132
+ bundlePath = installed.bundlePath,
133
+ description = if (update.hasKey("description")) update.getString("description") else null,
134
+ isMandatory = update.hasKey("isMandatory") && update.getBoolean("isMandatory"),
135
+ size = installed.size,
136
+ appVersion = OtaUpdate.appVersion(reactContext),
137
+ )
138
+
139
+ val result = Arguments.createMap().apply {
140
+ putString("bundlePath", installed.bundlePath)
141
+ putString("packageHash", packageHash)
142
+ putString("label", label)
143
+ putDouble("size", installed.size.toDouble())
144
+ }
145
+ promise.resolve(result)
146
+ } catch (e: Exception) {
147
+ Log.e(OtaUpdateStore.TAG, "download failed", e)
148
+ promise.reject("ota_download_failed", e.message, e)
149
+ }
150
+ }
151
+ }
152
+
153
+ private fun emitProgress(received: Long, total: Long) {
154
+ if (!reactContext.hasActiveReactInstance()) return
155
+ val payload: WritableMap = Arguments.createMap().apply {
156
+ putDouble("receivedBytes", received.toDouble())
157
+ putDouble("totalBytes", total.toDouble())
158
+ }
159
+ reactContext
160
+ .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
161
+ .emit(PROGRESS_EVENT, payload)
162
+ }
163
+
164
+ // --- Install --------------------------------------------------------------
165
+
166
+ @ReactMethod
167
+ fun installUpdate(packageHash: String, installMode: Int, minimumBackgroundDuration: Int, promise: Promise) {
168
+ try {
169
+ store.markPending(packageHash)
170
+
171
+ when (installMode) {
172
+ INSTALL_MODE_IMMEDIATE -> {
173
+ promise.resolve(null)
174
+ Handler(Looper.getMainLooper()).post { reload() }
175
+ }
176
+ INSTALL_MODE_ON_NEXT_RESUME -> {
177
+ restartOnResume = true
178
+ minimumBackgroundDurationMs = minimumBackgroundDuration * 1000L
179
+ promise.resolve(null)
180
+ }
181
+ else -> {
182
+ // ON_NEXT_RESTART: nothing more to do — getJSBundleFile picks it up.
183
+ promise.resolve(null)
184
+ }
185
+ }
186
+ } catch (e: Exception) {
187
+ promise.reject("ota_install_failed", e.message, e)
188
+ }
189
+ }
190
+
191
+ @ReactMethod
192
+ fun notifyApplicationReady(promise: Promise) {
193
+ try {
194
+ store.notifyApplicationReady()
195
+ // Reclaim disk from superseded packages once we know the current one works.
196
+ executor.execute { runCatching { store.pruneOldPackages() } }
197
+ promise.resolve(null)
198
+ } catch (e: Exception) {
199
+ promise.reject("ota_notify_failed", e.message, e)
200
+ }
201
+ }
202
+
203
+ @ReactMethod
204
+ fun restartApp(onlyIfUpdateIsPending: Boolean, promise: Promise) {
205
+ if (onlyIfUpdateIsPending && !store.isPending()) {
206
+ promise.resolve(false)
207
+ return
208
+ }
209
+ promise.resolve(true)
210
+ Handler(Looper.getMainLooper()).post { reload() }
211
+ }
212
+
213
+ @ReactMethod
214
+ fun clearUpdates(promise: Promise) {
215
+ try {
216
+ store.reset()
217
+ promise.resolve(null)
218
+ } catch (e: Exception) {
219
+ promise.reject("ota_clear_failed", e.message, e)
220
+ }
221
+ }
222
+
223
+ /**
224
+ * Recreates the React context so the new bundle is loaded. Handles both the
225
+ * bridge and bridgeless (new architecture) hosts; the bridgeless path is
226
+ * reached reflectively so this module still compiles against RN 0.71.
227
+ */
228
+ private fun reload() {
229
+ val application = reactContext.applicationContext
230
+ if (application !is ReactApplication) {
231
+ Log.e(OtaUpdateStore.TAG, "Application does not implement ReactApplication — cannot restart")
232
+ return
233
+ }
234
+
235
+ try {
236
+ val reactHost = runCatching {
237
+ ReactApplication::class.java.getMethod("getReactHost").invoke(application)
238
+ }.getOrNull()
239
+
240
+ if (reactHost != null) {
241
+ reactHost.javaClass.methods
242
+ .firstOrNull { it.name == "reload" && it.parameterTypes.size == 1 }
243
+ ?.invoke(reactHost, "OTA update applied")
244
+ ?: Log.e(OtaUpdateStore.TAG, "ReactHost has no reload(String) method")
245
+ return
246
+ }
247
+
248
+ application.reactNativeHost.reactInstanceManager.recreateReactContextInBackground()
249
+ } catch (e: Exception) {
250
+ Log.e(OtaUpdateStore.TAG, "failed to restart the React context", e)
251
+ }
252
+ }
253
+
254
+ // --- ON_NEXT_RESUME wiring -----------------------------------------------
255
+
256
+ override fun onHostResume() {
257
+ if (!restartOnResume) return
258
+ val backgroundedFor = System.currentTimeMillis() - backgroundedAt
259
+ if (backgroundedAt > 0 && backgroundedFor >= minimumBackgroundDurationMs) {
260
+ restartOnResume = false
261
+ Handler(Looper.getMainLooper()).post { reload() }
262
+ }
263
+ }
264
+
265
+ override fun onHostPause() {
266
+ backgroundedAt = System.currentTimeMillis()
267
+ }
268
+
269
+ override fun onHostDestroy() {
270
+ backgroundedAt = 0
271
+ }
272
+
273
+ override fun invalidate() {
274
+ reactContext.removeLifecycleEventListener(this)
275
+ executor.shutdown()
276
+ super.invalidate()
277
+ }
278
+ }
@@ -0,0 +1,15 @@
1
+ package com.otaupdate
2
+
3
+ import com.facebook.react.ReactPackage
4
+ import com.facebook.react.bridge.NativeModule
5
+ import com.facebook.react.bridge.ReactApplicationContext
6
+ import com.facebook.react.uimanager.ViewManager
7
+
8
+ /** Registered automatically by React Native autolinking. */
9
+ class OtaUpdatePackage : ReactPackage {
10
+ override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> =
11
+ listOf(OtaUpdateModule(reactContext))
12
+
13
+ override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> =
14
+ emptyList()
15
+ }