Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,12 @@ build/
**/local.properties
**/google-services.json
.DS_Store

# Keystores and bundle artifacts
*.jks
*.keystore
*.p12
*.aab
*.apk
*.apks

53 changes: 51 additions & 2 deletions jetpacker/android/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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"
}

Expand Down Expand Up @@ -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))
}
}
}
}

2 changes: 1 addition & 1 deletion jetpacker/android/app/src/main/res/xml/app_metadata.xml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
<AppFunctionAppMetadata xmlns:appfn="http://schemas.android.com/apk/androidx.appfunctions"
appfn:description="This app manages trips and travel expenses.
Operational Patterns:
- Use 'getAllTrips' to find the correct 'tripId' before adding expenses or managing trip details.
- Use 'searchTrip' to find the correct 'tripId' before adding expenses or managing trip details.
Constraints:
- Expenses must be associated with a valid trip ID."
appfn:displayDescription="@string/user_visible_description" />
4 changes: 3 additions & 1 deletion jetpacker/android/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified jetpacker/android/core/ui/src/main/res/drawable/img_paris.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion jetpacker/android/feature/trip/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down
2 changes: 2 additions & 0 deletions jetpacker/android/gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,5 @@ ksp.incremental=true
ksp.incremental.intermodule=true
android.nonTransitiveRClass=false


android.suppressUnsupportedCompileSdk=37.1
2 changes: 1 addition & 1 deletion jetpacker/android/gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
12 changes: 12 additions & 0 deletions jetpacker/play/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Ignore developer-specific profile configs
profiles/*.conf
!profiles/*.example

# Keystores and bundle artifacts
*.jks
*.keystore
*.p12
*.aab
*.apk
*.apks

112 changes: 112 additions & 0 deletions jetpacker/play/DEMO.md
Original file line number Diff line number Diff line change
@@ -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 |
70 changes: 70 additions & 0 deletions jetpacker/play/README.md
Original file line number Diff line number Diff line change
@@ -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<versionCode>.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.
Loading
Loading