native-update 1.4.9 → 3.1.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 (265) hide show
  1. package/Readme.md +35 -9
  2. package/android/manifest-additions.xml +28 -0
  3. package/android/src/main/AndroidManifest.xml +3 -7
  4. package/android/src/main/java/com/aoneahsan/nativeupdate/BackgroundNotificationManager.kt +21 -13
  5. package/android/src/main/java/com/aoneahsan/nativeupdate/BackgroundUpdatePlugin.kt +15 -0
  6. package/android/src/main/java/com/aoneahsan/nativeupdate/BackgroundUpdateWorker.kt +23 -7
  7. package/android/src/main/java/com/aoneahsan/nativeupdate/LiveUpdatePlugin.kt +346 -31
  8. package/android/src/main/java/com/aoneahsan/nativeupdate/NativeUpdatePlugin.kt +32 -34
  9. package/android/src/main/java/com/aoneahsan/nativeupdate/NotificationActionReceiver.kt +10 -1
  10. package/android/src/main/java/com/aoneahsan/nativeupdate/SecurityManager.kt +18 -18
  11. package/cli/AGENTS.md +43 -0
  12. package/cli/CLAUDE.md +65 -0
  13. package/cli/commands/backend-create.js +56 -15
  14. package/cli/commands/bundle-create.js +39 -7
  15. package/cli/commands/monitor.js +72 -51
  16. package/cli/index.js +10 -24
  17. package/cli/package.json +11 -3
  18. package/dist/esm/app-review/app-review-manager.js +0 -1
  19. package/dist/esm/app-review/index.js +0 -1
  20. package/dist/esm/app-review/platform-review-handler.js +0 -1
  21. package/dist/esm/app-review/review-conditions-checker.js +0 -1
  22. package/dist/esm/app-review/review-rate-limiter.js +0 -1
  23. package/dist/esm/app-review/types.js +0 -1
  24. package/dist/esm/app-update/app-update-checker.js +0 -1
  25. package/dist/esm/app-update/app-update-installer.d.ts +1 -0
  26. package/dist/esm/app-update/app-update-installer.js +19 -8
  27. package/dist/esm/app-update/app-update-manager.js +0 -1
  28. package/dist/esm/app-update/app-update-notifier.js +0 -1
  29. package/dist/esm/app-update/index.js +0 -1
  30. package/dist/esm/app-update/platform-app-update.js +12 -3
  31. package/dist/esm/app-update/types.js +0 -1
  32. package/dist/esm/background-update/background-scheduler.js +0 -1
  33. package/dist/esm/background-update/index.js +0 -1
  34. package/dist/esm/background-update/notification-manager.js +0 -1
  35. package/dist/esm/config/support.js +0 -1
  36. package/dist/esm/core/analytics.d.ts +13 -0
  37. package/dist/esm/core/analytics.js +15 -1
  38. package/dist/esm/core/cache-manager.js +0 -1
  39. package/dist/esm/core/config.d.ts +6 -30
  40. package/dist/esm/core/config.js +1 -8
  41. package/dist/esm/core/errors.d.ts +1 -0
  42. package/dist/esm/core/errors.js +1 -1
  43. package/dist/esm/core/event-emitter.d.ts +0 -3
  44. package/dist/esm/core/event-emitter.js +8 -3
  45. package/dist/esm/core/logger.js +0 -1
  46. package/dist/esm/core/performance.js +0 -1
  47. package/dist/esm/core/plugin-manager.js +3 -1
  48. package/dist/esm/core/security.d.ts +22 -3
  49. package/dist/esm/core/security.js +46 -7
  50. package/dist/esm/definitions.d.ts +13 -40
  51. package/dist/esm/definitions.js +0 -1
  52. package/dist/esm/index.d.ts +3 -4
  53. package/dist/esm/index.js +3 -3
  54. package/dist/esm/live-update/bundle-manager.d.ts +8 -0
  55. package/dist/esm/live-update/bundle-manager.js +20 -1
  56. package/dist/esm/live-update/certificate-pinning.js +0 -1
  57. package/dist/esm/live-update/delta-processor.d.ts +1 -1
  58. package/dist/esm/live-update/delta-processor.js +3 -1
  59. package/dist/esm/live-update/download-manager.d.ts +36 -5
  60. package/dist/esm/live-update/download-manager.js +70 -24
  61. package/dist/esm/live-update/rollout-checker.d.ts +1 -1
  62. package/dist/esm/live-update/rollout-checker.js +5 -6
  63. package/dist/esm/live-update/update-manager.d.ts +12 -1
  64. package/dist/esm/live-update/update-manager.js +38 -11
  65. package/dist/esm/live-update/version-manager.d.ts +9 -12
  66. package/dist/esm/live-update/version-manager.js +40 -68
  67. package/dist/esm/plugin.js +82 -89
  68. package/dist/esm/security/crypto.js +11 -7
  69. package/dist/esm/security/validator.js +0 -1
  70. package/dist/esm/types/rollout.d.ts +85 -0
  71. package/dist/esm/types/rollout.js +25 -0
  72. package/dist/esm/web.d.ts +26 -1
  73. package/dist/esm/web.js +214 -75
  74. package/dist/plugin.cjs.js +1 -2
  75. package/dist/plugin.esm.js +1 -2
  76. package/dist/plugin.js +2 -3
  77. package/docs/AGENTS.md +38 -0
  78. package/docs/CHANGELOG.md +275 -0
  79. package/docs/CLAUDE.md +101 -0
  80. package/docs/MANUAL-TASKS.md +17 -0
  81. package/docs/MARKETING_WEBSITE_TRACKER.md +18 -2
  82. package/docs/MIGRATION.md +170 -0
  83. package/docs/PACKAGES.md +167 -0
  84. package/docs/PERFORMANCE.md +110 -0
  85. package/docs/PROJECT_COMPLETION_TRACKER.md +4 -35
  86. package/docs/README.md +12 -2
  87. package/docs/REMAINING_FEATURES.md +123 -76
  88. package/docs/ROADMAP.md +200 -120
  89. package/docs/SECURITY-IF-REPO-PUBLIC.md +232 -0
  90. package/docs/TESTING_REQUIREMENTS.md +1 -112
  91. package/docs/ai-knowledge-base/00-project-profile.md +38 -0
  92. package/docs/ai-knowledge-base/01-features.md +46 -0
  93. package/docs/ai-knowledge-base/02-routes.md +72 -0
  94. package/docs/ai-knowledge-base/03-tech-stack.md +57 -0
  95. package/docs/ai-knowledge-base/README.md +23 -0
  96. package/docs/analytics-tracking.md +37 -0
  97. package/docs/capacitor-official-plugins.md +46 -0
  98. package/docs/capawesome-plugins.md +29 -0
  99. package/docs/deployment/HOSTINGER_DEPLOY.md +370 -0
  100. package/docs/deployment/PRODUCTION-DEPLOY-CHECKLIST.md +184 -0
  101. package/docs/docs-site/plan.md +201 -0
  102. package/docs/docs-site/scope.md +127 -0
  103. package/docs/docs-site/tracker.json +235 -0
  104. package/docs/error-handling-tracking.md +37 -0
  105. package/docs/features/laravel-nova-backend/ASSESSMENT-SUMMARY.md +96 -0
  106. package/docs/features/laravel-nova-backend/IMPLEMENTATION-PLAN.md +504 -0
  107. package/docs/features/laravel-nova-backend/progress-tracker.json +184 -0
  108. package/docs/guides/testing-guide.md +15 -41
  109. package/docs/package-audit.md +41 -0
  110. package/docs/play-store/ASO-METADATA.md +5 -0
  111. package/docs/play-store/DATA-SAFETY.md +5 -0
  112. package/docs/play-store/DECLARATIONS.md +5 -0
  113. package/docs/play-store/README.md +27 -0
  114. package/docs/play-store/RELEASE-NOTES.md +99 -0
  115. package/docs/play-store/RELEASE-STEPS.md +6 -0
  116. package/docs/play-store/STORE-LISTING.md +7 -0
  117. package/docs/play-store/SUBMISSION-CHECKLIST.md +5 -0
  118. package/docs/production-readiness.md +1 -1
  119. package/docs/project-audit/2026-05-16/00-FINALIZATION-PLAN.md +123 -0
  120. package/docs/project-audit/2026-05-16/01-BATCH-PLAN.md +227 -0
  121. package/docs/project-audit/2026-05-16/02-AUDIT-FINDINGS.md +144 -0
  122. package/docs/project-audit/2026-05-16/sessions/batch-01.md +42 -0
  123. package/docs/project-audit/2026-05-16/sessions/batch-02.md +74 -0
  124. package/docs/project-audit/2026-05-16/sessions/batch-03.md +71 -0
  125. package/docs/project-audit/2026-05-16/sessions/batch-04.md +88 -0
  126. package/docs/project-audit/2026-05-16/sessions/batch-05.md +63 -0
  127. package/docs/project-audit/2026-05-16/sessions/batch-06.md +103 -0
  128. package/docs/project-audit/2026-05-16/sessions/batch-07.md +61 -0
  129. package/docs/project-audit/2026-05-16/sessions/batch-08.md +101 -0
  130. package/docs/project-audit/2026-05-16/sessions/batch-09.md +105 -0
  131. package/docs/project-audit/2026-05-16/sessions/batch-10.md +102 -0
  132. package/docs/project-audit/AUDIT-HISTORY.md +74 -0
  133. package/docs/project-audit/BATCH-PLAN.md +168 -0
  134. package/docs/project-audit/MARKETING-WEBSITE-FINDINGS-2026-05-11.md +73 -0
  135. package/docs/project-audit/PRODUCTION-READINESS-AUDIT-2026-05-08.md +275 -0
  136. package/docs/project-finalization/00-tracker.json +60 -0
  137. package/docs/project-knowledge-base/01-system-overview.md +227 -0
  138. package/docs/project-knowledge-base/02-routes-pages-forms-users.md +352 -0
  139. package/docs/project-knowledge-base/03-tech-stack-modules-services.md +379 -0
  140. package/docs/project-knowledge-base/04-data-models-integrations.md +244 -0
  141. package/docs/project-knowledge-base/05-docs-corpus-inventory.md +256 -0
  142. package/docs/project-knowledge-base/06-operations-testing-legal-content.md +208 -0
  143. package/docs/project-knowledge-base/README.md +103 -0
  144. package/docs/project-profiles/native-update-capacitor-update-platform-project-profile-last-updated-2026-05-19.md +81 -0
  145. package/docs/rules-tracker.md +102 -0
  146. package/docs/security/PRE-LAUNCH-PENTEST-CHECKLIST.md +116 -0
  147. package/docs/seo-aeo-rules.json +3043 -0
  148. package/docs/social-media-marketing/README.md +81 -0
  149. package/docs/social-media-marketing/posts-batch-01.md +613 -0
  150. package/docs/social-media-marketing/posts-batch-02.md +528 -0
  151. package/docs/social-media-marketing/whatsapp-messages-batch-01.md +458 -0
  152. package/docs/social-media-marketing/whatsapp-status-batch-01.md +355 -0
  153. package/docs/tracking/finalization-2026-05-16-tracker.json +346 -0
  154. package/docs/tracking/full-audit-tracker.json +121 -0
  155. package/docs/tracking/production-readiness-audit-tracker.json +429 -0
  156. package/docs/tracking/seo-checklist-tracker.json +333 -0
  157. package/ios/Plugin/BackgroundUpdate/BackgroundUpdatePlugin.swift +66 -9
  158. package/ios/Plugin/LiveUpdate/LiveUpdatePlugin.swift +306 -52
  159. package/ios/Plugin/NativeUpdatePlugin.swift +22 -9
  160. package/ios/Plugin/Security/SecurityManager.swift +13 -14
  161. package/package.json +37 -38
  162. package/cli/node_modules/.yarn-integrity +0 -16
  163. package/cli/node_modules/commander/LICENSE +0 -22
  164. package/cli/node_modules/commander/Readme.md +0 -1148
  165. package/cli/node_modules/commander/esm.mjs +0 -16
  166. package/cli/node_modules/commander/index.js +0 -26
  167. package/cli/node_modules/commander/lib/argument.js +0 -145
  168. package/cli/node_modules/commander/lib/command.js +0 -2179
  169. package/cli/node_modules/commander/lib/error.js +0 -43
  170. package/cli/node_modules/commander/lib/help.js +0 -462
  171. package/cli/node_modules/commander/lib/option.js +0 -329
  172. package/cli/node_modules/commander/lib/suggestSimilar.js +0 -100
  173. package/cli/node_modules/commander/package-support.json +0 -16
  174. package/cli/node_modules/commander/package.json +0 -80
  175. package/cli/node_modules/commander/typings/esm.d.mts +0 -3
  176. package/cli/node_modules/commander/typings/index.d.ts +0 -884
  177. package/cli/yarn.lock +0 -8
  178. package/dist/esm/__tests__/bundle-manager.test.d.ts +0 -1
  179. package/dist/esm/__tests__/bundle-manager.test.js +0 -151
  180. package/dist/esm/__tests__/bundle-manager.test.js.map +0 -1
  181. package/dist/esm/__tests__/config.test.d.ts +0 -1
  182. package/dist/esm/__tests__/config.test.js +0 -70
  183. package/dist/esm/__tests__/config.test.js.map +0 -1
  184. package/dist/esm/__tests__/delta-processor.test.d.ts +0 -1
  185. package/dist/esm/__tests__/delta-processor.test.js +0 -77
  186. package/dist/esm/__tests__/delta-processor.test.js.map +0 -1
  187. package/dist/esm/__tests__/firestore-schema.test.d.ts +0 -1
  188. package/dist/esm/__tests__/firestore-schema.test.js +0 -74
  189. package/dist/esm/__tests__/firestore-schema.test.js.map +0 -1
  190. package/dist/esm/__tests__/integration.test.d.ts +0 -1
  191. package/dist/esm/__tests__/integration.test.js +0 -78
  192. package/dist/esm/__tests__/integration.test.js.map +0 -1
  193. package/dist/esm/__tests__/manifest-reader.test.d.ts +0 -1
  194. package/dist/esm/__tests__/manifest-reader.test.js +0 -271
  195. package/dist/esm/__tests__/manifest-reader.test.js.map +0 -1
  196. package/dist/esm/__tests__/rollout-checker.test.d.ts +0 -1
  197. package/dist/esm/__tests__/rollout-checker.test.js +0 -210
  198. package/dist/esm/__tests__/rollout-checker.test.js.map +0 -1
  199. package/dist/esm/__tests__/security.test.d.ts +0 -1
  200. package/dist/esm/__tests__/security.test.js +0 -54
  201. package/dist/esm/__tests__/security.test.js.map +0 -1
  202. package/dist/esm/__tests__/version-manager.test.d.ts +0 -1
  203. package/dist/esm/__tests__/version-manager.test.js +0 -45
  204. package/dist/esm/__tests__/version-manager.test.js.map +0 -1
  205. package/dist/esm/app-review/app-review-manager.js.map +0 -1
  206. package/dist/esm/app-review/index.js.map +0 -1
  207. package/dist/esm/app-review/platform-review-handler.js.map +0 -1
  208. package/dist/esm/app-review/review-conditions-checker.js.map +0 -1
  209. package/dist/esm/app-review/review-rate-limiter.js.map +0 -1
  210. package/dist/esm/app-review/types.js.map +0 -1
  211. package/dist/esm/app-update/app-update-checker.js.map +0 -1
  212. package/dist/esm/app-update/app-update-installer.js.map +0 -1
  213. package/dist/esm/app-update/app-update-manager.js.map +0 -1
  214. package/dist/esm/app-update/app-update-notifier.js.map +0 -1
  215. package/dist/esm/app-update/index.js.map +0 -1
  216. package/dist/esm/app-update/platform-app-update.js.map +0 -1
  217. package/dist/esm/app-update/types.js.map +0 -1
  218. package/dist/esm/background-update/background-scheduler.js.map +0 -1
  219. package/dist/esm/background-update/index.js.map +0 -1
  220. package/dist/esm/background-update/notification-manager.js.map +0 -1
  221. package/dist/esm/config/support.js.map +0 -1
  222. package/dist/esm/core/analytics.js.map +0 -1
  223. package/dist/esm/core/cache-manager.js.map +0 -1
  224. package/dist/esm/core/config.js.map +0 -1
  225. package/dist/esm/core/errors.js.map +0 -1
  226. package/dist/esm/core/event-emitter.js.map +0 -1
  227. package/dist/esm/core/logger.js.map +0 -1
  228. package/dist/esm/core/performance.js.map +0 -1
  229. package/dist/esm/core/plugin-manager.js.map +0 -1
  230. package/dist/esm/core/security.js.map +0 -1
  231. package/dist/esm/definitions.js.map +0 -1
  232. package/dist/esm/firestore/firestore-client.d.ts +0 -109
  233. package/dist/esm/firestore/firestore-client.js +0 -264
  234. package/dist/esm/firestore/firestore-client.js.map +0 -1
  235. package/dist/esm/firestore/index.d.ts +0 -11
  236. package/dist/esm/firestore/index.js +0 -11
  237. package/dist/esm/firestore/index.js.map +0 -1
  238. package/dist/esm/firestore/manifest-reader.d.ts +0 -87
  239. package/dist/esm/firestore/manifest-reader.js +0 -294
  240. package/dist/esm/firestore/manifest-reader.js.map +0 -1
  241. package/dist/esm/firestore/schema.d.ts +0 -507
  242. package/dist/esm/firestore/schema.js +0 -73
  243. package/dist/esm/firestore/schema.js.map +0 -1
  244. package/dist/esm/index.js.map +0 -1
  245. package/dist/esm/live-update/bundle-manager.js.map +0 -1
  246. package/dist/esm/live-update/certificate-pinning.js.map +0 -1
  247. package/dist/esm/live-update/delta-processor.js.map +0 -1
  248. package/dist/esm/live-update/download-manager.js.map +0 -1
  249. package/dist/esm/live-update/rollout-checker.js.map +0 -1
  250. package/dist/esm/live-update/update-manager.js.map +0 -1
  251. package/dist/esm/live-update/version-manager.js.map +0 -1
  252. package/dist/esm/plugin.js.map +0 -1
  253. package/dist/esm/security/crypto.js.map +0 -1
  254. package/dist/esm/security/validator.js.map +0 -1
  255. package/dist/esm/web.js.map +0 -1
  256. package/dist/plugin.cjs.js.map +0 -1
  257. package/dist/plugin.esm.js.map +0 -1
  258. package/dist/plugin.js.map +0 -1
  259. package/docs/FIREBASE_INTEGRATION_TRACKER.md +0 -321
  260. package/docs/FIREBASE_QUERIES_AND_INDEXES_AUDIT.md +0 -221
  261. package/docs/examples/firebase-backend-example.md +0 -27
  262. package/docs/guides/no-cost-backend-implementation-plan.md +0 -77
  263. package/docs/guides/no-cost-firestore-google-drive-backend.md +0 -60
  264. package/docs/play-console-rejection-rules.json +0 -428
  265. /package/docs/{MARKETING_WEBSITE_PLAN.md → archive/MARKETING_WEBSITE_PLAN.md} +0 -0
