1 /*
2  * Copyright (C) 2024 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package com.android.settings.connecteddevice.audiosharing.audiostreams;
18 
19 import android.app.Notification;
20 import android.app.NotificationChannel;
21 import android.app.NotificationManager;
22 import android.app.Service;
23 import android.app.settings.SettingsEnums;
24 import android.bluetooth.BluetoothAdapter;
25 import android.bluetooth.BluetoothDevice;
26 import android.bluetooth.BluetoothLeBroadcastReceiveState;
27 import android.bluetooth.BluetoothProfile;
28 import android.bluetooth.BluetoothVolumeControl;
29 import android.content.Intent;
30 import android.media.MediaMetadata;
31 import android.media.session.MediaSession;
32 import android.media.session.PlaybackState;
33 import android.os.Bundle;
34 import android.os.IBinder;
35 import android.util.Log;
36 
37 import androidx.annotation.IntRange;
38 import androidx.annotation.NonNull;
39 import androidx.annotation.Nullable;
40 import androidx.annotation.VisibleForTesting;
41 
42 import com.android.settings.R;
43 import com.android.settings.bluetooth.Utils;
44 import com.android.settings.connecteddevice.audiosharing.AudioSharingUtils;
45 import com.android.settings.overlay.FeatureFactory;
46 import com.android.settingslib.bluetooth.BluetoothCallback;
47 import com.android.settingslib.bluetooth.CachedBluetoothDevice;
48 import com.android.settingslib.bluetooth.CachedBluetoothDeviceManager;
49 import com.android.settingslib.bluetooth.LocalBluetoothLeBroadcastAssistant;
50 import com.android.settingslib.bluetooth.LocalBluetoothManager;
51 import com.android.settingslib.bluetooth.VolumeControlProfile;
52 import com.android.settingslib.core.instrumentation.MetricsFeatureProvider;
53 
54 import java.util.ArrayList;
55 import java.util.concurrent.ExecutorService;
56 import java.util.concurrent.Executors;
57 
58 public class AudioStreamMediaService extends Service {
59     static final String BROADCAST_ID = "audio_stream_media_service_broadcast_id";
60     static final String BROADCAST_TITLE = "audio_stream_media_service_broadcast_title";
61     static final String DEVICES = "audio_stream_media_service_devices";
62     private static final String TAG = "AudioStreamMediaService";
63     private static final int NOTIFICATION_ID = 1;
64     private static final int BROADCAST_CONTENT_TEXT = R.string.audio_streams_listening_now;
65     private static final String LEAVE_BROADCAST_ACTION = "leave_broadcast_action";
66     private static final String LEAVE_BROADCAST_TEXT = "Leave Broadcast";
67     private static final String CHANNEL_ID = "bluetooth_notification_channel";
68     private static final String DEFAULT_DEVICE_NAME = "";
69     private static final int STATIC_PLAYBACK_DURATION = 100;
70     private static final int STATIC_PLAYBACK_POSITION = 30;
71     private static final int ZERO_PLAYBACK_SPEED = 0;
72     private final AudioStreamsBroadcastAssistantCallback mBroadcastAssistantCallback =
73             new AudioStreamsBroadcastAssistantCallback() {
74                 @Override
75                 public void onSourceLost(int broadcastId) {
76                     super.onSourceLost(broadcastId);
77                     if (broadcastId == mBroadcastId) {
78                         stopSelf();
79                     }
80                 }
81 
82                 @Override
83                 public void onSourceRemoved(BluetoothDevice sink, int sourceId, int reason) {
84                     super.onSourceRemoved(sink, sourceId, reason);
85                     if (mAudioStreamsHelper != null
86                             && mAudioStreamsHelper.getAllConnectedSources().stream()
87                                     .map(BluetoothLeBroadcastReceiveState::getBroadcastId)
88                                     .noneMatch(id -> id == mBroadcastId)) {
89                         stopSelf();
90                     }
91                 }
92             };
93 
94     private final BluetoothCallback mBluetoothCallback =
95             new BluetoothCallback() {
96                 @Override
97                 public void onBluetoothStateChanged(int bluetoothState) {
98                     if (BluetoothAdapter.STATE_OFF == bluetoothState) {
99                         stopSelf();
100                     }
101                 }
102 
103                 @Override
104                 public void onProfileConnectionStateChanged(
105                         @NonNull CachedBluetoothDevice cachedDevice,
106                         @ConnectionState int state,
107                         int bluetoothProfile) {
108                     if (state == BluetoothAdapter.STATE_DISCONNECTED
109                             && bluetoothProfile == BluetoothProfile.LE_AUDIO_BROADCAST_ASSISTANT
110                             && mDevices != null) {
111                         mDevices.remove(cachedDevice.getDevice());
112                         cachedDevice
113                                 .getMemberDevice()
114                                 .forEach(
115                                         m -> {
116                                             // Check nullability to pass NullAway check
117                                             if (mDevices != null) {
118                                                 mDevices.remove(m.getDevice());
119                                             }
120                                         });
121                     }
122                     if (mDevices == null || mDevices.isEmpty()) {
123                         stopSelf();
124                     }
125                 }
126             };
127 
128     private final BluetoothVolumeControl.Callback mVolumeControlCallback =
129             new BluetoothVolumeControl.Callback() {
130                 @Override
131                 public void onDeviceVolumeChanged(
132                         @NonNull BluetoothDevice device,
133                         @IntRange(from = -255, to = 255) int volume) {
134                     if (mDevices == null || mDevices.isEmpty()) {
135                         Log.w(TAG, "active device or device has source is null!");
136                         return;
137                     }
138                     if (mDevices.contains(device)) {
139                         Log.d(
140                                 TAG,
141                                 "onDeviceVolumeChanged() bluetoothDevice : "
142                                         + device
143                                         + " volume: "
144                                         + volume);
145                         if (volume == 0) {
146                             mIsMuted = true;
147                         } else {
148                             mIsMuted = false;
149                             mLatestPositiveVolume = volume;
150                         }
151                         if (mLocalSession != null) {
152                             mLocalSession.setPlaybackState(getPlaybackState());
153                             if (mNotificationManager != null) {
154                                 mNotificationManager.notify(NOTIFICATION_ID, buildNotification());
155                             }
156                         }
157                     }
158                 }
159             };
160 
161     private final PlaybackState.Builder mPlayStatePlayingBuilder =
162             new PlaybackState.Builder()
163                     .setActions(PlaybackState.ACTION_PAUSE | PlaybackState.ACTION_SEEK_TO)
164                     .setState(
165                             PlaybackState.STATE_PLAYING,
166                             STATIC_PLAYBACK_POSITION,
167                             ZERO_PLAYBACK_SPEED)
168                     .addCustomAction(
169                             LEAVE_BROADCAST_ACTION,
170                             LEAVE_BROADCAST_TEXT,
171                             com.android.settings.R.drawable.ic_clear);
172     private final PlaybackState.Builder mPlayStatePausingBuilder =
173             new PlaybackState.Builder()
174                     .setActions(PlaybackState.ACTION_PLAY | PlaybackState.ACTION_SEEK_TO)
175                     .setState(
176                             PlaybackState.STATE_PAUSED,
177                             STATIC_PLAYBACK_POSITION,
178                             ZERO_PLAYBACK_SPEED)
179                     .addCustomAction(
180                             LEAVE_BROADCAST_ACTION,
181                             LEAVE_BROADCAST_TEXT,
182                             com.android.settings.R.drawable.ic_clear);
183 
184     private final MetricsFeatureProvider mMetricsFeatureProvider =
185             FeatureFactory.getFeatureFactory().getMetricsFeatureProvider();
186     private final ExecutorService mExecutor = Executors.newSingleThreadExecutor();
187     private int mBroadcastId;
188     @Nullable private ArrayList<BluetoothDevice> mDevices;
189     @Nullable private LocalBluetoothManager mLocalBtManager;
190     @Nullable private AudioStreamsHelper mAudioStreamsHelper;
191     @Nullable private LocalBluetoothLeBroadcastAssistant mLeBroadcastAssistant;
192     @Nullable private VolumeControlProfile mVolumeControl;
193     @Nullable private NotificationManager mNotificationManager;
194 
195     // Set 25 as default as the volume range from `VolumeControlProfile` is from 0 to 255.
196     // If the initial volume from `onDeviceVolumeChanged` is larger than zero (not muted), we will
197     // override this value. Otherwise, we raise the volume to 25 when the play button is clicked.
198     private int mLatestPositiveVolume = 25;
199     private boolean mIsMuted = false;
200     @VisibleForTesting @Nullable MediaSession mLocalSession;
201 
202     @Override
onCreate()203     public void onCreate() {
204         if (!AudioSharingUtils.isFeatureEnabled()) {
205             return;
206         }
207 
208         super.onCreate();
209         mLocalBtManager = Utils.getLocalBtManager(this);
210         if (mLocalBtManager == null) {
211             Log.w(TAG, "onCreate() : mLocalBtManager is null!");
212             return;
213         }
214 
215         mAudioStreamsHelper = new AudioStreamsHelper(mLocalBtManager);
216         mLeBroadcastAssistant = mAudioStreamsHelper.getLeBroadcastAssistant();
217         if (mLeBroadcastAssistant == null) {
218             Log.w(TAG, "onCreate() : mLeBroadcastAssistant is null!");
219             return;
220         }
221 
222         mNotificationManager = getSystemService(NotificationManager.class);
223         if (mNotificationManager == null) {
224             Log.w(TAG, "onCreate() : notificationManager is null!");
225             return;
226         }
227 
228         if (mNotificationManager.getNotificationChannel(CHANNEL_ID) == null) {
229             NotificationChannel notificationChannel =
230                     new NotificationChannel(
231                             CHANNEL_ID,
232                             getString(com.android.settings.R.string.bluetooth),
233                             NotificationManager.IMPORTANCE_HIGH);
234             mNotificationManager.createNotificationChannel(notificationChannel);
235         }
236 
237         mLocalBtManager.getEventManager().registerCallback(mBluetoothCallback);
238 
239         mVolumeControl = mLocalBtManager.getProfileManager().getVolumeControlProfile();
240         if (mVolumeControl != null) {
241             mVolumeControl.registerCallback(mExecutor, mVolumeControlCallback);
242         }
243 
244         mLeBroadcastAssistant.registerServiceCallBack(mExecutor, mBroadcastAssistantCallback);
245     }
246 
247     @Override
onDestroy()248     public void onDestroy() {
249         super.onDestroy();
250 
251         if (!AudioSharingUtils.isFeatureEnabled()) {
252             return;
253         }
254         if (mLocalBtManager != null) {
255             mLocalBtManager.getEventManager().unregisterCallback(mBluetoothCallback);
256         }
257         if (mLeBroadcastAssistant != null) {
258             mLeBroadcastAssistant.unregisterServiceCallBack(mBroadcastAssistantCallback);
259         }
260         if (mVolumeControl != null) {
261             mVolumeControl.unregisterCallback(mVolumeControlCallback);
262         }
263         if (mLocalSession != null) {
264             mLocalSession.release();
265             mLocalSession = null;
266         }
267     }
268 
269     @Override
onStartCommand(@ullable Intent intent, int flags, int startId)270     public int onStartCommand(@Nullable Intent intent, int flags, int startId) {
271         Log.d(TAG, "onStartCommand()");
272 
273         mBroadcastId = intent != null ? intent.getIntExtra(BROADCAST_ID, -1) : -1;
274         if (mBroadcastId == -1) {
275             Log.w(TAG, "Invalid broadcast ID. Service will not start.");
276             stopSelf();
277             return START_NOT_STICKY;
278         }
279 
280         if (intent != null) {
281             mDevices = intent.getParcelableArrayListExtra(DEVICES, BluetoothDevice.class);
282         }
283         if (mDevices == null || mDevices.isEmpty()) {
284             Log.w(TAG, "No device. Service will not start.");
285             stopSelf();
286             return START_NOT_STICKY;
287         }
288         if (intent != null) {
289             createLocalMediaSession(intent.getStringExtra(BROADCAST_TITLE));
290             startForeground(NOTIFICATION_ID, buildNotification());
291         }
292 
293         return START_NOT_STICKY;
294     }
295 
createLocalMediaSession(String title)296     private void createLocalMediaSession(String title) {
297         mLocalSession = new MediaSession(this, TAG);
298         mLocalSession.setMetadata(
299                 new MediaMetadata.Builder()
300                         .putString(MediaMetadata.METADATA_KEY_TITLE, title)
301                         .putLong(MediaMetadata.METADATA_KEY_DURATION, STATIC_PLAYBACK_DURATION)
302                         .build());
303         mLocalSession.setActive(true);
304         mLocalSession.setPlaybackState(getPlaybackState());
305         mLocalSession.setCallback(
306                 new MediaSession.Callback() {
307                     public void onSeekTo(long pos) {
308                         Log.d(TAG, "onSeekTo: " + pos);
309                         if (mLocalSession != null) {
310                             mLocalSession.setPlaybackState(getPlaybackState());
311                             if (mNotificationManager != null) {
312                                 mNotificationManager.notify(NOTIFICATION_ID, buildNotification());
313                             }
314                         }
315                     }
316 
317                     @Override
318                     public void onPause() {
319                         if (mDevices == null || mDevices.isEmpty()) {
320                             Log.w(TAG, "active device or device has source is null!");
321                             return;
322                         }
323                         Log.d(
324                                 TAG,
325                                 "onPause() setting volume for device : "
326                                         + mDevices.get(0)
327                                         + " volume: "
328                                         + 0);
329                         if (mVolumeControl != null) {
330                             mVolumeControl.setDeviceVolume(mDevices.get(0), 0, true);
331                             mMetricsFeatureProvider.action(
332                                     getApplicationContext(),
333                                     SettingsEnums
334                                             .ACTION_AUDIO_STREAM_NOTIFICATION_MUTE_BUTTON_CLICK,
335                                     1);
336                         }
337                     }
338 
339                     @Override
340                     public void onPlay() {
341                         if (mDevices == null || mDevices.isEmpty()) {
342                             Log.w(TAG, "active device or device has source is null!");
343                             return;
344                         }
345                         Log.d(
346                                 TAG,
347                                 "onPlay() setting volume for device : "
348                                         + mDevices.get(0)
349                                         + " volume: "
350                                         + mLatestPositiveVolume);
351                         if (mVolumeControl != null) {
352                             mVolumeControl.setDeviceVolume(
353                                     mDevices.get(0), mLatestPositiveVolume, true);
354                         }
355                         mMetricsFeatureProvider.action(
356                                 getApplicationContext(),
357                                 SettingsEnums.ACTION_AUDIO_STREAM_NOTIFICATION_MUTE_BUTTON_CLICK,
358                                 0);
359                     }
360 
361                     @Override
362                     public void onCustomAction(@NonNull String action, Bundle extras) {
363                         Log.d(TAG, "onCustomAction: " + action);
364                         if (action.equals(LEAVE_BROADCAST_ACTION) && mAudioStreamsHelper != null) {
365                             mAudioStreamsHelper.removeSource(mBroadcastId);
366                             mMetricsFeatureProvider.action(
367                                     getApplicationContext(),
368                                     SettingsEnums
369                                             .ACTION_AUDIO_STREAM_NOTIFICATION_LEAVE_BUTTON_CLICK);
370                         }
371                     }
372                 });
373     }
374 
getPlaybackState()375     private PlaybackState getPlaybackState() {
376         return mIsMuted ? mPlayStatePausingBuilder.build() : mPlayStatePlayingBuilder.build();
377     }
378 
getDeviceName()379     private String getDeviceName() {
380         if (mDevices == null || mDevices.isEmpty() || mLocalBtManager == null) {
381             return DEFAULT_DEVICE_NAME;
382         }
383 
384         CachedBluetoothDeviceManager manager = mLocalBtManager.getCachedDeviceManager();
385         if (manager == null) {
386             return DEFAULT_DEVICE_NAME;
387         }
388 
389         CachedBluetoothDevice device = manager.findDevice(mDevices.get(0));
390         return device != null ? device.getName() : DEFAULT_DEVICE_NAME;
391     }
392 
buildNotification()393     private Notification buildNotification() {
394         String deviceName = getDeviceName();
395         Notification.MediaStyle mediaStyle =
396                 new Notification.MediaStyle()
397                         .setMediaSession(
398                                 mLocalSession != null ? mLocalSession.getSessionToken() : null);
399         if (deviceName != null && !deviceName.isEmpty()) {
400             mediaStyle.setRemotePlaybackInfo(
401                     deviceName, com.android.settingslib.R.drawable.ic_bt_le_audio, null);
402         }
403         Notification.Builder notificationBuilder =
404                 new Notification.Builder(this, CHANNEL_ID)
405                         .setSmallIcon(com.android.settingslib.R.drawable.ic_bt_le_audio_sharing)
406                         .setStyle(mediaStyle)
407                         .setContentText(getString(BROADCAST_CONTENT_TEXT))
408                         .setSilent(true);
409         return notificationBuilder.build();
410     }
411 
412     @Nullable
413     @Override
onBind(Intent intent)414     public IBinder onBind(Intent intent) {
415         return null;
416     }
417 }
418