Merge pull request #170 from vernu/feat/simselection

sim selection via api
This commit is contained in:
Israel Abebe
2026-01-29 19:02:06 +03:00
committed by GitHub
13 changed files with 509 additions and 10 deletions

View File

@@ -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<SimInfoDTO> collectSimInfo(Context context) {
List<SimInfoDTO> 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<SubscriptionInfo> 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<SubscriptionInfo> 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;
}
}

View File

@@ -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<RegisterDeviceResponseDTO> apiCall = ApiManager.getApiService().updateDevice(deviceIdToUse, apiKey, updateDeviceInput);
apiCall.enqueue(new Callback<RegisterDeviceResponseDTO>() {
@Override

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,27 @@
package com.vernu.sms.dtos;
import java.util.List;
public class SimInfoCollectionDTO {
private long lastUpdated;
private List<SimInfoDTO> sims;
public SimInfoCollectionDTO() {
}
public long getLastUpdated() {
return lastUpdated;
}
public void setLastUpdated(long lastUpdated) {
this.lastUpdated = lastUpdated;
}
public List<SimInfoDTO> getSims() {
return sims;
}
public void setSims(List<SimInfoDTO> sims) {
this.sims = sims;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

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

View File

@@ -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<HeartbeatResponseDTO> call = ApiManager.getApiService().heartbeat(deviceId, apiKey, heartbeatInput);
Response<HeartbeatResponseDTO> response = call.execute();

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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)

View File

@@ -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<string, any>