たくさんの自由帳

Happy Eyeballs を実装してくれた OkHttp チームに感謝

投稿日 : | 0 日前

文字数(だいたい) : 4941

Pixel Watch 3に朝起こしてもらってますが、起こしてくれない時があった!!
どうしたんかなって見てみたら、オーバーヒートでシャットダウンしてたらしい。腕を怪我する前に守ってくれた模様、ありがと~~

Imgur

Imgur

本題

AndroidではHTTPクライアントライブラリにOkHttpをよく使うのですが、私の家の回線環境のせいなのか
ときたま、IPv4 / IPv6両方に対応したサーバー(ちなAmazon CloudFront)にあるファイルのダウンロードが全く進まなくなる時がありました。
今回はこれの調査をしました。

先に結論

OkHttpチームがHappy Eyeballs機能を実装してくれたため、OkHttpのバージョンを5系(記述時時点アルファ版です・・)にすると直るはずです。
https://square.github.io/okhttp/changelogs/changelog/#version-500-alpha11

もしくは、OkHttpDNSIPv4を優先する方法でもいいらしいです(バージョンアップできない場合)
詳しくは最後!→ #okhttp-アップデート以外で修正したい

環境

家の固定回線です。今のところ携帯回線(ギガ使うやつ)は再現しないですね、、
あとPixel系はなってない気がします。気のせいかも。

なまえあたい
たんまつXperia 1 V / OnePlus 7 Pro / Xiaomi Mi 11 Lite 5G
サーバーAWS (CloudFront + S3)

事の発端

AndroidアプリでOkHttpを使い、ファイルダウンロード機能を作ってたんですが、なんだか一向にダウンロードが進まない。
と思って端末変えるとダウンロード出来たり、時間によってはダウンロード出来たり、Wi-FiON/OFFを試すと動いたりと不安定です。

ちなみにブラウザではダウンロード出来るので、これもまた謎。

最低限のコード

コードはこんな感じで、時間によっては動くから間違いはないはず。
この状態になるとonFailureonResponseも呼ばれないので、本当にずっっっと読み込み中表示になっちゃうことになります。

val okHttpClient = OkHttpClient.Builder()
    .build()
val request = Request.Builder().apply {
    url("https://takusan.negitoro.dev/icon.png")
    get()
}.build()
var isInvokeCallback = false
val call = okHttpClient.newCall(request)
call.enqueue(object : Callback {
    override fun onFailure(call: Call, e: IOException) {
        isInvokeCallback = true
        println("onFailure")
    }
 
    override fun onResponse(call: Call, response: Response) {
        isInvokeCallback = true
        println("onResponse ${response.code}")
    }
})
Handler(Looper.getMainLooper()).postDelayed(10_000) {
    // 10 秒以内に onFailure も onResponse もない場合
    if (!isInvokeCallback) {
        call.cancel()
        println("10秒待ってもだめなのでキャンセルします")
    }
}

Logcatがこう

10秒待ってもだめなのでキャンセルします
onFailure

調べる

ちょうどパソコンの前に座ってるタイミングで発症したので、重い腰を上げて調査することにした。
何でかは知りませんがGoogle Pixel以外のAndroid端末でcurl使えます(何でだろう)。Pixelだとなんかエラーになってしまう。Xperiaにはある。
持っててよかったPixel以外

C:\Users\takusan23>adb shell
SO-51D:/ $ curl --help
Usage: curl [options...] <url>
 -d, --data <data>          HTTP POST data
 -f, --fail                 Fail fast with no output on HTTP errors
 -h, --help <category>      Get help for commands
 -i, --include              Include protocol response headers in the output
 -o, --output <file>        Write to file instead of stdout
 -O, --remote-name          Write output to a file named as the remote file
 -s, --silent               Silent mode
 -T, --upload-file <file>   Transfer local FILE to destination
 -u, --user <user:password> Server user and password
 -A, --user-agent <name>    Send User-Agent <name> to server
 -v, --verbose              Make the operation more talkative
 -V, --version              Show version number and quit

curlOkHttpのリクエストと同じ内容を投げてみます。が、なぜか通ります。
たまたま直ったかと思ってOkHttpで試してみたけどだめだった、、、

IPv6 が悪い?

ここでcurl -v付きで詳細を出してもらおうと思いました。
すると興味深い内容が出てきました。

