2025年12月4日木曜日

adk-java を Android アプリに組み込むための長い道のり

この記事は JP_Google Developer Experts Advent Calendar 2025 の4日目の記事です。

adk-java ライブラリをアプリに追加する


adk-java を Android アプリに入れるのなんて簡単でしょ。 https://google.github.io/adk-docs/#java に書いてある通りに build.gradle.kts に dependencies { implementation("com.google.adk:google-adk:0.4.0") } を書けばいいんでしょ。 そう思っていました....


Android Studio の [New Project...] からまっさらなプロジェクトを作って(もちろんこの時点ではビルドできるしアプリも実行できる)、上の依存を一行追加して Sync してビルドすると....

はい、エラー...

Duplicate class io.modelcontextprotocol.json.McpJsonInternal found in modules mcp-core-0.14.0.jar -> mcp-core-0.14.0 (io.modelcontextprotocol.sdk:mcp-core:0.14.0) and mcp-json-0.14.0.jar -> mcp-json-0.14.0 (io.modelcontextprotocol.sdk:mcp-json:0.14.0) ...


同じクラスファイル(io.modelcontextprotocol.json.McpJsonInternal)が異なる2つのライブラリ/モジュール(mcp-coreとmcp-json)から重複して含まれてますね...
ということで片方を exclude します。全部で3のライブラリで重複があるようです。 dependencies { implementation("com.google.adk:google-adk:0.4.0") { exclude(group = "io.modelcontextprotocol.sdk", module = "mcp-json") exclude(group = "javax.annotation", module = "javax.annotation-api") exclude(group = "org.slf4j", module = "jcl-over-slf4j") } ... }

これでうまくいくかと思いきや、またしてもエラー

Invalid build configuration. Attempt to create a global synthetic for 'Record desugaring' without a global-synthetics consumer.


adk-java の中で Java 16 以降で導入された Record クラスを使用しているが、それを古い Android バージョンでも動作させるための処理(Desugaring: デシュガーリング)が設定されてないと言われていますね...

一番簡単な解決策はプロジェクトの Javaの互換性バージョンを Java 17 に統一することなのでそうします。 kotlin { jvmToolchain(17) } android { ... compileOptions { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } }

今度こそビルド通るかと思いきや、またしてもエラー

MethodHandle.invoke and MethodHandle.invokeExact are only supported starting with Android O (--min-api 26): Lautovalue/shaded/com/google/common/hash/ChecksumHashFunction$ChecksumMethodHandles;->updateByteBuffer(Ljava/util/zip/Checksum;Ljava/nio/ByteBuffer;)Z


MethodHandle.invoke と MethodHandle.invokeExact は Android O 以降じゃないとサポートしてないと言われています...

ということで minSdk バージョンを 26 にします。 android { ... defaultConfig { ... minSdk = 26 ... } ... }

4度目...さすがにビルド通ってほしいが、まだエラー

Execution failed for task ':app:mergeDebugJavaResource'.
> A failure occurred while executing com.android.build.gradle.internal.tasks.MergeJavaResWorkAction
> 2 files found with path 'mozilla/public-suffix-list.txt' from inputs:
- org.apache.httpcomponents:httpclient:4.5.14/httpclient-4.5.14.jar
- org.apache.httpcomponents.client5:httpclient5:5.3.1/httpclient5-5.3.1.jar



異なる2つのライブラリに、全く同じパスとファイル名を持つリソースファイルが存在するという典型的なエラーです。
競合しているファイル(このエラーだと mozilla/public-suffix-list.txt)をビルドに含めないように設定します。
一つ対応しても同じエラーが別のファイルに対して何回も出てくるので、全部 excludes に追加します。 android { ... packaging { resources { excludes += "mozilla/public-suffix-list.txt" excludes += "META-INF/DEPENDENCIES" excludes += "META-INF/LICENSE.md" excludes += "META-INF/NOTICE.md" excludes += "META-INF/io.netty.versions.properties" excludes += "META-INF/INDEX.LIST" } } }

