하이브리드 앱 갤러리 호출 - haibeulideu aeb gaelleoli hochul

본 포스팅은 모바일 웹으로 구현했을 경우 사용 가능한 소스입니다.

혹시 하이브리드앱이나 webview 로 호출한 페이지에서 카메라나 사진첩을 연동해야 하시는 분은 아래 링크를 참고하시면 되겠습니다.

android webview 에서 카메라 호출 및 사진첩(갤러리) 호출하여 이미지 파일 업로드 하기

html5 의 속성을 이용하여 스마트폰의 카메라와 연결하는 방법이다.

스마트폰(모바일 디바이스)의 카메라와 연결하여 사진이나 동영상을 찍고 찍은 데이터를 javascript 를 이용하여 접근 및 제어가 가능하다.

http://mobilehtml5.org/

여기서 확인해보면 미디어 수준에서 접근하는 방법과 스트림 수준에서 접근하는 방법이 있다.

아래는 위 주소의 내용을 일부 발췌한 이미지이다.

하이브리드 앱 갤러리 호출 - haibeulideu aeb gaelleoli hochul

미디어 수준에서 접근하는 방법을 많은 브라우저에서 지원하는 것을 볼 수 있다.

그래서 미디어 수준에서 접근하는 방법을 사용했다.

카메라 연결하는 소스

<script>

$(function(){

$('#camera').change(function(e){

$('#pic').attr('src', URL.createObjectURL(e.target.files[0]));

});

});

</script>

<input type="file" id="camera" name="camera" capture="camera" accept="image/*" />

<br/>

<img id="pic" style="width:100%;" />

cs

동영상 촬영 연결하는 소스

<script>

$(function(){

$('#camcorder').change(function(e){

$('#mov').attr('src', URL.createObjectURL(e.target.files[0]));

});

});

</script>

<input type="file" id="camcorder" name="camcorder" capture="camcorder" accept="video/*" />

<br/>

<video id="mov" width="100%" autoplay="yes" controls="true" ></video>

cs

두 소스의 설명

4# : e.target.files[0] 는 파일 객체이다. URL.createObjectURL 은 파일 객체 또는 Blob 객체의 URL 를 생성한다. 생성된 URL은 바로 img 태그나 video 태그에 넣어주면 확인이 가능하다.

<input type="file" id="camera" name="camera">

cs

이렇게만 작성한다면 갤러리(사진첩)도 볼 수 있다...........

일반적으로 사용 후에 URL.revokeObjectURL() 을 호출하지 않아도 브라우저가 unloaded 될 때 자동으로 해제된다. 필요시에 따라 설정하면 될 것 같다.

URL.createObjectURL()의 데스크탑과 모바일의 브라우저별 호환성은 다음과 같다.

ps. file 업로드는 기존 방식과 다른 점이 없었다. 필자는 spring CommonsMultipartResolver 를 이용하여 모바일에서도 파일을 업로드했다.

참고 :

http://mobilehtml5.org/

https://developer.mozilla.org/ko/docs/Web/API/URL/createObjectURL

코딩하는 일용직 노동자

안드로이드

안드로이드 WebView에서 카메라/사진 갤러리 이미지 업로드 하기

bacass 2020. 6. 22. 10:49

# 들어가며
하이브리드 앱에서 웹뷰로 열린 웹문서에 <input type="file">태그가 있습니다.
사진을 첨부하기 위한 기능입니다. iOS는 앱에서 별도의 처리가 없어도 사진을 선택하면 웹으로 사진이 잘 등록됩니다만, 안드로이드에서는 사진을 선택해도 웹으로 등록이 안됩니다.
이번 포스팅에서는 <input type="file"> 태그에 카메라 or 사진 갤러리를 표시하고 사진촬영이나 이미지 선택후 웹에 이미지를 넘겨주는 처리를 알려드리겠습니다.

하이브리드 앱 갤러리 호출 - haibeulideu aeb gaelleoli hochul
+ 를 눌러 이미지를 등록하는 화면

우선 WebChromeClient() 를 상속받은 커스텀 클래스를 만들고 아래의 함수를 오버라이드 해줍니다.

class CustomWebChromeClient(val activity: AppCompatActivity) : WebChromeClient() {

    var filePathCallbackLollipop: ValueCallback<Array<Uri>>? = null

    ...

    // For Android 5.0+
    @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
    override fun onShowFileChooser(
        webView: WebView?, 
        filePathCallback: ValueCallback<Array<Uri>>?,
        fileChooserParams: FileChooserParams
    ): Boolean {
        // Callback 초기화 (중요!)
        if (filePathCallbackLollipop != null) {
            filePathCallbackLollipop?.onReceiveValue(null)
            filePathCallbackLollipop = null
        }
        filePathCallbackLollipop = filePathCallback
        val isCapture = fileChooserParams.isCaptureEnabled

        if (activity is IImageHandler) {
            activity.takePicture(filePathCallbackLollipop)
        }


        filePathCallbackLollipop = null
        return true
    }
}

