엑셀 버튼을 눌렀는데 아무 일도 없었다.
업무용 A/S 웹 서비스를 감싼 앱이었다. 목록 화면에서 엑셀로 내보내기를 누르면 PC 브라우저에서는 바로 파일이 떨어진다. 폰의 Chrome 으로 열어도 마찬가지다. 앱에서는 버튼이 눌린 느낌만 있고 그다음이 없었다. 오류 메시지도, 다운로드 알림도, 다운로드 폴더의 새 파일도 없었다.
TWA 시절에는 이런 일이 없었다. 화면을 그리는 게 Chrome 이었으니 다운로드도 Chrome 이 알아서 받았다. WebView 로 옮기면서 그 일을 누가 맡는지 정하지 않았던 것이다.
주소는 있는데 갈 곳이 없다
WebView 는 다운로드를 직접 하지 않는다. 페이지가 파일을 내려받으려 하면 DownloadListener 에 주소와 Content-Disposition, MIME 형식을 건네주고 끝이다. 전환 직후의 앱은 그 주소를 외부 링크처럼 Custom Tabs 로 넘기고 있었다.
엑셀 내보내기는 평범한 주소가 아니었다. 웹은 서버에서 받은 데이터나 화면에서 만든 데이터를 Blob 으로 묶고, URL.createObjectURL 로 blob:https://... 주소를 만들어 <a download> 에 꽂고, 그 링크를 스크립트로 클릭한다. 이 주소가 가리키는 건 네트워크 어딘가가 아니라 지금 이 페이지의 렌더러 메모리 안에 있는 바이트 덩어리다.
그러니 이 주소를 밖으로 넘기는 순간 의미가 없어진다. Custom Tabs 는 다른 프로세스라 그 메모리를 볼 수 없다. 시스템의 DownloadManager 는 http 와 https 만 받는다. data: 주소도 사정은 같다. 주소 문자열 안에 내용이 다 들어 있지만, 그걸 해석해서 파일로 써 줄 주체가 없다. 결국 blob 은 받을 수 있는 곳이 페이지 자신뿐이었다.
https 첨부도 안전하지 않았다. 앱의 첨부파일은 로그인한 사람만 받을 수 있는데, 로그인 쿠키는 WebView 의 쿠키 저장소에 있다. Custom Tabs 의 Chrome 은 그 쿠키를 모른다. 주소를 열면 로그인 화면이나 권한 오류가 나온다.
클릭보다 먼저 도착하는 스크립트
blob 을 페이지 안에서 읽으려면 웹이 링크를 클릭하는 순간에 끼어들어야 한다. 웹 쪽 코드를 고치지 않고 앱에서 끼어들 자리는 하나다. 페이지의 어떤 스크립트보다 먼저 실행되는 document-start 스크립트다. AndroidX WebKit 의 addDocumentStartJavaScript 로 넣으면 각 문서가 시작될 때, 웹의 스크립트가 돌기 전에 실행된다.
(function () {
if (window.__downloadHook) return; window.__downloadHook = true;
var ch = window.AppNative; if (!ch) return;
var blobs = new Map(), create = URL.createObjectURL, revoke = URL.revokeObjectURL;
URL.createObjectURL = function (o) {
var u = create.apply(URL, arguments);
if (o instanceof Blob) blobs.set(u, o);
return u;
};
URL.revokeObjectURL = function (u) {
setTimeout(function () { blobs.delete(u); }, 60000);
return revoke.apply(URL, arguments);
};
function handle(a) {
if (!a || !a.hasAttribute || !a.hasAttribute('download')) return false;
var href = a.href || '', name = a.getAttribute('download') || '';
if (href.indexOf('blob:') === 0) {
var b = blobs.get(href); if (!b) return false;
var r = new FileReader();
r.onload = function () { ch.postMessage(JSON.stringify({ type: 'data', name: name, mime: b.type || '', data: r.result })); };
r.readAsDataURL(b); return true;
}
// data: 는 그대로, https: 는 주소만 넘긴다
return false;
}
var click = HTMLAnchorElement.prototype.click;
HTMLAnchorElement.prototype.click = function () { if (handle(this)) return; return click.apply(this, arguments); };
document.addEventListener('click', function (e) {
var a = e.target && e.target.closest ? e.target.closest('a[download]') : null;
if (a && handle(a)) e.preventDefault();
}, true);
})();
그런데 내보내기 코드를 보면 a.click() 바로 다음 줄에서 URL.revokeObjectURL 을 부르는 화면이 있었다. 브라우저에서는 클릭이 이미 다운로드를 시작한 뒤라 문제가 없는 흔한 패턴이다. 내 쪽에서는 클릭을 가로챈 다음 비동기로 읽으러 가는 사이에 주소가 무효가 된다.
그래서 주소가 아니라 Blob 객체 자체를 붙잡았다. createObjectURL 을 감싸 만들어지는 모든 blob 주소와 원래 Blob 을 Map 에 넣어 둔다. revokeObjectURL 은 원래대로 불러 주되, Map 에서 지우는 건 1분 뒤로 미룬다. 주소가 무효가 돼도 Blob 객체는 내 Map 이 쥐고 있으니 FileReader 로 끝까지 읽을 수 있다.
클릭은 두 갈래로 잡는다. 스크립트가 만든 링크는 DOM 에 붙지도 않은 채 click() 만 불리는 경우가 많아서 HTMLAnchorElement.prototype.click 을 감쌌다. 사람이 진짜 링크를 누르는 경우는 캡처 단계의 click 리스너가 받는다. 어느 쪽이든 download 속성이 있는 링크만 건드리고, 내가 처리하지 못하는 링크는 원래 동작으로 돌려보낸다.
앱 도메인만 들어오는 문
읽은 내용을 앱으로 넘기는 통로는 addJavascriptInterface 가 아니라 WebMessageListener 로 만들었다. addJavascriptInterface 는 출처를 가리지 않고 모든 프레임에 객체를 꽂는다. 다운로드 통로는 파일을 기기에 쓰는 문이라 아무 프레임에나 열어 둘 수 없었다.
Set<String> origins = new HashSet<>();
origins.add("https://" + BuildConfig.HOST_NAME);
origins.add("https://*." + BuildConfig.HOST_NAME);
WebViewCompat.addWebMessageListener(webView, CHANNEL, origins, listener);
WebViewCompat.addDocumentStartJavaScript(webView, HOOK_SCRIPT, origins);
채널 객체와 훅 스크립트 모두 같은 allowlist 를 쓴다. 앱 도메인과 그 하위 도메인의 https 프레임에만 생기고, 외부 출처 iframe 에는 채널도 훅도 없다. base64 로 부푼 엑셀을 디코드하고 쓰는 일은 단일 IO 스레드가 맡는다. WebView 가 이 기능들을 지원하지 않으면 훅을 걸지 않고, 그때 blob 다운로드는 DownloadListener 에서 실패 안내 토스트로 끝난다.
다운로드 폴더는 버전마다 다르다
앱으로 넘어온 data: 문자열은 쉼표 앞의 헤더에서 MIME 과 base64 여부를 읽고 뒤쪽을 디코드한다. 파일명은 웹이 download 속성에 준 이름을 쓰되, 경로 구분자나 제어 문자는 밑줄로 바꾼다.
어디에 쓸지는 안드로이드 버전이 정한다. Android 10 이상은 MediaStore.Downloads 에 IS_PENDING 을 1 로 두고 항목을 만든 뒤, 내용을 다 쓰고 나서 0 으로 내린다. 쓰다 실패하면 항목을 지운다. 권한 없이 공용 다운로드 폴더에 들어간다. Android 9 이하는 공용 폴더에 쓰려면 저장소 권한이 필요하다. 엑셀 하나 받자고 권한을 묻기 싫어서, 권한 없이 쓸 수 있는 앱 전용 다운로드 폴더에 저장하고 같은 이름이 있으면 (1), (2) 를 붙였다.
저장이 끝나면 대화상자를 띄운다. 열기를 누르면 ACTION_VIEW 로 엑셀을 열 수 있는 앱에 넘긴다. Android 9 이하의 파일은 FileProvider 를 거친 content URI 에 읽기 권한을 붙여 준다. 처음에는 안내 문구가 하나라 구버전에서도 "다운로드 폴더에 저장했습니다"라고 나왔는데, 실제로는 파일 앱의 다운로드 폴더에 없으니 거짓말이었다. 9 이하에서는 "앱 저장 공간에 저장했습니다. 열기 버튼으로 확인할 수 있습니다"로 문구를 나눴다.
https 첨부는 다른 길로 보낸다. 앱 도메인 주소면 DownloadManager 에 맡기되, 요청에 CookieManager 에서 꺼낸 그 주소의 쿠키와 WebView 의 User-Agent 를 직접 실어 준다. 서버 입장에서는 로그인한 앱이 받는 것과 같다. 파일명은 Content-Disposition 의 filename*=UTF-8''... 를 먼저 읽는다. 한글 파일명이 깨지지 않게 하려는 것이다. 앱 도메인이 아닌 다운로드는 내 쿠키를 실어 보낼 이유가 없으니 예전처럼 Custom Tabs 로 넘긴다. 에뮬레이터에서 내보내기 방식이 다른 두 화면의 엑셀을 받아 원본과 내용이 같은 것을 확인했다.
새 창이 작업 화면을 덮었다
첨부 사진의 링크는 target="_blank" 로 새 창을 연다. 앱에는 창이 하나뿐이라, 사진을 누르면 그 사진이 지금 페이지를 덮어썼다. 쓰던 작업 화면이 사라지고, 메인 WebView 는 확대를 꺼 뒀으니 사진을 키워 볼 수도 없었다.
메인 WebView 에 setSupportMultipleWindows(true) 를 켜면 새 창 요청이 WebChromeClient.onCreateWindow 로 온다. 여기서 새 WebView 를 하나 만들어 WebViewTransport 에 실어 돌려주면, 새 창의 내용이 그 WebView 로 들어간다. 그 WebView 를 전체 화면 다이얼로그에 담은 게 PopupViewer 다. 확대를 켜고 화면 폭에 맞춰 보여 주며, 위쪽 막대에 페이지 제목과 닫기 버튼을 둔다. 뒤로가기는 뷰어 안에서 먼저 뒤로 가고, 갈 곳이 없으면 뷰어를 닫는다. 메인 페이지는 그 뒤에서 그대로 기다린다.
뷰어를 언제 띄울지가 까다로웠다. onCreateWindow 시점에는 새 창이 어디로 갈지 모른다. 그래서 뷰어 WebView 를 먼저 만들어 두고, 첫 이동을 보고 정한다. 새 창의 첫 이동은 shouldOverrideUrlLoading 을 거치지 않기도 해서 onPageStarted 에서도 같은 판단을 한다. 앱 도메인이면 그때 다이얼로그를 띄우고, 외부 도메인이면 Custom Tabs 로 넘기고 뷰어는 띄우지도 않은 채 정리한다.
새 창이 첨부파일이면 또 다르다. 첨부 링크가 _blank 로 열리면 새 창은 페이지를 그리지 않고 다운로드 응답만 받는다.
web.setDownloadListener((url, userAgent, contentDisposition, mimeType, contentLength) -> {
activity.downloads().onDownloadStart(url, userAgent, contentDisposition, mimeType);
// 다운로드만 하는 새 창이면 빈 뷰어를 남기지 않는다.
if (!committed) {
close();
}
});
activity.downloads().attach(web);
committed 는 onPageCommitVisible 에서 켜는 값이다. 화면에 한 번도 무언가 그려지지 않은 채 다운로드가 왔다면 이 창은 다운로드를 위한 창이었으니 다운로드만 넘기고 닫는다. 뷰어 WebView 에도 다운로드 훅을 똑같이 붙였다.
뷰어를 붙인 뒤에 남은 함정도 하나 있었다. 렌더러 프로세스는 메인 WebView 와 뷰어가 같이 쓴다. 렌더러가 죽으면 두 WebView 모두 onRenderProcessGone 을 받는데, 하나라도 처리하지 않으면 앱 전체가 종료된다. 메인 쪽만 복구하게 만들어 둔 상태라 뷰어가 열린 채 렌더러가 죽으면 앱이 꺼졌다. 뷰어도 이 콜백에서 자기를 닫고 true 를 돌려주게 했다.
가로로 돌아가는 요리 영상
새 창과 같은 계열의 문제를 식단서랍 앱은 동영상에서 만났다. 웹에 들어가는 YouTube 영상은 iframe 이다. 브라우저라면 전체 화면 버튼을 누를 때 브라우저가 화면을 넘겨주지만, WebView 는 전체 화면용 뷰를 onShowCustomView 로 앱에 건네기만 한다. 앱이 그 뷰를 띄우지 않으면 전체 화면은 일어나지 않는다.
@Override
public void onShowCustomView(View view, CustomViewCallback callback) {
if (customView != null) {
callback.onCustomViewHidden();
return;
}
customView = view;
customViewCallback = callback;
root.addView(view, new FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT));
setSystemBarsHidden(true);
setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_USER);
}
받은 뷰를 루트 레이아웃 맨 위에 꽉 차게 얹고, 상태 표시줄과 내비게이션 바를 숨긴다. 앱은 매니페스트에서 세로(userPortrait) 로 고정돼 있어서, 전체 화면 동안에만 방향을 USER 로 풀어 폰을 눕히면 가로로 돌게 했다. 회전 때 액티비티가 다시 만들어지면 재생 중인 영상이 끊기니 configChanges 에 orientation|screenSize 를 넣어 액티비티가 직접 받는다.
끝내는 길은 여러 개다. 영상의 전체 화면 해제 버튼, 기기의 뒤로가기, 알림을 눌러 다른 페이지로 들어오는 경우, 액티비티 종료. 이 길을 모두 hideCustomView() 하나로 모았다. 뷰를 떼고, 시스템 바를 되돌리고, 다시 세로로 묶은 뒤, WebView 에 onCustomViewHidden() 으로 끝났다고 알린다. 뒤로가기 콜백은 페이지 뒤로가기보다 전체 화면 해제를 먼저 본다. 그렇지 않으면 뒤로가기 한 번에 영상과 레시피 페이지가 같이 사라진다.
엑셀 파일, 새 창, 전체 화면은 TWA 에서는 생각할 필요도 없던 일이다. Chrome 이 화면 너머에서 조용히 해 주던 일들이었고, WebView 로 옮기자 그 하나하나가 앱이 직접 정해야 하는 결정이 됐다. 버튼을 눌렀는데 아무 일도 없었다는 건, 그 결정을 아직 아무도 하지 않았다는 뜻이었다.
다음 편에서는 화면 밖으로 나가는 길 대신, 앱이 웹에 기기의 기능을 건네주는 쪽을 이야기한다.