diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index a6033e3..4d50cc5 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -44,6 +44,15 @@
+
+
+
+
+
+
+
parts = smsManager.divideMessage(message);
- if (parts.size() > 1) {
- smsManager.sendMultipartTextMessage(phoneNo, null, parts, null, null);
- } else {
- smsManager.sendTextMessage(phoneNo, null, message, null, null);
+ private static final String TAG = "SMSHelper";
+
+ /**
+ * Sends an SMS message and returns whether the operation was successful
+ *
+ * @param phoneNo The recipient's phone number
+ * @param message The SMS message to send
+ * @param smsId The unique ID for this SMS
+ * @param smsBatchId The batch ID for this SMS
+ * @param context The application context
+ * @return boolean True if sending was initiated, false if permissions aren't granted
+ */
+ public static boolean sendSMS(String phoneNo, String message, String smsId, String smsBatchId, Context context) {
+ // Check if we have permission to send SMS
+ if (!TextBeeUtils.isPermissionGranted(context, Manifest.permission.SEND_SMS)) {
+ Log.e(TAG, "SMS permission not granted. Unable to send SMS.");
+
+ // Report failure to API
+ reportPermissionError(context, smsId, smsBatchId);
+
+ return false;
}
+
+ try {
+ SmsManager smsManager = SmsManager.getDefault();
+ // Create pending intents for status tracking
+ PendingIntent sentIntent = createSentPendingIntent(context, smsId, smsBatchId);
+ PendingIntent deliveredIntent = createDeliveredPendingIntent(context, smsId, smsBatchId);
+
+ // For SMS with more than 160 chars
+ ArrayList parts = smsManager.divideMessage(message);
+ if (parts.size() > 1) {
+ ArrayList sentIntents = new ArrayList<>();
+ ArrayList deliveredIntents = new ArrayList<>();
+
+ for (int i = 0; i < parts.size(); i++) {
+ sentIntents.add(sentIntent);
+ deliveredIntents.add(deliveredIntent);
+ }
+
+ smsManager.sendMultipartTextMessage(phoneNo, null, parts, sentIntents, deliveredIntents);
+ } else {
+ smsManager.sendTextMessage(phoneNo, null, message, sentIntent, deliveredIntent);
+ }
+
+ return true;
+ } catch (Exception e) {
+ Log.e(TAG, "Exception when sending SMS: " + e.getMessage());
+
+ // Report exception to API
+ reportSendingError(context, smsId, smsBatchId, e.getMessage());
+
+ return false;
+ }
}
+
+ /**
+ * Sends an SMS message from a specific SIM slot and returns whether the operation was successful
+ *
+ * @param phoneNo The recipient's phone number
+ * @param message The SMS message to send
+ * @param simSubscriptionId The specific SIM subscription ID to use
+ * @param smsId The unique ID for this SMS
+ * @param smsBatchId The batch ID for this SMS
+ * @param context The application context
+ * @return boolean True if sending was initiated, false if permissions aren't granted
+ */
+ public static boolean sendSMSFromSpecificSim(String phoneNo, String message, int simSubscriptionId,
+ String smsId, String smsBatchId, Context context) {
+ // Check for required permissions
+ if (!TextBeeUtils.isPermissionGranted(context, Manifest.permission.SEND_SMS) ||
+ !TextBeeUtils.isPermissionGranted(context, Manifest.permission.READ_PHONE_STATE)) {
+ Log.e(TAG, "SMS or Phone State permission not granted. Unable to send SMS from specific SIM.");
+
+ // Report failure to API
+ reportPermissionError(context, smsId, smsBatchId);
+
+ return false;
+ }
+
+ try {
+ // Get the SmsManager for the specific SIM
+ SmsManager smsManager;
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1) {
+ smsManager = SmsManager.getSmsManagerForSubscriptionId(simSubscriptionId);
+ } else {
+ // Fallback to default SmsManager for older Android versions
+ smsManager = SmsManager.getDefault();
+ Log.w(TAG, "Using default SIM as specific SIM selection not supported on this Android version");
+ }
+
+ // Create pending intents for status tracking
+ PendingIntent sentIntent = createSentPendingIntent(context, smsId, smsBatchId);
+ PendingIntent deliveredIntent = createDeliveredPendingIntent(context, smsId, smsBatchId);
- public static void sendSMSFromSpecificSim(String phoneNo, String message, int simSlot) {
- SmsManager smsManager = SmsManager.getSmsManagerForSubscriptionId(simSlot);
- smsManager.sendMultipartTextMessage(phoneNo, null, smsManager.divideMessage(message), null, null);
+ // For SMS with more than 160 chars
+ ArrayList parts = smsManager.divideMessage(message);
+ if (parts.size() > 1) {
+ ArrayList sentIntents = new ArrayList<>();
+ ArrayList deliveredIntents = new ArrayList<>();
+
+ for (int i = 0; i < parts.size(); i++) {
+ sentIntents.add(sentIntent);
+ deliveredIntents.add(deliveredIntent);
+ }
+
+ smsManager.sendMultipartTextMessage(phoneNo, null, parts, sentIntents, deliveredIntents);
+ } else {
+ smsManager.sendTextMessage(phoneNo, null, message, sentIntent, deliveredIntent);
+ }
+
+ return true;
+ } catch (Exception e) {
+ Log.e(TAG, "Exception when sending SMS from specific SIM: " + e.getMessage());
+
+ // Report exception to API
+ reportSendingError(context, smsId, smsBatchId, e.getMessage());
+
+ return false;
+ }
+ }
+
+ private static void reportPermissionError(Context context, String smsId, String smsBatchId) {
+ SMSDTO smsDTO = new SMSDTO();
+ smsDTO.setSmsId(smsId);
+ smsDTO.setSmsBatchId(smsBatchId);
+ smsDTO.setStatus("FAILED");
+ smsDTO.setFailedAtInMillis(System.currentTimeMillis());
+ smsDTO.setErrorCode("PERMISSION_DENIED");
+ smsDTO.setErrorMessage("SMS permission not granted");
+
+ updateSMSStatus(context, smsDTO);
+ }
+
+ private static void reportSendingError(Context context, String smsId, String smsBatchId, String errorMessage) {
+ SMSDTO smsDTO = new SMSDTO();
+ smsDTO.setSmsId(smsId);
+ smsDTO.setSmsBatchId(smsBatchId);
+ smsDTO.setStatus("FAILED");
+ smsDTO.setFailedAtInMillis(System.currentTimeMillis());
+ smsDTO.setErrorCode("SENDING_EXCEPTION");
+ smsDTO.setErrorMessage(errorMessage);
+
+ updateSMSStatus(context, smsDTO);
+ }
+
+ private static void updateSMSStatus(Context context, SMSDTO smsDTO) {
+ String deviceId = SharedPreferenceHelper.getSharedPreferenceString(context, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, "");
+ String apiKey = SharedPreferenceHelper.getSharedPreferenceString(context, AppConstants.SHARED_PREFS_API_KEY_KEY, "");
+
+ if (deviceId.isEmpty() || apiKey.isEmpty()) {
+ Log.e(TAG, "Device ID or API key not found");
+ return;
+ }
+
+ GatewayApiService apiService = ApiManager.getApiService();
+ Call call = apiService.updateSMSStatus(deviceId, apiKey, smsDTO);
+
+ call.enqueue(new Callback() {
+ @Override
+ public void onResponse(Call call, Response response) {
+ if (response.isSuccessful()) {
+ Log.d(TAG, "SMS status updated successfully - ID: " + smsDTO.getSmsId() + ", Status: " + smsDTO.getStatus());
+ } else {
+ Log.e(TAG, "Failed to update SMS status. Response code: " + response.code());
+ }
+ }
+
+ @Override
+ public void onFailure(Call call, Throwable t) {
+ Log.e(TAG, "API call failed: " + t.getMessage());
+ }
+ });
+ }
+
+ private static PendingIntent createSentPendingIntent(Context context, String smsId, String smsBatchId) {
+ // Create explicit intent (specify the component)
+ Intent intent = new Intent(context, SMSStatusReceiver.class);
+ intent.setAction(SMSStatusReceiver.SMS_SENT);
+ intent.putExtra("sms_id", smsId);
+ intent.putExtra("sms_batch_id", smsBatchId);
+
+ int flags = PendingIntent.FLAG_UPDATE_CURRENT;
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ flags |= PendingIntent.FLAG_MUTABLE;
+ }
+
+ // Use a unique request code to avoid PendingIntent collisions
+ int requestCode = (smsId + "_sent").hashCode();
+ return PendingIntent.getBroadcast(context, requestCode, intent, flags);
+ }
+
+ private static PendingIntent createDeliveredPendingIntent(Context context, String smsId, String smsBatchId) {
+ // Create explicit intent (specify the component)
+ Intent intent = new Intent(context, SMSStatusReceiver.class);
+ intent.setAction(SMSStatusReceiver.SMS_DELIVERED);
+ intent.putExtra("sms_id", smsId);
+ intent.putExtra("sms_batch_id", smsBatchId);
+
+ int flags = PendingIntent.FLAG_UPDATE_CURRENT;
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ flags |= PendingIntent.FLAG_MUTABLE;
+ }
+
+ // Use a unique request code to avoid PendingIntent collisions
+ int requestCode = (smsId + "_delivered").hashCode();
+ return PendingIntent.getBroadcast(context, requestCode, intent, flags);
}
}
diff --git a/android/app/src/main/java/com/vernu/sms/models/SMSPayload.java b/android/app/src/main/java/com/vernu/sms/models/SMSPayload.java
index db98ba1..b73bcd6 100644
--- a/android/app/src/main/java/com/vernu/sms/models/SMSPayload.java
+++ b/android/app/src/main/java/com/vernu/sms/models/SMSPayload.java
@@ -4,6 +4,8 @@ public class SMSPayload {
private String[] recipients;
private String message;
+ private String smsId;
+ private String smsBatchId;
// Legacy fields that are no longer used
private String[] receivers;
@@ -27,4 +29,20 @@ public class SMSPayload {
public void setMessage(String message) {
this.message = message;
}
+
+ public String getSmsId() {
+ return smsId;
+ }
+
+ public void setSmsId(String smsId) {
+ this.smsId = smsId;
+ }
+
+ public String getSmsBatchId() {
+ return smsBatchId;
+ }
+
+ public void setSmsBatchId(String smsBatchId) {
+ this.smsBatchId = smsBatchId;
+ }
}
diff --git a/android/app/src/main/java/com/vernu/sms/receivers/BootCompletedReceiver.java b/android/app/src/main/java/com/vernu/sms/receivers/BootCompletedReceiver.java
index 6996b7e..2dad5f4 100644
--- a/android/app/src/main/java/com/vernu/sms/receivers/BootCompletedReceiver.java
+++ b/android/app/src/main/java/com/vernu/sms/receivers/BootCompletedReceiver.java
@@ -5,17 +5,96 @@ import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.os.Build;
+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.TextBeeUtils;
+import com.vernu.sms.dtos.RegisterDeviceInputDTO;
+import com.vernu.sms.dtos.RegisterDeviceResponseDTO;
+import com.vernu.sms.helpers.SharedPreferenceHelper;
import com.vernu.sms.services.StickyNotificationService;
+import retrofit2.Call;
+import retrofit2.Callback;
+import retrofit2.Response;
+
public class BootCompletedReceiver extends BroadcastReceiver {
+ private static final String TAG = "BootCompletedReceiver";
+
@Override
public void onReceive(Context context, Intent intent) {
if (Intent.ACTION_BOOT_COMPLETED.equals(intent.getAction())) {
-// if(TextBeeUtils.isPermissionGranted(context, Manifest.permission.RECEIVE_SMS)){
-// TextBeeUtils.startStickyNotificationService(context);
-// }
+ boolean stickyNotificationEnabled = SharedPreferenceHelper.getSharedPreferenceBoolean(
+ context,
+ AppConstants.SHARED_PREFS_STICKY_NOTIFICATION_ENABLED_KEY,
+ false
+ );
+
+ if(stickyNotificationEnabled && TextBeeUtils.isPermissionGranted(context, Manifest.permission.RECEIVE_SMS)){
+ Log.i(TAG, "Device booted, starting sticky notification service");
+ TextBeeUtils.startStickyNotificationService(context);
+ }
+
+ // Report device info to server if device is registered
+ String deviceId = SharedPreferenceHelper.getSharedPreferenceString(
+ context,
+ AppConstants.SHARED_PREFS_DEVICE_ID_KEY,
+ ""
+ );
+
+ String apiKey = SharedPreferenceHelper.getSharedPreferenceString(
+ context,
+ AppConstants.SHARED_PREFS_API_KEY_KEY,
+ ""
+ );
+
+ // Only proceed if both device ID and API key are available
+ if (!deviceId.isEmpty() && !apiKey.isEmpty()) {
+ updateDeviceInfo(context, deviceId, apiKey);
+ }
}
}
+
+ /**
+ * Updates device information on the server after boot
+ */
+ private void updateDeviceInfo(Context context, String deviceId, String apiKey) {
+ FirebaseMessaging.getInstance().getToken()
+ .addOnCompleteListener(task -> {
+ if (!task.isSuccessful()) {
+ Log.e(TAG, "Failed to obtain FCM token after boot");
+ return;
+ }
+
+ String token = task.getResult();
+
+ RegisterDeviceInputDTO updateInput = new RegisterDeviceInputDTO();
+ updateInput.setFcmToken(token);
+ updateInput.setAppVersionCode(BuildConfig.VERSION_CODE);
+ updateInput.setAppVersionName(BuildConfig.VERSION_NAME);
+
+ Log.d(TAG, "Updating device info after boot - deviceId: " + deviceId);
+
+ ApiManager.getApiService()
+ .updateDevice(deviceId, apiKey, updateInput)
+ .enqueue(new Callback() {
+ @Override
+ public void onResponse(Call call, Response response) {
+ if (response.isSuccessful()) {
+ Log.d(TAG, "Device info updated successfully after boot");
+ } else {
+ Log.e(TAG, "Failed to update device info after boot. Response code: " + response.code());
+ }
+ }
+
+ @Override
+ public void onFailure(Call call, Throwable t) {
+ Log.e(TAG, "Error updating device info after boot: " + t.getMessage());
+ }
+ });
+ });
+ }
}
diff --git a/android/app/src/main/java/com/vernu/sms/receivers/SMSStatusReceiver.java b/android/app/src/main/java/com/vernu/sms/receivers/SMSStatusReceiver.java
new file mode 100644
index 0000000..15e9c45
--- /dev/null
+++ b/android/app/src/main/java/com/vernu/sms/receivers/SMSStatusReceiver.java
@@ -0,0 +1,180 @@
+package com.vernu.sms.receivers;
+
+import android.app.Activity;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.telephony.SmsManager;
+import android.util.Log;
+
+import com.vernu.sms.ApiManager;
+import com.vernu.sms.AppConstants;
+import com.vernu.sms.dtos.SMSDTO;
+import com.vernu.sms.dtos.SMSForwardResponseDTO;
+import com.vernu.sms.helpers.SharedPreferenceHelper;
+import com.vernu.sms.services.GatewayApiService;
+
+import retrofit2.Call;
+import retrofit2.Callback;
+import retrofit2.Response;
+
+public class SMSStatusReceiver extends BroadcastReceiver {
+ private static final String TAG = "SMSStatusReceiver";
+
+ public static final String SMS_SENT = "SMS_SENT";
+ public static final String SMS_DELIVERED = "SMS_DELIVERED";
+
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ String smsId = intent.getStringExtra("sms_id");
+ String smsBatchId = intent.getStringExtra("sms_batch_id");
+ String action = intent.getAction();
+
+ SMSDTO smsDTO = new SMSDTO();
+ smsDTO.setSmsId(smsId);
+ smsDTO.setSmsBatchId(smsBatchId);
+
+ if (SMS_SENT.equals(action)) {
+ handleSentStatus(context, getResultCode(), smsDTO);
+ } else if (SMS_DELIVERED.equals(action)) {
+ handleDeliveredStatus(context, getResultCode(), smsDTO);
+ }
+ }
+
+ private void handleSentStatus(Context context, int resultCode, SMSDTO smsDTO) {
+ long timestamp = System.currentTimeMillis();
+ String errorMessage = "";
+
+ switch (resultCode) {
+ case Activity.RESULT_OK:
+ smsDTO.setStatus("SENT");
+ smsDTO.setSentAtInMillis(timestamp);
+ Log.d(TAG, "SMS sent successfully - ID: " + smsDTO.getSmsId());
+ break;
+ case SmsManager.RESULT_ERROR_GENERIC_FAILURE:
+ errorMessage = "Generic failure";
+ 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;
+ case SmsManager.RESULT_ERROR_RADIO_OFF:
+ errorMessage = "Radio off";
+ 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;
+ case SmsManager.RESULT_ERROR_NULL_PDU:
+ errorMessage = "Null PDU";
+ 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;
+ case SmsManager.RESULT_ERROR_NO_SERVICE:
+ errorMessage = "No service";
+ 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;
+ case SmsManager.RESULT_ERROR_LIMIT_EXCEEDED:
+ errorMessage = "Sending limit exceeded";
+ 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;
+ case SmsManager.RESULT_ERROR_SHORT_CODE_NOT_ALLOWED:
+ errorMessage = "Short code not allowed";
+ 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;
+ case SmsManager.RESULT_ERROR_SHORT_CODE_NEVER_ALLOWED:
+ errorMessage = "Short code never allowed";
+ 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");
+ smsDTO.setFailedAtInMillis(timestamp);
+ smsDTO.setErrorCode(String.valueOf(resultCode));
+ smsDTO.setErrorMessage(errorMessage);
+ Log.e(TAG, "SMS failed to send - ID: " + smsDTO.getSmsId() + ", Unknown error code: " + resultCode);
+ break;
+ }
+
+ updateSMSStatus(context, smsDTO);
+ }
+
+ private void handleDeliveredStatus(Context context, int resultCode, SMSDTO smsDTO) {
+ long timestamp = System.currentTimeMillis();
+ String errorMessage = "";
+
+ switch (resultCode) {
+ case Activity.RESULT_OK:
+ smsDTO.setStatus("DELIVERED");
+ smsDTO.setDeliveredAtInMillis(timestamp);
+ Log.d(TAG, "SMS delivered successfully - ID: " + smsDTO.getSmsId());
+ break;
+ case Activity.RESULT_CANCELED:
+ errorMessage = "Delivery canceled";
+ smsDTO.setStatus("DELIVERY_FAILED");
+ smsDTO.setErrorCode(String.valueOf(resultCode));
+ smsDTO.setErrorMessage(errorMessage);
+ Log.e(TAG, "SMS delivery failed - ID: " + smsDTO.getSmsId() + ", Error code: " + resultCode + ", Error: " + errorMessage);
+ break;
+ default:
+ errorMessage = "Unknown delivery error";
+ smsDTO.setStatus("DELIVERY_FAILED");
+ smsDTO.setErrorCode(String.valueOf(resultCode));
+ smsDTO.setErrorMessage(errorMessage);
+ Log.e(TAG, "SMS delivery failed - ID: " + smsDTO.getSmsId() + ", Unknown error code: " + resultCode);
+ break;
+ }
+
+ updateSMSStatus(context, smsDTO);
+ }
+
+ private void updateSMSStatus(Context context, SMSDTO smsDTO) {
+ String deviceId = SharedPreferenceHelper.getSharedPreferenceString(context, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, "");
+ String apiKey = SharedPreferenceHelper.getSharedPreferenceString(context, AppConstants.SHARED_PREFS_API_KEY_KEY, "");
+
+ if (deviceId.isEmpty() || apiKey.isEmpty()) {
+ Log.e(TAG, "Device ID or API key not found");
+ return;
+ }
+
+ GatewayApiService apiService = ApiManager.getApiService();
+ Call call = apiService.updateSMSStatus(deviceId, apiKey, smsDTO);
+
+ call.enqueue(new Callback() {
+ @Override
+ public void onResponse(Call call, Response response) {
+ if (response.isSuccessful()) {
+ Log.d(TAG, "SMS status updated successfully - ID: " + smsDTO.getSmsId() + ", Status: " + smsDTO.getStatus());
+ } else {
+ Log.e(TAG, "Failed to update SMS status. Response code: " + response.code());
+ }
+ }
+
+ @Override
+ public void onFailure(Call call, Throwable t) {
+ Log.e(TAG, "API call failed: " + t.getMessage());
+ }
+ });
+ }
+}
\ No newline at end of file
diff --git a/android/app/src/main/java/com/vernu/sms/services/FCMService.java b/android/app/src/main/java/com/vernu/sms/services/FCMService.java
index b113084..92c717b 100644
--- a/android/app/src/main/java/com/vernu/sms/services/FCMService.java
+++ b/android/app/src/main/java/com/vernu/sms/services/FCMService.java
@@ -19,6 +19,12 @@ import com.vernu.sms.activities.MainActivity;
import com.vernu.sms.helpers.SMSHelper;
import com.vernu.sms.helpers.SharedPreferenceHelper;
import com.vernu.sms.models.SMSPayload;
+import com.vernu.sms.dtos.RegisterDeviceInputDTO;
+import com.vernu.sms.dtos.RegisterDeviceResponseDTO;
+import com.vernu.sms.ApiManager;
+import retrofit2.Call;
+import retrofit2.Callback;
+import retrofit2.Response;
public class FCMService extends FirebaseMessagingService {
@@ -27,33 +33,94 @@ public class FCMService extends FirebaseMessagingService {
@Override
public void onMessageReceived(RemoteMessage remoteMessage) {
-
Log.d(TAG, remoteMessage.getData().toString());
- Gson gson = new Gson();
- SMSPayload smsPayload = gson.fromJson(remoteMessage.getData().get("smsData"), SMSPayload.class);
+ try {
+ // Parse SMS payload data
+ Gson gson = new Gson();
+ SMSPayload smsPayload = gson.fromJson(remoteMessage.getData().get("smsData"), SMSPayload.class);
- // Check if message contains a data payload.
- if (remoteMessage.getData().size() > 0) {
- int preferedSim = SharedPreferenceHelper.getSharedPreferenceInt(this, AppConstants.SHARED_PREFS_PREFERRED_SIM_KEY, -1);
- for (String receiver : smsPayload.getRecipients()) {
- if(preferedSim == -1) {
- SMSHelper.sendSMS(receiver, smsPayload.getMessage());
- continue;
- }
- try {
- SMSHelper.sendSMSFromSpecificSim(receiver, smsPayload.getMessage(), preferedSim);
- } catch(Exception e) {
- Log.d("SMS_SEND_ERROR", e.getMessage());
- }
+ // Check if message contains a data payload
+ if (remoteMessage.getData().size() > 0) {
+ sendSMS(smsPayload);
}
+
+ // Handle any notification message
+ if (remoteMessage.getNotification() != null) {
+ // sendNotification("notif msg", "msg body");
+ }
+ } catch (Exception e) {
+ Log.e(TAG, "Error processing FCM message: " + e.getMessage());
}
+ }
- // TODO: Handle FCM Notification Messages
- if (remoteMessage.getNotification() != null) {
- // sendNotification("notif msg", "msg body");
+ /**
+ * Send SMS to recipients using the provided payload
+ */
+ private void sendSMS(SMSPayload smsPayload) {
+ if (smsPayload == null) {
+ Log.e(TAG, "SMS payload is null");
+ return;
}
+ // Get preferred SIM
+ int preferredSim = SharedPreferenceHelper.getSharedPreferenceInt(
+ this, AppConstants.SHARED_PREFS_PREFERRED_SIM_KEY, -1);
+
+ // Check if SMS payload contains valid recipients
+ String[] recipients = smsPayload.getRecipients();
+ if (recipients == null || recipients.length == 0) {
+ Log.e(TAG, "No recipients found in SMS payload");
+ return;
+ }
+
+ // Send SMS to each recipient
+ boolean atLeastOneSent = false;
+ int sentCount = 0;
+ int failedCount = 0;
+
+ for (String recipient : recipients) {
+ boolean smsSent;
+
+ // Try to send using default or specific SIM based on preference
+ if (preferredSim == -1) {
+ // Use default SIM
+ smsSent = SMSHelper.sendSMS(
+ recipient,
+ smsPayload.getMessage(),
+ smsPayload.getSmsId(),
+ smsPayload.getSmsBatchId(),
+ this
+ );
+ } else {
+ // Use specific SIM
+ try {
+ smsSent = SMSHelper.sendSMSFromSpecificSim(
+ recipient,
+ smsPayload.getMessage(),
+ preferredSim,
+ smsPayload.getSmsId(),
+ smsPayload.getSmsBatchId(),
+ this
+ );
+ } catch (Exception e) {
+ Log.e(TAG, "Error sending SMS from specific SIM: " + e.getMessage());
+ smsSent = false;
+ }
+ }
+
+ // Track sent and failed counts
+ if (smsSent) {
+ sentCount++;
+ atLeastOneSent = true;
+ } else {
+ failedCount++;
+ }
+ }
+
+ // Log summary
+ Log.d(TAG, "SMS sending complete - Batch: " + smsPayload.getSmsBatchId() +
+ ", Sent: " + sentCount + ", Failed: " + failedCount);
}
@Override
@@ -62,7 +129,39 @@ public class FCMService extends FirebaseMessagingService {
}
private void sendRegistrationToServer(String token) {
-
+ // Check if device ID and API key are saved in shared preferences
+ String deviceId = SharedPreferenceHelper.getSharedPreferenceString(this, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, "");
+ String apiKey = SharedPreferenceHelper.getSharedPreferenceString(this, AppConstants.SHARED_PREFS_API_KEY_KEY, "");
+
+ // Only proceed if both device ID and API key are available
+ if (deviceId.isEmpty() || apiKey.isEmpty()) {
+ Log.d(TAG, "Device ID or API key not available, skipping FCM token update");
+ return;
+ }
+
+ // Create update payload with new FCM token
+ RegisterDeviceInputDTO updateInput = new RegisterDeviceInputDTO();
+ updateInput.setFcmToken(token);
+
+ // Call API to update the device with new token
+ Log.d(TAG, "Updating FCM token for device: " + deviceId);
+ ApiManager.getApiService()
+ .updateDevice(deviceId, apiKey, updateInput)
+ .enqueue(new Callback() {
+ @Override
+ public void onResponse(Call call, Response response) {
+ if (response.isSuccessful()) {
+ Log.d(TAG, "FCM token updated successfully");
+ } else {
+ Log.e(TAG, "Failed to update FCM token. Response code: " + response.code());
+ }
+ }
+
+ @Override
+ public void onFailure(Call call, Throwable t) {
+ Log.e(TAG, "Error updating FCM token: " + t.getMessage());
+ }
+ });
}
/* build and show notification */
diff --git a/android/app/src/main/java/com/vernu/sms/services/GatewayApiService.java b/android/app/src/main/java/com/vernu/sms/services/GatewayApiService.java
index 20a4d2d..e561f29 100644
--- a/android/app/src/main/java/com/vernu/sms/services/GatewayApiService.java
+++ b/android/app/src/main/java/com/vernu/sms/services/GatewayApiService.java
@@ -21,4 +21,7 @@ public interface GatewayApiService {
@POST("gateway/devices/{deviceId}/receive-sms")
Call sendReceivedSMS(@Path("deviceId") String deviceId, @Header("x-api-key") String apiKey, @Body() SMSDTO body);
+
+ @PATCH("gateway/devices/{deviceId}/sms-status")
+ Call updateSMSStatus(@Path("deviceId") String deviceId, @Header("x-api-key") String apiKey, @Body() SMSDTO body);
}
\ No newline at end of file
diff --git a/api/src/gateway/gateway.controller.ts b/api/src/gateway/gateway.controller.ts
index 7e6cf75..f052519 100644
--- a/api/src/gateway/gateway.controller.ts
+++ b/api/src/gateway/gateway.controller.ts
@@ -25,6 +25,7 @@ import {
RetrieveSMSResponseDTO,
SendBulkSMSInputDTO,
SendSMSInputDTO,
+ UpdateSMSStatusDTO,
} from './gateway.dto'
import { GatewayService } from './gateway.service'
import { CanModifyDevice } from './guards/can-modify-device.guard'
@@ -149,4 +150,38 @@ export class GatewayController {
const result = await this.gatewayService.getMessages(deviceId, type, page, limit);
return result;
}
+
+ @ApiOperation({ summary: 'Update SMS status' })
+ @UseGuards(AuthGuard, CanModifyDevice)
+ @HttpCode(HttpStatus.OK)
+ @Patch('/devices/:id/sms-status')
+ async updateSMSStatus(
+ @Param('id') deviceId: string,
+ @Body() dto: UpdateSMSStatusDTO,
+ ) {
+ const data = await this.gatewayService.updateSMSStatus(deviceId, dto);
+ return { data };
+ }
+
+ @ApiOperation({ summary: 'Get a single SMS by ID' })
+ @UseGuards(AuthGuard, CanModifyDevice)
+ @Get('/devices/:id/sms/:smsId')
+ async getSMSById(
+ @Param('id') deviceId: string,
+ @Param('smsId') smsId: string,
+ ) {
+ const data = await this.gatewayService.getSMSById(deviceId, smsId);
+ return { data };
+ }
+
+ @ApiOperation({ summary: 'Get an SMS batch by ID with all its SMS messages' })
+ @UseGuards(AuthGuard, CanModifyDevice)
+ @Get('/devices/:id/sms-batch/:smsBatchId')
+ async getSmsBatchById(
+ @Param('id') deviceId: string,
+ @Param('smsBatchId') smsBatchId: string,
+ ) {
+ const data = await this.gatewayService.getSmsBatchById(deviceId, smsBatchId);
+ return { data };
+ }
}
diff --git a/api/src/gateway/gateway.dto.ts b/api/src/gateway/gateway.dto.ts
index dde4e23..f02795d 100644
--- a/api/src/gateway/gateway.dto.ts
+++ b/api/src/gateway/gateway.dto.ts
@@ -240,3 +240,62 @@ export class RetrieveSMSResponseDTO {
})
meta?: PaginationMetaDTO
}
+
+export class UpdateSMSStatusDTO {
+ @ApiProperty({
+ type: String,
+ required: true,
+ description: 'The ID of the SMS',
+ })
+ smsId: string
+
+ @ApiProperty({
+ type: String,
+ required: true,
+ description: 'The ID of the SMS batch',
+ })
+ smsBatchId: string
+
+ @ApiProperty({
+ type: String,
+ required: true,
+ description: 'The status of the SMS (sent, delivered, failed)',
+ enum: ['sent', 'delivered', 'failed'],
+ })
+ status: string
+
+ @ApiProperty({
+ type: Number,
+ required: false,
+ description: 'The time the message was sent (in milliseconds)',
+ })
+ sentAtInMillis?: number
+
+ @ApiProperty({
+ type: Number,
+ required: false,
+ description: 'The time the message was delivered (in milliseconds)',
+ })
+ deliveredAtInMillis?: number
+
+ @ApiProperty({
+ type: Number,
+ required: false,
+ description: 'The time the message failed (in milliseconds)',
+ })
+ failedAtInMillis?: number
+
+ @ApiProperty({
+ type: String,
+ required: false,
+ description: 'Error code if the message failed',
+ })
+ errorCode?: string
+
+ @ApiProperty({
+ type: String,
+ required: false,
+ description: 'Error message if the message failed',
+ })
+ errorMessage?: string
+}
diff --git a/api/src/gateway/gateway.service.ts b/api/src/gateway/gateway.service.ts
index 3866c36..c73b4aa 100644
--- a/api/src/gateway/gateway.service.ts
+++ b/api/src/gateway/gateway.service.ts
@@ -9,6 +9,7 @@ import {
RetrieveSMSDTO,
SendBulkSMSInputDTO,
SendSMSInputDTO,
+ UpdateSMSStatusDTO,
} from './gateway.dto'
import { User } from '../users/schemas/user.schema'
import { AuthService } from '../auth/auth.service'
@@ -690,6 +691,98 @@ export class GatewayService {
}
}
+ async updateSMSStatus(deviceId: string, dto: UpdateSMSStatusDTO): Promise {
+
+ const device = await this.deviceModel.findById(deviceId);
+
+ if (!device) {
+ throw new HttpException(
+ {
+ success: false,
+ error: 'Device not found',
+ },
+ HttpStatus.NOT_FOUND,
+ );
+ }
+
+ const sms = await this.smsModel.findById(dto.smsId);
+
+ if (!sms) {
+ throw new HttpException(
+ {
+ success: false,
+ error: 'SMS not found',
+ },
+ HttpStatus.NOT_FOUND,
+ );
+ }
+
+ // Verify the SMS belongs to this device
+ if (sms.device.toString() !== deviceId) {
+ throw new HttpException(
+ {
+ success: false,
+ error: 'SMS does not belong to this device',
+ },
+ HttpStatus.FORBIDDEN,
+ );
+ }
+
+ // Normalize status to uppercase for comparison
+ const normalizedStatus = dto.status.toUpperCase();
+
+ const updateData: any = {
+ status: normalizedStatus, // Store normalized status
+ };
+
+ // Update timestamps based on status
+ if (normalizedStatus === 'SENT' && dto.sentAtInMillis) {
+ updateData.sentAt = new Date(dto.sentAtInMillis);
+ } else if (normalizedStatus === 'DELIVERED' && dto.deliveredAtInMillis) {
+ updateData.deliveredAt = new Date(dto.deliveredAtInMillis);
+ } else if (normalizedStatus === 'FAILED' && dto.failedAtInMillis) {
+ updateData.failedAt = new Date(dto.failedAtInMillis);
+ updateData.errorCode = dto.errorCode;
+ updateData.errorMessage = dto.errorMessage || 'Unknown error';
+ }
+
+ // Update the SMS
+ await this.smsModel.findByIdAndUpdate(dto.smsId, { $set: updateData });
+
+ // Check if all SMS in batch have the same status, then update batch status
+ if (dto.smsBatchId) {
+ const smsBatch = await this.smsBatchModel.findById(dto.smsBatchId);
+ if (smsBatch) {
+ const allSmsInBatch = await this.smsModel.find({ smsBatch: dto.smsBatchId });
+
+ // Check if all SMS in batch have the same status (case insensitive)
+ const allHaveSameStatus = allSmsInBatch.every(sms => sms.status.toLowerCase() === normalizedStatus);
+
+ if (allHaveSameStatus) {
+ await this.smsBatchModel.findByIdAndUpdate(dto.smsBatchId, {
+ $set: { status: normalizedStatus }
+ });
+ }
+ }
+ }
+
+ // Trigger webhook event for SMS status update
+ try {
+ this.webhookService.deliverNotification({
+ sms,
+ user: device.user,
+ event: WebhookEvent.SMS_STATUS_UPDATED,
+ });
+ } catch (error) {
+ console.error('Failed to trigger webhook event:', error);
+ }
+
+ return {
+ success: true,
+ message: 'SMS status updated successfully',
+ };
+ }
+
async getStatsForUser(user: User) {
const devices = await this.deviceModel.find({ user: user._id })
const apiKeys = await this.authService.getUserApiKeys(user)
@@ -728,4 +821,78 @@ export class GatewayService {
} others`
}
}
+
+ async getSMSById(deviceId: string, smsId: string): Promise {
+ // Check if device exists and is enabled
+ const device = await this.deviceModel.findById(deviceId);
+ if (!device) {
+ throw new HttpException(
+ {
+ success: false,
+ error: 'Device not found',
+ },
+ HttpStatus.NOT_FOUND,
+ );
+ }
+
+ // Find the SMS that belongs to this device
+ const sms = await this.smsModel.findOne({
+ _id: smsId,
+ device: deviceId
+ });
+
+ if (!sms) {
+ throw new HttpException(
+ {
+ success: false,
+ error: 'SMS not found',
+ },
+ HttpStatus.NOT_FOUND,
+ );
+ }
+
+ return sms;
+ }
+
+ async getSmsBatchById(deviceId: string, smsBatchId: string): Promise {
+ // Check if device exists
+ const device = await this.deviceModel.findById(deviceId);
+ if (!device) {
+ throw new HttpException(
+ {
+ success: false,
+ error: 'Device not found',
+ },
+ HttpStatus.NOT_FOUND,
+ );
+ }
+
+ // Find the SMS batch that belongs to this device
+ const smsBatch = await this.smsBatchModel.findOne({
+ _id: smsBatchId,
+ device: deviceId
+ });
+
+ if (!smsBatch) {
+ throw new HttpException(
+ {
+ success: false,
+ error: 'SMS batch not found',
+ },
+ HttpStatus.NOT_FOUND,
+ );
+ }
+
+ // Find all SMS messages that belong to this batch
+ const smsMessages = await this.smsModel.find({
+ smsBatch: smsBatchId,
+ device: deviceId
+ });
+
+ // Return both the batch and its SMS messages
+ return {
+ batch: smsBatch,
+ messages: smsMessages
+ };
+ }
}
diff --git a/api/src/gateway/queue/sms-queue.service.ts b/api/src/gateway/queue/sms-queue.service.ts
index a2670af..206f2c3 100644
--- a/api/src/gateway/queue/sms-queue.service.ts
+++ b/api/src/gateway/queue/sms-queue.service.ts
@@ -41,6 +41,7 @@ export class SmsQueueService {
batches.push(fcmMessages.slice(i, i + this.maxSmsBatchSize))
}
+ let delayMultiplier = 1;
for (const batch of batches) {
await this.smsQueue.add(
'send-sms',
@@ -52,7 +53,7 @@ export class SmsQueueService {
{
priority: 1, // TODO: Make this dynamic based on users subscription plan
attempts: 1,
- delay: 1000, // 1 second
+ delay: 1000 * delayMultiplier++,
backoff: {
type: 'exponential',
delay: 5000, // 5 seconds
diff --git a/api/src/gateway/schemas/sms-batch.schema.ts b/api/src/gateway/schemas/sms-batch.schema.ts
index 3fee3e6..2629c05 100644
--- a/api/src/gateway/schemas/sms-batch.schema.ts
+++ b/api/src/gateway/schemas/sms-batch.schema.ts
@@ -32,8 +32,8 @@ export class SMSBatch {
@Prop({ type: Number, default: 0 })
failureCount: number
- @Prop({ type: String, default: 'pending', enum: ['pending', 'processing', 'completed', 'partial_success', 'failed'] })
- status: string
+ @Prop({ type: String, default: 'PENDING' })
+ status: 'PENDING' | 'PROCESSING' | 'COMPLETED' | 'PARTIAL_SUCCESS' | 'FAILED'
@Prop({ type: String })
error: string
diff --git a/api/src/gateway/schemas/sms.schema.ts b/api/src/gateway/schemas/sms.schema.ts
index 401ba75..1c20add 100644
--- a/api/src/gateway/schemas/sms.schema.ts
+++ b/api/src/gateway/schemas/sms.schema.ts
@@ -49,15 +49,18 @@ export class SMS {
@Prop({ type: Date })
failedAt: Date
+
+ @Prop({ type: Number, required: false })
+ errorCode: number
+
+ @Prop({ type: String, required: false })
+ errorMessage: string
// @Prop({ type: String })
// failureReason: string
- @Prop({ type: String, default: 'pending', enum: ['pending', 'sent', 'delivered', 'failed'] })
- status: string
-
- @Prop({ type: String })
- error: string
+ @Prop({ type: String, default: 'PENDING' })
+ status: 'PENDING' | 'SENT' | 'DELIVERED' | 'FAILED'
// misc metadata for debugging
@Prop({ type: Object })
diff --git a/api/src/webhook/webhook-event.enum.ts b/api/src/webhook/webhook-event.enum.ts
index b3b1ecd..8c9421c 100644
--- a/api/src/webhook/webhook-event.enum.ts
+++ b/api/src/webhook/webhook-event.enum.ts
@@ -1,3 +1,4 @@
export enum WebhookEvent {
MESSAGE_RECEIVED = 'MESSAGE_RECEIVED',
+ SMS_STATUS_UPDATED = 'SMS_STATUS_UPDATED',
}