자! 여기서 중요한 것이 바로 filePathCallback 입니다.
input type 태그가 작동되면 카메라로 사진을 찍든, 이미지를 선택을 하든.. 
결과 이미지를 filePathCallback 을 통해서 웹으로 돌려주는 것입니다.

input type 태그에서 capture="camera" 가 있는 경우와 없는 경우가 있습니다.
둘의 차이는 카메라 촬영을 할지 안할지를 정하는 옵션입니다.

<input type="file" capture="camera"> // isCaptureEnabled 이 true로 리턴됩니다.
<input type="file"> // isCaptureEnabled 이 false로 리턴됩니다.

이번 포스팅에서는 카메라 촬영도 함께 포함한 예제를 중심으로 진행하겠습니다.

카메라로 사진을 찍는 처리나 이미지를 선택하는 처리는 이미 다양한 자료나 라이브러리들이 있습니다.
여기서는 카메라는 Intent로 호출해서 촬영을 한 후 리턴하는 처리를 보여드리겠습니다.
그리고 이미지를 선택하는 처리는 ImagePicker라는 별도의 라이브러리를 이용하겠습니다.

dependencies {
    ...
    // ImagePicker
    implementation 'com.github.nguyenhoanglam:ImagePicker:1.3.3'
}

CustomWebChromeClient의 onShowFileChooser 함수가 호출되었으니 이제 takePicture()를 호출해서 웹뷰가 있는 액티비티로 이벤트를 넘기겠습니다.
여기서는 IImageHandler 를 만들어 이용했습니다.
WebViewActivity 는 IImageHandler를 implements 한 상태이고 커스텀 웹뷰와 CustomWebChromeClient에 activity를 넘겨줬으니 IImageHandler를 이용할 수 있습니다.

interface IImageHandler {
    fun takePicture(callBack: ValueCallback<Array<Uri>>?)

    fun uploadImageOnPage(resultCode: Int, intent: Intent?)
}


class WebViewActivity : AppCompatActivity(), IImageHandler {

    private val CAPTURE_CAMERA_RESULT = 3089
    private var filePathCallbackLollipop: ValueCallback<Array<Uri>>? = null

    ...

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

        when(requestCode){
            CAPTURE_CAMERA_RESULT -> {
                onCaptureImageResult(intent)
            }
            Config.RC_PICK_IMAGES -> {
                if (intent != null) {
                    val images: ArrayList<Image> = intent.getParcelableArrayListExtra(Config.EXTRA_IMAGES)
                    var data = Intent().apply {
                        data = Utils.getImageContentUri(this@WebViewActivity, images[0].path)
                    }
                    uploadImageOnPage(resultCode, data)

                } else {
                    /**
                     * 만약 사진촬영이나 선택을 하던중 취소할경우 filePathCallbackLollipop 을 null 로 해줘야
                     * 웹에서 사진첨부를 눌렀을때 이벤트를 다시 받을 수 있다.
                     */
                    filePathCallbackLollipop?.onReceiveValue(null)
                    filePathCallbackLollipop = null
                }
            }
        }
    }

    /**
     * <input type="file"> 태그가 호출되면 IImageHandler를 통해 이 함수가 호출된다.
     */
    override fun takePicture(callBack: ValueCallback<Array<Uri>>?) {
        filePathCallbackLollipop = callBack

        showSelectCameraOrImage()
    }

    /**
     * 카메라 / 갤러리 선택 팝업을 표시한다. 
     */
    private fun showSelectCameraOrImage() {
        CameraOrImageSelectDialog(object: CameraOrImageSelectDialog.OnClickSelectListener {
            override fun onClickCamera() {
                cameraIntent()
            }

            override fun onClickImage() {
                galleryIntent()
            }

        }).show(supportFragmentManager, "CameraOrImageSelectDialog")
    }

    /**
     * 카메라 작동
     */
    private fun cameraIntent() {
        val intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE)
        startActivityForResult(intent, CAPTURE_CAMERA_RESULT)
    }

    /**
     * 이미지 선택
     */
    private fun galleryIntent() {
        ImagePicker.with(this).run {
            setToolbarColor("#FFFFFF")
            setStatusBarColor("#FFFFFF")
            setToolbarTextColor("#000000")
            setToolbarIconColor("#000000")
            setProgressBarColor("#FFC300")
            setBackgroundColor("#FFFFFF")
            setCameraOnly(false)
            setMultipleMode(false)
            setFolderMode(true)
            setShowCamera(false)
            setFolderTitle(getString(R.string.select_image))
            setDoneTitle(getString(R.string.select))
            setKeepScreenOn(true)
            start()
        }
    }

    /**
     * 카메라 작동후 전달된 인텐트를 받는다.
     */
    private fun onCaptureImageResult(data: Intent?) {
        if (data == null) {
            /**
             * 만약 사진촬영이나 선택을 하던중 취소할경우 filePathCallbackLollipop 을 null 로 해줘야
             * 웹에서 사진첨부를 눌렀을때 이벤트를 다시 받을 수 있다.
             */
            filePathCallbackLollipop?.onReceiveValue(null)
            filePathCallbackLollipop = null

            return
        }
        val thumbnail = data.extras!!.get("data") as Bitmap
        saveImage(thumbnail)
    }

    /**
     * 비트맵을 로컬에 물리적 이미지 파일로 저장시킨다.
     */
    private fun saveImage(bitmap: Bitmap) {

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

        // create a directory if it doesn't already exist
        val photoDirectory = File(getExternalStorageDirectory().absolutePath + "/cameraphoto/")
        if (!photoDirectory.exists()) {
            photoDirectory.mkdirs()
        }
        val imgFile = File(photoDirectory,   "${System.currentTimeMillis()}.jpg")
        val fo: FileOutputStream
        try {
            imgFile.createNewFile()
            fo = FileOutputStream(imgFile)
            fo.write(bytes.toByteArray())
            fo.close()
        } catch (e: FileNotFoundException) {
            e.printStackTrace()
        } catch (e: IOException) {
            e.printStackTrace()
        }

        uploadImageOnPage(Activity.RESULT_OK, Intent().apply {
            data = imgFile.toUri()
        })

    }

    /**
     * 이미지를 웹뷰로 리턴시켜준다.
     */
    override fun uploadImageOnPage(resultCode: Int, intent: Intent?) {
        if (resultCode == Activity.RESULT_OK) {
            if (intent != null) {
                filePathCallbackLollipop?.onReceiveValue(
                    WebChromeClient.FileChooserParams.parseResult(Activity.RESULT_OK, intent)
                )
                filePathCallbackLollipop = null

            }
        } else {
            /**
             * 만약 사진촬영이나 선택을 하던중 취소할경우 filePathCallbackLollipop 을 null 로 해줘야
             * 웹에서 사진첨부를 눌렀을때 이벤트를 다시 받을 수 있다.
             */
            filePathCallbackLollipop?.onReceiveValue(null)
            filePathCallbackLollipop = null
        }
    }
}

