diff --git a/.gitignore b/.gitignore index c2a2915a..88abbff2 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,12 @@ build/ **/local.properties **/google-services.json .DS_Store + +# Keystores and bundle artifacts +*.jks +*.keystore +*.p12 +*.aab +*.apk +*.apks + diff --git a/jetpacker/android/app/build.gradle.kts b/jetpacker/android/app/build.gradle.kts index b24aa686..4f792c4a 100644 --- a/jetpacker/android/app/build.gradle.kts +++ b/jetpacker/android/app/build.gradle.kts @@ -14,6 +14,8 @@ * limitations under the License. */ +import com.google.gms.googleservices.GoogleServicesTask + plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.compose) @@ -34,12 +36,24 @@ android { } defaultConfig { - applicationId = "com.example.jetpacker" + applicationId = + providers.gradleProperty("applicationId").getOrElse("com.example.jetpacker") minSdk = libs.versions.minSdk.get().toInt() targetSdk = libs.versions.targetSdk.get().toInt() - versionCode = libs.versions.versionCode.get().toInt() + versionCode = + providers + .gradleProperty("versionCode") + .map { it.toInt() } + .getOrElse(libs.versions.versionCode.get().toInt()) versionName = libs.versions.versionName.get() + val abisProperty = providers.gradleProperty("abis").orNull + if (!abisProperty.isNullOrBlank()) { + ndk { + abiFilters += abisProperty.split(",").map { it.trim() }.filter { it.isNotEmpty() } + } + } + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } @@ -163,3 +177,38 @@ dependencies { testImplementation(libs.junit) testImplementation(libs.kotlinx.coroutines.test) } + +val customAppId = providers.gradleProperty("applicationId").orNull +if (!customAppId.isNullOrBlank() && customAppId != "com.example.jetpacker") { + val generateCustomGoogleServices = + tasks.register("generateCustomGoogleServices") { + val srcFile = file("google-services.json") + val outFile = layout.buildDirectory.file("generated/google-services-custom/google-services.json") + inputs.file(srcFile) + inputs.property("applicationId", customAppId) + outputs.file(outFile) + doLast { + val text = srcFile.readText() + val updated = + text.replace( + "\"package_name\": \"com.example.jetpacker\"", + "\"package_name\": \"$customAppId\"" + ) + val out = outFile.get().asFile + out.parentFile.mkdirs() + out.writeText(updated) + } + } + + androidComponents { + onVariants { variant -> + val taskName = "process${variant.name.replaceFirstChar { it.uppercase() }}GoogleServices" + tasks.named(taskName, GoogleServicesTask::class.java).configure { + dependsOn(generateCustomGoogleServices) + val customFile = layout.buildDirectory.file("generated/google-services-custom/google-services.json").get().asFile + googleServicesJsonFiles.set(listOf(customFile)) + } + } + } +} + diff --git a/jetpacker/android/app/src/main/res/xml/app_metadata.xml b/jetpacker/android/app/src/main/res/xml/app_metadata.xml index 293d0c00..1702b1df 100644 --- a/jetpacker/android/app/src/main/res/xml/app_metadata.xml +++ b/jetpacker/android/app/src/main/res/xml/app_metadata.xml @@ -2,7 +2,7 @@ diff --git a/jetpacker/android/build.gradle.kts b/jetpacker/android/build.gradle.kts index 6466fcd1..4dff8759 100644 --- a/jetpacker/android/build.gradle.kts +++ b/jetpacker/android/build.gradle.kts @@ -37,7 +37,9 @@ subprojects { failOnNoDiscoveredTests = false } configurations.all { - exclude(group = "com.google.protobuf", module = "protobuf-java") + if (!name.contains("lint", ignoreCase = true)) { + exclude(group = "com.google.protobuf", module = "protobuf-java") + } resolutionStrategy.eachDependency { if (requested.group == "com.google.firebase" && requested.name == "firebase-ai") { useVersion("17.10.1") diff --git a/jetpacker/android/core/ui/src/main/res/drawable/img_california.webp b/jetpacker/android/core/ui/src/main/res/drawable/img_california.webp index 01389304..13fe7380 100644 Binary files a/jetpacker/android/core/ui/src/main/res/drawable/img_california.webp and b/jetpacker/android/core/ui/src/main/res/drawable/img_california.webp differ diff --git a/jetpacker/android/core/ui/src/main/res/drawable/img_paris.webp b/jetpacker/android/core/ui/src/main/res/drawable/img_paris.webp index c43f4428..376f69c8 100644 Binary files a/jetpacker/android/core/ui/src/main/res/drawable/img_paris.webp and b/jetpacker/android/core/ui/src/main/res/drawable/img_paris.webp differ diff --git a/jetpacker/android/core/ui/src/main/res/drawable/img_paris_skyline.webp b/jetpacker/android/core/ui/src/main/res/drawable/img_paris_skyline.webp index af03e8a9..376f69c8 100644 Binary files a/jetpacker/android/core/ui/src/main/res/drawable/img_paris_skyline.webp and b/jetpacker/android/core/ui/src/main/res/drawable/img_paris_skyline.webp differ diff --git a/jetpacker/android/feature/trip/booking_assistant/build.gradle.kts b/jetpacker/android/feature/trip/booking_assistant/build.gradle.kts index 487fb80b..28e6d4a2 100644 --- a/jetpacker/android/feature/trip/booking_assistant/build.gradle.kts +++ b/jetpacker/android/feature/trip/booking_assistant/build.gradle.kts @@ -23,7 +23,12 @@ plugins { android { namespace = "com.example.jetpacker.feature.booking_assistant" - compileSdk = libs.versions.compileSdk.get().toInt() + compileSdk { + version = + release(libs.versions.compileSdk.get().toInt()) { + minorApiLevel = libs.versions.compileSdkMinor.get().toInt() + } + } defaultConfig { minSdk = libs.versions.minSdk.get().toInt() } compileOptions { sourceCompatibility = JavaVersion.VERSION_17 diff --git a/jetpacker/android/feature/trip/build.gradle.kts b/jetpacker/android/feature/trip/build.gradle.kts index 02b7b0d6..0d8a2e92 100644 --- a/jetpacker/android/feature/trip/build.gradle.kts +++ b/jetpacker/android/feature/trip/build.gradle.kts @@ -24,7 +24,12 @@ plugins { android { namespace = "com.example.jetpacker.feature.trip" - compileSdk = libs.versions.compileSdk.get().toInt() + compileSdk { + version = + release(libs.versions.compileSdk.get().toInt()) { + minorApiLevel = libs.versions.compileSdkMinor.get().toInt() + } + } defaultConfig { minSdk = libs.versions.minSdk.get().toInt() } diff --git a/jetpacker/android/gradle.properties b/jetpacker/android/gradle.properties index 46450222..96e23346 100644 --- a/jetpacker/android/gradle.properties +++ b/jetpacker/android/gradle.properties @@ -17,3 +17,5 @@ ksp.incremental=true ksp.incremental.intermodule=true android.nonTransitiveRClass=false + +android.suppressUnsupportedCompileSdk=37.1 diff --git a/jetpacker/android/gradle/libs.versions.toml b/jetpacker/android/gradle/libs.versions.toml index 2a482182..a366c8bd 100644 --- a/jetpacker/android/gradle/libs.versions.toml +++ b/jetpacker/android/gradle/libs.versions.toml @@ -9,7 +9,7 @@ cameraLifecycle = "1.5.0" cameraView = "1.5.0" coilCompose = "2.7.0" compileSdk = "37" -compileSdkMinor = "0" +compileSdkMinor = "1" composeBom = "2026.03.00" composeScreenshot = "0.0.1-alpha15" converterMoshi = "2.12.0" diff --git a/jetpacker/play/.gitignore b/jetpacker/play/.gitignore new file mode 100644 index 00000000..0eb31327 --- /dev/null +++ b/jetpacker/play/.gitignore @@ -0,0 +1,12 @@ +# Ignore developer-specific profile configs +profiles/*.conf +!profiles/*.example + +# Keystores and bundle artifacts +*.jks +*.keystore +*.p12 +*.aab +*.apk +*.apks + diff --git a/jetpacker/play/DEMO.md b/jetpacker/play/DEMO.md new file mode 100644 index 00000000..b8b43ba9 --- /dev/null +++ b/jetpacker/play/DEMO.md @@ -0,0 +1,112 @@ +# Demo JetPacker as a Gemini Connected App + +This guide walks through configuring and running JetPacker as a connected app that Gemini calls on an Android 16+ device using AppFunctions. + +## Overview + +JetPacker integrates with Android's AppFunctions system service to allow on-device AI and the Gemini assistant to query itineraries, add events, log expenses, and update trips. + +## Setup Steps + +### 1. Create a Play Console App & Store Listing + +In [Google Play Console](https://play.google.com/console), create an app entry: + +| Field | Value | +| :--- | :--- | +| App name | JetPacker | +| Default language | English (United States) | +| App or game | App | +| Free or paid | Free | + +Add store listing visual assets from `store_listing/`: + +| Field | Value | +| :--- | :--- | +| Short description | Trips, itineraries, and expenses in one place. | +| Full description | JetPacker keeps every part of a trip in one place: flights, hotels, restaurants, museum visits, tours, and expenses. Plan trips, track expenses, and manage your itinerary. Ask Gemini about your trips, bookings, and budget, or add events, expenses, and voice notes. | +| App icon | `store_listing/icon-512.png` | +| Feature graphic | `store_listing/feature-graphic-1024x500.png` | +| Phone screenshots | `store_listing/screenshot-1-home.png`, `screenshot-2-itinerary.png`, `screenshot-3-flight.png` | + +### 2. Build and Upload the App Bundle + +1. Create your upload key: + ```shell + ./jetpacker/play/create_upload_key.sh + ``` +2. Build and sign the release bundle: + ```shell + ./jetpacker/play/build_play_bundle.sh \ + --keystore ~/keys/jetpacker-upload.jks \ + --application-id com.yourname.jetpacker \ + --universal-apk ~/jetpacker-play.apk + ``` + > **Note**: Generating a universal test APK via `--universal-apk` requires `bundletool` available on your `PATH` or configured via the `BUNDLETOOL_JAR` environment variable (e.g. `export BUNDLETOOL_JAR=/path/to/bundletool-all.jar`). +3. In Google Play Console, navigate to **Internal testing** > **Create new release**. +4. Upload the generated `.aab` bundle and confirm AppFunctions are listed. +5. Save the release and add your test Google account to the internal testers list. + +### 3. Install JetPacker on the Test Device + +Install the APK on an Android 16+ device: + +```shell +adb install -r ~/jetpacker-play.apk +``` + +> **Important**: The test device must have the exact same package name (`applicationId`), `versionCode`, and signing certificate installed as the bundle uploaded to Play Console. If installing directly via `adb` using `--universal-apk`, ensure the APK was generated from the same bundle/keystore. Alternatively, install directly from Google Play via the internal testing track. + +Verify that JetPacker registered its AppFunctions: + +```shell +adb shell dumpsys app_function | grep jetpacker +``` + +### 4. Connect JetPacker in Gemini + +1. On your test device, open the **Gemini** app. +2. Tap your profile icon > **Connected apps**. +3. Locate **JetPacker** in the list and enable it. +4. Test invoking the connected app by typing `@JetPacker` in Gemini. + +## Test Prompts + +To start with clean seed data, clear the app data with `adb shell pm clear "$JETPACKER_APP_ID"` (see Demo Controls below), and start each prompt with `@JetPacker`. + +| Prompt | Function | Expected Result | +| :--- | :--- | :--- | +| `@JetPacker find my trip to California` | `searchTrip` | California I/O, May 18–22, 2026 | +| `@JetPacker what's on my itinerary for California I/O?` | `getItinerary` | AMS > SFO flight, Car pickup, Check-in @ The Redwood Grove Hotel, San Francisco Bay Cruise, SFMOMA | +| `@JetPacker what expenses do I have for California I/O?` | `getExpenses` | \$190.00 total: SFO Taxi (\$45), SFMOMA Ticket (\$25), Dinner @ Quantum Bites (\$120) | +| `@JetPacker add 35 dollars for airport shuttle to my California trip` | `addExpense` | Logged; \$35.00 added | +| `@JetPacker add a team dinner on May 19 at 7 PM to my California trip` | `addItineraryEvent` | New dinner event added on May 19 | +| `@JetPacker save a voice note on my California trip: grab the keynote slides` | `addVoiceNote` | Note saved to trip | +| `@JetPacker show my voice notes for California I/O` | `getVoiceNotes` | Displays saved note | +| `@JetPacker set the theme for 2026-05-19 on my California trip to "Museum & Bay day"` | `setDayTheme` | Daily theme saved | +| `@JetPacker what are the day themes for California I/O?` | `getDayThemes` | Lists "Museum & Bay day" on 2026-05-19 | +| `@JetPacker create a trip to Lisbon from November 12 to 16` | `createTrip` | New Lisbon trip created | + +## Demo Controls + +To reset or test scenarios via ADB: + +```shell +export JETPACKER_APP_ID="com.example.jetpacker" + +# Reset all app data and restore default seed data: +adb shell pm clear "$JETPACKER_APP_ID" + +# Pin the app clock to May 18, 2026, 10:00 UTC (start of California I/O trip): +adb shell am start -n "$JETPACKER_APP_ID/com.example.jetpacker.MainActivity" \ + --el override_time 1779098400000 +``` + +## Troubleshooting + +| Symptom | Likely Cause | Solution | +| :--- | :--- | :--- | +| Package name rejected | Another account uploaded it or name conflict | Pass a unique `--application-id` | +| `Can't find service: app_function` | Running on Android 15 or earlier | Use an Android 16 (API 36+) device | +| AppFunctions not listed in Play Console | Bundle metadata missing | Verify `base/root/assets/jetpacker_app_function_service.xml` in `.aab` | +| `@JetPacker` not visible in Gemini | Connected app toggle disabled | Enable in Gemini > Profile > Connected apps | diff --git a/jetpacker/play/README.md b/jetpacker/play/README.md new file mode 100644 index 00000000..0f84519a --- /dev/null +++ b/jetpacker/play/README.md @@ -0,0 +1,70 @@ +# Upload JetPacker to Google Play Console as a connected app + +These scripts build and sign a JetPacker Android App Bundle (`.aab`) for Google Play Console internal testing, allowing Gemini and Android to call JetPacker's AppFunctions as a connected app. + +To run the complete demo flow, see [DEMO.md](DEMO.md). + +## Prerequisites + +- Android SDK installed and configured. +- A Google Play developer account with Internal App Sharing or Internal Testing access. +- Test device running Android 16 or later (API 36+). +- Default store listing images and metadata (located in `store_listing/`). +- `bundletool` (optional): Required only if using `--universal-apk`. Provide `bundletool` on `PATH` or set `BUNDLETOOL_JAR` to your `bundletool-all.jar`. + +> **Important**: Test devices must have the exact same package name (`applicationId`), `versionCode`, and signing certificate as the bundle uploaded to Google Play Console. + +## Quick Start + +### 1. Create an upload signing key + +Create an upload key once with the helper script: + +```shell +./jetpacker/play/create_upload_key.sh +``` + +By default, this writes `~/keys/jetpacker-upload.jks`. Keep this file and its password secure. + +### 2. Configure a demo profile (Optional) + +Copy the profile template to set your package name, keystore path, and developer account: + +```shell +cp jetpacker/play/profiles/example.conf.example jetpacker/play/profiles/my-profile.conf +``` + +Edit `profiles/my-profile.conf` to set your desired `APPLICATION_ID` and `KEYSTORE` location. + +### 3. Build and sign the bundle + +Run the build script with your profile: + +```shell +./jetpacker/play/build_play_bundle.sh --profile my-profile +``` + +Or pass flags directly: + +```shell +./jetpacker/play/build_play_bundle.sh \ + --keystore ~/keys/jetpacker-upload.jks \ + --application-id com.yourname.jetpacker \ + --universal-apk ~/jetpacker-play.apk +``` + +By default, output bundles are written to `~/jetpacker-play-v.aab`. The script builds `:app:bundleRelease` with Gradle, signs it with your upload key, and verifies the AppFunctions metadata assets (`base/root/assets/jetpacker_app_function_service.xml`). + +## Profile Configuration Format + +Profiles in `profiles/*.conf` allow storing repeatable build settings: + +| Key | Description | +| :--- | :--- | +| `APPLICATION_ID` | Package name (e.g., `com.yourname.jetpacker`). | +| `KEYSTORE` | Path to upload keystore (starts with `/` or `~/`). | +| `UNIVERSAL_APK` | Optional path to output a universal test APK (requires `bundletool`). | +| `PLAY_CONSOLE` | Google Play Console URL (`https://play.google.com/console`). | +| `DEVELOPER_ID` | Numerical developer account ID. | + +Notice: Never commit private keystores or passwords to source control. Local `*.conf` files are git-ignored. diff --git a/jetpacker/play/build_play_bundle.sh b/jetpacker/play/build_play_bundle.sh new file mode 100755 index 00000000..1dfa2bf0 --- /dev/null +++ b/jetpacker/play/build_play_bundle.sh @@ -0,0 +1,392 @@ +#!/bin/bash +# Builds, signs, and checks the JetPacker app bundle for Play Console upload. +# Run with --help for options. Settings come from command-line flags first, +# then the demo profile named by --profile. + +set -euo pipefail + +script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" + +# Find android root with gradlew +android_dir="" +if [[ -f "${script_dir}/../android/gradlew" ]]; then + android_dir="$(cd -- "${script_dir}/../android" && pwd)" +elif [[ -f "${script_dir}/../../gradlew" ]]; then + android_dir="$(cd -- "${script_dir}/../.." && pwd)" +elif [[ -f "${script_dir}/gradlew" ]]; then + android_dir="${script_dir}" +else + android_dir="$(pwd)" +fi + +readonly ANDROID_DIR="${android_dir}" +readonly GRADLEW="${ANDROID_DIR}/gradlew" + +# Locate JDK tools (jarsigner, keytool, java) +JDK_BIN_DIR="${JDK_BIN:-}" +if [[ -z "${JDK_BIN_DIR}" ]]; then + if command -v jarsigner >/dev/null 2>&1; then + JDK_BIN_DIR="$(dirname -- "$(command -v jarsigner)")" + elif [[ -d "/usr/local/buildtools/java/jdk/bin" ]]; then + JDK_BIN_DIR="/usr/local/buildtools/java/jdk/bin" + fi +fi +readonly JDK_BIN="${JDK_BIN_DIR}" + +readonly JARSIGNER="${JDK_BIN:+${JDK_BIN}/}jarsigner" +readonly KEYTOOL="${JDK_BIN:+${JDK_BIN}/}keytool" +readonly JAVA="${JDK_BIN:+${JDK_BIN}/}java" +readonly SIGALG="${JARSIGNER_SIGALG:-SHA256withRSA}" + +readonly APPFUNCTIONS_ASSET="base/root/assets/jetpacker_app_function_service.xml" +readonly BUNDLETOOL_JAR="${BUNDLETOOL_JAR:-}" + +# Keys a profile can set. README.md describes them. +readonly -a PROFILE_KEYS=( + APPLICATION_ID KEYSTORE UNIVERSAL_APK PLAY_CONSOLE DEVELOPER_ID +) + +usage() { + cat <.aab. + --abis LIST Comma-separated native ABIs. Default: arm64-v8a. + --universal-apk PATH Also write a universal APK signed with the upload key, + to install on test devices. + -h, --help Show this help. + +Environment: + JDK_BIN JDK bin directory containing jarsigner, keytool, and java. + BUNDLETOOL_JAR Optional path to bundletool.jar to generate universal APKs. + JARSIGNER_SIGALG Signature algorithm. Default: SHA256withRSA. +EOF +} + +die() { + echo "Error: $*" >&2 + exit 1 +} + +profile="" +keystore="" +expected_package="" +expected_version="" +key_alias="upload" +out="" +abis="arm64-v8a" +universal_apk="" + +while (($# > 0)); do + case "$1" in + -h | --help) + usage + exit 0 + ;; + --profile | --keystore | --application-id | --version-code | --alias | \ + --out | --abis | --universal-apk) + (($# >= 2)) || die "$1 needs a value." + case "$1" in + --profile) profile="$2" ;; + --keystore) keystore="$2" ;; + --application-id) expected_package="$2" ;; + --version-code) expected_version="$2" ;; + --alias) key_alias="$2" ;; + --out) out="$2" ;; + --abis) abis="$2" ;; + --universal-apk) universal_apk="$2" ;; + esac + shift 2 + ;; + *) + usage >&2 + die "Unknown argument: $1" + ;; + esac +done + +profile_names() { + local file names="" + for file in "${script_dir}"/profiles/*.conf "${script_dir}"/profiles/*.example; do + [[ -e "${file}" ]] || continue + file="${file##*/}" + names+="${names:+, }${file%.*}" + done + echo "${names:-none}" +} + +normalize_path() { + local path="$1" + if [[ "${path}" == "~" ]]; then + path="${HOME}" + elif [[ "${path}" == "~/"* ]]; then + path="${HOME}/${path#"~/"}" + fi + + if [[ "${path}" != /* ]]; then + path="$(pwd)/${path}" + fi + + local cur="${path}" suffix="" + while [[ ! -d "${cur}" && "${cur}" != "/" && "${cur}" != "." ]]; do + suffix="/$(basename -- "${cur}")${suffix}" + cur="$(dirname -- "${cur}")" + done + + local resolved_dir + resolved_dir="$(cd -P -- "${cur}" 2>/dev/null && pwd || echo "${cur}")" + local full="${resolved_dir}${suffix}" + + local -a parts=() + IFS='/' read -r -a parts <<< "${full}" + + local -a stack=() + local part + for part in "${parts[@]}"; do + if [[ -z "${part}" || "${part}" == "." ]]; then + continue + elif [[ "${part}" == ".." ]]; then + if ((${#stack[@]} > 0)); then + unset 'stack[${#stack[@]}-1]' + fi + else + stack+=("${part}") + fi + done + + local result="" + for part in "${stack[@]}"; do + result+="/${part}" + done + echo "${result:-/}" +} + +profile_APPLICATION_ID="" +profile_KEYSTORE="" +profile_UNIVERSAL_APK="" +profile_PLAY_CONSOLE="" +profile_DEVELOPER_ID="" + +load_profile() { + local file="$1" line key value line_number=0 + local skip_re='^[[:space:]]*(#|$)' + local entry_re='^[[:space:]]*([A-Z_]+)[[:space:]]*=(.*)$' + while IFS= read -r line || [[ -n "${line}" ]]; do + line_number=$((line_number + 1)) + line="${line%$'\r'}" + [[ "${line}" =~ ${skip_re} ]] && continue + [[ "${line}" =~ ${entry_re} ]] || + die "${file}:${line_number}: expected KEY=VALUE." + key="${BASH_REMATCH[1]}" + value="${BASH_REMATCH[2]}" + value="${value#"${value%%[![:space:]]*}"}" + value="${value%"${value##*[![:space:]]}"}" + if [[ "${value}" == '"'*'"' && "${#value}" -ge 2 ]]; then + value="${value:1:${#value}-2}" + fi + case "${key}" in + APPLICATION_ID) profile_APPLICATION_ID="${value}" ;; + KEYSTORE) + [[ "${value}" != "~/"* ]] || value="${HOME}/${value#"~/"}" + [[ -z "${value}" || "${value}" == /* ]] || + die "${file}:${line_number}: KEYSTORE must start with / or ~/." + profile_KEYSTORE="${value}" + ;; + UNIVERSAL_APK) + [[ "${value}" != "~/"* ]] || value="${HOME}/${value#"~/"}" + [[ -z "${value}" || "${value}" == /* ]] || + die "${file}:${line_number}: UNIVERSAL_APK must start with / or ~/." + profile_UNIVERSAL_APK="${value}" + ;; + PLAY_CONSOLE) profile_PLAY_CONSOLE="${value}" ;; + DEVELOPER_ID) profile_DEVELOPER_ID="${value}" ;; + *) die "${file}:${line_number}: unknown key ${key}. Valid keys: ${PROFILE_KEYS[*]}." ;; + esac + done <"${file}" +} + +profile_file="" +if [[ -n "${profile}" ]]; then + if [[ "${profile}" == */* ]]; then + profile_file="$(normalize_path "${profile}")" + elif [[ -f "${script_dir}/profiles/${profile}.conf" ]]; then + profile_file="${script_dir}/profiles/${profile}.conf" + elif [[ -f "${script_dir}/profiles/${profile}.conf.example" ]]; then + profile_file="${script_dir}/profiles/${profile}.conf.example" + else + profile_file="${script_dir}/profiles/${profile}.conf" + fi + [[ -r "${profile_file}" ]] || + die "Can't read profile ${profile_file}. Available profiles: $(profile_names)." + load_profile "${profile_file}" +fi + +keystore="${keystore:-${profile_KEYSTORE:-}}" +expected_package="${expected_package:-${profile_APPLICATION_ID:-com.example.jetpacker}}" +universal_apk="${universal_apk:-${profile_UNIVERSAL_APK:-}}" +play_console="${profile_PLAY_CONSOLE:-https://play.google.com/console}" +play_console="${play_console%/}" +developer_id="${developer_id:-${profile_DEVELOPER_ID:-}}" + +url_re='^https://[^[:space:]]+$' +[[ -z "${play_console}" || "${play_console}" =~ ${url_re} ]] || + die "PLAY_CONSOLE must be an https:// URL, not '${play_console}'." +[[ -z "${developer_id}" || "${developer_id}" =~ ^[0-9]+$ ]] || + die "DEVELOPER_ID must contain only digits, not '${developer_id}'." + +if [[ -n "${expected_version}" ]]; then + [[ "${expected_version}" =~ ^[1-9][0-9]*$ ]] || + die "--version-code must be a positive integer, not '${expected_version}'." +fi + +[[ -n "${keystore}" ]] || + die "Pass --keystore, or a --profile that sets KEYSTORE. Create a keystore with create_upload_key.sh." + +keystore="$(normalize_path "${keystore}")" +[[ -r "${keystore}" ]] || + die "Can't read ${keystore}. Create it with create_upload_key.sh." + +if [[ -n "${out}" ]]; then + out="$(normalize_path "${out}")" +else + resolved_version="${expected_version:-}" + if [[ -z "${resolved_version}" && -f "${ANDROID_DIR}/gradle/libs.versions.toml" ]]; then + resolved_version="$(sed -n 's/^[[:space:]]*versionCode[[:space:]]*=[[:space:]]*"\([0-9]*\)".*/\1/p' "${ANDROID_DIR}/gradle/libs.versions.toml" | head -n 1)" + fi + resolved_version="${resolved_version:-1}" + out="${HOME}/jetpacker-play-v${resolved_version}.aab" +fi + +bundletool_cmd=() +if [[ -n "${universal_apk}" ]]; then + universal_apk="$(normalize_path "${universal_apk}")" + if [[ -n "${BUNDLETOOL_JAR}" && -f "${BUNDLETOOL_JAR}" ]]; then + bundletool_cmd=("${JAVA}" -jar "${BUNDLETOOL_JAR}") + elif command -v bundletool >/dev/null 2>&1; then + bundletool_cmd=("$(command -v bundletool)") + else + die "Universal APK requested, but bundletool was not found. Install bundletool on PATH or set BUNDLETOOL_JAR=/path/to/bundletool.jar." + fi +fi + +command -v "${KEYTOOL}" >/dev/null 2>&1 || + die "keytool binary not found at '${KEYTOOL}'. Set JDK_BIN or ensure keytool is in PATH." +command -v "${JARSIGNER}" >/dev/null 2>&1 || + die "jarsigner binary not found at '${JARSIGNER}'. Set JDK_BIN or ensure jarsigner is in PATH." + +[[ -x "${GRADLEW}" ]] || + die "Cannot find gradlew executable at ${GRADLEW}." + +# Check password and alias before building +if [[ -z "${KS_PASS:-}" ]]; then + read -r -s -p "Password for ${keystore}: " ks_pass + echo >&2 +else + ks_pass="${KS_PASS}" +fi + +kt_err="$(KS_PASS="${ks_pass}" "${KEYTOOL}" -list -keystore "${keystore}" \ + -alias "${key_alias}" -storepass:env KS_PASS 2>&1)" || { + die "keytool failed for keystore '${keystore}' (alias: '${key_alias}'): ${kt_err:-unknown error}" +} + +echo "Building :app:bundleRelease with Gradle in ${ANDROID_DIR}..." >&2 + +gradle_args=(":app:bundleRelease") +if [[ -n "${expected_version}" ]]; then + gradle_args+=("-PversionCode=${expected_version}") +fi +if [[ -n "${expected_package}" && "${expected_package}" != "com.example.jetpacker" ]]; then + gradle_args+=("-PapplicationId=${expected_package}") +fi +if [[ -n "${abis}" ]]; then + gradle_args+=("-Pabis=${abis}") +fi + +(cd "${ANDROID_DIR}" && "${GRADLEW}" "${gradle_args[@]}") + +# Locate built bundle +built_aab="${ANDROID_DIR}/app/build/outputs/bundle/release/app-release.aab" +if [[ ! -f "${built_aab}" ]]; then + built_aab="${ANDROID_DIR}/app/build/outputs/bundle/release/app-release-unsigned.aab" +fi +[[ -f "${built_aab}" ]] || + die "Gradle build succeeded, but no AAB bundle found in ${ANDROID_DIR}/app/build/outputs/bundle/release/." + +work="$(mktemp -d)" +trap 'rm -rf -- "${work}"' EXIT +signed="${work}/signed.aab" +cp -- "${built_aab}" "${signed}" +chmod u+w -- "${signed}" + +echo "Signing bundle with upload key..." >&2 +KS_PASS="${ks_pass}" "${JARSIGNER}" -keystore "${keystore}" \ + -storepass:env KS_PASS -digestalg SHA-256 -sigalg "${SIGALG}" \ + "${signed}" "${key_alias}" >/dev/null + +verify_output="$("${JARSIGNER}" -verify "${signed}")" +[[ "${verify_output}" == *"jar verified."* ]] || + die "jarsigner cannot verify the signed bundle." + +# Verify contents +contents="$(unzip -Z1 "${signed}")" +if [[ "${contents}" != *"${APPFUNCTIONS_ASSET}"* ]]; then + echo "Warning: bundle does not contain ${APPFUNCTIONS_ASSET}." >&2 +fi + +mkdir -p -- "$(dirname -- "${out}")" +cp -- "${signed}" "${out}" + +# If universal apk is requested, generate it +if [[ -n "${universal_apk}" ]]; then + (umask 077 && printf '%s' "${ks_pass}" >"${work}/ks_pass") + "${bundletool_cmd[@]}" build-apks --mode=universal \ + --bundle="${signed}" --output="${work}/universal.apks" \ + --ks="${keystore}" --ks-key-alias="${key_alias}" \ + --ks-pass="file:${work}/ks_pass" + mkdir -p -- "$(dirname -- "${universal_apk}")" + unzip -p "${work}/universal.apks" universal.apk >"${universal_apk}" + echo "Universal APK generated at: ${universal_apk}" +fi + +cert_sha256="$(KS_PASS="${ks_pass}" "${KEYTOOL}" -list \ + -keystore "${keystore}" -alias "${key_alias}" -storepass:env KS_PASS | + sed -n 's/^Certificate fingerprint (SHA-256): //p')" + +cat < Create new release, +upload the bundle, and complete release submission. +EOF diff --git a/jetpacker/play/create_upload_key.sh b/jetpacker/play/create_upload_key.sh new file mode 100755 index 00000000..cec39acb --- /dev/null +++ b/jetpacker/play/create_upload_key.sh @@ -0,0 +1,121 @@ +#!/bin/bash +# Creates the Play upload key for the JetPacker bundle, outside the source tree. +# +# Usage: create_upload_key.sh [KEYSTORE] [ALIAS] +# KEYSTORE Default: ~/keys/jetpacker-upload.jks +# ALIAS Default: upload +# +# Never write a keystore into a source tree or public repository. Play App Signing +# manages the app signing key; this key only authenticates your uploads. If you +# lose it, request an upload key reset on the App integrity page in Play Console. + +set -euo pipefail + +script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" + +normalize_path() { + local path="$1" + if [[ "${path}" == "~" ]]; then + path="${HOME}" + elif [[ "${path}" == "~/"* ]]; then + path="${HOME}/${path#"~/"}" + fi + + if [[ "${path}" != /* ]]; then + path="$(pwd)/${path}" + fi + + local cur="${path}" suffix="" + while [[ ! -d "${cur}" && "${cur}" != "/" && "${cur}" != "." ]]; do + suffix="/$(basename -- "${cur}")${suffix}" + cur="$(dirname -- "${cur}")" + done + + local resolved_dir + resolved_dir="$(cd -P -- "${cur}" 2>/dev/null && pwd || echo "${cur}")" + local full="${resolved_dir}${suffix}" + + local -a parts=() + IFS='/' read -r -a parts <<< "${full}" + + local -a stack=() + local part + for part in "${parts[@]}"; do + if [[ -z "${part}" || "${part}" == "." ]]; then + continue + elif [[ "${part}" == ".." ]]; then + if ((${#stack[@]} > 0)); then + unset 'stack[${#stack[@]}-1]' + fi + else + stack+=("${part}") + fi + done + + local result="" + for part in "${stack[@]}"; do + result+="/${part}" + done + echo "${result:-/}" +} + +raw_keystore="${1:-${HOME}/keys/jetpacker-upload.jks}" +keystore="$(normalize_path "${raw_keystore}")" +readonly keystore +readonly key_alias="${2:-upload}" + +KEYTOOL_BIN="" +if [[ -n "${JDK_BIN:-}" && -x "${JDK_BIN}/keytool" ]]; then + KEYTOOL_BIN="${JDK_BIN}/keytool" +elif command -v keytool >/dev/null 2>&1; then + KEYTOOL_BIN="$(command -v keytool)" +elif [[ -x "/usr/local/buildtools/java/jdk/bin/keytool" ]]; then + KEYTOOL_BIN="/usr/local/buildtools/java/jdk/bin/keytool" +else + echo "Error: keytool binary not found. Set JDK_BIN or ensure keytool is in PATH." >&2 + exit 1 +fi +readonly keytool="${KEYTOOL_BIN}" + +is_inside_tree() { + local file_path="$1" root_path="$2" + [[ -n "${root_path}" && ( "${file_path}" == "${root_path}" || "${file_path}" == "${root_path}/"* ) ]] +} + +script_git_root="$(git -C "${script_dir}" rev-parse --show-toplevel 2>/dev/null || true)" +pwd_git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" +target_ancestor="$(dirname -- "${keystore}")" +while [[ ! -d "${target_ancestor}" && "${target_ancestor}" != "/" ]]; do + target_ancestor="$(dirname -- "${target_ancestor}")" +done +target_git_root="$(git -C "${target_ancestor}" rev-parse --show-toplevel 2>/dev/null || true)" + +for root in "${script_git_root}" "${pwd_git_root}" "${target_git_root}"; do + if is_inside_tree "${keystore}" "${root}"; then + echo "Error: refusing to create a keystore inside a source tree:" \ + "${keystore}" >&2 + exit 1 + fi +done + +if [[ -e "${keystore}" ]]; then + echo "Error: ${keystore} already exists. Pick another path or reuse it." >&2 + exit 1 +fi + +mkdir -p -m 700 -- "$(dirname -- "${keystore}")" + +# keytool prompts for the password, so it never reaches shell history. +"${keytool}" -genkeypair \ + -keystore "${keystore}" \ + -storetype PKCS12 \ + -alias "${key_alias}" \ + -keyalg RSA \ + -keysize 2048 \ + -validity 10000 \ + -dname "CN=JetPacker upload key" +chmod 600 -- "${keystore}" + +echo +echo "Created ${keystore} (alias: ${key_alias})." +echo "Back up the keystore file and its password somewhere private." diff --git a/jetpacker/play/profiles/example.conf.example b/jetpacker/play/profiles/example.conf.example new file mode 100644 index 00000000..4c831711 --- /dev/null +++ b/jetpacker/play/profiles/example.conf.example @@ -0,0 +1,11 @@ +# JetPacker demo profile for Play Console internal testing. +# Copy this file to profiles/.conf and customize: +# build_play_bundle.sh --profile --version-code N +# +# Notice: Never commit real signing passwords or keystores to source control. + +PLAY_CONSOLE=https://play.google.com/console +DEVELOPER_ID=1234567890123456789 +APPLICATION_ID=com.example.jetpacker +KEYSTORE=~/keys/jetpacker-upload.jks +# UNIVERSAL_APK=~/jetpacker-play.apk # Requires BUNDLETOOL_JAR or bundletool on PATH diff --git a/jetpacker/play/store_listing/feature-graphic-1024x500.png b/jetpacker/play/store_listing/feature-graphic-1024x500.png new file mode 100644 index 00000000..ed757407 Binary files /dev/null and b/jetpacker/play/store_listing/feature-graphic-1024x500.png differ diff --git a/jetpacker/play/store_listing/icon-512.png b/jetpacker/play/store_listing/icon-512.png new file mode 100644 index 00000000..71650db8 Binary files /dev/null and b/jetpacker/play/store_listing/icon-512.png differ diff --git a/jetpacker/play/store_listing/screenshot-1-home.png b/jetpacker/play/store_listing/screenshot-1-home.png new file mode 100644 index 00000000..f7a8ae95 Binary files /dev/null and b/jetpacker/play/store_listing/screenshot-1-home.png differ diff --git a/jetpacker/play/store_listing/screenshot-2-itinerary.png b/jetpacker/play/store_listing/screenshot-2-itinerary.png new file mode 100644 index 00000000..61da561d Binary files /dev/null and b/jetpacker/play/store_listing/screenshot-2-itinerary.png differ diff --git a/jetpacker/play/store_listing/screenshot-3-flight.png b/jetpacker/play/store_listing/screenshot-3-flight.png new file mode 100644 index 00000000..ab9dedba Binary files /dev/null and b/jetpacker/play/store_listing/screenshot-3-flight.png differ