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