feat(android): complete Kotlin migration, UI overhaul, and dashboard polish

Kotlin migration (phases 3-5):
- Port all DTOs, helpers, models, workers, receivers, and services from Java to Kotlin
- Room DB files ported to Kotlin with all logic kept commented out (not yet enabled)
- Add SMSFilterScreen and SMSFilterViewModel in Compose (replaces Java SMSFilterActivity in new UI)
- All helpers exposed as Kotlin objects with @JvmStatic for Java interop

Onboarding improvements:
- Rewrote copy on all 5 onboarding screens (Welcome, Credentials, DeviceSetup, Permissions, SetupComplete)
- Added receive SMS toggle on SetupCompleteScreen (defaults on)
- Gateway now set to enabled by default after successful registration

Dashboard improvements:
- Add receive SMS toggle and SIM subscription ID display in device card
- Add permission warning card when SMS permissions are missing
- Remove all-time stats section (replaced by subscription usage bars)
- Trim Quick Actions to Dashboard + Docs; move Get Support and Share to Settings
- Merge user greeting into TopAppBar subtitle
- Device ID now has inline copy button

Dashboard UI polish (community review fixes):
- Redundant Enabled badge removed; only shows when gateway is Disabled
- Add Gateway label below the main switch for clarity
- Replace infinity symbol with Unlimited in usage display
- SIM subscription IDs use neutral color instead of primary/orange
- Status bar matches background color instead of primary orange

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
isra el
2026-06-10 08:57:16 +03:00
parent 9b629d3291
commit 243bbdd1d0
71 changed files with 2785 additions and 3316 deletions

View File

@@ -41,7 +41,7 @@ TextBee Android is mid-migration from a Java/XML legacy codebase to Kotlin + Jet
| File | Notes |
|---|---|
| `ui/splash/SplashActivity.kt` | Routes to legacy/onboarding/main based on SharedPrefs |
| `ui/main/NewMainActivity.kt` | Bottom nav (Dashboard / Messages / Settings) + compose route |
| `ui/main/NewMainActivity.kt` | Bottom nav (Dashboard / Messages / Settings) + compose + filters routes |
| `ui/dashboard/DashboardScreen.kt` | Device card, stats, subscription, quick actions |
| `ui/dashboard/DashboardViewModel.kt` | Stats, subscription, user profile, gateway toggle |
| `ui/messages/MessagesScreen.kt` | Filter chips, message list, detail dialog, FAB |
@@ -51,6 +51,50 @@ TextBee Android is mid-migration from a Java/XML legacy codebase to Kotlin + Jet
| `ui/settings/SettingsScreen.kt` | Account, Gateway, SMS, Legal, System, UI sections |
| `ui/settings/SettingsViewModel.kt` | Device name save, gateway toggle, SIM picker |
### UI — Settings (Phase 3) ✅
| File | Notes |
|---|---|
| `ui/settings/SMSFilterScreen.kt` | Full Compose SMS filter screen: enable switch, allow/block mode chips, rule list with FAB, add/edit dialog |
| `ui/settings/SMSFilterViewModel.kt` | `AndroidViewModel` — loads/saves `FilterConfig` via StateFlow; deep-copies config on each mutation |
`SMSFilterActivity.java` is still present for the legacy UI. The new UI navigates to the `"filters"` composable route inside `NewMainActivity`; bottom bar is hidden on both `"compose"` and `"filters"` routes.
### Data Layer — Kotlin Stubs (Phase 4) ✅
**Room DB (block-commented — feature not yet enabled):**
| File | Notes |
|---|---|
| `database/local/Sms.kt` | Replaces `SMS.java`; `@Entity` data class inside `/* */` block comment |
| `database/local/SmsDao.kt` | Replaces `SMSDao.java`; `@Dao` interface with suspend funs, block-commented |
| `database/local/AppDatabase.kt` | Replaces `AppDatabase.java`; singleton with companion object, block-commented |
| `database/local/DateConverter.kt` | Replaces `DateConverter.java`; `object` with `@TypeConverter`, block-commented |
**DTOs (all Java originals deleted):**
| File | Notes |
|---|---|
| `dtos/RegisterDeviceInputDTO.kt` | `class` with `var`; `@get:JvmName("isEnabled")` so Java/Kotlin callers get `isEnabled()` not `getEnabled()` |
| `dtos/RegisterDeviceResponseDTO.kt` | Regular `class` with `@JvmField var``MainActivity.java` accesses `.data`/`.error` as direct fields |
| `dtos/SMSDTO.kt` | Regular `class`; `message: String = ""` to avoid null default |
| `dtos/HeartbeatInputDTO.kt` | `var isCharging: Boolean?` (nullable) — generates `getIsCharging()`/`setIsCharging()` matching Java non-standard getter name |
| `dtos/HeartbeatResponseDTO.kt` | `@JvmField var` on all properties — `HeartbeatHelper` accesses fields directly (`.fcmTokenUpdated`) |
| `dtos/SimInfoDTO.kt` | `subscriptionId: Int = 0` (was primitive in Java) |
| `dtos/SimInfoCollectionDTO.kt` | `sims: MutableList<SimInfoDTO>? = null` |
| `dtos/SMSForwardResponseDTO.kt` | Empty class body |
### Helpers & Models (Phase 5) ✅
All helpers are Kotlin `object` with `@JvmStatic` on every public method — at the time of porting, Java workers/receivers called them; those callers were ported in Phase 6.
| File | Notes |
|---|---|
| `helpers/SharedPreferenceHelper.kt` | Replaces `SharedPreferenceHelper.java`; `PREF_FILE = "PREF"`, 7 methods |
| `helpers/SMSFilterHelper.kt` | Replaces `SMSFilterHelper.java`; nested `FilterMode` enum + `FilterConfig` class; Gson-compatible field names |
| `helpers/SMSHelper.kt` | Replaces `SMSHelper.java`; `FLAG_MUTABLE` on API >= S; private PendingIntent helpers |
| `helpers/HeartbeatHelper.kt` | Replaces `HeartbeatHelper.java`; `CountDownLatch` FCM token wait, `@Suppress("DEPRECATION")` for legacy network API |
| `helpers/HeartbeatManager.kt` | Replaces `HeartbeatManager.java`; `PeriodicWorkRequest.Builder(HeartbeatWorker::class.java, ...)` |
| `models/SMSFilterRule.kt` | Replaces `SMSFilterRule.java`; `@JvmOverloads constructor` for Java callers; nested `MatchType` + `FilterTarget` enums |
| `models/SMSPayload.kt` | Replaces `SMSPayload.java`; keeps legacy `receivers` + `smsBody` fields |
---
## What's Left (Java / Legacy)
@@ -67,119 +111,53 @@ TextBee Android is mid-migration from a Java/XML legacy codebase to Kotlin + Jet
| File | Priority | Notes |
|---|---|---|
| `activities/MainActivity.java` | High | Legacy main UI — remove after full Compose rollout |
| `activities/SMSFilterActivity.java` | High | Only user-facing Java screen still reachable from new UI |
### Database (Room)
| File | Priority | Notes |
|---|---|---|
| `database/local/AppDatabase.java` | Medium | Room DB; convert to Kotlin for coroutine-friendly DAOs |
| `database/local/SMS.java` | Medium | Room entity |
| `database/local/SMSDao.java` | Medium | DAO — high value: Kotlin suspend queries |
| `database/local/DateConverter.java` | Low | Trivial type converter |
### DTOs (Java)
| File | Priority | Notes |
|---|---|---|
| `dtos/RegisterDeviceInputDTO.java` | Medium | Setter-based Java — awkward from Kotlin |
| `dtos/RegisterDeviceResponseDTO.java` | Medium | Uses `Map<String, Object>` for `data` field |
| `dtos/HeartbeatInputDTO.java` | Low | Simple DTO |
| `dtos/HeartbeatResponseDTO.java` | Low | Simple DTO |
| `dtos/SMSDTO.java` | Medium | Core SMS payload |
| `dtos/SMSForwardResponseDTO.java` | Low | Simple DTO |
| `dtos/SimInfoDTO.java` / `SimInfoCollectionDTO.java` | Low | SIM info |
| `activities/SMSFilterActivity.java` | Medium | Legacy filter screen still reachable from legacy UI only |
### Helpers
| File | Priority | Notes |
|---|---|---|
| `helpers/SharedPreferenceHelper.java` | High | Called from every ViewModel — Kotlin extension would be cleaner |
| `helpers/HeartbeatHelper.java` | Medium | Heartbeat HTTP logic |
| `helpers/HeartbeatManager.java` | Medium | WorkManager scheduling |
| `helpers/SMSFilterHelper.java` | Medium | Filter rule evaluation |
| `helpers/SMSHelper.java` | Medium | SMS send/receive logic |
| `helpers/VersionTracker.java` | Low | Update check logic |
### Models
| File | Priority | Notes |
|---|---|---|
| `models/SMSFilterRule.java` | Medium | Convert to Kotlin data class |
| `models/SMSPayload.java` | Medium | Convert to Kotlin data class |
### Receivers
| File | Priority | Notes |
|---|---|---|
| `receivers/SMSBroadcastReceiver.java` | Medium | Receives incoming SMS, enqueues worker |
| `receivers/SMSStatusReceiver.java` | Medium | Tracks sent/delivered status |
| `receivers/BootCompletedReceiver.java` | Low | Reschedules heartbeat on boot |
| `helpers/VersionTracker.java` | Low | Update check logic — left for later |
### Services
| File | Priority | Notes |
|---|---|---|
| `services/GatewayApiService.java` | High | Java Retrofit interface — delete after legacy UI removed |
| `services/StickyNotificationService.java` | Medium | Foreground service for persistent notification |
| `services/FCMService.java` | Medium | Firebase push — triggers SMS send |
### Workers
| File | Priority | Notes |
|---|---|---|
| `workers/HeartbeatWorker.java` | Medium | Kotlin coroutines-based rewrite would simplify |
| `workers/SMSReceivedWorker.java` | Medium | Forwards received SMS to API |
| `workers/SMSStatusUpdateWorker.java` | Medium | Polls/updates SMS status |
| `workers/SmsSendWorker.java` | High | Core send logic — most complex worker |
---
## Migration Roadmap
### Phase 3 — SMS Filter Screen *(next up)*
Port `SMSFilterActivity.java` to Compose and integrate it as a nested route inside the Settings tab instead of a separate Activity. This is the only remaining Java screen reachable from the new UI.
**Files:**
- Create `ui/settings/SMSFilterScreen.kt` + `SMSFilterViewModel.kt`
- Reuse filter logic from `SMSFilterHelper.java` (call it from Kotlin until Phase 5)
- Add `"filters"` composable route to `NewMainActivity.kt` NavHost
- Update Settings "Configure Filters" row to navigate to route instead of `startActivity`
### Phase 3 — SMS Filter Screen ✅ Complete
Ported `SMSFilterActivity.java` to `SMSFilterScreen.kt` (Compose). Integrated as a nested `"filters"` route inside `NewMainActivity`. Legacy `SMSFilterActivity.java` unchanged — still reachable from legacy UI.
---
### Phase 4 — Data Layer
Convert the Room database and DTOs to idiomatic Kotlin. This unlocks suspend-based DAOs and removes the awkward Java setter pattern in DTOs.
**Files:**
- `SMS.java``Sms.kt` (data class + `@Entity`)
- `SMSDao.java``SmsDao.kt` (suspend functions)
- `AppDatabase.java``AppDatabase.kt`
- `RegisterDeviceInputDTO.java` → Kotlin data class (remove setter-based pattern)
- `RegisterDeviceResponseDTO.java` → Kotlin (replace `Map<String, Object>` with proper fields)
- Remaining Java DTOs → Kotlin data classes
### Phase 4 — Data Layer ✅ Complete
All DTOs ported to Kotlin; Java originals deleted. Room DB ported to Kotlin stubs with all logic still inside `/* */` block comments (feature remains disabled).
---
### Phase 5 — Helpers & Utilities
Convert the shared infrastructure that every component depends on. Do `SharedPreferenceHelper` first since it's the most impactful.
**Order:**
1. `SharedPreferenceHelper.java` → Kotlin object with inline extension helpers
2. `SMSFilterHelper.java` + `SMSHelper.java` → Kotlin (unblocks Phase 3 cleanup)
3. `TextBeeUtils.java` → Kotlin (split into focused util files)
4. `HeartbeatHelper.java` + `HeartbeatManager.java` → Kotlin
5. `models/SMSFilterRule.kt` + `models/SMSPayload.kt` → Kotlin data classes
6. `VersionTracker.java` → Kotlin
### Phase 5 — Helpers & Utilities ✅ Complete
All helpers and models ported to Kotlin `object`s with `@JvmStatic`. Java originals deleted. Java callers (workers, receivers — Phase 6) continue to work unchanged via `@JvmStatic` interop.
---
### Phase 6 — Background Services & Receivers
Rewrite workers and receivers in Kotlin. Workers benefit most from coroutines — the current Java workers use callbacks and `CountDownLatch` workarounds.
### Phase 6 — Background Services & Receivers ✅ Complete
All workers, receivers, and services ported to Kotlin; Java originals deleted.
**Order:**
1. `BootCompletedReceiver.java` → Kotlin (trivial, good warmup)
2. `SMSBroadcastReceiver.java` → Kotlin
3. `SMSStatusReceiver.java` → Kotlin
4. `HeartbeatWorker.java` → Kotlin coroutine worker
5. `SmsSendWorker.java` → Kotlin coroutine worker (most complex)
6. `SMSReceivedWorker.java` → Kotlin
7. `SMSStatusUpdateWorker.java` → Kotlin
8. `StickyNotificationService.java` → Kotlin
9. `FCMService.java` → Kotlin
| File | Notes |
|---|---|
| `receivers/BootCompletedReceiver.kt` | Restarts sticky notification + schedules heartbeat on boot |
| `receivers/SMSBroadcastReceiver.kt` | Deduplication fingerprint cache; Kotlin property access on `SMSDTO` |
| `receivers/SMSStatusReceiver.kt` | `setFailed()` private helper avoids `errorMessage` property shadowing |
| `workers/HeartbeatWorker.kt` | Simple `Worker` subclass; delegates to `HeartbeatHelper` |
| `workers/SmsSendWorker.kt` | SIM resolution priority chain; `Thread.sleep` rate limiting |
| `workers/SMSReceivedWorker.kt` | Fingerprint-based unique work name for deduplication |
| `workers/SMSStatusUpdateWorker.kt` | Exponential backoff, max 5 retries |
| `services/StickyNotificationService.kt` | Broad `Exception` catch replaces API-31-only `ForegroundServiceStartNotAllowedException` |
| `services/FCMService.kt` | Handles `heartbeat_check` type + SMS payload dispatch |
**Sticky notification fix**: Added service restart to `DashboardViewModel.loadLocalState()` — on every app launch, if gateway + sticky notification are enabled, the service is restarted. This matches legacy `MainActivity` behaviour and fixes the notification disappearing after Android kills the service on newer OS versions.
---
@@ -190,11 +168,12 @@ Once Compose UI is stable and rolled out to all users, remove the legacy UI enti
1. Remove the "Switch to Legacy UI" row from `SettingsScreen.kt`
2. Remove `USE_NEW_UI_KEY` logic from `SplashActivity.kt` (always route to new UI)
3. Delete `activities/MainActivity.java` and its XML layouts
4. Delete `services/GatewayApiService.java` (Java Retrofit interface)
5. Delete `ApiManager.java`
6. Remove "Try New UI" button from any remaining legacy layout XML
7. Clean up `AppConstants.java` — remove `SHARED_PREFS_USE_NEW_UI_KEY`
8. Convert `SMSGatewayApplication.java` → Kotlin
4. Delete `activities/SMSFilterActivity.java`
5. Delete `services/GatewayApiService.java` (Java Retrofit interface)
6. Delete `ApiManager.java`
7. Remove "Try New UI" button from any remaining legacy layout XML
8. Clean up `AppConstants.java` — remove `SHARED_PREFS_USE_NEW_UI_KEY`
9. Convert `SMSGatewayApplication.java` → Kotlin
---
@@ -202,6 +181,11 @@ Once Compose UI is stable and rolled out to all users, remove the legacy UI enti
- **`dynamicColor = false`** in `Theme.kt` — Material You overrides the brand orange on Android 12+; must stay false
- **`primaryContainer` avoided** in TopAppBar/nav — causes orange-on-orange in dark mode; use `surface` for bars, `surfaceVariant` for nav indicator
- **Java/Kotlin interop** — Java files call Kotlin objects fine; be careful with `companion object` vs `object` when called from Java
- **WorkManager workers** — must remain `ListenableWorker` subclass; Kotlin workers use `CoroutineWorker` which is the idiomatic replacement for `Worker`
- **`RegisterDeviceInputDTO`** — currently setter-based Java; Kotlin callers use `.apply { setEnabled(true) }` until Phase 4 replaces it with a proper data class
- **Java/Kotlin interop** — Only `ApiManager.java`, `TextBeeUtils.java`, legacy activities, and `GatewayApiService.java` remain Java; all others are Kotlin
- **WorkManager workers** — kept as `Worker` subclass (not `CoroutineWorker`) to avoid adding `work-runtime-ktx`; straightforward conversion candidate in a future cleanup
- **Sticky notification on Android 12+** — `ForegroundServiceStartNotAllowedException` is caught broadly; `DashboardViewModel` restarts service on every launch to compensate for OS killing it in the background
- **Room DB** — all DB logic remains commented out; do not uncomment until the feature is explicitly re-enabled
- **`@JvmField`** on `HeartbeatResponseDTO``HeartbeatHelper.kt` accesses `.fcmTokenUpdated`/`.name` as fields; `@JvmField` keeps direct field access instead of generating getters
- **`@JvmField`** on `RegisterDeviceResponseDTO``MainActivity.java` (legacy, still Java) accesses `.data`/`.error` as direct fields; remove once Phase 7 deletes the legacy activity
- **`@JvmOverloads`** on `SMSFilterRule` — generates no-arg and partial constructors needed by Gson deserialization of persisted filter config JSON
- **`@get:JvmName("isEnabled")`** on `RegisterDeviceInputDTO.enabled` and `@get:JvmName("isCaseSensitive")`on `SMSFilterRule.caseSensitive` — renames generated getter to match Java boolean convention; `MainActivity.java` and `SMSFilterActivity.java` use `isEnabled()`/`isCaseSensitive()`

View File

@@ -1,25 +0,0 @@
//package com.vernu.sms.database.local;
//
//import android.content.Context;
//import androidx.room.Database;
//import androidx.room.Room;
//import androidx.room.RoomDatabase;
//
//@Database(entities = {SMS.class}, version = 2)
//public abstract class AppDatabase extends RoomDatabase {
// private static volatile AppDatabase INSTANCE;
//
// public static AppDatabase getInstance(Context context) {
// if (INSTANCE == null) {
// synchronized (AppDatabase.class) {
// if (INSTANCE == null) {
// INSTANCE = Room.databaseBuilder(context.getApplicationContext(), AppDatabase.class, "db1")
// .build();
// }
// }
// }
// return INSTANCE;
// }
//
// public abstract SMSDao localReceivedSMSDao();
//}

View File

@@ -0,0 +1,28 @@
package com.vernu.sms.database.local
/*
import android.content.Context
import androidx.room.*
@Database(entities = [Sms::class], version = 2)
@TypeConverters(DateConverter::class)
abstract class AppDatabase : RoomDatabase() {
abstract fun localReceivedSMSDao(): SmsDao
companion object {
@Volatile
private var INSTANCE: AppDatabase? = null
fun getInstance(context: Context): AppDatabase {
return INSTANCE ?: synchronized(this) {
INSTANCE ?: Room.databaseBuilder(
context.applicationContext,
AppDatabase::class.java,
"db1"
).build().also { INSTANCE = it }
}
}
}
}
*/

View File

@@ -1,17 +0,0 @@
//package com.vernu.sms.database.local;
//
//import androidx.room.TypeConverter;
//
//import java.util.Date;
//
//public class DateConverter {
// @TypeConverter
// public static Date toDate(Long dateLong) {
// return dateLong == null ? null : new Date(dateLong);
// }
//
// @TypeConverter
// public static Long fromDate(Date date) {
// return date == null ? null : date.getTime();
// }
//}

View File

@@ -0,0 +1,14 @@
package com.vernu.sms.database.local
/*
import androidx.room.TypeConverter
import java.util.Date
object DateConverter {
@TypeConverter
fun toDate(value: Long?): Date? = value?.let { Date(it) }
@TypeConverter
fun fromDate(date: Date?): Long? = date?.time
}
*/

View File

@@ -1,193 +0,0 @@
//package com.vernu.sms.database.local;
//
//import androidx.annotation.NonNull;
//import androidx.room.ColumnInfo;
//import androidx.room.Entity;
//import androidx.room.PrimaryKey;
//import androidx.room.TypeConverters;
//
//import java.util.Date;
//
//@Entity(tableName = "sms")
//@TypeConverters(DateConverter.class)
//public class SMS {
//
// public SMS() {
// type = null;
// }
//
// @PrimaryKey(autoGenerate = true)
// private int id;
//
// // This is the ID of the SMS in the server
// @ColumnInfo(name = "_id")
// private String _id;
//
// @ColumnInfo(name = "message")
// private String message = "";
//
// @ColumnInfo(name = "encrypted_message")
// private String encryptedMessage = "";
//
// @ColumnInfo(name = "is_encrypted", defaultValue = "0")
// private boolean isEncrypted = false;
//
// @ColumnInfo(name = "sender")
// private String sender;
//
// @ColumnInfo(name = "recipient")
// private String recipient;
//
// @ColumnInfo(name = "requested_at")
// private Date requestedAt;
//
// @ColumnInfo(name = "sent_at")
// private Date sentAt;
//
// @ColumnInfo(name = "delivered_at")
// private Date deliveredAt;
//
// @ColumnInfo(name = "received_at")
// private Date receivedAt;
//
// @NonNull
// @ColumnInfo(name = "type")
// private String type;
//
// @ColumnInfo(name = "server_acknowledged_at")
// private Date serverAcknowledgedAt;
//
// public boolean hasServerAcknowledged() {
// return serverAcknowledgedAt != null;
// }
//
// @ColumnInfo(name = "last_acknowledged_request_at")
// private Date lastAcknowledgedRequestAt;
//
// @ColumnInfo(name = "retry_count", defaultValue = "0")
// private int retryCount = 0;
//
// public int getId() {
// return id;
// }
//
// public void setId(int id) {
// this.id = id;
// }
//
// public String get_id() {
// return _id;
// }
//
// public void set_id(String _id) {
// this._id = _id;
// }
//
// public String getMessage() {
// return message;
// }
//
// public void setMessage(String message) {
// this.message = message;
// }
//
// public String getEncryptedMessage() {
// return encryptedMessage;
// }
//
// public void setEncryptedMessage(String encryptedMessage) {
// this.encryptedMessage = encryptedMessage;
// }
//
// public boolean getIsEncrypted() {
// return isEncrypted;
// }
//
// public void setIsEncrypted(boolean isEncrypted) {
// this.isEncrypted = isEncrypted;
// }
//
// public String getSender() {
// return sender;
// }
//
// public void setSender(String sender) {
// this.sender = sender;
// }
//
// public String getRecipient() {
// return recipient;
// }
//
// public void setRecipient(String recipient) {
// this.recipient = recipient;
// }
//
// public Date getServerAcknowledgedAt() {
// return serverAcknowledgedAt;
// }
//
// public void setServerAcknowledgedAt(Date serverAcknowledgedAt) {
// this.serverAcknowledgedAt = serverAcknowledgedAt;
// }
//
//
//
// public Date getRequestedAt() {
// return requestedAt;
// }
//
// public void setRequestedAt(Date requestedAt) {
// this.requestedAt = requestedAt;
// }
//
// public Date getSentAt() {
// return sentAt;
// }
//
// public void setSentAt(Date sentAt) {
// this.sentAt = sentAt;
// }
//
// public Date getDeliveredAt() {
// return deliveredAt;
// }
//
// public void setDeliveredAt(Date deliveredAt) {
// this.deliveredAt = deliveredAt;
// }
//
// public Date getReceivedAt() {
// return receivedAt;
// }
//
// public void setReceivedAt(Date receivedAt) {
// this.receivedAt = receivedAt;
// }
//
// @NonNull
// public String getType() {
// return type;
// }
//
// public void setType(@NonNull String type) {
// this.type = type;
// }
//
//
// public Date getLastAcknowledgedRequestAt() {
// return lastAcknowledgedRequestAt;
// }
//
// public void setLastAcknowledgedRequestAt(Date lastAcknowledgedRequestAt) {
// this.lastAcknowledgedRequestAt = lastAcknowledgedRequestAt;
// }
//
// public int getRetryCount() {
// return retryCount;
// }
//
// public void setRetryCount(int retryCount) {
// this.retryCount = retryCount;
// }
//}

View File

@@ -1,27 +0,0 @@
//package com.vernu.sms.database.local;
//
//import androidx.room.Dao;
//import androidx.room.Delete;
//import androidx.room.Insert;
//import androidx.room.OnConflictStrategy;
//import androidx.room.Query;
//
//import java.util.List;
//
//@Dao
//public interface SMSDao {
//
// @Query("SELECT * FROM sms")
// List<SMS> getAll();
//
// @Query("SELECT * FROM sms WHERE id IN (:smsIds)")
// List<SMS> loadAllByIds(int[] smsIds);
//
// @Insert(onConflict = OnConflictStrategy.REPLACE)
// void insertAll(SMS... sms);
//
//
// @Delete
// void delete(SMS sms);
//
//}

View File

@@ -0,0 +1,29 @@
package com.vernu.sms.database.local
/*
import androidx.annotation.NonNull
import androidx.room.*
import java.util.Date
@Entity(tableName = "sms")
@TypeConverters(DateConverter::class)
data class Sms(
@PrimaryKey(autoGenerate = true) val id: Int = 0,
@ColumnInfo(name = "_id") val serverId: String? = null,
@ColumnInfo(name = "message") val message: String = "",
@ColumnInfo(name = "encrypted_message") val encryptedMessage: String = "",
@ColumnInfo(name = "is_encrypted", defaultValue = "0") val isEncrypted: Boolean = false,
@ColumnInfo(name = "sender") val sender: String? = null,
@ColumnInfo(name = "recipient") val recipient: String? = null,
@ColumnInfo(name = "requested_at") val requestedAt: Date? = null,
@ColumnInfo(name = "sent_at") val sentAt: Date? = null,
@ColumnInfo(name = "delivered_at") val deliveredAt: Date? = null,
@ColumnInfo(name = "received_at") val receivedAt: Date? = null,
@ColumnInfo(name = "type") @field:NonNull val type: String = "",
@ColumnInfo(name = "server_acknowledged_at") val serverAcknowledgedAt: Date? = null,
@ColumnInfo(name = "last_acknowledged_request_at") val lastAcknowledgedRequestAt: Date? = null,
@ColumnInfo(name = "retry_count", defaultValue = "0") val retryCount: Int = 0
) {
fun hasServerAcknowledged(): Boolean = serverAcknowledgedAt != null
}
*/

View File

@@ -0,0 +1,20 @@
package com.vernu.sms.database.local
/*
import androidx.room.*
@Dao
interface SmsDao {
@Query("SELECT * FROM sms")
suspend fun getAll(): List<Sms>
@Query("SELECT * FROM sms WHERE id IN (:smsIds)")
suspend fun loadAllByIds(smsIds: IntArray): List<Sms>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(vararg sms: Sms)
@Delete
suspend fun delete(sms: Sms)
}
*/

View File

@@ -1,160 +0,0 @@
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;
private Integer smsSendDelaySeconds;
private SimInfoCollectionDTO simInfo;
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;
}
public Integer getSmsSendDelaySeconds() {
return smsSendDelaySeconds;
}
public void setSmsSendDelaySeconds(Integer smsSendDelaySeconds) {
this.smsSendDelaySeconds = smsSendDelaySeconds;
}
public SimInfoCollectionDTO getSimInfo() {
return simInfo;
}
public void setSimInfo(SimInfoCollectionDTO simInfo) {
this.simInfo = simInfo;
}
}

View File

@@ -0,0 +1,21 @@
package com.vernu.sms.dtos
class HeartbeatInputDTO {
var fcmToken: String? = null
var batteryPercentage: Int? = null
var isCharging: Boolean? = null
var networkType: String? = null
var appVersionName: String? = null
var appVersionCode: Int? = null
var deviceUptimeMillis: Long? = null
var memoryFreeBytes: Long? = null
var memoryTotalBytes: Long? = null
var memoryMaxBytes: Long? = null
var storageAvailableBytes: Long? = null
var storageTotalBytes: Long? = null
var timezone: String? = null
var locale: String? = null
var receiveSMSEnabled: Boolean? = null
var smsSendDelaySeconds: Int? = null
var simInfo: SimInfoCollectionDTO? = null
}

View File

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

View File

@@ -0,0 +1,8 @@
package com.vernu.sms.dtos
class HeartbeatResponseDTO {
@JvmField var success: Boolean = false
@JvmField var fcmTokenUpdated: Boolean = false
@JvmField var lastHeartbeat: Long = 0
@JvmField var name: String? = null
}

View File

@@ -1,128 +0,0 @@
package com.vernu.sms.dtos;
public class RegisterDeviceInputDTO {
private String fcmToken;
private Boolean enabled;
private String brand;
private String manufacturer;
private String model;
private String name;
private String serial;
private String buildId;
private String os;
private String osVersion;
private String appVersionName;
private int appVersionCode;
private SimInfoCollectionDTO simInfo;
public RegisterDeviceInputDTO() {
}
public RegisterDeviceInputDTO(String fcmToken) {
this.fcmToken = fcmToken;
}
public String getFcmToken() {
return fcmToken;
}
public void setFcmToken(String fcmToken) {
this.fcmToken = fcmToken;
}
public Boolean isEnabled() {
return enabled;
}
public void setEnabled(Boolean enabled) {
this.enabled = enabled;
}
public String getBrand() {
return brand;
}
public void setBrand(String brand) {
this.brand = brand;
}
public String getManufacturer() {
return manufacturer;
}
public void setManufacturer(String manufacturer) {
this.manufacturer = manufacturer;
}
public String getModel() {
return model;
}
public void setModel(String model) {
this.model = model;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getSerial() {
return serial;
}
public void setSerial(String serial) {
this.serial = serial;
}
public String getBuildId() {
return buildId;
}
public void setBuildId(String buildId) {
this.buildId = buildId;
}
public String getOs() {
return os;
}
public void setOs(String os) {
this.os = os;
}
public String getOsVersion() {
return osVersion;
}
public void setOsVersion(String osVersion) {
this.osVersion = osVersion;
}
public String getAppVersionName() {
return appVersionName;
}
public void setAppVersionName(String appVersionName) {
this.appVersionName = appVersionName;
}
public int getAppVersionCode() {
return appVersionCode;
}
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,17 @@
package com.vernu.sms.dtos
class RegisterDeviceInputDTO {
var fcmToken: String? = null
@get:JvmName("isEnabled") var enabled: Boolean? = null
var brand: String? = null
var manufacturer: String? = null
var model: String? = null
var name: String? = null
var serial: String? = null
var buildId: String? = null
var os: String? = null
var osVersion: String? = null
var appVersionName: String? = null
var appVersionCode: Int = 0
var simInfo: SimInfoCollectionDTO? = null
}

View File

@@ -1,9 +0,0 @@
package com.vernu.sms.dtos;
import java.util.Map;
public class RegisterDeviceResponseDTO {
public boolean success;
public Map<String, Object> data;
public String error;
}

View File

@@ -0,0 +1,7 @@
package com.vernu.sms.dtos
class RegisterDeviceResponseDTO {
@JvmField var success: Boolean = false
@JvmField var data: Map<String, Any?>? = null
@JvmField var error: String? = null
}

View File

@@ -1,119 +0,0 @@
package com.vernu.sms.dtos;
import java.util.Date;
public class SMSDTO {
private String sender;
private String message = "";
private long receivedAtInMillis;
private String fingerprint;
private String smsId;
private String smsBatchId;
private String status;
private long sentAtInMillis;
private long deliveredAtInMillis;
private long failedAtInMillis;
private String errorCode;
private String errorMessage;
public SMSDTO() {
}
public String getSender() {
return sender;
}
public void setSender(String sender) {
this.sender = sender;
}
public String getMessage() {
return message;
}
public void setMessage(String message) {
this.message = message;
}
public long getReceivedAtInMillis() {
return receivedAtInMillis;
}
public void setReceivedAtInMillis(long receivedAtInMillis) {
this.receivedAtInMillis = receivedAtInMillis;
}
public String getSmsId() {
return smsId;
}
public void setSmsId(String smsId) {
this.smsId = smsId;
}
public String getSmsBatchId() {
return smsBatchId;
}
public void setSmsBatchId(String smsBatchId) {
this.smsBatchId = smsBatchId;
}
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
public long getSentAtInMillis() {
return sentAtInMillis;
}
public void setSentAtInMillis(long sentAtInMillis) {
this.sentAtInMillis = sentAtInMillis;
}
public long getDeliveredAtInMillis() {
return deliveredAtInMillis;
}
public void setDeliveredAtInMillis(long deliveredAtInMillis) {
this.deliveredAtInMillis = deliveredAtInMillis;
}
public long getFailedAtInMillis() {
return failedAtInMillis;
}
public void setFailedAtInMillis(long failedAtInMillis) {
this.failedAtInMillis = failedAtInMillis;
}
public String getErrorCode() {
return errorCode;
}
public void setErrorCode(String errorCode) {
this.errorCode = errorCode;
}
public String getErrorMessage() {
return errorMessage;
}
public void setErrorMessage(String errorMessage) {
this.errorMessage = errorMessage;
}
public String getFingerprint() {
return fingerprint;
}
public void setFingerprint(String fingerprint) {
this.fingerprint = fingerprint;
}
}

View File

@@ -0,0 +1,16 @@
package com.vernu.sms.dtos
class SMSDTO {
var sender: String? = null
var message: String = ""
var receivedAtInMillis: Long = 0
var fingerprint: String? = null
var smsId: String? = null
var smsBatchId: String? = null
var status: String? = null
var sentAtInMillis: Long = 0
var deliveredAtInMillis: Long = 0
var failedAtInMillis: Long = 0
var errorCode: String? = null
var errorMessage: String? = null
}

View File

@@ -1,9 +0,0 @@
package com.vernu.sms.dtos;
public class SMSForwardResponseDTO {
public SMSForwardResponseDTO() {
}
}

View File

@@ -0,0 +1,3 @@
package com.vernu.sms.dtos
class SMSForwardResponseDTO

View File

@@ -1,27 +0,0 @@
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,6 @@
package com.vernu.sms.dtos
class SimInfoCollectionDTO {
var lastUpdated: Long = 0
var sims: MutableList<SimInfoDTO>? = null
}

View File

@@ -1,97 +0,0 @@
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

@@ -0,0 +1,14 @@
package com.vernu.sms.dtos
class SimInfoDTO {
var subscriptionId: Int = 0
var iccId: String? = null
var cardId: Int? = null
var carrierName: String? = null
var displayName: String? = null
var simSlotIndex: Int? = null
var mcc: String? = null
var mnc: String? = null
var countryIso: String? = null
var subscriptionType: String? = null
}

View File

@@ -1,233 +0,0 @@
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);
// SMS send delay (device queue)
int smsSendDelaySeconds = SharedPreferenceHelper.getSharedPreferenceInt(
context,
AppConstants.SHARED_PREFS_SMS_SEND_DELAY_SECONDS_KEY,
AppConstants.DEFAULT_SMS_SEND_DELAY_SECONDS
);
heartbeatInput.setSmsSendDelaySeconds(smsSendDelaySeconds);
// 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");
}
// Sync device name from heartbeat response (ignore if blank)
if (responseBody.name != null && !responseBody.name.trim().isEmpty()) {
SharedPreferenceHelper.setSharedPreferenceString(
context,
AppConstants.SHARED_PREFS_DEVICE_NAME_KEY,
responseBody.name
);
Log.d(TAG, "Synced device name from heartbeat: " + responseBody.name);
}
SharedPreferenceHelper.setSharedPreferenceString(
context,
AppConstants.SHARED_PREFS_LAST_HEARTBEAT_MS_KEY,
String.valueOf(System.currentTimeMillis())
);
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

@@ -0,0 +1,162 @@
package com.vernu.sms.helpers
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.net.ConnectivityManager
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.TextBeeUtils
import com.vernu.sms.dtos.HeartbeatInputDTO
import com.vernu.sms.dtos.SimInfoCollectionDTO
import java.io.IOException
import java.util.Locale
import java.util.TimeZone
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
object HeartbeatHelper {
private const val TAG = "HeartbeatHelper"
@JvmStatic
fun sendHeartbeat(context: Context, deviceId: String, apiKey: String): Boolean {
if (deviceId.isEmpty()) {
Log.d(TAG, "Device not registered, skipping heartbeat")
return false
}
if (apiKey.isEmpty()) {
Log.e(TAG, "API key not available, skipping heartbeat")
return false
}
val heartbeatInput = HeartbeatInputDTO()
return try {
// FCM token (blocking wait up to 5 seconds)
try {
val latch = CountDownLatch(1)
var fcmTokenResult: String? = null
FirebaseMessaging.getInstance().token.addOnCompleteListener { task ->
if (task.isSuccessful) fcmTokenResult = task.result
latch.countDown()
}
if (latch.await(5, TimeUnit.SECONDS) && fcmTokenResult != null) {
heartbeatInput.fcmToken = fcmTokenResult
}
} catch (e: Exception) {
Log.e(TAG, "Failed to get FCM token: ${e.message}")
}
// Battery info
val batteryStatus = context.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
if (batteryStatus != null) {
val level = batteryStatus.getIntExtra(BatteryManager.EXTRA_LEVEL, -1)
val scale = batteryStatus.getIntExtra(BatteryManager.EXTRA_SCALE, -1)
heartbeatInput.batteryPercentage = ((level / scale.toFloat()) * 100).toInt()
val status = batteryStatus.getIntExtra(BatteryManager.EXTRA_STATUS, -1)
heartbeatInput.isCharging = status == BatteryManager.BATTERY_STATUS_CHARGING ||
status == BatteryManager.BATTERY_STATUS_FULL
}
// Network type
@Suppress("DEPRECATION")
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
@Suppress("DEPRECATION")
val activeNetwork = cm?.activeNetworkInfo
@Suppress("DEPRECATION")
heartbeatInput.networkType = when {
activeNetwork?.isConnected == true && activeNetwork.type == ConnectivityManager.TYPE_WIFI -> "wifi"
activeNetwork?.isConnected == true && activeNetwork.type == ConnectivityManager.TYPE_MOBILE -> "cellular"
else -> "none"
}
// App version
heartbeatInput.appVersionName = BuildConfig.VERSION_NAME
heartbeatInput.appVersionCode = BuildConfig.VERSION_CODE
// Device uptime
heartbeatInput.deviceUptimeMillis = SystemClock.uptimeMillis()
// Memory
val runtime = Runtime.getRuntime()
heartbeatInput.memoryFreeBytes = runtime.freeMemory()
heartbeatInput.memoryTotalBytes = runtime.totalMemory()
heartbeatInput.memoryMaxBytes = runtime.maxMemory()
// Storage
val statFs = StatFs(context.filesDir.path)
heartbeatInput.storageAvailableBytes = statFs.availableBytes
heartbeatInput.storageTotalBytes = statFs.totalBytes
// Locale / timezone
heartbeatInput.timezone = TimeZone.getDefault().id
heartbeatInput.locale = Locale.getDefault().toString()
// Preferences
heartbeatInput.receiveSMSEnabled = SharedPreferenceHelper.getSharedPreferenceBoolean(
context, AppConstants.SHARED_PREFS_RECEIVE_SMS_ENABLED_KEY, false
)
heartbeatInput.smsSendDelaySeconds = SharedPreferenceHelper.getSharedPreferenceInt(
context, AppConstants.SHARED_PREFS_SMS_SEND_DELAY_SECONDS_KEY,
AppConstants.DEFAULT_SMS_SEND_DELAY_SECONDS
)
// SIM info
heartbeatInput.simInfo = SimInfoCollectionDTO().apply {
lastUpdated = System.currentTimeMillis()
sims = TextBeeUtils.collectSimInfo(context)
}
// Send heartbeat (blocking)
val response = ApiManager.getApiService().heartbeat(deviceId, apiKey, heartbeatInput).execute()
if (response.isSuccessful && response.body() != null) {
val body = response.body()!!
if (body.fcmTokenUpdated) Log.d(TAG, "FCM token was updated during heartbeat")
if (!body.name.isNullOrBlank()) {
SharedPreferenceHelper.setSharedPreferenceString(
context, AppConstants.SHARED_PREFS_DEVICE_NAME_KEY, body.name!!
)
Log.d(TAG, "Synced device name from heartbeat: ${body.name}")
}
SharedPreferenceHelper.setSharedPreferenceString(
context, AppConstants.SHARED_PREFS_LAST_HEARTBEAT_MS_KEY,
System.currentTimeMillis().toString()
)
Log.d(TAG, "Heartbeat sent successfully")
true
} else {
Log.e(TAG, "Failed to send heartbeat. Response code: ${response.code()}")
false
}
} catch (e: IOException) {
Log.e(TAG, "Heartbeat API call failed: ${e.message}")
false
} catch (e: Exception) {
Log.e(TAG, "Error collecting device information: ${e.message}")
false
}
}
@JvmStatic
fun isDeviceEligibleForHeartbeat(context: Context): Boolean {
val deviceId = SharedPreferenceHelper.getSharedPreferenceString(
context, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, ""
) ?: ""
if (deviceId.isEmpty()) return false
val deviceEnabled = SharedPreferenceHelper.getSharedPreferenceBoolean(
context, AppConstants.SHARED_PREFS_GATEWAY_ENABLED_KEY, false
)
if (!deviceEnabled) return false
return SharedPreferenceHelper.getSharedPreferenceBoolean(
context, AppConstants.SHARED_PREFS_HEARTBEAT_ENABLED_KEY, true
)
}
}

View File

@@ -1,88 +0,0 @@
package com.vernu.sms.helpers;
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;
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
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(
appContext,
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");
// 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();
// 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 with unique work name: " + UNIQUE_WORK_NAME);
}
public static void cancelHeartbeat(Context context) {
Log.d(TAG, "Cancelling heartbeat work");
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);
}
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

@@ -0,0 +1,62 @@
package com.vernu.sms.helpers
import android.content.Context
import android.util.Log
import androidx.work.*
import com.vernu.sms.AppConstants
import com.vernu.sms.workers.HeartbeatWorker
import java.util.concurrent.TimeUnit
object HeartbeatManager {
private const val TAG = "HeartbeatManager"
private const val MIN_INTERVAL_MINUTES = 15
private const val UNIQUE_WORK_NAME = "heartbeat_unique_work"
@JvmStatic
fun scheduleHeartbeat(context: Context) {
val appContext = context.applicationContext
var intervalMinutes = SharedPreferenceHelper.getSharedPreferenceInt(
appContext, AppConstants.SHARED_PREFS_HEARTBEAT_INTERVAL_MINUTES_KEY, 30
)
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")
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
val heartbeatWork = PeriodicWorkRequest.Builder(
HeartbeatWorker::class.java,
intervalMinutes.toLong(),
TimeUnit.MINUTES
)
.setConstraints(constraints)
.addTag(AppConstants.HEARTBEAT_WORK_TAG)
.build()
WorkManager.getInstance(appContext)
.enqueueUniquePeriodicWork(
UNIQUE_WORK_NAME,
ExistingPeriodicWorkPolicy.REPLACE,
heartbeatWork
)
Log.d(TAG, "Heartbeat scheduled successfully with unique work name: $UNIQUE_WORK_NAME")
}
@JvmStatic
fun cancelHeartbeat(context: Context) {
Log.d(TAG, "Cancelling heartbeat work")
val appContext = context.applicationContext
WorkManager.getInstance(appContext).cancelUniqueWork(UNIQUE_WORK_NAME)
WorkManager.getInstance(appContext).cancelAllWorkByTag(AppConstants.HEARTBEAT_WORK_TAG)
}
@JvmStatic
fun triggerHeartbeat(context: Context) {
Log.d(TAG, "Triggering immediate heartbeat")
scheduleHeartbeat(context)
}
}

View File

