androidmemory-leaksandroid-serviceleakcanarymediabrowserservice

leak canary detects memory leak in MediaBrowserServiceCompat sample app


I have created a test app that implements MediaBrowserServiceCompat. I have followed this guide: https://developer.android.com/guide/topics/media-apps/audio-app/building-a-mediabrowserservice Created MediaPlaybackService and MainActivity. I have added leak canary and added AppWatcher.objectWatcher.watch(this) in the onDestroy method. When opening and exiting the app, leak canary finds a leak:

6153 bytes retained
    ┬
    ├─ android.service.media.MediaBrowserService$ServiceBinder
    │    Leaking: UNKNOWN
    │    GC Root: Global variable in native code
    │    ↓ MediaBrowserService$ServiceBinder.this$0
    │                                        ~~~~~~
    ├─ androidx.media.MediaBrowserServiceCompat$MediaBrowserServiceImplApi26$MediaBrowserServiceApi26
    │    Leaking: UNKNOWN
    │    MediaBrowserServiceCompat$MediaBrowserServiceImplApi26$MediaBrowserServiceApi26 does not wrap an activity context
    │    ↓ MediaBrowserServiceCompat$MediaBrowserServiceImplApi26$MediaBrowserServiceApi26.mBase
    │                                                                                      ~~~~~
    ╰→ com.example.mediabrowsertestapp.MediaPlaybackService
    ​     Leaking: YES (ObjectWatcher was watching this)
    ​     MediaPlaybackService does not wrap an activity context
    ​     key = 11f40383-1498-4743-9f20-208cbd2839a1
    ​     watchDurationMillis = 5191
    ​     retainedDurationMillis = 183

Please include this in bug reports and Stack Overflow questions.

    Build.VERSION.SDK_INT: 28
    Build.MANUFACTURER: HMD Global
    LeakCanary version: 2.0
    App process name: com.example.mediabrowsertestapp
    Analysis duration: 8967 ms
    Heap dump file path: /data/user/0/com.example.mediabrowsertestapp/files/leakcanary/2019-12-10_10-21-47_693.hprof
    Heap dump timestamp: 1575969720525

Since the app only contains code from the google sample, I can't figure out what to do with this leak. Should I just ignore it?

code: https://github.com/finneapps/MediaBrowserService-memory-leak

package com.example.mediabrowsertestapp

import android.os.Bundle
import android.support.v4.media.MediaBrowserCompat
import android.support.v4.media.session.MediaSessionCompat
import android.support.v4.media.session.PlaybackStateCompat
import androidx.media.MediaBrowserServiceCompat
import leakcanary.AppWatcher

private const val LOG_TAG = "MediaPlaybackService"

class MediaPlaybackService : MediaBrowserServiceCompat() {

    private var mediaSession: MediaSessionCompat? = null
    private lateinit var stateBuilder: PlaybackStateCompat.Builder

    override fun onCreate() {
        super.onCreate()
        mediaSession = MediaSessionCompat(baseContext, LOG_TAG).apply {
            setFlags(
                MediaSessionCompat.FLAG_HANDLES_MEDIA_BUTTONS
                        or MediaSessionCompat.FLAG_HANDLES_TRANSPORT_CONTROLS
            )
            stateBuilder = PlaybackStateCompat.Builder()
                .setActions(
                    PlaybackStateCompat.ACTION_PLAY
                            or PlaybackStateCompat.ACTION_PLAY_PAUSE
                )
            setPlaybackState(stateBuilder.build())
            setSessionToken(sessionToken)
        }
    }

    override fun onGetRoot(
        clientPackageName: String, clientUid: Int,
        rootHints: Bundle?
    ): BrowserRoot? {
        return BrowserRoot(LOG_TAG, null)
    }

    override fun onLoadChildren(
        parentMediaId: String,
        result: Result<List<MediaBrowserCompat.MediaItem>>
    ) {
        result.sendResult(emptyList())
    }

    override fun onDestroy() {
        super.onDestroy()
        AppWatcher.objectWatcher.watch(this)
    }
}
package com.example.mediabrowsertestapp

import android.content.ComponentName
import android.media.AudioManager
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.support.v4.media.MediaBrowserCompat
import android.support.v4.media.MediaMetadataCompat
import android.support.v4.media.session.MediaControllerCompat
import android.support.v4.media.session.PlaybackStateCompat

class MainActivity : AppCompatActivity() {
    private val controllerCallback = object : MediaControllerCompat.Callback() {

        override fun onMetadataChanged(metadata: MediaMetadataCompat?) {}

        override fun onPlaybackStateChanged(state: PlaybackStateCompat?) {}
    }

    private lateinit var mediaBrowser: MediaBrowserCompat

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        mediaBrowser = MediaBrowserCompat(
            this,
            ComponentName(this, MediaPlaybackService::class.java),
            connectionCallbacks,
            null 
        )
    }

    override fun onStart() {
        super.onStart()
        mediaBrowser.connect()
    }

    override fun onResume() {
        super.onResume()
        volumeControlStream = AudioManager.STREAM_MUSIC
    }

    override fun onStop() {
        super.onStop()
        MediaControllerCompat.getMediaController(this)?.unregisterCallback(controllerCallback)
        mediaBrowser.disconnect()
    }

    private val connectionCallbacks = object : MediaBrowserCompat.ConnectionCallback() {
        override fun onConnected() {
            mediaBrowser.sessionToken.also { token ->
                val mediaController = MediaControllerCompat(
                    this@MainActivity, // Context
                    token
                )
                MediaControllerCompat.setMediaController(this@MainActivity, mediaController)
            }

        }

        override fun onConnectionSuspended() {

        }

        override fun onConnectionFailed() {

        }
    }
}
apply plugin: 'com.android.application'

