たくさんの自由帳
Androidのお話
たくさんの自由帳
投稿日 : | 0 日前
文字数(だいたい) : 11977
目次
本題
Compose Multiplatform
環境
Compose Multiplatform プラグイン
プロジェクトを作る
Android Studio で開く
フォルダ構成
とりあえず実行してみたい
必要なライブラリを入れる
EdgeToEdge 出来てない
API を叩くのに使う AWS Sigv4 署名を作る関数
インターネット権限
S3 の CORS 設定
S3 API クライアントを作る
一覧画面を作ってみる
画像を投稿してみる
インターフェースを作る
Android 側
Web側
画像を選ぶボタン
S3 に投げる
削除ボタンをつくる
配布する
おまけ 日本語表示
おまけ マルチプラットフォーム環境の API リクエストと CloudFront のキャッシュ
ソースコード
分かったこと
おわりに
おわりに2
おわりに3
どうもこんばんわ。
流る星 -a Wish Star- 攻略しました。ソフマップのかべに貼ってあったやつ!!
かわいい。むずかしい話とかはなかったのでおすすめ!!!
ロープラでおてがる
めっちゃモダンなカミサマだった。
表情がいっぱい。おかげさまでスクショが埋まった。
きらきらしてるやつすき
後日談にえちえちシーンがあります!!!
本編よりこっちが本編なのでは!?!?長く感じた。うれしい
えちえちしーん良かった!!!
意地悪でも言ってくれるカミサマ、、
前回S3+Lambdaで画像を小さくするやつを作った。リサイズが面倒だったのとUltraHDR画像をここに貼り付けたかったんだよな。
で、で、で
現状はコンソールにログインして、S3バケットの画面を開き、そこに写真を放り込んでいますが、ちょい面倒。。。
専用クライアントアプリ作りたいなあ。
そっから画像をアップロードしてしばらく待ってれば、変換後のURLがコピーできるみたいな、そーゆーの作りたいなあ。
パソコンと、Androidから投稿したかったので、当初はReactでペライチアプリでも作るか~って思ってました。が、
そう言えばCompose Multiplatformって使ったことないじゃん私、Jetpack ComposeがAndroidだけじゃなくてWebとかでも動くらしい。
ブラウザ(パソコン)とAndroidでクライアント作りたいのでこれでいいやんって。
てか私一人しか使わないので動けば良い。
Web だと SEO とかなんとかあって SSR/SSG を選ぶ必要あるけど、今回は自分だけだし。
今回はAndroidとWebをターゲットにします。iOSはmac持ってない。ので。。Webは<canvas>に描画されます。
| なまえ | あたい |
|---|---|
| Android Studio | Android Studio Meerkat Feature Drop 2024.3.2 |
Android Studioにプラグインを入れたほうが良いらしい(?)
本当に必要なのかが分からない
項目を埋めますProject IDですが、Javaの文化なのかなんなのかわかりませんが、持っているドメインを逆さまにして、最後にアプリケーション名を入れる文化があります。
ドメインなかった頃はGitHub Pages使ってて未だにそれやってる。
出来たらDOWNLOAD、お好きな場所で解凍してください。
解凍したものをAndroid Studioで開きます。
そしたらしばらく待ちます。
ライブラリのダウンロードなりがあるので。。
ファイルツリーの表示はここから変更できます。
慣れない場合はここから変更できます。
デフォルト状態では、commonMainモジュールにあるComposable App()関数を、
それぞれのプラットフォーム(androidMain、wasmJsMain)の各エントリーポイント(AndroidならMainActivity、Webならmain.kt)で呼び出してる感じですね。
また、プラットフォーム固有処理はPlatform.ktにインターフェイスで定義があって、interface Platformを各プラットフォームで実装したものをactualで返す感じみたいです。
よく見るとバージョンカタログ等使われてるので、イケイケandroidアプリ開発の知見が必要そう、Gradle何もわからない。
Androidの場合はcomposeAppを選んで端末を繋げば実行ボタンが押せます。
Webの場合はGradleのコマンドパレットから、以下のコマンドを叩くと、ローカルサーバーが立ち上がります。
gradle :composeApp:wasmJsBrowserDevelopmentRunまず手始めにMaterial3じゃないので、Material3を使うようにライブラリを差し替えます。composeApp/build.gradle.ktsでそれぞれのプラットフォームで必要なライブラリを定義できます。
あとは、S3のREST APIを叩くためのライブラリ群です。androidMainにもHTTP Client都合で必要です。
androidMain.dependencies {
// 省略...
// Ktor Android Impl
implementation("io.ktor:ktor-client-okhttp:3.1.2")
}
commonMain.dependencies {
// 省略...
// HTTP Client
implementation("io.ktor:ktor-client-core:3.1.2")
// calc Hash
implementation(platform("org.kotlincrypto.hash:bom:0.7.0"))
implementation("org.kotlincrypto.hash:sha2")
// calc Hmac-Hadh
implementation(platform("org.kotlincrypto.macs:bom:0.7.0"))
implementation("org.kotlincrypto.macs:hmac-sha2")
// kotlinx.datetime
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.6.2")
}あとmaterial3用にimport直してね。
App()の中身をScaffold { }で囲むのと、MainActivityでenableEdgeToEdge()を呼び出す必要があります。
というわけで全部消しますか。
App.kt
@Composable
@Preview
fun App() {
var showContent by remember { mutableStateOf(false) }
MaterialTheme {
Scaffold { innerPadding ->
Column(
modifier = Modifier.padding(innerPadding).fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Button(onClick = { showContent = !showContent }) {
Text("Click me!")
}
AnimatedVisibility(showContent) {
val greeting = remember { Greeting().greet() }
Column(Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
Image(painterResource(Res.drawable.compose_multiplatform), null)
Text("Compose: $greeting")
}
}
}
}
}
}MainActivity
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
App()
}
}
}これでEdgeToEdgeできました。
AWSがKotlin CDKを出してくれてますが、JVM/Android用で、Kotlin/Wasmでは使えません、、
というわけでAPIを直接叩くことにしたのですが、叩くためにはAWS Sigv4 署名が必要で、結構複雑。
Compose Multiplatformで実装できます。
AWS Signature V4 を Kotlin Multiplatform で作る - たくさんの自由帳
https://takusan.negitoro.dev/posts/kotlin_multiplatform_create_aws_sign_v4_without_aws_cdk/
commonMainにAwsSignV4.ktを作ってこんな感じ。
/** ISO8601 */
@OptIn(FormatStringsInDatetimeFormats::class)
private val amzDateFormat = DateTimeComponents.Format {
byUnicodePattern("""yyyyMMdd'T'HHmmss'Z'""")
}
/** yyyyMMdd */
@OptIn(FormatStringsInDatetimeFormats::class)
private val yearMonthDayDateFormat = DateTimeComponents.Format {
byUnicodePattern("yyyyMMdd")
}
/** AWS Sigv4 署名を作る */
@OptIn(ExperimentalStdlibApi::class)
internal fun generateAwsSign(
url: String,
httpMethod: String = "GET",
contentType: String? = null,
region: String = "ap-northeast-1",
service: String = "s3",
amzDateString: String,
yyyyMMddString: String,
secretAccessKey: String,
accessKey: String,
requestHeader: HashMap<String, String> = hashMapOf(),
payloadSha256: String = "".sha256().toHexString()
): String {
val httpUrl = Url(urlString = url)
// リクエストヘッダーになければ追加
requestHeader.putIfAbsent("x-amz-date", amzDateString)
requestHeader.putIfAbsent("host", httpUrl.host)
if (contentType != null) {
requestHeader.putIfAbsent("Content-Type", contentType)
}
requestHeader.putIfAbsent("x-amz-content-sha256", payloadSha256)
// 1.正規リクエストを作成する
// パス、クエリパラメータは URL エンコードする
// リスト系はアルファベットでソート
val canonicalUri = httpUrl.encodedPath.encodeURLPath().ifBlank { "/" }
val canonicalQueryString = httpUrl.parameters
.names()
.map { it.encodeURLParameter() }
.sortedBy { name -> name }
.associateWith { name -> httpUrl.parameters[name]?.encodeURLParameter() }
.toList()
.joinToString(separator = "&") { (name, values) ->
"$name=${values ?: ""}" // こっちはイコール
}
val canonicalHeaders = requestHeader
.toList()
.sortedBy { (name, _) -> name.lowercase() }
.joinToString(separator = "\n") { (name, value) ->
"${name.lowercase()}:${value.trim()}"
} + "\n" // 末尾改行で終わる
val signedHeaders = requestHeader
.toList()
.map { (name, _) -> name.lowercase() }
.sorted()
.joinToString(separator = ";")
val hashedPayload = payloadSha256.lowercase()
val canonicalRequest = httpMethod + "\n" + canonicalUri + "\n" + canonicalQueryString + "\n" + canonicalHeaders + "\n" + signedHeaders + "\n" + hashedPayload
// 2.正規リクエストのハッシュを作成する。ペイロードと同じハッシュ関数
val hashedCanonicalRequest = canonicalRequest.sha256().toHexString()
// 3.署名文字列を作成する
val algorithm = "AWS4-HMAC-SHA256"
val requestDateTime = amzDateString
val credentialScope = "$yyyyMMddString/$region/$service/aws4_request"
val stringToSign = algorithm + "\n" + requestDateTime + "\n" + credentialScope + "\n" + hashedCanonicalRequest
// 4.SigV4 の署名キーの取得
val dateKey = "AWS4$secretAccessKey".toByteArray(Charsets.UTF_8).hmacSha256(message = yyyyMMddString)
val dateRegionKey = dateKey.hmacSha256(message = region)
val dateRegionServiceKey = dateRegionKey.hmacSha256(message = service)
val signingKey = dateRegionServiceKey.hmacSha256(message = "aws4_request")
// 5.署名を計算する
val signature = signingKey.hmacSha256(message = stringToSign).toHexString().lowercase()
// 6.リクエストヘッダーに署名を追加する
val authorizationHeaderValue = algorithm + " " + "Credential=$accessKey/$credentialScope" + "," + "SignedHeaders=$signedHeaders" + "," + "Signature=$signature"
return authorizationHeaderValue
}
/** ISO8601 の形式でフォーマットする */
internal fun Instant.formatAmzDateString(): String {
return this.format(amzDateFormat)
}
/** yyyy/MM/dd の形式でフォーマットする */
internal fun Instant.formatYearMonthDayDateString(): String {
return this.format(yearMonthDayDateFormat)
}
/** バイト配列から SHA-256 */
internal fun ByteArray.sha256(): ByteArray {
return SHA256().digest(this)
}
/** Java の putIfAbsent 相当 */
private fun <K, V> HashMap<K, V>.putIfAbsent(key: K, value: V): V? {
var v = this.get(key)
if (v == null) {
v = put(key, value)
}
return v
}
/** 文字列から SHA-256 */
private fun String.sha256(): ByteArray {
return this.toByteArray(Charsets.UTF_8).sha256()
}
/**
* HMAC-SHA256 を計算
* this がキーです
*/
private fun ByteArray.hmacSha256(message: String): ByteArray {
val secretKey = this
return HmacSHA256(secretKey).doFinal(message.toByteArray(Charsets.UTF_8))
}AndroidManifest.xmlでインターネット権限を足します。
<uses-permission android:name="android.permission.INTERNET" />もしKotlin/WasmでブラウザのComposeを使う場合、ブラウザのCORS制限に引っかかります。
ので、CORSの設定変更が必要です。以下参照。JSONコピーしてCORSの設定に貼り付ければ良い。
S3バケットの中身一覧取得、S3バケットへオブジェクトを追加、S3バケットからオブジェクトを削除する関数たちです。
今回は面倒くさがってxmlパーサーを入れずに正規表現で戦ってます。ちゃんとするべきです、、、
ACCESS_KEYとSECRET_ACCESS_KEY、REGIONは皆さん自分の値を入れてください!!!
/** S3 クライアント */
object AwsS3Client {
// TODO 各自入力してください
private const val ACCESS_KEY = ""
private const val SECRET_ACCESS_KEY = ""
private const val REGION = "ap-northeast-1"
// Kotlin Multiplatform HTTP Client
private val httpClient = HttpClient()
// xml パーサーの代わりに正規表現
private val regexKey = "<Key>(.*?)</Key>".toRegex()
private val regexLastModified = "<LastModified>(.*?)</LastModified>".toRegex()
/**
* バケット内のオブジェクト一覧を取得する
*
* @param bucketName バケット名
*/
suspend fun getObjectList(bucketName: String): List<ListObject> {
val now = Clock.System.now()
val url = "https://s3.$REGION.amazonaws.com/$bucketName/?list-type=2"
val amzDateString = now.formatAmzDateString()
val yyyyMMddString = now.formatYearMonthDayDateString()
// 署名を作成
val requestHeader = hashMapOf(
"x-amz-date" to amzDateString,
"host" to "s3.$REGION.amazonaws.com"
)
val signature = generateAwsSign(
url = url,
httpMethod = "GET",
contentType = null,
region = REGION,
service = "s3",
amzDateString = amzDateString,
yyyyMMddString = yyyyMMddString,
secretAccessKey = SECRET_ACCESS_KEY,
accessKey = ACCESS_KEY,
requestHeader = requestHeader
)
// レスポンス xml を取得
val response = httpClient.get {
url(url)
headers {
// 署名をリクエストヘッダーにつける
requestHeader.forEach { (name, value) ->
this[name] = value
}
this["Authorization"] = signature
}
}
// XML パーサー入れるまでもないので、正規表現で戦う、、、
val responseXml = response.bodyAsText()
val keyList = regexKey.findAll(responseXml).toList().map { it.groupValues[1] }
val lastModifiedList = regexLastModified.findAll(responseXml).toList().map { it.groupValues[1] }
// data class
// 同じ数ずつあるはず
return keyList.indices.map { index ->
ListObject(
key = keyList[index],
lastModified = lastModifiedList[index]
)
}
}
/**
* S3 バケットにデータを投稿する
*
* @param bucketName バケット名
* @param key オブジェクトのキー(名前)
* @param byteArray バイナリデータ
*/
@OptIn(ExperimentalStdlibApi::class)
suspend fun putObject(
bucketName: String,
key: String,
byteArray: ByteArray
): Boolean {
val now = Clock.System.now()
val url = "https://s3.$REGION.amazonaws.com/$bucketName/$key"
val amzDateString = now.formatAmzDateString()
val yyyyMMddString = now.formatYearMonthDayDateString()
// 署名を作成
val requestHeader = hashMapOf(
"x-amz-date" to amzDateString,
"host" to "s3.$REGION.amazonaws.com"
)
val signature = generateAwsSign(
url = url,
httpMethod = "PUT",
contentType = null,
region = REGION,
service = "s3",
amzDateString = amzDateString,
yyyyMMddString = yyyyMMddString,
secretAccessKey = SECRET_ACCESS_KEY,
accessKey = ACCESS_KEY,
requestHeader = requestHeader,
payloadSha256 = byteArray.sha256().toHexString()
)
// PutObject する
val response = httpClient.put {
url(url)
headers {
requestHeader.forEach { (name, value) ->
this[name] = value
}
this["Authorization"] = signature
}
setBody(byteArray)
}
return response.status == HttpStatusCode.OK
}
/**
* オブジェクトを削除する
*
* @param bucketName バケット名
* @param key オブジェクトのキー
*/
suspend fun deleteObject(
bucketName: String,
key: String
): Boolean {
val now = Clock.System.now()
val url = "https://s3.$REGION.amazonaws.com/$bucketName/$key"
val amzDateString = now.formatAmzDateString()
val yyyyMMddString = now.formatYearMonthDayDateString()
// 署名を作成
val requestHeader = hashMapOf(
"x-amz-date" to amzDateString,
"host" to "s3.$REGION.amazonaws.com"
)
val signature = generateAwsSign(
url = url,
httpMethod = "DELETE",
contentType = null,
region = REGION,
service = "s3",
amzDateString = amzDateString,
yyyyMMddString = yyyyMMddString,
secretAccessKey = SECRET_ACCESS_KEY,
accessKey = ACCESS_KEY,
requestHeader = requestHeader
)
// 削除する
val response = httpClient.delete {
url(url)
headers {
// 署名をリクエストヘッダーにつける
requestHeader.forEach { (name, value) ->
this[name] = value
}
this["Authorization"] = signature
}
}
// 204 No Content を返す
return response.status == HttpStatusCode.NoContent
}
data class ListObject(
val key: String,
val lastModified: String
)
}APIを叩いて、バケット一覧をLazyColumnでリスト表示できるようにしてみます。
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@Preview
fun App() {
// API を叩く
val objectList = remember { mutableStateOf<List<AwsS3Client.ListObject>?>(null) }
LaunchedEffect(key1 = Unit) {
objectList.value = AwsS3Client.getObjectList(bucketName = "") // TODO 各自バケット名入れてください
}
MaterialTheme {
Scaffold(
topBar = { TopAppBar(title = { Text(text = "S3 Bucket") }) }
) { innerPadding ->
LazyColumn(contentPadding = innerPadding) {
if (objectList.value == null) {
// 読み込み中
item {
Box(
modifier = Modifier
.height(100.dp)
.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
} else {
// 一覧画面
items(
items = objectList.value!!,
key = { it.key }
) { obj ->
Column(modifier = Modifier.fillMaxWidth()) {
Text(text = obj.key, fontSize = 16.sp)
Text(text = obj.lastModified)
}
HorizontalDivider()
}
}
}
}
}
}こんな感じ!!!
すごい、ちゃんとMultiplatformで動いてる!!
さて、少し難しくなります。
というのも、画像を選ぶ処理はそれぞれのOSでやる必要があるためです。
AndroidならPhotoPicker、Webなら<input type="file">ですね。OS事に違う処理を書きたい場合、expect/actualを使います。
interfaceを用意し、それぞれのプラットフォームでinterfaceを実装し、返してあげるイメージです。
既にPlatform.ktがinterfaceを切って、AndroidとWebでそれぞれ処理を書いています。
interface Platform {
val name: String
}
expect fun getPlatform(): Platform写真ピッカー、探せば見つかりそうだけど、Compose Multiplatformの練習にならないので、今回は自力で作ります。
といっても、写真ピッカーを開いて、選び終わるまでサスペンド関数が一時停止、
選んだら画像のバイト配列が返ってくる関数を、それぞれのプラットフォームで作ります。
Platform.ktのようにPhotoPicker.ktを作りました。
インターフェースと、それぞれのプラットフォームで作った実装を入れる変数を用意しました。
fun interface PhotoPicker {
/**
* 写真ピッカーを開く。
* 選び終わるまで一時停止し、選んだ画像を[PhotoPickerResult]で返す。
* 選ぶのを辞めたら null を返す
*/
suspend fun startPhotoPicker(): PhotoPickerResult?
data class PhotoPickerResult(
val name: String,
val byteArray: ByteArray
)
}
expect val photoPicker: PhotoPickerこんな感じに作ると、それぞれのプラットフォームで作れよって言われるので、埋めます。
PhotoPicker.android.ktを埋めます。
Android側はフォトピッカーを使うことにします。なのでComposable 関数を一つおいて貰う形にします。rememberLauncherForActivityResult()を使いたいので何かしらComposable 関数をおいてもらわないと、、、なので。
コルーチン間はChannel()で信号を飛ばし合っています。
/** Android の PhotoPicker を開くことを通達する Channel */
val openPlatformPhotoPickerSignalChannel = Channel<Unit>()
/** Android の PhotoPicker の選択結果を通達する Channel */
val resultPlatformPhotoPickerSignalChannel = Channel<PhotoPicker.PhotoPickerResult?>()
/**
* Android 側
* 写真ピッカーの処理
*/
actual val photoPicker = PhotoPicker {
// 開くことを要求
openPlatformPhotoPickerSignalChannel.send(Unit)
// 結果が送られてくるまで待つ
resultPlatformPhotoPickerSignalChannel.receive()
}
/** [PhotoPicker]を利用するためにこの関数を呼び出してください。 */
@Composable
fun PhotoPickerInitEffect() {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val platformPhotoPicker = rememberLauncherForActivityResult(
contract = ActivityResultContracts.PickVisualMedia(),
onResult = { uri ->
scope.launch(Dispatchers.IO) {
// 選んでない
if (uri == null) {
resultPlatformPhotoPickerSignalChannel.send(null)
return@launch
}
// 名前は取得できないので、適当に作る
val extension = when (context.contentResolver.getType(uri)) {
"image/jpeg" -> ".jpg"
"image/png" -> ".png"
"image/webp" -> ".webp"
else -> null
}
if (extension == null) {
resultPlatformPhotoPickerSignalChannel.send(null)
return@launch
}
// バイナリを取得して返す
resultPlatformPhotoPickerSignalChannel.send(
PhotoPicker.PhotoPickerResult(
name = "${System.currentTimeMillis()}$extension",
byteArray = context.contentResolver.openInputStream(uri)!!.use { it.readBytes() }
)
)
}
}
)
LaunchedEffect(key1 = Unit) {
// 来たら開く
for (unuse in openPlatformPhotoPickerSignalChannel) {
platformPhotoPicker.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly))
}
}
}これをMainActivityのCompose作ってるところ、エントリーポイントで一発呼び出します。
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
PhotoPickerInitEffect() // PhotoPicker.kt のため
App()
}
}
}Platform.wasmJs.ktです。
<input type="file">ですね。多分これで動くはず。。TypeScriptなしJavaScript、新鮮だな。。
/** <input> */
val inputElement = (document.createElement("input") as HTMLInputElement).apply {
setAttribute("type", "file")
setAttribute("accept", ".jpg, .png, .webp")
}
/** ファイル取得 Flow */
val inputChangeEventFlow = callbackFlow {
// ファイル選択イベント
inputElement.onchange = {
trySend(inputElement.files?.get(0))
Unit
}
// 選択画面を閉じたイベント
inputElement.oncancel = {
trySend(null)
}
// ファイル
awaitClose {
inputElement.onchange = null
inputElement.oncancel = null
}
}
/**
* Web 側
* 写真ピッカーの処理
*/
actual val photoPicker = PhotoPicker {
// 開く
inputElement.click()
// 選ぶのを待つ
val file = inputChangeEventFlow.firstOrNull()
// 返す
if (file == null) {
null
} else {
PhotoPicker.PhotoPickerResult(
name = file.name,
byteArray = file.readBytes().toByteArray()
)
}
}
/** [File]からバイナリを取得する */
private suspend fun File.readBytes() = suspendCoroutine { continuation ->
val fileReader = FileReader()
fileReader.onload = {
val arrayBuffer = fileReader.result as ArrayBuffer
continuation.resume(Int8Array(arrayBuffer))
}
fileReader.readAsArrayBuffer(this)
}をApp()のScaffold { }に置きました。ExtendedFloatingActionButtonを使ってみました。
あと成功したかを表示するSnackbarを
val snackbarHostState = remember { SnackbarHostState() }
Scaffold(
snackbarHost = {
SnackbarHost(hostState = snackbarHostState)
},
topBar = {
TopAppBar(title = { Text(text = "S3 Bucket") })
},
floatingActionButton = {
ExtendedFloatingActionButton(
text = { Text(text = "Upload") },
icon = { Icon(imageVector = Icons.Outlined.Add, contentDescription = null) },
onClick = {
scope.launch {
// 投稿処理
val (name, byteArray) = photoPicker.startPhotoPicker() ?: return@launch
}
}
)
}
) { innerPadding ->
// 省略...
}バケット名は各自直してください。
onClick = {
scope.launch {
// 投稿処理
val (name, byteArray) = photoPicker.startPhotoPicker() ?: return@launch
// S3 に投げる
val isSuccessful = AwsS3Client.putObject(
bucketName = "", // バケット名!!!!
key = name,
byteArray = byteArray
)
snackbarHostState.showSnackbar(message = if (isSuccessful) "Successful" else "Error")
}
}これで動くはず。
成功するとSnackbarが表示されるはずです!!
は、読者さんへの課題とします(おい!!)
まあボタン押したらオブジェクトのキーで、オブジェクト削除API叩くだけのはずですしおすし
Androidの場合は普通にAndroid StudioのGenerate Signed App Bundle or APK ...からAPK、PlayStoreの場合はAABを作ればよいはず。

