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
72 changes: 70 additions & 2 deletions jetpacker/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,13 @@ This project is built using the standard Android Gradle build system, allowing d
- Update `sdk.dir` inside `local.properties` with your local Android SDK directory path.

2. **Firebase Setup (`google-services.json`)**:
- Register the application in your Firebase Project Console.
- Download the project's custom `google-services.json` and place it in the `android/app/` directory (overwriting the mock placeholder file).
- Register an Android app in your **Firebase Project Console** using the package name `com.example.jetpacker`.
- In the Firebase Console, navigate to **Authentication** -> **Sign-in method** and enable **Anonymous** sign-in (used for session-based communication with the booking coordinator and AI services).
- Download your project's `google-services.json` and place it in the `android/app/` directory (replacing the mock placeholder file). The Gradle `google-services` plugin automatically parses this file and configures the required resources at build time—no code changes or extra XML files are required.
- *Git Safety*: To prevent accidentally committing your personal Firebase credentials back to the repository, tell git to ignore local changes to this file:
```bash
git update-index --skip-worktree android/app/google-services.json
```

3. **Firebase App Check Debug Attestation**:
- Run the application on an emulator or a connected device.
Expand Down Expand Up @@ -114,6 +119,69 @@ docker build -t jetpacker-server .
docker run -p 8080:8080 jetpacker-server
```

#### Deploying to Google Cloud Run (Production vs Parallel Staging)

The booking server can be deployed to Cloud Run behind a Google Cloud API Gateway that validates Firebase Authentication tokens.

We support side-by-side execution so you can run and test updates on a parallel staging backend without affecting the production service:
- **Production Service (`jetpacker-server`)**: Backs `/` routes on API Gateway (`/stream`, `/respond`, etc.).
- **Parallel Staging Service (`jetpacker-server-v2`)**: Backs `/v2/*` routes on API Gateway (`/v2/stream`, `/v2/respond`, etc.).

##### Deploying to Parallel Staging (`jetpacker-server-v2`)
1. **Build and submit the image**:
```bash
gcloud builds submit --tag gcr.io/android-devrel-ci/jetpacker-server:v2 \
--project=android-devrel-ci server/
```
2. **Deploy to Cloud Run**:
```bash
gcloud run deploy jetpacker-server-v2 \
--image gcr.io/android-devrel-ci/jetpacker-server:v2 \
--region us-central1 \
--project android-devrel-ci \
--set-env-vars CLOUD_RUN_AUDIENCE=https://jetpacker-server-v2-254502043090.us-central1.run.app \
--no-allow-unauthenticated
```

##### Deploying to Production (`jetpacker-server`)
1. **Build and submit the image**:
```bash
gcloud builds submit --tag gcr.io/android-devrel-ci/jetpacker-server:latest \
--project=android-devrel-ci server/
```
2. **Deploy to Cloud Run**:
```bash
gcloud run deploy jetpacker-server \
--image gcr.io/android-devrel-ci/jetpacker-server:latest \
--region us-central1 \
--project android-devrel-ci \
--set-env-vars CLOUD_RUN_AUDIENCE=https://jetpacker-server-254502043090.us-central1.run.app \
--no-allow-unauthenticated
```

##### Updating the API Gateway Config
To update API Gateway routes using `server/openapi.yaml`:
```bash
gcloud api-gateway api-configs create jetset-config-v<N> \
--api=jetset-api \
--openapi-spec=server/openapi.yaml \
--project=android-devrel-ci \
--backend-auth-service-account=api-gateway-invoker@android-devrel-ci.iam.gserviceaccount.com

gcloud api-gateway gateways update jetset-gateway \
--api=jetset-api \
--api-config=jetset-config-v<N> \
--location=us-central1 \
--project=android-devrel-ci
```

##### Configuring the Android App Endpoint
In `BookingAssistantViewModel.kt`, adjust `backendEndpoint`:
- `BackendEndpoint.PARALLEL_STAGING` *(default)*: Connects to `/v2` on API Gateway (`jetpacker-server-v2`).
- `BackendEndpoint.PRODUCTION`: Connects to `/` on API Gateway (`jetpacker-server`).
- `BackendEndpoint.LOCAL`: Connects to `http://localhost:8000` (via `adb reverse tcp:8000 tcp:8000`).


## On-Device AI Features
JetPacker integrates local on-device AI capabilities using ML Kit. These features run entirely on-device and can be toggled or customized in `android/core/flags/src/main/kotlin/com/example/jetpacker/core/flags/FeatureFlags.kt`:
- **ENABLE_TRIP_SUMMARY_AND_TIPS**: Generates an on-device card summary of the current trip using ML Kit GenAI Prompt.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,17 +36,24 @@ class JetPackerApplication : Application() {
ComposeUiFlags.isMediaQueryIntegrationEnabled = true
super.onCreate()
FeatureFlags.initialize(this)
Firebase.initialize(context = this)
Firebase.appCheck.installAppCheckProviderFactory(
DebugAppCheckProviderFactory.getInstance(),
)
Firebase.auth.signInAnonymously()
.addOnCompleteListener { task ->
if (task.isSuccessful) {
Log.d("JetPackerApplication", "Anonymous auth successful")
} else {
Log.w("JetPackerApplication", "Anonymous auth failed", task.exception)
}
try {
if (Firebase.initialize(context = this) != null) {
Firebase.appCheck.installAppCheckProviderFactory(
DebugAppCheckProviderFactory.getInstance(),
)
Firebase.auth.signInAnonymously()
.addOnCompleteListener { task ->
if (task.isSuccessful) {
Log.d("JetPackerApplication", "Anonymous auth successful")
} else {
Log.w("JetPackerApplication", "Anonymous auth failed", task.exception)
}
}
} else {
Log.w("JetPackerApplication", "Firebase not initialized")
}
} catch (e: Exception) {
Log.w("JetPackerApplication", "Firebase initialization error", e)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If initialization fails how the app should work? Do we need to exit here?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nah the rest of the features will still work so no need to fully cancel. We should just make sure not to crash

}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ import androidx.a2ui.model.protocol.A2uiClientEventMessage
import androidx.a2ui.model.protocol.A2uiComponentPayload
import androidx.a2ui.model.protocol.A2uiCreateSurfaceMessage
import androidx.a2ui.model.protocol.A2uiUpdateComponentsMessage
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
Expand Down Expand Up @@ -75,8 +78,32 @@ class BookingAssistantViewModel @Inject constructor(
private var activeStreamJob: Job? = null
private var streamIdleJob: Job? = null

// Set to true when testing against local server (http://localhost:8000)
val useLocalServer: Boolean = false
enum class BackendEndpoint {
LOCAL,
PARALLEL_STAGING,
PRODUCTION,
}

/**
* Active backend endpoint. Defaults to [BackendEndpoint.PARALLEL_STAGING] to connect to the
* side-by-side /v2 Cloud Run service without impacting production.
*/
var backendEndpoint by mutableStateOf(BackendEndpoint.PARALLEL_STAGING)

