Exo Player — HMS Video Kit Comparison by building a flavored Android Application . Part I

In this comparison article you will read about:

  • Learn about simple transition animations.
  • Learn about JUnit testing for URL.
  • Learn how to record videos using the CameraX library.
  • Learn to create a simple messaging system with Firebase Realtime Database. ( Bonus Content 😉 )
  • Learn how to add video files to Firebase Storage, call their URLs to Realtime NoSQL of Firebase Database, and use those as our video sources.
  • How to implement both Exo Player, Widevine, and HMS Video Kit, WisePlayer, and their pros and cons while building both of these projects by their flavor dimensions.

Please also note that the code language that supports this article will be on Kotlin and XML.

It is going to be a long ride so hold on and enjoy 😀

Global Setups for our application

What you will need for building this application is listed below:

Hardware Requirements

  • A computer that can run Android Studio.
  • An Android phone for debugging.

Software Requirements

  • Android SDK package
  • Android Studio 3.X
  • API level of at least 23
  • HMS Core (APK) 4.0.1.300 or later or later (Not needed for Exo Player 2.0)

To not hold too much space on our phone let’s start with adding necessary plugins to necessary builds.

plugins {
    id 'com.android.application'
    id 'kotlin-android'
    id 'kotlin-kapt'
    id 'kotlin-android-extensions' 

    id 'com.huawei.agconnect'

    id 'com.google.gms.google-services'
}

if (getGradle().getStartParameter().getTaskRequests().toString().toLowerCase().contains("gms")) {
    apply plugin: 'com.google.gms.google-services'
} else {
    apply plugin: 'com.huawei.agconnect'
}

To compare them both let us create a flavor dimension first on our project. Separating GMS (Google Mobile Services) and HMS (Huawei Mobile Services) products.

flavorDimensions "version"
productFlavors {
    hms {
        dimension "version"
    }

    gms {
        dimension "version"
    }
}

After that add gms and hms as a directory in your src folder. The selected build variant will be highlighted as blue. Note that “java” and “res” must also be added by hand! And don’t worry I’ll show you how to fill them properly.

Binding build features that we’ll use a lot in this project.

buildFeatures {
    dataBinding true
    viewBinding = true
}

Let us separate our dependencies now that we have flavor product builds. Note separated implementations are starting with their prefixed names.

/* ExoPlayer */
def exoP= "2.12.1"

/* Slider Activity */
def roadkill = "2.1.0" 

/* CameraX */
def camerax_version = "1.0.0-beta03"
def camerax_extensions = "1.0.0-alpha10"

/* BottomBar */
implementation 'nl.joery.animatedbottombar:library:1.0.9'
		
/* Firebase */
implementation platform('com.google.firebase:firebase-bom:26.1.0')
implementation 'com.google.firebase:firebase-analytics'
implementation 'com.google.firebase:firebase-database:19.5.1'
implementation 'com.google.firebase:firebase-storage:19.2.1'
implementation 'com.firebaseui:firebase-ui-auth:6.2.0'
implementation 'com.firebaseui:firebase-ui-database:6.2.0'
implementation 'com.firebaseui:firebase-ui-firestore:6.2.0'
implementation 'com.firebaseui:firebase-ui-storage:6.2.0'

/* Simple Video View */
implementation 'com.klinkerapps:simple_videoview:1.2.4'

/* Lottie */
implementation "com.airbnb.android:lottie: 3.4.0"

/*Sliding Activity*/
implementation "com.r0adkll:slidableactivity:$roadkill"

/* Exo */
gmsImplementation "com.google.android.exoplayer:exoplayer:$exoP"
gmsImplementation "com.google.android.exoplayer:exoplayer-core:$exoP"
gmsImplementation "com.google.android.exoplayer:exoplayer-dash:$exoP"
gmsImplementation "com.google.android.exoplayer:exoplayer-ui:$exoP"
gmsImplementation "com.google.android.exoplayer:exoplayer-hls:$exoP"
gmsImplementation "com.google.android.exoplayer:extension-okhttp:$exoP"

