
Начиная с июня 2023 года мы стали получать жалобы от пользователей о том, что у них не отображаются письма в Android-клиенте Почты Mail.ru. В ходе исследования мы даже приглашали пользователя к нам в офис для отладки. В конце концов мы поняли, что проблема на стороне WebView, компонента, с помощью которого мы можем отображать веб страницы. Ни для кого не секрет, что WebView используется во многих банковских и почтовых клиентах, в приложениях интернет-магазинов, сервисов доставки и многих других. Также изучили другие почтовые сервисы, нам хотелось понять, как они с этим справились. Оказалось — никак :)
Обнаружение проблемы
Сначала мы реализовали простой способ обнаружения проблемы:
class JSCheckWorksInterface(private val successCallback: () -> Unit) { @Keep @JavascriptInterface fun webViewWorks() { successCallback() } } val jsInterface = JSCheckWorksInterface { // код выполнится, если WebView работает :) } webView.addJavascriptInterface(jsInterface, "CheckWebViewWorksBridge") webView.loadUrl("javascript: void CheckWebViewWorksBridge.webViewWorks();")
Здесь мы ожидаем коллбек из JsBridge, и если не получаем его в течение определённого таймаута, то считаем, что с WebView что-то не так. Затем мы добавили в приложение диалог с инструкцией, как пользователю самостоятельно починить WebView. Но, во-первых, даже с инструкцией не все пользователи понимали, что нужно делать, а во-вторых, это помогало лишь временно.
Альтернативные методы чтения писем
Судя по ответу на issue, Google о проблеме давно знает и решать её не собирается. Дело в том, то с ней сталкивается лишь очень небольшая доля пользователей некоторых версий Android. Мы точно знаем про Android 10 и единичные случаи на Android 14. Вот только нашим почтовым клиентом пользуются десятки миллионов людей, и, по результатам нашей аналитики, с этой проблемой ежедневно сталкиваются десятки тысяч пользователей. Мы понимали, что это далеко не последняя проблема, связанная с WebView и хотелось бы защититься от потенциального повторения подобного. Тогда мы решили, что хотим иметь не зависимое от WebView решение для отображения писем, которое мы со своей стороны сможем контролировать и тем самым обеспечить бесперебойный доступ пользователей к критически важному функциональности — чтению писем.
Какие варианты мы рассмотрели:
Просмотр писем в виде PDF;
Просмотр писем в виде текста в обычной TextView;
Сделать свой WebView на основе Chromium;
Интегрировать GeckoView.
Мы остановились на последнем. Gecko — это браузерный движок, разработанный в Mozilla. GeckoView — это как бы «обёртка» над Gecko, оформленная в виде отдельной библиотеки. И, так как GeckoView весит немало, было решено попытаться удалить из неё всё, что не нужно, пересобрать и распространять её точечно для пользователей со сломанным WebView. А сделать это можно только с помощью Dynamic Feature Delivery. Этот инструмент позволяет выносить модули приложения из основного APK и доставлять их пользователям, например, когда они хотят воспользоваться нашей фичей, и удалять эти модули, если они уже не нужны. Благодаря этому даже тяжёлый GeckoView не повлияет на размер основного APK. Здесь мы расскажем про самые неочевидные проблемы, с которыми мы столкнулись при работе с самим GeckoView и при его интеграции в Dynamic Feature Delivery.
Неочевидные нюансы
Сначала мы вынесли работу с WebView в чтении писем под интерфейс и сделали альтернативную реализацию для GeckoView. Самая первая проблема, с которой мы столкнулись — неочевидное падение в базе данных при попытке создания GeckoView. Казалось бы, как связан кеш писем, и рендер HTML‑страниц в GeckoView? Дело в том, что GeckoView для своей работы создаёт отдельные процессы нашего приложения, в которых, разумеется, вызывается Application.onCreate(). Как известно, в Application.onCreate(), зачастую, происходит инициализация многих компонентов приложения, некоторые из которых строго завязаны на главный процесс приложения. Решение простое: проверяем, в каком процессе мы находимся, и в зависимости от этого решаем, нужно ли нам инициализировать все наши компоненты:
override fun onCreate() { super.onCreate() if (!isOnMainProcess(this)) { return } // set up applcation } fun isOnMainProcess(context: Context): Boolean { val runningAppProcesses = getActivityManager(context).runningAppProcesses val myPid = Process.myPid() val packageName = context.packageName return if (runningAppProcesses.isNullOrEmpty()) { val processName = getProcessName() if (processName.isNullOrEmpty()) { true } else { processName == packageName } } else { runningAppProcesses.any { val isCurrentProcess = it.pid == myPid val isMainProcessName = it.processName == packageName isCurrentProcess && isMainProcessName } } }
Следующими нашими граблями стало падение при инициализации нативных библиотек. Оказалось, что очень важен правильный порядок инициализации:
fun loadNativeLibraries(context: Context) { loadLibrary(context, "freebl3") loadLibrary(context, "ipcclientcerts") loadLibrary(context, "lgpllibs") loadLibrary(context, "mozavcodec") loadLibrary(context, "mozavutil") loadLibrary(context, "mozglue") loadLibrary(context, "nss3") loadLibrary(context, "nssckbi") loadLibrary(context, "plugin-container") loadLibrary(context, "softokn3") loadLibrary(context, "xul") } private fun loadLibrary(context: Context, libName: String) { try { SplitInstallHelper.loadLibrary(context, libName) } catch (e: UnsatisfiedLinkError) { Log.e(TAG, "Native libraries not loaded", e) } }
Далее, мы заметили, что при использовании Dynamic Feature у нас дублируются сервисы GeckoView в манифесте и GeckoView считало, что может запускать вдвое больше сервисов, чем на самом деле. В итоге мы ловили падение с вероятностью 50/50. Пришлось зафиксировать количество сервисов в коде GeckoView:
public static int getServiceCount( @NonNull final Context context, @NonNull final GeckoProcessType type ) { if (type == GeckoProcessType.CONTENT) { return 30; }
Разумеется, GeckoView не столь популярное решение, поэтому далеко не на всех ��стройствах оно работало одинаково. Где-то всё было хорошо, а где-то — много различных артефактов. Стабилизировать работу GeckoView нам помог метод setViewBackend(BACKEND_TEXTURE_VIEW) у GeckoView. Теперь всё работает одинаково и предсказуемо на всех устройствах. Одним из важных факторов при работе с движком отображения писем для нас было наличие API, позволяющего контролировать и переопределять сетевые запросы. Например, при загрузке картинок, встроенных в письмо, мы должны прокидывать различные дополнительные параметры. В WebViewClient для этого есть метод shouldInterceptRequest(). С его помощью мы можем контролировать сетевые запросы в нативном коде и делать с ними что угодно, даже, например, кешировать картинки. В GeckoView нам не удалось найти такой же простой способ управления запросами, поэтому, покопавшись в документации, мы решили приспособить для наших целей механизм WebExtensions. К сожалению, для этого пришлось немного потрогать JavaScript:
let port = browser.runtime.connectNative("InterceptorApp"); let customParam = null; function onBeforeRequest(requestDetails) { let redirectUrl; try { redirectUrl = new URL(requestDetails.url); } catch { return; } if (!!customParam && !redirectUrl.searchParams.get("customParam")) { redirectUrl.searchParams.append("customParam", customParam); // return обязательно внутри этого "if", иначе будем бесконечно попадать в onBeforeRequest return { redirectUrl: redirectUrl.href }; } } port.onMessage.addListener(response => { customParam = response.customParam; }); browser.webRequest.onBeforeRequest.addListener( onBeforeRequest, { urls: ["<all_urls>"] }, ["blocking"] );
Подключение к WebExtension занимает несколько секунд. Поэтому мы загружаем веб-страницу только тогда, когда порт взаимодействия с WebExtension становится доступен. Так мы гарантируем, что наши дополнительные параметры обязательно попадут в запрос, хотя придётся немного подождать.
class GeckoExtensionPortHolder { var port: Port? = null set(value) { field = value onPortAvailableListeners.forEach { it.invoke(field) } onPortAvailableListeners.clear() } private val onPortAvailableListeners: MutableList<(Port?) -> Unit> = mutableListOf() fun executeOnPortAvailable(action: (Port?) -> Unit) { val port = this.port if (port != null) { action.invoke(port) } else { onPortAvailableListeners.add(action) } } fun onPortInitializationError() { // handle error } fun clear() { this.port = null } }
Затем напишем код для подключения расширения из нашего Kotlin-кода:
private fun installInterceptionExtension(runtime: GeckoRuntime) { val portDelegate = object : WebExtension.PortDelegate { override fun onDisconnect(port: WebExtension.Port) { if (portHolder.port === port) { portHolder.clear() } } } val messageDelegate = object : WebExtension.MessageDelegate { override fun onConnect(port: WebExtension.Port) { portHolder.setPort(port) port.setDelegate(portDelegate) } } runtime.webExtensionController.ensureBuiltIn(EXTENSION_LOCATION, EXTENSION_ID) .accept({ extension -> extension?.setMessageDelegate(messageDelegate, EXTENSION_APP_ID) }, { throwable -> portHolder.onPortInitializationError() }) } companion object { private const val EXTENSION_LOCATION = "resource://android/assets/interception/" private const val EXTENSION_APP_ID = "InterceptorApp" private const val EXTENSION_ID = "interception@mail.ru" }
А теперь можно передать нужные нам параметры в WebExtension перед загрузкой контента:
val put = JSONObject().put("customParam", customParam) portHolder.executeOnPortAvailable { port -> port?.postMessage(put) session.load(loader) }
Изначально мы интегрировали GeckoView как динамическую фичу, и после исправления самых критичных проблем решили протестировать установку и запуск динамической фичи. Для этого в манифесте модуля с динамической фичей переключились с dist:install-time на dist:on-demand и выложили AAB в закрытое тестирование Google Play. Приложение упало с невнятным логом…
Дело в том, что GeckoView для нахождения пути к ассетам использует метод Context.getPackageResourcePath(). Затем нативный код сам пытался распарсить APK файл, чтобы найти в нём файл omni.ja и прочитать его содержимое. И это действительно работает, однако для Android 10 метод Context.getPackageResourcePath() возвращал путь к APK основного приложения, а не динамической фичи. Добавим sSplitApkPath и сделаем возможность проставлять её из самого приложения:
private static String sSplitApkPath = null; public static void setSplitApkPath(String path) { sSplitApkPath = path; } private String[] getMainProcessArgs() { final Context context = GeckoAppShell.getApplicationContext(); final ArrayList<String> args = new ArrayList<>(); // argv[0] is the program name, which for us is the package name. args.add(context.getPackageName()); if (!mInitInfo.xpcshell) { args.add("-greomni"); if (sSplitApkPath != null) { args.add(sSplitApkPath); } else { args.add(context.getPackageResourcePath()); } } if (mInitInfo.args != null) { args.addAll(Arrays.asList(mInitInfo.args)); } ...... }
Затем пришлось написать костыль логику для нахождения правильного пути к APK динамической фичи, в которой находится omni.ja:
private fun findResourcePath(): String? { return findFromSplitSourceDirs() ?: findFromInternalDirs() } private fun findFromSplitSourceDirs(): String? { return context.applicationInfo?.splitSourceDirs?.find { it.contains("/split_geckoview.apk") } } private fun findFromInternalDirs(): String? { val dir = context.filesDir return findFile(dir, dir.absolutePath, "geckoview.apk")?.absolutePath; } private fun findFile(file: File, dir: String, name: String): File? { if (file.isFile()) { if (file.absolutePath.contains(dir) && file.getName().contains(name)) { return file } } else if (file.isDirectory()) { val listFiles = file.listFiles() ?: return null for (child in listFiles) { val found = findFile(child, dir, name) if (found != null) { return found } } } return null }
Так выглядит итоговый код создания Runtime:
val settings = GeckoRuntimeSettings .Builder() .consoleOutput(true) .debugLogging(true) .pauseForDebugger(false) .build() settings.setConsoleOutputEnabled(true) GeckoThread.setSplitApkPath(findResourcePath()) runtime = GeckoRuntime.create(context, settings) installInterceptionExtension(runtime)
Подведение итогов, плюсы и минусы такого решения
А теперь рассмотрим плюсы и минусы такого решения. WebView позволяет нам отображать AMP-письма, то есть делать их «интерактивными». Например, можно встраивать в них опросы, формы подтверждения бронирования или написания отзыва и многое другое. Такие письма на один шаг сокращают воронку, потому что пользователям не нужно переходить по ссылке и открывать браузер для совершения целевого действия. К сожалению, эта технология не поддерживается в GeckoView. Ещё в GeckoView могут возникать различные артефакты, хотя мы свели к минимуму их количество. Далось это непросто, потому что информации о работе с этим GeckoView, а в связке с Dynamic Feature Delivery — вообще нет. Не так много юзеров используют Gecko для отображения писем, однако теперь мы уверены, что в случае очередного кривого обновления, ломающего WebView, мы можем переключиться на GeckoView. Также к плюсам можно отнести то, что GeckoView можно довольно просто пересобрать под себя.
Надеемся это окажется для вас полезным!
