Android WebViewでは、なぜかカメラが起動しない!!

webViewで、<input type="file"> でのカメラ起動は、IOSは何もしなくともカメラが起動するのですが、Androidだと、カメラがぜーんぜん起動しない・・・😢

かなり手こずったので、私がカメラ起動に成功した方法をまとめてみました。

スポットでソースコードを見ていくのではなく、0から作ると、とてもわかりやすいので、0から組みたてていきたいと思います。


ゼロから作っていく

Web側(WebView)のソースコードは、コレだけです。

<input type="file" multiple>

iOSは、これだけで、inputタグをタップしてあげればカメラが起動しますが、Androidは、何故かカメラが起動しません😢

それでは、ここからモバイルアプリ側のソースコードを書いていきます。

全体のソースは、最後に載せてますので、完成系だけ見たい人は、すっ飛ばしちゃってくださいませ。

まずは、空っぽActivityです。Kotlinで書いていきます。

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

モバイル側のレイアウトは、WebViewを表示できるようにしておきます。

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <WebView
        android:id="@+id/web_view"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

まずは、WebViewを表示できるようにする

MainActivityでWebViewが表示できるようにしていく

package com.example.qiitacamera

import android.os.Bundle
import android.view.View
import android.webkit.WebChromeClient
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.appcompat.app.AppCompatActivity

class MainActivity : AppCompatActivity() {
    /** WebView */
    private lateinit var webView: WebView

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

        // webViewのセット
        webView = findViewById<View>(R.id.web_view) as WebView
        val webSettings = webView.settings
        // WebView内でJavaScriptを有効にするための設定
        webSettings.javaScriptEnabled = true
        // WebViewがファイルへのアクセスを許可するための設定
        webSettings.allowFileAccess = true

        // WebViewをより安全に使いつつ、パフォーマンスも向上するためのの設定たち
        webSettings.mixedContentMode = 0
        webView.setLayerType(View.LAYER_TYPE_HARDWARE, null)

        // WebView内のUIイベントを処理
        webView.webChromeClient = object : WebChromeClient() {
            // 中身は後から書きます
        }

        // WebView内のページの読み込みとかのイベントを処理
        webView.webViewClient = object : WebViewClient() {
            // 中身は後から書きます
        }

        // 表示させたいwebのURL (私はDockerでローカルにたちあげているのでエミュレーターが参照するlocalにしてます)
        webView.loadUrl("http://10.0.2.2:8000/")
    }
}

マニフェストの設定がめっちゃ重要!!!

私は、大事なカメラのパーミッションを忘れていて、解決するのにめっちゃ時間がかかりました。

<?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-feature
        android:name="android.hardware.camera"
        android:required="false" />

    <uses-permission android:name="android.permission.INTERNET" />
    <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
    <uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
    <uses-permission android:name="android.permission.CAMERA" />
    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
        android:maxSdkVersion="32" />
    <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
        android:maxSdkVersion="32" />

		<!--  ↓コレ、最初許可してなくて、解決するのにめっちゃ時間かかりました。
            これらの宣言により、アプリは他のアプリケーションのカメラ機能やギャラリーへのアクセスを要求する際に、
            システムがそれらのアクセス権を持つアプリをユーザーに提示できます。
            ユーザーが同意した場合、アプリはそれらの機能を使用することができます。-->
    <queries>
        <!-- カメラ -->
        <intent>
            <action android:name="android.media.action.IMAGE_CAPTURE" />
        </intent>
        <!-- ギャラリー -->
        <intent>
            <action android:name="android.intent.action.GET_CONTENT" />
        </intent>
    </queries>

    <application
        android:allowBackup="true"
        android:dataExtractionRules="@xml/data_extraction_rules"
        android:fullBackupContent="@xml/backup_rules"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:usesCleartextTraffic="true"
        android:theme="@style/Theme.QiitaCamera"
        tools:targetApi="31">

        <activity
            android:name=".MainActivity"
            android:exported="true">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />

                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
    </application>

</manifest>

httpsの描画なら、
android:usesCleartextTraffic="true" 
は書かなくてOKです。 今回のコードは、localhostの画面を表示させたいのでhttpを許可する設定にしております。