/* Huawei */
implementation 'com.huawei.agconnect:agconnect-core:1.4.1.300'
hmsImplementation 'com.huawei.agconnect:agconnect-auth:1.4.1.300'
hmsImplementation 'com.huawei.hms:hwid:5.0.3.302'
hmsImplementation 'com.huawei.hms:videokit-player:1.0.1.300'

// CameraX core library using the camera2 implementation
//noinspection GradleDependency
implementation "androidx.camera:camera-core:${camerax_version}"
//noinspection GradleDependency
implementation "androidx.camera:camera-camera2:${camerax_version}"
// If you want to additionally use the CameraX Lifecycle library
//noinspection GradleDependency
implementation "androidx.camera:camera-lifecycle:${camerax_version}"
// If you want to additionally use the CameraX View class
//noinspection GradleDependency
implementation "androidx.camera:camera-view:${camerax_extensions}"
// If you want to additionally use the CameraX Extensions library
//noinspection GradleDependency
implementation "androidx.camera:camera-extensions:${camerax_extensions}"

// Login
…

Adding Predefined Video URL’s

These URL’s will be our predefined tests that will be used. You may find them here.

<resources>
    <string-array name="data_url">
        <item>http://videoplay-mos-dra.dbankcdn.com/P_VT/video_injection/61/v3/519249A7370974110613784576/MP4Mix_H.264_1920x1080_6000_HEAAC1_PVC_NoCut.mp4?accountinfo=Qj3ukBa%2B5OssJ6UBs%2FNh3iJ24kpPHADlWrk80tR3gxSjRYb5YH0Gk7Vv6TMUZcd5Q%2FK%2BEJYB%2BKZvpCwiL007kA%3D%3D%3A20200720094445%3AUTC%2C%2C%2C20200720094445%2C%2C%2C-1%2C1%2C0%2C%2C%2C1%2C%2C%2C%2C1%2C%2C0%2C%2C%2C%2C%2C1%2CEND&amp;GuardEncType=2&amp;contentCode=M2020072015070339800030113000000&amp;spVolumeId=MP2020072015070339800030113000000&amp;server=videocontent-dra.himovie.hicloud.com&amp;protocolType=1&amp;formatPriority=504*%2C204*%2C2</item>
        <item>http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4</item>
        <item>http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4</item>
        <item>http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerBlazes.mp4</item>
        <item>http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerEscapes.mp4</item>
        <item>http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerFun.mp4</item>
        <item>http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerJoyrides.mp4</item>
        <item>http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerMeltdowns.mp4</item>
        <item>http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/Sintel.mp4</item>
        <item>http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/SubaruOutbackOnStreetAndDirt.mp4</item>
        <item>http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/TearsOfSteel.mp4</item>
        <item>http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/VolkswagenGTIReview.mp4</item>
        <item>http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/WeAreGoingOnBullrun.mp4</item>
        <item>http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/WhatCarCanYouGetForAGrand.mp4</item>
    </string-array>

    <string-array name="data_name">
        <item>Analytics - HMS Core</item>
        <item>Big Buck Bunny</item>
        <item>Elephant Dream</item>
        <item>For Bigger Blazes</item>
        <item>For Bigger Escape</item>
        <item>For Bigger Fun</item>
        <item>For Bigger Joyrides</item>
        <item>For Bigger Meltdowns</item>
        <item>Sintel</item>
        <item>Subaru Outback On Street And Dirt</item>
        <item>Tears of Steel</item>
        <item>Volkswagen GTI Review</item>
        <item>We Are Going On Bullrun</item>
        <item>What care can you get for a grand?</item>
    </string-array>
</resources>

Defining Firebase constants

I am skipping all those setups a project part in Firebase part and moving directly to the part that interests you the most, my dear reader. You may refer to it here. If want to learn more about the Firebase setup. Since we are about to bind videos to each individual user there must be also login processes. I’ll also leave you, my dear reader.

Let us start with getting our logged-in users ID.

object AppUser {

    private var userId = ""

    fun setUserId(userId: String) {
        if (userId != "")
            this.userId = userId
        else
            this.userId = "dummy"
    }