これでようやくビルドが通りました!エラーが多い...

都市の時間を返すエージェントを作ってみる


ようやくビルドが通ったので Quickstart (https://google.github.io/adk-docs/get-started/java/) の HelloTimeAgent を作ってみました。

が、これもハードルが高かった〜

API Key どう渡す?


まず困ったのが API key の渡し方。公式ドキュメントには

echo 'export GOOGLE_API_KEY="YOUR_API_KEY"' > .env

しろとしか書いてない。しかし Android は環境変数は使えません。

そこで、API key がセットされなくてクラッシュするところから呼び出し元をたどっていくと、LlmRegistry にデフォルト登録される LlmFactory を利用していることがわかりました。 public final class LlmRegistry { ... /** Map of model name patterns regex to factories. */ private static final Map<String, LlmFactory> llmFactories = new ConcurrentHashMap<>(); /** Registers default LLM factories, e.g. for Gemini models. */ static { registerLlm("gemini-.*", modelName -> Gemini.builder().modelName(modelName).build()); registerLlm("apigee/.*", modelName -> ApigeeLlm.builder().modelName(modelName).build()); } この LlmRegistry に登録されている "gemini-.*" に対応する LlmFactory を、API key をセットした Gemini に置き換えればいけそうだとわかりました。 object HelloTimeAgent { init { LlmRegistry.registerLlm("gemini-.*") { modelName -> Gemini.builder() .modelName(modelName) .apiKey("MY API KEY HERE") .build() } } ... } これで API key がセットされてなくてクラッシュすることはなくなりました。

tool 名の statec method がないと言われる


Tool はリフレクションで呼ばれていて、Java の static メソッドになっている必要がありました。 public class FunctionTool extends BaseTool { ... public static FunctionTool create(Class<?> cls, String methodName, boolean requireConfirmation) { for (Method method : cls.getMethods()) { if (method.getName().equals(methodName) && Modifier.isStatic(method.getModifiers())) { return create(null, method, requireConfirmation); } } throw new IllegalArgumentException( String.format("Static method %s not found in class %s.", methodName, cls.getName())); } Java の static メソッドになっていないとこんなエラーでクラッシュします。

Caused by: java.lang.IllegalArgumentException: Static method getCurrentTime not found in class com.example.myapplication.HelloTimeAgent.

Kotlin の object のメソッドを Java の static メソッドにするには @JvmStatic をつけます。 object HelloTimeAgent { ... @Annotations.Schema(description = "Get the current time for a given city") @JvmStatic fun getCurrentTime( @Annotations.Schema( name = "timeZone", description = "timeZone of the city to get the time for" ) timeZone: String ): ... ... }

tool の戻り値は Map じゃないとだめ?


以下のように時刻の文字列だけ返せばいいかなと思ったのですが、これではうまく行きませんでした。 object HelloTimeAgent { ... @Annotations.Schema(description = "Get the current time for a given city") @JvmStatic fun getCurrentTime( ... ): String { val zoneId = ZoneId.of(timeZone) val zonedDateTime = ZonedDateTime.now(zoneId) val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss") return zonedDateTime.format(formatter) } } このように Map で返すとうまくいきました。ちなみに timeZone は Map に含めなくても問題ありませんでした。 object HelloTimeAgent { ... @Annotations.Schema(description = "Get the current time for a given city") @JvmStatic fun getCurrentTime( ... ): Map<String, String> { val zoneId = ZoneId.of(timeZone) val zonedDateTime = ZonedDateTime.now(zoneId) val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss") return mapOf( "timeZone" to timeZone, // なくてもよい "forecast" to zonedDateTime.format(formatter) ) } }

うごいたーーー



全体


全体のコードはこんな感じです。 class MainActivity : ComponentActivity() { private val viewModel by viewModels<MainViewModel>() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() setContent { MyApplicationTheme { ChatScreen( messages = viewModel.messages, loading = viewModel.loading, onClickSubmit = { viewModel.submit(it) } ) } } } } @Composable fun ChatScreen( messages: List<MainViewModel.Message>, loading: Boolean, onClickSubmit: (String) -> Unit, ) { Scaffold { innerPadding -> Column( modifier = Modifier .fillMaxSize() .padding(innerPadding) ) { LazyColumn(modifier = Modifier.weight(1f)) { items(messages) { when (it.from) { MainViewModel.From.User -> { Row { Spacer(Modifier.weight(1f)) Box( Modifier .weight(4f) .padding(8.dp) .background( MaterialTheme.colorScheme.surfaceVariant, RoundedCornerShape(8.dp) ) .padding(8.dp) ) { Text(text = it.text) } } } MainViewModel.From.Agent -> { Row { Box( Modifier .weight(4f) .padding(8.dp) .background( MaterialTheme.colorScheme.surfaceVariant, RoundedCornerShape(8.dp) ) .padding(8.dp) ) { Text(text = it.text) } Spacer(Modifier.weight(1f)) } } } } if (loading) { item("loading") { CircularProgressIndicator( modifier = Modifier .fillMaxWidth() .wrapContentWidth() ) } } } Divider() Row( horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(8.dp) ) { val state = rememberTextFieldState() TextField( state = state, lineLimits = TextFieldLineLimits.SingleLine, enabled = !loading, modifier = Modifier.weight(1f), ) Button( onClick = { onClickSubmit(state.text.toString()) state.clearText() }, enabled = !loading, ) { Text(text = "送信") } } } } } @Preview(showBackground = true) @Composable private fun Preview() { MyApplicationTheme { ChatScreen( messages = listOf( MainViewModel.Message(from = MainViewModel.From.User, text = "東京の時間は?"), MainViewModel.Message(from = MainViewModel.From.Agent, text = "9時です"), ), loading = false, onClickSubmit = {} ) } } class MainViewModel() : ViewModel() { private val runConfig = RunConfig.builder().build() private val runner = InMemoryRunner(HelloTimeAgent.initAgent()) private val session: Session = runner .sessionService() .createSession(runner.appName(), "user1234") .blockingGet() var messages = mutableStateListOf<Message>() var loading by mutableStateOf(false) fun submit(userInput: String) { if (loading) { return } messages.add(Message(from = From.User, text = userInput)) loading = true viewModelScope.launch { val event = withContext(Dispatchers.IO) { val userMsg = Content.fromParts(Part.fromText(userInput)) val events = runner.runAsync(session.userId(), session.id(), userMsg, runConfig) events.filter { it.finalResponse() }.blockingFirst() } messages.add(Message(from = From.Agent, text = event.stringifyContent())) loading = false } } data class Message(val from: From, val text: String) enum class From { User, Agent } } object HelloTimeAgent { init { LlmRegistry.registerLlm("gemini-.*") { modelName -> Gemini.builder() .modelName(modelName) .apiKey("API KEY HERE") .build() } } fun initAgent(): BaseAgent? { return LlmAgent.builder() .name("hello-time-agent") .description("Tells the current time in a specified city") .instruction( """ You are a helpful assistant that tells the current time in a city. Use the 'getCurrentTime' tool for this purpose. """ ) .model("gemini-2.5-flash") .tools(FunctionTool.create(HelloTimeAgent::class.java, "getCurrentTime")) .build(); } @Annotations.Schema(description = "Get the current time for a given city") @JvmStatic fun getCurrentTime( @Annotations.Schema( name = "timeZone", description = "timeZone of the city to get the time for" ) timeZone: String ): Map<String, String> { val zoneId = ZoneId.of(timeZone) val zonedDateTime = ZonedDateTime.now(zoneId) val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss") return mapOf( "timeZone" to timeZone, "forecast" to zonedDateTime.format(formatter) ) } }