Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Question] Codepush Integration #1

Closed
mvolonnino opened this issue Dec 21, 2023 · 10 comments
Closed

[Question] Codepush Integration #1

mvolonnino opened this issue Dec 21, 2023 · 10 comments

Comments

@mvolonnino
Copy link

@mgscreativa - Hey man! been digging this past week trying to get codepush to work within my Expo managed project.

I have codepush setup as well through app center, and can also create and deploy codepush bundles to appcenter as well.

The steps i use to create a codepush update & test it:

package.json scripts:

"update:staging:ios": "appcenter codepush release-react -a org/App -d Staging -e index.tsx --plist-file ios/App/Info.plist",
"update:staging:android": "appcenter codepush release-react -a org/App-1 -d Staging -e index.tsx",
 "update:staging:all": "yarn run update:staging:ios && yarn run update:staging:android",

Steps in order:

  1. two new eas builds under the preview-staging profile for both ios and android
  2. npx expo prebuild to generate the ios and android folders needed locally for the codepush update
  3. yarn update:staging:all to which i get successfull codepush bundles and releases to appcenter

Android is not working at all for me when it comes to codepush updates - when trying through a manual approach, it would see that there is an update for the installed version, download it, then when trying to install it i would run into this error:

codepush error update is invalid - a JS bundle file name "null"

When it comes to iOS on both the manual approach & the silent approach through only the codepush decorator, it would download, install, and restart the app. After it restarted it would succesfully use the codepush bundle, but upon force closing the app and then re opening, it would not use the bundle anymore and be on the original js bundle included in the adhoc release.

Im using Expo sdk 49 with Expo Router with the expo-dev-client setup.

I have internal adhoc testing setup as well where I can utilize the preview-staging build profiles that has also been working as expected, I have been able to continuously utilize eas build commands to build new builds for testing through preview-staging with everything working.

My current setup in eas.json.

{
  "cli": {
    "version": ">= 3.13.2"
  },
  "build": {
    // * Base build profiles that can be extended
    // this will run a dev staging build, and have QR code to scan for device
    "dev-staging": {
      "distribution": "internal",
      "developmentClient": true,
      "channel": "dev-staging", // this is used for EAS Updates to target specific builds
      "env": {
        "APP_ENV": "STAGING",
        "ANDROID_GOOGLE_SERVICES": "./google-services/google-services.json",
        "IOS_GOOGLE_SERVICES": "./google-services/GoogleService-Info.plist"
      }
    },
    // this will run a dev production build, and have QR code to scan for device
    "dev-production": {
      "distribution": "internal",
      "developmentClient": true,
      "channel": "dev-production",
      "env": {
        "APP_ENV": "PRODUCTION",
        "ANDROID_GOOGLE_SERVICES": "./google-services/google-services.json",
        "IOS_GOOGLE_SERVICES": "./google-services/GoogleService-Info.plist"
      }
    },
    // ...

    // * Build profiles that extend the base build profiles for simulator
    // this will run a dev simulator build, and can download & install on simulator through the CLI
    "dev-simulator": {
      "extends": "dev-staging",
      "channel": "dev-simulator",
      "ios": {
        "simulator": true
      }
    },
    "prod-simulator": {
      "extends": "dev-production",
      "ios": {
        "simulator": true
      }
    },
    // ...

    // * Build profiles used for preview builds (adhoc internal testing)
    // channels can be used for EAS Updates to target specific builds
    "preview-staging": {
      "extends": "dev-staging",
      "channel": "preview-staging",
      "developmentClient": false
    },
    "preview-production": {
      "extends": "dev-production",
      "channel": "preview-production",
      "developmentClient": false
    },
    // ...

    // * Build profiles used for release builds (app stores & test flight)
    // channels can be used for EAS Updates to target specific builds
    "release-beta": {
      "extends": "preview-staging",
      "channel": "release-beta",
      "distribution": "store"
    },
    "release": {
      "extends": "preview-production",
      "channel": "release",
      "distribution": "store"
    }
  },
  "submit": {
    "release-beta": {
      "android": {
        "track": "internal",
        "releaseStatus": "draft"
      }
    },
    "release": {
      "android": {
        "track": "production",
        "releaseStatus": "draft"
      }
    }
  }
}

