たくさんの自由帳
Androidのお話
たくさんの自由帳
投稿日 : | 0 日前
文字数(だいたい) : 4977
Pixel Watch 3
に朝起こしてもらってますが、起こしてくれない時があった!!
どうしたんかなって見てみたら、オーバーヒートでシャットダウンしてたらしい。腕を怪我する前に守ってくれた模様、ありがと~~
Android
ではHTTP
クライアントライブラリにOkHttp
をよく使うのですが、私の家の回線環境のせいなのか、
ときたま、IPv4 / IPv6
両方に対応したサーバー(ちなAmazon CloudFront
)にあるファイルのダウンロードが全く進まなくなる時がありました。
今回はこれの調査をしました。
OkHttp
チームがHappy Eyeballs
機能を実装してくれたため、OkHttp
のバージョンを5系
(記述時時点アルファ版です・・)にすると直るはずです。
https://square.github.io/okhttp/changelogs/changelog/#version-500-alpha11
もしくは、OkHttp
のDNS
でIPv4
を優先する方法でもいいらしいです(バージョンアップできない場合)
詳しくは最後!→ #okhttp-アップデート以外で修正したい
家の固定回線です。今のところ携帯回線(ギガ使うやつ)は再現しないですね、、
あとPixel
系はなってない気がします。気のせいかも。
なまえ | あたい |
---|---|
たんまつ | Xperia 1 V / OnePlus 7 Pro / Xiaomi Mi 11 Lite 5G |
サーバー | AWS (CloudFront + S3) |
Android
アプリでOkHttp
を使い、ファイルダウンロード機能を作ってたんですが、なんだか一向にダウンロードが進まない。
と思って端末変えるとダウンロード出来たり、時間によってはダウンロード出来たり、Wi-Fi
のON/OFF
を試すと動いたりと不安定です。
ちなみにブラウザではダウンロード出来るので、これもまた謎。
コードはこんな感じで、時間によっては動くから間違いはないはず。
この状態になるとonFailure
もonResponse
も呼ばれないので、本当にずっっっと読み込み中表示になっちゃうことになります。
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
curl
でOkHttp
のリクエストと同じ内容を投げてみます。が、なぜか通ります。
たまたま直ったかと思ってOkHttp
で試してみたけどだめだった、、、
ここで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)
(このブログのCloudFront
のIPアドレス
を例示して良いのか知らんので適当に予約済みIPアドレス
で代替しました。)
(CloudFront
からの借り物だし自分のですって言えない気がする。)
(URL
も適当です)
なんだか IPv4 にフォールバックしています。
curl
はIPv6
を使うことを諦めている説がある
というわけで見てみたところ、こちらです。
IPv4
とIPv6
のどちらか良い方を使う機能、Happy Eyeballs
って名前がついているらしい。
https://github.com/square/okhttp/issues/506
OkHttp
は既にアルファ版でこの機能が使えるらしい!
解決策としては、OkHttp
をv5
系にして、 → どこかのアルファ版からデフォルトOkHttpClient.Builder
でfastFallback(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
のディストリビューションを作ることにします。
CloudFront
はIPv4 / IPv6
どっちでも接続できるはず。(手元の端末の回線都合は知らん)
オリジンもS3
にします。S3
のコンテンツをCloudFront
で配信するようにして、これをサーバーとします。
適当にバケットを作ります。
できたら、開いて、適当にファイルをバケットに入れておきます。
このファイルをHappy Eyeballs
を有効にしたOkHttp
からダウンロード出来るか試します。
ディストリビューションを作成します。
オリジンには、さっき作ったS3
を選びます。
オリジンアクセスにはOAC
を使います。
Origin access control settings (recommended)
を選んで、Create new OAC
を押し、そのままにして作成します。
こんな警告が出るので、後で対応します。
あとここを選んで、作成すれば良いはず。
作成後、S3
の設定を変更するよう言われるので、コピーボタンを押して、リンクを押します。
アクセス許可を選び
バケットポリシーを押し、貼り付けます(コピーボタンを押したらクリップボードにコピーされるので、あとは貼り付ければ良い)。
CloudFront
のディストリビューションに戻って、ディストリビューションドメイン名
をコピーし、ブラウザのアドレス欄に打ち込みます。
そのあと、スラッシュ入れて、アップロードしたファイルの名前(今回はtakusan23_icon.png
)を入れて Enter
できた!!!
適当に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 Eyeballs
のON/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 Coroutines
のwithTimeout { }
のタイムアウトが作動して、タイムアウトになっています。
一方回線ハズレを引いている状態でも、Happy Eyeballs
が有効だとちゃんとダウンロードできました。
アルファ版だからアップデートは心配という場合は、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 = "ダウンロード開始")
}
}
}
}
これでも一応動きますが、多分Happy Eyeballs
を使えるアルファ版を使うほうが良いような気がする。
というのもReddit
チームいわく、よく動いているみたい。なので
https://www.reddit.com/r/RedditEng/comments/v1upr8/ipv6_support_on_android/
今回使った検証アプリのソースコード置いておきます
https://github.com/takusan23/OkHttpHappyEyeballs
以上です、お疲れ様でした 8888888888888