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

Commit 4ac36ca5 authored by Chelsea Hao's avatar Chelsea Hao Committed by Android (Google) Code Review
Browse files

Merge "Branch some audio sharing functions to settingslib." into main

parents 6130d3f7 c105b9e9
Loading
Loading
Loading
Loading
+183 −128
Original line number Diff line number Diff line
@@ -3,10 +3,13 @@ package com.android.settingslib.bluetooth;
import static com.android.settingslib.widget.AdaptiveOutlineDrawable.ICON_TYPE_ADVANCED;

import android.annotation.SuppressLint;
import android.bluetooth.BluetoothAdapter;
import android.bluetooth.BluetoothClass;
import android.bluetooth.BluetoothCsipSetCoordinator;
import android.bluetooth.BluetoothDevice;
import android.bluetooth.BluetoothLeBroadcastReceiveState;
import android.bluetooth.BluetoothProfile;
import android.bluetooth.BluetoothStatusCodes;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
@@ -30,6 +33,7 @@ import androidx.annotation.WorkerThread;
import androidx.core.graphics.drawable.IconCompat;

import com.android.settingslib.R;
import com.android.settingslib.flags.Flags;
import com.android.settingslib.widget.AdaptiveIcon;
import com.android.settingslib.widget.AdaptiveOutlineDrawable;