I have an index.tsx file right now that looks like this:

import '@expo/metro-runtime';

import { registerRootComponent } from 'expo';
import { ExpoRoot } from 'expo-router';

import deepLinkHandlers from 'features/DeepLinking';
import { nativeSentryWrap, sentryInit } from 'sentry/config';
import codePushWrap from 'updates/config';

/*
  Here we setup all notification handlers that are needed outside the scope of React
  - this are handlers that mostly handle notifications when the app is in background/quit state
  - very inconsistent on iOS and hard to debug
*/
deepLinkHandlers.backgroundHandlers.setBackgroundMessageHandler();

sentryInit();
/**
 * updated entry point to be able to utilize `sentry` and fix an issue with `expo-router`
 *
 * @see https://docs.expo.dev/router/reference/troubleshooting/
 */
export const App = () => {
  const ctx = require.context('./app');
  return <ExpoRoot context={ctx} />;
};

const CodePushedApp = codePushWrap(nativeSentryWrap(App));
registerRootComponent(CodePushedApp);

codePushWrap is just a simple wrapper method that uses codePush

  • this method worked on iOS like the manual check did below and the same issue as well
  • Android would never restart after an app resume, so i never saw anything in terms of the codepush actually downloading and installing, etc
import { CodePushOptions } from 'react-native-code-push';
import codePush from './codePush';

/**
 * Options for the `codePush` wrapper
 * - these options are configured to allow the the app `on start up` to check for updates
 * - if we have an update, it will install and restart immediately, making it seamless to the user
 *
 * - this is helpful, as if the app hits this `checkFrequency` - we then know its safe to restart the app as the user is not in the middle of something
 */
const codePushOptions: CodePushOptions = {
  checkFrequency: codePush.CheckFrequency.ON_APP_RESUME,
  installMode: codePush.InstallMode.ON_NEXT_SUSPEND,
};

/**
 * Wraps the root component with CodePush with the given options
 * @see codePushOptions
 * @param RootComponent - App component that is exported from 'expo-router/_app'
 */
function codePushWrap(RootComponent: React.ComponentType<JSX.IntrinsicAttributes>) {
  return codePush(codePushOptions)(RootComponent);
}

export default codePushWrap;

My manual approach looks like this:

  • this handles each step so i can show UI gracefully to the user
  • This manual approach worked for iOS with the codePush.restartApp. But upon user closing app, it reverted and didnt use the codepush bundle (on the codepush website, it shows 1 install, 1 download, 0 rollbacks as well)
  • Android is where i got that error above.
/* eslint-disable @typescript-eslint/no-use-before-define */
import { Alert, Platform } from 'react-native';
import { RemotePackage } from 'react-native-code-push';

import { useSettingsStore } from 'stores';
import Toast from 'layouts/ToastLayout/Toast';
import Wait from 'utils/Wait';
import codePush from './codePush';

const { updateAppStateSlice } = useSettingsStore.getState();

export enum UpdateCheck {
  Interactive = 'interactive',
  Silent = 'silent',
}

async function checkForUpdates(type: UpdateCheck) {
  /**
   * Interactive updates are used to check for updates when the user manually checks for updates
   * - this is helpful to show the user that there are updates available for the app, and give them the option to install the update
   */
  if (type === UpdateCheck.Interactive) {
    try {
      updateAppStateSlice({ checkingForUpdates: true });
      await interactiveCheck();
    } catch (error) {
      handleCodePushError(error);
    } finally {
      // clean up any alerts that may have been left behind
      Toast.Burnt.dismissAllAlerts();
      updateAppStateSlice({ checkingForUpdates: false });
    }
  }

  /**
   * Silent updates are used to check for updates when the app comes back into the foreground
   * - this is helpful to add an indicator to the user that there are updates available for the app, without interrupting their current flow
   */
  if (type === UpdateCheck.Silent) {
    try {
      const update = await codePush.checkForUpdate();
      const hasUpdate = !!update;
      if (hasUpdate) {
        console.log(`[CodePush] Update Metadata: ${JSON.stringify(update)}`);
      } else {
        console.log(`[CodePush] No update metadata found`);
      }

      updateAppStateSlice({ update });
    } catch (error) {
      // if there is an error here, we don't need to show the user anything since this is a silent check
      console.warn(`Error silently checking for updates: ${error}`);
    }
  }
}

