Merge pull request #178 from vernu/dev

Dev
This commit is contained in:
Israel Abebe
2026-02-06 20:17:07 +03:00
committed by GitHub
48 changed files with 3343 additions and 161 deletions

View File

@@ -15,6 +15,7 @@
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application
android:name=".SMSGatewayApplication"
android:allowBackup="true"
@@ -80,6 +81,11 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".activities.SMSFilterActivity"
android:exported="false"
android:theme="@style/Theme.SMSGateway.NoActionBar"
android:parentActivityName=".activities.MainActivity" />
<activity
android:name="com.journeyapps.barcodescanner.CaptureActivity"
android:screenOrientation="portrait"

View File

@@ -19,4 +19,9 @@ public class AppConstants {
public static final String SHARED_PREFS_LAST_VERSION_CODE_KEY = "LAST_VERSION_CODE";
public static final String SHARED_PREFS_LAST_VERSION_NAME_KEY = "LAST_VERSION_NAME";
public static final String SHARED_PREFS_STICKY_NOTIFICATION_ENABLED_KEY = "STICKY_NOTIFICATION_ENABLED";
public static final String HEARTBEAT_WORK_TAG = "heartbeat";
public static final String SHARED_PREFS_HEARTBEAT_ENABLED_KEY = "HEARTBEAT_ENABLED";
public static final String SHARED_PREFS_HEARTBEAT_INTERVAL_MINUTES_KEY = "HEARTBEAT_INTERVAL_MINUTES";
public static final String SHARED_PREFS_SMS_FILTER_CONFIG_KEY = "SMS_FILTER_CONFIG";
public static final String SHARED_PREFS_DEVICE_NAME_KEY = "DEVICE_NAME";
}

View File

@@ -1,14 +1,28 @@
package com.vernu.sms;
import android.app.Application;
import android.util.Log;
import androidx.work.Configuration;
import androidx.work.WorkManager;
public class SMSGatewayApplication extends Application implements Configuration.Provider {
private static final String TAG = "SMSGatewayApplication";
@Override
public void onCreate() {
super.onCreate();
// Initialize WorkManager early to ensure it's ready for background work
// This is important for background tasks like heartbeat
try {
WorkManager.initialize(this, getWorkManagerConfiguration());
Log.d(TAG, "WorkManager initialized successfully");
} catch (IllegalStateException e) {
// WorkManager might already be initialized (e.g., by androidx.startup)
// This is fine, we can continue
Log.d(TAG, "WorkManager already initialized or will be initialized automatically");
}
}
@Override

View File

@@ -15,6 +15,7 @@ import androidx.core.content.ContextCompat;
import com.google.firebase.crashlytics.FirebaseCrashlytics;
import com.vernu.sms.services.StickyNotificationService;
import com.vernu.sms.helpers.SharedPreferenceHelper;
import com.vernu.sms.dtos.SimInfoDTO;
import java.util.ArrayList;
import java.util.List;
@@ -117,4 +118,172 @@ public class TextBeeUtils {
public static void logException(Throwable throwable, String message) {
logException(throwable, message, null);
}
/**
* Collects all available SIM information (physical SIMs and eSIMs) from the device
*
* @param context The application context
* @return List of SimInfoDTO objects containing SIM information, or empty list if permission not granted
*/
public static List<SimInfoDTO> collectSimInfo(Context context) {
List<SimInfoDTO> 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<SubscriptionInfo> 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<SubscriptionInfo> 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;
}
}

View File

@@ -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<RegisterDeviceResponseDTO> call, Response<RegisterDeviceResponseDTO> 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<RegisterDeviceResponseDTO> call, Response<RegisterDeviceResponseDTO> 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<RegisterDeviceResponseDTO> apiCall = ApiManager.getApiService().registerDevice(newKey, registerDeviceInput);
apiCall.enqueue(new Callback<RegisterDeviceResponseDTO>() {
@Override
public void onResponse(Call<RegisterDeviceResponseDTO> call, Response<RegisterDeviceResponseDTO> 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<RegisterDeviceResponseDTO>() {
@Override
public void onResponse(Call<RegisterDeviceResponseDTO> call, Response<RegisterDeviceResponseDTO> 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<RegisterDeviceResponseDTO> apiCall = ApiManager.getApiService().updateDevice(deviceIdToUse, apiKey, updateDeviceInput);
apiCall.enqueue(new Callback<RegisterDeviceResponseDTO>() {
@Override
public void onResponse(Call<RegisterDeviceResponseDTO> call, Response<RegisterDeviceResponseDTO> 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

View File

@@ -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<String> 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<String> 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<FilterRulesAdapter.ViewHolder> {
private List<SMSFilterRule> rules;
public FilterRulesAdapter(List<SMSFilterRule> 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);
}
}
}
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,8 @@
package com.vernu.sms.dtos;
public class HeartbeatResponseDTO {
public boolean success;
public boolean fcmTokenUpdated;
public long lastHeartbeat;
public String name;
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,27 @@
package com.vernu.sms.dtos;
import java.util.List;
public class SimInfoCollectionDTO {
private long lastUpdated;
private List<SimInfoDTO> sims;
public SimInfoCollectionDTO() {
}
public long getLastUpdated() {
return lastUpdated;
}
public void setLastUpdated(long lastUpdated) {
this.lastUpdated = lastUpdated;
}
public List<SimInfoDTO> getSims() {
return sims;
}
public void setSims(List<SimInfoDTO> sims) {
this.sims = sims;
}
}

View File

@@ -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;
}
}

View File

@@ -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<HeartbeatResponseDTO> call = ApiManager.getApiService().heartbeat(deviceId, apiKey, heartbeatInput);
Response<HeartbeatResponseDTO> 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;
}
}

View File

@@ -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);
}
}

View File

@@ -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<SMSFilterRule> 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<SMSFilterRule> getRules() {
return rules;
}
public void setRules(List<SMSFilterRule> 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<FilterConfig>() {}.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);
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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<RegisterDeviceResponseDTO> call, Response<RegisterDeviceResponseDTO> 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());
}

View File

@@ -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<String, Long> 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<String> 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");
}
}
}

View File

@@ -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");

View File

@@ -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

View File

@@ -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<SMSForwardResponseDTO> updateSMSStatus(@Path("deviceId") String deviceId, @Header("x-api-key") String apiKey, @Body() SMSDTO body);
@POST("gateway/devices/{deviceId}/heartbeat")
Call<HeartbeatResponseDTO> heartbeat(@Path("deviceId") String deviceId, @Header("x-api-key") String apiKey, @Body() HeartbeatInputDTO body);
}

View File

@@ -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();
}
}
}

View File

@@ -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);
}
}

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:color="?attr/colorPrimary" android:state_enabled="true"/>
<item android:color="?attr/colorPrimary" android:alpha="0.12"/>
</selector>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:color="@color/white" android:state_enabled="true"/>
<item android:color="@color/white" android:alpha="0.38"/>
</selector>

View File

