diff --git a/Android.mk b/Android.mk index c842c5e690..6f3038c930 100644 --- a/Android.mk +++ b/Android.mk @@ -79,6 +79,8 @@ LOCAL_SRC_FILES += $(call all-proto-files-under, $(BASE_DIR)) LOCAL_SRC_FILES += $(call all-Iaidl-files-under, $(BASE_DIR)) LOCAL_SRC_FILES := $(filter-out $(EXCLUDE_FILES),$(LOCAL_SRC_FILES)) +LOCAL_AIDL_INCLUDES := $(LOCAL_PATH)/java + LOCAL_PROTOC_FLAGS := --proto_path=$(LOCAL_PATH) LOCAL_RESOURCE_DIR := $(addprefix $(LOCAL_PATH)/, $(RES_DIRS)) diff --git a/assets/quantum/res/drawable/quantum_ic_record_white_36.xml b/assets/quantum/res/drawable/quantum_ic_record_white_36.xml new file mode 100644 index 0000000000..35aaa4134a --- /dev/null +++ b/assets/quantum/res/drawable/quantum_ic_record_white_36.xml @@ -0,0 +1,9 @@ + + + diff --git a/java/com/android/dialer/app/res/mipmap-anydpi/ic_launcher.xml b/java/com/android/dialer/app/res/mipmap-anydpi/ic_launcher.xml index a8a8fa5518..5c84730caa 100644 --- a/java/com/android/dialer/app/res/mipmap-anydpi/ic_launcher.xml +++ b/java/com/android/dialer/app/res/mipmap-anydpi/ic_launcher.xml @@ -2,4 +2,5 @@ + diff --git a/java/com/android/dialer/app/res/mipmap-anydpi/ic_launcher_round.xml b/java/com/android/dialer/app/res/mipmap-anydpi/ic_launcher_round.xml index a8a8fa5518..5c84730caa 100644 --- a/java/com/android/dialer/app/res/mipmap-anydpi/ic_launcher_round.xml +++ b/java/com/android/dialer/app/res/mipmap-anydpi/ic_launcher_round.xml @@ -2,4 +2,5 @@ + diff --git a/java/com/android/dialer/app/res/values/ids.xml b/java/com/android/dialer/app/res/values/ids.xml index ca5f854de0..050caddf71 100644 --- a/java/com/android/dialer/app/res/values/ids.xml +++ b/java/com/android/dialer/app/res/values/ids.xml @@ -25,5 +25,6 @@ + diff --git a/java/com/android/dialer/app/res/values/strings_ext.xml b/java/com/android/dialer/app/res/values/strings_ext.xml new file mode 100644 index 0000000000..b59528fdef --- /dev/null +++ b/java/com/android/dialer/app/res/values/strings_ext.xml @@ -0,0 +1,49 @@ + + + + Call recording + + Audio output format + + + AAC in MPEG-4 container + AMR-WB + + + + "0" + "1" + + + "0" + + Audio source + + + MIC (1) + VOICE_CALL (4) + + + + "1" + "4" + + + "4" + + diff --git a/java/com/android/dialer/app/res/xml/call_recording_settings.xml b/java/com/android/dialer/app/res/xml/call_recording_settings.xml new file mode 100644 index 0000000000..72761959c3 --- /dev/null +++ b/java/com/android/dialer/app/res/xml/call_recording_settings.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + diff --git a/java/com/android/dialer/app/settings/CallRecordingSettingsFragment.java b/java/com/android/dialer/app/settings/CallRecordingSettingsFragment.java new file mode 100644 index 0000000000..a04e6a98fc --- /dev/null +++ b/java/com/android/dialer/app/settings/CallRecordingSettingsFragment.java @@ -0,0 +1,15 @@ +package com.android.dialer.app.settings; + +import android.os.Bundle; +import android.preference.PreferenceFragment; +import com.android.dialer.app.R; +import com.android.dialer.callrecord.impl.CallRecorderService; + +public class CallRecordingSettingsFragment extends PreferenceFragment { + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + addPreferencesFromResource(R.xml.call_recording_settings); + } +} diff --git a/java/com/android/dialer/app/settings/DialerSettingsActivity.java b/java/com/android/dialer/app/settings/DialerSettingsActivity.java index d9bcd6ba52..886b54678a 100644 --- a/java/com/android/dialer/app/settings/DialerSettingsActivity.java +++ b/java/com/android/dialer/app/settings/DialerSettingsActivity.java @@ -105,6 +105,12 @@ public void onBuildHeaders(List
target) { soundSettingsHeader.id = R.id.settings_header_sounds_and_vibration; target.add(soundSettingsHeader); + Header callRecordingHeader = new Header(); + callRecordingHeader.titleRes = R.string.call_recording_header_title; + callRecordingHeader.fragment = CallRecordingSettingsFragment.class.getName(); + callRecordingHeader.id = R.id.settings_header_call_recording; + target.add(callRecordingHeader); + Header quickResponseSettingsHeader = new Header(); Intent quickResponseSettingsIntent = new Intent(TelecomManager.ACTION_SHOW_RESPOND_VIA_SMS_SETTINGS); diff --git a/java/com/android/dialer/callrecord/AndroidManifest.xml b/java/com/android/dialer/callrecord/AndroidManifest.xml new file mode 100644 index 0000000000..5e25c7351e --- /dev/null +++ b/java/com/android/dialer/callrecord/AndroidManifest.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + diff --git a/java/com/android/dialer/callrecord/CallRecording.aidl b/java/com/android/dialer/callrecord/CallRecording.aidl new file mode 100644 index 0000000000..e8d65bef2a --- /dev/null +++ b/java/com/android/dialer/callrecord/CallRecording.aidl @@ -0,0 +1,3 @@ +package com.android.dialer.callrecord; + +parcelable CallRecording; diff --git a/java/com/android/dialer/callrecord/CallRecording.java b/java/com/android/dialer/callrecord/CallRecording.java new file mode 100644 index 0000000000..3d7ee10857 --- /dev/null +++ b/java/com/android/dialer/callrecord/CallRecording.java @@ -0,0 +1,107 @@ +/* + * Copyright (C) 2014 The CyanogenMod Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.dialer.callrecord; + +import android.content.ContentValues; +import android.os.Environment; +import android.os.Parcel; +import android.os.Parcelable; +import android.provider.MediaStore; +import android.text.TextUtils; +import android.webkit.MimeTypeMap; + +import java.io.File; + +public final class CallRecording implements Parcelable { + public String phoneNumber; + public long creationTime; + public String fileName; + public long startRecordingTime; + public long mediaId; + + public static final Parcelable.Creator CREATOR = + new Parcelable.Creator() { + @Override + public CallRecording createFromParcel(Parcel in) { + return new CallRecording(in); + } + + @Override + public CallRecording[] newArray(int size) { + return new CallRecording[size]; + } + }; + + public CallRecording(String phoneNumber, long creationTime, + String fileName, long startRecordingTime, long mediaId) { + this.phoneNumber = phoneNumber; + this.creationTime = creationTime; + this.fileName = fileName; + this.startRecordingTime = startRecordingTime; + this.mediaId = mediaId; + } + + public CallRecording(Parcel in) { + phoneNumber = in.readString(); + creationTime = in.readLong(); + fileName = in.readString(); + startRecordingTime = in.readLong(); + mediaId = in.readLong(); + } + + public static ContentValues generateMediaInsertValues(String fileName, long creationTime) { + final ContentValues cv = new ContentValues(5); + + cv.put(MediaStore.Audio.Media.RELATIVE_PATH, "Recordings/CallRecordings"); + cv.put(MediaStore.Audio.Media.DISPLAY_NAME, fileName); + cv.put(MediaStore.Audio.Media.DATE_TAKEN, creationTime); + cv.put(MediaStore.Audio.Media.IS_PENDING, 1); + + final String extension = MimeTypeMap.getFileExtensionFromUrl(fileName); + final String mime = !TextUtils.isEmpty(extension) + ? MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension) : "audio/*"; + cv.put(MediaStore.Audio.Media.MIME_TYPE, mime); + + return cv; + } + + public static ContentValues generateCompletedValues() { + final ContentValues cv = new ContentValues(1); + cv.put(MediaStore.Audio.Media.IS_PENDING, 0); + return cv; + } + + @Override + public void writeToParcel(Parcel out, int flags) { + out.writeString(phoneNumber); + out.writeLong(creationTime); + out.writeString(fileName); + out.writeLong(startRecordingTime); + out.writeLong(mediaId); + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public String toString() { + return "phoneNumber=" + phoneNumber + ", creationTime=" + creationTime + + ", fileName=" + fileName + ", startRecordingTime=" + startRecordingTime; + } +} diff --git a/java/com/android/dialer/callrecord/ICallRecorderService.aidl b/java/com/android/dialer/callrecord/ICallRecorderService.aidl new file mode 100644 index 0000000000..acbd5f8bb5 --- /dev/null +++ b/java/com/android/dialer/callrecord/ICallRecorderService.aidl @@ -0,0 +1,37 @@ +package com.android.dialer.callrecord; + +import com.android.dialer.callrecord.CallRecording; + +/** + * Service for recording phone calls. Only one recording may be active at a time + * (i.e. every call to startRecording should be followed by a call to stopRecording). + */ +interface ICallRecorderService { + /** + * Start a recording. + * + * @return true if recording started successfully + */ + boolean startRecording(String phoneNumber, long creationTime); + + /** + * stops the current recording + * + * @return call recording data including the output filename + */ + CallRecording stopRecording(); + + /** + * Recording status + * + * @return true if there is an active recording + */ + boolean isRecording(); + + /** + * Get recording currently in progress + * + * @return call recording object + */ + CallRecording getActiveRecording(); +} diff --git a/java/com/android/dialer/callrecord/impl/CallRecorderService.java b/java/com/android/dialer/callrecord/impl/CallRecorderService.java new file mode 100644 index 0000000000..de9db4b792 --- /dev/null +++ b/java/com/android/dialer/callrecord/impl/CallRecorderService.java @@ -0,0 +1,240 @@ +/* + * Copyright (C) 2014 The CyanogenMod Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.dialer.callrecord.impl; + +import android.app.Service; +import android.content.ContentUris; +import android.content.Intent; +import android.content.SharedPreferences; +import android.content.pm.PackageManager; +import android.media.MediaRecorder; +import android.net.Uri; +import android.os.IBinder; +import android.os.ParcelFileDescriptor; +import android.os.RemoteException; +import android.provider.MediaStore; +import android.text.TextUtils; +import android.util.Log; + +import com.android.dialer.callrecord.CallRecording; +import com.android.dialer.callrecord.ICallRecorderService; + +import java.io.IOException; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.Objects; + +import com.android.dialer.R; + +import static java.lang.Integer.parseInt; + +public class CallRecorderService extends Service { + + private static final String TAG = "CallRecorderService"; + private static final boolean DBG = false; + + private static final String KEY_CALL_RECORDING_AUDIO_SOURCE = "call_recording_audio_source"; + private static final String KEY_CALL_RECORDING_OUTPUT_FORMAT = "call_recording_output_format"; + + private MediaRecorder mMediaRecorder = null; + private CallRecording mCurrentRecording = null; + + private SimpleDateFormat DATE_FORMAT = new SimpleDateFormat("yyyyMMdd-HHmmss"); + + private final ICallRecorderService.Stub mBinder = new ICallRecorderService.Stub() { + @Override + public CallRecording stopRecording() { + return stopRecordingInternal(); + } + + @Override + public boolean startRecording(String phoneNumber, long creationTime) throws RemoteException { + return startRecordingInternal(phoneNumber, creationTime); + } + + @Override + public boolean isRecording() throws RemoteException { + return mMediaRecorder != null; + } + + @Override + public CallRecording getActiveRecording() throws RemoteException { + return mCurrentRecording; + } + }; + + @Override + public void onCreate() { + if (DBG) Log.d(TAG, "Creating CallRecorderService"); + } + + @Override + public IBinder onBind(Intent intent) { + return mBinder; + } + + private SharedPreferences getPrefs() { + // This replicates PreferenceManager.getDefaultSharedPreferences, except + // that we need multi process preferences, as the pref is written in a separate + // process (com.android.dialer vs. com.android.incallui) + final String prefName = getPackageName() + "_preferences"; + return getSharedPreferences(prefName, MODE_MULTI_PROCESS); + } + + private int getAudioSource() { + String def = getString(R.string.call_recording_audio_source_default); + return parseInt(getPrefs().getString(KEY_CALL_RECORDING_AUDIO_SOURCE, def)); + } + + private static final int OUTPUT_FORMAT_AAC_MPEG_4 = 0; + private static final int OUTPUT_FORMAT_AMR_WB = 1; + + private int getOutputFormat() { + String def = getString(R.string.call_recording_output_format_default); + return parseInt(getPrefs().getString(KEY_CALL_RECORDING_OUTPUT_FORMAT, def)); + } + + private synchronized boolean startRecordingInternal(String phoneNumber, long creationTime) { + if (mMediaRecorder != null) { + Log.i(TAG, "Start called with recording in progress, stopping current recording"); + stopRecordingInternal(); + } + + if (checkSelfPermission(android.Manifest.permission.RECORD_AUDIO) + != PackageManager.PERMISSION_GRANTED) { + Log.e(TAG, "Record audio permission not granted, can't record call"); + return false; + } + + Log.i(TAG, "Starting recording"); + + final int audioSource = getAudioSource(); + final int outputFormat = getOutputFormat(); + + mMediaRecorder = new MediaRecorder(); + try { + Log.d(TAG, "Creating media recorder with audio source " + audioSource); + + mMediaRecorder.setAudioSource(audioSource); + if (outputFormat == OUTPUT_FORMAT_AAC_MPEG_4) { + mMediaRecorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4); + mMediaRecorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC); + mMediaRecorder.setAudioSamplingRate(16000); + mMediaRecorder.setAudioEncodingBitRate(48000); + } else if (outputFormat == OUTPUT_FORMAT_AMR_WB){ + mMediaRecorder.setOutputFormat(MediaRecorder.OutputFormat.AMR_WB); + mMediaRecorder.setAudioEncoder(MediaRecorder.AudioEncoder.AMR_WB); + mMediaRecorder.setAudioEncodingBitRate(23850); + } else { + throw new IllegalStateException("unexpected output format " + outputFormat); + } + } catch (IllegalStateException e) { + Log.e(TAG, "Error initializing media recorder", e); + + releaseMediaRecorder(); + + return false; + } + + String fileName = generateFilename(phoneNumber, outputFormat); + Uri uri = getContentResolver().insert(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, + CallRecording.generateMediaInsertValues(fileName, creationTime)); + + try { + try (ParcelFileDescriptor pfd = getContentResolver().openFileDescriptor(uri, "w")) { + if (pfd == null) { + throw new IOException("Opening file for URI " + uri + " failed"); + } + mMediaRecorder.setOutputFile(pfd.getFileDescriptor()); + mMediaRecorder.prepare(); + } + mMediaRecorder.start(); + + long mediaId = Long.parseLong(uri.getLastPathSegment()); + mCurrentRecording = new CallRecording(phoneNumber, creationTime, + fileName, System.currentTimeMillis(), mediaId); + return true; + } catch (IOException | IllegalStateException e) { + Log.e(TAG, "Could not start recording", e); + getContentResolver().delete(uri, null, null); + } catch (RuntimeException e) { + getContentResolver().delete(uri, null, null); + // only catch exceptions thrown by the MediaRecorder JNI code + if (e.getMessage().indexOf("start failed") >= 0) { + Log.e(TAG, "Could not start recording", e); + } else { + throw e; + } + } + + releaseMediaRecorder(); + + return false; + } + + private synchronized CallRecording stopRecordingInternal() { + CallRecording recording = mCurrentRecording; + Log.d(TAG, "Stopping current recording"); + if (mMediaRecorder != null) { + try { + mMediaRecorder.stop(); + } catch (IllegalStateException e) { + Log.e(TAG, "Exception closing media recorder", e); + } + + releaseMediaRecorder(); + + Uri uri = ContentUris.withAppendedId( + MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, mCurrentRecording.mediaId); + getContentResolver().update(uri, CallRecording.generateCompletedValues(), null, null); + + mCurrentRecording = null; + } + return recording; + } + + @Override + public void onDestroy() { + super.onDestroy(); + if (DBG) Log.d(TAG, "Destroying CallRecorderService"); + } + + private String generateFilename(String number, int outputFormat) { + String timestamp = DATE_FORMAT.format(new Date()); + String extension = (outputFormat == OUTPUT_FORMAT_AAC_MPEG_4) ? ".m4a" : ".amr"; + + if (TextUtils.isEmpty(number)) { + number = "unknown"; + } + + // CallRecord_yyyyMMdd-HHmmss_numberextension.amr/m4a + return "CallRecord_" + timestamp + "_" + number + extension; + } + + private void releaseMediaRecorder() { + Objects.requireNonNull(mMediaRecorder); + try { + mMediaRecorder.reset(); + } catch (Exception e) { + Log.e(TAG, "unable to reset media recorder", e); + } + + mMediaRecorder.release(); + + mMediaRecorder = null; + } +} diff --git a/java/com/android/incallui/CallButtonPresenter.java b/java/com/android/incallui/CallButtonPresenter.java index f2f7a405dc..ca783039ab 100644 --- a/java/com/android/incallui/CallButtonPresenter.java +++ b/java/com/android/incallui/CallButtonPresenter.java @@ -16,13 +16,18 @@ package com.android.incallui; +import android.app.AlertDialog; import android.content.Context; +import android.content.SharedPreferences; +import android.content.pm.PackageManager; import android.os.Bundle; import android.os.Trace; +import android.preference.PreferenceManager; import android.support.v4.app.Fragment; import android.support.v4.os.UserManagerCompat; import android.telecom.CallAudioState; import android.telecom.PhoneAccountHandle; +import android.widget.Toast; import com.android.contacts.common.compat.CallCompat; import com.android.dialer.common.Assert; import com.android.dialer.common.LogUtil; @@ -40,6 +45,7 @@ import com.android.incallui.audiomode.AudioModeProvider; import com.android.incallui.audiomode.AudioModeProvider.AudioModeListener; import com.android.incallui.call.CallList; +import com.android.incallui.call.CallRecorder; import com.android.incallui.call.DialerCall; import com.android.incallui.call.DialerCall.CameraDirection; import com.android.incallui.call.DialerCallListener; @@ -62,12 +68,33 @@ public class CallButtonPresenter InCallButtonUiDelegate, DialerCallListener { + private static final String KEY_RECORDING_WARNING_PRESENTED = "recording_warning_presented"; + private final Context context; private InCallButtonUi inCallButtonUi; private DialerCall call; private boolean isInCallButtonUiReady; private PhoneAccountHandle otherAccount; + private CallRecorder.RecordingProgressListener recordingProgressListener = + new CallRecorder.RecordingProgressListener() { + @Override + public void onStartRecording() { + inCallButtonUi.setCallRecordingState(true); + inCallButtonUi.setCallRecordingDuration(0); + } + + @Override + public void onStopRecording() { + inCallButtonUi.setCallRecordingState(false); + } + + @Override + public void onRecordingTimeProgress(final long elapsedTimeMs) { + inCallButtonUi.setCallRecordingDuration(elapsedTimeMs); + } + }; + public CallButtonPresenter(Context context) { this.context = context.getApplicationContext(); } @@ -86,6 +113,9 @@ public void onInCallButtonUiReady(InCallButtonUi ui) { inCallPresenter.addCanAddCallListener(this); inCallPresenter.getInCallCameraManager().addCameraSelectionListener(this); + CallRecorder recorder = CallRecorder.getInstance(); + recorder.addRecordingProgressListener(recordingProgressListener); + // Update the buttons state immediately for the current call onStateChange(InCallState.NO_CALLS, inCallPresenter.getInCallState(), CallList.getInstance()); isInCallButtonUiReady = true; @@ -101,6 +131,10 @@ public void onInCallButtonUiUnready() { InCallPresenter.getInstance().removeDetailsListener(this); InCallPresenter.getInstance().getInCallCameraManager().removeCameraSelectionListener(this); InCallPresenter.getInstance().removeCanAddCallListener(this); + + CallRecorder recorder = CallRecorder.getInstance(); + recorder.removeRecordingProgressListener(recordingProgressListener); + isInCallButtonUiReady = false; if (call != null) { @@ -300,6 +334,53 @@ public void showDialpadClicked(boolean checked) { getActivity().showDialpadFragment(checked /* show */, true /* animate */); } + @Override + public void callRecordClicked(boolean checked) { + CallRecorder recorder = CallRecorder.getInstance(); + if (checked) { + final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); + boolean warningPresented = prefs.getBoolean(KEY_RECORDING_WARNING_PRESENTED, false); + if (!warningPresented) { + new AlertDialog.Builder(getActivity()) + .setTitle(R.string.recording_warning_title) + .setMessage(R.string.recording_warning_text) + .setPositiveButton(R.string.onscreenCallRecordText, (dialog, which) -> { + prefs.edit() + .putBoolean(KEY_RECORDING_WARNING_PRESENTED, true) + .apply(); + + startCallRecordingOrAskForPermission(); + }) + .setNegativeButton(android.R.string.cancel, null) + .show(); + } else { + startCallRecordingOrAskForPermission(); + } + } else { + if (recorder.isRecording()) { + recorder.finishRecording(); + } + } + } + + private void startCallRecordingOrAskForPermission() { + if (hasAllPermissions(CallRecorder.REQUIRED_PERMISSIONS)) { + CallRecorder recorder = CallRecorder.getInstance(); + recorder.startRecording(call.getNumber(), call.getCreationTimeMillis()); + } else { + inCallButtonUi.requestCallRecordingPermissions(CallRecorder.REQUIRED_PERMISSIONS); + } + } + + private boolean hasAllPermissions(String[] permissions) { + for (String p : permissions) { + if (context.checkSelfPermission(p) != PackageManager.PERMISSION_GRANTED) { + return false; + } + } + return true; + } + @Override public void changeToVideoClicked() { LogUtil.enterBlock("CallButtonPresenter.changeToVideoClicked"); @@ -486,6 +567,9 @@ private void updateButtonsState(DialerCall call) { && call.getState() != DialerCallState.DIALING && call.getState() != DialerCallState.CONNECTING; + final CallRecorder recorder = CallRecorder.getInstance(); + final boolean showCallRecordOption = !isVideo && call.getState() == DialerCallState.ACTIVE; + otherAccount = TelecomUtil.getOtherAccount(getContext(), call.getAccountHandle()); boolean showSwapSim = !call.isEmergencyCall() @@ -519,6 +603,7 @@ private void updateButtonsState(DialerCall call) { } inCallButtonUi.showButton(InCallButtonIds.BUTTON_DIALPAD, true); inCallButtonUi.showButton(InCallButtonIds.BUTTON_MERGE, showMerge); + inCallButtonUi.showButton(InCallButtonIds.BUTTON_RECORD_CALL, showCallRecordOption); inCallButtonUi.updateButtonStates(); } diff --git a/java/com/android/incallui/InCallServiceImpl.java b/java/com/android/incallui/InCallServiceImpl.java index b9d0eccbaf..b2d318f5d0 100644 --- a/java/com/android/incallui/InCallServiceImpl.java +++ b/java/com/android/incallui/InCallServiceImpl.java @@ -27,6 +27,7 @@ import com.android.dialer.feedback.FeedbackComponent; import com.android.incallui.audiomode.AudioModeProvider; import com.android.incallui.call.CallList; +import com.android.incallui.call.CallRecorder; import com.android.incallui.call.ExternalCallList; import com.android.incallui.call.TelecomAdapter; import com.android.incallui.speakeasy.SpeakEasyCallManager; @@ -112,6 +113,7 @@ public IBinder onBind(Intent intent) { InCallPresenter.getInstance().onServiceBind(); InCallPresenter.getInstance().maybeStartRevealAnimation(intent); TelecomAdapter.getInstance().setInCallService(this); + CallRecorder.getInstance().setUp(context); returnToCallController = new ReturnToCallController(this, ContactInfoCache.getInstance(context)); feedbackListener = FeedbackComponent.get(context).getCallFeedbackListener(); diff --git a/java/com/android/incallui/call/CallList.java b/java/com/android/incallui/call/CallList.java index 634a302a21..8428c8484e 100644 --- a/java/com/android/incallui/call/CallList.java +++ b/java/com/android/incallui/call/CallList.java @@ -26,6 +26,7 @@ import android.telecom.Call; import android.telecom.DisconnectCause; import android.telecom.PhoneAccount; +import android.text.TextUtils; import android.util.ArrayMap; import com.android.dialer.blocking.FilteredNumberAsyncQueryHandler; import com.android.dialer.common.Assert; @@ -535,6 +536,15 @@ public DialerCall getCallWithState(int state, int positionToFind) { return retval; } + public DialerCall getCallWithStateAndNumber(int state, String number) { + for (DialerCall call : callById.values()) { + if (TextUtils.equals(call.getNumber(), number) && call.getState() == state) { + return call; + } + } + return null; + } + /** * Return if there is any active or background call which was not a parent call (never had a child * call) diff --git a/java/com/android/incallui/call/CallRecorder.java b/java/com/android/incallui/call/CallRecorder.java new file mode 100644 index 0000000000..bcf689baf7 --- /dev/null +++ b/java/com/android/incallui/call/CallRecorder.java @@ -0,0 +1,268 @@ +/* + * Copyright (C) 2014 The CyanogenMod Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.incallui.call; + +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.ServiceConnection; +import android.content.res.XmlResourceParser; +import android.os.Handler; +import android.os.IBinder; +import android.os.Looper; +import android.os.RemoteException; +import android.os.SystemProperties; +import android.text.TextUtils; +import android.util.Log; +import android.widget.Toast; + +import com.android.dialer.R; +import com.android.dialer.callrecord.CallRecording; +import com.android.dialer.callrecord.ICallRecorderService; +import com.android.dialer.callrecord.impl.CallRecorderService; +import com.android.dialer.location.GeoUtil; +import com.android.incallui.call.state.DialerCallState; + +import org.xmlpull.v1.XmlPullParser; +import org.xmlpull.v1.XmlPullParserException; + +import java.io.IOException; +import java.util.Date; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Locale; + +/** + * InCall UI's interface to the call recorder + * + * Manages the call recorder service lifecycle. We bind to the service whenever an active call + * is established, and unbind when all calls have been disconnected. + */ +public class CallRecorder implements CallList.Listener { + public static final String TAG = "CallRecorder"; + + public static final String[] REQUIRED_PERMISSIONS = new String[] { + android.Manifest.permission.RECORD_AUDIO, + }; + + private static final int UPDATE_INTERVAL = 500; + + private static CallRecorder instance = null; + private Context context; + private boolean initialized = false; + private ICallRecorderService service = null; + + private HashSet progressListeners = + new HashSet(); + private Handler handler = new Handler(); + + private ServiceConnection connection = new ServiceConnection() { + @Override + public void onServiceConnected(ComponentName name, IBinder service) { + CallRecorder.this.service = ICallRecorderService.Stub.asInterface(service); + } + + @Override + public void onServiceDisconnected(ComponentName name) { + CallRecorder.this.service = null; + } + }; + + public static CallRecorder getInstance() { + if (instance == null) { + instance = new CallRecorder(); + } + return instance; + } + + private CallRecorder() { + CallList.getInstance().addListener(this); + } + + public void setUp(Context context) { + this.context = context.getApplicationContext(); + } + + private void initialize() { + if (!initialized) { + Intent serviceIntent = new Intent(context, CallRecorderService.class); + context.bindService(serviceIntent, connection, Context.BIND_AUTO_CREATE); + initialized = true; + } + } + + private void uninitialize() { + if (initialized) { + context.unbindService(connection); + initialized = false; + } + } + + public boolean startRecording(final String phoneNumber, final long creationTime) { + if (service == null) { + return false; + } + + try { + if (service.startRecording(phoneNumber, creationTime)) { + for (RecordingProgressListener l : progressListeners) { + l.onStartRecording(); + } + updateRecordingProgressTask.run(); + return true; + } else { + Toast.makeText(context, R.string.call_recording_failed_message, Toast.LENGTH_SHORT) + .show(); + } + } catch (RemoteException e) { + Log.w(TAG, "Failed to start recording " + phoneNumber + ", " + new Date(creationTime), e); + } + + return false; + } + + public boolean isRecording() { + if (service == null) { + return false; + } + + try { + return service.isRecording(); + } catch (RemoteException e) { + Log.w(TAG, "Exception checking recording status", e); + } + return false; + } + + public CallRecording getActiveRecording() { + if (service == null) { + return null; + } + + try { + return service.getActiveRecording(); + } catch (RemoteException e) { + Log.w("Exception getting active recording", e); + } + return null; + } + + public void finishRecording() { + if (service != null) { + try { + final CallRecording recording = service.stopRecording(); + if (recording != null) { + if (!TextUtils.isEmpty(recording.phoneNumber)) { + String msg = context.getResources().getString(R.string.call_recording_file_location, recording.fileName); + Toast.makeText(context, msg, Toast.LENGTH_SHORT).show(); + } + } + } catch (RemoteException e) { + Log.w(TAG, "Failed to stop recording", e); + } + } + + for (RecordingProgressListener l : progressListeners) { + l.onStopRecording(); + } + handler.removeCallbacks(updateRecordingProgressTask); + } + + // + // Call list listener methods. + // + @Override + public void onIncomingCall(DialerCall call) { /* do nothing */ } + + @Override + public void onCallListChange(final CallList callList) { + if (!initialized && callList.getActiveCall() != null) { + // we'll come here if this is the first active call + initialize(); + } else { + // we can come down this branch to resume a call that was on hold + CallRecording active = getActiveRecording(); + if (active != null) { + DialerCall call = + callList.getCallWithStateAndNumber(DialerCallState.ONHOLD, active.phoneNumber); + if (call != null) { + // The call associated with the active recording has been placed + // on hold, so stop the recording. + finishRecording(); + } + } + } + } + + @Override + public void onDisconnect(final DialerCall call) { + CallRecording active = getActiveRecording(); + if (active != null && TextUtils.equals(call.getNumber(), active.phoneNumber)) { + // finish the current recording if the call gets disconnected + finishRecording(); + } + + // tear down the service if there are no more active calls + if (CallList.getInstance().getActiveCall() == null) { + uninitialize(); + } + } + + @Override + public void onUpgradeToVideo(DialerCall call) { /* do nothing */ } + + @Override + public void onSessionModificationStateChange(DialerCall call) { /* do nothing */ } + + @Override + public void onWiFiToLteHandover(DialerCall call) { /* do nothing */ } + + @Override + public void onHandoverToWifiFailed(DialerCall call) { /* do nothing */ } + + @Override + public void onInternationalCallOnWifi(DialerCall call) { /* do nothing */ } + + // allow clients to listen for recording progress updates + public interface RecordingProgressListener { + void onStartRecording(); + void onStopRecording(); + void onRecordingTimeProgress(long elapsedTimeMs); + } + + public void addRecordingProgressListener(RecordingProgressListener listener) { + progressListeners.add(listener); + } + + public void removeRecordingProgressListener(RecordingProgressListener listener) { + progressListeners.remove(listener); + } + + private Runnable updateRecordingProgressTask = new Runnable() { + @Override + public void run() { + CallRecording active = getActiveRecording(); + if (active != null) { + long elapsed = System.currentTimeMillis() - active.startRecordingTime; + for (RecordingProgressListener l : progressListeners) { + l.onRecordingTimeProgress(elapsed); + } + } + handler.postDelayed(this, UPDATE_INTERVAL); + } + }; +} diff --git a/java/com/android/incallui/incall/impl/ButtonChooserFactory.java b/java/com/android/incallui/incall/impl/ButtonChooserFactory.java index 757d813528..733dcf96d0 100644 --- a/java/com/android/incallui/incall/impl/ButtonChooserFactory.java +++ b/java/com/android/incallui/incall/impl/ButtonChooserFactory.java @@ -117,9 +117,10 @@ private static Map createCommonMapping() { mapping.put(InCallButtonIds.BUTTON_MUTE, MappingInfo.builder(0).build()); mapping.put(InCallButtonIds.BUTTON_DIALPAD, MappingInfo.builder(1).build()); mapping.put(InCallButtonIds.BUTTON_AUDIO, MappingInfo.builder(2).build()); - mapping.put(InCallButtonIds.BUTTON_MERGE, MappingInfo.builder(3).setSlotOrder(5).build()); - mapping.put(InCallButtonIds.BUTTON_ADD_CALL, MappingInfo.builder(3).build()); - mapping.put(InCallButtonIds.BUTTON_SWAP_SIM, MappingInfo.builder(4).build()); + mapping.put(InCallButtonIds.BUTTON_RECORD_CALL, MappingInfo.builder(3).build()); + mapping.put(InCallButtonIds.BUTTON_MERGE, MappingInfo.builder(4).setSlotOrder(5).build()); + mapping.put(InCallButtonIds.BUTTON_ADD_CALL, MappingInfo.builder(4).build()); + mapping.put(InCallButtonIds.BUTTON_SWAP_SIM, MappingInfo.builder(5).build()); return mapping; } } diff --git a/java/com/android/incallui/incall/impl/ButtonController.java b/java/com/android/incallui/incall/impl/ButtonController.java index 328ebbe67c..7c15f7e0d1 100644 --- a/java/com/android/incallui/incall/impl/ButtonController.java +++ b/java/com/android/incallui/incall/impl/ButtonController.java @@ -16,6 +16,7 @@ package com.android.incallui.incall.impl; +import android.content.res.Resources; import android.graphics.drawable.AnimationDrawable; import android.support.annotation.CallSuper; import android.support.annotation.DrawableRes; @@ -23,6 +24,7 @@ import android.support.annotation.StringRes; import android.telecom.CallAudioState; import android.text.TextUtils; +import android.text.format.DateUtils; import android.view.View; import android.view.View.OnClickListener; import com.android.dialer.common.Assert; @@ -411,6 +413,95 @@ public void onCheckedChanged(CheckableLabeledButton checkableLabeledButton, bool } } + class CallRecordButtonController implements ButtonController, OnClickListener { + @NonNull private final InCallButtonUiDelegate delegate; + private boolean isEnabled = true; // We're not going to limit how users use call recording. + private boolean isAllowed = true; // We're not going to limit how users use call recording. + private boolean isChecked; + private long recordingSeconds; + private CheckableLabeledButton button; + + public CallRecordButtonController(@NonNull InCallButtonUiDelegate delegate) { + this.delegate = delegate; + } + + @Override + public boolean isEnabled() { + return isEnabled; + } + + @Override + public void setEnabled(boolean isEnabled) { + this.isEnabled = isEnabled; + if (button != null) { + button.setEnabled(isEnabled); + } + } + + @Override + public boolean isAllowed() { + return isAllowed; + } + + @Override + public void setAllowed(boolean isAllowed) { + this.isAllowed = isAllowed; + if (button != null) { + button.setVisibility(isAllowed ? View.VISIBLE : View.INVISIBLE); + } + } + + @Override + public void setChecked(boolean isChecked) { + this.isChecked = isChecked; + if (button != null) { + button.setChecked(isChecked); + } + } + + @Override + public int getInCallButtonId() { + return InCallButtonIds.BUTTON_RECORD_CALL; + } + + @Override + public void setButton(CheckableLabeledButton button) { + this.button = button; + if (button != null) { + final Resources res = button.getContext().getResources(); + if (isChecked) { + CharSequence duration = DateUtils.formatElapsedTime(recordingSeconds); + button.setLabelText(res.getString(R.string.onscreenCallRecordingText, duration)); + } else { + button.setLabelText(R.string.onscreenCallRecordText); + } + button.setEnabled(isEnabled); + button.setVisibility(View.VISIBLE); + button.setChecked(isChecked); + button.setOnClickListener(this); + button.setIconDrawable(R.drawable.quantum_ic_record_white_36); + button.setContentDescription(res.getText( + isChecked ? R.string.onscreenStopCallRecordText : R.string.onscreenCallRecordText)); + button.setShouldShowMoreIndicator(false); + } + } + + public void setRecordingState(boolean recording) { + isChecked = recording; + setButton(button); + } + + public void setRecordingDuration(long durationMs) { + recordingSeconds = (durationMs + 500) / 1000; + setButton(button); + } + + @Override + public void onClick(View v) { + delegate.callRecordClicked(!isChecked); + } + } + class DialpadButtonController extends SimpleCheckableButtonController { public DialpadButtonController(@NonNull InCallButtonUiDelegate delegate) { diff --git a/java/com/android/incallui/incall/impl/CheckableLabeledButton.java b/java/com/android/incallui/incall/impl/CheckableLabeledButton.java index bfc2781a9f..ec932b9dc4 100644 --- a/java/com/android/incallui/incall/impl/CheckableLabeledButton.java +++ b/java/com/android/incallui/incall/impl/CheckableLabeledButton.java @@ -156,6 +156,10 @@ public void setLabelText(@StringRes int stringRes) { labelView.setText(stringRes); } + public void setLabelText(CharSequence label) { + labelView.setText(label); + } + /** Shows or hides a little down arrow to indicate that the button will pop up a menu. */ public void setShouldShowMoreIndicator(boolean shouldShow) { iconView.setBackground(shouldShow ? backgroundMore : background); diff --git a/java/com/android/incallui/incall/impl/InCallFragment.java b/java/com/android/incallui/incall/impl/InCallFragment.java index 30620699ac..336550deb3 100644 --- a/java/com/android/incallui/incall/impl/InCallFragment.java +++ b/java/com/android/incallui/incall/impl/InCallFragment.java @@ -54,6 +54,7 @@ import com.android.incallui.audioroute.AudioRouteSelectorDialogFragment.AudioRouteSelectorPresenter; import com.android.incallui.contactgrid.ContactGridManager; import com.android.incallui.hold.OnHoldFragment; +import com.android.incallui.incall.impl.ButtonController.CallRecordButtonController; import com.android.incallui.incall.impl.ButtonController.SpeakerButtonController; import com.android.incallui.incall.impl.ButtonController.UpgradeToRttButtonController; import com.android.incallui.incall.impl.InCallButtonGridFragment.OnButtonGridCreatedListener; @@ -95,6 +96,8 @@ public class InCallFragment extends Fragment private int phoneType; private boolean stateRestored; + private static final int REQUEST_CODE_CALL_RECORD_PERMISSION = 1000; + // Add animation to educate users. If a call has enriched calling attachments then we'll // initially show the attachment page. After a delay seconds we'll animate to the button grid. private final Handler handler = new Handler(); @@ -117,7 +120,8 @@ private static boolean isSupportedButton(@InCallButtonIds int id) { || id == InCallButtonIds.BUTTON_MERGE || id == InCallButtonIds.BUTTON_MANAGE_VOICE_CONFERENCE || id == InCallButtonIds.BUTTON_SWAP_SIM - || id == InCallButtonIds.BUTTON_UPGRADE_TO_RTT; + || id == InCallButtonIds.BUTTON_UPGRADE_TO_RTT + || id == InCallButtonIds.BUTTON_RECORD_CALL; } @Override @@ -232,6 +236,7 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle bundle) { new ButtonController.ManageConferenceButtonController(inCallScreenDelegate)); buttonControllers.add( new ButtonController.SwitchToSecondaryButtonController(inCallScreenDelegate)); + buttonControllers.add(new ButtonController.CallRecordButtonController(inCallButtonUiDelegate)); inCallScreenDelegate.onInCallScreenDelegateInit(this); inCallScreenDelegate.onInCallScreenReady(); @@ -466,6 +471,39 @@ public void setAudioState(CallAudioState audioState) { getButtonController(InCallButtonIds.BUTTON_MUTE).setChecked(audioState.isMuted()); } + @Override + public void setCallRecordingState(boolean isRecording) { + ((CallRecordButtonController) getButtonController(InCallButtonIds.BUTTON_RECORD_CALL)) + .setRecordingState(isRecording); + } + + @Override + public void setCallRecordingDuration(long durationMs) { + ((CallRecordButtonController) getButtonController(InCallButtonIds.BUTTON_RECORD_CALL)) + .setRecordingDuration(durationMs); + } + + @Override + public void requestCallRecordingPermissions(String[] permissions) { + requestPermissions(permissions, REQUEST_CODE_CALL_RECORD_PERMISSION); + } + + @Override + public void onRequestPermissionsResult(int requestCode, + @NonNull String[] permissions, @NonNull int[] grantResults) { + if (requestCode == REQUEST_CODE_CALL_RECORD_PERMISSION) { + boolean allGranted = grantResults.length > 0; + for (int i = 0; i < grantResults.length; i++) { + allGranted &= grantResults[i] == PackageManager.PERMISSION_GRANTED; + } + if (allGranted) { + inCallButtonUiDelegate.callRecordClicked(true); + } + } else { + super.onRequestPermissionsResult(requestCode, permissions, grantResults); + } + } + @Override public void updateButtonStates() { // When the incall screen is ready, this method is called from #setSecondary, even though the diff --git a/java/com/android/incallui/incall/protocol/InCallButtonIds.java b/java/com/android/incallui/incall/protocol/InCallButtonIds.java index 80ea75e998..2c956c8be3 100644 --- a/java/com/android/incallui/incall/protocol/InCallButtonIds.java +++ b/java/com/android/incallui/incall/protocol/InCallButtonIds.java @@ -38,6 +38,7 @@ InCallButtonIds.BUTTON_MANAGE_VOICE_CONFERENCE, InCallButtonIds.BUTTON_SWITCH_TO_SECONDARY, InCallButtonIds.BUTTON_SWAP_SIM, + InCallButtonIds.BUTTON_RECORD_CALL, InCallButtonIds.BUTTON_COUNT, InCallButtonIds.BUTTON_UPGRADE_TO_RTT }) @@ -58,6 +59,7 @@ int BUTTON_MANAGE_VOICE_CONFERENCE = 12; int BUTTON_SWITCH_TO_SECONDARY = 13; int BUTTON_SWAP_SIM = 14; - int BUTTON_COUNT = 15; + int BUTTON_RECORD_CALL = 15; int BUTTON_UPGRADE_TO_RTT = 16; + int BUTTON_COUNT = 17; } diff --git a/java/com/android/incallui/incall/protocol/InCallButtonIdsExtension.java b/java/com/android/incallui/incall/protocol/InCallButtonIdsExtension.java index 5239d9d344..ee03c3d412 100644 --- a/java/com/android/incallui/incall/protocol/InCallButtonIdsExtension.java +++ b/java/com/android/incallui/incall/protocol/InCallButtonIdsExtension.java @@ -58,6 +58,8 @@ public static String toString(@InCallButtonIds int id) { return "SWAP_SIM"; } else if (id == InCallButtonIds.BUTTON_UPGRADE_TO_RTT) { return "UPGRADE_TO_RTT"; + } else if (id == InCallButtonIds.BUTTON_RECORD_CALL) { + return "RECORD_CALL"; } else { return "INVALID_BUTTON: " + id; } diff --git a/java/com/android/incallui/incall/protocol/InCallButtonUi.java b/java/com/android/incallui/incall/protocol/InCallButtonUi.java index 28dd84c421..395dd7e5f4 100644 --- a/java/com/android/incallui/incall/protocol/InCallButtonUi.java +++ b/java/com/android/incallui/incall/protocol/InCallButtonUi.java @@ -37,6 +37,12 @@ public interface InCallButtonUi { void setAudioState(CallAudioState audioState); + default void setCallRecordingState(boolean isRecording) {} + + default void setCallRecordingDuration(long durationMs) {} + + default void requestCallRecordingPermissions(String[] permissions) {} + /** * Once showButton() has been called on each of the individual buttons in the UI, call this to * configure the overflow menu appropriately. diff --git a/java/com/android/incallui/incall/protocol/InCallButtonUiDelegate.java b/java/com/android/incallui/incall/protocol/InCallButtonUiDelegate.java index 4cf40ef6a1..b88c724bcc 100644 --- a/java/com/android/incallui/incall/protocol/InCallButtonUiDelegate.java +++ b/java/com/android/incallui/incall/protocol/InCallButtonUiDelegate.java @@ -65,5 +65,7 @@ public interface InCallButtonUiDelegate { void swapSimClicked(); + default void callRecordClicked(boolean checked) {} + Context getContext(); } diff --git a/java/com/android/incallui/res/values/strings_ext.xml b/java/com/android/incallui/res/values/strings_ext.xml new file mode 100644 index 0000000000..b1a22a262f --- /dev/null +++ b/java/com/android/incallui/res/values/strings_ext.xml @@ -0,0 +1,33 @@ + + + + Failed to start call recording + Saved call recording to %1$s + + Record call + Recording call - %1$s + Stop recording + + Call recording notice + Recording + Enable call recording? + Warning: + You are fully responsible for compliance with any laws, regulations and rules that apply to the use of call recording functionality and the use or distribution of those recordings. + By continuing to use call recording, you acknowledge that you fully read this and GrapheneOS will not be held liable in any way for any possible consequences should there be any + due to your usage of the call recording feature. + + diff --git a/java/com/android/incallui/theme/res/values-night/colors.xml b/java/com/android/incallui/theme/res/values-night/colors.xml new file mode 100644 index 0000000000..83357c0461 --- /dev/null +++ b/java/com/android/incallui/theme/res/values-night/colors.xml @@ -0,0 +1,4 @@ + + + #000000 + diff --git a/java/com/android/incallui/theme/res/values/styles.xml b/java/com/android/incallui/theme/res/values/styles.xml index 5b65cc3d91..09bb1dd3eb 100644 --- a/java/com/android/incallui/theme/res/values/styles.xml +++ b/java/com/android/incallui/theme/res/values/styles.xml @@ -30,7 +30,7 @@ @null @drawable/incall_background_gradient - true + false