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