Get started with Kotlin/Wasm and Compose Multiplatform | Kotlin
https://kotlinlang.org/docs/wasm-get-started.html
Webの場合は、以下のコマンドをExecute Gradle Taskのテキストボックスで叩くと、静的サイト公開として必要なファイルが生成されます。gradle :composeApp:wasmJsBrowserDistributionこのコマンドパネルはGradleパネルのターミナルみたいなアイコンから。
ビルド成果物のパスはこれ。composeApp/build/dist/wasmJs/productExecutableの中にindex.htmlとかが入っているはず。
あとはこのフォルダの中身を、GitHub PagesかNetlifyかCloudflare Pages、S3+CloudFrontなどでホスティングすれば、
他の人でもアクセスできるようになります!
ブラウザで表示できるか試したい場合、Node.jsが入っていれば、成果物のパスへcdしてnpx serveすればよいです。IDEA Ultimateにはローカルサーバー機能があるらしいですが、Community版にはなかった、、
ここまで、なぜか頑なにText()に英語を入れていました。
なぜかというと初期状態では、Webの方で日本語を表示できません。

Using multiplatform resources in your app | Kotlin Multiplatform
https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-multiplatform-resources-usage.html
親切なことに、Compose Multiplatformでフォントをバンドルする方法が書かれているので、これに従います。
今回はKosugi MaruをGoogle Fontsからダウンロードしてきて使うことにします。
ダウンロードして解凍したら、フォントファイルをcomposeResourcesに配置します。
するとコード上で参照できるようになっているので、あとはこのフォントをText()に反映させるだけ。Text()のfontFamilyを一つ一つ付けていくのは面倒なので、大本であるMaterialThemeのfontFamilyを上書きする作戦で行きます。
Res.font.で追加したフォントが見つからない場合は一回コメントアウトして実行してみると良いかも。
// Web で日本語を表示できないので、MaterialTheme でフォントを伝搬させる
val bundleFont = FontFamily(Font(resource = Res.font.KosugiMaru_Regular))
val overrideFontFamily = MaterialTheme.typography.copy(
displayLarge = MaterialTheme.typography.displayLarge.copy(fontFamily = bundleFont),
displayMedium = MaterialTheme.typography.displayMedium.copy(fontFamily = bundleFont),
displaySmall = MaterialTheme.typography.displaySmall.copy(fontFamily = bundleFont),
headlineLarge = MaterialTheme.typography.headlineLarge.copy(fontFamily = bundleFont),
headlineMedium = MaterialTheme.typography.headlineMedium.copy(fontFamily = bundleFont),
headlineSmall = MaterialTheme.typography.headlineSmall.copy(fontFamily = bundleFont),
titleLarge = MaterialTheme.typography.titleLarge.copy(fontFamily = bundleFont),
titleMedium = MaterialTheme.typography.titleMedium.copy(fontFamily = bundleFont),
titleSmall = MaterialTheme.typography.titleSmall.copy(fontFamily = bundleFont),
bodyLarge = MaterialTheme.typography.bodyLarge.copy(fontFamily = bundleFont),
bodyMedium = MaterialTheme.typography.bodyMedium.copy(fontFamily = bundleFont),
bodySmall = MaterialTheme.typography.bodySmall.copy(fontFamily = bundleFont),
labelLarge = MaterialTheme.typography.labelLarge.copy(fontFamily = bundleFont),
labelMedium = MaterialTheme.typography.labelMedium.copy(fontFamily = bundleFont),
labelSmall = MaterialTheme.typography.labelSmall.copy(fontFamily = bundleFont),
)
MaterialTheme(typography = overrideFontFamily) {
// この中の Text() には自前フォントが適用されていr
}
CloudFrontにてCORSを利用する際の設定方法
https://zenn.dev/matsubokkuri/articles/cors-cloudfront
Ktorがリクエストする際、Webの場合はfetch()が勝手にOriginヘッダーを付けてくれますが、Androidの場合は多分デフォルトだとOriginヘッダー付与されません。
で、CloudFrontはOriginヘッダーが来たときのみ、CORS関連のヘッダーを付けて返しているらしい?
何が問題になるのかというと、CORS関連のヘッダーが、キャッシュの中身次第では付与されないことがあるということです。Originヘッダーが付いていないリクエストをキャッシュしてしまった場合、2回目以降はCORS関連の値がない状態でクライアントへ返されます。
例えば、Webが最初のリクエストなら、Origin付きのリクエストがキャッシュされてましたが、AndroidのようなOrigin付いてないリクエストが最初の場合、CORS関連のヘッダーがないレスポンスヘッダーをキャッシュするため、
2回目Webがリクエストすると、そのキャッシュが帰ってきて、結果的にCORSエラーになってしまう。
部分的にCloudFrontへのリクエストがCORS エラーでコケてて、何かと思ったらこれだった。
対策はCloudFrontのキャッシュポリシーで、Originヘッダーをキャッシュキーとしているポリシーに変更すれば良いはず。
キャッシュポリシー作りたい場合はヘッダーにOriginを、作るの面倒な場合はドロップダウンメニューにあるElemental-MediaPackageを使えば良いはず。
その下のオリジンリクエストポリシーはCORS-S3Originにしました。
オブジェクト一覧はこのコミットハッシュからどうぞ。
初回起動時は設定アイコンを押して、認証情報を埋める必要があります。(SharedPreference、localStorageに永続化されます)
このアプリを作りたかった理由が、前回S3とAWS Lambdaで画像をリサイズする仕組みを作ったのですが、
画像をいれるためにわざわざS3にログインするのは面倒だった。
あと、リサイズした画像はCloudFrontで配信しているので、URLをコピーしたり、画像のプレビューがしたかった。
なので、このアプリを使って、画像をアップロードして、リサイズした画像を表示するクライアントが欲しかった。
それが今回。
CoilでAsyncImage()をつかって、まるで写真アプリのような感じのUIを目指してみた。

