diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml
index dc863834..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
@@ -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/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" />
+
+
+
+
+
+
{
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/Dev4Mod/WaEnhancer"));
startActivity(intent);
});
binding.btnDonate.setOnClickListener(view -> {
diff --git a/app/src/main/java/com/wmods/wppenhacer/activities/CallRecordingSettingsActivity.java b/app/src/main/java/com/wmods/wppenhacer/activities/CallRecordingSettingsActivity.java
new file mode 100644
index 00000000..bea9f2a6
--- /dev/null
+++ b/app/src/main/java/com/wmods/wppenhacer/activities/CallRecordingSettingsActivity.java
@@ -0,0 +1,135 @@
+package com.wmods.wppenhacer.activities;
+
+import android.content.SharedPreferences;
+import android.os.Bundle;
+import android.util.Log;
+import android.view.MenuItem;
+import android.widget.RadioButton;
+import android.widget.RadioGroup;
+import android.widget.Toast;
+
+import androidx.appcompat.app.AppCompatActivity;
+import androidx.preference.PreferenceManager;
+
+import com.google.android.material.appbar.MaterialToolbar;
+import com.wmods.wppenhacer.R;
+
+import java.io.BufferedReader;
+import java.io.DataOutputStream;
+import java.io.InputStreamReader;
+
+public class CallRecordingSettingsActivity extends AppCompatActivity {
+
+ private static final String TAG = "WaEnhancer";
+ private SharedPreferences prefs;
+ private RadioGroup radioGroupMode;
+ private RadioButton radioRoot;
+ private RadioButton radioNonRoot;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_call_recording_settings);
+
+ MaterialToolbar toolbar = findViewById(R.id.toolbar);
+ setSupportActionBar(toolbar);
+ if (getSupportActionBar() != null) {
+ getSupportActionBar().setDisplayHomeAsUpEnabled(true);
+ getSupportActionBar().setTitle(R.string.call_recording_settings);
+ }
+
+ prefs = PreferenceManager.getDefaultSharedPreferences(this);
+
+ radioGroupMode = findViewById(R.id.radio_group_mode);
+ radioRoot = findViewById(R.id.radio_root);
+ radioNonRoot = findViewById(R.id.radio_non_root);
+
+ // Load saved preference
+ boolean useRoot = prefs.getBoolean("call_recording_use_root", false);
+ Log.d(TAG, "Loaded call_recording_use_root: " + useRoot);
+
+ if (useRoot) {
+ radioRoot.setChecked(true);
+ } else {
+ radioNonRoot.setChecked(true);
+ }
+
+ // Direct click listeners on radio buttons
+ radioRoot.setOnClickListener(v -> {
+ 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/activities/MainActivity.java b/app/src/main/java/com/wmods/wppenhacer/activities/MainActivity.java
index 615055ad..76af3855 100644
--- a/app/src/main/java/com/wmods/wppenhacer/activities/MainActivity.java
+++ b/app/src/main/java/com/wmods/wppenhacer/activities/MainActivity.java
@@ -43,6 +43,10 @@ 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.viewPager.setPageTransformer(new DepthPageTransformer());
binding.navView.setOnItemSelectedListener(new NavigationBarView.OnItemSelectedListener() {
@@ -70,6 +74,10 @@ public boolean onNavigationItemSelected(@NonNull MenuItem item) {
binding.viewPager.setCurrentItem(4, true);
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..b84d72b4
--- /dev/null
+++ b/app/src/main/java/com/wmods/wppenhacer/adapter/RecordingsAdapter.java
@@ -0,0 +1,220 @@
+package com.wmods.wppenhacer.adapter;
+
+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.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 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(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;
+ }
+
+ 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
+ @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) {
+ Recording recording = recordings.get(position);
+
+ // Contact name
+ holder.contactName.setText(recording.getContactName());
+
+ // 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 recordings.size();
+ }
+
+ static class ViewHolder extends RecyclerView.ViewHolder {
+ 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);
+ 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 ffde3e6a..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 {
@@ -25,5 +27,30 @@ public void onResume() {
public void onCreatePreferences(@Nullable Bundle savedInstanceState, @Nullable String rootKey) {
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);
+ 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/ui/fragments/RecordingsFragment.java b/app/src/main/java/com/wmods/wppenhacer/ui/fragments/RecordingsFragment.java
new file mode 100644
index 00000000..791e2d4e
--- /dev/null
+++ b/app/src/main/java/com/wmods/wppenhacer/ui/fragments/RecordingsFragment.java
@@ -0,0 +1,297 @@
+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.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.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.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 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
+ 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);
+
+ // 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() {
+ allRecordings.clear();
+
+ for (File baseDir : baseDirs) {
+ if (baseDir.exists() && baseDir.isDirectory()) {
+ traverseDirectory(baseDir);
+ }
+ }
+
+ if (allRecordings.isEmpty()) {
+ binding.emptyView.setVisibility(View.VISIBLE);
+ binding.recyclerView.setVisibility(View.GONE);
+ } else {
+ binding.emptyView.setVisibility(View.GONE);
+ binding.recyclerView.setVisibility(View.VISIBLE);
+
+ // 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);
+ }
+ }
+ }
+
+ private void traverseDirectory(File dir) {
+ File[] files = dir.listFiles();
+ if (files != null) {
+ for (File file : files) {
+ if (file.isDirectory()) {
+ traverseDirectory(file);
+ } else {
+ 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, 4, 0, R.string.sort_contact);
+
+ popup.setOnMenuItemClickListener(item -> {
+ currentSortType = item.getItemId();
+ applySort();
+ adapter.setRecordings(allRecordings);
+ return true;
+ });
+ popup.show();
+ }
+
+ // RecordingsAdapter.OnRecordingActionListener implementation
+
+ @Override
+ 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 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() + ".fileprovider", file);
+ Intent intent = new Intent(Intent.ACTION_SEND);
+ 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) {
+ Toast.makeText(requireContext(), "Error sharing: " + e.getMessage(), Toast.LENGTH_SHORT).show();
+ }
+ }
+
+ 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(getString(R.string.delete_multiple_confirmation, selected.size()))
+ .setPositiveButton(android.R.string.yes, (dialog, which) -> {
+ 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/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 1086aefe..22ba442e 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,19 +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(FindMethod.create().matcher(MethodMatcher.create().addUsingString("audio/ogg; codecs=opu").returnType(boolean.class)));
- var FMessageClass = loadFMessageClass(classLoader);
- if (result.isEmpty()) {
- throw new RuntimeException("OriginFMessageField not found");
- }
- for (var clazz : result) {
- var fields = clazz.getUsingFields();
- for (var field : fields) {
- var f = field.getField().getFieldInstance(classLoader);
- if (FMessageClass.isAssignableFrom(f.getDeclaringClass())) {
- return f;
+ String[] commonStrings = new String[]{
+ "audio/ogg; codecs=opus",
+ "audio/ogg",
+ "audio/amr",
+ "audio/mp4",
+ "audio/aac"
+ };
+
+ var clazz = loadFMessageClass(classLoader);
+
+ 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 field 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 1cf0df8a..d8a1f1e8 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
new file mode 100644
index 00000000..e6a5179b
--- /dev/null
+++ b/app/src/main/java/com/wmods/wppenhacer/xposed/features/media/CallRecording.java
@@ -0,0 +1,454 @@
+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.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.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.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.text.SimpleDateFormat;
+import java.util.Date;
+import java.util.Locale;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+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 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 = 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;
+ private static final short BITS_PER_SAMPLE = 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)) {
+ XposedBridge.log("WaEnhancer: Call Recording is disabled");
+ return;
+ }
+
+ XposedBridge.log("WaEnhancer: Call Recording feature initializing...");
+ hookCallStateChanges();
+ }
+
+ private void hookCallStateChanges() {
+ int hooksInstalled = 0;
+
+ try {
+ var clsCallEventCallback = Unobfuscator.findFirstClassUsingName(classLoader, StringMatchType.EndsWith, "VoiceServiceEventCallback");
+ if (clsCallEventCallback != null) {
+ XposedBridge.log("WaEnhancer: Found VoiceServiceEventCallback: " + clsCallEventCallback.getName());
+
+ // 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 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();
+ }
+ });
+ }
+ } catch (Throwable e) {
+ XposedBridge.log("WaEnhancer: Could not hook VoiceServiceEventCallback: " + e.getMessage());
+ }
+
+ // Hook VoipActivity onDestroy for call end
+ try {
+ var voipActivityClass = Unobfuscator.findFirstClassUsingName(classLoader, StringMatchType.Contains, "VoipActivity");
+ if (voipActivityClass != null && Activity.class.isAssignableFrom(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++;
+ }
+ } catch (Throwable e) {
+ XposedBridge.log("WaEnhancer: Could not hook VoipActivity: " + e.getMessage());
+ }
+
+ 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 {
+ 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) {}
+
+ // 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;
+ }
+ }
+ }
+ }
+ }
+ } catch (Throwable ignored) {}
+
+ } catch (Throwable e) {
+ XposedBridge.log("WaEnhancer: extractPhoneNumber error: " + e.getMessage());
+ }
+ }
+
+ private void grantVoiceCallPermission() {
+ if (permissionGranted) return;
+
+ try {
+ 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());
+ }
+ }
+
+ permissionGranted = true;
+ } catch (Throwable e) {
+ XposedBridge.log("WaEnhancer: grantVoiceCallPermission error: " + e.getMessage());
+ }
+ }
+
+ private synchronized void startRecording() {
+ if (isRecording.get()) {
+ XposedBridge.log("WaEnhancer: Already recording");
+ return;
+ }
+
+ try {
+ if (ContextCompat.checkSelfPermission(FeatureLoader.mApp, Manifest.permission.RECORD_AUDIO)
+ != PackageManager.PERMISSION_GRANTED) {
+ XposedBridge.log("WaEnhancer: No RECORD_AUDIO permission");
+ return;
+ }
+
+ String packageName = FeatureLoader.mApp.getPackageName();
+ String appName = packageName.contains("w4b") ? "WA Business" : "WhatsApp";
+
+ File parentDir;
+ if (android.os.Environment.isExternalStorageManager()) {
+ parentDir = new File(android.os.Environment.getExternalStorageDirectory(), "WA Call Recordings");
+ } else {
+ 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 + "/Voice");
+ if (!dir.exists() && !dir.mkdirs()) {
+ dir = new File(FeatureLoader.mApp.getExternalFilesDir(null), "Recordings/" + appName + "/Voice");
+ dir.mkdirs();
+ }
+
+ 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");
+ randomAccessFile.setLength(0);
+ randomAccessFile.write(new byte[44]);
+
+ boolean useRoot = prefs.getBoolean("call_recording_use_root", false);
+ if (useRoot) {
+ grantVoiceCallPermission();
+ }
+
+ int minBufferSize = AudioRecord.getMinBufferSize(SAMPLE_RATE, CHANNEL_CONFIG, AUDIO_FORMAT);
+ int bufferSize = minBufferSize * 6;
+ XposedBridge.log("WaEnhancer: Buffer: " + bufferSize + ", useRoot: " + useRoot);
+
+ 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"};
+
+ 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());
+
+ final int finalBufferSize = bufferSize;
+ recordingThread = new Thread(() -> {
+ byte[] buffer = new byte[finalBufferSize];
+ 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;
+ }
+ }
+ } else if (read < 0) {
+ break;
+ }
+ } catch (IOException e) {
+ break;
+ }
+ }
+ XposedBridge.log("WaEnhancer: Recording thread ended, bytes: " + payloadSize);
+ }, "WaEnhancer-RecordingThread");
+ recordingThread.start();
+
+ 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());
+ }
+ }
+
+ private synchronized void stopRecording() {
+ if (!isRecording.get()) return;
+
+ isRecording.set(false);
+
+ try {
+ if (audioRecord != null) {
+ try { audioRecord.stop(); } catch (Exception ignored) {}
+ audioRecord.release();
+ audioRecord = null;
+ }
+
+ if (recordingThread != null) {
+ recordingThread.join(2000);
+ recordingThread = null;
+ }
+
+ if (randomAccessFile != null) {
+ writeWavHeader();
+ randomAccessFile.close();
+ randomAccessFile = null;
+ }
+
+ 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());
+ }
+ }
+
+ private void writeWavHeader() throws IOException {
+ long totalDataLen = payloadSize + 36;
+ long byteRate = (long) SAMPLE_RATE * CHANNELS * BITS_PER_SAMPLE / 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) (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 * 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);
+ 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/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;
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_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/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
new file mode 100644
index 00000000..923b2b8f
--- /dev/null
+++ b/app/src/main/res/layout/fragment_recordings.xml
@@ -0,0 +1,133 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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..817b9439
--- /dev/null
+++ b/app/src/main/res/layout/item_recording.xml
@@ -0,0 +1,137 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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/arrays.xml b/app/src/main/res/values/arrays.xml
index 9d754cda..676a6400 100644
--- a/app/src/main/res/values/arrays.xml
+++ b/app/src/main/res/values/arrays.xml
@@ -123,6 +123,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.26.1.xx
@@ -130,6 +133,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.26.1.xx
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/values/strings_recordings.xml b/app/src/main/res/values/strings_recordings.xml
new file mode 100644
index 00000000..698ff76c
--- /dev/null
+++ b/app/src/main/res/values/strings_recordings.xml
@@ -0,0 +1,69 @@
+
+
+ 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
+ Sort By
+ 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 329afd8d..516fbf57 100644
--- a/app/src/main/res/xml/fragment_media.xml
+++ b/app/src/main/res/xml/fragment_media.xml
@@ -65,6 +65,42 @@
app:title="@string/send_video_in_60fps" />
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/changelog.txt b/changelog.txt
index 06ba7ede..a0157839 100644
--- a/changelog.txt
+++ b/changelog.txt
@@ -1,5 +1,7 @@
[WHATSAPP]
* Improved support for version 2.25.37.XX
+* Added Call Recording feature (Voice/Video as Audio)
+* Fixed OriginFMessageField not found error
* Add support for version 2.26.1.XX beta
[WAE]
diff --git a/gradlew b/gradlew
old mode 100644
new mode 100755