export default checkForUpdates;

/**
 * Checks for updates on the `codepush` server with `alerts` shown to the user
 * - this includes loading indicators and alerts to install the update, etc
 * @see UpdateCheck.Interactive
 */
async function interactiveCheck() {
  showBurntLoading('Checking for updates', 'Please wait...');

  const { appState } = useSettingsStore.getState();
  const update = appState.update || (await codePush.checkForUpdate());

  await waitDismissBurntAlerts();

  if (!update) {
    updateAppStateSlice({ update: null });
    Alert.alert(
      'No Update Available',
      `There is no update available at this time. Please check the ${Platform.select({
        ios: 'App',
        android: 'Play',
      })} Store periodically for new releases.`
    );
    return;
  }

  if (update.isMandatory) {
    Alert.alert(
      'Update Available',
      'This update is mandatory and will restart the app and apply the update',
      [
        {
          text: 'Install',
          onPress: async () => {
            await installUpdate(update);
          },
          isPreferred: true,
        },
      ],
      { cancelable: false }
    );
    return;
  }

  Alert.alert(
    'Update Available',
    'Installing the update will restart the app and apply the update',
    [
      {
        text: 'Install',
        onPress: async () => {
          await installUpdate(update);
        },
      },
      {
        style: 'destructive',
        text: 'Cancel',
        onPress: () => {
          updateAppStateSlice({ update });
        },
      },
    ]
  );
}

/**
 * Installs the update with `UI` shown to the user to indicate each step
 */
async function installUpdate(update: RemotePackage) {
  try {
    showBurntLoading('Downloading update', 'Please wait...');

    const download = await update.download();
    Alert.alert(
      'Update Downloaded',
      `JSON.stringify(download): ${JSON.stringify(download, null, 2)}`
    );

    await waitDismissBurntAlerts();

    showBurntLoading('Installing update', 'Please wait...');

    await download.install(codePush.InstallMode.ON_NEXT_RESTART);
    await codePush.notifyAppReady();

    await waitDismissBurntAlerts();

    Toast.Burnt.alert({
      preset: 'done',
      title: 'Update installed',
      message: 'The app will now restart to apply the update',
      duration: 2, // matches the wait time below to give the user time to read the alert
      shouldDismissByTap: false,
    });

    await Wait(2000);
    Toast.Burnt.dismissAllAlerts();
    await Wait(300);

    codePush.restartApp();
  } catch (error) {
    Toast.Burnt.dismissAllAlerts();
    handleCodePushError(error);
  }
}

/**
 * Waits for the `Burnt` alerts to dismiss
 * - first `wait` adds a `500ms` delay to allow the `Burnt` loading indicator more time to show
 * - second `wait` adds a `300ms` delay to allow slight delay before next alert is shown
 */
async function waitDismissBurntAlerts() {
  await Wait(500);
  Toast.Burnt.dismissAllAlerts();
  await Wait(300);
}

/**
 * Wrapper around `Burnt` loading indicator
 * - this config is used for each loading indicator shown to the user
 * - `autoHide` is set to `false` to allow the loading indicator to stay on screen until we dismiss it
 * - `shouldDismissByTap` is set to `false` to allow the loading indicator to stay on screen
 */
function showBurntLoading(title: string, message: string) {
  Toast.Burnt.alert({
    preset: 'spinner',
    title,
    message,
    duration: 5,
    autoHide: false,
    shouldDismissByTap: false,
  });
}

