Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions .github/workflows/publish-ota-update.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
name: Publish OTA Update

on:
workflow_dispatch:
push:
tags:
- 'v*.*.*'
pull_request:

permissions:
contents: read
packages: write

jobs:
build_and_publish:
name: Build and Publish OTA Update
runs-on: ubuntu-latest
strategy:
matrix:
platform: [ios, android]

steps:
- name: Check out the code
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7

- uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
with: {node-version-file: '.node-version'}

- name: Install dependencies
run: npm ci

- name: Build OTA update
run: |
mkdir -p dist/ota/bundles
npm run build:ota -- --platform ${{ matrix.platform }}

- name: Create Dockerfile for OTA bundle
run: |
cat > dist/ota/Dockerfile <<'EOF'
FROM scratch
COPY . /
EOF

- name: Generate image tag
id: tag
run: |
NATIVE_COMPAT_VERSION=$(cat NATIVE_VERSION)
REPO_LOWERCASE=$(echo "${{ github.repository }}" | tr '[:upper:]' '[:lower:]')
echo "repo=${REPO_LOWERCASE}" >> $GITHUB_OUTPUT
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
echo "tag=pr-${{ github.event.pull_request.number }}-${NATIVE_COMPAT_VERSION}-${{ matrix.platform }}" >> $GITHUB_OUTPUT
else
echo "tag=${{ github.ref_name }}-${NATIVE_COMPAT_VERSION}-${{ matrix.platform }}" >> $GITHUB_OUTPUT
fi

- name: Get Git commit timestamps
id: git-timestamp
run: echo "timestamp=$(git log -1 --pretty=%ct)" >> $GITHUB_OUTPUT

- name: Docker meta
id: meta
uses: docker/metadata-action@v5
env:
DOCKER_METADATA_ANNOTATIONS_LEVELS: manifest,index
with:
images: ghcr.io/${{ steps.tag.outputs.repo }}
tags: |
type=raw,value=${{ steps.tag.outputs.tag }}

- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

- name: Publish to GHCR
uses: docker/build-push-action@v6
env:
SOURCE_DATE_EPOCH: ${{ steps.git-timestamp.outputs.timestamp }}
with:
context: ./dist/ota
push: true
tags: ${{ steps.meta.outputs.tags }}
annotations: |
manifest:test.annotation=value
${{ steps.meta.outputs.annotations }}
# The following two lines make the image public
# See: https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry#ensuring-workflow-access-to-your-package
# and: https://github.com/docker/build-push-action/issues/294
# We're not using login-action as we have the permissions set at the workflow level
# All we need to do is set the actor to the GITHUB_TOKEN user
github-token: ${{ secrets.GITHUB_TOKEN }}
1 change: 1 addition & 0 deletions NATIVE_VERSION
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
1.0.0
103 changes: 103 additions & 0 deletions android/app/src/main/java/com/allaboutolaf/MainApplication.java
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,109 @@ protected String getJSMainModuleName() {
return "index";
}

