Android App Bundle: Launch Activity from Dynamic Feature Modules

November 5, 2019

Why use App Bundle

  • Reduce APK download size (average size savings of ~35%): just build as App Bundle, no extra code/configuration required
  • Dynamic Features Modules: allow less frequently used features (but large file size) to be downloaded on demand
  • Instant App: run app instantly without install

Android App Bundle

A new upload format that includes all your app’s compiled code and resources, but defers APK generation and signing to Google Play.

Dynamic Delivery

It is Google Play’s new app serving model, and it uses your app bundle to generate and serve optimized APKs for each user’s device configuration, so they download only the code and resources they need to run your app. You no longer have to build, sign, and manage multiple APKs, and users get smaller, more optimized downloads.

Dynamic feature modules

These are modules you can add to your app project and include them in your app bundle. Through Dynamic Delivery, your app can download and install dynamic features on demand.

Instant App

Google Play Instant enables native apps and games to launch on devices running Android 5.0 (API level 21) without being installed.

Prerequisite

Compressed download size restriction

While publishing with Android App Bundles helps your users install your app with the smallest download possible, compressed downloads are limited to 150 MB. That is, when a user downloads your app, the total size of the compressed APKs required to install your app (for example, the base APK + configuration APK(s)) must be no more than 150 MB. Any subsequent downloads, such as downloading a dynamic feature (and its configuration APK(s)) on demand, must also meet this compressed download size restriction.

Code Shrinking

In order to reduce module size, you might want to enable R8 Code Shrinking.

Edit module build.gradle.

android {
    ...
    buildTypes {
        release {
            // shrinkResources true
            // ERROR: Resource shrinker cannot be used for multi-apk applications: https://issuetracker.google.com/issues/120517460
            // minifyEnabled will enable R8 Code Shrinking
            minifyEnabled true
            proguardFiles getDefaultProguardFile('proguard-android.txt'),
                    'proguard-rules.pro'
        }
    }
}

Enable Dynamic Delivery (on demand)

The functionality that works well as an on demand module are those that aren’t needed by the majority of your users at install time.

You base module is your app module.

To create a new Dynamic Feature Module

  • Android Studio -> File -> New Module, select Dynamic Feature Module
    • Base application module: app
    • Module name (recommended camelCase or lowercase): quotemaker
    • Package name: com.luasoftware.quotemaker
    • Minimum API level: match the base module
  • Click Next for Module Download Options
    • Module title: The platform uses this title to identify the module to users when, for example, confirming whether the user wants to download the module.
    • Install-time inclusion: Do not include module at install-time (on-demand only)
    • Fusing (include module at install time for pre-Lollipop devices): check
  • Click Finish

build.gradle

Edit build.gradle of the new Dynamic Feature Module. Below is an example of minimum setup.

apply plugin: 'com.android.dynamic-feature'

apply plugin: 'kotlin-android'
apply plugin: 'kotlin-kapt'
apply plugin: 'kotlin-android-extensions'

android {
    compileSdkVersion 28

    defaultConfig {
        minSdkVersion 17
        targetSdkVersion 28
        // I assume the following is not required for dynamic feature module
        // versionCode 1
        // versionName "1.0"
        vectorDrawables.useSupportLibrary = true
    }

    compileOptions {
        sourceCompatibility = JavaVersion.VERSION_1_8
        targetCompatibility = JavaVersion.VERSION_1_8
    }

    kotlinOptions {
        jvmTarget = JavaVersion.VERSION_1_8
    }
}

androidExtensions {
    // https://kotlinlang.org/docs/tutorials/android-plugin.html#experimental-mode
    experimental = true
}

dependencies {
    implementation fileTree(dir: 'libs', include: ['*.jar'])
    implementation project(':app')

    implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
    implementation "androidx.core:core-ktx:$ktx_version"
    implementation "androidx.fragment:fragment-ktx:$ktx_fragment_version"

    implementation "androidx.appcompat:appcompat:$appcompat_version"
    implementation "com.google.android.material:material:$material_version"
    implementation "android.arch.lifecycle:extensions:$lifecycle_version"
    implementation "androidx.constraintlayout:constraintlayout:$constraint_layout_version"
}

NOTE: Sharing variable between modules in Android Studio Gradle

Create Activity in Dynamic Feature Module

You can use Android Studio -> New -> Activity -> Basic Activity, which will create the following files.

QuoteMakerXActivity.kt

import com.luasoftware.quotemaker.R // R from current module
import com.luasoftware.nuanxindan.R as appR // R from app module

class QuoteMakerXActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.quotemakerx)
        setSupportActionBar(toolbar)

        title = getString(R.string.title_activity_quote_makerx)
    }

    override fun attachBaseContext(base: Context) {
        super.attachBaseContext(base)
        // Emulates installation of on demand modules using SplitCompat.
        SplitCompat.installActivity(this)
    }
}

res/layout/quotemakerx