/**
 * Handles errors from `codepush` and shows an alert to the user
 * - will check to see if their is an `error.statusCode` and `error.message` to show to the user
 * @param error - error from `codepush`
 */
function handleCodePushError(error: any) {
  console.warn(`Error checking for updates: ${error}`);
  if (error instanceof Error && 'statusCode' in error && 'message' in error) {
    Alert.alert(
      'Error Checking for Updates',
      `There was an error checking for updates. Please try again later. ${
        error?.statusCode ? `Error Code: ${error.statusCode}` : ''
      }${error?.message ? `Error Message: ${error.message}` : ''}`
    );
  } else {
    Alert.alert(
      'Error Checking for Updates',
      `There was an error checking for updates. Please try again later. Error ${JSON.stringify(
        error,
        null,
        2
      )}`
    );
  }
}
@mvolonnino
Copy link
Author

@mgscreativa - i know this is a lot of information but wasnt sure if you had any insights for me or not, as I see your example utilizes expo and expo router too!

Banging my head against a wall right now trying to figure it out, so anything could be helpful 🙌

@mvolonnino
Copy link
Author

@mgscreativa - I should note, after iOS silent codepush successful update and app suspend that did apply the codepush bundle - then after force closing the app to see if it would still be applied and wasnt, when I try my manual approach (through a button in a drawer component), it hits the no update available showing to me that the app does have the update downloaded already?

@mgscreativa
Copy link
Owner

mgscreativa commented Dec 21, 2023

Hi! that's really strange because upon update, the bundle gets overwritten. The best approach I suggest is that you download and compile test apps with my project and try to replicate the issue, If you can't then there's an issue with your code.

Another thing I notice is that if you see ios an android folders in your project, then it's not an expo managed project. This example is an Expo managed app that uses a dev build to work

Another thing I notice is in your codepush build command, I think you missed --use-hermes and --target-binary-version, please check https://github.com/mgscreativa/react-native-code-push-expo-plugin-managed-workflow#send-a-bundle-update-release

@mvolonnino
Copy link
Author

@mgscreativa - so with the prebuild command I am doing and the --plist-file ios/App/Info.plist attached to the script, it grabs the correct target binary automatically and creates the bundles pointed towards what is in the app.json.

I will definitely try adding the --use-hermes as well.

Another thing I notice is that if you see ios an android folders in your project, then it's not an expo managed project. This example is an Expo managed app that uses a dev build to work

In terms of this, how do you go about creating a build that does not have the devClient: true, as if that is the case, to my knowledge - codepush bundle is not used and instead the dev clients bundle is always used with hot reload?

