From 64859b38adb1665a749f365c66303ceebdc2bd00 Mon Sep 17 00:00:00 2001 From: isra el Date: Mon, 2 Jun 2025 20:42:14 +0300 Subject: [PATCH 1/4] feat(android): track sms sent, delivered, and failed status --- android/app/src/main/AndroidManifest.xml | 9 + .../main/java/com/vernu/sms/dtos/SMSDTO.java | 73 ++++++ .../java/com/vernu/sms/helpers/SMSHelper.java | 235 +++++++++++++++++- .../java/com/vernu/sms/models/SMSPayload.java | 18 ++ .../sms/receivers/SMSStatusReceiver.java | 180 ++++++++++++++ .../com/vernu/sms/services/FCMService.java | 139 +++++++++-- .../vernu/sms/services/GatewayApiService.java | 3 + 7 files changed, 625 insertions(+), 32 deletions(-) create mode 100644 android/app/src/main/java/com/vernu/sms/receivers/SMSStatusReceiver.java 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/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 From 52e88e7e364430e5ce32b8f64dfe9c0970e50f4e Mon Sep 17 00:00:00 2001 From: isra el Date: Mon, 2 Jun 2025 20:47:15 +0300 Subject: [PATCH 2/4] feat(api): track sms status --- api/src/gateway/gateway.controller.ts | 12 +++ api/src/gateway/gateway.dto.ts | 59 +++++++++++++ api/src/gateway/gateway.service.ts | 93 +++++++++++++++++++++ api/src/gateway/queue/sms-queue.service.ts | 3 +- api/src/gateway/schemas/sms-batch.schema.ts | 4 +- api/src/gateway/schemas/sms.schema.ts | 13 +-- api/src/webhook/webhook-event.enum.ts | 1 + 7 files changed, 177 insertions(+), 8 deletions(-) diff --git a/api/src/gateway/gateway.controller.ts b/api/src/gateway/gateway.controller.ts index 7e6cf75..3471244 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,15 @@ export class GatewayController { const result = await this.gatewayService.getMessages(deviceId, type, page, limit); return result; } + + @ApiOperation({ summary: 'Update SMS status' }) + @UseGuards(AuthGuard, CanModifyDevice) + @Patch('/devices/:id/sms-status') + async updateSMSStatus( + @Param('id') deviceId: string, + @Body() dto: UpdateSMSStatusDTO, + ) { + const data = await this.gatewayService.updateSMSStatus(deviceId, dto); + 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..9e1550b 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) 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', } From 7694f3f60a8d809f6126e41e4d54ec4adc6e782a Mon Sep 17 00:00:00 2001 From: isra el Date: Tue, 3 Jun 2025 05:19:47 +0300 Subject: [PATCH 3/4] feat(api): create endpoints for getting a specific sms and sms batch by id --- api/src/gateway/gateway.controller.ts | 23 +++++++++ api/src/gateway/gateway.service.ts | 74 +++++++++++++++++++++++++++ 2 files changed, 97 insertions(+) diff --git a/api/src/gateway/gateway.controller.ts b/api/src/gateway/gateway.controller.ts index 3471244..f052519 100644 --- a/api/src/gateway/gateway.controller.ts +++ b/api/src/gateway/gateway.controller.ts @@ -153,6 +153,7 @@ export class GatewayController { @ApiOperation({ summary: 'Update SMS status' }) @UseGuards(AuthGuard, CanModifyDevice) + @HttpCode(HttpStatus.OK) @Patch('/devices/:id/sms-status') async updateSMSStatus( @Param('id') deviceId: string, @@ -161,4 +162,26 @@ export class GatewayController { 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.service.ts b/api/src/gateway/gateway.service.ts index 9e1550b..c73b4aa 100644 --- a/api/src/gateway/gateway.service.ts +++ b/api/src/gateway/gateway.service.ts @@ -821,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 + }; + } } From 2be8b3e8c7952ca38256e32a28c89939086f007a Mon Sep 17 00:00:00 2001 From: isra el Date: Tue, 3 Jun 2025 05:20:45 +0300 Subject: [PATCH 4/4] chore(android): sync app and device info and restart sticky notification on restart --- .../sms/receivers/BootCompletedReceiver.java | 85 ++++++++++++++++++- 1 file changed, 82 insertions(+), 3 deletions(-) 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()); + } + }); + }); + } }