Начиная с июня 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 можно довольно просто пересобрать под себя.
Надеемся это окажется для вас полезным!
Комментарии (3)
Mox
11.06.2024 13:53+10Крутое решение! С такими случаями видимо можно столкнуться только на масштабах VK
Еще знаете что подумал - понятно что вряд ли это произойдет, но было бы суперздорово поддержать Mozilla Foundation, раз уж вы их компонент используете.
pliashkou
Я так понимаю появилась возможность запускать JavaSscript в емэйлах? Не пробовали воспроизвести стандартные атаки?