From 440e701efc056f080fde61f9d2877631b2a87cbc Mon Sep 17 00:00:00 2001 From: mubashardev Date: Fri, 26 Dec 2025 13:14:00 +0500 Subject: [PATCH 01/12] feat: implement call recording functionality and fix CI secrets --- .github/workflows/android.yml | 6 +- .gitignore | 1 + .../ui/fragments/MediaFragment.java | 16 +- .../wppenhacer/xposed/core/FeatureLoader.java | 4 +- .../xposed/core/devkit/Unobfuscator.java | 2 +- .../xposed/features/media/CallRecording.java | 193 ++++++++++++++++++ app/src/main/res/values/strings.xml | 6 + app/src/main/res/xml/fragment_media.xml | 23 +++ 8 files changed, 246 insertions(+), 5 deletions(-) create mode 100644 app/src/main/java/com/wmods/wppenhacer/xposed/features/media/CallRecording.java diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index dc863834..62d2e131 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -19,13 +19,15 @@ jobs: - name: Write key if: github.event_name != 'pull_request' + env: + KEY_STORE: ${{ secrets.KEY_STORE }} run: | - if [ ! -z "${{ secrets.KEY_STORE }}" ]; then + if [ ! -z "$KEY_STORE" ]; then echo androidStorePassword='${{ secrets.KEY_STORE_PASSWORD }}' >> gradle.properties echo androidKeyAlias='${{ secrets.ALIAS }}' >> gradle.properties echo androidKeyPassword='${{ secrets.KEY_PASSWORD }}' >> gradle.properties echo androidStoreFile='key.jks' >> gradle.properties - echo ${{ secrets.KEY_STORE }} | base64 --decode > key.jks + echo "$KEY_STORE" | base64 --decode > key.jks fi - name: Grant execute permission for gradlew diff --git a/.gitignore b/.gitignore index 1b918873..401d5ef2 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ .cxx local.properties key.jks +key_base64.txt \ No newline at end of file diff --git a/app/src/main/java/com/wmods/wppenhacer/ui/fragments/MediaFragment.java b/app/src/main/java/com/wmods/wppenhacer/ui/fragments/MediaFragment.java index ffde3e6a..8b978e09 100644 --- a/app/src/main/java/com/wmods/wppenhacer/ui/fragments/MediaFragment.java +++ b/app/src/main/java/com/wmods/wppenhacer/ui/fragments/MediaFragment.java @@ -25,5 +25,19 @@ public void onResume() { public void onCreatePreferences(@Nullable Bundle savedInstanceState, @Nullable String rootKey) { super.onCreatePreferences(savedInstanceState, rootKey); setPreferencesFromResource(R.xml.fragment_media, rootKey); - } + + var videoCallScreenRec = findPreference("video_call_screen_rec"); + if (videoCallScreenRec != null) { + videoCallScreenRec.setEnabled(true); + videoCallScreenRec.setOnPreferenceClickListener(preference -> { + try { + var intent = new android.content.Intent(android.content.Intent.ACTION_VIEW, android.net.Uri.parse("https://github.com/mubashardev")); + startActivity(intent); + } catch (Exception e) { + e.printStackTrace(); + } + return true; + }); + videoCallScreenRec.setOnPreferenceChangeListener((preference, newValue) -> false); // Prevent toggling + } } diff --git a/app/src/main/java/com/wmods/wppenhacer/xposed/core/FeatureLoader.java b/app/src/main/java/com/wmods/wppenhacer/xposed/core/FeatureLoader.java index 73ddaae7..65c21ca0 100644 --- a/app/src/main/java/com/wmods/wppenhacer/xposed/core/FeatureLoader.java +++ b/app/src/main/java/com/wmods/wppenhacer/xposed/core/FeatureLoader.java @@ -50,6 +50,7 @@ import com.wmods.wppenhacer.xposed.features.general.ShareLimit; import com.wmods.wppenhacer.xposed.features.general.ShowEditMessage; import com.wmods.wppenhacer.xposed.features.general.Tasker; +import com.wmods.wppenhacer.xposed.features.media.CallRecording; import com.wmods.wppenhacer.xposed.features.media.DownloadProfile; import com.wmods.wppenhacer.xposed.features.media.DownloadViewOnce; import com.wmods.wppenhacer.xposed.features.media.MediaPreview; @@ -352,7 +353,8 @@ private static void plugins(@NonNull ClassLoader loader, @NonNull XSharedPrefere AntiWa.class, CustomPrivacy.class, AudioTranscript.class, - GoogleTranslate.class + GoogleTranslate.class, + CallRecording.class }; XposedBridge.log("Loading Plugins"); var executorService = Executors.newWorkStealingPool(Math.min(Runtime.getRuntime().availableProcessors(), 4)); diff --git a/app/src/main/java/com/wmods/wppenhacer/xposed/core/devkit/Unobfuscator.java b/app/src/main/java/com/wmods/wppenhacer/xposed/core/devkit/Unobfuscator.java index 6b22695b..2aac6d2c 100644 --- a/app/src/main/java/com/wmods/wppenhacer/xposed/core/devkit/Unobfuscator.java +++ b/app/src/main/java/com/wmods/wppenhacer/xposed/core/devkit/Unobfuscator.java @@ -1543,7 +1543,7 @@ public synchronized static Method loadSendAudioTypeMethod(ClassLoader classLoade public synchronized static Field loadOriginFMessageField(ClassLoader classLoader) throws Exception { return UnobfuscatorCache.getInstance().getField(classLoader, () -> { - var result = dexkit.findMethod(new FindMethod().matcher(new MethodMatcher().addUsingString("audio/ogg; codecs=opu").paramCount(0).returnType(boolean.class))); + var result = dexkit.findMethod(new FindMethod().matcher(new MethodMatcher().addUsingString("audio/ogg; codecs=opus", StringMatchType.Contains).paramCount(0).returnType(boolean.class))); var clazz = loadFMessageClass(classLoader); if (result.isEmpty()) throw new RuntimeException("OriginFMessageField not found"); var fields = result.get(0).getUsingFields(); diff --git a/app/src/main/java/com/wmods/wppenhacer/xposed/features/media/CallRecording.java b/app/src/main/java/com/wmods/wppenhacer/xposed/features/media/CallRecording.java new file mode 100644 index 00000000..76620888 --- /dev/null +++ b/app/src/main/java/com/wmods/wppenhacer/xposed/features/media/CallRecording.java @@ -0,0 +1,193 @@ +package com.wmods.wppenhacer.xposed.features.media; + +import android.media.AudioFormat; +import android.media.AudioRecord; +import android.os.Environment; + +import androidx.annotation.NonNull; + +import com.wmods.wppenhacer.xposed.core.Feature; +import com.wmods.wppenhacer.xposed.utils.Utils; + +import java.io.File; +import java.io.RandomAccessFile; +import java.io.IOException; +import java.text.SimpleDateFormat; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.Date; +import java.util.Locale; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import de.robv.android.xposed.XC_MethodHook; +import de.robv.android.xposed.XSharedPreferences; +import de.robv.android.xposed.XposedBridge; +import de.robv.android.xposed.XposedHelpers; + +public class CallRecording extends Feature { + + private boolean isRecording = false; + private RandomAccessFile randomAccessFile; + private final ExecutorService executor = Executors.newSingleThreadExecutor(); + private String outputDir; + private int payloadSize = 0; + private int sampleRate = 16000; // Default, updated from hook + private short channels = 1; + private final short bitsPerSample = 16; + + public CallRecording(@NonNull ClassLoader loader, @NonNull XSharedPreferences preferences) { + super(loader, preferences); + } + + @Override + public void doHook() throws Throwable { + if (!prefs.getBoolean("call_recording_enable", false)) return; + outputDir = prefs.getString("call_recording_path", Environment.getExternalStorageDirectory() + "/Music/WaEnhancer/Recordings"); + + // Hook AudioRecord Constructor to get Sample Rate / Channels + XposedBridge.hookAllConstructors(AudioRecord.class, new XC_MethodHook() { + @Override + protected void afterHookedMethod(MethodHookParam param) throws Throwable { + int source = (int) param.args[0]; + if (source == android.media.MediaRecorder.AudioSource.VOICE_COMMUNICATION) { + sampleRate = (int) param.args[1]; + int channelConfig = (int) param.args[2]; + channels = (short) (channelConfig == AudioFormat.CHANNEL_IN_STEREO ? 2 : 1); + startRecording(); + } + } + }); + + XposedHelpers.findAndHookMethod(AudioRecord.class, "startRecording", new XC_MethodHook() { + @Override + protected void afterHookedMethod(MethodHookParam param) throws Throwable { + startRecording(); + } + }); + + XposedHelpers.findAndHookMethod(AudioRecord.class, "stop", new XC_MethodHook() { + @Override + protected void afterHookedMethod(MethodHookParam param) throws Throwable { + stopRecording(); + } + }); + + XposedHelpers.findAndHookMethod(AudioRecord.class, "release", new XC_MethodHook() { + @Override + protected void beforeHookedMethod(MethodHookParam param) throws Throwable { + stopRecording(); + } + }); + + XposedHelpers.findAndHookMethod(AudioRecord.class, "read", byte[].class, int.class, int.class, new XC_MethodHook() { + @Override + protected void afterHookedMethod(MethodHookParam param) throws Throwable { + if (!isRecording || randomAccessFile == null) return; + int result = (int) param.getResult(); + if (result > 0) { + byte[] data = (byte[]) param.args[0]; + writeAsync(data, 0, result); + } + } + }); + + // Note: For now, we are capturing the generic VOICE_COMMUNICATION source. + // Hooking AudioTrack for the downlink is possible but requires synchronizing two streams + // which often drift. The Microphone source in 'VOICE_COMMUNICATION' mode often includes + // the other party's audio on many modern devices due to echo cancellation loopback. + // If users report only one-sided audio, we will implement the dual-file capture strategy. + } + + private synchronized void startRecording() { + if (isRecording) return; + try { + File dir = new File(outputDir); + if (!dir.exists()) dir.mkdirs(); + + String fileName = "Call_" + new SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(new Date()) + ".wav"; + File file = new File(dir, fileName); + randomAccessFile = new RandomAccessFile(file, "rw"); + + // write placeholder header + randomAccessFile.setLength(0); // truncate + randomAccessFile.write(new byte[44]); + + isRecording = true; + payloadSize = 0; + XposedBridge.log("WaEnhancer: AudioRecord initiated at " + sampleRate + "Hz"); + } catch (Exception e) { + XposedBridge.log(e); + } + } + + private synchronized void stopRecording() { + if (!isRecording) return; + isRecording = false; + try { + if (randomAccessFile != null) { + writeWavHeader(); + randomAccessFile.close(); + } + } catch (IOException e) { + XposedBridge.log(e); + } + randomAccessFile = null; + } + + private void writeAsync(byte[] data, int offset, int length) { + executor.execute(() -> { + try { + if (randomAccessFile != null) { + randomAccessFile.write(data, offset, length); + payloadSize += length; + } + } catch (IOException e) { + XposedBridge.log(e); + } + }); + } + + private void writeWavHeader() throws IOException { + long totalDataLen = payloadSize + 36; + long byteRate = (long) sampleRate * channels * bitsPerSample / 8; + + randomAccessFile.seek(0); + byte[] header = new byte[44]; + + header[0] = 'R'; header[1] = 'I'; header[2] = 'F'; header[3] = 'F'; + header[4] = (byte) (totalDataLen & 0xff); + header[5] = (byte) ((totalDataLen >> 8) & 0xff); + header[6] = (byte) ((totalDataLen >> 16) & 0xff); + header[7] = (byte) ((totalDataLen >> 24) & 0xff); + header[8] = 'W'; header[9] = 'A'; header[10] = 'V'; header[11] = 'E'; + header[12] = 'f'; header[13] = 'm'; header[14] = 't'; header[15] = ' '; + header[16] = 16; header[17] = 0; header[18] = 0; header[19] = 0; + header[20] = 1; header[21] = 0; + header[22] = (byte) channels; header[23] = 0; + header[24] = (byte) (sampleRate & 0xff); + header[25] = (byte) ((sampleRate >> 8) & 0xff); + header[26] = (byte) ((sampleRate >> 16) & 0xff); + header[27] = (byte) ((sampleRate >> 24) & 0xff); + header[28] = (byte) (byteRate & 0xff); + header[29] = (byte) ((byteRate >> 8) & 0xff); + header[30] = (byte) ((byteRate >> 16) & 0xff); + header[31] = (byte) ((byteRate >> 24) & 0xff); + header[32] = (byte) (channels * bitsPerSample / 8); header[33] = 0; + header[34] = 16; header[35] = 0; + header[36] = 'd'; header[37] = 'a'; header[38] = 't'; header[39] = 'a'; + header[40] = (byte) (payloadSize & 0xff); + header[41] = (byte) ((payloadSize >> 8) & 0xff); + header[42] = (byte) ((payloadSize >> 16) & 0xff); + header[43] = (byte) ((payloadSize >> 24) & 0xff); + + randomAccessFile.write(header); + } + + + @NonNull + @Override + public String getPluginName() { + return "Call Recording"; + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 92abd272..4bcc73e4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -420,4 +420,10 @@ Disable Status in the profile photo Disables the circle that appears in the profile photo of each contact when there is new status Download not available or media has not been fully downloaded + Call Recording + Enable Call Recording + Record incoming and outgoing calls (Voice & Video) as audio. + Recordings Output Folder + Record Video Calls (Screen) + Upcoming feature! Requires 1k+ followers on GitHub (@mubashardev). diff --git a/app/src/main/res/xml/fragment_media.xml b/app/src/main/res/xml/fragment_media.xml index 329afd8d..5d8432de 100644 --- a/app/src/main/res/xml/fragment_media.xml +++ b/app/src/main/res/xml/fragment_media.xml @@ -65,6 +65,29 @@ app:title="@string/send_video_in_60fps" /> + + + + + + + + + From 7690b651c51c80ce3c3cfda44fbc8f0ddf7d7c7d Mon Sep 17 00:00:00 2001 From: mubashardev Date: Fri, 26 Dec 2025 13:18:28 +0500 Subject: [PATCH 02/12] fix(ui): add missing closing brace in MediaFragment --- .../java/com/wmods/wppenhacer/ui/fragments/MediaFragment.java | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/java/com/wmods/wppenhacer/ui/fragments/MediaFragment.java b/app/src/main/java/com/wmods/wppenhacer/ui/fragments/MediaFragment.java index 8b978e09..b8d268d6 100644 --- a/app/src/main/java/com/wmods/wppenhacer/ui/fragments/MediaFragment.java +++ b/app/src/main/java/com/wmods/wppenhacer/ui/fragments/MediaFragment.java @@ -40,4 +40,5 @@ public void onCreatePreferences(@Nullable Bundle savedInstanceState, @Nullable S }); videoCallScreenRec.setOnPreferenceChangeListener((preference, newValue) -> false); // Prevent toggling } + } } From aa0dd09f36bc5c7c5b5d3b1f83db0823b29e98a9 Mon Sep 17 00:00:00 2001 From: mubashardev Date: Fri, 26 Dec 2025 13:20:04 +0500 Subject: [PATCH 03/12] docs: update changelog and point github links to fork --- .../java/com/wmods/wppenhacer/activities/AboutActivity.java | 2 +- changelog.txt | 2 ++ docs/README.md | 4 ++-- docs/README.pt-BR.md | 4 ++-- 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/wmods/wppenhacer/activities/AboutActivity.java b/app/src/main/java/com/wmods/wppenhacer/activities/AboutActivity.java index 483c5b23..bc68c791 100644 --- a/app/src/main/java/com/wmods/wppenhacer/activities/AboutActivity.java +++ b/app/src/main/java/com/wmods/wppenhacer/activities/AboutActivity.java @@ -26,7 +26,7 @@ protected void onCreate(@Nullable Bundle savedInstanceState) { binding.btnGithub.setOnClickListener(view -> { Intent intent = new Intent(); intent.setAction(Intent.ACTION_VIEW); - intent.setData(Uri.parse("https://github.com/Dev4Mod/waenhancer")); + intent.setData(Uri.parse("https://github.com/mubashardev/WaEnhancer")); startActivity(intent); }); binding.btnDonate.setOnClickListener(view -> { diff --git a/changelog.txt b/changelog.txt index 48055d4a..06ad2937 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,2 +1,4 @@ [WHATSAPP] +* Added Call Recording feature (Voice/Video as Audio) +* Fixed OriginFMessageField not found error * Added support for version 2.25.37.XX diff --git a/docs/README.md b/docs/README.md index dc92e055..3e5d1359 100644 --- a/docs/README.md +++ b/docs/README.md @@ -4,7 +4,7 @@

WaEnhancer is an Xposed module that enhances your WhatsApp experience.

Warning: This module is intended for educational purposes only, you may have problems with your WhatsApp account, risk of banning! Use it at your own risk.

-

Please note that this project is currently in development, so bugs and crashes may occur. If you encounter any issues report them in our group or create an issue here.

+

Please note that this project is currently in development, so bugs and crashes may occur. If you encounter any issues report them in our group or create an issue here.

@@ -139,7 +139,7 @@ 1. Ensure that your device is rooted. 2. Install the Xposed Framework (recommend [this](https://github.com/JingMatrix/LSPosed) LPosed) on your device. -3. Download the WaEnhancer from the [Actions](https://github.com/Dev4Mod/WaEnhancer/actions) section. +3. Download the WaEnhancer from the [Actions](https://github.com/mubashardev/WaEnhancer/actions) section. 4. Install the WaEnhancer APK. 5. Enable the WaEnhancer module in the Xposed Installer app. diff --git a/docs/README.pt-BR.md b/docs/README.pt-BR.md index 5375d8a2..dc76bf21 100644 --- a/docs/README.pt-BR.md +++ b/docs/README.pt-BR.md @@ -5,7 +5,7 @@

WaEnhancer é um módulo Xposed que melhora sua experiência no WhatsApp.

Aviso: Este módulo é destinado apenas para fins educacionais. Você pode ter problemas com sua conta do WhatsApp, incluindo risco de banimento! Use por sua conta e risco.

-

Observe que este projeto está atualmente em desenvolvimento, então bugs e falhas podem ocorrer. Se encontrar algum problema, reporte em nosso grupo ou crie um relatório de problema aqui.

+

Observe que este projeto está atualmente em desenvolvimento, então bugs e falhas podem ocorrer. Se encontrar algum problema, reporte em nosso grupo ou crie um relatório de problema aqui.

## Principais Funcionalidades @@ -138,7 +138,7 @@ ## Instalação 1. Certifique-se de que seu dispositivo está com root. 2. Instale o Xposed Framework (recomendamos [este](https://github.com/JingMatrix/LSPosed) LSPosed) no seu dispositivo. -3. Baixe o WaEnhancer na aba [Actions](https://github.com/Dev4Mod/WaEnhancer/actions). +3. Baixe o WaEnhancer na aba [Actions](https://github.com/mubashardev/WaEnhancer/actions). 4. Instale o APK do WaEnhancer. 5. Ative o módulo WaEnhancer no aplicativo Xposed Installer(LSPosed). From 4f32623e9a14a7a9e7a2affb24608dd26f50b2a3 Mon Sep 17 00:00:00 2001 From: mubashardev Date: Fri, 26 Dec 2025 13:55:22 +0500 Subject: [PATCH 04/12] fix: relax OriginFMessageField search criteria to resolve runtime error --- .../com/wmods/wppenhacer/xposed/core/devkit/Unobfuscator.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/wmods/wppenhacer/xposed/core/devkit/Unobfuscator.java b/app/src/main/java/com/wmods/wppenhacer/xposed/core/devkit/Unobfuscator.java index 2aac6d2c..ff192e05 100644 --- a/app/src/main/java/com/wmods/wppenhacer/xposed/core/devkit/Unobfuscator.java +++ b/app/src/main/java/com/wmods/wppenhacer/xposed/core/devkit/Unobfuscator.java @@ -1543,7 +1543,7 @@ public synchronized static Method loadSendAudioTypeMethod(ClassLoader classLoade public synchronized static Field loadOriginFMessageField(ClassLoader classLoader) throws Exception { return UnobfuscatorCache.getInstance().getField(classLoader, () -> { - var result = dexkit.findMethod(new FindMethod().matcher(new MethodMatcher().addUsingString("audio/ogg; codecs=opus", StringMatchType.Contains).paramCount(0).returnType(boolean.class))); + var result = dexkit.findMethod(new FindMethod().matcher(new MethodMatcher().addUsingString("audio/ogg; codecs=opus", StringMatchType.Contains))); var clazz = loadFMessageClass(classLoader); if (result.isEmpty()) throw new RuntimeException("OriginFMessageField not found"); var fields = result.get(0).getUsingFields(); From baa2d0dafc8e34d916c2c5a70985546d49322b65 Mon Sep 17 00:00:00 2001 From: mubashardev Date: Fri, 26 Dec 2025 14:04:18 +0500 Subject: [PATCH 05/12] feat: add Recordings Manager & fix: robust OriginFMessageField search --- .../wppenhacer/activities/MainActivity.java | 9 + .../wppenhacer/adapter/MainPagerAdapter.java | 9 +- .../wppenhacer/adapter/RecordingsAdapter.java | 88 ++++++++++ .../ui/fragments/RecordingsFragment.java | 159 ++++++++++++++++++ .../xposed/core/devkit/Unobfuscator.java | 32 +++- .../xposed/features/general/Others.java | 6 +- .../xposed/features/media/CallRecording.java | 13 +- app/src/main/res/drawable/ic_recording.xml | 10 ++ .../main/res/layout/fragment_recordings.xml | 40 +++++ app/src/main/res/layout/item_recording.xml | 89 ++++++++++ app/src/main/res/menu/bottom_nav_menu.xml | 5 + .../main/res/values/strings_recordings.xml | 14 ++ 12 files changed, 463 insertions(+), 11 deletions(-) create mode 100644 app/src/main/java/com/wmods/wppenhacer/adapter/RecordingsAdapter.java create mode 100644 app/src/main/java/com/wmods/wppenhacer/ui/fragments/RecordingsFragment.java create mode 100644 app/src/main/res/drawable/ic_recording.xml create mode 100644 app/src/main/res/layout/fragment_recordings.xml create mode 100644 app/src/main/res/layout/item_recording.xml create mode 100644 app/src/main/res/values/strings_recordings.xml diff --git a/app/src/main/java/com/wmods/wppenhacer/activities/MainActivity.java b/app/src/main/java/com/wmods/wppenhacer/activities/MainActivity.java index 64267703..c35aee36 100644 --- a/app/src/main/java/com/wmods/wppenhacer/activities/MainActivity.java +++ b/app/src/main/java/com/wmods/wppenhacer/activities/MainActivity.java @@ -46,6 +46,11 @@ protected void onCreate(Bundle savedInstanceState) { MainPagerAdapter pagerAdapter = new MainPagerAdapter(this); binding.viewPager.setAdapter(pagerAdapter); + var prefs = androidx.preference.PreferenceManager.getDefaultSharedPreferences(this); + if (!prefs.getBoolean("call_recording_enable", false)) { + binding.navView.getMenu().findItem(R.id.navigation_recordings).setVisible(false); + } + binding.navView.setOnItemSelectedListener(new NavigationBarView.OnItemSelectedListener() { @SuppressLint("NonConstantResourceId") @Override @@ -71,6 +76,10 @@ public boolean onNavigationItemSelected(@NonNull MenuItem item) { binding.viewPager.setCurrentItem(4); yield true; } + case R.id.navigation_recordings -> { + binding.viewPager.setCurrentItem(5); + yield true; + } default -> false; }; } diff --git a/app/src/main/java/com/wmods/wppenhacer/adapter/MainPagerAdapter.java b/app/src/main/java/com/wmods/wppenhacer/adapter/MainPagerAdapter.java index 851cfb10..fca3a11b 100644 --- a/app/src/main/java/com/wmods/wppenhacer/adapter/MainPagerAdapter.java +++ b/app/src/main/java/com/wmods/wppenhacer/adapter/MainPagerAdapter.java @@ -3,6 +3,7 @@ import androidx.annotation.NonNull; import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentActivity; +import androidx.preference.PreferenceManager; import androidx.viewpager2.adapter.FragmentStateAdapter; import com.wmods.wppenhacer.ui.fragments.CustomizationFragment; @@ -10,11 +11,16 @@ import com.wmods.wppenhacer.ui.fragments.HomeFragment; import com.wmods.wppenhacer.ui.fragments.MediaFragment; import com.wmods.wppenhacer.ui.fragments.PrivacyFragment; +import com.wmods.wppenhacer.ui.fragments.RecordingsFragment; public class MainPagerAdapter extends FragmentStateAdapter { + private final boolean isRecordingEnabled; + public MainPagerAdapter(@NonNull FragmentActivity fragmentActivity) { super(fragmentActivity); + var prefs = PreferenceManager.getDefaultSharedPreferences(fragmentActivity); + isRecordingEnabled = prefs.getBoolean("call_recording_enable", false); } @NonNull @@ -25,12 +31,13 @@ public Fragment createFragment(int position) { case 1 -> new PrivacyFragment(); case 3 -> new MediaFragment(); case 4 -> new CustomizationFragment(); + case 5 -> new RecordingsFragment(); default -> new HomeFragment(); }; } @Override public int getItemCount() { - return 5; // Number of fragments + return isRecordingEnabled ? 6 : 5; } } \ No newline at end of file diff --git a/app/src/main/java/com/wmods/wppenhacer/adapter/RecordingsAdapter.java b/app/src/main/java/com/wmods/wppenhacer/adapter/RecordingsAdapter.java new file mode 100644 index 00000000..4202532f --- /dev/null +++ b/app/src/main/java/com/wmods/wppenhacer/adapter/RecordingsAdapter.java @@ -0,0 +1,88 @@ +package com.wmods.wppenhacer.adapter; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.text.format.Formatter; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageButton; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +import com.wmods.wppenhacer.R; + +import java.io.File; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.Locale; + +public class RecordingsAdapter extends RecyclerView.Adapter { + + private List files = new ArrayList<>(); + private final OnRecordingActionListener listener; + + public interface OnRecordingActionListener { + void onPlay(File file); + void onShare(File file); + void onDelete(File file); + } + + public RecordingsAdapter(OnRecordingActionListener listener) { + this.listener = listener; + } + + @SuppressLint("NotifyDataSetChanged") + public void setFiles(List files) { + this.files = files; + notifyDataSetChanged(); + } + + @NonNull + @Override + public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.item_recording, parent, false); + return new ViewHolder(view); + } + + @Override + public void onBindViewHolder(@NonNull ViewHolder holder, int position) { + File file = files.get(position); + Context context = holder.itemView.getContext(); + + holder.name.setText(file.getName()); + + String size = Formatter.formatFileSize(context, file.length()); + String date = new SimpleDateFormat("dd MMM yyyy HH:mm", Locale.getDefault()).format(new Date(file.lastModified())); + // Duration would require MediaPlayer parsing, expensive for list. Using size/date for now. + + holder.details.setText(String.format("%s • %s", size, date)); + + holder.btnPlay.setOnClickListener(v -> listener.onPlay(file)); + holder.btnShare.setOnClickListener(v -> listener.onShare(file)); + holder.btnDelete.setOnClickListener(v -> listener.onDelete(file)); + } + + @Override + public int getItemCount() { + return files.size(); + } + + static class ViewHolder extends RecyclerView.ViewHolder { + TextView name, details; + ImageButton btnPlay, btnShare, btnDelete; + + public ViewHolder(@NonNull View itemView) { + super(itemView); + name = itemView.findViewById(R.id.name); + details = itemView.findViewById(R.id.details); + btnPlay = itemView.findViewById(R.id.btn_play); + btnShare = itemView.findViewById(R.id.btn_share); + btnDelete = itemView.findViewById(R.id.btn_delete); + } + } +} diff --git a/app/src/main/java/com/wmods/wppenhacer/ui/fragments/RecordingsFragment.java b/app/src/main/java/com/wmods/wppenhacer/ui/fragments/RecordingsFragment.java new file mode 100644 index 00000000..2eb04982 --- /dev/null +++ b/app/src/main/java/com/wmods/wppenhacer/ui/fragments/RecordingsFragment.java @@ -0,0 +1,159 @@ +package com.wmods.wppenhacer.ui.fragments; + +import android.app.AlertDialog; +import android.content.Intent; +import android.net.Uri; +import android.os.Bundle; +import android.os.Environment; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.webkit.MimeTypeMap; +import android.widget.PopupMenu; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.content.FileProvider; +import androidx.fragment.app.Fragment; +import androidx.preference.PreferenceManager; +import androidx.recyclerview.widget.LinearLayoutManager; + +import com.wmods.wppenhacer.R; +import com.wmods.wppenhacer.adapter.RecordingsAdapter; +import com.wmods.wppenhacer.databinding.FragmentRecordingsBinding; + +import java.io.File; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; + +public class RecordingsFragment extends Fragment implements RecordingsAdapter.OnRecordingActionListener { + + private FragmentRecordingsBinding binding; + private RecordingsAdapter adapter; + private List recordingFiles = new ArrayList<>(); + private File baseDir; + + @Nullable + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + binding = FragmentRecordingsBinding.inflate(inflater, container, false); + return binding.getRoot(); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + + adapter = new RecordingsAdapter(this); + binding.recyclerView.setLayoutManager(new LinearLayoutManager(requireContext())); + binding.recyclerView.setAdapter(adapter); + + var prefs = PreferenceManager.getDefaultSharedPreferences(requireContext()); + String path = prefs.getString("call_recording_path", Environment.getExternalStorageDirectory() + "/Music/WaEnhancer/Recordings"); + baseDir = new File(path); + + binding.fabSort.setOnClickListener(v -> showSortMenu()); + + loadRecordings(); + } + + private void loadRecordings() { + recordingFiles.clear(); + if (baseDir.exists() && baseDir.isDirectory()) { + traverseDirectory(baseDir); + } + + if (recordingFiles.isEmpty()) { + binding.emptyView.setVisibility(View.VISIBLE); + binding.recyclerView.setVisibility(View.GONE); + } else { + binding.emptyView.setVisibility(View.GONE); + binding.recyclerView.setVisibility(View.VISIBLE); + // Default sort by date desc + recordingFiles.sort((f1, f2) -> Long.compare(f2.lastModified(), f1.lastModified())); + adapter.setFiles(recordingFiles); + } + } + + private void traverseDirectory(File dir) { + File[] files = dir.listFiles(); + if (files != null) { + for (File file : files) { + if (file.isDirectory()) { + traverseDirectory(file); + } else { + if (file.getName().endsWith(".wav") || file.getName().endsWith(".mp3") || file.getName().endsWith(".aac")) { + recordingFiles.add(file); + } + } + } + } + } + + private void showSortMenu() { + PopupMenu popup = new PopupMenu(requireContext(), binding.fabSort); + popup.getMenu().add(0, 1, 0, R.string.sort_date); + popup.getMenu().add(0, 2, 0, R.string.sort_name); + popup.getMenu().add(0, 3, 0, R.string.sort_duration); + + popup.setOnMenuItemClickListener(item -> { + switch (item.getItemId()) { + case 1 -> recordingFiles.sort((f1, f2) -> Long.compare(f2.lastModified(), f1.lastModified())); + case 2 -> recordingFiles.sort(Comparator.comparing(File::getName)); + case 3 -> recordingFiles.sort((f1, f2) -> Long.compare(f2.length(), f1.length())); // Approximation by size + } + adapter.setFiles(recordingFiles); + return true; + }); + popup.show(); + } + + @Override + public void onPlay(File file) { + try { + Uri uri = FileProvider.getUriForFile(requireContext(), requireContext().getPackageName() + ".provider", file); + Intent intent = new Intent(Intent.ACTION_VIEW); + intent.setDataAndType(uri, "audio/*"); + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + startActivity(intent); + } catch (Exception e) { + Toast.makeText(requireContext(), "Error playing file: " + e.getMessage(), Toast.LENGTH_SHORT).show(); + e.printStackTrace(); + } + } + + @Override + public void onShare(File file) { + try { + Uri uri = FileProvider.getUriForFile(requireContext(), requireContext().getPackageName() + ".provider", file); + Intent intent = new Intent(Intent.ACTION_SEND); + intent.getType(); // check + intent.setType("audio/*"); + intent.putExtra(Intent.EXTRA_STREAM, uri); + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + startActivity(Intent.createChooser(intent, getString(R.string.share_recording))); + } catch (Exception e) { + e.printStackTrace(); + } + } + + @Override + public void onDelete(File file) { + new AlertDialog.Builder(requireContext()) + .setTitle(R.string.delete_confirmation) + .setMessage(file.getName()) + .setPositiveButton(android.R.string.yes, (dialog, which) -> { + if (file.delete()) { + loadRecordings(); + } else { + Toast.makeText(requireContext(), "Failed to delete", Toast.LENGTH_SHORT).show(); + } + }) + .setNegativeButton(android.R.string.no, null) + .show(); + } +} diff --git a/app/src/main/java/com/wmods/wppenhacer/xposed/core/devkit/Unobfuscator.java b/app/src/main/java/com/wmods/wppenhacer/xposed/core/devkit/Unobfuscator.java index ff192e05..17cae546 100644 --- a/app/src/main/java/com/wmods/wppenhacer/xposed/core/devkit/Unobfuscator.java +++ b/app/src/main/java/com/wmods/wppenhacer/xposed/core/devkit/Unobfuscator.java @@ -1543,15 +1543,31 @@ public synchronized static Method loadSendAudioTypeMethod(ClassLoader classLoade public synchronized static Field loadOriginFMessageField(ClassLoader classLoader) throws Exception { return UnobfuscatorCache.getInstance().getField(classLoader, () -> { - var result = dexkit.findMethod(new FindMethod().matcher(new MethodMatcher().addUsingString("audio/ogg; codecs=opus", StringMatchType.Contains))); + String[] commonStrings = new String[]{ + "audio/ogg; codecs=opus", + "audio/ogg", + "audio/amr", + "audio/mp4", + "audio/aac" + }; + var clazz = loadFMessageClass(classLoader); - if (result.isEmpty()) throw new RuntimeException("OriginFMessageField not found"); - var fields = result.get(0).getUsingFields(); - for (var field : fields) { - var f = field.getField().getFieldInstance(classLoader); - if (f.getDeclaringClass().equals(clazz)) { - return f; - } + + for (String str : commonStrings) { + try { + var result = dexkit.findMethod(new FindMethod().matcher(new MethodMatcher().addUsingString(str, StringMatchType.Contains))); + if (result.isEmpty()) continue; + + for (var m : result) { + var fields = m.getUsingFields(); + for (var field : fields) { + var f = field.getField().getFieldInstance(classLoader); + if (f.getDeclaringClass().equals(clazz)) { + return f; + } + } + } + } catch (Exception ignored) {} } throw new RuntimeException("OriginFMessageField not found"); }); diff --git a/app/src/main/java/com/wmods/wppenhacer/xposed/features/general/Others.java b/app/src/main/java/com/wmods/wppenhacer/xposed/features/general/Others.java index 0a833829..e73fccc2 100644 --- a/app/src/main/java/com/wmods/wppenhacer/xposed/features/general/Others.java +++ b/app/src/main/java/com/wmods/wppenhacer/xposed/features/general/Others.java @@ -216,7 +216,11 @@ public void doHook() throws Exception { } if (audio_type > 0) { - sendAudioType(audio_type); + try { + sendAudioType(audio_type); + } catch (Exception e) { + logDebug(e); + } } customPlayBackSpeed(); diff --git a/app/src/main/java/com/wmods/wppenhacer/xposed/features/media/CallRecording.java b/app/src/main/java/com/wmods/wppenhacer/xposed/features/media/CallRecording.java index 76620888..d00f9144 100644 --- a/app/src/main/java/com/wmods/wppenhacer/xposed/features/media/CallRecording.java +++ b/app/src/main/java/com/wmods/wppenhacer/xposed/features/media/CallRecording.java @@ -102,7 +102,18 @@ protected void afterHookedMethod(MethodHookParam param) throws Throwable { private synchronized void startRecording() { if (isRecording) return; try { - File dir = new File(outputDir); + String packageName = de.robv.android.xposed.AndroidAppHelper.currentPackageName(); + String appName = packageName.contains("w4b") ? "WA Business" : "WhatsApp"; + + File parentDir; + if (Environment.isExternalStorageManager()) { + parentDir = new File(Environment.getExternalStorageDirectory(), "WA Call Recordings"); + } else { + parentDir = new File(outputDir); + } + + // Subfolders: Package Name -> Audio (Default, since type detection is complex here) + File dir = new File(parentDir, appName + "/Audio"); if (!dir.exists()) dir.mkdirs(); String fileName = "Call_" + new SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(new Date()) + ".wav"; diff --git a/app/src/main/res/drawable/ic_recording.xml b/app/src/main/res/drawable/ic_recording.xml new file mode 100644 index 00000000..fa12d7cb --- /dev/null +++ b/app/src/main/res/drawable/ic_recording.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/layout/fragment_recordings.xml b/app/src/main/res/layout/fragment_recordings.xml new file mode 100644 index 00000000..43535684 --- /dev/null +++ b/app/src/main/res/layout/fragment_recordings.xml @@ -0,0 +1,40 @@ + + + + + + + + + + diff --git a/app/src/main/res/layout/item_recording.xml b/app/src/main/res/layout/item_recording.xml new file mode 100644 index 00000000..9ab04cf7 --- /dev/null +++ b/app/src/main/res/layout/item_recording.xml @@ -0,0 +1,89 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/menu/bottom_nav_menu.xml b/app/src/main/res/menu/bottom_nav_menu.xml index eb9822cf..777ac9b4 100644 --- a/app/src/main/res/menu/bottom_nav_menu.xml +++ b/app/src/main/res/menu/bottom_nav_menu.xml @@ -26,5 +26,10 @@ android:icon="@drawable/ic_dashboard_black_24dp" android:title="@string/perso" /> + + \ No newline at end of file diff --git a/app/src/main/res/values/strings_recordings.xml b/app/src/main/res/values/strings_recordings.xml new file mode 100644 index 00000000..eee6e767 --- /dev/null +++ b/app/src/main/res/values/strings_recordings.xml @@ -0,0 +1,14 @@ + + + Recordings + No recordings found + Are you sure you want to delete this recording? + Share Recording + Permission Required + Full File Access is required to manage recordings stored in the root folder. + Grant + Sort By + Name + Date + Duration + From ac4151ea44c9ad9f5e5f87b108a953ece1cd5d7a Mon Sep 17 00:00:00 2001 From: mubashardev Date: Fri, 26 Dec 2025 14:05:18 +0500 Subject: [PATCH 06/12] chore: update supported whatsapp versions list to include 2.25.x --- app/src/main/res/values/arrays.xml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml index 33259523..2a670594 100644 --- a/app/src/main/res/values/arrays.xml +++ b/app/src/main/res/values/arrays.xml @@ -132,6 +132,9 @@ 2.25.35.xx 2.25.36.xx 2.25.37.xx + 2.25.38.xx + 2.25.39.xx + 2.25.40.xx 2.25.25.xx @@ -147,6 +150,9 @@ 2.25.35.xx 2.25.36.xx 2.25.37.xx + 2.25.38.xx + 2.25.39.xx + 2.25.40.xx image/* From f2976be7d0cfdb38abff6a7cc3d7bd7f664cc575 Mon Sep 17 00:00:00 2001 From: mubashardev Date: Fri, 26 Dec 2025 14:06:41 +0500 Subject: [PATCH 07/12] fix: replace deprecated AndroidAppHelper with FeatureLoader context --- .../wmods/wppenhacer/xposed/features/media/CallRecording.java | 2 +- .../main/java/com/wmods/wppenhacer/xposed/spoofer/HookBL.java | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/wmods/wppenhacer/xposed/features/media/CallRecording.java b/app/src/main/java/com/wmods/wppenhacer/xposed/features/media/CallRecording.java index d00f9144..8bdf99e2 100644 --- a/app/src/main/java/com/wmods/wppenhacer/xposed/features/media/CallRecording.java +++ b/app/src/main/java/com/wmods/wppenhacer/xposed/features/media/CallRecording.java @@ -102,7 +102,7 @@ protected void afterHookedMethod(MethodHookParam param) throws Throwable { private synchronized void startRecording() { if (isRecording) return; try { - String packageName = de.robv.android.xposed.AndroidAppHelper.currentPackageName(); + String packageName = com.wmods.wppenhacer.xposed.core.FeatureLoader.mApp.getPackageName(); String appName = packageName.contains("w4b") ? "WA Business" : "WhatsApp"; File parentDir; diff --git a/app/src/main/java/com/wmods/wppenhacer/xposed/spoofer/HookBL.java b/app/src/main/java/com/wmods/wppenhacer/xposed/spoofer/HookBL.java index 73d247b8..42995f00 100644 --- a/app/src/main/java/com/wmods/wppenhacer/xposed/spoofer/HookBL.java +++ b/app/src/main/java/com/wmods/wppenhacer/xposed/spoofer/HookBL.java @@ -1,6 +1,6 @@ package com.wmods.wppenhacer.xposed.spoofer; -import android.app.AndroidAppHelper; + import android.app.Application; import android.content.Context; import android.content.pm.PackageManager; @@ -492,7 +492,7 @@ else if ("android.software.device_id_attestation".equals(featureName)) }; try { - Application app = AndroidAppHelper.currentApplication(); + Application app = com.wmods.wppenhacer.xposed.core.FeatureLoader.mApp; Class PackageManagerClass, SharedPreferencesClass; From 1d397ed1efa328e50373879bb1c02b09b79f2376 Mon Sep 17 00:00:00 2001 From: mubashardev Date: Fri, 26 Dec 2025 14:26:21 +0500 Subject: [PATCH 08/12] fix: debug call recording path permission and hook triggering --- .../xposed/features/media/CallRecording.java | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/wmods/wppenhacer/xposed/features/media/CallRecording.java b/app/src/main/java/com/wmods/wppenhacer/xposed/features/media/CallRecording.java index 8bdf99e2..41e1199c 100644 --- a/app/src/main/java/com/wmods/wppenhacer/xposed/features/media/CallRecording.java +++ b/app/src/main/java/com/wmods/wppenhacer/xposed/features/media/CallRecording.java @@ -50,6 +50,7 @@ public void doHook() throws Throwable { @Override protected void afterHookedMethod(MethodHookParam param) throws Throwable { int source = (int) param.args[0]; + XposedBridge.log("WaEnhancer: AudioRecord Source " + source); if (source == android.media.MediaRecorder.AudioSource.VOICE_COMMUNICATION) { sampleRate = (int) param.args[1]; int channelConfig = (int) param.args[2]; @@ -109,26 +110,34 @@ private synchronized void startRecording() { if (Environment.isExternalStorageManager()) { parentDir = new File(Environment.getExternalStorageDirectory(), "WA Call Recordings"); } else { - parentDir = new File(outputDir); + // Fallback to safe external storage (Android/data/com.whatsapp/files/Recordings) + parentDir = new File(com.wmods.wppenhacer.xposed.core.FeatureLoader.mApp.getExternalFilesDir(null), "Recordings"); } - // Subfolders: Package Name -> Audio (Default, since type detection is complex here) File dir = new File(parentDir, appName + "/Audio"); - if (!dir.exists()) dir.mkdirs(); + if (!dir.exists()) { + boolean created = dir.mkdirs(); + if (!created) { + XposedBridge.log("WaEnhancer: Failed to create directory: " + dir.getAbsolutePath()); + Utils.showToast("WaEnhancer: RW Error " + dir.getAbsolutePath(), android.widget.Toast.LENGTH_LONG); + return; + } + } String fileName = "Call_" + new SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(new Date()) + ".wav"; File file = new File(dir, fileName); randomAccessFile = new RandomAccessFile(file, "rw"); - // write placeholder header - randomAccessFile.setLength(0); // truncate + randomAccessFile.setLength(0); randomAccessFile.write(new byte[44]); isRecording = true; payloadSize = 0; - XposedBridge.log("WaEnhancer: AudioRecord initiated at " + sampleRate + "Hz"); + XposedBridge.log("WaEnhancer: Recording started: " + file.getAbsolutePath()); + Utils.showToast("rec: " + file.getName(), android.widget.Toast.LENGTH_SHORT); } catch (Exception e) { XposedBridge.log(e); + Utils.showToast("Rec Error: " + e.getMessage(), android.widget.Toast.LENGTH_LONG); } } From 11900df0c25e482188d0f75918d4fe04e84b618d Mon Sep 17 00:00:00 2001 From: mubashardev Date: Fri, 26 Dec 2025 14:54:42 +0500 Subject: [PATCH 09/12] refactor: reimplement call recording detection using multiple VoIP class hooks and internal WhatsApp classes --- .../xposed/features/media/CallRecording.java | 360 +++++++++++++----- gradlew | 0 2 files changed, 271 insertions(+), 89 deletions(-) mode change 100644 => 100755 gradlew diff --git a/app/src/main/java/com/wmods/wppenhacer/xposed/features/media/CallRecording.java b/app/src/main/java/com/wmods/wppenhacer/xposed/features/media/CallRecording.java index 41e1199c..3d48fd07 100644 --- a/app/src/main/java/com/wmods/wppenhacer/xposed/features/media/CallRecording.java +++ b/app/src/main/java/com/wmods/wppenhacer/xposed/features/media/CallRecording.java @@ -1,24 +1,30 @@ package com.wmods.wppenhacer.xposed.features.media; +import android.Manifest; +import android.app.Activity; +import android.content.pm.PackageManager; import android.media.AudioFormat; import android.media.AudioRecord; -import android.os.Environment; +import android.media.MediaRecorder; import androidx.annotation.NonNull; +import androidx.core.content.ContextCompat; import com.wmods.wppenhacer.xposed.core.Feature; +import com.wmods.wppenhacer.xposed.core.FeatureLoader; +import com.wmods.wppenhacer.xposed.core.WppCore; +import com.wmods.wppenhacer.xposed.core.devkit.Unobfuscator; import com.wmods.wppenhacer.xposed.utils.Utils; +import org.luckypray.dexkit.query.enums.StringMatchType; + import java.io.File; import java.io.RandomAccessFile; import java.io.IOException; import java.text.SimpleDateFormat; -import java.nio.ByteBuffer; -import java.nio.ByteOrder; import java.util.Date; import java.util.Locale; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicBoolean; import de.robv.android.xposed.XC_MethodHook; import de.robv.android.xposed.XSharedPreferences; @@ -27,14 +33,17 @@ public class CallRecording extends Feature { - private boolean isRecording = false; + private final AtomicBoolean isRecording = new AtomicBoolean(false); + private AudioRecord audioRecord; private RandomAccessFile randomAccessFile; - private final ExecutorService executor = Executors.newSingleThreadExecutor(); - private String outputDir; + private Thread recordingThread; private int payloadSize = 0; - private int sampleRate = 16000; // Default, updated from hook - private short channels = 1; - private final short bitsPerSample = 16; + + private static final int SAMPLE_RATE = 44100; + private static final int CHANNEL_CONFIG = AudioFormat.CHANNEL_IN_MONO; + private static final int AUDIO_FORMAT = AudioFormat.ENCODING_PCM_16BIT; + private static final short CHANNELS = 1; + private static final short BITS_PER_SAMPLE = 16; public CallRecording(@NonNull ClassLoader loader, @NonNull XSharedPreferences preferences) { super(loader, preferences); @@ -42,85 +51,184 @@ public CallRecording(@NonNull ClassLoader loader, @NonNull XSharedPreferences pr @Override public void doHook() throws Throwable { - if (!prefs.getBoolean("call_recording_enable", false)) return; - outputDir = prefs.getString("call_recording_path", Environment.getExternalStorageDirectory() + "/Music/WaEnhancer/Recordings"); + if (!prefs.getBoolean("call_recording_enable", false)) { + XposedBridge.log("WaEnhancer: Call Recording is disabled"); + return; + } + + XposedBridge.log("WaEnhancer: Call Recording feature initializing..."); - // Hook AudioRecord Constructor to get Sample Rate / Channels - XposedBridge.hookAllConstructors(AudioRecord.class, new XC_MethodHook() { - @Override - protected void afterHookedMethod(MethodHookParam param) throws Throwable { - int source = (int) param.args[0]; - XposedBridge.log("WaEnhancer: AudioRecord Source " + source); - if (source == android.media.MediaRecorder.AudioSource.VOICE_COMMUNICATION) { - sampleRate = (int) param.args[1]; - int channelConfig = (int) param.args[2]; - channels = (short) (channelConfig == AudioFormat.CHANNEL_IN_STEREO ? 2 : 1); - startRecording(); - } + // Hook call state changes using multiple approaches + hookCallStateChanges(); + } + + private void hookCallStateChanges() { + int hooksInstalled = 0; + + // Approach 1: Hook VoiceServiceEventCallback.fieldstatsReady (call end detection) + try { + var clsCallEventCallback = Unobfuscator.findFirstClassUsingName(classLoader, StringMatchType.EndsWith, "VoiceServiceEventCallback"); + if (clsCallEventCallback != null) { + XposedBridge.log("WaEnhancer: Found VoiceServiceEventCallback: " + clsCallEventCallback.getName()); + XposedBridge.hookAllMethods(clsCallEventCallback, "fieldstatsReady", new XC_MethodHook() { + @Override + protected void beforeHookedMethod(MethodHookParam param) throws Throwable { + XposedBridge.log("WaEnhancer: fieldstatsReady - Call Ended"); + stopRecording(); + } + }); + hooksInstalled++; } - }); + } catch (Throwable e) { + XposedBridge.log("WaEnhancer: Could not hook VoiceServiceEventCallback: " + e.getMessage()); + } - XposedHelpers.findAndHookMethod(AudioRecord.class, "startRecording", new XC_MethodHook() { + // Approach 2: Find VoipActivity using Unobfuscator + try { + var voipActivityClass = Unobfuscator.findFirstClassUsingName(classLoader, StringMatchType.Contains, "VoipActivity"); + if (voipActivityClass != null && Activity.class.isAssignableFrom(voipActivityClass)) { + hookVoipActivity(voipActivityClass); + hooksInstalled++; + XposedBridge.log("WaEnhancer: Hooked VoipActivity via dexkit: " + voipActivityClass.getName()); + } + } catch (Throwable e) { + XposedBridge.log("WaEnhancer: Error finding VoipActivity via dexkit: " + e.getMessage()); + } + + // Approach 3: Try known class names with fallbacks + String[] possibleClassNames = { + "com.whatsapp.calling.ui.VoipActivityV2", + "com.whatsapp.voipcalling.VoipActivityV2", + "com.whatsapp.voipcalling.VoipActivity", + "com.whatsapp.calling.VoipActivity", + "com.whatsapp.voip.VoipActivity" + }; + + for (String className : possibleClassNames) { + try { + var clazz = XposedHelpers.findClassIfExists(className, classLoader); + if (clazz != null && Activity.class.isAssignableFrom(clazz)) { + hookVoipActivity(clazz); + hooksInstalled++; + XposedBridge.log("WaEnhancer: Hooked known class: " + className); + } + } catch (Throwable ignored) {} + } + + // Approach 4: Hook Voip manager class methods + try { + var voipClass = WppCore.getVoipManagerClass(classLoader); + XposedBridge.log("WaEnhancer: Found Voip manager: " + voipClass.getName()); + + // Hook all methods to detect call start + for (var method : voipClass.getDeclaredMethods()) { + String methodName = method.getName().toLowerCase(); + if (methodName.contains("start") || methodName.contains("accept") || methodName.contains("answer")) { + try { + XposedBridge.hookMethod(method, new XC_MethodHook() { + @Override + protected void afterHookedMethod(MethodHookParam param) throws Throwable { + XposedBridge.log("WaEnhancer: Voip." + method.getName() + " called"); + startRecording(); + } + }); + hooksInstalled++; + } catch (Throwable ignored) {} + } + } + } catch (Throwable e) { + XposedBridge.log("WaEnhancer: Could not hook Voip manager: " + e.getMessage()); + } + + // Approach 5: Hook CallInfo class for call state + try { + var callInfoClass = WppCore.getVoipCallInfoClass(classLoader); + XposedBridge.log("WaEnhancer: Found CallInfo: " + callInfoClass.getName()); + XposedBridge.hookAllConstructors(callInfoClass, new XC_MethodHook() { + @Override + protected void afterHookedMethod(MethodHookParam param) throws Throwable { + XposedBridge.log("WaEnhancer: CallInfo created - call starting"); + startRecording(); + } + }); + hooksInstalled++; + } catch (Throwable e) { + XposedBridge.log("WaEnhancer: Could not hook CallInfo: " + e.getMessage()); + } + + XposedBridge.log("WaEnhancer: Call Recording initialized with " + hooksInstalled + " hooks"); + } + + private void hookVoipActivity(Class activityClass) { + XposedBridge.hookAllMethods(activityClass, "onResume", new XC_MethodHook() { @Override protected void afterHookedMethod(MethodHookParam param) throws Throwable { + XposedBridge.log("WaEnhancer: VoipActivity.onResume - Call Active"); startRecording(); } }); - - XposedHelpers.findAndHookMethod(AudioRecord.class, "stop", new XC_MethodHook() { + + XposedBridge.hookAllMethods(activityClass, "onDestroy", new XC_MethodHook() { @Override - protected void afterHookedMethod(MethodHookParam param) throws Throwable { + protected void beforeHookedMethod(MethodHookParam param) throws Throwable { + XposedBridge.log("WaEnhancer: VoipActivity.onDestroy - Call Ended"); stopRecording(); } }); - - XposedHelpers.findAndHookMethod(AudioRecord.class, "release", new XC_MethodHook() { + + XposedBridge.hookAllMethods(activityClass, "onStop", new XC_MethodHook() { @Override protected void beforeHookedMethod(MethodHookParam param) throws Throwable { - stopRecording(); - } - }); - - XposedHelpers.findAndHookMethod(AudioRecord.class, "read", byte[].class, int.class, int.class, new XC_MethodHook() { - @Override - protected void afterHookedMethod(MethodHookParam param) throws Throwable { - if (!isRecording || randomAccessFile == null) return; - int result = (int) param.getResult(); - if (result > 0) { - byte[] data = (byte[]) param.args[0]; - writeAsync(data, 0, result); - } + XposedBridge.log("WaEnhancer: VoipActivity.onStop"); } }); - - // Note: For now, we are capturing the generic VOICE_COMMUNICATION source. - // Hooking AudioTrack for the downlink is possible but requires synchronizing two streams - // which often drift. The Microphone source in 'VOICE_COMMUNICATION' mode often includes - // the other party's audio on many modern devices due to echo cancellation loopback. - // If users report only one-sided audio, we will implement the dual-file capture strategy. } private synchronized void startRecording() { - if (isRecording) return; + if (isRecording.get()) { + XposedBridge.log("WaEnhancer: Already recording, skipping"); + return; + } + try { - String packageName = com.wmods.wppenhacer.xposed.core.FeatureLoader.mApp.getPackageName(); + // Check microphone permission + if (ContextCompat.checkSelfPermission(FeatureLoader.mApp, Manifest.permission.RECORD_AUDIO) + != PackageManager.PERMISSION_GRANTED) { + XposedBridge.log("WaEnhancer: No RECORD_AUDIO permission"); + Utils.showToast("WaEnhancer: No mic permission", android.widget.Toast.LENGTH_SHORT); + return; + } + + String packageName = FeatureLoader.mApp.getPackageName(); String appName = packageName.contains("w4b") ? "WA Business" : "WhatsApp"; + // Get base path from preferences, or use root if MANAGE_EXTERNAL_STORAGE granted File parentDir; - if (Environment.isExternalStorageManager()) { - parentDir = new File(Environment.getExternalStorageDirectory(), "WA Call Recordings"); + if (android.os.Environment.isExternalStorageManager()) { + // Use root folder: /sdcard/WA Call Recordings/ + parentDir = new File(android.os.Environment.getExternalStorageDirectory(), "WA Call Recordings"); } else { - // Fallback to safe external storage (Android/data/com.whatsapp/files/Recordings) - parentDir = new File(com.wmods.wppenhacer.xposed.core.FeatureLoader.mApp.getExternalFilesDir(null), "Recordings"); + // Use path from settings or fallback to app files dir + String settingsPath = prefs.getString("call_recording_path", null); + if (settingsPath != null && !settingsPath.isEmpty()) { + parentDir = new File(settingsPath, "WA Call Recordings"); + } else { + parentDir = new File(FeatureLoader.mApp.getExternalFilesDir(null), "Recordings"); + } } - File dir = new File(parentDir, appName + "/Audio"); + // Folder structure: WA Call Recordings/[WhatsApp|WA Business]/Voice/ + File dir = new File(parentDir, appName + "/Voice"); if (!dir.exists()) { boolean created = dir.mkdirs(); if (!created) { XposedBridge.log("WaEnhancer: Failed to create directory: " + dir.getAbsolutePath()); - Utils.showToast("WaEnhancer: RW Error " + dir.getAbsolutePath(), android.widget.Toast.LENGTH_LONG); - return; + // Fallback to app files dir + dir = new File(FeatureLoader.mApp.getExternalFilesDir(null), "Recordings/" + appName + "/Voice"); + if (!dir.exists() && !dir.mkdirs()) { + Utils.showToast("WaEnhancer: Dir creation failed", android.widget.Toast.LENGTH_LONG); + return; + } } } @@ -128,49 +236,124 @@ private synchronized void startRecording() { File file = new File(dir, fileName); randomAccessFile = new RandomAccessFile(file, "rw"); - randomAccessFile.setLength(0); - randomAccessFile.write(new byte[44]); + // Write placeholder WAV header (44 bytes) + randomAccessFile.setLength(0); + randomAccessFile.write(new byte[44]); + + // Calculate buffer size + int minBufferSize = AudioRecord.getMinBufferSize(SAMPLE_RATE, CHANNEL_CONFIG, AUDIO_FORMAT); + int bufferSize = Math.max(minBufferSize * 2, 8192); + + // Create AudioRecord with VOICE_COMMUNICATION source + audioRecord = new AudioRecord( + MediaRecorder.AudioSource.VOICE_COMMUNICATION, + SAMPLE_RATE, + CHANNEL_CONFIG, + AUDIO_FORMAT, + bufferSize + ); + + if (audioRecord.getState() != AudioRecord.STATE_INITIALIZED) { + XposedBridge.log("WaEnhancer: AudioRecord failed to initialize, trying MIC source"); + audioRecord.release(); + + // Fallback to MIC source + audioRecord = new AudioRecord( + MediaRecorder.AudioSource.MIC, + SAMPLE_RATE, + CHANNEL_CONFIG, + AUDIO_FORMAT, + bufferSize + ); + + if (audioRecord.getState() != AudioRecord.STATE_INITIALIZED) { + XposedBridge.log("WaEnhancer: AudioRecord still failed to initialize"); + Utils.showToast("WaEnhancer: AudioRecord init failed", android.widget.Toast.LENGTH_LONG); + return; + } + } - isRecording = true; + isRecording.set(true); payloadSize = 0; + + audioRecord.startRecording(); + + // Start recording thread + final int finalBufferSize = bufferSize; + recordingThread = new Thread(() -> { + byte[] buffer = new byte[finalBufferSize]; + while (isRecording.get()) { + int read = audioRecord.read(buffer, 0, buffer.length); + if (read > 0) { + try { + synchronized (CallRecording.this) { + if (randomAccessFile != null) { + randomAccessFile.write(buffer, 0, read); + payloadSize += read; + } + } + } catch (IOException e) { + XposedBridge.log(e); + } + } + } + }, "WaEnhancer-RecordingThread"); + recordingThread.start(); + XposedBridge.log("WaEnhancer: Recording started: " + file.getAbsolutePath()); - Utils.showToast("rec: " + file.getName(), android.widget.Toast.LENGTH_SHORT); + Utils.showToast("Recording: " + fileName, android.widget.Toast.LENGTH_SHORT); + } catch (Exception e) { + XposedBridge.log("WaEnhancer: startRecording error: " + e.getMessage()); XposedBridge.log(e); Utils.showToast("Rec Error: " + e.getMessage(), android.widget.Toast.LENGTH_LONG); } } private synchronized void stopRecording() { - if (!isRecording) return; - isRecording = false; + if (!isRecording.get()) { + return; + } + + isRecording.set(false); + try { + // Wait for recording thread to finish + if (recordingThread != null) { + recordingThread.join(1000); + recordingThread = null; + } + + // Stop and release AudioRecord + if (audioRecord != null) { + try { + audioRecord.stop(); + } catch (Exception ignored) {} + audioRecord.release(); + audioRecord = null; + } + + // Write WAV header and close file if (randomAccessFile != null) { writeWavHeader(); randomAccessFile.close(); + randomAccessFile = null; + } + + XposedBridge.log("WaEnhancer: Recording stopped, size: " + payloadSize + " bytes"); + if (payloadSize > 1000) { + Utils.showToast("Recording saved!", android.widget.Toast.LENGTH_SHORT); } - } catch (IOException e) { + + } catch (Exception e) { + XposedBridge.log("WaEnhancer: stopRecording error: " + e.getMessage()); XposedBridge.log(e); } - randomAccessFile = null; - } - - private void writeAsync(byte[] data, int offset, int length) { - executor.execute(() -> { - try { - if (randomAccessFile != null) { - randomAccessFile.write(data, offset, length); - payloadSize += length; - } - } catch (IOException e) { - XposedBridge.log(e); - } - }); } private void writeWavHeader() throws IOException { long totalDataLen = payloadSize + 36; - long byteRate = (long) sampleRate * channels * bitsPerSample / 8; + long byteRate = (long) SAMPLE_RATE * CHANNELS * BITS_PER_SAMPLE / 8; randomAccessFile.seek(0); byte[] header = new byte[44]; @@ -184,16 +367,16 @@ private void writeWavHeader() throws IOException { header[12] = 'f'; header[13] = 'm'; header[14] = 't'; header[15] = ' '; header[16] = 16; header[17] = 0; header[18] = 0; header[19] = 0; header[20] = 1; header[21] = 0; - header[22] = (byte) channels; header[23] = 0; - header[24] = (byte) (sampleRate & 0xff); - header[25] = (byte) ((sampleRate >> 8) & 0xff); - header[26] = (byte) ((sampleRate >> 16) & 0xff); - header[27] = (byte) ((sampleRate >> 24) & 0xff); + header[22] = (byte) CHANNELS; header[23] = 0; + header[24] = (byte) (SAMPLE_RATE & 0xff); + header[25] = (byte) ((SAMPLE_RATE >> 8) & 0xff); + header[26] = (byte) ((SAMPLE_RATE >> 16) & 0xff); + header[27] = (byte) ((SAMPLE_RATE >> 24) & 0xff); header[28] = (byte) (byteRate & 0xff); header[29] = (byte) ((byteRate >> 8) & 0xff); header[30] = (byte) ((byteRate >> 16) & 0xff); header[31] = (byte) ((byteRate >> 24) & 0xff); - header[32] = (byte) (channels * bitsPerSample / 8); header[33] = 0; + header[32] = (byte) (CHANNELS * BITS_PER_SAMPLE / 8); header[33] = 0; header[34] = 16; header[35] = 0; header[36] = 'd'; header[37] = 'a'; header[38] = 't'; header[39] = 'a'; header[40] = (byte) (payloadSize & 0xff); @@ -204,7 +387,6 @@ private void writeWavHeader() throws IOException { randomAccessFile.write(header); } - @NonNull @Override public String getPluginName() { diff --git a/gradlew b/gradlew old mode 100644 new mode 100755 From 2233e2d7f71d974338c9a9390cd3cde667bcfa16 Mon Sep 17 00:00:00 2001 From: mubashardev Date: Fri, 26 Dec 2025 18:14:46 +0500 Subject: [PATCH 10/12] feat: Implement comprehensive call recording with UI for management and playback. --- app/src/main/AndroidManifest.xml | 15 + .../CallRecordingSettingsActivity.java | 135 ++++++ .../wppenhacer/adapter/RecordingsAdapter.java | 190 +++++++-- .../com/wmods/wppenhacer/model/Recording.java | 202 +++++++++ .../ui/dialogs/AudioPlayerDialog.java | 170 ++++++++ .../ui/fragments/MediaFragment.java | 12 + .../ui/fragments/RecordingsFragment.java | 230 ++++++++--- .../xposed/features/media/CallRecording.java | 391 ++++++++++-------- .../res/drawable/circle_button_background.xml | 5 + .../main/res/drawable/dialog_background.xml | 6 + .../drawable/duration_badge_background.xml | 6 + app/src/main/res/drawable/ic_check_circle.xml | 10 + app/src/main/res/drawable/ic_close.xml | 10 + app/src/main/res/drawable/ic_pause.xml | 10 + app/src/main/res/drawable/ic_play.xml | 10 + app/src/main/res/drawable/ic_warning.xml | 10 + .../activity_call_recording_settings.xml | 263 ++++++++++++ .../main/res/layout/dialog_audio_player.xml | 85 ++++ .../main/res/layout/fragment_recordings.xml | 161 ++++++-- app/src/main/res/layout/item_recording.xml | 132 ++++-- .../main/res/values/strings_recordings.xml | 57 ++- app/src/main/res/xml/file_paths.xml | 15 + app/src/main/res/xml/fragment_media.xml | 13 + 23 files changed, 1820 insertions(+), 318 deletions(-) create mode 100644 app/src/main/java/com/wmods/wppenhacer/activities/CallRecordingSettingsActivity.java create mode 100644 app/src/main/java/com/wmods/wppenhacer/model/Recording.java create mode 100644 app/src/main/java/com/wmods/wppenhacer/ui/dialogs/AudioPlayerDialog.java create mode 100644 app/src/main/res/drawable/circle_button_background.xml create mode 100644 app/src/main/res/drawable/dialog_background.xml create mode 100644 app/src/main/res/drawable/duration_badge_background.xml create mode 100644 app/src/main/res/drawable/ic_check_circle.xml create mode 100644 app/src/main/res/drawable/ic_close.xml create mode 100644 app/src/main/res/drawable/ic_pause.xml create mode 100644 app/src/main/res/drawable/ic_play.xml create mode 100644 app/src/main/res/drawable/ic_warning.xml create mode 100644 app/src/main/res/layout/activity_call_recording_settings.xml create mode 100644 app/src/main/res/layout/dialog_audio_player.xml create mode 100644 app/src/main/res/xml/file_paths.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index e1703db5..98014679 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -82,6 +82,11 @@ android:name=".activities.TextEditorActivity" android:theme="@style/AppTheme" /> + + + + + + { + Log.d(TAG, "Root mode clicked"); + radioRoot.setChecked(true); + radioNonRoot.setChecked(false); + Toast.makeText(this, "Checking root access...", Toast.LENGTH_SHORT).show(); + checkRootAccess(); + }); + + radioNonRoot.setOnClickListener(v -> { + Log.d(TAG, "Non-root mode clicked"); + radioNonRoot.setChecked(true); + radioRoot.setChecked(false); + boolean saved = prefs.edit().putBoolean("call_recording_use_root", false).commit(); + Log.d(TAG, "Saved non-root preference: " + saved); + Toast.makeText(this, R.string.non_root_mode_enabled, Toast.LENGTH_SHORT).show(); + }); + } + + private void checkRootAccess() { + new Thread(() -> { + boolean hasRoot = false; + String rootOutput = ""; + + try { + Log.d(TAG, "Executing su command..."); + Process process = Runtime.getRuntime().exec("su"); + DataOutputStream os = new DataOutputStream(process.getOutputStream()); + BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream())); + + os.writeBytes("id\n"); + os.writeBytes("exit\n"); + os.flush(); + + // Read output + StringBuilder sb = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) { + sb.append(line); + } + rootOutput = sb.toString(); + + int exitCode = process.waitFor(); + Log.d(TAG, "Root check exit code: " + exitCode + ", output: " + rootOutput); + + hasRoot = (exitCode == 0 && rootOutput.contains("uid=0")); + } catch (Exception e) { + Log.e(TAG, "Root check exception: " + e.getMessage()); + hasRoot = false; + } + + final boolean rootGranted = hasRoot; + final String output = rootOutput; + + runOnUiThread(() -> { + if (rootGranted) { + boolean saved = prefs.edit().putBoolean("call_recording_use_root", true).commit(); + Log.d(TAG, "Root granted, saved preference: " + saved); + Toast.makeText(this, R.string.root_access_granted, Toast.LENGTH_SHORT).show(); + } else { + boolean saved = prefs.edit().putBoolean("call_recording_use_root", false).commit(); + Log.d(TAG, "Root denied, saved preference: " + saved + ", output: " + output); + radioNonRoot.setChecked(true); + Toast.makeText(this, R.string.root_access_denied, Toast.LENGTH_LONG).show(); + } + }); + }).start(); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + if (item.getItemId() == android.R.id.home) { + finish(); + return true; + } + return super.onOptionsItemSelected(item); + } +} + diff --git a/app/src/main/java/com/wmods/wppenhacer/adapter/RecordingsAdapter.java b/app/src/main/java/com/wmods/wppenhacer/adapter/RecordingsAdapter.java index 4202532f..b84d72b4 100644 --- a/app/src/main/java/com/wmods/wppenhacer/adapter/RecordingsAdapter.java +++ b/app/src/main/java/com/wmods/wppenhacer/adapter/RecordingsAdapter.java @@ -1,45 +1,120 @@ package com.wmods.wppenhacer.adapter; -import android.annotation.SuppressLint; -import android.content.Context; -import android.text.format.Formatter; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; +import android.widget.CheckBox; import android.widget.ImageButton; +import android.widget.ImageView; +import android.widget.LinearLayout; import android.widget.TextView; import androidx.annotation.NonNull; import androidx.recyclerview.widget.RecyclerView; +import com.google.android.material.card.MaterialCardView; import com.wmods.wppenhacer.R; +import com.wmods.wppenhacer.model.Recording; -import java.io.File; import java.text.SimpleDateFormat; import java.util.ArrayList; import java.util.Date; +import java.util.HashSet; import java.util.List; import java.util.Locale; +import java.util.Set; public class RecordingsAdapter extends RecyclerView.Adapter { - private List files = new ArrayList<>(); + private List recordings = new ArrayList<>(); private final OnRecordingActionListener listener; + private boolean isSelectionMode = false; + private final Set selectedPositions = new HashSet<>(); + private OnSelectionChangeListener selectionChangeListener; public interface OnRecordingActionListener { - void onPlay(File file); - void onShare(File file); - void onDelete(File file); + void onPlay(Recording recording); + void onShare(Recording recording); + void onDelete(Recording recording); + void onLongPress(Recording recording, int position); + } + + public interface OnSelectionChangeListener { + void onSelectionChanged(int count); } public RecordingsAdapter(OnRecordingActionListener listener) { this.listener = listener; } - @SuppressLint("NotifyDataSetChanged") - public void setFiles(List files) { - this.files = files; + public void setSelectionChangeListener(OnSelectionChangeListener listener) { + this.selectionChangeListener = listener; + } + + public void setRecordings(List recordings) { + this.recordings = recordings; + clearSelection(); + notifyDataSetChanged(); + } + + public void setSelectionMode(boolean selectionMode) { + if (this.isSelectionMode != selectionMode) { + this.isSelectionMode = selectionMode; + if (!selectionMode) { + selectedPositions.clear(); + } + notifyDataSetChanged(); + } + } + + public boolean isSelectionMode() { + return isSelectionMode; + } + + public void toggleSelection(int position) { + if (selectedPositions.contains(position)) { + selectedPositions.remove(position); + } else { + selectedPositions.add(position); + } + notifyItemChanged(position); + if (selectionChangeListener != null) { + selectionChangeListener.onSelectionChanged(selectedPositions.size()); + } + } + + public void selectAll() { + selectedPositions.clear(); + for (int i = 0; i < recordings.size(); i++) { + selectedPositions.add(i); + } notifyDataSetChanged(); + if (selectionChangeListener != null) { + selectionChangeListener.onSelectionChanged(selectedPositions.size()); + } + } + + public void clearSelection() { + selectedPositions.clear(); + isSelectionMode = false; + notifyDataSetChanged(); + if (selectionChangeListener != null) { + selectionChangeListener.onSelectionChanged(0); + } + } + + public List getSelectedRecordings() { + List selected = new ArrayList<>(); + for (int position : selectedPositions) { + if (position < recordings.size()) { + selected.add(recordings.get(position)); + } + } + return selected; + } + + public int getSelectionCount() { + return selectedPositions.size(); } @NonNull @@ -51,35 +126,92 @@ public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { @Override public void onBindViewHolder(@NonNull ViewHolder holder, int position) { - File file = files.get(position); - Context context = holder.itemView.getContext(); - - holder.name.setText(file.getName()); + Recording recording = recordings.get(position); - String size = Formatter.formatFileSize(context, file.length()); - String date = new SimpleDateFormat("dd MMM yyyy HH:mm", Locale.getDefault()).format(new Date(file.lastModified())); - // Duration would require MediaPlayer parsing, expensive for list. Using size/date for now. + // Contact name + holder.contactName.setText(recording.getContactName()); - holder.details.setText(String.format("%s • %s", size, date)); - - holder.btnPlay.setOnClickListener(v -> listener.onPlay(file)); - holder.btnShare.setOnClickListener(v -> listener.onShare(file)); - holder.btnDelete.setOnClickListener(v -> listener.onDelete(file)); + // Phone number + String phoneNumber = recording.getPhoneNumber(); + if (phoneNumber != null && !phoneNumber.equals(recording.getContactName())) { + holder.phoneNumber.setVisibility(View.VISIBLE); + holder.phoneNumber.setText(phoneNumber); + } else { + holder.phoneNumber.setVisibility(View.GONE); + } + + // Duration + holder.duration.setText(recording.getFormattedDuration()); + + // Details: size and date + SimpleDateFormat dateFormat = new SimpleDateFormat("dd MMM yyyy, HH:mm", Locale.getDefault()); + String details = recording.getFormattedSize() + " • " + dateFormat.format(new Date(recording.getDate())); + holder.details.setText(details); + + // Selection mode UI + if (isSelectionMode) { + holder.checkbox.setVisibility(View.VISIBLE); + holder.actionsContainer.setVisibility(View.GONE); + holder.checkbox.setChecked(selectedPositions.contains(position)); + holder.card.setChecked(selectedPositions.contains(position)); + } else { + holder.checkbox.setVisibility(View.GONE); + holder.actionsContainer.setVisibility(View.VISIBLE); + holder.card.setChecked(false); + } + + // Click handling + holder.itemView.setOnClickListener(v -> { + if (isSelectionMode) { + toggleSelection(position); + } else { + listener.onPlay(recording); + } + }); + + holder.itemView.setOnLongClickListener(v -> { + if (!isSelectionMode) { + listener.onLongPress(recording, position); + } + return true; + }); + + holder.checkbox.setOnClickListener(v -> toggleSelection(position)); + + // Action buttons + holder.btnPlay.setOnClickListener(v -> listener.onPlay(recording)); + holder.btnShare.setOnClickListener(v -> listener.onShare(recording)); + holder.btnDelete.setOnClickListener(v -> listener.onDelete(recording)); } @Override public int getItemCount() { - return files.size(); + return recordings.size(); } static class ViewHolder extends RecyclerView.ViewHolder { - TextView name, details; - ImageButton btnPlay, btnShare, btnDelete; - - public ViewHolder(@NonNull View itemView) { + MaterialCardView card; + CheckBox checkbox; + ImageView icon; + TextView contactName; + TextView phoneNumber; + TextView duration; + TextView details; + LinearLayout actionsContainer; + ImageButton btnPlay; + ImageButton btnShare; + ImageButton btnDelete; + + ViewHolder(View itemView) { super(itemView); - name = itemView.findViewById(R.id.name); + card = (MaterialCardView) itemView; + checkbox = itemView.findViewById(R.id.checkbox); + icon = itemView.findViewById(R.id.icon); + contactName = itemView.findViewById(R.id.contact_name); + phoneNumber = itemView.findViewById(R.id.phone_number); + duration = itemView.findViewById(R.id.duration); details = itemView.findViewById(R.id.details); + actionsContainer = itemView.findViewById(R.id.actions_container); btnPlay = itemView.findViewById(R.id.btn_play); btnShare = itemView.findViewById(R.id.btn_share); btnDelete = itemView.findViewById(R.id.btn_delete); diff --git a/app/src/main/java/com/wmods/wppenhacer/model/Recording.java b/app/src/main/java/com/wmods/wppenhacer/model/Recording.java new file mode 100644 index 00000000..04651655 --- /dev/null +++ b/app/src/main/java/com/wmods/wppenhacer/model/Recording.java @@ -0,0 +1,202 @@ +package com.wmods.wppenhacer.model; + +import android.content.ContentResolver; +import android.content.Context; +import android.database.Cursor; +import android.net.Uri; +import android.provider.ContactsContract; + +import java.io.File; +import java.io.RandomAccessFile; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Model class representing a call recording with metadata. + */ +public class Recording { + + private final File file; + private String phoneNumber; + private String contactName; + private long duration; // in milliseconds + private final long date; + private final long size; + + // Pattern to extract phone number from filename: Call_+1234567890_20261226_164651.wav + private static final Pattern PHONE_PATTERN = Pattern.compile("Call_([+\\d]+)_\\d{8}_\\d{6}\\.wav"); + + public Recording(File file, Context context) { + this.file = file; + this.date = file.lastModified(); + this.size = file.length(); + + // Extract phone number from filename + extractPhoneNumber(); + + // Resolve contact name + if (context != null && phoneNumber != null) { + resolveContactName(context); + } + + // Parse duration from WAV header + parseDuration(); + } + + private void extractPhoneNumber() { + String filename = file.getName(); + Matcher matcher = PHONE_PATTERN.matcher(filename); + if (matcher.matches()) { + phoneNumber = matcher.group(1); + } else { + // Fallback: try to find any phone number pattern + Pattern fallbackPattern = Pattern.compile("([+]?\\d{10,15})"); + Matcher fallbackMatcher = fallbackPattern.matcher(filename); + if (fallbackMatcher.find()) { + phoneNumber = fallbackMatcher.group(1); + } + } + + // Default contact name to phone number + contactName = phoneNumber != null ? phoneNumber : "Unknown"; + } + + private void resolveContactName(Context context) { + if (phoneNumber == null || phoneNumber.isEmpty()) return; + + try { + ContentResolver resolver = context.getContentResolver(); + Uri uri = Uri.withAppendedPath(ContactsContract.PhoneLookup.CONTENT_FILTER_URI, Uri.encode(phoneNumber)); + + try (Cursor cursor = resolver.query(uri, + new String[]{ContactsContract.PhoneLookup.DISPLAY_NAME}, + null, null, null)) { + if (cursor != null && cursor.moveToFirst()) { + String name = cursor.getString(0); + if (name != null && !name.isEmpty()) { + contactName = name; + } + } + } + } catch (Exception e) { + // Keep phone number as name if lookup fails + } + } + + private void parseDuration() { + if (!file.exists() || file.length() < 44) { + duration = 0; + return; + } + + try (RandomAccessFile raf = new RandomAccessFile(file, "r")) { + // Read WAV header + byte[] header = new byte[44]; + raf.read(header); + + // Verify RIFF header + if (header[0] != 'R' || header[1] != 'I' || header[2] != 'F' || header[3] != 'F') { + duration = estimateDuration(); + return; + } + + // Get sample rate (bytes 24-27, little endian) + int sampleRate = (header[24] & 0xFF) | + ((header[25] & 0xFF) << 8) | + ((header[26] & 0xFF) << 16) | + ((header[27] & 0xFF) << 24); + + // Get byte rate (bytes 28-31, little endian) + int byteRate = (header[28] & 0xFF) | + ((header[29] & 0xFF) << 8) | + ((header[30] & 0xFF) << 16) | + ((header[31] & 0xFF) << 24); + + // Get data size (bytes 40-43, little endian) + long dataSize = (header[40] & 0xFF) | + ((header[41] & 0xFF) << 8) | + ((header[42] & 0xFF) << 16) | + ((long)(header[43] & 0xFF) << 24); + + if (byteRate > 0) { + duration = (dataSize * 1000L) / byteRate; + } else if (sampleRate > 0) { + // Assume 16-bit mono + duration = (dataSize * 1000L) / (sampleRate * 2); + } + + } catch (Exception e) { + duration = estimateDuration(); + } + } + + private long estimateDuration() { + // Estimate based on file size (assume 48kHz, 16-bit, mono = 96000 bytes/sec) + return (file.length() - 44) * 1000L / 96000; + } + + // Getters + + public File getFile() { + return file; + } + + public String getPhoneNumber() { + return phoneNumber; + } + + public String getContactName() { + return contactName; + } + + public long getDuration() { + return duration; + } + + public long getDate() { + return date; + } + + public long getSize() { + return size; + } + + public String getFormattedDuration() { + long seconds = duration / 1000; + long minutes = seconds / 60; + seconds = seconds % 60; + + if (minutes >= 60) { + long hours = minutes / 60; + minutes = minutes % 60; + return String.format("%d:%02d:%02d", hours, minutes, seconds); + } + return String.format("%d:%02d", minutes, seconds); + } + + public String getFormattedSize() { + if (size < 1024) return size + " B"; + if (size < 1024 * 1024) return String.format("%.1f KB", size / 1024.0); + return String.format("%.1f MB", size / (1024.0 * 1024.0)); + } + + /** + * Returns a grouping key for this recording (phone number or "Unknown") + */ + public String getGroupKey() { + return phoneNumber != null ? phoneNumber : "unknown"; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Recording recording = (Recording) o; + return file.equals(recording.file); + } + + @Override + public int hashCode() { + return file.hashCode(); + } +} diff --git a/app/src/main/java/com/wmods/wppenhacer/ui/dialogs/AudioPlayerDialog.java b/app/src/main/java/com/wmods/wppenhacer/ui/dialogs/AudioPlayerDialog.java new file mode 100644 index 00000000..39c18de9 --- /dev/null +++ b/app/src/main/java/com/wmods/wppenhacer/ui/dialogs/AudioPlayerDialog.java @@ -0,0 +1,170 @@ +package com.wmods.wppenhacer.ui.dialogs; + +import android.app.Dialog; +import android.content.Context; +import android.media.MediaPlayer; +import android.os.Handler; +import android.os.Looper; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.ImageButton; +import android.widget.SeekBar; +import android.widget.TextView; + +import com.wmods.wppenhacer.R; + +import java.io.File; +import java.io.IOException; + +/** + * A custom dialog for playing audio files in-app + */ +public class AudioPlayerDialog extends Dialog { + + private MediaPlayer mediaPlayer; + private Handler handler; + private Runnable updateRunnable; + + private SeekBar seekBar; + private ImageButton btnPlayPause; + private TextView tvCurrentTime; + private TextView tvTotalTime; + private TextView tvTitle; + + private boolean isPlaying = false; + + public AudioPlayerDialog(Context context, File audioFile) { + super(context, com.google.android.material.R.style.Theme_Material3_DayNight_Dialog); + + View view = LayoutInflater.from(context).inflate(R.layout.dialog_audio_player, null); + setContentView(view); + + // Set dialog window to have proper width + if (getWindow() != null) { + getWindow().setLayout( + (int)(context.getResources().getDisplayMetrics().widthPixels * 0.9), + android.view.ViewGroup.LayoutParams.WRAP_CONTENT + ); + getWindow().setBackgroundDrawableResource(android.R.color.transparent); + } + + // Initialize views + seekBar = view.findViewById(R.id.seekBar); + btnPlayPause = view.findViewById(R.id.btn_play_pause); + tvCurrentTime = view.findViewById(R.id.tv_current_time); + tvTotalTime = view.findViewById(R.id.tv_total_time); + tvTitle = view.findViewById(R.id.tv_title); + ImageButton btnClose = view.findViewById(R.id.btn_close); + + // Set title + tvTitle.setText(audioFile.getName()); + + // Initialize MediaPlayer + handler = new Handler(Looper.getMainLooper()); + + try { + mediaPlayer = new MediaPlayer(); + mediaPlayer.setDataSource(audioFile.getAbsolutePath()); + mediaPlayer.prepare(); + + int duration = mediaPlayer.getDuration(); + seekBar.setMax(duration); + tvTotalTime.setText(formatTime(duration)); + tvCurrentTime.setText(formatTime(0)); + + mediaPlayer.setOnCompletionListener(mp -> { + isPlaying = false; + btnPlayPause.setImageResource(R.drawable.ic_play); + seekBar.setProgress(0); + tvCurrentTime.setText(formatTime(0)); + mediaPlayer.seekTo(0); + }); + + } catch (IOException e) { + e.printStackTrace(); + dismiss(); + return; + } + + // Set up click listeners + btnPlayPause.setOnClickListener(v -> togglePlayPause()); + btnClose.setOnClickListener(v -> dismiss()); + + // Set up seekbar listener + seekBar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() { + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + if (fromUser && mediaPlayer != null) { + mediaPlayer.seekTo(progress); + tvCurrentTime.setText(formatTime(progress)); + } + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) {} + + @Override + public void onStopTrackingTouch(SeekBar seekBar) {} + }); + + // Update runnable + updateRunnable = new Runnable() { + @Override + public void run() { + if (mediaPlayer != null && isPlaying) { + int currentPosition = mediaPlayer.getCurrentPosition(); + seekBar.setProgress(currentPosition); + tvCurrentTime.setText(formatTime(currentPosition)); + handler.postDelayed(this, 100); + } + } + }; + + // Start playing automatically + togglePlayPause(); + + // Handle dialog dismiss + setOnDismissListener(dialog -> releasePlayer()); + } + + private void togglePlayPause() { + if (mediaPlayer == null) return; + + if (isPlaying) { + mediaPlayer.pause(); + btnPlayPause.setImageResource(R.drawable.ic_play); + handler.removeCallbacks(updateRunnable); + } else { + mediaPlayer.start(); + btnPlayPause.setImageResource(R.drawable.ic_pause); + handler.post(updateRunnable); + } + isPlaying = !isPlaying; + } + + private void releasePlayer() { + if (handler != null) { + handler.removeCallbacks(updateRunnable); + } + if (mediaPlayer != null) { + if (mediaPlayer.isPlaying()) { + mediaPlayer.stop(); + } + mediaPlayer.release(); + mediaPlayer = null; + } + } + + private String formatTime(int millis) { + int seconds = millis / 1000; + int minutes = seconds / 60; + seconds = seconds % 60; + + if (minutes >= 60) { + int hours = minutes / 60; + minutes = minutes % 60; + return String.format("%d:%02d:%02d", hours, minutes, seconds); + } + return String.format("%d:%02d", minutes, seconds); + } +} diff --git a/app/src/main/java/com/wmods/wppenhacer/ui/fragments/MediaFragment.java b/app/src/main/java/com/wmods/wppenhacer/ui/fragments/MediaFragment.java index b8d268d6..aa3192d2 100644 --- a/app/src/main/java/com/wmods/wppenhacer/ui/fragments/MediaFragment.java +++ b/app/src/main/java/com/wmods/wppenhacer/ui/fragments/MediaFragment.java @@ -1,10 +1,12 @@ package com.wmods.wppenhacer.ui.fragments; +import android.content.Intent; import android.os.Bundle; import androidx.annotation.Nullable; import com.wmods.wppenhacer.R; +import com.wmods.wppenhacer.activities.CallRecordingSettingsActivity; import com.wmods.wppenhacer.ui.fragments.base.BasePreferenceFragment; public class MediaFragment extends BasePreferenceFragment { @@ -26,6 +28,16 @@ public void onCreatePreferences(@Nullable Bundle savedInstanceState, @Nullable S super.onCreatePreferences(savedInstanceState, rootKey); setPreferencesFromResource(R.xml.fragment_media, rootKey); + // Call Recording Settings preference + var callRecordingSettings = findPreference("call_recording_settings"); + if (callRecordingSettings != null) { + callRecordingSettings.setOnPreferenceClickListener(preference -> { + Intent intent = new Intent(requireContext(), CallRecordingSettingsActivity.class); + startActivity(intent); + return true; + }); + } + var videoCallScreenRec = findPreference("video_call_screen_rec"); if (videoCallScreenRec != null) { videoCallScreenRec.setEnabled(true); diff --git a/app/src/main/java/com/wmods/wppenhacer/ui/fragments/RecordingsFragment.java b/app/src/main/java/com/wmods/wppenhacer/ui/fragments/RecordingsFragment.java index 2eb04982..791e2d4e 100644 --- a/app/src/main/java/com/wmods/wppenhacer/ui/fragments/RecordingsFragment.java +++ b/app/src/main/java/com/wmods/wppenhacer/ui/fragments/RecordingsFragment.java @@ -8,7 +8,6 @@ import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; -import android.webkit.MimeTypeMap; import android.widget.PopupMenu; import android.widget.Toast; @@ -19,23 +18,28 @@ import androidx.preference.PreferenceManager; import androidx.recyclerview.widget.LinearLayoutManager; +import com.google.android.material.chip.Chip; import com.wmods.wppenhacer.R; import com.wmods.wppenhacer.adapter.RecordingsAdapter; import com.wmods.wppenhacer.databinding.FragmentRecordingsBinding; +import com.wmods.wppenhacer.model.Recording; +import com.wmods.wppenhacer.ui.dialogs.AudioPlayerDialog; import java.io.File; import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; import java.util.Comparator; +import java.util.HashMap; import java.util.List; +import java.util.Map; public class RecordingsFragment extends Fragment implements RecordingsAdapter.OnRecordingActionListener { private FragmentRecordingsBinding binding; private RecordingsAdapter adapter; - private List recordingFiles = new ArrayList<>(); - private File baseDir; + private List allRecordings = new ArrayList<>(); + private List baseDirs = new ArrayList<>(); + private boolean isGroupByContact = false; + private int currentSortType = 1; // 1=date, 2=name, 3=duration, 4=contact @Nullable @Override @@ -52,30 +56,92 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceStat binding.recyclerView.setLayoutManager(new LinearLayoutManager(requireContext())); binding.recyclerView.setAdapter(adapter); - var prefs = PreferenceManager.getDefaultSharedPreferences(requireContext()); - String path = prefs.getString("call_recording_path", Environment.getExternalStorageDirectory() + "/Music/WaEnhancer/Recordings"); - baseDir = new File(path); + // Set up selection change listener + adapter.setSelectionChangeListener(count -> { + if (count > 0) { + binding.selectionBar.setVisibility(View.VISIBLE); + binding.tvSelectionCount.setText(getString(R.string.selected_count, count)); + } else { + binding.selectionBar.setVisibility(View.GONE); + } + }); + + // Initialize base directories + initializeBaseDirs(); + + // View mode toggle + binding.chipList.setOnClickListener(v -> { + isGroupByContact = false; + loadRecordings(); + }); + + binding.chipGroupByContact.setOnClickListener(v -> { + isGroupByContact = true; + loadRecordings(); + }); + + // Selection bar buttons + binding.btnCloseSelection.setOnClickListener(v -> adapter.clearSelection()); + binding.btnSelectAll.setOnClickListener(v -> adapter.selectAll()); + binding.btnShareSelected.setOnClickListener(v -> shareSelectedRecordings()); + binding.btnDeleteSelected.setOnClickListener(v -> deleteSelectedRecordings()); + // Sort FAB binding.fabSort.setOnClickListener(v -> showSortMenu()); loadRecordings(); } + private void initializeBaseDirs() { + var prefs = PreferenceManager.getDefaultSharedPreferences(requireContext()); + String path = prefs.getString("call_recording_path", null); + + baseDirs.clear(); + + // 1. Root folder if MANAGE_EXTERNAL_STORAGE + if (Environment.isExternalStorageManager()) { + baseDirs.add(new File(Environment.getExternalStorageDirectory(), "WA Call Recordings")); + } + + // 2. Settings path + if (path != null && !path.isEmpty()) { + baseDirs.add(new File(path, "WA Call Recordings")); + } + + // 3. WhatsApp app external files + baseDirs.add(new File("/sdcard/Android/data/com.whatsapp/files/Recordings")); + baseDirs.add(new File("/sdcard/Android/data/com.whatsapp.w4b/files/Recordings")); + + // 4. Legacy fallback + baseDirs.add(new File(Environment.getExternalStorageDirectory(), "Music/WaEnhancer/Recordings")); + } + private void loadRecordings() { - recordingFiles.clear(); - if (baseDir.exists() && baseDir.isDirectory()) { - traverseDirectory(baseDir); + allRecordings.clear(); + + for (File baseDir : baseDirs) { + if (baseDir.exists() && baseDir.isDirectory()) { + traverseDirectory(baseDir); + } } - if (recordingFiles.isEmpty()) { + if (allRecordings.isEmpty()) { binding.emptyView.setVisibility(View.VISIBLE); binding.recyclerView.setVisibility(View.GONE); } else { binding.emptyView.setVisibility(View.GONE); binding.recyclerView.setVisibility(View.VISIBLE); - // Default sort by date desc - recordingFiles.sort((f1, f2) -> Long.compare(f2.lastModified(), f1.lastModified())); - adapter.setFiles(recordingFiles); + + // Apply sorting + applySort(); + + if (isGroupByContact) { + // For group by contact, we'll navigate to ContactRecordingsActivity when a contact is clicked + // For now, just show sorted list (full group UI needs ContactRecordingsActivity) + adapter.setRecordings(allRecordings); + } else { + adapter.setRecordings(allRecordings); + } } } @@ -86,74 +152,146 @@ private void traverseDirectory(File dir) { if (file.isDirectory()) { traverseDirectory(file); } else { - if (file.getName().endsWith(".wav") || file.getName().endsWith(".mp3") || file.getName().endsWith(".aac")) { - recordingFiles.add(file); + String name = file.getName().toLowerCase(); + if (name.endsWith(".wav") || name.endsWith(".mp3") || name.endsWith(".aac") || name.endsWith(".m4a")) { + allRecordings.add(new Recording(file, requireContext())); } } } } } + private void applySort() { + switch (currentSortType) { + case 1 -> allRecordings.sort((r1, r2) -> Long.compare(r2.getDate(), r1.getDate())); // Date desc + case 2 -> allRecordings.sort(Comparator.comparing(Recording::getContactName)); // Name + case 3 -> allRecordings.sort((r1, r2) -> Long.compare(r2.getDuration(), r1.getDuration())); // Duration desc + case 4 -> allRecordings.sort(Comparator.comparing(Recording::getContactName) + .thenComparing((r1, r2) -> Long.compare(r2.getDate(), r1.getDate()))); // Contact then date + } + } + private void showSortMenu() { PopupMenu popup = new PopupMenu(requireContext(), binding.fabSort); popup.getMenu().add(0, 1, 0, R.string.sort_date); popup.getMenu().add(0, 2, 0, R.string.sort_name); - popup.getMenu().add(0, 3, 0, R.string.sort_duration); + popup.getMenu().add(0, 3, 0, R.string.sort_duration); + popup.getMenu().add(0, 4, 0, R.string.sort_contact); popup.setOnMenuItemClickListener(item -> { - switch (item.getItemId()) { - case 1 -> recordingFiles.sort((f1, f2) -> Long.compare(f2.lastModified(), f1.lastModified())); - case 2 -> recordingFiles.sort(Comparator.comparing(File::getName)); - case 3 -> recordingFiles.sort((f1, f2) -> Long.compare(f2.length(), f1.length())); // Approximation by size - } - adapter.setFiles(recordingFiles); + currentSortType = item.getItemId(); + applySort(); + adapter.setRecordings(allRecordings); return true; }); popup.show(); } + // RecordingsAdapter.OnRecordingActionListener implementation + @Override - public void onPlay(File file) { - try { - Uri uri = FileProvider.getUriForFile(requireContext(), requireContext().getPackageName() + ".provider", file); - Intent intent = new Intent(Intent.ACTION_VIEW); - intent.setDataAndType(uri, "audio/*"); - intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); - startActivity(intent); - } catch (Exception e) { - Toast.makeText(requireContext(), "Error playing file: " + e.getMessage(), Toast.LENGTH_SHORT).show(); - e.printStackTrace(); - } + public void onPlay(Recording recording) { + // Use in-app audio player + AudioPlayerDialog dialog = new AudioPlayerDialog(requireContext(), recording.getFile()); + dialog.show(); + } + + @Override + public void onShare(Recording recording) { + shareRecording(recording.getFile()); } @Override - public void onShare(File file) { + public void onDelete(Recording recording) { + new AlertDialog.Builder(requireContext()) + .setTitle(R.string.delete_confirmation) + .setMessage(recording.getFile().getName()) + .setPositiveButton(android.R.string.yes, (dialog, which) -> { + if (recording.getFile().delete()) { + loadRecordings(); + } else { + Toast.makeText(requireContext(), "Failed to delete", Toast.LENGTH_SHORT).show(); + } + }) + .setNegativeButton(android.R.string.no, null) + .show(); + } + + @Override + public void onLongPress(Recording recording, int position) { + // Enter selection mode + adapter.setSelectionMode(true); + adapter.toggleSelection(position); + } + + private void shareRecording(File file) { try { - Uri uri = FileProvider.getUriForFile(requireContext(), requireContext().getPackageName() + ".provider", file); + Uri uri = FileProvider.getUriForFile(requireContext(), + requireContext().getPackageName() + ".fileprovider", file); Intent intent = new Intent(Intent.ACTION_SEND); - intent.getType(); // check intent.setType("audio/*"); intent.putExtra(Intent.EXTRA_STREAM, uri); intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); startActivity(Intent.createChooser(intent, getString(R.string.share_recording))); } catch (Exception e) { - e.printStackTrace(); + Toast.makeText(requireContext(), "Error sharing: " + e.getMessage(), Toast.LENGTH_SHORT).show(); } } - @Override - public void onDelete(File file) { + private void shareSelectedRecordings() { + List selected = adapter.getSelectedRecordings(); + if (selected.isEmpty()) return; + + if (selected.size() == 1) { + shareRecording(selected.get(0).getFile()); + adapter.clearSelection(); + return; + } + + ArrayList uris = new ArrayList<>(); + for (Recording rec : selected) { + try { + Uri uri = FileProvider.getUriForFile(requireContext(), + requireContext().getPackageName() + ".fileprovider", rec.getFile()); + uris.add(uri); + } catch (Exception ignored) {} + } + + if (!uris.isEmpty()) { + Intent intent = new Intent(Intent.ACTION_SEND_MULTIPLE); + intent.setType("audio/*"); + intent.putParcelableArrayListExtra(Intent.EXTRA_STREAM, uris); + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + startActivity(Intent.createChooser(intent, getString(R.string.share_recordings))); + } + adapter.clearSelection(); + } + + private void deleteSelectedRecordings() { + List selected = adapter.getSelectedRecordings(); + if (selected.isEmpty()) return; + new AlertDialog.Builder(requireContext()) .setTitle(R.string.delete_confirmation) - .setMessage(file.getName()) + .setMessage(getString(R.string.delete_multiple_confirmation, selected.size())) .setPositiveButton(android.R.string.yes, (dialog, which) -> { - if (file.delete()) { - loadRecordings(); - } else { - Toast.makeText(requireContext(), "Failed to delete", Toast.LENGTH_SHORT).show(); + int deleted = 0; + for (Recording rec : selected) { + if (rec.getFile().delete()) { + deleted++; + } } + Toast.makeText(requireContext(), "Deleted " + deleted + " recordings", Toast.LENGTH_SHORT).show(); + adapter.clearSelection(); + loadRecordings(); }) .setNegativeButton(android.R.string.no, null) .show(); } + + @Override + public void onDestroyView() { + super.onDestroyView(); + binding = null; + } } diff --git a/app/src/main/java/com/wmods/wppenhacer/xposed/features/media/CallRecording.java b/app/src/main/java/com/wmods/wppenhacer/xposed/features/media/CallRecording.java index 3d48fd07..e6a5179b 100644 --- a/app/src/main/java/com/wmods/wppenhacer/xposed/features/media/CallRecording.java +++ b/app/src/main/java/com/wmods/wppenhacer/xposed/features/media/CallRecording.java @@ -12,7 +12,6 @@ import com.wmods.wppenhacer.xposed.core.Feature; import com.wmods.wppenhacer.xposed.core.FeatureLoader; -import com.wmods.wppenhacer.xposed.core.WppCore; import com.wmods.wppenhacer.xposed.core.devkit.Unobfuscator; import com.wmods.wppenhacer.xposed.utils.Utils; @@ -21,6 +20,8 @@ import java.io.File; import java.io.RandomAccessFile; import java.io.IOException; +import java.lang.reflect.Field; +import java.lang.reflect.Method; import java.text.SimpleDateFormat; import java.util.Date; import java.util.Locale; @@ -34,12 +35,15 @@ public class CallRecording extends Feature { private final AtomicBoolean isRecording = new AtomicBoolean(false); + private final AtomicBoolean isCallConnected = new AtomicBoolean(false); private AudioRecord audioRecord; private RandomAccessFile randomAccessFile; private Thread recordingThread; private int payloadSize = 0; + private volatile String currentPhoneNumber = null; + private static boolean permissionGranted = false; - private static final int SAMPLE_RATE = 44100; + private static final int SAMPLE_RATE = 48000; private static final int CHANNEL_CONFIG = AudioFormat.CHANNEL_IN_MONO; private static final int AUDIO_FORMAT = AudioFormat.ENCODING_PCM_16BIT; private static final short CHANNELS = 1; @@ -57,158 +61,215 @@ public void doHook() throws Throwable { } XposedBridge.log("WaEnhancer: Call Recording feature initializing..."); - - // Hook call state changes using multiple approaches hookCallStateChanges(); } private void hookCallStateChanges() { int hooksInstalled = 0; - // Approach 1: Hook VoiceServiceEventCallback.fieldstatsReady (call end detection) try { var clsCallEventCallback = Unobfuscator.findFirstClassUsingName(classLoader, StringMatchType.EndsWith, "VoiceServiceEventCallback"); if (clsCallEventCallback != null) { XposedBridge.log("WaEnhancer: Found VoiceServiceEventCallback: " + clsCallEventCallback.getName()); - XposedBridge.hookAllMethods(clsCallEventCallback, "fieldstatsReady", new XC_MethodHook() { + + // Hook ALL methods to discover which ones fire during call + for (Method method : clsCallEventCallback.getDeclaredMethods()) { + final String methodName = method.getName(); + try { + XposedBridge.hookAllMethods(clsCallEventCallback, methodName, new XC_MethodHook() { + @Override + protected void afterHookedMethod(MethodHookParam param) throws Throwable { + XposedBridge.log("WaEnhancer: VoiceCallback." + methodName + "()"); + + // Handle call end + if (methodName.equals("fieldstatsReady")) { + isCallConnected.set(false); + stopRecording(); + } + } + }); + hooksInstalled++; + } catch (Throwable ignored) {} + } + + // Hook soundPortCreated with 3 second delay to wait for call connection + XposedBridge.hookAllMethods(clsCallEventCallback, "soundPortCreated", new XC_MethodHook() { @Override - protected void beforeHookedMethod(MethodHookParam param) throws Throwable { - XposedBridge.log("WaEnhancer: fieldstatsReady - Call Ended"); - stopRecording(); + protected void afterHookedMethod(MethodHookParam param) throws Throwable { + XposedBridge.log("WaEnhancer: soundPortCreated - will record after 3s"); + extractPhoneNumberFromCallback(param.thisObject); + + final Object callback = param.thisObject; + new Thread(() -> { + try { + Thread.sleep(3000); + if (!isRecording.get()) { + XposedBridge.log("WaEnhancer: Starting recording after delay"); + extractPhoneNumberFromCallback(callback); + isCallConnected.set(true); + startRecording(); + } + } catch (Exception e) { + XposedBridge.log("WaEnhancer: Delay error: " + e.getMessage()); + } + }).start(); } }); - hooksInstalled++; } } catch (Throwable e) { XposedBridge.log("WaEnhancer: Could not hook VoiceServiceEventCallback: " + e.getMessage()); } - // Approach 2: Find VoipActivity using Unobfuscator + // Hook VoipActivity onDestroy for call end try { var voipActivityClass = Unobfuscator.findFirstClassUsingName(classLoader, StringMatchType.Contains, "VoipActivity"); if (voipActivityClass != null && Activity.class.isAssignableFrom(voipActivityClass)) { - hookVoipActivity(voipActivityClass); + XposedBridge.log("WaEnhancer: Found VoipActivity: " + voipActivityClass.getName()); + + XposedBridge.hookAllMethods(voipActivityClass, "onDestroy", new XC_MethodHook() { + @Override + protected void beforeHookedMethod(MethodHookParam param) throws Throwable { + XposedBridge.log("WaEnhancer: VoipActivity.onDestroy"); + isCallConnected.set(false); + stopRecording(); + } + }); hooksInstalled++; - XposedBridge.log("WaEnhancer: Hooked VoipActivity via dexkit: " + voipActivityClass.getName()); } } catch (Throwable e) { - XposedBridge.log("WaEnhancer: Error finding VoipActivity via dexkit: " + e.getMessage()); + XposedBridge.log("WaEnhancer: Could not hook VoipActivity: " + e.getMessage()); } - // Approach 3: Try known class names with fallbacks - String[] possibleClassNames = { - "com.whatsapp.calling.ui.VoipActivityV2", - "com.whatsapp.voipcalling.VoipActivityV2", - "com.whatsapp.voipcalling.VoipActivity", - "com.whatsapp.calling.VoipActivity", - "com.whatsapp.voip.VoipActivity" - }; - - for (String className : possibleClassNames) { + XposedBridge.log("WaEnhancer: Call Recording initialized with " + hooksInstalled + " hooks"); + } + + private void extractPhoneNumberFromCallback(Object callback) { + try { + Object callInfo = XposedHelpers.callMethod(callback, "getCallInfo"); + if (callInfo == null) return; + + // Try to get peerJid and resolve LID to phone number try { - var clazz = XposedHelpers.findClassIfExists(className, classLoader); - if (clazz != null && Activity.class.isAssignableFrom(clazz)) { - hookVoipActivity(clazz); - hooksInstalled++; - XposedBridge.log("WaEnhancer: Hooked known class: " + className); + Object peerJid = XposedHelpers.getObjectField(callInfo, "peerJid"); + if (peerJid != null) { + String peerStr = peerJid.toString(); + XposedBridge.log("WaEnhancer: peerJid = " + peerStr); + + // Check if it's a LID format + if (peerStr.contains("@lid")) { + // Try to get phone from the Jid object + try { + Object userMethod = XposedHelpers.callMethod(peerJid, "getUser"); + XposedBridge.log("WaEnhancer: peerJid.getUser() = " + userMethod); + } catch (Throwable ignored) {} + + // Try toPhoneNumber or similar + try { + Object phone = XposedHelpers.callMethod(peerJid, "toPhoneNumber"); + if (phone != null) { + currentPhoneNumber = "+" + phone.toString(); + XposedBridge.log("WaEnhancer: Found phone from toPhoneNumber: " + currentPhoneNumber); + return; + } + } catch (Throwable ignored) {} + } + + // Check if it's already a phone number format + if (peerStr.contains("@s.whatsapp.net") || peerStr.contains("@c.us")) { + String number = peerStr.split("@")[0]; + if (number.matches("\\d{6,15}")) { + currentPhoneNumber = "+" + number; + XposedBridge.log("WaEnhancer: Found phone: " + currentPhoneNumber); + return; + } + } } } catch (Throwable ignored) {} - } - - // Approach 4: Hook Voip manager class methods - try { - var voipClass = WppCore.getVoipManagerClass(classLoader); - XposedBridge.log("WaEnhancer: Found Voip manager: " + voipClass.getName()); - // Hook all methods to detect call start - for (var method : voipClass.getDeclaredMethods()) { - String methodName = method.getName().toLowerCase(); - if (methodName.contains("start") || methodName.contains("accept") || methodName.contains("answer")) { - try { - XposedBridge.hookMethod(method, new XC_MethodHook() { - @Override - protected void afterHookedMethod(MethodHookParam param) throws Throwable { - XposedBridge.log("WaEnhancer: Voip." + method.getName() + " called"); - startRecording(); + // Search participants map for phone numbers + try { + Object participants = XposedHelpers.getObjectField(callInfo, "participants"); + if (participants != null) { + XposedBridge.log("WaEnhancer: Participants = " + participants.toString()); + + if (participants instanceof java.util.Map) { + java.util.Map map = (java.util.Map) participants; + for (Object key : map.keySet()) { + String keyStr = key.toString(); + XposedBridge.log("WaEnhancer: Participant key = " + keyStr); + + // Check if key contains phone number + if (keyStr.contains("@s.whatsapp.net") || keyStr.contains("@c.us")) { + String number = keyStr.split("@")[0]; + if (number.matches("\\d{6,15}")) { + // Skip if it's the self number (creatorJid) + Object creatorJid = XposedHelpers.getObjectField(callInfo, "creatorJid"); + if (creatorJid != null && keyStr.equals(creatorJid.toString())) { + continue; + } + currentPhoneNumber = "+" + number; + XposedBridge.log("WaEnhancer: Found phone from participants: " + currentPhoneNumber); + return; + } } - }); - hooksInstalled++; - } catch (Throwable ignored) {} + } + } } - } + } catch (Throwable ignored) {} + } catch (Throwable e) { - XposedBridge.log("WaEnhancer: Could not hook Voip manager: " + e.getMessage()); + XposedBridge.log("WaEnhancer: extractPhoneNumber error: " + e.getMessage()); } + } + + private void grantVoiceCallPermission() { + if (permissionGranted) return; - // Approach 5: Hook CallInfo class for call state try { - var callInfoClass = WppCore.getVoipCallInfoClass(classLoader); - XposedBridge.log("WaEnhancer: Found CallInfo: " + callInfoClass.getName()); - XposedBridge.hookAllConstructors(callInfoClass, new XC_MethodHook() { - @Override - protected void afterHookedMethod(MethodHookParam param) throws Throwable { - XposedBridge.log("WaEnhancer: CallInfo created - call starting"); - startRecording(); + String packageName = FeatureLoader.mApp.getPackageName(); + XposedBridge.log("WaEnhancer: Granting CAPTURE_AUDIO_OUTPUT via root"); + + String[] commands = { + "pm grant " + packageName + " android.permission.CAPTURE_AUDIO_OUTPUT", + "appops set " + packageName + " RECORD_AUDIO allow", + }; + + for (String cmd : commands) { + try { + Process process = Runtime.getRuntime().exec(new String[]{"su", "-c", cmd}); + int exitCode = process.waitFor(); + XposedBridge.log("WaEnhancer: " + cmd + " exit: " + exitCode); + } catch (Exception e) { + XposedBridge.log("WaEnhancer: Root failed: " + e.getMessage()); } - }); - hooksInstalled++; + } + + permissionGranted = true; } catch (Throwable e) { - XposedBridge.log("WaEnhancer: Could not hook CallInfo: " + e.getMessage()); + XposedBridge.log("WaEnhancer: grantVoiceCallPermission error: " + e.getMessage()); } - - XposedBridge.log("WaEnhancer: Call Recording initialized with " + hooksInstalled + " hooks"); - } - - private void hookVoipActivity(Class activityClass) { - XposedBridge.hookAllMethods(activityClass, "onResume", new XC_MethodHook() { - @Override - protected void afterHookedMethod(MethodHookParam param) throws Throwable { - XposedBridge.log("WaEnhancer: VoipActivity.onResume - Call Active"); - startRecording(); - } - }); - - XposedBridge.hookAllMethods(activityClass, "onDestroy", new XC_MethodHook() { - @Override - protected void beforeHookedMethod(MethodHookParam param) throws Throwable { - XposedBridge.log("WaEnhancer: VoipActivity.onDestroy - Call Ended"); - stopRecording(); - } - }); - - XposedBridge.hookAllMethods(activityClass, "onStop", new XC_MethodHook() { - @Override - protected void beforeHookedMethod(MethodHookParam param) throws Throwable { - XposedBridge.log("WaEnhancer: VoipActivity.onStop"); - } - }); } private synchronized void startRecording() { if (isRecording.get()) { - XposedBridge.log("WaEnhancer: Already recording, skipping"); + XposedBridge.log("WaEnhancer: Already recording"); return; } try { - // Check microphone permission if (ContextCompat.checkSelfPermission(FeatureLoader.mApp, Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) { XposedBridge.log("WaEnhancer: No RECORD_AUDIO permission"); - Utils.showToast("WaEnhancer: No mic permission", android.widget.Toast.LENGTH_SHORT); return; } String packageName = FeatureLoader.mApp.getPackageName(); String appName = packageName.contains("w4b") ? "WA Business" : "WhatsApp"; - // Get base path from preferences, or use root if MANAGE_EXTERNAL_STORAGE granted File parentDir; if (android.os.Environment.isExternalStorageManager()) { - // Use root folder: /sdcard/WA Call Recordings/ parentDir = new File(android.os.Environment.getExternalStorageDirectory(), "WA Call Recordings"); } else { - // Use path from settings or fallback to app files dir String settingsPath = prefs.getString("call_recording_path", null); if (settingsPath != null && !settingsPath.isEmpty()) { parentDir = new File(settingsPath, "WA Call Recordings"); @@ -217,137 +278,135 @@ private synchronized void startRecording() { } } - // Folder structure: WA Call Recordings/[WhatsApp|WA Business]/Voice/ File dir = new File(parentDir, appName + "/Voice"); - if (!dir.exists()) { - boolean created = dir.mkdirs(); - if (!created) { - XposedBridge.log("WaEnhancer: Failed to create directory: " + dir.getAbsolutePath()); - // Fallback to app files dir - dir = new File(FeatureLoader.mApp.getExternalFilesDir(null), "Recordings/" + appName + "/Voice"); - if (!dir.exists() && !dir.mkdirs()) { - Utils.showToast("WaEnhancer: Dir creation failed", android.widget.Toast.LENGTH_LONG); - return; - } - } + if (!dir.exists() && !dir.mkdirs()) { + dir = new File(FeatureLoader.mApp.getExternalFilesDir(null), "Recordings/" + appName + "/Voice"); + dir.mkdirs(); } - String fileName = "Call_" + new SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(new Date()) + ".wav"; + String timestamp = new SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(new Date()); + String fileName = (currentPhoneNumber != null && !currentPhoneNumber.isEmpty()) + ? "Call_" + currentPhoneNumber.replaceAll("[^+0-9]", "") + "_" + timestamp + ".wav" + : "Call_" + timestamp + ".wav"; + File file = new File(dir, fileName); randomAccessFile = new RandomAccessFile(file, "rw"); - - // Write placeholder WAV header (44 bytes) randomAccessFile.setLength(0); randomAccessFile.write(new byte[44]); - // Calculate buffer size + boolean useRoot = prefs.getBoolean("call_recording_use_root", false); + if (useRoot) { + grantVoiceCallPermission(); + } + int minBufferSize = AudioRecord.getMinBufferSize(SAMPLE_RATE, CHANNEL_CONFIG, AUDIO_FORMAT); - int bufferSize = Math.max(minBufferSize * 2, 8192); + int bufferSize = minBufferSize * 6; + XposedBridge.log("WaEnhancer: Buffer: " + bufferSize + ", useRoot: " + useRoot); - // Create AudioRecord with VOICE_COMMUNICATION source - audioRecord = new AudioRecord( - MediaRecorder.AudioSource.VOICE_COMMUNICATION, - SAMPLE_RATE, - CHANNEL_CONFIG, - AUDIO_FORMAT, - bufferSize - ); + int[] audioSources = useRoot + ? new int[]{MediaRecorder.AudioSource.VOICE_CALL, 6, MediaRecorder.AudioSource.VOICE_COMMUNICATION, MediaRecorder.AudioSource.MIC} + : new int[]{6, MediaRecorder.AudioSource.VOICE_COMMUNICATION, MediaRecorder.AudioSource.MIC}; + String[] sourceNames = useRoot + ? new String[]{"VOICE_CALL", "VOICE_RECOGNITION", "VOICE_COMMUNICATION", "MIC"} + : new String[]{"VOICE_RECOGNITION", "VOICE_COMMUNICATION", "MIC"}; - if (audioRecord.getState() != AudioRecord.STATE_INITIALIZED) { - XposedBridge.log("WaEnhancer: AudioRecord failed to initialize, trying MIC source"); - audioRecord.release(); - - // Fallback to MIC source - audioRecord = new AudioRecord( - MediaRecorder.AudioSource.MIC, - SAMPLE_RATE, - CHANNEL_CONFIG, - AUDIO_FORMAT, - bufferSize - ); - - if (audioRecord.getState() != AudioRecord.STATE_INITIALIZED) { - XposedBridge.log("WaEnhancer: AudioRecord still failed to initialize"); - Utils.showToast("WaEnhancer: AudioRecord init failed", android.widget.Toast.LENGTH_LONG); - return; + audioRecord = null; + String usedSource = "none"; + + for (int i = 0; i < audioSources.length; i++) { + try { + XposedBridge.log("WaEnhancer: Trying " + sourceNames[i]); + AudioRecord testRecord = new AudioRecord(audioSources[i], SAMPLE_RATE, CHANNEL_CONFIG, AUDIO_FORMAT, bufferSize); + if (testRecord.getState() == AudioRecord.STATE_INITIALIZED) { + audioRecord = testRecord; + usedSource = sourceNames[i]; + XposedBridge.log("WaEnhancer: SUCCESS " + sourceNames[i]); + break; + } + testRecord.release(); + XposedBridge.log("WaEnhancer: FAILED " + sourceNames[i]); + } catch (Throwable t) { + XposedBridge.log("WaEnhancer: Exception " + sourceNames[i] + ": " + t.getMessage()); } } + if (audioRecord == null) { + XposedBridge.log("WaEnhancer: All audio sources failed"); + return; + } + isRecording.set(true); payloadSize = 0; - audioRecord.startRecording(); + XposedBridge.log("WaEnhancer: Recording started (" + usedSource + "): " + file.getAbsolutePath()); - // Start recording thread final int finalBufferSize = bufferSize; recordingThread = new Thread(() -> { byte[] buffer = new byte[finalBufferSize]; - while (isRecording.get()) { - int read = audioRecord.read(buffer, 0, buffer.length); - if (read > 0) { - try { + XposedBridge.log("WaEnhancer: Recording thread started"); + + while (isRecording.get() && audioRecord != null) { + try { + int read = audioRecord.read(buffer, 0, buffer.length); + if (read > 0) { synchronized (CallRecording.this) { if (randomAccessFile != null) { randomAccessFile.write(buffer, 0, read); payloadSize += read; } } - } catch (IOException e) { - XposedBridge.log(e); + } else if (read < 0) { + break; } + } catch (IOException e) { + break; } } + XposedBridge.log("WaEnhancer: Recording thread ended, bytes: " + payloadSize); }, "WaEnhancer-RecordingThread"); recordingThread.start(); - XposedBridge.log("WaEnhancer: Recording started: " + file.getAbsolutePath()); - Utils.showToast("Recording: " + fileName, android.widget.Toast.LENGTH_SHORT); + if (prefs.getBoolean("call_recording_toast", true)) { + Utils.showToast("Recording started", android.widget.Toast.LENGTH_SHORT); + } } catch (Exception e) { XposedBridge.log("WaEnhancer: startRecording error: " + e.getMessage()); - XposedBridge.log(e); - Utils.showToast("Rec Error: " + e.getMessage(), android.widget.Toast.LENGTH_LONG); } } private synchronized void stopRecording() { - if (!isRecording.get()) { - return; - } + if (!isRecording.get()) return; isRecording.set(false); try { - // Wait for recording thread to finish - if (recordingThread != null) { - recordingThread.join(1000); - recordingThread = null; - } - - // Stop and release AudioRecord if (audioRecord != null) { - try { - audioRecord.stop(); - } catch (Exception ignored) {} + try { audioRecord.stop(); } catch (Exception ignored) {} audioRecord.release(); audioRecord = null; } - // Write WAV header and close file + if (recordingThread != null) { + recordingThread.join(2000); + recordingThread = null; + } + if (randomAccessFile != null) { writeWavHeader(); randomAccessFile.close(); randomAccessFile = null; } - XposedBridge.log("WaEnhancer: Recording stopped, size: " + payloadSize + " bytes"); - if (payloadSize > 1000) { - Utils.showToast("Recording saved!", android.widget.Toast.LENGTH_SHORT); + XposedBridge.log("WaEnhancer: Recording stopped, size: " + payloadSize); + + if (prefs.getBoolean("call_recording_toast", true)) { + Utils.showToast(payloadSize > 1000 ? "Recording saved!" : "Recording failed", android.widget.Toast.LENGTH_SHORT); } + currentPhoneNumber = null; } catch (Exception e) { XposedBridge.log("WaEnhancer: stopRecording error: " + e.getMessage()); - XposedBridge.log(e); } } diff --git a/app/src/main/res/drawable/circle_button_background.xml b/app/src/main/res/drawable/circle_button_background.xml new file mode 100644 index 00000000..1bad8690 --- /dev/null +++ b/app/src/main/res/drawable/circle_button_background.xml @@ -0,0 +1,5 @@ + + + + diff --git a/app/src/main/res/drawable/dialog_background.xml b/app/src/main/res/drawable/dialog_background.xml new file mode 100644 index 00000000..4a3a58b9 --- /dev/null +++ b/app/src/main/res/drawable/dialog_background.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/app/src/main/res/drawable/duration_badge_background.xml b/app/src/main/res/drawable/duration_badge_background.xml new file mode 100644 index 00000000..e9dedd8e --- /dev/null +++ b/app/src/main/res/drawable/duration_badge_background.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_check_circle.xml b/app/src/main/res/drawable/ic_check_circle.xml new file mode 100644 index 00000000..d5599e06 --- /dev/null +++ b/app/src/main/res/drawable/ic_check_circle.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/ic_close.xml b/app/src/main/res/drawable/ic_close.xml new file mode 100644 index 00000000..5fc26f4d --- /dev/null +++ b/app/src/main/res/drawable/ic_close.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/ic_pause.xml b/app/src/main/res/drawable/ic_pause.xml new file mode 100644 index 00000000..3d9ff3c7 --- /dev/null +++ b/app/src/main/res/drawable/ic_pause.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/ic_play.xml b/app/src/main/res/drawable/ic_play.xml new file mode 100644 index 00000000..01e830d1 --- /dev/null +++ b/app/src/main/res/drawable/ic_play.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/ic_warning.xml b/app/src/main/res/drawable/ic_warning.xml new file mode 100644 index 00000000..729a1c00 --- /dev/null +++ b/app/src/main/res/drawable/ic_warning.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/layout/activity_call_recording_settings.xml b/app/src/main/res/layout/activity_call_recording_settings.xml new file mode 100644 index 00000000..7a41dca6 --- /dev/null +++ b/app/src/main/res/layout/activity_call_recording_settings.xml @@ -0,0 +1,263 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/dialog_audio_player.xml b/app/src/main/res/layout/dialog_audio_player.xml new file mode 100644 index 00000000..4e52787f --- /dev/null +++ b/app/src/main/res/layout/dialog_audio_player.xml @@ -0,0 +1,85 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_recordings.xml b/app/src/main/res/layout/fragment_recordings.xml index 43535684..923b2b8f 100644 --- a/app/src/main/res/layout/fragment_recordings.xml +++ b/app/src/main/res/layout/fragment_recordings.xml @@ -1,40 +1,133 @@ - - + - - - - - - + android:layout_height="56dp" + android:background="?attr/colorPrimary" + android:gravity="center_vertical" + android:orientation="horizontal" + android:paddingHorizontal="16dp" + android:visibility="gone"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_recording.xml b/app/src/main/res/layout/item_recording.xml index 9ab04cf7..817b9439 100644 --- a/app/src/main/res/layout/item_recording.xml +++ b/app/src/main/res/layout/item_recording.xml @@ -1,88 +1,136 @@ + app:strokeWidth="0dp" + android:checkable="true"> + + + + + + + + + + + + app:layout_constraintEnd_toEndOf="@+id/contact_name" + app:layout_constraintStart_toStartOf="@+id/contact_name" + app:layout_constraintTop_toBottomOf="@+id/phone_number" /> - + + - + - + + + + diff --git a/app/src/main/res/values/strings_recordings.xml b/app/src/main/res/values/strings_recordings.xml index eee6e767..698ff76c 100644 --- a/app/src/main/res/values/strings_recordings.xml +++ b/app/src/main/res/values/strings_recordings.xml @@ -1,9 +1,11 @@ - + Recordings No recordings found Are you sure you want to delete this recording? + Are you sure you want to delete %d recordings? Share Recording + Share Recordings Permission Required Full File Access is required to manage recordings stored in the root folder. Grant @@ -11,4 +13,57 @@ Name Date Duration + Contact + + + List + By Contact + + + %d selected + Select All + + + Recordings for %s + %d recording(s) + + + Play + Pause + Play/Pause + Close + Share + Delete + + + Show Recording Notifications + Show toast when recording starts/stops + + + Recording Settings + Select Recording Mode + Choose how call audio is captured. Root mode provides better quality but requires a rooted device. + + + 🔓 Root Mode (Recommended) + Uses system-level audio capture for the best recording quality. + Records both sides of the conversation + Crystal clear audio quality + Works with all VoIP apps + ⚠️ Requires rooted device with Magisk/SuperSU + + + 📱 Non-Root Mode + Uses standard Android audio APIs. Works on all devices but with limitations. + No root required + Works on any Android device + May only capture your microphone (not the other party) + + + ✓ Advantages + ⚠ Limitations + Changes take effect on the next call. Restart WhatsApp after changing settings for best results. + Root access granted! VOICE_CALL source enabled. + Root access denied. Falling back to non-root mode. + Non-root mode enabled. diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml new file mode 100644 index 00000000..cbed144b --- /dev/null +++ b/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,15 @@ + + + + + + + diff --git a/app/src/main/res/xml/fragment_media.xml b/app/src/main/res/xml/fragment_media.xml index 5d8432de..516fbf57 100644 --- a/app/src/main/res/xml/fragment_media.xml +++ b/app/src/main/res/xml/fragment_media.xml @@ -81,6 +81,19 @@ app:key="call_recording_path" app:title="@string/call_recording_path" /> + + + + Date: Mon, 29 Dec 2025 11:37:26 +0500 Subject: [PATCH 11/12] ci: Enable Android workflow to trigger on feature/* branches. --- .github/workflows/android.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 62d2e131..6c9150b2 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -2,7 +2,7 @@ name: Android CI on: push: - branches: [ "master" ] + branches: [ "master", "feature/*" ] jobs: build: permissions: write-all From 6d9387bb90ccd42b9a4edd632c3e96d038079cf0 Mon Sep 17 00:00:00 2001 From: mubashardev Date: Mon, 29 Dec 2025 21:09:14 +0500 Subject: [PATCH 12/12] Revert project URLs to original (keeping feature attribution) --- .../java/com/wmods/wppenhacer/activities/AboutActivity.java | 2 +- docs/README.md | 4 ++-- docs/README.pt-BR.md | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/wmods/wppenhacer/activities/AboutActivity.java b/app/src/main/java/com/wmods/wppenhacer/activities/AboutActivity.java index bc68c791..71b08627 100644 --- a/app/src/main/java/com/wmods/wppenhacer/activities/AboutActivity.java +++ b/app/src/main/java/com/wmods/wppenhacer/activities/AboutActivity.java @@ -26,7 +26,7 @@ protected void onCreate(@Nullable Bundle savedInstanceState) { binding.btnGithub.setOnClickListener(view -> { Intent intent = new Intent(); intent.setAction(Intent.ACTION_VIEW); - intent.setData(Uri.parse("https://github.com/mubashardev/WaEnhancer")); + intent.setData(Uri.parse("https://github.com/Dev4Mod/WaEnhancer")); startActivity(intent); }); binding.btnDonate.setOnClickListener(view -> { diff --git a/docs/README.md b/docs/README.md index 3e5d1359..dc92e055 100644 --- a/docs/README.md +++ b/docs/README.md @@ -4,7 +4,7 @@

WaEnhancer is an Xposed module that enhances your WhatsApp experience.

Warning: This module is intended for educational purposes only, you may have problems with your WhatsApp account, risk of banning! Use it at your own risk.

-

Please note that this project is currently in development, so bugs and crashes may occur. If you encounter any issues report them in our group or create an issue here.

+

Please note that this project is currently in development, so bugs and crashes may occur. If you encounter any issues report them in our group or create an issue here.

@@ -139,7 +139,7 @@ 1. Ensure that your device is rooted. 2. Install the Xposed Framework (recommend [this](https://github.com/JingMatrix/LSPosed) LPosed) on your device. -3. Download the WaEnhancer from the [Actions](https://github.com/mubashardev/WaEnhancer/actions) section. +3. Download the WaEnhancer from the [Actions](https://github.com/Dev4Mod/WaEnhancer/actions) section. 4. Install the WaEnhancer APK. 5. Enable the WaEnhancer module in the Xposed Installer app. diff --git a/docs/README.pt-BR.md b/docs/README.pt-BR.md index dc76bf21..5375d8a2 100644 --- a/docs/README.pt-BR.md +++ b/docs/README.pt-BR.md @@ -5,7 +5,7 @@

WaEnhancer é um módulo Xposed que melhora sua experiência no WhatsApp.

Aviso: Este módulo é destinado apenas para fins educacionais. Você pode ter problemas com sua conta do WhatsApp, incluindo risco de banimento! Use por sua conta e risco.

-

Observe que este projeto está atualmente em desenvolvimento, então bugs e falhas podem ocorrer. Se encontrar algum problema, reporte em nosso grupo ou crie um relatório de problema aqui.

+

Observe que este projeto está atualmente em desenvolvimento, então bugs e falhas podem ocorrer. Se encontrar algum problema, reporte em nosso grupo ou crie um relatório de problema aqui.

## Principais Funcionalidades @@ -138,7 +138,7 @@ ## Instalação 1. Certifique-se de que seu dispositivo está com root. 2. Instale o Xposed Framework (recomendamos [este](https://github.com/JingMatrix/LSPosed) LSPosed) no seu dispositivo. -3. Baixe o WaEnhancer na aba [Actions](https://github.com/mubashardev/WaEnhancer/actions). +3. Baixe o WaEnhancer na aba [Actions](https://github.com/Dev4Mod/WaEnhancer/actions). 4. Instale o APK do WaEnhancer. 5. Ative o módulo WaEnhancer no aplicativo Xposed Installer(LSPosed).