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

Commit 9fa2160a authored by Yiyi Shen's avatar Yiyi Shen Committed by Android (Google) Code Review
Browse files

Merge changes from topic "join_handler" into main

* changes:
  [Audiosharing] Handle device connected in handler activity
  [Audiosharing] Add activity skeleton to handle new connected sink
parents 98a039e8 51c5c2e4
Loading
Loading
Loading
Loading
+19 −0
Original line number Diff line number Diff line
<?xml version="1.0" encoding="utf-8"?>
<!-- Copyright (C) 2025 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.
-->

<PreferenceScreen
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:settings="http://schemas.android.com/apk/res-auto"
    android:key="audio_sharing_join_handler"
    settings:searchable="false"
    settings:controller="com.android.settings.connecteddevice.audiosharing.AudioSharingJoinHandlerController" />
 No newline at end of file
+46 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2025 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.settings.connecteddevice.audiosharing;

import android.os.Bundle;

import com.android.settings.SettingsActivity;
import com.android.settingslib.bluetooth.BluetoothUtils;
import com.android.settingslib.flags.Flags;

public class AudioSharingJoinHandlerActivity extends SettingsActivity {
    private static final String TAG = "AudioSharingJoinHandlerActivity";

    @Override
    protected void onCreate(Bundle savedState) {
        super.onCreate(savedState);
        if (!Flags.promoteAudioSharingForSecondAutoConnectedLeaDevice()
                || !BluetoothUtils.isAudioSharingUIAvailable(this)) {
            finish();
        }
    }

    @Override
    protected boolean isToolbarEnabled() {
        return false;
    }

    @Override
    protected boolean isValidFragment(String fragmentName) {
        return AudioSharingJoinHandlerDashboardFragment.class.getName().equals(fragmentName);
    }
}
+274 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2025 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.settings.connecteddevice.audiosharing;

import static com.android.settingslib.bluetooth.LocalBluetoothLeBroadcast.EXTRA_BLUETOOTH_DEVICE;

import android.bluetooth.BluetoothAdapter;
import android.bluetooth.BluetoothDevice;
import android.bluetooth.BluetoothLeBroadcastAssistant;
import android.bluetooth.BluetoothLeBroadcastMetadata;
import android.bluetooth.BluetoothLeBroadcastReceiveState;
import android.bluetooth.BluetoothProfile;
import android.content.Context;
import android.content.Intent;
import android.util.Log;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import androidx.annotation.WorkerThread;
import androidx.lifecycle.DefaultLifecycleObserver;
import androidx.lifecycle.LifecycleOwner;
import androidx.preference.PreferenceScreen;

import com.android.settings.bluetooth.Utils;
import com.android.settings.core.BasePreferenceController;
import com.android.settings.dashboard.DashboardFragment;
import com.android.settingslib.bluetooth.BluetoothCallback;
import com.android.settingslib.bluetooth.BluetoothEventManager;
import com.android.settingslib.bluetooth.BluetoothUtils;
import com.android.settingslib.bluetooth.CachedBluetoothDevice;
import com.android.settingslib.bluetooth.CachedBluetoothDeviceManager;
import com.android.settingslib.bluetooth.LocalBluetoothLeBroadcastAssistant;
import com.android.settingslib.bluetooth.LocalBluetoothManager;
import com.android.settingslib.flags.Flags;
import com.android.settingslib.utils.ThreadUtils;

import java.util.concurrent.Executor;
import java.util.concurrent.Executors;