まず、この時点でbuildしてみましょう。
run app成功したら、次へGO!
動かなかったら、なんとかしてもらうか、コメントください・・・

端末内のファイルと、カメラで撮影した写真を格納するためのクラス定数を定義する

続いて、端末内のファイルと、カメラの写真を格納するためのクラス定数を定義していきます。

 /** WebView */
   private lateinit var webView: WebView
   
   /** 画像のパスを保存する */
   private var mCameraPhotoPath: String? = null

   /** ファイル選択ダイアログが表示された際に、選択されたファイルのURLを受け取る
    * アップロードされたファイルの取得や操作する
    * */
   private var mUM: ValueCallback<Uri>? = null

   /** ファイル選択ダイアログが表示された際に、選択されたファイルのURLを受け取る
    * 複数のファイルが選択された場合
    *  */
   private var mUMA: ValueCallback<Array<Uri>>? = null

   /** ファイル選択リクエストの識別子 なんでもOK */
   private val FCR = 1

カメラを使用するパーミッション許可を要求する

つづいて、onCreateの中で、ユーザーにパーミッション許可を要求していきます。

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

       if (Build.VERSION.SDK_INT >= 23 && (ContextCompat.checkSelfPermission(
               this,
               Manifest.permission.WRITE_EXTERNAL_STORAGE
           ) != PackageManager.PERMISSION_GRANTED ||
                   ContextCompat.checkSelfPermission(
                       this,
                       Manifest.permission.CAMERA
                   ) != PackageManager.PERMISSION_GRANTED)
       ) {
           ActivityCompat.requestPermissions(
               this@MainActivity, arrayOf(
                   Manifest.permission.WRITE_EXTERNAL_STORAGE, Manifest.permission.CAMERA
               ), 1
           )
       }

念の為、ここまででbuildが通るか確認し、
パーミッション許可ダイアログが出ることを確認すると良いかもです。
こんな感じのダイアログが出ると思います。↓

WebView内でのページ読み込み時の挙動を設定する

つづいて、WebView内でのページ読み込み時にエラーが発生した際の動作をカスタマイズするために、KotlinでWebViewClientを拡張していきます。

// WebView内のページの読み込みとかのイベントを処理
    webView.webViewClient = object : WebViewClient() {
        override fun onReceivedError(
            view: WebView?,
            errorCode: Int,
            description: String?,
            failingUrl: String?
        ) {
                            // ページ読み込みでエラーが発生したらトーストを出す
            Toast.makeText(
                applicationContext, "Failed loading app!",
                Toast.LENGTH_SHORT
            ).show()
        }
    }

カメラ動作に必要なメソッドを作成する

つづいて、カメラ動作に必要なメソッドを作成していきます。
カメラで撮影した写真を一旦外部ストレージに退避させるためのメソッドです。

// 一時画像ファイルを外部ストレージに作成する
   private fun createImageFile(): File? {
       @SuppressLint("SimpleDateFormat") val timeStamp = SimpleDateFormat(
           "yyyyMMdd_HHmmss"
       ).format(Date())
       val imageFileName = "img_" + timeStamp + "_"
       // 外部ストレージのディレクトリを取得し、storageDir変数に格納
       val storageDir = Environment.getExternalStorageDirectory()
       // 一時的なJPEGファイルを作成し、外部ストレージ内の一時ディレクトリに作成
       return File.createTempFile(imageFileName, ".jpg", storageDir)
   }

外部ストレージのアクセスに、Environment.getExternalStorageDirectory()を使用している人もいるのではないでしょうか?
でも、私は、
Environment.getExternalStorageDirectory()だと、端末の権限系のダイアログが表示されてしまい、
カメラ起動ができませんでした。
(許可方法もちょっとわからず・・・)

カメラでの写真撮影や、ファイル選択ダイアログをカスタマイズする

続いて、Webページからファイルを選択する際に呼び出されるonShowFileChooserメソッドをオーバーライドします。
カメラでの写真撮影やファイル選択ダイアログをカスタマイズしています。

