mirror of
https://github.com/vernu/textbee.git
synced 2026-06-28 14:55:46 +00:00
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:
@@ -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()`
|
||||
|
||||
@@ -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();
|
||||
//}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
*/
|
||||
@@ -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();
|
||||
// }
|
||||
//}
|
||||
@@ -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
|
||||
}
|
||||
*/
|
||||
@@ -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;
|
||||
// }
|
||||
//}
|
||||
@@ -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);
|
||||
//
|
||||
//}
|
||||
@@ -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
|
||||
}
|
||||
*/
|
||||
@@ -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)
|
||||
}
|
||||
*/
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
package com.vernu.sms.dtos;
|
||||
|
||||
public class HeartbeatResponseDTO {
|
||||
public boolean success;
|
||||
public boolean fcmTokenUpdated;
|
||||
public long lastHeartbeat;
|
||||
public String name;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
16
android/app/src/main/java/com/vernu/sms/dtos/SMSDTO.kt
Normal file
16
android/app/src/main/java/com/vernu/sms/dtos/SMSDTO.kt
Normal 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
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
package com.vernu.sms.dtos;
|
||||
|
||||
public class SMSForwardResponseDTO {
|
||||
|
||||
public SMSForwardResponseDTO() {
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package com.vernu.sms.dtos
|
||||
|
||||
class SMSForwardResponseDTO
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.vernu.sms.dtos
|
||||
|
||||
class SimInfoCollectionDTO {
|
||||
var lastUpdated: Long = 0
|
||||
var sims: MutableList<SimInfoDTO>? = null
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
14
android/app/src/main/java/com/vernu/sms/dtos/SimInfoDTO.kt
Normal file
14
android/app/src/main/java/com/vernu/sms/dtos/SimInfoDTO.kt
Normal 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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
161
android/app/src/main/java/com/vernu/sms/helpers/SMSHelper.kt
Normal file
161
android/app/src/main/java/com/vernu/sms/helpers/SMSHelper.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
13
android/app/src/main/java/com/vernu/sms/models/SMSPayload.kt
Normal file
13
android/app/src/main/java/com/vernu/sms/models/SMSPayload.kt
Normal 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
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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}")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
173
android/app/src/main/java/com/vernu/sms/services/FCMService.kt
Normal file
173
android/app/src/main/java/com/vernu/sms/services/FCMService.kt
Normal 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())
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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() })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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") }
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user