@@ -9,8 +9,10 @@ import okhttp3.*
9
9
  import okhttp3.tls.HandshakeCertificates
10
10
  import java.io.File
11
11
  import java.io.FileOutputStream
12
+ import java.io.IOException
12
13
  import java.security.MessageDigest
13
14
  import java.util.concurrent.TimeUnit
15
+ import java.util.zip.ZipFile
14
16
  import javax.net.ssl.X509TrustManager
15
17
 
16
18
  class LiveUpdatePlugin(
@@ -20,11 +22,38 @@ class LiveUpdatePlugin(
20
22
  private var config: JSObject? = null
21
23
  private var progressListener: ((JSObject) -> Unit)? = null
22
24
  private var stateChangeListener: ((JSObject) -> Unit)? = null
25
+ /**
26
+ * Listener wired up by the parent Capacitor plugin to call
27
+ * `bridge.setServerBasePath(path)`. set() invokes it after a
28
+ * successful activation so the WebView re-targets at the new bundle.
29
+ */
30
+ private var webViewPathListener: ((String) -> Unit)? = null
23
31
  private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
24
32
  private lateinit var okHttpClient: OkHttpClient
25
33
  private val securityManager = SecurityManager(context)
26
34
  private val activeDownloads = mutableMapOf<String, Long>()
27
35
  private var downloadManager: android.app.DownloadManager? = null
36
+
37
+ /** Hard cap on uncompressed bundle size (zip-bomb defense). */
38
+ private val maxUncompressedBundleBytes: Long = 500L * 1024 * 1024
39
+
40
+ companion object {
41
+ // SharedPreferences keys. Kept as constants so A2 (boot-time
42
+ // re-verification) and A3 (crash-loop auto-rollback) stay in sync
43
+ // with the call-sites in setActiveBundle / notifyAppReady.
44
+ private const val PREF_FILE = "native_update"
45
+ private const val ACTIVE_BUNDLE_KEY = "active_bundle"
46
+ private const val PREVIOUS_BUNDLE_KEY = "previous_active_bundle"
47
+ private const val PENDING_BUNDLE_KEY = "pending_verify_bundle"
48
+ private const val PENDING_ATTEMPTS_KEY = "pending_verify_attempts"
49
+
50
+ // Activation of a new bundle is "pending" until JS calls
51
+ // notifyAppReady(). Each cold start before that bumps the counter.
52
+ // Two failed cold starts is a strong signal the bundle crashes
53
+ // the app, so we auto-rollback to the previous bundle on the
54
+ // second failure.
55
+ private const val MAX_PENDING_ATTEMPTS = 2
56
+ }
28
57
 
29
58
  init {
30
59
  // Initialize OkHttp with default settings
@@ -75,6 +104,10 @@ class LiveUpdatePlugin(
75
104
  fun setStateChangeListener(listener: (JSObject) -> Unit) {
76
105
  stateChangeListener = listener
77
106
  }
107
+
108
+ fun setWebViewPathListener(listener: (String) -> Unit) {
109
+ webViewPathListener = listener
110
+ }
78
111
 
79
112
  fun sync(call: PluginCall) {
80
113
  scope.launch {
@@ -115,100 +148,149 @@ class LiveUpdatePlugin(
115
148
 
116
149
  fun download(call: PluginCall) {
117
150
  scope.launch {
151
+ val bundleId = "bundle-${System.currentTimeMillis()}"
152
+ val downloadDir = File(context.filesDir, "updates/$bundleId")
153
+ var success = false
118
154
  try {
119
155
  val url = call.getString("url") ?: run {
120
156
  call.reject("URL is required")
121
157
  return@launch
122
158
  }
123
-
159
+
124
160
  val version = call.getString("version") ?: run {
125
161
  call.reject("Version is required")
126
162
  return@launch
127
163
  }
128
-
164
+
129
165
  val checksum = call.getString("checksum") ?: run {
130
166
  call.reject("Checksum is required")
131
167
  return@launch
132
168
  }
133
-
134
- // Validate URL
169
+
135
170
  if (!url.startsWith("https://") && config?.getJSObject("security")?.getBool("enforceHttps") != false) {
136
171
  call.reject("INSECURE_URL", "Download URL must use HTTPS")
137
172
  return@launch
138
173
  }
139
-
140
- val bundleId = "bundle-${System.currentTimeMillis()}"
141
- val downloadDir = File(context.filesDir, "updates/$bundleId")
174
+
142
175
  downloadDir.mkdirs()
143
-
144
- // Start download
176
+
145
177
  val downloadedFile = downloadBundle(url, downloadDir, bundleId)
146
-
147
- // Verify checksum
178
+
148
179
  if (!verifyChecksum(downloadedFile, checksum)) {
149
- downloadedFile.delete()
150
180
  call.reject("CHECKSUM_ERROR", "Bundle checksum validation failed")
151
181
  return@launch
152
182
  }
153
-
154
- // Verify signature if provided
183
+
155
184
  val signature = call.getString("signature")
156
185
  if (signature != null) {
157
186
  val securityConfig = config?.getJSObject("security")
158
187
  val publicKey = securityConfig?.getString("publicKey")
159
188
  val enableSignatureValidation = securityConfig?.getBoolean("enableSignatureValidation", false) ?: false
160
-
189
+
161
190
  if (enableSignatureValidation && publicKey != null) {
162
191
  val fileBytes = downloadedFile.readBytes()
163
192
  if (!securityManager.verifySignature(fileBytes, signature, publicKey)) {
164
- downloadedFile.delete()
165
193
  call.reject("SIGNATURE_ERROR", "Bundle signature validation failed")
166
194
  return@launch
167
195
  }
168
196
  }
169
197
  }
170
-
171
- // Create bundle info
198
+
199
+ // Extract zip into bundles/<id>/www/. Activation (i.e.
200
+ // pointing the WebView at it) is deferred to set() —
201
+ // matches iOS contract: download stages, set activates.
202
+ val extractedDir = File(downloadDir, "www")
203
+ try {
204
+ extractZipBundle(downloadedFile, extractedDir)
205
+ } catch (e: Exception) {
206
+ call.reject("EXTRACTION_ERROR", "Bundle extraction failed: ${e.message}")
207
+ return@launch
208
+ }
209
+
172
210
  val bundleInfo = JSObject()
173
211
  bundleInfo.put("bundleId", bundleId)
174
212
  bundleInfo.put("version", version)
175
213
  bundleInfo.put("path", downloadedFile.absolutePath)
214
+ bundleInfo.put("extractedPath", extractedDir.absolutePath)
176
215
  bundleInfo.put("downloadTime", System.currentTimeMillis())
177
216
  bundleInfo.put("size", downloadedFile.length())
178
217
  bundleInfo.put("status", "READY")
179
218
  bundleInfo.put("checksum", checksum)
180
219
  bundleInfo.put("verified", true)
181
-
182
- // Save bundle info
220
+ if (signature != null) {
221
+ bundleInfo.put("signature", signature)
222
+ }
223
+
183
224
  saveBundleInfo(bundleInfo)
184
-
185
- // Notify state change
225
+
186
226
  stateChangeListener?.invoke(JSObject().apply {
187
227
  put("status", "READY")
188
228
  put("bundleId", bundleId)
189
229
  put("version", version)
190
230
  })
191
-
231
+
232
+ success = true
192
233
  call.resolve(bundleInfo)
193
234
  } catch (e: Exception) {
194
235
  call.reject("DOWNLOAD_ERROR", e.message)
236
+ } finally {
237
+ // On any failure, remove the per-bundle directory so we
238
+ // don't leak partial downloads/extractions.
239
+ if (!success) {
240
+ downloadDir.deleteRecursively()
241
+ }
195
242
  }
196
243
  }
197
244
  }
198
-
245
+
199
246
  fun set(call: PluginCall) {
200
247
  val bundleId = call.getString("bundleId") ?: run {
201
248
  call.reject("Bundle ID is required")
202
249
  return
203
250
  }
204
-
205
- // Set active bundle
251
+
252
+ val bundleInfo = getBundleInfoById(bundleId) ?: run {
253
+ call.reject("Bundle not found: $bundleId")
254
+ return
255
+ }
256
+ val extractedPath = bundleInfo.optString("extractedPath", "")
257
+ if (extractedPath.isEmpty() || !File(extractedPath).exists()) {
258
+ call.reject("Bundle not extracted: $bundleId")
259
+ return
260
+ }
261
+ val verified = bundleInfo.optBoolean("verified", false)
262
+ if (!verified) {
263
+ call.reject("Refusing to activate unverified bundle: $bundleId")
264
+ return
265
+ }
266
+
206
267
  setActiveBundle(bundleId)
268
+ // Hop to UI thread; setServerBasePath must run on the WebView's
269
+ // thread per Capacitor's Bridge contract.
270
+ activity.runOnUiThread {
271
+ webViewPathListener?.invoke(extractedPath)
272
+ }
207
273
  call.resolve()
208
274
  }
209
275
 
210
276
  fun reload(call: PluginCall) {
211
- // In Android, we need to restart the activity or reload the WebView
277
+ // Re-applying the active bundle's serverBasePath causes the
278
+ // WebView to reload from the new asset root. activity.recreate()
279
+ // also works but is heavier and can drop transient state.
280
+ val activeBundleId = context
281
+ .getSharedPreferences(PREF_FILE, Context.MODE_PRIVATE)
282
+ .getString(ACTIVE_BUNDLE_KEY, null)
283
+ if (activeBundleId != null) {
284
+ val info = getBundleInfoById(activeBundleId)
285
+ val extractedPath = info?.optString("extractedPath", "") ?: ""
286
+ if (extractedPath.isNotEmpty() && File(extractedPath).exists()) {
287
+ activity.runOnUiThread {
288
+ webViewPathListener?.invoke(extractedPath)
289
+ }
290
+ call.resolve()
291
+ return
292
+ }
293
+ }
212
294
  activity.runOnUiThread {
213
295
  activity.recreate()
214
296
  }
@@ -401,6 +483,82 @@ class LiveUpdatePlugin(
401
483
  }
402
484
  }
403
485
 
486
+ /**
487
+ * Extract a zip into [destination] with three defenses applied:
488
+ * 1. Zip-Slip: every entry's resolved canonical path must stay
489
+ * inside [destination]. Catches `../` traversal.
490
+ * 2. Symlinks/special: entries that are not regular files or
491
+ * directories are skipped (no symlinks, devices, etc.).
492
+ * 3. Zip-bomb: total uncompressed size is capped.
493
+ *
494
+ * Mirrors `extractZipBundle` in `ios/Plugin/LiveUpdate/LiveUpdatePlugin.swift`.
495
+ */
496
+ private fun extractZipBundle(zipFile: File, destination: File) {
497
+ if (!destination.exists()) {
498
+ destination.mkdirs()
499
+ }
500
+ val canonicalDest = destination.canonicalFile
501
+
502
+ ZipFile(zipFile).use { archive ->
503
+ // Pre-flight: reject the archive entirely if it would expand
504
+ // beyond our cap, before we write any bytes to disk.
505
+ var totalUncompressed = 0L
506
+ for (entry in archive.entries()) {
507
+ if (entry.size < 0) {
508
+ throw IOException("Zip entry has unknown uncompressed size: ${entry.name}")
509
+ }
510
+ totalUncompressed += entry.size
511
+ if (totalUncompressed > maxUncompressedBundleBytes) {
512
+ throw IOException(
513
+ "Bundle exceeds maximum uncompressed size " +
514
+ "($maxUncompressedBundleBytes bytes)"
515
+ )
516
+ }
517
+ }
518
+
519
+ for (entry in archive.entries()) {
520
+ // Reject entries with absolute paths or backreferences.
521
+ val name = entry.name
522
+ if (name.startsWith("/") || name.startsWith("\\") || name.contains("..")) {
523
+ throw IOException("Zip entry has unsafe path: $name")
524
+ }
525
+
526
+ val outFile = File(destination, name)
527
+ val canonicalOut = outFile.canonicalFile
528
+ if (!canonicalOut.path.startsWith(canonicalDest.path + File.separator)
529
+ && canonicalOut.path != canonicalDest.path
530
+ ) {
531
+ throw IOException("Zip-Slip detected: $name escapes ${canonicalDest.path}")
532
+ }
533
+
534
+ if (entry.isDirectory) {
535
+ if (!outFile.exists() && !outFile.mkdirs()) {
536
+ throw IOException("Failed to create directory ${outFile.path}")
537
+ }
538
+ continue
539
+ }
540
+
541
+ // Java's ZipFile API exposes only ZipEntry, which can't
542
+ // distinguish symlinks from regular files at this level.
543
+ // We treat every non-directory entry as a regular file.
544
+ // Symlinks created by `zip --symlinks` are NOT a concern
545
+ // because ZipFile reads the symlink target as the entry
546
+ // body — it won't follow it during extraction.
547
+ outFile.parentFile?.mkdirs()
548
+
549
+ archive.getInputStream(entry).use { input ->
550
+ FileOutputStream(outFile).use { output ->
551
+ val buffer = ByteArray(8192)
552
+ var read: Int
553
+ while (input.read(buffer).also { read = it } != -1) {
554
+ output.write(buffer, 0, read)
555
+ }
556
+ }
557
+ }
558
+ }
559
+ }
560
+ }
561
+
404
562
  private fun verifyChecksum(file: File, expectedChecksum: String): Boolean {
405
563
  val digest = MessageDigest.getInstance("SHA-256")
406
564
  file.inputStream().use { input ->
@@ -428,8 +586,23 @@ class LiveUpdatePlugin(
428
586
  }
429
587
 
430
588
  private fun setActiveBundle(bundleId: String) {
431
- val prefs = context.getSharedPreferences("native_update", Context.MODE_PRIVATE)
432
- prefs.edit().putString("active_bundle", bundleId).apply()
589
+ val prefs = context.getSharedPreferences(PREF_FILE, Context.MODE_PRIVATE)
590
+ val currentActive = prefs.getString(ACTIVE_BUNDLE_KEY, null)
591
+ val editor = prefs.edit().putString(ACTIVE_BUNDLE_KEY, bundleId)
592
+
593
+ // Preserve the previous active bundle as a rollback target. Skip
594
+ // self-activation (setting to the same bundle) so we don't nuke
595
+ // a real previous-bundle pointer.
596
+ if (currentActive != null && currentActive != bundleId) {
597
+ editor.putString(PREVIOUS_BUNDLE_KEY, currentActive)
598
+ }
599
+
600
+ // Mark this activation as pending-verify. notifyAppReady() in the
601
+ // host app clears it; otherwise the next cold start increments the
602
+ // attempt counter and eventually triggers auto-rollback.
603
+ editor.putString(PENDING_BUNDLE_KEY, bundleId)
604
+ editor.putInt(PENDING_ATTEMPTS_KEY, 0)
605
+ editor.apply()
433
606
  }
434
607
 
435
608
  private fun clearAllBundles() {
@@ -508,8 +681,150 @@ class LiveUpdatePlugin(
508
681
  }
509
682
 
510
683
  private fun markBundleAsVerified() {
511
- val prefs = context.getSharedPreferences("native_update", Context.MODE_PRIVATE)
512
- prefs.edit().putBoolean("current_bundle_verified", true).apply()
684
+ // notifyAppReady() is the host app's "I booted clean on this bundle"
685
+ // signal. Clear the pending-verify state so cold starts after this
686
+ // no longer count against the bundle.
687
+ val prefs = context.getSharedPreferences(PREF_FILE, Context.MODE_PRIVATE)
688
+ prefs.edit()
689
+ .putBoolean("current_bundle_verified", true)
690
+ .remove(PENDING_BUNDLE_KEY)
691
+ .remove(PENDING_ATTEMPTS_KEY)
692
+ .apply()
693
+ }
694
+
695
+ /**
696
+ * Called from NativeUpdatePlugin.load() on every cold start. Runs two
697
+ * independent defenses, both silent when no OTA bundle is active:
698
+ *
699
+ * A2 — re-hash the active bundle and verify it still matches the
700
+ * checksum stored at install time. If signature + publicKey are
701
+ * configured, also re-verify the signature. This catches on-disk
702
+ * tampering on rooted devices.
703
+ * A3 — if the active bundle was never confirmed by notifyAppReady()
704
+ * on the previous launch, treat this cold start as a failed
705
+ * boot. After MAX_PENDING_ATTEMPTS failures, rollback to the
706
+ * previous known-good bundle to break the crash loop.
707
+ *
708
+ * On any failure the function rolls back and fires an
709
+ * updateStateChanged event with status "ROLLBACK" so the host app and
710
+ * analytics layer can observe it.
711
+ */
712
+ /**
713
+ * Restore the WebView's serverBasePath from SharedPreferences so the
714
+ * last-set bundle survives process death.
715
+ */
716
+ fun applyActiveBundlePathIfPresent() {
717
+ val activeBundleId = context
718
+ .getSharedPreferences(PREF_FILE, Context.MODE_PRIVATE)
719
+ .getString(ACTIVE_BUNDLE_KEY, null) ?: return
720
+ if (activeBundleId == "default") return
721
+ val info = getBundleInfoById(activeBundleId) ?: return
722
+ val extractedPath = info.optString("extractedPath", "")
723
+ if (extractedPath.isEmpty()) return
724
+ if (!File(extractedPath).exists()) return
725
+ activity.runOnUiThread {
726
+ webViewPathListener?.invoke(extractedPath)
727
+ }
728
+ }
729
+
730
+ fun verifyActiveBundleOnBoot() {
731
+ val prefs = context.getSharedPreferences(PREF_FILE, Context.MODE_PRIVATE)
732
+
733
+ // A3: crash-loop detection. If a bundle is still pending, this
734
+ // cold start is counted against it.
735
+ val pendingBundleId = prefs.getString(PENDING_BUNDLE_KEY, null)
736
+ if (pendingBundleId != null) {
737
+ val nextAttempt = prefs.getInt(PENDING_ATTEMPTS_KEY, 0) + 1
738
+ if (nextAttempt > MAX_PENDING_ATTEMPTS) {
739
+ rollbackToPrevious("auto-rollback after $nextAttempt failed cold starts", pendingBundleId)
740
+ prefs.edit()
741
+ .remove(PENDING_BUNDLE_KEY)
742
+ .remove(PENDING_ATTEMPTS_KEY)
743
+ .apply()
744
+ return
745
+ }
746
+ prefs.edit().putInt(PENDING_ATTEMPTS_KEY, nextAttempt).apply()
747
+ }
748
+
749
+ // A2: integrity re-verify. Only runs for non-default active bundles.
750
+ val activeBundleId = prefs.getString(ACTIVE_BUNDLE_KEY, null) ?: return
751
+ if (activeBundleId == "default") return
752
+
753
+ val bundleInfo = getBundleInfoById(activeBundleId) ?: run {
754
+ rollbackToPrevious("active bundle info missing from preferences", activeBundleId)
755
+ return
756
+ }
757
+
758
+ val bundlePath = bundleInfo.optString("path", "")
759
+ val expectedChecksum = bundleInfo.optString("checksum", "")
760
+ if (bundlePath.isEmpty() || expectedChecksum.isEmpty()) return
761
+
762
+ val bundleFile = File(bundlePath)
763
+ if (!bundleFile.exists()) {
764
+ rollbackToPrevious("bundle file missing on disk", activeBundleId)
765
+ return
766
+ }
767
+
768
+ if (!verifyChecksum(bundleFile, expectedChecksum)) {
769
+ rollbackToPrevious("bundle checksum mismatch on boot", activeBundleId)
770
+ return
771
+ }
772
+
773
+ val signature = bundleInfo.optString("signature", "")
774
+ if (signature.isNotEmpty()) {
775
+ val publicKey = config?.getJSObject("security")?.getString("publicKey")
776
+ ?: config?.getString("publicKey")
777
+ if (!publicKey.isNullOrEmpty()) {
778
+ val bytes = bundleFile.readBytes()
779
+ if (!securityManager.verifySignature(bytes, signature, publicKey)) {
780
+ rollbackToPrevious("bundle signature mismatch on boot", activeBundleId)
781
+ return
782
+ }
783
+ }
784
+ }
785
+ }
786
+
787
+ private fun rollbackToPrevious(reason: String, fromBundleId: String) {
788
+ val prefs = context.getSharedPreferences(PREF_FILE, Context.MODE_PRIVATE)
789
+ val previous = prefs.getString(PREVIOUS_BUNDLE_KEY, null)
790
+ val editor = prefs.edit()
791
+ if (previous != null) {
792
+ editor.putString(ACTIVE_BUNDLE_KEY, previous)
793
+ } else {
794
+ editor.remove(ACTIVE_BUNDLE_KEY)
795
+ }
796
+ editor.remove(PREVIOUS_BUNDLE_KEY).apply()
797
+
798
+ // Re-point the WebView at whatever we rolled back to. If we
799
+ // rolled all the way back to the bundled-in www/ (no previous
800
+ // OTA bundle), no listener call is needed — Capacitor's default
801
+ // serverBasePath is already correct.
802
+ if (previous != null) {
803
+ val info = getBundleInfoById(previous)
804
+ val extractedPath = info?.optString("extractedPath", "") ?: ""
805
+ if (extractedPath.isNotEmpty() && File(extractedPath).exists()) {
806
+ activity.runOnUiThread {
807
+ webViewPathListener?.invoke(extractedPath)
808
+ }
809
+ }
810
+ }
811
+
812
+ stateChangeListener?.invoke(JSObject().apply {
813
+ put("status", "ROLLBACK")
814
+ put("bundleId", fromBundleId)
815
+ put("rolledBackTo", previous ?: "default")
816
+ put("reason", reason)
817
+ })
818
+ }
819
+
820
+ private fun getBundleInfoById(bundleId: String): JSObject? {
821
+ val prefs = context.getSharedPreferences("native_update_bundles", Context.MODE_PRIVATE)
822
+ val raw = prefs.getString(bundleId, null) ?: return null
823
+ return try {
824
+ JSObject(raw)
825
+ } catch (e: Exception) {
826
+ null
827
+ }
513
828
  }
514
829
 
515
830
  // Async method for background update checks
@@ -5,18 +5,11 @@ import com.getcapacitor.Plugin
5
5
  import com.getcapacitor.PluginCall
6
6
  import com.getcapacitor.PluginMethod
7
7
  import com.getcapacitor.annotation.CapacitorPlugin
8
- import com.getcapacitor.annotation.Permission
9
- import android.Manifest
10
8
 
11
- @CapacitorPlugin(
12
- name = "NativeUpdate",
13
- permissions = [
14
- Permission(
15
- strings = [Manifest.permission.WRITE_EXTERNAL_STORAGE],
16
- alias = "storage"
17
- )
18
- ]
19
- )
9
+ // Plugin writes only to context.filesDir (the app sandbox), so it does
10
+ // not need WRITE_EXTERNAL_STORAGE. Declaring an unused dangerous
11
+ // permission is a Play Store rejection trap.
12
+ @CapacitorPlugin(name = "NativeUpdate")
20
13
  class NativeUpdatePlugin : Plugin() {
21
14
 
22
15
  private lateinit var liveUpdatePlugin: LiveUpdatePlugin
@@ -49,10 +42,36 @@ class NativeUpdatePlugin : Plugin() {
49
42
  liveUpdatePlugin.setProgressListener { progress ->
50
43
  notifyListeners("downloadProgress", progress)
51
44
  }
52
-
45
+
53
46
  liveUpdatePlugin.setStateChangeListener { state ->
54
47
  notifyListeners("updateStateChanged", state)
55
48
  }
49
+
50
+ // Wire the WebView retargeting hook. set() calls this with the
51
+ // extracted bundle's www/ path; we pass it to the Capacitor
52
+ // Bridge so subsequent loads serve the new bundle.
53
+ liveUpdatePlugin.setWebViewPathListener { path ->
54
+ bridge?.setServerBasePath(path)
55
+ }
56
+
57
+ // Boot-time integrity re-verify + crash-loop auto-rollback. Runs
58
+ // before any host-app code touches the WebView so a tampered or
59
+ // crash-looping bundle never gets a chance to load.
60
+ try {
61
+ liveUpdatePlugin.verifyActiveBundleOnBoot()
62
+ } catch (e: Exception) {
63
+ // Never let boot verification crash the plugin itself — fall
64
+ // through and let a later sync() surface the problem.
65
+ }
66
+
67
+ // Re-apply the active bundle's path so the WebView serves the
68
+ // last-set OTA bundle on cold start (otherwise process death
69
+ // would silently revert to the bundled-in www/).
70
+ try {
71
+ liveUpdatePlugin.applyActiveBundlePathIfPresent()
72
+ } catch (e: Exception) {
73
+ // Same rationale as above.
74
+ }
56
75
  }
57
76
 
58
77
  @PluginMethod
@@ -145,10 +164,7 @@ class NativeUpdatePlugin : Plugin() {
145
164
 
146
165
  @PluginMethod
147
166
  fun download(call: PluginCall) {
148
- if (!hasRequiredPermissions()) {
149
- requestPermissionForAlias("storage", call, "handleStoragePermission")
150
- return
151
- }
167
+ // No runtime permission required — bundle storage is in app sandbox.
152
168
  liveUpdatePlugin.download(call)
153
169
  }
154
170
 
@@ -294,22 +310,4 @@ class NativeUpdatePlugin : Plugin() {
294
310
  backgroundUpdatePlugin.requestNotificationPermissions(call)
295
311
  }
296
312
 
297
- // Permission Callbacks
298
-
299
- @PluginMethod
300
- fun handleStoragePermission(call: PluginCall) {
301
- if (hasPermission("storage")) {
302
- // Retry the original method
303
- when (call.methodName) {
304
- "download" -> liveUpdatePlugin.download(call)
305
- else -> call.reject("Unknown method")
306
- }
307
- } else {
308
- call.reject("Storage permission denied")
309
- }
310
- }
311
-
312
- override fun hasRequiredPermissions(): Boolean {
313
- return hasPermission("storage")
314
- }
315
313
  }
@@ -4,6 +4,7 @@ import android.content.BroadcastReceiver
4
4
  import android.content.Context
5
5
  import android.content.Intent
6
6
  import android.util.Log
7
+ import androidx.work.BackoffPolicy
7
8
  import androidx.work.ExistingWorkPolicy
8
9
  import androidx.work.OneTimeWorkRequestBuilder
9
10
  import androidx.work.WorkManager
@@ -47,12 +48,20 @@ class NotificationActionReceiver : BroadcastReceiver() {
47
48
  when (updateType) {
48
49
  "live_update" -> {
49
50
  val bundleId = intent.getStringExtra(EXTRA_BUNDLE_ID) ?: return
50
- // Trigger immediate installation through WorkManager
51
+ // Trigger immediate installation through WorkManager.
52
+ // Exponential backoff keeps an ailing network or server
53
+ // from burning battery; matches the policy used by
54
+ // BackgroundUpdatePlugin.triggerBackgroundCheck.
51
55
  val workRequest = OneTimeWorkRequestBuilder<BackgroundUpdateWorker>()
52
56
  .setInputData(workDataOf(
53
57
  "action" to "install_bundle",
54
58
  "bundle_id" to bundleId
55
59
  ))
60
+ .setBackoffCriteria(
61
+ BackoffPolicy.EXPONENTIAL,
62
+ 30,
63
+ java.util.concurrent.TimeUnit.SECONDS
64
+ )
56
65
  .build()
57
66
 
58
67
  WorkManager.getInstance(context).enqueueUniqueWork(
@@ -68,7 +68,8 @@ class SecurityManager(private val context: Context) {
68
68
  result.put("certificatePinning", certificatePinning)
69
69
 
70
70
  result.put("validateInputs", config?.getBool("validateInputs") ?: true)
71
- result.put("secureStorage", config?.getBool("secureStorage") ?: true)
71
+ // Hard-coded true since v2 — secure storage is no longer opt-out.
72
+ result.put("secureStorage", true)
72
73
 
73
74
  return result
74
75
  }
@@ -162,23 +163,20 @@ class SecurityManager(private val context: Context) {
162
163
  return digest.digest().joinToString("") { "%02x".format(it) }
163
164
  }
164
165
 
166
+ /**
167
+ * Writes always land in EncryptedSharedPreferences backed by the
168
+ * Android Keystore master key. The old `secureStorage: false` opt-out
169
+ * was removed in v2 — there is no legitimate reason to store an
170
+ * API key or signing metadata in plaintext on a device, and shipping
171
+ * the option was worse than not shipping it at all (the default was
172
+ * opt-in, not opt-out, so most integrations landed in plaintext).
173
+ */
165
174
  fun saveSecureData(key: String, value: String) {
166
- if (isSecureStorageEnabled()) {
167
- securePrefs.edit().putString(key, value).apply()
168
- } else {
169
- // Fallback to regular preferences (not recommended)
170
- val prefs = context.getSharedPreferences("native_update", Context.MODE_PRIVATE)
171
- prefs.edit().putString(key, value).apply()
172
- }
175
+ securePrefs.edit().putString(key, value).apply()
173
176
  }
174
-
177
+
175
178
  fun getSecureData(key: String): String? {
176
- return if (isSecureStorageEnabled()) {
177
- securePrefs.getString(key, null)
178
- } else {
179
- val prefs = context.getSharedPreferences("native_update", Context.MODE_PRIVATE)
180
- prefs.getString(key, null)
181
- }
179
+ return securePrefs.getString(key, null)
182
180
  }
183
181
 
184
182
  fun validatePath(path: String): Boolean {
@@ -204,9 +202,11 @@ class SecurityManager(private val context: Context) {
204
202
  return config?.getBool("enforceHttps") ?: true
205
203
  }
206
204
 
207
- fun isSecureStorageEnabled(): Boolean {
208
- return config?.getBool("secureStorage") ?: true
209
- }
205
+ /**
206
+ * Secure storage is unconditionally on since v2. Kept as a getter so
207
+ * `getSecurityInfo()` still reports accurately to callers.
208
+ */
209
+ fun isSecureStorageEnabled(): Boolean = true
210
210
 
211
211
  fun isInputValidationEnabled(): Boolean {
212
212
  return config?.getBool("validateInputs") ?: true