diff --git a/app/src/main/java/com/github/nullptroma/wallenc/app/tasks/TaskPipelineForegroundService.kt b/app/src/main/java/com/github/nullptroma/wallenc/app/tasks/TaskPipelineForegroundService.kt index ac3af91..912c0ec 100644 --- a/app/src/main/java/com/github/nullptroma/wallenc/app/tasks/TaskPipelineForegroundService.kt +++ b/app/src/main/java/com/github/nullptroma/wallenc/app/tasks/TaskPipelineForegroundService.kt @@ -3,7 +3,9 @@ package com.github.nullptroma.wallenc.app.tasks import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager +import android.app.PendingIntent import android.app.Service +import android.content.Intent import android.graphics.Color import android.os.IBinder import android.view.View @@ -52,7 +54,14 @@ class TaskPipelineForegroundService : Service() { private val serviceJob = SupervisorJob() private val serviceScope = CoroutineScope(serviceJob + Dispatchers.Main.immediate) - override fun onBind(intent: android.content.Intent?): IBinder? = null + override fun onBind(intent: Intent?): IBinder? = null + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + if (intent?.action == ACTION_CANCEL_ALL_TASKS && ::orchestrator.isInitialized) { + orchestrator.cancelAll() + } + return START_STICKY + } @OptIn(ExperimentalCoroutinesApi::class) override fun onCreate() { @@ -133,6 +142,11 @@ class TaskPipelineForegroundService : Service() { .setSmallIcon(android.R.drawable.stat_sys_download) .setOngoing(true) .setOnlyAlertOnce(true) + .addAction( + 0, + getString(R.string.task_notification_cancel), + cancelAllTasksPendingIntent(), + ) .build() private fun buildAccumulatedNotification(tasks: List): Notification { @@ -141,19 +155,44 @@ class TaskPipelineForegroundService : Service() { applyNotificationTemplateTextColor(big) bindTaskRows(big, sorted) + val collapsedSubtext = when { + sorted.isEmpty() -> + getString(R.string.task_notification_preparing) + + sorted.all { it.progress?.fraction == null } -> + getString(R.string.task_notification_indeterminate) + + else -> resources.getQuantityString( + R.plurals.task_notification_group_subtext, + sorted.size, + sorted.size, + ) + } + return NotificationCompat.Builder(this, CHANNEL_ID) .setContentTitle(getString(R.string.task_notification_title)) - .setContentText( - getString(R.string.task_notification_group_subtext, sorted.size), - ) + .setContentText(collapsedSubtext) .setSmallIcon(android.R.drawable.stat_sys_download) .setOngoing(true) .setOnlyAlertOnce(true) .setStyle(NotificationCompat.DecoratedCustomViewStyle()) .setCustomBigContentView(big) + .addAction( + 0, + getString(R.string.task_notification_cancel), + cancelAllTasksPendingIntent(), + ) .build() } + private fun cancelAllTasksPendingIntent(): PendingIntent = + PendingIntent.getService( + this, + 0, + Intent(this, TaskPipelineForegroundService::class.java).setAction(ACTION_CANCEL_ALL_TASKS), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + /** * Uses only [android.R.attr.textColor] from [android.R.style.TextAppearance_Material_Notification_Title] * so line size stays from layout (13sp) while color matches system notification title text. @@ -199,7 +238,7 @@ class TaskPipelineForegroundService : Service() { showTaskRow(remoteViews, i, sorted[i]) } val remaining = n - (MAX_TASK_ROWS - 1) - showOverflowRow(remoteViews, MAX_TASK_ROWS - 1, remaining) + showOverflowRow(remoteViews, remaining) } advanceIndeterminateDotPhasesAfterBind(sorted, n) } @@ -254,14 +293,19 @@ class TaskPipelineForegroundService : Service() { } } - private fun showOverflowRow(remoteViews: RemoteViews, index: Int, remainingCount: Int) { - remoteViews.setViewVisibility(TASK_ROW_IDS[index], View.VISIBLE) + private fun showOverflowRow(remoteViews: RemoteViews, remainingCount: Int) { + val overflowIndex = MAX_TASK_ROWS - 1 + remoteViews.setViewVisibility(TASK_ROW_IDS[overflowIndex], View.VISIBLE) remoteViews.setTextViewText( - TASK_TITLE_IDS[index], - getString(R.string.task_notification_more_tasks, remainingCount), + TASK_TITLE_IDS[overflowIndex], + resources.getQuantityString( + R.plurals.task_notification_more_tasks, + remainingCount, + remainingCount, + ), ) - remoteViews.setViewVisibility(TASK_LABEL_BAR_ROW_IDS[index], View.GONE) - remoteViews.setTextViewText(TASK_SUBTITLE_IDS[index], "") + remoteViews.setViewVisibility(TASK_LABEL_BAR_ROW_IDS[overflowIndex], View.GONE) + remoteViews.setTextViewText(TASK_SUBTITLE_IDS[overflowIndex], "") } private fun indeterminateSubtitleWithDots(taskId: TaskId, baseLabel: String): String { @@ -276,6 +320,9 @@ class TaskPipelineForegroundService : Service() { } companion object { + private const val ACTION_CANCEL_ALL_TASKS = + "com.github.nullptroma.wallenc.action.CANCEL_ALL_TASKS" + private const val CHANNEL_ID = "wallenc_task_pipeline" private const val FOREGROUND_NOTIFICATION_ID = 1001 diff --git a/app/src/main/res/values/plurals.xml b/app/src/main/res/values/plurals.xml new file mode 100644 index 0000000..94dd4bd --- /dev/null +++ b/app/src/main/res/values/plurals.xml @@ -0,0 +1,11 @@ + + + + %d task running + %d tasks running + + + +%d more + +%d more + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b774352..c0d265b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -5,6 +5,4 @@ Preparing… Working… Cancel - %d tasks running - +%d more \ No newline at end of file diff --git a/domain-vault/build.gradle.kts b/domain-vault/build.gradle.kts index 41f55f6..d948e3e 100644 --- a/domain-vault/build.gradle.kts +++ b/domain-vault/build.gradle.kts @@ -17,7 +17,7 @@ dependencies { implementation(libs.retrofit.converter.scalars) implementation(libs.retrofit.converter.jackson) - implementation("com.squareup.okhttp3:okhttp:4.12.0") + implementation(libs.okhttp3) implementation(libs.kotlinx.coroutines.core) testImplementation(libs.junit) diff --git a/domain-vault/src/main/java/com/github/nullptroma/wallenc/infrastructure/network/yandexdisk/dto/YandexDiskDto.kt b/domain-vault/src/main/java/com/github/nullptroma/wallenc/infrastructure/network/yandexdisk/dto/YandexDiskDto.kt index 4d02c3e..5a7d64b 100644 --- a/domain-vault/src/main/java/com/github/nullptroma/wallenc/infrastructure/network/yandexdisk/dto/YandexDiskDto.kt +++ b/domain-vault/src/main/java/com/github/nullptroma/wallenc/infrastructure/network/yandexdisk/dto/YandexDiskDto.kt @@ -6,9 +6,9 @@ import java.time.Instant @JsonIgnoreProperties(ignoreUnknown = true) data class DiskInfoDto( - @JsonProperty("trash_size") val trashSize: Long? = null, - @JsonProperty("total_space") val totalSpace: Long? = null, - @JsonProperty("used_space") val usedSpace: Long? = null, + @param:JsonProperty("trash_size") val trashSize: Long? = null, + @param:JsonProperty("total_space") val totalSpace: Long? = null, + @param:JsonProperty("used_space") val usedSpace: Long? = null, ) @JsonIgnoreProperties(ignoreUnknown = true) @@ -36,10 +36,10 @@ data class ResourceDto( val size: Long? = null, val modified: Instant? = null, val created: Instant? = null, - @JsonProperty("mime_type") val mimeType: String? = null, + @param:JsonProperty("mime_type") val mimeType: String? = null, val md5: String? = null, - @JsonProperty("custom_properties") val customProperties: Map? = null, - @JsonProperty("_embedded") val embedded: EmbeddedResourceListDto? = null, + @param:JsonProperty("custom_properties") val customProperties: Map? = null, + @param:JsonProperty("_embedded") val embedded: EmbeddedResourceListDto? = null, ) @JsonIgnoreProperties(ignoreUnknown = true) @@ -56,5 +56,5 @@ data class ApiErrorDto( @JsonIgnoreProperties(ignoreUnknown = true) data class CustomPropertiesPatchDto( - @JsonProperty("custom_properties") val customProperties: Map, + @param:JsonProperty("custom_properties") val customProperties: Map, ) diff --git a/domain-vault/src/main/java/com/github/nullptroma/wallenc/infrastructure/network/yandexdisk/repository/YandexDiskRepository.kt b/domain-vault/src/main/java/com/github/nullptroma/wallenc/infrastructure/network/yandexdisk/repository/YandexDiskRepository.kt index 3fe22a7..aabbf9f 100644 --- a/domain-vault/src/main/java/com/github/nullptroma/wallenc/infrastructure/network/yandexdisk/repository/YandexDiskRepository.kt +++ b/domain-vault/src/main/java/com/github/nullptroma/wallenc/infrastructure/network/yandexdisk/repository/YandexDiskRepository.kt @@ -13,7 +13,6 @@ import com.github.nullptroma.wallenc.infrastructure.network.yandexdisk.dto.Resou import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.delay import kotlinx.coroutines.withContext -import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.MediaType.Companion.toMediaType import okhttp3.Request import okhttp3.RequestBody.Companion.asRequestBody @@ -21,6 +20,7 @@ import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.ResponseBody import retrofit2.HttpException import retrofit2.Response +import java.io.FilterInputStream import java.io.IOException import java.io.InputStream @@ -74,20 +74,6 @@ class YandexDiskRepository( } } - suspend fun move(from: String, toPath: String, overwrite: Boolean = false): Unit = - withContext(ioDispatcher) { - val resp = wrapAuth { api.moveResource(from, toPath, overwrite) } - when (resp.code()) { - 201 -> Unit - 202 -> { - val link = resp.body()?.use { body -> parseLink(body) } - ?: throw IOException("MOVE 202 without body") - awaitOperation(link.href) - } - else -> throw failure("move", resp) - } - } - suspend fun setCustomProperties(path: String, props: Map): Unit = withContext(ioDispatcher) { val resp = wrapAuth { @@ -141,21 +127,17 @@ class YandexDiskRepository( resp.close() throw IOException("Download failed: HTTP ${resp.code}") } - val body = resp.body ?: run { + val body = resp.body + val stream = body?.byteStream() ?: run { resp.close() - throw IOException("Download: empty body") + throw IOException("Download failed: missing body") } - body.byteStream().let { stream -> - object : InputStream() { - override fun read(): Int = stream.read() - override fun read(b: ByteArray): Int = stream.read(b) - override fun read(b: ByteArray, off: Int, len: Int): Int = stream.read(b, off, len) - override fun close() { - try { - stream.close() - } finally { - resp.close() - } + object : FilterInputStream(stream) { + override fun close() { + try { + `in`.close() + } finally { + resp.close() } } } @@ -201,16 +183,5 @@ class YandexDiskRepository( private val OCTET_STREAM = "application/octet-stream".toMediaType() private const val OPERATION_POLL_DELAY_MS = 300L private const val OPERATION_POLL_MAX = 200 - - fun parseOperationId(href: String): String? { - val url = href.toHttpUrlOrNull() ?: return null - url.queryParameter("id")?.let { return it } - val segments = url.pathSegments - val idx = segments.indexOf("operations") - if (idx >= 0 && idx + 1 < segments.size) { - return segments[idx + 1] - } - return null - } } } diff --git a/domain-vault/src/main/java/com/github/nullptroma/wallenc/infrastructure/storages/common/BaseStorage.kt b/domain-vault/src/main/java/com/github/nullptroma/wallenc/infrastructure/storages/common/BaseStorage.kt index 361023a..3fb6e6d 100644 --- a/domain-vault/src/main/java/com/github/nullptroma/wallenc/infrastructure/storages/common/BaseStorage.kt +++ b/domain-vault/src/main/java/com/github/nullptroma/wallenc/infrastructure/storages/common/BaseStorage.kt @@ -18,10 +18,10 @@ import java.io.InputStream import java.util.UUID /** - * Общий «скелет» storage'а: единая логика meta-info, rename, setEncInfo, - * clearAllContent и делегирования размера/доступности к [accessor]. + * Общий «скелет» для [IStorage]: единая логика meta-info, rename, setEncInfo, + * clearAllContent и делегирование размеров и доступности в [accessor]. * - * Подклассы определяют только как создаётся [accessor], значение + * Подклассы определяют только способ создания [accessor], значение * [isVirtualStorage] и (при необходимости) расширяют [init] своими шагами * (например, проверкой ключа или инициализацией внешней связи). */ @@ -60,8 +60,8 @@ abstract class BaseStorage( protected open fun metaInfoUuidPart(): String = uuid.toString() /** - * Запускается единожды при старте storage'а. Подклассы могут переопределить, - * добавив свои шаги (init accessor'а, проверка ключа и т.п.). Обязательно + * Запускается единожды при первом использовании хранилища. Подклассы могут переопределить, + * добавив свои шаги (инициализацию [accessor], проверку ключа и т.п.). Обязательно * должен в какой-то момент вызвать [readMetaInfo]. */ open suspend fun init() { diff --git a/domain-vault/src/main/java/com/github/nullptroma/wallenc/infrastructure/storages/yandex/YandexStorageAccessor.kt b/domain-vault/src/main/java/com/github/nullptroma/wallenc/infrastructure/storages/yandex/YandexStorageAccessor.kt index 83d45a8..2092f8e 100644 --- a/domain-vault/src/main/java/com/github/nullptroma/wallenc/infrastructure/storages/yandex/YandexStorageAccessor.kt +++ b/domain-vault/src/main/java/com/github/nullptroma/wallenc/infrastructure/storages/yandex/YandexStorageAccessor.kt @@ -34,10 +34,10 @@ import java.time.Instant import java.util.UUID /** - * [IStorageAccessor] поверх папки приложения `app://…` на Яндекс.Диске. + * Реализация [IStorageAccessor] для дерева файлов `app://…` на Яндекс.Диске. * - * [isAvailable] = доступность vault'а ([vaultAvailability]) **и** успешная локальная - * инициализация этого storage ([storageReady]). + * [isAvailable] объединяет доступность удалённого хранилища ([vaultAvailability]) + * и успешную локальную инициализацию ([storageReady]) этого аксессора. */ class YandexStorageAccessor( private val storageUuid: UUID, @@ -265,7 +265,7 @@ class YandexStorageAccessor( while (queue.isNotEmpty()) { val rel = queue.removeFirst() if (isSystemRel(rel)) continue - val (files, dirs) = listImmediateChildren(rel) + val (_, dirs) = listImmediateChildren(rel) for (d in dirs) { if (!isSystemRel(d.metaInfo.path)) { out.add(d) @@ -402,10 +402,12 @@ class YandexStorageAccessor( override suspend fun openWriteSystemFile(name: String): OutputStream = withContext(ioDispatcher) { ensureSystemDirExists() val rel = "/$SYSTEM_HIDDEN_DIRNAME/$name" - val baos = ByteArrayOutputStream() - baos.onClosed { + val uploadBuffer = ByteArrayOutputStream() + uploadBuffer.onClosed { runBlocking(ioDispatcher) { - guard { repo.uploadBytes(toDiskPath(rel), baos.toByteArray(), overwrite = true) } + guard { + repo.uploadBytes(toDiskPath(rel), uploadBuffer.toByteArray(), overwrite = true) + } } } } diff --git a/domain-vault/src/main/java/com/github/nullptroma/wallenc/infrastructure/vaults/VaultsManager.kt b/domain-vault/src/main/java/com/github/nullptroma/wallenc/infrastructure/vaults/VaultsManager.kt index d28647f..739f767 100644 --- a/domain-vault/src/main/java/com/github/nullptroma/wallenc/infrastructure/vaults/VaultsManager.kt +++ b/domain-vault/src/main/java/com/github/nullptroma/wallenc/infrastructure/vaults/VaultsManager.kt @@ -31,7 +31,7 @@ import java.util.UUID @OptIn(ExperimentalCoroutinesApi::class) class VaultsManager( private val ioDispatcher: CoroutineDispatcher, - localVault: IVault, + private val localVault: IVault, keyRepo: StorageKeyMapStore, private val yandexAccountStore: YandexAccountStore, private val yandexUserInfoRepository: YandexUserInfoRepository, @@ -40,8 +40,6 @@ class VaultsManager( private val scope = CoroutineScope(SupervisorJob() + ioDispatcher) - private val localVault: IVault = localVault - private val yandexVaults: StateFlow> = yandexAccountStore.observeAll() .map { rows -> rows.map { row -> diff --git a/domain-vault/src/main/java/com/github/nullptroma/wallenc/infrastructure/vaults/local/LocalVault.kt b/domain-vault/src/main/java/com/github/nullptroma/wallenc/infrastructure/vaults/local/LocalVault.kt index 508c3aa..016734b 100644 --- a/domain-vault/src/main/java/com/github/nullptroma/wallenc/infrastructure/vaults/local/LocalVault.kt +++ b/domain-vault/src/main/java/com/github/nullptroma/wallenc/infrastructure/vaults/local/LocalVault.kt @@ -20,7 +20,7 @@ import kotlin.io.path.pathString class LocalVault( private val ioDispatcher: CoroutineDispatcher, - private val vaultRoot: File?, + vaultRoot: File?, ) : DescribedVault { override val uuid: UUID = vaultRoot?.let { root -> @@ -42,7 +42,7 @@ class LocalVault( private val _availableSpace = MutableStateFlow(null) override val availableSpace: StateFlow = _availableSpace - private val path = MutableStateFlow(vaultRoot) + private val path = MutableStateFlow(vaultRoot) init { CoroutineScope(ioDispatcher).launch { diff --git a/domain-vault/src/main/java/com/github/nullptroma/wallenc/infrastructure/vaults/yandex/YandexRegistration.kt b/domain-vault/src/main/java/com/github/nullptroma/wallenc/infrastructure/vaults/yandex/YandexRegistration.kt index bd62aa2..2bdf01a 100644 --- a/domain-vault/src/main/java/com/github/nullptroma/wallenc/infrastructure/vaults/yandex/YandexRegistration.kt +++ b/domain-vault/src/main/java/com/github/nullptroma/wallenc/infrastructure/vaults/yandex/YandexRegistration.kt @@ -1,15 +1,13 @@ package com.github.nullptroma.wallenc.infrastructure.vaults.yandex -import com.github.nullptroma.wallenc.vault.contract.CloudBrand import com.github.nullptroma.wallenc.vault.contract.VaultRegistration /** - * Регистрация удалённого vault'а Яндекс.Диска по результату OAuth. + * Регистрация удалённого хранилища Яндекс.Диска по результату OAuth. * * Живёт в `:data` (а не в `:vault-api`), потому что [VaultRegistration] * намеренно не sealed — конкретные реализации лежат рядом со своим поставщиком. - * presentation никогда не открывает этот тип, только перепасовывает обратно - * в `VaultRegistrar.register(...)`. + * Слой presentation не раскрывает этот тип, только передаёт его в `VaultRegistrar.register(...)`. */ data class YandexRegistration( val oauthToken: String, @@ -17,6 +15,4 @@ data class YandexRegistration( init { require(oauthToken.isNotBlank()) { "oauthToken must not be blank" } } - - val brand: CloudBrand get() = CloudBrand.YANDEX } diff --git a/domain-vault/src/main/java/com/github/nullptroma/wallenc/infrastructure/vaults/yandex/YandexVault.kt b/domain-vault/src/main/java/com/github/nullptroma/wallenc/infrastructure/vaults/yandex/YandexVault.kt index 94c738d..1f07fde 100644 --- a/domain-vault/src/main/java/com/github/nullptroma/wallenc/infrastructure/vaults/yandex/YandexVault.kt +++ b/domain-vault/src/main/java/com/github/nullptroma/wallenc/infrastructure/vaults/yandex/YandexVault.kt @@ -64,10 +64,10 @@ class YandexVault( _availableSpace.value = (total - used).coerceAtLeast(0L) _vaultReachable.value = true _storages.value = loadStoragesList() - } catch (e: YandexDiskAuthException) { + } catch (_: YandexDiskAuthException) { _vaultReachable.value = false _storages.value = emptyList() - } catch (e: Exception) { + } catch (_: Exception) { _vaultReachable.value = false _storages.value = emptyList() } @@ -118,7 +118,7 @@ class YandexVault( }, ) storage.init() - _storages.value = _storages.value + storage + _storages.value += storage storage } diff --git a/domain/src/main/java/com/github/nullptroma/wallenc/domain/interfaces/IVault.kt b/domain/src/main/java/com/github/nullptroma/wallenc/domain/interfaces/IVault.kt index b97a59e..e2eace9 100644 --- a/domain/src/main/java/com/github/nullptroma/wallenc/domain/interfaces/IVault.kt +++ b/domain/src/main/java/com/github/nullptroma/wallenc/domain/interfaces/IVault.kt @@ -4,9 +4,9 @@ import com.github.nullptroma.wallenc.domain.datatypes.StorageEncryptionInfo import kotlinx.coroutines.flow.StateFlow /** - * Контракт vault'а: коллекция [IStorage] с реактивным состоянием. + * Контракт хранилища ([IVault]): коллекция [IStorage] с реактивным состоянием. * - * domain не различает локальные/удалённые/Yandex/etc. — это общий порт. + * Слой domain не различает локальные/удалённые/Yandex и т.д. — это общий порт. */ interface IVault : IVaultInfo { val storages: StateFlow> diff --git a/domain/src/main/java/com/github/nullptroma/wallenc/domain/interfaces/IVaultInfo.kt b/domain/src/main/java/com/github/nullptroma/wallenc/domain/interfaces/IVaultInfo.kt index 8674868..2fae708 100644 --- a/domain/src/main/java/com/github/nullptroma/wallenc/domain/interfaces/IVaultInfo.kt +++ b/domain/src/main/java/com/github/nullptroma/wallenc/domain/interfaces/IVaultInfo.kt @@ -3,9 +3,9 @@ package com.github.nullptroma.wallenc.domain.interfaces import java.util.UUID /** - * Минимальная идентификация vault'а. + * Минимальная идентификация хранилища ([IVaultInfo]). * - * Намеренно «голая»: domain ничего не знает о брендах, локальности или статусе — + * Намеренно «голая»: доменный слой не знает о брендах, локальности или статусе — * вся категоризация лежит во внешнем кольце (`:vault-api: VaultDescriptor`). */ interface IVaultInfo { diff --git a/domain/src/main/java/com/github/nullptroma/wallenc/domain/interfaces/IVaultsManager.kt b/domain/src/main/java/com/github/nullptroma/wallenc/domain/interfaces/IVaultsManager.kt index 6268bec..fc0b432 100644 --- a/domain/src/main/java/com/github/nullptroma/wallenc/domain/interfaces/IVaultsManager.kt +++ b/domain/src/main/java/com/github/nullptroma/wallenc/domain/interfaces/IVaultsManager.kt @@ -3,10 +3,10 @@ package com.github.nullptroma.wallenc.domain.interfaces import kotlinx.coroutines.flow.StateFlow /** - * Единая точка доступа ко всем vault'ам приложения. + * Единая точка доступа ко всем подключённым хранилищам приложения. * - * domain не различает категории vault'ов — потребители (presentation) фильтруют - * [vaults] через `:vault-api` (`VaultDescriptor`/`DescribedVault`). + * Доменный слой не различает категории vault — потребители в UI (presentation) + * фильтруют [vaults] через `:vault-api` (`VaultDescriptor` / `DescribedVault`). */ interface IVaultsManager { val vaults: StateFlow> diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 60426c6..9ea4ee4 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,25 +1,26 @@ [versions] agp = "9.1.1" -jacksonModuleKotlin = "2.21.2" -kotlin = "2.3.20" +jacksonModuleKotlin = "2.21.3" +kotlin = "2.3.21" coreKtx = "1.18.0" junit = "4.13.2" junitVersion = "1.3.0" espressoCore = "3.7.0" -kotlinReflect = "2.3.0" -kotlinxCoroutinesCore = "1.10.2" +kotlinReflect = "2.3.21" +kotlinxCoroutinesCore = "1.11.0" kotlinxSerializationJson = "1.11.0" lifecycleRuntimeKtx = "2.10.0" activityCompose = "1.13.0" -composeBom = "2026.03.01" -navigation = "2.9.7" +composeBom = "2026.05.00" +navigation = "2.9.8" hiltNavigation = "1.3.0" timber = "5.0.1" yandexAuthSdk = "3.2.0" daggerHilt = "2.59.2" -ksp = "2.3.6" +ksp = "2.3.7" room = "2.8.4" retrofit = "3.0.0" +okhttp = "5.3.2" [libraries] jackson-datatype-jsr310 = { module = "com.fasterxml.jackson.datatype:jackson-datatype-jsr310" } @@ -29,6 +30,7 @@ kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-c kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigation" } androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigation" } +androidx-hilt-lifecycle-viewmodel-compose = { group = "androidx.hilt", name = "hilt-lifecycle-viewmodel-compose", version.ref = "hiltNavigation" } timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" } # Yandex @@ -47,6 +49,7 @@ room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" } retrofit-converter-scalars = { group = "com.squareup.retrofit2", name = "converter-scalars", version.ref = "retrofit" } retrofit-converter-jackson = { group = "com.squareup.retrofit2", name = "converter-jackson", version.ref = "retrofit" } +okhttp3 = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } junit = { group = "junit", name = "junit", version.ref = "junit" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index ed4de6a..1703649 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ #Sat Sep 07 01:04:14 MSK 2024 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.0-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/db/RoomFactory.kt b/infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/db/RoomFactory.kt index ac53ed7..c5d4c9d 100644 --- a/infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/db/RoomFactory.kt +++ b/infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/db/RoomFactory.kt @@ -8,7 +8,7 @@ class RoomFactory(private val context: Context) { fun buildAppDb(): AppDb { val room = Room.databaseBuilder( context, AppDb::class.java, "app-db" - ).fallbackToDestructiveMigration().build() + ).fallbackToDestructiveMigration(dropAllTables = true).build() return room } } diff --git a/infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/db/app/repository/StorageKeyMapRepository.kt b/infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/db/app/repository/StorageKeyMapRepository.kt index fc9a56b..f87b49e 100644 --- a/infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/db/app/repository/StorageKeyMapRepository.kt +++ b/infrastructure-android/src/main/java/com/github/nullptroma/wallenc/infrastructure/db/app/repository/StorageKeyMapRepository.kt @@ -22,8 +22,8 @@ class StorageKeyMapRepository( dao.add(*dbModels.toTypedArray()) } - override suspend fun delete(vararg keymaps: StorageKeyMap) = withContext(ioDispatcher) { - val dbModels = keymaps.map { DbStorageKeyMap.fromModel(it) } + override suspend fun delete(vararg values: StorageKeyMap) = withContext(ioDispatcher) { + val dbModels = values.map { DbStorageKeyMap.fromModel(it) } dao.delete(*dbModels.toTypedArray()) } } diff --git a/ui/build.gradle.kts b/ui/build.gradle.kts index a93605c..530cb69 100644 --- a/ui/build.gradle.kts +++ b/ui/build.gradle.kts @@ -51,6 +51,7 @@ kotlin { dependencies { implementation(libs.androidx.navigation.compose) implementation(libs.androidx.hilt.navigation.compose) + implementation(libs.androidx.hilt.lifecycle.viewmodel.compose) // Hilt implementation(libs.dagger.hilt) diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/WallencUi.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/WallencUi.kt index 6887025..1612efd 100644 --- a/ui/src/main/java/com/github/nullptroma/wallenc/ui/WallencUi.kt +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/WallencUi.kt @@ -21,7 +21,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import androidx.hilt.navigation.compose.hiltViewModel +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.currentBackStackEntryAsState @@ -66,7 +66,8 @@ fun WallencNavRoot(viewModel: WallencViewModel = hiltViewModel()) { TaskPipelineRoute::class.qualifiedName!! to NavBarItemData( R.string.task_pipeline_title, TaskPipelineRoute::class.qualifiedName!!, - Icons.AutoMirrored.Rounded.List + Icons.AutoMirrored.Rounded.List, + R.string.task_pipeline_open, ), SettingsRoute::class.qualifiedName!! to NavBarItemData( R.string.nav_label_settings, @@ -89,7 +90,7 @@ fun WallencNavRoot(viewModel: WallencViewModel = hiltViewModel()) { icon = { if (navBarItemData.icon != null) Icon( navBarItemData.icon, - contentDescription = stringResource(navBarItemData.nameStringResourceId) + contentDescription = stringResource(navBarItemData.iconContentDescriptionResourceId), ) }, label = { Text(stringResource(navBarItemData.nameStringResourceId)) }, diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/navigation/NavBarItemData.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/navigation/NavBarItemData.kt index 6a76a63..e5fd416 100644 --- a/ui/src/main/java/com/github/nullptroma/wallenc/ui/navigation/NavBarItemData.kt +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/navigation/NavBarItemData.kt @@ -2,4 +2,9 @@ package com.github.nullptroma.wallenc.ui.navigation import androidx.compose.ui.graphics.vector.ImageVector -data class NavBarItemData(val nameStringResourceId: Int, val screenRouteClass: String, val icon: ImageVector?) +data class NavBarItemData( + val nameStringResourceId: Int, + val screenRouteClass: String, + val icon: ImageVector?, + val iconContentDescriptionResourceId: Int = nameStringResourceId, +) diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/MainScreen.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/MainScreen.kt index 55fd18c..3a5fd97 100644 --- a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/MainScreen.kt +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/MainScreen.kt @@ -18,7 +18,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import androidx.hilt.navigation.compose.hiltViewModel +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.currentBackStackEntryAsState diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/remotes/RemoteVaultsScreen.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/remotes/RemoteVaultsScreen.kt index a679717..d39b266 100644 --- a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/remotes/RemoteVaultsScreen.kt +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/remotes/RemoteVaultsScreen.kt @@ -42,7 +42,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog -import androidx.hilt.navigation.compose.hiltViewModel +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.github.nullptroma.wallenc.ui.R import com.github.nullptroma.wallenc.vault.contract.CloudBrand @@ -174,7 +174,13 @@ fun RemoteVaultsScreen( style = MaterialTheme.typography.headlineSmall, color = MaterialTheme.colorScheme.onSurface, ) - Spacer(modifier = Modifier.height(20.dp)) + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = stringResource(R.string.remote_vaults_add_pick_provider), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(16.dp)) FilledTonalButton( onClick = { viewModel.setAddChoiceVisible(false) diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/tasks/TaskPipelineScreen.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/tasks/TaskPipelineScreen.kt index 265a009..8bcf9a0 100644 --- a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/tasks/TaskPipelineScreen.kt +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/tasks/TaskPipelineScreen.kt @@ -30,7 +30,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import androidx.hilt.navigation.compose.hiltViewModel +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.github.nullptroma.wallenc.domain.tasks.PipelineTask import com.github.nullptroma.wallenc.domain.tasks.TaskLogLevel diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/vault/LocalVaultScreen.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/vault/LocalVaultScreen.kt index dc4c07f..8220e14 100644 --- a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/vault/LocalVaultScreen.kt +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/vault/LocalVaultScreen.kt @@ -2,7 +2,7 @@ package com.github.nullptroma.wallenc.ui.screens.main.screens.vault import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.hilt.navigation.compose.hiltViewModel +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel @Composable fun LocalVaultScreen( diff --git a/vault-contracts/src/main/java/com/github/nullptroma/wallenc/vault/contract/RemoteVaultAuthenticator.kt b/vault-contracts/src/main/java/com/github/nullptroma/wallenc/vault/contract/RemoteVaultAuthenticator.kt index 3c0548b..15b939b 100644 --- a/vault-contracts/src/main/java/com/github/nullptroma/wallenc/vault/contract/RemoteVaultAuthenticator.kt +++ b/vault-contracts/src/main/java/com/github/nullptroma/wallenc/vault/contract/RemoteVaultAuthenticator.kt @@ -1,9 +1,9 @@ package com.github.nullptroma.wallenc.vault.contract /** - * Запуск OAuth-сценария привязки удалённого vault'а для конкретного [CloudBrand]. + * Запуск OAuth-сценария привязки удалённого хранилища для конкретного [CloudBrand]. * - * Реализация в `:app` (привязана к Activity). presentation вызывает [beginLink] + * Реализация в `:app` (привязана к Activity). Слой presentation вызывает [beginLink] * из контекста Compose-экрана и получает [VaultLinkOutcome] асинхронно. * * Намеренно императивный API через колбэк, чтобы корректно встроиться diff --git a/vault-contracts/src/main/java/com/github/nullptroma/wallenc/vault/contract/VaultLinkOutcome.kt b/vault-contracts/src/main/java/com/github/nullptroma/wallenc/vault/contract/VaultLinkOutcome.kt index 14e49e8..1af8505 100644 --- a/vault-contracts/src/main/java/com/github/nullptroma/wallenc/vault/contract/VaultLinkOutcome.kt +++ b/vault-contracts/src/main/java/com/github/nullptroma/wallenc/vault/contract/VaultLinkOutcome.kt @@ -1,9 +1,9 @@ package com.github.nullptroma.wallenc.vault.contract /** - * Результат сценария OAuth-линка нового удалённого vault'а. + * Результат сценария OAuth-линка нового удалённого хранилища. * - * presentation сводит это к: успех → отдать `registration` в [VaultRegistrar.register]; + * Слой presentation сводит это к: успех → отдать `registration` в [VaultRegistrar.register]; * cancel → ничего не делать; failure → показать сообщение. */ sealed interface VaultLinkOutcome { diff --git a/vault-contracts/src/main/java/com/github/nullptroma/wallenc/vault/contract/VaultRegistration.kt b/vault-contracts/src/main/java/com/github/nullptroma/wallenc/vault/contract/VaultRegistration.kt index 088959e..894baf2 100644 --- a/vault-contracts/src/main/java/com/github/nullptroma/wallenc/vault/contract/VaultRegistration.kt +++ b/vault-contracts/src/main/java/com/github/nullptroma/wallenc/vault/contract/VaultRegistration.kt @@ -1,14 +1,14 @@ package com.github.nullptroma.wallenc.vault.contract /** - * Маркер «полезной нагрузки» для регистрации удалённого vault'а через [VaultRegistrar]. + * Маркер «полезной нагрузки» для регистрации удалённого хранилища через [VaultRegistrar]. * * Намеренно НЕ sealed: конкретные реализации (`YandexRegistration`, …) живут в `:data` - * рядом с соответствующими реализациями vault'а, чтобы `:data` не разнесёшь по - * нескольким модулям без необходимости. Цена — отсутствие exhaustive-when через - * границу модуля, лечится fail-fast веткой `else` в `VaultsManager.register(...)`. + * рядом с соответствующими реализациями vault, чтобы не дробить модуль без нужды. + * Цена — отсутствие exhaustive-when через границу модуля; лечится fail-fast веткой + * `else` в `VaultsManager.register(...)`. * - * presentation/app никогда не «открывают» этот тип — они только перепасовывают - * объект из [VaultLinkOutcome.Success] в [VaultRegistrar.register]. + * Код приложения (presentation) не раскрывает этот тип — только передаёт экземпляр + * из [VaultLinkOutcome.Success] в [VaultRegistrar.register]. */ interface VaultRegistration