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 76f5b7b..f524e7e 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 @@ -371,6 +371,14 @@ class EncryptedStorageAccessor( } } + override suspend fun forceClearSyncLock() { + syncLockMutex.withLock { + openWriteSystemFile(SYNC_LOCK_FILENAME).use { out -> + out.write(ByteArray(0)) + } + } + } + private suspend fun appendSyncEntry(path: String, operation: StorageSyncOperation) { val cleanedPath = if (path.startsWith("/")) path else "/$path" val entries = readSyncJournal() diff --git a/domain-vault/src/main/java/com/github/nullptroma/wallenc/infrastructure/storages/local/LocalStorageAccessor.kt b/domain-vault/src/main/java/com/github/nullptroma/wallenc/infrastructure/storages/local/LocalStorageAccessor.kt index 504ef8b..e7ca253 100644 --- a/domain-vault/src/main/java/com/github/nullptroma/wallenc/infrastructure/storages/local/LocalStorageAccessor.kt +++ b/domain-vault/src/main/java/com/github/nullptroma/wallenc/infrastructure/storages/local/LocalStorageAccessor.kt @@ -669,6 +669,27 @@ class LocalStorageAccessor( } } + override suspend fun forceClearSyncLock() = withContext(ioDispatcher) { + val lockPath = systemLockPath() + if (!Files.exists(lockPath)) { + return@withContext + } + Files.createDirectories(lockPath.parent) + FileChannel.open( + lockPath, + StandardOpenOption.READ, + StandardOpenOption.WRITE, + ).use { channel -> + val fileLock = channel.lock() + try { + channel.truncate(0) + channel.force(true) + } finally { + fileLock.release() + } + } + } + private fun systemLockPath(): Path = _filesystemBasePath.resolve(SYSTEM_HIDDEN_DIRNAME).resolve(SYNC_LOCK_FILENAME) 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 5083af3..46e57a9 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 @@ -636,6 +636,14 @@ class YandexStorageAccessor( } } + override suspend fun forceClearSyncLock() = withContext(ioDispatcher) { + syncLockMutex.withLock { + openWriteSystemFile(SYNC_LOCK_FILENAME).use { out -> + out.write(ByteArray(0)) + } + } + } + private suspend fun appendSyncEntry(path: String, operation: StorageSyncOperation) { val cleanedPath = if (path.startsWith("/")) path else "/$path" val entries = readSyncJournal() diff --git a/domain/src/main/java/com/github/nullptroma/wallenc/domain/interfaces/IStorageAccessor.kt b/domain/src/main/java/com/github/nullptroma/wallenc/domain/interfaces/IStorageAccessor.kt index c4913b1..ae9ad9c 100644 --- a/domain/src/main/java/com/github/nullptroma/wallenc/domain/interfaces/IStorageAccessor.kt +++ b/domain/src/main/java/com/github/nullptroma/wallenc/domain/interfaces/IStorageAccessor.kt @@ -59,4 +59,10 @@ interface IStorageAccessor { suspend fun readSyncLock(): StorageSyncLock? suspend fun tryAcquireSyncLock(holderId: String, leaseUntil: Instant): Boolean suspend fun releaseSyncLock(holderId: String) + + /** + * Сбрасывает lock синхронизации без проверки [holderId] (снятие «залипшей» блокировки). + * Не использовать в обычном цикле синка — только для ручного вмешательства. + */ + suspend fun forceClearSyncLock() } \ No newline at end of file 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 4cdad20..fe1f72f 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 @@ -29,6 +29,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.ripple 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 @@ -63,6 +64,8 @@ fun StorageTree( onDisableEncryption: (Tree) -> Unit, getStatusText: (Tree) -> String, isEncryptionOpened: (Tree) -> Boolean, + isStorageSyncLockHeld: suspend (IStorageInfo) -> Boolean, + onClearStorageSyncLock: (IStorageInfo) -> Unit, ) { val cur = tree.value val available by cur.isAvailable.collectAsStateWithLifecycle() @@ -125,6 +128,15 @@ fun StorageTree( horizontalAlignment = Alignment.End ) { var expanded by remember { mutableStateOf(false) } + var syncLockHeld by remember { mutableStateOf(null) } + LaunchedEffect(expanded, cur.uuid) { + if (expanded) { + syncLockHeld = null + syncLockHeld = isStorageSyncLockHeld(cur) + } else { + syncLockHeld = null + } + } var showRenameDialog by remember { mutableStateOf(false) } var showRemoveConfirmDialog by remember { mutableStateOf(false) } var showLockDialog by remember { mutableStateOf(false) } @@ -166,6 +178,25 @@ fun StorageTree( text = { Text(stringResource(R.string.encrypt)) } ) } + HorizontalDivider() + DropdownMenuItem( + enabled = syncLockHeld == true, + onClick = { + expanded = false + if (syncLockHeld == true) { + onClearStorageSyncLock(cur) + } + }, + text = { + Text( + when (syncLockHeld) { + null -> stringResource(R.string.storage_sync_lock_checking) + true -> stringResource(R.string.storage_sync_unlock_action) + false -> stringResource(R.string.storage_sync_not_locked) + }, + ) + }, + ) } if (showRenameDialog) { @@ -295,7 +326,9 @@ fun StorageTree( onCloseEncrypted, onDisableEncryption, getStatusText, - isEncryptionOpened + isEncryptionOpened, + isStorageSyncLockHeld, + onClearStorageSyncLock, ) } } 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 c7296bd..0fe6a43 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 @@ -328,4 +328,37 @@ abstract class AbstractVaultBrowserViewModel( val openedMap = getOpenedStoragesUseCase.openedStorages.value return openedMap.containsKey(storage.uuid) } + + suspend fun isStorageSyncLockHeld(storage: IStorageInfo): Boolean { + val s = storage as? IStorage ?: return false + return try { + s.accessor.readSyncLock() != null + } catch (_: Exception) { + false + } + } + + fun clearStorageSyncLock(storage: IStorageInfo) { + taskOrchestrator.enqueue( + title = "Снятие блокировки синхронизации", + dispatcher = Dispatchers.IO, + work = { ctx -> + try { + val s = storage as? IStorage + if (s == null) { + ctx.log(TaskLogLevel.Error, "Некорректное хранилище") + _messages.emit("Некорректное хранилище") + return@enqueue + } + ctx.log(TaskLogLevel.Info, "Снимаю блокировку синхронизации…") + s.accessor.forceClearSyncLock() + ctx.log(TaskLogLevel.Info, "Блокировка синхронизации снята") + _messages.emit("Блокировка синхронизации снята") + } catch (e: Exception) { + ctx.log(TaskLogLevel.Error, e.message ?: "Не удалось снять блокировку") + _messages.emit(e.message ?: "Не удалось снять блокировку синхронизации") + } + }, + ) + } } 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 fe6aa93..46565a3 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 @@ -90,6 +90,8 @@ fun VaultBrowserScreen( onDisableEncryption = { tree -> viewModel.disableEncryption(tree.value) }, getStatusText = { tree -> viewModel.getStorageStatus(tree.value) }, isEncryptionOpened = { tree -> viewModel.isEncryptionSessionOpen(tree.value) }, + isStorageSyncLockHeld = { info -> viewModel.isStorageSyncLockHeld(info) }, + onClearStorageSyncLock = { info -> viewModel.clearStorageSyncLock(info) }, ) } item { Spacer(modifier = Modifier.height(8.dp)) } diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml index 1892d1e..641a216 100644 --- a/ui/src/main/res/values/strings.xml +++ b/ui/src/main/res/values/strings.xml @@ -34,6 +34,9 @@ New name Delete storage "%1$s"? Storage encryption actions + Проверка блокировки… + Снять блокировку синхронизации + Синхронизация не заблокирована Task pipeline Jobs diff --git a/usecases/src/main/java/com/github/nullptroma/wallenc/usecases/RunStorageSyncUseCase.kt b/usecases/src/main/java/com/github/nullptroma/wallenc/usecases/RunStorageSyncUseCase.kt index c7be295..0f6f023 100644 --- a/usecases/src/main/java/com/github/nullptroma/wallenc/usecases/RunStorageSyncUseCase.kt +++ b/usecases/src/main/java/com/github/nullptroma/wallenc/usecases/RunStorageSyncUseCase.kt @@ -27,8 +27,14 @@ class RunStorageSyncUseCase( syncEngine.syncAllGroups { fraction, label -> ctx.reportProgress(fraction, label) } + ctx.log(TaskLogLevel.Info, "Storage sync finished") ctx.reportProgress(null, "Storage sync: completed") - } finally { + } + catch (e: Exception) { + ctx.log(TaskLogLevel.Error, "Storage sync failed: ${e.message}") + ctx.reportProgress(null, "Storage sync: failed - ${e.message}") + } + finally { running.set(false) } },