The application follows Clean Architecture with three layers. Dependencies point inward: presentation depends on domain, data depends on domain, domain depends on nothing.
+---------------------+
| Presentation | Compose UI, ViewModel, State
+---------------------+
|
v
+---------------------+
| Domain | UseCases, Models, Repository interfaces
+---------------------+
^
|
+---------------------+
| Data | Repository implementations, DataSources
+---------------------+
The domain layer contains business logic with zero Android framework dependencies (except android.net.Uri for file references).
Use Cases:
SignFileUseCase-- orchestrates the signing workflow: key check, file validation, stream signing, signature saveVerifyFileUseCase-- orchestrates verification: file stream, signature load, cryptographic verifyGetFileInfoUseCase-- resolves file metadata from URIGenerateKeyPairUseCase-- ensures signing key exists in KeyStore
Models:
SigningResult-- sealed class with Success/Error variants. Error variants carry typed information (FileNotFound, PermissionDenied, SigningFailed, etc.)FileInfo-- file metadata (URI, name, size, MIME type, display size)SigningHistoryEntry-- in-memory record of a completed signing operation
Repository Interfaces:
SigningRepository-- key generation, stream signing, stream verificationFileRepository-- file info resolution, stream opening, signature saving
Implements repository interfaces with Android SDK components.
KeystoreDataSource:
- Wraps Android KeyStore for ECDSA P-256 key operations
- Attempts StrongBox hardware backing, falls back to TEE
- Streaming sign/verify with buffer cleanup
- Singleton scope (single KeyStore instance)
FileDataSource:
- Wraps ContentResolver for file access
- Uses MediaStore for signature file creation on Android Q+
- Legacy file I/O fallback for older API levels
- Singleton scope
Jetpack Compose UI with unidirectional data flow.
MainViewModel:
- Manages
MainUiStateviaStateFlow - Delegates to use cases for business operations
- Uses
SavedStateHandleto survive process death (selected file URI only) - Cancellation support for long-running signing operations
State:
SigningUiState-- sealed interface: Idle, FileSelected, Signing, Success, ErrorVerificationUiState-- sealed interface: Idle, Verifying, Valid, Invalid, ErrorPermissionState-- enum: NotRequested, Granted, Denied, ShowRationale, PermanentlyDenied
Components:
StatusDisplay-- animated status card with live region for TalkBackFilePickerButton/SignButton-- primary action buttons with proper touch targetsAboutSheet/SigningHistorySheet-- modal bottom sheetsPermissionRationaleDialog-- permission explanation dialog
Hilt provides compile-time verified DI.
AppModule binds:
ContentResolver(from application context)FileRepositorytoFileRepositoryImplSigningRepositorytoSigningRepositoryImplSamplePdfGenerator(debug-only utility)
All data sources are @Singleton scoped. Use cases are unscoped (new instance per injection, stateless).
User taps "Sign"
-> MainViewModel.onSignFile()
-> SignFileUseCase.invoke(fileUri)
-> SigningRepository.hasSigningKey()
-> FileRepository.getFileInfo(uri) // validate size
-> FileRepository.openFileStream(uri) // get InputStream
-> SigningRepository.signStream(stream) // ECDSA sign
-> FileRepository.saveSignature(uri, bytes) // write .sig
<- SigningResult.Success(originalUri, signatureUri, base64)
<- _uiState.update { Success(...) }
UI recomposes with success status
- UI: Main thread (Compose)
- ViewModel:
viewModelScope(Main dispatcher, cancellable) - Repository:
Dispatchers.IOviawithContext - KeyStore operations: blocking on IO dispatcher
- File I/O: blocking on IO dispatcher
All coroutine cancellation is cooperative. signingJob reference allows user-initiated cancellation.