@@ -20,26 +20,52 @@
android:layout_height="wrap_content"
android:background="?attr/colorPrimary"
android:orientation="vertical"
android:padding="24dp">
android:padding="28dp">
<TextView
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="textbee.dev - sms gateway"
android:textAlignment="center"
android:textColor="@color/white"
android:textSize="22sp"
android:textStyle="bold" />
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_gravity="center_horizontal"
android:layout_marginBottom="16dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:layout_marginBottom="8dp"
android:text="Your ultimate solution for seamless SMS communication"
android:textAlignment="center"
android:textColor="@color/white"
android:textSize="12sp" />
<ImageView
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginEnd="12dp"
android:src="@mipmap/ic_launcher"
android:contentDescription="App Icon" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="textbee.dev - sms gateway"
android:textColor="@color/white"
android:textSize="24sp"
android:textStyle="bold"
android:letterSpacing="0.02"
android:shadowColor="#80000000"
android:shadowDx="0"
android:shadowDy="2"
android:shadowRadius="4" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="Streamline your SMS workflows"
android:textColor="@color/white"
android:textSize="13sp"
android:letterSpacing="0.01"
android:alpha="0.95" />
</LinearLayout>
</LinearLayout>
</LinearLayout>
<!-- Device Info Card -->
@@ -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">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:orientation="horizontal"
android:padding="6dp">
android:padding="12dp">
<ImageView
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_width="52dp"
android:layout_height="52dp"
android:layout_gravity="center"
android:padding="3dp"
android:padding="6dp"
android:src="@drawable/ic_baseline_phone_android_24"
android:tint="?attr/colorPrimary" />
@@ -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 @@
<ImageButton
android:id="@+id/copyDeviceIdImgBtn"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginStart="2dp"
android:layout_width="26dp"
android:layout_height="26dp"
android:layout_marginStart="4dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:padding="2dp"
android:padding="4dp"
android:src="@drawable/ic_baseline_content_copy_24"
android:tint="?attr/colorPrimary" />
</LinearLayout>
@@ -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">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
android:padding="20dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
android:layout_marginBottom="18dp"
android:text="Account Information"
android:textColor="@color/text_primary"
android:textSize="18sp"
android:textStyle="bold" />
android:textSize="19sp"
android:textStyle="bold"
android:letterSpacing="0.01" />
<!-- Device ID Input Field -->
<com.google.android.material.textfield.TextInputLayout
@@ -212,12 +239,33 @@
android:hint="API Key"
app:boxBackgroundColor="@android:color/transparent"
app:boxStrokeColor="?attr/colorPrimary"
app:hintTextColor="?attr/colorPrimary">
app:hintTextColor="?attr/colorPrimary"
app:endIconMode="password_toggle"
app:endIconTint="?attr/colorPrimary">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/apiKeyEditText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textPassword"
android:textColor="@color/text_primary"
android:textIsSelectable="true" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
android:hint="Device name (optional)"
app:boxBackgroundColor="@android:color/transparent"
app:boxStrokeColor="?attr/colorPrimary"
app:hintTextColor="?attr/colorPrimary"
app:endIconTint="?attr/colorPrimary">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/deviceNameEditText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="text"
android:textColor="@color/text_primary"
android:textIsSelectable="true" />
@@ -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" />
<View
android:layout_width="0dp"
@@ -259,11 +312,20 @@
android:id="@+id/scanQRButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="5dp"
android:layout_marginStart="8dp"
android:drawableLeft="@drawable/ic_baseline_qr_code_24"
android:drawablePadding="6dp"
android:paddingHorizontal="20dp"
android:paddingVertical="12dp"
android:text="Scan QR"
android:textColor="@color/black"
android:theme="@style/Theme.Design.Light" />
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" />
</LinearLayout>
</LinearLayout>
@@ -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">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
android:padding="20dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
android:layout_marginBottom="18dp"
android:text="Configuration"
android:textColor="@color/text_primary"
android:textSize="18sp"
android:textStyle="bold" />
android:textSize="19sp"
android:textStyle="bold"
android:letterSpacing="0.01" />
<!-- Permissions Section -->
<LinearLayout
@@ -309,30 +372,28 @@
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="REQUIRED for textbee to function!"
android:textColor="@android:color/holo_red_dark"
android:textSize="14sp"
android:textStyle="bold"
android:layout_marginBottom="8dp" />
<Button
android:id="@+id/grantSMSPermissionBtn"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:backgroundTint="?attr/colorPrimary"
android:backgroundTint="@color/button_background_tint"
android:paddingHorizontal="24dp"
android:paddingVertical="12dp"
android:text="Grant SMS Permissions"
android:textColor="@color/white"
android:visibility="visible" />
android:textColor="@color/button_text_color"
android:textSize="15sp"
android:letterSpacing="0.01"
android:visibility="visible"
style="@style/Widget.MaterialComponents.Button"
app:cornerRadius="8dp" />
</LinearLayout>
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_height="1.5dp"
android:background="@color/divider"
android:layout_marginBottom="16dp" />
android:alpha="0.6"
android:layout_marginBottom="18dp" />
<!-- Receive SMS Toggle -->
<LinearLayout
@@ -371,6 +432,22 @@
android:minHeight="32dp" />
</LinearLayout>
<!-- Filter Configuration Link -->
<Button
android:id="@+id/configureFilterBtn"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
android:text="Configure Filter"
android:textColor="?attr/colorPrimary"
android:textSize="15sp"
android:letterSpacing="0.01"
android:backgroundTint="@android:color/transparent"
android:drawableStart="@android:drawable/ic_menu_manage"
android:drawablePadding="6dp"
android:paddingHorizontal="0dp"
style="@style/Widget.MaterialComponents.Button.TextButton" />
<!-- Sticky Notification Setting -->
<LinearLayout
android:layout_width="match_parent"
@@ -400,11 +477,11 @@
android:gravity="center_vertical">
<ImageView
android:layout_width="16dp"
android:layout_height="16dp"
android:layout_width="18dp"
android:layout_height="18dp"
android:src="@android:drawable/ic_dialog_info"
android:tint="?attr/colorPrimary"
android:layout_marginEnd="4dp" />
android:layout_marginEnd="6dp" />
<TextView
android:layout_width="wrap_content"
@@ -424,9 +501,10 @@
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_height="1.5dp"
android:background="@color/divider"
android:layout_marginBottom="16dp" />
android:alpha="0.6"
android:layout_marginBottom="18dp" />
<!-- Default SIM Selection -->
<TextView
@@ -440,10 +518,11 @@
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:text="Select your preferred SIM for sending SMS"
android:layout_marginBottom="12dp"
android:text="This is the default SIM that will be used for sending SMS. You can override for individual SMS by providing the specific simSubscriptionId in your API request."
android:textColor="@color/text_secondary"
android:textSize="14sp" />
android:textSize="14sp"
android:lineSpacingMultiplier="1.2" />
<RadioGroup
android:id="@+id/defaultSimSlotRadioGroup"
@@ -462,38 +541,41 @@
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">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
android:padding="20dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:layout_marginBottom="12dp"
android:text="How To Use"
android:textColor="@color/text_primary"
android:textSize="18sp"
android:textStyle="bold" />
android:textSize="19sp"
android:textStyle="bold"
android:letterSpacing="0.01" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginBottom="8dp">
android:layout_marginBottom="10dp"
android:gravity="center_vertical">
<TextView
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginEnd="8dp"
android:layout_width="28dp"
android:layout_height="28dp"
android:layout_marginEnd="12dp"
android:background="?attr/colorPrimary"
android:gravity="center"
android:text="1"
android:textColor="@color/white"
android:textSize="14sp"
android:textStyle="bold" />
<TextView
@@ -512,16 +594,18 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginBottom="8dp">
android:layout_marginBottom="10dp"
android:gravity="center_vertical">
<TextView
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginEnd="8dp"
android:layout_width="28dp"
android:layout_height="28dp"
android:layout_marginEnd="12dp"
android:background="?attr/colorPrimary"
android:gravity="center"
android:text="2"
android:textColor="@color/white"
android:textSize="14sp"
android:textStyle="bold" />
<TextView
@@ -534,16 +618,18 @@
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
android:orientation="horizontal"
android:gravity="center_vertical">
<TextView
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginEnd="8dp"
android:layout_width="28dp"
android:layout_height="28dp"
android:layout_marginEnd="12dp"
android:background="?attr/colorPrimary"
android:gravity="center"
android:text="3"
android:textColor="@color/white"
android:textSize="14sp"
android:textStyle="bold" />
<TextView
@@ -562,14 +648,14 @@
android:layout_marginHorizontal="16dp"
android:layout_marginBottom="24dp"
app:cardBackgroundColor="@color/background_secondary"
app:cardCornerRadius="8dp"
app:cardElevation="2dp">
app:cardCornerRadius="12dp"
app:cardElevation="4dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
android:padding="20dp">
<LinearLayout
android:layout_width="match_parent"
@@ -578,11 +664,12 @@
android:gravity="center_horizontal">
<ImageView
android:layout_width="32dp"
android:layout_height="32dp"
android:layout_width="36dp"
android:layout_height="36dp"
android:src="@drawable/ic_baseline_info_24"
android:tint="?attr/colorPrimary"
android:layout_marginEnd="12dp" />
android:padding="4dp"
android:layout_marginEnd="14dp" />
<LinearLayout
android:layout_width="match_parent"

