@appsonair.ir/react-native 1.0.11 → 1.0.13
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/android/src/main/AndroidManifest.xml +8 -0
- package/android/src/main/java/com/otaupdater/OTAApkInstallHelper.kt +298 -19
- package/android/src/main/java/com/otaupdater/OTAUpdaterModule.kt +5 -1
- package/lib/sync.d.ts +6 -1
- package/lib/sync.js +49 -18
- package/lib/ui/OTAUpdateModal.js +129 -27
- package/package.json +1 -1
|
@@ -9,6 +9,14 @@
|
|
|
9
9
|
android:process=":ota_restart"
|
|
10
10
|
android:theme="@android:style/Theme.Translucent.NoTitleBar" />
|
|
11
11
|
|
|
12
|
+
<receiver
|
|
13
|
+
android:name=".OTAApkInstallReceiver"
|
|
14
|
+
android:exported="false">
|
|
15
|
+
<intent-filter>
|
|
16
|
+
<action android:name="com.otaupdater.APK_INSTALL_STATUS" />
|
|
17
|
+
</intent-filter>
|
|
18
|
+
</receiver>
|
|
19
|
+
|
|
12
20
|
<provider
|
|
13
21
|
android:name=".OTAFirebaseInitProvider"
|
|
14
22
|
android:authorities="${applicationId}.otaupdater.firebaseinit"
|
|
@@ -1,31 +1,40 @@
|
|
|
1
1
|
package com.otaupdater
|
|
2
2
|
|
|
3
|
+
import android.app.PendingIntent
|
|
4
|
+
import android.content.BroadcastReceiver
|
|
3
5
|
import android.content.Context
|
|
4
6
|
import android.content.Intent
|
|
7
|
+
import android.content.pm.PackageInfo
|
|
8
|
+
import android.content.pm.PackageInstaller
|
|
9
|
+
import android.content.pm.PackageManager
|
|
5
10
|
import android.net.Uri
|
|
6
11
|
import android.os.Build
|
|
7
12
|
import android.provider.Settings
|
|
8
13
|
import android.util.Log
|
|
9
|
-
import androidx.core.content.FileProvider
|
|
10
14
|
import com.facebook.react.bridge.ReactApplicationContext
|
|
11
15
|
import java.io.BufferedInputStream
|
|
12
16
|
import java.io.File
|
|
17
|
+
import java.io.FileInputStream
|
|
13
18
|
import java.io.FileOutputStream
|
|
19
|
+
import java.security.MessageDigest
|
|
14
20
|
import java.util.zip.ZipFile
|
|
15
21
|
|
|
16
22
|
object OTAApkInstallHelper {
|
|
17
23
|
private const val TAG = "OTAApkInstall"
|
|
18
24
|
private const val MIN_APK_BYTES = 1024L * 100
|
|
25
|
+
const val ACTION_INSTALL_STATUS = "com.otaupdater.APK_INSTALL_STATUS"
|
|
19
26
|
|
|
20
27
|
/**
|
|
21
28
|
* @return "started" when the system install screen opens, "permission_required" when settings were opened
|
|
22
29
|
*/
|
|
23
30
|
fun install(reactContext: ReactApplicationContext, path: String): String {
|
|
24
|
-
val prepared = prepareInstallableApk(File(normalizePath(path)))
|
|
31
|
+
val prepared = prepareInstallableApk(reactContext, File(normalizePath(path)))
|
|
25
32
|
return installPrepared(reactContext, prepared)
|
|
26
33
|
}
|
|
27
34
|
|
|
28
35
|
fun installPrepared(reactContext: ReactApplicationContext, prepared: File): String {
|
|
36
|
+
validateApkForInstall(reactContext, prepared)
|
|
37
|
+
|
|
29
38
|
if (needsInstallPermission(reactContext) &&
|
|
30
39
|
!reactContext.packageManager.canRequestPackageInstalls()) {
|
|
31
40
|
OTAUpdaterStorage.setPendingApkPath(reactContext, prepared.absolutePath)
|
|
@@ -33,7 +42,7 @@ object OTAApkInstallHelper {
|
|
|
33
42
|
return "permission_required"
|
|
34
43
|
}
|
|
35
44
|
|
|
36
|
-
|
|
45
|
+
commitPackageInstallerSession(reactContext, prepared)
|
|
37
46
|
OTAUpdaterStorage.clearPendingApkPath(reactContext)
|
|
38
47
|
return "started"
|
|
39
48
|
}
|
|
@@ -48,8 +57,9 @@ object OTAApkInstallHelper {
|
|
|
48
57
|
}
|
|
49
58
|
|
|
50
59
|
try {
|
|
51
|
-
val prepared = prepareInstallableApk(File(path))
|
|
52
|
-
|
|
60
|
+
val prepared = prepareInstallableApk(reactContext, File(path))
|
|
61
|
+
validateApkForInstall(reactContext, prepared)
|
|
62
|
+
commitPackageInstallerSession(reactContext, prepared)
|
|
53
63
|
OTAUpdaterStorage.clearPendingApkPath(reactContext)
|
|
54
64
|
} catch (e: Exception) {
|
|
55
65
|
Log.e(TAG, "Failed to resume APK install", e)
|
|
@@ -61,6 +71,30 @@ object OTAApkInstallHelper {
|
|
|
61
71
|
* Ensures [file] is a real APK (has AndroidManifest.xml).
|
|
62
72
|
* If it is an outer zip that contains an .apk entry, extracts that APK first.
|
|
63
73
|
*/
|
|
74
|
+
fun prepareInstallableApk(reactContext: ReactApplicationContext, file: File): File {
|
|
75
|
+
if (!file.exists() || file.length() < MIN_APK_BYTES) {
|
|
76
|
+
throw IllegalArgumentException("APK file is missing or invalid: ${file.absolutePath}")
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
val apkFile =
|
|
80
|
+
if (isAndroidApk(file)) {
|
|
81
|
+
file
|
|
82
|
+
} else {
|
|
83
|
+
val unwrapped = unwrapApkFromZip(file)
|
|
84
|
+
if (unwrapped != null && isAndroidApk(unwrapped)) {
|
|
85
|
+
Log.i(TAG, "Unwrapped nested APK from zip: ${unwrapped.absolutePath}")
|
|
86
|
+
unwrapped
|
|
87
|
+
} else {
|
|
88
|
+
throw IllegalArgumentException(
|
|
89
|
+
"Wrong package: file is not a valid Android APK (${file.name}). " +
|
|
90
|
+
"Expected an APK or a zip that contains one.",
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return copyToInstallCache(reactContext, apkFile)
|
|
96
|
+
}
|
|
97
|
+
|
|
64
98
|
fun prepareInstallableApk(file: File): File {
|
|
65
99
|
if (!file.exists() || file.length() < MIN_APK_BYTES) {
|
|
66
100
|
throw IllegalArgumentException("APK file is missing or invalid: ${file.absolutePath}")
|
|
@@ -84,6 +118,136 @@ object OTAApkInstallHelper {
|
|
|
84
118
|
|
|
85
119
|
fun isAndroidApk(file: File): Boolean = hasAndroidManifest(file)
|
|
86
120
|
|
|
121
|
+
/**
|
|
122
|
+
* Catch the usual "App not installed" causes before the system UI fails opaquely:
|
|
123
|
+
* versionCode downgrade / same build, and signing-key mismatch.
|
|
124
|
+
*/
|
|
125
|
+
fun validateApkForInstall(context: Context, apkFile: File) {
|
|
126
|
+
val pm = context.packageManager
|
|
127
|
+
val archive =
|
|
128
|
+
readArchivePackageInfo(pm, apkFile)
|
|
129
|
+
?: throw IllegalArgumentException(
|
|
130
|
+
"Wrong package: Android cannot parse this APK (${apkFile.name})",
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
val packageName =
|
|
134
|
+
archive.packageName
|
|
135
|
+
?: throw IllegalArgumentException("Wrong package: APK has no package name")
|
|
136
|
+
|
|
137
|
+
val newCode = packageLongVersionCode(archive)
|
|
138
|
+
|
|
139
|
+
val installed =
|
|
140
|
+
try {
|
|
141
|
+
readInstalledPackageInfo(pm, packageName)
|
|
142
|
+
} catch (_: PackageManager.NameNotFoundException) {
|
|
143
|
+
Log.i(TAG, "APK $packageName is a fresh install (versionCode=$newCode)")
|
|
144
|
+
return
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
val oldCode = packageLongVersionCode(installed)
|
|
148
|
+
if (newCode < oldCode) {
|
|
149
|
+
throw IllegalArgumentException(
|
|
150
|
+
"Cannot install: APK versionCode $newCode is older than installed $oldCode. " +
|
|
151
|
+
"Upload an APK with a higher versionCode.",
|
|
152
|
+
)
|
|
153
|
+
}
|
|
154
|
+
if (newCode == oldCode) {
|
|
155
|
+
throw IllegalArgumentException(
|
|
156
|
+
"Cannot install: APK versionCode $newCode is already installed. " +
|
|
157
|
+
"Bump versionCode (build number) and upload again.",
|
|
158
|
+
)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (!signaturesMatch(installed, archive)) {
|
|
162
|
+
throw IllegalArgumentException(
|
|
163
|
+
"Cannot install: APK is signed with a different key than the installed app. " +
|
|
164
|
+
"Use the same keystore that signed the app currently on the device.",
|
|
165
|
+
)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
Log.i(
|
|
169
|
+
TAG,
|
|
170
|
+
"APK OK for update: $packageName versionCode $oldCode → $newCode (${apkFile.length()} bytes)",
|
|
171
|
+
)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
private fun commitPackageInstallerSession(context: Context, apkFile: File) {
|
|
175
|
+
val installer = context.packageManager.packageInstaller
|
|
176
|
+
val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL)
|
|
177
|
+
|
|
178
|
+
val sessionId = installer.createSession(params)
|
|
179
|
+
val session = installer.openSession(sessionId)
|
|
180
|
+
|
|
181
|
+
try {
|
|
182
|
+
FileInputStream(apkFile).use { input ->
|
|
183
|
+
session.openWrite("package", 0, apkFile.length()).use { output ->
|
|
184
|
+
input.copyTo(output)
|
|
185
|
+
session.fsync(output)
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
val action = Intent(ACTION_INSTALL_STATUS).apply {
|
|
190
|
+
setPackage(context.packageName)
|
|
191
|
+
}
|
|
192
|
+
val flags =
|
|
193
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
|
194
|
+
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
|
|
195
|
+
} else {
|
|
196
|
+
PendingIntent.FLAG_UPDATE_CURRENT
|
|
197
|
+
}
|
|
198
|
+
val pending =
|
|
199
|
+
PendingIntent.getBroadcast(context, sessionId, action, flags)
|
|
200
|
+
|
|
201
|
+
session.commit(pending.intentSender)
|
|
202
|
+
Log.i(TAG, "PackageInstaller session $sessionId committed for ${apkFile.name}")
|
|
203
|
+
} catch (e: Exception) {
|
|
204
|
+
try {
|
|
205
|
+
session.abandon()
|
|
206
|
+
} catch (_: Exception) {
|
|
207
|
+
// Ignore abandon errors.
|
|
208
|
+
}
|
|
209
|
+
throw e
|
|
210
|
+
} finally {
|
|
211
|
+
try {
|
|
212
|
+
session.close()
|
|
213
|
+
} catch (_: Exception) {
|
|
214
|
+
// Ignore close errors.
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
private fun copyToInstallCache(context: Context, apkFile: File): File {
|
|
220
|
+
val cacheDir = File(context.filesDir, "ota-apk-install")
|
|
221
|
+
if (!cacheDir.exists() && !cacheDir.mkdirs()) {
|
|
222
|
+
Log.w(TAG, "Could not create install cache dir; using source APK")
|
|
223
|
+
return apkFile
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
val dest = File(cacheDir, "update.apk")
|
|
227
|
+
if (dest.absolutePath == apkFile.absolutePath) {
|
|
228
|
+
return apkFile
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
try {
|
|
232
|
+
if (dest.exists()) {
|
|
233
|
+
dest.delete()
|
|
234
|
+
}
|
|
235
|
+
apkFile.inputStream().use { input ->
|
|
236
|
+
FileOutputStream(dest).use { output ->
|
|
237
|
+
input.copyTo(output)
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
if (dest.length() != apkFile.length() || !isAndroidApk(dest)) {
|
|
241
|
+
dest.delete()
|
|
242
|
+
return apkFile
|
|
243
|
+
}
|
|
244
|
+
return dest
|
|
245
|
+
} catch (e: Exception) {
|
|
246
|
+
Log.w(TAG, "Failed to copy APK to install cache; using source", e)
|
|
247
|
+
return apkFile
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
87
251
|
private fun hasAndroidManifest(file: File): Boolean {
|
|
88
252
|
return try {
|
|
89
253
|
ZipFile(file).use { zip ->
|
|
@@ -149,6 +313,15 @@ object OTAApkInstallHelper {
|
|
|
149
313
|
}
|
|
150
314
|
}
|
|
151
315
|
|
|
316
|
+
if (entry.size > 0 && out.length() != entry.size) {
|
|
317
|
+
Log.e(
|
|
318
|
+
TAG,
|
|
319
|
+
"Unwrapped APK size mismatch: got ${out.length()}, expected ${entry.size}",
|
|
320
|
+
)
|
|
321
|
+
out.delete()
|
|
322
|
+
return null
|
|
323
|
+
}
|
|
324
|
+
|
|
152
325
|
if (out.length() < MIN_APK_BYTES) {
|
|
153
326
|
out.delete()
|
|
154
327
|
return null
|
|
@@ -175,20 +348,6 @@ object OTAApkInstallHelper {
|
|
|
175
348
|
private fun needsInstallPermission(context: Context): Boolean =
|
|
176
349
|
Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
|
|
177
350
|
|
|
178
|
-
private fun launchInstallIntent(context: Context, file: File) {
|
|
179
|
-
val authority = "${context.packageName}.otaupdater.fileprovider"
|
|
180
|
-
val uri = FileProvider.getUriForFile(context, authority, file)
|
|
181
|
-
|
|
182
|
-
val installIntent =
|
|
183
|
-
Intent(Intent.ACTION_VIEW).apply {
|
|
184
|
-
setDataAndType(uri, "application/vnd.android.package-archive")
|
|
185
|
-
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
|
186
|
-
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
context.startActivity(installIntent)
|
|
190
|
-
}
|
|
191
|
-
|
|
192
351
|
private fun openUnknownSourcesSettings(reactContext: ReactApplicationContext) {
|
|
193
352
|
val intent =
|
|
194
353
|
Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply {
|
|
@@ -204,5 +363,125 @@ object OTAApkInstallHelper {
|
|
|
204
363
|
}
|
|
205
364
|
}
|
|
206
365
|
|
|
366
|
+
private fun readArchivePackageInfo(pm: PackageManager, apkFile: File): PackageInfo? {
|
|
367
|
+
val info =
|
|
368
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
|
369
|
+
pm.getPackageArchiveInfo(
|
|
370
|
+
apkFile.absolutePath,
|
|
371
|
+
PackageManager.PackageInfoFlags.of(PackageManager.GET_SIGNING_CERTIFICATES.toLong()),
|
|
372
|
+
)
|
|
373
|
+
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
|
374
|
+
@Suppress("DEPRECATION")
|
|
375
|
+
pm.getPackageArchiveInfo(apkFile.absolutePath, PackageManager.GET_SIGNING_CERTIFICATES)
|
|
376
|
+
} else {
|
|
377
|
+
@Suppress("DEPRECATION")
|
|
378
|
+
pm.getPackageArchiveInfo(apkFile.absolutePath, PackageManager.GET_SIGNATURES)
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
info?.applicationInfo?.sourceDir = apkFile.absolutePath
|
|
382
|
+
info?.applicationInfo?.publicSourceDir = apkFile.absolutePath
|
|
383
|
+
return info
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
private fun readInstalledPackageInfo(pm: PackageManager, packageName: String): PackageInfo {
|
|
387
|
+
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
|
388
|
+
pm.getPackageInfo(
|
|
389
|
+
packageName,
|
|
390
|
+
PackageManager.PackageInfoFlags.of(PackageManager.GET_SIGNING_CERTIFICATES.toLong()),
|
|
391
|
+
)
|
|
392
|
+
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
|
393
|
+
@Suppress("DEPRECATION")
|
|
394
|
+
pm.getPackageInfo(packageName, PackageManager.GET_SIGNING_CERTIFICATES)
|
|
395
|
+
} else {
|
|
396
|
+
@Suppress("DEPRECATION")
|
|
397
|
+
pm.getPackageInfo(packageName, PackageManager.GET_SIGNATURES)
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
private fun packageLongVersionCode(info: PackageInfo): Long {
|
|
402
|
+
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
|
403
|
+
info.longVersionCode
|
|
404
|
+
} else {
|
|
405
|
+
@Suppress("DEPRECATION")
|
|
406
|
+
info.versionCode.toLong()
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
private fun signaturesMatch(installed: PackageInfo, archive: PackageInfo): Boolean {
|
|
411
|
+
val installedDigests = signingDigests(installed)
|
|
412
|
+
val archiveDigests = signingDigests(archive)
|
|
413
|
+
if (installedDigests.isEmpty() || archiveDigests.isEmpty()) {
|
|
414
|
+
// Some devices omit archive signing info — don't block install on missing data.
|
|
415
|
+
Log.w(TAG, "Could not compare APK signatures; continuing install")
|
|
416
|
+
return true
|
|
417
|
+
}
|
|
418
|
+
return installedDigests.any { it in archiveDigests }
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
private fun signingDigests(info: PackageInfo): Set<String> {
|
|
422
|
+
val digests = linkedSetOf<String>()
|
|
423
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
|
424
|
+
val signingInfo = info.signingInfo ?: return digests
|
|
425
|
+
val signers =
|
|
426
|
+
if (signingInfo.hasMultipleSigners()) {
|
|
427
|
+
signingInfo.apkContentsSigners
|
|
428
|
+
} else {
|
|
429
|
+
signingInfo.signingCertificateHistory
|
|
430
|
+
}
|
|
431
|
+
for (signature in signers) {
|
|
432
|
+
digests.add(sha256Hex(signature.toByteArray()))
|
|
433
|
+
}
|
|
434
|
+
} else {
|
|
435
|
+
@Suppress("DEPRECATION")
|
|
436
|
+
val signatures = info.signatures ?: return digests
|
|
437
|
+
for (signature in signatures) {
|
|
438
|
+
digests.add(sha256Hex(signature.toByteArray()))
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
return digests
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
private fun sha256Hex(bytes: ByteArray): String {
|
|
445
|
+
val digest = MessageDigest.getInstance("SHA-256").digest(bytes)
|
|
446
|
+
return digest.joinToString("") { "%02x".format(it) }
|
|
447
|
+
}
|
|
448
|
+
|
|
207
449
|
private fun normalizePath(path: String): String = path.removePrefix("file://")
|
|
208
450
|
}
|
|
451
|
+
|
|
452
|
+
class OTAApkInstallReceiver : BroadcastReceiver() {
|
|
453
|
+
override fun onReceive(context: Context, intent: Intent?) {
|
|
454
|
+
if (intent == null) return
|
|
455
|
+
|
|
456
|
+
val status =
|
|
457
|
+
intent.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE)
|
|
458
|
+
val message = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE)
|
|
459
|
+
|
|
460
|
+
when (status) {
|
|
461
|
+
PackageInstaller.STATUS_PENDING_USER_ACTION -> {
|
|
462
|
+
val confirm =
|
|
463
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
|
464
|
+
intent.getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java)
|
|
465
|
+
} else {
|
|
466
|
+
@Suppress("DEPRECATION")
|
|
467
|
+
intent.getParcelableExtra(Intent.EXTRA_INTENT)
|
|
468
|
+
}
|
|
469
|
+
if (confirm != null) {
|
|
470
|
+
confirm.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
|
471
|
+
context.startActivity(confirm)
|
|
472
|
+
} else {
|
|
473
|
+
Log.e(OTAApkInstallHelper.TAG, "Install pending user action but EXTRA_INTENT missing")
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
PackageInstaller.STATUS_SUCCESS -> {
|
|
477
|
+
Log.i(OTAApkInstallHelper.TAG, "APK installed successfully")
|
|
478
|
+
}
|
|
479
|
+
else -> {
|
|
480
|
+
Log.e(
|
|
481
|
+
OTAApkInstallHelper.TAG,
|
|
482
|
+
"APK install failed status=$status message=${message ?: "unknown"}",
|
|
483
|
+
)
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
}
|
|
@@ -110,7 +110,11 @@ class OTAUpdaterModule(private val reactContext: ReactApplicationContext) :
|
|
|
110
110
|
fun installApk(path: String, promise: Promise) {
|
|
111
111
|
Thread {
|
|
112
112
|
try {
|
|
113
|
-
val prepared =
|
|
113
|
+
val prepared =
|
|
114
|
+
OTAApkInstallHelper.prepareInstallableApk(
|
|
115
|
+
reactContext,
|
|
116
|
+
File(path.removePrefix("file://")),
|
|
117
|
+
)
|
|
114
118
|
UiThreadUtil.runOnUiThread {
|
|
115
119
|
try {
|
|
116
120
|
val result = OTAApkInstallHelper.installPrepared(reactContext, prepared)
|
package/lib/sync.d.ts
CHANGED
|
@@ -77,7 +77,12 @@ export declare function checkForUpdate(serverUrl: string, deploymentKey: string,
|
|
|
77
77
|
export declare function getBundleReleaseDir(label: string): string;
|
|
78
78
|
export declare function getBundleFilePath(label: string): string;
|
|
79
79
|
export declare function getApkFilePath(label: string): string;
|
|
80
|
-
export declare function downloadBundle(downloadUrl: string, deploymentKey: string, label: string, expectedSize?: number, onProgress?: (progress: number, received?: number, total?: number) => void, extension?: 'bundle' | 'apk', deviceAbi?: string, packageFormat?: 'bundle' | 'zip', expectedHash?: string
|
|
80
|
+
export declare function downloadBundle(downloadUrl: string, deploymentKey: string, label: string, expectedSize?: number, onProgress?: (progress: number, received?: number, total?: number) => void, extension?: 'bundle' | 'apk', deviceAbi?: string, packageFormat?: 'bundle' | 'zip', expectedHash?: string,
|
|
81
|
+
/**
|
|
82
|
+
* When false, APK zip downloads are left as `.zip` for native unwrap/install.
|
|
83
|
+
* JS-side unzip has produced invalid `.apk` files on some devices.
|
|
84
|
+
*/
|
|
85
|
+
extractApk?: boolean): Promise<string>;
|
|
81
86
|
export declare function verifyBundleHash(filePath: string, expectedHash: string): Promise<boolean>;
|
|
82
87
|
type ProgressCallback = (status: SyncStatus, progress?: number) => void;
|
|
83
88
|
export declare function isSyncInProgress(): boolean;
|
package/lib/sync.js
CHANGED
|
@@ -750,7 +750,7 @@ async function cleanupPartialDownload(path) {
|
|
|
750
750
|
* bytesWritten !== Content-Length — including when Content-Length is wrong
|
|
751
751
|
* but the full file actually landed. Recover those cases via size/hash checks.
|
|
752
752
|
*/
|
|
753
|
-
async function tryRecoverCompletedDownload(destPath, extension, totalBytes, expectedHash, packageFormat, label, deviceAbi) {
|
|
753
|
+
async function tryRecoverCompletedDownload(destPath, extension, totalBytes, expectedHash, packageFormat, label, deviceAbi, extractApk = true) {
|
|
754
754
|
try {
|
|
755
755
|
const ReactNativeBlobUtil = require('react-native-blob-util').default;
|
|
756
756
|
const exists = await ReactNativeBlobUtil.fs.exists(destPath);
|
|
@@ -769,7 +769,9 @@ async function tryRecoverCompletedDownload(destPath, extension, totalBytes, expe
|
|
|
769
769
|
else if (!sizeLooksComplete) {
|
|
770
770
|
return null;
|
|
771
771
|
}
|
|
772
|
-
if (
|
|
772
|
+
if (extractApk &&
|
|
773
|
+
extension === 'apk' &&
|
|
774
|
+
(await shouldExtractApkZip(destPath, packageFormat))) {
|
|
773
775
|
return extractApkUpdate(label, destPath, deviceAbi);
|
|
774
776
|
}
|
|
775
777
|
if (extension === 'bundle' && (await shouldExtractZipPackage(destPath, packageFormat))) {
|
|
@@ -781,7 +783,12 @@ async function tryRecoverCompletedDownload(destPath, extension, totalBytes, expe
|
|
|
781
783
|
return null;
|
|
782
784
|
}
|
|
783
785
|
}
|
|
784
|
-
async function downloadBundle(downloadUrl, deploymentKey, label, expectedSize, onProgress, extension = 'bundle', deviceAbi, packageFormat = 'bundle', expectedHash
|
|
786
|
+
async function downloadBundle(downloadUrl, deploymentKey, label, expectedSize, onProgress, extension = 'bundle', deviceAbi, packageFormat = 'bundle', expectedHash,
|
|
787
|
+
/**
|
|
788
|
+
* When false, APK zip downloads are left as `.zip` for native unwrap/install.
|
|
789
|
+
* JS-side unzip has produced invalid `.apk` files on some devices.
|
|
790
|
+
*/
|
|
791
|
+
extractApk = true) {
|
|
785
792
|
const ReactNativeBlobUtil = require('react-native-blob-util').default;
|
|
786
793
|
const isZipBundle = extension === 'bundle' && packageFormat === 'zip';
|
|
787
794
|
const isApkZip = extension === 'apk' && packageFormat === 'zip';
|
|
@@ -812,15 +819,29 @@ async function downloadBundle(downloadUrl, deploymentKey, label, expectedSize, o
|
|
|
812
819
|
'X-Deployment-Key': deploymentKey,
|
|
813
820
|
...(deviceAbi ? { 'X-Device-Abi': deviceAbi } : {}),
|
|
814
821
|
});
|
|
822
|
+
let lastReportedProgress = 0;
|
|
823
|
+
let lastReportedReceived = 0;
|
|
824
|
+
let lastReportedTotal = totalBytes > 0 ? totalBytes : 0;
|
|
815
825
|
const reportDownloadProgress = (received, total) => {
|
|
816
826
|
if (!onProgress)
|
|
817
827
|
return;
|
|
818
|
-
const
|
|
819
|
-
|
|
820
|
-
|
|
828
|
+
const safeReceived = Math.max(Number(received) || 0, lastReportedReceived);
|
|
829
|
+
let resolvedTotal = total > 0 ? total : totalBytes;
|
|
830
|
+
if (!(resolvedTotal > 0)) {
|
|
831
|
+
resolvedTotal = lastReportedTotal;
|
|
832
|
+
}
|
|
833
|
+
if (resolvedTotal > lastReportedTotal) {
|
|
834
|
+
lastReportedTotal = resolvedTotal;
|
|
835
|
+
}
|
|
836
|
+
// Keep bytes monotonic even when total is still unknown.
|
|
837
|
+
lastReportedReceived = safeReceived;
|
|
838
|
+
if (!(resolvedTotal > 0)) {
|
|
839
|
+
onProgress(lastReportedProgress, safeReceived, 0);
|
|
821
840
|
return;
|
|
822
841
|
}
|
|
823
|
-
|
|
842
|
+
const nextProgress = Math.min(Math.max(safeReceived / resolvedTotal, lastReportedProgress), 0.99);
|
|
843
|
+
lastReportedProgress = nextProgress;
|
|
844
|
+
onProgress(nextProgress, safeReceived, resolvedTotal);
|
|
824
845
|
};
|
|
825
846
|
let pollTimer = null;
|
|
826
847
|
if (onProgress && totalBytes > 0) {
|
|
@@ -862,7 +883,9 @@ async function downloadBundle(downloadUrl, deploymentKey, label, expectedSize, o
|
|
|
862
883
|
throw new Error('Wrong package: hash verification failed');
|
|
863
884
|
}
|
|
864
885
|
}
|
|
865
|
-
if (
|
|
886
|
+
if (extractApk &&
|
|
887
|
+
extension === 'apk' &&
|
|
888
|
+
(await shouldExtractApkZip(destPath, packageFormat))) {
|
|
866
889
|
return extractApkUpdate(label, destPath, deviceAbi);
|
|
867
890
|
}
|
|
868
891
|
if (extension === 'bundle' && (await shouldExtractZipPackage(destPath, packageFormat))) {
|
|
@@ -873,7 +896,7 @@ async function downloadBundle(downloadUrl, deploymentKey, label, expectedSize, o
|
|
|
873
896
|
catch (error) {
|
|
874
897
|
lastError = error;
|
|
875
898
|
if (isRetryableDownloadError(error)) {
|
|
876
|
-
const recovered = await tryRecoverCompletedDownload(destPath, extension, totalBytes, expectedHash, packageFormat, label, deviceAbi);
|
|
899
|
+
const recovered = await tryRecoverCompletedDownload(destPath, extension, totalBytes, expectedHash, packageFormat, label, deviceAbi, extractApk);
|
|
877
900
|
if (recovered) {
|
|
878
901
|
if (onProgress)
|
|
879
902
|
onProgress(1);
|
|
@@ -1161,22 +1184,23 @@ async function performApkSync(update, showUI, messages, onProgress, changelog, c
|
|
|
1161
1184
|
}
|
|
1162
1185
|
const notes = changelog ?? getUpdateChangelog(update);
|
|
1163
1186
|
const progressExtra = buildUpdateProgressExtra(update, undefined, currentLabel);
|
|
1187
|
+
// Prefer zip (smaller). Native unwraps the APK before PackageInstaller runs.
|
|
1164
1188
|
const packageFormat = update.packageFormat ?? 'zip';
|
|
1165
1189
|
try {
|
|
1166
1190
|
reportProgress(showUI, messages, onProgress, types_1.SyncStatus.DOWNLOADING, 0, progressExtra);
|
|
1167
|
-
const
|
|
1191
|
+
const packagePath = await downloadBundle(update.downloadUrl, (0, config_1.getConfig)().deploymentKey, update.label, update.size, (p, received, total) => reportProgress(showUI, messages, onProgress, types_1.SyncStatus.DOWNLOADING, p, {
|
|
1168
1192
|
...progressExtra,
|
|
1169
1193
|
downloadedBytes: received,
|
|
1170
1194
|
totalBytes: total && total > 0 ? total : progressExtra.totalBytes,
|
|
1171
|
-
}), 'apk', update.deviceAbi, packageFormat, update.packageHash);
|
|
1172
|
-
|
|
1173
|
-
|
|
1195
|
+
}), 'apk', update.deviceAbi, packageFormat, update.packageHash, false);
|
|
1196
|
+
const lowerPath = packagePath.toLowerCase();
|
|
1197
|
+
if (!lowerPath.endsWith('.apk') && !lowerPath.endsWith('.zip')) {
|
|
1198
|
+
throw new Error('Wrong package: expected an APK or zip-of-APK download');
|
|
1174
1199
|
}
|
|
1175
1200
|
onProgress?.(types_1.SyncStatus.INSTALLING, 1);
|
|
1176
|
-
// Drop the OTA JS
|
|
1177
|
-
|
|
1178
|
-
await
|
|
1179
|
-
const installResult = await installApk(apkPath);
|
|
1201
|
+
// Drop only the OTA JS bundle — do not delete the APK/zip about to install.
|
|
1202
|
+
await clearJsBundleState();
|
|
1203
|
+
const installResult = await installApk(packagePath);
|
|
1180
1204
|
if (installResult === 'permission_required') {
|
|
1181
1205
|
reportProgress(showUI, messages, onProgress, types_1.SyncStatus.INSTALLING, 1, {
|
|
1182
1206
|
...progressExtra,
|
|
@@ -1228,7 +1252,8 @@ async function getInstalledBundlePath() {
|
|
|
1228
1252
|
}
|
|
1229
1253
|
return null;
|
|
1230
1254
|
}
|
|
1231
|
-
|
|
1255
|
+
/** Clear persisted JS OTA state without touching APK/zip install candidates. */
|
|
1256
|
+
async function clearJsBundleState() {
|
|
1232
1257
|
const meta = await getStoredBundleMeta();
|
|
1233
1258
|
await clearStoredBundleMeta();
|
|
1234
1259
|
if (OTAUpdaterNative?.clearBundlePath) {
|
|
@@ -1238,6 +1263,12 @@ async function clearUpdate() {
|
|
|
1238
1263
|
await removePathRecursive(getBundleReleaseDir(meta.label));
|
|
1239
1264
|
await cleanupPartialDownload(getBundleZipPath(meta.label));
|
|
1240
1265
|
await cleanupPartialDownload(getLegacyBundleFilePath(meta.label));
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
async function clearUpdate() {
|
|
1269
|
+
const meta = await getStoredBundleMeta();
|
|
1270
|
+
await clearJsBundleState();
|
|
1271
|
+
if (meta?.label) {
|
|
1241
1272
|
await cleanupPartialDownload(getApkFilePath(meta.label));
|
|
1242
1273
|
await cleanupPartialDownload(getApkZipPath(meta.label));
|
|
1243
1274
|
await removePathRecursive(getApkExtractDir(meta.label));
|
package/lib/ui/OTAUpdateModal.js
CHANGED
|
@@ -38,6 +38,25 @@ const react_1 = __importStar(require("react"));
|
|
|
38
38
|
const react_native_1 = require("react-native");
|
|
39
39
|
const sync_1 = require("../sync");
|
|
40
40
|
const types_1 = require("../types");
|
|
41
|
+
/** Never returns "—" so download size labels don't flicker between ticks. */
|
|
42
|
+
function stableFormatBytes(bytes) {
|
|
43
|
+
if (bytes == null || !Number.isFinite(bytes) || bytes < 0) {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
if (bytes < 1024) {
|
|
47
|
+
return `${Math.round(bytes)} B`;
|
|
48
|
+
}
|
|
49
|
+
if (bytes < 1024 * 1024) {
|
|
50
|
+
return `${(bytes / 1024).toFixed(1)} KB`;
|
|
51
|
+
}
|
|
52
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
53
|
+
}
|
|
54
|
+
function joinVersionParts(parts) {
|
|
55
|
+
const filtered = parts
|
|
56
|
+
.map((part) => (part == null ? '' : String(part).trim()))
|
|
57
|
+
.filter(Boolean);
|
|
58
|
+
return filtered.length > 0 ? filtered.join(' ') : null;
|
|
59
|
+
}
|
|
41
60
|
function OTAUpdateModal({ visible, status, progress, message, description, isMandatory, error, showActions, canSkip, updateType, appVersion, buildNumber, currentAppVersion, currentBuildNumber, currentLabel, label, totalBytes, downloadedBytes, title, fontFamily, messages, onUpdate, onSkip, onDismiss, }) {
|
|
42
61
|
const copy = (0, react_1.useMemo)(() => ({ ...sync_1.defaultMessages, ...messages }), [messages]);
|
|
43
62
|
const resolvedTitle = (0, react_1.useMemo)(() => {
|
|
@@ -59,13 +78,52 @@ function OTAUpdateModal({ visible, status, progress, message, description, isMan
|
|
|
59
78
|
...(customFontStyle ?? null),
|
|
60
79
|
writingDirection: 'rtl',
|
|
61
80
|
}), [customFontStyle]);
|
|
81
|
+
const isDownloading = status === types_1.SyncStatus.DOWNLOADING;
|
|
82
|
+
const stableProgressRef = (0, react_1.useRef)({ progress: 0, downloaded: 0, total: 0 });
|
|
83
|
+
(0, react_1.useEffect)(() => {
|
|
84
|
+
if (!isDownloading) {
|
|
85
|
+
stableProgressRef.current = { progress: 0, downloaded: 0, total: 0 };
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
const nextProgress = Math.min(Math.max(Number(progress) || 0, 0), 1);
|
|
89
|
+
const nextDownloaded = Number(downloadedBytes);
|
|
90
|
+
const nextTotal = Number(totalBytes);
|
|
91
|
+
if (nextProgress >= stableProgressRef.current.progress) {
|
|
92
|
+
stableProgressRef.current.progress = nextProgress;
|
|
93
|
+
}
|
|
94
|
+
if (Number.isFinite(nextTotal) && nextTotal > stableProgressRef.current.total) {
|
|
95
|
+
stableProgressRef.current.total = nextTotal;
|
|
96
|
+
}
|
|
97
|
+
if (Number.isFinite(nextDownloaded) &&
|
|
98
|
+
nextDownloaded >= stableProgressRef.current.downloaded) {
|
|
99
|
+
stableProgressRef.current.downloaded = nextDownloaded;
|
|
100
|
+
}
|
|
101
|
+
}, [isDownloading, progress, downloadedBytes, totalBytes]);
|
|
62
102
|
if (!visible)
|
|
63
103
|
return null;
|
|
64
|
-
|
|
104
|
+
if (isDownloading) {
|
|
105
|
+
const nextProgress = Math.min(Math.max(Number(progress) || 0, 0), 1);
|
|
106
|
+
const nextDownloaded = Number(downloadedBytes);
|
|
107
|
+
const nextTotal = Number(totalBytes);
|
|
108
|
+
if (nextProgress >= stableProgressRef.current.progress) {
|
|
109
|
+
stableProgressRef.current.progress = nextProgress;
|
|
110
|
+
}
|
|
111
|
+
if (Number.isFinite(nextTotal) && nextTotal > stableProgressRef.current.total) {
|
|
112
|
+
stableProgressRef.current.total = nextTotal;
|
|
113
|
+
}
|
|
114
|
+
if (Number.isFinite(nextDownloaded) &&
|
|
115
|
+
nextDownloaded >= stableProgressRef.current.downloaded) {
|
|
116
|
+
stableProgressRef.current.downloaded = nextDownloaded;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
const stableProgress = isDownloading
|
|
120
|
+
? stableProgressRef.current.progress
|
|
121
|
+
: Math.min(Math.max(Number(progress) || 0, 0), 1);
|
|
122
|
+
const progressPercent = Math.round(stableProgress * 100);
|
|
65
123
|
const isPrompt = status === types_1.SyncStatus.UPDATE_AVAILABLE;
|
|
66
124
|
const isDeferredDone = status === types_1.SyncStatus.UPDATE_INSTALLED;
|
|
67
125
|
const isError = status === types_1.SyncStatus.ERROR;
|
|
68
|
-
const showProgressBar =
|
|
126
|
+
const showProgressBar = isDownloading;
|
|
69
127
|
const showSpinner = status === types_1.SyncStatus.RESTARTING ||
|
|
70
128
|
status === types_1.SyncStatus.INSTALLING ||
|
|
71
129
|
(!showProgressBar && !isPrompt && !isDeferredDone && !isError);
|
|
@@ -74,22 +132,42 @@ function OTAUpdateModal({ visible, status, progress, message, description, isMan
|
|
|
74
132
|
: updateType === 'bundle'
|
|
75
133
|
? copy.versionBundle
|
|
76
134
|
: null;
|
|
135
|
+
// Show both version number and release label/name when available.
|
|
77
136
|
const currentVersionText = updateType === 'apk'
|
|
78
|
-
? [
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
137
|
+
? joinVersionParts([
|
|
138
|
+
currentAppVersion,
|
|
139
|
+
currentBuildNumber ? `(${currentBuildNumber})` : null,
|
|
140
|
+
])
|
|
141
|
+
: joinVersionParts([
|
|
142
|
+
currentAppVersion,
|
|
143
|
+
currentLabel && currentLabel !== currentAppVersion ? currentLabel : null,
|
|
144
|
+
]);
|
|
82
145
|
const nextVersionText = updateType === 'apk'
|
|
83
|
-
? [
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
146
|
+
? joinVersionParts([
|
|
147
|
+
appVersion,
|
|
148
|
+
buildNumber != null ? `(${buildNumber})` : null,
|
|
149
|
+
label && label !== appVersion ? label : null,
|
|
150
|
+
])
|
|
151
|
+
: joinVersionParts([
|
|
152
|
+
appVersion,
|
|
153
|
+
label && label !== appVersion ? label : null,
|
|
154
|
+
]);
|
|
87
155
|
const handleUpdate = () => (onUpdate ? onUpdate() : (0, sync_1.respondToUpdatePrompt)('update'));
|
|
88
156
|
const handleSkip = () => (onSkip ? onSkip() : (0, sync_1.respondToUpdatePrompt)('skip'));
|
|
89
157
|
const handleDismiss = () => onDismiss ? onDismiss() : (0, sync_1.respondToUpdatePrompt)('dismiss');
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
158
|
+
const stableDownloaded = showProgressBar
|
|
159
|
+
? stableProgressRef.current.downloaded
|
|
160
|
+
: downloadedBytes ?? 0;
|
|
161
|
+
const stableTotal = showProgressBar
|
|
162
|
+
? stableProgressRef.current.total
|
|
163
|
+
: totalBytes ?? 0;
|
|
164
|
+
const downloadedLabel = stableFormatBytes(stableDownloaded) || '0.0 MB';
|
|
165
|
+
const totalLabel = stableFormatBytes(stableTotal);
|
|
166
|
+
const sizeLabel = totalLabel
|
|
167
|
+
? `${downloadedLabel} / ${totalLabel}`
|
|
168
|
+
: downloadedLabel;
|
|
169
|
+
const showSizes = showProgressBar;
|
|
170
|
+
const showPromptSize = !showProgressBar && stableTotal > 0 && (isPrompt || isError);
|
|
93
171
|
return (<react_native_1.Modal visible transparent animationType="fade" statusBarTranslucent onRequestClose={() => {
|
|
94
172
|
if (canSkip)
|
|
95
173
|
handleSkip();
|
|
@@ -134,20 +212,25 @@ function OTAUpdateModal({ visible, status, progress, message, description, isMan
|
|
|
134
212
|
</react_native_1.ScrollView>
|
|
135
213
|
</react_native_1.View>) : null}
|
|
136
214
|
|
|
137
|
-
{showProgressBar ? (
|
|
215
|
+
{showProgressBar ? (<react_native_1.View style={styles.progressBlock}>
|
|
138
216
|
<react_native_1.View style={styles.progressTrack}>
|
|
139
|
-
<react_native_1.View style={[
|
|
217
|
+
<react_native_1.View style={[
|
|
218
|
+
styles.progressFill,
|
|
219
|
+
{ width: `${Math.max(progressPercent, 2)}%` },
|
|
220
|
+
]}/>
|
|
140
221
|
</react_native_1.View>
|
|
141
|
-
<react_native_1.
|
|
142
|
-
{
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
222
|
+
<react_native_1.View style={styles.progressMetaRow}>
|
|
223
|
+
<react_native_1.Text style={[styles.percent, !fontFamily && styles.percentBold]}>
|
|
224
|
+
{progressPercent}٪
|
|
225
|
+
</react_native_1.Text>
|
|
226
|
+
{showSizes ? (<react_native_1.Text style={styles.sizeText} numberOfLines={1}>
|
|
227
|
+
{sizeLabel}
|
|
228
|
+
</react_native_1.Text>) : null}
|
|
229
|
+
</react_native_1.View>
|
|
230
|
+
</react_native_1.View>) : null}
|
|
148
231
|
|
|
149
|
-
{
|
|
150
|
-
حجم دانلود: {(
|
|
232
|
+
{showPromptSize ? (<react_native_1.Text style={[styles.sizeTextCentered, textStyle]}>
|
|
233
|
+
حجم دانلود: {stableFormatBytes(stableTotal)}
|
|
151
234
|
</react_native_1.Text>) : null}
|
|
152
235
|
|
|
153
236
|
{showSpinner ? (<react_native_1.ActivityIndicator size="large" color="#2563eb" style={styles.spinner}/>) : null}
|
|
@@ -273,29 +356,48 @@ const styles = react_native_1.StyleSheet.create({
|
|
|
273
356
|
textAlign: 'center',
|
|
274
357
|
lineHeight: 20,
|
|
275
358
|
},
|
|
359
|
+
progressBlock: {
|
|
360
|
+
width: '100%',
|
|
361
|
+
marginTop: 8,
|
|
362
|
+
},
|
|
276
363
|
progressTrack: {
|
|
277
364
|
width: '100%',
|
|
278
365
|
height: 8,
|
|
279
366
|
backgroundColor: '#e5e7eb',
|
|
280
367
|
borderRadius: 999,
|
|
281
368
|
overflow: 'hidden',
|
|
282
|
-
marginTop: 8,
|
|
283
369
|
},
|
|
284
370
|
progressFill: {
|
|
285
371
|
height: '100%',
|
|
286
372
|
backgroundColor: '#2563eb',
|
|
287
373
|
borderRadius: 999,
|
|
288
374
|
},
|
|
289
|
-
|
|
375
|
+
progressMetaRow: {
|
|
290
376
|
marginTop: 8,
|
|
377
|
+
width: '100%',
|
|
378
|
+
flexDirection: 'row',
|
|
379
|
+
alignItems: 'center',
|
|
380
|
+
justifyContent: 'space-between',
|
|
381
|
+
direction: 'ltr',
|
|
382
|
+
},
|
|
383
|
+
percent: {
|
|
291
384
|
fontSize: 13,
|
|
292
385
|
color: '#6b7280',
|
|
293
|
-
textAlign: '
|
|
386
|
+
textAlign: 'left',
|
|
387
|
+
writingDirection: 'ltr',
|
|
294
388
|
},
|
|
295
389
|
percentBold: {
|
|
296
390
|
fontWeight: '600',
|
|
297
391
|
},
|
|
298
392
|
sizeText: {
|
|
393
|
+
fontSize: 12,
|
|
394
|
+
color: '#9ca3af',
|
|
395
|
+
textAlign: 'right',
|
|
396
|
+
writingDirection: 'ltr',
|
|
397
|
+
flexShrink: 1,
|
|
398
|
+
marginLeft: 8,
|
|
399
|
+
},
|
|
400
|
+
sizeTextCentered: {
|
|
299
401
|
marginTop: 4,
|
|
300
402
|
fontSize: 12,
|
|
301
403
|
color: '#9ca3af',
|