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 objects 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:
- Remove the "Switch to Legacy UI" row from
SettingsScreen.kt
- Remove
USE_NEW_UI_KEY logic from SplashActivity.kt (always route to new UI)
- Delete
activities/MainActivity.java and its XML layouts
- Delete
activities/SMSFilterActivity.java
- Delete
services/GatewayApiService.java (Java Retrofit interface)
- Delete
ApiManager.java
- Remove "Try New UI" button from any remaining legacy layout XML
- Clean up
AppConstants.java — remove SHARED_PREFS_USE_NEW_UI_KEY
- 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()