Google のプロダクト マネージャー
Android は、Gemini などのエージェントがアプリ間でタスクをオーケストレートするインテリジェンス システムへと進化しています。新しいユーザー エクスペリエンスを解放し、これらのエージェント ワークフローに参加するには、アプリのコア機能を公開する必要があります。
このセッションでは、アプリを Android のインテリジェンス レイヤと統合するための実践的なガイドを提供します。オンデバイスの Android MCP、サーバーサイド MCP、タスク自動化など、主要な統合パスを調査し、ユースケースに適したパスを選択するための意思決定フレームワークを提供します。また、パーソナル コンテキストを使用したプロアクティブなエクスペリエンスについても説明します。
ここでは以下について学びます。
AppFunctionDeclaration を使用してツールを宣言し、KDoc の説明を提供します。Kotlin のデフォルト値に依存しないようにします。文字起こし
それでは
皆さん、こんにちは。
デベロッパー リレーションズの Dan Galpin です。
Sarah Shamsie です。Android のデベロッパー インテリジェンス フレームワークを担当するプロダクト マネージャーです。
本日のセッションにご参加いただき、ありがとうございます。
ユーザーがデバイスを操作する方法にパラダイム シフトが起きています。
ウェブの時代には、ユーザーは通常、ウェブブラウザに移動して検索機能を使用することで、ジャーニーを開始していました。
その後、モバイルの時代になると、ユーザーの行動は、1 回限りのアクションを完了するために、より専門的なネイティブ アプリを使用する方向にシフトしました。
次に例を示します。
友人から夕食に誘うメッセージが届きました。
このメッセージに返信するには、カレンダーで予定を確認し、予定をカレンダーに追加し、移動手段が必要な場合は配車サービスを予約し、参加できることを友人にメッセージで返信する必要があります。
以前は、アクションを完了してジャーニーを達成するために、デバイス上のさまざまなアプリを切り替える、いわゆるアプリホッピングが必要でした。
そして現在は「インテリジェンスの時代」です。
インテリジェンスの時代において、ユーザーは、必要な具体的なアクションではなく、大まかな意図や目標を表現することからジャーニーを開始します。
デバイスは、単にアクションを可能にするものではなく、ユーザーのニーズを積極的に予測し、目標の達成を支援するものとして捉えられるようになっています。
また、このインテリジェンス フレームワークがモバイル デバイスだけでなく、スマートウォッチ、自動車、テレビ、ノートパソコン、XR、グラスなど、さまざまなフォーム ファクタのエコシステムで動作することを期待していることもわかりました。
インテリジェンスの時代では、1 つのクロスアプリ プロンプトで、先ほどのディナーの招待の例を実際に実現できます。
1 つのプロンプトだけで、デバイス上のエージェントが、カレンダーの確認、予定の追加、配車の予約、メッセージの送信など、必要なすべてのアクションを一度に実行できます。
本日は、この分野における Android の計画と、デベロッパーの皆様が最高のエクスペリエンスを構築する方法について詳しくご紹介します。
Dan。
サラさん、ありがとうございました。
Android はコアにインテリジェンスを追加し続けており、アプリがこのインテリジェンス時代に備えるための新しい方法を提供しています。
デベロッパー インテリジェンスの 2 つの柱のうち、1 つ目はアプリ内インテリジェンスです。これは、アプリをよりスマートにし、アプリにインテリジェンスを組み込むことです。ML Kit を通じて Gemini Nano 4 などのオンデバイス モデルを使用することで、メッセージ スレッドの要約、返信の書き換え、画像の理解、自然言語を使用したアプリの関数の呼び出しが可能になります。
Firebase AI Logic を使用すると、クラウド内の Gemini モデルをアプリで直接使用することもできます。
本日は、2 つ目の柱である、インテリジェンス システム内でアプリの機能を利用できるようにすることに焦点を当てます。
では、インテリジェンス システムとは何でしょうか。
つまり、サラが先ほど話したような、プロンプトによるエクスペリエンスについて話したり、ホストしたりするだけでなく、アプリ エコシステム全体のエクスペリエンスを向上させるために、深い統合を可能にするシステムなのです。
たとえば、Gemini Intelligence 向けに設計されたデバイスは、これらの統合ポイントを活用して、提案、要約された通知、音声入力の改善、フォームの自動入力などの機能を提供します。
現在、提案は先回りアシストという機能の一部になっています。
これまでは、プロンプトによるアシスタンスについて説明してきました。
これは、ユーザーがエージェントに 1 つ以上のタスクを代行するよう指示するもので、誰もが慣れ親しんでいるものです。
しかし、先回りアシストは実際には、ユーザーが次に行う可能性のあるアクションを予測することです。
ディナーの例に戻ると、エージェントは、メッセージに応じて、カレンダーに招待を追加したり、ライドシェアを予約したりするための提案をユーザーにプロアクティブに提示する可能性があります。
では、実際にどのようにユーザーにプロンプトを表示するのでしょうか。
先回りアシストは、ロック画面、通知、仮想キーボード、選択したアプリ内などの場所にアクションを表示できます。
では、アプリをこれらのプロアクティブなチップに実際に表示して、トラフィックを誘導するにはどうすればよいでしょうか。
最新の SDK バージョンをターゲットにビルドしたり、最新のツールでビルドしたりすることは、すでに実践されていると思いますが、今できる最善のことは、アプリがプロンプトによるアシスタンスにも対応できるようにすることです。これについては、サラが次に説明します。
Dan さん、ありがとうございました。
プロンプトによるアシスタンスでは、ユーザーがエージェントに直接アクセスして、アプリをまたいでタスクの開始とオーケストレーションを依頼します。
これにより、これまで手動で行っていた時間のかかるワークフローを自動化できます。
Android のインテリジェンス システムでは、ユーザーのデバイス上のアプリにアクセスできる Gemini などのシステム エージェントに主に焦点を当てています。
アプリがこのエクスペリエンスに表示され、システム エージェントとやり取りする方法はいくつかあります。
プラットフォーム主導のタスク自動化ソリューションと、システム エージェントへの直接 API 統合です。
可能な限り、直接 API 統合を使用して構築することをおすすめします。
これらはデベロッパーが管理できるため、アプリの表示方法やブランドの表示方法を決定して管理できます。
また、高速で信頼性が高く、ユーザーにプレミアムなエクスペリエンスを提供します。
エージェントとの統合を検討する際に、2 つの API パスを検討できます。
Model Context Protocol(MCP)は、サーバーサイドの関数呼び出しに関する業界のベスト プラクティスです。
しかし、オンデバイス ツール呼び出しについては、Android の統合関数呼び出しフレームワークである Android MCP を正式に発表します。
Android MCP は、Jetpack AppFunctions ライブラリを基盤としています。このライブラリは、技術的にも概念的にも MCP と密接に連携しながら、多くの Android デベロッパーに馴染みのある Kotlin の実装をサポートしています。
これにより、API の実装が比較的直感的で使いやすくなり、すでに構築されているサーバーサイドの MCP 統合から得た知見を活用できます。
多くの MCP プリミティブは、Jetpack AppFunctions コンストラクトに 1 対 1 でマッピングされます。
いくつか例を見てみましょう。
MCP ツール エンドポイントは、AppFunctionDeclaration アノテーションが付けられた Kotlin 関数に適切にマッピングされます。
pydantic モデルは、AppFunctionSerializable アノテーション付きの Kotlin データクラスにマッピングされます。
ただし、ストリーミング可能な HTTP や標準の I/O トランスポートではなく、最適化された Android Binder IPC で動作します。
このたび、Jetpack ライブラリの AppFunctions がベータ版になりました。
では、1 つの API パスを使用するのが適切な場合と、特に Android MCP を使用するのが適切な場合について説明します。
包括的な原則として、Android MCP は、オンデバイスまたはローカル コンテキストを活用してユーザー ジャーニーを完了する場合に特に役立ちます。
Android MCP が真価を発揮する例をいくつか見てみましょう。
具体的には、ローカルの暗号化されたデータにアクセスする必要がある場合は、Android MCP を検討する必要があります。
これは、エンドツーエンドの暗号化によりメッセージの履歴をデバイス上に保持する必要があるメッセージ アプリやコミュニケーション アプリでは特に重要です。
Android MCP は、エージェントがサポートしている場合、純粋にオンデバイスのアクションやオフラインのアクションにも役立ちます。
Wear OS のパートナーである Samsung Health は、ランニングやエクササイズを開始する機能をリリースしました。この機能は、完全にオンデバイスでオーケストレートされます。
ユーザーは、このようなアクションを非常に迅速に、高い信頼性と低いレイテンシで、場合によっては接続性の低い環境でも実行したいと考えています。ツール呼び出し、ルーティング、クエリ ルーティングのすべてをデバイス上で行うことで、これを実現できます。
Android MCP は、デバイス センサーにアクセスする場合や、デバイス ハードウェアと直接やり取りする場合にも、推奨される API パスです。
たとえば、多くの自動車アプリでは、UWB 接続を介して、多くの場合ハンズフリーで、ユーザーが車のドアを瞬時にロック解除できるようにしたいと考えています。
Android MCP は、この問題の解決に役立ちます。
最後に、アプリや API で使用できる Cloud Endpoints がない場合にも、このソリューションが適しています。
デバイス メーカーのファースト パーティ製アプリの多くはクライアントサイドのみであるため、Android MCP を使用しています。
たとえば、Gemini を使用して、Xiaomi Gallery などのクライアントサイドのみの写真アプリから写真を検索して取得できます。
ここで Gemini を起動して、「先週 Xiaomi ギャラリーで撮影した Android ボットの写真を探して」とリクエストします。
Xiaomi は、写真を検索するアプリ機能を公開しています。Gemini はこの機能を呼び出し、関連する写真を Gemini アプリの UI に直接返して表示します。ユーザーはアプリを切り替える必要がありません。
また、Android OS のオンデバイス アクションでも、まったく同じアーキテクチャを使用しています。
Android MCP は、最近リリースされたデバイス ヘルプ機能を支えています。この機能は、Android OS を Gemini アプリに直接統合したものです。
この機能はすでに Google Pixel と Lenovo のデバイスでリリースされており、現在は Android OEM エコシステム全体にスケールアウトされています。
ユーザーは、Gemini にアクセスして、デバイスの使用感を向上させるために設定、デバイスの設定、システム設定を最適化する方法を尋ねることで、デバイスヘルプを使用できます。
たとえば、色覚障害のあるユーザーが、さまざまなメニューを探す代わりに、Gemini に「私は色覚障害があります。
このスマートフォンをもっと使いやすくするにはどうすればいい?」
Gemini は、ローカルアプリの機能を呼び出して、リクエストをインテリジェントに推論し、ユーザーがシステム設定を変更する実際の方法や、デバイス設定を変更してエクスペリエンスを向上させる具体的な方法を提案します。
その後、アプリ内コントロールが Gemini アプリの UI に直接表示されるため、ユーザーはすぐに操作できます。
たとえば、この色覚異常の最適化リクエストを満たすために、Gemini は実際にこの色補正の切り替えを表示し、ユーザーがその場で変更できるようにしています。
Android MCP エクスペリエンスは、すでに一部のデバイスで利用可能になっています。Google は、Android エコシステムにさらに多くのユースケースを提供できるよう、複数のパートナーと協力しています。
Android MCP ツールの明示的な宣言は、Google のゴールド スタンダードですが、ユーザーがエージェントに複雑なユースケースや一般的でないユースケースの完了を求める場合もあります。このような場合に、プラットフォーム主導のタスク自動化機能が役立ちます。
このモデルでは、OEM がプリロードしたエージェントが Android のコンピュータ制御 API を使用して、アプリのサーフェスでタッチイベントをシミュレートできます。
このため、デベロッパーがコードを変更する必要はありません。エージェントは、アプリ画面を分析して、ユーザーと同じように UI をタップする必要があります。
ユーザーの管理と安全性を確保するため、エージェントは、支払いフローなどの機密性の高い操作を行う前に一時停止し、ユーザーに確認を求めることが重要です。
実際の例を見てみましょう。
ここでは、ユーザーがコーヒーとチョコレートのツアーの写真を撮り、Expedia に問い合わせるか、Gemini に相談して Expedia を調べ、同様のツアーを見つけられるかどうかを確認し、参加人数を 6 人に調整するようリクエストしています。
Gemini は、直接的な API 統合を必要とせずに、ユーザーと同じようにバックグラウンドで Expedia の UI を操作します。
その後、見つかった関連オプションを表示し、最終確認と予約のタイミングでユーザーにシームレスに引き継ぎます。
重要なのは、これら 3 つの異なるパスは相互に排他的ではなく、実際には Better Together であるということです。
ユーザーとデベロッパーは、エージェントがサーバー実装全体でツールを調整し、ローカルの Android アプリも調整して、最も包括的で全体的なエクスペリエンスをユーザーに提供できることを期待する必要があります。
それでは
サラさん、ありがとうございました。
Android MCP についてもう少し詳しく見ていきましょう。
また、可能な場合は、Jetpacker から MCP コード スニペットを pull していることをお知らせします。Jetpacker は、実際には Antigravity の助けを借りて構築された旅行サンプルアプリです。
これは基調講演でご覧いただきました。
ML Kit 生成 AI API や ML Kit 音声認識 API とのオンデバイス統合など、さまざまな優れたインテリジェント機能が搭載されています。
Android MCP とクラウドベースの MCP の構造は類似しています。
どちらにもクライアントとサーバーがあります。
MCP の世界では、サーバーがツールを提供し、クライアントがこれらのツールを検出して呼び出すことができます。
Android MCP では、アプリがサーバー、エージェントがクライアントになります。
Android プラットフォームは、ツール レジストリの役割を果たします。
アプリは、サーバーを提供するサービスを登録することから始めます。ここでは、サンプルに AppFunctions Jetpack ライブラリの依存関係を追加することから始めます。
implementation(libs.androidx.appfunctions)
ksp(libs.androidx.appfunctions.compiler)次に、KSP アノテーションを使用して AppFunctionService を作成します。
このサービスにより、アプリの関数が Android のライフサイクルを適切に尊重できるようになります。
Hilt で依存関係インジェクションを使用すると、AppFunction で使用できるデータ リポジトリを挿入できます。
@RequiresApi(36)
@AndroidEntryPoint
@AppFunctionServiceEntryPoint(
serviceName = "JetPackerAppFunctionService",
appFunctionXmlFileName = "jetpacker_app_function_service"
)
abstract class BaseJetPackerAppFunctionService : AppFunctionService() {
@Inject internal lateinit var tripDao: TripDao
@Inject internal lateinit var expenseDao: ExpenseDao
...失礼。
図に戻りましょう。
Android MCP サーバー用にサービスを設定すると、アプリは AppFunctions の形式でツールを登録し、それらのツールで使用される関連データモデルも登録します。
これで、AppFunctionSerializable アノテーションを使用してデータモデルを登録できるようになりました。この KDoc は非常に優れています。
/** Represents an event in a trip itinerary. */
@AppFunctionSerializable(isDescribedByKDoc = true)
data class TimelineEventSerializable(
/** The event's unique identifier. */
val id: String,
/** The trip ID this event belongs to. */
val tripId: String,
/** The type of event (e.g., TRANSPORTATION, ACCOMMODATION, FOOD_AND_DRINK). */
val type: String,
/** The timestamp of the event. */
val timestamp: Long,
/** The title of the event. */
val title: String,
/** The location of the event. */
val location: String,
/** A description of the event. */
val description: String?,
/** The title of the trip this event belongs to. */
val tripTitle: String? = null,
/** Optional title of a matched itinerary event when added as a reminder. */
val matchedEventTitle: String? = null,
/** Execution status of the tool call (e.g., SUCCESS). */
val status: String? = "SUCCESS",
/** Human-readable completion message instructing the agent not to repeat the call. */
val message: String? = null,
)これは、エージェントがモデルの使用方法を理解するために使用するメタデータになります。
AppFunctionDeclaration アノテーションを使用して、関数をツールとして登録できます。
@AppFunctionDeclaration(isDescribedByKDoc = true)
suspend fun getItinerary(tripId: String? = null): List<TimelineEventSerializable> {
return withContext(Dispatchers.IO) {
val trip = resolveTrip(tripId = tripId)
val targetTripId = trip?.id ?: ActiveTripSession.activeTripId ?: tripId?.takeIf { it.isNotBlank() } ?: "2026-1"
val dbEvents = eventDao.getEventsForTrip(targetTripId).first()
val events =
if (dbEvents.isNotEmpty()) { dbEvents } else {
val fallback = DummyData.events.filter { it.tripId == targetTripId }
if (fallback.isNotEmpty()) { eventDao.insertEvents(fallback) }
fallback
}
events.map { it.toSerializable(tripTitle = trip?.title) }
}
}isDescribedByKDoc を true に設定すると、KDoc はエージェントに関数の動作を理解するための説明を提供します。これは、人間が理解するのにも役立ちます。
/**
* Retrieves the itinerary events (with their eventId, title, type, and location) for a specific
* trip. Note: getItinerary returns event summaries only; call getEventDetails(eventId) with a
* specific event's ID to retrieve ticket prices, hotel nightly rates, baggage allowance, or
* visitor tips.
*
* @param tripId Optional ID of the trip (e.g., '2026-1'). Defaults to the active trip if omitted.
* @return A list of events in the itinerary.
*/
@AppFunctionDeclaration(isDescribedByKDoc = true)
suspend fun getItinerary(tripId: String? = null): List<TimelineEventSerializable> {
return withContext(Dispatchers.IO) {
val trip = resolveTrip(tripId = tripId)
val targetTripId = trip?.id ?: ActiveTripSession.activeTripId ?: tripId?.takeIf { it.isNotBlank() } ?: "2026-1"
val dbEvents = eventDao.getEventsForTrip(targetTripId).first()
...パラメータ設計のベスト プラクティスをいくつかご紹介します。
/**
* @param tripId Optional ID of the trip (e.g., '2026-1').
* Defaults to the active trip if omitted.
* @return A list of events in the itinerary.
*/
@AppFunctionDeclaration(isDescribedByKDoc = true)
suspend fun getItinerary(tripId: String? = null): List<TimelineEventSerializable> {
return withContext(Dispatchers.IO) {
// resolveTrip defaults to the active trip if tripId == null
val trip = resolveTrip(tripId = tripId)まず、明確で一意の名前を持つ最小限のパラメータのサブセットに焦点を当てます。
必須でないパラメータを null 値許容にして、関数本体でデフォルト値を解決します。
これは単なるベスト プラクティスではありません。
AppFunctions ライブラリは、省略された null 許容パラメータに null を渡すため、Kotlin のデフォルト値は無視されます。
最初の例は示しませんが、キャッチオール パラメータは使用しないでください。
完全なコンテキストや会話履歴などは、不適切です。
エージェントが大量のデータを渡して、データの引き出しにつながる可能性があります。これは、単に不適切な方法です。
AppFunction はデフォルトで UI スレッドで実行されるため、適切なコルーチン ディスパッチャを選択して、長時間実行オペレーションがバックグラウンドで実行されるようにする必要があります。
@AppFunctionDeclaration(isDescribedByKDoc = true)
suspend fun getItinerary(tripId: String? = null): List<TimelineEventSerializable> {
return withContext(Dispatchers.IO) {
val trip = resolveTrip(tripId = tripId)
val targetTripId = trip?.id ?: ActiveTripSession.activeTripId ?:
tripId?.takeIf { it.isNotBlank() } ?: "2026-1"
val dbEvents = eventDao.getEventsForTrip(targetTripId).first()
val events =
if (dbEvents.isNotEmpty()) { dbEvents } else {
val fallback = DummyData.events.filter { it.tripId == targetTripId }
...必要に応じて、エージェントがエラーの解決方法を理解できるように、詳細な情報を含む AppFunction 例外をスローする必要があります。
@AppFunctionDeclaration(isDescribedByKDoc = true)
suspend fun send(
message: Message
): Result {
...
throw AppFunctionException(
errorCode = AppFunctionException.ErrorCode.ERROR_INVALID_ARGUMENT,
errorMessage = "To address has invalid syntax!"
)
}AppFunction ができたので、アーキテクチャ図に戻りましょう。
エージェント クライアント アプリは、AppFunction を呼び出すためにプラットフォームに登録します。
たとえば、Gemini などがこれに該当します。
そのためには、特権的な権限を保持する必要があります。登録されたエージェントは、Android プラットフォームから AppFunction メタデータを検索し、1 つ以上の登録済み AppFunction を実行してユーザーのリクエストを満たす必要があるかどうかを判断できます。
1 つ以上の AppFunction について、エージェントは実際にどのように判断するのでしょうか。
最後に、アプリのメタデータ ファイルでアプリの機能を記述する必要があります。
<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.
Constraints:
- Expenses must be associated with a valid trip ID."
appfn:displayDescription="@string/user_visible_description" />エージェントは、まず AppFunctionAppMetadata 内のこの説明を読み、次に個々の AppFunction の説明を実際に読んで、ツールを選択します。
そのため、説明の冒頭では、アプリやツールの主な領域を簡潔に説明することに重点を置く必要があります。
これに続いて、オペレーション パターンを記述することで、エージェントがオペレーションの論理フローを把握できるようにします。これは、前提条件の概要を説明したり、タスクを完了するための最適なパスにエージェントを誘導したりするのに役立ちます。
また、入力データ型の論理的な制限を定義したり、運用パターンを強化したりする制約に関するセクションを追加することもできます。
以上が Android MCP の基本ですが、他にもできることがあります。
AppFunctions を動的に有効または無効にできるのは、便利な機能です。
viewModelScope.launch {
val newState =
if (enabled) {
AppFunctionManager.APP_FUNCTION_STATE_ENABLED
} else {
AppFunctionManager.APP_FUNCTION_STATE_DISABLED
}
appFunctionManager?.setAppFunctionEnabled(id, newState)
}これは、たとえば、ユーザーがログインしている場合にのみ AppFunction を利用できるようにしたい場合に重要です。
また、アプリの特定の画面またはアクティビティをスコープとする AppFunction を作成することもできます。
@AppFunctionSignature(
scope = AppFunctionMetadata.SCOPE_ACTIVITY,
appFunctionXmlFileName = "cart_functions"
)
fun interface AddCurrentItemToCart {
/** Adds the item currently shown on screen to the cart. Returns the new cart size. */
suspend fun addToCart(quantity: Int): Int
}そのため、AppFunction を宣言する代わりに AppFunctionSignature を宣言します。
その後、UI で AppFunction のハンドラを実装できます。
val addToCartAppFunctionAdapter =
appFunctionManager.getHandleAppFunctionRequestAdapter<AddCurrentItemToCart>()
coroutineScope.launch {
appFunctionManager.handleAppFunction(
addToCartAppFunctionAdapter.adapt { quantity ->
val newCartSize = cart.addItem(quantity)
newCartSize
}
)
}この方法の優れた点は、ハンドラのライフサイクルが、使用するコルーチン スコープのライフサイクルに自動的に関連付けられることです。
次に、この AppFunctions リクエスト アダプター(AppFunctions リクエスト アダプターのハンドル)をキャッシュに保存します。ライブラリは、内部でリフレクションを使用してこのアダプターをインスタンス化するためです。
また、登録が Android のライフサイクルに直接関連付けられていない、スコープ付きの AppFunction を作成する機能もあります。
最後に、アクセスレベルというものがあります。
@AppFunctionDeclaration(isDescribedByKDoc = true)
@AppFunctionAccessLevel(
level = AppFunctionMetadata.ACCESS_LEVEL_SELF,
isCompatEnforcementEnabled = true,
)
suspend fun getItinerary(tripId: String? = null): List<TimelineEventSerializable> {
return withContext(Dispatchers.IO) {
...また、self、system、trusted のアクセスレベルを取得して使用できます。これにより、ML Kit GenAI で構築されたエージェントなど、独自のアプリ内エージェントでのみ使用できる関数、システムでのみ使用できる関数、信頼できるシステム エージェントで使用できる関数が示されます。
また、独自のアプリ内エージェントを構築できるように、ML Kit が統合ツール呼び出しフレームワークの一部として AppFunctions をネイティブにサポートしていることをすでに発表しました。
一度定義すれば、システムレベルのエージェントとローカルのアプリ内モデルの両方でカバーできるため、ツールの宣言が断片化するのを防ぐことができます。
詳細については、こちらの講演「ML Kit GenAI でアプリのエージェント機能を解放する」をご覧いただくか、Jetpacker の最新情報をご確認ください。
かなりいいですね。
サラの話に戻りましょう。
それでは
Dan が Android MCP の仕組みを説明しました。
実践してみましょう。
アプリはサーバーであり、エージェントはクライアントであることを忘れないでください。目標は、アプリがエージェントにとって優れたサーバーになるようにすることです。
旅行サンプルアプリである Jetpacker を使用して、最初のツール宣言から Gemini を介した実際の関数呼び出しまでを追っていきます。
AppFunctions は手動で作成することも、コーディング エージェントを使用して作成することもできます。
Google は、関数の作成を開始するのに役立つ AppFunctions スキルをリリースしました。このスキルは、Android CLI を介して Android Studio に接続することも、お好みのコーディング エージェントで使用することもできます。
android skills add appfunctionsこのスキルは 3 つのステップで構成されています。
まず、コードベースを分析して、メディアの再生、メモの作成、メッセージの送信など、便利なツールとなるコア ユーザー ジャーニーを特定します。
次に、それらのジャーニーを公開する AppFunction 宣言を、必要な実装とプラットフォーム サポートとともに生成します。
たとえば、Gradle と KSP、アプリ メタデータ XML、依存関係注入などです。
最後に、KDoc の説明を改良します。これは、エージェントが関数を呼び出すタイミングと方法を決定するために使用するものです。
こちらが、私たちが求めている結果のタイプです。
これは Jetpacker の addExpense 関数です。
/**
* Adds an expense to a trip.
*
* @param tripId ID of the trip. If null, the active trip is used.
* @param category Expense category. If null, it's inferred from the title.
*/
@AppFunctionDeclaration(isDescribedByKDoc = true)
suspend fun addExpense(
title: String,
amount: Double,
currency: String,
tripId: String? = null,
category: String? = null,
): ExpenseSerializable = withContext(Dispatchers.IO) {
if (amount <= 0.0) {
throw AppFunctionInvalidArgumentException("amount must be greater than zero")
}
val trip = resolveTrip(tripId)
?: throw AppFunctionElementNotFoundException("No trip with ID $tripId")
val expense = Expense(trip.id, title, amount, currency,
category = category ?: inferExpenseCategory(title))
expenseDao.insertExpense(expense)
expense.toSerializable(remainingBudgetUsd(trip.id))
}説明は、動詞とリソースで始まる 1 行の文です。この例では、旅行に費用を追加します。
trip フィールドと category フィールドは、ユーザーが常に指定するとは限らないため、null 値を許容しますが、関数は本文でそれらを解決します。
リクエストが成功しない場合は、AppFunctionException がスローされ、エージェントにエラーの内容が通知されます。
では、テストについて見ていきましょう。
関数は、エージェントが使用できるサービスになりました。
実際には、テストする必要がある部分は 2 つあります。関数の動作とエージェントの動作です。
関数については、おそらくすでに作成されている単体テストから始めますが、エージェントが実際に関数呼び出しを行う場合は、いくつか注意すべき点があります。
まず、Kotlin のデフォルト値はここでは適用されません。
省略されたパラメータは、null、false、または 0 として到着します。
そのため、以前はデフォルトで 10 に設定されていた上限が、実際には 0 になります。
また、呼び出しはメインスレッドで開始されます。テストから関数を呼び出すと、それが明確にならないことがあるため、標準の IO はバックグラウンド ディスパッチャで実行する必要があります。
失敗した場合は AppFunctionException をスローします。
それ以外は、未加工の例外メッセージが添付された一般的な不明なエラーとしてエージェントに届きます。
入力はモデルから取得されるため、空白、USD などのドルの同義語、範囲外の値などのエッジケース シナリオを必ずテストする必要があります。
エージェント側では、エージェントが適切なタイミングでツールを選択し、関数を正しく実行しているかどうかを確認する必要があります。
各エージェントの動作は少しずつ異なるため、関数を使用する予定のエージェントで関数をテストすることが重要です。
テストには、オープンソースのサンプル エージェントを使用するか、Gemini を使用してこの統合をテストできます。
Gemini と AppFunctions の連携を確認したい場合や、Gemini アプリで Android MCP を起動したい場合は、本日リリースされた Connected Apps Platform を使用できます。このプラットフォームは現在プレビュー版です。
詳しくは、Connected Apps Platform を通じてアプリを Gemini と Google 検索に拡張する方法に関する以前の講演をご覧ください。
プレビュー プログラムに参加すると、Gemini でテストするプロセスは 2 つのステップになります。
まず、Google Connected Console で、Gemini が実際に使用できる接続済みアプリを作成します。
次に、Google Play Console で App Bundle をアップロードして AppFunctions にアクセスできるようにし、内部テストトラックを開始して、テスターが実際に機能を検証できるようにします。
では、Google Connected Console から始めましょう。
ここでは、右上の [Create an app] ボタンを使用して、接続されているアプリを作成します。
接続アプリを作成するときに、この画面で [Android MCP] を選択します。
サーバーサイドの MCP 実装を構築する場合は、ここにサーバーコードをアップロードします。
[設定] をクリックすると、デベロッパー アカウントに関連付けられている Google Play アプリがここに表示され、接続済みアプリにリンクできるようになります。
Google Play アプリをまだ作成していない場合は、まず Google Play Console で作成する必要があります。
ここでは、すでに Jetpacker があるので、それをリンクします。
次に、ユーザーがアプリを操作しようとしたときに Gemini のフロントエンドに表示される詳細を追加します。
これには、アプリアイコン、表示名、接続済みアプリの機能、ユーザーが実際に操作するために使用できるプロンプトの例などが含まれます。
ユーザーを適切に誘導できるよう、わかりやすい説明にしてください。
これで、接続済みアプリの設定は完了です。しかし、接続済みアプリが実際にどのように機能にアクセスするのか、疑問に思われるかもしれません。
関数はアプリコード内に含まれているため、Google Play Console に移動して App Bundle をアップロードし、テストトラックを作成して、実際のユーザーでテストできるようにする必要があります。
この画面で App Bundle をアップロードすると、Google Play が AppFunctions を検出して、接続済みアプリに直接リンクできるようになります。
これで、Jetpack 接続アプリが完成し、関数が統合されました。
次に、テスターを追加する必要があります。これにより、実際に関数を検証して、正しく動作していることを確認する人が用意されます。
[アプリ連携のバージョン] をクリックします。
この画面で [テスターを管理] を選択し、接続済みアプリが利用可能になったときに Gemini でアクセスできるテスターの限定リストを設定できます。
このテストトラックにテスターを追加する方法は、他のリリースの場合と同じです。
接続されたアプリのテスターのリストが、Google Play アプリのテスターのリストと同じであることを確認します。
では、統合をテストしてみましょう。
これはユーザーのスマートフォンにインストールされた Jetpacker アプリです。このサミットの旅行がすでに記録されています。
Gemini を起動してアプリを接続します。
先ほど Google Play にアップロードしたアプリと同じバージョンがデバイスにインストールされていること、そして、テスターリストに追加されたアカウントで Gemini を使用していることを確認する必要があります。
ここでは、Jetpacker に言及して、マウンテンビューのホテルの部屋の確認番号を尋ねます。
Gemini はデバイス上の Jetpacker の AppFunctions を呼び出し、「Redwood Grove Hotel の RED11223」と回答します。
次に、「ADS の予算はいくら残っていますか?」と尋ねます。
関数呼び出しは $1850 を返します。
ここで、変更を加えたいと思います。Gemini に、ADS への出張に空港から Uber を利用する費用として 45 ドルを追加するよう依頼します。
Gemini は、先ほどの addExpense 関数を呼び出して、これを旅程に直接追加します。
Jetpacker を開くと、経費がすでに表示されており、それに応じて予算が減っています。
Gemini 向けの接続済みアプリの構築を今すぐ開始したい場合は、現在プレビュー版の Connected Apps Platform へのアクセスを申請してください。
それでは
それでは、まとめに入りましょう。
モバイル コンピューティングは大きな転換期を迎えており、アプリを対応させるべき時期が来ています。
まずは、アプリのコアとなる価値の高いユーザー ジャーニーを特定することから始めましょう。
サーバーサイド MCP を使用してクラウドのバックエンドを公開し、Android MCP を使用して、エンドツーエンドの暗号化データ、ローカル オペレーション、デバイス センサー、その他のオンデバイス機能を操作します。
プロトタイプを作成するには、Android Studio と Android CLI をダウンロードします。
AndroidX AppFunctions ベータ版の依存関係をビルドファイルに追加し、最初のオンデバイス関数をローカルでテストできます。
Gemini を本番環境に早期導入するための登録は、こちらの QR コードから行えます。
また、Connected Apps Platform の使用に関する関連トークや、ML Kit または独自のエージェントで内部 AppFunctions を使用して独自のアプリ内エージェントを駆動する方法についてもご確認ください。
最後になりますが、本日はお時間をいただきありがとうございました。
皆様が Android 向けに素晴らしいエクスペリエンスを構築されることを楽しみにしています。
implementation(libs.androidx.appfunctions)
ksp(libs.androidx.appfunctions.compiler)@RequiresApi(36)
@AndroidEntryPoint
@AppFunctionServiceEntryPoint(
serviceName = "JetPackerAppFunctionService",
appFunctionXmlFileName = "jetpacker_app_function_service"
)
abstract class BaseJetPackerAppFunctionService : AppFunctionService() {
@Inject internal lateinit var tripDao: TripDao
@Inject internal lateinit var expenseDao: ExpenseDao
.../** Represents an event in a trip itinerary. */
@AppFunctionSerializable(isDescribedByKDoc = true)
data class TimelineEventSerializable(
/** The event's unique identifier. */
val id: String,
/** The trip ID this event belongs to. */
val tripId: String,
/** The type of event (e.g., TRANSPORTATION, ACCOMMODATION, FOOD_AND_DRINK). */
val type: String,
/** The timestamp of the event. */
val timestamp: Long,
/** The title of the event. */
val title: String,
/** The location of the event. */
val location: String,
/** A description of the event. */
val description: String?,
/** The title of the trip this event belongs to. */
val tripTitle: String? = null,
/** Optional title of a matched itinerary event when added as a reminder. */
val matchedEventTitle: String? = null,
/** Execution status of the tool call (e.g., SUCCESS). */
val status: String? = "SUCCESS",
/** Human-readable completion message instructing the agent not to repeat the call. */
val message: String? = null,
)@AppFunctionDeclaration(isDescribedByKDoc = true)
suspend fun getItinerary(tripId: String? = null): List<TimelineEventSerializable> {
return withContext(Dispatchers.IO) {
val trip = resolveTrip(tripId = tripId)
val targetTripId = trip?.id ?: ActiveTripSession.activeTripId ?: tripId?.takeIf { it.isNotBlank() } ?: "2026-1"
val dbEvents = eventDao.getEventsForTrip(targetTripId).first()
val events =
if (dbEvents.isNotEmpty()) { dbEvents } else {
val fallback = DummyData.events.filter { it.tripId == targetTripId }
if (fallback.isNotEmpty()) { eventDao.insertEvents(fallback) }
fallback
}
events.map { it.toSerializable(tripTitle = trip?.title) }
}
}/**
* Retrieves the itinerary events (with their eventId, title, type, and location) for a specific
* trip. Note: getItinerary returns event summaries only; call getEventDetails(eventId) with a
* specific event's ID to retrieve ticket prices, hotel nightly rates, baggage allowance, or
* visitor tips.
*
* @param tripId Optional ID of the trip (e.g., '2026-1'). Defaults to the active trip if omitted.
* @return A list of events in the itinerary.
*/
@AppFunctionDeclaration(isDescribedByKDoc = true)
suspend fun getItinerary(tripId: String? = null): List<TimelineEventSerializable> {
return withContext(Dispatchers.IO) {
val trip = resolveTrip(tripId = tripId)
val targetTripId = trip?.id ?: ActiveTripSession.activeTripId ?: tripId?.takeIf { it.isNotBlank() } ?: "2026-1"
val dbEvents = eventDao.getEventsForTrip(targetTripId).first()
.../**
* @param tripId Optional ID of the trip (e.g., '2026-1').
* Defaults to the active trip if omitted.
* @return A list of events in the itinerary.
*/
@AppFunctionDeclaration(isDescribedByKDoc = true)
suspend fun getItinerary(tripId: String? = null): List<TimelineEventSerializable> {
return withContext(Dispatchers.IO) {
// resolveTrip defaults to the active trip if tripId == null
val trip = resolveTrip(tripId = tripId)@AppFunctionDeclaration(isDescribedByKDoc = true)
suspend fun getItinerary(tripId: String? = null): List<TimelineEventSerializable> {
return withContext(Dispatchers.IO) {
val trip = resolveTrip(tripId = tripId)
val targetTripId = trip?.id ?: ActiveTripSession.activeTripId ?:
tripId?.takeIf { it.isNotBlank() } ?: "2026-1"
val dbEvents = eventDao.getEventsForTrip(targetTripId).first()
val events =
if (dbEvents.isNotEmpty()) { dbEvents } else {
val fallback = DummyData.events.filter { it.tripId == targetTripId }
...@AppFunctionDeclaration(isDescribedByKDoc = true)
suspend fun send(
message: Message
): Result {
...
throw AppFunctionException(
errorCode = AppFunctionException.ErrorCode.ERROR_INVALID_ARGUMENT,
errorMessage = "To address has invalid syntax!"
)
}<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.
Constraints:
- Expenses must be associated with a valid trip ID."
appfn:displayDescription="@string/user_visible_description" />viewModelScope.launch {
val newState =
if (enabled) {
AppFunctionManager.APP_FUNCTION_STATE_ENABLED
} else {
AppFunctionManager.APP_FUNCTION_STATE_DISABLED
}
appFunctionManager?.setAppFunctionEnabled(id, newState)
}@AppFunctionSignature(
scope = AppFunctionMetadata.SCOPE_ACTIVITY,
appFunctionXmlFileName = "cart_functions"
)
fun interface AddCurrentItemToCart {
/** Adds the item currently shown on screen to the cart. Returns the new cart size. */
suspend fun addToCart(quantity: Int): Int
}val addToCartAppFunctionAdapter =
appFunctionManager.getHandleAppFunctionRequestAdapter<AddCurrentItemToCart>()
coroutineScope.launch {
appFunctionManager.handleAppFunction(
addToCartAppFunctionAdapter.adapt { quantity ->
val newCartSize = cart.addItem(quantity)
newCartSize
}
)
}@AppFunctionDeclaration(isDescribedByKDoc = true)
@AppFunctionAccessLevel(
level = AppFunctionMetadata.ACCESS_LEVEL_SELF,
isCompatEnforcementEnabled = true,
)
suspend fun getItinerary(tripId: String? = null): List<TimelineEventSerializable> {
return withContext(Dispatchers.IO) {
...android skills add appfunctions/**
* Adds an expense to a trip.
*
* @param tripId ID of the trip. If null, the active trip is used.
* @param category Expense category. If null, it's inferred from the title.
*/
@AppFunctionDeclaration(isDescribedByKDoc = true)
suspend fun addExpense(
title: String,
amount: Double,
currency: String,
tripId: String? = null,
category: String? = null,
): ExpenseSerializable = withContext(Dispatchers.IO) {
if (amount <= 0.0) {
throw AppFunctionInvalidArgumentException("amount must be greater than zero")
}
val trip = resolveTrip(tripId)
?: throw AppFunctionElementNotFoundException("No trip with ID $tripId")
val expense = Expense(trip.id, title, amount, currency,
category = category ?: inferExpenseCategory(title))
expenseDao.insertExpense(expense)
expense.toSerializable(remainingBudgetUsd(trip.id))
}