// WebView内のUIイベントを処理
    webView.webChromeClient = object : WebChromeClient() {
        override fun onShowFileChooser(
            webView: WebView?,
            filePathCallback: ValueCallback<Array<Uri>>?,
            fileChooserParams: FileChooserParams?
        ): Boolean {
            if (mUMA != null) {
                mUMA!!.onReceiveValue(null)
            }

            mUMA = filePathCallback
            var takePictureIntent = Intent(MediaStore.ACTION_IMAGE_CAPTURE)

            if (takePictureIntent.resolveActivity(this@MainActivity.packageManager) != null) {
                var photoUri: Uri? = null
                try {
                    photoUri = createImageFile()
                    takePictureIntent.putExtra("PhotoPath", mCameraPhotoPath)
                } catch (ex: IOException) {
                    android.util.Log.e("WebActivity", "Image file creation failed", ex)
                }
                // ファイルが正常に作成された場合にのみ続行
                if (photoUri != null) {
                    mCameraPhotoPath = photoUri.toString()
                    takePictureIntent.putExtra(MediaStore.EXTRA_OUTPUT, photoUri)
                } else {
                    takePictureIntent.putExtra(MediaStore.EXTRA_OUTPUT, "null")
                }
            }

            val contentSelectionIntent = Intent(Intent.ACTION_GET_CONTENT)
            contentSelectionIntent.addCategory(Intent.CATEGORY_OPENABLE)
            contentSelectionIntent.type = "*/*"

            var intentArray: Array<Intent?>

            if (takePictureIntent != null) {
                intentArray = arrayOf(takePictureIntent)
            } else {
                intentArray = arrayOfNulls(0)
            }

            val chooserIntent = Intent(Intent.ACTION_CHOOSER)
            chooserIntent.putExtra(Intent.EXTRA_INTENT, contentSelectionIntent)
            chooserIntent.putExtra(Intent.EXTRA_TITLE, "Image Chooser")
            chooserIntent.putExtra(Intent.EXTRA_INITIAL_INTENTS, intentArray)
            startActivityForResult(chooserIntent, FCR)
            return true;
        }
    }

上記のプログラムだと、カメラ起動か、ファイル選択かが選べるようになりますが、
もしファイル選択のみにしたい場合は、
var takePictureIntent = Intent(MediaStore.ACTION_PICK_IMAGES)

などにして、アクションを変更してください。

ここまでで、Buildしてみましょう。
web側のinputタグをタップすると、カメラorファイル選択の画面が表示されるはずです。

しかし、このままだと、
カメラのIntentが終了したら、そのまま次の処理が走りません。

WebViewとモバイル間でカメラで撮影したデータを受け渡す

上記で書いたソースコードに、startActivityForResult(chooserIntent, FCR) という記述がありますが、
startActivityForResultで起動した、カメラのインテントの終了を受け取る処理を書いていきます。

以下のソースコードは、WebViewでファイルの選択が完了した後に実行され、選択されたファイルのURIを適切な形式でWebViewに渡す役割を果たします。

override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
    super.onActivityResult(requestCode, resultCode, data)

    if (requestCode == FCR) {
        if (mUMA != null) {
            val resultUri: Uri? = if (data?.data == null) {
                if (mCameraPhotoPath != null) {
                    Uri.parse(mCameraPhotoPath)
                } else {
                    null
                }
            } else {
                data.data
            }

            val results = if (resultUri != null) {
                arrayOf(resultUri)
            } else {
                null
            }

            mUMA?.onReceiveValue(results)
            mUMA = null
        } else if (requestCode == FCR && mUM != null) {
            val result: Uri? = if (resultCode != Activity.RESULT_OK || data == null) {
                null
            } else {
                data.data
            }

            mUM?.onReceiveValue(result)
            mUM = null
        }
    }
}

完成!!!

以上のソースコードで、
<input type="file" multiple>が正常に動くようになりました!!🚀

私は、エミュレーターで、アプリを起動して、カメラ機能を試しているので、エミュレーターだとこんな感じの写真がとれました。 (エミュレーターじゃなくて、実機でもカメラ撮影ができることを確認できております)

