1 /* 2 * Copyright 2021 HIMSA II K/S - www.himsa.com. 3 * Represented by EHIMA - www.ehima.com 4 * 5 * Licensed under the Apache License, Version 2.0 (the "License"); 6 * you may not use this file except in compliance with the License. 7 * You may obtain a copy of the License at 8 * 9 * http://www.apache.org/licenses/LICENSE-2.0 10 * 11 * Unless required by applicable law or agreed to in writing, software 12 * distributed under the License is distributed on an "AS IS" BASIS, 13 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 * See the License for the specific language governing permissions and 15 * limitations under the License. 16 */ 17 18 package com.android.bluetooth.leaudio; 19 20 import android.bluetooth.BluetoothLeAudioContentMetadata; 21 import android.bluetooth.BluetoothLeBroadcastMetadata; 22 import android.bluetooth.BluetoothLeBroadcastSettings; 23 import android.bluetooth.BluetoothLeBroadcastSubgroupSettings; 24 import android.content.Intent; 25 import android.content.SharedPreferences; 26 import android.os.Bundle; 27 import android.util.Log; 28 import android.view.LayoutInflater; 29 import android.view.View; 30 import android.widget.Button; 31 import android.widget.CheckBox; 32 import android.widget.EditText; 33 import android.widget.NumberPicker; 34 import android.widget.TextView; 35 import android.widget.Toast; 36 37 import androidx.appcompat.app.AlertDialog; 38 import androidx.appcompat.app.AppCompatActivity; 39 import androidx.lifecycle.ViewModelProviders; 40 import androidx.recyclerview.widget.LinearLayoutManager; 41 import androidx.recyclerview.widget.RecyclerView; 42 43 import com.google.android.material.floatingactionbutton.FloatingActionButton; 44 45 import java.io.ByteArrayOutputStream; 46 import java.nio.charset.StandardCharsets; 47 import java.util.ArrayList; 48 import java.util.List; 49 import java.util.Map; 50 51 public class BroadcasterActivity extends AppCompatActivity { 52 private BroadcasterViewModel mViewModel; 53 54 private final String BROADCAST_PREFS_KEY = "BROADCAST_PREFS_KEY"; 55 private final String PREF_SEP = ":"; 56 private final String VALUE_NOT_SET = "undefined"; 57 58 @Override onCreate(Bundle savedInstanceState)59 protected void onCreate(Bundle savedInstanceState) { 60 super.onCreate(savedInstanceState); 61 setContentView(R.layout.broadcaster_activity); 62 63 FloatingActionButton fab = findViewById(R.id.broadcast_fab); 64 fab.setOnClickListener( 65 view -> { 66 if (mViewModel.getBroadcastCount() < mViewModel.getMaximumNumberOfBroadcast()) { 67 // Start Dialog with the broadcast input details 68 AlertDialog.Builder alert = new AlertDialog.Builder(this); 69 LayoutInflater inflater = getLayoutInflater(); 70 alert.setTitle("Add the Broadcast:"); 71 72 View alertView = 73 inflater.inflate(R.layout.broadcaster_add_broadcast_dialog, null); 74 final EditText code_input_text = 75 alertView.findViewById(R.id.broadcast_code_input); 76 final EditText program_info = 77 alertView.findViewById(R.id.broadcast_program_info_input); 78 final NumberPicker contextPicker = 79 alertView.findViewById(R.id.context_picker); 80 final EditText broadcast_name = 81 alertView.findViewById(R.id.broadcast_name_input); 82 final CheckBox publicCheckbox = 83 alertView.findViewById(R.id.is_public_checkbox); 84 final EditText public_content = 85 alertView.findViewById(R.id.broadcast_public_content_input); 86 // Add context type selector 87 contextPicker.setMinValue(1); 88 contextPicker.setMaxValue( 89 alertView 90 .getResources() 91 .getStringArray(R.array.content_types) 92 .length 93 - 1); 94 contextPicker.setDisplayedValues( 95 alertView.getResources().getStringArray(R.array.content_types)); 96 final Button loadButton = alertView.findViewById(R.id.load_button); 97 loadButton.setOnClickListener( 98 new View.OnClickListener() { 99 @Override 100 public void onClick(View v) { 101 showSelectSavedBroadcastAlert( 102 code_input_text, 103 program_info, 104 contextPicker, 105 broadcast_name, 106 publicCheckbox, 107 public_content); 108 } 109 }); 110 final Button clearButton = alertView.findViewById(R.id.clear_button); 111 clearButton.setOnClickListener( 112 new View.OnClickListener() { 113 @Override 114 public void onClick(View v) { 115 SharedPreferences broadcastsPrefs = 116 getSharedPreferences(BROADCAST_PREFS_KEY, 0); 117 SharedPreferences.Editor editor = broadcastsPrefs.edit(); 118 editor.clear(); 119 editor.commit(); 120 Toast.makeText( 121 BroadcasterActivity.this, 122 "Saved broadcasts cleared", 123 Toast.LENGTH_SHORT) 124 .show(); 125 } 126 }); 127 alert.setView(alertView) 128 .setNegativeButton( 129 "Cancel", 130 (dialog, which) -> { 131 // Do nothing 132 }) 133 .setNeutralButton( 134 "Start", 135 (dialog, which) -> { 136 BluetoothLeBroadcastSettings broadcastSettings = 137 createBroadcastSettingsFromUI( 138 program_info.getText().toString(), 139 public_content.getText().toString(), 140 contextPicker.getValue(), 141 publicCheckbox.isChecked(), 142 broadcast_name.getText().toString(), 143 code_input_text.getText().toString()); 144 145 if (mViewModel.startBroadcast(broadcastSettings)) 146 Toast.makeText( 147 BroadcasterActivity.this, 148 "Broadcast was created.", 149 Toast.LENGTH_SHORT) 150 .show(); 151 }) 152 .setPositiveButton( 153 "Start & save", 154 (dialog, which) -> { 155 BluetoothLeBroadcastSettings broadcastSettings = 156 createBroadcastSettingsFromUI( 157 program_info.getText().toString(), 158 public_content.getText().toString(), 159 contextPicker.getValue(), 160 publicCheckbox.isChecked(), 161 broadcast_name.getText().toString(), 162 code_input_text.getText().toString()); 163 164 if (mViewModel.startBroadcast(broadcastSettings)) { 165 // Save only if started successfully 166 if (saveBroadcastToSharedPref( 167 program_info.getText().toString(), 168 public_content.getText().toString(), 169 contextPicker.getValue(), 170 publicCheckbox.isChecked(), 171 broadcast_name.getText().toString(), 172 code_input_text.getText().toString())) { 173 Toast.makeText( 174 BroadcasterActivity.this, 175 "Broadcast was created and" 176 + " saved", 177 Toast.LENGTH_SHORT) 178 .show(); 179 } else { 180 Toast.makeText( 181 BroadcasterActivity.this, 182 "Broadcast was created, but not" 183 + " saved (already" 184 + " exists).", 185 Toast.LENGTH_SHORT) 186 .show(); 187 } 188 } 189 }); 190 191 alert.show(); 192 } else { 193 Toast.makeText( 194 BroadcasterActivity.this, 195 "Maximum number of broadcasts reached: " 196 + Integer.valueOf( 197 mViewModel 198 .getMaximumNumberOfBroadcast()) 199 .toString(), 200 Toast.LENGTH_SHORT) 201 .show(); 202 } 203 }); 204 205 RecyclerView recyclerView = findViewById(R.id.broadcaster_recycle_view); 206 recyclerView.setLayoutManager(new LinearLayoutManager(this)); 207 recyclerView.setHasFixedSize(true); 208 209 final BroadcastItemsAdapter itemsAdapter = new BroadcastItemsAdapter(); 210 itemsAdapter.setOnItemClickListener( 211 broadcastId -> { 212 AlertDialog.Builder alert = new AlertDialog.Builder(this); 213 alert.setTitle("Broadcast Info:"); 214 215 // Load and fill in the metadata layout 216 final View metaLayout = 217 getLayoutInflater().inflate(R.layout.broadcast_metadata, null); 218 alert.setView(metaLayout); 219 220 BluetoothLeBroadcastMetadata metadata = null; 221 for (BluetoothLeBroadcastMetadata b : mViewModel.getAllBroadcastMetadata()) { 222 if (b.getBroadcastId() == broadcastId) { 223 metadata = b; 224 break; 225 } 226 } 227 228 if (metadata != null) { 229 TextView addr_text = metaLayout.findViewById(R.id.device_addr_text); 230 addr_text.setText( 231 "Device Address: " + metadata.getSourceDevice().toString()); 232 233 addr_text = metaLayout.findViewById(R.id.adv_sid_text); 234 addr_text.setText("Advertising SID: " + metadata.getSourceAdvertisingSid()); 235 236 addr_text = metaLayout.findViewById(R.id.pasync_interval_text); 237 addr_text.setText("Pa Sync Interval: " + metadata.getPaSyncInterval()); 238 239 addr_text = metaLayout.findViewById(R.id.is_encrypted_text); 240 addr_text.setText( 241 "Is Encrypted: " + (metadata.isEncrypted() ? "Yes" : "No")); 242 243 boolean isPublic = metadata.isPublicBroadcast(); 244 addr_text = metaLayout.findViewById(R.id.is_public_text); 245 addr_text.setText("Is Public Broadcast: " + (isPublic ? "Yes" : "No")); 246 247 String name = metadata.getBroadcastName(); 248 addr_text = metaLayout.findViewById(R.id.broadcast_name_text); 249 if (isPublic && name != null) { 250 addr_text.setText("Public Name: " + name); 251 } else { 252 addr_text.setVisibility(View.INVISIBLE); 253 } 254 255 BluetoothLeAudioContentMetadata publicMetadata = 256 metadata.getPublicBroadcastMetadata(); 257 addr_text = metaLayout.findViewById(R.id.public_program_info_text); 258 if (isPublic && publicMetadata != null) { 259 addr_text.setText("Public Info: " + publicMetadata.getProgramInfo()); 260 } else { 261 addr_text.setVisibility(View.INVISIBLE); 262 } 263 264 byte[] code = metadata.getBroadcastCode(); 265 addr_text = metaLayout.findViewById(R.id.broadcast_code_text); 266 if (code != null) { 267 addr_text.setText( 268 "Broadcast Code: " + new String(code, StandardCharsets.UTF_8)); 269 } else { 270 addr_text.setVisibility(View.INVISIBLE); 271 } 272 273 addr_text = metaLayout.findViewById(R.id.presentation_delay_text); 274 addr_text.setText( 275 "Presentation Delay: " 276 + metadata.getPresentationDelayMicros() 277 + " [us]"); 278 } 279 280 alert.setNeutralButton( 281 "Stop", 282 (dialog, which) -> { 283 mViewModel.stopBroadcast(broadcastId); 284 }); 285 alert.setPositiveButton( 286 "Modify", 287 (dialog, which) -> { 288 // Open activity for progam info 289 AlertDialog.Builder modifyAlert = new AlertDialog.Builder(this); 290 modifyAlert.setTitle("Modify the Broadcast:"); 291 292 LayoutInflater inflater = getLayoutInflater(); 293 View alertView = 294 inflater.inflate( 295 R.layout.broadcaster_add_broadcast_dialog, null); 296 EditText program_info_input_text = 297 alertView.findViewById(R.id.broadcast_program_info_input); 298 EditText broadcast_name_input_text = 299 alertView.findViewById(R.id.broadcast_name_input); 300 EditText public_content_input_text = 301 alertView.findViewById(R.id.broadcast_public_content_input); 302 303 // The Code cannot be changed, so just hide it 304 final EditText code_input_text = 305 alertView.findViewById(R.id.broadcast_code_input); 306 code_input_text.setVisibility(View.GONE); 307 // Public broadcast flag cannot be changed, so just hide it 308 final CheckBox public_input_checkbox = 309 alertView.findViewById(R.id.is_public_checkbox); 310 public_input_checkbox.setVisibility(View.GONE); 311 // Context picker cannot be changed, so just hide it 312 final NumberPicker content_input_text = 313 alertView.findViewById(R.id.context_picker); 314 content_input_text.setVisibility(View.GONE); 315 // Can't load when modify, so just hide buttons 316 final Button loadButton = alertView.findViewById(R.id.load_button); 317 loadButton.setVisibility(View.GONE); 318 final Button clearButton = 319 alertView.findViewById(R.id.clear_button); 320 clearButton.setVisibility(View.GONE); 321 322 modifyAlert 323 .setView(alertView) 324 .setNegativeButton( 325 "Cancel", 326 (modifyDialog, modifyWhich) -> { 327 // Do nothing 328 }) 329 .setPositiveButton( 330 "Update", 331 (modifyDialog, modifyWhich) -> { 332 BluetoothLeAudioContentMetadata.Builder 333 contentBuilder = 334 new BluetoothLeAudioContentMetadata 335 .Builder(); 336 String programInfo = 337 program_info_input_text 338 .getText() 339 .toString(); 340 if (!programInfo.isEmpty()) { 341 contentBuilder.setProgramInfo(programInfo); 342 } 343 344 final BluetoothLeAudioContentMetadata.Builder 345 publicContentBuilder = 346 new BluetoothLeAudioContentMetadata 347 .Builder(); 348 final String publicContent = 349 public_content_input_text 350 .getText() 351 .toString(); 352 if (!publicContent.isEmpty()) { 353 publicContentBuilder.setProgramInfo( 354 publicContent); 355 } 356 357 BluetoothLeBroadcastSubgroupSettings.Builder 358 subgroupBuilder = 359 new BluetoothLeBroadcastSubgroupSettings 360 .Builder() 361 .setContentMetadata( 362 contentBuilder 363 .build()); 364 365 final String broadcastName = 366 broadcast_name_input_text 367 .getText() 368 .toString(); 369 BluetoothLeBroadcastSettings.Builder builder = 370 new BluetoothLeBroadcastSettings 371 .Builder() 372 .setBroadcastName( 373 broadcastName.isEmpty() 374 ? null 375 : broadcastName) 376 .setPublicBroadcastMetadata( 377 publicContentBuilder 378 .build()); 379 380 // builder expect at least one subgroup setting 381 builder.addSubgroupSettings( 382 subgroupBuilder.build()); 383 384 if (mViewModel.updateBroadcast( 385 broadcastId, builder.build())) 386 Toast.makeText( 387 BroadcasterActivity.this, 388 "Broadcast was updated.", 389 Toast.LENGTH_SHORT) 390 .show(); 391 }); 392 393 modifyAlert.show(); 394 }); 395 396 alert.show(); 397 Log.d("CC", "Num broadcasts: " + mViewModel.getBroadcastCount()); 398 }); 399 recyclerView.setAdapter(itemsAdapter); 400 401 // Get the initial state 402 mViewModel = ViewModelProviders.of(this).get(BroadcasterViewModel.class); 403 final List<BluetoothLeBroadcastMetadata> metadata = mViewModel.getAllBroadcastMetadata(); 404 itemsAdapter.updateBroadcastsMetadata(metadata.isEmpty() ? new ArrayList<>() : metadata); 405 406 // Put a watch on updates 407 mViewModel 408 .getBroadcastUpdateMetadataLive() 409 .observe( 410 this, 411 audioBroadcast -> { 412 itemsAdapter.updateBroadcastMetadata(audioBroadcast); 413 414 Toast.makeText( 415 BroadcasterActivity.this, 416 "Updated broadcast " + audioBroadcast.getBroadcastId(), 417 Toast.LENGTH_SHORT) 418 .show(); 419 }); 420 421 // Put a watch on any error reports 422 mViewModel 423 .getBroadcastStatusMutableLive() 424 .observe( 425 this, 426 msg -> { 427 Toast.makeText(BroadcasterActivity.this, msg, Toast.LENGTH_SHORT) 428 .show(); 429 }); 430 431 // Put a watch on broadcast playback states 432 mViewModel 433 .getBroadcastPlaybackStartedMutableLive() 434 .observe( 435 this, 436 reasonAndBidPair -> { 437 Toast.makeText( 438 BroadcasterActivity.this, 439 "Playing broadcast " 440 + reasonAndBidPair.second 441 + ", reason " 442 + reasonAndBidPair.first, 443 Toast.LENGTH_SHORT) 444 .show(); 445 446 itemsAdapter.updateBroadcastPlayback(reasonAndBidPair.second, true); 447 }); 448 449 mViewModel 450 .getBroadcastPlaybackStoppedMutableLive() 451 .observe( 452 this, 453 reasonAndBidPair -> { 454 Toast.makeText( 455 BroadcasterActivity.this, 456 "Paused broadcast " 457 + reasonAndBidPair.second 458 + ", reason " 459 + reasonAndBidPair.first, 460 Toast.LENGTH_SHORT) 461 .show(); 462 463 itemsAdapter.updateBroadcastPlayback(reasonAndBidPair.second, false); 464 }); 465 466 mViewModel 467 .getBroadcastAddedMutableLive() 468 .observe( 469 this, 470 broadcastId -> { 471 itemsAdapter.addBroadcasts(broadcastId); 472 473 Toast.makeText( 474 BroadcasterActivity.this, 475 "Broadcast was added broadcastId: " + broadcastId, 476 Toast.LENGTH_SHORT) 477 .show(); 478 }); 479 480 // Put a watch on broadcast removal 481 mViewModel 482 .getBroadcastRemovedMutableLive() 483 .observe( 484 this, 485 reasonAndBidPair -> { 486 itemsAdapter.removeBroadcast(reasonAndBidPair.second); 487 488 Toast.makeText( 489 BroadcasterActivity.this, 490 "Broadcast was removed " 491 + " broadcastId: " 492 + reasonAndBidPair.second 493 + ", reason: " 494 + reasonAndBidPair.first, 495 Toast.LENGTH_SHORT) 496 .show(); 497 }); 498 499 // Prevent destruction when loses focus 500 this.setFinishOnTouchOutside(false); 501 } 502 503 @Override onBackPressed()504 public void onBackPressed() { 505 Intent intent = new Intent(this, MainActivity.class); 506 intent.addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT); 507 startActivity(intent); 508 } 509 createBroadcastSettingsFromUI( String programInfo, String publicContent, int contextTypeUI, boolean isPublic, String broadcastName, String broadcastCode)510 private BluetoothLeBroadcastSettings createBroadcastSettingsFromUI( 511 String programInfo, 512 String publicContent, 513 int contextTypeUI, 514 boolean isPublic, 515 String broadcastName, 516 String broadcastCode) { 517 518 final BluetoothLeAudioContentMetadata.Builder contentBuilder = 519 new BluetoothLeAudioContentMetadata.Builder(); 520 if (!programInfo.isEmpty()) { 521 contentBuilder.setProgramInfo(programInfo); 522 } 523 524 final BluetoothLeAudioContentMetadata.Builder publicContentBuilder = 525 new BluetoothLeAudioContentMetadata.Builder(); 526 if (!publicContent.isEmpty()) { 527 publicContentBuilder.setProgramInfo(publicContent); 528 } 529 530 // Extract raw metadata 531 byte[] metaBuffer = contentBuilder.build().getRawMetadata(); 532 ByteArrayOutputStream stream = new ByteArrayOutputStream(); 533 stream.write(metaBuffer, 0, metaBuffer.length); 534 535 // Extend raw metadata with context type 536 final int contextValue = 1 << (contextTypeUI - 1); 537 stream.write((byte) 0x03); // Length 538 stream.write((byte) 0x02); // Type for the Streaming Audio Context 539 stream.write((byte) (contextValue & 0x00FF)); // Value LSB 540 stream.write((byte) ((contextValue & 0xFF00) >> 8)); // Value MSB 541 542 BluetoothLeBroadcastSubgroupSettings.Builder subgroupBuilder = 543 new BluetoothLeBroadcastSubgroupSettings.Builder() 544 .setContentMetadata( 545 BluetoothLeAudioContentMetadata.fromRawBytes(stream.toByteArray())); 546 BluetoothLeBroadcastSettings.Builder builder = 547 new BluetoothLeBroadcastSettings.Builder() 548 .setPublicBroadcast(isPublic) 549 .setBroadcastName(broadcastName.isEmpty() ? null : broadcastName) 550 .setBroadcastCode(broadcastCode.isEmpty() ? null : broadcastCode.getBytes()) 551 .setPublicBroadcastMetadata(publicContentBuilder.build()); 552 553 // builder expect at least one subgroup setting 554 builder.addSubgroupSettings(subgroupBuilder.build()); 555 return builder.build(); 556 } 557 saveBroadcastToSharedPref( String programInfo, String publicContent, int contextTypeUI, boolean isPublic, String broadcastName, String broadcastCode)558 private boolean saveBroadcastToSharedPref( 559 String programInfo, 560 String publicContent, 561 int contextTypeUI, 562 boolean isPublic, 563 String broadcastName, 564 String broadcastCode) { 565 566 SharedPreferences broadcastsPrefs = getSharedPreferences(BROADCAST_PREFS_KEY, 0); 567 if (broadcastsPrefs.contains(broadcastName)) { 568 return false; 569 } else { 570 String toStore = 571 programInfo 572 + PREF_SEP 573 + publicContent 574 + PREF_SEP 575 + contextTypeUI 576 + PREF_SEP 577 + isPublic 578 + PREF_SEP 579 + broadcastName 580 + PREF_SEP; 581 if (broadcastCode.isEmpty()) { 582 toStore += VALUE_NOT_SET; 583 } else { 584 toStore += broadcastCode; 585 } 586 SharedPreferences.Editor editor = broadcastsPrefs.edit(); 587 editor.putString(broadcastName, toStore); 588 editor.commit(); 589 } 590 return true; 591 } 592 showSelectSavedBroadcastAlert( final EditText code_input_text, final EditText program_info, final NumberPicker contextPicker, final EditText broadcast_name, final CheckBox publicCheckbox, final EditText public_content)593 private final void showSelectSavedBroadcastAlert( 594 final EditText code_input_text, 595 final EditText program_info, 596 final NumberPicker contextPicker, 597 final EditText broadcast_name, 598 final CheckBox publicCheckbox, 599 final EditText public_content) { 600 601 ArrayList<String> listSavedBroadcast = new ArrayList(); 602 603 final SharedPreferences broadcastsPrefs = getSharedPreferences(BROADCAST_PREFS_KEY, 0); 604 Map<String, ?> allEntries = broadcastsPrefs.getAll(); 605 for (Map.Entry<String, ?> entry : allEntries.entrySet()) { 606 listSavedBroadcast.add(entry.getKey()); 607 } 608 609 AlertDialog.Builder alertDialog = new AlertDialog.Builder(this); 610 alertDialog.setTitle("Select saved broadcast"); 611 alertDialog 612 .setSingleChoiceItems( 613 listSavedBroadcast.toArray(new String[listSavedBroadcast.size()]), 614 0, 615 (dialog, which) -> { 616 String[] broadcastValues = 617 broadcastsPrefs 618 .getString(listSavedBroadcast.get(which), "") 619 .split(PREF_SEP); 620 if (broadcastValues.length != 6) { 621 Toast.makeText( 622 this, 623 "Could not retrieve " 624 + listSavedBroadcast.get(which) 625 + ".", 626 Toast.LENGTH_SHORT) 627 .show(); 628 return; 629 } 630 program_info.setText(broadcastValues[0]); 631 public_content.setText(broadcastValues[1]); 632 contextPicker.setValue(Integer.valueOf(broadcastValues[2])); 633 publicCheckbox.setChecked(Boolean.parseBoolean(broadcastValues[3])); 634 broadcast_name.setText(broadcastValues[4]); 635 if (!VALUE_NOT_SET.equals(broadcastValues[5])) { 636 code_input_text.setText(broadcastValues[5]); 637 } 638 dialog.dismiss(); 639 }) 640 .setNegativeButton("Cancel", (dialog, which) -> {}); 641 AlertDialog savedBroadcastsAlertDialog = alertDialog.create(); 642 savedBroadcastsAlertDialog.show(); 643 } 644 } 645