Начиная с июня 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)


  1. pliashkou
    11.06.2024 13:53
    +4

    Я так понимаю появилась возможность запускать JavaSscript в емэйлах? Не пробовали воспроизвести стандартные атаки?


  1. Mox
    11.06.2024 13:53
    +10

    Крутое решение! С такими случаями видимо можно столкнуться только на масштабах VK

    Еще знаете что подумал - понятно что вряд ли это произойдет, но было бы суперздорово поддержать Mozilla Foundation, раз уж вы их компонент используете.


  1. dyadyaSerezha
    11.06.2024 13:53
    +1

    А этот WebView не open source?