Convert core module to Kotlin Multiplatform

Phase 0 of the iOS migration: core now targets android, iosArm64, and
iosSimulatorArm64 via a new fluxa.kmp.library convention plugin.
AppStrings and the TTL cache lose their JVM-only dependencies
(org.json, java.time, java.util.concurrent) in favor of
kotlinx-serialization, kotlinx-datetime, and small expect/actual
platform shims, with Locale-returning APIs kept as Android-only
extension functions since only Android UI consumes them today.
This commit is contained in:
KhooLy 2026-07-13 02:30:20 +03:00
parent 20dada92a7
commit b976f28fd1
22 changed files with 205 additions and 59 deletions

View file

@ -20,6 +20,7 @@ import okio.Path.Companion.toOkioPath
import com.lagradost.cloudstream3.AcraApplication
import com.lagradost.cloudstream3.app
import com.fluxa.app.common.AppStrings
import com.fluxa.app.common.initialize
import com.fluxa.app.ui.catalog.EpisodeReleaseWorker
import com.fluxa.app.plugins.PluginAutoUpdateWorker
import com.fluxa.app.ui.catalog.TrailerResolver

View file

@ -11,6 +11,7 @@ import android.view.View
import android.widget.RemoteViews
import com.fluxa.app.R
import com.fluxa.app.common.AppStrings
import com.fluxa.app.common.locale
import com.fluxa.app.ui.MainActivity
import org.json.JSONArray
import org.json.JSONObject

View file

@ -2,6 +2,7 @@
package com.fluxa.app.ui.catalog
import com.fluxa.app.common.AppStrings
import com.fluxa.app.common.locale
import com.fluxa.app.common.ReleaseDateUtils
import com.fluxa.app.data.local.*
import com.fluxa.app.data.remote.*

View file

@ -1,6 +1,7 @@
package com.fluxa.app.ui.catalog
import com.fluxa.app.common.AppStrings
import com.fluxa.app.common.locale
import com.fluxa.app.data.local.UserProfile
import com.fluxa.app.data.remote.Meta

View file

@ -2,6 +2,7 @@
package com.fluxa.app.ui.catalog
import com.fluxa.app.common.AppStrings
import com.fluxa.app.common.locale
import com.fluxa.app.data.local.*
import com.fluxa.app.data.remote.*
import com.fluxa.app.data.repository.*

View file

@ -2,6 +2,7 @@
package com.fluxa.app.ui.catalog
import com.fluxa.app.common.AppStrings
import com.fluxa.app.common.locale
import com.fluxa.app.data.local.*
import com.fluxa.app.data.remote.*
import com.fluxa.app.data.repository.*

View file

@ -1,6 +1,7 @@
package com.fluxa.app.ui.catalog
import com.fluxa.app.common.AppStrings
import com.fluxa.app.common.locale
import com.fluxa.app.data.local.*
import com.fluxa.app.data.remote.*
import com.fluxa.app.data.repository.*

View file

@ -29,5 +29,9 @@ gradlePlugin {
id = "fluxa.android.hilt"
implementationClass = "FluxaAndroidHiltPlugin"
}
register("fluxaKmpLibrary") {
id = "fluxa.kmp.library"
implementationClass = "FluxaKmpLibraryPlugin"
}
}
}

View file

@ -0,0 +1,41 @@
import com.android.build.gradle.LibraryExtension
import org.gradle.api.JavaVersion
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.configure
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension
class FluxaKmpLibraryPlugin : Plugin<Project> {
override fun apply(target: Project) = with(target) {
pluginManager.apply("com.android.library")
pluginManager.apply("org.jetbrains.kotlin.multiplatform")
extensions.configure<LibraryExtension> {
compileSdk = 36
defaultConfig { minSdk = 30 }
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
}
extensions.configure<KotlinMultiplatformExtension> {
androidTarget {
compilerOptions { jvmTarget.set(JvmTarget.JVM_17) }
}
iosArm64()
iosSimulatorArm64()
targets.configureEach {
compilations.configureEach {
compileTaskProvider.configure {
compilerOptions {
freeCompilerArgs.add("-Xexpect-actual-classes")
}
}
}
}
}
}
}

View file

@ -1,12 +1,21 @@
plugins {
alias(libs.plugins.fluxa.android.library)
alias(libs.plugins.fluxa.kmp.library)
}
android {
namespace = "com.fluxa.app.core"
}
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.bundles.coroutines)
kotlin {
sourceSets {
commonMain.dependencies {
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.serialization.json)
implementation(libs.kotlinx.datetime)
}
androidMain.dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.kotlinx.coroutines.android)
}
}
}

View file

@ -0,0 +1,28 @@
package com.fluxa.app.common
import android.content.Context
import java.util.Locale
import java.util.concurrent.ConcurrentHashMap
private var appContext: Context? = null
fun AppStrings.Companion.initialize(context: Context) {
appContext = context.applicationContext
}
fun AppStrings.Companion.locale(language: String?): Locale {
val tag = language
?.substringBefore('_')
?.takeIf { it.isNotBlank() }
?: return Locale.US
return Locale.forLanguageTag(tag)
}
internal actual fun readI18nAssetText(fileName: String): String? {
val context = appContext ?: return null
return runCatching {
context.assets.open("i18n/$fileName").bufferedReader().use { it.readText() }
}.getOrNull()
}
internal actual fun createStringsCache(): MutableMap<String, AppStrings> = ConcurrentHashMap()

View file

@ -0,0 +1,11 @@
package com.fluxa.app.common
import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.withLock
actual fun epochMillisNow(): Long = System.currentTimeMillis()
internal actual class SimpleLock actual constructor() {
private val lock = ReentrantLock()
actual fun <T> withLock(block: () -> T): T = lock.withLock(block)
}

View file

