Behind two mobile services: HMS and GMS in one application

Hello, Habr! My name is Andrey, I am making an application “Purse»For Android. For more than half a year now, we have been helping Huawei smartphone users pay for purchases with bank cards contactlessly – via NFC. To do this, we needed to add support for HMS: Push Kit, Map Kit and Safety Detect. Under the cut, I will tell you what problems we had to solve during development, why exactly and what came of it, and also share a test project for a faster immersion in the topic.

In order to provide all users of new Huawei smartphones with the opportunity contactless payment out of the box and provide a better user experience in other scenarios, in January 2020 we began work to support new push notifications, maps and security checks. The result should have been the appearance in AppGallery of a version of the Wallet with mobile services native to Huawei phones.

Here’s what we managed to find out at the stage of the initial study.

  • Huawei distributes AppGallery and HMS without restrictions – you can download and install them on devices from other manufacturers;
  • After we installed AppGallery on Xiaomi Mi A1, all updates began to be pulled up first of all from the new site. The impression is that AppGallery has time to update applications faster than competitors;
  • Huawei is now striving to fill the AppGallery with applications as quickly as possible. To speed up the migration to HMS, they decided to provide developers with an already familiar (similar to GMS) API;
  • At first, until the Huawei developer ecosystem is fully operational, the lack of Google services will most likely be the main problem for users of new Huawei smartphones, and they will by all means try to install them

We decided to make one common version of the application for all distribution sites. She must be able to identify and use the appropriate type of mobile service at runtime. This option seemed slower to implement than a separate version for each type of service, but we hoped to win in another:

  • The risk of getting the version intended for Google Play on Huawei devices and vice versa is eliminated;
  • You can implement any algorithm for choosing mobile services, including using the feature toggle;
  • Testing one application is easier than testing two;
  • Each release can be uploaded to all distribution sites;
  • You don’t have to switch from writing code to managing the build of the project during development / modification.

To work with different implementations of mobile services in one version of the application, you must:

  1. Hide all requests for abstraction, saving work with GMS;
  2. Add an implementation for HMS;
  3. Develop a mechanism for choosing the implementation of services at runtime.

The methodology for implementing Push Kit and Safety Detect support is significantly different from the Map Kit, so we will consider them separately.

Push Kit and Safety Detect support

As it should be in such cases, the integration process began with the study documentation… The following points were found in the warning section:

  • 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.

The main thing to learn from these warnings – there is a difference in getting a push token on different versions EMUI… After calling the getToken () method, the real token can be returned by calling the onNewToken () method of the service. Our tests on real devices showed that phones with EMUI <10.0 return null or an empty string when the getToken method is called, after which the onNewToken () method of the service is called. Phones with EMUI> = 10.0 always returned a push token from the getToken () method.

You can implement such a data source to bring the logic of work to a single form:

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)
               })
       }
   }
}

Important notes:

  • The suggested solution does not work in all cases. When testing on physical devices, no problems were identified, but the approach does not work on the pool of devices provided by AppGallery for online debugging. Moreover, it does not work due to the fact that the call to the HmsMessageService.onNewToken () method does not occur, which does not seem to correspond to the documentation. The reason for this behavior to this day remains unclear to us;
  • It turned out that on some devices the HmsMessageService.onMessageReceived () method can be called on the main thread, so be careful with trips to the database and the network from it;
  • Once you add the dependency on the com.huawei.hms: push library, the post-build project manifest will declare the com.huawei.hms.support.api.push.service.HmsMsgService service configured to run in a separate process: pushservice. From this moment on, when spawning each process, in it will be created your instance of the Application class… This is fundamentally important to be aware of if you access files or a database, or, for example, collect data on the speed of application initialization through Firebase Performance. We saw the spawn of the second process only on non-Huawei devices where AppGallery and HMS were installed.

For the cases of supporting the application with a push token and checking the device for security, the general algorithm will be the same:

  • We create a separate data source for each type of service;
  • Add by repository for push notifications and security, accepting the type of mobile services as input and choosing a specific data source;
  • Some entity of business logic determines which type of mobile services (from the available ones) is appropriate to use in a particular case.

