Retire FluxaCoreNative's hand-written JNI in favor of core_invoke

Every stateless method on FluxaCoreNative now calls the corresponding
core_invoke route via FluxaCoreUniFfi instead of a hand-written
external fun; only the handle-lifecycle functions (headless engine,
app core state) keep their dedicated JNI bindings. NuvioCoreBridge and
ExternalLibraryClient's generic coreInvoke calls move onto the same
UniFFI path. Public method signatures are unchanged, so no other call
site needed to change.
This commit is contained in:
KhooLy 2026-07-18 15:01:38 +03:00
parent c5df51f68a
commit ae3e135b6f
10 changed files with 938 additions and 852 deletions

View file

@ -1,7 +1,7 @@
package com.fluxa.app.ui.catalog
import android.util.Log
import com.fluxa.app.core.rust.FluxaCoreStateHandle
import com.fluxa.app.core.rust.FluxaUniFfiCoreStateHandle
import com.fluxa.app.data.local.*
import com.fluxa.app.data.local.UserProfile
import com.fluxa.app.data.remote.Meta
@ -36,7 +36,7 @@ internal class HomeLibraryCoordinator(
private val repository: StremioRepository,
private val traktRepository: TraktRepository,
private val scope: CoroutineScope,
private val coreState: FluxaCoreStateHandle,
private val coreState: FluxaUniFfiCoreStateHandle,
private val gson: Gson
) {
private val _state = MutableStateFlow(LibraryUiState())

View file

@ -1,7 +1,7 @@
package com.fluxa.app.ui.catalog
import com.fluxa.app.core.rust.FluxaCoreNative
import com.fluxa.app.core.rust.FluxaCoreStateHandle
import com.fluxa.app.core.rust.FluxaCoreUniFfi
import com.fluxa.app.core.rust.FluxaUniFfiCoreStateHandle
import com.fluxa.app.data.remote.Meta
import com.google.gson.Gson
import java.io.Closeable
@ -11,7 +11,7 @@ import kotlinx.coroutines.flow.asStateFlow
class HomeSearchFocusStateHolder(
initialHistory: List<Meta>,
private val coreState: FluxaCoreStateHandle = FluxaCoreNative.createAppCoreState(
private val coreState: FluxaUniFfiCoreStateHandle = FluxaCoreUniFfi.createAppCoreState(
mapOf(
"homeSearch" to mapOf(
"searchHistory" to initialHistory

View file

@ -6,7 +6,8 @@ import com.fluxa.app.data.remote.*
import com.fluxa.app.data.repository.*
import com.fluxa.app.core.rust.FluxaAndroidHeadlessEnvironment
import com.fluxa.app.core.rust.FluxaCoreNative
import com.fluxa.app.core.rust.FluxaCoreStateHandle
import com.fluxa.app.core.rust.FluxaCoreUniFfi
import com.fluxa.app.core.rust.FluxaUniFfiCoreStateHandle
import com.fluxa.app.core.rust.FluxaHeadlessRuntimeFactory
import com.fluxa.app.domain.discovery.DiscoverCatalogOption
import com.fluxa.app.domain.discovery.MetadataFeedOption
@ -71,7 +72,7 @@ class HomeViewModel @Inject constructor(
private val addonListType = object : TypeToken<List<AddonDescriptor>>() {}.type
private val headlessRuntime = FluxaHeadlessRuntimeFactory.createUniFfi(headlessEnvironment)
private val initialSearchHistory = searchHistoryStore.load(null)
private val coreState: FluxaCoreStateHandle = FluxaCoreNative.createAppCoreState(
private val coreState: FluxaUniFfiCoreStateHandle = FluxaCoreUniFfi.createAppCoreState(
mapOf(
"home" to mapOf(
"categories" to emptyList<HomeCategory>(),
@ -143,6 +144,7 @@ class HomeViewModel @Inject constructor(
scope = viewModelScope,
gson = gson,
dispatch = ::dispatchHeadless,
activeProfile = { currentActiveProfile },
setActiveProfile = { setActiveProfileState(it) },
setWatchlist = ::setWatchlistState,
setContinueWatching = ::setCurrentWatchlistState,
@ -237,6 +239,16 @@ class HomeViewModel @Inject constructor(
private var currentWatchlist: List<Meta> = emptyList()
private val _currentContinueWatchingCount = MutableStateFlow(0)
val currentContinueWatchingCount: StateFlow<Int> = _currentContinueWatchingCount.asStateFlow()
private val _syncingProviders = MutableStateFlow<Set<String>>(emptySet())
val syncingProviders: StateFlow<Set<String>> = _syncingProviders.asStateFlow()
fun setProviderSyncing(provider: String, syncing: Boolean) {
_syncingProviders.value = if (syncing) {
_syncingProviders.value + provider
} else {
_syncingProviders.value - provider
}
}
private var externalContinueWatching: List<Meta> = emptyList()
private var traktWatchedState: TraktWatchedState = TraktWatchedState()
private var currentActiveProfile: UserProfile? = null
@ -1086,7 +1098,7 @@ class HomeViewModel @Inject constructor(
cacheSaveJob?.cancel()
cacheSaveJob = viewModelScope.launch(Dispatchers.Default) {
delay(600)
homeCategoryCache.save(profile, categories)
homeCategoryCache.save(profile, categories.filterNot { it.id == "continue_watching" })
}
}

View file

@ -1,7 +1,7 @@
package com.fluxa.app.ui.catalog
import android.content.Context
import com.fluxa.app.core.rust.FluxaCoreStateHandle
import com.fluxa.app.core.rust.FluxaUniFfiCoreStateHandle
import com.fluxa.app.data.local.UserProfile
import com.fluxa.app.data.local.WatchlistManager
import com.fluxa.app.data.remote.AddonDescriptor
@ -19,7 +19,7 @@ class HomeViewModelCoordinatorFactory @Inject constructor() {
repository: StremioRepository,
traktRepository: TraktRepository,
scope: CoroutineScope,
coreState: FluxaCoreStateHandle,
coreState: FluxaUniFfiCoreStateHandle,
gson: Gson
): HomeLibraryCoordinator {
return HomeLibraryCoordinator(repository, traktRepository, scope, coreState, gson)

View file

@ -11,8 +11,8 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.media3.ui.AspectRatioFrameLayout
import com.fluxa.app.core.rust.FluxaCoreNative
import com.fluxa.app.core.rust.FluxaCoreStateHandle
import com.fluxa.app.core.rust.FluxaCoreUniFfi
import com.fluxa.app.core.rust.FluxaUniFfiCoreStateHandle
import com.fluxa.app.data.remote.IntroTimestamps
import com.fluxa.app.data.remote.Stream
import com.fluxa.app.data.remote.Video
@ -39,7 +39,7 @@ internal class PlayerScreenState(
initialVolume: Int
) {
private val gson = Gson()
private val coreState: FluxaCoreStateHandle = FluxaCoreNative.createAppCoreState(
private val coreState: FluxaUniFfiCoreStateHandle = FluxaCoreUniFfi.createAppCoreState(
mapOf(
"player" to mapOf(
"currentVideoId" to initialVideoId,
@ -111,7 +111,6 @@ internal class PlayerScreenState(
var showSettings by mutableStateOf(false)
var activeSettingsTab by mutableIntStateOf(0)
var inlineDelayTarget by mutableStateOf<Int?>(null)
var audioDelayMs by mutableLongStateOf(0L)
var subtitleDelayMs by mutableLongStateOf(0L)

View file

@ -407,11 +407,11 @@ class FluxaCoreBenchmarkTest {
!name.contains("cacheTrimPolicy", ignoreCase = true)
}
assertTrue(nativeCacheStorageFunctions.isEmpty())
assertTrue(nativeFunctionNames.any { it == "cacheEntryPolicyJsonNative" })
assertTrue(nativeFunctionNames.any { it == "cacheTrimPolicyJsonNative" })
assertTrue(nativeFunctionNames.any { it == "cacheEntryPolicy" })
assertTrue(nativeFunctionNames.any { it == "cacheTrimPolicy" })
assertFalse(nativeFunctionNames.any { it == "startLocalStreamServerNative" })
assertFalse(nativeFunctionNames.any { it == "stopLocalStreamServerNative" })
assertTrue(nativeFunctionNames.any { it == "parseManifestJsonNative" })
assertTrue(nativeFunctionNames.any { it == "coreInvokeNative" })
}
private fun startStaticServer(payload: ByteArray, requestCount: AtomicInteger): StaticByteServer {

View file

@ -34,4 +34,13 @@ class FluxaCoreUniFfiContractTest {
assertEquals("Movie", meta["name"])
}
}
@Test
fun coreInvokeRoutesCalendarMethodThroughUniFfi() {
val value = FluxaCoreUniFfi.coreInvokeValue(
"calendarSeasonCandidates",
"""{"seasonsCount":10,"lastVideoId":"tt1:2:3"}"""
)
assertEquals("[2,3,10]", value.toString())
}
}

View file

@ -1,10 +1,12 @@
package com.fluxa.app.data.repository
import android.util.Log
import com.fluxa.app.core.rust.FluxaCoreUniFfi
import com.fluxa.app.data.BuildConfig
import com.fluxa.app.data.local.UserProfile
import com.fluxa.app.data.local.safeLocalAddons
import com.fluxa.app.data.local.safeLanguage
import com.fluxa.app.data.remote.AnilistGraphQlRequest
import com.fluxa.app.data.remote.Meta
import com.fluxa.app.data.remote.MetaDetail
import com.fluxa.app.data.remote.SimklEpisode
@ -13,6 +15,7 @@ import com.fluxa.app.data.remote.TraktApi
import com.fluxa.app.data.remote.TraktEpisode
import com.fluxa.app.common.AppStrings
import com.fluxa.app.domain.ContentIdentity
import com.google.gson.JsonParser
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
@ -25,6 +28,29 @@ import javax.inject.Singleton
private const val EPISODE_PROGRESS_UNIT_MS = 45 * 60_000L
private const val ANILIST_CURRENT_LIST_QUERY = """
query (${'$'}userId: Int) {
MediaListCollection(userId: ${'$'}userId, type: ANIME, status: CURRENT) {
lists {
entries {
status
progress
updatedAt
media {
id
title { english romaji native }
coverImage { extraLarge large }
bannerImage
episodes
seasonYear
genres
}
}
}
}
}
"""
@Singleton
class ExternalLibraryClient @Inject constructor(
private val traktApi: TraktApi,
@ -43,7 +69,8 @@ class ExternalLibraryClient @Inject constructor(
val trakt = async { getTraktPlaybackItems(profile, language) }
val mal = async { getMalContinueWatchingItems(profile.malAccessToken) }
val simkl = async { getSimklContinueWatchingItems(profile.simklAccessToken) }
val combined = trakt.await() + mal.await() + simkl.await()
val anilist = async { getAnilistContinueWatchingItems(profile.anilistAccessToken) }
val combined = trakt.await() + mal.await() + simkl.await() + anilist.await()
distinctByIdentityKey(combined)
}
}
@ -258,6 +285,75 @@ class ExternalLibraryClient @Inject constructor(
)
}
private suspend fun getAnilistContinueWatchingItems(token: String?): List<Meta> {
if (token.isNullOrBlank()) return emptyList()
val authorization = "Bearer $token"
return try {
val viewerResponse = traktApi.anilistGraphQl(
authorization,
AnilistGraphQlRequest(query = "query { Viewer { id } }")
)
val viewerId = viewerResponse
.getAsJsonObject("data")
?.getAsJsonObject("Viewer")
?.get("id")
?.takeUnless { it.isJsonNull }
?.asInt ?: return emptyList()
val listResponse = traktApi.anilistGraphQl(
authorization,
AnilistGraphQlRequest(
query = ANILIST_CURRENT_LIST_QUERY,
variables = mapOf("userId" to viewerId)
)
)
val lists = listResponse
.getAsJsonObject("data")
?.getAsJsonObject("MediaListCollection")
?.getAsJsonArray("lists")
?: com.google.gson.JsonArray()
val entries = com.google.gson.JsonArray()
lists.forEach { list ->
list.asJsonObject.getAsJsonArray("entries")?.forEach(entries::add)
}
if (entries.size() == 0) return emptyList()
val args = com.google.gson.JsonObject().apply {
add("entries", entries)
addProperty("nowMs", System.currentTimeMillis())
}
val envelope = JsonParser.parseString(
FluxaCoreUniFfi.coreInvoke("anilistEntriesToSync", args.toString())
).asJsonObject
if (envelope.get("ok")?.asBoolean != true) return emptyList()
val progress = envelope.getAsJsonObject("value")?.getAsJsonObject("progress") ?: return emptyList()
progress.entrySet().mapNotNull { (_, value) -> anilistProgressEntryToMeta(value.asJsonObject) }
} catch (e: Exception) {
Log.w("ExternalLibraryClient", "Failed to load AniList continue watching items", e)
emptyList()
}
}
private fun anilistProgressEntryToMeta(entry: com.google.gson.JsonObject): Meta? {
val meta = entry.getAsJsonObject("meta") ?: return null
val id = meta.get("id")?.takeUnless { it.isJsonNull }?.asString ?: return null
val savedAt = entry.get("savedAt")?.takeUnless { it.isJsonNull }?.asString
val lastWatchedAt = savedAt?.let { runCatching { java.time.Instant.parse(it).toEpochMilli() }.getOrNull() }
return Meta(
id = id,
name = meta.get("name")?.takeUnless { it.isJsonNull }?.asString ?: id,
type = meta.get("type")?.takeUnless { it.isJsonNull }?.asString ?: "series",
poster = meta.get("poster")?.takeUnless { it.isJsonNull }?.asString,
background = meta.get("background")?.takeUnless { it.isJsonNull }?.asString,
timeOffset = entry.get("timeOffset")?.takeUnless { it.isJsonNull }?.asLong,
duration = entry.get("duration")?.takeUnless { it.isJsonNull }?.asLong,
lastVideoId = entry.get("lastVideoId")?.takeUnless { it.isJsonNull }?.asString,
lastEpisodeName = entry.get("lastEpisodeName")?.takeUnless { it.isJsonNull }?.asString,
lastWatchedAt = lastWatchedAt,
reason = "AniList"
)
}
private fun findEpisodeArtwork(detail: MetaDetail?, lastVideoId: String?, episode: TraktEpisode?): String? {
val videos = detail?.videos.orEmpty()
if (videos.isEmpty()) return null

View file

@ -0,0 +1,61 @@
package com.fluxa.app.data.repository
import com.fluxa.app.core.rust.FluxaCoreUniFfi
import com.google.gson.JsonArray
import com.google.gson.JsonElement
import com.google.gson.JsonObject
object NuvioCoreBridge {
fun buildLocalProfiles(
sessionProfile: JsonObject,
nuvioProfiles: JsonElement,
avatarCatalog: JsonElement,
existingProfiles: JsonElement
): JsonArray = invoke(
"nuvioBuildLocalProfiles",
JsonObject().apply {
add("sessionProfile", sessionProfile)
add("nuvioProfiles", nuvioProfiles)
add("avatarCatalog", avatarCatalog)
add("existingProfiles", existingProfiles)
}
).asJsonArray
fun libraryToWatchlist(library: JsonElement): JsonArray = invoke(
"nuvioLibraryToWatchlist",
JsonObject().apply { add("library", library) }
).asJsonArray
fun progressMetaNeeds(watchProgress: JsonElement, library: JsonElement): JsonArray = invoke(
"nuvioProgressMetaNeeds",
JsonObject().apply {
add("watchProgress", watchProgress)
add("library", library)
}
).asJsonArray
fun importMergePlan(
library: JsonElement,
addonMetas: JsonObject,
watchProgress: JsonElement,
watchHistory: JsonElement
): JsonObject = invoke(
"nuvioImportMergePlan",
JsonObject().apply {
add("progress", JsonObject())
add("watched", JsonObject())
add("library", library)
add("addonMetas", addonMetas)
add("watchProgress", watchProgress)
add("watchHistory", watchHistory)
}
).asJsonObject
fun mapCollections(collections: JsonElement): JsonArray = invoke(
"nuvioMapCollections",
JsonObject().apply { add("collections", collections) }
).asJsonArray
private fun invoke(method: String, args: JsonObject): JsonElement =
FluxaCoreUniFfi.coreInvokeValue(method, args.toString())
}