Skip to content

Instantly share code, notes, and snippets.

@rodneyrehm
Last active January 6, 2020 12:36
Show Gist options
  • Save rodneyrehm/b6726c119233c81a0bc2a0a179d0fa0e to your computer and use it in GitHub Desktop.
Save rodneyrehm/b6726c119233c81a0bc2a0a179d0fa0e to your computer and use it in GitHub Desktop.
ExpoKit: building non-OTA standalones

ExpoKit: building non-OTA standalones

This gist is based on Hosting An App on Your Servers in order to create standalone applications that contain all bundles and assets (that have not been expo published) and do not download over-the-air updates (OTA).

We needed APKs and IPAs for integration/end-to-end testing that would not pull the latest OTA updates, but rather run the code they were built with. We didn't want to expo publish countless intermediate versions. We needed the ability to build different versions concurrently without one build interfering with the other.

Usage

change the values of manifestBaseUrl and releaseChannel in post-expo-export.ts to suit your needs

# clean target, otherwise `expo export` complains and exits
rimraf exported-assets;
# build bundle and export assets
expo export --public-url 'https://example.org/' --output-dir 'exported-assets' --config app.json .
# patch files in `ios/` and `android/` similar to what `expo publish` would have done
ts-node --dir scripts post-expo-export.ts

condensed to npm run build:export:

{
  "scripts": {
    "build:export": "rimraf exported-assets; (expo export --public-url 'https://example.org/' --output-dir 'exported-assets' --config app.json .) && ts-node --dir scripts patch-e2e-build.ts",
  }
}

Problems

The manifest must come from exp.host, otherwise the android app will crash with

Could not load app: java.lang.Exception: Could not load embedded manifest. Are you sure this experience has been published?
java.lang.NullPointerException: Attempt to invoke virtual method 'int java.lang.String.length()' on a null object reference
import path from 'path'
import fs from 'fs-extra'
import { readConfigJsonAsync } from '@expo/config'
import * as ExponentTools from '@expo/xdl/build/detach/ExponentTools'
import StandaloneContext from '@expo/xdl/build/detach/StandaloneContext'
import * as IosPlist from '@expo/xdl/build/detach/IosPlist'
// @ts-ignore IosWorkspace not yet converted to TypeScript
import * as IosWorkspace from '@expo/xdl/build/detach/IosWorkspace'
const projectRoot = path.resolve(__dirname, '..')
const exportedAndroidManifset = path.resolve(projectRoot, 'exported-assets/android-index.json')
const exportedIosManifset = path.resolve(projectRoot, 'exported-assets/ios-index.json')
const exportedBundles = path.resolve(projectRoot, 'exported-assets/bundles')
const releaseChannel = 'ejected-automation'
;(async () => {
const { exp } = await readConfigJsonAsync(projectRoot)
// manifest *must* come from `exp.host`, otherwise the android app will crash with
// Could not load app: java.lang.Exception: Could not load embedded manifest. Are you sure this experience has been published?
// java.lang.NullPointerException: Attempt to invoke virtual method 'int java.lang.String.length()' on a null object reference
const manifestBaseUrl = '//exp.host/@me/project'
const manifestUrl = 'https:' + manifestBaseUrl
const manifestInitialUrl = 'exp:' + manifestBaseUrl
const androidManifest = await readJSON(exportedAndroidManifset)
const iosManifest = await readJSON(exportedIosManifset)
if (exp.ios && exp.ios.publishManifestPath) {
iosManifest.updates = { enabled: false }
iosManifest.releaseChannel = releaseChannel
await writeJSON(exp.ios.publishManifestPath, iosManifest)
const exportedIosBundle = path.resolve(exportedBundles, iosManifest.bundleUrl.replace(/^.+\/bundles\//, ''))
await copyFile(exportedIosBundle, exp.ios.publishBundlePath)
const context = StandaloneContext.createUserContext(projectRoot, exp)
const { supportingDirectory } = IosWorkspace.getPaths(context)
await IosPlist.modifyAsync(supportingDirectory, 'EXShell', (shellPlist: any) => {
shellPlist.releaseChannel = releaseChannel
shellPlist.areRemoteUpdatesEnabled = false
shellPlist.manifestUrl = manifestInitialUrl
return shellPlist
})
}
if (exp.android && exp.android.publishManifestPath) {
androidManifest.updates = { enabled: false }
androidManifest.releaseChannel = releaseChannel
await writeJSON(exp.android.publishManifestPath, androidManifest)
const exportedAndroidBundle = path.resolve(exportedBundles, androidManifest.bundleUrl.replace(/^.+\/bundles\//, ''))
await copyFile(exportedAndroidBundle, exp.android.publishBundlePath)
const constantsPath = path.join(
projectRoot,
'android',
'app',
'src',
'main',
'java',
'host',
'exp',
'exponent',
'generated',
'AppConstants.java',
)
await ExponentTools.deleteLinesInFileAsync(
'START EMBEDDED RESPONSES',
'END EMBEDDED RESPONSES',
constantsPath,
)
await ExponentTools.regexFileAsync(
'// ADD EMBEDDED RESPONSES HERE',
`// ADD EMBEDDED RESPONSES HERE
// START EMBEDDED RESPONSES
embeddedResponses.add(new Constants.EmbeddedResponse("${manifestUrl}", "assets://shell-app-manifest.json", "application/json"));
embeddedResponses.add(new Constants.EmbeddedResponse("${androidManifest.bundleUrl}", "assets://shell-app.bundle", "application/javascript"));
// END EMBEDDED RESPONSES`,
constantsPath,
)
await ExponentTools.regexFileAsync(
/RELEASE_CHANNEL = "[^"]*"/,
`RELEASE_CHANNEL = "${releaseChannel}"`,
constantsPath,
)
await ExponentTools.regexFileAsync(
/ARE_REMOTE_UPDATES_ENABLED = true/,
'ARE_REMOTE_UPDATES_ENABLED = false',
constantsPath,
)
await ExponentTools.regexFileAsync(
/INITIAL_URL = "[^"]*"/,
`INITIAL_URL = "${manifestInitialUrl}"`,
constantsPath,
)
const mainActivitiyPath = path.join(
projectRoot,
'android',
'app',
'src',
'main',
'java',
'host',
'exp',
'exponent',
'MainActivity.java',
)
await ExponentTools.regexFileAsync(
/return "exp:\/\/[^"]*";/,
`return "${manifestInitialUrl}";`,
mainActivitiyPath,
)
}
})()
async function readJSON (source: string) {
const content = await fs.readFile(source, 'utf8')
return JSON.parse(content)
}
async function writeJSON (target: string, data: any) {
const content = JSON.stringify(data)
await fs.writeFile(target, content, 'utf8')
}
async function copyFile (source: string, target: string) {
const content = await fs.readFile(source)
await fs.writeFile(target, content)
}
@rodneyrehm
Copy link
Author

opened expo/expo#6683 to inquire for possible fix in expo cli

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment