From a6593be278cc0f94387289f99607a050efe7878b Mon Sep 17 00:00:00 2001 From: Danny Baumann Date: Mon, 9 Jul 2018 11:19:24 +0200 Subject: Re-add call recording. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Author: Danny Baumann Date: Mon Jul 9 11:19:24 2018 +0200 Re-add call recording. Change-Id: I53fadf5754b5b6cc3e9920d57480e470e2305ac0 Author: Markus Gruber Date: Sat Oct 13 09:17:01 2018 +0200 Allow call recording for Austria * Call recording is legal in Austria, so it should be available in the UI Change-Id: Iaae0b222d2a1108572832732471e7e063f84dd1f Author: Alexandre Pary Date: Wed Oct 17 11:33:35 2018 +0200 Allow call recording for Belgium * Call recording is legal in Belgium, so it should be available in the UI Change-Id: I0d18c5c31aa5fbde08a849932ac0c8088508dbd8 Author: Arekusu Rin Date: Thu Oct 18 10:20:42 2018 +0200 Allow call recording for Bulgaria. * Call recording is legal, subject to certain restrictions, in Bulgaria. Call recording without notification or one side's consent is not a criminal offense, and only affects the admissibility of said call recording as evidence. Change-Id: Ie35f23056914fb2e7639ea509675e21e7fdfab26 (cherry picked from commit 4cec325c31dbe5894ab576b6161065ad0458612d) Author: Bruno Martins Date: Tue Oct 23 21:03:47 2018 +0100 res: Fix malformed XML * The legal precedent source URL includes double dashes and breaks aapt2 compilation. Replace it by a shortened one. Change-Id: Ic1cb1b6af16d27649e36478ca7597b78b93b1338 Author: Arekusu Rin Date: Thu Oct 25 12:50:57 2018 +0200 Enable or disable call recording for numerous countries via MCC. * This change handles call recording within the Dialer. Changes were made to the template of all of the XML files, and all links were changed to https, where possible. Quotes of the precedents and/or laws can be found within each country's XML file. Countries' whose status was not changed are not explicitly mentioned below, despite any changes to their files. * Call recording is disabled for: Andorra, Iceland, Indonesia, Monaco, Switzerland, the United States of America and some of its territories - Guam, Northern Mariana Islands, Puerto Rico and the United States Virgin Islands. * Call recording is enabled for: Albania, American Samoa, Argentina, Armenia, Aruba, Belarus, Bonaire, Bosnia and Herzegovina, Brazil, Canada, Chile, Croatia, Curaçao, Cyprus, Estonia, Faroe Islands, French Guiana, French Polynesia, Georgia, Greece, Greenland, Guadeloupe, Hungary, India, Ireland, Israel, Japan, Kosovo, Latvia, Liechtenstein, Lithuania, Luxembourg, Malta, Martinique, Mayotte, Moldova, Montenegro, Morocco, New Caledonia, New Zealand, North Macedonia, Peru, Russia, Réunion, Saba, Saint Barthélemy, Saint-Martin, Saint-Pierre-et-Miquelon, Serbia, Singapore, Sint Eustatius, Sint Maarten, Slovakia, Slovenia, South Africa, South Korea, Turkey, Ukraine and Wallis-et-Futuna. Change-Id: Iba5b7028d26cac281099f81bf3d5c21e2ee4d1a9 Author: Arekusu Rin Date: Wed Jun 12 09:58:05 2019 +0200 Enable Call Recording for Sri Lanka and Costa Rica. * Call recording is enabled for: Sri Lanka (413) and Costa Rica (712). * Fixes: Removed newline from Belgium (206) and space from Russia (250). Change-Id: I4c9ecf41e9fd472b97fff5cd03800414737be87a Author: Danny Baumann Date: Thu Nov 7 08:34:44 2019 +0100 Base 'call recording allowed' decision on current country. Selection of resources by MCC happens via the SIM MCC, but what matters for legislation is the current country, not the country the SIM origins from. Because of that, move the decision about whether call recording is allowed or not to the current country instead of SIM MCC. Change-Id: I0ee365d7af8e3392716318e5a51e12e0efe7029a Author: Han Wang <416810799@qq.com> Date: Wed Nov 20 13:27:00 2019 +0200 Enable call recording for China Change-Id: Id51a2e6a119e99ff50696b50513aed323c61565c Author: mhkjahromi Date: Sat Dec 7 18:32:20 2019 +0330 Enable call recording for Iran Change-Id: I5640405d9bd38ac3d83fd618543190c1b0d800fb Author: Danny Baumann Date: Thu Feb 20 13:19:27 2020 +0100 Refactor call recording to use MediaProvider. Change-Id: Id53d43d8bf10715a1597ff754f6c38a992302190 Author: Danny Baumann Date: Fri Jun 5 13:19:46 2020 +0200 Iterate old recordings properly when migrating call recording data. SparseArray.get() expects a key, not an index. Change-Id: I0ba40180dc9df9f8a8f4036ccbe47cc59a50cfbb Change-Id: Ie9e0af8ccadb1bab1c52a5d905344d0c8fcab92c --- Android.mk | 12 + AndroidManifest.xml | 3 +- .../res/drawable/quantum_ic_record_white_36.xml | 9 + .../android/dialer/app/res/values/cm_arrays.xml | 29 + .../android/dialer/app/res/values/cm_strings.xml | 7 + java/com/android/dialer/app/res/values/colors.xml | 2 + java/com/android/dialer/app/res/xml/file_paths.xml | 4 + .../android/dialer/app/res/xml/sound_settings.xml | 14 + .../dialer/app/settings/SoundSettingsFragment.java | 5 + .../dialer/binary/common/DialerApplication.java | 5 + .../dialer/calldetails/CallDetailsActivity.java | 7 +- .../calldetails/CallDetailsActivityCommon.java | 16 +- .../dialer/calldetails/CallDetailsAdapter.java | 7 +- .../calldetails/CallDetailsAdapterCommon.java | 7 +- .../calldetails/CallDetailsEntryViewHolder.java | 77 ++ .../dialer/calldetails/OldCallDetailsActivity.java | 7 +- .../dialer/calldetails/OldCallDetailsAdapter.java | 7 +- .../res/drawable/recording_playback_button.xml | 26 + .../calldetails/res/layout/call_details_entry.xml | 23 +- .../dialer/calldetails/res/values/cm_strings.xml | 23 + .../android/dialer/callrecord/AndroidManifest.xml | 26 + .../android/dialer/callrecord/CallRecording.aidl | 3 + .../android/dialer/callrecord/CallRecording.java | 107 ++ .../callrecord/CallRecordingAutoMigrator.java | 157 +++ .../dialer/callrecord/CallRecordingDataStore.java | 224 ++++ .../dialer/callrecord/ICallRecorderService.aidl | 37 + .../callrecord/impl/CallRecorderService.java | 222 ++++ .../dialer/callrecord/res/values/config.xml | 23 + .../callrecord/res/xml/call_record_states.xml | 1324 ++++++++++++++++++++ java/com/android/incallui/CallButtonPresenter.java | 84 ++ java/com/android/incallui/InCallServiceImpl.java | 2 + java/com/android/incallui/call/CallList.java | 10 + java/com/android/incallui/call/CallRecorder.java | 336 +++++ .../incallui/callpending/CallPendingActivity.java | 3 + .../incallui/incall/impl/ButtonChooserFactory.java | 7 +- .../incallui/incall/impl/ButtonController.java | 91 ++ .../incall/impl/CheckableLabeledButton.java | 4 + .../incallui/incall/impl/InCallFragment.java | 40 +- .../incallui/incall/protocol/InCallButtonIds.java | 4 +- .../incall/protocol/InCallButtonIdsExtension.java | 2 + .../incallui/incall/protocol/InCallButtonUi.java | 6 + .../incall/protocol/InCallButtonUiDelegate.java | 2 + .../com/android/incallui/res/values/cm_strings.xml | 26 + .../android/incallui/rtt/impl/RttChatFragment.java | 9 + .../video/impl/SurfaceViewVideoCallFragment.java | 12 + .../incallui/video/impl/VideoCallFragment.java | 12 + privapp_whitelist_com.android.dialer-ext.xml | 22 + 47 files changed, 3064 insertions(+), 21 deletions(-) create mode 100644 assets/quantum/res/drawable/quantum_ic_record_white_36.xml create mode 100644 java/com/android/dialer/app/res/values/cm_arrays.xml create mode 100644 java/com/android/dialer/calldetails/res/drawable/recording_playback_button.xml create mode 100644 java/com/android/dialer/calldetails/res/values/cm_strings.xml create mode 100644 java/com/android/dialer/callrecord/AndroidManifest.xml create mode 100644 java/com/android/dialer/callrecord/CallRecording.aidl create mode 100644 java/com/android/dialer/callrecord/CallRecording.java create mode 100644 java/com/android/dialer/callrecord/CallRecordingAutoMigrator.java create mode 100644 java/com/android/dialer/callrecord/CallRecordingDataStore.java create mode 100644 java/com/android/dialer/callrecord/ICallRecorderService.aidl create mode 100644 java/com/android/dialer/callrecord/impl/CallRecorderService.java create mode 100644 java/com/android/dialer/callrecord/res/values/config.xml create mode 100644 java/com/android/dialer/callrecord/res/xml/call_record_states.xml create mode 100644 java/com/android/incallui/call/CallRecorder.java create mode 100644 java/com/android/incallui/res/values/cm_strings.xml create mode 100644 privapp_whitelist_com.android.dialer-ext.xml diff --git a/Android.mk b/Android.mk index b7a4a8ffe..db6cff7bd 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)) @@ -176,9 +178,19 @@ LOCAL_PRIVILEGED_MODULE := true LOCAL_PRODUCT_MODULE := true LOCAL_USE_AAPT2 := true LOCAL_REQUIRED_MODULES := privapp_whitelist_com.android.dialer +LOCAL_REQUIRED_MODULES += privapp_whitelist_com.android.dialer-ext.xml include $(BUILD_PACKAGE) +include $(CLEAR_VARS) +LOCAL_MODULE := privapp_whitelist_com.android.dialer-ext.xml +LOCAL_MODULE_CLASS := ETC +LOCAL_MODULE_TAGS := optional +LOCAL_MODULE_PATH := $(TARGET_OUT_PRODUCT_ETC)/permissions +LOCAL_PRODUCT_MODULE := true +LOCAL_SRC_FILES := $(LOCAL_MODULE) +include $(BUILD_PREBUILT) + # Cleanup local state BASE_DIR := EXCLUDE_FILES := diff --git a/AndroidManifest.xml b/AndroidManifest.xml index d96d27629..9ec517382 100644 --- a/AndroidManifest.xml +++ b/AndroidManifest.xml @@ -114,7 +114,8 @@ android:appCategory="social" android:supportsRtl="true" android:usesCleartextTraffic="false" - android:extractNativeLibs="false"> + android:extractNativeLibs="false" + android:requestLegacyExternalStorage="true"> 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 000000000..35aaa4134 --- /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/values/cm_arrays.xml b/java/com/android/dialer/app/res/values/cm_arrays.xml new file mode 100644 index 000000000..a788fd342 --- /dev/null +++ b/java/com/android/dialer/app/res/values/cm_arrays.xml @@ -0,0 +1,29 @@ + + + + + @string/wb_amr_format + @string/aac_format + + + + "0" + "1" + + + diff --git a/java/com/android/dialer/app/res/values/cm_strings.xml b/java/com/android/dialer/app/res/values/cm_strings.xml index 0ba0d500a..b28dcaeb2 100644 --- a/java/com/android/dialer/app/res/values/cm_strings.xml +++ b/java/com/android/dialer/app/res/values/cm_strings.xml @@ -31,4 +31,11 @@ In order to enable Do Not Disturb, the Phone app needs to be granted the permission to control the Do Not Disturb status.\nPlease allow it. Allow Deny + + call_recording_category + Call recording + call_recording_format + Audio format + AMR-WB + AAC diff --git a/java/com/android/dialer/app/res/values/colors.xml b/java/com/android/dialer/app/res/values/colors.xml index a9713474d..16fb2d098 100644 --- a/java/com/android/dialer/app/res/values/colors.xml +++ b/java/com/android/dialer/app/res/values/colors.xml @@ -18,6 +18,8 @@ #80000000 ?colorIcon + #8a000000 + #FF3F3B diff --git a/java/com/android/dialer/app/res/xml/file_paths.xml b/java/com/android/dialer/app/res/xml/file_paths.xml index 0dd41a085..b43f45093 100644 --- a/java/com/android/dialer/app/res/xml/file_paths.xml +++ b/java/com/android/dialer/app/res/xml/file_paths.xml @@ -22,4 +22,8 @@ + + diff --git a/java/com/android/dialer/app/res/xml/sound_settings.xml b/java/com/android/dialer/app/res/xml/sound_settings.xml index 4da5c1514..aa025874f 100644 --- a/java/com/android/dialer/app/res/xml/sound_settings.xml +++ b/java/com/android/dialer/app/res/xml/sound_settings.xml @@ -71,4 +71,18 @@ + + + + + + diff --git a/java/com/android/dialer/app/settings/SoundSettingsFragment.java b/java/com/android/dialer/app/settings/SoundSettingsFragment.java index d9f24ab7e..f7fc0d0b6 100644 --- a/java/com/android/dialer/app/settings/SoundSettingsFragment.java +++ b/java/com/android/dialer/app/settings/SoundSettingsFragment.java @@ -37,6 +37,7 @@ import android.telephony.CarrierConfigManager; import android.telephony.TelephonyManager; import android.widget.Toast; import com.android.dialer.app.R; +import com.android.dialer.callrecord.impl.CallRecorderService; import com.android.dialer.util.SettingsUtil; public class SoundSettingsFragment extends PreferenceFragment @@ -140,6 +141,10 @@ public class SoundSettingsFragment extends PreferenceFragment getPreferenceScreen().removePreference(dtmfToneLength); dtmfToneLength = null; } + if (!CallRecorderService.isEnabled(getActivity())) { + getPreferenceScreen().removePreference( + findPreference(context.getString(R.string.call_recording_category_key))); + } notificationManager = context.getSystemService(NotificationManager.class); } diff --git a/java/com/android/dialer/binary/common/DialerApplication.java b/java/com/android/dialer/binary/common/DialerApplication.java index 31d4d828e..0f150253a 100644 --- a/java/com/android/dialer/binary/common/DialerApplication.java +++ b/java/com/android/dialer/binary/common/DialerApplication.java @@ -26,6 +26,7 @@ import com.android.dialer.calllog.CallLogComponent; import com.android.dialer.calllog.CallLogFramework; import com.android.dialer.calllog.config.CallLogConfig; import com.android.dialer.calllog.config.CallLogConfigComponent; +import com.android.dialer.callrecord.CallRecordingAutoMigrator; import com.android.dialer.common.LogUtil; import com.android.dialer.common.concurrent.DialerExecutorComponent; import com.android.dialer.inject.HasRootComponent; @@ -48,6 +49,10 @@ public abstract class DialerApplication extends Application implements HasRootCo new FilteredNumberAsyncQueryHandler(this), DialerExecutorComponent.get(this).dialerExecutorFactory()) .asyncAutoMigrate(); + new CallRecordingAutoMigrator( + this.getApplicationContext(), + DialerExecutorComponent.get(this).dialerExecutorFactory()) + .asyncAutoMigrate(); initializeAnnotatedCallLog(); PersistentLogger.initialize(this); diff --git a/java/com/android/dialer/calldetails/CallDetailsActivity.java b/java/com/android/dialer/calldetails/CallDetailsActivity.java index 36b830851..c1acbc373 100644 --- a/java/com/android/dialer/calldetails/CallDetailsActivity.java +++ b/java/com/android/dialer/calldetails/CallDetailsActivity.java @@ -28,6 +28,7 @@ import com.android.dialer.calldetails.CallDetailsFooterViewHolder.DeleteCallDeta import com.android.dialer.calldetails.CallDetailsFooterViewHolder.ReportCallIdListener; import com.android.dialer.calldetails.CallDetailsHeaderViewHolder.CallDetailsHeaderListener; import com.android.dialer.calllog.database.contract.AnnotatedCallLogContract.AnnotatedCallLog; +import com.android.dialer.callrecord.CallRecordingDataStore; import com.android.dialer.common.Assert; import com.android.dialer.enrichedcall.EnrichedCallComponent; import com.android.dialer.protos.ProtoParsers; @@ -93,7 +94,8 @@ public final class CallDetailsActivity extends CallDetailsActivityCommon { CallDetailsEntryListener callDetailsEntryListener, CallDetailsHeaderListener callDetailsHeaderListener, ReportCallIdListener reportCallIdListener, - DeleteCallDetailsListener deleteCallDetailsListener) { + DeleteCallDetailsListener deleteCallDetailsListener, + CallRecordingDataStore callRecordingDataStore) { return new CallDetailsAdapter( this, headerInfo, @@ -101,7 +103,8 @@ public final class CallDetailsActivity extends CallDetailsActivityCommon { callDetailsEntryListener, callDetailsHeaderListener, reportCallIdListener, - deleteCallDetailsListener); + deleteCallDetailsListener, + callRecordingDataStore); } @Override diff --git a/java/com/android/dialer/calldetails/CallDetailsActivityCommon.java b/java/com/android/dialer/calldetails/CallDetailsActivityCommon.java index a26f322dd..79e761368 100644 --- a/java/com/android/dialer/calldetails/CallDetailsActivityCommon.java +++ b/java/com/android/dialer/calldetails/CallDetailsActivityCommon.java @@ -38,6 +38,7 @@ import com.android.dialer.assisteddialing.ui.AssistedDialingSettingActivity; import com.android.dialer.calldetails.CallDetailsEntries.CallDetailsEntry; import com.android.dialer.callintent.CallInitiationType; import com.android.dialer.callintent.CallIntentBuilder; +import com.android.dialer.callrecord.CallRecordingDataStore; import com.android.dialer.common.Assert; import com.android.dialer.common.LogUtil; import com.android.dialer.common.concurrent.DialerExecutor.FailureListener; @@ -96,6 +97,7 @@ abstract class CallDetailsActivityCommon extends AppCompatActivity { private CallDetailsAdapterCommon adapter; private CallDetailsEntries callDetailsEntries; private UiListener> checkRttTranscriptAvailabilityListener; + private CallRecordingDataStore callRecordingDataStore; /** * Handles the intent that launches {@link OldCallDetailsActivity} or {@link CallDetailsActivity}, @@ -108,7 +110,8 @@ abstract class CallDetailsActivityCommon extends AppCompatActivity { CallDetailsEntryViewHolder.CallDetailsEntryListener callDetailsEntryListener, CallDetailsHeaderViewHolder.CallDetailsHeaderListener callDetailsHeaderListener, CallDetailsFooterViewHolder.ReportCallIdListener reportCallIdListener, - CallDetailsFooterViewHolder.DeleteCallDetailsListener deleteCallDetailsListener); + CallDetailsFooterViewHolder.DeleteCallDetailsListener deleteCallDetailsListener, + CallRecordingDataStore callRecordingDataStore); /** Returns the phone number of the call details. */ protected abstract String getNumber(); @@ -129,10 +132,18 @@ abstract class CallDetailsActivityCommon extends AppCompatActivity { checkRttTranscriptAvailabilityListener = DialerExecutorComponent.get(this) .createUiListener(getFragmentManager(), "Query RTT transcript availability"); + callRecordingDataStore = new CallRecordingDataStore(); handleIntent(getIntent()); setupRecyclerViewForEntries(); } + @Override + @CallSuper + protected void onDestroy() { + super.onDestroy(); + callRecordingDataStore.close(); + } + @Override @CallSuper protected void onResume() { @@ -205,7 +216,8 @@ abstract class CallDetailsActivityCommon extends AppCompatActivity { callDetailsEntryListener, callDetailsHeaderListener, reportCallIdListener, - deleteCallDetailsListener); + deleteCallDetailsListener, + callRecordingDataStore); RecyclerView recyclerView = findViewById(R.id.recycler_view); recyclerView.setLayoutManager(new LinearLayoutManager(this)); diff --git a/java/com/android/dialer/calldetails/CallDetailsAdapter.java b/java/com/android/dialer/calldetails/CallDetailsAdapter.java index 40d856fa7..7e5ebe170 100644 --- a/java/com/android/dialer/calldetails/CallDetailsAdapter.java +++ b/java/com/android/dialer/calldetails/CallDetailsAdapter.java @@ -23,6 +23,7 @@ import android.view.View; import com.android.dialer.calldetails.CallDetailsEntryViewHolder.CallDetailsEntryListener; import com.android.dialer.calldetails.CallDetailsFooterViewHolder.DeleteCallDetailsListener; import com.android.dialer.calldetails.CallDetailsHeaderViewHolder.CallDetailsHeaderListener; +import com.android.dialer.callrecord.CallRecordingDataStore; import com.android.dialer.glidephotomanager.PhotoInfo; /** @@ -43,14 +44,16 @@ final class CallDetailsAdapter extends CallDetailsAdapterCommon { CallDetailsEntryListener callDetailsEntryListener, CallDetailsHeaderListener callDetailsHeaderListener, CallDetailsFooterViewHolder.ReportCallIdListener reportCallIdListener, - DeleteCallDetailsListener deleteCallDetailsListener) { + DeleteCallDetailsListener deleteCallDetailsListener, + CallRecordingDataStore callRecordingDataStore) { super( context, callDetailsEntries, callDetailsEntryListener, callDetailsHeaderListener, reportCallIdListener, - deleteCallDetailsListener); + deleteCallDetailsListener, + callRecordingDataStore); this.headerInfo = calldetailsHeaderInfo; } diff --git a/java/com/android/dialer/calldetails/CallDetailsAdapterCommon.java b/java/com/android/dialer/calldetails/CallDetailsAdapterCommon.java index ec9263f1f..d33fea816 100644 --- a/java/com/android/dialer/calldetails/CallDetailsAdapterCommon.java +++ b/java/com/android/dialer/calldetails/CallDetailsAdapterCommon.java @@ -32,6 +32,7 @@ import com.android.dialer.calldetails.CallDetailsHeaderViewHolder.CallDetailsHea import com.android.dialer.calllogutils.CallTypeHelper; import com.android.dialer.calllogutils.CallbackActionHelper; import com.android.dialer.calllogutils.CallbackActionHelper.CallbackAction; +import com.android.dialer.callrecord.CallRecordingDataStore; import com.android.dialer.common.Assert; import com.android.dialer.duo.DuoComponent; import com.android.dialer.glidephotomanager.PhotoInfo; @@ -51,6 +52,7 @@ abstract class CallDetailsAdapterCommon extends RecyclerView.Adapter recordings; + if (CallRecorderService.isEnabled(context)) { + callRecordingDataStore.open(context); // opens unless already open + recordings = callRecordingDataStore.getRecordings(number, entry.getDate()); + } else { + recordings = null; + } + + int count = recordings != null ? recordings.size() : 0; + playbackButton.setOnClickListener(v -> handleRecordingClick(v, recordings)); + playbackButton.setText( + context.getResources().getQuantityString(R.plurals.play_recordings, count, count)); + playbackButton.setVisibility(count > 0 ? View.VISIBLE : View.GONE); + setMultimediaDetails(number, entry, showMultimediaDivider); if (isRttCall) { if (entry.getHasRttTranscript()) { @@ -209,6 +246,46 @@ public class CallDetailsEntryViewHolder extends ViewHolder { DialerUtils.startActivityWithErrorToast(context, IntentUtil.getSendSmsIntent(number)); } + private void handleRecordingClick(View v, List recordings) { + final Context context = v.getContext(); + if (recordings.size() == 1) { + playRecording(context, recordings.get(0)); + } else { + PopupMenu menu = new PopupMenu(context, v); + String pattern = DateFormat.getBestDateTimePattern(Locale.getDefault(), + DateFormat.is24HourFormat(context) ? "Hmss" : "hmssa"); + SimpleDateFormat format = new SimpleDateFormat(pattern); + + for (int i = 0; i < recordings.size(); i++) { + final long startTime = recordings.get(i).startRecordingTime; + final String formattedDate = format.format(new Date(startTime)); + menu.getMenu().add(Menu.NONE, i, i, formattedDate); + } + menu.setOnMenuItemClickListener(item -> { + playRecording(context, recordings.get(item.getItemId())); + return true; + }); + menu.show(); + } + } + + private void playRecording(Context context, CallRecording recording) { + Uri uri = ContentUris.withAppendedId( + MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, recording.mediaId); + String extension = MimeTypeMap.getFileExtensionFromUrl(recording.fileName); + String mime = !TextUtils.isEmpty(extension) + ? MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension) : "audio/*"; + try { + Intent intent = new Intent(Intent.ACTION_VIEW) + .setDataAndType(uri, mime) + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + context.startActivity(intent); + } catch (ActivityNotFoundException e) { + Toast.makeText(context, R.string.call_playback_no_app_found_toast, Toast.LENGTH_LONG) + .show(); + } + } + private static boolean isIncoming(@NonNull HistoryResult historyResult) { return historyResult.getType() == Type.INCOMING_POST_CALL || historyResult.getType() == Type.INCOMING_CALL_COMPOSER; diff --git a/java/com/android/dialer/calldetails/OldCallDetailsActivity.java b/java/com/android/dialer/calldetails/OldCallDetailsActivity.java index 26217ab8a..0f53d6908 100644 --- a/java/com/android/dialer/calldetails/OldCallDetailsActivity.java +++ b/java/com/android/dialer/calldetails/OldCallDetailsActivity.java @@ -22,6 +22,7 @@ import com.android.dialer.calldetails.CallDetailsEntryViewHolder.CallDetailsEntr import com.android.dialer.calldetails.CallDetailsFooterViewHolder.DeleteCallDetailsListener; import com.android.dialer.calldetails.CallDetailsFooterViewHolder.ReportCallIdListener; import com.android.dialer.calldetails.CallDetailsHeaderViewHolder.CallDetailsHeaderListener; +import com.android.dialer.callrecord.CallRecordingDataStore; import com.android.dialer.common.Assert; import com.android.dialer.dialercontact.DialerContact; import com.android.dialer.protos.ProtoParsers; @@ -80,7 +81,8 @@ public final class OldCallDetailsActivity extends CallDetailsActivityCommon { CallDetailsEntryListener callDetailsEntryListener, CallDetailsHeaderListener callDetailsHeaderListener, ReportCallIdListener reportCallIdListener, - DeleteCallDetailsListener deleteCallDetailsListener) { + DeleteCallDetailsListener deleteCallDetailsListener, + CallRecordingDataStore callRecordingDataStore) { return new OldCallDetailsAdapter( /* context = */ this, contact, @@ -88,7 +90,8 @@ public final class OldCallDetailsActivity extends CallDetailsActivityCommon { callDetailsEntryListener, callDetailsHeaderListener, reportCallIdListener, - deleteCallDetailsListener); + deleteCallDetailsListener, + callRecordingDataStore); } @Override diff --git a/java/com/android/dialer/calldetails/OldCallDetailsAdapter.java b/java/com/android/dialer/calldetails/OldCallDetailsAdapter.java index 878803cc3..af54538db 100644 --- a/java/com/android/dialer/calldetails/OldCallDetailsAdapter.java +++ b/java/com/android/dialer/calldetails/OldCallDetailsAdapter.java @@ -23,6 +23,7 @@ import android.view.View; import com.android.dialer.calldetails.CallDetailsEntryViewHolder.CallDetailsEntryListener; import com.android.dialer.calldetails.CallDetailsFooterViewHolder.DeleteCallDetailsListener; import com.android.dialer.calldetails.CallDetailsHeaderViewHolder.CallDetailsHeaderListener; +import com.android.dialer.callrecord.CallRecordingDataStore; import com.android.dialer.dialercontact.DialerContact; import com.android.dialer.glidephotomanager.PhotoInfo; import com.android.dialer.lettertile.LetterTileDrawable; @@ -45,14 +46,16 @@ final class OldCallDetailsAdapter extends CallDetailsAdapterCommon { CallDetailsEntryListener callDetailsEntryListener, CallDetailsHeaderListener callDetailsHeaderListener, CallDetailsFooterViewHolder.ReportCallIdListener reportCallIdListener, - DeleteCallDetailsListener deleteCallDetailsListener) { + DeleteCallDetailsListener deleteCallDetailsListener, + CallRecordingDataStore callRecordingDataStore) { super( context, callDetailsEntries, callDetailsEntryListener, callDetailsHeaderListener, reportCallIdListener, - deleteCallDetailsListener); + deleteCallDetailsListener, + callRecordingDataStore); this.contact = contact; } diff --git a/java/com/android/dialer/calldetails/res/drawable/recording_playback_button.xml b/java/com/android/dialer/calldetails/res/drawable/recording_playback_button.xml new file mode 100644 index 000000000..c6fb87f74 --- /dev/null +++ b/java/com/android/dialer/calldetails/res/drawable/recording_playback_button.xml @@ -0,0 +1,26 @@ + + + + + + + diff --git a/java/com/android/dialer/calldetails/res/layout/call_details_entry.xml b/java/com/android/dialer/calldetails/res/layout/call_details_entry.xml index bfbb4f8a9..ffe3ade5e 100644 --- a/java/com/android/dialer/calldetails/res/layout/call_details_entry.xml +++ b/java/com/android/dialer/calldetails/res/layout/call_details_entry.xml @@ -19,7 +19,8 @@ xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="wrap_content" - android:paddingTop="@dimen/call_entry_padding"> + android:paddingTop="@dimen/call_entry_padding" + android:paddingBottom="@dimen/call_entry_bottom_padding"> @@ -56,12 +56,27 @@ android:layout_marginEnd="@dimen/call_entry_padding" android:layout_alignParentEnd="true"/> + + - \ No newline at end of file + diff --git a/java/com/android/dialer/calldetails/res/values/cm_strings.xml b/java/com/android/dialer/calldetails/res/values/cm_strings.xml new file mode 100644 index 000000000..076a49479 --- /dev/null +++ b/java/com/android/dialer/calldetails/res/values/cm_strings.xml @@ -0,0 +1,23 @@ + + + + + Play recording + Play recordings + + No app could be found for playback of the selected recording. + diff --git a/java/com/android/dialer/callrecord/AndroidManifest.xml b/java/com/android/dialer/callrecord/AndroidManifest.xml new file mode 100644 index 000000000..5e25c7351 --- /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 000000000..e8d65bef2 --- /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 000000000..a887d1a56 --- /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, "Music/Call Recordings"); + 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/CallRecordingAutoMigrator.java b/java/com/android/dialer/callrecord/CallRecordingAutoMigrator.java new file mode 100644 index 000000000..81c16124f --- /dev/null +++ b/java/com/android/dialer/callrecord/CallRecordingAutoMigrator.java @@ -0,0 +1,157 @@ +/* + * Copyright (C) 2016 The Android Open Source Project + * Copyright (C) 2020 The LineageOS 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.annotation.TargetApi; +import android.content.ContentResolver; +import android.content.Context; +import android.content.pm.PackageManager; +import android.net.Uri; +import android.os.Build; +import android.os.Environment; +import android.provider.MediaStore; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.text.TextUtils; +import android.util.SparseArray; + +import com.android.dialer.common.Assert; +import com.android.dialer.common.LogUtil; +import com.android.dialer.common.concurrent.DialerExecutor.Worker; +import com.android.dialer.common.concurrent.DialerExecutorFactory; +import com.android.voicemail.impl.mail.utils.LogUtils; + +import org.apache.commons.io.IOUtils; + +import java.io.File; +import java.io.IOException; +import java.io.OutputStream; +import java.nio.file.Files; + +public class CallRecordingAutoMigrator { + private static final String TAG = "CallRecordingAutoMigrator"; + + @NonNull + private final Context appContext; + @NonNull private final DialerExecutorFactory dialerExecutorFactory; + + public CallRecordingAutoMigrator( + @NonNull Context appContext, + @NonNull DialerExecutorFactory dialerExecutorFactory) { + this.appContext = Assert.isNotNull(appContext); + this.dialerExecutorFactory = Assert.isNotNull(dialerExecutorFactory); + } + + public void asyncAutoMigrate() { + dialerExecutorFactory + .createNonUiTaskBuilder(new ShouldAttemptAutoMigrate(appContext)) + .onSuccess(this::autoMigrate) + .build() + .executeParallel(null); + } + + @TargetApi(26) + private void autoMigrate(boolean shouldAttemptAutoMigrate) { + if (!shouldAttemptAutoMigrate) { + return; + } + + final CallRecordingDataStore store = new CallRecordingDataStore(); + store.open(appContext); + + final ContentResolver cr = appContext.getContentResolver(); + final SparseArray oldRecordingData = store.getUnmigratedRecordingData(); + final File dir = Environment.getExternalStoragePublicDirectory("CallRecordings"); + for (File recording : dir.listFiles()) { + OutputStream os = null; + try { + // determine data store ID and call creation time of recording + int id = -1; + long creationTime = System.currentTimeMillis(); + for (int i = 0; i < oldRecordingData.size(); i++) { + if (TextUtils.equals(recording.getName(), oldRecordingData.valueAt(i).fileName)) { + creationTime = oldRecordingData.valueAt(i).creationTime; + id = oldRecordingData.keyAt(i); + break; + } + } + + // create media store entry for recording + Uri uri = cr.insert(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, + CallRecording.generateMediaInsertValues(recording.getName(), creationTime)); + os = cr.openOutputStream(uri); + + // copy file contents to media store stream + Files.copy(recording.toPath(), os); + + // insert media store id to store + if (id >= 0) { + store.updateMigratedRecording(id, Integer.parseInt(uri.getLastPathSegment())); + } + + // mark recording as complete + cr.update(uri, CallRecording.generateCompletedValues(), null, null); + + // delete file + LogUtils.i(TAG, "Successfully migrated recording " + recording + " (ID " + id + ")"); + recording.delete(); + } catch (IOException e) { + LogUtils.w(TAG, "Failed migrating call recording " + recording, e); + } finally { + if (os != null) { + IOUtils.closeQuietly(os); + } + } + } + + if (dir.listFiles().length == 0) { + dir.delete(); + } + + store.close(); + } + + private static class ShouldAttemptAutoMigrate implements Worker { + private final Context appContext; + + ShouldAttemptAutoMigrate(Context appContext) { + this.appContext = appContext; + } + + @Nullable + @Override + public Boolean doInBackground(@Nullable Void input) { + if (Build.VERSION.SDK_INT < 26) { + return false; + } + if (appContext.checkSelfPermission(android.Manifest.permission.WRITE_EXTERNAL_STORAGE) + != PackageManager.PERMISSION_GRANTED) { + LogUtil.i(TAG, "not attempting auto-migrate: no storage permission"); + return false; + } + + final File dir = Environment.getExternalStoragePublicDirectory("CallRecordings"); + if (!dir.exists()) { + LogUtil.i(TAG, "not attempting auto-migrate: no recordings present"); + return false; + } + + return true; + } + } +} diff --git a/java/com/android/dialer/callrecord/CallRecordingDataStore.java b/java/com/android/dialer/callrecord/CallRecordingDataStore.java new file mode 100644 index 000000000..88b603b54 --- /dev/null +++ b/java/com/android/dialer/callrecord/CallRecordingDataStore.java @@ -0,0 +1,224 @@ +/* + * 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.content.Context; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteException; +import android.database.sqlite.SQLiteOpenHelper; +import android.database.sqlite.SQLiteStatement; +import android.provider.BaseColumns; +import android.util.Log; +import android.util.SparseArray; + +import java.util.ArrayList; +import java.util.List; + +/** + * Persistent data store for call recordings. Usage: + * open() + * read/write operations + * close() + */ +public class CallRecordingDataStore { + private static final String TAG = "CallRecordingStore"; + private SQLiteOpenHelper mOpenHelper = null; + private SQLiteDatabase mDatabase = null; + + /** + * Open before reading/writing. Will not open handle if one is already open. + */ + public void open(Context context) { + if (mDatabase == null) { + mOpenHelper = new CallRecordingSQLiteOpenHelper(context); + mDatabase = mOpenHelper.getWritableDatabase(); + } + } + + /** + * close when finished reading/writing + */ + public void close() { + if (mDatabase != null) { + mDatabase.close(); + } + if (mOpenHelper != null) { + mOpenHelper.close(); + } + mDatabase = null; + mOpenHelper = null; + } + + /** + * Save a recording in the data store + * + * @param recording the recording to store + */ + public void putRecording(CallRecording recording) { + final String insertSql = "INSERT INTO " + + CallRecordingsContract.CallRecording.TABLE_NAME + " (" + + CallRecordingsContract.CallRecording.COLUMN_NAME_PHONE_NUMBER + ", " + + CallRecordingsContract.CallRecording.COLUMN_NAME_CALL_DATE + ", " + + CallRecordingsContract.CallRecording.COLUMN_NAME_RECORDING_FILENAME + ", " + + CallRecordingsContract.CallRecording.COLUMN_NAME_CREATION_DATE + ", " + + CallRecordingsContract.CallRecording.COLUMN_NAME_MEDIA_ID + ") " + + " VALUES (?, ?, ?, ?, ?)"; + + try { + SQLiteStatement stmt = mDatabase.compileStatement(insertSql); + int idx = 1; + stmt.bindString(idx++, recording.phoneNumber); + stmt.bindLong(idx++, recording.creationTime); + stmt.bindString(idx++, recording.fileName); + stmt.bindLong(idx++, System.currentTimeMillis()); + stmt.bindLong(idx++, recording.mediaId); + long id = stmt.executeInsert(); + Log.i(TAG, "Saved recording " + recording + " with id " + id); + } catch (SQLiteException e) { + Log.w(TAG, "Failed to save recording " + recording, e); + } + } + + /** + * Get all recordings associated with a phone call + * + * @param phoneNumber phone number no spaces + * @param callCreationDate time that the call was created + * @return list of recordings + */ + public List getRecordings(String phoneNumber, long callCreationDate) { + List resultList = new ArrayList(); + + final String query = "SELECT " + + CallRecordingsContract.CallRecording.COLUMN_NAME_RECORDING_FILENAME + "," + + CallRecordingsContract.CallRecording.COLUMN_NAME_CREATION_DATE + "," + + CallRecordingsContract.CallRecording.COLUMN_NAME_MEDIA_ID + + " FROM " + CallRecordingsContract.CallRecording.TABLE_NAME + + " WHERE " + CallRecordingsContract.CallRecording.COLUMN_NAME_PHONE_NUMBER + " = ?" + + " AND " + CallRecordingsContract.CallRecording.COLUMN_NAME_CALL_DATE + " = ?" + + " AND " + CallRecordingsContract.CallRecording.COLUMN_NAME_MEDIA_ID + " != 0" + + " ORDER BY " + CallRecordingsContract.CallRecording.COLUMN_NAME_CREATION_DATE; + + String args[] = { + phoneNumber, String.valueOf(callCreationDate) + }; + + try { + Cursor cursor = mDatabase.rawQuery(query, args); + while (cursor.moveToNext()) { + String fileName = cursor.getString(0); + long creationDate = cursor.getLong(1); + long mediaId = cursor.getLong(2); + // FIXME: need to check whether media entry still exists? + resultList.add( + new CallRecording(phoneNumber, callCreationDate, fileName, creationDate, mediaId)); + } + cursor.close(); + } catch (SQLiteException e) { + Log.w(TAG, "Failed to fetch recordings for number " + phoneNumber + + ", date " + callCreationDate, e); + } + + return resultList; + } + + public SparseArray getUnmigratedRecordingData() { + final String query = "SELECT " + + CallRecordingsContract.CallRecording._ID + "," + + CallRecordingsContract.CallRecording.COLUMN_NAME_PHONE_NUMBER + "," + + CallRecordingsContract.CallRecording.COLUMN_NAME_RECORDING_FILENAME + "," + + CallRecordingsContract.CallRecording.COLUMN_NAME_CREATION_DATE + + " FROM " + CallRecordingsContract.CallRecording.TABLE_NAME + + " WHERE " + CallRecordingsContract.CallRecording.COLUMN_NAME_MEDIA_ID + " == 0"; + final SparseArray result = new SparseArray<>(); + + try { + Cursor cursor = mDatabase.rawQuery(query, null); + while (cursor.moveToNext()) { + int id = cursor.getInt(0); + String phoneNumber = cursor.getString(1); + String fileName = cursor.getString(2); + long creationDate = cursor.getLong(3); + CallRecording recording = new CallRecording( + phoneNumber, creationDate, fileName, creationDate, 0); + result.put(id, recording); + } + cursor.close(); + } catch (SQLiteException e) { + Log.w(TAG, "Failed to fetch recordings for migration", e); + } + + return result; + } + + public void updateMigratedRecording(int id, int mediaId) { + ContentValues cv = new ContentValues(1); + cv.put(CallRecordingsContract.CallRecording.COLUMN_NAME_MEDIA_ID, mediaId); + mDatabase.update(CallRecordingsContract.CallRecording.TABLE_NAME, cv, + CallRecordingsContract.CallRecording._ID + " = ?", new String[] { String.valueOf(id) }); + } + + static class CallRecordingsContract { + static interface CallRecording extends BaseColumns { + static final String TABLE_NAME = "call_recordings"; + static final String COLUMN_NAME_PHONE_NUMBER = "phone_number"; + static final String COLUMN_NAME_CALL_DATE = "call_date"; + static final String COLUMN_NAME_RECORDING_FILENAME = "recording_filename"; + static final String COLUMN_NAME_CREATION_DATE = "creation_date"; + static final String COLUMN_NAME_MEDIA_ID = "media_id"; + } + } + + static class CallRecordingSQLiteOpenHelper extends SQLiteOpenHelper { + private static final int VERSION = 2; + private static final String DB_NAME = "callrecordings.db"; + + public CallRecordingSQLiteOpenHelper(Context context) { + super(context, DB_NAME, null, VERSION); + } + + @Override + public void onCreate(SQLiteDatabase db) { + db.execSQL("CREATE TABLE " + CallRecordingsContract.CallRecording.TABLE_NAME + " (" + + CallRecordingsContract.CallRecording._ID + " INTEGER PRIMARY KEY AUTOINCREMENT," + + CallRecordingsContract.CallRecording.COLUMN_NAME_PHONE_NUMBER + " TEXT," + + CallRecordingsContract.CallRecording.COLUMN_NAME_CALL_DATE + " LONG," + + CallRecordingsContract.CallRecording.COLUMN_NAME_RECORDING_FILENAME + " TEXT, " + + CallRecordingsContract.CallRecording.COLUMN_NAME_CREATION_DATE + " LONG," + + CallRecordingsContract.CallRecording.COLUMN_NAME_MEDIA_ID + " INTEGER DEFAULT 0" + + ");" + ); + + db.execSQL("CREATE INDEX IF NOT EXISTS phone_number_call_date_index ON " + + CallRecordingsContract.CallRecording.TABLE_NAME + " (" + + CallRecordingsContract.CallRecording.COLUMN_NAME_PHONE_NUMBER + ", " + + CallRecordingsContract.CallRecording.COLUMN_NAME_CALL_DATE + ");" + ); + } + + @Override + public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { + if (oldVersion < 2) { + db.execSQL("ALTER TABLE " + CallRecordingsContract.CallRecording.TABLE_NAME + + " ADD COLUMN " + CallRecordingsContract.CallRecording.COLUMN_NAME_MEDIA_ID + + " INTEGER DEFAULT 0;"); + } + } + } +} diff --git a/java/com/android/dialer/callrecord/ICallRecorderService.aidl b/java/com/android/dialer/callrecord/ICallRecorderService.aidl new file mode 100644 index 000000000..acbd5f8bb --- /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 000000000..298e8ade0 --- /dev/null +++ b/java/com/android/dialer/callrecord/impl/CallRecorderService.java @@ -0,0 +1,222 @@ +/* + * 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.Context; +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.provider.Settings; +import android.util.Log; + +import com.android.dialer.callrecord.CallRecording; +import com.android.dialer.callrecord.ICallRecorderService; + +import java.io.File; +import java.io.IOException; +import java.text.SimpleDateFormat; +import java.util.Date; + +import com.android.dialer.R; + +public class CallRecorderService extends Service { + private static final String TAG = "CallRecorderService"; + private static final boolean DBG = false; + + private MediaRecorder mMediaRecorder = null; + private CallRecording mCurrentRecording = null; + + private SimpleDateFormat DATE_FORMAT = new SimpleDateFormat("yyMMdd_HHmmssSSS"); + + 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 int getAudioSource() { + return getResources().getInteger(R.integer.call_recording_audio_source); + } + + private int getAudioFormatChoice() { + // 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"; + final SharedPreferences prefs = getSharedPreferences(prefName, MODE_MULTI_PROCESS); + + try { + String value = prefs.getString(getString(R.string.call_recording_format_key), null); + if (value != null) { + return Integer.parseInt(value); + } + } catch (NumberFormatException e) { + // ignore and fall through + } + return 0; + } + + private synchronized boolean startRecordingInternal(String phoneNumber, long creationTime) { + if (mMediaRecorder != null) { + if (DBG) { + Log.d(TAG, "Start called with recording in progress, stopping current recording"); + } + stopRecordingInternal(); + } + + if (checkSelfPermission(android.Manifest.permission.RECORD_AUDIO) + != PackageManager.PERMISSION_GRANTED) { + Log.w(TAG, "Record audio permission not granted, can't record call"); + return false; + } + + if (DBG) Log.d(TAG, "Starting recording"); + + mMediaRecorder = new MediaRecorder(); + try { + int audioSource = getAudioSource(); + int formatChoice = getAudioFormatChoice(); + if (DBG) Log.d(TAG, "Creating media recorder with audio source " + audioSource); + mMediaRecorder.setAudioSource(audioSource); + mMediaRecorder.setOutputFormat(formatChoice == 0 + ? MediaRecorder.OutputFormat.AMR_WB : MediaRecorder.OutputFormat.MPEG_4); + mMediaRecorder.setAudioEncoder(formatChoice == 0 + ? MediaRecorder.AudioEncoder.AMR_WB : MediaRecorder.AudioEncoder.AAC); + } catch (IllegalStateException e) { + Log.w(TAG, "Error initializing media recorder", e); + mMediaRecorder.reset(); + mMediaRecorder.release(); + mMediaRecorder = null; + return false; + } + + String fileName = generateFilename(phoneNumber); + Uri uri = getContentResolver().insert(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, + CallRecording.generateMediaInsertValues(fileName, creationTime)); + + 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.w(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.w(TAG, "Could not start recording", e); + } else { + throw e; + } + } + + mMediaRecorder.reset(); + mMediaRecorder.release(); + mMediaRecorder = null; + + return false; + } + + private synchronized CallRecording stopRecordingInternal() { + CallRecording recording = mCurrentRecording; + if (DBG) Log.d(TAG, "Stopping current recording"); + if (mMediaRecorder != null) { + try { + mMediaRecorder.stop(); + mMediaRecorder.reset(); + mMediaRecorder.release(); + } catch (IllegalStateException e) { + Log.e(TAG, "Exception closing media recorder", e); + } + + Uri uri = ContentUris.withAppendedId( + MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, mCurrentRecording.mediaId); + getContentResolver().update(uri, CallRecording.generateCompletedValues(), null, null); + + mMediaRecorder = null; + mCurrentRecording = null; + } + return recording; + } + + @Override + public void onDestroy() { + super.onDestroy(); + if (DBG) Log.d(TAG, "Destroying CallRecorderService"); + } + + private String generateFilename(String number) { + String timestamp = DATE_FORMAT.format(new Date()); + + if (TextUtils.isEmpty(number)) { + number = "unknown"; + } + + int formatChoice = getAudioFormatChoice(); + String extension = formatChoice == 0 ? ".amr" : ".m4a"; + return number + "_" + timestamp + extension; + } + + public static boolean isEnabled(Context context) { + return context.getResources().getBoolean(R.bool.call_recording_enabled); + } +} diff --git a/java/com/android/dialer/callrecord/res/values/config.xml b/java/com/android/dialer/callrecord/res/values/config.xml new file mode 100644 index 000000000..7aabd6cac --- /dev/null +++ b/java/com/android/dialer/callrecord/res/values/config.xml @@ -0,0 +1,23 @@ + + + + + false + + 1 + diff --git a/java/com/android/dialer/callrecord/res/xml/call_record_states.xml b/java/com/android/dialer/callrecord/res/xml/call_record_states.xml new file mode 100644 index 000000000..9907fe9e2 --- /dev/null +++ b/java/com/android/dialer/callrecord/res/xml/call_record_states.xml @@ -0,0 +1,1324 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/java/com/android/incallui/CallButtonPresenter.java b/java/com/android/incallui/CallButtonPresenter.java index 2a9600a2b..cff283c21 100644 --- a/java/com/android/incallui/CallButtonPresenter.java +++ b/java/com/android/incallui/CallButtonPresenter.java @@ -16,9 +16,13 @@ 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; @@ -40,6 +44,7 @@ import com.android.incallui.InCallPresenter.IncomingCallListener; 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 +67,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 +112,9 @@ public class CallButtonPresenter 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 +130,10 @@ public class CallButtonPresenter 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) { @@ -296,6 +329,52 @@ public class CallButtonPresenter 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"); @@ -482,6 +561,10 @@ public class CallButtonPresenter && call.getState() != DialerCallState.DIALING && call.getState() != DialerCallState.CONNECTING; + final CallRecorder recorder = CallRecorder.getInstance(); + final boolean showCallRecordOption = recorder.canRecordInCurrentCountry() + && !isVideo && call.getState() == DialerCallState.ACTIVE; + otherAccount = TelecomUtil.getOtherAccount(getContext(), call.getAccountHandle()); boolean showSwapSim = !call.isEmergencyCall() @@ -515,6 +598,7 @@ public class CallButtonPresenter } 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 b9d0eccba..b2d318f5d 100644 --- a/java/com/android/incallui/InCallServiceImpl.java +++ b/java/com/android/incallui/InCallServiceImpl.java @@ -27,6 +27,7 @@ import com.android.dialer.blocking.FilteredNumberAsyncQueryHandler; 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 class InCallServiceImpl extends InCallService { 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 634a302a2..8428c8484 100644 --- a/java/com/android/incallui/call/CallList.java +++ b/java/com/android/incallui/call/CallList.java @@ -26,6 +26,7 @@ import android.support.annotation.VisibleForTesting; 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 class CallList implements DialerCallDelegate { 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 000000000..867d5a57c --- /dev/null +++ b/java/com/android/incallui/call/CallRecorder.java @@ -0,0 +1,336 @@ +/* + * 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.CallRecordingDataStore; +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, + android.Manifest.permission.WRITE_EXTERNAL_STORAGE + }; + private static final HashMap RECORD_ALLOWED_STATE_BY_COUNTRY = new HashMap<>(); + + 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; + } + + public boolean isEnabled() { + return CallRecorderService.isEnabled(context); + } + + public boolean canRecordInCurrentCountry() { + if (!isEnabled()) { + return false; + } + if (RECORD_ALLOWED_STATE_BY_COUNTRY.isEmpty()) { + loadAllowedStates(); + } + + String currentCountryIso = GeoUtil.getCurrentCountryIso(context); + Boolean allowedState = RECORD_ALLOWED_STATE_BY_COUNTRY.get(currentCountryIso); + + return allowedState != null && allowedState; + } + + private CallRecorder() { + CallList.getInstance().addListener(this); + } + + public void setUp(Context context) { + this.context = context.getApplicationContext(); + } + + private void initialize() { + if (isEnabled() && !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)) { + new Thread(() -> { + CallRecordingDataStore dataStore = new CallRecordingDataStore(); + dataStore.open(context); + dataStore.putRecording(recording); + dataStore.close(); + }).start(); + } else { + // Data store is an index by number so that we can link recordings in the + // call detail page. If phone number is not available (conference call or + // unknown number) then just display a toast. + 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) {} + + @Override + public void onSessionModificationStateChange(DialerCall call) {} + + @Override + public void onWiFiToLteHandover(DialerCall call) {} + + @Override + public void onHandoverToWifiFailed(DialerCall call) {} + + @Override + public void onInternationalCallOnWifi(DialerCall call) {} + + // 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 static final int UPDATE_INTERVAL = 500; + + 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); + } + }; + + private void loadAllowedStates() { + XmlResourceParser parser = context.getResources().getXml(R.xml.call_record_states); + try { + // Consume all START_DOCUMENT which can appear more than once. + while (parser.next() == XmlPullParser.START_DOCUMENT) {} + + parser.require(XmlPullParser.START_TAG, null, "call-record-allowed-flags"); + + while (parser.next() != XmlPullParser.END_DOCUMENT) { + if (parser.getEventType() != XmlPullParser.START_TAG) { + continue; + } + parser.require(XmlPullParser.START_TAG, null, "country"); + + String iso = parser.getAttributeValue(null, "iso"); + String allowed = parser.getAttributeValue(null, "allowed"); + if (iso != null && ("true".equals(allowed) || "false".equals(allowed))) { + for (String splittedIso : iso.split(",")) { + RECORD_ALLOWED_STATE_BY_COUNTRY.put( + splittedIso.toUpperCase(Locale.US), Boolean.valueOf(allowed)); + } + } else { + throw new XmlPullParserException("Unexpected country specification", parser, null); + } + } + Log.d(TAG, "Loaded " + RECORD_ALLOWED_STATE_BY_COUNTRY.size() + " country records"); + } catch (XmlPullParserException | IOException e) { + Log.e(TAG, "Could not parse allowed country list", e); + RECORD_ALLOWED_STATE_BY_COUNTRY.clear(); + } finally { + parser.close(); + } + } +} diff --git a/java/com/android/incallui/callpending/CallPendingActivity.java b/java/com/android/incallui/callpending/CallPendingActivity.java index 4086e1419..5177783b0 100644 --- a/java/com/android/incallui/callpending/CallPendingActivity.java +++ b/java/com/android/incallui/callpending/CallPendingActivity.java @@ -284,6 +284,9 @@ public class CallPendingActivity extends FragmentActivity @Override public void swapSimClicked() {} + @Override + public void callRecordClicked(boolean checked) {} + @Override public Context getContext() { return CallPendingActivity.this; diff --git a/java/com/android/incallui/incall/impl/ButtonChooserFactory.java b/java/com/android/incallui/incall/impl/ButtonChooserFactory.java index 757d81352..733dcf96d 100644 --- a/java/com/android/incallui/incall/impl/ButtonChooserFactory.java +++ b/java/com/android/incallui/incall/impl/ButtonChooserFactory.java @@ -117,9 +117,10 @@ class ButtonChooserFactory { 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 328ebbe67..2ad3d3e6d 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.NonNull; 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 @@ interface ButtonController { } } + class CallRecordButtonController implements ButtonController, OnClickListener { + @NonNull private final InCallButtonUiDelegate delegate; + private boolean isEnabled; + private boolean isAllowed; + 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(isAllowed ? View.VISIBLE : View.INVISIBLE); + 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 bfc2781a9..ec932b9dc 100644 --- a/java/com/android/incallui/incall/impl/CheckableLabeledButton.java +++ b/java/com/android/incallui/incall/impl/CheckableLabeledButton.java @@ -156,6 +156,10 @@ public class CheckableLabeledButton extends LinearLayout implements Checkable { 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 30620699a..336550deb 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; 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 @@ public class InCallFragment extends Fragment || 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 class InCallFragment extends Fragment 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 class InCallFragment extends Fragment 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 80ea75e99..2c956c8be 100644 --- a/java/com/android/incallui/incall/protocol/InCallButtonIds.java +++ b/java/com/android/incallui/incall/protocol/InCallButtonIds.java @@ -38,6 +38,7 @@ import java.lang.annotation.RetentionPolicy; 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 @@ public @interface InCallButtonIds { 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 5239d9d34..ee03c3d41 100644 --- a/java/com/android/incallui/incall/protocol/InCallButtonIdsExtension.java +++ b/java/com/android/incallui/incall/protocol/InCallButtonIdsExtension.java @@ -58,6 +58,8 @@ public class InCallButtonIdsExtension { 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 28dd84c42..f22efeb2b 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); + void setCallRecordingState(boolean isRecording); + + void setCallRecordingDuration(long durationMs); + + 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 4cf40ef6a..4e25ff098 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(); + void callRecordClicked(boolean checked); + Context getContext(); } diff --git a/java/com/android/incallui/res/values/cm_strings.xml b/java/com/android/incallui/res/values/cm_strings.xml new file mode 100644 index 000000000..5e65ffc3b --- /dev/null +++ b/java/com/android/incallui/res/values/cm_strings.xml @@ -0,0 +1,26 @@ + + + + Failed to start call recording + Saved call recording to %s + Record call + Recording call - %1$s + Stop recording + Recording + Enable call recording? + Notice: You are 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. + diff --git a/java/com/android/incallui/rtt/impl/RttChatFragment.java b/java/com/android/incallui/rtt/impl/RttChatFragment.java index 649e80840..3e76f1f2b 100644 --- a/java/com/android/incallui/rtt/impl/RttChatFragment.java +++ b/java/com/android/incallui/rtt/impl/RttChatFragment.java @@ -595,4 +595,13 @@ public class RttChatFragment extends Fragment @Override public void onAudioRouteSelectorDismiss() {} + + @Override + public void requestCallRecordingPermissions(String[] permissions) {} + + @Override + public void setCallRecordingDuration(long duration) {} + + @Override + public void setCallRecordingState(boolean isRecording) {} } diff --git a/java/com/android/incallui/video/impl/SurfaceViewVideoCallFragment.java b/java/com/android/incallui/video/impl/SurfaceViewVideoCallFragment.java index 89db07903..07965b985 100644 --- a/java/com/android/incallui/video/impl/SurfaceViewVideoCallFragment.java +++ b/java/com/android/incallui/video/impl/SurfaceViewVideoCallFragment.java @@ -796,6 +796,18 @@ public class SurfaceViewVideoCallFragment extends Fragment updateMutePreviewOverlayVisibility(); } + @Override + public void setCallRecordingState(boolean isRecording) { + } + + @Override + public void setCallRecordingDuration(long durationMs) { + } + + @Override + public void requestCallRecordingPermissions(String[] permissions) { + } + @Override public void updateButtonStates() { LogUtil.i("SurfaceViewVideoCallFragment.updateButtonState", null); diff --git a/java/com/android/incallui/video/impl/VideoCallFragment.java b/java/com/android/incallui/video/impl/VideoCallFragment.java index edfc17c46..3fbce5c76 100644 --- a/java/com/android/incallui/video/impl/VideoCallFragment.java +++ b/java/com/android/incallui/video/impl/VideoCallFragment.java @@ -908,6 +908,18 @@ public class VideoCallFragment extends Fragment updateMutePreviewOverlayVisibility(); } + @Override + public void setCallRecordingState(boolean isRecording) { + } + + @Override + public void setCallRecordingDuration(long durationMs) { + } + + @Override + public void requestCallRecordingPermissions(String[] permissions) { + } + @Override public void updateButtonStates() { LogUtil.i("VideoCallFragment.updateButtonState", null); diff --git a/privapp_whitelist_com.android.dialer-ext.xml b/privapp_whitelist_com.android.dialer-ext.xml new file mode 100644 index 000000000..09e6e0fda --- /dev/null +++ b/privapp_whitelist_com.android.dialer-ext.xml @@ -0,0 +1,22 @@ + + + + + + + + -- cgit v1.2.3