Merge pull request #231 from vernu/dev

rewrite android app ui in jetpack compose with full kotlin migration
This commit is contained in:
vernu
2026-06-10 09:07:11 +03:00
committed by GitHub
96 changed files with 6807 additions and 2925 deletions

4
.gitignore vendored
View File

@@ -2,4 +2,6 @@
.DS_Store
*-monitor
.cursor
.cursor
.claude/
.omc/

View File

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

191
android/MIGRATION.md Normal file
View File

@@ -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<SimInfoDTO>? = null` |
| `dtos/SMSForwardResponseDTO.kt` | Empty class body |
### Helpers & Models (Phase 5) ✅
All helpers are Kotlin `object` with `@JvmStatic` on every public method — at the time of porting, Java workers/receivers called them; those callers were ported in Phase 6.
| File | Notes |
|---|---|
| `helpers/SharedPreferenceHelper.kt` | Replaces `SharedPreferenceHelper.java`; `PREF_FILE = "PREF"`, 7 methods |
| `helpers/SMSFilterHelper.kt` | Replaces `SMSFilterHelper.java`; nested `FilterMode` enum + `FilterConfig` class; Gson-compatible field names |
| `helpers/SMSHelper.kt` | Replaces `SMSHelper.java`; `FLAG_MUTABLE` on API >= S; private PendingIntent helpers |
| `helpers/HeartbeatHelper.kt` | Replaces `HeartbeatHelper.java`; `CountDownLatch` FCM token wait, `@Suppress("DEPRECATION")` for legacy network API |
| `helpers/HeartbeatManager.kt` | Replaces `HeartbeatManager.java`; `PeriodicWorkRequest.Builder(HeartbeatWorker::class.java, ...)` |
| `models/SMSFilterRule.kt` | Replaces `SMSFilterRule.java`; `@JvmOverloads constructor` for Java callers; nested `MatchType` + `FilterTarget` enums |
| `models/SMSPayload.kt` | Replaces `SMSPayload.java`; keeps legacy `receivers` + `smsBody` fields |
---
## What's Left (Java / Legacy)
### 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()`

View File

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

View File

@@ -12,6 +12,7 @@
<uses-permission android:name="android.permission.RECEIVE_SMS" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_REMOTE_MESSAGING"/>
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application
@@ -39,7 +40,8 @@
<service
android:name=".services.StickyNotificationService"
android:enabled="true"
android:exported="false">
android:exported="false"
android:foregroundServiceType="remoteMessaging">
</service>
<receiver
android:name=".receivers.SMSBroadcastReceiver"
@@ -69,15 +71,26 @@
</receiver>
<activity
android:name=".activities.MainActivity"
android:name=".ui.splash.SplashActivity"
android:exported="true"
android:theme="@style/Theme.SMSGateway.NoActionBar">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".ui.onboarding.OnboardingActivity"
android:exported="false"
android:theme="@style/Theme.SMSGateway.NoActionBar" />
<activity
android:name=".ui.main.NewMainActivity"
android:exported="false"
android:theme="@style/Theme.SMSGateway.NoActionBar" />
<activity
android:name=".activities.MainActivity"
android:exported="true"
android:theme="@style/Theme.SMSGateway.NoActionBar" />
<activity
android:name=".activities.SMSFilterActivity"
android:exported="false"

View File

@@ -0,0 +1,20 @@
package com.vernu.sms
import com.vernu.sms.services.GatewayApiServiceKt
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
object ApiManagerKt {
@Volatile
private var instance: GatewayApiServiceKt? = null
fun getApiService(): GatewayApiServiceKt =
instance ?: synchronized(this) {
instance ?: Retrofit.Builder()
.baseUrl(AppConstants.API_BASE_URL)
.addConverterFactory(GsonConverterFactory.create())
.build()
.create(GatewayApiServiceKt::class.java)
.also { instance = it }
}
}

View File

@@ -26,4 +26,6 @@ public class AppConstants {
public static final String SHARED_PREFS_SMS_SEND_DELAY_SECONDS_KEY = "SMS_SEND_DELAY_SECONDS";
/** Default delay between SMS sends (seconds). 5s helps avoid carrier/device throttling. */
public static final int DEFAULT_SMS_SEND_DELAY_SECONDS = 5;
public static final String SHARED_PREFS_USE_NEW_UI_KEY = "USE_NEW_UI";
public static final String SHARED_PREFS_LAST_HEARTBEAT_MS_KEY = "LAST_HEARTBEAT_MS";
}

View File

@@ -54,7 +54,7 @@ public class MainActivity extends AppCompatActivity {
private Context mContext;
private Switch gatewaySwitch, receiveSMSSwitch, stickyNotificationSwitch;
private EditText apiKeyEditText, fcmTokenEditText, deviceIdEditText, deviceNameEditText, smsSendDelayEditText;
private Button registerDeviceBtn, grantSMSPermissionBtn, scanQRBtn, checkUpdatesBtn, configureFilterBtn;
private Button registerDeviceBtn, grantSMSPermissionBtn, scanQRBtn, checkUpdatesBtn, configureFilterBtn, tryNewUIBtn;
private ImageButton copyDeviceIdImgBtn;
private TextView deviceBrandAndModelTxt, deviceIdTxt, appVersionNameTxt, appVersionCodeTxt;
private RadioGroup defaultSimSlotRadioGroup;
@@ -92,6 +92,7 @@ public class MainActivity extends AppCompatActivity {
checkUpdatesBtn = findViewById(R.id.checkUpdatesBtn);
configureFilterBtn = findViewById(R.id.configureFilterBtn);
smsSendDelayEditText = findViewById(R.id.smsSendDelayEditText);
tryNewUIBtn = findViewById(R.id.tryNewUIBtn);
deviceIdTxt.setText(deviceId);
deviceIdEditText.setText(deviceId);
@@ -264,6 +265,13 @@ public class MainActivity extends AppCompatActivity {
startActivity(filterIntent);
});
tryNewUIBtn.setOnClickListener(view -> {
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);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,38 @@
package com.vernu.sms.dtos
import com.google.gson.annotations.SerializedName
data class MessagesResponse(
@SerializedName("data") val data: List<SmsMessage>? = 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<String>? = 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<String>
)

View File

@@ -1,128 +0,0 @@
package com.vernu.sms.dtos;
public class RegisterDeviceInputDTO {
private String fcmToken;
private Boolean enabled;
private String brand;
private String manufacturer;
private String model;
private String name;
private String serial;
private String buildId;
private String os;
private String osVersion;
private String appVersionName;
private int appVersionCode;
private SimInfoCollectionDTO simInfo;
public RegisterDeviceInputDTO() {
}
public RegisterDeviceInputDTO(String fcmToken) {
this.fcmToken = fcmToken;
}
public String getFcmToken() {
return fcmToken;
}
public void setFcmToken(String fcmToken) {
this.fcmToken = fcmToken;
}
public Boolean isEnabled() {
return enabled;
}
public void setEnabled(Boolean enabled) {
this.enabled = enabled;
}
public String getBrand() {
return brand;
}
public void setBrand(String brand) {
this.brand = brand;
}
public String getManufacturer() {
return manufacturer;
}
public void setManufacturer(String manufacturer) {
this.manufacturer = manufacturer;
}
public String getModel() {
return model;
}
public void setModel(String model) {
this.model = model;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getSerial() {
return serial;
}
public void setSerial(String serial) {
this.serial = serial;
}
public String getBuildId() {
return buildId;
}
public void setBuildId(String buildId) {
this.buildId = buildId;
}
public String getOs() {
return os;
}
public void setOs(String os) {
this.os = os;
}
public String getOsVersion() {
return osVersion;
}
public void setOsVersion(String osVersion) {
this.osVersion = osVersion;
}
public String getAppVersionName() {
return appVersionName;
}
public void setAppVersionName(String appVersionName) {
this.appVersionName = appVersionName;
}
public int getAppVersionCode() {
return appVersionCode;
}
public void setAppVersionCode(int appVersionCode) {
this.appVersionCode = appVersionCode;
}
public SimInfoCollectionDTO getSimInfo() {
return simInfo;
}
public void setSimInfo(SimInfoCollectionDTO simInfo) {
this.simInfo = simInfo;
}
}

View File

@@ -0,0 +1,17 @@
package com.vernu.sms.dtos
class RegisterDeviceInputDTO {
var fcmToken: String? = null
@get:JvmName("isEnabled") var enabled: Boolean? = null
var brand: String? = null
var manufacturer: String? = null
var model: String? = null
var name: String? = null
var serial: String? = null
var buildId: String? = null
var os: String? = null
var osVersion: String? = null
var appVersionName: String? = null
var appVersionCode: Int = 0
var simInfo: SimInfoCollectionDTO? = null
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,97 +0,0 @@
package com.vernu.sms.dtos;
public class SimInfoDTO {
private int subscriptionId;
private String iccId;
private Integer cardId;
private String carrierName;
private String displayName;
private Integer simSlotIndex;
private String mcc;
private String mnc;
private String countryIso;
private String subscriptionType;
public SimInfoDTO() {
}
public int getSubscriptionId() {
return subscriptionId;
}
public void setSubscriptionId(int subscriptionId) {
this.subscriptionId = subscriptionId;
}
public String getIccId() {
return iccId;
}
public void setIccId(String iccId) {
this.iccId = iccId;
}
public Integer getCardId() {
return cardId;
}
public void setCardId(Integer cardId) {
this.cardId = cardId;
}
public String getCarrierName() {
return carrierName;
}
public void setCarrierName(String carrierName) {
this.carrierName = carrierName;
}
public String getDisplayName() {
return displayName;
}
public void setDisplayName(String displayName) {
this.displayName = displayName;
}
public Integer getSimSlotIndex() {
return simSlotIndex;
}
public void setSimSlotIndex(Integer simSlotIndex) {
this.simSlotIndex = simSlotIndex;
}
public String getMcc() {
return mcc;
}
public void setMcc(String mcc) {
this.mcc = mcc;
}
public String getMnc() {
return mnc;
}
public void setMnc(String mnc) {
this.mnc = mnc;
}
public String getCountryIso() {
return countryIso;
}
public void setCountryIso(String countryIso) {
this.countryIso = countryIso;
}
public String getSubscriptionType() {
return subscriptionType;
}
public void setSubscriptionType(String subscriptionType) {
this.subscriptionType = subscriptionType;
}
}

View File

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

View File

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

View File

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

View File

@@ -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<HeartbeatResponseDTO> call = ApiManager.getApiService().heartbeat(deviceId, apiKey, heartbeatInput);
Response<HeartbeatResponseDTO> response = call.execute();
if (response.isSuccessful() && response.body() != null) {
HeartbeatResponseDTO responseBody = response.body();
if (responseBody.fcmTokenUpdated) {
Log.d(TAG, "FCM token was updated during heartbeat");
}
// Sync device name from heartbeat response (ignore if blank)
if (responseBody.name != null && !responseBody.name.trim().isEmpty()) {
SharedPreferenceHelper.setSharedPreferenceString(
context,
AppConstants.SHARED_PREFS_DEVICE_NAME_KEY,
responseBody.name
);
Log.d(TAG, "Synced device name from heartbeat: " + responseBody.name);
}
Log.d(TAG, "Heartbeat sent successfully");
return true;
} else {
Log.e(TAG, "Failed to send heartbeat. Response code: " + (response.code()));
return false;
}
} catch (IOException e) {
Log.e(TAG, "Heartbeat API call failed: " + e.getMessage());
return false;
} catch (Exception e) {
Log.e(TAG, "Error collecting device information: " + e.getMessage());
return false;
}
}
/**
* Checks if device is eligible to send heartbeat (registered, enabled, heartbeat enabled).
*
* @param context Application context
* @return true if device is eligible, false otherwise
*/
public static boolean isDeviceEligibleForHeartbeat(Context context) {
// Check if device is registered
String deviceId = SharedPreferenceHelper.getSharedPreferenceString(
context,
AppConstants.SHARED_PREFS_DEVICE_ID_KEY,
""
);
if (deviceId.isEmpty()) {
return false;
}
// Check if device is enabled
boolean deviceEnabled = SharedPreferenceHelper.getSharedPreferenceBoolean(
context,
AppConstants.SHARED_PREFS_GATEWAY_ENABLED_KEY,
false
);
if (!deviceEnabled) {
return false;
}
// Check if heartbeat feature is enabled
boolean heartbeatEnabled = SharedPreferenceHelper.getSharedPreferenceBoolean(
context,
AppConstants.SHARED_PREFS_HEARTBEAT_ENABLED_KEY,
true // Default to true
);
return heartbeatEnabled;
}
}

View File

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

View File

@@ -1,88 +0,0 @@
package com.vernu.sms.helpers;
import android.content.Context;
import android.util.Log;
import androidx.work.Constraints;
import androidx.work.ExistingPeriodicWorkPolicy;
import androidx.work.NetworkType;
import androidx.work.PeriodicWorkRequest;
import androidx.work.WorkManager;
import com.vernu.sms.AppConstants;
import com.vernu.sms.workers.HeartbeatWorker;
import java.util.concurrent.TimeUnit;
public class HeartbeatManager {
private static final String TAG = "HeartbeatManager";
private static final int MIN_INTERVAL_MINUTES = 15; // Android WorkManager minimum
private static final String UNIQUE_WORK_NAME = "heartbeat_unique_work";
public static void scheduleHeartbeat(Context context) {
// Use application context to ensure WorkManager works even when app is closed
Context appContext = context.getApplicationContext();
// Get interval from shared preferences (default 30 minutes)
int intervalMinutes = SharedPreferenceHelper.getSharedPreferenceInt(
appContext,
AppConstants.SHARED_PREFS_HEARTBEAT_INTERVAL_MINUTES_KEY,
30
);
// Enforce minimum interval
if (intervalMinutes < MIN_INTERVAL_MINUTES) {
Log.w(TAG, "Interval " + intervalMinutes + " minutes is less than minimum " + MIN_INTERVAL_MINUTES + " minutes, using minimum");
intervalMinutes = MIN_INTERVAL_MINUTES;
}
Log.d(TAG, "Scheduling heartbeat with interval: " + intervalMinutes + " minutes");
// Create constraints
Constraints constraints = new Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build();
// Create periodic work request
PeriodicWorkRequest heartbeatWork = new PeriodicWorkRequest.Builder(
HeartbeatWorker.class,
intervalMinutes,
TimeUnit.MINUTES
)
.setConstraints(constraints)
.addTag(AppConstants.HEARTBEAT_WORK_TAG)
.build();
// Use enqueueUniquePeriodicWork to ensure only one periodic work exists
// This ensures the work persists across app restarts and device reboots
WorkManager.getInstance(appContext)
.enqueueUniquePeriodicWork(
UNIQUE_WORK_NAME,
ExistingPeriodicWorkPolicy.REPLACE,
heartbeatWork
);
Log.d(TAG, "Heartbeat scheduled successfully with unique work name: " + UNIQUE_WORK_NAME);
}
public static void cancelHeartbeat(Context context) {
Log.d(TAG, "Cancelling heartbeat work");
Context appContext = context.getApplicationContext();
// Cancel by unique work name (more reliable)
WorkManager.getInstance(appContext)
.cancelUniqueWork(UNIQUE_WORK_NAME);
// Also cancel by tag as fallback
WorkManager.getInstance(appContext)
.cancelAllWorkByTag(AppConstants.HEARTBEAT_WORK_TAG);
}
public static void triggerHeartbeat(Context context) {
// This can be used for testing - trigger immediate heartbeat
Log.d(TAG, "Triggering immediate heartbeat");
// For immediate execution, we could create a OneTimeWorkRequest
// but for now, just reschedule which will run soon
scheduleHeartbeat(context);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,157 +0,0 @@
package com.vernu.sms.receivers;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.provider.Telephony;
import android.telephony.SmsMessage;
import android.util.Log;
import com.vernu.sms.AppConstants;
import com.vernu.sms.dtos.SMSDTO;
import com.vernu.sms.helpers.SharedPreferenceHelper;
import com.vernu.sms.helpers.SMSFilterHelper;
import com.vernu.sms.workers.SMSReceivedWorker;
import java.security.MessageDigest;
import java.util.HashSet;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
public class SMSBroadcastReceiver extends BroadcastReceiver {
private static final String TAG = "SMSBroadcastReceiver";
// In-memory cache to prevent rapid duplicate processing (5 seconds TTL)
private static final ConcurrentHashMap<String, Long> processedFingerprints = new ConcurrentHashMap<>();
private static final long CACHE_TTL_MS = 5000; // 5 seconds
@Override
public void onReceive(Context context, Intent intent) {
Log.d(TAG, "onReceive: " + intent.getAction());
if (!Objects.equals(intent.getAction(), Telephony.Sms.Intents.SMS_RECEIVED_ACTION)) {
Log.d(TAG, "Not Valid intent");
return;
}
SmsMessage[] messages = Telephony.Sms.Intents.getMessagesFromIntent(intent);
if (messages == null) {
Log.d(TAG, "No messages found");
return;
}
String deviceId = SharedPreferenceHelper.getSharedPreferenceString(context, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, "");
String apiKey = SharedPreferenceHelper.getSharedPreferenceString(context, AppConstants.SHARED_PREFS_API_KEY_KEY, "");
boolean receiveSMSEnabled = SharedPreferenceHelper.getSharedPreferenceBoolean(context, AppConstants.SHARED_PREFS_RECEIVE_SMS_ENABLED_KEY, false);
if (deviceId.isEmpty() || apiKey.isEmpty() || !receiveSMSEnabled) {
Log.d(TAG, "Device ID or API Key is empty or Receive SMS Feature is disabled");
return;
}
// SMS receivedSMS = new SMS();
// receivedSMS.setType("RECEIVED");
// for (SmsMessage message : messages) {
// receivedSMS.setMessage(receivedSMS.getMessage() + message.getMessageBody());
// receivedSMS.setSender(message.getOriginatingAddress());
// receivedSMS.setReceivedAt(new Date(message.getTimestampMillis()));
// }
SMSDTO receivedSMSDTO = new SMSDTO();
for (SmsMessage message : messages) {
receivedSMSDTO.setMessage(receivedSMSDTO.getMessage() + message.getMessageBody());
receivedSMSDTO.setSender(message.getOriginatingAddress());
receivedSMSDTO.setReceivedAtInMillis(message.getTimestampMillis());
}
// receivedSMSDTO.setSender(receivedSMS.getSender());
// receivedSMSDTO.setMessage(receivedSMS.getMessage());
// receivedSMSDTO.setReceivedAt(receivedSMS.getReceivedAt());
// Apply SMS filter
String sender = receivedSMSDTO.getSender();
String message = receivedSMSDTO.getMessage();
if (sender != null && !SMSFilterHelper.shouldProcessSMS(sender, message, context)) {
Log.d(TAG, "SMS from " + sender + " filtered out by filter rules");
return;
}
// Generate fingerprint for deduplication
String fingerprint = generateFingerprint(
receivedSMSDTO.getSender(),
receivedSMSDTO.getMessage(),
receivedSMSDTO.getReceivedAtInMillis()
);
receivedSMSDTO.setFingerprint(fingerprint);
// Check in-memory cache to prevent rapid duplicate processing
long currentTime = System.currentTimeMillis();
Long lastProcessedTime = processedFingerprints.get(fingerprint);
if (lastProcessedTime != null && (currentTime - lastProcessedTime) < CACHE_TTL_MS) {
Log.d(TAG, "Duplicate SMS detected in cache, skipping: " + fingerprint);
return;
}
// Update cache
processedFingerprints.put(fingerprint, currentTime);
// Clean up old cache entries periodically
cleanupCache(currentTime);
SMSReceivedWorker.enqueueWork(context, deviceId, apiKey, receivedSMSDTO);
}
// private void updateLocalReceivedSMS(SMS localReceivedSMS, Context context) {
// Executors.newSingleThreadExecutor().execute(() -> {
// AppDatabase appDatabase = AppDatabase.getInstance(context);
// appDatabase.localReceivedSMSDao().insertAll(localReceivedSMS);
// });
// }
/**
* Generate a unique fingerprint for an SMS message based on sender, message content, and timestamp
*/
private String generateFingerprint(String sender, String message, long timestamp) {
try {
String data = (sender != null ? sender : "") + "|" +
(message != null ? message : "") + "|" +
timestamp;
MessageDigest md = MessageDigest.getInstance("MD5");
byte[] hashBytes = md.digest(data.getBytes("UTF-8"));
StringBuilder sb = new StringBuilder();
for (byte b : hashBytes) {
sb.append(String.format("%02x", b));
}
return sb.toString();
} catch (Exception e) {
Log.e(TAG, "Error generating fingerprint: " + e.getMessage());
// Fallback to simple string concatenation if MD5 fails
return (sender != null ? sender : "") + "_" +
(message != null ? message : "") + "_" +
timestamp;
}
}
/**
* Clean up old cache entries to prevent memory leaks
*/
private void cleanupCache(long currentTime) {
// Only cleanup occasionally (every 100 entries processed)
if (processedFingerprints.size() > 100) {
Set<String> keysToRemove = new HashSet<>();
for (String key : processedFingerprints.keySet()) {
Long timestamp = processedFingerprints.get(key);
if (timestamp != null && (currentTime - timestamp) > CACHE_TTL_MS) {
keysToRemove.add(key);
}
}
for (String key : keysToRemove) {
processedFingerprints.remove(key);
}
Log.d(TAG, "Cleaned up " + keysToRemove.size() + " expired cache entries");
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<UserProfileWrapper>
@GET("gateway/stats")
suspend fun getStats(
@Header("x-api-key") apiKey: String
): Response<GatewayStatsResponse>
@POST("gateway/devices")
suspend fun registerDevice(
@Header("x-api-key") apiKey: String,
@Body body: RegisterDeviceInputDTO
): Response<RegisterDeviceResponseDTO>
@GET("billing/current-subscription")
suspend fun getCurrentSubscription(
@Header("x-api-key") apiKey: String
): Response<SubscriptionResponse>
@PATCH("gateway/devices/{deviceId}")
suspend fun updateDevice(
@Path("deviceId") deviceId: String,
@Header("x-api-key") apiKey: String,
@Body body: RegisterDeviceInputDTO
): Response<RegisterDeviceResponseDTO>
@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<MessagesResponse>
@POST("gateway/devices/{deviceId}/send-sms")
suspend fun sendSms(
@Path("deviceId") deviceId: String,
@Header("x-api-key") apiKey: String,
@Body body: SendSmsRequest
): Response<Any>
}

View File

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

View File

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

View File

@@ -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<String>,
showOpenSettings: Boolean,
onGrant: () -> Unit,
onOpenSettings: () -> Unit
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) {
Column(modifier = Modifier.padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Icons.Default.Warning,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Permissions Required",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onErrorContainer
)
}
Spacer(modifier = Modifier.height(6.dp))
Text(
text = "The gateway won't work without: ${missingPermissions.joinToString { friendlyPermissionName(it) }}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onErrorContainer
)
Spacer(modifier = Modifier.height(12.dp))
Button(
onClick = if (showOpenSettings) onOpenSettings else onGrant,
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error,
contentColor = MaterialTheme.colorScheme.onError
)
) {
Text(if (showOpenSettings) "Open App Settings" else "Grant Permissions")
}
}
}
}
private fun friendlyPermissionName(permission: String) = when (permission) {
Manifest.permission.SEND_SMS -> "Send SMS"
Manifest.permission.RECEIVE_SMS -> "Receive SMS"
Manifest.permission.READ_PHONE_STATE -> "Phone State"
else -> permission.substringAfterLast(".")
}
@Composable
private fun SimCardsSection(sims: List<SimInfoDTO>) {
val context = LocalContext.current
val clipboard = LocalClipboardManager.current
Divider(modifier = Modifier.padding(top = 10.dp, bottom = 2.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "SIM Cards",
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = "simSubscriptionId",
style = MaterialTheme.typography.labelSmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
)
}
sims.forEach { sim ->
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "SIM ${(sim.simSlotIndex ?: 0) + 1} · ${sim.carrierName ?: sim.displayName ?: "Unknown"}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f)
)
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = sim.subscriptionId.toString(),
style = MaterialTheme.typography.labelSmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
IconButton(
onClick = {
clipboard.setText(AnnotatedString(sim.subscriptionId.toString()))
Toast.makeText(context, "Subscription ID copied", Toast.LENGTH_SHORT).show()
},
modifier = Modifier.size(28.dp)
) {
Icon(
Icons.Default.ContentCopy,
contentDescription = "Copy subscription ID",
modifier = Modifier.size(13.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
}
@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
}
}

View File

@@ -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<SimInfoDTO> = emptyList(),
val isReceiveSmsEnabled: Boolean = false
)
class DashboardViewModel(app: Application) : AndroidViewModel(app) {
private val context get() = getApplication<Application>().applicationContext
private val _state = MutableStateFlow(DashboardState())
val state: StateFlow<DashboardState> = _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<SimInfoDTO>()
}
val isReceiveSms = SharedPreferenceHelper.getSharedPreferenceBoolean(
context, AppConstants.SHARED_PREFS_RECEIVE_SMS_ENABLED_KEY, false
)
_state.update {
it.copy(
deviceName = deviceName,
deviceId = deviceId,
isGatewayEnabled = isEnabled,
lastHeartbeatMs = lastHeartbeatMs,
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) }
}
}
}
}

View File

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

View File

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

View File

@@ -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<String> = 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<Application>().applicationContext
private val _state = MutableStateFlow(ComposeState())
val state: StateFlow<ComposeState> = _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.")
}
}
}
}
}

View File

@@ -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<SmsMessage?>(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) {
""
}
}

View File

@@ -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<SmsMessage> = 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<Application>().applicationContext
private val _state = MutableStateFlow(MessagesState())
val state: StateFlow<MessagesState> = _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")
}
}
}
}
}

View File

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

View File

@@ -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<OnboardingState> = _state.asStateFlow()
private val _registrationSuccess = Channel<Unit>(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) }
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 (03600)",
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<SimOption>,
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
}
)
}
}
}
}
}
}

View File

@@ -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<SimOption> = 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<Application>().applicationContext
private val _state = MutableStateFlow(SettingsState())
val state: StateFlow<SettingsState> = _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
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,55 +0,0 @@
package com.vernu.sms.workers;
import android.content.Context;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.work.Worker;
import androidx.work.WorkerParameters;
import com.vernu.sms.AppConstants;
import com.vernu.sms.helpers.SharedPreferenceHelper;
import com.vernu.sms.helpers.HeartbeatHelper;
public class HeartbeatWorker extends Worker {
private static final String TAG = "HeartbeatWorker";
public HeartbeatWorker(@NonNull Context context, @NonNull WorkerParameters workerParams) {
super(context, workerParams);
}
@NonNull
@Override
public Result doWork() {
Context context = getApplicationContext();
// Check if device is eligible for heartbeat
if (!HeartbeatHelper.isDeviceEligibleForHeartbeat(context)) {
Log.d(TAG, "Device not eligible for heartbeat, skipping");
return Result.success(); // Not a failure, just skip
}
// Get device ID and API key
String deviceId = SharedPreferenceHelper.getSharedPreferenceString(
context,
AppConstants.SHARED_PREFS_DEVICE_ID_KEY,
""
);
String apiKey = SharedPreferenceHelper.getSharedPreferenceString(
context,
AppConstants.SHARED_PREFS_API_KEY_KEY,
""
);
// Send heartbeat using shared helper
boolean success = HeartbeatHelper.sendHeartbeat(context, deviceId, apiKey);
if (success) {
return Result.success();
} else {
Log.e(TAG, "Failed to send heartbeat, will retry");
return Result.retry();
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

View File

@@ -792,6 +792,18 @@
android:textAllCaps="false"
android:paddingHorizontal="0dp"
style="@style/Widget.AppCompat.Button.Borderless" />
<Button
android:id="@+id/tryNewUIBtn"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:text="✨ Try New UI"
android:textColor="?attr/colorPrimary"
android:background="@android:color/transparent"
android:textAllCaps="false"
android:paddingHorizontal="0dp"
style="@style/Widget.AppCompat.Button.Borderless" />
</LinearLayout>
</LinearLayout>
</LinearLayout>

View File

@@ -1,9 +1,10 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
id 'com.android.application' version '7.1.2' apply false
id 'com.android.library' version '7.1.2' apply false
id 'com.google.gms.google-services' version '4.3.10' apply true
id 'com.android.application' version '7.4.2' apply false
id 'com.android.library' version '7.4.2' apply false
id 'com.google.gms.google-services' version '4.3.15' apply false
id 'com.google.firebase.crashlytics' version '2.9.9' apply false
id 'org.jetbrains.kotlin.android' version '1.8.22' apply false
}
task clean(type: Delete) {

View File

@@ -1,6 +1,6 @@
#Fri Mar 18 21:22:00 EAT 2022
distributionBase=GRADLE_USER_HOME
distributionUrl=https\://services.gradle.org/distributions/gradle-7.2-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-bin.zip
distributionPath=wrapper/dists
zipStorePath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME