Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 24cf07c2 authored by Beth Thibodeau's avatar Beth Thibodeau Committed by Android (Google) Code Review
Browse files

Merge "Use same steps to test MBS connection as when actually connecting"

parents 4873666f 7d8b80e6
Loading
Loading
Loading
Loading
+49 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2020 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.android.systemui.media;

import android.content.ComponentName;
import android.content.Context;
import android.media.browse.MediaBrowser;
import android.os.Bundle;

import javax.inject.Inject;

/**
 * Testable wrapper around {@link MediaBrowser} constructor
 */
public class MediaBrowserFactory {
    private final Context mContext;

    @Inject
    public MediaBrowserFactory(Context context) {
        mContext = context;
    }

    /**
     * Creates a new MediaBrowser
     *
     * @param serviceComponent
     * @param callback
     * @param rootHints
     * @return
     */
    public MediaBrowser create(ComponentName serviceComponent,
            MediaBrowser.ConnectionCallback callback, Bundle rootHints) {
        return new MediaBrowser(mContext, serviceComponent, callback, rootHints);
    }
}
+22 −10
Original line number Diff line number Diff line
@@ -28,6 +28,7 @@ import android.os.UserHandle
import android.provider.Settings
import android.service.media.MediaBrowserService
import android.util.Log
import com.android.internal.annotations.VisibleForTesting
import com.android.systemui.broadcast.BroadcastDispatcher
import com.android.systemui.dagger.SysUISingleton
import com.android.systemui.dagger.qualifiers.Background
@@ -47,7 +48,8 @@ class MediaResumeListener @Inject constructor(
    private val context: Context,
    private val broadcastDispatcher: BroadcastDispatcher,
    @Background private val backgroundExecutor: Executor,
    private val tunerService: TunerService
    private val tunerService: TunerService,
    private val mediaBrowserFactory: ResumeMediaBrowserFactory
) : MediaDataManager.Listener {

    private var useMediaResumption: Boolean = Utils.useMediaResumption(context)
@@ -59,7 +61,8 @@ class MediaResumeListener @Inject constructor(
    private var mediaBrowser: ResumeMediaBrowser? = null
    private var currentUserId: Int = context.userId

    private val userChangeReceiver = object : BroadcastReceiver() {
    @VisibleForTesting
    val userChangeReceiver = object : BroadcastReceiver() {
        override fun onReceive(context: Context, intent: Intent) {
            if (Intent.ACTION_USER_UNLOCKED == intent.action) {
                loadMediaResumptionControls()
@@ -152,7 +155,7 @@ class MediaResumeListener @Inject constructor(

        resumeComponents.forEach {
            if (!blockedApps.contains(it.packageName)) {
                val browser = ResumeMediaBrowser(context, mediaBrowserCallback, it)
                val browser = mediaBrowserFactory.create(mediaBrowserCallback, it)
                browser.findRecentMedia()
            }
        }
@@ -193,14 +196,10 @@ class MediaResumeListener @Inject constructor(
    private fun tryUpdateResumptionList(key: String, componentName: ComponentName) {
        Log.d(TAG, "Testing if we can connect to $componentName")
        mediaBrowser?.disconnect()
        mediaBrowser = ResumeMediaBrowser(context,
        mediaBrowser = mediaBrowserFactory.create(
                object : ResumeMediaBrowser.Callback() {
                    override fun onConnected() {
                        Log.d(TAG, "yes we can resume with $componentName")
                        mediaDataManager.setResumeAction(key, getResumeAction(componentName))
                        updateResumptionList(componentName)
                        mediaBrowser?.disconnect()
                        mediaBrowser = null
                        Log.d(TAG, "Connected to $componentName")
                    }

                    override fun onError() {
@@ -209,6 +208,19 @@ class MediaResumeListener @Inject constructor(
                        mediaBrowser?.disconnect()
                        mediaBrowser = null
                    }

                    override fun addTrack(
                        desc: MediaDescription,
                        component: ComponentName,
                        browser: ResumeMediaBrowser
                    ) {
                        // Since this is a test, just save the component for later
                        Log.d(TAG, "Can get resumable media from $componentName")
                        mediaDataManager.setResumeAction(key, getResumeAction(componentName))
                        updateResumptionList(componentName)
                        mediaBrowser?.disconnect()
                        mediaBrowser = null
                    }
                },
                componentName)
        mediaBrowser?.testConnection()
@@ -245,7 +257,7 @@ class MediaResumeListener @Inject constructor(
    private fun getResumeAction(componentName: ComponentName): Runnable {
        return Runnable {
            mediaBrowser?.disconnect()
            mediaBrowser = ResumeMediaBrowser(context,
            mediaBrowser = mediaBrowserFactory.create(
                object : ResumeMediaBrowser.Callback() {
                    override fun onConnected() {
                        if (mediaBrowser?.token == null) {
+33 −44
Original line number Diff line number Diff line
@@ -30,6 +30,8 @@ import android.service.media.MediaBrowserService;
import android.text.TextUtils;
import android.util.Log;

import com.android.internal.annotations.VisibleForTesting;

import java.util.List;

/**
@@ -46,6 +48,7 @@ public class ResumeMediaBrowser {
    private static final String TAG = "ResumeMediaBrowser";
    private final Context mContext;
    private final Callback mCallback;
    private MediaBrowserFactory mBrowserFactory;
    private MediaBrowser mMediaBrowser;
    private ComponentName mComponentName;

@@ -55,10 +58,12 @@ public class ResumeMediaBrowser {
     * @param callback used to report media items found
     * @param componentName Component name of the MediaBrowserService this browser will connect to
     */
    public ResumeMediaBrowser(Context context, Callback callback, ComponentName componentName) {
    public ResumeMediaBrowser(Context context, Callback callback, ComponentName componentName,
            MediaBrowserFactory browserFactory) {
        mContext = context;
        mCallback = callback;
        mComponentName = componentName;
        mBrowserFactory = browserFactory;
    }

    /**
@@ -74,7 +79,7 @@ public class ResumeMediaBrowser {
        disconnect();
        Bundle rootHints = new Bundle();
        rootHints.putBoolean(MediaBrowserService.BrowserRoot.EXTRA_RECENT, true);
        mMediaBrowser = new MediaBrowser(mContext,
        mMediaBrowser = mBrowserFactory.create(
                mComponentName,
                mConnectionCallback,
                rootHints);
@@ -88,8 +93,8 @@ public class ResumeMediaBrowser {
                List<MediaBrowser.MediaItem> children) {
            if (children.size() == 0) {
                Log.d(TAG, "No children found for " + mComponentName);
                return;
            }
                mCallback.onError();
            } else {
                // We ask apps to return a playable item as the first child when sending
                // a request with EXTRA_RECENT; if they don't, no resume controls
                MediaBrowser.MediaItem child = children.get(0);
@@ -99,6 +104,8 @@ public class ResumeMediaBrowser {
                            ResumeMediaBrowser.this);
                } else {
                    Log.d(TAG, "Child found but not playable for " + mComponentName);
                    mCallback.onError();
                }
            }
            disconnect();
        }
@@ -131,7 +138,7 @@ public class ResumeMediaBrowser {
            Log.d(TAG, "Service connected for " + mComponentName);
            if (mMediaBrowser != null && mMediaBrowser.isConnected()) {
                String root = mMediaBrowser.getRoot();
                if (!TextUtils.isEmpty(root)) {
                if (!TextUtils.isEmpty(root) && mMediaBrowser != null) {
                    mCallback.onConnected();
                    mMediaBrowser.subscribe(root, mSubscriptionCallback);
                    return;
@@ -182,7 +189,7 @@ public class ResumeMediaBrowser {
        disconnect();
        Bundle rootHints = new Bundle();
        rootHints.putBoolean(MediaBrowserService.BrowserRoot.EXTRA_RECENT, true);
        mMediaBrowser = new MediaBrowser(mContext, mComponentName,
        mMediaBrowser = mBrowserFactory.create(mComponentName,
                new MediaBrowser.ConnectionCallback() {
                    @Override
                    public void onConnected() {
@@ -192,7 +199,7 @@ public class ResumeMediaBrowser {
                            return;
                        }
                        MediaSession.Token token = mMediaBrowser.getSessionToken();
                        MediaController controller = new MediaController(mContext, token);
                        MediaController controller = createMediaController(token);
                        controller.getTransportControls();
                        controller.getTransportControls().prepare();
                        controller.getTransportControls().play();
@@ -212,6 +219,11 @@ public class ResumeMediaBrowser {
        mMediaBrowser.connect();
    }

    @VisibleForTesting
    protected MediaController createMediaController(MediaSession.Token token) {
        return new MediaController(mContext, token);
    }

    /**
     * Get the media session token
     * @return the token, or null if the MediaBrowser is null or disconnected
@@ -235,42 +247,19 @@ public class ResumeMediaBrowser {

    /**
     * Used to test if SystemUI is allowed to connect to the given component as a MediaBrowser.
     * ResumeMediaBrowser.Callback#onError or ResumeMediaBrowser.Callback#onConnected will be called
     * depending on whether it was successful.
     * If it can connect, ResumeMediaBrowser.Callback#onConnected will be called. If valid media is
     * found, then ResumeMediaBrowser.Callback#addTrack will also be called. This allows for more
     * detailed logging if the service has issues. If it cannot connect, or cannot find valid media,
     * then ResumeMediaBrowser.Callback#onError will be called.
     * ResumeMediaBrowser#disconnect should be called after this to ensure the connection is closed.
     */
    public void testConnection() {
        disconnect();
        final MediaBrowser.ConnectionCallback connectionCallback =
                new MediaBrowser.ConnectionCallback() {
                    @Override
                    public void onConnected() {
                        Log.d(TAG, "connected");
                        if (mMediaBrowser == null || !mMediaBrowser.isConnected()
                                || TextUtils.isEmpty(mMediaBrowser.getRoot())) {
                            mCallback.onError();
                        } else {
                            mCallback.onConnected();
                        }
                    }

                    @Override
                    public void onConnectionSuspended() {
                        Log.d(TAG, "suspended");
                        mCallback.onError();
                    }

                    @Override
                    public void onConnectionFailed() {
                        Log.d(TAG, "failed");
                        mCallback.onError();
                    }
                };
        Bundle rootHints = new Bundle();
        rootHints.putBoolean(MediaBrowserService.BrowserRoot.EXTRA_RECENT, true);
        mMediaBrowser = new MediaBrowser(mContext,
        mMediaBrowser = mBrowserFactory.create(
                mComponentName,
                connectionCallback,
                mConnectionCallback,
                rootHints);
        mMediaBrowser.connect();
    }
+48 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2020 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.android.systemui.media;

import android.content.ComponentName;
import android.content.Context;

import javax.inject.Inject;

/**
 * Testable wrapper around {@link ResumeMediaBrowser} constructor
 */
public class ResumeMediaBrowserFactory {
    private final Context mContext;
    private final MediaBrowserFactory mBrowserFactory;

    @Inject
    public ResumeMediaBrowserFactory(Context context, MediaBrowserFactory browserFactory) {
        mContext = context;
        mBrowserFactory = browserFactory;
    }

    /**
     * Creates a new ResumeMediaBrowser.
     *
     * @param callback will be called on connection or error, and addTrack when media item found
     * @param componentName component to browse
     * @return
     */
    public ResumeMediaBrowser create(ResumeMediaBrowser.Callback callback,
            ComponentName componentName) {
        return new ResumeMediaBrowser(mContext, callback, componentName, mBrowserFactory);
    }
}
+258 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2020 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.android.systemui.media

import android.app.PendingIntent
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.content.pm.PackageManager
import android.content.pm.ResolveInfo
import android.content.pm.ServiceInfo
import android.graphics.Color
import android.media.MediaDescription
import android.media.session.MediaSession
import android.provider.Settings
import android.testing.AndroidTestingRunner
import android.testing.TestableLooper
import androidx.test.filters.SmallTest
import com.android.systemui.SysuiTestCase
import com.android.systemui.broadcast.BroadcastDispatcher
import com.android.systemui.tuner.TunerService
import com.android.systemui.util.concurrency.FakeExecutor
import com.android.systemui.util.time.FakeSystemClock
import org.junit.After
import com.google.common.truth.Truth.assertThat
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.ArgumentCaptor
import org.mockito.ArgumentMatchers.anyInt
import org.mockito.Captor
import org.mockito.Mock
import org.mockito.Mockito
import org.mockito.Mockito.mock
import org.mockito.Mockito.never
import org.mockito.Mockito.times
import org.mockito.Mockito.verify
import org.mockito.Mockito.`when` as whenever
import org.mockito.MockitoAnnotations

private const val KEY = "TEST_KEY"
private const val OLD_KEY = "RESUME_KEY"
private const val APP = "APP"
private const val BG_COLOR = Color.RED
private const val PACKAGE_NAME = "PKG"
private const val CLASS_NAME = "CLASS"
private const val ARTIST = "ARTIST"
private const val TITLE = "TITLE"
private const val USER_ID = 0
private const val MEDIA_PREFERENCES = "media_control_prefs"
private const val RESUME_COMPONENTS = "package1/class1:package2/class2:package3/class3"

private fun <T> capture(argumentCaptor: ArgumentCaptor<T>): T = argumentCaptor.capture()
private fun <T> eq(value: T): T = Mockito.eq(value) ?: value
private fun <T> any(): T = Mockito.any<T>()

@SmallTest
@RunWith(AndroidTestingRunner::class)
@TestableLooper.RunWithLooper
class MediaResumeListenerTest : SysuiTestCase() {

    @Mock private lateinit var broadcastDispatcher: BroadcastDispatcher
    @Mock private lateinit var mediaDataManager: MediaDataManager
    @Mock private lateinit var device: MediaDeviceData
    @Mock private lateinit var token: MediaSession.Token
    @Mock private lateinit var tunerService: TunerService
    @Mock private lateinit var resumeBrowserFactory: ResumeMediaBrowserFactory
    @Mock private lateinit var resumeBrowser: ResumeMediaBrowser
    @Mock private lateinit var sharedPrefs: SharedPreferences
    @Mock private lateinit var sharedPrefsEditor: SharedPreferences.Editor
    @Mock private lateinit var mockContext: Context
    @Mock private lateinit var pendingIntent: PendingIntent

    @Captor lateinit var callbackCaptor: ArgumentCaptor<ResumeMediaBrowser.Callback>

    private lateinit var executor: FakeExecutor
    private lateinit var data: MediaData
    private lateinit var resumeListener: MediaResumeListener

    private var originalQsSetting = Settings.Global.getInt(context.contentResolver,
        Settings.Global.SHOW_MEDIA_ON_QUICK_SETTINGS, 1)
    private var originalResumeSetting = Settings.Secure.getInt(context.contentResolver,
        Settings.Secure.MEDIA_CONTROLS_RESUME, 0)

    @Before
    fun setup() {
        MockitoAnnotations.initMocks(this)

        Settings.Global.putInt(context.contentResolver,
            Settings.Global.SHOW_MEDIA_ON_QUICK_SETTINGS, 1)
        Settings.Secure.putInt(context.contentResolver,
            Settings.Secure.MEDIA_CONTROLS_RESUME, 1)

        whenever(resumeBrowserFactory.create(capture(callbackCaptor), any()))
                .thenReturn(resumeBrowser)

        // resume components are stored in sharedpreferences
        whenever(mockContext.getSharedPreferences(eq(MEDIA_PREFERENCES), anyInt()))
                .thenReturn(sharedPrefs)
        whenever(sharedPrefs.getString(any(), any())).thenReturn(RESUME_COMPONENTS)
        whenever(sharedPrefs.edit()).thenReturn(sharedPrefsEditor)
        whenever(sharedPrefsEditor.putString(any(), any())).thenReturn(sharedPrefsEditor)
        whenever(mockContext.packageManager).thenReturn(context.packageManager)
        whenever(mockContext.contentResolver).thenReturn(context.contentResolver)

        executor = FakeExecutor(FakeSystemClock())
        resumeListener = MediaResumeListener(mockContext, broadcastDispatcher, executor,
                tunerService, resumeBrowserFactory)
        resumeListener.setManager(mediaDataManager)
        mediaDataManager.addListener(resumeListener)

        data = MediaData(
                userId = USER_ID,
                initialized = true,
                backgroundColor = BG_COLOR,
                app = APP,
                appIcon = null,
                artist = ARTIST,
                song = TITLE,
                artwork = null,
                actions = emptyList(),
                actionsToShowInCompact = emptyList(),
                packageName = PACKAGE_NAME,
                token = token,
                clickIntent = null,
                device = device,
                active = true,
                notificationKey = KEY,
                resumeAction = null)
    }

    @After
    fun tearDown() {
        Settings.Global.putInt(context.contentResolver,
            Settings.Global.SHOW_MEDIA_ON_QUICK_SETTINGS, originalQsSetting)
        Settings.Secure.putInt(context.contentResolver,
            Settings.Secure.MEDIA_CONTROLS_RESUME, originalResumeSetting)
    }

    @Test
    fun testWhenNoResumption_doesNothing() {
        Settings.Secure.putInt(context.contentResolver,
            Settings.Secure.MEDIA_CONTROLS_RESUME, 0)

        // When listener is created, we do NOT register a user change listener
        val listener = MediaResumeListener(context, broadcastDispatcher, executor, tunerService,
                resumeBrowserFactory)
        listener.setManager(mediaDataManager)
        verify(broadcastDispatcher, never()).registerReceiver(eq(listener.userChangeReceiver),
            any(), any(), any())

        // When data is loaded, we do NOT execute or update anything
        listener.onMediaDataLoaded(KEY, OLD_KEY, data)
        assertThat(executor.numPending()).isEqualTo(0)
        verify(mediaDataManager, never()).setResumeAction(any(), any())
    }

    @Test
    fun testOnLoad_checksForResume_noService() {
        // When media data is loaded that has not been checked yet, and does not have a MBS
        resumeListener.onMediaDataLoaded(KEY, null, data)

        // Then we report back to the manager
        verify(mediaDataManager).setResumeAction(KEY, null)
    }

    @Test
    fun testOnLoad_checksForResume_hasService() {
        // Set up mocks to successfully find a MBS that returns valid media
        val pm = mock(PackageManager::class.java)
        whenever(mockContext.packageManager).thenReturn(pm)
        val resolveInfo = ResolveInfo()
        val serviceInfo = ServiceInfo()
        serviceInfo.packageName = PACKAGE_NAME
        resolveInfo.serviceInfo = serviceInfo
        resolveInfo.serviceInfo.name = CLASS_NAME
        val resumeInfo = listOf(resolveInfo)
        whenever(pm.queryIntentServices(any(), anyInt())).thenReturn(resumeInfo)

        val description = MediaDescription.Builder().setTitle(TITLE).build()
        val component = ComponentName(PACKAGE_NAME, CLASS_NAME)
        whenever(resumeBrowser.testConnection()).thenAnswer {
            callbackCaptor.value.addTrack(description, component, resumeBrowser)
        }

        // When media data is loaded that has not been checked yet, and does have a MBS
        val dataCopy = data.copy(resumeAction = null, hasCheckedForResume = false)
        resumeListener.onMediaDataLoaded(KEY, null, dataCopy)

        // Then we test whether the service is valid
        executor.runAllReady()
        verify(resumeBrowser).testConnection()

        // And since it is, we report back to the manager
        verify(mediaDataManager).setResumeAction(eq(KEY), any())

        // But we do not tell it to add new controls
        verify(mediaDataManager, never())
                .addResumptionControls(anyInt(), any(), any(), any(), any(), any(), any())

        // Finally, make sure the resume browser disconnected
        verify(resumeBrowser).disconnect()
    }

    @Test
    fun testOnLoad_doesNotCheckAgain() {
        // When a media data is loaded that has been checked already
        var dataCopy = data.copy(hasCheckedForResume = true)
        resumeListener.onMediaDataLoaded(KEY, null, dataCopy)

        // Then we should not check it again
        verify(resumeBrowser, never()).testConnection()
        verify(mediaDataManager, never()).setResumeAction(KEY, null)
    }

    @Test
    fun testOnUserUnlock_loadsTracks() {
        // Set up mock service to successfully find valid media
        val description = MediaDescription.Builder().setTitle(TITLE).build()
        val component = ComponentName(PACKAGE_NAME, CLASS_NAME)
        whenever(resumeBrowser.token).thenReturn(token)
        whenever(resumeBrowser.appIntent).thenReturn(pendingIntent)
        whenever(resumeBrowser.findRecentMedia()).thenAnswer {
            callbackCaptor.value.addTrack(description, component, resumeBrowser)
        }

        // Make sure broadcast receiver is registered
        resumeListener.setManager(mediaDataManager)
        verify(broadcastDispatcher).registerReceiver(eq(resumeListener.userChangeReceiver),
                any(), any(), any())

        // When we get an unlock event
        val intent = Intent(Intent.ACTION_USER_UNLOCKED)
        resumeListener.userChangeReceiver.onReceive(context, intent)

        // Then we should attempt to find recent media for each saved component
        verify(resumeBrowser, times(3)).findRecentMedia()

        // Then since the mock service found media, the manager should be informed
        verify(mediaDataManager, times(3)).addResumptionControls(anyInt(),
                any(), any(), any(), any(), any(), eq(PACKAGE_NAME))
    }
}
 No newline at end of file
Loading