웹에서 파일 선택하기: onShowFileChooser()를 이용한 단일 및 복수 파일 선택 방법
이 글에서는 안드로이드에서 WebChromeClient
의 onShowFileChooser()
를 이용하여 단일 파일 선택, 복수 파일 선택을 지원하는 방법을 소개합니다.
기본으로 구현된 파일 선택 기능이 없다
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())
}
}
여기까지 처리하면 단일 파일, 복수 파일, 파일 선택 해제 처리까지 모두 정상적으로 동작합니다. 🎉
저희와 함께 헤이딜러 서비스를 발전 시켜나가실 분들을 기다리고 있습니다.