たくさんの自由帳

Compose Multiplatform を使って、AWS S3 にアップロードするアプリを作る

投稿日 : | 0 日前

文字数(だいたい) : 11977

どうもこんばんわ。
流る星 -a Wish Star- 攻略しました。ソフマップのかべに貼ってあったやつ!!

ソフマップ

ソフマップ

かわいい。むずかしい話とかはなかったのでおすすめ!!!
ロープラでおてがる

かわいい

めっちゃモダンなカミサマだった。

モダン

表情がいっぱい。おかげさまでスクショが埋まった。
きらきらしてるやつすき

かわいい

かわいい

かわいい

後日談にえちえちシーンがあります!!!
本編よりこっちが本編なのでは!?!?長く感じた。うれしい

おねえさん
!?!?!?

えっ

えっ

えちえちしーん良かった!!!
意地悪でも言ってくれるカミサマ、、

本題

前回S3+Lambdaで画像を小さくするやつを作った。リサイズが面倒だったのとUltraHDR画像をここに貼り付けたかったんだよな。
で、で、で

現状はコンソールにログインして、S3バケットの画面を開き、そこに写真を放り込んでいますが、ちょい面倒。。。

専用クライアントアプリ作りたいなあ。 そっから画像をアップロードしてしばらく待ってれば、変換後のURLがコピーできるみたいな、そーゆーの作りたいなあ。

Compose Multiplatform

パソコンと、Androidから投稿したかったので、当初はReactでペライチアプリでも作るか~って思ってました。が、

そう言えばCompose Multiplatformって使ったことないじゃん私Jetpack ComposeAndroidだけじゃなくてWebとかでも動くらしい。
ブラウザ(パソコン)とAndroidでクライアント作りたいのでこれでいいやんって。
てか私一人しか使わないので動けば良い

Web だと SEO とかなんとかあって SSR/SSG を選ぶ必要あるけど、今回は自分だけだし。

環境

今回はAndroidWebをターゲットにします。
iOSmac持ってない。ので。。
Web<canvas>に描画されます。

なまえあたい
Android StudioAndroid Studio Meerkat Feature Drop 2024.3.2

Compose Multiplatform プラグイン

Android Studioにプラグインを入れたほうが良いらしい(?)
本当に必要なのかが分からない

プロジェクトを作る

これを開いて
https://kmp.jetbrains.com/

項目を埋めます
Project IDですが、Javaの文化なのかなんなのかわかりませんが、持っているドメインを逆さまにして、最後にアプリケーション名を入れる文化があります。
ドメインなかった頃はGitHub Pages使ってて未だにそれやってる。

Imgur

出来たらDOWNLOAD、お好きな場所で解凍してください。

Android Studio で開く

解凍したものをAndroid Studioで開きます。

Imgur

そしたらしばらく待ちます。
ライブラリのダウンロードなりがあるので。。

フォルダ構成

ファイルツリーの表示はここから変更できます。
慣れない場合はここから変更できます。

Imgur

デフォルト状態では、commonMainモジュールにあるComposable App()関数を、
それぞれのプラットフォーム(androidMainwasmJsMain)の各エントリーポイント(AndroidならMainActivityWebならmain.kt)で呼び出してる感じですね。

また、プラットフォーム固有処理は
Platform.ktにインターフェイスで定義があって、
interface Platformを各プラットフォームで実装したものをactualで返す感じみたいです。

よく見るとバージョンカタログ等使われてるので、イケイケandroidアプリ開発の知見が必要そう、
Gradle何もわからない。

とりあえず実行してみたい

Androidの場合はcomposeAppを選んで端末を繋げば実行ボタンが押せます。
Imgur

Webの場合はGradleのコマンドパレットから、以下のコマンドを叩くと、ローカルサーバーが立ち上がります。

Imgur

Imgur

gradle  :composeApp:wasmJsBrowserDevelopmentRun

Imgur

必要なライブラリを入れる

まず手始めにMaterial3じゃないので、Material3を使うようにライブラリを差し替えます。
composeApp/build.gradle.ktsでそれぞれのプラットフォームで必要なライブラリを定義できます。

Imgur

あとは、S3REST 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直してね。

EdgeToEdge 出来てない

App()の中身をScaffold { }で囲むのと、MainActivityenableEdgeToEdge()を呼び出す必要があります。
というわけで全部消しますか。

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できました。

