@hot-updater/react-native 1.0.4 → 1.0.6

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 (68) hide show
  1. package/HotUpdater.podspec +24 -3
  2. package/Package.swift +100 -0
  3. package/android/build.gradle +3 -4
  4. package/android/hot-updater-builtin-assets.gradle +67 -0
  5. package/android/hot-updater-min-bundle-id.gradle +69 -0
  6. package/android/src/main/java/com/hotupdater/ArtifactFileResolver.kt +85 -0
  7. package/android/src/main/java/com/hotupdater/BuiltInAssetResolver.kt +116 -11
  8. package/android/src/main/java/com/hotupdater/BundleFileStorageService.kt +117 -12
  9. package/android/src/main/java/com/hotupdater/HotUpdaterImpl.kt +5 -52
  10. package/android/src/newarch/HotUpdaterModule.kt +46 -2
  11. package/android/src/oldarch/HotUpdaterModule.kt +46 -2
  12. package/ios/HotUpdater/Interface/HotUpdaterInterface.h +66 -0
  13. package/ios/HotUpdater/Interface/HotUpdaterInterface.m +3 -0
  14. package/ios/HotUpdater/Internal/ArtifactFileResolver.swift +87 -0
  15. package/ios/HotUpdater/Internal/BundleFileStorageService.swift +88 -13
  16. package/ios/HotUpdater/Internal/HotUpdater.mm +41 -90
  17. package/ios/HotUpdater/Internal/HotUpdaterImpl.swift +25 -11
  18. package/ios/HotUpdater/Internal/HotUpdaterInterface.swift +26 -0
  19. package/ios/HotUpdater/Internal/HotUpdaterMinBundleId.m +10 -0
  20. package/ios/HotUpdater/Package.swift +88 -0
  21. package/ios/Plugins/HotUpdaterMinBundleIdPlugin/HotUpdaterMinBundleIdPlugin.swift +28 -0
  22. package/ios/scripts/generate-min-bundle-id.zsh +49 -0
  23. package/lib/commonjs/checkForUpdate.js +8 -0
  24. package/lib/commonjs/checkForUpdate.js.map +1 -1
  25. package/lib/commonjs/fetchUpdateResponse.js +5 -2
  26. package/lib/commonjs/fetchUpdateResponse.js.map +1 -1
  27. package/lib/commonjs/httpClient.js +64 -5
  28. package/lib/commonjs/httpClient.js.map +1 -1
  29. package/lib/commonjs/native.js +9 -0
  30. package/lib/commonjs/native.js.map +1 -1
  31. package/lib/commonjs/sdkVersion.js +1 -1
  32. package/lib/commonjs/specs/NativeHotUpdater.js.map +1 -1
  33. package/lib/module/checkForUpdate.js +8 -0
  34. package/lib/module/checkForUpdate.js.map +1 -1
  35. package/lib/module/fetchUpdateResponse.js +4 -1
  36. package/lib/module/fetchUpdateResponse.js.map +1 -1
  37. package/lib/module/httpClient.js +67 -7
  38. package/lib/module/httpClient.js.map +1 -1
  39. package/lib/module/native.js +9 -0
  40. package/lib/module/native.js.map +1 -1
  41. package/lib/module/sdkVersion.js +1 -1
  42. package/lib/module/specs/NativeHotUpdater.js.map +1 -1
  43. package/lib/typescript/commonjs/checkForUpdate.d.ts.map +1 -1
  44. package/lib/typescript/commonjs/fetchUpdateResponse.d.ts +2 -0
  45. package/lib/typescript/commonjs/fetchUpdateResponse.d.ts.map +1 -1
  46. package/lib/typescript/commonjs/httpClient.d.ts +18 -2
  47. package/lib/typescript/commonjs/httpClient.d.ts.map +1 -1
  48. package/lib/typescript/commonjs/native.d.ts.map +1 -1
  49. package/lib/typescript/commonjs/sdkVersion.d.ts +1 -1
  50. package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts +13 -1
  51. package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts.map +1 -1
  52. package/lib/typescript/module/checkForUpdate.d.ts.map +1 -1
  53. package/lib/typescript/module/fetchUpdateResponse.d.ts +2 -0
  54. package/lib/typescript/module/fetchUpdateResponse.d.ts.map +1 -1
  55. package/lib/typescript/module/httpClient.d.ts +18 -2
  56. package/lib/typescript/module/httpClient.d.ts.map +1 -1
  57. package/lib/typescript/module/native.d.ts.map +1 -1
  58. package/lib/typescript/module/sdkVersion.d.ts +1 -1
  59. package/lib/typescript/module/specs/NativeHotUpdater.d.ts +13 -1
  60. package/lib/typescript/module/specs/NativeHotUpdater.d.ts.map +1 -1
  61. package/package.json +5 -4
  62. package/react-native.config.js +7 -0
  63. package/src/checkForUpdate.ts +11 -0
  64. package/src/fetchUpdateResponse.ts +4 -1
  65. package/src/httpClient.ts +84 -9
  66. package/src/native.ts +7 -0
  67. package/src/sdkVersion.ts +1 -1
  68. package/src/specs/NativeHotUpdater.ts +13 -1
@@ -16,14 +16,35 @@ Pod::Spec.new do |s|
16
16
  s.source_files = "ios/**/*.{h,m,mm,swift}"
17
17
  s.public_header_files = "ios/HotUpdater/Public/*.h"
18
18
  s.private_header_files = "ios/HotUpdater/Internal/*.h"
19
- s.exclude_files = ["ios/HotUpdater/Package.swift", "ios/HotUpdater/Test/**/*.{swift,h,m,mm}"]
19
+ # ios/HotUpdater/Interface, ios/Plugins and HotUpdaterInterface.swift are
20
+ # for Swift Package Manager builds (Package.swift) only.
21
+ s.exclude_files = [
22
+ "ios/HotUpdater/Package.swift",
23
+ "ios/HotUpdater/Test/**/*.{swift,h,m,mm}",
24
+ "ios/HotUpdater/Interface/**/*",
25
+ "ios/HotUpdater/Internal/HotUpdaterInterface.swift",
26
+ "ios/Plugins/**/*"
27
+ ]
20
28
  s.libraries = "bz2"
21
29
 
22
30
  s.pod_target_xcconfig = {
23
31
  "DEFINES_MODULE" => "YES",
24
- "OTHER_SWIFT_FLAGS" => "-enable-experimental-feature AccessLevelOnImport"
32
+ "OTHER_SWIFT_FLAGS" => "-enable-experimental-feature AccessLevelOnImport",
33
+ "HEADER_SEARCH_PATHS" => "\"$(DERIVED_FILE_DIR)\""
25
34
  }
26
35
 
36
+ # Every build, before compiling, writes the build's MIN_BUNDLE_ID into the
37
+ # header HotUpdaterMinBundleId.m includes, so Xcode, xcodebuild and the CLI
38
+ # all ship the time they built.
39
+ s.script_phases = [{
40
+ :name => "[HotUpdater] Generate MIN_BUNDLE_ID",
41
+ :script => File.read(File.join(__dir__, "ios/scripts/generate-min-bundle-id.zsh")),
42
+ :shell_path => "/bin/zsh",
43
+ :execution_position => :before_compile,
44
+ :output_files => ["$(DERIVED_FILE_DIR)/HotUpdaterBuildMinBundleId.h"],
45
+ :always_out_of_date => "1"
46
+ }]
47
+
27
48
  # Use install_modules_dependencies helper to install the dependencies if React Native version >=0.71.0.
28
49
  # See https://github.com/facebook/react-native/blob/febf6b7f33fdb4904669f99d795eba4c0f95d7bf/scripts/cocoapods/new_architecture.rb#L79.
29
50
  if respond_to?(:install_modules_dependencies, true)
@@ -35,7 +56,7 @@ Pod::Spec.new do |s|
35
56
  if ENV["RCT_NEW_ARCH_ENABLED"] == "1" then
36
57
  s.compiler_flags = folly_compiler_flags + " -DRCT_NEW_ARCH_ENABLED=1"
