feat(android): implement heartbeat check

This commit is contained in:
isra el
2026-01-27 17:38:15 +03:00
parent 541f32406e
commit dc8ca9d9a7
9 changed files with 517 additions and 0 deletions

View File

@@ -15,6 +15,7 @@
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application
android:name=".SMSGatewayApplication"
android:allowBackup="true"

View File

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

View File

@@ -32,6 +32,7 @@ import com.vernu.sms.dtos.RegisterDeviceInputDTO;
import com.vernu.sms.dtos.RegisterDeviceResponseDTO;
import com.vernu.sms.helpers.SharedPreferenceHelper;
import com.vernu.sms.helpers.VersionTracker;
import com.vernu.sms.helpers.HeartbeatManager;
import com.google.firebase.crashlytics.FirebaseCrashlytics;
import java.util.Arrays;
import java.util.Objects;
@@ -107,6 +108,12 @@ public class MainActivity extends AppCompatActivity {
Log.d(TAG, "Starting sticky notification service on app start");
}
// Schedule heartbeat if device is enabled and registered
if (gatewayEnabled && !deviceId.isEmpty()) {
HeartbeatManager.scheduleHeartbeat(mContext);
Log.d(TAG, "Scheduling heartbeat on app start");
}
if (deviceId == null || deviceId.isEmpty()) {
registerDeviceBtn.setText("Register");
} else {
@@ -164,8 +171,12 @@ public class MainActivity extends AppCompatActivity {
if (SharedPreferenceHelper.getSharedPreferenceBoolean(mContext, AppConstants.SHARED_PREFS_STICKY_NOTIFICATION_ENABLED_KEY, false)) {
TextBeeUtils.startStickyNotificationService(mContext);
}
// Schedule heartbeat
HeartbeatManager.scheduleHeartbeat(mContext);
} else {
TextBeeUtils.stopStickyNotificationService(mContext);
// Cancel heartbeat
HeartbeatManager.cancelHeartbeat(mContext);
}
compoundButton.setEnabled(true);
}
@@ -389,6 +400,21 @@ public class MainActivity extends AppCompatActivity {
SharedPreferenceHelper.setSharedPreferenceString(mContext, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, deviceId);
SharedPreferenceHelper.setSharedPreferenceBoolean(mContext, AppConstants.SHARED_PREFS_GATEWAY_ENABLED_KEY, registerDeviceInput.isEnabled());
gatewaySwitch.setChecked(registerDeviceInput.isEnabled());
// Sync heartbeatIntervalMinutes from server response
if (response.body().data.get("heartbeatIntervalMinutes") != null) {
Object intervalObj = response.body().data.get("heartbeatIntervalMinutes");
if (intervalObj instanceof Number) {
int intervalMinutes = ((Number) intervalObj).intValue();
SharedPreferenceHelper.setSharedPreferenceInt(mContext, AppConstants.SHARED_PREFS_HEARTBEAT_INTERVAL_MINUTES_KEY, intervalMinutes);
Log.d(TAG, "Synced heartbeat interval from server: " + intervalMinutes + " minutes");
}
}
// Schedule heartbeat if device is enabled
if (registerDeviceInput.isEnabled()) {
HeartbeatManager.scheduleHeartbeat(mContext);
}
}
// Update stored version information
@@ -432,6 +458,21 @@ public class MainActivity extends AppCompatActivity {
SharedPreferenceHelper.setSharedPreferenceString(mContext, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, deviceId);
SharedPreferenceHelper.setSharedPreferenceBoolean(mContext, AppConstants.SHARED_PREFS_GATEWAY_ENABLED_KEY, registerDeviceInput.isEnabled());
gatewaySwitch.setChecked(registerDeviceInput.isEnabled());
// Sync heartbeatIntervalMinutes from server response
if (response.body().data.get("heartbeatIntervalMinutes") != null) {
Object intervalObj = response.body().data.get("heartbeatIntervalMinutes");
if (intervalObj instanceof Number) {
int intervalMinutes = ((Number) intervalObj).intValue();
SharedPreferenceHelper.setSharedPreferenceInt(mContext, AppConstants.SHARED_PREFS_HEARTBEAT_INTERVAL_MINUTES_KEY, intervalMinutes);
Log.d(TAG, "Synced heartbeat interval from server: " + intervalMinutes + " minutes");
}
}
// Schedule heartbeat if device is enabled
if (registerDeviceInput.isEnabled()) {
HeartbeatManager.scheduleHeartbeat(mContext);
}
}
// Update stored version information
@@ -503,6 +544,21 @@ public class MainActivity extends AppCompatActivity {
SharedPreferenceHelper.setSharedPreferenceString(mContext, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, deviceId);
deviceIdTxt.setText(deviceId);
deviceIdEditText.setText(deviceId);
// Sync heartbeatIntervalMinutes from server response
if (response.body().data.get("heartbeatIntervalMinutes") != null) {
Object intervalObj = response.body().data.get("heartbeatIntervalMinutes");
if (intervalObj instanceof Number) {
int intervalMinutes = ((Number) intervalObj).intValue();
SharedPreferenceHelper.setSharedPreferenceInt(mContext, AppConstants.SHARED_PREFS_HEARTBEAT_INTERVAL_MINUTES_KEY, intervalMinutes);
Log.d(TAG, "Synced heartbeat interval from server: " + intervalMinutes + " minutes");
}
}
// Schedule heartbeat if device is enabled
if (updateDeviceInput.isEnabled()) {
HeartbeatManager.scheduleHeartbeat(mContext);
}
}
// Update stored version information

