1 /* 2 * Copyright (C) 2022 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.google.android.car.kitchensink.audiorecorder; 18 19 import static android.R.layout.simple_spinner_dropdown_item; 20 import static android.R.layout.simple_spinner_item; 21 22 import static com.google.android.car.kitchensink.KitchenSinkActivity.DUMP_ARG_CMD; 23 24 import android.Manifest; 25 import android.content.ClipData; 26 import android.content.ClipboardManager; 27 import android.content.pm.PackageManager; 28 import android.media.AudioDeviceInfo; 29 import android.media.AudioManager; 30 import android.media.MediaPlayer; 31 import android.media.MediaRecorder; 32 import android.os.Build; 33 import android.os.Bundle; 34 import android.util.IndentingPrintWriter; 35 import android.util.Log; 36 import android.view.LayoutInflater; 37 import android.view.View; 38 import android.view.ViewGroup; 39 import android.widget.AdapterView; 40 import android.widget.ArrayAdapter; 41 import android.widget.Button; 42 import android.widget.Spinner; 43 import android.widget.TextView; 44 45 import androidx.activity.result.ActivityResultLauncher; 46 import androidx.activity.result.contract.ActivityResultContracts; 47 import androidx.fragment.app.Fragment; 48 49 import com.google.android.car.kitchensink.KitchenSinkActivity; 50 import com.google.android.car.kitchensink.R; 51 52 import java.io.FileDescriptor; 53 import java.io.IOException; 54 import java.io.PrintWriter; 55 import java.time.Instant; 56 import java.time.ZoneId; 57 import java.time.format.DateTimeFormatter; 58 import java.util.Arrays; 59 import java.util.Map; 60 61 public final class AudioRecorderTestFragment extends Fragment { 62 63 public static final String FRAGMENT_NAME = "audio recorder"; 64 private static final String TAG = "CAR.AUDIO.RECORDER.KS"; 65 private static final String[] PERMISSIONS = {Manifest.permission.RECORD_AUDIO}; 66 private static final String PATTERN_FORMAT = "yyyy_MM_dd_kk_mm_ss_"; 67 68 private final Map<String, DumpCommand> mDumpCommands = Map.ofEntries( 69 Map.entry("start-recording", 70 new DumpCommand("start-recording", "Starts recording audio to file.") { 71 @Override 72 boolean runCommand(IndentingPrintWriter writer) { 73 startRecording(); 74 writer.println("Started recording"); 75 return true; 76 } 77 }), 78 Map.entry("stop-recording", 79 new DumpCommand("stop-recording", "Stops recording audio to file.") { 80 @Override 81 boolean runCommand(IndentingPrintWriter writer) { 82 stopRecording(); 83 writer.println("Stopped recording"); 84 return true; 85 } 86 }), 87 Map.entry("start-playback", 88 new DumpCommand("start-playback", "Start audio playback.") { 89 @Override 90 boolean runCommand(IndentingPrintWriter writer) { 91 startPlayback(); 92 writer.println("Started playback"); 93 return true; 94 } 95 }), 96 Map.entry("stop-playback", 97 new DumpCommand("stop-playback", "Stop audio playback.") { 98 @Override 99 boolean runCommand(IndentingPrintWriter writer) { 100 stopPlayback(); 101 writer.println("Stopped Playback"); 102 return true; 103 } 104 }), 105 Map.entry("help", 106 new DumpCommand("help", "Print help information.") { 107 @Override 108 boolean runCommand(IndentingPrintWriter writer) { 109 dumpHelp(writer); 110 return true; 111 } 112 })); 113 114 private Spinner mDeviceAddressSpinner; 115 private ArrayAdapter<AudioDeviceInfoWrapper> mDeviceAddressAdapter; 116 private MediaRecorder mMediaRecorder; 117 private TextView mStatusTextView; 118 private TextView mFilePathTextView; 119 private String mFileName = ""; 120 private MediaPlayer mMediaPlayer; 121 122 private final ActivityResultLauncher<String[]> mRequestPermissionLauncher = 123 registerForActivityResult( 124 new ActivityResultContracts.RequestMultiplePermissions(), permissions -> { 125 boolean allGranted = false; 126 for (String permission : permissions.keySet()) { 127 boolean granted = permissions.get(permission); 128 Log.d(TAG, "permission [" + permission + "] granted " + granted); 129 allGranted = allGranted && granted; 130 } 131 132 if (allGranted) { 133 setStatus("All Permissions Granted"); 134 return; 135 } 136 setStatus("Not All Permissions Granted"); 137 }); 138 139 @Override onCreateView(LayoutInflater inflater, ViewGroup container, Bundle bundle)140 public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle bundle) { 141 Log.d(TAG, "onCreateView"); 142 View view = inflater.inflate(R.layout.audio_recorder, container, /* attachToRoo= */ false); 143 144 initTextViews(view); 145 initButtons(view); 146 initInputDevices(view); 147 hasPermissionRequestIfNeeded(); 148 149 return view; 150 } 151 152 @Override onDestroyView()153 public void onDestroyView() { 154 super.onDestroyView(); 155 Log.d(TAG, "onDestroyView"); 156 157 stopRecording(); 158 stopPlayback(); 159 } 160 161 @Override dump(String prefix, FileDescriptor fd, PrintWriter printWriter, String[] args)162 public void dump(String prefix, FileDescriptor fd, PrintWriter printWriter, String[] args) { 163 IndentingPrintWriter writer = new IndentingPrintWriter(printWriter, /* prefix= */ " "); 164 if (args != null && args.length > 0) { 165 runDumpCommand(writer, args); 166 return; 167 } 168 writer.println(AudioRecorderTestFragment.class.getSimpleName()); 169 writer.increaseIndent(); 170 dumpRecordingState(writer); 171 dumpPlaybackState(writer); 172 writer.decreaseIndent(); 173 } 174 runDumpCommand(IndentingPrintWriter writer, String[] args)175 private void runDumpCommand(IndentingPrintWriter writer, String[] args) { 176 if (args.length > 1 && args[0].equals(DUMP_ARG_CMD) && mDumpCommands.containsKey(args[1])) { 177 String commandString = args[1]; 178 DumpCommand command = mDumpCommands.get(commandString); 179 if (command.supportsCommand(commandString) && command.runCommand(writer)) { 180 return; 181 } 182 } 183 dumpHelp(writer); 184 } 185 dumpHelp(IndentingPrintWriter writer)186 private void dumpHelp(IndentingPrintWriter writer) { 187 writer.printf("adb shell 'dumpsys activity %s/.%s fragment \"%s\" cmd <command>'\n\n", 188 KitchenSinkActivity.class.getPackage().getName(), 189 KitchenSinkActivity.class.getSimpleName(), 190 FRAGMENT_NAME); 191 writer.increaseIndent(); 192 writer.printf("Supported commands: \n"); 193 writer.increaseIndent(); 194 for (DumpCommand command : mDumpCommands.values()) { 195 writer.printf("%s\n", command); 196 } 197 writer.decreaseIndent(); 198 writer.decreaseIndent(); 199 } 200 dumpPlaybackState(PrintWriter writer)201 private void dumpPlaybackState(PrintWriter writer) { 202 writer.printf("Is playing: %s\n", (mMediaPlayer != null && mMediaPlayer.isPlaying())); 203 } 204 dumpRecordingState(PrintWriter writer)205 private void dumpRecordingState(PrintWriter writer) { 206 writer.printf("Is recording: %s\n", mMediaRecorder != null); 207 writer.printf("Recording path: %s\n", getFilePath()); 208 writer.printf("Adb command: %s\n", getFileCopyAdbCommand()); 209 } 210 initTextViews(View view)211 private void initTextViews(View view) { 212 mStatusTextView = view.findViewById(R.id.status_text_view); 213 mFilePathTextView = view.findViewById(R.id.file_path_edit); 214 mFilePathTextView.setOnClickListener(v -> { 215 ClipboardManager clipboard = getContext().getSystemService(ClipboardManager.class); 216 ClipData clip = ClipData.newPlainText("adb copy command", getFileCopyAdbCommand()); 217 clipboard.setPrimaryClip(clip); 218 }); 219 } 220 getFileCopyAdbCommand()221 private String getFileCopyAdbCommand() { 222 return "adb pull -s " + Build.getSerial() + " " + getFilePath(); 223 } 224 getFilePath()225 private String getFilePath() { 226 return mFilePathTextView.getText().toString(); 227 } 228 setStatus(String status)229 private void setStatus(String status) { 230 mStatusTextView.setText(status); 231 Log.d(TAG, "setStatus " + status); 232 } 233 setFilePath(String path)234 private void setFilePath(String path) { 235 Log.d(TAG, "setFilePath: " + path); 236 mFilePathTextView.setText(path); 237 } 238 initButtons(View view)239 private void initButtons(View view) { 240 Log.d(TAG, "initButtons"); 241 242 setListenerForButton(view, R.id.button_start_input, v -> startRecording()); 243 setListenerForButton(view, R.id.button_stop_input, v -> stopRecording()); 244 setListenerForButton(view , R.id.button_start_playback, v -> startPlayback()); 245 setListenerForButton(view, R.id.button_stop_playback, v -> stopPlayback()); 246 } 247 setListenerForButton(View view, int resourceId, View.OnClickListener listener)248 private void setListenerForButton(View view, int resourceId, View.OnClickListener listener) { 249 Button stopPlaybackButton = view.findViewById(resourceId); 250 stopPlaybackButton.setOnClickListener(listener); 251 } 252 startPlayback()253 private void startPlayback() { 254 Log.d(TAG, "startPlayback " + mFileName); 255 256 if (mMediaRecorder != null) { 257 setStatus("Still recording, stop first"); 258 return; 259 } 260 261 if (mFileName.isEmpty()) { 262 setStatus("No recording available"); 263 return; 264 } 265 266 MediaPlayer mediaPlayer = new MediaPlayer(); 267 268 try { 269 mediaPlayer.setDataSource(mFileName); 270 mediaPlayer.setOnCompletionListener(mediaPlayer1 -> stopPlayback()); 271 mediaPlayer.prepare(); 272 mediaPlayer.start(); 273 } catch (IOException e) { 274 Log.e(TAG, "startPlayback media player failed", e); 275 } 276 277 mMediaPlayer = mediaPlayer; 278 setStatus("Started playback"); 279 } 280 stopPlayback()281 private void stopPlayback() { 282 Log.d(TAG, "stopPlayback"); 283 284 if (mMediaPlayer == null) { 285 setStatus("Playback stopped"); 286 return; 287 } 288 289 mMediaPlayer.stop(); 290 mMediaPlayer = null; 291 setStatus("Stopped playback"); 292 } 293 hasPermissionRequestIfNeeded()294 private boolean hasPermissionRequestIfNeeded() { 295 Log.d(TAG, "hasPermissionRequestIfNeeded"); 296 297 boolean allPermissionsGranted = true; 298 299 for (String requiredPermission : PERMISSIONS) { 300 int checkValue = getContext().checkCallingOrSelfPermission(requiredPermission); 301 Log.d(TAG, "hasPermissionRequestIfNeeded " + requiredPermission + " granted " 302 + (checkValue == PackageManager.PERMISSION_GRANTED)); 303 304 allPermissionsGranted = allPermissionsGranted 305 && (checkValue == PackageManager.PERMISSION_GRANTED); 306 } 307 308 if (allPermissionsGranted) { 309 return true; 310 } 311 312 mRequestPermissionLauncher.launch(PERMISSIONS); 313 return false; 314 } 315 initInputDevices(View view)316 private void initInputDevices(View view) { 317 Log.d(TAG, "initInputDevices"); 318 319 AudioManager audioManager = getContext().getSystemService(AudioManager.class); 320 321 AudioDeviceInfo[] audioDeviceInfos = 322 audioManager.getDevices(AudioManager.GET_DEVICES_INPUTS); 323 AudioDeviceInfoWrapper[] audioDeviceInfoWrappers = 324 Arrays.stream(audioDeviceInfos).map(AudioDeviceInfoWrapper::new) 325 .toArray(AudioDeviceInfoWrapper[]::new); 326 327 mDeviceAddressSpinner = view.findViewById(R.id.device_spinner); 328 329 mDeviceAddressAdapter = 330 new ArrayAdapter<>(getContext(), simple_spinner_item, audioDeviceInfoWrappers); 331 mDeviceAddressAdapter.setDropDownViewResource( 332 simple_spinner_dropdown_item); 333 334 mDeviceAddressSpinner.setAdapter(mDeviceAddressAdapter); 335 336 mDeviceAddressSpinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { 337 @Override 338 public void onItemSelected(AdapterView<?> parent, View view, int position, long id) { 339 stopRecording(); 340 } 341 342 @Override 343 public void onNothingSelected(AdapterView<?> parent) { 344 Log.d(TAG, "onNothingSelected"); 345 } 346 }); 347 } 348 stopRecording()349 private void stopRecording() { 350 Log.d(TAG, "stopRecording"); 351 352 if (mMediaRecorder == null) { 353 setStatus("stopRecording already stopped"); 354 return; 355 } 356 357 mMediaRecorder.stop(); 358 mMediaRecorder = null; 359 setStatus("stopRecording recorder stopped"); 360 } 361 startRecording()362 private void startRecording() { 363 Log.d(TAG, "startRecording"); 364 365 if (!hasPermissionRequestIfNeeded()) { 366 Log.w(TAG, "startRecording missing permission"); 367 return; 368 } 369 370 AudioDeviceInfoWrapper audioInputDeviceInfoWrapper = mDeviceAddressAdapter.getItem( 371 mDeviceAddressSpinner.getSelectedItemPosition()); 372 373 String fileName = getFileName(audioInputDeviceInfoWrapper); 374 375 Log.d(TAG, "startRecording file name " + fileName); 376 377 MediaRecorder recorder = new MediaRecorder(getContext()); 378 recorder.setAudioSource(MediaRecorder.AudioSource.DEFAULT); 379 recorder.setPreferredDevice(audioInputDeviceInfoWrapper.getAudioDeviceInfo()); 380 recorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4); 381 recorder.setOutputFile(fileName); 382 recorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC); 383 384 try { 385 recorder.prepare(); 386 } catch (IOException e) { 387 Log.e(TAG, "startRecording prepare failed", e); 388 return; 389 } 390 391 recorder.start(); 392 393 mFileName = fileName; 394 mMediaRecorder = recorder; 395 setFilePath(mFileName); 396 setStatus("Recording Started"); 397 } 398 getFileName( AudioDeviceInfoWrapper audioInputDeviceInfoWrapper)399 private String getFileName( 400 AudioDeviceInfoWrapper audioInputDeviceInfoWrapper) { 401 DateTimeFormatter formatter = DateTimeFormatter.ofPattern(PATTERN_FORMAT) 402 .withZone(ZoneId.systemDefault()); 403 String shortName = formatter.format(Instant.now()) 404 + audioInputDeviceInfoWrapper.toStringNoSymbols(); 405 return getActivity().getCacheDir().getAbsolutePath() + "/" + shortName + ".mp4"; 406 } 407 408 private static final class AudioDeviceInfoWrapper { 409 410 private final AudioDeviceInfo mAudioDeviceInfo; 411 AudioDeviceInfoWrapper(AudioDeviceInfo audioDeviceInfo)412 AudioDeviceInfoWrapper(AudioDeviceInfo audioDeviceInfo) { 413 mAudioDeviceInfo = audioDeviceInfo; 414 } 415 getAudioDeviceInfo()416 AudioDeviceInfo getAudioDeviceInfo() { 417 return mAudioDeviceInfo; 418 } 419 420 @Override toString()421 public String toString() { 422 StringBuilder builder = new StringBuilder() 423 .append("Type: ") 424 .append(typeToString(mAudioDeviceInfo.getType())); 425 426 if (!mAudioDeviceInfo.getAddress().isEmpty()) { 427 builder.append(", Address: "); 428 builder.append(mAudioDeviceInfo.getAddress()); 429 } 430 431 return builder.toString(); 432 } 433 toStringNoSymbols()434 public String toStringNoSymbols() { 435 StringBuilder builder = new StringBuilder(); 436 437 if (!mAudioDeviceInfo.getAddress().isEmpty()) { 438 builder.append("address_"); 439 builder.append(mAudioDeviceInfo.getAddress().replace("//s", "_")); 440 } else { 441 builder.append("type_"); 442 builder.append(typeToString(mAudioDeviceInfo.getType())); 443 } 444 445 return builder.toString(); 446 } 447 typeToString(int type)448 static String typeToString(int type) { 449 switch (type) { 450 case AudioDeviceInfo.TYPE_BUILTIN_MIC: 451 return "MIC"; 452 case AudioDeviceInfo.TYPE_FM_TUNER: 453 return "FM_TUNER"; 454 case AudioDeviceInfo.TYPE_AUX_LINE: 455 return "AUX_LINE"; 456 case AudioDeviceInfo.TYPE_ECHO_REFERENCE: 457 return "ECHO_REFERENCE"; 458 case AudioDeviceInfo.TYPE_BUS: 459 return "BUS"; 460 case AudioDeviceInfo.TYPE_REMOTE_SUBMIX: 461 return "REMOTE_SUBMIX"; 462 default: 463 return "TYPE[" + type + "]"; 464 } 465 } 466 } 467 468 private abstract class DumpCommand { 469 470 private final String mDescription; 471 private final String mCommand; 472 DumpCommand(String command, String description)473 DumpCommand(String command, String description) { 474 mCommand = command; 475 mDescription = description; 476 } 477 supportsCommand(String command)478 boolean supportsCommand(String command) { 479 return mCommand.equals(command); 480 } 481 runCommand(IndentingPrintWriter writer)482 abstract boolean runCommand(IndentingPrintWriter writer); 483 484 @Override toString()485 public String toString() { 486 return new StringBuilder() 487 .append(mCommand) 488 .append(": ") 489 .append(mDescription) 490 .toString(); 491 } 492 } 493 } 494