ডায়নামিক ফিচার মডিউল রেসিপি

এই রেসিপি দেখায় যে কীভাবে Navigation 3-এর সাথে ডায়নামিক ফিচার মডিউল (DFM) ইন্টিগ্রেট করতে হয়। এই রেসিপি অনুসরণ করার আগে Play ফিচার ডেলিভারি সম্পর্কে ভালোভাবে জেনে নিন।

এটি কীভাবে কাজ করে

এই উদাহরণে তিনটি আলাদা মডিউল ও ডেলিভারি বিকল্পে স্ক্রিনের জন্য তিনটি কী নির্ধারণ করা হয়েছে:

  • Home: মূল :app মডিউলের একটি স্ক্রিন যা ডায়নামিক ফিচার মডিউল থেকে স্ক্রিনে নেভিগেট করার জন্য বোতাম দেখায়।
  • InstallTimeModule.Home: :dynamicfeature:installtime মডিউলের একটি স্ক্রিন, যা ডেলিভারির সময় ইনস্টল করা হয়। যেমন, এটি এমন একটি অ্যাসেট-সমৃদ্ধ অনবোর্ডিং মডিউলের জন্য ব্যবহার করা হতে পারে যা অ্যাপ প্রথমবার ইনস্টল করার সময় প্রয়োজন হয়, কিন্তু অনবোর্ডিং সম্পূর্ণ হওয়ার পরে যা মুছে ফেলা যেতে পারে।
  • OnDemandModule.Home: :dynamicfeature:ondemand মডিউলের একটি স্ক্রিন, যা চাহিদা অনুযায়ী ইনস্টল করা হয়। যেমন, এটি এমন মডিউলের জন্য ব্যবহার করা হতে পারে যার ফাইল সাইজ অনেক বড় এবং ব্যবহারকারীর খুব ছোট একটি অংশই এটি ব্যবহার করবে বলে আশা করা হয়।

DynamicModule

DynamicModule হল একটি অ্যাবস্ট্রাক্ট ক্লাস যা ডায়নামিক ফিচার মডিউল মডেল করতে ব্যবহার করা হয়। অর্থাৎ, এটি সংশ্লিষ্ট DynamicModuleEntryBuilder-এর জন্য moduleName ও entryBuilderClassName, দুটিই স্টোর করে (পরবর্তী বিভাগে বর্ণনা করা হয়েছে)।

object ExampleModule : DynamicModule(
    entryBuilderClassName = "com.example.module.ExampleEntryBuilder",
    moduleName = "example"
) {
    @Serializable
    data object Screen : AppNavKey
}

DynamicModuleEntryBuilder

প্রতিটি ডায়নামিক ফিচার মডিউলে DynamicModuleEntryBuilder-এর প্রয়োগ অবশ্যই থাকতে হবে, যা মূল :app মডিউলের entryProvider-এ সেই মডিউলের এন্ট্রি যোগ করতে ব্যবহার করা হয়।

// Inside :example module

@Suppress("unused")
class ExampleEntryBuilder : DynamicModuleEntryBuilder {
    override fun EntryProviderScope<NavKey>.build() {
        appEntry<ExampleModule.Screen> {
            ExampleScreen()
        }
    }
}

@Composable
private fun ExampleScreen() {
    // ...
}

buildDynamicEntries()

EntryProviderScope-এর একটি এক্সটেনশন ফাংশন যা সেই মডিউলের জন্য DynamicModuleEntryBuilder সমাধান করে এবং এর build() ফাংশনকে কল করে।

// Inside :app module

val ALL_DYNAMIC_MODULES_MAP = listOf(
   ExampleModule,
   // ...
).associateBy { it.moduleName }

NavDisplay(
    // ...
    entryProvider = entryProvider {
        // ...
        dynamicFeatureManager.installedModules
            .mapNotNull { ALL_DYNAMIC_MODULES_MAP[it] }
            .forEach { buildDynamicEntries(it) }
    }
)

DynamicFeatureManager

