웹에서 파일 선택하기: onShowFileChooser()를 이용한 단일 및 복수 파일 선택 방법

galcyurio
PRND
Published in
21 min readMar 30, 2023
Photo by CHUTTERSNAP on Unsplash

이 글에서는 안드로이드에서 WebChromeClientonShowFileChooser()를 이용하여 단일 파일 선택, 복수 파일 선택을 지원하는 방법을 소개합니다.

기본으로 구현된 파일 선택 기능이 없다

HTML에서는 <input type="file"> 태그를 통해 파일을 입력받을 수 있습니다. 웹에서 <input type="file"> 태그를 클릭하면, 안드로이드에서는 내부적으로 WebChromeClient.onShowFileChooser()가 호출됩니다. 하지만 안드로이드에서는 이 동작에 대한 기본 설정을 구현하지 않아서 파일 선택을 클릭해도 제대로 동작하지 않습니다.

public boolean onShowFileChooser(WebView webView, ValueCallback<Uri[]> filePathCallback,
FileChooserParams fileChooserParams) {
return false;
}

파일 선택 이벤트 처리

웹에서 파일 선택을 눌렀을 때, 기본적으로 뜨는 화면이 없으므로 WebChromeClient.onShowFileChooser()를 이용하여 파일 선택 이벤트를 처리합니다.

파일을 선택하면, 파일 선택 클릭 토스트 메시지를 띄워보겠습니다.

class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
// ...
binding.webView.setupFileChooser()
}

private fun WebView.setupFileChooser() {
webChromeClient = PrndWebChromeClient(this@MainActivity)
}
}
class PrndWebChromeClient(
private val context: Context,
) : WebChromeClient() {
override fun onShowFileChooser(
webView: WebView?,
filePathCallback: ValueCallback<Array<Uri>>?,
fileChooserParams: FileChooserParams?
): Boolean {
Toast.makeText(context, "파일 선택 클릭", Toast.LENGTH_SHORT).show()
return super.onShowFileChooser(webView, filePathCallback, fileChooserParams)
}
}

FileChooser 화면 보여주기

이제 파일을 선택할 수 있는 FileChooser 화면을 보여줄 차례입니다. 직접 FileChooser 화면을 구현할 수도 있겠지만 Intent를 이용하면 기본 앱 또는 유저가 원하는 앱을 통해 파일을 선택하도록 만들수 있어서 파일 관련 권한이 필요하지 않습니다.

class PrndWebChromeClient(
private val context: Context,
) : WebChromeClient() {
private val fileChooser = FileChooser(context)

override fun onShowFileChooser(
webView: WebView?,
filePathCallback: ValueCallback<Array<Uri>>?,
fileChooserParams: FileChooserParams?
): Boolean {
fileChooser.show()
return super.onShowFileChooser(webView, filePathCallback, fileChooserParams)
}
}

파일 선택 화면을 띄우는 작업들은 모두 FileChooser 객체로 분리합니다.

class FileChooser(
private val context: Context,
) {
fun show() {
val chooserIntent = createChooserIntent()
context.startActivity(chooserIntent)
}

private fun createChooserIntent(): Intent {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "*/*"
val mimeTypes = arrayOf("image/*", "application/pdf")
putExtra(Intent.EXTRA_MIME_TYPES, mimeTypes)
}
return Intent.createChooser(intent, "첨부파일 선택")
}
}
  • ACTION_OPEN_DOCUMENT은 파일을 선택하고 열기 위한 Intent Action입니다.
  • CATEGORY_OPENABLE을 이용하여 해당 Intent가 전달되는 앱이 열 수 있는 파일로만 제한합니다.
  • EXTRA_MIME_TYPES으로 원하는 형태의 mimeType을 지정합니다.

인텐트는 반드시 MIME 유형을 지정해야 하며 반드시 CATEGORY_OPENABLE 카테고리를 선언해야 합니다. 해당할 경우, EXTRA_MIME_TYPES 엑스트라를 사용해서 MIME 유형의 배열을 추가하고 하나 이상의 MIME 유형을 지정할 수 있습니다 이 방법을 사용할 경우에는 setType()에서 기본 MIME 유형을 "*/*"로 설정해야 합니다.

더 자세한 내용은 공식 문서를 참조해보세요.

파일 선택 결과 가져오기

다른 앱에서 선택한 파일을 우리 앱으로 가져올 차례입니다. startActivityForResult()onActivityResult() 또는 Activity Result API를 사용해야 하지만 FileChooser에서 책임을 가져가게 하기 위해 TedOnActivityResult를 활용해보겠습니다.

별도 라이브러리를 쓰지 않는다면 Activity에 의존하게 만들어서 결과를 받아오면 됩니다.

