┌──────────────────────────────────────────────────────────────────────────────────────────────┐
│ ui/ │
│ Compose screens, Material 3 theme, type-safe Navigation, per-screen ViewModels exposing │
│ StateFlow<UiState> and SharedFlow<UiEvent>. │
└──────────────────────────────────────────────────────────────────────────────────────────────┘
│
▼ injected UseCases
┌──────────────────────────────────────────────────────────────────────────────────────────────┐
│ domain/ │
│ Immutable models + the message enums (MessageStatus/Type/Direction, ScheduledState), │
│ repository interfaces, UseCases (SendSmsUseCase, RetrySendUseCase, …). │
│ No Android imports — kotlinx.serialization only (a multiplatform, non-Android library). │
└──────────────────────────────────────────────────────────────────────────────────────────────┘
│
▼ Hilt bindings
┌──────────────────────────────────────────────────────────────────────────────────────────────┐
│ data/ │
│ Room (entities + DAOs + FTS4) + SQLCipher key managed by DatabaseKeyManager. │
│ DataStore (Settings + Security). │
│ ContentResolver wrappers: TelephonyReader (SMS provider), ContactsReader, │
│ BlockedNumberSystem (BlockedNumberContract). │
│ Repositories implement the domain interfaces and convert entities to domain models. │
│ ConversationMirror is the single point of insertion into Room for incoming/outgoing SMS. │
└──────────────────────────────────────────────────────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────────────────────────────────────┐
│ system/ │
│ BroadcastReceivers + Services + WorkManager workers + NotificationChannels. │
│ Bridges Android system events to the data layer. │
└──────────────────────────────────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────────────────────────────────┐
│ security/ │
│ AppLockManager (PIN + biometric), AutoLockObserver, VaultManager, PanicService. │
│ Acts as a cross-cutting concern. │
└──────────────────────────────────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────────────────────────────────┐
│ core/ │
│ Result/Outcome, AppError sealed, AeadCipher + KeystoreManager + PasswordKdf, logging. │
│ Zero dependencies on the rest of the app. │
└──────────────────────────────────────────────────────────────────────────────────────────────┘
domain/ is now 100 % free of data/, system/ and security/ imports (verified by grep on
the whole package). The dependency inversion is done; a :domain Gradle module is extractable.
- Message enums live in
domain/model(out ofdata/local/db/entity). Room still stores them asINTEGERviaMessageEnumConverters; the backup format still serialises them asInt— both package-independent, nothing changed on disk/wire (proven byMessageEnumSerializationTest). - Entity → domain mappers (
XxxEntity.toDomain()) live indata/local/db/mapper;domain/modelimports nodatatype. - All settings types (
AppSettings+ its 8 nested settings classes + ~14 enums) live indomain/settings. They are serialised by name/key bySettingsRepository(DataStore), so the package move changed nothing on disk (proven by the instrumentedSettingsRoundTripTest). - Every collaborator the use cases need is now a domain port, implemented in the layer that owns
the Android/data detail and wired by
@Bindsindi/RepositoryModule:domain/sender:SmsSender(impldata/sms/SmsSenderImpl),DefaultSmsAppChecker,SentSmsRecorder(implTelephonyReader),SenderNameProvider(impldata/sender/…Impl).domain/mms:MmsDispatcher(implMmsSender),OutgoingAttachmentStore, value typesMmsAttachment/MediaAttachmentSpec.domain/repository:OutgoingMessageMirror(implConversationMirror— narrow outgoing-write view; the 3 status receivers also use the port so interface-declared param defaults resolve), plus the existing repositories.domain/schedulerScheduledMessageScheduler(implsystem/…SchedulerImpl),domain/securityPanicStateProvider(implAppLockManager),domain/vaultVaultMover(implVaultManager),domain/locationLocationProvider+GeoLocation,domain/pdfPdfExporter+PdfExportResult,domain/backupBackupRestorer+RestoreResult,domain/settingsAppSettingsSource(implSettingsRepository).RetrySendUseCasereads viaConversationRepository.findMessageForResend(unguarded by-id lookup — re-dispatch never surfaces the body to the UI) instead of injectingMessageDao.
Ports that intentionally take an opaque String instead of an Android type (to keep domain/
Android-free): PdfExportResult.shareUri, BackupRestorer.restore(uriString),
SentSmsRecorder.insertSentSms(): String? — all round-trip Uri.toString() / Uri.parse() losslessly
at the UI/data boundary.
- The carrier sends the SMS to the modem.
- Android dispatches a
SMS_DELIVERbroadcast to SMS Tech (because it is the default SMS app declared in the manifest with theBROADCAST_SMSpermission). SmsDeliverReceiver.onReceiveis invoked. It callsgoAsync()to keep the process alive while the work runs on the application coroutine scope.- Messages are reconstructed from the PDU array via
Telephony.Sms.Intents.getMessagesFromIntent. - The sender is checked against the blocklist (
BlockedNumberRepository.isBlocked). - If allowed:
TelephonyReader.insertInboxSmswrites the row into the system SMS provider (otherwise the inbox would stay empty — this is a duty of the default app since KitKat).ConversationMirror.upsertIncomingSmswrites a typed, mirrored row into our SQLCipher-backed Room database, ensures the conversation exists, updateslastMessageAt,lastMessagePreviewand incrementsunreadCount.IncomingMessageNotifier.notifyIncomingposts aMessagingStylenotification with inline reply (RemoteInput) and mark-as-read actions, respecting the user's preview-visibility setting.
- UI builds an outgoing message in
ThreadViewModel.sendand callsSendSmsUseCase. - The use case:
- Verifies SMS Tech is still the default app.
- Skips recipients in the blocklist (defense in depth).
- Inserts the row in the system Sent box.
- Mirrors a
MessageEntitywithstatus = PENDINGinto Room. - Dispatches the message through
SmsSender.send, which callsSmsManager.sendMultipartTextMessagewithPendingIntents carrying the local Room id.
SmsSentReceiverandSmsDeliveredReceiverupdate the mirrored row toSENT/DELIVERED/FAILEDas the modem reports back.
ThreadViewModelenters Recording state when the user holds the mic button (push-to-talk).VoiceRecorderwrites an AAC/M4A clip tocache/voice_mms/(60 s / 300 KB hard caps).- On release without drag-to-cancel, the composer switches to Reviewing and
VoicePlaybackControllerplays the clip back through a single shared MediaPlayer instance. - Send button →
SendVoiceMmsUseCase:- Default-app guard, blocklist guard, then for each non-blocked recipient:
ConversationMirror.upsertOutgoingMmswrites an MMS row +AttachmentEntityreferencing the audio file by absolute path.MmsSender.sendVoiceMmsbuilds aSendReqPDU through the in-treecom.filestech.sms.pdu.*classes (ported from AOSP under Apache-2.0, renamed in v1.3.10 fromcom.google.android.mms.pdu.*to bypass the Android 10+ Hidden API blacklist), persists the encoded bytes tocache/mms_outgoing/, shares them via FileProvider, and hands the Uri toSmsManager.sendMultimediaMessagetogether with a resultPendingIntent.
- The OS routes the PDU to the carrier MMSC; SMS Tech does not touch HTTP / APN itself.
- Default-app guard, blocklist guard, then for each non-blocked recipient:
MmsSentReceiverflips the row toSENTorFAILEDbased on the broadcast resultCode and deletes the transient.pducache file.
- The carrier pushes a
WAP_PUSH_DELIVERintent.MmsWapPushReceiverparses them-notification.indPDU viaPduParser, extracts thecontentLocationURL +transactionId, and refuses anything larger than 1 MiB (defence-in-depth — SMS Tech only consumes ≤ 300 KB clips). The receiver resolves its Hilt dependencies on-demand viaEntryPointAccessors.fromApplicationrather than@AndroidEntryPointfield injection — the latter crashes silently during the Hilt-generated wrapper on certain Android 10 OEM ROMs when the receiver is dispatched at cold-start (noonReceiveever called, MMS dropped without a log). MmsDownloader.downloadcreates an empty file incache/mms_incoming/, shares it through FileProvider, and callsSmsManager.downloadMultimediaMessage. The OS performs the MMSC HTTP GET and writes theRetrieveConfPDU bytes into the file.MmsDownloadedReceiverparses the result withPduParser. The first non-presentation mediaPduPart(image / video / audio / file —application/smilandtext/*parts are skipped) is persisted tocache/mms_incoming/. The firsttext/plainpart is decoded as the user caption (UTF-8 fallback when the WAP "any-charset" MIBenum 0 is used).ConversationMirror.upsertIncomingMmsmirrors a typed Room row +AttachmentEntity, storing the caption verbatim inmessages.bodyand a derived preview label (🖼️/🎤/🎞️/📎if no caption / Subject is present) in the conversation list.IncomingMessageNotifier.notifyIncomingthen posts aMessagingStylenotification using the preview label as the displayed body — identical pipeline to incoming SMS.
- Database: SQLCipher 4 with a 32-byte random key. The key file lives at
files/db/master.keyand is wrapped by an AES-256-GCM key in the AndroidKeyStore (KeystoreManager.ALIAS_DB_MASTER). The raw key is wiped from memory immediately after SQLCipher consumes it. - Backups (
.smsbk): PBKDF2-HMAC-SHA512 derives a 32-byte key from the user's passphrase, combined with a fresh 16-byte salt. Iterations are calibrated at first run (~250 ms target). The resulting key encrypts the JSON payload via AES-256-GCM with a 12-byte IV and a 128-bit tag. - App lock PIN: PBKDF2-HMAC-SHA512 hash stored alongside the random salt and iteration count. Verification uses a constant-time comparison. After 5 consecutive failures, a monotonic exponential backoff blocks further attempts.
- Ktlint + Detekt + Android Lint must pass with zero warnings as errors on CI.
- Coverage target: ≥ 70 % on
domain/anddata/. - Strict null safety, no
!!outside test code. - All I/O on
Dispatchers.IO; structured concurrency only (noGlobalScope). - All user-facing strings live in
res/values/strings.xml(values-frmirror is the same set).