diff --git a/.changeset/legal-wombats-follow.md b/.changeset/legal-wombats-follow.md new file mode 100644 index 000000000..6e1dd2f99 --- /dev/null +++ b/.changeset/legal-wombats-follow.md @@ -0,0 +1,6 @@ +--- +'rock': patch +'rock-docs': patch +--- + +feat: android adhoc builds diff --git a/packages/cli/src/lib/adHocTemplates.ts b/packages/cli/src/lib/adHocTemplates.ts index 0787555e3..1737cec42 100644 --- a/packages/cli/src/lib/adHocTemplates.ts +++ b/packages/cli/src/lib/adHocTemplates.ts @@ -1,340 +1,391 @@ -// Template functions for ad-hoc iOS distribution -export function templateIndexHtml({ +// Template functions for ad-hoc iOS and Android distribution + +// Shared CSS styles for both iOS and Android templates +const sharedStyles = ` + :root { + /* Light mode variables */ + --bg-primary: #ffffff; + --bg-secondary: #f5f5f7; + --text-primary: #1d1d1f; + --text-secondary: #86868b; + --accent-primary: #8232ff; + --accent-hover: rgba(130, 50, 255, 0.3); + --border-color: #e5e5e7; + --shadow-color: rgba(0, 0, 0, 0.1); + } + + @media (prefers-color-scheme: dark) { + :root { + /* Dark mode variables */ + --bg-primary: #1c1c1e; + --bg-secondary: #2c2c2e; + --text-primary: #ffffff; + --text-secondary: #8e8e93; + --accent-primary: #8232ff; + --accent-hover: rgba(130, 50, 255, 0.4); + --border-color: #38383a; + --shadow-color: rgba(0, 0, 0, 0.3); + } + } + + * { + margin: 0; + padding: 0; + box-sizing: border-box; + } + + body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, + Oxygen, Ubuntu, Cantarell, sans-serif; + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: 20px; + font-size: 16px; + background-color: var(--bg-primary); + color: var(--text-primary); + transition: background-color 0.3s ease, color 0.3s ease; + } + + .container { + text-align: center; + max-width: 500px; + width: 100%; + } + + .app-icon { + width: 100px; + height: 100px; + margin: 0 auto 15px; + display: flex; + align-items: center; + justify-content: center; + font-size: 48px; + color: var(--text-primary); + background: var(--bg-secondary); + border-radius: 25px; + transition: background-color 0.3s ease; + } + + h1 { + color: var(--text-primary); + font-size: 28px; + font-weight: 600; + margin-bottom: 15px; + overflow-wrap: break-word; + transition: color 0.3s ease; + } + + .subtitle { + color: var(--text-secondary); + font-size: 16px; + line-height: 1.5; + margin-bottom: 30px; + transition: color 0.3s ease; + } + + .version { + color: var(--text-primary); + font-size: 16px; + line-height: 1.5; + margin-bottom: 10px; + transition: color 0.3s ease; + } + + .download-button { + background: var(--accent-primary); + color: white; + border: none; + padding: 16px 32px; + border-radius: 4px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: all 0.3s ease; + text-decoration: none; + display: inline-block; + margin-bottom: 20px; + box-shadow: 0 4px 12px var(--shadow-color); + } + + .download-button:hover { + transform: translateY(-2px); + box-shadow: 0 8px 24px var(--accent-hover); + } + + .download-button:active { + transform: translateY(0); + } + + .instructions { + background: var(--bg-secondary); + border-radius: 4px; + padding: 20px; + margin-top: 20px; + text-align: left; + border: 1px solid var(--border-color); + transition: background-color 0.3s ease, border-color 0.3s ease; + } + + .instructions h3 { + color: var(--text-primary); + font-size: 16px; + margin-bottom: 10px; + transition: color 0.3s ease; + } + + .instructions ol { + color: var(--text-secondary); + font-size: 14px; + line-height: 1.6; + padding-left: 20px; + transition: color 0.3s ease; + } + + .instructions li { + margin-bottom: 8px; + } + + .adhoc-info { + text-align: left; + margin-top: 20px; + padding: 1em 2em; + border-left: 3px solid var(--accent-primary); + background: var(--bg-primary); + border-radius: 4px; + transition: background-color 0.3s ease; + } + + .adhoc-info-title { + font-weight: 600; + margin-bottom: 10px; + color: var(--text-primary); + transition: color 0.3s ease; + } + + .adhoc-info-text { + color: var(--text-primary); + margin: 0; + transition: color 0.3s ease; + } + + .footer { + text-align: center; + margin-top: 40px; + font-size: 12px; + color: var(--text-secondary); + transition: color 0.3s ease; + } + + .link { + color: var(--accent-primary); + text-decoration: none; + transition: color 0.3s ease; + } + + .link:hover { + text-decoration: underline; + } + + .toast { + padding: 1em 3em; + font-size: 14px; + border: 1px solid var(--accent-primary); + color: var(--text-primary); + position: fixed; + bottom: 1em; + left: 50%; + transform: translateX(-50%); + max-width: 500px; + width: calc(100% - 2em); + text-align: left; + border-radius: 4px; + background-color: var(--bg-primary); + display: none; + box-shadow: 0 8px 24px var(--shadow-color); + transition: background-color 0.3s ease, border-color 0.3s ease, color 0.3s ease; + } + + .toast-visible { + display: block; + animation: slideUp 0.3s ease; + } + + @keyframes slideUp { + from { + opacity: 0; + transform: translateX(-50%) translateY(20px); + } + to { + opacity: 1; + transform: translateX(-50%) translateY(0); + } + } + + .toast-icon { + font-size: 1em; + position: absolute; + left: 1em; + top: 50%; + transform: translateY(-50%); + } + + .toast-close { + font-size: 1em; + padding: 0.5em; + cursor: pointer; + position: absolute; + right: 1em; + top: 50%; + transform: translateY(-50%); + color: var(--text-secondary); + transition: color 0.3s ease; + } + + .toast-close:hover { + color: var(--text-primary); + } + + /* Smooth transitions for all elements */ + * { + transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease; + } +`; + +// Shared footer HTML +const sharedFooter = ` + +`; + +// Shared toast functions for both iOS and Android templates +const sharedToastFunctions = () => ` + function showToast() { + setTimeout(() => { + const toast = document.getElementById('home-screen-toast'); + toast.classList.add('toast-visible'); + }, 2000); + } + + function hideToast() { + const toast = document.getElementById('home-screen-toast'); + toast.classList.remove('toast-visible'); + } +`; + +// Base template function for creating ad-hoc distribution pages +function createAdHocTemplate({ + platform, appName, version, - bundleIdentifier, + identifier, + icon, + buttonText, + toastMessage, + script, + instructions, + adhocDescription, }: { + platform: 'iOS' | 'Android'; appName: string; version: string; - bundleIdentifier: string; + identifier: string; + icon: string; + buttonText: string; + toastMessage: string; + script: string; + instructions: string; + adhocDescription: string; }) { return ` - - - - - Download ${appName} for iOS - - - -
-
📱
- -
- 💡 -