API を叩くのに使う AWS Sigv4 署名を作る関数

AWSKotlin 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/

commonMainAwsSignV4.ktを作ってこんな感じ。
Imgur

/** 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" />

S3 の CORS 設定

もしKotlin/WasmでブラウザのComposeを使う場合、ブラウザのCORS制限に引っかかります。
ので、CORSの設定変更が必要です。以下参照。JSONコピーしてCORSの設定に貼り付ければ良い。

S3 API クライアントを作る

S3バケットの中身一覧取得、S3バケットへオブジェクトを追加、S3バケットからオブジェクトを削除する関数たちです。
今回は面倒くさがってxmlパーサーを入れずに正規表現で戦ってます。ちゃんとするべきです、、、

ACCESS_KEYSECRET_ACCESS_KEYREGION皆さん自分の値を入れてください!!!

/** 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で動いてる!!

Imgur

Imgur

画像を投稿してみる

さて、少し難しくなります。
というのも、画像を選ぶ処理はそれぞれのOSでやる必要があるためです。

AndroidならPhotoPickerWebなら<input type="file">ですね。
OS事に違う処理を書きたい場合、expect/actualを使います。

interfaceを用意し、それぞれのプラットフォームでinterfaceを実装し、返してあげるイメージです。
既にPlatform.ktinterfaceを切って、AndroidWebでそれぞれ処理を書いています。

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

こんな感じに作ると、それぞれのプラットフォームで作れよって言われるので、埋めます。
Imgur

Android 側

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

これをMainActivityCompose作ってるところ、エントリーポイントで一発呼び出します。

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()

        setContent {
            PhotoPickerInitEffect() // PhotoPicker.kt のため
            App()
        }
    }
}

Web側

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 ->
    // 省略...
}

S3 に投げる

バケット名は各自直してください。

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が表示されるはずです!!

Imgur

Imgur

削除ボタンをつくる

は、読者さんへの課題とします(おい!!)
まあボタン押したらオブジェクトのキーで、オブジェクト削除API叩くだけのはずですしおすし

配布する

Androidの場合は普通にAndroid StudioGenerate Signed App Bundle or APK ...からAPKPlayStoreの場合はAABを作ればよいはず。

Imgur

Imgur

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パネルのターミナルみたいなアイコンから。
Imgur

ビルド成果物のパスはこれ。
composeApp/build/dist/wasmJs/productExecutableの中にindex.htmlとかが入っているはず。
Imgur

あとはこのフォルダの中身を、GitHub PagesNetlifyCloudflare PagesS3+CloudFrontなどでホスティングすれば、
他の人でもアクセスできるようになります!

ブラウザで表示できるか試したい場合、Node.jsが入っていれば、成果物のパスへcdしてnpx serveすればよいです。
IDEA Ultimateにはローカルサーバー機能があるらしいですが、Community版にはなかった、、

Imgur

おまけ 日本語表示

ここまで、なぜか頑なにText()に英語を入れていました。
なぜかというと初期状態では、Webの方で日本語を表示できません。

Imgur

親切なことに、Compose Multiplatformでフォントをバンドルする方法が書かれているので、これに従います。
今回はKosugi MaruGoogle Fontsからダウンロードしてきて使うことにします。

ダウンロードして解凍したら、フォントファイルをcomposeResourcesに配置します。
Imgur

するとコード上で参照できるようになっているので、あとはこのフォントをText()に反映させるだけ。
Text()fontFamilyを一つ一つ付けていくのは面倒なので、大本であるMaterialThemefontFamilyを上書きする作戦で行きます。

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
}

これでWebでも日本語が表示されています!
Imgur

おまけ マルチプラットフォーム環境の API リクエストと CloudFront のキャッシュ

Ktorがリクエストする際、Webの場合はfetch()が勝手にOriginヘッダーを付けてくれますが、
Androidの場合は多分デフォルトだとOriginヘッダー付与されません。

Imgur

Imgur

で、CloudFrontOriginヘッダーが来たときのみ、CORS関連のヘッダーを付けて返しているらしい?

何が問題になるのかというと、CORS関連のヘッダーが、キャッシュの中身次第では付与されないことがあるということです。
Originヘッダーが付いていないリクエストをキャッシュしてしまった場合、2回目以降はCORS関連の値がない状態でクライアントへ返されます。

例えば、Webが最初のリクエストなら、Origin付きのリクエストがキャッシュされてましたが、
AndroidのようなOrigin付いてないリクエストが最初の場合、CORS関連のヘッダーがないレスポンスヘッダーをキャッシュするため、
2回目Webがリクエストすると、そのキャッシュが帰ってきて、結果的にCORSエラーになってしまう。

部分的にCloudFrontへのリクエストがCORS エラーでコケてて、何かと思ったらこれだった。
Imgur

対策はCloudFrontキャッシュポリシーで、Originヘッダーをキャッシュキーとしているポリシーに変更すれば良いはず。
キャッシュポリシー作りたい場合はヘッダーにOriginを、作るの面倒な場合はドロップダウンメニューにあるElemental-MediaPackageを使えば良いはず。
Imgur

その下のオリジンリクエストポリシーCORS-S3Originにしました。
Imgur

ソースコード

オブジェクト一覧はこのコミットハッシュからどうぞ。
初回起動時は設定アイコンを押して、認証情報を埋める必要があります。(SharedPreferencelocalStorageに永続化されます)

このアプリを作りたかった理由が、前回S3AWS Lambdaで画像をリサイズする仕組みを作ったのですが、
画像をいれるためにわざわざS3にログインするのは面倒だった。
あと、リサイズした画像はCloudFrontで配信しているので、URLをコピーしたり、画像のプレビューがしたかった。

なので、このアプリを使って、画像をアップロードして、リサイズした画像を表示するクライアントが欲しかった。
それが今回。

CoilAsyncImage()をつかって、まるで写真アプリのような感じのUIを目指してみた。

Imgur

Imgur

分かったこと

FAQ | Kotlin Multiplatform

https://www.jetbrains.com/help/kotlin-multiplatform-dev/faq.html


AndroidJetpack ComposeJetbrainsCompose Multiplatformの差は無い?
限りなく同じ API が提供されている。
ただし、Android に依存している(stringResource()painterResource())は置き換えが必要。

そのまま使えるけど、あとも一個、これはKotlin/Wasmとか関係なく、WebAssemblyまだシングルスレッドのハズ。
なので多分、コンカレントは正しいですが、パラレルは間違い。
(正確にはシングルスレッドでもイベントループという方式らしく、fetch() API等の内部ではスレッドを使っている、ハズ)
(が、Web フロントエンド開発者から見たユーザーランド(というか使える機能)では、スレッドを直接作ったりは出来ないそう)

Kotlin/Wasmじゃなくて、JSはどこいったんだいって話はこれだ、

FAQ | Kotlin Multiplatform

https://www.jetbrains.com/help/kotlin-multiplatform-dev/faq.html

多分、ComposeWeb ブラウザに描画するためにC++製ライブラリSkiaを使ってて、
それをブラウザで動かすにはwasmでコンパイルする必要があって、だからKotlin/JSじゃなくKotlin/Wasmになってるんだと思う。しらんけど。

おわりに

Compose Multiplatform、思ってた以上にはいい感じに動いてる。期待以上。です!

Imgur

それはそうと、めちゃめちゃCPUメモリを消費する。
なんか黎明期のJetpack Composeもこんな感じに、開発環境が重たかった気がする。一周回って懐かしい。
https://takusan.negitoro.dev/posts/android_jc/#終わりに

あとactual/expect、これ@Composable関数でも使えます。
ドラッグアンドドロップをAndroid/Webで実装したときのやつです。

おわりに2

Compose Multiplatformあんまり関係ない話だけど、どうかMaven Central以外のライブラリホスティングを考えて欲しい、、、
あそこお硬いし難しすぎる。やったこと無いのに言うのあれだけど、NPMnpm publishとかもっと簡単なんじゃないだろうか。

てか OSSRH 終わるんだけど。Central Portal って何ですか?

Imgur

おわりに3

AndroidComposeの方が、入力の補完やフォーマットがよく効いている気がする、、気のせいかな。
Modifier毎に改行入れてくれないのと、compComposable 関数作ってくれないのが厳しい、なんか設定変えれば良いのかな。

Imgur

actualAndroid側を作ってるとき、Context触れないの中々にきつい。。