View File

@@ -0,0 +1,230 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/background_primary"
tools:context=".activities.SMSFilterActivity">
<!-- Header Section -->
<LinearLayout
android:id="@+id/header"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/colorPrimary"
android:orientation="vertical"
android:padding="28dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginBottom="10dp">
<ImageButton
android:id="@+id/backButton"
android:layout_width="48dp"
android:layout_height="48dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:src="@android:drawable/ic_menu_revert"
android:tint="@color/white"
android:contentDescription="Back"
android:padding="8dp" />
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="SMS Filter"
android:textAlignment="center"
android:textColor="@color/white"
android:textSize="24sp"
android:textStyle="bold"
android:letterSpacing="0.02"
android:shadowColor="#80000000"
android:shadowDx="0"
android:shadowDy="2"
android:shadowRadius="4" />
<View
android:layout_width="48dp"
android:layout_height="48dp" />
</LinearLayout>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Configure SMS filtering rules"
android:textAlignment="center"
android:textColor="@color/white"
android:textSize="13sp"
android:letterSpacing="0.01"
android:alpha="0.95" />
</LinearLayout>
<!-- Scrollable Content -->
<ScrollView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_below="@id/header">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<!-- Filter Settings Card -->
<androidx.cardview.widget.CardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:layout_marginTop="16dp"
android:layout_marginBottom="16dp"
app:cardBackgroundColor="@color/background_secondary"
app:cardCornerRadius="12dp"
app:cardElevation="4dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="20dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="18dp"
android:text="Filter Settings"
android:textColor="@color/text_primary"
android:textSize="19sp"
android:textStyle="bold"
android:letterSpacing="0.01" />
<!-- Filter Enabled Toggle -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginBottom="16dp">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Enable Filter"
android:textColor="@color/text_primary"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Enable SMS filtering"
android:textColor="@color/text_secondary"
android:textSize="14sp" />
</LinearLayout>
<Switch
android:id="@+id/filterEnabledSwitch"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:minHeight="32dp" />
</LinearLayout>
<View
android:layout_width="match_parent"
android:layout_height="1.5dp"
android:background="@color/divider"
android:alpha="0.6"
android:layout_marginBottom="18dp" />
<!-- Filter Mode Selection -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
android:text="Filter Mode"
android:textColor="@color/text_primary"
android:textSize="16sp"
android:textStyle="bold" />
<RadioGroup
android:id="@+id/filterModeRadioGroup"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:layout_marginStart="8dp">
<RadioButton
android:id="@+id/allowListRadio"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Allow List (only process SMS matching these filters)"
android:textColor="@color/text_primary"
android:paddingVertical="8dp" />
<RadioButton
android:id="@+id/blockListRadio"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Block List (exclude these numbers/messages)"
android:textColor="@color/text_primary"
android:paddingVertical="8dp" />
</RadioGroup>
</LinearLayout>
</androidx.cardview.widget.CardView>
<!-- Filter Rules Card -->
<androidx.cardview.widget.CardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:layout_marginBottom="16dp"
app:cardBackgroundColor="@color/background_secondary"
app:cardCornerRadius="12dp"
app:cardElevation="4dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="20dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="18dp"
android:text="Filter Rules"
android:textColor="@color/text_primary"
android:textSize="19sp"
android:textStyle="bold"
android:letterSpacing="0.01" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/filterRulesRecyclerView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:nestedScrollingEnabled="false" />
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/addRuleFab"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end"
android:layout_marginTop="16dp"
android:src="@android:drawable/ic_input_add"
app:tint="@color/white"
app:backgroundTint="?attr/colorPrimary" />
</LinearLayout>
</androidx.cardview.widget.CardView>
</LinearLayout>
</ScrollView>
</RelativeLayout>

View File

@@ -0,0 +1,132 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="24dp">
<TextView
android:id="@+id/dialogTitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="20dp"
android:text="Add Filter Rule"
android:textColor="@color/text_primary"
android:textSize="20sp"
android:textStyle="bold" />
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
android:hint="Pattern (phone number or text)"
app:boxBackgroundColor="@android:color/transparent"
app:boxStrokeColor="?attr/colorPrimary"
app:hintTextColor="?attr/colorPrimary">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/patternEditText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="text"
android:textColor="@color/text_primary"
android:maxLines="1" />
</com.google.android.material.textfield.TextInputLayout>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:text="Filter Target"
android:textColor="@color/text_primary"
android:textSize="16sp"
android:textStyle="bold" />
<Spinner
android:id="@+id/filterTargetSpinner"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
android:minHeight="48dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:text="Match Type"
android:textColor="@color/text_primary"
android:textSize="16sp"
android:textStyle="bold" />
<Spinner
android:id="@+id/matchTypeSpinner"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
android:minHeight="48dp" />
<!-- Case Sensitivity Toggle -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginBottom="24dp">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Case Sensitive"
android:textColor="@color/text_primary"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Match pattern with exact case"
android:textColor="@color/text_secondary"
android:textSize="14sp" />
</LinearLayout>
<Switch
android:id="@+id/caseSensitiveSwitch"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:minHeight="32dp" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="end">
<Button
android:id="@+id/cancelButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:text="Cancel"
android:textColor="?attr/colorPrimary"
android:backgroundTint="@android:color/transparent"
style="@style/Widget.MaterialComponents.Button.TextButton" />
<Button
android:id="@+id/saveButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Save"
android:textColor="@color/button_text_color"
android:backgroundTint="@color/button_background_tint"
style="@style/Widget.MaterialComponents.Button"
app:cornerRadius="8dp" />
</LinearLayout>
</LinearLayout>

View File

@@ -0,0 +1,71 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.cardview.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
app:cardBackgroundColor="@color/background_primary"
app:cardCornerRadius="8dp"
app:cardElevation="2dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:padding="16dp">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/patternText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Pattern"
android:textColor="@color/text_primary"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:id="@+id/matchTypeText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="Match Type"
android:textColor="@color/text_secondary"
android:textSize="14sp" />
<TextView
android:id="@+id/filterTargetText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:text="Filter Target"
android:textColor="@color/text_secondary"
android:textSize="12sp" />
</LinearLayout>
<ImageButton
android:id="@+id/editButton"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginEnd="8dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:src="@android:drawable/ic_menu_edit"
android:tint="?attr/colorPrimary"
android:contentDescription="Edit rule" />
<ImageButton
android:id="@+id/deleteButton"
android:layout_width="40dp"
android:layout_height="40dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:src="@android:drawable/ic_menu_delete"
android:tint="#F44336"
android:contentDescription="Delete rule" />
</LinearLayout>
</androidx.cardview.widget.CardView>

View File