@Override
protected String getJSBundleFile() {
android.content.SharedPreferences prefs = getApplication().getSharedPreferences("ota", android.content.Context.MODE_PRIVATE);
String pendingBundlePath = prefs.getString("jsBundlePath_pending", null);

// 1. Activate new update if one is pending
if (pendingBundlePath != null) {
android.content.SharedPreferences.Editor editor = prefs.edit();
editor.putString("jsBundlePath", pendingBundlePath);
editor.putBoolean("isUpdatePendingVerification", true);
editor.remove("jsBundlePath_pending");

// Also save the version tag of the new update
java.io.File pendingBundle = new java.io.File(pendingBundlePath);
String versionTag = pendingBundle.getParentFile().getParentFile().getName();
editor.putString("currentJSVersion", versionTag);

editor.apply();
}

// 2. Check if the last update caused a crash
if (prefs.getBoolean("isUpdatePendingVerification", false)) {
// Crash detected! Start rollback.
android.content.SharedPreferences.Editor editor = prefs.edit();
editor.remove("isUpdatePendingVerification");

String currentBundlePath = prefs.getString("jsBundlePath", null);
if (currentBundlePath != null) {
java.io.File currentBundle = new java.io.File(currentBundlePath);
java.io.File updateDir = currentBundle.getParentFile().getParentFile();

// Delete the faulty update
deleteRecursively(updateDir);

// Find the next best update
String channel = prefs.getString("updateChannel", "release");
java.io.File channelDir = new java.io.File(getApplication().getFilesDir(), "updates/" + channel);
java.io.File[] availableUpdates = getSortedUpdatesInDirectory(channelDir);

boolean foundViableUpdate = false;
for (int i = 0; i < Math.min(3, availableUpdates.length); i++) {
java.io.File nextBestUpdate = availableUpdates[i];
String nextBestBundlePath = new java.io.File(nextBestUpdate, "bundles/main.jsbundle").getAbsolutePath();

if (new java.io.File(nextBestBundlePath).exists()) {
editor.putString("jsBundlePath", nextBestBundlePath);
foundViableUpdate = true;
break;
}
}

if (!foundViableUpdate) {
editor.remove("jsBundlePath");

// Cross-channel fallback
if (!channel.equals("release")) {
deleteRecursively(channelDir);
editor.remove("updateChannel");

java.io.File releaseChannelDir = new java.io.File(getApplication().getFilesDir(), "updates/release");
java.io.File[] releaseUpdates = getSortedUpdatesInDirectory(releaseChannelDir);

if (releaseUpdates.length > 0) {
java.io.File latestReleaseUpdate = releaseUpdates[0];
String latestReleaseBundlePath = new java.io.File(latestReleaseUpdate, "bundles/main.jsbundle").getAbsolutePath();
editor.putString("jsBundlePath", latestReleaseBundlePath);
}
}
}
}
editor.apply();
}

// 3. Determine the final path
String jsBundlePath = prefs.getString("jsBundlePath", null);
if (jsBundlePath != null) {
java.io.File file = new java.io.File(jsBundlePath);
if (file.exists()) {
return jsBundlePath;
}
}

return super.getJSBundleFile();
}

private java.io.File[] getSortedUpdatesInDirectory(java.io.File directory) {
java.io.File[] files = directory.listFiles();
if (files == null) {
return new java.io.File[0];
}
java.util.Arrays.sort(files, (f1, f2) -> Long.compare(f2.lastModified(), f1.lastModified()));
return files;
}

private void deleteRecursively(java.io.File fileOrDirectory) {
if (fileOrDirectory.isDirectory()) {
for (java.io.File child : fileOrDirectory.listFiles()) {
deleteRecursively(child);
}
}
fileOrDirectory.delete();
}

