Initial commit

This commit is contained in:
tanexc
2026-06-12 23:49:11 +03:00
commit f69ffd79c2
222 changed files with 7094 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
/build
+63
View File
@@ -0,0 +1,63 @@
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.androidMultiplatformLibrary)
alias(libs.plugins.kotlinxSerialization)
}
kotlin {
android {
namespace = "ru.tanexc.hemoanalysis"
compileSdk {
version = release(36)
}
minSdk = 26
}
listOf(
iosArm64(),
iosSimulatorArm64()
).forEach { iosTarget ->
iosTarget.binaries.framework {
baseName = "datakit"
isStatic = true
}
}
sourceSets {
commonMain {
dependencies {
api(projects.domain)
api(projects.core.database)
api(projects.core.network)
// settings store
implementation(libs.multiplatform.settings)
implementation(libs.multiplatform.settings.serialization)
implementation(libs.kotlinx.serialization.json)
implementation(libs.kotlinx.coroutines.core)
// koin
implementation(libs.koin.core)
implementation(libs.kotlinx.datetime)
}
}
androidMain {
dependencies {
// datastore
implementation(libs.androidx.datastore)
implementation(libs.androidx.datastore.preferences)
}
}
iosMain {
dependencies {
// datastore
implementation(libs.androidx.datastore)
implementation(libs.androidx.datastore.preferences)
}
}
}
}
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>
@@ -0,0 +1,33 @@
package ru.tanexc.hemoanalysis.usecase
import android.content.Context
import android.util.Log
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import ru.tanexc.hemoanalysis.Constants.INTERNAL_MODELS_DIR
import ru.tanexc.hemoanalysis.domain.repository.ModelRepository
import ru.tanexc.hemoanalysis.domain.usecase.GetLatestModelFileUseCase
import java.io.File
internal actual class GetLatestModelFileUseCaseImpl : GetLatestModelFileUseCase, KoinComponent {
private val context: Context by inject()
private val modelRepository: ModelRepository by inject()
actual override operator fun invoke(): ByteArray? {
val filename = modelRepository.getCurrentModelInfo()?.filename?: return null
val modelsDir = File(context.filesDir, INTERNAL_MODELS_DIR)
if (!modelsDir.exists()) return null
val modelFile = File(modelsDir, filename)
if (!modelFile.exists()) return null
Log.i("GetLatestModelFileUseCase", "get model bytearray ${modelFile.absolutePath}")
return modelFile.inputStream().buffered().use { inputStream ->
inputStream.readBytes()
}
}
}
@@ -0,0 +1,15 @@
package ru.tanexc.hemoanalysis.usecase
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import ru.tanexc.hemoanalysis.domain.repository.ModelRepository
import ru.tanexc.hemoanalysis.domain.usecase.GetModelPathUseCase
import kotlin.getValue
actual class GetModelPathUseCaseImpl actual constructor() : GetModelPathUseCase, KoinComponent {
private val modelRepository: ModelRepository by inject()
actual override fun invoke(): String? {
return modelRepository.getCurrentModelInfo()?.filename
}
}
@@ -0,0 +1,42 @@
package ru.tanexc.hemoanalysis.usecase
import android.content.Context
import android.util.Log
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import ru.tanexc.hemoanalysis.Constants.INTERNAL_MODELS_DIR
import ru.tanexc.hemoanalysis.domain.model.Size
import ru.tanexc.hemoanalysis.domain.usecase.BytesReader
import ru.tanexc.hemoanalysis.domain.usecase.SaveModelFileUseCase
import java.io.File
internal actual class SaveModelFileUseCaseImpl : SaveModelFileUseCase, KoinComponent {
val context: Context by inject()
actual override suspend operator fun invoke(
filename: String,
bytesReader: BytesReader
): Size {
val modelsDir = File(context.filesDir, INTERNAL_MODELS_DIR)
modelsDir.mkdirs()
Log.i("SaveModelFileUseCase", "saving model into ${modelsDir.absolutePath}")
val modelFile = File(modelsDir, filename)
modelFile.outputStream().buffered().use { output ->
var bytes = bytesReader.readNext(BUFFER_SIZE)
while (bytes.isNotEmpty()) {
output.write(bytes)
bytes = bytesReader.readNext(BUFFER_SIZE)
}
}
Log.i("SaveModelFileUseCase", "model saved into file $modelFile ${modelFile.length()}")
return Size(modelFile.length().toInt())
}
private val BUFFER_SIZE = 1024 * 1024
}
@@ -0,0 +1,6 @@
package ru.tanexc.hemoanalysis
internal object Constants {
const val INTERNAL_MODELS_DIR = "models"
const val DEFAULT_MODEL_NAME = "model.ort"
}
@@ -0,0 +1,41 @@
package ru.tanexc.hemoanalysis.di
import org.koin.core.context.loadKoinModules
import org.koin.core.module.dsl.singleOf
import org.koin.core.qualifier.named
import org.koin.dsl.bind
import org.koin.dsl.module
import ru.tanexc.hemoanalysis.repository.AnalysisParamsRepositoryImpl
import ru.tanexc.hemoanalysis.repository.ModelRepositoryImpl
import ru.tanexc.hemoanalysis.core.database.databaseModule
import ru.tanexc.hemoanalysis.core.network.di.networkModule
import ru.tanexc.hemoanalysis.domain.repository.AnalysisParamsRepository
import ru.tanexc.hemoanalysis.domain.repository.ModelRepository
import ru.tanexc.hemoanalysis.domain.usecase.GetLatestModelFileUseCase
import ru.tanexc.hemoanalysis.domain.usecase.GetModelPathUseCase
import ru.tanexc.hemoanalysis.domain.usecase.SaveModelFileUseCase
import ru.tanexc.hemoanalysis.usecase.GetLatestModelFileUseCaseImpl
import ru.tanexc.hemoanalysis.usecase.GetModelPathUseCaseImpl
import ru.tanexc.hemoanalysis.usecase.SaveModelFileUseCaseImpl
val dataModule = module {
loadKoinModules(listOf(databaseModule, networkModule))
singleOf(::GetModelPathUseCaseImpl) bind GetModelPathUseCase::class
singleOf(::SaveModelFileUseCaseImpl) bind SaveModelFileUseCase::class
singleOf(::GetLatestModelFileUseCaseImpl) bind GetLatestModelFileUseCase::class
single<AnalysisParamsRepository> {
AnalysisParamsRepositoryImpl(
settings = get(named("AnalysisParamsSettings"))
)
}
single<ModelRepository> {
ModelRepositoryImpl(
settings = get(named("ModelInfoSettings")),
api = get(),
saveModelFileUseCase = get()
)
}
}
@@ -0,0 +1,15 @@
package ru.tanexc.hemoanalysis.repository
import com.russhwolf.settings.Settings
import com.russhwolf.settings.float
import com.russhwolf.settings.int
import ru.tanexc.hemoanalysis.domain.repository.AnalysisParamsRepository
internal class AnalysisParamsRepositoryImpl(
settings: Settings
): AnalysisParamsRepository {
override var confThreshold by settings.float("conf_threshold", 0.5f)
override var iouThreshold by settings.float("iou_threshold", 0.5f)
override var preprocessVariant by settings.int("preprocess_variant", 0)
override var roiVariant by settings.int("roi_variant", 0)
}
@@ -0,0 +1,183 @@
package ru.tanexc.hemoanalysis.repository
import com.russhwolf.settings.Settings
import com.russhwolf.settings.string
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.format
import kotlinx.datetime.format.DateTimeFormat
import kotlinx.datetime.format.char
import kotlinx.datetime.toLocalDateTime
import kotlinx.serialization.json.Json
import org.koin.core.logger.Logger
import ru.tanexc.hemoanalysis.core.network.ModelApi
import ru.tanexc.hemoanalysis.core.network.RequestState
import ru.tanexc.hemoanalysis.domain.State
import ru.tanexc.hemoanalysis.domain.model.ModelInfo
import ru.tanexc.hemoanalysis.domain.model.Size
import ru.tanexc.hemoanalysis.domain.repository.ModelRepository
import ru.tanexc.hemoanalysis.domain.usecase.SaveModelFileUseCase
import kotlin.time.Clock
import kotlin.time.Instant
internal class ModelRepositoryImpl(
settings: Settings,
private val api: ModelApi,
private val saveModelFileUseCase: SaveModelFileUseCase,
) : ModelRepository {
private var currentModelInfo: String by settings.string("current_model_info", "")
private var latestModelInfo: String by settings.string("latest_model_info", "")
private var apiKey: String by settings.string("api_key", "")
private val json = Json {
ignoreUnknownKeys = true
}
override fun getLatestModelInfo(): ModelInfo? = runCatching {
json.decodeFromString<ModelInfo>(latestModelInfo)
}.getOrNull()
override fun getCurrentModelInfo(): ModelInfo? = runCatching {
json.decodeFromString<ModelInfo>(currentModelInfo)
}.getOrNull()
override fun updateModelInfo(): Flow<State<ModelInfo?>> = flow {
emit(State.Loading)
when (val state = api.getLatestModelInfo(apiKey)) {
is RequestState.Error -> emit(State.Error)
is RequestState.Success -> {
val timeNow = nowFormatted()
val meta = state.data
val currentInfo = getCurrentModelInfo()?.copy(
lastUpdateTime = timeNow
)
currentModelInfo = currentInfo.asString()
if (meta.version.isBiggerVersion(currentInfo?.version ?: "")) {
val modelInfo = ModelInfo(
version = meta.version,
lastUpdateTime = timeNow,
createdAt = meta.createdAt.toDisplayDateTime(),
filename = meta.filename,
isLatest = true,
size = Size.ZERO
)
currentModelInfo = currentInfo?.copy(
isLatest = false
).asString()
latestModelInfo = modelInfo.asString()
emit(State.Success(modelInfo))
} else {
emit(State.Success(currentInfo))
}
}
}
}
override fun loadLatestModel(): Flow<State<ModelInfo?>> = flow {
emit(State.Loading)
val latestInfo = getLatestModelInfo()
if (latestInfo == null) {
emit(State.Error)
return@flow
}
val state = api.downloadLatestModel(
version = latestInfo.version,
filename = latestInfo.filename,
apiKey = apiKey
)
when (state) {
is RequestState.Error -> emit(State.Error)
is RequestState.Success -> {
val savedSize = saveModelFileUseCase(latestInfo.filename, state.data.bytesReader)
val currentInfo = latestInfo.copy(
lastUpdateTime = nowFormatted(),
isLatest = true,
size = savedSize
)
currentModelInfo = currentInfo.asString()
emit(State.Success(currentInfo))
}
}
}
override fun getKeyApi(): String = apiKey
override fun updateApiKey(apiKey: String) {
this.apiKey = apiKey
}
private fun String.toVersionParts(): List<Int> {
return split(".").map { part ->
part.toIntOrNull() ?: 0
}
}
private fun String.isBiggerVersion(
second: String
): Boolean {
val firstParts = this.toVersionParts()
val secondParts = second.toVersionParts()
val maxSize = maxOf(firstParts.size, secondParts.size)
for (index in 0 until maxSize) {
val firstValue = firstParts.getOrElse(index) { 0 }
val secondValue = secondParts.getOrElse(index) { 0 }
if (firstValue > secondValue) {
return true
}
}
return false
}
private fun ModelInfo?.asString() = this?.let(json::encodeToString) ?: ""
private fun nowFormatted(): String {
return Clock.System.now().toDisplayDateTime()
}
private fun String.toDisplayDateTime(): String {
if (runCatching { displayDateTimeFormat.parse(this) }.isSuccess) return this
return runCatching {
Instant.parse(this).toDisplayDateTime()
}.getOrElse {
runCatching {
LocalDateTime.parse(this).toDisplayDateTime()
}.getOrDefault(this)
}
}
private fun Instant.toDisplayDateTime(): String {
return toLocalDateTime(TimeZone.currentSystemDefault()).toDisplayDateTime()
}
private fun LocalDateTime.toDisplayDateTime(): String {
return format(displayDateTimeFormat)
}
companion object {
private val displayDateTimeFormat: DateTimeFormat<LocalDateTime> = LocalDateTime.Format {
day()
char('.')
monthNumber()
char('.')
year()
char(' ')
hour()
char(':')
minute()
}
}
}
@@ -0,0 +1,7 @@
package ru.tanexc.hemoanalysis.usecase
import ru.tanexc.hemoanalysis.domain.usecase.GetLatestModelFileUseCase
internal expect class GetLatestModelFileUseCaseImpl(): GetLatestModelFileUseCase {
override operator fun invoke(): ByteArray?
}
@@ -0,0 +1,7 @@
package ru.tanexc.hemoanalysis.usecase
import ru.tanexc.hemoanalysis.domain.usecase.GetModelPathUseCase
expect class GetModelPathUseCaseImpl() : GetModelPathUseCase {
override fun invoke(): String?
}
@@ -0,0 +1,9 @@
package ru.tanexc.hemoanalysis.usecase
import ru.tanexc.hemoanalysis.domain.model.Size
import ru.tanexc.hemoanalysis.domain.usecase.BytesReader
import ru.tanexc.hemoanalysis.domain.usecase.SaveModelFileUseCase
internal expect class SaveModelFileUseCaseImpl(): SaveModelFileUseCase {
override suspend fun invoke(filename: String, bytesReader: BytesReader): Size
}
@@ -0,0 +1,44 @@
package ru.tanexc.hemoanalysis.usecase
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.readBytes
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import platform.Foundation.NSApplicationSupportDirectory
import platform.Foundation.NSData
import platform.Foundation.NSFileManager
import platform.Foundation.NSSearchPathForDirectoriesInDomains
import platform.Foundation.NSUserDomainMask
import platform.Foundation.dataWithContentsOfFile
import ru.tanexc.hemoanalysis.Constants.INTERNAL_MODELS_DIR
import ru.tanexc.hemoanalysis.domain.repository.ModelRepository
import ru.tanexc.hemoanalysis.domain.usecase.GetLatestModelFileUseCase
import kotlin.getValue
internal actual class GetLatestModelFileUseCaseImpl : GetLatestModelFileUseCase, KoinComponent {
private val modelRepository: ModelRepository by inject()
@OptIn(ExperimentalForeignApi::class)
actual override operator fun invoke(): ByteArray? {
val filename = modelRepository.getCurrentModelInfo()?.filename?: return null
val appSupportDir = getApplicationSupportDir()
val modelFilePath = "$appSupportDir/$INTERNAL_MODELS_DIR/$filename"
if (!NSFileManager.defaultManager.fileExistsAtPath(modelFilePath)) return null
val data = NSData.dataWithContentsOfFile(modelFilePath) ?: return null
return data.bytes?.readBytes(data.length.toInt())
}
private fun getApplicationSupportDir(): String {
val paths = NSSearchPathForDirectoriesInDomains(
directory = NSApplicationSupportDirectory,
domainMask = NSUserDomainMask,
expandTilde = true
)
return paths.first() as? String ?: error("Application Support directory not found")
}
}
@@ -0,0 +1,32 @@
package ru.tanexc.hemoanalysis.usecase
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import platform.Foundation.NSApplicationSupportDirectory
import platform.Foundation.NSFileManager
import platform.Foundation.NSSearchPathForDirectoriesInDomains
import platform.Foundation.NSUserDomainMask
import ru.tanexc.hemoanalysis.Constants.INTERNAL_MODELS_DIR
import ru.tanexc.hemoanalysis.domain.repository.ModelRepository
import ru.tanexc.hemoanalysis.domain.usecase.GetModelPathUseCase
import kotlin.getValue
actual class GetModelPathUseCaseImpl actual constructor() : GetModelPathUseCase, KoinComponent {
private val modelRepository: ModelRepository by inject()
actual override fun invoke(): String? {
val filename = modelRepository.getCurrentModelInfo()?.filename ?: return null
val modelPath = "${getApplicationSupportDir()}/$INTERNAL_MODELS_DIR/$filename"
return modelPath.takeIf { NSFileManager.defaultManager.fileExistsAtPath(it) }
}
private fun getApplicationSupportDir(): String {
val paths = NSSearchPathForDirectoriesInDomains(
directory = NSApplicationSupportDirectory,
domainMask = NSUserDomainMask,
expandTilde = true
)
return paths.first() as? String ?: error("Application Support directory not found")
}
}
@@ -0,0 +1,89 @@
package ru.tanexc.hemoanalysis.usecase
import kotlinx.cinterop.BetaInteropApi
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.allocArrayOf
import kotlinx.cinterop.memScoped
import platform.Foundation.NSApplicationSupportDirectory
import platform.Foundation.NSData
import platform.Foundation.NSFileHandle
import platform.Foundation.NSFileManager
import platform.Foundation.NSSearchPathForDirectoriesInDomains
import platform.Foundation.NSUserDomainMask
import platform.Foundation.closeFile
import platform.Foundation.create
import platform.Foundation.fileHandleForWritingAtPath
import platform.Foundation.seekToEndOfFile
import platform.Foundation.writeData
import ru.tanexc.hemoanalysis.Constants.INTERNAL_MODELS_DIR
import ru.tanexc.hemoanalysis.domain.model.Size
import ru.tanexc.hemoanalysis.domain.usecase.BytesReader
import ru.tanexc.hemoanalysis.domain.usecase.SaveModelFileUseCase
internal actual class SaveModelFileUseCaseImpl actual constructor() :
SaveModelFileUseCase {
@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class)
actual override suspend operator fun invoke(
filename: String,
bytesReader: BytesReader
): Size {
val appSupportDir = getApplicationSupportDir()
val modelDir = "$appSupportDir/$INTERNAL_MODELS_DIR"
NSFileManager.defaultManager.createDirectoryAtPath(
path = modelDir,
withIntermediateDirectories = true,
attributes = null,
error = null
)
val filePath = "$modelDir/$filename"
if (NSFileManager.defaultManager.fileExistsAtPath(filePath)) {
NSFileManager.defaultManager.removeItemAtPath(filePath, error = null)
}
check(
NSFileManager.defaultManager.createFileAtPath(
path = filePath,
contents = null,
attributes = null
)
) { "Cannot create file at $filePath" }
val handle = NSFileHandle.fileHandleForWritingAtPath(filePath)
?: error("Cannot open file for writing at $filePath")
var totalBytes = 0
try {
handle.seekToEndOfFile()
var bytes = bytesReader.readNext(BUFFER_SIZE)
while (bytes.isNotEmpty()) {
handle.writeData(bytes.toNSData())
totalBytes += bytes.size
bytes = bytesReader.readNext(BUFFER_SIZE)
}
} finally {
handle.closeFile()
}
return Size(totalBytes)
}
private fun getApplicationSupportDir(): String {
val paths = NSSearchPathForDirectoriesInDomains(
directory = NSApplicationSupportDirectory,
domainMask = NSUserDomainMask,
expandTilde = true
)
return paths.first() as? String ?: error("Application Support directory not found")
}
private val BUFFER_SIZE = 1024 * 1024
@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class)
private fun ByteArray.toNSData(): NSData = memScoped {
NSData.create(
bytes = allocArrayOf(this@toNSData),
length = this@toNSData.size.toULong()
)
}
}
+1
View File
@@ -0,0 +1 @@
/build
+56
View File
@@ -0,0 +1,56 @@
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.androidMultiplatformLibrary)
}
kotlin {
android {
namespace = "ru.tanexc.hemoanalysis.core.database"
compileSdk {
version = release(36)
}
minSdk = 26
}
listOf(
iosArm64(),
iosSimulatorArm64()
).forEach { iosTarget ->
iosTarget.binaries.framework {
baseName = "databasekit"
isStatic = true
}
}
sourceSets {
commonMain {
dependencies {
// settings store
implementation(libs.multiplatform.settings)
implementation(libs.multiplatform.settings.serialization)
implementation(libs.kotlinx.serialization.json)
implementation(libs.kotlinx.coroutines.core)
// koin
implementation(libs.koin.core)
}
}
androidMain {
dependencies {
// datastore
implementation(libs.androidx.datastore)
implementation(libs.androidx.datastore.preferences)
}
}
iosMain {
dependencies {
// datastore
implementation(libs.androidx.datastore)
implementation(libs.androidx.datastore.preferences)
}
}
}
}
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>
@@ -0,0 +1,12 @@
package ru.tanexc.hemoanalysis.core.database
import android.content.Context
import com.russhwolf.settings.Settings
import com.russhwolf.settings.SharedPreferencesSettings
import org.koin.core.scope.Scope
internal actual fun Scope.createAnalysisParamsSettings(): Settings {
return SharedPreferencesSettings(
delegate = get<Context>().getSharedPreferences("analysis_params_settings", Context.MODE_PRIVATE)
)
}
@@ -0,0 +1,12 @@
package ru.tanexc.hemoanalysis.core.database
import android.content.Context
import com.russhwolf.settings.Settings
import com.russhwolf.settings.SharedPreferencesSettings
import org.koin.core.scope.Scope
internal actual fun Scope.createModelInfoSettings(): Settings {
return SharedPreferencesSettings(
delegate = get<Context>().getSharedPreferences("model_info_settings", Context.MODE_PRIVATE)
)
}
@@ -0,0 +1,6 @@
package ru.tanexc.hemoanalysis.core.database
import com.russhwolf.settings.Settings
import org.koin.core.scope.Scope
internal expect fun Scope.createAnalysisParamsSettings(): Settings
@@ -0,0 +1,10 @@
package ru.tanexc.hemoanalysis.core.database
import com.russhwolf.settings.Settings
import org.koin.core.qualifier.StringQualifier
import org.koin.dsl.module
val databaseModule = module {
single<Settings>(qualifier = StringQualifier("AnalysisParamsSettings")) { createAnalysisParamsSettings() }
single<Settings>(qualifier = StringQualifier("ModelInfoSettings")) { createModelInfoSettings() }
}
@@ -0,0 +1,6 @@
package ru.tanexc.hemoanalysis.core.database
import com.russhwolf.settings.Settings
import org.koin.core.scope.Scope
internal expect fun Scope.createModelInfoSettings(): Settings
@@ -0,0 +1,12 @@
package ru.tanexc.hemoanalysis.core.database
import com.russhwolf.settings.NSUserDefaultsSettings
import com.russhwolf.settings.Settings
import org.koin.core.scope.Scope
import platform.Foundation.NSUserDefaults
internal actual fun Scope.createAnalysisParamsSettings(): Settings {
return NSUserDefaultsSettings(
delegate = NSUserDefaults.standardUserDefaults
)
}
@@ -0,0 +1,12 @@
package ru.tanexc.hemoanalysis.core.database
import com.russhwolf.settings.NSUserDefaultsSettings
import com.russhwolf.settings.Settings
import org.koin.core.scope.Scope
import platform.Foundation.NSUserDefaults
internal actual fun Scope.createModelInfoSettings(): Settings {
return NSUserDefaultsSettings(
delegate = NSUserDefaults.standardUserDefaults
)
}
+1
View File
@@ -0,0 +1 @@
/build
+58
View File
@@ -0,0 +1,58 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.androidMultiplatformLibrary)
alias(libs.plugins.kotlinxSerialization)
}
kotlin {
android {
namespace = "ru.tanexc.hemoanalysis.navigation"
compilerOptions {
jvmTarget.set(JvmTarget.JVM_17)
}
compileSdk {
version = release(36)
}
minSdk = 26
}
listOf(
iosArm64(),
iosSimulatorArm64()
).forEach { iosTarget ->
iosTarget.binaries.framework {
baseName = "navigationkit"
isStatic = true
}
}
sourceSets {
commonMain {
dependencies {
implementation(libs.kotlinx.serialization.json)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.compose.foundation)
// decompose
implementation(libs.decompose)
implementation(libs.decompose.extensions.compose)
implementation(libs.decompose.extensions.compose.experimental)
implementation(libs.essenty.lifecycle)
// koin
implementation(libs.koin.core)
}
}
androidMain {
dependencies {
implementation(libs.androidx.activity.ktx)
}
}
}
}
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>
@@ -0,0 +1,16 @@
package ru.tanexc.hemoanalysis.navigation
import com.arkivanov.decompose.ComponentContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.SupervisorJob
abstract class BaseComponent(
val context: ComponentContext,
val onNavigate: (Config) -> Unit,
val onBack: () -> Unit
): ComponentContext by context {
val componentScope: CoroutineScope
get() = CoroutineScope(SupervisorJob() + Dispatchers.IO).withLifecycle(lifecycle)
}
@@ -0,0 +1,26 @@
package ru.tanexc.hemoanalysis.navigation
import kotlinx.serialization.Serializable
@Serializable
sealed class Config(
val navLabelResId: String,
val navIconResId: String
) {
@Serializable
data object Analysis: Config(
navLabelResId = "analysis_label",
navIconResId = "analysis",
)
@Serializable
data object Settings: Config(
navLabelResId = "settings_label",
navIconResId = "settings",
)
companion object {
val navEntries: List<Config> = listOf(Analysis, Settings)
}
}
@@ -0,0 +1,17 @@
package ru.tanexc.hemoanalysis.navigation
import com.arkivanov.essenty.lifecycle.Lifecycle
import com.arkivanov.essenty.lifecycle.doOnDestroy
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.cancel
import org.koin.core.parameter.parametersOf
import org.koin.mp.KoinPlatform.getKoin
fun CoroutineScope.withLifecycle(lifecycle: Lifecycle): CoroutineScope {
lifecycle.doOnDestroy(::cancel)
return this
}
inline fun <reified T : BaseComponent> getComponent(vararg parameters: Any?) = getKoin().get<T> {
parametersOf(*parameters)
}
+1
View File
@@ -0,0 +1 @@
/build
+52
View File
@@ -0,0 +1,52 @@
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.androidMultiplatformLibrary)
alias(libs.plugins.kotlinxSerialization)
}
kotlin {
android {
namespace = "ru.tanexc.hemoanalysis.core.network"
compileSdk {
version = release(36)
}
minSdk = 26
}
listOf(
iosArm64(),
iosSimulatorArm64()
).forEach { iosTarget ->
iosTarget.binaries.framework {
baseName = "nwtworkKit"
isStatic = true
}
}
sourceSets {
commonMain {
dependencies {
api(projects.domain)
implementation(libs.kotlinx.coroutines.core)
// koin
implementation(libs.koin.core)
// ktor
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.client.logging)
implementation(libs.ktor.serialization.kotlinx.json)
}
}
androidMain.dependencies {
implementation(libs.ktor.client.okhttp)
}
iosMain.dependencies {
implementation(libs.ktor.client.darwin)
}
}
}
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>
@@ -0,0 +1,10 @@
package ru.tanexc.hemoanalysis.core.network.util
import io.ktor.client.HttpClient
import io.ktor.client.HttpClientConfig
import io.ktor.client.engine.okhttp.OkHttp
internal actual fun PlatformHttpClient(declaration: HttpClientConfig<*>.() -> Unit): HttpClient = HttpClient(
engineFactory = OkHttp,
block = declaration,
)
@@ -0,0 +1,3 @@
package ru.tanexc.hemoanalysis.core.network
internal const val DEFAULT_BASE_URL = "http://111.88.154.194:8000"
@@ -0,0 +1,67 @@
package ru.tanexc.hemoanalysis.core.network
import io.ktor.client.HttpClient
import io.ktor.client.request.header
import io.ktor.client.request.parameter
import io.ktor.client.statement.HttpResponse
import io.ktor.client.statement.bodyAsChannel
import io.ktor.http.ContentType
import io.ktor.http.HttpStatusCode
import io.ktor.http.contentType
import io.ktor.http.isSuccess
import io.ktor.util.logging.KtorSimpleLogger
import io.ktor.utils.io.availableForRead
import io.ktor.utils.io.readBuffer
import kotlinx.io.readByteArray
import ru.tanexc.hemoanalysis.core.network.response.ModelDownloadResponse
import ru.tanexc.hemoanalysis.core.network.response.ModelMetaResponse
import ru.tanexc.hemoanalysis.core.network.util.get
import ru.tanexc.hemoanalysis.domain.usecase.BytesReader
import kotlin.math.log
import io.ktor.client.request.get as ktorGet
class ModelApi(
private val httpClient: HttpClient,
) {
suspend fun getLatestModelInfo(apiKey: String): RequestState<ModelMetaResponse> =
httpClient.get(url = "$DEFAULT_BASE_URL/models/latest") {
header("X-API-KEY", apiKey)
}
suspend fun downloadLatestModel(
apiKey: String,
version: String,
filename: String
): RequestState<ModelDownloadResponse> = runCatching {
val response: HttpResponse =
httpClient.ktorGet("$DEFAULT_BASE_URL/models/latest/download") {
header("X-API-KEY", apiKey)
parameter("version", version)
parameter("filename", filename)
contentType(ContentType.Application.OctetStream)
}
when {
response.status.isSuccess() -> {
val buffer = response.bodyAsChannel()
val bytesReader = object : BytesReader {
override suspend fun readNext(size: Int): ByteArray {
return buffer.readBuffer(size).readByteArray()
}
}
RequestState.Success(
ModelDownloadResponse(
bytesReader = bytesReader,
totalBytes = buffer.availableForRead
)
)
}
response.status == HttpStatusCode.NotFound -> RequestState.Error("Model not found")
else -> RequestState.Error(response.status.description)
}
}.getOrElse {
RequestState.Error(
message = it.message ?: "Request error: $DEFAULT_BASE_URL/models/latest/download"
)
}
}
@@ -0,0 +1,7 @@
package ru.tanexc.hemoanalysis.core.network
sealed interface RequestState<out T> {
data class Success<T>(val data: T): RequestState<T>
data class Error(val message: String): RequestState<Nothing>
}
@@ -0,0 +1,31 @@
package ru.tanexc.hemoanalysis.core.network.di
import io.ktor.client.HttpClient
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.plugins.logging.DEFAULT
import io.ktor.client.plugins.logging.LogLevel
import io.ktor.client.plugins.logging.Logger
import io.ktor.client.plugins.logging.Logging
import io.ktor.client.plugins.logging.SIMPLE
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json
import org.koin.core.module.dsl.singleOf
import org.koin.dsl.module
import ru.tanexc.hemoanalysis.core.network.ModelApi
import ru.tanexc.hemoanalysis.core.network.util.PlatformHttpClient
val networkModule = module {
single<HttpClient> {
PlatformHttpClient {
install(ContentNegotiation) {
json(Json { ignoreUnknownKeys = true })
}
install(Logging) {
level = LogLevel.HEADERS
}
}
}
singleOf(::ModelApi)
}
@@ -0,0 +1,8 @@
package ru.tanexc.hemoanalysis.core.network.response
import ru.tanexc.hemoanalysis.domain.usecase.BytesReader
data class ModelDownloadResponse(
val bytesReader: BytesReader,
val totalBytes: Int
)
@@ -0,0 +1,12 @@
package ru.tanexc.hemoanalysis.core.network.response
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class ModelMetaResponse(
val version: String,
@SerialName("created_at")
val createdAt: String,
val filename: String
)
@@ -0,0 +1,7 @@
package ru.tanexc.hemoanalysis.core.network.util
import io.ktor.client.HttpClient
import io.ktor.client.HttpClientConfig
import org.koin.core.scope.Scope
internal expect fun PlatformHttpClient(declaration: HttpClientConfig<*>.() -> Unit): HttpClient
@@ -0,0 +1,28 @@
package ru.tanexc.hemoanalysis.core.network.util
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.request.HttpRequestBuilder
import io.ktor.client.request.get
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.client.statement.HttpResponse
import io.ktor.http.ContentType
import io.ktor.http.contentType
import io.ktor.http.isSuccess
import ru.tanexc.hemoanalysis.core.network.RequestState
internal suspend inline fun <reified T> HttpClient.get(
url: String,
block: HttpRequestBuilder.() -> Unit = {}
): RequestState<T> = runCatching {
get(
urlString = url,
block = block
).handleResponse<T>()
}.getOrElse { RequestState.Error(message = it.message ?: "Request error: $url") }
internal suspend inline fun <reified T> HttpResponse.handleResponse(): RequestState<T> = when {
status.isSuccess() -> RequestState.Success(body())
else -> RequestState.Error(status.description)
}
@@ -0,0 +1,10 @@
package ru.tanexc.hemoanalysis.core.network.util
import io.ktor.client.HttpClient
import io.ktor.client.HttpClientConfig
import io.ktor.client.engine.darwin.Darwin
internal actual fun PlatformHttpClient(declaration: HttpClientConfig<*>.() -> Unit): HttpClient = HttpClient(
engineFactory = Darwin,
block = declaration
)