@@ -2,12 +2,12 @@
<!-- Base application theme. -->
<style name="Theme.SMSGateway" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
<!-- Primary brand color. -->
<item name="colorPrimary">#D97706</item>
<item name="colorPrimaryVariant">#B86504</item>
<item name="colorPrimary">#C4620A</item>
<item name="colorPrimaryVariant">#A04405</item>
<item name="colorOnPrimary">@color/white</item>
<!-- Secondary brand color. -->
<item name="colorSecondary">#f35b04</item>
<item name="colorSecondaryVariant">#f18701</item>
<item name="colorSecondary">#B45309</item>
<item name="colorSecondaryVariant">#92400E</item>
<item name="colorOnSecondary">@color/black</item>
<!-- Status bar color. -->
<item name="android:statusBarColor" tools:targetApi="l">?attr/colorPrimaryVariant</item>
@@ -23,12 +23,12 @@
<!-- Theme without action bar for MainActivity -->
<style name="Theme.SMSGateway.NoActionBar" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<!-- Primary brand color. -->
<item name="colorPrimary">#D97706</item>
<item name="colorPrimaryVariant">#B86504</item>
<item name="colorPrimary">#C4620A</item>
<item name="colorPrimaryVariant">#A04405</item>
<item name="colorOnPrimary">@color/white</item>
<!-- Secondary brand color. -->
<item name="colorSecondary">#f35b04</item>
<item name="colorSecondaryVariant">#f18701</item>
<item name="colorSecondary">#B45309</item>
<item name="colorSecondaryVariant">#92400E</item>
<item name="colorOnSecondary">@color/black</item>
<!-- Status bar color. -->
<item name="android:statusBarColor" tools:targetApi="l">?attr/colorPrimaryVariant</item>

View File

@@ -2,12 +2,12 @@
<!-- Base application theme. -->
<style name="Theme.SMSGateway" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
<!-- Primary brand color. -->
<item name="colorPrimary">#D97706</item>
<item name="colorPrimaryVariant">#B86504</item>
<item name="colorPrimary">#C4620A</item>
<item name="colorPrimaryVariant">#A04405</item>
<item name="colorOnPrimary">@color/white</item>
<!-- Secondary brand color. -->
<item name="colorSecondary">#f35b04</item>
<item name="colorSecondaryVariant">#f18701</item>
<item name="colorSecondary">#B45309</item>
<item name="colorSecondaryVariant">#92400E</item>
<item name="colorOnSecondary">@color/black</item>
<!-- Status bar color. -->
<item name="android:statusBarColor" tools:targetApi="l">?attr/colorPrimaryVariant</item>
@@ -17,12 +17,12 @@
<!-- Theme without action bar for MainActivity -->
<style name="Theme.SMSGateway.NoActionBar" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<!-- Primary brand color. -->
<item name="colorPrimary">#D97706</item>
<item name="colorPrimaryVariant">#B86504</item>
<item name="colorPrimary">#C4620A</item>
<item name="colorPrimaryVariant">#A04405</item>
<item name="colorOnPrimary">@color/white</item>
<!-- Secondary brand color. -->
<item name="colorSecondary">#f35b04</item>
<item name="colorSecondaryVariant">#f18701</item>
<item name="colorSecondary">#B45309</item>
<item name="colorSecondaryVariant">#92400E</item>
<item name="colorOnSecondary">@color/black</item>
<!-- Status bar color. -->
<item name="android:statusBarColor" tools:targetApi="l">?attr/colorPrimaryVariant</item>

View File

@@ -26,6 +26,8 @@ import {
SendBulkSMSInputDTO,
SendSMSInputDTO,
UpdateSMSStatusDTO,
HeartbeatInputDTO,
HeartbeatResponseDTO,
} from './gateway.dto'
import { GatewayService } from './gateway.service'
import { CanModifyDevice } from './guards/can-modify-device.guard'
@@ -70,6 +72,18 @@ export class GatewayController {
return { data }
}
@ApiOperation({ summary: 'Device heartbeat' })
@UseGuards(AuthGuard, CanModifyDevice)
@Post('/devices/:id/heartbeat')
@HttpCode(HttpStatus.OK)
async heartbeat(
@Param('id') deviceId: string,
@Body() input: HeartbeatInputDTO,
): Promise<HeartbeatResponseDTO> {
const data = await this.gatewayService.heartbeat(deviceId, input)
return data
}
@ApiOperation({ summary: 'Delete device' })
@UseGuards(AuthGuard, CanModifyDevice)
@Delete('/devices/:id')

View File

@@ -1,5 +1,45 @@
import { ApiProperty } from '@nestjs/swagger'
export class SimInfoDTO {
@ApiProperty({ type: Number, required: true })
subscriptionId: number
@ApiProperty({ type: String, required: false })
iccId?: string
@ApiProperty({ type: Number, required: false })
cardId?: number
@ApiProperty({ type: String, required: false })
carrierName?: string
@ApiProperty({ type: String, required: false })
displayName?: string
@ApiProperty({ type: Number, required: false })
simSlotIndex?: number
@ApiProperty({ type: String, required: false })
mcc?: string
@ApiProperty({ type: String, required: false })
mnc?: string
@ApiProperty({ type: String, required: false })
countryIso?: string
@ApiProperty({ type: String, required: false, enum: ['PHYSICAL_SIM', 'ESIM'] })
subscriptionType?: string
}
export class SimInfoCollectionDTO {
@ApiProperty({ type: Date, required: true })
lastUpdated: Date
@ApiProperty({ type: [SimInfoDTO], required: true })
sims: SimInfoDTO[]
}
export class RegisterDeviceInputDTO {
@ApiProperty({ type: Boolean })
enabled?: boolean
@@ -16,6 +56,9 @@ export class RegisterDeviceInputDTO {
@ApiProperty({ type: String })
model?: string
@ApiProperty({ type: String, required: false })
name?: string
@ApiProperty({ type: String })
serial?: string
@@ -33,6 +76,9 @@ export class RegisterDeviceInputDTO {
@ApiProperty({ type: String })
appVersionCode?: number
@ApiProperty({ type: SimInfoCollectionDTO, required: false })
simInfo?: SimInfoCollectionDTO
}
export class SMSData {
@@ -51,6 +97,21 @@ export class SMSData {
})
recipients: string[]
@ApiProperty({
type: Number,
required: false,
description: 'Optional SIM subscription ID to use for sending SMS',
})
simSubscriptionId?: number
@ApiProperty({
type: String,
required: false,
description: 'Optional ISO 8601 date string to schedule SMS for future delivery (e.g., "2024-01-15T10:30:00Z"). Must be a future date.',
example: '2024-01-15T10:30:00Z',
})
scheduledAt?: string
// TODO: restructure the Payload such that it contains bactchId, smsId, recipients and message in an optimized way
// message: string
// bactchId: string
@@ -299,3 +360,144 @@ export class UpdateSMSStatusDTO {
})
errorMessage?: string
}
export class HeartbeatInputDTO {
@ApiProperty({
type: String,
required: false,
description: 'FCM token for push notifications',
})
fcmToken?: string
@ApiProperty({
type: Number,
required: false,
description: 'Battery percentage (0-100)',
})
batteryPercentage?: number
@ApiProperty({
type: Boolean,
required: false,
description: 'Whether device is currently charging',
})
isCharging?: boolean
@ApiProperty({
type: String,
required: false,
description: 'Network type',
enum: ['wifi', 'cellular', 'none'],
})
networkType?: 'wifi' | 'cellular' | 'none'
@ApiProperty({
type: String,
required: false,
description: 'App version name',
})
appVersionName?: string
@ApiProperty({
type: Number,
required: false,
description: 'App version code',
})
appVersionCode?: number
@ApiProperty({
type: Number,
required: false,
description: 'Device uptime in milliseconds since boot',
})
deviceUptimeMillis?: number
@ApiProperty({
type: Number,
required: false,
description: 'Free memory in bytes',
})
memoryFreeBytes?: number
@ApiProperty({
type: Number,
required: false,
description: 'Total memory in bytes',
})
memoryTotalBytes?: number
@ApiProperty({
type: Number,
required: false,
description: 'Max memory in bytes',
})
memoryMaxBytes?: number
@ApiProperty({
type: Number,
required: false,
description: 'Available storage in bytes',
})
storageAvailableBytes?: number
@ApiProperty({
type: Number,
required: false,
description: 'Total storage in bytes',
})
storageTotalBytes?: number
@ApiProperty({
type: String,
required: false,
description: 'Device timezone (e.g., "America/New_York")',
})
timezone?: string
@ApiProperty({
type: String,
required: false,
description: 'Device locale (e.g., "en_US")',
})
locale?: string
@ApiProperty({
type: Boolean,
required: false,
description: 'Whether receive SMS feature is enabled',
})
receiveSMSEnabled?: boolean
@ApiProperty({ type: SimInfoCollectionDTO, required: false })
simInfo?: SimInfoCollectionDTO
}
export class HeartbeatResponseDTO {
@ApiProperty({
type: Boolean,
required: true,
description: 'Whether the heartbeat was successful',
})
success: boolean
@ApiProperty({
type: Boolean,
required: true,
description: 'Whether the FCM token was updated',
})
fcmTokenUpdated: boolean
@ApiProperty({
type: Date,
required: true,
description: 'Server timestamp of the heartbeat',
})
lastHeartbeat: Date
@ApiProperty({
type: String,
required: false,
description: 'Device name (if updated)',
})
name?: string
}

