たくさんの自由帳
Androidのお話
たくさんの自由帳
投稿日 : | 0 日前
文字数(だいたい) : 4977
Pixel Watch 3に朝起こしてもらってますが、起こしてくれない時があった!!
どうしたんかなって見てみたら、オーバーヒートでシャットダウンしてたらしい。腕を怪我する前に守ってくれた模様、ありがと~~
AndroidではHTTPクライアントライブラリにOkHttpをよく使うのですが、私の家の回線環境のせいなのか、
ときたま、IPv4 / IPv6両方に対応したサーバー(ちなAmazon CloudFront)にあるファイルのダウンロードが全く進まなくなる時がありました。
今回はこれの調査をしました。
OkHttpチームがHappy Eyeballs機能を実装してくれたため、OkHttpのバージョンを5系(記述時時点アルファ版です・・)にすると直るはずです。家の固定回線です。今のところ携帯回線(ギガ使うやつ)は再現しないですね、、
あと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 quitcurlで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って名前がついているらしい。
OkHttpは既にアルファ版でこの機能が使えるらしい!OkHttpをv5系にして、OkHttpClient.BuilderでfastFallback(true)を呼び出せば良いらしいです!trueになったっぽい!Happy Eyeballs · Issue #506 · square/okhttp
http://en.wikipedia.org/wiki/Happy_Eyeballs
https://github.com/square/okhttp/issues/506
再現させたい方向け。調べてる感じ回線によるので、多分ならない回線だと再現できない。
IPv4 / IPv6 デュアルスタックなサーバーを用意できれば良いはず。Amazon CloudFront (IPv4 / IPv6 両方行ける)で配信してるので適当に画像をリクエストするでも良いはず。https://takusan.negitoro.dev/icon.pngVPSとか借りるの面倒なんで、今回は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部分をカスタマイズすれば一応は回避できるらしい。
Happy Eyeballs · Issue #506 · square/okhttp
http://en.wikipedia.org/wiki/Happy_Eyeballs
https://github.com/square/okhttp/issues/506
/** 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チームいわく、よく動いているみたい。なので
以上です、お疲れ様でした 8888888888888