Snap for 11367679 from 8eb26342de to 24Q2-release

Change-Id: I5087de78039d530b2116d20cdcdf404ac774b4bb
This commit is contained in:
Android Build Coastguard Worker
2024-01-27 02:20:52 +00:00
43 changed files with 2230 additions and 430 deletions

View File

@@ -17,7 +17,6 @@
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:androidprv="http://schemas.android.com/apk/prv/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
@@ -26,36 +25,22 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="3"
android:layout_marginBottom="35dp">
android:layout_marginBottom="55dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingStart="40dp"
android:paddingEnd="40dp"
android:layout_gravity="bottom"
android:gravity="center"
android:orientation="vertical">
<ImageView
android:src="@drawable/ic_qr_code_scanner"
android:tint="?androidprv:attr/materialColorPrimaryContainer"
android:layout_width="@dimen/qrcode_icon_size"
android:layout_height="@dimen/qrcode_icon_size"
android:contentDescription="@null"/>
<TextView
style="@style/QrCodeScanner"
android:textSize="24sp"
android:text="@string/bluetooth_find_broadcast_button_scan"
android:text="Scan an audio stream QR code to listen with the active LE device"
android:gravity="center"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="19dp"/>
<TextView
style="@style/QrCodeScanner"
android:text="@string/bt_le_audio_scan_qr_code_scanner"
android:gravity="center"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"/>
android:layout_marginTop="20dp"/>
</LinearLayout>
</LinearLayout>

View File

@@ -233,6 +233,26 @@
<!-- Bluetooth Settings -->
<!-- Bluetooth developer settings: Bluetooth LE Audio modes -->
<string-array name="bluetooth_leaudio_mode">
<!-- Do not translate. -->
<item>Disabled</item>
<!-- Do not translate. -->
<item>Unicast</item>
<!-- Do not translate. -->
<item>Unicast and Broadcast</item>
</string-array>
<!-- Values for Bluetooth LE Audio mode -->
<string-array name="bluetooth_leaudio_mode_values" translatable="false">
<!-- Do not translate. -->
<item>disabled</item>
<!-- Do not translate. -->
<item>unicast</item>
<!-- Do not translate. -->
<item>broadcast</item>
</string-array>
<!-- Bluetooth developer settings: Titles for maximum number of connected audio devices -->
<string-array name="bluetooth_max_connected_audio_devices">
<item>Use System Default: <xliff:g id="default_bluetooth_max_connected_audio_devices">%1$d</xliff:g></item>

View File

@@ -249,7 +249,8 @@
<string name="bluetooth_disable_leaudio">Disable Bluetooth LE audio</string>
<!-- Summary of toggle for disabling Bluetooth LE audio [CHAR LIMIT=none]-->
<string name="bluetooth_disable_leaudio_summary">Disables Bluetooth LE audio feature if the device supports LE audio hardware capabilities.</string>
<!-- Setting toggle title for switch Bluetooth LE Audio mode. [CHAR LIMIT=40] -->
<string name="bluetooth_leaudio_mode">Bluetooth LE Audio mode</string>
<!-- Setting toggle title for enabling Bluetooth LE Audio toggle in Device Details. [CHAR LIMIT=40] -->
<string name="bluetooth_show_leaudio_device_details">Show LE audio toggle in Device Details</string>
@@ -9623,10 +9624,6 @@
<string name="permit_voice_activation_apps">Allow voice activation</string>
<!-- Description for a setting which controls whether an app can be voice activated [CHAR LIMIT=NONE] -->
<string name ="allow_voice_activation_apps_description">Voice activation turns-on approved apps, hands-free, using voice command. Built-in adaptive sensing ensures data stays private only to you.\n\n<a href="">More about protected adaptive sensing</a></string>
<!-- Label for a setting which controls whether an app can receive sandboxed detection training data [CHAR LIMIT=NONE] -->
<string name = "permit_receive_sandboxed_detection_training_data">Improve voice activation</string>
<!-- Description for a setting which controls whether an app can receive sandboxed detection training data [CHAR LIMIT=NONE] -->
<string name= "receive_sandboxed_detection_training_data_description">This device uses private intelligence to improve the voice activation model. Apps can receive summarized updates that are aggregated across many users to maintain privacy while improving the model for everyone.\n\n<a href="">More about private intelligence</a></string>
<!-- Manage full screen intent permission title [CHAR LIMIT=40] -->
<string name="full_screen_intent_title">Full screen notifications</string>

View File

@@ -45,9 +45,15 @@
<com.android.settings.connecteddevice.audiosharing.AudioSharingNamePreference
android:key="audio_sharing_stream_name"
android:summary="********"
android:title="Stream name"
android:title="Name"
settings:controller="com.android.settings.connecteddevice.audiosharing.AudioSharingNamePreferenceController" />
<com.android.settings.widget.ValidatedEditTextPreference
android:key="audio_sharing_stream_password"
android:summary="********"
android:title="Password"
settings:controller="com.android.settings.connecteddevice.audiosharing.AudioSharingPasswordPreferenceController" />
<SwitchPreferenceCompat
android:key="audio_sharing_stream_compatibility"
android:title="Improve compatibility"

View File

@@ -18,23 +18,31 @@
<PreferenceScreen
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:settings="http://schemas.android.com/apk/res-auto"
android:title="@string/audio_streams_title">
android:title="Find an audio stream">
<Preference
android:key="audio_streams_scan_qr_code"
android:title="@string/bluetooth_find_broadcast_button_scan"
android:icon="@drawable/ic_add_24dp"
android:summary="@string/audio_streams_qr_code_summary"
settings:controller="com.android.settings.connecteddevice.audiosharing.audiostreams.AudioStreamsScanQrCodeController" />
<com.android.settingslib.widget.TopIntroPreference
android:key="audio_streams_top_intro"
android:title="Listen to a device that's sharing audio or to a nearby Auracast broadcast"
settings:searchable="false"/>
<Preference
android:key="audio_streams_active_device"
android:title="Listen with"
android:title="Your audio device"
settings:controller="com.android.settings.connecteddevice.audiosharing.audiostreams.AudioStreamsActiveDeviceController" />
<com.android.settings.connecteddevice.audiosharing.audiostreams.AudioStreamsProgressCategoryPreference
android:key="audio_streams_nearby_category"
android:title="@string/audio_streams_pref_title"
settings:controller="com.android.settings.connecteddevice.audiosharing.audiostreams.AudioStreamsProgressCategoryController" />
android:title="Audio streams nearby"
settings:controller="com.android.settings.connecteddevice.audiosharing.audiostreams.AudioStreamsProgressCategoryController">
<Preference
android:key="audio_streams_scan_qr_code"
android:title="Scan a QR code"
android:icon="@drawable/ic_add_24dp"
android:summary="Start listening by scanning a stream's QR code"
android:order="0"
settings:controller="com.android.settings.connecteddevice.audiosharing.audiostreams.AudioStreamsScanQrCodeController" />
</com.android.settings.connecteddevice.audiosharing.audiostreams.AudioStreamsProgressCategoryPreference>
</PreferenceScreen>

View File

@@ -16,6 +16,7 @@
-->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
@@ -23,70 +24,78 @@
android:id="@+id/dialog_bg"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:paddingStart="25dp"
android:paddingEnd="25dp"
android:orientation="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="@dimen/broadcast_dialog_margin"
android:layout_marginBottom="25dp"
android:orientation="vertical">
<ImageView
android:id="@+id/dialog_icon"
android:layout_width="36dp"
android:layout_height="36dp"
android:layout_marginTop="@dimen/broadcast_dialog_icon_margin_top"
android:layout_marginBottom="@dimen/broadcast_dialog_title_img_margin_top"
android:layout_width="30dp"
android:layout_height="30dp"
android:layout_marginTop="24dp"
android:layout_gravity="center"
android:src="@drawable/ic_bt_audio_sharing"/>
<TextView
style="@style/BroadcastDialogTitleStyle"
android:id="@+id/dialog_title"
android:textAppearance="@android:style/TextAppearance.DeviceDefault.Headline"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="15dp"
android:gravity="center"
android:layout_gravity="center"/>
<TextView
style="@style/BroadcastDialogBodyStyle"
android:id="@+id/dialog_subtitle"
android:textAppearance="@android:style/TextAppearance.DeviceDefault.Small"
android:textStyle="bold"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="15dp"
android:gravity="center"
android:layout_gravity="center"
android:visibility="gone"/>
<TextView
style="@style/BroadcastDialogBodyStyle"
android:id="@+id/dialog_subtitle_2"
android:textAppearance="@android:style/TextAppearance.DeviceDefault.Small"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="15dp"
android:gravity="center"
android:layout_gravity="center"
android:visibility="gone"/>
</LinearLayout>
<LinearLayout
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="@dimen/broadcast_dialog_margin"
android:orientation="horizontal">
android:layout_marginBottom="@dimen/broadcast_dialog_margin">
<Button
android:id="@+id/left_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginLeft="16dp"
android:layout_weight="1"
android:visibility="invisible"/>
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
style="@style/BroadcastActionButton"/>
<Button
android:id="@+id/right_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginRight="16dp"
android:visibility="invisible"/>
</LinearLayout>
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
style="@style/BroadcastActionButton"/>
</androidx.constraintlayout.widget.ConstraintLayout>
</LinearLayout>
</FrameLayout>

View File

@@ -36,7 +36,7 @@
android:gravity="start"
android:textSize="15sp"
android:textColor="?android:attr/textColorPrimary"
android:text="Scan this QR code with another device connected to LE audio headphones to start sharing audio"/>
android:text="To listen to this audio stream, other people can connect compatible headphones to their Android device. They can then scan this QR code."/>
<LinearLayout
android:layout_width="match_parent"
@@ -50,6 +50,13 @@
android:layout_width="@dimen/qrcode_size"
android:layout_height="@dimen/qrcode_size"
android:src="@android:color/transparent"/>
<TextView
android:id="@+id/password"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="15sp"
android:textColor="?android:attr/textColorPrimary"/>
</LinearLayout>
</LinearLayout>

View File

@@ -373,6 +373,13 @@
android:title="@string/bluetooth_disable_leaudio"
android:summary="@string/bluetooth_disable_leaudio_summary" />
<ListPreference
android:key="bluetooth_leaudio_mode"
android:title="@string/bluetooth_leaudio_mode"
android:summary="@string/summary_placeholder"
android:entries="@array/bluetooth_leaudio_mode"
android:entryValues="@array/bluetooth_leaudio_mode_values"/>
<SwitchPreferenceCompat
android:key="bluetooth_show_leaudio_device_details"
android:title="@string/bluetooth_show_leaudio_device_details"/>

View File

@@ -52,7 +52,6 @@
settings:keywords="@string/keywords_more_mobile_networks"
settings:userRestriction="no_config_mobile_networks"
settings:isPreferenceVisible="@bool/config_show_sim_info"
settings:allowDividerAbove="true"
settings:useAdminDisabledSummary="true"
settings:searchable="@bool/config_show_sim_info"/>

View File

@@ -20,6 +20,7 @@ import static androidx.lifecycle.Lifecycle.Event.ON_CREATE;
import android.app.Activity;
import android.app.Dialog;
import android.content.ActivityNotFoundException;
import android.content.ComponentName;
import android.content.ContentResolver;
import android.content.Context;
@@ -656,7 +657,11 @@ public class CredentialManagerPreferenceController extends BasePreferenceControl
CombinedProviderInfo.createSettingsActivityIntent(
mContext, packageName, settingsActivity, getUser());
if (settingsIntent != null) {
mContext.startActivity(settingsIntent);
try {
mContext.startActivity(settingsIntent);
} catch (ActivityNotFoundException e) {
Log.e(TAG, "Failed to open settings activity", e);
}
}
}
});

View File

