ラベル Android の投稿を表示しています。 すべての投稿を表示
ラベル Android の投稿を表示しています。 すべての投稿を表示

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) ) } }

2024年6月23日日曜日

Kotlinらしいコードを書こう - Convert Java File to Kotlin File のあとにやること

KotlinFest 2024 で話すはずだった講演内容です。







みなさん、こんにちは。あんざいゆきです。Android の Google Developer Expert をしています。よろしくお願いします。

私はいろんなクライアントさんの Android アプリ開発のお手伝いをさせていただいていまして、Java から Kotlin に変換した Pull Request のレビューをすることがあります。

プロジェクトの大多数がまだ Java だったり、最近 Android 開発をはじめたばかりだったりして Kotlin になじみがない場合だと、自動変換されただけのような状態でレビュー依頼されることがままあります。

そこでこのセッションでは、Java から Kotlin に自動変換したあと、より Kotlin らしいコードにするためにどういうことをしてほしいのかを紹介したいと思います。
Kotlin らしいコードの話をする前に、Java から Kotlin に変換する Pull Request について話したいと思います。
1 commit で Kotlin 化すると、Java ファイルの削除と Kotlin のファイルの新規追加の履歴になり、Kotlin 化前後のコードを比較するのがけっこう大変です。
そこで、Kotlin 化する前に拡張子を .java から .kt に rename する commit を入れておきます。中身は Java のままです。
commit したら拡張子をまた .kt から .java に戻し、その後に Convert Java File to Kotlin File などで Kotlin 化します。
こうした場合、rename と Kotlin 化の 2つの commit の Pull Request になります。
Files changed タブだと 1 commit で Kotlin 化したときと同じように Kotlin 化前後のコードを比較するのが大変なんですが、
Commits タグを開いて
2つめの commit をみると
同じ .kt ファイルの変更なので、Kotlin 化前後のコードが比較しやすい表示になります。
では本題に入りましょう。
残念ながら Java コード側の情報が少ないと変換したコードに !! が出てくることがあります。
!! が不要になるようにコードを修正しましょう。
この例では引数の newItems を non-null にすれば !! は不要になります。
@NonNull アノテーションがついていない場合、@Nullable なメソッドに渡されている変数は nullable だと解釈されます。
例えば Bundle の putString() メソッドは key も value も @Nullable アノテーションがついています。
そのため Kotlin に自動変換すると、createInstance() メソッドの title 引数は nullable String になります。
もともと null が来ることを想定しているならこのままでよいですが、
null が来ることがありえない、あってはいけないという場合はそれを表現するよう non-null にしましょう。
型パラメータも自動変換時に nullable と判定されることがあるので注意しましょう。
初期化の後に利用されることが保証できる場合(初期化前アクセスが発生するのはコーディングエラー時のみという場合)、
lateinit var を使うことで non-null にすることができます。
var を val にできないか考えましょう。
Kotlin の標準ライブラリに用意されている関数を利用することで val にできることがよくあります。
様々な便利関数があるので、変換されたやつでいいやってなる前に、活用できるものがないか調べましょう。
?.let や ?: を使ってより簡潔に記述できるようにならないか考えましょう。
apply や also は初期化処理をまとめるのによく使います。
Kotlin では使っていない lambda の引数を _ にすることができます。
自動変換ではやってくれませんが、変換後のコードでグレーの波線が出るので対応しましょう。
A から B に変換する処理は Aの拡張関数にすると呼び出し側がすっきりします。
自動変換では Smart cast が効いている部分の cast を外してくれないので自分で外しましょう。
また、as? を使うことでチェックと呼び出し部分を1行で書くこともできます。
Java の switch 文は自動で when にしてくれますが、if else の連続は自動で when にしてくれません。
必要に応じて自分で when にしましょう。
Java から Kotlin の lambda を引数にとるメソッドを呼んでいる部分があるとします。
これを Kotlin 化した場合、関数参照を使ったほうが記述が簡潔になる場合があります。
List を操作する Java コードを Kotlin に自動変換した場合、MutableList を使ったものになります。
Kotlin std lib に用意されているメソッドを使うと MutableList を不要にできることがあります。
Java では Mutable Collection と Immutable Collection で型が分かれていないので、自動変換すると mutable として外部に公開するべきでないところでも mutable として公開されてしまいます。
Mutable Collection の変数は private にし、公開用に Immutable Collection 型のプロパティを定義するようにします。
Java で ArrayList を new しているところは自動変換しても同じです。
mutableListOf() を使ってより Kotlin らしいコードにしましょう。
同様に HashMap, LinkedHashMap には mutableMapOf(), HashSet, LinkedHashSet には mutableSetOf() が使えます。
List や Map を構成する部分は、自動変換ではほぼそのままの形にしかなりません。
初期化時のみ List や Map を編集するのであれば buildList { } や buildMap { } を使って Mutable Collection の変数が定義されないようにしましょう。
Android 特有の内容も紹介します。
Bundle の生成用に bundleOf() メソッドが用意されています。
TextUtils.isEmpty() は Kotlin の isNullOrEmpty() に置き換えましょう。
TextUtils.equals() は Kotlin の == に置き換えましょう。
Kotlin では、Activity や Fragment で ViewModel のインスタンスを取得するのに by viewModels() を利用することができます。
DI で値がセットされるフィールドは Java から Kotlin に自動変換すると nullable の var になってしまいます。
しかしこれらは参照されるときには値がすでにセットされていることが期待されるものなので、lateinit var に変えましょう。
最後にチェックリストをまとめました。ありがとうございました。