mirror of
https://github.com/vernu/textbee.git
synced 2026-06-11 01:09:42 -04:00
Merge pull request #231 from vernu/dev
rewrite android app ui in jetpack compose with full kotlin migration
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -2,4 +2,6 @@
|
||||
.DS_Store
|
||||
*-monitor
|
||||
|
||||
.cursor
|
||||
.cursor
|
||||
.claude/
|
||||
.omc/
|
||||
107
android/BUILD_VARIANTS_SETUP.md
Normal file
107
android/BUILD_VARIANTS_SETUP.md
Normal 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
191
android/MIGRATION.md
Normal 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()`
|
||||
@@ -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'
|
||||
@@ -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"
|
||||
|
||||
20
android/app/src/main/java/com/vernu/sms/ApiManagerKt.kt
Normal file
20
android/app/src/main/java/com/vernu/sms/ApiManagerKt.kt
Normal 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 }
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
//package com.vernu.sms.database.local;
|
||||
//
|
||||
//import android.content.Context;
|
||||
//import androidx.room.Database;
|
||||
//import androidx.room.Room;
|
||||
//import androidx.room.RoomDatabase;
|
||||
//
|
||||
//@Database(entities = {SMS.class}, version = 2)
|
||||
//public abstract class AppDatabase extends RoomDatabase {
|
||||
// private static volatile AppDatabase INSTANCE;
|
||||
//
|
||||
// public static AppDatabase getInstance(Context context) {
|
||||
// if (INSTANCE == null) {
|
||||
// synchronized (AppDatabase.class) {
|
||||
// if (INSTANCE == null) {
|
||||
// INSTANCE = Room.databaseBuilder(context.getApplicationContext(), AppDatabase.class, "db1")
|
||||
// .build();
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// return INSTANCE;
|
||||
// }
|
||||
//
|
||||
// public abstract SMSDao localReceivedSMSDao();
|
||||
//}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.vernu.sms.database.local
|
||||
|
||||
/*
|
||||
import android.content.Context
|
||||
import androidx.room.*
|
||||
|
||||
@Database(entities = [Sms::class], version = 2)
|
||||
@TypeConverters(DateConverter::class)
|
||||
abstract class AppDatabase : RoomDatabase() {
|
||||
|
||||
abstract fun localReceivedSMSDao(): SmsDao
|
||||
|
||||
companion object {
|
||||
@Volatile
|
||||
private var INSTANCE: AppDatabase? = null
|
||||
|
||||
fun getInstance(context: Context): AppDatabase {
|
||||
return INSTANCE ?: synchronized(this) {
|
||||
INSTANCE ?: Room.databaseBuilder(
|
||||
context.applicationContext,
|
||||
AppDatabase::class.java,
|
||||
"db1"
|
||||
).build().also { INSTANCE = it }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
*/
|
||||
@@ -1,17 +0,0 @@
|
||||
//package com.vernu.sms.database.local;
|
||||
//
|
||||
//import androidx.room.TypeConverter;
|
||||
//
|
||||
//import java.util.Date;
|
||||
//
|
||||
//public class DateConverter {
|
||||
// @TypeConverter
|
||||
// public static Date toDate(Long dateLong) {
|
||||
// return dateLong == null ? null : new Date(dateLong);
|
||||
// }
|
||||
//
|
||||
// @TypeConverter
|
||||
// public static Long fromDate(Date date) {
|
||||
// return date == null ? null : date.getTime();
|
||||
// }
|
||||
//}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.vernu.sms.database.local
|
||||
|
||||
/*
|
||||
import androidx.room.TypeConverter
|
||||
import java.util.Date
|
||||
|
||||
object DateConverter {
|
||||
@TypeConverter
|
||||
fun toDate(value: Long?): Date? = value?.let { Date(it) }
|
||||
|
||||
@TypeConverter
|
||||
fun fromDate(date: Date?): Long? = date?.time
|
||||
}
|
||||
*/
|
||||
@@ -1,193 +0,0 @@
|
||||
//package com.vernu.sms.database.local;
|
||||
//
|
||||
//import androidx.annotation.NonNull;
|
||||
//import androidx.room.ColumnInfo;
|
||||
//import androidx.room.Entity;
|
||||
//import androidx.room.PrimaryKey;
|
||||
//import androidx.room.TypeConverters;
|
||||
//
|
||||
//import java.util.Date;
|
||||
//
|
||||
//@Entity(tableName = "sms")
|
||||
//@TypeConverters(DateConverter.class)
|
||||
//public class SMS {
|
||||
//
|
||||
// public SMS() {
|
||||
// type = null;
|
||||
// }
|
||||
//
|
||||
// @PrimaryKey(autoGenerate = true)
|
||||
// private int id;
|
||||
//
|
||||
// // This is the ID of the SMS in the server
|
||||
// @ColumnInfo(name = "_id")
|
||||
// private String _id;
|
||||
//
|
||||
// @ColumnInfo(name = "message")
|
||||
// private String message = "";
|
||||
//
|
||||
// @ColumnInfo(name = "encrypted_message")
|
||||
// private String encryptedMessage = "";
|
||||
//
|
||||
// @ColumnInfo(name = "is_encrypted", defaultValue = "0")
|
||||
// private boolean isEncrypted = false;
|
||||
//
|
||||
// @ColumnInfo(name = "sender")
|
||||
// private String sender;
|
||||
//
|
||||
// @ColumnInfo(name = "recipient")
|
||||
// private String recipient;
|
||||
//
|
||||
// @ColumnInfo(name = "requested_at")
|
||||
// private Date requestedAt;
|
||||
//
|
||||
// @ColumnInfo(name = "sent_at")
|
||||
// private Date sentAt;
|
||||
//
|
||||
// @ColumnInfo(name = "delivered_at")
|
||||
// private Date deliveredAt;
|
||||
//
|
||||
// @ColumnInfo(name = "received_at")
|
||||
// private Date receivedAt;
|
||||
//
|
||||
// @NonNull
|
||||
// @ColumnInfo(name = "type")
|
||||
// private String type;
|
||||
//
|
||||
// @ColumnInfo(name = "server_acknowledged_at")
|
||||
// private Date serverAcknowledgedAt;
|
||||
//
|
||||
// public boolean hasServerAcknowledged() {
|
||||
// return serverAcknowledgedAt != null;
|
||||
// }
|
||||
//
|
||||
// @ColumnInfo(name = "last_acknowledged_request_at")
|
||||
// private Date lastAcknowledgedRequestAt;
|
||||
//
|
||||
// @ColumnInfo(name = "retry_count", defaultValue = "0")
|
||||
// private int retryCount = 0;
|
||||
//
|
||||
// public int getId() {
|
||||
// return id;
|
||||
// }
|
||||
//
|
||||
// public void setId(int id) {
|
||||
// this.id = id;
|
||||
// }
|
||||
//
|
||||
// public String get_id() {
|
||||
// return _id;
|
||||
// }
|
||||
//
|
||||
// public void set_id(String _id) {
|
||||
// this._id = _id;
|
||||
// }
|
||||
//
|
||||
// public String getMessage() {
|
||||
// return message;
|
||||
// }
|
||||
//
|
||||
// public void setMessage(String message) {
|
||||
// this.message = message;
|
||||
// }
|
||||
//
|
||||
// public String getEncryptedMessage() {
|
||||
// return encryptedMessage;
|
||||
// }
|
||||
//
|
||||
// public void setEncryptedMessage(String encryptedMessage) {
|
||||
// this.encryptedMessage = encryptedMessage;
|
||||
// }
|
||||
//
|
||||
// public boolean getIsEncrypted() {
|
||||
// return isEncrypted;
|
||||
// }
|
||||
//
|
||||
// public void setIsEncrypted(boolean isEncrypted) {
|
||||
// this.isEncrypted = isEncrypted;
|
||||
// }
|
||||
//
|
||||
// public String getSender() {
|
||||
// return sender;
|
||||
// }
|
||||
//
|
||||
// public void setSender(String sender) {
|
||||
// this.sender = sender;
|
||||
// }
|
||||
//
|
||||
// public String getRecipient() {
|
||||
// return recipient;
|
||||
// }
|
||||
//
|
||||
// public void setRecipient(String recipient) {
|
||||
// this.recipient = recipient;
|
||||
// }
|
||||
//
|
||||
// public Date getServerAcknowledgedAt() {
|
||||
// return serverAcknowledgedAt;
|
||||
// }
|
||||
//
|
||||
// public void setServerAcknowledgedAt(Date serverAcknowledgedAt) {
|
||||
// this.serverAcknowledgedAt = serverAcknowledgedAt;
|
||||
// }
|
||||
//
|
||||
//
|
||||
//
|
||||
// public Date getRequestedAt() {
|
||||
// return requestedAt;
|
||||
// }
|
||||
//
|
||||
// public void setRequestedAt(Date requestedAt) {
|
||||
// this.requestedAt = requestedAt;
|
||||
// }
|
||||
//
|
||||
// public Date getSentAt() {
|
||||
// return sentAt;
|
||||
// }
|
||||
//
|
||||
// public void setSentAt(Date sentAt) {
|
||||
// this.sentAt = sentAt;
|
||||
// }
|
||||
//
|
||||
// public Date getDeliveredAt() {
|
||||
// return deliveredAt;
|
||||
// }
|
||||
//
|
||||
// public void setDeliveredAt(Date deliveredAt) {
|
||||
// this.deliveredAt = deliveredAt;
|
||||
// }
|
||||
//
|
||||
// public Date getReceivedAt() {
|
||||
// return receivedAt;
|
||||
// }
|
||||
//
|
||||
// public void setReceivedAt(Date receivedAt) {
|
||||
// this.receivedAt = receivedAt;
|
||||
// }
|
||||
//
|
||||
// @NonNull
|
||||
// public String getType() {
|
||||
// return type;
|
||||
// }
|
||||
//
|
||||
// public void setType(@NonNull String type) {
|
||||
// this.type = type;
|
||||
// }
|
||||
//
|
||||
//
|
||||
// public Date getLastAcknowledgedRequestAt() {
|
||||
// return lastAcknowledgedRequestAt;
|
||||
// }
|
||||
//
|
||||
// public void setLastAcknowledgedRequestAt(Date lastAcknowledgedRequestAt) {
|
||||
// this.lastAcknowledgedRequestAt = lastAcknowledgedRequestAt;
|
||||
// }
|
||||
//
|
||||
// public int getRetryCount() {
|
||||
// return retryCount;
|
||||
// }
|
||||
//
|
||||
// public void setRetryCount(int retryCount) {
|
||||
// this.retryCount = retryCount;
|
||||
// }
|
||||
//}
|
||||
@@ -1,27 +0,0 @@
|
||||
//package com.vernu.sms.database.local;
|
||||
//
|
||||
//import androidx.room.Dao;
|
||||
//import androidx.room.Delete;
|
||||
//import androidx.room.Insert;
|
||||
//import androidx.room.OnConflictStrategy;
|
||||
//import androidx.room.Query;
|
||||
//
|
||||
//import java.util.List;
|
||||
//
|
||||
//@Dao
|
||||
//public interface SMSDao {
|
||||
//
|
||||
// @Query("SELECT * FROM sms")
|
||||
// List<SMS> getAll();
|
||||
//
|
||||
// @Query("SELECT * FROM sms WHERE id IN (:smsIds)")
|
||||
// List<SMS> loadAllByIds(int[] smsIds);
|
||||
//
|
||||
// @Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
// void insertAll(SMS... sms);
|
||||
//
|
||||
//
|
||||
// @Delete
|
||||
// void delete(SMS sms);
|
||||
//
|
||||
//}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.vernu.sms.database.local
|
||||
|
||||
/*
|
||||
import androidx.annotation.NonNull
|
||||
import androidx.room.*
|
||||
import java.util.Date
|
||||
|
||||
@Entity(tableName = "sms")
|
||||
@TypeConverters(DateConverter::class)
|
||||
data class Sms(
|
||||
@PrimaryKey(autoGenerate = true) val id: Int = 0,
|
||||
@ColumnInfo(name = "_id") val serverId: String? = null,
|
||||
@ColumnInfo(name = "message") val message: String = "",
|
||||
@ColumnInfo(name = "encrypted_message") val encryptedMessage: String = "",
|
||||
@ColumnInfo(name = "is_encrypted", defaultValue = "0") val isEncrypted: Boolean = false,
|
||||
@ColumnInfo(name = "sender") val sender: String? = null,
|
||||
@ColumnInfo(name = "recipient") val recipient: String? = null,
|
||||
@ColumnInfo(name = "requested_at") val requestedAt: Date? = null,
|
||||
@ColumnInfo(name = "sent_at") val sentAt: Date? = null,
|
||||
@ColumnInfo(name = "delivered_at") val deliveredAt: Date? = null,
|
||||
@ColumnInfo(name = "received_at") val receivedAt: Date? = null,
|
||||
@ColumnInfo(name = "type") @field:NonNull val type: String = "",
|
||||
@ColumnInfo(name = "server_acknowledged_at") val serverAcknowledgedAt: Date? = null,
|
||||
@ColumnInfo(name = "last_acknowledged_request_at") val lastAcknowledgedRequestAt: Date? = null,
|
||||
@ColumnInfo(name = "retry_count", defaultValue = "0") val retryCount: Int = 0
|
||||
) {
|
||||
fun hasServerAcknowledged(): Boolean = serverAcknowledgedAt != null
|
||||
}
|
||||
*/
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.vernu.sms.database.local
|
||||
|
||||
/*
|
||||
import androidx.room.*
|
||||
|
||||
@Dao
|
||||
interface SmsDao {
|
||||
@Query("SELECT * FROM sms")
|
||||
suspend fun getAll(): List<Sms>
|
||||
|
||||
@Query("SELECT * FROM sms WHERE id IN (:smsIds)")
|
||||
suspend fun loadAllByIds(smsIds: IntArray): List<Sms>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertAll(vararg sms: Sms)
|
||||
|
||||
@Delete
|
||||
suspend fun delete(sms: Sms)
|
||||
}
|
||||
*/
|
||||
@@ -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
|
||||
)
|
||||
@@ -1,160 +0,0 @@
|
||||
package com.vernu.sms.dtos;
|
||||
|
||||
public class HeartbeatInputDTO {
|
||||
private String fcmToken;
|
||||
private Integer batteryPercentage;
|
||||
private Boolean isCharging;
|
||||
private String networkType;
|
||||
private String appVersionName;
|
||||
private Integer appVersionCode;
|
||||
private Long deviceUptimeMillis;
|
||||
private Long memoryFreeBytes;
|
||||
private Long memoryTotalBytes;
|
||||
private Long memoryMaxBytes;
|
||||
private Long storageAvailableBytes;
|
||||
private Long storageTotalBytes;
|
||||
private String timezone;
|
||||
private String locale;
|
||||
private Boolean receiveSMSEnabled;
|
||||
private Integer smsSendDelaySeconds;
|
||||
private SimInfoCollectionDTO simInfo;
|
||||
|
||||
public HeartbeatInputDTO() {
|
||||
}
|
||||
|
||||
public String getFcmToken() {
|
||||
return fcmToken;
|
||||
}
|
||||
|
||||
public void setFcmToken(String fcmToken) {
|
||||
this.fcmToken = fcmToken;
|
||||
}
|
||||
|
||||
public Integer getBatteryPercentage() {
|
||||
return batteryPercentage;
|
||||
}
|
||||
|
||||
public void setBatteryPercentage(Integer batteryPercentage) {
|
||||
this.batteryPercentage = batteryPercentage;
|
||||
}
|
||||
|
||||
public Boolean getIsCharging() {
|
||||
return isCharging;
|
||||
}
|
||||
|
||||
public void setIsCharging(Boolean isCharging) {
|
||||
this.isCharging = isCharging;
|
||||
}
|
||||
|
||||
public String getNetworkType() {
|
||||
return networkType;
|
||||
}
|
||||
|
||||
public void setNetworkType(String networkType) {
|
||||
this.networkType = networkType;
|
||||
}
|
||||
|
||||
public String getAppVersionName() {
|
||||
return appVersionName;
|
||||
}
|
||||
|
||||
public void setAppVersionName(String appVersionName) {
|
||||
this.appVersionName = appVersionName;
|
||||
}
|
||||
|
||||
public Integer getAppVersionCode() {
|
||||
return appVersionCode;
|
||||
}
|
||||
|
||||
public void setAppVersionCode(Integer appVersionCode) {
|
||||
this.appVersionCode = appVersionCode;
|
||||
}
|
||||
|
||||
public Long getDeviceUptimeMillis() {
|
||||
return deviceUptimeMillis;
|
||||
}
|
||||
|
||||
public void setDeviceUptimeMillis(Long deviceUptimeMillis) {
|
||||
this.deviceUptimeMillis = deviceUptimeMillis;
|
||||
}
|
||||
|
||||
public Long getMemoryFreeBytes() {
|
||||
return memoryFreeBytes;
|
||||
}
|
||||
|
||||
public void setMemoryFreeBytes(Long memoryFreeBytes) {
|
||||
this.memoryFreeBytes = memoryFreeBytes;
|
||||
}
|
||||
|
||||
public Long getMemoryTotalBytes() {
|
||||
return memoryTotalBytes;
|
||||
}
|
||||
|
||||
public void setMemoryTotalBytes(Long memoryTotalBytes) {
|
||||
this.memoryTotalBytes = memoryTotalBytes;
|
||||
}
|
||||
|
||||
public Long getMemoryMaxBytes() {
|
||||
return memoryMaxBytes;
|
||||
}
|
||||
|
||||
public void setMemoryMaxBytes(Long memoryMaxBytes) {
|
||||
this.memoryMaxBytes = memoryMaxBytes;
|
||||
}
|
||||
|
||||
public Long getStorageAvailableBytes() {
|
||||
return storageAvailableBytes;
|
||||
}
|
||||
|
||||
public void setStorageAvailableBytes(Long storageAvailableBytes) {
|
||||
this.storageAvailableBytes = storageAvailableBytes;
|
||||
}
|
||||
|
||||
public Long getStorageTotalBytes() {
|
||||
return storageTotalBytes;
|
||||
}
|
||||
|
||||
public void setStorageTotalBytes(Long storageTotalBytes) {
|
||||
this.storageTotalBytes = storageTotalBytes;
|
||||
}
|
||||
|
||||
public String getTimezone() {
|
||||
return timezone;
|
||||
}
|
||||
|
||||
public void setTimezone(String timezone) {
|
||||
this.timezone = timezone;
|
||||
}
|
||||
|
||||
public String getLocale() {
|
||||
return locale;
|
||||
}
|
||||
|
||||
public void setLocale(String locale) {
|
||||
this.locale = locale;
|
||||
}
|
||||
|
||||
public Boolean getReceiveSMSEnabled() {
|
||||
return receiveSMSEnabled;
|
||||
}
|
||||
|
||||
public void setReceiveSMSEnabled(Boolean receiveSMSEnabled) {
|
||||
this.receiveSMSEnabled = receiveSMSEnabled;
|
||||
}
|
||||
|
||||
public Integer getSmsSendDelaySeconds() {
|
||||
return smsSendDelaySeconds;
|
||||
}
|
||||
|
||||
public void setSmsSendDelaySeconds(Integer smsSendDelaySeconds) {
|
||||
this.smsSendDelaySeconds = smsSendDelaySeconds;
|
||||
}
|
||||
|
||||
public SimInfoCollectionDTO getSimInfo() {
|
||||
return simInfo;
|
||||
}
|
||||
|
||||
public void setSimInfo(SimInfoCollectionDTO simInfo) {
|
||||
this.simInfo = simInfo;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.vernu.sms.dtos
|
||||
|
||||
class HeartbeatInputDTO {
|
||||
var fcmToken: String? = null
|
||||
var batteryPercentage: Int? = null
|
||||
var isCharging: Boolean? = null
|
||||
var networkType: String? = null
|
||||
var appVersionName: String? = null
|
||||
var appVersionCode: Int? = null
|
||||
var deviceUptimeMillis: Long? = null
|
||||
var memoryFreeBytes: Long? = null
|
||||
var memoryTotalBytes: Long? = null
|
||||
var memoryMaxBytes: Long? = null
|
||||
var storageAvailableBytes: Long? = null
|
||||
var storageTotalBytes: Long? = null
|
||||
var timezone: String? = null
|
||||
var locale: String? = null
|
||||
var receiveSMSEnabled: Boolean? = null
|
||||
var smsSendDelaySeconds: Int? = null
|
||||
var simInfo: SimInfoCollectionDTO? = null
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
package com.vernu.sms.dtos;
|
||||
|
||||
public class HeartbeatResponseDTO {
|
||||
public boolean success;
|
||||
public boolean fcmTokenUpdated;
|
||||
public long lastHeartbeat;
|
||||
public String name;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.vernu.sms.dtos
|
||||
|
||||
class HeartbeatResponseDTO {
|
||||
@JvmField var success: Boolean = false
|
||||
@JvmField var fcmTokenUpdated: Boolean = false
|
||||
@JvmField var lastHeartbeat: Long = 0
|
||||
@JvmField var name: String? = null
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
@@ -1,128 +0,0 @@
|
||||
package com.vernu.sms.dtos;
|
||||
|
||||
public class RegisterDeviceInputDTO {
|
||||
private String fcmToken;
|
||||
private Boolean enabled;
|
||||
private String brand;
|
||||
private String manufacturer;
|
||||
private String model;
|
||||
private String name;
|
||||
private String serial;
|
||||
private String buildId;
|
||||
private String os;
|
||||
private String osVersion;
|
||||
private String appVersionName;
|
||||
private int appVersionCode;
|
||||
private SimInfoCollectionDTO simInfo;
|
||||
|
||||
public RegisterDeviceInputDTO() {
|
||||
}
|
||||
|
||||
public RegisterDeviceInputDTO(String fcmToken) {
|
||||
this.fcmToken = fcmToken;
|
||||
}
|
||||
|
||||
public String getFcmToken() {
|
||||
return fcmToken;
|
||||
}
|
||||
|
||||
public void setFcmToken(String fcmToken) {
|
||||
this.fcmToken = fcmToken;
|
||||
}
|
||||
|
||||
public Boolean isEnabled() {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
public void setEnabled(Boolean enabled) {
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
public String getBrand() {
|
||||
return brand;
|
||||
}
|
||||
|
||||
public void setBrand(String brand) {
|
||||
this.brand = brand;
|
||||
}
|
||||
|
||||
public String getManufacturer() {
|
||||
return manufacturer;
|
||||
}
|
||||
|
||||
public void setManufacturer(String manufacturer) {
|
||||
this.manufacturer = manufacturer;
|
||||
}
|
||||
|
||||
public String getModel() {
|
||||
return model;
|
||||
}
|
||||
|
||||
public void setModel(String model) {
|
||||
this.model = model;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public void setName(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public String getSerial() {
|
||||
return serial;
|
||||
}
|
||||
|
||||
public void setSerial(String serial) {
|
||||
this.serial = serial;
|
||||
}
|
||||
|
||||
public String getBuildId() {
|
||||
return buildId;
|
||||
}
|
||||
|
||||
public void setBuildId(String buildId) {
|
||||
this.buildId = buildId;
|
||||
}
|
||||
|
||||
public String getOs() {
|
||||
return os;
|
||||
}
|
||||
|
||||
public void setOs(String os) {
|
||||
this.os = os;
|
||||
}
|
||||
|
||||
public String getOsVersion() {
|
||||
return osVersion;
|
||||
}
|
||||
|
||||
public void setOsVersion(String osVersion) {
|
||||
this.osVersion = osVersion;
|
||||
}
|
||||
|
||||
public String getAppVersionName() {
|
||||
return appVersionName;
|
||||
}
|
||||
|
||||
public void setAppVersionName(String appVersionName) {
|
||||
this.appVersionName = appVersionName;
|
||||
}
|
||||
|
||||
public int getAppVersionCode() {
|
||||
return appVersionCode;
|
||||
}
|
||||
|
||||
public void setAppVersionCode(int appVersionCode) {
|
||||
this.appVersionCode = appVersionCode;
|
||||
}
|
||||
|
||||
public SimInfoCollectionDTO getSimInfo() {
|
||||
return simInfo;
|
||||
}
|
||||
|
||||
public void setSimInfo(SimInfoCollectionDTO simInfo) {
|
||||
this.simInfo = simInfo;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.vernu.sms.dtos
|
||||
|
||||
class RegisterDeviceInputDTO {
|
||||
var fcmToken: String? = null
|
||||
@get:JvmName("isEnabled") var enabled: Boolean? = null
|
||||
var brand: String? = null
|
||||
var manufacturer: String? = null
|
||||
var model: String? = null
|
||||
var name: String? = null
|
||||
var serial: String? = null
|
||||
var buildId: String? = null
|
||||
var os: String? = null
|
||||
var osVersion: String? = null
|
||||
var appVersionName: String? = null
|
||||
var appVersionCode: Int = 0
|
||||
var simInfo: SimInfoCollectionDTO? = null
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
package com.vernu.sms.dtos;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
public class RegisterDeviceResponseDTO {
|
||||
public boolean success;
|
||||
public Map<String, Object> data;
|
||||
public String error;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.vernu.sms.dtos
|
||||
|
||||
class RegisterDeviceResponseDTO {
|
||||
@JvmField var success: Boolean = false
|
||||
@JvmField var data: Map<String, Any?>? = null
|
||||
@JvmField var error: String? = null
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
package com.vernu.sms.dtos;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
public class SMSDTO {
|
||||
private String sender;
|
||||
private String message = "";
|
||||
private long receivedAtInMillis;
|
||||
private String fingerprint;
|
||||
|
||||
private String smsId;
|
||||
private String smsBatchId;
|
||||
private String status;
|
||||
private long sentAtInMillis;
|
||||
private long deliveredAtInMillis;
|
||||
private long failedAtInMillis;
|
||||
private String errorCode;
|
||||
private String errorMessage;
|
||||
|
||||
public SMSDTO() {
|
||||
}
|
||||
|
||||
|
||||
public String getSender() {
|
||||
return sender;
|
||||
}
|
||||
|
||||
public void setSender(String sender) {
|
||||
this.sender = sender;
|
||||
}
|
||||
|
||||
public String getMessage() {
|
||||
return message;
|
||||
}
|
||||
|
||||
public void setMessage(String message) {
|
||||
this.message = message;
|
||||
}
|
||||
|
||||
public long getReceivedAtInMillis() {
|
||||
return receivedAtInMillis;
|
||||
}
|
||||
|
||||
public void setReceivedAtInMillis(long receivedAtInMillis) {
|
||||
this.receivedAtInMillis = receivedAtInMillis;
|
||||
}
|
||||
|
||||
public String getSmsId() {
|
||||
return smsId;
|
||||
}
|
||||
|
||||
public void setSmsId(String smsId) {
|
||||
this.smsId = smsId;
|
||||
}
|
||||
|
||||
public String getSmsBatchId() {
|
||||
return smsBatchId;
|
||||
}
|
||||
|
||||
public void setSmsBatchId(String smsBatchId) {
|
||||
this.smsBatchId = smsBatchId;
|
||||
}
|
||||
|
||||
public String getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public void setStatus(String status) {
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
public long getSentAtInMillis() {
|
||||
return sentAtInMillis;
|
||||
}
|
||||
|
||||
public void setSentAtInMillis(long sentAtInMillis) {
|
||||
this.sentAtInMillis = sentAtInMillis;
|
||||
}
|
||||
|
||||
public long getDeliveredAtInMillis() {
|
||||
return deliveredAtInMillis;
|
||||
}
|
||||
|
||||
public void setDeliveredAtInMillis(long deliveredAtInMillis) {
|
||||
this.deliveredAtInMillis = deliveredAtInMillis;
|
||||
}
|
||||
|
||||
public long getFailedAtInMillis() {
|
||||
return failedAtInMillis;
|
||||
}
|
||||
|
||||
public void setFailedAtInMillis(long failedAtInMillis) {
|
||||
this.failedAtInMillis = failedAtInMillis;
|
||||
}
|
||||
|
||||
public String getErrorCode() {
|
||||
return errorCode;
|
||||
}
|
||||
|
||||
public void setErrorCode(String errorCode) {
|
||||
this.errorCode = errorCode;
|
||||
}
|
||||
|
||||
public String getErrorMessage() {
|
||||
return errorMessage;
|
||||
}
|
||||
|
||||
public void setErrorMessage(String errorMessage) {
|
||||
this.errorMessage = errorMessage;
|
||||
}
|
||||
|
||||
public String getFingerprint() {
|
||||
return fingerprint;
|
||||
}
|
||||
|
||||
public void setFingerprint(String fingerprint) {
|
||||
this.fingerprint = fingerprint;
|
||||
}
|
||||
}
|
||||
16
android/app/src/main/java/com/vernu/sms/dtos/SMSDTO.kt
Normal file
16
android/app/src/main/java/com/vernu/sms/dtos/SMSDTO.kt
Normal file
@@ -0,0 +1,16 @@
|
||||
package com.vernu.sms.dtos
|
||||
|
||||
class SMSDTO {
|
||||
var sender: String? = null
|
||||
var message: String = ""
|
||||
var receivedAtInMillis: Long = 0
|
||||
var fingerprint: String? = null
|
||||
var smsId: String? = null
|
||||
var smsBatchId: String? = null
|
||||
var status: String? = null
|
||||
var sentAtInMillis: Long = 0
|
||||
var deliveredAtInMillis: Long = 0
|
||||
var failedAtInMillis: Long = 0
|
||||
var errorCode: String? = null
|
||||
var errorMessage: String? = null
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
package com.vernu.sms.dtos;
|
||||
|
||||
public class SMSForwardResponseDTO {
|
||||
|
||||
public SMSForwardResponseDTO() {
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package com.vernu.sms.dtos
|
||||
|
||||
class SMSForwardResponseDTO
|
||||
@@ -1,27 +0,0 @@
|
||||
package com.vernu.sms.dtos;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public class SimInfoCollectionDTO {
|
||||
private long lastUpdated;
|
||||
private List<SimInfoDTO> sims;
|
||||
|
||||
public SimInfoCollectionDTO() {
|
||||
}
|
||||
|
||||
public long getLastUpdated() {
|
||||
return lastUpdated;
|
||||
}
|
||||
|
||||
public void setLastUpdated(long lastUpdated) {
|
||||
this.lastUpdated = lastUpdated;
|
||||
}
|
||||
|
||||
public List<SimInfoDTO> getSims() {
|
||||
return sims;
|
||||
}
|
||||
|
||||
public void setSims(List<SimInfoDTO> sims) {
|
||||
this.sims = sims;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.vernu.sms.dtos
|
||||
|
||||
class SimInfoCollectionDTO {
|
||||
var lastUpdated: Long = 0
|
||||
var sims: MutableList<SimInfoDTO>? = null
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
package com.vernu.sms.dtos;
|
||||
|
||||
public class SimInfoDTO {
|
||||
private int subscriptionId;
|
||||
private String iccId;
|
||||
private Integer cardId;
|
||||
private String carrierName;
|
||||
private String displayName;
|
||||
private Integer simSlotIndex;
|
||||
private String mcc;
|
||||
private String mnc;
|
||||
private String countryIso;
|
||||
private String subscriptionType;
|
||||
|
||||
public SimInfoDTO() {
|
||||
}
|
||||
|
||||
public int getSubscriptionId() {
|
||||
return subscriptionId;
|
||||
}
|
||||
|
||||
public void setSubscriptionId(int subscriptionId) {
|
||||
this.subscriptionId = subscriptionId;
|
||||
}
|
||||
|
||||
public String getIccId() {
|
||||
return iccId;
|
||||
}
|
||||
|
||||
public void setIccId(String iccId) {
|
||||
this.iccId = iccId;
|
||||
}
|
||||
|
||||
public Integer getCardId() {
|
||||
return cardId;
|
||||
}
|
||||
|
||||
public void setCardId(Integer cardId) {
|
||||
this.cardId = cardId;
|
||||
}
|
||||
|
||||
public String getCarrierName() {
|
||||
return carrierName;
|
||||
}
|
||||
|
||||
public void setCarrierName(String carrierName) {
|
||||
this.carrierName = carrierName;
|
||||
}
|
||||
|
||||
public String getDisplayName() {
|
||||
return displayName;
|
||||
}
|
||||
|
||||
public void setDisplayName(String displayName) {
|
||||
this.displayName = displayName;
|
||||
}
|
||||
|
||||
public Integer getSimSlotIndex() {
|
||||
return simSlotIndex;
|
||||
}
|
||||
|
||||
public void setSimSlotIndex(Integer simSlotIndex) {
|
||||
this.simSlotIndex = simSlotIndex;
|
||||
}
|
||||
|
||||
public String getMcc() {
|
||||
return mcc;
|
||||
}
|
||||
|
||||
public void setMcc(String mcc) {
|
||||
this.mcc = mcc;
|
||||
}
|
||||
|
||||
public String getMnc() {
|
||||
return mnc;
|
||||
}
|
||||
|
||||
public void setMnc(String mnc) {
|
||||
this.mnc = mnc;
|
||||
}
|
||||
|
||||
public String getCountryIso() {
|
||||
return countryIso;
|
||||
}
|
||||
|
||||
public void setCountryIso(String countryIso) {
|
||||
this.countryIso = countryIso;
|
||||
}
|
||||
|
||||
public String getSubscriptionType() {
|
||||
return subscriptionType;
|
||||
}
|
||||
|
||||
public void setSubscriptionType(String subscriptionType) {
|
||||
this.subscriptionType = subscriptionType;
|
||||
}
|
||||
}
|
||||
14
android/app/src/main/java/com/vernu/sms/dtos/SimInfoDTO.kt
Normal file
14
android/app/src/main/java/com/vernu/sms/dtos/SimInfoDTO.kt
Normal file
@@ -0,0 +1,14 @@
|
||||
package com.vernu.sms.dtos
|
||||
|
||||
class SimInfoDTO {
|
||||
var subscriptionId: Int = 0
|
||||
var iccId: String? = null
|
||||
var cardId: Int? = null
|
||||
var carrierName: String? = null
|
||||
var displayName: String? = null
|
||||
var simSlotIndex: Int? = null
|
||||
var mcc: String? = null
|
||||
var mnc: String? = null
|
||||
var countryIso: String? = null
|
||||
var subscriptionType: String? = null
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package com.vernu.sms.helpers
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.net.ConnectivityManager
|
||||
import android.os.BatteryManager
|
||||
import android.os.StatFs
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
import com.google.firebase.messaging.FirebaseMessaging
|
||||
import com.vernu.sms.ApiManager
|
||||
import com.vernu.sms.AppConstants
|
||||
import com.vernu.sms.BuildConfig
|
||||
import com.vernu.sms.TextBeeUtils
|
||||
import com.vernu.sms.dtos.HeartbeatInputDTO
|
||||
import com.vernu.sms.dtos.SimInfoCollectionDTO
|
||||
import java.io.IOException
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
object HeartbeatHelper {
|
||||
private const val TAG = "HeartbeatHelper"
|
||||
|
||||
@JvmStatic
|
||||
fun sendHeartbeat(context: Context, deviceId: String, apiKey: String): Boolean {
|
||||
if (deviceId.isEmpty()) {
|
||||
Log.d(TAG, "Device not registered, skipping heartbeat")
|
||||
return false
|
||||
}
|
||||
if (apiKey.isEmpty()) {
|
||||
Log.e(TAG, "API key not available, skipping heartbeat")
|
||||
return false
|
||||
}
|
||||
|
||||
val heartbeatInput = HeartbeatInputDTO()
|
||||
|
||||
return try {
|
||||
// FCM token (blocking wait up to 5 seconds)
|
||||
try {
|
||||
val latch = CountDownLatch(1)
|
||||
var fcmTokenResult: String? = null
|
||||
FirebaseMessaging.getInstance().token.addOnCompleteListener { task ->
|
||||
if (task.isSuccessful) fcmTokenResult = task.result
|
||||
latch.countDown()
|
||||
}
|
||||
if (latch.await(5, TimeUnit.SECONDS) && fcmTokenResult != null) {
|
||||
heartbeatInput.fcmToken = fcmTokenResult
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to get FCM token: ${e.message}")
|
||||
}
|
||||
|
||||
// Battery info
|
||||
val batteryStatus = context.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
|
||||
if (batteryStatus != null) {
|
||||
val level = batteryStatus.getIntExtra(BatteryManager.EXTRA_LEVEL, -1)
|
||||
val scale = batteryStatus.getIntExtra(BatteryManager.EXTRA_SCALE, -1)
|
||||
heartbeatInput.batteryPercentage = ((level / scale.toFloat()) * 100).toInt()
|
||||
val status = batteryStatus.getIntExtra(BatteryManager.EXTRA_STATUS, -1)
|
||||
heartbeatInput.isCharging = status == BatteryManager.BATTERY_STATUS_CHARGING ||
|
||||
status == BatteryManager.BATTERY_STATUS_FULL
|
||||
}
|
||||
|
||||
// Network type
|
||||
@Suppress("DEPRECATION")
|
||||
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
|
||||
@Suppress("DEPRECATION")
|
||||
val activeNetwork = cm?.activeNetworkInfo
|
||||
@Suppress("DEPRECATION")
|
||||
heartbeatInput.networkType = when {
|
||||
activeNetwork?.isConnected == true && activeNetwork.type == ConnectivityManager.TYPE_WIFI -> "wifi"
|
||||
activeNetwork?.isConnected == true && activeNetwork.type == ConnectivityManager.TYPE_MOBILE -> "cellular"
|
||||
else -> "none"
|
||||
}
|
||||
|
||||
// App version
|
||||
heartbeatInput.appVersionName = BuildConfig.VERSION_NAME
|
||||
heartbeatInput.appVersionCode = BuildConfig.VERSION_CODE
|
||||
|
||||
// Device uptime
|
||||
heartbeatInput.deviceUptimeMillis = SystemClock.uptimeMillis()
|
||||
|
||||
// Memory
|
||||
val runtime = Runtime.getRuntime()
|
||||
heartbeatInput.memoryFreeBytes = runtime.freeMemory()
|
||||
heartbeatInput.memoryTotalBytes = runtime.totalMemory()
|
||||
heartbeatInput.memoryMaxBytes = runtime.maxMemory()
|
||||
|
||||
// Storage
|
||||
val statFs = StatFs(context.filesDir.path)
|
||||
heartbeatInput.storageAvailableBytes = statFs.availableBytes
|
||||
heartbeatInput.storageTotalBytes = statFs.totalBytes
|
||||
|
||||
// Locale / timezone
|
||||
heartbeatInput.timezone = TimeZone.getDefault().id
|
||||
heartbeatInput.locale = Locale.getDefault().toString()
|
||||
|
||||
// Preferences
|
||||
heartbeatInput.receiveSMSEnabled = SharedPreferenceHelper.getSharedPreferenceBoolean(
|
||||
context, AppConstants.SHARED_PREFS_RECEIVE_SMS_ENABLED_KEY, false
|
||||
)
|
||||
heartbeatInput.smsSendDelaySeconds = SharedPreferenceHelper.getSharedPreferenceInt(
|
||||
context, AppConstants.SHARED_PREFS_SMS_SEND_DELAY_SECONDS_KEY,
|
||||
AppConstants.DEFAULT_SMS_SEND_DELAY_SECONDS
|
||||
)
|
||||
|
||||
// SIM info
|
||||
heartbeatInput.simInfo = SimInfoCollectionDTO().apply {
|
||||
lastUpdated = System.currentTimeMillis()
|
||||
sims = TextBeeUtils.collectSimInfo(context)
|
||||
}
|
||||
|
||||
// Send heartbeat (blocking)
|
||||
val response = ApiManager.getApiService().heartbeat(deviceId, apiKey, heartbeatInput).execute()
|
||||
if (response.isSuccessful && response.body() != null) {
|
||||
val body = response.body()!!
|
||||
if (body.fcmTokenUpdated) Log.d(TAG, "FCM token was updated during heartbeat")
|
||||
if (!body.name.isNullOrBlank()) {
|
||||
SharedPreferenceHelper.setSharedPreferenceString(
|
||||
context, AppConstants.SHARED_PREFS_DEVICE_NAME_KEY, body.name!!
|
||||
)
|
||||
Log.d(TAG, "Synced device name from heartbeat: ${body.name}")
|
||||
}
|
||||
SharedPreferenceHelper.setSharedPreferenceString(
|
||||
context, AppConstants.SHARED_PREFS_LAST_HEARTBEAT_MS_KEY,
|
||||
System.currentTimeMillis().toString()
|
||||
)
|
||||
Log.d(TAG, "Heartbeat sent successfully")
|
||||
true
|
||||
} else {
|
||||
Log.e(TAG, "Failed to send heartbeat. Response code: ${response.code()}")
|
||||
false
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Log.e(TAG, "Heartbeat API call failed: ${e.message}")
|
||||
false
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error collecting device information: ${e.message}")
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun isDeviceEligibleForHeartbeat(context: Context): Boolean {
|
||||
val deviceId = SharedPreferenceHelper.getSharedPreferenceString(
|
||||
context, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, ""
|
||||
) ?: ""
|
||||
if (deviceId.isEmpty()) return false
|
||||
|
||||
val deviceEnabled = SharedPreferenceHelper.getSharedPreferenceBoolean(
|
||||
context, AppConstants.SHARED_PREFS_GATEWAY_ENABLED_KEY, false
|
||||
)
|
||||
if (!deviceEnabled) return false
|
||||
|
||||
return SharedPreferenceHelper.getSharedPreferenceBoolean(
|
||||
context, AppConstants.SHARED_PREFS_HEARTBEAT_ENABLED_KEY, true
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
package com.vernu.sms.helpers;
|
||||
|
||||
import android.content.Context;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.work.Constraints;
|
||||
import androidx.work.ExistingPeriodicWorkPolicy;
|
||||
import androidx.work.NetworkType;
|
||||
import androidx.work.PeriodicWorkRequest;
|
||||
import androidx.work.WorkManager;
|
||||
|
||||
import com.vernu.sms.AppConstants;
|
||||
import com.vernu.sms.workers.HeartbeatWorker;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
public class HeartbeatManager {
|
||||
private static final String TAG = "HeartbeatManager";
|
||||
private static final int MIN_INTERVAL_MINUTES = 15; // Android WorkManager minimum
|
||||
private static final String UNIQUE_WORK_NAME = "heartbeat_unique_work";
|
||||
|
||||
public static void scheduleHeartbeat(Context context) {
|
||||
// Use application context to ensure WorkManager works even when app is closed
|
||||
Context appContext = context.getApplicationContext();
|
||||
|
||||
// Get interval from shared preferences (default 30 minutes)
|
||||
int intervalMinutes = SharedPreferenceHelper.getSharedPreferenceInt(
|
||||
appContext,
|
||||
AppConstants.SHARED_PREFS_HEARTBEAT_INTERVAL_MINUTES_KEY,
|
||||
30
|
||||
);
|
||||
|
||||
// Enforce minimum interval
|
||||
if (intervalMinutes < MIN_INTERVAL_MINUTES) {
|
||||
Log.w(TAG, "Interval " + intervalMinutes + " minutes is less than minimum " + MIN_INTERVAL_MINUTES + " minutes, using minimum");
|
||||
intervalMinutes = MIN_INTERVAL_MINUTES;
|
||||
}
|
||||
|
||||
Log.d(TAG, "Scheduling heartbeat with interval: " + intervalMinutes + " minutes");
|
||||
|
||||
// Create constraints
|
||||
Constraints constraints = new Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
.build();
|
||||
|
||||
// Create periodic work request
|
||||
PeriodicWorkRequest heartbeatWork = new PeriodicWorkRequest.Builder(
|
||||
HeartbeatWorker.class,
|
||||
intervalMinutes,
|
||||
TimeUnit.MINUTES
|
||||
)
|
||||
.setConstraints(constraints)
|
||||
.addTag(AppConstants.HEARTBEAT_WORK_TAG)
|
||||
.build();
|
||||
|
||||
// Use enqueueUniquePeriodicWork to ensure only one periodic work exists
|
||||
// This ensures the work persists across app restarts and device reboots
|
||||
WorkManager.getInstance(appContext)
|
||||
.enqueueUniquePeriodicWork(
|
||||
UNIQUE_WORK_NAME,
|
||||
ExistingPeriodicWorkPolicy.REPLACE,
|
||||
heartbeatWork
|
||||
);
|
||||
|
||||
Log.d(TAG, "Heartbeat scheduled successfully with unique work name: " + UNIQUE_WORK_NAME);
|
||||
}
|
||||
|
||||
public static void cancelHeartbeat(Context context) {
|
||||
Log.d(TAG, "Cancelling heartbeat work");
|
||||
Context appContext = context.getApplicationContext();
|
||||
|
||||
// Cancel by unique work name (more reliable)
|
||||
WorkManager.getInstance(appContext)
|
||||
.cancelUniqueWork(UNIQUE_WORK_NAME);
|
||||
|
||||
// Also cancel by tag as fallback
|
||||
WorkManager.getInstance(appContext)
|
||||
.cancelAllWorkByTag(AppConstants.HEARTBEAT_WORK_TAG);
|
||||
}
|
||||
|
||||
public static void triggerHeartbeat(Context context) {
|
||||
// This can be used for testing - trigger immediate heartbeat
|
||||
Log.d(TAG, "Triggering immediate heartbeat");
|
||||
// For immediate execution, we could create a OneTimeWorkRequest
|
||||
// but for now, just reschedule which will run soon
|
||||
scheduleHeartbeat(context);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.vernu.sms.helpers
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.work.*
|
||||
import com.vernu.sms.AppConstants
|
||||
import com.vernu.sms.workers.HeartbeatWorker
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
object HeartbeatManager {
|
||||
private const val TAG = "HeartbeatManager"
|
||||
private const val MIN_INTERVAL_MINUTES = 15
|
||||
private const val UNIQUE_WORK_NAME = "heartbeat_unique_work"
|
||||
|
||||
@JvmStatic
|
||||
fun scheduleHeartbeat(context: Context) {
|
||||
val appContext = context.applicationContext
|
||||
var intervalMinutes = SharedPreferenceHelper.getSharedPreferenceInt(
|
||||
appContext, AppConstants.SHARED_PREFS_HEARTBEAT_INTERVAL_MINUTES_KEY, 30
|
||||
)
|
||||
if (intervalMinutes < MIN_INTERVAL_MINUTES) {
|
||||
Log.w(TAG, "Interval $intervalMinutes minutes is less than minimum $MIN_INTERVAL_MINUTES minutes, using minimum")
|
||||
intervalMinutes = MIN_INTERVAL_MINUTES
|
||||
}
|
||||
Log.d(TAG, "Scheduling heartbeat with interval: $intervalMinutes minutes")
|
||||
|
||||
val constraints = Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
.build()
|
||||
|
||||
val heartbeatWork = PeriodicWorkRequest.Builder(
|
||||
HeartbeatWorker::class.java,
|
||||
intervalMinutes.toLong(),
|
||||
TimeUnit.MINUTES
|
||||
)
|
||||
.setConstraints(constraints)
|
||||
.addTag(AppConstants.HEARTBEAT_WORK_TAG)
|
||||
.build()
|
||||
|
||||
WorkManager.getInstance(appContext)
|
||||
.enqueueUniquePeriodicWork(
|
||||
UNIQUE_WORK_NAME,
|
||||
ExistingPeriodicWorkPolicy.REPLACE,
|
||||
heartbeatWork
|
||||
)
|
||||
Log.d(TAG, "Heartbeat scheduled successfully with unique work name: $UNIQUE_WORK_NAME")
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun cancelHeartbeat(context: Context) {
|
||||
Log.d(TAG, "Cancelling heartbeat work")
|
||||
val appContext = context.applicationContext
|
||||
WorkManager.getInstance(appContext).cancelUniqueWork(UNIQUE_WORK_NAME)
|
||||
WorkManager.getInstance(appContext).cancelAllWorkByTag(AppConstants.HEARTBEAT_WORK_TAG)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun triggerHeartbeat(context: Context) {
|
||||
Log.d(TAG, "Triggering immediate heartbeat")
|
||||
scheduleHeartbeat(context)
|
||||
}
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
package com.vernu.sms.helpers;
|
||||
|
||||
import android.content.Context;
|
||||
import android.util.Log;
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.reflect.TypeToken;
|
||||
import com.vernu.sms.AppConstants;
|
||||
import com.vernu.sms.models.SMSFilterRule;
|
||||
|
||||
import java.lang.reflect.Type;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public class SMSFilterHelper {
|
||||
private static final String TAG = "SMSFilterHelper";
|
||||
|
||||
public enum FilterMode {
|
||||
ALLOW_LIST,
|
||||
BLOCK_LIST
|
||||
}
|
||||
|
||||
public static class FilterConfig {
|
||||
private boolean enabled = false;
|
||||
private FilterMode mode = FilterMode.BLOCK_LIST; // Default to block list
|
||||
private List<SMSFilterRule> rules = new ArrayList<>();
|
||||
|
||||
public FilterConfig() {
|
||||
}
|
||||
|
||||
public boolean isEnabled() {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
public void setEnabled(boolean enabled) {
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
public FilterMode getMode() {
|
||||
return mode;
|
||||
}
|
||||
|
||||
public void setMode(FilterMode mode) {
|
||||
this.mode = mode;
|
||||
}
|
||||
|
||||
public List<SMSFilterRule> getRules() {
|
||||
return rules;
|
||||
}
|
||||
|
||||
public void setRules(List<SMSFilterRule> rules) {
|
||||
this.rules = rules != null ? rules : new ArrayList<>();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load filter configuration from SharedPreferences
|
||||
*/
|
||||
public static FilterConfig loadFilterConfig(Context context) {
|
||||
String json = SharedPreferenceHelper.getSharedPreferenceString(
|
||||
context,
|
||||
AppConstants.SHARED_PREFS_SMS_FILTER_CONFIG_KEY,
|
||||
null
|
||||
);
|
||||
|
||||
if (json == null || json.isEmpty()) {
|
||||
return new FilterConfig();
|
||||
}
|
||||
|
||||
try {
|
||||
Gson gson = new Gson();
|
||||
Type type = new TypeToken<FilterConfig>() {}.getType();
|
||||
FilterConfig config = gson.fromJson(json, type);
|
||||
return config != null ? config : new FilterConfig();
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error loading filter config: " + e.getMessage());
|
||||
return new FilterConfig();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save filter configuration to SharedPreferences
|
||||
*/
|
||||
public static void saveFilterConfig(Context context, FilterConfig config) {
|
||||
try {
|
||||
Gson gson = new Gson();
|
||||
String json = gson.toJson(config);
|
||||
SharedPreferenceHelper.setSharedPreferenceString(
|
||||
context,
|
||||
AppConstants.SHARED_PREFS_SMS_FILTER_CONFIG_KEY,
|
||||
json
|
||||
);
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error saving filter config: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an SMS should be processed based on filter configuration
|
||||
* @param sender The sender phone number
|
||||
* @param message The message content
|
||||
* @param context Application context
|
||||
* @return true if SMS should be processed, false if it should be filtered out
|
||||
*/
|
||||
public static boolean shouldProcessSMS(String sender, String message, Context context) {
|
||||
FilterConfig config = loadFilterConfig(context);
|
||||
|
||||
// If filter is disabled, process all SMS
|
||||
if (!config.isEnabled()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If no rules, process all SMS (empty filter doesn't block anything)
|
||||
if (config.getRules() == null || config.getRules().isEmpty()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if sender and/or message matches any rule
|
||||
boolean matchesAnyRule = false;
|
||||
for (SMSFilterRule rule : config.getRules()) {
|
||||
if (rule.matches(sender, message)) {
|
||||
matchesAnyRule = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply filter mode
|
||||
if (config.getMode() == FilterMode.ALLOW_LIST) {
|
||||
// Only process if matches a rule
|
||||
return matchesAnyRule;
|
||||
} else {
|
||||
// Block list: process if it does NOT match any rule
|
||||
return !matchesAnyRule;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy method for backward compatibility - checks sender only
|
||||
*/
|
||||
public static boolean shouldProcessSMS(String sender, Context context) {
|
||||
return shouldProcessSMS(sender, null, context);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.vernu.sms.helpers
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import com.vernu.sms.AppConstants
|
||||
import com.vernu.sms.models.SMSFilterRule
|
||||
|
||||
object SMSFilterHelper {
|
||||
private const val TAG = "SMSFilterHelper"
|
||||
|
||||
enum class FilterMode { ALLOW_LIST, BLOCK_LIST }
|
||||
|
||||
class FilterConfig {
|
||||
@get:JvmName("isEnabled") var enabled: Boolean = false
|
||||
var mode: FilterMode = FilterMode.BLOCK_LIST
|
||||
var rules: MutableList<SMSFilterRule> = mutableListOf()
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun loadFilterConfig(context: Context): FilterConfig {
|
||||
val json = SharedPreferenceHelper.getSharedPreferenceString(
|
||||
context, AppConstants.SHARED_PREFS_SMS_FILTER_CONFIG_KEY, null
|
||||
)
|
||||
if (json.isNullOrEmpty()) return FilterConfig()
|
||||
return try {
|
||||
val type = object : TypeToken<FilterConfig>() {}.type
|
||||
Gson().fromJson<FilterConfig>(json, type) ?: FilterConfig()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error loading filter config: ${e.message}")
|
||||
FilterConfig()
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun saveFilterConfig(context: Context, config: FilterConfig) {
|
||||
try {
|
||||
SharedPreferenceHelper.setSharedPreferenceString(
|
||||
context,
|
||||
AppConstants.SHARED_PREFS_SMS_FILTER_CONFIG_KEY,
|
||||
Gson().toJson(config)
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error saving filter config: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun shouldProcessSMS(sender: String?, message: String?, context: Context): Boolean {
|
||||
val config = loadFilterConfig(context)
|
||||
if (!config.enabled) return true
|
||||
if (config.rules.isEmpty()) return true
|
||||
val matchesAnyRule = config.rules.any { it.matches(sender, message) }
|
||||
return if (config.mode == FilterMode.ALLOW_LIST) matchesAnyRule else !matchesAnyRule
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun shouldProcessSMS(sender: String?, context: Context): Boolean =
|
||||
shouldProcessSMS(sender, null, context)
|
||||
}
|
||||
@@ -1,213 +0,0 @@
|
||||
package com.vernu.sms.helpers;
|
||||
|
||||
import android.Manifest;
|
||||
import android.app.PendingIntent;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.telephony.SmsManager;
|
||||
import android.os.Build;
|
||||
import android.telephony.SubscriptionManager;
|
||||
import android.util.Log;
|
||||
|
||||
import com.vernu.sms.AppConstants;
|
||||
import com.vernu.sms.TextBeeUtils;
|
||||
import com.vernu.sms.dtos.SMSDTO;
|
||||
import com.vernu.sms.receivers.SMSStatusReceiver;
|
||||
import com.vernu.sms.workers.SMSStatusUpdateWorker;
|
||||
|
||||
import java.util.ArrayList;
|
||||
|
||||
public class SMSHelper {
|
||||
private static final String TAG = "SMSHelper";
|
||||
|
||||
/**
|
||||
* Sends an SMS message and returns whether the operation was successful
|
||||
*
|
||||
* @param phoneNo The recipient's phone number
|
||||
* @param message The SMS message to send
|
||||
* @param smsId The unique ID for this SMS
|
||||
* @param smsBatchId The batch ID for this SMS
|
||||
* @param context The application context
|
||||
* @return boolean True if sending was initiated, false if permissions aren't granted
|
||||
*/
|
||||
public static boolean sendSMS(String phoneNo, String message, String smsId, String smsBatchId, Context context) {
|
||||
// Check if we have permission to send SMS
|
||||
if (!TextBeeUtils.isPermissionGranted(context, Manifest.permission.SEND_SMS)) {
|
||||
Log.e(TAG, "SMS permission not granted. Unable to send SMS.");
|
||||
|
||||
// Report failure to API
|
||||
reportPermissionError(context, smsId, smsBatchId);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
SmsManager smsManager = SmsManager.getDefault();
|
||||
|
||||
// Create pending intents for status tracking
|
||||
PendingIntent sentIntent = createSentPendingIntent(context, smsId, smsBatchId);
|
||||
PendingIntent deliveredIntent = createDeliveredPendingIntent(context, smsId, smsBatchId);
|
||||
|
||||
// For SMS with more than 160 chars
|
||||
ArrayList<String> parts = smsManager.divideMessage(message);
|
||||
if (parts.size() > 1) {
|
||||
ArrayList<PendingIntent> sentIntents = new ArrayList<>();
|
||||
ArrayList<PendingIntent> deliveredIntents = new ArrayList<>();
|
||||
|
||||
for (int i = 0; i < parts.size(); i++) {
|
||||
sentIntents.add(sentIntent);
|
||||
deliveredIntents.add(deliveredIntent);
|
||||
}
|
||||
|
||||
smsManager.sendMultipartTextMessage(phoneNo, null, parts, sentIntents, deliveredIntents);
|
||||
} else {
|
||||
smsManager.sendTextMessage(phoneNo, null, message, sentIntent, deliveredIntent);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Exception when sending SMS: " + e.getMessage());
|
||||
|
||||
// Report exception to API
|
||||
reportSendingError(context, smsId, smsBatchId, e.getMessage());
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends an SMS message from a specific SIM slot and returns whether the operation was successful
|
||||
*
|
||||
* @param phoneNo The recipient's phone number
|
||||
* @param message The SMS message to send
|
||||
* @param simSubscriptionId The specific SIM subscription ID to use
|
||||
* @param smsId The unique ID for this SMS
|
||||
* @param smsBatchId The batch ID for this SMS
|
||||
* @param context The application context
|
||||
* @return boolean True if sending was initiated, false if permissions aren't granted
|
||||
*/
|
||||
public static boolean sendSMSFromSpecificSim(String phoneNo, String message, int simSubscriptionId,
|
||||
String smsId, String smsBatchId, Context context) {
|
||||
// Check for required permissions
|
||||
if (!TextBeeUtils.isPermissionGranted(context, Manifest.permission.SEND_SMS) ||
|
||||
!TextBeeUtils.isPermissionGranted(context, Manifest.permission.READ_PHONE_STATE)) {
|
||||
Log.e(TAG, "SMS or Phone State permission not granted. Unable to send SMS from specific SIM.");
|
||||
|
||||
// Report failure to API
|
||||
reportPermissionError(context, smsId, smsBatchId);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// Get the SmsManager for the specific SIM
|
||||
SmsManager smsManager;
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1) {
|
||||
smsManager = SmsManager.getSmsManagerForSubscriptionId(simSubscriptionId);
|
||||
} else {
|
||||
// Fallback to default SmsManager for older Android versions
|
||||
smsManager = SmsManager.getDefault();
|
||||
Log.w(TAG, "Using default SIM as specific SIM selection not supported on this Android version");
|
||||
}
|
||||
|
||||
// Create pending intents for status tracking
|
||||
PendingIntent sentIntent = createSentPendingIntent(context, smsId, smsBatchId);
|
||||
PendingIntent deliveredIntent = createDeliveredPendingIntent(context, smsId, smsBatchId);
|
||||
|
||||
// For SMS with more than 160 chars
|
||||
ArrayList<String> parts = smsManager.divideMessage(message);
|
||||
if (parts.size() > 1) {
|
||||
ArrayList<PendingIntent> sentIntents = new ArrayList<>();
|
||||
ArrayList<PendingIntent> deliveredIntents = new ArrayList<>();
|
||||
|
||||
for (int i = 0; i < parts.size(); i++) {
|
||||
sentIntents.add(sentIntent);
|
||||
deliveredIntents.add(deliveredIntent);
|
||||
}
|
||||
|
||||
smsManager.sendMultipartTextMessage(phoneNo, null, parts, sentIntents, deliveredIntents);
|
||||
} else {
|
||||
smsManager.sendTextMessage(phoneNo, null, message, sentIntent, deliveredIntent);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Exception when sending SMS from specific SIM: " + e.getMessage());
|
||||
|
||||
// Report exception to API
|
||||
reportSendingError(context, smsId, smsBatchId, e.getMessage());
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static void reportPermissionError(Context context, String smsId, String smsBatchId) {
|
||||
SMSDTO smsDTO = new SMSDTO();
|
||||
smsDTO.setSmsId(smsId);
|
||||
smsDTO.setSmsBatchId(smsBatchId);
|
||||
smsDTO.setStatus("FAILED");
|
||||
smsDTO.setFailedAtInMillis(System.currentTimeMillis());
|
||||
smsDTO.setErrorCode("PERMISSION_DENIED");
|
||||
smsDTO.setErrorMessage("SMS permission not granted");
|
||||
|
||||
updateSMSStatus(context, smsDTO);
|
||||
}
|
||||
|
||||
private static void reportSendingError(Context context, String smsId, String smsBatchId, String errorMessage) {
|
||||
SMSDTO smsDTO = new SMSDTO();
|
||||
smsDTO.setSmsId(smsId);
|
||||
smsDTO.setSmsBatchId(smsBatchId);
|
||||
smsDTO.setStatus("FAILED");
|
||||
smsDTO.setFailedAtInMillis(System.currentTimeMillis());
|
||||
smsDTO.setErrorCode("SENDING_EXCEPTION");
|
||||
smsDTO.setErrorMessage(errorMessage);
|
||||
|
||||
updateSMSStatus(context, smsDTO);
|
||||
}
|
||||
|
||||
private static void updateSMSStatus(Context context, SMSDTO smsDTO) {
|
||||
String deviceId = SharedPreferenceHelper.getSharedPreferenceString(context, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, "");
|
||||
String apiKey = SharedPreferenceHelper.getSharedPreferenceString(context, AppConstants.SHARED_PREFS_API_KEY_KEY, "");
|
||||
|
||||
if (deviceId.isEmpty() || apiKey.isEmpty()) {
|
||||
Log.e(TAG, "Device ID or API key not found");
|
||||
return;
|
||||
}
|
||||
|
||||
SMSStatusUpdateWorker.enqueueWork(context, deviceId, apiKey, smsDTO);
|
||||
}
|
||||
|
||||
private static PendingIntent createSentPendingIntent(Context context, String smsId, String smsBatchId) {
|
||||
// Create explicit intent (specify the component)
|
||||
Intent intent = new Intent(context, SMSStatusReceiver.class);
|
||||
intent.setAction(SMSStatusReceiver.SMS_SENT);
|
||||
intent.putExtra("sms_id", smsId);
|
||||
intent.putExtra("sms_batch_id", smsBatchId);
|
||||
|
||||
int flags = PendingIntent.FLAG_UPDATE_CURRENT;
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
flags |= PendingIntent.FLAG_MUTABLE;
|
||||
}
|
||||
|
||||
// Use a unique request code to avoid PendingIntent collisions
|
||||
int requestCode = (smsId + "_sent").hashCode();
|
||||
return PendingIntent.getBroadcast(context, requestCode, intent, flags);
|
||||
}
|
||||
|
||||
private static PendingIntent createDeliveredPendingIntent(Context context, String smsId, String smsBatchId) {
|
||||
// Create explicit intent (specify the component)
|
||||
Intent intent = new Intent(context, SMSStatusReceiver.class);
|
||||
intent.setAction(SMSStatusReceiver.SMS_DELIVERED);
|
||||
intent.putExtra("sms_id", smsId);
|
||||
intent.putExtra("sms_batch_id", smsBatchId);
|
||||
|
||||
int flags = PendingIntent.FLAG_UPDATE_CURRENT;
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
flags |= PendingIntent.FLAG_MUTABLE;
|
||||
}
|
||||
|
||||
// Use a unique request code to avoid PendingIntent collisions
|
||||
int requestCode = (smsId + "_delivered").hashCode();
|
||||
return PendingIntent.getBroadcast(context, requestCode, intent, flags);
|
||||
}
|
||||
}
|
||||
161
android/app/src/main/java/com/vernu/sms/helpers/SMSHelper.kt
Normal file
161
android/app/src/main/java/com/vernu/sms/helpers/SMSHelper.kt
Normal file
@@ -0,0 +1,161 @@
|
||||
package com.vernu.sms.helpers
|
||||
|
||||
import android.Manifest
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.telephony.SmsManager
|
||||
import android.util.Log
|
||||
import com.vernu.sms.AppConstants
|
||||
import com.vernu.sms.TextBeeUtils
|
||||
import com.vernu.sms.dtos.SMSDTO
|
||||
import com.vernu.sms.receivers.SMSStatusReceiver
|
||||
import com.vernu.sms.workers.SMSStatusUpdateWorker
|
||||
|
||||
object SMSHelper {
|
||||
private const val TAG = "SMSHelper"
|
||||
|
||||
@JvmStatic
|
||||
fun sendSMS(
|
||||
phoneNo: String,
|
||||
message: String,
|
||||
smsId: String,
|
||||
smsBatchId: String,
|
||||
context: Context
|
||||
): Boolean {
|
||||
if (!TextBeeUtils.isPermissionGranted(context, Manifest.permission.SEND_SMS)) {
|
||||
Log.e(TAG, "SMS permission not granted. Unable to send SMS.")
|
||||
reportPermissionError(context, smsId, smsBatchId)
|
||||
return false
|
||||
}
|
||||
return try {
|
||||
val smsManager = SmsManager.getDefault()
|
||||
val sentIntent = createSentPendingIntent(context, smsId, smsBatchId)
|
||||
val deliveredIntent = createDeliveredPendingIntent(context, smsId, smsBatchId)
|
||||
val parts = smsManager.divideMessage(message)
|
||||
if (parts.size > 1) {
|
||||
val sentIntents = ArrayList<PendingIntent>(parts.size).also { list ->
|
||||
repeat(parts.size) { list.add(sentIntent) }
|
||||
}
|
||||
val deliveredIntents = ArrayList<PendingIntent>(parts.size).also { list ->
|
||||
repeat(parts.size) { list.add(deliveredIntent) }
|
||||
}
|
||||
smsManager.sendMultipartTextMessage(phoneNo, null, parts, sentIntents, deliveredIntents)
|
||||
} else {
|
||||
smsManager.sendTextMessage(phoneNo, null, message, sentIntent, deliveredIntent)
|
||||
}
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Exception when sending SMS: ${e.message}")
|
||||
reportSendingError(context, smsId, smsBatchId, e.message)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun sendSMSFromSpecificSim(
|
||||
phoneNo: String,
|
||||
message: String,
|
||||
simSubscriptionId: Int,
|
||||
smsId: String,
|
||||
smsBatchId: String,
|
||||
context: Context
|
||||
): Boolean {
|
||||
if (!TextBeeUtils.isPermissionGranted(context, Manifest.permission.SEND_SMS) ||
|
||||
!TextBeeUtils.isPermissionGranted(context, Manifest.permission.READ_PHONE_STATE)
|
||||
) {
|
||||
Log.e(TAG, "SMS or Phone State permission not granted. Unable to send SMS from specific SIM.")
|
||||
reportPermissionError(context, smsId, smsBatchId)
|
||||
return false
|
||||
}
|
||||
return try {
|
||||
@Suppress("DEPRECATION")
|
||||
val smsManager = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1) {
|
||||
SmsManager.getSmsManagerForSubscriptionId(simSubscriptionId)
|
||||
} else {
|
||||
Log.w(TAG, "Using default SIM as specific SIM selection not supported on this Android version")
|
||||
SmsManager.getDefault()
|
||||
}
|
||||
val sentIntent = createSentPendingIntent(context, smsId, smsBatchId)
|
||||
val deliveredIntent = createDeliveredPendingIntent(context, smsId, smsBatchId)
|
||||
val parts = smsManager.divideMessage(message)
|
||||
if (parts.size > 1) {
|
||||
val sentIntents = ArrayList<PendingIntent>(parts.size).also { list ->
|
||||
repeat(parts.size) { list.add(sentIntent) }
|
||||
}
|
||||
val deliveredIntents = ArrayList<PendingIntent>(parts.size).also { list ->
|
||||
repeat(parts.size) { list.add(deliveredIntent) }
|
||||
}
|
||||
smsManager.sendMultipartTextMessage(phoneNo, null, parts, sentIntents, deliveredIntents)
|
||||
} else {
|
||||
smsManager.sendTextMessage(phoneNo, null, message, sentIntent, deliveredIntent)
|
||||
}
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Exception when sending SMS from specific SIM: ${e.message}")
|
||||
reportSendingError(context, smsId, smsBatchId, e.message)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun reportPermissionError(context: Context, smsId: String, smsBatchId: String) {
|
||||
val smsDTO = SMSDTO().apply {
|
||||
this.smsId = smsId
|
||||
this.smsBatchId = smsBatchId
|
||||
status = "FAILED"
|
||||
failedAtInMillis = System.currentTimeMillis()
|
||||
errorCode = "PERMISSION_DENIED"
|
||||
errorMessage = "SMS permission not granted"
|
||||
}
|
||||
updateSMSStatus(context, smsDTO)
|
||||
}
|
||||
|
||||
private fun reportSendingError(context: Context, smsId: String, smsBatchId: String, error: String?) {
|
||||
val smsDTO = SMSDTO().apply {
|
||||
this.smsId = smsId
|
||||
this.smsBatchId = smsBatchId
|
||||
status = "FAILED"
|
||||
failedAtInMillis = System.currentTimeMillis()
|
||||
errorCode = "SENDING_EXCEPTION"
|
||||
errorMessage = error
|
||||
}
|
||||
updateSMSStatus(context, smsDTO)
|
||||
}
|
||||
|
||||
private fun updateSMSStatus(context: Context, smsDTO: SMSDTO) {
|
||||
val deviceId = SharedPreferenceHelper.getSharedPreferenceString(
|
||||
context, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, ""
|
||||
) ?: ""
|
||||
val apiKey = SharedPreferenceHelper.getSharedPreferenceString(
|
||||
context, AppConstants.SHARED_PREFS_API_KEY_KEY, ""
|
||||
) ?: ""
|
||||
if (deviceId.isEmpty() || apiKey.isEmpty()) {
|
||||
Log.e(TAG, "Device ID or API key not found")
|
||||
return
|
||||
}
|
||||
SMSStatusUpdateWorker.enqueueWork(context, deviceId, apiKey, smsDTO)
|
||||
}
|
||||
|
||||
private fun createSentPendingIntent(context: Context, smsId: String, smsBatchId: String): PendingIntent {
|
||||
val intent = Intent(context, SMSStatusReceiver::class.java).apply {
|
||||
action = SMSStatusReceiver.SMS_SENT
|
||||
putExtra("sms_id", smsId)
|
||||
putExtra("sms_batch_id", smsBatchId)
|
||||
}
|
||||
var flags = PendingIntent.FLAG_UPDATE_CURRENT
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) flags = flags or PendingIntent.FLAG_MUTABLE
|
||||
return PendingIntent.getBroadcast(context, (smsId + "_sent").hashCode(), intent, flags)
|
||||
}
|
||||
|
||||
private fun createDeliveredPendingIntent(context: Context, smsId: String, smsBatchId: String): PendingIntent {
|
||||
val intent = Intent(context, SMSStatusReceiver::class.java).apply {
|
||||
action = SMSStatusReceiver.SMS_DELIVERED
|
||||
putExtra("sms_id", smsId)
|
||||
putExtra("sms_batch_id", smsBatchId)
|
||||
}
|
||||
var flags = PendingIntent.FLAG_UPDATE_CURRENT
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) flags = flags or PendingIntent.FLAG_MUTABLE
|
||||
return PendingIntent.getBroadcast(context, (smsId + "_delivered").hashCode(), intent, flags)
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
package com.vernu.sms.helpers;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
|
||||
public class SharedPreferenceHelper {
|
||||
private final static String PREF_FILE = "PREF";
|
||||
|
||||
|
||||
public static void setSharedPreferenceString(Context context, String key, String value) {
|
||||
SharedPreferences settings = context.getSharedPreferences(PREF_FILE, 0);
|
||||
SharedPreferences.Editor editor = settings.edit();
|
||||
editor.putString(key, value);
|
||||
editor.apply();
|
||||
}
|
||||
|
||||
public static void setSharedPreferenceInt(Context context, String key, int value) {
|
||||
SharedPreferences settings = context.getSharedPreferences(PREF_FILE, 0);
|
||||
SharedPreferences.Editor editor = settings.edit();
|
||||
editor.putInt(key, value);
|
||||
editor.apply();
|
||||
}
|
||||
|
||||
public static void setSharedPreferenceBoolean(Context context, String key, boolean value) {
|
||||
SharedPreferences settings = context.getSharedPreferences(PREF_FILE, 0);
|
||||
SharedPreferences.Editor editor = settings.edit();
|
||||
editor.putBoolean(key, value);
|
||||
editor.apply();
|
||||
}
|
||||
|
||||
public static String getSharedPreferenceString(Context context, String key, String defValue) {
|
||||
SharedPreferences settings = context.getSharedPreferences(PREF_FILE, 0);
|
||||
return settings.getString(key, defValue);
|
||||
}
|
||||
|
||||
|
||||
public static int getSharedPreferenceInt(Context context, String key, int defValue) {
|
||||
SharedPreferences settings = context.getSharedPreferences(PREF_FILE, 0);
|
||||
return settings.getInt(key, defValue);
|
||||
}
|
||||
|
||||
|
||||
public static boolean getSharedPreferenceBoolean(Context context, String key, boolean defValue) {
|
||||
SharedPreferences settings = context.getSharedPreferences(PREF_FILE, 0);
|
||||
return settings.getBoolean(key, defValue);
|
||||
}
|
||||
|
||||
public static void clearSharedPreference(Context context, String key) {
|
||||
SharedPreferences settings = context.getSharedPreferences(PREF_FILE, 0);
|
||||
SharedPreferences.Editor editor = settings.edit();
|
||||
editor.remove(key);
|
||||
editor.apply();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.vernu.sms.helpers
|
||||
|
||||
import android.content.Context
|
||||
|
||||
object SharedPreferenceHelper {
|
||||
private const val PREF_FILE = "PREF"
|
||||
|
||||
@JvmStatic
|
||||
fun setSharedPreferenceString(context: Context, key: String, value: String) {
|
||||
context.getSharedPreferences(PREF_FILE, 0).edit().putString(key, value).apply()
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun setSharedPreferenceInt(context: Context, key: String, value: Int) {
|
||||
context.getSharedPreferences(PREF_FILE, 0).edit().putInt(key, value).apply()
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun setSharedPreferenceBoolean(context: Context, key: String, value: Boolean) {
|
||||
context.getSharedPreferences(PREF_FILE, 0).edit().putBoolean(key, value).apply()
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun getSharedPreferenceString(context: Context, key: String, defValue: String?): String? {
|
||||
return context.getSharedPreferences(PREF_FILE, 0).getString(key, defValue)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun getSharedPreferenceInt(context: Context, key: String, defValue: Int): Int {
|
||||
return context.getSharedPreferences(PREF_FILE, 0).getInt(key, defValue)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun getSharedPreferenceBoolean(context: Context, key: String, defValue: Boolean): Boolean {
|
||||
return context.getSharedPreferences(PREF_FILE, 0).getBoolean(key, defValue)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun clearSharedPreference(context: Context, key: String) {
|
||||
context.getSharedPreferences(PREF_FILE, 0).edit().remove(key).apply()
|
||||
}
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
package com.vernu.sms.models;
|
||||
|
||||
public class SMSFilterRule {
|
||||
public enum MatchType {
|
||||
EXACT,
|
||||
STARTS_WITH,
|
||||
ENDS_WITH,
|
||||
CONTAINS
|
||||
}
|
||||
|
||||
public enum FilterTarget {
|
||||
SENDER,
|
||||
MESSAGE,
|
||||
BOTH
|
||||
}
|
||||
|
||||
private String pattern;
|
||||
private MatchType matchType;
|
||||
private FilterTarget filterTarget = FilterTarget.SENDER; // Default to sender for backward compatibility
|
||||
private boolean caseSensitive = false; // Default to case insensitive
|
||||
|
||||
public SMSFilterRule() {
|
||||
}
|
||||
|
||||
public SMSFilterRule(String pattern, MatchType matchType) {
|
||||
this.pattern = pattern;
|
||||
this.matchType = matchType;
|
||||
this.filterTarget = FilterTarget.SENDER;
|
||||
this.caseSensitive = false;
|
||||
}
|
||||
|
||||
public SMSFilterRule(String pattern, MatchType matchType, FilterTarget filterTarget) {
|
||||
this.pattern = pattern;
|
||||
this.matchType = matchType;
|
||||
this.filterTarget = filterTarget;
|
||||
this.caseSensitive = false;
|
||||
}
|
||||
|
||||
public SMSFilterRule(String pattern, MatchType matchType, FilterTarget filterTarget, boolean caseSensitive) {
|
||||
this.pattern = pattern;
|
||||
this.matchType = matchType;
|
||||
this.filterTarget = filterTarget;
|
||||
this.caseSensitive = caseSensitive;
|
||||
}
|
||||
|
||||
public String getPattern() {
|
||||
return pattern;
|
||||
}
|
||||
|
||||
public void setPattern(String pattern) {
|
||||
this.pattern = pattern;
|
||||
}
|
||||
|
||||
public MatchType getMatchType() {
|
||||
return matchType;
|
||||
}
|
||||
|
||||
public void setMatchType(MatchType matchType) {
|
||||
this.matchType = matchType;
|
||||
}
|
||||
|
||||
public FilterTarget getFilterTarget() {
|
||||
return filterTarget;
|
||||
}
|
||||
|
||||
public void setFilterTarget(FilterTarget filterTarget) {
|
||||
this.filterTarget = filterTarget != null ? filterTarget : FilterTarget.SENDER;
|
||||
}
|
||||
|
||||
public boolean isCaseSensitive() {
|
||||
return caseSensitive;
|
||||
}
|
||||
|
||||
public void setCaseSensitive(boolean caseSensitive) {
|
||||
this.caseSensitive = caseSensitive;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a string matches this filter rule based on match type
|
||||
*/
|
||||
private boolean matchesString(String text) {
|
||||
if (pattern == null || text == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
String patternToMatch = pattern;
|
||||
String textToMatch = text;
|
||||
|
||||
// Apply case sensitivity
|
||||
if (!caseSensitive) {
|
||||
patternToMatch = patternToMatch.toLowerCase();
|
||||
textToMatch = textToMatch.toLowerCase();
|
||||
}
|
||||
|
||||
switch (matchType) {
|
||||
case EXACT:
|
||||
return textToMatch.equals(patternToMatch);
|
||||
case STARTS_WITH:
|
||||
return textToMatch.startsWith(patternToMatch);
|
||||
case ENDS_WITH:
|
||||
return textToMatch.endsWith(patternToMatch);
|
||||
case CONTAINS:
|
||||
return textToMatch.contains(patternToMatch);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the given sender and/or message matches this filter rule
|
||||
*/
|
||||
public boolean matches(String sender, String message) {
|
||||
if (pattern == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (filterTarget) {
|
||||
case SENDER:
|
||||
return matchesString(sender);
|
||||
case MESSAGE:
|
||||
return matchesString(message);
|
||||
case BOTH:
|
||||
return matchesString(sender) || matchesString(message);
|
||||
default:
|
||||
return matchesString(sender);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy method for backward compatibility - checks sender only
|
||||
*/
|
||||
public boolean matches(String sender) {
|
||||
return matches(sender, null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.vernu.sms.models
|
||||
|
||||
class SMSFilterRule @JvmOverloads constructor(
|
||||
var pattern: String? = null,
|
||||
var matchType: MatchType? = null,
|
||||
var filterTarget: FilterTarget = FilterTarget.SENDER,
|
||||
@get:JvmName("isCaseSensitive") var caseSensitive: Boolean = false
|
||||
) {
|
||||
enum class MatchType { EXACT, STARTS_WITH, ENDS_WITH, CONTAINS }
|
||||
enum class FilterTarget { SENDER, MESSAGE, BOTH }
|
||||
|
||||
private fun matchesString(text: String?): Boolean {
|
||||
val p = pattern ?: return false
|
||||
val t = text ?: return false
|
||||
val pat = if (caseSensitive) p else p.lowercase()
|
||||
val txt = if (caseSensitive) t else t.lowercase()
|
||||
return when (matchType) {
|
||||
MatchType.EXACT -> txt == pat
|
||||
MatchType.STARTS_WITH -> txt.startsWith(pat)
|
||||
MatchType.ENDS_WITH -> txt.endsWith(pat)
|
||||
MatchType.CONTAINS -> txt.contains(pat)
|
||||
null -> false
|
||||
}
|
||||
}
|
||||
|
||||
fun matches(sender: String?, message: String?): Boolean {
|
||||
if (pattern == null) return false
|
||||
return when (filterTarget) {
|
||||
FilterTarget.SENDER -> matchesString(sender)
|
||||
FilterTarget.MESSAGE -> matchesString(message)
|
||||
FilterTarget.BOTH -> matchesString(sender) || matchesString(message)
|
||||
}
|
||||
}
|
||||
|
||||
fun matches(sender: String?): Boolean = matches(sender, null)
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
package com.vernu.sms.models;
|
||||
|
||||
public class SMSPayload {
|
||||
|
||||
private String[] recipients;
|
||||
private String message;
|
||||
private String smsId;
|
||||
private String smsBatchId;
|
||||
private Integer simSubscriptionId;
|
||||
|
||||
// Legacy fields that are no longer used
|
||||
private String[] receivers;
|
||||
private String smsBody;
|
||||
|
||||
public SMSPayload() {
|
||||
}
|
||||
|
||||
public String[] getRecipients() {
|
||||
return recipients;
|
||||
}
|
||||
|
||||
public void setRecipients(String[] recipients) {
|
||||
this.recipients = recipients;
|
||||
}
|
||||
|
||||
public String getMessage() {
|
||||
return message;
|
||||
}
|
||||
|
||||
public void setMessage(String message) {
|
||||
this.message = message;
|
||||
}
|
||||
|
||||
public String getSmsId() {
|
||||
return smsId;
|
||||
}
|
||||
|
||||
public void setSmsId(String smsId) {
|
||||
this.smsId = smsId;
|
||||
}
|
||||
|
||||
public String getSmsBatchId() {
|
||||
return smsBatchId;
|
||||
}
|
||||
|
||||
public void setSmsBatchId(String smsBatchId) {
|
||||
this.smsBatchId = smsBatchId;
|
||||
}
|
||||
|
||||
public Integer getSimSubscriptionId() {
|
||||
return simSubscriptionId;
|
||||
}
|
||||
|
||||
public void setSimSubscriptionId(Integer simSubscriptionId) {
|
||||
this.simSubscriptionId = simSubscriptionId;
|
||||
}
|
||||
}
|
||||
13
android/app/src/main/java/com/vernu/sms/models/SMSPayload.kt
Normal file
13
android/app/src/main/java/com/vernu/sms/models/SMSPayload.kt
Normal file
@@ -0,0 +1,13 @@
|
||||
package com.vernu.sms.models
|
||||
|
||||
class SMSPayload {
|
||||
var recipients: Array<String>? = null
|
||||
var message: String? = null
|
||||
var smsId: String? = null
|
||||
var smsBatchId: String? = null
|
||||
var simSubscriptionId: Int? = null
|
||||
|
||||
// Legacy fields — no longer actively used but kept for backward compatibility
|
||||
var receivers: Array<String>? = null
|
||||
var smsBody: String? = null
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
package com.vernu.sms.receivers;
|
||||
|
||||
import android.Manifest;
|
||||
import android.content.BroadcastReceiver;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.os.Build;
|
||||
import android.util.Log;
|
||||
|
||||
import com.google.firebase.messaging.FirebaseMessaging;
|
||||
import com.vernu.sms.ApiManager;
|
||||
import com.vernu.sms.AppConstants;
|
||||
import com.vernu.sms.BuildConfig;
|
||||
import com.vernu.sms.TextBeeUtils;
|
||||
import com.vernu.sms.dtos.RegisterDeviceInputDTO;
|
||||
import com.vernu.sms.dtos.RegisterDeviceResponseDTO;
|
||||
import com.vernu.sms.helpers.SharedPreferenceHelper;
|
||||
import com.vernu.sms.helpers.HeartbeatManager;
|
||||
import com.vernu.sms.services.StickyNotificationService;
|
||||
|
||||
import retrofit2.Call;
|
||||
import retrofit2.Callback;
|
||||
import retrofit2.Response;
|
||||
|
||||
public class BootCompletedReceiver extends BroadcastReceiver {
|
||||
private static final String TAG = "BootCompletedReceiver";
|
||||
|
||||
@Override
|
||||
public void onReceive(Context context, Intent intent) {
|
||||
if (Intent.ACTION_BOOT_COMPLETED.equals(intent.getAction())) {
|
||||
boolean stickyNotificationEnabled = SharedPreferenceHelper.getSharedPreferenceBoolean(
|
||||
context,
|
||||
AppConstants.SHARED_PREFS_STICKY_NOTIFICATION_ENABLED_KEY,
|
||||
false
|
||||
);
|
||||
|
||||
if(stickyNotificationEnabled && TextBeeUtils.isPermissionGranted(context, Manifest.permission.RECEIVE_SMS)){
|
||||
Log.i(TAG, "Device booted, starting sticky notification service");
|
||||
TextBeeUtils.startStickyNotificationService(context);
|
||||
}
|
||||
|
||||
// Report device info to server if device is registered
|
||||
String deviceId = SharedPreferenceHelper.getSharedPreferenceString(
|
||||
context,
|
||||
AppConstants.SHARED_PREFS_DEVICE_ID_KEY,
|
||||
""
|
||||
);
|
||||
|
||||
String apiKey = SharedPreferenceHelper.getSharedPreferenceString(
|
||||
context,
|
||||
AppConstants.SHARED_PREFS_API_KEY_KEY,
|
||||
""
|
||||
);
|
||||
|
||||
// Only proceed if both device ID and API key are available
|
||||
if (!deviceId.isEmpty() && !apiKey.isEmpty()) {
|
||||
updateDeviceInfo(context, deviceId, apiKey);
|
||||
|
||||
// Schedule heartbeat if device is enabled
|
||||
boolean deviceEnabled = SharedPreferenceHelper.getSharedPreferenceBoolean(
|
||||
context,
|
||||
AppConstants.SHARED_PREFS_GATEWAY_ENABLED_KEY,
|
||||
false
|
||||
);
|
||||
if (deviceEnabled) {
|
||||
Log.i(TAG, "Device booted, scheduling heartbeat");
|
||||
HeartbeatManager.scheduleHeartbeat(context);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates device information on the server after boot
|
||||
*/
|
||||
private void updateDeviceInfo(Context context, String deviceId, String apiKey) {
|
||||
FirebaseMessaging.getInstance().getToken()
|
||||
.addOnCompleteListener(task -> {
|
||||
if (!task.isSuccessful()) {
|
||||
Log.e(TAG, "Failed to obtain FCM token after boot");
|
||||
return;
|
||||
}
|
||||
|
||||
String token = task.getResult();
|
||||
|
||||
RegisterDeviceInputDTO updateInput = new RegisterDeviceInputDTO();
|
||||
updateInput.setFcmToken(token);
|
||||
updateInput.setAppVersionCode(BuildConfig.VERSION_CODE);
|
||||
updateInput.setAppVersionName(BuildConfig.VERSION_NAME);
|
||||
|
||||
Log.d(TAG, "Updating device info after boot - deviceId: " + deviceId);
|
||||
|
||||
ApiManager.getApiService()
|
||||
.updateDevice(deviceId, apiKey, updateInput)
|
||||
.enqueue(new Callback<RegisterDeviceResponseDTO>() {
|
||||
@Override
|
||||
public void onResponse(Call<RegisterDeviceResponseDTO> call, Response<RegisterDeviceResponseDTO> response) {
|
||||
if (response.isSuccessful()) {
|
||||
Log.d(TAG, "Device info updated successfully after boot");
|
||||
|
||||
// Sync heartbeatIntervalMinutes from server response
|
||||
if (response.body() != null && response.body().data != null) {
|
||||
if (response.body().data.get("heartbeatIntervalMinutes") != null) {
|
||||
Object intervalObj = response.body().data.get("heartbeatIntervalMinutes");
|
||||
if (intervalObj instanceof Number) {
|
||||
int intervalMinutes = ((Number) intervalObj).intValue();
|
||||
SharedPreferenceHelper.setSharedPreferenceInt(context, AppConstants.SHARED_PREFS_HEARTBEAT_INTERVAL_MINUTES_KEY, intervalMinutes);
|
||||
Log.d(TAG, "Synced heartbeat interval from server: " + intervalMinutes + " minutes");
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Log.e(TAG, "Failed to update device info after boot. Response code: " + response.code());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(Call<RegisterDeviceResponseDTO> call, Throwable t) {
|
||||
Log.e(TAG, "Error updating device info after boot: " + t.getMessage());
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package com.vernu.sms.receivers
|
||||
|
||||
import android.Manifest
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.util.Log
|
||||
import com.google.firebase.messaging.FirebaseMessaging
|
||||
import com.vernu.sms.ApiManager
|
||||
import com.vernu.sms.AppConstants
|
||||
import com.vernu.sms.BuildConfig
|
||||
import com.vernu.sms.TextBeeUtils
|
||||
import com.vernu.sms.dtos.RegisterDeviceInputDTO
|
||||
import com.vernu.sms.dtos.RegisterDeviceResponseDTO
|
||||
import com.vernu.sms.helpers.HeartbeatManager
|
||||
import com.vernu.sms.helpers.SharedPreferenceHelper
|
||||
import retrofit2.Call
|
||||
import retrofit2.Callback
|
||||
import retrofit2.Response
|
||||
|
||||
class BootCompletedReceiver : BroadcastReceiver() {
|
||||
companion object {
|
||||
private const val TAG = "BootCompletedReceiver"
|
||||
}
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.action != Intent.ACTION_BOOT_COMPLETED) return
|
||||
|
||||
val stickyNotificationEnabled = SharedPreferenceHelper.getSharedPreferenceBoolean(
|
||||
context, AppConstants.SHARED_PREFS_STICKY_NOTIFICATION_ENABLED_KEY, false
|
||||
)
|
||||
if (stickyNotificationEnabled && TextBeeUtils.isPermissionGranted(context, Manifest.permission.RECEIVE_SMS)) {
|
||||
Log.i(TAG, "Device booted, starting sticky notification service")
|
||||
TextBeeUtils.startStickyNotificationService(context)
|
||||
}
|
||||
|
||||
val deviceId = SharedPreferenceHelper.getSharedPreferenceString(
|
||||
context, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, ""
|
||||
) ?: ""
|
||||
val apiKey = SharedPreferenceHelper.getSharedPreferenceString(
|
||||
context, AppConstants.SHARED_PREFS_API_KEY_KEY, ""
|
||||
) ?: ""
|
||||
|
||||
if (deviceId.isNotEmpty() && apiKey.isNotEmpty()) {
|
||||
updateDeviceInfo(context, deviceId, apiKey)
|
||||
|
||||
val deviceEnabled = SharedPreferenceHelper.getSharedPreferenceBoolean(
|
||||
context, AppConstants.SHARED_PREFS_GATEWAY_ENABLED_KEY, false
|
||||
)
|
||||
if (deviceEnabled) {
|
||||
Log.i(TAG, "Device booted, scheduling heartbeat")
|
||||
HeartbeatManager.scheduleHeartbeat(context)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateDeviceInfo(context: Context, deviceId: String, apiKey: String) {
|
||||
FirebaseMessaging.getInstance().token.addOnCompleteListener { task ->
|
||||
if (!task.isSuccessful) {
|
||||
Log.e(TAG, "Failed to obtain FCM token after boot")
|
||||
return@addOnCompleteListener
|
||||
}
|
||||
|
||||
val input = RegisterDeviceInputDTO().apply {
|
||||
fcmToken = task.result
|
||||
appVersionCode = BuildConfig.VERSION_CODE
|
||||
appVersionName = BuildConfig.VERSION_NAME
|
||||
}
|
||||
|
||||
Log.d(TAG, "Updating device info after boot - deviceId: $deviceId")
|
||||
|
||||
ApiManager.getApiService()
|
||||
.updateDevice(deviceId, apiKey, input)
|
||||
.enqueue(object : Callback<RegisterDeviceResponseDTO> {
|
||||
override fun onResponse(
|
||||
call: Call<RegisterDeviceResponseDTO>,
|
||||
response: Response<RegisterDeviceResponseDTO>
|
||||
) {
|
||||
if (response.isSuccessful) {
|
||||
Log.d(TAG, "Device info updated successfully after boot")
|
||||
val data = response.body()?.data ?: return
|
||||
val intervalObj = data["heartbeatIntervalMinutes"] as? Number ?: return
|
||||
SharedPreferenceHelper.setSharedPreferenceInt(
|
||||
context,
|
||||
AppConstants.SHARED_PREFS_HEARTBEAT_INTERVAL_MINUTES_KEY,
|
||||
intervalObj.toInt()
|
||||
)
|
||||
Log.d(TAG, "Synced heartbeat interval from server: ${intervalObj.toInt()} minutes")
|
||||
} else {
|
||||
Log.e(TAG, "Failed to update device info after boot. Response code: ${response.code()}")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFailure(call: Call<RegisterDeviceResponseDTO>, t: Throwable) {
|
||||
Log.e(TAG, "Error updating device info after boot: ${t.message}")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
package com.vernu.sms.receivers;
|
||||
|
||||
import android.content.BroadcastReceiver;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.provider.Telephony;
|
||||
import android.telephony.SmsMessage;
|
||||
import android.util.Log;
|
||||
import com.vernu.sms.AppConstants;
|
||||
import com.vernu.sms.dtos.SMSDTO;
|
||||
import com.vernu.sms.helpers.SharedPreferenceHelper;
|
||||
import com.vernu.sms.helpers.SMSFilterHelper;
|
||||
import com.vernu.sms.workers.SMSReceivedWorker;
|
||||
|
||||
import java.security.MessageDigest;
|
||||
import java.util.HashSet;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
|
||||
public class SMSBroadcastReceiver extends BroadcastReceiver {
|
||||
private static final String TAG = "SMSBroadcastReceiver";
|
||||
// In-memory cache to prevent rapid duplicate processing (5 seconds TTL)
|
||||
private static final ConcurrentHashMap<String, Long> processedFingerprints = new ConcurrentHashMap<>();
|
||||
private static final long CACHE_TTL_MS = 5000; // 5 seconds
|
||||
|
||||
@Override
|
||||
public void onReceive(Context context, Intent intent) {
|
||||
Log.d(TAG, "onReceive: " + intent.getAction());
|
||||
|
||||
if (!Objects.equals(intent.getAction(), Telephony.Sms.Intents.SMS_RECEIVED_ACTION)) {
|
||||
Log.d(TAG, "Not Valid intent");
|
||||
return;
|
||||
}
|
||||
|
||||
SmsMessage[] messages = Telephony.Sms.Intents.getMessagesFromIntent(intent);
|
||||
if (messages == null) {
|
||||
Log.d(TAG, "No messages found");
|
||||
return;
|
||||
}
|
||||
|
||||
String deviceId = SharedPreferenceHelper.getSharedPreferenceString(context, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, "");
|
||||
String apiKey = SharedPreferenceHelper.getSharedPreferenceString(context, AppConstants.SHARED_PREFS_API_KEY_KEY, "");
|
||||
boolean receiveSMSEnabled = SharedPreferenceHelper.getSharedPreferenceBoolean(context, AppConstants.SHARED_PREFS_RECEIVE_SMS_ENABLED_KEY, false);
|
||||
|
||||
if (deviceId.isEmpty() || apiKey.isEmpty() || !receiveSMSEnabled) {
|
||||
Log.d(TAG, "Device ID or API Key is empty or Receive SMS Feature is disabled");
|
||||
return;
|
||||
}
|
||||
|
||||
// SMS receivedSMS = new SMS();
|
||||
// receivedSMS.setType("RECEIVED");
|
||||
// for (SmsMessage message : messages) {
|
||||
// receivedSMS.setMessage(receivedSMS.getMessage() + message.getMessageBody());
|
||||
// receivedSMS.setSender(message.getOriginatingAddress());
|
||||
// receivedSMS.setReceivedAt(new Date(message.getTimestampMillis()));
|
||||
// }
|
||||
|
||||
SMSDTO receivedSMSDTO = new SMSDTO();
|
||||
|
||||
for (SmsMessage message : messages) {
|
||||
receivedSMSDTO.setMessage(receivedSMSDTO.getMessage() + message.getMessageBody());
|
||||
receivedSMSDTO.setSender(message.getOriginatingAddress());
|
||||
receivedSMSDTO.setReceivedAtInMillis(message.getTimestampMillis());
|
||||
}
|
||||
// receivedSMSDTO.setSender(receivedSMS.getSender());
|
||||
// receivedSMSDTO.setMessage(receivedSMS.getMessage());
|
||||
// receivedSMSDTO.setReceivedAt(receivedSMS.getReceivedAt());
|
||||
|
||||
// Apply SMS filter
|
||||
String sender = receivedSMSDTO.getSender();
|
||||
String message = receivedSMSDTO.getMessage();
|
||||
if (sender != null && !SMSFilterHelper.shouldProcessSMS(sender, message, context)) {
|
||||
Log.d(TAG, "SMS from " + sender + " filtered out by filter rules");
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate fingerprint for deduplication
|
||||
String fingerprint = generateFingerprint(
|
||||
receivedSMSDTO.getSender(),
|
||||
receivedSMSDTO.getMessage(),
|
||||
receivedSMSDTO.getReceivedAtInMillis()
|
||||
);
|
||||
receivedSMSDTO.setFingerprint(fingerprint);
|
||||
|
||||
// Check in-memory cache to prevent rapid duplicate processing
|
||||
long currentTime = System.currentTimeMillis();
|
||||
Long lastProcessedTime = processedFingerprints.get(fingerprint);
|
||||
|
||||
if (lastProcessedTime != null && (currentTime - lastProcessedTime) < CACHE_TTL_MS) {
|
||||
Log.d(TAG, "Duplicate SMS detected in cache, skipping: " + fingerprint);
|
||||
return;
|
||||
}
|
||||
|
||||
// Update cache
|
||||
processedFingerprints.put(fingerprint, currentTime);
|
||||
|
||||
// Clean up old cache entries periodically
|
||||
cleanupCache(currentTime);
|
||||
|
||||
SMSReceivedWorker.enqueueWork(context, deviceId, apiKey, receivedSMSDTO);
|
||||
}
|
||||
|
||||
// private void updateLocalReceivedSMS(SMS localReceivedSMS, Context context) {
|
||||
// Executors.newSingleThreadExecutor().execute(() -> {
|
||||
// AppDatabase appDatabase = AppDatabase.getInstance(context);
|
||||
// appDatabase.localReceivedSMSDao().insertAll(localReceivedSMS);
|
||||
// });
|
||||
// }
|
||||
|
||||
/**
|
||||
* Generate a unique fingerprint for an SMS message based on sender, message content, and timestamp
|
||||
*/
|
||||
private String generateFingerprint(String sender, String message, long timestamp) {
|
||||
try {
|
||||
String data = (sender != null ? sender : "") + "|" +
|
||||
(message != null ? message : "") + "|" +
|
||||
timestamp;
|
||||
|
||||
MessageDigest md = MessageDigest.getInstance("MD5");
|
||||
byte[] hashBytes = md.digest(data.getBytes("UTF-8"));
|
||||
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (byte b : hashBytes) {
|
||||
sb.append(String.format("%02x", b));
|
||||
}
|
||||
return sb.toString();
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error generating fingerprint: " + e.getMessage());
|
||||
// Fallback to simple string concatenation if MD5 fails
|
||||
return (sender != null ? sender : "") + "_" +
|
||||
(message != null ? message : "") + "_" +
|
||||
timestamp;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up old cache entries to prevent memory leaks
|
||||
*/
|
||||
private void cleanupCache(long currentTime) {
|
||||
// Only cleanup occasionally (every 100 entries processed)
|
||||
if (processedFingerprints.size() > 100) {
|
||||
Set<String> keysToRemove = new HashSet<>();
|
||||
for (String key : processedFingerprints.keySet()) {
|
||||
Long timestamp = processedFingerprints.get(key);
|
||||
if (timestamp != null && (currentTime - timestamp) > CACHE_TTL_MS) {
|
||||
keysToRemove.add(key);
|
||||
}
|
||||
}
|
||||
for (String key : keysToRemove) {
|
||||
processedFingerprints.remove(key);
|
||||
}
|
||||
Log.d(TAG, "Cleaned up " + keysToRemove.size() + " expired cache entries");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package com.vernu.sms.receivers
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.provider.Telephony
|
||||
import android.util.Log
|
||||
import com.vernu.sms.AppConstants
|
||||
import com.vernu.sms.dtos.SMSDTO
|
||||
import com.vernu.sms.helpers.SMSFilterHelper
|
||||
import com.vernu.sms.helpers.SharedPreferenceHelper
|
||||
import com.vernu.sms.workers.SMSReceivedWorker
|
||||
import java.security.MessageDigest
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
class SMSBroadcastReceiver : BroadcastReceiver() {
|
||||
companion object {
|
||||
private const val TAG = "SMSBroadcastReceiver"
|
||||
private val processedFingerprints = ConcurrentHashMap<String, Long>()
|
||||
private const val CACHE_TTL_MS = 5000L
|
||||
}
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
Log.d(TAG, "onReceive: ${intent.action}")
|
||||
|
||||
if (intent.action != Telephony.Sms.Intents.SMS_RECEIVED_ACTION) {
|
||||
Log.d(TAG, "Not Valid intent")
|
||||
return
|
||||
}
|
||||
|
||||
val messages = Telephony.Sms.Intents.getMessagesFromIntent(intent) ?: run {
|
||||
Log.d(TAG, "No messages found")
|
||||
return
|
||||
}
|
||||
|
||||
val deviceId = SharedPreferenceHelper.getSharedPreferenceString(
|
||||
context, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, ""
|
||||
) ?: ""
|
||||
val apiKey = SharedPreferenceHelper.getSharedPreferenceString(
|
||||
context, AppConstants.SHARED_PREFS_API_KEY_KEY, ""
|
||||
) ?: ""
|
||||
val receiveSMSEnabled = SharedPreferenceHelper.getSharedPreferenceBoolean(
|
||||
context, AppConstants.SHARED_PREFS_RECEIVE_SMS_ENABLED_KEY, false
|
||||
)
|
||||
|
||||
if (deviceId.isEmpty() || apiKey.isEmpty() || !receiveSMSEnabled) {
|
||||
Log.d(TAG, "Device ID or API Key is empty or Receive SMS Feature is disabled")
|
||||
return
|
||||
}
|
||||
|
||||
val dto = SMSDTO()
|
||||
for (message in messages) {
|
||||
dto.message += message.messageBody ?: ""
|
||||
dto.sender = message.originatingAddress
|
||||
dto.receivedAtInMillis = message.timestampMillis
|
||||
}
|
||||
|
||||
val sender = dto.sender
|
||||
if (sender != null && !SMSFilterHelper.shouldProcessSMS(sender, dto.message, context)) {
|
||||
Log.d(TAG, "SMS from $sender filtered out by filter rules")
|
||||
return
|
||||
}
|
||||
|
||||
val fingerprint = generateFingerprint(dto.sender, dto.message, dto.receivedAtInMillis)
|
||||
dto.fingerprint = fingerprint
|
||||
|
||||
val currentTime = System.currentTimeMillis()
|
||||
val lastProcessedTime = processedFingerprints[fingerprint]
|
||||
if (lastProcessedTime != null && (currentTime - lastProcessedTime) < CACHE_TTL_MS) {
|
||||
Log.d(TAG, "Duplicate SMS detected in cache, skipping: $fingerprint")
|
||||
return
|
||||
}
|
||||
|
||||
processedFingerprints[fingerprint] = currentTime
|
||||
cleanupCache(currentTime)
|
||||
|
||||
SMSReceivedWorker.enqueueWork(context, deviceId, apiKey, dto)
|
||||
}
|
||||
|
||||
private fun generateFingerprint(sender: String?, message: String, timestamp: Long): String {
|
||||
return try {
|
||||
val data = "${sender ?: ""}|$message|$timestamp"
|
||||
val hashBytes = MessageDigest.getInstance("MD5").digest(data.toByteArray(Charsets.UTF_8))
|
||||
hashBytes.joinToString("") { "%02x".format(it) }
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error generating fingerprint: ${e.message}")
|
||||
"${sender ?: ""}_${message}_$timestamp"
|
||||
}
|
||||
}
|
||||
|
||||
private fun cleanupCache(currentTime: Long) {
|
||||
if (processedFingerprints.size > 100) {
|
||||
val keysToRemove = processedFingerprints.entries
|
||||
.filter { (currentTime - it.value) > CACHE_TTL_MS }
|
||||
.map { it.key }
|
||||
keysToRemove.forEach { processedFingerprints.remove(it) }
|
||||
Log.d(TAG, "Cleaned up ${keysToRemove.size} expired cache entries")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
package com.vernu.sms.receivers;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.content.BroadcastReceiver;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.telephony.SmsManager;
|
||||
import android.util.Log;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.Modifier;
|
||||
|
||||
import com.vernu.sms.AppConstants;
|
||||
import com.vernu.sms.dtos.SMSDTO;
|
||||
import com.vernu.sms.helpers.SharedPreferenceHelper;
|
||||
import com.vernu.sms.workers.SMSStatusUpdateWorker;
|
||||
|
||||
|
||||
public class SMSStatusReceiver extends BroadcastReceiver {
|
||||
private static final String TAG = "SMSStatusReceiver";
|
||||
|
||||
public static final String SMS_SENT = "SMS_SENT";
|
||||
public static final String SMS_DELIVERED = "SMS_DELIVERED";
|
||||
|
||||
/**
|
||||
* Resolves a result code to the constant name (e.g. SmsManager.RESULT_ERROR_GENERIC_FAILURE)
|
||||
* via reflection. Returns null if no matching constant is found.
|
||||
*/
|
||||
private static String getResultCodeName(int resultCode) {
|
||||
for (Class<?> clazz : new Class<?>[]{ SmsManager.class, Activity.class }) {
|
||||
try {
|
||||
for (Field field : clazz.getDeclaredFields()) {
|
||||
if (field.getType() != int.class) continue;
|
||||
if (!Modifier.isStatic(field.getModifiers()) || !Modifier.isFinal(field.getModifiers())) continue;
|
||||
if (!field.getName().startsWith("RESULT_")) continue;
|
||||
field.setAccessible(true);
|
||||
if (field.getInt(null) == resultCode) {
|
||||
return clazz.getSimpleName() + "." + field.getName();
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "Reflection failed for " + clazz.getSimpleName() + ": " + e.getMessage());
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onReceive(Context context, Intent intent) {
|
||||
String smsId = intent.getStringExtra("sms_id");
|
||||
String smsBatchId = intent.getStringExtra("sms_batch_id");
|
||||
String action = intent.getAction();
|
||||
|
||||
SMSDTO smsDTO = new SMSDTO();
|
||||
smsDTO.setSmsId(smsId);
|
||||
smsDTO.setSmsBatchId(smsBatchId);
|
||||
|
||||
if (SMS_SENT.equals(action)) {
|
||||
handleSentStatus(context, intent, getResultCode(), smsDTO);
|
||||
} else if (SMS_DELIVERED.equals(action)) {
|
||||
handleDeliveredStatus(context, getResultCode(), smsDTO);
|
||||
}
|
||||
}
|
||||
|
||||
private void handleSentStatus(Context context, Intent intent, int resultCode, SMSDTO smsDTO) {
|
||||
long timestamp = System.currentTimeMillis();
|
||||
String errorMessage = "";
|
||||
|
||||
switch (resultCode) {
|
||||
case Activity.RESULT_OK:
|
||||
smsDTO.setStatus("SENT");
|
||||
smsDTO.setSentAtInMillis(timestamp);
|
||||
Log.d(TAG, "SMS sent successfully - ID: " + smsDTO.getSmsId());
|
||||
break;
|
||||
case SmsManager.RESULT_ERROR_GENERIC_FAILURE:
|
||||
errorMessage = "SMS failed on device. Common causes: no SMS credit on SIM, weak signal, or carrier blocked. Check SIM balance and signal, then try again.";
|
||||
int radioCode = intent.getIntExtra("errorCode", -1);
|
||||
if (radioCode != -1) {
|
||||
errorMessage += " (code " + radioCode + ")";
|
||||
}
|
||||
smsDTO.setStatus("FAILED");
|
||||
smsDTO.setFailedAtInMillis(timestamp);
|
||||
smsDTO.setErrorCode(String.valueOf(resultCode));
|
||||
smsDTO.setErrorMessage(errorMessage);
|
||||
Log.e(TAG, "SMS failed to send - ID: " + smsDTO.getSmsId() + ", Error code: " + resultCode + ", Error: " + errorMessage);
|
||||
break;
|
||||
case SmsManager.RESULT_ERROR_RADIO_OFF:
|
||||
errorMessage = "Mobile radio is off (e.g. airplane mode). Turn off airplane mode and ensure cellular is on.";
|
||||
smsDTO.setStatus("FAILED");
|
||||
smsDTO.setFailedAtInMillis(timestamp);
|
||||
smsDTO.setErrorCode(String.valueOf(resultCode));
|
||||
smsDTO.setErrorMessage(errorMessage);
|
||||
Log.e(TAG, "SMS failed to send - ID: " + smsDTO.getSmsId() + ", Error code: " + resultCode + ", Error: " + errorMessage);
|
||||
break;
|
||||
case SmsManager.RESULT_ERROR_NULL_PDU:
|
||||
errorMessage = "Message could not be sent; invalid format or carrier issue. Try a shorter message or different recipient.";
|
||||
smsDTO.setStatus("FAILED");
|
||||
smsDTO.setFailedAtInMillis(timestamp);
|
||||
smsDTO.setErrorCode(String.valueOf(resultCode));
|
||||
smsDTO.setErrorMessage(errorMessage);
|
||||
Log.e(TAG, "SMS failed to send - ID: " + smsDTO.getSmsId() + ", Error code: " + resultCode + ", Error: " + errorMessage);
|
||||
break;
|
||||
case SmsManager.RESULT_ERROR_NO_SERVICE:
|
||||
errorMessage = "No cellular service. Check signal and try again when you have coverage.";
|
||||
smsDTO.setStatus("FAILED");
|
||||
smsDTO.setFailedAtInMillis(timestamp);
|
||||
smsDTO.setErrorCode(String.valueOf(resultCode));
|
||||
smsDTO.setErrorMessage(errorMessage);
|
||||
Log.e(TAG, "SMS failed to send - ID: " + smsDTO.getSmsId() + ", Error code: " + resultCode + ", Error: " + errorMessage);
|
||||
break;
|
||||
case SmsManager.RESULT_ERROR_LIMIT_EXCEEDED:
|
||||
errorMessage = "Device/carrier send limit reached (too many SMS in a short time). Wait a few minutes or lower the send rate.";
|
||||
smsDTO.setStatus("FAILED");
|
||||
smsDTO.setFailedAtInMillis(timestamp);
|
||||
smsDTO.setErrorCode(String.valueOf(resultCode));
|
||||
smsDTO.setErrorMessage(errorMessage);
|
||||
Log.e(TAG, "SMS failed to send - ID: " + smsDTO.getSmsId() + ", Error code: " + resultCode + ", Error: " + errorMessage);
|
||||
break;
|
||||
case SmsManager.RESULT_ERROR_SHORT_CODE_NOT_ALLOWED:
|
||||
errorMessage = "Short code not allowed on this carrier. Use a full phone number.";
|
||||
smsDTO.setStatus("FAILED");
|
||||
smsDTO.setFailedAtInMillis(timestamp);
|
||||
smsDTO.setErrorCode(String.valueOf(resultCode));
|
||||
smsDTO.setErrorMessage(errorMessage);
|
||||
Log.e(TAG, "SMS failed to send - ID: " + smsDTO.getSmsId() + ", Error code: " + resultCode + ", Error: " + errorMessage);
|
||||
break;
|
||||
case SmsManager.RESULT_ERROR_SHORT_CODE_NEVER_ALLOWED:
|
||||
errorMessage = "Short codes are not supported on this carrier. Use a full phone number.";
|
||||
smsDTO.setStatus("FAILED");
|
||||
smsDTO.setFailedAtInMillis(timestamp);
|
||||
smsDTO.setErrorCode(String.valueOf(resultCode));
|
||||
smsDTO.setErrorMessage(errorMessage);
|
||||
Log.e(TAG, "SMS failed to send - ID: " + smsDTO.getSmsId() + ", Error code: " + resultCode + ", Error: " + errorMessage);
|
||||
break;
|
||||
case SmsManager.RESULT_NETWORK_ERROR:
|
||||
errorMessage = "Network error while sending. Check signal and try again.";
|
||||
smsDTO.setStatus("FAILED");
|
||||
smsDTO.setFailedAtInMillis(timestamp);
|
||||
smsDTO.setErrorCode(String.valueOf(resultCode));
|
||||
smsDTO.setErrorMessage(errorMessage);
|
||||
Log.e(TAG, "SMS failed to send - ID: " + smsDTO.getSmsId() + ", Error code: " + resultCode + ", Error: " + errorMessage);
|
||||
break;
|
||||
default:
|
||||
String codeName = getResultCodeName(resultCode);
|
||||
errorMessage = codeName != null ? codeName : ("Unknown error (code " + resultCode + ")");
|
||||
smsDTO.setStatus("FAILED");
|
||||
smsDTO.setFailedAtInMillis(timestamp);
|
||||
smsDTO.setErrorCode(String.valueOf(resultCode));
|
||||
smsDTO.setErrorMessage(errorMessage);
|
||||
Log.e(TAG, "SMS failed to send - ID: " + smsDTO.getSmsId() + ", Error: " + errorMessage);
|
||||
break;
|
||||
}
|
||||
|
||||
updateSMSStatus(context, smsDTO);
|
||||
}
|
||||
|
||||
private void handleDeliveredStatus(Context context, int resultCode, SMSDTO smsDTO) {
|
||||
long timestamp = System.currentTimeMillis();
|
||||
String errorMessage = "";
|
||||
|
||||
switch (resultCode) {
|
||||
case Activity.RESULT_OK:
|
||||
smsDTO.setStatus("DELIVERED");
|
||||
smsDTO.setDeliveredAtInMillis(timestamp);
|
||||
Log.d(TAG, "SMS delivered successfully - ID: " + smsDTO.getSmsId());
|
||||
break;
|
||||
case Activity.RESULT_CANCELED:
|
||||
errorMessage = "Delivery report was canceled (e.g. carrier does not support delivery receipts). Message may still have been delivered.";
|
||||
smsDTO.setStatus("DELIVERY_FAILED");
|
||||
smsDTO.setErrorCode(String.valueOf(resultCode));
|
||||
smsDTO.setErrorMessage(errorMessage);
|
||||
Log.e(TAG, "SMS delivery failed - ID: " + smsDTO.getSmsId() + ", Error code: " + resultCode + ", Error: " + errorMessage);
|
||||
break;
|
||||
default:
|
||||
String deliveryCodeName = getResultCodeName(resultCode);
|
||||
errorMessage = deliveryCodeName != null ? deliveryCodeName : ("Unknown delivery error (code " + resultCode + ")");
|
||||
smsDTO.setStatus("DELIVERY_FAILED");
|
||||
smsDTO.setErrorCode(String.valueOf(resultCode));
|
||||
smsDTO.setErrorMessage(errorMessage);
|
||||
Log.e(TAG, "SMS delivery failed - ID: " + smsDTO.getSmsId() + ", Error: " + errorMessage);
|
||||
break;
|
||||
}
|
||||
|
||||
updateSMSStatus(context, smsDTO);
|
||||
}
|
||||
|
||||
private void updateSMSStatus(Context context, SMSDTO smsDTO) {
|
||||
String deviceId = SharedPreferenceHelper.getSharedPreferenceString(context, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, "");
|
||||
String apiKey = SharedPreferenceHelper.getSharedPreferenceString(context, AppConstants.SHARED_PREFS_API_KEY_KEY, "");
|
||||
|
||||
if (deviceId.isEmpty() || apiKey.isEmpty()) {
|
||||
Log.e(TAG, "Device ID or API key not found");
|
||||
return;
|
||||
}
|
||||
|
||||
SMSStatusUpdateWorker.enqueueWork(context, deviceId, apiKey, smsDTO);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package com.vernu.sms.receivers
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.telephony.SmsManager
|
||||
import android.util.Log
|
||||
import com.vernu.sms.AppConstants
|
||||
import com.vernu.sms.dtos.SMSDTO
|
||||
import com.vernu.sms.helpers.SharedPreferenceHelper
|
||||
import com.vernu.sms.workers.SMSStatusUpdateWorker
|
||||
import java.lang.reflect.Modifier
|
||||
|
||||
class SMSStatusReceiver : BroadcastReceiver() {
|
||||
companion object {
|
||||
private const val TAG = "SMSStatusReceiver"
|
||||
const val SMS_SENT = "SMS_SENT"
|
||||
const val SMS_DELIVERED = "SMS_DELIVERED"
|
||||
|
||||
private fun getResultCodeName(resultCode: Int): String? {
|
||||
for (clazz in arrayOf<Class<*>>(SmsManager::class.java, Activity::class.java)) {
|
||||
try {
|
||||
for (field in clazz.declaredFields) {
|
||||
if (field.type != Int::class.javaPrimitiveType) continue
|
||||
if (!Modifier.isStatic(field.modifiers) || !Modifier.isFinal(field.modifiers)) continue
|
||||
if (!field.name.startsWith("RESULT_")) continue
|
||||
field.isAccessible = true
|
||||
if (field.getInt(null) == resultCode) return "${clazz.simpleName}.${field.name}"
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Reflection failed for ${clazz.simpleName}: ${e.message}")
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
val smsId = intent.getStringExtra("sms_id")
|
||||
val smsBatchId = intent.getStringExtra("sms_batch_id")
|
||||
|
||||
val smsDTO = SMSDTO().apply {
|
||||
this.smsId = smsId
|
||||
this.smsBatchId = smsBatchId
|
||||
}
|
||||
|
||||
when (intent.action) {
|
||||
SMS_SENT -> handleSentStatus(context, intent, resultCode, smsDTO)
|
||||
SMS_DELIVERED -> handleDeliveredStatus(context, resultCode, smsDTO)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleSentStatus(context: Context, intent: Intent, resultCode: Int, smsDTO: SMSDTO) {
|
||||
val timestamp = System.currentTimeMillis()
|
||||
when (resultCode) {
|
||||
Activity.RESULT_OK -> {
|
||||
smsDTO.status = "SENT"
|
||||
smsDTO.sentAtInMillis = timestamp
|
||||
Log.d(TAG, "SMS sent successfully - ID: ${smsDTO.smsId}")
|
||||
}
|
||||
SmsManager.RESULT_ERROR_GENERIC_FAILURE -> {
|
||||
val radioCode = intent.getIntExtra("errorCode", -1)
|
||||
var msg = "SMS failed on device. Common causes: no SMS credit on SIM, weak signal, or carrier blocked. Check SIM balance and signal, then try again."
|
||||
if (radioCode != -1) msg += " (code $radioCode)"
|
||||
setFailed(smsDTO, timestamp, resultCode, msg)
|
||||
Log.e(TAG, "SMS failed to send - ID: ${smsDTO.smsId}, Error code: $resultCode, Error: $msg")
|
||||
}
|
||||
SmsManager.RESULT_ERROR_RADIO_OFF -> setFailed(smsDTO, timestamp, resultCode,
|
||||
"Mobile radio is off (e.g. airplane mode). Turn off airplane mode and ensure cellular is on.")
|
||||
SmsManager.RESULT_ERROR_NULL_PDU -> setFailed(smsDTO, timestamp, resultCode,
|
||||
"Message could not be sent; invalid format or carrier issue. Try a shorter message or different recipient.")
|
||||
SmsManager.RESULT_ERROR_NO_SERVICE -> setFailed(smsDTO, timestamp, resultCode,
|
||||
"No cellular service. Check signal and try again when you have coverage.")
|
||||
SmsManager.RESULT_ERROR_LIMIT_EXCEEDED -> setFailed(smsDTO, timestamp, resultCode,
|
||||
"Device/carrier send limit reached (too many SMS in a short time). Wait a few minutes or lower the send rate.")
|
||||
SmsManager.RESULT_ERROR_SHORT_CODE_NOT_ALLOWED -> setFailed(smsDTO, timestamp, resultCode,
|
||||
"Short code not allowed on this carrier. Use a full phone number.")
|
||||
SmsManager.RESULT_ERROR_SHORT_CODE_NEVER_ALLOWED -> setFailed(smsDTO, timestamp, resultCode,
|
||||
"Short codes are not supported on this carrier. Use a full phone number.")
|
||||
SmsManager.RESULT_NETWORK_ERROR -> setFailed(smsDTO, timestamp, resultCode,
|
||||
"Network error while sending. Check signal and try again.")
|
||||
else -> {
|
||||
val msg = getResultCodeName(resultCode) ?: "Unknown error (code $resultCode)"
|
||||
setFailed(smsDTO, timestamp, resultCode, msg)
|
||||
Log.e(TAG, "SMS failed to send - ID: ${smsDTO.smsId}, Error: $msg")
|
||||
}
|
||||
}
|
||||
updateSMSStatus(context, smsDTO)
|
||||
}
|
||||
|
||||
private fun handleDeliveredStatus(context: Context, resultCode: Int, smsDTO: SMSDTO) {
|
||||
val timestamp = System.currentTimeMillis()
|
||||
when (resultCode) {
|
||||
Activity.RESULT_OK -> {
|
||||
smsDTO.status = "DELIVERED"
|
||||
smsDTO.deliveredAtInMillis = timestamp
|
||||
Log.d(TAG, "SMS delivered successfully - ID: ${smsDTO.smsId}")
|
||||
}
|
||||
Activity.RESULT_CANCELED -> {
|
||||
val msg = "Delivery report was canceled (e.g. carrier does not support delivery receipts). Message may still have been delivered."
|
||||
smsDTO.status = "DELIVERY_FAILED"
|
||||
smsDTO.errorCode = resultCode.toString()
|
||||
smsDTO.errorMessage = msg
|
||||
Log.e(TAG, "SMS delivery failed - ID: ${smsDTO.smsId}, Error: $msg")
|
||||
}
|
||||
else -> {
|
||||
val msg = getResultCodeName(resultCode) ?: "Unknown delivery error (code $resultCode)"
|
||||
smsDTO.status = "DELIVERY_FAILED"
|
||||
smsDTO.errorCode = resultCode.toString()
|
||||
smsDTO.errorMessage = msg
|
||||
Log.e(TAG, "SMS delivery failed - ID: ${smsDTO.smsId}, Error: $msg")
|
||||
}
|
||||
}
|
||||
updateSMSStatus(context, smsDTO)
|
||||
}
|
||||
|
||||
private fun setFailed(smsDTO: SMSDTO, timestamp: Long, resultCode: Int, msg: String) {
|
||||
smsDTO.status = "FAILED"
|
||||
smsDTO.failedAtInMillis = timestamp
|
||||
smsDTO.errorCode = resultCode.toString()
|
||||
smsDTO.errorMessage = msg
|
||||
}
|
||||
|
||||
private fun updateSMSStatus(context: Context, smsDTO: SMSDTO) {
|
||||
val deviceId = SharedPreferenceHelper.getSharedPreferenceString(
|
||||
context, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, ""
|
||||
) ?: ""
|
||||
val apiKey = SharedPreferenceHelper.getSharedPreferenceString(
|
||||
context, AppConstants.SHARED_PREFS_API_KEY_KEY, ""
|
||||
) ?: ""
|
||||
if (deviceId.isEmpty() || apiKey.isEmpty()) {
|
||||
Log.e(TAG, "Device ID or API key not found")
|
||||
return
|
||||
}
|
||||
SMSStatusUpdateWorker.enqueueWork(context, deviceId, apiKey, smsDTO)
|
||||
}
|
||||
}
|
||||
@@ -1,204 +0,0 @@
|
||||
package com.vernu.sms.services;
|
||||
|
||||
import android.app.NotificationChannel;
|
||||
import android.app.NotificationManager;
|
||||
import android.app.PendingIntent;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.media.RingtoneManager;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.util.Log;
|
||||
import androidx.core.app.NotificationCompat;
|
||||
import com.google.firebase.messaging.FirebaseMessagingService;
|
||||
import com.google.firebase.messaging.RemoteMessage;
|
||||
import com.google.gson.Gson;
|
||||
import com.vernu.sms.AppConstants;
|
||||
import com.vernu.sms.R;
|
||||
import com.vernu.sms.activities.MainActivity;
|
||||
import com.vernu.sms.helpers.SharedPreferenceHelper;
|
||||
import com.vernu.sms.helpers.HeartbeatHelper;
|
||||
import com.vernu.sms.helpers.HeartbeatManager;
|
||||
import com.vernu.sms.models.SMSPayload;
|
||||
import com.vernu.sms.workers.SmsSendWorker;
|
||||
import com.vernu.sms.dtos.RegisterDeviceInputDTO;
|
||||
import com.vernu.sms.dtos.RegisterDeviceResponseDTO;
|
||||
import com.vernu.sms.ApiManager;
|
||||
import retrofit2.Call;
|
||||
import retrofit2.Callback;
|
||||
import retrofit2.Response;
|
||||
|
||||
public class FCMService extends FirebaseMessagingService {
|
||||
|
||||
private static final String TAG = "FirebaseMessagingService";
|
||||
private static final String DEFAULT_NOTIFICATION_CHANNEL_ID = "N1";
|
||||
|
||||
@Override
|
||||
public void onMessageReceived(RemoteMessage remoteMessage) {
|
||||
Log.d(TAG, remoteMessage.getData().toString());
|
||||
|
||||
try {
|
||||
// Check message type first
|
||||
String messageType = remoteMessage.getData().get("type");
|
||||
|
||||
if ("heartbeat_check".equals(messageType)) {
|
||||
// Handle heartbeat check request from backend
|
||||
handleHeartbeatCheck();
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse SMS payload data (legacy handling)
|
||||
Gson gson = new Gson();
|
||||
SMSPayload smsPayload = gson.fromJson(remoteMessage.getData().get("smsData"), SMSPayload.class);
|
||||
|
||||
// Check if message contains a data payload
|
||||
if (remoteMessage.getData().size() > 0) {
|
||||
sendSMS(smsPayload);
|
||||
}
|
||||
|
||||
// Handle any notification message
|
||||
if (remoteMessage.getNotification() != null) {
|
||||
// sendNotification("notif msg", "msg body");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Error processing FCM message: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle heartbeat check request from backend
|
||||
*/
|
||||
private void handleHeartbeatCheck() {
|
||||
Log.d(TAG, "Received heartbeat check request from backend");
|
||||
|
||||
// Check if device is eligible for heartbeat
|
||||
if (!HeartbeatHelper.isDeviceEligibleForHeartbeat(this)) {
|
||||
Log.d(TAG, "Device not eligible for heartbeat, skipping heartbeat check");
|
||||
return;
|
||||
}
|
||||
|
||||
// Get device ID and API key
|
||||
String deviceId = SharedPreferenceHelper.getSharedPreferenceString(
|
||||
this,
|
||||
AppConstants.SHARED_PREFS_DEVICE_ID_KEY,
|
||||
""
|
||||
);
|
||||
|
||||
String apiKey = SharedPreferenceHelper.getSharedPreferenceString(
|
||||
this,
|
||||
AppConstants.SHARED_PREFS_API_KEY_KEY,
|
||||
""
|
||||
);
|
||||
|
||||
// Send heartbeat using shared helper
|
||||
boolean success = HeartbeatHelper.sendHeartbeat(this, deviceId, apiKey);
|
||||
|
||||
if (success) {
|
||||
Log.d(TAG, "Heartbeat sent successfully in response to backend check");
|
||||
// Ensure scheduled work is added if missing
|
||||
HeartbeatManager.scheduleHeartbeat(this);
|
||||
} else {
|
||||
Log.e(TAG, "Failed to send heartbeat in response to backend check");
|
||||
// Still try to ensure scheduled work is added
|
||||
HeartbeatManager.scheduleHeartbeat(this);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueue SMS to recipients via the device-side send queue.
|
||||
* SIM resolution and rate limiting are handled by SmsSendWorker.
|
||||
*/
|
||||
private void sendSMS(SMSPayload smsPayload) {
|
||||
if (smsPayload == null) {
|
||||
Log.e(TAG, "SMS payload is null");
|
||||
return;
|
||||
}
|
||||
|
||||
String[] recipients = smsPayload.getRecipients();
|
||||
if (recipients == null || recipients.length == 0) {
|
||||
Log.e(TAG, "No recipients found in SMS payload");
|
||||
return;
|
||||
}
|
||||
|
||||
for (String recipient : recipients) {
|
||||
SmsSendWorker.enqueue(this, recipient, smsPayload.getMessage(),
|
||||
smsPayload.getSmsId(), smsPayload.getSmsBatchId(),
|
||||
smsPayload.getSimSubscriptionId());
|
||||
}
|
||||
|
||||
Log.d(TAG, "Enqueued " + recipients.length + " SMS for sending - Batch: " + smsPayload.getSmsBatchId());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onNewToken(String token) {
|
||||
sendRegistrationToServer(token);
|
||||
}
|
||||
|
||||
private void sendRegistrationToServer(String token) {
|
||||
// Check if device ID and API key are saved in shared preferences
|
||||
String deviceId = SharedPreferenceHelper.getSharedPreferenceString(this, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, "");
|
||||
String apiKey = SharedPreferenceHelper.getSharedPreferenceString(this, AppConstants.SHARED_PREFS_API_KEY_KEY, "");
|
||||
|
||||
// Only proceed if both device ID and API key are available
|
||||
if (deviceId.isEmpty() || apiKey.isEmpty()) {
|
||||
Log.d(TAG, "Device ID or API key not available, skipping FCM token update");
|
||||
return;
|
||||
}
|
||||
|
||||
// Create update payload with new FCM token
|
||||
RegisterDeviceInputDTO updateInput = new RegisterDeviceInputDTO();
|
||||
updateInput.setFcmToken(token);
|
||||
|
||||
// Call API to update the device with new token
|
||||
Log.d(TAG, "Updating FCM token for device: " + deviceId);
|
||||
ApiManager.getApiService()
|
||||
.updateDevice(deviceId, apiKey, updateInput)
|
||||
.enqueue(new Callback<RegisterDeviceResponseDTO>() {
|
||||
@Override
|
||||
public void onResponse(Call<RegisterDeviceResponseDTO> call, Response<RegisterDeviceResponseDTO> response) {
|
||||
if (response.isSuccessful()) {
|
||||
Log.d(TAG, "FCM token updated successfully");
|
||||
} else {
|
||||
Log.e(TAG, "Failed to update FCM token. Response code: " + response.code());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(Call<RegisterDeviceResponseDTO> call, Throwable t) {
|
||||
Log.e(TAG, "Error updating FCM token: " + t.getMessage());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/* build and show notification */
|
||||
private void sendNotification(String title, String messageBody) {
|
||||
Intent intent = new Intent(this, MainActivity.class);
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
|
||||
PendingIntent pendingIntent = PendingIntent.getActivity(this, 0 /* Request code */, intent,
|
||||
PendingIntent.FLAG_ONE_SHOT);
|
||||
|
||||
String channelId = DEFAULT_NOTIFICATION_CHANNEL_ID;
|
||||
Uri defaultSoundUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION);
|
||||
NotificationCompat.Builder notificationBuilder =
|
||||
new NotificationCompat.Builder(this, DEFAULT_NOTIFICATION_CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_launcher_foreground)
|
||||
.setContentTitle(title)
|
||||
.setContentText(messageBody)
|
||||
.setAutoCancel(true)
|
||||
.setSound(defaultSoundUri)
|
||||
.setContentIntent(pendingIntent);
|
||||
|
||||
NotificationManager notificationManager =
|
||||
(NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
|
||||
|
||||
// Since android Oreo notification channel is needed.
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
NotificationChannel channel = new NotificationChannel(channelId,
|
||||
"Channel human readable title",
|
||||
NotificationManager.IMPORTANCE_DEFAULT);
|
||||
notificationManager.createNotificationChannel(channel);
|
||||
}
|
||||
|
||||
notificationManager.notify(0 /* ID of notification */, notificationBuilder.build());
|
||||
}
|
||||
}
|
||||
173
android/app/src/main/java/com/vernu/sms/services/FCMService.kt
Normal file
173
android/app/src/main/java/com/vernu/sms/services/FCMService.kt
Normal file
@@ -0,0 +1,173 @@
|
||||
package com.vernu.sms.services
|
||||
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.media.RingtoneManager
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import com.google.firebase.messaging.FirebaseMessagingService
|
||||
import com.google.firebase.messaging.RemoteMessage
|
||||
import com.google.gson.Gson
|
||||
import com.vernu.sms.ApiManager
|
||||
import com.vernu.sms.AppConstants
|
||||
import com.vernu.sms.R
|
||||
import com.vernu.sms.activities.MainActivity
|
||||
import com.vernu.sms.dtos.RegisterDeviceInputDTO
|
||||
import com.vernu.sms.dtos.RegisterDeviceResponseDTO
|
||||
import com.vernu.sms.helpers.HeartbeatHelper
|
||||
import com.vernu.sms.helpers.HeartbeatManager
|
||||
import com.vernu.sms.helpers.SharedPreferenceHelper
|
||||
import com.vernu.sms.models.SMSPayload
|
||||
import com.vernu.sms.workers.SmsSendWorker
|
||||
import retrofit2.Call
|
||||
import retrofit2.Callback
|
||||
import retrofit2.Response
|
||||
|
||||
class FCMService : FirebaseMessagingService() {
|
||||
companion object {
|
||||
private const val TAG = "FirebaseMessagingService"
|
||||
private const val DEFAULT_NOTIFICATION_CHANNEL_ID = "N1"
|
||||
}
|
||||
|
||||
override fun onMessageReceived(remoteMessage: RemoteMessage) {
|
||||
Log.d(TAG, remoteMessage.data.toString())
|
||||
|
||||
try {
|
||||
val messageType = remoteMessage.data["type"]
|
||||
if (messageType == "heartbeat_check") {
|
||||
handleHeartbeatCheck()
|
||||
return
|
||||
}
|
||||
|
||||
val smsPayload = Gson().fromJson(remoteMessage.data["smsData"], SMSPayload::class.java)
|
||||
|
||||
if (remoteMessage.data.isNotEmpty()) {
|
||||
sendSMS(smsPayload)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error processing FCM message: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleHeartbeatCheck() {
|
||||
Log.d(TAG, "Received heartbeat check request from backend")
|
||||
|
||||
if (!HeartbeatHelper.isDeviceEligibleForHeartbeat(this)) {
|
||||
Log.d(TAG, "Device not eligible for heartbeat, skipping heartbeat check")
|
||||
return
|
||||
}
|
||||
|
||||
val deviceId = SharedPreferenceHelper.getSharedPreferenceString(
|
||||
this, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, ""
|
||||
) ?: ""
|
||||
val apiKey = SharedPreferenceHelper.getSharedPreferenceString(
|
||||
this, AppConstants.SHARED_PREFS_API_KEY_KEY, ""
|
||||
) ?: ""
|
||||
|
||||
val success = HeartbeatHelper.sendHeartbeat(this, deviceId, apiKey)
|
||||
if (success) {
|
||||
Log.d(TAG, "Heartbeat sent successfully in response to backend check")
|
||||
} else {
|
||||
Log.e(TAG, "Failed to send heartbeat in response to backend check")
|
||||
}
|
||||
HeartbeatManager.scheduleHeartbeat(this)
|
||||
}
|
||||
|
||||
private fun sendSMS(smsPayload: SMSPayload?) {
|
||||
if (smsPayload == null) {
|
||||
Log.e(TAG, "SMS payload is null")
|
||||
return
|
||||
}
|
||||
|
||||
val recipients = smsPayload.recipients
|
||||
if (recipients == null || recipients.isEmpty()) {
|
||||
Log.e(TAG, "No recipients found in SMS payload")
|
||||
return
|
||||
}
|
||||
|
||||
for (recipient in recipients) {
|
||||
SmsSendWorker.enqueue(
|
||||
this, recipient, smsPayload.message ?: "",
|
||||
smsPayload.smsId, smsPayload.smsBatchId, smsPayload.simSubscriptionId
|
||||
)
|
||||
}
|
||||
|
||||
Log.d(TAG, "Enqueued ${recipients.size} SMS for sending - Batch: ${smsPayload.smsBatchId}")
|
||||
}
|
||||
|
||||
override fun onNewToken(token: String) {
|
||||
sendRegistrationToServer(token)
|
||||
}
|
||||
|
||||
private fun sendRegistrationToServer(token: String) {
|
||||
val deviceId = SharedPreferenceHelper.getSharedPreferenceString(
|
||||
this, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, ""
|
||||
) ?: ""
|
||||
val apiKey = SharedPreferenceHelper.getSharedPreferenceString(
|
||||
this, AppConstants.SHARED_PREFS_API_KEY_KEY, ""
|
||||
) ?: ""
|
||||
|
||||
if (deviceId.isEmpty() || apiKey.isEmpty()) {
|
||||
Log.d(TAG, "Device ID or API key not available, skipping FCM token update")
|
||||
return
|
||||
}
|
||||
|
||||
val updateInput = RegisterDeviceInputDTO().apply { fcmToken = token }
|
||||
Log.d(TAG, "Updating FCM token for device: $deviceId")
|
||||
|
||||
ApiManager.getApiService()
|
||||
.updateDevice(deviceId, apiKey, updateInput)
|
||||
.enqueue(object : Callback<RegisterDeviceResponseDTO> {
|
||||
override fun onResponse(
|
||||
call: Call<RegisterDeviceResponseDTO>,
|
||||
response: Response<RegisterDeviceResponseDTO>
|
||||
) {
|
||||
if (response.isSuccessful) {
|
||||
Log.d(TAG, "FCM token updated successfully")
|
||||
} else {
|
||||
Log.e(TAG, "Failed to update FCM token. Response code: ${response.code()}")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFailure(call: Call<RegisterDeviceResponseDTO>, t: Throwable) {
|
||||
Log.e(TAG, "Error updating FCM token: ${t.message}")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun sendNotification(title: String, messageBody: String) {
|
||||
val intent = Intent(this, MainActivity::class.java).apply {
|
||||
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
||||
}
|
||||
val pendingIntent = PendingIntent.getActivity(
|
||||
this, 0, intent, PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
val defaultSoundUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION)
|
||||
val notificationBuilder = NotificationCompat.Builder(this, DEFAULT_NOTIFICATION_CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_launcher_foreground)
|
||||
.setContentTitle(title)
|
||||
.setContentText(messageBody)
|
||||
.setAutoCancel(true)
|
||||
.setSound(defaultSoundUri)
|
||||
.setContentIntent(pendingIntent)
|
||||
|
||||
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
notificationManager.createNotificationChannel(
|
||||
NotificationChannel(
|
||||
DEFAULT_NOTIFICATION_CHANNEL_ID,
|
||||
"Channel human readable title",
|
||||
NotificationManager.IMPORTANCE_DEFAULT
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
notificationManager.notify(0, notificationBuilder.build())
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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() })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
""
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
package com.vernu.sms.ui.settings
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vernu.sms.helpers.SMSFilterHelper
|
||||
import com.vernu.sms.models.SMSFilterRule
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SMSFilterScreen(
|
||||
onNavigateBack: () -> Unit,
|
||||
viewModel: SMSFilterViewModel = viewModel()
|
||||
) {
|
||||
val config by viewModel.config.collectAsState()
|
||||
var showDialog by remember { mutableStateOf(false) }
|
||||
var editingIndex by remember { mutableStateOf<Int?>(null) }
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("SMS Filters", fontWeight = FontWeight.SemiBold) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onNavigateBack) {
|
||||
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
)
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
FloatingActionButton(onClick = {
|
||||
editingIndex = null
|
||||
showDialog = true
|
||||
}) {
|
||||
Icon(Icons.Default.Add, contentDescription = "Add rule")
|
||||
}
|
||||
}
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text("Enable SMS Filtering", style = MaterialTheme.typography.bodyLarge)
|
||||
Text(
|
||||
"Filter incoming SMS based on rules below",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = config.enabled,
|
||||
onCheckedChange = { viewModel.setEnabled(it) }
|
||||
)
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
if (config.enabled) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
FilterChip(
|
||||
selected = config.mode == SMSFilterHelper.FilterMode.ALLOW_LIST,
|
||||
onClick = { viewModel.setMode(SMSFilterHelper.FilterMode.ALLOW_LIST) },
|
||||
label = { Text("Allow List") }
|
||||
)
|
||||
FilterChip(
|
||||
selected = config.mode == SMSFilterHelper.FilterMode.BLOCK_LIST,
|
||||
onClick = { viewModel.setMode(SMSFilterHelper.FilterMode.BLOCK_LIST) },
|
||||
label = { Text("Block List") }
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = if (config.mode == SMSFilterHelper.FilterMode.ALLOW_LIST)
|
||||
"Only SMS matching a rule will be forwarded"
|
||||
else
|
||||
"SMS matching a rule will be blocked",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 8.dp)
|
||||
)
|
||||
Divider()
|
||||
}
|
||||
|
||||
if (config.rules.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.FilterList,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(48.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
"No filter rules",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
"Tap + to add a rule",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
LazyColumn(modifier = Modifier.weight(1f)) {
|
||||
itemsIndexed(config.rules) { index, rule ->
|
||||
FilterRuleRow(
|
||||
rule = rule,
|
||||
onEdit = {
|
||||
editingIndex = index
|
||||
showDialog = true
|
||||
},
|
||||
onDelete = { viewModel.deleteRule(index) }
|
||||
)
|
||||
if (index < config.rules.lastIndex) {
|
||||
Divider(modifier = Modifier.padding(start = 16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showDialog) {
|
||||
FilterRuleDialog(
|
||||
rule = editingIndex?.let { config.rules.getOrNull(it) },
|
||||
onConfirm = { rule ->
|
||||
val idx = editingIndex
|
||||
if (idx != null) viewModel.updateRule(idx, rule) else viewModel.addRule(rule)
|
||||
showDialog = false
|
||||
},
|
||||
onDismiss = { showDialog = false }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FilterRuleRow(
|
||||
rule: SMSFilterRule,
|
||||
onEdit: () -> Unit,
|
||||
onDelete: () -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onEdit)
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = rule.pattern ?: "",
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
SuggestionChip(
|
||||
onClick = {},
|
||||
label = {
|
||||
Text(
|
||||
rule.matchType?.name?.replace('_', ' ')
|
||||
?.lowercase()?.replaceFirstChar { it.uppercaseChar() } ?: "",
|
||||
style = MaterialTheme.typography.labelSmall
|
||||
)
|
||||
}
|
||||
)
|
||||
SuggestionChip(
|
||||
onClick = {},
|
||||
label = {
|
||||
Text(
|
||||
rule.filterTarget.name.lowercase().replaceFirstChar { it.uppercaseChar() },
|
||||
style = MaterialTheme.typography.labelSmall
|
||||
)
|
||||
}
|
||||
)
|
||||
if (rule.caseSensitive) {
|
||||
SuggestionChip(
|
||||
onClick = {},
|
||||
label = { Text("Case sensitive", style = MaterialTheme.typography.labelSmall) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
IconButton(onClick = onDelete) {
|
||||
Icon(
|
||||
Icons.Default.Delete,
|
||||
contentDescription = "Delete rule",
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun FilterRuleDialog(
|
||||
rule: SMSFilterRule?,
|
||||
onConfirm: (SMSFilterRule) -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
var pattern by remember(rule) { mutableStateOf(rule?.pattern ?: "") }
|
||||
var matchType by remember(rule) {
|
||||
mutableStateOf(rule?.matchType ?: SMSFilterRule.MatchType.CONTAINS)
|
||||
}
|
||||
var filterTarget by remember(rule) {
|
||||
mutableStateOf(rule?.filterTarget ?: SMSFilterRule.FilterTarget.SENDER)
|
||||
}
|
||||
var caseSensitive by remember(rule) { mutableStateOf(rule?.caseSensitive ?: false) }
|
||||
var matchTypeExpanded by remember { mutableStateOf(false) }
|
||||
var filterTargetExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(if (rule == null) "Add Rule" else "Edit Rule") },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
OutlinedTextField(
|
||||
value = pattern,
|
||||
onValueChange = { pattern = it },
|
||||
label = { Text("Pattern") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = filterTargetExpanded,
|
||||
onExpandedChange = { filterTargetExpanded = !filterTargetExpanded }
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = filterTarget.name.lowercase()
|
||||
.replaceFirstChar { it.uppercaseChar() },
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text("Filter Target") },
|
||||
trailingIcon = {
|
||||
ExposedDropdownMenuDefaults.TrailingIcon(expanded = filterTargetExpanded)
|
||||
},
|
||||
modifier = Modifier.menuAnchor().fillMaxWidth()
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = filterTargetExpanded,
|
||||
onDismissRequest = { filterTargetExpanded = false }
|
||||
) {
|
||||
SMSFilterRule.FilterTarget.values().forEach { target ->
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(target.name.lowercase().replaceFirstChar { it.uppercaseChar() })
|
||||
},
|
||||
onClick = {
|
||||
filterTarget = target
|
||||
filterTargetExpanded = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = matchTypeExpanded,
|
||||
onExpandedChange = { matchTypeExpanded = !matchTypeExpanded }
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = matchType.name.replace('_', ' ').lowercase()
|
||||
.replaceFirstChar { it.uppercaseChar() },
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text("Match Type") },
|
||||
trailingIcon = {
|
||||
ExposedDropdownMenuDefaults.TrailingIcon(expanded = matchTypeExpanded)
|
||||
},
|
||||
modifier = Modifier.menuAnchor().fillMaxWidth()
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = matchTypeExpanded,
|
||||
onDismissRequest = { matchTypeExpanded = false }
|
||||
) {
|
||||
SMSFilterRule.MatchType.values().forEach { type ->
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(
|
||||
type.name.replace('_', ' ').lowercase()
|
||||
.replaceFirstChar { it.uppercaseChar() }
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
matchType = type
|
||||
matchTypeExpanded = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
"Case sensitive",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Switch(checked = caseSensitive, onCheckedChange = { caseSensitive = it })
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
onConfirm(SMSFilterRule(pattern.trim(), matchType, filterTarget, caseSensitive))
|
||||
},
|
||||
enabled = pattern.isNotBlank()
|
||||
) { Text("Save") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text("Cancel") }
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.vernu.sms.ui.settings
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import com.vernu.sms.helpers.SMSFilterHelper
|
||||
import com.vernu.sms.models.SMSFilterRule
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
class SMSFilterViewModel(app: Application) : AndroidViewModel(app) {
|
||||
|
||||
private val _config = MutableStateFlow(SMSFilterHelper.loadFilterConfig(app))
|
||||
val config: StateFlow<SMSFilterHelper.FilterConfig> = _config.asStateFlow()
|
||||
|
||||
fun setEnabled(enabled: Boolean) = mutate { it.enabled = enabled }
|
||||
|
||||
fun setMode(mode: SMSFilterHelper.FilterMode) = mutate { it.mode = mode }
|
||||
|
||||
fun addRule(rule: SMSFilterRule) = mutate { it.rules.add(rule) }
|
||||
|
||||
fun updateRule(index: Int, rule: SMSFilterRule) = mutate { it.rules[index] = rule }
|
||||
|
||||
fun deleteRule(index: Int) = mutate { it.rules.removeAt(index) }
|
||||
|
||||
private fun mutate(block: (SMSFilterHelper.FilterConfig) -> Unit) {
|
||||
val current = _config.value
|
||||
val copy = SMSFilterHelper.FilterConfig().apply {
|
||||
enabled = current.enabled
|
||||
mode = current.mode
|
||||
rules = ArrayList(current.rules)
|
||||
}
|
||||
block(copy)
|
||||
_config.value = copy
|
||||
SMSFilterHelper.saveFilterConfig(getApplication(), copy)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,584 @@
|
||||
package com.vernu.sms.ui.settings
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vernu.sms.BuildConfig
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SettingsScreen(
|
||||
onSwitchToLegacy: () -> Unit,
|
||||
onNavigateToFilters: () -> Unit,
|
||||
onDisconnect: () -> Unit,
|
||||
viewModel: SettingsViewModel = viewModel()
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
val context = LocalContext.current
|
||||
val clipboard = LocalClipboardManager.current
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
var showDisconnectDialog by remember { mutableStateOf(false) }
|
||||
var showLegacyDialog by remember { mutableStateOf(false) }
|
||||
var showAboutDialog by remember { mutableStateOf(false) }
|
||||
var showDeviceNameDialog by remember { mutableStateOf(false) }
|
||||
var editedDeviceName by remember(state.deviceName) { mutableStateOf(state.deviceName) }
|
||||
var showDelayDialog by remember { mutableStateOf(false) }
|
||||
var editedDelay by remember(state.smsSendDelaySeconds) { mutableStateOf(state.smsSendDelaySeconds.toString()) }
|
||||
|
||||
LaunchedEffect(state.snackbarMessage) {
|
||||
state.snackbarMessage?.let {
|
||||
snackbarHostState.showSnackbar(it)
|
||||
viewModel.clearSnackbar()
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(Icons.Default.Settings, contentDescription = null, tint = MaterialTheme.colorScheme.primary)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Settings", fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
)
|
||||
)
|
||||
},
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) }
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
SettingsSectionHeader("Account")
|
||||
|
||||
SettingsRow(
|
||||
icon = Icons.Default.Fingerprint,
|
||||
title = "Device ID",
|
||||
subtitle = state.deviceId.ifEmpty { "—" },
|
||||
subtitleFont = FontFamily.Monospace,
|
||||
trailing = {
|
||||
IconButton(onClick = { clipboard.setText(AnnotatedString(state.deviceId)) }) {
|
||||
Icon(Icons.Default.ContentCopy, contentDescription = "Copy", modifier = Modifier.size(18.dp))
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
SettingsRow(
|
||||
icon = Icons.Default.Key,
|
||||
title = "API Key",
|
||||
subtitle = if (state.apiKey.isEmpty()) "—" else "••••••••" + state.apiKey.takeLast(4),
|
||||
trailing = {
|
||||
IconButton(onClick = { clipboard.setText(AnnotatedString(state.apiKey)) }) {
|
||||
Icon(Icons.Default.ContentCopy, contentDescription = "Copy", modifier = Modifier.size(18.dp))
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
SettingsRow(
|
||||
icon = Icons.Default.Edit,
|
||||
title = "Device Name",
|
||||
subtitle = state.deviceName,
|
||||
onClick = {
|
||||
editedDeviceName = state.deviceName
|
||||
showDeviceNameDialog = true
|
||||
},
|
||||
trailing = {
|
||||
if (state.isSavingDeviceName) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp)
|
||||
} else {
|
||||
Icon(Icons.Default.ChevronRight, contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
Divider(modifier = Modifier.padding(start = 56.dp))
|
||||
|
||||
SettingsRow(
|
||||
icon = Icons.Default.LinkOff,
|
||||
title = "Disconnect Device",
|
||||
titleColor = MaterialTheme.colorScheme.error,
|
||||
onClick = { showDisconnectDialog = true }
|
||||
)
|
||||
|
||||
SettingsSectionHeader("Gateway")
|
||||
|
||||
SettingsSwitchRow(
|
||||
icon = Icons.Default.Power,
|
||||
title = "Gateway Enabled",
|
||||
subtitle = "Allow sending and receiving SMS",
|
||||
checked = state.isGatewayEnabled,
|
||||
onCheckedChange = { viewModel.setGatewayEnabled(it) }
|
||||
)
|
||||
|
||||
if (state.availableSims.size > 1) {
|
||||
SimSelectionRow(
|
||||
sims = state.availableSims,
|
||||
selectedId = state.preferredSimSubscriptionId,
|
||||
onSelect = { viewModel.setPreferredSim(it) }
|
||||
)
|
||||
Text(
|
||||
text = "Use the simSubscriptionId field in your API requests to override this setting",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = 56.dp, end = 16.dp, bottom = 8.dp)
|
||||
)
|
||||
}
|
||||
|
||||
SettingsSectionHeader("SMS")
|
||||
|
||||
SettingsSwitchRow(
|
||||
icon = Icons.Default.MoveToInbox,
|
||||
title = "Receive SMS",
|
||||
subtitle = "Forward incoming SMS to backend",
|
||||
checked = state.isReceiveSmsEnabled,
|
||||
onCheckedChange = { viewModel.setReceiveSms(it) }
|
||||
)
|
||||
|
||||
SettingsRow(
|
||||
icon = Icons.Default.Timer,
|
||||
title = "Send Delay",
|
||||
subtitle = "${state.smsSendDelaySeconds}s between each SMS",
|
||||
onClick = {
|
||||
editedDelay = state.smsSendDelaySeconds.toString()
|
||||
showDelayDialog = true
|
||||
},
|
||||
trailing = {
|
||||
Icon(Icons.Default.ChevronRight, contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
)
|
||||
|
||||
SettingsRow(
|
||||
icon = Icons.Default.FilterList,
|
||||
title = "Configure Filters",
|
||||
subtitle = "Allow/block list for incoming SMS",
|
||||
onClick = onNavigateToFilters,
|
||||
trailing = {
|
||||
Icon(Icons.Default.ChevronRight, contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
)
|
||||
|
||||
SettingsSectionHeader("System")
|
||||
|
||||
SettingsSwitchRow(
|
||||
icon = Icons.Default.NotificationsActive,
|
||||
title = "Sticky Notification",
|
||||
subtitle = "Keeps the gateway alive in the background",
|
||||
checked = state.isStickyNotificationEnabled,
|
||||
onCheckedChange = { viewModel.setStickyNotification(it) }
|
||||
)
|
||||
|
||||
SettingsRow(
|
||||
icon = Icons.Default.Info,
|
||||
title = "App Version",
|
||||
subtitle = "${state.appVersionName} (Build ${state.appVersionCode})"
|
||||
)
|
||||
|
||||
SettingsRow(
|
||||
icon = Icons.Default.AutoAwesome,
|
||||
title = "About",
|
||||
subtitle = "textbee.dev",
|
||||
onClick = { showAboutDialog = true },
|
||||
trailing = {
|
||||
Icon(Icons.Default.ChevronRight, contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
)
|
||||
|
||||
SettingsRow(
|
||||
icon = Icons.Default.SystemUpdate,
|
||||
title = "Check for Updates",
|
||||
onClick = {
|
||||
val versionInfo = "${BuildConfig.VERSION_NAME}(${BuildConfig.VERSION_CODE})"
|
||||
val url = "https://textbee.dev/download?currentVersion=${Uri.encode(versionInfo)}"
|
||||
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
|
||||
},
|
||||
trailing = {
|
||||
Icon(Icons.Default.OpenInBrowser, contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(18.dp))
|
||||
}
|
||||
)
|
||||
|
||||
SettingsSectionHeader("Community")
|
||||
|
||||
SettingsRow(
|
||||
icon = Icons.Default.SupportAgent,
|
||||
title = "Get Support",
|
||||
onClick = {
|
||||
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://app.textbee.dev/dashboard/account/get-support")))
|
||||
},
|
||||
trailing = {
|
||||
Icon(Icons.Default.OpenInBrowser, contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(18.dp))
|
||||
}
|
||||
)
|
||||
|
||||
SettingsRow(
|
||||
icon = Icons.Default.Share,
|
||||
title = "Share textbee",
|
||||
subtitle = "Help spread the word",
|
||||
onClick = {
|
||||
val shareText = "i've been using textbee.dev to send SMS via API from my own phone, " +
|
||||
"no Twilio or paid services needed. works great for automations, alerts, " +
|
||||
"notifications, or anything that needs programmatic SMS. open source and free to start\n\n" +
|
||||
"https://textbee.dev"
|
||||
context.startActivity(
|
||||
Intent.createChooser(
|
||||
Intent(Intent.ACTION_SEND).apply {
|
||||
type = "text/plain"
|
||||
putExtra(Intent.EXTRA_TEXT, shareText)
|
||||
},
|
||||
"Share TextBee"
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
SettingsSectionHeader("Legal")
|
||||
|
||||
SettingsRow(
|
||||
icon = Icons.Default.Gavel,
|
||||
title = "Terms of Service",
|
||||
onClick = {
|
||||
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://textbee.dev/terms-of-service")))
|
||||
},
|
||||
trailing = {
|
||||
Icon(Icons.Default.OpenInBrowser, contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(18.dp))
|
||||
}
|
||||
)
|
||||
|
||||
SettingsRow(
|
||||
icon = Icons.Default.Policy,
|
||||
title = "Privacy Policy",
|
||||
onClick = {
|
||||
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://textbee.dev/privacy-policy")))
|
||||
},
|
||||
trailing = {
|
||||
Icon(Icons.Default.OpenInBrowser, contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(18.dp))
|
||||
}
|
||||
)
|
||||
|
||||
SettingsSectionHeader("UI")
|
||||
|
||||
SettingsRow(
|
||||
icon = Icons.Default.SwapHoriz,
|
||||
title = "Switch to Legacy UI",
|
||||
subtitle = "Use the original interface",
|
||||
onClick = { showLegacyDialog = true },
|
||||
trailing = {
|
||||
Icon(Icons.Default.ChevronRight, contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
}
|
||||
}
|
||||
|
||||
if (showDeviceNameDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showDeviceNameDialog = false },
|
||||
title = { Text("Edit Device Name") },
|
||||
text = {
|
||||
OutlinedTextField(
|
||||
value = editedDeviceName,
|
||||
onValueChange = { editedDeviceName = it },
|
||||
label = { Text("Device Name") },
|
||||
singleLine = true
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
viewModel.saveDeviceName(editedDeviceName)
|
||||
showDeviceNameDialog = false
|
||||
}) { Text("Save") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showDeviceNameDialog = false }) { Text("Cancel") }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (showDelayDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showDelayDialog = false },
|
||||
title = { Text("SMS Send Delay") },
|
||||
text = {
|
||||
Column {
|
||||
Text(
|
||||
"Delay between each SMS in seconds (0–3600)",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
value = editedDelay,
|
||||
onValueChange = { editedDelay = it.filter { c -> c.isDigit() } },
|
||||
label = { Text("Seconds") },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
singleLine = true
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
viewModel.setSmsSendDelay(editedDelay.toIntOrNull() ?: 5)
|
||||
showDelayDialog = false
|
||||
}) { Text("Save") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showDelayDialog = false }) { Text("Cancel") }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (showLegacyDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showLegacyDialog = false },
|
||||
title = { Text("Switch to Legacy UI?") },
|
||||
text = { Text("You can switch back from the Legacy Settings screen anytime.") },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
showLegacyDialog = false
|
||||
onSwitchToLegacy()
|
||||
}) { Text("Switch") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showLegacyDialog = false }) { Text("Cancel") }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (showAboutDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showAboutDialog = false },
|
||||
title = {
|
||||
Text("textbee.dev", fontWeight = FontWeight.Bold)
|
||||
},
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
"An open-source SMS gateway that turns your Android into a personal SMS API. " +
|
||||
"Send and receive messages programmatically without relying on expensive third-party services.",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://textbee.dev")))
|
||||
}
|
||||
) {
|
||||
Text("textbee.dev")
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://github.com/vernu/textbee")))
|
||||
}
|
||||
) {
|
||||
Text("GitHub")
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { showAboutDialog = false }) { Text("Close") }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (showDisconnectDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showDisconnectDialog = false },
|
||||
title = { Text("Disconnect Device?") },
|
||||
text = { Text("This will remove all credentials from this device. You'll need to reconnect to use the gateway.") },
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
showDisconnectDialog = false
|
||||
onDisconnect()
|
||||
},
|
||||
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error)
|
||||
) { Text("Disconnect") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showDisconnectDialog = false }) { Text("Cancel") }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingsSectionHeader(title: String) {
|
||||
Text(
|
||||
text = title.uppercase(),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(start = 16.dp, top = 20.dp, bottom = 4.dp)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingsRow(
|
||||
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||
title: String,
|
||||
subtitle: String? = null,
|
||||
subtitleFont: FontFamily = FontFamily.Default,
|
||||
titleColor: androidx.compose.ui.graphics.Color = MaterialTheme.colorScheme.onSurface,
|
||||
onClick: (() -> Unit)? = null,
|
||||
trailing: @Composable (() -> Unit)? = null
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier)
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(24.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(text = title, style = MaterialTheme.typography.bodyLarge, color = titleColor)
|
||||
subtitle?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodySmall.copy(fontFamily = subtitleFont),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
trailing?.invoke()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingsSwitchRow(
|
||||
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||
title: String,
|
||||
subtitle: String? = null,
|
||||
checked: Boolean,
|
||||
onCheckedChange: (Boolean) -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(24.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(text = title, style = MaterialTheme.typography.bodyLarge)
|
||||
subtitle?.let {
|
||||
Text(text = it, style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
Switch(checked = checked, onCheckedChange = onCheckedChange)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun SimSelectionRow(
|
||||
sims: List<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
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
20
android/app/src/main/java/com/vernu/sms/ui/theme/Color.kt
Normal file
20
android/app/src/main/java/com/vernu/sms/ui/theme/Color.kt
Normal 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)
|
||||
84
android/app/src/main/java/com/vernu/sms/ui/theme/Theme.kt
Normal file
84
android/app/src/main/java/com/vernu/sms/ui/theme/Theme.kt
Normal 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
|
||||
)
|
||||
}
|
||||
49
android/app/src/main/java/com/vernu/sms/ui/theme/Type.kt
Normal file
49
android/app/src/main/java/com/vernu/sms/ui/theme/Type.kt
Normal 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
|
||||
)
|
||||
)
|
||||
@@ -1,55 +0,0 @@
|
||||
package com.vernu.sms.workers;
|
||||
|
||||
import android.content.Context;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.work.Worker;
|
||||
import androidx.work.WorkerParameters;
|
||||
|
||||
import com.vernu.sms.AppConstants;
|
||||
import com.vernu.sms.helpers.SharedPreferenceHelper;
|
||||
import com.vernu.sms.helpers.HeartbeatHelper;
|
||||
|
||||
public class HeartbeatWorker extends Worker {
|
||||
private static final String TAG = "HeartbeatWorker";
|
||||
|
||||
public HeartbeatWorker(@NonNull Context context, @NonNull WorkerParameters workerParams) {
|
||||
super(context, workerParams);
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public Result doWork() {
|
||||
Context context = getApplicationContext();
|
||||
|
||||
// Check if device is eligible for heartbeat
|
||||
if (!HeartbeatHelper.isDeviceEligibleForHeartbeat(context)) {
|
||||
Log.d(TAG, "Device not eligible for heartbeat, skipping");
|
||||
return Result.success(); // Not a failure, just skip
|
||||
}
|
||||
|
||||
// Get device ID and API key
|
||||
String deviceId = SharedPreferenceHelper.getSharedPreferenceString(
|
||||
context,
|
||||
AppConstants.SHARED_PREFS_DEVICE_ID_KEY,
|
||||
""
|
||||
);
|
||||
|
||||
String apiKey = SharedPreferenceHelper.getSharedPreferenceString(
|
||||
context,
|
||||
AppConstants.SHARED_PREFS_API_KEY_KEY,
|
||||
""
|
||||
);
|
||||
|
||||
// Send heartbeat using shared helper
|
||||
boolean success = HeartbeatHelper.sendHeartbeat(context, deviceId, apiKey);
|
||||
|
||||
if (success) {
|
||||
return Result.success();
|
||||
} else {
|
||||
Log.e(TAG, "Failed to send heartbeat, will retry");
|
||||
return Result.retry();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.vernu.sms.workers
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.work.Worker
|
||||
import androidx.work.WorkerParameters
|
||||
import com.vernu.sms.AppConstants
|
||||
import com.vernu.sms.helpers.HeartbeatHelper
|
||||
import com.vernu.sms.helpers.SharedPreferenceHelper
|
||||
|
||||
class HeartbeatWorker(context: Context, workerParams: WorkerParameters) : Worker(context, workerParams) {
|
||||
companion object {
|
||||
private const val TAG = "HeartbeatWorker"
|
||||
}
|
||||
|
||||
override fun doWork(): Result {
|
||||
val context = applicationContext
|
||||
|
||||
if (!HeartbeatHelper.isDeviceEligibleForHeartbeat(context)) {
|
||||
Log.d(TAG, "Device not eligible for heartbeat, skipping")
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
val deviceId = SharedPreferenceHelper.getSharedPreferenceString(
|
||||
context, AppConstants.SHARED_PREFS_DEVICE_ID_KEY, ""
|
||||
) ?: ""
|
||||
val apiKey = SharedPreferenceHelper.getSharedPreferenceString(
|
||||
context, AppConstants.SHARED_PREFS_API_KEY_KEY, ""
|
||||
) ?: ""
|
||||
|
||||
return if (HeartbeatHelper.sendHeartbeat(context, deviceId, apiKey)) {
|
||||
Result.success()
|
||||
} else {
|
||||
Log.e(TAG, "Failed to send heartbeat, will retry")
|
||||
Result.retry()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
package com.vernu.sms.workers;
|
||||
|
||||
import android.content.Context;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.work.Data;
|
||||
import androidx.work.Worker;
|
||||
import androidx.work.WorkerParameters;
|
||||
import androidx.work.BackoffPolicy;
|
||||
import androidx.work.Constraints;
|
||||
import androidx.work.NetworkType;
|
||||
import androidx.work.OneTimeWorkRequest;
|
||||
import androidx.work.WorkManager;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.vernu.sms.ApiManager;
|
||||
import com.vernu.sms.dtos.SMSDTO;
|
||||
import com.vernu.sms.dtos.SMSForwardResponseDTO;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import retrofit2.Call;
|
||||
import retrofit2.Response;
|
||||
|
||||
public class SMSReceivedWorker extends Worker {
|
||||
private static final String TAG = "SMSReceivedWorker";
|
||||
private static final int MAX_RETRIES = 5;
|
||||
|
||||
public static final String KEY_DEVICE_ID = "device_id";
|
||||
public static final String KEY_API_KEY = "api_key";
|
||||
public static final String KEY_SMS_DTO = "sms_dto";
|
||||
public static final String KEY_RETRY_COUNT = "retry_count";
|
||||
|
||||
public SMSReceivedWorker(@NonNull Context context, @NonNull WorkerParameters workerParams) {
|
||||
super(context, workerParams);
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public Result doWork() {
|
||||
String deviceId = getInputData().getString(KEY_DEVICE_ID);
|
||||
String apiKey = getInputData().getString(KEY_API_KEY);
|
||||
String smsDtoJson = getInputData().getString(KEY_SMS_DTO);
|
||||
int retryCount = getInputData().getInt(KEY_RETRY_COUNT, 0);
|
||||
|
||||
if (deviceId == null || apiKey == null || smsDtoJson == null) {
|
||||
Log.e(TAG, "Missing required parameters");
|
||||
return Result.failure();
|
||||
}
|
||||
|
||||
// Check if we've exceeded the maximum retry count
|
||||
if (retryCount >= MAX_RETRIES) {
|
||||
Log.e(TAG, "Maximum retry count reached for received SMS");
|
||||
return Result.failure();
|
||||
}
|
||||
|
||||
SMSDTO smsDTO = new Gson().fromJson(smsDtoJson, SMSDTO.class);
|
||||
|
||||
try {
|
||||
Call<SMSForwardResponseDTO> call = ApiManager.getApiService().sendReceivedSMS(deviceId, apiKey, smsDTO);
|
||||
Response<SMSForwardResponseDTO> response = call.execute();
|
||||
|
||||
if (response.isSuccessful()) {
|
||||
Log.d(TAG, "Received SMS sent to server successfully");
|
||||
return Result.success();
|
||||
} else {
|
||||
Log.e(TAG, "Failed to send received SMS to server. Response code: " + response.code());
|
||||
return Result.retry();
|
||||
}
|
||||
} catch (IOException e) {
|
||||
Log.e(TAG, "API call failed: " + e.getMessage());
|
||||
return Result.retry();
|
||||
}
|
||||
}
|
||||
|
||||
public static void enqueueWork(Context context, String deviceId, String apiKey, SMSDTO smsDTO) {
|
||||
Data inputData = new Data.Builder()
|
||||
.putString(KEY_DEVICE_ID, deviceId)
|
||||
.putString(KEY_API_KEY, apiKey)
|
||||
.putString(KEY_SMS_DTO, new Gson().toJson(smsDTO))
|
||||
.putInt(KEY_RETRY_COUNT, 0)
|
||||
.build();
|
||||
|
||||
Constraints constraints = new Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
.build();
|
||||
|
||||
OneTimeWorkRequest workRequest = new OneTimeWorkRequest.Builder(SMSReceivedWorker.class)
|
||||
.setConstraints(constraints)
|
||||
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 10, TimeUnit.SECONDS)
|
||||
.setInputData(inputData)
|
||||
.addTag("sms_received")
|
||||
.build();
|
||||
|
||||
// Use fingerprint for unique work name if available, otherwise fallback to timestamp
|
||||
String uniqueWorkName;
|
||||
if (smsDTO.getFingerprint() != null && !smsDTO.getFingerprint().isEmpty()) {
|
||||
uniqueWorkName = "sms_received_" + smsDTO.getFingerprint();
|
||||
} else {
|
||||
// Fallback to timestamp if fingerprint is not available
|
||||
uniqueWorkName = "sms_received_" + System.currentTimeMillis();
|
||||
Log.w(TAG, "Fingerprint not available, using timestamp for work name");
|
||||
}
|
||||
|
||||
WorkManager.getInstance(context)
|
||||
.beginUniqueWork(uniqueWorkName,
|
||||
androidx.work.ExistingWorkPolicy.KEEP,
|
||||
workRequest)
|
||||
.enqueue();
|
||||
|
||||
Log.d(TAG, "Work enqueued for received SMS from: " + smsDTO.getSender() + " with fingerprint: " + uniqueWorkName);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package com.vernu.sms.workers
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.work.*
|
||||
import com.google.gson.Gson
|
||||
import com.vernu.sms.ApiManager
|
||||
import com.vernu.sms.dtos.SMSDTO
|
||||
import com.vernu.sms.dtos.SMSForwardResponseDTO
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class SMSReceivedWorker(context: Context, workerParams: WorkerParameters) : Worker(context, workerParams) {
|
||||
companion object {
|
||||
private const val TAG = "SMSReceivedWorker"
|
||||
private const val MAX_RETRIES = 5
|
||||
|
||||
const val KEY_DEVICE_ID = "device_id"
|
||||
const val KEY_API_KEY = "api_key"
|
||||
const val KEY_SMS_DTO = "sms_dto"
|
||||
const val KEY_RETRY_COUNT = "retry_count"
|
||||
|
||||
fun enqueueWork(context: Context, deviceId: String, apiKey: String, smsDTO: SMSDTO) {
|
||||
val inputData = Data.Builder()
|
||||
.putString(KEY_DEVICE_ID, deviceId)
|
||||
.putString(KEY_API_KEY, apiKey)
|
||||
.putString(KEY_SMS_DTO, Gson().toJson(smsDTO))
|
||||
.putInt(KEY_RETRY_COUNT, 0)
|
||||
.build()
|
||||
|
||||
val constraints = Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
.build()
|
||||
|
||||
val workRequest = OneTimeWorkRequest.Builder(SMSReceivedWorker::class.java)
|
||||
.setConstraints(constraints)
|
||||
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 10, TimeUnit.SECONDS)
|
||||
.setInputData(inputData)
|
||||
.addTag("sms_received")
|
||||
.build()
|
||||
|
||||
val fp = smsDTO.fingerprint
|
||||
val uniqueWorkName = if (!fp.isNullOrEmpty()) {
|
||||
"sms_received_$fp"
|
||||
} else {
|
||||
Log.w(TAG, "Fingerprint not available, using timestamp for work name")
|
||||
"sms_received_${System.currentTimeMillis()}"
|
||||
}
|
||||
|
||||
WorkManager.getInstance(context)
|
||||
.beginUniqueWork(uniqueWorkName, ExistingWorkPolicy.KEEP, workRequest)
|
||||
.enqueue()
|
||||
|
||||
Log.d(TAG, "Work enqueued for received SMS from: ${smsDTO.sender} with fingerprint: $uniqueWorkName")
|
||||
}
|
||||
}
|
||||
|
||||
override fun doWork(): Result {
|
||||
val deviceId = inputData.getString(KEY_DEVICE_ID)
|
||||
val apiKey = inputData.getString(KEY_API_KEY)
|
||||
val smsDtoJson = inputData.getString(KEY_SMS_DTO)
|
||||
val retryCount = inputData.getInt(KEY_RETRY_COUNT, 0)
|
||||
|
||||
if (deviceId == null || apiKey == null || smsDtoJson == null) {
|
||||
Log.e(TAG, "Missing required parameters")
|
||||
return Result.failure()
|
||||
}
|
||||
|
||||
if (retryCount >= MAX_RETRIES) {
|
||||
Log.e(TAG, "Maximum retry count reached for received SMS")
|
||||
return Result.failure()
|
||||
}
|
||||
|
||||
val smsDTO = Gson().fromJson(smsDtoJson, SMSDTO::class.java)
|
||||
|
||||
return try {
|
||||
val response = ApiManager.getApiService().sendReceivedSMS(deviceId, apiKey, smsDTO).execute()
|
||||
if (response.isSuccessful) {
|
||||
Log.d(TAG, "Received SMS sent to server successfully")
|
||||
Result.success()
|
||||
} else {
|
||||
Log.e(TAG, "Failed to send received SMS to server. Response code: ${response.code()}")
|
||||
Result.retry()
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Log.e(TAG, "API call failed: ${e.message}")
|
||||
Result.retry()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
package com.vernu.sms.workers;
|
||||
|
||||
import android.content.Context;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.work.Data;
|
||||
import androidx.work.Worker;
|
||||
import androidx.work.WorkerParameters;
|
||||
import androidx.work.BackoffPolicy;
|
||||
import androidx.work.Constraints;
|
||||
import androidx.work.NetworkType;
|
||||
import androidx.work.OneTimeWorkRequest;
|
||||
import androidx.work.WorkManager;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.vernu.sms.ApiManager;
|
||||
import com.vernu.sms.dtos.SMSDTO;
|
||||
import com.vernu.sms.dtos.SMSForwardResponseDTO;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import retrofit2.Call;
|
||||
import retrofit2.Response;
|
||||
|
||||
public class SMSStatusUpdateWorker extends Worker {
|
||||
private static final String TAG = "SMSStatusUpdateWorker";
|
||||
private static final int MAX_RETRIES = 5;
|
||||
|
||||
public static final String KEY_DEVICE_ID = "device_id";
|
||||
public static final String KEY_API_KEY = "api_key";
|
||||
public static final String KEY_SMS_DTO = "sms_dto";
|
||||
public static final String KEY_RETRY_COUNT = "retry_count";
|
||||
|
||||
public SMSStatusUpdateWorker(@NonNull Context context, @NonNull WorkerParameters workerParams) {
|
||||
super(context, workerParams);
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public Result doWork() {
|
||||
String deviceId = getInputData().getString(KEY_DEVICE_ID);
|
||||
String apiKey = getInputData().getString(KEY_API_KEY);
|
||||
String smsDtoJson = getInputData().getString(KEY_SMS_DTO);
|
||||
int retryCount = getInputData().getInt(KEY_RETRY_COUNT, 0);
|
||||
|
||||
if (deviceId == null || apiKey == null || smsDtoJson == null) {
|
||||
Log.e(TAG, "Missing required parameters");
|
||||
return Result.failure();
|
||||
}
|
||||
|
||||
// Check if we've exceeded the maximum retry count
|
||||
if (retryCount >= MAX_RETRIES) {
|
||||
Log.e(TAG, "Maximum retry count reached for SMS status update");
|
||||
return Result.failure();
|
||||
}
|
||||
|
||||
SMSDTO smsDTO = new Gson().fromJson(smsDtoJson, SMSDTO.class);
|
||||
|
||||
try {
|
||||
Call<SMSForwardResponseDTO> call = ApiManager.getApiService().updateSMSStatus(deviceId, apiKey, smsDTO);
|
||||
Response<SMSForwardResponseDTO> response = call.execute();
|
||||
|
||||
if (response.isSuccessful()) {
|
||||
Log.d(TAG, "SMS status updated successfully - ID: " + smsDTO.getSmsId() + ", Status: " + smsDTO.getStatus());
|
||||
return Result.success();
|
||||
} else {
|
||||
Log.e(TAG, "Failed to update SMS status. Response code: " + response.code());
|
||||
return Result.retry();
|
||||
}
|
||||
} catch (IOException e) {
|
||||
Log.e(TAG, "API call failed: " + e.getMessage());
|
||||
return Result.retry();
|
||||
}
|
||||
}
|
||||
|
||||
public static void enqueueWork(Context context, String deviceId, String apiKey, SMSDTO smsDTO) {
|
||||
Data inputData = new Data.Builder()
|
||||
.putString(KEY_DEVICE_ID, deviceId)
|
||||
.putString(KEY_API_KEY, apiKey)
|
||||
.putString(KEY_SMS_DTO, new Gson().toJson(smsDTO))
|
||||
.putInt(KEY_RETRY_COUNT, 0)
|
||||
.build();
|
||||
|
||||
Constraints constraints = new Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
.build();
|
||||
|
||||
OneTimeWorkRequest workRequest = new OneTimeWorkRequest.Builder(SMSStatusUpdateWorker.class)
|
||||
.setConstraints(constraints)
|
||||
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 10, TimeUnit.SECONDS)
|
||||
.setInputData(inputData)
|
||||
.build();
|
||||
|
||||
String uniqueWorkName = "sms_status_" + smsDTO.getStatus() + "_" + System.currentTimeMillis();
|
||||
WorkManager.getInstance(context)
|
||||
.beginUniqueWork(uniqueWorkName,
|
||||
androidx.work.ExistingWorkPolicy.REPLACE,
|
||||
workRequest)
|
||||
.enqueue();
|
||||
|
||||
Log.d(TAG, "Work enqueued for SMS status update - ID: " + smsDTO.getSmsId());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package com.vernu.sms.workers
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.work.*
|
||||
import com.google.gson.Gson
|
||||
import com.vernu.sms.ApiManager
|
||||
import com.vernu.sms.dtos.SMSDTO
|
||||
import com.vernu.sms.dtos.SMSForwardResponseDTO
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class SMSStatusUpdateWorker(context: Context, workerParams: WorkerParameters) : Worker(context, workerParams) {
|
||||
companion object {
|
||||
private const val TAG = "SMSStatusUpdateWorker"
|
||||
private const val MAX_RETRIES = 5
|
||||
|
||||
const val KEY_DEVICE_ID = "device_id"
|
||||
const val KEY_API_KEY = "api_key"
|
||||
const val KEY_SMS_DTO = "sms_dto"
|
||||
const val KEY_RETRY_COUNT = "retry_count"
|
||||
|
||||
fun enqueueWork(context: Context, deviceId: String, apiKey: String, smsDTO: SMSDTO) {
|
||||
val inputData = Data.Builder()
|
||||
.putString(KEY_DEVICE_ID, deviceId)
|
||||
.putString(KEY_API_KEY, apiKey)
|
||||
.putString(KEY_SMS_DTO, Gson().toJson(smsDTO))
|
||||
.putInt(KEY_RETRY_COUNT, 0)
|
||||
.build()
|
||||
|
||||
val constraints = Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
.build()
|
||||
|
||||
val workRequest = OneTimeWorkRequest.Builder(SMSStatusUpdateWorker::class.java)
|
||||
.setConstraints(constraints)
|
||||
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 10, TimeUnit.SECONDS)
|
||||
.setInputData(inputData)
|
||||
.build()
|
||||
|
||||
val uniqueWorkName = "sms_status_${smsDTO.status}_${System.currentTimeMillis()}"
|
||||
WorkManager.getInstance(context)
|
||||
.beginUniqueWork(uniqueWorkName, ExistingWorkPolicy.REPLACE, workRequest)
|
||||
.enqueue()
|
||||
|
||||
Log.d(TAG, "Work enqueued for SMS status update - ID: ${smsDTO.smsId}")
|
||||
}
|
||||
}
|
||||
|
||||
override fun doWork(): Result {
|
||||
val deviceId = inputData.getString(KEY_DEVICE_ID)
|
||||
val apiKey = inputData.getString(KEY_API_KEY)
|
||||
val smsDtoJson = inputData.getString(KEY_SMS_DTO)
|
||||
val retryCount = inputData.getInt(KEY_RETRY_COUNT, 0)
|
||||
|
||||
if (deviceId == null || apiKey == null || smsDtoJson == null) {
|
||||
Log.e(TAG, "Missing required parameters")
|
||||
return Result.failure()
|
||||
}
|
||||
|
||||
if (retryCount >= MAX_RETRIES) {
|
||||
Log.e(TAG, "Maximum retry count reached for SMS status update")
|
||||
return Result.failure()
|
||||
}
|
||||
|
||||
val smsDTO = Gson().fromJson(smsDtoJson, SMSDTO::class.java)
|
||||
|
||||
return try {
|
||||
val response = ApiManager.getApiService().updateSMSStatus(deviceId, apiKey, smsDTO).execute()
|
||||
if (response.isSuccessful) {
|
||||
Log.d(TAG, "SMS status updated successfully - ID: ${smsDTO.smsId}, Status: ${smsDTO.status}")
|
||||
Result.success()
|
||||
} else {
|
||||
Log.e(TAG, "Failed to update SMS status. Response code: ${response.code()}")
|
||||
Result.retry()
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Log.e(TAG, "API call failed: ${e.message}")
|
||||
Result.retry()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
package com.vernu.sms.workers;
|
||||
|
||||
import android.content.Context;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.work.Data;
|
||||
import androidx.work.ExistingWorkPolicy;
|
||||
import androidx.work.OneTimeWorkRequest;
|
||||
import androidx.work.Worker;
|
||||
import androidx.work.WorkManager;
|
||||
import androidx.work.WorkerParameters;
|
||||
|
||||
import com.vernu.sms.AppConstants;
|
||||
import com.vernu.sms.TextBeeUtils;
|
||||
import com.vernu.sms.helpers.SMSHelper;
|
||||
import com.vernu.sms.helpers.SharedPreferenceHelper;
|
||||
|
||||
public class SmsSendWorker extends Worker {
|
||||
private static final String TAG = "SmsSendWorker";
|
||||
private static final String QUEUE_NAME = "sms_send_queue";
|
||||
|
||||
public static final String KEY_PHONE = "phone";
|
||||
public static final String KEY_MESSAGE = "message";
|
||||
public static final String KEY_SMS_ID = "sms_id";
|
||||
public static final String KEY_SMS_BATCH_ID = "sms_batch_id";
|
||||
public static final String KEY_SIM_SUBSCRIPTION_ID = "sim_subscription_id";
|
||||
|
||||
public SmsSendWorker(@NonNull Context context, @NonNull WorkerParameters workerParams) {
|
||||
super(context, workerParams);
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public Result doWork() {
|
||||
String phone = getInputData().getString(KEY_PHONE);
|
||||
String message = getInputData().getString(KEY_MESSAGE);
|
||||
String smsId = getInputData().getString(KEY_SMS_ID);
|
||||
String smsBatchId = getInputData().getString(KEY_SMS_BATCH_ID);
|
||||
int simSubscriptionId = getInputData().getInt(KEY_SIM_SUBSCRIPTION_ID, -1);
|
||||
|
||||
if (phone == null || message == null || smsId == null) {
|
||||
Log.e(TAG, "Missing required parameters");
|
||||
return Result.failure();
|
||||
}
|
||||
|
||||
Context context = getApplicationContext();
|
||||
|
||||
// Resolve SIM: backend-provided > app preference > device default
|
||||
Integer resolvedSim = resolveSim(context, simSubscriptionId);
|
||||
|
||||
if (resolvedSim != null) {
|
||||
SMSHelper.sendSMSFromSpecificSim(phone, message, resolvedSim, smsId, smsBatchId, context);
|
||||
} else {
|
||||
SMSHelper.sendSMS(phone, message, smsId, smsBatchId, context);
|
||||
}
|
||||
|
||||
// Enforce rate limit delay
|
||||
int delaySeconds = SharedPreferenceHelper.getSharedPreferenceInt(
|
||||
context, AppConstants.SHARED_PREFS_SMS_SEND_DELAY_SECONDS_KEY, AppConstants.DEFAULT_SMS_SEND_DELAY_SECONDS);
|
||||
delaySeconds = Math.max(0, Math.min(delaySeconds, 3600));
|
||||
|
||||
if (delaySeconds > 0) {
|
||||
try {
|
||||
Thread.sleep(delaySeconds * 1000L);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
}
|
||||
|
||||
return Result.success();
|
||||
}
|
||||
|
||||
private Integer resolveSim(Context context, int backendSimId) {
|
||||
// Priority 1: backend-provided SIM
|
||||
if (backendSimId != -1 && TextBeeUtils.isValidSubscriptionId(context, backendSimId)) {
|
||||
Log.d(TAG, "Using backend-provided SIM subscription ID: " + backendSimId);
|
||||
return backendSimId;
|
||||
}
|
||||
|
||||
// Priority 2: app preference
|
||||
int preferredSim = SharedPreferenceHelper.getSharedPreferenceInt(
|
||||
context, AppConstants.SHARED_PREFS_PREFERRED_SIM_KEY, -1);
|
||||
if (preferredSim != -1 && TextBeeUtils.isValidSubscriptionId(context, preferredSim)) {
|
||||
Log.d(TAG, "Using app-preferred SIM subscription ID: " + preferredSim);
|
||||
return preferredSim;
|
||||
}
|
||||
|
||||
// Priority 3: device default
|
||||
return null;
|
||||
}
|
||||
|
||||
public static void enqueue(Context context, String phone, String message,
|
||||
String smsId, String smsBatchId, Integer simSubscriptionId) {
|
||||
Data inputData = new Data.Builder()
|
||||
.putString(KEY_PHONE, phone)
|
||||
.putString(KEY_MESSAGE, message)
|
||||
.putString(KEY_SMS_ID, smsId)
|
||||
.putString(KEY_SMS_BATCH_ID, smsBatchId)
|
||||
.putInt(KEY_SIM_SUBSCRIPTION_ID, simSubscriptionId != null ? simSubscriptionId : -1)
|
||||
.build();
|
||||
|
||||
OneTimeWorkRequest workRequest = new OneTimeWorkRequest.Builder(SmsSendWorker.class)
|
||||
.setInputData(inputData)
|
||||
.build();
|
||||
|
||||
WorkManager.getInstance(context)
|
||||
.beginUniqueWork(QUEUE_NAME, ExistingWorkPolicy.APPEND_OR_REPLACE, workRequest)
|
||||
.enqueue();
|
||||
|
||||
Log.d(TAG, "SMS enqueued for sending - ID: " + smsId + ", Phone: " + phone);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package com.vernu.sms.workers
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.work.*
|
||||
import com.vernu.sms.AppConstants
|
||||
import com.vernu.sms.TextBeeUtils
|
||||
import com.vernu.sms.helpers.SMSHelper
|
||||
import com.vernu.sms.helpers.SharedPreferenceHelper
|
||||
|
||||
class SmsSendWorker(context: Context, workerParams: WorkerParameters) : Worker(context, workerParams) {
|
||||
companion object {
|
||||
private const val TAG = "SmsSendWorker"
|
||||
private const val QUEUE_NAME = "sms_send_queue"
|
||||
|
||||
const val KEY_PHONE = "phone"
|
||||
const val KEY_MESSAGE = "message"
|
||||
const val KEY_SMS_ID = "sms_id"
|
||||
const val KEY_SMS_BATCH_ID = "sms_batch_id"
|
||||
const val KEY_SIM_SUBSCRIPTION_ID = "sim_subscription_id"
|
||||
|
||||
fun enqueue(
|
||||
context: Context, phone: String, message: String,
|
||||
smsId: String?, smsBatchId: String?, simSubscriptionId: Int?
|
||||
) {
|
||||
val inputData = Data.Builder()
|
||||
.putString(KEY_PHONE, phone)
|
||||
.putString(KEY_MESSAGE, message)
|
||||
.putString(KEY_SMS_ID, smsId)
|
||||
.putString(KEY_SMS_BATCH_ID, smsBatchId)
|
||||
.putInt(KEY_SIM_SUBSCRIPTION_ID, simSubscriptionId ?: -1)
|
||||
.build()
|
||||
|
||||
val workRequest = OneTimeWorkRequest.Builder(SmsSendWorker::class.java)
|
||||
.setInputData(inputData)
|
||||
.build()
|
||||
|
||||
WorkManager.getInstance(context)
|
||||
.beginUniqueWork(QUEUE_NAME, ExistingWorkPolicy.APPEND_OR_REPLACE, workRequest)
|
||||
.enqueue()
|
||||
|
||||
Log.d(TAG, "SMS enqueued for sending - ID: $smsId, Phone: $phone")
|
||||
}
|
||||
}
|
||||
|
||||
override fun doWork(): Result {
|
||||
val phone = inputData.getString(KEY_PHONE)
|
||||
val message = inputData.getString(KEY_MESSAGE)
|
||||
val smsId = inputData.getString(KEY_SMS_ID)
|
||||
val smsBatchId = inputData.getString(KEY_SMS_BATCH_ID)
|
||||
val simSubscriptionId = inputData.getInt(KEY_SIM_SUBSCRIPTION_ID, -1)
|
||||
|
||||
if (phone == null || message == null || smsId == null) {
|
||||
Log.e(TAG, "Missing required parameters")
|
||||
return Result.failure()
|
||||
}
|
||||
|
||||
val context = applicationContext
|
||||
val resolvedSim = resolveSim(context, simSubscriptionId)
|
||||
|
||||
if (resolvedSim != null) {
|
||||
SMSHelper.sendSMSFromSpecificSim(phone, message, resolvedSim, smsId, smsBatchId ?: "", context)
|
||||
} else {
|
||||
SMSHelper.sendSMS(phone, message, smsId, smsBatchId ?: "", context)
|
||||
}
|
||||
|
||||
val delaySeconds = SharedPreferenceHelper.getSharedPreferenceInt(
|
||||
context, AppConstants.SHARED_PREFS_SMS_SEND_DELAY_SECONDS_KEY,
|
||||
AppConstants.DEFAULT_SMS_SEND_DELAY_SECONDS
|
||||
).coerceIn(0, 3600)
|
||||
|
||||
if (delaySeconds > 0) {
|
||||
try {
|
||||
Thread.sleep(delaySeconds * 1000L)
|
||||
} catch (e: InterruptedException) {
|
||||
Thread.currentThread().interrupt()
|
||||
}
|
||||
}
|
||||
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
private fun resolveSim(context: Context, backendSimId: Int): Int? {
|
||||
if (backendSimId != -1 && TextBeeUtils.isValidSubscriptionId(context, backendSimId)) {
|
||||
Log.d(TAG, "Using backend-provided SIM subscription ID: $backendSimId")
|
||||
return backendSimId
|
||||
}
|
||||
|
||||
val preferredSim = SharedPreferenceHelper.getSharedPreferenceInt(
|
||||
context, AppConstants.SHARED_PREFS_PREFERRED_SIM_KEY, -1
|
||||
)
|
||||
if (preferredSim != -1 && TextBeeUtils.isValidSubscriptionId(context, preferredSim)) {
|
||||
Log.d(TAG, "Using app-preferred SIM subscription ID: $preferredSim")
|
||||
return preferredSim
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
}
|
||||
BIN
android/app/src/main/res/drawable/ic_app_logo.webp
Normal file
BIN
android/app/src/main/res/drawable/ic_app_logo.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.4 KiB |
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user