Yandex штуки

This commit is contained in:
2026-05-03 22:03:47 +03:00
parent be1ba29f4d
commit ad985679ee
11 changed files with 113 additions and 38 deletions

View File

@@ -0,0 +1,5 @@
package com.github.nullptroma.wallenc.data.network.yandexdisk
import java.io.IOException
class YandexDiskAuthException(message: String? = null) : IOException(message)

View File

@@ -1,10 +1,13 @@
package com.github.nullptroma.wallenc.data.network.yandexdisk.repository package com.github.nullptroma.wallenc.data.network.yandexdisk.repository
import android.util.Log
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue import com.fasterxml.jackson.module.kotlin.readValue
import com.github.nullptroma.wallenc.data.network.yandexdisk.YandexDiskApi import com.github.nullptroma.wallenc.data.network.yandexdisk.YandexDiskApi
import com.github.nullptroma.wallenc.data.network.yandexdisk.YandexDiskAuthException
import com.github.nullptroma.wallenc.data.network.yandexdisk.dto.CustomPropertiesPatchDto import com.github.nullptroma.wallenc.data.network.yandexdisk.dto.CustomPropertiesPatchDto
import com.github.nullptroma.wallenc.data.network.yandexdisk.dto.DiskInfoDto import com.github.nullptroma.wallenc.data.network.yandexdisk.dto.DiskInfoDto
import com.github.nullptroma.wallenc.data.network.yandexdisk.dto.EmbeddedResourceListDto
import com.github.nullptroma.wallenc.data.network.yandexdisk.dto.LinkDto import com.github.nullptroma.wallenc.data.network.yandexdisk.dto.LinkDto
import com.github.nullptroma.wallenc.data.network.yandexdisk.dto.OperationStatusDto import com.github.nullptroma.wallenc.data.network.yandexdisk.dto.OperationStatusDto
import com.github.nullptroma.wallenc.data.network.yandexdisk.dto.ResourceDto import com.github.nullptroma.wallenc.data.network.yandexdisk.dto.ResourceDto
@@ -22,8 +25,6 @@ import retrofit2.Response
import java.io.IOException import java.io.IOException
import java.io.InputStream import java.io.InputStream
class YandexDiskAuthException(message: String? = null) : IOException(message)
class YandexDiskRepository( class YandexDiskRepository(
private val api: YandexDiskApi, private val api: YandexDiskApi,
private val rawHttp: okhttp3.OkHttpClient, private val rawHttp: okhttp3.OkHttpClient,
@@ -36,7 +37,15 @@ class YandexDiskRepository(
suspend fun list(path: String, limit: Int, offset: Int, sort: String? = null): ResourceDto = suspend fun list(path: String, limit: Int, offset: Int, sort: String? = null): ResourceDto =
withContext(ioDispatcher) { withContext(ioDispatcher) {
try {
wrapAuth { api.listResources(path, limit, offset, sort) } wrapAuth { api.listResources(path, limit, offset, sort) }
} catch (e: HttpException) {
if (e.code() == 404) {
ResourceDto(embedded = EmbeddedResourceListDto(items = emptyList()))
} else {
throw e
}
}
} }
suspend fun get(path: String): ResourceDto = withContext(ioDispatcher) { suspend fun get(path: String): ResourceDto = withContext(ioDispatcher) {
@@ -47,7 +56,7 @@ class YandexDiskRepository(
val resp = wrapAuth { api.createFolder(path) } val resp = wrapAuth { api.createFolder(path) }
when (resp.code()) { when (resp.code()) {
201 -> Unit 201 -> Unit
409 -> Unit // уже существует 409 -> Unit
else -> throw failure("createFolder", resp) else -> throw failure("createFolder", resp)
} }
} }
@@ -170,10 +179,19 @@ class YandexDiskRepository(
try { try {
return block() return block()
} catch (e: HttpException) { } catch (e: HttpException) {
if (e.code() == 401) throw YandexDiskAuthException(e.message()) when (e.code()) {
401 -> {
Log.w(TAG, "Disk API 401: ${e.message()}")
throw YandexDiskAuthException(e.message())
}
404 -> throw e
else -> {
Log.w(TAG, "Disk API HTTP ${e.code()}: ${e.message()}")
throw e throw e
} }
} }
}
}
private fun failure(op: String, resp: Response<ResponseBody>): IOException { private fun failure(op: String, resp: Response<ResponseBody>): IOException {
val msg = resp.errorBody()?.string() ?: resp.message() val msg = resp.errorBody()?.string() ?: resp.message()
@@ -184,6 +202,7 @@ class YandexDiskRepository(
jackson.readValue(body.string()) jackson.readValue(body.string())
companion object { companion object {
private const val TAG = "YandexDiskRepo"
private val jackson = jacksonObjectMapper().apply { findAndRegisterModules() } private val jackson = jacksonObjectMapper().apply { findAndRegisterModules() }
private val OCTET_STREAM = "application/octet-stream".toMediaType() private val OCTET_STREAM = "application/octet-stream".toMediaType()
private const val OPERATION_POLL_DELAY_MS = 300L private const val OPERATION_POLL_DELAY_MS = 300L

View File

@@ -86,10 +86,8 @@ abstract class BaseStorage(
private suspend fun updateMetaInfo(meta: IStorageMetaInfo) = withContext(ioDispatcher) { private suspend fun updateMetaInfo(meta: IStorageMetaInfo) = withContext(ioDispatcher) {
val writer = accessor.openWriteSystemFile(metaInfoFileName) val writer = accessor.openWriteSystemFile(metaInfoFileName)
try { writer.use { writer ->
jackson.writeValue(writer, meta) jackson.writeValue(writer, meta)
} finally {
writer.close()
} }
_metaInfo.value = meta _metaInfo.value = meta
} }

View File

@@ -1,6 +1,6 @@
package com.github.nullptroma.wallenc.data.storages.yandex package com.github.nullptroma.wallenc.data.storages.yandex
import com.github.nullptroma.wallenc.data.network.yandexdisk.repository.YandexDiskAuthException import com.github.nullptroma.wallenc.data.network.yandexdisk.YandexDiskAuthException
import com.github.nullptroma.wallenc.data.network.yandexdisk.dto.ResourceDto import com.github.nullptroma.wallenc.data.network.yandexdisk.dto.ResourceDto
import com.github.nullptroma.wallenc.data.network.yandexdisk.repository.YandexDiskRepository import com.github.nullptroma.wallenc.data.network.yandexdisk.repository.YandexDiskRepository
import com.github.nullptroma.wallenc.data.utils.CloseHandledStreamExtension.Companion.onClosed import com.github.nullptroma.wallenc.data.utils.CloseHandledStreamExtension.Companion.onClosed
@@ -23,6 +23,7 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import android.util.Log
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
@@ -72,13 +73,16 @@ class YandexStorageAccessor(
try { try {
scanSizeAndNumOfFiles() scanSizeAndNumOfFiles()
_storageReady.value = true _storageReady.value = true
Log.d(TAG, "init ok storageUuid=$storageUuid")
} catch (e: YandexDiskAuthException) { } catch (e: YandexDiskAuthException) {
reportAuthFailure() reportAuthFailure()
_storageReady.value = false _storageReady.value = false
Log.w(TAG, "init auth failed storageUuid=$storageUuid", e)
throw e throw e
} catch (_: Exception) { } catch (e: Exception) {
_storageReady.value = false _storageReady.value = false
throw Exception("Yandex storage init failed") Log.w(TAG, "init failed storageUuid=$storageUuid", e)
throw Exception("Yandex storage init failed", e)
} }
} }
@@ -432,6 +436,7 @@ class YandexStorageAccessor(
} }
companion object { companion object {
private const val TAG = "YandexStorageAcc"
private const val SYSTEM_HIDDEN_DIRNAME = "wallenc-yandex-system" private const val SYSTEM_HIDDEN_DIRNAME = "wallenc-yandex-system"
private const val DATA_PAGE_LENGTH = 10 private const val DATA_PAGE_LENGTH = 10
private const val API_LIST_LIMIT = 200 private const val API_LIST_LIMIT = 200

View File

@@ -1,6 +1,6 @@
package com.github.nullptroma.wallenc.data.vaults.yandex package com.github.nullptroma.wallenc.data.vaults.yandex
import com.github.nullptroma.wallenc.data.network.yandexdisk.repository.YandexDiskAuthException import com.github.nullptroma.wallenc.data.network.yandexdisk.YandexDiskAuthException
import com.github.nullptroma.wallenc.data.network.yandexdisk.repository.YandexDiskRepository import com.github.nullptroma.wallenc.data.network.yandexdisk.repository.YandexDiskRepository
import com.github.nullptroma.wallenc.data.storages.yandex.YandexStorage import com.github.nullptroma.wallenc.data.storages.yandex.YandexStorage
import com.github.nullptroma.wallenc.domain.datatypes.StorageEncryptionInfo import com.github.nullptroma.wallenc.domain.datatypes.StorageEncryptionInfo
@@ -12,6 +12,7 @@ import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import android.util.Log
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.util.UUID import java.util.UUID
@@ -64,12 +65,15 @@ class YandexVault(
_availableSpace.value = (total - used).coerceAtLeast(0L) _availableSpace.value = (total - used).coerceAtLeast(0L)
_vaultReachable.value = true _vaultReachable.value = true
_storages.value = loadStoragesList() _storages.value = loadStoragesList()
} catch (_: YandexDiskAuthException) { Log.d(TAG, "refresh ok uuid=$uuid storages=${_storages.value.size}")
} catch (e: YandexDiskAuthException) {
_vaultReachable.value = false _vaultReachable.value = false
_storages.value = emptyList() _storages.value = emptyList()
} catch (_: Exception) { Log.w(TAG, "refresh auth failed uuid=$uuid: ${e.message}")
} catch (e: Exception) {
_vaultReachable.value = false _vaultReachable.value = false
_storages.value = emptyList() _storages.value = emptyList()
Log.w(TAG, "refresh failed uuid=$uuid", e)
} }
} }
@@ -96,8 +100,8 @@ class YandexVault(
try { try {
storage.init() storage.init()
out.add(storage) out.add(storage)
} catch (_: Exception) { } catch (e: Exception) {
// пропускаем битое/частично созданное хранилище Log.w(TAG, "skip broken storage uuid=$storageUuid: ${e.message}")
} }
} }
if (items.size < APP_LIST_LIMIT) break if (items.size < APP_LIST_LIMIT) break
@@ -108,6 +112,7 @@ class YandexVault(
override suspend fun createStorage(): IStorage = withContext(ioDispatcher) { override suspend fun createStorage(): IStorage = withContext(ioDispatcher) {
val id = UUID.randomUUID() val id = UUID.randomUUID()
Log.d(TAG, "createStorage start vault=$uuid storage=$id")
repo.createFolder("app:/$id") repo.createFolder("app:/$id")
val storage = YandexStorage( val storage = YandexStorage(
uuid = id, uuid = id,
@@ -121,6 +126,7 @@ class YandexVault(
) )
storage.init() storage.init()
_storages.value = _storages.value + storage _storages.value = _storages.value + storage
Log.d(TAG, "createStorage done storage=$id")
storage storage
} }
@@ -132,11 +138,13 @@ class YandexVault(
override suspend fun remove(storage: IStorage) = withContext(ioDispatcher) { override suspend fun remove(storage: IStorage) = withContext(ioDispatcher) {
if (storage !is YandexStorage) return@withContext if (storage !is YandexStorage) return@withContext
Log.d(TAG, "remove storage=${storage.uuid}")
repo.delete("app:/${storage.uuid}", permanently = true) repo.delete("app:/${storage.uuid}", permanently = true)
_storages.value = _storages.value.filter { it.uuid != storage.uuid } _storages.value = _storages.value.filter { it.uuid != storage.uuid }
} }
private companion object { private companion object {
private const val TAG = "YandexVault"
private const val APP_LIST_LIMIT = 200 private const val APP_LIST_LIMIT = 200
} }
} }

View File

@@ -23,6 +23,7 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.UUID import java.util.UUID
import kotlin.system.measureTimeMillis import kotlin.system.measureTimeMillis
@@ -32,7 +33,7 @@ import kotlin.system.measureTimeMillis
*/ */
abstract class AbstractVaultBrowserViewModel( abstract class AbstractVaultBrowserViewModel(
storagesFlow: Flow<List<IStorage>>, storagesFlow: Flow<List<IStorage>>,
private val canAddStorage: Boolean, private val vaultAvailabilityFlow: Flow<Boolean>,
private val resolveCreateVaultUuid: () -> UUID?, private val resolveCreateVaultUuid: () -> UUID?,
private val removeStorageUseCase: RemoveStorageUseCase, private val removeStorageUseCase: RemoveStorageUseCase,
private val getOpenedStoragesUseCase: GetOpenedStoragesUseCase, private val getOpenedStoragesUseCase: GetOpenedStoragesUseCase,
@@ -43,7 +44,7 @@ abstract class AbstractVaultBrowserViewModel(
private val taskOrchestrator: ITaskOrchestrator, private val taskOrchestrator: ITaskOrchestrator,
private val logger: ILogger, private val logger: ILogger,
) : ViewModelBase<VaultBrowserScreenState>( ) : ViewModelBase<VaultBrowserScreenState>(
VaultBrowserScreenState(storagesList = emptyList(), isLoading = true, canAddStorage = canAddStorage), VaultBrowserScreenState(storagesList = emptyList(), isLoading = true, addStorageFabEnabled = false),
) { ) {
private val _messages = MutableSharedFlow<String>() private val _messages = MutableSharedFlow<String>()
@@ -63,6 +64,14 @@ abstract class AbstractVaultBrowserViewModel(
init { init {
collectFlows(storagesFlow) collectFlows(storagesFlow)
viewModelScope.launch {
vaultAvailabilityFlow
.distinctUntilChanged()
.collect { available ->
updateState(state.value.copy(addStorageFabEnabled = available))
logger.debug(TAG, "vault availability → add FAB enabled=$available")
}
}
} }
private fun updateStateLoading() { private fun updateStateLoading() {
@@ -125,20 +134,36 @@ abstract class AbstractVaultBrowserViewModel(
} }
fun createStorage() { fun createStorage() {
if (!state.value.canAddStorage) return if (!state.value.addStorageFabEnabled) {
logger.debug(TAG, "createStorage ignored (vault unavailable or FAB disabled)")
return
}
logger.debug(TAG, "createStorage: enqueue task")
taskOrchestrator.enqueue( taskOrchestrator.enqueue(
title = "Create storage", title = "Create storage",
dispatcher = Dispatchers.IO, dispatcher = Dispatchers.IO,
work = { ctx -> work = { ctx ->
try {
ctx.log(TaskLogLevel.Info, "Creating storage…") ctx.log(TaskLogLevel.Info, "Creating storage…")
val uuid = resolveCreateVaultUuid() val uuid = resolveCreateVaultUuid()
?: throw IllegalStateException("Vault is not available") ?: throw IllegalStateException("Vault is not available")
manageVaultUseCase.createStorage(uuid) logger.debug(TAG, "createStorage: vaultUuid=$uuid")
val storage = manageVaultUseCase.createStorage(uuid)
ctx.log(TaskLogLevel.Info, "Storage created") ctx.log(TaskLogLevel.Info, "Storage created")
logger.debug(TAG, "createStorage: done storageUuid=${storage.uuid}")
} catch (e: Exception) {
logger.debug(TAG, "createStorage failed: ${e.stackTraceToString()}")
ctx.log(TaskLogLevel.Error, e.message ?: e.toString())
throw e
}
}, },
) )
} }
private companion object {
private const val TAG = "VaultBrowser"
}
private val storageOpMutex = Any() private val storageOpMutex = Any()
private val runningStorages = mutableSetOf<UUID>() private val runningStorages = mutableSetOf<UUID>()

View File

@@ -34,7 +34,9 @@ class LocalVaultViewModel @Inject constructor(
storagesFlow = vaultsManager.vaults storagesFlow = vaultsManager.vaults
.map { vaults -> vaults.described().locals.firstOrNull() } .map { vaults -> vaults.described().locals.firstOrNull() }
.flatMapLatest { v -> v?.storages ?: flowOf(emptyList()) }, .flatMapLatest { v -> v?.storages ?: flowOf(emptyList()) },
canAddStorage = true, vaultAvailabilityFlow = vaultsManager.vaults
.map { vaults -> vaults.described().locals.firstOrNull() }
.flatMapLatest { v -> v?.isAvailable ?: flowOf(false) },
resolveCreateVaultUuid = { vaultsManager.vaults.value.described().locals.firstOrNull()?.uuid }, resolveCreateVaultUuid = { vaultsManager.vaults.value.described().locals.firstOrNull()?.uuid },
removeStorageUseCase = removeStorageUseCase, removeStorageUseCase = removeStorageUseCase,
getOpenedStoragesUseCase = getOpenedStoragesUseCase, getOpenedStoragesUseCase = getOpenedStoragesUseCase,

View File

@@ -11,6 +11,8 @@ import com.github.nullptroma.wallenc.domain.usecases.RenameStorageUseCase
import com.github.nullptroma.wallenc.domain.usecases.StorageFileManagementUseCase import com.github.nullptroma.wallenc.domain.usecases.StorageFileManagementUseCase
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import java.util.UUID import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
@@ -28,8 +30,9 @@ class RemoteVaultViewModel @Inject constructor(
logger: ILogger, logger: ILogger,
) : AbstractVaultBrowserViewModel( ) : AbstractVaultBrowserViewModel(
storagesFlow = manageVaultUseCase.storagesOf(savedStateHandle.requireVaultUuid()), storagesFlow = manageVaultUseCase.storagesOf(savedStateHandle.requireVaultUuid()),
canAddStorage = false, vaultAvailabilityFlow = manageVaultUseCase.observe(savedStateHandle.requireVaultUuid())
resolveCreateVaultUuid = { null }, .flatMapLatest { v -> v?.isAvailable ?: flowOf(false) },
resolveCreateVaultUuid = { savedStateHandle.requireVaultUuid() },
removeStorageUseCase = removeStorageUseCase, removeStorageUseCase = removeStorageUseCase,
getOpenedStoragesUseCase = getOpenedStoragesUseCase, getOpenedStoragesUseCase = getOpenedStoragesUseCase,
storageFileManagementUseCase = storageFileManagementUseCase, storageFileManagementUseCase = storageFileManagementUseCase,

View File

@@ -50,13 +50,22 @@ fun VaultBrowserScreen(
modifier = modifier, modifier = modifier,
contentWindowInsets = WindowInsets(0.dp), contentWindowInsets = WindowInsets(0.dp),
floatingActionButton = { floatingActionButton = {
if (uiState.canAddStorage) { val fabEnabled = uiState.addStorageFabEnabled
FloatingActionButton( FloatingActionButton(
onClick = { viewModel.createStorage() }, onClick = { if (fabEnabled) viewModel.createStorage() },
containerColor = if (fabEnabled) {
MaterialTheme.colorScheme.primaryContainer
} else {
MaterialTheme.colorScheme.surfaceVariant
},
contentColor = if (fabEnabled) {
MaterialTheme.colorScheme.onPrimaryContainer
} else {
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.55f)
},
) { ) {
Icon(Icons.Filled.Add, contentDescription = null) Icon(Icons.Filled.Add, contentDescription = null)
} }
}
}, },
) { innerPadding -> ) { innerPadding ->
LazyColumn( LazyColumn(

View File

@@ -6,5 +6,6 @@ import com.github.nullptroma.wallenc.domain.interfaces.IStorageInfo
data class VaultBrowserScreenState( data class VaultBrowserScreenState(
val storagesList: List<Tree<IStorageInfo>>, val storagesList: List<Tree<IStorageInfo>>,
val isLoading: Boolean, val isLoading: Boolean,
val canAddStorage: Boolean = false, /** FAB «добавить storage»: активна только когда vault доступен (сеть/API/путь). */
val addStorageFabEnabled: Boolean = false,
) )