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.
- package/Readme.md +35 -9
- package/android/manifest-additions.xml +28 -0
- package/android/src/main/AndroidManifest.xml +3 -7
- package/android/src/main/java/com/aoneahsan/nativeupdate/BackgroundNotificationManager.kt +21 -13
- package/android/src/main/java/com/aoneahsan/nativeupdate/BackgroundUpdatePlugin.kt +15 -0
- package/android/src/main/java/com/aoneahsan/nativeupdate/BackgroundUpdateWorker.kt +23 -7
- package/android/src/main/java/com/aoneahsan/nativeupdate/LiveUpdatePlugin.kt +346 -31
- package/android/src/main/java/com/aoneahsan/nativeupdate/NativeUpdatePlugin.kt +32 -34
- package/android/src/main/java/com/aoneahsan/nativeupdate/NotificationActionReceiver.kt +10 -1
- package/android/src/main/java/com/aoneahsan/nativeupdate/SecurityManager.kt +18 -18
- package/cli/AGENTS.md +43 -0
- package/cli/CLAUDE.md +65 -0
- package/cli/commands/backend-create.js +56 -15
- package/cli/commands/bundle-create.js +39 -7
- package/cli/commands/monitor.js +72 -51
- package/cli/index.js +10 -24
- package/cli/package.json +11 -3
- package/dist/esm/app-review/app-review-manager.js +0 -1
- package/dist/esm/app-review/index.js +0 -1
- package/dist/esm/app-review/platform-review-handler.js +0 -1
- package/dist/esm/app-review/review-conditions-checker.js +0 -1
- package/dist/esm/app-review/review-rate-limiter.js +0 -1
- package/dist/esm/app-review/types.js +0 -1
- package/dist/esm/app-update/app-update-checker.js +0 -1
- package/dist/esm/app-update/app-update-installer.d.ts +1 -0
- package/dist/esm/app-update/app-update-installer.js +19 -8
- package/dist/esm/app-update/app-update-manager.js +0 -1
- package/dist/esm/app-update/app-update-notifier.js +0 -1
- package/dist/esm/app-update/index.js +0 -1
- package/dist/esm/app-update/platform-app-update.js +12 -3
- package/dist/esm/app-update/types.js +0 -1
- package/dist/esm/background-update/background-scheduler.js +0 -1
- package/dist/esm/background-update/index.js +0 -1
- package/dist/esm/background-update/notification-manager.js +0 -1
- package/dist/esm/config/support.js +0 -1
- package/dist/esm/core/analytics.d.ts +13 -0
- package/dist/esm/core/analytics.js +15 -1
- package/dist/esm/core/cache-manager.js +0 -1
- package/dist/esm/core/config.d.ts +6 -30
- package/dist/esm/core/config.js +1 -8
- package/dist/esm/core/errors.d.ts +1 -0
- package/dist/esm/core/errors.js +1 -1
- package/dist/esm/core/event-emitter.d.ts +0 -3
- package/dist/esm/core/event-emitter.js +8 -3
- package/dist/esm/core/logger.js +0 -1
- package/dist/esm/core/performance.js +0 -1
- package/dist/esm/core/plugin-manager.js +3 -1
- package/dist/esm/core/security.d.ts +22 -3
- package/dist/esm/core/security.js +46 -7
- package/dist/esm/definitions.d.ts +13 -40
- package/dist/esm/definitions.js +0 -1
- package/dist/esm/index.d.ts +3 -4
- package/dist/esm/index.js +3 -3
- package/dist/esm/live-update/bundle-manager.d.ts +8 -0
- package/dist/esm/live-update/bundle-manager.js +20 -1
- package/dist/esm/live-update/certificate-pinning.js +0 -1
- package/dist/esm/live-update/delta-processor.d.ts +1 -1
- package/dist/esm/live-update/delta-processor.js +3 -1
- package/dist/esm/live-update/download-manager.d.ts +36 -5
- package/dist/esm/live-update/download-manager.js +70 -24
- package/dist/esm/live-update/rollout-checker.d.ts +1 -1
- package/dist/esm/live-update/rollout-checker.js +5 -6
- package/dist/esm/live-update/update-manager.d.ts +12 -1
- package/dist/esm/live-update/update-manager.js +38 -11
- package/dist/esm/live-update/version-manager.d.ts +9 -12
- package/dist/esm/live-update/version-manager.js +40 -68
- package/dist/esm/plugin.js +82 -89
- package/dist/esm/security/crypto.js +11 -7
- package/dist/esm/security/validator.js +0 -1
- package/dist/esm/types/rollout.d.ts +85 -0
- package/dist/esm/types/rollout.js +25 -0
- package/dist/esm/web.d.ts +26 -1
- package/dist/esm/web.js +214 -75
- package/dist/plugin.cjs.js +1 -2
- package/dist/plugin.esm.js +1 -2
- package/dist/plugin.js +2 -3
- package/docs/AGENTS.md +38 -0
- package/docs/CHANGELOG.md +275 -0
- package/docs/CLAUDE.md +101 -0
- package/docs/MANUAL-TASKS.md +17 -0
- package/docs/MARKETING_WEBSITE_TRACKER.md +18 -2
- package/docs/MIGRATION.md +170 -0
- package/docs/PACKAGES.md +167 -0
- package/docs/PERFORMANCE.md +110 -0
- package/docs/PROJECT_COMPLETION_TRACKER.md +4 -35
- package/docs/README.md +12 -2
- package/docs/REMAINING_FEATURES.md +123 -76
- package/docs/ROADMAP.md +200 -120
- package/docs/SECURITY-IF-REPO-PUBLIC.md +232 -0
- package/docs/TESTING_REQUIREMENTS.md +1 -112
- package/docs/ai-knowledge-base/00-project-profile.md +38 -0
- package/docs/ai-knowledge-base/01-features.md +46 -0
- package/docs/ai-knowledge-base/02-routes.md +72 -0
- package/docs/ai-knowledge-base/03-tech-stack.md +57 -0
- package/docs/ai-knowledge-base/README.md +23 -0
- package/docs/analytics-tracking.md +37 -0
- package/docs/capacitor-official-plugins.md +46 -0
- package/docs/capawesome-plugins.md +29 -0
- package/docs/deployment/HOSTINGER_DEPLOY.md +370 -0
- package/docs/deployment/PRODUCTION-DEPLOY-CHECKLIST.md +184 -0
- package/docs/docs-site/plan.md +201 -0
- package/docs/docs-site/scope.md +127 -0
- package/docs/docs-site/tracker.json +235 -0
- package/docs/error-handling-tracking.md +37 -0
- package/docs/features/laravel-nova-backend/ASSESSMENT-SUMMARY.md +96 -0
- package/docs/features/laravel-nova-backend/IMPLEMENTATION-PLAN.md +504 -0
- package/docs/features/laravel-nova-backend/progress-tracker.json +184 -0
- package/docs/guides/testing-guide.md +15 -41
- package/docs/package-audit.md +41 -0
- package/docs/play-store/ASO-METADATA.md +5 -0
- package/docs/play-store/DATA-SAFETY.md +5 -0
- package/docs/play-store/DECLARATIONS.md +5 -0
- package/docs/play-store/README.md +27 -0
- package/docs/play-store/RELEASE-NOTES.md +99 -0
- package/docs/play-store/RELEASE-STEPS.md +6 -0
- package/docs/play-store/STORE-LISTING.md +7 -0
- package/docs/play-store/SUBMISSION-CHECKLIST.md +5 -0
- package/docs/production-readiness.md +1 -1
- package/docs/project-audit/2026-05-16/00-FINALIZATION-PLAN.md +123 -0
- package/docs/project-audit/2026-05-16/01-BATCH-PLAN.md +227 -0
- package/docs/project-audit/2026-05-16/02-AUDIT-FINDINGS.md +144 -0
- package/docs/project-audit/2026-05-16/sessions/batch-01.md +42 -0
- package/docs/project-audit/2026-05-16/sessions/batch-02.md +74 -0
- package/docs/project-audit/2026-05-16/sessions/batch-03.md +71 -0
- package/docs/project-audit/2026-05-16/sessions/batch-04.md +88 -0
- package/docs/project-audit/2026-05-16/sessions/batch-05.md +63 -0
- package/docs/project-audit/2026-05-16/sessions/batch-06.md +103 -0
- package/docs/project-audit/2026-05-16/sessions/batch-07.md +61 -0
- package/docs/project-audit/2026-05-16/sessions/batch-08.md +101 -0
- package/docs/project-audit/2026-05-16/sessions/batch-09.md +105 -0
- package/docs/project-audit/2026-05-16/sessions/batch-10.md +102 -0
- package/docs/project-audit/AUDIT-HISTORY.md +74 -0
- package/docs/project-audit/BATCH-PLAN.md +168 -0
- package/docs/project-audit/MARKETING-WEBSITE-FINDINGS-2026-05-11.md +73 -0
- package/docs/project-audit/PRODUCTION-READINESS-AUDIT-2026-05-08.md +275 -0
- package/docs/project-finalization/00-tracker.json +60 -0
- package/docs/project-knowledge-base/01-system-overview.md +227 -0
- package/docs/project-knowledge-base/02-routes-pages-forms-users.md +352 -0
- package/docs/project-knowledge-base/03-tech-stack-modules-services.md +379 -0
- package/docs/project-knowledge-base/04-data-models-integrations.md +244 -0
- package/docs/project-knowledge-base/05-docs-corpus-inventory.md +256 -0
- package/docs/project-knowledge-base/06-operations-testing-legal-content.md +208 -0
- package/docs/project-knowledge-base/README.md +103 -0
- package/docs/project-profiles/native-update-capacitor-update-platform-project-profile-last-updated-2026-05-19.md +81 -0
- package/docs/rules-tracker.md +102 -0
- package/docs/security/PRE-LAUNCH-PENTEST-CHECKLIST.md +116 -0
- package/docs/seo-aeo-rules.json +3043 -0
- package/docs/social-media-marketing/README.md +81 -0
- package/docs/social-media-marketing/posts-batch-01.md +613 -0
- package/docs/social-media-marketing/posts-batch-02.md +528 -0
- package/docs/social-media-marketing/whatsapp-messages-batch-01.md +458 -0
- package/docs/social-media-marketing/whatsapp-status-batch-01.md +355 -0
- package/docs/tracking/finalization-2026-05-16-tracker.json +346 -0
- package/docs/tracking/full-audit-tracker.json +121 -0
- package/docs/tracking/production-readiness-audit-tracker.json +429 -0
- package/docs/tracking/seo-checklist-tracker.json +333 -0
- package/ios/Plugin/BackgroundUpdate/BackgroundUpdatePlugin.swift +66 -9
- package/ios/Plugin/LiveUpdate/LiveUpdatePlugin.swift +306 -52
- package/ios/Plugin/NativeUpdatePlugin.swift +22 -9
- package/ios/Plugin/Security/SecurityManager.swift +13 -14
- package/package.json +37 -38
- package/cli/node_modules/.yarn-integrity +0 -16
- package/cli/node_modules/commander/LICENSE +0 -22
- package/cli/node_modules/commander/Readme.md +0 -1148
- package/cli/node_modules/commander/esm.mjs +0 -16
- package/cli/node_modules/commander/index.js +0 -26
- package/cli/node_modules/commander/lib/argument.js +0 -145
- package/cli/node_modules/commander/lib/command.js +0 -2179
- package/cli/node_modules/commander/lib/error.js +0 -43
- package/cli/node_modules/commander/lib/help.js +0 -462
- package/cli/node_modules/commander/lib/option.js +0 -329
- package/cli/node_modules/commander/lib/suggestSimilar.js +0 -100
- package/cli/node_modules/commander/package-support.json +0 -16
- package/cli/node_modules/commander/package.json +0 -80
- package/cli/node_modules/commander/typings/esm.d.mts +0 -3
- package/cli/node_modules/commander/typings/index.d.ts +0 -884
- package/cli/yarn.lock +0 -8
- package/dist/esm/__tests__/bundle-manager.test.d.ts +0 -1
- package/dist/esm/__tests__/bundle-manager.test.js +0 -151
- package/dist/esm/__tests__/bundle-manager.test.js.map +0 -1
- package/dist/esm/__tests__/config.test.d.ts +0 -1
- package/dist/esm/__tests__/config.test.js +0 -70
- package/dist/esm/__tests__/config.test.js.map +0 -1
- package/dist/esm/__tests__/delta-processor.test.d.ts +0 -1
- package/dist/esm/__tests__/delta-processor.test.js +0 -77
- package/dist/esm/__tests__/delta-processor.test.js.map +0 -1
- package/dist/esm/__tests__/firestore-schema.test.d.ts +0 -1
- package/dist/esm/__tests__/firestore-schema.test.js +0 -74
- package/dist/esm/__tests__/firestore-schema.test.js.map +0 -1
- package/dist/esm/__tests__/integration.test.d.ts +0 -1
- package/dist/esm/__tests__/integration.test.js +0 -78
- package/dist/esm/__tests__/integration.test.js.map +0 -1
- package/dist/esm/__tests__/manifest-reader.test.d.ts +0 -1
- package/dist/esm/__tests__/manifest-reader.test.js +0 -271
- package/dist/esm/__tests__/manifest-reader.test.js.map +0 -1
- package/dist/esm/__tests__/rollout-checker.test.d.ts +0 -1
- package/dist/esm/__tests__/rollout-checker.test.js +0 -210
- package/dist/esm/__tests__/rollout-checker.test.js.map +0 -1
- package/dist/esm/__tests__/security.test.d.ts +0 -1
- package/dist/esm/__tests__/security.test.js +0 -54
- package/dist/esm/__tests__/security.test.js.map +0 -1
- package/dist/esm/__tests__/version-manager.test.d.ts +0 -1
- package/dist/esm/__tests__/version-manager.test.js +0 -45
- package/dist/esm/__tests__/version-manager.test.js.map +0 -1
- package/dist/esm/app-review/app-review-manager.js.map +0 -1
- package/dist/esm/app-review/index.js.map +0 -1
- package/dist/esm/app-review/platform-review-handler.js.map +0 -1
- package/dist/esm/app-review/review-conditions-checker.js.map +0 -1
- package/dist/esm/app-review/review-rate-limiter.js.map +0 -1
- package/dist/esm/app-review/types.js.map +0 -1
- package/dist/esm/app-update/app-update-checker.js.map +0 -1
- package/dist/esm/app-update/app-update-installer.js.map +0 -1
- package/dist/esm/app-update/app-update-manager.js.map +0 -1
- package/dist/esm/app-update/app-update-notifier.js.map +0 -1
- package/dist/esm/app-update/index.js.map +0 -1
- package/dist/esm/app-update/platform-app-update.js.map +0 -1
- package/dist/esm/app-update/types.js.map +0 -1
- package/dist/esm/background-update/background-scheduler.js.map +0 -1
- package/dist/esm/background-update/index.js.map +0 -1
- package/dist/esm/background-update/notification-manager.js.map +0 -1
- package/dist/esm/config/support.js.map +0 -1
- package/dist/esm/core/analytics.js.map +0 -1
- package/dist/esm/core/cache-manager.js.map +0 -1
- package/dist/esm/core/config.js.map +0 -1
- package/dist/esm/core/errors.js.map +0 -1
- package/dist/esm/core/event-emitter.js.map +0 -1
- package/dist/esm/core/logger.js.map +0 -1
- package/dist/esm/core/performance.js.map +0 -1
- package/dist/esm/core/plugin-manager.js.map +0 -1
- package/dist/esm/core/security.js.map +0 -1
- package/dist/esm/definitions.js.map +0 -1
- package/dist/esm/firestore/firestore-client.d.ts +0 -109
- package/dist/esm/firestore/firestore-client.js +0 -264
- package/dist/esm/firestore/firestore-client.js.map +0 -1
- package/dist/esm/firestore/index.d.ts +0 -11
- package/dist/esm/firestore/index.js +0 -11
- package/dist/esm/firestore/index.js.map +0 -1
- package/dist/esm/firestore/manifest-reader.d.ts +0 -87
- package/dist/esm/firestore/manifest-reader.js +0 -294
- package/dist/esm/firestore/manifest-reader.js.map +0 -1
- package/dist/esm/firestore/schema.d.ts +0 -507
- package/dist/esm/firestore/schema.js +0 -73
- package/dist/esm/firestore/schema.js.map +0 -1
- package/dist/esm/index.js.map +0 -1
- package/dist/esm/live-update/bundle-manager.js.map +0 -1
- package/dist/esm/live-update/certificate-pinning.js.map +0 -1
- package/dist/esm/live-update/delta-processor.js.map +0 -1
- package/dist/esm/live-update/download-manager.js.map +0 -1
- package/dist/esm/live-update/rollout-checker.js.map +0 -1
- package/dist/esm/live-update/update-manager.js.map +0 -1
- package/dist/esm/live-update/version-manager.js.map +0 -1
- package/dist/esm/plugin.js.map +0 -1
- package/dist/esm/security/crypto.js.map +0 -1
- package/dist/esm/security/validator.js.map +0 -1
- package/dist/esm/web.js.map +0 -1
- package/dist/plugin.cjs.js.map +0 -1
- package/dist/plugin.esm.js.map +0 -1
- package/dist/plugin.js.map +0 -1
- package/docs/FIREBASE_INTEGRATION_TRACKER.md +0 -321
- package/docs/FIREBASE_QUERIES_AND_INDEXES_AUDIT.md +0 -221
- package/docs/examples/firebase-backend-example.md +0 -27
- package/docs/guides/no-cost-backend-implementation-plan.md +0 -77
- package/docs/guides/no-cost-firestore-google-drive-backend.md +0 -60
- package/docs/play-console-rejection-rules.json +0 -428
- /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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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(
|
|
432
|
-
prefs.
|
|
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
|
-
|
|
512
|
-
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
208
|
-
|
|
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
|