ডায়নামিক ফিচার মডিউল ইনস্টলেশন ম্যানেজ করে এমন ক্লাস।

ডায়নামিক ফিচার মডিউলের মধ্যে থাকা কোনও কী-তে নেভিগেট করতে, installModule পদ্ধতি ব্যবহার করুন, যা আগে থেকে ইনস্টল করা না থাকলে সংশ্লিষ্ট মডিউল ইনস্টল করা এবং মডিউল ইনস্টল করা হলে কলব্যাক এক্সিকিউট করার কাজ অটোমেটিক ম্যানেজ করে। মডিউলটি আগে থেকেই ইনস্টল করা থাকলে, এই কলব্যাকটি অবিলম্বে চালু করা হয়।

// Inside :app module

// Initialize the manager
val dynamicFeatureManager = retainDynamicFeatureManager()

// E.g. in a Button's onClick
dynamicFeatureManager.installModule(
    moduleName = ExampleModule.moduleName,
    onModuleInstalled = {
        backStack.add(ExampleModule.Screen)
    }
)

ইনস্টলেশনের প্রোগ্রেস মনিটর করতে, DynamicFeatureDownloadProgressDialog কম্পোজেবলে ম্যানেজার অ্যাটাচ করুন:

DynamicFeatureDownloadProgressDialog(dynamicFeatureManager)

Proguard নিয়ম

R8 মিনিফিকেশন চালু করা হলে, entryBuilderClassName-এর মাধ্যমে রেফার করা ক্লাস অ্যাক্সেস করা যাবে কিনা তা নিশ্চিত করতে, proguard-rules.pro-এ এই নিয়ম যোগ করুন:

-keep class * implements com.example.nav3recipes.dynamicfeature.DynamicModuleEntryBuilder {
   public <init>();
}

কীভাবে স্থানীয়ভাবে পরীক্ষা করবেন

ইমপ্লিমেন্টেশন কাজ করছে কিনা তা পরীক্ষা করে দেখতে, শুধু অ্যাপটি চালান এবং টার্গেট মডিউলের এন্ট্রিগুলিতে নেভিগেট করুন। Android Studio, রান কনফিগারেশনে ডিফল্ট হিসেবে সব ডায়নামিক ফিচার মডিউল অন্তর্ভুক্ত করবে।

মডিউল ডাউনলোড ও ইনস্টলেশন সিমুলেট করতে, bundletool ব্যবহার করুন।

পরামর্শ: আপনি Homebrew (brew install bundletool)-এর মাধ্যমে bundletool ইনস্টল করে থাকলে, নিচের কমান্ডে java -jar bundletool.jar-এর পরিবর্তে শুধু bundletool ব্যবহার করতে পারবেন।

  1. প্রোজেক্ট AAB তৈরি করুন।

    ./gradlew :app:bundleDebug
    
  2. bundletool ব্যবহার করে লোকাল টেস্টিং চালু রেখে তৈরি করা AAB-কে APKS-এ কনভার্ট করুন।

    java -jar bundletool.jar build-apks --local-testing --bundle <project-path>/app/build/outputs/bundle/debug/app-debug.aab --output app-debug.apks --overwrite
    
  3. bundletool ব্যবহার করে কনভার্ট করা APKS ইনস্টল করুন।

    java -jar bundletool.jar install-apks --apks app-debug.apks
    
  4. লঞ্চার আইকনের মাধ্যমে অ্যাপটি চালান।

এখন আপনি প্রথমবার কোনও অন-ডিমান্ড মডিউল এন্ট্রিতে নেভিগেট করার সময় ডাউনলোড প্রগ্রেস ডায়ালগ দেখতে পাবেন।

প্রয়োগ সংক্রান্ত নোট