Currently I am using eas build --profile preview-staging --platform all which creates a build that does not utilize the devClient to be able to then test a codepush bundle on a 'release' mode of the app (with android its just the apk i download on my android device which is the standalone app, and iOS utilizing the adhoc internal distro for my own device to also get the standalone app to test as well.

Am I off basis here in my thinking?

@mvolonnino
Copy link
Author

I guess a better way to state this, I would not really need codepush OTA on dev client builds as that is where I currently do development, I would need them for the production/beta builds that are distributed through the app stores & TestFlight (for my QA testers) - which in my thought process would be the same as a standalone app which is what my preview- profiles in the eas.json creates for me.

@mvolonnino
Copy link
Author

Okay so adding the --use-hermes does not help either, still on iOS, it does download and install correctly to which after that first reload, shows the codepush bundle, but then after the next restart, just reverts back to the original bundle

@mvolonnino
Copy link
Author

Upon further testing, the bundle does correctly install like ive said, I have methods setup to attach on the codepush label in the version im running to be able to tell which codepush update the app is running. That all works just fine on iOS, once I close out the app, the I have this method to run:

import { useEffect } from 'react';
import { Alert, AppState } from 'react-native';
import moment from 'moment-timezone';

import { useSettingsStore } from 'stores';
import codePush from 'updates/codePush';
import checkForUpdates, { UpdateCheck } from '../checkForUpdates';

const { updateAppStateSlice } = useSettingsStore.getState();

/**
 * Checks to see if the last check time is more than an hour ago
 * - rate limit of 8 request per 5 mins
 * @see https://learn.microsoft.com/en-us/appcenter/distribution/codepush/
 */
const hasEnoughHoursPast = (lastCheckTime: number | null, numHr: number) => {
  if (!lastCheckTime) return true;
  return moment().diff(moment(lastCheckTime), 'hours') >= numHr;
};

/**
 * Hook that checks for updates on app state change
 * - this is used to check for updates when the app is in `active` state
 * - defaults to `UpdateCheck.Silent` which should only show an indicator that an update is available to not interrupt the user (we do not want to interrupt the user if they are in the middle of something, i.e registering for a program, etc)
 *
 * @default UpdateCheck.Silent
 */
const useCheckForUpdates = (type: UpdateCheck = UpdateCheck.Silent) => {
  useEffect(() => {
    const listener = AppState.addEventListener('change', nextAppState => {
      const { lastSilentUpdateCheckTime } = useSettingsStore.getState().appState;

      const shouldCheckForUpdates =
        nextAppState === 'active' && hasEnoughHoursPast(lastSilentUpdateCheckTime, 1);

      if (shouldCheckForUpdates) {
        console.log(`[useCheckForUpdates][CodePush] Checking for updates`);
        (async () => {
          await checkForUpdates(type);
          updateAppStateSlice({ lastSilentUpdateCheckTime: moment().valueOf() });
        })();
      }
    });

    return () => {
      listener.remove();
    };
  }, [type]);

  /**
   * This checks to see if the app is now running on a new version that was just installed from codepush
   * - will update the `codePushLabel` in the app state to reflect the new version, and clear the `update` from the app state since we are now running on the latest update from codepush or version from the app store
   */
  useEffect(() => {
    (async () => {
      const LocalPackage = await codePush.getUpdateMetadata();
      Alert.alert('LocalPackage', JSON.stringify(LocalPackage));

      if (LocalPackage?.isFirstRun) {
        console.log(
          `[useCheckForUpdates][CodePush] Update Metadata: ${JSON.stringify(LocalPackage)}`
        );
        const { label } = LocalPackage;
        updateAppStateSlice({ codePushLabel: label });
      } else if (!LocalPackage) {
        console.log(
          `[useCheckForUpdates][CodePush] No update metadata found, running on latest released version`
        );
        updateAppStateSlice({ codePushLabel: null });
      }
    })();
  }, []);
};

export default useCheckForUpdates;

And the LocalPackage within the second useEffect does show in the Alert.alert, which shows some keys points that all look correct to me:

{
        isPending: false,
        packageSize: 2337248,
        appVersion: '2.2.9',
        packageHash: 'fakehash',
        downloadUrl: 'fakeurl',
        failedInstall: false,
        isFirstRun: false,
      }

But like I said, its not running the codepush bundle, its the original bundle

@mgscreativa
Copy link
Owner

I don't really know why that happens to you, I have it working in several production apps Expo managed, and locally compiled with EAS. This demo is based on this PR microsoft/react-native-code-push#2415 from @deggertsen, you can take a look at the PR and update my test project files just to check.

Another check you can do is to use this repo and compile it and send it to test flight to see if the modified bundle hits the app after restart. If it does, then there's an issue with your project.

@mvolonnino
Copy link
Author

Damn that is wild, I wonder what are your steps for building a standalone app? From your commands it looks like you do it locally - have you ever tried using eas build for it to be built in their cloud workers?

Trying to not have to do local builds and need to involve xcode & android studio with the new workflow

@mgscreativa
Copy link
Owner

Local builds are not hard at all, you just need to setup the dev environment for iOS and android and EAS does the rest.

Didn't tried EAS cloud build, but believe me, local builds are the same and the error you account for is not related to the build process, because it will fail otherwise

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants