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
@@ -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()
)
}
}