class PrndWebChromeClient(
context: Context,
private val coroutineScope: CoroutineScope,
) : WebChromeClient() {
override fun onShowFileChooser(...): Boolean {
coroutineScope.launch {
fileChooser.show()
}
return super.onShowFileChooser(webView, filePathCallback, fileChooserParams)
}
}
class FileChooser(
private val context: Context,
) {
suspend fun show() {
val chooserIntent = createChooserIntent()

val intent: Intent? = TedOnActivityResult.with(context)
.startActivityForResult(chooserIntent)
.data

// 선택 X: null
// 선택 O: Intent { dat=content://com.android.providers.media.documents/... flg=0x43 }
Log.i("TEST", "$intent")
}
}

선택한 경우에는 intent.data에 선택한 파일의 URI가 내려오고 선택하지 않은 경우에는 intent가 null입니다. 헷갈리지 않도록 결과를 FileChooserResult와 같은 객체로 포장해주세요.

sealed interface FileChooserResult {
object Empty : FileChooserResult

data class File(
val resultCode: Int,
val data: Intent,
val uri: Uri,
) : FileChooserResult

companion object {
fun parse(
resultCode: Int,
data: Intent?,
): FileChooserResult {
if (data == null) return Empty

val singleFileChooserData = data.data
return when {
singleFileChooserData != null -> File(resultCode, data, singleFileChooserData)
else -> Empty
}
}
}
}
class FileChooser(
private val context: Context,
) {
suspend fun show() {
val chooserIntent = createChooserIntent()

val activityResult = TedOnActivityResult.with(context)
.startActivityForResult(chooserIntent)
val fileChooserResult = FileChooserResult.parse(
resultCode = activityResult.resultCode,
data = activityResult.data,
)

// 선택 X: kr.co.prnd.sample.FileChooserResult$Empty@cca43f2
// 선택 O: File(resultCode=-1, data=Intent { dat=content://com.android.providers.media.documents/... flg=0x43 }, uri=content://com.android.providers.media.documents/document/image%3A1000004727)
Log.i("TEST", "$fileChooserResult")
}
}

파일 선택 결과 처리

선택한 파일의 URI까지 가져왔으니 이제 웹뷰로 파일을 넘겨줄 차례입니다. WebChromeClient.onShowFileChooser() 함수의 filePathCallback: ValueCallback<Array<Uri>>을 활용하여 파일을 넘겨줄 수 있습니다.

파일 선택을 취소한 경우에는 filePathCallback이 null로 전달되므로 null 처리를 해주어야 합니다.

override fun onShowFileChooser(
webView: WebView?,
filePathCallback: ValueCallback<Array<Uri>>?,
fileChooserParams: FileChooserParams?
): Boolean {
if (filePathCallback == null) return false
coroutineScope.launch {
fileChooser.show(filePathCallback)
}
return true // true로 변경!
}

filePathCallback을 호출했다면 true로 반환하고 아니라면 false를 반환해야 합니다. 이 예제에서는 항상 콜백을 호출할 것이므로 true로 고정합니다.

private fun handleFileChooserResult(
fileChooserResult: FileChooserResult,
filePathCallback: ValueCallback<Array<Uri>>,
) {
when (fileChooserResult) {
FileChooserResult.Empty -> filePathCallback.onReceiveValue(null)
is FileChooserResult.File -> {
val result = WebChromeClient.FileChooserParams.parseResult(
fileChooserResult.resultCode,
fileChooserResult.data,
)
filePathCallback.onReceiveValue(result)
}
}
}
  • Empty이면 onReceiveValue(null) 호출을 통해 현재 선택된 파일을 제거합니다.
  • File이면 FileChooserParams.parseResult()를 이용하여 ValueCallback에서 원하는 타입으로 변경하고 onReceiveValue()를 호출하여 파일을 선택합니다.

권한 처리

파일이 선택되었으므로 이렇게 까지 하면 끝이라고 생각하겠지만 파일을 읽으려고 하는 순간 에러가 발생합니다. 바로 해당 content URI에 대한 권한이 없기 때문입니다.

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

<script>
const fileInput = document.getElementById('file');