    fun getUserId() = userId

}

Firebase Database Helper:

object FirebaseDbHelper {

    fun rootRef() : DatabaseReference {
        return FirebaseDatabase.getInstance().reference
    }

    fun getUser(userID : String) : DatabaseReference {
        return FirebaseDatabase.getInstance().reference.child("User").child(userID)
    }

    fun onDisconnect(db : DatabaseReference) : Task<Void> {
        return db.child("onlineStatus").onDisconnect().setValue(ServerValue.TIMESTAMP)
    }

    fun onConnect(db : DatabaseReference) : Task<Void> {
        return db.child("onlineStatus").setValue("true")
    }

    fun getUserInfo(userID : String) : DatabaseReference {
        return FirebaseDatabase.getInstance().reference.child("UserInfo").child(userID)
    }

    fun getVideoFeed(userID : String) : DatabaseReference {
        return FirebaseDatabase.getInstance().reference.child("UserFeed/Video/$userID")
    }

    fun getVideoFeedItem(userID: String, listId:String):DatabaseReference{
        return FirebaseDatabase.getInstance().reference.child("UserFeed/Video/$userID/$listId")
    }

    fun getShared(): DatabaseReference{
        return FirebaseDatabase.getInstance().reference.child("uploads/Shareable")
    }

    fun getShareItem(listId: String):DatabaseReference{
        return FirebaseDatabase.getInstance().reference.child("uploads/Shareable/$listId")
    }

    fun getUnSharedItem(listId: String): DatabaseReference{
        return FirebaseDatabase.getInstance().reference.child("uploads/UnShareable/$listId")
    }

    fun getPostMessageRootRef() : DatabaseReference {
        return FirebaseDatabase.getInstance().reference.child("PostMessage")
    }

    fun getPostMessageRef(listID : String) : DatabaseReference {
        return FirebaseDatabase.getInstance().reference.child("PostMessage/$listID")
    }

    fun getPostMessageUnderRef(postID : String, listID : String) : DatabaseReference {
        return FirebaseDatabase.getInstance().reference.child("PostMessageUnder/$postID/$listID/Message")
    }
}

Now that we have helper lets create our global constants:

class Constants {
    companion object {

        /* Firebase References */
        val fUserInfoDB = FirebaseDbHelper.getUserInfo(AppUser.getUserId())
        val fFeedRef = FirebaseDbHelper.getVideoFeed(AppUser.getUserId())
        val fSharedRef = FirebaseDbHelper.getShared()
/* Recording Video */
const val FILENAME = "yyyy-MM-dd-HH-mm-ss-SSS" //"yyyy-MM-dd-HH-mm-ss-SSS"
const val VIDEO_EXTENSION = ".mp4"
		var recPath = Environment.getExternalStorageDirectory().path + "/Pictures/ExoVideoReference"
fun getOutputDirectory(context: Context): File {
    val appContext = context.applicationContext
    val mediaDir = context.externalMediaDirs.firstOrNull()?.let {
        File(
            recPath
        ).apply { mkdirs() }
    }
    return if (mediaDir != null && mediaDir.exists()) mediaDir else appContext.filesDir
}
fun createFile(baseFolder: File, format: String, extension: String) =
    File(
        baseFolder, SimpleDateFormat(format, Locale.ROOT)
            .format(System.currentTimeMillis()) + extension
    )
}
}

Let’s create our Data classes to use:

object DataClass {
    data class ProfileVideoDataClass(
        val shareStat: String = "",
        val like: String = "",
        val timeDate: String = "",
        val timeMillis: String = "",
        val uploaderID: String = "",
        val videoUrl: String = "",
        val videoName:String = ""
    )

    data class UploadsShareableDataClass(
        val like: String = "",
        val timeDate: String = "",
        val timeMillis: String = "",
        val uploaderID: String = "",
        val videoUrl: String = "",
        val videoName:String = ""
    )

    data class PostMessageDataClass(
        val comment : String = "",
        val comment_lovely : String = "",
        val commenter_ID : String = "",
        val commenter_image : String = "",
        val commenter_name : String = "",
        val time : String = "",
        val type : String = ""
    )
}

