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