diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 407980f..3e55916 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -7,6 +7,9 @@ + - - - + + + + + + + + + + + + + + + + + + + + listOf( - storage.accessor.filesUpdates.map { Unit }, - storage.accessor.dirsUpdates.map { Unit }, + storage.accessor.filesUpdates.map {}, + storage.accessor.dirsUpdates.map {}, ) } merge(*triggers.toTypedArray()) 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 1fd63c3..c26cae8 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 @@ -196,11 +196,10 @@ class YandexDiskRepository( } return@withContext object : FilterInputStream(stream) { override fun close() { - try { - `in`.close() - } finally { - resp.close() + `in`.use { + // Response must be closed after the wrapped stream. } + resp.close() } } } diff --git a/domain-vault/src/main/java/com/github/nullptroma/wallenc/infrastructure/storages/encrypt/EncryptedStorageAccessor.kt b/domain-vault/src/main/java/com/github/nullptroma/wallenc/infrastructure/storages/encrypt/EncryptedStorageAccessor.kt index 37062b1..a354c09 100644 --- a/domain-vault/src/main/java/com/github/nullptroma/wallenc/infrastructure/storages/encrypt/EncryptedStorageAccessor.kt +++ b/domain-vault/src/main/java/com/github/nullptroma/wallenc/infrastructure/storages/encrypt/EncryptedStorageAccessor.kt @@ -28,7 +28,6 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock -import java.io.FileNotFoundException import java.io.InputStream import java.io.OutputStream import java.time.Instant diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index fc26362..5d946b4 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -21,9 +21,9 @@ ksp = "2.3.7" room = "2.8.4" retrofit = "3.0.0" okhttp = "5.3.2" -workRuntime = "2.10.0" +workRuntime = "2.11.2" hiltWork = "1.3.0" -cameraX = "1.5.0" +cameraX = "1.6.1" mlkitBarcode = "17.3.0" javaOtp = "0.4.0" diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/elements/StorageTree.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/elements/StorageTree.kt index 6458c67..639022b 100644 --- a/ui/src/main/java/com/github/nullptroma/wallenc/ui/elements/StorageTree.kt +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/elements/StorageTree.kt @@ -39,7 +39,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.PlatformTextStyle import androidx.compose.ui.text.style.TextAlign diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/StorageHomeScreen.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/StorageHomeScreen.kt index f0763d2..69d3f57 100644 --- a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/StorageHomeScreen.kt +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/StorageHomeScreen.kt @@ -54,10 +54,8 @@ fun StorageHomeScreen( } Text( - text = if (uiState.storageName.isBlank()) { + text = uiState.storageName.ifBlank { stringResource(R.string.storage_home_unnamed_storage) - } else { - uiState.storageName }, style = MaterialTheme.typography.headlineSmall, ) diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/twofa/OtpAuthUriParser.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/twofa/OtpAuthUriParser.kt index 9c69eb2..c207bd5 100644 --- a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/twofa/OtpAuthUriParser.kt +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/twofa/OtpAuthUriParser.kt @@ -1,6 +1,6 @@ package com.github.nullptroma.wallenc.ui.screens.main.screens.storage.twofa -import android.net.Uri +import androidx.core.net.toUri data class ParsedOtpAuthToken( val issuer: String, @@ -12,7 +12,7 @@ data class ParsedOtpAuthToken( ) fun parseOtpAuthTotpUri(raw: String): ParsedOtpAuthToken? { - val uri = runCatching { Uri.parse(raw.trim()) }.getOrNull() ?: return null + val uri = runCatching { raw.trim().toUri() }.getOrNull() ?: return null if (!uri.scheme.equals("otpauth", ignoreCase = true)) return null if (!uri.host.equals("totp", ignoreCase = true)) return null diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/twofa/TwoFaTokensScreen.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/twofa/TwoFaTokensScreen.kt index 13aadf7..4090279 100644 --- a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/twofa/TwoFaTokensScreen.kt +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/twofa/TwoFaTokensScreen.kt @@ -6,7 +6,6 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxHeight @@ -44,18 +43,21 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.DropdownMenu import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.runtime.getValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.Alignment import androidx.compose.ui.layout.onSizeChanged -import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.AnnotatedString @@ -68,6 +70,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.offset import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalClipboard import androidx.core.content.ContextCompat import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -77,6 +80,7 @@ import com.github.nullptroma.wallenc.ui.elements.QrScannerDialog import com.github.nullptroma.wallenc.usecases.TwoFaCodeState import com.github.nullptroma.wallenc.usecases.buildTwoFaCodeState import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collectLatest import kotlin.math.roundToInt import androidx.compose.ui.unit.IntOffset @@ -86,7 +90,7 @@ fun TwoFaTokensScreen( viewModel: TwoFaTokensViewModel = hiltViewModel(), ) { val uiState by viewModel.state.collectAsStateWithLifecycle() - val clipboard = LocalClipboardManager.current + val clipboard = LocalClipboard.current val nowMillis by produceState(initialValue = System.currentTimeMillis()) { while (true) { value = System.currentTimeMillis() @@ -197,7 +201,7 @@ fun TwoFaTokensScreen( .fillMaxWidth() .padding(start = 14.dp, end = 14.dp, bottom = 10.dp), horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = androidx.compose.ui.Alignment.Top, + verticalAlignment = Alignment.Top, ) { Column( verticalArrangement = Arrangement.spacedBy(2.dp), @@ -242,7 +246,7 @@ fun TwoFaTokensScreen( ) { Row( horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, + verticalAlignment = Alignment.CenterVertically, ) { Icon( imageVector = Icons.Default.ContentCopy, @@ -326,15 +330,18 @@ private fun TwoFaTokenEditDialog( var account by remember(startValue) { mutableStateOf(startValue?.account.orEmpty()) } var secret by remember(startValue) { mutableStateOf(startValue?.secret.orEmpty()) } var notes by remember(startValue) { mutableStateOf(startValue?.notes.orEmpty()) } - var digitsValue by remember(startValue) { mutableStateOf((startValue?.digits ?: 6).coerceIn(6, 8)) } - var periodSecondsValue by remember(startValue) { mutableStateOf((startValue?.periodSeconds ?: 30).coerceIn(10, 120)) } + var digitsValue by remember(startValue) { mutableIntStateOf((startValue?.digits ?: 6).coerceIn(6, 8)) } + var periodSecondsValue by remember(startValue) { mutableIntStateOf((startValue?.periodSeconds ?: 30).coerceIn(10, 120)) } var algorithmValue by remember(startValue) { mutableStateOf((startValue?.algorithm ?: "SHA1").uppercase()) } var algorithmExpanded by remember { mutableStateOf(false) } var showScanner by remember { mutableStateOf(false) } var scanError by remember { mutableStateOf(null) } var permissionDenied by remember { mutableStateOf(false) } val dialogScrollState = rememberScrollState() - var scrollContainerHeightPx by remember { mutableStateOf(0) } + var scrollContainerHeightPx by remember { mutableIntStateOf(0) } + var scrollProgress by remember { mutableFloatStateOf(0f) } + val density = LocalDensity.current + val scanInvalidText = stringResource(R.string.two_fa_scan_qr_invalid) val cameraPermissionLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.RequestPermission(), ) { granted -> @@ -345,6 +352,12 @@ private fun TwoFaTokenEditDialog( permissionDenied = true } } + LaunchedEffect(dialogScrollState) { + snapshotFlow { dialogScrollState.value to dialogScrollState.maxValue } + .collectLatest { (value, maxValue) -> + scrollProgress = if (maxValue == 0) 0f else value.toFloat() / maxValue.toFloat() + } + } AlertDialog( onDismissRequest = { if (!isBusy) onDismiss() }, @@ -512,12 +525,11 @@ private fun TwoFaTokenEditDialog( val content = viewport + dialogScrollState.maxValue.toFloat() val thumbHeightPx = (viewport * (viewport / content)).coerceAtLeast(24f) val maxTravel = (viewport - thumbHeightPx).coerceAtLeast(0f) - val offsetY = if (dialogScrollState.maxValue == 0) 0f - else maxTravel * (dialogScrollState.value.toFloat() / dialogScrollState.maxValue.toFloat()) + val offsetY = maxTravel * scrollProgress Box( modifier = Modifier .offset { IntOffset(0, offsetY.roundToInt()) } - .size(width = 3.dp, height = (thumbHeightPx / context.resources.displayMetrics.density).dp) + .size(width = 3.dp, height = with(density) { thumbHeightPx.toDp() }) .background(thumbColor), ) } @@ -555,7 +567,7 @@ private fun TwoFaTokenEditDialog( onScanned = { raw -> val parsed = parseOtpAuthTotpUri(raw) if (parsed == null) { - scanError = context.getString(R.string.two_fa_scan_qr_invalid) + scanError = scanInvalidText return@QrScannerDialog } issuer = parsed.issuer diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/vault/AbstractVaultBrowserViewModel.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/vault/AbstractVaultBrowserViewModel.kt index 1a4e1a7..329d751 100644 --- a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/vault/AbstractVaultBrowserViewModel.kt +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/vault/AbstractVaultBrowserViewModel.kt @@ -107,7 +107,7 @@ abstract class AbstractVaultBrowserViewModel( list.add(tree) while (opened.containsKey(tree.value.uuid)) { val child = opened.getValue(tree.value.uuid) - val nextTree = Tree(child) + val nextTree = Tree(child) tree.children = listOf(nextTree) tree = nextTree } diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/vault/VaultBrowserScreen.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/vault/VaultBrowserScreen.kt index 3754699..d016bc7 100644 --- a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/vault/VaultBrowserScreen.kt +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/vault/VaultBrowserScreen.kt @@ -25,6 +25,9 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha @@ -47,19 +50,28 @@ fun VaultBrowserScreen( ) { val uiState by viewModel.state.collectAsStateWithLifecycle() val context = LocalContext.current - LaunchedEffect(Unit) { + var pendingNotification by remember { mutableStateOf(null) } + + LaunchedEffect(viewModel) { viewModel.userNotifications.collect { notification -> - val text = when (notification) { - is UserNotification.TextRes -> { - if (notification.formatArgs.isEmpty()) { - context.getString(notification.id) - } else { - context.getString(notification.id, *notification.formatArgs.toTypedArray()) - } - } - is UserNotification.Plain -> notification.message + pendingNotification = notification + } + } + val notificationText = when (val notification = pendingNotification) { + is UserNotification.TextRes -> { + if (notification.formatArgs.isEmpty()) { + stringResource(notification.id) + } else { + stringResource(notification.id, *notification.formatArgs.toTypedArray()) } - Toast.makeText(context, text, Toast.LENGTH_SHORT).show() + } + is UserNotification.Plain -> notification.message + null -> null + } + LaunchedEffect(notificationText) { + if (notificationText != null) { + Toast.makeText(context, notificationText, Toast.LENGTH_SHORT).show() + pendingNotification = null } } diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/sync/StorageSyncScreen.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/sync/StorageSyncScreen.kt index 3545afa..bbbbb3c 100644 --- a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/sync/StorageSyncScreen.kt +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/sync/StorageSyncScreen.kt @@ -3,7 +3,6 @@ package com.github.nullptroma.wallenc.ui.screens.sync import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -41,7 +40,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.unit.dp @@ -59,19 +57,19 @@ fun StorageSyncScreen( ) { val state by viewModel.state.collectAsStateWithLifecycle() val snackbarHostState = remember { SnackbarHostState() } - val context = LocalContext.current - LaunchedEffect(state.userMessage) { - val um = state.userMessage ?: return@LaunchedEffect - val text = when (um) { - is UserNotification.TextRes -> { - if (um.formatArgs.isEmpty()) { - context.getString(um.id) - } else { - context.getString(um.id, *um.formatArgs.toTypedArray()) - } + val userMessageText = when (val um = state.userMessage) { + is UserNotification.TextRes -> { + if (um.formatArgs.isEmpty()) { + stringResource(um.id) + } else { + stringResource(um.id, *um.formatArgs.toTypedArray()) } - is UserNotification.Plain -> um.message } + is UserNotification.Plain -> um.message + null -> null + } + LaunchedEffect(state.userMessage) { + val text = userMessageText ?: return@LaunchedEffect snackbarHostState.showSnackbar(text) viewModel.consumeUserMessage() } diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/sync/StorageSyncViewModel.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/sync/StorageSyncViewModel.kt index f7e1de3..82b19f0 100644 --- a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/sync/StorageSyncViewModel.kt +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/sync/StorageSyncViewModel.kt @@ -149,9 +149,7 @@ class StorageSyncViewModel @Inject constructor( viewModelScope.launch { withGroupMutationBusy { val storage = storageByUuid[storageUuid] - if (storage == null) { - return@withGroupMutationBusy UserNotification.TextRes(R.string.sync_storage_not_in_vaults) - } + ?: return@withGroupMutationBusy UserNotification.TextRes(R.string.sync_storage_not_in_vaults) val isEncrypted = storage.metaInfo.value.encInfo != null val result = groupsUseCase.addStorageToGroup( groupId = groupId, @@ -290,8 +288,7 @@ class StorageSyncViewModel @Inject constructor( } private fun vaultType(vault: DescribedVault?): String { - val descriptor = vault?.descriptor - return when (descriptor) { + return when (val descriptor = vault?.descriptor) { is VaultDescriptor.LocalDevice -> uiStrings(R.string.vault_type_local_device) is VaultDescriptor.LinkedRemote -> uiStrings(R.string.vault_type_remote, descriptor.brand.name) null -> uiStrings(R.string.vault_type_unknown) @@ -299,8 +296,7 @@ class StorageSyncViewModel @Inject constructor( } private fun vaultTitle(vault: DescribedVault?): String { - val descriptor = vault?.descriptor - return when (descriptor) { + return when (val descriptor = vault?.descriptor) { is VaultDescriptor.LocalDevice -> uiStrings(R.string.vault_title_local) is VaultDescriptor.LinkedRemote -> descriptor.accountDisplayName null -> uiStrings(R.string.vault_title_unknown) diff --git a/usecases/src/main/java/com/github/nullptroma/wallenc/usecases/ManageTextSecretsUseCase.kt b/usecases/src/main/java/com/github/nullptroma/wallenc/usecases/ManageTextSecretsUseCase.kt index 8bb3086..141000a 100644 --- a/usecases/src/main/java/com/github/nullptroma/wallenc/usecases/ManageTextSecretsUseCase.kt +++ b/usecases/src/main/java/com/github/nullptroma/wallenc/usecases/ManageTextSecretsUseCase.kt @@ -19,7 +19,6 @@ import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.buildJsonArray import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.contentOrNull -import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonPrimitive import java.util.UUID @@ -33,10 +32,10 @@ class ManageTextSecretsUseCase { storage.accessor.filesUpdates .filter { page -> page.data.any { file -> - domainFilePathEquals(file.metaInfo.path, StorageDomainDataFiles.TEXT_SECRETS_FILE) + domainFilePathEquals(file.metaInfo.path) } } - .map { Unit }, + .map {}, ).map { mutex.withLock { readAll(storage) } }.distinctUntilChanged() @@ -155,6 +154,6 @@ class ManageTextSecretsUseCase { } } - private fun domainFilePathEquals(left: String, right: String): Boolean = - left.trimStart('/') == right.trimStart('/') + private fun domainFilePathEquals(path: String): Boolean = + path.trimStart('/') == StorageDomainDataFiles.TEXT_SECRETS_FILE.trimStart('/') } diff --git a/usecases/src/main/java/com/github/nullptroma/wallenc/usecases/ManageTwoFaTokensUseCase.kt b/usecases/src/main/java/com/github/nullptroma/wallenc/usecases/ManageTwoFaTokensUseCase.kt index adb3bfc..c985969 100644 --- a/usecases/src/main/java/com/github/nullptroma/wallenc/usecases/ManageTwoFaTokensUseCase.kt +++ b/usecases/src/main/java/com/github/nullptroma/wallenc/usecases/ManageTwoFaTokensUseCase.kt @@ -29,10 +29,10 @@ class ManageTwoFaTokensUseCase { storage.accessor.filesUpdates .filter { page -> page.data.any { file -> - domainFilePathEquals(file.metaInfo.path, StorageDomainDataFiles.TWO_FA_TOKENS_FILE) + domainFilePathEquals(file.metaInfo.path) } } - .map { Unit }, + .map {}, ).map { mutex.withLock { readAll(storage) } }.distinctUntilChanged() @@ -153,6 +153,6 @@ class ManageTwoFaTokensUseCase { } } - private fun domainFilePathEquals(left: String, right: String): Boolean = - left.trimStart('/') == right.trimStart('/') + private fun domainFilePathEquals(path: String): Boolean = + path.trimStart('/') == StorageDomainDataFiles.TWO_FA_TOKENS_FILE.trimStart('/') } diff --git a/usecases/src/test/java/com/github/nullptroma/wallenc/usecases/StorageDomainUseCasesTest.kt b/usecases/src/test/java/com/github/nullptroma/wallenc/usecases/StorageDomainUseCasesTest.kt index d920d33..ef8578d 100644 --- a/usecases/src/test/java/com/github/nullptroma/wallenc/usecases/StorageDomainUseCasesTest.kt +++ b/usecases/src/test/java/com/github/nullptroma/wallenc/usecases/StorageDomainUseCasesTest.kt @@ -138,7 +138,7 @@ private class FakeStorage : IStorage { private class FakeMetaInfo : IStorageMetaInfo { override val encInfo: StorageEncryptionInfo? = null - override val name: String? = "Fake" + override val name: String = "Fake" override val lastModified: Instant = Instant.now() }