DynamicFeatureManager

  • এই রেসিপি যেকোনও সময় শুধুমাত্র একটি ইনস্টলেশন সেশন সমর্থন করে। আপনার অ্যাপকে যদি একই সাথে একাধিক সেশন ব্যবহার করতে হয়, তাহলে আপনাকে একই সাথে একাধিক সেশন ট্র্যাক করার জন্য ম্যানেজারকে মানিয়ে নিতে হবে।
  • এই রেসিপি নেটওয়ার্কের সমস্যা বা ইন্টার্নাল সমস্যার মতো বিষয়ের জন্য অটোমেটিক আবার চেষ্টা করার সুবিধা প্রয়োগ করে না। অতিরিক্ত তথ্যের জন্য অনুরোধ সংক্রান্ত সমস্যার সমাধান করা দেখুন।
  • আপনার অ্যাপ অন্য মডিউল থেকে রিসোর্স ও অ্যাসেস অ্যাক্সেস করলে, মনে রাখবেন যে কোনও মডিউল ইনস্টল করার পরে এই রেসিপি অটোমেটিক SplitCompat ইনস্টল করে না।
/*
 * Copyright 2026 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.example.nav3recipes.dynamicfeature

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.dropUnlessResumed
import androidx.navigation3.runtime.entryProvider
import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.ui.NavDisplay
import com.example.nav3recipes.content.ContentGreen
import com.example.nav3recipes.ui.setEdgeToEdgeConfig
import kotlinx.serialization.Serializable

class DynamicFeatureActivity : ComponentActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        setEdgeToEdgeConfig()
        super.onCreate(savedInstanceState)
        setContent {
            val backStack = rememberNavBackStack(Home)
            val dynamicFeatureManager = retainDynamicFeatureManager()

            DynamicFeatureDownloadProgressDialog(dynamicFeatureManager)

            NavDisplay(
                backStack = backStack,
                onBack = { backStack.removeLastOrNull() },
                modifier = Modifier.fillMaxSize(),
                entryProvider = entryProvider {
                    appEntry<Home> {
                        HomeScreen(
                            onNavigateToInstallTime = {
                                dynamicFeatureManager.installModule(
                                    moduleName = InstallTimeModule.moduleName,
                                    onModuleInstalled = {
                                        backStack.add(InstallTimeModule.Home)
                                    }
                                )
                            },
                            onNavigateToOnDemand = {
                                dynamicFeatureManager.installModule(
                                    moduleName = OnDemandModule.moduleName,
                                    onModuleInstalled = {
                                        backStack.add(OnDemandModule.Home)
                                    }
                                )
                            },
                        )
                    }

                    dynamicFeatureManager.installedModules
                        .mapNotNull { ALL_DYNAMIC_MODULES_MAP[it] }
                        .forEach { buildDynamicEntries(it) }
                }
            )
        }
    }
}

@Composable
fun HomeScreen(
    onNavigateToInstallTime: () -> Unit,
    onNavigateToOnDemand: () -> Unit,
    modifier: Modifier = Modifier,
) {
    ContentGreen(
        title = "Home screen",
        modifier = modifier,
    ) {
        Column {
            Button(onClick = dropUnlessResumed { onNavigateToInstallTime() }) {
                Text(text = "Go to Install Time Module Screen")
            }
            Button(onClick = dropUnlessResumed { onNavigateToOnDemand() }) {
                Text(text = "Go to On Demand Module Screen")
            }
        }
    }
}

@Serializable
private data object Home : AppNavKey

private val ALL_DYNAMIC_MODULES_MAP = listOf(
    InstallTimeModule,
    OnDemandModule
).associateBy { it.moduleName }

object InstallTimeModule : DynamicModule(
    entryBuilderClassName = "com.example.dynamicfeature.installtime.InstallTimeEntryBuilder",
    moduleName = "installtime",
) {
    @Serializable
    data object Home : AppNavKey {
        override fun toContentKey(): Any {
            return "InstallTimeHome"
        }
    }
}

object OnDemandModule : DynamicModule(
    entryBuilderClassName = "com.example.dynamicfeature.ondemand.OnDemandEntryBuilder",
    moduleName = "ondemand"
) {
    @Serializable
    data object Home : AppNavKey {
        override fun toContentKey(): Any {
            return "OnDemandHome"
        }
    }
}
/*
 * Copyright 2026 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.example.nav3recipes.dynamicfeature

import android.content.Context
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.IntentSenderRequest
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.retain.retain
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import com.google.android.play.core.ktx.bytesDownloaded
import com.google.android.play.core.ktx.errorCode
import com.google.android.play.core.ktx.sessionId
import com.google.android.play.core.ktx.status
import com.google.android.play.core.ktx.totalBytesToDownload
import com.google.android.play.core.splitinstall.SplitInstallManagerFactory
import com.google.android.play.core.splitinstall.SplitInstallRequest
import com.google.android.play.core.splitinstall.SplitInstallSessionState
import com.google.android.play.core.splitinstall.SplitInstallStateUpdatedListener
import com.google.android.play.core.splitinstall.model.SplitInstallSessionStatus

@Composable
fun retainDynamicFeatureManager(): DynamicFeatureManager {
    val applicationContext = LocalContext.current.applicationContext

    val manager = retain {
        DynamicFeatureManager(applicationContext)
    }

    DisposableEffect(manager) {
        onDispose {
            manager.dispose()
        }
    }

    return manager
}

sealed interface InstallStatus {
    data object Idle : InstallStatus
    data class Pending(val sessionId: Int) : InstallStatus
    data class Downloading(val sessionId: Int, val progress: Float) : InstallStatus
    data class Installing(val sessionId: Int) : InstallStatus
    data class RequiresUserConfirmation(val state: SplitInstallSessionState) : InstallStatus
    data class Failed(val sessionId: Int, val errorCode: Int) : InstallStatus
}

@Stable
class DynamicFeatureManager(context: Context) {
    private val splitInstallManager = SplitInstallManagerFactory.create(context)

    // Maintain a single listener for the lifetime of the manager
    private val listener = SplitInstallStateUpdatedListener { state ->
        val sessionId = state.sessionId
        // Capture session ID if we're expecting an install but haven't got the ID yet (race condition)
        if (activeSessionId == null && activeModuleName != null) {
            if (state.moduleNames().contains(activeModuleName)) {
                activeSessionId = sessionId
            }
        }

        if (sessionId == activeSessionId) {
            updateStatus(state)
        }
    }

    private var activeSessionId: Int? = null
    private var activeModuleName: String? = null
    private var onModuleInstalledCallback: (() -> Unit)? = null

    var status by mutableStateOf<InstallStatus>(InstallStatus.Idle)
        private set

    var installedModules: Set<String> by mutableStateOf(splitInstallManager.installedModules.toSet())
        private set

    init {
        splitInstallManager.registerListener(listener)
    }

    fun dispose() {
        splitInstallManager.unregisterListener(listener)
    }

    fun installModule(moduleName: String, onModuleInstalled: () -> Unit) {
        if (splitInstallManager.installedModules.contains(moduleName)) {
            onModuleInstalled()
            return
        }

        // Avoid starting multiple installs if one is already in progress
        if (status !is InstallStatus.Idle && status !is InstallStatus.Failed) return

        activeModuleName = moduleName
        onModuleInstalledCallback = onModuleInstalled
        // Use a placeholder ID to indicate we are waiting for a session
        status = InstallStatus.Pending(-1)

        splitInstallManager.startInstall(
            SplitInstallRequest.newBuilder().addModule(moduleName).build()
        ).addOnSuccessListener { sessionId ->
            if (activeSessionId == null) {
                activeSessionId = sessionId
            }
            // Only update status if it hasn't progressed beyond our placeholder
            if (status is InstallStatus.Pending && (status as InstallStatus.Pending).sessionId == -1) {
                status = InstallStatus.Pending(sessionId)
            }
        }.addOnFailureListener {
            status = InstallStatus.Failed(-1, -1)
            onModuleInstalledCallback = null
        }
    }

    private fun clearSessionState() {
        activeSessionId = null
        activeModuleName = null
        onModuleInstalledCallback = null
    }

    private fun updateStatus(state: SplitInstallSessionState) {
        val sessionId = state.sessionId
        when (state.status) {
            SplitInstallSessionStatus.PENDING -> {
                status = InstallStatus.Pending(sessionId)
            }

            SplitInstallSessionStatus.DOWNLOADING -> {
                val progress = if (state.totalBytesToDownload > 0) {
                    state.bytesDownloaded.toFloat() / state.totalBytesToDownload
                } else 0f
                status = InstallStatus.Downloading(sessionId, progress)
            }

            SplitInstallSessionStatus.DOWNLOADED -> {
                status = InstallStatus.Installing(sessionId)
            }

            SplitInstallSessionStatus.INSTALLING -> {
                status = InstallStatus.Installing(sessionId)
            }

            SplitInstallSessionStatus.REQUIRES_USER_CONFIRMATION -> {
                status = InstallStatus.RequiresUserConfirmation(state)
            }

            SplitInstallSessionStatus.INSTALLED -> {
                status = InstallStatus.Idle
                installedModules = splitInstallManager.installedModules.toSet()
                onModuleInstalledCallback?.invoke()
                clearSessionState()
            }

            SplitInstallSessionStatus.FAILED -> {
                status = InstallStatus.Failed(sessionId, state.errorCode)
                clearSessionState()
            }

            SplitInstallSessionStatus.CANCELING -> {
                status = InstallStatus.Pending(sessionId)
            }

            SplitInstallSessionStatus.CANCELED -> {
                status = InstallStatus.Idle
                clearSessionState()
            }

            SplitInstallSessionStatus.UNKNOWN -> {
                status = InstallStatus.Failed(sessionId, -1)
                clearSessionState()
            }
        }
    }

    fun startConfirmationDialogForResult(
        state: SplitInstallSessionState,
        launcher: ActivityResultLauncher<IntentSenderRequest>
    ) {
        splitInstallManager.startConfirmationDialogForResult(state, launcher)
    }

    fun cancelInstallModule() {
        activeSessionId?.let { id ->
            splitInstallManager.cancelInstall(id)
        }
        status = InstallStatus.Idle
        clearSessionState()
    }
}
/*
 * Copyright 2026 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.example.nav3recipes.dynamicfeature

import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation3.runtime.NavKey

interface AppNavKey : NavKey {
    // By default, `toString()` is used to generate the `contentKey` for NavEntries.
    // Instead of overriding `toString()`, we provide a dedicated `toContentKey()` function to make
    // it possible to uniquely identify keys for nested objects with the same simple name
    // (e.g., `ModuleA.Home` vs `ModuleB.Home`), without polluting the `toString()` representation.
    fun toContentKey(): Any = this.toString()
}

/**
 * An extension on [EntryProviderScope] specifically for [AppNavKey]s that overrides the default
 * `contentKey` resolution. It explicitly resolves the key via `it.toContentKey()` instead of the default
 * string representation.
 */
inline fun <reified K : AppNavKey> EntryProviderScope<NavKey>.appEntry(
    noinline clazzContentKey: (key: @JvmSuppressWildcards K) -> Any = { it.toContentKey() },
    metadata: Map<String, Any> = emptyMap(),
    noinline content: @androidx.compose.runtime.Composable (K) -> Unit,
) {
    addEntryProvider(K::class, clazzContentKey, { metadata }, content)
}
/*
 * Copyright 2026 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.example.nav3recipes.dynamicfeature


import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import kotlin.math.roundToInt

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DynamicFeatureDownloadProgressDialog(
    dynamicFeatureManager: DynamicFeatureManager,
    modifier: Modifier = Modifier,
) {
    val status = dynamicFeatureManager.status

    if (status is InstallStatus.Idle) return

    val launcher = rememberLauncherForActivityResult(
        contract = ActivityResultContracts.StartIntentSenderForResult()
    ) {
        // The session status will be updated automatically via the
        // SplitInstallStateUpdatedListener in DynamicFeatureManager,
        // so no explicit handling is required here.
    }

    val progress = if (status is InstallStatus.Downloading) status.progress else 0f
    val progressPercentage = "${(progress * 100).roundToInt()}%"

    AlertDialog(
        onDismissRequest = {},
        modifier = modifier,
        title = {
            Text(text = getStatusTitle(status))
        },
        text = {
            Column(
                verticalArrangement = Arrangement.spacedBy(16.dp),
                horizontalAlignment = Alignment.CenterHorizontally,
                modifier = Modifier.fillMaxWidth(),
            ) {
                if (status is InstallStatus.Downloading) {
                    CircularProgressIndicator(
                        progress = { progress },
                        modifier = Modifier.semantics {
                            contentDescription = "Downloading: $progressPercentage"
                        }
                    )
                    Text(
                        text = progressPercentage,
                        style = MaterialTheme.typography.labelSmall,
                    )
                } else if (status !is InstallStatus.Failed && status !is InstallStatus.RequiresUserConfirmation) {
                    CircularProgressIndicator()
                }

                if (status is InstallStatus.Failed) {
                    Text(
                        text = "Error code: ${status.errorCode}",
                        color = MaterialTheme.colorScheme.error,
                        style = MaterialTheme.typography.bodySmall,
                    )
                }
            }
        },
        confirmButton = {
            if (status is InstallStatus.RequiresUserConfirmation) {
                Button(onClick = {
                    dynamicFeatureManager.startConfirmationDialogForResult(
                        status.state,
                        launcher
                    )
                }) {
                    Text(text = "Confirm Download")
                }
            }
        },
        dismissButton = {
            TextButton(onClick = dynamicFeatureManager::cancelInstallModule) {
                Text(text = "Cancel")
            }
        }
    )
}

private fun getStatusTitle(status: InstallStatus): String {
    return when (status) {
        is InstallStatus.Pending -> "Requesting..."
        is InstallStatus.Downloading -> "Downloading..."
        is InstallStatus.Installing -> "Installing..."
        is InstallStatus.RequiresUserConfirmation -> "Requires Confirmation"
        is InstallStatus.Failed -> "Installation Failed"
        else -> ""
    }
}
/*
 * Copyright 2026 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.example.nav3recipes.dynamicfeature

import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation3.runtime.NavKey

/**
 * Base class for defining a dynamic feature module.
 *
 * Caches the [DynamicModuleEntryBuilder] instance to guarantee that the reflection instantiation
 * via [Class.forName] and [Class.newInstance] occurs exactly once per application process lifetime,
 * optimizing performance during recompositions when the `entryProvider` block is re-evaluated.
 */