@@ -1,142 +0,0 @@
package com.vernu.sms.helpers;
import android.content.Context;
import android.util.Log;
import com.google.gson.Gson;
import com.google.gson.reflect.TypeToken;
import com.vernu.sms.AppConstants;
import com.vernu.sms.models.SMSFilterRule;
import java.lang.reflect.Type;
import java.util.ArrayList;
import java.util.List;
public class SMSFilterHelper {
private static final String TAG = "SMSFilterHelper";
public enum FilterMode {
ALLOW_LIST,
BLOCK_LIST
}
public static class FilterConfig {
private boolean enabled = false;
private FilterMode mode = FilterMode.BLOCK_LIST; // Default to block list
private List<SMSFilterRule> rules = new ArrayList<>();
public FilterConfig() {
}
public boolean isEnabled() {
return enabled;
}
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
public FilterMode getMode() {
return mode;
}
public void setMode(FilterMode mode) {
this.mode = mode;
}
public List<SMSFilterRule> getRules() {
return rules;
}
public void setRules(List<SMSFilterRule> rules) {
this.rules = rules != null ? rules : new ArrayList<>();
}
}
/**
* Load filter configuration from SharedPreferences
*/
public static FilterConfig loadFilterConfig(Context context) {
String json = SharedPreferenceHelper.getSharedPreferenceString(
context,
AppConstants.SHARED_PREFS_SMS_FILTER_CONFIG_KEY,
null
);
if (json == null || json.isEmpty()) {
return new FilterConfig();
}
try {
Gson gson = new Gson();
Type type = new TypeToken<FilterConfig>() {}.getType();
FilterConfig config = gson.fromJson(json, type);
return config != null ? config : new FilterConfig();
} catch (Exception e) {
Log.e(TAG, "Error loading filter config: " + e.getMessage());
return new FilterConfig();
}
}
/**
* Save filter configuration to SharedPreferences
*/
public static void saveFilterConfig(Context context, FilterConfig config) {
try {
Gson gson = new Gson();
String json = gson.toJson(config);
SharedPreferenceHelper.setSharedPreferenceString(
context,
AppConstants.SHARED_PREFS_SMS_FILTER_CONFIG_KEY,
json
);
} catch (Exception e) {
Log.e(TAG, "Error saving filter config: " + e.getMessage());
}
}
/**
* Check if an SMS should be processed based on filter configuration
* @param sender The sender phone number
* @param message The message content
* @param context Application context
* @return true if SMS should be processed, false if it should be filtered out
*/
public static boolean shouldProcessSMS(String sender, String message, Context context) {
FilterConfig config = loadFilterConfig(context);
// If filter is disabled, process all SMS
if (!config.isEnabled()) {
return true;
}
// If no rules, process all SMS (empty filter doesn't block anything)
if (config.getRules() == null || config.getRules().isEmpty()) {
return true;
}
// Check if sender and/or message matches any rule
boolean matchesAnyRule = false;
for (SMSFilterRule rule : config.getRules()) {
if (rule.matches(sender, message)) {
matchesAnyRule = true;
break;
}
}
// Apply filter mode
if (config.getMode() == FilterMode.ALLOW_LIST) {
// Only process if matches a rule
return matchesAnyRule;
} else {
// Block list: process if it does NOT match any rule
return !matchesAnyRule;
}
}
/**
* Legacy method for backward compatibility - checks sender only
*/
public static boolean shouldProcessSMS(String sender, Context context) {
return shouldProcessSMS(sender, null, context);
}
}

View File

@@ -0,0 +1,61 @@
package com.vernu.sms.helpers
import android.content.Context
import android.util.Log
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import com.vernu.sms.AppConstants
import com.vernu.sms.models.SMSFilterRule
object SMSFilterHelper {
private const val TAG = "SMSFilterHelper"
enum class FilterMode { ALLOW_LIST, BLOCK_LIST }
class FilterConfig {
@get:JvmName("isEnabled") var enabled: Boolean = false
var mode: FilterMode = FilterMode.BLOCK_LIST
var rules: MutableList<SMSFilterRule> = mutableListOf()
}
@JvmStatic
fun loadFilterConfig(context: Context): FilterConfig {
val json = SharedPreferenceHelper.getSharedPreferenceString(
context, AppConstants.SHARED_PREFS_SMS_FILTER_CONFIG_KEY, null
)
if (json.isNullOrEmpty()) return FilterConfig()
return try {
val type = object : TypeToken<FilterConfig>() {}.type
Gson().fromJson<FilterConfig>(json, type) ?: FilterConfig()
} catch (e: Exception) {
Log.e(TAG, "Error loading filter config: ${e.message}")
FilterConfig()
}
}
@JvmStatic
fun saveFilterConfig(context: Context, config: FilterConfig) {
try {
SharedPreferenceHelper.setSharedPreferenceString(
context,
AppConstants.SHARED_PREFS_SMS_FILTER_CONFIG_KEY,
Gson().toJson(config)
)
} catch (e: Exception) {
Log.e(TAG, "Error saving filter config: ${e.message}")
}
}
@JvmStatic
fun shouldProcessSMS(sender: String?, message: String?, context: Context): Boolean {
val config = loadFilterConfig(context)
if (!config.enabled) return true
if (config.rules.isEmpty()) return true
val matchesAnyRule = config.rules.any { it.matches(sender, message) }
return if (config.mode == FilterMode.ALLOW_LIST) matchesAnyRule else !matchesAnyRule
}
@JvmStatic
fun shouldProcessSMS(sender: String?, context: Context): Boolean =
shouldProcessSMS(sender, null, context)
}

View File

@@ -1,213 +0,0 @@
package com.vernu.sms.helpers;
import android.Manifest;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.telephony.SmsManager;
import android.os.Build;
import android.telephony.SubscriptionManager;
import android.util.Log;
import com.vernu.sms.AppConstants;
import com.vernu.sms.TextBeeUtils;
import com.vernu.sms.dtos.SMSDTO;
import com.vernu.sms.receivers.SMSStatusReceiver;
import com.vernu.sms.workers.SMSStatusUpdateWorker;
import java.util.ArrayList;
public class SMSHelper {
private static final String TAG = "SMSHelper";
/**
* Sends an SMS message and returns whether the operation was successful
*
* @param phoneNo The recipient's phone number
* @param message The SMS message to send
* @param smsId The unique ID for this SMS
* @param smsBatchId The batch ID for this SMS
* @param context The application context
* @return boolean True if sending was initiated, false if permissions aren't granted
*/
public static boolean sendSMS(String phoneNo, String message, String smsId, String smsBatchId, Context context) {
// Check if we have permission to send SMS
if (!TextBeeUtils.isPermissionGranted(context, Manifest.permission.SEND_SMS)) {
Log.e(TAG, "SMS permission not granted. Unable to send SMS.");
// Report failure to API
reportPermissionError(context, smsId, smsBatchId);
return false;
}
try {
SmsManager smsManager = SmsManager.getDefault();
// Create pending intents for status tracking
PendingIntent sentIntent = createSentPendingIntent(context, smsId, smsBatchId);
PendingIntent deliveredIntent = createDeliveredPendingIntent(context, smsId, smsBatchId);
// For SMS with more than 160 chars
ArrayList<String> parts = smsManager.divideMessage(message);
if (parts.size() > 1) {
ArrayList<PendingIntent> sentIntents = new ArrayList<>();
ArrayList<PendingIntent> deliveredIntents = new ArrayList<>();
for (int i = 0; i < parts.size(); i++) {
sentIntents.add(sentIntent);
deliveredIntents.add(deliveredIntent);
}
smsManager.sendMultipartTextMessage(phoneNo, null, parts, sentIntents, deliveredIntents);
} else {
smsManager.sendTextMessage(phoneNo, null, message, sentIntent, deliveredIntent);
}
return true;
} catch (Exception e) {
Log.e(TAG, "Exception when sending SMS: " + e.getMessage());
// Report exception to API
reportSendingError(context, smsId, smsBatchId, e.getMessage());
return false;
}
}
/**
* Sends an SMS message from a specific SIM slot and returns whether the operation was successful
*
* @param phoneNo The recipient's phone number
* @param message The SMS message to send
* @param simSubscriptionId The specific SIM subscription ID to use
* @param smsId The unique ID for this SMS
* @param smsBatchId The batch ID for this SMS
* @param context The application context
* @return boolean True if sending was initiated, false if permissions aren't granted
*/
public static boolean sendSMSFromSpecificSim(String phoneNo, String message, int simSubscriptionId,
String smsId, String smsBatchId, Context context) {
// Check for required permissions
if (!TextBeeUtils.isPermissionGranted(context, Manifest.permission.SEND_SMS) ||
!TextBeeUtils.isPermissionGranted(context, Manifest.permission.READ_PHONE_STATE)) {
Log.e(TAG, "SMS or Phone State permission not granted. Unable to send SMS from specific SIM.");
// Report failure to API
reportPermissionError(context, smsId, smsBatchId);
return false;
}
try {
// Get the SmsManager for the specific SIM
SmsManager smsManager;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1) {
smsManager = SmsManager.getSmsManagerForSubscriptionId(simSubscriptionId);
} else {
// Fallback to default SmsManager for older Android versions
smsManager = SmsManager.getDefault();
Log.w(TAG, "Using default SIM as specific SIM selection not supported on this Android version");
}
// Create pending intents for status tracking
PendingIntent sentIntent = createSentPendingIntent(context, smsId, smsBatchId);
PendingIntent deliveredIntent = createDeliveredPendingIntent(context, smsId, smsBatchId);
// For SMS with more than 160 chars
ArrayList<String> parts = smsManager.divideMessage(message);
if (parts.size() > 1) {
ArrayList<PendingIntent> sentIntents = new ArrayList<>();
ArrayList<PendingIntent> deliveredIntents = new ArrayList<>();
for (int i = 0; i < parts.size(); i++) {
sentIntents.add(sentIntent);
deliveredIntents.add(deliveredIntent);
}
smsManager.sendMultipartTextMessage(phoneNo, null, parts, sentIntents, deliveredIntents);
} else {
smsManager.sendTextMessage(phoneNo, null, message, sentIntent, deliveredIntent);
}
return true;
} catch (Exception e) {
Log.e(TAG, "Exception when sending SMS from specific SIM: " + e.getMessage());
// Report exception to API
reportSendingError(context, smsId, smsBatchId, e.getMessage());
return false;
}
}
private static void reportPermissionError(Context context, String smsId, String smsBatchId) {
SMSDTO smsDTO = new SMSDTO();
smsDTO.setSmsId(smsId);
smsDTO.setSmsBatchId(smsBatchId);
smsDTO.setStatus("FAILED");
smsDTO.setFailedAtInMillis(System.currentTimeMillis());
smsDTO.setErrorCode("PERMISSION_DENIED");
smsDTO.setErrorMessage("SMS permission not granted");
updateSMSStatus(context, smsDTO);
}
private static void reportSendingError(Context context, String smsId, String smsBatchId, String errorMessage) {
SMSDTO smsDTO = new SMSDTO();
smsDTO.setSmsId(smsId);
smsDTO.setSmsBatchId(smsBatchId);
smsDTO.setStatus("FAILED");
smsDTO.setFailedAtInMillis(System.currentTimeMillis());
smsDTO.setErrorCode("SENDING_EXCEPTION");
smsDTO.setErrorMessage(errorMessage);
updateSMSStatus(context, smsDTO);
}
private static void updateSMSStatus(Context context, SMSDTO smsDTO) {
String deviceId = SharedPreferenceHelper.getSharedPreferenceString(context, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, "");
String apiKey = SharedPreferenceHelper.getSharedPreferenceString(context, AppConstants.SHARED_PREFS_API_KEY_KEY, "");
if (deviceId.isEmpty() || apiKey.isEmpty()) {
Log.e(TAG, "Device ID or API key not found");
return;
}
SMSStatusUpdateWorker.enqueueWork(context, deviceId, apiKey, smsDTO);
}
private static PendingIntent createSentPendingIntent(Context context, String smsId, String smsBatchId) {
// Create explicit intent (specify the component)
Intent intent = new Intent(context, SMSStatusReceiver.class);
intent.setAction(SMSStatusReceiver.SMS_SENT);
intent.putExtra("sms_id", smsId);
intent.putExtra("sms_batch_id", smsBatchId);
int flags = PendingIntent.FLAG_UPDATE_CURRENT;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
flags |= PendingIntent.FLAG_MUTABLE;
}
// Use a unique request code to avoid PendingIntent collisions
int requestCode = (smsId + "_sent").hashCode();
return PendingIntent.getBroadcast(context, requestCode, intent, flags);
}
private static PendingIntent createDeliveredPendingIntent(Context context, String smsId, String smsBatchId) {
// Create explicit intent (specify the component)
Intent intent = new Intent(context, SMSStatusReceiver.class);
intent.setAction(SMSStatusReceiver.SMS_DELIVERED);
intent.putExtra("sms_id", smsId);
intent.putExtra("sms_batch_id", smsBatchId);
int flags = PendingIntent.FLAG_UPDATE_CURRENT;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
flags |= PendingIntent.FLAG_MUTABLE;
}
// Use a unique request code to avoid PendingIntent collisions
int requestCode = (smsId + "_delivered").hashCode();
return PendingIntent.getBroadcast(context, requestCode, intent, flags);
}
}

View File

@@ -0,0 +1,161 @@
package com.vernu.sms.helpers
import android.Manifest
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import android.telephony.SmsManager
import android.util.Log
import com.vernu.sms.AppConstants
import com.vernu.sms.TextBeeUtils
import com.vernu.sms.dtos.SMSDTO
import com.vernu.sms.receivers.SMSStatusReceiver
import com.vernu.sms.workers.SMSStatusUpdateWorker
object SMSHelper {
private const val TAG = "SMSHelper"
@JvmStatic
fun sendSMS(
phoneNo: String,
message: String,
smsId: String,
smsBatchId: String,
context: Context
): Boolean {
if (!TextBeeUtils.isPermissionGranted(context, Manifest.permission.SEND_SMS)) {
Log.e(TAG, "SMS permission not granted. Unable to send SMS.")
reportPermissionError(context, smsId, smsBatchId)
return false
}
return try {
val smsManager = SmsManager.getDefault()
val sentIntent = createSentPendingIntent(context, smsId, smsBatchId)
val deliveredIntent = createDeliveredPendingIntent(context, smsId, smsBatchId)
val parts = smsManager.divideMessage(message)
if (parts.size > 1) {
val sentIntents = ArrayList<PendingIntent>(parts.size).also { list ->
repeat(parts.size) { list.add(sentIntent) }
}
val deliveredIntents = ArrayList<PendingIntent>(parts.size).also { list ->
repeat(parts.size) { list.add(deliveredIntent) }
}
smsManager.sendMultipartTextMessage(phoneNo, null, parts, sentIntents, deliveredIntents)
} else {
smsManager.sendTextMessage(phoneNo, null, message, sentIntent, deliveredIntent)
}
true
} catch (e: Exception) {
Log.e(TAG, "Exception when sending SMS: ${e.message}")
reportSendingError(context, smsId, smsBatchId, e.message)
false
}
}
@JvmStatic
fun sendSMSFromSpecificSim(
phoneNo: String,
message: String,
simSubscriptionId: Int,
smsId: String,
smsBatchId: String,
context: Context
): Boolean {
if (!TextBeeUtils.isPermissionGranted(context, Manifest.permission.SEND_SMS) ||
!TextBeeUtils.isPermissionGranted(context, Manifest.permission.READ_PHONE_STATE)
) {
Log.e(TAG, "SMS or Phone State permission not granted. Unable to send SMS from specific SIM.")
reportPermissionError(context, smsId, smsBatchId)
return false
}
return try {
@Suppress("DEPRECATION")
val smsManager = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1) {
SmsManager.getSmsManagerForSubscriptionId(simSubscriptionId)
} else {
Log.w(TAG, "Using default SIM as specific SIM selection not supported on this Android version")
SmsManager.getDefault()
}
val sentIntent = createSentPendingIntent(context, smsId, smsBatchId)
val deliveredIntent = createDeliveredPendingIntent(context, smsId, smsBatchId)
val parts = smsManager.divideMessage(message)
if (parts.size > 1) {
val sentIntents = ArrayList<PendingIntent>(parts.size).also { list ->
repeat(parts.size) { list.add(sentIntent) }
}
val deliveredIntents = ArrayList<PendingIntent>(parts.size).also { list ->
repeat(parts.size) { list.add(deliveredIntent) }
}
smsManager.sendMultipartTextMessage(phoneNo, null, parts, sentIntents, deliveredIntents)
} else {
smsManager.sendTextMessage(phoneNo, null, message, sentIntent, deliveredIntent)
}
true
} catch (e: Exception) {
Log.e(TAG, "Exception when sending SMS from specific SIM: ${e.message}")
reportSendingError(context, smsId, smsBatchId, e.message)
false
}
}
private fun reportPermissionError(context: Context, smsId: String, smsBatchId: String) {
val smsDTO = SMSDTO().apply {
this.smsId = smsId
this.smsBatchId = smsBatchId
status = "FAILED"
failedAtInMillis = System.currentTimeMillis()
errorCode = "PERMISSION_DENIED"
errorMessage = "SMS permission not granted"
}
updateSMSStatus(context, smsDTO)
}
private fun reportSendingError(context: Context, smsId: String, smsBatchId: String, error: String?) {
val smsDTO = SMSDTO().apply {
this.smsId = smsId
this.smsBatchId = smsBatchId
status = "FAILED"
failedAtInMillis = System.currentTimeMillis()
errorCode = "SENDING_EXCEPTION"
errorMessage = error
}
updateSMSStatus(context, smsDTO)
}
private fun updateSMSStatus(context: Context, smsDTO: SMSDTO) {
val deviceId = SharedPreferenceHelper.getSharedPreferenceString(
context, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, ""
) ?: ""
val apiKey = SharedPreferenceHelper.getSharedPreferenceString(
context, AppConstants.SHARED_PREFS_API_KEY_KEY, ""
) ?: ""
if (deviceId.isEmpty() || apiKey.isEmpty()) {
Log.e(TAG, "Device ID or API key not found")
return
}
SMSStatusUpdateWorker.enqueueWork(context, deviceId, apiKey, smsDTO)
}
private fun createSentPendingIntent(context: Context, smsId: String, smsBatchId: String): PendingIntent {
val intent = Intent(context, SMSStatusReceiver::class.java).apply {
action = SMSStatusReceiver.SMS_SENT
putExtra("sms_id", smsId)
putExtra("sms_batch_id", smsBatchId)
}
var flags = PendingIntent.FLAG_UPDATE_CURRENT
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) flags = flags or PendingIntent.FLAG_MUTABLE
return PendingIntent.getBroadcast(context, (smsId + "_sent").hashCode(), intent, flags)
}
private fun createDeliveredPendingIntent(context: Context, smsId: String, smsBatchId: String): PendingIntent {
val intent = Intent(context, SMSStatusReceiver::class.java).apply {
action = SMSStatusReceiver.SMS_DELIVERED
putExtra("sms_id", smsId)
putExtra("sms_batch_id", smsBatchId)
}
var flags = PendingIntent.FLAG_UPDATE_CURRENT
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) flags = flags or PendingIntent.FLAG_MUTABLE
return PendingIntent.getBroadcast(context, (smsId + "_delivered").hashCode(), intent, flags)
}
}

View File

@@ -1,54 +0,0 @@
package com.vernu.sms.helpers;
import android.content.Context;
import android.content.SharedPreferences;
public class SharedPreferenceHelper {
private final static String PREF_FILE = "PREF";
public static void setSharedPreferenceString(Context context, String key, String value) {
SharedPreferences settings = context.getSharedPreferences(PREF_FILE, 0);
SharedPreferences.Editor editor = settings.edit();
editor.putString(key, value);
editor.apply();
}
public static void setSharedPreferenceInt(Context context, String key, int value) {
SharedPreferences settings = context.getSharedPreferences(PREF_FILE, 0);
SharedPreferences.Editor editor = settings.edit();
editor.putInt(key, value);
editor.apply();
}
public static void setSharedPreferenceBoolean(Context context, String key, boolean value) {
SharedPreferences settings = context.getSharedPreferences(PREF_FILE, 0);
SharedPreferences.Editor editor = settings.edit();
editor.putBoolean(key, value);
editor.apply();
}
public static String getSharedPreferenceString(Context context, String key, String defValue) {
SharedPreferences settings = context.getSharedPreferences(PREF_FILE, 0);
return settings.getString(key, defValue);
}
public static int getSharedPreferenceInt(Context context, String key, int defValue) {
SharedPreferences settings = context.getSharedPreferences(PREF_FILE, 0);
return settings.getInt(key, defValue);
}
public static boolean getSharedPreferenceBoolean(Context context, String key, boolean defValue) {
SharedPreferences settings = context.getSharedPreferences(PREF_FILE, 0);
return settings.getBoolean(key, defValue);
}
public static void clearSharedPreference(Context context, String key) {
SharedPreferences settings = context.getSharedPreferences(PREF_FILE, 0);
SharedPreferences.Editor editor = settings.edit();
editor.remove(key);
editor.apply();
}
}

View File

@@ -0,0 +1,42 @@
package com.vernu.sms.helpers
import android.content.Context
object SharedPreferenceHelper {
private const val PREF_FILE = "PREF"
@JvmStatic
fun setSharedPreferenceString(context: Context, key: String, value: String) {
context.getSharedPreferences(PREF_FILE, 0).edit().putString(key, value).apply()
}
@JvmStatic
fun setSharedPreferenceInt(context: Context, key: String, value: Int) {
context.getSharedPreferences(PREF_FILE, 0).edit().putInt(key, value).apply()
}
@JvmStatic
fun setSharedPreferenceBoolean(context: Context, key: String, value: Boolean) {
context.getSharedPreferences(PREF_FILE, 0).edit().putBoolean(key, value).apply()
}
@JvmStatic
fun getSharedPreferenceString(context: Context, key: String, defValue: String?): String? {
return context.getSharedPreferences(PREF_FILE, 0).getString(key, defValue)
}
@JvmStatic
fun getSharedPreferenceInt(context: Context, key: String, defValue: Int): Int {
return context.getSharedPreferences(PREF_FILE, 0).getInt(key, defValue)
}
@JvmStatic
fun getSharedPreferenceBoolean(context: Context, key: String, defValue: Boolean): Boolean {
return context.getSharedPreferences(PREF_FILE, 0).getBoolean(key, defValue)
}
@JvmStatic
fun clearSharedPreference(context: Context, key: String) {
context.getSharedPreferences(PREF_FILE, 0).edit().remove(key).apply()
}
}

View File

