Skip to content

🏗️ Migrate to Expo + EAS + CNG (Continuous Native Generation)#617

Open
guytepper wants to merge 1 commit into
mainfrom
migrate/expo-eas-cng
Open

🏗️ Migrate to Expo + EAS + CNG (Continuous Native Generation)#617
guytepper wants to merge 1 commit into
mainfrom
migrate/expo-eas-cng

Conversation

@guytepper
Copy link
Copy Markdown
Member

Summary

Migrates Better Rail from a bare workflow (committed, hand-maintained ios/ + android/) to Continuous Native Generation. The native projects are now git-ignored and regenerated by expo prebuild from app.config.ts + config plugins + targets/. Builds run on EAS.

All existing native targets are preserved and reproduced declaratively.

What changed

Foundation

  • app.config.ts — single source of truth; env-driven (APP_VARIANT) dev/prod Firebase, Info.plist, entitlements (App Group, keychain, Live Activities), URL schemes, fonts, plugins.
  • eas.jsondevelopment / preview / production profiles.
  • babel-preset-expo + expo/metro-config; entry via registerRootComponent.
  • Icons + GoogleService files relocated to repo root; ios/ + android/ git-ignored.

iOS@bacons/apple-targets (targets/) + plugins/withBetterRailIos

  • All 5 targets reproduced: app, BetterRailWidget (WidgetKit + Live Activities), StationIntent (Siri RouteIntent), BetterRailWatch (watchOS app), BetterRailWidgetWatch.
  • Injects the RNBetterRail native module + shared Swift + Route.intentdefinition (app-side codegen) into the app target; bundles stationsData.json; quick-actions handler via a bridging header.
  • Watch SPM deps (EasySkeleton, HTMLString) vendored; RN built from source (so RCT-Folly resolves under SDK 54).

Androidplugins/withBetterRailAndroid

  • Copies the widget Kotlin + resources; wires Hilt / Room / kapt / DataStore / WorkManager gradle; widget receivers/config-activities/service in the manifest; @HiltAndroidApp + widget lifecycle; RNScreens fragment factory + deep-link onNewIntent; Jetifier.

Source of truth lives in ios-native/ and android-native/ (the plugins copy from these on every prebuild).

Verification

  • iOS: clean expo prebuild --cleanpod installxcodebuildall 5 targets compile and embed (widget + intent in the app; watch app embedded with its widget; watch built for watchOS). Verified running on simulator incl. the search → donateRouteIntent flow.
  • Android: clean prebuild → ./gradlew assembleDebugAPK built with both widget providers, config activities, the update receiver, and the RemoteViews service registered.

Follow-ups for maintainers (not in this PR)

  • eas init to set extra.eas.projectId, then eas build. Signed builds need the App Group capability registered for all 5 bundle ids + push entitlement (Team UE6BVYPPFX).
  • Provide production GoogleService-Info.plist / google-services.json to CI/EAS as secrets (git-ignored at repo root).
  • On-device validation of Live Activity push / widget render / watch app.
  • detox build commands still pass -sdk iphonesimulator (would force watch targets to iOS) — adjust if Detox is kept.

🤖 Generated with Claude Code

Move ios/ and android/ from committed bare projects to generated artifacts
reproduced by `expo prebuild` from app.config.ts + config plugins + targets/.

Foundation:
- app.config.ts (env-driven dev/prod Firebase), eas.json (dev/preview/prod)
- babel-preset-expo + expo/metro-config; registerRootComponent entry
- relocate icons + GoogleService files to repo root; git-ignore ios/ + android/

iOS (@bacons/apple-targets + plugins/withBetterRailIos):
- targets/{widget,intent,watch,watch-widget} reproduce all 5 native targets
  (WidgetKit + Live Activities, Siri RouteIntent, watchOS app + watch widget)
- inject RNBetterRail native module + shared Swift + intentdefinition codegen
  into the app target; bundle stationsData.json; bridging-header quick-actions
- vendored watch SPM deps (EasySkeleton, HTMLString); build RN from source

Android (plugins/withBetterRailAndroid):
- copy widget Kotlin + resources; Hilt/Room/kapt/DataStore/WorkManager gradle
- manifest widget receivers/activities/service; @HiltAndroidApp + lifecycle;
  RNScreens fragment factory + deep-link onNewIntent; jetifier

Verified: both platforms compile from a clean prebuild (iOS all 5 targets embed;
android assembleDebug produces an APK with the widgets registered).
Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request migrates the project to Expo's Continuous Native Generation (CNG) by removing the bare native directories and introducing custom Expo config plugins and Apple targets to dynamically generate the iOS and Android projects. The code review feedback highlights several critical issues regarding the idempotency and robustness of these new config plugins. Specifically, the plugins do not check for existing entries before adding manifest components or Xcode file references, which will cause duplicate declarations and build failures on subsequent prebuilds. Additionally, several regex replacements in the Kotlin and Gradle modifiers are fragile and prone to failure, and a potential runtime crash was identified in the watch widget's locale utility due to unsafe array indexing.

Comment on lines +20 to +40
const withMainActivityMods = (config) =>
withMainActivity(config, (cfg) => {
let src = cfg.modResults.contents

if (!src.includes("RNScreensFragmentFactory")) {
// imports (Bundle is already imported by the Expo template)
src = src.replace(/(package [^\n]+\n)/, `$1\n${IMPORTS}\n`)

// Set the RNScreens fragment factory before the existing super.onCreate(...) call.
src = src.replace(
/(\n)(\s*)super\.onCreate\(/,
`$1$2supportFragmentManager.fragmentFactory = RNScreensFragmentFactory()\n$2super.onCreate(`,
)

// Add onNewIntent as a new method after getMainComponentName().
src = src.replace(/(override fun getMainComponentName\(\): String = "[^"]*"\n)/, `$1${ON_NEW_INTENT}`)
}

cfg.modResults.contents = src
return cfg
})
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

In the default Expo MainActivity.kt template, the onCreate method is not overridden. Consequently, the regex search for super.onCreate( will fail to match, and the RNScreensFragmentFactory will never be registered. This can lead to fragment restoration crashes on Android. We should check if onCreate exists, and if not, inject the entire method.

const withMainActivityMods = (config) =>
  withMainActivity(config, (cfg) => {
    let src = cfg.modResults.contents

    if (!src.includes("RNScreensFragmentFactory")) {
      // imports (Bundle is already imported by the Expo template)
      src = src.replace(/(package [^\n]+\n)/, `$1\n${IMPORTS}\n`)

      // Set the RNScreens fragment factory. If onCreate doesn't exist in the template, inject it.
      if (src.includes("onCreate")) {
        src = src.replace(
          /(\n)(\s*)super\.onCreate\(/,
          `$1$2supportFragmentManager.fragmentFactory = RNScreensFragmentFactory()\n$2super.onCreate(`,
        )
      } else {
        const onCreateMethod = `\n  override fun onCreate(savedInstanceState: android.os.Bundle?) {\n    supportFragmentManager.fragmentFactory = RNScreensFragmentFactory()\n    super.onCreate(savedInstanceState)\n  }\n`
        // Insert onCreate before the last closing brace of the class
        src = src.replace(/(\n}\s*)$/, `\n${onCreateMethod}$1`)
      }

      // Add onNewIntent as a new method after getMainComponentName().
      src = src.replace(/(override fun getMainComponentName\(\): String = "[^\"]*"\n)/, `$1${ON_NEW_INTENT}`)
    }

    cfg.modResults.contents = src
    return cfg
  })

Comment on lines +34 to +99
const withAndroidManifestMods = (config) =>
withAndroidManifest(config, (cfg) => {
const app = cfg.modResults.manifest.application[0]

app.activity = app.activity || []
app.receiver = app.receiver || []
app.service = app.service || []
app["meta-data"] = app["meta-data"] || []

app.activity.push(
configActivity(".widget.CompactWidget2x2ConfigActivity"),
configActivity(".widget.CompactWidget4x2ConfigActivity"),
)

app.receiver.push(
widgetReceiver(
".widget.ModernCompactWidget2x2Provider",
"com.betterrail.widget.modern.compact.ACTION_REFRESH",
"com.betterrail.widget.modern.compact.ACTION_WIDGET_UPDATE",
"@xml/compact_widget_2x2_info",
),
widgetReceiver(
".widget.ModernCompactWidget4x2Provider",
"com.betterrail.widget.modern.compact4x2.ACTION_REFRESH",
"com.betterrail.widget.modern.compact4x2.ACTION_WIDGET_UPDATE",
"@xml/compact_widget_4x2_info",
),
{
$: { "android:name": ".widget.scheduler.WidgetUpdateReceiver", "android:exported": "false" },
"intent-filter": [
{ action: [{ $: { "android:name": "com.betterrail.widget.UPDATE_ALL_WIDGETS" } }] },
{
action: [{ $: { "android:name": "android.intent.action.BOOT_COMPLETED" } }],
category: [{ $: { "android:name": "android.intent.category.DEFAULT" } }],
},
{
action: [{ $: { "android:name": "android.intent.action.MY_PACKAGE_REPLACED" } }],
category: [{ $: { "android:name": "android.intent.category.DEFAULT" } }],
data: [{ $: { "android:scheme": "package" } }],
},
{
action: [{ $: { "android:name": "android.intent.action.PACKAGE_REPLACED" } }],
category: [{ $: { "android:name": "android.intent.category.DEFAULT" } }],
data: [{ $: { "android:scheme": "package" } }],
},
],
},
)

app.service.push({
$: {
"android:name": ".widget.TrainWidgetService",
"android:permission": "android.permission.BIND_REMOTEVIEWS",
"android:exported": "false",
},
})

app["meta-data"].push({
$: {
"android:name": "com.google.firebase.messaging.default_notification_icon",
"android:resource": "@drawable/notification_icon",
},
})

return cfg
})
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

This plugin blindly pushes activities, receivers, services, and metadata into the manifest arrays on every prebuild. Since Expo preserves the android directory between prebuilds unless --clean is specified, running expo prebuild multiple times will accumulate duplicate declarations in AndroidManifest.xml, leading to manifest merger and build failures. We should use a helper function to ensure each component is added only once.

const pushUnique = (array, item, key = "android:name") => {
  const exists = array.some((x) => x.$ && x.$[key] === item.$[key])
  if (!exists) {
    array.push(item)
  }
}

const withAndroidManifestMods = (config) =>
  withAndroidManifest(config, (cfg) => {
    const app = cfg.modResults.manifest.application[0]

    app.activity = app.activity || []
    app.receiver = app.receiver || []
    app.service = app.service || []
    app["meta-data"] = app["meta-data"] || []

    pushUnique(app.activity, configActivity(".widget.CompactWidget2x2ConfigActivity"))
    pushUnique(app.activity, configActivity(".widget.CompactWidget4x2ConfigActivity"))

    pushUnique(app.receiver, widgetReceiver(
      ".widget.ModernCompactWidget2x2Provider",
      "com.betterrail.widget.modern.compact.ACTION_REFRESH",
      "com.betterrail.widget.modern.compact.ACTION_WIDGET_UPDATE",
      "@xml/compact_widget_2x2_info",
    ))
    pushUnique(app.receiver, widgetReceiver(
      ".widget.ModernCompactWidget4x2Provider",
      "com.betterrail.widget.modern.compact4x2.ACTION_REFRESH",
      "com.betterrail.widget.modern.compact4x2.ACTION_WIDGET_UPDATE",
      "@xml/compact_widget_4x2_info",
    ))
    pushUnique(app.receiver, {
      $: { "android:name": ".widget.scheduler.WidgetUpdateReceiver", "android:exported": "false" },
      "intent-filter": [
        { action: [{ $: { "android:name": "com.betterrail.widget.UPDATE_ALL_WIDGETS" } }] },
        {
          action: [{ $: { "android:name": "android.intent.action.BOOT_COMPLETED" } }],
          category: [{ $: { "android:name": "android.intent.category.DEFAULT" } }],
        },
        {
          action: [{ $: { "android:name": "android.intent.action.MY_PACKAGE_REPLACED" } }],
          category: [{ $: { "android:name": "android.intent.category.DEFAULT" } }],
          data: [{ $: { "android:scheme": "package" } }],
        },
        {
          action: [{ $: { "android:name": "android.intent.action.PACKAGE_REPLACED" } }],
          category: [{ $: { "android:name": "android.intent.category.DEFAULT" } }],
          data: [{ $: { "android:scheme": "package" } }],
        },
      ],
    })

    pushUnique(app.service, {
      $: { 
        "android:name": ".widget.TrainWidgetService",
        "android:permission": "android.permission.BIND_REMOTEVIEWS",
        "android:exported": "false"
      }
    })

    pushUnique(app["meta-data"], {
      $: {
        "android:name": "com.google.firebase.messaging.default_notification_icon",
        "android:resource": "@drawable/notification_icon"
      }
    })

    return cfg
  })

Comment on lines +56 to +72
const src = path.join(projectRoot, relSrc)
fs.copyFileSync(src, path.join(destDir, destName))
}

// Compiled Swift + ObjC sources -> Sources build phase of the app target.
// File paths are basenames: the group's own path (BetterRail/BetterRailNative) supplies
// the prefix, so passing the subdir again would double it.
for (const [relSrc, destName] of [...SWIFT_FILES, ...OBJC_FILES]) {
copy(relSrc, destName)
proj.addSourceFile(destName, { target: targetKey }, groupKey)
}

// Intent definition -> Sources phase (triggers RouteIntent codegen).
copy(INTENT_DEF[0], INTENT_DEF[1])
proj.addSourceFile(INTENT_DEF[1], { target: targetKey }, groupKey)

// Bundled resources -> Copy Bundle Resources phase (loaded via Bundle.main at runtime).
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The plugin adds files to the Xcode project using proj.addSourceFile and proj.addResourceFile on every prebuild. If expo prebuild is run multiple times without --clean, these files will be added repeatedly to the pbxproj file, creating duplicate file references and build phase entries, which leads to duplicate symbol errors or multiple build command errors in Xcode. We should check if the file reference already exists in the project before adding it to ensure idempotency.

    const hasFile = (name) => {
      const fileRefSection = proj.hash.project.objects.PBXFileReference;
      for (const key in fileRefSection) {
        if (fileRefSection[key].path === name || fileRefSection[key].name === name) {
          return true;
        }
      }
      return false;
    }

    const copy = (relSrc, destName) => {
      const src = path.join(projectRoot, relSrc)
      fs.copyFileSync(src, path.join(destDir, destName))
    }

    // Compiled Swift + ObjC sources -> Sources build phase of the app target.
    for (const [relSrc, destName] of [...SWIFT_FILES, ...OBJC_FILES]) {
      copy(relSrc, destName)
      if (!hasFile(destName)) {
        proj.addSourceFile(destName, { target: targetKey }, groupKey)
      }
    }

    // Intent definition -> Sources phase (triggers RouteIntent codegen).
    copy(INTENT_DEF[0], INTENT_DEF[1])
    if (!hasFile(INTENT_DEF[1])) {
      proj.addSourceFile(INTENT_DEF[1], { target: targetKey }, groupKey)
    }

    // Bundled resources -> Copy Bundle Resources phase (loaded via Bundle.main at runtime).
    for (const [relSrc, destName] of RESOURCE_FILES) {
      copy(relSrc, destName)
      if (!hasFile(destName)) {
        proj.addResourceFile(destName, { target: targetKey }, groupKey)
      }
    }

Comment on lines +35 to +42
src = mergeContents({
tag: "better-rail-android-plugins",
src,
newSrc: APP_PLUGINS,
anchor: /apply plugin: "com\.facebook\.react"/,
offset: 1,
comment: "//",
}).contents
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The regex /apply plugin: "com\.facebook\.react"/ is used to anchor the injection of Hilt and kapt plugins. However, Gradle files can use single quotes (apply plugin: 'com.facebook.react') or modern plugins { id(...) } syntax. If the template uses single quotes or slightly different spacing, the regex will fail to match, and the plugins won't be applied, breaking the build. We should use a more robust regex that matches both single and double quotes, and optional spaces.

Suggested change
src = mergeContents({
tag: "better-rail-android-plugins",
src,
newSrc: APP_PLUGINS,
anchor: /apply plugin: "com\.facebook\.react"/,
offset: 1,
comment: "//",
}).contents
src = mergeContents({
tag: "better-rail-android-plugins",
src,
newSrc: APP_PLUGINS,
anchor: /apply plugin:\s*['"]com\.facebook\.react['"]/,
offset: 1,
comment: "//",
}).contents

Comment on lines +74 to +86
let langCode = Bundle.main.preferredLocalizations[0]
let usLocale = Locale(identifier: "en-US")
var langName = SupportedLanguages.english

if let languageName = usLocale.localizedString(forLanguageCode: langCode)?.lowercased() {
if let languageValue = SupportedLanguages(rawValue: languageName) {
langName = languageValue
}
}

return langName
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

In getUserLocale(), the code accesses Bundle.main.preferredLocalizations[0] directly. If preferredLocalizations is empty, this will cause a runtime crash due to out-of-bounds array access. It is safer to use Bundle.main.preferredLocalizations.first with a guard statement.

func getUserLocale() -> SupportedLanguages {
  guard let langCode = Bundle.main.preferredLocalizations.first else {
    return .english
  }
  let usLocale = Locale(identifier: "en-US")
  var langName = SupportedLanguages.english
 
  if let languageName = usLocale.localizedString(forLanguageCode: langCode)?.lowercased() {
    if let languageValue = SupportedLanguages(rawValue: languageName) {
      langName = languageValue
    }
  }
  
  return langName
}

Comment on lines +54 to +56
// onCreate init (after super.onCreate())
src = src.replace(/(override fun onCreate\(\)\s*{\s*\n\s*super\.onCreate\(\)\n)/, `$1${ONCREATE_INIT}\n`)
// lifecycle override methods before the final closing brace of the file
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The regex /(override fun onCreate\(\)\s*{\s*\n\s*super\.onCreate\(\)\n)/ is used to inject the widget lifecycle initialization. This regex is highly sensitive to formatting, spacing, and line endings (e.g., \r\n on Windows). Replacing super.onCreate() directly is much simpler and more robust.

Suggested change
// onCreate init (after super.onCreate())
src = src.replace(/(override fun onCreate\(\)\s*{\s*\n\s*super\.onCreate\(\)\n)/, `$1${ONCREATE_INIT}\n`)
// lifecycle override methods before the final closing brace of the file
// onCreate init (after super.onCreate())
src = src.replace("super.onCreate()", `super.onCreate()\n${ONCREATE_INIT}`)

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

Successfully merging this pull request may close these issues.

1 participant