Search code examples
androidkotlinwebviewandroid-webview

Android WebView Handling input type "File" (File Explorer and Camera separate)


I have a webform that's fairly simple. The only people accessing the webform are using mobile devices so I built the form with mobile in use.

To make it simpler for the user, I have two file upload buttons (the HTML here isn't too important other than to explain what I'm trying to do and why I haven't been able to resolve it). One upload button is a simple upload button where the user would navigate their files. The second upload button should open up the camera straight away.
This is working fine when I open the form on a mobile browser.

<div class = "col-6-6">
    <label class = "image-label label-upload">
        <span class = "icons i-upload"></span>
        Upload
        <input name="image-upload" type = "file" accept="image/*">
    </label>
</div>
<div class = "col-6-6">
    <label class = "image-label label-upload">
        <span class = "icons i-camera"></span>
        Capture
        <input name="image-capture" type = "file" accept="image/*" capture = "environment">
    </label>
</div>

What I'm now attempting to do is simulate the exact same behavior the browser has as an App using webview.

package com.example.testapp

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


class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        supportActionBar?.hide()

        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        val myWebView: WebView = findViewById(R.id.wv)

        myWebView.loadUrl("https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/capture")

        myWebView.webViewClient = WebViewClient()
        myWebView.webChromeClient = WebChromeClient()

        myWebView.settings.javaScriptEnabled = true
        myWebView.settings.allowFileAccess = true
        myWebView.settings.allowContentAccess = true
        myWebView.settings.javaScriptCanOpenWindowsAutomatically = true
        myWebView.settings.mediaPlaybackRequiresUserGesture = false

    }
    override fun onBackPressed() {
        val myWebView: WebView = findViewById(R.id.wv)
        myWebView.webViewClient = WebViewClient()
        if (myWebView.canGoBack()) myWebView.goBack() else super.onBackPressed()
    }
}

I also have the following in my Android manifest:

<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.CAMERA2" />
<uses-permission android:name="android.permission.ACCESS_MEDIA_LOCATION"/>
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<uses-feature android:name="android.hardware.camera" android:required="true"/>

I've seen some solutions online and tried them out but they don't seem to work exactly how I'm expecting them to...
Solutions are often in Java, trying to do this in Kotlin. As for the Kotlin solutions I've tried out, they either don't give enough information (so unable to understand), it defaults to just choosing an image (no camera option), you get a pop-up menu asking if you want to use the camera or file explorer (it should know which one to used based off of the HTML use of capture), or it will have both options and the camera option doesn't work properly.

Also, should be said that all users of this app are on Android 8 and higher. So there's no need for legacy solutions.

Any and all help would be greatly appreciated. I'm trying to keep it simple, not sure if there's something already built in that can be accessed to make this happen.

Regards,

Alex


Solution

  • I figured it out! Actually took a bit of playing around with some other solutions and asking for some external help.

    Under AndroidManifest.xml I added the following inside <Application>

    <provider
        android:name="androidx.core.content.FileProvider"
        android:authorities="${applicationId}.fileprovider"
        android:exported="false"
        android:grantUriPermissions="true">
        <meta-data
            android:name="android.support.FILE_PROVIDER_PATHS"
            android:resource="@xml/file_paths" />
    </provider>
    

    Then I created a new xml file (app > res > xml), file_paths.xml

    <paths xmlns:android="http://schemas.android.com/apk/res/android">
        <external-path
            name="camera_files"
            path="DCIM/Camera" />
    </paths>
    

    And finally... my updated Kotlin file (MainActivity.kt)

    package com.example.testapp
    
    import android.Manifest
    import android.content.ContentResolver
    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.KeyEvent
    import android.webkit.ValueCallback
    import android.webkit.WebChromeClient
    import android.webkit.WebSettings
    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.text.SimpleDateFormat
    import java.util.Date
    import java.util.Locale
    
    class MainActivity : AppCompatActivity() {
        private lateinit var myWebView: WebView
        private var fileUploadCallback: ValueCallback<Array<Uri>>? = null
        private lateinit var currentPhotoUri: Uri
    
        companion object {
            private const val FILE_CHOOSER_REQUEST_CODE = 1
            private const val CAMERA_PERMISSION_REQUEST_CODE = 2
        }
    
        override fun onCreate(savedInstanceState: Bundle?) {
            super.onCreate(savedInstanceState)
            setContentView(R.layout.activity_main)
    
            myWebView = findViewById(R.id.wv)
            myWebView.loadUrl("https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/capture")
            myWebView.webViewClient = WebViewClient()
            myWebView.webChromeClient = object : WebChromeClient() {
                override fun onShowFileChooser(
                    webView: WebView?,
                    filePathCallback: ValueCallback<Array<Uri>>?,
                    fileChooserParams: FileChooserParams?
                ): Boolean {
                    fileUploadCallback?.onReceiveValue(null)
                    fileUploadCallback = filePathCallback
    
                    if (fileChooserParams?.acceptTypes?.contains("image/*") == true && fileChooserParams.isCaptureEnabled) {
                        // Launch camera
                        if (ContextCompat.checkSelfPermission(
                                this@MainActivity,
                                Manifest.permission.CAMERA
                            ) == PackageManager.PERMISSION_GRANTED
                        ) {
                            launchCamera()
                        } else {
                            ActivityCompat.requestPermissions(
                                this@MainActivity,
                                arrayOf(Manifest.permission.CAMERA),
                                CAMERA_PERMISSION_REQUEST_CODE
                            )
                        }
                    } else {
                        // Use file picker
                        val intent = Intent(Intent.ACTION_GET_CONTENT)
                        intent.addCategory(Intent.CATEGORY_OPENABLE)
                        intent.type = "image/*"
                        val chooserIntent = Intent.createChooser(intent, "Choose File")
                        startActivityForResult(chooserIntent, FILE_CHOOSER_REQUEST_CODE)
                    }
    
                    return true
                }
            }
    
            val webSettings: WebSettings = myWebView.settings
            with(webSettings) {
                javaScriptEnabled = true
                allowFileAccess = true
                allowContentAccess = true
                javaScriptCanOpenWindowsAutomatically = true
                mediaPlaybackRequiresUserGesture = false
                domStorageEnabled = true
            }
        }
    
        override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
            if (keyCode == KeyEvent.KEYCODE_BACK && myWebView.canGoBack()) {
                myWebView.goBack()
                return true
            }
            return super.onKeyDown(keyCode, event)
        }
    
        override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
            if (requestCode == FILE_CHOOSER_REQUEST_CODE) {
                if (fileUploadCallback == null) {
                    super.onActivityResult(requestCode, resultCode, data)
                    return
                }
    
                val results: Array<Uri>? = when {
                    resultCode == RESULT_OK && data?.data != null -> arrayOf(data.data!!)
                    resultCode == RESULT_OK -> arrayOf(currentPhotoUri)
                    else -> null
                }
    
                fileUploadCallback?.onReceiveValue(results)
                fileUploadCallback = null
            } else {
                super.onActivityResult(requestCode, resultCode, data)
            }
        }
    
        override fun onRequestPermissionsResult(
            requestCode: Int,
            permissions: Array<out String>,
            grantResults: IntArray
        ) {
            super.onRequestPermissionsResult(requestCode, permissions, grantResults)
            if (requestCode == CAMERA_PERMISSION_REQUEST_CODE) {
                if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
                    // Permission granted, launch camera
                    launchCamera()
                } else {
                    // Permission denied, show an error or request permission again
                    Toast.makeText(this, "Camera permission denied", Toast.LENGTH_SHORT).show()
                }
            }
        }
    
        private fun launchCamera() {
            val captureIntent = Intent(MediaStore.ACTION_IMAGE_CAPTURE)
            currentPhotoUri = createImageFileUri()
            captureIntent.putExtra(MediaStore.EXTRA_OUTPUT, currentPhotoUri)
            startActivityForResult(captureIntent, FILE_CHOOSER_REQUEST_CODE)
        }
    
        private fun createImageFileUri(): Uri {
            val fileName = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date()) + ".jpg"
            val contentValues = ContentValues().apply {
                put(MediaStore.Images.Media.DISPLAY_NAME, fileName)
                put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg")
                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
                    put(MediaStore.Images.Media.RELATIVE_PATH, Environment.DIRECTORY_DCIM)
                }
            }
            val resolver: ContentResolver = contentResolver
            val imageUri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues)
            return imageUri ?: throw RuntimeException("ImageUri is null")
        }
    }
    

    It's not as "simple" as I had hoped it would be. But it works! Also it does save the picture to the cameraroll, I believe it's also possible to save to temporary storage by using something like applicationContext.externalCacheDir.
    I hope this saves someone from having a headache, as for me. I am done with this and don't want to think about it any longer lol.

    Alex