Исправлено много варнингов

This commit is contained in:
2026-05-11 22:18:15 +03:00
parent d176f2a464
commit 61bcaa95d8
29 changed files with 162 additions and 123 deletions

View File

@@ -3,7 +3,9 @@ package com.github.nullptroma.wallenc.app.tasks
import android.app.Notification import android.app.Notification
import android.app.NotificationChannel import android.app.NotificationChannel
import android.app.NotificationManager import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service import android.app.Service
import android.content.Intent
import android.graphics.Color import android.graphics.Color
import android.os.IBinder import android.os.IBinder
import android.view.View import android.view.View
@@ -52,7 +54,14 @@ class TaskPipelineForegroundService : Service() {
private val serviceJob = SupervisorJob() private val serviceJob = SupervisorJob()
private val serviceScope = CoroutineScope(serviceJob + Dispatchers.Main.immediate) 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) @OptIn(ExperimentalCoroutinesApi::class)
override fun onCreate() { override fun onCreate() {
@@ -133,6 +142,11 @@ class TaskPipelineForegroundService : Service() {
.setSmallIcon(android.R.drawable.stat_sys_download) .setSmallIcon(android.R.drawable.stat_sys_download)
.setOngoing(true) .setOngoing(true)
.setOnlyAlertOnce(true) .setOnlyAlertOnce(true)
.addAction(
0,
getString(R.string.task_notification_cancel),
cancelAllTasksPendingIntent(),
)
.build() .build()
private fun buildAccumulatedNotification(tasks: List<TaskForegroundItem>): Notification { private fun buildAccumulatedNotification(tasks: List<TaskForegroundItem>): Notification {
@@ -141,19 +155,44 @@ class TaskPipelineForegroundService : Service() {
applyNotificationTemplateTextColor(big) applyNotificationTemplateTextColor(big)
bindTaskRows(big, sorted) 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) return NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle(getString(R.string.task_notification_title)) .setContentTitle(getString(R.string.task_notification_title))
.setContentText( .setContentText(collapsedSubtext)
getString(R.string.task_notification_group_subtext, sorted.size),
)
.setSmallIcon(android.R.drawable.stat_sys_download) .setSmallIcon(android.R.drawable.stat_sys_download)
.setOngoing(true) .setOngoing(true)
.setOnlyAlertOnce(true) .setOnlyAlertOnce(true)
.setStyle(NotificationCompat.DecoratedCustomViewStyle()) .setStyle(NotificationCompat.DecoratedCustomViewStyle())
.setCustomBigContentView(big) .setCustomBigContentView(big)
.addAction(
0,
getString(R.string.task_notification_cancel),
cancelAllTasksPendingIntent(),
)
.build() .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] * 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. * 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]) showTaskRow(remoteViews, i, sorted[i])
} }
val remaining = n - (MAX_TASK_ROWS - 1) val remaining = n - (MAX_TASK_ROWS - 1)
showOverflowRow(remoteViews, MAX_TASK_ROWS - 1, remaining) showOverflowRow(remoteViews, remaining)
} }
advanceIndeterminateDotPhasesAfterBind(sorted, n) advanceIndeterminateDotPhasesAfterBind(sorted, n)
} }
@@ -254,14 +293,19 @@ class TaskPipelineForegroundService : Service() {
} }
} }
private fun showOverflowRow(remoteViews: RemoteViews, index: Int, remainingCount: Int) { private fun showOverflowRow(remoteViews: RemoteViews, remainingCount: Int) {
remoteViews.setViewVisibility(TASK_ROW_IDS[index], View.VISIBLE) val overflowIndex = MAX_TASK_ROWS - 1
remoteViews.setViewVisibility(TASK_ROW_IDS[overflowIndex], View.VISIBLE)
remoteViews.setTextViewText( remoteViews.setTextViewText(
TASK_TITLE_IDS[index], TASK_TITLE_IDS[overflowIndex],
getString(R.string.task_notification_more_tasks, remainingCount), resources.getQuantityString(
R.plurals.task_notification_more_tasks,
remainingCount,
remainingCount,
),
) )
remoteViews.setViewVisibility(TASK_LABEL_BAR_ROW_IDS[index], View.GONE) remoteViews.setViewVisibility(TASK_LABEL_BAR_ROW_IDS[overflowIndex], View.GONE)
remoteViews.setTextViewText(TASK_SUBTITLE_IDS[index], "") remoteViews.setTextViewText(TASK_SUBTITLE_IDS[overflowIndex], "")
} }
private fun indeterminateSubtitleWithDots(taskId: TaskId, baseLabel: String): String { private fun indeterminateSubtitleWithDots(taskId: TaskId, baseLabel: String): String {
@@ -276,6 +320,9 @@ class TaskPipelineForegroundService : Service() {
} }
companion object { 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 CHANNEL_ID = "wallenc_task_pipeline"
private const val FOREGROUND_NOTIFICATION_ID = 1001 private const val FOREGROUND_NOTIFICATION_ID = 1001

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<plurals name="task_notification_group_subtext">
<item quantity="one">%d task running</item>
<item quantity="other">%d tasks running</item>
</plurals>
<plurals name="task_notification_more_tasks">
<item quantity="one">+%d more</item>
<item quantity="other">+%d more</item>
</plurals>
</resources>

View File

@@ -5,6 +5,4 @@
<string name="task_notification_preparing">Preparing…</string> <string name="task_notification_preparing">Preparing…</string>
<string name="task_notification_indeterminate">Working…</string> <string name="task_notification_indeterminate">Working…</string>
<string name="task_notification_cancel">Cancel</string> <string name="task_notification_cancel">Cancel</string>
<string name="task_notification_group_subtext">%d tasks running</string>
<string name="task_notification_more_tasks">+%d more</string>
</resources> </resources>

View File

@@ -17,7 +17,7 @@ dependencies {
implementation(libs.retrofit.converter.scalars) implementation(libs.retrofit.converter.scalars)
implementation(libs.retrofit.converter.jackson) implementation(libs.retrofit.converter.jackson)
implementation("com.squareup.okhttp3:okhttp:4.12.0") implementation(libs.okhttp3)
implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.coroutines.core)
testImplementation(libs.junit) testImplementation(libs.junit)

View File

@@ -6,9 +6,9 @@ import java.time.Instant
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)
data class DiskInfoDto( data class DiskInfoDto(
@JsonProperty("trash_size") val trashSize: Long? = null, @param:JsonProperty("trash_size") val trashSize: Long? = null,
@JsonProperty("total_space") val totalSpace: Long? = null, @param:JsonProperty("total_space") val totalSpace: Long? = null,
@JsonProperty("used_space") val usedSpace: Long? = null, @param:JsonProperty("used_space") val usedSpace: Long? = null,
) )
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)
@@ -36,10 +36,10 @@ data class ResourceDto(
val size: Long? = null, val size: Long? = null,
val modified: Instant? = null, val modified: Instant? = null,
val created: 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, val md5: String? = null,
@JsonProperty("custom_properties") val customProperties: Map<String, Any?>? = null, @param:JsonProperty("custom_properties") val customProperties: Map<String, Any?>? = null,
@JsonProperty("_embedded") val embedded: EmbeddedResourceListDto? = null, @param:JsonProperty("_embedded") val embedded: EmbeddedResourceListDto? = null,
) )
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)
@@ -56,5 +56,5 @@ data class ApiErrorDto(
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)
data class CustomPropertiesPatchDto( data class CustomPropertiesPatchDto(
@JsonProperty("custom_properties") val customProperties: Map<String, String>, @param:JsonProperty("custom_properties") val customProperties: Map<String, String>,
) )

