MOB-AND-001: Arquitectura Android MedTime¶
Identificador: MOB-AND-001 Version: 1.0.0 Fecha: 2025-12-08 Autor: SpecQueen Technical Division Estado: Borrador
- 1. Introduccion
- 1.1. Proposito
- 1.2. Alcance
- 1.3. Requisitos del Sistema
- 1.4. Referencias
- 2. Arquitectura General
- 2.1. Clean Architecture Overview
- 2.2. Estructura de Proyecto
- 2.3. Dependency Injection
- 2.4. Principios SOLID
- 3. Capa Domain
- 3.1. Entities
- 3.2. Use Cases
- 3.3. Repository Interfaces
- 3.4. Error Handling
- 4. Capa Data
- 4.1. Repository Implementations
- 4.2. Data Sources
- 4.3. Mappers
- 4.4. Cache Strategy
- 5. Capa Presentation
- 5.1. Jetpack Compose UI
- 5.2. ViewModels
- 5.3. Navigation
- 5.4. UI State Management
- 6. Persistencia Local
- 6.1. Room Database
- 6.2. Android Keystore
- 6.3. DataStore
- 6.4. Migraciones
- 7. Networking
- 7.1. API Client
- 7.2. Request/Response
- 7.3. Interceptors
- 7.4. Error Mapping
- 8. Cifrado E2E
- 8.1. Android Security Library
- 8.2. Key Management
- 8.3. Blob Encryption
- 8.4. Zero-Knowledge Flows
- 9. Offline-First Engine
- 9.1. Sync Manager
- 9.2. Conflict Resolution
- 9.3. Operation Queue
- 9.4. Connectivity Monitor
- 10. Push Notifications
- 10.1. FCM Registration
- 10.2. Notification Handling
- 10.3. Rich Notifications
- 10.4. Notification Channels
- 11. Widgets
- 11.1. Glance Integration
- 11.2. Widget State
- 11.3. Widget Types
- 11.4. Refresh Strategy
- 12. Wear OS
- 12.1. Data Layer API
- 12.2. Complications
- 12.3. Standalone Mode
- 12.4. Health Services
- 13. Testing
- 13.1. Unit Tests
- 13.2. UI Tests
- 13.3. Screenshot Tests
- 13.4. Mock Strategies
- 14. Dependencies
- 14.1. Gradle Version Catalog
- 14.2. Version Constraints
- 14.3. Security Audit
- 15. Build y CI/CD
- 15.1. Build Variants
- 15.2. ProGuard/R8
- 15.3. GitHub Actions
- 15.4. Play Store
1. Introduccion¶
1.1. Proposito¶
Este documento define la arquitectura tecnica de la aplicacion Android de MedTime. Establece los patrones, estructuras y decisiones arquitectonicas que guian el desarrollo de la app.
MedTime Android es una aplicacion de gestion de medicamentos que prioriza:
- Privacidad: Arquitectura Zero-Knowledge donde el servidor no puede leer datos del usuario
- Offline-First: Funcionalidad completa sin conexion a internet
- Seguridad: Cifrado E2E de todos los datos sensibles (PHI)
- UX: Interfaz intuitiva con Jetpack Compose moderno
1.2. Alcance¶
Este documento cubre:
- Arquitectura de software (Clean Architecture)
- Patrones de diseno (MVVM, Repository)
- Persistencia local (Room, Android Keystore)
- Networking (Retrofit/Ktor, Kotlin Coroutines)
- Cifrado (Android Security Library, Libsodium)
- Sincronizacion offline
- Extensiones (Widgets, Wear OS)
- Testing
1.3. Requisitos del Sistema¶
| Requisito | Valor | Justificacion |
|---|---|---|
| Android Minimo | 8.0 (API 26) | Security improvements, Keystore |
| Target SDK | 34 (Android 14) | Latest APIs |
| Kotlin | 1.9+ | K2 compiler, Context receivers |
| Dispositivos | Phone, Tablet | Universal app |
| Wear OS | 3.0+ | Companion y standalone |
1.4. Referencias¶
| Documento | Descripcion |
|---|---|
| 01-arquitectura-tecnica.md | Arquitectura base Clean Architecture |
| 04-seguridad-cliente.md | Seguridad Zero-Knowledge, E2E |
| 07-testing-strategy.md | Estrategia de testing |
| MTS-OFF-001 | Modo offline |
| MTS-WCH-001 | Wear OS |
| MTS-WDG-001 | Widgets |
2. Arquitectura General¶
2.1. Clean Architecture Overview¶
MedTime Android implementa Clean Architecture con tres capas principales:
┌─────────────────────────────────────────────────────────────────┐
│ PRESENTATION LAYER │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────────┐ │
│ │ Compose │ │ ViewModels │ │ Navigation │ │
│ │ (UI) │ │ (MVVM) │ │ (NavController) │ │
│ └─────────────┘ └─────────────┘ └─────────────────────────┘ │
├─────────────────────────────────────────────────────────────────┤
│ DOMAIN LAYER │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────────┐ │
│ │ Entities │ │ Use Cases │ │ Repository Interfaces │ │
│ │ (Models) │ │ (Interactor)│ │ (Ports) │ │
│ └─────────────┘ └─────────────┘ └─────────────────────────┘ │
├─────────────────────────────────────────────────────────────────┤
│ DATA LAYER │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────────┐ │
│ │Repositories │ │Data Sources │ │ Mappers │ │
│ │ (Adapters) │ │(Local/Remote│ │ (DTO <-> Entity) │ │
│ └─────────────┘ └─────────────┘ └─────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
Flujo de dependencias:
Presentation → Domain ← Data
- Presentation depende de Domain (usa Use Cases)
- Data depende de Domain (implementa Repository Interfaces)
- Domain NO depende de nadie (capa central independiente)
2.2. Estructura de Proyecto¶
app/
├── src/main/
│ ├── java/com/medtime/
│ │ ├── MedTimeApp.kt # Application class
│ │ │
│ │ ├── di/ # Dependency Injection
│ │ │ ├── AppModule.kt
│ │ │ ├── DataModule.kt
│ │ │ ├── DomainModule.kt
│ │ │ └── NetworkModule.kt
│ │ │
│ │ ├── domain/
│ │ │ ├── entities/
│ │ │ │ ├── Medication.kt
│ │ │ │ ├── Schedule.kt
│ │ │ │ ├── DoseLog.kt
│ │ │ │ ├── User.kt
│ │ │ │ ├── ClinicalAnalysis.kt
│ │ │ │ └── Prescription.kt
│ │ │ │
│ │ │ ├── usecases/
│ │ │ │ ├── medications/
│ │ │ │ │ ├── GetMedicationsUseCase.kt
│ │ │ │ │ ├── AddMedicationUseCase.kt
│ │ │ │ │ ├── UpdateMedicationUseCase.kt
│ │ │ │ │ └── DeleteMedicationUseCase.kt
│ │ │ │ ├── schedules/
│ │ │ │ │ ├── GetSchedulesUseCase.kt
│ │ │ │ │ └── UpdateScheduleUseCase.kt
│ │ │ │ ├── doselogs/
│ │ │ │ │ ├── LogDoseUseCase.kt
│ │ │ │ │ └── GetDoseHistoryUseCase.kt
│ │ │ │ ├── sync/
│ │ │ │ │ ├── SyncDataUseCase.kt
│ │ │ │ │ └── ResolveConflictUseCase.kt
│ │ │ │ └── auth/
│ │ │ │ ├── LoginUseCase.kt
│ │ │ │ ├── LogoutUseCase.kt
│ │ │ │ └── RefreshTokenUseCase.kt
│ │ │ │
│ │ │ ├── repositories/
│ │ │ │ ├── MedicationRepository.kt
│ │ │ │ ├── ScheduleRepository.kt
│ │ │ │ ├── DoseLogRepository.kt
│ │ │ │ ├── UserRepository.kt
│ │ │ │ ├── SyncRepository.kt
│ │ │ │ └── AuthRepository.kt
│ │ │ │
│ │ │ └── errors/
│ │ │ ├── DomainError.kt
│ │ │ ├── ValidationError.kt
│ │ │ └── SyncError.kt
│ │ │
│ │ ├── data/
│ │ │ ├── repositories/
│ │ │ │ ├── MedicationRepositoryImpl.kt
│ │ │ │ ├── ScheduleRepositoryImpl.kt
│ │ │ │ ├── DoseLogRepositoryImpl.kt
│ │ │ │ ├── UserRepositoryImpl.kt
│ │ │ │ ├── SyncRepositoryImpl.kt
│ │ │ │ └── AuthRepositoryImpl.kt
│ │ │ │
│ │ │ ├── datasources/
│ │ │ │ ├── local/
│ │ │ │ │ ├── MedTimeDatabase.kt
│ │ │ │ │ ├── dao/
│ │ │ │ │ │ ├── MedicationDao.kt
│ │ │ │ │ │ ├── ScheduleDao.kt
│ │ │ │ │ │ ├── DoseLogDao.kt
│ │ │ │ │ │ └── SyncQueueDao.kt
│ │ │ │ │ ├── KeystoreManager.kt
│ │ │ │ │ └── DataStoreManager.kt
│ │ │ │ └── remote/
│ │ │ │ ├── ApiClient.kt
│ │ │ │ ├── AuthApiService.kt
│ │ │ │ ├── SyncApiService.kt
│ │ │ │ └── CatalogApiService.kt
│ │ │ │
│ │ │ ├── models/
│ │ │ │ ├── dto/
│ │ │ │ │ ├── MedicationDto.kt
│ │ │ │ │ ├── ScheduleDto.kt
│ │ │ │ │ ├── SyncBlobDto.kt
│ │ │ │ │ └── AuthResponseDto.kt
│ │ │ │ └── entities/
│ │ │ │ ├── MedicationEntity.kt # Room
│ │ │ │ ├── ScheduleEntity.kt
│ │ │ │ ├── DoseLogEntity.kt
│ │ │ │ └── SyncQueueEntity.kt
│ │ │ │
│ │ │ └── mappers/
│ │ │ ├── MedicationMapper.kt
│ │ │ ├── ScheduleMapper.kt
│ │ │ ├── DoseLogMapper.kt
│ │ │ └── SyncBlobMapper.kt
│ │ │
│ │ ├── presentation/
│ │ │ ├── screens/
│ │ │ │ ├── home/
│ │ │ │ │ ├── HomeScreen.kt
│ │ │ │ │ ├── HomeViewModel.kt
│ │ │ │ │ └── components/
│ │ │ │ │ ├── TodayDosesCard.kt
│ │ │ │ │ ├── NextDoseWidget.kt
│ │ │ │ │ └── AdherenceRing.kt
│ │ │ │ ├── medications/
│ │ │ │ │ ├── MedicationListScreen.kt
│ │ │ │ │ ├── MedicationDetailScreen.kt
│ │ │ │ │ ├── AddMedicationScreen.kt
│ │ │ │ │ └── MedicationViewModel.kt
│ │ │ │ ├── calendar/
│ │ │ │ │ ├── CalendarScreen.kt
│ │ │ │ │ ├── DayDetailScreen.kt
│ │ │ │ │ └── CalendarViewModel.kt
│ │ │ │ ├── profile/
│ │ │ │ │ ├── ProfileScreen.kt
│ │ │ │ │ ├── SettingsScreen.kt
│ │ │ │ │ └── ProfileViewModel.kt
│ │ │ │ └── auth/
│ │ │ │ ├── LoginScreen.kt
│ │ │ │ ├── RegisterScreen.kt
│ │ │ │ └── AuthViewModel.kt
│ │ │ │
│ │ │ ├── navigation/
│ │ │ │ ├── NavGraph.kt
│ │ │ │ ├── Destinations.kt
│ │ │ │ └── BottomNavigation.kt
│ │ │ │
│ │ │ ├── components/
│ │ │ │ ├── MedicationCard.kt
│ │ │ │ ├── DoseButton.kt
│ │ │ │ ├── TimeSlotPicker.kt
│ │ │ │ ├── LoadingView.kt
│ │ │ │ └── ErrorView.kt
│ │ │ │
│ │ │ └── theme/
│ │ │ ├── Color.kt
│ │ │ ├── Typography.kt
│ │ │ ├── Theme.kt
│ │ │ └── Shapes.kt
│ │ │
│ │ ├── core/
│ │ │ ├── crypto/
│ │ │ │ ├── CryptoManager.kt
│ │ │ │ ├── KeyDerivation.kt
│ │ │ │ ├── BlobEncryption.kt
│ │ │ │ └── SecureRandom.kt
│ │ │ │
│ │ │ ├── sync/
│ │ │ │ ├── SyncManager.kt
│ │ │ │ ├── SyncQueue.kt
│ │ │ │ ├── ConflictResolver.kt
│ │ │ │ └── ConnectivityMonitor.kt
│ │ │ │
│ │ │ ├── notifications/
│ │ │ │ ├── NotificationManager.kt
│ │ │ │ ├── NotificationScheduler.kt
│ │ │ │ ├── NotificationChannels.kt
│ │ │ │ └── FCMService.kt
│ │ │ │
│ │ │ ├── workers/
│ │ │ │ ├── SyncWorker.kt
│ │ │ │ ├── ReminderWorker.kt
│ │ │ │ └── WorkerModule.kt
│ │ │ │
│ │ │ └── extensions/
│ │ │ ├── DateExtensions.kt
│ │ │ ├── StringExtensions.kt
│ │ │ ├── FlowExtensions.kt
│ │ │ └── ContextExtensions.kt
│ │ │
│ │ └── MainActivity.kt
│ │
│ └── res/
│ ├── values/
│ ├── drawable/
│ └── xml/
│
├── widget/ # Glance widgets module
│ └── src/main/java/com/medtime/widget/
│ ├── NextDoseWidget.kt
│ ├── AdherenceWidget.kt
│ └── WidgetStateManager.kt
│
├── wear/ # Wear OS module
│ └── src/main/java/com/medtime/wear/
│ ├── WearApp.kt
│ ├── WatchHomeScreen.kt
│ ├── ComplicationService.kt
│ └── DataLayerManager.kt
│
└── build.gradle.kts
2.3. Dependency Injection¶
MedTime utiliza Hilt para DI (oficial de Android, compile-time, type-safe):
// di/AppModule.kt
@Module
@InstallIn(SingletonComponent::class)
object AppModule {
@Provides
@Singleton
fun provideContext(@ApplicationContext context: Context): Context = context
@Provides
@Singleton
fun provideCryptoManager(
keystoreManager: KeystoreManager
): CryptoManager = CryptoManagerImpl(keystoreManager)
@Provides
@Singleton
fun provideSyncManager(
syncUseCase: SyncDataUseCase,
connectivityMonitor: ConnectivityMonitor,
workManager: WorkManager
): SyncManager = SyncManagerImpl(syncUseCase, connectivityMonitor, workManager)
@Provides
@Singleton
fun provideConnectivityMonitor(
@ApplicationContext context: Context
): ConnectivityMonitor = ConnectivityMonitorImpl(context)
}
// di/DataModule.kt
@Module
@InstallIn(SingletonComponent::class)
object DataModule {
@Provides
@Singleton
fun provideDatabase(
@ApplicationContext context: Context
): MedTimeDatabase = Room.databaseBuilder(
context,
MedTimeDatabase::class.java,
"medtime.db"
)
.fallbackToDestructiveMigration()
.build()
@Provides
fun provideMedicationDao(database: MedTimeDatabase): MedicationDao =
database.medicationDao()
@Provides
fun provideScheduleDao(database: MedTimeDatabase): ScheduleDao =
database.scheduleDao()
@Provides
fun provideDoseLogDao(database: MedTimeDatabase): DoseLogDao =
database.doseLogDao()
@Provides
fun provideSyncQueueDao(database: MedTimeDatabase): SyncQueueDao =
database.syncQueueDao()
@Provides
@Singleton
fun provideKeystoreManager(
@ApplicationContext context: Context
): KeystoreManager = KeystoreManagerImpl(context)
@Provides
@Singleton
fun provideDataStore(
@ApplicationContext context: Context
): DataStore<Preferences> = PreferenceDataStoreFactory.create(
produceFile = { context.preferencesDataStoreFile("medtime_prefs") }
)
}
// di/NetworkModule.kt
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides
@Singleton
fun provideOkHttpClient(
authInterceptor: AuthInterceptor,
loggingInterceptor: HttpLoggingInterceptor
): OkHttpClient = OkHttpClient.Builder()
.addInterceptor(authInterceptor)
.addInterceptor(loggingInterceptor)
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.certificatePinner(createCertificatePinner())
.build()
@Provides
@Singleton
fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit = Retrofit.Builder()
.baseUrl(BuildConfig.API_BASE_URL)
.client(okHttpClient)
.addConverterFactory(MoshiConverterFactory.create())
.build()
@Provides
@Singleton
fun provideAuthApiService(retrofit: Retrofit): AuthApiService =
retrofit.create(AuthApiService::class.java)
@Provides
@Singleton
fun provideSyncApiService(retrofit: Retrofit): SyncApiService =
retrofit.create(SyncApiService::class.java)
private fun createCertificatePinner(): CertificatePinner =
CertificatePinner.Builder()
.add("api.medtime.app", "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")
.build()
}
// di/DomainModule.kt
@Module
@InstallIn(SingletonComponent::class)
object DomainModule {
@Provides
fun provideGetMedicationsUseCase(
repository: MedicationRepository
): GetMedicationsUseCase = GetMedicationsUseCase(repository)
@Provides
fun provideLogDoseUseCase(
doseLogRepository: DoseLogRepository,
medicationRepository: MedicationRepository,
syncManager: SyncManager
): LogDoseUseCase = LogDoseUseCase(
doseLogRepository,
medicationRepository,
syncManager
)
@Provides
fun provideSyncDataUseCase(
syncRepository: SyncRepository,
conflictResolver: ConflictResolver
): SyncDataUseCase = SyncDataUseCase(syncRepository, conflictResolver)
}
// Usage in ViewModel
@HiltViewModel
class MedicationViewModel @Inject constructor(
private val getMedicationsUseCase: GetMedicationsUseCase,
private val syncManager: SyncManager
) : ViewModel() {
private val _uiState = MutableStateFlow(MedicationUiState())
val uiState: StateFlow<MedicationUiState> = _uiState.asStateFlow()
init {
loadMedications()
}
private fun loadMedications() {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) }
try {
val medications = getMedicationsUseCase.execute()
_uiState.update {
it.copy(
isLoading = false,
medications = medications
)
}
} catch (e: DomainError) {
_uiState.update {
it.copy(
isLoading = false,
error = e
)
}
}
}
}
}
2.4. Principios SOLID¶
| Principio | Aplicacion en MedTime |
|---|---|
| Single Responsibility | Cada UseCase tiene una sola responsabilidad |
| Open/Closed | Interfaces permiten extension sin modificacion |
| Liskov Substitution | Repositories son intercambiables (mock/real) |
| Interface Segregation | Interfaces pequenas y especificas |
| Dependency Inversion | Domain define interfaces, Data implementa |
3. Capa Domain¶
3.1. Entities¶
Las entities son modelos de negocio puros, sin dependencias de frameworks:
// domain/entities/Medication.kt
import java.time.LocalDate
import java.time.LocalDateTime
import java.util.UUID
data class Medication(
val id: UUID,
val name: String,
val genericName: String? = null,
val dosage: Dosage,
val form: MedicationForm,
val route: AdministrationRoute,
val frequency: DosageFrequency,
val instructions: String? = null,
val startDate: LocalDate,
val endDate: LocalDate? = null,
val isActive: Boolean,
val requiresPrescription: Boolean,
val inventory: Inventory? = null,
val reminders: List<Reminder> = emptyList(),
val createdAt: LocalDateTime,
val updatedAt: LocalDateTime,
val version: Long
) {
data class Dosage(
val amount: Double,
val unit: DosageUnit
) {
val displayString: String
get() = "${amount.formatAmount()} ${unit.abbreviation}"
}
data class Inventory(
val currentQuantity: Double,
val minimumQuantity: Double,
val packageSize: Int? = null,
val expirationDate: LocalDate? = null
) {
val isLow: Boolean
get() = currentQuantity <= minimumQuantity
}
}
// domain/entities/DoseLog.kt
data class DoseLog(
val id: UUID,
val medicationId: UUID,
val scheduledTime: LocalDateTime,
val takenTime: LocalDateTime? = null,
val status: DoseStatus,
val skipReason: SkipReason? = null,
val notes: String? = null,
val createdAt: LocalDateTime,
val updatedAt: LocalDateTime,
val version: Long
)
enum class DoseStatus {
PENDING, TAKEN, SKIPPED, MISSED, LATE
}
enum class SkipReason {
SIDE_EFFECTS,
FORGOT_TO_TAKE,
OUT_OF_MEDICATION,
DOCTOR_ADVISED,
FELT_BETTER,
OTHER
}
// domain/entities/Schedule.kt
data class Schedule(
val id: UUID,
val medicationId: UUID,
val times: List<ScheduleTime>,
val daysOfWeek: Set<DayOfWeek>? = null, // null = every day
val isActive: Boolean,
val createdAt: LocalDateTime,
val updatedAt: LocalDateTime,
val version: Long
) {
data class ScheduleTime(
val hour: Int,
val minute: Int,
val label: String? = null
) {
val localTime: java.time.LocalTime
get() = java.time.LocalTime.of(hour, minute)
}
}
// Enums compartidos
enum class MedicationForm(val icon: String) {
TABLET("pill"),
CAPSULE("capsule"),
LIQUID("drop"),
INJECTION("syringe"),
CREAM("tube"),
PATCH("bandage"),
INHALER("wind"),
DROPS("drop"),
OTHER("pills")
}
enum class DosageUnit(val abbreviation: String) {
MG("mg"),
G("g"),
ML("ml"),
UNITS("units"),
DROPS("drops"),
PUFFS("puffs"),
TABLETS("tablets"),
CAPSULES("capsules")
}
enum class AdministrationRoute {
ORAL, SUBLINGUAL, TOPICAL, INTRAVENOUS, INTRAMUSCULAR,
SUBCUTANEOUS, INHALED, RECTAL, OPHTHALMIC, OTIC, NASAL
}
sealed class DosageFrequency {
data class TimesPerDay(val times: Int) : DosageFrequency()
data class EveryXHours(val hours: Int) : DosageFrequency()
data class Custom(val times: List<java.time.LocalTime>) : DosageFrequency()
object AsNeeded : DosageFrequency()
val displayString: String
get() = when (this) {
is TimesPerDay -> "${times}x al dia"
is EveryXHours -> "Cada $hours horas"
is Custom -> "Horario personalizado"
is AsNeeded -> "Segun necesidad"
}
}
3.2. Use Cases¶
Los Use Cases encapsulan logica de negocio:
// domain/usecases/medications/GetMedicationsUseCase.kt
class GetMedicationsUseCase @Inject constructor(
private val repository: MedicationRepository
) {
data class Filter(
val isActive: Boolean? = null,
val searchText: String? = null,
val form: MedicationForm? = null
)
suspend fun execute(filter: Filter? = null): List<Medication> {
var medications = repository.getAllMedications()
filter?.let { f ->
f.isActive?.let { active ->
medications = medications.filter { it.isActive == active }
}
f.searchText?.let { text ->
if (text.isNotEmpty()) {
medications = medications.filter {
it.name.contains(text, ignoreCase = true) ||
it.genericName?.contains(text, ignoreCase = true) == true
}
}
}
f.form?.let { form ->
medications = medications.filter { it.form == form }
}
}
return medications.sortedBy { it.name }
}
}
// domain/usecases/doselogs/LogDoseUseCase.kt
class LogDoseUseCase @Inject constructor(
private val doseLogRepository: DoseLogRepository,
private val medicationRepository: MedicationRepository,
private val syncManager: SyncManager
) {
suspend fun execute(
medicationId: UUID,
scheduledTime: LocalDateTime,
status: DoseStatus,
takenTime: LocalDateTime? = null,
skipReason: SkipReason? = null,
notes: String? = null
): DoseLog {
// Validar que el medicamento existe
val medication = medicationRepository.getMedication(medicationId)
?: throw DomainError.MedicationNotFound(medicationId)
// Crear el log
val now = LocalDateTime.now()
val doseLog = DoseLog(
id = UUID.randomUUID(),
medicationId = medicationId,
scheduledTime = scheduledTime,
takenTime = takenTime ?: if (status == DoseStatus.TAKEN) now else null,
status = status,
skipReason = skipReason,
notes = notes,
createdAt = now,
updatedAt = now,
version = 1
)
// Guardar localmente
val savedLog = doseLogRepository.save(doseLog)
// Actualizar inventario si se tomo la dosis
if (status == DoseStatus.TAKEN) {
medication.inventory?.let { inventory ->
val newQuantity = inventory.currentQuantity - medication.dosage.amount
val updatedMedication = medication.copy(
inventory = inventory.copy(currentQuantity = newQuantity),
updatedAt = now,
version = medication.version + 1
)
medicationRepository.save(updatedMedication)
}
}
// Encolar para sync
syncManager.enqueue(SyncOperation.DoseLog(savedLog))
return savedLog
}
}
// domain/usecases/sync/SyncDataUseCase.kt
class SyncDataUseCase @Inject constructor(
private val syncRepository: SyncRepository,
private val conflictResolver: ConflictResolver
) {
suspend fun execute(): SyncResult {
// 1. Obtener cambios locales pendientes
val pendingChanges = syncRepository.getPendingChanges()
// 2. Obtener version del servidor
val serverVersion = syncRepository.getServerVersion()
val localVersion = syncRepository.getLocalVersion()
var uploaded = 0
var downloaded = 0
val conflicts = mutableListOf<SyncConflict>()
val errors = mutableListOf<SyncError>()
// 3. Subir cambios locales
pendingChanges.forEach { change ->
try {
syncRepository.push(change)
uploaded++
} catch (e: SyncError.Conflict) {
conflicts.add(e.conflict)
} catch (e: SyncError) {
errors.add(e)
}
}
// 4. Descargar cambios del servidor
if (serverVersion > localVersion) {
val serverChanges = syncRepository.pull(since = localVersion)
serverChanges.forEach { change ->
try {
syncRepository.applyServerChange(change)
downloaded++
} catch (e: SyncError) {
errors.add(e)
}
}
}
// 5. Auto-resolver conflictos simples (LWW)
conflicts.filter { conflictResolver.canAutoResolve(it) }.forEach { conflict ->
val resolution = conflictResolver.autoResolve(conflict)
resolveConflict(conflict, resolution)
}
return SyncResult(
uploaded = uploaded,
downloaded = downloaded,
conflicts = conflicts.filterNot { conflictResolver.canAutoResolve(it) },
errors = errors
)
}
suspend fun resolveConflict(conflict: SyncConflict, resolution: ConflictResolution) {
syncRepository.resolveConflict(conflict, resolution)
}
}
data class SyncResult(
val uploaded: Int,
val downloaded: Int,
val conflicts: List<SyncConflict>,
val errors: List<SyncError>
)
data class SyncConflict(
val entityType: EntityType,
val entityId: UUID,
val localVersion: Long,
val serverVersion: Long,
val localData: ByteArray,
val serverData: ByteArray
)
sealed class ConflictResolution {
object KeepLocal : ConflictResolution()
object KeepServer : ConflictResolution()
data class Merge(val mergedData: ByteArray) : ConflictResolution()
}
3.3. Repository Interfaces¶
Los Repository Interfaces definen contratos que la capa Data debe implementar:
// domain/repositories/MedicationRepository.kt
interface MedicationRepository {
suspend fun getAllMedications(): List<Medication>
suspend fun getMedication(id: UUID): Medication?
suspend fun save(medication: Medication): Medication
suspend fun delete(medication: Medication)
suspend fun search(query: String): List<Medication>
}
// domain/repositories/DoseLogRepository.kt
interface DoseLogRepository {
suspend fun getLogs(medicationId: UUID, dateRange: ClosedRange<LocalDate>?): List<DoseLog>
suspend fun getLog(id: UUID): DoseLog?
suspend fun save(log: DoseLog): DoseLog
suspend fun delete(log: DoseLog)
suspend fun getPendingDoses(date: LocalDate): List<DoseLog>
suspend fun getAdherenceStats(medicationId: UUID, days: Int): AdherenceStats
}
data class AdherenceStats(
val totalDoses: Int,
val takenDoses: Int,
val missedDoses: Int,
val skippedDoses: Int
) {
val adherenceRate: Double
get() = if (totalDoses > 0) takenDoses.toDouble() / totalDoses else 0.0
}
// domain/repositories/SyncRepository.kt
interface SyncRepository {
suspend fun getPendingChanges(): List<SyncChange>
suspend fun getServerVersion(): Long
suspend fun getLocalVersion(): Long
suspend fun push(change: SyncChange)
suspend fun pull(since: Long): List<SyncChange>
suspend fun applyServerChange(change: SyncChange)
suspend fun resolveConflict(conflict: SyncConflict, resolution: ConflictResolution)
}
data class SyncChange(
val entityType: EntityType,
val entityId: UUID,
val operation: SyncOperationType,
val encryptedBlob: ByteArray,
val version: Long,
val timestamp: LocalDateTime
)
enum class SyncOperationType { CREATE, UPDATE, DELETE }
enum class EntityType {
MEDICATION, SCHEDULE, DOSE_LOG, CLINICAL_ANALYSIS, PRESCRIPTION
}
3.4. Error Handling¶
// domain/errors/DomainError.kt
sealed class DomainError : Exception() {
// Entity errors
data class MedicationNotFound(val id: UUID) : DomainError()
data class ScheduleNotFound(val id: UUID) : DomainError()
data class DoseLogNotFound(val id: UUID) : DomainError()
object UserNotFound : DomainError()
// Validation errors
data class InvalidDosage(val reason: String) : DomainError()
data class InvalidSchedule(val reason: String) : DomainError()
object InvalidDateRange : DomainError()
// Business rule violations
data class MedicationAlreadyExists(val name: String) : DomainError()
object CannotDeleteActiveMedication : DomainError()
data class ScheduleConflict(val existingTime: LocalDateTime) : DomainError()
// Sync errors
data class SyncFailed(val underlying: Throwable) : DomainError()
data class ConflictDetected(val conflict: SyncConflict) : DomainError()
object OfflineOperationFailed : DomainError()
// Crypto errors
object EncryptionFailed : DomainError()
object DecryptionFailed : DomainError()
object KeyNotFound : DomainError()
// Generic
data class Unknown(val underlying: Throwable) : DomainError()
override val message: String
get() = when (this) {
is MedicationNotFound -> "Medicamento no encontrado: $id"
is InvalidDosage -> "Dosificacion invalida: $reason"
is SyncFailed -> "Error de sincronizacion: ${underlying.message}"
is EncryptionFailed -> "Error al cifrar datos"
is DecryptionFailed -> "Error al descifrar datos"
else -> "Error desconocido"
}
val recoverySuggestion: String?
get() = when (this) {
is SyncFailed -> "Verifica tu conexion a internet e intenta de nuevo"
is OfflineOperationFailed -> "Esta operacion se completara cuando tengas conexion"
is DecryptionFailed -> "Verifica que tu sesion este activa"
else -> null
}
}
4. Capa Data¶
4.1. Repository Implementations¶
// data/repositories/MedicationRepositoryImpl.kt
class MedicationRepositoryImpl @Inject constructor(
private val medicationDao: MedicationDao,
private val apiService: MedicationApiService,
private val cryptoManager: CryptoManager,
private val mapper: MedicationMapper,
private val syncQueueDao: SyncQueueDao
) : MedicationRepository {
override suspend fun getAllMedications(): List<Medication> {
// Siempre lee de local (offline-first)
return medicationDao.getAll().map { entity ->
// Descifrar blob
val decryptedData = cryptoManager.decrypt(entity.encryptedBlob)
mapper.toDomain(entity, decryptedData)
}
}
override suspend fun getMedication(id: UUID): Medication? {
return medicationDao.getById(id.toString())?.let { entity ->
val decryptedData = cryptoManager.decrypt(entity.encryptedBlob)
mapper.toDomain(entity, decryptedData)
}
}
override suspend fun save(medication: Medication): Medication {
// 1. Cifrar datos sensibles
val sensitiveData = mapper.toSensitiveData(medication)
val encryptedBlob = cryptoManager.encrypt(sensitiveData)
// 2. Crear/actualizar entity
val entity = mapper.toEntity(medication, encryptedBlob)
// 3. Guardar localmente
medicationDao.insertOrUpdate(entity)
// 4. Marcar para sync
syncQueueDao.insert(
SyncQueueEntity(
id = UUID.randomUUID().toString(),
entityType = EntityType.MEDICATION.name,
entityId = medication.id.toString(),
operation = SyncOperationType.UPDATE.name,
encryptedBlob = encryptedBlob,
createdAt = System.currentTimeMillis(),
retryCount = 0
)
)
return medication
}
override suspend fun delete(medication: Medication) {
// 1. Soft delete local
medicationDao.delete(medication.id.toString())
// 2. Marcar para sync
syncQueueDao.insert(
SyncQueueEntity(
id = UUID.randomUUID().toString(),
entityType = EntityType.MEDICATION.name,
entityId = medication.id.toString(),
operation = SyncOperationType.DELETE.name,
encryptedBlob = ByteArray(0),
createdAt = System.currentTimeMillis(),
retryCount = 0
)
)
}
override suspend fun search(query: String): List<Medication> {
// Busqueda local en campos no cifrados
return medicationDao.search("%$query%").map { entity ->
val decryptedData = cryptoManager.decrypt(entity.encryptedBlob)
mapper.toDomain(entity, decryptedData)
}
}
}
4.2. Data Sources¶
// data/datasources/local/MedTimeDatabase.kt
@Database(
entities = [
MedicationEntity::class,
ScheduleEntity::class,
DoseLogEntity::class,
SyncQueueEntity::class,
CacheEntity::class
],
version = 1,
exportSchema = true
)
@TypeConverters(Converters::class)
abstract class MedTimeDatabase : RoomDatabase() {
abstract fun medicationDao(): MedicationDao
abstract fun scheduleDao(): ScheduleDao
abstract fun doseLogDao(): DoseLogDao
abstract fun syncQueueDao(): SyncQueueDao
}
// data/datasources/local/dao/MedicationDao.kt
@Dao
interface MedicationDao {
@Query("SELECT * FROM medications WHERE is_deleted = 0")
suspend fun getAll(): List<MedicationEntity>
@Query("SELECT * FROM medications WHERE id = :id AND is_deleted = 0")
suspend fun getById(id: String): MedicationEntity?
@Query("SELECT * FROM medications WHERE name_searchable LIKE :query AND is_deleted = 0")
suspend fun search(query: String): List<MedicationEntity>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertOrUpdate(entity: MedicationEntity)
@Query("UPDATE medications SET is_deleted = 1, updated_at = :timestamp WHERE id = :id")
suspend fun delete(id: String, timestamp: Long = System.currentTimeMillis())
@Query("SELECT * FROM medications WHERE updated_at > :since AND is_deleted = 0")
suspend fun getModifiedSince(since: Long): List<MedicationEntity>
}
// data/datasources/local/dao/SyncQueueDao.kt
@Dao
interface SyncQueueDao {
@Query("SELECT * FROM sync_queue WHERE retry_count < :maxRetries ORDER BY created_at ASC")
suspend fun getPending(maxRetries: Int = 3): List<SyncQueueEntity>
@Insert
suspend fun insert(entity: SyncQueueEntity)
@Query("DELETE FROM sync_queue WHERE entity_id = :entityId")
suspend fun delete(entityId: String)
@Query("UPDATE sync_queue SET retry_count = retry_count + 1 WHERE id = :id")
suspend fun incrementRetry(id: String)
@Query("UPDATE sync_queue SET retry_count = 0")
suspend fun resetRetries()
}
// data/datasources/remote/ApiClient.kt
interface AuthApiService {
@POST("v1/auth/login")
suspend fun login(@Body request: LoginRequest): Response<AuthResponse>
@POST("v1/auth/register")
suspend fun register(@Body request: RegisterRequest): Response<AuthResponse>
@POST("v1/auth/refresh")
suspend fun refreshToken(): Response<AuthResponse>
@POST("v1/auth/logout")
suspend fun logout(): Response<Unit>
}
interface SyncApiService {
@POST("v1/sync/push")
suspend fun push(@Body request: PushRequest): Response<PushResponse>
@GET("v1/sync/pull")
suspend fun pull(
@Query("since_version") sinceVersion: Long,
@Query("device_id") deviceId: String
): Response<PullResponse>
}
interface CatalogApiService {
@GET("v1/catalog/medications/search")
suspend fun searchMedications(
@Query("q") query: String,
@Query("page") page: Int,
@Query("page_size") pageSize: Int,
@Header("X-Privacy-Consent") consent: String = "accepted"
): Response<SearchResponse>
@GET("v1/catalog/medications/{id}")
suspend fun getMedication(@Path("id") id: String): Response<MedicationDto>
@GET("v1/catalog/bundle")
suspend fun getCatalogBundle(): Response<CatalogBundleResponse>
}
4.3. Mappers¶
// data/mappers/MedicationMapper.kt
class MedicationMapper @Inject constructor() {
fun toDomain(entity: MedicationEntity, decryptedData: ByteArray): Medication {
val sensitiveData = Json.decodeFromString<MedicationSensitiveData>(
decryptedData.decodeToString()
)
return Medication(
id = UUID.fromString(entity.id),
name = sensitiveData.name,
genericName = sensitiveData.genericName,
dosage = Medication.Dosage(
amount = sensitiveData.dosageAmount,
unit = DosageUnit.valueOf(sensitiveData.dosageUnit)
),
form = MedicationForm.valueOf(entity.form),
route = AdministrationRoute.valueOf(entity.route),
frequency = parseDosageFrequency(sensitiveData.frequency),
instructions = sensitiveData.instructions,
startDate = LocalDate.parse(entity.startDate),
endDate = entity.endDate?.let { LocalDate.parse(it) },
isActive = entity.isActive,
requiresPrescription = entity.requiresPrescription,
inventory = sensitiveData.inventory?.let {
Medication.Inventory(
currentQuantity = it.currentQuantity,
minimumQuantity = it.minimumQuantity,
packageSize = it.packageSize,
expirationDate = it.expirationDate?.let { d -> LocalDate.parse(d) }
)
},
reminders = emptyList(), // Loaded separately
createdAt = LocalDateTime.ofInstant(
Instant.ofEpochMilli(entity.createdAt),
ZoneId.systemDefault()
),
updatedAt = LocalDateTime.ofInstant(
Instant.ofEpochMilli(entity.updatedAt),
ZoneId.systemDefault()
),
version = entity.version
)
}
fun toEntity(medication: Medication, encryptedBlob: ByteArray): MedicationEntity {
return MedicationEntity(
id = medication.id.toString(),
nameSearchable = medication.name.lowercase(),
form = medication.form.name,
route = medication.route.name,
startDate = medication.startDate.toString(),
endDate = medication.endDate?.toString(),
isActive = medication.isActive,
requiresPrescription = medication.requiresPrescription,
encryptedBlob = encryptedBlob,
createdAt = medication.createdAt.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli(),
updatedAt = medication.updatedAt.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli(),
version = medication.version,
isDeleted = false
)
}
fun toSensitiveData(medication: Medication): ByteArray {
val data = MedicationSensitiveData(
name = medication.name,
genericName = medication.genericName,
dosageAmount = medication.dosage.amount,
dosageUnit = medication.dosage.unit.name,
frequency = serializeDosageFrequency(medication.frequency),
instructions = medication.instructions,
inventory = medication.inventory?.let {
InventorySensitiveData(
currentQuantity = it.currentQuantity,
minimumQuantity = it.minimumQuantity,
packageSize = it.packageSize,
expirationDate = it.expirationDate?.toString()
)
}
)
return Json.encodeToString(data).toByteArray()
}
private fun parseDosageFrequency(value: String): DosageFrequency {
return when {
value.startsWith("times_per_day:") ->
DosageFrequency.TimesPerDay(value.substringAfter(":").toInt())
value.startsWith("every_x_hours:") ->
DosageFrequency.EveryXHours(value.substringAfter(":").toInt())
value == "as_needed" -> DosageFrequency.AsNeeded
else -> DosageFrequency.AsNeeded
}
}
private fun serializeDosageFrequency(frequency: DosageFrequency): String {
return when (frequency) {
is DosageFrequency.TimesPerDay -> "times_per_day:${frequency.times}"
is DosageFrequency.EveryXHours -> "every_x_hours:${frequency.hours}"
is DosageFrequency.Custom -> "custom"
is DosageFrequency.AsNeeded -> "as_needed"
}
}
}
@Serializable
private data class MedicationSensitiveData(
val name: String,
val genericName: String?,
val dosageAmount: Double,
val dosageUnit: String,
val frequency: String,
val instructions: String?,
val inventory: InventorySensitiveData?
)
@Serializable
private data class InventorySensitiveData(
val currentQuantity: Double,
val minimumQuantity: Double,
val packageSize: Int?,
val expirationDate: String?
)
4.4. Cache Strategy¶
// data/cache/CacheManager.kt
class CacheManager @Inject constructor(
private val cacheDao: CacheDao,
private val dataStore: DataStore<Preferences>
) {
companion object {
private const val CATALOG_CACHE_DURATION = 24 * 60 * 60 * 1000L // 24 hours
private const val STUDIES_CACHE_DURATION = 7 * 24 * 60 * 60 * 1000L // 7 days
}
suspend fun <T> getCached(
key: String,
maxAge: Long = CATALOG_CACHE_DURATION,
fetch: suspend () -> T
): T {
val cached = cacheDao.get(key)
val now = System.currentTimeMillis()
return if (cached != null && (now - cached.timestamp) < maxAge) {
Json.decodeFromString(cached.data)
} else {
val fresh = fetch()
cacheDao.insertOrUpdate(
CacheEntity(
key = key,
data = Json.encodeToString(fresh),
timestamp = now
)
)
fresh
}
}
suspend fun invalidate(key: String) {
cacheDao.delete(key)
}
suspend fun invalidateAll() {
cacheDao.deleteAll()
}
}
@Entity(tableName = "cache")
data class CacheEntity(
@PrimaryKey val key: String,
val data: String,
val timestamp: Long
)
@Dao
interface CacheDao {
@Query("SELECT * FROM cache WHERE `key` = :key")
suspend fun get(key: String): CacheEntity?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertOrUpdate(entity: CacheEntity)
@Query("DELETE FROM cache WHERE `key` = :key")
suspend fun delete(key: String)
@Query("DELETE FROM cache")
suspend fun deleteAll()
}
5. Capa Presentation¶
5.1. Jetpack Compose UI¶
// presentation/screens/home/HomeScreen.kt
@Composable
fun HomeScreen(
viewModel: HomeViewModel = hiltViewModel(),
onNavigateToMedication: (UUID) -> Unit,
onNavigateToCalendar: () -> Unit
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
Scaffold(
topBar = {
TopAppBar(
title = { Text("MedTime") },
actions = {
IconButton(onClick = onNavigateToCalendar) {
Icon(Icons.Default.CalendarMonth, contentDescription = "Calendario")
}
}
)
}
) { padding ->
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(padding),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Next Dose Card
item {
NextDoseCard(
nextDose = uiState.nextDose,
onTakeDose = { viewModel.takeDose(it) },
onSkipDose = { viewModel.skipDose(it) }
)
}
// Adherence Summary
item {
AdherenceCard(
stats = uiState.weeklyAdherence,
streakDays = uiState.streakDays
)
}
// Today's Doses
item {
Text(
text = "Hoy",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(vertical = 8.dp)
)
}
items(uiState.todayDoses, key = { it.id }) { dose ->
DoseCard(
dose = dose,
onTake = { viewModel.takeDose(it) },
onSkip = { viewModel.skipDose(it) },
onClick = { onNavigateToMedication(dose.medicationId) }
)
}
// Empty state
if (uiState.todayDoses.isEmpty() && !uiState.isLoading) {
item {
EmptyStateCard(
message = "No hay dosis programadas para hoy",
icon = Icons.Outlined.CheckCircle
)
}
}
}
// Loading overlay
if (uiState.isLoading) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
// Error snackbar
uiState.error?.let { error ->
LaunchedEffect(error) {
// Show snackbar
}
}
}
}
// presentation/screens/home/components/NextDoseCard.kt
@Composable
fun NextDoseCard(
nextDose: DoseWithMedication?,
onTakeDose: (DoseLog) -> Unit,
onSkipDose: (DoseLog) -> Unit,
modifier: Modifier = Modifier
) {
Card(
modifier = modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
)
) {
if (nextDose != null) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = getMedicationIcon(nextDose.medication.form),
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
Text(
text = "Siguiente dosis",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
}
Text(
text = nextDose.medication.name,
style = MaterialTheme.typography.headlineSmall
)
Text(
text = nextDose.medication.dosage.displayString,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f)
)
Text(
text = nextDose.doseLog.scheduledTime.format(
DateTimeFormatter.ofPattern("HH:mm")
),
style = MaterialTheme.typography.displaySmall
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
OutlinedButton(
onClick = { onSkipDose(nextDose.doseLog) },
modifier = Modifier.weight(1f)
) {
Text("Omitir")
}
Button(
onClick = { onTakeDose(nextDose.doseLog) },
modifier = Modifier.weight(1f)
) {
Icon(Icons.Default.Check, contentDescription = null)
Spacer(Modifier.width(4.dp))
Text("Tomar")
}
}
}
} else {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
imageVector = Icons.Outlined.CheckCircle,
contentDescription = null,
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(Modifier.height(8.dp))
Text(
text = "No hay dosis pendientes",
style = MaterialTheme.typography.bodyLarge
)
}
}
}
}
// presentation/screens/home/components/AdherenceCard.kt
@Composable
fun AdherenceCard(
stats: AdherenceStats?,
streakDays: Int,
modifier: Modifier = Modifier
) {
Card(modifier = modifier.fillMaxWidth()) {
Row(
modifier = Modifier.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Circular progress
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.size(80.dp)
) {
CircularProgressIndicator(
progress = { stats?.adherenceRate?.toFloat() ?: 0f },
modifier = Modifier.fillMaxSize(),
strokeWidth = 8.dp,
trackColor = MaterialTheme.colorScheme.surfaceVariant
)
Text(
text = "${((stats?.adherenceRate ?: 0.0) * 100).toInt()}%",
style = MaterialTheme.typography.titleMedium
)
}
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Esta semana",
style = MaterialTheme.typography.titleMedium
)
Spacer(Modifier.height(4.dp))
Text(
text = "${stats?.takenDoses ?: 0} de ${stats?.totalDoses ?: 0} dosis",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
)
if (streakDays > 0) {
Spacer(Modifier.height(8.dp))
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.LocalFireDepartment,
contentDescription = null,
tint = Color(0xFFFF9800),
modifier = Modifier.size(16.dp)
)
Text(
text = "$streakDays dias de racha",
style = MaterialTheme.typography.labelMedium,
color = Color(0xFFFF9800)
)
}
}
}
}
}
}
5.2. ViewModels¶
// presentation/screens/home/HomeViewModel.kt
@HiltViewModel
class HomeViewModel @Inject constructor(
private val getMedicationsUseCase: GetMedicationsUseCase,
private val getSchedulesUseCase: GetSchedulesUseCase,
private val logDoseUseCase: LogDoseUseCase,
private val getAdherenceStatsUseCase: GetAdherenceStatsUseCase,
private val syncManager: SyncManager
) : ViewModel() {
private val _uiState = MutableStateFlow(HomeUiState())
val uiState: StateFlow<HomeUiState> = _uiState.asStateFlow()
init {
loadData()
observeSync()
}
private fun loadData() {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) }
try {
val medications = getMedicationsUseCase.execute(
GetMedicationsUseCase.Filter(isActive = true)
)
val todayDoses = calculateTodayDoses(medications)
val adherence = getAdherenceStatsUseCase.execute(days = 7)
_uiState.update {
it.copy(
isLoading = false,
medications = medications,
todayDoses = todayDoses,
nextDose = todayDoses.firstOrNull { dose ->
dose.doseLog.status == DoseStatus.PENDING
},
weeklyAdherence = adherence,
streakDays = calculateStreak()
)
}
} catch (e: DomainError) {
_uiState.update { it.copy(isLoading = false, error = e) }
}
}
}
private fun observeSync() {
viewModelScope.launch {
syncManager.syncState.collect { state ->
when (state) {
is SyncState.Completed -> loadData() // Refresh after sync
is SyncState.Failed -> {
_uiState.update { it.copy(error = state.error) }
}
else -> {}
}
}
}
}
fun takeDose(dose: DoseLog) {
viewModelScope.launch {
try {
logDoseUseCase.execute(
medicationId = dose.medicationId,
scheduledTime = dose.scheduledTime,
status = DoseStatus.TAKEN
)
loadData()
} catch (e: DomainError) {
_uiState.update { it.copy(error = e) }
}
}
}
fun skipDose(dose: DoseLog, reason: SkipReason = SkipReason.OTHER) {
viewModelScope.launch {
try {
logDoseUseCase.execute(
medicationId = dose.medicationId,
scheduledTime = dose.scheduledTime,
status = DoseStatus.SKIPPED,
skipReason = reason
)
loadData()
} catch (e: DomainError) {
_uiState.update { it.copy(error = e) }
}
}
}
fun clearError() {
_uiState.update { it.copy(error = null) }
}
private suspend fun calculateTodayDoses(medications: List<Medication>): List<DoseWithMedication> {
// Implementation
return emptyList()
}
private suspend fun calculateStreak(): Int {
// Implementation
return 0
}
}
data class HomeUiState(
val isLoading: Boolean = false,
val medications: List<Medication> = emptyList(),
val todayDoses: List<DoseWithMedication> = emptyList(),
val nextDose: DoseWithMedication? = null,
val weeklyAdherence: AdherenceStats? = null,
val streakDays: Int = 0,
val error: DomainError? = null
)
data class DoseWithMedication(
val doseLog: DoseLog,
val medication: Medication
)
5.3. Navigation¶
// presentation/navigation/NavGraph.kt
@Composable
fun NavGraph(
navController: NavHostController = rememberNavController(),
startDestination: String = Destinations.Home.route
) {
NavHost(
navController = navController,
startDestination = startDestination
) {
composable(Destinations.Home.route) {
HomeScreen(
onNavigateToMedication = { id ->
navController.navigate(Destinations.MedicationDetail.createRoute(id))
},
onNavigateToCalendar = {
navController.navigate(Destinations.Calendar.route)
}
)
}
composable(Destinations.Medications.route) {
MedicationListScreen(
onNavigateToDetail = { id ->
navController.navigate(Destinations.MedicationDetail.createRoute(id))
},
onNavigateToAdd = {
navController.navigate(Destinations.AddMedication.route)
}
)
}
composable(
route = Destinations.MedicationDetail.route,
arguments = listOf(
navArgument("medicationId") { type = NavType.StringType }
)
) { backStackEntry ->
val medicationId = backStackEntry.arguments?.getString("medicationId")
MedicationDetailScreen(
medicationId = UUID.fromString(medicationId),
onNavigateBack = { navController.popBackStack() },
onNavigateToEdit = { id ->
navController.navigate(Destinations.EditMedication.createRoute(id))
}
)
}
composable(Destinations.AddMedication.route) {
AddMedicationScreen(
onNavigateBack = { navController.popBackStack() },
onMedicationAdded = { id ->
navController.navigate(Destinations.MedicationDetail.createRoute(id)) {
popUpTo(Destinations.Medications.route)
}
}
)
}
composable(Destinations.Calendar.route) {
CalendarScreen(
onNavigateToDay = { date ->
navController.navigate(Destinations.DayDetail.createRoute(date))
}
)
}
composable(Destinations.Profile.route) {
ProfileScreen(
onNavigateToSettings = {
navController.navigate(Destinations.Settings.route)
}
)
}
composable(Destinations.Settings.route) {
SettingsScreen(
onNavigateBack = { navController.popBackStack() }
)
}
}
}
// presentation/navigation/Destinations.kt
sealed class Destinations(val route: String) {
object Home : Destinations("home")
object Medications : Destinations("medications")
object MedicationDetail : Destinations("medication/{medicationId}") {
fun createRoute(id: UUID) = "medication/$id"
}
object AddMedication : Destinations("medication/add")
object EditMedication : Destinations("medication/{medicationId}/edit") {
fun createRoute(id: UUID) = "medication/$id/edit"
}
object Calendar : Destinations("calendar")
object DayDetail : Destinations("calendar/{date}") {
fun createRoute(date: LocalDate) = "calendar/$date"
}
object Profile : Destinations("profile")
object Settings : Destinations("settings")
object Auth : Destinations("auth")
object Login : Destinations("auth/login")
object Register : Destinations("auth/register")
}
// presentation/navigation/BottomNavigation.kt
@Composable
fun MedTimeBottomNavigation(
navController: NavHostController
) {
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentDestination = navBackStackEntry?.destination
NavigationBar {
BottomNavItem.entries.forEach { item ->
NavigationBarItem(
icon = {
Icon(
imageVector = if (currentDestination?.hierarchy?.any {
it.route == item.route
} == true) item.selectedIcon else item.unselectedIcon,
contentDescription = item.label
)
},
label = { Text(item.label) },
selected = currentDestination?.hierarchy?.any { it.route == item.route } == true,
onClick = {
navController.navigate(item.route) {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
}
)
}
}
}
enum class BottomNavItem(
val route: String,
val label: String,
val selectedIcon: ImageVector,
val unselectedIcon: ImageVector
) {
HOME(
route = Destinations.Home.route,
label = "Inicio",
selectedIcon = Icons.Filled.Home,
unselectedIcon = Icons.Outlined.Home
),
MEDICATIONS(
route = Destinations.Medications.route,
label = "Medicamentos",
selectedIcon = Icons.Filled.Medication,
unselectedIcon = Icons.Outlined.Medication
),
CALENDAR(
route = Destinations.Calendar.route,
label = "Calendario",
selectedIcon = Icons.Filled.CalendarMonth,
unselectedIcon = Icons.Outlined.CalendarMonth
),
PROFILE(
route = Destinations.Profile.route,
label = "Perfil",
selectedIcon = Icons.Filled.Person,
unselectedIcon = Icons.Outlined.Person
)
}
5.4. UI State Management¶
// presentation/state/UiState.kt
sealed interface UiState<out T> {
object Loading : UiState<Nothing>
data class Success<T>(val data: T) : UiState<T>
data class Error(val error: DomainError) : UiState<Nothing>
}
// Extension functions for UiState
fun <T> UiState<T>.getOrNull(): T? = when (this) {
is UiState.Success -> data
else -> null
}
fun <T> UiState<T>.isLoading(): Boolean = this is UiState.Loading
fun <T> UiState<T>.errorOrNull(): DomainError? = when (this) {
is UiState.Error -> error
else -> null
}
// presentation/state/StateExtensions.kt
@Composable
fun <T> UiState<T>.CollectAsState(
onLoading: @Composable () -> Unit = { CircularProgressIndicator() },
onError: @Composable (DomainError) -> Unit = { error ->
Text("Error: ${error.message}")
},
onSuccess: @Composable (T) -> Unit
) {
when (this) {
is UiState.Loading -> onLoading()
is UiState.Error -> onError(error)
is UiState.Success -> onSuccess(data)
}
}
// Sealed class for one-time events
sealed interface UiEvent {
data class ShowSnackbar(val message: String) : UiEvent
data class Navigate(val destination: String) : UiEvent
object NavigateBack : UiEvent
}
// Base ViewModel with event handling
abstract class BaseViewModel : ViewModel() {
private val _events = Channel<UiEvent>(Channel.BUFFERED)
val events: Flow<UiEvent> = _events.receiveAsFlow()
protected fun sendEvent(event: UiEvent) {
viewModelScope.launch {
_events.send(event)
}
}
}
// Usage in Composable
@Composable
fun CollectEvents(
viewModel: BaseViewModel,
snackbarHostState: SnackbarHostState,
navController: NavController
) {
LaunchedEffect(Unit) {
viewModel.events.collect { event ->
when (event) {
is UiEvent.ShowSnackbar -> {
snackbarHostState.showSnackbar(event.message)
}
is UiEvent.Navigate -> {
navController.navigate(event.destination)
}
is UiEvent.NavigateBack -> {
navController.popBackStack()
}
}
}
}
}
6. Persistencia Local¶
6.1. Room Database¶
// data/datasources/local/MedTimeDatabase.kt
@Database(
entities = [
MedicationEntity::class,
ScheduleEntity::class,
DoseLogEntity::class,
SyncQueueEntity::class,
CacheEntity::class
],
version = 1,
exportSchema = true
)
@TypeConverters(Converters::class)
abstract class MedTimeDatabase : RoomDatabase() {
abstract fun medicationDao(): MedicationDao
abstract fun scheduleDao(): ScheduleDao
abstract fun doseLogDao(): DoseLogDao
abstract fun syncQueueDao(): SyncQueueDao
abstract fun cacheDao(): CacheDao
}
// data/models/entities/MedicationEntity.kt
@Entity(tableName = "medications")
data class MedicationEntity(
@PrimaryKey val id: String,
@ColumnInfo(name = "name_searchable") val nameSearchable: String,
val form: String,
val route: String,
@ColumnInfo(name = "start_date") val startDate: String,
@ColumnInfo(name = "end_date") val endDate: String?,
@ColumnInfo(name = "is_active") val isActive: Boolean,
@ColumnInfo(name = "requires_prescription") val requiresPrescription: Boolean,
@ColumnInfo(name = "encrypted_blob") val encryptedBlob: ByteArray,
@ColumnInfo(name = "created_at") val createdAt: Long,
@ColumnInfo(name = "updated_at") val updatedAt: Long,
val version: Long,
@ColumnInfo(name = "is_deleted") val isDeleted: Boolean = false
)
// data/models/entities/ScheduleEntity.kt
@Entity(tableName = "schedules")
data class ScheduleEntity(
@PrimaryKey val id: String,
@ColumnInfo(name = "medication_id") val medicationId: String,
val times: String, // JSON array of times
@ColumnInfo(name = "days_of_week") val daysOfWeek: String?, // JSON array
@ColumnInfo(name = "is_active") val isActive: Boolean,
@ColumnInfo(name = "encrypted_blob") val encryptedBlob: ByteArray,
@ColumnInfo(name = "created_at") val createdAt: Long,
@ColumnInfo(name = "updated_at") val updatedAt: Long,
val version: Long,
@ColumnInfo(name = "is_deleted") val isDeleted: Boolean = false
)
// data/models/entities/DoseLogEntity.kt
@Entity(tableName = "dose_logs")
data class DoseLogEntity(
@PrimaryKey val id: String,
@ColumnInfo(name = "medication_id") val medicationId: String,
@ColumnInfo(name = "scheduled_time") val scheduledTime: Long,
@ColumnInfo(name = "taken_time") val takenTime: Long?,
val status: String,
@ColumnInfo(name = "skip_reason") val skipReason: String?,
@ColumnInfo(name = "encrypted_blob") val encryptedBlob: ByteArray,
@ColumnInfo(name = "created_at") val createdAt: Long,
@ColumnInfo(name = "updated_at") val updatedAt: Long,
val version: Long,
@ColumnInfo(name = "is_deleted") val isDeleted: Boolean = false
)
// data/models/entities/SyncQueueEntity.kt
@Entity(tableName = "sync_queue")
data class SyncQueueEntity(
@PrimaryKey val id: String,
@ColumnInfo(name = "entity_type") val entityType: String,
@ColumnInfo(name = "entity_id") val entityId: String,
val operation: String,
@ColumnInfo(name = "encrypted_blob") val encryptedBlob: ByteArray,
@ColumnInfo(name = "created_at") val createdAt: Long,
@ColumnInfo(name = "retry_count") val retryCount: Int
)
// data/datasources/local/Converters.kt
class Converters {
@TypeConverter
fun fromByteArray(value: ByteArray?): String? {
return value?.let { Base64.encodeToString(it, Base64.DEFAULT) }
}
@TypeConverter
fun toByteArray(value: String?): ByteArray? {
return value?.let { Base64.decode(it, Base64.DEFAULT) }
}
}
6.2. Android Keystore¶
// data/datasources/local/KeystoreManager.kt
interface KeystoreManager {
suspend fun generateMasterKey(): SecretKey
suspend fun getMasterKey(): SecretKey?
suspend fun saveMasterKey(key: SecretKey)
suspend fun deleteMasterKey()
suspend fun saveSecureValue(key: String, value: ByteArray)
suspend fun getSecureValue(key: String): ByteArray?
suspend fun deleteSecureValue(key: String)
}
class KeystoreManagerImpl @Inject constructor(
@ApplicationContext private val context: Context
) : KeystoreManager {
companion object {
private const val KEYSTORE_PROVIDER = "AndroidKeyStore"
private const val MASTER_KEY_ALIAS = "medtime_master_key"
private const val KEY_SIZE = 256
private const val SHARED_PREFS_NAME = "medtime_secure_prefs"
}
private val keyStore: KeyStore = KeyStore.getInstance(KEYSTORE_PROVIDER).apply { load(null) }
override suspend fun generateMasterKey(): SecretKey {
val keyGenerator = KeyGenerator.getInstance(
KeyProperties.KEY_ALGORITHM_AES,
KEYSTORE_PROVIDER
)
val keyGenParameterSpec = KeyGenParameterSpec.Builder(
MASTER_KEY_ALIAS,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setKeySize(KEY_SIZE)
.setUserAuthenticationRequired(false) // Can be true for biometric
.setRandomizedEncryptionRequired(true)
.build()
keyGenerator.init(keyGenParameterSpec)
return keyGenerator.generateKey()
}
override suspend fun getMasterKey(): SecretKey? {
return if (keyStore.containsAlias(MASTER_KEY_ALIAS)) {
val entry = keyStore.getEntry(MASTER_KEY_ALIAS, null) as? KeyStore.SecretKeyEntry
entry?.secretKey
} else {
null
}
}
override suspend fun saveMasterKey(key: SecretKey) {
keyStore.setEntry(
MASTER_KEY_ALIAS,
KeyStore.SecretKeyEntry(key),
null
)
}
override suspend fun deleteMasterKey() {
if (keyStore.containsAlias(MASTER_KEY_ALIAS)) {
keyStore.deleteEntry(MASTER_KEY_ALIAS)
}
}
override suspend fun saveSecureValue(key: String, value: ByteArray) {
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
val encryptedPrefs = EncryptedSharedPreferences.create(
context,
SHARED_PREFS_NAME,
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
encryptedPrefs.edit()
.putString(key, Base64.encodeToString(value, Base64.DEFAULT))
.apply()
}
override suspend fun getSecureValue(key: String): ByteArray? {
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
val encryptedPrefs = EncryptedSharedPreferences.create(
context,
SHARED_PREFS_NAME,
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
return encryptedPrefs.getString(key, null)?.let {
Base64.decode(it, Base64.DEFAULT)
}
}
override suspend fun deleteSecureValue(key: String) {
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
val encryptedPrefs = EncryptedSharedPreferences.create(
context,
SHARED_PREFS_NAME,
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
encryptedPrefs.edit().remove(key).apply()
}
}
// Keystore key constants
object KeystoreKeys {
const val MASTER_KEY = "medtime.master_key"
const val AUTH_TOKEN = "medtime.auth_token"
const val REFRESH_TOKEN = "medtime.refresh_token"
const val DEVICE_ID = "medtime.device_id"
const val BIOMETRIC_KEY = "medtime.biometric_key"
const val MASTER_KEY_SALT = "medtime.master_key_salt"
}
6.3. DataStore¶
// data/datasources/local/DataStoreManager.kt
class DataStoreManager @Inject constructor(
private val dataStore: DataStore<Preferences>
) {
companion object {
val NOTIFICATIONS_ENABLED = booleanPreferencesKey("notifications_enabled")
val SOUND_ENABLED = booleanPreferencesKey("sound_enabled")
val HAPTIC_ENABLED = booleanPreferencesKey("haptic_enabled")
val REMINDER_MINUTES_BEFORE = intPreferencesKey("reminder_minutes_before")
val LAST_SYNC_VERSION = longPreferencesKey("last_sync_version")
val LAST_SYNC_DATE = longPreferencesKey("last_sync_date")
val ONBOARDING_COMPLETED = booleanPreferencesKey("onboarding_completed")
val PRIVACY_CONSENT_VERSION = stringPreferencesKey("privacy_consent_version")
val CACHED_USER_NAME = stringPreferencesKey("cached_user_name")
val CACHED_TIER = stringPreferencesKey("cached_tier")
}
val notificationsEnabled: Flow<Boolean> = dataStore.data
.map { preferences -> preferences[NOTIFICATIONS_ENABLED] ?: true }
val soundEnabled: Flow<Boolean> = dataStore.data
.map { preferences -> preferences[SOUND_ENABLED] ?: true }
val reminderMinutesBefore: Flow<Int> = dataStore.data
.map { preferences -> preferences[REMINDER_MINUTES_BEFORE] ?: 5 }
val lastSyncVersion: Flow<Long> = dataStore.data
.map { preferences -> preferences[LAST_SYNC_VERSION] ?: 0L }
val onboardingCompleted: Flow<Boolean> = dataStore.data
.map { preferences -> preferences[ONBOARDING_COMPLETED] ?: false }
suspend fun setNotificationsEnabled(enabled: Boolean) {
dataStore.edit { preferences ->
preferences[NOTIFICATIONS_ENABLED] = enabled
}
}
suspend fun setSoundEnabled(enabled: Boolean) {
dataStore.edit { preferences ->
preferences[SOUND_ENABLED] = enabled
}
}
suspend fun setReminderMinutesBefore(minutes: Int) {
dataStore.edit { preferences ->
preferences[REMINDER_MINUTES_BEFORE] = minutes
}
}
suspend fun setLastSyncVersion(version: Long) {
dataStore.edit { preferences ->
preferences[LAST_SYNC_VERSION] = version
}
}
suspend fun setLastSyncDate(timestamp: Long) {
dataStore.edit { preferences ->
preferences[LAST_SYNC_DATE] = timestamp
}
}
suspend fun setOnboardingCompleted(completed: Boolean) {
dataStore.edit { preferences ->
preferences[ONBOARDING_COMPLETED] = completed
}
}
suspend fun setPrivacyConsentVersion(version: String) {
dataStore.edit { preferences ->
preferences[PRIVACY_CONSENT_VERSION] = version
}
}
suspend fun clear() {
dataStore.edit { preferences ->
preferences.clear()
}
}
}
6.4. Migraciones¶
// data/datasources/local/migrations/Migrations.kt
object Migrations {
val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(database: SupportSQLiteDatabase) {
// Add new column to medications table
database.execSQL(
"ALTER TABLE medications ADD COLUMN brand_name TEXT"
)
}
}
val MIGRATION_2_3 = object : Migration(2, 3) {
override fun migrate(database: SupportSQLiteDatabase) {
// Add new table for interactions
database.execSQL("""
CREATE TABLE IF NOT EXISTS interactions (
id TEXT PRIMARY KEY NOT NULL,
medication_id_1 TEXT NOT NULL,
medication_id_2 TEXT NOT NULL,
severity TEXT NOT NULL,
description TEXT,
created_at INTEGER NOT NULL,
FOREIGN KEY (medication_id_1) REFERENCES medications(id),
FOREIGN KEY (medication_id_2) REFERENCES medications(id)
)
""".trimIndent())
}
}
val ALL_MIGRATIONS = arrayOf(MIGRATION_1_2, MIGRATION_2_3)
}
// Database builder with migrations
@Provides
@Singleton
fun provideDatabase(
@ApplicationContext context: Context
): MedTimeDatabase = Room.databaseBuilder(
context,
MedTimeDatabase::class.java,
"medtime.db"
)
.addMigrations(*Migrations.ALL_MIGRATIONS)
.addCallback(DatabaseCallback())
.build()
// Database callback for initialization
class DatabaseCallback : RoomDatabase.Callback() {
override fun onCreate(db: SupportSQLiteDatabase) {
super.onCreate(db)
// Initialize default data
}
override fun onOpen(db: SupportSQLiteDatabase) {
super.onOpen(db)
// Run maintenance tasks
}
}
7. Networking¶
7.1. API Client¶
// data/datasources/remote/ApiClient.kt
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides
@Singleton
fun provideMoshi(): Moshi = Moshi.Builder()
.add(KotlinJsonAdapterFactory())
.add(UUIDAdapter())
.add(LocalDateTimeAdapter())
.build()
@Provides
@Singleton
fun provideLoggingInterceptor(): HttpLoggingInterceptor =
HttpLoggingInterceptor().apply {
level = if (BuildConfig.DEBUG) {
HttpLoggingInterceptor.Level.BODY
} else {
HttpLoggingInterceptor.Level.NONE
}
}
@Provides
@Singleton
fun provideOkHttpClient(
authInterceptor: AuthInterceptor,
retryInterceptor: RetryInterceptor,
loggingInterceptor: HttpLoggingInterceptor
): OkHttpClient = OkHttpClient.Builder()
.addInterceptor(authInterceptor)
.addInterceptor(retryInterceptor)
.addInterceptor(loggingInterceptor)
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.certificatePinner(createCertificatePinner())
.build()
@Provides
@Singleton
fun provideRetrofit(
okHttpClient: OkHttpClient,
moshi: Moshi
): Retrofit = Retrofit.Builder()
.baseUrl(BuildConfig.API_BASE_URL)
.client(okHttpClient)
.addConverterFactory(MoshiConverterFactory.create(moshi))
.build()
private fun createCertificatePinner(): CertificatePinner =
CertificatePinner.Builder()
.add("api.medtime.app", "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")
.add("api.medtime.app", "sha256/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=")
.build()
}
// Custom adapters
class UUIDAdapter {
@ToJson
fun toJson(uuid: UUID): String = uuid.toString()
@FromJson
fun fromJson(value: String): UUID = UUID.fromString(value)
}
class LocalDateTimeAdapter {
@ToJson
fun toJson(dateTime: LocalDateTime): String =
dateTime.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)
@FromJson
fun fromJson(value: String): LocalDateTime =
LocalDateTime.parse(value, DateTimeFormatter.ISO_LOCAL_DATE_TIME)
}
7.2. Request/Response¶
// data/models/dto/ApiDtos.kt
// Auth
@JsonClass(generateAdapter = true)
data class LoginRequest(
val email: String,
val password: String
)
@JsonClass(generateAdapter = true)
data class RegisterRequest(
val email: String,
val password: String,
val name: String
)
@JsonClass(generateAdapter = true)
data class AuthResponse(
@Json(name = "access_token") val accessToken: String,
@Json(name = "refresh_token") val refreshToken: String,
@Json(name = "expires_in") val expiresIn: Long,
val user: UserDto
)
// Sync
@JsonClass(generateAdapter = true)
data class PushRequest(
val blobs: List<SyncBlobDto>
)
@JsonClass(generateAdapter = true)
data class SyncBlobDto(
@Json(name = "entity_id") val entityId: String,
@Json(name = "entity_type") val entityType: String,
@Json(name = "encrypted_blob") val encryptedBlob: String, // Base64
@Json(name = "client_version") val clientVersion: Long,
@Json(name = "is_deleted") val isDeleted: Boolean
)
@JsonClass(generateAdapter = true)
data class PushResponse(
val accepted: List<AcceptedBlob>,
val rejected: List<RejectedBlob>,
@Json(name = "server_version") val serverVersion: Long
)
@JsonClass(generateAdapter = true)
data class AcceptedBlob(
@Json(name = "entity_id") val entityId: String,
@Json(name = "new_version") val newVersion: Long
)
@JsonClass(generateAdapter = true)
data class RejectedBlob(
@Json(name = "entity_id") val entityId: String,
val reason: String,
@Json(name = "server_version") val serverVersion: Long
)
@JsonClass(generateAdapter = true)
data class PullResponse(
val blobs: List<SyncBlobDto>,
@Json(name = "server_version") val serverVersion: Long,
@Json(name = "has_more") val hasMore: Boolean
)
// Catalog
@JsonClass(generateAdapter = true)
data class MedicationCatalogDto(
val id: String,
val name: String,
@Json(name = "generic_name") val genericName: String?,
val form: String,
val strength: String?,
val manufacturer: String?
)
@JsonClass(generateAdapter = true)
data class SearchResponse(
val results: List<MedicationCatalogDto>,
val page: Int,
@Json(name = "page_size") val pageSize: Int,
@Json(name = "total_count") val totalCount: Int
)
7.3. Interceptors¶
// data/datasources/remote/interceptors/AuthInterceptor.kt
class AuthInterceptor @Inject constructor(
private val keystoreManager: KeystoreManager,
private val tokenRefresher: TokenRefresher
) : Interceptor {
private val publicPaths = listOf(
"/v1/auth/login",
"/v1/auth/register",
"/v1/catalog/bundle"
)
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
// Skip auth for public endpoints
if (isPublicEndpoint(request.url.encodedPath)) {
return chain.proceed(request)
}
// Get token
val token = runBlocking {
keystoreManager.getSecureValue(KeystoreKeys.AUTH_TOKEN)?.decodeToString()
} ?: throw IOException("Unauthorized - No token")
// Check expiration and refresh if needed
val validToken = if (isTokenExpired(token)) {
runBlocking { tokenRefresher.refresh() }
} else {
token
}
val authenticatedRequest = request.newBuilder()
.header("Authorization", "Bearer $validToken")
.build()
return chain.proceed(authenticatedRequest)
}
private fun isPublicEndpoint(path: String): Boolean =
publicPaths.any { path.startsWith(it) }
private fun isTokenExpired(token: String): Boolean {
return try {
val payload = token.split(".")[1]
val decodedPayload = Base64.decode(payload, Base64.URL_SAFE).decodeToString()
val json = JSONObject(decodedPayload)
val exp = json.getLong("exp")
Instant.ofEpochSecond(exp).isBefore(Instant.now())
} catch (e: Exception) {
true
}
}
}
// data/datasources/remote/interceptors/RetryInterceptor.kt
class RetryInterceptor @Inject constructor() : Interceptor {
companion object {
private const val MAX_RETRIES = 3
private const val INITIAL_DELAY_MS = 1000L
}
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
var lastException: IOException? = null
repeat(MAX_RETRIES) { attempt ->
try {
val response = chain.proceed(request)
when (response.code) {
in 200..299 -> return response
401, 403, 404, 409 -> return response // Don't retry these
429 -> {
val retryAfter = response.header("Retry-After")?.toLongOrNull()
?: (INITIAL_DELAY_MS * (attempt + 1))
response.close()
Thread.sleep(retryAfter)
}
in 500..599 -> {
response.close()
val delay = INITIAL_DELAY_MS * (1 shl attempt) // Exponential backoff
Thread.sleep(delay)
}
else -> return response
}
} catch (e: IOException) {
lastException = e
val delay = INITIAL_DELAY_MS * (1 shl attempt)
Thread.sleep(delay)
}
}
throw lastException ?: IOException("Request failed after $MAX_RETRIES retries")
}
}
7.4. Error Mapping¶
// data/datasources/remote/ApiError.kt
sealed class ApiError : Exception() {
object InvalidUrl : ApiError()
object InvalidResponse : ApiError()
object Unauthorized : ApiError()
object Forbidden : ApiError()
object NotFound : ApiError()
object Conflict : ApiError()
data class RateLimited(val retryAfter: Long?) : ApiError()
data class ServerError(val code: Int) : ApiError()
data class HttpError(val code: Int) : ApiError()
data class NetworkError(val cause: Throwable) : ApiError()
data class DecodingError(val cause: Throwable) : ApiError()
object Unknown : ApiError()
override val message: String
get() = when (this) {
is InvalidUrl -> "URL invalida"
is InvalidResponse -> "Respuesta invalida del servidor"
is Unauthorized -> "Sesion expirada. Inicia sesion nuevamente."
is Forbidden -> "No tienes permiso para esta accion"
is NotFound -> "Recurso no encontrado"
is Conflict -> "Conflicto de datos. Sincroniza e intenta de nuevo."
is RateLimited -> "Demasiadas solicitudes. Espera un momento."
is ServerError -> "Error del servidor ($code)"
is HttpError -> "Error HTTP ($code)"
is NetworkError -> "Error de red. Verifica tu conexion."
is DecodingError -> "Error al procesar datos"
is Unknown -> "Error desconocido"
}
val isRetryable: Boolean
get() = when (this) {
is ServerError, is NetworkError, is RateLimited -> true
else -> false
}
fun toDomainError(): DomainError = when (this) {
is Unauthorized -> DomainError.UserNotFound
is Conflict -> DomainError.SyncFailed(this)
is NetworkError -> DomainError.OfflineOperationFailed
else -> DomainError.Unknown(this)
}
}
// Extension function to map Response to Result
suspend fun <T> Response<T>.toResult(): Result<T> {
return if (isSuccessful) {
body()?.let { Result.success(it) }
?: Result.failure(ApiError.InvalidResponse)
} else {
Result.failure(
when (code()) {
401 -> ApiError.Unauthorized
403 -> ApiError.Forbidden
404 -> ApiError.NotFound
409 -> ApiError.Conflict
429 -> ApiError.RateLimited(headers()["Retry-After"]?.toLongOrNull())
in 500..599 -> ApiError.ServerError(code())
else -> ApiError.HttpError(code())
}
)
}
}
8. Cifrado E2E¶
8.1. Android Security Library¶
// core/crypto/CryptoManager.kt
interface CryptoManager {
suspend fun generateMasterKey(): ByteArray
suspend fun deriveMasterKey(password: String, salt: ByteArray): ByteArray
suspend fun encrypt(data: ByteArray): ByteArray
suspend fun decrypt(encryptedData: ByteArray): ByteArray
fun hash(data: ByteArray): String
}
class CryptoManagerImpl @Inject constructor(
private val keystoreManager: KeystoreManager
) : CryptoManager {
companion object {
private const val KEY_SIZE = 32 // 256 bits
private const val NONCE_SIZE = 12 // 96 bits for AES-GCM
private const val TAG_SIZE = 16 // 128 bits
private const val ARGON2_MEMORY = 65536 // 64 MiB
private const val ARGON2_ITERATIONS = 3
private const val ARGON2_PARALLELISM = 4
}
private var masterKey: ByteArray? = null
init {
loadMasterKey()
}
override suspend fun generateMasterKey(): ByteArray {
val key = ByteArray(KEY_SIZE)
SecureRandom().nextBytes(key)
saveMasterKey(key)
return key
}
override suspend fun deriveMasterKey(password: String, salt: ByteArray): ByteArray {
// Use Argon2id for key derivation
val argon2 = Argon2Factory.create(Argon2Factory.Argon2Types.ARGON2id)
val derivedKey = argon2.hash(
ARGON2_ITERATIONS,
ARGON2_MEMORY,
ARGON2_PARALLELISM,
password.toCharArray(),
salt
).rawBytes
saveMasterKey(derivedKey)
return derivedKey
}
override suspend fun encrypt(data: ByteArray): ByteArray {
val key = masterKey ?: throw CryptoException.KeyNotFound()
// Generate random nonce
val nonce = ByteArray(NONCE_SIZE)
SecureRandom().nextBytes(nonce)
// Create cipher
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
val keySpec = SecretKeySpec(key, "AES")
val gcmSpec = GCMParameterSpec(TAG_SIZE * 8, nonce)
cipher.init(Cipher.ENCRYPT_MODE, keySpec, gcmSpec)
// Encrypt
val ciphertext = cipher.doFinal(data)
// Return nonce + ciphertext (includes tag)
return nonce + ciphertext
}
override suspend fun decrypt(encryptedData: ByteArray): ByteArray {
val key = masterKey ?: throw CryptoException.KeyNotFound()
if (encryptedData.size < NONCE_SIZE + TAG_SIZE) {
throw CryptoException.InvalidData()
}
// Extract nonce and ciphertext
val nonce = encryptedData.copyOfRange(0, NONCE_SIZE)
val ciphertext = encryptedData.copyOfRange(NONCE_SIZE, encryptedData.size)
// Create cipher
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
val keySpec = SecretKeySpec(key, "AES")
val gcmSpec = GCMParameterSpec(TAG_SIZE * 8, nonce)
cipher.init(Cipher.DECRYPT_MODE, keySpec, gcmSpec)
// Decrypt
return cipher.doFinal(ciphertext)
}
override fun hash(data: ByteArray): String {
val digest = MessageDigest.getInstance("SHA-256")
val hashBytes = digest.digest(data)
return hashBytes.joinToString("") { "%02x".format(it) }
}
private fun loadMasterKey() {
runBlocking {
masterKey = keystoreManager.getSecureValue(KeystoreKeys.MASTER_KEY)
}
}
private suspend fun saveMasterKey(key: ByteArray) {
keystoreManager.saveSecureValue(KeystoreKeys.MASTER_KEY, key)
masterKey = key
}
}
sealed class CryptoException : Exception() {
class KeyNotFound : CryptoException()
class KeyDerivationFailed : CryptoException()
class EncryptionFailed : CryptoException()
class DecryptionFailed : CryptoException()
class InvalidPassword : CryptoException()
class InvalidData : CryptoException()
class NotInitialized : CryptoException()
}
8.2. Patron de Inicializacion Segura (Mutex + StateFlow)¶
CRITICO: El CryptoManager debe garantizar inicializacion thread-safe para evitar race conditions durante acceso concurrente. Usar patron Mutex con ensureInitialized().
// core/crypto/CryptoManagerSafe.kt
package com.medtime.core.crypto
import de.mkammerer.argon2.Argon2Factory
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import java.security.SecureRandom
import javax.crypto.Cipher
import javax.crypto.spec.GCMParameterSpec
import javax.crypto.spec.SecretKeySpec
import javax.inject.Inject
import javax.inject.Singleton
/**
* Thread-safe CryptoManager con inicializacion lazy y protegida por Mutex.
* Resuelve PERF-004: race condition en inicializacion concurrente.
*
* Decision del Director #2 (DV2): Usar Argon2id para ambas plataformas.
*/
@Singleton
class CryptoManagerSafe @Inject constructor(
private val keystoreManager: KeystoreManager
) : CryptoManager {
companion object {
private const val KEY_SIZE = 32 // 256 bits
private const val NONCE_SIZE = 12 // 96 bits for AES-GCM
private const val TAG_SIZE = 16 // 128 bits
// Argon2id parameters (Decision del Director #2 - DV2)
private const val ARGON2_MEMORY = 65536 // 64 MiB
private const val ARGON2_ITERATIONS = 3 // t=3
private const val ARGON2_PARALLELISM = 4 // p=4
}
// State management
private sealed class InitState {
object Uninitialized : InitState()
object Initializing : InitState()
data class Initialized(val key: ByteArray) : InitState()
data class Failed(val error: Throwable) : InitState()
}
private val initMutex = Mutex()
private val _state = MutableStateFlow<InitState>(InitState.Uninitialized)
val initializationState: StateFlow<InitState> = _state
private var masterKey: ByteArray? = null
// MARK: - Public API
/**
* Garantiza inicializacion antes de operaciones criptograficas.
* Thread-safe: multiples llamadas concurrentes esperan la misma inicializacion.
*/
suspend fun ensureInitialized() {
when (val currentState = _state.value) {
is InitState.Initialized -> return // Already initialized
is InitState.Failed -> throw currentState.error
else -> {
// Need to initialize or wait for initialization
initMutex.withLock {
// Double-check after acquiring lock
when (val state = _state.value) {
is InitState.Initialized -> return
is InitState.Failed -> throw state.error
is InitState.Initializing -> {
// Wait for initialization to complete
_state.first { it !is InitState.Initializing }
val finalState = _state.value
if (finalState is InitState.Failed) {
throw finalState.error
}
}
is InitState.Uninitialized -> {
_state.value = InitState.Initializing
try {
performInitialization()
} catch (e: Exception) {
_state.value = InitState.Failed(e)
throw e
}
}
}
}
}
}
}
override suspend fun encrypt(data: ByteArray): ByteArray {
ensureInitialized()
val key = masterKey ?: throw CryptoException.NotInitialized()
// Generate random nonce
val nonce = ByteArray(NONCE_SIZE)
SecureRandom().nextBytes(nonce)
// Create cipher
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
val keySpec = SecretKeySpec(key, "AES")
val gcmSpec = GCMParameterSpec(TAG_SIZE * 8, nonce)
cipher.init(Cipher.ENCRYPT_MODE, keySpec, gcmSpec)
// Encrypt
val ciphertext = cipher.doFinal(data)
// Return nonce + ciphertext (includes tag)
return nonce + ciphertext
}
override suspend fun decrypt(encryptedData: ByteArray): ByteArray {
ensureInitialized()
val key = masterKey ?: throw CryptoException.NotInitialized()
if (encryptedData.size < NONCE_SIZE + TAG_SIZE) {
throw CryptoException.InvalidData()
}
// Extract nonce and ciphertext
val nonce = encryptedData.copyOfRange(0, NONCE_SIZE)
val ciphertext = encryptedData.copyOfRange(NONCE_SIZE, encryptedData.size)
// Create cipher
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
val keySpec = SecretKeySpec(key, "AES")
val gcmSpec = GCMParameterSpec(TAG_SIZE * 8, nonce)
cipher.init(Cipher.DECRYPT_MODE, keySpec, gcmSpec)
// Decrypt
return cipher.doFinal(ciphertext)
}
override suspend fun deriveMasterKey(password: String, salt: ByteArray): ByteArray {
// Use Argon2id for key derivation (Decision del Director #2)
val argon2 = Argon2Factory.create(Argon2Factory.Argon2Types.ARGON2id)
val derivedKey = argon2.hash(
ARGON2_ITERATIONS,
ARGON2_MEMORY,
ARGON2_PARALLELISM,
password.toCharArray(),
salt
).rawBytes
saveMasterKey(derivedKey)
_state.value = InitState.Initialized(derivedKey)
return derivedKey
}
override suspend fun generateMasterKey(): ByteArray {
val key = ByteArray(KEY_SIZE)
SecureRandom().nextBytes(key)
saveMasterKey(key)
_state.value = InitState.Initialized(key)
return key
}
override fun hash(data: ByteArray): String {
val digest = java.security.MessageDigest.getInstance("SHA-256")
val hashBytes = digest.digest(data)
return hashBytes.joinToString("") { "%02x".format(it) }
}
// MARK: - Private
private suspend fun performInitialization() {
val existingKey = keystoreManager.getSecureValue(KeystoreKeys.MASTER_KEY)
if (existingKey != null) {
masterKey = existingKey
_state.value = InitState.Initialized(existingKey)
} else {
// No key found - waiting for setup or derivation
_state.value = InitState.Uninitialized
}
}
private suspend fun saveMasterKey(key: ByteArray) {
keystoreManager.saveSecureValue(KeystoreKeys.MASTER_KEY, key)
masterKey = key
}
}
// MARK: - Usage Example
/*
// Uso correcto del CryptoManagerSafe
class MedicationRepository @Inject constructor(
private val crypto: CryptoManagerSafe,
private val database: MedicationDatabase
) {
suspend fun saveMedication(med: Medication) {
// ensureInitialized() es llamado automaticamente por encrypt()
val encrypted = crypto.encrypt(med.toByteArray())
database.save(encrypted)
}
}
// Multiples llamadas concurrentes son seguras:
coroutineScope {
launch { crypto.encrypt(data1) }
launch { crypto.encrypt(data2) }
launch { crypto.encrypt(data3) }
// Todas esperan la misma inicializacion sin race condition
}
*/
8.3. Key Management¶
// core/crypto/KeyManager.kt
interface KeyManager {
suspend fun setupKeys(password: String)
suspend fun unlockWithBiometrics(): Boolean
suspend fun changePassword(oldPassword: String, newPassword: String)
suspend fun deleteAllKeys()
}
class KeyManagerImpl @Inject constructor(
private val cryptoManager: CryptoManager,
private val keystoreManager: KeystoreManager,
private val biometricManager: BiometricManager
) : KeyManager {
companion object {
private const val SALT_SIZE = 32
}
override suspend fun setupKeys(password: String) {
// Generate random salt
val salt = ByteArray(SALT_SIZE)
SecureRandom().nextBytes(salt)
// Save salt
keystoreManager.saveSecureValue(KeystoreKeys.MASTER_KEY_SALT, salt)
// Derive and save master key
cryptoManager.deriveMasterKey(password, salt)
// Optionally enable biometrics
if (biometricManager.canAuthenticate() == BiometricManager.BIOMETRIC_SUCCESS) {
enableBiometrics(password)
}
}
override suspend fun unlockWithBiometrics(): Boolean {
if (biometricManager.canAuthenticate() != BiometricManager.BIOMETRIC_SUCCESS) {
return false
}
return withContext(Dispatchers.Main) {
suspendCancellableCoroutine { continuation ->
val promptInfo = BiometricPrompt.PromptInfo.Builder()
.setTitle("Desbloquear MedTime")
.setDescription("Usa tu huella o rostro para desbloquear")
.setNegativeButtonText("Usar contraseña")
.build()
// This would be called from an Activity/Fragment
// For simplicity, returning false here
continuation.resume(false)
}
}
}
override suspend fun changePassword(oldPassword: String, newPassword: String) {
// Verify old password
val salt = keystoreManager.getSecureValue(KeystoreKeys.MASTER_KEY_SALT)
?: throw CryptoException.KeyNotFound()
// Re-derive with old password to verify
cryptoManager.deriveMasterKey(oldPassword, salt)
// Generate new salt
val newSalt = ByteArray(SALT_SIZE)
SecureRandom().nextBytes(newSalt)
// Save new salt and derive new key
keystoreManager.saveSecureValue(KeystoreKeys.MASTER_KEY_SALT, newSalt)
cryptoManager.deriveMasterKey(newPassword, newSalt)
// Update biometric key if enabled
if (biometricManager.canAuthenticate() == BiometricManager.BIOMETRIC_SUCCESS) {
enableBiometrics(newPassword)
}
}
override suspend fun deleteAllKeys() {
keystoreManager.deleteSecureValue(KeystoreKeys.MASTER_KEY)
keystoreManager.deleteSecureValue(KeystoreKeys.MASTER_KEY_SALT)
keystoreManager.deleteSecureValue(KeystoreKeys.BIOMETRIC_KEY)
}
private suspend fun enableBiometrics(password: String) {
// Store password encrypted with biometric-protected key
keystoreManager.saveSecureValue(
KeystoreKeys.BIOMETRIC_KEY,
password.toByteArray()
)
}
}
8.4. Blob Encryption¶
// core/crypto/BlobEncryption.kt
interface BlobEncryption {
suspend fun <T> encryptBlob(obj: T, serializer: KSerializer<T>): ByteArray
suspend fun <T> decryptBlob(data: ByteArray, serializer: KSerializer<T>): T
}
class BlobEncryptionImpl @Inject constructor(
private val cryptoManager: CryptoManager,
private val json: Json
) : BlobEncryption {
override suspend fun <T> encryptBlob(obj: T, serializer: KSerializer<T>): ByteArray {
// 1. Serialize to JSON
val jsonString = json.encodeToString(serializer, obj)
val jsonBytes = jsonString.toByteArray(Charsets.UTF_8)
// 2. Compress (for large blobs)
val compressedBytes = compress(jsonBytes)
// 3. Encrypt
return cryptoManager.encrypt(compressedBytes)
}
override suspend fun <T> decryptBlob(data: ByteArray, serializer: KSerializer<T>): T {
// 1. Decrypt
val decryptedBytes = cryptoManager.decrypt(data)
// 2. Decompress
val decompressedBytes = decompress(decryptedBytes)
// 3. Deserialize
val jsonString = decompressedBytes.toString(Charsets.UTF_8)
return json.decodeFromString(serializer, jsonString)
}
private fun compress(data: ByteArray): ByteArray {
return ByteArrayOutputStream().use { baos ->
GZIPOutputStream(baos).use { gzip ->
gzip.write(data)
}
baos.toByteArray()
}
}
private fun decompress(data: ByteArray): ByteArray {
return try {
ByteArrayInputStream(data).use { bais ->
GZIPInputStream(bais).use { gzip ->
gzip.readBytes()
}
}
} catch (e: Exception) {
// Not compressed, return as-is
data
}
}
}
8.5. Zero-Knowledge Flows¶
// core/crypto/ZeroKnowledgeManager.kt
/**
* Manages Zero-Knowledge architecture flows
* The server NEVER sees plaintext PHI data
*/
class ZeroKnowledgeManager @Inject constructor(
private val blobEncryption: BlobEncryption,
private val syncRepository: SyncRepository
) {
/**
* Prepare data for server sync (encrypt locally first)
*/
suspend inline fun <reified T : Any> prepareForSync(
entity: T,
entityId: UUID,
entityType: EntityType,
operation: SyncOperationType
): SyncBlobDto {
// Encrypt the entire entity
val encryptedBlob = blobEncryption.encryptBlob(
entity,
serializer()
)
return SyncBlobDto(
entityId = entityId.toString(),
entityType = entityType.name,
encryptedBlob = Base64.encodeToString(encryptedBlob, Base64.NO_WRAP),
clientVersion = 1,
isDeleted = operation == SyncOperationType.DELETE
)
}
/**
* Process data received from server (decrypt locally)
*/
suspend inline fun <reified T : Any> processFromServer(blob: SyncBlobDto): T {
val encryptedBytes = Base64.decode(blob.encryptedBlob, Base64.NO_WRAP)
return blobEncryption.decryptBlob(encryptedBytes, serializer())
}
/**
* Verify that server cannot read data
* This is called during development/testing to ensure compliance
*/
fun verifyZeroKnowledge(blob: SyncBlobDto): ZeroKnowledgeVerification {
// Server should only see:
// - entityId (UUID)
// - entityType (string)
// - encryptedBlob (opaque bytes)
// - timestamps
// - version numbers
val serverVisibleFields = setOf(
"entity_id",
"entity_type",
"encrypted_blob",
"client_version",
"is_deleted"
)
// Verify blob is encrypted (starts with valid AES-GCM header)
val encryptedBytes = Base64.decode(blob.encryptedBlob, Base64.NO_WRAP)
val isEncrypted = encryptedBytes.size >= 28 // Minimum: 12 nonce + 16 tag
return ZeroKnowledgeVerification(
isCompliant = isEncrypted,
serverVisibleFields = serverVisibleFields,
phiExposed = !isEncrypted
)
}
}
data class ZeroKnowledgeVerification(
val isCompliant: Boolean,
val serverVisibleFields: Set<String>,
val phiExposed: Boolean
)
9. Offline-First Engine¶
9.1. Sync Manager¶
// core/sync/SyncManager.kt
interface SyncManager {
val syncState: StateFlow<SyncState>
suspend fun sync()
suspend fun enqueue(operation: SyncOperation)
suspend fun retryFailed()
}
sealed class SyncState {
object Idle : SyncState()
data class Syncing(val progress: Float) : SyncState()
data class Completed(val result: SyncResult) : SyncState()
data class Failed(val error: Throwable) : SyncState()
object Offline : SyncState()
}
class SyncManagerImpl @Inject constructor(
private val syncUseCase: SyncDataUseCase,
private val connectivityMonitor: ConnectivityMonitor,
private val workManager: WorkManager,
private val syncQueueDao: SyncQueueDao
) : SyncManager {
private val _syncState = MutableStateFlow<SyncState>(SyncState.Idle)
override val syncState: StateFlow<SyncState> = _syncState.asStateFlow()
private var syncJob: Job? = null
init {
observeConnectivity()
}
private fun observeConnectivity() {
connectivityMonitor.isConnected
.onEach { isConnected ->
if (isConnected) {
sync()
} else {
_syncState.value = SyncState.Offline
}
}
.launchIn(CoroutineScope(Dispatchers.IO + SupervisorJob()))
}
override suspend fun sync() {
// Cancel existing sync
syncJob?.cancel()
// Check network
if (!connectivityMonitor.isConnected.first()) {
_syncState.value = SyncState.Offline
return
}
_syncState.value = SyncState.Syncing(0f)
syncJob = CoroutineScope(Dispatchers.IO).launch {
try {
val result = syncUseCase.execute()
_syncState.value = SyncState.Completed(result)
} catch (e: Exception) {
_syncState.value = SyncState.Failed(e)
}
}
syncJob?.join()
}
override suspend fun enqueue(operation: SyncOperation) {
val entity = SyncQueueEntity(
id = UUID.randomUUID().toString(),
entityType = operation.entityType.name,
entityId = operation.entityId.toString(),
operation = operation.operationType.name,
encryptedBlob = operation.encryptedBlob,
createdAt = System.currentTimeMillis(),
retryCount = 0
)
syncQueueDao.insert(entity)
// Auto-sync if connected
if (connectivityMonitor.isConnected.first()) {
sync()
}
}
override suspend fun retryFailed() {
syncQueueDao.resetRetries()
sync()
}
/**
* Schedule periodic sync using WorkManager
*/
fun schedulePeriodicSync() {
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.setRequiresBatteryNotLow(true)
.build()
val syncWork = PeriodicWorkRequestBuilder<SyncWorker>(
15, TimeUnit.MINUTES
)
.setConstraints(constraints)
.setBackoffCriteria(
BackoffPolicy.EXPONENTIAL,
WorkRequest.MIN_BACKOFF_MILLIS,
TimeUnit.MILLISECONDS
)
.build()
workManager.enqueueUniquePeriodicWork(
"medtime_sync",
ExistingPeriodicWorkPolicy.KEEP,
syncWork
)
}
}
sealed class SyncOperation {
abstract val entityType: EntityType
abstract val entityId: UUID
abstract val operationType: SyncOperationType
abstract val encryptedBlob: ByteArray
data class Medication(val medication: com.medtime.domain.entities.Medication, val blob: ByteArray) : SyncOperation() {
override val entityType = EntityType.MEDICATION
override val entityId = medication.id
override val operationType = SyncOperationType.UPDATE
override val encryptedBlob = blob
}
data class DoseLog(val doseLog: com.medtime.domain.entities.DoseLog, val blob: ByteArray) : SyncOperation() {
override val entityType = EntityType.DOSE_LOG
override val entityId = doseLog.id
override val operationType = SyncOperationType.CREATE
override val encryptedBlob = blob
}
}
9.2. Conflict Resolution¶
// core/sync/ConflictResolver.kt
interface ConflictResolver {
fun canAutoResolve(conflict: SyncConflict): Boolean
suspend fun autoResolve(conflict: SyncConflict): ConflictResolution
suspend fun merge(conflict: SyncConflict): ByteArray
}
/**
* Last-Write-Wins conflict resolver with smart merging
*/
class ConflictResolverImpl @Inject constructor(
private val json: Json
) : ConflictResolver {
override fun canAutoResolve(conflict: SyncConflict): Boolean {
// Auto-resolve if version difference is 1 (simple sequential edit)
val versionDiff = abs(conflict.serverVersion - conflict.localVersion)
return versionDiff <= 1
}
override suspend fun autoResolve(conflict: SyncConflict): ConflictResolution {
// Last-Write-Wins by default
// Prefer server version to ensure consistency
return ConflictResolution.KeepServer
}
override suspend fun merge(conflict: SyncConflict): ByteArray {
// Attempt field-level merge based on entity type
return when (conflict.entityType) {
EntityType.MEDICATION -> mergeMedication(conflict)
EntityType.DOSE_LOG -> conflict.serverData // Dose logs are append-only
else -> conflict.serverData // Default to server
}
}
private suspend fun mergeMedication(conflict: SyncConflict): ByteArray {
// Decode both versions
val local = json.decodeFromString<MedicationMergeDto>(
conflict.localData.decodeToString()
)
val server = json.decodeFromString<MedicationMergeDto>(
conflict.serverData.decodeToString()
)
// Merge strategy: take most recent value for each field
val merged = MedicationMergeDto(
name = if (local.nameUpdatedAt > server.nameUpdatedAt) local.name else server.name,
nameUpdatedAt = maxOf(local.nameUpdatedAt, server.nameUpdatedAt),
dosage = if (local.dosageUpdatedAt > server.dosageUpdatedAt) local.dosage else server.dosage,
dosageUpdatedAt = maxOf(local.dosageUpdatedAt, server.dosageUpdatedAt),
version = maxOf(local.version, server.version) + 1
)
return json.encodeToString(merged).toByteArray()
}
}
@Serializable
private data class MedicationMergeDto(
val name: String,
val nameUpdatedAt: Long,
val dosage: String,
val dosageUpdatedAt: Long,
val version: Long
)
9.3. Operation Queue¶
// core/sync/SyncQueue.kt
class SyncQueue @Inject constructor(
private val syncQueueDao: SyncQueueDao
) {
companion object {
private const val MAX_RETRIES = 3
}
suspend fun add(operation: SyncOperation) {
val entity = SyncQueueEntity(
id = UUID.randomUUID().toString(),
entityType = operation.entityType.name,
entityId = operation.entityId.toString(),
operation = operation.operationType.name,
encryptedBlob = operation.encryptedBlob,
createdAt = System.currentTimeMillis(),
retryCount = 0
)
syncQueueDao.insert(entity)
}
suspend fun getAll(): List<SyncChange> {
return syncQueueDao.getPending(MAX_RETRIES)
.sortedBy { it.createdAt }
.mapNotNull { entity ->
val entityType = EntityType.values().find { it.name == entity.entityType }
val operation = SyncOperationType.values().find { it.name == entity.operation }
if (entityType != null && operation != null) {
SyncChange(
entityType = entityType,
entityId = UUID.fromString(entity.entityId),
operation = operation,
encryptedBlob = entity.encryptedBlob,
version = 1,
timestamp = LocalDateTime.ofInstant(
Instant.ofEpochMilli(entity.createdAt),
ZoneId.systemDefault()
)
)
} else null
}
}
suspend fun remove(entityId: UUID) {
syncQueueDao.delete(entityId.toString())
}
suspend fun incrementRetry(id: String) {
syncQueueDao.incrementRetry(id)
}
suspend fun retryFailed() {
syncQueueDao.resetRetries()
}
suspend fun clear() {
// Clear all pending operations
syncQueueDao.getPending(Int.MAX_VALUE).forEach {
syncQueueDao.delete(it.entityId)
}
}
}
9.4. Connectivity Monitor¶
// core/sync/ConnectivityMonitor.kt
interface ConnectivityMonitor {
val isConnected: StateFlow<Boolean>
val connectionType: StateFlow<ConnectionType>
}
enum class ConnectionType {
WIFI, CELLULAR, ETHERNET, UNKNOWN, NONE
}
class ConnectivityMonitorImpl @Inject constructor(
@ApplicationContext private val context: Context
) : ConnectivityMonitor {
private val connectivityManager =
context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
private val _isConnected = MutableStateFlow(false)
override val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow()
private val _connectionType = MutableStateFlow(ConnectionType.NONE)
override val connectionType: StateFlow<ConnectionType> = _connectionType.asStateFlow()
private val networkCallback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
_isConnected.value = true
updateConnectionType()
}
override fun onLost(network: Network) {
_isConnected.value = false
_connectionType.value = ConnectionType.NONE
}
override fun onCapabilitiesChanged(
network: Network,
networkCapabilities: NetworkCapabilities
) {
updateConnectionType()
}
}
init {
registerCallback()
// Check initial state
_isConnected.value = isNetworkAvailable()
updateConnectionType()
}
private fun registerCallback() {
val request = NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build()
connectivityManager.registerNetworkCallback(request, networkCallback)
}
private fun isNetworkAvailable(): Boolean {
val network = connectivityManager.activeNetwork ?: return false
val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false
return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
}
private fun updateConnectionType() {
val network = connectivityManager.activeNetwork
val capabilities = network?.let { connectivityManager.getNetworkCapabilities(it) }
_connectionType.value = when {
capabilities == null -> ConnectionType.NONE
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> ConnectionType.WIFI
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> ConnectionType.CELLULAR
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> ConnectionType.ETHERNET
else -> ConnectionType.UNKNOWN
}
}
}
10. Push Notifications¶
10.1. FCM Registration¶
// core/notifications/FCMService.kt
@AndroidEntryPoint
class FCMService : FirebaseMessagingService() {
@Inject
lateinit var keystoreManager: KeystoreManager
@Inject
lateinit var notificationManager: MedTimeNotificationManager
override fun onNewToken(token: String) {
super.onNewToken(token)
// Save token to secure storage
runBlocking {
keystoreManager.saveSecureValue("fcm_token", token.toByteArray())
}
// Send to server
sendTokenToServer(token)
}
override fun onMessageReceived(message: RemoteMessage) {
super.onMessageReceived(message)
val data = message.data
val notificationType = data["type"] ?: return
when (notificationType) {
"sync_required" -> handleSyncRequired()
"dose_reminder" -> handleDoseReminder(data)
"interaction_alert" -> handleInteractionAlert(data)
else -> handleGenericNotification(message)
}
}
private fun handleSyncRequired() {
// Trigger background sync
val workRequest = OneTimeWorkRequestBuilder<SyncWorker>()
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
.build()
WorkManager.getInstance(applicationContext).enqueue(workRequest)
}
private fun handleDoseReminder(data: Map<String, String>) {
val medicationId = data["medication_id"] ?: return
val medicationName = data["medication_name"] ?: "Medicamento"
val dosage = data["dosage"] ?: ""
val scheduledTime = data["scheduled_time"]?.toLongOrNull() ?: return
notificationManager.showDoseReminder(
medicationId = medicationId,
medicationName = medicationName,
dosage = dosage,
scheduledTime = scheduledTime
)
}
private fun handleInteractionAlert(data: Map<String, String>) {
val title = data["title"] ?: "Alerta de interaccion"
val body = data["body"] ?: ""
val severity = data["severity"] ?: "medium"
notificationManager.showInteractionAlert(
title = title,
body = body,
severity = severity
)
}
private fun handleGenericNotification(message: RemoteMessage) {
message.notification?.let { notification ->
notificationManager.showGenericNotification(
title = notification.title ?: "",
body = notification.body ?: ""
)
}
}
private fun sendTokenToServer(token: String) {
// Implementation to send token to backend
}
}
10.2. Notification Handling¶
// core/notifications/NotificationScheduler.kt
class NotificationScheduler @Inject constructor(
private val context: Context,
private val workManager: WorkManager,
private val scheduleRepository: ScheduleRepository,
private val medicationRepository: MedicationRepository,
private val dataStoreManager: DataStoreManager
) {
suspend fun scheduleReminders(medication: Medication, schedule: Schedule) {
// Cancel existing reminders
cancelReminders(medication.id)
if (!schedule.isActive) return
val reminderMinutes = dataStoreManager.reminderMinutesBefore.first()
schedule.times.forEach { time ->
// Schedule for next 7 days
repeat(7) { dayOffset ->
val doseDate = calculateDoseDate(time, dayOffset, schedule.daysOfWeek)
?: return@repeat
// Reminder notification
val reminderDate = doseDate.minusMinutes(reminderMinutes.toLong())
if (reminderDate.isAfter(LocalDateTime.now())) {
scheduleNotification(
id = "reminder-${medication.id}-${doseDate.toInstant(ZoneOffset.UTC).toEpochMilli()}",
title = "Hora de tomar ${medication.name}",
body = "${medication.dosage.displayString} en $reminderMinutes minutos",
triggerTime = reminderDate,
data = mapOf(
"medicationId" to medication.id.toString(),
"scheduledTime" to doseDate.toInstant(ZoneOffset.UTC).toEpochMilli().toString(),
"type" to "reminder"
)
)
}
// Missed dose notification (15 min after)
val missedDate = doseDate.plusMinutes(15)
if (missedDate.isAfter(LocalDateTime.now())) {
scheduleNotification(
id = "missed-${medication.id}-${doseDate.toInstant(ZoneOffset.UTC).toEpochMilli()}",
title = "Dosis pendiente",
body = "No has registrado ${medication.name}",
triggerTime = missedDate,
data = mapOf(
"medicationId" to medication.id.toString(),
"scheduledTime" to doseDate.toInstant(ZoneOffset.UTC).toEpochMilli().toString(),
"type" to "missed"
),
isCritical = true
)
}
}
}
}
suspend fun cancelReminders(medicationId: UUID) {
workManager.cancelAllWorkByTag("reminder-$medicationId")
workManager.cancelAllWorkByTag("missed-$medicationId")
}
suspend fun rescheduleAllReminders() {
// Cancel all existing
workManager.cancelAllWorkByTag("medtime_reminder")
// Get all active medications and schedules
val medications = medicationRepository.getAllMedications().filter { it.isActive }
medications.forEach { medication ->
scheduleRepository.getSchedule(medication.id)?.let { schedule ->
scheduleReminders(medication, schedule)
}
}
}
private fun scheduleNotification(
id: String,
title: String,
body: String,
triggerTime: LocalDateTime,
data: Map<String, String>,
isCritical: Boolean = false
) {
val delay = Duration.between(LocalDateTime.now(), triggerTime).toMillis()
if (delay <= 0) return
val workRequest = OneTimeWorkRequestBuilder<ReminderWorker>()
.setInitialDelay(delay, TimeUnit.MILLISECONDS)
.setInputData(
Data.Builder()
.putString("id", id)
.putString("title", title)
.putString("body", body)
.putBoolean("isCritical", isCritical)
.apply { data.forEach { (k, v) -> putString(k, v) } }
.build()
)
.addTag("medtime_reminder")
.addTag(id.substringBefore("-"))
.build()
workManager.enqueue(workRequest)
}
private fun calculateDoseDate(
time: Schedule.ScheduleTime,
dayOffset: Int,
daysOfWeek: Set<DayOfWeek>?
): LocalDateTime? {
val date = LocalDate.now().plusDays(dayOffset.toLong())
val dateTime = date.atTime(time.hour, time.minute)
// Check day of week filter
daysOfWeek?.let { days ->
if (date.dayOfWeek !in days) return null
}
return dateTime
}
}
10.3. Rich Notifications¶
// core/notifications/MedTimeNotificationManager.kt
class MedTimeNotificationManager @Inject constructor(
@ApplicationContext private val context: Context
) {
private val notificationManager =
context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
fun showDoseReminder(
medicationId: String,
medicationName: String,
dosage: String,
scheduledTime: Long
) {
val takeIntent = createActionIntent(
NotificationAction.TAKE_DOSE,
medicationId,
scheduledTime
)
val skipIntent = createActionIntent(
NotificationAction.SKIP_DOSE,
medicationId,
scheduledTime
)
val snoozeIntent = createActionIntent(
NotificationAction.SNOOZE,
medicationId,
scheduledTime
)
val notification = NotificationCompat.Builder(context, NotificationChannels.DOSE_REMINDERS)
.setSmallIcon(R.drawable.ic_pill)
.setContentTitle("Hora de tomar $medicationName")
.setContentText(dosage)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setCategory(NotificationCompat.CATEGORY_REMINDER)
.setAutoCancel(true)
.addAction(
R.drawable.ic_check,
"Tomar",
takeIntent
)
.addAction(
R.drawable.ic_snooze,
"10 min",
snoozeIntent
)
.addAction(
R.drawable.ic_close,
"Omitir",
skipIntent
)
.setContentIntent(createContentIntent(medicationId))
.build()
notificationManager.notify(
"dose_$medicationId".hashCode(),
notification
)
}
fun showInteractionAlert(
title: String,
body: String,
severity: String
) {
val notification = NotificationCompat.Builder(context, NotificationChannels.INTERACTION_ALERTS)
.setSmallIcon(R.drawable.ic_warning)
.setContentTitle(title)
.setContentText(body)
.setStyle(NotificationCompat.BigTextStyle().bigText(body))
.setPriority(
if (severity == "high") NotificationCompat.PRIORITY_MAX
else NotificationCompat.PRIORITY_HIGH
)
.setCategory(NotificationCompat.CATEGORY_ALARM)
.setAutoCancel(true)
.setColor(
when (severity) {
"high" -> Color.RED
"medium" -> Color.rgb(255, 152, 0)
else -> Color.YELLOW
}
)
.build()
notificationManager.notify(
"interaction_${System.currentTimeMillis()}".hashCode(),
notification
)
}
fun showGenericNotification(title: String, body: String) {
val notification = NotificationCompat.Builder(context, NotificationChannels.GENERAL)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(title)
.setContentText(body)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setAutoCancel(true)
.build()
notificationManager.notify(
"general_${System.currentTimeMillis()}".hashCode(),
notification
)
}
private fun createActionIntent(
action: NotificationAction,
medicationId: String,
scheduledTime: Long
): PendingIntent {
val intent = Intent(context, NotificationActionReceiver::class.java).apply {
this.action = action.name
putExtra("medicationId", medicationId)
putExtra("scheduledTime", scheduledTime)
}
return PendingIntent.getBroadcast(
context,
action.ordinal,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
}
private fun createContentIntent(medicationId: String): PendingIntent {
val intent = Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
putExtra("medicationId", medicationId)
}
return PendingIntent.getActivity(
context,
0,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
}
}
enum class NotificationAction {
TAKE_DOSE,
SKIP_DOSE,
SNOOZE,
VIEW_DETAILS
}
10.4. Notification Channels¶
// core/notifications/NotificationChannels.kt
object NotificationChannels {
const val DOSE_REMINDERS = "dose_reminders"
const val MISSED_DOSES = "missed_doses"
const val LOW_INVENTORY = "low_inventory"
const val INTERACTION_ALERTS = "interaction_alerts"
const val SYNC = "sync"
const val GENERAL = "general"
fun createChannels(context: Context) {
val notificationManager =
context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val channels = listOf(
NotificationChannel(
DOSE_REMINDERS,
"Recordatorios de dosis",
NotificationManager.IMPORTANCE_HIGH
).apply {
description = "Notificaciones para recordarte tomar tus medicamentos"
enableVibration(true)
setShowBadge(true)
},
NotificationChannel(
MISSED_DOSES,
"Dosis perdidas",
NotificationManager.IMPORTANCE_HIGH
).apply {
description = "Alertas cuando no has tomado una dosis"
enableVibration(true)
setShowBadge(true)
},
NotificationChannel(
LOW_INVENTORY,
"Inventario bajo",
NotificationManager.IMPORTANCE_DEFAULT
).apply {
description = "Avisos cuando tus medicamentos se están acabando"
},
NotificationChannel(
INTERACTION_ALERTS,
"Alertas de interacciones",
NotificationManager.IMPORTANCE_MAX
).apply {
description = "Alertas críticas sobre interacciones medicamentosas"
enableVibration(true)
setShowBadge(true)
setBypassDnd(true)
},
NotificationChannel(
SYNC,
"Sincronización",
NotificationManager.IMPORTANCE_LOW
).apply {
description = "Estado de sincronización en segundo plano"
setShowBadge(false)
},
NotificationChannel(
GENERAL,
"General",
NotificationManager.IMPORTANCE_DEFAULT
).apply {
description = "Notificaciones generales de la app"
}
)
notificationManager.createNotificationChannels(channels)
}
}
11. Widgets¶
11.1. Glance Integration¶
// widget/NextDoseWidget.kt
class NextDoseWidget : GlanceAppWidget() {
override val sizeMode = SizeMode.Responsive(
setOf(
DpSize(100.dp, 100.dp), // Small
DpSize(200.dp, 100.dp), // Medium
DpSize(300.dp, 150.dp) // Large
)
)
override suspend fun provideGlance(context: Context, id: GlanceId) {
provideContent {
val size = LocalSize.current
val state = currentState<NextDoseWidgetState>()
GlanceTheme {
when {
size.width < 150.dp -> SmallNextDoseContent(state)
size.width < 250.dp -> MediumNextDoseContent(state)
else -> LargeNextDoseContent(state)
}
}
}
}
}
@Composable
private fun SmallNextDoseContent(state: NextDoseWidgetState) {
Column(
modifier = GlanceModifier
.fillMaxSize()
.background(GlanceTheme.colors.background)
.padding(8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalAlignment = Alignment.CenterHorizontally
) {
Image(
provider = ImageProvider(R.drawable.ic_pill),
contentDescription = null,
modifier = GlanceModifier.size(24.dp)
)
Spacer(modifier = GlanceModifier.height(4.dp))
if (state.nextDose != null) {
Text(
text = state.nextDose.time,
style = TextStyle(
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
color = GlanceTheme.colors.onBackground
)
)
Text(
text = state.nextDose.medicationName,
style = TextStyle(
fontSize = 12.sp,
color = GlanceTheme.colors.onBackground
),
maxLines = 1
)
} else {
Text(
text = "✓",
style = TextStyle(
fontSize = 24.sp,
color = GlanceTheme.colors.primary
)
)
}
}
}
@Composable
private fun MediumNextDoseContent(state: NextDoseWidgetState) {
Row(
modifier = GlanceModifier
.fillMaxSize()
.background(GlanceTheme.colors.background)
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Image(
provider = ImageProvider(R.drawable.ic_pill),
contentDescription = null,
modifier = GlanceModifier.size(40.dp)
)
Spacer(modifier = GlanceModifier.width(12.dp))
if (state.nextDose != null) {
Column(modifier = GlanceModifier.defaultWeight()) {
Text(
text = "Siguiente dosis",
style = TextStyle(
fontSize = 12.sp,
color = GlanceTheme.colors.onBackground.copy(alpha = 0.7f)
)
)
Text(
text = state.nextDose.medicationName,
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
color = GlanceTheme.colors.onBackground
),
maxLines = 1
)
Text(
text = state.nextDose.dosage,
style = TextStyle(
fontSize = 14.sp,
color = GlanceTheme.colors.onBackground.copy(alpha = 0.7f)
)
)
}
Text(
text = state.nextDose.time,
style = TextStyle(
fontSize = 24.sp,
fontWeight = FontWeight.Bold,
color = GlanceTheme.colors.primary
)
)
} else {
Text(
text = "No hay dosis pendientes",
style = TextStyle(
fontSize = 14.sp,
color = GlanceTheme.colors.onBackground
)
)
}
}
}
@Composable
private fun LargeNextDoseContent(state: NextDoseWidgetState) {
Column(
modifier = GlanceModifier
.fillMaxSize()
.background(GlanceTheme.colors.background)
.padding(16.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
Image(
provider = ImageProvider(R.drawable.ic_pill),
contentDescription = null,
modifier = GlanceModifier.size(24.dp)
)
Spacer(modifier = GlanceModifier.width(8.dp))
Text(
text = "MedTime",
style = TextStyle(
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
color = GlanceTheme.colors.primary
)
)
}
Spacer(modifier = GlanceModifier.height(12.dp))
if (state.nextDose != null) {
Row(
modifier = GlanceModifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = GlanceModifier.defaultWeight()) {
Text(
text = state.nextDose.medicationName,
style = TextStyle(
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
color = GlanceTheme.colors.onBackground
)
)
Text(
text = state.nextDose.dosage,
style = TextStyle(
fontSize = 14.sp,
color = GlanceTheme.colors.onBackground.copy(alpha = 0.7f)
)
)
}
Column(horizontalAlignment = Alignment.End) {
Text(
text = state.nextDose.time,
style = TextStyle(
fontSize = 28.sp,
fontWeight = FontWeight.Bold,
color = GlanceTheme.colors.primary
)
)
Text(
text = state.nextDose.timeUntil,
style = TextStyle(
fontSize = 12.sp,
color = GlanceTheme.colors.onBackground.copy(alpha = 0.5f)
)
)
}
}
Spacer(modifier = GlanceModifier.height(12.dp))
Row(
modifier = GlanceModifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Button(
text = "Tomar",
onClick = actionRunCallback<TakeDoseAction>(),
modifier = GlanceModifier.defaultWeight()
)
Spacer(modifier = GlanceModifier.width(8.dp))
OutlineButton(
text = "Omitir",
onClick = actionRunCallback<SkipDoseAction>(),
modifier = GlanceModifier.defaultWeight()
)
}
} else {
Box(
modifier = GlanceModifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Image(
provider = ImageProvider(R.drawable.ic_check_circle),
contentDescription = null,
modifier = GlanceModifier.size(48.dp),
colorFilter = ColorFilter.tint(GlanceTheme.colors.primary)
)
Spacer(modifier = GlanceModifier.height(8.dp))
Text(
text = "No hay dosis pendientes",
style = TextStyle(
fontSize = 14.sp,
color = GlanceTheme.colors.onBackground
)
)
}
}
}
}
}
11.2. Widget State¶
// widget/WidgetStateManager.kt
data class NextDoseWidgetState(
val nextDose: NextDoseData? = null,
val lastUpdated: Long = System.currentTimeMillis()
)
data class NextDoseData(
val medicationId: String,
val medicationName: String,
val dosage: String,
val time: String,
val timeUntil: String,
val scheduledTimestamp: Long
)
class WidgetStateManager @Inject constructor(
@ApplicationContext private val context: Context,
private val medicationRepository: MedicationRepository,
private val scheduleRepository: ScheduleRepository
) {
suspend fun updateNextDoseWidget() {
val nextDose = calculateNextDose()
val state = NextDoseWidgetState(nextDose = nextDose)
GlanceAppWidgetManager(context)
.getGlanceIds(NextDoseWidget::class.java)
.forEach { glanceId ->
updateAppWidgetState(context, glanceId) {
it[stringPreferencesKey("state")] = Json.encodeToString(state)
}
NextDoseWidget().update(context, glanceId)
}
}
private suspend fun calculateNextDose(): NextDoseData? {
val now = LocalDateTime.now()
val medications = medicationRepository.getAllMedications()
.filter { it.isActive }
var nextDoseTime: LocalDateTime? = null
var nextMedication: Medication? = null
for (medication in medications) {
val schedule = scheduleRepository.getSchedule(medication.id) ?: continue
if (!schedule.isActive) continue
for (scheduleTime in schedule.times) {
val doseDateTime = now.toLocalDate().atTime(scheduleTime.hour, scheduleTime.minute)
if (doseDateTime.isAfter(now)) {
if (nextDoseTime == null || doseDateTime.isBefore(nextDoseTime)) {
nextDoseTime = doseDateTime
nextMedication = medication
}
}
}
}
return if (nextDoseTime != null && nextMedication != null) {
val timeUntil = Duration.between(now, nextDoseTime)
NextDoseData(
medicationId = nextMedication.id.toString(),
medicationName = nextMedication.name,
dosage = nextMedication.dosage.displayString,
time = nextDoseTime.format(DateTimeFormatter.ofPattern("HH:mm")),
timeUntil = formatDuration(timeUntil),
scheduledTimestamp = nextDoseTime.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli()
)
} else null
}
private fun formatDuration(duration: Duration): String {
return when {
duration.toMinutes() < 60 -> "En ${duration.toMinutes()} min"
duration.toHours() < 24 -> "En ${duration.toHours()} h"
else -> "En ${duration.toDays()} dias"
}
}
}
11.3. Widget Types¶
// widget/AdherenceWidget.kt
class AdherenceWidget : GlanceAppWidget() {
override suspend fun provideGlance(context: Context, id: GlanceId) {
provideContent {
val state = currentState<AdherenceWidgetState>()
GlanceTheme {
AdherenceWidgetContent(state)
}
}
}
}
@Composable
private fun AdherenceWidgetContent(state: AdherenceWidgetState) {
Row(
modifier = GlanceModifier
.fillMaxSize()
.background(GlanceTheme.colors.background)
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Circular progress
Box(
modifier = GlanceModifier.size(80.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
progress = state.adherenceRate.toFloat(),
modifier = GlanceModifier.fillMaxSize(),
color = when {
state.adherenceRate >= 0.9 -> ColorProvider(Color.Green)
state.adherenceRate >= 0.7 -> ColorProvider(Color.Yellow)
else -> ColorProvider(Color.Red)
}
)
Text(
text = "${(state.adherenceRate * 100).toInt()}%",
style = TextStyle(
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
color = GlanceTheme.colors.onBackground
)
)
}
Spacer(modifier = GlanceModifier.width(16.dp))
Column(modifier = GlanceModifier.defaultWeight()) {
Text(
text = "Esta semana",
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
color = GlanceTheme.colors.onBackground
)
)
Spacer(modifier = GlanceModifier.height(4.dp))
Text(
text = "${state.takenCount} de ${state.totalCount} dosis",
style = TextStyle(
fontSize = 14.sp,
color = GlanceTheme.colors.onBackground.copy(alpha = 0.7f)
)
)
if (state.streakDays > 0) {
Spacer(modifier = GlanceModifier.height(8.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Image(
provider = ImageProvider(R.drawable.ic_flame),
contentDescription = null,
modifier = GlanceModifier.size(16.dp),
colorFilter = ColorFilter.tint(ColorProvider(Color(0xFFFF9800)))
)
Spacer(modifier = GlanceModifier.width(4.dp))
Text(
text = "${state.streakDays} dias",
style = TextStyle(
fontSize = 12.sp,
color = ColorProvider(Color(0xFFFF9800))
)
)
}
}
}
}
}
data class AdherenceWidgetState(
val adherenceRate: Double = 0.0,
val takenCount: Int = 0,
val totalCount: Int = 0,
val streakDays: Int = 0
)
11.4. Refresh Strategy¶
// widget/WidgetRefreshWorker.kt
@HiltWorker
class WidgetRefreshWorker @AssistedInject constructor(
@Assisted context: Context,
@Assisted workerParams: WorkerParameters,
private val widgetStateManager: WidgetStateManager
) : CoroutineWorker(context, workerParams) {
override suspend fun doWork(): Result {
return try {
widgetStateManager.updateNextDoseWidget()
widgetStateManager.updateAdherenceWidget()
Result.success()
} catch (e: Exception) {
if (runAttemptCount < 3) Result.retry()
else Result.failure()
}
}
companion object {
fun schedulePeriodicRefresh(workManager: WorkManager) {
val constraints = Constraints.Builder()
.setRequiresBatteryNotLow(true)
.build()
val refreshWork = PeriodicWorkRequestBuilder<WidgetRefreshWorker>(
30, TimeUnit.MINUTES
)
.setConstraints(constraints)
.build()
workManager.enqueueUniquePeriodicWork(
"widget_refresh",
ExistingPeriodicWorkPolicy.KEEP,
refreshWork
)
}
fun refreshNow(workManager: WorkManager) {
val refreshWork = OneTimeWorkRequestBuilder<WidgetRefreshWorker>()
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
.build()
workManager.enqueue(refreshWork)
}
}
}
// Widget actions
class TakeDoseAction : ActionCallback {
override suspend fun onAction(
context: Context,
glanceId: GlanceId,
parameters: ActionParameters
) {
val intent = Intent(context, NotificationActionReceiver::class.java).apply {
action = NotificationAction.TAKE_DOSE.name
// Add medication details from widget state
}
context.sendBroadcast(intent)
// Refresh widget
WidgetRefreshWorker.refreshNow(WorkManager.getInstance(context))
}
}
class SkipDoseAction : ActionCallback {
override suspend fun onAction(
context: Context,
glanceId: GlanceId,
parameters: ActionParameters
) {
val intent = Intent(context, NotificationActionReceiver::class.java).apply {
action = NotificationAction.SKIP_DOSE.name
}
context.sendBroadcast(intent)
WidgetRefreshWorker.refreshNow(WorkManager.getInstance(context))
}
}
12. Wear OS¶
12.1. Data Layer API¶
// wear/DataLayerManager.kt
class DataLayerManager @Inject constructor(
@ApplicationContext private val context: Context
) {
private val dataClient: DataClient = Wearable.getDataClient(context)
private val messageClient: MessageClient = Wearable.getMessageClient(context)
private val nodeClient: NodeClient = Wearable.getNodeClient(context)
companion object {
private const val NEXT_DOSE_PATH = "/next_dose"
private const val TODAY_STATS_PATH = "/today_stats"
private const val MEDICATIONS_PATH = "/medications"
private const val DOSE_LOGGED_PATH = "/dose_logged"
}
// Send data to watch
suspend fun updateNextDose(dose: WatchDoseData) {
val dataMapRequest = PutDataMapRequest.create(NEXT_DOSE_PATH).apply {
dataMap.putString("medicationId", dose.medicationId)
dataMap.putString("medicationName", dose.medicationName)
dataMap.putString("dosage", dose.dosage)
dataMap.putLong("scheduledTime", dose.scheduledTime)
dataMap.putLong("timestamp", System.currentTimeMillis()) // Force update
}
val request = dataMapRequest.asPutDataRequest().setUrgent()
dataClient.putDataItem(request).await()
}
suspend fun updateTodayStats(taken: Int, total: Int) {
val dataMapRequest = PutDataMapRequest.create(TODAY_STATS_PATH).apply {
dataMap.putInt("taken", taken)
dataMap.putInt("total", total)
dataMap.putLong("timestamp", System.currentTimeMillis())
}
val request = dataMapRequest.asPutDataRequest().setUrgent()
dataClient.putDataItem(request).await()
}
suspend fun updateMedications(medications: List<WatchMedicationData>) {
val dataMapRequest = PutDataMapRequest.create(MEDICATIONS_PATH).apply {
val dataMapArrayList = medications.map { med ->
DataMap().apply {
putString("id", med.id)
putString("name", med.name)
putString("dosage", med.dosage)
med.nextDoseTime?.let { putLong("nextDoseTime", it) }
}
}
dataMap.putDataMapArrayList("medications", ArrayList(dataMapArrayList))
dataMap.putLong("timestamp", System.currentTimeMillis())
}
val request = dataMapRequest.asPutDataRequest()
dataClient.putDataItem(request).await()
}
// Receive messages from watch
suspend fun listenForDoseLogged(): Flow<DoseLoggedMessage> = callbackFlow {
val listener = MessageClient.OnMessageReceivedListener { event ->
if (event.path == DOSE_LOGGED_PATH) {
val dataMap = DataMap.fromByteArray(event.data)
val message = DoseLoggedMessage(
medicationId = dataMap.getString("medicationId"),
scheduledTime = dataMap.getLong("scheduledTime"),
takenTime = dataMap.getLong("takenTime"),
status = dataMap.getString("status")
)
trySend(message)
}
}
messageClient.addListener(listener)
awaitClose { messageClient.removeListener(listener) }
}
// Check if watch is connected
suspend fun isWatchConnected(): Boolean {
return try {
val nodes = nodeClient.connectedNodes.await()
nodes.any { it.isNearby }
} catch (e: Exception) {
false
}
}
}
data class WatchDoseData(
val medicationId: String,
val medicationName: String,
val dosage: String,
val scheduledTime: Long
)
data class WatchMedicationData(
val id: String,
val name: String,
val dosage: String,
val nextDoseTime: Long?
)
data class DoseLoggedMessage(
val medicationId: String,
val scheduledTime: Long,
val takenTime: Long,
val status: String
)
12.2. Complications¶
// wear/ComplicationService.kt
class NextDoseComplicationService : SuspendingComplicationDataSourceService() {
@Inject
lateinit var medicationRepository: MedicationRepository
@Inject
lateinit var scheduleRepository: ScheduleRepository
override fun getPreviewData(type: ComplicationType): ComplicationData? {
return when (type) {
ComplicationType.SHORT_TEXT -> ShortTextComplicationData.Builder(
text = PlainComplicationText.Builder("08:00").build(),
contentDescription = PlainComplicationText.Builder("Siguiente dosis").build()
)
.setTitle(PlainComplicationText.Builder("💊").build())
.build()
ComplicationType.LONG_TEXT -> LongTextComplicationData.Builder(
text = PlainComplicationText.Builder("Aspirina 100mg").build(),
contentDescription = PlainComplicationText.Builder("Siguiente dosis").build()
)
.setTitle(PlainComplicationText.Builder("08:00").build())
.build()
ComplicationType.RANGED_VALUE -> RangedValueComplicationData.Builder(
value = 0.85f,
min = 0f,
max = 1f,
contentDescription = PlainComplicationText.Builder("Adherencia").build()
)
.setText(PlainComplicationText.Builder("85%").build())
.build()
else -> null
}
}
override suspend fun onComplicationRequest(request: ComplicationRequest): ComplicationData? {
val nextDose = calculateNextDose()
return when (request.complicationType) {
ComplicationType.SHORT_TEXT -> {
if (nextDose != null) {
ShortTextComplicationData.Builder(
text = PlainComplicationText.Builder(
nextDose.time.format(DateTimeFormatter.ofPattern("HH:mm"))
).build(),
contentDescription = PlainComplicationText.Builder(
"Siguiente dosis: ${nextDose.medicationName}"
).build()
)
.setTitle(PlainComplicationText.Builder("💊").build())
.setTapAction(createTapAction())
.build()
} else {
ShortTextComplicationData.Builder(
text = PlainComplicationText.Builder("--:--").build(),
contentDescription = PlainComplicationText.Builder("Sin dosis").build()
)
.setTitle(PlainComplicationText.Builder("✓").build())
.build()
}
}
ComplicationType.LONG_TEXT -> {
if (nextDose != null) {
LongTextComplicationData.Builder(
text = PlainComplicationText.Builder(
"${nextDose.medicationName} ${nextDose.dosage}"
).build(),
contentDescription = PlainComplicationText.Builder(
"Siguiente dosis"
).build()
)
.setTitle(PlainComplicationText.Builder(
nextDose.time.format(DateTimeFormatter.ofPattern("HH:mm"))
).build())
.setTapAction(createTapAction())
.build()
} else {
LongTextComplicationData.Builder(
text = PlainComplicationText.Builder("Sin dosis pendientes").build(),
contentDescription = PlainComplicationText.Builder("Completo").build()
).build()
}
}
else -> null
}
}
private suspend fun calculateNextDose(): NextDoseInfo? {
// Similar to widget calculation
return null
}
private fun createTapAction(): PendingIntent {
val intent = Intent(this, WearMainActivity::class.java)
return PendingIntent.getActivity(
this,
0,
intent,
PendingIntent.FLAG_IMMUTABLE
)
}
data class NextDoseInfo(
val medicationName: String,
val dosage: String,
val time: LocalTime
)
}
12.3. Standalone Mode¶
// wear/WearMainActivity.kt
@AndroidEntryPoint
class WearMainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MedTimeWearTheme {
WearApp()
}
}
}
}
@Composable
fun WearApp() {
val navController = rememberSwipeDismissableNavController()
SwipeDismissableNavHost(
navController = navController,
startDestination = "home"
) {
composable("home") {
WearHomeScreen(
onNavigateToMedications = {
navController.navigate("medications")
}
)
}
composable("medications") {
WearMedicationListScreen(
onNavigateBack = { navController.popBackStack() }
)
}
}
}
@Composable
fun WearHomeScreen(
viewModel: WearHomeViewModel = hiltViewModel(),
onNavigateToMedications: () -> Unit
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
ScalingLazyColumn(
modifier = Modifier.fillMaxSize(),
autoCentering = AutoCenteringParams(itemIndex = 0)
) {
item {
ListHeader {
Text("MedTime")
}
}
// Next dose card
item {
uiState.nextDose?.let { dose ->
NextDoseWearCard(
dose = dose,
onTake = { viewModel.takeDose(dose) }
)
} ?: NoDosesCard()
}
// Today's progress
item {
TodayProgressChip(
taken = uiState.takenToday,
total = uiState.totalToday
)
}
// Medications list button
item {
Chip(
onClick = onNavigateToMedications,
label = { Text("Medicamentos") },
icon = {
Icon(
imageVector = Icons.Default.Medication,
contentDescription = null
)
},
modifier = Modifier.fillMaxWidth()
)
}
}
}
@Composable
fun NextDoseWearCard(
dose: WatchDose,
onTake: () -> Unit
) {
var showConfirmation by remember { mutableStateOf(false) }
TitleCard(
onClick = { showConfirmation = true },
title = { Text(dose.medicationName) },
time = {
Text(
text = formatTime(dose.scheduledTime),
style = MaterialTheme.typography.display3
)
}
) {
Text(dose.dosage)
}
if (showConfirmation) {
ConfirmationOverlay(
onConfirm = {
onTake()
showConfirmation = false
},
onCancel = { showConfirmation = false }
)
}
}
@Composable
fun TodayProgressChip(taken: Int, total: Int) {
Chip(
onClick = {},
label = {
Text("Hoy: $taken/$total")
},
secondaryLabel = {
LinearProgressIndicator(
progress = { if (total > 0) taken.toFloat() / total else 0f },
modifier = Modifier.fillMaxWidth()
)
},
modifier = Modifier.fillMaxWidth()
)
}
12.4. Health Services¶
// wear/HealthServicesManager.kt
class HealthServicesManager @Inject constructor(
@ApplicationContext private val context: Context
) {
private val healthServicesClient = HealthServices.getClient(context)
private val passiveMonitoringClient = healthServicesClient.passiveMonitoringClient
// MedTime might use heart rate to correlate with medication effects
// (e.g., beta blockers affecting heart rate)
suspend fun supportsHeartRate(): Boolean {
val capabilities = passiveMonitoringClient.getCapabilitiesAsync().await()
return DataType.HEART_RATE_BPM in capabilities.supportedDataTypesPassiveMonitoring
}
suspend fun startHeartRateMonitoring() {
if (!supportsHeartRate()) return
val config = PassiveListenerConfig.builder()
.setDataTypes(setOf(DataType.HEART_RATE_BPM))
.build()
passiveMonitoringClient.setPassiveListenerServiceAsync(
HeartRatePassiveService::class.java,
config
).await()
}
suspend fun stopHeartRateMonitoring() {
passiveMonitoringClient.clearPassiveListenerServiceAsync().await()
}
}
class HeartRatePassiveService : PassiveListenerService() {
override fun onNewDataPointsReceived(dataPoints: DataPointContainer) {
val heartRatePoints = dataPoints.getData(DataType.HEART_RATE_BPM)
heartRatePoints.forEach { dataPoint ->
val bpm = dataPoint.value
val timestamp = dataPoint.getTimeInstant(TimeReference.CURRENT_TIME)
// Could correlate with recent medication intake
// e.g., log heart rate after taking blood pressure medication
}
}
}
13. Testing¶
13.1. Unit Tests¶
// test/domain/usecases/GetMedicationsUseCaseTest.kt
@ExperimentalCoroutinesApi
class GetMedicationsUseCaseTest {
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
private lateinit var useCase: GetMedicationsUseCase
private lateinit var mockRepository: MedicationRepository
@Before
fun setup() {
mockRepository = mockk()
useCase = GetMedicationsUseCase(mockRepository)
}
@Test
fun `execute returns medications from repository`() = runTest {
// Given
val medications = listOf(
createMedication(name = "Aspirin"),
createMedication(name = "Ibuprofen")
)
coEvery { mockRepository.getAllMedications() } returns medications
// When
val result = useCase.execute(filter = null)
// Then
assertEquals(2, result.size)
assertEquals("Aspirin", result[0].name)
assertEquals("Ibuprofen", result[1].name)
}
@Test
fun `execute with active filter returns only active medications`() = runTest {
// Given
val medications = listOf(
createMedication(name = "Active Med", isActive = true),
createMedication(name = "Inactive Med", isActive = false)
)
coEvery { mockRepository.getAllMedications() } returns medications
// When
val result = useCase.execute(
filter = GetMedicationsUseCase.Filter(isActive = true)
)
// Then
assertEquals(1, result.size)
assertEquals("Active Med", result[0].name)
}
@Test
fun `execute with search filter filters by name`() = runTest {
// Given
val medications = listOf(
createMedication(name = "Aspirin"),
createMedication(name = "Ibuprofen"),
createMedication(name = "Paracetamol")
)
coEvery { mockRepository.getAllMedications() } returns medications
// When
val result = useCase.execute(
filter = GetMedicationsUseCase.Filter(searchText = "ibu")
)
// Then
assertEquals(1, result.size)
assertEquals("Ibuprofen", result[0].name)
}
@Test
fun `execute sorts medications by name`() = runTest {
// Given
val medications = listOf(
createMedication(name = "Zebra"),
createMedication(name = "Alpha"),
createMedication(name = "Beta")
)
coEvery { mockRepository.getAllMedications() } returns medications
// When
val result = useCase.execute(filter = null)
// Then
assertEquals("Alpha", result[0].name)
assertEquals("Beta", result[1].name)
assertEquals("Zebra", result[2].name)
}
private fun createMedication(
id: UUID = UUID.randomUUID(),
name: String = "Test Med",
isActive: Boolean = true
) = Medication(
id = id,
name = name,
genericName = null,
dosage = Medication.Dosage(100.0, DosageUnit.MG),
form = MedicationForm.TABLET,
route = AdministrationRoute.ORAL,
frequency = DosageFrequency.TimesPerDay(2),
instructions = null,
startDate = LocalDate.now(),
endDate = null,
isActive = isActive,
requiresPrescription = false,
inventory = null,
reminders = emptyList(),
createdAt = LocalDateTime.now(),
updatedAt = LocalDateTime.now(),
version = 1
)
}
// test/data/repositories/MedicationRepositoryImplTest.kt
@ExperimentalCoroutinesApi
class MedicationRepositoryImplTest {
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
private lateinit var repository: MedicationRepositoryImpl
private lateinit var mockMedicationDao: MedicationDao
private lateinit var mockCryptoManager: CryptoManager
private lateinit var mockSyncQueueDao: SyncQueueDao
private lateinit var mapper: MedicationMapper
@Before
fun setup() {
mockMedicationDao = mockk()
mockCryptoManager = mockk()
mockSyncQueueDao = mockk()
mapper = MedicationMapper()
repository = MedicationRepositoryImpl(
medicationDao = mockMedicationDao,
apiService = mockk(),
cryptoManager = mockCryptoManager,
mapper = mapper,
syncQueueDao = mockSyncQueueDao
)
}
@Test
fun `getAllMedications decrypts blobs`() = runTest {
// Given
val encryptedBlob = "encrypted".toByteArray()
val decryptedJson = """
{
"name": "Test Med",
"genericName": null,
"dosageAmount": 100.0,
"dosageUnit": "MG",
"frequency": "times_per_day:2",
"instructions": null,
"inventory": null
}
""".trimIndent().toByteArray()
val entity = MedicationEntity(
id = UUID.randomUUID().toString(),
nameSearchable = "test med",
form = "TABLET",
route = "ORAL",
startDate = "2024-01-01",
endDate = null,
isActive = true,
requiresPrescription = false,
encryptedBlob = encryptedBlob,
createdAt = System.currentTimeMillis(),
updatedAt = System.currentTimeMillis(),
version = 1,
isDeleted = false
)
coEvery { mockMedicationDao.getAll() } returns listOf(entity)
coEvery { mockCryptoManager.decrypt(encryptedBlob) } returns decryptedJson
// When
val result = repository.getAllMedications()
// Then
assertEquals(1, result.size)
assertEquals("Test Med", result[0].name)
coVerify { mockCryptoManager.decrypt(encryptedBlob) }
}
@Test
fun `save encrypts and adds to sync queue`() = runTest {
// Given
val medication = createTestMedication()
val encryptedBlob = "encrypted".toByteArray()
coEvery { mockCryptoManager.encrypt(any()) } returns encryptedBlob
coEvery { mockMedicationDao.insertOrUpdate(any()) } just Runs
coEvery { mockSyncQueueDao.insert(any()) } just Runs
// When
repository.save(medication)
// Then
coVerify { mockCryptoManager.encrypt(any()) }
coVerify { mockMedicationDao.insertOrUpdate(any()) }
coVerify { mockSyncQueueDao.insert(any()) }
}
}
13.2. UI Tests¶
// androidTest/presentation/screens/HomeScreenTest.kt
@HiltAndroidTest
@ExperimentalCoroutinesApi
class HomeScreenTest {
@get:Rule(order = 0)
val hiltRule = HiltAndroidRule(this)
@get:Rule(order = 1)
val composeRule = createAndroidComposeRule<MainActivity>()
@Inject
lateinit var medicationRepository: MedicationRepository
@Before
fun setup() {
hiltRule.inject()
}
@Test
fun homeScreen_showsNextDoseCard() {
composeRule.setContent {
MedTimeTheme {
HomeScreen(
onNavigateToMedication = {},
onNavigateToCalendar = {}
)
}
}
composeRule
.onNodeWithText("Siguiente dosis")
.assertIsDisplayed()
}
@Test
fun homeScreen_takeDose_updatesUI() {
composeRule.setContent {
MedTimeTheme {
HomeScreen(
onNavigateToMedication = {},
onNavigateToCalendar = {}
)
}
}
// Click take button
composeRule
.onNodeWithText("Tomar")
.performClick()
// Verify success feedback
composeRule.waitUntil(timeoutMillis = 5000) {
composeRule
.onAllNodesWithText("Dosis registrada")
.fetchSemanticsNodes().isNotEmpty()
}
}
@Test
fun homeScreen_emptyState_showsMessage() {
// Clear all medications
runBlocking {
medicationRepository.getAllMedications().forEach {
medicationRepository.delete(it)
}
}
composeRule.setContent {
MedTimeTheme {
HomeScreen(
onNavigateToMedication = {},
onNavigateToCalendar = {}
)
}
}
composeRule
.onNodeWithText("No hay dosis programadas para hoy")
.assertIsDisplayed()
}
}
// androidTest/presentation/screens/MedicationFlowTest.kt
@HiltAndroidTest
class MedicationFlowTest {
@get:Rule(order = 0)
val hiltRule = HiltAndroidRule(this)
@get:Rule(order = 1)
val composeRule = createAndroidComposeRule<MainActivity>()
@Test
fun addMedication_completesSuccessfully() {
composeRule.setContent {
MedTimeTheme {
NavGraph()
}
}
// Navigate to medications
composeRule
.onNodeWithText("Medicamentos")
.performClick()
// Tap add button
composeRule
.onNodeWithContentDescription("Agregar medicamento")
.performClick()
// Fill name
composeRule
.onNodeWithTag("medication_name")
.performTextInput("Aspirina")
// Select dosage
composeRule
.onNodeWithTag("dosage_field")
.performClick()
composeRule
.onNodeWithTag("dosage_amount")
.performTextInput("100")
composeRule
.onNodeWithText("mg")
.performClick()
// Save
composeRule
.onNodeWithText("Guardar")
.performClick()
// Verify medication appears
composeRule.waitUntil(timeoutMillis = 5000) {
composeRule
.onAllNodesWithText("Aspirina")
.fetchSemanticsNodes().isNotEmpty()
}
}
}
13.3. Screenshot Tests¶
// androidTest/presentation/screenshots/ScreenshotTests.kt
@HiltAndroidTest
class ScreenshotTests {
@get:Rule(order = 0)
val hiltRule = HiltAndroidRule(this)
@get:Rule(order = 1)
val composeRule = createComposeRule()
@Test
fun homeScreen_withDoses() {
composeRule.setContent {
MedTimeTheme {
HomeScreen(
viewModel = createTestViewModel(
nextDose = createTestDose(),
todayDoses = listOf(createTestDose(), createTestDose()),
adherence = AdherenceStats(20, 18, 1, 1)
),
onNavigateToMedication = {},
onNavigateToCalendar = {}
)
}
}
composeRule.onRoot().captureToImage().assertAgainstGolden(
"home_screen_with_doses"
)
}
@Test
fun homeScreen_empty() {
composeRule.setContent {
MedTimeTheme {
HomeScreen(
viewModel = createTestViewModel(
nextDose = null,
todayDoses = emptyList(),
adherence = null
),
onNavigateToMedication = {},
onNavigateToCalendar = {}
)
}
}
composeRule.onRoot().captureToImage().assertAgainstGolden(
"home_screen_empty"
)
}
@Test
fun medicationCard_allForms() {
MedicationForm.values().forEach { form ->
composeRule.setContent {
MedTimeTheme {
MedicationCard(
medication = createTestMedication(form = form)
)
}
}
composeRule.onRoot().captureToImage().assertAgainstGolden(
"medication_card_${form.name.lowercase()}"
)
}
}
@Test
fun adherenceWidget_sizes() {
val state = AdherenceWidgetState(
adherenceRate = 0.85,
takenCount = 17,
totalCount = 20,
streakDays = 7
)
listOf(
DpSize(155.dp, 155.dp) to "small",
DpSize(329.dp, 155.dp) to "medium"
).forEach { (size, name) ->
composeRule.setContent {
Box(modifier = Modifier.size(size)) {
AdherenceWidgetContent(state)
}
}
composeRule.onRoot().captureToImage().assertAgainstGolden(
"adherence_widget_$name"
)
}
}
}
13.4. Mock Strategies¶
// test/mocks/MockMedicationRepository.kt
class MockMedicationRepository : MedicationRepository {
private val medications = mutableListOf<Medication>()
var errorToThrow: DomainError? = null
override suspend fun getAllMedications(): List<Medication> {
errorToThrow?.let { throw it }
return medications.toList()
}
override suspend fun getMedication(id: UUID): Medication? {
errorToThrow?.let { throw it }
return medications.find { it.id == id }
}
override suspend fun save(medication: Medication): Medication {
errorToThrow?.let { throw it }
val index = medications.indexOfFirst { it.id == medication.id }
if (index >= 0) {
medications[index] = medication
} else {
medications.add(medication)
}
return medication
}
override suspend fun delete(medication: Medication) {
errorToThrow?.let { throw it }
medications.removeIf { it.id == medication.id }
}
override suspend fun search(query: String): List<Medication> {
errorToThrow?.let { throw it }
return medications.filter {
it.name.contains(query, ignoreCase = true)
}
}
fun setMedications(meds: List<Medication>) {
medications.clear()
medications.addAll(meds)
}
}
// test/mocks/MockCryptoManager.kt
class MockCryptoManager : CryptoManager {
var encryptCalled = false
var decryptCalled = false
var encryptedData: ByteArray = ByteArray(0)
var decryptedData: ByteArray = ByteArray(0)
var errorToThrow: CryptoException? = null
override suspend fun generateMasterKey(): ByteArray {
return ByteArray(32) { it.toByte() }
}
override suspend fun deriveMasterKey(password: String, salt: ByteArray): ByteArray {
return ByteArray(32) { it.toByte() }
}
override suspend fun encrypt(data: ByteArray): ByteArray {
encryptCalled = true
errorToThrow?.let { throw it }
return encryptedData.ifEmpty { data }
}
override suspend fun decrypt(encryptedData: ByteArray): ByteArray {
decryptCalled = true
errorToThrow?.let { throw it }
return decryptedData.ifEmpty { encryptedData }
}
override fun hash(data: ByteArray): String {
return "mock_hash_${data.size}"
}
}
// test/fakes/FakeDataStore.kt
class FakeDataStore : DataStore<Preferences> {
private val prefsFlow = MutableStateFlow(emptyPreferences())
override val data: Flow<Preferences> = prefsFlow
override suspend fun updateData(transform: suspend (Preferences) -> Preferences): Preferences {
val newPrefs = transform(prefsFlow.value)
prefsFlow.value = newPrefs
return newPrefs
}
fun clear() {
prefsFlow.value = emptyPreferences()
}
}
// test/fixtures/TestFixtures.kt
object TestFixtures {
fun createMedication(
id: UUID = UUID.randomUUID(),
name: String = "Test Medication",
isActive: Boolean = true,
form: MedicationForm = MedicationForm.TABLET
) = Medication(
id = id,
name = name,
genericName = null,
dosage = Medication.Dosage(100.0, DosageUnit.MG),
form = form,
route = AdministrationRoute.ORAL,
frequency = DosageFrequency.TimesPerDay(2),
instructions = null,
startDate = LocalDate.now(),
endDate = null,
isActive = isActive,
requiresPrescription = false,
inventory = null,
reminders = emptyList(),
createdAt = LocalDateTime.now(),
updatedAt = LocalDateTime.now(),
version = 1
)
fun createDoseLog(
id: UUID = UUID.randomUUID(),
medicationId: UUID = UUID.randomUUID(),
status: DoseStatus = DoseStatus.PENDING
) = DoseLog(
id = id,
medicationId = medicationId,
scheduledTime = LocalDateTime.now().plusHours(1),
takenTime = null,
status = status,
skipReason = null,
notes = null,
createdAt = LocalDateTime.now(),
updatedAt = LocalDateTime.now(),
version = 1
)
}
14. Dependencies¶
14.1. Gradle Version Catalog¶
# gradle/libs.versions.toml
[versions]
# Android
agp = "8.2.0"
kotlin = "1.9.21"
ksp = "1.9.21-1.0.15"
# AndroidX
core-ktx = "1.12.0"
lifecycle = "2.7.0"
activity-compose = "1.8.2"
navigation-compose = "2.7.6"
room = "2.6.1"
datastore = "1.0.0"
work = "2.9.0"
# Compose
compose-bom = "2024.01.00"
compose-compiler = "1.5.7"
# Hilt
hilt = "2.50"
hilt-navigation-compose = "1.1.0"
# Networking
retrofit = "2.9.0"
okhttp = "4.12.0"
moshi = "1.15.0"
# Security
security-crypto = "1.1.0-alpha06"
argon2 = "1.0.0"
# Firebase
firebase-bom = "32.7.0"
# Glance
glance = "1.0.0"
# Wear OS
wear-compose = "1.3.0"
horologist = "0.5.15"
health-services = "1.0.0-rc01"
# Testing
junit = "5.10.1"
mockk = "1.13.8"
turbine = "1.0.0"
truth = "1.1.5"
[libraries]
# AndroidX
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "core-ktx" }
androidx-lifecycle-runtime = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycle" }
androidx-lifecycle-viewmodel = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" }
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activity-compose" }
androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigation-compose" }
# Compose
compose-bom = { module = "androidx.compose:compose-bom", version.ref = "compose-bom" }
compose-ui = { module = "androidx.compose.ui:ui" }
compose-ui-graphics = { module = "androidx.compose.ui:ui-graphics" }
compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" }
compose-material3 = { module = "androidx.compose.material3:material3" }
compose-material-icons = { module = "androidx.compose.material:material-icons-extended" }
# Room
room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" }
room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" }
room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" }
# DataStore
datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" }
# WorkManager
work-runtime = { module = "androidx.work:work-runtime-ktx", version.ref = "work" }
# Hilt
hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" }
hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt" }
hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "hilt-navigation-compose" }
hilt-work = { module = "androidx.hilt:hilt-work", version = "1.1.0" }
# Networking
retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" }
retrofit-moshi = { module = "com.squareup.retrofit2:converter-moshi", version.ref = "retrofit" }
okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
okhttp-logging = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "okhttp" }
moshi = { module = "com.squareup.moshi:moshi-kotlin", version.ref = "moshi" }
moshi-codegen = { module = "com.squareup.moshi:moshi-kotlin-codegen", version.ref = "moshi" }
# Security
security-crypto = { module = "androidx.security:security-crypto", version.ref = "security-crypto" }
argon2 = { module = "com.lambdapioneer.argon2kt:argon2kt", version.ref = "argon2" }
# Firebase
firebase-bom = { module = "com.google.firebase:firebase-bom", version.ref = "firebase-bom" }
firebase-messaging = { module = "com.google.firebase:firebase-messaging-ktx" }
firebase-analytics = { module = "com.google.firebase:firebase-analytics-ktx" }
# Glance
glance-appwidget = { module = "androidx.glance:glance-appwidget", version.ref = "glance" }
glance-material3 = { module = "androidx.glance:glance-material3", version.ref = "glance" }
# Wear OS
wear-compose-material = { module = "androidx.wear.compose:compose-material", version.ref = "wear-compose" }
wear-compose-foundation = { module = "androidx.wear.compose:compose-foundation", version.ref = "wear-compose" }
wear-compose-navigation = { module = "androidx.wear.compose:compose-navigation", version.ref = "wear-compose" }
horologist-compose-layout = { module = "com.google.android.horologist:horologist-compose-layout", version.ref = "horologist" }
health-services = { module = "androidx.health:health-services-client", version.ref = "health-services" }
# Testing
junit = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit" }
mockk = { module = "io.mockk:mockk", version.ref = "mockk" }
mockk-android = { module = "io.mockk:mockk-android", version.ref = "mockk" }
turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" }
truth = { module = "com.google.truth:truth", version.ref = "truth" }
compose-ui-test = { module = "androidx.compose.ui:ui-test-junit4" }
compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" }
hilt-testing = { module = "com.google.dagger:hilt-android-testing", version.ref = "hilt" }
[bundles]
compose = ["compose-ui", "compose-ui-graphics", "compose-ui-tooling-preview", "compose-material3", "compose-material-icons"]
room = ["room-runtime", "room-ktx"]
networking = ["retrofit", "retrofit-moshi", "okhttp", "okhttp-logging", "moshi"]
testing = ["junit", "mockk", "turbine", "truth"]
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
android-library = { id = "com.android.library", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
14.2. Version Constraints¶
| Dependency | Version | Razon |
|---|---|---|
| Hilt | 2.50+ | DI container oficial Android |
| Room | 2.6+ | Database con KSP |
| Retrofit | 2.9+ | REST client maduro |
| Moshi | 1.15+ | JSON parsing eficiente |
| Security-Crypto | 1.1.0+ | EncryptedSharedPreferences |
| Argon2kt | 1.0+ | Key derivation segura |
| Glance | 1.0+ | Widgets con Compose |
Politica de Dependencias:
- Minimizar dependencias externas - Preferir APIs de Android/Jetpack
- No incluir RxJava - Usar Kotlin Coroutines/Flow
- No incluir SQLCipher - Room + encryption at app level
- Auditar seguridad - Revisar CVEs antes de adoptar
14.3. Root Detection (DV2-P3: SEC-BAJO-003)¶
// core/security/RootDetector.kt
package com.medtime.core.security
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import java.io.File
import javax.inject.Inject
import javax.inject.Singleton
/**
* Detecta si el dispositivo esta rooted
* Comportamiento por defecto: BLOCK (bloquear app si detectado)
*/
@Singleton
class RootDetector @Inject constructor(
private val context: Context
) {
enum class DetectionResult {
SECURE, // No root detectado
ROOTED, // Root detectado
SUSPICIOUS // Indicadores sospechosos
}
enum class SecurityAction {
BLOCK, // Bloquear app completamente (DEFAULT)
WARN, // Mostrar warning, permitir continuar
LOG, // Solo log, para debug
NONE // No action (solo para testing)
}
companion object {
// CONFIGURACION POR DEFECTO: BLOCK
val DEFAULT_ACTION = SecurityAction.BLOCK
}
/**
* Detecta root con multiple heuristicas
*/
fun detect(): DetectionResult {
var score = 0
val checks = mapOf(
"Su binary" to checkSuBinary(),
"Busybox binary" to checkBusyBox(),
"Root apps" to checkRootApps(),
"Dangerous props" to checkDangerousProps(),
"RW system paths" to checkRWPaths(),
"Test keys" to checkTestKeys(),
"Magisk" to checkMagisk()
)
checks.forEach { (name, detected) ->
if (detected) {
score++
Logger.security.warning("Root indicator: $name")
}
}
// Clasificacion por score
return when {
score >= 3 -> DetectionResult.ROOTED
score > 0 -> DetectionResult.SUSPICIOUS
else -> DetectionResult.SECURE
}
}
// MARK: - Detection Methods
private fun checkSuBinary(): Boolean {
// Buscar binario 'su' en paths comunes
val paths = arrayOf(
"/system/bin/su",
"/system/xbin/su",
"/system/sbin/su",
"/sbin/su",
"/vendor/bin/su",
"/system/su",
"/system/bin/.ext/.su",
"/system/usr/we-need-root/su-backup",
"/system/xbin/mu",
"/su/bin/su",
"/data/local/su",
"/data/local/bin/su",
"/data/local/xbin/su"
)
return paths.any { File(it).exists() }
}
private fun checkBusyBox(): Boolean {
// BusyBox es comun en dispositivos rooted
val paths = arrayOf(
"/system/bin/busybox",
"/system/xbin/busybox",
"/sbin/busybox",
"/system/sbin/busybox",
"/vendor/bin/busybox",
"/data/local/busybox",
"/data/local/bin/busybox",
"/data/local/xbin/busybox"
)
return paths.any { File(it).exists() }
}
private fun checkRootApps(): Boolean {
// Apps comunes en dispositivos rooted
val packages = arrayOf(
"com.noshufou.android.su",
"com.noshufou.android.su.elite",
"eu.chainfire.supersu",
"com.koushikdutta.superuser",
"com.thirdparty.superuser",
"com.yellowes.su",
"com.topjohnwu.magisk",
"com.kingroot.kinguser",
"com.kingo.root",
"com.smedialink.oneclickroot",
"com.zhiqupk.root.global",
"com.alephzain.framaroot"
)
return packages.any { isPackageInstalled(it) }
}
private fun isPackageInstalled(packageName: String): Boolean {
return try {
context.packageManager.getPackageInfo(packageName, 0)
true
} catch (e: PackageManager.NameNotFoundException) {
false
}
}
private fun checkDangerousProps(): Boolean {
// Properties que indican root/custom ROM
val props = arrayOf(
"[ro.debuggable]:[1]",
"[ro.secure]:[0]"
)
return try {
val process = Runtime.getRuntime().exec("getprop")
val output = process.inputStream.bufferedReader().use { it.readText() }
props.any { output.contains(it) }
} catch (e: Exception) {
false
}
}
private fun checkRWPaths(): Boolean {
// Verificar si /system esta montado como RW (read-write)
return try {
val process = Runtime.getRuntime().exec("mount")
val output = process.inputStream.bufferedReader().use { it.readText() }
output.contains("/system") && output.contains("rw,")
} catch (e: Exception) {
false
}
}
private fun checkTestKeys(): Boolean {
// Build tags contienen "test-keys" en ROMs custom
val buildTags = Build.TAGS
return buildTags != null && buildTags.contains("test-keys")
}
private fun checkMagisk(): Boolean {
// Detectar Magisk (metodo moderno de root)
val magiskPaths = arrayOf(
"/sbin/.magisk",
"/sbin/.core",
"/dev/magisk",
"/data/adb/magisk",
"/cache/.disable_magisk"
)
return magiskPaths.any { File(it).exists() }
}
// MARK: - Action Handler
fun enforcePolicy(
result: DetectionResult,
action: SecurityAction = DEFAULT_ACTION
) {
when (result to action) {
DetectionResult.SECURE to _ -> {
Logger.security.info("Device integrity: SECURE")
}
DetectionResult.ROOTED to SecurityAction.BLOCK -> {
Logger.security.critical("Root detected - BLOCKING APP")
// Lanzar activity de bloqueo
showBlockScreen()
}
DetectionResult.ROOTED to SecurityAction.WARN -> {
Logger.security.warning("Root detected - WARNING USER")
showWarning()
}
DetectionResult.ROOTED to SecurityAction.LOG -> {
Logger.security.warning("Root detected - LOGGED ONLY")
}
DetectionResult.SUSPICIOUS to SecurityAction.BLOCK -> {
Logger.security.error("Suspicious indicators - BLOCKING APP")
showBlockScreen()
}
DetectionResult.SUSPICIOUS to SecurityAction.WARN -> {
Logger.security.warning("Suspicious indicators - WARNING USER")
showWarning()
}
DetectionResult.SUSPICIOUS to SecurityAction.LOG -> {
Logger.security.info("Suspicious indicators - LOGGED ONLY")
}
_ to SecurityAction.NONE -> {
Logger.security.debug("Detection disabled for testing")
}
}
}
private fun showBlockScreen() {
// Lanzar RootBlockActivity
val intent = Intent(context, RootBlockActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
}
context.startActivity(intent)
}
private fun showWarning() {
// Mostrar dialog de advertencia
// Implementar via ViewModel/Event system
// (no podemos mostrar AlertDialog desde aqui sin Activity context)
}
}
// ui/security/RootBlockActivity.kt
@Composable
fun RootBlockScreen() {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.error),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(24.dp),
modifier = Modifier.padding(40.dp)
) {
Icon(
imageVector = Icons.Filled.Warning,
contentDescription = "Security Alert",
tint = MaterialTheme.colorScheme.onError,
modifier = Modifier.size(100.dp)
)
Text(
text = "Security Alert",
style = MaterialTheme.typography.headlineLarge,
color = MaterialTheme.colorScheme.onError,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center
)
Text(
text = "This device has been modified (rooted). For your safety, " +
"MedTime cannot run on modified devices as it may compromise " +
"your health data security.",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onError,
textAlign = TextAlign.Center
)
}
}
}
@AndroidEntryPoint
class RootBlockActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MedTimeTheme {
RootBlockScreen()
}
}
}
override fun onBackPressed() {
// Prevenir que usuario salga de pantalla de bloqueo
// No llamar super.onBackPressed()
}
}
// INTEGRACION EN APP LAUNCH
// app/MedTimeApplication.kt
@HiltAndroidApp
class MedTimeApplication : Application() {
@Inject
lateinit var rootDetector: RootDetector
override fun onCreate() {
super.onCreate()
// ROOT CHECK AL INICIO
val detectionResult = rootDetector.detect()
val action = when (BuildConfig.BUILD_TYPE) {
"release" -> RootDetector.SecurityAction.BLOCK
"staging" -> RootDetector.SecurityAction.WARN
else -> RootDetector.SecurityAction.LOG
}
rootDetector.enforcePolicy(detectionResult, action)
// Continue con inicializacion normal...
}
}
Configuracion de Comportamiento:
| Ambiente | Action | Justificacion |
|---|---|---|
| Production | BLOCK | PHI critico, zero-tolerance |
| Staging | WARN | Permitir testing en dev devices |
| Debug | LOG | Facilitar desarrollo |
| Unit Tests | NONE | No interferir con tests |
Variables de Build Config (en build.gradle.kts):
buildTypes {
release {
buildConfigField("String", "ROOT_ACTION", "\"BLOCK\"")
}
create("staging") {
buildConfigField("String", "ROOT_ACTION", "\"WARN\"")
}
debug {
buildConfigField("String", "ROOT_ACTION", "\"LOG\"")
}
}
Justificacion de BLOCK como Default:
- HIPAA Compliance: Dispositivos modificados no garantizan integridad
- Keystore Vulnerabilities: Root permite acceso a Android Keystore
- SSL Pinning Bypass: Ataques MITM posibles con root
- Zero-Knowledge Compromise: Cliente comprometido = arquitectura falla
- SELinux Disabled: Root tipicamente deshabilita protecciones de sistema
Metricas de Telemetria:
// Logging cada deteccion (sin PII)
data class RootTelemetry(
val result: String, // "secure", "suspicious", "rooted"
val action: String, // "block", "warn", "log"
val timestamp: Long,
val osVersion: String,
val deviceManufacturer: String,
val deviceModel: String,
val buildTags: String
// NO incluir IMEI, Android ID, ni identificadores
)
SafetyNet/Play Integrity Integration (Opcional):
// Para deteccion mas robusta, integrar Google Play Integrity API
// Requiere backend verification
dependencies {
implementation("com.google.android.play:integrity:1.3.0")
}
class PlayIntegrityChecker @Inject constructor(
private val context: Context
) {
suspend fun checkIntegrity(): IntegrityResult {
val integrityManager = IntegrityManagerFactory.create(context)
// Request integrity token
val request = IntegrityTokenRequest.builder()
.setCloudProjectNumber(PROJECT_NUMBER)
.build()
val response = integrityManager.requestIntegrityToken(request).await()
// Send token to backend for verification
return verifyTokenOnBackend(response.token())
}
}
14.4. Security Audit¶
# Scripts/audit_dependencies.sh
#!/bin/bash
echo "Auditing Android dependencies..."
# Run Gradle dependency check
./gradlew dependencyCheckAnalyze
# Check for known vulnerabilities
./gradlew dependencyUpdates -DoutputFormatter=json
# Verify signatures
./gradlew verifySigning
echo "Dependencies audit complete."
15. Build y CI/CD¶
15.1. Build Variants¶
// app/build.gradle.kts
android {
namespace = "com.medtime"
compileSdk = 34
defaultConfig {
applicationId = "com.medtime"
minSdk = 26
targetSdk = 34
versionCode = 1
versionName = "1.0.0"
testInstrumentationRunner = "com.medtime.CustomTestRunner"
}
buildTypes {
debug {
applicationIdSuffix = ".dev"
isDebuggable = true
buildConfigField("String", "API_BASE_URL", "\"https://api-dev.medtime.app/\"")
buildConfigField("Boolean", "ENABLE_LOGGING", "true")
}
create("staging") {
applicationIdSuffix = ".staging"
isDebuggable = true
buildConfigField("String", "API_BASE_URL", "\"https://api-staging.medtime.app/\"")
buildConfigField("Boolean", "ENABLE_LOGGING", "true")
signingConfig = signingConfigs.getByName("debug")
}
release {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
buildConfigField("String", "API_BASE_URL", "\"https://api.medtime.app/\"")
buildConfigField("Boolean", "ENABLE_LOGGING", "false")
}
}
flavorDimensions += "tier"
productFlavors {
create("free") {
dimension = "tier"
applicationIdSuffix = ".free"
buildConfigField("String", "TIER", "\"free\"")
buildConfigField("Int", "MAX_MEDICATIONS", "5")
}
create("pro") {
dimension = "tier"
applicationIdSuffix = ".pro"
buildConfigField("String", "TIER", "\"pro\"")
buildConfigField("Int", "MAX_MEDICATIONS", "50")
}
create("perfect") {
dimension = "tier"
buildConfigField("String", "TIER", "\"perfect\"")
buildConfigField("Int", "MAX_MEDICATIONS", "999")
}
}
buildFeatures {
compose = true
buildConfig = true
}
composeOptions {
kotlinCompilerExtensionVersion = libs.versions.compose.compiler.get()
}
}
15.2. ProGuard/R8¶
# proguard-rules.pro
# Keep Kotlin metadata for reflection
-keepattributes *Annotation*, Signature, Exception
-keep class kotlin.Metadata { *; }
# Keep serialization
-keepclassmembers class * {
@kotlinx.serialization.SerialName <fields>;
}
# Keep Moshi adapters
-keepclassmembers class * {
@com.squareup.moshi.* <methods>;
}
-keep class **JsonAdapter {
<init>(...);
}
# Keep Room entities
-keep class com.medtime.data.models.entities.** { *; }
# Keep crypto classes
-keep class com.medtime.core.crypto.** { *; }
# Keep data classes used in sync
-keep class com.medtime.data.models.dto.** { *; }
# Keep Hilt generated code
-keep class dagger.hilt.** { *; }
-keep class * extends dagger.hilt.internal.GeneratedComponent { *; }
# Remove logging in release
-assumenosideeffects class android.util.Log {
public static int v(...);
public static int d(...);
public static int i(...);
}
15.3. GitHub Actions¶
# .github/workflows/android.yml
name: Android CI
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main, develop ]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
- name: Setup Gradle
uses: gradle/gradle-build-action@v2
- name: Run unit tests
run: ./gradlew testDebugUnitTest
- name: Upload test results
uses: actions/upload-artifact@v4
if: failure()
with:
name: test-results
path: app/build/reports/tests/
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
- name: Run lint
run: ./gradlew lintDebug
- name: Upload lint results
uses: actions/upload-artifact@v4
with:
name: lint-results
path: app/build/reports/lint-results-debug.html
build:
runs-on: ubuntu-latest
needs: [test, lint]
steps:
- uses: actions/checkout@v4
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
- name: Build debug APK
run: ./gradlew assembleDebug
- name: Upload APK
uses: actions/upload-artifact@v4
with:
name: app-debug
path: app/build/outputs/apk/debug/app-debug.apk
deploy-beta:
runs-on: ubuntu-latest
needs: build
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
- name: Decode keystore
run: echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > keystore.jks
- name: Build release bundle
env:
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
run: ./gradlew bundleRelease
- name: Upload to Play Store
uses: r0adkll/upload-google-play@v1
with:
serviceAccountJsonPlainText: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
packageName: com.medtime
releaseFiles: app/build/outputs/bundle/release/app-release.aab
track: internal
status: completed
15.4. Play Store¶
# fastlane/metadata/android/es-MX/full_description.txt
MedTime - Tu compañero inteligente de medicamentos
Nunca olvides una dosis. MedTime te ayuda a gestionar tus medicamentos con:
- Recordatorios inteligentes que se adaptan a tu horario
- Registro de dosis con un solo toque
- Compartir con familiares y cuidadores
- Modo offline - funciona sin internet
- Privacidad total - tus datos permanecen cifrados en tu dispositivo
Características principales:
- Agrega medicamentos ilimitados
- Horarios de recordatorio personalizables
- Alertas de interacciones
- Estadísticas de adherencia y rachas
- App complementaria para Wear OS
- Widgets para pantalla de inicio
- Exporta reportes para tu médico
Privacidad primero:
MedTime usa cifrado de extremo a extremo. Tus datos de salud se cifran en tu dispositivo y nunca son visibles para nuestros servidores. Tu privacidad es nuestra prioridad.
# fastlane/metadata/android/es-MX/short_description.txt
Gestiona tus medicamentos con recordatorios inteligentes y privacidad total
# fastlane/metadata/android/es-MX/title.txt
MedTime - Recordatorios de Medicamentos
16. Anexo A: Diagrama de Arquitectura¶
┌──────────────────────────────────────────────────────────────────────────┐
│ MedTime Android │
├──────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ PRESENTATION LAYER │ │
│ │ ┌──────────────┐ ┌───────────────┐ ┌─────────────────────────┐ │ │
│ │ │ Compose │ │ ViewModels │ │ NavController │ │ │
│ │ │ (UI) │──│ (MVVM) │──│ (Navigation) │ │ │
│ │ └──────────────┘ └───────────────┘ └─────────────────────────┘ │ │
│ └────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ DOMAIN LAYER │ │
│ │ ┌──────────────┐ ┌───────────────┐ ┌─────────────────────────┐ │ │
│ │ │ Entities │ │ Use Cases │ │ Repository Interfaces │ │ │
│ │ │ (Models) │ │ (Interactor) │ │ (Ports) │ │ │
│ │ └──────────────┘ └───────────────┘ └─────────────────────────┘ │ │
│ └────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ DATA LAYER │ │
│ │ ┌──────────────┐ ┌───────────────┐ ┌─────────────────────────┐ │ │
│ │ │ Repositories │ │ Data Sources │ │ Mappers │ │ │
│ │ │ (Adapters) │ │ Local/Remote │ │ (DTO <-> Entity) │ │ │
│ │ └──────────────┘ └───────────────┘ └─────────────────────────┘ │ │
│ └────────────────────────────────────────────────────────────────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ CORE LAYER │ │
│ │ ┌───────────┐ ┌────────────┐ ┌───────────┐ ┌────────────────┐ │ │
│ │ │ Crypto │ │ Sync │ │Connectivity│ │ Notifications │ │ │
│ │ │ (E2E/ZK) │ │ Manager │ │ Monitor │ │ Manager │ │ │
│ │ └───────────┘ └────────────┘ └───────────┘ └────────────────┘ │ │
│ └────────────────────────────────────────────────────────────────────┘ │
│ │ │ │ │
│ ┌────────────┴──────────────┴──────────────┘ │
│ ▼ ▼ │
│ ┌──────────────────────┐ ┌──────────────────────────────────────┐ │
│ │ LOCAL STORAGE │ │ REMOTE (Server) │ │
│ │ ┌────────────────┐ │ │ ┌─────────────────────────────────┐ │ │
│ │ │ Room │ │ │ │ API Client (Retrofit/Ktor) │ │ │
│ │ │ (SQLite) │ │ │ │ ┌────────────────────────────┐ │ │ │
│ │ └────────────────┘ │ │ │ │ Encrypted Blobs Only │ │ │ │
│ │ ┌────────────────┐ │ │ │ │ (Zero-Knowledge) │ │ │ │
│ │ │ Keystore │ │ │ │ └────────────────────────────┘ │ │ │
│ │ │ (Encryption │ │ │ └─────────────────────────────────┘ │ │
│ │ │ Keys) │ │ └──────────────────────────────────────┘ │
│ │ └────────────────┘ │ │
│ │ ┌────────────────┐ │ │
│ │ │ DataStore │ │ │
│ │ │ (Settings) │ │ │
│ │ └────────────────┘ │ │
│ └──────────────────────┘ │
│ │
│ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ EXTENSIONS │ │
│ │ ┌──────────────┐ ┌───────────────┐ ┌─────────────────────────┐ │ │
│ │ │ Widgets │ │ Wear OS │ │ Notifications │ │ │
│ │ │ (Glance) │ │ (Wearable) │ │ (FCM) │ │ │
│ │ └──────────────┘ └───────────────┘ └─────────────────────────┘ │ │
│ └────────────────────────────────────────────────────────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────────────────┘
17. Anexo B: Checklist de Implementacion¶
17.1. Fase 1: Setup Inicial¶
- Crear proyecto Android Studio con modulos (app, widget, wear)
- Configurar Gradle Version Catalog
- Setup Room database
- Configurar Android Keystore wrapper
- Implementar Hilt DI
17.2. Fase 2: Domain Layer¶
- Definir entities
- Implementar use cases core
- Definir repository interfaces
- Implementar error handling
17.3. Fase 3: Data Layer¶
- Implementar Room DAOs y entities
- Implementar API client (Retrofit)
- Crear mappers
- Implementar repositories
17.4. Fase 4: Crypto¶
- Implementar CryptoManager
- Key derivation (Argon2id)
- Blob encryption/decryption
- Android Keystore integration
17.5. Fase 5: Sync Engine¶
- Implementar SyncManager
- Queue de operaciones (WorkManager)
- Conflict resolution
- Connectivity monitor
17.6. Fase 6: Presentation¶
- Implementar navigation
- Crear ViewModels
- Diseñar screens principales (Compose)
- Implementar components
17.7. Fase 7: Notifications¶
- FCM registration
- Local notifications (WorkManager)
- Notification channels
- Notification actions
17.8. Fase 8: Extensions¶
- Widget con Glance
- Widget state management
- Wear OS app
- Data Layer connectivity
17.9. Fase 9: Testing¶
- Unit tests (80%+ coverage)
- UI tests (Compose testing)
- Screenshot tests
- Integration tests
17.10. Fase 10: Release¶
- Play Store metadata
- Screenshots
- Privacy policy
- Beta testing (Firebase App Distribution)
18. Observaciones de Validacion Arquitectonica¶
Validado por: ArchitectureDrone (MTS-DRN-ARC-001) Fecha: 2025-12-08
18.1. Resultado:APROBADO CON OBSERVACIONES MENORES¶
18.1. Resumen de Validacion¶
| Criterio | Estado |
|---|---|
| Clean Architecture correctamente implementada | ✅ APROBADO |
| Separacion LOCAL (95%) vs SERVIDOR (5%) | ✅ APROBADO |
| Zero-Knowledge garantizado | ✅ APROBADO |
| Principios SOLID aplicados | ✅ APROBADO |
| Consistencia con 04-seguridad-cliente.md | ✅ APROBADO |
| Consistencia con 02-arquitectura-cliente-servidor.md | ✅ APROBADO |
| Paridad funcional con MOB-IOS-001 | ✅ APROBADO |
18.2. Observaciones Menores¶
Las siguientes observaciones son de documentacion y completitud, NO afectan el diseno arquitectonico:
18.2.1. OBS-001: Parametros de Argon2id no explicitos¶
Ubicacion: Seccion 8.2 CryptoManager Detalle: El documento menciona Argon2id pero no especifica parametros exactos. Recomendacion: Agregar constantes explicitas en implementacion:
// CryptoManager.kt - Parametros Argon2id segun 04-seguridad-cliente.md
object Argon2Params {
const val MEMORY_COST = 64 * 1024 // 64 MiB
const val ITERATIONS = 3
const val PARALLELISM = 4
const val OUTPUT_LENGTH = 32 // 256 bits
}
18.2.2. OBS-002: Blind Index para email/telefono no detallado¶
Ubicacion: Seccion 8 Cifrado E2E Detalle: Se menciona blind indexes pero no hay implementacion Android especifica. Recomendacion: Agregar implementacion en cliente (el calculo ocurre en cliente, servidor solo almacena):
// BlindIndexManager.kt
object BlindIndexManager {
fun createEmailBlindIndex(email: String, globalSalt: ByteArray): String {
val normalized = email.lowercase().trim()
val input = "email:$normalized".toByteArray(Charsets.UTF_8)
val hmac = Mac.getInstance("HmacSHA256").apply {
init(SecretKeySpec(globalSalt, "HmacSHA256"))
}
return hmac.doFinal(input).take(16).toHexString()
}
fun createPhoneBlindIndex(phone: String, globalSalt: ByteArray): String {
val normalized = phone.replace(Regex("[^0-9+]"), "")
val input = "phone:$normalized".toByteArray(Charsets.UTF_8)
val hmac = Mac.getInstance("HmacSHA256").apply {
init(SecretKeySpec(globalSalt, "HmacSHA256"))
}
return hmac.doFinal(input).take(16).toHexString()
}
}
18.2.3. OBS-003: Ejemplos de Testing incompletos¶
Ubicacion: Seccion 13 Testing Detalle: La seccion define estructura pero ejemplos de tests se cortan. Recomendacion: Completar tests unitarios para componentes criticos:
// CryptoManagerTest.kt
@Test
fun `encrypt and decrypt should be reversible`() = runTest {
val plaintext = "sensitive medication data"
val encrypted = cryptoManager.encrypt(plaintext.toByteArray())
val decrypted = cryptoManager.decrypt(encrypted)
assertThat(String(decrypted)).isEqualTo(plaintext)
}
@Test
fun `different plaintexts should produce different ciphertexts`() = runTest {
val data1 = cryptoManager.encrypt("data1".toByteArray())
val data2 = cryptoManager.encrypt("data2".toByteArray())
assertThat(data1).isNotEqualTo(data2)
}
// SyncManagerTest.kt
@Test
fun `sync should handle offline gracefully`() = runTest {
coEvery { connectivityManager.isConnected() } returns false
val result = syncManager.sync()
assertThat(result).isInstanceOf(SyncResult.Offline::class.java)
coVerify(exactly = 0) { syncApi.uploadBlob(any()) }
}
18.2.4. OBS-004: Wear OS Health Services y Zero-Knowledge¶
Ubicacion: Seccion 12 Wear OS
Detalle: Se menciona Health Services para monitoreo de frecuencia cardiaca.
Aclaracion: Datos de Health Services son sensibles (PHI).
Recomendacion: Confirmar que datos de salud son LOCAL_ONLY:
// HealthDataManager.kt
object HealthDataClassification {
// Datos de Health Services NUNCA se sincronizan al servidor
// Son LOCAL_ONLY para cumplir Zero-Knowledge
val HEALTH_DATA_POLICY = DataPolicy.LOCAL_ONLY
// Solo se sincronizan:
// - Hora de toma de medicamento (timestamp)
// - Estado de toma (taken/skipped/missed)
//
// NUNCA se sincronizan:
// - Frecuencia cardiaca
// - Datos de pasos
// - Cualquier metrica de salud de Wear OS
}
18.3. Acciones Recomendadas¶
| Prioridad | Accion | Momento |
|---|---|---|
| Baja | Agregar parametros Argon2id explicitos | Pre-implementacion |
| Baja | Implementar BlindIndexManager | Con modulo AUTH |
| Media | Completar suite de tests | Durante desarrollo |
| Baja | Documentar politica Health Services | Pre-implementacion Wear |
18.4. Conclusion¶
El documento MOB-AND-001 cumple con todos los requisitos arquitectonicos de MedTime:
- Clean Architecture: Capas correctamente separadas, Domain aislado de frameworks
- Zero-Knowledge: Cifrado E2E antes de enviar, servidor permanece ciego
- Offline-First: Motor local completo con sincronizacion robusta
- Paridad iOS: Todas las capacidades tienen equivalente Android
Las observaciones menores son mejoras de documentacion que pueden incorporarse durante la fase de implementacion sin afectar el diseno arquitectonico aprobado.
Documento generado por SpecQueen Technical Division - IT-13 Validado por ArchitectureDrone - 2025-12-08 "La resistencia es futil cuando la arquitectura es perfecta."