<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".view.QuoteMakerXActivity">

    <com.google.android.material.appbar.AppBarLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:theme="@style/AppTheme.AppBarOverlay">

        <androidx.appcompat.widget.Toolbar
            android:id="@+id/toolbar"
            android:layout_width="match_parent"
            android:layout_height="?attr/actionBarSize"
            android:background="?attr/colorPrimary"
            app:popupTheme="@style/AppTheme.PopupOverlay" />

    </com.google.android.material.appbar.AppBarLayout>

    <include layout="@layout/quotemakerx_content" />

    <com.google.android.material.floatingactionbutton.FloatingActionButton
        android:id="@+id/fab"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="bottom|end"
        android:layout_margin="@dimen/fab_margin"
        app:srcCompat="@android:drawable/ic_dialog_email" />

</androidx.coordinatorlayout.widget.CoordinatorLayout>

res/layout/quotemakerx_content

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    app:layout_behavior="@string/appbar_scrolling_view_behavior"
    tools:context=".view.TestActivity"
    tools:showIn="@layout/test">

</androidx.constraintlayout.widget.ConstraintLayout>

AndroidManifest.xml (in Dynamic Feature Module, not app)

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:dist="http://schemas.android.com/apk/distribution"
    package="com.luasoftware.quotemaker">

    <dist:module
        dist:instant="false"
        dist:title="@string/title_quotemaker">
        <dist:delivery>
            <dist:on-demand />
        </dist:delivery>

        <dist:fusing dist:include="true" />
    </dist:module>

    <application>
        <!-- android:label="@string/title_activity_quote_makerx" -->
        <activity
            android:name=".view.QuoteMakerXActivity"
            android:screenOrientation="portrait"
            android:theme="@style/AppTheme.NoActionBar">
            <intent-filter>
                <action android:name="android.intent.action.VIEW" />
            </intent-filter>
        </activity>
    </application>
</manifest>

NOTE: Notice that @string/title_quotemaker or @string/title_activity_quote_makerx must be specified in app’s res\values\strings.xml, as it doesn’t fetch the resource from current modules’s strings.xml. For activity, you still can update label/title during runtime by fetching the string from current modules’s strings.xml.

Load Activity of Dynamic Features Modules

Edit app’s build.gradle.

dependencies {
    implementation 'com.google.android.play:core:1.6.4'
}

Edit Application class.

class LuaApp: MultiDexApplication() {
    override fun onCreate() {
        super.onCreate()
    }

    override fun attachBaseContext(base: Context) {
        super.attachBaseContext(base)
        // Emulates installation of future on demand modules using SplitCompat.
        SplitCompat.install(this)
    }
}

Launch Activity

fun launchActivity(id: String) {
    val packageName = "com.luasoftware.quotemaker"
    val activityClassName = "$packageName.view.QuoteMakerXActivity"

    val intent = Intent(Intent.ACTION_VIEW).setClassName(
            BuildConfig.APPLICATION_ID, // BuildConfig of app Module
            activityClassName
        )
        .putExtra("id", id)
    startActivity(intent)
}

Load Module

val moduleName = "quotemaker"

fun loadModule() {
    val splitInstallManager = SplitInstallManagerFactory.create(context)
    if (!splitInstallManager.installedModules.contains(moduleName)) {
        Timber.d("Install module: $moduleName")
        val request =
            SplitInstallRequest
                .newBuilder()
                // You can download multiple on demand modules per
                // request by invoking the following method for each
                // module you want to install.
                .addModule(moduleName)
                // .addModule("promotionalFilters")
                .build()

        // TODO: move listener code and activeSessionId to ViewModel
        var activeSessionId: Int? = null
        var listener: SplitInstallStateUpdatedListener? = null
        listener = SplitInstallStateUpdatedListener { state ->
            if (state.sessionId() == activeSessionId) {
                when(state.status()) {
                    SplitInstallSessionStatus.DOWNLOADING -> {
                        val percentage = (state.bytesDownloaded() / state.totalBytesToDownload().toFloat() * 100).roundToInt()
                        Timber.d("Downloading $percentage%")
                    }
                    SplitInstallSessionStatus.INSTALLED -> {
                        launchActivity()
                        splitInstallManager.unregisterListener(listener)
                    }
                    SplitInstallSessionStatus.FAILED -> {
                        splitInstallManager.unregisterListener(listener)
                    }
                    else -> {

                    }
                }
            }
        }
        splitInstallManager.registerListener(listener)

        // TODO: need to show some UI loading screen
        splitInstallManager
            // Submits the request to install the module through the
            // asynchronous startInstall() task. Your app needs to be
            // in the foreground to submit the request.
            .startInstall(request)
            // You should also be able to gracefully handle
            // request state changes and errors. To learn more, go to
            // the section about how to Monitor the request state.
            .addOnSuccessListener { sessionId ->
                // should not launch activity here, as success doesn't mean the module is installed
                // listen to SplitInstallStateUpdatedListener event instead
                // launchActivity("SAMPLE_ID")
                activeSessionId = sessionId
            }
            .addOnFailureListener { exception ->
                when ((exception as SplitInstallException).errorCode) {
                    SplitInstallErrorCode.NETWORK_ERROR -> {
                        Timber.d("Network Error")
                    }
                    else -> {
                        Timber.e(exception, "splitInstallManager failed")
                    }
                }
            }
    }
    else {
        Timber.d("Module found: $moduleName")
        launchActivity("SAMPLE_ID")
    }
}