Androidのエミュレーターのセルフィーかわいい

Googleとか、Androidとか、こういう遊び心ある感じ、私は大好きです。

全体のソースコード

MainActivity.kt

package com.example.qiitacamera

import android.Manifest
import android.app.Activity
import android.content.ContentValues
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Environment
import android.provider.MediaStore
import android.view.View
import android.webkit.ValueCallback
import android.webkit.WebChromeClient
import android.webkit.WebView
import android.webkit.WebViewClient
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import java.io.IOException
import java.text.SimpleDateFormat
import java.util.Date

class MainActivity : AppCompatActivity() {
    /** WebView */
    private lateinit var webView: WebView

    /** 画像のパスを保存する */
    private var mCameraPhotoPath: String? = null

    /** ファイル選択ダイアログが表示された際に、選択されたファイルのURLを受け取る
     * アップロードされたファイルの取得や操作する
     * */
    private var mUM: ValueCallback<Uri>? = null

    /** ファイル選択ダイアログが表示された際に、選択されたファイルのURLを受け取る
     * 複数のファイルが選択された場合
     *  */
    private var mUMA: ValueCallback<Array<Uri>>? = null

    /** ファイル選択リクエストの識別子 なんでもOK */
    private val FCR = 1

    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
        super.onActivityResult(requestCode, resultCode, data)