@@ -52,8 +56,8 @@ public class BluetoothUtils {
    public static final String BT_ADVANCED_HEADER_ENABLED = "bt_advanced_header_enabled";
    private static final int METADATA_FAST_PAIR_CUSTOMIZED_FIELDS = 25;
    private static final String KEY_HEARABLE_CONTROL_SLICE = "HEARABLE_CONTROL_SLICE_WITH_WIDTH";
    private static final Set<String> EXCLUSIVE_MANAGERS = ImmutableSet.of(
            "com.google.android.gms.dck");
    private static final Set<String> EXCLUSIVE_MANAGERS =
            ImmutableSet.of("com.google.android.gms.dck");

    private static ErrorListener sErrorListener;

@@ -89,23 +93,23 @@ public class BluetoothUtils {
    /**
     * @param context to access resources from
     * @param cachedDevice to get class from
     * @return pair containing the drawable and the description of the Bluetooth class
     *         of the device.
     * @return pair containing the drawable and the description of the Bluetooth class of the
     *     device.
     */
    public static Pair<Drawable, String> getBtClassDrawableWithDescription(Context context,
            CachedBluetoothDevice cachedDevice) {
    public static Pair<Drawable, String> getBtClassDrawableWithDescription(
            Context context, CachedBluetoothDevice cachedDevice) {
        BluetoothClass btClass = cachedDevice.getBtClass();
        if (btClass != null) {
            switch (btClass.getMajorDeviceClass()) {
                case BluetoothClass.Device.Major.COMPUTER:
                    return new Pair<>(getBluetoothDrawable(context,
                            com.android.internal.R.drawable.ic_bt_laptop),
                    return new Pair<>(
                            getBluetoothDrawable(
                                    context, com.android.internal.R.drawable.ic_bt_laptop),
                            context.getString(R.string.bluetooth_talkback_computer));

                case BluetoothClass.Device.Major.PHONE:
                    return new Pair<>(
                            getBluetoothDrawable(context,
                                    com.android.internal.R.drawable.ic_phone),
                            getBluetoothDrawable(context, com.android.internal.R.drawable.ic_phone),
                            context.getString(R.string.bluetooth_talkback_phone));

                case BluetoothClass.Device.Major.PERIPHERAL:
@@ -115,8 +119,8 @@ public class BluetoothUtils {

                case BluetoothClass.Device.Major.IMAGING:
                    return new Pair<>(
                            getBluetoothDrawable(context,
                                    com.android.internal.R.drawable.ic_settings_print),
                            getBluetoothDrawable(
                                    context, com.android.internal.R.drawable.ic_settings_print),
                            context.getString(R.string.bluetooth_talkback_imaging));

                default:
@@ -125,8 +129,9 @@ public class BluetoothUtils {
        }

        if (cachedDevice.isHearingAidDevice()) {
            return new Pair<>(getBluetoothDrawable(context,
                    com.android.internal.R.drawable.ic_bt_hearing_aid),
            return new Pair<>(
                    getBluetoothDrawable(
                            context, com.android.internal.R.drawable.ic_bt_hearing_aid),
                    context.getString(R.string.bluetooth_talkback_hearing_aids));
        }

@@ -138,7 +143,8 @@ public class BluetoothUtils {
                // The device should show hearing aid icon if it contains any hearing aid related
                // profiles
                if (profile instanceof HearingAidProfile || profile instanceof HapClientProfile) {
                    return new Pair<>(getBluetoothDrawable(context, profileResId),
                    return new Pair<>(
                            getBluetoothDrawable(context, profileResId),
                            context.getString(R.string.bluetooth_talkback_hearing_aids));
                }
                if (resId == 0) {
@@ -153,42 +159,40 @@ public class BluetoothUtils {
        if (btClass != null) {
            if (doesClassMatch(btClass, BluetoothClass.PROFILE_HEADSET)) {
                return new Pair<>(
                        getBluetoothDrawable(context,
                                com.android.internal.R.drawable.ic_bt_headset_hfp),
                        getBluetoothDrawable(
                                context, com.android.internal.R.drawable.ic_bt_headset_hfp),
                        context.getString(R.string.bluetooth_talkback_headset));
            }
            if (doesClassMatch(btClass, BluetoothClass.PROFILE_A2DP)) {
                return new Pair<>(
                        getBluetoothDrawable(context,
                                com.android.internal.R.drawable.ic_bt_headphones_a2dp),
                        getBluetoothDrawable(
                                context, com.android.internal.R.drawable.ic_bt_headphones_a2dp),
                        context.getString(R.string.bluetooth_talkback_headphone));
            }
        }
        return new Pair<>(
                getBluetoothDrawable(context,
                        com.android.internal.R.drawable.ic_settings_bluetooth).mutate(),
                getBluetoothDrawable(context, com.android.internal.R.drawable.ic_settings_bluetooth)
                        .mutate(),
                context.getString(R.string.bluetooth_talkback_bluetooth));
    }

    /**
     * Get bluetooth drawable by {@code resId}
     */
    /** Get bluetooth drawable by {@code resId} */
    public static Drawable getBluetoothDrawable(Context context, @DrawableRes int resId) {
        return context.getDrawable(resId);
    }

    /**
     * Get colorful bluetooth icon with description
     */
    public static Pair<Drawable, String> getBtRainbowDrawableWithDescription(Context context,
            CachedBluetoothDevice cachedDevice) {
    /** Get colorful bluetooth icon with description */
    public static Pair<Drawable, String> getBtRainbowDrawableWithDescription(
            Context context, CachedBluetoothDevice cachedDevice) {
        final Resources resources = context.getResources();
        final Pair<Drawable, String> pair = BluetoothUtils.getBtDrawableWithDescription(context,
                cachedDevice);
        final Pair<Drawable, String> pair =
                BluetoothUtils.getBtDrawableWithDescription(context, cachedDevice);

        if (pair.first instanceof BitmapDrawable) {
            return new Pair<>(new AdaptiveOutlineDrawable(
                    resources, ((BitmapDrawable) pair.first).getBitmap()), pair.second);
            return new Pair<>(
                    new AdaptiveOutlineDrawable(
                            resources, ((BitmapDrawable) pair.first).getBitmap()),
                    pair.second);
        }

        int hashCode;
@@ -198,15 +202,12 @@ public class BluetoothUtils {
            hashCode = cachedDevice.getAddress().hashCode();
        }

        return new Pair<>(buildBtRainbowDrawable(context,
                pair.first, hashCode), pair.second);
        return new Pair<>(buildBtRainbowDrawable(context, pair.first, hashCode), pair.second);
    }

    /**
     * Build Bluetooth device icon with rainbow
     */
    private static Drawable buildBtRainbowDrawable(Context context, Drawable drawable,
            int hashCode) {
    /** Build Bluetooth device icon with rainbow */
    private static Drawable buildBtRainbowDrawable(
            Context context, Drawable drawable, int hashCode) {
        final Resources resources = context.getResources();

        // Deal with normal headset
@@ -222,38 +223,37 @@ public class BluetoothUtils {
        return adaptiveIcon;
    }

    /**
     * Get bluetooth icon with description
     */
    public static Pair<Drawable, String> getBtDrawableWithDescription(Context context,
            CachedBluetoothDevice cachedDevice) {
        final Pair<Drawable, String> pair = BluetoothUtils.getBtClassDrawableWithDescription(
                context, cachedDevice);
    /** Get bluetooth icon with description */
    public static Pair<Drawable, String> getBtDrawableWithDescription(
            Context context, CachedBluetoothDevice cachedDevice) {
        final Pair<Drawable, String> pair =
                BluetoothUtils.getBtClassDrawableWithDescription(context, cachedDevice);
        final BluetoothDevice bluetoothDevice = cachedDevice.getDevice();
        final int iconSize = context.getResources().getDimensionPixelSize(
                R.dimen.bt_nearby_icon_size);
        final int iconSize =
                context.getResources().getDimensionPixelSize(R.dimen.bt_nearby_icon_size);
        final Resources resources = context.getResources();

        // Deal with advanced device icon
        if (isAdvancedDetailsHeader(bluetoothDevice)) {
            final Uri iconUri = getUriMetaData(bluetoothDevice,
                    BluetoothDevice.METADATA_MAIN_ICON);
            final Uri iconUri = getUriMetaData(bluetoothDevice, BluetoothDevice.METADATA_MAIN_ICON);
            if (iconUri != null) {
                try {
                    context.getContentResolver().takePersistableUriPermission(iconUri,
                            Intent.FLAG_GRANT_READ_URI_PERMISSION);
                    context.getContentResolver()
                            .takePersistableUriPermission(
                                    iconUri, Intent.FLAG_GRANT_READ_URI_PERMISSION);
                } catch (SecurityException e) {
                    Log.e(TAG, "Failed to take persistable permission for: " + iconUri, e);
                }
                try {
                    final Bitmap bitmap = MediaStore.Images.Media.getBitmap(
                    final Bitmap bitmap =
                            MediaStore.Images.Media.getBitmap(
                                    context.getContentResolver(), iconUri);
                    if (bitmap != null) {
                        final Bitmap resizedBitmap = Bitmap.createScaledBitmap(bitmap, iconSize,
                                iconSize, false);
                        final Bitmap resizedBitmap =
                                Bitmap.createScaledBitmap(bitmap, iconSize, iconSize, false);
                        bitmap.recycle();
                        return new Pair<>(new BitmapDrawable(resources,
                                resizedBitmap), pair.second);
                        return new Pair<>(
                                new BitmapDrawable(resources, resizedBitmap), pair.second);
                    }
                } catch (IOException e) {
                    Log.e(TAG, "Failed to get drawable for: " + iconUri, e);
@@ -280,8 +280,8 @@ public class BluetoothUtils {
            return true;
        }
        // The metadata is for Android S
        String deviceType = getStringMetaData(bluetoothDevice,
                BluetoothDevice.METADATA_DEVICE_TYPE);
        String deviceType =
                getStringMetaData(bluetoothDevice, BluetoothDevice.METADATA_DEVICE_TYPE);
        if (TextUtils.equals(deviceType, BluetoothDevice.DEVICE_TYPE_UNTETHERED_HEADSET)
                || TextUtils.equals(deviceType, BluetoothDevice.DEVICE_TYPE_WATCH)
                || TextUtils.equals(deviceType, BluetoothDevice.DEVICE_TYPE_DEFAULT)
@@ -306,8 +306,8 @@ public class BluetoothUtils {
            return true;
        }
        // The metadata is for Android S
        String deviceType = getStringMetaData(bluetoothDevice,
                BluetoothDevice.METADATA_DEVICE_TYPE);
        String deviceType =
                getStringMetaData(bluetoothDevice, BluetoothDevice.METADATA_DEVICE_TYPE);
        if (TextUtils.equals(deviceType, BluetoothDevice.DEVICE_TYPE_UNTETHERED_HEADSET)) {
            Log.d(TAG, "isAdvancedUntetheredDevice: is untethered device ");
            return true;
@@ -321,15 +321,15 @@ public class BluetoothUtils {
     * @param device Must be one of the public constants in {@link BluetoothClass.Device}
     * @return true if device class matches, false otherwise.
     */
    public static boolean isDeviceClassMatched(@NonNull BluetoothDevice bluetoothDevice,
            int device) {
    public static boolean isDeviceClassMatched(
            @NonNull BluetoothDevice bluetoothDevice, int device) {
        final BluetoothClass bluetoothClass = bluetoothDevice.getBluetoothClass();
        return bluetoothClass != null && bluetoothClass.getDeviceClass() == device;
    }

    private static boolean isAdvancedHeaderEnabled() {
        if (!DeviceConfig.getBoolean(DeviceConfig.NAMESPACE_SETTINGS_UI, BT_ADVANCED_HEADER_ENABLED,
                true)) {
        if (!DeviceConfig.getBoolean(
                DeviceConfig.NAMESPACE_SETTINGS_UI, BT_ADVANCED_HEADER_ENABLED, true)) {
            Log.d(TAG, "isAdvancedDetailsHeader: advancedEnabled is false");
            return false;
        }
@@ -345,9 +345,7 @@ public class BluetoothUtils {
        return false;
    }

    /**
     * Create an Icon pointing to a drawable.
     */
    /** Create an Icon pointing to a drawable. */
    public static IconCompat createIconWithDrawable(Drawable drawable) {
        Bitmap bitmap;
        if (drawable instanceof BitmapDrawable) {
@@ -355,19 +353,15 @@ public class BluetoothUtils {
        } else {
            final int width = drawable.getIntrinsicWidth();
            final int height = drawable.getIntrinsicHeight();
            bitmap = createBitmap(drawable,
                    width > 0 ? width : 1,
                    height > 0 ? height : 1);
            bitmap = createBitmap(drawable, width > 0 ? width : 1, height > 0 ? height : 1);
        }
        return IconCompat.createWithBitmap(bitmap);
    }

    /**
     * Build device icon with advanced outline
     */
    /** Build device icon with advanced outline */
    public static Drawable buildAdvancedDrawable(Context context, Drawable drawable) {
        final int iconSize = context.getResources().getDimensionPixelSize(
                R.dimen.advanced_icon_size);
        final int iconSize =
                context.getResources().getDimensionPixelSize(R.dimen.advanced_icon_size);
        final Resources resources = context.getResources();

        Bitmap bitmap = null;
@@ -376,14 +370,12 @@ public class BluetoothUtils {
        } else {
            final int width = drawable.getIntrinsicWidth();
            final int height = drawable.getIntrinsicHeight();
            bitmap = createBitmap(drawable,
                    width > 0 ? width : 1,
                    height > 0 ? height : 1);
            bitmap = createBitmap(drawable, width > 0 ? width : 1, height > 0 ? height : 1);
        }

        if (bitmap != null) {
            final Bitmap resizedBitmap = Bitmap.createScaledBitmap(bitmap, iconSize,
                    iconSize, false);
            final Bitmap resizedBitmap =
                    Bitmap.createScaledBitmap(bitmap, iconSize, iconSize, false);
            bitmap.recycle();
            return new AdaptiveOutlineDrawable(resources, resizedBitmap, ICON_TYPE_ADVANCED);
        }
@@ -391,9 +383,7 @@ public class BluetoothUtils {
        return drawable;
    }

    /**
     * Creates a drawable with specified width and height.
     */
    /** Creates a drawable with specified width and height. */
    public static Bitmap createBitmap(Drawable drawable, int width, int height) {
        final Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
        final Canvas canvas = new Canvas(bitmap);
@@ -487,11 +477,8 @@ public class BluetoothUtils {
    }

    /**
     * Check if the Bluetooth device is an AvailableMediaBluetoothDevice, which means:
     * 1) currently connected
     * 2) is Hearing Aid or LE Audio
     *    OR
     * 3) connected profile matches currentAudioProfile
     * Check if the Bluetooth device is an AvailableMediaBluetoothDevice, which means: 1) currently
     * connected 2) is Hearing Aid or LE Audio OR 3) connected profile matches currentAudioProfile
     *
     * @param cachedDevice the CachedBluetoothDevice
     * @param audioManager audio manager to get the current audio profile
@@ -519,8 +506,11 @@ public class BluetoothUtils {
            // It would show in Available Devices group.
            if (cachedDevice.isConnectedAshaHearingAidDevice()
                    || cachedDevice.isConnectedLeAudioDevice()) {
                Log.d(TAG, "isFilterMatched() device : "
                        + cachedDevice.getName() + ", the profile is connected.");
                Log.d(
                        TAG,
                        "isFilterMatched() device : "
                                + cachedDevice.getName()
                                + ", the profile is connected.");
                return true;
            }
            // According to the current audio profile type,
@@ -541,11 +531,79 @@ public class BluetoothUtils {
        return isFilterMatched;
    }

    /** Returns if the le audio sharing is enabled. */
    public static boolean isAudioSharingEnabled() {
        BluetoothAdapter adapter = BluetoothAdapter.getDefaultAdapter();
        return Flags.enableLeAudioSharing()
                && adapter.isLeAudioBroadcastSourceSupported()
                        == BluetoothStatusCodes.FEATURE_SUPPORTED
                && adapter.isLeAudioBroadcastAssistantSupported()
                        == BluetoothStatusCodes.FEATURE_SUPPORTED;
    }

    /** Returns if the broadcast is on-going. */
    @WorkerThread
    public static boolean isBroadcasting(@Nullable LocalBluetoothManager manager) {
        if (manager == null) return false;
        LocalBluetoothLeBroadcast broadcast =
                manager.getProfileManager().getLeAudioBroadcastProfile();
        return broadcast != null && broadcast.isEnabled(null);
    }

    /**
     * Checks if the Bluetooth device is an available hearing device, which means:
     * 1) currently connected
     * 2) is Hearing Aid
     * 3) connected profile match hearing aid related profiles (e.g. ASHA, HAP)
     * Check if {@link CachedBluetoothDevice} has connected to a broadcast source.
     *
     * @param cachedDevice The cached bluetooth device to check.
     * @param localBtManager The BT manager to provide BT functions.
     * @return Whether the device has connected to a broadcast source.
     */
    @WorkerThread
    public static boolean hasConnectedBroadcastSource(
            CachedBluetoothDevice cachedDevice, LocalBluetoothManager localBtManager) {
        if (localBtManager == null) {
            Log.d(TAG, "Skip check hasConnectedBroadcastSource due to bt manager is null");
            return false;
        }
        LocalBluetoothLeBroadcastAssistant assistant =
                localBtManager.getProfileManager().getLeAudioBroadcastAssistantProfile();
        if (assistant == null) {
            Log.d(TAG, "Skip check hasConnectedBroadcastSource due to assistant profile is null");
            return false;
        }
        List<BluetoothLeBroadcastReceiveState> sourceList =
                assistant.getAllSources(cachedDevice.getDevice());
        if (!sourceList.isEmpty() && sourceList.stream().anyMatch(BluetoothUtils::isConnected)) {
            Log.d(
                    TAG,
                    "Lead device has connected broadcast source, device = "
                            + cachedDevice.getDevice().getAnonymizedAddress());
            return true;
        }
        // Return true if member device is in broadcast.
        for (CachedBluetoothDevice device : cachedDevice.getMemberDevice()) {
            List<BluetoothLeBroadcastReceiveState> list =
                    assistant.getAllSources(device.getDevice());
            if (!list.isEmpty() && list.stream().anyMatch(BluetoothUtils::isConnected)) {
                Log.d(
                        TAG,
                        "Member device has connected broadcast source, device = "
                                + device.getDevice().getAnonymizedAddress());
                return true;
            }
        }
        return false;
    }

    /** Checks the connectivity status based on the provided broadcast receive state. */
    @WorkerThread
    public static boolean isConnected(BluetoothLeBroadcastReceiveState state) {
        return state.getBisSyncState().stream().anyMatch(bitmap -> bitmap != 0);
    }

    /**
     * Checks if the Bluetooth device is an available hearing device, which means: 1) currently
     * connected 2) is Hearing Aid 3) connected profile match hearing aid related profiles (e.g.
     * ASHA, HAP)
     *
     * @param cachedDevice the CachedBluetoothDevice
     * @return if the device is Available hearing device
@@ -553,19 +611,20 @@ public class BluetoothUtils {
    @WorkerThread
    public static boolean isAvailableHearingDevice(CachedBluetoothDevice cachedDevice) {
        if (isDeviceConnected(cachedDevice) && cachedDevice.isConnectedHearingAidDevice()) {
            Log.d(TAG, "isFilterMatched() device : "
                    + cachedDevice.getName() + ", the profile is connected.");
            Log.d(
                    TAG,
                    "isFilterMatched() device : "
                            + cachedDevice.getName()
                            + ", the profile is connected.");
            return true;
        }
        return false;
    }

    /**
     * Check if the Bluetooth device is a ConnectedBluetoothDevice, which means:
     * 1) currently connected
     * 2) is not Hearing Aid or LE Audio
     *    AND
     * 3) connected profile does not match currentAudioProfile
     * Check if the Bluetooth device is a ConnectedBluetoothDevice, which means: 1) currently
     * connected 2) is not Hearing Aid or LE Audio AND 3) connected profile does not match
     * currentAudioProfile
     *
     * @param cachedDevice the CachedBluetoothDevice
     * @param audioManager audio manager to get the current audio profile
@@ -675,29 +734,28 @@ public class BluetoothUtils {
    }

    /**
     * Returns the BluetoothDevice's exclusive manager
     * ({@link BluetoothDevice.METADATA_EXCLUSIVE_MANAGER} in metadata) if it exists and is in the
     * given set, otherwise null.
     * Returns the BluetoothDevice's exclusive manager ({@link
     * BluetoothDevice.METADATA_EXCLUSIVE_MANAGER} in metadata) if it exists and is in the given
     * set, otherwise null.
     */
    @Nullable
    private static String getAllowedExclusiveManager(BluetoothDevice bluetoothDevice) {
        byte[] exclusiveManagerNameBytes = bluetoothDevice.getMetadata(
                BluetoothDevice.METADATA_EXCLUSIVE_MANAGER);
        byte[] exclusiveManagerNameBytes =
                bluetoothDevice.getMetadata(BluetoothDevice.METADATA_EXCLUSIVE_MANAGER);
        if (exclusiveManagerNameBytes == null) {
            Log.d(TAG, "Bluetooth device " + bluetoothDevice.getName()
            Log.d(
                    TAG,
                    "Bluetooth device "
                            + bluetoothDevice.getName()
                            + " doesn't have exclusive manager");
            return null;
        }
        String exclusiveManagerName = new String(exclusiveManagerNameBytes);
        return getExclusiveManagers().contains(exclusiveManagerName) ? exclusiveManagerName
                : null;
        return getExclusiveManagers().contains(exclusiveManagerName) ? exclusiveManagerName : null;
    }

    /**
     * Checks if given package is installed
     */
    private static boolean isPackageInstalled(Context context,
            String packageName) {
    /** Checks if given package is installed */
    private static boolean isPackageInstalled(Context context, String packageName) {
        PackageManager packageManager = context.getPackageManager();
        try {
            packageManager.getPackageInfo(packageName, 0);
@@ -709,13 +767,12 @@ public class BluetoothUtils {
    }

    /**
     * A BluetoothDevice is exclusively managed if
     * 1) it has field {@link BluetoothDevice.METADATA_EXCLUSIVE_MANAGER} in metadata.
     * 2) the exclusive manager app name is in the allowlist.
     * 3) the exclusive manager app is installed.
     * A BluetoothDevice is exclusively managed if 1) it has field {@link
     * BluetoothDevice.METADATA_EXCLUSIVE_MANAGER} in metadata. 2) the exclusive manager app name is
     * in the allowlist. 3) the exclusive manager app is installed.
     */
    public static boolean isExclusivelyManagedBluetoothDevice(@NonNull Context context,
            @NonNull BluetoothDevice bluetoothDevice) {
    public static boolean isExclusivelyManagedBluetoothDevice(
            @NonNull Context context, @NonNull BluetoothDevice bluetoothDevice) {
        String exclusiveManagerName = getAllowedExclusiveManager(bluetoothDevice);
        if (exclusiveManagerName == null) {
            return false;
@@ -728,9 +785,7 @@ public class BluetoothUtils {
        }
    }

    /**
     * Return the allowlist for exclusive manager names.
     */
    /** Return the allowlist for exclusive manager names. */
    @NonNull
    public static Set<String> getExclusiveManagers() {
        return EXCLUSIVE_MANAGERS;
+42 −0

File changed.

Preview size limit exceeded, changes collapsed.