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" /> - -