MacのWi-Fiの遅延を防ぐには

MacのWi-Fi通信は60秒ごとに遅くなる。
原因は位置情報サービスにあり、位置情報サービスをオフにすると通信の遅延はなくなるらしい。

位置情報サービスを無効にする手順は以下の通り。

  1. Macで、アップルメニュー >「システム環境設定」と選択して「セキュリティとプライバシー」をクリックし、「プライバシー」をクリックします。
  2. 「位置情報サービス」をクリックします。
  3. 左下のロックがロックされている場合 、クリックして環境設定パネルのロックを解除します。
  4. 「位置情報サービスを有効にする」の選択を解除します。

Ktor HttpClientで画像をアップロードする

Ktor HttpClientで画像をアップロードするコードを紹介する。

使用したプログラミング言語とライブラリのバージョン

  • Kotlin 1.6.10
  • Jetpack Compose 1.1.1
  • Ktor 2.0.0

Ktor HttpClientで画像をアップロードする

build.gradleの編集

build.gradle (:app) に以下の行を追加する。

implementation "io.ktor:ktor-client-core:2.0.0"
implementation "io.ktor:ktor-client-android:2.0.0"

画像をアップロードする

関数storeは、引数にアップロードするBitmapと、レスポンスを返す関数をとる。

画像はJPEGに変換してアップロードする。

val jpg = ByteArrayOutputStream()
bitmap.compress(Bitmap.CompressFormat.JPEG, 100, jpg)

HttpClientはデフォルトのエンジンを使用する。
後で、レスポンスをJSONに変換するコードを追加する。

val client = HttpClient()

HttpClientのsubmitFormWithBinaryData()メソッドを使用して画像をアップロードする。

val response = client.submitFormWithBinaryData(
    url = "http://192.168.10.114/upload.php",
    formData = formData {
        appendInput(key = "file", headers = Headers.build {
            append(HttpHeaders.ContentType, "image/jpg")
            append(HttpHeaders.ContentDisposition, "filename=image.jpg")
        }) {
            buildPacket {
                writeFully(jpg.toByteArray())
            }
        }
    }
)

全体のコードは次のようになる。

object ImageRepository {
    suspend fun store(bitmap: Bitmap, onResult: (String?) -> Unit) {
        val jpg = ByteArrayOutputStream()
        bitmap.compress(Bitmap.CompressFormat.JPEG, 100, jpg)

        val client = HttpClient()
        val response = client.submitFormWithBinaryData(
            url = "http://192.168.10.114/upload.php",
            formData = formData {
                appendInput(key = "file", headers = Headers.build {
                    append(HttpHeaders.ContentType, "image/jpg")
                    append(HttpHeaders.ContentDisposition, "filename=image.jpg")
                }) {
                    buildPacket {
                        writeFully(jpg.toByteArray())
                    }
                }
            }
        )
        onResult(response.bodyAsText())
    }
}

JSONのレスポンスを受け取る

レスポンスのJSONをパースして、オブジェクトを返す。

build.gradleの編集

build.gradle (:app) に以下の行を追加する。

plugins {
    id 'org.jetbrains.kotlin.plugin.serialization' version '1.6.10' // 追加
}
dependencies {
    implementation 'io.ktor:ktor-serialization-kotlinx-json:2.0.0' // 追加
    implementation 'io.ktor:ktor-client-content-negotiation:2.0.0' // 追加
    implementation 'org.jetbrains.kotlin:kotlin-serialization:1.6.21' // 追加
}

JSONのレスポンスを受け取る

パースするJSONの形式に合わせてクラスを作成する。

@Serializable
data class StoreResponse(
    val success: String? = null,
    val error: String? = null,
    val filename: String? = null
)

HttpClientがJSONを扱えるように設定する。

val client = HttpClient {
    install(ContentNegotiation) {
        json(json = Json, contentType = ContentType.Application.Json)
    }
}

そうすると、自動的にクラスに合わせてパースしてくれる。

val response: StoreResponse = client.submitFormWithBinaryData(
    // 省略
).body()

全体のコードは次のようになる。

object ImageRepository {
    suspend fun upload(bitmap: Bitmap, onResult: (StoreResponse?) -> Unit) {
        val jpg = ByteArrayOutputStream()
        bitmap.compress(Bitmap.CompressFormat.JPEG, 100, jpg)

        val client = HttpClient {
            install(ContentNegotiation) {
                json(json = Json, contentType = ContentType.Application.Json)
            }
        }

        val response: StoreResponse = client.submitFormWithBinaryData(
            url = "http://192.168.10.114/upload.php",
            formData = formData {
                appendInput(key = "file", headers = Headers.build {
                    append(HttpHeaders.ContentType, "image/jpg")
                    append(HttpHeaders.ContentDisposition, "filename=image.jpg")
                }) {
                    buildPacket {
                        writeFully(jpg.toByteArray())
                    }
                }
            }
        ).body()
        onResult(response)
    }
}