public class AudioSharingJoinHandlerController extends BasePreferenceController
        implements DefaultLifecycleObserver, BluetoothCallback {
    private static final String TAG = "AudioSharingJoinHandlerCtrl";
    private static final String KEY = "audio_sharing_join_handler";

    @Nullable private final LocalBluetoothManager mBtManager;
    @Nullable private final BluetoothEventManager mEventManager;
    @Nullable private final CachedBluetoothDeviceManager mDeviceManager;
    @Nullable private final LocalBluetoothLeBroadcastAssistant mAssistant;
    private final Executor mExecutor;
    @Nullable private DashboardFragment mFragment;
    @Nullable private AudioSharingDialogHandler mDialogHandler;
    @VisibleForTesting
    BluetoothLeBroadcastAssistant.Callback mAssistantCallback =
            new BluetoothLeBroadcastAssistant.Callback() {
                @Override
                public void onSearchStarted(int reason) {
                }

                @Override
                public void onSearchStartFailed(int reason) {
                }

                @Override
                public void onSearchStopped(int reason) {
                }

                @Override
                public void onSearchStopFailed(int reason) {
                }

                @Override
                public void onSourceFound(@NonNull BluetoothLeBroadcastMetadata source) {
                }

                @Override
                public void onSourceAdded(
                        @NonNull BluetoothDevice sink, int sourceId, int reason) {
                    Log.d(TAG, "onSourceAdded: dismiss stale dialog.");
                    if (mDeviceManager != null && mDialogHandler != null) {
                        CachedBluetoothDevice cachedDevice = mDeviceManager.findDevice(sink);
                        if (cachedDevice != null) {
                            mDialogHandler.closeOpeningDialogsForLeaDevice(cachedDevice);
                        }
                    }
                }

                @Override
                public void onSourceAddFailed(
                        @NonNull BluetoothDevice sink,
                        @NonNull BluetoothLeBroadcastMetadata source,
                        int reason) {
                }

                @Override
                public void onSourceModified(
                        @NonNull BluetoothDevice sink, int sourceId, int reason) {
                }

                @Override
                public void onSourceModifyFailed(
                        @NonNull BluetoothDevice sink, int sourceId, int reason) {
                }

                @Override
                public void onSourceRemoved(
                        @NonNull BluetoothDevice sink, int sourceId, int reason) {
                }

                @Override
                public void onSourceRemoveFailed(
                        @NonNull BluetoothDevice sink, int sourceId, int reason) {
                }

                @Override
                public void onReceiveStateChanged(
                        @NonNull BluetoothDevice sink,
                        int sourceId,
                        @NonNull BluetoothLeBroadcastReceiveState state) {
                }
            };

    public AudioSharingJoinHandlerController(@NonNull Context context,
            @NonNull String preferenceKey) {
        super(context, preferenceKey);
        mBtManager = Utils.getLocalBtManager(mContext);
        mEventManager = mBtManager == null ? null : mBtManager.getEventManager();
        mDeviceManager = mBtManager == null ? null : mBtManager.getCachedDeviceManager();
        mAssistant = mBtManager == null ? null
                : mBtManager.getProfileManager().getLeAudioBroadcastAssistantProfile();
        mExecutor = Executors.newSingleThreadExecutor();
    }

    /**
     * Initialize the controller.
     *
     * @param fragment The fragment to provide the context and metrics category for {@link
     *                 AudioSharingBluetoothDeviceUpdater} and provide the host for dialogs.
     */
    public void init(@NonNull DashboardFragment fragment) {
        mFragment = fragment;
        mDialogHandler = new AudioSharingDialogHandler(mContext, fragment);
    }

    @Override
    public void onStart(@NonNull LifecycleOwner owner) {
        var unused = ThreadUtils.postOnBackgroundThread(() -> {
            if (!isAvailable()) {
                Log.d(TAG, "Skip onStart(), feature is not supported.");
                return;
            }
            if (mEventManager == null || mDialogHandler == null || mAssistant == null) {
                Log.d(TAG, "Skip onStart(), profile is not ready.");
                return;
            }
            Log.d(TAG, "onStart() Register callbacks.");
            mEventManager.registerCallback(this);
            mAssistant.registerServiceCallBack(mExecutor, mAssistantCallback);
            mDialogHandler.registerCallbacks(mExecutor);
        });
    }

    @Override
    public void onStop(@NonNull LifecycleOwner owner) {
        var unused = ThreadUtils.postOnBackgroundThread(() -> {
            if (!isAvailable()) {
                Log.d(TAG, "Skip onStop(), feature is not supported.");
                return;
            }
            if (mEventManager == null || mDialogHandler == null || mAssistant == null) {
                Log.d(TAG, "Skip onStop(), profile is not ready.");
                return;
            }
            Log.d(TAG, "onStop() Unregister callbacks.");
            mEventManager.unregisterCallback(this);
            mAssistant.unregisterServiceCallBack(mAssistantCallback);
            mDialogHandler.unregisterCallbacks();
        });
    }


    @Override
    public int getAvailabilityStatus() {
        return (Flags.promoteAudioSharingForSecondAutoConnectedLeaDevice()
                && BluetoothUtils.isAudioSharingUIAvailable(mContext))
                ? AVAILABLE_UNSEARCHABLE
                : UNSUPPORTED_ON_DEVICE;
    }

    @Override
    public String getPreferenceKey() {
        return KEY;
    }

    @Override
    public int getSliceHighlightMenuRes() {
        return 0;
    }

    @Override
    public void displayPreference(@NonNull PreferenceScreen screen) {
        super.displayPreference(screen);
        if (mFragment == null
                || mFragment.getActivity() == null
                || mFragment.getActivity().getIntent() == null) {
            Log.d(TAG, "Skip handleDeviceConnectedFromIntent, fragment intent is null");
            return;
        }
        Intent intent = mFragment.getActivity().getIntent();
        var unused =
                ThreadUtils.postOnBackgroundThread(() -> handleDeviceConnectedFromIntent(intent));
    }

    @Override
    public void onProfileConnectionStateChanged(
            @NonNull CachedBluetoothDevice cachedDevice,
            @ConnectionState int state,
            int bluetoothProfile) {
        if (mDialogHandler == null || mFragment == null) {
            Log.d(TAG, "Ignore onProfileConnectionStateChanged, not init correctly");
            return;
        }
        // Close related dialogs if the BT remote device is disconnected.
        if (state == BluetoothAdapter.STATE_DISCONNECTED) {
            boolean isLeAudioSupported = BluetoothUtils.isLeAudioSupported(cachedDevice);
            if (isLeAudioSupported
                    && bluetoothProfile == BluetoothProfile.LE_AUDIO_BROADCAST_ASSISTANT) {
                mDialogHandler.closeOpeningDialogsForLeaDevice(cachedDevice);
            } else if (!isLeAudioSupported && !cachedDevice.isConnected()) {
                mDialogHandler.closeOpeningDialogsForNonLeaDevice(cachedDevice);
            }
        }
    }

    /** Handle just connected device via intent. */
    @WorkerThread
    public void handleDeviceConnectedFromIntent(@NonNull Intent intent) {
        BluetoothDevice device = intent.getParcelableExtra(EXTRA_BLUETOOTH_DEVICE,
                BluetoothDevice.class);
        CachedBluetoothDevice cachedDevice =
                (device == null || mDeviceManager == null)
                        ? null
                        : mDeviceManager.findDevice(device);
        if (cachedDevice == null) {
            Log.d(TAG, "Skip handleDeviceConnectedFromIntent, device is null");
            return;
        }
        if (mDialogHandler == null) {
            Log.d(TAG, "Skip handleDeviceConnectedFromIntent, handler is null");
            return;
        }
        Log.d(TAG, "handleDeviceConnectedFromIntent, device = " + device.getAnonymizedAddress());
        mDialogHandler.handleDeviceConnected(cachedDevice, /* userTriggered= */ false);
    }

    @VisibleForTesting
    void setDialogHandler(@Nullable AudioSharingDialogHandler dialogHandler) {
        mDialogHandler = dialogHandler;
    }
}
+55 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2025 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.settings.connecteddevice.audiosharing;