@@ -1,135 +0,0 @@
package com.vernu.sms.models;
public class SMSFilterRule {
public enum MatchType {
EXACT,
STARTS_WITH,
ENDS_WITH,
CONTAINS
}
public enum FilterTarget {
SENDER,
MESSAGE,
BOTH
}
private String pattern;
private MatchType matchType;
private FilterTarget filterTarget = FilterTarget.SENDER; // Default to sender for backward compatibility
private boolean caseSensitive = false; // Default to case insensitive
public SMSFilterRule() {
}
public SMSFilterRule(String pattern, MatchType matchType) {
this.pattern = pattern;
this.matchType = matchType;
this.filterTarget = FilterTarget.SENDER;
this.caseSensitive = false;
}
public SMSFilterRule(String pattern, MatchType matchType, FilterTarget filterTarget) {
this.pattern = pattern;
this.matchType = matchType;
this.filterTarget = filterTarget;
this.caseSensitive = false;
}
public SMSFilterRule(String pattern, MatchType matchType, FilterTarget filterTarget, boolean caseSensitive) {
this.pattern = pattern;
this.matchType = matchType;
this.filterTarget = filterTarget;
this.caseSensitive = caseSensitive;
}
public String getPattern() {
return pattern;
}
public void setPattern(String pattern) {
this.pattern = pattern;
}
public MatchType getMatchType() {
return matchType;
}
public void setMatchType(MatchType matchType) {
this.matchType = matchType;
}
public FilterTarget getFilterTarget() {
return filterTarget;
}
public void setFilterTarget(FilterTarget filterTarget) {
this.filterTarget = filterTarget != null ? filterTarget : FilterTarget.SENDER;
}
public boolean isCaseSensitive() {
return caseSensitive;
}
public void setCaseSensitive(boolean caseSensitive) {
this.caseSensitive = caseSensitive;
}
/**
* Check if a string matches this filter rule based on match type
*/
private boolean matchesString(String text) {
if (pattern == null || text == null) {
return false;
}
String patternToMatch = pattern;
String textToMatch = text;
// Apply case sensitivity
if (!caseSensitive) {
patternToMatch = patternToMatch.toLowerCase();
textToMatch = textToMatch.toLowerCase();
}
switch (matchType) {
case EXACT:
return textToMatch.equals(patternToMatch);
case STARTS_WITH:
return textToMatch.startsWith(patternToMatch);
case ENDS_WITH:
return textToMatch.endsWith(patternToMatch);
case CONTAINS:
return textToMatch.contains(patternToMatch);
default:
return false;
}
}
/**
* Check if the given sender and/or message matches this filter rule
*/
public boolean matches(String sender, String message) {
if (pattern == null) {
return false;
}
switch (filterTarget) {
case SENDER:
return matchesString(sender);
case MESSAGE:
return matchesString(message);
case BOTH:
return matchesString(sender) || matchesString(message);
default:
return matchesString(sender);
}
}
/**
* Legacy method for backward compatibility - checks sender only
*/
public boolean matches(String sender) {
return matches(sender, null);
}
}

View File

@@ -0,0 +1,36 @@
package com.vernu.sms.models
class SMSFilterRule @JvmOverloads constructor(
var pattern: String? = null,
var matchType: MatchType? = null,
var filterTarget: FilterTarget = FilterTarget.SENDER,
@get:JvmName("isCaseSensitive") var caseSensitive: Boolean = false
) {
enum class MatchType { EXACT, STARTS_WITH, ENDS_WITH, CONTAINS }
enum class FilterTarget { SENDER, MESSAGE, BOTH }
private fun matchesString(text: String?): Boolean {
val p = pattern ?: return false
val t = text ?: return false
val pat = if (caseSensitive) p else p.lowercase()
val txt = if (caseSensitive) t else t.lowercase()
return when (matchType) {
MatchType.EXACT -> txt == pat
MatchType.STARTS_WITH -> txt.startsWith(pat)
MatchType.ENDS_WITH -> txt.endsWith(pat)
MatchType.CONTAINS -> txt.contains(pat)
null -> false
}
}
fun matches(sender: String?, message: String?): Boolean {
if (pattern == null) return false
return when (filterTarget) {
FilterTarget.SENDER -> matchesString(sender)
FilterTarget.MESSAGE -> matchesString(message)
FilterTarget.BOTH -> matchesString(sender) || matchesString(message)
}
}
fun matches(sender: String?): Boolean = matches(sender, null)
}

View File

@@ -1,57 +0,0 @@
package com.vernu.sms.models;
public class SMSPayload {
private String[] recipients;
private String message;
private String smsId;
private String smsBatchId;
private Integer simSubscriptionId;
// Legacy fields that are no longer used
private String[] receivers;
private String smsBody;
public SMSPayload() {
}
public String[] getRecipients() {
return recipients;
}
public void setRecipients(String[] recipients) {
this.recipients = recipients;
}
public String getMessage() {
return message;
}
public void setMessage(String message) {
this.message = message;
}
public String getSmsId() {
return smsId;
}
public void setSmsId(String smsId) {
this.smsId = smsId;
}
public String getSmsBatchId() {
return smsBatchId;
}
public void setSmsBatchId(String smsBatchId) {
this.smsBatchId = smsBatchId;
}
public Integer getSimSubscriptionId() {
return simSubscriptionId;
}
public void setSimSubscriptionId(Integer simSubscriptionId) {
this.simSubscriptionId = simSubscriptionId;
}
}

View File

@@ -0,0 +1,13 @@
package com.vernu.sms.models
class SMSPayload {
var recipients: Array<String>? = null
var message: String? = null
var smsId: String? = null
var smsBatchId: String? = null
var simSubscriptionId: Int? = null
// Legacy fields — no longer actively used but kept for backward compatibility
var receivers: Array<String>? = null
var smsBody: String? = null
}

View File

@@ -1,124 +0,0 @@
package com.vernu.sms.receivers;
import android.Manifest;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.os.Build;
import android.util.Log;
import com.google.firebase.messaging.FirebaseMessaging;
import com.vernu.sms.ApiManager;
import com.vernu.sms.AppConstants;
import com.vernu.sms.BuildConfig;
import com.vernu.sms.TextBeeUtils;
import com.vernu.sms.dtos.RegisterDeviceInputDTO;
import com.vernu.sms.dtos.RegisterDeviceResponseDTO;
import com.vernu.sms.helpers.SharedPreferenceHelper;
import com.vernu.sms.helpers.HeartbeatManager;
import com.vernu.sms.services.StickyNotificationService;
import retrofit2.Call;
import retrofit2.Callback;
import retrofit2.Response;
public class BootCompletedReceiver extends BroadcastReceiver {
private static final String TAG = "BootCompletedReceiver";
@Override
public void onReceive(Context context, Intent intent) {
if (Intent.ACTION_BOOT_COMPLETED.equals(intent.getAction())) {
boolean stickyNotificationEnabled = SharedPreferenceHelper.getSharedPreferenceBoolean(
context,
AppConstants.SHARED_PREFS_STICKY_NOTIFICATION_ENABLED_KEY,
false
);
if(stickyNotificationEnabled && TextBeeUtils.isPermissionGranted(context, Manifest.permission.RECEIVE_SMS)){
Log.i(TAG, "Device booted, starting sticky notification service");
TextBeeUtils.startStickyNotificationService(context);
}
// Report device info to server if device is registered
String deviceId = SharedPreferenceHelper.getSharedPreferenceString(
context,
AppConstants.SHARED_PREFS_DEVICE_ID_KEY,
""
);
String apiKey = SharedPreferenceHelper.getSharedPreferenceString(
context,
AppConstants.SHARED_PREFS_API_KEY_KEY,
""
);
// Only proceed if both device ID and API key are available
if (!deviceId.isEmpty() && !apiKey.isEmpty()) {
updateDeviceInfo(context, deviceId, apiKey);
// 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);
}
}
}
}
/**
* Updates device information on the server after boot
*/
private void updateDeviceInfo(Context context, String deviceId, String apiKey) {
FirebaseMessaging.getInstance().getToken()
.addOnCompleteListener(task -> {
if (!task.isSuccessful()) {
Log.e(TAG, "Failed to obtain FCM token after boot");
return;
}
String token = task.getResult();
RegisterDeviceInputDTO updateInput = new RegisterDeviceInputDTO();
updateInput.setFcmToken(token);
updateInput.setAppVersionCode(BuildConfig.VERSION_CODE);
updateInput.setAppVersionName(BuildConfig.VERSION_NAME);
Log.d(TAG, "Updating device info after boot - deviceId: " + deviceId);
ApiManager.getApiService()
.updateDevice(deviceId, apiKey, updateInput)
.enqueue(new Callback<RegisterDeviceResponseDTO>() {
@Override
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());
}
}
@Override
public void onFailure(Call<RegisterDeviceResponseDTO> call, Throwable t) {
Log.e(TAG, "Error updating device info after boot: " + t.getMessage());
}
});
});
}
}

View File

@@ -0,0 +1,100 @@
package com.vernu.sms.receivers
import android.Manifest
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.util.Log
import com.google.firebase.messaging.FirebaseMessaging
import com.vernu.sms.ApiManager
import com.vernu.sms.AppConstants
import com.vernu.sms.BuildConfig
import com.vernu.sms.TextBeeUtils
import com.vernu.sms.dtos.RegisterDeviceInputDTO
import com.vernu.sms.dtos.RegisterDeviceResponseDTO
import com.vernu.sms.helpers.HeartbeatManager
import com.vernu.sms.helpers.SharedPreferenceHelper
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
class BootCompletedReceiver : BroadcastReceiver() {
companion object {
private const val TAG = "BootCompletedReceiver"
}
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != Intent.ACTION_BOOT_COMPLETED) return
val stickyNotificationEnabled = SharedPreferenceHelper.getSharedPreferenceBoolean(
context, AppConstants.SHARED_PREFS_STICKY_NOTIFICATION_ENABLED_KEY, false
)
if (stickyNotificationEnabled && TextBeeUtils.isPermissionGranted(context, Manifest.permission.RECEIVE_SMS)) {
Log.i(TAG, "Device booted, starting sticky notification service")
TextBeeUtils.startStickyNotificationService(context)
}
val deviceId = SharedPreferenceHelper.getSharedPreferenceString(
context, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, ""
) ?: ""
val apiKey = SharedPreferenceHelper.getSharedPreferenceString(
context, AppConstants.SHARED_PREFS_API_KEY_KEY, ""
) ?: ""
if (deviceId.isNotEmpty() && apiKey.isNotEmpty()) {
updateDeviceInfo(context, deviceId, apiKey)
val deviceEnabled = SharedPreferenceHelper.getSharedPreferenceBoolean(
context, AppConstants.SHARED_PREFS_GATEWAY_ENABLED_KEY, false
)
if (deviceEnabled) {
Log.i(TAG, "Device booted, scheduling heartbeat")
HeartbeatManager.scheduleHeartbeat(context)
}
}
}
private fun updateDeviceInfo(context: Context, deviceId: String, apiKey: String) {
FirebaseMessaging.getInstance().token.addOnCompleteListener { task ->
if (!task.isSuccessful) {
Log.e(TAG, "Failed to obtain FCM token after boot")
return@addOnCompleteListener
}
val input = RegisterDeviceInputDTO().apply {
fcmToken = task.result
appVersionCode = BuildConfig.VERSION_CODE
appVersionName = BuildConfig.VERSION_NAME
}
Log.d(TAG, "Updating device info after boot - deviceId: $deviceId")
ApiManager.getApiService()
.updateDevice(deviceId, apiKey, input)
.enqueue(object : Callback<RegisterDeviceResponseDTO> {
override fun onResponse(
call: Call<RegisterDeviceResponseDTO>,
response: Response<RegisterDeviceResponseDTO>
) {
if (response.isSuccessful) {
Log.d(TAG, "Device info updated successfully after boot")
val data = response.body()?.data ?: return
val intervalObj = data["heartbeatIntervalMinutes"] as? Number ?: return
SharedPreferenceHelper.setSharedPreferenceInt(
context,
AppConstants.SHARED_PREFS_HEARTBEAT_INTERVAL_MINUTES_KEY,
intervalObj.toInt()
)
Log.d(TAG, "Synced heartbeat interval from server: ${intervalObj.toInt()} minutes")
} else {
Log.e(TAG, "Failed to update device info after boot. Response code: ${response.code()}")
}
}
override fun onFailure(call: Call<RegisterDeviceResponseDTO>, t: Throwable) {
Log.e(TAG, "Error updating device info after boot: ${t.message}")
}
})
}
}
}

View File

@@ -1,157 +0,0 @@
package com.vernu.sms.receivers;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.provider.Telephony;
import android.telephony.SmsMessage;
import android.util.Log;
import com.vernu.sms.AppConstants;
import com.vernu.sms.dtos.SMSDTO;
import com.vernu.sms.helpers.SharedPreferenceHelper;
import com.vernu.sms.helpers.SMSFilterHelper;
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<String, Long> processedFingerprints = new ConcurrentHashMap<>();
private static final long CACHE_TTL_MS = 5000; // 5 seconds
@Override
public void onReceive(Context context, Intent intent) {
Log.d(TAG, "onReceive: " + intent.getAction());
if (!Objects.equals(intent.getAction(), Telephony.Sms.Intents.SMS_RECEIVED_ACTION)) {
Log.d(TAG, "Not Valid intent");
return;
}
SmsMessage[] messages = Telephony.Sms.Intents.getMessagesFromIntent(intent);
if (messages == null) {
Log.d(TAG, "No messages found");
return;
}
String deviceId = SharedPreferenceHelper.getSharedPreferenceString(context, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, "");
String apiKey = SharedPreferenceHelper.getSharedPreferenceString(context, AppConstants.SHARED_PREFS_API_KEY_KEY, "");
boolean receiveSMSEnabled = SharedPreferenceHelper.getSharedPreferenceBoolean(context, AppConstants.SHARED_PREFS_RECEIVE_SMS_ENABLED_KEY, false);
if (deviceId.isEmpty() || apiKey.isEmpty() || !receiveSMSEnabled) {
Log.d(TAG, "Device ID or API Key is empty or Receive SMS Feature is disabled");
return;
}
// SMS receivedSMS = new SMS();
// receivedSMS.setType("RECEIVED");
// for (SmsMessage message : messages) {
// receivedSMS.setMessage(receivedSMS.getMessage() + message.getMessageBody());
// receivedSMS.setSender(message.getOriginatingAddress());
// receivedSMS.setReceivedAt(new Date(message.getTimestampMillis()));
// }
SMSDTO receivedSMSDTO = new SMSDTO();
for (SmsMessage message : messages) {
receivedSMSDTO.setMessage(receivedSMSDTO.getMessage() + message.getMessageBody());
receivedSMSDTO.setSender(message.getOriginatingAddress());
receivedSMSDTO.setReceivedAtInMillis(message.getTimestampMillis());
}
// receivedSMSDTO.setSender(receivedSMS.getSender());
// receivedSMSDTO.setMessage(receivedSMS.getMessage());
// receivedSMSDTO.setReceivedAt(receivedSMS.getReceivedAt());
// Apply SMS filter
String sender = receivedSMSDTO.getSender();
String message = receivedSMSDTO.getMessage();
if (sender != null && !SMSFilterHelper.shouldProcessSMS(sender, message, context)) {
Log.d(TAG, "SMS from " + sender + " filtered out by filter rules");
return;
}
// 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);
}
// private void updateLocalReceivedSMS(SMS localReceivedSMS, Context context) {
// Executors.newSingleThreadExecutor().execute(() -> {
// AppDatabase appDatabase = AppDatabase.getInstance(context);
// 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<String> 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");
}
}
}

View File

@@ -0,0 +1,100 @@
package com.vernu.sms.receivers
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.provider.Telephony
import android.util.Log
import com.vernu.sms.AppConstants
import com.vernu.sms.dtos.SMSDTO
import com.vernu.sms.helpers.SMSFilterHelper
import com.vernu.sms.helpers.SharedPreferenceHelper
import com.vernu.sms.workers.SMSReceivedWorker
import java.security.MessageDigest
import java.util.concurrent.ConcurrentHashMap
class SMSBroadcastReceiver : BroadcastReceiver() {
companion object {
private const val TAG = "SMSBroadcastReceiver"
private val processedFingerprints = ConcurrentHashMap<String, Long>()
private const val CACHE_TTL_MS = 5000L
}
override fun onReceive(context: Context, intent: Intent) {
Log.d(TAG, "onReceive: ${intent.action}")
if (intent.action != Telephony.Sms.Intents.SMS_RECEIVED_ACTION) {
Log.d(TAG, "Not Valid intent")
return
}
val messages = Telephony.Sms.Intents.getMessagesFromIntent(intent) ?: run {
Log.d(TAG, "No messages found")
return
}
val deviceId = SharedPreferenceHelper.getSharedPreferenceString(
context, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, ""
) ?: ""
val apiKey = SharedPreferenceHelper.getSharedPreferenceString(
context, AppConstants.SHARED_PREFS_API_KEY_KEY, ""
) ?: ""
val receiveSMSEnabled = SharedPreferenceHelper.getSharedPreferenceBoolean(
context, AppConstants.SHARED_PREFS_RECEIVE_SMS_ENABLED_KEY, false
)
if (deviceId.isEmpty() || apiKey.isEmpty() || !receiveSMSEnabled) {
Log.d(TAG, "Device ID or API Key is empty or Receive SMS Feature is disabled")
return
}
val dto = SMSDTO()
for (message in messages) {
dto.message += message.messageBody ?: ""
dto.sender = message.originatingAddress
dto.receivedAtInMillis = message.timestampMillis
}
val sender = dto.sender
if (sender != null && !SMSFilterHelper.shouldProcessSMS(sender, dto.message, context)) {
Log.d(TAG, "SMS from $sender filtered out by filter rules")
return
}
val fingerprint = generateFingerprint(dto.sender, dto.message, dto.receivedAtInMillis)
dto.fingerprint = fingerprint
val currentTime = System.currentTimeMillis()
val lastProcessedTime = processedFingerprints[fingerprint]
if (lastProcessedTime != null && (currentTime - lastProcessedTime) < CACHE_TTL_MS) {
Log.d(TAG, "Duplicate SMS detected in cache, skipping: $fingerprint")
return
}
processedFingerprints[fingerprint] = currentTime
cleanupCache(currentTime)
SMSReceivedWorker.enqueueWork(context, deviceId, apiKey, dto)
}
private fun generateFingerprint(sender: String?, message: String, timestamp: Long): String {
return try {
val data = "${sender ?: ""}|$message|$timestamp"
val hashBytes = MessageDigest.getInstance("MD5").digest(data.toByteArray(Charsets.UTF_8))
hashBytes.joinToString("") { "%02x".format(it) }
} catch (e: Exception) {
Log.e(TAG, "Error generating fingerprint: ${e.message}")
"${sender ?: ""}_${message}_$timestamp"
}
}
private fun cleanupCache(currentTime: Long) {
if (processedFingerprints.size > 100) {
val keysToRemove = processedFingerprints.entries
.filter { (currentTime - it.value) > CACHE_TTL_MS }
.map { it.key }
keysToRemove.forEach { processedFingerprints.remove(it) }
Log.d(TAG, "Cleaned up ${keysToRemove.size} expired cache entries")
}
}
}

View File

@@ -1,198 +0,0 @@
package com.vernu.sms.receivers;
import android.app.Activity;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.telephony.SmsManager;
import android.util.Log;
import java.lang.reflect.Field;
import java.lang.reflect.Modifier;
import com.vernu.sms.AppConstants;
import com.vernu.sms.dtos.SMSDTO;
import com.vernu.sms.helpers.SharedPreferenceHelper;
import com.vernu.sms.workers.SMSStatusUpdateWorker;
public class SMSStatusReceiver extends BroadcastReceiver {
private static final String TAG = "SMSStatusReceiver";
public static final String SMS_SENT = "SMS_SENT";
public static final String SMS_DELIVERED = "SMS_DELIVERED";
/**
* Resolves a result code to the constant name (e.g. SmsManager.RESULT_ERROR_GENERIC_FAILURE)
* via reflection. Returns null if no matching constant is found.
*/
private static String getResultCodeName(int resultCode) {
for (Class<?> clazz : new Class<?>[]{ SmsManager.class, Activity.class }) {
try {
for (Field field : clazz.getDeclaredFields()) {
if (field.getType() != int.class) continue;
if (!Modifier.isStatic(field.getModifiers()) || !Modifier.isFinal(field.getModifiers())) continue;
if (!field.getName().startsWith("RESULT_")) continue;
field.setAccessible(true);
if (field.getInt(null) == resultCode) {
return clazz.getSimpleName() + "." + field.getName();
}
}
} catch (Exception e) {
Log.w(TAG, "Reflection failed for " + clazz.getSimpleName() + ": " + e.getMessage());
}
}
return null;
}
@Override
public void onReceive(Context context, Intent intent) {
String smsId = intent.getStringExtra("sms_id");
String smsBatchId = intent.getStringExtra("sms_batch_id");
String action = intent.getAction();
SMSDTO smsDTO = new SMSDTO();
smsDTO.setSmsId(smsId);
smsDTO.setSmsBatchId(smsBatchId);
if (SMS_SENT.equals(action)) {
handleSentStatus(context, intent, getResultCode(), smsDTO);
} else if (SMS_DELIVERED.equals(action)) {
handleDeliveredStatus(context, getResultCode(), smsDTO);
}
}
private void handleSentStatus(Context context, Intent intent, int resultCode, SMSDTO smsDTO) {
long timestamp = System.currentTimeMillis();
String errorMessage = "";
switch (resultCode) {
case Activity.RESULT_OK:
smsDTO.setStatus("SENT");
smsDTO.setSentAtInMillis(timestamp);
Log.d(TAG, "SMS sent successfully - ID: " + smsDTO.getSmsId());
break;
case SmsManager.RESULT_ERROR_GENERIC_FAILURE:
errorMessage = "SMS failed on device. Common causes: no SMS credit on SIM, weak signal, or carrier blocked. Check SIM balance and signal, then try again.";
int radioCode = intent.getIntExtra("errorCode", -1);
if (radioCode != -1) {
errorMessage += " (code " + radioCode + ")";
}
smsDTO.setStatus("FAILED");
smsDTO.setFailedAtInMillis(timestamp);
smsDTO.setErrorCode(String.valueOf(resultCode));
smsDTO.setErrorMessage(errorMessage);
Log.e(TAG, "SMS failed to send - ID: " + smsDTO.getSmsId() + ", Error code: " + resultCode + ", Error: " + errorMessage);
break;
case SmsManager.RESULT_ERROR_RADIO_OFF:
errorMessage = "Mobile radio is off (e.g. airplane mode). Turn off airplane mode and ensure cellular is on.";
smsDTO.setStatus("FAILED");
smsDTO.setFailedAtInMillis(timestamp);
smsDTO.setErrorCode(String.valueOf(resultCode));
smsDTO.setErrorMessage(errorMessage);
Log.e(TAG, "SMS failed to send - ID: " + smsDTO.getSmsId() + ", Error code: " + resultCode + ", Error: " + errorMessage);
break;
case SmsManager.RESULT_ERROR_NULL_PDU:
errorMessage = "Message could not be sent; invalid format or carrier issue. Try a shorter message or different recipient.";
smsDTO.setStatus("FAILED");
smsDTO.setFailedAtInMillis(timestamp);
smsDTO.setErrorCode(String.valueOf(resultCode));
smsDTO.setErrorMessage(errorMessage);
Log.e(TAG, "SMS failed to send - ID: " + smsDTO.getSmsId() + ", Error code: " + resultCode + ", Error: " + errorMessage);
break;
case SmsManager.RESULT_ERROR_NO_SERVICE:
errorMessage = "No cellular service. Check signal and try again when you have coverage.";
smsDTO.setStatus("FAILED");
smsDTO.setFailedAtInMillis(timestamp);
smsDTO.setErrorCode(String.valueOf(resultCode));
smsDTO.setErrorMessage(errorMessage);
Log.e(TAG, "SMS failed to send - ID: " + smsDTO.getSmsId() + ", Error code: " + resultCode + ", Error: " + errorMessage);
break;
case SmsManager.RESULT_ERROR_LIMIT_EXCEEDED:
errorMessage = "Device/carrier send limit reached (too many SMS in a short time). Wait a few minutes or lower the send rate.";
smsDTO.setStatus("FAILED");
smsDTO.setFailedAtInMillis(timestamp);
smsDTO.setErrorCode(String.valueOf(resultCode));
smsDTO.setErrorMessage(errorMessage);
Log.e(TAG, "SMS failed to send - ID: " + smsDTO.getSmsId() + ", Error code: " + resultCode + ", Error: " + errorMessage);
break;
case SmsManager.RESULT_ERROR_SHORT_CODE_NOT_ALLOWED:
errorMessage = "Short code not allowed on this carrier. Use a full phone number.";
smsDTO.setStatus("FAILED");
smsDTO.setFailedAtInMillis(timestamp);
smsDTO.setErrorCode(String.valueOf(resultCode));
smsDTO.setErrorMessage(errorMessage);
Log.e(TAG, "SMS failed to send - ID: " + smsDTO.getSmsId() + ", Error code: " + resultCode + ", Error: " + errorMessage);
break;
case SmsManager.RESULT_ERROR_SHORT_CODE_NEVER_ALLOWED:
errorMessage = "Short codes are not supported on this carrier. Use a full phone number.";
smsDTO.setStatus("FAILED");
smsDTO.setFailedAtInMillis(timestamp);
smsDTO.setErrorCode(String.valueOf(resultCode));
smsDTO.setErrorMessage(errorMessage);
Log.e(TAG, "SMS failed to send - ID: " + smsDTO.getSmsId() + ", Error code: " + resultCode + ", Error: " + errorMessage);
break;
case SmsManager.RESULT_NETWORK_ERROR:
errorMessage = "Network error while sending. Check signal and try again.";
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:
String codeName = getResultCodeName(resultCode);
errorMessage = codeName != null ? codeName : ("Unknown error (code " + resultCode + ")");
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: " + errorMessage);
break;
}
updateSMSStatus(context, smsDTO);
}
private void handleDeliveredStatus(Context context, int resultCode, SMSDTO smsDTO) {
long timestamp = System.currentTimeMillis();
String errorMessage = "";
switch (resultCode) {
case Activity.RESULT_OK:
smsDTO.setStatus("DELIVERED");
smsDTO.setDeliveredAtInMillis(timestamp);
Log.d(TAG, "SMS delivered successfully - ID: " + smsDTO.getSmsId());
break;
case Activity.RESULT_CANCELED:
errorMessage = "Delivery report was canceled (e.g. carrier does not support delivery receipts). Message may still have been delivered.";
smsDTO.setStatus("DELIVERY_FAILED");
smsDTO.setErrorCode(String.valueOf(resultCode));
smsDTO.setErrorMessage(errorMessage);
Log.e(TAG, "SMS delivery failed - ID: " + smsDTO.getSmsId() + ", Error code: " + resultCode + ", Error: " + errorMessage);
break;
default:
String deliveryCodeName = getResultCodeName(resultCode);
errorMessage = deliveryCodeName != null ? deliveryCodeName : ("Unknown delivery error (code " + resultCode + ")");
smsDTO.setStatus("DELIVERY_FAILED");
smsDTO.setErrorCode(String.valueOf(resultCode));
smsDTO.setErrorMessage(errorMessage);
Log.e(TAG, "SMS delivery failed - ID: " + smsDTO.getSmsId() + ", Error: " + errorMessage);
break;
}
updateSMSStatus(context, smsDTO);
}
private void updateSMSStatus(Context context, SMSDTO smsDTO) {
String deviceId = SharedPreferenceHelper.getSharedPreferenceString(context, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, "");
String apiKey = SharedPreferenceHelper.getSharedPreferenceString(context, AppConstants.SHARED_PREFS_API_KEY_KEY, "");
if (deviceId.isEmpty() || apiKey.isEmpty()) {
Log.e(TAG, "Device ID or API key not found");
return;
}
SMSStatusUpdateWorker.enqueueWork(context, deviceId, apiKey, smsDTO);
}
}

View File

@@ -0,0 +1,138 @@
package com.vernu.sms.receivers
import android.app.Activity
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.telephony.SmsManager
import android.util.Log
import com.vernu.sms.AppConstants
import com.vernu.sms.dtos.SMSDTO
import com.vernu.sms.helpers.SharedPreferenceHelper
import com.vernu.sms.workers.SMSStatusUpdateWorker
import java.lang.reflect.Modifier
class SMSStatusReceiver : BroadcastReceiver() {
companion object {
private const val TAG = "SMSStatusReceiver"
const val SMS_SENT = "SMS_SENT"
const val SMS_DELIVERED = "SMS_DELIVERED"
private fun getResultCodeName(resultCode: Int): String? {
for (clazz in arrayOf<Class<*>>(SmsManager::class.java, Activity::class.java)) {
try {
for (field in clazz.declaredFields) {
if (field.type != Int::class.javaPrimitiveType) continue
if (!Modifier.isStatic(field.modifiers) || !Modifier.isFinal(field.modifiers)) continue
if (!field.name.startsWith("RESULT_")) continue
field.isAccessible = true
if (field.getInt(null) == resultCode) return "${clazz.simpleName}.${field.name}"
}
} catch (e: Exception) {
Log.w(TAG, "Reflection failed for ${clazz.simpleName}: ${e.message}")
}
}
return null
}
}
override fun onReceive(context: Context, intent: Intent) {
val smsId = intent.getStringExtra("sms_id")
val smsBatchId = intent.getStringExtra("sms_batch_id")
val smsDTO = SMSDTO().apply {
this.smsId = smsId
this.smsBatchId = smsBatchId
}
when (intent.action) {
SMS_SENT -> handleSentStatus(context, intent, resultCode, smsDTO)
SMS_DELIVERED -> handleDeliveredStatus(context, resultCode, smsDTO)
}
}
private fun handleSentStatus(context: Context, intent: Intent, resultCode: Int, smsDTO: SMSDTO) {
val timestamp = System.currentTimeMillis()
when (resultCode) {
Activity.RESULT_OK -> {
smsDTO.status = "SENT"
smsDTO.sentAtInMillis = timestamp
Log.d(TAG, "SMS sent successfully - ID: ${smsDTO.smsId}")
}
SmsManager.RESULT_ERROR_GENERIC_FAILURE -> {
val radioCode = intent.getIntExtra("errorCode", -1)
var msg = "SMS failed on device. Common causes: no SMS credit on SIM, weak signal, or carrier blocked. Check SIM balance and signal, then try again."
if (radioCode != -1) msg += " (code $radioCode)"
setFailed(smsDTO, timestamp, resultCode, msg)
Log.e(TAG, "SMS failed to send - ID: ${smsDTO.smsId}, Error code: $resultCode, Error: $msg")
}
SmsManager.RESULT_ERROR_RADIO_OFF -> setFailed(smsDTO, timestamp, resultCode,
"Mobile radio is off (e.g. airplane mode). Turn off airplane mode and ensure cellular is on.")
SmsManager.RESULT_ERROR_NULL_PDU -> setFailed(smsDTO, timestamp, resultCode,
"Message could not be sent; invalid format or carrier issue. Try a shorter message or different recipient.")
SmsManager.RESULT_ERROR_NO_SERVICE -> setFailed(smsDTO, timestamp, resultCode,
"No cellular service. Check signal and try again when you have coverage.")
SmsManager.RESULT_ERROR_LIMIT_EXCEEDED -> setFailed(smsDTO, timestamp, resultCode,
"Device/carrier send limit reached (too many SMS in a short time). Wait a few minutes or lower the send rate.")
SmsManager.RESULT_ERROR_SHORT_CODE_NOT_ALLOWED -> setFailed(smsDTO, timestamp, resultCode,
"Short code not allowed on this carrier. Use a full phone number.")
SmsManager.RESULT_ERROR_SHORT_CODE_NEVER_ALLOWED -> setFailed(smsDTO, timestamp, resultCode,
"Short codes are not supported on this carrier. Use a full phone number.")
SmsManager.RESULT_NETWORK_ERROR -> setFailed(smsDTO, timestamp, resultCode,
"Network error while sending. Check signal and try again.")
else -> {
val msg = getResultCodeName(resultCode) ?: "Unknown error (code $resultCode)"
setFailed(smsDTO, timestamp, resultCode, msg)
Log.e(TAG, "SMS failed to send - ID: ${smsDTO.smsId}, Error: $msg")
}
}
updateSMSStatus(context, smsDTO)
}
private fun handleDeliveredStatus(context: Context, resultCode: Int, smsDTO: SMSDTO) {
val timestamp = System.currentTimeMillis()
when (resultCode) {
Activity.RESULT_OK -> {
smsDTO.status = "DELIVERED"
smsDTO.deliveredAtInMillis = timestamp
Log.d(TAG, "SMS delivered successfully - ID: ${smsDTO.smsId}")
}
Activity.RESULT_CANCELED -> {
val msg = "Delivery report was canceled (e.g. carrier does not support delivery receipts). Message may still have been delivered."
smsDTO.status = "DELIVERY_FAILED"
smsDTO.errorCode = resultCode.toString()
smsDTO.errorMessage = msg
Log.e(TAG, "SMS delivery failed - ID: ${smsDTO.smsId}, Error: $msg")
}
else -> {
val msg = getResultCodeName(resultCode) ?: "Unknown delivery error (code $resultCode)"
smsDTO.status = "DELIVERY_FAILED"
smsDTO.errorCode = resultCode.toString()
smsDTO.errorMessage = msg
Log.e(TAG, "SMS delivery failed - ID: ${smsDTO.smsId}, Error: $msg")
}
}
updateSMSStatus(context, smsDTO)
}
private fun setFailed(smsDTO: SMSDTO, timestamp: Long, resultCode: Int, msg: String) {
smsDTO.status = "FAILED"
smsDTO.failedAtInMillis = timestamp
smsDTO.errorCode = resultCode.toString()
smsDTO.errorMessage = msg
}
private fun updateSMSStatus(context: Context, smsDTO: SMSDTO) {
val deviceId = SharedPreferenceHelper.getSharedPreferenceString(
context, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, ""
) ?: ""
val apiKey = SharedPreferenceHelper.getSharedPreferenceString(
context, AppConstants.SHARED_PREFS_API_KEY_KEY, ""
) ?: ""
if (deviceId.isEmpty() || apiKey.isEmpty()) {
Log.e(TAG, "Device ID or API key not found")
return
}
SMSStatusUpdateWorker.enqueueWork(context, deviceId, apiKey, smsDTO)
}
}

View File

@@ -1,204 +0,0 @@
package com.vernu.sms.services;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.media.RingtoneManager;
import android.net.Uri;
import android.os.Build;
import android.util.Log;
import androidx.core.app.NotificationCompat;
import com.google.firebase.messaging.FirebaseMessagingService;
import com.google.firebase.messaging.RemoteMessage;
import com.google.gson.Gson;
import com.vernu.sms.AppConstants;
import com.vernu.sms.R;
import com.vernu.sms.activities.MainActivity;
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.workers.SmsSendWorker;
import com.vernu.sms.dtos.RegisterDeviceInputDTO;
import com.vernu.sms.dtos.RegisterDeviceResponseDTO;
import com.vernu.sms.ApiManager;
import retrofit2.Call;
import retrofit2.Callback;
import retrofit2.Response;
public class FCMService extends FirebaseMessagingService {
private static final String TAG = "FirebaseMessagingService";
private static final String DEFAULT_NOTIFICATION_CHANNEL_ID = "N1";
@Override
public void onMessageReceived(RemoteMessage remoteMessage) {
Log.d(TAG, remoteMessage.getData().toString());
try {
// 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);
// Check if message contains a data payload
if (remoteMessage.getData().size() > 0) {
sendSMS(smsPayload);
}
// Handle any notification message
if (remoteMessage.getNotification() != null) {
// sendNotification("notif msg", "msg body");
}
} catch (Exception e) {
Log.e(TAG, "Error processing FCM message: " + e.getMessage());
}
}
/**
* 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);
}
}
/**
* Enqueue SMS to recipients via the device-side send queue.
* SIM resolution and rate limiting are handled by SmsSendWorker.
*/
private void sendSMS(SMSPayload smsPayload) {
if (smsPayload == null) {
Log.e(TAG, "SMS payload is null");
return;
}
String[] recipients = smsPayload.getRecipients();
if (recipients == null || recipients.length == 0) {
Log.e(TAG, "No recipients found in SMS payload");
return;
}
for (String recipient : recipients) {
SmsSendWorker.enqueue(this, recipient, smsPayload.getMessage(),
smsPayload.getSmsId(), smsPayload.getSmsBatchId(),
smsPayload.getSimSubscriptionId());
}
Log.d(TAG, "Enqueued " + recipients.length + " SMS for sending - Batch: " + smsPayload.getSmsBatchId());
}
@Override
public void onNewToken(String token) {
sendRegistrationToServer(token);
}
private void sendRegistrationToServer(String token) {
// Check if device ID and API key are saved in shared preferences
String deviceId = SharedPreferenceHelper.getSharedPreferenceString(this, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, "");
String apiKey = SharedPreferenceHelper.getSharedPreferenceString(this, AppConstants.SHARED_PREFS_API_KEY_KEY, "");
// Only proceed if both device ID and API key are available
if (deviceId.isEmpty() || apiKey.isEmpty()) {
Log.d(TAG, "Device ID or API key not available, skipping FCM token update");
return;
}
// Create update payload with new FCM token
RegisterDeviceInputDTO updateInput = new RegisterDeviceInputDTO();
updateInput.setFcmToken(token);
// Call API to update the device with new token
Log.d(TAG, "Updating FCM token for device: " + deviceId);
ApiManager.getApiService()
.updateDevice(deviceId, apiKey, updateInput)
.enqueue(new Callback<RegisterDeviceResponseDTO>() {
@Override
public void onResponse(Call<RegisterDeviceResponseDTO> call, Response<RegisterDeviceResponseDTO> response) {
if (response.isSuccessful()) {
Log.d(TAG, "FCM token updated successfully");
} else {
Log.e(TAG, "Failed to update FCM token. Response code: " + response.code());
}
}
@Override
public void onFailure(Call<RegisterDeviceResponseDTO> call, Throwable t) {
Log.e(TAG, "Error updating FCM token: " + t.getMessage());
}
});
}
/* build and show notification */
private void sendNotification(String title, String messageBody) {
Intent intent = new Intent(this, MainActivity.class);
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
PendingIntent pendingIntent = PendingIntent.getActivity(this, 0 /* Request code */, intent,
PendingIntent.FLAG_ONE_SHOT);
String channelId = DEFAULT_NOTIFICATION_CHANNEL_ID;
Uri defaultSoundUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION);
NotificationCompat.Builder notificationBuilder =
new NotificationCompat.Builder(this, DEFAULT_NOTIFICATION_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_launcher_foreground)
.setContentTitle(title)
.setContentText(messageBody)
.setAutoCancel(true)
.setSound(defaultSoundUri)
.setContentIntent(pendingIntent);
NotificationManager notificationManager =
(NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
// Since android Oreo notification channel is needed.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
NotificationChannel channel = new NotificationChannel(channelId,
"Channel human readable title",
NotificationManager.IMPORTANCE_DEFAULT);
notificationManager.createNotificationChannel(channel);
}
notificationManager.notify(0 /* ID of notification */, notificationBuilder.build());
}
}

View File

@@ -0,0 +1,173 @@
package com.vernu.sms.services
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.media.RingtoneManager
import android.os.Build
import android.util.Log
import androidx.core.app.NotificationCompat
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import com.google.gson.Gson
import com.vernu.sms.ApiManager
import com.vernu.sms.AppConstants
import com.vernu.sms.R
import com.vernu.sms.activities.MainActivity
import com.vernu.sms.dtos.RegisterDeviceInputDTO
import com.vernu.sms.dtos.RegisterDeviceResponseDTO
import com.vernu.sms.helpers.HeartbeatHelper
import com.vernu.sms.helpers.HeartbeatManager
import com.vernu.sms.helpers.SharedPreferenceHelper
import com.vernu.sms.models.SMSPayload
import com.vernu.sms.workers.SmsSendWorker
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
class FCMService : FirebaseMessagingService() {
companion object {
private const val TAG = "FirebaseMessagingService"
private const val DEFAULT_NOTIFICATION_CHANNEL_ID = "N1"
}
override fun onMessageReceived(remoteMessage: RemoteMessage) {
Log.d(TAG, remoteMessage.data.toString())
try {
val messageType = remoteMessage.data["type"]
if (messageType == "heartbeat_check") {
handleHeartbeatCheck()
return
}
val smsPayload = Gson().fromJson(remoteMessage.data["smsData"], SMSPayload::class.java)
if (remoteMessage.data.isNotEmpty()) {
sendSMS(smsPayload)
}
} catch (e: Exception) {
Log.e(TAG, "Error processing FCM message: ${e.message}")
}
}
private fun handleHeartbeatCheck() {
Log.d(TAG, "Received heartbeat check request from backend")
if (!HeartbeatHelper.isDeviceEligibleForHeartbeat(this)) {
Log.d(TAG, "Device not eligible for heartbeat, skipping heartbeat check")
return
}
val deviceId = SharedPreferenceHelper.getSharedPreferenceString(
this, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, ""
) ?: ""
val apiKey = SharedPreferenceHelper.getSharedPreferenceString(
this, AppConstants.SHARED_PREFS_API_KEY_KEY, ""
) ?: ""
val success = HeartbeatHelper.sendHeartbeat(this, deviceId, apiKey)
if (success) {
Log.d(TAG, "Heartbeat sent successfully in response to backend check")
} else {
Log.e(TAG, "Failed to send heartbeat in response to backend check")
}
HeartbeatManager.scheduleHeartbeat(this)
}
private fun sendSMS(smsPayload: SMSPayload?) {
if (smsPayload == null) {
Log.e(TAG, "SMS payload is null")
return
}
val recipients = smsPayload.recipients
if (recipients == null || recipients.isEmpty()) {
Log.e(TAG, "No recipients found in SMS payload")
return
}
for (recipient in recipients) {
SmsSendWorker.enqueue(
this, recipient, smsPayload.message ?: "",
smsPayload.smsId, smsPayload.smsBatchId, smsPayload.simSubscriptionId
)
}
Log.d(TAG, "Enqueued ${recipients.size} SMS for sending - Batch: ${smsPayload.smsBatchId}")
}
override fun onNewToken(token: String) {
sendRegistrationToServer(token)
}
private fun sendRegistrationToServer(token: String) {
val deviceId = SharedPreferenceHelper.getSharedPreferenceString(
this, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, ""
) ?: ""
val apiKey = SharedPreferenceHelper.getSharedPreferenceString(
this, AppConstants.SHARED_PREFS_API_KEY_KEY, ""
) ?: ""
if (deviceId.isEmpty() || apiKey.isEmpty()) {
Log.d(TAG, "Device ID or API key not available, skipping FCM token update")
return
}
val updateInput = RegisterDeviceInputDTO().apply { fcmToken = token }
Log.d(TAG, "Updating FCM token for device: $deviceId")
ApiManager.getApiService()
.updateDevice(deviceId, apiKey, updateInput)
.enqueue(object : Callback<RegisterDeviceResponseDTO> {
override fun onResponse(
call: Call<RegisterDeviceResponseDTO>,
response: Response<RegisterDeviceResponseDTO>
) {
if (response.isSuccessful) {
Log.d(TAG, "FCM token updated successfully")
} else {
Log.e(TAG, "Failed to update FCM token. Response code: ${response.code()}")
}
}
override fun onFailure(call: Call<RegisterDeviceResponseDTO>, t: Throwable) {
Log.e(TAG, "Error updating FCM token: ${t.message}")
}
})
}
private fun sendNotification(title: String, messageBody: String) {
val intent = Intent(this, MainActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
}
val pendingIntent = PendingIntent.getActivity(
this, 0, intent, PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE
)
val defaultSoundUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION)
val notificationBuilder = NotificationCompat.Builder(this, DEFAULT_NOTIFICATION_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_launcher_foreground)
.setContentTitle(title)
.setContentText(messageBody)
.setAutoCancel(true)
.setSound(defaultSoundUri)
.setContentIntent(pendingIntent)
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
notificationManager.createNotificationChannel(
NotificationChannel(
DEFAULT_NOTIFICATION_CHANNEL_ID,
"Channel human readable title",
NotificationManager.IMPORTANCE_DEFAULT
)
)
}
notificationManager.notify(0, notificationBuilder.build())
}
}

View File

@@ -1,107 +0,0 @@
package com.vernu.sms.services;
import android.app.ForegroundServiceStartNotAllowedException;
import android.app.*;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.os.IBinder;
import android.provider.Telephony;
import android.util.Log;
import android.widget.Toast;
import androidx.core.app.NotificationCompat;
import com.vernu.sms.R;
import com.vernu.sms.activities.MainActivity;
import com.vernu.sms.receivers.SMSBroadcastReceiver;
import com.vernu.sms.AppConstants;
import com.vernu.sms.helpers.SharedPreferenceHelper;
public class StickyNotificationService extends Service {
private static final String TAG = "StickyNotificationService";
@Override
public IBinder onBind(Intent intent) {
Log.i(TAG, "Service onBind " + intent.getAction());
return null;
}
@Override
public void onCreate() {
super.onCreate();
Log.i(TAG, "Service Started");
// Only show notification if enabled in preferences
boolean stickyNotificationEnabled = SharedPreferenceHelper.getSharedPreferenceBoolean(
getApplicationContext(),
AppConstants.SHARED_PREFS_STICKY_NOTIFICATION_ENABLED_KEY,
false
);
if (stickyNotificationEnabled) {
Notification notification = createNotification();
try {
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
startForeground(1, notification, android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_REMOTE_MESSAGING);
} else {
startForeground(1, notification);
}
Log.i(TAG, "Started foreground service with sticky notification");
} catch (ForegroundServiceStartNotAllowedException e) {
Log.w(TAG, "Cannot start foreground from background, stopping service: " + e.getMessage());
stopSelf();
return;
}
} else {
Log.i(TAG, "Sticky notification disabled by user preference");
}
}
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
Log.i(TAG, "Received start id " + startId + ": " + intent);
return START_STICKY;
}
@Override
public void onDestroy() {
super.onDestroy();
Log.i(TAG, "StickyNotificationService destroyed");
}
private Notification createNotification() {
String notificationChannelId = "stickyNotificationChannel";
NotificationManager notificationManager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
NotificationChannel channel = null;
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
channel = new NotificationChannel(notificationChannelId, notificationChannelId, NotificationManager.IMPORTANCE_HIGH);
channel.enableVibration(false);
channel.setShowBadge(false);
notificationManager.createNotificationChannel(channel);
Intent notificationIntent = new Intent(this, MainActivity.class);
PendingIntent pendingIntent = PendingIntent.getActivity(this, 0, notificationIntent, PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT);
Notification.Builder builder = new Notification.Builder(this, notificationChannelId);
return builder.setContentTitle("TextBee Active")
.setContentText("SMS gateway service is active")
.setContentIntent(pendingIntent)
.setOngoing(true)
.setSmallIcon(R.mipmap.ic_launcher)
.build();
} else {
NotificationCompat.Builder builder = new NotificationCompat.Builder(this, notificationChannelId);
return builder.setContentTitle("TextBee Active")
.setContentText("SMS gateway service is active")
.setOngoing(true)
.setSmallIcon(R.mipmap.ic_launcher)
.build();
}
}
}