// Backwards compatibility property
var useLocalServer: Boolean
get() = backendEndpoint == BackendEndpoint.LOCAL
set(value) {
backendEndpoint = if (value) BackendEndpoint.LOCAL else BackendEndpoint.PARALLEL_STAGING
}

val baseUrl: String
get() =
when (backendEndpoint) {
BackendEndpoint.LOCAL -> "http://localhost:8000"
BackendEndpoint.PARALLEL_STAGING -> "https://jetset-gateway-38wzy18y.uc.gateway.dev/v2"
BackendEndpoint.PRODUCTION -> "https://jetset-gateway-38wzy18y.uc.gateway.dev"
}

private val client = OkHttpClient.Builder().readTimeout(0, TimeUnit.MILLISECONDS).build()

Expand Down Expand Up @@ -310,9 +337,9 @@ class BookingAssistantViewModel @Inject constructor(
}
}

log("Starting connectToStream with sId: $sId, useLocalServer: $useLocalServer")
log("Starting connectToStream with sId: $sId, backendEndpoint: $backendEndpoint")

if (useLocalServer) {
if (backendEndpoint == BackendEndpoint.LOCAL) {
streamToken = "local_dev_token"
streamUrl = "http://localhost:8000/run_sse"
} else {
Expand All @@ -323,7 +350,7 @@ class BookingAssistantViewModel @Inject constructor(
return
}

val gatewayUrl = "https://jetset-gateway-38wzy18y.uc.gateway.dev/stream?auth_only=true"
val gatewayUrl = "$baseUrl/stream?auth_only=true"
val tokenRequest = Request.Builder()
.url(gatewayUrl)
.addHeader("Authorization", "Bearer $fbToken")
Expand Down Expand Up @@ -656,7 +683,6 @@ class BookingAssistantViewModel @Inject constructor(
}

val token = getFirebaseAuthToken()
val baseUrl = if (useLocalServer) "http://localhost:8000" else "https://jetset-gateway-38wzy18y.uc.gateway.dev"
val encodedSessionId = URLEncoder.encode(sId, "UTF-8")
val encodedAgentId = URLEncoder.encode(agentId, "UTF-8")
val request = Request.Builder()
Expand All @@ -680,7 +706,13 @@ class BookingAssistantViewModel @Inject constructor(

private suspend fun getFirebaseAuthToken(): String {
log("getFirebaseAuthToken: getting FirebaseAuth instance")
val auth = FirebaseAuth.getInstance()
val auth = try {
FirebaseAuth.getInstance()
} catch (e: Exception) {
log("FirebaseAuth.getInstance failed: ${e.message}")
Log.e("BookingAssistant", "FirebaseAuth.getInstance failed", e)
return ""

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why do we return an empty token here? should it return the exception?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Basically we choose to catch the exception here instead of at the caller site so that we can run the app even when firebase is not initialized and at least test the other features. Not the best user experience as the firebase features would not work, but that's not our demo scenario anyway.

}
var user = auth.currentUser
log("getFirebaseAuthToken: current user is ${user?.uid ?: "null"}")
if (user == null) {
Expand Down
8 changes: 8 additions & 0 deletions jetpacker/server/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
__pycache__
*.pyc
*.pyo
*.pyd
.Python
env/
venv/
booking/.adk
14 changes: 14 additions & 0 deletions jetpacker/server/booking/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
from __future__ import annotations

import sys
from typing import Any


def __getattr__(name: str) -> Any:
if name == "root_agent":
if "main" in sys.modules:
return sys.modules["main"].ItineraryOrchestratorAgent()
import main

return main.ItineraryOrchestratorAgent()
raise AttributeError(f"module '{__name__}' has no attribute '{name}'")
14 changes: 14 additions & 0 deletions jetpacker/server/booking/agent.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
from __future__ import annotations

import sys
from typing import Any


def __getattr__(name: str) -> Any:
if name == "root_agent":
if "main" in sys.modules:
return sys.modules["main"].ItineraryOrchestratorAgent()
import main

return main.ItineraryOrchestratorAgent()
raise AttributeError(f"module '{__name__}' has no attribute '{name}'")
57 changes: 47 additions & 10 deletions jetpacker/server/main.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import asyncio
import json
from typing import AsyncGenerator
from typing import Any, AsyncGenerator
import urllib.request

import fastapi
Expand All @@ -13,6 +13,8 @@
from google.adk.events.event_actions import EventActions
from google import genai
from google.genai import types
import inspect
import os
import pydantic
import uvicorn

Expand Down Expand Up @@ -482,20 +484,39 @@ def list_agents(self) -> list[str]:
return ["booking"]


app = get_fast_api_app(
agents_dir=".",
agent_loader=BookingAgentLoader(),
web=False,
auto_create_session=True,
)
_adk_sig = inspect.signature(get_fast_api_app)
_adk_kwargs: dict[str, Any] = {
"agents_dir": ".",
}
if "web" in _adk_sig.parameters:
_adk_kwargs["web"] = False
if "agent_loader" in _adk_sig.parameters:
_adk_kwargs["agent_loader"] = BookingAgentLoader()
if "auto_create_session" in _adk_sig.parameters:
_adk_kwargs["auto_create_session"] = True

print(f"DEBUG: Initializing ADK FastAPI with kwargs: {_adk_kwargs}")
app = get_fast_api_app(**_adk_kwargs)


class ResponseData(pydantic.BaseModel):
seat: str | None = None
time: str | None = None
selectedFlightId: str | None = None
selectedRoomId: str | None = None
nightlyRate: int | None = None
checkInTime: str | None = None
floorPreference: str | None = None
breakfastIncluded: bool | None = None
lateCheckout: bool | None = None
tickets: str | None = None
people: str | None = None
confirmed: bool | None = None
selectionType: str | None = None
priorityBoarding: bool | None = None
extraBag: bool | None = None
totalFare: int | None = None
clientDataModel: dict[str, Any] | None = None


@app.post("/respond")
Expand All @@ -508,7 +529,18 @@ async def respond(session_id: str, data: ResponseData, agent_id: str | None = No
# For backward compatibility or default flight use flight_Flight
pause_key = agent_id or "flight_Flight"

if data.seat:
if (
data.clientDataModel is not None
or data.selectionType in ("time", "confirm_flight_config", "hotel_room", "confirm_hotel_config")
or data.selectedFlightId is not None
or data.selectedRoomId is not None
or data.priorityBoarding is not None
or data.extraBag is not None
or data.breakfastIncluded is not None
or data.lateCheckout is not None
):
session.results[pause_key] = data.model_dump(exclude_none=True)
elif data.seat:
session.results[pause_key] = data.seat
elif data.time:
session.results[pause_key] = data.time
Expand Down Expand Up @@ -536,7 +568,11 @@ async def respond(session_id: str, data: ResponseData, agent_id: str | None = No
@app.get("/stream")
def get_stream_token(auth_only: bool = False):
"""Returns an OIDC token for direct streaming access."""
audience = "https://jetpacker-server-254502043090.us-central1.run.app"
del auth_only # Unused parameter kept for gateway compatibility
audience = os.environ.get(
"CLOUD_RUN_AUDIENCE",
"https://jetpacker-server-254502043090.us-central1.run.app",
)
url = f"http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/identity?audience={audience}"

try:
Expand All @@ -545,7 +581,8 @@ def get_stream_token(auth_only: bool = False):
token = urllib.request.urlopen(req).read().decode("utf-8")
return {"token": token, "url": f"{audience}/run_sse"}
except Exception as e:
raise fastapi.HTTPException(status_code=500, detail=str(e))
# If metadata server fails or running unauthenticated/local, provide fallback
return {"token": "dev_stream_token", "url": f"{audience}/run_sse"}


if __name__ == "__main__":
Expand Down
Loading
Loading