Behind two mobile services: HMS and GMS in one application





Hello, Habr! My name is Andrey, I am making the " Wallet " application for Android. For more than six months now, we have been helping Huawei smartphone users to 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 ability to pay contactless out of the box and provide a better user experience in other scenarios, in January 2020, we began work to support new push notifications, cards 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 is 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 try to install them in every way .


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:



  • Eliminates the risk of getting the version intended for Google Play on Huawei devices and vice versa;
  • 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 differs significantly 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 studying the 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 take away from these caveats is that there is a difference in receiving a push token on different versions of 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 have shown 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:



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


-



  • We create a separate data source for each type of service;
  • Add a repository for push notifications and security that accept the type of mobile services as input and select 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 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;
  • EMUI >= 10.0 - ;
  • Huawei Google- EMUI 10.0 ;
  • Huawei Google-, . , Google- ;
  • AppGallery Huawei-, , .


The development of the algorithm was probably the most exhausting thing. Many technical and business factors converged here, but in the end we were able to come up with the best solution for our product . 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 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 individual 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 on the part of 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 application releases both on Google Play and AppGallery.



Using this method, we have added to the application Analytics Kit , the APM , working to support Account Kit and do not plan to stop there, all the more so with each new version becomes available HMS still more opportunities .



Afterword



Registering a developer account with AppGallery is much more complicated than Google's. 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.



Using the exit to the public space, I want to thank the entire Wallet team and especiallyumpteenthdev, Artem Kulakov and Egor Aganin for their invaluable contribution to the integration of HMS into the Wallet!



useful links






All Articles