From a2f4ced9299ad61b91be6e517b7677814fe6e199 Mon Sep 17 00:00:00 2001 From: Lukasz Modzelewski Date: Fri, 5 Dec 2025 10:09:51 +0100 Subject: [PATCH 1/6] feat: add adhoc for android --- packages/cli/package.json | 1 + packages/cli/src/lib/adHocTemplates.ts | 743 +++++++++++--------- packages/cli/src/lib/plugins/remoteCache.ts | 86 ++- pnpm-lock.yaml | 10 + 4 files changed, 501 insertions(+), 339 deletions(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index 1dffaf2ad..0086e940b 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -29,6 +29,7 @@ "@rock-js/tools": "^0.11.13", "adm-zip": "^0.5.16", "commander": "^12.1.0", + "node-apk": "^1.2.1", "tar": "^7.5.1", "tslib": "^2.3.0" }, diff --git a/packages/cli/src/lib/adHocTemplates.ts b/packages/cli/src/lib/adHocTemplates.ts index 0787555e3..2c9588e09 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..5cf0b52a2 100644 --- a/packages/cli/src/lib/plugins/remoteCache.ts +++ b/packages/cli/src/lib/plugins/remoteCache.ts @@ -19,7 +19,11 @@ import { } 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 +174,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 +215,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 +226,7 @@ ${output }); getResponseIndexHtml( Buffer.from( - templateIndexHtml({ appName, bundleIdentifier, version }), + templateIndexHtmlIOS({ appName, bundleIdentifier, version }), ), 'text/html', ); @@ -231,6 +253,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 +348,29 @@ async function getInfoPlistFromIpa(binaryPath: string) { }; } +async function getManifestFromApk(binaryPath: string) { + const apkFileName = path.basename(binaryPath, '.apk'); + + try { + const nodeApk = await import('node-apk'); + const { Apk } = nodeApk.default || nodeApk; + const apk = new Apk(binaryPath); + const manifest = await apk.getManifestInfo(); + apk.close(); + + return { + packageName: manifest.package || apkFileName, + version: manifest.versionName || '1.0', + }; + } 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 +508,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/pnpm-lock.yaml b/pnpm-lock.yaml index d2dcb1d79..32ddb6a43 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -87,6 +87,9 @@ importers: commander: specifier: ^12.1.0 version: 12.1.0 + node-apk: + specifier: ^1.2.1 + version: 1.2.1 tar: specifier: ^7.5.1 version: 7.5.1 @@ -4932,6 +4935,9 @@ packages: resolution: {integrity: sha512-WDD0bdg9mbq6F4mRxEYcPWwfA1vxd0mrvKOyxI7Xj/atfRHVeutzuWByG//jfm4uPzp0y4Kj051EORCBSQMycw==} engines: {node: '>=12.0.0'} + node-apk@1.2.1: + resolution: {integrity: sha512-I0TY1x5m1pkFzjYdaGrrAu/Mh9qnnk2/BoMAU6bvBxTTD/oNQyTWbu3LTdONgV2rnLHf23jJ00Y/VV4BzZ6YXQ==} + node-fetch@2.6.7: resolution: {integrity: sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==} engines: {node: 4.x || >=6.0.0} @@ -12694,6 +12700,10 @@ snapshots: nocache@3.0.4: {} + node-apk@1.2.1: + dependencies: + node-forge: 1.3.1 + node-fetch@2.6.7: dependencies: whatwg-url: 5.0.0 From 4876b07c08d1fe40905064a0b7359659831da288 Mon Sep 17 00:00:00 2001 From: Lukasz Modzelewski Date: Fri, 5 Dec 2025 10:25:59 +0100 Subject: [PATCH 2/6] fix: update rock docs link in adhoc index.html --- packages/cli/src/lib/adHocTemplates.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/lib/adHocTemplates.ts b/packages/cli/src/lib/adHocTemplates.ts index 2c9588e09..1737cec42 100644 --- a/packages/cli/src/lib/adHocTemplates.ts +++ b/packages/cli/src/lib/adHocTemplates.ts @@ -343,7 +343,7 @@ function createAdHocTemplate({

    ${adhocDescription}

    - Learn more at Rock Ad-hoc documentation. + Learn more at Rock Ad-hoc documentation.

    ${sharedFooter} From f27ae2f54a7c336b52c91607f669d6856502ff7f Mon Sep 17 00:00:00 2001 From: Lukasz Modzelewski Date: Wed, 17 Dec 2025 11:35:15 +0100 Subject: [PATCH 3/6] docs: mention android adhoc --- website/src/docs/cli/introduction.md | 28 ++++++++++++++++++++-------- website/src/docs/configuration.md | 7 +++---- 2 files changed, 23 insertions(+), 12 deletions(-) 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). From a449e29f4a449beb7f0d6f62ba12e6b981077b8e Mon Sep 17 00:00:00 2001 From: Lukasz Modzelewski Date: Wed, 17 Dec 2025 11:36:16 +0100 Subject: [PATCH 4/6] add changeset --- .changeset/legal-wombats-follow.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/legal-wombats-follow.md 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 From 111ffb3a206fce6d93eb57dd61b2dadaea0611b2 Mon Sep 17 00:00:00 2001 From: Lukasz Modzelewski Date: Thu, 18 Dec 2025 09:40:30 +0100 Subject: [PATCH 5/6] fix: use aapt instead of node-apk --- packages/cli/package.json | 1 - packages/cli/src/lib/plugins/remoteCache.ts | 57 +++++++++++++++++---- pnpm-lock.yaml | 10 ---- 3 files changed, 46 insertions(+), 22 deletions(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index 0086e940b..1dffaf2ad 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -29,7 +29,6 @@ "@rock-js/tools": "^0.11.13", "adm-zip": "^0.5.16", "commander": "^12.1.0", - "node-apk": "^1.2.1", "tar": "^7.5.1", "tslib": "^2.3.0" }, diff --git a/packages/cli/src/lib/plugins/remoteCache.ts b/packages/cli/src/lib/plugins/remoteCache.ts index 5cf0b52a2..8a1714776 100644 --- a/packages/cli/src/lib/plugins/remoteCache.ts +++ b/packages/cli/src/lib/plugins/remoteCache.ts @@ -1,3 +1,4 @@ +import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -255,7 +256,7 @@ ${output // Upload index.html for Android ad-hoc distribution if (args.adHoc && isArtifactAPK) { - const { version, packageName } = await getManifestFromApk(binaryPath); + const { version, packageName } = getManifestFromApk(binaryPath); const { url: urlIndexHtml, getResponse: getResponseIndexHtml } = await remoteBuildCache.upload({ artifactName, @@ -348,20 +349,54 @@ async function getInfoPlistFromIpa(binaryPath: string) { }; } -async function getManifestFromApk(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`, + ); +} + +function getManifestFromApk(binaryPath: string) { const apkFileName = path.basename(binaryPath, '.apk'); try { - const nodeApk = await import('node-apk'); - const { Apk } = nodeApk.default || nodeApk; - const apk = new Apk(binaryPath); - const manifest = await apk.getManifestInfo(); - apk.close(); + const aaptPath = findAapt(); - return { - packageName: manifest.package || apkFileName, - version: manifest.versionName || '1.0', - }; + const output = execFileSync(aaptPath, ['dump', 'badging', binaryPath], { + encoding: 'utf8', + }); + + 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 { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 32ddb6a43..d2dcb1d79 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -87,9 +87,6 @@ importers: commander: specifier: ^12.1.0 version: 12.1.0 - node-apk: - specifier: ^1.2.1 - version: 1.2.1 tar: specifier: ^7.5.1 version: 7.5.1 @@ -4935,9 +4932,6 @@ packages: resolution: {integrity: sha512-WDD0bdg9mbq6F4mRxEYcPWwfA1vxd0mrvKOyxI7Xj/atfRHVeutzuWByG//jfm4uPzp0y4Kj051EORCBSQMycw==} engines: {node: '>=12.0.0'} - node-apk@1.2.1: - resolution: {integrity: sha512-I0TY1x5m1pkFzjYdaGrrAu/Mh9qnnk2/BoMAU6bvBxTTD/oNQyTWbu3LTdONgV2rnLHf23jJ00Y/VV4BzZ6YXQ==} - node-fetch@2.6.7: resolution: {integrity: sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==} engines: {node: 4.x || >=6.0.0} @@ -12700,10 +12694,6 @@ snapshots: nocache@3.0.4: {} - node-apk@1.2.1: - dependencies: - node-forge: 1.3.1 - node-fetch@2.6.7: dependencies: whatwg-url: 5.0.0 From c99fc1b7bb7e5b0f23c522434741bf8348d2dc35 Mon Sep 17 00:00:00 2001 From: Lukasz Modzelewski Date: Thu, 18 Dec 2025 11:27:11 +0100 Subject: [PATCH 6/6] fix: use spawn instead of node:child_process --- packages/cli/src/lib/plugins/remoteCache.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/lib/plugins/remoteCache.ts b/packages/cli/src/lib/plugins/remoteCache.ts index 8a1714776..26a83b126 100644 --- a/packages/cli/src/lib/plugins/remoteCache.ts +++ b/packages/cli/src/lib/plugins/remoteCache.ts @@ -1,4 +1,3 @@ -import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -16,6 +15,7 @@ import { logger, relativeToCwd, RockError, + spawn, spinner, } from '@rock-js/tools'; import AdmZip from 'adm-zip'; @@ -256,7 +256,7 @@ ${output // Upload index.html for Android ad-hoc distribution if (args.adHoc && isArtifactAPK) { - const { version, packageName } = getManifestFromApk(binaryPath); + const { version, packageName } = await getManifestFromApk(binaryPath); const { url: urlIndexHtml, getResponse: getResponseIndexHtml } = await remoteBuildCache.upload({ artifactName, @@ -376,15 +376,17 @@ Please follow instructions at: https://reactnative.dev/docs/set-up-your-environm ); } -function getManifestFromApk(binaryPath: string) { +async function getManifestFromApk(binaryPath: string) { const apkFileName = path.basename(binaryPath, '.apk'); try { const aaptPath = findAapt(); - const output = execFileSync(aaptPath, ['dump', 'badging', binaryPath], { - encoding: 'utf8', - }); + const { stdout: output } = await spawn( + aaptPath, + ['dump', 'badging', binaryPath], + { stdio: 'pipe' }, + ); const packageMatch = output?.match(/package: name='([^']+)'/); const versionMatch = output?.match(/versionName='([^']+)'/);