$ curl -v https://example.com
*   Trying 2001:0db8:85a3:0:0:8a2e:0370:7334:443...
* TCP_NODELAY set
*   Trying 192.0.2.1:443...
* TCP_NODELAY set
* Connected to example.com (192.0.2.1) port 443 (#0)

(このブログのCloudFrontIPアドレスを例示して良いのか知らんので適当に予約済みIPアドレスで代替しました。)
CloudFrontからの借り物だし自分のですって言えない気がする。)
URLも適当です)

なんだか IPv4 にフォールバックしています。
curlIPv6を使うことを諦めている説がある

OkHttp の IPv6 周りが怪しい

というわけで見てみたところ、こちらです。
IPv4IPv6のどちらか良い方を使う機能、Happy Eyeballsって名前がついているらしい。

https://github.com/square/okhttp/issues/506

OkHttpは既にアルファ版でこの機能が使えるらしい!
解決策としては、OkHttpv5系にして、OkHttpClient.BuilderfastFallback(true)を呼び出せば良いらしいです! → どこかのアルファ版からデフォルトtrueになったっぽい!
https://github.com/square/okhttp/issues/506#issuecomment-1024256588

再現させる

再現させたい方向け。調べてる感じ回線によるので、多分ならない回線だと再現できない

再現させるにはIPv4 / IPv6 デュアルスタックなサーバーを用意できれば良いはず。
ちなみにこのブログもAmazon CloudFront (IPv4 / IPv6 両方行ける)で配信してるので適当に画像をリクエストするでも良いはず。https://takusan.negitoro.dev/icon.png

サーバーを用意する

VPSとか借りるの面倒なんで、今回はAmazon CloudFrontのディストリビューションを作ることにします。
CloudFrontIPv4 / IPv6どっちでも接続できるはず。(手元の端末の回線都合は知らん)

オリジンもS3にします。S3のコンテンツをCloudFrontで配信するようにして、これをサーバーとします。

S3

適当にバケットを作ります。

Imgur

できたら、開いて、適当にファイルをバケットに入れておきます。
このファイルをHappy Eyeballsを有効にしたOkHttpからダウンロード出来るか試します。

Imgur

CloudFront

ディストリビューションを作成します。
オリジンには、さっき作ったS3を選びます。

Imgur

オリジンアクセスにはOACを使います。
Origin access control settings (recommended)を選んで、Create new OACを押し、そのままにして作成します。

Imgur

Imgur

こんな警告が出るので、後で対応します。

Imgur

あとここを選んで、作成すれば良いはず。

Imgur

作成後、S3の設定を変更するよう言われるので、コピーボタンを押して、リンクを押します。

Imgur

アクセス許可を選び

Imgur

バケットポリシーを押し、貼り付けます(コピーボタンを押したらクリップボードにコピーされるので、あとは貼り付ければ良い)。

Imgur

疎通確認

CloudFrontのディストリビューションに戻って、ディストリビューションドメイン名をコピーし、ブラウザのアドレス欄に打ち込みます。
そのあと、スラッシュ入れて、アップロードしたファイルの名前(今回はtakusan23_icon.png)を入れて Enter

Imgur

できた!!!

OkHttp を使う Android アプリを用意する

適当にJetpack Composeを使うプロジェクトを作ってください。
そのあと、OkHttpライブラリを追加します。appフォルダの方のbuild.gradle (.kts)を開き、以下を足す。

dependencies {
    implementation("com.squareup.okhttp3:okhttp:5.0.0-alpha.14")
 
    // ... 以下省略

次にAndroidManifest.xmlで、インターネット権限を追加します。

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">
 
    <uses-permission android:name="android.permission.INTERNET" />
 
    <!-- 以下省略 -->

最後にMainActivityで適当に画面を作って終わり。
Happy EyeballsON/OFF用スイッチを付けました。
サンプルのためにダウンロード処理をUI (Compose)に書いていますが、本当はViewModelに処理を書くべきです。画面回転を超えられないので。

/** OkHttp 非同期モードのコールバックを Kotlin Coroutines に対応させたもの */
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            OkHttpHappyEyeballsTheme {
                MainScreen()
            }
        }
    }
}
 
/** OkHttp 非同期モードのコールバックを Kotlin Coroutines に対応させたもの */
private suspend fun Call.suspendExecute() = suspendCancellableCoroutine { cancellableContinuation ->
    enqueue(object : Callback {
        override fun onFailure(call: Call, e: IOException) {
            cancellableContinuation.resumeWithException(e)
        }
 
        override fun onResponse(call: Call, response: Response) {
            cancellableContinuation.resume(response)
        }
    })
    cancellableContinuation.invokeOnCancellation { this.cancel() }
}
 