apply plugin: 'kotlin-android'

apply plugin: 'kotlin-android-extensions'

android {
    compileSdkVersion 29
    buildToolsVersion "29.0.2"
    defaultConfig {
        applicationId "com.example.mediabrowsertestapp"
        minSdkVersion 15
        targetSdkVersion 29
        versionCode 1
        versionName "1.0"
        testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
    }
    buildTypes {
        release {
            minifyEnabled false
            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
        }
    }
}

dependencies {
    implementation fileTree(dir: 'libs', include: ['*.jar'])
    implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
    implementation 'androidx.appcompat:appcompat:1.1.0'
    implementation 'androidx.core:core-ktx:1.1.0'
    implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
    implementation "androidx.media:media:1.1.0"
    debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.0'
    testImplementation 'junit:junit:4.12'
    androidTestImplementation 'androidx.test.ext:junit:1.1.0'
    androidTestImplementation 'androidx.test.espresso:espresso-core:3.1.1'
}

Solution

  • Your media service extends MediaBrowserServiceCompat. At first, this looks like an issue with MediaBrowserServiceCompat. androidx.media:media:1.1.0 is the latest release, and the latest sources for MediaBrowserServiceCompat are currently here.

    MediaBrowserServiceCompat is a base service class that delegates to a subclass of the AOSP MediaBrowserService class (sources). One tricky bit here is that while MediaBrowserService is a service, when used by MediaBrowserServiceCompat it's not actually created as a real Android service but instead created as a mere delegate that MediaBrowserServiceCompat passes callbacks to. That in itself means it's easy to make mistakes.

    The MediaBrowserService subclass holds a reference to the MediaBrowserServiceCompat instance so that t

    The leak trace shows there's a native reference to MediaBrowserService$ServiceBinder. When MediaBrowserServiceCompat receives its onBind() call it returns the binder from MediaBrowserService. That binder should be held as long as MediaBrowserServiceCompat is alived, and released when it's destroyed. At this point we need a heap dump to dig further.

    I downloaded the sources, built the app and deployed it on an emulator (API 29) and was able to reproduce the leak by pressing back. I noticed that the MediaSessionCompat constructor javadoc states "You must call {@link #release()} when finished with the session.". I tried calling that in onDestroy() but the leak still happens.

    I'm wondering if this happens only with app compat, or also with AOSP. I ported the code back to AOSP (no compat) and the same thing is happening.

    ┬
    ├─ android.service.media.MediaBrowserService$ServiceBinder
    │    Leaking: UNKNOWN
    │    GC Root: Global variable in native code
    │    ↓ MediaBrowserService$ServiceBinder.this$0
    │                                        ~~~~~~
    ╰→ com.example.mediabrowsertestapp.MediaPlaybackService
    ​     Leaking: YES (ObjectWatcher was watching this)
    ​     MediaPlaybackService2 does not wrap an activity context
    ​     key = e9c30a2e-e06e-4c4b-b375-f8c8c1482761
    ​     watchDurationMillis = 5214
    ​     retainedDurationMillis = 179
    
    METADATA
    
    Build.VERSION.SDK_INT: 25
    Build.MANUFACTURER: Google
    LeakCanary version: 2.0
    App process name: com.example.mediabrowsertestapp
    Analysis duration: 2159 ms
    

    I removed as much code as I could and then saw that the leak was still happening. Here's the final code:

    class MediaPlaybackService : MediaBrowserService() {
    
        override fun onLoadChildren(
            parentId: String,
            result: Result<MutableList<MediaBrowser.MediaItem>>
        ) {
            result.sendResult(mutableListOf())
        }
    
        override fun onGetRoot(
            clientPackageName: String, clientUid: Int,
            rootHints: Bundle?
        ): BrowserRoot? {
            return BrowserRoot("MediaPlaybackService", null)
        }
    
    
        override fun onDestroy() {
            super.onDestroy()
            AppWatcher.objectWatcher.watch(this)
        }
    }
    
    class MainActivity : Activity() {
        private lateinit var mediaBrowser: MediaBrowser
    
        override fun onCreate(savedInstanceState: Bundle?) {
            super.onCreate(savedInstanceState)
            setContentView(R.layout.activity_main)
            mediaBrowser = MediaBrowser(
                this,
                ComponentName(this, MediaPlaybackService::class.java),
                connectionCallbacks,
                null
            )
        }
    
        override fun onStart() {
            super.onStart()
            mediaBrowser.connect()
        }
    
        override fun onStop() {
            super.onStop()
            mediaBrowser.disconnect()
        }
    
        private val connectionCallbacks = object : MediaBrowser.ConnectionCallback() {
            override fun onConnected() {
            }
    
            override fun onConnectionSuspended() {
    
            }
    
            override fun onConnectionFailed() {
    
            }
        }
    }
    

    This should likely be an issue filed against the latest Android version, even though it has been around for a while. By design, inter process calls lead to binders being held in memory longer than expecetd. MediaBrowserService.ServiceBinder should release it's reference to its outer class MediaBrowserService when MediaBrowserService is destroyed.

    Here's a PR that reproduces it in AOSP: https://github.com/finneapps/MediaBrowserService-memory-leak/pull/1