import android.content.Context;

import androidx.annotation.Nullable;

import com.android.settings.R;
import com.android.settings.dashboard.DashboardFragment;

public class AudioSharingJoinHandlerDashboardFragment extends DashboardFragment {
    private static final String TAG = "AudioSharingJoinHandlerFrag";

    @Nullable private AudioSharingJoinHandlerController mController;

    @Override
    public int getMetricsCategory() {
        // TODO: use real enum
        return 0;
    }

    @Override
    protected int getPreferenceScreenResId() {
        return R.xml.bluetooth_le_audio_sharing_join_handler;
    }

    @Override
    protected String getLogTag() {
        return TAG;
    }

    @Override
    public void onAttach(Context context) {
        super.onAttach(context);
        mController = use(AudioSharingJoinHandlerController.class);
        if (mController != null) {
            mController.init(this);
        }
    }
}
+94 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2025 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.settings.connecteddevice.audiosharing;

import static com.google.common.truth.Truth.assertThat;

import static org.mockito.Mockito.never;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.verify;

import android.bluetooth.BluetoothAdapter;
import android.bluetooth.BluetoothStatusCodes;
import android.os.Bundle;
import android.platform.test.annotations.DisableFlags;
import android.platform.test.annotations.EnableFlags;
import android.platform.test.flag.junit.SetFlagsRule;

