Привет, Хабр! Меня зовут Андрей, я делаю приложение Кошелёк для Android. Уже больше полугода мы помогаем пользователям смартфонов Huawei оплачивать покупки банковскими картами бесконтактно через NFC. Для этого нам потребовалось добавить поддержку HMS: Push Kit, Map Kit и Safety Detect. Под катом я расскажу, какие проблемы нам пришлось решать при разработке, почему именно так и что из этого вышло, а также поделюсь тестовым проектом для более быстрого погружения в тему.
Для того, чтобы предоставить всем пользователям новых смартфонов Huawei возможность бесконтактной оплаты из коробки и обеспечить лучший пользовательский опыт в остальных сценариях, в январе 2020 года мы начали работы по поддержке новых пушей, карт и проверок на безопасность. Результатом должно было стать появление в AppGallery версии Кошелька с родными для телефонов Huawei мобильными сервисами.
Вот что удалось выяснить на этапе первоначальной проработки
- Huawei распространяет AppGallery и HMS без ограничений можно скачать и установить их на устройства других производителей;
- После того, как мы установили AppGallery на Xiaomi Mi A1, все обновления начали подтягиваться в первую очередь с новой площадки. Сложилось впечатление, что AppGallery успевает обновлять приложения быстрее конкурентов;
- Сейчас Huawei стремится как можно быстрее наполнить AppGallery приложениями. Чтобы ускорить миграцию на HMS, они решили предоставить разработчикам уже знакомый (похожий на GMS) API;
- На первых порах, пока экосистема Huawei для разработчиков не заработает на полную мощность, отсутствие Google-сервисов скорее всего будет являться главной проблемой для пользователей новых смартфонов Huawei, и они будут всеми способами пытаться их установить.
Мы решили делать одну общую версию приложения для всех площадок распространения. Она должна уметь определять и использовать подходящий тип мобильных сервисов в рантайме. Этот вариант казался медленнее в реализации, чем отдельная версия под каждый тип сервисов, но мы рассчитывали выиграть в другом:
- Исключается риск попадания версии, предназначенной для Google Play, на девайсы Huawei и наоборот;
- Можно внедрить любой алгоритм выбора мобильных сервисов, в том числе с использованием feature toggle;
- Тестировать одно приложение проще, чем два;
- Каждый релиз можно выкладывать на все площадки распространения;
- Не приходится переключаться с написания кода на управление сборкой проекта при разработке/модификации.
Для работы с разными реализациями мобильных сервисов в одной версии приложения необходимо:
- Спрятать все обращения за абстракцию, сохранив работу с GMS;
- Добавить реализацию для HMS;
- Разработать механизм выбора реализации сервисов в рантайме.
Методика внедрения поддержки Push Kit и Safety Detect значительно отличается от Map Kit, поэтому рассмотрим их отдельно.
Поддержка Push Kit и Safety Detect
Как и положено в таких случаях, процесс интеграции начался с изучения документации. В разделе предостережений обнаружились вот такие пункты:
- If the EMUI version is 10.0 or later on a Huawei device, a token will be returned through the getToken method. If the getToken method fails to be called, HUAWEI Push Kit automatically caches the token request and calls the method again. A token will then be returned through the onNewToken method.
- If the EMUI version on a Huawei device is earlier than 10.0 and no token is returned using the getToken method, a token will be returned using the onNewToken method.
- For an app with the automatic initialization capability, the getToken method does not need to be called explicitly to apply for a token. The HMS Core Push SDK will automatically apply for a token and call the onNewToken method to return the token.
Главное, что нужно вынести из этих предостережений существует разница в получении пуш-токена на разных версиях EMUI. После вызова метода getToken(), реальный токен может быть возвращен через вызов метода onNewToken() сервиса. Наши испытания на реальных устройствах показали, что телефоны с EMUI < 10.0 на вызов метода getToken возвращают null или пустую строку, после чего происходит вызов метода onNewToken() сервиса. Телефоны с EMUI >= 10.0 всегда возвращали пуш-токен из метода getToken().
Можно реализовать вот такой источник данных, чтобы привести логику работы к единому виду:
class HmsDataSource( private val hmsInstanceId: HmsInstanceId, private val agConnectServicesConfig: AGConnectServicesConfig) { private val currentPushToken = BehaviorSubject.create<String>() fun getHmsPushToken(): Single<String> = Maybe .merge( getHmsPushTokenFromSingleton(), currentPushToken.firstElement() ) .firstOrError() fun onPushTokenUpdated(token: String): Completable = Completable .fromCallable { currentPushToken.onNext(token) } private fun getHmsPushTokenFromSingleton(): Maybe<String> = Maybe .fromCallable<String> { val appId = agConnectServicesConfig.getString("client/app_id") hmsInstanceId.getToken(appId, "HCM").takeIf { it.isNotEmpty() } } .onErrorComplete()}
class AppHmsMessagingService : HmsMessageService() { val onPushTokenUpdated: OnPushTokenUpdated = Di.onPushTokenUpdated override fun onMessageReceived(remoteMessage: RemoteMessage?) { super.onMessageReceived(remoteMessage) Log.d(LOG_TAG, "onMessageReceived remoteMessage=$remoteMessage") } override fun onNewToken(token: String?) { super.onNewToken(token) Log.d(LOG_TAG, "onNewToken: token=$token") if (token?.isNotEmpty() == true) { onPushTokenUpdated(token, MobileServiceType.Huawei) .subscribe({},{ Log.e(LOG_TAG, "Error deliver updated token", it) }) } }}
Важные замечания:
- Предложенное решение работает не во всех случаях. При тестировании на физических устройствах проблем выявлено не было, но на пуле устройств, предоставляемых AppGallery для онлайн-дебаггинга, подход не срабатывает. Причём не срабатывает из за того, что вызова метода HmsMessageService.onNewToken() не происходит, что, кажется, не соответствует документации. Причина такого поведения по сей день остаётся для нас невыясненной;
- Оказалось, что на некоторых устройствах метод HmsMessageService.onMessageReceived() может вызываться на main потоке, поэтому будьте аккуратнее с походами в БД и сеть из него;
- Как только вы добавите зависимость от библиотеки com.huawei.hms:push, в манифесте проекта после сборки будет объявлен сервис com.huawei.hms.support.api.push.service.HmsMsgService, сконфигурированный для работы в отдельном процессе :pushservice. С этого момента, при порождении каждого процесса, в нём будет создаваться свой экземпляр класса Application. Это принципиально важно осознавать, если вы обращаетесь к файлам или БД или, например, собираете данные о скорости инициализации приложения через Firebase Performance. Мы встретились с порождением второго процесса только на не-Huawei устройствах, куда были установлены AppGallery и HMS.
Для случаев поддержки работы приложения с пуш-токеном и проверки устройства на безопасность общий алгоритм будет одинаковым:
- Создаём по отдельному источнику данных для каждого типа сервисов;
- Добавляем по репозиторию для пушей и безопасности, принимающих на вход тип мобильных сервисов и выбирающих конкретный источник данных;
- Некая сущность бизнес-логики определяет, какой тип мобильных сервисов (из доступных) уместно использовать в конкретном случае.
Разработка механизма выбора реализации сервисов в рантайме
Как действовать, если на устройстве установлен всего один тип сервисов или их нет вовсе, понятно, а вот что делать, если одновременно установлены и Google-, и Huawei-сервисы?
Вот что мы обнаружили и из чего исходили:
- При внедрении любой новой технологии её нужно использовать в приоритете, если устройство пользователя полностью соответствует всем требованиям;
- На устройствах с EMUI >= 10.0 алгоритм получения пуш-токена отличается от предыдущих версий;
- Подавляющее большинство устройств Huawei без Google-сервисов будут иметь версию EMUI 10.0 и выше;
- На новые устройства Huawei пользователи будут пытаться установить Google-сервисы, чтобы пользоваться всеми привычными приложениями. Надёжного способа сделать это нет, поэтому мы не должны рассчитывать на стабильную и корректную работу Google-сервисов на таких устройствах;
- Технически пользователи смартфонов других вендоров могут установить себе AppGallery и Huawei-сервисы, но мы предполагаем, что на текущий момент таких пользователей очень мало.
Разработка алгоритма оказалась, наверное, самым выматывающим делом. Здесь в одну точку сошлось множество технических и бизнесовых факторов, но в конечном итоге нам удалось прийти к наилучшему для нашего продукта решению. Сейчас даже немного странно, что описание самой обсуждаемой части алгоритма помещается в одно предложение, но я рад, что в конечном итоге получилось просто:
В случае, если на устройстве установлены оба типа сервисов и удалось определить, что версия EMUI < 10 используем Google, иначе используем Huawei.
Для реализации итогового алгоритма требуется найти способ определить версию EMUI на устройстве пользователя.
Один из способов сделать это прочитать системные свойства:
class EmuiDataSource { @SuppressLint("PrivateApi") fun getEmuiApiLevel(): Maybe<Int> = Maybe .fromCallable<Int> { val clazz = Class.forName("android.os.SystemProperties") val get = clazz.getMethod("getInt", String::class.java, Int::class.java) val currentApiLevel = get.invoke( clazz, "ro.build.hw_emui_api_level", UNKNOWN_API_LEVEL ) as Int currentApiLevel.takeIf { it != UNKNOWN_API_LEVEL } } .onErrorComplete() private companion object { const val UNKNOWN_API_LEVEL = -1 }}
Для правильного выполнения проверок на безопасность дополнительно нужно учесть, что состояние сервисов не должно требовать обновления.
Итоговая реализация алгоритма, учитывающая тип операции, для которой выбирается сервис, и определение версии EMUI устройства, может выглядеть так:
sealed class MobileServiceEnvironment( val mobileServiceType: MobileServiceType) { abstract val isUpdateRequired: Boolean data class GoogleMobileServices( override val isUpdateRequired: Boolean ) : MobileServiceEnvironment(MobileServiceType.Google) data class HuaweiMobileServices( override val isUpdateRequired: Boolean, val emuiApiLevel: Int? ) : MobileServiceEnvironment(MobileServiceType.Huawei)}
class SelectMobileServiceType( private val mobileServicesRepository: MobileServicesRepository) { operator fun invoke( case: Case ): Maybe<MobileServiceType> = mobileServicesRepository .getAvailableServices() .map { excludeEnvironmentsByCase(case, it) } .flatMapMaybe { selectEnvironment(it) } .map { it.mobileServiceType } private fun excludeEnvironmentsByCase( case: Case, envs: Set<MobileServiceEnvironment> ): Iterable<MobileServiceEnvironment> = when (case) { Case.Push, Case.Map -> envs Case.Security -> envs.filter { !it.isUpdateRequired } } private fun selectEnvironment( envs: Iterable<MobileServiceEnvironment> ): Maybe<MobileServiceEnvironment> = Maybe .fromCallable { envs.firstOrNull { it is HuaweiMobileServices && (it.emuiApiLevel == null || it.emuiApiLevel >= 21) } ?: envs.firstOrNull { it is GoogleMobileServices } ?: envs.firstOrNull { it is HuaweiMobileServices } } enum class Case { Push, Map, Security }}
Поддержка Map Kit
После реализации алгоритма выбора сервисов в рантайме, алгоритм добавления поддержки базового функционала карт выглядит тривиально:
- Определить тип сервисов для отображения карт;
- Заинфлейтить соответствующий layout и работать с конкретной реализацией карт.
Однако здесь есть одна особенность, о которой хочется рассказать. Rx головного мозга позволяет практически куда угодно добавить любую асинхронную операцию без риска переписать всё приложение, но накладывает и свои ограничения. Например, в данном случае для определения соответствующего лэйаута, скорее всего, потребуется вызвать .blockingGet() где-нибудь на Main потоке, что совсем нехорошо. Решить эту проблему можно, например, с помощью дочерних фрагментов:
class MapFragment : Fragment(), OnGeoMapReadyCallback { override fun onActivityCreated(savedInstanceState: Bundle?) { super.onActivityCreated(savedInstanceState) ViewModelProvider(this)[MapViewModel::class.java].apply { mobileServiceType.observe(viewLifecycleOwner, Observer { result -> val fragment = when (result.getOrNull()) { Google -> GoogleMapFragment.newInstance() Huawei -> HuaweiMapFragment.newInstance() else -> NoServicesMapFragment.newInstance() } replaceFragment(fragment) }) } } override fun onMapReady(geoMap: GeoMap) { geoMap.uiSettings.isZoomControlsEnabled = true }}
class GoogleMapFragment : Fragment(), OnMapReadyCallback { private var callback: OnGeoMapReadyCallback? = null override fun onAttach(context: Context) { super.onAttach(context) callback = parentFragment as? OnGeoMapReadyCallback } override fun onDetach() { super.onDetach() callback = null } override fun onMapReady(googleMap: GoogleMap?) { if (googleMap != null) { val geoMap = geoMapFactory.create(googleMap) callback?.onMapReady(geoMap) } }}
class HuaweiMapFragment : Fragment(), OnMapReadyCallback { private var callback: OnGeoMapReadyCallback? = null override fun onAttach(context: Context) { super.onAttach(context) callback = parentFragment as? OnGeoMapReadyCallback } override fun onDetach() { super.onDetach() callback = null } override fun onMapReady(huaweiMap: HuaweiMap?) { if (huaweiMap != null) { val geoMap = geoMapFactory.create(huaweiMap) callback?.onMapReady(geoMap) } }}
Теперь можно написать отдельную реализацию для работы с картой для каждого отдельного фрагмента. Если потребуется реализовать одинаковую логику, то можно поступить по знакомому алгоритму подогнать работу с каждым типом карт под один интерфейс и передать одну из реализаций этого интерфейса в родительский фрагмент, как это сделано в MapFragment.onMapReady()
Что из этого вышло
В первые дни после релиза обновленной версии приложения число установок достигло 1 млн. Мы связываем это отчасти с фичерингом со стороны AppGallery, а отчасти с тем, что наш релиз подсветило несколько СМИ и блогеров. А ещё со скоростью обновления приложений ведь в AppGallery на протяжении двух недель лежала версия с самым высоким versionCode.
Мы получаем полезные отзывы о работе приложения в общем и о токенизации банковских карт в частности от пользователей в нашей ветке на 4pda. После релиза Pay-функциональности для Huawei посетителей на форуме прибавилось, и проблем, с которыми они сталкиваются, тоже. Мы продолжаем работать над всеми обращениями, но массовых проблем при этом не наблюдаем.
В целом, релиз приложения в AppGallery прошёл успешно и можно сделать вывод, что наш подход к решению задачи оказался рабочим. Благодаря выбранному методу реализации у нас сохранилась возможность выкладывать все релизы приложения как в Google Play, так и в AppGallery.
Пользуясь этим методом, мы уже добавили в приложение Analytics Kit, APM, работаем над поддержкой Account Kit и не планируем на этом останавливаться, тем более, что с каждой новой версией HMS становится доступно всё больше возможностей.
Послесловие
Регистрация аккаунта разработчика в AppGallery представляет собой гораздо более сложную процедуру, чем в случае с Google. У меня, например, этап проверки подтверждения личности занял 9 дней. Не думаю что так происходит со всеми, но любая задержка способна поубавить оптимизма. Поэтому вместе с полным кодом всего демо-решения, описанного в статье, я закоммитил в репозиторий и все ключи приложения, чтобы у вас была возможность не только оценить решение целиком, но и прямо сейчас испытать и усовершенствовать предложенный подход.
Пользуясь выходом в публичное пространство, хочу поблагодарить всю команду Кошелька и особенно umpteenthdev, Артёма Кулакова и Егора Аганина за неоценимый вклад в интеграцию HMS в Кошелёк!
Полезные ссылки
- Полный код демонстрационного проекта на GitHub;
- Скачать AppGallery на телефон любого производителя. Актуальную версию приложения HMS-Core можно загрузить из AppGallery;
- Push Kit codelab;
- Map Kit codelab;
- Safety Detect codelab;
- Инструкция к сервису онлайн-дебаггинга своих приложений на устройствах Huawei. Возможность использования появляется после регистрации в AppGallery Connect;
- Ветка приложения Кошелёк на 4PDA.