За двумя мобильными сервисами: 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.
    Cardsmobile
    Разработчик приложения для хранения карт «Кошелёк»

    Комментарии 2

      0
      А в Украине этот кошелёк будет юзабелен? Скоро ли?
        0
        Артём, здравствуйте. У нас очень амбициозные планы: мы думаем о запуске сервиса в Украине в 2021-22гг, возможно, под другим брендом. Обо всех планах расскажем, в том числе и здесь!

      Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

      Самое читаемое