Check Home Screen to see installation progress

- ✕ -
- -

${appName}

-

${bundleIdentifier} (${version})

-

- Download and install the latest version of our iOS app directly to your - device. + + + + + Download ${appName} for ${platform} + + + +

+
${icon}
+ +
+ 💡 +

${toastMessage}

+ ✕ +
+ +

${appName}

+

${identifier} (${version})

+

+ Download and install the latest version of our ${platform} app directly to your + device. +

+ + + ${buttonText} + + + + +
+

Installation Instructions:

+
    ${instructions}
+
+ +
+

Ad-hoc Distribution

+

+ ${adhocDescription} +

+ Learn more at Rock Ad-hoc documentation.

- - - Install App - - - - -
-

Installation Instructions:

-
    -
  1. Tap the "Install App" button above
  2. -
  3. When prompted, tap "Install" in the popup dialog
  4. -
  5. The app will now start installing and will be available on your home screen
  6. -
-
- -
-

Ad-hoc Distribution

-

- This app is distributed via ad-hoc distribution for testing purposes. - Your device must either be enrolled in enterprise distribution or have its UDID added to the app's provisioning profile. -

- Learn more at Rock Ad-hoc documentation. -

-
-
- - - `; + ${sharedFooter} +
+ + +`; +} + +export function templateIndexHtmlIOS({ + appName, + version, + bundleIdentifier, +}: { + appName: string; + version: string; + bundleIdentifier: string; +}) { + return createAdHocTemplate({ + platform: 'iOS', + appName, + version, + identifier: bundleIdentifier, + icon: '📱', + buttonText: 'Install App', + toastMessage: 'Check Home Screen to see installation progress', + script: ` + // Update the link dynamically to point to the manifest.plist + const link = document.getElementById('action-button'); + const currentUrl = window.location.href; + const manifestUrl = currentUrl.replace('index.html', 'manifest.plist'); + link.href = \`itms-services://?action=download-manifest&url=\${encodeURIComponent(manifestUrl)}\`; + ${sharedToastFunctions()} + `, + instructions: ` +
  • Tap the "Install App" button above
  • +
  • When prompted, tap "Install" in the popup dialog
  • +
  • The app will now start installing and will be available on your home screen
  • + `, + adhocDescription: + "This app is distributed via ad-hoc distribution for testing purposes. Your device must either be enrolled in enterprise distribution or have its UDID added to the app's provisioning profile.", + }); } export function templateManifestPlist({ @@ -388,3 +439,39 @@ export function templateManifestPlist({ `; } + +export function templateIndexHtmlAndroid({ + appName, + version, + packageName, +}: { + appName: string; + version: string; + packageName: string; +}) { + return createAdHocTemplate({ + platform: 'Android', + appName, + version, + identifier: packageName, + icon: '🤖', + buttonText: 'Download APK', + toastMessage: 'APK download started. Check your notifications to install.', + script: ` + // Update the link dynamically to point to the APK file + const link = document.getElementById('action-button'); + const currentUrl = window.location.href; + const apkFileName = '${appName}.apk'; + link.href = currentUrl.replace('index.html', apkFileName); + ${sharedToastFunctions()} + `, + instructions: ` +
  • Tap the "Download APK" button above
  • +
  • Once downloaded, open the APK file from your notifications or downloads folder
  • +
  • If prompted, enable "Install from Unknown Sources" in your device settings
  • +
  • Follow the on-screen prompts to complete the installation
  • + `, + adhocDescription: + 'This app is distributed via ad-hoc distribution for testing purposes. You may need to enable installation from unknown sources in your Android device settings.', + }); +} diff --git a/packages/cli/src/lib/plugins/remoteCache.ts b/packages/cli/src/lib/plugins/remoteCache.ts index 4a0950e61..26a83b126 100644 --- a/packages/cli/src/lib/plugins/remoteCache.ts +++ b/packages/cli/src/lib/plugins/remoteCache.ts @@ -15,11 +15,16 @@ import { logger, relativeToCwd, RockError, + spawn, spinner, } from '@rock-js/tools'; import AdmZip from 'adm-zip'; import * as tar from 'tar'; -import { templateIndexHtml, templateManifestPlist } from '../adHocTemplates.js'; +import { + templateIndexHtmlAndroid, + templateIndexHtmlIOS, + templateManifestPlist, +} from '../adHocTemplates.js'; type Flags = { platform?: 'ios' | 'android'; @@ -170,19 +175,37 @@ ${output args, ); + const isArtifactIPA = args.binaryPath?.endsWith('.ipa'); + const isArtifactAPK = args.binaryPath?.endsWith('.apk'); + try { let uploadedArtifact; const appFileName = path.basename(binaryPath); const appName = appFileName.replace(/\.[^/.]+$/, ''); + + const uploadContent: { + messagePrefix: string; + artifactName: string | undefined; + } = { + messagePrefix: 'build', + artifactName: undefined, + }; + + if (args.adHoc && isArtifactIPA) { + uploadContent.messagePrefix = 'IPA, index.html and manifest.plist'; + uploadContent.artifactName = `ad-hoc/${artifactName}/${appName}.ipa`; + } else if (args.adHoc && isArtifactAPK) { + uploadContent.messagePrefix = 'APK, index.html'; + uploadContent.artifactName = `ad-hoc/${artifactName}/${appName}.apk`; + } + const { name, url, getResponse } = await remoteBuildCache.upload({ artifactName, - uploadArtifactName: args.adHoc - ? `ad-hoc/${artifactName}/${appName}.ipa` - : undefined, + uploadArtifactName: uploadContent.artifactName, }); - const uploadMessage = `${ - args.adHoc ? 'IPA, index.html and manifest.plist' : 'build' - } to ${color.bold(remoteBuildCache.name)}`; + + const uploadMessage = `${uploadContent.messagePrefix} to ${color.bold(remoteBuildCache.name)}`; + const loader = spinner({ silent: isJsonOutput }); loader.start(`Uploading ${uploadMessage}`); await handleUploadResponse(getResponse, buffer, (progress, totalMB) => { @@ -193,8 +216,8 @@ ${output uploadedArtifact = { name, url }; - // Upload index.html and manifest.plist for ad-hoc distribution - if (args.adHoc) { + // Upload index.html and manifest.plist for iOS ad-hoc distribution + if (args.adHoc && isArtifactIPA) { const { version, bundleIdentifier } = await getInfoPlistFromIpa(binaryPath); const { url: urlIndexHtml, getResponse: getResponseIndexHtml } = @@ -204,7 +227,7 @@ ${output }); getResponseIndexHtml( Buffer.from( - templateIndexHtml({ appName, bundleIdentifier, version }), + templateIndexHtmlIOS({ appName, bundleIdentifier, version }), ), 'text/html', ); @@ -231,6 +254,25 @@ ${output uploadedArtifact = { name, url: urlIndexHtml.split('?')[0] + '' }; } + // Upload index.html for Android ad-hoc distribution + if (args.adHoc && isArtifactAPK) { + const { version, packageName } = await getManifestFromApk(binaryPath); + const { url: urlIndexHtml, getResponse: getResponseIndexHtml } = + await remoteBuildCache.upload({ + artifactName, + uploadArtifactName: `ad-hoc/${artifactName}/index.html`, + }); + getResponseIndexHtml( + Buffer.from( + templateIndexHtmlAndroid({ appName, packageName, version }), + ), + 'text/html', + ); + + // For ad-hoc distribution, we want the url to point to the index.html for easier installation + uploadedArtifact = { name, url: urlIndexHtml.split('?')[0] + '' }; + } + loader.stop(`Uploaded ${uploadMessage}`); if (isJsonOutput) { @@ -307,6 +349,65 @@ async function getInfoPlistFromIpa(binaryPath: string) { }; } +function findAapt() { + const sdkRoot = + process.env['ANDROID_HOME'] || process.env['ANDROID_SDK_ROOT']; + + if (!sdkRoot) { + throw new RockError( + 'ANDROID_HOME or ANDROID_SDK_ROOT environment variable is not set. Please follow instructions at: https://reactnative.dev/docs/set-up-your-environment?platform=android', + ); + } + + const buildToolsPath = path.join(sdkRoot, 'build-tools'); + const versions = fs.readdirSync(buildToolsPath); + + for (const version of versions) { + const aaptPath = path.join(buildToolsPath, version, 'aapt'); + if (fs.existsSync(aaptPath)) { + logger.debug(`Found aapt at: ${aaptPath}`); + return aaptPath; + } + } + + throw new RockError( + `"aapt" not found in Android Build-Tools directory: ${colorLink(buildToolsPath)} +Please follow instructions at: https://reactnative.dev/docs/set-up-your-environment?platform=android`, + ); +} + +async function getManifestFromApk(binaryPath: string) { + const apkFileName = path.basename(binaryPath, '.apk'); + + try { + const aaptPath = findAapt(); + + const { stdout: output } = await spawn( + aaptPath, + ['dump', 'badging', binaryPath], + { stdio: 'pipe' }, + ); + + const packageMatch = output?.match(/package: name='([^']+)'/); + const versionMatch = output?.match(/versionName='([^']+)'/); + + const packageName = packageMatch?.[1] || apkFileName; + const version = versionMatch?.[1] || '1.0'; + + logger.debug( + `Extracted APK manifest - package: ${packageName}, version: ${version}`, + ); + + return { packageName, version }; + } catch (error) { + logger.debug('Failed to parse APK manifest, using fallback', error); + return { + packageName: apkFileName, + version: '1.0', + }; + } +} + async function getBinaryBuffer( binaryPath: string, artifactName: string, @@ -444,7 +545,7 @@ Example Harmony: --traits debug`, { name: '--ad-hoc', description: - 'Upload IPA for ad-hoc distribution and installation from URL. Additionally uploads index.html and manifest.plist', + 'Upload IPA or APK for ad-hoc distribution and installation from URL. For iOS: uploads IPA, index.html and manifest.plist. For Android: uploads APK and index.html', }, ], }); diff --git a/website/src/docs/cli/introduction.md b/website/src/docs/cli/introduction.md index 6b80c3cfa..f710ba415 100644 --- a/website/src/docs/cli/introduction.md +++ b/website/src/docs/cli/introduction.md @@ -404,7 +404,7 @@ Available actions: | `list` | Lists the latest artifact matching the specified criteria | | `list-all` | Lists all artifacts (optionally filtered by platform and traits) | | `download` | Downloads an artifact from remote cache to local cache | -| `upload` | Uploads a binary to remote cache. Accepts `--ad-hoc` flag for Ad-Hoc distritbuion | +| `upload` | Uploads a binary to remote cache. Accepts `--ad-hoc` flag for Ad-Hoc distribution | | `delete` | Deletes artifacts from remote cache | | `get-provider-name` | Returns the name of the configured remote cache provider | @@ -419,7 +419,7 @@ Actions have different options available: | `-p, --platform ` | Platform to target (`ios`, `android`, or `harmony`). Must be used with `--traits` | | `-t, --traits ` | Comma-separated traits that construct the final artifact name. For Android: variant (e.g., `debug`, `release`). For iOS: destination and configuration (e.g., `simulator,Release`) | | `--binary-path ` | Path to the binary to upload (used with `upload` action) | -| `--ad-hoc ` | Upload IPA for ad-hoc distribution and installation from URL. Additionally uploads index.html and manifest.plist' | +| `--ad-hoc ` | Upload binary for ad-hoc distribution and installation from URL. **iOS**: Uploads IPA, index.html, and manifest.plist. **Android**: Uploads APK and index.html | For example, to download remote cache for iOS simulator with Release configuration, you can use `remote-cache download` with `--name` option @@ -435,29 +435,41 @@ npx rock remote-cache download --platform ios --traits simulator,Release #### Ad-hoc distribution -Ad-hoc distribution allows you to share your iOS app with testers without going through the App Store. Testers can install your app directly on their devices by visiting a web page and tapping "Install App". +Ad-hoc distribution allows you to share your mobile app with testers without going through the App Store or Play Store. Testers can install your app directly on their devices by visiting a web page. **What is Ad-hoc distribution?** -Ad-hoc distribution is a method for sharing iOS apps with testers without going through the App Store. It requires devices to be registered in your Apple Developer account and is perfect for beta testing, internal testing, or client demos. Apps installed this way will appear on the device's home screen just like any other app. +Ad-hoc distribution is a method for sharing mobile apps with testers without going through official app stores. It's perfect for beta testing, internal testing, or client demos. For iOS, devices must be registered in your Apple Developer account. For Android, testers need to enable "Install from Unknown Sources" in their device settings. Apps installed this way will appear on the device's home screen just like any other app. **How it works:** -1. Build your app with a valid provisioning profile that includes your testers' devices +1. Build your app with proper configuration + ```shell + # iOS - requires valid provisioning profile that includes your testers devices npx rock build:ios --archive # ...other required flags + + # Android - requires signing with keystore (use any signed variant e.g., release) + npx rock build:android --variant release # ...other required flags ``` + 2. Use `upload --ad-hoc` to upload the app for ad-hoc distribution + ```shell + # iOS npx rock remote-cache upload --ad-hoc --platform ios --traits device,Release + + # Android - traits should match your build variant + npx rock remote-cache upload --ad-hoc --platform android --traits release ``` + 3. Share the generated URL with your testers -4. Testers visit the URL and tap "Install App" to install directly on their device +4. Testers visit the URL and install the app on their device (iOS: tap "Install App"; Android: Download APK and install) The command creates a special folder structure that includes: -- Your signed IPA file +- Your signed binary (IPA for iOS, APK for Android) - An HTML page for easy installation (you need to configure your provider to **make this file publicly available**) -- A manifest file that iOS uses to install the app +- A manifest.plist file (iOS only) The folder will be available at `ad-hoc/` directory of your configured remote cache provider. diff --git a/website/src/docs/configuration.md b/website/src/docs/configuration.md index 8fb6cebdd..9aef61fd4 100644 --- a/website/src/docs/configuration.md +++ b/website/src/docs/configuration.md @@ -534,11 +534,10 @@ async upload({ artifactName, uploadArtifactName }) { - For iOS simulator builds (APP directory), Rock creates a temporary `app.tar.gz` to preserve permissions and includes it in the artifact; you just receive the buffer via `getResponse`. You don't need to create the tarball yourself. - **Ad-hoc distribution:** - with `--ad-hoc` flag passed to `remote-cache upload` Rock uploads: - - The signed IPA at `/ad-hoc//.ipa` - - An `index.html` landing page (make sure it's accessible for testers) - - A `manifest.plist` + - **iOS**: The signed IPA at `/ad-hoc//.ipa`, an `index.html` landing page, and a `manifest.plist` file + - **Android**: The signed APK at `/ad-hoc//.apk` and an `index.html` landing page - This `index.html` file will display an ad-hoc distribution web portal, allowing developers and testers to install apps on their provisioned devices by simply clicking "Install App". + This `index.html` file will display an ad-hoc distribution web portal, allowing developers and testers to install apps on their devices by simply clicking "Install App" (iOS) or "Download APK" (Android). Learn more about ad-hoc distribution and how it works with `remote-cache upload --ad-hoc` command [here](./cli/introduction#ad-hoc-distribution).