diff --git a/.gitignore b/.gitignore index 586d6fc..774574e 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,6 @@ .DS_Store *-monitor -.cursor \ No newline at end of file +.cursor +.claude/ +.omc/ \ No newline at end of file diff --git a/android/BUILD_VARIANTS_SETUP.md b/android/BUILD_VARIANTS_SETUP.md new file mode 100644 index 0000000..1b7545f --- /dev/null +++ b/android/BUILD_VARIANTS_SETUP.md @@ -0,0 +1,107 @@ +# Android Build Variants Setup (Dev vs Prod) + +This document explains how to use the dev and prod build variants for the SMS Gateway Android app. + +## Overview + +The app now supports two build variants: +- **Dev**: For development and testing +- **Prod**: For production releases + +## Key Differences + +| Feature | Dev | Prod | +|---------|-----|------| +| Package Name | `com.vernu.sms.dev` | `com.vernu.sms` | +| App Name | "SMS Gateway (Dev)" | "SMS Gateway" | +| API Base URL | `https://api-dev.textbee.dev/api/v1/` | `https://api.textbee.dev/api/v1/` | +| Firebase Config | `app/src/dev/google-services.json` | `app/src/prod/google-services.json` | +| Version Suffix | `-dev` appended | No suffix | + +## Setup Instructions + +### 1. Firebase Configuration + +#### For Production: +- The current `google-services.json` has been moved to `app/src/prod/google-services.json` +- No changes needed if you're already using the production Firebase project + +#### For Development: +- Create a new Firebase project for development +- Download the `google-services.json` for your dev project +- **Important**: Make sure the package name in Firebase is set to `com.vernu.sms.dev` +- Replace the template file at `app/src/dev/google-services.json` with your actual dev configuration + +### 2. API Configuration + +The API base URLs are now configured via build variants: +- **Dev**: `https://api-dev.textbee.dev/api/v1/` +- **Prod**: `https://api.textbee.dev/api/v1/` + +To change the dev API URL, edit the `buildConfigField` in `app/build.gradle`: +```gradle +dev { + buildConfigField "String", "API_BASE_URL", '"https://api-dev.textbee.dev/api/v1/"' +} +``` + +## Building the App + +### Using Android Studio: +1. Open the "Build Variants" panel (View → Tool Windows → Build Variants) +2. Select the desired variant: + - `devDebug` - Development build for debugging + - `devRelease` - Development release build + - `prodDebug` - Production build for debugging + - `prodRelease` - Production release build + +### Using Command Line: +```bash +# Build dev debug +./gradlew assembleDevDebug + +# Build dev release +./gradlew assembleDevRelease + +# Build prod debug +./gradlew assembleProdDebug + +# Build prod release +./gradlew assembleProdRelease +``` + +## Installation + +Both variants can be installed simultaneously on the same device since they have different package names: +- Dev app will show as "SMS Gateway (Dev)" +- Prod app will show as "SMS Gateway" + +## Environment Detection + +You can detect which environment the app is running in using: +```java +if (BuildConfig.ENVIRONMENT.equals("development")) { + // Development-specific code +} else { + // Production code +} +``` + +## Important Notes + +1. **Different Package Names**: Dev and prod apps are completely separate and can coexist +2. **Separate Data**: Each variant maintains its own app data and preferences +3. **Firebase Projects**: Use separate Firebase projects for dev and prod +4. **API Endpoints**: Ensure your backend has corresponding dev/prod endpoints +5. **Testing**: Always test the prod build before releasing + +## Troubleshooting + +### Build Errors: +- Ensure both `google-services.json` files are properly configured +- Check that package names match between Firebase console and app configuration +- Verify API URLs are accessible + +### Firebase Issues: +- Confirm the package name in Firebase matches the variant (`com.vernu.sms.dev` for dev) +- Ensure SHA-1 fingerprints are added to Firebase if using authentication \ No newline at end of file diff --git a/android/MIGRATION.md b/android/MIGRATION.md new file mode 100644 index 0000000..13d99db --- /dev/null +++ b/android/MIGRATION.md @@ -0,0 +1,191 @@ +# Android Kotlin/Compose Migration + +## Overview + +TextBee Android is mid-migration from a Java/XML legacy codebase to Kotlin + Jetpack Compose. The new UI runs in parallel with the legacy UI — users can switch between them via Settings. The SplashActivity routes to the appropriate UI on launch. + +--- + +## What's Done + +### Theme +| File | Notes | +|---|---| +| `ui/theme/Color.kt` | Brand orange (`#C4620A`), full light/dark palette | +| `ui/theme/Theme.kt` | `TextBeeTheme` wrapper, `dynamicColor = false` to preserve brand color | +| `ui/theme/Type.kt` | Material3 typography scale | + +### Infrastructure +| File | Notes | +|---|---| +| `ApiManagerKt.kt` | Kotlin singleton Retrofit client, mirrors `ApiManager.java` | +| `services/GatewayApiServiceKt.kt` | Kotlin suspend-function Retrofit interface (all new UI endpoints) | +| `dtos/GatewayStatsResponse.kt` | Nullable stats DTO | +| `dtos/SubscriptionResponse.kt` | Plan, usage, renewal DTO | +| `dtos/UserProfileResponse.kt` | `name`, `email` from `/auth/who-am-i` | +| `dtos/MessagesResponse.kt` | `SmsMessage`, `PaginationMeta`, `SendSmsRequest` | + +### UI — Onboarding +| File | Notes | +|---|---| +| `ui/onboarding/OnboardingActivity.kt` | Compose NavHost shell | +| `ui/onboarding/OnboardingViewModel.kt` | Registration state + API calls | +| `ui/onboarding/screens/WelcomeScreen.kt` | Get Started / I have a Device ID | +| `ui/onboarding/screens/CredentialsScreen.kt` | QR scan + manual API key entry | +| `ui/onboarding/screens/DeviceSetupScreen.kt` | Register or reconnect device | +| `ui/onboarding/screens/PermissionsScreen.kt` | SMS + phone state permissions | +| `ui/onboarding/screens/SetupCompleteScreen.kt` | Success + navigate to dashboard | +| `ui/onboarding/screens/OnboardingComponents.kt` | Shared step indicator, etc. | + +### UI — Main App +| File | Notes | +|---|---| +| `ui/splash/SplashActivity.kt` | Routes to legacy/onboarding/main based on SharedPrefs | +| `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 | +| `ui/messages/MessagesViewModel.kt` | Paginated message fetch, filter state | +| `ui/messages/ComposeScreen.kt` | Multi-recipient input, message field, send with snackbar feedback | +| `ui/messages/ComposeViewModel.kt` | Send SMS, error body parsing, success/error state | +| `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? = 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) + +### Core App +| File | Priority | Notes | +|---|---|---| +| `AppConstants.java` | Low | Constants only — convert when touching other things | +| `SMSGatewayApplication.java` | Low | Application class, minimal logic | +| `TextBeeUtils.java` | Medium | Heavily used utility; convert once helpers are stable | +| `ApiManager.java` | Low | Still used by legacy UI; delete after legacy removal | + +### Activities (Legacy UI) +| File | Priority | Notes | +|---|---|---| +| `activities/MainActivity.java` | High | Legacy main UI — remove after full Compose rollout | +| `activities/SMSFilterActivity.java` | Medium | Legacy filter screen — still reachable from legacy UI only | + +### Helpers +| File | Priority | Notes | +|---|---|---| +| `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 | + +--- + +## Migration Roadmap + +### 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 ✅ 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 ✅ 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 ✅ Complete +All workers, receivers, and services ported to Kotlin; Java originals deleted. + +| 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. + +--- + +### Phase 7 — Legacy UI Removal +Once Compose UI is stable and rolled out to all users, remove the legacy UI entirely. + +**Steps:** +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 `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 + +--- + +## Key Constraints to Keep in Mind + +- **`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** — 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()` diff --git a/android/app/build.gradle b/android/app/build.gradle index aaf6f87..2968dea 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -1,16 +1,18 @@ plugins { id 'com.android.application' + id 'com.google.gms.google-services' id 'com.google.firebase.crashlytics' + id 'org.jetbrains.kotlin.android' } android { - compileSdk 32 + compileSdk 34 defaultConfig { minSdk 24 - targetSdk 32 - versionCode 17 - versionName "2.7.1" + targetSdk 34 + versionCode 18 + versionName "2.8.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" @@ -68,6 +70,18 @@ android { sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } + + kotlinOptions { + jvmTarget = '1.8' + } + + buildFeatures { + compose true + } + + composeOptions { + kotlinCompilerExtensionVersion '1.4.8' + } } dependencies { @@ -88,13 +102,29 @@ dependencies { implementation 'com.google.code.gson:gson:2.9.0' implementation 'com.squareup.retrofit2:retrofit:2.9.0' implementation 'com.squareup.retrofit2:converter-gson:2.9.0' - implementation 'com.journeyapps:zxing-android-embedded:4.1.0' + implementation 'com.journeyapps:zxing-android-embedded:4.3.0' implementation 'androidx.work:work-runtime:2.7.1' + // Jetpack Compose + implementation platform('androidx.compose:compose-bom:2023.08.00') + implementation 'androidx.compose.ui:ui' + implementation 'androidx.compose.material3:material3' + implementation 'androidx.compose.ui:ui-tooling-preview' + implementation 'androidx.compose.material:material-icons-extended' + debugImplementation 'androidx.compose.ui:ui-tooling' + + // Compose integration + implementation 'androidx.activity:activity-compose:1.7.2' + implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.6.1' + implementation 'androidx.navigation:navigation-compose:2.7.2' + + // Coroutines + Lifecycle + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3' + implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.6.1' + // def room_version = "2.4.2" // implementation "androidx.room:room-runtime:$room_version" // annotationProcessor "androidx.room:room-compiler:$room_version" } -apply plugin: 'com.google.gms.google-services' \ No newline at end of file diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index b9864ee..601b57d 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -12,6 +12,7 @@ + + android:exported="false" + android:foregroundServiceType="remoteMessaging"> - + + + { + SharedPreferenceHelper.setSharedPreferenceBoolean(mContext, AppConstants.SHARED_PREFS_USE_NEW_UI_KEY, true); + Intent intent = new Intent(MainActivity.this, com.vernu.sms.ui.splash.SplashActivity.class); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); + startActivity(intent); + }); + // SMS Send Delay setting: save 3 seconds after user stops typing int currentDelay = SharedPreferenceHelper.getSharedPreferenceInt( mContext, AppConstants.SHARED_PREFS_SMS_SEND_DELAY_SECONDS_KEY, AppConstants.DEFAULT_SMS_SEND_DELAY_SECONDS); diff --git a/android/app/src/main/java/com/vernu/sms/database/local/AppDatabase.java b/android/app/src/main/java/com/vernu/sms/database/local/AppDatabase.java deleted file mode 100644 index 8ae2586..0000000 --- a/android/app/src/main/java/com/vernu/sms/database/local/AppDatabase.java +++ /dev/null @@ -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(); -//} \ No newline at end of file diff --git a/android/app/src/main/java/com/vernu/sms/database/local/AppDatabase.kt b/android/app/src/main/java/com/vernu/sms/database/local/AppDatabase.kt new file mode 100644 index 0000000..1042128 --- /dev/null +++ b/android/app/src/main/java/com/vernu/sms/database/local/AppDatabase.kt @@ -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 } + } + } + } +} +*/ diff --git a/android/app/src/main/java/com/vernu/sms/database/local/DateConverter.java b/android/app/src/main/java/com/vernu/sms/database/local/DateConverter.java deleted file mode 100644 index e540f0b..0000000 --- a/android/app/src/main/java/com/vernu/sms/database/local/DateConverter.java +++ /dev/null @@ -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(); -// } -//} \ No newline at end of file diff --git a/android/app/src/main/java/com/vernu/sms/database/local/DateConverter.kt b/android/app/src/main/java/com/vernu/sms/database/local/DateConverter.kt new file mode 100644 index 0000000..43dff90 --- /dev/null +++ b/android/app/src/main/java/com/vernu/sms/database/local/DateConverter.kt @@ -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 +} +*/ diff --git a/android/app/src/main/java/com/vernu/sms/database/local/SMS.java b/android/app/src/main/java/com/vernu/sms/database/local/SMS.java deleted file mode 100644 index 297417c..0000000 --- a/android/app/src/main/java/com/vernu/sms/database/local/SMS.java +++ /dev/null @@ -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; -// } -//} \ No newline at end of file diff --git a/android/app/src/main/java/com/vernu/sms/database/local/SMSDao.java b/android/app/src/main/java/com/vernu/sms/database/local/SMSDao.java deleted file mode 100644 index 541ba1e..0000000 --- a/android/app/src/main/java/com/vernu/sms/database/local/SMSDao.java +++ /dev/null @@ -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 getAll(); -// -// @Query("SELECT * FROM sms WHERE id IN (:smsIds)") -// List loadAllByIds(int[] smsIds); -// -// @Insert(onConflict = OnConflictStrategy.REPLACE) -// void insertAll(SMS... sms); -// -// -// @Delete -// void delete(SMS sms); -// -//} \ No newline at end of file diff --git a/android/app/src/main/java/com/vernu/sms/database/local/Sms.kt b/android/app/src/main/java/com/vernu/sms/database/local/Sms.kt new file mode 100644 index 0000000..20e00bf --- /dev/null +++ b/android/app/src/main/java/com/vernu/sms/database/local/Sms.kt @@ -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 +} +*/ diff --git a/android/app/src/main/java/com/vernu/sms/database/local/SmsDao.kt b/android/app/src/main/java/com/vernu/sms/database/local/SmsDao.kt new file mode 100644 index 0000000..4fe80f9 --- /dev/null +++ b/android/app/src/main/java/com/vernu/sms/database/local/SmsDao.kt @@ -0,0 +1,20 @@ +package com.vernu.sms.database.local + +/* +import androidx.room.* + +@Dao +interface SmsDao { + @Query("SELECT * FROM sms") + suspend fun getAll(): List + + @Query("SELECT * FROM sms WHERE id IN (:smsIds)") + suspend fun loadAllByIds(smsIds: IntArray): List + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAll(vararg sms: Sms) + + @Delete + suspend fun delete(sms: Sms) +} +*/ diff --git a/android/app/src/main/java/com/vernu/sms/dtos/GatewayStatsResponse.kt b/android/app/src/main/java/com/vernu/sms/dtos/GatewayStatsResponse.kt new file mode 100644 index 0000000..30ab25f --- /dev/null +++ b/android/app/src/main/java/com/vernu/sms/dtos/GatewayStatsResponse.kt @@ -0,0 +1,14 @@ +package com.vernu.sms.dtos + +import com.google.gson.annotations.SerializedName + +data class GatewayStatsResponse( + @SerializedName("data") val data: GatewayStatsData? = null +) + +data class GatewayStatsData( + @SerializedName("totalSentSMSCount") val totalSentSMSCount: Int? = null, + @SerializedName("totalReceivedSMSCount") val totalReceivedSMSCount: Int? = null, + @SerializedName("totalDeviceCount") val totalDeviceCount: Int? = null, + @SerializedName("totalApiKeyCount") val totalApiKeyCount: Int? = null +) diff --git a/android/app/src/main/java/com/vernu/sms/dtos/HeartbeatInputDTO.java b/android/app/src/main/java/com/vernu/sms/dtos/HeartbeatInputDTO.java deleted file mode 100644 index 60b4702..0000000 --- a/android/app/src/main/java/com/vernu/sms/dtos/HeartbeatInputDTO.java +++ /dev/null @@ -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; - } -} diff --git a/android/app/src/main/java/com/vernu/sms/dtos/HeartbeatInputDTO.kt b/android/app/src/main/java/com/vernu/sms/dtos/HeartbeatInputDTO.kt new file mode 100644 index 0000000..453d963 --- /dev/null +++ b/android/app/src/main/java/com/vernu/sms/dtos/HeartbeatInputDTO.kt @@ -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 +} diff --git a/android/app/src/main/java/com/vernu/sms/dtos/HeartbeatResponseDTO.java b/android/app/src/main/java/com/vernu/sms/dtos/HeartbeatResponseDTO.java deleted file mode 100644 index 61bba72..0000000 --- a/android/app/src/main/java/com/vernu/sms/dtos/HeartbeatResponseDTO.java +++ /dev/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; -} diff --git a/android/app/src/main/java/com/vernu/sms/dtos/HeartbeatResponseDTO.kt b/android/app/src/main/java/com/vernu/sms/dtos/HeartbeatResponseDTO.kt new file mode 100644 index 0000000..f984310 --- /dev/null +++ b/android/app/src/main/java/com/vernu/sms/dtos/HeartbeatResponseDTO.kt @@ -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 +} diff --git a/android/app/src/main/java/com/vernu/sms/dtos/MessagesResponse.kt b/android/app/src/main/java/com/vernu/sms/dtos/MessagesResponse.kt new file mode 100644 index 0000000..49c8c8d --- /dev/null +++ b/android/app/src/main/java/com/vernu/sms/dtos/MessagesResponse.kt @@ -0,0 +1,38 @@ +package com.vernu.sms.dtos + +import com.google.gson.annotations.SerializedName + +data class MessagesResponse( + @SerializedName("data") val data: List? = null, + @SerializedName("meta") val meta: PaginationMeta? = null +) + +data class SmsMessage( + @SerializedName("_id") val id: String? = null, + @SerializedName("message") val message: String? = null, + @SerializedName("sender") val sender: String? = null, + @SerializedName("recipient") val recipient: String? = null, + @SerializedName("recipients") val recipients: List? = null, + @SerializedName("requestedAt") val requestedAt: String? = null, + @SerializedName("receivedAt") val receivedAt: String? = null, + @SerializedName("createdAt") val createdAt: String? = null, + @SerializedName("status") val status: String? = null, + @SerializedName("errorCode") val errorCode: String? = null, + @SerializedName("errorMessage") val errorMessage: String? = null +) { + val isReceived: Boolean get() = sender != null + val counterparty: String get() = if (isReceived) sender ?: "Unknown" + else recipient ?: recipients?.firstOrNull() ?: "Unknown" +} + +data class PaginationMeta( + @SerializedName("page") val page: Int? = null, + @SerializedName("limit") val limit: Int? = null, + @SerializedName("total") val total: Int? = null, + @SerializedName("totalPages") val totalPages: Int? = null +) + +data class SendSmsRequest( + @SerializedName("message") val message: String, + @SerializedName("recipients") val recipients: List +) diff --git a/android/app/src/main/java/com/vernu/sms/dtos/RegisterDeviceInputDTO.java b/android/app/src/main/java/com/vernu/sms/dtos/RegisterDeviceInputDTO.java deleted file mode 100644 index ae6187b..0000000 --- a/android/app/src/main/java/com/vernu/sms/dtos/RegisterDeviceInputDTO.java +++ /dev/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; - } -} diff --git a/android/app/src/main/java/com/vernu/sms/dtos/RegisterDeviceInputDTO.kt b/android/app/src/main/java/com/vernu/sms/dtos/RegisterDeviceInputDTO.kt new file mode 100644 index 0000000..3a8eef8 --- /dev/null +++ b/android/app/src/main/java/com/vernu/sms/dtos/RegisterDeviceInputDTO.kt @@ -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 +} diff --git a/android/app/src/main/java/com/vernu/sms/dtos/RegisterDeviceResponseDTO.java b/android/app/src/main/java/com/vernu/sms/dtos/RegisterDeviceResponseDTO.java deleted file mode 100644 index 64ed318..0000000 --- a/android/app/src/main/java/com/vernu/sms/dtos/RegisterDeviceResponseDTO.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.vernu.sms.dtos; - -import java.util.Map; - -public class RegisterDeviceResponseDTO { - public boolean success; - public Map data; - public String error; -} diff --git a/android/app/src/main/java/com/vernu/sms/dtos/RegisterDeviceResponseDTO.kt b/android/app/src/main/java/com/vernu/sms/dtos/RegisterDeviceResponseDTO.kt new file mode 100644 index 0000000..31482c4 --- /dev/null +++ b/android/app/src/main/java/com/vernu/sms/dtos/RegisterDeviceResponseDTO.kt @@ -0,0 +1,7 @@ +package com.vernu.sms.dtos + +class RegisterDeviceResponseDTO { + @JvmField var success: Boolean = false + @JvmField var data: Map? = null + @JvmField var error: String? = null +} diff --git a/android/app/src/main/java/com/vernu/sms/dtos/SMSDTO.java b/android/app/src/main/java/com/vernu/sms/dtos/SMSDTO.java deleted file mode 100644 index 9f17000..0000000 --- a/android/app/src/main/java/com/vernu/sms/dtos/SMSDTO.java +++ /dev/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; - } -} diff --git a/android/app/src/main/java/com/vernu/sms/dtos/SMSDTO.kt b/android/app/src/main/java/com/vernu/sms/dtos/SMSDTO.kt new file mode 100644 index 0000000..13925ae --- /dev/null +++ b/android/app/src/main/java/com/vernu/sms/dtos/SMSDTO.kt @@ -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 +} diff --git a/android/app/src/main/java/com/vernu/sms/dtos/SMSForwardResponseDTO.java b/android/app/src/main/java/com/vernu/sms/dtos/SMSForwardResponseDTO.java deleted file mode 100644 index 47aebf9..0000000 --- a/android/app/src/main/java/com/vernu/sms/dtos/SMSForwardResponseDTO.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.vernu.sms.dtos; - -public class SMSForwardResponseDTO { - - public SMSForwardResponseDTO() { - } - - -} diff --git a/android/app/src/main/java/com/vernu/sms/dtos/SMSForwardResponseDTO.kt b/android/app/src/main/java/com/vernu/sms/dtos/SMSForwardResponseDTO.kt new file mode 100644 index 0000000..9da22e7 --- /dev/null +++ b/android/app/src/main/java/com/vernu/sms/dtos/SMSForwardResponseDTO.kt @@ -0,0 +1,3 @@ +package com.vernu.sms.dtos + +class SMSForwardResponseDTO diff --git a/android/app/src/main/java/com/vernu/sms/dtos/SimInfoCollectionDTO.java b/android/app/src/main/java/com/vernu/sms/dtos/SimInfoCollectionDTO.java deleted file mode 100644 index e955034..0000000 --- a/android/app/src/main/java/com/vernu/sms/dtos/SimInfoCollectionDTO.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.vernu.sms.dtos; - -import java.util.List; - -public class SimInfoCollectionDTO { - private long lastUpdated; - private List sims; - - public SimInfoCollectionDTO() { - } - - public long getLastUpdated() { - return lastUpdated; - } - - public void setLastUpdated(long lastUpdated) { - this.lastUpdated = lastUpdated; - } - - public List getSims() { - return sims; - } - - public void setSims(List sims) { - this.sims = sims; - } -} diff --git a/android/app/src/main/java/com/vernu/sms/dtos/SimInfoCollectionDTO.kt b/android/app/src/main/java/com/vernu/sms/dtos/SimInfoCollectionDTO.kt new file mode 100644 index 0000000..0aed504 --- /dev/null +++ b/android/app/src/main/java/com/vernu/sms/dtos/SimInfoCollectionDTO.kt @@ -0,0 +1,6 @@ +package com.vernu.sms.dtos + +class SimInfoCollectionDTO { + var lastUpdated: Long = 0 + var sims: MutableList? = null +} diff --git a/android/app/src/main/java/com/vernu/sms/dtos/SimInfoDTO.java b/android/app/src/main/java/com/vernu/sms/dtos/SimInfoDTO.java deleted file mode 100644 index 43cc6a8..0000000 --- a/android/app/src/main/java/com/vernu/sms/dtos/SimInfoDTO.java +++ /dev/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; - } -} diff --git a/android/app/src/main/java/com/vernu/sms/dtos/SimInfoDTO.kt b/android/app/src/main/java/com/vernu/sms/dtos/SimInfoDTO.kt new file mode 100644 index 0000000..5ed73e1 --- /dev/null +++ b/android/app/src/main/java/com/vernu/sms/dtos/SimInfoDTO.kt @@ -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 +} diff --git a/android/app/src/main/java/com/vernu/sms/dtos/SubscriptionResponse.kt b/android/app/src/main/java/com/vernu/sms/dtos/SubscriptionResponse.kt new file mode 100644 index 0000000..bedb0e9 --- /dev/null +++ b/android/app/src/main/java/com/vernu/sms/dtos/SubscriptionResponse.kt @@ -0,0 +1,27 @@ +package com.vernu.sms.dtos + +import com.google.gson.annotations.SerializedName + +data class SubscriptionResponse( + @SerializedName("plan") val plan: SubscriptionPlan? = null, + @SerializedName("currentPeriodStart") val currentPeriodStart: String? = null, + @SerializedName("currentPeriodEnd") val currentPeriodEnd: String? = null, + @SerializedName("isActive") val isActive: Boolean? = null, + @SerializedName("usage") val usage: SubscriptionUsage? = null +) + +data class SubscriptionPlan( + @SerializedName("name") val name: String? = null, + @SerializedName("displayName") val displayName: String? = null +) + +data class SubscriptionUsage( + @SerializedName("processedSmsToday") val processedSmsToday: Int? = null, + @SerializedName("processedSmsLastMonth") val processedSmsLastMonth: Int? = null, + @SerializedName("dailyLimit") val dailyLimit: Int? = null, + @SerializedName("monthlyLimit") val monthlyLimit: Int? = null, + @SerializedName("dailyRemaining") val dailyRemaining: Int? = null, + @SerializedName("monthlyRemaining") val monthlyRemaining: Int? = null, + @SerializedName("dailyUsagePercentage") val dailyUsagePercentage: Int? = null, + @SerializedName("monthlyUsagePercentage") val monthlyUsagePercentage: Int? = null +) diff --git a/android/app/src/main/java/com/vernu/sms/dtos/UserProfileResponse.kt b/android/app/src/main/java/com/vernu/sms/dtos/UserProfileResponse.kt new file mode 100644 index 0000000..31b7d0d --- /dev/null +++ b/android/app/src/main/java/com/vernu/sms/dtos/UserProfileResponse.kt @@ -0,0 +1,12 @@ +package com.vernu.sms.dtos + +import com.google.gson.annotations.SerializedName + +data class UserProfileWrapper( + @SerializedName("data") val data: UserProfile? = null +) + +data class UserProfile( + @SerializedName("name") val name: String? = null, + @SerializedName("email") val email: String? = null +) diff --git a/android/app/src/main/java/com/vernu/sms/helpers/HeartbeatHelper.java b/android/app/src/main/java/com/vernu/sms/helpers/HeartbeatHelper.java deleted file mode 100644 index cf27eef..0000000 --- a/android/app/src/main/java/com/vernu/sms/helpers/HeartbeatHelper.java +++ /dev/null @@ -1,228 +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 call = ApiManager.getApiService().heartbeat(deviceId, apiKey, heartbeatInput); - Response 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); - } - - 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; - } -} diff --git a/android/app/src/main/java/com/vernu/sms/helpers/HeartbeatHelper.kt b/android/app/src/main/java/com/vernu/sms/helpers/HeartbeatHelper.kt new file mode 100644 index 0000000..0d691a9 --- /dev/null +++ b/android/app/src/main/java/com/vernu/sms/helpers/HeartbeatHelper.kt @@ -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 + ) + } +} diff --git a/android/app/src/main/java/com/vernu/sms/helpers/HeartbeatManager.java b/android/app/src/main/java/com/vernu/sms/helpers/HeartbeatManager.java deleted file mode 100644 index d017a72..0000000 --- a/android/app/src/main/java/com/vernu/sms/helpers/HeartbeatManager.java +++ /dev/null @@ -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); - } -} diff --git a/android/app/src/main/java/com/vernu/sms/helpers/HeartbeatManager.kt b/android/app/src/main/java/com/vernu/sms/helpers/HeartbeatManager.kt new file mode 100644 index 0000000..627ab53 --- /dev/null +++ b/android/app/src/main/java/com/vernu/sms/helpers/HeartbeatManager.kt @@ -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) + } +} diff --git a/android/app/src/main/java/com/vernu/sms/helpers/SMSFilterHelper.java b/android/app/src/main/java/com/vernu/sms/helpers/SMSFilterHelper.java deleted file mode 100644 index 999713e..0000000 --- a/android/app/src/main/java/com/vernu/sms/helpers/SMSFilterHelper.java +++ /dev/null @@ -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 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 getRules() { - return rules; - } - - public void setRules(List 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() {}.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); - } -} diff --git a/android/app/src/main/java/com/vernu/sms/helpers/SMSFilterHelper.kt b/android/app/src/main/java/com/vernu/sms/helpers/SMSFilterHelper.kt new file mode 100644 index 0000000..2b28e6a --- /dev/null +++ b/android/app/src/main/java/com/vernu/sms/helpers/SMSFilterHelper.kt @@ -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 = 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() {}.type + Gson().fromJson(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) +} diff --git a/android/app/src/main/java/com/vernu/sms/helpers/SMSHelper.java b/android/app/src/main/java/com/vernu/sms/helpers/SMSHelper.java deleted file mode 100644 index ab2e4d4..0000000 --- a/android/app/src/main/java/com/vernu/sms/helpers/SMSHelper.java +++ /dev/null @@ -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 parts = smsManager.divideMessage(message); - if (parts.size() > 1) { - ArrayList sentIntents = new ArrayList<>(); - ArrayList 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 parts = smsManager.divideMessage(message); - if (parts.size() > 1) { - ArrayList sentIntents = new ArrayList<>(); - ArrayList 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); - } -} diff --git a/android/app/src/main/java/com/vernu/sms/helpers/SMSHelper.kt b/android/app/src/main/java/com/vernu/sms/helpers/SMSHelper.kt new file mode 100644 index 0000000..9b7c060 --- /dev/null +++ b/android/app/src/main/java/com/vernu/sms/helpers/SMSHelper.kt @@ -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(parts.size).also { list -> + repeat(parts.size) { list.add(sentIntent) } + } + val deliveredIntents = ArrayList(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(parts.size).also { list -> + repeat(parts.size) { list.add(sentIntent) } + } + val deliveredIntents = ArrayList(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) + } +} diff --git a/android/app/src/main/java/com/vernu/sms/helpers/SharedPreferenceHelper.java b/android/app/src/main/java/com/vernu/sms/helpers/SharedPreferenceHelper.java deleted file mode 100644 index ea7386c..0000000 --- a/android/app/src/main/java/com/vernu/sms/helpers/SharedPreferenceHelper.java +++ /dev/null @@ -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(); - } -} \ No newline at end of file diff --git a/android/app/src/main/java/com/vernu/sms/helpers/SharedPreferenceHelper.kt b/android/app/src/main/java/com/vernu/sms/helpers/SharedPreferenceHelper.kt new file mode 100644 index 0000000..ea737ae --- /dev/null +++ b/android/app/src/main/java/com/vernu/sms/helpers/SharedPreferenceHelper.kt @@ -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() + } +} diff --git a/android/app/src/main/java/com/vernu/sms/models/SMSFilterRule.java b/android/app/src/main/java/com/vernu/sms/models/SMSFilterRule.java deleted file mode 100644 index 97574e2..0000000 --- a/android/app/src/main/java/com/vernu/sms/models/SMSFilterRule.java +++ /dev/null @@ -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); - } -} diff --git a/android/app/src/main/java/com/vernu/sms/models/SMSFilterRule.kt b/android/app/src/main/java/com/vernu/sms/models/SMSFilterRule.kt new file mode 100644 index 0000000..0d66734 --- /dev/null +++ b/android/app/src/main/java/com/vernu/sms/models/SMSFilterRule.kt @@ -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) +} diff --git a/android/app/src/main/java/com/vernu/sms/models/SMSPayload.java b/android/app/src/main/java/com/vernu/sms/models/SMSPayload.java deleted file mode 100644 index b9ec05f..0000000 --- a/android/app/src/main/java/com/vernu/sms/models/SMSPayload.java +++ /dev/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; - } -} diff --git a/android/app/src/main/java/com/vernu/sms/models/SMSPayload.kt b/android/app/src/main/java/com/vernu/sms/models/SMSPayload.kt new file mode 100644 index 0000000..448d081 --- /dev/null +++ b/android/app/src/main/java/com/vernu/sms/models/SMSPayload.kt @@ -0,0 +1,13 @@ +package com.vernu.sms.models + +class SMSPayload { + var recipients: Array? = 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? = null + var smsBody: String? = null +} diff --git a/android/app/src/main/java/com/vernu/sms/receivers/BootCompletedReceiver.java b/android/app/src/main/java/com/vernu/sms/receivers/BootCompletedReceiver.java deleted file mode 100644 index 487446b..0000000 --- a/android/app/src/main/java/com/vernu/sms/receivers/BootCompletedReceiver.java +++ /dev/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() { - @Override - public void onResponse(Call call, Response 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 call, Throwable t) { - Log.e(TAG, "Error updating device info after boot: " + t.getMessage()); - } - }); - }); - } -} diff --git a/android/app/src/main/java/com/vernu/sms/receivers/BootCompletedReceiver.kt b/android/app/src/main/java/com/vernu/sms/receivers/BootCompletedReceiver.kt new file mode 100644 index 0000000..6138f0f --- /dev/null +++ b/android/app/src/main/java/com/vernu/sms/receivers/BootCompletedReceiver.kt @@ -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 { + override fun onResponse( + call: Call, + response: Response + ) { + 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, t: Throwable) { + Log.e(TAG, "Error updating device info after boot: ${t.message}") + } + }) + } + } +} diff --git a/android/app/src/main/java/com/vernu/sms/receivers/SMSBroadcastReceiver.java b/android/app/src/main/java/com/vernu/sms/receivers/SMSBroadcastReceiver.java deleted file mode 100644 index fb3c50a..0000000 --- a/android/app/src/main/java/com/vernu/sms/receivers/SMSBroadcastReceiver.java +++ /dev/null @@ -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 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 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"); - } - } -} \ No newline at end of file diff --git a/android/app/src/main/java/com/vernu/sms/receivers/SMSBroadcastReceiver.kt b/android/app/src/main/java/com/vernu/sms/receivers/SMSBroadcastReceiver.kt new file mode 100644 index 0000000..0f037fe --- /dev/null +++ b/android/app/src/main/java/com/vernu/sms/receivers/SMSBroadcastReceiver.kt @@ -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() + 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") + } + } +} diff --git a/android/app/src/main/java/com/vernu/sms/receivers/SMSStatusReceiver.java b/android/app/src/main/java/com/vernu/sms/receivers/SMSStatusReceiver.java deleted file mode 100644 index 07fa1cf..0000000 --- a/android/app/src/main/java/com/vernu/sms/receivers/SMSStatusReceiver.java +++ /dev/null @@ -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); - } -} \ No newline at end of file diff --git a/android/app/src/main/java/com/vernu/sms/receivers/SMSStatusReceiver.kt b/android/app/src/main/java/com/vernu/sms/receivers/SMSStatusReceiver.kt new file mode 100644 index 0000000..6f7b7d3 --- /dev/null +++ b/android/app/src/main/java/com/vernu/sms/receivers/SMSStatusReceiver.kt @@ -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>(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) + } +} diff --git a/android/app/src/main/java/com/vernu/sms/services/FCMService.java b/android/app/src/main/java/com/vernu/sms/services/FCMService.java deleted file mode 100644 index 46ddc9a..0000000 --- a/android/app/src/main/java/com/vernu/sms/services/FCMService.java +++ /dev/null @@ -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() { - @Override - public void onResponse(Call call, Response 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 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()); - } -} \ No newline at end of file diff --git a/android/app/src/main/java/com/vernu/sms/services/FCMService.kt b/android/app/src/main/java/com/vernu/sms/services/FCMService.kt new file mode 100644 index 0000000..ef9ab26 --- /dev/null +++ b/android/app/src/main/java/com/vernu/sms/services/FCMService.kt @@ -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 { + override fun onResponse( + call: Call, + response: 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 fun onFailure(call: Call, 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()) + } +} diff --git a/android/app/src/main/java/com/vernu/sms/services/GatewayApiServiceKt.kt b/android/app/src/main/java/com/vernu/sms/services/GatewayApiServiceKt.kt new file mode 100644 index 0000000..f529150 --- /dev/null +++ b/android/app/src/main/java/com/vernu/sms/services/GatewayApiServiceKt.kt @@ -0,0 +1,58 @@ +package com.vernu.sms.services + +import com.vernu.sms.dtos.GatewayStatsResponse +import com.vernu.sms.dtos.MessagesResponse +import com.vernu.sms.dtos.RegisterDeviceInputDTO +import com.vernu.sms.dtos.RegisterDeviceResponseDTO +import com.vernu.sms.dtos.SendSmsRequest +import com.vernu.sms.dtos.SubscriptionResponse +import com.vernu.sms.dtos.UserProfileWrapper +import retrofit2.Response +import retrofit2.http.* + +interface GatewayApiServiceKt { + + @GET("auth/who-am-i") + suspend fun whoAmI( + @Header("x-api-key") apiKey: String + ): Response + + @GET("gateway/stats") + suspend fun getStats( + @Header("x-api-key") apiKey: String + ): Response + + @POST("gateway/devices") + suspend fun registerDevice( + @Header("x-api-key") apiKey: String, + @Body body: RegisterDeviceInputDTO + ): Response + + @GET("billing/current-subscription") + suspend fun getCurrentSubscription( + @Header("x-api-key") apiKey: String + ): Response + + @PATCH("gateway/devices/{deviceId}") + suspend fun updateDevice( + @Path("deviceId") deviceId: String, + @Header("x-api-key") apiKey: String, + @Body body: RegisterDeviceInputDTO + ): Response + + @GET("gateway/devices/{deviceId}/messages") + suspend fun getMessages( + @Path("deviceId") deviceId: String, + @Header("x-api-key") apiKey: String, + @Query("page") page: Int, + @Query("limit") limit: Int, + @Query("type") type: String + ): Response + + @POST("gateway/devices/{deviceId}/send-sms") + suspend fun sendSms( + @Path("deviceId") deviceId: String, + @Header("x-api-key") apiKey: String, + @Body body: SendSmsRequest + ): Response +} diff --git a/android/app/src/main/java/com/vernu/sms/services/StickyNotificationService.java b/android/app/src/main/java/com/vernu/sms/services/StickyNotificationService.java deleted file mode 100644 index c0659eb..0000000 --- a/android/app/src/main/java/com/vernu/sms/services/StickyNotificationService.java +++ /dev/null @@ -1,103 +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 { - 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(); - } - - } -} \ No newline at end of file diff --git a/android/app/src/main/java/com/vernu/sms/services/StickyNotificationService.kt b/android/app/src/main/java/com/vernu/sms/services/StickyNotificationService.kt new file mode 100644 index 0000000..b14bd1b --- /dev/null +++ b/android/app/src/main/java/com/vernu/sms/services/StickyNotificationService.kt @@ -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() + } + } +} diff --git a/android/app/src/main/java/com/vernu/sms/ui/dashboard/DashboardScreen.kt b/android/app/src/main/java/com/vernu/sms/ui/dashboard/DashboardScreen.kt new file mode 100644 index 0000000..2c097ff --- /dev/null +++ b/android/app/src/main/java/com/vernu/sms/ui/dashboard/DashboardScreen.kt @@ -0,0 +1,617 @@ +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.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 = { + TopAppBar( + title = { + Row(verticalAlignment = Alignment.CenterVertically) { + Image( + painter = painterResource(id = R.drawable.ic_app_logo), + contentDescription = null, + modifier = Modifier.size(28.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + 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 = { + IconButton(onClick = { viewModel.refresh() }) { + Icon(Icons.Default.Refresh, contentDescription = "Refresh") + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) + } + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(horizontal = 16.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Spacer(modifier = Modifier.height(4.dp)) + 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, + unavailable = state.subscriptionUnavailable + ) + QuickActionsSection() + Spacer(modifier = Modifier.height(8.dp)) + } + } +} + +@Composable +private fun DeviceStatusCard( + state: DashboardState, + 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" + + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) + ) { + Column(modifier = Modifier.padding(20.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + val hardwareModel = "${Build.BRAND.replaceFirstChar { it.uppercase() }} ${Build.MODEL}" + val customName = state.deviceName.trim() + val displayName = customName.ifEmpty { hardwareModel } + val showModel = customName.isNotEmpty() && + customName.lowercase() != hardwareModel.lowercase() + + Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) { + Text( + text = displayName, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold + ) + if (showModel) { + Text( + text = hardwareModel, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + if (state.deviceId.isNotEmpty()) { + 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.isReceiveSmsEnabled, + onCheckedChange = onReceiveSmsToggle, + modifier = Modifier.scale(0.75f) + ) + } + if (state.availableSims.isNotEmpty()) { + SimCardsSection(sims = state.availableSims) + } + } + } +} + +@Composable +private fun PermissionWarningCard( + missingPermissions: List, + 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) { + 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 + ) + } + } + } + } +} + +@Composable +private fun SubscriptionCard( + subscription: SubscriptionResponse?, + isLoading: Boolean, + unavailable: Boolean +) { + val context = LocalContext.current + + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) + ) { + when { + isLoading -> { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(modifier = Modifier.size(24.dp)) + } + } + unavailable || subscription == null -> { + // silently hide rather than show an error card + } + else -> { + Column(modifier = Modifier.padding(20.dp)) { + val planName = subscription.plan?.name?.uppercase() ?: "FREE" + val isFree = subscription.plan?.name?.lowercase() == "free" || + subscription.plan?.name == null + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column { + Text( + text = "Subscription", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + Spacer(modifier = Modifier.height(4.dp)) + Surface( + color = if (isFree) MaterialTheme.colorScheme.surfaceVariant + else MaterialTheme.colorScheme.primary.copy(alpha = 0.15f), + shape = MaterialTheme.shapes.small + ) { + Text( + text = planName, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + color = if (isFree) MaterialTheme.colorScheme.onSurfaceVariant + else MaterialTheme.colorScheme.primary + ) + } + } + if (isFree) { + OutlinedButton( + onClick = { + context.startActivity( + Intent(Intent.ACTION_VIEW, Uri.parse("https://textbee.dev/pricing")) + ) + } + ) { + Text("Upgrade") + } + } else { + TextButton( + onClick = { + context.startActivity( + Intent(Intent.ACTION_VIEW, Uri.parse("https://app.textbee.dev/dashboard/account")) + ) + } + ) { + Text("Manage") + } + } + } + + if (isFree) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Unlock higher limits, more devices & priority support", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } else { + subscription.usage?.let { usage -> + Spacer(modifier = Modifier.height(16.dp)) + UsageRow( + label = "Today", + used = usage.processedSmsToday, + limit = usage.dailyLimit, + pct = usage.dailyUsagePercentage + ) + Spacer(modifier = Modifier.height(10.dp)) + UsageRow( + label = "This month", + used = usage.processedSmsLastMonth, + limit = usage.monthlyLimit, + pct = usage.monthlyUsagePercentage + ) + } + + subscription.currentPeriodEnd?.let { dateStr -> + formatDate(dateStr)?.let { formatted -> + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = "Renews $formatted", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } + } + } + } +} + +@Composable +private fun UsageRow(label: String, used: Int?, limit: Int?, pct: Int?) { + val isUnlimited = limit == -1 + val progress = if (isUnlimited || limit == null || limit == 0) 0f + else (pct ?: 0).coerceIn(0, 100) / 100f + + Column { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text(text = label, style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant) + Text( + text = if (isUnlimited) "${used ?: 0} / Unlimited" + else "${used ?: 0} / ${limit ?: "—"}", + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Medium + ) + } + Spacer(modifier = Modifier.height(4.dp)) + if (!isUnlimited && limit != null && limit > 0) { + LinearProgressIndicator( + progress = progress, + modifier = Modifier + .fillMaxWidth() + .height(6.dp), + color = if (progress > 0.8f) MaterialTheme.colorScheme.error + else MaterialTheme.colorScheme.primary, + trackColor = MaterialTheme.colorScheme.surfaceVariant + ) + } + } +} + +@Composable +private fun QuickActionsSection() { + val context = LocalContext.current + + 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) + ) { + OutlinedButton( + onClick = { + context.startActivity( + Intent(Intent.ACTION_VIEW, Uri.parse("https://app.textbee.dev/dashboard")) + ) + }, + 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") + } + } + } +} + +private fun formatDate(isoDate: String): String? { + return try { + val sdf = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.getDefault()) + sdf.timeZone = TimeZone.getTimeZone("UTC") + val date = sdf.parse(isoDate.take(19)) ?: return null + SimpleDateFormat("MMM d, yyyy", Locale.getDefault()).format(date) + } catch (e: Exception) { + null + } +} diff --git a/android/app/src/main/java/com/vernu/sms/ui/dashboard/DashboardViewModel.kt b/android/app/src/main/java/com/vernu/sms/ui/dashboard/DashboardViewModel.kt new file mode 100644 index 0000000..bf953ec --- /dev/null +++ b/android/app/src/main/java/com/vernu/sms/ui/dashboard/DashboardViewModel.kt @@ -0,0 +1,186 @@ +package com.vernu.sms.ui.dashboard + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +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 +import com.vernu.sms.helpers.SharedPreferenceHelper +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +data class DashboardState( + val deviceName: String = "", + val deviceId: String = "", + val isGatewayEnabled: Boolean = false, + val lastHeartbeatMs: Long? = null, + val isTogglingGateway: Boolean = false, + val subscription: SubscriptionResponse? = null, + val isSubscriptionLoading: Boolean = true, + val subscriptionUnavailable: Boolean = false, + val userProfile: UserProfile? = null, + val availableSims: List = emptyList(), + val isReceiveSmsEnabled: Boolean = false +) + +class DashboardViewModel(app: Application) : AndroidViewModel(app) { + + private val context get() = getApplication().applicationContext + + private val _state = MutableStateFlow(DashboardState()) + val state: StateFlow = _state.asStateFlow() + + init { + loadLocalState() + fetchSubscription() + fetchUserProfile() + } + + fun refresh() { + loadLocalState() + fetchSubscription() + fetchUserProfile() + } + + private fun loadLocalState() { + val deviceName = SharedPreferenceHelper.getSharedPreferenceString( + context, AppConstants.SHARED_PREFS_DEVICE_NAME_KEY, "" + ) ?: "" + val deviceId = SharedPreferenceHelper.getSharedPreferenceString( + context, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, "" + ) ?: "" + val isEnabled = SharedPreferenceHelper.getSharedPreferenceBoolean( + context, AppConstants.SHARED_PREFS_GATEWAY_ENABLED_KEY, false + ) + val lastHeartbeatStr = SharedPreferenceHelper.getSharedPreferenceString( + context, AppConstants.SHARED_PREFS_LAST_HEARTBEAT_MS_KEY, "" + ) + val lastHeartbeatMs = lastHeartbeatStr?.toLongOrNull() + + val sims = try { + TextBeeUtils.collectSimInfo(context) + } catch (e: Exception) { + emptyList() + } + 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, + availableSims = sims, + isReceiveSmsEnabled = isReceiveSms + ) + } + + // 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) + } + } + + private fun fetchUserProfile() { + val apiKey = SharedPreferenceHelper.getSharedPreferenceString( + context, AppConstants.SHARED_PREFS_API_KEY_KEY, "" + ) ?: "" + if (apiKey.isEmpty()) return + viewModelScope.launch { + try { + val response = ApiManagerKt.getApiService().whoAmI(apiKey) + if (response.isSuccessful) { + _state.update { it.copy(userProfile = response.body()?.data) } + } + } catch (e: Exception) { + TextBeeUtils.logException(e, "User profile fetch failed") + } + } + } + + private fun fetchSubscription() { + val apiKey = SharedPreferenceHelper.getSharedPreferenceString( + context, AppConstants.SHARED_PREFS_API_KEY_KEY, "" + ) ?: "" + if (apiKey.isEmpty()) { + _state.update { it.copy(isSubscriptionLoading = false, subscriptionUnavailable = true) } + return + } + viewModelScope.launch { + _state.update { it.copy(isSubscriptionLoading = true, subscriptionUnavailable = false) } + try { + val response = ApiManagerKt.getApiService().getCurrentSubscription(apiKey) + if (response.isSuccessful) { + _state.update { it.copy(isSubscriptionLoading = false, subscription = response.body()) } + } else { + _state.update { it.copy(isSubscriptionLoading = false, subscriptionUnavailable = true) } + } + } catch (e: Exception) { + _state.update { it.copy(isSubscriptionLoading = false, subscriptionUnavailable = true) } + TextBeeUtils.logException(e, "Subscription fetch failed") + } + } + } + + 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( + context, AppConstants.SHARED_PREFS_API_KEY_KEY, "" + ) ?: "" + if (deviceId.isEmpty() || apiKey.isEmpty()) return + + viewModelScope.launch { + _state.update { it.copy(isTogglingGateway = true) } + try { + val input = RegisterDeviceInputDTO().apply { this.enabled = enabled } + val response = ApiManagerKt.getApiService().updateDevice(deviceId, apiKey, input) + if (response.isSuccessful) { + SharedPreferenceHelper.setSharedPreferenceBoolean( + context, AppConstants.SHARED_PREFS_GATEWAY_ENABLED_KEY, enabled + ) + _state.update { it.copy(isGatewayEnabled = enabled) } + try { + if (enabled) { + if (SharedPreferenceHelper.getSharedPreferenceBoolean( + context, AppConstants.SHARED_PREFS_STICKY_NOTIFICATION_ENABLED_KEY, false + ) + ) { + TextBeeUtils.startStickyNotificationService(context) + } + HeartbeatManager.scheduleHeartbeat(context) + } else { + TextBeeUtils.stopStickyNotificationService(context) + HeartbeatManager.cancelHeartbeat(context) + } + } catch (e: Exception) { + TextBeeUtils.logException(e, "Gateway service toggle failed") + } + } + } catch (e: Exception) { + TextBeeUtils.logException(e, "Gateway toggle failed") + } finally { + _state.update { it.copy(isTogglingGateway = false) } + } + } + } +} diff --git a/android/app/src/main/java/com/vernu/sms/ui/main/NewMainActivity.kt b/android/app/src/main/java/com/vernu/sms/ui/main/NewMainActivity.kt new file mode 100644 index 0000000..4f44a8b --- /dev/null +++ b/android/app/src/main/java/com/vernu/sms/ui/main/NewMainActivity.kt @@ -0,0 +1,171 @@ +package com.vernu.sms.ui.main + +import android.content.Intent +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Dashboard +import androidx.compose.material.icons.filled.Message +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.navigation.NavGraph.Companion.findStartDestination +import androidx.navigation.NavHostController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +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.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 + +enum class MainDestination(val label: String, val icon: ImageVector) { + DASHBOARD("Dashboard", Icons.Default.Dashboard), + MESSAGES("Messages", Icons.Default.Message), + SETTINGS("Settings", Icons.Default.Settings) +} + +class NewMainActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + TextBeeTheme { + val navController = rememberNavController() + MainScaffold( + navController = navController, + onSwitchToLegacy = { + SharedPreferenceHelper.setSharedPreferenceBoolean( + this, AppConstants.SHARED_PREFS_USE_NEW_UI_KEY, false + ) + startActivity( + Intent(this, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + } + ) + }, + onDisconnect = { + listOf( + AppConstants.SHARED_PREFS_DEVICE_ID_KEY, + AppConstants.SHARED_PREFS_API_KEY_KEY, + AppConstants.SHARED_PREFS_GATEWAY_ENABLED_KEY, + AppConstants.SHARED_PREFS_DEVICE_NAME_KEY, + AppConstants.SHARED_PREFS_LAST_HEARTBEAT_MS_KEY + ).forEach { key -> + SharedPreferenceHelper.clearSharedPreference(this, key) + } + HeartbeatManager.cancelHeartbeat(this) + startActivity( + Intent(this, OnboardingActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + } + ) + } + ) + } + } + } +} + +@Composable +private fun MainScaffold( + navController: NavHostController, + onSwitchToLegacy: () -> Unit, + onDisconnect: () -> Unit +) { + val backStackEntry by navController.currentBackStackEntryAsState() + val currentRoute = backStackEntry?.destination?.route + val showBottomBar = currentRoute != "compose" && currentRoute != "filters" + + Scaffold( + bottomBar = { + if (showBottomBar) { + NavigationBar( + containerColor = MaterialTheme.colorScheme.surface, + tonalElevation = 4.dp + ) { + MainDestination.values().forEach { dest -> + val selected = currentRoute == dest.name + NavigationBarItem( + selected = selected, + onClick = { + navController.navigate(dest.name) { + popUpTo(navController.graph.findStartDestination().id) { + saveState = true + } + launchSingleTop = true + restoreState = true + } + }, + icon = { + Icon( + dest.icon, + contentDescription = dest.label, + modifier = Modifier.size(if (selected) 26.dp else 22.dp) + ) + }, + label = { + Text( + dest.label, + style = MaterialTheme.typography.labelSmall, + fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal + ) + }, + colors = NavigationBarItemDefaults.colors( + selectedIconColor = MaterialTheme.colorScheme.primary, + selectedTextColor = MaterialTheme.colorScheme.primary, + indicatorColor = MaterialTheme.colorScheme.surfaceVariant, + unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant, + unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant + ) + ) + } + } + } + } + ) { innerPadding -> + NavHost( + navController = navController, + startDestination = MainDestination.DASHBOARD.name, + modifier = Modifier.padding(innerPadding) + ) { + composable(MainDestination.DASHBOARD.name) { + DashboardScreen() + } + composable(MainDestination.MESSAGES.name) { + MessagesScreen( + onNavigateToCompose = { navController.navigate("compose") } + ) + } + composable(MainDestination.SETTINGS.name) { + SettingsScreen( + onSwitchToLegacy = onSwitchToLegacy, + onNavigateToFilters = { navController.navigate("filters") }, + onDisconnect = onDisconnect + ) + } + composable("compose") { + ComposeScreen( + onNavigateBack = { navController.popBackStack() } + ) + } + composable("filters") { + SMSFilterScreen(onNavigateBack = { navController.popBackStack() }) + } + } + } +} diff --git a/android/app/src/main/java/com/vernu/sms/ui/messages/ComposeScreen.kt b/android/app/src/main/java/com/vernu/sms/ui/messages/ComposeScreen.kt new file mode 100644 index 0000000..97fcbc7 --- /dev/null +++ b/android/app/src/main/java/com/vernu/sms/ui/messages/ComposeScreen.kt @@ -0,0 +1,178 @@ +package com.vernu.sms.ui.messages + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Send +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.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@Composable +fun ComposeScreen( + viewModel: ComposeViewModel = viewModel(), + onNavigateBack: () -> Unit +) { + val state by viewModel.state.collectAsState() + var recipientInput by remember { mutableStateOf("") } + val snackbarHostState = remember { SnackbarHostState() } + + LaunchedEffect(state.sendSuccess) { + if (state.sendSuccess) { + snackbarHostState.showSnackbar("Message sent successfully!") + viewModel.clearSuccess() + } + } + + LaunchedEffect(state.sendError) { + state.sendError?.let { + snackbarHostState.showSnackbar(it) + viewModel.clearError() + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { + Text("New Message", fontWeight = FontWeight.SemiBold) + }, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon(Icons.Default.ArrowBack, contentDescription = "Back") + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) + }, + snackbarHost = { SnackbarHost(snackbarHostState) } + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(16.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + text = "Recipients", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + OutlinedTextField( + value = recipientInput, + onValueChange = { recipientInput = it }, + modifier = Modifier.weight(1f), + label = { Text("Phone number") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone), + singleLine = true + ) + Button( + onClick = { + val trimmed = recipientInput.trim() + if (trimmed.isNotEmpty() && !state.recipients.contains(trimmed)) { + viewModel.addRecipient(trimmed) + recipientInput = "" + } + }, + enabled = recipientInput.trim().isNotEmpty() + ) { + Text("Add") + } + } + + if (state.recipients.isNotEmpty()) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + state.recipients.forEach { number -> + InputChip( + selected = false, + onClick = {}, + label = { Text(number) }, + trailingIcon = { + IconButton( + onClick = { viewModel.removeRecipient(number) }, + modifier = Modifier.size(18.dp) + ) { + Icon( + Icons.Default.Close, + contentDescription = "Remove", + modifier = Modifier.size(14.dp) + ) + } + } + ) + } + } + } + + Divider() + + Text( + text = "Message", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold + ) + + OutlinedTextField( + value = state.message, + onValueChange = { viewModel.setMessage(it) }, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 120.dp), + label = { Text("Type your message") }, + maxLines = 8, + supportingText = { + Text("${state.message.length} characters") + } + ) + + Button( + onClick = { viewModel.sendSms() }, + modifier = Modifier.fillMaxWidth(), + enabled = !state.isSending && + state.recipients.isNotEmpty() && + state.message.isNotEmpty() + ) { + if (state.isSending) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + color = MaterialTheme.colorScheme.onPrimary, + strokeWidth = 2.dp + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("Sending...") + } else { + Icon( + Icons.Default.Send, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("Send Message") + } + } + } + } +} diff --git a/android/app/src/main/java/com/vernu/sms/ui/messages/ComposeViewModel.kt b/android/app/src/main/java/com/vernu/sms/ui/messages/ComposeViewModel.kt new file mode 100644 index 0000000..cecc25b --- /dev/null +++ b/android/app/src/main/java/com/vernu/sms/ui/messages/ComposeViewModel.kt @@ -0,0 +1,101 @@ +package com.vernu.sms.ui.messages + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import com.vernu.sms.ApiManagerKt +import com.vernu.sms.AppConstants +import com.vernu.sms.dtos.SendSmsRequest +import com.vernu.sms.helpers.SharedPreferenceHelper +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.json.JSONObject +import retrofit2.Response + +data class ComposeState( + val recipients: List = emptyList(), + val message: String = "", + val isSending: Boolean = false, + val sendError: String? = null, + val sendSuccess: Boolean = false +) + +class ComposeViewModel(app: Application) : AndroidViewModel(app) { + + private val context get() = getApplication().applicationContext + + private val _state = MutableStateFlow(ComposeState()) + val state: StateFlow = _state.asStateFlow() + + fun addRecipient(number: String) { + _state.update { it.copy(recipients = it.recipients + number) } + } + + fun removeRecipient(number: String) { + _state.update { it.copy(recipients = it.recipients - number) } + } + + fun setMessage(msg: String) { + _state.update { it.copy(message = msg) } + } + + fun clearError() { + _state.update { it.copy(sendError = null) } + } + + fun clearSuccess() { + _state.update { it.copy(sendSuccess = false, message = "") } + } + + private fun extractErrorMessage(response: Response<*>): String { + return try { + val body = response.errorBody()?.string() + if (!body.isNullOrBlank()) { + val json = JSONObject(body) + json.optString("message").takeIf { it.isNotBlank() } + ?: json.optString("error").takeIf { it.isNotBlank() } + ?: "Failed to send (${response.code()})" + } else { + "Failed to send (${response.code()})" + } + } catch (e: Exception) { + "Failed to send (${response.code()})" + } + } + + fun sendSms() { + val apiKey = SharedPreferenceHelper.getSharedPreferenceString( + context, AppConstants.SHARED_PREFS_API_KEY_KEY, "" + ) ?: "" + val deviceId = SharedPreferenceHelper.getSharedPreferenceString( + context, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, "" + ) ?: "" + if (apiKey.isEmpty() || deviceId.isEmpty()) return + + val s = _state.value + if (s.recipients.isEmpty() || s.message.isEmpty()) return + + viewModelScope.launch { + _state.update { it.copy(isSending = true, sendError = null) } + try { + val response = ApiManagerKt.getApiService().sendSms( + deviceId, apiKey, SendSmsRequest(s.message, s.recipients) + ) + if (response.isSuccessful) { + _state.update { it.copy(isSending = false, sendSuccess = true) } + } else { + _state.update { + it.copy(isSending = false, sendError = extractErrorMessage(response)) + } + } + } catch (e: Exception) { + _state.update { + it.copy(isSending = false, sendError = "Network error. Please try again.") + } + } + } + } +} diff --git a/android/app/src/main/java/com/vernu/sms/ui/messages/MessagesScreen.kt b/android/app/src/main/java/com/vernu/sms/ui/messages/MessagesScreen.kt new file mode 100644 index 0000000..7e992d3 --- /dev/null +++ b/android/app/src/main/java/com/vernu/sms/ui/messages/MessagesScreen.kt @@ -0,0 +1,360 @@ +package com.vernu.sms.ui.messages + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDownward +import androidx.compose.material.icons.filled.ArrowUpward +import androidx.compose.material.icons.filled.Create +import androidx.compose.material.icons.filled.Forum +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vernu.sms.dtos.SmsMessage +import java.text.SimpleDateFormat +import java.util.Locale +import java.util.TimeZone + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MessagesScreen( + viewModel: MessagesViewModel = viewModel(), + onNavigateToCompose: () -> Unit = {} +) { + val state by viewModel.state.collectAsState() + var selectedMessage by remember { mutableStateOf(null) } + + Scaffold( + topBar = { + TopAppBar( + title = { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Default.Forum, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("Messages", fontWeight = FontWeight.SemiBold) + } + }, + actions = { + IconButton(onClick = { viewModel.refresh() }) { + Icon(Icons.Default.Refresh, contentDescription = "Refresh") + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) + }, + floatingActionButton = { + FloatingActionButton( + onClick = onNavigateToCompose, + containerColor = MaterialTheme.colorScheme.primary + ) { + Icon( + Icons.Default.Create, + contentDescription = "Compose", + tint = MaterialTheme.colorScheme.onPrimary + ) + } + } + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + listOf("all" to "All", "sent" to "Sent", "received" to "Received").forEach { (value, label) -> + FilterChip( + selected = state.filter == value, + onClick = { viewModel.setFilter(value) }, + label = { Text(label) } + ) + } + } + + when { + state.isLoading -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } + state.error != null -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = state.error ?: "Error", + color = MaterialTheme.colorScheme.error + ) + Spacer(modifier = Modifier.height(8.dp)) + Button(onClick = { viewModel.refresh() }) { + Text("Retry") + } + } + } + } + state.messages.isEmpty() -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Icon( + Icons.Default.Forum, + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "No messages yet", + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + else -> { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(state.messages) { message -> + MessageItem( + message = message, + onClick = { selectedMessage = message } + ) + } + + if (state.currentPage < state.totalPages) { + item { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + contentAlignment = Alignment.Center + ) { + if (state.isLoadingMore) { + CircularProgressIndicator(modifier = Modifier.size(24.dp)) + } else { + OutlinedButton(onClick = { viewModel.loadMore() }) { + Text("Load more") + } + } + } + } + } + + item { Spacer(modifier = Modifier.height(80.dp)) } + } + } + } + } + } + + selectedMessage?.let { msg -> + MessageDetailDialog(message = msg, onDismiss = { selectedMessage = null }) + } +} + +@Composable +private fun MessageItem(message: SmsMessage, onClick: () -> Unit) { + val accentColor = if (message.isReceived) Color(0xFF4CAF50) + else MaterialTheme.colorScheme.primary + + Card( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick), + elevation = CardDefaults.cardElevation(defaultElevation = 1.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(IntrinsicSize.Min) + ) { + Box( + modifier = Modifier + .width(4.dp) + .fillMaxHeight() + .background(accentColor) + ) + Column( + modifier = Modifier + .padding(12.dp) + .fillMaxWidth() + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = if (message.isReceived) Icons.Default.ArrowDownward + else Icons.Default.ArrowUpward, + contentDescription = null, + modifier = Modifier.size(14.dp), + tint = accentColor + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = message.counterparty, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold + ) + } + Text( + text = formatRelativeTime(message.createdAt), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = message.message ?: "", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + if (!message.isReceived && message.status != null) { + Spacer(modifier = Modifier.height(4.dp)) + StatusBadge(status = message.status) + } + } + } + } +} + +@Composable +private fun StatusBadge(status: String) { + val (color, label) = when (status.lowercase()) { + "delivered" -> MaterialTheme.colorScheme.tertiary to "Delivered" + "sent" -> MaterialTheme.colorScheme.primary to "Sent" + "failed" -> MaterialTheme.colorScheme.error to "Failed" + else -> MaterialTheme.colorScheme.onSurfaceVariant to "Pending" + } + Surface( + color = color.copy(alpha = 0.15f), + shape = MaterialTheme.shapes.extraSmall + ) { + Text( + text = label, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), + style = MaterialTheme.typography.labelSmall, + color = color, + fontWeight = FontWeight.SemiBold + ) + } +} + +@Composable +private fun MessageDetailDialog(message: SmsMessage, onDismiss: () -> Unit) { + val accentColor = if (message.isReceived) Color(0xFF4CAF50) + else MaterialTheme.colorScheme.primary + + AlertDialog( + onDismissRequest = onDismiss, + title = { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = if (message.isReceived) Icons.Default.ArrowDownward + else Icons.Default.ArrowUpward, + contentDescription = null, + tint = accentColor, + modifier = Modifier.size(18.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = if (message.isReceived) "Received from" else "Sent to", + style = MaterialTheme.typography.titleMedium + ) + } + }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = message.counterparty, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.SemiBold, + color = accentColor + ) + if (!message.isReceived && message.status != null) { + StatusBadge(status = message.status) + } + Divider() + Text( + text = message.message ?: "", + style = MaterialTheme.typography.bodyMedium + ) + val timestamp = if (message.isReceived) message.receivedAt else message.requestedAt + timestamp?.let { + Text( + text = formatFullDate(it), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { Text("Close") } + } + ) +} + +private fun formatRelativeTime(isoDate: String?): String { + if (isoDate == null) return "" + return try { + val sdf = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.getDefault()) + sdf.timeZone = TimeZone.getTimeZone("UTC") + val date = sdf.parse(isoDate.take(19)) ?: return "" + val diffMs = System.currentTimeMillis() - date.time + when { + diffMs < 60_000 -> "Just now" + diffMs < 3_600_000 -> "${diffMs / 60_000}m ago" + diffMs < 86_400_000 -> "${diffMs / 3_600_000}h ago" + diffMs < 604_800_000 -> "${diffMs / 86_400_000}d ago" + else -> SimpleDateFormat("MMM d", Locale.getDefault()).format(date) + } + } catch (e: Exception) { + "" + } +} + +private fun formatFullDate(isoDate: String): String { + return try { + val sdf = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.getDefault()) + sdf.timeZone = TimeZone.getTimeZone("UTC") + val date = sdf.parse(isoDate.take(19)) ?: return "" + SimpleDateFormat("MMM d, yyyy 'at' h:mm a", Locale.getDefault()).format(date) + } catch (e: Exception) { + "" + } +} diff --git a/android/app/src/main/java/com/vernu/sms/ui/messages/MessagesViewModel.kt b/android/app/src/main/java/com/vernu/sms/ui/messages/MessagesViewModel.kt new file mode 100644 index 0000000..7eb7fc1 --- /dev/null +++ b/android/app/src/main/java/com/vernu/sms/ui/messages/MessagesViewModel.kt @@ -0,0 +1,103 @@ +package com.vernu.sms.ui.messages + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import com.vernu.sms.ApiManagerKt +import com.vernu.sms.AppConstants +import com.vernu.sms.dtos.SmsMessage +import com.vernu.sms.helpers.SharedPreferenceHelper +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +data class MessagesState( + val messages: List = emptyList(), + val isLoading: Boolean = true, + val isLoadingMore: Boolean = false, + val error: String? = null, + val filter: String = "all", + val currentPage: Int = 1, + val totalPages: Int = 1, + val total: Int = 0 +) + +class MessagesViewModel(app: Application) : AndroidViewModel(app) { + + private val context get() = getApplication().applicationContext + + private val _state = MutableStateFlow(MessagesState()) + val state: StateFlow = _state.asStateFlow() + + init { + fetchMessages(reset = true) + } + + fun setFilter(filter: String) { + _state.update { it.copy(filter = filter, currentPage = 1) } + fetchMessages(reset = true) + } + + fun refresh() = fetchMessages(reset = true) + + fun loadMore() { + val s = _state.value + if (s.isLoadingMore || s.currentPage >= s.totalPages) return + _state.update { it.copy(currentPage = it.currentPage + 1) } + fetchMessages(reset = false) + } + + private fun fetchMessages(reset: Boolean) { + val apiKey = SharedPreferenceHelper.getSharedPreferenceString( + context, AppConstants.SHARED_PREFS_API_KEY_KEY, "" + ) ?: "" + val deviceId = SharedPreferenceHelper.getSharedPreferenceString( + context, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, "" + ) ?: "" + if (apiKey.isEmpty() || deviceId.isEmpty()) { + _state.update { it.copy(isLoading = false, error = "Device not connected") } + return + } + + val page = if (reset) 1 else _state.value.currentPage + val filter = _state.value.filter + + viewModelScope.launch { + if (reset) { + _state.update { it.copy(isLoading = true, error = null) } + } else { + _state.update { it.copy(isLoadingMore = true) } + } + try { + val response = ApiManagerKt.getApiService() + .getMessages(deviceId, apiKey, page, 20, filter) + if (response.isSuccessful) { + val body = response.body() + val newMessages = body?.data ?: emptyList() + val meta = body?.meta + _state.update { + it.copy( + messages = if (reset) newMessages else it.messages + newMessages, + isLoading = false, + isLoadingMore = false, + error = null, + currentPage = page, + totalPages = meta?.totalPages ?: 1, + total = meta?.total ?: 0 + ) + } + } else { + _state.update { + it.copy(isLoading = false, isLoadingMore = false, error = "Failed to load messages") + } + } + } catch (e: Exception) { + _state.update { + it.copy(isLoading = false, isLoadingMore = false, error = "Network error") + } + } + } + } +} diff --git a/android/app/src/main/java/com/vernu/sms/ui/onboarding/OnboardingActivity.kt b/android/app/src/main/java/com/vernu/sms/ui/onboarding/OnboardingActivity.kt new file mode 100644 index 0000000..f48cec9 --- /dev/null +++ b/android/app/src/main/java/com/vernu/sms/ui/onboarding/OnboardingActivity.kt @@ -0,0 +1,104 @@ +package com.vernu.sms.ui.onboarding + +import android.content.Intent +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.viewModels +import androidx.compose.runtime.Composable +import androidx.navigation.NavHostController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import com.journeyapps.barcodescanner.ScanContract +import com.journeyapps.barcodescanner.ScanOptions +import com.vernu.sms.ui.main.NewMainActivity +import com.vernu.sms.ui.onboarding.screens.* +import com.vernu.sms.ui.theme.TextBeeTheme + +class OnboardingActivity : ComponentActivity() { + + private val viewModel: OnboardingViewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val qrLauncher = registerForActivityResult(ScanContract()) { result -> + result?.contents?.let { scanned -> + viewModel.onQrScanned(scanned.trim()) + } + } + + setContent { + TextBeeTheme { + val navController = rememberNavController() + OnboardingNavGraph( + navController = navController, + viewModel = viewModel, + onScanQr = { + qrLauncher.launch(ScanOptions().apply { + setPrompt("Scan the QR code from textbee.dev/dashboard") + setBeepEnabled(true) + setOrientationLocked(false) + }) + }, + onComplete = { + val intent = Intent(this, NewMainActivity::class.java) + intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + startActivity(intent) + } + ) + } + } + } +} + +@Composable +private fun OnboardingNavGraph( + navController: NavHostController, + viewModel: OnboardingViewModel, + onScanQr: () -> Unit, + onComplete: () -> Unit +) { + NavHost(navController = navController, startDestination = "welcome") { + composable("welcome") { + WelcomeScreen( + onGetStarted = { + viewModel.setReturningUser(false) + navController.navigate("credentials") + }, + onHaveDeviceId = { + viewModel.setReturningUser(true) + navController.navigate("credentials") + } + ) + } + composable("credentials") { + CredentialsScreen( + viewModel = viewModel, + onScanQr = onScanQr, + onNext = { navController.navigate("device_setup") }, + onBack = { navController.popBackStack() } + ) + } + composable("device_setup") { + DeviceSetupScreen( + viewModel = viewModel, + onSuccess = { navController.navigate("permissions") }, + onBack = { navController.popBackStack() } + ) + } + composable("permissions") { + PermissionsScreen( + onContinue = { navController.navigate("setup_complete") }, + onBack = { navController.popBackStack() } + ) + } + composable("setup_complete") { + SetupCompleteScreen( + viewModel = viewModel, + onOpenDashboard = onComplete + ) + } + } +} diff --git a/android/app/src/main/java/com/vernu/sms/ui/onboarding/OnboardingViewModel.kt b/android/app/src/main/java/com/vernu/sms/ui/onboarding/OnboardingViewModel.kt new file mode 100644 index 0000000..f0d6c4c --- /dev/null +++ b/android/app/src/main/java/com/vernu/sms/ui/onboarding/OnboardingViewModel.kt @@ -0,0 +1,185 @@ +package com.vernu.sms.ui.onboarding + +import android.content.Context +import android.os.Build +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.google.firebase.messaging.FirebaseMessaging +import com.vernu.sms.ApiManagerKt +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.SimInfoCollectionDTO +import com.vernu.sms.helpers.HeartbeatManager +import com.vernu.sms.helpers.SharedPreferenceHelper +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.coroutines.suspendCoroutine + +data class OnboardingState( + val apiKey: String = "", + val deviceId: String = "", + val deviceName: String = "${Build.BRAND} ${Build.MODEL}", + val isReturningUser: Boolean = false, + val useExistingDeviceId: Boolean = false, + val isQrScanned: Boolean = false, + val isLoading: Boolean = false, + val errorMessage: String? = null, + val registeredDeviceId: String? = null, + val registeredDeviceName: String? = null +) + +class OnboardingViewModel : ViewModel() { + + private val _state = MutableStateFlow(OnboardingState()) + val state: StateFlow = _state.asStateFlow() + + private val _registrationSuccess = Channel(Channel.CONFLATED) + val registrationSuccess = _registrationSuccess.receiveAsFlow() + + fun setApiKey(key: String) { + _state.update { it.copy(apiKey = key.trim(), errorMessage = null, isQrScanned = false) } + } + + fun onQrScanned(key: String) { + _state.update { it.copy(apiKey = key.trim(), errorMessage = null, isQrScanned = true) } + } + + fun setDeviceId(id: String) { + _state.update { it.copy(deviceId = id.trim(), errorMessage = null) } + } + + fun setDeviceName(name: String) { + _state.update { it.copy(deviceName = name) } + } + + fun setReturningUser(returning: Boolean) { + _state.update { it.copy(isReturningUser = returning, useExistingDeviceId = returning) } + } + + fun setUseExistingDeviceId(use: Boolean) { + _state.update { it.copy(useExistingDeviceId = use, deviceId = if (!use) "" else it.deviceId) } + } + + fun clearError() { + _state.update { it.copy(errorMessage = null) } + } + + fun registerOrUpdateDevice(context: Context) { + val current = _state.value + val apiKey = current.apiKey + val deviceId = current.deviceId + val shouldUpdate = current.isReturningUser || (current.useExistingDeviceId && deviceId.isNotEmpty()) + + if (apiKey.isEmpty()) { + _state.update { it.copy(errorMessage = "Please enter your API key.") } + return + } + if ((current.isReturningUser || current.useExistingDeviceId) && deviceId.isEmpty()) { + _state.update { it.copy(errorMessage = "Please enter your Device ID.") } + return + } + + viewModelScope.launch { + _state.update { it.copy(isLoading = true, errorMessage = null) } + try { + val fcmToken = getFcmToken() + val collectedSimInfo = SimInfoCollectionDTO().apply { + lastUpdated = System.currentTimeMillis() + sims = TextBeeUtils.collectSimInfo(context) + } + val input = RegisterDeviceInputDTO().apply { + 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) { + ApiManagerKt.getApiService().updateDevice(deviceId, apiKey, input) + } else { + ApiManagerKt.getApiService().registerDevice(apiKey, input) + } + + if (response.isSuccessful) { + val data = response.body()?.data + ?: throw IllegalStateException("missing_response") + val registeredId = data["_id"] as? String + ?: throw IllegalStateException("missing_id") + val heartbeatInterval = (data["heartbeatIntervalMinutes"] as? Double)?.toInt() ?: 30 + val name = data["name"] as? String ?: "" + + SharedPreferenceHelper.setSharedPreferenceString( + context, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, registeredId + ) + SharedPreferenceHelper.setSharedPreferenceString( + context, AppConstants.SHARED_PREFS_API_KEY_KEY, apiKey + ) + SharedPreferenceHelper.setSharedPreferenceInt( + context, AppConstants.SHARED_PREFS_HEARTBEAT_INTERVAL_MINUTES_KEY, heartbeatInterval + ) + val resolvedName = name.ifEmpty { current.deviceName } + 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 { + it.copy( + isLoading = false, + registeredDeviceId = registeredId, + registeredDeviceName = resolvedName + ) + } + _registrationSuccess.send(Unit) + } else { + _state.update { + it.copy( + isLoading = false, + errorMessage = when (response.code()) { + 401 -> "Invalid API key. Go back and check your key." + 404 -> "Device ID not found. Verify it in your dashboard." + in 500..599 -> "Server error. Please try again in a moment." + else -> "Request failed (${response.code()}). Please try again." + } + ) + } + } + } catch (e: Exception) { + val message = when { + e.message == "missing_id" -> "Unexpected server response. Please try again." + e.message?.contains("Unable to resolve host") == true || + e.message?.contains("timeout") == true || + e.message?.contains("connect") == true -> + "No internet connection. Check your network and retry." + else -> "Something went wrong. Please try again." + } + _state.update { it.copy(isLoading = false, errorMessage = message) } + TextBeeUtils.logException(e, "Onboarding device registration failed") + } + } + } + + private suspend fun getFcmToken(): String = suspendCoroutine { cont -> + FirebaseMessaging.getInstance().token + .addOnSuccessListener { token -> cont.resume(token) } + .addOnFailureListener { e -> cont.resumeWithException(e) } + } +} diff --git a/android/app/src/main/java/com/vernu/sms/ui/onboarding/screens/CredentialsScreen.kt b/android/app/src/main/java/com/vernu/sms/ui/onboarding/screens/CredentialsScreen.kt new file mode 100644 index 0000000..83ab245 --- /dev/null +++ b/android/app/src/main/java/com/vernu/sms/ui/onboarding/screens/CredentialsScreen.kt @@ -0,0 +1,285 @@ +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 +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.QrCodeScanner +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff +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 +import androidx.compose.ui.unit.dp +import com.vernu.sms.ui.onboarding.OnboardingViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CredentialsScreen( + viewModel: OnboardingViewModel, + onScanQr: () -> Unit, + onNext: () -> Unit, + 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") + + Scaffold( + topBar = { + TopAppBar( + title = {}, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.Default.ArrowBack, contentDescription = "Back") + } + } + ) + } + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(horizontal = 24.dp) + .verticalScroll(rememberScrollState()) + ) { + Spacer(modifier = Modifier.height(8.dp)) + + StepIndicator(current = 1, total = 3) + + Spacer(modifier = Modifier.height(24.dp)) + + Text( + text = "Connect your account", + style = MaterialTheme.typography.headlineMedium + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + 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(16.dp)) + + TabRow(selectedTabIndex = selectedTab) { + tabs.forEachIndexed { index, title -> + Tab( + selected = selectedTab == index, + onClick = { selectedTab = index }, + text = { Text(title) } + ) + } + } + + Spacer(modifier = Modifier.height(24.dp)) + + when (selectedTab) { + 0 -> QrTab( + apiKey = state.apiKey, + isQrScanned = state.isQrScanned, + onScanQr = onScanQr, + onSwitchToManual = { selectedTab = 1 } + ) + 1 -> ManualTab( + apiKey = state.apiKey, + showApiKey = showApiKey, + onApiKeyChange = { viewModel.setApiKey(it) }, + onToggleVisibility = { showApiKey = !showApiKey } + ) + } + + Spacer(modifier = Modifier.height(32.dp)) + + Button( + onClick = onNext, + enabled = state.apiKey.isNotEmpty(), + modifier = Modifier + .fillMaxWidth() + .height(52.dp) + ) { + Text("Next →") + } + + Spacer(modifier = Modifier.height(24.dp)) + } + } +} + +@Composable +private fun QrTab( + apiKey: String, + isQrScanned: Boolean, + onScanQr: () -> Unit, + onSwitchToManual: () -> Unit +) { + val context = LocalContext.current + + Column(horizontalAlignment = Alignment.CenterHorizontally) { + 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)) + + if (isQrScanned && apiKey.isNotEmpty()) { + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer + ), + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Default.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.width(12.dp)) + Column { + Text( + text = "QR scanned successfully", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary + ) + Text( + text = apiKey.take(8) + "••••••••", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + Spacer(modifier = Modifier.height(16.dp)) + OutlinedButton(onClick = onScanQr) { + Icon(Icons.Default.QrCodeScanner, contentDescription = null) + Spacer(modifier = Modifier.width(8.dp)) + Text("Scan Again") + } + } else { + Button( + onClick = onScanQr, + modifier = Modifier + .fillMaxWidth() + .height(52.dp) + ) { + Icon(Icons.Default.QrCodeScanner, contentDescription = null) + Spacer(modifier = Modifier.width(8.dp)) + Text("Scan QR Code") + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + TextButton(onClick = onSwitchToManual) { + Text("Enter manually instead") + } + } +} + +@Composable +private fun ManualTab( + apiKey: String, + showApiKey: Boolean, + onApiKeyChange: (String) -> Unit, + onToggleVisibility: () -> Unit +) { + val context = LocalContext.current + + OutlinedTextField( + value = apiKey, + onValueChange = onApiKeyChange, + label = { Text("API Key") }, + placeholder = { Text("Paste your API key here") }, + visualTransformation = if (showApiKey) VisualTransformation.None else PasswordVisualTransformation(), + trailingIcon = { + IconButton(onClick = onToggleVisibility) { + Icon( + imageVector = if (showApiKey) Icons.Default.VisibilityOff else Icons.Default.Visibility, + contentDescription = if (showApiKey) "Hide" else "Show" + ) + } + }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + modifier = Modifier.fillMaxWidth(), + 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 + ) + } +} diff --git a/android/app/src/main/java/com/vernu/sms/ui/onboarding/screens/DeviceSetupScreen.kt b/android/app/src/main/java/com/vernu/sms/ui/onboarding/screens/DeviceSetupScreen.kt new file mode 100644 index 0000000..84f9aa9 --- /dev/null +++ b/android/app/src/main/java/com/vernu/sms/ui/onboarding/screens/DeviceSetupScreen.kt @@ -0,0 +1,201 @@ +package com.vernu.sms.ui.onboarding.screens + +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.ArrowBack +import androidx.compose.material.icons.filled.Close +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.unit.dp +import com.vernu.sms.ui.onboarding.OnboardingViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DeviceSetupScreen( + viewModel: OnboardingViewModel, + onSuccess: () -> Unit, + onBack: () -> Unit +) { + val state by viewModel.state.collectAsState() + val context = LocalContext.current + + LaunchedEffect(Unit) { + viewModel.registrationSuccess.collect { onSuccess() } + } + + Box(modifier = Modifier.fillMaxSize()) { + Scaffold( + topBar = { + TopAppBar( + title = {}, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.Default.ArrowBack, contentDescription = "Back") + } + } + ) + } + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(horizontal = 24.dp) + .verticalScroll(rememberScrollState()) + ) { + Spacer(modifier = Modifier.height(8.dp)) + + StepIndicator(current = 2, total = 3) + + Spacer(modifier = Modifier.height(24.dp)) + + Text( + text = if (state.isReturningUser) "Reconnect your device" else "Set up your device", + style = MaterialTheme.typography.headlineMedium + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = if (state.isReturningUser) + "Enter your Device ID to reconnect this device to your account" + else + "Give this device a name and register it to start sending SMS", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Spacer(modifier = Modifier.height(24.dp)) + + if (state.isReturningUser) { + OutlinedTextField( + value = state.deviceId, + onValueChange = { viewModel.setDeviceId(it) }, + label = { Text("Device ID") }, + placeholder = { Text("Enter your Device ID") }, + supportingText = { Text("Find it at textbee.dev/dashboard → Devices") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + Spacer(modifier = Modifier.height(16.dp)) + } else { + var expandDeviceId by remember { mutableStateOf(false) } + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + Checkbox( + checked = expandDeviceId, + onCheckedChange = { + expandDeviceId = it + viewModel.setUseExistingDeviceId(it) + } + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + 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)) + OutlinedTextField( + value = state.deviceId, + onValueChange = { viewModel.setDeviceId(it) }, + label = { Text("Device ID") }, + placeholder = { Text("Enter your Device ID") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + Spacer(modifier = Modifier.height(16.dp)) + } + } + + OutlinedTextField( + value = state.deviceName, + onValueChange = { viewModel.setDeviceName(it) }, + label = { Text("Device Name (optional)") }, + placeholder = { Text("e.g. My Gateway Phone") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + + state.errorMessage?.let { error -> + Spacer(modifier = Modifier.height(16.dp)) + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer + ), + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier.padding(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = error, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onErrorContainer, + modifier = Modifier.weight(1f) + ) + IconButton(onClick = { viewModel.clearError() }, modifier = Modifier.size(20.dp)) { + Icon( + Icons.Default.Close, + contentDescription = "Dismiss", + tint = MaterialTheme.colorScheme.onErrorContainer, + modifier = Modifier.size(16.dp) + ) + } + } + } + } + + Spacer(modifier = Modifier.height(32.dp)) + + Button( + onClick = { viewModel.registerOrUpdateDevice(context) }, + enabled = !state.isLoading, + modifier = Modifier + .fillMaxWidth() + .height(52.dp) + ) { + Text(if (state.isReturningUser) "Reconnect Device" else "Register Device") + } + + Spacer(modifier = Modifier.height(24.dp)) + } + } + + if (state.isLoading) { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background.copy(alpha = 0.7f) + ) { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + CircularProgressIndicator(color = MaterialTheme.colorScheme.primary) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "Connecting your device…", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onBackground + ) + } + } + } + } +} diff --git a/android/app/src/main/java/com/vernu/sms/ui/onboarding/screens/OnboardingComponents.kt b/android/app/src/main/java/com/vernu/sms/ui/onboarding/screens/OnboardingComponents.kt new file mode 100644 index 0000000..bdd9f35 --- /dev/null +++ b/android/app/src/main/java/com/vernu/sms/ui/onboarding/screens/OnboardingComponents.kt @@ -0,0 +1,65 @@ +package com.vernu.sms.ui.onboarding.screens + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +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.text.font.FontWeight +import androidx.compose.ui.unit.dp + +@Composable +fun StepIndicator(current: Int, total: Int) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + repeat(total) { index -> + val step = index + 1 + val isActive = step == current + val isDone = step < current + Box( + modifier = Modifier + .size(if (isActive) 28.dp else 24.dp) + .background( + color = when { + isActive -> MaterialTheme.colorScheme.primary + isDone -> MaterialTheme.colorScheme.primary.copy(alpha = 0.4f) + else -> MaterialTheme.colorScheme.surfaceVariant + }, + shape = CircleShape + ), + contentAlignment = Alignment.Center + ) { + Text( + text = step.toString(), + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + color = if (isActive || isDone) Color.White + else MaterialTheme.colorScheme.onSurfaceVariant + ) + } + if (index < total - 1) { + Box( + modifier = Modifier + .width(24.dp) + .height(2.dp) + .background( + if (isDone) MaterialTheme.colorScheme.primary.copy(alpha = 0.4f) + else MaterialTheme.colorScheme.surfaceVariant + ) + ) + } + } + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "Step $current of $total", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} diff --git a/android/app/src/main/java/com/vernu/sms/ui/onboarding/screens/PermissionsScreen.kt b/android/app/src/main/java/com/vernu/sms/ui/onboarding/screens/PermissionsScreen.kt new file mode 100644 index 0000000..ede72a1 --- /dev/null +++ b/android/app/src/main/java/com/vernu/sms/ui/onboarding/screens/PermissionsScreen.kt @@ -0,0 +1,252 @@ +package com.vernu.sms.ui.onboarding.screens + +import android.Manifest +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.provider.Settings +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +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.ArrowBack +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.PhoneAndroid +import androidx.compose.material.icons.filled.Sms +import androidx.compose.material3.* +import androidx.compose.runtime.* +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 + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PermissionsScreen( + onContinue: () -> Unit, + onBack: () -> Unit +) { + val context = LocalContext.current + + val permissions = remember { + listOf( + PermissionItem( + permission = Manifest.permission.SEND_SMS, + label = "Send SMS", + rationale = "Required to send messages from your device", + icon = Icons.Default.Sms + ), + PermissionItem( + permission = Manifest.permission.RECEIVE_SMS, + label = "Receive SMS", + rationale = "Required to receive and forward incoming messages", + icon = Icons.Default.Sms + ), + PermissionItem( + permission = Manifest.permission.READ_PHONE_STATE, + label = "Phone State", + rationale = "Required to detect SIM cards for multi-SIM support", + icon = Icons.Default.PhoneAndroid + ) + ) + } + + var grantedMap by remember { + mutableStateOf( + permissions.associate { item -> + item.permission to (ContextCompat.checkSelfPermission(context, item.permission) == PackageManager.PERMISSION_GRANTED) + } + ) + } + + val launcher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestMultiplePermissions() + ) { results -> + grantedMap = grantedMap + results + } + + val allGranted = grantedMap.values.all { it } + + Scaffold( + topBar = { + TopAppBar( + title = {}, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.Default.ArrowBack, contentDescription = "Back") + } + } + ) + } + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(horizontal = 24.dp) + .verticalScroll(rememberScrollState()) + ) { + Spacer(modifier = Modifier.height(8.dp)) + + StepIndicator(current = 3, total = 3) + + Spacer(modifier = Modifier.height(24.dp)) + + Text( + text = "Grant Permissions", + style = MaterialTheme.typography.headlineMedium + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "These permissions are required for the SMS gateway to work", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Spacer(modifier = Modifier.height(24.dp)) + + if (!allGranted) { + Button( + onClick = { + val missing = permissions + .filter { grantedMap[it.permission] == false } + .map { it.permission } + .toTypedArray() + launcher.launch(missing) + }, + modifier = Modifier + .fillMaxWidth() + .height(48.dp) + ) { + Text("Grant All Permissions") + } + Spacer(modifier = Modifier.height(16.dp)) + } + + permissions.forEach { item -> + val isGranted = grantedMap[item.permission] == true + PermissionRow( + item = item, + isGranted = isGranted, + onGrant = { launcher.launch(arrayOf(item.permission)) }, + onOpenSettings = { + context.startActivity( + Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = Uri.fromParts("package", context.packageName, null) + } + ) + } + ) + Spacer(modifier = Modifier.height(8.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, + modifier = Modifier + .fillMaxWidth() + .height(52.dp) + ) { + Text(if (allGranted) "Continue" else "Continue Anyway") + } + + if (!allGranted) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Some features may be limited without all permissions", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + } + } +} + +@Composable +private fun PermissionRow( + item: PermissionItem, + isGranted: Boolean, + onGrant: () -> Unit, + onOpenSettings: () -> Unit +) { + Card( + colors = CardDefaults.cardColors( + containerColor = if (isGranted) + MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f) + else + MaterialTheme.colorScheme.surfaceVariant + ), + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = item.icon, + contentDescription = null, + tint = if (isGranted) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = item.label, + style = MaterialTheme.typography.titleMedium + ) + Text( + text = item.rationale, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Spacer(modifier = Modifier.width(8.dp)) + if (isGranted) { + Icon( + Icons.Default.Check, + contentDescription = "Granted", + tint = MaterialTheme.colorScheme.primary + ) + } else { + TextButton(onClick = onGrant) { + Text("Grant") + } + } + } + } +} + +private data class PermissionItem( + val permission: String, + val label: String, + val rationale: String, + val icon: ImageVector +) diff --git a/android/app/src/main/java/com/vernu/sms/ui/onboarding/screens/SetupCompleteScreen.kt b/android/app/src/main/java/com/vernu/sms/ui/onboarding/screens/SetupCompleteScreen.kt new file mode 100644 index 0000000..38dc166 --- /dev/null +++ b/android/app/src/main/java/com/vernu/sms/ui/onboarding/screens/SetupCompleteScreen.kt @@ -0,0 +1,230 @@ +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 +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.ContentCopy +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.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 +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.vernu.sms.ui.onboarding.OnboardingViewModel + +@Composable +fun SetupCompleteScreen( + viewModel: OnboardingViewModel, + onOpenDashboard: () -> Unit +) { + 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) { + scale.animateTo( + targetValue = 1f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessMedium + ) + ) + } + + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Box( + modifier = Modifier + .size(96.dp) + .scale(scale.value) + .background(MaterialTheme.colorScheme.primary, CircleShape), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = Color.White + ) + } + + Spacer(modifier = Modifier.height(32.dp)) + + Text( + text = "You're all set!", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = state.registeredDeviceName ?: "Your device", + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.primary + ) + + Spacer(modifier = Modifier.height(24.dp)) + + state.registeredDeviceId?.let { deviceId -> + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ), + modifier = Modifier.fillMaxWidth() + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = "Device ID", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(4.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = deviceId, + style = MaterialTheme.typography.bodyMedium, + fontFamily = FontFamily.Monospace, + modifier = Modifier.weight(1f) + ) + IconButton( + onClick = { + clipboardManager.setText(AnnotatedString(deviceId)) + } + ) { + Icon( + Icons.Default.ContentCopy, + contentDescription = "Copy", + modifier = Modifier.size(18.dp) + ) + } + } + 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 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(32.dp)) + + Button( + onClick = onOpenDashboard, + modifier = Modifier + .fillMaxWidth() + .height(52.dp) + ) { + 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 + ) + } + } + } +} diff --git a/android/app/src/main/java/com/vernu/sms/ui/onboarding/screens/WelcomeScreen.kt b/android/app/src/main/java/com/vernu/sms/ui/onboarding/screens/WelcomeScreen.kt new file mode 100644 index 0000000..9d6be9a --- /dev/null +++ b/android/app/src/main/java/com/vernu/sms/ui/onboarding/screens/WelcomeScreen.kt @@ -0,0 +1,150 @@ +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.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +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() + .background(MaterialTheme.colorScheme.background) + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Box( + modifier = Modifier + .size(96.dp) + .background(MaterialTheme.colorScheme.primaryContainer, CircleShape), + contentAlignment = Alignment.Center + ) { + Image( + painter = painterResource(id = R.drawable.ic_app_logo), + contentDescription = null, + modifier = Modifier.size(60.dp) + ) + } + + Spacer(modifier = Modifier.height(32.dp)) + + Text( + text = "textbee", + style = MaterialTheme.typography.headlineLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) + + Spacer(modifier = Modifier.height(12.dp)) + + Text( + text = "Turn your Android into a\nprogrammatic SMS gateway", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + + 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, + modifier = Modifier + .fillMaxWidth() + .height(52.dp) + ) { + Text("Get Started", style = MaterialTheme.typography.labelLarge) + } + + Spacer(modifier = Modifier.height(12.dp)) + + OutlinedButton( + onClick = onHaveDeviceId, + modifier = Modifier + .fillMaxWidth() + .height(52.dp) + ) { + Text("Reconnect a device", style = MaterialTheme.typography.labelLarge) + } + + Spacer(modifier = Modifier.height(16.dp)) + + 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 + ) + } + } +} diff --git a/android/app/src/main/java/com/vernu/sms/ui/settings/SMSFilterScreen.kt b/android/app/src/main/java/com/vernu/sms/ui/settings/SMSFilterScreen.kt new file mode 100644 index 0000000..8f82335 --- /dev/null +++ b/android/app/src/main/java/com/vernu/sms/ui/settings/SMSFilterScreen.kt @@ -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(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") } + } + ) +} diff --git a/android/app/src/main/java/com/vernu/sms/ui/settings/SMSFilterViewModel.kt b/android/app/src/main/java/com/vernu/sms/ui/settings/SMSFilterViewModel.kt new file mode 100644 index 0000000..60c6205 --- /dev/null +++ b/android/app/src/main/java/com/vernu/sms/ui/settings/SMSFilterViewModel.kt @@ -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 = _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) + } +} diff --git a/android/app/src/main/java/com/vernu/sms/ui/settings/SettingsScreen.kt b/android/app/src/main/java/com/vernu/sms/ui/settings/SettingsScreen.kt new file mode 100644 index 0000000..f787e45 --- /dev/null +++ b/android/app/src/main/java/com/vernu/sms/ui/settings/SettingsScreen.kt @@ -0,0 +1,584 @@ +package com.vernu.sms.ui.settings + +import android.content.Intent +import android.net.Uri +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +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.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 +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vernu.sms.BuildConfig + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingsScreen( + onSwitchToLegacy: () -> Unit, + onNavigateToFilters: () -> Unit, + onDisconnect: () -> Unit, + viewModel: SettingsViewModel = viewModel() +) { + val state by viewModel.state.collectAsState() + val context = LocalContext.current + val clipboard = LocalClipboardManager.current + val snackbarHostState = remember { SnackbarHostState() } + + var showDisconnectDialog by remember { mutableStateOf(false) } + var showLegacyDialog by remember { mutableStateOf(false) } + var showAboutDialog by remember { mutableStateOf(false) } + var showDeviceNameDialog by remember { mutableStateOf(false) } + var editedDeviceName by remember(state.deviceName) { mutableStateOf(state.deviceName) } + var showDelayDialog by remember { mutableStateOf(false) } + var editedDelay by remember(state.smsSendDelaySeconds) { mutableStateOf(state.smsSendDelaySeconds.toString()) } + + LaunchedEffect(state.snackbarMessage) { + state.snackbarMessage?.let { + snackbarHostState.showSnackbar(it) + viewModel.clearSnackbar() + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Default.Settings, contentDescription = null, tint = MaterialTheme.colorScheme.primary) + Spacer(modifier = Modifier.width(8.dp)) + Text("Settings", fontWeight = FontWeight.SemiBold) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface + ) + ) + }, + snackbarHost = { SnackbarHost(snackbarHostState) } + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()) + ) { + SettingsSectionHeader("Account") + + SettingsRow( + icon = Icons.Default.Fingerprint, + title = "Device ID", + subtitle = state.deviceId.ifEmpty { "—" }, + subtitleFont = FontFamily.Monospace, + trailing = { + IconButton(onClick = { clipboard.setText(AnnotatedString(state.deviceId)) }) { + Icon(Icons.Default.ContentCopy, contentDescription = "Copy", modifier = Modifier.size(18.dp)) + } + } + ) + + SettingsRow( + icon = Icons.Default.Key, + title = "API Key", + subtitle = if (state.apiKey.isEmpty()) "—" else "••••••••" + state.apiKey.takeLast(4), + trailing = { + IconButton(onClick = { clipboard.setText(AnnotatedString(state.apiKey)) }) { + Icon(Icons.Default.ContentCopy, contentDescription = "Copy", modifier = Modifier.size(18.dp)) + } + } + ) + + SettingsRow( + icon = Icons.Default.Edit, + title = "Device Name", + subtitle = state.deviceName, + onClick = { + editedDeviceName = state.deviceName + showDeviceNameDialog = true + }, + trailing = { + if (state.isSavingDeviceName) { + CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp) + } else { + Icon(Icons.Default.ChevronRight, contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + ) + + Divider(modifier = Modifier.padding(start = 56.dp)) + + SettingsRow( + icon = Icons.Default.LinkOff, + title = "Disconnect Device", + titleColor = MaterialTheme.colorScheme.error, + onClick = { showDisconnectDialog = true } + ) + + SettingsSectionHeader("Gateway") + + SettingsSwitchRow( + icon = Icons.Default.Power, + title = "Gateway Enabled", + subtitle = "Allow sending and receiving SMS", + checked = state.isGatewayEnabled, + onCheckedChange = { viewModel.setGatewayEnabled(it) } + ) + + if (state.availableSims.size > 1) { + SimSelectionRow( + sims = state.availableSims, + 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") + + SettingsSwitchRow( + icon = Icons.Default.MoveToInbox, + title = "Receive SMS", + subtitle = "Forward incoming SMS to backend", + checked = state.isReceiveSmsEnabled, + onCheckedChange = { viewModel.setReceiveSms(it) } + ) + + SettingsRow( + icon = Icons.Default.Timer, + title = "Send Delay", + subtitle = "${state.smsSendDelaySeconds}s between each SMS", + onClick = { + editedDelay = state.smsSendDelaySeconds.toString() + showDelayDialog = true + }, + trailing = { + Icon(Icons.Default.ChevronRight, contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant) + } + ) + + SettingsRow( + icon = Icons.Default.FilterList, + title = "Configure Filters", + subtitle = "Allow/block list for incoming SMS", + onClick = onNavigateToFilters, + trailing = { + Icon(Icons.Default.ChevronRight, contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant) + } + ) + + SettingsSectionHeader("System") + + SettingsSwitchRow( + icon = Icons.Default.NotificationsActive, + title = "Sticky Notification", + subtitle = "Keeps the gateway alive in the background", + checked = state.isStickyNotificationEnabled, + onCheckedChange = { viewModel.setStickyNotification(it) } + ) + + SettingsRow( + icon = Icons.Default.Info, + title = "App Version", + subtitle = "${state.appVersionName} (Build ${state.appVersionCode})" + ) + + SettingsRow( + icon = Icons.Default.AutoAwesome, + title = "About", + subtitle = "textbee.dev", + onClick = { showAboutDialog = true }, + trailing = { + Icon(Icons.Default.ChevronRight, contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant) + } + ) + + SettingsRow( + icon = Icons.Default.SystemUpdate, + title = "Check for Updates", + onClick = { + val versionInfo = "${BuildConfig.VERSION_NAME}(${BuildConfig.VERSION_CODE})" + val url = "https://textbee.dev/download?currentVersion=${Uri.encode(versionInfo)}" + context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) + }, + trailing = { + Icon(Icons.Default.OpenInBrowser, contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(18.dp)) + } + ) + + 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( + icon = Icons.Default.Gavel, + title = "Terms of Service", + onClick = { + context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://textbee.dev/terms-of-service"))) + }, + trailing = { + Icon(Icons.Default.OpenInBrowser, contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(18.dp)) + } + ) + + SettingsRow( + icon = Icons.Default.Policy, + title = "Privacy Policy", + onClick = { + context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://textbee.dev/privacy-policy"))) + }, + trailing = { + Icon(Icons.Default.OpenInBrowser, contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(18.dp)) + } + ) + + SettingsSectionHeader("UI") + + SettingsRow( + icon = Icons.Default.SwapHoriz, + title = "Switch to Legacy UI", + subtitle = "Use the original interface", + onClick = { showLegacyDialog = true }, + trailing = { + Icon(Icons.Default.ChevronRight, contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant) + } + ) + + Spacer(modifier = Modifier.height(32.dp)) + } + } + + if (showDeviceNameDialog) { + AlertDialog( + onDismissRequest = { showDeviceNameDialog = false }, + title = { Text("Edit Device Name") }, + text = { + OutlinedTextField( + value = editedDeviceName, + onValueChange = { editedDeviceName = it }, + label = { Text("Device Name") }, + singleLine = true + ) + }, + confirmButton = { + TextButton(onClick = { + viewModel.saveDeviceName(editedDeviceName) + showDeviceNameDialog = false + }) { Text("Save") } + }, + dismissButton = { + TextButton(onClick = { showDeviceNameDialog = false }) { Text("Cancel") } + } + ) + } + + if (showDelayDialog) { + AlertDialog( + onDismissRequest = { showDelayDialog = false }, + title = { Text("SMS Send Delay") }, + text = { + Column { + Text( + "Delay between each SMS in seconds (0–3600)", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(8.dp)) + OutlinedTextField( + value = editedDelay, + onValueChange = { editedDelay = it.filter { c -> c.isDigit() } }, + label = { Text("Seconds") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + singleLine = true + ) + } + }, + confirmButton = { + TextButton(onClick = { + viewModel.setSmsSendDelay(editedDelay.toIntOrNull() ?: 5) + showDelayDialog = false + }) { Text("Save") } + }, + dismissButton = { + TextButton(onClick = { showDelayDialog = false }) { Text("Cancel") } + } + ) + } + + if (showLegacyDialog) { + AlertDialog( + onDismissRequest = { showLegacyDialog = false }, + title = { Text("Switch to Legacy UI?") }, + text = { Text("You can switch back from the Legacy Settings screen anytime.") }, + confirmButton = { + TextButton(onClick = { + showLegacyDialog = false + onSwitchToLegacy() + }) { Text("Switch") } + }, + dismissButton = { + TextButton(onClick = { showLegacyDialog = false }) { Text("Cancel") } + } + ) + } + + if (showAboutDialog) { + AlertDialog( + onDismissRequest = { showAboutDialog = false }, + title = { + Text("textbee.dev", fontWeight = FontWeight.Bold) + }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + "An open-source SMS gateway that turns your Android into a personal SMS API. " + + "Send and receive messages programmatically without relying on expensive third-party services.", + style = MaterialTheme.typography.bodyMedium + ) + Spacer(modifier = Modifier.height(4.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton( + onClick = { + context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://textbee.dev"))) + } + ) { + Text("textbee.dev") + } + OutlinedButton( + onClick = { + context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://github.com/vernu/textbee"))) + } + ) { + Text("GitHub") + } + } + } + }, + confirmButton = { + TextButton(onClick = { showAboutDialog = false }) { Text("Close") } + } + ) + } + + if (showDisconnectDialog) { + AlertDialog( + onDismissRequest = { showDisconnectDialog = false }, + title = { Text("Disconnect Device?") }, + text = { Text("This will remove all credentials from this device. You'll need to reconnect to use the gateway.") }, + confirmButton = { + TextButton( + onClick = { + showDisconnectDialog = false + onDisconnect() + }, + colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error) + ) { Text("Disconnect") } + }, + dismissButton = { + TextButton(onClick = { showDisconnectDialog = false }) { Text("Cancel") } + } + ) + } +} + +@Composable +private fun SettingsSectionHeader(title: String) { + Text( + text = title.uppercase(), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(start = 16.dp, top = 20.dp, bottom = 4.dp) + ) +} + +@Composable +private fun SettingsRow( + icon: androidx.compose.ui.graphics.vector.ImageVector, + title: String, + subtitle: String? = null, + subtitleFont: FontFamily = FontFamily.Default, + titleColor: androidx.compose.ui.graphics.Color = MaterialTheme.colorScheme.onSurface, + onClick: (() -> Unit)? = null, + trailing: @Composable (() -> Unit)? = null +) { + Row( + modifier = Modifier + .fillMaxWidth() + .then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.width(16.dp)) + Column(modifier = Modifier.weight(1f)) { + Text(text = title, style = MaterialTheme.typography.bodyLarge, color = titleColor) + subtitle?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall.copy(fontFamily = subtitleFont), + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + trailing?.invoke() + } +} + +@Composable +private fun SettingsSwitchRow( + icon: androidx.compose.ui.graphics.vector.ImageVector, + title: String, + subtitle: String? = null, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.width(16.dp)) + Column(modifier = Modifier.weight(1f)) { + Text(text = title, style = MaterialTheme.typography.bodyLarge) + subtitle?.let { + Text(text = it, style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + Switch(checked = checked, onCheckedChange = onCheckedChange) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SimSelectionRow( + sims: List, + selectedId: Int, + onSelect: (Int) -> Unit +) { + var expanded by remember { mutableStateOf(false) } + val selectedSim = sims.find { it.subscriptionId == selectedId } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.SimCard, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.width(16.dp)) + Column(modifier = Modifier.weight(1f)) { + Text(text = "Default SIM", style = MaterialTheme.typography.bodyLarge) + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = !expanded } + ) { + OutlinedTextField( + value = selectedSim?.let { "${it.displayName} · ID: ${it.subscriptionId}" } + ?: "Device Default", + onValueChange = {}, + readOnly = true, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + modifier = Modifier + .menuAnchor() + .fillMaxWidth(), + textStyle = MaterialTheme.typography.bodySmall + ) + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + DropdownMenuItem( + text = { Text("Device Default") }, + onClick = { + onSelect(-1) + expanded = false + } + ) + sims.forEach { sim -> + DropdownMenuItem( + text = { Text("${sim.displayName} · ID: ${sim.subscriptionId}") }, + onClick = { + onSelect(sim.subscriptionId) + expanded = false + } + ) + } + } + } + } + } +} diff --git a/android/app/src/main/java/com/vernu/sms/ui/settings/SettingsViewModel.kt b/android/app/src/main/java/com/vernu/sms/ui/settings/SettingsViewModel.kt new file mode 100644 index 0000000..c6d436a --- /dev/null +++ b/android/app/src/main/java/com/vernu/sms/ui/settings/SettingsViewModel.kt @@ -0,0 +1,215 @@ +package com.vernu.sms.ui.settings + +import android.app.Application +import android.os.Build +import android.telephony.SubscriptionManager +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import com.vernu.sms.ApiManagerKt +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.helpers.SharedPreferenceHelper +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.json.JSONObject +import retrofit2.Response + +data class SimOption(val subscriptionId: Int, val displayName: String) + +data class SettingsState( + val deviceId: String = "", + val apiKey: String = "", + val deviceName: String = "", + val isGatewayEnabled: Boolean = false, + val isReceiveSmsEnabled: Boolean = false, + val isStickyNotificationEnabled: Boolean = false, + val smsSendDelaySeconds: Int = AppConstants.DEFAULT_SMS_SEND_DELAY_SECONDS, + val preferredSimSubscriptionId: Int = -1, + val availableSims: List = emptyList(), + val appVersionName: String = BuildConfig.VERSION_NAME, + val appVersionCode: Int = BuildConfig.VERSION_CODE, + val isSavingDeviceName: Boolean = false, + val snackbarMessage: String? = null +) + +class SettingsViewModel(app: Application) : AndroidViewModel(app) { + + private val context get() = getApplication().applicationContext + + private val _state = MutableStateFlow(SettingsState()) + val state: StateFlow = _state.asStateFlow() + + init { + loadSettings() + } + + private fun loadSettings() { + val deviceId = SharedPreferenceHelper.getSharedPreferenceString( + context, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, "" + ) ?: "" + val apiKey = SharedPreferenceHelper.getSharedPreferenceString( + context, AppConstants.SHARED_PREFS_API_KEY_KEY, "" + ) ?: "" + val deviceName = SharedPreferenceHelper.getSharedPreferenceString( + context, AppConstants.SHARED_PREFS_DEVICE_NAME_KEY, "" + ) ?: "" + val isGatewayEnabled = SharedPreferenceHelper.getSharedPreferenceBoolean( + context, AppConstants.SHARED_PREFS_GATEWAY_ENABLED_KEY, false + ) + val isReceiveSms = SharedPreferenceHelper.getSharedPreferenceBoolean( + context, AppConstants.SHARED_PREFS_RECEIVE_SMS_ENABLED_KEY, false + ) + val isSticky = SharedPreferenceHelper.getSharedPreferenceBoolean( + context, AppConstants.SHARED_PREFS_STICKY_NOTIFICATION_ENABLED_KEY, false + ) + val smsDelay = SharedPreferenceHelper.getSharedPreferenceInt( + context, AppConstants.SHARED_PREFS_SMS_SEND_DELAY_SECONDS_KEY, + AppConstants.DEFAULT_SMS_SEND_DELAY_SECONDS + ) + val preferredSim = SharedPreferenceHelper.getSharedPreferenceInt( + context, AppConstants.SHARED_PREFS_PREFERRED_SIM_KEY, -1 + ) + + val sims = try { + TextBeeUtils.getAvailableSimSlots(context).map { info -> + SimOption( + subscriptionId = info.subscriptionId, + displayName = "${info.carrierName} (SIM ${info.simSlotIndex + 1})" + ) + } + } catch (e: Exception) { + emptyList() + } + + _state.update { + it.copy( + deviceId = deviceId, + apiKey = apiKey, + deviceName = deviceName.ifEmpty { "${Build.BRAND} ${Build.MODEL}" }, + isGatewayEnabled = isGatewayEnabled, + isReceiveSmsEnabled = isReceiveSms, + isStickyNotificationEnabled = isSticky, + smsSendDelaySeconds = smsDelay, + preferredSimSubscriptionId = preferredSim, + availableSims = sims + ) + } + } + + fun setGatewayEnabled(enabled: Boolean) { + val deviceId = _state.value.deviceId + val apiKey = _state.value.apiKey + if (deviceId.isEmpty() || apiKey.isEmpty()) return + + viewModelScope.launch { + try { + val input = RegisterDeviceInputDTO().apply { this.enabled = enabled } + val response = ApiManagerKt.getApiService().updateDevice(deviceId, apiKey, input) + if (response.isSuccessful) { + SharedPreferenceHelper.setSharedPreferenceBoolean( + context, AppConstants.SHARED_PREFS_GATEWAY_ENABLED_KEY, enabled + ) + _state.update { it.copy(isGatewayEnabled = enabled) } + if (enabled) { + TextBeeUtils.startStickyNotificationService(context) + com.vernu.sms.helpers.HeartbeatManager.scheduleHeartbeat(context) + } else { + TextBeeUtils.stopStickyNotificationService(context) + com.vernu.sms.helpers.HeartbeatManager.cancelHeartbeat(context) + } + } else { + _state.update { it.copy(snackbarMessage = extractErrorMessage(response, "Failed to update gateway status")) } + } + } catch (e: Exception) { + _state.update { it.copy(snackbarMessage = "Network error. Try again.") } + TextBeeUtils.logException(e, "Gateway toggle from settings failed") + } + } + } + + fun setReceiveSms(enabled: Boolean) { + SharedPreferenceHelper.setSharedPreferenceBoolean( + context, AppConstants.SHARED_PREFS_RECEIVE_SMS_ENABLED_KEY, enabled + ) + _state.update { it.copy(isReceiveSmsEnabled = enabled) } + } + + fun setStickyNotification(enabled: Boolean) { + SharedPreferenceHelper.setSharedPreferenceBoolean( + context, AppConstants.SHARED_PREFS_STICKY_NOTIFICATION_ENABLED_KEY, enabled + ) + try { + if (enabled) TextBeeUtils.startStickyNotificationService(context) + else TextBeeUtils.stopStickyNotificationService(context) + } catch (e: Exception) { + TextBeeUtils.logException(e, "Sticky notification toggle failed") + _state.update { it.copy(snackbarMessage = "Could not start notification service") } + } + _state.update { it.copy(isStickyNotificationEnabled = enabled) } + } + + fun setSmsSendDelay(seconds: Int) { + val clamped = seconds.coerceIn(0, 3600) + SharedPreferenceHelper.setSharedPreferenceInt( + context, AppConstants.SHARED_PREFS_SMS_SEND_DELAY_SECONDS_KEY, clamped + ) + _state.update { it.copy(smsSendDelaySeconds = clamped) } + } + + fun setPreferredSim(subscriptionId: Int) { + SharedPreferenceHelper.setSharedPreferenceInt( + context, AppConstants.SHARED_PREFS_PREFERRED_SIM_KEY, subscriptionId + ) + _state.update { it.copy(preferredSimSubscriptionId = subscriptionId) } + } + + fun saveDeviceName(name: String) { + val deviceId = _state.value.deviceId + val apiKey = _state.value.apiKey + if (deviceId.isEmpty() || apiKey.isEmpty() || name.isBlank()) return + + viewModelScope.launch { + _state.update { it.copy(isSavingDeviceName = true) } + try { + val input = RegisterDeviceInputDTO().apply { this.name = name.trim() } + val response = ApiManagerKt.getApiService().updateDevice(deviceId, apiKey, input) + if (response.isSuccessful) { + SharedPreferenceHelper.setSharedPreferenceString( + context, AppConstants.SHARED_PREFS_DEVICE_NAME_KEY, name.trim() + ) + _state.update { it.copy(deviceName = name.trim(), snackbarMessage = "Device name saved") } + } else { + _state.update { it.copy(snackbarMessage = extractErrorMessage(response, "Failed to save device name")) } + } + } catch (e: Exception) { + _state.update { it.copy(snackbarMessage = "Network error. Try again.") } + TextBeeUtils.logException(e, "Save device name failed") + } finally { + _state.update { it.copy(isSavingDeviceName = false) } + } + } + } + + fun clearSnackbar() = _state.update { it.copy(snackbarMessage = null) } + + private fun extractErrorMessage(response: Response<*>, fallback: String): String { + return try { + val body = response.errorBody()?.string() + if (!body.isNullOrBlank()) { + val json = JSONObject(body) + json.optString("message").takeIf { it.isNotBlank() } + ?: json.optString("error").takeIf { it.isNotBlank() } + ?: fallback + } else { + fallback + } + } catch (e: Exception) { + fallback + } + } +} diff --git a/android/app/src/main/java/com/vernu/sms/ui/splash/SplashActivity.kt b/android/app/src/main/java/com/vernu/sms/ui/splash/SplashActivity.kt new file mode 100644 index 0000000..f8ee15d --- /dev/null +++ b/android/app/src/main/java/com/vernu/sms/ui/splash/SplashActivity.kt @@ -0,0 +1,86 @@ +package com.vernu.sms.ui.splash + +import android.content.Intent +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +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.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.vernu.sms.R +import com.vernu.sms.AppConstants +import com.vernu.sms.activities.MainActivity +import com.vernu.sms.helpers.SharedPreferenceHelper +import com.vernu.sms.ui.main.NewMainActivity +import com.vernu.sms.ui.onboarding.OnboardingActivity +import com.vernu.sms.ui.theme.TextBeeTheme + +class SplashActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + TextBeeTheme { + SplashContent() + } + } + Handler(Looper.getMainLooper()).postDelayed({ route() }, 400) + } + + private fun route() { + val useNewUi = SharedPreferenceHelper.getSharedPreferenceBoolean( + this, AppConstants.SHARED_PREFS_USE_NEW_UI_KEY, true + ) + val deviceId = SharedPreferenceHelper.getSharedPreferenceString( + this, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, "" + ) + val target = when { + !useNewUi -> Intent(this, MainActivity::class.java) + deviceId.isNullOrEmpty() -> Intent(this, OnboardingActivity::class.java) + else -> Intent(this, NewMainActivity::class.java) + } + startActivity(target) + finish() + } +} + +@Composable +private fun SplashContent() { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.primary), + contentAlignment = Alignment.Center + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Image( + painter = painterResource(id = R.drawable.ic_app_logo), + contentDescription = null, + modifier = Modifier.size(96.dp) + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "textbee", + style = MaterialTheme.typography.headlineLarge, + color = Color.White, + fontWeight = FontWeight.Bold + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "SMS Gateway", + style = MaterialTheme.typography.bodyLarge, + color = Color.White.copy(alpha = 0.8f) + ) + } + } +} diff --git a/android/app/src/main/java/com/vernu/sms/ui/theme/Color.kt b/android/app/src/main/java/com/vernu/sms/ui/theme/Color.kt new file mode 100644 index 0000000..7adbcea --- /dev/null +++ b/android/app/src/main/java/com/vernu/sms/ui/theme/Color.kt @@ -0,0 +1,20 @@ +package com.vernu.sms.ui.theme + +import androidx.compose.ui.graphics.Color + +val Orange700 = Color(0xFFC4620A) +val Orange800 = Color(0xFFA04405) +val Orange600 = Color(0xFFB45309) +val Orange900 = Color(0xFF92400E) +val OrangeLight = Color(0xFFFFF7ED) + +val Gray50 = Color(0xFFF9FAFB) +val Gray100 = Color(0xFFF3F4F6) +val Gray200 = Color(0xFFE5E7EB) +val Gray400 = Color(0xFF9CA3AF) +val Gray600 = Color(0xFF4B5563) +val Gray900 = Color(0xFF111827) + +val Green500 = Color(0xFF22C55E) +val Red500 = Color(0xFFEF4444) +val Blue500 = Color(0xFF3B82F6) diff --git a/android/app/src/main/java/com/vernu/sms/ui/theme/Theme.kt b/android/app/src/main/java/com/vernu/sms/ui/theme/Theme.kt new file mode 100644 index 0000000..651be45 --- /dev/null +++ b/android/app/src/main/java/com/vernu/sms/ui/theme/Theme.kt @@ -0,0 +1,84 @@ +package com.vernu.sms.ui.theme + +import android.app.Activity +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.core.view.WindowCompat + +private val LightColorScheme = lightColorScheme( + primary = Orange700, + onPrimary = Color.White, + primaryContainer = OrangeLight, + onPrimaryContainer = Orange900, + secondary = Orange600, + onSecondary = Color.White, + secondaryContainer = OrangeLight, + onSecondaryContainer = Orange900, + background = Gray50, + onBackground = Gray900, + surface = Color.White, + onSurface = Gray900, + surfaceVariant = Gray100, + onSurfaceVariant = Gray600, + outline = Gray200, + error = Red500, +) + +private val DarkColorScheme = darkColorScheme( + primary = Orange600, + onPrimary = Color.White, + primaryContainer = Orange900, + onPrimaryContainer = OrangeLight, + secondary = Orange700, + onSecondary = Color.White, + background = Gray900, + onBackground = Gray100, + surface = Color(0xFF1F2937), + onSurface = Gray100, + surfaceVariant = Color(0xFF374151), + onSurfaceVariant = Gray400, + outline = Color(0xFF4B5563), + error = Red500, +) + +@Composable +fun TextBeeTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + dynamicColor: Boolean = false, + content: @Composable () -> Unit +) { + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + darkTheme -> DarkColorScheme + else -> LightColorScheme + } + + val view = LocalView.current + if (!view.isInEditMode) { + SideEffect { + val window = (view.context as Activity).window + window.statusBarColor = colorScheme.background.toArgb() + WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme + } + } + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) +} diff --git a/android/app/src/main/java/com/vernu/sms/ui/theme/Type.kt b/android/app/src/main/java/com/vernu/sms/ui/theme/Type.kt new file mode 100644 index 0000000..81e2d61 --- /dev/null +++ b/android/app/src/main/java/com/vernu/sms/ui/theme/Type.kt @@ -0,0 +1,49 @@ +package com.vernu.sms.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +val Typography = Typography( + headlineLarge = TextStyle( + fontWeight = FontWeight.Bold, + fontSize = 28.sp, + lineHeight = 36.sp + ), + headlineMedium = TextStyle( + fontWeight = FontWeight.SemiBold, + fontSize = 22.sp, + lineHeight = 30.sp + ), + titleLarge = TextStyle( + fontWeight = FontWeight.SemiBold, + fontSize = 18.sp, + lineHeight = 26.sp + ), + titleMedium = TextStyle( + fontWeight = FontWeight.Medium, + fontSize = 16.sp, + lineHeight = 24.sp + ), + bodyLarge = TextStyle( + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp + ), + bodyMedium = TextStyle( + fontWeight = FontWeight.Normal, + fontSize = 14.sp, + lineHeight = 20.sp + ), + labelLarge = TextStyle( + fontWeight = FontWeight.Medium, + fontSize = 14.sp, + lineHeight = 20.sp + ), + labelSmall = TextStyle( + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp + ) +) diff --git a/android/app/src/main/java/com/vernu/sms/workers/HeartbeatWorker.java b/android/app/src/main/java/com/vernu/sms/workers/HeartbeatWorker.java deleted file mode 100644 index 6fb2c02..0000000 --- a/android/app/src/main/java/com/vernu/sms/workers/HeartbeatWorker.java +++ /dev/null @@ -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(); - } - } -} diff --git a/android/app/src/main/java/com/vernu/sms/workers/HeartbeatWorker.kt b/android/app/src/main/java/com/vernu/sms/workers/HeartbeatWorker.kt new file mode 100644 index 0000000..b81c6cb --- /dev/null +++ b/android/app/src/main/java/com/vernu/sms/workers/HeartbeatWorker.kt @@ -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() + } + } +} diff --git a/android/app/src/main/java/com/vernu/sms/workers/SMSReceivedWorker.java b/android/app/src/main/java/com/vernu/sms/workers/SMSReceivedWorker.java deleted file mode 100644 index 1f45182..0000000 --- a/android/app/src/main/java/com/vernu/sms/workers/SMSReceivedWorker.java +++ /dev/null @@ -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 call = ApiManager.getApiService().sendReceivedSMS(deviceId, apiKey, smsDTO); - Response 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); - } -} \ No newline at end of file diff --git a/android/app/src/main/java/com/vernu/sms/workers/SMSReceivedWorker.kt b/android/app/src/main/java/com/vernu/sms/workers/SMSReceivedWorker.kt new file mode 100644 index 0000000..dd8d1f6 --- /dev/null +++ b/android/app/src/main/java/com/vernu/sms/workers/SMSReceivedWorker.kt @@ -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() + } + } +} diff --git a/android/app/src/main/java/com/vernu/sms/workers/SMSStatusUpdateWorker.java b/android/app/src/main/java/com/vernu/sms/workers/SMSStatusUpdateWorker.java deleted file mode 100644 index 6549be2..0000000 --- a/android/app/src/main/java/com/vernu/sms/workers/SMSStatusUpdateWorker.java +++ /dev/null @@ -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 call = ApiManager.getApiService().updateSMSStatus(deviceId, apiKey, smsDTO); - Response 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()); - } -} \ No newline at end of file diff --git a/android/app/src/main/java/com/vernu/sms/workers/SMSStatusUpdateWorker.kt b/android/app/src/main/java/com/vernu/sms/workers/SMSStatusUpdateWorker.kt new file mode 100644 index 0000000..5a5a922 --- /dev/null +++ b/android/app/src/main/java/com/vernu/sms/workers/SMSStatusUpdateWorker.kt @@ -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() + } + } +} diff --git a/android/app/src/main/java/com/vernu/sms/workers/SmsSendWorker.java b/android/app/src/main/java/com/vernu/sms/workers/SmsSendWorker.java deleted file mode 100644 index d9f3832..0000000 --- a/android/app/src/main/java/com/vernu/sms/workers/SmsSendWorker.java +++ /dev/null @@ -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); - } -} diff --git a/android/app/src/main/java/com/vernu/sms/workers/SmsSendWorker.kt b/android/app/src/main/java/com/vernu/sms/workers/SmsSendWorker.kt new file mode 100644 index 0000000..fbc3031 --- /dev/null +++ b/android/app/src/main/java/com/vernu/sms/workers/SmsSendWorker.kt @@ -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 + } +} diff --git a/android/app/src/main/res/drawable/ic_app_logo.webp b/android/app/src/main/res/drawable/ic_app_logo.webp new file mode 100644 index 0000000..9749c70 Binary files /dev/null and b/android/app/src/main/res/drawable/ic_app_logo.webp differ diff --git a/android/app/src/main/res/layout/activity_main.xml b/android/app/src/main/res/layout/activity_main.xml index f20bd45..7a7827b 100644 --- a/android/app/src/main/res/layout/activity_main.xml +++ b/android/app/src/main/res/layout/activity_main.xml @@ -792,6 +792,18 @@ android:textAllCaps="false" android:paddingHorizontal="0dp" style="@style/Widget.AppCompat.Button.Borderless" /> + +