View File

@@ -0,0 +1,100 @@
package com.vernu.sms.services
import android.app.*
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import android.util.Log
import androidx.core.app.NotificationCompat
import com.vernu.sms.AppConstants
import com.vernu.sms.R
import com.vernu.sms.activities.MainActivity
import com.vernu.sms.helpers.SharedPreferenceHelper
class StickyNotificationService : Service() {
companion object {
private const val TAG = "StickyNotificationService"
private const val NOTIFICATION_CHANNEL_ID = "stickyNotificationChannel"
private const val NOTIFICATION_ID = 1
}
override fun onBind(intent: Intent): IBinder? {
Log.i(TAG, "Service onBind ${intent.action}")
return null
}
override fun onCreate() {
super.onCreate()
Log.i(TAG, "Service Started")
val stickyNotificationEnabled = SharedPreferenceHelper.getSharedPreferenceBoolean(
applicationContext, AppConstants.SHARED_PREFS_STICKY_NOTIFICATION_ENABLED_KEY, false
)
if (stickyNotificationEnabled) {
val notification = createNotification()
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_REMOTE_MESSAGING)
} else {
startForeground(NOTIFICATION_ID, notification)
}
Log.i(TAG, "Started foreground service with sticky notification")
} catch (e: Exception) {
// ForegroundServiceStartNotAllowedException on API 31+ when app is in background
Log.w(TAG, "Cannot start foreground service (likely background restriction): ${e.message}")
stopSelf()
}
} else {
Log.i(TAG, "Sticky notification disabled by user preference")
}
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
Log.i(TAG, "Received start id $startId: $intent")
return START_STICKY
}
override fun onDestroy() {
super.onDestroy()
Log.i(TAG, "StickyNotificationService destroyed")
}
private fun createNotification(): Notification {
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
NOTIFICATION_CHANNEL_ID, NOTIFICATION_CHANNEL_ID, NotificationManager.IMPORTANCE_HIGH
).apply {
enableVibration(false)
setShowBadge(false)
}
notificationManager.createNotificationChannel(channel)
val pendingIntent = PendingIntent.getActivity(
this, 0,
Intent(this, MainActivity::class.java),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
Notification.Builder(this, NOTIFICATION_CHANNEL_ID)
.setContentTitle("TextBee Active")
.setContentText("SMS gateway service is active")
.setContentIntent(pendingIntent)
.setOngoing(true)
.setSmallIcon(R.mipmap.ic_launcher)
.build()
} else {
@Suppress("DEPRECATION")
NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
.setContentTitle("TextBee Active")
.setContentText("SMS gateway service is active")
.setOngoing(true)
.setSmallIcon(R.mipmap.ic_launcher)
.build()
}
}
}

View File

@@ -1,40 +1,89 @@
package com.vernu.sms.ui.dashboard
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.provider.Settings
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.MenuBook
import androidx.compose.material.icons.filled.OpenInBrowser
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.filled.SupportAgent
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vernu.sms.R
import com.vernu.sms.dtos.SimInfoDTO
import com.vernu.sms.dtos.SubscriptionResponse
import com.vernu.sms.dtos.UserProfile
import java.text.SimpleDateFormat
import java.util.Locale
import java.util.TimeZone
private val REQUIRED_PERMISSIONS = listOf(
Manifest.permission.SEND_SMS,
Manifest.permission.RECEIVE_SMS,
Manifest.permission.READ_PHONE_STATE
)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DashboardScreen(
viewModel: DashboardViewModel = viewModel()
) {
val state by viewModel.state.collectAsState()
val context = LocalContext.current
fun checkMissingPermissions() = REQUIRED_PERMISSIONS.filter {
ContextCompat.checkSelfPermission(context, it) != PackageManager.PERMISSION_GRANTED
}
var missingPermissions by remember { mutableStateOf(checkMissingPermissions()) }
var permissionsDenied by remember { mutableStateOf(false) }
val permissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) {
val stillMissing = checkMissingPermissions()
missingPermissions = stillMissing
if (stillMissing.isNotEmpty()) permissionsDenied = true
}
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
missingPermissions = checkMissingPermissions()
if (missingPermissions.isEmpty()) permissionsDenied = false
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
Scaffold(
topBar = {
@@ -47,12 +96,23 @@ fun DashboardScreen(
modifier = Modifier.size(28.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "textbee.dev",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
Column {
Text(
text = "textbee.dev",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
val greeting = state.userProfile?.name?.takeIf { it.isNotBlank() }
?: state.userProfile?.email?.takeIf { it.isNotBlank() }
if (greeting != null) {
Text(
text = "Hello, $greeting",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
},
actions = {
@@ -75,9 +135,25 @@ fun DashboardScreen(
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Spacer(modifier = Modifier.height(4.dp))
UserGreetingHeader(userProfile = state.userProfile)
DeviceStatusCard(state = state, onToggle = { viewModel.toggleGateway(it) })
StatsSection(state = state)
if (missingPermissions.isNotEmpty()) {
PermissionWarningCard(
missingPermissions = missingPermissions,
showOpenSettings = permissionsDenied,
onGrant = { permissionLauncher.launch(missingPermissions.toTypedArray()) },
onOpenSettings = {
context.startActivity(
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = Uri.fromParts("package", context.packageName, null)
}
)
}
)
}
DeviceStatusCard(
state = state,
onToggle = { viewModel.toggleGateway(it) },
onReceiveSmsToggle = { viewModel.setReceiveSms(it) }
)
SubscriptionCard(
subscription = state.subscription,
isLoading = state.isSubscriptionLoading,
@@ -89,24 +165,13 @@ fun DashboardScreen(
}
}
@Composable
private fun UserGreetingHeader(userProfile: UserProfile?) {
val displayName = userProfile?.name?.takeIf { it.isNotBlank() }
?: userProfile?.email?.takeIf { it.isNotBlank() }
?: return
Text(
text = "Hello, $displayName",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold
)
}
@Composable
private fun DeviceStatusCard(
state: DashboardState,
onToggle: (Boolean) -> Unit
onToggle: (Boolean) -> Unit,
onReceiveSmsToggle: (Boolean) -> Unit
) {
val clipboard = LocalClipboardManager.current
val statusColor = if (state.isGatewayEnabled) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant
val statusText = if (state.isGatewayEnabled) "Enabled" else "Disabled"
@@ -142,31 +207,197 @@ private fun DeviceStatusCard(
)
}
if (state.deviceId.isNotEmpty()) {
Text(
text = state.deviceId,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
)
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = state.deviceId,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
)
IconButton(
onClick = { clipboard.setText(AnnotatedString(state.deviceId)) },
modifier = Modifier.size(20.dp)
) {
Icon(
Icons.Default.ContentCopy,
contentDescription = "Copy Device ID",
modifier = Modifier.size(12.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
)
}
}
}
}
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Switch(
checked = state.isGatewayEnabled,
onCheckedChange = onToggle,
enabled = !state.isTogglingGateway
)
Text(
text = "Gateway",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
if (!state.isGatewayEnabled) {
Spacer(modifier = Modifier.height(12.dp))
Surface(
color = statusColor.copy(alpha = 0.15f),
shape = MaterialTheme.shapes.small
) {
Text(
text = statusText,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp),
style = MaterialTheme.typography.labelMedium,
color = statusColor,
fontWeight = FontWeight.SemiBold
)
}
}
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f).padding(end = 8.dp)) {
Text(
text = "Receive SMS",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = "Received messages appear in your dashboard and API",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
)
}
Switch(
checked = state.isGatewayEnabled,
onCheckedChange = onToggle,
enabled = !state.isTogglingGateway
checked = state.isReceiveSmsEnabled,
onCheckedChange = onReceiveSmsToggle,
modifier = Modifier.scale(0.75f)
)
}
Spacer(modifier = Modifier.height(12.dp))
Surface(
color = statusColor.copy(alpha = 0.15f),
shape = MaterialTheme.shapes.small
) {
Text(
text = statusText,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp),
style = MaterialTheme.typography.labelMedium,
color = statusColor,
fontWeight = FontWeight.SemiBold
if (state.availableSims.isNotEmpty()) {
SimCardsSection(sims = state.availableSims)
}
}
}
}
@Composable
private fun PermissionWarningCard(
missingPermissions: List<String>,
showOpenSettings: Boolean,
onGrant: () -> Unit,
onOpenSettings: () -> Unit
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) {
Column(modifier = Modifier.padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Default.Warning,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Permissions Required",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onErrorContainer
)
}
Spacer(modifier = Modifier.height(6.dp))
Text(
text = "The gateway won't work without: ${missingPermissions.joinToString { friendlyPermissionName(it) }}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onErrorContainer
)
Spacer(modifier = Modifier.height(12.dp))
Button(
onClick = if (showOpenSettings) onOpenSettings else onGrant,
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error,
contentColor = MaterialTheme.colorScheme.onError
)
) {
Text(if (showOpenSettings) "Open App Settings" else "Grant Permissions")
}
}
}
}
private fun friendlyPermissionName(permission: String) = when (permission) {
Manifest.permission.SEND_SMS -> "Send SMS"
Manifest.permission.RECEIVE_SMS -> "Receive SMS"
Manifest.permission.READ_PHONE_STATE -> "Phone State"
else -> permission.substringAfterLast(".")
}
@Composable
private fun SimCardsSection(sims: List<SimInfoDTO>) {
val context = LocalContext.current
val clipboard = LocalClipboardManager.current
Divider(modifier = Modifier.padding(top = 10.dp, bottom = 2.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "SIM Cards",
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = "simSubscriptionId",
style = MaterialTheme.typography.labelSmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
)
}
sims.forEach { sim ->
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "SIM ${(sim.simSlotIndex ?: 0) + 1} · ${sim.carrierName ?: sim.displayName ?: "Unknown"}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f)
)
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = sim.subscriptionId.toString(),
style = MaterialTheme.typography.labelSmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
IconButton(
onClick = {
clipboard.setText(AnnotatedString(sim.subscriptionId.toString()))
Toast.makeText(context, "Subscription ID copied", Toast.LENGTH_SHORT).show()
},
modifier = Modifier.size(28.dp)
) {
Icon(
Icons.Default.ContentCopy,
contentDescription = "Copy subscription ID",
modifier = Modifier.size(13.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
@@ -311,7 +542,7 @@ private fun UsageRow(label: String, used: Int?, limit: Int?, pct: Int?) {
Text(text = label, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(
text = if (isUnlimited) "${used ?: 0} / "
text = if (isUnlimited) "${used ?: 0} / Unlimited"
else "${used ?: 0} / ${limit ?: "—"}",
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Medium
@@ -332,157 +563,44 @@ private fun UsageRow(label: String, used: Int?, limit: Int?, pct: Int?) {
}
}
@Composable
private fun StatsSection(state: DashboardState) {
Text(
text = "All-Time Stats",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
when {
state.isStatsLoading -> {
Card(modifier = Modifier.fillMaxWidth()) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(32.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(modifier = Modifier.size(24.dp))
}
}
}
state.statsUnavailable -> {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Text(
text = "Stats unavailable",
modifier = Modifier.padding(16.dp),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
else -> {
val stats = state.stats
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
StatCard(label = "Total Sent", value = stats?.totalSentSMS?.toString() ?: "", modifier = Modifier.weight(1f))
StatCard(label = "Total Received", value = stats?.totalReceivedSMS?.toString() ?: "", modifier = Modifier.weight(1f))
}
}
}
}
@Composable
private fun StatCard(label: String, value: String, modifier: Modifier = Modifier) {
Card(
modifier = modifier,
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp)
) {
Column(
modifier = Modifier.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = value,
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = label,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
@Composable
private fun QuickActionsSection() {
val context = LocalContext.current
Text(
text = "Quick Actions",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
OutlinedButton(
onClick = {
context.startActivity(
Intent(Intent.ACTION_VIEW, Uri.parse("https://app.textbee.dev/dashboard"))
)
},
modifier = Modifier.weight(1f)
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = "Quick Actions",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Icon(Icons.Default.OpenInBrowser, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(modifier = Modifier.width(6.dp))
Text("Dashboard")
}
OutlinedButton(
onClick = {
context.startActivity(
Intent(Intent.ACTION_VIEW, Uri.parse("https://textbee.dev/docs"))
)
},
modifier = Modifier.weight(1f)
) {
Icon(Icons.Default.MenuBook, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(modifier = Modifier.width(6.dp))
Text("Explore Docs")
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
OutlinedButton(
onClick = {
context.startActivity(
Intent(Intent.ACTION_VIEW, Uri.parse("https://app.textbee.dev/dashboard/account/get-support"))
)
},
modifier = Modifier.weight(1f)
) {
Icon(Icons.Default.SupportAgent, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(modifier = Modifier.width(6.dp))
Text("Get Support")
}
OutlinedButton(
onClick = {
val shareText = "i've been using textbee.dev to send SMS via API from my own phone, " +
"no Twilio or paid services needed. works great for automations, alerts, " +
"notifications, or anything that needs programmatic SMS. open source and free to start\n\n" +
"https://textbee.dev"
context.startActivity(
Intent.createChooser(
Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, shareText)
},
"Share TextBee"
OutlinedButton(
onClick = {
context.startActivity(
Intent(Intent.ACTION_VIEW, Uri.parse("https://app.textbee.dev/dashboard"))
)
)
},
modifier = Modifier.weight(1f)
) {
Icon(Icons.Default.Share, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(modifier = Modifier.width(6.dp))
Text("Share")
},
modifier = Modifier.weight(1f)
) {
Icon(Icons.Default.OpenInBrowser, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(modifier = Modifier.width(6.dp))
Text("Dashboard")
}
OutlinedButton(
onClick = {
context.startActivity(
Intent(Intent.ACTION_VIEW, Uri.parse("https://textbee.dev/docs"))
)
},
modifier = Modifier.weight(1f)
) {
Icon(Icons.Default.MenuBook, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(modifier = Modifier.width(6.dp))
Text("Explore Docs")
}
}
}
}

View File

@@ -7,6 +7,7 @@ import com.vernu.sms.ApiManagerKt
import com.vernu.sms.AppConstants
import com.vernu.sms.TextBeeUtils
import com.vernu.sms.dtos.RegisterDeviceInputDTO
import com.vernu.sms.dtos.SimInfoDTO
import com.vernu.sms.dtos.SubscriptionResponse
import com.vernu.sms.dtos.UserProfile
import com.vernu.sms.helpers.HeartbeatManager
@@ -17,26 +18,18 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
data class GatewayStats(
val totalSentSMS: Int?,
val totalReceivedSMS: Int?,
val totalDevices: Int?,
val totalApiKeys: Int?
)
data class DashboardState(
val deviceName: String = "",
val deviceId: String = "",
val isGatewayEnabled: Boolean = false,
val lastHeartbeatMs: Long? = null,
val stats: GatewayStats? = null,
val isStatsLoading: Boolean = true,
val statsUnavailable: Boolean = false,
val isTogglingGateway: Boolean = false,
val subscription: SubscriptionResponse? = null,
val isSubscriptionLoading: Boolean = true,
val subscriptionUnavailable: Boolean = false,
val userProfile: UserProfile? = null
val userProfile: UserProfile? = null,
val availableSims: List<SimInfoDTO> = emptyList(),
val isReceiveSmsEnabled: Boolean = false
)
class DashboardViewModel(app: Application) : AndroidViewModel(app) {
@@ -48,14 +41,12 @@ class DashboardViewModel(app: Application) : AndroidViewModel(app) {
init {
loadLocalState()
fetchStats()
fetchSubscription()
fetchUserProfile()
}
fun refresh() {
loadLocalState()
fetchStats()
fetchSubscription()
fetchUserProfile()
}
@@ -75,52 +66,31 @@ class DashboardViewModel(app: Application) : AndroidViewModel(app) {
)
val lastHeartbeatMs = lastHeartbeatStr?.toLongOrNull()
val sims = try {
TextBeeUtils.collectSimInfo(context)
} catch (e: Exception) {
emptyList<SimInfoDTO>()
}
val isReceiveSms = SharedPreferenceHelper.getSharedPreferenceBoolean(
context, AppConstants.SHARED_PREFS_RECEIVE_SMS_ENABLED_KEY, false
)
_state.update {
it.copy(
deviceName = deviceName,
deviceId = deviceId,
isGatewayEnabled = isEnabled,
lastHeartbeatMs = lastHeartbeatMs
lastHeartbeatMs = lastHeartbeatMs,
availableSims = sims,
isReceiveSmsEnabled = isReceiveSms
)
}
}
private fun fetchStats() {
val apiKey = SharedPreferenceHelper.getSharedPreferenceString(
context, AppConstants.SHARED_PREFS_API_KEY_KEY, ""
) ?: ""
if (apiKey.isEmpty()) {
_state.update { it.copy(isStatsLoading = false, statsUnavailable = true) }
return
}
viewModelScope.launch {
_state.update { it.copy(isStatsLoading = true, statsUnavailable = false) }
try {
val response = ApiManagerKt.getApiService().getStats(apiKey)
if (response.isSuccessful) {
val data = response.body()?.data
_state.update {
it.copy(
isStatsLoading = false,
statsUnavailable = data == null,
stats = data?.let { d ->
GatewayStats(
totalSentSMS = d.totalSentSMSCount,
totalReceivedSMS = d.totalReceivedSMSCount,
totalDevices = d.totalDeviceCount,
totalApiKeys = d.totalApiKeyCount
)
}
)
}
} else {
_state.update { it.copy(isStatsLoading = false, statsUnavailable = true) }
}
} catch (e: Exception) {
_state.update { it.copy(isStatsLoading = false, statsUnavailable = true) }
TextBeeUtils.logException(e, "Dashboard stats fetch failed")
}
// Restart sticky notification service on every launch if it should be running,
// matching legacy MainActivity behaviour (service is killed by OS on modern Android).
if (isEnabled) {
TextBeeUtils.startStickyNotificationService(context)
if (deviceId.isNotEmpty()) HeartbeatManager.scheduleHeartbeat(context)
}
}
@@ -165,6 +135,13 @@ class DashboardViewModel(app: Application) : AndroidViewModel(app) {
}
}
fun setReceiveSms(enabled: Boolean) {
SharedPreferenceHelper.setSharedPreferenceBoolean(
context, AppConstants.SHARED_PREFS_RECEIVE_SMS_ENABLED_KEY, enabled
)
_state.update { it.copy(isReceiveSmsEnabled = enabled) }
}
fun toggleGateway(enabled: Boolean) {
val deviceId = _state.value.deviceId
val apiKey = SharedPreferenceHelper.getSharedPreferenceString(
@@ -175,7 +152,7 @@ class DashboardViewModel(app: Application) : AndroidViewModel(app) {
viewModelScope.launch {
_state.update { it.copy(isTogglingGateway = true) }
try {
val input = RegisterDeviceInputDTO().apply { setEnabled(enabled) }
val input = RegisterDeviceInputDTO().apply { this.enabled = enabled }
val response = ApiManagerKt.getApiService().updateDevice(deviceId, apiKey, input)
if (response.isSuccessful) {
SharedPreferenceHelper.setSharedPreferenceBoolean(

View File

@@ -23,13 +23,13 @@ import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import com.vernu.sms.AppConstants
import com.vernu.sms.activities.MainActivity
import com.vernu.sms.activities.SMSFilterActivity
import com.vernu.sms.helpers.HeartbeatManager
import com.vernu.sms.helpers.SharedPreferenceHelper
import com.vernu.sms.ui.dashboard.DashboardScreen
import com.vernu.sms.ui.messages.ComposeScreen
import com.vernu.sms.ui.messages.MessagesScreen
import com.vernu.sms.ui.onboarding.OnboardingActivity
import com.vernu.sms.ui.settings.SMSFilterScreen
import com.vernu.sms.ui.settings.SettingsScreen
import com.vernu.sms.ui.theme.TextBeeTheme
@@ -58,9 +58,6 @@ class NewMainActivity : ComponentActivity() {
}
)
},
onNavigateToFilters = {
startActivity(Intent(this, SMSFilterActivity::class.java))
},
onDisconnect = {
listOf(
AppConstants.SHARED_PREFS_DEVICE_ID_KEY,
@@ -88,12 +85,11 @@ class NewMainActivity : ComponentActivity() {
private fun MainScaffold(
navController: NavHostController,
onSwitchToLegacy: () -> Unit,
onNavigateToFilters: () -> Unit,
onDisconnect: () -> Unit
) {
val backStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = backStackEntry?.destination?.route
val showBottomBar = currentRoute != "compose"
val showBottomBar = currentRoute != "compose" && currentRoute != "filters"
Scaffold(
bottomBar = {
@@ -158,7 +154,7 @@ private fun MainScaffold(
composable(MainDestination.SETTINGS.name) {
SettingsScreen(
onSwitchToLegacy = onSwitchToLegacy,
onNavigateToFilters = onNavigateToFilters,
onNavigateToFilters = { navController.navigate("filters") },
onDisconnect = onDisconnect
)
}
@@ -167,6 +163,9 @@ private fun MainScaffold(
onNavigateBack = { navController.popBackStack() }
)
}
composable("filters") {
SMSFilterScreen(onNavigateBack = { navController.popBackStack() })
}
}
}
}

View File

@@ -92,21 +92,21 @@ class OnboardingViewModel : ViewModel() {
_state.update { it.copy(isLoading = true, errorMessage = null) }
try {
val fcmToken = getFcmToken()
val simInfo = SimInfoCollectionDTO().apply {
setLastUpdated(System.currentTimeMillis())
setSims(TextBeeUtils.collectSimInfo(context))
val collectedSimInfo = SimInfoCollectionDTO().apply {
lastUpdated = System.currentTimeMillis()
sims = TextBeeUtils.collectSimInfo(context)
}
val input = RegisterDeviceInputDTO().apply {
setFcmToken(fcmToken)
setBrand(Build.BRAND)
setManufacturer(Build.MANUFACTURER)
setModel(Build.MODEL)
setBuildId(Build.ID)
setOs(Build.VERSION.BASE_OS)
setAppVersionCode(BuildConfig.VERSION_CODE)
setAppVersionName(BuildConfig.VERSION_NAME)
setName(current.deviceName.ifEmpty { "${Build.BRAND} ${Build.MODEL}" })
setSimInfo(simInfo)
this.fcmToken = fcmToken
brand = Build.BRAND
manufacturer = Build.MANUFACTURER
model = Build.MODEL
buildId = Build.ID
os = Build.VERSION.BASE_OS
appVersionCode = BuildConfig.VERSION_CODE
appVersionName = BuildConfig.VERSION_NAME
name = current.deviceName.ifEmpty { "${Build.BRAND} ${Build.MODEL}" }
simInfo = collectedSimInfo
}
val response = if (shouldUpdate) {
@@ -136,6 +136,9 @@ class OnboardingViewModel : ViewModel() {
SharedPreferenceHelper.setSharedPreferenceString(
context, AppConstants.SHARED_PREFS_DEVICE_NAME_KEY, resolvedName
)
SharedPreferenceHelper.setSharedPreferenceBoolean(
context, AppConstants.SHARED_PREFS_GATEWAY_ENABLED_KEY, true
)
HeartbeatManager.scheduleHeartbeat(context)
_state.update {

View File

@@ -1,5 +1,7 @@
package com.vernu.sms.ui.onboarding.screens
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
@@ -14,6 +16,7 @@ import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
@@ -29,6 +32,7 @@ fun CredentialsScreen(
onBack: () -> Unit
) {
val state by viewModel.state.collectAsState()
val context = LocalContext.current
var selectedTab by remember { mutableStateOf(0) }
var showApiKey by remember { mutableStateOf(false) }
val tabs = listOf("Scan QR Code", "Enter Manually")
@@ -64,12 +68,25 @@ fun CredentialsScreen(
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Enter your API key from textbee.dev/dashboard",
text = "Enter your API key from your textbee.dev dashboard",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
TextButton(
onClick = {
context.startActivity(
Intent(Intent.ACTION_VIEW, Uri.parse("https://app.textbee.dev/register"))
)
},
contentPadding = PaddingValues(0.dp)
) {
Text(
text = "Don't have an account? Sign up free",
style = MaterialTheme.typography.bodySmall
)
}
Spacer(modifier = Modifier.height(24.dp))
Spacer(modifier = Modifier.height(16.dp))
TabRow(selectedTabIndex = selectedTab) {
tabs.forEachIndexed { index, title ->
@@ -122,12 +139,48 @@ private fun QrTab(
onScanQr: () -> Unit,
onSwitchToManual: () -> Unit
) {
val context = LocalContext.current
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = "Open textbee.dev/dashboard → Settings → Register Device → Show QR Code",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "1. Go to your textbee.dev dashboard",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f)
)
TextButton(
onClick = {
context.startActivity(
Intent(Intent.ACTION_VIEW, Uri.parse("https://app.textbee.dev/dashboard"))
)
},
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 0.dp)
) {
Text("Open", style = MaterialTheme.typography.bodySmall)
}
}
Text(
text = "2. Click \"Register Device\" or \"Generate API Key\"",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.fillMaxWidth()
)
Text(
text = "3. Scan the QR code shown on screen",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.fillMaxWidth()
)
}
Spacer(modifier = Modifier.height(24.dp))
@@ -196,6 +249,8 @@ private fun ManualTab(
onApiKeyChange: (String) -> Unit,
onToggleVisibility: () -> Unit
) {
val context = LocalContext.current
OutlinedTextField(
value = apiKey,
onValueChange = onApiKeyChange,
@@ -212,9 +267,19 @@ private fun ManualTab(
},
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
modifier = Modifier.fillMaxWidth(),
supportingText = {
Text("Find your API key at textbee.dev/dashboard")
},
singleLine = true
)
TextButton(
onClick = {
context.startActivity(
Intent(Intent.ACTION_VIEW, Uri.parse("https://app.textbee.dev/dashboard"))
)
},
contentPadding = PaddingValues(0.dp)
) {
Text(
text = "Get your API key at app.textbee.dev/dashboard",
style = MaterialTheme.typography.bodySmall
)
}
}

View File

@@ -63,7 +63,7 @@ fun DeviceSetupScreen(
text = if (state.isReturningUser)
"Enter your Device ID to reconnect this device to your account"
else
"Register this device as an SMS gateway",
"Give this device a name and register it to start sending SMS",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -97,10 +97,16 @@ fun DeviceSetupScreen(
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "I want to reuse an existing Device ID",
text = "This device was previously registered",
style = MaterialTheme.typography.bodyMedium
)
}
Text(
text = "Use this if you reinstalled the app and want to reconnect your existing device rather than create a new one in your dashboard",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 48.dp)
)
if (expandDeviceId) {
Spacer(modifier = Modifier.height(8.dp))

View File

@@ -21,6 +21,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
@@ -144,7 +145,26 @@ fun PermissionsScreen(
Spacer(modifier = Modifier.height(8.dp))
}
Spacer(modifier = Modifier.height(24.dp))
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "These permissions are only used to send and receive SMS on your behalf. textbee never accesses your existing message history.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
TextButton(
onClick = {
context.startActivity(
Intent(Intent.ACTION_VIEW, Uri.parse("https://textbee.dev/privacy-policy"))
)
},
contentPadding = PaddingValues(0.dp)
) {
Text("Privacy Policy", style = MaterialTheme.typography.bodySmall)
}
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = onContinue,

View File

@@ -1,6 +1,10 @@
package com.vernu.sms.ui.onboarding.screens
import android.content.Intent
import android.net.Uri
import androidx.compose.animation.core.*
import com.vernu.sms.AppConstants
import com.vernu.sms.helpers.SharedPreferenceHelper
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
@@ -15,6 +19,7 @@ import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.ClipboardManager
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
@@ -29,6 +34,8 @@ fun SetupCompleteScreen(
) {
val state by viewModel.state.collectAsState()
val clipboardManager: ClipboardManager = LocalClipboardManager.current
val context = LocalContext.current
var receiveSmsEnabled by remember { mutableStateOf(true) }
val scale = remember { Animatable(0f) }
LaunchedEffect(Unit) {
@@ -41,10 +48,13 @@ fun SetupCompleteScreen(
)
}
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
@@ -118,20 +128,64 @@ fun SetupCompleteScreen(
)
}
}
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Keep this handy. You will need it for API calls and managing devices in your dashboard.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
Spacer(modifier = Modifier.height(12.dp))
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) {
Text(
text = "Forward received SMS",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold
)
Spacer(modifier = Modifier.height(2.dp))
Text(
text = "SMS you receive on this phone will appear in your textbee dashboard and be accessible via API",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Switch(
checked = receiveSmsEnabled,
onCheckedChange = { enabled ->
receiveSmsEnabled = enabled
SharedPreferenceHelper.setSharedPreferenceBoolean(
context,
AppConstants.SHARED_PREFS_RECEIVE_SMS_ENABLED_KEY,
enabled
)
},
modifier = Modifier.scale(0.75f)
)
}
}
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Your gateway is ready to use. Configure it further from the Settings tab.",
text = "Your device is registered and ready. Head to your dashboard to send your first SMS or connect via API.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(40.dp))
Spacer(modifier = Modifier.height(32.dp))
Button(
onClick = onOpenDashboard,
@@ -139,7 +193,38 @@ fun SetupCompleteScreen(
.fillMaxWidth()
.height(52.dp)
) {
Text("Open Dashboard", style = MaterialTheme.typography.labelLarge)
Text("Open App Dashboard", style = MaterialTheme.typography.labelLarge)
}
Spacer(modifier = Modifier.height(12.dp))
OutlinedButton(
onClick = {
context.startActivity(
Intent(Intent.ACTION_VIEW, Uri.parse("https://app.textbee.dev/dashboard"))
)
},
modifier = Modifier
.fillMaxWidth()
.height(52.dp)
) {
Text("Open Web Dashboard", style = MaterialTheme.typography.labelLarge)
}
Spacer(modifier = Modifier.height(8.dp))
TextButton(
onClick = {
context.startActivity(
Intent(Intent.ACTION_VIEW, Uri.parse("https://textbee.dev/docs"))
)
}
) {
Text(
text = "New to textbee? Read the quickstart guide",
style = MaterialTheme.typography.bodySmall
)
}
}
}
}

View File

@@ -1,24 +1,29 @@
package com.vernu.sms.ui.onboarding.screens
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ChatBubble
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.vernu.sms.R
@Composable
fun WelcomeScreen(
onGetStarted: () -> Unit,
onHaveDeviceId: () -> Unit
) {
val context = LocalContext.current
Column(
modifier = Modifier
.fillMaxSize()
@@ -33,11 +38,10 @@ fun WelcomeScreen(
.background(MaterialTheme.colorScheme.primaryContainer, CircleShape),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Default.ChatBubble,
Image(
painter = painterResource(id = R.drawable.ic_app_logo),
contentDescription = null,
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.primary
modifier = Modifier.size(60.dp)
)
}
@@ -59,7 +63,38 @@ fun WelcomeScreen(
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(64.dp))
Spacer(modifier = Modifier.height(24.dp))
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
listOf(
"Create a free account at textbee.dev",
"Connect this phone as your SMS gateway",
"Send SMS via API from any app or automation"
).forEachIndexed { i, step ->
Row(
verticalAlignment = Alignment.Top,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = "${i + 1}.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Bold,
modifier = Modifier.width(20.dp)
)
Text(
text = step,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
Spacer(modifier = Modifier.height(32.dp))
Button(
onClick = onGetStarted,
@@ -78,15 +113,38 @@ fun WelcomeScreen(
.fillMaxWidth()
.height(52.dp)
) {
Text("I have a Device ID", style = MaterialTheme.typography.labelLarge)
Text("Reconnect a device", style = MaterialTheme.typography.labelLarge)
}
Spacer(modifier = Modifier.height(32.dp))
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "textbee.dev",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
TextButton(
onClick = {
context.startActivity(
Intent(Intent.ACTION_VIEW, Uri.parse("https://app.textbee.dev/register"))
)
}
) {
Text(
text = "Don't have an account? Sign up free",
style = MaterialTheme.typography.bodySmall
)
}
Spacer(modifier = Modifier.height(8.dp))
TextButton(
onClick = {
context.startActivity(
Intent(Intent.ACTION_VIEW, Uri.parse("https://textbee.dev"))
)
}
) {
Text(
text = "textbee.dev",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}

View File

@@ -0,0 +1,354 @@
package com.vernu.sms.ui.settings
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vernu.sms.helpers.SMSFilterHelper
import com.vernu.sms.models.SMSFilterRule
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SMSFilterScreen(
onNavigateBack: () -> Unit,
viewModel: SMSFilterViewModel = viewModel()
) {
val config by viewModel.config.collectAsState()
var showDialog by remember { mutableStateOf(false) }
var editingIndex by remember { mutableStateOf<Int?>(null) }
Scaffold(
topBar = {
TopAppBar(
title = { Text("SMS Filters", fontWeight = FontWeight.SemiBold) },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface
)
)
},
floatingActionButton = {
FloatingActionButton(onClick = {
editingIndex = null
showDialog = true
}) {
Icon(Icons.Default.Add, contentDescription = "Add rule")
}
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text("Enable SMS Filtering", style = MaterialTheme.typography.bodyLarge)
Text(
"Filter incoming SMS based on rules below",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Switch(
checked = config.enabled,
onCheckedChange = { viewModel.setEnabled(it) }
)
}
Divider()
if (config.enabled) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
FilterChip(
selected = config.mode == SMSFilterHelper.FilterMode.ALLOW_LIST,
onClick = { viewModel.setMode(SMSFilterHelper.FilterMode.ALLOW_LIST) },
label = { Text("Allow List") }
)
FilterChip(
selected = config.mode == SMSFilterHelper.FilterMode.BLOCK_LIST,
onClick = { viewModel.setMode(SMSFilterHelper.FilterMode.BLOCK_LIST) },
label = { Text("Block List") }
)
}
Text(
text = if (config.mode == SMSFilterHelper.FilterMode.ALLOW_LIST)
"Only SMS matching a rule will be forwarded"
else
"SMS matching a rule will be blocked",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 8.dp)
)
Divider()
}
if (config.rules.isEmpty()) {
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(
Icons.Default.FilterList,
contentDescription = null,
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
"No filter rules",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
"Tap + to add a rule",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
} else {
LazyColumn(modifier = Modifier.weight(1f)) {
itemsIndexed(config.rules) { index, rule ->
FilterRuleRow(
rule = rule,
onEdit = {
editingIndex = index
showDialog = true
},
onDelete = { viewModel.deleteRule(index) }
)
if (index < config.rules.lastIndex) {
Divider(modifier = Modifier.padding(start = 16.dp))
}
}
}
}
}
}
if (showDialog) {
FilterRuleDialog(
rule = editingIndex?.let { config.rules.getOrNull(it) },
onConfirm = { rule ->
val idx = editingIndex
if (idx != null) viewModel.updateRule(idx, rule) else viewModel.addRule(rule)
showDialog = false
},
onDismiss = { showDialog = false }
)
}
}
@Composable
private fun FilterRuleRow(
rule: SMSFilterRule,
onEdit: () -> Unit,
onDelete: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onEdit)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = rule.pattern ?: "",
fontWeight = FontWeight.SemiBold,
style = MaterialTheme.typography.bodyLarge
)
Spacer(modifier = Modifier.height(4.dp))
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
SuggestionChip(
onClick = {},
label = {
Text(
rule.matchType?.name?.replace('_', ' ')
?.lowercase()?.replaceFirstChar { it.uppercaseChar() } ?: "",
style = MaterialTheme.typography.labelSmall
)
}
)
SuggestionChip(
onClick = {},
label = {
Text(
rule.filterTarget.name.lowercase().replaceFirstChar { it.uppercaseChar() },
style = MaterialTheme.typography.labelSmall
)
}
)
if (rule.caseSensitive) {
SuggestionChip(
onClick = {},
label = { Text("Case sensitive", style = MaterialTheme.typography.labelSmall) }
)
}
}
}
IconButton(onClick = onDelete) {
Icon(
Icons.Default.Delete,
contentDescription = "Delete rule",
tint = MaterialTheme.colorScheme.error
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun FilterRuleDialog(
rule: SMSFilterRule?,
onConfirm: (SMSFilterRule) -> Unit,
onDismiss: () -> Unit
) {
var pattern by remember(rule) { mutableStateOf(rule?.pattern ?: "") }
var matchType by remember(rule) {
mutableStateOf(rule?.matchType ?: SMSFilterRule.MatchType.CONTAINS)
}
var filterTarget by remember(rule) {
mutableStateOf(rule?.filterTarget ?: SMSFilterRule.FilterTarget.SENDER)
}
var caseSensitive by remember(rule) { mutableStateOf(rule?.caseSensitive ?: false) }
var matchTypeExpanded by remember { mutableStateOf(false) }
var filterTargetExpanded by remember { mutableStateOf(false) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(if (rule == null) "Add Rule" else "Edit Rule") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
OutlinedTextField(
value = pattern,
onValueChange = { pattern = it },
label = { Text("Pattern") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
ExposedDropdownMenuBox(
expanded = filterTargetExpanded,
onExpandedChange = { filterTargetExpanded = !filterTargetExpanded }
) {
OutlinedTextField(
value = filterTarget.name.lowercase()
.replaceFirstChar { it.uppercaseChar() },
onValueChange = {},
readOnly = true,
label = { Text("Filter Target") },
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(expanded = filterTargetExpanded)
},
modifier = Modifier.menuAnchor().fillMaxWidth()
)
ExposedDropdownMenu(
expanded = filterTargetExpanded,
onDismissRequest = { filterTargetExpanded = false }
) {
SMSFilterRule.FilterTarget.values().forEach { target ->
DropdownMenuItem(
text = {
Text(target.name.lowercase().replaceFirstChar { it.uppercaseChar() })
},
onClick = {
filterTarget = target
filterTargetExpanded = false
}
)
}
}
}
ExposedDropdownMenuBox(
expanded = matchTypeExpanded,
onExpandedChange = { matchTypeExpanded = !matchTypeExpanded }
) {
OutlinedTextField(
value = matchType.name.replace('_', ' ').lowercase()
.replaceFirstChar { it.uppercaseChar() },
onValueChange = {},
readOnly = true,
label = { Text("Match Type") },
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(expanded = matchTypeExpanded)
},
modifier = Modifier.menuAnchor().fillMaxWidth()
)
ExposedDropdownMenu(
expanded = matchTypeExpanded,
onDismissRequest = { matchTypeExpanded = false }
) {
SMSFilterRule.MatchType.values().forEach { type ->
DropdownMenuItem(
text = {
Text(
type.name.replace('_', ' ').lowercase()
.replaceFirstChar { it.uppercaseChar() }
)
},
onClick = {
matchType = type
matchTypeExpanded = false
}
)
}
}
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Text(
"Case sensitive",
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f)
)
Switch(checked = caseSensitive, onCheckedChange = { caseSensitive = it })
}
}
},
confirmButton = {
TextButton(
onClick = {
onConfirm(SMSFilterRule(pattern.trim(), matchType, filterTarget, caseSensitive))
},
enabled = pattern.isNotBlank()
) { Text("Save") }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Cancel") }
}
)
}

View File

@@ -0,0 +1,37 @@
package com.vernu.sms.ui.settings
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import com.vernu.sms.helpers.SMSFilterHelper
import com.vernu.sms.models.SMSFilterRule
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
class SMSFilterViewModel(app: Application) : AndroidViewModel(app) {
private val _config = MutableStateFlow(SMSFilterHelper.loadFilterConfig(app))
val config: StateFlow<SMSFilterHelper.FilterConfig> = _config.asStateFlow()
fun setEnabled(enabled: Boolean) = mutate { it.enabled = enabled }
fun setMode(mode: SMSFilterHelper.FilterMode) = mutate { it.mode = mode }
fun addRule(rule: SMSFilterRule) = mutate { it.rules.add(rule) }
fun updateRule(index: Int, rule: SMSFilterRule) = mutate { it.rules[index] = rule }
fun deleteRule(index: Int) = mutate { it.rules.removeAt(index) }
private fun mutate(block: (SMSFilterHelper.FilterConfig) -> Unit) {
val current = _config.value
val copy = SMSFilterHelper.FilterConfig().apply {
enabled = current.enabled
mode = current.mode
rules = ArrayList(current.rules)
}
block(copy)
_config.value = copy
SMSFilterHelper.saveFilterConfig(getApplication(), copy)
}
}

View File

@@ -142,6 +142,12 @@ fun SettingsScreen(
selectedId = state.preferredSimSubscriptionId,
onSelect = { viewModel.setPreferredSim(it) }
)
Text(
text = "Use the simSubscriptionId field in your API requests to override this setting",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 56.dp, end = 16.dp, bottom = 8.dp)
)
}
SettingsSectionHeader("SMS")
@@ -220,6 +226,41 @@ fun SettingsScreen(
}
)
SettingsSectionHeader("Community")
SettingsRow(
icon = Icons.Default.SupportAgent,
title = "Get Support",
onClick = {
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://app.textbee.dev/dashboard/account/get-support")))
},
trailing = {
Icon(Icons.Default.OpenInBrowser, contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(18.dp))
}
)
SettingsRow(
icon = Icons.Default.Share,
title = "Share textbee",
subtitle = "Help spread the word",
onClick = {
val shareText = "i've been using textbee.dev to send SMS via API from my own phone, " +
"no Twilio or paid services needed. works great for automations, alerts, " +
"notifications, or anything that needs programmatic SMS. open source and free to start\n\n" +
"https://textbee.dev"
context.startActivity(
Intent.createChooser(
Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, shareText)
},
"Share TextBee"
)
)
}
)
SettingsSectionHeader("Legal")
SettingsRow(
@@ -506,7 +547,8 @@ private fun SimSelectionRow(
onExpandedChange = { expanded = !expanded }
) {
OutlinedTextField(
value = selectedSim?.displayName ?: "Device Default",
value = selectedSim?.let { "${it.displayName} · ID: ${it.subscriptionId}" }
?: "Device Default",
onValueChange = {},
readOnly = true,
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
@@ -528,7 +570,7 @@ private fun SimSelectionRow(
)
sims.forEach { sim ->
DropdownMenuItem(
text = { Text(sim.displayName) },
text = { Text("${sim.displayName} · ID: ${sim.subscriptionId}") },
onClick = {
onSelect(sim.subscriptionId)
expanded = false

View File

@@ -108,7 +108,7 @@ class SettingsViewModel(app: Application) : AndroidViewModel(app) {
viewModelScope.launch {
try {
val input = RegisterDeviceInputDTO().apply { setEnabled(enabled) }
val input = RegisterDeviceInputDTO().apply { this.enabled = enabled }
val response = ApiManagerKt.getApiService().updateDevice(deviceId, apiKey, input)
if (response.isSuccessful) {
SharedPreferenceHelper.setSharedPreferenceBoolean(
@@ -176,7 +176,7 @@ class SettingsViewModel(app: Application) : AndroidViewModel(app) {
viewModelScope.launch {
_state.update { it.copy(isSavingDeviceName = true) }
try {
val input = RegisterDeviceInputDTO().apply { setName(name.trim()) }
val input = RegisterDeviceInputDTO().apply { this.name = name.trim() }
val response = ApiManagerKt.getApiService().updateDevice(deviceId, apiKey, input)
if (response.isSuccessful) {
SharedPreferenceHelper.setSharedPreferenceString(

View File

@@ -71,7 +71,7 @@ fun TextBeeTheme(
if (!view.isInEditMode) {
SideEffect {
val window = (view.context as Activity).window
window.statusBarColor = colorScheme.primary.toArgb()
window.statusBarColor = colorScheme.background.toArgb()
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme
}
}

View File

@@ -1,55 +0,0 @@
package com.vernu.sms.workers;
import android.content.Context;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.work.Worker;
import androidx.work.WorkerParameters;
import com.vernu.sms.AppConstants;
import com.vernu.sms.helpers.SharedPreferenceHelper;
import com.vernu.sms.helpers.HeartbeatHelper;
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 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,
""
);
String apiKey = SharedPreferenceHelper.getSharedPreferenceString(
context,
AppConstants.SHARED_PREFS_API_KEY_KEY,
""
);
// Send heartbeat using shared helper
boolean success = HeartbeatHelper.sendHeartbeat(context, deviceId, apiKey);
if (success) {
return Result.success();
} else {
Log.e(TAG, "Failed to send heartbeat, will retry");
return Result.retry();
}
}
}

View File

@@ -0,0 +1,38 @@
package com.vernu.sms.workers
import android.content.Context
import android.util.Log
import androidx.work.Worker
import androidx.work.WorkerParameters
import com.vernu.sms.AppConstants
import com.vernu.sms.helpers.HeartbeatHelper
import com.vernu.sms.helpers.SharedPreferenceHelper
class HeartbeatWorker(context: Context, workerParams: WorkerParameters) : Worker(context, workerParams) {
companion object {
private const val TAG = "HeartbeatWorker"
}
override fun doWork(): Result {
val context = applicationContext
if (!HeartbeatHelper.isDeviceEligibleForHeartbeat(context)) {
Log.d(TAG, "Device not eligible for heartbeat, skipping")
return Result.success()
}
val deviceId = SharedPreferenceHelper.getSharedPreferenceString(
context, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, ""
) ?: ""
val apiKey = SharedPreferenceHelper.getSharedPreferenceString(
context, AppConstants.SHARED_PREFS_API_KEY_KEY, ""
) ?: ""
return if (HeartbeatHelper.sendHeartbeat(context, deviceId, apiKey)) {
Result.success()
} else {
Log.e(TAG, "Failed to send heartbeat, will retry")
Result.retry()
}
}
}

View File

@@ -1,115 +0,0 @@
package com.vernu.sms.workers;
import android.content.Context;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.work.Data;
import androidx.work.Worker;
import androidx.work.WorkerParameters;
import androidx.work.BackoffPolicy;
import androidx.work.Constraints;
import androidx.work.NetworkType;
import androidx.work.OneTimeWorkRequest;
import androidx.work.WorkManager;
import com.google.gson.Gson;
import com.vernu.sms.ApiManager;
import com.vernu.sms.dtos.SMSDTO;
import com.vernu.sms.dtos.SMSForwardResponseDTO;
import java.io.IOException;
import java.util.concurrent.TimeUnit;
import retrofit2.Call;
import retrofit2.Response;
public class SMSReceivedWorker extends Worker {
private static final String TAG = "SMSReceivedWorker";
private static final int MAX_RETRIES = 5;
public static final String KEY_DEVICE_ID = "device_id";
public static final String KEY_API_KEY = "api_key";
public static final String KEY_SMS_DTO = "sms_dto";
public static final String KEY_RETRY_COUNT = "retry_count";
public SMSReceivedWorker(@NonNull Context context, @NonNull WorkerParameters workerParams) {
super(context, workerParams);
}
@NonNull
@Override
public Result doWork() {
String deviceId = getInputData().getString(KEY_DEVICE_ID);
String apiKey = getInputData().getString(KEY_API_KEY);
String smsDtoJson = getInputData().getString(KEY_SMS_DTO);
int retryCount = getInputData().getInt(KEY_RETRY_COUNT, 0);
if (deviceId == null || apiKey == null || smsDtoJson == null) {
Log.e(TAG, "Missing required parameters");
return Result.failure();
}
// Check if we've exceeded the maximum retry count
if (retryCount >= MAX_RETRIES) {
Log.e(TAG, "Maximum retry count reached for received SMS");
return Result.failure();
}
SMSDTO smsDTO = new Gson().fromJson(smsDtoJson, SMSDTO.class);
try {
Call<SMSForwardResponseDTO> call = ApiManager.getApiService().sendReceivedSMS(deviceId, apiKey, smsDTO);
Response<SMSForwardResponseDTO> response = call.execute();
if (response.isSuccessful()) {
Log.d(TAG, "Received SMS sent to server successfully");
return Result.success();
} else {
Log.e(TAG, "Failed to send received SMS to server. Response code: " + response.code());
return Result.retry();
}
} catch (IOException e) {
Log.e(TAG, "API call failed: " + e.getMessage());
return Result.retry();
}
}
public static void enqueueWork(Context context, String deviceId, String apiKey, SMSDTO smsDTO) {
Data inputData = new Data.Builder()
.putString(KEY_DEVICE_ID, deviceId)
.putString(KEY_API_KEY, apiKey)
.putString(KEY_SMS_DTO, new Gson().toJson(smsDTO))
.putInt(KEY_RETRY_COUNT, 0)
.build();
Constraints constraints = new Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build();
OneTimeWorkRequest workRequest = new OneTimeWorkRequest.Builder(SMSReceivedWorker.class)
.setConstraints(constraints)
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 10, TimeUnit.SECONDS)
.setInputData(inputData)
.addTag("sms_received")
.build();
// 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.KEEP,
workRequest)
.enqueue();
Log.d(TAG, "Work enqueued for received SMS from: " + smsDTO.getSender() + " with fingerprint: " + uniqueWorkName);
}
}

View File

@@ -0,0 +1,90 @@
package com.vernu.sms.workers
import android.content.Context
import android.util.Log
import androidx.work.*
import com.google.gson.Gson
import com.vernu.sms.ApiManager
import com.vernu.sms.dtos.SMSDTO
import com.vernu.sms.dtos.SMSForwardResponseDTO
import java.io.IOException
import java.util.concurrent.TimeUnit
class SMSReceivedWorker(context: Context, workerParams: WorkerParameters) : Worker(context, workerParams) {
companion object {
private const val TAG = "SMSReceivedWorker"
private const val MAX_RETRIES = 5
const val KEY_DEVICE_ID = "device_id"
const val KEY_API_KEY = "api_key"
const val KEY_SMS_DTO = "sms_dto"
const val KEY_RETRY_COUNT = "retry_count"
fun enqueueWork(context: Context, deviceId: String, apiKey: String, smsDTO: SMSDTO) {
val inputData = Data.Builder()
.putString(KEY_DEVICE_ID, deviceId)
.putString(KEY_API_KEY, apiKey)
.putString(KEY_SMS_DTO, Gson().toJson(smsDTO))
.putInt(KEY_RETRY_COUNT, 0)
.build()
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
val workRequest = OneTimeWorkRequest.Builder(SMSReceivedWorker::class.java)
.setConstraints(constraints)
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 10, TimeUnit.SECONDS)
.setInputData(inputData)
.addTag("sms_received")
.build()
val fp = smsDTO.fingerprint
val uniqueWorkName = if (!fp.isNullOrEmpty()) {
"sms_received_$fp"
} else {
Log.w(TAG, "Fingerprint not available, using timestamp for work name")
"sms_received_${System.currentTimeMillis()}"
}
WorkManager.getInstance(context)
.beginUniqueWork(uniqueWorkName, ExistingWorkPolicy.KEEP, workRequest)
.enqueue()
Log.d(TAG, "Work enqueued for received SMS from: ${smsDTO.sender} with fingerprint: $uniqueWorkName")
}
}
override fun doWork(): Result {
val deviceId = inputData.getString(KEY_DEVICE_ID)
val apiKey = inputData.getString(KEY_API_KEY)
val smsDtoJson = inputData.getString(KEY_SMS_DTO)
val retryCount = inputData.getInt(KEY_RETRY_COUNT, 0)
if (deviceId == null || apiKey == null || smsDtoJson == null) {
Log.e(TAG, "Missing required parameters")
return Result.failure()
}
if (retryCount >= MAX_RETRIES) {
Log.e(TAG, "Maximum retry count reached for received SMS")
return Result.failure()
}
val smsDTO = Gson().fromJson(smsDtoJson, SMSDTO::class.java)
return try {
val response = ApiManager.getApiService().sendReceivedSMS(deviceId, apiKey, smsDTO).execute()
if (response.isSuccessful) {
Log.d(TAG, "Received SMS sent to server successfully")
Result.success()
} else {
Log.e(TAG, "Failed to send received SMS to server. Response code: ${response.code()}")
Result.retry()
}
} catch (e: IOException) {
Log.e(TAG, "API call failed: ${e.message}")
Result.retry()
}
}
}

View File

@@ -1,105 +0,0 @@
package com.vernu.sms.workers;
import android.content.Context;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.work.Data;
import androidx.work.Worker;
import androidx.work.WorkerParameters;
import androidx.work.BackoffPolicy;
import androidx.work.Constraints;
import androidx.work.NetworkType;
import androidx.work.OneTimeWorkRequest;
import androidx.work.WorkManager;
import com.google.gson.Gson;
import com.vernu.sms.ApiManager;
import com.vernu.sms.dtos.SMSDTO;
import com.vernu.sms.dtos.SMSForwardResponseDTO;
import java.io.IOException;
import java.util.concurrent.TimeUnit;
import retrofit2.Call;
import retrofit2.Response;
public class SMSStatusUpdateWorker extends Worker {
private static final String TAG = "SMSStatusUpdateWorker";
private static final int MAX_RETRIES = 5;
public static final String KEY_DEVICE_ID = "device_id";
public static final String KEY_API_KEY = "api_key";
public static final String KEY_SMS_DTO = "sms_dto";
public static final String KEY_RETRY_COUNT = "retry_count";
public SMSStatusUpdateWorker(@NonNull Context context, @NonNull WorkerParameters workerParams) {
super(context, workerParams);
}
@NonNull
@Override
public Result doWork() {
String deviceId = getInputData().getString(KEY_DEVICE_ID);
String apiKey = getInputData().getString(KEY_API_KEY);
String smsDtoJson = getInputData().getString(KEY_SMS_DTO);
int retryCount = getInputData().getInt(KEY_RETRY_COUNT, 0);
if (deviceId == null || apiKey == null || smsDtoJson == null) {
Log.e(TAG, "Missing required parameters");
return Result.failure();
}
// Check if we've exceeded the maximum retry count
if (retryCount >= MAX_RETRIES) {
Log.e(TAG, "Maximum retry count reached for SMS status update");
return Result.failure();
}
SMSDTO smsDTO = new Gson().fromJson(smsDtoJson, SMSDTO.class);
try {
Call<SMSForwardResponseDTO> call = ApiManager.getApiService().updateSMSStatus(deviceId, apiKey, smsDTO);
Response<SMSForwardResponseDTO> response = call.execute();
if (response.isSuccessful()) {
Log.d(TAG, "SMS status updated successfully - ID: " + smsDTO.getSmsId() + ", Status: " + smsDTO.getStatus());
return Result.success();
} else {
Log.e(TAG, "Failed to update SMS status. Response code: " + response.code());
return Result.retry();
}
} catch (IOException e) {
Log.e(TAG, "API call failed: " + e.getMessage());
return Result.retry();
}
}
public static void enqueueWork(Context context, String deviceId, String apiKey, SMSDTO smsDTO) {
Data inputData = new Data.Builder()
.putString(KEY_DEVICE_ID, deviceId)
.putString(KEY_API_KEY, apiKey)
.putString(KEY_SMS_DTO, new Gson().toJson(smsDTO))
.putInt(KEY_RETRY_COUNT, 0)
.build();
Constraints constraints = new Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build();
OneTimeWorkRequest workRequest = new OneTimeWorkRequest.Builder(SMSStatusUpdateWorker.class)
.setConstraints(constraints)
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 10, TimeUnit.SECONDS)
.setInputData(inputData)
.build();
String uniqueWorkName = "sms_status_" + smsDTO.getStatus() + "_" + System.currentTimeMillis();
WorkManager.getInstance(context)
.beginUniqueWork(uniqueWorkName,
androidx.work.ExistingWorkPolicy.REPLACE,
workRequest)
.enqueue();
Log.d(TAG, "Work enqueued for SMS status update - ID: " + smsDTO.getSmsId());
}
}

View File

@@ -0,0 +1,82 @@
package com.vernu.sms.workers
import android.content.Context
import android.util.Log
import androidx.work.*
import com.google.gson.Gson
import com.vernu.sms.ApiManager
import com.vernu.sms.dtos.SMSDTO
import com.vernu.sms.dtos.SMSForwardResponseDTO
import java.io.IOException
import java.util.concurrent.TimeUnit
class SMSStatusUpdateWorker(context: Context, workerParams: WorkerParameters) : Worker(context, workerParams) {
companion object {
private const val TAG = "SMSStatusUpdateWorker"
private const val MAX_RETRIES = 5
const val KEY_DEVICE_ID = "device_id"
const val KEY_API_KEY = "api_key"
const val KEY_SMS_DTO = "sms_dto"
const val KEY_RETRY_COUNT = "retry_count"
fun enqueueWork(context: Context, deviceId: String, apiKey: String, smsDTO: SMSDTO) {
val inputData = Data.Builder()
.putString(KEY_DEVICE_ID, deviceId)
.putString(KEY_API_KEY, apiKey)
.putString(KEY_SMS_DTO, Gson().toJson(smsDTO))
.putInt(KEY_RETRY_COUNT, 0)
.build()
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
val workRequest = OneTimeWorkRequest.Builder(SMSStatusUpdateWorker::class.java)
.setConstraints(constraints)
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 10, TimeUnit.SECONDS)
.setInputData(inputData)
.build()
val uniqueWorkName = "sms_status_${smsDTO.status}_${System.currentTimeMillis()}"
WorkManager.getInstance(context)
.beginUniqueWork(uniqueWorkName, ExistingWorkPolicy.REPLACE, workRequest)
.enqueue()
Log.d(TAG, "Work enqueued for SMS status update - ID: ${smsDTO.smsId}")
}
}
override fun doWork(): Result {
val deviceId = inputData.getString(KEY_DEVICE_ID)
val apiKey = inputData.getString(KEY_API_KEY)
val smsDtoJson = inputData.getString(KEY_SMS_DTO)
val retryCount = inputData.getInt(KEY_RETRY_COUNT, 0)
if (deviceId == null || apiKey == null || smsDtoJson == null) {
Log.e(TAG, "Missing required parameters")
return Result.failure()
}
if (retryCount >= MAX_RETRIES) {
Log.e(TAG, "Maximum retry count reached for SMS status update")
return Result.failure()
}
val smsDTO = Gson().fromJson(smsDtoJson, SMSDTO::class.java)
return try {
val response = ApiManager.getApiService().updateSMSStatus(deviceId, apiKey, smsDTO).execute()
if (response.isSuccessful) {
Log.d(TAG, "SMS status updated successfully - ID: ${smsDTO.smsId}, Status: ${smsDTO.status}")
Result.success()
} else {
Log.e(TAG, "Failed to update SMS status. Response code: ${response.code()}")
Result.retry()
}
} catch (e: IOException) {
Log.e(TAG, "API call failed: ${e.message}")
Result.retry()
}
}
}

View File

@@ -1,113 +0,0 @@
package com.vernu.sms.workers;
import android.content.Context;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.work.Data;
import androidx.work.ExistingWorkPolicy;
import androidx.work.OneTimeWorkRequest;
import androidx.work.Worker;
import androidx.work.WorkManager;
import androidx.work.WorkerParameters;
import com.vernu.sms.AppConstants;
import com.vernu.sms.TextBeeUtils;
import com.vernu.sms.helpers.SMSHelper;
import com.vernu.sms.helpers.SharedPreferenceHelper;
public class SmsSendWorker extends Worker {
private static final String TAG = "SmsSendWorker";
private static final String QUEUE_NAME = "sms_send_queue";
public static final String KEY_PHONE = "phone";
public static final String KEY_MESSAGE = "message";
public static final String KEY_SMS_ID = "sms_id";
public static final String KEY_SMS_BATCH_ID = "sms_batch_id";
public static final String KEY_SIM_SUBSCRIPTION_ID = "sim_subscription_id";
public SmsSendWorker(@NonNull Context context, @NonNull WorkerParameters workerParams) {
super(context, workerParams);
}
@NonNull
@Override
public Result doWork() {
String phone = getInputData().getString(KEY_PHONE);
String message = getInputData().getString(KEY_MESSAGE);
String smsId = getInputData().getString(KEY_SMS_ID);
String smsBatchId = getInputData().getString(KEY_SMS_BATCH_ID);
int simSubscriptionId = getInputData().getInt(KEY_SIM_SUBSCRIPTION_ID, -1);
if (phone == null || message == null || smsId == null) {
Log.e(TAG, "Missing required parameters");
return Result.failure();
}
Context context = getApplicationContext();
// Resolve SIM: backend-provided > app preference > device default
Integer resolvedSim = resolveSim(context, simSubscriptionId);
if (resolvedSim != null) {
SMSHelper.sendSMSFromSpecificSim(phone, message, resolvedSim, smsId, smsBatchId, context);
} else {
SMSHelper.sendSMS(phone, message, smsId, smsBatchId, context);
}
// Enforce rate limit delay
int delaySeconds = SharedPreferenceHelper.getSharedPreferenceInt(
context, AppConstants.SHARED_PREFS_SMS_SEND_DELAY_SECONDS_KEY, AppConstants.DEFAULT_SMS_SEND_DELAY_SECONDS);
delaySeconds = Math.max(0, Math.min(delaySeconds, 3600));
if (delaySeconds > 0) {
try {
Thread.sleep(delaySeconds * 1000L);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
return Result.success();
}
private Integer resolveSim(Context context, int backendSimId) {
// Priority 1: backend-provided SIM
if (backendSimId != -1 && TextBeeUtils.isValidSubscriptionId(context, backendSimId)) {
Log.d(TAG, "Using backend-provided SIM subscription ID: " + backendSimId);
return backendSimId;
}
// Priority 2: app preference
int preferredSim = SharedPreferenceHelper.getSharedPreferenceInt(
context, AppConstants.SHARED_PREFS_PREFERRED_SIM_KEY, -1);
if (preferredSim != -1 && TextBeeUtils.isValidSubscriptionId(context, preferredSim)) {
Log.d(TAG, "Using app-preferred SIM subscription ID: " + preferredSim);
return preferredSim;
}
// Priority 3: device default
return null;
}
public static void enqueue(Context context, String phone, String message,
String smsId, String smsBatchId, Integer simSubscriptionId) {
Data inputData = new Data.Builder()
.putString(KEY_PHONE, phone)
.putString(KEY_MESSAGE, message)
.putString(KEY_SMS_ID, smsId)
.putString(KEY_SMS_BATCH_ID, smsBatchId)
.putInt(KEY_SIM_SUBSCRIPTION_ID, simSubscriptionId != null ? simSubscriptionId : -1)
.build();
OneTimeWorkRequest workRequest = new OneTimeWorkRequest.Builder(SmsSendWorker.class)
.setInputData(inputData)
.build();
WorkManager.getInstance(context)
.beginUniqueWork(QUEUE_NAME, ExistingWorkPolicy.APPEND_OR_REPLACE, workRequest)
.enqueue();
Log.d(TAG, "SMS enqueued for sending - ID: " + smsId + ", Phone: " + phone);
}
}

View File

@@ -0,0 +1,99 @@
package com.vernu.sms.workers
import android.content.Context
import android.util.Log
import androidx.work.*
import com.vernu.sms.AppConstants
import com.vernu.sms.TextBeeUtils
import com.vernu.sms.helpers.SMSHelper
import com.vernu.sms.helpers.SharedPreferenceHelper
class SmsSendWorker(context: Context, workerParams: WorkerParameters) : Worker(context, workerParams) {
companion object {
private const val TAG = "SmsSendWorker"
private const val QUEUE_NAME = "sms_send_queue"
const val KEY_PHONE = "phone"
const val KEY_MESSAGE = "message"
const val KEY_SMS_ID = "sms_id"
const val KEY_SMS_BATCH_ID = "sms_batch_id"
const val KEY_SIM_SUBSCRIPTION_ID = "sim_subscription_id"
fun enqueue(
context: Context, phone: String, message: String,
smsId: String?, smsBatchId: String?, simSubscriptionId: Int?
) {
val inputData = Data.Builder()
.putString(KEY_PHONE, phone)
.putString(KEY_MESSAGE, message)
.putString(KEY_SMS_ID, smsId)
.putString(KEY_SMS_BATCH_ID, smsBatchId)
.putInt(KEY_SIM_SUBSCRIPTION_ID, simSubscriptionId ?: -1)
.build()
val workRequest = OneTimeWorkRequest.Builder(SmsSendWorker::class.java)
.setInputData(inputData)
.build()
WorkManager.getInstance(context)
.beginUniqueWork(QUEUE_NAME, ExistingWorkPolicy.APPEND_OR_REPLACE, workRequest)
.enqueue()
Log.d(TAG, "SMS enqueued for sending - ID: $smsId, Phone: $phone")
}
}
override fun doWork(): Result {
val phone = inputData.getString(KEY_PHONE)
val message = inputData.getString(KEY_MESSAGE)
val smsId = inputData.getString(KEY_SMS_ID)
val smsBatchId = inputData.getString(KEY_SMS_BATCH_ID)
val simSubscriptionId = inputData.getInt(KEY_SIM_SUBSCRIPTION_ID, -1)
if (phone == null || message == null || smsId == null) {
Log.e(TAG, "Missing required parameters")
return Result.failure()
}
val context = applicationContext
val resolvedSim = resolveSim(context, simSubscriptionId)
if (resolvedSim != null) {
SMSHelper.sendSMSFromSpecificSim(phone, message, resolvedSim, smsId, smsBatchId ?: "", context)
} else {
SMSHelper.sendSMS(phone, message, smsId, smsBatchId ?: "", context)
}
val delaySeconds = SharedPreferenceHelper.getSharedPreferenceInt(
context, AppConstants.SHARED_PREFS_SMS_SEND_DELAY_SECONDS_KEY,
AppConstants.DEFAULT_SMS_SEND_DELAY_SECONDS
).coerceIn(0, 3600)
if (delaySeconds > 0) {
try {
Thread.sleep(delaySeconds * 1000L)
} catch (e: InterruptedException) {
Thread.currentThread().interrupt()
}
}
return Result.success()
}
private fun resolveSim(context: Context, backendSimId: Int): Int? {
if (backendSimId != -1 && TextBeeUtils.isValidSubscriptionId(context, backendSimId)) {
Log.d(TAG, "Using backend-provided SIM subscription ID: $backendSimId")
return backendSimId
}
val preferredSim = SharedPreferenceHelper.getSharedPreferenceInt(
context, AppConstants.SHARED_PREFS_PREFERRED_SIM_KEY, -1
)
if (preferredSim != -1 && TextBeeUtils.isValidSubscriptionId(context, preferredSim)) {
Log.d(TAG, "Using app-preferred SIM subscription ID: $preferredSim")
return preferredSim
}
return null
}
}