        if (requestCode == FCR) {
            if (mUMA != null) {
                val resultUri: Uri? = if (data?.data == null) {
                    if (mCameraPhotoPath != null) {
                        Uri.parse(mCameraPhotoPath)
                    } else {
                        null
                    }
                } else {
                    data.data
                }

                val results = if (resultUri != null) {
                    arrayOf(resultUri)
                } else {
                    null
                }

                mUMA?.onReceiveValue(results)
                mUMA = null
            } else if (requestCode == FCR && mUM != null) {
                val result: Uri? = if (resultCode != Activity.RESULT_OK || data == null) {
                    null
                } else {
                    data.data
                }

                mUM?.onReceiveValue(result)
                mUM = null
            }
        }
    }

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

        if (Build.VERSION.SDK_INT >= 23 && (ContextCompat.checkSelfPermission(
                this,
                Manifest.permission.WRITE_EXTERNAL_STORAGE
            ) != PackageManager.PERMISSION_GRANTED ||
                    ContextCompat.checkSelfPermission(
                        this,
                        Manifest.permission.CAMERA
                    ) != PackageManager.PERMISSION_GRANTED)
        ) {
            ActivityCompat.requestPermissions(
                this@MainActivity, arrayOf(
                    Manifest.permission.WRITE_EXTERNAL_STORAGE, Manifest.permission.CAMERA
                ), 1
            )
        }

        // webViewのセット
        webView = findViewById<View>(R.id.web_view) as WebView
        val webSettings = webView.settings
        // WebView内でJavaScriptを有効にするための設定
        webSettings.javaScriptEnabled = true
        // WebViewがファイルへのアクセスを許可するための設定
        webSettings.allowFileAccess = true

        // WebViewをより安全に使いつつ、パフォーマンスも向上するためのの設定たち
        webSettings.mixedContentMode = 0
        webView.setLayerType(View.LAYER_TYPE_HARDWARE, null)

        // WebView内のUIイベントを処理
        webView.webChromeClient = object : WebChromeClient() {
            override fun onShowFileChooser(
                webView: WebView?,
                filePathCallback: ValueCallback<Array<Uri>>?,
                fileChooserParams: FileChooserParams?
            ): Boolean {
                if (mUMA != null) {
                    mUMA!!.onReceiveValue(null)
                }

                mUMA = filePathCallback
                var takePictureIntent = Intent(MediaStore.ACTION_IMAGE_CAPTURE)

                if (takePictureIntent.resolveActivity(this@MainActivity.packageManager) != null) {
                    var photoUri: Uri? = null
                    try {
                        photoUri = createImageFile()
                        takePictureIntent.putExtra("PhotoPath", mCameraPhotoPath)
                    } catch (ex: IOException) {
                        android.util.Log.e("WebActivity", "Image file creation failed", ex)
                    }
                    // ファイルが正常に作成された場合にのみ続行
                    if (photoUri != null) {
                        mCameraPhotoPath = photoUri.toString()
                        takePictureIntent.putExtra(MediaStore.EXTRA_OUTPUT, photoUri)
                    } else {
                        takePictureIntent.putExtra(MediaStore.EXTRA_OUTPUT, "null")
                    }
                }

                val contentSelectionIntent = Intent(Intent.ACTION_GET_CONTENT)
                contentSelectionIntent.addCategory(Intent.CATEGORY_OPENABLE)
                contentSelectionIntent.type = "*/*"

                var intentArray: Array<Intent?>

                if (takePictureIntent != null) {
                    intentArray = arrayOf(takePictureIntent)
                } else {
                    intentArray = arrayOfNulls(0)
                }

                val chooserIntent = Intent(Intent.ACTION_CHOOSER)
                chooserIntent.putExtra(Intent.EXTRA_INTENT, contentSelectionIntent)
                chooserIntent.putExtra(Intent.EXTRA_TITLE, "Image Chooser")
                chooserIntent.putExtra(Intent.EXTRA_INITIAL_INTENTS, intentArray)
                startActivityForResult(chooserIntent, FCR)
                return true;
            }
        }

        // WebView内のページの読み込みとかのイベントを処理
        webView.webViewClient = object : WebViewClient() {
            override fun onReceivedError(
                view: WebView?,
                errorCode: Int,
                description: String?,
                failingUrl: String?
            ) {
                Toast.makeText(
                    applicationContext, "Failed loading app!",
                    Toast.LENGTH_SHORT
                ).show()
            }
        }

        // 表示させたいwebのURL (私はDockerでローカルにたちあげているのでエミュレーターが参照するlocal)
        webView.loadUrl("http://10.0.2.2:8000/")
    }

    // insert()メソッドを使って画像ファイルのメタデータを含むContentValuesを使用して、
    // 外部ストレージの画像コンテンツURI(MediaStore.Images.Media.EXTERNAL_CONTENT_URI)に新しい画像ファイルを挿入する。
    private fun createImageFile(): Uri? {
        // Create an image file name
        val timeStamp: String = SimpleDateFormat("yyyyMMdd_HHmmss").format(Date())
        val imageFileName = "JPEG_" + timeStamp + "_"

        val contentValues = ContentValues().apply {
            put(MediaStore.Images.Media.DISPLAY_NAME, "$imageFileName.jpg")
            put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg")
            put(MediaStore.Images.Media.RELATIVE_PATH, Environment.DIRECTORY_PICTURES)
        }

        val resolver = applicationContext.contentResolver
        val imageUri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues)

        return imageUri
    }
}

最後に、大事ポイント

AndroidManifest.kt

<queries>
    <!-- カメラ -->
    <intent>
        <action android:name="android.media.action.IMAGE_CAPTURE" />
    </intent>
    <!-- ギャラリー -->
    <intent>
        <action android:name="android.intent.action.GET_CONTENT" />
    </intent>
</queries>

AndroidManifest.kt で上記↑↑↑↑の許可を書かないと、

MainActivityの以下↓↓↓↓が、永遠に true になりませんので、ご注意を!!!!

if (takePictureIntent.resolveActivity(this@MainActivity.packageManager) != null) {

私は、ここに詰まりました・・・

WebViewでカメラが起動できるようになるまで5日くらいかかったなぁ・・・。

WebViewは、アプリ側の負担が軽いかとおもいきや、こういった思わぬ落とし穴があるんですね。

Web側とのやりとりが大変だから、全部ネイティブでやらせてくれぇ。

git hub載せとく

https://github.com/ricodroid404/android_webView_camera/tree/main

おわりに

AndroidのWebViewでカメラが起動できない問題、調べたら困っている人が私以外にもいたので、
動いた方法を記事にしてみました!!

参考になれば嬉しいです!

事象が解決したり、誤った解釈や情報があったら、コメントで教えていただきたいです