fileInput.addEventListener('change', (event) => {
const fileReader = new FileReader();
fileReader.onload = (event) => {
const contents = event.target.result;
console.log("성공");
console.log(contents); // 파일 내용 출력
};

const file = event.target.files[0];
fileReader.readAsBinaryString(file);
});
</script>
Cannot open content uri: content://com.android.providers.media.documents/document/image%3A1000004727
java.lang.SecurityException: Permission Denial: reading com.android.providers.media.MediaDocumentsProvider uri content://com.android.providers.media.documents/document/image%3A1000004727 from pid=25420, uid=10015 requires that you obtain access using ACTION_OPEN_DOCUMENT or related APIs
at android.os.Parcel.createExceptionOrNull(Parcel.java:3023)
at android.os.Parcel.createException(Parcel.java:3007)
at android.os.Parcel.readException(Parcel.java:2990)
at android.database.DatabaseUtils.readExceptionFromParcel(DatabaseUtils.java:190)
at android.database.DatabaseUtils.readExceptionWithFileNotFoundExceptionFromParcel(DatabaseUtils.java:153)
at android.content.ContentProviderProxy.openTypedAssetFile(ContentProviderNative.java:780)
at android.content.ContentResolver.openTypedAssetFileDescriptor(ContentResolver.java:2034)
at android.content.ContentResolver.openAssetFileDescriptor(ContentResolver.java:1849)
at android.content.ContentResolver.openFileDescriptor(ContentResolver.java:1682)
at android.content.ContentResolver.openFileDescriptor(ContentResolver.java:1629)
at org.chromium.base.ContentUriUtils.a(chromium-TrichromeWebViewGoogle6432.aab-stable-556311534:63)
at org.chromium.base.ContentUriUtils.openContentUriForRead(chromium-TrichromeWebViewGoogle6432.aab-stable-556311534:1)

권한을 가져오려면 Intent.FLAG_GRANT_READ_URI_PERMISSION, Intent.FLAG_GRANT_WRITE_URI_PERMISSION 2개의 Intent flag를 통해 권한을 가져와야 합니다.

private fun takePersistableUriPermission(uri: Uri) {
val takeFlags =
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
context.contentResolver.takePersistableUriPermission(uri, takeFlags)
}
private fun handleFileChooserResult(...) {
when (fileChooserResult) {
is FileChooserResult.File -> {
takePersistableUriPermission(fileChooserResult.uri)
...
}
}
}

위와 같이 권한을 허용해 주면 로그에서 성공 메시지를 확인할 수 있습니다.

복수 파일 선택 처리 (선택)

지금까지는 단일 파일에 대한 선택 처리를 했지만 HTTP 표준에 따르면 multiple이 있는 경우 복수 파일을 선택할 수 있어야 합니다.

<input type="file" multiple>

HTML에서 multiple 속성을 추가하면 onShowFileChooser()fileChooserParams: FileChooserParams?로 전달됩니다. 이 값을 확인하고 multiple이면 복수 파일 처리, 아니면 단일 파일 처리하면 됩니다.

다시 처음부터 돌아가서 Intent를 만드는 것부터 시작해봅시다.

private fun createChooserIntent(fileChooserParams: FileChooserParams): Intent {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
// ...

val isMultiple = fileChooserParams.mode == FileChooserParams.MODE_OPEN_MULTIPLE
if (isMultiple) {
putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true) // 복수 파일 선택 허용
}
}
return Intent.createChooser(intent, "첨부파일 선택")
}

의도대로 여러 파일을 선택할 수 있었지만 웹으로 파일이 전달되지는 않았습니다. 그 이유는 선택한 파일이 여러개이기 때문에 intent.data로 전달되는게 아니라 다른 방식으로 전달되기 때문입니다.

multiple 모드여도 파일을 하나만 선택하면 intent.data로 전달됩니다.

이어서 파일 선택 결과 sealed interface에도 복수 파일을 나타내는 Files를 추가하겠습니다.

sealed interface FileChooserResult {
// ...

data class Files(
val uris: List<Uri>,
) : FileChooserResult
}

단일 파일이면 intent.data로 전달되지만 복수 파일이면 intent.clipData로 전달됩니다. 이 점을 참고하여 Files를 만들어봅니다.

fun parse(
resultCode: Int,
data: Intent?,
): FileChooserResult {
if (data == null) return Empty

val singleFileChooserData = data.data
val multipleFileChooserData = data.getMultipleFileChooserData()
return when {
singleFileChooserData != null -> File(resultCode, data, singleFileChooserData)
multipleFileChooserData != null -> Files(multipleFileChooserData)
else -> Empty
}
}

private fun Intent.getMultipleFileChooserData(): List<Uri>? {
val clipData = clipData ?: return null
return (0 until clipData.itemCount).map { clipData.getItemAt(it).uri }
}

이제 Files의 데이터를 웹으로 전달할 차례입니다. 복수 파일인 경우에도 마찬가지로 권한 처리가 필요합니다.

private fun handleFileChooserResult(...) {
when (fileChooserResult) {
// ...
is FileChooserResult.Files -> {
fileChooserResult.uris.forEach(::takePersistableUriPermission)
filePathCallback.onReceiveValue(fileChooserResult.uris.toTypedArray())
}
}

여기까지 처리하면 단일 파일, 복수 파일, 파일 선택 해제 처리까지 모두 정상적으로 동작합니다. 🎉

지금까지 긴 글 봐주셔서 감사합니다. 위에서 작성한 코드들은 모두 GitHub에서 커밋별로 확인하실 수 있습니다.

--

--