Setting up Themes

<resources xmlns:tools="http://schemas.android.com/tools">
    <!-- Base application theme. -->
    <style name="Theme.ExoVideoReference" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
        <item name="colorPrimary">@color/midnight</item>
        <item name="colorPrimaryVariant">@color/midnight</item>
        <item name="colorOnPrimary">@color/white</item>
        <item name="colorSecondary">@color/teal_200</item>
        <item name="colorSecondaryVariant">@color/teal_700</item>
        <item name="colorOnSecondary">@color/midnight</item>
        <item name="android:statusBarColor" tools:targetApi="l">?attr/colorPrimaryVariant</item>
    </style>

    <style name="Theme.ExoVideoReference.TransStatusBar" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
        <item name="colorPrimary">@color/midnight</item>
        <item name="colorPrimaryVariant">@color/midnight</item>
        <item name="colorOnPrimary">@color/white</item>
        <item name="colorSecondary">@color/teal_200</item>
        <item name="colorSecondaryVariant">@color/teal_700</item>
        <item name="colorOnSecondary">@color/black</item>
        <item name="android:windowActivityTransitions">true</item>
        <item name="android:windowTranslucentStatus">true</item>
        <item name="android:windowActionBarOverlay">true</item>
        <item name="android:statusBarColor" tools:targetApi="l">?attr/colorPrimaryVariant</item>
    </style>

    <style name="BlurTheme" parent="android:style/Theme.Translucent">
        <item name="android:windowNoTitle">true</item>
        <item name="android:windowContentOverlay">@null</item>
        <item name="android:backgroundDimEnabled">true</item>
        <item name="android:background">@color/transparent</item>

<style name="DialogSlide">
        <item name="android:windowEnterAnimation">@anim/slide_in_up</item>
        <item name="android:windowExitAnimation">@anim/slide_out_down</item>
    </style>

    <style name="Popup_Animation">
        <item name="android:windowEnterAnimation">@anim/fade_in</item>
        <item name="android:windowExitAnimation">@anim/fade_out</item>
    </style>

    <style name="ActionBar" parent="ThemeOverlay.AppCompat.ActionBar">
        <item name="android:displayOptions">homeAsUp</item>
    </style>

    <style name="BottomBarItemText">
        <item name="android:textAllCaps">false</item>
        <item name="android:fontFamily">@font/bicubik</item>
        <item name="android:textSize">16sp</item>
        <item name="android:textStyle">bold</item>
    </style>

    <style name="mSeekBar" parent="Widget.AppCompat.SeekBar">
        <item name="android:progressBackgroundTint">#f4511e</item>
        <item name="android:progressTint">#EF484B</item>
        <item name="colorControlNormal">#827717</item>
        <item name="thumbTint">#FFD600</item>
        <item name="thumbColor">#FFD600</item>
        <item name="android:colorControlActivated">#FF6D00</item>
    </style>

</resources>

Setting Animations

Now we will define some animations to pop popup windows, implement some slide animations for our dialog windows and turn the back or front of our cards.

Fade-in animation:

<set xmlns:android="http://schemas.android.com/apk/res/android">
    <alpha
        android:fromAlpha="0.0"
        android:toAlpha="1.0"
        android:duration="@android:integer/config_mediumAnimTime"/>
</set>

Fade-out animation:

<set xmlns:android="http://schemas.android.com/apk/res/android">
    <alpha
        android:fromAlpha="1.0"
        android:toAlpha="0.0"
        android:duration="@android:integer/config_mediumAnimTime"/>
</set>

Slide up animation:

<translate xmlns:android="http://schemas.android.com/apk/res/android"
    android:fromYDelta="100%p"
    android:toYDelta="0"
    android:duration="@android:integer/config_mediumAnimTime"/>

Slide down animation:

<translate xmlns:android="http://schemas.android.com/apk/res/android"
    android:fromYDelta="0"
    android:toYDelta="100%p"
    android:duration="@android:integer/config_mediumAnimTime"/>