@RequiresApi(Build.VERSION_CODES.Q)
@Composable
private fun MainScreen() {
    val context = LocalContext.current
    val scope = rememberCoroutineScope()
 
    val errorDialogText = remember { mutableStateOf<String?>(null) }
    val downloadUrl = remember { mutableStateOf("https://takusan.negitoro.dev/icon.png") }
    val isUseHappyEyeballs = remember { mutableStateOf(false) }
 
    fun startDownload() {
        scope.launch(Dispatchers.IO) {
            val okHttpClient = OkHttpClient.Builder().apply {
                // Happy Eyeballs を有効
                fastFallback(isUseHappyEyeballs.value)
            }.build()
            val request = Request.Builder().apply {
                url(downloadUrl.value)
                get()
            }.build()
            try {
                // 指定時間以内に終わらなければキャンセルする suspendExecute()
                withTimeout(10_000) {
                    okHttpClient.newCall(request).suspendExecute()
                }.use { response ->
                    // エラーは return
                    if (!response.isSuccessful) {
                        errorDialogText.value = response.code.toString()
                        return@launch
                    }
                    // Downloads/OkHttpHappyEyeballs フォルダに保存
                    val fileContentValues = contentValuesOf(
                        MediaStore.Downloads.DISPLAY_NAME to System.currentTimeMillis().toString(),
                        MediaStore.Downloads.RELATIVE_PATH to "${Environment.DIRECTORY_DOWNLOADS}/OkHttpHappyEyeballs"
                    )
                    val uri = context.contentResolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, fileContentValues)!!
                    context.contentResolver.openOutputStream(uri)?.use { outputStream ->
                        response.body.byteStream().use { inputStream ->
                            inputStream.copyTo(outputStream)
                        }
                    }
                    withContext(Dispatchers.Main) {
                        Toast.makeText(context, "ダウンロードが完了しました", Toast.LENGTH_SHORT).show()
                    }
                }
            } catch (e: Exception) {
                errorDialogText.value = e.toString()
                // キャンセル系は再スロー
                if (e is CancellationException) {
                    throw e
                }
            }
        }
    }
 
    if (errorDialogText.value != null) {
        AlertDialog(
            onDismissRequest = { errorDialogText.value = null },
            title = { Text(text = "OkHttp エラー") },
            text = { Text(text = errorDialogText.value!!) },
            confirmButton = {
                Button(onClick = { errorDialogText.value = null }) {
                    Text(text = "閉じる")
                }
            }
        )
    }
 
    Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
        Column(
            modifier = Modifier
                .padding(innerPadding)
                .padding(10.dp)
        ) {
 
            OutlinedTextField(
                modifier = Modifier.fillMaxWidth(),
                value = downloadUrl.value,
                onValueChange = { downloadUrl.value = it },
                label = { Text(text = "画像の URL") }
            )
            Row(verticalAlignment = Alignment.CenterVertically) {
                Text(text = "HappyEyeballs を有効")
                Switch(
                    checked = isUseHappyEyeballs.value,
                    onCheckedChange = { isUseHappyEyeballs.value = it }
                )
            }
            Button(onClick = { startDownload() }) {
                Text(text = "ダウンロード開始")
            }
        }
    }
}

再現した

タイミング良く回線ハズレを引き当てました!!!
Happy Eyeballsなしの場合はコールバックが一向に呼ばれないので、Kotlin CoroutineswithTimeout { }のタイムアウトが作動して、タイムアウトになっています。

Imgur

Imgur

一方回線ハズレを引いている状態でも、Happy Eyeballsが有効だとちゃんとダウンロードできました。

Imgur

Imgur

OkHttp アップデート以外で修正したい

アルファ版だからアップデートは心配という場合は、DNS部分をカスタマイズすれば一応は回避できるらしい。 https://github.com/square/okhttp/issues/506#issuecomment-765899011

/** IPv4 アドレスを優先する OkHttp DNS。IPv6 アドレスを後ろに追いやっている */
class PriorityIpv4Dns() : Dns {
    override fun lookup(hostname: String): List<InetAddress> {
        return Dns.SYSTEM.lookup(hostname).sortedBy { Inet6Address::class.java.isInstance(it) }
    }
}

差分を貼り付けるの面倒なので全部張りますが、
IPv4を優先するスイッチを付けました。有効にすると、上記のPriorityIpv4Dnsが使われるようにしてみました。