@ -1,9 +1,12 @@
package com.fluxa.app.common
import android.content.Context
import org.json.JSONObject
import java.util.Locale
import java.util.concurrent.ConcurrentHashMap
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
internal expect fun readI18nAssetText(fileName: String): String?
internal expect fun createStringsCache(): MutableMap<String, AppStrings>
class AppStrings private constructor(
private val values: Map<String, String>,
@ -12,21 +15,15 @@ class AppStrings private constructor(
fun get(key: String): String = values[key] ?: fallback[key] ?: key
companion object {
private val cache = ConcurrentHashMap<String, AppStrings>()
private var appContext: Context? = null
private val cache = createStringsCache()
private val RUNTIME_UNIT_REGEX = Regex("""\b(min|m|dk)\.?\b""", RegexOption.IGNORE_CASE)
private val WHITESPACE_REGEX = Regex("""\s+""")
private val ISO_DURATION_REGEX = Regex("""^PT(?:(\d+)H)?(?:(\d+)M)?$""", RegexOption.IGNORE_CASE)
private val HOURS_REGEX = Regex("""(\d+)\s*(?:h|hr|hour|hours|sa|saat)""", RegexOption.IGNORE_CASE)
private val MINUTES_REGEX = Regex("""(\d+)\s*(?:m|min|minute|minutes|dk)""", RegexOption.IGNORE_CASE)
fun initialize(context: Context) {
appContext = context.applicationContext
}
fun t(language: String?, key: String): String {
val context = appContext ?: return key
return load(context, language).get(key)
return load(language).get(key)
}
fun format(language: String?, key: String, vararg args: Any?): String {
@ -39,14 +36,6 @@ class AppStrings private constructor(
return t(language, key).split("|").map { it.trim() }.filter { it.isNotEmpty() }
}
fun locale(language: String?): Locale {
val tag = language
?.substringBefore('_')
?.takeIf { it.isNotBlank() }
?: return Locale.US
return Locale.forLanguageTag(tag)
}
fun runtimeMinutes(language: String?, minutes: Int): String {
val hours = minutes / 60
val remainder = minutes % 60
@ -102,11 +91,11 @@ class AppStrings private constructor(
return normalized.toIntOrNull()?.takeIf { it > 0 }
}
fun load(context: Context, language: String?): AppStrings {
private fun load(language: String?): AppStrings {
val fileName = languageFileName(language)
return cache.getOrPut(fileName) {
val fallback = readAsset(context, "i18n/en-US.json")
val values = if (fileName == "en-US.json") fallback else readAsset(context, "i18n/$fileName")
val fallback = readAsset("en-US.json")
val values = if (fileName == "en-US.json") fallback else readAsset(fileName)
AppStrings(values, fallback)
}
}
@ -116,7 +105,7 @@ class AppStrings private constructor(
?.trim()
.orEmpty()
val languageCode = normalized.removeSuffix(".json")
return when (languageCode.lowercase(Locale.ROOT)) {
return when (languageCode.lowercase()) {
"" -> "en-US.json"
"en", "en-us" -> "en-US.json"
"tr", "tr-tr" -> "tr-TR.json"
@ -124,11 +113,12 @@ class AppStrings private constructor(
}
}
private fun readAsset(context: Context, path: String): Map<String, String> {
private fun readAsset(fileName: String): Map<String, String> {
val json = readI18nAssetText(fileName) ?: return emptyMap()
return runCatching {
val json = context.assets.open(path).bufferedReader().use { it.readText() }
val obj = JSONObject(json)
obj.keys().asSequence().associateWith { key -> obj.optString(key, key) }
Json.parseToJsonElement(json).jsonObject
.entries
.associate { (key, value) -> key to (value.jsonPrimitive.contentOrNull ?: key) }
}.getOrDefault(emptyMap())
}
}

View file

@ -0,0 +1,7 @@
package com.fluxa.app.common
expect fun epochMillisNow(): Long
internal expect class SimpleLock() {
fun <T> withLock(block: () -> T): T
}

View file

@ -1,16 +1,22 @@
package com.fluxa.app.common
import java.time.Instant
import java.time.LocalDate
import java.time.OffsetDateTime
import java.time.ZoneId
import java.time.temporal.ChronoUnit
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.daysUntil
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Clock
import kotlin.time.ExperimentalTime
import kotlin.time.Instant
@OptIn(ExperimentalTime::class)
object ReleaseDateUtils {
private val isoDateRegex = Regex("""\d{4}-\d{2}-\d{2}""")
private val offsetSuffixRegex = Regex(""".*[+-]\d{2}:?\d{2}$""")
fun todayIso(): String = LocalDate.now(ZoneId.systemDefault()).toString()
private fun todayDate(): LocalDate =
Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date
fun todayIso(): String = todayDate().toString()
fun isoDate(value: String?): String? {
if (value.isNullOrBlank()) return null
@ -21,16 +27,16 @@ object ReleaseDateUtils {
return runCatching {
val instant = when {
trimmed.endsWith("Z", ignoreCase = true) -> Instant.parse(trimmed)
offsetSuffixRegex.matches(trimmed) -> OffsetDateTime.parse(trimmed).toInstant()
offsetSuffixRegex.matches(trimmed) -> Instant.parse(trimmed)
else -> Instant.parse("${trimmed.substringBefore(".")}Z")
}
instant.atZone(ZoneId.systemDefault()).toLocalDate().toString()
instant.toLocalDateTime(TimeZone.currentSystemDefault()).date.toString()
}.getOrNull() ?: trimmed.take(10).takeIf { it.matches(isoDateRegex) }
}
fun daysSince(value: String?): Long? {
val date = isoDate(value)?.let(LocalDate::parse) ?: return null
return ChronoUnit.DAYS.between(date, LocalDate.now(ZoneId.systemDefault()))
return date.daysUntil(todayDate()).toLong()
}
fun isUpcoming(value: String?): Boolean {

View file

@ -1,11 +1,9 @@
package com.fluxa.app.common
import java.util.concurrent.ConcurrentHashMap
class TtlMemoryCache<T>(
private val maxEntries: Int,
private val ttlMillis: Long,
private val nowMillis: () -> Long = System::currentTimeMillis
private val nowMillis: () -> Long = ::epochMillisNow
) {
private data class Entry<T>(
val value: T,
@ -13,18 +11,18 @@ class TtlMemoryCache<T>(
val insertionOrder: Long
)
private val entries = ConcurrentHashMap<String, Entry<T>>()
private val lock = SimpleLock()
private val entries = mutableMapOf<String, Entry<T>>()
private var nextInsertionOrder = 0L
fun get(key: String): T? {
val entry = entries[key] ?: return null
return if (nowMillis() < entry.expiresAtMillis) entry.value else {
fun get(key: String): T? = lock.withLock {
val entry = entries[key] ?: return@withLock null
if (nowMillis() < entry.expiresAtMillis) entry.value else {
entries.remove(key); null
}
}
@Synchronized
fun put(key: String, value: T) {
fun put(key: String, value: T) = lock.withLock {
val now = nowMillis()
trimExpired(now)
entries[key] = Entry(
@ -35,23 +33,20 @@ class TtlMemoryCache<T>(
trimToMaxSize()
}
@Synchronized
fun firstWithPrefix(prefix: String): T? {
fun firstWithPrefix(prefix: String): T? = lock.withLock {
val now = nowMillis()
trimExpired(now)
val key = entries.keys.asSequence().filter { it.startsWith(prefix) }.minOrNull() ?: return null
return entries[key]?.value
val key = entries.keys.asSequence().filter { it.startsWith(prefix) }.minOrNull() ?: return@withLock null
entries[key]?.value
}
@Synchronized
fun invalidatePrefix(prefix: String) {
fun invalidatePrefix(prefix: String) = lock.withLock {
entries.keys.removeAll { it.startsWith(prefix) }
}
@Synchronized
fun size(): Int {
fun size(): Int = lock.withLock {
trimExpired(nowMillis())
return entries.size
entries.size
}
private fun trimExpired(now: Long) {

View file

@ -0,0 +1,20 @@
package com.fluxa.app.common
import kotlinx.cinterop.BetaInteropApi
import kotlinx.cinterop.ExperimentalForeignApi
import platform.Foundation.NSBundle
import platform.Foundation.NSData
import platform.Foundation.NSString
import platform.Foundation.NSUTF8StringEncoding
import platform.Foundation.create
import platform.Foundation.dataWithContentsOfFile
@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class)
internal actual fun readI18nAssetText(fileName: String): String? {
val baseName = fileName.removeSuffix(".json")
val path = NSBundle.mainBundle.pathForResource(baseName, "json", "i18n") ?: return null
val data = NSData.dataWithContentsOfFile(path) ?: return null
return NSString.create(data, NSUTF8StringEncoding)?.toString()
}
internal actual fun createStringsCache(): MutableMap<String, AppStrings> = mutableMapOf()

View file

@ -0,0 +1,19 @@
package com.fluxa.app.common
import platform.Foundation.NSDate
import platform.Foundation.NSLock
import platform.Foundation.timeIntervalSince1970
actual fun epochMillisNow(): Long = (NSDate().timeIntervalSince1970 * 1000).toLong()
internal actual class SimpleLock actual constructor() {
private val lock = NSLock()
actual fun <T> withLock(block: () -> T): T {
lock.lock()
try {
return block()
} finally {
lock.unlock()
}
}
}

View file

@ -36,6 +36,8 @@ androidxTestRunner = "1.6.2"
androidxTestJunit = "1.2.1"
jackson = "2.13.1"
kotlinMetadataJvm = "2.4.0"
kotlinxSerialization = "1.11.0"
kotlinxDatetime = "0.8.0"
[libraries]
# Build-logic plugin artifacts (compileOnly in build-logic)
@ -116,6 +118,10 @@ zxing = { group = "com.google.zxing", name = "core", version.ref =
kotlin-metadata-jvm = { group = "org.jetbrains.kotlin", name = "kotlin-metadata-jvm", version.ref = "kotlinMetadataJvm" }
# Kotlinx serialization (KMP-portable JSON, replaces org.json in shared code)
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" }
kotlinx-datetime = { group = "org.jetbrains.kotlinx", name = "kotlinx-datetime", version.ref = "kotlinxDatetime" }
# Jackson
jackson-databind = { group = "com.fasterxml.jackson.core", name = "jackson-databind", version.ref = "jackson" }
jackson-module-kotlin = { group = "com.fasterxml.jackson.module", name = "jackson-module-kotlin", version.ref = "jackson" }
@ -136,10 +142,13 @@ retrofit = ["retrofit", "retrofit-gson"]
android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" }
android-library = { id = "com.android.library", version.ref = "androidGradlePlugin" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-multiplatform= { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", 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" }
fluxa-android-library = { id = "fluxa.android.library" }
fluxa-android-application = { id = "fluxa.android.application" }
fluxa-android-compose = { id = "fluxa.android.compose" }
fluxa-android-hilt = { id = "fluxa.android.hilt" }
fluxa-kmp-library = { id = "fluxa.kmp.library" }