Pull to refresh
Кошелёк
Создаём приложение, с которым покупают

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

Reading time 10 min
Views 7K


Привет, Хабр! Меня зовут Андрей, я делаю приложение «Кошелёк» для 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.
Tags:
Hubs:
+14
Comments 2
Comments Comments 2

Articles

Information

Website
koshelekteam.ru
Registered
Founded
2013
Employees
201–500 employees
Location
Россия
Representative
Tatiana Kazakova