View File

@@ -0,0 +1,142 @@
package com.vernu.sms.dtos;
public class HeartbeatInputDTO {
private String fcmToken;
private Integer batteryPercentage;
private Boolean isCharging;
private String networkType;
private String appVersionName;
private Integer appVersionCode;
private Long deviceUptimeMillis;
private Long memoryFreeBytes;
private Long memoryTotalBytes;
private Long memoryMaxBytes;
private Long storageAvailableBytes;
private Long storageTotalBytes;
private String timezone;
private String locale;
private Boolean receiveSMSEnabled;
public HeartbeatInputDTO() {
}
public String getFcmToken() {
return fcmToken;
}
public void setFcmToken(String fcmToken) {
this.fcmToken = fcmToken;
}
public Integer getBatteryPercentage() {
return batteryPercentage;
}
public void setBatteryPercentage(Integer batteryPercentage) {
this.batteryPercentage = batteryPercentage;
}
public Boolean getIsCharging() {
return isCharging;
}
public void setIsCharging(Boolean isCharging) {
this.isCharging = isCharging;
}
public String getNetworkType() {
return networkType;
}
public void setNetworkType(String networkType) {
this.networkType = networkType;
}
public String getAppVersionName() {
return appVersionName;
}
public void setAppVersionName(String appVersionName) {
this.appVersionName = appVersionName;
}
public Integer getAppVersionCode() {
return appVersionCode;
}
public void setAppVersionCode(Integer appVersionCode) {
this.appVersionCode = appVersionCode;
}
public Long getDeviceUptimeMillis() {
return deviceUptimeMillis;
}
public void setDeviceUptimeMillis(Long deviceUptimeMillis) {
this.deviceUptimeMillis = deviceUptimeMillis;
}
public Long getMemoryFreeBytes() {
return memoryFreeBytes;
}
public void setMemoryFreeBytes(Long memoryFreeBytes) {
this.memoryFreeBytes = memoryFreeBytes;
}
public Long getMemoryTotalBytes() {
return memoryTotalBytes;
}
public void setMemoryTotalBytes(Long memoryTotalBytes) {
this.memoryTotalBytes = memoryTotalBytes;
}
public Long getMemoryMaxBytes() {
return memoryMaxBytes;
}
public void setMemoryMaxBytes(Long memoryMaxBytes) {
this.memoryMaxBytes = memoryMaxBytes;
}
public Long getStorageAvailableBytes() {
return storageAvailableBytes;
}
public void setStorageAvailableBytes(Long storageAvailableBytes) {
this.storageAvailableBytes = storageAvailableBytes;
}
public Long getStorageTotalBytes() {
return storageTotalBytes;
}
public void setStorageTotalBytes(Long storageTotalBytes) {
this.storageTotalBytes = storageTotalBytes;
}
public String getTimezone() {
return timezone;
}
public void setTimezone(String timezone) {
this.timezone = timezone;
}
public String getLocale() {
return locale;
}
public void setLocale(String locale) {
this.locale = locale;
}
public Boolean getReceiveSMSEnabled() {
return receiveSMSEnabled;
}
public void setReceiveSMSEnabled(Boolean receiveSMSEnabled) {
this.receiveSMSEnabled = receiveSMSEnabled;
}
}

View File

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

View File