@Serializable
data class StoreResponse(
    val success: String? = null,
    val error: String? = null,
    val filename: String? = null
)

AndroidのWebViewの「ファイルを選択」ボタン(input type=”file”)で選択されたファイルを取得する

以前、「AndroidのWebViewの「ファイルを選択」ボタン(input typ=”file”)で写真を撮るかファイルを選択する」を書いた。
この方法はカメラアプリが起動したときにアクティビティが破棄されると、選択した画像が表示されない問題があった。

アクティビティが破棄されても、選択された画像を取得する方法を紹介する。

WebViewが表示していたページが破棄されると、選択画像をWebViewで選択状態にすることはできない。
画像をサーバーに保存してWebViewに通知するなど、Web側での対応が必要になるだろう。

ActivityのonCreateでWebViewを作成する。
WebChromeClientのonShowFileChooserをオーバーライドして、<input type=”file”>をクリックした時のイベントを処理する。
onShowFileChooserでは、startForResultでインテントを起動し、写真の撮影か画像ファイルの選択を促す。
写真が撮影されるか画像ファイルが選択されると、fileChooserのgetSelectedPictureで画像データを取得する。

アクティビティが破棄されたり復元されたときに、fileChooserの状態を復元することも必要だ。

class MainActivity : AppCompatActivity() {
    private val fileChooser = FileChooser()
    private val startForResult =
        registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
            this.fileChooser.getSelectedPicture(this, result.resultCode, result.data)?.let { jpg ->
                // 取得した画像を処理する
            }
        }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        setupWebView()
    }

    @SuppressLint("SetJavaScriptEnabled")
    private fun setupWebView() {
        webView = WebView(this)
        setContentView(webView)
        webView.loadUrl(URL)
        // JavaScriptを有効にする
        webView.settings.javaScriptEnabled = true
        webView.webChromeClient = object : WebChromeClient() {
            // <input type="file">をクリックした時のイベント
            override fun onShowFileChooser(
                webView: WebView?,
                filePathCallback: ValueCallback<Array<Uri>>?,
                fileChooserParams: FileChooserParams?
            ): Boolean {
                fileChooser.createPictureChooseIntent(
                    this@MainActivity,
                    filePathCallback,
                    fileChooserParams
                )?.let { intent ->
                    startForResult.launch(intent)
                }
                return true
            }
        }
    }
    override fun onSaveInstanceState(outState: Bundle) {
        this.fileChooser.onSaveInstanceState(outState)
        super.onSaveInstanceState(outState)
    }

    override fun onRestoreInstanceState(savedInstanceState: Bundle) {
        this.fileChooser.onRestoreInstanceState(savedInstanceState)
    }
}

FileChooserクラスは、「ファイルを選択」ボタン(input typ=”file”)が押された時に、写真の撮影か画像の選択を促し、画像を取得する処理を行う。

「ファイルを選択」ボタン(input typ=”file”)が押された時、createPictureChooseIntentで写真の撮影または選択のインテントを作成する。
写真の撮影にはカメラの権限が必要なため、権限の確認を忘れないようにする。
撮影された写真のURLはimageUriに保持するため、アクティビティが復元されたときにBundleから復元している。

写真が撮影、または画像が選択されると、getSelectedPictureで画像を取得する。
撮影または選択されたファイルを読み込み、JPEGに変換する。
filePathCallbackはブラウザのファイルを選択ボタンのコールバック関数で、処理待ちの状態になっているので、onReceiveValueを実行して処理を完了する。

private const val REQUEST_ID_MULTIPLE_PERMISSIONS = 122
private const val TAG = "FileChooser"

class FileChooser {
    private var filePathCallback: ValueCallback<Array<Uri>>? = null
    private var imageUri: Uri? = null

    /**
     * 写真の撮影または選択のインテントを作成する
     */
    fun createPictureChooseIntent(
        activity: Activity,
        filePathCallback: ValueCallback<Array<Uri>>?,
        fileChooserParams: FileChooserParams?
    ): Intent? {
        this.filePathCallback?.onReceiveValue(null)
        this.filePathCallback = filePathCallback
        return createPictureChooseIntent(activity, fileChooserParams)
    }

