package dev.privacyllc.period.di import android.content.Context import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.PreferenceDataStoreFactory import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.emptyPreferences import androidx.datastore.preferences.preferencesDataStoreFile import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import dev.privacyllc.period.core.data.CycleData import dev.privacyllc.period.core.data.CycleRepository import dev.privacyllc.period.core.datastore.UserPreferencesRepository import dev.privacyllc.period.core.notifications.ReminderScheduler import dev.privacyllc.period.core.security.AppLockRepository import dev.privacyllc.period.domain.prediction.PersonalPredictionEngine import dev.privacyllc.period.domain.prediction.PredictionEngine import dev.privacyllc.period.launcher.AndroidLauncherAliasSwitcher import dev.privacyllc.period.launcher.LauncherAliasSwitcher import java.time.Clock import javax.inject.Qualifier import javax.inject.Singleton /** * Where the app learns what a file path is, and the only place it does. * * Note what is absent: no `PeriodDatabase`, no DAO, no Room import anywhere in * this module or anywhere above it. `CycleData.repository` hands back a * repository and keeps the storage to itself — see * docs/architecture/README.md. */ @Module @InstallIn(SingletonComponent::class) object DataModule { /** * Singleton because Room and DataStore both are: two instances over one * file is a corruption bug that only shows up under concurrency. */ @Provides @Singleton fun cycleRepository( @ApplicationContext context: Context, engine: PredictionEngine, clock: Clock, ): CycleRepository = CycleData.repository(context, engine, clock) @Provides @Singleton fun preferencesDataStore(@ApplicationContext context: Context): DataStore = PreferenceDataStoreFactory.create { context.preferencesDataStoreFile("user_preferences") } @Provides @Singleton fun userPreferencesRepository(store: DataStore) = UserPreferencesRepository(store) /** * The app lock's own store, in its own file. * * Qualified because it is a second `DataStore` and Hilt would * otherwise have two bindings for one type — but the separate *file* is the * real point, not the qualifier. `UserPreferencesRepository.resetToDefaults()` * is `edit { it.clear() }`; a verifier record sharing that store would be one * future caller away from silent removal, and removing it locks the user out * of their own history with no way back. */ @Provides @Singleton @AppLockStore fun appLockDataStore(@ApplicationContext context: Context): DataStore = PreferenceDataStoreFactory.create( // A corrupt lock file is replaced rather than thrown at every // caller. Without this, one bad write leaves the app unable to read // OR write the file: the lock can never be set again, and the // "Forgot your PIN?" erase — the only way back in — fails too, // because it writes to this same store. // // Empty means "no lock", which is deliberate and matches how // AppLockRepository reads an unreadable file. The reasoning is // there, and in docs/security/SECURITY.md. corruptionHandler = ReplaceFileCorruptionHandler { emptyPreferences() }, ) { context.preferencesDataStoreFile("app_lock") } @Provides @Singleton fun appLockRepository(@AppLockStore store: DataStore): AppLockRepository = AppLockRepository(store) /** * The engine, and the one line that decides which one the product ships. * * Everything else depends on [PredictionEngine] rather than an * implementation, which is what made this swap a single line and what makes * the next one a single line too. * * `BaselinePredictionEngine` stays in the tree. It stopped being the product * and became the control: `EngineComparisonTest` scores both over the §51 * fixtures every build, so "the new engine is better" is a number rather * than an opinion. At the swap it was **mean absolute error 0.67 against * 1.00, and the window contained the actual start 9 times out of 9 against * 7** — the second mattering more, because a window that misses is a broken * promise rather than a tight forecast. */ @Provides @Singleton fun predictionEngine(): PredictionEngine = PersonalPredictionEngine() /** Injected rather than read from the environment, so §50's date edge cases stay testable. */ @Provides @Singleton fun clock(): Clock = Clock.systemDefaultZone() @Provides @Singleton fun reminderScheduler(@ApplicationContext context: Context): ReminderScheduler = ReminderScheduler(context) @Provides @Singleton fun launcherAliasSwitcher(switcher: AndroidLauncherAliasSwitcher): LauncherAliasSwitcher = switcher } /** Distinguishes the lock's store from the settings store; they are different files. */ @Qualifier @Retention(AnnotationRetention.BINARY) annotation class AppLockStore