FAQ | Kotlin Multiplatform
https://www.jetbrains.com/help/kotlin-multiplatform-dev/faq.html
AndroidのJetpack ComposeとJetbrainsのCompose Multiplatformの差は無い?stringResource()、painterResource())は置き換えが必要。Kotlin Coroutines等はそのまま使える。Dispatchers.IOはJavaとKotlin/Native (?)しか無いため、IOスレッド用Dispatcherをexpect val / actual valを使って自分で作ることでiOSでも動かしているらしい。compose-multiplatform/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/platform.common.kt at da82a7f31d69fa3ec50812d242e5c2bb053de29b · JetBrains/compose-multiplatform
Compose Multiplatform, a modern UI framework for Kotlin that makes building performant and beautiful user interfaces easy and enjoyable. - JetBrains/compose-multiplatform
https://github.com/JetBrains/compose-multiplatform/blob/da82a7f31d69fa3ec50812d242e5c2bb053de29b/examples/imageviewer/shared/src/commonMain/kotlin/example/imageviewer/platform.common.kt
そのまま使えるけど、あとも一個、これはKotlin/Wasmとか関係なく、WebAssemblyもまだシングルスレッドのハズ。
なので多分、コンカレントは正しいですが、パラレルは間違い。
(正確にはシングルスレッドでもイベントループという方式らしく、fetch() API等の内部ではスレッドを使っている、ハズ)
(が、Web フロントエンド開発者から見たユーザーランド(というか使える機能)では、スレッドを直接作ったりは出来ないそう)
Kotlin/Wasmじゃなくて、JSはどこいったんだいって話はこれだ、
FAQ | Kotlin Multiplatform
https://www.jetbrains.com/help/kotlin-multiplatform-dev/faq.html
多分、ComposeをWeb ブラウザに描画するためにC++製ライブラリSkiaを使ってて、
それをブラウザで動かすにはwasmでコンパイルする必要があって、だからKotlin/JSじゃなくKotlin/Wasmになってるんだと思う。しらんけど。
Compose Multiplatform、思ってた以上にはいい感じに動いてる。期待以上。です!
CPUとメモリを消費する。Jetpack Composeもこんな感じに、開発環境が重たかった気がする。一周回って懐かしい。あとactual/expect、これ@Composable関数でも使えます。
ドラッグアンドドロップをAndroid/Webで実装したときのやつです。
Compose Multiplatformあんまり関係ない話だけど、どうかMaven Central以外のライブラリホスティングを考えて欲しい、、、
あそこお硬いし難しすぎる。やったこと無いのに言うのあれだけど、NPMのnpm publishとかもっと簡単なんじゃないだろうか。
てか OSSRH 終わるんだけど。Central Portal って何ですか?
AndroidのComposeの方が、入力の補完やフォーマットがよく効いている気がする、、気のせいかな。Modifier毎に改行入れてくれないのと、compでComposable 関数作ってくれないのが厳しい、なんか設定変えれば良いのかな。
actualでAndroid側を作ってるとき、Context触れないの中々にきつい。。