diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 5a08d7a..b6e35e3 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -81,6 +81,11 @@ + { + Intent filterIntent = new Intent(MainActivity.this, SMSFilterActivity.class); + startActivity(filterIntent); + }); } private void renderAvailableSimOptions() { diff --git a/android/app/src/main/java/com/vernu/sms/activities/SMSFilterActivity.java b/android/app/src/main/java/com/vernu/sms/activities/SMSFilterActivity.java new file mode 100644 index 0000000..832e0c2 --- /dev/null +++ b/android/app/src/main/java/com/vernu/sms/activities/SMSFilterActivity.java @@ -0,0 +1,267 @@ +package com.vernu.sms.activities; + +import android.app.AlertDialog; +import android.content.Context; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ArrayAdapter; +import android.widget.Button; +import android.widget.ImageButton; +import android.widget.RadioButton; +import android.widget.RadioGroup; +import android.widget.Spinner; +import android.widget.Switch; +import android.widget.TextView; +import android.widget.Toast; +import androidx.annotation.NonNull; +import androidx.appcompat.app.AppCompatActivity; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; +import com.google.android.material.floatingactionbutton.FloatingActionButton; +import com.google.android.material.textfield.TextInputEditText; +import com.vernu.sms.R; +import com.vernu.sms.helpers.SMSFilterHelper; +import com.vernu.sms.models.SMSFilterRule; + +import java.util.ArrayList; +import java.util.List; + +public class SMSFilterActivity extends AppCompatActivity { + private Context mContext; + private Switch filterEnabledSwitch; + private RadioGroup filterModeRadioGroup; + private RadioButton allowListRadio; + private RadioButton blockListRadio; + private RecyclerView filterRulesRecyclerView; + private FloatingActionButton addRuleFab; + private FilterRulesAdapter adapter; + private SMSFilterHelper.FilterConfig filterConfig; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_sms_filter); + + mContext = getApplicationContext(); + + // Initialize views + ImageButton backButton = findViewById(R.id.backButton); + filterEnabledSwitch = findViewById(R.id.filterEnabledSwitch); + filterModeRadioGroup = findViewById(R.id.filterModeRadioGroup); + allowListRadio = findViewById(R.id.allowListRadio); + blockListRadio = findViewById(R.id.blockListRadio); + filterRulesRecyclerView = findViewById(R.id.filterRulesRecyclerView); + addRuleFab = findViewById(R.id.addRuleFab); + + // Setup back button + backButton.setOnClickListener(v -> finish()); + + // Load filter config + filterConfig = SMSFilterHelper.loadFilterConfig(mContext); + + // Setup RecyclerView + adapter = new FilterRulesAdapter(filterConfig.getRules()); + filterRulesRecyclerView.setLayoutManager(new LinearLayoutManager(this)); + filterRulesRecyclerView.setAdapter(adapter); + + // Load current settings + filterEnabledSwitch.setChecked(filterConfig.isEnabled()); + // Default to block list if mode is not set + if (filterConfig.getMode() == SMSFilterHelper.FilterMode.ALLOW_LIST) { + allowListRadio.setChecked(true); + } else { + blockListRadio.setChecked(true); + } + + // Setup listeners + filterEnabledSwitch.setOnCheckedChangeListener((buttonView, isChecked) -> { + filterConfig.setEnabled(isChecked); + saveFilterConfig(); + }); + + filterModeRadioGroup.setOnCheckedChangeListener((group, checkedId) -> { + if (checkedId == R.id.allowListRadio) { + filterConfig.setMode(SMSFilterHelper.FilterMode.ALLOW_LIST); + } else { + filterConfig.setMode(SMSFilterHelper.FilterMode.BLOCK_LIST); + } + saveFilterConfig(); + }); + + addRuleFab.setOnClickListener(v -> showAddEditRuleDialog(-1)); + } + + private void saveFilterConfig() { + SMSFilterHelper.saveFilterConfig(mContext, filterConfig); + } + + private void showAddEditRuleDialog(int position) { + AlertDialog.Builder builder = new AlertDialog.Builder(this); + View dialogView = LayoutInflater.from(this).inflate(R.layout.dialog_add_filter_rule, null); + builder.setView(dialogView); + + TextInputEditText patternEditText = dialogView.findViewById(R.id.patternEditText); + Spinner filterTargetSpinner = dialogView.findViewById(R.id.filterTargetSpinner); + Spinner matchTypeSpinner = dialogView.findViewById(R.id.matchTypeSpinner); + Switch caseSensitiveSwitch = dialogView.findViewById(R.id.caseSensitiveSwitch); + Button cancelButton = dialogView.findViewById(R.id.cancelButton); + Button saveButton = dialogView.findViewById(R.id.saveButton); + + // Setup filter target spinner + String[] filterTargets = {"Sender", "Message", "Both"}; + ArrayAdapter targetAdapter = new ArrayAdapter<>(this, android.R.layout.simple_spinner_item, filterTargets); + targetAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); + filterTargetSpinner.setAdapter(targetAdapter); + + // Setup match type spinner + String[] matchTypes = {"Exact Match", "Starts With", "Ends With", "Contains"}; + ArrayAdapter spinnerAdapter = new ArrayAdapter<>(this, android.R.layout.simple_spinner_item, matchTypes); + spinnerAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); + matchTypeSpinner.setAdapter(spinnerAdapter); + + // If editing, populate fields + boolean isEdit = position >= 0; + TextView dialogTitle = dialogView.findViewById(R.id.dialogTitle); + if (isEdit) { + SMSFilterRule rule = filterConfig.getRules().get(position); + patternEditText.setText(rule.getPattern()); + filterTargetSpinner.setSelection(rule.getFilterTarget().ordinal()); + matchTypeSpinner.setSelection(rule.getMatchType().ordinal()); + caseSensitiveSwitch.setChecked(rule.isCaseSensitive()); + if (dialogTitle != null) { + dialogTitle.setText("Edit Filter Rule"); + } + } else { + // Default to case insensitive + caseSensitiveSwitch.setChecked(false); + } + + AlertDialog dialog = builder.create(); + + cancelButton.setOnClickListener(v -> dialog.dismiss()); + + saveButton.setOnClickListener(v -> { + String pattern = patternEditText.getText() != null ? patternEditText.getText().toString().trim() : ""; + if (pattern.isEmpty()) { + Toast.makeText(this, "Please enter a pattern", Toast.LENGTH_SHORT).show(); + return; + } + + SMSFilterRule.FilterTarget filterTarget = SMSFilterRule.FilterTarget.values()[filterTargetSpinner.getSelectedItemPosition()]; + SMSFilterRule.MatchType matchType = SMSFilterRule.MatchType.values()[matchTypeSpinner.getSelectedItemPosition()]; + boolean caseSensitive = caseSensitiveSwitch.isChecked(); + + if (isEdit) { + SMSFilterRule rule = filterConfig.getRules().get(position); + rule.setPattern(pattern); + rule.setFilterTarget(filterTarget); + rule.setMatchType(matchType); + rule.setCaseSensitive(caseSensitive); + adapter.notifyItemChanged(position); + } else { + SMSFilterRule newRule = new SMSFilterRule(pattern, matchType, filterTarget, caseSensitive); + filterConfig.getRules().add(newRule); + adapter.notifyItemInserted(filterConfig.getRules().size() - 1); + } + + saveFilterConfig(); + dialog.dismiss(); + }); + + dialog.show(); + } + + private void deleteRule(int position) { + new AlertDialog.Builder(this) + .setTitle("Delete Rule") + .setMessage("Are you sure you want to delete this filter rule?") + .setPositiveButton("Delete", (dialog, which) -> { + filterConfig.getRules().remove(position); + adapter.notifyItemRemoved(position); + saveFilterConfig(); + }) + .setNegativeButton("Cancel", null) + .show(); + } + + private class FilterRulesAdapter extends RecyclerView.Adapter { + private List rules; + + public FilterRulesAdapter(List rules) { + this.rules = rules != null ? rules : new ArrayList<>(); + } + + @NonNull + @Override + public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + View view = LayoutInflater.from(parent.getContext()) + .inflate(R.layout.item_filter_rule, parent, false); + return new ViewHolder(view); + } + + @Override + public void onBindViewHolder(@NonNull ViewHolder holder, int position) { + SMSFilterRule rule = rules.get(position); + holder.patternText.setText(rule.getPattern()); + + String matchTypeText = ""; + switch (rule.getMatchType()) { + case EXACT: + matchTypeText = "Exact Match"; + break; + case STARTS_WITH: + matchTypeText = "Starts With"; + break; + case ENDS_WITH: + matchTypeText = "Ends With"; + break; + case CONTAINS: + matchTypeText = "Contains"; + break; + } + holder.matchTypeText.setText(matchTypeText); + + String filterTargetText = ""; + switch (rule.getFilterTarget()) { + case SENDER: + filterTargetText = "Filter: Sender"; + break; + case MESSAGE: + filterTargetText = "Filter: Message"; + break; + case BOTH: + filterTargetText = "Filter: Sender or Message"; + break; + } + String caseText = rule.isCaseSensitive() ? " (Case Sensitive)" : " (Case Insensitive)"; + holder.filterTargetText.setText(filterTargetText + caseText); + + holder.editButton.setOnClickListener(v -> showAddEditRuleDialog(position)); + holder.deleteButton.setOnClickListener(v -> deleteRule(position)); + } + + @Override + public int getItemCount() { + return rules.size(); + } + + class ViewHolder extends RecyclerView.ViewHolder { + TextView patternText; + TextView matchTypeText; + TextView filterTargetText; + ImageButton editButton; + ImageButton deleteButton; + + ViewHolder(View itemView) { + super(itemView); + patternText = itemView.findViewById(R.id.patternText); + matchTypeText = itemView.findViewById(R.id.matchTypeText); + filterTargetText = itemView.findViewById(R.id.filterTargetText); + editButton = itemView.findViewById(R.id.editButton); + deleteButton = itemView.findViewById(R.id.deleteButton); + } + } + } +} diff --git a/android/app/src/main/java/com/vernu/sms/helpers/SMSFilterHelper.java b/android/app/src/main/java/com/vernu/sms/helpers/SMSFilterHelper.java new file mode 100644 index 0000000..999713e --- /dev/null +++ b/android/app/src/main/java/com/vernu/sms/helpers/SMSFilterHelper.java @@ -0,0 +1,142 @@ +package com.vernu.sms.helpers; + +import android.content.Context; +import android.util.Log; +import com.google.gson.Gson; +import com.google.gson.reflect.TypeToken; +import com.vernu.sms.AppConstants; +import com.vernu.sms.models.SMSFilterRule; + +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.List; + +public class SMSFilterHelper { + private static final String TAG = "SMSFilterHelper"; + + public enum FilterMode { + ALLOW_LIST, + BLOCK_LIST + } + + public static class FilterConfig { + private boolean enabled = false; + private FilterMode mode = FilterMode.BLOCK_LIST; // Default to block list + private List rules = new ArrayList<>(); + + public FilterConfig() { + } + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public FilterMode getMode() { + return mode; + } + + public void setMode(FilterMode mode) { + this.mode = mode; + } + + public List getRules() { + return rules; + } + + public void setRules(List rules) { + this.rules = rules != null ? rules : new ArrayList<>(); + } + } + + /** + * Load filter configuration from SharedPreferences + */ + public static FilterConfig loadFilterConfig(Context context) { + String json = SharedPreferenceHelper.getSharedPreferenceString( + context, + AppConstants.SHARED_PREFS_SMS_FILTER_CONFIG_KEY, + null + ); + + if (json == null || json.isEmpty()) { + return new FilterConfig(); + } + + try { + Gson gson = new Gson(); + Type type = new TypeToken() {}.getType(); + FilterConfig config = gson.fromJson(json, type); + return config != null ? config : new FilterConfig(); + } catch (Exception e) { + Log.e(TAG, "Error loading filter config: " + e.getMessage()); + return new FilterConfig(); + } + } + + /** + * Save filter configuration to SharedPreferences + */ + public static void saveFilterConfig(Context context, FilterConfig config) { + try { + Gson gson = new Gson(); + String json = gson.toJson(config); + SharedPreferenceHelper.setSharedPreferenceString( + context, + AppConstants.SHARED_PREFS_SMS_FILTER_CONFIG_KEY, + json + ); + } catch (Exception e) { + Log.e(TAG, "Error saving filter config: " + e.getMessage()); + } + } + + /** + * Check if an SMS should be processed based on filter configuration + * @param sender The sender phone number + * @param message The message content + * @param context Application context + * @return true if SMS should be processed, false if it should be filtered out + */ + public static boolean shouldProcessSMS(String sender, String message, Context context) { + FilterConfig config = loadFilterConfig(context); + + // If filter is disabled, process all SMS + if (!config.isEnabled()) { + return true; + } + + // If no rules, process all SMS (empty filter doesn't block anything) + if (config.getRules() == null || config.getRules().isEmpty()) { + return true; + } + + // Check if sender and/or message matches any rule + boolean matchesAnyRule = false; + for (SMSFilterRule rule : config.getRules()) { + if (rule.matches(sender, message)) { + matchesAnyRule = true; + break; + } + } + + // Apply filter mode + if (config.getMode() == FilterMode.ALLOW_LIST) { + // Only process if matches a rule + return matchesAnyRule; + } else { + // Block list: process if it does NOT match any rule + return !matchesAnyRule; + } + } + + /** + * Legacy method for backward compatibility - checks sender only + */ + public static boolean shouldProcessSMS(String sender, Context context) { + return shouldProcessSMS(sender, null, context); + } +} diff --git a/android/app/src/main/java/com/vernu/sms/models/SMSFilterRule.java b/android/app/src/main/java/com/vernu/sms/models/SMSFilterRule.java new file mode 100644 index 0000000..97574e2 --- /dev/null +++ b/android/app/src/main/java/com/vernu/sms/models/SMSFilterRule.java @@ -0,0 +1,135 @@ +package com.vernu.sms.models; + +public class SMSFilterRule { + public enum MatchType { + EXACT, + STARTS_WITH, + ENDS_WITH, + CONTAINS + } + + public enum FilterTarget { + SENDER, + MESSAGE, + BOTH + } + + private String pattern; + private MatchType matchType; + private FilterTarget filterTarget = FilterTarget.SENDER; // Default to sender for backward compatibility + private boolean caseSensitive = false; // Default to case insensitive + + public SMSFilterRule() { + } + + public SMSFilterRule(String pattern, MatchType matchType) { + this.pattern = pattern; + this.matchType = matchType; + this.filterTarget = FilterTarget.SENDER; + this.caseSensitive = false; + } + + public SMSFilterRule(String pattern, MatchType matchType, FilterTarget filterTarget) { + this.pattern = pattern; + this.matchType = matchType; + this.filterTarget = filterTarget; + this.caseSensitive = false; + } + + public SMSFilterRule(String pattern, MatchType matchType, FilterTarget filterTarget, boolean caseSensitive) { + this.pattern = pattern; + this.matchType = matchType; + this.filterTarget = filterTarget; + this.caseSensitive = caseSensitive; + } + + public String getPattern() { + return pattern; + } + + public void setPattern(String pattern) { + this.pattern = pattern; + } + + public MatchType getMatchType() { + return matchType; + } + + public void setMatchType(MatchType matchType) { + this.matchType = matchType; + } + + public FilterTarget getFilterTarget() { + return filterTarget; + } + + public void setFilterTarget(FilterTarget filterTarget) { + this.filterTarget = filterTarget != null ? filterTarget : FilterTarget.SENDER; + } + + public boolean isCaseSensitive() { + return caseSensitive; + } + + public void setCaseSensitive(boolean caseSensitive) { + this.caseSensitive = caseSensitive; + } + + /** + * Check if a string matches this filter rule based on match type + */ + private boolean matchesString(String text) { + if (pattern == null || text == null) { + return false; + } + + String patternToMatch = pattern; + String textToMatch = text; + + // Apply case sensitivity + if (!caseSensitive) { + patternToMatch = patternToMatch.toLowerCase(); + textToMatch = textToMatch.toLowerCase(); + } + + switch (matchType) { + case EXACT: + return textToMatch.equals(patternToMatch); + case STARTS_WITH: + return textToMatch.startsWith(patternToMatch); + case ENDS_WITH: + return textToMatch.endsWith(patternToMatch); + case CONTAINS: + return textToMatch.contains(patternToMatch); + default: + return false; + } + } + + /** + * Check if the given sender and/or message matches this filter rule + */ + public boolean matches(String sender, String message) { + if (pattern == null) { + return false; + } + + switch (filterTarget) { + case SENDER: + return matchesString(sender); + case MESSAGE: + return matchesString(message); + case BOTH: + return matchesString(sender) || matchesString(message); + default: + return matchesString(sender); + } + } + + /** + * Legacy method for backward compatibility - checks sender only + */ + public boolean matches(String sender) { + return matches(sender, null); + } +} diff --git a/android/app/src/main/java/com/vernu/sms/receivers/SMSBroadcastReceiver.java b/android/app/src/main/java/com/vernu/sms/receivers/SMSBroadcastReceiver.java index f3c0cb9..fb3c50a 100644 --- a/android/app/src/main/java/com/vernu/sms/receivers/SMSBroadcastReceiver.java +++ b/android/app/src/main/java/com/vernu/sms/receivers/SMSBroadcastReceiver.java @@ -9,6 +9,7 @@ import android.util.Log; import com.vernu.sms.AppConstants; import com.vernu.sms.dtos.SMSDTO; import com.vernu.sms.helpers.SharedPreferenceHelper; +import com.vernu.sms.helpers.SMSFilterHelper; import com.vernu.sms.workers.SMSReceivedWorker; import java.security.MessageDigest; @@ -67,6 +68,14 @@ public class SMSBroadcastReceiver extends BroadcastReceiver { // receivedSMSDTO.setMessage(receivedSMS.getMessage()); // receivedSMSDTO.setReceivedAt(receivedSMS.getReceivedAt()); + // Apply SMS filter + String sender = receivedSMSDTO.getSender(); + String message = receivedSMSDTO.getMessage(); + if (sender != null && !SMSFilterHelper.shouldProcessSMS(sender, message, context)) { + Log.d(TAG, "SMS from " + sender + " filtered out by filter rules"); + return; + } + // Generate fingerprint for deduplication String fingerprint = generateFingerprint( receivedSMSDTO.getSender(), diff --git a/android/app/src/main/res/layout/activity_main.xml b/android/app/src/main/res/layout/activity_main.xml index aa94ff7..19bfd90 100644 --- a/android/app/src/main/res/layout/activity_main.xml +++ b/android/app/src/main/res/layout/activity_main.xml @@ -394,6 +394,22 @@ android:minHeight="32dp" /> + +