This commit is contained in:
Yamozha
2021-04-02 02:24:13 +03:00
parent c23950b545
commit 7256d79e2c
31493 changed files with 3036630 additions and 0 deletions

2
node_modules/expo-updates/.eslintrc.js generated vendored Normal file
View File

@ -0,0 +1,2 @@
// @generated by expo-module-scripts
module.exports = require('expo-module-scripts/eslintrc.base.js');

177
node_modules/expo-updates/CHANGELOG.md generated vendored Normal file
View File

@ -0,0 +1,177 @@
# Changelog
## Unpublished
### 🛠 Breaking changes
### 🎉 New features
### 🐛 Bug fixes
## 0.4.2 — 2021-02-16
### 🎉 New features
- Keep current update and one older update, for safety and to make rollbacks faster ([#11449](https://github.com/expo/expo/pull/11449) by [@esamelson](https://github.com/esamelson))
### 🐛 Bug fixes
- Improved thread safety around reaping ([#11447](https://github.com/expo/expo/pull/11447) by [@esamelson](https://github.com/esamelson))
- Fixed support for Android Gradle plugin 4.1+ ([#11926](https://github.com/expo/expo/pull/11926) by [@esamelson](https://github.com/esamelson))
## 0.4.1 — 2020-11-25
### 🛠 Breaking changes
- This version adds an internal database migration, which means that when a user's device upgrades from an app with `expo-updates@0.3.x` to an app with `expo-updates@0.4.x`, any updates they had previously downloaded will no longer be accessible.
- For **managed workflow apps**, this is inconsequential as this upgrade will be part of a major SDK version upgrade. You do not need to do anything if your app is made using the managed workflow.
- For **bare workflow apps**, this means updates downloaded on clients running `expo-updates@0.3.x` will need to be redownloaded in order to run after those clients are upgraded to `expo-updates@0.4.x`. We recommend incrementing your runtime/SDK version after updating to `expo-updates@0.4.x`, and republishing any OTA updates that you do not intend to distribute embedded in your application binary.
## 0.4.0 — 2020-11-17
### 🛠 Breaking changes
- On iOS enabled `use_frameworks!` usage by replacing `React` dependency with `React-Core`. ([#11057](https://github.com/expo/expo/pull/11057) by [@bbarthec](https://github.com/bbarthec))
### 🐛 Bug fixes
- Fixed issue in **managed workflow** where `reloadAsync` doesn't reload the app if called immediately after the app starts. ([#10917](https://github.com/expo/expo/pull/10917) and [#10918](https://github.com/expo/expo/pull/10918) by [@esamelson](https://github.com/esamelson))
## 0.3.5 — 2020-10-02
_This version does not introduce any user-facing changes._
## 0.3.4 — 2020-09-22
### 🐛 Bug fixes
- Fixed `NSInvalidArgumentException` being thrown in bare applications on iOS (unrecognized selector `appLoaderTask:didFinishBackgroundUpdateWithStatus:update:error:` sent to instance of `EXUpdatesAppController`). ([#10289](https://github.com/expo/expo/issues/10289) by [@sjchmiela](https://github.com/sjchmiela))
## 0.3.3 — 2020-09-21
_This version does not introduce any user-facing changes._
## 0.3.2 — 2020-09-16
_This version does not introduce any user-facing changes._
## 0.3.1 — 2020-08-26
_This version does not introduce any user-facing changes._
## 0.3.0 — 2020-08-18
### 🎉 New features
- Easier to follow installation instructions by moving them to the Expo documentation ([#9145](https://github.com/expo/expo/pull/9145)).
## 0.2.12 — 2020-07-24
### 🐛 Bug fixes
- Fetch asset manifest through programmatic CLI interface instead of depending on a running React Native CLI server, so `./gradlew :app:assembleRelease` works as expected without needing to run `react-native start` beforehand. ([#9372](https://github.com/expo/expo/pull/9372)).
## 0.2.11 — 2020-06-29
### 🐛 Bug fixes
- Fixed an issue where the publish workflow was broken on Android. Note that the publish workflow will not be supported in a future version of expo-updates, so we recommend [switching to the no-publish workflow](https://blog.expo.io/over-the-air-updates-from-expo-are-now-even-easier-to-use-376e2213fabf).
## 0.2.10 — 2020-06-23
### 🐛 Bug fixes
- Fixed reading the `expo.modules.updates.ENABLED` setting from AndroidManifest.xml.
- Improved the error message logged when an embedded manifest cannot be found.
## 0.2.9 — 2020-06-15
### 🐛 Bug fixes
- Fixed issue where launch screen on iOS doesn't show whilst updates are being retrieved if it is contained within a storyboard instead of a nib. ([#8750](https://github.com/expo/expo/pull/8750) by [@MattsTheChief](https://github.com/MattsTheChief))
- Fixed an issue where the REACT_NATIVE_PACKAGER_HOSTNAME env var was not respected in the build scripts for iOS or Android.
## 0.2.8 — 2020-05-29
*This version does not introduce any user-facing changes.*
## 0.2.7 - 2020-05-27
### 🐛 Bug fixes
- Added a better error message to the `create-manifest-ios.sh` script in case the Xcode shell cannot find the node binary.
- Added an optional `bundleIn${targetName}` field to Gradle build script config. ([#8464](https://github.com/expo/expo/pull/8464) by [@rickysullivan](https://github.com/rickysullivan))
- Fixed a bug on iOS with bundling assets from outside the project root.
## 0.2.6 — 2020-05-27
*This version does not introduce any user-facing changes.*
## 0.2.5
### 🐛 Bug fixes
- Fixed broken Android builds on Windows.
## 0.2.4
### 🐛 Bug fixes
- Support monorepos ([#8419](https://github.com/expo/expo/pull/8419) by [@janicduplessis](https://github.com/janicduplessis))
- Support entry file configuration in Xcode/gradle build scripts ([#8415](https://github.com/expo/expo/pull/8415) and [#8418](https://github.com/expo/expo/pull/8418) by [@janicduplessis](https://github.com/janicduplessis))
- Added a more helpful error message when trying to run a build without the packager server running.
## 0.2.3
### 🐛 Bug fixes
- Temporarily vendor `filterPlatformAssetScales` method from `@react-native-community/cli` in order to fix builds when `npm` was used to install dependencies (rather than `yarn`).
- Fixed an issue on iOS where calling the JS module methods in development mode, after publishing at least one update, would crash the app.
## 0.2.2
### 🐛 Bug fixes
- Fixed an issue on iOS where expo-updates expected more assets to be embedded than actually are by the React Native CLI.
- Added a better error message on iOS when embedded assets are missing.
## 0.2.1
### 🐛 Bug fixes
- Added a better error message to the `createManifest` script when project does not have the `hashAssetFiles` plugin configured.
## 0.2.0
### 🎉 New features
- Added support for the **no-publish workflow**. In this workflow, release builds of both iOS and Android apps will create and embed a new update at build-time from the JS code currently on disk, rather than embedding a copy of the most recently published update. For more information, along with upgrade instructions if you're upgrading from 0.1.x and would like to use the no-publish workflow, read [this blog post](https://blog.expo.io/over-the-air-updates-from-expo-are-now-even-easier-to-use-376e2213fabf).
- Added `Updates.updateId` and `Updates.releaseChannel` constant exports
### 🐛 Bug fixes
- Fixed an issue with recovering from an unexpectedly deleted asset on iOS.
- Fixed handling of invalid EXPO_UDPATE_URL values on Android.
- Updates Configuration Conditional From Equal To Prefix Check. ([#8225](https://github.com/expo/expo/pull/8225) by [@thorbenprimke](https://github.com/thorbenprimke))
## 0.1.3
### 🐛 Bug fixes
- Fixed some issues with `runtimeVersion` on Android for apps using `expo export`.
## 0.1.2
### 🐛 Bug fixes
- Fixed SSR support on Web. ([#7625](https://github.com/expo/expo/pull/7625) by [@EvanBacon](https://github.com/EvanBacon))
## 0.1.1
### 🐛 Bug fixes
- Fixed 'unable to resolve class GradleVersion' when using Gradle 5. ([#7577](https://github.com/expo/expo/pull/7577) by [@IjzerenHein](https://github.com/IjzerenHein))
## 0.1.0
Initial public beta 🎉

258
node_modules/expo-updates/README.md generated vendored Normal file
View File

@ -0,0 +1,258 @@
# expo-updates
`expo-updates` fetches and manages updates to your app stored on a remote server.
## API documentation
- [Documentation for the master branch](https://github.com/expo/expo/blob/master/docs/pages/versions/unversioned/sdk/updates.md)
- [Documentation for the latest stable release](https://docs.expo.io/versions/latest/sdk/updates/)
Additionally, for an introduction to this module and tooling around OTA updates, you can watch [this talk](https://www.youtube.com/watch?v=Si909la3rLk) by [@esamelson](https://github.com/esamelson) from ReactEurope 2020.
## Compatibility
This module requires `expo-cli@3.17.6` or later; make sure your global installation is at least this version before proceeding.
Additionally, this module is only compatible with Expo SDK 37 or later. For bare workflow projects, if the `expo` package is installed, it must be version `37.0.2` or later.
Finally, this module is not compatible with ExpoKit. Make sure you do not have `expokit` listed as a dependency in package.json before adding this module.
## Upgrading
If you're upgrading from `expo-updates@0.1.x`, you can opt into the **no-publish workflow**. In this workflow, release builds of both iOS and Android apps will create and embed a new update at build-time from the JS code currently on disk, rather than embedding a copy of the most recently published update. For instructions and more information, see the [CHANGELOG](https://github.com/expo/expo/blob/master/packages/expo-updates/CHANGELOG.md). (For new projects, the no-publish workflow is enabled by default.)
# Installation in managed Expo projects
For [managed](https://docs.expo.io/versions/latest/introduction/managed-vs-bare/) Expo projects, please follow the installation instructions in the [API documentation for the latest stable release](https://docs.expo.io/versions/latest/sdk/updates/).
# Installation in bare React Native projects
Learn how to install expo-updates in your project in the [Installing expo-updates documentation page](https://docs.expo.io/bare/installing-updates/).
## Embedded Assets
In certain situations, assets that are `require`d by your JavaScript are embedded into your application binary by Xcode/Android Studio. This allows these assets to load when the packager server running locally on your machine is not available.
Debug builds of Android apps do not, by default, have any assets bundled into the APK; they are always loaded at runtime from the Metro packager.
Debug builds of iOS apps built for the iOS simulator also do not have assets bundled into the app. They are loaded at runtime from Metro. Debug builds of iOS apps built for a real device **do** have assets bundled into the app binary, so they can be loaded from disk if they cannot be loaded from the packager at runtime.
Release builds of both iOS and Android apps include a full embedded update, including manifest, JavaScript bundle, and all imported assets. This is critical to ensure that your app can load for all users immediately upon installation, without needing to talk to a server first.
## Configuration
Some build-time configuration options are available to allow your app to update automatically on launch. On iOS, these properties are set as keys in `Expo.plist` and on Android as `meta-data` tags in `AndroidManifest.xml`, adjacent to the tags added during installation.
On Android, you may also define these properties at runtime by passing a `Map` as the second parameter of `UpdatesController.initialize()`. If provided, the values in this Map will override any values specified in `AndroidManifest.xml`. On iOS, you may set these properties at runtime by calling `[UpdatesController.sharedInstance setConfiguration:]` at any point _before_ calling `start` or `startAndShowLaunchScreen`, and the values in this dictionary will override Expo.plist.
| iOS plist/dictionary key | Android Map key | Android meta-data name | Default | Required? |
| --- | --- | --- | --- | --- |
| `EXUpdatesEnabled` | `enabled` | `expo.modules.updates.ENABLED` | `true` | ❌ |
Whether updates are enabled. Setting this to `false` disables all update functionality, all module methods, and forces the app to load with the manifest and assets bundled into the app binary.
| iOS plist/dictionary key | Android Map key | Android meta-data name | Default | Required? |
| --- | --- | --- | --- | --- |
| `EXUpdatesURL` | `updateUrl` | `expo.modules.updates.EXPO_UPDATE_URL` | (none) | ✅ |
The URL to the remote server where the app should check for updates. A request to this URL should return a valid manifest object for the latest available update and tells expo-updates how to fetch the JS bundle and other assets that comprise the update. (Example: for apps published with `expo publish`, this URL would be `https://exp.host/@username/slug`.)
| iOS plist/dictionary key | Android Map key | Android meta-data name | Default | Required? |
| --- | --- | --- | --- | --- |
| `EXUpdatesSDKVersion` | `sdkVersion` | `expo.modules.updates.EXPO_SDK_VERSION` | (none) | (exactly one of `sdkVersion` or `runtimeVersion` is required) |
The SDK version string to send under the `Expo-SDK-Version` header in the manifest request. Required for apps hosted on Expo's server.
| iOS plist/dictionary key | Android Map key | Android meta-data name | Default | Required? |
| --- | --- | --- | --- | --- |
| `EXUpdatesRuntimeVersion` | `runtimeVersion` | `expo.modules.updates.EXPO_RUNTIME_VERSION` | (none) | (exactly one of `sdkVersion` or `runtimeVersion` is required) |
The Runtime Version string to send under the `Expo-Runtime-Version` header in the manifest request.
| iOS plist/dictionary key | Android Map key | Android meta-data name | Default | Required? |
| --- | --- | --- | --- | --- |
| `EXUpdatesReleaseChannel` | `releaseChannel` | `expo.modules.updates.EXPO_RELEASE_CHANNEL` | `default` | ❌ |
The release channel string to send under the `Expo-Release-Channel` header in the manifest request.
| iOS plist/dictionary key | Android Map key | Android meta-data name | Default | Required? |
| --- | --- | --- | --- | --- |
| `EXUpdatesCheckOnLaunch` | `checkOnLaunch` | `expo.modules.updates.EXPO_UPDATES_CHECK_ON_LAUNCH` | `ALWAYS` | ❌ |
The condition under which `expo-updates` should automatically check for (and download, if one exists) an update upon app launch. Possible values are `ALWAYS`, `NEVER` (if you want to exclusively control updates via this module's JS API), or `WIFI_ONLY` (if you want the app to automatically download updates only if the device is on an unmetered Wi-Fi connection when it launches).
| iOS plist/dictionary key | Android Map key | Android meta-data name | Default | Required? |
| --- | --- | --- | --- | --- |
| `EXUpdatesLaunchWaitMs` | `launchWaitMs` | `expo.modules.updates.EXPO_UPDATES_LAUNCH_WAIT_MS` | `0` | ❌ |
The number of milliseconds `expo-updates` should delay the app launch and stay on the splash screen while trying to download an update, before falling back to a previously downloaded version. Setting this to `0` will cause the app to always launch with a previously downloaded update and will result in the fastest app launch possible.
# Removing pre-installed expo-updates
Projects created by `expo init` and `expo eject` come with expo-updates pre-installed, because we anticipate most users will want this functionality. However, if you do not intend to use OTA updates, you can disable or uninstall the module.
### Disabling expo-updates
If you disable updates, the module will stay installed in case you ever want to use it in the future, but none of the OTA-updating code paths will ever be executed in your builds. To disable OTA updates, add the `EXUpdatesEnabled` key to Expo.plist with a boolean value of `NO`, and add the following line to AndroidManifest.xml:
```xml
<meta-data android:name="expo.modules.updates.ENABLED" android:value="false"/>
```
### Uninstalling expo-updates
Uninstalling the module will entirely remove all expo-updates related code from your codebase. To do so, complete the following steps:
- Remove `expo-updates` from your package.json and reinstall your node modules.
- Remove the line `../node_modules/expo-updates/scripts/create-manifest-ios.sh` from the "Bundle React Native code and images" Build Phase in Xcode.
- Delete Expo.plist from your Xcode project and file system.
- Remove the line `apply from: "../../node_modules/expo-updates/scripts/create-manifest-android.gradle"` from `android/app/build.gradle`.
- Remove all `meta-data` tags with `expo.modules.updates` in the `android:name` field from AndroidManifest.xml.
- Apply the following three diffs:
#### `AppDelegate.h`
Remove`EXUpdatesAppControllerDelegate` as a protocol of your `AppDelegate`.
```diff
-#import <EXUpdates/EXUpdatesAppController.h>
#import <React/RCTBridgeDelegate.h>
#import <UMCore/UMAppDelegateWrapper.h>
-@interface AppDelegate : UMAppDelegateWrapper <RCTBridgeDelegate, EXUpdatesAppControllerDelegate>
+@interface AppDelegate : UMAppDelegateWrapper <RCTBridgeDelegate>
@property (nonatomic, strong) UMModuleRegistryAdapter *moduleRegistryAdapter;
@property (nonatomic, strong) UIWindow *window;
```
#### `AppDelegate.m`
```diff
#import <UMReactNativeAdapter/UMNativeModulesProxy.h>
#import <UMReactNativeAdapter/UMModuleRegistryAdapter.h>
-@interface AppDelegate ()
-
-@property (nonatomic, strong) NSDictionary *launchOptions;
-
-@end
-
@implementation AppDelegate
...
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{
self.moduleRegistryAdapter = [[UMModuleRegistryAdapter alloc] initWithModuleRegistryProvider:[[UMModuleRegistryProvider alloc] init]];
- self.launchOptions = launchOptions;
-
- self.window = [[UIWindow alloc] initWithFrame:[UIScreen mainScreen].bounds];
-#ifdef DEBUG
- [self initializeReactNativeApp];
-#else
- EXUpdatesAppController *controller = [EXUpdatesAppController sharedInstance];
- controller.delegate = self;
- [controller startAndShowLaunchScreen:self.window];
-#endif
-
- [super application:application didFinishLaunchingWithOptions:launchOptions];
-
- return YES;
-}
-
-- (RCTBridge *)initializeReactNativeApp
-{
- RCTBridge *bridge = [[RCTBridge alloc] initWithDelegate:self launchOptions:self.launchOptions];
+ RCTBridge *bridge = [[RCTBridge alloc] initWithDelegate:self launchOptions:launchOptions];
RCTRootView *rootView = [[RCTRootView alloc] initWithBridge:bridge moduleName:@"YOUR-APP-NAME" initialProperties:nil];
rootView.backgroundColor = [[UIColor alloc] initWithRed:1.0f green:1.0f blue:1.0f alpha:1];
+ self.window = [[UIWindow alloc] initWithFrame:[UIScreen mainScreen].bounds];
UIViewController *rootViewController = [UIViewController new];
rootViewController.view = rootView;
self.window.rootViewController = rootViewController;
[self.window makeKeyAndVisible];
- return bridge;
+ [super application:application didFinishLaunchingWithOptions:launchOptions];
+
+ return YES;
}
...
#ifdef DEBUG
return [[RCTBundleURLProvider sharedSettings] jsBundleURLForBundleRoot:@"index" fallbackResource:nil];
#else
- return [[EXUpdatesAppController sharedInstance] launchAssetUrl];
+ return [[NSBundle mainBundle] URLForResource:@"main" withExtension:@"jsbundle"];
#endif
}
-- (void)appController:(EXUpdatesAppController *)appController didStartWithSuccess:(BOOL)success
-{
- appController.bridge = [self initializeReactNativeApp];
-}
-
@end
```
#### `MainApplication.java`
```diff
-import android.net.Uri;
-import expo.modules.updates.UpdatesController;
-import javax.annotation.Nullable;
-
public class MainApplication extends Application implements ReactApplication {
private final ReactModuleRegistryProvider mModuleRegistryProvider = new ReactModuleRegistryProvider(
new BasePackageList().getPackageList(),
...
protected String getJSMainModuleName() {
return "index";
}
-
- @Override
- protected @Nullable String getJSBundleFile() {
- if (BuildConfig.DEBUG) {
- return super.getJSBundleFile();
- } else {
- return UpdatesController.getInstance().getLaunchAssetFile();
- }
- }
-
- @Override
- protected @Nullable String getBundleAssetName() {
- if (BuildConfig.DEBUG) {
- return super.getBundleAssetName();
- } else {
- return UpdatesController.getInstance().getBundleAssetName();
- }
- }
};
...
public void onCreate() {
super.onCreate();
SoLoader.init(this, /* native exopackage */ false);
-
- if (!BuildConfig.DEBUG) {
- UpdatesController.initialize(this);
- }
-
initializeFlipper(this, getReactNativeHost().getReactInstanceManager());
}
}
```
#### Remove Pods Target EXUpdates (Optional)
If, after following above steps, your `npm run ios` or `yarn ios` fails and you see `EXUpdates` in logs, follow the steps below:
- Open the iOS directory in Xcode
- Go to Pods module on right side
- In the targets, find `EXUpdates`, right click and delete

88
node_modules/expo-updates/android/build.gradle generated vendored Normal file
View File

@ -0,0 +1,88 @@
apply plugin: 'com.android.library'
apply plugin: 'maven'
group = 'host.exp.exponent'
version = '0.4.2'
// Simple helper that allows the root project to override versions declared by this library.
def safeExtGet(prop, fallback) {
rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
}
// Upload android library to maven with javadoc and android sources
configurations {
deployerJars
}
// Creating sources with comments
task androidSourcesJar(type: Jar) {
classifier = 'sources'
from android.sourceSets.main.java.srcDirs
}
// Put the androidSources and javadoc to the artifacts
artifacts {
archives androidSourcesJar
}
uploadArchives {
repositories {
mavenDeployer {
configuration = configurations.deployerJars
repository(url: mavenLocal().url)
}
}
}
android {
compileSdkVersion safeExtGet("compileSdkVersion", 28)
defaultConfig {
minSdkVersion safeExtGet("minSdkVersion", 21)
targetSdkVersion safeExtGet("targetSdkVersion", 29)
versionCode 30
versionName '0.4.2'
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
lintOptions {
abortOnError false
}
compileOptions {
sourceCompatibility = '1.8'
targetCompatibility = '1.8'
}
testOptions {
unitTests.includeAndroidResources = true
}
}
if (new File(rootProject.projectDir.parentFile, 'package.json').exists()) {
apply from: project(":unimodules-core").file("../unimodules-core.gradle")
} else {
throw new GradleException(
'\'unimodules-core.gradle\' was not found in the usual React Native dependency location. ' +
'This package can only be used in such projects. Are you sure you\'ve installed the dependencies properly?')
}
dependencies {
unimodule 'unimodules-core'
implementation "com.facebook.react:react-native:+"
def room_version = "2.1.0"
implementation "androidx.room:room-runtime:$room_version"
annotationProcessor "androidx.room:room-compiler:$room_version"
implementation("com.squareup.okhttp3:okhttp:3.12.1")
implementation("com.squareup.okhttp3:okhttp-urlconnection:3.12.1")
implementation("com.squareup.okio:okio:1.15.0")
implementation("commons-io:commons-io:2.6")
implementation("org.apache.commons:commons-lang3:3.9")
testImplementation 'junit:junit:4.12'
testImplementation 'androidx.test:core:1.0.0'
testImplementation 'org.mockito:mockito-core:1.10.19'
androidTestImplementation 'androidx.test:runner:1.1.0'
androidTestImplementation 'androidx.test:rules:1.1.0'
}

View File

@ -0,0 +1,134 @@
package expo.modules.updates.db;
import android.content.Context;
import org.junit.After;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import java.util.Arrays;
import java.util.Date;
import java.util.List;
import java.util.UUID;
import androidx.room.Room;
import androidx.test.internal.runner.junit4.AndroidJUnit4ClassRunner;
import androidx.test.platform.app.InstrumentationRegistry;
import expo.modules.updates.db.dao.AssetDao;
import expo.modules.updates.db.dao.UpdateDao;
import expo.modules.updates.db.entity.AssetEntity;
import expo.modules.updates.db.entity.UpdateEntity;
@RunWith(AndroidJUnit4ClassRunner.class)
public class UpdatesDatabaseTest {
private UpdatesDatabase db;
private UpdateDao updateDao;
private AssetDao assetDao;
@Before
public void createDb() {
Context context = InstrumentationRegistry.getInstrumentation().getTargetContext();
db = Room.inMemoryDatabaseBuilder(context, UpdatesDatabase.class).build();
updateDao = db.updateDao();
assetDao = db.assetDao();
}
@After
public void closeDb() {
db.close();
}
@Test
public void testInsertUpdate() {
UUID uuid = UUID.randomUUID();
Date date = new Date();
String runtimeVersion = "1.0";
String projectId = "https://exp.host/@esamelson/test-project";
UpdateEntity testUpdate = new UpdateEntity(uuid, date, runtimeVersion, projectId);
updateDao.insertUpdate(testUpdate);
UpdateEntity byId = updateDao.loadUpdateWithId(uuid);
Assert.assertNotNull(byId);
Assert.assertEquals(uuid, byId.id);
Assert.assertEquals(date, byId.commitTime);
Assert.assertEquals(runtimeVersion, byId.runtimeVersion);
Assert.assertEquals(projectId, byId.scopeKey);
updateDao.deleteUpdates(Arrays.asList(testUpdate));
Assert.assertEquals(0, updateDao.loadAllUpdatesForScope(projectId).size());
}
@Test
public void testMarkUpdateReady() {
UUID uuid = UUID.randomUUID();
Date date = new Date();
String runtimeVersion = "1.0";
String projectId = "https://exp.host/@esamelson/test-project";
UpdateEntity testUpdate = new UpdateEntity(uuid, date, runtimeVersion, projectId);
updateDao.insertUpdate(testUpdate);
Assert.assertEquals(0, updateDao.loadLaunchableUpdatesForScope(projectId).size());
updateDao.markUpdateFinished(testUpdate);
Assert.assertEquals(1, updateDao.loadLaunchableUpdatesForScope(projectId).size());
updateDao.deleteUpdates(Arrays.asList(testUpdate));
Assert.assertEquals(0, updateDao.loadAllUpdatesForScope(projectId).size());
}
@Test
public void testDeleteUnusedAssets() {
String runtimeVersion = "1.0";
String projectId = "https://exp.host/@esamelson/test-project";
UpdateEntity update1 = new UpdateEntity(UUID.randomUUID(), new Date(), runtimeVersion, projectId);
AssetEntity asset1 = new AssetEntity("asset1", "png");
AssetEntity commonAsset = new AssetEntity("commonAsset", "png");
updateDao.insertUpdate(update1);
assetDao.insertAssets(Arrays.asList(asset1, commonAsset), update1);
UpdateEntity update2 = new UpdateEntity(UUID.randomUUID(), new Date(), runtimeVersion, projectId);
AssetEntity asset2 = new AssetEntity("asset2", "png");
updateDao.insertUpdate(update2);
assetDao.insertAssets(Arrays.asList(asset2), update2);
assetDao.addExistingAssetToUpdate(update2, commonAsset, false);
UpdateEntity update3 = new UpdateEntity(UUID.randomUUID(), new Date(), runtimeVersion, projectId);
AssetEntity asset3 = new AssetEntity("asset3", "png");
updateDao.insertUpdate(update3);
assetDao.insertAssets(Arrays.asList(asset3), update3);
assetDao.addExistingAssetToUpdate(update3, commonAsset, false);
// update 1 will be deleted
// update 2 will have keep = false
// update 3 will have keep = true
updateDao.deleteUpdates(Arrays.asList(update1));
updateDao.markUpdateFinished(update3);
// check that test has been properly set up
List<UpdateEntity> allUpdates = updateDao.loadAllUpdatesForScope(projectId);
Assert.assertEquals(2, allUpdates.size());
for (UpdateEntity update : allUpdates) {
if (update.id.equals(update2.id)) {
Assert.assertFalse(update.keep);
} else {
Assert.assertTrue(update.keep);
}
}
Assert.assertNotNull(assetDao.loadAssetWithKey("asset1"));
Assert.assertNotNull(assetDao.loadAssetWithKey("asset2"));
Assert.assertNotNull(assetDao.loadAssetWithKey("asset3"));
Assert.assertNotNull(assetDao.loadAssetWithKey("commonAsset"));
assetDao.deleteUnusedAssets();
Assert.assertNull(assetDao.loadAssetWithKey("asset1"));
Assert.assertNull(assetDao.loadAssetWithKey("asset2"));
Assert.assertNotNull(assetDao.loadAssetWithKey("asset3"));
Assert.assertNotNull(assetDao.loadAssetWithKey("commonAsset"));
}
}

View File

@ -0,0 +1,71 @@
package expo.modules.updates.launcher;
import org.junit.Assert;
import org.junit.Test;
import org.junit.runner.RunWith;
import java.util.Arrays;
import java.util.Collections;
import java.util.Date;
import java.util.List;
import java.util.UUID;
import androidx.test.internal.runner.junit4.AndroidJUnit4ClassRunner;
import expo.modules.updates.db.entity.UpdateEntity;
@RunWith(AndroidJUnit4ClassRunner.class)
public class SelectionPolicyNewestTest {
String runtimeVersion = "1.0";
String scopeKey = "dummyScope";
UpdateEntity update1 = new UpdateEntity(UUID.randomUUID(), new Date(1608667857774L), runtimeVersion, scopeKey);
UpdateEntity update2 = new UpdateEntity(UUID.randomUUID(), new Date(1608667857775L), runtimeVersion, scopeKey);
UpdateEntity update3 = new UpdateEntity(UUID.randomUUID(), new Date(1608667857776L), runtimeVersion, scopeKey);
UpdateEntity update4 = new UpdateEntity(UUID.randomUUID(), new Date(1608667857777L), runtimeVersion, scopeKey);
UpdateEntity update5 = new UpdateEntity(UUID.randomUUID(), new Date(1608667857778L), runtimeVersion, scopeKey);
SelectionPolicy selectionPolicy = new SelectionPolicyNewest(runtimeVersion);
@Test
public void testSelectUpdatesToDelete_onlyOneUpdate() {
List<UpdateEntity> updatesToDelete = selectionPolicy.selectUpdatesToDelete(Collections.singletonList(update1), update1);
Assert.assertEquals(0, updatesToDelete.size());
}
@Test
public void testSelectUpdatesToDelete_olderUpdates() {
List<UpdateEntity> updatesToDelete = selectionPolicy.selectUpdatesToDelete(
Arrays.asList(update1, update2, update3),
update3
);
Assert.assertEquals(1, updatesToDelete.size());
Assert.assertTrue(updatesToDelete.contains(update1));
Assert.assertFalse(updatesToDelete.contains(update2));
Assert.assertFalse(updatesToDelete.contains(update3));
}
@Test
public void testSelectUpdatesToDelete_newerUpdates() {
List<UpdateEntity> updatesToDelete = selectionPolicy.selectUpdatesToDelete(
Arrays.asList(update1, update2),
update1
);
Assert.assertEquals(0, updatesToDelete.size());
}
@Test
public void testSelectUpdatesToDelete_olderAndNewerUpdates() {
List<UpdateEntity> updatesToDelete = selectionPolicy.selectUpdatesToDelete(
Arrays.asList(update1, update2, update3, update4, update5),
update4
);
Assert.assertEquals(2, updatesToDelete.size());
Assert.assertTrue(updatesToDelete.contains(update1));
Assert.assertTrue(updatesToDelete.contains(update2));
Assert.assertFalse(updatesToDelete.contains(update3));
Assert.assertFalse(updatesToDelete.contains(update4));
Assert.assertFalse(updatesToDelete.contains(update5));
}
}

View File

@ -0,0 +1,58 @@
package expo.modules.updates.manifest;
import android.net.Uri;
import org.json.JSONException;
import org.json.JSONObject;
import org.junit.Assert;
import org.junit.Test;
import org.junit.runner.RunWith;
import androidx.test.internal.runner.junit4.AndroidJUnit4ClassRunner;
@RunWith(AndroidJUnit4ClassRunner.class)
public class LegacyManifestTest {
@Test
public void testGetAssetsUrlBase_assetUrlOverride_absoluteUrl() throws JSONException {
String assetUrlBase = "https://xxx.dev/~assets";
Uri manifestUrl = Uri.parse("https://esamelson.github.io/self-hosting-test/android-index.json");
JSONObject manifestJson = new JSONObject();
manifestJson.put("assetUrlOverride", assetUrlBase);
Uri expected = Uri.parse(assetUrlBase);
Uri actual = LegacyManifest.getAssetsUrlBase(manifestUrl, manifestJson);
Assert.assertEquals(expected, actual);
}
@Test
public void testGetAssetsUrlBase_assetUrlOverride_relativeUrl() throws JSONException {
Uri manifestUrl = Uri.parse("https://esamelson.github.io/self-hosting-test/android-index.json");
JSONObject manifestJson = new JSONObject();
manifestJson.put("assetUrlOverride", "my_assets");
Uri expected = Uri.parse("https://esamelson.github.io/self-hosting-test/my_assets");
Uri actual = LegacyManifest.getAssetsUrlBase(manifestUrl, manifestJson);
Assert.assertEquals(expected, actual);
}
@Test
public void testGetAssetsUrlBase_assetUrlOverride_relativeUrlDotSlash() throws JSONException {
Uri manifestUrl = Uri.parse("https://esamelson.github.io/self-hosting-test/android-index.json");
JSONObject manifestJson = new JSONObject();
manifestJson.put("assetUrlOverride", "./assets");
Uri expected = Uri.parse("https://esamelson.github.io/self-hosting-test/assets");
Uri actual = LegacyManifest.getAssetsUrlBase(manifestUrl, manifestJson);
Assert.assertEquals(expected, actual);
}
@Test
public void testGetAssetsUrlBase_assetUrlOverride_default() throws JSONException {
Uri manifestUrl = Uri.parse("https://esamelson.github.io/self-hosting-test/android-index.json");
JSONObject manifestJson = new JSONObject();
Uri expected = Uri.parse("https://esamelson.github.io/self-hosting-test/assets");
Uri actual = LegacyManifest.getAssetsUrlBase(manifestUrl, manifestJson);
Assert.assertEquals(expected, actual);
}
}

View File

@ -0,0 +1,4 @@
<manifest package="expo.modules.updates"
xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
</manifest>

View File

@ -0,0 +1,227 @@
package expo.modules.updates;
import android.content.Context;
import android.content.pm.ApplicationInfo;
import android.content.pm.PackageManager;
import android.net.Uri;
import android.util.Log;
import java.util.HashMap;
import java.util.Map;
import androidx.annotation.Nullable;
public class UpdatesConfiguration {
private static final String TAG = UpdatesConfiguration.class.getSimpleName();
public static final String UPDATES_CONFIGURATION_ENABLED_KEY = "enabled";
public static final String UPDATES_CONFIGURATION_SCOPE_KEY_KEY = "scopeKey";
public static final String UPDATES_CONFIGURATION_UPDATE_URL_KEY = "updateUrl";
public static final String UPDATES_CONFIGURATION_REQUEST_HEADERS_KEY = "requestHeaders";
public static final String UPDATES_CONFIGURATION_RELEASE_CHANNEL_KEY = "releaseChannel";
public static final String UPDATES_CONFIGURATION_SDK_VERSION_KEY = "sdkVersion";
public static final String UPDATES_CONFIGURATION_RUNTIME_VERSION_KEY = "runtimeVersion";
public static final String UPDATES_CONFIGURATION_CHECK_ON_LAUNCH_KEY = "checkOnLaunch";
public static final String UPDATES_CONFIGURATION_LAUNCH_WAIT_MS_KEY = "launchWaitMs";
public static final String UPDATES_CONFIGURATION_HAS_EMBEDDED_UPDATE = "hasEmbeddedUpdate";
private static final String UPDATES_CONFIGURATION_RELEASE_CHANNEL_DEFAULT_VALUE = "default";
private static final int UPDATES_CONFIGURATION_LAUNCH_WAIT_MS_DEFAULT_VALUE = 0;
public enum CheckAutomaticallyConfiguration {
NEVER,
WIFI_ONLY,
ALWAYS,
}
private boolean mIsEnabled;
private String mScopeKey;
private Uri mUpdateUrl;
private Map<String, String> mRequestHeaders = new HashMap<>();
private String mSdkVersion;
private String mRuntimeVersion;
private String mReleaseChannel = UPDATES_CONFIGURATION_RELEASE_CHANNEL_DEFAULT_VALUE;
private int mLaunchWaitMs = UPDATES_CONFIGURATION_LAUNCH_WAIT_MS_DEFAULT_VALUE;
private CheckAutomaticallyConfiguration mCheckOnLaunch = CheckAutomaticallyConfiguration.ALWAYS;
private boolean mHasEmbeddedUpdate = true;
public boolean isEnabled() {
return mIsEnabled;
}
public String getScopeKey() {
return mScopeKey;
}
public Uri getUpdateUrl() {
return mUpdateUrl;
}
public Map<String, String> getRequestHeaders() {
if (mRequestHeaders == null) {
return new HashMap<>();
}
return mRequestHeaders;
}
public String getReleaseChannel() {
return mReleaseChannel;
}
public String getSdkVersion() {
return mSdkVersion;
}
public String getRuntimeVersion() {
return mRuntimeVersion;
}
public CheckAutomaticallyConfiguration getCheckOnLaunch() {
return mCheckOnLaunch;
}
public int getLaunchWaitMs() {
return mLaunchWaitMs;
}
public boolean hasEmbeddedUpdate() {
return mHasEmbeddedUpdate;
}
public UpdatesConfiguration loadValuesFromMetadata(Context context) {
try {
ApplicationInfo ai = context.getPackageManager().getApplicationInfo(context.getPackageName(), PackageManager.GET_META_DATA);
String urlString = ai.metaData.getString("expo.modules.updates.EXPO_UPDATE_URL");
mUpdateUrl = urlString == null ? null : Uri.parse(urlString);
mScopeKey = ai.metaData.getString("expo.modules.updates.EXPO_SCOPE_KEY");
maybeSetDefaultScopeKey();
mIsEnabled = ai.metaData.getBoolean("expo.modules.updates.ENABLED", true);
mSdkVersion = ai.metaData.getString("expo.modules.updates.EXPO_SDK_VERSION");
mReleaseChannel = ai.metaData.getString("expo.modules.updates.EXPO_RELEASE_CHANNEL", "default");
mLaunchWaitMs = ai.metaData.getInt("expo.modules.updates.EXPO_UPDATES_LAUNCH_WAIT_MS", 0);
Object runtimeVersion = ai.metaData.get("expo.modules.updates.EXPO_RUNTIME_VERSION");
mRuntimeVersion = runtimeVersion == null ? null : String.valueOf(runtimeVersion);
String checkOnLaunchString = ai.metaData.getString("expo.modules.updates.EXPO_UPDATES_CHECK_ON_LAUNCH", "ALWAYS");
try {
mCheckOnLaunch = CheckAutomaticallyConfiguration.valueOf(checkOnLaunchString);
} catch (IllegalArgumentException e) {
Log.e(TAG, "Invalid value " + checkOnLaunchString + " for expo.modules.updates.EXPO_UPDATES_CHECK_ON_LAUNCH in AndroidManifest; defaulting to ALWAYS");
mCheckOnLaunch = CheckAutomaticallyConfiguration.ALWAYS;
}
} catch (Exception e) {
Log.e(TAG, "Could not read expo-updates configuration data in AndroidManifest", e);
}
return this;
}
public UpdatesConfiguration loadValuesFromMap(Map<String, Object> map) {
Boolean isEnabledFromMap = readValueCheckingType(map, UPDATES_CONFIGURATION_ENABLED_KEY, Boolean.class);
if (isEnabledFromMap != null) {
mIsEnabled = isEnabledFromMap;
}
Uri updateUrlFromMap = readValueCheckingType(map, UPDATES_CONFIGURATION_UPDATE_URL_KEY, Uri.class);
if (updateUrlFromMap != null) {
mUpdateUrl = updateUrlFromMap;
}
String scopeKeyFromMap = readValueCheckingType(map, UPDATES_CONFIGURATION_SCOPE_KEY_KEY, String.class);
if (scopeKeyFromMap != null) {
mScopeKey = scopeKeyFromMap;
}
maybeSetDefaultScopeKey();
Map<String, String> requestHeadersFromMap = readValueCheckingType(map, UPDATES_CONFIGURATION_REQUEST_HEADERS_KEY, Map.class);
if (requestHeadersFromMap != null) {
mRequestHeaders = requestHeadersFromMap;
}
String releaseChannelFromMap = readValueCheckingType(map, UPDATES_CONFIGURATION_RELEASE_CHANNEL_KEY, String.class);
if (releaseChannelFromMap != null) {
mReleaseChannel = releaseChannelFromMap;
}
String sdkVersionFromMap = readValueCheckingType(map, UPDATES_CONFIGURATION_SDK_VERSION_KEY, String.class);
if (sdkVersionFromMap != null) {
mSdkVersion = sdkVersionFromMap;
}
String runtimeVersionFromMap = readValueCheckingType(map, UPDATES_CONFIGURATION_RUNTIME_VERSION_KEY, String.class);
if (runtimeVersionFromMap != null) {
mRuntimeVersion = runtimeVersionFromMap;
}
String checkOnLaunchFromMap = readValueCheckingType(map, UPDATES_CONFIGURATION_CHECK_ON_LAUNCH_KEY, String.class);
if (checkOnLaunchFromMap != null) {
try {
mCheckOnLaunch = CheckAutomaticallyConfiguration.valueOf(checkOnLaunchFromMap);
} catch (IllegalArgumentException e) {
throw new AssertionError("UpdatesConfiguration failed to initialize: invalid value " + checkOnLaunchFromMap + " provided for checkOnLaunch");
}
}
Integer launchWaitMsFromMap = readValueCheckingType(map, UPDATES_CONFIGURATION_LAUNCH_WAIT_MS_KEY, Integer.class);
if (launchWaitMsFromMap != null) {
mLaunchWaitMs = launchWaitMsFromMap;
}
Boolean hasEmbeddedUpdateFromMap = readValueCheckingType(map, UPDATES_CONFIGURATION_HAS_EMBEDDED_UPDATE, Boolean.class);
if (hasEmbeddedUpdateFromMap != null) {
mHasEmbeddedUpdate = hasEmbeddedUpdateFromMap;
}
return this;
}
private @Nullable <T> T readValueCheckingType(Map<String, Object> map, String key, Class<T> clazz) {
if (!map.containsKey(key)) {
return null;
}
Object value = map.get(key);
if (clazz.isInstance(value)) {
return clazz.cast(value);
} else {
throw new AssertionError("UpdatesConfiguration failed to initialize: bad value of type " + value.getClass().getSimpleName() + " provided for key " + key);
}
}
private void maybeSetDefaultScopeKey() {
// set updateUrl as the default value if none is provided
if (mScopeKey == null) {
if (mUpdateUrl != null) {
mScopeKey = getNormalizedUrlOrigin(mUpdateUrl);
} else {
throw new AssertionError("expo-updates must be configured with a valid update URL or scope key.");
}
}
}
/* package */ static String getNormalizedUrlOrigin(Uri url) {
String scheme = url.getScheme();
int port = url.getPort();
if (port == getDefaultPortForScheme(scheme)) {
port = -1;
}
return port > -1
? scheme + "://" + url.getHost() + ":" + port
: scheme + "://" + url.getHost();
}
private static int getDefaultPortForScheme(String scheme) {
if ("http".equals(scheme) || "ws".equals(scheme)) {
return 80;
} else if ("https".equals(scheme) || "wss".equals(scheme)) {
return 443;
} else if ("ftp".equals(scheme)) {
return 21;
}
return -1;
}
}

View File

@ -0,0 +1,339 @@
package expo.modules.updates;
import android.content.Context;
import android.net.Uri;
import android.os.AsyncTask;
import android.os.Handler;
import android.os.Looper;
import android.util.Log;
import com.facebook.react.ReactApplication;
import com.facebook.react.ReactInstanceManager;
import com.facebook.react.ReactNativeHost;
import com.facebook.react.bridge.Arguments;
import com.facebook.react.bridge.JSBundleLoader;
import com.facebook.react.bridge.WritableMap;
import androidx.annotation.Nullable;
import expo.modules.updates.db.DatabaseHolder;
import expo.modules.updates.db.Reaper;
import expo.modules.updates.db.UpdatesDatabase;
import expo.modules.updates.db.entity.AssetEntity;
import expo.modules.updates.db.entity.UpdateEntity;
import expo.modules.updates.launcher.DatabaseLauncher;
import expo.modules.updates.launcher.NoDatabaseLauncher;
import expo.modules.updates.launcher.Launcher;
import expo.modules.updates.launcher.SelectionPolicy;
import expo.modules.updates.launcher.SelectionPolicyNewest;
import expo.modules.updates.loader.LoaderTask;
import expo.modules.updates.manifest.Manifest;
import java.io.File;
import java.lang.ref.WeakReference;
import java.lang.reflect.Field;
import java.util.Map;
public class UpdatesController {
private static final String TAG = UpdatesController.class.getSimpleName();
private static final String UPDATE_AVAILABLE_EVENT = "updateAvailable";
private static final String UPDATE_NO_UPDATE_AVAILABLE_EVENT = "noUpdateAvailable";
private static final String UPDATE_ERROR_EVENT = "error";
private static UpdatesController sInstance;
private WeakReference<ReactNativeHost> mReactNativeHost;
private UpdatesConfiguration mUpdatesConfiguration;
private File mUpdatesDirectory;
private Exception mUpdatesDirectoryException;
private Launcher mLauncher;
private DatabaseHolder mDatabaseHolder;
private SelectionPolicy mSelectionPolicy;
// launch conditions
private boolean mIsLoaderTaskFinished = false;
private boolean mIsEmergencyLaunch = false;
private UpdatesController(Context context, UpdatesConfiguration updatesConfiguration) {
mUpdatesConfiguration = updatesConfiguration;
mDatabaseHolder = new DatabaseHolder(UpdatesDatabase.getInstance(context));
mSelectionPolicy = new SelectionPolicyNewest(UpdatesUtils.getRuntimeVersion(updatesConfiguration));
if (context instanceof ReactApplication) {
mReactNativeHost = new WeakReference<>(((ReactApplication) context).getReactNativeHost());
}
try {
mUpdatesDirectory = UpdatesUtils.getOrCreateUpdatesDirectory(context);
} catch (Exception e) {
mUpdatesDirectoryException = e;
mUpdatesDirectory = null;
}
}
public static UpdatesController getInstance() {
if (sInstance == null) {
throw new IllegalStateException("UpdatesController.getInstance() was called before the module was initialized");
}
return sInstance;
}
/**
* Initializes the UpdatesController singleton. This should be called as early as possible in the
* application's lifecycle.
* @param context the base context of the application, ideally a {@link ReactApplication}
*/
public static void initialize(Context context) {
if (sInstance == null) {
UpdatesConfiguration updatesConfiguration = new UpdatesConfiguration().loadValuesFromMetadata(context);
sInstance = new UpdatesController(context, updatesConfiguration);
sInstance.start(context);
}
}
/**
* Initializes the UpdatesController singleton. This should be called as early as possible in the
* application's lifecycle. Use this method to set or override configuration values at runtime
* rather than from AndroidManifest.xml.
* @param context the base context of the application, ideally a {@link ReactApplication}
*/
public static void initialize(Context context, Map<String, Object> configuration) {
if (sInstance == null) {
UpdatesConfiguration updatesConfiguration = new UpdatesConfiguration()
.loadValuesFromMetadata(context)
.loadValuesFromMap(configuration);
sInstance = new UpdatesController(context, updatesConfiguration);
sInstance.start(context);
}
}
/**
* If UpdatesController.initialize() is not provided with a {@link ReactApplication}, this method
* can be used to set a {@link ReactNativeHost} on the class. This is optional, but required in
* order for `Updates.reload()` and some Updates module events to work.
* @param reactNativeHost the ReactNativeHost of the application running the Updates module
*/
public void setReactNativeHost(ReactNativeHost reactNativeHost) {
mReactNativeHost = new WeakReference<>(reactNativeHost);
}
/**
* Returns the path on disk to the launch asset (JS bundle) file for the React Native host to use.
* Blocks until the configured timeout runs out, or a new update has been downloaded and is ready
* to use (whichever comes sooner). ReactNativeHost.getJSBundleFile() should call into this.
*
* If this returns null, something has gone wrong and expo-updates has not been able to launch or
* find an update to use. In (and only in) this case, `getBundleAssetName()` will return a nonnull
* fallback value to use.
*/
public synchronized @Nullable String getLaunchAssetFile() {
while (!mIsLoaderTaskFinished) {
try {
wait();
} catch (InterruptedException e) {
Log.e(TAG, "Interrupted while waiting for launch asset file", e);
}
}
if (mLauncher == null) {
return null;
}
return mLauncher.getLaunchAssetFile();
}
/**
* Returns the filename of the launch asset (JS bundle) file embedded in the APK bundle, which can
* be read using `context.getAssets()`. This is only nonnull if `getLaunchAssetFile` is null and
* should only be used in such a situation. ReactNativeHost.getBundleAssetName() should call into
* this.
*/
public @Nullable String getBundleAssetName() {
if (mLauncher == null) {
return null;
}
return mLauncher.getBundleAssetName();
}
/**
* Returns a map of the locally downloaded assets for the current update. Keys are the remote URLs
* of the assets and values are local paths. This should be exported by the Updates JS module and
* can be used by `expo-asset` or a similar module to override React Native's asset resolution and
* use the locally downloaded assets.
*/
public @Nullable Map<AssetEntity, String> getLocalAssetFiles() {
if (mLauncher == null) {
return null;
}
return mLauncher.getLocalAssetFiles();
}
public boolean isUsingEmbeddedAssets() {
if (mLauncher == null) {
return true;
}
return mLauncher.isUsingEmbeddedAssets();
}
// other getters
public DatabaseHolder getDatabaseHolder() {
return mDatabaseHolder;
}
public UpdatesDatabase getDatabase() {
return mDatabaseHolder.getDatabase();
}
public void releaseDatabase() {
mDatabaseHolder.releaseDatabase();
}
public Uri getUpdateUrl() {
return mUpdatesConfiguration.getUpdateUrl();
}
public UpdatesConfiguration getUpdatesConfiguration() {
return mUpdatesConfiguration;
}
public File getUpdatesDirectory() {
return mUpdatesDirectory;
}
public UpdateEntity getLaunchedUpdate() {
return mLauncher.getLaunchedUpdate();
}
public SelectionPolicy getSelectionPolicy() {
return mSelectionPolicy;
}
public boolean isEmergencyLaunch() {
return mIsEmergencyLaunch;
}
/**
* Starts the update process to launch a previously-loaded update and (if configured to do so)
* check for a new update from the server. This method should be called as early as possible in
* the application's lifecycle.
* @param context the base context of the application, ideally a {@link ReactApplication}
*/
public synchronized void start(final Context context) {
if (!mUpdatesConfiguration.isEnabled()) {
mLauncher = new NoDatabaseLauncher(context, mUpdatesConfiguration);
}
if (mUpdatesDirectory == null) {
mLauncher = new NoDatabaseLauncher(context, mUpdatesConfiguration, mUpdatesDirectoryException);
mIsEmergencyLaunch = true;
}
new LoaderTask(mUpdatesConfiguration, mDatabaseHolder, mUpdatesDirectory, mSelectionPolicy, new LoaderTask.LoaderTaskCallback() {
@Override
public void onFailure(Exception e) {
mLauncher = new NoDatabaseLauncher(context, mUpdatesConfiguration, e);
mIsEmergencyLaunch = true;
notifyController();
}
@Override
public boolean onCachedUpdateLoaded(UpdateEntity update) {
return true;
}
@Override
public void onRemoteManifestLoaded(Manifest manifest) { }
@Override
public void onSuccess(Launcher launcher, boolean isUpToDate) {
mLauncher = launcher;
notifyController();
}
@Override
public void onBackgroundUpdateFinished(LoaderTask.BackgroundUpdateStatus status, @Nullable UpdateEntity update, @Nullable Exception exception) {
if (status == LoaderTask.BackgroundUpdateStatus.ERROR) {
if (exception == null) {
throw new AssertionError("Background update with error status must have a nonnull exception object");
}
WritableMap params = Arguments.createMap();
params.putString("message", exception.getMessage());
UpdatesUtils.sendEventToReactNative(mReactNativeHost, UPDATE_ERROR_EVENT, params);
} else if (status == LoaderTask.BackgroundUpdateStatus.UPDATE_AVAILABLE) {
if (update == null) {
throw new AssertionError("Background update with error status must have a nonnull update object");
}
WritableMap params = Arguments.createMap();
params.putString("manifestString", update.metadata.toString());
UpdatesUtils.sendEventToReactNative(mReactNativeHost, UPDATE_AVAILABLE_EVENT, params);
} else if (status == LoaderTask.BackgroundUpdateStatus.NO_UPDATE_AVAILABLE) {
UpdatesUtils.sendEventToReactNative(mReactNativeHost, UPDATE_NO_UPDATE_AVAILABLE_EVENT, null);
}
}
}).start(context);
}
private synchronized void notifyController() {
mIsLoaderTaskFinished = true;
notify();
}
private void runReaper() {
AsyncTask.execute(() -> {
UpdatesDatabase database = getDatabase();
Reaper.reapUnusedUpdates(mUpdatesConfiguration, database, mUpdatesDirectory, getLaunchedUpdate(), mSelectionPolicy);
releaseDatabase();
});
}
public void relaunchReactApplication(Context context, Launcher.LauncherCallback callback) {
if (mReactNativeHost == null || mReactNativeHost.get() == null) {
callback.onFailure(new Exception("Could not reload application. Ensure you have passed the correct instance of ReactApplication into UpdatesController.initialize()."));
return;
}
final ReactNativeHost host = mReactNativeHost.get();
final String oldLaunchAssetFile = mLauncher.getLaunchAssetFile();
UpdatesDatabase database = getDatabase();
final DatabaseLauncher newLauncher = new DatabaseLauncher(mUpdatesConfiguration, mUpdatesDirectory, mSelectionPolicy);
newLauncher.launch(database, context, new Launcher.LauncherCallback() {
@Override
public void onFailure(Exception e) {
callback.onFailure(e);
}
@Override
public void onSuccess() {
mLauncher = newLauncher;
releaseDatabase();
final ReactInstanceManager instanceManager = host.getReactInstanceManager();
String newLaunchAssetFile = mLauncher.getLaunchAssetFile();
if (newLaunchAssetFile != null && !newLaunchAssetFile.equals(oldLaunchAssetFile)) {
// Unfortunately, even though RN exposes a way to reload an application,
// it assumes that the JS bundle will stay at the same location throughout
// the entire lifecycle of the app. Since we need to change the location of
// the bundle, we need to use reflection to set an otherwise inaccessible
// field of the ReactInstanceManager.
try {
JSBundleLoader newJSBundleLoader = JSBundleLoader.createFileLoader(newLaunchAssetFile);
Field jsBundleLoaderField = instanceManager.getClass().getDeclaredField("mBundleLoader");
jsBundleLoaderField.setAccessible(true);
jsBundleLoaderField.set(instanceManager, newJSBundleLoader);
} catch (Exception e) {
Log.e(TAG, "Could not reset JSBundleLoader in ReactInstanceManager", e);
}
}
callback.onSuccess();
Handler handler = new Handler(Looper.getMainLooper());
handler.post(instanceManager::recreateReactContextInBackground);
runReaper();
}
});
}
}

View File

@ -0,0 +1,29 @@
package expo.modules.updates;
import java.io.File;
import java.util.Map;
// this unused import must stay because of versioning
import expo.modules.updates.UpdatesConfiguration;
import expo.modules.updates.db.DatabaseHolder;
import expo.modules.updates.db.entity.AssetEntity;
import expo.modules.updates.db.entity.UpdateEntity;
import expo.modules.updates.launcher.Launcher;
import expo.modules.updates.launcher.SelectionPolicy;
public interface UpdatesInterface {
UpdatesConfiguration getConfiguration();
SelectionPolicy getSelectionPolicy();
File getDirectory();
DatabaseHolder getDatabaseHolder();
boolean isEmergencyLaunch();
boolean isUsingEmbeddedAssets();
boolean canRelaunch();
UpdateEntity getLaunchedUpdate();
Map<AssetEntity, String> getLocalAssetFiles();
void relaunchReactApplication(Launcher.LauncherCallback callback);
}

View File

@ -0,0 +1,213 @@
package expo.modules.updates;
import android.content.Context;
import android.os.AsyncTask;
import android.os.Bundle;
import android.util.Log;
import java.util.HashMap;
import java.util.Map;
import org.unimodules.core.ExportedModule;
import org.unimodules.core.ModuleRegistry;
import org.unimodules.core.Promise;
import org.unimodules.core.interfaces.ExpoMethod;
import androidx.annotation.Nullable;
import expo.modules.updates.db.DatabaseHolder;
import expo.modules.updates.db.UpdatesDatabase;
import expo.modules.updates.db.entity.AssetEntity;
import expo.modules.updates.db.entity.UpdateEntity;
import expo.modules.updates.launcher.Launcher;
import expo.modules.updates.loader.FileDownloader;
import expo.modules.updates.manifest.Manifest;
import expo.modules.updates.loader.RemoteLoader;
public class UpdatesModule extends ExportedModule {
private static final String NAME = "ExpoUpdates";
private static final String TAG = UpdatesModule.class.getSimpleName();
private ModuleRegistry mModuleRegistry;
public UpdatesModule(Context context) {
super(context);
}
@Override
public String getName() {
return NAME;
}
@Override
public void onCreate(ModuleRegistry moduleRegistry) {
mModuleRegistry = moduleRegistry;
}
private UpdatesInterface getUpdatesService() {
return mModuleRegistry.getModule(UpdatesInterface.class);
}
@Override
public Map<String, Object> getConstants() {
Map<String, Object> constants = new HashMap<>();
try {
UpdatesInterface updatesService = getUpdatesService();
if (updatesService != null) {
constants.put("isEmergencyLaunch", updatesService.isEmergencyLaunch());
UpdateEntity launchedUpdate = updatesService.getLaunchedUpdate();
if (launchedUpdate != null) {
constants.put("updateId", launchedUpdate.id.toString());
constants.put("manifestString", launchedUpdate.metadata != null ? launchedUpdate.metadata.toString() : "{}");
}
Map<AssetEntity, String> localAssetFiles = updatesService.getLocalAssetFiles();
if (localAssetFiles != null) {
Map<String, String> localAssets = new HashMap<>();
for (AssetEntity asset : localAssetFiles.keySet()) {
localAssets.put(asset.key, localAssetFiles.get(asset));
}
constants.put("localAssets", localAssets);
}
constants.put("isEnabled", updatesService.getConfiguration().isEnabled());
constants.put("releaseChannel", updatesService.getConfiguration().getReleaseChannel());
constants.put("isUsingEmbeddedAssets", updatesService.isUsingEmbeddedAssets());
}
} catch (Exception e) {
// do nothing; this is expected in a development client
constants.put("isEnabled", false);
}
return constants;
}
@ExpoMethod
public void reload(final Promise promise) {
try {
UpdatesInterface updatesService = getUpdatesService();
if (!updatesService.canRelaunch()) {
promise.reject("ERR_UPDATES_DISABLED", "You cannot reload when expo-updates is not enabled.");
return;
}
updatesService.relaunchReactApplication(new Launcher.LauncherCallback() {
@Override
public void onFailure(Exception e) {
Log.e(TAG, "Failed to relaunch application", e);
promise.reject("ERR_UPDATES_RELOAD", e.getMessage(), e);
}
@Override
public void onSuccess() {
promise.resolve(null);
}
});
} catch (IllegalStateException e) {
promise.reject(
"ERR_UPDATES_RELOAD",
"The updates module controller has not been properly initialized. If you're using a development client, you cannot use `Updates.reloadAsync`. Otherwise, make sure you have called the native method UpdatesController.initialize()."
);
}
}
@ExpoMethod
public void checkForUpdateAsync(final Promise promise) {
try {
final UpdatesInterface updatesService = getUpdatesService();
if (!updatesService.getConfiguration().isEnabled()) {
promise.reject("ERR_UPDATES_DISABLED", "You cannot check for updates when expo-updates is not enabled.");
return;
}
FileDownloader.downloadManifest(updatesService.getConfiguration(), getContext(), new FileDownloader.ManifestDownloadCallback() {
@Override
public void onFailure(String message, Exception e) {
promise.reject("ERR_UPDATES_CHECK", message, e);
Log.e(TAG, message, e);
}
@Override
public void onSuccess(Manifest manifest) {
UpdateEntity launchedUpdate = updatesService.getLaunchedUpdate();
Bundle updateInfo = new Bundle();
if (launchedUpdate == null) {
// this shouldn't ever happen, but if we don't have anything to compare
// the new manifest to, let the user know an update is available
updateInfo.putBoolean("isAvailable", true);
updateInfo.putString("manifestString", manifest.getRawManifestJson().toString());
promise.resolve(updateInfo);
return;
}
if (updatesService.getSelectionPolicy().shouldLoadNewUpdate(manifest.getUpdateEntity(), launchedUpdate)) {
updateInfo.putBoolean("isAvailable", true);
updateInfo.putString("manifestString", manifest.getRawManifestJson().toString());
promise.resolve(updateInfo);
} else {
updateInfo.putBoolean("isAvailable", false);
promise.resolve(updateInfo);
}
}
});
} catch (IllegalStateException e) {
promise.reject(
"ERR_UPDATES_CHECK",
"The updates module controller has not been properly initialized. If you're using a development client, you cannot check for updates. Otherwise, make sure you have called the native method UpdatesController.initialize()."
);
}
}
@ExpoMethod
public void fetchUpdateAsync(final Promise promise) {
try {
final UpdatesInterface updatesService = getUpdatesService();
if (!updatesService.getConfiguration().isEnabled()) {
promise.reject("ERR_UPDATES_DISABLED", "You cannot fetch updates when expo-updates is not enabled.");
return;
}
AsyncTask.execute(() -> {
final DatabaseHolder databaseHolder = updatesService.getDatabaseHolder();
new RemoteLoader(getContext(), updatesService.getConfiguration(), databaseHolder.getDatabase(), updatesService.getDirectory())
.start(
updatesService.getConfiguration().getUpdateUrl(),
new RemoteLoader.LoaderCallback() {
@Override
public void onFailure(Exception e) {
databaseHolder.releaseDatabase();
promise.reject("ERR_UPDATES_FETCH", "Failed to download new update", e);
}
@Override
public boolean onManifestLoaded(Manifest manifest) {
return updatesService.getSelectionPolicy().shouldLoadNewUpdate(
manifest.getUpdateEntity(),
updatesService.getLaunchedUpdate()
);
}
@Override
public void onSuccess(@Nullable UpdateEntity update) {
databaseHolder.releaseDatabase();
Bundle updateInfo = new Bundle();
if (update == null) {
updateInfo.putBoolean("isNew", false);
} else {
updateInfo.putBoolean("isNew", true);
updateInfo.putString("manifestString", update.metadata.toString());
}
promise.resolve(updateInfo);
}
}
);
});
} catch (IllegalStateException e) {
promise.reject(
"ERR_UPDATES_FETCH",
"The updates module controller has not been properly initialized. If you're using a development client, you cannot fetch updates. Otherwise, make sure you have called the native method UpdatesController.initialize()."
);
}
}
}

View File

@ -0,0 +1,23 @@
package expo.modules.updates;
import android.content.Context;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import org.unimodules.core.BasePackage;
import org.unimodules.core.ExportedModule;
import org.unimodules.core.interfaces.InternalModule;
public class UpdatesPackage extends BasePackage {
@Override
public List<InternalModule> createInternalModules(Context context) {
return Collections.singletonList((InternalModule) new UpdatesService(context));
}
@Override
public List<ExportedModule> createExportedModules(Context context) {
return Collections.singletonList((ExportedModule) new UpdatesModule(context));
}
}

View File

@ -0,0 +1,87 @@
package expo.modules.updates;
import android.content.Context;
import org.unimodules.core.interfaces.InternalModule;
import java.io.File;
import java.util.Collections;
import java.util.List;
import java.util.Map;
// these unused imports must stay because of versioning
import expo.modules.updates.UpdatesConfiguration;
import expo.modules.updates.UpdatesController;
import expo.modules.updates.db.DatabaseHolder;
import expo.modules.updates.db.entity.AssetEntity;
import expo.modules.updates.db.entity.UpdateEntity;
import expo.modules.updates.launcher.Launcher;
import expo.modules.updates.launcher.SelectionPolicy;
public class UpdatesService implements InternalModule, UpdatesInterface {
private static final String TAG = UpdatesService.class.getSimpleName();
protected Context mContext;
public UpdatesService(Context context) {
super();
mContext = context;
}
@Override
public List<Class> getExportedInterfaces() {
return Collections.singletonList((Class) UpdatesInterface.class);
}
@Override
public UpdatesConfiguration getConfiguration() {
return UpdatesController.getInstance().getUpdatesConfiguration();
}
@Override
public SelectionPolicy getSelectionPolicy() {
return UpdatesController.getInstance().getSelectionPolicy();
}
@Override
public File getDirectory() {
return UpdatesController.getInstance().getUpdatesDirectory();
}
@Override
public DatabaseHolder getDatabaseHolder() {
return UpdatesController.getInstance().getDatabaseHolder();
}
@Override
public boolean isEmergencyLaunch() {
return UpdatesController.getInstance().isEmergencyLaunch();
}
@Override
public boolean isUsingEmbeddedAssets() {
return UpdatesController.getInstance().isUsingEmbeddedAssets();
}
@Override
public boolean canRelaunch() {
return getConfiguration().isEnabled();
}
@Override
public UpdateEntity getLaunchedUpdate() {
return UpdatesController.getInstance().getLaunchedUpdate();
}
@Override
public Map<AssetEntity, String> getLocalAssetFiles() {
return UpdatesController.getInstance().getLocalAssetFiles();
}
@Override
public void relaunchReactApplication(Launcher.LauncherCallback callback) {
UpdatesController.getInstance().relaunchReactApplication(mContext, callback);
}
}

View File

@ -0,0 +1,184 @@
package expo.modules.updates;
import android.content.Context;
import android.net.ConnectivityManager;
import android.net.Uri;
import android.os.AsyncTask;
import android.util.Log;
import com.facebook.react.ReactInstanceManager;
import com.facebook.react.ReactNativeHost;
import com.facebook.react.bridge.Arguments;
import com.facebook.react.bridge.ReactContext;
import com.facebook.react.bridge.WritableMap;
import com.facebook.react.modules.core.DeviceEventManagerModule;
import org.apache.commons.io.FileUtils;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.UnsupportedEncodingException;
import java.lang.ref.WeakReference;
import java.security.DigestInputStream;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import androidx.annotation.Nullable;
import expo.modules.updates.db.entity.AssetEntity;
public class UpdatesUtils {
private static final String TAG = UpdatesUtils.class.getSimpleName();
private static final String UPDATES_DIRECTORY_NAME = ".expo-internal";
private static final String UPDATES_EVENT_NAME = "Expo.nativeUpdatesEvent";
public static File getOrCreateUpdatesDirectory(Context context) throws Exception {
File updatesDirectory = new File(context.getFilesDir(), UPDATES_DIRECTORY_NAME);
boolean exists = updatesDirectory.exists();
if (exists) {
if (updatesDirectory.isFile()) {
throw new Exception("File already exists at the location of the Updates Directory: " + updatesDirectory.toString() + " ; aborting");
}
} else {
if (!updatesDirectory.mkdir()) {
throw new Exception("Failed to create Updates Directory: mkdir() returned false");
}
}
return updatesDirectory;
}
public static String sha256(String string) throws NoSuchAlgorithmException, UnsupportedEncodingException {
try {
MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] data = string.getBytes("UTF-8");
md.update(data, 0, data.length);
byte[] sha1hash = md.digest();
return bytesToHex(sha1hash);
} catch (NoSuchAlgorithmException | UnsupportedEncodingException e) {
Log.e(TAG, "Failed to checksum string via SHA-256", e);
throw e;
}
}
public static byte[] sha256(File file) throws NoSuchAlgorithmException, IOException {
try (
InputStream inputStream = new FileInputStream(file);
DigestInputStream digestInputStream = new DigestInputStream(inputStream, MessageDigest.getInstance("SHA-256"))
) {
MessageDigest md = digestInputStream.getMessageDigest();
return md.digest();
} catch (NoSuchAlgorithmException | IOException e) {
Log.e(TAG, "Failed to checksum file via SHA-256: " + file.toString(), e);
throw e;
}
}
public static byte[] sha256AndWriteToFile(InputStream inputStream, File destination) throws NoSuchAlgorithmException, IOException {
try (
DigestInputStream digestInputStream = new DigestInputStream(inputStream, MessageDigest.getInstance("SHA-256"))
) {
// write file atomically by writing it to a temporary path and then renaming
// this protects us against partially written files if the process is interrupted
File tmpFile = new File(destination.getAbsolutePath() + ".tmp");
FileUtils.copyInputStreamToFile(digestInputStream, tmpFile);
if (!tmpFile.renameTo(destination)) {
throw new IOException("File download was successful, but failed to move from temporary to permanent location " + destination.getAbsolutePath());
}
MessageDigest md = digestInputStream.getMessageDigest();
return md.digest();
}
}
public static String createFilenameForAsset(AssetEntity asset) {
return asset.key;
}
public static void sendEventToReactNative(@Nullable final WeakReference<ReactNativeHost> reactNativeHost, final String eventName, final WritableMap params) {
if (reactNativeHost != null && reactNativeHost.get() != null) {
final ReactInstanceManager instanceManager = reactNativeHost.get().getReactInstanceManager();
AsyncTask.execute(() -> {
try {
ReactContext reactContext = null;
// in case we're trying to send an event before the reactContext has been initialized
// continue to retry for 5000ms
for (int i = 0; i < 5; i++) {
reactContext = instanceManager.getCurrentReactContext();
if (reactContext != null) {
break;
}
Thread.sleep(1000);
}
if (reactContext != null) {
DeviceEventManagerModule.RCTDeviceEventEmitter emitter = reactContext.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class);
if (emitter != null) {
WritableMap eventParams = params;
if (eventParams == null) {
eventParams = Arguments.createMap();
}
eventParams.putString("type", eventName);
emitter.emit(UPDATES_EVENT_NAME, eventParams);
return;
}
}
Log.e(TAG, "Could not emit " + eventName + " event; no event emitter was found.");
} catch (Exception e) {
Log.e(TAG, "Could not emit " + eventName + " event; no react context was found.");
}
});
} else {
Log.e(TAG, "Could not emit " + eventName + " event; UpdatesController was not initialized with an instance of ReactApplication.");
}
}
public static boolean shouldCheckForUpdateOnLaunch(UpdatesConfiguration updatesConfiguration, Context context) {
if (updatesConfiguration.getUpdateUrl() == null) {
return false;
}
UpdatesConfiguration.CheckAutomaticallyConfiguration configuration = updatesConfiguration.getCheckOnLaunch();
switch (configuration) {
case NEVER:
return false;
case WIFI_ONLY:
ConnectivityManager cm = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE);
if (cm == null) {
Log.e(TAG, "Could not determine active network connection is metered; not checking for updates");
return false;
}
return !cm.isActiveNetworkMetered();
case ALWAYS:
default:
return true;
}
}
public static String getRuntimeVersion(UpdatesConfiguration updatesConfiguration) {
String runtimeVersion = updatesConfiguration.getRuntimeVersion();
String sdkVersion = updatesConfiguration.getSdkVersion();
if (runtimeVersion != null && runtimeVersion.length() > 0) {
return runtimeVersion;
} else if (sdkVersion != null && sdkVersion.length() > 0) {
return sdkVersion;
} else {
throw new AssertionError("One of expo_runtime_version or expo_sdk_version must be defined in the Android app manifest");
}
}
// https://stackoverflow.com/questions/9655181/how-to-convert-a-byte-array-to-a-hex-string-in-java
private static final char[] HEX_ARRAY = "0123456789ABCDEF".toCharArray();
public static String bytesToHex(byte[] bytes) {
char[] hexChars = new char[bytes.length * 2];
for (int j = 0; j < bytes.length; j++) {
int v = bytes[j] & 0xFF;
hexChars[j * 2] = HEX_ARRAY[v >>> 4];
hexChars[j * 2 + 1] = HEX_ARRAY[v & 0x0F];
}
return new String(hexChars);
}
}

View File

@ -0,0 +1,137 @@
package expo.modules.updates.db;
import android.net.Uri;
import android.util.Log;
import expo.modules.updates.db.enums.HashType;
import expo.modules.updates.db.enums.UpdateStatus;
import org.json.JSONException;
import org.json.JSONObject;
import java.nio.ByteBuffer;
import java.util.Date;
import java.util.UUID;
import androidx.room.TypeConverter;
public class Converters {
private static final String TAG = Converters.class.getSimpleName();
@TypeConverter
public static Date longToDate(Long value) {
return value == null ? null : new Date(value);
}
@TypeConverter
public static Long dateToLong(Date date) {
return date == null ? null : date.getTime();
}
@TypeConverter
public static Uri stringToUri(String string) {
return string == null ? null : Uri.parse(string);
}
@TypeConverter
public static String uriToString(Uri uri) {
return uri == null ? null : uri.toString();
}
@TypeConverter
public static JSONObject stringToJsonObject(String string) {
if (string == null) {
return null;
}
JSONObject jsonObject;
try {
jsonObject = new JSONObject(string);
} catch (JSONException e) {
Log.e(TAG, "Could not convert string to JSONObject", e);
jsonObject = new JSONObject();
}
return jsonObject;
}
@TypeConverter
public static String jsonObjectToString(JSONObject jsonObject) {
if (jsonObject == null) {
return null;
}
return jsonObject.toString();
}
@TypeConverter
public static UUID bytesToUuid(byte[] bytes) {
ByteBuffer bb = ByteBuffer.wrap(bytes);
long firstLong = bb.getLong();
long secondLong = bb.getLong();
return new UUID(firstLong, secondLong);
}
@TypeConverter
public static byte[] uuidToBytes(UUID uuid) {
ByteBuffer bb = ByteBuffer.wrap(new byte[16]);
bb.putLong(uuid.getMostSignificantBits());
bb.putLong(uuid.getLeastSignificantBits());
return bb.array();
}
@TypeConverter
public static UpdateStatus intToStatus(int value) {
switch (value) {
case 0:
return UpdateStatus.FAILED;
case 1:
return UpdateStatus.READY;
case 2:
return UpdateStatus.LAUNCHABLE;
case 3:
return UpdateStatus.PENDING;
case 5:
return UpdateStatus.EMBEDDED;
case 6:
return UpdateStatus.DEVELOPMENT;
case 4:
default:
return UpdateStatus.UNUSED;
}
}
@TypeConverter
public static int statusToInt(UpdateStatus status) {
switch (status) {
case FAILED:
return 0;
case READY:
return 1;
case LAUNCHABLE:
return 2;
case PENDING:
return 3;
case EMBEDDED:
return 5;
case DEVELOPMENT:
return 6;
case UNUSED:
default:
return 4;
}
}
@TypeConverter
public static HashType intToHashType(int value) {
return HashType.SHA256; // only one hash type for now, SHA256 = 0
}
@TypeConverter
public static int hashTypeToInt(HashType hashType) {
return 0; // only one hash type for now, SHA256 = 0
}
}

View File

@ -0,0 +1,33 @@
package expo.modules.updates.db;
import android.util.Log;
public class DatabaseHolder {
private static final String TAG = DatabaseHolder.class.getSimpleName();
private UpdatesDatabase mDatabase;
private boolean isInUse = false;
public DatabaseHolder(UpdatesDatabase database) {
mDatabase = database;
}
public synchronized UpdatesDatabase getDatabase() {
while (isInUse) {
try {
wait();
} catch (InterruptedException e) {
Log.e(TAG, "Interrupted while waiting for database", e);
}
}
isInUse = true;
return mDatabase;
}
public synchronized void releaseDatabase() {
isInUse = false;
notify();
}
}

View File

@ -0,0 +1,66 @@
package expo.modules.updates.db;
import android.util.Log;
import expo.modules.updates.UpdatesConfiguration;
import expo.modules.updates.launcher.SelectionPolicy;
import expo.modules.updates.db.entity.AssetEntity;
import expo.modules.updates.db.entity.UpdateEntity;
import java.io.File;
import java.util.LinkedList;
import java.util.List;
public class Reaper {
private static String TAG = Reaper.class.getSimpleName();
public static void reapUnusedUpdates(UpdatesConfiguration configuration, UpdatesDatabase database, File updatesDirectory, UpdateEntity launchedUpdate, SelectionPolicy selectionPolicy) {
if (launchedUpdate == null) {
Log.d(TAG, "Tried to reap while no update was launched; aborting");
return;
}
List<UpdateEntity> allUpdates = database.updateDao().loadAllUpdatesForScope(configuration.getScopeKey());
List<UpdateEntity> updatesToDelete = selectionPolicy.selectUpdatesToDelete(allUpdates, launchedUpdate);
database.updateDao().deleteUpdates(updatesToDelete);
List<AssetEntity> assetsToDelete = database.assetDao().deleteUnusedAssets();
LinkedList<AssetEntity> erroredAssets = new LinkedList<>();
for (AssetEntity asset : assetsToDelete) {
if (!asset.markedForDeletion) {
Log.e(TAG, "Tried to delete asset with URL " + asset.url + " but it was not marked for deletion");
continue;
}
File path = new File(updatesDirectory, asset.relativePath);
try {
if (path.exists() && !path.delete()) {
Log.e(TAG, "Failed to delete asset with URL " + asset.url + " at path " + path.toString());
erroredAssets.add(asset);
}
} catch (Exception e) {
Log.e(TAG, "Failed to delete asset with URL " + asset.url + " at path " + path.toString(), e);
erroredAssets.add(asset);
}
}
// retry failed deletions
for (AssetEntity asset : erroredAssets) {
File path = new File(updatesDirectory, asset.relativePath);
try {
if (!path.exists() || path.delete()) {
erroredAssets.remove(asset);
} else {
Log.e(TAG, "Retried and failed again deleting asset with URL " + asset.url + " at path " + path.toString());
}
} catch (Exception e) {
Log.e(TAG, "Retried and failed again deleting asset with URL " + asset.url + " at path " + path.toString(), e);
erroredAssets.add(asset);
}
}
}
}

View File

@ -0,0 +1,43 @@
package expo.modules.updates.db;
import android.content.Context;
import android.util.Log;
import expo.modules.updates.db.dao.AssetDao;
import expo.modules.updates.db.dao.JSONDataDao;
import expo.modules.updates.db.dao.UpdateDao;
import expo.modules.updates.db.entity.AssetEntity;
import expo.modules.updates.db.entity.JSONDataEntity;
import expo.modules.updates.db.entity.UpdateAssetEntity;
import expo.modules.updates.db.entity.UpdateEntity;
import androidx.annotation.NonNull;
import androidx.room.Database;
import androidx.room.Room;
import androidx.room.RoomDatabase;
import androidx.room.TypeConverters;
import androidx.sqlite.db.SupportSQLiteDatabase;
@Database(entities = {UpdateEntity.class, UpdateAssetEntity.class, AssetEntity.class, JSONDataEntity.class}, exportSchema = false, version = 4)
@TypeConverters({Converters.class})
public abstract class UpdatesDatabase extends RoomDatabase {
private static UpdatesDatabase sInstance;
private static final String DB_NAME = "updates.db";
private static final String TAG = UpdatesDatabase.class.getSimpleName();
public abstract UpdateDao updateDao();
public abstract AssetDao assetDao();
public abstract JSONDataDao jsonDataDao();
public static synchronized UpdatesDatabase getInstance(Context context) {
if (sInstance == null) {
sInstance = Room.databaseBuilder(context, UpdatesDatabase.class, DB_NAME)
.fallbackToDestructiveMigration()
.allowMainThreadQueries()
.build();
}
return sInstance;
}
}

View File

@ -0,0 +1,124 @@
package expo.modules.updates.db.dao;
import androidx.annotation.Nullable;
import androidx.room.Update;
import expo.modules.updates.db.entity.AssetEntity;
import expo.modules.updates.db.entity.UpdateAssetEntity;
import expo.modules.updates.db.entity.UpdateEntity;
import java.util.List;
import java.util.UUID;
import androidx.room.Dao;
import androidx.room.Insert;
import androidx.room.OnConflictStrategy;
import androidx.room.Query;
import androidx.room.Transaction;
@Dao
public abstract class AssetDao {
/**
* for private use only
* must be marked public for Room
* so we use the underscore to discourage use
*/
@Insert(onConflict = OnConflictStrategy.REPLACE)
public abstract long _insertAsset(AssetEntity asset);
@Insert(onConflict = OnConflictStrategy.REPLACE)
public abstract void _insertUpdateAsset(UpdateAssetEntity updateAsset);
@Query("UPDATE updates SET launch_asset_id = :assetId WHERE id = :updateId;")
public abstract void _setUpdateLaunchAsset(long assetId, UUID updateId);
@Query("UPDATE assets SET marked_for_deletion = 1;")
public abstract void _markAllAssetsForDeletion();
@Query("UPDATE assets SET marked_for_deletion = 0 WHERE id IN (" +
" SELECT asset_id" +
" FROM updates_assets" +
" INNER JOIN updates ON updates_assets.update_id = updates.id" +
" WHERE updates.keep);")
public abstract void _unmarkUsedAssetsFromDeletion();
@Query("SELECT * FROM assets WHERE marked_for_deletion = 1;")
public abstract List<AssetEntity> _loadAssetsMarkedForDeletion();
@Query("DELETE FROM assets WHERE marked_for_deletion = 1;")
public abstract void _deleteAssetsMarkedForDeletion();
@Query("SELECT * FROM assets WHERE `key` = :key LIMIT 1;")
public abstract List<AssetEntity> _loadAssetWithKey(String key);
/**
* for public use
*/
@Query("SELECT assets.id, url, `key`, headers, type, assets.metadata, download_time, relative_path, hash, hash_type, marked_for_deletion" +
" FROM assets" +
" INNER JOIN updates_assets ON updates_assets.asset_id = assets.id" +
" INNER JOIN updates ON updates_assets.update_id = updates.id" +
" WHERE updates.id = :id;")
public abstract List<AssetEntity> loadAssetsForUpdate(UUID id);
@Update
public abstract void updateAsset(AssetEntity assetEntity);
@Transaction
public void insertAssets(List<AssetEntity> assets, UpdateEntity update) {
for (AssetEntity asset : assets) {
long assetId = _insertAsset(asset);
_insertUpdateAsset(new UpdateAssetEntity(update.id, assetId));
if (asset.isLaunchAsset) {
_setUpdateLaunchAsset(assetId, update.id);
}
}
}
public @Nullable AssetEntity loadAssetWithKey(String key) {
List<AssetEntity> assets = _loadAssetWithKey(key);
if (assets.size() > 0) {
return assets.get(0);
}
return null;
}
public void mergeAndUpdateAsset(AssetEntity existingEntity, AssetEntity newEntity) {
// if the existing entry came from an embedded manifest, it may not have a URL in the database
if (newEntity.url != null && existingEntity.url == null) {
existingEntity.url = newEntity.url;
updateAsset(existingEntity);
}
// we need to keep track of whether the calling class expects this asset to be the launch asset
existingEntity.isLaunchAsset = newEntity.isLaunchAsset;
}
@Transaction
public boolean addExistingAssetToUpdate(UpdateEntity update, AssetEntity asset, boolean isLaunchAsset) {
AssetEntity existingAssetEntry = loadAssetWithKey(asset.key);
if (existingAssetEntry == null) {
return false;
}
long assetId = existingAssetEntry.id;
_insertUpdateAsset(new UpdateAssetEntity(update.id, assetId));
if (isLaunchAsset) {
_setUpdateLaunchAsset(assetId, update.id);
}
return true;
}
@Transaction
public List<AssetEntity> deleteUnusedAssets() {
// the simplest way to mark the assets we want to delete
// is to mark all assets for deletion, then go back and unmark
// those assets in updates we want to keep
// this is safe since this is a transaction and will be rolled back upon failure
_markAllAssetsForDeletion();
_unmarkUsedAssetsFromDeletion();
List<AssetEntity> deletedAssets = _loadAssetsMarkedForDeletion();
_deleteAssetsMarkedForDeletion();
return deletedAssets;
}
}

View File

@ -0,0 +1,46 @@
package expo.modules.updates.db.dao;
import java.util.Date;
import java.util.List;
import javax.annotation.Nullable;
import androidx.room.Dao;
import androidx.room.Insert;
import androidx.room.Query;
import androidx.room.Transaction;
import expo.modules.updates.db.entity.JSONDataEntity;
@Dao
public abstract class JSONDataDao {
/**
* for private use only
* must be marked public for Room
* so we use the underscore to discourage use
*/
@Query("SELECT * FROM json_data WHERE `key` = :key AND scope_key = :scopeKey ORDER BY last_updated DESC LIMIT 1;")
public abstract List<JSONDataEntity> _loadJSONDataForKey(String key, String scopeKey);
@Insert
public abstract void _insertJSONData(JSONDataEntity jsonDataEntity);
@Query("DELETE FROM json_data WHERE `key` = :key AND scope_key = :scopeKey;")
public abstract void _deleteJSONDataForKey(String key, String scopeKey);
/**
* for public use
*/
public @Nullable String loadJSONStringForKey(String key, String scopeKey) {
List<JSONDataEntity> rows = _loadJSONDataForKey(key, scopeKey);
if (rows == null || rows.size() == 0) {
return null;
}
return rows.get(0).value;
}
@Transaction
public void setJSONStringForKey(String key, String value, String scopeKey) {
_deleteJSONDataForKey(key, scopeKey);
_insertJSONData(new JSONDataEntity(key, value, new Date(), scopeKey));
}
}

View File

@ -0,0 +1,92 @@
package expo.modules.updates.db.dao;
import androidx.room.Delete;
import androidx.room.Update;
import expo.modules.updates.db.enums.UpdateStatus;
import expo.modules.updates.db.entity.AssetEntity;
import expo.modules.updates.db.entity.UpdateEntity;
import java.util.Arrays;
import java.util.List;
import java.util.UUID;
import androidx.room.Dao;
import androidx.room.Insert;
import androidx.room.Query;
import androidx.room.Transaction;
@Dao
public abstract class UpdateDao {
/**
* for private use only
* must be marked public for Room
* so we use the underscore to discourage use
*/
@Query("SELECT * FROM updates WHERE scope_key = :scopeKey AND status IN (:statuses);")
public abstract List<UpdateEntity> _loadUpdatesForProjectWithStatuses(String scopeKey, List<UpdateStatus> statuses);
@Query("SELECT * FROM updates WHERE id = :id;")
public abstract List<UpdateEntity> _loadUpdatesWithId(UUID id);
@Query("SELECT assets.* FROM assets INNER JOIN updates ON updates.launch_asset_id = assets.id WHERE updates.id = :id;")
public abstract AssetEntity _loadLaunchAsset(UUID id);
@Query("UPDATE updates SET keep = 1 WHERE id = :id;")
public abstract void _keepUpdate(UUID id);
@Query("UPDATE updates SET status = :status WHERE id = :id;")
public abstract void _markUpdateWithStatus(UpdateStatus status, UUID id);
@Update
public abstract void _updateUpdate(UpdateEntity update);
/**
* for public use
*/
@Query("SELECT * FROM updates WHERE scope_key = :scopeKey;")
public abstract List<UpdateEntity> loadAllUpdatesForScope(String scopeKey);
public List<UpdateEntity> loadLaunchableUpdatesForScope(String scopeKey) {
return _loadUpdatesForProjectWithStatuses(scopeKey, Arrays.asList(UpdateStatus.READY, UpdateStatus.EMBEDDED, UpdateStatus.DEVELOPMENT));
}
public UpdateEntity loadUpdateWithId(UUID id) {
List<UpdateEntity> updateEntities = _loadUpdatesWithId(id);
return updateEntities.size() > 0 ? updateEntities.get(0) : null;
}
public AssetEntity loadLaunchAsset(UUID id) {
AssetEntity assetEntity = _loadLaunchAsset(id);
assetEntity.isLaunchAsset = true;
return assetEntity;
}
@Insert
public abstract void insertUpdate(UpdateEntity update);
public void setUpdateScopeKey(UpdateEntity update, String newScopeKey) {
update.scopeKey = newScopeKey;
_updateUpdate(update);
}
@Transaction
public void markUpdateFinished(UpdateEntity update, boolean hasSkippedEmbeddedAssets) {
UpdateStatus statusToMark = UpdateStatus.READY;
if (update.status == UpdateStatus.DEVELOPMENT) {
statusToMark = UpdateStatus.DEVELOPMENT;
} else if (hasSkippedEmbeddedAssets) {
statusToMark = UpdateStatus.EMBEDDED;
}
_markUpdateWithStatus(statusToMark, update.id);
_keepUpdate(update.id);
}
public void markUpdateFinished(UpdateEntity update) {
markUpdateFinished(update, false);
}
@Delete
public abstract void deleteUpdates(List<UpdateEntity> updates);
}

View File

@ -0,0 +1,76 @@
package expo.modules.updates.db.entity;
import android.net.Uri;
import androidx.room.Index;
import expo.modules.updates.db.enums.HashType;
import org.json.JSONObject;
import java.util.Date;
import androidx.annotation.NonNull;
import androidx.room.ColumnInfo;
import androidx.room.Entity;
import androidx.room.Ignore;
import androidx.room.PrimaryKey;
@Entity(tableName = "assets",
indices = {@Index(value = {"key"}, unique = true)})
public class AssetEntity {
@PrimaryKey(autoGenerate = true)
// 0 is treated as unset while inserting the entity into the db
public long id = 0;
public Uri url = null;
@ColumnInfo(name = "key")
@NonNull
public String key;
public JSONObject headers = null;
@NonNull
public String type;
public JSONObject metadata = null;
@ColumnInfo(name = "download_time")
public Date downloadTime = null;
@ColumnInfo(name = "relative_path")
public String relativePath = null;
public byte[] hash = null;
@ColumnInfo(name = "hash_type")
@NonNull
public HashType hashType = HashType.SHA256;
@ColumnInfo(name = "marked_for_deletion")
@NonNull
public boolean markedForDeletion = false;
@Ignore
public boolean isLaunchAsset = false;
@Ignore
public String embeddedAssetFilename = null;
@Ignore
public String resourcesFilename = null;
@Ignore
public String resourcesFolder = null;
@Ignore
public Float scale = null;
@Ignore
public Float[] scales = null;
public AssetEntity(String key, String type) {
this.key = key;
this.type = type;
}
}

View File

@ -0,0 +1,38 @@
package expo.modules.updates.db.entity;
import java.util.Date;
import androidx.annotation.NonNull;
import androidx.room.ColumnInfo;
import androidx.room.Entity;
import androidx.room.Index;
import androidx.room.PrimaryKey;
@Entity(tableName = "json_data",
indices = {@Index(value = {"scope_key"})})
public class JSONDataEntity {
@PrimaryKey(autoGenerate = true)
// 0 is treated as unset while inserting the entity into the db
public long id = 0;
@NonNull
public String key;
@NonNull
public String value;
@ColumnInfo(name = "last_updated")
@NonNull
public Date lastUpdated;
@ColumnInfo(name = "scope_key")
@NonNull
public String scopeKey;
public JSONDataEntity(String key, String value, Date lastUpdated, String scopeKey) {
this.key = key;
this.value = value;
this.lastUpdated = lastUpdated;
this.scopeKey = scopeKey;
}
}

View File

@ -0,0 +1,38 @@
package expo.modules.updates.db.entity;
import java.util.UUID;
import androidx.annotation.NonNull;
import androidx.room.ColumnInfo;
import androidx.room.Entity;
import androidx.room.ForeignKey;
import androidx.room.Index;
import static androidx.room.ForeignKey.CASCADE;
@Entity(tableName = "updates_assets",
primaryKeys = {"update_id", "asset_id"},
foreignKeys = {
@ForeignKey(entity = UpdateEntity.class,
parentColumns = "id",
childColumns = "update_id",
onDelete = CASCADE),
@ForeignKey(entity = AssetEntity.class,
parentColumns = "id",
childColumns = "asset_id",
onDelete = CASCADE)},
indices = {@Index(value = "asset_id")})
public class UpdateAssetEntity {
@ColumnInfo(name = "update_id")
@NonNull
public UUID updateId;
@ColumnInfo(name = "asset_id")
@NonNull
public long assetId;
public UpdateAssetEntity(UUID updateId, long assetId) {
this.updateId = updateId;
this.assetId = assetId;
}
}

View File

@ -0,0 +1,61 @@
package expo.modules.updates.db.entity;
import expo.modules.updates.db.enums.UpdateStatus;
import org.json.JSONObject;
import java.util.Date;
import java.util.UUID;
import androidx.annotation.NonNull;
import androidx.room.ColumnInfo;
import androidx.room.Entity;
import androidx.room.ForeignKey;
import androidx.room.Index;
import androidx.room.PrimaryKey;
import static androidx.room.ForeignKey.CASCADE;
@Entity(tableName = "updates",
foreignKeys = @ForeignKey(entity = AssetEntity.class,
parentColumns = "id",
childColumns = "launch_asset_id",
onDelete = CASCADE),
indices = {@Index(value = "launch_asset_id"),
@Index(value = {"scope_key", "commit_time"}, unique = true)})
public class UpdateEntity {
@PrimaryKey
@ColumnInfo(typeAffinity = ColumnInfo.BLOB)
@NonNull
public UUID id;
@ColumnInfo(name = "scope_key")
@NonNull
public String scopeKey;
@ColumnInfo(name = "commit_time")
@NonNull
public Date commitTime;
@ColumnInfo(name = "runtime_version")
@NonNull
public String runtimeVersion;
@ColumnInfo(name = "launch_asset_id")
public Long launchAssetId = null;
public JSONObject metadata = null;
@NonNull
public UpdateStatus status = UpdateStatus.PENDING;
@NonNull
public boolean keep = false;
public UpdateEntity(UUID id, Date commitTime, String runtimeVersion, String scopeKey) {
this.id = id;
this.commitTime = commitTime;
this.runtimeVersion = runtimeVersion;
this.scopeKey = scopeKey;
}
}

View File

@ -0,0 +1,5 @@
package expo.modules.updates.db.enums;
public enum HashType {
SHA256
}

View File

@ -0,0 +1,5 @@
package expo.modules.updates.db.enums;
public enum UpdateStatus {
FAILED, READY, LAUNCHABLE, PENDING, UNUSED, EMBEDDED, DEVELOPMENT
}

View File

@ -0,0 +1,235 @@
package expo.modules.updates.launcher;
import android.content.Context;
import android.util.Log;
import java.io.File;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import androidx.annotation.Nullable;
import expo.modules.updates.UpdatesConfiguration;
import expo.modules.updates.db.UpdatesDatabase;
import expo.modules.updates.db.entity.AssetEntity;
import expo.modules.updates.db.entity.UpdateEntity;
import expo.modules.updates.db.enums.UpdateStatus;
import expo.modules.updates.loader.EmbeddedLoader;
import expo.modules.updates.loader.FileDownloader;
import expo.modules.updates.manifest.Manifest;
public class DatabaseLauncher implements Launcher {
private static final String TAG = DatabaseLauncher.class.getSimpleName();
private UpdatesConfiguration mConfiguration;
private File mUpdatesDirectory;
private SelectionPolicy mSelectionPolicy;
private UpdateEntity mLaunchedUpdate = null;
private String mLaunchAssetFile = null;
private String mBundleAssetName = null;
private Map<AssetEntity, String> mLocalAssetFiles = null;
private int mAssetsToDownload = 0;
private int mAssetsToDownloadFinished = 0;
private Exception mLaunchAssetException = null;
private LauncherCallback mCallback = null;
public DatabaseLauncher(UpdatesConfiguration configuration, File updatesDirectory, SelectionPolicy selectionPolicy) {
mConfiguration = configuration;
mUpdatesDirectory = updatesDirectory;
mSelectionPolicy = selectionPolicy;
}
public @Nullable UpdateEntity getLaunchedUpdate() {
return mLaunchedUpdate;
}
public @Nullable String getLaunchAssetFile() {
return mLaunchAssetFile;
}
public @Nullable String getBundleAssetName() {
return mBundleAssetName;
}
public @Nullable Map<AssetEntity, String> getLocalAssetFiles() {
return mLocalAssetFiles;
}
public boolean isUsingEmbeddedAssets() {
return mLocalAssetFiles == null;
}
public synchronized void launch(UpdatesDatabase database, Context context, LauncherCallback callback) {
if (mCallback != null) {
throw new AssertionError("DatabaseLauncher has already started. Create a new instance in order to launch a new version.");
}
mCallback = callback;
mLaunchedUpdate = getLaunchableUpdate(database, context);
if (mLaunchedUpdate == null) {
mCallback.onFailure(new Exception("No launchable update was found. If this is a bare workflow app, make sure you have configured expo-updates correctly in android/app/build.gradle."));
return;
}
if (mLaunchedUpdate.status == UpdateStatus.EMBEDDED) {
mBundleAssetName = EmbeddedLoader.BARE_BUNDLE_FILENAME;
if (mLocalAssetFiles != null) {
throw new AssertionError("mLocalAssetFiles should be null for embedded updates");
}
mCallback.onSuccess();
return;
} else if (mLaunchedUpdate.status == UpdateStatus.DEVELOPMENT) {
mCallback.onSuccess();
return;
}
// verify that we have all assets on disk
// according to the database, we should, but something could have gone wrong on disk
AssetEntity launchAsset = database.updateDao().loadLaunchAsset(mLaunchedUpdate.id);
if (launchAsset.relativePath == null) {
throw new AssertionError("Launch Asset relativePath should not be null");
}
File launchAssetFile = ensureAssetExists(launchAsset, database, context);
if (launchAssetFile != null) {
mLaunchAssetFile = launchAssetFile.toString();
}
List<AssetEntity> assetEntities = database.assetDao().loadAssetsForUpdate(mLaunchedUpdate.id);
mLocalAssetFiles = new HashMap<>();
for (AssetEntity asset : assetEntities) {
String filename = asset.relativePath;
if (filename != null) {
File assetFile = ensureAssetExists(asset, database, context);
if (assetFile != null) {
mLocalAssetFiles.put(
asset,
assetFile.toURI().toString()
);
}
}
}
if (mAssetsToDownload == 0) {
if (mLaunchAssetFile == null) {
mCallback.onFailure(new Exception("mLaunchAssetFile was immediately null; this should never happen"));
} else {
mCallback.onSuccess();
}
}
}
public UpdateEntity getLaunchableUpdate(UpdatesDatabase database, Context context) {
List<UpdateEntity> launchableUpdates = database.updateDao().loadLaunchableUpdatesForScope(mConfiguration.getScopeKey());
// We can only run an update marked as embedded if it's actually the update embedded in the
// current binary. We might have an older update from a previous binary still listed as
// "EMBEDDED" in the database so we need to do this check.
Manifest embeddedManifest = EmbeddedLoader.readEmbeddedManifest(context, mConfiguration);
ArrayList<UpdateEntity> filteredLaunchableUpdates = new ArrayList<>();
for (UpdateEntity update : launchableUpdates) {
if (update.status == UpdateStatus.EMBEDDED) {
if (embeddedManifest != null && !embeddedManifest.getUpdateEntity().id.equals(update.id)) {
continue;
}
}
filteredLaunchableUpdates.add(update);
}
return mSelectionPolicy.selectUpdateToLaunch(filteredLaunchableUpdates);
}
private File ensureAssetExists(AssetEntity asset, UpdatesDatabase database, Context context) {
File assetFile = new File(mUpdatesDirectory, asset.relativePath);
boolean assetFileExists = assetFile.exists();
if (!assetFileExists) {
// something has gone wrong, we're missing this asset
// first we check to see if a copy is embedded in the binary
Manifest embeddedManifest = EmbeddedLoader.readEmbeddedManifest(context, mConfiguration);
if (embeddedManifest != null) {
ArrayList<AssetEntity> embeddedAssets = embeddedManifest.getAssetEntityList();
AssetEntity matchingEmbeddedAsset = null;
for (AssetEntity embeddedAsset : embeddedAssets) {
if (embeddedAsset.key.equals(asset.key)) {
matchingEmbeddedAsset = embeddedAsset;
break;
}
}
if (matchingEmbeddedAsset != null) {
try {
byte[] hash = EmbeddedLoader.copyAssetAndGetHash(matchingEmbeddedAsset, assetFile, context);
if (hash != null && Arrays.equals(hash, asset.hash)) {
assetFileExists = true;
}
} catch (Exception e) {
// things are really not going our way...
Log.e(TAG, "Failed to copy matching embedded asset", e);
}
}
}
}
if (!assetFileExists) {
// we still don't have the asset locally, so try downloading it remotely
mAssetsToDownload++;
FileDownloader.downloadAsset(asset, mUpdatesDirectory, mConfiguration, new FileDownloader.AssetDownloadCallback() {
@Override
public void onFailure(Exception e, AssetEntity assetEntity) {
Log.e(TAG, "Failed to load asset from disk or network", e);
if (assetEntity.isLaunchAsset) {
mLaunchAssetException = e;
}
maybeFinish(assetEntity, null);
}
@Override
public void onSuccess(AssetEntity assetEntity, boolean isNew) {
database.assetDao().updateAsset(assetEntity);
File assetFile = new File(mUpdatesDirectory, assetEntity.relativePath);
maybeFinish(assetEntity, assetFile.exists() ? assetFile : null);
}
});
return null;
} else {
return assetFile;
}
}
private synchronized void maybeFinish(AssetEntity asset, File assetFile) {
mAssetsToDownloadFinished++;
if (asset.isLaunchAsset) {
if (assetFile == null) {
Log.e(TAG, "Could not launch; failed to load update from disk or network");
mLaunchAssetFile = null;
} else {
mLaunchAssetFile = assetFile.toString();
}
} else {
if (assetFile != null) {
mLocalAssetFiles.put(
asset,
assetFile.toString()
);
}
}
if (mAssetsToDownloadFinished == mAssetsToDownload) {
if (mLaunchAssetFile == null) {
if (mLaunchAssetException == null) {
mLaunchAssetException = new Exception("Launcher mLaunchAssetFile is unexpectedly null");
}
mCallback.onFailure(mLaunchAssetException);
} else {
mCallback.onSuccess();
}
}
}
}

View File

@ -0,0 +1,21 @@
package expo.modules.updates.launcher;
import java.util.Map;
import androidx.annotation.Nullable;
import expo.modules.updates.db.entity.AssetEntity;
import expo.modules.updates.db.entity.UpdateEntity;
public interface Launcher {
interface LauncherCallback{
void onFailure(Exception e);
void onSuccess();
}
@Nullable UpdateEntity getLaunchedUpdate();
@Nullable String getLaunchAssetFile();
@Nullable String getBundleAssetName();
@Nullable Map<AssetEntity, String> getLocalAssetFiles();
boolean isUsingEmbeddedAssets();
}

View File

@ -0,0 +1,104 @@
package expo.modules.updates.launcher;
import android.content.Context;
import android.os.AsyncTask;
import android.util.Log;
import org.apache.commons.io.FileUtils;
import java.io.File;
import java.util.HashMap;
import java.util.Map;
import androidx.annotation.Nullable;
import expo.modules.updates.UpdatesConfiguration;
import expo.modules.updates.db.entity.AssetEntity;
import expo.modules.updates.db.entity.UpdateEntity;
import expo.modules.updates.loader.EmbeddedLoader;
import expo.modules.updates.manifest.BareManifest;
import expo.modules.updates.manifest.Manifest;
public class NoDatabaseLauncher implements Launcher {
private static final String TAG = NoDatabaseLauncher.class.getSimpleName();
private static final String ERROR_LOG_FILENAME = "expo-error.log";
private String mBundleAssetName;
private Map<AssetEntity, String> mLocalAssetFiles;
public NoDatabaseLauncher(Context context, UpdatesConfiguration configuration) {
this(context, configuration, null);
}
public NoDatabaseLauncher(final Context context, UpdatesConfiguration configuration, final @Nullable Exception fatalException) {
Manifest embeddedManifest = EmbeddedLoader.readEmbeddedManifest(context, configuration);
if (embeddedManifest == null) {
throw new RuntimeException("Failed to launch with embedded update because the embedded manifest was null");
}
if (embeddedManifest instanceof BareManifest) {
mBundleAssetName = EmbeddedLoader.BARE_BUNDLE_FILENAME;
mLocalAssetFiles = null;
} else {
mBundleAssetName = EmbeddedLoader.BUNDLE_FILENAME;
mLocalAssetFiles = new HashMap<>();
for (AssetEntity asset : embeddedManifest.getAssetEntityList()) {
mLocalAssetFiles.put(
asset,
"asset:///" + asset.embeddedAssetFilename
);
}
}
if (fatalException != null) {
AsyncTask.execute(() -> {
writeErrorToLog(context, fatalException);
});
}
}
public @Nullable UpdateEntity getLaunchedUpdate() {
return null;
}
public @Nullable String getLaunchAssetFile() {
return null;
}
public @Nullable String getBundleAssetName() {
return mBundleAssetName;
}
public @Nullable Map<AssetEntity, String> getLocalAssetFiles() {
return mLocalAssetFiles;
}
public boolean isUsingEmbeddedAssets() {
return mLocalAssetFiles == null;
}
private void writeErrorToLog(Context context, Exception fatalException) {
try {
File errorLogFile = new File(context.getFilesDir(), ERROR_LOG_FILENAME);
String exceptionString = fatalException.toString();
FileUtils.writeStringToFile(errorLogFile, exceptionString, "UTF-8", true);
} catch (Exception e) {
Log.e(TAG, "Failed to write fatal error to log", e);
}
}
public static @Nullable String consumeErrorLog(Context context) {
try {
File errorLogFile = new File(context.getFilesDir(), ERROR_LOG_FILENAME);
if (!errorLogFile.exists()) {
return null;
}
String logContents = FileUtils.readFileToString(errorLogFile, "UTF-8");
errorLogFile.delete();
return logContents;
} catch (Exception e) {
Log.e(TAG, "Failed to read error log", e);
return null;
}
}
}

View File

@ -0,0 +1,11 @@
package expo.modules.updates.launcher;
import expo.modules.updates.db.entity.UpdateEntity;
import java.util.List;
public interface SelectionPolicy {
UpdateEntity selectUpdateToLaunch(List<UpdateEntity> updates);
List<UpdateEntity> selectUpdatesToDelete(List<UpdateEntity> updates, UpdateEntity launchedUpdate);
boolean shouldLoadNewUpdate(UpdateEntity newUpdate, UpdateEntity launchedUpdate);
}

View File

@ -0,0 +1,78 @@
package expo.modules.updates.launcher;
import expo.modules.updates.db.entity.UpdateEntity;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
/**
* Simple Update selection policy which chooses
* the newest update (based on commit time) out
* of all the possible stored updates.
*
* If multiple updates have the same (most
* recent) commit time, this class will return
* the earliest one in the list.
*/
public class SelectionPolicyNewest implements SelectionPolicy {
private List<String> mRuntimeVersions;
public SelectionPolicyNewest(List<String> runtimeVersions) {
mRuntimeVersions = runtimeVersions;
}
public SelectionPolicyNewest(String runtimeVersion) {
mRuntimeVersions = Arrays.asList(runtimeVersion);
}
@Override
public UpdateEntity selectUpdateToLaunch(List<UpdateEntity> updates) {
UpdateEntity updateToLaunch = null;
for (UpdateEntity update : updates) {
if (!mRuntimeVersions.contains(update.runtimeVersion)) {
continue;
}
if (updateToLaunch == null || updateToLaunch.commitTime.before(update.commitTime)) {
updateToLaunch = update;
}
}
return updateToLaunch;
}
@Override
public List<UpdateEntity> selectUpdatesToDelete(List<UpdateEntity> updates, UpdateEntity launchedUpdate) {
if (launchedUpdate == null) {
return new ArrayList<>();
}
List<UpdateEntity> updatesToDelete = new ArrayList<>();
// keep the launched update and one other, the next newest, to be safe and make rollbacks faster
UpdateEntity nextNewestUpdate = null;
for (UpdateEntity update : updates) {
if (update.commitTime.before(launchedUpdate.commitTime)) {
updatesToDelete.add(update);
if (nextNewestUpdate == null || nextNewestUpdate.commitTime.before(update.commitTime)) {
nextNewestUpdate = update;
}
}
}
if (nextNewestUpdate != null) {
updatesToDelete.remove(nextNewestUpdate);
}
return updatesToDelete;
}
@Override
public boolean shouldLoadNewUpdate(UpdateEntity newUpdate, UpdateEntity launchedUpdate) {
if (launchedUpdate == null) {
return true;
}
if (newUpdate == null) {
return false;
}
return newUpdate.commitTime.after(launchedUpdate.commitTime);
}
}

View File

@ -0,0 +1,95 @@
package expo.modules.updates.loader;
import android.annotation.SuppressLint;
import android.security.keystore.KeyProperties;
import android.util.Base64;
import java.io.IOException;
import java.security.InvalidKeyException;
import java.security.KeyFactory;
import java.security.NoSuchAlgorithmException;
import java.security.PublicKey;
import java.security.Signature;
import java.security.SignatureException;
import java.security.spec.InvalidKeySpecException;
import java.security.spec.X509EncodedKeySpec;
import okhttp3.CacheControl;
import okhttp3.Call;
import okhttp3.Callback;
import okhttp3.Request;
import okhttp3.Response;
public class Crypto {
public interface RSASignatureListener {
void onError(Exception exception, boolean isNetworkError);
void onCompleted(boolean isValid);
}
private static String PUBLIC_KEY_URL = "https://exp.host/--/manifest-public-key";
public static void verifyPublicRSASignature(final String plainText, final String cipherText, final RSASignatureListener listener) {
fetchPublicKeyAndVerifyPublicRSASignature(true, plainText, cipherText, listener);
}
// On first attempt use cache. If verification fails try a second attempt without
// cache in case the keys were actually rotated.
// On second attempt reject promise if it fails.
private static void fetchPublicKeyAndVerifyPublicRSASignature(final boolean isFirstAttempt, final String plainText, final String cipherText, final RSASignatureListener listener) {
final CacheControl cacheControl = isFirstAttempt ? CacheControl.FORCE_CACHE : CacheControl.FORCE_NETWORK;
final Request request = new Request.Builder()
.url(PUBLIC_KEY_URL)
.cacheControl(cacheControl)
.build();
FileDownloader.downloadData(request, new Callback() {
@Override
public void onFailure(Call call, IOException e) {
listener.onError(e, true);
}
@Override
public void onResponse(Call call, Response response) throws IOException {
Exception exception;
try {
boolean isValid = verifyPublicRSASignature(response.body().string(), plainText, cipherText);
listener.onCompleted(isValid);
return;
} catch (Exception e) {
exception = e;
}
if (isFirstAttempt) {
fetchPublicKeyAndVerifyPublicRSASignature(false, plainText, cipherText, listener);
} else {
listener.onError(exception, false);
}
}
});
}
private static boolean verifyPublicRSASignature(String publicKey, String plainText, String cipherText)
throws NoSuchAlgorithmException, InvalidKeySpecException, InvalidKeyException, SignatureException {
// remove comments from public key
String publicKeySplit[] = publicKey.split("\\r?\\n");
String publicKeyNoComments = "";
for (String line : publicKeySplit) {
if (!line.contains("PUBLIC KEY-----")) {
publicKeyNoComments += line + "\n";
}
}
Signature signature = Signature.getInstance("SHA256withRSA");
byte[] decodedPublicKey = Base64.decode(publicKeyNoComments, Base64.DEFAULT);
X509EncodedKeySpec publicKeySpec = new X509EncodedKeySpec(decodedPublicKey);
@SuppressLint("InlinedApi") KeyFactory keyFactory = KeyFactory.getInstance(KeyProperties.KEY_ALGORITHM_RSA);
PublicKey key = keyFactory.generatePublic(publicKeySpec);
signature.initVerify(key);
signature.update(plainText.getBytes());
return signature.verify(Base64.decode(cipherText, Base64.DEFAULT));
}
}

View File

@ -0,0 +1,238 @@
package expo.modules.updates.loader;
import android.content.Context;
import android.util.Log;
import androidx.annotation.Nullable;
import expo.modules.updates.UpdatesConfiguration;
import expo.modules.updates.db.enums.UpdateStatus;
import expo.modules.updates.UpdatesUtils;
import expo.modules.updates.db.UpdatesDatabase;
import expo.modules.updates.db.entity.AssetEntity;
import expo.modules.updates.db.entity.UpdateEntity;
import expo.modules.updates.manifest.Manifest;
import expo.modules.updates.manifest.ManifestFactory;
import org.apache.commons.io.IOUtils;
import org.json.JSONObject;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.Date;
public class EmbeddedLoader {
private static final String TAG = EmbeddedLoader.class.getSimpleName();
public static final String MANIFEST_FILENAME = "app.manifest";
public static final String BUNDLE_FILENAME = "app.bundle";
public static final String BARE_BUNDLE_FILENAME = "index.android.bundle";
private static Manifest sEmbeddedManifest = null;
private Context mContext;
private UpdatesConfiguration mConfiguration;
private UpdatesDatabase mDatabase;
private File mUpdatesDirectory;
private float mPixelDensity;
private UpdateEntity mUpdateEntity;
private ArrayList<AssetEntity> mErroredAssetList = new ArrayList<>();
private ArrayList<AssetEntity> mSkippedAssetList = new ArrayList<>();
private ArrayList<AssetEntity> mExistingAssetList = new ArrayList<>();
private ArrayList<AssetEntity> mFinishedAssetList = new ArrayList<>();
public EmbeddedLoader(Context context, UpdatesConfiguration configuration, UpdatesDatabase database, File updatesDirectory) {
mContext = context;
mConfiguration = configuration;
mDatabase = database;
mUpdatesDirectory = updatesDirectory;
mPixelDensity = context.getResources().getDisplayMetrics().density;
}
public boolean loadEmbeddedUpdate() {
boolean success = false;
Manifest manifest = readEmbeddedManifest(mContext, mConfiguration);
if (manifest != null) {
success = processManifest(manifest);
reset();
}
return success;
}
public void reset() {
mUpdateEntity = null;
mErroredAssetList = new ArrayList<>();
mSkippedAssetList = new ArrayList<>();
mExistingAssetList = new ArrayList<>();
mFinishedAssetList = new ArrayList<>();
}
public static @Nullable Manifest readEmbeddedManifest(Context context, UpdatesConfiguration configuration) {
if (!configuration.hasEmbeddedUpdate()) {
return null;
}
if (sEmbeddedManifest == null) {
try (InputStream stream = context.getAssets().open(MANIFEST_FILENAME)) {
String manifestString = IOUtils.toString(stream, "UTF-8");
JSONObject manifestJson = new JSONObject(manifestString);
// automatically verify embedded manifest since it was already codesigned
manifestJson.put("isVerified", true);
sEmbeddedManifest = ManifestFactory.getEmbeddedManifest(manifestJson, configuration, context);
} catch (Exception e) {
Log.e(TAG, "Could not read embedded manifest", e);
throw new AssertionError("The embedded manifest is invalid or could not be read. Make sure you have configured expo-updates correctly in android/app/build.gradle. " + e.getMessage());
}
}
return sEmbeddedManifest;
}
public static byte[] copyAssetAndGetHash(AssetEntity asset, File destination, Context context) throws NoSuchAlgorithmException, IOException {
if (asset.embeddedAssetFilename != null) {
return copyContextAssetAndGetHash(asset, destination, context);
} else if (asset.resourcesFilename != null && asset.resourcesFolder != null) {
return copyResourceAndGetHash(asset, destination, context);
} else {
throw new AssertionError("Failed to copy asset " + asset.key + " from APK assets or resources because not enough information was provided.");
}
}
public static byte[] copyContextAssetAndGetHash(AssetEntity asset, File destination, Context context) throws NoSuchAlgorithmException, IOException {
try (
InputStream inputStream = context.getAssets().open(asset.embeddedAssetFilename)
) {
return UpdatesUtils.sha256AndWriteToFile(inputStream, destination);
} catch (Exception e) {
Log.e(TAG, "Failed to copy asset " + asset.embeddedAssetFilename, e);
throw e;
}
}
public static byte[] copyResourceAndGetHash(AssetEntity asset, File destination, Context context) throws NoSuchAlgorithmException, IOException {
int id = context.getResources().getIdentifier(asset.resourcesFilename, asset.resourcesFolder, context.getPackageName());
try (
InputStream inputStream = context.getResources().openRawResource(id)
) {
return UpdatesUtils.sha256AndWriteToFile(inputStream, destination);
} catch (Exception e) {
Log.e(TAG, "Failed to copy asset " + asset.embeddedAssetFilename, e);
throw e;
}
}
// private helper methods
private boolean processManifest(Manifest manifest) {
UpdateEntity newUpdateEntity = manifest.getUpdateEntity();
UpdateEntity existingUpdateEntity = mDatabase.updateDao().loadUpdateWithId(newUpdateEntity.id);
if (existingUpdateEntity != null && existingUpdateEntity.status == UpdateStatus.READY) {
// hooray, we already have this update downloaded and ready to go!
mUpdateEntity = existingUpdateEntity;
return true;
} else {
if (existingUpdateEntity == null) {
// no update already exists with this ID, so we need to insert it and download everything.
mUpdateEntity = newUpdateEntity;
mDatabase.updateDao().insertUpdate(mUpdateEntity);
} else {
// we've already partially downloaded the update, so we should use the existing entity.
// however, it's not ready, so we should try to download all the assets again.
mUpdateEntity = existingUpdateEntity;
}
copyAllAssets(manifest.getAssetEntityList());
return true;
}
}
private void copyAllAssets(ArrayList<AssetEntity> assetList) {
for (AssetEntity asset : assetList) {
if (shouldSkipAsset(asset)) {
mSkippedAssetList.add(asset);
continue;
}
AssetEntity matchingDbEntry = mDatabase.assetDao().loadAssetWithKey(asset.key);
if (matchingDbEntry != null) {
mDatabase.assetDao().mergeAndUpdateAsset(matchingDbEntry, asset);
asset = matchingDbEntry;
}
// if we already have a local copy of this asset, don't try to download it again!
if (asset.relativePath != null && new File(mUpdatesDirectory, asset.relativePath).exists()) {
mExistingAssetList.add(asset);
continue;
}
String filename = UpdatesUtils.createFilenameForAsset(asset);
File destination = new File(mUpdatesDirectory, filename);
if (destination.exists()) {
asset.relativePath = filename;
mExistingAssetList.add(asset);
} else {
try {
asset.hash = copyAssetAndGetHash(asset, destination, mContext);
asset.downloadTime = new Date();
asset.relativePath = filename;
mFinishedAssetList.add(asset);
} catch (FileNotFoundException e) {
throw new AssertionError("APK bundle must contain the expected embedded asset " +
(asset.embeddedAssetFilename != null ? asset.embeddedAssetFilename : asset.resourcesFilename));
} catch (Exception e) {
mErroredAssetList.add(asset);
}
}
}
for (AssetEntity asset : mExistingAssetList) {
boolean existingAssetFound = mDatabase.assetDao().addExistingAssetToUpdate(mUpdateEntity, asset, asset.isLaunchAsset);
if (!existingAssetFound) {
// the database and filesystem have gotten out of sync
// do our best to create a new entry for this file even though it already existed on disk
byte[] hash = null;
try {
hash = UpdatesUtils.sha256(new File(mUpdatesDirectory, asset.relativePath));
} catch (Exception e) {
}
asset.downloadTime = new Date();
asset.hash = hash;
mFinishedAssetList.add(asset);
}
}
mDatabase.assetDao().insertAssets(mFinishedAssetList, mUpdateEntity);
if (mErroredAssetList.size() == 0) {
mDatabase.updateDao().markUpdateFinished(mUpdateEntity, mSkippedAssetList.size() != 0);
}
// TODO: maybe try downloading failed assets in background
}
private boolean shouldSkipAsset(AssetEntity asset) {
if (asset.scales == null || asset.scale == null) {
return false;
}
return pickClosestScale(asset.scales) != asset.scale;
}
// https://developer.android.com/guide/topics/resources/providing-resources.html#BestMatch
// If a perfect match is not available, the OS will pick the next largest scale.
// If only smaller scales are available, the OS will choose the largest available one.
private float pickClosestScale(Float[] scales) {
float closestScale = Float.MAX_VALUE;
float largestScale = 0;
for (float scale : scales) {
if (scale >= mPixelDensity && (scale < closestScale)) {
closestScale = scale;
}
if (scale > largestScale) {
largestScale = scale;
}
}
return closestScale < Float.MAX_VALUE ? closestScale : largestScale;
}
}

View File

@ -0,0 +1,285 @@
package expo.modules.updates.loader;
import android.content.Context;
import android.net.Uri;
import android.util.Log;
import androidx.annotation.Nullable;
import expo.modules.updates.UpdatesConfiguration;
import expo.modules.updates.UpdatesUtils;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.util.Arrays;
import java.util.Date;
import java.util.Map;
import expo.modules.updates.db.entity.AssetEntity;
import expo.modules.updates.launcher.NoDatabaseLauncher;
import expo.modules.updates.manifest.Manifest;
import expo.modules.updates.manifest.ManifestFactory;
import okhttp3.CacheControl;
import okhttp3.Call;
import okhttp3.Callback;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
public class FileDownloader {
private static final String TAG = FileDownloader.class.getSimpleName();
private static OkHttpClient sClient = new OkHttpClient.Builder().build();
public interface FileDownloadCallback {
void onFailure(Exception e);
void onSuccess(File file, @Nullable byte[] hash);
}
public interface ManifestDownloadCallback {
void onFailure(String message, Exception e);
void onSuccess(Manifest manifest);
}
public interface AssetDownloadCallback {
void onFailure(Exception e, AssetEntity assetEntity);
void onSuccess(AssetEntity assetEntity, boolean isNew);
}
public static void downloadFileToPath(Request request, final File destination, final FileDownloadCallback callback) {
downloadData(request, new Callback() {
@Override
public void onFailure(Call call, IOException e) {
callback.onFailure(e);
}
@Override
public void onResponse(Call call, Response response) throws IOException {
if (!response.isSuccessful()) {
callback.onFailure(new Exception("Network request failed: " + response.body().string()));
return;
}
try (
InputStream inputStream = response.body().byteStream();
) {
byte[] hash = UpdatesUtils.sha256AndWriteToFile(inputStream, destination);
callback.onSuccess(destination, hash);
} catch (Exception e) {
Log.e(TAG, "Failed to download file to destination " + destination.toString(), e);
callback.onFailure(e);
}
}
});
}
public static void downloadManifest(final UpdatesConfiguration configuration, final Context context, final ManifestDownloadCallback callback) {
try {
downloadData(setHeadersForManifestUrl(configuration, context), new Callback() {
@Override
public void onFailure(Call call, IOException e) {
callback.onFailure("Failed to download manifest from URL: " + configuration.getUpdateUrl(), e);
}
@Override
public void onResponse(Call call, Response response) throws IOException {
if (!response.isSuccessful()) {
callback.onFailure("Failed to download manifest from URL: " + configuration.getUpdateUrl(), new Exception(response.body().string()));
return;
}
try {
String manifestString = response.body().string();
JSONObject manifestJson = extractManifest(manifestString, configuration);
boolean isSigned = manifestJson.has("manifestString") && manifestJson.has("signature");
// XDL serves unsigned manifests with the `signature` key set to "UNSIGNED".
// We should treat these manifests as unsigned rather than signed with an invalid signature.
if (isSigned && "UNSIGNED".equals(manifestJson.getString("signature"))) {
isSigned = false;
manifestJson = new JSONObject(manifestJson.getString("manifestString"));
manifestJson.put("isVerified", false);
}
if (isSigned) {
final String innerManifestString = manifestJson.getString("manifestString");
Crypto.verifyPublicRSASignature(
innerManifestString,
manifestJson.getString("signature"),
new Crypto.RSASignatureListener() {
@Override
public void onError(Exception e, boolean isNetworkError) {
callback.onFailure("Could not validate signed manifest", e);
}
@Override
public void onCompleted(boolean isValid) {
if (isValid) {
try {
JSONObject manifestJson = new JSONObject(innerManifestString);
manifestJson.put("isVerified", true);
Manifest manifest = ManifestFactory.getManifest(manifestJson, configuration, context);
callback.onSuccess(manifest);
} catch (JSONException e) {
callback.onFailure("Failed to parse manifest data", e);
}
} else {
callback.onFailure("Manifest signature is invalid; aborting", new Exception("Manifest signature is invalid"));
}
}
}
);
} else {
Manifest manifest = ManifestFactory.getManifest(manifestJson, configuration, context);
callback.onSuccess(manifest);
}
} catch (Exception e) {
callback.onFailure("Failed to parse manifest data", e);
}
}
});
} catch (Exception e) {
callback.onFailure("Failed to download manifest from URL " + configuration.getUpdateUrl().toString(), e);
}
}
public static void downloadAsset(final AssetEntity asset, File destinationDirectory, UpdatesConfiguration configuration, final AssetDownloadCallback callback) {
if (asset.url == null) {
callback.onFailure(new Exception("Could not download asset " + asset.key + " with no URL"), asset);
return;
}
final String filename = UpdatesUtils.createFilenameForAsset(asset);
File path = new File(destinationDirectory, filename);
if (path.exists()) {
asset.relativePath = filename;
callback.onSuccess(asset, false);
} else {
try {
downloadFileToPath(setHeadersForUrl(asset.url, configuration), path, new FileDownloadCallback() {
@Override
public void onFailure(Exception e) {
callback.onFailure(e, asset);
}
@Override
public void onSuccess(File file, @Nullable byte[] hash) {
asset.downloadTime = new Date();
asset.relativePath = filename;
asset.hash = hash;
callback.onSuccess(asset, true);
}
});
} catch (Exception e) {
callback.onFailure(e, asset);
}
}
}
public static void downloadData(Request request, Callback callback) {
downloadData(request, callback, false);
}
private static void downloadData(final Request request, final Callback callback, final boolean isRetry) {
sClient.newCall(request).enqueue(new Callback() {
@Override
public void onFailure(Call call, IOException e) {
if (isRetry) {
callback.onFailure(call, e);
} else {
downloadData(request, callback, true);
}
}
@Override
public void onResponse(Call call, Response response) throws IOException {
callback.onResponse(call, response);
}
});
}
private static JSONObject extractManifest(String manifestString, UpdatesConfiguration configuration) throws IOException {
try {
return new JSONObject(manifestString);
} catch (JSONException e) {
// Ignore this error, try to parse manifest as array
}
// TODO: either add support for runtimeVersion or deprecate multi-manifests
try {
// the manifestString could be an array of manifest objects
// in this case, we choose the first compatible manifest in the array
JSONArray manifestArray = new JSONArray(manifestString);
for (int i = 0; i < manifestArray.length(); i++) {
JSONObject manifestCandidate = manifestArray.getJSONObject(i);
String sdkVersion = manifestCandidate.getString("sdkVersion");
if (configuration.getSdkVersion() != null && Arrays.asList(configuration.getSdkVersion().split(",")).contains(sdkVersion)){
return manifestCandidate;
}
}
} catch (JSONException e) {
throw new IOException("Manifest string is not a valid JSONObject or JSONArray: " + manifestString, e);
}
throw new IOException("No compatible manifest found. SDK Versions supported: " + configuration.getSdkVersion() + " Provided manifestString: " + manifestString);
}
private static Request setHeadersForUrl(Uri url, UpdatesConfiguration configuration) {
Request.Builder requestBuilder = new Request.Builder()
.url(url.toString())
.header("Expo-Platform", "android")
.header("Expo-Api-Version", "1")
.header("Expo-Updates-Environment", "BARE");
for (Map.Entry<String, String> entry : configuration.getRequestHeaders().entrySet()) {
requestBuilder.header(entry.getKey(), entry.getValue());
}
return requestBuilder.build();
}
private static Request setHeadersForManifestUrl(UpdatesConfiguration configuration, Context context) {
Request.Builder requestBuilder = new Request.Builder()
.url(configuration.getUpdateUrl().toString())
.header("Accept", "application/expo+json,application/json")
.header("Expo-Platform", "android")
.header("Expo-Api-Version", "1")
.header("Expo-Updates-Environment", "BARE")
.header("Expo-JSON-Error", "true")
.header("Expo-Accept-Signature", "true")
.cacheControl(CacheControl.FORCE_NETWORK);
String runtimeVersion = configuration.getRuntimeVersion();
String sdkVersion = configuration.getSdkVersion();
if (runtimeVersion != null && runtimeVersion.length() > 0) {
requestBuilder = requestBuilder.header("Expo-Runtime-Version", runtimeVersion);
} else {
requestBuilder = requestBuilder.header("Expo-SDK-Version", sdkVersion);
}
String releaseChannel = configuration.getReleaseChannel();
requestBuilder = requestBuilder.header("Expo-Release-Channel", releaseChannel);
String previousFatalError = NoDatabaseLauncher.consumeErrorLog(context);
if (previousFatalError != null) {
// some servers can have max length restrictions for headers,
// so we restrict the length of the string to 1024 characters --
// this should satisfy the requirements of most servers
requestBuilder = requestBuilder.header(
"Expo-Fatal-Error",
previousFatalError.substring(0, Math.min(1024, previousFatalError.length()))
);
}
for (Map.Entry<String, String> entry : configuration.getRequestHeaders().entrySet()) {
requestBuilder.header(entry.getKey(), entry.getValue());
}
return requestBuilder.build();
}
}

View File

@ -0,0 +1,327 @@
package expo.modules.updates.loader;
import android.content.Context;
import android.os.AsyncTask;
import android.os.Handler;
import android.os.HandlerThread;
import android.util.Log;
import java.io.File;
import androidx.annotation.Nullable;
import expo.modules.updates.UpdatesConfiguration;
import expo.modules.updates.UpdatesUtils;
import expo.modules.updates.db.DatabaseHolder;
import expo.modules.updates.db.Reaper;
import expo.modules.updates.db.UpdatesDatabase;
import expo.modules.updates.db.entity.UpdateEntity;
import expo.modules.updates.launcher.DatabaseLauncher;
import expo.modules.updates.launcher.Launcher;
import expo.modules.updates.launcher.SelectionPolicy;
import expo.modules.updates.manifest.Manifest;
public class LoaderTask {
private static final String TAG = LoaderTask.class.getSimpleName();
public enum BackgroundUpdateStatus {
ERROR, NO_UPDATE_AVAILABLE, UPDATE_AVAILABLE
}
public interface LoaderTaskCallback {
void onFailure(Exception e);
/**
* This method gives the calling class a backdoor option to ignore the cached update and force
* a remote load if it decides the cached update is not runnable. Returning false from this
* callback will force a remote load, overriding the timeout and configuration settings for
* whether or not to check for a remote update. Returning true from this callback will make
* LoaderTask proceed as usual.
*/
boolean onCachedUpdateLoaded(UpdateEntity update);
void onRemoteManifestLoaded(Manifest manifest);
void onSuccess(Launcher launcher, boolean isUpToDate);
void onBackgroundUpdateFinished(BackgroundUpdateStatus status, @Nullable UpdateEntity update, @Nullable Exception exception);
}
private interface Callback {
void onFailure(Exception e);
void onSuccess();
}
private UpdatesConfiguration mConfiguration;
private DatabaseHolder mDatabaseHolder;
private File mDirectory;
private SelectionPolicy mSelectionPolicy;
private LoaderTaskCallback mCallback;
// success conditions
private boolean mIsReadyToLaunch = false;
private boolean mTimeoutFinished = false;
private boolean mHasLaunched = false;
private boolean mIsUpToDate = false;
private HandlerThread mHandlerThread;
private Launcher mCandidateLauncher;
private Launcher mFinalizedLauncher;
public LoaderTask(UpdatesConfiguration configuration,
DatabaseHolder databaseHolder,
File directory,
SelectionPolicy selectionPolicy,
LoaderTaskCallback callback) {
mConfiguration = configuration;
mDatabaseHolder = databaseHolder;
mDirectory = directory;
mSelectionPolicy = selectionPolicy;
mCallback = callback;
mHandlerThread = new HandlerThread("expo-updates-timer");
}
public void start(Context context) {
if (!mConfiguration.isEnabled()) {
mCallback.onFailure(new Exception("LoaderTask was passed a configuration object with updates disabled. You should load updates from an embedded source rather than calling LoaderTask, or enable updates in the configuration."));
return;
}
if (mConfiguration.getUpdateUrl() == null) {
mCallback.onFailure(new Exception("LoaderTask was passed a configuration object with a null URL. You must pass a nonnull URL in order to use LoaderTask to load updates."));
return;
}
if (mDirectory == null) {
throw new AssertionError("LoaderTask directory must be nonnull.");
}
boolean shouldCheckForUpdate = UpdatesUtils.shouldCheckForUpdateOnLaunch(mConfiguration, context);
int delay = mConfiguration.getLaunchWaitMs();
if (delay > 0 && shouldCheckForUpdate) {
mHandlerThread.start();
new Handler(mHandlerThread.getLooper()).postDelayed(this::timeout, delay);
} else {
mTimeoutFinished = true;
}
launchFallbackUpdateFromDisk(context, new Callback() {
private void launchRemoteUpdate() {
launchRemoteUpdateInBackground(context, new Callback() {
@Override
public void onFailure(Exception e) {
finish(e);
runReaper();
}
@Override
public void onSuccess() {
synchronized (LoaderTask.this) {
mIsReadyToLaunch = true;
}
finish(null);
runReaper();
}
});
}
@Override
public void onFailure(Exception e) {
// An unexpected failure has occurred here, or we are running in an environment with no
// embedded update and we have no update downloaded (e.g. Expo client).
// What to do in this case depends on whether or not we're trying to load a remote update.
// If we are, then we should wait for the task to finish. If not, we need to fail here.
if (!shouldCheckForUpdate) {
finish(e);
} else {
launchRemoteUpdate();
}
Log.e(TAG, "Failed to launch embedded or launchable update", e);
}
@Override
public void onSuccess() {
if (mCandidateLauncher.getLaunchedUpdate() != null &&
!mCallback.onCachedUpdateLoaded(mCandidateLauncher.getLaunchedUpdate())) {
// ignore timer and other settings and force launch a remote update
stopTimer();
mCandidateLauncher = null;
launchRemoteUpdate();
} else {
synchronized (LoaderTask.this) {
mIsReadyToLaunch = true;
maybeFinish();
}
if (shouldCheckForUpdate) {
launchRemoteUpdate();
} else {
runReaper();
}
}
}
});
}
/**
* This method should be called at the end of the LoaderTask. Whether or not the task has
* successfully loaded an update to launch, the timer will stop and the appropriate callback
* function will be fired.
*/
private synchronized void finish(@Nullable Exception e) {
if (mHasLaunched) {
// we've already fired once, don't do it again
return;
}
mHasLaunched = true;
mFinalizedLauncher = mCandidateLauncher;
if (!mIsReadyToLaunch || mFinalizedLauncher == null || mFinalizedLauncher.getLaunchedUpdate() == null) {
mCallback.onFailure(e != null ? e : new Exception("LoaderTask encountered an unexpected error and could not launch an update."));
} else {
mCallback.onSuccess(mFinalizedLauncher, mIsUpToDate);
}
if (!mTimeoutFinished) {
stopTimer();
}
if (e != null) {
Log.e(TAG, "Unexpected error encountered while loading this app", e);
}
}
/**
* This method should be called to conditionally fire the callback. If the task has successfully
* loaded an update to launch and the timer isn't still running, the appropriate callback function
* will be fired. If not, no callback will be fired.
*/
private synchronized void maybeFinish() {
if (!mIsReadyToLaunch || !mTimeoutFinished) {
// too early, bail out
return;
}
finish(null);
}
private synchronized void stopTimer() {
mTimeoutFinished = true;
mHandlerThread.quitSafely();
}
private synchronized void timeout() {
if (!mTimeoutFinished) {
mTimeoutFinished = true;
maybeFinish();
}
stopTimer();
}
private void launchFallbackUpdateFromDisk(Context context, Callback diskUpdateCallback) {
UpdatesDatabase database = mDatabaseHolder.getDatabase();
DatabaseLauncher launcher = new DatabaseLauncher(mConfiguration, mDirectory, mSelectionPolicy);
mCandidateLauncher = launcher;
if (mConfiguration.hasEmbeddedUpdate()) {
// if the embedded update should be launched (e.g. if it's newer than any other update we have
// in the database, which can happen if the app binary is updated), load it into the database
// so we can launch it
UpdateEntity embeddedUpdate = EmbeddedLoader.readEmbeddedManifest(context, mConfiguration).getUpdateEntity();
UpdateEntity launchableUpdate = launcher.getLaunchableUpdate(database, context);
if (mSelectionPolicy.shouldLoadNewUpdate(embeddedUpdate, launchableUpdate)) {
new EmbeddedLoader(context, mConfiguration, database, mDirectory).loadEmbeddedUpdate();
}
}
launcher.launch(database, context, new Launcher.LauncherCallback() {
@Override
public void onFailure(Exception e) {
mDatabaseHolder.releaseDatabase();
diskUpdateCallback.onFailure(e);
}
@Override
public void onSuccess() {
mDatabaseHolder.releaseDatabase();
diskUpdateCallback.onSuccess();
}
});
}
private void launchRemoteUpdateInBackground(Context context, Callback remoteUpdateCallback) {
AsyncTask.execute(() -> {
UpdatesDatabase database = mDatabaseHolder.getDatabase();
new RemoteLoader(context, mConfiguration, database, mDirectory)
.start(mConfiguration.getUpdateUrl(), new RemoteLoader.LoaderCallback() {
@Override
public void onFailure(Exception e) {
mDatabaseHolder.releaseDatabase();
remoteUpdateCallback.onFailure(e);
mCallback.onBackgroundUpdateFinished(BackgroundUpdateStatus.ERROR, null, e);
Log.e(TAG, "Failed to download remote update", e);
}
@Override
public boolean onManifestLoaded(Manifest manifest) {
if (mSelectionPolicy.shouldLoadNewUpdate(
manifest.getUpdateEntity(),
mCandidateLauncher == null ? null : mCandidateLauncher.getLaunchedUpdate())) {
mIsUpToDate = false;
mCallback.onRemoteManifestLoaded(manifest);
return true;
} else {
mIsUpToDate = true;
return false;
}
}
@Override
public void onSuccess(@Nullable UpdateEntity update) {
// a new update has loaded successfully; we need to launch it with a new Launcher and
// replace the old Launcher so that the callback fires with the new one
final DatabaseLauncher newLauncher = new DatabaseLauncher(mConfiguration, mDirectory, mSelectionPolicy);
newLauncher.launch(database, context, new Launcher.LauncherCallback() {
@Override
public void onFailure(Exception e) {
mDatabaseHolder.releaseDatabase();
remoteUpdateCallback.onFailure(e);
Log.e(TAG, "Loaded new update but it failed to launch", e);
}
@Override
public void onSuccess() {
mDatabaseHolder.releaseDatabase();
boolean hasLaunched;
synchronized (LoaderTask.this) {
hasLaunched = mHasLaunched;
if (!hasLaunched) {
mCandidateLauncher = newLauncher;
mIsUpToDate = true;
}
}
remoteUpdateCallback.onSuccess();
if (hasLaunched) {
if (update == null) {
mCallback.onBackgroundUpdateFinished(BackgroundUpdateStatus.NO_UPDATE_AVAILABLE, null, null);
} else {
mCallback.onBackgroundUpdateFinished(BackgroundUpdateStatus.UPDATE_AVAILABLE, update, null);
}
}
}
});
}
});
});
}
private void runReaper() {
AsyncTask.execute(() -> {
synchronized (LoaderTask.this) {
if (mFinalizedLauncher != null && mFinalizedLauncher.getLaunchedUpdate() != null) {
UpdatesDatabase database = mDatabaseHolder.getDatabase();
Reaper.reapUnusedUpdates(mConfiguration, database, mDirectory, mFinalizedLauncher.getLaunchedUpdate(), mSelectionPolicy);
mDatabaseHolder.releaseDatabase();
}
}
});
}
}

View File

@ -0,0 +1,240 @@
package expo.modules.updates.loader;
import android.content.Context;
import android.net.Uri;
import android.util.Log;
import androidx.annotation.Nullable;
import expo.modules.updates.UpdatesConfiguration;
import expo.modules.updates.UpdatesController;
import expo.modules.updates.db.enums.UpdateStatus;
import expo.modules.updates.UpdatesUtils;
import expo.modules.updates.db.UpdatesDatabase;
import expo.modules.updates.db.entity.AssetEntity;
import expo.modules.updates.db.entity.UpdateEntity;
import expo.modules.updates.manifest.Manifest;
import java.io.File;
import java.util.ArrayList;
import java.util.Date;
public class RemoteLoader {
private static String TAG = RemoteLoader.class.getSimpleName();
private Context mContext;
private UpdatesConfiguration mConfiguration;
private UpdatesDatabase mDatabase;
private File mUpdatesDirectory;
private UpdateEntity mUpdateEntity;
private LoaderCallback mCallback;
private int mAssetTotal = 0;
private ArrayList<AssetEntity> mErroredAssetList = new ArrayList<>();
private ArrayList<AssetEntity> mExistingAssetList = new ArrayList<>();
private ArrayList<AssetEntity> mFinishedAssetList = new ArrayList<>();
public interface LoaderCallback {
void onFailure(Exception e);
void onSuccess(@Nullable UpdateEntity update);
/**
* Called when a manifest has been downloaded. The calling class should determine whether or not
* the RemoteLoader should continue to download the update described by this manifest, based on
* (for example) whether or not it already has the update downloaded locally.
*
* @param manifest Manifest downloaded by RemoteLoader
* @return true if RemoteLoader should download the update described in the manifest,
* false if not.
*/
boolean onManifestLoaded(Manifest manifest);
}
public RemoteLoader(Context context, UpdatesConfiguration configuration, UpdatesDatabase database, File updatesDirectory) {
mContext = context;
mConfiguration = configuration;
mDatabase = database;
mUpdatesDirectory = updatesDirectory;
}
// lifecycle methods for class
public void start(Uri url, LoaderCallback callback) {
if (mCallback != null) {
callback.onFailure(new Exception("RemoteLoader has already started. Create a new instance in order to load multiple URLs in parallel."));
return;
}
mCallback = callback;
FileDownloader.downloadManifest(mConfiguration, mContext, new FileDownloader.ManifestDownloadCallback() {
@Override
public void onFailure(String message, Exception e) {
finishWithError(message, e);
}
@Override
public void onSuccess(Manifest manifest) {
if (mCallback.onManifestLoaded(manifest)) {
processManifest(manifest);
} else {
mCallback.onSuccess(null);
}
}
});
}
private void reset() {
mUpdateEntity = null;
mCallback = null;
mAssetTotal = 0;
mErroredAssetList = new ArrayList<>();
mExistingAssetList = new ArrayList<>();
mFinishedAssetList = new ArrayList<>();
}
private void finishWithSuccess() {
if (mCallback == null) {
Log.e(TAG, "RemoteLoader tried to finish but it already finished or was never initialized.");
return;
}
mCallback.onSuccess(mUpdateEntity);
reset();
}
private void finishWithError(String message, Exception e) {
Log.e(TAG, message, e);
if (mCallback == null) {
Log.e(TAG, "RemoteLoader tried to finish but it already finished or was never initialized.");
return;
}
mCallback.onFailure(e);
reset();
}
// private helper methods
private void processManifest(Manifest manifest) {
if (manifest.isDevelopmentMode()) {
// insert into database but don't try to load any assets;
// the RN runtime will take care of that and we don't want to cache anything
UpdateEntity updateEntity = manifest.getUpdateEntity();
mDatabase.updateDao().insertUpdate(updateEntity);
mDatabase.updateDao().markUpdateFinished(updateEntity);
finishWithSuccess();
return;
}
UpdateEntity newUpdateEntity = manifest.getUpdateEntity();
UpdateEntity existingUpdateEntity = mDatabase.updateDao().loadUpdateWithId(newUpdateEntity.id);
// if something has gone wrong on the server and we have two updates with the same id
// but different scope keys, we should try to launch something rather than show a cryptic
// error to the user.
if (existingUpdateEntity != null && !existingUpdateEntity.scopeKey.equals(newUpdateEntity.scopeKey)) {
mDatabase.updateDao().setUpdateScopeKey(existingUpdateEntity, newUpdateEntity.scopeKey);
Log.e(TAG, "Loaded an update with the same ID but a different scopeKey than one we already have on disk. This is a server error. Overwriting the scopeKey and loading the existing update.");
}
if (existingUpdateEntity != null && existingUpdateEntity.status == UpdateStatus.READY) {
// hooray, we already have this update downloaded and ready to go!
mUpdateEntity = existingUpdateEntity;
finishWithSuccess();
} else {
if (existingUpdateEntity == null) {
// no update already exists with this ID, so we need to insert it and download everything.
mUpdateEntity = newUpdateEntity;
mDatabase.updateDao().insertUpdate(mUpdateEntity);
} else {
// we've already partially downloaded the update, so we should use the existing entity.
// however, it's not ready, so we should try to download all the assets again.
mUpdateEntity = existingUpdateEntity;
}
downloadAllAssets(manifest.getAssetEntityList());
}
}
private void downloadAllAssets(ArrayList<AssetEntity> assetList) {
mAssetTotal = assetList.size();
for (AssetEntity assetEntity : assetList) {
AssetEntity matchingDbEntry = mDatabase.assetDao().loadAssetWithKey(assetEntity.key);
if (matchingDbEntry != null) {
mDatabase.assetDao().mergeAndUpdateAsset(matchingDbEntry, assetEntity);
assetEntity = matchingDbEntry;
}
// if we already have a local copy of this asset, don't try to download it again!
if (assetEntity.relativePath != null && new File(mUpdatesDirectory, assetEntity.relativePath).exists()) {
handleAssetDownloadCompleted(assetEntity, true, false);
continue;
}
if (assetEntity.url == null) {
Log.e(TAG, "Failed to download asset with no URL provided");
handleAssetDownloadCompleted(assetEntity, false, false);
continue;
}
FileDownloader.downloadAsset(assetEntity, mUpdatesDirectory, mConfiguration, new FileDownloader.AssetDownloadCallback() {
@Override
public void onFailure(Exception e, AssetEntity assetEntity) {
Log.e(TAG, "Failed to download asset from " + assetEntity.url, e);
handleAssetDownloadCompleted(assetEntity, false, false);
}
@Override
public void onSuccess(AssetEntity assetEntity, boolean isNew) {
handleAssetDownloadCompleted(assetEntity, true, isNew);
}
});
}
}
private synchronized void handleAssetDownloadCompleted(AssetEntity assetEntity, boolean success, boolean isNew) {
if (success) {
if (isNew) {
mFinishedAssetList.add(assetEntity);
} else {
mExistingAssetList.add(assetEntity);
}
} else {
mErroredAssetList.add(assetEntity);
}
if (mFinishedAssetList.size() + mErroredAssetList.size() + mExistingAssetList.size() == mAssetTotal) {
try {
for (AssetEntity asset : mExistingAssetList) {
boolean existingAssetFound = mDatabase.assetDao().addExistingAssetToUpdate(mUpdateEntity, asset, asset.isLaunchAsset);
if (!existingAssetFound) {
// the database and filesystem have gotten out of sync
// do our best to create a new entry for this file even though it already existed on disk
byte[] hash = null;
try {
hash = UpdatesUtils.sha256(new File(mUpdatesDirectory, asset.relativePath));
} catch (Exception e) {
}
asset.downloadTime = new Date();
asset.hash = hash;
mFinishedAssetList.add(asset);
}
}
mDatabase.assetDao().insertAssets(mFinishedAssetList, mUpdateEntity);
if (mErroredAssetList.size() == 0) {
mDatabase.updateDao().markUpdateFinished(mUpdateEntity);
}
} catch (Exception e) {
finishWithError("Error while adding new update to database", e);
return;
}
if (mErroredAssetList.size() > 0) {
finishWithError("Failed to load all assets", new Exception("Failed to load all assets"));
} else {
finishWithSuccess();
}
}
}
}

View File

@ -0,0 +1,119 @@
package expo.modules.updates.manifest;
import android.util.Log;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import java.util.ArrayList;
import java.util.Date;
import java.util.UUID;
import expo.modules.updates.UpdatesConfiguration;
import expo.modules.updates.UpdatesUtils;
import expo.modules.updates.db.entity.AssetEntity;
import expo.modules.updates.db.entity.UpdateEntity;
import expo.modules.updates.db.enums.UpdateStatus;
import expo.modules.updates.loader.EmbeddedLoader;
public class BareManifest implements Manifest {
private static String TAG = BareManifest.class.getSimpleName();
private UUID mId;
private String mScopeKey;
private Date mCommitTime;
private String mRuntimeVersion;
private JSONObject mMetadata;
private JSONArray mAssets;
private JSONObject mManifestJson;
private BareManifest(JSONObject manifestJson,
UUID id,
String scopeKey,
Date commitTime,
String runtimeVersion,
JSONObject metadata,
JSONArray assets) {
mManifestJson = manifestJson;
mScopeKey = scopeKey;
mId = id;
mCommitTime = commitTime;
mRuntimeVersion = runtimeVersion;
mMetadata = metadata;
mAssets = assets;
}
public static BareManifest fromManifestJson(JSONObject manifestJson, UpdatesConfiguration configuration) throws JSONException {
UUID id = UUID.fromString(manifestJson.getString("id"));
Date commitTime = new Date(manifestJson.getLong("commitTime"));
String runtimeVersion = UpdatesUtils.getRuntimeVersion(configuration);
JSONObject metadata = manifestJson.optJSONObject("metadata");
JSONArray assets = manifestJson.optJSONArray("assets");
if (runtimeVersion.contains(",")) {
throw new AssertionError("Should not be initializing a BareManifest in an environment with multiple runtime versions.");
}
return new BareManifest(manifestJson, id, configuration.getScopeKey(), commitTime, runtimeVersion, metadata, assets);
}
public JSONObject getRawManifestJson() {
return mManifestJson;
}
public UpdateEntity getUpdateEntity() {
UpdateEntity updateEntity = new UpdateEntity(mId, mCommitTime, mRuntimeVersion, mScopeKey);
if (mMetadata != null) {
updateEntity.metadata = mMetadata;
}
updateEntity.status = UpdateStatus.EMBEDDED;
return updateEntity;
}
public ArrayList<AssetEntity> getAssetEntityList() {
ArrayList<AssetEntity> assetList = new ArrayList<>();
AssetEntity bundleAssetEntity = new AssetEntity("bundle-" + mCommitTime.getTime(), "js");
bundleAssetEntity.isLaunchAsset = true;
bundleAssetEntity.embeddedAssetFilename = EmbeddedLoader.BARE_BUNDLE_FILENAME;
assetList.add(bundleAssetEntity);
if (mAssets != null && mAssets.length() > 0) {
for (int i = 0; i < mAssets.length(); i++) {
try {
JSONObject assetObject = mAssets.getJSONObject(i);
String type = assetObject.getString("type");
AssetEntity assetEntity = new AssetEntity(
assetObject.getString("packagerHash") + "." + type,
type
);
assetEntity.resourcesFilename = assetObject.optString("resourcesFilename");
assetEntity.resourcesFolder = assetObject.optString("resourcesFolder");
JSONArray scales = assetObject.optJSONArray("scales");
// if there's only one scale we don't to decide later on which one to copy
// so we avoid this work now
if (scales != null && scales.length() > 1) {
assetEntity.scale = (float)assetObject.optDouble("scale");
assetEntity.scales = new Float[scales.length()];
for (int j = 0; j < scales.length(); j++) {
assetEntity.scales[j] = (float)scales.getDouble(j);
}
}
assetList.add(assetEntity);
} catch (JSONException e) {
Log.e(TAG, "Could not read asset from manifest", e);
}
}
}
return assetList;
}
public boolean isDevelopmentMode() {
return false;
}
}

View File

@ -0,0 +1,231 @@
package expo.modules.updates.manifest;
import android.net.Uri;
import android.util.Log;
import expo.modules.updates.UpdatesConfiguration;
import expo.modules.updates.UpdatesUtils;
import expo.modules.updates.db.entity.AssetEntity;
import expo.modules.updates.db.entity.UpdateEntity;
import expo.modules.updates.db.enums.UpdateStatus;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import java.net.URI;
import java.text.DateFormat;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Locale;
import java.util.TimeZone;
import java.util.UUID;
import static expo.modules.updates.loader.EmbeddedLoader.BUNDLE_FILENAME;
public class LegacyManifest implements Manifest {
private static String TAG = Manifest.class.getSimpleName();
private static String EXPO_ASSETS_URL_BASE = "https://d1wp6m56sqw74a.cloudfront.net/~assets/";
private static String[] EXPO_DOMAINS = new String[] {"expo.io", "exp.host", "expo.test"};
private Uri mAssetsUrlBase = null;
private UUID mId;
private String mScopeKey;
private Date mCommitTime;
private String mRuntimeVersion;
private JSONObject mMetadata;
private Uri mBundleUrl;
private JSONArray mAssets;
private JSONObject mManifestJson;
private Uri mManifestUrl;
private LegacyManifest(JSONObject manifestJson,
Uri manifestUrl,
UUID id,
String scopeKey,
Date commitTime,
String runtimeVersion,
JSONObject metadata,
Uri bundleUrl,
JSONArray assets) {
mManifestJson = manifestJson;
mManifestUrl = manifestUrl;
mId = id;
mScopeKey = scopeKey;
mCommitTime = commitTime;
mRuntimeVersion = runtimeVersion;
mMetadata = metadata;
mBundleUrl = bundleUrl;
mAssets = assets;
}
public static LegacyManifest fromLegacyManifestJson(JSONObject manifestJson, UpdatesConfiguration configuration) throws JSONException {
UUID id;
Date commitTime;
if (isUsingDeveloperTool(manifestJson)) {
// xdl doesn't always serve a releaseId, but we don't need one in dev mode
id = UUID.randomUUID();
commitTime = new Date();
} else {
id = UUID.fromString(manifestJson.getString("releaseId"));
String commitTimeString = manifestJson.getString("commitTime");
try {
DateFormat formatter = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US);
formatter.setTimeZone(TimeZone.getTimeZone("GMT"));
commitTime = formatter.parse(commitTimeString);
} catch (ParseException e) {
Log.e(TAG, "Could not parse commitTime", e);
commitTime = new Date();
}
}
String runtimeVersion = manifestJson.getString("sdkVersion");
Object runtimeVersionObject = manifestJson.opt("runtimeVersion");
if (runtimeVersionObject != null) {
if (runtimeVersionObject instanceof String) {
runtimeVersion = (String)runtimeVersionObject;
} else if (runtimeVersionObject instanceof JSONObject) {
runtimeVersion = ((JSONObject)runtimeVersionObject).optString("android", runtimeVersion);
}
}
Uri bundleUrl = Uri.parse(manifestJson.getString("bundleUrl"));
JSONArray bundledAssets = manifestJson.optJSONArray("bundledAssets");
return new LegacyManifest(manifestJson,configuration.getUpdateUrl(), id, configuration.getScopeKey(), commitTime, runtimeVersion, manifestJson, bundleUrl, bundledAssets);
}
public JSONObject getRawManifestJson() {
return mManifestJson;
}
public UpdateEntity getUpdateEntity() {
UpdateEntity updateEntity = new UpdateEntity(mId, mCommitTime, mRuntimeVersion, mScopeKey);
if (mMetadata != null) {
updateEntity.metadata = mMetadata;
}
if (isDevelopmentMode()) {
updateEntity.status = UpdateStatus.DEVELOPMENT;
}
return updateEntity;
}
public ArrayList<AssetEntity> getAssetEntityList() {
ArrayList<AssetEntity> assetList = new ArrayList<>();
String key;
try {
key = "bundle-" + UpdatesUtils.sha256(mBundleUrl.toString());
} catch (Exception e) {
key = "bundle-" + mCommitTime.getTime();
Log.e(TAG, "Failed to get SHA-256 checksum of bundle URL");
}
AssetEntity bundleAssetEntity = new AssetEntity(key, "js");
bundleAssetEntity.url = mBundleUrl;
bundleAssetEntity.isLaunchAsset = true;
bundleAssetEntity.embeddedAssetFilename = BUNDLE_FILENAME;
assetList.add(bundleAssetEntity);
if (mAssets != null && mAssets.length() > 0) {
for (int i = 0; i < mAssets.length(); i++) {
try {
String bundledAsset = mAssets.getString(i);
int extensionIndex = bundledAsset.lastIndexOf('.');
int prefixLength = "asset_".length();
String hash = extensionIndex > 0
? bundledAsset.substring(prefixLength, extensionIndex)
: bundledAsset.substring(prefixLength);
String type = extensionIndex > 0 ? bundledAsset.substring(extensionIndex + 1) : "";
AssetEntity assetEntity = new AssetEntity(hash + "." + type, type);
assetEntity.url = Uri.withAppendedPath(getAssetsUrlBase(), hash);
assetEntity.embeddedAssetFilename = bundledAsset;
assetList.add(assetEntity);
} catch (JSONException e) {
Log.e(TAG, "Could not read asset from manifest", e);
}
}
}
return assetList;
}
private Uri getAssetsUrlBase() {
if (mAssetsUrlBase == null) {
mAssetsUrlBase = getAssetsUrlBase(mManifestUrl, getRawManifestJson());
}
return mAssetsUrlBase;
}
/* package */ static Uri getAssetsUrlBase(Uri manifestUrl, JSONObject manifestJson) {
String hostname = manifestUrl.getHost();
if (hostname == null) {
return Uri.parse(EXPO_ASSETS_URL_BASE);
} else {
for (String expoDomain : EXPO_DOMAINS) {
if (hostname.equals(expoDomain) || hostname.endsWith("." + expoDomain)) {
return Uri.parse(EXPO_ASSETS_URL_BASE);
}
}
// assetUrlOverride may be an absolute or relative URL
// if relative, we should resolve with respect to the manifest URL
String assetsPathOrUrl = manifestJson.optString("assetUrlOverride", "assets");
Uri maybeAssetsUrl = Uri.parse(assetsPathOrUrl);
if (maybeAssetsUrl != null && maybeAssetsUrl.isAbsolute()) {
return maybeAssetsUrl;
} else {
String normalizedAssetsPath;
try {
URI assetsPathURI = new URI(assetsPathOrUrl);
normalizedAssetsPath = assetsPathURI.normalize().toString();
} catch (Exception e) {
Log.e(TAG, "Failed to normalize assetUrlOverride", e);
normalizedAssetsPath = assetsPathOrUrl;
}
// use manifest URL as the base
Uri.Builder assetsBaseUrlBuilder = manifestUrl.buildUpon();
List<String> segments = manifestUrl.getPathSegments();
assetsBaseUrlBuilder.path("");
for (int i = 0; i < segments.size() - 1; i++) {
assetsBaseUrlBuilder.appendPath(segments.get(i));
}
assetsBaseUrlBuilder.appendPath(normalizedAssetsPath);
return assetsBaseUrlBuilder.build();
}
}
}
public boolean isDevelopmentMode() {
return isDevelopmentMode(mManifestJson);
}
private static boolean isDevelopmentMode(final JSONObject manifest) {
try {
return (manifest != null &&
manifest.has("developer") &&
manifest.has("packagerOpts") &&
manifest.getJSONObject("packagerOpts").optBoolean("dev", false));
} catch (JSONException e) {
return false;
}
}
private static boolean isUsingDeveloperTool(final JSONObject manifest) {
try {
return (manifest != null &&
manifest.has("developer") &&
manifest.getJSONObject("developer").has("tool"));
} catch (JSONException e) {
return false;
}
}
}

View File

@ -0,0 +1,15 @@
package expo.modules.updates.manifest;
import expo.modules.updates.db.entity.AssetEntity;
import expo.modules.updates.db.entity.UpdateEntity;
import org.json.JSONObject;
import java.util.ArrayList;
public interface Manifest {
UpdateEntity getUpdateEntity();
ArrayList<AssetEntity> getAssetEntityList();
JSONObject getRawManifestJson();
boolean isDevelopmentMode();
}

View File

@ -0,0 +1,54 @@
package expo.modules.updates.manifest;
import android.content.Context;
import android.content.pm.ApplicationInfo;
import android.content.pm.PackageManager;
import android.util.Log;
import org.json.JSONException;
import org.json.JSONObject;
import expo.modules.updates.UpdatesConfiguration;
public class ManifestFactory {
private static final String TAG = ManifestFactory.class.getSimpleName();
private static Boolean sIsLegacy = null;
private static boolean isLegacy(Context context) {
if (sIsLegacy == null) {
try {
ApplicationInfo ai = context.getPackageManager().getApplicationInfo(context.getPackageName(), PackageManager.GET_META_DATA);
sIsLegacy = ai.metaData.getBoolean("expo.modules.updates.EXPO_LEGACY_MANIFEST", true);
} catch (Exception e) {
Log.e(TAG, "Failed to read expo.modules.updates.EXPO_LEGACY_MANIFEST meta-data from AndroidManifest", e);
}
}
return sIsLegacy;
}
public static Manifest getManifest(JSONObject manifestJson, UpdatesConfiguration configuration, Context context) throws JSONException {
if (isLegacy(context)) {
return LegacyManifest.fromLegacyManifestJson(manifestJson, configuration);
} else {
return NewManifest.fromManifestJson(manifestJson, configuration);
}
}
public static Manifest getEmbeddedManifest(JSONObject manifestJson, UpdatesConfiguration configuration, Context context) throws JSONException {
if (isLegacy(context)) {
if (manifestJson.has("releaseId")) {
return LegacyManifest.fromLegacyManifestJson(manifestJson, configuration);
} else {
return BareManifest.fromManifestJson(manifestJson, configuration);
}
} else {
if (manifestJson.has("runtimeVersion")) {
return NewManifest.fromManifestJson(manifestJson, configuration);
} else {
return BareManifest.fromManifestJson(manifestJson, configuration);
}
}
}
}

View File

@ -0,0 +1,108 @@
package expo.modules.updates.manifest;
import android.net.Uri;
import android.util.Log;
import expo.modules.updates.UpdatesConfiguration;
import expo.modules.updates.db.entity.AssetEntity;
import expo.modules.updates.db.entity.UpdateEntity;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import java.util.ArrayList;
import java.util.Date;
import java.util.UUID;
import static expo.modules.updates.loader.EmbeddedLoader.BUNDLE_FILENAME;
public class NewManifest implements Manifest {
private static String TAG = Manifest.class.getSimpleName();
private UUID mId;
private String mScopeKey;
private Date mCommitTime;
private String mRuntimeVersion;
private JSONObject mMetadata;
private Uri mBundleUrl;
private JSONArray mAssets;
private JSONObject mManifestJson;
private NewManifest(JSONObject manifestJson,
UUID id,
String scopeKey,
Date commitTime,
String runtimeVersion,
JSONObject metadata,
Uri bundleUrl,
JSONArray assets) {
mManifestJson = manifestJson;
mId = id;
mScopeKey = scopeKey;
mCommitTime = commitTime;
mRuntimeVersion = runtimeVersion;
mMetadata = metadata;
mBundleUrl = bundleUrl;
mAssets = assets;
}
public static NewManifest fromManifestJson(JSONObject manifestJson, UpdatesConfiguration configuration) throws JSONException {
UUID id = UUID.fromString(manifestJson.getString("id"));
Date commitTime = new Date(manifestJson.getLong("commitTime"));
String runtimeVersion = manifestJson.getString("runtimeVersion");
JSONObject metadata = manifestJson.optJSONObject("metadata");
Uri bundleUrl = Uri.parse(manifestJson.getString("bundleUrl"));
JSONArray assets = manifestJson.optJSONArray("assets");
return new NewManifest(manifestJson, id, configuration.getScopeKey(), commitTime, runtimeVersion, metadata, bundleUrl, assets);
}
public JSONObject getRawManifestJson() {
return mManifestJson;
}
public UpdateEntity getUpdateEntity() {
UpdateEntity updateEntity = new UpdateEntity(mId, mCommitTime, mRuntimeVersion, mScopeKey);
if (mMetadata != null) {
updateEntity.metadata = mMetadata;
}
return updateEntity;
}
public ArrayList<AssetEntity> getAssetEntityList() {
ArrayList<AssetEntity> assetList = new ArrayList<>();
AssetEntity bundleAssetEntity = new AssetEntity("bundle-" + mCommitTime.getTime(), "js");
bundleAssetEntity.url = mBundleUrl;
bundleAssetEntity.isLaunchAsset = true;
bundleAssetEntity.embeddedAssetFilename = BUNDLE_FILENAME;
assetList.add(bundleAssetEntity);
if (mAssets != null && mAssets.length() > 0) {
for (int i = 0; i < mAssets.length(); i++) {
try {
JSONObject assetObject = mAssets.getJSONObject(i);
AssetEntity assetEntity = new AssetEntity(
assetObject.getString("key"),
assetObject.getString("type")
);
assetEntity.url = Uri.parse(assetObject.getString("url"));
assetEntity.embeddedAssetFilename = assetObject.optString("embeddedAssetFilename");
assetList.add(assetEntity);
} catch (JSONException e) {
Log.e(TAG, "Could not read asset from manifest", e);
}
}
}
return assetList;
}
public boolean isDevelopmentMode() {
return false;
}
}

2
node_modules/expo-updates/build/ExpoUpdates.d.ts generated vendored Normal file
View File

@ -0,0 +1,2 @@
declare const _default: import("@unimodules/core").ProxyNativeModule;
export default _default;

3
node_modules/expo-updates/build/ExpoUpdates.js generated vendored Normal file
View File

@ -0,0 +1,3 @@
import { NativeModulesProxy } from '@unimodules/core';
export default NativeModulesProxy.ExpoUpdates || {};
//# sourceMappingURL=ExpoUpdates.js.map

1
node_modules/expo-updates/build/ExpoUpdates.js.map generated vendored Normal file
View File

@ -0,0 +1 @@
{"version":3,"file":"ExpoUpdates.js","sourceRoot":"","sources":["../src/ExpoUpdates.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AACtD,eAAe,kBAAkB,CAAC,WAAW,IAAK,EAAU,CAAC","sourcesContent":["import { NativeModulesProxy } from '@unimodules/core';\nexport default NativeModulesProxy.ExpoUpdates || ({} as any);\n"]}

5
node_modules/expo-updates/build/ExpoUpdates.web.d.ts generated vendored Normal file
View File

@ -0,0 +1,5 @@
declare const _default: {
readonly name: string;
reload(): Promise<void>;
};
export default _default;

12
node_modules/expo-updates/build/ExpoUpdates.web.js generated vendored Normal file
View File

@ -0,0 +1,12 @@
import { canUseDOM } from 'fbjs/lib/ExecutionEnvironment';
export default {
get name() {
return 'ExpoUpdates';
},
async reload() {
if (!canUseDOM)
return;
window.location.reload(true);
},
};
//# sourceMappingURL=ExpoUpdates.web.js.map

View File

@ -0,0 +1 @@
{"version":3,"file":"ExpoUpdates.web.js","sourceRoot":"","sources":["../src/ExpoUpdates.web.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,+BAA+B,CAAC;AAE1D,eAAe;IACb,IAAI,IAAI;QACN,OAAO,aAAa,CAAC;IACvB,CAAC;IACD,KAAK,CAAC,MAAM;QACV,IAAI,CAAC,SAAS;YAAE,OAAO;QACvB,MAAM,CAAC,QAAQ,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC;IAC/B,CAAC;CACF,CAAC","sourcesContent":["import { canUseDOM } from 'fbjs/lib/ExecutionEnvironment';\n\nexport default {\n get name(): string {\n return 'ExpoUpdates';\n },\n async reload(): Promise<void> {\n if (!canUseDOM) return;\n window.location.reload(true);\n },\n};\n"]}

14
node_modules/expo-updates/build/Updates.d.ts generated vendored Normal file
View File

@ -0,0 +1,14 @@
import { EventSubscription } from 'fbemitter';
import { Listener, LocalAssets, Manifest, UpdateCheckResult, UpdateEvent, UpdateFetchResult } from './Updates.types';
export * from './Updates.types';
export declare const updateId: string | null;
export declare const releaseChannel: string;
export declare const localAssets: LocalAssets;
export declare const isEmergencyLaunch: boolean;
export declare const isUsingEmbeddedAssets: boolean;
export declare const manifest: Manifest | object;
export declare function reloadAsync(): Promise<void>;
export declare function checkForUpdateAsync(): Promise<UpdateCheckResult>;
export declare function fetchUpdateAsync(): Promise<UpdateFetchResult>;
export declare function clearUpdateCacheExperimentalAsync(_sdkVersion?: string): void;
export declare function addListener(listener: Listener<UpdateEvent>): EventSubscription;

90
node_modules/expo-updates/build/Updates.js generated vendored Normal file
View File

@ -0,0 +1,90 @@
import { RCTDeviceEventEmitter, CodedError, NativeModulesProxy, UnavailabilityError, } from '@unimodules/core';
import { EventEmitter } from 'fbemitter';
import ExpoUpdates from './ExpoUpdates';
export * from './Updates.types';
export const updateId = ExpoUpdates.updateId && typeof ExpoUpdates.updateId === 'string'
? ExpoUpdates.updateId.toLowerCase()
: null;
export const releaseChannel = ExpoUpdates.releaseChannel ?? 'default';
export const localAssets = ExpoUpdates.localAssets ?? {};
export const isEmergencyLaunch = ExpoUpdates.isEmergencyLaunch || false;
export const isUsingEmbeddedAssets = ExpoUpdates.isUsingEmbeddedAssets || false;
let _manifest = ExpoUpdates.manifest;
if (ExpoUpdates.manifestString) {
_manifest = JSON.parse(ExpoUpdates.manifestString);
}
export const manifest = _manifest ?? {};
const isUsingDeveloperTool = !!manifest.developer?.tool;
const isUsingExpoDevelopmentClient = NativeModulesProxy.ExponentConstants?.appOwnership === 'expo';
const manualUpdatesInstructions = isUsingExpoDevelopmentClient
? 'To test manual updates, publish your project using `expo publish` and open the published ' +
'version in this development client.'
: 'To test manual updates, make a release build with `npm run ios --configuration Release` or ' +
'`npm run android --variant Release`.';
export async function reloadAsync() {
if (!ExpoUpdates.reload) {
throw new UnavailabilityError('Updates', 'reloadAsync');
}
if (__DEV__ && !isUsingExpoDevelopmentClient) {
throw new CodedError('ERR_UPDATES_DISABLED', `You cannot use the Updates module in development mode in a production app. ${manualUpdatesInstructions}`);
}
await ExpoUpdates.reload();
}
export async function checkForUpdateAsync() {
if (!ExpoUpdates.checkForUpdateAsync) {
throw new UnavailabilityError('Updates', 'checkForUpdateAsync');
}
if (__DEV__ || isUsingDeveloperTool) {
throw new CodedError('ERR_UPDATES_DISABLED', `You cannot check for updates in development mode. ${manualUpdatesInstructions}`);
}
const result = await ExpoUpdates.checkForUpdateAsync();
if (result.manifestString) {
result.manifest = JSON.parse(result.manifestString);
delete result.manifestString;
}
return result;
}
export async function fetchUpdateAsync() {
if (!ExpoUpdates.fetchUpdateAsync) {
throw new UnavailabilityError('Updates', 'fetchUpdateAsync');
}
if (__DEV__ || isUsingDeveloperTool) {
throw new CodedError('ERR_UPDATES_DISABLED', `You cannot fetch updates in development mode. ${manualUpdatesInstructions}`);
}
const result = await ExpoUpdates.fetchUpdateAsync();
if (result.manifestString) {
result.manifest = JSON.parse(result.manifestString);
delete result.manifestString;
}
return result;
}
export function clearUpdateCacheExperimentalAsync(_sdkVersion) {
console.warn("This method is no longer necessary. `expo-updates` now automatically deletes your app's old bundle files!");
}
let _emitter;
function _getEmitter() {
if (!_emitter) {
_emitter = new EventEmitter();
RCTDeviceEventEmitter.addListener('Expo.nativeUpdatesEvent', _emitEvent);
}
return _emitter;
}
function _emitEvent(params) {
let newParams = params;
if (typeof params === 'string') {
newParams = JSON.parse(params);
}
if (newParams.manifestString) {
newParams.manifest = JSON.parse(newParams.manifestString);
delete newParams.manifestString;
}
if (!_emitter) {
throw new Error(`EventEmitter must be initialized to use from its listener`);
}
_emitter.emit('Expo.updatesEvent', newParams);
}
export function addListener(listener) {
const emitter = _getEmitter();
return emitter.addListener('Expo.updatesEvent', listener);
}
//# sourceMappingURL=Updates.js.map

1
node_modules/expo-updates/build/Updates.js.map generated vendored Normal file

File diff suppressed because one or more lines are too long

32
node_modules/expo-updates/build/Updates.types.d.ts generated vendored Normal file
View File

@ -0,0 +1,32 @@
import Constants from 'expo-constants';
export declare enum UpdateEventType {
UPDATE_AVAILABLE = "updateAvailable",
NO_UPDATE_AVAILABLE = "noUpdateAvailable",
ERROR = "error"
}
export declare type Manifest = typeof Constants.manifest;
export declare type UpdateCheckResult = {
isAvailable: false;
} | {
isAvailable: true;
manifest: Manifest;
};
export declare type UpdateFetchResult = {
isNew: false;
} | {
isNew: true;
manifest: Manifest;
};
export declare type Listener<E> = (event: E) => void;
export declare type UpdateEvent = {
type: UpdateEventType.NO_UPDATE_AVAILABLE;
} | {
type: UpdateEventType.UPDATE_AVAILABLE;
manifest: Manifest;
} | {
type: UpdateEventType.ERROR;
message: string;
};
export declare type LocalAssets = {
[remoteUrl: string]: string;
};

7
node_modules/expo-updates/build/Updates.types.js generated vendored Normal file
View File

@ -0,0 +1,7 @@
export var UpdateEventType;
(function (UpdateEventType) {
UpdateEventType["UPDATE_AVAILABLE"] = "updateAvailable";
UpdateEventType["NO_UPDATE_AVAILABLE"] = "noUpdateAvailable";
UpdateEventType["ERROR"] = "error";
})(UpdateEventType || (UpdateEventType = {}));
//# sourceMappingURL=Updates.types.js.map

1
node_modules/expo-updates/build/Updates.types.js.map generated vendored Normal file
View File

@ -0,0 +1 @@
{"version":3,"file":"Updates.types.js","sourceRoot":"","sources":["../src/Updates.types.ts"],"names":[],"mappings":"AAEA,MAAM,CAAN,IAAY,eAIX;AAJD,WAAY,eAAe;IACzB,uDAAoC,CAAA;IACpC,4DAAyC,CAAA;IACzC,kCAAe,CAAA;AACjB,CAAC,EAJW,eAAe,KAAf,eAAe,QAI1B","sourcesContent":["import Constants from 'expo-constants';\n\nexport enum UpdateEventType {\n UPDATE_AVAILABLE = 'updateAvailable',\n NO_UPDATE_AVAILABLE = 'noUpdateAvailable',\n ERROR = 'error',\n}\n\n// TODO(eric): move source of truth for manifest type to this module\nexport type Manifest = typeof Constants.manifest;\n\nexport type UpdateCheckResult = { isAvailable: false } | { isAvailable: true; manifest: Manifest };\n\nexport type UpdateFetchResult = { isNew: false } | { isNew: true; manifest: Manifest };\n\nexport type Listener<E> = (event: E) => void;\n\nexport type UpdateEvent =\n | { type: UpdateEventType.NO_UPDATE_AVAILABLE }\n | { type: UpdateEventType.UPDATE_AVAILABLE; manifest: Manifest }\n | { type: UpdateEventType.ERROR; message: string };\n\nexport type LocalAssets = { [remoteUrl: string]: string };\n"]}

1
node_modules/expo-updates/build/index.d.ts generated vendored Normal file
View File

@ -0,0 +1 @@
export * from './Updates';

2
node_modules/expo-updates/build/index.js generated vendored Normal file
View File

@ -0,0 +1,2 @@
export * from './Updates';
//# sourceMappingURL=index.js.map

1
node_modules/expo-updates/build/index.js.map generated vendored Normal file
View File

@ -0,0 +1 @@
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,WAAW,CAAC","sourcesContent":["export * from './Updates';\n"]}

17
node_modules/expo-updates/bundle-expo-assets.sh generated vendored Executable file
View File

@ -0,0 +1,17 @@
#!/usr/bin/env bash
set -eo pipefail
if [[ "$CONFIGURATION" == Debug* ]]; then
if [ -z "$NODE_BINARY" ]; then
export NODE_BINARY=node
fi
../node_modules/react-native/scripts/react-native-xcode.sh
exit 0
fi
pushd "${SRCROOT}/.."
export PATH="$(if [ -f ~/.expo/PATH ]; then echo $PATH:$(cat ~/.expo/PATH); else echo $PATH; fi)"
dest="$CONFIGURATION_BUILD_DIR/$UNLOCALIZED_RESOURCES_FOLDER_PATH"
expo bundle-assets --platform ios --dest "$dest"
popd

64
node_modules/expo-updates/expo-updates.gradle generated vendored Normal file
View File

@ -0,0 +1,64 @@
// Gradle script for downloading assets that make up an OTA update and bundling them into the APK
import org.apache.tools.ant.taskdefs.condition.Os
import org.gradle.util.GradleVersion
void runBefore(String dependentTaskName, Task task) {
Task dependentTask = tasks.findByPath(dependentTaskName);
if (dependentTask != null) {
dependentTask.dependsOn task
}
}
def config = project.hasProperty("react") ? project.react : [];
afterEvaluate {
def projectRoot = file("../../")
def inputExcludes = ["android/**", "ios/**"]
android.applicationVariants.each { variant ->
def folderName = variant.name
def targetName = folderName.capitalize()
def assetsDir = file("$buildDir/intermediates/merged_assets/${folderName}/out")
GradleVersion gradleVersion = GradleVersion.current()
if (gradleVersion < GradleVersion.version('5.0')) {
assetsDir = file("$buildDir/intermediates/merged_assets/${folderName}/merge${targetName}Assets/out")
}
// Bundle task name for variant
def bundleExpoAssetsTaskName = "bundle${targetName}ExpoUpdatesAssets"
def currentBundleTask = tasks.create(
name: bundleExpoAssetsTaskName,
type: Exec) {
description = "expo-updates: Bundle assets for ${targetName}."
// Create dirs if they are not there (e.g. the "clean" task just ran)
doFirst {
assetsDir.mkdirs()
}
// Set up inputs and outputs so gradle can cache the result
inputs.files fileTree(dir: projectRoot, excludes: inputExcludes)
outputs.dir assetsDir
// Set up the call to exp
workingDir projectRoot
if (Os.isFamily(Os.FAMILY_WINDOWS)) {
commandLine("cmd", "/c", ".\\node_modules\\expo-updates\\run-expo.bat", "bundle-assets", projectRoot, "--platform", "android", "--dest", assetsDir)
} else {
commandLine("./node_modules/expo-updates/run-expo.sh", "bundle-assets", projectRoot, "--platform", "android", "--dest", assetsDir)
}
enabled config."bundleIn${targetName}" || targetName.toLowerCase().contains("release")
}
currentBundleTask.dependsOn("merge${targetName}Resources")
currentBundleTask.dependsOn("merge${targetName}Assets")
runBefore("process${targetName}Resources", currentBundleTask)
}
}

25
node_modules/expo-updates/ios/EXUpdates.podspec generated vendored Normal file
View File

@ -0,0 +1,25 @@
require 'json'
package = JSON.parse(File.read(File.join(__dir__, '..', 'package.json')))
Pod::Spec.new do |s|
s.name = 'EXUpdates'
s.version = package['version']
s.summary = package['description']
s.description = package['description']
s.license = package['license']
s.author = package['author']
s.homepage = package['homepage']
s.platform = :ios, '10.0'
s.source = { git: 'https://github.com/expo/expo.git' }
s.source_files = 'EXUpdates/**/*.{h,m}'
s.preserve_paths = 'EXUpdates/**/*.{h,m}'
s.requires_arc = true
s.dependency 'UMCore'
s.dependency 'React-Core'
s.test_spec 'Tests' do |test_spec|
test_spec.source_files = 'Tests/*.{h,m}'
end
end

View File

@ -0,0 +1,16 @@
// Copyright © 2019 650 Industries. All rights reserved.
#import <EXUpdates/EXUpdatesUpdate.h>
NS_ASSUME_NONNULL_BEGIN
@protocol EXUpdatesAppLauncher
@property (nullable, nonatomic, strong, readonly) EXUpdatesUpdate *launchedUpdate;
@property (nullable, nonatomic, strong, readonly) NSURL *launchAssetUrl;
@property (nullable, nonatomic, strong, readonly) NSDictionary *assetFilesMap;
@property (nonatomic, assign, readonly) BOOL isUsingEmbeddedAssets;
@end
NS_ASSUME_NONNULL_END

View File

@ -0,0 +1,17 @@
// Copyright © 2019 650 Industries. All rights reserved.
#import <EXUpdates/EXUpdatesAppLauncher.h>
#import <EXUpdates/EXUpdatesConfig.h>
NS_ASSUME_NONNULL_BEGIN
@interface EXUpdatesAppLauncherNoDatabase : NSObject <EXUpdatesAppLauncher>
- (void)launchUpdateWithConfig:(EXUpdatesConfig *)config;
- (void)launchUpdateWithConfig:(EXUpdatesConfig *)config fatalError:(NSError *)error;
+ (nullable NSString *)consumeError;
@end
NS_ASSUME_NONNULL_END

View File

@ -0,0 +1,109 @@
// Copyright © 2019 650 Industries. All rights reserved.
#import <EXUpdates/EXUpdatesAsset.h>
#import <EXUpdates/EXUpdatesAppLauncherNoDatabase.h>
#import <EXUpdates/EXUpdatesEmbeddedAppLoader.h>
NS_ASSUME_NONNULL_BEGIN
static NSString * const EXUpdatesErrorLogFile = @"expo-error.log";
@interface EXUpdatesAppLauncherNoDatabase ()
@property (nullable, nonatomic, strong, readwrite) EXUpdatesUpdate *launchedUpdate;
@property (nullable, nonatomic, strong, readwrite) NSURL *launchAssetUrl;
@property (nullable, nonatomic, strong, readwrite) NSMutableDictionary *assetFilesMap;
@end
@implementation EXUpdatesAppLauncherNoDatabase
- (void)launchUpdateWithConfig:(EXUpdatesConfig *)config
{
_launchedUpdate = [EXUpdatesEmbeddedAppLoader embeddedManifestWithConfig:config database:nil];
if (_launchedUpdate) {
if (_launchedUpdate.status == EXUpdatesUpdateStatusEmbedded) {
NSAssert(_assetFilesMap == nil, @"assetFilesMap should be null for embedded updates");
_launchAssetUrl = [[NSBundle mainBundle] URLForResource:EXUpdatesBareEmbeddedBundleFilename withExtension:EXUpdatesBareEmbeddedBundleFileType];
} else {
_launchAssetUrl = [[NSBundle mainBundle] URLForResource:EXUpdatesEmbeddedBundleFilename withExtension:EXUpdatesEmbeddedBundleFileType];
NSMutableDictionary *assetFilesMap = [NSMutableDictionary new];
for (EXUpdatesAsset *asset in _launchedUpdate.assets) {
NSURL *localUrl = [[NSBundle mainBundle] URLForResource:asset.mainBundleFilename withExtension:asset.type];
if (localUrl && asset.key) {
assetFilesMap[asset.key] = localUrl.absoluteString;
}
}
_assetFilesMap = assetFilesMap;
}
}
}
- (BOOL)isUsingEmbeddedAssets
{
return _assetFilesMap == nil;
}
- (void)launchUpdateWithConfig:(EXUpdatesConfig *)config fatalError:(NSError *)error;
{
[self launchUpdateWithConfig:config];
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
[self _writeErrorToLog:error];
});
}
+ (nullable NSString *)consumeError;
{
NSString *errorLogFilePath = [[self class] _errorLogFilePath];
NSData *data = [NSData dataWithContentsOfFile:errorLogFilePath options:kNilOptions error:nil];
if (data) {
NSError *err;
if (![NSFileManager.defaultManager removeItemAtPath:errorLogFilePath error:&err]) {
NSLog(@"Could not delete error log: %@", err.localizedDescription);
}
return [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding];
} else {
return nil;
}
}
- (void)_writeErrorToLog:(NSError *)error
{
NSString *serializedError = [NSString stringWithFormat:@"Expo encountered a fatal error: %@", [self _serializeError:error]];
NSData *data = [serializedError dataUsingEncoding:NSUTF8StringEncoding];
NSError *err;
if (![data writeToFile:[[self class] _errorLogFilePath] options:NSDataWritingAtomic error:&err]) {
NSLog(@"Could not write fatal error to log: %@", error.localizedDescription);
}
}
- (NSString *)_serializeError:(NSError *)error
{
NSString *localizedFailureReason = error.localizedFailureReason;
NSError *underlyingError = error.userInfo[NSUnderlyingErrorKey];
NSMutableString *serialization = [[NSString stringWithFormat:@"Time: %f\nDomain: %@\nCode: %li\nDescription: %@",
[[NSDate date] timeIntervalSince1970] * 1000,
error.domain,
(long)error.code,
error.localizedDescription] mutableCopy];
if (localizedFailureReason) {
[serialization appendFormat:@"\nFailure Reason: %@", localizedFailureReason];
}
if (underlyingError) {
[serialization appendFormat:@"\n\nUnderlying Error:\n%@", [self _serializeError:underlyingError]];
}
return serialization;
}
+ (NSString *)_errorLogFilePath
{
NSURL *applicationDocumentsDirectory = [[NSFileManager.defaultManager URLsForDirectory:NSApplicationSupportDirectory inDomains:NSUserDomainMask] lastObject];
return [[applicationDocumentsDirectory URLByAppendingPathComponent:EXUpdatesErrorLogFile] path];
}
@end
NS_ASSUME_NONNULL_END

View File

@ -0,0 +1,29 @@
// Copyright © 2019 650 Industries. All rights reserved.
#import <EXUpdates/EXUpdatesAppLauncher.h>
#import <EXUpdates/EXUpdatesSelectionPolicy.h>
NS_ASSUME_NONNULL_BEGIN
typedef void (^EXUpdatesAppLauncherCompletionBlock)(NSError * _Nullable error, BOOL success);
typedef void (^EXUpdatesAppLauncherUpdateCompletionBlock)(NSError * _Nullable error, EXUpdatesUpdate * _Nullable launchableUpdate);
@interface EXUpdatesAppLauncherWithDatabase : NSObject <EXUpdatesAppLauncher>
- (instancetype)initWithConfig:(EXUpdatesConfig *)config
database:(EXUpdatesDatabase *)database
directory:(NSURL *)directory
completionQueue:(dispatch_queue_t)completionQueue;
- (void)launchUpdateWithSelectionPolicy:(id<EXUpdatesSelectionPolicy>)selectionPolicy
completion:(EXUpdatesAppLauncherCompletionBlock)completion;
+ (void)launchableUpdateWithConfig:(EXUpdatesConfig *)config
database:(EXUpdatesDatabase *)database
selectionPolicy:(id<EXUpdatesSelectionPolicy>)selectionPolicy
completion:(EXUpdatesAppLauncherUpdateCompletionBlock)completion
completionQueue:(dispatch_queue_t)completionQueue;
@end
NS_ASSUME_NONNULL_END

View File

@ -0,0 +1,288 @@
// Copyright © 2019 650 Industries. All rights reserved.
#import <EXUpdates/EXUpdatesAppLauncherWithDatabase.h>
#import <EXUpdates/EXUpdatesEmbeddedAppLoader.h>
#import <EXUpdates/EXUpdatesDatabase.h>
#import <EXUpdates/EXUpdatesFileDownloader.h>
#import <EXUpdates/EXUpdatesUtils.h>
NS_ASSUME_NONNULL_BEGIN
@interface EXUpdatesAppLauncherWithDatabase ()
@property (nullable, nonatomic, strong, readwrite) EXUpdatesUpdate *launchedUpdate;
@property (nullable, nonatomic, strong, readwrite) NSURL *launchAssetUrl;
@property (nullable, nonatomic, strong, readwrite) NSMutableDictionary *assetFilesMap;
@property (nonatomic, strong) EXUpdatesConfig *config;
@property (nonatomic, strong) EXUpdatesDatabase *database;
@property (nonatomic, strong) NSURL *directory;
@property (nonatomic, strong) EXUpdatesFileDownloader *downloader;
@property (nonatomic, copy) EXUpdatesAppLauncherCompletionBlock completion;
@property (nonatomic, strong) dispatch_queue_t completionQueue;
@property (nonatomic, strong) dispatch_queue_t launcherQueue;
@property (nonatomic, assign) NSUInteger completedAssets;
@property (nonatomic, strong) NSError *launchAssetError;
@end
static NSString * const EXUpdatesAppLauncherErrorDomain = @"AppLauncher";
@implementation EXUpdatesAppLauncherWithDatabase
- (instancetype)initWithConfig:(EXUpdatesConfig *)config
database:(EXUpdatesDatabase *)database
directory:(NSURL *)directory
completionQueue:(dispatch_queue_t)completionQueue
{
if (self = [super init]) {
_launcherQueue = dispatch_queue_create("expo.launcher.LauncherQueue", DISPATCH_QUEUE_SERIAL);
_completedAssets = 0;
_config = config;
_database = database;
_directory = directory;
_completionQueue = completionQueue;
}
return self;
}
+ (void)launchableUpdateWithConfig:(EXUpdatesConfig *)config
database:(EXUpdatesDatabase *)database
selectionPolicy:(id<EXUpdatesSelectionPolicy>)selectionPolicy
completion:(EXUpdatesAppLauncherUpdateCompletionBlock)completion
completionQueue:(dispatch_queue_t)completionQueue
{
dispatch_async(database.databaseQueue, ^{
NSError *error;
NSArray<EXUpdatesUpdate *> *launchableUpdates = [database launchableUpdatesWithConfig:config error:&error];
dispatch_async(completionQueue, ^{
if (!launchableUpdates) {
completion(error, nil);
}
// We can only run an update marked as embedded if it's actually the update embedded in the
// current binary. We might have an older update from a previous binary still listed in the
// database with Embedded status so we need to filter that out here.
EXUpdatesUpdate *embeddedManifest = [EXUpdatesEmbeddedAppLoader embeddedManifestWithConfig:config database:database];
NSMutableArray<EXUpdatesUpdate *>*filteredLaunchableUpdates = [NSMutableArray new];
for (EXUpdatesUpdate *update in launchableUpdates) {
if (update.status == EXUpdatesUpdateStatusEmbedded) {
if (embeddedManifest && ![update.updateId isEqual:embeddedManifest.updateId]) {
continue;
}
}
[filteredLaunchableUpdates addObject:update];
}
completion(nil, [selectionPolicy launchableUpdateWithUpdates:filteredLaunchableUpdates]);
});
});
}
- (void)launchUpdateWithSelectionPolicy:(id<EXUpdatesSelectionPolicy>)selectionPolicy
completion:(EXUpdatesAppLauncherCompletionBlock)completion
{
NSAssert(!_completion, @"EXUpdatesAppLauncher:launchUpdateWithSelectionPolicy:successBlock should not be called twice on the same instance");
_completion = completion;
if (!_launchedUpdate) {
[[self class] launchableUpdateWithConfig:_config database:_database selectionPolicy:selectionPolicy completion:^(NSError * _Nullable error, EXUpdatesUpdate * _Nullable launchableUpdate) {
if (error || !launchableUpdate) {
if (self->_completion) {
dispatch_async(self->_completionQueue, ^{
NSMutableDictionary *userInfo = [NSMutableDictionary new];
userInfo[NSLocalizedDescriptionKey] = @"No launchable updates found in database";
if (error) {
userInfo[NSUnderlyingErrorKey] = error;
}
self->_completion([NSError errorWithDomain:EXUpdatesAppLauncherErrorDomain code:1011 userInfo:userInfo], NO);
});
}
} else {
self->_launchedUpdate = launchableUpdate;
[self _ensureAllAssetsExist];
}
} completionQueue:_launcherQueue];
} else {
[self _ensureAllAssetsExist];
}
}
- (BOOL)isUsingEmbeddedAssets
{
return _assetFilesMap == nil;
}
- (void)_ensureAllAssetsExist
{
if (_launchedUpdate.status == EXUpdatesUpdateStatusEmbedded) {
NSAssert(_assetFilesMap == nil, @"assetFilesMap should be null for embedded updates");
_launchAssetUrl = [[NSBundle mainBundle] URLForResource:EXUpdatesBareEmbeddedBundleFilename withExtension:EXUpdatesBareEmbeddedBundleFileType];
dispatch_async(self->_completionQueue, ^{
self->_completion(self->_launchAssetError, self->_launchAssetUrl != nil);
self->_completion = nil;
});
return;
} else if (_launchedUpdate.status == EXUpdatesUpdateStatusDevelopment) {
dispatch_async(self->_completionQueue, ^{
self->_completion(nil, YES);
self->_completion = nil;
});
return;
}
_assetFilesMap = [NSMutableDictionary new];
if (_launchedUpdate) {
NSUInteger totalAssetCount = _launchedUpdate.assets.count;
for (EXUpdatesAsset *asset in _launchedUpdate.assets) {
NSURL *assetLocalUrl = [_directory URLByAppendingPathComponent:asset.filename];
[self _ensureAssetExists:asset withLocalUrl:assetLocalUrl completion:^(BOOL exists) {
dispatch_assert_queue(self->_launcherQueue);
self->_completedAssets++;
if (exists) {
if (asset.isLaunchAsset) {
self->_launchAssetUrl = assetLocalUrl;
} else {
if (asset.key) {
self->_assetFilesMap[asset.key] = assetLocalUrl.absoluteString;
}
}
}
if (self->_completedAssets == totalAssetCount) {
dispatch_async(self->_completionQueue, ^{
self->_completion(self->_launchAssetError, self->_launchAssetUrl != nil);
self->_completion = nil;
});
}
}];
}
}
}
- (void)_ensureAssetExists:(EXUpdatesAsset *)asset withLocalUrl:(NSURL *)assetLocalUrl completion:(void (^)(BOOL exists))completion
{
[self _checkExistenceOfAsset:asset withLocalUrl:assetLocalUrl completion:^(BOOL exists) {
if (exists) {
completion(YES);
return;
}
[self _maybeCopyAssetFromMainBundle:asset withLocalUrl:assetLocalUrl completion:^(BOOL success, NSError * _Nullable error) {
if (success) {
completion(YES);
return;
}
if (error) {
NSLog(@"Error copying embedded asset %@: %@", asset.key, error.localizedDescription);
}
[self _downloadAsset:asset withLocalUrl:assetLocalUrl completion:^(NSError * _Nullable error, EXUpdatesAsset *asset, NSURL *assetLocalUrl) {
if (error) {
if (asset.isLaunchAsset) {
// save the error -- since this is the launch asset, the launcher will fail
// so we want to propagate this error
self->_launchAssetError = error;
}
NSLog(@"Failed to load missing asset %@: %@", asset.key, error.localizedDescription);
completion(NO);
} else {
// attempt to update the database record to match the newly downloaded asset
// but don't block launching on this
dispatch_async(self->_database.databaseQueue, ^{
NSError *error;
[self->_database updateAsset:asset error:&error];
if (error) {
NSLog(@"Could not write data for downloaded asset to database: %@", error.localizedDescription);
}
});
completion(YES);
}
}];
}];
}];
}
- (void)_checkExistenceOfAsset:(EXUpdatesAsset *)asset withLocalUrl:(NSURL *)assetLocalUrl completion:(void (^)(BOOL exists))completion
{
dispatch_async([EXUpdatesFileDownloader assetFilesQueue], ^{
BOOL exists = [NSFileManager.defaultManager fileExistsAtPath:[assetLocalUrl path]];
dispatch_async(self->_launcherQueue, ^{
completion(exists);
});
});
}
- (void)_maybeCopyAssetFromMainBundle:(EXUpdatesAsset *)asset
withLocalUrl:(NSURL *)assetLocalUrl
completion:(void (^)(BOOL success, NSError * _Nullable error))completion
{
EXUpdatesUpdate *embeddedManifest = [EXUpdatesEmbeddedAppLoader embeddedManifestWithConfig:_config database:_database];
if (embeddedManifest) {
EXUpdatesAsset *matchingAsset;
for (EXUpdatesAsset *embeddedAsset in embeddedManifest.assets) {
if ([embeddedAsset.key isEqualToString:asset.key]) {
matchingAsset = embeddedAsset;
break;
}
}
if (matchingAsset && matchingAsset.mainBundleFilename) {
dispatch_async([EXUpdatesFileDownloader assetFilesQueue], ^{
NSString *bundlePath = [[NSBundle mainBundle] pathForResource:matchingAsset.mainBundleFilename ofType:matchingAsset.type];
NSError *error;
BOOL success = [NSFileManager.defaultManager copyItemAtPath:bundlePath toPath:[assetLocalUrl path] error:&error];
dispatch_async(self->_launcherQueue, ^{
completion(success, error);
});
});
return;
}
}
completion(NO, nil);
}
- (void)_downloadAsset:(EXUpdatesAsset *)asset
withLocalUrl:(NSURL *)assetLocalUrl
completion:(void (^)(NSError * _Nullable error, EXUpdatesAsset *asset, NSURL *assetLocalUrl))completion
{
if (!asset.url) {
completion([NSError errorWithDomain:EXUpdatesAppLauncherErrorDomain code:1007 userInfo:@{NSLocalizedDescriptionKey: @"Failed to download asset with no URL provided"}], asset, assetLocalUrl);
}
dispatch_async([EXUpdatesFileDownloader assetFilesQueue], ^{
[self.downloader downloadFileFromURL:asset.url toPath:[assetLocalUrl path] successBlock:^(NSData *data, NSURLResponse *response) {
dispatch_async(self->_launcherQueue, ^{
if ([response isKindOfClass:[NSHTTPURLResponse class]]) {
asset.headers = ((NSHTTPURLResponse *)response).allHeaderFields;
}
asset.contentHash = [EXUpdatesUtils sha256WithData:data];
asset.downloadTime = [NSDate date];
completion(nil, asset, assetLocalUrl);
});
} errorBlock:^(NSError *error, NSURLResponse *response) {
dispatch_async(self->_launcherQueue, ^{
completion(error, asset, assetLocalUrl);
});
}];
});
}
- (EXUpdatesFileDownloader *)downloader
{
if (!_downloader) {
_downloader = [[EXUpdatesFileDownloader alloc] initWithUpdatesConfig:_config];
}
return _downloader;
}
@end
NS_ASSUME_NONNULL_END

View File

@ -0,0 +1,15 @@
// Copyright © 2019 650 Industries. All rights reserved.
#import <EXUpdates/EXUpdatesUpdate.h>
NS_ASSUME_NONNULL_BEGIN
@protocol EXUpdatesSelectionPolicy
- (nullable EXUpdatesUpdate *)launchableUpdateWithUpdates:(NSArray<EXUpdatesUpdate *> *)updates;
- (NSArray<EXUpdatesUpdate *> *)updatesToDeleteWithLaunchedUpdate:(EXUpdatesUpdate *)launchedUpdate updates:(NSArray<EXUpdatesUpdate *> *)updates;
- (BOOL)shouldLoadNewUpdate:(nullable EXUpdatesUpdate *)newUpdate withLaunchedUpdate:(nullable EXUpdatesUpdate *)launchedUpdate;
@end
NS_ASSUME_NONNULL_END

View File

@ -0,0 +1,14 @@
// Copyright © 2019 650 Industries. All rights reserved.
#import <EXUpdates/EXUpdatesSelectionPolicy.h>
NS_ASSUME_NONNULL_BEGIN
@interface EXUpdatesSelectionPolicyNewest : NSObject <EXUpdatesSelectionPolicy>
- (instancetype)initWithRuntimeVersion:(NSString *)runtimeVersion;
- (instancetype)initWithRuntimeVersions:(NSArray<NSString *> *)runtimeVersions;
@end
NS_ASSUME_NONNULL_END

View File

@ -0,0 +1,83 @@
// Copyright © 2019 650 Industries. All rights reserved.
#import <EXUpdates/EXUpdatesConfig.h>
#import <EXUpdates/EXUpdatesSelectionPolicyNewest.h>
NS_ASSUME_NONNULL_BEGIN
@interface EXUpdatesSelectionPolicyNewest ()
@property (nonatomic, strong) NSArray<NSString *> *runtimeVersions;
@end
@implementation EXUpdatesSelectionPolicyNewest
- (instancetype)initWithRuntimeVersions:(NSArray<NSString *> *)runtimeVersions
{
if (self = [super init]) {
_runtimeVersions = runtimeVersions;
}
return self;
}
- (instancetype)initWithRuntimeVersion:(NSString *)runtimeVersion
{
return [self initWithRuntimeVersions:@[runtimeVersion]];
}
- (nullable EXUpdatesUpdate *)launchableUpdateWithUpdates:(NSArray<EXUpdatesUpdate *> *)updates
{
EXUpdatesUpdate *runnableUpdate;
NSDate *runnableUpdateCommitTime;
for (EXUpdatesUpdate *update in updates) {
if (![_runtimeVersions containsObject:update.runtimeVersion]) {
continue;
}
NSDate *commitTime = update.commitTime;
if (!runnableUpdateCommitTime || [runnableUpdateCommitTime compare:commitTime] == NSOrderedAscending) {
runnableUpdate = update;
runnableUpdateCommitTime = commitTime;
}
}
return runnableUpdate;
}
- (NSArray<EXUpdatesUpdate *> *)updatesToDeleteWithLaunchedUpdate:(EXUpdatesUpdate *)launchedUpdate updates:(NSArray<EXUpdatesUpdate *> *)updates
{
if (!launchedUpdate) {
return @[];
}
NSMutableArray<EXUpdatesUpdate *> *updatesToDelete = [NSMutableArray new];
// keep the launched update and one other, the next newest, to be safe and make rollbacks faster
EXUpdatesUpdate *nextNewestUpdate;
for (EXUpdatesUpdate *update in updates) {
if ([launchedUpdate.commitTime compare:update.commitTime] == NSOrderedDescending) {
[updatesToDelete addObject:update];
if (!nextNewestUpdate || [update.commitTime compare:nextNewestUpdate.commitTime] == NSOrderedDescending) {
nextNewestUpdate = update;
}
}
}
if (nextNewestUpdate) {
[updatesToDelete removeObject:nextNewestUpdate];
}
return updatesToDelete;
}
- (BOOL)shouldLoadNewUpdate:(nullable EXUpdatesUpdate *)newUpdate withLaunchedUpdate:(nullable EXUpdatesUpdate *)launchedUpdate
{
if (!newUpdate) {
return false;
}
if (!launchedUpdate) {
return true;
}
return [launchedUpdate.commitTime compare:newUpdate.commitTime] == NSOrderedAscending;
}
@end
NS_ASSUME_NONNULL_END

View File

@ -0,0 +1,29 @@
// Copyright © 2019 650 Industries. All rights reserved.
#import <EXUpdates/EXUpdatesAppLoader.h>
#import <EXUpdates/EXUpdatesAsset.h>
#import <EXUpdates/EXUpdatesDatabase.h>
#import <EXUpdates/EXUpdatesUpdate.h>
NS_ASSUME_NONNULL_BEGIN
@interface EXUpdatesAppLoader ()
@property (nonatomic, strong) EXUpdatesConfig *config;
@property (nonatomic, strong) EXUpdatesDatabase *database;
@property (nonatomic, strong) NSURL *directory;
@property (nonatomic, strong) EXUpdatesUpdate *updateManifest;
@property (nonatomic, copy) EXUpdatesAppLoaderManifestBlock manifestBlock;
@property (nonatomic, copy) EXUpdatesAppLoaderSuccessBlock successBlock;
@property (nonatomic, copy) EXUpdatesAppLoaderErrorBlock errorBlock;
- (void)startLoadingFromManifest:(EXUpdatesUpdate *)updateManifest;
- (void)handleAssetDownloadAlreadyExists:(EXUpdatesAsset *)asset;
- (void)handleAssetDownloadWithData:(NSData *)data response:(nullable NSURLResponse *)response asset:(EXUpdatesAsset *)asset;
- (void)handleAssetDownloadWithError:(NSError *)error asset:(EXUpdatesAsset *)asset;
- (void)downloadAsset:(EXUpdatesAsset *)asset;
@end
NS_ASSUME_NONNULL_END

View File

@ -0,0 +1,35 @@
// Copyright © 2019 650 Industries. All rights reserved.
#import <EXUpdates/EXUpdatesConfig.h>
#import <EXUpdates/EXUpdatesDatabase.h>
#import <EXUpdates/EXUpdatesUpdate.h>
NS_ASSUME_NONNULL_BEGIN
typedef BOOL (^EXUpdatesAppLoaderManifestBlock)(EXUpdatesUpdate *update);
typedef void (^EXUpdatesAppLoaderSuccessBlock)(EXUpdatesUpdate * _Nullable update);
typedef void (^EXUpdatesAppLoaderErrorBlock)(NSError *error);
@interface EXUpdatesAppLoader : NSObject
- (instancetype)initWithConfig:(EXUpdatesConfig *)config
database:(EXUpdatesDatabase *)database
directory:(NSURL *)directory
completionQueue:(dispatch_queue_t)completionQueue;
/**
* Load an update from the given URL, which should respond with a valid manifest.
*
* The `onManifest` block is called as soon as the manifest has been downloaded.
* The block should determine whether or not the update described by this manifest
* should be downloaded, based on (for example) whether or not it already has the
* update downloaded locally, and return the corresponding BOOL value.
*/
- (void)loadUpdateFromUrl:(NSURL *)url
onManifest:(EXUpdatesAppLoaderManifestBlock)manifestBlock
success:(EXUpdatesAppLoaderSuccessBlock)success
error:(EXUpdatesAppLoaderErrorBlock)error;
@end
NS_ASSUME_NONNULL_END

View File

@ -0,0 +1,325 @@
// Copyright © 2019 650 Industries. All rights reserved.
#import <EXUpdates/EXUpdatesAppLoader+Private.h>
#import <EXUpdates/EXUpdatesDatabase.h>
#import <EXUpdates/EXUpdatesFileDownloader.h>
#import <EXUpdates/EXUpdatesUtils.h>
#import <UMCore/UMUtilities.h>
NS_ASSUME_NONNULL_BEGIN
@interface EXUpdatesAppLoader ()
@property (nonatomic, strong) NSMutableArray<EXUpdatesAsset *> *assetsToLoad;
@property (nonatomic, strong) NSMutableArray<EXUpdatesAsset *> *erroredAssets;
@property (nonatomic, strong) NSMutableArray<EXUpdatesAsset *> *finishedAssets;
@property (nonatomic, strong) NSMutableArray<EXUpdatesAsset *> *existingAssets;
@property (nonatomic, strong) NSLock *arrayLock;
@property (nonatomic, strong) dispatch_queue_t completionQueue;
@end
static NSString * const EXUpdatesAppLoaderErrorDomain = @"EXUpdatesAppLoader";
@implementation EXUpdatesAppLoader
- (instancetype)initWithConfig:(EXUpdatesConfig *)config
database:(EXUpdatesDatabase *)database
directory:(NSURL *)directory
completionQueue:(dispatch_queue_t)completionQueue
{
if (self = [super init]) {
_assetsToLoad = [NSMutableArray new];
_erroredAssets = [NSMutableArray new];
_finishedAssets = [NSMutableArray new];
_existingAssets = [NSMutableArray new];
_arrayLock = [[NSLock alloc] init];
_config = config;
_database = database;
_directory = directory;
_completionQueue = completionQueue;
}
return self;
}
- (void)_reset
{
_assetsToLoad = [NSMutableArray new];
_erroredAssets = [NSMutableArray new];
_finishedAssets = [NSMutableArray new];
_existingAssets = [NSMutableArray new];
_updateManifest = nil;
_manifestBlock = nil;
_successBlock = nil;
_errorBlock = nil;
}
# pragma mark - subclass methods
- (void)loadUpdateFromUrl:(NSURL *)url
onManifest:(EXUpdatesAppLoaderManifestBlock)manifestBlock
success:(EXUpdatesAppLoaderSuccessBlock)success
error:(EXUpdatesAppLoaderErrorBlock)error
{
@throw [NSException exceptionWithName:NSInternalInconsistencyException reason:@"Should not call EXUpdatesAppLoader#loadUpdate -- use a subclass instead" userInfo:nil];
}
- (void)downloadAsset:(EXUpdatesAsset *)asset
{
@throw [NSException exceptionWithName:NSInternalInconsistencyException reason:@"Should not call EXUpdatesAppLoader#loadUpdate -- use a subclass instead" userInfo:nil];
}
# pragma mark - loading and database logic
- (void)startLoadingFromManifest:(EXUpdatesUpdate *)updateManifest
{
if (![self _shouldStartLoadingUpdate:updateManifest]) {
if (_successBlock) {
_successBlock(nil);
}
return;
}
if (updateManifest.isDevelopmentMode) {
dispatch_async(_database.databaseQueue, ^{
NSError *updateError;
[self->_database addUpdate:updateManifest error:&updateError];
if (updateError) {
[self _finishWithError:updateError];
return;
}
NSError *updateReadyError;
[self->_database markUpdateFinished:updateManifest error:&updateReadyError];
if (updateReadyError) {
[self _finishWithError:updateReadyError];
return;
}
EXUpdatesAppLoaderSuccessBlock successBlock;
if (self->_successBlock) {
successBlock = self->_successBlock;
}
dispatch_async(self->_completionQueue, ^{
if (successBlock) {
successBlock(updateManifest);
}
[self _reset];
});
});
return;
}
dispatch_async(_database.databaseQueue, ^{
NSError *existingUpdateError;
EXUpdatesUpdate *existingUpdate = [self->_database updateWithId:updateManifest.updateId config:self->_config error:&existingUpdateError];
// if something has gone wrong on the server and we have two updates with the same id
// but different scope keys, we should try to launch something rather than show a cryptic
// error to the user.
if (existingUpdate && ![existingUpdate.scopeKey isEqualToString:updateManifest.scopeKey]) {
NSError *setScopeKeyError;
[self->_database setScopeKey:updateManifest.scopeKey onUpdate:existingUpdate error:&setScopeKeyError];
if (setScopeKeyError) {
[self _finishWithError:setScopeKeyError];
return;
}
NSLog(@"EXUpdatesAppLoader: Loaded an update with the same ID but a different scopeKey than one we already have on disk. This is a server error. Overwriting the scopeKey and loading the existing update.");
}
if (existingUpdate && existingUpdate.status == EXUpdatesUpdateStatusReady) {
if (self->_successBlock) {
dispatch_async(self->_completionQueue, ^{
self->_successBlock(updateManifest);
});
}
return;
}
if (existingUpdate) {
// we've already partially downloaded the update.
// however, it's not ready, so we should try to download all the assets again.
self->_updateManifest = updateManifest;
} else {
if (existingUpdateError) {
NSLog(@"Failed to select old update from DB: %@", existingUpdateError.localizedDescription);
}
// no update already exists with this ID, so we need to insert it and download everything.
self->_updateManifest = updateManifest;
NSError *updateError;
[self->_database addUpdate:self->_updateManifest error:&updateError];
if (updateError) {
[self _finishWithError:updateError];
return;
}
}
if (self->_updateManifest.assets && self->_updateManifest.assets.count > 0) {
self->_assetsToLoad = [self->_updateManifest.assets mutableCopy];
for (EXUpdatesAsset *asset in self->_updateManifest.assets) {
// before downloading, check to see if we already have this asset in the database
NSError *matchingAssetError;
EXUpdatesAsset *matchingDbEntry = [self->_database assetWithKey:asset.key error:&matchingAssetError];
if (matchingAssetError || !matchingDbEntry || !matchingDbEntry.filename) {
[self downloadAsset:asset];
} else {
NSError *mergeError;
[self->_database mergeAsset:asset withExistingEntry:matchingDbEntry error:&mergeError];
if (mergeError) {
NSLog(@"Failed to merge asset with existing database entry: %@", mergeError.localizedDescription);
}
// make sure the file actually exists on disk
dispatch_async([EXUpdatesFileDownloader assetFilesQueue], ^{
NSURL *urlOnDisk = [self->_directory URLByAppendingPathComponent:asset.filename];
if ([[NSFileManager defaultManager] fileExistsAtPath:[urlOnDisk path]]) {
// file already exists, we don't need to download it again
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
[self handleAssetDownloadAlreadyExists:asset];
});
} else {
[self downloadAsset:asset];
}
});
}
}
} else {
[self _finish];
}
});
}
- (void)handleAssetDownloadAlreadyExists:(EXUpdatesAsset *)asset
{
[_arrayLock lock];
[self->_assetsToLoad removeObject:asset];
[self->_existingAssets addObject:asset];
if (![self->_assetsToLoad count]) {
[self _finish];
}
[_arrayLock unlock];
}
- (void)handleAssetDownloadWithError:(NSError *)error asset:(EXUpdatesAsset *)asset
{
// TODO: retry. for now log an error
NSLog(@"error loading asset %@: %@", asset.key, error.localizedDescription);
[_arrayLock lock];
[self->_assetsToLoad removeObject:asset];
[self->_erroredAssets addObject:asset];
if (![self->_assetsToLoad count]) {
[self _finish];
}
[_arrayLock unlock];
}
- (void)handleAssetDownloadWithData:(NSData *)data response:(nullable NSURLResponse *)response asset:(EXUpdatesAsset *)asset
{
[_arrayLock lock];
[self->_assetsToLoad removeObject:asset];
if ([response isKindOfClass:[NSHTTPURLResponse class]]) {
asset.headers = ((NSHTTPURLResponse *)response).allHeaderFields;
}
asset.contentHash = [EXUpdatesUtils sha256WithData:data];
asset.downloadTime = [NSDate date];
[self->_finishedAssets addObject:asset];
if (![self->_assetsToLoad count]) {
[self _finish];
}
[_arrayLock unlock];
}
# pragma mark - internal
- (BOOL)_shouldStartLoadingUpdate:(EXUpdatesUpdate *)updateManifest
{
return _manifestBlock(updateManifest);
}
- (void)_finishWithError:(NSError *)error
{
dispatch_async(_completionQueue, ^{
if (self->_errorBlock) {
self->_errorBlock(error);
}
[self _reset];
});
}
- (void)_finish
{
dispatch_async(_database.databaseQueue, ^{
[self->_arrayLock lock];
for (EXUpdatesAsset *existingAsset in self->_existingAssets) {
NSError *error;
BOOL existingAssetFound = [self->_database addExistingAsset:existingAsset toUpdateWithId:self->_updateManifest.updateId error:&error];
if (!existingAssetFound) {
// the database and filesystem have gotten out of sync
// do our best to create a new entry for this file even though it already existed on disk
NSData *contents = [NSData dataWithContentsOfURL:[self->_directory URLByAppendingPathComponent:existingAsset.filename]];
existingAsset.contentHash = [EXUpdatesUtils sha256WithData:contents];
existingAsset.downloadTime = [NSDate date];
[self->_finishedAssets addObject:existingAsset];
}
if (error) {
NSLog(@"Error searching for existing asset in DB: %@", error.localizedDescription);
}
}
NSError *assetError;
[self->_database addNewAssets:self->_finishedAssets toUpdateWithId:self->_updateManifest.updateId error:&assetError];
if (assetError) {
[self->_arrayLock unlock];
[self _finishWithError:assetError];
return;
}
if (![self->_erroredAssets count]) {
NSError *updateReadyError;
[self->_database markUpdateFinished:self->_updateManifest error:&updateReadyError];
if (updateReadyError) {
[self->_arrayLock unlock];
[self _finishWithError:updateReadyError];
return;
}
}
EXUpdatesAppLoaderSuccessBlock successBlock;
EXUpdatesAppLoaderErrorBlock errorBlock;
if (self->_erroredAssets.count) {
if (self->_errorBlock) {
errorBlock = self->_errorBlock;
}
} else {
if (self->_successBlock) {
successBlock = self->_successBlock;
}
}
[self->_arrayLock unlock];
dispatch_async(self->_completionQueue, ^{
if (errorBlock) {
errorBlock([NSError errorWithDomain:EXUpdatesAppLoaderErrorDomain
code:1012
userInfo:@{NSLocalizedDescriptionKey: @"Failed to load all assets"}]);
} else if (successBlock) {
successBlock(self->_updateManifest);
}
[self _reset];
});
});
}
@end
NS_ASSUME_NONNULL_END

View File

@ -0,0 +1,50 @@
// Copyright © 2020 650 Industries. All rights reserved.
#import <EXUpdates/EXUpdatesAppLauncher.h>
#import <EXUpdates/EXUpdatesConfig.h>
#import <EXUpdates/EXUpdatesDatabase.h>
#import <EXUpdates/EXUpdatesSelectionPolicy.h>
#import <EXUpdates/EXUpdatesUpdate.h>
NS_ASSUME_NONNULL_BEGIN
typedef NS_ENUM(NSInteger, EXUpdatesBackgroundUpdateStatus) {
EXUpdatesBackgroundUpdateStatusError = 0,
EXUpdatesBackgroundUpdateStatusNoUpdateAvailable = 1,
EXUpdatesBackgroundUpdateStatusUpdateAvailable = 2
};
@class EXUpdatesAppLoaderTask;
@protocol EXUpdatesAppLoaderTaskDelegate <NSObject>
/**
* This method gives the delegate a backdoor option to ignore the cached update and force
* a remote load if it decides the cached update is not runnable. Returning NO from this
* callback will force a remote load, overriding the timeout and configuration settings for
* whether or not to check for a remote update. Returning YES from this callback will make
* EXUpdatesAppLoaderTask proceed as usual.
*/
- (BOOL)appLoaderTask:(EXUpdatesAppLoaderTask *)appLoaderTask didLoadCachedUpdate:(EXUpdatesUpdate *)update;
- (void)appLoaderTask:(EXUpdatesAppLoaderTask *)appLoaderTask didStartLoadingUpdate:(EXUpdatesUpdate *)update;
- (void)appLoaderTask:(EXUpdatesAppLoaderTask *)appLoaderTask didFinishWithLauncher:(id<EXUpdatesAppLauncher>)launcher isUpToDate:(BOOL)isUpToDate;
- (void)appLoaderTask:(EXUpdatesAppLoaderTask *)appLoaderTask didFinishWithError:(NSError *)error;
- (void)appLoaderTask:(EXUpdatesAppLoaderTask *)appLoaderTask didFinishBackgroundUpdateWithStatus:(EXUpdatesBackgroundUpdateStatus)status update:(nullable EXUpdatesUpdate *)update error:(nullable NSError *)error;
@end
@interface EXUpdatesAppLoaderTask : NSObject
@property (nonatomic, weak) id<EXUpdatesAppLoaderTaskDelegate> delegate;
- (instancetype)initWithConfig:(EXUpdatesConfig *)config
database:(EXUpdatesDatabase *)database
directory:(NSURL *)directory
selectionPolicy:(id<EXUpdatesSelectionPolicy>)selectionPolicy
delegateQueue:(dispatch_queue_t)delegateQueue;
- (void)start;
@end
NS_ASSUME_NONNULL_END

View File

@ -0,0 +1,299 @@
// Copyright © 2020 650 Industries. All rights reserved.
#import <EXUpdates/EXUpdatesAppLauncherWithDatabase.h>
#import <EXUpdates/EXUpdatesAppLoaderTask.h>
#import <EXUpdates/EXUpdatesEmbeddedAppLoader.h>
#import <EXUpdates/EXUpdatesReaper.h>
#import <EXUpdates/EXUpdatesRemoteAppLoader.h>
#import <EXUpdates/EXUpdatesUtils.h>
NS_ASSUME_NONNULL_BEGIN
static NSString * const EXUpdatesAppLoaderTaskErrorDomain = @"EXUpdatesAppLoaderTask";
@interface EXUpdatesAppLoaderTask ()
@property (nonatomic, strong) EXUpdatesConfig *config;
@property (nonatomic, strong) EXUpdatesDatabase *database;
@property (nonatomic, strong) NSURL *directory;
@property (nonatomic, strong) id<EXUpdatesSelectionPolicy> selectionPolicy;
@property (nonatomic, strong) dispatch_queue_t delegateQueue;
@property (nonatomic, strong) id<EXUpdatesAppLauncher> candidateLauncher;
@property (nonatomic, strong) id<EXUpdatesAppLauncher> finalizedLauncher;
@property (nonatomic, strong) EXUpdatesEmbeddedAppLoader *embeddedAppLoader;
@property (nonatomic, strong) EXUpdatesRemoteAppLoader *remoteAppLoader;
@property (nonatomic, strong) NSTimer *timer;
@property (nonatomic, assign) BOOL isReadyToLaunch;
@property (nonatomic, assign) BOOL isTimerFinished;
@property (nonatomic, assign) BOOL hasLaunched;
@property (nonatomic, assign) BOOL isUpToDate;
@property (nonatomic, strong) dispatch_queue_t loaderTaskQueue;
@end
@implementation EXUpdatesAppLoaderTask
- (instancetype)initWithConfig:(EXUpdatesConfig *)config
database:(EXUpdatesDatabase *)database
directory:(NSURL *)directory
selectionPolicy:(id<EXUpdatesSelectionPolicy>)selectionPolicy
delegateQueue:(dispatch_queue_t)delegateQueue
{
if (self = [super init]) {
_config = config;
_database = database;
_directory = directory;
_selectionPolicy = selectionPolicy;
_isUpToDate = NO;
_delegateQueue = delegateQueue;
_loaderTaskQueue = dispatch_queue_create("expo.loader.LoaderTaskQueue", DISPATCH_QUEUE_SERIAL);
}
return self;
}
- (void)start
{
if (!_config.isEnabled) {
dispatch_async(_delegateQueue, ^{
[self->_delegate appLoaderTask:self
didFinishWithError:[NSError errorWithDomain:EXUpdatesAppLoaderTaskErrorDomain code:1030 userInfo:@{
NSLocalizedDescriptionKey: @"EXUpdatesAppLoaderTask was passed a configuration object with updates disabled. You should load updates from an embedded source rather than calling EXUpdatesAppLoaderTask, or enable updates in the configuration."
}]];
});
return;
}
if (!_config.updateUrl) {
dispatch_async(_delegateQueue, ^{
[self->_delegate appLoaderTask:self
didFinishWithError:[NSError errorWithDomain:EXUpdatesAppLoaderTaskErrorDomain code:1030 userInfo:@{
NSLocalizedDescriptionKey: @"EXUpdatesAppLoaderTask was passed a configuration object with a null URL. You must pass a nonnull URL in order to use EXUpdatesAppLoaderTask to load updates."
}]];
});
return;
}
if (!_directory) {
dispatch_async(_delegateQueue, ^{
[self->_delegate appLoaderTask:self
didFinishWithError:[NSError errorWithDomain:EXUpdatesAppLoaderTaskErrorDomain code:1030 userInfo:@{
NSLocalizedDescriptionKey: @"EXUpdatesAppLoaderTask directory must be nonnull."
}]];
});
return;
}
__block BOOL shouldCheckForUpdate = [EXUpdatesUtils shouldCheckForUpdateWithConfig:_config];
NSNumber *launchWaitMs = _config.launchWaitMs;
if ([launchWaitMs isEqualToNumber:@(0)] || !shouldCheckForUpdate) {
self->_isTimerFinished = YES;
} else {
NSDate *fireDate = [NSDate dateWithTimeIntervalSinceNow:[launchWaitMs doubleValue] / 1000];
self->_timer = [[NSTimer alloc] initWithFireDate:fireDate interval:0 target:self selector:@selector(_timerDidFire) userInfo:nil repeats:NO];
[[NSRunLoop mainRunLoop] addTimer:self->_timer forMode:NSDefaultRunLoopMode];
}
[self _loadEmbeddedUpdateWithCompletion:^{
[self _launchWithCompletion:^(NSError * _Nullable error, BOOL success) {
if (!success) {
if (!shouldCheckForUpdate){
[self _finishWithError:error];
}
NSLog(@"Failed to launch embedded or launchable update: %@", error.localizedDescription);
} else {
if (self->_delegate &&
![self->_delegate appLoaderTask:self didLoadCachedUpdate:self->_candidateLauncher.launchedUpdate]) {
// ignore timer and other settings and force launch a remote update.
self->_candidateLauncher = nil;
[self _stopTimer];
shouldCheckForUpdate = YES;
} else {
self->_isReadyToLaunch = YES;
[self _maybeFinish];
}
}
if (shouldCheckForUpdate) {
[self _loadRemoteUpdateWithCompletion:^(NSError * _Nullable error, EXUpdatesUpdate * _Nullable update) {
[self _handleRemoteUpdateLoaded:update error:error];
}];
} else {
[self _runReaper];
}
}];
}];
}
- (void)_finishWithError:(nullable NSError *)error
{
dispatch_assert_queue(_loaderTaskQueue);
if (_hasLaunched) {
// we've already fired once, don't do it again
return;
}
_hasLaunched = YES;
_finalizedLauncher = _candidateLauncher;
if (_delegate) {
dispatch_async(_delegateQueue, ^{
if (self->_isReadyToLaunch && (self->_finalizedLauncher.launchAssetUrl || self->_finalizedLauncher.launchedUpdate.status == EXUpdatesUpdateStatusDevelopment)) {
[self->_delegate appLoaderTask:self didFinishWithLauncher:self->_finalizedLauncher isUpToDate:self->_isUpToDate];
} else {
[self->_delegate appLoaderTask:self didFinishWithError:error ?: [NSError errorWithDomain:EXUpdatesAppLoaderTaskErrorDomain code:1031 userInfo:@{
NSLocalizedDescriptionKey: @"EXUpdatesAppLoaderTask encountered an unexpected error and could not launch an update."
}]];
}
});
}
[self _stopTimer];
}
- (void)_maybeFinish
{
if (!_isTimerFinished || !_isReadyToLaunch) {
// too early, bail out
return;
}
[self _finishWithError:nil];
}
- (void)_timerDidFire
{
dispatch_async(_loaderTaskQueue, ^{
self->_isTimerFinished = YES;
[self _maybeFinish];
});
}
- (void)_stopTimer
{
if (_timer) {
[_timer invalidate];
_timer = nil;
}
_isTimerFinished = YES;
}
- (void)_runReaper
{
if (_finalizedLauncher.launchedUpdate) {
[EXUpdatesReaper reapUnusedUpdatesWithConfig:_config
database:_database
directory:_directory
selectionPolicy:_selectionPolicy
launchedUpdate:_finalizedLauncher.launchedUpdate];
}
}
- (void)_loadEmbeddedUpdateWithCompletion:(void (^)(void))completion
{
[EXUpdatesAppLauncherWithDatabase launchableUpdateWithConfig:_config database:_database selectionPolicy:_selectionPolicy completion:^(NSError * _Nullable error, EXUpdatesUpdate * _Nullable launchableUpdate) {
if (self->_config.hasEmbeddedUpdate &&
[self->_selectionPolicy shouldLoadNewUpdate:[EXUpdatesEmbeddedAppLoader embeddedManifestWithConfig:self->_config database:self->_database]
withLaunchedUpdate:launchableUpdate]) {
self->_embeddedAppLoader = [[EXUpdatesEmbeddedAppLoader alloc] initWithConfig:self->_config database:self->_database directory:self->_directory completionQueue:self->_loaderTaskQueue];
[self->_embeddedAppLoader loadUpdateFromEmbeddedManifestWithCallback:^BOOL(EXUpdatesUpdate * _Nonnull update) {
// we already checked using selection policy, so we don't need to check again
return YES;
} success:^(EXUpdatesUpdate * _Nullable update) {
completion();
} error:^(NSError * _Nonnull error) {
completion();
}];
} else {
completion();
}
} completionQueue:_loaderTaskQueue];
}
- (void)_launchWithCompletion:(void (^)(NSError * _Nullable error, BOOL success))completion
{
EXUpdatesAppLauncherWithDatabase *launcher = [[EXUpdatesAppLauncherWithDatabase alloc] initWithConfig:_config database:_database directory:_directory completionQueue:_loaderTaskQueue];
_candidateLauncher = launcher;
[launcher launchUpdateWithSelectionPolicy:_selectionPolicy completion:completion];
}
- (void)_loadRemoteUpdateWithCompletion:(void (^)(NSError * _Nullable error, EXUpdatesUpdate * _Nullable update))completion
{
_remoteAppLoader = [[EXUpdatesRemoteAppLoader alloc] initWithConfig:_config database:_database directory:_directory completionQueue:_loaderTaskQueue];
[_remoteAppLoader loadUpdateFromUrl:_config.updateUrl onManifest:^BOOL(EXUpdatesUpdate * _Nonnull update) {
if ([self->_selectionPolicy shouldLoadNewUpdate:update withLaunchedUpdate:self->_candidateLauncher.launchedUpdate]) {
self->_isUpToDate = NO;
if (self->_delegate) {
dispatch_async(self->_delegateQueue, ^{
[self->_delegate appLoaderTask:self didStartLoadingUpdate:update];
});
}
return YES;
} else {
self->_isUpToDate = YES;
return NO;
}
} success:^(EXUpdatesUpdate * _Nullable update) {
completion(nil, update);
} error:^(NSError *error) {
completion(error, nil);
}];
}
- (void)_handleRemoteUpdateLoaded:(nullable EXUpdatesUpdate *)update error:(nullable NSError *)error
{
// If the app has not yet been launched (because the timer is still running),
// create a new launcher so that we can launch with the newly downloaded update.
// Otherwise, we've already launched. Send an event to the notify JS of the new update.
dispatch_async(_loaderTaskQueue, ^{
[self _stopTimer];
if (update) {
if (!self->_hasLaunched) {
EXUpdatesAppLauncherWithDatabase *newLauncher = [[EXUpdatesAppLauncherWithDatabase alloc] initWithConfig:self->_config database:self->_database directory:self->_directory completionQueue:self->_loaderTaskQueue];
[newLauncher launchUpdateWithSelectionPolicy:self->_selectionPolicy completion:^(NSError * _Nullable error, BOOL success) {
if (success) {
if (!self->_hasLaunched) {
self->_candidateLauncher = newLauncher;
self->_isReadyToLaunch = YES;
self->_isUpToDate = YES;
[self _finishWithError:nil];
}
} else {
[self _finishWithError:error];
NSLog(@"Downloaded update but failed to relaunch: %@", error.localizedDescription);
}
[self _runReaper];
}];
} else {
[self _didFinishBackgroundUpdateWithStatus:EXUpdatesBackgroundUpdateStatusUpdateAvailable manifest:update error:nil];
[self _runReaper];
}
} else {
// there's no update, so signal we're ready to launch
[self _finishWithError:error];
if (error) {
[self _didFinishBackgroundUpdateWithStatus:EXUpdatesBackgroundUpdateStatusError manifest:nil error:error];
} else {
[self _didFinishBackgroundUpdateWithStatus:EXUpdatesBackgroundUpdateStatusNoUpdateAvailable manifest:nil error:nil];
}
[self _runReaper];
}
});
}
- (void)_didFinishBackgroundUpdateWithStatus:(EXUpdatesBackgroundUpdateStatus)status manifest:(nullable EXUpdatesUpdate *)manifest error:(nullable NSError *)error
{
if (_delegate) {
dispatch_async(_delegateQueue, ^{
[self->_delegate appLoaderTask:self didFinishBackgroundUpdateWithStatus:status update:manifest error:error];
});
}
}
@end
NS_ASSUME_NONNULL_END

View File

@ -0,0 +1,32 @@
// Copyright © 2019 650 Industries. All rights reserved.
#import <Foundation/Foundation.h>
NS_ASSUME_NONNULL_BEGIN
@interface EXUpdatesAsset : NSObject
/**
* properties determined by asset source
*/
@property (nonatomic, strong) NSString *key;
@property (nonatomic, strong) NSString *type;
@property (nullable, nonatomic, strong) NSURL *url;
@property (nullable, nonatomic, strong) NSDictionary *metadata;
@property (nullable, nonatomic, strong) NSString *mainBundleDir; // used for embedded assets
@property (nullable, nonatomic, strong) NSString *mainBundleFilename; // used for embedded assets
@property (nonatomic, assign) BOOL isLaunchAsset;
/**
* properties determined at runtime by updates implementation
*/
@property (nullable, nonatomic, strong) NSDate *downloadTime;
@property (nullable, nonatomic, strong) NSString *filename;
@property (nullable, nonatomic, strong) NSString *contentHash;
@property (nullable, nonatomic, strong) NSDictionary *headers;
- (instancetype)initWithKey:(NSString *)key type:(NSString *)type;
@end
NS_ASSUME_NONNULL_END

View File

@ -0,0 +1,26 @@
// Copyright © 2019 650 Industries. All rights reserved.
#import <EXUpdates/EXUpdatesAsset.h>
#import <EXUpdates/EXUpdatesUtils.h>
NS_ASSUME_NONNULL_BEGIN
@implementation EXUpdatesAsset
- (instancetype)initWithKey:(NSString *)key type:(NSString *)type
{
if (self = [super init]) {
_key = key;
_type = type;
}
return self;
}
- (nullable NSString *)filename
{
return _key;
}
@end
NS_ASSUME_NONNULL_END

View File

@ -0,0 +1,21 @@
// Copyright © 2019 650 Industries. All rights reserved.
#import <EXUpdates/EXUpdatesConfig.h>
NS_ASSUME_NONNULL_BEGIN
typedef void (^EXUpdatesVerifySignatureSuccessBlock)(BOOL isValid);
typedef void (^EXUpdatesVerifySignatureErrorBlock)(NSError *error);
@interface EXUpdatesCrypto : NSObject
+ (void)verifySignatureWithData:(NSString *)data
signature:(NSString *)signature
config:(EXUpdatesConfig *)config
cacheDirectory:(NSURL *)cacheDirectory
successBlock:(EXUpdatesVerifySignatureSuccessBlock)successBlock
errorBlock:(EXUpdatesVerifySignatureErrorBlock)errorBlock;
@end
NS_ASSUME_NONNULL_END

View File

@ -0,0 +1,217 @@
// Copyright 2019-present 650 Industries. All rights reserved.
#import <CommonCrypto/CommonDigest.h>
#import <EXUpdates/EXUpdatesCrypto.h>
#import <EXUpdates/EXUpdatesFileDownloader.h>
NS_ASSUME_NONNULL_BEGIN
static NSString * const EXUpdatesCryptoPublicKeyUrl = @"https://exp.host/--/manifest-public-key";
static NSString * const EXUpdatesCryptoPublicKeyTag = @"exp.host.publickey";
static NSString * const EXUpdatesCryptoPublicKeyFilename = @"manifestPublicKey.pem";
@implementation EXUpdatesCrypto
+ (void)verifySignatureWithData:(NSString *)data
signature:(NSString *)signature
config:(EXUpdatesConfig *)config
cacheDirectory:(NSURL *)cacheDirectory
successBlock:(EXUpdatesVerifySignatureSuccessBlock)successBlock
errorBlock:(EXUpdatesVerifySignatureErrorBlock)errorBlock
{
[self fetchAndVerifySignatureWithData:data
signature:signature
config:config
cacheDirectory:cacheDirectory
useCache:YES
successBlock:successBlock
errorBlock:errorBlock];
}
+ (void)fetchAndVerifySignatureWithData:(NSString *)data
signature:(NSString *)signature
config:(EXUpdatesConfig *)config
cacheDirectory:(NSURL *)cacheDirectory
useCache:(BOOL)useCache
successBlock:(EXUpdatesVerifySignatureSuccessBlock)successBlock
errorBlock:(EXUpdatesVerifySignatureErrorBlock)errorBlock
{
if (!data || !signature) {
errorBlock([NSError errorWithDomain:@"EXUpdatesCrypto" code:1001 userInfo:@{ NSLocalizedDescriptionKey: @"Cannot verify the manifest because it is empty or has no signature." }]);
return;
}
NSURL *cachedPublicKeyUrl = [cacheDirectory URLByAppendingPathComponent:EXUpdatesCryptoPublicKeyFilename];
if (useCache) {
NSData *publicKeyData = [NSData dataWithContentsOfFile:[cachedPublicKeyUrl absoluteString]];
[[self class] verifyWithPublicKey:publicKeyData signature:signature signedString:data callback:^(BOOL isValid) {
if (isValid) {
successBlock(isValid);
} else {
[[self class] fetchAndVerifySignatureWithData:data
signature:signature
config:config
cacheDirectory:cacheDirectory
useCache:NO
successBlock:successBlock
errorBlock:errorBlock];
}
}];
} else {
NSURLSessionConfiguration *configuration = NSURLSessionConfiguration.defaultSessionConfiguration;
configuration.requestCachePolicy = NSURLRequestReloadIgnoringCacheData;
EXUpdatesFileDownloader *fileDownloader = [[EXUpdatesFileDownloader alloc] initWithUpdatesConfig:config URLSessionConfiguration:configuration];
[fileDownloader downloadFileFromURL:[NSURL URLWithString:EXUpdatesCryptoPublicKeyUrl]
toPath:[cachedPublicKeyUrl path]
successBlock:^(NSData *publicKeyData, NSURLResponse *response) {
[[self class] verifyWithPublicKey:publicKeyData signature:signature signedString:data callback:successBlock];
}
errorBlock:^(NSError *error, NSURLResponse *response) {
errorBlock(error);
}
];
}
}
+ (void)verifyWithPublicKey:(NSData *)publicKeyData
signature:(NSString *)signature
signedString:(NSString *)signedString
callback:(EXUpdatesVerifySignatureSuccessBlock)callback
{
if (!publicKeyData) {
callback(NO);
} else {
dispatch_async(dispatch_get_main_queue(), ^{
SecKeyRef publicKey = [self keyRefFromPEMData:publicKeyData];
NSData *signatureData = [[NSData alloc] initWithBase64EncodedString:signature options:0];
NSData *signedData = [signedString dataUsingEncoding:NSUTF8StringEncoding];
BOOL isValid = NO;
if (publicKey) {
isValid = [self verifyRSASHA256SignedData:signedData signatureData:signatureData publicKey:publicKey];
CFRelease(publicKey);
}
callback(isValid);
});
}
}
/**
* Returns a CFRef to a SecKey given the raw pem data.
* The CFRef should be CFReleased when you're finished.
*
* Here is the Apple doc for this black hole:
* https://developer.apple.com/library/prerelease/content/documentation/Security/Conceptual/CertKeyTrustProgGuide/iPhone_Tasks/iPhone_Tasks.html#//apple_ref/doc/uid/TP40001358-CH208-SW13
*/
+ (nullable SecKeyRef)keyRefFromPEMData:(NSData *)pemData
{
NSString *pemString = [[NSString alloc] initWithData:pemData encoding:NSUTF8StringEncoding];
NSString *key = [NSString string];
NSArray<NSString *> *keyLines = [pemString componentsSeparatedByString:@"\n"];
BOOL foundKey = NO;
for (NSString *line in keyLines) {
if ([line isEqualToString:@"-----BEGIN PUBLIC KEY-----"]) {
foundKey = YES;
} else if ([line isEqualToString:@"-----END PUBLIC KEY-----"]) {
foundKey = NO;
} else if (foundKey) {
key = [key stringByAppendingString:line];
}
}
if (key.length == 0) {
return nil;
}
NSData *keyData = [[NSData alloc] initWithBase64EncodedString:key options:0];
if (keyData == nil) {
return nil;
}
NSData *tag = [NSData dataWithBytes:[EXUpdatesCryptoPublicKeyTag UTF8String] length:[EXUpdatesCryptoPublicKeyTag length]];
// Delete any old lingering key with the same tag.
NSDictionary *deleteParams = @{
(__bridge id)kSecClass: (__bridge id)kSecClassKey,
(__bridge id)kSecAttrKeyType: (__bridge id)kSecAttrKeyTypeRSA,
(__bridge id)kSecAttrApplicationTag: tag,
};
OSStatus secStatus = SecItemDelete((CFDictionaryRef)deleteParams);
SecKeyRef savedKeyRef = nil;
// Add key to system keychain.
NSDictionary *saveParams = @{
(__bridge id)kSecClass: (__bridge id) kSecClassKey,
(__bridge id)kSecAttrKeyType: (__bridge id) kSecAttrKeyTypeRSA,
(__bridge id)kSecAttrApplicationTag: tag,
(__bridge id)kSecAttrKeyClass: (__bridge id) kSecAttrKeyClassPublic,
(__bridge id)kSecReturnPersistentRef: (__bridge id)kCFBooleanTrue,
(__bridge id)kSecValueData: keyData,
(__bridge id)kSecAttrKeySizeInBits: [NSNumber numberWithUnsignedInteger:keyData.length],
(__bridge id)kSecAttrEffectiveKeySize: [NSNumber numberWithUnsignedInteger:keyData.length],
(__bridge id)kSecAttrCanDerive: (__bridge id) kCFBooleanFalse,
(__bridge id)kSecAttrCanEncrypt: (__bridge id) kCFBooleanTrue,
(__bridge id)kSecAttrCanDecrypt: (__bridge id) kCFBooleanFalse,
(__bridge id)kSecAttrCanVerify: (__bridge id) kCFBooleanTrue,
(__bridge id)kSecAttrCanSign: (__bridge id) kCFBooleanFalse,
(__bridge id)kSecAttrCanWrap: (__bridge id) kCFBooleanTrue,
(__bridge id)kSecAttrCanUnwrap: (__bridge id) kCFBooleanFalse,
};
secStatus = SecItemAdd((CFDictionaryRef)saveParams, (CFTypeRef *)&savedKeyRef);
if (savedKeyRef != nil) {
CFRelease(savedKeyRef);
}
if (secStatus != noErr && secStatus != errSecDuplicateItem) {
return nil;
}
// Fetch the SecKeyRef version of the key.
// note that kSecAttrKeyClass: kSecAttrKeyClassPublic doesn't seem to be required here.
// also: this doesn't work on iOS < 10.0
SecKeyRef keyRef = nil;
NSDictionary *queryParams = @{
(__bridge id)kSecClass: (__bridge id) kSecClassKey,
(__bridge id)kSecAttrKeyType: (__bridge id) kSecAttrKeyTypeRSA,
(__bridge id)kSecAttrApplicationTag: tag,
(__bridge id)kSecReturnRef: (__bridge id) kCFBooleanTrue,
};
secStatus = SecItemCopyMatching((CFDictionaryRef)queryParams, (CFTypeRef *)&keyRef);
if (secStatus != noErr) {
return nil;
}
return keyRef;
}
+ (BOOL)verifyRSASHA256SignedData:(NSData *)signedData signatureData:(NSData *)signatureData publicKey:(nullable SecKeyRef)publicKey
{
if (!publicKey) {
return NO;
}
uint8_t hashBytes[CC_SHA256_DIGEST_LENGTH];
if (!CC_SHA256([signedData bytes], (CC_LONG)[signedData length], hashBytes)) {
return NO;
}
OSStatus status = SecKeyRawVerify(publicKey,
kSecPaddingPKCS1SHA256,
hashBytes,
CC_SHA256_DIGEST_LENGTH,
[signatureData bytes],
[signatureData length]);
return status == errSecSuccess;
}
@end
NS_ASSUME_NONNULL_END

View File

@ -0,0 +1,25 @@
// Copyright © 2019 650 Industries. All rights reserved.
#import <EXUpdates/EXUpdatesAppLoader+Private.h>
NS_ASSUME_NONNULL_BEGIN
extern NSString * const EXUpdatesEmbeddedManifestName;
extern NSString * const EXUpdatesEmbeddedManifestType;
extern NSString * const EXUpdatesEmbeddedBundleFilename;
extern NSString * const EXUpdatesEmbeddedBundleFileType;
extern NSString * const EXUpdatesBareEmbeddedBundleFilename;
extern NSString * const EXUpdatesBareEmbeddedBundleFileType;
@interface EXUpdatesEmbeddedAppLoader : EXUpdatesAppLoader
+ (nullable EXUpdatesUpdate *)embeddedManifestWithConfig:(EXUpdatesConfig *)config
database:(nullable EXUpdatesDatabase *)database;
- (void)loadUpdateFromEmbeddedManifestWithCallback:(EXUpdatesAppLoaderManifestBlock)manifestBlock
success:(EXUpdatesAppLoaderSuccessBlock)success
error:(EXUpdatesAppLoaderErrorBlock)error;
@end
NS_ASSUME_NONNULL_END

View File

@ -0,0 +1,124 @@
// Copyright © 2019 650 Industries. All rights reserved.
#import <EXUpdates/EXUpdatesFileDownloader.h>
#import <EXUpdates/EXUpdatesEmbeddedAppLoader.h>
NS_ASSUME_NONNULL_BEGIN
NSString * const EXUpdatesEmbeddedManifestName = @"app";
NSString * const EXUpdatesEmbeddedManifestType = @"manifest";
NSString * const EXUpdatesEmbeddedBundleFilename = @"app";
NSString * const EXUpdatesEmbeddedBundleFileType = @"bundle";
NSString * const EXUpdatesBareEmbeddedBundleFilename = @"main";
NSString * const EXUpdatesBareEmbeddedBundleFileType = @"jsbundle";
static NSString * const EXUpdatesEmbeddedAppLoaderErrorDomain = @"EXUpdatesEmbeddedAppLoader";
@implementation EXUpdatesEmbeddedAppLoader
+ (nullable EXUpdatesUpdate *)embeddedManifestWithConfig:(EXUpdatesConfig *)config
database:(nullable EXUpdatesDatabase *)database
{
static EXUpdatesUpdate *embeddedManifest;
static dispatch_once_t once;
dispatch_once(&once, ^{
if (!config.hasEmbeddedUpdate) {
embeddedManifest = nil;
} else if (!embeddedManifest) {
NSString *path = [[NSBundle mainBundle] pathForResource:EXUpdatesEmbeddedManifestName ofType:EXUpdatesEmbeddedManifestType];
NSData *manifestData = [NSData dataWithContentsOfFile:path];
if (!manifestData) {
@throw [NSException exceptionWithName:NSInternalInconsistencyException
reason:@"The embedded manifest is invalid or could not be read. Make sure you have configured expo-updates correctly in your Xcode Build Phases."
userInfo:@{}];
}
NSError *err;
id manifest = [NSJSONSerialization JSONObjectWithData:manifestData options:kNilOptions error:&err];
if (!manifest) {
@throw [NSException exceptionWithName:NSInternalInconsistencyException
reason:@"The embedded manifest is invalid or could not be read. Make sure you have configured expo-updates correctly in your Xcode Build Phases."
userInfo:@{}];
} else {
NSAssert([manifest isKindOfClass:[NSDictionary class]], @"embedded manifest should be a valid JSON file");
NSMutableDictionary *mutableManifest = [manifest mutableCopy];
// automatically verify embedded manifest since it was already codesigned
mutableManifest[@"isVerified"] = @(YES);
embeddedManifest = [EXUpdatesUpdate updateWithEmbeddedManifest:[mutableManifest copy]
config:config
database:database];
if (!embeddedManifest.updateId) {
@throw [NSException exceptionWithName:NSInternalInconsistencyException
reason:@"The embedded manifest is invalid. Make sure you have configured expo-updates correctly in your Xcode Build Phases."
userInfo:@{}];
}
}
}
});
return embeddedManifest;
}
- (void)loadUpdateFromEmbeddedManifestWithCallback:(EXUpdatesAppLoaderManifestBlock)manifestBlock
success:(EXUpdatesAppLoaderSuccessBlock)success
error:(EXUpdatesAppLoaderErrorBlock)error
{
EXUpdatesUpdate *embeddedManifest = [[self class] embeddedManifestWithConfig:self.config
database:self.database];
if (embeddedManifest) {
self.manifestBlock = manifestBlock;
self.successBlock = success;
self.errorBlock = error;
[self startLoadingFromManifest:embeddedManifest];
} else {
error([NSError errorWithDomain:EXUpdatesEmbeddedAppLoaderErrorDomain
code:1008
userInfo:@{NSLocalizedDescriptionKey: @"Failed to load embedded manifest. Make sure you have configured expo-updates correctly."}]);
}
}
- (void)downloadAsset:(EXUpdatesAsset *)asset
{
NSURL *destinationUrl = [self.directory URLByAppendingPathComponent:asset.filename];
dispatch_async([EXUpdatesFileDownloader assetFilesQueue], ^{
if ([[NSFileManager defaultManager] fileExistsAtPath:[destinationUrl path]]) {
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
[self handleAssetDownloadAlreadyExists:asset];
});
} else {
NSAssert(asset.mainBundleFilename, @"embedded asset mainBundleFilename must be nonnull");
NSString *bundlePath = asset.mainBundleDir
? [[NSBundle mainBundle] pathForResource:asset.mainBundleFilename ofType:asset.type inDirectory:asset.mainBundleDir]
: [[NSBundle mainBundle] pathForResource:asset.mainBundleFilename ofType:asset.type];
NSAssert(bundlePath, @"NSBundle must contain the expected assets");
if (!bundlePath) {
@throw [NSException exceptionWithName:NSInternalInconsistencyException
reason:[NSString stringWithFormat:@"Could not find the expected embedded asset %@.%@. Check that expo-updates is installed correctly.", asset.mainBundleFilename, asset.type]
userInfo:nil];
}
NSError *err;
if ([[NSFileManager defaultManager] copyItemAtPath:bundlePath toPath:[destinationUrl path] error:&err]) {
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
[self handleAssetDownloadWithData:[NSData dataWithContentsOfFile:bundlePath] response:nil asset:asset];
});
} else {
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
[self handleAssetDownloadWithError:err asset:asset];
});
}
}
});
}
- (void)loadUpdateFromUrl:(NSURL *)url
success:(EXUpdatesAppLoaderSuccessBlock)success
error:(EXUpdatesAppLoaderErrorBlock)error
{
@throw [NSException exceptionWithName:NSInternalInconsistencyException reason:@"Should not call EXUpdatesEmbeddedAppLoader#loadUpdateFromUrl" userInfo:nil];
}
@end
NS_ASSUME_NONNULL_END

View File

@ -0,0 +1,37 @@
// Copyright © 2019 650 Industries. All rights reserved.
#import <EXUpdates/EXUpdatesConfig.h>
#import <EXUpdates/EXUpdatesUpdate.h>
NS_ASSUME_NONNULL_BEGIN
typedef void (^EXUpdatesFileDownloaderSuccessBlock)(NSData *data, NSURLResponse *response);
typedef void (^EXUpdatesFileDownloaderManifestSuccessBlock)(EXUpdatesUpdate *update);
typedef void (^EXUpdatesFileDownloaderErrorBlock)(NSError *error, NSURLResponse *response);
@interface EXUpdatesFileDownloader : NSObject
- (instancetype)initWithUpdatesConfig:(EXUpdatesConfig *)updatesConfig;
- (instancetype)initWithUpdatesConfig:(EXUpdatesConfig *)updatesConfig
URLSessionConfiguration:(NSURLSessionConfiguration *)sessionConfiguration;
- (void)downloadDataFromURL:(NSURL *)url
successBlock:(EXUpdatesFileDownloaderSuccessBlock)successBlock
errorBlock:(EXUpdatesFileDownloaderErrorBlock)errorBlock;
- (void)downloadFileFromURL:(NSURL *)url
toPath:(NSString *)destinationPath
successBlock:(EXUpdatesFileDownloaderSuccessBlock)successBlock
errorBlock:(EXUpdatesFileDownloaderErrorBlock)errorBlock;
- (void)downloadManifestFromURL:(NSURL *)url
withDatabase:(EXUpdatesDatabase *)database
cacheDirectory:(NSURL *)cacheDirectory
successBlock:(EXUpdatesFileDownloaderManifestSuccessBlock)successBlock
errorBlock:(EXUpdatesFileDownloaderErrorBlock)errorBlock;
+ (dispatch_queue_t)assetFilesQueue;
@end
NS_ASSUME_NONNULL_END

View File

@ -0,0 +1,291 @@
// Copyright © 2019 650 Industries. All rights reserved.
#import <EXUpdates/EXUpdatesAppLauncherNoDatabase.h>
#import <EXUpdates/EXUpdatesCrypto.h>
#import <EXUpdates/EXUpdatesFileDownloader.h>
NS_ASSUME_NONNULL_BEGIN
NSString * const EXUpdatesFileDownloaderErrorDomain = @"EXUpdatesFileDownloader";
NSTimeInterval const EXUpdatesDefaultTimeoutInterval = 60;
@interface EXUpdatesFileDownloader () <NSURLSessionDataDelegate>
@property (nonatomic, strong) NSURLSession *session;
@property (nonatomic, strong) NSURLSessionConfiguration *sessionConfiguration;
@property (nonatomic, strong) EXUpdatesConfig *config;
@end
@implementation EXUpdatesFileDownloader
- (instancetype)initWithUpdatesConfig:(EXUpdatesConfig *)updatesConfig
{
return [self initWithUpdatesConfig:updatesConfig
URLSessionConfiguration:NSURLSessionConfiguration.defaultSessionConfiguration];
}
- (instancetype)initWithUpdatesConfig:(EXUpdatesConfig *)updatesConfig
URLSessionConfiguration:(NSURLSessionConfiguration *)sessionConfiguration
{
if (self = [super init]) {
_sessionConfiguration = sessionConfiguration;
_session = [NSURLSession sessionWithConfiguration:_sessionConfiguration delegate:self delegateQueue:nil];
_config = updatesConfig;
}
return self;
}
- (void)dealloc
{
[_session finishTasksAndInvalidate];
}
+ (dispatch_queue_t)assetFilesQueue
{
static dispatch_queue_t theQueue;
static dispatch_once_t once;
dispatch_once(&once, ^{
if (!theQueue) {
theQueue = dispatch_queue_create("expo.controller.AssetFilesQueue", DISPATCH_QUEUE_SERIAL);
}
});
return theQueue;
}
- (void)downloadFileFromURL:(NSURL *)url
toPath:(NSString *)destinationPath
successBlock:(EXUpdatesFileDownloaderSuccessBlock)successBlock
errorBlock:(EXUpdatesFileDownloaderErrorBlock)errorBlock
{
[self downloadDataFromURL:url successBlock:^(NSData *data, NSURLResponse *response) {
NSError *error;
if ([data writeToFile:destinationPath options:NSDataWritingAtomic error:&error]) {
successBlock(data, response);
} else {
errorBlock([NSError errorWithDomain:EXUpdatesFileDownloaderErrorDomain
code:1002
userInfo:@{
NSLocalizedDescriptionKey: [NSString stringWithFormat:@"Could not write to path %@: %@", destinationPath, error.localizedDescription],
NSUnderlyingErrorKey: error
}
], response);
}
} errorBlock:errorBlock];
}
- (void)downloadManifestFromURL:(NSURL *)url
withDatabase:(EXUpdatesDatabase *)database
cacheDirectory:(NSURL *)cacheDirectory
successBlock:(EXUpdatesFileDownloaderManifestSuccessBlock)successBlock
errorBlock:(EXUpdatesFileDownloaderErrorBlock)errorBlock
{
NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:url
cachePolicy:NSURLRequestReloadIgnoringCacheData
timeoutInterval:EXUpdatesDefaultTimeoutInterval];
[self _setManifestHTTPHeaderFields:request];
[self _downloadDataWithRequest:request successBlock:^(NSData *data, NSURLResponse *response) {
NSError *err;
id parsedJson = [NSJSONSerialization JSONObjectWithData:data options:kNilOptions error:&err];
if (err) {
errorBlock(err, response);
return;
}
NSDictionary *manifest = [self _extractManifest:parsedJson error:&err];
if (err) {
errorBlock(err, response);
return;
}
id innerManifestString = manifest[@"manifestString"];
id signature = manifest[@"signature"];
BOOL isSigned = innerManifestString != nil && signature != nil;
// XDL serves unsigned manifests with the `signature` key set to "UNSIGNED".
// We should treat these manifests as unsigned rather than signed with an invalid signature.
if (isSigned && [signature isKindOfClass:[NSString class]] && [(NSString *)signature isEqualToString:@"UNSIGNED"]) {
isSigned = NO;
NSError *err;
manifest = [NSJSONSerialization JSONObjectWithData:[(NSString *)innerManifestString dataUsingEncoding:NSUTF8StringEncoding] options:kNilOptions error:&err];
NSAssert(!err && manifest && [manifest isKindOfClass:[NSDictionary class]], @"manifest should be a valid JSON object");
NSMutableDictionary *mutableManifest = [manifest mutableCopy];
mutableManifest[@"isVerified"] = @(NO);
manifest = [mutableManifest copy];
}
if (isSigned) {
NSAssert([innerManifestString isKindOfClass:[NSString class]], @"manifestString should be a string");
NSAssert([signature isKindOfClass:[NSString class]], @"signature should be a string");
[EXUpdatesCrypto verifySignatureWithData:(NSString *)innerManifestString
signature:(NSString *)signature
config:self->_config
cacheDirectory:cacheDirectory
successBlock:^(BOOL isValid) {
if (isValid) {
NSError *err;
id innerManifest = [NSJSONSerialization JSONObjectWithData:[(NSString *)innerManifestString dataUsingEncoding:NSUTF8StringEncoding] options:kNilOptions error:&err];
NSAssert(!err && innerManifest && [innerManifest isKindOfClass:[NSDictionary class]], @"manifest should be a valid JSON object");
NSMutableDictionary *mutableInnerManifest = [(NSDictionary *)innerManifest mutableCopy];
mutableInnerManifest[@"isVerified"] = @(YES);
EXUpdatesUpdate *update = [EXUpdatesUpdate updateWithManifest:[mutableInnerManifest copy]
config:self->_config
database:database];
successBlock(update);
} else {
NSError *error = [NSError errorWithDomain:EXUpdatesFileDownloaderErrorDomain code:1003 userInfo:@{NSLocalizedDescriptionKey: @"Manifest verification failed"}];
errorBlock(error, response);
}
}
errorBlock:^(NSError *error) {
errorBlock(error, response);
}
];
} else {
EXUpdatesUpdate *update = [EXUpdatesUpdate updateWithManifest:(NSDictionary *)manifest
config:self->_config
database:database];
successBlock(update);
}
} errorBlock:errorBlock];
}
- (void)downloadDataFromURL:(NSURL *)url
successBlock:(EXUpdatesFileDownloaderSuccessBlock)successBlock
errorBlock:(EXUpdatesFileDownloaderErrorBlock)errorBlock
{
// pass any custom cache policy onto this specific request
NSURLRequestCachePolicy cachePolicy = _sessionConfiguration ? _sessionConfiguration.requestCachePolicy : NSURLRequestUseProtocolCachePolicy;
NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:url cachePolicy:cachePolicy timeoutInterval:EXUpdatesDefaultTimeoutInterval];
[self _setHTTPHeaderFields:request];
[self _downloadDataWithRequest:request successBlock:successBlock errorBlock:errorBlock];
}
- (void)_downloadDataWithRequest:(NSURLRequest *)request
successBlock:(EXUpdatesFileDownloaderSuccessBlock)successBlock
errorBlock:(EXUpdatesFileDownloaderErrorBlock)errorBlock
{
NSURLSessionDataTask *task = [_session dataTaskWithRequest:request completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) {
if (!error && [response isKindOfClass:[NSHTTPURLResponse class]]) {
NSHTTPURLResponse *httpResponse = (NSHTTPURLResponse *)response;
if (httpResponse.statusCode != 200) {
NSStringEncoding encoding = [self _encodingFromResponse:response];
NSString *body = [[NSString alloc] initWithData:data encoding:encoding];
error = [self _errorFromResponse:httpResponse body:body];
}
}
if (error) {
errorBlock(error, response);
} else {
successBlock(data, response);
}
}];
[task resume];
}
- (nullable NSDictionary *)_extractManifest:(id)parsedJson error:(NSError **)error
{
if ([parsedJson isKindOfClass:[NSDictionary class]]) {
return (NSDictionary *)parsedJson;
} else if ([parsedJson isKindOfClass:[NSArray class]]) {
// TODO: either add support for runtimeVersion or deprecate multi-manifests
for (id providedManifest in (NSArray *)parsedJson) {
if ([providedManifest isKindOfClass:[NSDictionary class]] && providedManifest[@"sdkVersion"]){
NSString *sdkVersion = providedManifest[@"sdkVersion"];
NSArray<NSString *> *supportedSdkVersions = [_config.sdkVersion componentsSeparatedByString:@","];
if ([supportedSdkVersions containsObject:sdkVersion]){
return providedManifest;
}
}
}
}
if (error) {
*error = [NSError errorWithDomain:EXUpdatesFileDownloaderErrorDomain code:1009 userInfo:@{NSLocalizedDescriptionKey: [NSString stringWithFormat:@"No compatible update found at %@. Only %@ are supported.", _config.updateUrl.absoluteString, _config.sdkVersion]}];
}
return nil;
}
- (void)_setHTTPHeaderFields:(NSMutableURLRequest *)request
{
[request setValue:@"ios" forHTTPHeaderField:@"Expo-Platform"];
[request setValue:@"1" forHTTPHeaderField:@"Expo-Api-Version"];
[request setValue:@"BARE" forHTTPHeaderField:@"Expo-Updates-Environment"];
for (NSString *key in _config.requestHeaders) {
[request setValue:_config.requestHeaders[key] forHTTPHeaderField:key];
}
}
- (void)_setManifestHTTPHeaderFields:(NSMutableURLRequest *)request
{
[request setValue:@"application/expo+json,application/json" forHTTPHeaderField:@"Accept"];
[request setValue:@"true" forHTTPHeaderField:@"Expo-JSON-Error"];
[request setValue:@"true" forHTTPHeaderField:@"Expo-Accept-Signature"];
[request setValue:_config.releaseChannel forHTTPHeaderField:@"Expo-Release-Channel"];
NSString *runtimeVersion = _config.runtimeVersion;
if (runtimeVersion) {
[request setValue:runtimeVersion forHTTPHeaderField:@"Expo-Runtime-Version"];
} else {
[request setValue:_config.sdkVersion forHTTPHeaderField:@"Expo-SDK-Version"];
}
NSString *previousFatalError = [EXUpdatesAppLauncherNoDatabase consumeError];
if (previousFatalError) {
// some servers can have max length restrictions for headers,
// so we restrict the length of the string to 1024 characters --
// this should satisfy the requirements of most servers
if ([previousFatalError length] > 1024) {
previousFatalError = [previousFatalError substringToIndex:1024];
}
[request setValue:previousFatalError forHTTPHeaderField:@"Expo-Fatal-Error"];
}
[self _setHTTPHeaderFields:request];
}
#pragma mark - NSURLSessionTaskDelegate
- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task willPerformHTTPRedirection:(NSHTTPURLResponse *)response newRequest:(NSURLRequest *)request completionHandler:(void (^)(NSURLRequest *))completionHandler
{
completionHandler(request);
}
#pragma mark - NSURLSessionDataDelegate
- (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask willCacheResponse:(NSCachedURLResponse *)proposedResponse completionHandler:(void (^)(NSCachedURLResponse *cachedResponse))completionHandler
{
completionHandler(proposedResponse);
}
#pragma mark - Parsing the response
- (NSStringEncoding)_encodingFromResponse:(NSURLResponse *)response
{
if (response.textEncodingName) {
CFStringRef cfEncodingName = (__bridge CFStringRef)response.textEncodingName;
CFStringEncoding cfEncoding = CFStringConvertIANACharSetNameToEncoding(cfEncodingName);
if (cfEncoding != kCFStringEncodingInvalidId) {
return CFStringConvertEncodingToNSStringEncoding(cfEncoding);
}
}
// Default to UTF-8
return NSUTF8StringEncoding;
}
- (NSError *)_errorFromResponse:(NSHTTPURLResponse *)response body:(NSString *)body
{
NSDictionary *userInfo = @{
NSLocalizedDescriptionKey: body,
};
return [NSError errorWithDomain:EXUpdatesFileDownloaderErrorDomain code:response.statusCode userInfo:userInfo];
}
@end
NS_ASSUME_NONNULL_END

View File

@ -0,0 +1,11 @@
// Copyright © 2019 650 Industries. All rights reserved.
#import <EXUpdates/EXUpdatesAppLoader+Private.h>
NS_ASSUME_NONNULL_BEGIN
@interface EXUpdatesRemoteAppLoader : EXUpdatesAppLoader
@end
NS_ASSUME_NONNULL_END

View File

@ -0,0 +1,77 @@
// Copyright © 2019 650 Industries. All rights reserved.
#import <EXUpdates/EXUpdatesRemoteAppLoader.h>
#import <EXUpdates/EXUpdatesCrypto.h>
#import <EXUpdates/EXUpdatesFileDownloader.h>
NS_ASSUME_NONNULL_BEGIN
@interface EXUpdatesRemoteAppLoader ()
@property (nonatomic, strong) EXUpdatesFileDownloader *downloader;
@end
static NSString * const EXUpdatesRemoteAppLoaderErrorDomain = @"EXUpdatesRemoteAppLoader";
@implementation EXUpdatesRemoteAppLoader
- (instancetype)initWithConfig:(EXUpdatesConfig *)config
database:(EXUpdatesDatabase *)database
directory:(NSURL *)directory
completionQueue:(dispatch_queue_t)completionQueue
{
if (self = [super initWithConfig:config database:database directory:directory completionQueue:completionQueue]) {
_downloader = [[EXUpdatesFileDownloader alloc] initWithUpdatesConfig:self.config];
}
return self;
}
- (void)loadUpdateFromUrl:(NSURL *)url
onManifest:(EXUpdatesAppLoaderManifestBlock)manifestBlock
success:(EXUpdatesAppLoaderSuccessBlock)success
error:(EXUpdatesAppLoaderErrorBlock)error
{
self.manifestBlock = manifestBlock;
self.successBlock = success;
self.errorBlock = error;
[_downloader downloadManifestFromURL:url withDatabase:self.database cacheDirectory:self.directory successBlock:^(EXUpdatesUpdate *update) {
[self startLoadingFromManifest:update];
} errorBlock:^(NSError *error, NSURLResponse *response) {
if (self.errorBlock) {
self.errorBlock(error);
}
}];
}
- (void)downloadAsset:(EXUpdatesAsset *)asset
{
NSURL *urlOnDisk = [self.directory URLByAppendingPathComponent:asset.filename];
dispatch_async([EXUpdatesFileDownloader assetFilesQueue], ^{
if ([[NSFileManager defaultManager] fileExistsAtPath:[urlOnDisk path]]) {
// file already exists, we don't need to download it again
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
[self handleAssetDownloadAlreadyExists:asset];
});
} else {
if (!asset.url) {
[self handleAssetDownloadWithError:[NSError errorWithDomain:EXUpdatesRemoteAppLoaderErrorDomain code:1006 userInfo:@{NSLocalizedDescriptionKey: @"Failed to download asset with no URL provided"}] asset:asset];
return;
}
[self->_downloader downloadFileFromURL:asset.url toPath:[urlOnDisk path] successBlock:^(NSData *data, NSURLResponse *response) {
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
[self handleAssetDownloadWithData:data response:response asset:asset];
});
} errorBlock:^(NSError *error, NSURLResponse *response) {
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
[self handleAssetDownloadWithError:error asset:asset];
});
}];
}
});
}
@end
NS_ASSUME_NONNULL_END

View File

@ -0,0 +1,39 @@
// Copyright © 2019 650 Industries. All rights reserved.
#import <EXUpdates/EXUpdatesAsset.h>
#import <EXUpdates/EXUpdatesConfig.h>
#import <EXUpdates/EXUpdatesUpdate.h>
NS_ASSUME_NONNULL_BEGIN
typedef NS_ENUM(NSInteger, EXUpdatesDatabaseHashType) {
EXUpdatesDatabaseHashTypeSha1 = 0
};
@interface EXUpdatesDatabase : NSObject
@property (nonatomic, strong) dispatch_queue_t databaseQueue;
- (BOOL)openDatabaseInDirectory:(NSURL *)directory withError:(NSError ** _Nullable)error;
- (void)closeDatabase;
- (void)addUpdate:(EXUpdatesUpdate *)update error:(NSError ** _Nullable)error;
- (void)addNewAssets:(NSArray<EXUpdatesAsset *> *)assets toUpdateWithId:(NSUUID *)updateId error:(NSError ** _Nullable)error;
- (BOOL)addExistingAsset:(EXUpdatesAsset *)asset toUpdateWithId:(NSUUID *)updateId error:(NSError ** _Nullable)error;
- (void)updateAsset:(EXUpdatesAsset *)asset error:(NSError ** _Nullable)error;
- (void)mergeAsset:(EXUpdatesAsset *)asset withExistingEntry:(EXUpdatesAsset *)existingAsset error:(NSError ** _Nullable)error;
- (void)markUpdateFinished:(EXUpdatesUpdate *)update error:(NSError ** _Nullable)error;
- (void)setScopeKey:(NSString *)scopeKey onUpdate:(EXUpdatesUpdate *)update error:(NSError ** _Nullable)error;
- (void)deleteUpdates:(NSArray<EXUpdatesUpdate *> *)updates error:(NSError ** _Nullable)error;
- (nullable NSArray<EXUpdatesAsset *> *)deleteUnusedAssetsWithError:(NSError ** _Nullable)error;
- (nullable NSArray<EXUpdatesUpdate *> *)allUpdatesWithConfig:(EXUpdatesConfig *)config error:(NSError ** _Nullable)error;
- (nullable NSArray<EXUpdatesUpdate *> *)launchableUpdatesWithConfig:(EXUpdatesConfig *)config error:(NSError ** _Nullable)error;
- (nullable EXUpdatesUpdate *)updateWithId:(NSUUID *)updateId config:(EXUpdatesConfig *)config error:(NSError ** _Nullable)error;
- (nullable NSArray<EXUpdatesAsset *> *)assetsWithUpdateId:(NSUUID *)updateId error:(NSError ** _Nullable)error;
- (nullable EXUpdatesAsset *)assetWithKey:(NSString *)key error:(NSError ** _Nullable)error;
@end
NS_ASSUME_NONNULL_END

View File

@ -0,0 +1,644 @@
// Copyright © 2019 650 Industries. All rights reserved.
#import <EXUpdates/EXUpdatesDatabase.h>
#import <sqlite3.h>
NS_ASSUME_NONNULL_BEGIN
@interface EXUpdatesDatabase ()
@property (nonatomic, assign) sqlite3 *db;
@property (nonatomic, readwrite, strong) NSLock *lock;
@end
static NSString * const EXUpdatesDatabaseErrorDomain = @"EXUpdatesDatabase";
static NSString * const EXUpdatesDatabaseFilename = @"expo-v4.db";
@implementation EXUpdatesDatabase
# pragma mark - lifecycle
- (instancetype)init
{
if (self = [super init]) {
_databaseQueue = dispatch_queue_create("expo.database.DatabaseQueue", DISPATCH_QUEUE_SERIAL);
}
return self;
}
- (BOOL)openDatabaseInDirectory:(NSURL *)directory withError:(NSError ** _Nullable)error
{
sqlite3 *db;
NSURL *dbUrl = [directory URLByAppendingPathComponent:EXUpdatesDatabaseFilename];
BOOL shouldInitializeDatabase = ![[NSFileManager defaultManager] fileExistsAtPath:[dbUrl path]];
int resultCode = sqlite3_open([[dbUrl path] UTF8String], &db);
if (resultCode != SQLITE_OK) {
NSLog(@"Error opening SQLite db: %@", [self _errorFromSqlite:_db].localizedDescription);
sqlite3_close(db);
if (resultCode == SQLITE_CORRUPT || resultCode == SQLITE_NOTADB) {
NSString *archivedDbFilename = [NSString stringWithFormat:@"%f-%@", [[NSDate date] timeIntervalSince1970], EXUpdatesDatabaseFilename];
NSURL *destinationUrl = [directory URLByAppendingPathComponent:archivedDbFilename];
NSError *err;
if ([[NSFileManager defaultManager] moveItemAtURL:dbUrl toURL:destinationUrl error:&err]) {
NSLog(@"Moved corrupt SQLite db to %@", archivedDbFilename);
if (sqlite3_open([[dbUrl absoluteString] UTF8String], &db) != SQLITE_OK) {
if (error != nil) {
*error = [self _errorFromSqlite:_db];
}
return NO;
}
shouldInitializeDatabase = YES;
} else {
NSString *description = [NSString stringWithFormat:@"Could not move existing corrupt database: %@", [err localizedDescription]];
if (error != nil) {
*error = [NSError errorWithDomain:EXUpdatesDatabaseErrorDomain
code:1004
userInfo:@{ NSLocalizedDescriptionKey: description, NSUnderlyingErrorKey: err }];
}
return NO;
}
} else {
if (error != nil) {
*error = [self _errorFromSqlite:_db];
}
return NO;
}
}
_db = db;
if (shouldInitializeDatabase) {
return [self _initializeDatabase:error];
}
return YES;
}
- (void)closeDatabase
{
sqlite3_close(_db);
_db = nil;
}
- (void)dealloc
{
[self closeDatabase];
}
- (BOOL)_initializeDatabase:(NSError **)error
{
NSAssert(_db, @"Missing database handle");
dispatch_assert_queue(_databaseQueue);
NSString * const createTableStmts = @"\
PRAGMA foreign_keys = ON;\
CREATE TABLE \"updates\" (\
\"id\" BLOB UNIQUE,\
\"scope_key\" TEXT NOT NULL,\
\"commit_time\" INTEGER NOT NULL,\
\"runtime_version\" TEXT NOT NULL,\
\"launch_asset_id\" INTEGER,\
\"metadata\" TEXT,\
\"status\" INTEGER NOT NULL,\
\"keep\" INTEGER NOT NULL,\
PRIMARY KEY(\"id\"),\
FOREIGN KEY(\"launch_asset_id\") REFERENCES \"assets\"(\"id\") ON DELETE CASCADE\
);\
CREATE TABLE \"assets\" (\
\"id\" INTEGER PRIMARY KEY AUTOINCREMENT,\
\"url\" TEXT,\
\"key\" TEXT NOT NULL UNIQUE,\
\"headers\" TEXT,\
\"type\" TEXT NOT NULL,\
\"metadata\" TEXT,\
\"download_time\" INTEGER NOT NULL,\
\"relative_path\" TEXT NOT NULL,\
\"hash\" BLOB NOT NULL,\
\"hash_type\" INTEGER NOT NULL,\
\"marked_for_deletion\" INTEGER NOT NULL\
);\
CREATE TABLE \"updates_assets\" (\
\"update_id\" BLOB NOT NULL,\
\"asset_id\" INTEGER NOT NULL,\
FOREIGN KEY(\"update_id\") REFERENCES \"updates\"(\"id\") ON DELETE CASCADE,\
FOREIGN KEY(\"asset_id\") REFERENCES \"assets\"(\"id\") ON DELETE CASCADE\
);\
CREATE TABLE \"json_data\" (\
\"id\" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,\
\"key\" TEXT NOT NULL,\
\"value\" TEXT NOT NULL,\
\"last_updated\" INTEGER NOT NULL,\
\"scope_key\" TEXT NOT NULL\
);\
CREATE UNIQUE INDEX \"index_updates_scope_key_commit_time\" ON \"updates\" (\"scope_key\", \"commit_time\");\
CREATE INDEX \"index_updates_launch_asset_id\" ON \"updates\" (\"launch_asset_id\");\
CREATE INDEX \"index_json_data_scope_key\" ON \"json_data\" (\"scope_key\")\
";
char *errMsg;
if (sqlite3_exec(_db, [createTableStmts UTF8String], NULL, NULL, &errMsg) != SQLITE_OK) {
if (error != nil) {
*error = [self _errorFromSqlite:_db];
}
sqlite3_free(errMsg);
return NO;
};
return YES;
}
# pragma mark - insert and update
- (void)addUpdate:(EXUpdatesUpdate *)update error:(NSError ** _Nullable)error
{
NSString * const sql = @"INSERT INTO \"updates\" (\"id\", \"scope_key\", \"commit_time\", \"runtime_version\", \"metadata\", \"status\" , \"keep\")\
VALUES (?1, ?2, ?3, ?4, ?5, ?6, 1);";
[self _executeSql:sql
withArgs:@[
update.updateId,
update.scopeKey,
@([update.commitTime timeIntervalSince1970] * 1000),
update.runtimeVersion,
update.metadata ?: [NSNull null],
@(EXUpdatesUpdateStatusPending)
]
error:error];
}
- (void)addNewAssets:(NSArray<EXUpdatesAsset *> *)assets toUpdateWithId:(NSUUID *)updateId error:(NSError ** _Nullable)error
{
sqlite3_exec(_db, "BEGIN;", NULL, NULL, NULL);
for (EXUpdatesAsset *asset in assets) {
NSAssert(asset.downloadTime, @"asset downloadTime should be nonnull");
NSAssert(asset.filename, @"asset filename should be nonnull");
NSAssert(asset.contentHash, @"asset contentHash should be nonnull");
NSString * const assetInsertSql = @"INSERT OR REPLACE INTO \"assets\" (\"key\", \"url\", \"headers\", \"type\", \"metadata\", \"download_time\", \"relative_path\", \"hash\", \"hash_type\", \"marked_for_deletion\")\
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, 0);";
if ([self _executeSql:assetInsertSql
withArgs:@[
asset.key,
asset.url ? asset.url.absoluteString : [NSNull null],
asset.headers ?: [NSNull null],
asset.type,
asset.metadata ?: [NSNull null],
@(asset.downloadTime.timeIntervalSince1970 * 1000),
asset.filename,
asset.contentHash,
@(EXUpdatesDatabaseHashTypeSha1)
]
error:error] == nil) {
sqlite3_exec(_db, "ROLLBACK;", NULL, NULL, NULL);
return;
}
// statements must stay in precisely this order for last_insert_rowid() to work correctly
if (asset.isLaunchAsset) {
NSString * const updateSql = @"UPDATE updates SET launch_asset_id = last_insert_rowid() WHERE id = ?1;";
if ([self _executeSql:updateSql withArgs:@[updateId] error:error] == nil) {
sqlite3_exec(_db, "ROLLBACK;", NULL, NULL, NULL);
return;
}
}
NSString * const updateInsertSql = @"INSERT OR REPLACE INTO updates_assets (\"update_id\", \"asset_id\") VALUES (?1, last_insert_rowid());";
if ([self _executeSql:updateInsertSql withArgs:@[updateId] error:error] == nil) {
sqlite3_exec(_db, "ROLLBACK;", NULL, NULL, NULL);
return;
}
}
sqlite3_exec(_db, "COMMIT;", NULL, NULL, NULL);
}
- (BOOL)addExistingAsset:(EXUpdatesAsset *)asset toUpdateWithId:(NSUUID *)updateId error:(NSError ** _Nullable)error
{
BOOL success;
sqlite3_exec(_db, "BEGIN;", NULL, NULL, NULL);
NSString * const assetSelectSql = @"SELECT id FROM assets WHERE \"key\" = ?1 LIMIT 1;";
NSArray<NSDictionary *> *rows = [self _executeSql:assetSelectSql withArgs:@[asset.key] error:error];
if (!rows || ![rows count]) {
success = NO;
} else {
NSNumber *assetId = rows[0][@"id"];
NSString * const insertSql = @"INSERT OR REPLACE INTO updates_assets (\"update_id\", \"asset_id\") VALUES (?1, ?2);";
if ([self _executeSql:insertSql withArgs:@[updateId, assetId] error:error] == nil) {
sqlite3_exec(_db, "ROLLBACK;", NULL, NULL, NULL);
return NO;
}
if (asset.isLaunchAsset) {
NSString * const updateSql = @"UPDATE updates SET launch_asset_id = ?1 WHERE id = ?2;";
if ([self _executeSql:updateSql withArgs:@[assetId, updateId] error:error] == nil) {
sqlite3_exec(_db, "ROLLBACK;", NULL, NULL, NULL);
return NO;
}
}
success = YES;
}
sqlite3_exec(_db, "COMMIT;", NULL, NULL, NULL);
return success;
}
- (void)updateAsset:(EXUpdatesAsset *)asset error:(NSError ** _Nullable)error
{
NSAssert(asset.downloadTime, @"asset downloadTime should be nonnull");
NSAssert(asset.filename, @"asset filename should be nonnull");
NSAssert(asset.contentHash, @"asset contentHash should be nonnull");
NSString * const assetUpdateSql = @"UPDATE \"assets\" SET \"headers\" = ?2, \"type\" = ?3, \"metadata\" = ?4, \"download_time\" = ?5, \"relative_path\" = ?6, \"hash\" = ?7, \"url\" = ?8 WHERE \"key\" = ?1;";
[self _executeSql:assetUpdateSql
withArgs:@[
asset.key,
asset.headers ?: [NSNull null],
asset.type,
asset.metadata ?: [NSNull null],
@(asset.downloadTime.timeIntervalSince1970 * 1000),
asset.filename,
asset.contentHash,
asset.url ? asset.url.absoluteString : [NSNull null]
]
error:error];
}
- (void)mergeAsset:(EXUpdatesAsset *)asset withExistingEntry:(EXUpdatesAsset *)existingAsset error:(NSError ** _Nullable)error
{
// if the existing entry came from an embedded manifest, it may not have a URL in the database
if (asset.url && !existingAsset.url) {
existingAsset.url = asset.url;
[self updateAsset:existingAsset error:error];
}
// all other properties should be overridden by database values
asset.filename = existingAsset.filename;
asset.contentHash = existingAsset.contentHash;
asset.downloadTime = existingAsset.downloadTime;
}
- (void)markUpdateFinished:(EXUpdatesUpdate *)update error:(NSError ** _Nullable)error
{
if (update.status != EXUpdatesUpdateStatusDevelopment) {
update.status = EXUpdatesUpdateStatusReady;
}
NSString * const updateSql = @"UPDATE updates SET status = ?1, keep = 1 WHERE id = ?2;";
[self _executeSql:updateSql
withArgs:@[
@(update.status),
update.updateId
]
error:error];
}
- (void)setScopeKey:(NSString *)scopeKey onUpdate:(EXUpdatesUpdate *)update error:(NSError ** _Nullable)error
{
NSString * const updateSql = @"UPDATE updates SET scope_key = ?1 WHERE id = ?2;";
[self _executeSql:updateSql withArgs:@[scopeKey, update.updateId] error:error];
}
# pragma mark - delete
- (void)deleteUpdates:(NSArray<EXUpdatesUpdate *> *)updates error:(NSError ** _Nullable)error
{
sqlite3_exec(_db, "BEGIN;", NULL, NULL, NULL);
NSString * const sql = @"DELETE FROM updates WHERE id = ?1;";
for (EXUpdatesUpdate *update in updates) {
if ([self _executeSql:sql withArgs:@[update.updateId] error:error] == nil) {
sqlite3_exec(_db, "ROLLBACK;", NULL, NULL, NULL);
return;
}
}
sqlite3_exec(_db, "COMMIT;", NULL, NULL, NULL);
}
- (nullable NSArray<EXUpdatesAsset *> *)deleteUnusedAssetsWithError:(NSError ** _Nullable)error
{
// the simplest way to mark the assets we want to delete
// is to mark all assets for deletion, then go back and unmark
// those assets in updates we want to keep
// this is safe as long as we do this inside of a transaction
sqlite3_exec(_db, "BEGIN;", NULL, NULL, NULL);
NSString * const update1Sql = @"UPDATE assets SET marked_for_deletion = 1;";
if ([self _executeSql:update1Sql withArgs:nil error:error] == nil) {
sqlite3_exec(_db, "ROLLBACK;", NULL, NULL, NULL);
return nil;
}
NSString * const update2Sql = @"UPDATE assets SET marked_for_deletion = 0 WHERE id IN (\
SELECT asset_id \
FROM updates_assets \
INNER JOIN updates ON updates_assets.update_id = updates.id\
WHERE updates.keep = 1\
);";
if ([self _executeSql:update2Sql withArgs:nil error:error] == nil) {
sqlite3_exec(_db, "ROLLBACK;", NULL, NULL, NULL);
return nil;
}
NSString * const selectSql = @"SELECT * FROM assets WHERE marked_for_deletion = 1;";
NSArray<NSDictionary *> *rows = [self _executeSql:selectSql withArgs:nil error:error];
if (!rows) {
sqlite3_exec(_db, "ROLLBACK;", NULL, NULL, NULL);
return nil;
}
NSMutableArray *assets = [NSMutableArray new];
for (NSDictionary *row in rows) {
[assets addObject:[self _assetWithRow:row]];
}
NSString * const deleteSql = @"DELETE FROM assets WHERE marked_for_deletion = 1;";
if ([self _executeSql:deleteSql withArgs:nil error:error] == nil) {
sqlite3_exec(_db, "ROLLBACK;", NULL, NULL, NULL);
return nil;
}
sqlite3_exec(_db, "COMMIT;", NULL, NULL, NULL);
return assets;
}
# pragma mark - select
- (nullable NSArray<EXUpdatesUpdate *> *)allUpdatesWithConfig:(EXUpdatesConfig *)config error:(NSError ** _Nullable)error
{
NSString * const sql = @"SELECT * FROM updates WHERE scope_key = ?1;";
NSArray<NSDictionary *> *rows = [self _executeSql:sql withArgs:@[config.scopeKey] error:error];
if (!rows) {
return nil;
}
NSMutableArray<EXUpdatesUpdate *> *launchableUpdates = [NSMutableArray new];
for (NSDictionary *row in rows) {
[launchableUpdates addObject:[self _updateWithRow:row config:config]];
}
return launchableUpdates;
}
- (nullable NSArray<EXUpdatesUpdate *> *)launchableUpdatesWithConfig:(EXUpdatesConfig *)config error:(NSError ** _Nullable)error
{
NSString *sql = [NSString stringWithFormat:@"SELECT *\
FROM updates\
WHERE scope_key = ?1\
AND status IN (%li, %li, %li);", (long)EXUpdatesUpdateStatusReady, (long)EXUpdatesUpdateStatusEmbedded, (long)EXUpdatesUpdateStatusDevelopment];
NSArray<NSDictionary *> *rows = [self _executeSql:sql withArgs:@[config.scopeKey] error:error];
if (!rows) {
return nil;
}
NSMutableArray<EXUpdatesUpdate *> *launchableUpdates = [NSMutableArray new];
for (NSDictionary *row in rows) {
[launchableUpdates addObject:[self _updateWithRow:row config:config]];
}
return launchableUpdates;
}
- (nullable EXUpdatesUpdate *)updateWithId:(NSUUID *)updateId config:(EXUpdatesConfig *)config error:(NSError ** _Nullable)error
{
NSString * const sql = @"SELECT *\
FROM updates\
WHERE updates.id = ?1;";
NSArray<NSDictionary *> *rows = [self _executeSql:sql withArgs:@[updateId] error:error];
if (!rows || ![rows count]) {
return nil;
} else {
return [self _updateWithRow:rows[0] config:config];
}
}
- (nullable NSArray<EXUpdatesAsset *> *)assetsWithUpdateId:(NSUUID *)updateId error:(NSError ** _Nullable)error
{
NSString * const sql = @"SELECT assets.id, \"key\", url, type, relative_path, assets.metadata, launch_asset_id\
FROM assets\
INNER JOIN updates_assets ON updates_assets.asset_id = assets.id\
INNER JOIN updates ON updates_assets.update_id = updates.id\
WHERE updates.id = ?1;";
NSArray<NSDictionary *> *rows = [self _executeSql:sql withArgs:@[updateId] error:error];
if (!rows) {
return nil;
}
NSMutableArray<EXUpdatesAsset *> *assets = [NSMutableArray arrayWithCapacity:rows.count];
for (NSDictionary *row in rows) {
[assets addObject:[self _assetWithRow:row]];
}
return assets;
}
- (nullable EXUpdatesAsset *)assetWithKey:(NSString *)key error:(NSError ** _Nullable)error
{
NSString * const sql = @"SELECT * FROM assets WHERE \"key\" = ?1 LIMIT 1;";
NSArray<NSDictionary *> *rows = [self _executeSql:sql withArgs:@[key] error:error];
if (!rows || ![rows count]) {
return nil;
} else {
return [self _assetWithRow:rows[0]];
}
}
# pragma mark - helper methods
- (nullable NSArray<NSDictionary *> *)_executeSql:(NSString *)sql withArgs:(nullable NSArray *)args error:(NSError ** _Nullable)error
{
NSAssert(_db, @"Missing database handle");
dispatch_assert_queue(_databaseQueue);
sqlite3_stmt *stmt;
if (sqlite3_prepare_v2(_db, [sql UTF8String], -1, &stmt, NULL) != SQLITE_OK) {
if (error != nil) {
*error = [self _errorFromSqlite:_db];
}
return nil;
}
if (args) {
if (![self _bindStatement:stmt withArgs:args]) {
if (error != nil) {
*error = [self _errorFromSqlite:_db];
}
return nil;
}
}
NSMutableArray *rows = [NSMutableArray arrayWithCapacity:0];
NSMutableArray *columnNames = [NSMutableArray arrayWithCapacity:0];
int columnCount = 0;
BOOL didFetchColumns = NO;
int result;
BOOL hasMore = YES;
BOOL didError = NO;
while (hasMore) {
result = sqlite3_step(stmt);
switch (result) {
case SQLITE_ROW: {
if (!didFetchColumns) {
// get all column names once at the beginning
columnCount = sqlite3_column_count(stmt);
for (int i = 0; i < columnCount; i++) {
[columnNames addObject:[NSString stringWithUTF8String:sqlite3_column_name(stmt, i)]];
}
didFetchColumns = YES;
}
NSMutableDictionary *entry = [NSMutableDictionary dictionary];
for (int i = 0; i < columnCount; i++) {
id columnValue = [self _getValueWithStatement:stmt column:i];
entry[columnNames[i]] = columnValue;
}
[rows addObject:entry];
break;
}
case SQLITE_DONE:
hasMore = NO;
break;
default:
didError = YES;
hasMore = NO;
break;
}
}
if (didError && error != nil) {
*error = [self _errorFromSqlite:_db];
}
sqlite3_finalize(stmt);
return didError ? nil : rows;
}
- (id)_getValueWithStatement:(sqlite3_stmt *)stmt column:(int)column
{
int columnType = sqlite3_column_type(stmt, column);
switch (columnType) {
case SQLITE_INTEGER:
return @(sqlite3_column_int64(stmt, column));
case SQLITE_FLOAT:
return @(sqlite3_column_double(stmt, column));
case SQLITE_BLOB:
NSAssert(sqlite3_column_bytes(stmt, column) == 16, @"SQLite BLOB value should be a valid UUID");
return [[NSUUID alloc] initWithUUIDBytes:sqlite3_column_blob(stmt, column)];
case SQLITE_TEXT:
return [[NSString alloc] initWithBytes:(char *)sqlite3_column_text(stmt, column)
length:sqlite3_column_bytes(stmt, column)
encoding:NSUTF8StringEncoding];
}
return [NSNull null];
}
- (BOOL)_bindStatement:(sqlite3_stmt *)stmt withArgs:(NSArray *)args
{
__block BOOL success = YES;
[args enumerateObjectsUsingBlock:^(id obj, NSUInteger idx, BOOL *stop) {
if ([obj isKindOfClass:[NSUUID class]]) {
uuid_t bytes;
[((NSUUID *)obj) getUUIDBytes:bytes];
if (sqlite3_bind_blob(stmt, (int)idx + 1, bytes, 16, SQLITE_TRANSIENT) != SQLITE_OK) {
success = NO;
*stop = YES;
}
} else if ([obj isKindOfClass:[NSNumber class]]) {
if (sqlite3_bind_int64(stmt, (int)idx + 1, [((NSNumber *)obj) longLongValue]) != SQLITE_OK) {
success = NO;
*stop = YES;
}
} else if ([obj isKindOfClass:[NSDictionary class]]) {
NSError *error;
NSData *jsonData = [NSJSONSerialization dataWithJSONObject:(NSDictionary *)obj options:kNilOptions error:&error];
if (!error && sqlite3_bind_text(stmt, (int)idx + 1, jsonData.bytes, (int)jsonData.length, SQLITE_TRANSIENT) != SQLITE_OK) {
success = NO;
*stop = YES;
}
} else if ([obj isKindOfClass:[NSNull class]]) {
if (sqlite3_bind_null(stmt, (int)idx + 1) != SQLITE_OK) {
success = NO;
*stop = YES;
}
} else {
// convert to string
NSString *string = [obj isKindOfClass:[NSString class]] ? (NSString *)obj : [obj description];
NSData *data = [string dataUsingEncoding:NSUTF8StringEncoding];
if (sqlite3_bind_text(stmt, (int)idx + 1, data.bytes, (int)data.length, SQLITE_TRANSIENT) != SQLITE_OK) {
success = NO;
*stop = YES;
}
}
}];
return success;
}
- (NSError *)_errorFromSqlite:(struct sqlite3 *)db
{
int code = sqlite3_errcode(db);
int extendedCode = sqlite3_extended_errcode(db);
NSString *message = [NSString stringWithUTF8String:sqlite3_errmsg(db)];
return [NSError errorWithDomain:EXUpdatesDatabaseErrorDomain
code:extendedCode
userInfo:@{NSLocalizedDescriptionKey: [NSString stringWithFormat:@"Error code %i: %@ (extended error code %i)", code, message, extendedCode]}];
}
- (EXUpdatesUpdate *)_updateWithRow:(NSDictionary *)row config:(EXUpdatesConfig *)config
{
NSError *error;
id metadata = nil;
id rowMetadata = row[@"metadata"];
if ([rowMetadata isKindOfClass:[NSString class]]) {
metadata = [NSJSONSerialization JSONObjectWithData:[(NSString *)rowMetadata dataUsingEncoding:NSUTF8StringEncoding] options:kNilOptions error:&error];
NSAssert(!error && metadata && [metadata isKindOfClass:[NSDictionary class]], @"Update metadata should be a valid JSON object");
}
EXUpdatesUpdate *update = [EXUpdatesUpdate updateWithId:row[@"id"]
scopeKey:row[@"scope_key"]
commitTime:[NSDate dateWithTimeIntervalSince1970:[(NSNumber *)row[@"commit_time"] doubleValue] / 1000]
runtimeVersion:row[@"runtime_version"]
metadata:metadata
status:(EXUpdatesUpdateStatus)[(NSNumber *)row[@"status"] integerValue]
keep:[(NSNumber *)row[@"keep"] boolValue]
config:config
database:self];
return update;
}
- (EXUpdatesAsset *)_assetWithRow:(NSDictionary *)row
{
NSError *error;
id metadata = nil;
id rowMetadata = row[@"metadata"];
if ([rowMetadata isKindOfClass:[NSString class]]) {
metadata = [NSJSONSerialization JSONObjectWithData:[(NSString *)rowMetadata dataUsingEncoding:NSUTF8StringEncoding] options:kNilOptions error:&error];
NSAssert(!error && metadata && [metadata isKindOfClass:[NSDictionary class]], @"Asset metadata should be a valid JSON object");
}
id launchAssetId = row[@"launch_asset_id"];
id rowUrl = row[@"url"];
NSURL *url;
if (rowUrl && [rowUrl isKindOfClass:[NSString class]]) {
url = [NSURL URLWithString:rowUrl];
}
EXUpdatesAsset *asset = [[EXUpdatesAsset alloc] initWithKey:row[@"key"] type:row[@"type"]];
asset.url = url;
asset.downloadTime = [NSDate dateWithTimeIntervalSince1970:([(NSNumber *)row[@"download_time"] doubleValue] / 1000)];
asset.filename = row[@"relative_path"];
asset.contentHash = row[@"hash"];
asset.metadata = metadata;
asset.isLaunchAsset = (launchAssetId && [launchAssetId isKindOfClass:[NSNumber class]])
? [(NSNumber *)launchAssetId isEqualToNumber:(NSNumber *)row[@"id"]]
: NO;
return asset;
}
@end
NS_ASSUME_NONNULL_END

View File

@ -0,0 +1,20 @@
// Copyright © 2019 650 Industries. All rights reserved.
#import <EXUpdates/EXUpdatesConfig.h>
#import <EXUpdates/EXUpdatesDatabase.h>
#import <EXUpdates/EXUpdatesSelectionPolicy.h>
#import <EXUpdates/EXUpdatesUpdate.h>
NS_ASSUME_NONNULL_BEGIN
@interface EXUpdatesReaper : NSObject
+ (void)reapUnusedUpdatesWithConfig:(EXUpdatesConfig *)config
database:(EXUpdatesDatabase *)database
directory:(NSURL *)directory
selectionPolicy:(id<EXUpdatesSelectionPolicy>)selectionPolicy
launchedUpdate:(EXUpdatesUpdate *)launchedUpdate;
@end
NS_ASSUME_NONNULL_END

View File

@ -0,0 +1,79 @@
// Copyright © 2019 650 Industries. All rights reserved.
#import <EXUpdates/EXUpdatesFileDownloader.h>
#import <EXUpdates/EXUpdatesReaper.h>
NS_ASSUME_NONNULL_BEGIN
@implementation EXUpdatesReaper
+ (void)reapUnusedUpdatesWithConfig:(EXUpdatesConfig *)config
database:(EXUpdatesDatabase *)database
directory:(NSURL *)directory
selectionPolicy:(id<EXUpdatesSelectionPolicy>)selectionPolicy
launchedUpdate:(EXUpdatesUpdate *)launchedUpdate
{
dispatch_async(database.databaseQueue, ^{
NSError *error;
NSDate *beginDeleteFromDatabase = [NSDate date];
[database markUpdateFinished:launchedUpdate error:&error];
if (error) {
NSLog(@"Error reaping updates: %@", error.localizedDescription);
return;
}
NSArray<EXUpdatesUpdate *> *allUpdates = [database allUpdatesWithConfig:config error:&error];
if (!allUpdates || error) {
NSLog(@"Error reaping updates: %@", error.localizedDescription);
return;
}
NSArray<EXUpdatesUpdate *> *updatesToDelete = [selectionPolicy updatesToDeleteWithLaunchedUpdate:launchedUpdate updates:allUpdates];
[database deleteUpdates:updatesToDelete error:&error];
if (error) {
NSLog(@"Error reaping updates: %@", error.localizedDescription);
return;
}
NSArray<EXUpdatesAsset *> *assetsForDeletion = [database deleteUnusedAssetsWithError:&error];
if (error) {
NSLog(@"Error reaping updates: %@", error.localizedDescription);
return;
}
NSLog(@"Deleted assets and updates from SQLite in %f ms", [beginDeleteFromDatabase timeIntervalSinceNow] * -1000);
dispatch_async([EXUpdatesFileDownloader assetFilesQueue], ^{
NSUInteger deletedAssets = 0;
NSMutableArray<EXUpdatesAsset *> *erroredAssets = [NSMutableArray new];
NSDate *beginDeleteAssets = [NSDate date];
for (EXUpdatesAsset *asset in assetsForDeletion) {
NSURL *localUrl = [directory URLByAppendingPathComponent:asset.filename];
NSError *error;
if ([NSFileManager.defaultManager fileExistsAtPath:localUrl.path] && ![NSFileManager.defaultManager removeItemAtURL:localUrl error:&error]) {
NSLog(@"Error deleting asset at %@: %@", localUrl, error.localizedDescription);
[erroredAssets addObject:asset];
} else {
deletedAssets++;
}
}
NSLog(@"Deleted %lu assets from disk in %f ms", (unsigned long)deletedAssets, [beginDeleteAssets timeIntervalSinceNow] * -1000);
NSDate *beginRetryDeletes = [NSDate date];
// retry errored deletions
for (EXUpdatesAsset *asset in erroredAssets) {
NSURL *localUrl = [directory URLByAppendingPathComponent:asset.filename];
NSError *error;
if ([NSFileManager.defaultManager fileExistsAtPath:localUrl.path] && ![NSFileManager.defaultManager removeItemAtURL:localUrl error:&error]) {
NSLog(@"Retried deleting asset at %@ and failed again: %@", localUrl, error.localizedDescription);
}
}
NSLog(@"Retried deleting assets from disk in %f ms", [beginRetryDeletes timeIntervalSinceNow] * -1000);
});
});
}
@end
NS_ASSUME_NONNULL_END

View File

@ -0,0 +1,99 @@
// Copyright © 2019 650 Industries. All rights reserved.
#import <EXUpdates/EXUpdatesAppLoader.h>
#import <EXUpdates/EXUpdatesAppLoaderTask.h>
#import <EXUpdates/EXUpdatesConfig.h>
#import <EXUpdates/EXUpdatesDatabase.h>
#import <EXUpdates/EXUpdatesEmbeddedAppLoader.h>
#import <EXUpdates/EXUpdatesSelectionPolicy.h>
#import <EXUpdates/EXUpdatesService.h>
#import <React/RCTBridge.h>
NS_ASSUME_NONNULL_BEGIN
@class EXUpdatesAppController;
@protocol EXUpdatesAppControllerDelegate <NSObject>
- (void)appController:(EXUpdatesAppController *)appController didStartWithSuccess:(BOOL)success;
@end
@interface EXUpdatesAppController : NSObject <EXUpdatesAppLoaderTaskDelegate>
/**
Delegate which will be notified when EXUpdates has an update ready to launch and
`launchAssetUrl` is nonnull.
*/
@property (nonatomic, weak) id<EXUpdatesAppControllerDelegate> delegate;
/**
The RCTBridge for which EXUpdates is providing the JS bundle and assets.
This is optional, but required in order for `Updates.reload()` and Updates module events to work.
*/
@property (nonatomic, weak) RCTBridge *bridge;
/**
The URL on disk to source asset for the RCTBridge.
Will be null until the EXUpdatesAppController delegate method is called.
This should be provided in the `sourceURLForBridge:` method of RCTBridgeDelegate.
*/
@property (nullable, nonatomic, readonly, strong) NSURL *launchAssetUrl;
/**
A dictionary of the locally downloaded assets for the current update. Keys are the remote URLs
of the assets and values are local paths. This should be exported by the Updates JS module and
can be used by `expo-asset` or a similar module to override React Native's asset resolution and
use the locally downloaded assets.
*/
@property (nullable, nonatomic, readonly, strong) NSDictionary *assetFilesMap;
@property (nonatomic, readonly, assign) BOOL isUsingEmbeddedAssets;
/**
for internal use in EXUpdates
*/
@property (nonatomic, readonly) EXUpdatesConfig *config;
@property (nonatomic, readonly) EXUpdatesDatabase *database;
@property (nonatomic, readonly) id<EXUpdatesSelectionPolicy> selectionPolicy;
@property (nonatomic, readonly) NSURL *updatesDirectory;
@property (nonatomic, readonly) dispatch_queue_t assetFilesQueue;
@property (nonatomic, readonly, assign) BOOL isStarted;
@property (nonatomic, readonly, assign) BOOL isEmergencyLaunch;
@property (nullable, nonatomic, readonly, strong) EXUpdatesUpdate *launchedUpdate;
+ (instancetype)sharedInstance;
/**
Overrides the configuration values specified in Expo.plist with the ones provided in this
dictionary. This method can be used if any of these values should be determined at runtime
instead of buildtime. If used, this method must be called before any other method on the
shared instance of EXUpdatesAppController.
*/
- (void)setConfiguration:(NSDictionary *)configuration;
/**
Starts the update process to launch a previously-loaded update and (if configured to do so)
check for a new update from the server. This method should be called as early as possible in
the application's lifecycle.
Note that iOS may stop showing the app's splash screen in case the update is taking a while
to load. If your splash screen setup is simple, you may want to use the
`startAndShowLaunchScreen:` method instead.
*/
- (void)start;
/**
Starts the update process to launch a previously-loaded update and (if configured to do so)
check for a new update from the server. This method should be called as early as possible in
the application's lifecycle.
Note that iOS may stop showing the app's splash screen in case the update is taking a while
to load. This method will attempt to find `LaunchScreen.xib` and load it into view while the
update is loading.
*/
- (void)startAndShowLaunchScreen:(UIWindow *)window;
- (void)requestRelaunchWithCompletion:(EXUpdatesAppRelaunchCompletionBlock)completion;
@end
NS_ASSUME_NONNULL_END

View File

@ -0,0 +1,286 @@
// Copyright © 2019 650 Industries. All rights reserved.
#import <EXUpdates/EXUpdatesAppController.h>
#import <EXUpdates/EXUpdatesAppLauncher.h>
#import <EXUpdates/EXUpdatesAppLauncherNoDatabase.h>
#import <EXUpdates/EXUpdatesAppLauncherWithDatabase.h>
#import <EXUpdates/EXUpdatesReaper.h>
#import <EXUpdates/EXUpdatesSelectionPolicyNewest.h>
#import <EXUpdates/EXUpdatesUtils.h>
NS_ASSUME_NONNULL_BEGIN
static NSString * const EXUpdatesAppControllerErrorDomain = @"EXUpdatesAppController";
static NSString * const EXUpdatesConfigPlistName = @"Expo";
static NSString * const EXUpdatesUpdateAvailableEventName = @"updateAvailable";
static NSString * const EXUpdatesNoUpdateAvailableEventName = @"noUpdateAvailable";
static NSString * const EXUpdatesErrorEventName = @"error";
@interface EXUpdatesAppController ()
@property (nonatomic, readwrite, strong) EXUpdatesConfig *config;
@property (nonatomic, readwrite, strong) id<EXUpdatesAppLauncher> launcher;
@property (nonatomic, readwrite, strong) EXUpdatesDatabase *database;
@property (nonatomic, readwrite, strong) id<EXUpdatesSelectionPolicy> selectionPolicy;
@property (nonatomic, readwrite, strong) dispatch_queue_t assetFilesQueue;
@property (nonatomic, readwrite, strong) NSURL *updatesDirectory;
@property (nonatomic, strong) id<EXUpdatesAppLauncher> candidateLauncher;
@property (nonatomic, assign) BOOL hasLaunched;
@property (nonatomic, strong) dispatch_queue_t controllerQueue;
@property (nonatomic, assign) BOOL isStarted;
@property (nonatomic, assign) BOOL isEmergencyLaunch;
@end
@implementation EXUpdatesAppController
+ (instancetype)sharedInstance
{
static EXUpdatesAppController *theController;
static dispatch_once_t once;
dispatch_once(&once, ^{
if (!theController) {
theController = [[EXUpdatesAppController alloc] init];
}
});
return theController;
}
- (instancetype)init
{
if (self = [super init]) {
_config = [self _loadConfigFromExpoPlist];
_database = [[EXUpdatesDatabase alloc] init];
_selectionPolicy = [[EXUpdatesSelectionPolicyNewest alloc] initWithRuntimeVersion:[EXUpdatesUtils getRuntimeVersionWithConfig:_config]];
_assetFilesQueue = dispatch_queue_create("expo.controller.AssetFilesQueue", DISPATCH_QUEUE_SERIAL);
_controllerQueue = dispatch_queue_create("expo.controller.ControllerQueue", DISPATCH_QUEUE_SERIAL);
_isStarted = NO;
}
return self;
}
- (void)setConfiguration:(NSDictionary *)configuration
{
if (_updatesDirectory) {
@throw [NSException exceptionWithName:NSInternalInconsistencyException
reason:@"EXUpdatesAppController:setConfiguration should not be called after start"
userInfo:@{}];
}
[_config loadConfigFromDictionary:configuration];
_selectionPolicy = [[EXUpdatesSelectionPolicyNewest alloc] initWithRuntimeVersion:[EXUpdatesUtils getRuntimeVersionWithConfig:_config]];
}
- (void)start
{
NSAssert(!_updatesDirectory, @"EXUpdatesAppController:start should only be called once per instance");
if (!_config.isEnabled) {
EXUpdatesAppLauncherNoDatabase *launcher = [[EXUpdatesAppLauncherNoDatabase alloc] init];
_launcher = launcher;
[launcher launchUpdateWithConfig:_config];
if (_delegate) {
[_delegate appController:self didStartWithSuccess:self.launchAssetUrl != nil];
}
return;
}
if (!_config.updateUrl) {
@throw [NSException exceptionWithName:NSInternalInconsistencyException
reason:@"expo-updates is enabled, but no valid URL is configured under EXUpdatesURL. If you are making a release build for the first time, make sure you have run `expo publish` at least once."
userInfo:@{}];
}
_isStarted = YES;
NSError *fsError;
_updatesDirectory = [EXUpdatesUtils initializeUpdatesDirectoryWithError:&fsError];
if (fsError) {
[self _emergencyLaunchWithFatalError:fsError];
return;
}
__block BOOL dbSuccess;
__block NSError *dbError;
dispatch_semaphore_t dbSemaphore = dispatch_semaphore_create(0);
dispatch_async(_database.databaseQueue, ^{
dbSuccess = [self->_database openDatabaseInDirectory:self->_updatesDirectory withError:&dbError];
dispatch_semaphore_signal(dbSemaphore);
});
dispatch_semaphore_wait(dbSemaphore, DISPATCH_TIME_FOREVER);
if (!dbSuccess) {
[self _emergencyLaunchWithFatalError:dbError];
return;
}
EXUpdatesAppLoaderTask *loaderTask = [[EXUpdatesAppLoaderTask alloc] initWithConfig:_config
database:_database
directory:_updatesDirectory
selectionPolicy:_selectionPolicy
delegateQueue:_controllerQueue];
loaderTask.delegate = self;
[loaderTask start];
}
- (void)startAndShowLaunchScreen:(UIWindow *)window
{
NSBundle *mainBundle = [NSBundle mainBundle];
UIViewController *rootViewController = [UIViewController new];
NSString *launchScreen = (NSString *)[mainBundle objectForInfoDictionaryKey:@"UILaunchStoryboardName"] ?: @"LaunchScreen";
if ([mainBundle pathForResource:launchScreen ofType:@"nib"] != nil) {
NSArray *views = [mainBundle loadNibNamed:launchScreen owner:self options:nil];
rootViewController.view = views.firstObject;
rootViewController.view.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
} else if ([mainBundle pathForResource:launchScreen ofType:@"storyboard"] != nil ||
[mainBundle pathForResource:launchScreen ofType:@"storyboardc"] != nil) {
UIStoryboard *launchScreenStoryboard = [UIStoryboard storyboardWithName:launchScreen bundle:nil];
rootViewController = [launchScreenStoryboard instantiateInitialViewController];
} else {
NSLog(@"Launch screen could not be loaded from a .xib or .storyboard. Unexpected loading behavior may occur.");
UIView *view = [UIView new];
view.backgroundColor = [UIColor whiteColor];
rootViewController.view = view;
}
window.rootViewController = rootViewController;
[window makeKeyAndVisible];
[self start];
}
- (void)requestRelaunchWithCompletion:(EXUpdatesAppRelaunchCompletionBlock)completion
{
if (_bridge) {
EXUpdatesAppLauncherWithDatabase *launcher = [[EXUpdatesAppLauncherWithDatabase alloc] initWithConfig:_config database:_database directory:_updatesDirectory completionQueue:_controllerQueue];
_candidateLauncher = launcher;
[launcher launchUpdateWithSelectionPolicy:self->_selectionPolicy completion:^(NSError * _Nullable error, BOOL success) {
if (success) {
self->_launcher = self->_candidateLauncher;
completion(YES);
[self->_bridge reload];
[self _runReaper];
} else {
NSLog(@"Failed to relaunch: %@", error.localizedDescription);
completion(NO);
}
}];
} else {
NSLog(@"EXUpdatesAppController: Failed to reload because bridge was nil. Did you set the bridge property on the controller singleton?");
completion(NO);
}
}
- (nullable EXUpdatesUpdate *)launchedUpdate
{
return _launcher.launchedUpdate ?: nil;
}
- (nullable NSURL *)launchAssetUrl
{
return _launcher.launchAssetUrl ?: nil;
}
- (nullable NSDictionary *)assetFilesMap
{
return _launcher.assetFilesMap ?: nil;
}
- (BOOL)isUsingEmbeddedAssets
{
if (!_launcher) {
return YES;
}
return _launcher.isUsingEmbeddedAssets;
}
# pragma mark - EXUpdatesAppLoaderTaskDelegate
- (BOOL)appLoaderTask:(EXUpdatesAppLoaderTask *)appLoaderTask didLoadCachedUpdate:(nonnull EXUpdatesUpdate *)update
{
return YES;
}
- (void)appLoaderTask:(EXUpdatesAppLoaderTask *)appLoaderTask didStartLoadingUpdate:(EXUpdatesUpdate *)update
{
// do nothing here for now
}
- (void)appLoaderTask:(EXUpdatesAppLoaderTask *)appLoaderTask didFinishWithLauncher:(id<EXUpdatesAppLauncher>)launcher isUpToDate:(BOOL)isUpToDate
{
_launcher = launcher;
if (self->_delegate) {
[EXUpdatesUtils runBlockOnMainThread:^{
[self->_delegate appController:self didStartWithSuccess:YES];
}];
}
}
- (void)appLoaderTask:(EXUpdatesAppLoaderTask *)appLoaderTask didFinishWithError:(NSError *)error
{
[self _emergencyLaunchWithFatalError:error];
}
- (void)appLoaderTask:(EXUpdatesAppLoaderTask *)appLoaderTask didFinishBackgroundUpdateWithStatus:(EXUpdatesBackgroundUpdateStatus)status update:(nullable EXUpdatesUpdate *)update error:(nullable NSError *)error
{
if (status == EXUpdatesBackgroundUpdateStatusError) {
NSAssert(error != nil, @"Background update with error status must have a nonnull error object");
[EXUpdatesUtils sendEventToBridge:_bridge withType:EXUpdatesErrorEventName body:@{@"message": error.localizedDescription}];
} else if (status == EXUpdatesBackgroundUpdateStatusUpdateAvailable) {
NSAssert(update != nil, @"Background update with error status must have a nonnull update object");
[EXUpdatesUtils sendEventToBridge:_bridge withType:EXUpdatesUpdateAvailableEventName body:@{@"manifest": update.rawManifest}];
} else if (status == EXUpdatesBackgroundUpdateStatusNoUpdateAvailable) {
[EXUpdatesUtils sendEventToBridge:_bridge withType:EXUpdatesNoUpdateAvailableEventName body:@{}];
}
}
# pragma mark - internal
- (EXUpdatesConfig *)_loadConfigFromExpoPlist
{
NSString *configPath = [[NSBundle mainBundle] pathForResource:EXUpdatesConfigPlistName ofType:@"plist"];
if (!configPath) {
@throw [NSException exceptionWithName:NSInternalInconsistencyException
reason:@"Cannot load configuration from Expo.plist. Please ensure you've followed the setup and installation instructions for expo-updates to create Expo.plist and add it to your Xcode project."
userInfo:@{}];
}
return [EXUpdatesConfig configWithDictionary:[NSDictionary dictionaryWithContentsOfFile:configPath]];
}
- (void)_runReaper
{
if (_launcher.launchedUpdate) {
[EXUpdatesReaper reapUnusedUpdatesWithConfig:_config
database:_database
directory:_updatesDirectory
selectionPolicy:_selectionPolicy
launchedUpdate:_launcher.launchedUpdate];
}
}
- (void)_emergencyLaunchWithFatalError:(NSError *)error
{
_isEmergencyLaunch = YES;
EXUpdatesAppLauncherNoDatabase *launcher = [[EXUpdatesAppLauncherNoDatabase alloc] init];
_launcher = launcher;
[launcher launchUpdateWithConfig:_config fatalError:error];
if (_delegate) {
[EXUpdatesUtils runBlockOnMainThread:^{
[self->_delegate appController:self didStartWithSuccess:self.launchAssetUrl != nil];
}];
}
}
@end
NS_ASSUME_NONNULL_END

View File

@ -0,0 +1,34 @@
// Copyright © 2019 650 Industries. All rights reserved.
NS_ASSUME_NONNULL_BEGIN
typedef NS_ENUM(NSInteger, EXUpdatesCheckAutomaticallyConfig) {
EXUpdatesCheckAutomaticallyConfigAlways = 0,
EXUpdatesCheckAutomaticallyConfigWifiOnly = 1,
EXUpdatesCheckAutomaticallyConfigNever = 2
};
@interface EXUpdatesConfig : NSObject
@property (nonatomic, readonly) BOOL isEnabled;
@property (nonatomic, readonly) NSString *scopeKey;
@property (nonatomic, readonly) NSURL *updateUrl;
@property (nonatomic, readonly) NSDictionary *requestHeaders;
@property (nonatomic, readonly) NSString *releaseChannel;
@property (nonatomic, readonly) NSNumber *launchWaitMs;
@property (nonatomic, readonly) EXUpdatesCheckAutomaticallyConfig checkOnLaunch;
@property (nullable, nonatomic, readonly) NSString *sdkVersion;
@property (nullable, nonatomic, readonly) NSString *runtimeVersion;
@property (nonatomic, readonly) BOOL usesLegacyManifest;
@property (nonatomic, readonly) BOOL hasEmbeddedUpdate;
+ (instancetype)configWithDictionary:(NSDictionary *)config;
- (void)loadConfigFromDictionary:(NSDictionary *)config;
+ (NSString *)normalizedURLOrigin:(NSURL *)url;
@end
NS_ASSUME_NONNULL_END

View File

@ -0,0 +1,172 @@
// Copyright © 2019 650 Industries. All rights reserved.
#import <EXUpdates/EXUpdatesConfig.h>
NS_ASSUME_NONNULL_BEGIN
@interface EXUpdatesConfig ()
@property (nonatomic, readwrite, assign) BOOL isEnabled;
@property (nonatomic, readwrite, strong) NSString *scopeKey;
@property (nonatomic, readwrite, strong) NSURL *updateUrl;
@property (nonatomic, readwrite, strong) NSDictionary *requestHeaders;
@property (nonatomic, readwrite, strong) NSString *releaseChannel;
@property (nonatomic, readwrite, strong) NSNumber *launchWaitMs;
@property (nonatomic, readwrite, assign) EXUpdatesCheckAutomaticallyConfig checkOnLaunch;
@property (nullable, nonatomic, readwrite, strong) NSString *sdkVersion;
@property (nullable, nonatomic, readwrite, strong) NSString *runtimeVersion;
@end
static NSString * const EXUpdatesDefaultReleaseChannelName = @"default";
static NSString * const EXUpdatesConfigEnabledKey = @"EXUpdatesEnabled";
static NSString * const EXUpdatesConfigScopeKeyKey = @"EXUpdatesScopeKey";
static NSString * const EXUpdatesConfigUpdateUrlKey = @"EXUpdatesURL";
static NSString * const EXUpdatesConfigRequestHeadersKey = @"EXUpdatesRequestHeaders";
static NSString * const EXUpdatesConfigReleaseChannelKey = @"EXUpdatesReleaseChannel";
static NSString * const EXUpdatesConfigLaunchWaitMsKey = @"EXUpdatesLaunchWaitMs";
static NSString * const EXUpdatesConfigCheckOnLaunchKey = @"EXUpdatesCheckOnLaunch";
static NSString * const EXUpdatesConfigSDKVersionKey = @"EXUpdatesSDKVersion";
static NSString * const EXUpdatesConfigRuntimeVersionKey = @"EXUpdatesRuntimeVersion";
static NSString * const EXUpdatesConfigUsesLegacyManifestKey = @"EXUpdatesUsesLegacyManifest";
static NSString * const EXUpdatesConfigHasEmbeddedUpdateKey = @"EXUpdatesHasEmbeddedUpdate";
static NSString * const EXUpdatesConfigAlwaysString = @"ALWAYS";
static NSString * const EXUpdatesConfigWifiOnlyString = @"WIFI_ONLY";
static NSString * const EXUpdatesConfigNeverString = @"NEVER";
@implementation EXUpdatesConfig
- (instancetype)init
{
if (self = [super init]) {
_isEnabled = YES;
_requestHeaders = @{};
_releaseChannel = EXUpdatesDefaultReleaseChannelName;
_launchWaitMs = @(0);
_checkOnLaunch = EXUpdatesCheckAutomaticallyConfigAlways;
_usesLegacyManifest = YES;
_hasEmbeddedUpdate = YES;
}
return self;
}
+ (instancetype)configWithDictionary:(NSDictionary *)config
{
EXUpdatesConfig *updatesConfig = [[EXUpdatesConfig alloc] init];
[updatesConfig loadConfigFromDictionary:config];
return updatesConfig;
}
- (void)loadConfigFromDictionary:(NSDictionary *)config
{
id isEnabled = config[EXUpdatesConfigEnabledKey];
if (isEnabled && [isEnabled isKindOfClass:[NSNumber class]]) {
_isEnabled = [(NSNumber *)isEnabled boolValue];
}
id updateUrl = config[EXUpdatesConfigUpdateUrlKey];
if (updateUrl && [updateUrl isKindOfClass:[NSString class]]) {
NSURL *url = [NSURL URLWithString:(NSString *)updateUrl];
_updateUrl = url;
}
id scopeKey = config[EXUpdatesConfigScopeKeyKey];
if (scopeKey && [scopeKey isKindOfClass:[NSString class]]) {
_scopeKey = (NSString *)scopeKey;
}
// set updateUrl as the default value if none is provided
if (!_scopeKey) {
if (_updateUrl) {
_scopeKey = [[self class] normalizedURLOrigin:_updateUrl];
} else {
@throw [NSException exceptionWithName:NSInternalInconsistencyException
reason:@"expo-updates must be configured with a valid update URL or scope key."
userInfo:@{}];
}
}
id requestHeaders = config[EXUpdatesConfigRequestHeadersKey];
if (requestHeaders && [requestHeaders isKindOfClass:[NSDictionary class]]) {
_requestHeaders = (NSDictionary *)requestHeaders;
}
id releaseChannel = config[EXUpdatesConfigReleaseChannelKey];
if (releaseChannel && [releaseChannel isKindOfClass:[NSString class]]) {
_releaseChannel = (NSString *)releaseChannel;
}
id launchWaitMs = config[EXUpdatesConfigLaunchWaitMsKey];
if (launchWaitMs && [launchWaitMs isKindOfClass:[NSNumber class]]) {
_launchWaitMs = (NSNumber *)launchWaitMs;
} else if (launchWaitMs && [launchWaitMs isKindOfClass:[NSString class]]) {
NSNumberFormatter *formatter = [[NSNumberFormatter alloc] init];
formatter.numberStyle = NSNumberFormatterNoStyle;
_launchWaitMs = [formatter numberFromString:(NSString *)launchWaitMs];
}
id checkOnLaunch = config[EXUpdatesConfigCheckOnLaunchKey];
if (checkOnLaunch && [checkOnLaunch isKindOfClass:[NSString class]]) {
if ([EXUpdatesConfigNeverString isEqualToString:(NSString *)checkOnLaunch]) {
_checkOnLaunch = EXUpdatesCheckAutomaticallyConfigNever;
} else if ([EXUpdatesConfigWifiOnlyString isEqualToString:(NSString *)checkOnLaunch]) {
_checkOnLaunch = EXUpdatesCheckAutomaticallyConfigWifiOnly;
} else if ([EXUpdatesConfigAlwaysString isEqualToString:(NSString *)checkOnLaunch]) {
_checkOnLaunch = EXUpdatesCheckAutomaticallyConfigAlways;
}
}
id sdkVersion = config[EXUpdatesConfigSDKVersionKey];
if (sdkVersion && [sdkVersion isKindOfClass:[NSString class]]) {
_sdkVersion = (NSString *)sdkVersion;
}
id runtimeVersion = config[EXUpdatesConfigRuntimeVersionKey];
if (runtimeVersion && [runtimeVersion isKindOfClass:[NSString class]]) {
_runtimeVersion = (NSString *)runtimeVersion;
}
NSAssert(_sdkVersion || _runtimeVersion, @"One of EXUpdatesSDKVersion or EXUpdatesRuntimeVersion must be configured in expo-updates");
id usesLegacyManifest = config[EXUpdatesConfigUsesLegacyManifestKey];
if (usesLegacyManifest && [usesLegacyManifest isKindOfClass:[NSNumber class]]) {
_usesLegacyManifest = [(NSNumber *)usesLegacyManifest boolValue];
}
id hasEmbeddedUpdate = config[EXUpdatesConfigHasEmbeddedUpdateKey];
if (hasEmbeddedUpdate && [hasEmbeddedUpdate isKindOfClass:[NSNumber class]]) {
_hasEmbeddedUpdate = [(NSNumber *)hasEmbeddedUpdate boolValue];
}
}
+ (NSString *)normalizedURLOrigin:(NSURL *)url
{
NSString *scheme = url.scheme;
NSNumber *port = url.port;
if (port && port.integerValue > -1 && [port isEqual:[[self class] defaultPortForScheme:scheme]]) {
port = nil;
}
return (port && port.integerValue > -1)
? [NSString stringWithFormat:@"%@://%@:%ld", scheme, url.host, (long)port.integerValue]
: [NSString stringWithFormat:@"%@://%@", scheme, url.host];
}
+ (nullable NSNumber *)defaultPortForScheme:(NSString *)scheme
{
if ([@"http" isEqualToString:scheme] || [@"ws" isEqualToString:scheme]) {
return @(80);
} else if ([@"https" isEqualToString:scheme] || [@"wss" isEqualToString:scheme]) {
return @(443);
} else if ([@"ftp" isEqualToString:scheme]) {
return @(21);
}
return nil;
}
@end
NS_ASSUME_NONNULL_END

View File

@ -0,0 +1,7 @@
// Copyright © 2019 650 Industries. All rights reserved.
#import <UMCore/UMExportedModule.h>
#import <UMCore/UMModuleRegistryConsumer.h>
@interface EXUpdatesModule : UMExportedModule <UMModuleRegistryConsumer>
@end

View File

@ -0,0 +1,129 @@
// Copyright 2019 650 Industries. All rights reserved.
#import <EXUpdates/EXUpdatesConfig.h>
#import <EXUpdates/EXUpdatesDatabase.h>
#import <EXUpdates/EXUpdatesFileDownloader.h>
#import <EXUpdates/EXUpdatesModule.h>
#import <EXUpdates/EXUpdatesRemoteAppLoader.h>
#import <EXUpdates/EXUpdatesService.h>
#import <EXUpdates/EXUpdatesUpdate.h>
@interface EXUpdatesModule ()
@property (nonatomic, weak) id<EXUpdatesInterface> updatesService;
@end
@implementation EXUpdatesModule
UM_EXPORT_MODULE(ExpoUpdates);
- (void)setModuleRegistry:(UMModuleRegistry *)moduleRegistry
{
_updatesService = [moduleRegistry getModuleImplementingProtocol:@protocol(EXUpdatesInterface)];
}
- (NSDictionary *)constantsToExport
{
if (!_updatesService.isStarted) {
return @{
@"isEnabled": @(NO)
};
}
EXUpdatesUpdate *launchedUpdate = _updatesService.launchedUpdate;
if (!launchedUpdate) {
return @{
@"isEnabled": @(NO)
};
} else {
return @{
@"isEnabled": @(YES),
@"isUsingEmbeddedAssets": @(_updatesService.isUsingEmbeddedAssets),
@"updateId": launchedUpdate.updateId.UUIDString ?: @"",
@"manifest": launchedUpdate.rawManifest ?: @{},
@"releaseChannel": _updatesService.config.releaseChannel,
@"localAssets": _updatesService.assetFilesMap ?: @{},
@"isEmergencyLaunch": @(_updatesService.isEmergencyLaunch)
};
}
}
UM_EXPORT_METHOD_AS(reload,
reloadAsync:(UMPromiseResolveBlock)resolve
reject:(UMPromiseRejectBlock)reject)
{
if (!_updatesService.canRelaunch) {
reject(@"ERR_UPDATES_DISABLED", @"The updates module controller has not been properly initialized. If you're in development mode, you cannot use this method. Otherwise, make sure you have called [[EXUpdatesAppController sharedInstance] start].", nil);
return;
}
[_updatesService requestRelaunchWithCompletion:^(BOOL success) {
if (success) {
resolve(nil);
} else {
reject(@"ERR_UPDATES_RELOAD", @"Could not reload application. Ensure you have set the `bridge` property of EXUpdatesAppController.", nil);
}
}];
}
UM_EXPORT_METHOD_AS(checkForUpdateAsync,
checkForUpdateAsync:(UMPromiseResolveBlock)resolve
reject:(UMPromiseRejectBlock)reject)
{
if (!_updatesService.isStarted) {
reject(@"ERR_UPDATES_DISABLED", @"The updates module controller has not been properly initialized. If you're in development mode, you cannot check for updates. Otherwise, make sure you have called [[EXUpdatesAppController sharedInstance] start].", nil);
return;
}
EXUpdatesFileDownloader *fileDownloader = [[EXUpdatesFileDownloader alloc] initWithUpdatesConfig:_updatesService.config];
[fileDownloader downloadManifestFromURL:_updatesService.config.updateUrl
withDatabase:_updatesService.database
cacheDirectory:_updatesService.directory
successBlock:^(EXUpdatesUpdate *update) {
EXUpdatesUpdate *launchedUpdate = self->_updatesService.launchedUpdate;
id<EXUpdatesSelectionPolicy> selectionPolicy = self->_updatesService.selectionPolicy;
if ([selectionPolicy shouldLoadNewUpdate:update withLaunchedUpdate:launchedUpdate]) {
resolve(@{
@"isAvailable": @(YES),
@"manifest": update.rawManifest
});
} else {
resolve(@{
@"isAvailable": @(NO)
});
}
} errorBlock:^(NSError *error, NSURLResponse *response) {
reject(@"ERR_UPDATES_CHECK", error.localizedDescription, error);
}];
}
UM_EXPORT_METHOD_AS(fetchUpdateAsync,
fetchUpdateAsync:(UMPromiseResolveBlock)resolve
reject:(UMPromiseRejectBlock)reject)
{
if (!_updatesService.isStarted) {
reject(@"ERR_UPDATES_DISABLED", @"The updates module controller has not been properly initialized. If you're in development mode, you cannot fetch updates. Otherwise, make sure you have called [[EXUpdatesAppController sharedInstance] start].", nil);
return;
}
EXUpdatesRemoteAppLoader *remoteAppLoader = [[EXUpdatesRemoteAppLoader alloc] initWithConfig:_updatesService.config database:_updatesService.database directory:_updatesService.directory completionQueue:self.methodQueue];
[remoteAppLoader loadUpdateFromUrl:_updatesService.config.updateUrl onManifest:^BOOL(EXUpdatesUpdate * _Nonnull update) {
return [self->_updatesService.selectionPolicy shouldLoadNewUpdate:update withLaunchedUpdate:self->_updatesService.launchedUpdate];
} success:^(EXUpdatesUpdate * _Nullable update) {
if (update) {
resolve(@{
@"isNew": @(YES),
@"manifest": update.rawManifest
});
} else {
resolve(@{
@"isNew": @(NO)
});
}
} error:^(NSError * _Nonnull error) {
reject(@"ERR_UPDATES_FETCH", @"Failed to download new update", error);
}];
}
@end

View File

@ -0,0 +1,35 @@
// Copyright 2020-present 650 Industries. All rights reserved.
#import <EXUpdates/EXUpdatesConfig.h>
#import <EXUpdates/EXUpdatesDatabase.h>
#import <EXUpdates/EXUpdatesSelectionPolicy.h>
#import <EXUpdates/EXUpdatesUpdate.h>
#import <UMCore/UMInternalModule.h>
NS_ASSUME_NONNULL_BEGIN
typedef void (^EXUpdatesAppRelaunchCompletionBlock)(BOOL success);
@protocol EXUpdatesInterface
@property (nonatomic, readonly) EXUpdatesConfig *config;
@property (nonatomic, readonly) EXUpdatesDatabase *database;
@property (nonatomic, readonly) id<EXUpdatesSelectionPolicy> selectionPolicy;
@property (nonatomic, readonly) NSURL *directory;
@property (nullable, nonatomic, readonly, strong) EXUpdatesUpdate *launchedUpdate;
@property (nullable, nonatomic, readonly, strong) NSDictionary *assetFilesMap;
@property (nonatomic, readonly, assign) BOOL isUsingEmbeddedAssets;
@property (nonatomic, readonly, assign) BOOL isStarted;
@property (nonatomic, readonly, assign) BOOL isEmergencyLaunch;
@property (nonatomic, readonly, assign) BOOL canRelaunch;
- (void)requestRelaunchWithCompletion:(EXUpdatesAppRelaunchCompletionBlock)completion;
@end
@interface EXUpdatesService : NSObject <UMInternalModule, EXUpdatesInterface>
@end
NS_ASSUME_NONNULL_END

View File

@ -0,0 +1,75 @@
// Copyright 2020-present 650 Industries. All rights reserved.
#import <EXUpdates/EXUpdatesAppController.h>
#import <EXUpdates/EXUpdatesService.h>
#import <UMCore/UMUtilities.h>
NS_ASSUME_NONNULL_BEGIN
@implementation EXUpdatesService
UM_REGISTER_MODULE();
+ (const NSArray<Protocol *> *)exportedInterfaces
{
return @[@protocol(EXUpdatesInterface)];
}
- (EXUpdatesConfig *)config
{
return EXUpdatesAppController.sharedInstance.config;
}
- (EXUpdatesDatabase *)database
{
return EXUpdatesAppController.sharedInstance.database;
}
- (id<EXUpdatesSelectionPolicy>)selectionPolicy
{
return EXUpdatesAppController.sharedInstance.selectionPolicy;
}
- (NSURL *)directory
{
return EXUpdatesAppController.sharedInstance.updatesDirectory;
}
- (nullable EXUpdatesUpdate *)launchedUpdate
{
return EXUpdatesAppController.sharedInstance.launchedUpdate;
}
- (nullable NSDictionary *)assetFilesMap
{
return EXUpdatesAppController.sharedInstance.assetFilesMap;
}
- (BOOL)isUsingEmbeddedAssets
{
return EXUpdatesAppController.sharedInstance.isUsingEmbeddedAssets;
}
- (BOOL)isStarted
{
return EXUpdatesAppController.sharedInstance.isStarted;
}
- (BOOL)isEmergencyLaunch
{
return EXUpdatesAppController.sharedInstance.isEmergencyLaunch;
}
- (BOOL)canRelaunch
{
return EXUpdatesAppController.sharedInstance.isStarted;
}
- (void)requestRelaunchWithCompletion:(EXUpdatesAppRelaunchCompletionBlock)completion
{
return [EXUpdatesAppController.sharedInstance requestRelaunchWithCompletion:completion];
}
@end
NS_ASSUME_NONNULL_END

View File

@ -0,0 +1,20 @@
// Copyright © 2019 650 Industries. All rights reserved.
#import <React/RCTBridge.h>
#import <EXUpdates/EXUpdatesConfig.h>
NS_ASSUME_NONNULL_BEGIN
@interface EXUpdatesUtils : NSObject
+ (void)runBlockOnMainThread:(void (^)(void))block;
+ (NSString *)sha256WithData:(NSData *)data;
+ (nullable NSURL *)initializeUpdatesDirectoryWithError:(NSError ** _Nullable)error;
+ (void)sendEventToBridge:(nullable RCTBridge *)bridge withType:(NSString *)eventType body:(NSDictionary *)body;
+ (BOOL)shouldCheckForUpdateWithConfig:(EXUpdatesConfig *)config;
+ (NSString *)getRuntimeVersionWithConfig:(EXUpdatesConfig *)config;
@end
NS_ASSUME_NONNULL_END

View File

@ -0,0 +1,106 @@
// Copyright © 2019 650 Industries. All rights reserved.
#import <CommonCrypto/CommonDigest.h>
#import <EXUpdates/EXUpdatesUtils.h>
#import <SystemConfiguration/SystemConfiguration.h>
#import <arpa/inet.h>
NS_ASSUME_NONNULL_BEGIN
static NSString * const EXUpdatesEventName = @"Expo.nativeUpdatesEvent";
static NSString * const EXUpdatesUtilsErrorDomain = @"EXUpdatesUtils";
@implementation EXUpdatesUtils
+ (void)runBlockOnMainThread:(void (^)(void))block
{
if ([NSThread isMainThread]) {
block();
} else {
dispatch_async(dispatch_get_main_queue(), block);
}
}
+ (NSString *)sha256WithData:(NSData *)data
{
uint8_t digest[CC_SHA256_DIGEST_LENGTH];
CC_SHA256(data.bytes, (CC_LONG)data.length, digest);
NSMutableString *output = [NSMutableString stringWithCapacity:CC_SHA256_DIGEST_LENGTH * 2];
for (int i = 0; i < CC_SHA256_DIGEST_LENGTH; i++)
{
[output appendFormat:@"%02x", digest[i]];
}
return output;
}
+ (nullable NSURL *)initializeUpdatesDirectoryWithError:(NSError ** _Nullable)error
{
NSFileManager *fileManager = NSFileManager.defaultManager;
NSURL *applicationDocumentsDirectory = [[fileManager URLsForDirectory:NSApplicationSupportDirectory inDomains:NSUserDomainMask] lastObject];
NSURL *updatesDirectory = [applicationDocumentsDirectory URLByAppendingPathComponent:@".expo-internal"];
NSString *updatesDirectoryPath = [updatesDirectory path];
BOOL isDir;
BOOL exists = [fileManager fileExistsAtPath:updatesDirectoryPath isDirectory:&isDir];
if (exists) {
if (!isDir) {
*error = [NSError errorWithDomain:EXUpdatesUtilsErrorDomain code:1005 userInfo:@{NSLocalizedDescriptionKey: @"Failed to create the Updates Directory; a file already exists with the required directory name"}];
return nil;
}
} else {
NSError *err;
BOOL wasCreated = [fileManager createDirectoryAtPath:updatesDirectoryPath withIntermediateDirectories:YES attributes:nil error:&err];
if (!wasCreated) {
*error = err;
return nil;
}
}
return updatesDirectory;
}
+ (void)sendEventToBridge:(nullable RCTBridge *)bridge withType:(NSString *)eventType body:(NSDictionary *)body
{
if (bridge) {
NSMutableDictionary *mutableBody = [body mutableCopy];
mutableBody[@"type"] = eventType;
[bridge enqueueJSCall:@"RCTDeviceEventEmitter.emit" args:@[EXUpdatesEventName, mutableBody]];
} else {
NSLog(@"EXUpdates: Could not emit %@ event. Did you set the bridge property on the controller singleton?", eventType);
}
}
+ (BOOL)shouldCheckForUpdateWithConfig:(EXUpdatesConfig *)config
{
switch (config.checkOnLaunch) {
case EXUpdatesCheckAutomaticallyConfigNever:
return NO;
case EXUpdatesCheckAutomaticallyConfigWifiOnly: {
struct sockaddr_in zeroAddress;
bzero(&zeroAddress, sizeof(zeroAddress));
zeroAddress.sin_len = sizeof(zeroAddress);
zeroAddress.sin_family = AF_INET;
SCNetworkReachabilityRef reachability = SCNetworkReachabilityCreateWithAddress(kCFAllocatorDefault, (const struct sockaddr *) &zeroAddress);
SCNetworkReachabilityFlags flags;
SCNetworkReachabilityGetFlags(reachability, &flags);
return (flags & kSCNetworkReachabilityFlagsIsWWAN) == 0;
}
case EXUpdatesCheckAutomaticallyConfigAlways:
default:
return YES;
}
}
+ (NSString *)getRuntimeVersionWithConfig:(EXUpdatesConfig *)config
{
return config.runtimeVersion ?: config.sdkVersion;
}
@end
NS_ASSUME_NONNULL_END

View File

@ -0,0 +1,15 @@
// Copyright © 2019 650 Industries. All rights reserved.
#import <EXUpdates/EXUpdatesUpdate.h>
NS_ASSUME_NONNULL_BEGIN
@interface EXUpdatesBareUpdate : NSObject
+ (EXUpdatesUpdate *)updateWithBareManifest:(NSDictionary *)manifest
config:(EXUpdatesConfig *)config
database:(EXUpdatesDatabase *)database;
@end
NS_ASSUME_NONNULL_END

View File

@ -0,0 +1,84 @@
// Copyright © 2019 650 Industries. All rights reserved.
#import <EXUpdates/EXUpdatesBareUpdate.h>
#import <EXUpdates/EXUpdatesEmbeddedAppLoader.h>
#import <EXUpdates/EXUpdatesUpdate+Private.h>
#import <EXUpdates/EXUpdatesUtils.h>
NS_ASSUME_NONNULL_BEGIN
@implementation EXUpdatesBareUpdate
+ (EXUpdatesUpdate *)updateWithBareManifest:(NSDictionary *)manifest
config:(EXUpdatesConfig *)config
database:(EXUpdatesDatabase *)database
{
EXUpdatesUpdate *update = [[EXUpdatesUpdate alloc] initWithRawManifest:manifest
config:config
database:database];
id updateId = manifest[@"id"];
id commitTime = manifest[@"commitTime"];
id metadata = manifest[@"metadata"];
id assets = manifest[@"assets"];
NSAssert([updateId isKindOfClass:[NSString class]], @"update ID should be a string");
NSAssert([commitTime isKindOfClass:[NSNumber class]], @"commitTime should be a number");
NSAssert(!metadata || [metadata isKindOfClass:[NSDictionary class]], @"metadata should be null or an object");
NSAssert(assets && [assets isKindOfClass:[NSArray class]], @"assets should be a nonnull array");
NSUUID *uuid = [[NSUUID alloc] initWithUUIDString:(NSString *)updateId];
NSAssert(uuid, @"update ID should be a valid UUID");
NSMutableArray<EXUpdatesAsset *> *processedAssets = [NSMutableArray new];
NSString *bundleKey = [NSString stringWithFormat:@"bundle-%@", commitTime];
EXUpdatesAsset *jsBundleAsset = [[EXUpdatesAsset alloc] initWithKey:bundleKey type:EXUpdatesBareEmbeddedBundleFileType];
jsBundleAsset.isLaunchAsset = YES;
jsBundleAsset.mainBundleFilename = EXUpdatesBareEmbeddedBundleFilename;
[processedAssets addObject:jsBundleAsset];
for (NSDictionary *assetDict in (NSArray *)assets) {
NSAssert([assetDict isKindOfClass:[NSDictionary class]], @"assets must be objects");
id packagerHash = assetDict[@"packagerHash"];
id type = assetDict[@"type"];
id mainBundleDir = assetDict[@"nsBundleDir"];
id mainBundleFilename = assetDict[@"nsBundleFilename"];
NSAssert(packagerHash && [packagerHash isKindOfClass:[NSString class]], @"asset key should be a nonnull string");
NSAssert(type && [type isKindOfClass:[NSString class]], @"asset type should be a nonnull string");
NSAssert(mainBundleFilename && [mainBundleFilename isKindOfClass:[NSString class]], @"asset nsBundleFilename should be a nonnull string");
if (mainBundleDir) {
NSAssert([mainBundleDir isKindOfClass:[NSString class]], @"asset nsBundleDir should be a string");
}
NSString *key = [NSString stringWithFormat:@"%@.%@", packagerHash, type];
EXUpdatesAsset *asset = [[EXUpdatesAsset alloc] initWithKey:key type:(NSString *)type];
asset.mainBundleDir = mainBundleDir;
asset.mainBundleFilename = mainBundleFilename;
[processedAssets addObject:asset];
}
update.updateId = uuid;
update.commitTime = [NSDate dateWithTimeIntervalSince1970:[(NSNumber *)commitTime doubleValue] / 1000];
update.runtimeVersion = [EXUpdatesUtils getRuntimeVersionWithConfig:config];
if (metadata) {
update.metadata = (NSDictionary *)metadata;
}
update.status = EXUpdatesUpdateStatusEmbedded;
update.keep = YES;
update.assets = processedAssets;
if ([update.runtimeVersion containsString:@","]) {
@throw [NSException exceptionWithName:NSInternalInconsistencyException
reason:@"Should not be initializing EXUpdatesBareUpdate in an environment with multiple runtime versions."
userInfo:@{}];
}
return update;
}
@end
NS_ASSUME_NONNULL_END

Some files were not shown because too many files have changed in this diff Show More