import com.android.settings.testutils.shadow.ShadowBluetoothAdapter;
import com.android.settingslib.flags.Flags;

import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.junit.MockitoJUnit;
import org.mockito.junit.MockitoRule;
import org.robolectric.Robolectric;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.annotation.Config;
import org.robolectric.shadow.api.Shadow;

@RunWith(RobolectricTestRunner.class)
@Config(shadows = {ShadowBluetoothAdapter.class})
public class AudioSharingJoinHandlerActivityTest {
    @Rule
    public final MockitoRule mMockitoRule = MockitoJUnit.rule();
    @Rule
    public final SetFlagsRule mSetFlagsRule = new SetFlagsRule();

    private ShadowBluetoothAdapter mShadowBluetoothAdapter;
    private AudioSharingJoinHandlerActivity mActivity;

    @Before
    public void setUp() {
        mActivity = spy(Robolectric.buildActivity(AudioSharingJoinHandlerActivity.class).get());
        mShadowBluetoothAdapter = Shadow.extract(BluetoothAdapter.getDefaultAdapter());
        mShadowBluetoothAdapter.setEnabled(true);
        mShadowBluetoothAdapter.setIsLeAudioBroadcastSourceSupported(
                BluetoothStatusCodes.FEATURE_SUPPORTED);
        mShadowBluetoothAdapter.setIsLeAudioBroadcastAssistantSupported(
                BluetoothStatusCodes.FEATURE_SUPPORTED);
    }

    @Test
    @DisableFlags(Flags.FLAG_PROMOTE_AUDIO_SHARING_FOR_SECOND_AUTO_CONNECTED_LEA_DEVICE)
    public void onCreate_flagOff_finish() {
        mSetFlagsRule.disableFlags(Flags.FLAG_ENABLE_LE_AUDIO_SHARING);
        mActivity.onCreate(new Bundle());
        verify(mActivity).finish();
    }

    @Test
    @EnableFlags({Flags.FLAG_ENABLE_LE_AUDIO_SHARING,
            Flags.FLAG_PROMOTE_AUDIO_SHARING_FOR_SECOND_AUTO_CONNECTED_LEA_DEVICE})
    public void onCreate_flagOn_create() {
        mActivity.onCreate(new Bundle());
        verify(mActivity, never()).finish();
    }

    @Test
    public void isValidFragment_returnsTrue() {
        assertThat(mActivity.isValidFragment(
                AudioSharingJoinHandlerDashboardFragment.class.getName())).isTrue();
    }

    @Test
    public void isValidFragment_returnsFalse() {
        assertThat(mActivity.isValidFragment("")).isFalse();
    }
}
Loading