За двумя мобильными сервисами: HMS и GMS в одном приложении

от автора

Привет, Хабр! Меня зовут Андрей, я делаю приложение «Кошелёк» для 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;
  • Тестировать одно приложение проще, чем два;
  • Каждый релиз можно выкладывать на все площадки распространения;
  • Не приходится переключаться с написания кода на управление сборкой проекта при разработке/модификации.

Для работы с разными реализациями мобильных сервисов в одной версии приложения необходимо:

  1. Спрятать все обращения за абстракцию, сохранив работу с GMS;
  2. Добавить реализацию для HMS;
  3. Разработать механизм выбора реализации сервисов в рантайме.

Методика внедрения поддержки 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

После реализации алгоритма выбора сервисов в рантайме, алгоритм добавления поддержки базового функционала карт выглядит тривиально:

  1. Определить тип сервисов для отображения карт;
  2. Заинфлейтить соответствующий 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.

ссылка на оригинал статьи https://habr.com/ru/company/cardsmobile/blog/522008/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *