diff --git a/app/src/main/java/org/hogwarts/android/hogwarts-application.kt b/app/src/main/java/org/hogwarts/android/hogwarts-application.kt index ba29c5c..a25a40c 100644 --- a/app/src/main/java/org/hogwarts/android/hogwarts-application.kt +++ b/app/src/main/java/org/hogwarts/android/hogwarts-application.kt @@ -4,7 +4,6 @@ import android.app.Application import android.app.NotificationChannel import android.app.NotificationManager import android.os.Build -import android.os.StrictMode import androidx.hilt.work.HiltWorkerFactory import androidx.lifecycle.ProcessLifecycleOwner import androidx.lifecycle.lifecycleScope @@ -17,7 +16,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import org.hogwarts.android.core.data.preferences.AppPreferences -import timber.log.Timber +import org.hogwarts.android.startup.AppStartupInitializer import javax.inject.Inject /** @@ -34,6 +33,9 @@ class HogwartsApplication : Application(), Configuration.Provider, ImageLoaderFa @Inject lateinit var appPreferences: AppPreferences + @Inject + lateinit var appStartupInitializer: AppStartupInitializer + override val workManagerConfiguration: Configuration get() = Configuration.Builder() .setWorkerFactory(workerFactory) @@ -49,6 +51,11 @@ class HogwartsApplication : Application(), Configuration.Provider, ImageLoaderFa override fun onCreate() { super.onCreate() + // All startup hooks (Timber, StrictMode, WorkManager + Firebase + // init confirmation) funnel through AppStartupInitializer so the + // boot sequence stays in one place. + appStartupInitializer.initialize() + // AppLocalesMetadataHolderService (manifest, autoStoreLocales=true) handles // locale persistence on Android <13; the platform LocaleManager handles 13+. // We just warm the DataStore cache off-main so settings reads stay snappy. @@ -57,33 +64,9 @@ class HogwartsApplication : Application(), Configuration.Provider, ImageLoaderFa appPreferences.themeMode.first() } - if (BuildConfig.DEBUG) { - Timber.plant(Timber.DebugTree()) - enableStrictMode() - } - createNotificationChannels() } - private fun enableStrictMode() { - StrictMode.setThreadPolicy( - StrictMode.ThreadPolicy.Builder() - .detectDiskReads() - .detectDiskWrites() - .detectNetwork() - .penaltyLog() - .build() - ) - StrictMode.setVmPolicy( - StrictMode.VmPolicy.Builder() - .detectLeakedSqlLiteObjects() - .detectLeakedClosableObjects() - .detectActivityLeaks() - .penaltyLog() - .build() - ) - } - private fun createNotificationChannels() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val notificationManager = getSystemService(NotificationManager::class.java) diff --git a/app/src/main/java/org/hogwarts/android/startup/app-startup-initializer.kt b/app/src/main/java/org/hogwarts/android/startup/app-startup-initializer.kt index 8504299..d1d0263 100644 --- a/app/src/main/java/org/hogwarts/android/startup/app-startup-initializer.kt +++ b/app/src/main/java/org/hogwarts/android/startup/app-startup-initializer.kt @@ -1,28 +1,112 @@ package org.hogwarts.android.startup import android.content.Context +import android.os.StrictMode +import android.util.Log +import androidx.work.WorkManager +import com.google.firebase.FirebaseApp +import com.google.firebase.crashlytics.FirebaseCrashlytics +import dagger.hilt.android.qualifiers.ApplicationContext +import org.hogwarts.android.BuildConfig import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton +/** + * Single entry point for all startup hooks invoked from + * `HogwartsApplication.onCreate()`. Adding a new hook here keeps `onCreate` + * legible and makes the boot sequence testable. + */ @Singleton class AppStartupInitializer @Inject constructor( - private val context: Context + @ApplicationContext private val context: Context ) { + fun initialize() { - Timber.d("AppStartupInitializer: Beginning app initialization") - initializeTimber() - initializeCoil() - Timber.d("AppStartupInitializer: App initialization complete") + // Why: Timber must plant before any other startup log statements so + // both debug console output and release Crashlytics breadcrumbs catch + // the boot sequence. + plantTimber() + + if (BuildConfig.DEBUG) { + // Why: StrictMode surfaces accidental main-thread disk/network IO + // and leaked Closeables during development. Release payloads never + // include the policy so users don't pay the perf hit. + enableStrictMode() + } + + // Why: WorkManager is created lazily on the first getInstance() call. + // Touching it at boot confirms HiltWorkerFactory wiring and surfaces + // any misconfiguration as a startup crash instead of a silent runtime + // failure when the first periodic sync would have fired. + confirmWorkManager() + + // Why: Firebase auto-inits via FirebaseInitProvider in the manifest. + // Verifying the instance exists at boot means a missing + // google-services.json (E01.S01) shows up as one loud log line on + // every launch instead of as a confusing failure deep inside the FCM + // token upload path. + confirmFirebase() + + Timber.i("AppStartupInitializer complete") + } + + private fun plantTimber() { + if (BuildConfig.DEBUG) { + Timber.plant(Timber.DebugTree()) + } else { + Timber.plant(CrashlyticsTree()) + } + } + + private fun enableStrictMode() { + StrictMode.setThreadPolicy( + StrictMode.ThreadPolicy.Builder() + .detectDiskReads() + .detectDiskWrites() + .detectNetwork() + .penaltyLog() + .build() + ) + StrictMode.setVmPolicy( + StrictMode.VmPolicy.Builder() + .detectLeakedSqlLiteObjects() + .detectLeakedClosableObjects() + .detectActivityLeaks() + .penaltyLog() + .build() + ) } - private fun initializeTimber() { - // Timber is already initialized in HogwartsApp, this is for future enhancements - Timber.d("Timber initialized") + private fun confirmWorkManager() { + val wm = WorkManager.getInstance(context) + Timber.i("WorkManager initialized: ${wm.javaClass.simpleName}") } - private fun initializeCoil() { - // Coil uses default ImageLoader, customize for disk cache size if needed - Timber.d("Coil image loader ready") + private fun confirmFirebase() { + try { + val app = FirebaseApp.getInstance() + Timber.i("Firebase initialized: project=${app.options.projectId}") + } catch (e: IllegalStateException) { + // E01.S01 (commit google-services.json) hasn't landed yet — log + // loud so the gap is obvious, but don't crash the app. + Timber.w("Firebase not initialized: ${e.message}") + } + } +} + +/** + * Release-build Timber tree that funnels warnings + errors to Crashlytics. + * Below WARN is dropped to keep the breadcrumb retention focused on signals + * a release user would actually want to debug. + */ +private class CrashlyticsTree : Timber.Tree() { + override fun log(priority: Int, tag: String?, message: String, t: Throwable?) { + if (priority < Log.WARN) return + val crashlytics = FirebaseCrashlytics.getInstance() + crashlytics.log(if (tag != null) "$tag: $message" else message) + if (t != null && priority >= Log.ERROR) { + crashlytics.recordException(t) + } } }