@Override
protected boolean isNewArchEnabled() {
return BuildConfig.IS_NEW_ARCHITECTURE_ENABLED;
Expand Down
102 changes: 97 additions & 5 deletions ios/AllAboutOlaf/AppDelegate.mm
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,103 @@ - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(

- (NSURL *)sourceURLForBridge:(RCTBridge *)bridge
{
#if DEBUG
return [[RCTBundleURLProvider sharedSettings] jsBundleURLForBundleRoot:@"index"];
#else
return [[NSBundle mainBundle] URLForResource:@"main" withExtension:@"jsbundle"];
#endif
NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults];
NSString *pendingBundlePath = [defaults stringForKey:@"jsBundlePath_pending"];

// 1. Activate new update if one is pending
if (pendingBundlePath != nil) {
[defaults setObject:pendingBundlePath forKey:@"jsBundlePath"];
[defaults setBool:YES forKey:@"isUpdatePendingVerification"];
[defaults removeObjectForKey:@"jsBundlePath_pending"];

// Also save the version tag of the new update
NSURL *pendingBundleURL = [NSURL fileURLWithPath:pendingBundlePath];
NSURL *updateDirURL = [[pendingBundleURL URLByDeletingLastPathComponent] URLByDeletingLastPathComponent];
NSString *versionTag = [updateDirURL lastPathComponent];
[defaults setObject:versionTag forKey:@"currentJSVersion"];
}

// 2. Check if the last update caused a crash
if ([defaults boolForKey:@"isUpdatePendingVerification"]) {
// Crash detected! Start rollback.
[defaults removeObjectForKey:@"isUpdatePendingVerification"]; // Assume failure until proven otherwise

NSString *currentBundlePath = [defaults stringForKey:@"jsBundlePath"];
NSURL *currentBundleURL = [NSURL fileURLWithPath:currentBundlePath];
NSURL *updateDirURL = [[currentBundleURL URLByDeletingLastPathComponent] URLByDeletingLastPathComponent];

// Delete the faulty update
[[NSFileManager defaultManager] removeItemAtURL:updateDirURL error:nil];

// Find the next best update
NSString *channel = [defaults stringForKey:@"updateChannel"] ?: @"release";
NSURL *channelDirURL = [[self getUpdatesDirectory] URLByAppendingPathComponent:channel];

NSArray *availableUpdates = [self getSortedUpdatesInDirectory:channelDirURL];

// Try up to 3 historical updates
BOOL foundViableUpdate = NO;
for (int i = 0; i < MIN(3, [availableUpdates count]); i++) {
NSURL *nextBestUpdateURL = [availableUpdates objectAtIndex:i];
NSString *nextBestBundlePath = [[nextBestUpdateURL URLByAppendingPathComponent:@"bundles"] URLByAppendingPathComponent:@"main.jsbundle"].path;

if ([[NSFileManager defaultManager] fileExistsAtPath:nextBestBundlePath]) {
[defaults setObject:nextBestBundlePath forKey:@"jsBundlePath"];
foundViableUpdate = YES;
break;
}
}

if (!foundViableUpdate) {
[defaults removeObjectForKey:@"jsBundlePath"];

// Cross-channel fallback
if (![channel isEqualToString:@"release"]) {
[[NSFileManager defaultManager] removeItemAtURL:channelDirURL error:nil];
[defaults removeObjectForKey:@"updateChannel"];

NSURL *releaseChannelDirURL = [[self getUpdatesDirectory] URLByAppendingPathComponent:@"release"];
NSArray *releaseUpdates = [self getSortedUpdatesInDirectory:releaseChannelDirURL];

if ([releaseUpdates count] > 0) {
NSURL *latestReleaseUpdateURL = [releaseUpdates firstObject];
NSString *latestReleaseBundlePath = [[latestReleaseUpdateURL URLByAppendingPathComponent:@"bundles"] URLByAppendingPathComponent:@"main.jsbundle"].path;
[defaults setObject:latestReleaseBundlePath forKey:@"jsBundlePath"];
}
}
}
}

// 3. Determine the final URL
NSString *jsBundlePath = [defaults stringForKey:@"jsBundlePath"];
if (jsBundlePath != nil && [[NSFileManager defaultManager] fileExistsAtPath:jsBundlePath]) {
return [NSURL fileURLWithPath:jsBundlePath];
} else {
#if DEBUG
return [[RCTBundleURLProvider sharedSettings] jsBundleURLForBundleRoot:@"index"];
#else
return [[NSBundle mainBundle] URLForResource:@"main" withExtension:@"jsbundle"];
#endif
}
}

- (NSURL *)getUpdatesDirectory {
return [[[[NSFileManager defaultManager] URLsForDirectory:NSApplicationSupportDirectory inDomains:NSUserDomainMask] firstObject] URLByAppendingPathComponent:@"updates"];
}

- (NSArray<NSURL *> *)getSortedUpdatesInDirectory:(NSURL *)directoryURL {
NSArray *properties = @[NSURLContentModificationDateKey];
NSArray *contents = [[NSFileManager defaultManager] contentsOfDirectoryAtURL:directoryURL
includingPropertiesForKeys:properties
options:NSDirectoryEnumerationSkipsHiddenFiles
error:nil];

return [contents sortedArrayUsingComparator:^NSComparisonResult(NSURL *url1, NSURL *url2) {
NSDate *date1, *date2;
[url1 getResourceValue:&date1 forKey:NSURLContentModificationDateKey error:nil];
[url2 getResourceValue:&date2 forKey:NSURLContentModificationDateKey error:nil];
return [date2 compare:date1]; // Descending order
}];
}

@end
Loading
Loading