diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 2f0e761..b6e35e3 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -15,6 +15,7 @@
+
+
collectSimInfo(Context context) {
+ List simInfoList = new ArrayList<>();
+
+ if (ActivityCompat.checkSelfPermission(context, Manifest.permission.READ_PHONE_STATE) != PackageManager.PERMISSION_GRANTED) {
+ Log.w(TAG, "READ_PHONE_STATE permission not granted, cannot collect SIM info");
+ return simInfoList;
+ }
+
+ try {
+ SubscriptionManager subscriptionManager = SubscriptionManager.from(context);
+ List subscriptionInfoList = subscriptionManager.getActiveSubscriptionInfoList();
+
+ if (subscriptionInfoList == null) {
+ Log.d(TAG, "No active subscriptions found");
+ return simInfoList;
+ }
+
+ for (SubscriptionInfo subscriptionInfo : subscriptionInfoList) {
+ SimInfoDTO simInfo = new SimInfoDTO();
+ simInfo.setSubscriptionId(subscriptionInfo.getSubscriptionId());
+
+ // Get ICCID (may be null for eSIM)
+ try {
+ String iccId = subscriptionInfo.getIccId();
+ if (iccId != null && !iccId.isEmpty()) {
+ simInfo.setIccId(iccId);
+ }
+ } catch (Exception e) {
+ Log.d(TAG, "Could not get ICCID for subscription " + subscriptionInfo.getSubscriptionId());
+ }
+
+ // Get Card ID
+ try {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ int cardId = subscriptionInfo.getCardId();
+ // INVALID_CARD_ID is -1, check for valid card ID (>= 0)
+ if (cardId >= 0) {
+ simInfo.setCardId(cardId);
+ }
+ }
+ } catch (Exception e) {
+ Log.d(TAG, "Could not get Card ID for subscription " + subscriptionInfo.getSubscriptionId());
+ }
+
+ // Get carrier name
+ try {
+ CharSequence carrierName = subscriptionInfo.getCarrierName();
+ if (carrierName != null) {
+ simInfo.setCarrierName(carrierName.toString());
+ }
+ } catch (Exception e) {
+ Log.d(TAG, "Could not get carrier name for subscription " + subscriptionInfo.getSubscriptionId());
+ }
+
+ // Get display name
+ try {
+ CharSequence displayName = subscriptionInfo.getDisplayName();
+ if (displayName != null) {
+ simInfo.setDisplayName(displayName.toString());
+ }
+ } catch (Exception e) {
+ Log.d(TAG, "Could not get display name for subscription " + subscriptionInfo.getSubscriptionId());
+ }
+
+ // Get SIM slot index
+ try {
+ int simSlotIndex = subscriptionInfo.getSimSlotIndex();
+ if (simSlotIndex >= 0) {
+ simInfo.setSimSlotIndex(simSlotIndex);
+ }
+ } catch (Exception e) {
+ Log.d(TAG, "Could not get SIM slot index for subscription " + subscriptionInfo.getSubscriptionId());
+ }
+
+ // Get MCC
+ try {
+ String mcc = subscriptionInfo.getMccString();
+ if (mcc != null && !mcc.isEmpty()) {
+ simInfo.setMcc(mcc);
+ }
+ } catch (Exception e) {
+ Log.d(TAG, "Could not get MCC for subscription " + subscriptionInfo.getSubscriptionId());
+ }
+
+ // Get MNC
+ try {
+ String mnc = subscriptionInfo.getMncString();
+ if (mnc != null && !mnc.isEmpty()) {
+ simInfo.setMnc(mnc);
+ }
+ } catch (Exception e) {
+ Log.d(TAG, "Could not get MNC for subscription " + subscriptionInfo.getSubscriptionId());
+ }
+
+ // Get country ISO
+ try {
+ String countryIso = subscriptionInfo.getCountryIso();
+ if (countryIso != null && !countryIso.isEmpty()) {
+ simInfo.setCountryIso(countryIso);
+ }
+ } catch (Exception e) {
+ Log.d(TAG, "Could not get country ISO for subscription " + subscriptionInfo.getSubscriptionId());
+ }
+
+ // Get subscription type (0 = physical SIM, 1 = eSIM)
+ try {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ int subscriptionType = subscriptionInfo.getSubscriptionType();
+ if (subscriptionType == SubscriptionManager.SUBSCRIPTION_TYPE_LOCAL_SIM) {
+ simInfo.setSubscriptionType("PHYSICAL_SIM");
+ } else if (subscriptionType == SubscriptionManager.SUBSCRIPTION_TYPE_REMOTE_SIM) {
+ simInfo.setSubscriptionType("ESIM");
+ }
+ } else {
+ // For older Android versions, default to PHYSICAL_SIM
+ simInfo.setSubscriptionType("PHYSICAL_SIM");
+ }
+ } catch (Exception e) {
+ Log.d(TAG, "Could not get subscription type for subscription " + subscriptionInfo.getSubscriptionId());
+ }
+
+ simInfoList.add(simInfo);
+ }
+ } catch (Exception e) {
+ Log.e(TAG, "Error collecting SIM info: " + e.getMessage(), e);
+ }
+
+ return simInfoList;
+ }
+
+ /**
+ * Validates if a subscription ID exists in the active subscriptions
+ *
+ * @param context The application context
+ * @param subscriptionId The subscription ID to validate
+ * @return true if the subscription ID exists, false otherwise
+ */
+ public static boolean isValidSubscriptionId(Context context, int subscriptionId) {
+ if (ActivityCompat.checkSelfPermission(context, Manifest.permission.READ_PHONE_STATE) != PackageManager.PERMISSION_GRANTED) {
+ return false;
+ }
+
+ try {
+ SubscriptionManager subscriptionManager = SubscriptionManager.from(context);
+ List subscriptionInfoList = subscriptionManager.getActiveSubscriptionInfoList();
+
+ if (subscriptionInfoList == null) {
+ return false;
+ }
+
+ for (SubscriptionInfo subscriptionInfo : subscriptionInfoList) {
+ if (subscriptionInfo.getSubscriptionId() == subscriptionId) {
+ return true;
+ }
+ }
+ } catch (Exception e) {
+ Log.e(TAG, "Error validating subscription ID: " + e.getMessage(), e);
+ }
+
+ return false;
+ }
}
diff --git a/android/app/src/main/java/com/vernu/sms/activities/MainActivity.java b/android/app/src/main/java/com/vernu/sms/activities/MainActivity.java
index 0e43052..f4e8a90 100644
--- a/android/app/src/main/java/com/vernu/sms/activities/MainActivity.java
+++ b/android/app/src/main/java/com/vernu/sms/activities/MainActivity.java
@@ -7,6 +7,7 @@ import android.content.ClipData;
import android.content.ClipboardManager;
import android.content.Context;
import android.content.Intent;
+import com.vernu.sms.activities.SMSFilterActivity;
import android.os.Build;
import android.os.Bundle;
import android.util.Log;
@@ -30,9 +31,14 @@ import com.vernu.sms.TextBeeUtils;
import com.vernu.sms.R;
import com.vernu.sms.dtos.RegisterDeviceInputDTO;
import com.vernu.sms.dtos.RegisterDeviceResponseDTO;
+import com.vernu.sms.dtos.SimInfoCollectionDTO;
import com.vernu.sms.helpers.SharedPreferenceHelper;
import com.vernu.sms.helpers.VersionTracker;
+import com.vernu.sms.helpers.HeartbeatManager;
import com.google.firebase.crashlytics.FirebaseCrashlytics;
+import com.google.gson.Gson;
+import okhttp3.ResponseBody;
+import java.io.IOException;
import java.util.Arrays;
import java.util.Objects;
import retrofit2.Call;
@@ -43,8 +49,8 @@ public class MainActivity extends AppCompatActivity {
private Context mContext;
private Switch gatewaySwitch, receiveSMSSwitch, stickyNotificationSwitch;
- private EditText apiKeyEditText, fcmTokenEditText, deviceIdEditText;
- private Button registerDeviceBtn, grantSMSPermissionBtn, scanQRBtn, checkUpdatesBtn;
+ private EditText apiKeyEditText, fcmTokenEditText, deviceIdEditText, deviceNameEditText;
+ private Button registerDeviceBtn, grantSMSPermissionBtn, scanQRBtn, checkUpdatesBtn, configureFilterBtn;
private ImageButton copyDeviceIdImgBtn;
private TextView deviceBrandAndModelTxt, deviceIdTxt, appVersionNameTxt, appVersionCodeTxt;
private RadioGroup defaultSimSlotRadioGroup;
@@ -66,6 +72,7 @@ public class MainActivity extends AppCompatActivity {
apiKeyEditText = findViewById(R.id.apiKeyEditText);
fcmTokenEditText = findViewById(R.id.fcmTokenEditText);
deviceIdEditText = findViewById(R.id.deviceIdEditText);
+ deviceNameEditText = findViewById(R.id.deviceNameEditText);
registerDeviceBtn = findViewById(R.id.registerDeviceBtn);
grantSMSPermissionBtn = findViewById(R.id.grantSMSPermissionBtn);
scanQRBtn = findViewById(R.id.scanQRButton);
@@ -76,6 +83,7 @@ public class MainActivity extends AppCompatActivity {
appVersionNameTxt = findViewById(R.id.appVersionNameTxt);
appVersionCodeTxt = findViewById(R.id.appVersionCodeTxt);
checkUpdatesBtn = findViewById(R.id.checkUpdatesBtn);
+ configureFilterBtn = findViewById(R.id.configureFilterBtn);
deviceIdTxt.setText(deviceId);
deviceIdEditText.setText(deviceId);
@@ -107,6 +115,12 @@ public class MainActivity extends AppCompatActivity {
Log.d(TAG, "Starting sticky notification service on app start");
}
+ // Schedule heartbeat if device is enabled and registered
+ if (gatewayEnabled && !deviceId.isEmpty()) {
+ HeartbeatManager.scheduleHeartbeat(mContext);
+ Log.d(TAG, "Scheduling heartbeat on app start");
+ }
+
if (deviceId == null || deviceId.isEmpty()) {
registerDeviceBtn.setText("Register");
} else {
@@ -134,6 +148,12 @@ public class MainActivity extends AppCompatActivity {
});
apiKeyEditText.setText(SharedPreferenceHelper.getSharedPreferenceString(mContext, AppConstants.SHARED_PREFS_API_KEY_KEY, ""));
+ String storedDeviceName = SharedPreferenceHelper.getSharedPreferenceString(mContext, AppConstants.SHARED_PREFS_DEVICE_NAME_KEY, "");
+ if (storedDeviceName.isEmpty()) {
+ deviceNameEditText.setText(Build.BRAND + " " + Build.MODEL);
+ } else {
+ deviceNameEditText.setText(storedDeviceName);
+ }
gatewaySwitch.setChecked(SharedPreferenceHelper.getSharedPreferenceBoolean(mContext, AppConstants.SHARED_PREFS_GATEWAY_ENABLED_KEY, false));
gatewaySwitch.setOnCheckedChangeListener((compoundButton, isCheked) -> {
View view = compoundButton.getRootView();
@@ -151,7 +171,7 @@ public class MainActivity extends AppCompatActivity {
public void onResponse(Call call, Response response) {
Log.d(TAG, response.toString());
if (!response.isSuccessful()) {
- Snackbar.make(view, response.message().isEmpty() ? "An error occurred :( "+ response.code() : response.message(), Snackbar.LENGTH_LONG).show();
+ Snackbar.make(view, extractErrorMessage(response), Snackbar.LENGTH_LONG).show();
compoundButton.setEnabled(true);
return;
}
@@ -164,8 +184,12 @@ public class MainActivity extends AppCompatActivity {
if (SharedPreferenceHelper.getSharedPreferenceBoolean(mContext, AppConstants.SHARED_PREFS_STICKY_NOTIFICATION_ENABLED_KEY, false)) {
TextBeeUtils.startStickyNotificationService(mContext);
}
+ // Schedule heartbeat
+ HeartbeatManager.scheduleHeartbeat(mContext);
} else {
TextBeeUtils.stopStickyNotificationService(mContext);
+ // Cancel heartbeat
+ HeartbeatManager.cancelHeartbeat(mContext);
}
compoundButton.setEnabled(true);
}
@@ -226,6 +250,11 @@ public class MainActivity extends AppCompatActivity {
Intent browserIntent = new Intent(Intent.ACTION_VIEW, android.net.Uri.parse(downloadUrl));
startActivity(browserIntent);
});
+
+ configureFilterBtn.setOnClickListener(view -> {
+ Intent filterIntent = new Intent(MainActivity.this, SMSFilterActivity.class);
+ startActivity(filterIntent);
+ });
}
private void renderAvailableSimOptions() {
@@ -245,7 +274,8 @@ public class MainActivity extends AppCompatActivity {
// Create radio buttons for each SIM with proper styling
TextBeeUtils.getAvailableSimSlots(mContext).forEach(subscriptionInfo -> {
- String simInfo = "SIM " + (subscriptionInfo.getSimSlotIndex() + 1) + " (" + subscriptionInfo.getDisplayName() + ")";
+ String displayName = subscriptionInfo.getDisplayName() != null ? subscriptionInfo.getDisplayName().toString() : "Unknown";
+ String simInfo = displayName + " (Subscription ID: " + subscriptionInfo.getSubscriptionId() + ")";
RadioButton radioButton = new RadioButton(mContext);
radioButton.setText(simInfo);
radioButton.setId(subscriptionInfo.getSubscriptionId());
@@ -280,6 +310,44 @@ public class MainActivity extends AppCompatActivity {
}
}
+ /**
+ * Extracts error message from API response, trying multiple sources:
+ * 1. Error message from response body (error field)
+ * 2. Response message from HTTP headers
+ * 3. Generic error with status code as fallback
+ */
+ private String extractErrorMessage(Response> response) {
+ // Try to parse error from response body
+ try {
+ ResponseBody errorBody = response.errorBody();
+ if (errorBody != null) {
+ String errorBodyString = errorBody.string();
+ if (errorBodyString != null && !errorBodyString.isEmpty()) {
+ try {
+ Gson gson = new Gson();
+ RegisterDeviceResponseDTO errorResponse = gson.fromJson(errorBodyString, RegisterDeviceResponseDTO.class);
+ if (errorResponse != null && errorResponse.error != null && !errorResponse.error.isEmpty()) {
+ return errorResponse.error;
+ }
+ } catch (Exception e) {
+ // If JSON parsing fails, try to extract message from raw string
+ Log.d(TAG, "Could not parse error response as JSON: " + errorBodyString);
+ }
+ }
+ }
+ } catch (IOException e) {
+ Log.d(TAG, "Could not read error body: " + e.getMessage());
+ }
+
+ // Fall back to response message
+ if (response.message() != null && !response.message().isEmpty()) {
+ return response.message();
+ }
+
+ // Final fallback to generic error with status code
+ return "An error occurred :( " + response.code();
+ }
+
/**
* Apply the custom radio button style to a programmatically created radio button
*/
@@ -364,6 +432,19 @@ public class MainActivity extends AppCompatActivity {
registerDeviceInput.setAppVersionCode(BuildConfig.VERSION_CODE);
registerDeviceInput.setAppVersionName(BuildConfig.VERSION_NAME);
+ // Get device name from input field or default to "brand model"
+ String deviceName = deviceNameEditText.getText().toString().trim();
+ if (deviceName.isEmpty()) {
+ deviceName = Build.BRAND + " " + Build.MODEL;
+ }
+ registerDeviceInput.setName(deviceName);
+
+ // Collect SIM information
+ SimInfoCollectionDTO simInfoCollection = new SimInfoCollectionDTO();
+ simInfoCollection.setLastUpdated(System.currentTimeMillis());
+ simInfoCollection.setSims(TextBeeUtils.collectSimInfo(mContext));
+ registerDeviceInput.setSimInfo(simInfoCollection);
+
// If the user provided a device ID, use it for updating instead of creating new
if (!deviceIdInput.isEmpty()) {
Log.d(TAG, "Updating device with deviceId: "+ deviceIdInput);
@@ -373,7 +454,7 @@ public class MainActivity extends AppCompatActivity {
public void onResponse(Call call, Response response) {
Log.d(TAG, response.toString());
if (!response.isSuccessful()) {
- Snackbar.make(view, response.message().isEmpty() ? "An error occurred :( "+ response.code() : response.message(), Snackbar.LENGTH_LONG).show();
+ Snackbar.make(view, extractErrorMessage(response), Snackbar.LENGTH_LONG).show();
registerDeviceBtn.setEnabled(true);
registerDeviceBtn.setText("Update");
return;
@@ -389,6 +470,29 @@ public class MainActivity extends AppCompatActivity {
SharedPreferenceHelper.setSharedPreferenceString(mContext, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, deviceId);
SharedPreferenceHelper.setSharedPreferenceBoolean(mContext, AppConstants.SHARED_PREFS_GATEWAY_ENABLED_KEY, registerDeviceInput.isEnabled());
gatewaySwitch.setChecked(registerDeviceInput.isEnabled());
+
+ // Sync heartbeatIntervalMinutes from server response
+ if (response.body().data.get("heartbeatIntervalMinutes") != null) {
+ Object intervalObj = response.body().data.get("heartbeatIntervalMinutes");
+ if (intervalObj instanceof Number) {
+ int intervalMinutes = ((Number) intervalObj).intValue();
+ SharedPreferenceHelper.setSharedPreferenceInt(mContext, AppConstants.SHARED_PREFS_HEARTBEAT_INTERVAL_MINUTES_KEY, intervalMinutes);
+ Log.d(TAG, "Synced heartbeat interval from server: " + intervalMinutes + " minutes");
+ }
+ }
+
+ // Sync device name from server response
+ if (response.body().data.get("name") != null) {
+ String deviceName = response.body().data.get("name").toString();
+ SharedPreferenceHelper.setSharedPreferenceString(mContext, AppConstants.SHARED_PREFS_DEVICE_NAME_KEY, deviceName);
+ deviceNameEditText.setText(deviceName);
+ Log.d(TAG, "Synced device name from server: " + deviceName);
+ }
+
+ // Schedule heartbeat if device is enabled
+ if (registerDeviceInput.isEnabled()) {
+ HeartbeatManager.scheduleHeartbeat(mContext);
+ }
}
// Update stored version information
@@ -412,18 +516,18 @@ public class MainActivity extends AppCompatActivity {
}
Call apiCall = ApiManager.getApiService().registerDevice(newKey, registerDeviceInput);
- apiCall.enqueue(new Callback() {
- @Override
- public void onResponse(Call call, Response response) {
- Log.d(TAG, response.toString());
- if (!response.isSuccessful()) {
- Snackbar.make(view, response.message().isEmpty() ? "An error occurred :( "+ response.code() : response.message(), Snackbar.LENGTH_LONG).show();
- registerDeviceBtn.setEnabled(true);
- registerDeviceBtn.setText("Update");
- return;
- }
- SharedPreferenceHelper.setSharedPreferenceString(mContext, AppConstants.SHARED_PREFS_API_KEY_KEY, newKey);
- Snackbar.make(view, "Device Registration Successful :)", Snackbar.LENGTH_LONG).show();
+ apiCall.enqueue(new Callback() {
+ @Override
+ public void onResponse(Call call, Response response) {
+ Log.d(TAG, response.toString());
+ if (!response.isSuccessful()) {
+ Snackbar.make(view, extractErrorMessage(response), Snackbar.LENGTH_LONG).show();
+ registerDeviceBtn.setEnabled(true);
+ registerDeviceBtn.setText("Update");
+ return;
+ }
+ SharedPreferenceHelper.setSharedPreferenceString(mContext, AppConstants.SHARED_PREFS_API_KEY_KEY, newKey);
+ Snackbar.make(view, "Device Registration Successful :)", Snackbar.LENGTH_LONG).show();
if (response.body() != null && response.body().data != null && response.body().data.get("_id") != null) {
deviceId = response.body().data.get("_id").toString();
@@ -432,6 +536,29 @@ public class MainActivity extends AppCompatActivity {
SharedPreferenceHelper.setSharedPreferenceString(mContext, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, deviceId);
SharedPreferenceHelper.setSharedPreferenceBoolean(mContext, AppConstants.SHARED_PREFS_GATEWAY_ENABLED_KEY, registerDeviceInput.isEnabled());
gatewaySwitch.setChecked(registerDeviceInput.isEnabled());
+
+ // Sync heartbeatIntervalMinutes from server response
+ if (response.body().data.get("heartbeatIntervalMinutes") != null) {
+ Object intervalObj = response.body().data.get("heartbeatIntervalMinutes");
+ if (intervalObj instanceof Number) {
+ int intervalMinutes = ((Number) intervalObj).intValue();
+ SharedPreferenceHelper.setSharedPreferenceInt(mContext, AppConstants.SHARED_PREFS_HEARTBEAT_INTERVAL_MINUTES_KEY, intervalMinutes);
+ Log.d(TAG, "Synced heartbeat interval from server: " + intervalMinutes + " minutes");
+ }
+ }
+
+ // Sync device name from server response
+ if (response.body().data.get("name") != null) {
+ String deviceName = response.body().data.get("name").toString();
+ SharedPreferenceHelper.setSharedPreferenceString(mContext, AppConstants.SHARED_PREFS_DEVICE_NAME_KEY, deviceName);
+ deviceNameEditText.setText(deviceName);
+ Log.d(TAG, "Synced device name from server: " + deviceName);
+ }
+
+ // Schedule heartbeat if device is enabled
+ if (registerDeviceInput.isEnabled()) {
+ HeartbeatManager.scheduleHeartbeat(mContext);
+ }
}
// Update stored version information
@@ -484,13 +611,26 @@ public class MainActivity extends AppCompatActivity {
updateDeviceInput.setAppVersionCode(BuildConfig.VERSION_CODE);
updateDeviceInput.setAppVersionName(BuildConfig.VERSION_NAME);
+ // Get device name from input field or default to "brand model"
+ String deviceName = deviceNameEditText.getText().toString().trim();
+ if (deviceName.isEmpty()) {
+ deviceName = Build.BRAND + " " + Build.MODEL;
+ }
+ updateDeviceInput.setName(deviceName);
+
+ // Collect SIM information
+ SimInfoCollectionDTO simInfoCollection = new SimInfoCollectionDTO();
+ simInfoCollection.setLastUpdated(System.currentTimeMillis());
+ simInfoCollection.setSims(TextBeeUtils.collectSimInfo(mContext));
+ updateDeviceInput.setSimInfo(simInfoCollection);
+
Call apiCall = ApiManager.getApiService().updateDevice(deviceIdToUse, apiKey, updateDeviceInput);
apiCall.enqueue(new Callback() {
@Override
public void onResponse(Call call, Response response) {
Log.d(TAG, response.toString());
if (!response.isSuccessful()) {
- Snackbar.make(view, response.message().isEmpty() ? "An error occurred :( "+ response.code() : response.message(), Snackbar.LENGTH_LONG).show();
+ Snackbar.make(view, extractErrorMessage(response), Snackbar.LENGTH_LONG).show();
registerDeviceBtn.setEnabled(true);
registerDeviceBtn.setText("Update");
return;
@@ -503,6 +643,29 @@ public class MainActivity extends AppCompatActivity {
SharedPreferenceHelper.setSharedPreferenceString(mContext, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, deviceId);
deviceIdTxt.setText(deviceId);
deviceIdEditText.setText(deviceId);
+
+ // Sync heartbeatIntervalMinutes from server response
+ if (response.body().data.get("heartbeatIntervalMinutes") != null) {
+ Object intervalObj = response.body().data.get("heartbeatIntervalMinutes");
+ if (intervalObj instanceof Number) {
+ int intervalMinutes = ((Number) intervalObj).intValue();
+ SharedPreferenceHelper.setSharedPreferenceInt(mContext, AppConstants.SHARED_PREFS_HEARTBEAT_INTERVAL_MINUTES_KEY, intervalMinutes);
+ Log.d(TAG, "Synced heartbeat interval from server: " + intervalMinutes + " minutes");
+ }
+ }
+
+ // Sync device name from server response
+ if (response.body().data.get("name") != null) {
+ String deviceName = response.body().data.get("name").toString();
+ SharedPreferenceHelper.setSharedPreferenceString(mContext, AppConstants.SHARED_PREFS_DEVICE_NAME_KEY, deviceName);
+ deviceNameEditText.setText(deviceName);
+ Log.d(TAG, "Synced device name from server: " + deviceName);
+ }
+
+ // Schedule heartbeat if device is enabled
+ if (updateDeviceInput.isEnabled()) {
+ HeartbeatManager.scheduleHeartbeat(mContext);
+ }
}
// Update stored version information
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/dtos/HeartbeatInputDTO.java b/android/app/src/main/java/com/vernu/sms/dtos/HeartbeatInputDTO.java
new file mode 100644
index 0000000..5de6b49
--- /dev/null
+++ b/android/app/src/main/java/com/vernu/sms/dtos/HeartbeatInputDTO.java
@@ -0,0 +1,151 @@
+package com.vernu.sms.dtos;
+
+public class HeartbeatInputDTO {
+ private String fcmToken;
+ private Integer batteryPercentage;
+ private Boolean isCharging;
+ private String networkType;
+ private String appVersionName;
+ private Integer appVersionCode;
+ private Long deviceUptimeMillis;
+ private Long memoryFreeBytes;
+ private Long memoryTotalBytes;
+ private Long memoryMaxBytes;
+ private Long storageAvailableBytes;
+ private Long storageTotalBytes;
+ private String timezone;
+ private String locale;
+ private Boolean receiveSMSEnabled;
+ private SimInfoCollectionDTO simInfo;
+
+ public HeartbeatInputDTO() {
+ }
+
+ public String getFcmToken() {
+ return fcmToken;
+ }
+
+ public void setFcmToken(String fcmToken) {
+ this.fcmToken = fcmToken;
+ }
+
+ public Integer getBatteryPercentage() {
+ return batteryPercentage;
+ }
+
+ public void setBatteryPercentage(Integer batteryPercentage) {
+ this.batteryPercentage = batteryPercentage;
+ }
+
+ public Boolean getIsCharging() {
+ return isCharging;
+ }
+
+ public void setIsCharging(Boolean isCharging) {
+ this.isCharging = isCharging;
+ }
+
+ public String getNetworkType() {
+ return networkType;
+ }
+
+ public void setNetworkType(String networkType) {
+ this.networkType = networkType;
+ }
+
+ public String getAppVersionName() {
+ return appVersionName;
+ }
+
+ public void setAppVersionName(String appVersionName) {
+ this.appVersionName = appVersionName;
+ }
+
+ public Integer getAppVersionCode() {
+ return appVersionCode;
+ }
+
+ public void setAppVersionCode(Integer appVersionCode) {
+ this.appVersionCode = appVersionCode;
+ }
+
+ public Long getDeviceUptimeMillis() {
+ return deviceUptimeMillis;
+ }
+
+ public void setDeviceUptimeMillis(Long deviceUptimeMillis) {
+ this.deviceUptimeMillis = deviceUptimeMillis;
+ }
+
+ public Long getMemoryFreeBytes() {
+ return memoryFreeBytes;
+ }
+
+ public void setMemoryFreeBytes(Long memoryFreeBytes) {
+ this.memoryFreeBytes = memoryFreeBytes;
+ }
+
+ public Long getMemoryTotalBytes() {
+ return memoryTotalBytes;
+ }
+
+ public void setMemoryTotalBytes(Long memoryTotalBytes) {
+ this.memoryTotalBytes = memoryTotalBytes;
+ }
+
+ public Long getMemoryMaxBytes() {
+ return memoryMaxBytes;
+ }
+
+ public void setMemoryMaxBytes(Long memoryMaxBytes) {
+ this.memoryMaxBytes = memoryMaxBytes;
+ }
+
+ public Long getStorageAvailableBytes() {
+ return storageAvailableBytes;
+ }
+
+ public void setStorageAvailableBytes(Long storageAvailableBytes) {
+ this.storageAvailableBytes = storageAvailableBytes;
+ }
+
+ public Long getStorageTotalBytes() {
+ return storageTotalBytes;
+ }
+
+ public void setStorageTotalBytes(Long storageTotalBytes) {
+ this.storageTotalBytes = storageTotalBytes;
+ }
+
+ public String getTimezone() {
+ return timezone;
+ }
+
+ public void setTimezone(String timezone) {
+ this.timezone = timezone;
+ }
+
+ public String getLocale() {
+ return locale;
+ }
+
+ public void setLocale(String locale) {
+ this.locale = locale;
+ }
+
+ public Boolean getReceiveSMSEnabled() {
+ return receiveSMSEnabled;
+ }
+
+ public void setReceiveSMSEnabled(Boolean receiveSMSEnabled) {
+ this.receiveSMSEnabled = receiveSMSEnabled;
+ }
+
+ public SimInfoCollectionDTO getSimInfo() {
+ return simInfo;
+ }
+
+ public void setSimInfo(SimInfoCollectionDTO simInfo) {
+ this.simInfo = simInfo;
+ }
+}
diff --git a/android/app/src/main/java/com/vernu/sms/dtos/HeartbeatResponseDTO.java b/android/app/src/main/java/com/vernu/sms/dtos/HeartbeatResponseDTO.java
new file mode 100644
index 0000000..61bba72
--- /dev/null
+++ b/android/app/src/main/java/com/vernu/sms/dtos/HeartbeatResponseDTO.java
@@ -0,0 +1,8 @@
+package com.vernu.sms.dtos;
+
+public class HeartbeatResponseDTO {
+ public boolean success;
+ public boolean fcmTokenUpdated;
+ public long lastHeartbeat;
+ public String name;
+}
diff --git a/android/app/src/main/java/com/vernu/sms/dtos/RegisterDeviceInputDTO.java b/android/app/src/main/java/com/vernu/sms/dtos/RegisterDeviceInputDTO.java
index b366e50..ae6187b 100644
--- a/android/app/src/main/java/com/vernu/sms/dtos/RegisterDeviceInputDTO.java
+++ b/android/app/src/main/java/com/vernu/sms/dtos/RegisterDeviceInputDTO.java
@@ -6,12 +6,14 @@ public class RegisterDeviceInputDTO {
private String brand;
private String manufacturer;
private String model;
+ private String name;
private String serial;
private String buildId;
private String os;
private String osVersion;
private String appVersionName;
private int appVersionCode;
+ private SimInfoCollectionDTO simInfo;
public RegisterDeviceInputDTO() {
}
@@ -60,6 +62,14 @@ public class RegisterDeviceInputDTO {
this.model = model;
}
+ public String getName() {
+ return name;
+ }
+
+ public void setName(String name) {
+ this.name = name;
+ }
+
public String getSerial() {
return serial;
}
@@ -107,4 +117,12 @@ public class RegisterDeviceInputDTO {
public void setAppVersionCode(int appVersionCode) {
this.appVersionCode = appVersionCode;
}
+
+ public SimInfoCollectionDTO getSimInfo() {
+ return simInfo;
+ }
+
+ public void setSimInfo(SimInfoCollectionDTO simInfo) {
+ this.simInfo = simInfo;
+ }
}
diff --git a/android/app/src/main/java/com/vernu/sms/dtos/SMSDTO.java b/android/app/src/main/java/com/vernu/sms/dtos/SMSDTO.java
index 20a1867..9f17000 100644
--- a/android/app/src/main/java/com/vernu/sms/dtos/SMSDTO.java
+++ b/android/app/src/main/java/com/vernu/sms/dtos/SMSDTO.java
@@ -6,6 +6,7 @@ public class SMSDTO {
private String sender;
private String message = "";
private long receivedAtInMillis;
+ private String fingerprint;
private String smsId;
private String smsBatchId;
@@ -107,4 +108,12 @@ public class SMSDTO {
public void setErrorMessage(String errorMessage) {
this.errorMessage = errorMessage;
}
+
+ public String getFingerprint() {
+ return fingerprint;
+ }
+
+ public void setFingerprint(String fingerprint) {
+ this.fingerprint = fingerprint;
+ }
}
diff --git a/android/app/src/main/java/com/vernu/sms/dtos/SimInfoCollectionDTO.java b/android/app/src/main/java/com/vernu/sms/dtos/SimInfoCollectionDTO.java
new file mode 100644
index 0000000..e955034
--- /dev/null
+++ b/android/app/src/main/java/com/vernu/sms/dtos/SimInfoCollectionDTO.java
@@ -0,0 +1,27 @@
+package com.vernu.sms.dtos;
+
+import java.util.List;
+
+public class SimInfoCollectionDTO {
+ private long lastUpdated;
+ private List sims;
+
+ public SimInfoCollectionDTO() {
+ }
+
+ public long getLastUpdated() {
+ return lastUpdated;
+ }
+
+ public void setLastUpdated(long lastUpdated) {
+ this.lastUpdated = lastUpdated;
+ }
+
+ public List getSims() {
+ return sims;
+ }
+
+ public void setSims(List sims) {
+ this.sims = sims;
+ }
+}
diff --git a/android/app/src/main/java/com/vernu/sms/dtos/SimInfoDTO.java b/android/app/src/main/java/com/vernu/sms/dtos/SimInfoDTO.java
new file mode 100644
index 0000000..43cc6a8
--- /dev/null
+++ b/android/app/src/main/java/com/vernu/sms/dtos/SimInfoDTO.java
@@ -0,0 +1,97 @@
+package com.vernu.sms.dtos;
+
+public class SimInfoDTO {
+ private int subscriptionId;
+ private String iccId;
+ private Integer cardId;
+ private String carrierName;
+ private String displayName;
+ private Integer simSlotIndex;
+ private String mcc;
+ private String mnc;
+ private String countryIso;
+ private String subscriptionType;
+
+ public SimInfoDTO() {
+ }
+
+ public int getSubscriptionId() {
+ return subscriptionId;
+ }
+
+ public void setSubscriptionId(int subscriptionId) {
+ this.subscriptionId = subscriptionId;
+ }
+
+ public String getIccId() {
+ return iccId;
+ }
+
+ public void setIccId(String iccId) {
+ this.iccId = iccId;
+ }
+
+ public Integer getCardId() {
+ return cardId;
+ }
+
+ public void setCardId(Integer cardId) {
+ this.cardId = cardId;
+ }
+
+ public String getCarrierName() {
+ return carrierName;
+ }
+
+ public void setCarrierName(String carrierName) {
+ this.carrierName = carrierName;
+ }
+
+ public String getDisplayName() {
+ return displayName;
+ }
+
+ public void setDisplayName(String displayName) {
+ this.displayName = displayName;
+ }
+
+ public Integer getSimSlotIndex() {
+ return simSlotIndex;
+ }
+
+ public void setSimSlotIndex(Integer simSlotIndex) {
+ this.simSlotIndex = simSlotIndex;
+ }
+
+ public String getMcc() {
+ return mcc;
+ }
+
+ public void setMcc(String mcc) {
+ this.mcc = mcc;
+ }
+
+ public String getMnc() {
+ return mnc;
+ }
+
+ public void setMnc(String mnc) {
+ this.mnc = mnc;
+ }
+
+ public String getCountryIso() {
+ return countryIso;
+ }
+
+ public void setCountryIso(String countryIso) {
+ this.countryIso = countryIso;
+ }
+
+ public String getSubscriptionType() {
+ return subscriptionType;
+ }
+
+ public void setSubscriptionType(String subscriptionType) {
+ this.subscriptionType = subscriptionType;
+ }
+}
diff --git a/android/app/src/main/java/com/vernu/sms/helpers/HeartbeatHelper.java b/android/app/src/main/java/com/vernu/sms/helpers/HeartbeatHelper.java
new file mode 100644
index 0000000..eed9283
--- /dev/null
+++ b/android/app/src/main/java/com/vernu/sms/helpers/HeartbeatHelper.java
@@ -0,0 +1,220 @@
+package com.vernu.sms.helpers;
+
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.net.ConnectivityManager;
+import android.net.NetworkInfo;
+import android.os.BatteryManager;
+import android.os.StatFs;
+import android.os.SystemClock;
+import android.util.Log;
+
+import com.google.firebase.messaging.FirebaseMessaging;
+import com.vernu.sms.ApiManager;
+import com.vernu.sms.AppConstants;
+import com.vernu.sms.BuildConfig;
+import com.vernu.sms.dtos.HeartbeatInputDTO;
+import com.vernu.sms.dtos.HeartbeatResponseDTO;
+import com.vernu.sms.dtos.SimInfoCollectionDTO;
+import com.vernu.sms.TextBeeUtils;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.Locale;
+import java.util.TimeZone;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+
+import retrofit2.Call;
+import retrofit2.Response;
+
+public class HeartbeatHelper {
+ private static final String TAG = "HeartbeatHelper";
+
+ /**
+ * Collects device information and sends a heartbeat request to the API.
+ *
+ * @param context Application context
+ * @param deviceId Device ID
+ * @param apiKey API key for authentication
+ * @return true if heartbeat was sent successfully, false otherwise
+ */
+ public static boolean sendHeartbeat(Context context, String deviceId, String apiKey) {
+ if (deviceId == null || deviceId.isEmpty()) {
+ Log.d(TAG, "Device not registered, skipping heartbeat");
+ return false;
+ }
+
+ if (apiKey == null || apiKey.isEmpty()) {
+ Log.e(TAG, "API key not available, skipping heartbeat");
+ return false;
+ }
+
+ // Collect device information
+ HeartbeatInputDTO heartbeatInput = new HeartbeatInputDTO();
+
+ try {
+ // Get FCM token (blocking wait)
+ try {
+ CountDownLatch latch = new CountDownLatch(1);
+ final String[] fcmToken = new String[1];
+ FirebaseMessaging.getInstance().getToken().addOnCompleteListener(task -> {
+ if (task.isSuccessful()) {
+ fcmToken[0] = task.getResult();
+ }
+ latch.countDown();
+ });
+ if (latch.await(5, TimeUnit.SECONDS) && fcmToken[0] != null) {
+ heartbeatInput.setFcmToken(fcmToken[0]);
+ }
+ } catch (Exception e) {
+ Log.e(TAG, "Failed to get FCM token: " + e.getMessage());
+ // Continue without FCM token
+ }
+
+ // Get battery information
+ IntentFilter ifilter = new IntentFilter(Intent.ACTION_BATTERY_CHANGED);
+ Intent batteryStatus = context.registerReceiver(null, ifilter);
+ if (batteryStatus != null) {
+ int level = batteryStatus.getIntExtra(BatteryManager.EXTRA_LEVEL, -1);
+ int scale = batteryStatus.getIntExtra(BatteryManager.EXTRA_SCALE, -1);
+ int batteryPct = (int) ((level / (float) scale) * 100);
+ heartbeatInput.setBatteryPercentage(batteryPct);
+
+ int status = batteryStatus.getIntExtra(BatteryManager.EXTRA_STATUS, -1);
+ boolean isCharging = status == BatteryManager.BATTERY_STATUS_CHARGING ||
+ status == BatteryManager.BATTERY_STATUS_FULL;
+ heartbeatInput.setIsCharging(isCharging);
+ }
+
+ // Get network type
+ ConnectivityManager cm = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE);
+ if (cm != null) {
+ NetworkInfo activeNetwork = cm.getActiveNetworkInfo();
+ if (activeNetwork != null && activeNetwork.isConnected()) {
+ if (activeNetwork.getType() == ConnectivityManager.TYPE_WIFI) {
+ heartbeatInput.setNetworkType("wifi");
+ } else if (activeNetwork.getType() == ConnectivityManager.TYPE_MOBILE) {
+ heartbeatInput.setNetworkType("cellular");
+ } else {
+ heartbeatInput.setNetworkType("none");
+ }
+ } else {
+ heartbeatInput.setNetworkType("none");
+ }
+ }
+
+ // Get app version
+ heartbeatInput.setAppVersionName(BuildConfig.VERSION_NAME);
+ heartbeatInput.setAppVersionCode(BuildConfig.VERSION_CODE);
+
+ // Get device uptime
+ heartbeatInput.setDeviceUptimeMillis(SystemClock.uptimeMillis());
+
+ // Get memory information
+ Runtime runtime = Runtime.getRuntime();
+ heartbeatInput.setMemoryFreeBytes(runtime.freeMemory());
+ heartbeatInput.setMemoryTotalBytes(runtime.totalMemory());
+ heartbeatInput.setMemoryMaxBytes(runtime.maxMemory());
+
+ // Get storage information
+ File internalStorage = context.getFilesDir();
+ StatFs statFs = new StatFs(internalStorage.getPath());
+ long availableBytes = statFs.getAvailableBytes();
+ long totalBytes = statFs.getTotalBytes();
+ heartbeatInput.setStorageAvailableBytes(availableBytes);
+ heartbeatInput.setStorageTotalBytes(totalBytes);
+
+ // Get system information
+ heartbeatInput.setTimezone(TimeZone.getDefault().getID());
+ heartbeatInput.setLocale(Locale.getDefault().toString());
+
+ // Get receive SMS enabled status
+ boolean receiveSMSEnabled = SharedPreferenceHelper.getSharedPreferenceBoolean(
+ context,
+ AppConstants.SHARED_PREFS_RECEIVE_SMS_ENABLED_KEY,
+ false
+ );
+ heartbeatInput.setReceiveSMSEnabled(receiveSMSEnabled);
+
+ // Collect SIM information
+ SimInfoCollectionDTO simInfoCollection = new SimInfoCollectionDTO();
+ simInfoCollection.setLastUpdated(System.currentTimeMillis());
+ simInfoCollection.setSims(TextBeeUtils.collectSimInfo(context));
+ heartbeatInput.setSimInfo(simInfoCollection);
+
+ // Send heartbeat request
+ Call call = ApiManager.getApiService().heartbeat(deviceId, apiKey, heartbeatInput);
+ Response response = call.execute();
+
+ if (response.isSuccessful() && response.body() != null) {
+ HeartbeatResponseDTO responseBody = response.body();
+ if (responseBody.fcmTokenUpdated) {
+ Log.d(TAG, "FCM token was updated during heartbeat");
+ }
+
+ // Sync device name from heartbeat response (ignore if blank)
+ if (responseBody.name != null && !responseBody.name.trim().isEmpty()) {
+ SharedPreferenceHelper.setSharedPreferenceString(
+ context,
+ AppConstants.SHARED_PREFS_DEVICE_NAME_KEY,
+ responseBody.name
+ );
+ Log.d(TAG, "Synced device name from heartbeat: " + responseBody.name);
+ }
+
+ Log.d(TAG, "Heartbeat sent successfully");
+ return true;
+ } else {
+ Log.e(TAG, "Failed to send heartbeat. Response code: " + (response.code()));
+ return false;
+ }
+ } catch (IOException e) {
+ Log.e(TAG, "Heartbeat API call failed: " + e.getMessage());
+ return false;
+ } catch (Exception e) {
+ Log.e(TAG, "Error collecting device information: " + e.getMessage());
+ return false;
+ }
+ }
+
+ /**
+ * Checks if device is eligible to send heartbeat (registered, enabled, heartbeat enabled).
+ *
+ * @param context Application context
+ * @return true if device is eligible, false otherwise
+ */
+ public static boolean isDeviceEligibleForHeartbeat(Context context) {
+ // Check if device is registered
+ String deviceId = SharedPreferenceHelper.getSharedPreferenceString(
+ context,
+ AppConstants.SHARED_PREFS_DEVICE_ID_KEY,
+ ""
+ );
+
+ if (deviceId.isEmpty()) {
+ return false;
+ }
+
+ // Check if device is enabled
+ boolean deviceEnabled = SharedPreferenceHelper.getSharedPreferenceBoolean(
+ context,
+ AppConstants.SHARED_PREFS_GATEWAY_ENABLED_KEY,
+ false
+ );
+
+ if (!deviceEnabled) {
+ return false;
+ }
+
+ // Check if heartbeat feature is enabled
+ boolean heartbeatEnabled = SharedPreferenceHelper.getSharedPreferenceBoolean(
+ context,
+ AppConstants.SHARED_PREFS_HEARTBEAT_ENABLED_KEY,
+ true // Default to true
+ );
+
+ return heartbeatEnabled;
+ }
+}
diff --git a/android/app/src/main/java/com/vernu/sms/helpers/HeartbeatManager.java b/android/app/src/main/java/com/vernu/sms/helpers/HeartbeatManager.java
new file mode 100644
index 0000000..d017a72
--- /dev/null
+++ b/android/app/src/main/java/com/vernu/sms/helpers/HeartbeatManager.java
@@ -0,0 +1,88 @@
+package com.vernu.sms.helpers;
+
+import android.content.Context;
+import android.util.Log;
+
+import androidx.work.Constraints;
+import androidx.work.ExistingPeriodicWorkPolicy;
+import androidx.work.NetworkType;
+import androidx.work.PeriodicWorkRequest;
+import androidx.work.WorkManager;
+
+import com.vernu.sms.AppConstants;
+import com.vernu.sms.workers.HeartbeatWorker;
+
+import java.util.concurrent.TimeUnit;
+
+public class HeartbeatManager {
+ private static final String TAG = "HeartbeatManager";
+ private static final int MIN_INTERVAL_MINUTES = 15; // Android WorkManager minimum
+ private static final String UNIQUE_WORK_NAME = "heartbeat_unique_work";
+
+ public static void scheduleHeartbeat(Context context) {
+ // Use application context to ensure WorkManager works even when app is closed
+ Context appContext = context.getApplicationContext();
+
+ // Get interval from shared preferences (default 30 minutes)
+ int intervalMinutes = SharedPreferenceHelper.getSharedPreferenceInt(
+ appContext,
+ AppConstants.SHARED_PREFS_HEARTBEAT_INTERVAL_MINUTES_KEY,
+ 30
+ );
+
+ // Enforce minimum interval
+ if (intervalMinutes < MIN_INTERVAL_MINUTES) {
+ Log.w(TAG, "Interval " + intervalMinutes + " minutes is less than minimum " + MIN_INTERVAL_MINUTES + " minutes, using minimum");
+ intervalMinutes = MIN_INTERVAL_MINUTES;
+ }
+
+ Log.d(TAG, "Scheduling heartbeat with interval: " + intervalMinutes + " minutes");
+
+ // Create constraints
+ Constraints constraints = new Constraints.Builder()
+ .setRequiredNetworkType(NetworkType.CONNECTED)
+ .build();
+
+ // Create periodic work request
+ PeriodicWorkRequest heartbeatWork = new PeriodicWorkRequest.Builder(
+ HeartbeatWorker.class,
+ intervalMinutes,
+ TimeUnit.MINUTES
+ )
+ .setConstraints(constraints)
+ .addTag(AppConstants.HEARTBEAT_WORK_TAG)
+ .build();
+
+ // Use enqueueUniquePeriodicWork to ensure only one periodic work exists
+ // This ensures the work persists across app restarts and device reboots
+ WorkManager.getInstance(appContext)
+ .enqueueUniquePeriodicWork(
+ UNIQUE_WORK_NAME,
+ ExistingPeriodicWorkPolicy.REPLACE,
+ heartbeatWork
+ );
+
+ Log.d(TAG, "Heartbeat scheduled successfully with unique work name: " + UNIQUE_WORK_NAME);
+ }
+
+ public static void cancelHeartbeat(Context context) {
+ Log.d(TAG, "Cancelling heartbeat work");
+ Context appContext = context.getApplicationContext();
+
+ // Cancel by unique work name (more reliable)
+ WorkManager.getInstance(appContext)
+ .cancelUniqueWork(UNIQUE_WORK_NAME);
+
+ // Also cancel by tag as fallback
+ WorkManager.getInstance(appContext)
+ .cancelAllWorkByTag(AppConstants.HEARTBEAT_WORK_TAG);
+ }
+
+ public static void triggerHeartbeat(Context context) {
+ // This can be used for testing - trigger immediate heartbeat
+ Log.d(TAG, "Triggering immediate heartbeat");
+ // For immediate execution, we could create a OneTimeWorkRequest
+ // but for now, just reschedule which will run soon
+ scheduleHeartbeat(context);
+ }
+}
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/models/SMSPayload.java b/android/app/src/main/java/com/vernu/sms/models/SMSPayload.java
index b73bcd6..b9ec05f 100644
--- a/android/app/src/main/java/com/vernu/sms/models/SMSPayload.java
+++ b/android/app/src/main/java/com/vernu/sms/models/SMSPayload.java
@@ -6,6 +6,7 @@ public class SMSPayload {
private String message;
private String smsId;
private String smsBatchId;
+ private Integer simSubscriptionId;
// Legacy fields that are no longer used
private String[] receivers;
@@ -45,4 +46,12 @@ public class SMSPayload {
public void setSmsBatchId(String smsBatchId) {
this.smsBatchId = smsBatchId;
}
+
+ public Integer getSimSubscriptionId() {
+ return simSubscriptionId;
+ }
+
+ public void setSimSubscriptionId(Integer simSubscriptionId) {
+ this.simSubscriptionId = simSubscriptionId;
+ }
}
diff --git a/android/app/src/main/java/com/vernu/sms/receivers/BootCompletedReceiver.java b/android/app/src/main/java/com/vernu/sms/receivers/BootCompletedReceiver.java
index 2dad5f4..487446b 100644
--- a/android/app/src/main/java/com/vernu/sms/receivers/BootCompletedReceiver.java
+++ b/android/app/src/main/java/com/vernu/sms/receivers/BootCompletedReceiver.java
@@ -15,6 +15,7 @@ import com.vernu.sms.TextBeeUtils;
import com.vernu.sms.dtos.RegisterDeviceInputDTO;
import com.vernu.sms.dtos.RegisterDeviceResponseDTO;
import com.vernu.sms.helpers.SharedPreferenceHelper;
+import com.vernu.sms.helpers.HeartbeatManager;
import com.vernu.sms.services.StickyNotificationService;
import retrofit2.Call;
@@ -54,6 +55,17 @@ public class BootCompletedReceiver extends BroadcastReceiver {
// Only proceed if both device ID and API key are available
if (!deviceId.isEmpty() && !apiKey.isEmpty()) {
updateDeviceInfo(context, deviceId, apiKey);
+
+ // Schedule heartbeat if device is enabled
+ boolean deviceEnabled = SharedPreferenceHelper.getSharedPreferenceBoolean(
+ context,
+ AppConstants.SHARED_PREFS_GATEWAY_ENABLED_KEY,
+ false
+ );
+ if (deviceEnabled) {
+ Log.i(TAG, "Device booted, scheduling heartbeat");
+ HeartbeatManager.scheduleHeartbeat(context);
+ }
}
}
}
@@ -85,6 +97,18 @@ public class BootCompletedReceiver extends BroadcastReceiver {
public void onResponse(Call call, Response response) {
if (response.isSuccessful()) {
Log.d(TAG, "Device info updated successfully after boot");
+
+ // Sync heartbeatIntervalMinutes from server response
+ if (response.body() != null && response.body().data != null) {
+ if (response.body().data.get("heartbeatIntervalMinutes") != null) {
+ Object intervalObj = response.body().data.get("heartbeatIntervalMinutes");
+ if (intervalObj instanceof Number) {
+ int intervalMinutes = ((Number) intervalObj).intValue();
+ SharedPreferenceHelper.setSharedPreferenceInt(context, AppConstants.SHARED_PREFS_HEARTBEAT_INTERVAL_MINUTES_KEY, intervalMinutes);
+ Log.d(TAG, "Synced heartbeat interval from server: " + intervalMinutes + " minutes");
+ }
+ }
+ }
} else {
Log.e(TAG, "Failed to update device info after boot. Response code: " + response.code());
}
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 80eda09..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,13 +9,21 @@ 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;
+import java.util.HashSet;
import java.util.Objects;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
public class SMSBroadcastReceiver extends BroadcastReceiver {
private static final String TAG = "SMSBroadcastReceiver";
+ // In-memory cache to prevent rapid duplicate processing (5 seconds TTL)
+ private static final ConcurrentHashMap processedFingerprints = new ConcurrentHashMap<>();
+ private static final long CACHE_TTL_MS = 5000; // 5 seconds
@Override
public void onReceive(Context context, Intent intent) {
@@ -60,6 +68,37 @@ 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(),
+ receivedSMSDTO.getMessage(),
+ receivedSMSDTO.getReceivedAtInMillis()
+ );
+ receivedSMSDTO.setFingerprint(fingerprint);
+
+ // Check in-memory cache to prevent rapid duplicate processing
+ long currentTime = System.currentTimeMillis();
+ Long lastProcessedTime = processedFingerprints.get(fingerprint);
+
+ if (lastProcessedTime != null && (currentTime - lastProcessedTime) < CACHE_TTL_MS) {
+ Log.d(TAG, "Duplicate SMS detected in cache, skipping: " + fingerprint);
+ return;
+ }
+
+ // Update cache
+ processedFingerprints.put(fingerprint, currentTime);
+
+ // Clean up old cache entries periodically
+ cleanupCache(currentTime);
+
SMSReceivedWorker.enqueueWork(context, deviceId, apiKey, receivedSMSDTO);
}
@@ -69,4 +108,50 @@ public class SMSBroadcastReceiver extends BroadcastReceiver {
// appDatabase.localReceivedSMSDao().insertAll(localReceivedSMS);
// });
// }
+
+ /**
+ * Generate a unique fingerprint for an SMS message based on sender, message content, and timestamp
+ */
+ private String generateFingerprint(String sender, String message, long timestamp) {
+ try {
+ String data = (sender != null ? sender : "") + "|" +
+ (message != null ? message : "") + "|" +
+ timestamp;
+
+ MessageDigest md = MessageDigest.getInstance("MD5");
+ byte[] hashBytes = md.digest(data.getBytes("UTF-8"));
+
+ StringBuilder sb = new StringBuilder();
+ for (byte b : hashBytes) {
+ sb.append(String.format("%02x", b));
+ }
+ return sb.toString();
+ } catch (Exception e) {
+ Log.e(TAG, "Error generating fingerprint: " + e.getMessage());
+ // Fallback to simple string concatenation if MD5 fails
+ return (sender != null ? sender : "") + "_" +
+ (message != null ? message : "") + "_" +
+ timestamp;
+ }
+ }
+
+ /**
+ * Clean up old cache entries to prevent memory leaks
+ */
+ private void cleanupCache(long currentTime) {
+ // Only cleanup occasionally (every 100 entries processed)
+ if (processedFingerprints.size() > 100) {
+ Set keysToRemove = new HashSet<>();
+ for (String key : processedFingerprints.keySet()) {
+ Long timestamp = processedFingerprints.get(key);
+ if (timestamp != null && (currentTime - timestamp) > CACHE_TTL_MS) {
+ keysToRemove.add(key);
+ }
+ }
+ for (String key : keysToRemove) {
+ processedFingerprints.remove(key);
+ }
+ Log.d(TAG, "Cleaned up " + keysToRemove.size() + " expired cache entries");
+ }
+ }
}
\ No newline at end of file
diff --git a/android/app/src/main/java/com/vernu/sms/receivers/SMSStatusReceiver.java b/android/app/src/main/java/com/vernu/sms/receivers/SMSStatusReceiver.java
index e50e7a4..0cafb3d 100644
--- a/android/app/src/main/java/com/vernu/sms/receivers/SMSStatusReceiver.java
+++ b/android/app/src/main/java/com/vernu/sms/receivers/SMSStatusReceiver.java
@@ -102,6 +102,14 @@ public class SMSStatusReceiver extends BroadcastReceiver {
smsDTO.setErrorMessage(errorMessage);
Log.e(TAG, "SMS failed to send - ID: " + smsDTO.getSmsId() + ", Error code: " + resultCode + ", Error: " + errorMessage);
break;
+ case SmsManager.RESULT_NETWORK_ERROR:
+ errorMessage = "Network error";
+ smsDTO.setStatus("FAILED");
+ smsDTO.setFailedAtInMillis(timestamp);
+ smsDTO.setErrorCode(String.valueOf(resultCode));
+ smsDTO.setErrorMessage(errorMessage);
+ Log.e(TAG, "SMS failed to send - ID: " + smsDTO.getSmsId() + ", Error code: " + resultCode + ", Error: " + errorMessage);
+ break;
default:
errorMessage = "Unknown error";
smsDTO.setStatus("FAILED");
diff --git a/android/app/src/main/java/com/vernu/sms/services/FCMService.java b/android/app/src/main/java/com/vernu/sms/services/FCMService.java
index 92c717b..c923d31 100644
--- a/android/app/src/main/java/com/vernu/sms/services/FCMService.java
+++ b/android/app/src/main/java/com/vernu/sms/services/FCMService.java
@@ -18,7 +18,10 @@ import com.vernu.sms.R;
import com.vernu.sms.activities.MainActivity;
import com.vernu.sms.helpers.SMSHelper;
import com.vernu.sms.helpers.SharedPreferenceHelper;
+import com.vernu.sms.helpers.HeartbeatHelper;
+import com.vernu.sms.helpers.HeartbeatManager;
import com.vernu.sms.models.SMSPayload;
+import com.vernu.sms.TextBeeUtils;
import com.vernu.sms.dtos.RegisterDeviceInputDTO;
import com.vernu.sms.dtos.RegisterDeviceResponseDTO;
import com.vernu.sms.ApiManager;
@@ -36,7 +39,16 @@ public class FCMService extends FirebaseMessagingService {
Log.d(TAG, remoteMessage.getData().toString());
try {
- // Parse SMS payload data
+ // Check message type first
+ String messageType = remoteMessage.getData().get("type");
+
+ if ("heartbeat_check".equals(messageType)) {
+ // Handle heartbeat check request from backend
+ handleHeartbeatCheck();
+ return;
+ }
+
+ // Parse SMS payload data (legacy handling)
Gson gson = new Gson();
SMSPayload smsPayload = gson.fromJson(remoteMessage.getData().get("smsData"), SMSPayload.class);
@@ -54,6 +66,45 @@ public class FCMService extends FirebaseMessagingService {
}
}
+ /**
+ * Handle heartbeat check request from backend
+ */
+ private void handleHeartbeatCheck() {
+ Log.d(TAG, "Received heartbeat check request from backend");
+
+ // Check if device is eligible for heartbeat
+ if (!HeartbeatHelper.isDeviceEligibleForHeartbeat(this)) {
+ Log.d(TAG, "Device not eligible for heartbeat, skipping heartbeat check");
+ return;
+ }
+
+ // Get device ID and API key
+ String deviceId = SharedPreferenceHelper.getSharedPreferenceString(
+ this,
+ AppConstants.SHARED_PREFS_DEVICE_ID_KEY,
+ ""
+ );
+
+ String apiKey = SharedPreferenceHelper.getSharedPreferenceString(
+ this,
+ AppConstants.SHARED_PREFS_API_KEY_KEY,
+ ""
+ );
+
+ // Send heartbeat using shared helper
+ boolean success = HeartbeatHelper.sendHeartbeat(this, deviceId, apiKey);
+
+ if (success) {
+ Log.d(TAG, "Heartbeat sent successfully in response to backend check");
+ // Ensure scheduled work is added if missing
+ HeartbeatManager.scheduleHeartbeat(this);
+ } else {
+ Log.e(TAG, "Failed to send heartbeat in response to backend check");
+ // Still try to ensure scheduled work is added
+ HeartbeatManager.scheduleHeartbeat(this);
+ }
+ }
+
/**
* Send SMS to recipients using the provided payload
*/
@@ -63,9 +114,35 @@ public class FCMService extends FirebaseMessagingService {
return;
}
- // Get preferred SIM
- int preferredSim = SharedPreferenceHelper.getSharedPreferenceInt(
- this, AppConstants.SHARED_PREFS_PREFERRED_SIM_KEY, -1);
+ // Determine which SIM to use (priority: backend-provided > app preference > device default)
+ Integer simSubscriptionId = null;
+
+ // First, check if backend provided a SIM subscription ID
+ if (smsPayload.getSimSubscriptionId() != null) {
+ int backendSimId = smsPayload.getSimSubscriptionId();
+ // Validate that the subscription ID exists
+ if (TextBeeUtils.isValidSubscriptionId(this, backendSimId)) {
+ simSubscriptionId = backendSimId;
+ Log.d(TAG, "Using backend-provided SIM subscription ID: " + backendSimId);
+ } else {
+ Log.w(TAG, "Backend-provided SIM subscription ID " + backendSimId + " is not valid, falling back to app preference");
+ }
+ }
+
+ // If backend didn't provide a valid SIM, check app preference
+ if (simSubscriptionId == null) {
+ int preferredSim = SharedPreferenceHelper.getSharedPreferenceInt(
+ this, AppConstants.SHARED_PREFS_PREFERRED_SIM_KEY, -1);
+ if (preferredSim != -1) {
+ // Validate that the preferred SIM still exists
+ if (TextBeeUtils.isValidSubscriptionId(this, preferredSim)) {
+ simSubscriptionId = preferredSim;
+ Log.d(TAG, "Using app-preferred SIM subscription ID: " + preferredSim);
+ } else {
+ Log.w(TAG, "App-preferred SIM subscription ID " + preferredSim + " is no longer valid, using device default");
+ }
+ }
+ }
// Check if SMS payload contains valid recipients
String[] recipients = smsPayload.getRecipients();
@@ -82,9 +159,10 @@ public class FCMService extends FirebaseMessagingService {
for (String recipient : recipients) {
boolean smsSent;
- // Try to send using default or specific SIM based on preference
- if (preferredSim == -1) {
+ // Send using determined SIM (or device default if simSubscriptionId is null)
+ if (simSubscriptionId == null) {
// Use default SIM
+ Log.d(TAG, "Using device default SIM");
smsSent = SMSHelper.sendSMS(
recipient,
smsPayload.getMessage(),
@@ -98,7 +176,7 @@ public class FCMService extends FirebaseMessagingService {
smsSent = SMSHelper.sendSMSFromSpecificSim(
recipient,
smsPayload.getMessage(),
- preferredSim,
+ simSubscriptionId,
smsPayload.getSmsId(),
smsPayload.getSmsBatchId(),
this
diff --git a/android/app/src/main/java/com/vernu/sms/services/GatewayApiService.java b/android/app/src/main/java/com/vernu/sms/services/GatewayApiService.java
index e561f29..6874a17 100644
--- a/android/app/src/main/java/com/vernu/sms/services/GatewayApiService.java
+++ b/android/app/src/main/java/com/vernu/sms/services/GatewayApiService.java
@@ -4,6 +4,8 @@ import com.vernu.sms.dtos.SMSDTO;
import com.vernu.sms.dtos.SMSForwardResponseDTO;
import com.vernu.sms.dtos.RegisterDeviceInputDTO;
import com.vernu.sms.dtos.RegisterDeviceResponseDTO;
+import com.vernu.sms.dtos.HeartbeatInputDTO;
+import com.vernu.sms.dtos.HeartbeatResponseDTO;
import retrofit2.Call;
import retrofit2.http.Body;
@@ -24,4 +26,7 @@ public interface GatewayApiService {
@PATCH("gateway/devices/{deviceId}/sms-status")
Call updateSMSStatus(@Path("deviceId") String deviceId, @Header("x-api-key") String apiKey, @Body() SMSDTO body);
+
+ @POST("gateway/devices/{deviceId}/heartbeat")
+ Call heartbeat(@Path("deviceId") String deviceId, @Header("x-api-key") String apiKey, @Body() HeartbeatInputDTO body);
}
\ No newline at end of file
diff --git a/android/app/src/main/java/com/vernu/sms/workers/HeartbeatWorker.java b/android/app/src/main/java/com/vernu/sms/workers/HeartbeatWorker.java
new file mode 100644
index 0000000..6fb2c02
--- /dev/null
+++ b/android/app/src/main/java/com/vernu/sms/workers/HeartbeatWorker.java
@@ -0,0 +1,55 @@
+package com.vernu.sms.workers;
+
+import android.content.Context;
+import android.util.Log;
+
+import androidx.annotation.NonNull;
+import androidx.work.Worker;
+import androidx.work.WorkerParameters;
+
+import com.vernu.sms.AppConstants;
+import com.vernu.sms.helpers.SharedPreferenceHelper;
+import com.vernu.sms.helpers.HeartbeatHelper;
+
+public class HeartbeatWorker extends Worker {
+ private static final String TAG = "HeartbeatWorker";
+
+ public HeartbeatWorker(@NonNull Context context, @NonNull WorkerParameters workerParams) {
+ super(context, workerParams);
+ }
+
+ @NonNull
+ @Override
+ public Result doWork() {
+ Context context = getApplicationContext();
+
+ // Check if device is eligible for heartbeat
+ if (!HeartbeatHelper.isDeviceEligibleForHeartbeat(context)) {
+ Log.d(TAG, "Device not eligible for heartbeat, skipping");
+ return Result.success(); // Not a failure, just skip
+ }
+
+ // Get device ID and API key
+ String deviceId = SharedPreferenceHelper.getSharedPreferenceString(
+ context,
+ AppConstants.SHARED_PREFS_DEVICE_ID_KEY,
+ ""
+ );
+
+ String apiKey = SharedPreferenceHelper.getSharedPreferenceString(
+ context,
+ AppConstants.SHARED_PREFS_API_KEY_KEY,
+ ""
+ );
+
+ // Send heartbeat using shared helper
+ boolean success = HeartbeatHelper.sendHeartbeat(context, deviceId, apiKey);
+
+ if (success) {
+ return Result.success();
+ } else {
+ Log.e(TAG, "Failed to send heartbeat, will retry");
+ return Result.retry();
+ }
+ }
+}
diff --git a/android/app/src/main/java/com/vernu/sms/workers/SMSReceivedWorker.java b/android/app/src/main/java/com/vernu/sms/workers/SMSReceivedWorker.java
index 51c8c38..1f45182 100644
--- a/android/app/src/main/java/com/vernu/sms/workers/SMSReceivedWorker.java
+++ b/android/app/src/main/java/com/vernu/sms/workers/SMSReceivedWorker.java
@@ -94,13 +94,22 @@ public class SMSReceivedWorker extends Worker {
.addTag("sms_received")
.build();
- String uniqueWorkName = "sms_received_" + System.currentTimeMillis();
+ // Use fingerprint for unique work name if available, otherwise fallback to timestamp
+ String uniqueWorkName;
+ if (smsDTO.getFingerprint() != null && !smsDTO.getFingerprint().isEmpty()) {
+ uniqueWorkName = "sms_received_" + smsDTO.getFingerprint();
+ } else {
+ // Fallback to timestamp if fingerprint is not available
+ uniqueWorkName = "sms_received_" + System.currentTimeMillis();
+ Log.w(TAG, "Fingerprint not available, using timestamp for work name");
+ }
+
WorkManager.getInstance(context)
.beginUniqueWork(uniqueWorkName,
- androidx.work.ExistingWorkPolicy.APPEND_OR_REPLACE,
+ androidx.work.ExistingWorkPolicy.KEEP,
workRequest)
.enqueue();
- Log.d(TAG, "Work enqueued for received SMS from: " + smsDTO.getSender());
+ Log.d(TAG, "Work enqueued for received SMS from: " + smsDTO.getSender() + " with fingerprint: " + uniqueWorkName);
}
}
\ No newline at end of file
diff --git a/android/app/src/main/res/color/button_background_tint.xml b/android/app/src/main/res/color/button_background_tint.xml
new file mode 100644
index 0000000..b9bdc50
--- /dev/null
+++ b/android/app/src/main/res/color/button_background_tint.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/android/app/src/main/res/color/button_text_color.xml b/android/app/src/main/res/color/button_text_color.xml
new file mode 100644
index 0000000..29aebc3
--- /dev/null
+++ b/android/app/src/main/res/color/button_text_color.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/android/app/src/main/res/layout/activity_main.xml b/android/app/src/main/res/layout/activity_main.xml
index f756ab0..016fa94 100644
--- a/android/app/src/main/res/layout/activity_main.xml
+++ b/android/app/src/main/res/layout/activity_main.xml
@@ -20,26 +20,52 @@
android:layout_height="wrap_content"
android:background="?attr/colorPrimary"
android:orientation="vertical"
- android:padding="24dp">
+ android:padding="28dp">
-
+ android:orientation="horizontal"
+ android:gravity="center_vertical"
+ android:layout_gravity="center_horizontal"
+ android:layout_marginBottom="16dp">
-
+
+
+
+
+
+
+
+
+
@@ -50,21 +76,21 @@
android:layout_marginTop="-24dp"
android:layout_marginBottom="16dp"
app:cardBackgroundColor="@color/background_secondary"
- app:cardCornerRadius="8dp"
- app:cardElevation="4dp">
+ app:cardCornerRadius="12dp"
+ app:cardElevation="6dp">
+ android:padding="12dp">
@@ -72,7 +98,7 @@
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_gravity="center"
- android:layout_marginStart="3dp"
+ android:layout_marginStart="8dp"
android:layout_weight="1"
android:orientation="vertical">
@@ -109,11 +135,11 @@
@@ -163,23 +189,24 @@
android:layout_marginHorizontal="16dp"
android:layout_marginBottom="16dp"
app:cardBackgroundColor="@color/background_secondary"
- app:cardCornerRadius="8dp"
- app:cardElevation="2dp">
+ app:cardCornerRadius="12dp"
+ app:cardElevation="4dp">
+ android:padding="20dp">
+ android:textSize="19sp"
+ android:textStyle="bold"
+ android:letterSpacing="0.01" />
+ app:hintTextColor="?attr/colorPrimary"
+ app:endIconMode="password_toggle"
+ app:endIconTint="?attr/colorPrimary">
+
+
+
+
+
@@ -246,10 +294,15 @@
android:id="@+id/registerDeviceBtn"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
- android:backgroundTint="?attr/colorPrimary"
- android:paddingHorizontal="24dp"
+ android:backgroundTint="@color/button_background_tint"
+ android:paddingHorizontal="28dp"
+ android:paddingVertical="12dp"
android:text="Connect"
- android:textColor="@color/white" />
+ android:textColor="@color/button_text_color"
+ android:textSize="15sp"
+ android:letterSpacing="0.01"
+ style="@style/Widget.MaterialComponents.Button"
+ app:cornerRadius="8dp" />
+ android:textColor="?attr/colorPrimary"
+ android:textSize="15sp"
+ android:letterSpacing="0.01"
+ android:backgroundTint="@android:color/transparent"
+ style="@style/Widget.MaterialComponents.Button.OutlinedButton"
+ app:cornerRadius="8dp"
+ app:strokeColor="?attr/colorPrimary"
+ app:strokeWidth="1.5dp" />
@@ -276,23 +338,24 @@
android:layout_marginHorizontal="16dp"
android:layout_marginBottom="16dp"
app:cardBackgroundColor="@color/background_secondary"
- app:cardCornerRadius="8dp"
- app:cardElevation="2dp">
+ app:cardCornerRadius="12dp"
+ app:cardElevation="4dp">
+ android:padding="20dp">
+ android:textSize="19sp"
+ android:textStyle="bold"
+ android:letterSpacing="0.01" />
-
-
+ android:textColor="@color/button_text_color"
+ android:textSize="15sp"
+ android:letterSpacing="0.01"
+ android:visibility="visible"
+ style="@style/Widget.MaterialComponents.Button"
+ app:cornerRadius="8dp" />
+ android:alpha="0.6"
+ android:layout_marginBottom="18dp" />
+
+
+
+ android:layout_marginEnd="6dp" />
+ android:alpha="0.6"
+ android:layout_marginBottom="18dp" />
+ android:textSize="14sp"
+ android:lineSpacingMultiplier="1.2" />
+ app:cardCornerRadius="12dp"
+ app:cardElevation="4dp">
+ android:padding="20dp">
+ android:textSize="19sp"
+ android:textStyle="bold"
+ android:letterSpacing="0.01" />
+ android:layout_marginBottom="10dp"
+ android:gravity="center_vertical">
+ android:layout_marginBottom="10dp"
+ android:gravity="center_vertical">
+ android:orientation="horizontal"
+ android:gravity="center_vertical">
+ app:cardCornerRadius="12dp"
+ app:cardElevation="4dp">
+ android:padding="20dp">
+ android:padding="4dp"
+ android:layout_marginEnd="14dp" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/layout/dialog_add_filter_rule.xml b/android/app/src/main/res/layout/dialog_add_filter_rule.xml
new file mode 100644
index 0000000..39511ab
--- /dev/null
+++ b/android/app/src/main/res/layout/dialog_add_filter_rule.xml
@@ -0,0 +1,132 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/layout/item_filter_rule.xml b/android/app/src/main/res/layout/item_filter_rule.xml
new file mode 100644
index 0000000..8d31091
--- /dev/null
+++ b/android/app/src/main/res/layout/item_filter_rule.xml
@@ -0,0 +1,71 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/values-night/themes.xml b/android/app/src/main/res/values-night/themes.xml
index 3b93190..6bdd278 100644
--- a/android/app/src/main/res/values-night/themes.xml
+++ b/android/app/src/main/res/values-night/themes.xml
@@ -2,12 +2,12 @@