Back animation:

<set xmlns:android="http://schemas.android.com/apk/res/android">
    <objectAnimator
        android:valueFrom="1.0"
        android:valueTo="0.0"
        android:propertyName="alpha"
        android:duration="0"/>
    <objectAnimator
        android:valueFrom="-180"
        android:valueTo="0"
        android:propertyName="rotationY"
        android:repeatMode="reverse"
        android:duration="1000"/>
    <objectAnimator
        android:valueFrom="0.0"
        android:valueTo="1.0"
        android:propertyName="alpha"
        android:startOffset="500"
        android:duration="0"/>
</set>

Front animation:

<set xmlns:android="http://schemas.android.com/apk/res/android">
    <objectAnimator
        android:duration="1000"
        android:propertyName="rotationY"
        android:valueFrom="0"
        android:valueTo="180" />
    <objectAnimator
        android:duration="1"
        android:propertyName="alpha"
        android:startOffset="500"
        android:valueFrom="1.0"
        android:valueTo="0.0" />
</set>

Animation Helpers to Rotate any View

We will use it to turn any view from -x to +x to create an effect like we’re actually turning any defined view.

object FlipCard {

    fun flipFrontAnimator(
        frontAnim: AnimatorSet,
        frontView: View,
        backAnim: AnimatorSet,
        backView: View
    ) {
        frontAnim.setTarget(frontView)
        backAnim.setTarget(backAnim)
        frontAnim.start()
        backAnim.start()
        frontView.visibility = View.GONE
        backView.visibility = View.VISIBLE
    }

    fun flipBackAnimator(
        frontAnim: AnimatorSet,
        frontView: View,
        backAnim: AnimatorSet,
        backView: View
    ) {
        frontAnim.setTarget(backView)
        backAnim.setTarget(frontView)
        frontAnim.start()
        backAnim.start()
        frontView.visibility = View.VISIBLE
        backView.visibility = View.GONE
    }
}

Custom URL Test Implementation with JUnit

We need a test based controller to help our users to enter a valid URL address for that let’s begin by creating a new Url Validator class:

class UrlValidatorHelper : TextWatcher {

    internal var isValid = false

    override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}

    override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}

    override fun afterTextChanged(s: Editable?) {
        isValid = isValidUrl(s)
    }

    companion object {

        fun isValidUrl(url: CharSequence?): Boolean {
            return url!=null && URLUtil.isValidUrl(url.trim().toString()) && Patterns.WEB_URL.matcher(url).matches()
        }
    }
}

Now that we have URL helper lets create our test classes under test>>java>>”package_name”>>UrlValidatorTest:

class UrlValidatorTest {

    @Test
    fun urlValidator_InvalidCertificate_RunsFalse(){
        assertFalse(UrlValidatorHelper.isValidUrl("www.youtube.com/watch?v=Yr8xDSPjII8&list=RDMMYr8xDSPjII8&start_radio=1"))
    }

    @Test
    fun urlValidator_InvalidIdentifier_RunsFalse(){
        assertFalse(UrlValidatorHelper.isValidUrl("https://youtube.com/watch?v=Yr8xDSPjII8&list=RDMMYr8xDSPjII8&start_radio=1"))
    }

    @Test
    fun urlValidator_InvalidDomain_RunsFalse(){
        assertFalse(UrlValidatorHelper.isValidUrl("https://www.com/watch?v=Yr8xDSPjII8&list=RDMMYr8xDSPjII8&start_radio=1"))
    }

    @Test
    fun urlValidator_InvalidExtension_RunsFalse(){
        assertFalse(UrlValidatorHelper.isValidUrl("https://www.youtube/watch?v=Yr8xDSPjII8&list=RDMMYr8xDSPjII8&start_radio=1"))
    }

    @Test
    fun urlValidator_EmptyUrl_RunsFalse(){
        assertFalse(UrlValidatorHelper.isValidUrl(""))
    }

    @Test
    fun urlValidator_NullUrl_RunsFalse(){
        assertFalse(UrlValidatorHelper.isValidUrl(null))
    }
}