@@ -16,6 +16,7 @@
package com.android.settings.applications.credentials;
import android.content.ActivityNotFoundException;
import android.content.Context;
import android.content.Intent;
import android.credentials.CredentialManager;
@@ -26,6 +27,7 @@ import android.provider.Settings;
import android.service.autofill.AutofillService;
import android.service.autofill.AutofillServiceInfo;
import android.view.autofill.AutofillManager;
import android.util.Slog;
import androidx.annotation.Nullable;
import androidx.annotation.NonNull;
@@ -132,7 +134,11 @@ public class DefaultCombinedPreferenceController extends DefaultAppPreferenceCon
new PrimaryProviderPreference.Delegate() {
public void onOpenButtonClicked() {
if (settingsActivityIntent != null) {
startActivity(settingsActivityIntent);
try {
startActivity(settingsActivityIntent);
} catch (ActivityNotFoundException e) {
Slog.e(TAG, "Failed to open settings activity", e);
}
}
}

View File

@@ -19,6 +19,8 @@ package com.android.settings.connecteddevice.audiosharing;
import android.app.settings.SettingsEnums;
import android.content.Context;
import android.util.AttributeSet;
import android.util.Log;
import android.view.View;
import android.widget.ImageButton;
import androidx.preference.PreferenceViewHolder;
@@ -30,6 +32,7 @@ import com.android.settings.widget.ValidatedEditTextPreference;
public class AudioSharingNamePreference extends ValidatedEditTextPreference {
private static final String TAG = "AudioSharingNamePreference";
private boolean mShowQrCodeIcon = false;
public AudioSharingNamePreference(
Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
@@ -58,17 +61,50 @@ public class AudioSharingNamePreference extends ValidatedEditTextPreference {
setWidgetLayoutResource(R.layout.preference_widget_qrcode);
}
void setShowQrCodeIcon(boolean show) {
mShowQrCodeIcon = show;
notifyChanged();
}
@Override
public void onBindViewHolder(PreferenceViewHolder holder) {
super.onBindViewHolder(holder);
final ImageButton shareButton = (ImageButton) holder.findViewById(R.id.button_icon);
ImageButton shareButton = (ImageButton) holder.findViewById(R.id.button_icon);
View divider =
holder.findViewById(
com.android.settingslib.widget.preference.twotarget.R.id
.two_target_divider);
if (shareButton != null && divider != null) {
if (mShowQrCodeIcon) {
configureVisibleStateForQrCodeIcon(shareButton, divider);
} else {
configureInvisibleStateForQrCodeIcon(shareButton, divider);
}
} else {
Log.w(TAG, "onBindViewHolder() : shareButton or divider is null!");
}
}
private void configureVisibleStateForQrCodeIcon(ImageButton shareButton, View divider) {
divider.setVisibility(View.VISIBLE);
shareButton.setVisibility(View.VISIBLE);
shareButton.setImageDrawable(getContext().getDrawable(R.drawable.ic_qrcode_24dp));
shareButton.setOnClickListener(
unused ->
new SubSettingLauncher(getContext())
.setTitleText("Audio sharing QR code")
.setDestination(AudioStreamsQrCodeFragment.class.getName())
.setSourceMetricsCategory(SettingsEnums.AUDIO_SHARING_SETTINGS)
.launch());
shareButton.setOnClickListener(unused -> launchAudioSharingQrCodeFragment());
}
private void configureInvisibleStateForQrCodeIcon(ImageButton shareButton, View divider) {
divider.setVisibility(View.INVISIBLE);
shareButton.setVisibility(View.INVISIBLE);
shareButton.setOnClickListener(null);
}
private void launchAudioSharingQrCodeFragment() {
new SubSettingLauncher(getContext())
.setTitleText("Audio sharing QR code")
.setDestination(AudioStreamsQrCodeFragment.class.getName())
.setSourceMetricsCategory(SettingsEnums.AUDIO_SHARING_SETTINGS)
.launch();
}
}

View File

@@ -16,25 +16,128 @@
package com.android.settings.connecteddevice.audiosharing;
import static com.android.settings.connecteddevice.audiosharing.AudioSharingUtils.isBroadcasting;
import android.bluetooth.BluetoothLeBroadcast;
import android.bluetooth.BluetoothLeBroadcastMetadata;
import android.content.Context;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.lifecycle.DefaultLifecycleObserver;
import androidx.lifecycle.LifecycleOwner;
import androidx.preference.Preference;
import androidx.preference.PreferenceScreen;
import com.android.settings.bluetooth.Utils;
import com.android.settings.core.BasePreferenceController;
import com.android.settings.widget.ValidatedEditTextPreference;
import com.android.settingslib.bluetooth.BluetoothUtils;
import com.android.settingslib.bluetooth.LocalBluetoothLeBroadcast;
import com.android.settingslib.bluetooth.LocalBluetoothManager;
import com.android.settingslib.utils.ThreadUtils;
import java.util.concurrent.Executor;
import java.util.concurrent.Executors;
public class AudioSharingNamePreferenceController extends BasePreferenceController
implements ValidatedEditTextPreference.Validator, Preference.OnPreferenceChangeListener {
implements ValidatedEditTextPreference.Validator,
Preference.OnPreferenceChangeListener,
DefaultLifecycleObserver {
private static final String TAG = "AudioSharingNamePreferenceController";
private static final boolean DEBUG = BluetoothUtils.D;
private static final String PREF_KEY = "audio_sharing_stream_name";
private AudioSharingNameTextValidator mAudioSharingNameTextValidator;
private final BluetoothLeBroadcast.Callback mBroadcastCallback =
new BluetoothLeBroadcast.Callback() {
@Override
public void onBroadcastMetadataChanged(
int broadcastId, BluetoothLeBroadcastMetadata metadata) {
if (DEBUG) {
Log.d(
TAG,
"onBroadcastMetadataChanged() broadcastId : "
+ broadcastId
+ " metadata: "
+ metadata);
}
updateQrCodeIcon(true);
}
@Override
public void onBroadcastStartFailed(int reason) {}
@Override
public void onBroadcastStarted(int reason, int broadcastId) {}
@Override
public void onBroadcastStopFailed(int reason) {}
@Override
public void onBroadcastStopped(int reason, int broadcastId) {
if (DEBUG) {
Log.d(
TAG,
"onBroadcastStopped() reason : "
+ reason
+ " broadcastId: "
+ broadcastId);
}
updateQrCodeIcon(false);
}
@Override
public void onBroadcastUpdateFailed(int reason, int broadcastId) {
Log.w(TAG, "onBroadcastUpdateFailed() reason : " + reason);
// Do nothing if update failed.
}
@Override
public void onBroadcastUpdated(int reason, int broadcastId) {
if (DEBUG) {
Log.d(TAG, "onBroadcastUpdated() reason : " + reason);
}
updateBroadcastName();
}
@Override
public void onPlaybackStarted(int reason, int broadcastId) {}
@Override
public void onPlaybackStopped(int reason, int broadcastId) {}
};
@Nullable private final LocalBluetoothManager mLocalBtManager;
@Nullable private final LocalBluetoothLeBroadcast mBroadcast;
private final Executor mExecutor;
private final AudioSharingNameTextValidator mAudioSharingNameTextValidator;
@Nullable private AudioSharingNamePreference mPreference;
public AudioSharingNamePreferenceController(Context context, String preferenceKey) {
super(context, preferenceKey);
mLocalBtManager = Utils.getLocalBluetoothManager(context);
mBroadcast =
(mLocalBtManager != null)
? mLocalBtManager.getProfileManager().getLeAudioBroadcastProfile()
: null;
mAudioSharingNameTextValidator = new AudioSharingNameTextValidator();
mExecutor = Executors.newSingleThreadExecutor();
}
@Override
public void onStart(@NonNull LifecycleOwner owner) {
if (mBroadcast != null) {
mBroadcast.registerServiceCallBack(mExecutor, mBroadcastCallback);
}
}
@Override
public void onStop(@NonNull LifecycleOwner owner) {
if (mBroadcast != null) {
mBroadcast.unregisterServiceCallBack(mBroadcastCallback);
}
}
@Override
@@ -42,6 +145,17 @@ public class AudioSharingNamePreferenceController extends BasePreferenceControll
return AudioSharingUtils.isFeatureEnabled() ? AVAILABLE : UNSUPPORTED_ON_DEVICE;
}
@Override
public void displayPreference(PreferenceScreen screen) {
super.displayPreference(screen);
mPreference = screen.findPreference(getPreferenceKey());
if (mPreference != null) {
mPreference.setValidator(this);
updateBroadcastName();
updateQrCodeIcon(isBroadcasting(mLocalBtManager));
}
}
@Override
public String getPreferenceKey() {
return PREF_KEY;
@@ -49,10 +163,59 @@ public class AudioSharingNamePreferenceController extends BasePreferenceControll
@Override
public boolean onPreferenceChange(Preference preference, Object newValue) {
// TODO: update broadcast when name is changed.
if (mPreference != null
&& mPreference.getSummary() != null
&& ((String) newValue).contentEquals(mPreference.getSummary())) {
return false;
}
var unused =
ThreadUtils.postOnBackgroundThread(
() -> {
if (mBroadcast != null) {
mBroadcast.setProgramInfo((String) newValue);
if (isBroadcasting(mLocalBtManager)) {
// Update broadcast, UI update will be handled after callback
mBroadcast.updateBroadcast();
} else {
// Directly update UI if no ongoing broadcast
updateBroadcastName();
}
}
});
return true;
}
private void updateBroadcastName() {
if (mPreference != null) {
var unused =
ThreadUtils.postOnBackgroundThread(
() -> {
if (mBroadcast != null) {
String name = mBroadcast.getProgramInfo();
ThreadUtils.postOnMainThread(
() -> {
if (mPreference != null) {
mPreference.setText(name);
mPreference.setSummary(name);
}
});
}
});
}
}
private void updateQrCodeIcon(boolean show) {
if (mPreference != null) {
ThreadUtils.postOnMainThread(
() -> {
if (mPreference != null) {
mPreference.setShowQrCodeIcon(show);
}
});
}
}
@Override
public boolean isTextValid(String value) {
return mAudioSharingNameTextValidator.isTextValid(value);

View File

@@ -18,10 +18,27 @@ package com.android.settings.connecteddevice.audiosharing;
import com.android.settings.widget.ValidatedEditTextPreference;
import java.nio.charset.StandardCharsets;
/**
* Validator for Audio Sharing Name, which should be a UTF-8 encoded string containing a minimum of
* 4 characters and a maximum of 32 human-readable characters.
*/
public class AudioSharingNameTextValidator implements ValidatedEditTextPreference.Validator {
private static final int MIN_LENGTH = 4;
private static final int MAX_LENGTH = 32;
@Override
public boolean isTextValid(String value) {
// TODO: Add validate rule if applicable.
return true;
if (value == null || value.length() < MIN_LENGTH || value.length() > MAX_LENGTH) {
return false;
}
return isValidUTF8(value);
}
private static boolean isValidUTF8(String value) {
byte[] bytes = value.getBytes(StandardCharsets.UTF_8);
String reconstructedString = new String(bytes, StandardCharsets.UTF_8);
return value.equals(reconstructedString);
}
}

View File

@@ -0,0 +1,130 @@
/*
* Copyright (C) 2023 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.bluetooth.BluetoothLeBroadcast;
import android.bluetooth.BluetoothLeBroadcastMetadata;
import android.content.Context;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.lifecycle.DefaultLifecycleObserver;
import androidx.lifecycle.LifecycleOwner;
import androidx.preference.Preference;
import androidx.preference.PreferenceScreen;
import com.android.settings.bluetooth.Utils;
import com.android.settings.core.BasePreferenceController;
import com.android.settings.widget.ValidatedEditTextPreference;
import com.android.settingslib.bluetooth.LocalBluetoothLeBroadcast;
import java.util.concurrent.Executor;
import java.util.concurrent.Executors;
public class AudioSharingPasswordPreferenceController extends BasePreferenceController
implements ValidatedEditTextPreference.Validator,
Preference.OnPreferenceChangeListener,
DefaultLifecycleObserver {
private static final String PREF_KEY = "audio_sharing_stream_password";
private final BluetoothLeBroadcast.Callback mBroadcastCallback =
new BluetoothLeBroadcast.Callback() {
@Override
public void onBroadcastMetadataChanged(
int broadcastId, BluetoothLeBroadcastMetadata metadata) {}
@Override
public void onBroadcastStartFailed(int reason) {}
@Override
public void onBroadcastStarted(int reason, int broadcastId) {}
@Override
public void onBroadcastStopFailed(int reason) {}
@Override
public void onBroadcastStopped(int reason, int broadcastId) {}
@Override
public void onBroadcastUpdateFailed(int reason, int broadcastId) {}
@Override
public void onBroadcastUpdated(int reason, int broadcastId) {}
@Override
public void onPlaybackStarted(int reason, int broadcastId) {}
@Override
public void onPlaybackStopped(int reason, int broadcastId) {}
};
@Nullable private final LocalBluetoothLeBroadcast mBroadcast;
private final Executor mExecutor;
private final AudioSharingPasswordValidator mAudioSharingPasswordValidator;
@Nullable private ValidatedEditTextPreference mPreference;
public AudioSharingPasswordPreferenceController(Context context, String preferenceKey) {
super(context, preferenceKey);
mBroadcast =
Utils.getLocalBtManager(context).getProfileManager().getLeAudioBroadcastProfile();
mAudioSharingPasswordValidator = new AudioSharingPasswordValidator();
mExecutor = Executors.newSingleThreadExecutor();
}
@Override
public void onStart(@NonNull LifecycleOwner owner) {
if (mBroadcast != null) {
mBroadcast.registerServiceCallBack(mExecutor, mBroadcastCallback);
}
}
@Override
public void onStop(@NonNull LifecycleOwner owner) {
if (mBroadcast != null) {
mBroadcast.unregisterServiceCallBack(mBroadcastCallback);
}
}
@Override
public int getAvailabilityStatus() {
return AudioSharingUtils.isFeatureEnabled() ? AVAILABLE : UNSUPPORTED_ON_DEVICE;
}
@Override
public void displayPreference(PreferenceScreen screen) {
super.displayPreference(screen);
mPreference = screen.findPreference(getPreferenceKey());
if (mPreference != null) {
mPreference.setValidator(this);
}
}
@Override
public String getPreferenceKey() {
return PREF_KEY;
}
@Override
public boolean onPreferenceChange(Preference preference, Object newValue) {
// TODO(chelseahao): implement
return true;
}
@Override
public boolean isTextValid(String value) {
return mAudioSharingPasswordValidator.isTextValid(value);
}
}

View File

@@ -0,0 +1,51 @@
/*
* Copyright (C) 2023 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 com.android.settings.widget.ValidatedEditTextPreference;
import java.nio.charset.StandardCharsets;
/**
* Validator for Audio Sharing Password, which should be a UTF-8 string that has at least 4 octets
* and should not exceed 16 octets.
*/
public class AudioSharingPasswordValidator implements ValidatedEditTextPreference.Validator {
private static final int MIN_OCTETS = 4;
private static final int MAX_OCTETS = 16;
@Override
public boolean isTextValid(String value) {
if (value == null
|| getOctetsCount(value) < MIN_OCTETS
|| getOctetsCount(value) > MAX_OCTETS) {
return false;
}
return isValidUTF8(value);
}
private static int getOctetsCount(String value) {
return value.getBytes(StandardCharsets.UTF_8).length;
}
private static boolean isValidUTF8(String value) {
byte[] bytes = value.getBytes(StandardCharsets.UTF_8);
String reconstructedString = new String(bytes, StandardCharsets.UTF_8);
return value.equals(reconstructedString);
}
}

View File

@@ -336,7 +336,7 @@ public class AudioSharingUtils {
}
/** Returns if the broadcast is on-going. */
public static boolean isBroadcasting(LocalBluetoothManager manager) {
public static boolean isBroadcasting(@Nullable LocalBluetoothManager manager) {
if (manager == null) return false;
LocalBluetoothLeBroadcast broadcast =
manager.getProfileManager().getLeAudioBroadcastProfile();

View File

@@ -16,39 +16,170 @@
package com.android.settings.connecteddevice.audiosharing.audiostreams;
import android.bluetooth.BluetoothDevice;
import android.bluetooth.BluetoothLeBroadcastAssistant;
import android.bluetooth.BluetoothLeBroadcastMetadata;
import android.bluetooth.BluetoothLeBroadcastReceiveState;
import android.content.Context;
import android.util.Log;
import android.view.View;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.lifecycle.DefaultLifecycleObserver;
import androidx.lifecycle.LifecycleOwner;
import androidx.preference.PreferenceScreen;
import com.android.settings.R;
import com.android.settings.bluetooth.Utils;
import com.android.settings.core.BasePreferenceController;
import com.android.settingslib.bluetooth.LocalBluetoothLeBroadcastAssistant;
import com.android.settingslib.utils.ThreadUtils;
import com.android.settingslib.widget.ActionButtonsPreference;
import java.util.concurrent.Executor;
import java.util.concurrent.Executors;
public class AudioStreamButtonController extends BasePreferenceController
implements DefaultLifecycleObserver {
private static final String TAG = "AudioStreamButtonController";
private static final String KEY = "audio_stream_button";
private final BluetoothLeBroadcastAssistant.Callback mBroadcastAssistantCallback =
new AudioStreamsBroadcastAssistantCallback() {
@Override
public void onSourceRemoved(BluetoothDevice sink, int sourceId, int reason) {
super.onSourceRemoved(sink, sourceId, reason);
updateButton();
}
@Override
public void onSourceRemoveFailed(BluetoothDevice sink, int sourceId, int reason) {
super.onSourceRemoveFailed(sink, sourceId, reason);
updateButton();
}
@Override
public void onReceiveStateChanged(
BluetoothDevice sink,
int sourceId,
BluetoothLeBroadcastReceiveState state) {
super.onReceiveStateChanged(sink, sourceId, state);
if (mAudioStreamsHelper.isConnected(state)) {
updateButton();
}
}
@Override
public void onSourceAddFailed(
BluetoothDevice sink, BluetoothLeBroadcastMetadata source, int reason) {
super.onSourceAddFailed(sink, source, reason);
updateButton();
}
@Override
public void onSourceLost(int broadcastId) {
super.onSourceLost(broadcastId);
updateButton();
}
};
private final AudioStreamsRepository mAudioStreamsRepository =
AudioStreamsRepository.getInstance();
private final Executor mExecutor;
private final AudioStreamsHelper mAudioStreamsHelper;
private final @Nullable LocalBluetoothLeBroadcastAssistant mLeBroadcastAssistant;
private @Nullable ActionButtonsPreference mPreference;
private int mBroadcastId = -1;
public AudioStreamButtonController(Context context, String preferenceKey) {
super(context, preferenceKey);
mExecutor = Executors.newSingleThreadExecutor();
mAudioStreamsHelper = new AudioStreamsHelper(Utils.getLocalBtManager(context));
mLeBroadcastAssistant = mAudioStreamsHelper.getLeBroadcastAssistant();
}
@Override
public void onStart(@NonNull LifecycleOwner owner) {
if (mLeBroadcastAssistant == null) {
Log.w(TAG, "onStart(): LeBroadcastAssistant is null!");
return;
}
mLeBroadcastAssistant.registerServiceCallBack(mExecutor, mBroadcastAssistantCallback);
}
@Override
public void onStop(@NonNull LifecycleOwner owner) {
if (mLeBroadcastAssistant == null) {
Log.w(TAG, "onStop(): LeBroadcastAssistant is null!");
return;
}
mLeBroadcastAssistant.unregisterServiceCallBack(mBroadcastAssistantCallback);
}
@Override
public final void displayPreference(PreferenceScreen screen) {
mPreference = screen.findPreference(getPreferenceKey());
if (mPreference != null) {
mPreference.setButton1Enabled(true);
// TODO(chelseahao): update this based on stream connection state
mPreference
.setButton1Text(R.string.bluetooth_device_context_disconnect)
.setButton1Icon(R.drawable.ic_settings_close);
}
updateButton();
super.displayPreference(screen);
}
private void updateButton() {
if (mPreference != null) {
if (mAudioStreamsHelper.getAllConnectedSources().stream()
.map(BluetoothLeBroadcastReceiveState::getBroadcastId)
.anyMatch(connectedBroadcastId -> connectedBroadcastId == mBroadcastId)) {
ThreadUtils.postOnMainThread(
() -> {
if (mPreference != null) {
mPreference.setButton1Enabled(true);
mPreference
.setButton1Text(
R.string.bluetooth_device_context_disconnect)
.setButton1Icon(R.drawable.ic_settings_close)
.setButton1OnClickListener(
unused -> {
if (mPreference != null) {
mPreference.setButton1Enabled(false);
}
mAudioStreamsHelper.removeSource(mBroadcastId);
});
}
});
} else {
View.OnClickListener clickToRejoin =
unused ->
ThreadUtils.postOnBackgroundThread(
() -> {
var metadata =
mAudioStreamsRepository.getSavedMetadata(
mContext, mBroadcastId);
if (metadata != null) {
mAudioStreamsHelper.addSource(metadata);
ThreadUtils.postOnMainThread(
() -> {
if (mPreference != null) {
mPreference.setButton1Enabled(
false);
}
});
}
});
ThreadUtils.postOnMainThread(
() -> {
if (mPreference != null) {
mPreference.setButton1Enabled(true);
mPreference
.setButton1Text(R.string.bluetooth_device_context_connect)
.setButton1Icon(R.drawable.ic_add_24dp)
.setButton1OnClickListener(clickToRejoin);
}
});
}
} else {
Log.w(TAG, "updateButton(): preference is null!");
}
}
@Override
public int getAvailabilityStatus() {
return AVAILABLE;

View File

@@ -104,7 +104,7 @@ public class AudioStreamConfirmDialog extends InstrumentedDialogFragment {
private Dialog getErrorDialog() {
return new AudioStreamsDialogFragment.DialogBuilder(mActivity)
.setTitle("Can't listen to audio stream")
.setSubTitle1("Can't play this audio stream. Learn more")
.setSubTitle2("Can't play this audio stream. Learn more")
.setRightButtonText("Close")
.setRightButtonOnClickListener(
unused -> {

View File

@@ -16,22 +16,64 @@
package com.android.settings.connecteddevice.audiosharing.audiostreams;
import android.bluetooth.BluetoothDevice;
import android.bluetooth.BluetoothLeBroadcastAssistant;
import android.bluetooth.BluetoothLeBroadcastReceiveState;
import android.content.Context;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.lifecycle.DefaultLifecycleObserver;
import androidx.lifecycle.LifecycleOwner;
import androidx.preference.PreferenceScreen;
import com.android.settings.R;
import com.android.settings.bluetooth.Utils;
import com.android.settings.core.BasePreferenceController;
import com.android.settings.dashboard.DashboardFragment;
import com.android.settings.widget.EntityHeaderController;
import com.android.settingslib.bluetooth.LocalBluetoothLeBroadcastAssistant;
import com.android.settingslib.utils.ThreadUtils;
import com.android.settingslib.widget.LayoutPreference;
import java.util.concurrent.Executor;
import java.util.concurrent.Executors;
import javax.annotation.Nullable;
public class AudioStreamHeaderController extends BasePreferenceController
implements DefaultLifecycleObserver {
private static final String TAG = "AudioStreamHeaderController";
private static final String KEY = "audio_stream_header";
private final Executor mExecutor;
private final AudioStreamsHelper mAudioStreamsHelper;
@Nullable private final LocalBluetoothLeBroadcastAssistant mLeBroadcastAssistant;
private final BluetoothLeBroadcastAssistant.Callback mBroadcastAssistantCallback =
new AudioStreamsBroadcastAssistantCallback() {
@Override
public void onSourceRemoved(BluetoothDevice sink, int sourceId, int reason) {
super.onSourceRemoved(sink, sourceId, reason);
updateSummary();
}
@Override
public void onSourceLost(int broadcastId) {
super.onSourceLost(broadcastId);
updateSummary();
}
@Override
public void onReceiveStateChanged(
BluetoothDevice sink,
int sourceId,
BluetoothLeBroadcastReceiveState state) {
super.onReceiveStateChanged(sink, sourceId, state);
if (mAudioStreamsHelper.isConnected(state)) {
updateSummary();
}
}
};
private @Nullable EntityHeaderController mHeaderController;
private @Nullable DashboardFragment mFragment;
private String mBroadcastName = "";
@@ -39,6 +81,27 @@ public class AudioStreamHeaderController extends BasePreferenceController
public AudioStreamHeaderController(Context context, String preferenceKey) {
super(context, preferenceKey);
mExecutor = Executors.newSingleThreadExecutor();
mAudioStreamsHelper = new AudioStreamsHelper(Utils.getLocalBtManager(context));
mLeBroadcastAssistant = mAudioStreamsHelper.getLeBroadcastAssistant();
}
@Override
public void onStart(@NonNull LifecycleOwner owner) {
if (mLeBroadcastAssistant == null) {
Log.w(TAG, "onStart(): LeBroadcastAssistant is null!");
return;
}
mLeBroadcastAssistant.registerServiceCallBack(mExecutor, mBroadcastAssistantCallback);
}
@Override
public void onStop(@NonNull LifecycleOwner owner) {
if (mLeBroadcastAssistant == null) {
Log.w(TAG, "onStop(): LeBroadcastAssistant is null!");
return;
}
mLeBroadcastAssistant.unregisterServiceCallBack(mBroadcastAssistantCallback);
}
@Override
@@ -55,14 +118,37 @@ public class AudioStreamHeaderController extends BasePreferenceController
}
mHeaderController.setIcon(
screen.getContext().getDrawable(R.drawable.ic_bt_audio_sharing));
// TODO(chelseahao): update this based on stream connection state
mHeaderController.setSummary("Listening now");
mHeaderController.done(true);
screen.addPreference(headerPreference);
updateSummary();
}
super.displayPreference(screen);
}
private void updateSummary() {
var unused =
ThreadUtils.postOnBackgroundThread(
() -> {
var latestSummary =
mAudioStreamsHelper.getAllConnectedSources().stream()
.map(
BluetoothLeBroadcastReceiveState
::getBroadcastId)
.anyMatch(
connectedBroadcastId ->
connectedBroadcastId
== mBroadcastId)
? "Listening now"
: "";
ThreadUtils.postOnMainThread(
() -> {
if (mHeaderController != null) {
mHeaderController.setSummary(latestSummary);
mHeaderController.done(true);
}
});
});
}
@Override
public int getAvailabilityStatus() {
return AVAILABLE;

View File

@@ -21,8 +21,10 @@ import android.bluetooth.BluetoothLeBroadcastMetadata;
import android.bluetooth.BluetoothLeBroadcastReceiveState;
import android.content.Context;
import android.util.AttributeSet;
import android.view.View;
import androidx.annotation.Nullable;
import androidx.preference.PreferenceViewHolder;
import com.android.settings.R;
import com.android.settingslib.widget.TwoTargetPreference;
@@ -56,7 +58,6 @@ class AudioStreamPreference extends TwoTargetPreference {
}
mIsConnected = isConnected;
setSummary(summary);
setOrder(isConnected ? 0 : 1);
setOnPreferenceClickListener(onPreferenceClickListener);
notifyChanged();
}
@@ -70,6 +71,23 @@ class AudioStreamPreference extends TwoTargetPreference {
mAudioStream.setState(state);
}
void setAudioStreamMetadata(BluetoothLeBroadcastMetadata metadata) {
mAudioStream.setMetadata(metadata);
}
int getAudioStreamBroadcastId() {
return mAudioStream.getBroadcastId();
}
int getAudioStreamRssi() {
return mAudioStream.getRssi();
}
@Nullable
BluetoothLeBroadcastMetadata getAudioStreamMetadata() {
return mAudioStream.getMetadata();
}
AudioStreamsProgressCategoryController.AudioStreamState getAudioStreamState() {
return mAudioStream.getState();
}
@@ -84,25 +102,31 @@ class AudioStreamPreference extends TwoTargetPreference {
return R.layout.preference_widget_lock;
}
@Override
public void onBindViewHolder(PreferenceViewHolder holder) {
super.onBindViewHolder(holder);
View divider =
holder.findViewById(
com.android.settingslib.widget.preference.twotarget.R.id
.two_target_divider);
if (divider != null) {
divider.setVisibility(View.GONE);
}
}
static AudioStreamPreference fromMetadata(
Context context,
BluetoothLeBroadcastMetadata source,
AudioStreamsProgressCategoryController.AudioStreamState streamState) {
Context context, BluetoothLeBroadcastMetadata source) {
AudioStreamPreference preference = new AudioStreamPreference(context, /* attrs= */ null);
preference.setTitle(getBroadcastName(source));
preference.setAudioStream(new AudioStream(source.getBroadcastId(), streamState));
preference.setAudioStream(new AudioStream(source));
return preference;
}
static AudioStreamPreference fromReceiveState(
Context context,
BluetoothLeBroadcastReceiveState receiveState,
AudioStreamsProgressCategoryController.AudioStreamState streamState) {
Context context, BluetoothLeBroadcastReceiveState receiveState) {
AudioStreamPreference preference = new AudioStreamPreference(context, /* attrs= */ null);
preference.setTitle(getBroadcastName(receiveState));
preference.setAudioStream(
new AudioStream(
receiveState.getSourceId(), receiveState.getBroadcastId(), streamState));
preference.setAudioStream(new AudioStream(receiveState));
return preference;
}
@@ -127,41 +151,45 @@ class AudioStreamPreference extends TwoTargetPreference {
}
private static final class AudioStream {
private int mSourceId;
private int mBroadcastId;
private AudioStreamsProgressCategoryController.AudioStreamState mState;
private static final int UNAVAILABLE = -1;
@Nullable private BluetoothLeBroadcastMetadata mMetadata;
@Nullable private BluetoothLeBroadcastReceiveState mReceiveState;
private AudioStreamsProgressCategoryController.AudioStreamState mState =
AudioStreamsProgressCategoryController.AudioStreamState.UNKNOWN;
private AudioStream(
int broadcastId, AudioStreamsProgressCategoryController.AudioStreamState state) {
mBroadcastId = broadcastId;
mState = state;
private AudioStream(BluetoothLeBroadcastMetadata metadata) {
mMetadata = metadata;
}
private AudioStream(
int sourceId,
int broadcastId,
AudioStreamsProgressCategoryController.AudioStreamState state) {
mSourceId = sourceId;
mBroadcastId = broadcastId;
mState = state;
private AudioStream(BluetoothLeBroadcastReceiveState receiveState) {
mReceiveState = receiveState;
}
// TODO(chelseahao): use this to handleSourceRemoved
private int getSourceId() {
return mSourceId;
}
// TODO(chelseahao): use this to handleSourceRemoved
private int getBroadcastId() {
return mBroadcastId;
return mMetadata != null
? mMetadata.getBroadcastId()
: mReceiveState != null ? mReceiveState.getBroadcastId() : UNAVAILABLE;
}
private int getRssi() {
return mMetadata != null ? mMetadata.getRssi() : Integer.MAX_VALUE;
}
private AudioStreamsProgressCategoryController.AudioStreamState getState() {
return mState;
}
@Nullable
private BluetoothLeBroadcastMetadata getMetadata() {
return mMetadata;
}
private void setState(AudioStreamsProgressCategoryController.AudioStreamState state) {
mState = state;
}
private void setMetadata(BluetoothLeBroadcastMetadata metadata) {
mMetadata = metadata;
}
}
}

View File

@@ -24,21 +24,12 @@ import android.util.Log;
import com.android.settingslib.bluetooth.BluetoothUtils;
import java.util.Locale;
public class AudioStreamsBroadcastAssistantCallback
implements BluetoothLeBroadcastAssistant.Callback {
private static final String TAG = "AudioStreamsBroadcastAssistantCallback";
private static final boolean DEBUG = BluetoothUtils.D;
private final AudioStreamsProgressCategoryController mCategoryController;
public AudioStreamsBroadcastAssistantCallback(
AudioStreamsProgressCategoryController audioStreamsProgressCategoryController) {
mCategoryController = audioStreamsProgressCategoryController;
}
@Override
public void onReceiveStateChanged(
BluetoothDevice sink, int sourceId, BluetoothLeBroadcastReceiveState state) {
@@ -52,45 +43,30 @@ public class AudioStreamsBroadcastAssistantCallback
+ " state: "
+ state);
}
mCategoryController.handleSourceConnected(state);
}
@Override
public void onSearchStartFailed(int reason) {
Log.w(TAG, "onSearchStartFailed() reason : " + reason);
mCategoryController.showToast(
String.format(Locale.US, "Failed to start scanning, reason %d", reason));
}
@Override
public void onSearchStarted(int reason) {
if (mCategoryController == null) {
Log.w(TAG, "onSearchStarted() : mCategoryController is null!");
return;
}
if (DEBUG) {
Log.d(TAG, "onSearchStarted() reason : " + reason);
}
mCategoryController.setScanning(true);
}
@Override
public void onSearchStopFailed(int reason) {
Log.w(TAG, "onSearchStopFailed() reason : " + reason);
mCategoryController.showToast(
String.format(Locale.US, "Failed to stop scanning, reason %d", reason));
}
@Override
public void onSearchStopped(int reason) {
if (mCategoryController == null) {
Log.w(TAG, "onSearchStopped() : mCategoryController is null!");
return;
}
if (DEBUG) {
Log.d(TAG, "onSearchStopped() reason : " + reason);
}
mCategoryController.setScanning(false);
}
@Override
@@ -106,8 +82,6 @@ public class AudioStreamsBroadcastAssistantCallback
+ " reason: "
+ reason);
}
mCategoryController.showToast(
String.format(Locale.US, "Failed to join broadcast, reason %d", reason));
}
@Override
@@ -126,14 +100,9 @@ public class AudioStreamsBroadcastAssistantCallback
@Override
public void onSourceFound(BluetoothLeBroadcastMetadata source) {
if (mCategoryController == null) {
Log.w(TAG, "onSourceFound() : mCategoryController is null!");
return;
}
if (DEBUG) {
Log.d(TAG, "onSourceFound() broadcastId : " + source.getBroadcastId());
}
mCategoryController.handleSourceFound(source);
}
@Override
@@ -141,7 +110,6 @@ public class AudioStreamsBroadcastAssistantCallback
if (DEBUG) {
Log.d(TAG, "onSourceLost() broadcastId : " + broadcastId);
}
mCategoryController.handleSourceLost(broadcastId);
}
@Override
@@ -153,12 +121,6 @@ public class AudioStreamsBroadcastAssistantCallback
@Override
public void onSourceRemoveFailed(BluetoothDevice sink, int sourceId, int reason) {
Log.w(TAG, "onSourceRemoveFailed() sourceId : " + sourceId + " reason : " + reason);
mCategoryController.showToast(
String.format(
Locale.US,
"Failed to remove source %d for sink %s",
sourceId,
sink.getAddress()));
}
@Override
@@ -166,8 +128,5 @@ public class AudioStreamsBroadcastAssistantCallback
if (DEBUG) {
Log.d(TAG, "onSourceRemoved() sourceId : " + sourceId + " reason : " + reason);
}
mCategoryController.showToast(
String.format(
Locale.US, "Source %d removed for sink %s", sourceId, sink.getAddress()));
}
}

View File

@@ -0,0 +1,117 @@
/*
* Copyright (C) 2024 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.audiostreams;
import android.bluetooth.BluetoothDevice;
import android.bluetooth.BluetoothLeBroadcastMetadata;
import android.bluetooth.BluetoothLeBroadcastReceiveState;
import android.util.Log;
import java.util.Locale;
public class AudioStreamsProgressCategoryCallback extends AudioStreamsBroadcastAssistantCallback {
private static final String TAG = "AudioStreamsProgressCategoryCallback";
private final AudioStreamsProgressCategoryController mCategoryController;
public AudioStreamsProgressCategoryCallback(
AudioStreamsProgressCategoryController audioStreamsProgressCategoryController) {
mCategoryController = audioStreamsProgressCategoryController;
}
@Override
public void onReceiveStateChanged(
BluetoothDevice sink, int sourceId, BluetoothLeBroadcastReceiveState state) {
super.onReceiveStateChanged(sink, sourceId, state);
mCategoryController.handleSourceConnected(state);
}
@Override
public void onSearchStartFailed(int reason) {
super.onSearchStartFailed(reason);
mCategoryController.showToast(
String.format(Locale.US, "Failed to start scanning, reason %d", reason));
}
@Override
public void onSearchStarted(int reason) {
super.onSearchStarted(reason);
if (mCategoryController == null) {
Log.w(TAG, "onSearchStarted() : mCategoryController is null!");
return;
}
mCategoryController.setScanning(true);
}
@Override
public void onSearchStopFailed(int reason) {
super.onSearchStopFailed(reason);
mCategoryController.showToast(
String.format(Locale.US, "Failed to stop scanning, reason %d", reason));
}
@Override
public void onSearchStopped(int reason) {
super.onSearchStopped(reason);
if (mCategoryController == null) {
Log.w(TAG, "onSearchStopped() : mCategoryController is null!");
return;
}
mCategoryController.setScanning(false);
}
@Override
public void onSourceAddFailed(
BluetoothDevice sink, BluetoothLeBroadcastMetadata source, int reason) {
super.onSourceAddFailed(sink, source, reason);
mCategoryController.showToast(
String.format(Locale.US, "Failed to join broadcast, reason %d", reason));
}
@Override
public void onSourceFound(BluetoothLeBroadcastMetadata source) {
super.onSourceFound(source);
if (mCategoryController == null) {
Log.w(TAG, "onSourceFound() : mCategoryController is null!");
return;
}
mCategoryController.handleSourceFound(source);
}
@Override
public void onSourceLost(int broadcastId) {
super.onSourceLost(broadcastId);
mCategoryController.handleSourceLost(broadcastId);
}
@Override
public void onSourceRemoveFailed(BluetoothDevice sink, int sourceId, int reason) {
super.onSourceRemoveFailed(sink, sourceId, reason);
mCategoryController.showToast(
String.format(
Locale.US,
"Failed to remove source %d for sink %s",
sourceId,
sink.getAddress()));
}
@Override
public void onSourceRemoved(BluetoothDevice sink, int sourceId, int reason) {
super.onSourceRemoved(sink, sourceId, reason);
mCategoryController.handleSourceRemoved();
}
}

View File

@@ -16,6 +16,8 @@
package com.android.settings.connecteddevice.audiosharing.audiostreams;
import static com.android.settings.connecteddevice.audiosharing.audiostreams.AudioStreamsScanQrCodeController.REQUEST_SCAN_BT_BROADCAST_QR_CODE;
import static java.util.Collections.emptyList;
import android.app.AlertDialog;
@@ -43,8 +45,10 @@ import androidx.preference.PreferenceScreen;
import com.android.settings.R;
import com.android.settings.bluetooth.Utils;
import com.android.settings.connecteddevice.audiosharing.AudioSharingUtils;
import com.android.settings.connecteddevice.audiosharing.audiostreams.qrcode.QrCodeScanModeActivity;
import com.android.settings.core.BasePreferenceController;
import com.android.settings.core.SubSettingLauncher;
import com.android.settingslib.bluetooth.BluetoothBroadcastUtils;
import com.android.settingslib.bluetooth.BluetoothCallback;
import com.android.settingslib.bluetooth.BluetoothUtils;
import com.android.settingslib.bluetooth.CachedBluetoothDevice;
@@ -53,6 +57,7 @@ import com.android.settingslib.bluetooth.LocalBluetoothManager;
import com.android.settingslib.utils.ThreadUtils;
import java.nio.charset.StandardCharsets;
import java.util.Comparator;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executor;
import java.util.concurrent.Executors;
@@ -74,25 +79,77 @@ public class AudioStreamsProgressCategoryController extends BasePreferenceContro
}
};
private final Preference.OnPreferenceClickListener mAddSourceOrShowDialog =
preference -> {
var p = (AudioStreamPreference) preference;
if (DEBUG) {
Log.d(
TAG,
"preferenceClicked(): attempt to join broadcast id : "
+ p.getAudioStreamBroadcastId());
}
var source = p.getAudioStreamMetadata();
if (source != null) {
if (source.isEncrypted()) {
ThreadUtils.postOnMainThread(() -> launchPasswordDialog(source, p));
} else {
moveToState(p, AudioStreamState.ADD_SOURCE_WAIT_FOR_RESPONSE);
}
}
return true;
};
private final Preference.OnPreferenceClickListener mLaunchDetailFragment =
preference -> {
var p = (AudioStreamPreference) preference;
Bundle broadcast = new Bundle();
broadcast.putString(
AudioStreamDetailsFragment.BROADCAST_NAME_ARG, (String) p.getTitle());
broadcast.putInt(
AudioStreamDetailsFragment.BROADCAST_ID_ARG, p.getAudioStreamBroadcastId());
new SubSettingLauncher(mContext)
.setTitleText("Audio stream details")
.setDestination(AudioStreamDetailsFragment.class.getName())
// TODO(chelseahao): Add logging enum
.setSourceMetricsCategory(SettingsEnums.PAGE_UNKNOWN)
.setArguments(broadcast)
.launch();
return true;
};
private final AudioStreamsRepository mAudioStreamsRepository =
AudioStreamsRepository.getInstance();
enum AudioStreamState {
UNKNOWN,
// When mTimedSourceFromQrCode is present and this source has not been synced.
WAIT_FOR_SYNC,
// When source has been synced but not added to any sink.
SYNCED,
// When addSource is called for this source and waiting for response.
WAIT_FOR_SOURCE_ADD,
ADD_SOURCE_WAIT_FOR_RESPONSE,
// Source is added to active sink.
SOURCE_ADDED,
}
private final Comparator<AudioStreamPreference> mComparator =
Comparator.<AudioStreamPreference, Boolean>comparing(
p ->
p.getAudioStreamState()
== AudioStreamsProgressCategoryController
.AudioStreamState.SOURCE_ADDED)
.thenComparingInt(AudioStreamPreference::getAudioStreamRssi)
.reversed();
private final Executor mExecutor;
private final AudioStreamsBroadcastAssistantCallback mBroadcastAssistantCallback;
private final AudioStreamsProgressCategoryCallback mBroadcastAssistantCallback;
private final AudioStreamsHelper mAudioStreamsHelper;
private final @Nullable LocalBluetoothLeBroadcastAssistant mLeBroadcastAssistant;
private final @Nullable LocalBluetoothManager mBluetoothManager;
private final ConcurrentHashMap<Integer, AudioStreamPreference> mBroadcastIdToPreferenceMap =
new ConcurrentHashMap<>();
private TimedSourceFromQrCode mTimedSourceFromQrCode;
private @Nullable TimedSourceFromQrCode mTimedSourceFromQrCode;
private AudioStreamsProgressCategoryPreference mCategoryPreference;
private AudioStreamsDashboardFragment mFragment;
@@ -102,7 +159,7 @@ public class AudioStreamsProgressCategoryController extends BasePreferenceContro
mBluetoothManager = Utils.getLocalBtManager(mContext);
mAudioStreamsHelper = new AudioStreamsHelper(mBluetoothManager);
mLeBroadcastAssistant = mAudioStreamsHelper.getLeBroadcastAssistant();
mBroadcastAssistantCallback = new AudioStreamsBroadcastAssistantCallback(this);
mBroadcastAssistantCallback = new AudioStreamsProgressCategoryCallback(this);
}
@Override
@@ -155,41 +212,18 @@ public class AudioStreamsProgressCategoryController extends BasePreferenceContro
}
void handleSourceFound(BluetoothLeBroadcastMetadata source) {
Preference.OnPreferenceClickListener addSourceOrShowDialog =
preference -> {
if (DEBUG) {
Log.d(
TAG,
"preferenceClicked(): attempt to join broadcast id : "
+ source.getBroadcastId());
}
if (source.isEncrypted()) {
ThreadUtils.postOnMainThread(
() ->
launchPasswordDialog(
source, (AudioStreamPreference) preference));
} else {
mAudioStreamsHelper.addSource(source);
((AudioStreamPreference) preference)
.setAudioStreamState(AudioStreamState.WAIT_FOR_SOURCE_ADD);
updatePreferenceConnectionState(
(AudioStreamPreference) preference,
AudioStreamState.WAIT_FOR_SOURCE_ADD,
null);
}
return true;
};
var broadcastIdFound = source.getBroadcastId();
mBroadcastIdToPreferenceMap.compute(
broadcastIdFound,
(k, v) -> {
if (v == null) {
return addNewPreference(
source, AudioStreamState.SYNCED, addSourceOrShowDialog);
// No existing preference for this source founded, add one and set initial
// state to SYNCED.
return addNewPreference(source, AudioStreamState.SYNCED);
}
var fromState = v.getAudioStreamState();
if (fromState == AudioStreamState.WAIT_FOR_SYNC) {
if (fromState == AudioStreamState.WAIT_FOR_SYNC
&& mTimedSourceFromQrCode != null) {
var pendingSource = mTimedSourceFromQrCode.get();
if (pendingSource == null) {
Log.w(
@@ -198,15 +232,20 @@ public class AudioStreamsProgressCategoryController extends BasePreferenceContro
+ fromState
+ " for broadcastId : "
+ broadcastIdFound);
v.setAudioStreamState(AudioStreamState.SYNCED);
v.setAudioStreamMetadata(source);
moveToState(v, AudioStreamState.SYNCED);
return v;
}
mAudioStreamsHelper.addSource(pendingSource);
mTimedSourceFromQrCode.consumed();
v.setAudioStreamState(AudioStreamState.WAIT_FOR_SOURCE_ADD);
updatePreferenceConnectionState(
v, AudioStreamState.WAIT_FOR_SOURCE_ADD, null);
// A preference with source founded is existed from a QR code scan. As the
// source is now synced, we update the preference with pendingSource from QR
// code scan and add source with it (since it has the password).
v.setAudioStreamMetadata(pendingSource);
moveToState(v, AudioStreamState.ADD_SOURCE_WAIT_FOR_RESPONSE);
} else {
// A preference with source founded existed either because it's already
// connected (SOURCE_ADDED), or other unexpected reason. We update the
// preference with this source and won't change it's state.
v.setAudioStreamMetadata(source);
if (fromState != AudioStreamState.SOURCE_ADDED) {
Log.w(
TAG,
@@ -229,18 +268,18 @@ public class AudioStreamsProgressCategoryController extends BasePreferenceContro
metadataFromQrCode.getBroadcastId(),
(k, v) -> {
if (v == null) {
mTimedSourceFromQrCode.waitForConsume();
return addNewPreference(
metadataFromQrCode, AudioStreamState.WAIT_FOR_SYNC, null);
// No existing preference for this source from the QR code scan, add one and
// set initial state to WAIT_FOR_SYNC.
return addNewPreference(metadataFromQrCode, AudioStreamState.WAIT_FOR_SYNC);
}
var fromState = v.getAudioStreamState();
if (fromState == AudioStreamState.SYNCED) {
mAudioStreamsHelper.addSource(metadataFromQrCode);
mTimedSourceFromQrCode.consumed();
v.setAudioStreamState(AudioStreamState.WAIT_FOR_SOURCE_ADD);
updatePreferenceConnectionState(
v, AudioStreamState.WAIT_FOR_SOURCE_ADD, null);
// A preference with source from the QR code is existed because it has been
// founded during scanning, now we have the password, we can add source.
v.setAudioStreamMetadata(metadataFromQrCode);
moveToState(v, AudioStreamState.ADD_SOURCE_WAIT_FOR_RESPONSE);
} else {
v.setAudioStreamMetadata(metadataFromQrCode);
Log.w(
TAG,
"handleSourceFromQrCode(): unexpected state : "
@@ -265,54 +304,71 @@ public class AudioStreamsProgressCategoryController extends BasePreferenceContro
mAudioStreamsHelper.removeSource(broadcastId);
}
void handleSourceRemoved() {
for (var entry : mBroadcastIdToPreferenceMap.entrySet()) {
var preference = entry.getValue();
// Look for preference has SOURCE_ADDED state, re-check if they are still connected. If
// not, means the source is removed from the sink, we move back the preference to SYNCED
// state.
if (preference.getAudioStreamState() == AudioStreamState.SOURCE_ADDED
&& mAudioStreamsHelper.getAllConnectedSources().stream()
.noneMatch(
connected ->
connected.getBroadcastId()
== preference.getAudioStreamBroadcastId())) {
ThreadUtils.postOnMainThread(
() -> {
var metadata = preference.getAudioStreamMetadata();
if (metadata != null) {
moveToState(preference, AudioStreamState.SYNCED);
} else {
handleSourceLost(preference.getAudioStreamBroadcastId());
}
});
return;
}
}
}
void handleSourceConnected(BluetoothLeBroadcastReceiveState receiveState) {
if (!mAudioStreamsHelper.isConnected(receiveState)) {
return;
}
var sourceAddedState = AudioStreamState.SOURCE_ADDED;
var broadcastIdConnected = receiveState.getBroadcastId();
mBroadcastIdToPreferenceMap.compute(
broadcastIdConnected,
(k, v) -> {
if (v == null) {
return addNewPreference(
receiveState,
sourceAddedState,
p -> launchDetailFragment(broadcastIdConnected));
// No existing preference for this source even if it's already connected,
// add one and set initial state to SOURCE_ADDED. This could happen because
// we retrieves the connected source during onStart() from
// AudioStreamsHelper#getAllConnectedSources() even before the source is
// founded by scanning.
return addNewPreference(receiveState, AudioStreamState.SOURCE_ADDED);
}
var fromState = v.getAudioStreamState();
if (fromState == AudioStreamState.WAIT_FOR_SOURCE_ADD
if (fromState == AudioStreamState.ADD_SOURCE_WAIT_FOR_RESPONSE
|| fromState == AudioStreamState.SYNCED
|| fromState == AudioStreamState.WAIT_FOR_SYNC) {
if (mTimedSourceFromQrCode != null) {
mTimedSourceFromQrCode.consumed();
}
|| fromState == AudioStreamState.WAIT_FOR_SYNC
|| fromState == AudioStreamState.SOURCE_ADDED) {
// Expected state, do nothing
} else {
if (fromState != AudioStreamState.SOURCE_ADDED) {
Log.w(
TAG,
"handleSourceConnected(): unexpected state : "
+ fromState
+ " for broadcastId : "
+ broadcastIdConnected);
}
Log.w(
TAG,
"handleSourceConnected(): unexpected state : "
+ fromState
+ " for broadcastId : "
+ broadcastIdConnected);
}
v.setAudioStreamState(sourceAddedState);
updatePreferenceConnectionState(
v, sourceAddedState, p -> launchDetailFragment(broadcastIdConnected));
moveToState(v, AudioStreamState.SOURCE_ADDED);
return v;
});
}
private static String getPreferenceSummary(AudioStreamState state) {
return switch (state) {
case WAIT_FOR_SYNC -> "Scanning...";
case WAIT_FOR_SOURCE_ADD -> "Connecting...";
case SOURCE_ADDED -> "Listening now";
default -> "";
};
}
void showToast(String msg) {
AudioSharingUtils.toastMessage(mContext, msg);
}
@@ -322,7 +378,7 @@ public class AudioStreamsProgressCategoryController extends BasePreferenceContro
ThreadUtils.postOnMainThread(
() -> {
if (mCategoryPreference != null) {
mCategoryPreference.removeAll();
mCategoryPreference.removeAudioStreamPreferences();
mCategoryPreference.setVisible(hasActive);
}
});
@@ -348,7 +404,6 @@ public class AudioStreamsProgressCategoryController extends BasePreferenceContro
Log.d(TAG, "startScanning()");
}
mLeBroadcastAssistant.registerServiceCallBack(mExecutor, mBroadcastAssistantCallback);
mLeBroadcastAssistant.startSearchingForSources(emptyList());
// Handle QR code scan and display currently connected streams
var unused =
@@ -358,6 +413,7 @@ public class AudioStreamsProgressCategoryController extends BasePreferenceContro
mAudioStreamsHelper
.getAllConnectedSources()
.forEach(this::handleSourceConnected);
mLeBroadcastAssistant.startSearchingForSources(emptyList());
});
}
@@ -374,68 +430,93 @@ public class AudioStreamsProgressCategoryController extends BasePreferenceContro
}
mLeBroadcastAssistant.unregisterServiceCallBack(mBroadcastAssistantCallback);
if (mTimedSourceFromQrCode != null) {
mTimedSourceFromQrCode.consumed();
mTimedSourceFromQrCode.cleanup();
mTimedSourceFromQrCode = null;
}
}
private AudioStreamPreference addNewPreference(
BluetoothLeBroadcastReceiveState receiveState,
AudioStreamState state,
Preference.OnPreferenceClickListener onClickListener) {
var preference = AudioStreamPreference.fromReceiveState(mContext, receiveState, state);
updatePreferenceConnectionState(preference, state, onClickListener);
BluetoothLeBroadcastReceiveState receiveState, AudioStreamState state) {
var preference = AudioStreamPreference.fromReceiveState(mContext, receiveState);
moveToState(preference, state);
return preference;
}
private AudioStreamPreference addNewPreference(
BluetoothLeBroadcastMetadata metadata,
AudioStreamState state,
Preference.OnPreferenceClickListener onClickListener) {
var preference = AudioStreamPreference.fromMetadata(mContext, metadata, state);
updatePreferenceConnectionState(preference, state, onClickListener);
BluetoothLeBroadcastMetadata metadata, AudioStreamState state) {
var preference = AudioStreamPreference.fromMetadata(mContext, metadata);
moveToState(preference, state);
return preference;
}
private void updatePreferenceConnectionState(
AudioStreamPreference preference,
AudioStreamState state,
Preference.OnPreferenceClickListener onClickListener) {
private void moveToState(AudioStreamPreference preference, AudioStreamState state) {
if (preference.getAudioStreamState() == state) {
return;
}
preference.setAudioStreamState(state);
// Perform action according to the new state
if (state == AudioStreamState.ADD_SOURCE_WAIT_FOR_RESPONSE) {
if (mTimedSourceFromQrCode != null) {
mTimedSourceFromQrCode.consumed(preference.getAudioStreamBroadcastId());
}
var metadata = preference.getAudioStreamMetadata();
if (metadata != null) {
mAudioStreamsHelper.addSource(metadata);
// Cache the metadata that used for add source, if source is added successfully, we
// will save it persistently.
mAudioStreamsRepository.cacheMetadata(metadata);
}
} else if (state == AudioStreamState.SOURCE_ADDED) {
if (mTimedSourceFromQrCode != null) {
mTimedSourceFromQrCode.consumed(preference.getAudioStreamBroadcastId());
}
// Saved connected metadata for user to re-join this broadcast later.
var cached =
mAudioStreamsRepository.getCachedMetadata(
preference.getAudioStreamBroadcastId());
if (cached != null) {
mAudioStreamsRepository.saveMetadata(mContext, cached);
}
} else if (state == AudioStreamState.WAIT_FOR_SYNC) {
if (mTimedSourceFromQrCode != null) {
mTimedSourceFromQrCode.waitForConsume();
}
}
// Get preference click listener according to the new state
Preference.OnPreferenceClickListener listener;
if (state == AudioStreamState.SYNCED) {
listener = mAddSourceOrShowDialog;
} else if (state == AudioStreamState.SOURCE_ADDED) {
listener = mLaunchDetailFragment;
} else {
listener = null;
}
// Get preference summary according to the new state
String summary;
if (state == AudioStreamState.WAIT_FOR_SYNC) {
summary = "Scanning...";
} else if (state == AudioStreamState.ADD_SOURCE_WAIT_FOR_RESPONSE) {
summary = "Connecting...";
} else if (state == AudioStreamState.SOURCE_ADDED) {
summary = "Listening now";
} else {
summary = "";
}
// Update UI
ThreadUtils.postOnMainThread(
() -> {
preference.setIsConnected(
state == AudioStreamState.SOURCE_ADDED,
getPreferenceSummary(state),
onClickListener);
state == AudioStreamState.SOURCE_ADDED, summary, listener);
if (mCategoryPreference != null) {
mCategoryPreference.addPreference(preference);
mCategoryPreference.addAudioStreamPreference(preference, mComparator);
}
});
}
private boolean launchDetailFragment(int broadcastId) {
if (!mBroadcastIdToPreferenceMap.containsKey(broadcastId)) {
Log.w(
TAG,
"launchDetailFragment(): broadcastId not exist in BroadcastIdToPreferenceMap!");
return false;
}
AudioStreamPreference preference = mBroadcastIdToPreferenceMap.get(broadcastId);
Bundle broadcast = new Bundle();
broadcast.putString(
AudioStreamDetailsFragment.BROADCAST_NAME_ARG, (String) preference.getTitle());
broadcast.putInt(AudioStreamDetailsFragment.BROADCAST_ID_ARG, broadcastId);
new SubSettingLauncher(mContext)
.setTitleText("Audio stream details")
.setDestination(AudioStreamDetailsFragment.class.getName())
// TODO(chelseahao): Add logging enum
.setSourceMetricsCategory(SettingsEnums.PAGE_UNKNOWN)
.setArguments(broadcast)
.launch();
return true;
}
private void launchPasswordDialog(
BluetoothLeBroadcastMetadata source, AudioStreamPreference preference) {
View layout =
@@ -457,15 +538,16 @@ public class AudioStreamsProgressCategoryController extends BasePreferenceContro
R.id.broadcast_edit_text))
.getText()
.toString();
mAudioStreamsHelper.addSource(
var metadata =
new BluetoothLeBroadcastMetadata.Builder(source)
.setBroadcastCode(
code.getBytes(StandardCharsets.UTF_8))
.build());
preference.setAudioStreamState(
AudioStreamState.WAIT_FOR_SOURCE_ADD);
updatePreferenceConnectionState(
preference, AudioStreamState.WAIT_FOR_SOURCE_ADD, null);
.build();
// Update the metadata after user entered the password
preference.setAudioStreamMetadata(metadata);
moveToState(
preference,
AudioStreamState.ADD_SOURCE_WAIT_FOR_RESPONSE);
})
.create();
alertDialog.show();
@@ -474,16 +556,17 @@ public class AudioStreamsProgressCategoryController extends BasePreferenceContro
private AudioStreamsDialogFragment.DialogBuilder getNoLeDeviceDialog() {
return new AudioStreamsDialogFragment.DialogBuilder(mContext)
.setTitle("Connect compatible headphones")
.setSubTitle1(
.setSubTitle2(
"To listen to an audio stream, first connect headphones that support LE"
+ " Audio to this device. Learn more")
.setLeftButtonText("Close")
.setLeftButtonOnClickListener(AlertDialog::dismiss)
.setRightButtonText("Connect a device")
.setRightButtonOnClickListener(
unused ->
mContext.startActivity(
new Intent(Settings.ACTION_BLUETOOTH_SETTINGS)));
dialog -> {
mContext.startActivity(new Intent(Settings.ACTION_BLUETOOTH_SETTINGS));
dialog.dismiss();
});
}
private AudioStreamsDialogFragment.DialogBuilder getBroadcastUnavailableDialog(
@@ -495,8 +578,18 @@ public class AudioStreamsProgressCategoryController extends BasePreferenceContro
.setLeftButtonText("Close")
.setLeftButtonOnClickListener(AlertDialog::dismiss)
.setRightButtonText("Retry")
// TODO(chelseahao): Add retry action
.setRightButtonOnClickListener(AlertDialog::dismiss);
.setRightButtonOnClickListener(
dialog -> {
if (mFragment != null) {
Intent intent = new Intent(mContext, QrCodeScanModeActivity.class);
intent.setAction(
BluetoothBroadcastUtils
.ACTION_BLUETOOTH_LE_AUDIO_QR_CODE_SCANNER);
mFragment.startActivityForResult(
intent, REQUEST_SCAN_BT_BROADCAST_QR_CODE);
dialog.dismiss();
}
});
}
private class TimedSourceFromQrCode {
@@ -529,11 +622,18 @@ public class AudioStreamsProgressCategoryController extends BasePreferenceContro
mTimer.start();
}
private void consumed() {
private void cleanup() {
mTimer.cancel();
mSourceFromQrCode = null;
}
private void consumed(int broadcastId) {
if (mSourceFromQrCode == null || broadcastId != mSourceFromQrCode.getBroadcastId()) {
return;
}
cleanup();
}
private BluetoothLeBroadcastMetadata get() {
return mSourceFromQrCode;
}

View File

@@ -19,9 +19,15 @@ package com.android.settings.connecteddevice.audiosharing.audiostreams;
import android.content.Context;
import android.util.AttributeSet;
import androidx.annotation.NonNull;
import com.android.settings.ProgressCategory;
import com.android.settings.R;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
public class AudioStreamsProgressCategoryPreference extends ProgressCategory {
public AudioStreamsProgressCategoryPreference(Context context) {
@@ -46,6 +52,37 @@ public class AudioStreamsProgressCategoryPreference extends ProgressCategory {
init();
}
void addAudioStreamPreference(
@NonNull AudioStreamPreference preference,
Comparator<AudioStreamPreference> comparator) {
super.addPreference(preference);
List<AudioStreamPreference> preferences = getAllAudioStreamPreferences();
preferences.sort(comparator);
for (int i = 0; i < preferences.size(); i++) {
// setOrder to i + 1, since the order 0 preference should always be the
// "audio_streams_scan_qr_code"
preferences.get(i).setOrder(i + 1);
}
}
void removeAudioStreamPreferences() {
List<AudioStreamPreference> streams = getAllAudioStreamPreferences();
for (var toRemove : streams) {
removePreference(toRemove);
}
}
private List<AudioStreamPreference> getAllAudioStreamPreferences() {
List<AudioStreamPreference> streams = new ArrayList<>();
for (int i = 0; i < getPreferenceCount(); i++) {
if (getPreference(i) instanceof AudioStreamPreference) {
streams.add((AudioStreamPreference) getPreference(i));
}
}
return streams;
}
private void init() {
setEmptyTextRes(R.string.audio_streams_empty);
}

View File

@@ -24,6 +24,9 @@ import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.TextView;
import androidx.annotation.Nullable;
import com.android.settings.R;
import com.android.settings.bluetooth.Utils;
@@ -34,6 +37,7 @@ import com.android.settingslib.qrcode.QrCodeGenerator;
import com.google.zxing.WriterException;
import java.nio.charset.StandardCharsets;
import java.util.Optional;
public class AudioStreamsQrCodeFragment extends InstrumentedFragment {
@@ -49,30 +53,47 @@ public class AudioStreamsQrCodeFragment extends InstrumentedFragment {
public final View onCreateView(
LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
View view = inflater.inflate(R.xml.bluetooth_audio_streams_qr_code, container, false);
getQrCodeBitmap()
.ifPresent(
bm ->
BluetoothLeBroadcastMetadata broadcastMetadata = getBroadcastMetadata();
if (broadcastMetadata != null) {
getQrCodeBitmap(broadcastMetadata)
.ifPresent(
bm -> {
((ImageView) view.requireViewById(R.id.qrcode_view))
.setImageBitmap(bm));
.setImageBitmap(bm);
((TextView) view.requireViewById(R.id.password))
.setText(
"Password: "
+ new String(
broadcastMetadata
.getBroadcastCode(),
StandardCharsets.UTF_8));
});
}
return view;
}
private Optional<Bitmap> getQrCodeBitmap() {
String broadcastMetadata = getBroadcastMetadataQrCode();
if (broadcastMetadata.isEmpty()) {
private Optional<Bitmap> getQrCodeBitmap(@Nullable BluetoothLeBroadcastMetadata metadata) {
if (metadata == null) {
Log.d(TAG, "onCreateView: broadcastMetadata is empty!");
return Optional.empty();
}
String metadataStr = BluetoothLeBroadcastMetadataExt.INSTANCE.toQrCodeString(metadata);
if (metadataStr.isEmpty()) {
Log.d(TAG, "onCreateView: metadataStr is empty!");
return Optional.empty();
}
Log.d("chelsea", metadataStr);
try {
int qrcodeSize = getContext().getResources().getDimensionPixelSize(R.dimen.qrcode_size);
Bitmap bitmap = QrCodeGenerator.encodeQrCode(broadcastMetadata, qrcodeSize);
Bitmap bitmap = QrCodeGenerator.encodeQrCode(metadataStr, qrcodeSize);
return Optional.of(bitmap);
} catch (WriterException e) {
Log.d(
TAG,
"onCreateView: broadcastMetadata "
+ broadcastMetadata
+ metadata
+ " qrCode generation exception "
+ e);
}
@@ -80,23 +101,24 @@ public class AudioStreamsQrCodeFragment extends InstrumentedFragment {
return Optional.empty();
}
private String getBroadcastMetadataQrCode() {
@Nullable
private BluetoothLeBroadcastMetadata getBroadcastMetadata() {
LocalBluetoothLeBroadcast localBluetoothLeBroadcast =
Utils.getLocalBtManager(getActivity())
.getProfileManager()
.getLeAudioBroadcastProfile();
if (localBluetoothLeBroadcast == null) {
Log.d(TAG, "getBroadcastMetadataQrCode: localBluetoothLeBroadcast is null!");
return "";
return null;
}
BluetoothLeBroadcastMetadata metadata =
localBluetoothLeBroadcast.getLatestBluetoothLeBroadcastMetadata();
if (metadata == null) {
Log.d(TAG, "getBroadcastMetadataQrCode: metadata is null!");
return "";
return null;
}
return BluetoothLeBroadcastMetadataExt.INSTANCE.toQrCodeString(metadata);
return metadata;
}
}

View File

@@ -0,0 +1,160 @@
/*
* Copyright (C) 2024 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.audiostreams;
import android.bluetooth.BluetoothLeBroadcastMetadata;
import android.content.Context;
import android.content.SharedPreferences;
import android.util.Log;
import com.android.settingslib.bluetooth.BluetoothLeBroadcastMetadataExt;
import com.android.settingslib.bluetooth.BluetoothUtils;
import com.android.settingslib.utils.ThreadUtils;
import java.util.concurrent.ConcurrentHashMap;
import javax.annotation.Nullable;
/** Manages the caching and storage of Bluetooth audio stream metadata. */
public class AudioStreamsRepository {
private static final String TAG = "AudioStreamsRepository";
private static final boolean DEBUG = BluetoothUtils.D;
private static final String PREF_KEY = "bluetooth_audio_stream_pref";
private static final String METADATA_KEY = "bluetooth_audio_stream_metadata";
@Nullable
private static AudioStreamsRepository sInstance = null;
private AudioStreamsRepository() {}
/**
* Gets the single instance of AudioStreamsRepository.
*
* @return The AudioStreamsRepository instance.
*/
public static synchronized AudioStreamsRepository getInstance() {
if (sInstance == null) {
sInstance = new AudioStreamsRepository();
}
return sInstance;
}
private final ConcurrentHashMap<Integer, BluetoothLeBroadcastMetadata>
mBroadcastIdToMetadataCacheMap = new ConcurrentHashMap<>();
/**
* Caches BluetoothLeBroadcastMetadata in a local cache.
*
* @param metadata The BluetoothLeBroadcastMetadata to be cached.
*/
void cacheMetadata(BluetoothLeBroadcastMetadata metadata) {
if (DEBUG) {
Log.d(
TAG,
"cacheMetadata(): broadcastId "
+ metadata.getBroadcastId()
+ " saved in local cache.");
}
mBroadcastIdToMetadataCacheMap.put(metadata.getBroadcastId(), metadata);
}
/**
* Gets cached BluetoothLeBroadcastMetadata by broadcastId.
*
* @param broadcastId The broadcastId to look up in the cache.
* @return The cached BluetoothLeBroadcastMetadata or null if not found.
*/
@Nullable
BluetoothLeBroadcastMetadata getCachedMetadata(int broadcastId) {
var metadata = mBroadcastIdToMetadataCacheMap.get(broadcastId);
if (metadata == null) {
Log.w(
TAG,
"getCachedMetadata(): broadcastId not found in"
+ " mBroadcastIdToMetadataCacheMap.");
return null;
}
return metadata;
}
/**
* Saves metadata to SharedPreferences asynchronously.
*
* @param context The context.
* @param metadata The BluetoothLeBroadcastMetadata to be saved.
*/
void saveMetadata(Context context, BluetoothLeBroadcastMetadata metadata) {
var unused =
ThreadUtils.postOnBackgroundThread(
() -> {
SharedPreferences sharedPref =
context.getSharedPreferences(PREF_KEY, Context.MODE_PRIVATE);
if (sharedPref != null) {
SharedPreferences.Editor editor = sharedPref.edit();
editor.putString(
METADATA_KEY,
BluetoothLeBroadcastMetadataExt.INSTANCE.toQrCodeString(
metadata));
editor.apply();
if (DEBUG) {
Log.d(
TAG,
"saveMetadata(): broadcastId "
+ metadata.getBroadcastId()
+ " metadata saved in storage.");
}
}
});
}
/**
* Gets saved metadata from SharedPreferences.
*
* @param context The context.
* @param broadcastId The broadcastId to retrieve metadata for.
* @return The saved BluetoothLeBroadcastMetadata or null if not found.
*/
@Nullable
BluetoothLeBroadcastMetadata getSavedMetadata(Context context, int broadcastId) {
SharedPreferences sharedPref = context.getSharedPreferences(PREF_KEY, Context.MODE_PRIVATE);
if (sharedPref != null) {
String savedMetadataStr = sharedPref.getString(METADATA_KEY, null);
if (savedMetadataStr == null) {
Log.w(TAG, "getSavedMetadata(): savedMetadataStr is null");
return null;
}
var savedMetadata =
BluetoothLeBroadcastMetadataExt.INSTANCE.convertToBroadcastMetadata(
savedMetadataStr);
if (savedMetadata == null || savedMetadata.getBroadcastId() != broadcastId) {
Log.w(TAG, "getSavedMetadata(): savedMetadata doesn't match broadcast Id.");
return null;
}
if (DEBUG) {
Log.d(
TAG,
"getSavedMetadata(): broadcastId "
+ savedMetadata.getBroadcastId()
+ " metadata found in storage.");
}
return savedMetadata;
}
return null;
}
}

View File

@@ -16,7 +16,6 @@
package com.android.settings.connecteddevice.audiosharing.audiostreams;
import android.bluetooth.BluetoothLeBroadcastMetadata;
import android.bluetooth.BluetoothProfile;
import android.content.Context;
import android.content.Intent;
@@ -58,14 +57,12 @@ public class AudioStreamsScanQrCodeController extends BasePreferenceController
};
private final LocalBluetoothManager mLocalBtManager;
private final AudioStreamsHelper mAudioStreamsHelper;
private AudioStreamsDashboardFragment mFragment;
private Preference mPreference;
public AudioStreamsScanQrCodeController(Context context, String preferenceKey) {
super(context, preferenceKey);
mLocalBtManager = Utils.getLocalBtManager(mContext);
mAudioStreamsHelper = new AudioStreamsHelper(mLocalBtManager);
}
public void setFragment(AudioStreamsDashboardFragment fragment) {
@@ -124,10 +121,6 @@ public class AudioStreamsScanQrCodeController extends BasePreferenceController
});
}
void addSource(BluetoothLeBroadcastMetadata source) {
mAudioStreamsHelper.addSource(source);
}
private void updateVisibility() {
ThreadUtils.postOnBackgroundThread(
() -> {

View File

@@ -229,6 +229,7 @@ public class QrCodeScanModeFragment extends InstrumentedFragment
}
mErrorMessage.setVisibility(View.INVISIBLE);
mTextureView.setVisibility(View.INVISIBLE);
triggerVibrationForQrCodeRecognition(getContext());

View File

@@ -0,0 +1,139 @@
/*
* Copyright 2024 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.development;
import android.bluetooth.BluetoothAdapter;
import android.bluetooth.BluetoothManager;
import android.bluetooth.BluetoothStatusCodes;
import android.content.Context;
import android.os.SystemProperties;
import android.sysprop.BluetoothProperties;
import android.text.TextUtils;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import androidx.preference.ListPreference;
import androidx.preference.Preference;
import com.android.settings.R;
import com.android.settings.core.PreferenceControllerMixin;
import com.android.settingslib.development.DeveloperOptionsPreferenceController;
/**
* Preference controller to control Bluetooth LE audio mode
*/
public class BluetoothLeAudioModePreferenceController
extends DeveloperOptionsPreferenceController
implements Preference.OnPreferenceChangeListener, PreferenceControllerMixin {
private static final String PREFERENCE_KEY = "bluetooth_leaudio_mode";
static final String LE_AUDIO_DYNAMIC_SWITCHER_MODE_PROPERTY =
"persist.bluetooth.leaudio_dynamic_switcher.mode";
@Nullable private final DevelopmentSettingsDashboardFragment mFragment;
private final String[] mListValues;
private final String[] mListSummaries;
@VisibleForTesting
@Nullable String mNewMode;
@VisibleForTesting
BluetoothAdapter mBluetoothAdapter;
boolean mChanged = false;
public BluetoothLeAudioModePreferenceController(@NonNull Context context,
@Nullable DevelopmentSettingsDashboardFragment fragment) {
super(context);
mFragment = fragment;
mBluetoothAdapter = context.getSystemService(BluetoothManager.class).getAdapter();
mListValues = context.getResources().getStringArray(R.array.bluetooth_leaudio_mode_values);
mListSummaries = context.getResources().getStringArray(R.array.bluetooth_leaudio_mode);
}
@Override
@NonNull public String getPreferenceKey() {
return PREFERENCE_KEY;
}
@Override
public boolean isAvailable() {
return BluetoothProperties.isProfileBapBroadcastSourceEnabled().orElse(false);
}
@Override
public boolean onPreferenceChange(@NonNull Preference preference, Object newValue) {
if (mFragment == null) {
return false;
}
BluetoothRebootDialog.show(mFragment);
mChanged = true;
mNewMode = newValue.toString();
return false;
}
@Override
public void updateState(@NonNull Preference preference) {
if (mBluetoothAdapter == null) {
return;
}
if (mBluetoothAdapter.isLeAudioBroadcastSourceSupported()
== BluetoothStatusCodes.FEATURE_SUPPORTED) {
SystemProperties.set(LE_AUDIO_DYNAMIC_SWITCHER_MODE_PROPERTY, "broadcast");
} else if (mBluetoothAdapter.isLeAudioSupported()
== BluetoothStatusCodes.FEATURE_SUPPORTED) {
SystemProperties.set(LE_AUDIO_DYNAMIC_SWITCHER_MODE_PROPERTY, "unicast");
} else {
SystemProperties.set(LE_AUDIO_DYNAMIC_SWITCHER_MODE_PROPERTY, "disabled");
}
final String currentValue = SystemProperties.get(LE_AUDIO_DYNAMIC_SWITCHER_MODE_PROPERTY);
int index = 0;
for (int i = 0; i < mListValues.length; i++) {
if (TextUtils.equals(currentValue, mListValues[i])) {
index = i;
break;
}
}
final ListPreference listPreference = (ListPreference) preference;
listPreference.setValue(mListValues[index]);
listPreference.setSummary(mListSummaries[index]);
}
/**
* Called when the RebootDialog confirm is clicked.
*/
public void onRebootDialogConfirmed() {
if (!mChanged) {
return;
}
SystemProperties.set(LE_AUDIO_DYNAMIC_SWITCHER_MODE_PROPERTY, mNewMode);
}
/**
* Called when the RebootDialog cancel is clicked.
*/
public void onRebootDialogCanceled() {
mChanged = false;
}
}

View File

@@ -21,6 +21,7 @@ import android.bluetooth.BluetoothManager;
import android.bluetooth.BluetoothStatusCodes;
import android.content.Context;
import android.os.SystemProperties;
import android.sysprop.BluetoothProperties;
import androidx.annotation.VisibleForTesting;
import androidx.preference.Preference;
@@ -64,6 +65,12 @@ public class BluetoothLeAudioPreferenceController
return PREFERENCE_KEY;
}
@Override
public boolean isAvailable() {
return BluetoothProperties.isProfileBapUnicastClientEnabled().orElse(false)
&& !BluetoothProperties.isProfileBapBroadcastSourceEnabled().orElse(false);
}
@Override
public boolean onPreferenceChange(Preference preference, Object newValue) {
BluetoothRebootDialog.show(mFragment);

View File

@@ -454,6 +454,11 @@ public class DevelopmentSettingsDashboardFragment extends RestrictedDashboardFra
getDevelopmentOptionsController(
BluetoothLeAudioPreferenceController.class);
leAudioFeatureController.onRebootDialogConfirmed();
final BluetoothLeAudioModePreferenceController leAudioModeController =
getDevelopmentOptionsController(
BluetoothLeAudioModePreferenceController.class);
leAudioModeController.onRebootDialogConfirmed();
}
@Override
@@ -471,6 +476,11 @@ public class DevelopmentSettingsDashboardFragment extends RestrictedDashboardFra
getDevelopmentOptionsController(
BluetoothLeAudioPreferenceController.class);
leAudioFeatureController.onRebootDialogCanceled();
final BluetoothLeAudioModePreferenceController leAudioModeController =
getDevelopmentOptionsController(
BluetoothLeAudioModePreferenceController.class);
leAudioModeController.onRebootDialogCanceled();
}
@Override
@@ -670,6 +680,7 @@ public class DevelopmentSettingsDashboardFragment extends RestrictedDashboardFra
controllers.add(new BluetoothAvrcpVersionPreferenceController(context));
controllers.add(new BluetoothMapVersionPreferenceController(context));
controllers.add(new BluetoothLeAudioPreferenceController(context, fragment));
controllers.add(new BluetoothLeAudioModePreferenceController(context, fragment));
controllers.add(new BluetoothLeAudioDeviceDetailsPreferenceController(context));
controllers.add(new BluetoothLeAudioAllowListPreferenceController(context, fragment));
controllers.add(new BluetoothA2dpHwOffloadPreferenceController(context, fragment));

View File

@@ -41,6 +41,7 @@ import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import androidx.preference.PreferenceScreen;
import com.android.internal.accessibility.common.ShortcutConstants;
import com.android.settings.R;
import com.android.settings.accessibility.AccessibilityGestureNavigationTutorial;
import com.android.settings.core.SubSettingLauncher;
@@ -353,7 +354,7 @@ public class SystemNavigationGestureSettings extends RadioButtonPickerFragment i
private boolean isAnyServiceSupportAccessibilityButton() {
final AccessibilityManager ams = getContext().getSystemService(AccessibilityManager.class);
final List<String> targets = ams.getAccessibilityShortcutTargets(
AccessibilityManager.ACCESSIBILITY_BUTTON);
ShortcutConstants.UserShortcutType.SOFTWARE);
return !targets.isEmpty();
}

View File

@@ -26,8 +26,11 @@ import androidx.preference.Preference
import com.android.settings.R
import com.android.settings.SettingsPreferenceFragment
import com.android.settings.dashboard.DashboardFragment
import com.android.settings.flags.Flags
import com.android.settings.network.telephony.MobileNetworkUtils
import com.android.settings.search.BaseSearchIndexProvider
import com.android.settings.spa.SpaActivity.Companion.startSpaActivity
import com.android.settings.spa.network.NetworkCellularGroupProvider
import com.android.settingslib.search.SearchIndexable
import com.android.settingslib.spa.framework.util.collectLatestWithLifecycle
import com.android.settingslib.spaprivileged.framework.common.userManager
@@ -40,6 +43,15 @@ class MobileNetworkListFragment : DashboardFragment() {
collectAirplaneModeAndFinishIfOn()
}
override fun onCreate(icicle: Bundle?) {
super.onCreate(icicle)
if (Flags.isDualSimOnboardingEnabled()) {
context?.startSpaActivity(NetworkCellularGroupProvider.name);
finish()
}
}
override fun onResume() {
super.onResume()
// Disable the animation of the preference list

View File

@@ -32,7 +32,19 @@ class SubscriptionInfoListViewModel(application: Application) : AndroidViewModel
application.getSystemService(SubscriptionManager::class.java)!!
private val scope = viewModelScope + Dispatchers.Default
/**
* Getting the active Subscription list
*/
//ToDo: renaming the function name
val subscriptionInfoListFlow = application.subscriptionsChangedFlow().map {
SubscriptionUtil.getActiveSubscriptions(subscriptionManager)
}.stateIn(scope, SharingStarted.Eagerly, initialValue = emptyList())
/**
* Getting the Selectable SubscriptionInfo List from the SubscriptionManager's
* getAvailableSubscriptionInfoList
*/
val selectableSubscriptionInfoListFlow = application.subscriptionsChangedFlow().map {
SubscriptionUtil.getSelectableSubscriptionInfoList(application)
}.stateIn(scope, SharingStarted.Eagerly, initialValue = emptyList())
}

View File

@@ -48,6 +48,7 @@ import com.android.settings.spa.development.UsageStatsPageProvider
import com.android.settings.spa.development.compat.PlatformCompatAppListPageProvider
import com.android.settings.spa.home.HomePageProvider
import com.android.settings.spa.network.NetworkAndInternetPageProvider
import com.android.settings.spa.network.NetworkCellularGroupProvider
import com.android.settings.spa.network.SimOnboardingPageProvider
import com.android.settings.spa.notification.AppListNotificationsPageProvider
import com.android.settings.spa.notification.NotificationMainPageProvider
@@ -118,6 +119,7 @@ open class SettingsSpaEnvironment(context: Context) : SpaEnvironment(context) {
ApnEditPageProvider,
SimOnboardingPageProvider,
BatteryOptimizationModeAppListPageProvider,
NetworkCellularGroupProvider,
)
override val logger = if (FeatureFlagUtils.isEnabled(

View File

@@ -20,24 +20,12 @@ import android.Manifest
import android.app.AppOpsManager
import android.app.settings.SettingsEnums
import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import com.android.settings.R
import com.android.settings.overlay.FeatureFactory
import com.android.settingslib.spa.widget.preference.SwitchPreference
import com.android.settingslib.spa.widget.preference.SwitchPreferenceModel
import com.android.settingslib.spaprivileged.model.app.AppOpsController
import com.android.settingslib.spaprivileged.model.app.PackageManagers.hasGrantPermission
import com.android.settingslib.spaprivileged.template.app.AppOpPermissionListModel
import com.android.settingslib.spaprivileged.template.app.AppOpPermissionRecord
import com.android.settingslib.spaprivileged.template.app.TogglePermissionAppListProvider
import kotlinx.coroutines.Dispatchers
/**
* This class builds an App List under voice activation apps and the individual page which
@@ -56,97 +44,15 @@ class VoiceActivationAppsListModel(context: Context) : AppOpPermissionListModel(
override val appOp = AppOpsManager.OP_RECEIVE_SANDBOX_TRIGGER_AUDIO
override val permission = Manifest.permission.RECEIVE_SANDBOX_TRIGGER_AUDIO
override val setModeByUid = true
private var receiveDetectionTrainingDataOpController:AppOpsController? = null
override fun setAllowed(record: AppOpPermissionRecord, newAllowed: Boolean) {
super.setAllowed(record, newAllowed)
if (!newAllowed && receiveDetectionTrainingDataOpController != null) {
receiveDetectionTrainingDataOpController!!.setAllowed(false)
}
logPermissionChange(newAllowed)
}
override fun isChangeable(record: AppOpPermissionRecord): Boolean =
super.isChangeable(record) && record.app.hasGrantPermission(permission)
@Composable
override fun InfoPageAdditionalContent(
record: AppOpPermissionRecord,
isAllowed: () -> Boolean?,
) {
SwitchPreference(createReceiveDetectionTrainingDataOpSwitchModel(record, isAllowed))
}
@Composable
private fun createReceiveDetectionTrainingDataOpSwitchModel(
record: AppOpPermissionRecord,
isReceiveSandBoxTriggerAudioOpAllowed: () -> Boolean?
): ReceiveDetectionTrainingDataOpSwitchModel {
val context = LocalContext.current
receiveDetectionTrainingDataOpController = remember {
AppOpsController(
context = context,
app = record.app,
op = AppOpsManager.OP_RECEIVE_SANDBOXED_DETECTION_TRAINING_DATA,
)
}
val isReceiveDetectionTrainingDataOpAllowed = isReceiveDetectionTrainingDataOpAllowed(record, receiveDetectionTrainingDataOpController!!)
return remember(record) {
ReceiveDetectionTrainingDataOpSwitchModel(
context,
record,
isReceiveSandBoxTriggerAudioOpAllowed,
receiveDetectionTrainingDataOpController!!,
isReceiveDetectionTrainingDataOpAllowed,
)
}.also { model -> LaunchedEffect(model, Dispatchers.Default) { model.initState() } }
}
private inner class ReceiveDetectionTrainingDataOpSwitchModel(
context: Context,
private val record: AppOpPermissionRecord,
isReceiveSandBoxTriggerAudioOpAllowed: () -> Boolean?,
receiveDetectionTrainingDataOpController: AppOpsController,
isReceiveDetectionTrainingDataOpAllowed: () -> Boolean?,
) : SwitchPreferenceModel {
private var appChangeable by mutableStateOf(true)
override val title: String = context.getString(R.string.permit_receive_sandboxed_detection_training_data)
override val summary: () -> String = { context.getString(R.string.receive_sandboxed_detection_training_data_description) }
override val checked = { isReceiveDetectionTrainingDataOpAllowed() == true && isReceiveSandBoxTriggerAudioOpAllowed() == true }
override val changeable = { appChangeable && isReceiveSandBoxTriggerAudioOpAllowed() == true }
fun initState() {
appChangeable = isChangeable(record)
}
override val onCheckedChange: (Boolean) -> Unit = { newChecked ->
receiveDetectionTrainingDataOpController.setAllowed(newChecked)
}
}
@Composable
private fun isReceiveDetectionTrainingDataOpAllowed(
record: AppOpPermissionRecord,
controller: AppOpsController
): () -> Boolean? {
if (record.hasRequestBroaderPermission) {
// Broader permission trumps the specific permission.
return { true }
}
val mode = controller.mode.observeAsState()
return {
when (mode.value) {
null -> null
AppOpsManager.MODE_ALLOWED -> true
AppOpsManager.MODE_DEFAULT -> record.app.hasGrantPermission(
Manifest.permission.RECEIVE_SANDBOXED_DETECTION_TRAINING_DATA)
else -> false
}
}
}
private fun logPermissionChange(newAllowed: Boolean) {
val category = when {
newAllowed -> SettingsEnums.APP_SPECIAL_PERMISSION_RECEIVE_SANDBOX_TRIGGER_AUDIO_ALLOW

View File

@@ -0,0 +1,465 @@
/*
* Copyright (C) 2024 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.spa.network
import android.app.Application
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.Bundle
import android.os.UserManager
import android.telephony.SubscriptionInfo
import android.telephony.SubscriptionManager
import android.telephony.TelephonyManager
import android.telephony.euicc.EuiccManager
import android.util.Log
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.Message
import androidx.compose.material.icons.outlined.Add
import androidx.compose.material.icons.outlined.DataUsage
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableIntState
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.toMutableStateList
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import com.android.settings.R
import com.android.settings.network.SubscriptionInfoListViewModel
import com.android.settings.network.SubscriptionUtil
import com.android.settings.network.telephony.MobileNetworkUtils
import com.android.settings.wifi.WifiPickerTrackerHelper
import com.android.settingslib.spa.framework.common.SettingsEntryBuilder
import com.android.settingslib.spa.framework.common.SettingsPageProvider
import com.android.settingslib.spa.framework.common.createSettingsPage
import com.android.settingslib.spa.framework.compose.navigator
import com.android.settingslib.spa.framework.util.collectLatestWithLifecycle
import com.android.settingslib.spa.widget.preference.ListPreferenceOption
import com.android.settingslib.spa.widget.preference.Preference
import com.android.settingslib.spa.widget.preference.PreferenceModel
import com.android.settingslib.spa.widget.preference.SwitchPreference
import com.android.settingslib.spa.widget.preference.SwitchPreferenceModel
import com.android.settingslib.spa.widget.preference.TwoTargetSwitchPreference
import com.android.settingslib.spa.widget.scaffold.RegularScaffold
import com.android.settingslib.spa.widget.ui.Category
import com.android.settingslib.spa.widget.ui.SettingsIcon
import com.android.settingslib.spaprivileged.framework.common.broadcastReceiverFlow
import com.android.settingslib.spaprivileged.model.enterprise.Restrictions
import com.android.settingslib.spaprivileged.template.preference.RestrictedPreference
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* Showing the sim onboarding which is the process flow of sim switching on.
*/
object NetworkCellularGroupProvider : SettingsPageProvider {
override val name = "NetworkCellularGroupProvider"
private lateinit var subscriptionViewModel: SubscriptionInfoListViewModel
private val owner = createSettingsPage()
var selectableSubscriptionInfoList: List<SubscriptionInfo> = listOf()
var defaultVoiceSubId: Int = SubscriptionManager.INVALID_SUBSCRIPTION_ID
var defaultSmsSubId: Int = SubscriptionManager.INVALID_SUBSCRIPTION_ID
var defaultDataSubId: Int = SubscriptionManager.INVALID_SUBSCRIPTION_ID
var nonDds: Int = SubscriptionManager.INVALID_SUBSCRIPTION_ID
fun buildInjectEntry() = SettingsEntryBuilder.createInject(owner = owner)
.setUiLayoutFn {
// never using
Preference(object : PreferenceModel {
override val title = name
override val onClick = navigator(name)
})
}
@Composable
override fun Page(arguments: Bundle?) {
val context = LocalContext.current
var selectableSubscriptionInfoListRemember = remember {
mutableListOf<SubscriptionInfo>().toMutableStateList()
}
var callsSelectedId = rememberSaveable {
mutableIntStateOf(SubscriptionManager.INVALID_SUBSCRIPTION_ID)
}
var textsSelectedId = rememberSaveable {
mutableIntStateOf(SubscriptionManager.INVALID_SUBSCRIPTION_ID)
}
var mobileDataSelectedId = rememberSaveable {
mutableIntStateOf(SubscriptionManager.INVALID_SUBSCRIPTION_ID)
}
var nonDdsRemember = rememberSaveable {
mutableIntStateOf(SubscriptionManager.INVALID_SUBSCRIPTION_ID)
}
subscriptionViewModel = SubscriptionInfoListViewModel(
context.applicationContext as Application)
allOfFlows(context, subscriptionViewModel.selectableSubscriptionInfoListFlow)
.collectLatestWithLifecycle(LocalLifecycleOwner.current) {
selectableSubscriptionInfoListRemember.clear()
selectableSubscriptionInfoListRemember.addAll(selectableSubscriptionInfoList)
callsSelectedId.intValue = defaultVoiceSubId
textsSelectedId.intValue = defaultSmsSubId
mobileDataSelectedId.intValue = defaultDataSubId
nonDdsRemember.intValue = nonDds
}
PageImpl(selectableSubscriptionInfoListRemember,
callsSelectedId,
textsSelectedId,
mobileDataSelectedId,
nonDdsRemember)
}
private fun allOfFlows(context: Context,
selectableSubscriptionInfoListFlow: Flow<List<SubscriptionInfo>>) =
combine(
selectableSubscriptionInfoListFlow,
context.defaultVoiceSubscriptionFlow(),
context.defaultSmsSubscriptionFlow(),
context.defaultDefaultDataSubscriptionFlow(),
NetworkCellularGroupProvider::refreshUiStates,
).flowOn(Dispatchers.Default)
fun refreshUiStates(
inputSelectableSubscriptionInfoList: List<SubscriptionInfo>,
inputDefaultVoiceSubId: Int,
inputDefaultSmsSubId: Int,
inputDefaultDateSubId: Int
): Unit {
selectableSubscriptionInfoList = inputSelectableSubscriptionInfoList
defaultVoiceSubId = inputDefaultVoiceSubId
defaultSmsSubId = inputDefaultSmsSubId
defaultDataSubId = inputDefaultDateSubId
nonDds = if (defaultDataSubId == SubscriptionManager.INVALID_SUBSCRIPTION_ID) {
SubscriptionManager.INVALID_SUBSCRIPTION_ID
} else {
selectableSubscriptionInfoList
.filter { info ->
(info.simSlotIndex != -1) && (info.subscriptionId != defaultDataSubId)
}
.map { it.subscriptionId }
.firstOrNull() ?: SubscriptionManager.INVALID_SUBSCRIPTION_ID
}
}
}
@Composable
fun PageImpl(selectableSubscriptionInfoList: List<SubscriptionInfo>,
defaultVoiceSubId: MutableIntState,
defaultSmsSubId: MutableIntState,
defaultDataSubId: MutableIntState,
nonDds: MutableIntState) {
val context = LocalContext.current
var activeSubscriptionInfoList: List<SubscriptionInfo> =
selectableSubscriptionInfoList.filter { subscriptionInfo ->
subscriptionInfo.simSlotIndex != -1
}
var subscriptionManager = context.getSystemService(SubscriptionManager::class.java)
val stringSims = stringResource(R.string.provider_network_settings_title)
RegularScaffold(title = stringSims) {
SimsSectionImpl(
context,
subscriptionManager,
selectableSubscriptionInfoList
)
PrimarySimSectionImpl(
subscriptionManager,
activeSubscriptionInfoList,
defaultVoiceSubId,
defaultSmsSubId,
defaultDataSubId,
nonDds
)
}
}
@Composable
fun SimsSectionImpl(
context: Context,
subscriptionManager: SubscriptionManager?,
subscriptionInfoList: List<SubscriptionInfo>
) {
val coroutineScope = rememberCoroutineScope()
for (subInfo in subscriptionInfoList) {
val checked = rememberSaveable() {
mutableStateOf(false)
}
//TODO: Add the Restricted TwoTargetSwitchPreference in SPA
TwoTargetSwitchPreference(remember {
object : SwitchPreferenceModel {
override val title = subInfo.displayName.toString()
override val summary = { subInfo.number }
override val checked = {
coroutineScope.launch {
withContext(Dispatchers.Default) {
checked.value = subscriptionManager?.isSubscriptionEnabled(
subInfo.subscriptionId)?:false
}
}
checked.value
}
override val onCheckedChange = { newChecked: Boolean ->
startToggleSubscriptionDialog(context, subInfo, newChecked)
}
}
}) {
startMobileNetworkSettings(context, subInfo)
}
}
// + add sim
if (showEuiccSettings(context)) {
RestrictedPreference(
model = object : PreferenceModel {
override val title = stringResource(id = R.string.mobile_network_list_add_more)
override val icon = @Composable { SettingsIcon(Icons.Outlined.Add) }
override val onClick = {
startAddSimFlow(context)
}
},
restrictions = Restrictions(keys =
listOf(UserManager.DISALLOW_CONFIG_MOBILE_NETWORKS)),
)
}
}
@Composable
fun PrimarySimSectionImpl(
subscriptionManager: SubscriptionManager?,
activeSubscriptionInfoList: List<SubscriptionInfo>,
callsSelectedId: MutableIntState,
textsSelectedId: MutableIntState,
mobileDataSelectedId: MutableIntState,
nonDds: MutableIntState
) {
var state = rememberSaveable { mutableStateOf(false) }
var callsAndSmsList = remember {
mutableListOf(ListPreferenceOption(id = -1, text = "Loading"))
}
var dataList = remember {
mutableListOf(ListPreferenceOption(id = -1, text = "Loading"))
}
if (activeSubscriptionInfoList.size >= 2) {
state.value = true
callsAndSmsList.clear()
dataList.clear()
for (info in activeSubscriptionInfoList) {
var item = ListPreferenceOption(
id = info.subscriptionId,
text = "${info.displayName}"
)
callsAndSmsList.add(item)
dataList.add(item)
}
callsAndSmsList.add(ListPreferenceOption(
id = SubscriptionManager.INVALID_SUBSCRIPTION_ID,
text = stringResource(id = R.string.sim_calls_ask_first_prefs_title)
))
} else {
// hide the primary sim
state.value = false
Log.d("NetworkCellularGroupProvider", "Hide primary sim")
}
if (state.value) {
val coroutineScope = rememberCoroutineScope()
var context = LocalContext.current
val telephonyManagerForNonDds: TelephonyManager? =
context.getSystemService(TelephonyManager::class.java)
?.createForSubscriptionId(nonDds.intValue)
val automaticDataChecked = rememberSaveable() {
mutableStateOf(false)
}
Category(title = stringResource(id = R.string.primary_sim_title)) {
createPrimarySimListPreference(
stringResource(id = R.string.primary_sim_calls_title),
callsAndSmsList,
callsSelectedId,
ImageVector.vectorResource(R.drawable.ic_phone),
) {
callsSelectedId.intValue = it
coroutineScope.launch {
setDefaultVoice(subscriptionManager, it)
}
}
createPrimarySimListPreference(
stringResource(id = R.string.primary_sim_texts_title),
callsAndSmsList,
textsSelectedId,
Icons.AutoMirrored.Outlined.Message,
) {
textsSelectedId.intValue = it
coroutineScope.launch {
setDefaultSms(subscriptionManager, it)
}
}
createPrimarySimListPreference(
stringResource(id = R.string.mobile_data_settings_title),
dataList,
mobileDataSelectedId,
Icons.Outlined.DataUsage,
) {
mobileDataSelectedId.intValue = it
coroutineScope.launch {
// TODO: to fix the WifiPickerTracker crash when create
// the wifiPickerTrackerHelper
setDefaultData(context,
subscriptionManager,
null/*wifiPickerTrackerHelper*/,
it)
}
}
}
val autoDataTitle = stringResource(id = R.string.primary_sim_automatic_data_title)
val autoDataSummary = stringResource(id = R.string.primary_sim_automatic_data_msg)
SwitchPreference(remember {
object : SwitchPreferenceModel {
override val title = autoDataTitle
override val summary = { autoDataSummary }
override val changeable: () -> Boolean = {
nonDds.intValue != SubscriptionManager.INVALID_SUBSCRIPTION_ID
}
override val checked = {
coroutineScope.launch {
withContext(Dispatchers.Default) {
automaticDataChecked.value = telephonyManagerForNonDds != null
&& telephonyManagerForNonDds.isMobileDataPolicyEnabled(
TelephonyManager.MOBILE_DATA_POLICY_AUTO_DATA_SWITCH)
}
}
automaticDataChecked.value
}
override val onCheckedChange: ((Boolean) -> Unit)? =
{ newChecked: Boolean ->
coroutineScope.launch {
setAutomaticData(telephonyManagerForNonDds, newChecked)
}
}
}
})
}
}
private fun Context.defaultVoiceSubscriptionFlow(): Flow<Int> =
merge(
flowOf(null), // kick an initial value
broadcastReceiverFlow(
IntentFilter(TelephonyManager.ACTION_DEFAULT_VOICE_SUBSCRIPTION_CHANGED)
),
).map { SubscriptionManager.getDefaultVoiceSubscriptionId() }
.conflate().flowOn(Dispatchers.Default)
private fun Context.defaultSmsSubscriptionFlow(): Flow<Int> =
merge(
flowOf(null), // kick an initial value
broadcastReceiverFlow(
IntentFilter(SubscriptionManager.ACTION_DEFAULT_SMS_SUBSCRIPTION_CHANGED)
),
).map { SubscriptionManager.getDefaultSmsSubscriptionId() }
.conflate().flowOn(Dispatchers.Default)
private fun Context.defaultDefaultDataSubscriptionFlow(): Flow<Int> =
merge(
flowOf(null), // kick an initial value
broadcastReceiverFlow(
IntentFilter(TelephonyManager.ACTION_DEFAULT_DATA_SUBSCRIPTION_CHANGED)
),
).map { SubscriptionManager.getDefaultDataSubscriptionId() }
.conflate().flowOn(Dispatchers.Default)
private fun startToggleSubscriptionDialog(
context: Context,
subInfo: SubscriptionInfo,
newStatus: Boolean
) {
SubscriptionUtil.startToggleSubscriptionDialogActivity(
context,
subInfo.subscriptionId,
newStatus
)
}
private fun startMobileNetworkSettings(context: Context, subInfo: SubscriptionInfo) {
MobileNetworkUtils.launchMobileNetworkSettings(context, subInfo)
}
private fun startAddSimFlow(context: Context) {
val intent = Intent(EuiccManager.ACTION_PROVISION_EMBEDDED_SUBSCRIPTION)
intent.putExtra(EuiccManager.EXTRA_FORCE_PROVISION, true)
context.startActivity(intent)
}
private fun showEuiccSettings(context: Context): Boolean {
return MobileNetworkUtils.showEuiccSettings(context)
}
private suspend fun setDefaultVoice(
subscriptionManager: SubscriptionManager?,
subId: Int): Unit = withContext(Dispatchers.Default) {
subscriptionManager?.setDefaultVoiceSubscriptionId(subId)
}
private suspend fun setDefaultSms(
subscriptionManager: SubscriptionManager?,
subId: Int): Unit = withContext(Dispatchers.Default) {
subscriptionManager?.setDefaultSmsSubId(subId)
}
private suspend fun setDefaultData(context: Context,
subscriptionManager: SubscriptionManager?,
wifiPickerTrackerHelper: WifiPickerTrackerHelper?,
subId: Int): Unit = withContext(Dispatchers.Default) {
subscriptionManager?.setDefaultDataSubId(subId)
MobileNetworkUtils.setMobileDataEnabled(
context,
subId,
true /* enabled */,
true /* disableOtherSubscriptions */)
if (wifiPickerTrackerHelper != null
&& !wifiPickerTrackerHelper.isCarrierNetworkProvisionEnabled(subId)) {
wifiPickerTrackerHelper.setCarrierNetworkEnabled(true)
}
}
private suspend fun setAutomaticData(telephonyManager: TelephonyManager?, newState: Boolean): Unit =
withContext(Dispatchers.Default) {
telephonyManager?.setMobileDataPolicyEnabled(
TelephonyManager.MOBILE_DATA_POLICY_AUTO_DATA_SWITCH,
newState)
//TODO: setup backup calling
}

View File

@@ -108,21 +108,22 @@ private fun primarySimBody(onboardingService: SimOnboardingService) {
list,
callsSelectedId,
ImageVector.vectorResource(R.drawable.ic_phone),
true
onIdSelected = { callsSelectedId.intValue = it }
)
createPrimarySimListPreference(
stringResource(id = R.string.primary_sim_texts_title),
list,
textsSelectedId,
Icons.AutoMirrored.Outlined.Message,
true
onIdSelected = { textsSelectedId.intValue = it }
)
createPrimarySimListPreference(
stringResource(id = R.string.mobile_data_settings_title),
list,
mobileDataSelectedId,
stringResource(id = R.string.mobile_data_settings_title),
list,
mobileDataSelectedId,
Icons.Outlined.DataUsage,
true
onIdSelected = { mobileDataSelectedId.intValue = it }
)
val autoDataTitle = stringResource(id = R.string.primary_sim_automatic_data_title)
@@ -140,17 +141,18 @@ private fun primarySimBody(onboardingService: SimOnboardingService) {
@Composable
fun createPrimarySimListPreference(
title: String,
list: List<ListPreferenceOption>,
selectedId: MutableIntState,
icon: ImageVector,
enable: Boolean
title: String,
list: List<ListPreferenceOption>,
selectedId: MutableIntState,
icon: ImageVector,
enable: Boolean = true,
onIdSelected: (id: Int) -> Unit
) = ListPreference(remember {
object : ListPreferenceModel {
override val title = title
override val options = list
override val selectedId = selectedId
override val onIdSelected: (id: Int) -> Unit = { selectedId.intValue = it }
override val onIdSelected = onIdSelected
override val icon = @Composable {
SettingsIcon(icon)
}

View File

@@ -17,6 +17,7 @@
package com.android.settings.wifi;
import static android.Manifest.permission.ACCESS_FINE_LOCATION;
import static android.os.UserManager.DISALLOW_ADD_WIFI_CONFIG;
import static android.os.UserManager.DISALLOW_CONFIG_WIFI;
import android.app.KeyguardManager;
@@ -122,7 +123,7 @@ public class WifiDialogActivity extends ObservableActivity implements WifiDialog
}
super.onCreate(savedInstanceState);
if (!isConfigWifiAllowed()) {
if (!isConfigWifiAllowed() || !isAddWifiConfigAllowed()) {
finish();
return;
}
@@ -393,6 +394,16 @@ public class WifiDialogActivity extends ObservableActivity implements WifiDialog
return isConfigWifiAllowed;
}
@VisibleForTesting
boolean isAddWifiConfigAllowed() {
UserManager userManager = getSystemService(UserManager.class);
if (userManager != null && userManager.hasUserRestriction(DISALLOW_ADD_WIFI_CONFIG)) {
Log.e(TAG, "The user is not allowed to add Wi-Fi configuration.");
return false;
}
return true;
}
private boolean hasWifiManager() {
if (mWifiManager != null) return true;
mWifiManager = getSystemService(WifiManager.class);

View File

@@ -16,27 +16,58 @@
package com.android.settings.dashboard.profileselector;
import static android.os.UserManager.USER_TYPE_FULL_SYSTEM;
import static android.os.UserManager.USER_TYPE_PROFILE_MANAGED;
import static android.os.UserManager.USER_TYPE_PROFILE_PRIVATE;
import static com.android.settings.dashboard.profileselector.ProfileSelectFragment.EXTRA_PROFILE;
import static com.google.common.truth.Truth.assertThat;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.when;
import android.content.pm.UserInfo;
import androidx.test.core.app.ApplicationProvider;
import com.android.settings.testutils.shadow.ShadowUserManager;
import org.junit.Before;
import org.junit.Ignore;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.MockitoAnnotations;
import org.mockito.junit.MockitoJUnit;
import org.mockito.junit.MockitoRule;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.annotation.Config;
@Ignore("b/313569889")
@Config(shadows = {
ShadowUserManager.class,
})
@RunWith(RobolectricTestRunner.class)
public class ProfileSelectLocationFragmentTest {
private static final String PERSONAL_PROFILE_NAME = "personal";
private static final String WORK_PROFILE_NAME = "work";
private static final String PRIVATE_PROFILE_NAME = "private";
@Rule
public final MockitoRule rule = MockitoJUnit.rule();
private ShadowUserManager mUserManager;
private ProfileSelectLocationFragment mProfileSelectLocationFragment;
@Before
public void setUp() {
MockitoAnnotations.initMocks(this);
mProfileSelectLocationFragment = new ProfileSelectLocationFragment();
mUserManager = ShadowUserManager.getShadow();
mUserManager.addProfile(
new UserInfo(0, PERSONAL_PROFILE_NAME, null, 0, USER_TYPE_FULL_SYSTEM));
mUserManager.addProfile(
new UserInfo(1, WORK_PROFILE_NAME, null, 0, USER_TYPE_PROFILE_MANAGED));
mUserManager.addProfile(
new UserInfo(11, PRIVATE_PROFILE_NAME, null, 0, USER_TYPE_PROFILE_PRIVATE));
mProfileSelectLocationFragment = spy(new ProfileSelectLocationFragment());
when(mProfileSelectLocationFragment.getContext()).thenReturn(
ApplicationProvider.getApplicationContext());
}
@Test
@@ -46,7 +77,7 @@ public class ProfileSelectLocationFragmentTest {
EXTRA_PROFILE, -1)).isEqualTo(ProfileSelectFragment.ProfileType.PERSONAL);
assertThat(mProfileSelectLocationFragment.getFragments()[1].getArguments().getInt(
EXTRA_PROFILE, -1)).isEqualTo(ProfileSelectFragment.ProfileType.WORK);
assertThat(mProfileSelectLocationFragment.getFragments()[1].getArguments().getInt(
assertThat(mProfileSelectLocationFragment.getFragments()[2].getArguments().getInt(
EXTRA_PROFILE, -1)).isEqualTo(ProfileSelectFragment.ProfileType.PRIVATE);
}
}

View File

@@ -0,0 +1,108 @@
/*
* Copyright (C) 2022 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.development;
import static com.android.settings.development.BluetoothLeAudioModePreferenceController
.LE_AUDIO_DYNAMIC_SWITCHER_MODE_PROPERTY;
import static com.google.common.truth.Truth.assertThat;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.when;
import android.bluetooth.BluetoothAdapter;
import android.content.Context;
import android.os.SystemProperties;
import androidx.preference.ListPreference;
import androidx.preference.PreferenceScreen;
import com.android.settings.R;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.RuntimeEnvironment;
@RunWith(RobolectricTestRunner.class)
public class BluetoothLeAudioModePreferenceControllerTest {
@Mock
private PreferenceScreen mPreferenceScreen;
@Mock
private DevelopmentSettingsDashboardFragment mFragment;
@Mock
private BluetoothAdapter mBluetoothAdapter;
@Mock
private ListPreference mPreference;
private Context mContext;
private BluetoothLeAudioModePreferenceController mController;
private String[] mListValues;
private String[] mListSummaries;
@Before
public void setup() {
MockitoAnnotations.initMocks(this);
mContext = RuntimeEnvironment.application;
mListValues = mContext.getResources().getStringArray(
R.array.bluetooth_leaudio_mode_values);
mListSummaries = mContext.getResources().getStringArray(
R.array.bluetooth_leaudio_mode);
mController = spy(new BluetoothLeAudioModePreferenceController(mContext, mFragment));
when(mPreferenceScreen.findPreference(mController.getPreferenceKey()))
.thenReturn(mPreference);
mController.mBluetoothAdapter = mBluetoothAdapter;
mController.displayPreference(mPreferenceScreen);
}
@Test
public void onRebootDialogConfirmed_changeLeAudioMode_shouldSetLeAudioMode() {
mController.mChanged = true;
SystemProperties.set(LE_AUDIO_DYNAMIC_SWITCHER_MODE_PROPERTY, mListValues[0]);
mController.mNewMode = mListValues[1];
mController.onRebootDialogConfirmed();
assertThat(SystemProperties.get(LE_AUDIO_DYNAMIC_SWITCHER_MODE_PROPERTY, mListValues[0])
.equals(mController.mNewMode)).isTrue();
}
@Test
public void onRebootDialogConfirmed_notChangeLeAudioMode_shouldNotSetLeAudioMode() {
mController.mChanged = false;
SystemProperties.set(LE_AUDIO_DYNAMIC_SWITCHER_MODE_PROPERTY, mListValues[0]);
mController.mNewMode = mListValues[1];
mController.onRebootDialogConfirmed();
assertThat(SystemProperties.get(LE_AUDIO_DYNAMIC_SWITCHER_MODE_PROPERTY, mListValues[0])
.equals(mController.mNewMode)).isFalse();
}
@Test
public void onRebootDialogCanceled_shouldNotSetLeAudioMode() {
mController.mChanged = true;
SystemProperties.set(LE_AUDIO_DYNAMIC_SWITCHER_MODE_PROPERTY, mListValues[0]);
mController.mNewMode = mListValues[1];
mController.onRebootDialogCanceled();
assertThat(SystemProperties.get(LE_AUDIO_DYNAMIC_SWITCHER_MODE_PROPERTY, mListValues[0])
.equals(mController.mNewMode)).isFalse();
}
}

View File

@@ -18,6 +18,7 @@ package com.android.settings.wifi;
import static android.Manifest.permission.ACCESS_COARSE_LOCATION;
import static android.Manifest.permission.ACCESS_FINE_LOCATION;
import static android.os.UserManager.DISALLOW_ADD_WIFI_CONFIG;
import static android.os.UserManager.DISALLOW_CONFIG_WIFI;
import static com.android.settings.wifi.WifiDialogActivity.REQUEST_CODE_WIFI_DPP_ENROLLEE_QR_CODE_SCANNER;
@@ -50,7 +51,6 @@ import com.android.wifitrackerlib.WifiEntry;
import com.google.android.setupcompat.util.WizardManagerHelper;
import org.junit.Before;
import org.junit.Ignore;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Mock;
@@ -58,7 +58,6 @@ import org.mockito.MockitoAnnotations;
import org.robolectric.Robolectric;
import org.robolectric.RobolectricTestRunner;
@Ignore("b/314867581")
@RunWith(RobolectricTestRunner.class)
public class WifiDialogActivityTest {
@@ -242,6 +241,20 @@ public class WifiDialogActivityTest {
assertThat(mActivity.isConfigWifiAllowed()).isFalse();
}
@Test
public void isAddWifiConfigAllowed_hasNoUserRestriction_returnTrue() {
when(mUserManager.hasUserRestriction(DISALLOW_ADD_WIFI_CONFIG)).thenReturn(false);
assertThat(mActivity.isAddWifiConfigAllowed()).isTrue();
}
@Test
public void isAddWifiConfigAllowed_hasUserRestriction_returnFalse() {
when(mUserManager.hasUserRestriction(DISALLOW_ADD_WIFI_CONFIG)).thenReturn(true);
assertThat(mActivity.isAddWifiConfigAllowed()).isFalse();
}
@Test
public void hasPermissionForResult_noCallingPackage_returnFalse() {
when(mActivity.getCallingPackage()).thenReturn(null);