package net.iptux.xposed.callrecording; import android.content.Context; import android.database.Cursor; import android.media.MediaRecorder; import android.net.Uri; import android.os.Build; import android.provider.ContactsContract; import android.text.TextUtils; import android.view.View; import java.io.File; import static de.robv.android.xposed.XposedHelpers.findAndHookMethod; import static de.robv.android.xposed.XposedHelpers.getObjectField; import de.robv.android.xposed.IXposedHookLoadPackage; import de.robv.android.xposed.XC_MethodHook; import de.robv.android.xposed.XposedBridge; import de.robv.android.xposed.XposedHelpers; import de.robv.android.xposed.callbacks.XC_LoadPackage.LoadPackageParam; public class ModCallRecording implements IXposedHookLoadPackage { private static final String PACKAGE_DIALER = "com.android.dialer"; private static final String CALL_RECORDING_SERVICE = "com.android.services.callrecorder.CallRecorderService"; private static final String CALL_BUTTON_PRESENTER = "com.android.incallui.CallButtonPresenter"; private static final String CALL_BUTTON_FRAGMENT = "com.android.incallui.CallButtonFragment"; private static final String CALL_RECORDING_LISTENER = "com.android.incallui.CallRecorder"; private static final String CALL_RECORDING_LISTENER_LOS15 = "com.android.incallui.call.CallRecorder"; private static final String CALL_RECORDING_SERVICE_LOS15 = "com.android.dialer.callrecord.impl.CallRecorderService"; private static final String CALL_BUTTON_FRAGMENT_LOS15 = "com.android.incallui.incall.impl.ButtonController.CallRecordButtonController"; private static final String CALL_STATE_NO_CALLS = "NO_CALLS"; private static final String CALL_STATE_INCALL = "INCALL"; private static final String CALL_STATE_INCOMING = "INCOMING"; private static final String CALL_STATE_OUTGOING = "OUTGOING"; private static final String CALL_STATE_PENDING_OUTGOING = "PENDING_OUTGOING"; private static Object sCallButtonFragment = null; private static int sCallingStateParamIndex = 1; private static String sCallingState = CALL_STATE_NO_CALLS; private static boolean sRecordIncoming = false; private static boolean sRecordOutgoing = false; private static String sRecordButtonFieldName = null; private static Settings sSettings = Settings.getInstance(); @Override public void handleLoadPackage(LoadPackageParam lpparam) throws Throwable { if (PACKAGE_DIALER.equals(lpparam.packageName)) { Utility.d("handleLoadPackage: packageName=%s", lpparam.packageName); String callRecordingListener; String callRecordingServiceName; String callButtonFragment; int version = Build.VERSION.SDK_INT; if (version >= Build.VERSION_CODES.O) { callRecordingListener = CALL_RECORDING_LISTENER_LOS15; callRecordingServiceName = CALL_RECORDING_SERVICE_LOS15; callButtonFragment = CALL_BUTTON_FRAGMENT_LOS15; } else { callRecordingListener = CALL_RECORDING_LISTENER; callRecordingServiceName = CALL_RECORDING_SERVICE; callButtonFragment = CALL_BUTTON_FRAGMENT; } findAndHookMethod(callRecordingServiceName, lpparam.classLoader, "isEnabled", Context.class, new XC_MethodHook() { @Override protected void afterHookedMethod(XC_MethodHook.MethodHookParam param) throws Throwable { sSettings.reload(); if (sSettings.isRecordEnable()) { param.setResult(Boolean.TRUE); } } }); try { findAndHookMethod(callRecordingListener, lpparam.classLoader, "canRecordInCurrentCountry", new XC_MethodHook() { @Override protected void afterHookedMethod(XC_MethodHook.MethodHookParam param) throws Throwable { // This method is used in place of isEnabled in later versions sSettings.reload(); if (sSettings.isRecordEnable()) { param.setResult(Boolean.TRUE); } } }); } catch (Throwable e) { // ignored } findAndHookMethod(callRecordingServiceName, lpparam.classLoader, "getAudioSource", new XC_MethodHook() { @Override protected void afterHookedMethod(XC_MethodHook.MethodHookParam param) throws Throwable { sSettings.reload(); if (sSettings.forceAudioSource()) { param.setResult(MediaRecorder.AudioSource.VOICE_CALL); } } }); findAndHookMethod(callRecordingServiceName, lpparam.classLoader, "generateFilename", String.class, new XC_MethodHook() { @Override protected void afterHookedMethod(XC_MethodHook.MethodHookParam param) throws Throwable { String result = (String) param.getResult(); String trim = result.trim(); sSettings.reload(); if (sSettings.isPrependContactName() || sSettings.isSeparateFolder()) { Context context = (Context) param.thisObject; String number = (String) param.args[0]; String name = getContactName(context, number); if (!TextUtils.isEmpty(name)) { if (sSettings.isPrependContactName()) { trim = name + '_' + trim; } number = name; } if (sSettings.isSeparateFolder()) { File folder = new File(Utility.getRecordingFolder(), number); if (folder.exists() || folder.mkdirs()) { trim = number + File.separator + trim; } } } param.setResult(trim); } }); final Class<?> CallButtonPresenter = XposedHelpers.findClass(CALL_BUTTON_PRESENTER, lpparam.classLoader); XposedBridge.hookAllMethods(CallButtonPresenter, "onStateChange", new XC_MethodHook() { @Override protected void afterHookedMethod(XC_MethodHook.MethodHookParam param) throws Throwable { updateCallState(param.args[sCallingStateParamIndex]); } }); if (version > Build.VERSION_CODES.P) { // not support } else if (version >= Build.VERSION_CODES.O) { sRecordButtonFieldName = "button"; } else if (version >= Build.VERSION_CODES.N_MR1) { sRecordButtonFieldName = "mCallRecordButton"; } else if (version >= Build.VERSION_CODES.N) { sRecordButtonFieldName = "mRecordButton"; } else if (version >= Build.VERSION_CODES.LOLLIPOP) { sRecordButtonFieldName = "mCallRecordButton"; } else if (version >= Build.VERSION_CODES.KITKAT) { sRecordButtonFieldName = "mRecordButton"; sCallingStateParamIndex = 0; } else { // not support } final Class<?> CallButtonFragment = XposedHelpers.findClass(callButtonFragment, lpparam.classLoader); XposedBridge.hookAllMethods(CallButtonFragment, "setEnabled", new XC_MethodHook() { @Override protected void afterHookedMethod(XC_MethodHook.MethodHookParam param) throws Throwable { boolean isEnabled = (boolean) param.args[0]; sCallButtonFragment = isEnabled ? param.thisObject : null; } }); if (version > Build.VERSION_CODES.P) { // not support } else if (version >= Build.VERSION_CODES.M) { findAndHookMethod(callRecordingServiceName, lpparam.classLoader, "getAudioFormatChoice", new XC_MethodHook() { @Override protected void afterHookedMethod(XC_MethodHook.MethodHookParam param) throws Throwable { sSettings.reload(); if (sSettings.isAACFormat()) { param.setResult(1); } } }); } else if (version >= Build.VERSION_CODES.KITKAT) { findAndHookMethod(callRecordingServiceName, lpparam.classLoader, "getAudioFormat", new XC_MethodHook() { @Override protected void afterHookedMethod(XC_MethodHook.MethodHookParam param) throws Throwable { sSettings.reload(); if (sSettings.isAACFormat()) { param.setResult(MediaRecorder.OutputFormat.MPEG_4); } } }); findAndHookMethod(callRecordingServiceName, lpparam.classLoader, "getAudioEncoder", new XC_MethodHook() { @Override protected void afterHookedMethod(XC_MethodHook.MethodHookParam param) throws Throwable { sSettings.reload(); if (sSettings.isAACFormat()) { // original code use HE_AAC, see https://review.cyanogenmod.org/147231 param.setResult(MediaRecorder.AudioEncoder.AAC); } } }); } else { // not support } } } String getContactName(Context context, String number) { if (null == context || TextUtils.isEmpty(number)) { return null; } Uri lookupUri = Uri.withAppendedPath(ContactsContract.PhoneLookup.CONTENT_FILTER_URI, Uri.encode(number)); Cursor cursor = null; try { cursor = context.getContentResolver().query(lookupUri, new String[] {ContactsContract.PhoneLookup.DISPLAY_NAME}, null, null, null); if (null == cursor || cursor.getCount() == 0) { return null; } cursor.moveToNext(); String name = cursor.getString(cursor.getColumnIndex(ContactsContract.PhoneLookup.DISPLAY_NAME)); return name; } finally { if (null != cursor) { cursor.close(); } } } void updateCallState(Object state) throws Throwable { String newState = state.toString(); if (sCallingState.equals(newState)) return; Utility.d("updateCallState: %s -> %s", sCallingState, newState); if (CALL_STATE_INCALL.equals(newState)) { if (CALL_STATE_INCOMING.equals(sCallingState)) { sRecordIncoming = true; } else if (CALL_STATE_OUTGOING.equals(sCallingState)) { sRecordOutgoing = true; } else if (CALL_STATE_NO_CALLS.equals(sCallingState)) { // NO_CALLS -> INCALL, must be incoming call sRecordIncoming = true; } else if (CALL_STATE_PENDING_OUTGOING.equals(sCallingState)) { // PENDING_OUTGOING -> INCALL sRecordOutgoing = true; } else { Utility.log("unexpected state: %s -> %s", sCallingState, newState); Utility.log("if you see this, please report to developer"); } startRecordingOnDemand(sCallButtonFragment); } else if (CALL_STATE_NO_CALLS.equals(newState)) { sRecordIncoming = false; sRecordOutgoing = false; } sCallingState = newState; } void startRecordingOnDemand(Object obj) throws Throwable { if (null == obj) { return; } sSettings.reload(); if (sRecordIncoming && sSettings.isRecordIncoming()) { clickView(obj, sRecordButtonFieldName, sSettings.getRecordDelay()); sRecordIncoming = false; } if (sRecordOutgoing && sSettings.isRecordOutgoing()) { clickView(obj, sRecordButtonFieldName, sSettings.getRecordDelay()); sRecordOutgoing = false; } } void clickView(Object obj, String name, long delayMillis) throws Throwable { View view = (View) getObjectField(obj, name); Utility.d("clickView: name=%s, delayMillis=%d", name, delayMillis); if (null == view) return; if (!Utility.isExternalStorageAvailable()) { String warning = "External storage not available, recording may not start!"; Utility.showToast(view.getContext(), warning); } view.postDelayed(new ViewClicker(view, name), delayMillis); } class ViewClicker implements Runnable { View view; String name; ViewClicker(View view, String name) { this.view = view; this.name = name; } @Override public void run() { if (!view.isEnabled()) { Utility.log("tried to click a disabled view: %s", name); } try { view.performClick(); Utility.d("view %s clicked", name); } catch (Throwable e) { XposedBridge.log(e); } } } }