Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 38 additions & 12 deletions shared/src/commonMain/kotlin/org/jetbrains/kotlinconf/AppInit.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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<ApplicationStorage> { MultiplatformSettingsStorage(get()) }
single { BufferedDelegatingLogger(get()) }
single<Logger> { get<BufferedDelegatingLogger>() }

single<ApplicationStorage> { MultiplatformSettingsStorage(get(), get()) }
single {
val flags = get<ApplicationStorage>().getFlagsBlocking()
val endpoint = when {
Expand All @@ -63,15 +79,8 @@ private fun initKoin(
else -> ServerBasedTimeProvider(get())
}
}
single<Logger> {
val flags = get<ApplicationStorage>().getFlagsBlocking()
when {
flags != null && flags.debugLogging -> DebugLogger(platformLogger)
else -> NoopProdLogger()
}
}
single { FlagsManager(platformFlags, get(), get()) }
single { CoroutineScope(SupervisorJob() + Dispatchers.Default) }
single { appScope }
singleOf(::ConferenceService)
}

Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 <reified T> String?.decodeOrNull(): T? {
if (this == null) return null
Expand Down Expand Up @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Entry>()

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()
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,5 @@ class NoopProdLogger : Logger {
// No logging in prod
}
}

internal const val MAX_LOG_MESSAGES_IN_MEMORY = 200
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
*/
Expand All @@ -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()
Expand All @@ -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()
Expand All @@ -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()
Expand All @@ -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()
Expand All @@ -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()
Expand Down