NOTE: When in development mode, splitInstallManager.installedModules.contains(moduleName) seems to always return true.

Uninstall Module

splitInstallManager.deferredUninstall(listOf(moduleName))

NOTE: Seems like unintallation of module is not immediate, thus can’t be used for testing purpose. The only way to uninstall a module for testing is to uninstall the app and re-install again.

Testing

Ways to test App Bundle

In Android Studio -> Run -> Edit Configuration -> Android App -> App -> General -> Installation Options, if Default APK is selected, splitInstallManager.installedModules.contains(moduleName) will always return true. If APK from app bundle is selected, splitInstallManager.startInstall will fail with .SplitInstallException: Split Install Error(-2): A requested module is not available (to this user/device, for the installed apk). I have checked the module under Dynamic features to deploy.

NOTE: I am using Android Studio 3.5.2 and com.google.android.play:core:1.6.4. I assume testing splitInstallManager.startInstall is not possible during development with Android Studio (need to deploy via Google Play on Internal Test Track or Internal App Sharing, which is quite troublesome)

You need to publish your applicate using Google Play Console before you could deploy APK via Google Play for testing. If are not ready to launch yet:

  • You can limit listing to one country only
  • Don’t upload production release
  • At least Internal test track release is required before the App is considered published. The status will change from Ready to publish to Pending publication (awaiting the review approval, might take between 30 minutes to 2 hours)

Internal test track

  • Internal test track is available within minutes after upload.
  • Support up to 100 testers per app
  • Internal tester will still receive internal test even in country where your app is not available. Device exclusion rules don’t apply to internal testers as well.

Generate App Bundle: Android Studio -> Build -> Generate Signed Bundle / APK -> Android App Bundle

Goto Google Play Console -> Release management -> App releases -> Internal test track -> Internal test -> Manage.

Click Create Release to upload the generated and upload the generated .aab file (in <PROJECT>\app\release directory).

NOTE: You probably need to configure App Signing by Google Play.

NOTE: If you are using Google Cloud or Firebase, you probably want to add the App signing certificate SHA-1 certificate fingerprint (Google Play Console -> Select App -> Release Management -> App signing -> App signing certificate).

Expand Manager testers to add Users, and also get the Opt-in URL to be sent to tester.

Click on the Opt-in URL on your Android device, click Join the Programme.

Click on download it on Google Play to start installing the App (there is an indication of Internal Early Access after the App title on the Google Play page).

NOTE: Originally I install via APK (without Google Play). After I join the Internal test track/programme, it is not possible to update the App even though a new version is available (only option available is Uninstall and Open, Update is not available). The App is not listed in the Installed section of Google Play App as well. I uninstalled the App and install via the Internal test track/programme link. Now Update is possible whenever a new update is available, and the App in listed in the Installed section (and listed in Updates section when update is available, with some delay) of Google Play App. Since I disable auto update for Google Play, I have to manually find the APP and click on Update to perform an update.

NOTE: Set up an open, closed, or internal test

Internal app sharing

You need to add authorized testers.

  • Goto Google Play Console and select your app.
  • Goto Development -> Internal app sharing -> Manager Testers. You can choose either
    • Anyone that you share the link with can download
    • Restrict access to email lists
  • Click Save

Generate App Bundle: Android Studio -> Build -> Generate Signed Bundle / APK -> Android App Bundle

Visit https://play.google.com/apps/publish/internalappsharing/ and upload the generated .aab file (in <PROJECT>\app\release directory).

Provide a Version name and click Copy Link when the upload is successful.

On your Android devices, Google Play Store app -> Settings -> About -> Play store version (tap 7 times). Internal app sharing will appear under User control, turn it on.

Click on the Internal app sharing URL link.

NOTE: Internal app sharing version of the app seems incompatible with the public version or the APK version (due to different certificate being used), thus require the public/APK version to be uninstalled before Internal app sharing version could be installed.

NOTE: Share app bundles and APKs internally

Caveats

Each modules can’t have layout file of the same name, else you might bump into the following error when generate Android App Bundle.

Modules ‘base’ and ‘quotemaker’ contain entry ‘res/layout/test.xml’ with different content.

Initially the version code of my base module is 15. Later I upgraded the base module version code to 16 then the build app bundle process complaint for the above error.

App Bundle modules should have the same version code but found [15,16]

NOTE: This is probably a bug, where do a Build -> Rebuild Project will solve this problem.

A few more things

NOTE: Refer to Android App Bundle: Dyanmic Feature Modules ViewModel and Helper Class for more resuable code and helper class.

Refrences:

This work is licensed under a
Creative Commons Attribution-NonCommercial 4.0 International License.