How to Use PSPDFKit for Android with Dynamic Delivery

Dynamic delivery — or on-demand delivery — was introduced to Android to allow developers to ship only the necessary features of an app while allowing optional features to be downloaded as needed by the user. These on-demand features — known as dynamic feature modules (DFM) — are downloaded automatically during runtime rather than download/install time. Note that these features are downloaded only once. In the case of our PSPDFKit SDK, this feature becomes useful when a user only wants to view a PDF.

Using PSPDFKit for Android in a Dynamic Feature Module in Android Studio

  1. Follow the steps listed here to add a dynamic feature module. Choose the settings that apply to you as explained on the page.

  2. Next, add the following dependencies to your app-level build.gradle file:

    api 'com.pspdfkit:pspdfkit:2024.4.0'
    api 'com.google.android.play:feature-delivery:2.0.1'
    api 'com.google.android.play:feature-delivery-ktx:2.0.1'

Note the use of the api configuration, because these libraries will be used by all modules in the test application. If you have more than two modules, it makes more sense to add the libraries individually in each module using the implementation configuration. You have to add these dependencies in the base module, as well as the module containing the PSPDFKit library. Read more on why this is happening on this issue.

  1. Follow the steps listed on this page to add a simple implementation of the PSPDFKit SDK. In this example, store your test PDF file inside the base module’s assets folder. In an actual project, this would be replaced by the user picking a specific file to display from the device, or another use case that doesn’t necessarily involve packaging a PDF file in the app.

  2. Add the following lines of code at the top of the MainActivity file of the base module:

    private var mySessionId = 0
    private lateinit var splitInstallManager: SplitInstallManager
    private val stateLiveData = MutableLiveData<DownloadState>()
    
    companion object {
    // The test PDF file is stored in the main assets folder and is called `pdfquick.pdf`.
    const val FILE_NAME = "file:///android_asset/pdfquick.pdf"
    }
    
    // Creates a listener for request status updates.
    private val listener = SplitInstallStateUpdatedListener { state ->
    if (state.sessionId() == mySessionId) {
        when (state.status()) {
            SplitInstallSessionStatus.CANCELED -> {
                stateLiveData.value = DownloadState(state = "CANCELED")
            }
            SplitInstallSessionStatus.CANCELING -> {
                stateLiveData.value = DownloadState(state = "CANCELING")
            }
            SplitInstallSessionStatus.DOWNLOADED -> {
                stateLiveData.value = DownloadState(state = "DOWNLOADED")
            }
            SplitInstallSessionStatus.DOWNLOADING -> {
                stateLiveData.value = DownloadState(state = "DOWNLOADING")
            }
            SplitInstallSessionStatus.FAILED -> {
                stateLiveData.value = DownloadState(state = "FAILED")
            }
            SplitInstallSessionStatus.INSTALLED -> {
                val modules = state.moduleNames()
                stateLiveData.value = DownloadState(modules.contains("pspdfDynamicFeature"), "INSTALLED")
            }
            SplitInstallSessionStatus.INSTALLING -> {
                stateLiveData.value = DownloadState(state = "PENDING")
            }
            SplitInstallSessionStatus.PENDING -> {
                stateLiveData.value = DownloadState(state = "PENDING")
            }
            SplitInstallSessionStatus.REQUIRES_USER_CONFIRMATION -> {
                stateLiveData.value = DownloadState(state = "REQUIRES_USER_CONFIRMATION")
            }
            SplitInstallSessionStatus.UNKNOWN -> {
                stateLiveData.value = DownloadState(state = "UNKNOWN error")
            }
        }
    }
    }
    
    data class DownloadState(
    val isDownloaded: Boolean = false,
    val state: String
    )

With this code, there’s a way to track the session ID of the operation to download the DFM. There’s also a LiveData variable to track the download state and update the user of the status. In a production application, this would be replaced by a progress indicator or something similar. splitInstallManager handles the requesting and installation of split APKs, and the session ID will be initialized in the next step.

  1. splitInstallManager needs to be initialized, and the listener above needs to be attached to the download request. Initialize splitInstallManager = SplitInstallManagerFactory.create(this) in onCreate, attach a click listener to a button to trigger the download, and then observe the LiveData variable. onCreate then becomes:

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
    
        splitInstallManager = SplitInstallManagerFactory.create(this)
    
        val stateText = findViewById<TextView>(R.id.text_state)
        findViewById<Button>(R.id.btn_download_show).setOnClickListener {
            downloadDynamicModule()
        }
    
        stateLiveData.observe(this) { downloadState ->
            val builder = StringBuilder()
            builder.appendLine(downloadState.state)
            stateText.text = builder
    
            if (downloadState.isDownloaded) {
                val intent = Intent().setClassName(
                    BuildConfig.APPLICATION_ID, "com.pspdfkitexample.pspdfdynamicfeature.PDFDynamicActivity"
                )
                startActivity(intent)
            }
        }
    }

The LiveData variable observes the state, updates the UI TextView, and triggers navigation the moment the value for isDownloaded is true.

Information

Make sure your dynamic module has the same package and activity names if you wish to use the exact navigation code above. Otherwise, the app will crash once the attempt to trigger navigation happens. If the package names are different, modify the intent as well.

The method downloadDynamicModule is:

private fun downloadDynamicModule() {
    val request = SplitInstallRequest.newBuilder().addModule("pspdfDynamicFeature").build()

    splitInstallManager.registerListener(listener)

    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.
        .addOnSuccessListener { sessionId ->
            mySessionId = sessionId
        }.addOnFailureListener { exception ->
            Toast.makeText(
                this,
                "Request to download dynamic module has failed with the error ${exception.message}", Toast.LENGTH_LONG
            ).show()
        }
}
  1. Finally, unregister the splitInstallManager in your onStop, or when it’s no longer needed:

    override fun onStop() {
        super.onStop()
        splitInstallManager.unregisterListener(listener)
    }

Testing the App

Testing the app can be done in one of two ways:

  1. Uploading a release application to the Google Play Store in the internal test track, and then installing on a device to confirm the downloading works as expected.

  2. Using bundletool by Google locally. Once this tool has been installed, navigate to the folder with the generated debug bundle file named app_debug.aab and start a terminal there. Now, run the following two bundletool commands from the terminal. This will simulate a download of the DFM.

    ```shell
    bundletool build-apks --local-testing --bundle app_debug.aab --output my_app.apks
    
    bundletool install-apks --apks my_app.apks
    
    ```

The first command builds the necessary apk file with the local-testing flag, while the second installs the apk on the device, assuming there’s only one device connected/running.