Saltar a contenido

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

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:

  1. Privacidad: Arquitectura Zero-Knowledge donde el servidor no puede leer datos del usuario
  2. Offline-First: Funcionalidad completa sin conexion a internet
  3. Seguridad: Cifrado E2E de todos los datos sensibles (PHI)
  4. 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:

  1. Minimizar dependencias externas - Preferir APIs de Android/Jetpack
  2. No incluir RxJava - Usar Kotlin Coroutines/Flow
  3. No incluir SQLCipher - Room + encryption at app level
  4. 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:

  1. HIPAA Compliance: Dispositivos modificados no garantizan integridad
  2. Keystore Vulnerabilities: Root permite acceso a Android Keystore
  3. SSL Pinning Bypass: Ataques MITM posibles con root
  4. Zero-Knowledge Compromise: Cliente comprometido = arquitectura falla
  5. 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:

  1. Clean Architecture: Capas correctamente separadas, Domain aislado de frameworks
  2. Zero-Knowledge: Cifrado E2E antes de enviar, servidor permanece ciego
  3. Offline-First: Motor local completo con sincronizacion robusta
  4. 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."