/** IPv4 アドレスを優先する OkHttp DNS 実装。IPv6 アドレスを後ろに追いやっている */
class PriorityIpv4Dns() : Dns {
    override fun lookup(hostname: String): List<InetAddress> {
        return Dns.SYSTEM.lookup(hostname).sortedBy { Inet6Address::class.java.isInstance(it) }
    }
}
 
@RequiresApi(Build.VERSION_CODES.Q)
@Composable
private fun MainScreen() {
    val context = LocalContext.current
    val scope = rememberCoroutineScope()
 
    val errorDialogText = remember { mutableStateOf<String?>(null) }
    val downloadUrl = remember { mutableStateOf("https://takusan.negitoro.dev/icon.png") }
    val isUseHappyEyeballs = remember { mutableStateOf(false) }
    val isPriorityIpv4 = remember { mutableStateOf(false) }
 
    fun startDownload() {
        scope.launch(Dispatchers.IO) {
            val okHttpClient = OkHttpClient.Builder().apply {
                // Happy Eyeballs を有効
                fastFallback(isUseHappyEyeballs.value)
                // IPv4 を優先
                if (isPriorityIpv4.value) {
                    dns(PriorityIpv4Dns())
                }
            }.build()
            val request = Request.Builder().apply {
                url(downloadUrl.value)
                get()
            }.build()
            try {
                // 指定時間以内に終わらなければキャンセルする suspendExecute()
                withTimeout(10_000) {
                    okHttpClient.newCall(request).suspendExecute()
                }.use { response ->
                    // エラーは return
                    if (!response.isSuccessful) {
                        errorDialogText.value = response.code.toString()
                        return@launch
                    }
                    // Downloads/OkHttpHappyEyeballs フォルダに保存
                    val fileContentValues = contentValuesOf(
                        MediaStore.Downloads.DISPLAY_NAME to System.currentTimeMillis().toString(),
                        MediaStore.Downloads.RELATIVE_PATH to "${Environment.DIRECTORY_DOWNLOADS}/OkHttpHappyEyeballs"
                    )
                    val uri = context.contentResolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, fileContentValues)!!
                    context.contentResolver.openOutputStream(uri)?.use { outputStream ->
                        response.body.byteStream().use { inputStream ->
                            inputStream.copyTo(outputStream)
                        }
                    }
                    withContext(Dispatchers.Main) {
                        Toast.makeText(context, "ダウンロードが完了しました", Toast.LENGTH_SHORT).show()
                    }
                }
            } catch (e: Exception) {
                errorDialogText.value = e.toString()
                // キャンセル系は再スロー
                if (e is CancellationException) {
                    throw e
                }
            }
        }
    }
 
    if (errorDialogText.value != null) {
        AlertDialog(
            onDismissRequest = { errorDialogText.value = null },
            title = { Text(text = "OkHttp エラー") },
            text = { Text(text = errorDialogText.value!!) },
            confirmButton = {
                Button(onClick = { errorDialogText.value = null }) {
                    Text(text = "閉じる")
                }
            }
        )
    }
 
    Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
        Column(
            modifier = Modifier
                .padding(innerPadding)
                .padding(10.dp)
        ) {
 
            OutlinedTextField(
                modifier = Modifier.fillMaxWidth(),
                value = downloadUrl.value,
                onValueChange = { downloadUrl.value = it },
                label = { Text(text = "画像の URL") }
            )
            Row(verticalAlignment = Alignment.CenterVertically) {
                Text(text = "HappyEyeballs を有効")
                Switch(
                    checked = isUseHappyEyeballs.value,
                    onCheckedChange = { isUseHappyEyeballs.value = it }
                )
            }
            Row(verticalAlignment = Alignment.CenterVertically) {
                Text(text = "IPv4 を優先する")
                Switch(
                    checked = isPriorityIpv4.value,
                    onCheckedChange = { isPriorityIpv4.value = it }
                )
            }
            Button(onClick = { startDownload() }) {
                Text(text = "ダウンロード開始")
            }
        }
    }
}

Imgur

これでも一応動きますが、多分Happy Eyeballsを使えるアルファ版を使うほうが良いような気がする。
というのもRedditチームいわく、よく動いているみたい。なので

https://www.reddit.com/r/RedditEng/comments/v1upr8/ipv6_support_on_android/

おわりに

今回使った検証アプリのソースコード置いておきます
https://github.com/takusan23/OkHttpHappyEyeballs

以上です、お疲れ様でした 8888888888888