mirror of
https://github.com/FluxaMedia/fluxa.git
synced 2026-08-17 20:46:04 +00:00
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:
parent
20dada92a7
commit
b976f28fd1
22 changed files with 205 additions and 59 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.*
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.*
|
||||
|
|
|
|||
|
|
@ -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.*
|
||||
|
|
|
|||
|
|
@ -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.*
|
||||
|
|
|
|||
|
|
@ -29,5 +29,9 @@ gradlePlugin {
|
|||
id = "fluxa.android.hilt"
|
||||
implementationClass = "FluxaAndroidHiltPlugin"
|
||||
}
|
||||
register("fluxaKmpLibrary") {
|
||||
id = "fluxa.kmp.library"
|
||||
implementationClass = "FluxaKmpLibraryPlugin"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
41
build-logic/src/main/kotlin/FluxaKmpLibraryPlugin.kt
Normal file
41
build-logic/src/main/kotlin/FluxaKmpLibraryPlugin.kt
Normal 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package com.fluxa.app.common
|
||||
|
||||
expect fun epochMillisNow(): Long
|
||||
|
||||
internal expect class SimpleLock() {
|
||||
fun <T> withLock(block: () -> T): T
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
@ -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) {
|
||||
|
|
@ -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()
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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" }
|
||||
|
|
|
|||
Loading…
Reference in a new issue