abstract class DynamicModule(
    val entryBuilderClassName: String,
    val moduleName: String,
) {
    private var dynamicModuleEntryBuilder: DynamicModuleEntryBuilder? = null

    internal fun getDynamicModuleEntryBuilder(): DynamicModuleEntryBuilder {
        return dynamicModuleEntryBuilder ?: (Class.forName(entryBuilderClassName)
            .getConstructor()
            .newInstance() as DynamicModuleEntryBuilder).also { dynamicModuleEntryBuilder = it }
    }
}

/**
 * Interface that every dynamic feature module must implement to register its navigation entries.
 *
 * It is invoked by [buildDynamicEntries] to add the module's routes into the base [EntryProviderScope].
 *
 * **Example:**
 * ```kotlin
 * class ExampleEntryBuilder : DynamicModuleEntryBuilder {
 *     override fun EntryProviderScope<NavKey>.build() {
 *         appEntry<ExampleModule.Screen> {
 *             ExampleScreen()
 *         }
 *     }
 * }
 * ```
 */
fun interface DynamicModuleEntryBuilder {
    fun EntryProviderScope<NavKey>.build()
}

fun EntryProviderScope<NavKey>.buildDynamicEntries(
    module: DynamicModule,
) {
    with(module.getDynamicModuleEntryBuilder()) {
        build()
    }
}