chore: add fallback heartbeat check using fcm

This commit is contained in:
isra el
2026-01-29 18:47:51 +03:00
parent a6df612bfa
commit ede8e4c210
5 changed files with 373 additions and 174 deletions

View File

@@ -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<HeartbeatResponseDTO> call = ApiManager.getApiService().heartbeat(deviceId, apiKey, heartbeatInput);
Response<HeartbeatResponseDTO> response = call.execute();
if (response.isSuccessful() && response.body() != null) {
HeartbeatResponseDTO responseBody = response.body();
if (responseBody.fcmTokenUpdated) {
Log.d(TAG, "FCM token was updated during heartbeat");
}
Log.d(TAG, "Heartbeat sent successfully");
return true;
} else {
Log.e(TAG, "Failed to send heartbeat. Response code: " + (response.code()));
return false;
}
} catch (IOException e) {
Log.e(TAG, "Heartbeat API call failed: " + e.getMessage());
return false;
} catch (Exception e) {
Log.e(TAG, "Error collecting device information: " + e.getMessage());
return false;
}
}
/**
* Checks if device is eligible to send heartbeat (registered, enabled, heartbeat enabled).
*
* @param context Application context
* @return true if device is eligible, false otherwise
*/
public static boolean isDeviceEligibleForHeartbeat(Context context) {
// Check if device is registered
String deviceId = SharedPreferenceHelper.getSharedPreferenceString(
context,
AppConstants.SHARED_PREFS_DEVICE_ID_KEY,
""
);
if (deviceId.isEmpty()) {
return false;
}
// Check if device is enabled
boolean deviceEnabled = SharedPreferenceHelper.getSharedPreferenceBoolean(
context,
AppConstants.SHARED_PREFS_GATEWAY_ENABLED_KEY,
false
);
if (!deviceEnabled) {
return false;
}
// Check if heartbeat feature is enabled
boolean heartbeatEnabled = SharedPreferenceHelper.getSharedPreferenceBoolean(
context,
AppConstants.SHARED_PREFS_HEARTBEAT_ENABLED_KEY,
true // Default to true
);
return heartbeatEnabled;
}
}

View File

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

View File

@@ -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<HeartbeatResponseDTO> call = ApiManager.getApiService().heartbeat(deviceId, apiKey, heartbeatInput);
Response<HeartbeatResponseDTO> response = call.execute();
if (response.isSuccessful() && response.body() != null) {
HeartbeatResponseDTO responseBody = response.body();
if (responseBody.fcmTokenUpdated) {
Log.d(TAG, "FCM token was updated during heartbeat");
}
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();
}
}

View File

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

View File

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