사진 올리기 버튼을 눌렀는데 아무 일도 일어나지 않았다.
웹에서는 늘 되던 버튼이다. 레시피에 사진을 붙이는 <input type="file" accept="image/*"> 하나. 브라우저로 열면 갤러리와 카메라를 고르는 창이 뜨고, TWA 로 감싼 앱에서도 똑같이 떴다. 그런데 WebView 에 주소만 넣고 띄운 화면에서는 버튼이 눌리는 느낌만 있고 창이 뜨지 않는다.
TWA 는 앱 안에 Chrome 을 띄우는 방식이었다. 주소창만 숨겼을 뿐 그 안은 Chrome 이었고, 파일 선택 창도, 링크를 어디서 열지도, 위치 권한을 묻는 창도 전부 Chrome 이 알아서 했다. WebView 는 그렇지 않다. HTML 을 그리고 스크립트를 돌리는 데까지가 자기 일이고, 그 밖에서 무언가를 해야 하는 순간이 오면 앱에게 묻는다. 앱이 대답하지 않으면 아무 일도 없다. 사진 버튼은 그 질문에 대답하는 코드가 아직 없다는 첫 신호였다.
이 링크는 누가 여는가
가장 먼저 대답해야 하는 질문은 링크였다. 페이지 안에서 링크를 누를 때마다 WebView 는 WebViewClient.shouldOverrideUrlLoading 을 부른다. false 를 돌려주면 자기가 열고, true 를 돌려주면 앱이 처리했다는 뜻이다.
내 기준은 단순했다. 앱 도메인과 그 하위 도메인이면 WebView 가 연다. 그 밖의 http(s) 는 Chrome Custom Tabs 로 넘긴다. 외부 사이트를 앱 안에서 그대로 열면 앱 도메인을 위해 붙여 둔 것들, 이를테면 사용자 에이전트 뒤에 붙인 앱 식별자나 쿠키 설정이 남의 사이트에도 따라간다. Custom Tabs 는 툴바 색만 앱에 맞춘 Chrome 화면이 위에 뜨고, 닫으면 하던 화면으로 돌아온다.
window.open() 으로 생기는 about:blank 는 그냥 삼킨다. 밖으로 넘길 내용이 없다. tel:, mailto: 같은 나머지 스킴은 ACTION_VIEW 인텐트로 던져서 전화 앱이나 메일 앱이 받게 했다.
까다로운 건 intent:// 였다. 한국 사이트에서 카카오나 결제 앱을 부를 때 흔히 쓰는 형식이다. 주소 안에 패키지 이름과 browser_fallback_url 이 들어 있다. Chrome 은 이걸 읽어 앱을 띄우고, 앱이 없으면 대체 주소나 Play 스토어로 보낸다. 이제 그 순서를 내가 써야 했다.
Intent intent = Intent.parseUri(uri.toString(), Intent.URI_INTENT_SCHEME);
// 외부 페이지가 앱 내부 컴포넌트를 직접 지정하지 못하도록 브라우저와 같은 방식으로 제한한다.
intent.addCategory(Intent.CATEGORY_BROWSABLE);
intent.setComponent(null);
intent.setSelector(null);
try {
startActivity(intent);
return;
} catch (ActivityNotFoundException ignored) {
// 대상 앱 미설치 → fallback URL 또는 마켓
}
String fallback = intent.getStringExtra("browser_fallback_url");
if (fallback != null && !fallback.isEmpty()) {
openExternally(Uri.parse(fallback));
return;
}
// 대체 주소도 없으면 패키지 이름으로 Play 스토어를 연다
가운데 세 줄이 중요하다. intent:// 주소는 웹 페이지가 쓰는 문자열이고, 그 안에 컴포넌트 이름을 직접 적을 수 있다. 그대로 실행하면 웹 페이지가 내 앱의 내보내지 않은 액티비티까지 지목할 수 있다. 브라우저는 BROWSABLE 카테고리를 붙이고 컴포넌트와 셀렉터를 지워서 이 길을 막는다. 같은 방식으로 막았다. 대체 주소는 다시 openExternally 로 들어가 Custom Tabs 로 열린다.
http 로 적힌 우리 주소
링크 규칙은 두 번째 앱, 업무용 A/S 웹 서비스 앱으로 옮기면서 한 번 더 다듬었다. 페이지나 메일에 http:// 로 적힌 앱 도메인 주소가 들어올 수 있다.
targetSdk 가 28 을 넘으면 앱은 기본으로 평문 http 통신을 막는다. 그 주소를 WebView 가 그대로 열면 화면에는 오류만 뜬다. 그렇다고 http 를 허용하는 건 거꾸로 가는 길이다. 그래서 앱 도메인 판정은 https 만 인정하게 좁히고, http 로 들어온 앱 도메인 주소는 스킴만 https 로 바꿔 다시 연다. 다른 사이트의 http 주소는 손대지 않고 Custom Tabs 에 맡긴다.
파일 선택 창은 내가 띄운다
다시 사진 버튼으로 돌아간다. <input type="file"> 을 누르면 WebView 는 WebChromeClient.onShowFileChooser 를 부른다. 기본 구현은 false 를 돌려주고 끝이다. 버튼이 대답하지 않은 이유가 이것이었다.
이 메서드를 구현하면 WebView 가 콜백 하나를 건넨다. 앱이 원하는 방식으로 파일을 고르게 한 뒤 고른 파일의 Uri 배열을 콜백에 돌려주면, 웹 쪽 input 에 파일이 들어간다. 취소했으면 null 을 돌려줘야 한다. 앞선 요청이 끝나지 않은 채 남지 않도록, 새 요청이 오면 이전 콜백부터 null 로 닫고 시작한다.
선택 창은 ACTION_GET_CONTENT 인텐트로 만든다. 문제는 웹이 accept 에 무엇을 적느냐였다. 처음에는 image/ 로 시작하는 값이 하나라도 있으면 이미지 선택기로, 아니면 accept 값을 그대로 MIME 목록에 넣었다. 이 생각은 두 군데서 틀렸다. accept=".pdf" 나 accept=".xlsx" 처럼 확장자를 적는 페이지가 있는데, Android 의 파일 선택기는 확장자를 모른다. MIME 만 안다. 그리고 image/*,video/* 처럼 섞어 적으면 이미지 쪽으로 판정돼 동영상을 고를 수 없었다. getAcceptTypes() 의 항목 하나에 쉼표로 묶인 값이 통째로 들어올 수도 있다.
for (String raw : acceptTypes) {
for (String item : raw.split(",")) {
String t = item.trim().toLowerCase(Locale.ROOT);
String mime = null;
if (t.startsWith(".")) {
mime = MimeTypeMap.getSingleton().getMimeTypeFromExtension(t.substring(1));
} else if (t.contains("/")) {
mime = t;
}
if (mime != null && !out.contains(mime)) {
out.add(mime);
}
}
}
쉼표로 한 번 더 쪼개고, 점으로 시작하면 MimeTypeMap 으로 MIME 을 찾는다. 모르는 값은 버린다. 이렇게 얻은 목록이 하나면 setType 에 그대로 넣고, 여럿이면 타입은 */* 로 열어 둔 채 EXTRA_MIME_TYPES 에 목록을 싣는다. 이미지와 PDF 가 섞인 accept 라면 둘 다 고를 수 있어야 한다. 목록에 이미지가 하나라도 있으면 그때만 카메라 촬영을 선택지에 끼워 넣는다.
카메라는 선택지에 없었다
선택 창은 뜨게 됐다. 갤러리도 있고 파일 앱도 있었다. 카메라만 없었다.
코드는 카메라 인텐트를 만들기 전에 camera.resolveActivity(getPackageManager()) 가 null 이면 카메라를 빼도록 되어 있었다. 카메라 앱이 없는 기기에서 빈 선택지를 보여 주지 않으려는 방어였다. 이 방어가 카메라가 멀쩡히 있는 폰에서도 카메라를 빼고 있었다.
Android 11 부터 앱은 기기에 설치된 다른 앱을 마음대로 볼 수 없다. 패키지 가시성이라는 규칙이다. resolveActivity 나 queryIntentActivities 는 내 앱에게 보이는 앱 안에서만 답을 찾는다. 기본 카메라 앱이 있어도 내 앱에게는 보이지 않으니 "처리할 앱 없음"이 된다. 이 판정은 두 번째 앱에 구조를 옮기면서 선택지가 비어 있는 걸 보고 찾았고, 같은 수정을 양쪽에 넣었다.
<queries>
<intent>
<action android:name="android.media.action.IMAGE_CAPTURE" />
</intent>
<intent>
<action android:name="android.support.customtabs.action.CustomTabsService" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" />
</intent>
</queries>
매니페스트에 "이런 일을 하는 앱은 보여 달라"고 적는 것이다. 촬영 인텐트를 받는 앱, 그리고 Custom Tabs 를 지원하는 브라우저와 https 를 여는 브라우저. Custom Tabs 도 결국 기기의 브라우저를 찾아야 하니 함께 넣었다. TWA 시절에는 이 목록이 필요 없었다. 촬영 인텐트를 찾는 쪽이 Chrome 이었으니까.
찍은 사진을 어디에 둘 것인가
카메라 앱에 촬영을 맡길 때는 사진을 쓸 자리를 EXTRA_OUTPUT 으로 알려 준다. 그 자리는 내 앱의 저장 공간이고, 카메라 앱은 남의 앱이다. 파일 경로를 그대로 넘기면 Android 7 부터는 FileUriExposedException 으로 막힌다. 그래서 FileProvider 가 필요하다. 내 앱의 파일 하나를 content:// 주소로 감싸 정해진 권한만 빌려주는 장치다.
어디를 빌려줄 수 있는지는 res/xml/filepaths.xml 에 적는다. 앱 캐시 아래 camera/ 폴더 하나뿐이다. TWA 시절 이 파일에는 스플래시 이미지 경로가 적혀 있었는데, 그 자리를 촬영 폴더가 넘겨받았다.
권한을 넘기는 데서 한 번 더 걸렸다. 인텐트에 읽기·쓰기 권한 플래그를 붙여도, 선택 창(createChooser)의 초기 선택지로 끼워 넣은 인텐트에는 그 플래그가 전달되지 않는 기기가 있다. 카메라 앱은 받은 주소에 쓰지 못하고 촬영은 실패한다. queryIntentActivities 로 촬영 앱 목록을 받아 앱마다 grantUriPermission 으로 직접 권한을 줬다. 이 목록을 받을 수 있는 것도 <queries> 덕분이다.
촬영이 끝나면 또 하나의 함정이 있다. EXTRA_OUTPUT 을 준 카메라 앱은 결과 인텐트에 아무것도 싣지 않고 돌아온다. 사진은 이미 내가 알려 준 자리에 있으니까. 결과 인텐트만 보고 파일을 찾으면 "아무것도 고르지 않음"이 된다. 그래서 카메라 인텐트를 만들 때 그 Uri 를 기억해 두고, 결과가 비어 있으면 기억해 둔 Uri 를 웹에 돌려준다.
캐시 폴더에 쌓이는 원본도 정리해야 했다. 업로드가 끝나면 원본은 필요 없지만, 웹이 파일을 다 읽었는지 앱에 알려 주는 신호는 없다. 그래서 시간을 기준으로 삼았다. 앱이 시작할 때 별도 스레드에서 camera/ 폴더를 훑어 하루 지난 파일을 지운다.
마지막은 A/S 앱의 현장 화면에서 나온 요구였다. 그 화면에는 "카메라" 버튼이 따로 있고, 웹은 <input type="file" accept="image/*" capture> 로 그 버튼을 만든다. capture 는 갤러리를 묻지 말고 바로 찍으라는 뜻인데, 앱에서는 매번 선택 창이 먼저 떴다.
if (params.isCaptureEnabled() && onlyImages) {
Intent camera = buildCameraIntent();
if (camera != null) {
try {
fileChooserLauncher.launch(camera);
return true;
} catch (ActivityNotFoundException e) {
pendingCameraUri = null;
// 아래 일반 선택기로 대체
}
}
}
FileChooserParams.isCaptureEnabled() 가 웹의 capture 속성을 그대로 알려 준다. 이미지 전용 입력이고 capture 가 붙어 있으면 선택 창을 건너뛰고 카메라 인텐트를 바로 띄운다. 카메라를 못 띄우면 일반 선택 창으로 내려간다. multiple 이 붙은 사진 첨부 입력은 그대로 선택 창을 거친다.
위치를 묻는 것도 이제 앱이다
A/S 앱에는 현장에서 처리 내용을 저장할 때 위치 좌표를 함께 남기는 화면이 있다. 웹은 navigator.geolocation 을 부를 뿐이다. TWA 시절에는 매니페스트에 위치 권한만 선언해 두면 Chrome 이 권한 창을 띄우고 좌표를 넘겼다.
WebView 에서는 두 가지를 해야 한다. 설정에서 setGeolocationEnabled(true) 를 켜고, 웹이 위치를 요청할 때 불리는 onGeolocationPermissionsShowPrompt 에 대답한다. 순서는 이렇다. 요청한 출처가 앱 도메인이 아니면 거절한다. 앱에 이미 위치 권한이 있으면 바로 허용한다. 없으면 콜백을 잠시 들고 있다가 Android 런타임 권한을 요청하고, 사람이 고른 결과를 콜백에 돌려준다. 거절당하면 웹은 권한 거부 응답을 받고 좌표 없이 저장을 이어 간다.
여기서 한 번 크게 막혔다. 콜백의 세 번째 인자 retain 은 이 결정을 기억할지 묻는다. 거절을 기억시키지 않으려고 false 로 돌려줬더니, 웹 엔진이 같은 요청을 곧바로 다시 물었다. 요청과 거절이 끝없이 되풀이되는 사이 웹은 끝내 응답을 받지 못했다. 거절은 retain 을 true 로 돌려줘 그 실행 동안 기억시키는 것으로 끊었다. 대신 그 기억을 지우는 때를 정해 뒀다. 앱이 시작할 때, 그리고 설정 화면에서 권한을 허용하고 돌아왔을 때 GeolocationPermissions.clearAll() 을 부른다.
사진 버튼 하나에서 시작해 링크, 파일, 카메라, 위치까지 왔다. 하나같이 TWA 에서는 존재조차 몰랐던 코드다. Chrome 이 말없이 내려 주던 판단을 이제 앱이 하나씩 직접 내린다.
앱이 대신해야 할 일은 반대 방향에도 있었다. 다음 편은 웹이 만든 파일을 폰으로 내려받는 길 이야기다.