View File

@@ -13,7 +13,6 @@ import com.github.nullptroma.wallenc.infrastructure.network.yandexdisk.dto.Resou
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request import okhttp3.Request
import okhttp3.RequestBody.Companion.asRequestBody import okhttp3.RequestBody.Companion.asRequestBody
@@ -21,6 +20,7 @@ import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.ResponseBody import okhttp3.ResponseBody
import retrofit2.HttpException import retrofit2.HttpException
import retrofit2.Response import retrofit2.Response
import java.io.FilterInputStream
import java.io.IOException import java.io.IOException
import java.io.InputStream 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<String, String>): Unit = suspend fun setCustomProperties(path: String, props: Map<String, String>): Unit =
withContext(ioDispatcher) { withContext(ioDispatcher) {
val resp = wrapAuth { val resp = wrapAuth {
@@ -141,25 +127,21 @@ class YandexDiskRepository(
resp.close() resp.close()
throw IOException("Download failed: HTTP ${resp.code}") throw IOException("Download failed: HTTP ${resp.code}")
} }
val body = resp.body ?: run { val body = resp.body
val stream = body?.byteStream() ?: run {
resp.close() resp.close()
throw IOException("Download: empty body") throw IOException("Download failed: missing body")
} }
body.byteStream().let { stream -> object : FilterInputStream(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() { override fun close() {
try { try {
stream.close() `in`.close()
} finally { } finally {
resp.close() resp.close()
} }
} }
} }
} }
}
private suspend fun awaitOperation(href: String) { private suspend fun awaitOperation(href: String) {
repeat(OPERATION_POLL_MAX) { repeat(OPERATION_POLL_MAX) {
@@ -201,16 +183,5 @@ class YandexDiskRepository(
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
private const val OPERATION_POLL_MAX = 200 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
}
} }
} }