Development of a mechanism for choosing the implementation of services at runtime

How to proceed if only one type of services is installed on the device or there are none at all, but what to do if both Google and Huawei services are installed at the same time?

Here’s what we found and where we started:

  • When introducing any new technology, it must be used as a priority if the user’s device fully meets all the requirements;
  • On devices with EMUI> = 10.0, the algorithm for obtaining a push token differs from previous versions;
  • The vast majority of Huawei devices without Google services will have EMUI version 10.0 or higher;
  • On new Huawei devices, users will try to install Google services in order to use all the familiar applications. There is no reliable way to do this, so we should not rely on stable and correct operation of Google services on such devices;
  • Technically, users of smartphones from other vendors can install AppGallery and Huawei services for themselves, but we assume that at the moment there are very few such users.

The development of the algorithm turned out to be, perhaps, the most exhausting business. Many technical and business factors converged here, but in the end we managed to come up with the best for our product decision. Now it’s even a little strange that the description of the most discussed part of the algorithm fits into one sentence, but I’m glad that in the end it turned out simply:

If both types of services are installed on the device and it was possible to determine that the EMUI version is <10 - we use Google, otherwise we use Huawei.

To implement the final algorithm, it is required to find a way to determine the EMUI version on the user’s device.

One way to do this is to read the system properties:

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
    }
}

For the correct execution of security checks, it is additionally necessary to take into account that the state of the services should not require updating.

The final implementation of the algorithm, taking into account the type of operation for which the service is selected, and determining the EMUI version of the device, may look like this:


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 support

After the implementation of the algorithm for selecting services at runtime, the algorithm for adding support for the basic functionality of maps looks trivial:

  1. Determine the type of services for displaying maps;
  2. Inflate the appropriate layout and work with a specific map implementation.

However, there is one feature here that I want to talk about. Rx of the brain allows you to add any asynchronous operation almost anywhere without the risk of rewriting the entire application, but it also imposes its own limitations. For example, in this case, to determine the appropriate layout, most likely, you will need to call .blockingGet () somewhere on the Main thread, which is not good at all. You can solve this problem, for example, using child fragments:

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)
       }
   }
}

Now you can write a separate implementation to work with the map for each separate fragment. If you need to implement the same logic, then you can follow the familiar algorithm – adjust the work with each type of map under one interface and pass one of the implementations of this interface to the parent fragment, as is done in MapFragment.onMapReady ()

What came of it

In the first days after the release of the updated version of the application, the number of installations reached 1 million. We attribute this partly to the featured feature from AppGallery, and partly to the fact that our release was highlighted by several media and bloggers. And also with the speed of updating applications – after all, the version with the highest versionCode was in AppGallery for two weeks.

We receive useful feedback on the operation of the application in general and on the tokenization of bank cards in particular from users in our thread on w3bsit3-dns.com. After the release of Pay functionality for Huawei, the forum has increased in number of visitors, and so have the problems they face. We continue to work on all appeals, but we do not observe any massive problems.

In general, the release of the application in AppGallery was successful and we can conclude that our approach to solving the problem turned out to be working. Thanks to the chosen implementation method, we still have the ability to upload all releases of the application both in Google Play and in AppGallery.

Using this method, we have already added to the application Analytics Kit, APM, working on support Account Kit and we do not plan to dwell on this, especially since with each new version of HMS, more and more features become available.

Afterword

Registering a developer account with AppGallery is much more complicated than Google. For example, it took me 9 days to verify my identity. I don’t think this happens to everyone, but any delay can diminish optimism. Therefore, along with the complete code of the entire demo solution described in the article, I have committed all application keys to the repository so that you have the opportunity not only to evaluate the solution as a whole, but also right now to test and improve the proposed approach.

Taking advantage of the access to the public space, I would like to thank the entire team of the Wallet and especially the umpteenthdev, Artem Kulakov and Yegor Aganin for their invaluable contribution to the integration of HMS into the Wallet!

useful links

Similar Posts

Leave a Reply