diff --git a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/AppInit.kt b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/AppInit.kt index 8a8ed62a..bf75fb10 100644 --- a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/AppInit.kt +++ b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/AppInit.kt @@ -7,6 +7,8 @@ import com.mmk.kmpnotifier.notification.configuration.NotificationPlatformConfig import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch import org.jetbrains.kotlinconf.navigation.navigateToSession import org.jetbrains.kotlinconf.screens.AboutConferenceViewModel import org.jetbrains.kotlinconf.screens.LicensesViewModel @@ -19,6 +21,7 @@ import org.jetbrains.kotlinconf.screens.SpeakersViewModel import org.jetbrains.kotlinconf.screens.StartNotificationsViewModel import org.jetbrains.kotlinconf.storage.ApplicationStorage import org.jetbrains.kotlinconf.storage.MultiplatformSettingsStorage +import org.jetbrains.kotlinconf.utils.BufferedDelegatingLogger import org.jetbrains.kotlinconf.utils.DebugLogger import org.jetbrains.kotlinconf.utils.Logger import org.jetbrains.kotlinconf.utils.NoopProdLogger @@ -36,18 +39,31 @@ fun initApp( platformModule: Module, flags: Flags = Flags(), ) { - val koin = initKoin(platformLogger, platformModule, flags) - initNotifier(configuration = koin.get(), logger = koin.get()) + val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + val koin = initKoin(appScope, platformModule, flags) + initLogging( + appScope = appScope, + platformLogger = platformLogger, + bufferedDelegatingLogger = koin.get(), + applicationStorage = koin.get(), + ) + initNotifier( + configuration = koin.get(), + logger = koin.get(), + ) } private fun initKoin( - platformLogger: Logger, + appScope: CoroutineScope, platformModule: Module, platformFlags: Flags, ): Koin { return startKoin { val appModule = module { - single { MultiplatformSettingsStorage(get()) } + single { BufferedDelegatingLogger(get()) } + single { get() } + + single { MultiplatformSettingsStorage(get(), get()) } single { val flags = get().getFlagsBlocking() val endpoint = when { @@ -63,15 +79,8 @@ private fun initKoin( else -> ServerBasedTimeProvider(get()) } } - single { - val flags = get().getFlagsBlocking() - when { - flags != null && flags.debugLogging -> DebugLogger(platformLogger) - else -> NoopProdLogger() - } - } single { FlagsManager(platformFlags, get(), get()) } - single { CoroutineScope(SupervisorJob() + Dispatchers.Default) } + single { appScope } singleOf(::ConferenceService) } @@ -93,6 +102,23 @@ private fun initKoin( }.koin } +private fun initLogging( + appScope: CoroutineScope, + platformLogger: Logger, + bufferedDelegatingLogger: BufferedDelegatingLogger, + applicationStorage: ApplicationStorage, +) { + appScope.launch { + val flags = applicationStorage.getFlags().first() + bufferedDelegatingLogger.attach( + when { + flags != null && flags.debugLogging -> DebugLogger(platformLogger) + else -> NoopProdLogger() + } + ) + } +} + private fun initNotifier( configuration: NotificationPlatformConfiguration, logger: Logger, diff --git a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/storage/MultiplatformSettingsStorage.kt b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/storage/MultiplatformSettingsStorage.kt index 704cdfc3..025f34ac 100644 --- a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/storage/MultiplatformSettingsStorage.kt +++ b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/storage/MultiplatformSettingsStorage.kt @@ -16,19 +16,18 @@ import org.jetbrains.kotlinconf.SessionId import org.jetbrains.kotlinconf.Theme import org.jetbrains.kotlinconf.VoteInfo import org.jetbrains.kotlinconf.utils.Logger -import org.jetbrains.kotlinconf.utils.TaggedLogger import org.jetbrains.kotlinconf.utils.tagged @OptIn(ExperimentalSettingsApi::class) class MultiplatformSettingsStorage( private val settings: ObservableSettings, - logger: Logger? = null, // TODO inject a logger here https://github.com/JetBrains/kotlinconf-app/issues/544 + logger: Logger, ) : ApplicationStorage { private val json = Json { ignoreUnknownKeys = true } - private var taggedLogger: TaggedLogger? = logger?.tagged("MultiplatformSettingsStorage") + private var taggedLogger = logger.tagged("MultiplatformSettingsStorage") private inline fun String?.decodeOrNull(): T? { if (this == null) return null @@ -92,39 +91,50 @@ class MultiplatformSettingsStorage( override fun ensureCurrentVersion() { var version = settings.getInt(Keys.STORAGE_VERSION, 0) - taggedLogger?.log { "Storage version is $version" } + taggedLogger.log { "Storage version is $version" } if (version == 0) { - // Fully destructive migration on unknown previous version + taggedLogger.log { "Unknown previous storage version, performing destructive migration" } destructiveUpgrade() return } + if (version > CURRENT_STORAGE_VERSION) { + taggedLogger.log { "Storage version not recognized, performing destructive migration" } + destructiveUpgrade() + return + } + + if (version == CURRENT_STORAGE_VERSION) { + taggedLogger.log { "Storage version matches expected version, no need to migrate" } + return + } + while (version < CURRENT_STORAGE_VERSION) { - taggedLogger?.log { "Finding migrations from $version to $CURRENT_STORAGE_VERSION..." } + taggedLogger.log { "Finding migrations from $version to $CURRENT_STORAGE_VERSION..." } // Find a migration from the current version that takes us as far forward as possible val nextMigration = migrations.filter { it.from == version }.maxByOrNull { it.to } if (nextMigration == null) { - taggedLogger?.log { "No matching migrations found" } + taggedLogger.log { "No matching migrations found" } // Failed to find a migration path to latest, fall back to destructive destructiveUpgrade() return } - taggedLogger?.log { "Running migration from ${nextMigration.from} to ${nextMigration.to}" } + taggedLogger.log { "Running migration from ${nextMigration.from} to ${nextMigration.to}" } nextMigration.migrate() version = nextMigration.to settings.set(Keys.STORAGE_VERSION, version) - taggedLogger?.log { "Successfully migrated to $version" } + taggedLogger.log { "Successfully migrated to $version" } } } private fun destructiveUpgrade() { - taggedLogger?.log { "Performing destructive upgrade to $CURRENT_STORAGE_VERSION" } + taggedLogger.log { "Performing destructive upgrade to $CURRENT_STORAGE_VERSION" } settings.clear() settings.set(Keys.STORAGE_VERSION, CURRENT_STORAGE_VERSION) } diff --git a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/utils/BufferedDelegatingLogger.kt b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/utils/BufferedDelegatingLogger.kt new file mode 100644 index 00000000..85db6234 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/utils/BufferedDelegatingLogger.kt @@ -0,0 +1,56 @@ +package org.jetbrains.kotlinconf.utils + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** + * A logger that's constructed during startup and later attached to a real logger. + * Until attached, it buffers log entries in memory. When [attach] is called, + * it forwards the buffered entries to the attached logger in order, and from + * that point on it delegates all calls directly. + */ +class BufferedDelegatingLogger( + private val scope: CoroutineScope, +) : Logger { + private var delegate: Logger? = null + + private data class Entry(val tag: String, val lazyMessage: () -> String) + + private val mutex = Mutex() + + private val buffer = mutableListOf() + + override fun log(tag: String, lazyMessage: () -> String) { + val current = delegate + if (current != null) { + current.log(tag, lazyMessage) + return + } + + scope.launch { + mutex.withLock { + buffer += Entry(tag, lazyMessage) + while (buffer.size > MAX_LOG_MESSAGES_IN_MEMORY) { + buffer.removeAt(0) + } + } + } + } + + fun attach(realLogger: Logger) { + require(delegate == null) { "Logger delegate was already set, this should only happen once" } + + delegate = realLogger + + scope.launch { + mutex.withLock { + buffer.forEach { entry -> + realLogger.log(entry.tag, entry.lazyMessage) + } + buffer.clear() + } + } + } +} diff --git a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/utils/DebugLogger.kt b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/utils/DebugLogger.kt index 352855e0..8e18d25e 100644 --- a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/utils/DebugLogger.kt +++ b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/utils/DebugLogger.kt @@ -10,6 +10,9 @@ class DebugLogger(private val platformLogger: Logger) : Logger { override fun log(tag: String, lazyMessage: () -> String) { logs += "${Clock.System.now()} [${tag}] ${lazyMessage()}" + while (logs.size > MAX_LOG_MESSAGES_IN_MEMORY) { + logs.removeAt(0) + } platformLogger.log(tag, lazyMessage) } diff --git a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/utils/Logger.kt b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/utils/Logger.kt index dd16d24b..cd8c15ab 100644 --- a/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/utils/Logger.kt +++ b/shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/utils/Logger.kt @@ -20,3 +20,5 @@ class NoopProdLogger : Logger { // No logging in prod } } + +internal const val MAX_LOG_MESSAGES_IN_MEMORY = 200 diff --git a/shared/src/commonTest/kotlin/org/jetbrains/kotlinconf/storage/MultiplatformSettingsStorageMigrationTest.kt b/shared/src/commonTest/kotlin/org/jetbrains/kotlinconf/storage/MultiplatformSettingsStorageMigrationTest.kt index 3f3be320..f24bda61 100644 --- a/shared/src/commonTest/kotlin/org/jetbrains/kotlinconf/storage/MultiplatformSettingsStorageMigrationTest.kt +++ b/shared/src/commonTest/kotlin/org/jetbrains/kotlinconf/storage/MultiplatformSettingsStorageMigrationTest.kt @@ -7,8 +7,7 @@ import com.russhwolf.settings.observable.makeObservable import com.russhwolf.settings.set import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest -import kotlinx.serialization.json.Json -import org.jetbrains.kotlinconf.NotificationSettings +import org.jetbrains.kotlinconf.utils.Logger import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNull @@ -19,6 +18,10 @@ class MultiplatformSettingsStorageMigrationTest { @OptIn(ExperimentalSettingsApi::class) private fun inMemorySettings(): ObservableSettings = MapSettings().makeObservable() + private fun emptyLogger() = object : Logger { + override fun log(tag: String, lazyMessage: () -> String) {} + } + /** * Creates a storage object with data matching the older, 2025 storage version. */ @@ -38,7 +41,7 @@ class MultiplatformSettingsStorageMigrationTest { @Test fun migration_2025_to_2026_updates_version() { val settings = get2025Settings() - val storage = MultiplatformSettingsStorage(settings) + val storage = MultiplatformSettingsStorage(settings, emptyLogger()) // Run migrations storage.ensureCurrentVersion() @@ -50,7 +53,7 @@ class MultiplatformSettingsStorageMigrationTest { @Test fun migration_2026_to_2026_removes_news_cache() { val settings = get2025Settings() - val storage = MultiplatformSettingsStorage(settings) + val storage = MultiplatformSettingsStorage(settings, emptyLogger()) // Run migrations storage.ensureCurrentVersion() @@ -62,7 +65,7 @@ class MultiplatformSettingsStorageMigrationTest { @Test fun migration_2026_to_2026_migrates_notification_settings() = runTest { val settings = get2025Settings() - val storage = MultiplatformSettingsStorage(settings) + val storage = MultiplatformSettingsStorage(settings, emptyLogger()) // Run migrations storage.ensureCurrentVersion() @@ -78,7 +81,7 @@ class MultiplatformSettingsStorageMigrationTest { settings["userId2025"] = "user-123" settings["newsCache"] = "legacy-data" settings["notificationSettings"] = "{\"sessionReminders\":false,\"scheduleUpdates\":true}" - val storage = MultiplatformSettingsStorage(settings) + val storage = MultiplatformSettingsStorage(settings, emptyLogger()) // Run migrations storage.ensureCurrentVersion() @@ -93,7 +96,7 @@ class MultiplatformSettingsStorageMigrationTest { val settings = inMemorySettings() settings["storageVersion"] = 2024_000 // A version we don't have a migration for settings["favorites"] = "[\"S1\",\"S2\"]" - val storage = MultiplatformSettingsStorage(settings) + val storage = MultiplatformSettingsStorage(settings, emptyLogger()) // Run migrations storage.ensureCurrentVersion()