37
58
  s.pod_target_xcconfig = {
38
- "HEADER_SEARCH_PATHS" => "\"$(PODS_ROOT)/boost\"",
59
+ "HEADER_SEARCH_PATHS" => "\"$(PODS_ROOT)/boost\" \"$(DERIVED_FILE_DIR)\"",
39
60
  "OTHER_CPLUSPLUSFLAGS" => "-DFOLLY_NO_CONFIG -DFOLLY_MOBILE=1 -DFOLLY_USE_LIBCPP=1",
40
61
  "CLANG_CXX_LANGUAGE_STANDARD" => "c++17"
41
62
  }
package/Package.swift ADDED
@@ -0,0 +1,100 @@
1
+ // swift-tools-version: 6.0
2
+ import PackageDescription
3
+
4
+ // Swift Package Manager support for React Native's SwiftPM autolinking
5
+ // (`npx react-native spm`, react-native >= 0.87). CocoaPods builds use
6
+ // HotUpdater.podspec instead and never read this file.
7
+ //
8
+ // The autolinker references this package through
9
+ // <app>/ios/build/generated/autolinking/libs/HotUpdater (`spm.name` in
10
+ // react-native.config.js), so the two React Native packages below resolve
11
+ // relative to that symlink.
12
+ //
13
+ // SwiftPM compiles one language per target, so the module is split:
14
+ // HotUpdaterSwift holds the Swift implementation, and HotUpdater, the
15
+ // Objective-C++ module React Native loads, calls it through the protocols in
16
+ // HotUpdaterInterface (see HotUpdaterInterface.h for why). Swift reaches the
17
+ // Objective-C++ side through `@_silgen_name` symbols only.
18
+ let reactNativeDependencies: [Target.Dependency] = [
19
+ .product(name: "ReactHeaders", package: "ReactNative"),
20
+ .product(name: "ReactNativeHeaders", package: "ReactNative"),
21
+ .product(name: "ReactNativeDependenciesHeaders", package: "ReactNative"),
22
+ .product(name: "ReactAppHeaders", package: "React-GeneratedCode"),
23
+ ]
24
+
25
+ let reactNativeCxxSettings: [CXXSetting] = [
26
+ .define("RCT_NEW_ARCH_ENABLED", to: "1"),
27
+ .define("DEBUG", .when(configuration: .debug)),
28
+ .define("NDEBUG", .when(configuration: .release)),
29
+ ]
30
+
31
+ let package = Package(
32
+ name: "HotUpdater",
33
+ platforms: [.iOS(.v15)],
34
+ products: [
35
+ .library(name: "HotUpdater", targets: ["HotUpdater"]),
36
+ ],
37
+ dependencies: [
38
+ .package(name: "ReactNative", path: "../../../../xcframeworks"),
39
+ .package(name: "React-GeneratedCode", path: "../../../ios"),
40
+ ],
41
+ targets: [
42
+ .target(
43
+ name: "HotUpdater",
44
+ dependencies: [
45
+ "HotUpdaterInterface",
46
+ "HotUpdaterSwift",
47
+ ] + reactNativeDependencies,
48
+ path: "ios/HotUpdater",
49
+ sources: ["Internal/HotUpdater.mm"],
50
+ publicHeadersPath: "Public",
51
+ cxxSettings: reactNativeCxxSettings
52
+ ),
53
+ .target(
54
+ name: "HotUpdaterSwift",
55
+ dependencies: [
56
+ "HotUpdaterBsdiffPatch",
57
+ "HotUpdaterInterface",
58
+ "HotUpdaterRecovery",
59
+ ] + reactNativeDependencies,
60
+ path: "ios/HotUpdater/Internal",
61
+ exclude: [
62
+ "BsdiffPatchBridge.h",
63
+ "BsdiffPatchBridge.mm",
64
+ "HotUpdater-Bridging-Header.h",
65
+ "HotUpdater.mm",
66
+ "HotUpdaterMinBundleId.m",
67
+ ],
68
+ plugins: ["HotUpdaterMinBundleIdPlugin"]
69
+ ),
70
+ .target(
71
+ name: "HotUpdaterInterface",
72
+ dependencies: reactNativeDependencies,
73
+ path: "ios/HotUpdater/Interface",
74
+ publicHeadersPath: "."
75
+ ),
76
+ .target(
77
+ name: "HotUpdaterRecovery",
78
+ dependencies: reactNativeDependencies,
79
+ path: "ios/HotUpdater/Recovery",
80
+ publicHeadersPath: ".",
81
+ cxxSettings: reactNativeCxxSettings
82
+ ),
83
+ .target(
84
+ name: "HotUpdaterBsdiffPatch",
85
+ path: "ios/HotUpdater/Internal",
86
+ sources: ["BsdiffPatchBridge.mm"],
87
+ publicHeadersPath: ".",
88
+ linkerSettings: [.linkedLibrary("bz2")]
89
+ ),
90
+ // Writes this build's MIN_BUNDLE_ID before every build, as the
91
+ // podspec's script phase does for CocoaPods.
92
+ .plugin(
93
+ name: "HotUpdaterMinBundleIdPlugin",
94
+ capability: .buildTool(),
95
+ path: "ios/Plugins/HotUpdaterMinBundleIdPlugin"
96
+ ),
97
+ ],
98
+ swiftLanguageModes: [.v5],
99
+ cxxLanguageStandard: .cxx20
100
+ )
@@ -31,6 +31,7 @@ def isNewArchitectureEnabled() {
31
31
  apply plugin: "com.android.library"
32
32
  apply plugin: "kotlin-android"
33
33
  apply from: "$projectDir/react-native-helpers.gradle"
34
+ apply from: "$projectDir/hot-updater-builtin-assets.gradle"
34
35
 
35
36
  if (isNewArchitectureEnabled()) {
36
37
  apply plugin: "com.facebook.react"
@@ -71,7 +72,6 @@ android {
71
72
  targetSdkVersion getExtOrIntegerDefault("targetSdkVersion")
72
73
  buildConfigField "boolean", "IS_NEW_ARCHITECTURE_ENABLED", isNewArchitectureEnabled().toString()
73
74
  consumerProguardFiles 'proguard-rules.pro'
74
- buildConfigField "long", "BUILD_TIMESTAMP", "${System.currentTimeMillis()}L"
75
75
 
76
76
  ndk {
77
77
  abiFilters(*reactNativeArchitectures())
@@ -82,9 +82,6 @@ android {
82
82
  arguments "-DANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES=ON"
83
83
  }
84
84
  }
85
-
86
- def minBundleId = project.hasProperty("MIN_BUNDLE_ID") ? project.properties["MIN_BUNDLE_ID"] : null
87
- buildConfigField "String", "MIN_BUNDLE_ID", minBundleId == null ? "\"null\"" : "\"${minBundleId}\""
88
85
  }
89
86
 
90
87
  buildFeatures {
@@ -136,6 +133,8 @@ android {
136
133
  }
137
134
  }
138
135
 
136
+ apply from: "$projectDir/hot-updater-min-bundle-id.gradle"
137
+
139
138
  repositories {
140
139
  mavenCentral()
141
140
  google()
@@ -0,0 +1,67 @@
1
+ import groovy.json.JsonOutput
2
+ import java.security.MessageDigest
3
+
4
+ /**
5
+ * Records, in the app's assets, the SHA-256 of every asset React Native's
6
+ * bundle task emits, keyed by its path in the bundle, as an OTA manifest lists
7
+ * it (`drawable-mdpi/logo.png`).
8
+ *
9
+ * AAPT2 re-encodes PNGs in release builds (`crunchPngs`), so the packaged
10
+ * bytes no longer match the OTA's while the image is the same. With this
11
+ * record, the first OTA after a store install still reuses such an asset
12
+ * instead of downloading it: the record proves it came from the same source.
13
+ */
14
+ class HotUpdaterBuiltInAssetIndex implements Action<Task> {
15
+ static final String FILE_NAME = "hot-updater-builtin-assets.json"
16
+
17
+ private final Provider<Directory> resourcesDir
18
+ private final Provider<Directory> jsBundleDir
19
+
20
+ HotUpdaterBuiltInAssetIndex(Provider<Directory> resourcesDir, Provider<Directory> jsBundleDir) {
21
+ this.resourcesDir = resourcesDir
22
+ this.jsBundleDir = jsBundleDir
23
+ }
24
+
25
+ private static String sha256(File file) {
26
+ MessageDigest digest = MessageDigest.getInstance("SHA-256")
27
+ file.withInputStream { input ->
28
+ byte[] buffer = new byte[65536]
29
+ int read
30
+ while ((read = input.read(buffer)) > 0) {
31
+ digest.update(buffer, 0, read)
32
+ }
33
+ }
34
+ return digest.digest().collect { String.format("%02x", it) }.join()
35
+ }
36
+
37
+ @Override
38
+ void execute(Task task) {
39
+ File resources = resourcesDir.get().asFile
40
+ File output = jsBundleDir.get().asFile
41
+ Map<String, String> assets = new TreeMap<>()
42
+ if (resources.isDirectory()) {
43
+ resources.eachFileRecurse { File file ->
44
+ if (!file.isFile()) return
45
+ String path = resources.toPath().relativize(file.toPath()).toString().replace(File.separatorChar, '/' as char)
46
+ // A nine-patch's border becomes a chunk, not a re-encoding of the image.
47
+ if (path.endsWith(".9.png")) return
48
+ assets[path] = sha256(file)
49
+ }
50
+ }
51
+ output.mkdirs()
52
+ new File(output, FILE_NAME).text = JsonOutput.toJson([schemaVersion: 1, assets: assets])
53
+ }
54
+ }
55
+
56
+ def hotUpdaterRecordBuiltInAssets = { Project app ->
57
+ app.tasks.matching { it.name ==~ /createBundle.+JsAndAssets/ }.configureEach { Task task ->
58
+ if (!task.hasProperty("resourcesDir") || !task.hasProperty("jsBundleDir")) return
59
+ task.doLast(new HotUpdaterBuiltInAssetIndex(task.resourcesDir, task.jsBundleDir))
60
+ }
61
+ }
62
+
63
+ rootProject.allprojects { Project candidate ->
64
+ candidate.pluginManager.withPlugin("com.android.application") {
65
+ hotUpdaterRecordBuiltInAssets(candidate)
66
+ }
67
+ }
@@ -0,0 +1,69 @@
1
+ // Writes this build's MIN_BUNDLE_ID into a generated source on every build,
2
+ // at execution time, so neither an up-to-date check nor the configuration
3
+ // cache keeps an earlier build's value. MIN_BUNDLE_ID is a UUIDv7 with zeroed
4
+ // random bits on the same epoch millisecond clock as the bundle IDs
5
+ // `hot-updater deploy` issues. The hot-updater CLI passes its own value as
6
+ // -PMIN_BUNDLE_ID, which wins.
7
+ abstract class GenerateHotUpdaterMinBundleIdTask extends DefaultTask {
8
+ @Input
9
+ @Optional
10
+ abstract Property<String> getMinBundleId()
11
+
12
+ @OutputDirectory
13
+ abstract DirectoryProperty getOutputDirectory()
14
+
15
+ @TaskAction
16
+ void generate() {
17
+ def minBundleId = getMinBundleId().getOrNull()?.toLowerCase(Locale.ROOT)
18
+ if (minBundleId == null) {
19
+ def timestampMs = System.currentTimeMillis()
20
+ minBundleId = String.format(
21
+ "%08x-%04x-7000-8000-000000000000",
22
+ timestampMs >>> 16,
23
+ timestampMs & 0xffff
24
+ )
25
+ } else if (!(minBundleId ==~ /[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}/)) {
26
+ throw new GradleException("MIN_BUNDLE_ID must be a UUIDv7, got: ${getMinBundleId().get()}")
27
+ }
28
+
29
+ def source = getOutputDirectory().file("com/hotupdater/HotUpdaterMinBundleId.java").get().asFile
30
+ source.parentFile.mkdirs()
31
+ source.text = """package com.hotupdater;
32
+
33
+ // Generated by generateHotUpdaterMinBundleId on every build.
34
+ final class HotUpdaterMinBundleId {
35
+ private HotUpdaterMinBundleId() {}
36
+
37
+ static String get() {
38
+ return "${minBundleId}";
39
+ }
40
+ }
41
+ """
42
+ }
43
+ }
44
+
45
+ def generateHotUpdaterMinBundleId = tasks.register("generateHotUpdaterMinBundleId", GenerateHotUpdaterMinBundleIdTask) {
46
+ minBundleId.set(providers.gradleProperty("MIN_BUNDLE_ID"))
47
+ outputDirectory.set(layout.buildDirectory.dir("generated/source/hotUpdaterMinBundleId"))
48
+ outputs.upToDateWhen { false }
49
+ }
50
+
51
+ def agpVersion = androidComponents.pluginVersion
52
+
53
+ // AGP 7.3 added generated source directories to the Variant API. Older AGP,
54
+ // which React Native 0.70 and earlier ship, registers the task on the legacy
55
+ // variant API instead.
56
+ if (agpVersion.major > 7 || (agpVersion.major == 7 && agpVersion.minor >= 3)) {
57
+ androidComponents {
58
+ onVariants(selector().all()) { variant ->
59
+ variant.sources.java.addGeneratedSourceDirectory(generateHotUpdaterMinBundleId) { it.outputDirectory }
60
+ }
61
+ }
62
+ } else {
63
+ android.libraryVariants.all { variant ->
64
+ variant.registerJavaGeneratingTask(
65
+ generateHotUpdaterMinBundleId,
66
+ generateHotUpdaterMinBundleId.get().outputDirectory.get().asFile
67
+ )
68
+ }
69
+ }
@@ -0,0 +1,85 @@
1
+ package com.hotupdater
2
+
3
+ import kotlinx.coroutines.Dispatchers
4
+ import kotlinx.coroutines.withContext
5
+ import okhttp3.MediaType.Companion.toMediaType
6
+ import okhttp3.OkHttpClient
7
+ import okhttp3.Request
8
+ import okhttp3.RequestBody.Companion.toRequestBody
9
+ import org.json.JSONArray
10
+ import org.json.JSONObject
11
+ import java.util.concurrent.TimeUnit
12
+
13
+ /** A download URL the server signed for one manifest file. */
14
+ data class ResolvedArtifactFile(
15
+ val url: String,
16
+ val compression: String?,
17
+ )
18
+
19
+ /**
20
+ * Artifact protocol v2: the server signs only the files an install downloads
21
+ * one by one, after reusing local files and choosing the archive.
22
+ */
23
+ interface ArtifactFileResolver {
24
+ /** Download URLs for [paths], at most [ARTIFACT_FILES_REQUEST_LIMIT]. */
25
+ suspend fun resolve(paths: List<String>): Map<String, ResolvedArtifactFile>
26
+
27
+ companion object {
28
+ /** `ARTIFACT_FILES_REQUEST_LIMIT` in @hot-updater/protocol. */
29
+ const val ARTIFACT_FILES_REQUEST_LIMIT = 100
30
+ }
31
+ }
32
+
33
+ /**
34
+ * POSTs `{ paths }` to the files URL the SDK composed, with the update check's
35
+ * headers and request timeout.
36
+ */
37
+ class HttpArtifactFileResolver(
38
+ private val filesUrl: String,
39
+ private val headers: Map<String, String>,
40
+ private val timeoutMs: Long = DEFAULT_TIMEOUT_MS,
41
+ ) : ArtifactFileResolver {
42
+ override suspend fun resolve(paths: List<String>): Map<String, ResolvedArtifactFile> =
43
+ withContext(Dispatchers.IO) {
44
+ val body = JSONObject().put("paths", JSONArray(paths)).toString()
45
+ val request =
46
+ Request
47
+ .Builder()
48
+ .url(filesUrl)
49
+ .header("Accept", "application/json")
50
+ .apply { headers.forEach { (name, value) -> header(name, value) } }
51
+ .post(body.toRequestBody("application/json".toMediaType()))
52
+ .build()
53
+ client
54
+ .newBuilder()
55
+ .callTimeout(timeoutMs, TimeUnit.MILLISECONDS)
56
+ .build()
57
+ .newCall(request)
58
+ .execute()
59
+ .use { response ->
60
+ if (!response.isSuccessful) {
61
+ throw HttpStatusException(response.code, response.message)
62
+ }
63
+ val files =
64
+ JSONObject(response.body?.string() ?: "").optJSONObject("files")
65
+ ?: throw IllegalStateException("Artifact files response has no files")
66
+ paths.associateWith { path ->
67
+ val file =
68
+ files.optJSONObject(path)
69
+ ?: throw IllegalStateException("Artifact files response omits $path")
70
+ ResolvedArtifactFile(
71
+ url = file.getString("url"),
72
+ compression =
73
+ if (file.has("compression") && !file.isNull("compression")) file.getString("compression") else null,
74
+ )
75
+ }
76
+ }
77
+ }
78
+
79
+ companion object {
80
+ /** The SDK's request timeout when the app sets none. */
81
+ const val DEFAULT_TIMEOUT_MS = 5_000L
82
+
83
+ private val client = OkHttpClient()
84
+ }
85
+ }
@@ -15,6 +15,28 @@ interface BuiltInAssetResolver {
15
15
  expectedHash: String,
16
16
  destination: File,
17
17
  ): Boolean
18
+
19
+ /**
20
+ * Copies the packaged asset at [assetPath] when the native build recorded
21
+ * [expectedHash] as the hash of its source, although its packaged bytes
22
+ * differ, as AAPT2's PNG crunch leaves them. Returns the SHA-256 of the
23
+ * copied bytes, or null when the asset is not the same.
24
+ */
25
+ fun copyIfSourceMatches(
26
+ assetPath: String,
27
+ expectedHash: String,
28
+ destination: File,
29
+ ): String? = null
30
+
31
+ /**
32
+ * The SHA-256 of the packaged asset at [assetPath] when the native build
33
+ * recorded [expectedHash] as its source; null otherwise. Read from the
34
+ * APK itself, so nothing in app storage can vouch for a file.
35
+ */
36
+ fun packagedHashIfSourceMatches(
37
+ assetPath: String,
38
+ expectedHash: String,
39
+ ): String? = null
18
40
  }
19
41
 
20
42
  internal class AndroidBuiltInAssetResolver(
@@ -28,6 +50,12 @@ internal class AndroidBuiltInAssetResolver(
28
50
 
29
51
  private val lock = Any()
30
52
 
53
+ /** Source hashes the native build recorded, by asset path. */
54
+ private val sourceHashes: Map<String, String> by lazy { readSourceHashes() }
55
+
56
+ /** Packaged bytes' hashes by locator, read from the APK in this process only. */
57
+ private val packagedHashes = mutableMapOf<String, String>()
58
+
31
59
  override fun copyIfMatches(
32
60
  assetPath: String,
33
61
  expectedHash: String,
@@ -50,6 +78,73 @@ internal class AndroidBuiltInAssetResolver(
50
78
  true
51
79
  }
52
80
 
81
+ override fun copyIfSourceMatches(
82
+ assetPath: String,
83
+ expectedHash: String,
84
+ destination: File,
85
+ ): String? =
86
+ synchronized(lock) {
87
+ val normalizedPath = RelativePathResolver.normalizeRelativePath(assetPath) ?: return@synchronized null
88
+ if (!sourceHashes[normalizedPath].equals(expectedHash, ignoreCase = true)) return@synchronized null
89
+ val packageIdentity = packageIdentity() ?: return@synchronized null
90
+ // Resolved again, never from the cached index in app storage.
91
+ val locator = resolveLocator(normalizedPath) ?: return@synchronized null
92
+ val packagedHash = copyLocator(locator, destination) { true } ?: return@synchronized null
93
+ packagedHashes[locator] = packagedHash
94
+
95
+ val index = readIndex(packageIdentity)
96
+ index.locators[normalizedPath] = locator
97
+ writeIndex(index)
98
+ packagedHash
99
+ }
100
+
101
+ override fun packagedHashIfSourceMatches(
102
+ assetPath: String,
103
+ expectedHash: String,
104
+ ): String? =
105
+ synchronized(lock) {
106
+ val normalizedPath = RelativePathResolver.normalizeRelativePath(assetPath) ?: return@synchronized null
107
+ if (!sourceHashes[normalizedPath].equals(expectedHash, ignoreCase = true)) return@synchronized null
108
+ val locator = resolveLocator(normalizedPath) ?: return@synchronized null
109
+ packagedHashes[locator] ?: hashLocator(locator)?.also { packagedHashes[locator] = it }
110
+ }
111
+
112
+ private fun hashLocator(locator: String): String? =
113
+ try {
114
+ openLocator(locator)?.use { source ->
115
+ val digest = MessageDigest.getInstance("SHA-256")
116
+ val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
117
+ while (true) {
118
+ val count = source.read(buffer)
119
+ if (count < 0) break
120
+ digest.update(buffer, 0, count)
121
+ }
122
+ digest.digest().joinToString("") { "%02x".format(it) }
123
+ }
124
+ } catch (_: Exception) {
125
+ null
126
+ }
127
+
128
+ private fun readSourceHashes(): Map<String, String> =
129
+ try {
130
+ val json =
131
+ JSONObject(
132
+ context.assets
133
+ .open(SOURCE_HASHES_ASSET)
134
+ .bufferedReader()
135
+ .use { it.readText() },
136
+ )
137
+ if (json.optInt("schemaVersion") != SOURCE_HASHES_SCHEMA_VERSION) {
138
+ emptyMap()
139
+ } else {
140
+ val assets = json.optJSONObject("assets") ?: JSONObject()
141
+ assets.keys().asSequence().associateWith { assets.getString(it) }
142
+ }
143
+ } catch (_: Exception) {
144
+ // A build without the record reuses only byte-identical assets.
145
+ emptyMap()
146
+ }
147
+
53
148
  private fun resolveLocator(assetPath: String): String? {
54
149
  if (assetPath == "index.android.bundle") {
55
150
  return try {
@@ -123,8 +218,15 @@ internal class AndroidBuiltInAssetResolver(
123
218
  locator: String,
124
219
  expectedHash: String,
125
220
  destination: File,
126
- ): Boolean {
127
- val input = openLocator(locator) ?: return false
221
+ ): Boolean = copyLocator(locator, destination) { it.equals(expectedHash, ignoreCase = true) } != null
222
+
223
+ /** Copies the located bytes when [accepts] their SHA-256, and returns it. */
224
+ private fun copyLocator(
225
+ locator: String,
226
+ destination: File,
227
+ accepts: (String) -> Boolean,
228
+ ): String? {
229
+ val input = openLocator(locator) ?: return null
128
230
  val tempFile = File(destination.parentFile, "${destination.name}.builtin.tmp")
129
231
  return try {
130
232
  destination.parentFile?.mkdirs()
@@ -141,21 +243,20 @@ internal class AndroidBuiltInAssetResolver(
141
243
  }
142
244
  }
143
245
  val actualHash = digest.digest().joinToString("") { "%02x".format(it) }
144
- if (!actualHash.equals(expectedHash, ignoreCase = true)) {
246
+ if (!accepts(actualHash)) {
145
247
  tempFile.delete()
146
- false
248
+ null
147
249
  } else {
148
250
  if (destination.exists()) destination.delete()
149
- tempFile.renameTo(destination) ||
150
- run {
151
- tempFile.copyTo(destination, overwrite = true)
152
- tempFile.delete()
153
- true
154
- }
251
+ if (!tempFile.renameTo(destination)) {
252
+ tempFile.copyTo(destination, overwrite = true)
253
+ tempFile.delete()
254
+ }
255
+ actualHash
155
256
  }
156
257
  } catch (_: Exception) {
157
258
  tempFile.delete()
158
- false
259
+ null
159
260
  }
160
261
  }
161
262
 
@@ -282,5 +383,9 @@ internal class AndroidBuiltInAssetResolver(
282
383
 
283
384
  companion object {
284
385
  private const val SCHEMA_VERSION = 1
386
+
387
+ /** Written by the native build: see android/hot-updater-builtin-assets.gradle. */
388
+ private const val SOURCE_HASHES_ASSET = "hot-updater-builtin-assets.json"
389
+ private const val SOURCE_HASHES_SCHEMA_VERSION = 1
285
390
  }
286
391
  }