@sigx/lynx-updates 0.6.1

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 (53) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +162 -0
  3. package/android/com/sigx/updates/UpdateDownloader.kt +154 -0
  4. package/android/com/sigx/updates/UpdateStore.kt +367 -0
  5. package/android/com/sigx/updates/UpdatesActivityHook.kt +25 -0
  6. package/android/com/sigx/updates/UpdatesBundleResolver.kt +18 -0
  7. package/android/com/sigx/updates/UpdatesEventBus.kt +54 -0
  8. package/android/com/sigx/updates/UpdatesLifecyclePublisher.kt +42 -0
  9. package/android/com/sigx/updates/UpdatesModule.kt +235 -0
  10. package/dist/controller.d.ts +31 -0
  11. package/dist/controller.d.ts.map +1 -0
  12. package/dist/controller.js +344 -0
  13. package/dist/controller.js.map +1 -0
  14. package/dist/events.d.ts +18 -0
  15. package/dist/events.d.ts.map +1 -0
  16. package/dist/events.js +61 -0
  17. package/dist/events.js.map +1 -0
  18. package/dist/index.d.ts +5 -0
  19. package/dist/index.d.ts.map +1 -0
  20. package/dist/index.js +5 -0
  21. package/dist/index.js.map +1 -0
  22. package/dist/native.d.ts +41 -0
  23. package/dist/native.d.ts.map +1 -0
  24. package/dist/native.js +161 -0
  25. package/dist/native.js.map +1 -0
  26. package/dist/provider/static-manifest.d.ts +66 -0
  27. package/dist/provider/static-manifest.d.ts.map +1 -0
  28. package/dist/provider/static-manifest.js +173 -0
  29. package/dist/provider/static-manifest.js.map +1 -0
  30. package/dist/state.d.ts +23 -0
  31. package/dist/state.d.ts.map +1 -0
  32. package/dist/state.js +73 -0
  33. package/dist/state.js.map +1 -0
  34. package/dist/types.d.ts +203 -0
  35. package/dist/types.d.ts.map +1 -0
  36. package/dist/types.js +12 -0
  37. package/dist/types.js.map +1 -0
  38. package/dist/updates.d.ts +45 -0
  39. package/dist/updates.d.ts.map +1 -0
  40. package/dist/updates.js +67 -0
  41. package/dist/updates.js.map +1 -0
  42. package/dist/use-updates.d.ts +16 -0
  43. package/dist/use-updates.d.ts.map +1 -0
  44. package/dist/use-updates.js +29 -0
  45. package/dist/use-updates.js.map +1 -0
  46. package/ios/UpdateDownloader.swift +152 -0
  47. package/ios/UpdateStore.swift +286 -0
  48. package/ios/UpdatesBundleResolver.swift +15 -0
  49. package/ios/UpdatesEventBus.swift +59 -0
  50. package/ios/UpdatesLifecyclePublisher.swift +48 -0
  51. package/ios/UpdatesModule.swift +178 -0
  52. package/package.json +59 -0
  53. package/signalx-module.json +35 -0
