LoadedApk$ServiceDispatcher.mContext is Leaking

Viewed 940

I am getting this leak after several rotation and app being in background. Here is stack trace which I m not able to understand the cause. Also 32474006 bytes retained object is very much. I have 10 same leak.

32474006 bytes retained by leaking objects
Displaying only 1 leak trace out of 10 with the same signature
Signature: 329ec5b3be0cfe3ed2fc888129f5a6be93fb9
┬───
│ GC Root: Global variable in native code
│
├─ android.app.LoadedApk$ServiceDispatcher$DeathMonitor instance
│    Leaking: UNKNOWN
│    ↓ LoadedApk$ServiceDispatcher$DeathMonitor.this$0
│                                               ~~~~~~
├─ android.app.LoadedApk$ServiceDispatcher instance
│    Leaking: UNKNOWN
│    ↓ LoadedApk$ServiceDispatcher.mContext
│                                  ~~~~~~~~
╰→ com.ics.homework.ui.MainActivity instance
​     Leaking: YES (ObjectWatcher was watching this because com.ics.homework.ui.MainActivity received Activity#onDestroy() callback and Activity#mDestroyed is true)
​     key = 8bcc50f8-ea3f-47d9-8dc3-904042a58df4
​     watchDurationMillis = 60220
​     retainedDurationMillis = 55216
====================================
0 LIBRARY LEAKS

Cause of Leak

@AndroidEntryPoint
class MainActivity : AppCompatActivity(), NavController.OnDestinationChangedListener { 

....

override fun onStart() {
    super.onStart()
    findChromeCustomTabsNavigator(navController).bindCustomTabsService()
}
....
}

I have tried to implement Chrome Custom Tab using This Tutorial

@Navigator.Name("chrome")
class ChromeCustomTabsNavigator(
private val context: Context
) : Navigator<ChromeCustomTabsNavigator.Destination>() {

/**
 * Initialized when `findChromeCustomTabsNavigator().bindCustomTabsService()` is called.
 */
private var session: CustomTabsSession? = null

private val urisInProgress = mutableMapOf<Uri, Long>()

private var connection :CustomTabsServiceConnection?= null

/**
 * Prevent the user from repeatedly launching Chrome Custom Tabs for the same URL. Throttle
 * rapid repeats unless the URL has finished loading, or this timeout has passed (just in
 * case something went wrong with detecting that the page finished loading).
 * Feel free to change this value with [Fragment.findChromeCustomTabsNavigator.throttleTimeout()]
 * if you feel the need, or for testing purposes.
 * Defaults to two seconds.
 */
@SuppressWarnings("WeakerAccess")
var throttleTimeout: Long = 2000L

private val upIconBitmap: Bitmap by lazy {
    AppCompatResources.getDrawable(context, R.drawable.ic_baseline_keyboard_backspace_24)?.toBitmap()!!
}

override fun createDestination() =
    Destination(this)

override fun navigate(
    destination: Destination, args: Bundle?, navOptions: NavOptions?, navigatorExtras: Extras?
): NavDestination? {
    // The Navigation framework enforces the destination URL being non-null
    val uri = args?.getParcelable<Uri>(KEY_URI)!!

    if (!shouldAllowLaunch(uri)) return null

    buildCustomTabsIntent(destination).launchUrl(context, uri)

    return null // Do not add to the back stack, managed by Chrome Custom Tabs
}

override fun popBackStack() = true // Managed by Chrome Custom Tabs

private fun buildCustomTabsIntent(destination: Destination): CustomTabsIntent {
    val builder = CustomTabsIntent.Builder()
    val params = CustomTabColorSchemeParams.Builder()

    session?.let { builder.setSession(it) }
    builder.setColorScheme(destination.colorScheme)
    if (destination.toolbarColor != 0) {
        params.setToolbarColor(ContextCompat.getColor(context, destination.toolbarColor))
    }
    if (destination.navigationBarColor != 0) {
        params.setNavigationBarColor(ContextCompat.getColor(context, destination.navigationBarColor))
    }
    builder.setDefaultColorSchemeParams(params.build())
    builder.setStartAnimations(context, destination.enterAnim, destination.popEnterAnim)
    builder.setExitAnimations(context, destination.popExitAnim, destination.exitAnim)
    builder.setShowTitle(destination.showTitle)
    if (destination.upInsteadOfClose) {
        builder.setCloseButtonIcon(upIconBitmap)
    }
    if (destination.addDefaultShareMenuItem) {
        builder.setShareState(CustomTabsIntent.SHARE_STATE_ON)
    }
    val customTabsIntent = builder.build()

    // Adding referrer so websites know where their traffic came from, per Google's recommendations:
    // https://medium.com/google-developers/best-practices-for-custom-tabs-5700e55143ee
    customTabsIntent.intent.putExtra(
        Intent.EXTRA_REFERRER, Uri.parse("android-app://" + context.packageName)
    )
    return customTabsIntent
}

private fun shouldAllowLaunch(uri: Uri): Boolean {
    urisInProgress[uri]?.let { tabStartTime ->
        // Have we launched this URI before recently?
        if (System.currentTimeMillis() - tabStartTime > throttleTimeout) {
            // Since we've exceeded the throttle timeout, continue as normal, launching
            // the destination and updating the time.
            Timber.w("Throttle timeout for $uri exceeded. This means ChromeCustomTabsNavigator failed to accurately determine that the URL finished loading. If you see this error frequently, it could indicate a bug in ChromeCustomTabsNavigator.")
        } else {
            // The user has tried to repeatedly open the same URL in rapid succession. Let them chill.
            // The tab probably just hasn't opened yet. Abort opening the tab a second time.
            urisInProgress.remove(uri)
            return false
        }
    }
    urisInProgress[uri] = System.currentTimeMillis()
    return true
}

/**
 * Boilerplate setup for Chrome Custom Tabs. This should suffice for most apps using Chrome
 * Custom Tabs with the Navigation component. It warms up Chrome in advance to save a few
 * milliseconds, and sets a [CustomTabsSession] for the [ChromeCustomTabsNavigator] so that
 * [CustomTabsSession.mayLaunchUrl] can be called from application code.
 */
fun bindCustomTabsService() {
    connection = object : CustomTabsServiceConnection() {
        override fun onCustomTabsServiceConnected(name: ComponentName, client: CustomTabsClient) {
            client.warmup(0L)
            session = client.newSession(customTabsCallback)
            //context.unbindService(this)
        }

        override fun onServiceDisconnected(name: ComponentName?) {}
    }
    CustomTabsClient.bindCustomTabsService(context, CUSTOM_TAB_PACKAGE_NAME, connection!!)
}

fun unBindCustomTabsService(){
    if(connection !=null) return
    context.unbindService(connection!!)
}

/**
 * Possibly pre-load one or more URLs. Note that
 * per https://developer.chrome.com/multidevice/android/customtabs#pre-render-content,
 * mayLaunchUrl should only be used if the odds are at least 50% of the user clicking
 * the link.
 * @see [CustomTabsSession.mayLaunchUrl] for more details on mayLaunchUrl.
 */
fun mayLaunchUrl(url: Uri, extras: Bundle? = null, otherLikelyBundles: List<Bundle>? = null) {
    session?.mayLaunchUrl(url, extras, otherLikelyBundles)
}

val customTabsCallback: CustomTabsCallback by lazy {
    object : CustomTabsCallback() {
        override fun onNavigationEvent(navigationEvent: Int, extras: Bundle?) {
            when (navigationEvent) {
                NAVIGATION_ABORTED, NAVIGATION_FAILED, NAVIGATION_FINISHED -> {
                    // Navigation has finished. Remove the indication that page has not finished
                    // loading, so we will allow the user to try to open the same page again.
                    with(urisInProgress.entries) {
                        remove(first())
                    }
                }
            }
        }
    }
}

companion object {
    private const val TAG = "ChromeTabsNavigator"
    private const val CUSTOM_TAB_PACKAGE_NAME = "com.android.chrome"
    const val KEY_URI = "uri"
}

@NavDestination.ClassType(Activity::class)
class Destination(navigator: Navigator<out NavDestination>) : NavDestination(navigator) {

    var colorScheme: Int = 1

    @ColorRes
    var toolbarColor: Int = 0

    @ColorRes
    var navigationBarColor: Int = 0

    @AnimRes
    var enterAnim: Int = 0

    @AnimRes
    var exitAnim: Int = 0

    @AnimRes
    var popEnterAnim: Int = 0

    @AnimRes
    var popExitAnim: Int = 0

    var showTitle: Boolean = false

    var upInsteadOfClose: Boolean = false

    var addDefaultShareMenuItem: Boolean = false

    override fun onInflate(context: Context, attrs: AttributeSet) {
        super.onInflate(context, attrs)

        context.withStyledAttributes(attrs, R.styleable.ChromeCustomTabsNavigator, 0, 0) {
            colorScheme = getInt(R.styleable.ChromeCustomTabsNavigator_colorScheme, 0)
            toolbarColor = getResourceId(R.styleable.ChromeCustomTabsNavigator_toolbarColor, 0)
            navigationBarColor =
                getResourceId(R.styleable.ChromeCustomTabsNavigator_navigationBarColor, 0)
            enterAnim = getResourceId(R.styleable.ChromeCustomTabsNavigator_enterAnim, 0)
            exitAnim = getResourceId(R.styleable.ChromeCustomTabsNavigator_exitAnim, 0)
            popEnterAnim = getResourceId(R.styleable.ChromeCustomTabsNavigator_popEnterAnim, 0)
            popExitAnim = getResourceId(R.styleable.ChromeCustomTabsNavigator_popExitAnim, 0)
            showTitle = getBoolean(R.styleable.ChromeCustomTabsNavigator_showTitle, false)
            upInsteadOfClose =
                getBoolean(R.styleable.ChromeCustomTabsNavigator_upInsteadOfClose, false)
            addDefaultShareMenuItem =
                getBoolean(R.styleable.ChromeCustomTabsNavigator_addDefaultShareMenuItem, false)
        }
    }
}
}
/**
 * From https://proandroiddev.com/add-chrome-custom-tabs-to-the-android-navigation-component-75092ce20c6a
 */
class EnhancedNavHostFragment : NavHostFragment() {
@SuppressLint("RestrictedApi")
override fun onCreateNavController(navController: NavController) {
    super.onCreateNavController(navController)
    context?.let { navController.navigatorProvider += ChromeCustomTabsNavigator(it) }
}
}

Extension Function

fun Fragment.findChromeCustomTabsNavigator(): ChromeCustomTabsNavigator =
findNavController().navigatorProvider.getNavigator(ChromeCustomTabsNavigator::class.java)

fun AppCompatActivity.findChromeCustomTabsNavigator(navController: NavController): ChromeCustomTabsNavigator =
navController.navigatorProvider.getNavigator(ChromeCustomTabsNavigator::class.java)

Note I am binding service in main activity because I have to use custom tab within drawer as well as some fragments too.

EnhancedNavHostFragment- Added Inside main Activity Layout

<androidx.fragment.app.FragmentContainerView
            android:id="@+id/fragmentContainerView"
            android:name="com.ics.homework.utils.EnhancedNavHostFragment"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            app:defaultNavHost="true"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@id/toolbar"
            app:navGraph="@navigation/main_nav_graph" />
2 Answers

This looks like a leak in the Android Framework code, which you can figure out by looking at the sources for LoadedApk.java.

When a separate process connects to a service in your process, a ServiceDispatcher.DeathMonitor is created. This is used to notify LoadedApk if the connected process dies.

The leak here is happening because the service gets destroyed, but somehow the native reference to the DeathMonitor isn't released. That seems to imply that IBinder.unleakToDeath() isn't called by LoadedApk.

You should try to repro on the latest Android release and see if the bug still exists. If yes, file a bug in AOSP.

Looks like the leak is caused by the line:

navController.addOnDestinationChangedListener(this)

Here you're passing an instance of your activity to the controller but never removing it when the activity is destroyed, hence the leak.

I would recommend adding the listener instead in onResume and removing it in onPause.

Related