View File

@@ -18,10 +18,10 @@ import java.io.InputStream
import java.util.UUID import java.util.UUID
/** /**
* Общий «скелет» storage'а: единая логика meta-info, rename, setEncInfo, * Общий «скелет» для [IStorage]: единая логика meta-info, rename, setEncInfo,
* clearAllContent и делегирования размера/доступности к [accessor]. * clearAllContent и делегирование размеров и доступности в [accessor].
* *
* Подклассы определяют только как создаётся [accessor], значение * Подклассы определяют только способ создания [accessor], значение
* [isVirtualStorage] и (при необходимости) расширяют [init] своими шагами * [isVirtualStorage] и (при необходимости) расширяют [init] своими шагами
* (например, проверкой ключа или инициализацией внешней связи). * (например, проверкой ключа или инициализацией внешней связи).
*/ */
@@ -60,8 +60,8 @@ abstract class BaseStorage(
protected open fun metaInfoUuidPart(): String = uuid.toString() protected open fun metaInfoUuidPart(): String = uuid.toString()
/** /**
* Запускается единожды при старте storage'а. Подклассы могут переопределить, * Запускается единожды при первом использовании хранилища. Подклассы могут переопределить,
* добавив свои шаги (init accessor'а, проверка ключа и т.п.). Обязательно * добавив свои шаги (инициализацию [accessor], проверку ключа и т.п.). Обязательно
* должен в какой-то момент вызвать [readMetaInfo]. * должен в какой-то момент вызвать [readMetaInfo].
*/ */
open suspend fun init() { open suspend fun init() {

View File

@@ -34,10 +34,10 @@ import java.time.Instant
import java.util.UUID import java.util.UUID
/** /**
* [IStorageAccessor] поверх папки приложения `app:/<storageUuid>/…` на Яндекс.Диске. * Реализация [IStorageAccessor] для дерева файлов `app:/<storageUuid>/…` на Яндекс.Диске.
* *
* [isAvailable] = доступность vault'а ([vaultAvailability]) **и** успешная локальная * [isAvailable] объединяет доступность удалённого хранилища ([vaultAvailability])
* инициализация этого storage ([storageReady]). * и успешную локальную инициализацию ([storageReady]) этого аксессора.
*/ */
class YandexStorageAccessor( class YandexStorageAccessor(
private val storageUuid: UUID, private val storageUuid: UUID,
@@ -265,7 +265,7 @@ class YandexStorageAccessor(
while (queue.isNotEmpty()) { while (queue.isNotEmpty()) {
val rel = queue.removeFirst() val rel = queue.removeFirst()
if (isSystemRel(rel)) continue if (isSystemRel(rel)) continue
val (files, dirs) = listImmediateChildren(rel) val (_, dirs) = listImmediateChildren(rel)
for (d in dirs) { for (d in dirs) {
if (!isSystemRel(d.metaInfo.path)) { if (!isSystemRel(d.metaInfo.path)) {
out.add(d) out.add(d)
@@ -402,10 +402,12 @@ class YandexStorageAccessor(
override suspend fun openWriteSystemFile(name: String): OutputStream = withContext(ioDispatcher) { override suspend fun openWriteSystemFile(name: String): OutputStream = withContext(ioDispatcher) {
ensureSystemDirExists() ensureSystemDirExists()
val rel = "/$SYSTEM_HIDDEN_DIRNAME/$name" val rel = "/$SYSTEM_HIDDEN_DIRNAME/$name"
val baos = ByteArrayOutputStream() val uploadBuffer = ByteArrayOutputStream()
baos.onClosed { uploadBuffer.onClosed {
runBlocking(ioDispatcher) { runBlocking(ioDispatcher) {
guard { repo.uploadBytes(toDiskPath(rel), baos.toByteArray(), overwrite = true) } guard {
repo.uploadBytes(toDiskPath(rel), uploadBuffer.toByteArray(), overwrite = true)
}
} }
} }
} }

View File

@@ -31,7 +31,7 @@ import java.util.UUID
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
class VaultsManager( class VaultsManager(
private val ioDispatcher: CoroutineDispatcher, private val ioDispatcher: CoroutineDispatcher,
localVault: IVault, private val localVault: IVault,
keyRepo: StorageKeyMapStore, keyRepo: StorageKeyMapStore,
private val yandexAccountStore: YandexAccountStore, private val yandexAccountStore: YandexAccountStore,
private val yandexUserInfoRepository: YandexUserInfoRepository, private val yandexUserInfoRepository: YandexUserInfoRepository,
@@ -40,8 +40,6 @@ class VaultsManager(
private val scope = CoroutineScope(SupervisorJob() + ioDispatcher) private val scope = CoroutineScope(SupervisorJob() + ioDispatcher)
private val localVault: IVault = localVault
private val yandexVaults: StateFlow<List<IVault>> = yandexAccountStore.observeAll() private val yandexVaults: StateFlow<List<IVault>> = yandexAccountStore.observeAll()
.map { rows -> .map { rows ->
rows.map { row -> rows.map { row ->

View File

@@ -20,7 +20,7 @@ import kotlin.io.path.pathString
class LocalVault( class LocalVault(
private val ioDispatcher: CoroutineDispatcher, private val ioDispatcher: CoroutineDispatcher,
private val vaultRoot: File?, vaultRoot: File?,
) : DescribedVault { ) : DescribedVault {
override val uuid: UUID = vaultRoot?.let { root -> override val uuid: UUID = vaultRoot?.let { root ->
@@ -42,7 +42,7 @@ class LocalVault(
private val _availableSpace = MutableStateFlow<Long?>(null) private val _availableSpace = MutableStateFlow<Long?>(null)
override val availableSpace: StateFlow<Long?> = _availableSpace override val availableSpace: StateFlow<Long?> = _availableSpace
private val path = MutableStateFlow<File?>(vaultRoot) private val path = MutableStateFlow(vaultRoot)
init { init {
CoroutineScope(ioDispatcher).launch { CoroutineScope(ioDispatcher).launch {

View File

@@ -1,15 +1,13 @@
package com.github.nullptroma.wallenc.infrastructure.vaults.yandex package com.github.nullptroma.wallenc.infrastructure.vaults.yandex
import com.github.nullptroma.wallenc.vault.contract.CloudBrand
import com.github.nullptroma.wallenc.vault.contract.VaultRegistration import com.github.nullptroma.wallenc.vault.contract.VaultRegistration
/** /**
* Регистрация удалённого vault'а Яндекс.Диска по результату OAuth. * Регистрация удалённого хранилища Яндекс.Диска по результату OAuth.
* *
* Живёт в `:data` (а не в `:vault-api`), потому что [VaultRegistration] * Живёт в `:data` (а не в `:vault-api`), потому что [VaultRegistration]
* намеренно не sealed — конкретные реализации лежат рядом со своим поставщиком. * намеренно не sealed — конкретные реализации лежат рядом со своим поставщиком.
* presentation никогда не открывает этот тип, только перепасовывает обратно * Слой presentation не раскрывает этот тип, только передаёт его в `VaultRegistrar.register(...)`.
* в `VaultRegistrar.register(...)`.
*/ */
data class YandexRegistration( data class YandexRegistration(
val oauthToken: String, val oauthToken: String,
@@ -17,6 +15,4 @@ data class YandexRegistration(
init { init {
require(oauthToken.isNotBlank()) { "oauthToken must not be blank" } require(oauthToken.isNotBlank()) { "oauthToken must not be blank" }
} }
val brand: CloudBrand get() = CloudBrand.YANDEX
} }

View File

@@ -64,10 +64,10 @@ 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 (e: YandexDiskAuthException) { } catch (_: YandexDiskAuthException) {
_vaultReachable.value = false _vaultReachable.value = false
_storages.value = emptyList() _storages.value = emptyList()
} catch (e: Exception) { } catch (_: Exception) {
_vaultReachable.value = false _vaultReachable.value = false
_storages.value = emptyList() _storages.value = emptyList()
} }
@@ -118,7 +118,7 @@ class YandexVault(
}, },
) )
storage.init() storage.init()
_storages.value = _storages.value + storage _storages.value += storage
storage storage
} }

View File

@@ -4,9 +4,9 @@ import com.github.nullptroma.wallenc.domain.datatypes.StorageEncryptionInfo
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
/** /**
* Контракт vault'а: коллекция [IStorage] с реактивным состоянием. * Контракт хранилища ([IVault]): коллекция [IStorage] с реактивным состоянием.
* *
* domain не различает локальные/удалённые/Yandex/etc. — это общий порт. * Слой domain не различает локальные/удалённые/Yandex и т.д. — это общий порт.
*/ */
interface IVault : IVaultInfo { interface IVault : IVaultInfo {
val storages: StateFlow<List<IStorage>> val storages: StateFlow<List<IStorage>>

View File

@@ -3,9 +3,9 @@ package com.github.nullptroma.wallenc.domain.interfaces
import java.util.UUID import java.util.UUID
/** /**
* Минимальная идентификация vault'а. * Минимальная идентификация хранилища ([IVaultInfo]).
* *
* Намеренно «голая»: domain ничего не знает о брендах, локальности или статусе — * Намеренно «голая»: доменный слой не знает о брендах, локальности или статусе —
* вся категоризация лежит во внешнем кольце (`:vault-api: VaultDescriptor`). * вся категоризация лежит во внешнем кольце (`:vault-api: VaultDescriptor`).
*/ */
interface IVaultInfo { interface IVaultInfo {

View File

@@ -3,10 +3,10 @@ package com.github.nullptroma.wallenc.domain.interfaces
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
/** /**
* Единая точка доступа ко всем vault'ам приложения. * Единая точка доступа ко всем подключённым хранилищам приложения.
* *
* domain не различает категории vault'ов — потребители (presentation) фильтруют * Доменный слой не различает категории vault — потребители в UI (presentation)
* [vaults] через `:vault-api` (`VaultDescriptor`/`DescribedVault`). * фильтруют [vaults] через `:vault-api` (`VaultDescriptor` / `DescribedVault`).
*/ */
interface IVaultsManager { interface IVaultsManager {
val vaults: StateFlow<List<IVault>> val vaults: StateFlow<List<IVault>>

View File

@@ -1,25 +1,26 @@
[versions] [versions]
agp = "9.1.1" agp = "9.1.1"
jacksonModuleKotlin = "2.21.2" jacksonModuleKotlin = "2.21.3"
kotlin = "2.3.20" kotlin = "2.3.21"
coreKtx = "1.18.0" coreKtx = "1.18.0"
junit = "4.13.2" junit = "4.13.2"
junitVersion = "1.3.0" junitVersion = "1.3.0"
espressoCore = "3.7.0" espressoCore = "3.7.0"
kotlinReflect = "2.3.0" kotlinReflect = "2.3.21"
kotlinxCoroutinesCore = "1.10.2" kotlinxCoroutinesCore = "1.11.0"
kotlinxSerializationJson = "1.11.0" kotlinxSerializationJson = "1.11.0"
lifecycleRuntimeKtx = "2.10.0" lifecycleRuntimeKtx = "2.10.0"
activityCompose = "1.13.0" activityCompose = "1.13.0"
composeBom = "2026.03.01" composeBom = "2026.05.00"
navigation = "2.9.7" navigation = "2.9.8"
hiltNavigation = "1.3.0" hiltNavigation = "1.3.0"
timber = "5.0.1" timber = "5.0.1"
yandexAuthSdk = "3.2.0" yandexAuthSdk = "3.2.0"
daggerHilt = "2.59.2" daggerHilt = "2.59.2"
ksp = "2.3.6" ksp = "2.3.7"
room = "2.8.4" room = "2.8.4"
retrofit = "3.0.0" retrofit = "3.0.0"
okhttp = "5.3.2"
[libraries] [libraries]
jackson-datatype-jsr310 = { module = "com.fasterxml.jackson.datatype:jackson-datatype-jsr310" } 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" } 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-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-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" } timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" }
# Yandex # 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 = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
retrofit-converter-scalars = { group = "com.squareup.retrofit2", name = "converter-scalars", 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" } 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" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
junit = { group = "junit", name = "junit", version.ref = "junit" } junit = { group = "junit", name = "junit", version.ref = "junit" }

View File

@@ -1,6 +1,6 @@
#Sat Sep 07 01:04:14 MSK 2024 #Sat Sep 07 01:04:14 MSK 2024
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists 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 zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists

View File

@@ -8,7 +8,7 @@ class RoomFactory(private val context: Context) {
fun buildAppDb(): AppDb { fun buildAppDb(): AppDb {
val room = Room.databaseBuilder( val room = Room.databaseBuilder(
context, AppDb::class.java, "app-db" context, AppDb::class.java, "app-db"
).fallbackToDestructiveMigration().build() ).fallbackToDestructiveMigration(dropAllTables = true).build()
return room return room
} }
} }

View File

@@ -22,8 +22,8 @@ class StorageKeyMapRepository(
dao.add(*dbModels.toTypedArray()) dao.add(*dbModels.toTypedArray())
} }
override suspend fun delete(vararg keymaps: StorageKeyMap) = withContext(ioDispatcher) { override suspend fun delete(vararg values: StorageKeyMap) = withContext(ioDispatcher) {
val dbModels = keymaps.map { DbStorageKeyMap.fromModel(it) } val dbModels = values.map { DbStorageKeyMap.fromModel(it) }
dao.delete(*dbModels.toTypedArray()) dao.delete(*dbModels.toTypedArray())
} }
} }

View File

@@ -51,6 +51,7 @@ kotlin {
dependencies { dependencies {
implementation(libs.androidx.navigation.compose) implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.hilt.navigation.compose) implementation(libs.androidx.hilt.navigation.compose)
implementation(libs.androidx.hilt.lifecycle.viewmodel.compose)
// Hilt // Hilt
implementation(libs.dagger.hilt) implementation(libs.dagger.hilt)

View File

@@ -21,7 +21,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource 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.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.currentBackStackEntryAsState
@@ -66,7 +66,8 @@ fun WallencNavRoot(viewModel: WallencViewModel = hiltViewModel()) {
TaskPipelineRoute::class.qualifiedName!! to NavBarItemData( TaskPipelineRoute::class.qualifiedName!! to NavBarItemData(
R.string.task_pipeline_title, R.string.task_pipeline_title,
TaskPipelineRoute::class.qualifiedName!!, TaskPipelineRoute::class.qualifiedName!!,
Icons.AutoMirrored.Rounded.List Icons.AutoMirrored.Rounded.List,
R.string.task_pipeline_open,
), ),
SettingsRoute::class.qualifiedName!! to NavBarItemData( SettingsRoute::class.qualifiedName!! to NavBarItemData(
R.string.nav_label_settings, R.string.nav_label_settings,
@@ -89,7 +90,7 @@ fun WallencNavRoot(viewModel: WallencViewModel = hiltViewModel()) {
icon = { icon = {
if (navBarItemData.icon != null) Icon( if (navBarItemData.icon != null) Icon(
navBarItemData.icon, navBarItemData.icon,
contentDescription = stringResource(navBarItemData.nameStringResourceId) contentDescription = stringResource(navBarItemData.iconContentDescriptionResourceId),
) )
}, },
label = { Text(stringResource(navBarItemData.nameStringResourceId)) }, label = { Text(stringResource(navBarItemData.nameStringResourceId)) },

View File

@@ -2,4 +2,9 @@ package com.github.nullptroma.wallenc.ui.navigation
import androidx.compose.ui.graphics.vector.ImageVector 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,
)

View File

@@ -18,7 +18,7 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp 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.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.currentBackStackEntryAsState

View File

@@ -42,7 +42,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog 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 androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.github.nullptroma.wallenc.ui.R import com.github.nullptroma.wallenc.ui.R
import com.github.nullptroma.wallenc.vault.contract.CloudBrand import com.github.nullptroma.wallenc.vault.contract.CloudBrand
@@ -174,7 +174,13 @@ fun RemoteVaultsScreen(
style = MaterialTheme.typography.headlineSmall, style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onSurface, 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( FilledTonalButton(
onClick = { onClick = {
viewModel.setAddChoiceVisible(false) viewModel.setAddChoiceVisible(false)

View File

@@ -30,7 +30,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp 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 androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.github.nullptroma.wallenc.domain.tasks.PipelineTask import com.github.nullptroma.wallenc.domain.tasks.PipelineTask
import com.github.nullptroma.wallenc.domain.tasks.TaskLogLevel import com.github.nullptroma.wallenc.domain.tasks.TaskLogLevel

View File

@@ -2,7 +2,7 @@ package com.github.nullptroma.wallenc.ui.screens.main.screens.vault
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
@Composable @Composable
fun LocalVaultScreen( fun LocalVaultScreen(

View File

@@ -1,9 +1,9 @@
package com.github.nullptroma.wallenc.vault.contract package com.github.nullptroma.wallenc.vault.contract
/** /**
* Запуск OAuth-сценария привязки удалённого vault'а для конкретного [CloudBrand]. * Запуск OAuth-сценария привязки удалённого хранилища для конкретного [CloudBrand].
* *
* Реализация в `:app` (привязана к Activity). presentation вызывает [beginLink] * Реализация в `:app` (привязана к Activity). Слой presentation вызывает [beginLink]
* из контекста Compose-экрана и получает [VaultLinkOutcome] асинхронно. * из контекста Compose-экрана и получает [VaultLinkOutcome] асинхронно.
* *
* Намеренно императивный API через колбэк, чтобы корректно встроиться * Намеренно императивный API через колбэк, чтобы корректно встроиться

View File

@@ -1,9 +1,9 @@
package com.github.nullptroma.wallenc.vault.contract package com.github.nullptroma.wallenc.vault.contract
/** /**
* Результат сценария OAuth-линка нового удалённого vault'а. * Результат сценария OAuth-линка нового удалённого хранилища.
* *
* presentation сводит это к: успех → отдать `registration` в [VaultRegistrar.register]; * Слой presentation сводит это к: успех → отдать `registration` в [VaultRegistrar.register];
* cancel → ничего не делать; failure → показать сообщение. * cancel → ничего не делать; failure → показать сообщение.
*/ */
sealed interface VaultLinkOutcome { sealed interface VaultLinkOutcome {

View File

@@ -1,14 +1,14 @@
package com.github.nullptroma.wallenc.vault.contract package com.github.nullptroma.wallenc.vault.contract
/** /**
* Маркер «полезной нагрузки» для регистрации удалённого vault'а через [VaultRegistrar]. * Маркер «полезной нагрузки» для регистрации удалённого хранилища через [VaultRegistrar].
* *
* Намеренно НЕ sealed: конкретные реализации (`YandexRegistration`, …) живут в `:data` * Намеренно НЕ sealed: конкретные реализации (`YandexRegistration`, …) живут в `:data`
* рядом с соответствующими реализациями vault'а, чтобы `:data` не разнесёшь по * рядом с соответствующими реализациями vault, чтобы не дробить модуль без нужды.
* нескольким модулям без необходимости. Цена — отсутствие exhaustive-when через * Цена — отсутствие exhaustive-when через границу модуля; лечится fail-fast веткой
* границу модуля, лечится fail-fast веткой `else` в `VaultsManager.register(...)`. * `else` в `VaultsManager.register(...)`.
* *
* presentation/app никогда не «открывают» этот тип — они только перепасовывают * Код приложения (presentation) не раскрывает этот тип — только передаёт экземпляр
* объект из [VaultLinkOutcome.Success] в [VaultRegistrar.register]. * из [VaultLinkOutcome.Success] в [VaultRegistrar.register].
*/ */
interface VaultRegistration interface VaultRegistration