@hot-updater/react-native 1.0.5 → 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.
- package/HotUpdater.podspec +24 -3
- package/Package.swift +100 -0
- package/android/build.gradle +3 -4
- package/android/hot-updater-builtin-assets.gradle +67 -0
- package/android/hot-updater-min-bundle-id.gradle +69 -0
- package/android/src/main/java/com/hotupdater/ArtifactFileResolver.kt +85 -0
- package/android/src/main/java/com/hotupdater/BuiltInAssetResolver.kt +116 -11
- package/android/src/main/java/com/hotupdater/BundleFileStorageService.kt +117 -12
- package/android/src/main/java/com/hotupdater/HotUpdaterImpl.kt +5 -52
- package/android/src/newarch/HotUpdaterModule.kt +46 -2
- package/android/src/oldarch/HotUpdaterModule.kt +46 -2
- package/ios/HotUpdater/Interface/HotUpdaterInterface.h +66 -0
- package/ios/HotUpdater/Interface/HotUpdaterInterface.m +3 -0
- package/ios/HotUpdater/Internal/ArtifactFileResolver.swift +87 -0
- package/ios/HotUpdater/Internal/BundleFileStorageService.swift +88 -13
- package/ios/HotUpdater/Internal/HotUpdater.mm +41 -90
- package/ios/HotUpdater/Internal/HotUpdaterImpl.swift +25 -11
- package/ios/HotUpdater/Internal/HotUpdaterInterface.swift +26 -0
- package/ios/HotUpdater/Internal/HotUpdaterMinBundleId.m +10 -0
- package/ios/HotUpdater/Package.swift +88 -0
- package/ios/Plugins/HotUpdaterMinBundleIdPlugin/HotUpdaterMinBundleIdPlugin.swift +28 -0
- package/ios/scripts/generate-min-bundle-id.zsh +49 -0
- package/lib/commonjs/checkForUpdate.js +8 -0
- package/lib/commonjs/checkForUpdate.js.map +1 -1
- package/lib/commonjs/fetchUpdateResponse.js +5 -2
- package/lib/commonjs/fetchUpdateResponse.js.map +1 -1
- package/lib/commonjs/httpClient.js +64 -5
- package/lib/commonjs/httpClient.js.map +1 -1
- package/lib/commonjs/native.js +9 -0
- package/lib/commonjs/native.js.map +1 -1
- package/lib/commonjs/sdkVersion.js +1 -1
- package/lib/commonjs/specs/NativeHotUpdater.js.map +1 -1
- package/lib/module/checkForUpdate.js +8 -0
- package/lib/module/checkForUpdate.js.map +1 -1
- package/lib/module/fetchUpdateResponse.js +4 -1
- package/lib/module/fetchUpdateResponse.js.map +1 -1
- package/lib/module/httpClient.js +67 -7
- package/lib/module/httpClient.js.map +1 -1
- package/lib/module/native.js +9 -0
- package/lib/module/native.js.map +1 -1
- package/lib/module/sdkVersion.js +1 -1
- package/lib/module/specs/NativeHotUpdater.js.map +1 -1
- package/lib/typescript/commonjs/checkForUpdate.d.ts.map +1 -1
- package/lib/typescript/commonjs/fetchUpdateResponse.d.ts +2 -0
- package/lib/typescript/commonjs/fetchUpdateResponse.d.ts.map +1 -1
- package/lib/typescript/commonjs/httpClient.d.ts +18 -2
- package/lib/typescript/commonjs/httpClient.d.ts.map +1 -1
- package/lib/typescript/commonjs/native.d.ts.map +1 -1
- package/lib/typescript/commonjs/sdkVersion.d.ts +1 -1
- package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts +13 -1
- package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts.map +1 -1
- package/lib/typescript/module/checkForUpdate.d.ts.map +1 -1
- package/lib/typescript/module/fetchUpdateResponse.d.ts +2 -0
- package/lib/typescript/module/fetchUpdateResponse.d.ts.map +1 -1
- package/lib/typescript/module/httpClient.d.ts +18 -2
- package/lib/typescript/module/httpClient.d.ts.map +1 -1
- package/lib/typescript/module/native.d.ts.map +1 -1
- package/lib/typescript/module/sdkVersion.d.ts +1 -1
- package/lib/typescript/module/specs/NativeHotUpdater.d.ts +13 -1
- package/lib/typescript/module/specs/NativeHotUpdater.d.ts.map +1 -1
- package/package.json +5 -4
- package/react-native.config.js +7 -0
- package/src/checkForUpdate.ts +11 -0
- package/src/fetchUpdateResponse.ts +4 -1
- package/src/httpClient.ts +84 -9
- package/src/native.ts +7 -0
- package/src/sdkVersion.ts +1 -1
- package/src/specs/NativeHotUpdater.ts +13 -1
package/HotUpdater.podspec
CHANGED
|
@@ -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
|
-
|
|
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
|
+
)
|
package/android/build.gradle
CHANGED
|
@@ -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
|
-
|
|
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
|
|
246
|
+
if (!accepts(actualHash)) {
|
|
145
247
|
tempFile.delete()
|
|
146
|
-
|
|
248
|
+
null
|
|
147
249
|
} else {
|
|
148
250
|
if (destination.exists()) destination.delete()
|
|
149
|
-
tempFile.renameTo(destination)
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
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
|
}
|