웹뷰액티비티에서 takePicture()가 호출되면 카메라/갤러리 선택 팝업을 보여주겠습니다.
커스텀 팝업을 만들겠습니다.

하이브리드 앱 갤러리 호출 - haibeulideu aeb gaelleoli hochul
카메라/갤러리 선택 팝업
class CameraOrImageSelectDialog(private val listener: OnClickSelectListener) : DialogFragment() {

    interface OnClickSelectListener {
        fun onClickCamera()
        fun onClickImage()
    }

    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        val view = inflater.inflate(R.layout.dialog_camera_image_select, container, false)

        return view
    }

    override fun onStart() {
        super.onStart()

        if (dialog != null) {
            val width = ViewGroup.LayoutParams.MATCH_PARENT
            val height = ViewGroup.LayoutParams.WRAP_CONTENT
            dialog!!.window!!.setLayout(width, height)
            dialog!!.window!!.setGravity(Gravity.BOTTOM)
            dialog!!.window!!.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT))

            dialog!!.setCancelable(false)
        }

    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        llSelectCamera.setOnClickListener {
            listener.onClickCamera()
            dismiss()
        }

        llSelectImage.setOnClickListener {
            listener.onClickImage()
            dismiss()
        }
    }
}

팝업에서 카메라를 선택하면 cameraIntent() 함수를 호출합니다.
카메라로 사진을 찍으면 onActivityResult() 로 받은후 
onCaptureImageResult() 와 saveImage() 함수를 거쳐 이미지가 저장됩니다.
그리고 uploadImageOnPage() 함수에서 웹으로 사진을 리턴해줍니다.

하이브리드 앱 갤러리 호출 - haibeulideu aeb gaelleoli hochul
카메라를 이용한 사진 촬영

팝업에서 갤러리를 선택하면 galleryIntent() 함수를 호출합니다.
ImagePicker 를 통해 이미지를 선택하면 onActivityResult() 로 받은후 
uploadImageOnPage() 함수에서 웹으로 사진을 리턴해줍니다.

하이브리드 앱 갤러리 호출 - haibeulideu aeb gaelleoli hochul
ImagePicker를 이용해 업로드한 사진을 선택한다.
하이브리드 앱 갤러리 호출 - haibeulideu aeb gaelleoli hochul
선택한 이미지와 촬영한 사진이 정상적으로 업로드 되었다.

# 마치며
촬영된 사진을 파일로 만들거나 이미지를 선택하는 부분은 앞으로 수정될 가능성이 높습니다.
Android 10(Q) 부터는 기존의 저장공간과는 다른 개념으로 바뀌기 때문입니다.
자세한 내용은 여기에서 확인하시기 바랍니다.