@@ -0,0 +1,74 @@
package com.vernu.sms.helpers;
import android.content.Context;
import android.util.Log;
import androidx.work.Constraints;
import androidx.work.NetworkType;
import androidx.work.PeriodicWorkRequest;
import androidx.work.WorkManager;
import com.vernu.sms.AppConstants;
import com.vernu.sms.workers.HeartbeatWorker;
import java.util.concurrent.TimeUnit;
public class HeartbeatManager {
private static final String TAG = "HeartbeatManager";
private static final int MIN_INTERVAL_MINUTES = 15; // Android WorkManager minimum
public static void scheduleHeartbeat(Context context) {
// Get interval from shared preferences (default 30 minutes)
int intervalMinutes = SharedPreferenceHelper.getSharedPreferenceInt(
context,
AppConstants.SHARED_PREFS_HEARTBEAT_INTERVAL_MINUTES_KEY,
30
);
// Enforce minimum interval
if (intervalMinutes < MIN_INTERVAL_MINUTES) {
Log.w(TAG, "Interval " + intervalMinutes + " minutes is less than minimum " + MIN_INTERVAL_MINUTES + " minutes, using minimum");
intervalMinutes = MIN_INTERVAL_MINUTES;
}
Log.d(TAG, "Scheduling heartbeat with interval: " + intervalMinutes + " minutes");
// Cancel any existing heartbeat work
cancelHeartbeat(context);
// Create constraints
Constraints constraints = new Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build();
// Create periodic work request
PeriodicWorkRequest heartbeatWork = new PeriodicWorkRequest.Builder(
HeartbeatWorker.class,
intervalMinutes,
TimeUnit.MINUTES
)
.setConstraints(constraints)
.addTag(AppConstants.HEARTBEAT_WORK_TAG)
.build();
// Enqueue the work
WorkManager.getInstance(context)
.enqueue(heartbeatWork);
Log.d(TAG, "Heartbeat scheduled successfully");
}
public static void cancelHeartbeat(Context context) {
Log.d(TAG, "Cancelling heartbeat work");
WorkManager.getInstance(context)
.cancelAllWorkByTag(AppConstants.HEARTBEAT_WORK_TAG);
}
public static void triggerHeartbeat(Context context) {
// This can be used for testing - trigger immediate heartbeat
Log.d(TAG, "Triggering immediate heartbeat");
// For immediate execution, we could create a OneTimeWorkRequest
// but for now, just reschedule which will run soon
scheduleHeartbeat(context);
}
}

View File

@@ -15,6 +15,7 @@ import com.vernu.sms.TextBeeUtils;
import com.vernu.sms.dtos.RegisterDeviceInputDTO;
import com.vernu.sms.dtos.RegisterDeviceResponseDTO;
import com.vernu.sms.helpers.SharedPreferenceHelper;
import com.vernu.sms.helpers.HeartbeatManager;
import com.vernu.sms.services.StickyNotificationService;
import retrofit2.Call;
@@ -54,6 +55,17 @@ public class BootCompletedReceiver extends BroadcastReceiver {
// Only proceed if both device ID and API key are available
if (!deviceId.isEmpty() && !apiKey.isEmpty()) {
updateDeviceInfo(context, deviceId, apiKey);
// Schedule heartbeat if device is enabled
boolean deviceEnabled = SharedPreferenceHelper.getSharedPreferenceBoolean(
context,
AppConstants.SHARED_PREFS_GATEWAY_ENABLED_KEY,
false
);
if (deviceEnabled) {
Log.i(TAG, "Device booted, scheduling heartbeat");
HeartbeatManager.scheduleHeartbeat(context);
}
}
}
}
@@ -85,6 +97,18 @@ public class BootCompletedReceiver extends BroadcastReceiver {
public void onResponse(Call<RegisterDeviceResponseDTO> call, Response<RegisterDeviceResponseDTO> response) {
if (response.isSuccessful()) {
Log.d(TAG, "Device info updated successfully after boot");
// Sync heartbeatIntervalMinutes from server response
if (response.body() != null && response.body().data != null) {
if (response.body().data.get("heartbeatIntervalMinutes") != null) {
Object intervalObj = response.body().data.get("heartbeatIntervalMinutes");
if (intervalObj instanceof Number) {
int intervalMinutes = ((Number) intervalObj).intValue();
SharedPreferenceHelper.setSharedPreferenceInt(context, AppConstants.SHARED_PREFS_HEARTBEAT_INTERVAL_MINUTES_KEY, intervalMinutes);
Log.d(TAG, "Synced heartbeat interval from server: " + intervalMinutes + " minutes");
}
}
}
} else {
Log.e(TAG, "Failed to update device info after boot. Response code: " + response.code());
}

View File

@@ -4,6 +4,8 @@ import com.vernu.sms.dtos.SMSDTO;
import com.vernu.sms.dtos.SMSForwardResponseDTO;
import com.vernu.sms.dtos.RegisterDeviceInputDTO;
import com.vernu.sms.dtos.RegisterDeviceResponseDTO;
import com.vernu.sms.dtos.HeartbeatInputDTO;
import com.vernu.sms.dtos.HeartbeatResponseDTO;
import retrofit2.Call;
import retrofit2.http.Body;
@@ -24,4 +26,7 @@ public interface GatewayApiService {
@PATCH("gateway/devices/{deviceId}/sms-status")
Call<SMSForwardResponseDTO> updateSMSStatus(@Path("deviceId") String deviceId, @Header("x-api-key") String apiKey, @Body() SMSDTO body);
@POST("gateway/devices/{deviceId}/heartbeat")
Call<HeartbeatResponseDTO> heartbeat(@Path("deviceId") String deviceId, @Header("x-api-key") String apiKey, @Body() HeartbeatInputDTO body);
}

View File

@@ -0,0 +1,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<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());
return Result.retry();
}
}
}