    private fun createPictureChooseIntent(
        activity: Activity,
        fileChooserParams: FileChooserParams?
    ): Intent? {
        // 権限がないときは、権限を要求する
        if (!checkAndRequestPermissions(activity, REQUEST_ID_MULTIPLE_PERMISSIONS)) {
            this.filePathCallback?.onReceiveValue(null)
            this.filePathCallback = null
            return null
        }
        val chooserIntent = Intent.createChooser(fileChooserParams?.createIntent(), "写真の選択")
        this.imageUri = createImageFile(activity)
        val imageCaptureIntent = Intent(MediaStore.ACTION_IMAGE_CAPTURE)
        imageCaptureIntent.putExtra(MediaStore.EXTRA_OUTPUT, this.imageUri)
        chooserIntent.putExtra(
            Intent.EXTRA_INITIAL_INTENTS,
            arrayOf<Parcelable>(imageCaptureIntent)
        )
        return chooserIntent
    }

    private fun createImageFile(activity: Activity): Uri? {
        val folder = activity.getExternalFilesDir(Environment.DIRECTORY_DCIM)
        val date = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date())
        val fileName = String.format("_%s.jpg", date)
        val cameraFile = File(folder, fileName)
        return FileProvider.getUriForFile(
            activity,
            activity.applicationContext.packageName + ".fileprovider",
            cameraFile
        )
    }

    fun onSaveInstanceState(outState: Bundle) {
        outState.putParcelable("imageUri", this.imageUri)
    }

    fun onRestoreInstanceState(savedInstanceState: Bundle) {
        this.imageUri = savedInstanceState.getParcelable("imageUri")
    }

    /**
     * 選択された画像を取得する
     * @param context
     * @param resultCode
     * @param data
     */
    fun getSelectedPicture(
        context: Context,
        resultCode: Int,
        data: Intent?
    ): ByteArrayOutputStream? {
        var bmp: Bitmap?
        try {
            if (resultCode == Activity.RESULT_OK) {
                val result = FileChooserParams.parseResult(resultCode, data)
                val uri = result?.get(0) ?: imageUri
                uri?.let {
                    // 撮影された写真のJPGデータを作成
                    bmp = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
                        ImageDecoder.decodeBitmap(
                            ImageDecoder.createSource(
                                context.contentResolver,
                                uri
                            )
                        )
                    } else {
                        MediaStore.Images.Media.getBitmap(context.contentResolver, uri)
                    }
                    val jpg = ByteArrayOutputStream()
                    bmp?.compress(Bitmap.CompressFormat.JPEG, 100, jpg)
                    return jpg
                }
            }
        } finally {
            this.imageUri = null
            this.filePathCallback?.onReceiveValue(null)
            this.filePathCallback = null
        }
        return null
    }
}

/**
 * 権限があるか確認し、権限がなければ要求する
 *
 * @return 権限があるときはtrue
 */
private fun checkAndRequestPermissions(activity: Activity, requestCode: Int): Boolean {
    val permissionNeeded = permissionNeeded(activity, arrayOf(Manifest.permission.CAMERA))
    if (permissionNeeded.isNotEmpty()) {
        ActivityCompat.requestPermissions(activity, permissionNeeded, requestCode)
        return false
    }
    return true
}

private fun permissionNeeded(activity: Activity, permissions: Array<String>): Array<String> {
    val listPermissionsNeeded: MutableList<String> = ArrayList()
    for (permission in permissions) {
        if (ContextCompat.checkSelfPermission(
                activity,
                permission
            ) != PackageManager.PERMISSION_GRANTED
        ) {
            listPermissionsNeeded.add(permission)
        }
    }
    return listPermissionsNeeded.toTypedArray()
}

CapacitorのAndroidアプリでviewportを有効にするには

CapacitorのAndroidアプリでmetaタグのviewport設定が無視される。

viewportの設定を有効にするには、WebViewのWebSettingsのsetUseWideViewPortメソッドを使用する。
setUseWideViewPortメソッドの引数をtrueにすると、metaタグのviewportのサポートが有効になる。

ソースコードの修正には、以下のページが参考になる。

簡単な方法は、MainActivity.javaを編集する。

public class MainActivity extends BridgeActivity {
    @Override
    public void onStart() {
        super.onStart();
        this.bridge.getWebView().getSettings().setUseWideViewPort(true);
    }
}

これで、metaタグのviewportの設定が有効になる。