Initial commit
This commit is contained in:
@@ -0,0 +1 @@
|
||||
/build
|
||||
@@ -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>
|
||||
+33
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+15
@@ -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
|
||||
}
|
||||
}
|
||||
+42
@@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+15
@@ -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)
|
||||
}
|
||||
+183
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+7
@@ -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?
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package ru.tanexc.hemoanalysis.usecase
|
||||
|
||||
import ru.tanexc.hemoanalysis.domain.usecase.GetModelPathUseCase
|
||||
|
||||
expect class GetModelPathUseCaseImpl() : GetModelPathUseCase {
|
||||
override fun invoke(): String?
|
||||
}
|
||||
+9
@@ -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
|
||||
}
|
||||
+44
@@ -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")
|
||||
}
|
||||
}
|
||||
+32
@@ -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")
|
||||
}
|
||||
}
|
||||
+89
@@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
/build
|
||||
@@ -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>
|
||||
+12
@@ -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)
|
||||
)
|
||||
}
|
||||
+12
@@ -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)
|
||||
)
|
||||
}
|
||||
+6
@@ -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
|
||||
+10
@@ -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() }
|
||||
}
|
||||
+6
@@ -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
|
||||
+12
@@ -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
|
||||
)
|
||||
}
|
||||
+12
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
/build
|
||||
@@ -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>
|
||||
+16
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
/build
|
||||
@@ -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>
|
||||
+10
@@ -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>
|
||||
}
|
||||
+31
@@ -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)
|
||||
}
|
||||
+8
@@ -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
|
||||
)
|
||||
+12
@@ -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
|
||||
)
|
||||
+7
@@ -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
|
||||
+28
@@ -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)
|
||||
}
|
||||
+10
@@ -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
|
||||
)
|
||||
Reference in New Issue
Block a user