@@ -0,0 +1,367 @@
1
+ package com.sigx.updates
2
+
3
+ import android.content.Context
4
+ import android.content.pm.PackageManager
5
+ import android.os.Build
6
+ import android.util.Log
7
+ import com.lynx.tasm.LynxView
8
+ import org.json.JSONObject
9
+ import java.io.File
10
+ import java.io.FileOutputStream
11
+ import java.lang.ref.WeakReference
12
+ import java.security.MessageDigest
13
+
14
+ /**
15
+ * On-disk update store + state machine shared by [UpdatesBundleResolver]
16
+ * (startup, before any LynxView exists) and [UpdatesModule] (JS bridge).
17
+ *
18
+ * Layout under `filesDir/sigx-updates/`:
19
+ * state.json — single source of truth (atomic writes)
20
+ * updates/<updateId>/main.lynx.bundle + update.json
21
+ * tmp/ — in-flight downloads; wiped at startup
22
+ *
23
+ * Update lifecycle: downloaded (dir exists) → pending (state.pendingUpdateId)
24
+ * → committed (state.currentUpdateId). A pending update that exhausts its
25
+ * launch attempts before JS calls markReady() is rolled back and deleted.
26
+ */
27
+ object UpdateStore {
28
+
29
+ private const val TAG = "SigxUpdates"
30
+ private const val DIR = "sigx-updates"
31
+ private const val SCHEMA_VERSION = 1
32
+ private const val DEFAULT_MAX_LAUNCH_ATTEMPTS = 2
33
+ private const val RUNTIME_VERSION_META_KEY = "com.sigx.updates.RUNTIME_VERSION"
34
+
35
+ /** What this process actually loaded (set by the resolver). */
36
+ @Volatile var launchedUpdateId: String? = null
37
+ private set
38
+ /** True when this launch is a pending update's trial run. */
39
+ @Volatile var isFirstLaunchAfterUpdate: Boolean = false
40
+ private set
41
+ /** True when the resolver rolled back at this startup. */
42
+ @Volatile var didRollBack: Boolean = false
43
+ private set
44
+ /** Update id that was rolled back at this startup (for events/info). */
45
+ @Volatile var rolledBackUpdateId: String? = null
46
+ private set
47
+
48
+ /** Last-attached LynxView — apply-now reload target. */
49
+ @Volatile private var lynxViewRef: WeakReference<LynxView>? = null
50
+
51
+ fun attachView(view: LynxView) {
52
+ lynxViewRef = WeakReference(view)
53
+ }
54
+
55
+ fun currentView(): LynxView? = lynxViewRef?.get()
56
+
57
+ // ── Paths ────────────────────────────────────────────────────────────
58
+
59
+ fun rootDir(context: Context): File = File(context.filesDir, DIR)
60
+ fun updatesDir(context: Context): File = File(rootDir(context), "updates")
61
+ fun tmpDir(context: Context): File = File(rootDir(context), "tmp")
62
+ fun updateDir(context: Context, updateId: String): File = File(updatesDir(context), updateId)
63
+ fun bundleFile(context: Context, updateId: String): File =
64
+ File(updateDir(context, updateId), "main.lynx.bundle")
65
+ fun updateJsonFile(context: Context, updateId: String): File =
66
+ File(updateDir(context, updateId), "update.json")
67
+ private fun stateFile(context: Context): File = File(rootDir(context), "state.json")
68
+
69
+ // ── Binary identity ──────────────────────────────────────────────────
70
+
71
+ /** The binary's runtime fingerprint, injected by `sigx prebuild` as manifest meta-data. */
72
+ fun installedRuntimeVersion(context: Context): String {
73
+ return try {
74
+ val ai = context.packageManager.getApplicationInfo(
75
+ context.packageName, PackageManager.GET_META_DATA)
76
+ ai.metaData?.getString(RUNTIME_VERSION_META_KEY) ?: "unknown"
77
+ } catch (e: Exception) {
78
+ Log.w(TAG, "Could not read runtime version meta-data: ${e.message}")
79
+ "unknown"
80
+ }
81
+ }
82
+
83
+ fun installedBinaryVersion(context: Context): String {
84
+ return try {
85
+ val info = context.packageManager.getPackageInfo(context.packageName, 0)
86
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
87
+ info.longVersionCode.toString()
88
+ } else {
89
+ @Suppress("DEPRECATION")
90
+ info.versionCode.toString()
91
+ }
92
+ } catch (e: Exception) {
93
+ "unknown"
94
+ }
95
+ }
96
+
97
+ // ── State ────────────────────────────────────────────────────────────
98
+
99
+ data class State(
100
+ var installedRuntimeVersion: String = "",
101
+ var installedBinaryVersion: String = "",
102
+ var currentUpdateId: String? = null,
103
+ var previousUpdateId: String? = null,
104
+ var pendingUpdateId: String? = null,
105
+ var pendingLaunchAttempts: Int = 0,
106
+ var maxLaunchAttempts: Int = DEFAULT_MAX_LAUNCH_ATTEMPTS,
107
+ var lastRollbackUpdateId: String? = null,
108
+ var lastRollbackReason: String? = null,
109
+ )
110
+
111
+ @Synchronized
112
+ fun readState(context: Context): State? {
113
+ val file = stateFile(context)
114
+ if (!file.exists()) return null
115
+ return try {
116
+ val json = JSONObject(file.readText())
117
+ State(
118
+ installedRuntimeVersion = json.optString("installedRuntimeVersion", ""),
119
+ installedBinaryVersion = json.optString("installedBinaryVersion", ""),
120
+ currentUpdateId = json.optString("currentUpdateId").ifEmpty { null },
121
+ previousUpdateId = json.optString("previousUpdateId").ifEmpty { null },
122
+ pendingUpdateId = json.optString("pendingUpdateId").ifEmpty { null },
123
+ pendingLaunchAttempts = json.optInt("pendingLaunchAttempts", 0),
124
+ maxLaunchAttempts = json.optInt("maxLaunchAttempts", DEFAULT_MAX_LAUNCH_ATTEMPTS),
125
+ lastRollbackUpdateId = json.optString("lastRollbackUpdateId").ifEmpty { null },
126
+ lastRollbackReason = json.optString("lastRollbackReason").ifEmpty { null },
127
+ )
128
+ } catch (e: Exception) {
129
+ Log.w(TAG, "state.json unreadable: ${e.message}")
130
+ null
131
+ }
132
+ }
133
+
134
+ /** Atomic + fsynced write — the launch-attempt counter must survive a crash. */
135
+ @Synchronized
136
+ fun writeState(context: Context, state: State) {
137
+ val json = JSONObject()
138
+ .put("schemaVersion", SCHEMA_VERSION)
139
+ .put("installedRuntimeVersion", state.installedRuntimeVersion)
140
+ .put("installedBinaryVersion", state.installedBinaryVersion)
141
+ .put("currentUpdateId", state.currentUpdateId ?: "")
142
+ .put("previousUpdateId", state.previousUpdateId ?: "")
143
+ .put("pendingUpdateId", state.pendingUpdateId ?: "")
144
+ .put("pendingLaunchAttempts", state.pendingLaunchAttempts)
145
+ .put("maxLaunchAttempts", state.maxLaunchAttempts)
146
+ .put("lastRollbackUpdateId", state.lastRollbackUpdateId ?: "")
147
+ .put("lastRollbackReason", state.lastRollbackReason ?: "")
148
+ val file = stateFile(context)
149
+ file.parentFile?.mkdirs()
150
+ val tmp = File(file.parentFile, "state.json.tmp")
151
+ FileOutputStream(tmp).use { out ->
152
+ out.write(json.toString().toByteArray(Charsets.UTF_8))
153
+ out.fd.sync()
154
+ }
155
+ if (!tmp.renameTo(file)) {
156
+ // Windows-style rename-over-existing failure can't happen on
157
+ // Android (POSIX rename), but belt-and-braces:
158
+ file.delete()
159
+ tmp.renameTo(file)
160
+ }
161
+ }
162
+
163
+ /** Fresh state seeded with the binary's identity. */
164
+ fun freshState(context: Context): State = State(
165
+ installedRuntimeVersion = installedRuntimeVersion(context),
166
+ installedBinaryVersion = installedBinaryVersion(context),
167
+ )
168
+
169
+ // ── Startup resolution (called by UpdatesBundleResolver) ─────────────
170
+
171
+ /**
172
+ * Decide which bundle this launch loads. Returns an absolute path to an
173
+ * OTA bundle, or null to use the baked asset. Mutates rollback state —
174
+ * call exactly once per process launch.
175
+ */
176
+ @Synchronized
177
+ fun resolveStartupBundlePath(context: Context): String? {
178
+ try {
179
+ tmpDir(context).deleteRecursively()
180
+
181
+ var state = readState(context)
182
+ if (state == null) {
183
+ if (rootDir(context).exists() && stateFile(context).exists()) {
184
+ // Unparseable state — wipe everything, run baked.
185
+ Log.w(TAG, "Corrupt state.json — clearing all updates")
186
+ rootDir(context).deleteRecursively()
187
+ }
188
+ return null
189
+ }
190
+
191
+ // Binary-update tripwire: a store update (or any reinstall that
192
+ // changed the fingerprint/versionCode) invalidates every
193
+ // downloaded update — they were published for the old runtime.
194
+ val runtimeNow = installedRuntimeVersion(context)
195
+ val binaryNow = installedBinaryVersion(context)
196
+ if (state.installedRuntimeVersion != runtimeNow ||
197
+ state.installedBinaryVersion != binaryNow
198
+ ) {
199
+ Log.i(TAG, "Binary changed (runtime ${state.installedRuntimeVersion} -> $runtimeNow, " +
200
+ "build ${state.installedBinaryVersion} -> $binaryNow) — clearing updates")
201
+ updatesDir(context).deleteRecursively()
202
+ writeState(context, freshState(context))
203
+ return null
204
+ }
205
+
206
+ // Pending update: crash-guarded trial launch.
207
+ val pending = state.pendingUpdateId
208
+ if (pending != null) {
209
+ if (state.pendingLaunchAttempts >= state.maxLaunchAttempts) {
210
+ Log.w(TAG, "Update $pending failed ${state.pendingLaunchAttempts} launches — rolling back")
211
+ rollbackPending(context, state, "crash")
212
+ state = readState(context) ?: return null
213
+ } else {
214
+ val bundle = bundleFile(context, pending)
215
+ val firstAttempt = state.pendingLaunchAttempts == 0
216
+ val ok = bundle.exists() && (!firstAttempt || verifySha256(context, pending))
217
+ if (!ok) {
218
+ Log.w(TAG, "Update $pending missing or corrupt — rolling back")
219
+ rollbackPending(context, state, "corrupt")
220
+ state = readState(context) ?: return null
221
+ } else {
222
+ state.pendingLaunchAttempts += 1
223
+ writeState(context, state)
224
+ launchedUpdateId = pending
225
+ isFirstLaunchAfterUpdate = firstAttempt
226
+ sweepOrphans(context, state)
227
+ return bundle.absolutePath
228
+ }
229
+ }
230
+ }
231
+
232
+ // Committed update: trusted (existence check only).
233
+ val current = state.currentUpdateId
234
+ if (current != null) {
235
+ val bundle = bundleFile(context, current)
236
+ if (bundle.exists()) {
237
+ launchedUpdateId = current
238
+ sweepOrphans(context, state)
239
+ return bundle.absolutePath
240
+ }
241
+ Log.w(TAG, "Committed update $current missing on disk — reverting to baked bundle")
242
+ state.currentUpdateId = null
243
+ writeState(context, state)
244
+ }
245
+
246
+ sweepOrphans(context, state)
247
+ return null
248
+ } catch (e: Exception) {
249
+ // Any unexpected failure must never take the app down with it —
250
+ // the baked bundle is always the safe answer.
251
+ Log.e(TAG, "resolveStartupBundlePath failed: ${e.message}")
252
+ return null
253
+ }
254
+ }
255
+
256
+ private fun rollbackPending(context: Context, state: State, reason: String) {
257
+ val pending = state.pendingUpdateId ?: return
258
+ didRollBack = true
259
+ rolledBackUpdateId = pending
260
+ updateDir(context, pending).deleteRecursively()
261
+ state.lastRollbackUpdateId = pending
262
+ state.lastRollbackReason = reason
263
+ state.pendingUpdateId = null
264
+ state.pendingLaunchAttempts = 0
265
+ writeState(context, state)
266
+ }
267
+
268
+ /** Delete update dirs not referenced by state (≤3 may remain by invariant). */
269
+ private fun sweepOrphans(context: Context, state: State) {
270
+ val keep = setOfNotNull(state.currentUpdateId, state.previousUpdateId, state.pendingUpdateId)
271
+ updatesDir(context).listFiles()?.forEach { dir ->
272
+ if (dir.isDirectory && dir.name !in keep) {
273
+ dir.deleteRecursively()
274
+ }
275
+ }
276
+ }
277
+
278
+ // ── Transitions (called by UpdatesModule) ─────────────────────────────
279
+
280
+ /** Stage a downloaded update to load on the next launch. */
281
+ @Synchronized
282
+ fun stagePending(context: Context, updateId: String): String? {
283
+ if (!bundleFile(context, updateId).exists()) {
284
+ return "Update $updateId is not on disk"
285
+ }
286
+ val state = readState(context) ?: freshState(context)
287
+ if (state.currentUpdateId == updateId) return null // already active
288
+ state.pendingUpdateId = updateId
289
+ state.pendingLaunchAttempts = 0
290
+ writeState(context, state)
291
+ return null
292
+ }
293
+
294
+ /**
295
+ * Record a launch attempt for an in-place reload (applyNow). The reload
296
+ * doesn't go through the resolver, so the crash guard is armed here.
297
+ */
298
+ @Synchronized
299
+ fun recordReloadAttempt(context: Context, updateId: String): String? {
300
+ val state = readState(context) ?: freshState(context)
301
+ if (state.pendingUpdateId != updateId) {
302
+ state.pendingUpdateId = updateId
303
+ }
304
+ state.pendingLaunchAttempts += 1
305
+ writeState(context, state)
306
+ launchedUpdateId = updateId
307
+ isFirstLaunchAfterUpdate = true
308
+ return null
309
+ }
310
+
311
+ /** Commit the running pending update as healthy. Idempotent. */
312
+ @Synchronized
313
+ fun markReady(context: Context) {
314
+ val state = readState(context) ?: return
315
+ val pending = state.pendingUpdateId ?: return
316
+ if (launchedUpdateId != pending) return // pending staged but not yet launched
317
+ // previous → deleted, current → previous, pending → current
318
+ state.previousUpdateId?.let { updateDir(context, it).deleteRecursively() }
319
+ state.previousUpdateId = state.currentUpdateId
320
+ state.currentUpdateId = pending
321
+ state.pendingUpdateId = null
322
+ state.pendingLaunchAttempts = 0
323
+ writeState(context, state)
324
+ Log.i(TAG, "Update $pending committed")
325
+ }
326
+
327
+ @Synchronized
328
+ fun setMaxLaunchAttempts(context: Context, max: Int) {
329
+ val state = readState(context) ?: freshState(context)
330
+ state.maxLaunchAttempts = max.coerceIn(1, 10)
331
+ writeState(context, state)
332
+ }
333
+
334
+ @Synchronized
335
+ fun clearAll(context: Context) {
336
+ updatesDir(context).deleteRecursively()
337
+ tmpDir(context).deleteRecursively()
338
+ writeState(context, freshState(context))
339
+ }
340
+
341
+ // ── Helpers ──────────────────────────────────────────────────────────
342
+
343
+ /** Re-hash the stored bundle against the sha256 recorded in update.json. */
344
+ fun verifySha256(context: Context, updateId: String): Boolean {
345
+ return try {
346
+ val meta = JSONObject(updateJsonFile(context, updateId).readText())
347
+ val expected = meta.optString("sha256").lowercase()
348
+ if (expected.isEmpty()) return false
349
+ sha256Of(bundleFile(context, updateId)) == expected
350
+ } catch (e: Exception) {
351
+ false
352
+ }
353
+ }
354
+
355
+ fun sha256Of(file: File): String {
356
+ val digest = MessageDigest.getInstance("SHA-256")
357
+ file.inputStream().use { input ->
358
+ val buffer = ByteArray(64 * 1024)
359
+ while (true) {
360
+ val read = input.read(buffer)
361
+ if (read < 0) break
362
+ digest.update(buffer, 0, read)
363
+ }
364
+ }
365
+ return digest.digest().joinToString("") { "%02x".format(it) }
366
+ }
367
+ }
@@ -0,0 +1,25 @@
1
+ package com.sigx.updates
2
+
3
+ import android.app.Activity
4
+
5
+ /**
6
+ * Activity hook (declared in signalx-module.json) that turns resume-after-
7
+ * pause into a `foreground` event for JS `checkOn: ['foreground']` re-checks.
8
+ * The first onResume of a launch is swallowed — cold start is covered by
9
+ * the `launch` trigger and would otherwise double-check.
10
+ */
11
+ object UpdatesActivityHook {
12
+
13
+ private var sawPause = false
14
+
15
+ fun onResume(activity: Activity) {
16
+ if (sawPause) {
17
+ sawPause = false
18
+ UpdatesEventBus.emitForeground()
19
+ }
20
+ }
21
+
22
+ fun onPause(activity: Activity) {
23
+ sawPause = true
24
+ }
25
+ }
@@ -0,0 +1,18 @@
1
+ package com.sigx.updates
2
+
3
+ import android.content.Context
4
+
5
+ /**
6
+ * Startup bundle resolver — the host's `GeneratedBundleResolver` delegates
7
+ * here (declared as `android.bundleResolverClass` in signalx-module.json).
8
+ *
9
+ * Runs synchronously in MainActivity.onCreate BEFORE any LynxView is built,
10
+ * and mutates rollback state (the launch-attempt counter), so it must be
11
+ * called exactly once per process launch — which the generated host
12
+ * guarantees.
13
+ */
14
+ object UpdatesBundleResolver {
15
+
16
+ fun resolveStartupBundlePath(context: Context): String? =
17
+ UpdateStore.resolveStartupBundlePath(context)
18
+ }
@@ -0,0 +1,54 @@
1
+ package com.sigx.updates
2
+
3
+ import com.lynx.react.bridge.JavaOnlyMap
4
+ import java.util.UUID
5
+ import java.util.concurrent.ConcurrentHashMap
6
+
7
+ /**
8
+ * Module → publisher event bus (the `BackgroundEventBus` pattern). The
9
+ * module emits download progress / foreground events here; the per-LynxView
10
+ * [UpdatesLifecyclePublisher] pumps them into JS via `sendGlobalEvent` on
11
+ * the `__sigxUpdatesEvent` channel.
12
+ */
13
+ object UpdatesEventBus {
14
+
15
+ const val CHANNEL = "__sigxUpdatesEvent"
16
+
17
+ private val listeners = ConcurrentHashMap<UUID, (JavaOnlyMap) -> Unit>()
18
+
19
+ fun addListener(listener: (JavaOnlyMap) -> Unit): UUID {
20
+ val token = UUID.randomUUID()
21
+ listeners[token] = listener
22
+ return token
23
+ }
24
+
25
+ fun removeListener(token: UUID) {
26
+ listeners.remove(token)
27
+ }
28
+
29
+ fun emit(payload: JavaOnlyMap) {
30
+ for (listener in listeners.values) {
31
+ try {
32
+ listener(payload)
33
+ } catch (_: Throwable) {
34
+ // A broken publisher must not break the others.
35
+ }
36
+ }
37
+ }
38
+
39
+ fun emitProgress(receivedBytes: Long, totalBytes: Long?) {
40
+ val map = JavaOnlyMap()
41
+ map.putString("kind", "progress")
42
+ map.putDouble("receivedBytes", receivedBytes.toDouble())
43
+ if (totalBytes != null && totalBytes >= 0) {
44
+ map.putDouble("totalBytes", totalBytes.toDouble())
45
+ }
46
+ emit(map)
47
+ }
48
+
49
+ fun emitForeground() {
50
+ val map = JavaOnlyMap()
51
+ map.putString("kind", "foreground")
52
+ emit(map)
53
+ }
54
+ }
@@ -0,0 +1,42 @@
1
+ package com.sigx.updates
2
+
3
+ import android.util.Log
4
+ import com.lynx.react.bridge.JavaOnlyArray
5
+ import com.lynx.react.bridge.JavaOnlyMap
6
+ import com.lynx.tasm.LynxView
7
+ import java.util.UUID
8
+
9
+ /**
10
+ * Per-LynxView publisher: pumps [UpdatesEventBus] payloads into JS
11
+ * (`__sigxUpdatesEvent`) and registers the view with [UpdateStore] so
12
+ * `applyNow` has a reload target. Instantiated by the generated
13
+ * `GeneratedLifecyclePublishers.attachAll(lynxView)`.
14
+ */
15
+ class UpdatesLifecyclePublisher(private val lynxView: LynxView) {
16
+
17
+ private var token: UUID? = null
18
+
19
+ fun attach() {
20
+ UpdateStore.attachView(lynxView)
21
+ token = UpdatesEventBus.addListener { payload -> publish(payload) }
22
+ }
23
+
24
+ fun detach() {
25
+ token?.let { UpdatesEventBus.removeListener(it) }
26
+ token = null
27
+ }
28
+
29
+ private fun publish(payload: JavaOnlyMap) {
30
+ try {
31
+ val params = JavaOnlyArray()
32
+ params.pushMap(payload)
33
+ lynxView.sendGlobalEvent(UpdatesEventBus.CHANNEL, params)
34
+ } catch (e: Throwable) {
35
+ Log.w(TAG, "publish failed: ${e.message}")
36
+ }
37
+ }
38
+
39
+ private companion object {
40
+ const val TAG = "SigxUpdates"
41
+ }
42
+ }