/* * 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.settingslib.bluetooth; import static com.android.settingslib.bluetooth.HearingDeviceLocalDataManager.Data.INVALID_VOLUME; import android.bluetooth.BluetoothDevice; import android.content.ContentResolver; import android.content.Context; import android.database.ContentObserver; import android.net.Uri; import android.os.Handler; import android.os.UserHandle; import android.provider.Settings; import android.util.ArrayMap; import android.util.KeyValueListParser; import android.util.Log; import androidx.annotation.GuardedBy; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import com.android.settingslib.utils.ThreadUtils; import java.util.HashMap; import java.util.Map; import java.util.concurrent.Executor; /** * The class to manage hearing device local data from Settings. * *

Note: Before calling any methods to get or change the local data, you must first call * the {@code start()} method to load the data from Settings. Whenever the data is modified, you * must call the {@code stop()} method to save the data into Settings. After calling {@code stop()}, * you should not call any methods to get or change the local data without again calling * {@code start()}. */ public class HearingDeviceLocalDataManager { private static final String TAG = "HearingDeviceDataMgr"; private static final boolean DEBUG = true; /** Interface for listening hearing device local data changed */ public interface OnDeviceLocalDataChangeListener { /** * The method is called when the local data of the device with the address is changed. * * @param address the device anonymized address * @param data the updated data */ void onDeviceLocalDataChange(@NonNull String address, @Nullable Data data); } static final String KEY_ADDR = "addr"; static final String KEY_AMBIENT = "ambient"; static final String KEY_GROUP_AMBIENT = "group_ambient"; static final String KEY_AMBIENT_CONTROL_EXPANDED = "control_expanded"; static final String LOCAL_AMBIENT_VOLUME_SETTINGS = Settings.Global.HEARING_DEVICE_LOCAL_AMBIENT_VOLUME; private static final Object sLock = new Object(); private final Context mContext; private Executor mListenerExecutor; @GuardedBy("sLock") private final Map mAddrToDataMap = new HashMap<>(); private OnDeviceLocalDataChangeListener mListener; private SettingsObserver mSettingsObserver; private boolean mIsStarted = false; public HearingDeviceLocalDataManager(@NonNull Context context) { mContext = context; mSettingsObserver = new SettingsObserver(ThreadUtils.getUiThreadHandler()); } /** * Clears the local data of the device. This method should be called when the device is * unpaired. */ public static void clear(@NonNull Context context, @NonNull BluetoothDevice device) { HearingDeviceLocalDataManager manager = new HearingDeviceLocalDataManager(context); manager.getLocalDataFromSettings(); manager.remove(device); manager.putAmbientVolumeSettings(); } /** Starts the manager. Loads the data from Settings and start observing any changes. */ public synchronized void start() { if (mIsStarted) { return; } mIsStarted = true; getLocalDataFromSettings(); mSettingsObserver.register(mContext.getContentResolver()); } /** Stops the manager. Flushes the data into Settings and stop observing. */ public synchronized void stop() { if (!mIsStarted) { return; } putAmbientVolumeSettings(); mSettingsObserver.unregister(mContext.getContentResolver()); mIsStarted = false; } /** * Sets a listener which will be be notified when hearing device local data is changed. * * @param listener the listener to be notified * @param executor the executor to run the * {@link OnDeviceLocalDataChangeListener#onDeviceLocalDataChange(String, * Data)} callback */ public void setOnDeviceLocalDataChangeListener( @NonNull OnDeviceLocalDataChangeListener listener, @NonNull Executor executor) { mListener = listener; mListenerExecutor = executor; } /** * Gets the local data of the corresponding hearing device. This should be called after * {@link #start()} is called(). * * @param device the device to query the local data */ @NonNull public Data get(@NonNull BluetoothDevice device) { if (!mIsStarted) { Log.w(TAG, "Manager is not started. Please call start() first."); return new Data(); } synchronized (sLock) { return mAddrToDataMap.getOrDefault(device.getAnonymizedAddress(), new Data()); } } /** Flushes the data into Settings . */ public synchronized void flush() { if (!mIsStarted) { return; } putAmbientVolumeSettings(); } /** * Puts the local data of the corresponding hearing device. * * @param device the device to update the local data * @param data the local data to be stored */ private void put(BluetoothDevice device, Data data) { if (device == null) { return; } synchronized (sLock) { final String addr = device.getAnonymizedAddress(); if (data == null) { mAddrToDataMap.remove(addr); } else { mAddrToDataMap.put(addr, data); } if (mListener != null && mListenerExecutor != null) { mListenerExecutor.execute(() -> mListener.onDeviceLocalDataChange(addr, data)); } } } /** * Removes the local data of the corresponding hearing device. * * @param device the device to remove the local data */ private void remove(BluetoothDevice device) { if (device == null) { return; } synchronized (sLock) { final String addr = device.getAnonymizedAddress(); mAddrToDataMap.remove(addr); if (mListener != null && mListenerExecutor != null) { mListenerExecutor.execute(() -> mListener.onDeviceLocalDataChange(addr, null)); } } } /** * Updates the ambient volume of the corresponding hearing device. This should be called after * {@link #start()} is called(). * * @param device the device to update * @param value the ambient value * @return if the local data is updated */ public boolean updateAmbient(@Nullable BluetoothDevice device, int value) { if (!mIsStarted) { Log.w(TAG, "Manager is not started. Please call start() first."); return false; } if (device == null) { return false; } synchronized (sLock) { Data data = get(device); if (value == data.ambient) { return false; } put(device, new Data.Builder(data).ambient(value).build()); return true; } } /** * Updates the group ambient volume of the corresponding hearing device. This should be called * after {@link #start()} is called(). * * @param device the device to update * @param value the group ambient value * @return if the local data is updated */ public boolean updateGroupAmbient(@Nullable BluetoothDevice device, int value) { if (!mIsStarted) { Log.w(TAG, "Manager is not started. Please call start() first."); return false; } if (device == null) { return false; } synchronized (sLock) { Data data = get(device); if (value == data.groupAmbient) { return false; } put(device, new Data.Builder(data).groupAmbient(value).build()); return true; } } /** * Updates the ambient control is expanded or not of the corresponding hearing device. This * should be called after {@link #start()} is called(). * * @param device the device to update * @param expanded the ambient control is expanded or not * @return if the local data is updated */ public boolean updateAmbientControlExpanded(@Nullable BluetoothDevice device, boolean expanded) { if (!mIsStarted) { Log.w(TAG, "Manager is not started. Please call start() first."); return false; } if (device == null) { return false; } synchronized (sLock) { Data data = get(device); if (expanded == data.ambientControlExpanded) { return false; } put(device, new Data.Builder(data).ambientControlExpanded(expanded).build()); return true; } } void getLocalDataFromSettings() { synchronized (sLock) { Map updatedAddrToDataMap = parseFromSettings(); notifyIfDataChanged(mAddrToDataMap, updatedAddrToDataMap); mAddrToDataMap.clear(); mAddrToDataMap.putAll(updatedAddrToDataMap); } } void putAmbientVolumeSettings() { synchronized (sLock) { StringBuilder builder = new StringBuilder(); for (Map.Entry entry : mAddrToDataMap.entrySet()) { builder.append(KEY_ADDR).append("=").append(entry.getKey()); builder.append(entry.getValue().toSettingsFormat()).append(";"); } ThreadUtils.postOnBackgroundThread(() -> { Settings.Global.putStringForUser(mContext.getContentResolver(), LOCAL_AMBIENT_VOLUME_SETTINGS, builder.toString(), UserHandle.USER_SYSTEM); }); } } @GuardedBy("sLock") private Map parseFromSettings() { String settings = Settings.Global.getStringForUser(mContext.getContentResolver(), LOCAL_AMBIENT_VOLUME_SETTINGS, UserHandle.USER_SYSTEM); Map addrToDataMap = new ArrayMap<>(); if (settings != null && !settings.isEmpty()) { String[] localDataArray = settings.split(";"); for (String localData : localDataArray) { KeyValueListParser parser = new KeyValueListParser(','); parser.setString(localData); String address = parser.getString(KEY_ADDR, ""); if (!address.isEmpty()) { Data data = new Data.Builder() .ambient(parser.getInt(KEY_AMBIENT, INVALID_VOLUME)) .groupAmbient(parser.getInt(KEY_GROUP_AMBIENT, INVALID_VOLUME)) .ambientControlExpanded( parser.getBoolean(KEY_AMBIENT_CONTROL_EXPANDED, false)) .build(); addrToDataMap.put(address, data); } } } return addrToDataMap; } @GuardedBy("sLock") private void notifyIfDataChanged(Map oldAddrToDataMap, Map newAddrToDataMap) { newAddrToDataMap.forEach((addr, data) -> { Data oldData = oldAddrToDataMap.get(addr); if (oldData == null || !oldData.equals(data)) { if (mListener != null) { mListenerExecutor.execute(() -> mListener.onDeviceLocalDataChange(addr, data)); } } }); } private final class SettingsObserver extends ContentObserver { private final Uri mAmbientVolumeUri = Settings.Global.getUriFor( LOCAL_AMBIENT_VOLUME_SETTINGS); SettingsObserver(Handler handler) { super(handler); } void register(ContentResolver contentResolver) { contentResolver.registerContentObserver(mAmbientVolumeUri, false, this, UserHandle.USER_SYSTEM); } void unregister(ContentResolver contentResolver) { contentResolver.unregisterContentObserver(this); } @Override public void onChange(boolean selfChange, @Nullable Uri uri) { if (mAmbientVolumeUri.equals(uri)) { Log.v(TAG, "Local data on change, manager: " + HearingDeviceLocalDataManager.this); getLocalDataFromSettings(); } } } public record Data(int ambient, int groupAmbient, boolean ambientControlExpanded) { public static int INVALID_VOLUME = Integer.MIN_VALUE; private Data() { this(INVALID_VOLUME, INVALID_VOLUME, false); } /** * Return {@code true} if one of {@link #ambient} or {@link #groupAmbient} is assigned to * a valid value. */ public boolean hasAmbientData() { return ambient != INVALID_VOLUME || groupAmbient != INVALID_VOLUME; } /** * @return the composed string which is used to store the local data in * {@link Settings.Global#HEARING_DEVICE_LOCAL_AMBIENT_VOLUME} */ @NonNull public String toSettingsFormat() { String string = ""; if (ambient != INVALID_VOLUME) { string += ("," + KEY_AMBIENT + "=" + ambient); } if (groupAmbient != INVALID_VOLUME) { string += ("," + KEY_GROUP_AMBIENT + "=" + groupAmbient); } string += ("," + KEY_AMBIENT_CONTROL_EXPANDED + "=" + ambientControlExpanded); return string; } /** Builder for a Data object */ public static final class Builder { private int mAmbient; private int mGroupAmbient; private boolean mAmbientControlExpanded; public Builder() { this.mAmbient = INVALID_VOLUME; this.mGroupAmbient = INVALID_VOLUME; this.mAmbientControlExpanded = false; } public Builder(@NonNull Data other) { this.mAmbient = other.ambient; this.mGroupAmbient = other.groupAmbient; this.mAmbientControlExpanded = other.ambientControlExpanded; } /** Sets the ambient volume */ @NonNull public Builder ambient(int ambient) { this.mAmbient = ambient; return this; } /** Sets the group ambient volume */ @NonNull public Builder groupAmbient(int groupAmbient) { this.mGroupAmbient = groupAmbient; return this; } /** Sets the ambient control expanded */ @NonNull public Builder ambientControlExpanded(boolean ambientControlExpanded) { this.mAmbientControlExpanded = ambientControlExpanded; return this; } /** Build the Data object */ @NonNull public Data build() { return new Data(mAmbient, mGroupAmbient, mAmbientControlExpanded); } } } }