View File

@@ -14,6 +14,7 @@ import { ConfigModule } from '@nestjs/config'
import { SmsQueueService } from './queue/sms-queue.service'
import { SmsQueueProcessor } from './queue/sms-queue.processor'
import { SmsStatusUpdateTask } from './tasks/sms-status-update.task'
import { HeartbeatCheckTask } from './tasks/heartbeat-check.task'
@Module({
imports: [
@@ -50,7 +51,7 @@ import { SmsStatusUpdateTask } from './tasks/sms-status-update.task'
ConfigModule,
],
controllers: [GatewayController],
providers: [GatewayService, SmsQueueService, SmsQueueProcessor, SmsStatusUpdateTask],
providers: [GatewayService, SmsQueueService, SmsQueueProcessor, SmsStatusUpdateTask, HeartbeatCheckTask],
exports: [MongooseModule, GatewayService, SmsQueueService],
})
export class GatewayModule {}

View File

@@ -50,6 +50,7 @@ describe('GatewayService', () => {
const mockSmsModel = {
create: jest.fn(),
find: jest.fn(),
findOne: jest.fn(),
updateMany: jest.fn(),
countDocuments: jest.fn(),
}
@@ -179,7 +180,7 @@ describe('GatewayService', () => {
})
expect(service.updateDevice).toHaveBeenCalledWith(
mockDevice._id.toString(),
{ ...mockDeviceInput, enabled: true }
{ ...mockDeviceInput, enabled: true, user: mockUser }
)
expect(result).toBeDefined()
@@ -538,6 +539,7 @@ describe('GatewayService', () => {
beforeEach(() => {
mockDeviceModel.findById.mockResolvedValue(mockDevice)
mockSmsModel.findOne.mockResolvedValue(null)
mockSmsModel.create.mockResolvedValue(mockSms)
mockDeviceModel.findByIdAndUpdate.mockImplementation(() => ({
exec: jest.fn().mockResolvedValue(true),

View File

@@ -10,6 +10,8 @@ import {
SendBulkSMSInputDTO,
SendSMSInputDTO,
UpdateSMSStatusDTO,
HeartbeatInputDTO,
HeartbeatResponseDTO,
} from './gateway.dto'
import { User } from '../users/schemas/user.schema'
import { AuthService } from '../auth/auth.service'
@@ -44,13 +46,28 @@ export class GatewayService {
buildId: input.buildId,
})
const deviceData: any = { ...input, user }
// Set default name to "brand model" if not provided
if (!deviceData.name && input.brand && input.model) {
deviceData.name = `${input.brand} ${input.model}`
}
// Handle simInfo if provided
if (input.simInfo) {
deviceData.simInfo = {
...input.simInfo,
lastUpdated: input.simInfo.lastUpdated || new Date(),
}
}
if (device && device.appVersionCode <= 11) {
return await this.updateDevice(device._id.toString(), {
...input,
...deviceData,
enabled: true,
})
} else {
return await this.deviceModel.create({ ...input, user })
return await this.deviceModel.create(deviceData)
}
}
@@ -80,10 +97,20 @@ export class GatewayService {
if (input.enabled !== false) {
input.enabled = true;
}
const updateData: any = { ...input }
// Handle simInfo if provided
if (input.simInfo) {
updateData.simInfo = {
...input.simInfo,
lastUpdated: input.simInfo.lastUpdated || new Date(),
}
}
return await this.deviceModel.findByIdAndUpdate(
deviceId,
{ $set: input },
{ $set: updateData },
{ new: true },
)
}
@@ -104,6 +131,55 @@ export class GatewayService {
// return await this.deviceModel.findByIdAndDelete(deviceId)
}
private calculateDelayFromScheduledAt(scheduledAt?: string): number | undefined {
if (!scheduledAt) {
return undefined
}
try {
const scheduledDate = new Date(scheduledAt)
// Check if date is valid
if (isNaN(scheduledDate.getTime())) {
throw new HttpException(
{
success: false,
error: 'Invalid scheduledAt format. Must be a valid ISO 8601 date string.',
},
HttpStatus.BAD_REQUEST,
)
}
const now = Date.now()
const scheduledTime = scheduledDate.getTime()
const delayMs = scheduledTime - now
// Reject past dates
if (delayMs < 0) {
throw new HttpException(
{
success: false,
error: 'scheduledAt must be a future date',
},
HttpStatus.BAD_REQUEST,
)
}
return delayMs
} catch (error) {
if (error instanceof HttpException) {
throw error
}
throw new HttpException(
{
success: false,
error: 'Invalid scheduledAt format. Must be a valid ISO 8601 date string.',
},
HttpStatus.BAD_REQUEST,
)
}
}
async sendSMS(deviceId: string, smsData: SendSMSInputDTO): Promise<any> {
const device = await this.deviceModel.findById(deviceId)
@@ -140,6 +216,20 @@ export class GatewayService {
)
}
// Calculate delay from scheduledAt if provided
const delayMs = this.calculateDelayFromScheduledAt(smsData.scheduledAt)
// Validate that scheduling requires queue to be enabled
if (delayMs !== undefined && !this.smsQueueService.isQueueEnabled()) {
throw new HttpException(
{
success: false,
error: 'SMS scheduling requires queue to be enabled',
},
HttpStatus.BAD_REQUEST,
)
}
await this.billingService.canPerformAction(
device.user.toString(),
'send_sms',
@@ -181,12 +271,18 @@ export class GatewayService {
recipient,
requestedAt: new Date(),
status: 'pending',
...(smsData.simSubscriptionId !== undefined && {
simSubscriptionId: smsData.simSubscriptionId,
}),
})
const updatedSMSData = {
smsId: sms._id,
smsBatchId: smsBatch._id,
message,
recipients: [recipient],
...(smsData.simSubscriptionId !== undefined && {
simSubscriptionId: smsData.simSubscriptionId,
}),
// Legacy fields to be removed in the future
smsBody: message,
@@ -219,6 +315,7 @@ export class GatewayService {
deviceId,
fcmMessages,
smsBatch._id.toString(),
delayMs,
)
return {
@@ -339,6 +436,18 @@ export class GatewayService {
body.messages.map((m) => m.recipients).flat().length,
)
// Check if any message has scheduledAt and validate queue is enabled
const hasScheduledMessages = body.messages.some((m) => m.scheduledAt)
if (hasScheduledMessages && !this.smsQueueService.isQueueEnabled()) {
throw new HttpException(
{
success: false,
error: 'SMS scheduling requires queue to be enabled',
},
HttpStatus.BAD_REQUEST,
)
}
const { messageTemplate, messages } = body
const smsBatch = await this.smsBatchModel.create({
@@ -353,7 +462,8 @@ export class GatewayService {
status: 'pending',
})
const fcmMessages: Message[] = []
// Track FCM messages with their calculated delays for grouping
const fcmMessagesWithDelays: Array<{ message: Message; delayMs?: number }> = []
for (const smsData of messages) {
const message = smsData.message
@@ -367,6 +477,9 @@ export class GatewayService {
continue
}
// Calculate delay for this message's scheduledAt
const delayMs = this.calculateDelayFromScheduledAt(smsData.scheduledAt)
for (let recipient of recipients) {
recipient = recipient.replace(/\s+/g, "")
const sms = await this.smsModel.create({
@@ -374,15 +487,21 @@ export class GatewayService {
smsBatch: smsBatch._id,
message: message,
type: SMSType.SENT,
recipient,
recipient,
requestedAt: new Date(),
status: 'pending',
...(smsData.simSubscriptionId !== undefined && {
simSubscriptionId: smsData.simSubscriptionId,
}),
})
const updatedSMSData = {
smsId: sms._id,
smsBatchId: smsBatch._id,
message,
recipients: [recipient],
...(smsData.simSubscriptionId !== undefined && {
simSubscriptionId: smsData.simSubscriptionId,
}),
// Legacy fields to be removed in the future
smsBody: message,
@@ -399,19 +518,32 @@ recipient,
priority: 'high',
},
}
fcmMessages.push(fcmMessage)
fcmMessagesWithDelays.push({ message: fcmMessage, delayMs })
}
}
// Check if we should use the queue
if (this.smsQueueService.isQueueEnabled()) {
try {
// Add to queue
await this.smsQueueService.addSendSmsJob(
deviceId,
fcmMessages,
smsBatch._id.toString(),
)
// Group messages by delay (undefined delay means immediate, group together)
const messagesByDelay = new Map<number | undefined, Message[]>()
for (const { message, delayMs } of fcmMessagesWithDelays) {
const delayKey = delayMs !== undefined ? delayMs : undefined
if (!messagesByDelay.has(delayKey)) {
messagesByDelay.set(delayKey, [])
}
messagesByDelay.get(delayKey)!.push(message)
}
// Queue each group with its respective delay
for (const [delayMs, messages] of messagesByDelay.entries()) {
await this.smsQueueService.addSendSmsJob(
deviceId,
messages,
smsBatch._id.toString(),
delayMs,
)
}
return {
success: true,
@@ -426,7 +558,7 @@ recipient,
status: 'failed',
error: e.message,
successCount: 0,
failureCount: fcmMessages.length,
failureCount: fcmMessagesWithDelays.length,
},
})
@@ -447,6 +579,8 @@ recipient,
}
}
// For non-queue path, convert back to simple array
const fcmMessages = fcmMessagesWithDelays.map(({ message }) => message)
const fcmMessagesBatches = fcmMessages.map((m) => [m])
const fcmResponses: BatchResponse[] = []
@@ -545,6 +679,29 @@ recipient,
? new Date(dto.receivedAtInMillis)
: dto.receivedAt
// Deduplication: Check for existing SMS with same device, sender, message, and receivedAt (within ±5 seconds tolerance)
const toleranceMs = 5000 // 5 seconds
const toleranceStart = new Date(receivedAt.getTime() - toleranceMs)
const toleranceEnd = new Date(receivedAt.getTime() + toleranceMs)
const existingSMS = await this.smsModel.findOne({
device: device._id,
type: SMSType.RECEIVED,
sender: dto.sender,
message: dto.message,
receivedAt: {
$gte: toleranceStart,
$lte: toleranceEnd,
},
})
if (existingSMS) {
console.log(
`Duplicate SMS detected for device ${deviceId}, sender ${dto.sender}, returning existing record: ${existingSMS._id}`,
)
return existingSMS
}
const sms = await this.smsModel.create({
device: device._id,
message: dto.message,
@@ -894,4 +1051,110 @@ const updatedSms = await this.smsModel.findByIdAndUpdate(
messages: smsMessages
};
}
async heartbeat(
deviceId: string,
input: HeartbeatInputDTO,
): Promise<HeartbeatResponseDTO> {
const device = await this.deviceModel.findById(deviceId)
if (!device) {
throw new HttpException(
{
success: false,
error: 'Device not found',
},
HttpStatus.NOT_FOUND,
)
}
const now = new Date()
const updateData: any = {
lastHeartbeat: now,
}
let fcmTokenUpdated = false
// Update FCM token if provided and different
if (input.fcmToken && input.fcmToken !== device.fcmToken) {
updateData.fcmToken = input.fcmToken
fcmTokenUpdated = true
}
// Update receiveSMSEnabled if provided and different
if (
input.receiveSMSEnabled !== undefined &&
input.receiveSMSEnabled !== device.receiveSMSEnabled
) {
updateData.receiveSMSEnabled = input.receiveSMSEnabled
}
// Update batteryInfo if provided
if (input.batteryPercentage !== undefined || input.isCharging !== undefined) {
if (input.batteryPercentage !== undefined) {
updateData['batteryInfo.percentage'] = input.batteryPercentage
}
if (input.isCharging !== undefined) {
updateData['batteryInfo.isCharging'] = input.isCharging
}
updateData['batteryInfo.lastUpdated'] = now
}
// Update networkInfo if provided
if (input.networkType !== undefined) {
updateData['networkInfo.networkType'] = input.networkType
updateData['networkInfo.lastUpdated'] = now
}
// Update appVersionInfo if provided
if (input.appVersionName !== undefined || input.appVersionCode !== undefined) {
if (input.appVersionName !== undefined) {
updateData['appVersionInfo.versionName'] = input.appVersionName
}
if (input.appVersionCode !== undefined) {
updateData['appVersionInfo.versionCode'] = input.appVersionCode
}
updateData['appVersionInfo.lastUpdated'] = now
}
// Update deviceUptimeInfo if provided
if (input.deviceUptimeMillis !== undefined) {
updateData['deviceUptimeInfo.uptimeMillis'] = input.deviceUptimeMillis
updateData['deviceUptimeInfo.lastUpdated'] = now
}
// Update systemInfo if timezone or locale provided
if (input.timezone !== undefined || input.locale !== undefined) {
if (input.timezone !== undefined) {
updateData['systemInfo.timezone'] = input.timezone
}
if (input.locale !== undefined) {
updateData['systemInfo.locale'] = input.locale
}
updateData['systemInfo.lastUpdated'] = now
}
// Update simInfo if provided
if (input.simInfo !== undefined) {
updateData.simInfo = {
...input.simInfo,
lastUpdated: input.simInfo.lastUpdated || now,
}
}
// Update device with all changes
await this.deviceModel.findByIdAndUpdate(deviceId, {
$set: updateData,
})
// Fetch updated device to get current name
const updatedDevice = await this.deviceModel.findById(deviceId)
return {
success: true,
fcmTokenUpdated,
lastHeartbeat: now,
name: updatedDevice?.name,
}
}
}

View File

@@ -32,6 +32,7 @@ export class SmsQueueService {
deviceId: string,
fcmMessages: Message[],
smsBatchId: string,
delayMs?: number,
) {
this.logger.debug(`Adding send-sms job for batch ${smsBatchId}`)
@@ -41,8 +42,13 @@ export class SmsQueueService {
batches.push(fcmMessages.slice(i, i + this.maxSmsBatchSize))
}
// If delayMs is provided, use it for all batches (scheduled send)
// Otherwise, use the existing delay multiplier logic
const useScheduledDelay = delayMs !== undefined && delayMs >= 0
let delayMultiplier = 1;
for (const batch of batches) {
const delay = useScheduledDelay ? delayMs : 1000 * delayMultiplier++
await this.smsQueue.add(
'send-sms',
{
@@ -53,7 +59,7 @@ export class SmsQueueService {
{
priority: 1, // TODO: Make this dynamic based on users subscription plan
attempts: 1,
delay: 1000 * delayMultiplier++,
delay: delay,
backoff: {
type: 'exponential',
delay: 5000, // 5 seconds

View File

@@ -26,6 +26,9 @@ export class Device {
@Prop({ type: String })
model: string
@Prop({ type: String, required: false })
name?: string
@Prop({ type: String })
serial: string
@@ -49,6 +52,142 @@ export class Device {
@Prop({ type: Number, default: 0 })
receivedSMSCount: number
@Prop({ type: Boolean, default: true })
heartbeatEnabled: boolean
@Prop({ type: Number, default: 30 })
heartbeatIntervalMinutes: number
@Prop({ type: Boolean, default: false })
receiveSMSEnabled: boolean
@Prop({ type: Date })
lastHeartbeat: Date
@Prop({
type: {
percentage: Number,
isCharging: Boolean,
lastUpdated: Date,
},
})
batteryInfo: {
percentage?: number
isCharging?: boolean
lastUpdated?: Date
}
@Prop({
type: {
networkType: String,
lastUpdated: Date,
},
})
networkInfo: {
networkType?: 'wifi' | 'cellular' | 'none'
lastUpdated?: Date
}
@Prop({
type: {
versionName: String,
versionCode: Number,
lastUpdated: Date,
},
})
appVersionInfo: {
versionName?: string
versionCode?: number
lastUpdated?: Date
}
@Prop({
type: {
uptimeMillis: Number,
lastUpdated: Date,
},
})
deviceUptimeInfo: {
uptimeMillis?: number
lastUpdated?: Date
}
@Prop({
type: {
freeBytes: Number,
totalBytes: Number,
maxBytes: Number,
lastUpdated: Date,
},
})
memoryInfo: {
freeBytes?: number
totalBytes?: number
maxBytes?: number
lastUpdated?: Date
}
@Prop({
type: {
availableBytes: Number,
totalBytes: Number,
lastUpdated: Date,
},
})
storageInfo: {
availableBytes?: number
totalBytes?: number
lastUpdated?: Date
}
@Prop({
type: {
timezone: String,
locale: String,
lastUpdated: Date,
},
})
systemInfo: {
timezone?: string
locale?: string
lastUpdated?: Date
}
@Prop({
type: {
lastUpdated: Date,
sims: [
{
subscriptionId: Number,
iccId: String,
cardId: Number,
carrierName: String,
displayName: String,
simSlotIndex: Number,
mcc: String,
mnc: String,
countryIso: String,
subscriptionType: String,
},
],
},
})
simInfo: {
lastUpdated?: Date
sims?: Array<{
subscriptionId: number
iccId?: string
cardId?: number
carrierName?: string
displayName?: string
simSlotIndex?: number
mcc?: string
mnc?: string
countryIso?: string
subscriptionType?: string
}>
}
}
export const DeviceSchema = SchemaFactory.createForClass(Device)

View File

@@ -62,6 +62,9 @@ export class SMS {
@Prop({ type: String, default: 'pending' })
status: 'pending' | 'sent' | 'delivered' | 'failed' | 'unknown' | 'received'
@Prop({ type: Number, required: false })
simSubscriptionId?: number
// misc metadata for debugging
@Prop({ type: Object })
metadata: Record<string, any>

View File

@@ -0,0 +1,97 @@
import { Injectable, Logger } from '@nestjs/common'
import { Cron, CronExpression } from '@nestjs/schedule'
import { InjectModel } from '@nestjs/mongoose'
import { Model } from 'mongoose'
import { Device, DeviceDocument } from '../schemas/device.schema'
import * as firebaseAdmin from 'firebase-admin'
import { Message } from 'firebase-admin/messaging'
@Injectable()
export class HeartbeatCheckTask {
private readonly logger = new Logger(HeartbeatCheckTask.name)
constructor(
@InjectModel(Device.name) private deviceModel: Model<DeviceDocument>,
) {}
/**
* Cron job that runs every 5 minutes to check for devices with stale heartbeats
* (>30 minutes) and send FCM push notifications to trigger heartbeat requests.
*/
@Cron(CronExpression.EVERY_5_MINUTES)
async checkAndTriggerStaleHeartbeats() {
this.logger.log('Running cron job to check for stale heartbeats')
const thirtyMinutesAgo = new Date(Date.now() - 30 * 60 * 1000)
try {
// Find devices with stale heartbeats
const devices = await this.deviceModel.find({
heartbeatEnabled: true,
enabled: true,
$or: [
{ lastHeartbeat: null },
{ lastHeartbeat: { $lt: thirtyMinutesAgo } },
],
fcmToken: { $exists: true, $ne: null },
})
if (devices.length === 0) {
this.logger.log('No devices with stale heartbeats found')
return
}
this.logger.log(
`Found ${devices.length} device(s) with stale heartbeats, sending FCM notifications`,
)
// Send FCM messages to trigger heartbeats
const fcmMessages: Message[] = []
const deviceIds: string[] = []
for (const device of devices) {
if (!device.fcmToken) {
continue
}
const fcmMessage: Message = {
data: {
type: 'heartbeat_check',
},
token: device.fcmToken,
android: {
priority: 'high',
},
}
fcmMessages.push(fcmMessage)
deviceIds.push(device._id.toString())
}
if (fcmMessages.length === 0) {
this.logger.warn('No valid FCM tokens found for devices with stale heartbeats')
return
}
// Send FCM messages
const response = await firebaseAdmin.messaging().sendEach(fcmMessages)
this.logger.log(
`Sent ${response.successCount} heartbeat check FCM notification(s), ${response.failureCount} failed`,
)
// Log failures for debugging
if (response.failureCount > 0) {
response.responses.forEach((resp, index) => {
if (!resp.success) {
this.logger.error(
`Failed to send heartbeat check to device ${deviceIds[index]}: ${resp.error?.message || 'Unknown error'}`,
)
}
})
}
} catch (error) {
this.logger.error('Error checking and triggering stale heartbeats', error)
}
}
}

View File

@@ -29,6 +29,7 @@ import { Spinner } from '@/components/ui/spinner'
import httpBrowserClient from '@/lib/httpBrowserClient'
import { formatError } from '@/lib/utils/errorHandler'
import { RateLimitError } from '@/components/shared/rate-limit-error'
import { formatDeviceName } from '@/lib/utils'
const DEFAULT_MAX_FILE_SIZE = 1024 * 1024 // 1 MB
const DEFAULT_MAX_ROWS = 50
@@ -110,6 +111,9 @@ export default function BulkSMSSend() {
return row[key.trim()] || ''
}),
recipients: [row[selectedColumn]],
...(selectedSimSubscriptionId !== undefined && {
simSubscriptionId: selectedSimSubscriptionId,
}),
}))
const payload = {
messageTemplate,
@@ -122,6 +126,9 @@ export default function BulkSMSSend() {
}
const [selectedDeviceId, setSelectedDeviceId] = useState<string | null>(null)
const [selectedSimSubscriptionId, setSelectedSimSubscriptionId] = useState<
number | undefined
>(undefined)
const { data: devices } = useQuery({
queryKey: ['devices'],
@@ -141,6 +148,17 @@ export default function BulkSMSSend() {
mutationFn: handleSendBulkSMS,
})
const selectedDevice = devices?.data?.find(
(device) => device._id === selectedDeviceId
)
const availableSims =
selectedDevice?.simInfo?.sims &&
Array.isArray(selectedDevice.simInfo.sims) &&
selectedDevice.simInfo.sims.length > 0
? selectedDevice.simInfo.sims
: []
const isStep2Disabled = csvData.length === 0
const isStep3Disabled = isStep2Disabled || !selectedColumn || !messageTemplate
@@ -205,7 +223,10 @@ export default function BulkSMSSend() {
<div>
<Label htmlFor='device-select'>Select Device</Label>
<Select
onValueChange={setSelectedDeviceId}
onValueChange={(value) => {
setSelectedDeviceId(value)
setSelectedSimSubscriptionId(undefined)
}}
value={selectedDeviceId}
>
<SelectTrigger id='device-select'>
@@ -218,7 +239,7 @@ export default function BulkSMSSend() {
value={device._id}
disabled={!device.enabled}
>
{device.brand} - {device.model}{' '}
{formatDeviceName(device)}{' '}
{device.enabled ? '' : ' (disabled)'}
</SelectItem>
))}
@@ -226,6 +247,34 @@ export default function BulkSMSSend() {
</Select>
</div>
{availableSims.length > 1 && (
<div>
<Label htmlFor='sim-select'>Select SIM (optional)</Label>
<Select
onValueChange={(value) =>
setSelectedSimSubscriptionId(
value ? Number(value) : undefined
)
}
value={selectedSimSubscriptionId?.toString()}
>
<SelectTrigger id='sim-select'>
<SelectValue placeholder='Select SIM (optional)' />
</SelectTrigger>
<SelectContent>
{availableSims.map((sim) => (
<SelectItem
key={sim.subscriptionId}
value={sim.subscriptionId.toString()}
>
{sim.displayName || 'SIM'} ({sim.subscriptionId})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div className='space-y-4'>
<div>
<Label htmlFor='recipient-column'>

View File

@@ -10,6 +10,7 @@ import httpBrowserClient from '@/lib/httpBrowserClient'
import { ApiEndpoints } from '@/config/api'
import { useQuery } from '@tanstack/react-query'
import { Skeleton } from '@/components/ui/skeleton'
import { formatDeviceName } from '@/lib/utils'
export default function DeviceList() {
const { toast } = useToast()
@@ -83,7 +84,7 @@ export default function DeviceList() {
<div className='flex-1'>
<div className='flex items-center justify-between'>
<h3 className='font-semibold text-sm'>
{device.brand} {device.model}
{formatDeviceName(device)}
</h3>
<Badge
variant={

View File

@@ -46,6 +46,7 @@ import { Spinner } from '@/components/ui/spinner'
import { toast } from '@/hooks/use-toast'
import { formatError } from '@/lib/utils/errorHandler'
import { formatRateLimitMessageForToast } from '@/components/shared/rate-limit-error'
import { formatDeviceName } from '@/lib/utils'
// Helper function to format timestamps
@@ -192,7 +193,7 @@ function ReplyDialog({ sms, onClose, open, onOpenChange }: { sms: any; onClose?:
<SelectContent>
{devices?.data?.map((device: any) => (
<SelectItem key={device._id} value={device._id}>
{device.brand} {device.model}{' '}
{formatDeviceName(device)}{' '}
{device.enabled ? '' : '(disabled)'}
</SelectItem>
))}
@@ -357,7 +358,7 @@ function FollowUpDialog({ message, onClose, open, onOpenChange }: { message: any
<SelectContent>
{devices?.data?.map((device: any) => (
<SelectItem key={device._id} value={device._id}>
{device.brand} {device.model}{' '}
{formatDeviceName(device)}{' '}
{device.enabled ? '' : '(disabled)'}
</SelectItem>
))}
@@ -788,7 +789,7 @@ export default function MessageHistory() {
<SelectItem key={device._id} value={device._id}>
<div className='flex items-center gap-2'>
<span className='font-medium'>
{device.brand} {device.model}
{formatDeviceName(device)}
</span>
{!device.enabled && (
<Badge

View File

@@ -1,5 +1,6 @@
'use client'
import { useEffect } from 'react'
import {
Card,
CardContent,
@@ -17,7 +18,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { useForm, useFieldArray, Controller } from 'react-hook-form'
import { useForm, useFieldArray, Controller, useWatch } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { sendSmsSchema } from '@/lib/schemas'
import type { SendSmsFormData } from '@/lib/schemas'
@@ -29,6 +30,7 @@ import { useMutation, useQuery } from '@tanstack/react-query'
import { Spinner } from '@/components/ui/spinner'
import { formatError } from '@/lib/utils/errorHandler'
import { RateLimitError } from '@/components/shared/rate-limit-error'
import { formatDeviceName } from '@/lib/utils'
export default function SendSms() {
const { data: devices, isLoading: isLoadingDevices } = useQuery({
@@ -55,6 +57,7 @@ export default function SendSms() {
register,
control,
handleSubmit,
setValue,
formState: { errors },
} = useForm<SendSmsFormData>({
resolver: zodResolver(sendSmsSchema),
@@ -72,6 +75,28 @@ export default function SendSms() {
name: 'recipients',
})
const selectedDeviceId = useWatch({
control,
name: 'deviceId',
})
const selectedDevice = devices?.data?.find(
(device) => device._id === selectedDeviceId
)
const availableSims =
selectedDevice?.simInfo?.sims &&
Array.isArray(selectedDevice.simInfo.sims) &&
selectedDevice.simInfo.sims.length > 0
? selectedDevice.simInfo.sims
: []
useEffect(() => {
if (selectedDeviceId) {
setValue('simSubscriptionId', undefined)
}
}, [selectedDeviceId, setValue])
return (
<div>
<Card>
@@ -104,7 +129,7 @@ export default function SendSms() {
value={device._id}
disabled={!device.enabled}
>
{device.brand} {device.model}{' '}
{formatDeviceName(device)}{' '}
{device.enabled ? '' : '(disabled)'}
</SelectItem>
))}
@@ -119,6 +144,42 @@ export default function SendSms() {
)}
</div>
{availableSims.length > 1 && (
<div>
<Controller
name='simSubscriptionId'
control={control}
render={({ field }) => (
<Select
onValueChange={(value) =>
field.onChange(Number(value))
}
value={field.value?.toString()}
>
<SelectTrigger>
<SelectValue placeholder='Select SIM (optional)' />
</SelectTrigger>
<SelectContent>
{availableSims.map((sim) => (
<SelectItem
key={sim.subscriptionId}
value={sim.subscriptionId.toString()}
>
{sim.displayName || 'SIM'} ({sim.subscriptionId})
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
{errors.simSubscriptionId && (
<p className='text-sm text-destructive mt-1'>
{errors.simSubscriptionId.message}
</p>
)}
</div>
)}
<div className='space-y-2'>
{fields.map((field, index) => (
<div key={field.id}>

View File

@@ -39,6 +39,7 @@ import {
} from '@/components/ui/popover'
import { Calendar } from 'lucide-react'
import { truncate } from 'fs'
import { formatDeviceName } from '@/lib/utils'
const WebhooksHistory = () => {
const {
@@ -179,7 +180,7 @@ const WebhooksHistory = () => {
<SelectItem key={device._id} value={device._id}>
<div className="flex items-center gap-2">
<span className="font-medium">
{device.brand} {device.model}
{formatDeviceName(device)}
</span>
{!device.enabled && (
<Badge

View File

@@ -21,6 +21,7 @@ export const sendSmsSchema = z.object({
.max(1600, {
message: 'Message cannot exceed 1600 characters',
}),
simSubscriptionId: z.number().optional(),
})
export type SendSmsFormData = z.infer<typeof sendSmsSchema>

View File

@@ -4,3 +4,22 @@ import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
/**
* Formats device display name with optional custom name in brackets
* @param device Device object with brand, model, and optional name
* @returns Formatted string like "Brand Model" or "Brand Model (Custom Name)"
*/
export function formatDeviceName(device: {
brand: string
model: string
name?: string | null
}): string {
const baseName = `${device.brand} ${device.model}`
if (device.name && device.name.trim() !== '' && device.name !== baseName) {
return `${baseName} (${device.name})`
}
return baseName
}