From 141b8b334a96a4ad23714e5a8fc7a2290aa112e6 Mon Sep 17 00:00:00 2001 From: isra el Date: Mon, 26 Jan 2026 14:17:08 +0300 Subject: [PATCH 01/20] fix: handle duplicate received sms issue --- .../main/java/com/vernu/sms/dtos/SMSDTO.java | 9 +++ .../sms/receivers/SMSBroadcastReceiver.java | 76 +++++++++++++++++++ .../sms/receivers/SMSStatusReceiver.java | 8 ++ .../vernu/sms/workers/SMSReceivedWorker.java | 15 +++- api/src/gateway/gateway.service.ts | 23 ++++++ 5 files changed, 128 insertions(+), 3 deletions(-) diff --git a/android/app/src/main/java/com/vernu/sms/dtos/SMSDTO.java b/android/app/src/main/java/com/vernu/sms/dtos/SMSDTO.java index 20a1867..9f17000 100644 --- a/android/app/src/main/java/com/vernu/sms/dtos/SMSDTO.java +++ b/android/app/src/main/java/com/vernu/sms/dtos/SMSDTO.java @@ -6,6 +6,7 @@ public class SMSDTO { private String sender; private String message = ""; private long receivedAtInMillis; + private String fingerprint; private String smsId; private String smsBatchId; @@ -107,4 +108,12 @@ public class SMSDTO { public void setErrorMessage(String errorMessage) { this.errorMessage = errorMessage; } + + public String getFingerprint() { + return fingerprint; + } + + public void setFingerprint(String fingerprint) { + this.fingerprint = fingerprint; + } } diff --git a/android/app/src/main/java/com/vernu/sms/receivers/SMSBroadcastReceiver.java b/android/app/src/main/java/com/vernu/sms/receivers/SMSBroadcastReceiver.java index 80eda09..f3c0cb9 100644 --- a/android/app/src/main/java/com/vernu/sms/receivers/SMSBroadcastReceiver.java +++ b/android/app/src/main/java/com/vernu/sms/receivers/SMSBroadcastReceiver.java @@ -11,11 +11,18 @@ import com.vernu.sms.dtos.SMSDTO; import com.vernu.sms.helpers.SharedPreferenceHelper; import com.vernu.sms.workers.SMSReceivedWorker; +import java.security.MessageDigest; +import java.util.HashSet; import java.util.Objects; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; public class SMSBroadcastReceiver extends BroadcastReceiver { private static final String TAG = "SMSBroadcastReceiver"; + // In-memory cache to prevent rapid duplicate processing (5 seconds TTL) + private static final ConcurrentHashMap processedFingerprints = new ConcurrentHashMap<>(); + private static final long CACHE_TTL_MS = 5000; // 5 seconds @Override public void onReceive(Context context, Intent intent) { @@ -60,6 +67,29 @@ public class SMSBroadcastReceiver extends BroadcastReceiver { // receivedSMSDTO.setMessage(receivedSMS.getMessage()); // receivedSMSDTO.setReceivedAt(receivedSMS.getReceivedAt()); + // 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 +99,50 @@ public class SMSBroadcastReceiver extends BroadcastReceiver { // appDatabase.localReceivedSMSDao().insertAll(localReceivedSMS); // }); // } + + /** + * Generate a unique fingerprint for an SMS message based on sender, message content, and timestamp + */ + private String generateFingerprint(String sender, String message, long timestamp) { + try { + String data = (sender != null ? sender : "") + "|" + + (message != null ? message : "") + "|" + + timestamp; + + MessageDigest md = MessageDigest.getInstance("MD5"); + byte[] hashBytes = md.digest(data.getBytes("UTF-8")); + + StringBuilder sb = new StringBuilder(); + for (byte b : hashBytes) { + sb.append(String.format("%02x", b)); + } + return sb.toString(); + } catch (Exception e) { + Log.e(TAG, "Error generating fingerprint: " + e.getMessage()); + // Fallback to simple string concatenation if MD5 fails + return (sender != null ? sender : "") + "_" + + (message != null ? message : "") + "_" + + timestamp; + } + } + + /** + * Clean up old cache entries to prevent memory leaks + */ + private void cleanupCache(long currentTime) { + // Only cleanup occasionally (every 100 entries processed) + if (processedFingerprints.size() > 100) { + Set keysToRemove = new HashSet<>(); + for (String key : processedFingerprints.keySet()) { + Long timestamp = processedFingerprints.get(key); + if (timestamp != null && (currentTime - timestamp) > CACHE_TTL_MS) { + keysToRemove.add(key); + } + } + for (String key : keysToRemove) { + processedFingerprints.remove(key); + } + Log.d(TAG, "Cleaned up " + keysToRemove.size() + " expired cache entries"); + } + } } \ No newline at end of file diff --git a/android/app/src/main/java/com/vernu/sms/receivers/SMSStatusReceiver.java b/android/app/src/main/java/com/vernu/sms/receivers/SMSStatusReceiver.java index e50e7a4..0cafb3d 100644 --- a/android/app/src/main/java/com/vernu/sms/receivers/SMSStatusReceiver.java +++ b/android/app/src/main/java/com/vernu/sms/receivers/SMSStatusReceiver.java @@ -102,6 +102,14 @@ public class SMSStatusReceiver extends BroadcastReceiver { smsDTO.setErrorMessage(errorMessage); Log.e(TAG, "SMS failed to send - ID: " + smsDTO.getSmsId() + ", Error code: " + resultCode + ", Error: " + errorMessage); break; + case SmsManager.RESULT_NETWORK_ERROR: + errorMessage = "Network error"; + smsDTO.setStatus("FAILED"); + smsDTO.setFailedAtInMillis(timestamp); + smsDTO.setErrorCode(String.valueOf(resultCode)); + smsDTO.setErrorMessage(errorMessage); + Log.e(TAG, "SMS failed to send - ID: " + smsDTO.getSmsId() + ", Error code: " + resultCode + ", Error: " + errorMessage); + break; default: errorMessage = "Unknown error"; smsDTO.setStatus("FAILED"); diff --git a/android/app/src/main/java/com/vernu/sms/workers/SMSReceivedWorker.java b/android/app/src/main/java/com/vernu/sms/workers/SMSReceivedWorker.java index 51c8c38..1f45182 100644 --- a/android/app/src/main/java/com/vernu/sms/workers/SMSReceivedWorker.java +++ b/android/app/src/main/java/com/vernu/sms/workers/SMSReceivedWorker.java @@ -94,13 +94,22 @@ public class SMSReceivedWorker extends Worker { .addTag("sms_received") .build(); - String uniqueWorkName = "sms_received_" + System.currentTimeMillis(); + // Use fingerprint for unique work name if available, otherwise fallback to timestamp + String uniqueWorkName; + if (smsDTO.getFingerprint() != null && !smsDTO.getFingerprint().isEmpty()) { + uniqueWorkName = "sms_received_" + smsDTO.getFingerprint(); + } else { + // Fallback to timestamp if fingerprint is not available + uniqueWorkName = "sms_received_" + System.currentTimeMillis(); + Log.w(TAG, "Fingerprint not available, using timestamp for work name"); + } + WorkManager.getInstance(context) .beginUniqueWork(uniqueWorkName, - androidx.work.ExistingWorkPolicy.APPEND_OR_REPLACE, + androidx.work.ExistingWorkPolicy.KEEP, workRequest) .enqueue(); - Log.d(TAG, "Work enqueued for received SMS from: " + smsDTO.getSender()); + Log.d(TAG, "Work enqueued for received SMS from: " + smsDTO.getSender() + " with fingerprint: " + uniqueWorkName); } } \ No newline at end of file diff --git a/api/src/gateway/gateway.service.ts b/api/src/gateway/gateway.service.ts index ec69c33..1f26ed5 100644 --- a/api/src/gateway/gateway.service.ts +++ b/api/src/gateway/gateway.service.ts @@ -545,6 +545,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, From 541f32406e35c6010294e87f9bf51dd4cf6ff3d8 Mon Sep 17 00:00:00 2001 From: isra el Date: Tue, 27 Jan 2026 15:30:06 +0300 Subject: [PATCH 02/20] feat(api): create heart beat endpoint --- api/src/gateway/gateway.controller.ts | 14 +++ api/src/gateway/gateway.dto.ts | 131 +++++++++++++++++++++++ api/src/gateway/gateway.service.ts | 127 ++++++++++++++++++++++ api/src/gateway/schemas/device.schema.ts | 101 +++++++++++++++++ 4 files changed, 373 insertions(+) diff --git a/api/src/gateway/gateway.controller.ts b/api/src/gateway/gateway.controller.ts index 45aab7d..0eab5a2 100644 --- a/api/src/gateway/gateway.controller.ts +++ b/api/src/gateway/gateway.controller.ts @@ -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 { + const data = await this.gatewayService.heartbeat(deviceId, input) + return data + } + @ApiOperation({ summary: 'Delete device' }) @UseGuards(AuthGuard, CanModifyDevice) @Delete('/devices/:id') diff --git a/api/src/gateway/gateway.dto.ts b/api/src/gateway/gateway.dto.ts index f02795d..d08df9a 100644 --- a/api/src/gateway/gateway.dto.ts +++ b/api/src/gateway/gateway.dto.ts @@ -299,3 +299,134 @@ 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 +} + +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 +} diff --git a/api/src/gateway/gateway.service.ts b/api/src/gateway/gateway.service.ts index 1f26ed5..7285faf 100644 --- a/api/src/gateway/gateway.service.ts +++ b/api/src/gateway/gateway.service.ts @@ -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' @@ -917,4 +919,129 @@ const updatedSms = await this.smsModel.findByIdAndUpdate( messages: smsMessages }; } + + async heartbeat( + deviceId: string, + input: HeartbeatInputDTO, + ): Promise { + 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) { + updateData.batteryInfo = { + ...(device.batteryInfo || {}), + ...(input.batteryPercentage !== undefined && { percentage: input.batteryPercentage }), + ...(input.isCharging !== undefined && { isCharging: input.isCharging }), + lastUpdated: now, + } + } + + // Update networkInfo if provided + if (input.networkType !== undefined) { + updateData.networkInfo = { + ...(device.networkInfo || {}), + type: input.networkType, + lastUpdated: now, + } + } + + // Update appVersionInfo if provided + if (input.appVersionName !== undefined || input.appVersionCode !== undefined) { + updateData.appVersionInfo = { + ...(device.appVersionInfo || {}), + ...(input.appVersionName !== undefined && { versionName: input.appVersionName }), + ...(input.appVersionCode !== undefined && { versionCode: input.appVersionCode }), + lastUpdated: now, + } + } + + // Update deviceUptimeInfo if provided + if (input.deviceUptimeMillis !== undefined) { + updateData.deviceUptimeInfo = { + ...(device.deviceUptimeInfo || {}), + uptimeMillis: input.deviceUptimeMillis, + lastUpdated: now, + } + } + + // Update memoryInfo if any memory field provided + if ( + input.memoryFreeBytes !== undefined || + input.memoryTotalBytes !== undefined || + input.memoryMaxBytes !== undefined + ) { + updateData.memoryInfo = { + ...(device.memoryInfo || {}), + ...(input.memoryFreeBytes !== undefined && { freeBytes: input.memoryFreeBytes }), + ...(input.memoryTotalBytes !== undefined && { totalBytes: input.memoryTotalBytes }), + ...(input.memoryMaxBytes !== undefined && { maxBytes: input.memoryMaxBytes }), + lastUpdated: now, + } + } + + // Update storageInfo if any storage field provided + if ( + input.storageAvailableBytes !== undefined || + input.storageTotalBytes !== undefined + ) { + updateData.storageInfo = { + ...(device.storageInfo || {}), + ...(input.storageAvailableBytes !== undefined && { availableBytes: input.storageAvailableBytes }), + ...(input.storageTotalBytes !== undefined && { totalBytes: input.storageTotalBytes }), + lastUpdated: now, + } + } + + // Update systemInfo if timezone or locale provided + if (input.timezone !== undefined || input.locale !== undefined) { + updateData.systemInfo = { + ...(device.systemInfo || {}), + ...(input.timezone !== undefined && { timezone: input.timezone }), + ...(input.locale !== undefined && { locale: input.locale }), + lastUpdated: now, + } + } + + // Update device with all changes + await this.deviceModel.findByIdAndUpdate(deviceId, { + $set: updateData, + }) + + return { + success: true, + fcmTokenUpdated, + lastHeartbeat: now, + } + } } diff --git a/api/src/gateway/schemas/device.schema.ts b/api/src/gateway/schemas/device.schema.ts index 77abd5f..7ee8958 100644 --- a/api/src/gateway/schemas/device.schema.ts +++ b/api/src/gateway/schemas/device.schema.ts @@ -49,6 +49,107 @@ 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: { + type: String, + lastUpdated: Date, + }, + }) + networkInfo: { + type?: '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 + } } export const DeviceSchema = SchemaFactory.createForClass(Device) From dc8ca9d9a75d96abfdc58d8affb14cd90aacc41c Mon Sep 17 00:00:00 2001 From: isra el Date: Tue, 27 Jan 2026 17:38:15 +0300 Subject: [PATCH 03/20] feat(android): implement heartbeat check --- android/app/src/main/AndroidManifest.xml | 1 + .../main/java/com/vernu/sms/AppConstants.java | 3 + .../vernu/sms/activities/MainActivity.java | 56 +++++ .../com/vernu/sms/dtos/HeartbeatInputDTO.java | 142 ++++++++++++ .../vernu/sms/dtos/HeartbeatResponseDTO.java | 7 + .../vernu/sms/helpers/HeartbeatManager.java | 74 +++++++ .../sms/receivers/BootCompletedReceiver.java | 24 ++ .../vernu/sms/services/GatewayApiService.java | 5 + .../vernu/sms/workers/HeartbeatWorker.java | 205 ++++++++++++++++++ 9 files changed, 517 insertions(+) create mode 100644 android/app/src/main/java/com/vernu/sms/dtos/HeartbeatInputDTO.java create mode 100644 android/app/src/main/java/com/vernu/sms/dtos/HeartbeatResponseDTO.java create mode 100644 android/app/src/main/java/com/vernu/sms/helpers/HeartbeatManager.java create mode 100644 android/app/src/main/java/com/vernu/sms/workers/HeartbeatWorker.java diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 2f0e761..5a08d7a 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -15,6 +15,7 @@ + call, Response response) { if (response.isSuccessful()) { Log.d(TAG, "Device info updated successfully after boot"); + + // Sync heartbeatIntervalMinutes from server response + if (response.body() != null && response.body().data != null) { + if (response.body().data.get("heartbeatIntervalMinutes") != null) { + Object intervalObj = response.body().data.get("heartbeatIntervalMinutes"); + if (intervalObj instanceof Number) { + int intervalMinutes = ((Number) intervalObj).intValue(); + SharedPreferenceHelper.setSharedPreferenceInt(context, AppConstants.SHARED_PREFS_HEARTBEAT_INTERVAL_MINUTES_KEY, intervalMinutes); + Log.d(TAG, "Synced heartbeat interval from server: " + intervalMinutes + " minutes"); + } + } + } } else { Log.e(TAG, "Failed to update device info after boot. Response code: " + response.code()); } diff --git a/android/app/src/main/java/com/vernu/sms/services/GatewayApiService.java b/android/app/src/main/java/com/vernu/sms/services/GatewayApiService.java index e561f29..6874a17 100644 --- a/android/app/src/main/java/com/vernu/sms/services/GatewayApiService.java +++ b/android/app/src/main/java/com/vernu/sms/services/GatewayApiService.java @@ -4,6 +4,8 @@ import com.vernu.sms.dtos.SMSDTO; import com.vernu.sms.dtos.SMSForwardResponseDTO; import com.vernu.sms.dtos.RegisterDeviceInputDTO; import com.vernu.sms.dtos.RegisterDeviceResponseDTO; +import com.vernu.sms.dtos.HeartbeatInputDTO; +import com.vernu.sms.dtos.HeartbeatResponseDTO; import retrofit2.Call; import retrofit2.http.Body; @@ -24,4 +26,7 @@ public interface GatewayApiService { @PATCH("gateway/devices/{deviceId}/sms-status") Call updateSMSStatus(@Path("deviceId") String deviceId, @Header("x-api-key") String apiKey, @Body() SMSDTO body); + + @POST("gateway/devices/{deviceId}/heartbeat") + Call heartbeat(@Path("deviceId") String deviceId, @Header("x-api-key") String apiKey, @Body() HeartbeatInputDTO body); } \ No newline at end of file diff --git a/android/app/src/main/java/com/vernu/sms/workers/HeartbeatWorker.java b/android/app/src/main/java/com/vernu/sms/workers/HeartbeatWorker.java new file mode 100644 index 0000000..0558b2a --- /dev/null +++ b/android/app/src/main/java/com/vernu/sms/workers/HeartbeatWorker.java @@ -0,0 +1,205 @@ +package com.vernu.sms.workers; + +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.Build; +import android.os.StatFs; +import android.os.SystemClock; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.work.Worker; +import androidx.work.WorkerParameters; + +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.helpers.SharedPreferenceHelper; + +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 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 registered + String deviceId = SharedPreferenceHelper.getSharedPreferenceString( + context, + AppConstants.SHARED_PREFS_DEVICE_ID_KEY, + "" + ); + + if (deviceId.isEmpty()) { + Log.d(TAG, "Device not registered, skipping heartbeat"); + return Result.success(); // Not a failure, just skip + } + + // Check if device is enabled + boolean deviceEnabled = SharedPreferenceHelper.getSharedPreferenceBoolean( + context, + AppConstants.SHARED_PREFS_GATEWAY_ENABLED_KEY, + false + ); + + if (!deviceEnabled) { + Log.d(TAG, "Device not enabled, skipping heartbeat"); + return Result.success(); // Not a failure, just skip + } + + // Check if heartbeat feature is enabled + boolean heartbeatEnabled = SharedPreferenceHelper.getSharedPreferenceBoolean( + context, + AppConstants.SHARED_PREFS_HEARTBEAT_ENABLED_KEY, + true // Default to true + ); + + if (!heartbeatEnabled) { + Log.d(TAG, "Heartbeat feature disabled, skipping heartbeat"); + return Result.success(); // Not a failure, just skip + } + + String apiKey = SharedPreferenceHelper.getSharedPreferenceString( + context, + AppConstants.SHARED_PREFS_API_KEY_KEY, + "" + ); + + if (apiKey.isEmpty()) { + Log.e(TAG, "API key not available, skipping heartbeat"); + return Result.success(); // Not a failure, just skip + } + + // 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); + + // Send heartbeat request + Call call = ApiManager.getApiService().heartbeat(deviceId, apiKey, heartbeatInput); + Response response = call.execute(); + + if (response.isSuccessful() && response.body() != null) { + HeartbeatResponseDTO responseBody = response.body(); + if (responseBody.fcmTokenUpdated) { + Log.d(TAG, "FCM token was updated during heartbeat"); + } + Log.d(TAG, "Heartbeat sent successfully"); + return Result.success(); + } else { + Log.e(TAG, "Failed to send heartbeat. Response code: " + (response.code())); + return Result.retry(); + } + } catch (IOException e) { + Log.e(TAG, "Heartbeat API call failed: " + e.getMessage()); + return Result.retry(); + } catch (Exception e) { + Log.e(TAG, "Error collecting device information: " + e.getMessage()); + return Result.retry(); + } + } +} From 0665aa5432ab5c44f7da4a8572fb78a9ff0ccebd Mon Sep 17 00:00:00 2001 From: isra el Date: Tue, 27 Jan 2026 19:46:09 +0300 Subject: [PATCH 04/20] feat: support sim card selection via api --- .../main/java/com/vernu/sms/TextBeeUtils.java | 168 ++++++++++++++++++ .../vernu/sms/activities/MainActivity.java | 13 ++ .../com/vernu/sms/dtos/HeartbeatInputDTO.java | 9 + .../sms/dtos/RegisterDeviceInputDTO.java | 9 + .../vernu/sms/dtos/SimInfoCollectionDTO.java | 27 +++ .../java/com/vernu/sms/dtos/SimInfoDTO.java | 97 ++++++++++ .../java/com/vernu/sms/models/SMSPayload.java | 9 + .../com/vernu/sms/services/FCMService.java | 40 ++++- .../vernu/sms/workers/HeartbeatWorker.java | 8 + api/src/gateway/gateway.dto.ts | 53 ++++++ api/src/gateway/gateway.service.ts | 48 ++++- api/src/gateway/schemas/device.schema.ts | 35 ++++ api/src/gateway/schemas/sms.schema.ts | 3 + 13 files changed, 509 insertions(+), 10 deletions(-) create mode 100644 android/app/src/main/java/com/vernu/sms/dtos/SimInfoCollectionDTO.java create mode 100644 android/app/src/main/java/com/vernu/sms/dtos/SimInfoDTO.java diff --git a/android/app/src/main/java/com/vernu/sms/TextBeeUtils.java b/android/app/src/main/java/com/vernu/sms/TextBeeUtils.java index 6e708de..6688b02 100644 --- a/android/app/src/main/java/com/vernu/sms/TextBeeUtils.java +++ b/android/app/src/main/java/com/vernu/sms/TextBeeUtils.java @@ -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,171 @@ 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 collectSimInfo(Context context) { + List simInfoList = new ArrayList<>(); + + if (ActivityCompat.checkSelfPermission(context, Manifest.permission.READ_PHONE_STATE) != PackageManager.PERMISSION_GRANTED) { + Log.w(TAG, "READ_PHONE_STATE permission not granted, cannot collect SIM info"); + return simInfoList; + } + + try { + SubscriptionManager subscriptionManager = SubscriptionManager.from(context); + List subscriptionInfoList = subscriptionManager.getActiveSubscriptionInfoList(); + + if (subscriptionInfoList == null) { + Log.d(TAG, "No active subscriptions found"); + return simInfoList; + } + + for (SubscriptionInfo subscriptionInfo : subscriptionInfoList) { + SimInfoDTO simInfo = new SimInfoDTO(); + simInfo.setSubscriptionId(subscriptionInfo.getSubscriptionId()); + + // Get ICCID (may be null for eSIM) + try { + String iccId = subscriptionInfo.getIccId(); + if (iccId != null && !iccId.isEmpty()) { + simInfo.setIccId(iccId); + } + } catch (Exception e) { + Log.d(TAG, "Could not get ICCID for subscription " + subscriptionInfo.getSubscriptionId()); + } + + // Get Card ID + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + int cardId = subscriptionInfo.getCardId(); + if (cardId != SubscriptionManager.INVALID_CARD_ID) { + simInfo.setCardId(cardId); + } + } + } catch (Exception e) { + Log.d(TAG, "Could not get Card ID for subscription " + subscriptionInfo.getSubscriptionId()); + } + + // Get carrier name + try { + CharSequence carrierName = subscriptionInfo.getCarrierName(); + if (carrierName != null) { + simInfo.setCarrierName(carrierName.toString()); + } + } catch (Exception e) { + Log.d(TAG, "Could not get carrier name for subscription " + subscriptionInfo.getSubscriptionId()); + } + + // Get display name + try { + CharSequence displayName = subscriptionInfo.getDisplayName(); + if (displayName != null) { + simInfo.setDisplayName(displayName.toString()); + } + } catch (Exception e) { + Log.d(TAG, "Could not get display name for subscription " + subscriptionInfo.getSubscriptionId()); + } + + // Get SIM slot index + try { + int simSlotIndex = subscriptionInfo.getSimSlotIndex(); + if (simSlotIndex >= 0) { + simInfo.setSimSlotIndex(simSlotIndex); + } + } catch (Exception e) { + Log.d(TAG, "Could not get SIM slot index for subscription " + subscriptionInfo.getSubscriptionId()); + } + + // Get MCC + try { + String mcc = subscriptionInfo.getMccString(); + if (mcc != null && !mcc.isEmpty()) { + simInfo.setMcc(mcc); + } + } catch (Exception e) { + Log.d(TAG, "Could not get MCC for subscription " + subscriptionInfo.getSubscriptionId()); + } + + // Get MNC + try { + String mnc = subscriptionInfo.getMncString(); + if (mnc != null && !mnc.isEmpty()) { + simInfo.setMnc(mnc); + } + } catch (Exception e) { + Log.d(TAG, "Could not get MNC for subscription " + subscriptionInfo.getSubscriptionId()); + } + + // Get country ISO + try { + String countryIso = subscriptionInfo.getCountryIso(); + if (countryIso != null && !countryIso.isEmpty()) { + simInfo.setCountryIso(countryIso); + } + } catch (Exception e) { + Log.d(TAG, "Could not get country ISO for subscription " + subscriptionInfo.getSubscriptionId()); + } + + // Get subscription type (0 = physical SIM, 1 = eSIM) + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + int subscriptionType = subscriptionInfo.getSubscriptionType(); + if (subscriptionType == SubscriptionManager.SUBSCRIPTION_TYPE_LOCAL_SIM) { + simInfo.setSubscriptionType("PHYSICAL_SIM"); + } else if (subscriptionType == SubscriptionManager.SUBSCRIPTION_TYPE_REMOTE_SIM) { + simInfo.setSubscriptionType("ESIM"); + } + } else { + // For older Android versions, default to PHYSICAL_SIM + simInfo.setSubscriptionType("PHYSICAL_SIM"); + } + } catch (Exception e) { + Log.d(TAG, "Could not get subscription type for subscription " + subscriptionInfo.getSubscriptionId()); + } + + simInfoList.add(simInfo); + } + } catch (Exception e) { + Log.e(TAG, "Error collecting SIM info: " + e.getMessage(), e); + } + + return simInfoList; + } + + /** + * Validates if a subscription ID exists in the active subscriptions + * + * @param context The application context + * @param subscriptionId The subscription ID to validate + * @return true if the subscription ID exists, false otherwise + */ + public static boolean isValidSubscriptionId(Context context, int subscriptionId) { + if (ActivityCompat.checkSelfPermission(context, Manifest.permission.READ_PHONE_STATE) != PackageManager.PERMISSION_GRANTED) { + return false; + } + + try { + SubscriptionManager subscriptionManager = SubscriptionManager.from(context); + List subscriptionInfoList = subscriptionManager.getActiveSubscriptionInfoList(); + + if (subscriptionInfoList == null) { + return false; + } + + for (SubscriptionInfo subscriptionInfo : subscriptionInfoList) { + if (subscriptionInfo.getSubscriptionId() == subscriptionId) { + return true; + } + } + } catch (Exception e) { + Log.e(TAG, "Error validating subscription ID: " + e.getMessage(), e); + } + + return false; + } } diff --git a/android/app/src/main/java/com/vernu/sms/activities/MainActivity.java b/android/app/src/main/java/com/vernu/sms/activities/MainActivity.java index 9730418..a4fdd57 100644 --- a/android/app/src/main/java/com/vernu/sms/activities/MainActivity.java +++ b/android/app/src/main/java/com/vernu/sms/activities/MainActivity.java @@ -30,6 +30,7 @@ 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; @@ -375,6 +376,12 @@ public class MainActivity extends AppCompatActivity { registerDeviceInput.setAppVersionCode(BuildConfig.VERSION_CODE); registerDeviceInput.setAppVersionName(BuildConfig.VERSION_NAME); + // 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); @@ -525,6 +532,12 @@ public class MainActivity extends AppCompatActivity { updateDeviceInput.setAppVersionCode(BuildConfig.VERSION_CODE); updateDeviceInput.setAppVersionName(BuildConfig.VERSION_NAME); + // Collect SIM information + SimInfoCollectionDTO simInfoCollection = new SimInfoCollectionDTO(); + simInfoCollection.setLastUpdated(System.currentTimeMillis()); + simInfoCollection.setSims(TextBeeUtils.collectSimInfo(mContext)); + updateDeviceInput.setSimInfo(simInfoCollection); + Call apiCall = ApiManager.getApiService().updateDevice(deviceIdToUse, apiKey, updateDeviceInput); apiCall.enqueue(new Callback() { @Override diff --git a/android/app/src/main/java/com/vernu/sms/dtos/HeartbeatInputDTO.java b/android/app/src/main/java/com/vernu/sms/dtos/HeartbeatInputDTO.java index c946305..5de6b49 100644 --- a/android/app/src/main/java/com/vernu/sms/dtos/HeartbeatInputDTO.java +++ b/android/app/src/main/java/com/vernu/sms/dtos/HeartbeatInputDTO.java @@ -16,6 +16,7 @@ public class HeartbeatInputDTO { private String timezone; private String locale; private Boolean receiveSMSEnabled; + private SimInfoCollectionDTO simInfo; public HeartbeatInputDTO() { } @@ -139,4 +140,12 @@ public class HeartbeatInputDTO { public void setReceiveSMSEnabled(Boolean receiveSMSEnabled) { this.receiveSMSEnabled = receiveSMSEnabled; } + + public SimInfoCollectionDTO getSimInfo() { + return simInfo; + } + + public void setSimInfo(SimInfoCollectionDTO simInfo) { + this.simInfo = simInfo; + } } diff --git a/android/app/src/main/java/com/vernu/sms/dtos/RegisterDeviceInputDTO.java b/android/app/src/main/java/com/vernu/sms/dtos/RegisterDeviceInputDTO.java index b366e50..07d7e37 100644 --- a/android/app/src/main/java/com/vernu/sms/dtos/RegisterDeviceInputDTO.java +++ b/android/app/src/main/java/com/vernu/sms/dtos/RegisterDeviceInputDTO.java @@ -12,6 +12,7 @@ public class RegisterDeviceInputDTO { private String osVersion; private String appVersionName; private int appVersionCode; + private SimInfoCollectionDTO simInfo; public RegisterDeviceInputDTO() { } @@ -107,4 +108,12 @@ public class RegisterDeviceInputDTO { public void setAppVersionCode(int appVersionCode) { this.appVersionCode = appVersionCode; } + + public SimInfoCollectionDTO getSimInfo() { + return simInfo; + } + + public void setSimInfo(SimInfoCollectionDTO simInfo) { + this.simInfo = simInfo; + } } diff --git a/android/app/src/main/java/com/vernu/sms/dtos/SimInfoCollectionDTO.java b/android/app/src/main/java/com/vernu/sms/dtos/SimInfoCollectionDTO.java new file mode 100644 index 0000000..e955034 --- /dev/null +++ b/android/app/src/main/java/com/vernu/sms/dtos/SimInfoCollectionDTO.java @@ -0,0 +1,27 @@ +package com.vernu.sms.dtos; + +import java.util.List; + +public class SimInfoCollectionDTO { + private long lastUpdated; + private List sims; + + public SimInfoCollectionDTO() { + } + + public long getLastUpdated() { + return lastUpdated; + } + + public void setLastUpdated(long lastUpdated) { + this.lastUpdated = lastUpdated; + } + + public List getSims() { + return sims; + } + + public void setSims(List sims) { + this.sims = sims; + } +} diff --git a/android/app/src/main/java/com/vernu/sms/dtos/SimInfoDTO.java b/android/app/src/main/java/com/vernu/sms/dtos/SimInfoDTO.java new file mode 100644 index 0000000..43cc6a8 --- /dev/null +++ b/android/app/src/main/java/com/vernu/sms/dtos/SimInfoDTO.java @@ -0,0 +1,97 @@ +package com.vernu.sms.dtos; + +public class SimInfoDTO { + private int subscriptionId; + private String iccId; + private Integer cardId; + private String carrierName; + private String displayName; + private Integer simSlotIndex; + private String mcc; + private String mnc; + private String countryIso; + private String subscriptionType; + + public SimInfoDTO() { + } + + public int getSubscriptionId() { + return subscriptionId; + } + + public void setSubscriptionId(int subscriptionId) { + this.subscriptionId = subscriptionId; + } + + public String getIccId() { + return iccId; + } + + public void setIccId(String iccId) { + this.iccId = iccId; + } + + public Integer getCardId() { + return cardId; + } + + public void setCardId(Integer cardId) { + this.cardId = cardId; + } + + public String getCarrierName() { + return carrierName; + } + + public void setCarrierName(String carrierName) { + this.carrierName = carrierName; + } + + public String getDisplayName() { + return displayName; + } + + public void setDisplayName(String displayName) { + this.displayName = displayName; + } + + public Integer getSimSlotIndex() { + return simSlotIndex; + } + + public void setSimSlotIndex(Integer simSlotIndex) { + this.simSlotIndex = simSlotIndex; + } + + public String getMcc() { + return mcc; + } + + public void setMcc(String mcc) { + this.mcc = mcc; + } + + public String getMnc() { + return mnc; + } + + public void setMnc(String mnc) { + this.mnc = mnc; + } + + public String getCountryIso() { + return countryIso; + } + + public void setCountryIso(String countryIso) { + this.countryIso = countryIso; + } + + public String getSubscriptionType() { + return subscriptionType; + } + + public void setSubscriptionType(String subscriptionType) { + this.subscriptionType = subscriptionType; + } +} diff --git a/android/app/src/main/java/com/vernu/sms/models/SMSPayload.java b/android/app/src/main/java/com/vernu/sms/models/SMSPayload.java index b73bcd6..b9ec05f 100644 --- a/android/app/src/main/java/com/vernu/sms/models/SMSPayload.java +++ b/android/app/src/main/java/com/vernu/sms/models/SMSPayload.java @@ -6,6 +6,7 @@ public class SMSPayload { private String message; private String smsId; private String smsBatchId; + private Integer simSubscriptionId; // Legacy fields that are no longer used private String[] receivers; @@ -45,4 +46,12 @@ public class SMSPayload { public void setSmsBatchId(String smsBatchId) { this.smsBatchId = smsBatchId; } + + public Integer getSimSubscriptionId() { + return simSubscriptionId; + } + + public void setSimSubscriptionId(Integer simSubscriptionId) { + this.simSubscriptionId = simSubscriptionId; + } } diff --git a/android/app/src/main/java/com/vernu/sms/services/FCMService.java b/android/app/src/main/java/com/vernu/sms/services/FCMService.java index 92c717b..8fb9d03 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,7 @@ 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.TextBeeUtils; import com.vernu.sms.dtos.RegisterDeviceInputDTO; import com.vernu.sms.dtos.RegisterDeviceResponseDTO; import com.vernu.sms.ApiManager; @@ -63,9 +64,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 +109,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 +126,7 @@ public class FCMService extends FirebaseMessagingService { smsSent = SMSHelper.sendSMSFromSpecificSim( recipient, smsPayload.getMessage(), - preferredSim, + simSubscriptionId, smsPayload.getSmsId(), smsPayload.getSmsBatchId(), this diff --git a/android/app/src/main/java/com/vernu/sms/workers/HeartbeatWorker.java b/android/app/src/main/java/com/vernu/sms/workers/HeartbeatWorker.java index 0558b2a..095d8cc 100644 --- a/android/app/src/main/java/com/vernu/sms/workers/HeartbeatWorker.java +++ b/android/app/src/main/java/com/vernu/sms/workers/HeartbeatWorker.java @@ -21,7 +21,9 @@ 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.helpers.SharedPreferenceHelper; +import com.vernu.sms.TextBeeUtils; import java.io.File; import java.io.IOException; @@ -179,6 +181,12 @@ public class HeartbeatWorker extends Worker { ); heartbeatInput.setReceiveSMSEnabled(receiveSMSEnabled); + // Collect SIM information + SimInfoCollectionDTO simInfoCollection = new SimInfoCollectionDTO(); + simInfoCollection.setLastUpdated(System.currentTimeMillis()); + simInfoCollection.setSims(TextBeeUtils.collectSimInfo(context)); + heartbeatInput.setSimInfo(simInfoCollection); + // Send heartbeat request Call call = ApiManager.getApiService().heartbeat(deviceId, apiKey, heartbeatInput); Response response = call.execute(); diff --git a/api/src/gateway/gateway.dto.ts b/api/src/gateway/gateway.dto.ts index d08df9a..29cbc2e 100644 --- a/api/src/gateway/gateway.dto.ts +++ b/api/src/gateway/gateway.dto.ts @@ -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 @@ -33,6 +73,9 @@ export class RegisterDeviceInputDTO { @ApiProperty({ type: String }) appVersionCode?: number + + @ApiProperty({ type: SimInfoCollectionDTO, required: false }) + simInfo?: SimInfoCollectionDTO } export class SMSData { @@ -51,6 +94,13 @@ export class SMSData { }) recipients: string[] + @ApiProperty({ + type: Number, + required: false, + description: 'Optional SIM subscription ID to use for sending SMS', + }) + simSubscriptionId?: number + // TODO: restructure the Payload such that it contains bactchId, smsId, recipients and message in an optimized way // message: string // bactchId: string @@ -406,6 +456,9 @@ export class HeartbeatInputDTO { description: 'Whether receive SMS feature is enabled', }) receiveSMSEnabled?: boolean + + @ApiProperty({ type: SimInfoCollectionDTO, required: false }) + simInfo?: SimInfoCollectionDTO } export class HeartbeatResponseDTO { diff --git a/api/src/gateway/gateway.service.ts b/api/src/gateway/gateway.service.ts index 7285faf..5fefd4d 100644 --- a/api/src/gateway/gateway.service.ts +++ b/api/src/gateway/gateway.service.ts @@ -46,13 +46,23 @@ export class GatewayService { buildId: input.buildId, }) + const deviceData: any = { ...input, user } + + // 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) } } @@ -82,10 +92,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 }, ) } @@ -183,12 +203,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, @@ -376,15 +402,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, @@ -1033,6 +1065,14 @@ const updatedSms = await this.smsModel.findByIdAndUpdate( } } + // 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, diff --git a/api/src/gateway/schemas/device.schema.ts b/api/src/gateway/schemas/device.schema.ts index 7ee8958..454b57c 100644 --- a/api/src/gateway/schemas/device.schema.ts +++ b/api/src/gateway/schemas/device.schema.ts @@ -150,6 +150,41 @@ export class Device { 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) diff --git a/api/src/gateway/schemas/sms.schema.ts b/api/src/gateway/schemas/sms.schema.ts index b3db790..370d51e 100644 --- a/api/src/gateway/schemas/sms.schema.ts +++ b/api/src/gateway/schemas/sms.schema.ts @@ -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 From e0e6be937994f38a84915e84dca371c82c43b3b6 Mon Sep 17 00:00:00 2001 From: isra el Date: Tue, 27 Jan 2026 19:49:59 +0300 Subject: [PATCH 05/20] fix(android): fix android build issue --- android/app/src/main/java/com/vernu/sms/TextBeeUtils.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/android/app/src/main/java/com/vernu/sms/TextBeeUtils.java b/android/app/src/main/java/com/vernu/sms/TextBeeUtils.java index 6688b02..a2a5120 100644 --- a/android/app/src/main/java/com/vernu/sms/TextBeeUtils.java +++ b/android/app/src/main/java/com/vernu/sms/TextBeeUtils.java @@ -160,7 +160,8 @@ public class TextBeeUtils { try { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { int cardId = subscriptionInfo.getCardId(); - if (cardId != SubscriptionManager.INVALID_CARD_ID) { + // INVALID_CARD_ID is -1, check for valid card ID (>= 0) + if (cardId >= 0) { simInfo.setCardId(cardId); } } From 8a42b95e8f3effb627a9429fd5ab1d1ab8f40122 Mon Sep 17 00:00:00 2001 From: isra el Date: Wed, 28 Jan 2026 17:42:32 +0300 Subject: [PATCH 06/20] fix(api): fix heartbeat info saving issue --- api/src/gateway/gateway.service.ts | 73 ++++++++++++------------ api/src/gateway/schemas/device.schema.ts | 4 +- 2 files changed, 39 insertions(+), 38 deletions(-) diff --git a/api/src/gateway/gateway.service.ts b/api/src/gateway/gateway.service.ts index 5fefd4d..826d622 100644 --- a/api/src/gateway/gateway.service.ts +++ b/api/src/gateway/gateway.service.ts @@ -991,40 +991,36 @@ const updatedSms = await this.smsModel.findByIdAndUpdate( // Update batteryInfo if provided if (input.batteryPercentage !== undefined || input.isCharging !== undefined) { - updateData.batteryInfo = { - ...(device.batteryInfo || {}), - ...(input.batteryPercentage !== undefined && { percentage: input.batteryPercentage }), - ...(input.isCharging !== undefined && { isCharging: input.isCharging }), - lastUpdated: now, + 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 = { - ...(device.networkInfo || {}), - type: input.networkType, - lastUpdated: now, - } + updateData['networkInfo.networkType'] = input.networkType + updateData['networkInfo.lastUpdated'] = now } // Update appVersionInfo if provided if (input.appVersionName !== undefined || input.appVersionCode !== undefined) { - updateData.appVersionInfo = { - ...(device.appVersionInfo || {}), - ...(input.appVersionName !== undefined && { versionName: input.appVersionName }), - ...(input.appVersionCode !== undefined && { versionCode: input.appVersionCode }), - lastUpdated: now, + 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 = { - ...(device.deviceUptimeInfo || {}), - uptimeMillis: input.deviceUptimeMillis, - lastUpdated: now, - } + updateData['deviceUptimeInfo.uptimeMillis'] = input.deviceUptimeMillis + updateData['deviceUptimeInfo.lastUpdated'] = now } // Update memoryInfo if any memory field provided @@ -1033,13 +1029,16 @@ const updatedSms = await this.smsModel.findByIdAndUpdate( input.memoryTotalBytes !== undefined || input.memoryMaxBytes !== undefined ) { - updateData.memoryInfo = { - ...(device.memoryInfo || {}), - ...(input.memoryFreeBytes !== undefined && { freeBytes: input.memoryFreeBytes }), - ...(input.memoryTotalBytes !== undefined && { totalBytes: input.memoryTotalBytes }), - ...(input.memoryMaxBytes !== undefined && { maxBytes: input.memoryMaxBytes }), - lastUpdated: now, + if (input.memoryFreeBytes !== undefined) { + updateData['memoryInfo.freeBytes'] = input.memoryFreeBytes } + if (input.memoryTotalBytes !== undefined) { + updateData['memoryInfo.totalBytes'] = input.memoryTotalBytes + } + if (input.memoryMaxBytes !== undefined) { + updateData['memoryInfo.maxBytes'] = input.memoryMaxBytes + } + updateData['memoryInfo.lastUpdated'] = now } // Update storageInfo if any storage field provided @@ -1047,22 +1046,24 @@ const updatedSms = await this.smsModel.findByIdAndUpdate( input.storageAvailableBytes !== undefined || input.storageTotalBytes !== undefined ) { - updateData.storageInfo = { - ...(device.storageInfo || {}), - ...(input.storageAvailableBytes !== undefined && { availableBytes: input.storageAvailableBytes }), - ...(input.storageTotalBytes !== undefined && { totalBytes: input.storageTotalBytes }), - lastUpdated: now, + if (input.storageAvailableBytes !== undefined) { + updateData['storageInfo.availableBytes'] = input.storageAvailableBytes } + if (input.storageTotalBytes !== undefined) { + updateData['storageInfo.totalBytes'] = input.storageTotalBytes + } + updateData['storageInfo.lastUpdated'] = now } // Update systemInfo if timezone or locale provided if (input.timezone !== undefined || input.locale !== undefined) { - updateData.systemInfo = { - ...(device.systemInfo || {}), - ...(input.timezone !== undefined && { timezone: input.timezone }), - ...(input.locale !== undefined && { locale: input.locale }), - lastUpdated: now, + 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 diff --git a/api/src/gateway/schemas/device.schema.ts b/api/src/gateway/schemas/device.schema.ts index 454b57c..46ad1dd 100644 --- a/api/src/gateway/schemas/device.schema.ts +++ b/api/src/gateway/schemas/device.schema.ts @@ -77,12 +77,12 @@ export class Device { @Prop({ type: { - type: String, + networkType: String, lastUpdated: Date, }, }) networkInfo: { - type?: 'wifi' | 'cellular' | 'none' + networkType?: 'wifi' | 'cellular' | 'none' lastUpdated?: Date } From 4b4f3bace9e611f71289d5c2401499527e09867b Mon Sep 17 00:00:00 2001 From: isra el Date: Wed, 28 Jan 2026 18:41:14 +0300 Subject: [PATCH 07/20] fix(android): fix heartbeat background work issue --- .../com/vernu/sms/SMSGatewayApplication.java | 14 ++++++++ .../vernu/sms/helpers/HeartbeatManager.java | 32 +++++++++++++------ 2 files changed, 37 insertions(+), 9 deletions(-) diff --git a/android/app/src/main/java/com/vernu/sms/SMSGatewayApplication.java b/android/app/src/main/java/com/vernu/sms/SMSGatewayApplication.java index 52fa912..7db4698 100644 --- a/android/app/src/main/java/com/vernu/sms/SMSGatewayApplication.java +++ b/android/app/src/main/java/com/vernu/sms/SMSGatewayApplication.java @@ -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 diff --git a/android/app/src/main/java/com/vernu/sms/helpers/HeartbeatManager.java b/android/app/src/main/java/com/vernu/sms/helpers/HeartbeatManager.java index 6e230a5..d017a72 100644 --- a/android/app/src/main/java/com/vernu/sms/helpers/HeartbeatManager.java +++ b/android/app/src/main/java/com/vernu/sms/helpers/HeartbeatManager.java @@ -4,6 +4,7 @@ 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; @@ -16,11 +17,15 @@ 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( - context, + appContext, AppConstants.SHARED_PREFS_HEARTBEAT_INTERVAL_MINUTES_KEY, 30 ); @@ -33,9 +38,6 @@ public class HeartbeatManager { Log.d(TAG, "Scheduling heartbeat with interval: " + intervalMinutes + " minutes"); - // Cancel any existing heartbeat work - cancelHeartbeat(context); - // Create constraints Constraints constraints = new Constraints.Builder() .setRequiredNetworkType(NetworkType.CONNECTED) @@ -51,16 +53,28 @@ public class HeartbeatManager { .addTag(AppConstants.HEARTBEAT_WORK_TAG) .build(); - // Enqueue the work - WorkManager.getInstance(context) - .enqueue(heartbeatWork); + // 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"); + 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"); - WorkManager.getInstance(context) + 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); } From a6df612bfaf550206ac373945f5eb94c43f1c2f4 Mon Sep 17 00:00:00 2001 From: isra el Date: Thu, 29 Jan 2026 18:37:44 +0300 Subject: [PATCH 08/20] feat(api): implement delayed sms sending --- api/src/gateway/gateway.dto.ts | 8 ++ api/src/gateway/gateway.service.ts | 113 +++++++++++++++++++-- api/src/gateway/queue/sms-queue.service.ts | 8 +- 3 files changed, 119 insertions(+), 10 deletions(-) diff --git a/api/src/gateway/gateway.dto.ts b/api/src/gateway/gateway.dto.ts index 29cbc2e..ad7c3e4 100644 --- a/api/src/gateway/gateway.dto.ts +++ b/api/src/gateway/gateway.dto.ts @@ -101,6 +101,14 @@ export class SMSData { }) 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 diff --git a/api/src/gateway/gateway.service.ts b/api/src/gateway/gateway.service.ts index 826d622..2aa11b9 100644 --- a/api/src/gateway/gateway.service.ts +++ b/api/src/gateway/gateway.service.ts @@ -126,6 +126,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 { const device = await this.deviceModel.findById(deviceId) @@ -162,6 +211,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', @@ -247,6 +310,7 @@ export class GatewayService { deviceId, fcmMessages, smsBatch._id.toString(), + delayMs, ) return { @@ -367,6 +431,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({ @@ -381,7 +457,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 @@ -395,6 +472,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({ @@ -433,19 +513,32 @@ export class GatewayService { 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() + 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, @@ -460,7 +553,7 @@ export class GatewayService { status: 'failed', error: e.message, successCount: 0, - failureCount: fcmMessages.length, + failureCount: fcmMessagesWithDelays.length, }, }) @@ -481,6 +574,8 @@ export class GatewayService { } } + // For non-queue path, convert back to simple array + const fcmMessages = fcmMessagesWithDelays.map(({ message }) => message) const fcmMessagesBatches = fcmMessages.map((m) => [m]) const fcmResponses: BatchResponse[] = [] diff --git a/api/src/gateway/queue/sms-queue.service.ts b/api/src/gateway/queue/sms-queue.service.ts index 206f2c3..608e9b4 100644 --- a/api/src/gateway/queue/sms-queue.service.ts +++ b/api/src/gateway/queue/sms-queue.service.ts @@ -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 From ede8e4c210607069f2fd301b9c6033a7ff6826d5 Mon Sep 17 00:00:00 2001 From: isra el Date: Thu, 29 Jan 2026 18:47:51 +0300 Subject: [PATCH 09/20] chore: add fallback heartbeat check using fcm --- .../vernu/sms/helpers/HeartbeatHelper.java | 209 ++++++++++++++++++ .../com/vernu/sms/services/FCMService.java | 52 ++++- .../vernu/sms/workers/HeartbeatWorker.java | 186 ++-------------- api/src/gateway/gateway.module.ts | 3 +- api/src/gateway/tasks/heartbeat-check.task.ts | 97 ++++++++ 5 files changed, 373 insertions(+), 174 deletions(-) create mode 100644 android/app/src/main/java/com/vernu/sms/helpers/HeartbeatHelper.java create mode 100644 api/src/gateway/tasks/heartbeat-check.task.ts diff --git a/android/app/src/main/java/com/vernu/sms/helpers/HeartbeatHelper.java b/android/app/src/main/java/com/vernu/sms/helpers/HeartbeatHelper.java new file mode 100644 index 0000000..e927ffe --- /dev/null +++ b/android/app/src/main/java/com/vernu/sms/helpers/HeartbeatHelper.java @@ -0,0 +1,209 @@ +package com.vernu.sms.helpers; + +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.net.ConnectivityManager; +import android.net.NetworkInfo; +import android.os.BatteryManager; +import android.os.StatFs; +import android.os.SystemClock; +import android.util.Log; + +import com.google.firebase.messaging.FirebaseMessaging; +import com.vernu.sms.ApiManager; +import com.vernu.sms.AppConstants; +import com.vernu.sms.BuildConfig; +import com.vernu.sms.dtos.HeartbeatInputDTO; +import com.vernu.sms.dtos.HeartbeatResponseDTO; +import com.vernu.sms.dtos.SimInfoCollectionDTO; +import com.vernu.sms.TextBeeUtils; + +import java.io.File; +import java.io.IOException; +import java.util.Locale; +import java.util.TimeZone; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import retrofit2.Call; +import retrofit2.Response; + +public class HeartbeatHelper { + private static final String TAG = "HeartbeatHelper"; + + /** + * Collects device information and sends a heartbeat request to the API. + * + * @param context Application context + * @param deviceId Device ID + * @param apiKey API key for authentication + * @return true if heartbeat was sent successfully, false otherwise + */ + public static boolean sendHeartbeat(Context context, String deviceId, String apiKey) { + if (deviceId == null || deviceId.isEmpty()) { + Log.d(TAG, "Device not registered, skipping heartbeat"); + return false; + } + + if (apiKey == null || apiKey.isEmpty()) { + Log.e(TAG, "API key not available, skipping heartbeat"); + return false; + } + + // Collect device information + HeartbeatInputDTO heartbeatInput = new HeartbeatInputDTO(); + + try { + // Get FCM token (blocking wait) + try { + CountDownLatch latch = new CountDownLatch(1); + final String[] fcmToken = new String[1]; + FirebaseMessaging.getInstance().getToken().addOnCompleteListener(task -> { + if (task.isSuccessful()) { + fcmToken[0] = task.getResult(); + } + latch.countDown(); + }); + if (latch.await(5, TimeUnit.SECONDS) && fcmToken[0] != null) { + heartbeatInput.setFcmToken(fcmToken[0]); + } + } catch (Exception e) { + Log.e(TAG, "Failed to get FCM token: " + e.getMessage()); + // Continue without FCM token + } + + // Get battery information + IntentFilter ifilter = new IntentFilter(Intent.ACTION_BATTERY_CHANGED); + Intent batteryStatus = context.registerReceiver(null, ifilter); + if (batteryStatus != null) { + int level = batteryStatus.getIntExtra(BatteryManager.EXTRA_LEVEL, -1); + int scale = batteryStatus.getIntExtra(BatteryManager.EXTRA_SCALE, -1); + int batteryPct = (int) ((level / (float) scale) * 100); + heartbeatInput.setBatteryPercentage(batteryPct); + + int status = batteryStatus.getIntExtra(BatteryManager.EXTRA_STATUS, -1); + boolean isCharging = status == BatteryManager.BATTERY_STATUS_CHARGING || + status == BatteryManager.BATTERY_STATUS_FULL; + heartbeatInput.setIsCharging(isCharging); + } + + // Get network type + ConnectivityManager cm = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); + if (cm != null) { + NetworkInfo activeNetwork = cm.getActiveNetworkInfo(); + if (activeNetwork != null && activeNetwork.isConnected()) { + if (activeNetwork.getType() == ConnectivityManager.TYPE_WIFI) { + heartbeatInput.setNetworkType("wifi"); + } else if (activeNetwork.getType() == ConnectivityManager.TYPE_MOBILE) { + heartbeatInput.setNetworkType("cellular"); + } else { + heartbeatInput.setNetworkType("none"); + } + } else { + heartbeatInput.setNetworkType("none"); + } + } + + // Get app version + heartbeatInput.setAppVersionName(BuildConfig.VERSION_NAME); + heartbeatInput.setAppVersionCode(BuildConfig.VERSION_CODE); + + // Get device uptime + heartbeatInput.setDeviceUptimeMillis(SystemClock.uptimeMillis()); + + // Get memory information + Runtime runtime = Runtime.getRuntime(); + heartbeatInput.setMemoryFreeBytes(runtime.freeMemory()); + heartbeatInput.setMemoryTotalBytes(runtime.totalMemory()); + heartbeatInput.setMemoryMaxBytes(runtime.maxMemory()); + + // Get storage information + File internalStorage = context.getFilesDir(); + StatFs statFs = new StatFs(internalStorage.getPath()); + long availableBytes = statFs.getAvailableBytes(); + long totalBytes = statFs.getTotalBytes(); + heartbeatInput.setStorageAvailableBytes(availableBytes); + heartbeatInput.setStorageTotalBytes(totalBytes); + + // Get system information + heartbeatInput.setTimezone(TimeZone.getDefault().getID()); + heartbeatInput.setLocale(Locale.getDefault().toString()); + + // Get receive SMS enabled status + boolean receiveSMSEnabled = SharedPreferenceHelper.getSharedPreferenceBoolean( + context, + AppConstants.SHARED_PREFS_RECEIVE_SMS_ENABLED_KEY, + false + ); + heartbeatInput.setReceiveSMSEnabled(receiveSMSEnabled); + + // Collect SIM information + SimInfoCollectionDTO simInfoCollection = new SimInfoCollectionDTO(); + simInfoCollection.setLastUpdated(System.currentTimeMillis()); + simInfoCollection.setSims(TextBeeUtils.collectSimInfo(context)); + heartbeatInput.setSimInfo(simInfoCollection); + + // Send heartbeat request + Call call = ApiManager.getApiService().heartbeat(deviceId, apiKey, heartbeatInput); + Response response = call.execute(); + + if (response.isSuccessful() && response.body() != null) { + HeartbeatResponseDTO responseBody = response.body(); + if (responseBody.fcmTokenUpdated) { + Log.d(TAG, "FCM token was updated during heartbeat"); + } + Log.d(TAG, "Heartbeat sent successfully"); + return true; + } else { + Log.e(TAG, "Failed to send heartbeat. Response code: " + (response.code())); + return false; + } + } catch (IOException e) { + Log.e(TAG, "Heartbeat API call failed: " + e.getMessage()); + return false; + } catch (Exception e) { + Log.e(TAG, "Error collecting device information: " + e.getMessage()); + return false; + } + } + + /** + * Checks if device is eligible to send heartbeat (registered, enabled, heartbeat enabled). + * + * @param context Application context + * @return true if device is eligible, false otherwise + */ + public static boolean isDeviceEligibleForHeartbeat(Context context) { + // Check if device is registered + String deviceId = SharedPreferenceHelper.getSharedPreferenceString( + context, + AppConstants.SHARED_PREFS_DEVICE_ID_KEY, + "" + ); + + if (deviceId.isEmpty()) { + return false; + } + + // Check if device is enabled + boolean deviceEnabled = SharedPreferenceHelper.getSharedPreferenceBoolean( + context, + AppConstants.SHARED_PREFS_GATEWAY_ENABLED_KEY, + false + ); + + if (!deviceEnabled) { + return false; + } + + // Check if heartbeat feature is enabled + boolean heartbeatEnabled = SharedPreferenceHelper.getSharedPreferenceBoolean( + context, + AppConstants.SHARED_PREFS_HEARTBEAT_ENABLED_KEY, + true // Default to true + ); + + return heartbeatEnabled; + } +} diff --git a/android/app/src/main/java/com/vernu/sms/services/FCMService.java b/android/app/src/main/java/com/vernu/sms/services/FCMService.java index 8fb9d03..c923d31 100644 --- a/android/app/src/main/java/com/vernu/sms/services/FCMService.java +++ b/android/app/src/main/java/com/vernu/sms/services/FCMService.java @@ -18,6 +18,8 @@ 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; @@ -37,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); @@ -55,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 */ diff --git a/android/app/src/main/java/com/vernu/sms/workers/HeartbeatWorker.java b/android/app/src/main/java/com/vernu/sms/workers/HeartbeatWorker.java index 095d8cc..6fb2c02 100644 --- a/android/app/src/main/java/com/vernu/sms/workers/HeartbeatWorker.java +++ b/android/app/src/main/java/com/vernu/sms/workers/HeartbeatWorker.java @@ -1,39 +1,15 @@ package com.vernu.sms.workers; 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.Build; -import android.os.StatFs; -import android.os.SystemClock; import android.util.Log; import androidx.annotation.NonNull; import androidx.work.Worker; import androidx.work.WorkerParameters; -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.helpers.SharedPreferenceHelper; -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; +import com.vernu.sms.helpers.HeartbeatHelper; public class HeartbeatWorker extends Worker { private static final String TAG = "HeartbeatWorker"; @@ -47,166 +23,32 @@ public class HeartbeatWorker extends Worker { public Result doWork() { Context context = getApplicationContext(); - // Check if device is registered + // 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, "" ); - if (deviceId.isEmpty()) { - Log.d(TAG, "Device not registered, skipping heartbeat"); - return Result.success(); // Not a failure, just skip - } - - // Check if device is enabled - boolean deviceEnabled = SharedPreferenceHelper.getSharedPreferenceBoolean( - context, - AppConstants.SHARED_PREFS_GATEWAY_ENABLED_KEY, - false - ); - - if (!deviceEnabled) { - Log.d(TAG, "Device not enabled, skipping heartbeat"); - return Result.success(); // Not a failure, just skip - } - - // Check if heartbeat feature is enabled - boolean heartbeatEnabled = SharedPreferenceHelper.getSharedPreferenceBoolean( - context, - AppConstants.SHARED_PREFS_HEARTBEAT_ENABLED_KEY, - true // Default to true - ); - - if (!heartbeatEnabled) { - Log.d(TAG, "Heartbeat feature disabled, skipping heartbeat"); - return Result.success(); // Not a failure, just skip - } - String apiKey = SharedPreferenceHelper.getSharedPreferenceString( context, AppConstants.SHARED_PREFS_API_KEY_KEY, "" ); - if (apiKey.isEmpty()) { - Log.e(TAG, "API key not available, skipping heartbeat"); - return Result.success(); // Not a failure, just skip - } + // Send heartbeat using shared helper + boolean success = HeartbeatHelper.sendHeartbeat(context, deviceId, apiKey); - // Collect device information - HeartbeatInputDTO heartbeatInput = new HeartbeatInputDTO(); - - try { - // Get FCM token (blocking wait) - try { - CountDownLatch latch = new CountDownLatch(1); - final String[] fcmToken = new String[1]; - FirebaseMessaging.getInstance().getToken().addOnCompleteListener(task -> { - if (task.isSuccessful()) { - fcmToken[0] = task.getResult(); - } - latch.countDown(); - }); - if (latch.await(5, TimeUnit.SECONDS) && fcmToken[0] != null) { - heartbeatInput.setFcmToken(fcmToken[0]); - } - } catch (Exception e) { - Log.e(TAG, "Failed to get FCM token: " + e.getMessage()); - // Continue without FCM token - } - - // Get battery information - IntentFilter ifilter = new IntentFilter(Intent.ACTION_BATTERY_CHANGED); - Intent batteryStatus = context.registerReceiver(null, ifilter); - if (batteryStatus != null) { - int level = batteryStatus.getIntExtra(BatteryManager.EXTRA_LEVEL, -1); - int scale = batteryStatus.getIntExtra(BatteryManager.EXTRA_SCALE, -1); - int batteryPct = (int) ((level / (float) scale) * 100); - heartbeatInput.setBatteryPercentage(batteryPct); - - int status = batteryStatus.getIntExtra(BatteryManager.EXTRA_STATUS, -1); - boolean isCharging = status == BatteryManager.BATTERY_STATUS_CHARGING || - status == BatteryManager.BATTERY_STATUS_FULL; - heartbeatInput.setIsCharging(isCharging); - } - - // Get network type - ConnectivityManager cm = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); - if (cm != null) { - NetworkInfo activeNetwork = cm.getActiveNetworkInfo(); - if (activeNetwork != null && activeNetwork.isConnected()) { - if (activeNetwork.getType() == ConnectivityManager.TYPE_WIFI) { - heartbeatInput.setNetworkType("wifi"); - } else if (activeNetwork.getType() == ConnectivityManager.TYPE_MOBILE) { - heartbeatInput.setNetworkType("cellular"); - } else { - heartbeatInput.setNetworkType("none"); - } - } else { - heartbeatInput.setNetworkType("none"); - } - } - - // Get app version - heartbeatInput.setAppVersionName(BuildConfig.VERSION_NAME); - heartbeatInput.setAppVersionCode(BuildConfig.VERSION_CODE); - - // Get device uptime - heartbeatInput.setDeviceUptimeMillis(SystemClock.uptimeMillis()); - - // Get memory information - Runtime runtime = Runtime.getRuntime(); - heartbeatInput.setMemoryFreeBytes(runtime.freeMemory()); - heartbeatInput.setMemoryTotalBytes(runtime.totalMemory()); - heartbeatInput.setMemoryMaxBytes(runtime.maxMemory()); - - // Get storage information - File internalStorage = context.getFilesDir(); - StatFs statFs = new StatFs(internalStorage.getPath()); - long availableBytes = statFs.getAvailableBytes(); - long totalBytes = statFs.getTotalBytes(); - heartbeatInput.setStorageAvailableBytes(availableBytes); - heartbeatInput.setStorageTotalBytes(totalBytes); - - // Get system information - heartbeatInput.setTimezone(TimeZone.getDefault().getID()); - heartbeatInput.setLocale(Locale.getDefault().toString()); - - // Get receive SMS enabled status - boolean receiveSMSEnabled = SharedPreferenceHelper.getSharedPreferenceBoolean( - context, - AppConstants.SHARED_PREFS_RECEIVE_SMS_ENABLED_KEY, - false - ); - heartbeatInput.setReceiveSMSEnabled(receiveSMSEnabled); - - // Collect SIM information - SimInfoCollectionDTO simInfoCollection = new SimInfoCollectionDTO(); - simInfoCollection.setLastUpdated(System.currentTimeMillis()); - simInfoCollection.setSims(TextBeeUtils.collectSimInfo(context)); - heartbeatInput.setSimInfo(simInfoCollection); - - // Send heartbeat request - Call call = ApiManager.getApiService().heartbeat(deviceId, apiKey, heartbeatInput); - Response response = call.execute(); - - if (response.isSuccessful() && response.body() != null) { - HeartbeatResponseDTO responseBody = response.body(); - if (responseBody.fcmTokenUpdated) { - Log.d(TAG, "FCM token was updated during heartbeat"); - } - Log.d(TAG, "Heartbeat sent successfully"); - return Result.success(); - } else { - Log.e(TAG, "Failed to send heartbeat. Response code: " + (response.code())); - return Result.retry(); - } - } catch (IOException e) { - Log.e(TAG, "Heartbeat API call failed: " + e.getMessage()); - return Result.retry(); - } catch (Exception e) { - Log.e(TAG, "Error collecting device information: " + e.getMessage()); + if (success) { + return Result.success(); + } else { + Log.e(TAG, "Failed to send heartbeat, will retry"); return Result.retry(); } } diff --git a/api/src/gateway/gateway.module.ts b/api/src/gateway/gateway.module.ts index 7a38af2..93f408f 100644 --- a/api/src/gateway/gateway.module.ts +++ b/api/src/gateway/gateway.module.ts @@ -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 {} diff --git a/api/src/gateway/tasks/heartbeat-check.task.ts b/api/src/gateway/tasks/heartbeat-check.task.ts new file mode 100644 index 0000000..435c858 --- /dev/null +++ b/api/src/gateway/tasks/heartbeat-check.task.ts @@ -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, + ) {} + + /** + * 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) + } + } +} From 312e3a4fd4467ecfc2085bb265665206d184fc9b Mon Sep 17 00:00:00 2001 From: isra el Date: Thu, 29 Jan 2026 19:16:25 +0300 Subject: [PATCH 10/20] chore(api): remove unnecessary fields from heartbeat data --- api/src/gateway/gateway.service.ts | 32 ------------------------------ 1 file changed, 32 deletions(-) diff --git a/api/src/gateway/gateway.service.ts b/api/src/gateway/gateway.service.ts index 2aa11b9..a34480e 100644 --- a/api/src/gateway/gateway.service.ts +++ b/api/src/gateway/gateway.service.ts @@ -1118,38 +1118,6 @@ const updatedSms = await this.smsModel.findByIdAndUpdate( updateData['deviceUptimeInfo.lastUpdated'] = now } - // Update memoryInfo if any memory field provided - if ( - input.memoryFreeBytes !== undefined || - input.memoryTotalBytes !== undefined || - input.memoryMaxBytes !== undefined - ) { - if (input.memoryFreeBytes !== undefined) { - updateData['memoryInfo.freeBytes'] = input.memoryFreeBytes - } - if (input.memoryTotalBytes !== undefined) { - updateData['memoryInfo.totalBytes'] = input.memoryTotalBytes - } - if (input.memoryMaxBytes !== undefined) { - updateData['memoryInfo.maxBytes'] = input.memoryMaxBytes - } - updateData['memoryInfo.lastUpdated'] = now - } - - // Update storageInfo if any storage field provided - if ( - input.storageAvailableBytes !== undefined || - input.storageTotalBytes !== undefined - ) { - if (input.storageAvailableBytes !== undefined) { - updateData['storageInfo.availableBytes'] = input.storageAvailableBytes - } - if (input.storageTotalBytes !== undefined) { - updateData['storageInfo.totalBytes'] = input.storageTotalBytes - } - updateData['storageInfo.lastUpdated'] = now - } - // Update systemInfo if timezone or locale provided if (input.timezone !== undefined || input.locale !== undefined) { if (input.timezone !== undefined) { From b6dde1a096ca34fc59c7bfd32a49ed036c11560f Mon Sep 17 00:00:00 2001 From: isra el Date: Thu, 29 Jan 2026 19:25:14 +0300 Subject: [PATCH 11/20] fix failing test --- api/src/gateway/gateway.service.spec.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/api/src/gateway/gateway.service.spec.ts b/api/src/gateway/gateway.service.spec.ts index 71011ad..a50732e 100644 --- a/api/src/gateway/gateway.service.spec.ts +++ b/api/src/gateway/gateway.service.spec.ts @@ -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), From 32b463a10c4390bd81f2acc865018c417b06d177 Mon Sep 17 00:00:00 2001 From: isra el Date: Thu, 5 Feb 2026 21:27:18 +0300 Subject: [PATCH 12/20] ui(android): enhance app ui --- .../app/src/main/res/layout/activity_main.xml | 177 +++++++++++------- .../app/src/main/res/values-night/themes.xml | 16 +- android/app/src/main/res/values/themes.xml | 16 +- 3 files changed, 125 insertions(+), 84 deletions(-) diff --git a/android/app/src/main/res/layout/activity_main.xml b/android/app/src/main/res/layout/activity_main.xml index f756ab0..18c1948 100644 --- a/android/app/src/main/res/layout/activity_main.xml +++ b/android/app/src/main/res/layout/activity_main.xml @@ -20,7 +20,7 @@ android:layout_height="wrap_content" android:background="?attr/colorPrimary" android:orientation="vertical" - android:padding="24dp"> + android:padding="28dp"> + android:textSize="24sp" + android:textStyle="bold" + android:letterSpacing="0.02" + android:shadowColor="#80000000" + android:shadowDx="0" + android:shadowDy="2" + android:shadowRadius="4" /> + android:textSize="13sp" + android:letterSpacing="0.01" + android:alpha="0.95" /> @@ -50,21 +57,21 @@ android:layout_marginTop="-24dp" android:layout_marginBottom="16dp" app:cardBackgroundColor="@color/background_secondary" - app:cardCornerRadius="8dp" - app:cardElevation="4dp"> + app:cardCornerRadius="12dp" + app:cardElevation="6dp"> + android:padding="12dp"> @@ -72,7 +79,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 +116,11 @@ @@ -163,23 +170,24 @@ android:layout_marginHorizontal="16dp" android:layout_marginBottom="16dp" app:cardBackgroundColor="@color/background_secondary" - app:cardCornerRadius="8dp" - app:cardElevation="2dp"> + app:cardCornerRadius="12dp" + app:cardElevation="4dp"> + android:padding="20dp"> + android:textSize="19sp" + android:textStyle="bold" + android:letterSpacing="0.01" /> + app:hintTextColor="?attr/colorPrimary" + app:endIconMode="password_toggle" + app:endIconTint="?attr/colorPrimary"> @@ -247,9 +257,14 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:backgroundTint="?attr/colorPrimary" - android:paddingHorizontal="24dp" + android:paddingHorizontal="28dp" + android:paddingVertical="12dp" android:text="Connect" - android:textColor="@color/white" /> + android:textColor="@color/white" + android:textSize="15sp" + android:letterSpacing="0.01" + style="@style/Widget.MaterialComponents.Button" + app:cornerRadius="8dp" /> + android:textColor="?attr/colorPrimary" + android:textSize="15sp" + android:letterSpacing="0.01" + android:backgroundTint="@android:color/transparent" + style="@style/Widget.MaterialComponents.Button.OutlinedButton" + app:cornerRadius="8dp" + app:strokeColor="?attr/colorPrimary" + app:strokeWidth="1.5dp" /> @@ -276,23 +300,24 @@ android:layout_marginHorizontal="16dp" android:layout_marginBottom="16dp" app:cardBackgroundColor="@color/background_secondary" - app:cardCornerRadius="8dp" - app:cardElevation="2dp"> + app:cardCornerRadius="12dp" + app:cardElevation="4dp"> + android:padding="20dp"> + android:textSize="19sp" + android:textStyle="bold" + android:letterSpacing="0.01" /> + android:textSize="15sp" + android:letterSpacing="0.01" + android:visibility="visible" + style="@style/Widget.MaterialComponents.Button" + app:cornerRadius="8dp" /> + android:alpha="0.6" + android:layout_marginBottom="18dp" /> + android:layout_marginEnd="6dp" /> + android:alpha="0.6" + android:layout_marginBottom="18dp" /> + app:cardCornerRadius="12dp" + app:cardElevation="4dp"> + android:padding="20dp"> + android:textSize="19sp" + android:textStyle="bold" + android:letterSpacing="0.01" /> + android:layout_marginBottom="10dp" + android:gravity="center_vertical"> + android:layout_marginBottom="10dp" + android:gravity="center_vertical"> + android:orientation="horizontal" + android:gravity="center_vertical"> + app:cardCornerRadius="12dp" + app:cardElevation="4dp"> + android:padding="20dp"> + android:padding="4dp" + android:layout_marginEnd="14dp" />