diff --git a/.github/workflows/apple.yml b/.github/workflows/apple.yml index 0157181..415aebe 100644 --- a/.github/workflows/apple.yml +++ b/.github/workflows/apple.yml @@ -12,6 +12,7 @@ on: - "config.mk" - "make/**" - ".github/workflows/apple.yml" + - "localization/**" push: branches: - master @@ -25,6 +26,7 @@ on: - "config.mk" - "make/**" - ".github/workflows/apple.yml" + - "localization/**" permissions: contents: read @@ -63,5 +65,14 @@ jobs: - name: Install XcodeGen run: brew install xcodegen + - name: Install SwiftLint + # Required by the VniDrop target's SwiftLint build phase (typed-resources rules). + run: brew install swiftlint + + - name: Install Bun + # The Apple l10n catalog (Localizable.xcstrings) and L10n.swift are + # generated from localization/strings.json at build time, not tracked. + uses: oven-sh/setup-bun@v2 + - name: Build and test Apple app run: make check-apple diff --git a/AGENTS.md b/AGENTS.md index b09f4a9..dab6e35 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,6 +48,15 @@ Domain docs (reference, do not paste into PRs): 8. **Every bug fix includes a regression test** at the lowest layer that catches it. 9. After code changes, run the **relevant** checks in [Build and test](#build-and-test) and fix failures before finishing. +10. **`localization/strings.json` is the single source of truth for all localized + strings.** The KMP Compose resources (`shared/src/commonMain/composeResources/ + values*/strings.xml`) and the Apple catalog + accessors + (`apple/VniDrop/Resources/Localizable.xcstrings`, `apple/VniDrop/Generated/ + L10n.swift`) are **generated** by the loc CLI (`cd localization && bun run + src/cli.ts generate`) — never hand-edit them. To add/change a string: edit + `strings.json` (set `targets` to `kmp`, `apple`, or omit for both), then + regenerate. A key referenced in code but only present in a generated file will + be silently dropped the next time generation runs. --- @@ -291,6 +300,8 @@ branch from updated `master`. - Flaky multi-minute sleeps in tests - Unsigned commits when signing is required - Force-push or secret commits without explicit user direction +- Hand-editing generated localization files (`values*/strings.xml`, + `Localizable.xcstrings`, `L10n.swift`) instead of `localization/strings.json` --- diff --git a/Makefile b/Makefile index 5cf49b3..81b36df 100644 --- a/Makefile +++ b/Makefile @@ -113,7 +113,7 @@ apple-core: ## Build the Rust XCFramework and generated Swift bindings. @test "$(HOST_OS)" = macos || { printf 'Apple builds require macOS.\n' >&2; exit 1; } cd $(ROOT) && apple/scripts/build-core.sh $(APPLE_PROFILE) -apple-project: apple-core ## Generate the native Apple Xcode project. +apple-project: apple-core localization ## Generate the native Apple Xcode project. cd $(ROOT)/apple && $(XCODEGEN) generate open-apple-project: apple-project ## Generate and open the native Apple Xcode project. diff --git a/apple/.gitignore b/apple/.gitignore index 3910689..40759f5 100644 --- a/apple/.gitignore +++ b/apple/.gitignore @@ -3,6 +3,10 @@ VnidropCore/vnidrop.xcframework/ VnidropCore/Sources/VnidropCore/Vnidrop.swift +# Generated from localization/strings.json (cd localization && bun run src/cli.ts generate) +VniDrop/Resources/Localizable.xcstrings +VniDrop/Generated/ + # Generated by XcodeGen from project.yml VniDrop.xcodeproj/ diff --git a/apple/.swiftlint.yml b/apple/.swiftlint.yml new file mode 100644 index 0000000..b9a3f8e --- /dev/null +++ b/apple/.swiftlint.yml @@ -0,0 +1,34 @@ +# Focused lint for the native app: enforce the typed-resources convention only +# (no default style rules, so this stays signal, not noise). +only_rules: + - custom_rules + +included: + - VniDrop + +excluded: + - VniDrop/Generated + +custom_rules: + raw_localized_string: + name: "Raw localized key" + regex: 'String\(localized:\s*"' + message: "Use a typed L10n.* accessor, not a raw key string." + severity: warning + raw_localized_string_key: + name: "Raw LocalizedStringKey" + regex: 'LocalizedStringKey\("' + message: "Use a typed L10n.* accessor instead of a raw key." + severity: warning + raw_sf_symbol: + name: "Raw SF Symbol" + regex: 'system(Name|Image):\s*"' + message: "Use SFSafeSymbols: Image(systemSymbol:) or systemSymbol:." + severity: warning + raw_swiftui_string_literal: + name: "Raw SwiftUI string" + # A non-empty string literal as the leading arg of a view initializer is an + # implicit LocalizedStringKey. Empty labels (e.g. Picker("", …)) are allowed. + regex: '\b(Text|Label|Button|Toggle|Link|NavigationLink|Section|Picker|Stepper|TextField|SecureField|DisclosureGroup|Menu|GroupBox)\("[^"]' + message: "Pass a typed L10n.* accessor (or Text(verbatim:)), not a raw string literal." + severity: warning diff --git a/apple/Tests/AppPreferencesRepositoryTests.swift b/apple/Tests/AppPreferencesRepositoryTests.swift index 60acd67..47d30f9 100644 --- a/apple/Tests/AppPreferencesRepositoryTests.swift +++ b/apple/Tests/AppPreferencesRepositoryTests.swift @@ -19,7 +19,6 @@ final class AppPreferencesRepositoryTests: XCTestCase { let repo = AppPreferencesRepository(defaults: defaults(), fallback: fallback()) XCTAssertEqual(repo.preferences.username, "Default") XCTAssertEqual(repo.preferences.themeMode, .system) - XCTAssertFalse(repo.preferences.notificationsEnabled) XCTAssertEqual(repo.preferences.relayConfiguration, .automatic) } @@ -29,7 +28,6 @@ final class AppPreferencesRepositoryTests: XCTestCase { let repo = AppPreferencesRepository(defaults: store, fallback: fb) repo.setUsername("Bob") repo.setThemeMode(.dark) - repo.setNotificationsEnabled(true) repo.setReceiveFolder(ReceiveFolder(kind: .iosSecurityScopedUrl, value: "file:///x", displayName: "Custom")) repo.setRelayConfiguration(RelayConfiguration( mode: .strictCustom, @@ -40,7 +38,6 @@ final class AppPreferencesRepositoryTests: XCTestCase { let reloaded = AppPreferencesRepository(defaults: store, fallback: fb) XCTAssertEqual(reloaded.preferences.username, "Bob") XCTAssertEqual(reloaded.preferences.themeMode, .dark) - XCTAssertTrue(reloaded.preferences.notificationsEnabled) XCTAssertEqual(reloaded.preferences.receiveFolder.displayName, "Custom") XCTAssertEqual(reloaded.preferences.receiveFolder.kind, .iosSecurityScopedUrl) XCTAssertEqual(reloaded.preferences.relayConfiguration, RelayConfiguration( diff --git a/apple/Tests/ApprovalCoordinatorTests.swift b/apple/Tests/ApprovalCoordinatorTests.swift index 97ac8c0..617578a 100644 --- a/apple/Tests/ApprovalCoordinatorTests.swift +++ b/apple/Tests/ApprovalCoordinatorTests.swift @@ -11,7 +11,6 @@ final class ApprovalCoordinatorTests: XCTestCase { private func makeCoordinator(_ core: FakeCoreGateway) -> ApprovalCoordinator { ApprovalCoordinator( repository: core, - preferences: Fixtures.preferences(), notifications: LocalNotificationService(), visibility: AppVisibility(), messages: UiMessageController() diff --git a/apple/Tests/CoreDispatcherTests.swift b/apple/Tests/CoreDispatcherTests.swift new file mode 100644 index 0000000..4a71272 --- /dev/null +++ b/apple/Tests/CoreDispatcherTests.swift @@ -0,0 +1,50 @@ +import XCTest +@testable import VniDrop + +final class CoreDispatcherTests: XCTestCase { + + /// Regression guard for the receive-cancel deadlock: an interrupt-lane call + /// must complete even while the serial lane is occupied by a blocking call. + /// With a single shared queue (the old design) the interrupt would be stuck + /// behind the blocked `receive`, and this would time out. + func testInterruptCompletesWhileSerialLaneIsBlocked() async { + let dispatcher = CoreDispatcher() + let serialEntered = DispatchSemaphore(value: 0) + let releaseSerial = DispatchSemaphore(value: 0) + + // Occupy the serial lane with a call that blocks until we release it. + let serialTask = Task { + await dispatcher.run { + serialEntered.signal() + releaseSerial.wait() + } + } + XCTAssertEqual(serialEntered.wait(timeout: .now() + 2), .success, "serial lane never started") + + // The interrupt lane must run despite the serial lane being blocked. + let interruptDone = DispatchSemaphore(value: 0) + Task.detached { + _ = await dispatcher.runInterrupt { 42 } + interruptDone.signal() + } + XCTAssertEqual( + interruptDone.wait(timeout: .now() + 2), .success, + "interrupt lane was blocked behind the occupied serial lane") + + releaseSerial.signal() + _ = await serialTask.value + } + + func testRunPropagatesValuesAndErrors() async { + let dispatcher = CoreDispatcher() + + let value = await dispatcher.run { 7 } + XCTAssertEqual(try? value.get(), 7) + + let failure = await dispatcher.run { () -> Int in throw TestError.unimplemented } + switch failure { + case .success: XCTFail("expected the thrown error to propagate") + case .failure(let error): XCTAssertTrue(error is TestError) + } + } +} diff --git a/apple/Tests/ProgressDerivationTests.swift b/apple/Tests/ProgressDerivationTests.swift index 4a20940..ea54eb8 100644 --- a/apple/Tests/ProgressDerivationTests.swift +++ b/apple/Tests/ProgressDerivationTests.swift @@ -40,7 +40,7 @@ final class ProgressDerivationTests: XCTestCase { event(phase: "import", kind: "started", json: "{}"), ] let progress = progressForTransfer(events: events, transferId: 1) - XCTAssertEqual(progress?.labelKey, "progress_preparing") + XCTAssertEqual(progress?.labelKey, L10n.Progress.preparing) XCTAssertEqual(progress?.progress, 0.3) } @@ -57,15 +57,15 @@ final class ProgressDerivationTests: XCTestCase { remoteEndpointId: "peer-a", totalSizeHint: 100 ) - XCTAssertEqual(progress?.kind, "completed") - XCTAssertEqual(progress?.labelKey, "progress_completed") + XCTAssertEqual(progress?.kind, .completed) + XCTAssertEqual(progress?.labelKey, L10n.Progress.completed) XCTAssertEqual(progress?.progress, 1) } func testStatusLabelKeys() { - XCTAssertEqual(statusLabelKey(.sharing), "status_available") - XCTAssertEqual(statusLabelKey(.receiving), "status_receiving") - XCTAssertEqual(statusLabelKey(.done), "status_completed") + XCTAssertEqual(statusLabelKey(.sharing), L10n.Status.available) + XCTAssertEqual(statusLabelKey(.receiving), L10n.Status.receiving) + XCTAssertEqual(statusLabelKey(.done), L10n.Status.completed) } private func event(phase: String, kind: String, json: String) -> CoreEventModel { diff --git a/apple/Tests/ReceiveModelTests.swift b/apple/Tests/ReceiveModelTests.swift index 0a83491..cb4975e 100644 --- a/apple/Tests/ReceiveModelTests.swift +++ b/apple/Tests/ReceiveModelTests.swift @@ -24,6 +24,9 @@ final class ReceiveModelTests: XCTestCase { XCTAssertEqual(model.state.historyDeleteTarget, .transfer(transferId: 5)) model.confirmHistoryDelete() + // Must close immediately (not after the async delete) so the alert can't + // re-present on macOS. + XCTAssertNil(model.state.historyDeleteTarget) await waitUntil { core.deletedTransfers.contains(5) } XCTAssertEqual(core.deletedTransfers, [5]) XCTAssertNil(model.state.historyDeleteTarget) diff --git a/apple/Tests/SendModelTests.swift b/apple/Tests/SendModelTests.swift index 859d868..641dbf2 100644 --- a/apple/Tests/SendModelTests.swift +++ b/apple/Tests/SendModelTests.swift @@ -32,6 +32,9 @@ final class SendModelTests: XCTestCase { XCTAssertTrue(model.state.isDeleteConfirmationOpen) model.confirmDeleteTransfer() + // Must close immediately (not after the async delete) so the alert can't + // re-present on macOS. + XCTAssertFalse(model.state.isDeleteConfirmationOpen) await waitUntil { core.deletedTransfers.contains(3) } XCTAssertEqual(core.deletedTransfers, [3]) XCTAssertNil(model.state.selectedTransferId) diff --git a/apple/Tests/TransferNotificationTests.swift b/apple/Tests/TransferNotificationTests.swift new file mode 100644 index 0000000..931461d --- /dev/null +++ b/apple/Tests/TransferNotificationTests.swift @@ -0,0 +1,51 @@ +import XCTest +@testable import VniDrop + +@MainActor +final class TransferNotificationTests: XCTestCase { + + func testTransferNotificationsFireForTerminalStatesOnly() { + let transfers = [ + Fixtures.transfer(id: 1, direction: .send, status: .failed), + Fixtures.transfer(id: 2, direction: .receive, status: .done), + Fixtures.transfer(id: 3, direction: .receive, status: .failed), + Fixtures.transfer(id: 4, direction: .receive, status: .receiving), // in-flight, ignored + Fixtures.transfer(id: 5, direction: .send, status: .sharing), // active share, ignored + Fixtures.transfer(id: 6, direction: .send, status: .done), // send-done isn't notified + ] + let planned = plannedTransferNotifications(transfers, published: []) + XCTAssertEqual(planned.map(\.kind), [.sendFailed, .receiveCompleted, .receiveFailed]) + XCTAssertEqual(planned.map(\.id), ["send-failed-1", "receive-completed-2", "receive-failed-3"]) + XCTAssertEqual(planned.first?.transferName, "Photos") + } + + func testTransferNotificationsSkipAlreadyPublished() { + let transfers = [Fixtures.transfer(id: 2, direction: .receive, status: .done)] + XCTAssertTrue(plannedTransferNotifications(transfers, published: ["receive-completed-2"]).isEmpty) + } + + func testReceiverNotificationsFireOnlyForCompletedReceivers() { + let requests = [ + Fixtures.request(id: "a", requestedAt: 1, status: .completed), + Fixtures.request(id: "b", requestedAt: 2, status: .accepted), + Fixtures.request(id: "c", requestedAt: 3, status: .requested), + ] + let planned = plannedReceiverNotifications(requests, published: []) + XCTAssertEqual(planned.map(\.id), ["receiver-completed-a"]) + XCTAssertEqual(planned.first?.kind, .receiverCompleted) + XCTAssertEqual(planned.first?.receiver, "Peer") + XCTAssertEqual(planned.first?.transferName, "Photos") + } + + func testReceiverNotificationsSkipAlreadyPublished() { + let requests = [Fixtures.request(id: "a", requestedAt: 1, status: .completed)] + XCTAssertTrue(plannedReceiverNotifications(requests, published: ["receiver-completed-a"]).isEmpty) + } + + func testReceiverNotificationsFireForFailedReceivers() { + let requests = [Fixtures.request(id: "x", requestedAt: 1, status: .failed)] + let planned = plannedReceiverNotifications(requests, published: []) + XCTAssertEqual(planned.map(\.id), ["receiver-failed-x"]) + XCTAssertEqual(planned.first?.kind, .receiverFailed) + } +} diff --git a/apple/Tests/UiFeedbackTests.swift b/apple/Tests/UiFeedbackTests.swift index 7c1df1e..fdbbfbe 100644 --- a/apple/Tests/UiFeedbackTests.swift +++ b/apple/Tests/UiFeedbackTests.swift @@ -42,22 +42,22 @@ final class UserFacingErrorTests: XCTestCase { } func testToUiTextMapsKnownReasons() { - XCTAssertEqual(InvitationError.message("The transfer was refused").toUiText(), .resource("error_permission")) - XCTAssertEqual(InvitationError.message("invalid ticket").toUiText(), .resource("error_invalid_ticket")) - XCTAssertEqual(InvitationError.message("Select at least one file to share").toUiText(), .resource("error_share_empty")) - XCTAssertEqual(InvitationError.message("Camera access is required").toUiText(), .resource("error_camera")) + XCTAssertEqual(InvitationError.message("The transfer was refused").toUiText(), .resource(L10n.Error.permission)) + XCTAssertEqual(InvitationError.message("invalid ticket").toUiText(), .resource(L10n.Error.invalidTicket)) + XCTAssertEqual(InvitationError.message("Select at least one file to share").toUiText(), .resource(L10n.Error.shareEmpty)) + XCTAssertEqual(InvitationError.message("Camera access is required").toUiText(), .resource(L10n.Error.camera)) } func testToUiTextFallsBackToGeneric() { - XCTAssertEqual(InvitationError.message("something entirely unexpected").toUiText(), .resource("error_generic")) + XCTAssertEqual(InvitationError.message("something entirely unexpected").toUiText(), .resource(L10n.Error.generic)) } func testToUiTextMapsTypedTransferFailures() { - XCTAssertEqual(VnidropError.FilesystemPermission(reason: "read-only folder").toUiText(), .resource("error_filesystem")) - XCTAssertEqual(VnidropError.DestinationExists(reason: "target exists").toUiText(), .resource("error_destination_exists")) - XCTAssertEqual(VnidropError.StorageFull(reason: "disk full").toUiText(), .resource("error_storage_full")) - XCTAssertEqual(VnidropError.Network(reason: "offline").toUiText(), .resource("error_network")) - XCTAssertEqual(VnidropError.InvalidInput(reason: "bad path").toUiText(), .resource("error_invalid_input")) + XCTAssertEqual(VnidropError.FilesystemPermission(reason: "read-only folder").toUiText(), .resource(L10n.Error.filesystem)) + XCTAssertEqual(VnidropError.DestinationExists(reason: "target exists").toUiText(), .resource(L10n.Error.destinationExists)) + XCTAssertEqual(VnidropError.StorageFull(reason: "disk full").toUiText(), .resource(L10n.Error.storageFull)) + XCTAssertEqual(VnidropError.Network(reason: "offline").toUiText(), .resource(L10n.Error.network)) + XCTAssertEqual(VnidropError.InvalidInput(reason: "bad path").toUiText(), .resource(L10n.Error.invalidInput)) XCTAssertFalse(VnidropError.FilesystemPermission(reason: "read-only").canRetryWithoutChangingInput) XCTAssertFalse(VnidropError.DestinationExists(reason: "target exists").canRetryWithoutChangingInput) XCTAssertTrue(VnidropError.Network(reason: "offline").canRetryWithoutChangingInput) diff --git a/apple/VniDrop/App/AppGraph.swift b/apple/VniDrop/App/AppGraph.swift index dd029a9..d261499 100644 --- a/apple/VniDrop/App/AppGraph.swift +++ b/apple/VniDrop/App/AppGraph.swift @@ -12,6 +12,7 @@ final class AppGraph: ObservableObject { let preferencesRepository: AppPreferencesRepository let filePreviewRepository: FilePreviewRepository let approvalCoordinator: ApprovalCoordinator + let transferNotificationCoordinator: TransferNotificationCoordinator init(dependencies: AppDependencies, coreRepository: CoreRepository? = nil) { self.dependencies = dependencies @@ -23,13 +24,17 @@ final class AppGraph: ObservableObject { username: dependencies.environment.defaultUsername, receiveFolder: dependencies.fileSystemService.defaultReceiveFolder(), themeMode: .system, - notificationsEnabled: false, diagnosticsEnabled: false ) ) self.approvalCoordinator = ApprovalCoordinator( repository: coreRepository, - preferences: preferencesRepository, + notifications: dependencies.notificationService, + visibility: visibility, + messages: messages + ) + self.transferNotificationCoordinator = TransferNotificationCoordinator( + repository: coreRepository, notifications: dependencies.notificationService, visibility: visibility, messages: messages diff --git a/apple/VniDrop/App/RootView.swift b/apple/VniDrop/App/RootView.swift index 5a77d2d..4954e07 100644 --- a/apple/VniDrop/App/RootView.swift +++ b/apple/VniDrop/App/RootView.swift @@ -1,3 +1,4 @@ +import SFSafeSymbols import SwiftUI /// App root, ported from `App.kt`. Owns the object graph and feature models, wires @@ -62,6 +63,14 @@ struct RootView: View { onRefuse: approvals.refuse ) } + .overlay { + // A small, unobtrusive indicator while the core finishes its async + // startup — otherwise the lists look empty and the app feels stalled. + if !sendModel.coreState.isInitialized { + CoreStartingOverlay() + } + } + .animation(.easeInOut(duration: 0.25), value: sendModel.coreState.isInitialized) .vniDropTheme(isDark: isDark) .preferredColorScheme(appModel.themeMode.preferredColorScheme) .environment(\.vniColors, isDark ? .dark : .light) @@ -111,7 +120,7 @@ struct RootView: View { #if os(macOS) NavigationSplitView { List(AppDestination.allCases, selection: sidebarBinding) { destination in - Label(LocalizedStringKey(destination.labelKey), systemImage: destination.systemImage) + Label(String(localized: destination.labelKey), systemSymbol: destination.systemSymbol) .tag(destination) } .navigationSplitViewColumnWidth(min: 180, ideal: 200, max: 260) @@ -123,7 +132,7 @@ struct RootView: View { ForEach(AppDestination.allCases) { destination in screen(for: destination, windowClass: windowClass) .tabItem { - Label(LocalizedStringKey(destination.labelKey), systemImage: destination.systemImage) + Label(String(localized: destination.labelKey), systemSymbol: destination.systemSymbol) } .tag(destination) } @@ -182,6 +191,32 @@ struct RootView: View { } } +/// A full-window cover with a centered spinner shown while the core is starting. +private struct CoreStartingOverlay: View { + var body: some View { + ZStack { + backgroundColor.ignoresSafeArea() + VStack(spacing: 16) { + ProgressView().controlSize(.large) + Text(String(localized: L10n.App.starting)) + .font(.headline) + .foregroundStyle(.secondary) + } + } + .transition(.opacity) + .accessibilityElement(children: .combine) + .accessibilityLabel(Text(String(localized: L10n.App.starting))) + } + + private var backgroundColor: Color { + #if os(iOS) + Color(uiColor: .systemBackground) + #else + Color(nsColor: .windowBackgroundColor) + #endif + } +} + #if os(iOS) import UIKit #else diff --git a/apple/VniDrop/App/VniDropApp.swift b/apple/VniDrop/App/VniDropApp.swift index 0119980..3882b17 100644 --- a/apple/VniDrop/App/VniDropApp.swift +++ b/apple/VniDrop/App/VniDropApp.swift @@ -1,5 +1,8 @@ import SwiftUI +/// Scene identifier for the single main window. +private let mainWindowId = "main" + /// Native app entry point for iOS, iPadOS, and macOS. /// Opens `.vnd` invitations via `onOpenURL` and routes them to the receive flow. @main @@ -7,11 +10,21 @@ struct VniDropApp: App { @StateObject private var externalInvitations = ExternalInvitationController() var body: some Scene { - WindowGroup { + #if os(macOS) + // A single-instance `Window` (not `WindowGroup`): the app must never open a + // second window. `Window` also drops the ⌘N "New Window" command. + Window(Text(verbatim: "VniDrop"), id: mainWindowId) { RootView(dependencies: makeAppDependencies(externalInvitations: externalInvitations)) .ignoresSafeArea() .onOpenURL(perform: openInvitation) } + #else + WindowGroup(id: mainWindowId) { + RootView(dependencies: makeAppDependencies(externalInvitations: externalInvitations)) + .ignoresSafeArea() + .onOpenURL(perform: openInvitation) + } + #endif } /// Reads a `.vnd` invitation document under a security scope, enforcing the diff --git a/apple/VniDrop/Core/AppPreferences.swift b/apple/VniDrop/Core/AppPreferences.swift index cdd87e0..df56a2c 100644 --- a/apple/VniDrop/Core/AppPreferences.swift +++ b/apple/VniDrop/Core/AppPreferences.swift @@ -120,7 +120,6 @@ struct AppPreferences: Equatable { var username: String var receiveFolder: ReceiveFolder var themeMode: ThemeMode - var notificationsEnabled: Bool var diagnosticsEnabled: Bool var diagnosticsInstallId: String var relayConfiguration: RelayConfiguration @@ -130,7 +129,6 @@ struct AppPreferencesDefaults { let username: String let receiveFolder: ReceiveFolder let themeMode: ThemeMode - var notificationsEnabled: Bool = false var diagnosticsEnabled: Bool = false } @@ -147,7 +145,6 @@ final class AppPreferencesRepository: ObservableObject { static let receiveFolderValue = "receive_folder_value" static let receiveFolderDisplayName = "receive_folder_display_name" static let themeMode = "theme_mode" - static let notificationsEnabled = "notifications_enabled" static let diagnosticsEnabled = "diagnostics_enabled" static let diagnosticsInstallId = "diagnostics_install_id" static let relayConfiguration = "relay_configuration" @@ -163,14 +160,12 @@ final class AppPreferencesRepository: ObservableObject { let username = (defaults.string(forKey: Key.username)).flatMap { $0.isEmpty ? nil : $0 } ?? fallback.username let folder = resolveReceiveFolder(defaults, fallback: fallback.receiveFolder) let themeMode = defaults.string(forKey: Key.themeMode).flatMap(ThemeMode.init(rawValue:)) ?? fallback.themeMode - let notifications = defaults.object(forKey: Key.notificationsEnabled) as? Bool ?? fallback.notificationsEnabled let diagnostics = defaults.object(forKey: Key.diagnosticsEnabled) as? Bool ?? fallback.diagnosticsEnabled let installId = defaults.string(forKey: Key.diagnosticsInstallId) ?? "" return AppPreferences( username: username, receiveFolder: folder, themeMode: themeMode, - notificationsEnabled: notifications, diagnosticsEnabled: diagnostics, diagnosticsInstallId: installId, relayConfiguration: resolveRelayConfiguration(defaults) @@ -224,11 +219,6 @@ final class AppPreferencesRepository: ObservableObject { reload() } - func setNotificationsEnabled(_ enabled: Bool) { - defaults.set(enabled, forKey: Key.notificationsEnabled) - reload() - } - func setDiagnosticsEnabled(_ enabled: Bool) { defaults.set(enabled, forKey: Key.diagnosticsEnabled) reload() diff --git a/apple/VniDrop/Core/CoreDispatcher.swift b/apple/VniDrop/Core/CoreDispatcher.swift new file mode 100644 index 0000000..efffaea --- /dev/null +++ b/apple/VniDrop/Core/CoreDispatcher.swift @@ -0,0 +1,41 @@ +import Foundation + +/// Dispatch-queue labels for the core's serial and interrupt lanes. +enum QueueLabel { + static let core = "com.vnidrop.core" + static let interrupt = "com.vnidrop.core.interrupt" +} + +/// Two-lane dispatcher for blocking core calls. +/// +/// `run` serializes calls on one queue so the core is driven from a single lane. +/// `runInterrupt` uses a *separate* concurrent lane, so an interrupt-style call +/// (cancel) can reach the core while a blocking call (`receive`) still occupies +/// the serial lane. The core is internally synchronized and explicitly supports +/// cancel arriving from another thread mid-receive (see VnidropCore.block_on); +/// a single shared queue would deadlock it. +final class CoreDispatcher: Sendable { + private let serialQueue: DispatchQueue + private let interruptQueue: DispatchQueue + + init(label: String = QueueLabel.core, interruptLabel: String = QueueLabel.interrupt) { + serialQueue = DispatchQueue(label: label, qos: .userInitiated) + interruptQueue = DispatchQueue(label: interruptLabel, qos: .userInitiated, attributes: .concurrent) + } + + /// Runs a blocking core call on the serial lane and hops the result back. + func run(_ block: @escaping @Sendable () throws -> T) async -> Result { + await withCheckedContinuation { continuation in + serialQueue.async { continuation.resume(returning: Result { try block() }) } + } + } + + /// Like `run`, but off the serial lane so it can interrupt a blocking call in + /// flight there (e.g. cancel a `receive`). Only use for core calls that are + /// safe to run concurrently with another core call. + func runInterrupt(_ block: @escaping @Sendable () throws -> T) async -> Result { + await withCheckedContinuation { continuation in + interruptQueue.async { continuation.resume(returning: Result { try block() }) } + } + } +} diff --git a/apple/VniDrop/Core/CoreModels.swift b/apple/VniDrop/Core/CoreModels.swift index c2cda88..ca594f2 100644 --- a/apple/VniDrop/Core/CoreModels.swift +++ b/apple/VniDrop/Core/CoreModels.swift @@ -15,10 +15,56 @@ struct CoreEventModel: Equatable, Identifiable, Sendable { let timestamp: Int64 let scope: String let transferId: UInt64? + /// Raw wire values as emitted by the core. Interpret them through the typed + /// `eventDirection` / `eventPhase` / `eventKind` accessors below — logic code + /// should never compare these strings directly. let direction: String? let phase: String let kind: String let dataJson: String + + var eventDirection: EventDirection? { direction.flatMap(EventDirection.init(rawValue:)) } + var eventPhase: EventPhase? { EventPhase(rawValue: phase) } + var eventKind: EventKind? { EventKind(rawValue: kind) } +} + +/// Direction of a core event, matching the wire strings the core emits. +enum EventDirection: String, Equatable, Sendable { + case send + case receive +} + +/// Phase of a core progress event (the `phase` wire field). +enum EventPhase: String, Equatable, Sendable { + case importing = "import" + case ticket + case access + case transfer + case download + case export + case lifecycle + case network + case handshake + case error +} + +/// Kind of a core progress event (the `kind` wire field). +enum EventKind: String, Equatable, Sendable { + case started + case copyProgress = "copy-progress" + case copyDone = "copy-done" + case outboardProgress = "outboard-progress" + case done + case created + case progress + case completed + case aborted + case failed + case connecting + case connected + case foundCollection = "found-collection" + case cancelled + case shareStopped = "share-stopped" } enum ShareAccessPolicy: Equatable, Sendable { diff --git a/apple/VniDrop/Core/CoreRepository.swift b/apple/VniDrop/Core/CoreRepository.swift index e7751ad..ed9c81a 100644 --- a/apple/VniDrop/Core/CoreRepository.swift +++ b/apple/VniDrop/Core/CoreRepository.swift @@ -80,7 +80,9 @@ final class CoreRepository: ObservableObject, CoreGateway { // access the handle from the main actor. The underlying core is internally // synchronized, and `nonisolated(unsafe)` documents that crossing for Swift 6. private nonisolated(unsafe) var core: VnidropCore? - private let queue = DispatchQueue(label: "com.vnidrop.core", qos: .userInitiated) + // Core calls run through `dispatcher` (see runCore/runInterrupt); the factory + // and transition flag drive relay-aware (re)initialization. + private let dispatcher = CoreDispatcher() private let coreFactory: any CoreBindingFactory private var isNetworkTransitionInProgress = false private lazy var sink = RepositoryEventSink { [weak self] event in @@ -222,7 +224,9 @@ final class CoreRepository: ObservableObject, CoreGateway { // MARK: - Lifecycle actions func cancel(transferId: UInt64) async -> Result { - await runCore { + // Off the serial `queue`: a receive in flight is blocking it, and the + // cancel signal must reach the core to unblock that receive. + await runInterrupt { try self.requireCore().cancelTransfer(transferId: transferId) }.map { self.refreshSnapshot() } } @@ -356,17 +360,14 @@ final class CoreRepository: ObservableObject, CoreGateway { /// Runs a blocking core call off the main actor and hops the result back. private nonisolated func runCore(_ block: @escaping @Sendable () throws -> T) async -> Result { - await withCheckedContinuation { continuation in - queue.async { - let result: Result - do { - result = .success(try block()) - } catch { - result = .failure(error) - } - continuation.resume(returning: result) - } - } + await dispatcher.run(block) + } + + /// Like `runCore`, but off the serial lane so it can interrupt a blocking + /// call in flight there (e.g. cancel a `receive`). Only use for core calls + /// that are safe to run concurrently with another core call. + private nonisolated func runInterrupt(_ block: @escaping @Sendable () throws -> T) async -> Result { + await dispatcher.runInterrupt(block) } private nonisolated static func nextTransferId() -> UInt64 { @@ -401,14 +402,15 @@ private extension CoreEvent { } } -private let refreshPhases: Set = ["lifecycle", "error", "ticket", "import", "download", "export", "handshake"] -private let refreshKinds: Set = [ - "started", "done", "created", "failed", "cancelled", "share-stopped", "found-collection", "connected", +private let refreshPhases: Set = [.lifecycle, .error, .ticket, .importing, .download, .export, .handshake] +private let refreshKinds: Set = [ + .started, .done, .created, .failed, .cancelled, .shareStopped, .foundCollection, .connected, ] private extension CoreEventModel { var shouldRefreshTransfers: Bool { - refreshPhases.contains(phase) && refreshKinds.contains(kind) + guard let eventPhase, let eventKind else { return false } + return refreshPhases.contains(eventPhase) && refreshKinds.contains(eventKind) } } diff --git a/apple/VniDrop/Core/LocalNotificationService.swift b/apple/VniDrop/Core/LocalNotificationService.swift index 4f04f68..8b246e2 100644 --- a/apple/VniDrop/Core/LocalNotificationService.swift +++ b/apple/VniDrop/Core/LocalNotificationService.swift @@ -16,12 +16,53 @@ struct LocalNotification { let body: String } +/// Presents notifications even while the app is active. Without a delegate the +/// system drops the banner when the app is frontmost — very visible on macOS, +/// where the app window is usually open when a transfer completes. +private final class NotificationPresenter: NSObject, UNUserNotificationCenterDelegate { + func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification + ) async -> UNNotificationPresentationOptions { + [.banner, .sound, .list] + } + + /// Handle a notification tap inside the running instance and bring the existing + /// window forward, rather than letting the default launch behavior surface (which + /// on macOS can spin up a second process). The approval/transfer UI is driven by + /// core state, so activating the window is enough to reveal a pending approval. + func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse + ) async { + #if os(macOS) + await MainActor.run { + NSApp.activate(ignoringOtherApps: true) + // Reopen/focus the single main window (activation triggers SwiftUI's + // reopen handling when it was closed). + for window in NSApp.windows where window.canBecomeMain { + window.makeKeyAndOrderFront(nil) + break + } + } + #endif + } +} + /// Local notification service backed by `UNUserNotificationCenter`. @MainActor final class LocalNotificationService: ObservableObject { @Published private(set) var permission: NotificationPermission = .notDetermined private let center = UNUserNotificationCenter.current() + private let presenter = NotificationPresenter() + + init() { + center.delegate = presenter + // Seed the permission immediately so gating (approval/lifecycle + // notifications) never races a not-yet-refreshed `.notDetermined`. + Task { _ = await refreshPermission() } + } func refreshPermission() async -> NotificationPermission { let settings = await center.notificationSettings() @@ -75,11 +116,6 @@ final class LocalNotificationService: ObservableObject { center.removeDeliveredNotifications(withIdentifiers: [id]) } - func cancelAll() { - center.removeAllPendingNotificationRequests() - center.removeAllDeliveredNotifications() - } - private static func map(_ status: UNAuthorizationStatus) -> NotificationPermission { switch status { case .authorized, .provisional, .ephemeral: return .granted diff --git a/apple/VniDrop/Core/TransferProgress.swift b/apple/VniDrop/Core/TransferProgress.swift index 6c5dabd..c8eebbb 100644 --- a/apple/VniDrop/Core/TransferProgress.swift +++ b/apple/VniDrop/Core/TransferProgress.swift @@ -18,9 +18,9 @@ func windowClassFor(width: Double) -> WindowClass { /// resolved at the view layer. struct TransferProgress: Equatable { let transferId: UInt64? - let phase: String - let kind: String - let labelKey: String + let phase: EventPhase + let kind: EventKind + let labelKey: String.LocalizationValue let progress: Double? var detail: String? = nil /// Pre-resolved label that overrides `labelKey` when set (e.g. "Sending to 2", @@ -28,44 +28,31 @@ struct TransferProgress: Equatable { var label: String? = nil } -func statusLabelKey(_ status: TransferStatus) -> String { +func statusLabelKey(_ status: TransferStatus) -> String.LocalizationValue { switch status { - case .importing: return "status_preparing" - case .sharing: return "status_available" - case .receiving: return "status_receiving" - case .done: return "status_completed" - case .cancelled: return "status_cancelled" - case .stopped: return "status_stopped" - case .failed: return "status_failed" + case .importing: return L10n.Status.preparing + case .sharing: return L10n.Status.available + case .receiving: return L10n.Status.receiving + case .done: return L10n.Status.completed + case .cancelled: return L10n.Status.cancelled + case .stopped: return L10n.Status.stopped + case .failed: return L10n.Status.failed } } -private let progressPhases: Set = [ - "import", "ticket", "access", "transfer", "download", "export", - "lifecycle", "network", "handshake", "error", -] - -private let progressKinds: Set = [ - "started", "copy-progress", "copy-done", "outboard-progress", "done", - "created", "progress", "completed", "aborted", "failed", - "connecting", "connected", "found-collection", - "cancelled", "share-stopped", -] - -/// Latest progress snapshot for a transfer. Events are newest-first. +/// Latest progress snapshot for a transfer. Events are newest-first. Only events +/// whose `phase` and `kind` map to known cases participate. func progressForTransfer(events: [CoreEventModel], transferId: UInt64) -> TransferProgress? { let relevant = events.filter { event in - event.transferId == transferId - && progressPhases.contains(event.phase) - && progressKinds.contains(event.kind) + event.transferId == transferId && event.eventPhase != nil && event.eventKind != nil } - guard let latest = relevant.first else { return nil } + guard let latest = relevant.first, let phase = latest.eventPhase, let kind = latest.eventKind else { return nil } let sizeHint = findKnownSize(events: events, transferId: transferId) return TransferProgress( transferId: transferId, - phase: latest.phase, - kind: latest.kind, - labelKey: humanProgressLabel(latest), + phase: phase, + kind: kind, + labelKey: humanProgressLabel(phase: phase, kind: kind), progress: parseProgress(latest.dataJson, sizeHint: sizeHint), detail: progressDetail(latest) ) @@ -80,33 +67,34 @@ func progressForReceiver( ) -> TransferProgress? { if remoteEndpointId.isEmpty { return nil } let connectionIds = connectionIdsForEndpoint(events: events, remoteEndpointId: remoteEndpointId) + let receiverKinds: Set = [.started, .progress, .completed, .aborted] let transferEvents = events.filter { event in event.transferId == transferId - && event.direction == "send" - && event.phase == "transfer" - && ["started", "progress", "completed", "aborted"].contains(event.kind) + && event.eventDirection == .send + && event.eventPhase == .transfer + && (event.eventKind.map(receiverKinds.contains) ?? false) && eventBelongsToReceiver(event, remoteEndpointId: remoteEndpointId, connectionIds: connectionIds) } - if transferEvents.isEmpty { return nil } - - let latest = transferEvents[0] - if latest.kind == "aborted" { + guard let latest = transferEvents.first, let latestKind = latest.eventKind else { return nil } + if latestKind == .aborted { return TransferProgress( - transferId: transferId, phase: "transfer", kind: "aborted", - labelKey: "progress_interrupted", progress: nil, detail: nil + transferId: transferId, phase: .transfer, kind: .aborted, + labelKey: L10n.Progress.interrupted, progress: nil, detail: nil + ) + } + // Events are newest-first, so a completed latest event is terminal even when + // progress/started events precede it — it must show as Completed, not Sending. + if latestKind == .completed { + return TransferProgress( + transferId: transferId, phase: .transfer, kind: .completed, + labelKey: L10n.Progress.completed, progress: 1, detail: nil ) } let progress = aggregateReceiverProgress(events: transferEvents, totalSizeHint: totalSizeHint) - if latest.kind == "completed" && (progress.map { $0 >= 0.999 } ?? true) { - return TransferProgress( - transferId: transferId, phase: "transfer", kind: "completed", - labelKey: "progress_completed", progress: 1, detail: nil - ) - } return TransferProgress( - transferId: transferId, phase: "transfer", kind: latest.kind, - labelKey: "progress_sending", progress: progress, detail: progressDetail(latest) + transferId: transferId, phase: .transfer, kind: latestKind, + labelKey: L10n.Progress.sending, progress: progress, detail: progressDetail(latest) ) } @@ -127,26 +115,26 @@ func formatBytes(_ size: UInt64) -> String { // MARK: - Internals (ported literally from AppUiModels.kt) -private func humanProgressLabel(_ event: CoreEventModel) -> String { - switch (event.phase, event.kind) { - case ("import", "copy-progress"), ("import", "outboard-progress"), ("import", "started"): - return "progress_preparing" - case ("import", "done"): return "progress_ready" - case ("ticket", "created"): return "progress_share_ready" - case ("network", "connecting"): return "progress_connecting" - case ("network", "connected"): return "progress_connected" - case ("download", "found-collection"): return "progress_getting_ready" - case ("download", "progress"): return "progress_downloading" - case ("export", "progress"): return "progress_saving" - case ("transfer", "progress"): return "progress_sending" - case ("transfer", "started"): return "progress_connected" - case ("transfer", "completed"): return "progress_completed" - case ("lifecycle", "done"): return "progress_completed" - case ("lifecycle", "cancelled"): return "progress_cancelled" +private func humanProgressLabel(phase: EventPhase, kind: EventKind) -> String.LocalizationValue { + switch (phase, kind) { + case (.importing, .copyProgress), (.importing, .outboardProgress), (.importing, .started): + return L10n.Progress.preparing + case (.importing, .done): return L10n.Progress.ready + case (.ticket, .created): return L10n.Progress.shareReady + case (.network, .connecting): return L10n.Progress.connecting + case (.network, .connected): return L10n.Progress.connected + case (.download, .foundCollection): return L10n.Progress.gettingReady + case (.download, .progress): return L10n.Progress.downloading + case (.export, .progress): return L10n.Progress.saving + case (.transfer, .progress): return L10n.Progress.sending + case (.transfer, .started): return L10n.Progress.connected + case (.transfer, .completed): return L10n.Progress.completed + case (.lifecycle, .done): return L10n.Progress.completed + case (.lifecycle, .cancelled): return L10n.Progress.cancelled default: - if event.phase == "handshake" { return "progress_requesting_access" } - if event.kind == "failed" { return "progress_failed" } - return "progress_working" + if phase == .handshake { return L10n.Progress.requestingAccess } + if kind == .failed { return L10n.Progress.failed } + return L10n.Progress.working } } @@ -217,14 +205,14 @@ private func aggregateReceiverProgress(events: [CoreEventModel], totalSizeHint: order.append(requestKey) } if let size, size > 0 { state.size = size } - switch event.kind { - case "progress", "started": + switch event.eventKind { + case .progress, .started: if let endOffset { state.offset = max(state.offset, endOffset) } state.aborted = false - case "completed": + case .completed: state.completed = true if let s = state.size { state.offset = s } - case "aborted": + case .aborted: state.aborted = true default: break diff --git a/apple/VniDrop/Features/Approvals/ApprovalCoordinator.swift b/apple/VniDrop/Features/Approvals/ApprovalCoordinator.swift index 0417987..5b2bd7d 100644 --- a/apple/VniDrop/Features/Approvals/ApprovalCoordinator.swift +++ b/apple/VniDrop/Features/Approvals/ApprovalCoordinator.swift @@ -27,7 +27,6 @@ final class ApprovalCoordinator: ObservableObject { @Published private(set) var state = ApprovalState() private let repository: CoreGateway - private let preferences: AppPreferencesRepository private let notifications: LocalNotificationService private let visibility: AppVisibility private let messages: UiMessageController @@ -37,13 +36,11 @@ final class ApprovalCoordinator: ObservableObject { init( repository: CoreGateway, - preferences: AppPreferencesRepository, notifications: LocalNotificationService, visibility: AppVisibility, messages: UiMessageController ) { self.repository = repository - self.preferences = preferences self.notifications = notifications self.visibility = visibility self.messages = messages @@ -68,17 +65,15 @@ final class ApprovalCoordinator: ObservableObject { .store(in: &cancellables) // Recompute notifications when any input changes. - Publishers.CombineLatest4( - preferences.$preferences, + Publishers.CombineLatest3( visibility.$isForeground, $state, notifications.$permission ) - .sink { [weak self] preferences, foreground, approvalState, permission in + .sink { [weak self] foreground, approvalState, permission in guard let self else { return } Task { await self.synchronizeNotifications( - enabled: preferences.notificationsEnabled, foreground: foreground, pending: approvalState.pending, permission: permission @@ -137,30 +132,41 @@ final class ApprovalCoordinator: ObservableObject { } private func synchronizeNotifications( - enabled: Bool, foreground: Bool, pending: [PendingApproval], permission: NotificationPermission ) async { - if foreground || !enabled || permission != .granted { - notifications.cancelAll() + // iOS suppresses notifications while the user is in the app (the modal shows + // instead); macOS presents them even when active (the app window is usually + // open), relying on the presenter delegate. + #if os(iOS) + let suppressed = foreground || permission != .granted + #else + let suppressed = permission != .granted + #endif + if suppressed { + // Cancel only our own approval notifications — other coordinators + // (e.g. transfer-lifecycle) manage their own and must not be wiped. + for id in publishedNotificationIds { notifications.cancel(id: Self.notificationId(id)) } return } for request in pending where !publishedNotificationIds.contains(request.id) { + // Reserve the id *before* awaiting: the CombineLatest can fire several + // times near-simultaneously, and without this each pass re-adds the same + // notification identifier. macOS coalesces a repeated add of an in-flight + // id into a silent update and shows no banner. + publishedNotificationIds.insert(request.id) let receiver = request.receiverName ?? request.receiverDeviceName - ?? String(localized: "approval_nearby_device") - let title = String(localized: "approval_connection_request") - let body = String( - format: String(localized: "approval_request_body"), - receiver, request.transferName - ) + ?? String(localized: L10n.Approval.nearbyDevice) + let title = String(localized: L10n.Approval.connectionRequest) + let body = L10n.Approval.requestBody(receiver: receiver, transferName: request.transferName) let result = await notifications.publish( LocalNotification(id: Self.notificationId(request.id), title: title, body: body) ) - switch result { - case .success: publishedNotificationIds.insert(request.id) - case .failure(let error): messages.error(error) + if case .failure(let error) = result { + publishedNotificationIds.remove(request.id) + messages.error(error) } } } diff --git a/apple/VniDrop/Features/Approvals/ApprovalModal.swift b/apple/VniDrop/Features/Approvals/ApprovalModal.swift index e317edf..53a6e01 100644 --- a/apple/VniDrop/Features/Approvals/ApprovalModal.swift +++ b/apple/VniDrop/Features/Approvals/ApprovalModal.swift @@ -1,4 +1,5 @@ import SwiftUI +import SFSafeSymbols /// Non-dismissable receiver-approval modal, presented as a native sheet that can't /// be swiped away. The endpoint id is the trusted identity; display names are @@ -38,32 +39,32 @@ private struct ApprovalSheet: View { var body: some View { let busy = state.respondingIds.contains(request.id) - let receiver = request.receiverName ?? request.receiverDeviceName ?? String(localized: "approval_nearby_device") + let receiver = request.receiverName ?? request.receiverDeviceName ?? String(localized: L10n.Approval.nearbyDevice) VStack(spacing: 16) { - Image(systemName: "checkmark.shield.fill") + Image(systemSymbol: .checkmarkShieldFill) .font(.system(size: 44)) .foregroundStyle(.tint) .padding(.top, 12) - Text(LocalizedStringKey("approval_connection_request")) + Text(String(localized: L10n.Approval.connectionRequest)) .font(.title2).fontWeight(.semibold) - Text(String(format: String(localized: "approval_request_body"), receiver, request.transferName)) + Text(L10n.Approval.requestBody(receiver: receiver, transferName: request.transferName)) .multilineTextAlignment(.center) - Text(String(format: String(localized: "approval_endpoint_id"), request.remoteEndpointId)) + Text(L10n.Approval.endpointId(deviceId: request.remoteEndpointId)) .font(.caption).foregroundStyle(.secondary) .multilineTextAlignment(.center) if state.pending.count > 1 { - Text(String(format: String(localized: "approval_pending_count"), state.pending.count)) + Text(L10n.Approval.pendingCount(count: state.pending.count)) .font(.caption).foregroundStyle(.secondary) } Spacer(minLength: 0) if busy { ProgressView() } VStack(spacing: 10) { Button(action: { onAccept(request.id) }) { - Text(LocalizedStringKey("button_approve")).frame(maxWidth: .infinity) + Text(String(localized: L10n.Button.approve)).frame(maxWidth: .infinity) } .buttonStyle(.borderedProminent).controlSize(.large).disabled(busy) Button(role: .destructive, action: { onRefuse(request.id) }) { - Text(LocalizedStringKey("button_refuse")).frame(maxWidth: .infinity) + Text(String(localized: L10n.Button.refuse)).frame(maxWidth: .infinity) } .buttonStyle(.bordered).controlSize(.large).disabled(busy) } diff --git a/apple/VniDrop/Features/Notifications/TransferNotificationCoordinator.swift b/apple/VniDrop/Features/Notifications/TransferNotificationCoordinator.swift new file mode 100644 index 0000000..4c52fb7 --- /dev/null +++ b/apple/VniDrop/Features/Notifications/TransferNotificationCoordinator.swift @@ -0,0 +1,198 @@ +import Combine +import Foundation + +/// A transfer-lifecycle moment worth a local notification. +enum TransferNotificationKind: Equatable { + case sendFailed // A share you own failed. + case receiveCompleted // An incoming transfer finished downloading. + case receiveFailed // An incoming transfer failed. + case receiverCompleted // A receiver finished downloading your shared transfer. + case receiverFailed // A receiver's download of your shared transfer failed. +} + +/// A notification resolved from core state but not yet published. `transferName` +/// is the raw name (may be nil); the coordinator localizes and applies fallbacks. +struct PlannedNotification: Equatable { + let id: String + let kind: TransferNotificationKind + let transferName: String? + let receiver: String? +} + +/// Pure: transfer-status notifications for this snapshot, excluding already-published +/// ids. A terminal transfer yields at most one notification, keyed by (kind, id). +func plannedTransferNotifications(_ transfers: [Transfer], published: Set) -> [PlannedNotification] { + transfers.compactMap { transfer in + let kind: TransferNotificationKind + switch (transfer.direction, transfer.status) { + case (.send, .failed): kind = .sendFailed + case (.receive, .done): kind = .receiveCompleted + case (.receive, .failed): kind = .receiveFailed + default: return nil + } + let id = transferNotificationId(kind, transferId: transfer.transferId) + guard !published.contains(id) else { return nil } + return PlannedNotification(id: id, kind: kind, transferName: transfer.transferName, receiver: nil) + } +} + +/// Pure: one notification per receiver that has finished downloading a shared +/// transfer, excluding already-published ids. +func plannedReceiverNotifications(_ requests: [ReceiverRequestModel], published: Set) -> [PlannedNotification] { + requests.compactMap { request in + let kind: TransferNotificationKind + let idPrefix: String + switch request.status { + case .completed: kind = .receiverCompleted; idPrefix = "receiver-completed" + case .failed: kind = .receiverFailed; idPrefix = "receiver-failed" + default: return nil + } + let id = "\(idPrefix)-\(request.id)" + guard !published.contains(id) else { return nil } + return PlannedNotification( + id: id, kind: kind, + transferName: request.transferName, + receiver: request.receiverName ?? request.receiverDeviceName + ) + } +} + +private func transferNotificationId(_ kind: TransferNotificationKind, transferId: UInt64) -> String { + switch kind { + case .sendFailed: return "send-failed-\(transferId)" + case .receiveCompleted: return "receive-completed-\(transferId)" + case .receiveFailed: return "receive-failed-\(transferId)" + case .receiverCompleted: return "receiver-completed-\(transferId)" + case .receiverFailed: return "receiver-failed-\(transferId)" + } +} + +/// Fires local notifications for transfer-lifecycle moments (a receive finishing +/// or failing, a share failing, a receiver completing), so a user who left the +/// app can see the outcome. Approval prompts are handled by `ApprovalCoordinator`. +/// +/// Gated on the OS notification permission (and, on iOS, on being backgrounded). +/// Each moment is terminal, so it is marked seen the first time it is observed and +/// never re-published. The first state snapshot — which includes existing history +/// such as past receives — only primes those ids as seen, so only new transitions +/// notify. +@MainActor +final class TransferNotificationCoordinator: ObservableObject { + private let repository: CoreGateway + private let notifications: LocalNotificationService + private let visibility: AppVisibility + private let messages: UiMessageController + + private var published = Set() + private var primedTransfers = false + private var cancellables = Set() + + init( + repository: CoreGateway, + notifications: LocalNotificationService, + visibility: AppVisibility, + messages: UiMessageController + ) { + self.repository = repository + self.notifications = notifications + self.visibility = visibility + self.messages = messages + + repository.statePublisher + .sink { [weak self] core in + guard let self, core.isInitialized else { return } + Task { await self.syncTransfers(core.transfers) } + } + .store(in: &cancellables) + + repository.signals + .sink { [weak self] signal in + guard let self else { return } + switch signal { + case .receiverHistoryChanged(let transferId), .transfersChanged(let transferId): + Task { await self.syncReceivers(transferId: transferId) } + case .approvalChanged: + break + } + } + .store(in: &cancellables) + } + + /// iOS suppresses notifications while the user is in the app (the convention); + /// macOS presents them even when active (also the convention — the app window + /// is usually open), relying on the presenter delegate to show the banner. + private var canPublish: Bool { + guard notifications.permission == .granted else { return false } + #if os(iOS) + return !visibility.isForeground + #else + return true + #endif + } + + private func syncTransfers(_ transfers: [Transfer]) async { + let planned = plannedTransferNotifications(transfers, published: published) + guard primedTransfers else { + // The first snapshot includes existing history (e.g. past receives). + // Mark those terminal transfers seen without notifying, so only new + // transitions notify. + primedTransfers = true + for plan in planned { published.insert(plan.id) } + return + } + for plan in planned { await deliver(plan) } + } + + private func syncReceivers(transferId: UInt64) async { + let result = await repository.receiverRequests(transferId: transferId) + switch result { + case .success(let requests): + for plan in plannedReceiverNotifications(requests, published: published) { + await deliver(plan) + } + case .failure(let error): + messages.error(error) + } + } + + /// Mark seen unconditionally (a terminal moment notifies at most once), then + /// publish only when the gate allows. + private func deliver(_ plan: PlannedNotification) async { + published.insert(plan.id) + guard canPublish else { return } + let name = plan.transferName ?? String(localized: L10n.Receive.unknownTransfer) + let notification: LocalNotification + switch plan.kind { + case .sendFailed: + notification = LocalNotification( + id: plan.id, + title: String(localized: L10n.Notifications.sendFailedTitle), + body: L10n.Notifications.sendFailedBody(transferName: name)) + case .receiveCompleted: + notification = LocalNotification( + id: plan.id, + title: String(localized: L10n.Notifications.receiveCompletedTitle), + body: L10n.Notifications.receiveCompletedBody(transferName: name)) + case .receiveFailed: + notification = LocalNotification( + id: plan.id, + title: String(localized: L10n.Notifications.receiveFailedTitle), + body: L10n.Notifications.receiveFailedBody(transferName: name)) + case .receiverCompleted: + let receiver = plan.receiver ?? String(localized: L10n.Approval.nearbyDevice) + notification = LocalNotification( + id: plan.id, + title: String(localized: L10n.Notifications.receiverCompletedTitle), + body: L10n.Notifications.receiverCompletedBody(receiver: receiver, transferName: name)) + case .receiverFailed: + let receiver = plan.receiver ?? String(localized: L10n.Approval.nearbyDevice) + notification = LocalNotification( + id: plan.id, + title: String(localized: L10n.Notifications.receiverFailedTitle), + body: L10n.Notifications.receiverFailedBody(receiver: receiver, transferName: name)) + } + if case .failure(let error) = await notifications.publish(notification) { + messages.error(error) + } + } +} diff --git a/apple/VniDrop/Features/Receive/ReceiveInvitationActions.swift b/apple/VniDrop/Features/Receive/ReceiveInvitationActions.swift index 9fb38b7..38141f1 100644 --- a/apple/VniDrop/Features/Receive/ReceiveInvitationActions.swift +++ b/apple/VniDrop/Features/Receive/ReceiveInvitationActions.swift @@ -1,8 +1,9 @@ +import SFSafeSymbols import SwiftUI enum ReceiveMethodAvailability { case available, unavailable, hidden } -/// Invitation acquisition actions shared by the native Apple feature models. +/// Invitation acquisition actions, ported from `ReceiveInvitationActions` (iosMain). @MainActor protocol ReceiveInvitationActions: AnyObject { var fileAvailability: ReceiveMethodAvailability { get } @@ -23,25 +24,25 @@ struct ReceiveMethodPanel: View { var body: some View { VStack(alignment: .leading, spacing: 12) { - Text(LocalizedStringKey("receive_choose_method_title")).font(VniType.titleLarge) - Text(LocalizedStringKey("receive_choose_method_body")).foregroundStyle(colors.foregroundLighter) + Text(String(localized: L10n.Receive.chooseMethodTitle)).font(VniType.titleLarge) + Text(String(localized: L10n.Receive.chooseMethodBody)).foregroundStyle(colors.foregroundLighter) MethodRow( - icon: "doc", titleKey: "receive_method_file", descKey: "receive_method_file_description", + icon: .doc, titleKey: L10n.Receive.methodFile, descKey: L10n.Receive.methodFileDescription, availability: actions.fileAvailability ) { actions.pickInvitation { model.onInvitationResult(.invitationFile, $0) } } if actions.qrAvailability != .hidden { MethodRow( - icon: "qrcode.viewfinder", titleKey: "receive_method_scan", descKey: "receive_method_scan_description", + icon: .qrcodeViewfinder, titleKey: L10n.Receive.methodScan, descKey: L10n.Receive.methodScanDescription, availability: actions.qrAvailability ) { actions.scanQrCode { model.onInvitationResult(.qrCode, $0) } } } if actions.nfcAvailability != .hidden { MethodRow( - icon: "wave.3.right", - titleOverride: model.state.isWaitingForNfc ? String(localized: "receive_nfc_waiting") : nil, - titleKey: "receive_method_nfc", descKey: "receive_method_nfc_description", + icon: .wave3Right, + titleOverride: model.state.isWaitingForNfc ? String(localized: L10n.Receive.nfcWaiting) : nil, + titleKey: L10n.Receive.methodNfc, descKey: L10n.Receive.methodNfcDescription, availability: model.state.isWaitingForNfc ? .unavailable : actions.nfcAvailability ) { model.setWaitingForNfc(true) @@ -56,10 +57,10 @@ struct ReceiveMethodPanel: View { private struct MethodRow: View { @Environment(\.vniColors) private var colors - let icon: String + let icon: SFSymbol var titleOverride: String? = nil - let titleKey: String - let descKey: String + let titleKey: String.LocalizationValue + let descKey: String.LocalizationValue let availability: ReceiveMethodAvailability let onTap: () -> Void @@ -67,20 +68,20 @@ private struct MethodRow: View { let enabled = availability == .available Button(action: onTap) { HStack(spacing: 14) { - Image(systemName: icon).font(.system(size: 22)) + Image(systemSymbol: icon).font(.system(size: 22)) .foregroundStyle(enabled ? colors.brandLink : colors.foregroundLighter) .frame(width: 24) VStack(alignment: .leading, spacing: 3) { if let titleOverride { Text(titleOverride).font(VniType.bodyLarge) } else { - Text(LocalizedStringKey(titleKey)).font(VniType.bodyLarge) + Text(String(localized: titleKey)).font(VniType.bodyLarge) } - Text(LocalizedStringKey(descKey)).font(VniType.bodySmall).foregroundStyle(colors.foregroundLighter) + Text(String(localized: descKey)).font(VniType.bodySmall).foregroundStyle(colors.foregroundLighter) } Spacer() if availability == .unavailable { - Text(LocalizedStringKey("value_unavailable")).font(VniType.labelSmall).foregroundStyle(colors.foregroundLighter) + Text(String(localized: L10n.Value.unavailable)).font(VniType.labelSmall).foregroundStyle(colors.foregroundLighter) } } .padding(16) @@ -102,7 +103,7 @@ struct InvitationReviewPanel: View { var body: some View { VStack(alignment: .leading, spacing: 14) { - Text(LocalizedStringKey("receive_review_title")).font(VniType.titleLarge) + Text(String(localized: L10n.Receive.reviewTitle)).font(VniType.titleLarge) if state.isInspecting { ProgressView().frame(maxWidth: .infinity).padding(40) } @@ -110,28 +111,31 @@ struct InvitationReviewPanel: View { let metadata = inspection.metadata VStack(alignment: .leading, spacing: 8) { Text(metadata.transferName).font(VniType.bodyLarge).lineLimit(2) - Text("\(metadata.fileCount) \(String(localized: "metadata_files").lowercased()) · \(formatBytes(metadata.totalSize))") + Text(L10n.Format.separatedTriple( + first: "\(metadata.fileCount)", + second: String(localized: L10n.Metadata.files).lowercased(), + third: formatBytes(metadata.totalSize))) .foregroundStyle(colors.foregroundLighter) } .padding(16) .frame(maxWidth: .infinity, alignment: .leading) .background(colors.backgroundSurface200, in: RoundedRectangle(cornerRadius: 14)) - Field(label: String(localized: "field_receiver_name"), + Field(label: String(localized: L10n.Field.receiverName), value: Binding(get: { state.receiverName }, set: { model.setReceiverName($0) })) - Text(state.receiveFolder?.displayName ?? String(localized: "value_unavailable")) + Text(state.receiveFolder?.displayName ?? String(localized: L10n.Value.unavailable)) .font(VniType.bodySmall) .foregroundStyle(state.folderAccessStatus == .writable ? colors.foregroundLight : colors.destructiveDefault) if state.isReceiving { let progressId = state.activeReceiveTransferId - ?? model.coreState.events.first { $0.direction == "receive" && $0.transferId != nil }?.transferId + ?? model.coreState.events.first { $0.eventDirection == .receive && $0.transferId != nil }?.transferId let progress = progressId.flatMap { progressForTransfer(events: model.coreState.events, transferId: $0) } - ProgressRow(labelKey: progress?.labelKey ?? "progress_receiving", progress: progress?.progress, detail: progress?.detail) - SecondaryButton(title: String(localized: "button_cancel_receive"), action: model.cancelActiveReceive) + ProgressRow(labelKey: progress?.labelKey ?? L10n.Progress.receiving, progress: progress?.progress, detail: progress?.detail) + SecondaryButton(title: String(localized: L10n.Button.cancelReceive), action: model.cancelActiveReceive) } else { PrimaryButton( - title: String(localized: "button_receive"), action: model.receive, + title: String(localized: L10n.Button.receive), action: model.receive, enabled: state.canReceive(coreInitialized: model.coreState.isInitialized) ) } diff --git a/apple/VniDrop/Features/Receive/ReceiveModel.swift b/apple/VniDrop/Features/Receive/ReceiveModel.swift index 919f287..3927e92 100644 --- a/apple/VniDrop/Features/Receive/ReceiveModel.swift +++ b/apple/VniDrop/Features/Receive/ReceiveModel.swift @@ -121,6 +121,10 @@ final class ReceiveModel: ObservableObject { func confirmHistoryDelete() { guard let target = state.historyDeleteTarget, !state.isDeletingHistory else { return } state.isDeletingHistory = true + // Close synchronously: the alert's dismiss binding runs async and no-ops while + // `isDeletingHistory`, which would otherwise leave the target set and macOS + // re-present it. + state.historyDeleteTarget = nil Task { let result: Result switch target { @@ -133,7 +137,7 @@ final class ReceiveModel: ObservableObject { case .success: state.historyDeleteTarget = nil state.isDeletingHistory = false - let key = target == .all ? "receive_history_cleared" : "transfer_deleted" + let key = target == .all ? L10n.Receive.historyCleared : L10n.Transfer.deleted messages.tryShow(UiMessage(text: .resource(key), tone: .success)) case .failure(let error): state.isDeletingHistory = false @@ -173,9 +177,9 @@ final class ReceiveModel: ObservableObject { resetAcquisition() let canReveal = fileSystemService.canRevealReceiveFolder(folder) messages.tryShow(UiMessage( - text: .resource("receive_completed"), + text: .resource(L10n.Receive.completed), tone: .success, - actionLabel: canReveal ? .resource("button_show_in_files") : nil, + actionLabel: canReveal ? .resource(L10n.Button.showInFiles) : nil, onAction: canReveal ? { self.revealReceiveFolder(folder) } : nil )) case .failure(let error): @@ -192,8 +196,8 @@ final class ReceiveModel: ObservableObject { messages.tryShow(UiMessage( text: uiText, tone: .error, - actionLabel: error.canRetryWithoutChangingInput ? .resource("button_retry") : nil, - onAction: error.canRetryWithoutChangingInput ? { self.receive() } : nil + actionLabel: .resource(L10n.Button.retry), + onAction: { self.receive() } )) } } @@ -202,7 +206,7 @@ final class ReceiveModel: ObservableObject { func cancelActiveReceive() { let transferId = state.activeReceiveTransferId ?? coreState.transfers.first { $0.direction == .receive && $0.status == .receiving }?.transferId - ?? coreState.events.first { $0.direction == "receive" && $0.transferId != nil }?.transferId + ?? coreState.events.first { $0.eventDirection == .receive && $0.transferId != nil }?.transferId guard let transferId else { return } Task { let result = await repository.cancel(transferId: transferId) @@ -222,14 +226,14 @@ final class ReceiveModel: ObservableObject { Task { let result = await fileSystemService.revealReceiveFolder(folder) if case .failure = result { - messages.show(UiMessage(text: .resource("receive_open_files_failed"), tone: .error)) + messages.show(UiMessage(text: .resource(L10n.Receive.openFilesFailed), tone: .error)) } } } private func inspectInvitation(_ method: ReceiveMethod, _ raw: String) { let ticket = raw.trimmingCharacters(in: .whitespacesAndNewlines) - if ticket.isEmpty { return messages.error(.resource("error_invitation_empty")) } + if ticket.isEmpty { return messages.error(.resource(L10n.Error.invitationEmpty)) } state.isAcquisitionOpen = true state.ticket = ticket state.method = method diff --git a/apple/VniDrop/Features/Receive/ReceiveScreen.swift b/apple/VniDrop/Features/Receive/ReceiveScreen.swift index 9bd50e8..314300c 100644 --- a/apple/VniDrop/Features/Receive/ReceiveScreen.swift +++ b/apple/VniDrop/Features/Receive/ReceiveScreen.swift @@ -1,4 +1,5 @@ import SwiftUI +import SFSafeSymbols /// Receive screen, rebuilt on native SwiftUI. A grouped `List` of received /// transfers with swipe-to-delete, and the acquisition flow as a native sheet. @@ -22,17 +23,17 @@ struct ReceiveScreen: View { history } } - .navigationTitle(Text(LocalizedStringKey("receive_title"))) + .navigationTitle(Text(String(localized: L10n.Receive.title))) .toolbar { ToolbarItem(placement: .primaryAction) { Button(action: model.openAcquisition) { - Label(String(localized: "button_receive_files"), systemImage: "plus") + Label(String(localized: L10n.Button.receiveFiles), systemSymbol: .plus) } } if !deletable.isEmpty { ToolbarItem(placement: .primaryAction) { Button(role: .destructive, action: model.requestClearHistory) { - Label(String(localized: "receive_clear_history"), systemImage: "trash") + Label(String(localized: L10n.Receive.clearHistory), systemSymbol: .trash) } } } @@ -50,11 +51,11 @@ struct ReceiveScreen: View { } } .alert( - Text(LocalizedStringKey(clearAllPending ? "receive_clear_history_title" : "receive_delete_history_title")), + Text(String(localized: clearAllPending ? L10n.Receive.clearHistoryTitle : L10n.Receive.deleteHistoryTitle)), isPresented: Binding(get: { model.state.historyDeleteTarget != nil }, set: { if !$0 { Task { @MainActor in model.dismissHistoryDelete() } } }) ) { - Button(String(localized: "button_cancel"), role: .cancel, action: model.dismissHistoryDelete) - Button(String(localized: clearAllPending ? "receive_clear_history" : "button_delete_transfer"), + Button(String(localized: L10n.Button.cancel), role: .cancel, action: model.dismissHistoryDelete) + Button(String(localized: clearAllPending ? L10n.Receive.clearHistory : L10n.Button.deleteTransfer), role: .destructive, action: model.confirmHistoryDelete) } message: { historyDeleteMessage @@ -74,27 +75,36 @@ struct ReceiveScreen: View { Button(role: .destructive) { model.requestDeleteHistoryItem(transfer.transferId) } label: { - Label(String(localized: "button_delete_transfer"), systemImage: "trash") + Label(String(localized: L10n.Button.deleteTransfer), systemSymbol: .trash) + } + } + } + .contextMenu { + if transfer.status.isTerminalReceiveHistory { + Button(role: .destructive) { + model.requestDeleteHistoryItem(transfer.transferId) + } label: { + Label(String(localized: L10n.Button.deleteTransfer), systemSymbol: .trash) } } } } } header: { - Text(LocalizedStringKey("receive_history_title")) + Text(String(localized: L10n.Receive.historyTitle)) } footer: { - Text(LocalizedStringKey("receive_new_subtitle")) + Text(String(localized: L10n.Receive.newSubtitle)) } } } private var emptyState: some View { ContentUnavailableView { - Label(String(localized: "receive_empty_title"), systemImage: "tray.and.arrow.down") + Label(String(localized: L10n.Receive.emptyTitle), systemSymbol: .trayAndArrowDown) } description: { - Text(LocalizedStringKey("receive_empty_body")) + Text(String(localized: L10n.Receive.emptyBody)) } actions: { Button(action: model.openAcquisition) { - Label(String(localized: "button_receive_files"), systemImage: "plus") + Label(String(localized: L10n.Button.receiveFiles), systemSymbol: .plus) } .buttonStyle(.borderedProminent) .controlSize(.large) @@ -107,10 +117,10 @@ struct ReceiveScreen: View { private var historyDeleteMessage: some View { if let target = model.state.historyDeleteTarget { if target == .all { - Text(LocalizedStringKey("receive_clear_history_description")) + Text(String(localized: L10n.Receive.clearHistoryDescription)) } else { - Text(String(format: String(localized: "receive_delete_history_description"), - transferName(for: target) ?? String(localized: "receive_unknown_transfer"))) + Text(L10n.Receive.deleteHistoryDescription( + transferName: transferName(for: target) ?? String(localized: L10n.Receive.unknownTransfer))) } } } @@ -129,14 +139,14 @@ private struct ReceiveTransferRow: View { var body: some View { HStack(spacing: 12) { - Image(systemName: "doc") + Image(systemSymbol: .doc) .foregroundStyle(.secondary) .frame(width: 40, height: 40) .background(.quaternary, in: RoundedRectangle(cornerRadius: 9)) VStack(alignment: .leading, spacing: 3) { - Text(transfer.transferName ?? String(localized: "receive_unknown_transfer")) + Text(transfer.transferName ?? String(localized: L10n.Receive.unknownTransfer)) .font(.body).lineLimit(1) - Text("\(formatBytes(transfer.totalSize)) · \(statusLabel(transfer.status))") + Text(L10n.Format.separatedPair(first: formatBytes(transfer.totalSize), second: statusLabel(transfer.status))) .font(.caption).foregroundStyle(.secondary) if transfer.status == .receiving, let progress { ProgressRow(labelKey: progress.labelKey, progress: progress.progress, detail: progress.detail) diff --git a/apple/VniDrop/Features/Send/SendModel.swift b/apple/VniDrop/Features/Send/SendModel.swift index 53aa931..eda9c95 100644 --- a/apple/VniDrop/Features/Send/SendModel.swift +++ b/apple/VniDrop/Features/Send/SendModel.swift @@ -207,6 +207,9 @@ final class SendModel: ObservableObject { func confirmDeleteTransfer() { guard let transferId = state.selectedTransferId, !state.isDeleting else { return } state.isDeleting = true + // Close synchronously: the alert's dismiss binding runs async and no-ops while + // `isDeleting`, which would otherwise leave the flag true and macOS re-present it. + state.isDeleteConfirmationOpen = false Task { let result = await repository.delete(transferId: transferId) switch result { @@ -217,7 +220,32 @@ final class SendModel: ObservableObject { state.receiverHistory = [] state.isDeleteConfirmationOpen = false state.isDeleting = false - messages.tryShow(UiMessage(text: .resource("transfer_deleted"), tone: .success)) + messages.tryShow(UiMessage(text: .resource(L10n.Transfer.deleted), tone: .success)) + case .failure(let error): + state.isDeleting = false + messages.error(error) + } + } + } + + /// Deletes a transfer by id, independent of the detail selection — used by the + /// list context menu so it can act inline without navigating into the detail. + func deleteTransfer(id: UInt64) { + if state.isDeleting { return } + state.isDeleting = true + Task { + let result = await repository.delete(transferId: id) + switch result { + case .success: + filePreviewRepository.remove(transferId: id) + if state.selectedTransferId == id { + state.selectedTransferId = nil + state.detailPanel = nil + state.receiverHistory = [] + } + state.isDeleting = false + _ = await repository.refresh() + messages.tryShow(UiMessage(text: .resource(L10n.Transfer.deleted), tone: .success)) case .failure(let error): state.isDeleting = false messages.error(error) @@ -249,7 +277,7 @@ final class SendModel: ObservableObject { switch result { case .success: _ = await repository.refresh() - messages.tryShow(UiMessage(text: .resource("transfer_event_stopped"), tone: .info)) + messages.tryShow(UiMessage(text: .resource(L10n.Transfer.eventStopped), tone: .info)) case .failure(let error): messages.error(error) } @@ -261,10 +289,10 @@ final class SendModel: ObservableObject { func onInvitationResult(_ action: InvitationAction, _ result: Result) { switch result { case .success: - let key: String? + let key: String.LocalizationValue? switch action { - case .export: key = "transfer_invitation_saved" - case .nfc: key = "transfer_nfc_written" + case .export: key = L10n.Transfer.invitationSaved + case .nfc: key = L10n.Transfer.nfcWritten case .share: key = nil // system share sheet already confirms } if let key { messages.tryShow(UiMessage(text: .resource(key), tone: .success)) } @@ -297,7 +325,14 @@ final class SendModel: ObservableObject { state.transferName = "" state.accessPolicy = .requireApproval state.isSharing = false - messages.show(UiMessage(text: .resource("send_transfer_created"), tone: .success)) + // Jump straight to the new transfer's share panel (QR + delivery) rather + // than dropping the user on the list to drill in manually. Refresh first + // so the transfer exists in state before it's selected. + _ = await repository.refresh() + state.selectedTransferId = share.transferId + state.detailPanel = .share + refreshReceivers(share.transferId) + messages.show(UiMessage(text: .resource(L10n.Send.transferCreated), tone: .success)) case .failure(let error): state.isSharing = false messages.error(error) diff --git a/apple/VniDrop/Features/Send/SendScreen.swift b/apple/VniDrop/Features/Send/SendScreen.swift index 5b56ef7..4d73bea 100644 --- a/apple/VniDrop/Features/Send/SendScreen.swift +++ b/apple/VniDrop/Features/Send/SendScreen.swift @@ -1,4 +1,5 @@ import SwiftUI +import SFSafeSymbols /// Send screen, rebuilt on native SwiftUI. A grouped `List` of outgoing transfers, /// with the composer and detail panels as native sheets and delete as an alert. @@ -6,6 +7,11 @@ struct SendScreen: View { @ObservedObject var model: SendModel let windowClass: WindowClass + /// Transfer whose share panel is presented inline from the list context menu. + @State private var shareTarget: Transfer? + /// Transfer pending an inline (list-level) delete confirmation. + @State private var deleteTarget: Transfer? + private var outgoing: [Transfer] { model.coreState.transfers.filter { $0.direction == .send } } @@ -27,11 +33,11 @@ struct SendScreen: View { catalog } } - .navigationTitle(Text(LocalizedStringKey("send_title"))) + .navigationTitle(Text(String(localized: L10n.Send.title))) .toolbar { ToolbarItem(placement: .primaryAction) { Button(action: model.openComposer) { - Label(String(localized: "button_create_new_transfer"), systemImage: "plus") + Label(String(localized: L10n.Button.createNewTransfer), systemSymbol: .plus) } } } @@ -40,6 +46,18 @@ struct SendScreen: View { detailView(for: transfer) } } + // Attached inside the NavigationStack (a different sheet host than the + // composer drawer on the outer body, so the two don't clash). Opens the + // share panel over the list without navigating into the transfer detail. + .adaptiveDrawer( + isPresented: Binding(get: { shareTarget != nil }, set: { if !$0 { shareTarget = nil } }), + windowClass: windowClass, + onDismiss: { shareTarget = nil } + ) { + if let shareTarget { + TransferSharePanel(model: model, transfer: shareTarget) + } + } } .adaptiveDrawer( isPresented: Binding(get: { model.state.isComposerOpen }, set: { _ in }), @@ -48,8 +66,24 @@ struct SendScreen: View { ) { TransferComposer(model: model, windowClass: windowClass) } + .alert( + Text(String(localized: L10n.Transfer.deleteTitle)), + isPresented: Binding(get: { deleteTarget != nil }, set: { if !$0 { deleteTarget = nil } }) + ) { + Button(String(localized: L10n.Button.cancel), role: .cancel) { deleteTarget = nil } + Button(String(localized: L10n.Button.deleteTransfer), role: .destructive) { + if let target = deleteTarget { model.deleteTransfer(id: target.transferId) } + deleteTarget = nil + } + } message: { + if let target = deleteTarget { + Text(L10n.Transfer.deleteDescription( + transferName: target.transferName ?? String(localized: L10n.Send.newTransferTitle))) + } + } } + /// The pushed transfer details view, with its detail-panel sheet and delete /// alert attached here so they present from the detail's own context (presenting /// modals from the parent stack while a detail is pushed is unreliable on macOS). @@ -65,14 +99,14 @@ struct SendScreen: View { } } .alert( - Text(LocalizedStringKey("transfer_delete_title")), + Text(String(localized: L10n.Transfer.deleteTitle)), isPresented: Binding(get: { model.state.isDeleteConfirmationOpen }, set: { if !$0 { Task { @MainActor in model.dismissDeleteTransfer() } } }) ) { - Button(String(localized: "button_cancel"), role: .cancel, action: model.dismissDeleteTransfer) - Button(String(localized: "button_delete_transfer"), role: .destructive, action: model.confirmDeleteTransfer) + Button(String(localized: L10n.Button.cancel), role: .cancel, action: model.dismissDeleteTransfer) + Button(String(localized: L10n.Button.deleteTransfer), role: .destructive, action: model.confirmDeleteTransfer) } message: { - Text(String(format: String(localized: "transfer_delete_description"), - transfer.transferName ?? String(localized: "send_new_transfer_title"))) + Text(L10n.Transfer.deleteDescription( + transferName: transfer.transferName ?? String(localized: L10n.Send.newTransferTitle))) } } @@ -90,23 +124,45 @@ struct SendScreen: View { ) } .buttonStyle(.plain) + .contextMenu { + if transfer.ticket != nil { + Button { + shareTarget = transfer + } label: { + Label(String(localized: L10n.Transfer.shareTitle), systemSymbol: .squareAndArrowUp) + } + } + if transfer.status == .sharing { + Button(role: .destructive) { + model.stopSharing(transferId: transfer.transferId) + } label: { + Label(String(localized: L10n.Send.stopSharing), systemSymbol: .stopCircle) + } + } + Divider() + Button(role: .destructive) { + deleteTarget = transfer + } label: { + Label(String(localized: L10n.Button.deleteTransfer), systemSymbol: .trash) + } + } } } header: { - Text(LocalizedStringKey("send_transfers_title")) + Text(String(localized: L10n.Send.transfersTitle)) } footer: { - Text(LocalizedStringKey("send_subtitle")) + Text(String(localized: L10n.Send.subtitle)) } } } private var emptyState: some View { ContentUnavailableView { - Label(String(localized: "send_empty_title"), systemImage: "paperplane") + Label(String(localized: L10n.Send.emptyTitle), systemSymbol: .paperplane) } description: { - Text(LocalizedStringKey("send_empty_body")) + Text(String(localized: L10n.Send.emptyBody)) } actions: { Button(action: model.openComposer) { - Label(String(localized: "button_create_new_transfer"), systemImage: "plus") + Label(String(localized: L10n.Button.createNewTransfer), systemSymbol: .plus) } .buttonStyle(.borderedProminent) .controlSize(.large) @@ -127,21 +183,18 @@ struct SendScreen: View { private func sharingProgress(for transfer: Transfer) -> TransferProgress? { let active = (model.receiversByTransfer[transfer.transferId] ?? []).filter { $0.status == .accepted } if active.isEmpty { return nil } - let fractions: [Double] = active.compactMap { receiver -> Double? in - let progress = progressForReceiver(events: model.coreState.events, transferId: transfer.transferId, - remoteEndpointId: receiver.remoteEndpointId, totalSizeHint: transfer.totalSize) - guard progress?.kind == "started" || progress?.kind == "progress" else { return nil } - return progress?.progress + let fractions = active.compactMap { + progressForReceiver(events: model.coreState.events, transferId: transfer.transferId, + remoteEndpointId: $0.remoteEndpointId, totalSizeHint: transfer.totalSize)?.progress } - guard !fractions.isEmpty else { return nil } - let combined = fractions.reduce(0, +) / Double(fractions.count) + let combined = fractions.isEmpty ? nil : fractions.reduce(0, +) / Double(fractions.count) if active.count == 1 { - return TransferProgress(transferId: transfer.transferId, phase: "transfer", kind: "progress", - labelKey: "progress_sending", progress: combined) + return TransferProgress(transferId: transfer.transferId, phase: .transfer, kind: .progress, + labelKey: L10n.Progress.sending, progress: combined) } - return TransferProgress(transferId: transfer.transferId, phase: "transfer", kind: "progress", - labelKey: "progress_sending", progress: combined, - label: String(format: String(localized: "progress_sending_to_count"), active.count)) + return TransferProgress(transferId: transfer.transferId, phase: .transfer, kind: .progress, + labelKey: L10n.Progress.sending, progress: combined, + label: L10n.Progress.sendingToCount(count: active.count)) } } @@ -157,19 +210,19 @@ private struct TransferListItem: View { .background(.quaternary, in: RoundedRectangle(cornerRadius: 9)) VStack(alignment: .leading, spacing: 3) { HStack { - Text(transfer.transferName ?? String(localized: "send_new_transfer_title")) + Text(transfer.transferName ?? String(localized: L10n.Send.newTransferTitle)) .font(.body).lineLimit(1) Spacer() StatusPill(label: statusLabel(transfer.status), tone: transfer.status.pillTone) } - Text("\(formatBytes(transfer.totalSize)) · \(accessPolicyLabel(transfer.accessPolicy))") + Text(L10n.Format.separatedPair(first: formatBytes(transfer.totalSize), second: accessPolicyLabel(transfer.accessPolicy))) .font(.caption).foregroundStyle(.secondary).lineLimit(1) if let progress, transfer.status == .importing || transfer.status == .sharing { ProgressRow(labelKey: progress.labelKey, progress: progress.progress, detail: progress.detail, labelText: progress.label) .padding(.top, 2) } } - Image(systemName: "chevron.forward") + Image(systemSymbol: .chevronForward) .font(.footnote.weight(.semibold)).foregroundStyle(.tertiary) } .contentShape(Rectangle()) @@ -184,7 +237,7 @@ struct FileArtwork: View { image.resizable().aspectRatio(contentMode: .fill) .clipShape(RoundedRectangle(cornerRadius: 8)) } else { - Image(systemName: "doc") + Image(systemSymbol: .doc) .font(.system(size: 18)) .foregroundStyle(.secondary) } @@ -192,13 +245,13 @@ struct FileArtwork: View { } func statusLabel(_ status: TransferStatus) -> String { - String(localized: String.LocalizationValue(statusLabelKey(status))) + String(localized: statusLabelKey(status)) } func accessPolicyLabel(_ policy: ShareAccessPolicy) -> String { switch policy { - case .requireApproval: return String(localized: "send_access_approval") - case .anyoneWithTransfer: return String(localized: "send_access_anyone") + case .requireApproval: return String(localized: L10n.Send.accessApproval) + case .anyoneWithTransfer: return String(localized: L10n.Send.accessAnyone) } } diff --git a/apple/VniDrop/Features/Send/TransferComposer.swift b/apple/VniDrop/Features/Send/TransferComposer.swift index 6404bf8..8dc4fd9 100644 --- a/apple/VniDrop/Features/Send/TransferComposer.swift +++ b/apple/VniDrop/Features/Send/TransferComposer.swift @@ -1,4 +1,5 @@ import SwiftUI +import SFSafeSymbols /// Transfer composer drawer, ported from `feature/send/TransferComposer.kt`. /// Two steps: choose files/folder, then review + name + access policy + share. @@ -23,13 +24,13 @@ struct TransferComposer: View { private var chooseStep: some View { VStack(alignment: .leading, spacing: 16) { - Text(LocalizedStringKey("send_choose_file_title")).font(.title2).fontWeight(.semibold) - Text(LocalizedStringKey("send_choose_file_body")) + Text(String(localized: L10n.Send.chooseFileTitle)).font(.title2).fontWeight(.semibold) + Text(String(localized: L10n.Send.chooseFileBody)) .font(.subheadline).foregroundStyle(.secondary) VStack(spacing: 14) { - Image(systemName: "doc").font(.system(size: 30)).foregroundStyle(.tint) - PrimaryButton(title: String(localized: "button_choose_files"), action: model.selectFile).fixedSize() - QuietButton(title: String(localized: "button_choose_folder"), action: model.selectFolder) + Image(systemSymbol: .doc).font(.system(size: 30)).foregroundStyle(.tint) + PrimaryButton(title: String(localized: L10n.Button.chooseFiles), action: model.selectFile).fixedSize() + QuietButton(title: String(localized: L10n.Button.chooseFolder), action: model.selectFolder) } .frame(maxWidth: .infinity) .padding(28) @@ -39,9 +40,9 @@ struct TransferComposer: View { private var reviewStep: some View { VStack(alignment: .leading, spacing: 16) { - Text(LocalizedStringKey("send_review_title")).font(.title2).fontWeight(.semibold) + Text(String(localized: L10n.Send.reviewTitle)).font(.title2).fontWeight(.semibold) if state.selectedFiles.count > 1 { - Text(String(format: String(localized: "send_selected_files_count"), state.selectedFiles.count)) + Text(L10n.Send.selectedFilesCount(count: state.selectedFiles.count)) .font(.subheadline).foregroundStyle(.secondary) } ForEach(state.selectedFiles) { file in @@ -51,52 +52,64 @@ struct TransferComposer: View { onRemove: { model.removeSelectedFile(file.value) } ) } - Field(label: String(localized: "field_transfer_name"), + Field(label: String(localized: L10n.Field.transferName), value: Binding(get: { state.transferName }, set: { model.setTransferName($0) })) - Field(label: String(localized: "field_sender_name"), + Field(label: String(localized: L10n.Field.senderName), value: Binding(get: { state.senderName }, set: { model.setSenderName($0) })) - Text(LocalizedStringKey("send_access_title")).font(.headline) + Text(String(localized: L10n.Send.accessTitle)).font(.headline) PolicyOption( - icon: "checkmark.shield", titleKey: "send_access_approval", descKey: "send_access_approval_description", + icon: .checkmarkShield, titleKey: L10n.Send.accessApproval, descKey: L10n.Send.accessApprovalDescription, selected: state.accessPolicy == .requireApproval, onTap: { model.setAccessPolicy(.requireApproval) } ) PolicyOption( - icon: "globe", titleKey: "send_access_anyone", descKey: "send_access_anyone_description", + icon: .globe, titleKey: L10n.Send.accessAnyone, descKey: L10n.Send.accessAnyoneDescription, selected: state.accessPolicy == .anyoneWithTransfer, onTap: { model.setAccessPolicy(.anyoneWithTransfer) } ) if state.accessPolicy == .anyoneWithTransfer { - Label(String(localized: "send_access_anyone_warning"), systemImage: "exclamationmark.triangle.fill") + Label(String(localized: L10n.Send.accessAnyoneWarning), systemSymbol: .exclamationmarkTriangleFill) .font(.caption).foregroundStyle(.orange) } actions } } - @ViewBuilder private var actions: some View { let shareTitle = state.isSharing - ? String(localized: "button_sharing_file") : String(localized: "button_share_file") - let shareButton = PrimaryButton( - title: shareTitle, action: model.createShare, - enabled: state.canCreateShare(coreInitialized: model.coreState.isInitialized) - ) - if windowClass == .phone { - VStack(spacing: 8) { - shareButton - QuietButton(title: String(localized: "button_change_files"), action: model.selectFile, enabled: !state.isSharing) - QuietButton(title: String(localized: "button_choose_folder"), action: model.selectFolder, enabled: !state.isSharing) - } - } else { - HStack(spacing: 8) { - shareButton.fixedSize() - QuietButton(title: String(localized: "button_change_files"), action: model.selectFile, enabled: !state.isSharing) - QuietButton(title: String(localized: "button_choose_folder"), action: model.selectFolder, enabled: !state.isSharing) - QuietButton(title: String(localized: "button_clear"), action: model.clearSelectedSource, enabled: !state.isSharing) + ? String(localized: L10n.Button.sharingFile) : String(localized: L10n.Button.shareFile) + return VStack(spacing: 10) { + PrimaryButton( + title: shareTitle, action: model.createShare, + enabled: state.canCreateShare(coreInitialized: model.coreState.isInitialized) + ) + // Secondary source actions as an even row of bordered buttons rather than + // bare text links, so they read as controls and align with the primary. + HStack(spacing: 10) { + sourceButton(title: L10n.Button.changeFiles, symbol: .docBadgeArrowUp, action: model.selectFile) + sourceButton(title: L10n.Button.chooseFolder, symbol: .folder, action: model.selectFolder) + if windowClass != .phone { + sourceButton(title: L10n.Button.clear, symbol: .xmark, action: model.clearSelectedSource) + } } } } + + private func sourceButton( + title: String.LocalizationValue, symbol: SFSymbol, action: @escaping () -> Void + ) -> some View { + Button(action: action) { + Label(String(localized: title), systemSymbol: symbol) + .lineLimit(1) + .minimumScaleFactor(0.85) + .frame(maxWidth: .infinity) + .frame(minHeight: 20) + } + .buttonStyle(.bordered) + .controlSize(.large) + .tint(.secondary) + .disabled(state.isSharing) + } } private struct SelectedFileCard: View { @@ -116,7 +129,7 @@ private struct SelectedFileCard: View { Spacer() if canRemove { Button(role: .destructive, action: onRemove) { - Image(systemName: "trash") + Image(systemSymbol: .trash) } .buttonStyle(.borderless) .tint(.red) @@ -128,32 +141,32 @@ private struct SelectedFileCard: View { } private var subtitle: String { - if file.isDirectory { return String(localized: "send_folder_label") } + if file.isDirectory { return String(localized: L10n.Send.folderLabel) } if let size = file.sizeBytes { return formatBytes(size) } - return String(localized: "send_file_size_unknown") + return String(localized: L10n.Send.fileSizeUnknown) } } private struct PolicyOption: View { - let icon: String - let titleKey: String - let descKey: String + let icon: SFSymbol + let titleKey: String.LocalizationValue + let descKey: String.LocalizationValue let selected: Bool let onTap: () -> Void var body: some View { Button(action: onTap) { HStack(spacing: 12) { - Image(systemName: icon) + Image(systemSymbol: icon) .font(.system(size: 20)) .foregroundStyle(selected ? AnyShapeStyle(.tint) : AnyShapeStyle(.secondary)) .frame(width: 22) VStack(alignment: .leading, spacing: 3) { - Text(LocalizedStringKey(titleKey)) - Text(LocalizedStringKey(descKey)).font(.caption).foregroundStyle(.secondary) + Text(String(localized: titleKey)) + Text(String(localized: descKey)).font(.caption).foregroundStyle(.secondary) } Spacer() - Image(systemName: selected ? "checkmark.circle.fill" : "circle") + Image(systemSymbol: selected ? .checkmarkCircleFill : .circle) .foregroundStyle(selected ? AnyShapeStyle(.tint) : AnyShapeStyle(.tertiary)) } .padding(14) diff --git a/apple/VniDrop/Features/Send/TransferDetailsView.swift b/apple/VniDrop/Features/Send/TransferDetailsView.swift index 2145154..b0358fc 100644 --- a/apple/VniDrop/Features/Send/TransferDetailsView.swift +++ b/apple/VniDrop/Features/Send/TransferDetailsView.swift @@ -1,4 +1,5 @@ import SwiftUI +import SFSafeSymbols import CoreImage.CIFilterBuiltins /// Transfer details + drawer panels, ported from `feature/send/TransferDetails.kt`. @@ -23,80 +24,78 @@ struct TransferDetailsView: View { var body: some View { Form { Section { - LabeledContent(String(localized: "metadata_status"), value: statusLabel(transfer.status)) - LabeledContent(String(localized: "metadata_size"), value: formatBytes(transfer.totalSize)) - LabeledContent(String(localized: "send_access_title"), value: accessPolicyLabel(transfer.accessPolicy)) + LabeledContent(String(localized: L10n.Metadata.status), value: statusLabel(transfer.status)) + LabeledContent(String(localized: L10n.Metadata.size), value: formatBytes(transfer.totalSize)) + LabeledContent(String(localized: L10n.Send.accessTitle), value: accessPolicyLabel(transfer.accessPolicy)) } header: { - Text(transfer.transferName ?? String(localized: "send_new_transfer_title")) + Text(transfer.transferName ?? String(localized: L10n.Send.newTransferTitle)) } Section { DetailDestination( - title: String(localized: "transfer_activity_title"), - description: String(localized: "transfer_activity_description"), + title: String(localized: L10n.Transfer.activityTitle), + description: String(localized: L10n.Transfer.activityDescription), count: events.filter { $0.transferId == transfer.transferId && $0.isMeaningfulActivity }.count, onTap: model.openActivity ) DetailDestination( - title: String(localized: "transfer_receivers_title"), + title: String(localized: L10n.Transfer.receiversTitle), description: receiversDescription(pendingReceivers, completedReceivers), count: pendingReceivers + completedReceivers, onTap: model.openReceivers ) - if transfer.invitationPresentation != .unavailable { - DetailDestination( - title: String(localized: "transfer_share_title"), - description: String(localized: "transfer_share_description"), - count: 0, - onTap: model.openShare - ) - } } - if isActiveShare { - Section { + Section { + if isActiveShare { Button(role: .destructive) { showStopConfirmation = true } label: { - Label(String(localized: "send_stop_sharing"), systemImage: "stop.circle") + Label(String(localized: L10n.Send.stopSharing), systemSymbol: .stopCircle) } } + Button(role: .destructive, action: model.requestDeleteTransfer) { + Label(String(localized: L10n.Button.deleteTransfer), systemSymbol: .trash) + } } } .formStyle(.grouped) - .navigationTitle(Text(LocalizedStringKey("send_transfer_details_title"))) + .navigationTitle(Text(String(localized: L10n.Send.transferDetailsTitle))) #if os(iOS) .navigationBarTitleDisplayMode(.inline) #endif .toolbar { ToolbarItem(placement: .primaryAction) { - Button(role: .destructive, action: model.requestDeleteTransfer) { - Image(systemName: "trash") + Button(action: model.openShare) { + Label(String(localized: L10n.Transfer.shareTitle), systemSymbol: .squareAndArrowUp) } + .help(String(localized: L10n.Transfer.shareTitle)) } } .confirmationDialog( - Text(LocalizedStringKey("send_stop_sharing")), + Text(String(localized: L10n.Send.stopSharing)), isPresented: $showStopConfirmation, titleVisibility: .visible ) { - Button(String(localized: "send_stop_sharing"), role: .destructive) { + Button(String(localized: L10n.Send.stopSharing), role: .destructive) { model.stopSharing(transferId: transfer.transferId) } - Button(String(localized: "button_cancel"), role: .cancel) {} + Button(String(localized: L10n.Button.cancel), role: .cancel) {} } message: { - Text(LocalizedStringKey("send_stop_sharing_description")) + Text(String(localized: L10n.Send.stopSharingDescription)) } } } private func receiversDescription(_ pending: Int, _ completed: Int) -> String { if pending > 0 && completed > 0 { - return "\(String(format: String(localized: "transfer_receivers_pending"), pending)) · \(String(format: String(localized: "transfer_receivers_completed_count"), completed))" + return L10n.Format.separatedPair( + first: L10n.Transfer.receiversPending(count: pending), + second: L10n.Transfer.receiversCompletedCount(count: completed)) } - if pending > 0 { return String(format: String(localized: "transfer_receivers_pending"), pending) } - if completed > 0 { return String(format: String(localized: "transfer_receivers_completed_count"), completed) } - return String(localized: "transfer_receivers_description") + if pending > 0 { return L10n.Transfer.receiversPending(count: pending) } + if completed > 0 { return L10n.Transfer.receiversCompletedCount(count: completed) } + return String(localized: L10n.Transfer.receiversDescription) } private struct DetailDestination: View { @@ -113,11 +112,11 @@ private struct DetailDestination: View { } Spacer() if count > 0 { - Text("\(count)") + Text(verbatim: "\(count)") .font(.footnote) .foregroundStyle(.secondary) } - Image(systemName: "chevron.forward") + Image(systemSymbol: .chevronForward) .font(.footnote.weight(.semibold)).foregroundStyle(.tertiary) } .contentShape(Rectangle()) @@ -173,13 +172,13 @@ struct TransferActivityPanel: View { let visible = events .filter { $0.transferId == transferId && $0.isMeaningfulActivity } .sorted { $0.timestamp > $1.timestamp } - PanelContainer(title: String(localized: "transfer_activity_title")) { + PanelContainer(title: String(localized: L10n.Transfer.activityTitle)) { if visible.isEmpty { - Text(LocalizedStringKey("transfer_no_activity")).foregroundStyle(colors.foregroundLighter) + Text(String(localized: L10n.Transfer.noActivity)).foregroundStyle(colors.foregroundLighter) } else { ForEach(Array(visible.enumerated()), id: \.offset) { index, event in if index > 0 { Divider().overlay(colors.borderDefault) } - Text(LocalizedStringKey(event.activityTitleKey)) + Text(String(localized: event.activityTitleKey)) .fontWeight(.medium).padding(.vertical, 14) } } @@ -196,11 +195,11 @@ struct ReceiverHistoryPanel: View { let onCancel: (String) -> Void var body: some View { - PanelContainer(title: String(localized: "transfer_receivers_title")) { + PanelContainer(title: String(localized: L10n.Transfer.receiversTitle)) { if loading { ProgressView().frame(maxWidth: .infinity).padding(40) } else if receivers.isEmpty { - Text(LocalizedStringKey("transfer_no_receivers")).foregroundStyle(colors.foregroundLighter) + Text(String(localized: L10n.Transfer.noReceivers)).foregroundStyle(colors.foregroundLighter) } else { ForEach(Array(receivers.enumerated()), id: \.element.id) { index, receiver in if index > 0 { Divider().overlay(colors.borderDefault) } @@ -234,7 +233,7 @@ private struct ReceiverRow: View { } var body: some View { - let name = receiver.receiverName ?? receiver.receiverDeviceName ?? String(localized: "transfer_nearby_device") + let name = receiver.receiverName ?? receiver.receiverDeviceName ?? String(localized: L10n.Transfer.nearbyDevice) let showLive = sendProgress != nil && receiver.status != .completed && receiver.status != .refused && receiver.status != .expired && receiver.status != .failed @@ -247,12 +246,13 @@ private struct ReceiverRow: View { if showLive, let sendProgress { ProgressRow(labelKey: sendProgress.labelKey, progress: sendProgress.progress, detail: sendProgress.detail, labelText: sendProgress.label) } else { - Text(LocalizedStringKey(receiver.status.statusTextKey)) + Text(String(localized: receiver.status.statusTextKey)) .font(VniType.bodySmall).fontWeight(.medium) .foregroundStyle(receiver.status.statusColor(colors)) } if let reason = receiver.reason, !reason.isEmpty { - Text(reason).font(VniType.bodySmall).foregroundStyle(colors.foregroundLighter) + Text(receiverReasonUiText(reason).resolved()) + .font(VniType.bodySmall).foregroundStyle(colors.foregroundLighter) } } .frame(maxWidth: .infinity, alignment: .leading) @@ -260,7 +260,7 @@ private struct ReceiverRow: View { Button(role: .destructive) { onCancel(receiver.id) } label: { - Text(LocalizedStringKey("button_refuse")) + Text(String(localized: L10n.Button.refuse)) .font(VniType.bodySmall) } .buttonStyle(.borderless) @@ -278,24 +278,22 @@ struct TransferSharePanel: View { let transfer: Transfer var body: some View { - PanelContainer(title: String(localized: "transfer_share_title")) { + PanelContainer(title: String(localized: L10n.Transfer.shareTitle)) { switch transfer.invitationPresentation { case .ready(let ticket): let qrImage = QRCode.generate(from: ticket) qrCard(image: qrImage) if qrImage != nil { - Text(LocalizedStringKey("transfer_scan_qr")) + Text(String(localized: L10n.Transfer.scanQr)) .font(VniType.bodySmall).foregroundStyle(colors.foregroundLighter) .frame(maxWidth: .infinity) } ShareActionsView(model: model, transfer: transfer, ticket: ticket) case .preparing: - Text(LocalizedStringKey("transfer_event_preparing")).foregroundStyle(colors.foregroundLighter) + Text(String(localized: L10n.Transfer.eventPreparing)).foregroundStyle(colors.foregroundLighter) case .unavailable: - Text(LocalizedStringKey( - transfer.status == .failed ? "transfer_event_failed" : "transfer_event_stopped" - )) - .foregroundStyle(colors.foregroundLighter) + Text(String(localized: transfer.status == .failed ? L10n.Transfer.eventFailed : L10n.Transfer.eventStopped)) + .foregroundStyle(colors.foregroundLighter) } } } @@ -306,9 +304,9 @@ struct TransferSharePanel: View { image.interpolation(.none).resizable().scaledToFit().padding(14) } else { VStack(spacing: 10) { - Image(systemName: "qrcode") + Image(systemSymbol: .qrcode) .font(.system(size: 36, weight: .medium)) - Text(LocalizedStringKey("transfer_qr_unavailable")) + Text(String(localized: L10n.Transfer.qrUnavailable)) .font(VniType.bodySmall) .multilineTextAlignment(.center) } @@ -374,32 +372,32 @@ extension CoreEventModel { "receiver-refused", "receiver-completed", "share-stopped", "failed"].contains(kind) } - var activityTitleKey: String { - if phase == "import" && kind == "started" { return "transfer_event_preparing" } - if phase == "ticket" && kind == "created" { return "transfer_event_ready" } - if phase == "network" { return "transfer_event_connecting" } - if phase == "download" { return "transfer_event_downloading" } - if phase == "export" { return "transfer_event_saving" } - if kind == "receiver-requested" { return "transfer_event_requested" } - if kind == "receiver-accepted" || kind == "receiver-auto-approved" { return "transfer_event_approved" } - if kind == "receiver-refused" { return "transfer_event_refused" } - if kind == "receiver-completed" { return "transfer_event_completed" } - if kind == "share-stopped" || (phase == "lifecycle" && kind == "cancelled") { return "transfer_event_stopped" } - if kind == "failed" { return "transfer_event_failed" } - return "transfer_event_updated" + var activityTitleKey: String.LocalizationValue { + if phase == "import" && kind == "started" { return L10n.Transfer.eventPreparing } + if phase == "ticket" && kind == "created" { return L10n.Transfer.eventReady } + if phase == "network" { return L10n.Transfer.eventConnecting } + if phase == "download" { return L10n.Transfer.eventDownloading } + if phase == "export" { return L10n.Transfer.eventSaving } + if kind == "receiver-requested" { return L10n.Transfer.eventRequested } + if kind == "receiver-accepted" || kind == "receiver-auto-approved" { return L10n.Transfer.eventApproved } + if kind == "receiver-refused" { return L10n.Transfer.eventRefused } + if kind == "receiver-completed" { return L10n.Transfer.eventCompleted } + if kind == "share-stopped" || (phase == "lifecycle" && kind == "cancelled") { return L10n.Transfer.eventStopped } + if kind == "failed" { return L10n.Transfer.eventFailed } + return L10n.Transfer.eventUpdated } } extension ReceiverDeliveryStatus { - var statusTextKey: String { + var statusTextKey: String.LocalizationValue { switch self { - case .requested: return "transfer_receiver_requested" - case .accepted: return "transfer_receiver_accepted" - case .refused: return "transfer_receiver_refused" - case .expired: return "transfer_receiver_expired" - case .completed: return "transfer_receiver_completed" - case .failed: return "transfer_receiver_failed" - case .unknown: return "transfer_receiver_unknown" + case .requested: return L10n.Transfer.receiverRequested + case .accepted: return L10n.Transfer.receiverAccepted + case .refused: return L10n.Transfer.receiverRefused + case .expired: return L10n.Transfer.receiverExpired + case .completed: return L10n.Transfer.receiverCompleted + case .failed: return L10n.Transfer.receiverFailed + case .unknown: return L10n.Transfer.receiverUnknown } } diff --git a/apple/VniDrop/Features/Send/TransferShareActions.swift b/apple/VniDrop/Features/Send/TransferShareActions.swift index 73ca4df..325f342 100644 --- a/apple/VniDrop/Features/Send/TransferShareActions.swift +++ b/apple/VniDrop/Features/Send/TransferShareActions.swift @@ -2,7 +2,7 @@ import SwiftUI enum NfcShareAvailability { case available, unavailable, hidden } -/// Invitation delivery actions shared by the native Apple feature models. +/// Invitation delivery actions, ported from `TransferShareActions` (iosMain). /// Platform implementations perform export, native share, and NFC write. @MainActor protocol TransferShareActions: AnyObject { @@ -30,7 +30,7 @@ struct ShareActionsView: View { VStack(spacing: 12) { if actions.nfcAvailability != .hidden { SecondaryButton( - title: writingNfc ? String(localized: "transfer_nfc_waiting") : String(localized: "button_write_nfc"), + title: writingNfc ? String(localized: L10n.Transfer.nfcWaiting) : String(localized: L10n.Button.writeNfc), action: { writingNfc = true actions.writeInvitationToNfc(ticket: ticket) { result in @@ -41,16 +41,16 @@ struct ShareActionsView: View { enabled: actions.nfcAvailability == .available && !writingNfc ) if actions.nfcAvailability == .unavailable { - Text(LocalizedStringKey("transfer_nfc_unavailable")) + Text(String(localized: L10n.Transfer.nfcUnavailable)) .font(VniType.bodySmall).foregroundStyle(colors.foregroundLighter) } } - SecondaryButton(title: String(localized: "button_download_invitation"), action: { + SecondaryButton(title: String(localized: L10n.Button.downloadInvitation), action: { actions.exportInvitation(ticket: ticket, transferName: transfer.transferName ?? "") { model.onInvitationResult(.export, $0) } }) - PrimaryButton(title: String(localized: "button_native_share"), action: { + PrimaryButton(title: String(localized: L10n.Button.nativeShare), action: { actions.shareInvitation(ticket: ticket, transferName: transfer.transferName ?? "") { model.onInvitationResult(.share, $0) } diff --git a/apple/VniDrop/Features/Settings/SettingsModel.swift b/apple/VniDrop/Features/Settings/SettingsModel.swift index 674b53a..b5d3c3f 100644 --- a/apple/VniDrop/Features/Settings/SettingsModel.swift +++ b/apple/VniDrop/Features/Settings/SettingsModel.swift @@ -12,16 +12,16 @@ enum SettingsSection: Hashable { case about case bugReport - var titleKey: String { + var titleKey: String.LocalizationValue { switch self { - case .overview: return "settings_title" - case .preferences: return "preferences_title" - case .appearance: return "appearance_title" - case .notifications: return "notifications_title" - case .network: return "settings_network_title" - case .storage: return "storage_title" - case .about: return "about_title" - case .bugReport: return "about_bug_report" + case .overview: return L10n.Settings.title + case .preferences: return L10n.Preferences.title + case .appearance: return L10n.Appearance.title + case .notifications: return L10n.Notifications.title + case .network: return L10n.Settings.networkTitle + case .storage: return L10n.Storage.title + case .about: return L10n.About.title + case .bugReport: return L10n.About.bugReport } } } @@ -43,7 +43,6 @@ struct SettingsState: Equatable { var isValidatingFolder = false var supportsCustomReceiveFolders = true var themeMode: ThemeMode = .system - var notificationsEnabled = false var notificationPermission: NotificationPermission = .notDetermined var diagnosticsEnabled = false var relayMode: RelayPreferenceMode = .automatic @@ -53,7 +52,7 @@ struct SettingsState: Equatable { var isApplyingRelayConfiguration = false var hasActiveNetworkWork = false var endpointId: String? - var relayApplyErrorKey: String? + var relayApplyErrorKey: String.LocalizationValue? var deviceInfo: DeviceInfo? var appVersion = "" var isLoadingDeviceInfo = false @@ -66,14 +65,16 @@ struct SettingsState: Equatable { var bugLogPreviewBytes = 0 var storage: StorageBreakdown? var isCalculatingStorage = false + var storageLoadFailed = false var isDeletingTransfers = false + var isCleaningStorage = false static func == (lhs: SettingsState, rhs: SettingsState) -> Bool { lhs.selectedSection == rhs.selectedSection && lhs.username == rhs.username && lhs.receiveFolder == rhs.receiveFolder && lhs.folderAccessStatus == rhs.folderAccessStatus && lhs.isValidatingFolder == rhs.isValidatingFolder && lhs.supportsCustomReceiveFolders == rhs.supportsCustomReceiveFolders - && lhs.themeMode == rhs.themeMode && lhs.notificationsEnabled == rhs.notificationsEnabled + && lhs.themeMode == rhs.themeMode && lhs.notificationPermission == rhs.notificationPermission && lhs.diagnosticsEnabled == rhs.diagnosticsEnabled && lhs.appVersion == rhs.appVersion && lhs.relayMode == rhs.relayMode && lhs.relayURLs == rhs.relayURLs @@ -89,7 +90,9 @@ struct SettingsState: Equatable { && lhs.bugIncludeLogs == rhs.bugIncludeLogs && lhs.isSubmittingBugReport == rhs.isSubmittingBugReport && lhs.bugLogPreviewBytes == rhs.bugLogPreviewBytes && lhs.storage == rhs.storage && lhs.isCalculatingStorage == rhs.isCalculatingStorage + && lhs.storageLoadFailed == rhs.storageLoadFailed && lhs.isDeletingTransfers == rhs.isDeletingTransfers + && lhs.isCleaningStorage == rhs.isCleaningStorage && lhs.deviceInfo?.operatingSystem == rhs.deviceInfo?.operatingSystem } } @@ -110,7 +113,6 @@ final class SettingsModel: ObservableObject { private let bugReports: BugReportService private let diagnosticsIncluded: Bool - private var enableNotificationsAfterSettings = false private var usernamePersistTask: Task? private var hasLocalUsernameDraft = false private var hasRelayConfigurationDraft = false @@ -149,7 +151,6 @@ final class SettingsModel: ObservableObject { self.state.username = self.hasLocalUsernameDraft ? self.state.username : prefs.username self.state.receiveFolder = folder self.state.themeMode = prefs.themeMode - self.state.notificationsEnabled = prefs.notificationsEnabled self.state.diagnosticsEnabled = prefs.diagnosticsEnabled if !self.hasRelayConfigurationDraft { self.state.relayMode = prefs.relayConfiguration.mode @@ -168,7 +169,7 @@ final class SettingsModel: ObservableObject { || coreState.transfers.contains(where: { $0.status.isActiveTransfer }) self.state.hasActiveNetworkWork = hasActiveWork self.state.endpointId = coreState.status?.endpointId - if !hasActiveWork && self.state.relayApplyErrorKey == "relay_apply_active_transfers" { + if !hasActiveWork && self.state.relayApplyErrorKey == L10n.Relay.applyActiveTransfers { self.state.relayApplyErrorKey = nil } } @@ -208,26 +209,23 @@ final class SettingsModel: ObservableObject { func onReceiveFolderPickFailed(_ reason: String) { messages.error(InvitationError.message(reason)) } func resetReceiveFolder() { preferences.resetReceiveFolder() } - func setNotificationsEnabled(_ enabled: Bool) { + /// Whether the current receive folder is the platform default (so the reset + /// action can be hidden when it would be a no-op). Compared by location, not + /// display name, which can differ once resolved. + var isUsingDefaultReceiveFolder: Bool { + guard let folder = state.receiveFolder else { return true } + let fallback = fileSystemService.defaultReceiveFolder() + return folder.kind == fallback.kind && folder.value == fallback.value + } + + /// Ask the OS for notification permission. This is the only time the app can + /// grant it; disabling or fine-tuning afterwards happens in the Settings app. + func requestNotifications() { Task { - if !enabled { - preferences.setNotificationsEnabled(false) - notifications.cancelAll() - return - } let permission = await notifications.requestPermission() state.notificationPermission = permission - if permission == .granted { - await enableNotifications() - } else { - preferences.setNotificationsEnabled(false) - let key = permission == .unsupported ? "notifications_unsupported" : "notifications_permission_denied" - messages.show(UiMessage( - text: .resource(key), - tone: .warning, - actionLabel: permission == .denied ? .resource("button_open_settings") : nil, - onAction: permission == .denied ? { self.openNotificationSettings() } : nil - )) + if permission == .unsupported { + messages.show(UiMessage(text: .resource(L10n.Notifications.unsupported), tone: .warning)) } } } @@ -237,7 +235,7 @@ final class SettingsModel: ObservableObject { Task { preferences.setDiagnosticsEnabled(enabled) messages.show(UiMessage( - text: .resource(enabled ? "diagnostics_enabled_message" : "diagnostics_disabled_message"), + text: .resource(enabled ? L10n.Diagnostics.enabledMessage : L10n.Diagnostics.disabledMessage), tone: .success )) } @@ -298,8 +296,8 @@ final class SettingsModel: ObservableObject { || coreState.transfers.contains(where: { $0.status.isActiveTransfer }) guard !hasActiveWork else { state.hasActiveNetworkWork = true - state.relayApplyErrorKey = "relay_apply_active_transfers" - messages.show(UiMessage(text: .resource("relay_apply_active_transfers"), tone: .warning)) + state.relayApplyErrorKey = L10n.Relay.applyActiveTransfers + messages.show(UiMessage(text: .resource(L10n.Relay.applyActiveTransfers), tone: .warning)) return } @@ -318,16 +316,16 @@ final class SettingsModel: ObservableObject { preferences.setRelayConfiguration(configuration) state.isApplyingRelayConfiguration = false state.relayConfigurationIsDirty = false - messages.show(UiMessage(text: .resource("relay_settings_applied"), tone: .success)) + messages.show(UiMessage(text: .resource(L10n.Relay.settingsApplied), tone: .success)) case .failure(let error): if let lifecycleError = error as? CoreNetworkLifecycleError { state.isApplyingRelayConfiguration = false switch lifecycleError { case .activeNetworkWork: state.hasActiveNetworkWork = true - state.relayApplyErrorKey = "relay_apply_active_transfers" + state.relayApplyErrorKey = L10n.Relay.applyActiveTransfers case .transitionInProgress: - state.relayApplyErrorKey = "relay_apply_failed" + state.relayApplyErrorKey = L10n.Relay.applyFailed } return } @@ -337,11 +335,11 @@ final class SettingsModel: ObservableObject { ) state.isApplyingRelayConfiguration = false if case .success = rollbackResult { - state.relayApplyErrorKey = "relay_apply_failed" - messages.show(UiMessage(text: .resource("relay_apply_failed"), tone: .error)) + state.relayApplyErrorKey = L10n.Relay.applyFailed + messages.show(UiMessage(text: .resource(L10n.Relay.applyFailed), tone: .error)) } else { - state.relayApplyErrorKey = "relay_restore_failed" - messages.show(UiMessage(text: .resource("relay_restore_failed"), tone: .error)) + state.relayApplyErrorKey = L10n.Relay.restoreFailed + messages.show(UiMessage(text: .resource(L10n.Relay.restoreFailed), tone: .error)) } } } @@ -369,11 +367,11 @@ final class SettingsModel: ObservableObject { let what = snapshot.bugWhatHappened.trimmingCharacters(in: .whitespacesAndNewlines) let expected = snapshot.bugExpected.trimmingCharacters(in: .whitespacesAndNewlines) if what.isEmpty { - messages.show(UiMessage(text: .resource("bug_report_missing_what"), tone: .warning)) + messages.show(UiMessage(text: .resource(L10n.Bug.reportMissingWhat), tone: .warning)) return } if expected.isEmpty { - messages.show(UiMessage(text: .resource("bug_report_missing_expected"), tone: .warning)) + messages.show(UiMessage(text: .resource(L10n.Bug.reportMissingExpected), tone: .warning)) return } state.isSubmittingBugReport = true @@ -392,58 +390,55 @@ final class SettingsModel: ObservableObject { state.bugSteps = "" state.bugContact = "" state.bugIncludeLogs = true - messages.show(UiMessage(text: .resource("bug_report_submitted"), tone: .success)) + messages.show(UiMessage(text: .resource(L10n.Bug.reportSubmitted), tone: .success)) onSuccess() case .failure: state.isSubmittingBugReport = false - messages.show(UiMessage(text: .resource("bug_report_submit_failed"), tone: .error)) + messages.show(UiMessage(text: .resource(L10n.Bug.reportSubmitFailed), tone: .error)) } } } func openNotificationSettings() { Task { - enableNotificationsAfterSettings = true - let result = await notifications.openSettings() - if case .failure = result { - enableNotificationsAfterSettings = false - messages.show(UiMessage(text: .resource("notifications_settings_open_failed"), tone: .error)) + if case .failure = await notifications.openSettings() { + messages.show(UiMessage(text: .resource(L10n.Notifications.settingsOpenFailed), tone: .error)) } } } + /// Re-read the OS permission (called on appear and when returning to the + /// foreground, e.g. after a trip to Settings) so the toggle stays in sync. func refreshNotificationPermission() { Task { - let permission = await notifications.refreshPermission() - state.notificationPermission = permission - if enableNotificationsAfterSettings { - enableNotificationsAfterSettings = false - if permission == .granted { await enableNotifications() } - } else if permission != .granted && state.notificationsEnabled { - preferences.setNotificationsEnabled(false) - notifications.cancelAll() - } + state.notificationPermission = await notifications.refreshPermission() } } - private func enableNotifications() async { - preferences.setNotificationsEnabled(true) - messages.show(UiMessage(text: .resource("notifications_enabled_message"), tone: .success)) - } - // MARK: - Storage - /// Recomputes the on-disk usage breakdown off the main actor. + /// Recomputes the on-disk usage breakdown off the main actor. Safe to call + /// before the core is ready: it keeps the spinner up and waits for the core to + /// finish initializing (it starts asynchronously at launch) rather than bailing. func loadStorageUsage() { if state.isCalculatingStorage { return } state.isCalculatingStorage = true + state.storageLoadFailed = false let tempDir = NSTemporaryDirectory() Task { + // The core initializes asynchronously at launch; poll briefly so opening + // Storage early doesn't leave the summary stuck. + var attempts = 0 + while !repository.state.isInitialized && attempts < 100 { + try? await Task.sleep(nanoseconds: 100_000_000) + attempts += 1 + } let coreResult = await repository.storageUsage() let artifactsResult = await repository.receivedArtifacts() guard case .success(let core) = coreResult, case .success(let artifacts) = artifactsResult else { state.isCalculatingStorage = false + state.storageLoadFailed = true return } let diskSizes = await Task.detached { @@ -478,13 +473,90 @@ final class SettingsModel: ObservableObject { state.isDeletingTransfers = false if failures == 0 { loadStorageUsage() - messages.show(UiMessage(text: .resource("storage_transfers_deleted"), tone: .success)) + messages.show(UiMessage(text: .resource(L10n.Storage.transfersDeleted), tone: .success)) } else { messages.error(InvitationError.message("Could not delete \(failures) transfer records")) } } } + /// Reclaims disk space the core's transfer deletion doesn't touch: the app's + /// temporary directory (leftover picker/staging copies) and any stray `.Trash` + /// folders that accumulate inside app-owned directories. Never touches received + /// files, the core database, or user-chosen receive folders. + func freeUpSpace() { + if state.isCleaningStorage { return } + // Purging staging while a transfer is mid-flight could break it. + let hasActive = repository.state.transfers.contains { + $0.status == .sharing || $0.status == .importing || $0.status == .receiving + } + if hasActive { + messages.tryShow(UiMessage(text: .resource(L10n.Storage.cleanupBusy), tone: .warning)) + return + } + state.isCleaningStorage = true + let tempDir = NSTemporaryDirectory() + let dataDir = environment.defaultCoreDataDir + // Only clean the receive folder's trash when it is app-owned (iOS fixed + // Documents), never a user-chosen macOS folder like ~/Downloads. + let receiveTrashRoot = fileSystemService.supportsCustomReceiveFolders ? nil : state.receiveFolder?.value + Task { + let freed = await Task.detached { + SettingsModel.reclaimJunk(tempDir: tempDir, dataDir: dataDir, receiveTrashRoot: receiveTrashRoot) + }.value + state.isCleaningStorage = false + loadStorageUsage() + messages.show(UiMessage( + text: .dynamic(L10n.Storage.cleanupFreed(size: formatBytes(freed))), + tone: .success + )) + } + } + + /// Deletes temp-directory contents and `.Trash` folders under the given roots, + /// returning the number of bytes reclaimed. Runs off the main actor. + nonisolated static func reclaimJunk(tempDir: String, dataDir: String, receiveTrashRoot: String?) -> UInt64 { + let fm = FileManager.default + var freed: UInt64 = 0 + // Empty the temporary directory. + if let entries = try? fm.contentsOfDirectory(atPath: tempDir) { + for name in entries { + let path = (tempDir as NSString).appendingPathComponent(name) + freed += itemSize(path) + try? fm.removeItem(atPath: path) + } + } + // Remove stray `.Trash` folders inside app-owned directories. + for root in [dataDir, receiveTrashRoot].compactMap({ $0 }) { + for trash in trashDirectories(under: root) { + freed += directorySize(trash) + try? fm.removeItem(atPath: trash) + } + } + return freed + } + + /// Paths of every directory named `.Trash` under `root` (not descending into them). + private nonisolated static func trashDirectories(under root: String) -> [String] { + let url = URL(fileURLWithPath: root, isDirectory: true) + guard let enumerator = FileManager.default.enumerator( + at: url, includingPropertiesForKeys: [.isDirectoryKey] + ) else { return [] } + var result: [String] = [] + for case let fileURL as URL in enumerator where fileURL.lastPathComponent == ".Trash" { + result.append(fileURL.path) + enumerator.skipDescendants() + } + return result + } + + /// Allocated size of a file or directory (0 if missing). + private nonisolated static func itemSize(_ path: String) -> UInt64 { + var isDirectory: ObjCBool = false + guard FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory) else { return 0 } + return isDirectory.boolValue ? directorySize(path) : fileSize(path) + } + nonisolated static func fileSize(_ path: String) -> UInt64 { let values = try? URL(fileURLWithPath: path).resourceValues( forKeys: [.isRegularFileKey, .totalFileAllocatedSizeKey, .fileSizeKey] diff --git a/apple/VniDrop/Features/Settings/SettingsScreen.swift b/apple/VniDrop/Features/Settings/SettingsScreen.swift index bbc9951..5b436fa 100644 --- a/apple/VniDrop/Features/Settings/SettingsScreen.swift +++ b/apple/VniDrop/Features/Settings/SettingsScreen.swift @@ -1,4 +1,5 @@ import SwiftUI +import SFSafeSymbols /// Settings screen, rebuilt on a native `Form` with `NavigationStack` push /// navigation. The model stays the source of truth via a derived path binding. @@ -25,37 +26,37 @@ struct SettingsScreen: View { Form { Section { NavigationLink(value: SettingsSection.preferences) { - SettingsRow(icon: "person.crop.circle", title: String(localized: "preferences_title"), value: model.state.username) + SettingsRow(icon: .personCropCircle, title: String(localized: L10n.Preferences.title), value: model.state.username) } NavigationLink(value: SettingsSection.appearance) { - SettingsRow(icon: "sun.max", title: String(localized: "appearance_title"), value: themeModeLabel(model.state.themeMode)) + SettingsRow(icon: .sunMax, title: String(localized: L10n.Appearance.title), value: themeModeLabel(model.state.themeMode)) } } Section { NavigationLink(value: SettingsSection.notifications) { - SettingsRow(icon: "bell", title: String(localized: "notifications_title"), value: nil) + SettingsRow(icon: .bell, title: String(localized: L10n.Notifications.title), value: nil) } NavigationLink(value: SettingsSection.storage) { - SettingsRow(icon: "internaldrive", title: String(localized: "storage_title"), value: nil) + SettingsRow(icon: .internaldrive, title: String(localized: L10n.Storage.title), value: nil) } } - Section(String(localized: "settings_advanced_title")) { + Section(String(localized: L10n.Settings.advancedTitle)) { NavigationLink(value: SettingsSection.network) { SettingsRow( - icon: "network", - title: String(localized: "settings_network_title"), + icon: .network, + title: String(localized: L10n.Settings.networkTitle), value: relayModeLabel(model.state.relayMode) ) } } Section { NavigationLink(value: SettingsSection.about) { - SettingsRow(icon: "info.circle", title: String(localized: "about_title"), value: nil) + SettingsRow(icon: .infoCircle, title: String(localized: L10n.About.title), value: nil) } } } .formStyle(.grouped) - .navigationTitle(Text(LocalizedStringKey("settings_title"))) + .navigationTitle(Text(String(localized: L10n.Settings.title))) .navigationDestination(for: SettingsSection.self) { section in sectionForm(section) } @@ -68,7 +69,7 @@ struct SettingsScreen: View { SettingsSectionContent(model: model, section: section) } .formStyle(.grouped) - .navigationTitle(Text(LocalizedStringKey(section.titleKey))) + .navigationTitle(Text(String(localized: section.titleKey))) if section == .about { content @@ -80,7 +81,7 @@ struct SettingsScreen: View { Button { showBugReport = true } label: { - Label(String(localized: "about_bug_report"), systemImage: "ladybug") + Label(String(localized: L10n.About.bugReport), systemSymbol: .ladybug) } } } @@ -124,30 +125,30 @@ private struct SettingsSectionContent: View { func relayModeLabel(_ mode: RelayPreferenceMode) -> String { switch mode { - case .automatic: return String(localized: "relay_mode_automatic") - case .strictCustom: return String(localized: "relay_mode_custom") - case .customWithDirectFallback: return String(localized: "relay_mode_custom_direct_fallback") - case .localOnly: return String(localized: "relay_mode_local_only") + case .automatic: return String(localized: L10n.Relay.modeAutomatic) + case .strictCustom: return String(localized: L10n.Relay.modeCustom) + case .customWithDirectFallback: return String(localized: L10n.Relay.modeCustomDirectFallback) + case .localOnly: return String(localized: L10n.Relay.modeLocalOnly) } } -func relayModeDescriptionKey(_ mode: RelayPreferenceMode) -> String { +func relayModeDescription(_ mode: RelayPreferenceMode) -> String.LocalizationValue { switch mode { - case .automatic: return "relay_mode_automatic_description" - case .strictCustom: return "relay_mode_custom_description" - case .customWithDirectFallback: return "relay_mode_custom_direct_fallback_description" - case .localOnly: return "relay_mode_local_only_description" + case .automatic: return L10n.Relay.modeAutomaticDescription + case .strictCustom: return L10n.Relay.modeCustomDescription + case .customWithDirectFallback: return L10n.Relay.modeCustomDirectFallbackDescription + case .localOnly: return L10n.Relay.modeLocalOnlyDescription } } struct SettingsRow: View { - let icon: String + let icon: SFSymbol let title: String let value: String? var body: some View { HStack(spacing: 12) { - Image(systemName: icon) + Image(systemSymbol: icon) .foregroundStyle(.tint) .frame(width: 26) Text(title).foregroundStyle(.primary) @@ -161,8 +162,8 @@ struct SettingsRow: View { func themeModeLabel(_ mode: ThemeMode) -> String { switch mode { - case .system: return String(localized: "appearance_system_mode") - case .light: return String(localized: "appearance_light_mode") - case .dark: return String(localized: "appearance_dark_mode") + case .system: return String(localized: L10n.Appearance.systemMode) + case .light: return String(localized: L10n.Appearance.lightMode) + case .dark: return String(localized: L10n.Appearance.darkMode) } } diff --git a/apple/VniDrop/Features/Settings/SettingsSections.swift b/apple/VniDrop/Features/Settings/SettingsSections.swift index 4d7698e..88f21af 100644 --- a/apple/VniDrop/Features/Settings/SettingsSections.swift +++ b/apple/VniDrop/Features/Settings/SettingsSections.swift @@ -1,4 +1,5 @@ import SwiftUI +import SFSafeSymbols /// Settings section detail views, rebuilt as native `Form` content. Each view is /// placed inside a parent `Form`, so it returns `Section`s / rows directly. @@ -7,16 +8,26 @@ struct PreferencesSettings: View { @ObservedObject var model: SettingsModel var body: some View { - Section(String(localized: "field_username")) { - TextField(String(localized: "field_username"), + Section(String(localized: L10n.Field.username)) { + TextField(String(localized: L10n.Field.username), text: Binding(get: { model.state.username }, set: { model.setUsername($0) })) } if model.state.supportsCustomReceiveFolders { - Section(String(localized: "preferences_receive_folder_title")) { - Text(model.state.receiveFolder?.displayName ?? String(localized: "value_unavailable")) - .foregroundStyle(.secondary) - Button(String(localized: "button_choose_folder"), action: model.chooseReceiveFolder) - Button(String(localized: "button_reset_default"), action: model.resetReceiveFolder) + Section(String(localized: L10n.Preferences.receiveFolderTitle)) { + LabeledContent { + Button(String(localized: L10n.Button.chooseFolder), action: model.chooseReceiveFolder) + } label: { + Label { + Text(model.state.receiveFolder?.displayName ?? String(localized: L10n.Value.unavailable)) + .lineLimit(1) + .truncationMode(.middle) + } icon: { + Image(systemSymbol: .folder) + } + } + if !model.isUsingDefaultReceiveFolder { + Button(String(localized: L10n.Button.resetDefault), role: .cancel, action: model.resetReceiveFolder) + } } } } @@ -27,7 +38,7 @@ struct AppearanceSettings: View { var body: some View { Section { - Picker(String(localized: "appearance_title"), + Picker(String(localized: L10n.Appearance.title), selection: Binding(get: { model.state.themeMode }, set: { model.setThemeMode($0) })) { ForEach(ThemeMode.allCases, id: \.self) { mode in Text(themeModeLabel(mode)).tag(mode) @@ -44,16 +55,22 @@ struct NotificationSettings: View { var body: some View { Section { - Toggle(isOn: Binding( - get: { model.state.notificationsEnabled }, - set: { model.setNotificationsEnabled($0) } - )) { - Text(LocalizedStringKey("notifications_local_title")) - } - if model.state.notificationPermission == .denied { - Button(String(localized: "button_open_settings"), action: model.openNotificationSettings) + Text(String(localized: L10n.Notifications.description)).foregroundStyle(.secondary) + switch model.state.notificationPermission { + case .notDetermined: + Button(String(localized: L10n.Notifications.localTitle), action: model.requestNotifications) + case .granted: + // Allowed — the OS Settings app is where you disable or fine-tune. + Text(String(localized: L10n.Notifications.enabledMessage)).foregroundStyle(.secondary) + Button(String(localized: L10n.Button.openSettings), action: model.openNotificationSettings) + case .denied: + Text(String(localized: L10n.Notifications.permissionDenied)).foregroundStyle(.secondary) + Button(String(localized: L10n.Button.openSettings), action: model.openNotificationSettings) + case .unsupported: + Text(String(localized: L10n.Notifications.unsupported)).foregroundStyle(.secondary) } } + .onAppear { model.refreshNotificationPermission() } } } @@ -63,7 +80,7 @@ struct NetworkSettings: View { var body: some View { Section { Picker( - String(localized: "settings_network_title"), + "", selection: Binding(get: { model.state.relayMode }, set: { model.setRelayMode($0) }) ) { ForEach(RelayPreferenceMode.allCases, id: \.self) { mode in @@ -71,24 +88,27 @@ struct NetworkSettings: View { } } .pickerStyle(.inline) + .labelsHidden() .disabled(model.state.isApplyingRelayConfiguration) + } header: { + Text(String(localized: L10n.Settings.networkTitle)) } footer: { - Text(LocalizedStringKey(relayModeDescriptionKey(model.state.relayMode))) + Text(String(localized: relayModeDescription(model.state.relayMode))) } Section { Label { - Text(LocalizedStringKey("relay_privacy_description")) + Text(String(localized: L10n.Relay.privacyDescription)) .fixedSize(horizontal: false, vertical: true) } icon: { - Image(systemName: "lock.shield") + Image(systemSymbol: .lockShield) } .foregroundStyle(.secondary) } if let endpointId = model.state.endpointId, !endpointId.isEmpty { Section { - Text(String(format: String(localized: "approval_endpoint_id"), endpointId)) + Text(L10n.Approval.endpointId(deviceId: endpointId)) .font(.footnote.monospaced()) .textSelection(.enabled) } @@ -98,10 +118,10 @@ struct NetworkSettings: View { Section { if model.state.relayMode == .strictCustom { Label { - Text(LocalizedStringKey("relay_strict_warning")) + Text(String(localized: L10n.Relay.strictWarning)) .fixedSize(horizontal: false, vertical: true) } icon: { - Image(systemName: "exclamationmark.shield.fill") + Image(systemSymbol: .exclamationmarkShieldFill) } .foregroundStyle(.orange) } @@ -110,7 +130,7 @@ struct NetworkSettings: View { VStack(alignment: .leading, spacing: 6) { HStack { TextField( - "https://relay.example.com", + "", text: Binding( get: { model.state.relayURLs.indices.contains(index) @@ -118,8 +138,12 @@ struct NetworkSettings: View { : "" }, set: { model.setRelayURL($0, at: index) } - ) + ), + // `Text(verbatim:)` avoids macOS markdown-linkifying the + // URL-shaped placeholder into a purple link. + prompt: Text(verbatim: "https://relay.example.com") ) + .labelsHidden() #if os(iOS) .keyboardType(.URL) .textInputAutocapitalization(.never) @@ -130,10 +154,10 @@ struct NetworkSettings: View { Button(role: .destructive) { model.removeRelayURL(at: index) } label: { - Image(systemName: "minus.circle.fill") + Image(systemSymbol: .minusCircleFill) } .buttonStyle(.borderless) - .accessibilityLabel(Text(LocalizedStringKey("relay_remove_url"))) + .accessibilityLabel(Text(String(localized: L10n.Relay.removeUrl))) .disabled(model.state.isApplyingRelayConfiguration) } @@ -146,16 +170,16 @@ struct NetworkSettings: View { } Button(action: model.addRelayURL) { - Label(String(localized: "relay_add_url"), systemImage: "plus.circle") + Label(String(localized: L10n.Relay.addUrl), systemSymbol: .plusCircle) } .disabled( model.state.relayURLs.count >= RelayConfigurationValidator.maximumRelayCount || model.state.isApplyingRelayConfiguration ) } header: { - Text(LocalizedStringKey("relay_custom_urls_label")) + Text(String(localized: L10n.Relay.customUrlsLabel)) } footer: { - Text(LocalizedStringKey("relay_custom_urls_help")) + Text(String(localized: L10n.Relay.customUrlsHelp)) } } @@ -164,7 +188,7 @@ struct NetworkSettings: View { Label { Text(relayValidationMessage(error)) } icon: { - Image(systemName: "exclamationmark.triangle.fill") + Image(systemSymbol: .exclamationmarkTriangleFill) } .foregroundStyle(.red) } @@ -173,13 +197,9 @@ struct NetworkSettings: View { if model.state.hasActiveNetworkWork || model.state.relayApplyErrorKey != nil { Section { Label { - Text(LocalizedStringKey( - model.state.hasActiveNetworkWork - ? "relay_apply_active_transfers" - : model.state.relayApplyErrorKey ?? "relay_apply_failed" - )) + Text(String(localized: model.state.hasActiveNetworkWork ? L10n.Relay.applyActiveTransfers : (model.state.relayApplyErrorKey ?? L10n.Relay.applyFailed))) } icon: { - Image(systemName: "exclamationmark.triangle.fill") + Image(systemSymbol: .exclamationmarkTriangleFill) } .foregroundStyle(.red) } @@ -188,9 +208,7 @@ struct NetworkSettings: View { Section { Button(action: model.applyRelayConfiguration) { HStack { - Text(LocalizedStringKey( - model.state.isApplyingRelayConfiguration ? "relay_applying" : "relay_apply" - )) + Text(String(localized: model.state.isApplyingRelayConfiguration ? L10n.Relay.applying : L10n.Relay.apply)) if model.state.isApplyingRelayConfiguration { Spacer() ProgressView() @@ -203,7 +221,7 @@ struct NetworkSettings: View { || model.state.hasActiveNetworkWork ) } footer: { - Text(LocalizedStringKey("relay_apply_restart_description")) + Text(String(localized: L10n.Relay.applyRestartDescription)) } } } @@ -211,18 +229,15 @@ struct NetworkSettings: View { private func relayValidationMessage(_ error: RelayConfigurationValidationError) -> String { switch error { case .missingURL: - return String(localized: "relay_validation_missing_url") + return String(localized: L10n.Relay.validationMissingUrl) case .tooManyURLs: - return String( - format: String(localized: "relay_validation_too_many_urls"), - RelayConfigurationValidator.maximumRelayCount - ) + return L10n.Relay.validationTooManyUrls(maximum: RelayConfigurationValidator.maximumRelayCount) case .httpsRequired(let index): - return String(format: String(localized: "relay_validation_https_required"), index + 1) + return L10n.Relay.validationHttpsRequired(line: index + 1) case .invalidURL(let index): - return String(format: String(localized: "relay_validation_invalid_url"), index + 1) + return L10n.Relay.validationInvalidUrl(line: index + 1) case .duplicateURL(let index): - return String(format: String(localized: "relay_validation_duplicate_url"), index + 1) + return L10n.Relay.validationDuplicateUrl(line: index + 1) } } @@ -230,57 +245,131 @@ struct StorageSettings: View { @ObservedObject var model: SettingsModel @State private var showDeleteConfirmation = false + private var isBusy: Bool { + model.state.isCalculatingStorage || model.state.isCleaningStorage || model.state.isDeletingTransfers + } + var body: some View { Section { - if let storage = model.state.storage { - LabeledContent(String(localized: "storage_received_files"), value: formatBytes(storage.receivedFiles)) - LabeledContent(String(localized: "storage_transfer_data"), value: formatBytes(storage.transferCache)) - LabeledContent(String(localized: "storage_app_data"), value: formatBytes(storage.appData)) - LabeledContent(String(localized: "storage_temporary"), value: formatBytes(storage.temporary)) - LabeledContent(String(localized: "storage_total")) { - Text(formatBytes(storage.total)).fontWeight(.semibold) - } - } else { - HStack { - Text(LocalizedStringKey("storage_calculating")).foregroundStyle(.secondary) - Spacer() - ProgressView() + usageContent + } header: { + HStack { + Text(String(localized: L10n.Storage.usageHeader)) + Spacer() + if model.state.isCalculatingStorage { + ProgressView().controlSize(.small) + } else { + Button(action: model.loadStorageUsage) { + Label(String(localized: L10n.Storage.refresh), systemSymbol: .arrowClockwise) + .labelStyle(.iconOnly) + } + .buttonStyle(.borderless) + .disabled(isBusy) + .help(String(localized: L10n.Storage.refresh)) } } } footer: { - Text(LocalizedStringKey("storage_footer")) + Text(String(localized: L10n.Storage.footer)) } + // Reclaim reversible junk (temp + trash) — non-destructive to history. Section { - Button(role: .destructive) { + Button(action: model.freeUpSpace) { + actionLabel( + title: L10n.Storage.freeUpSpace, + busyTitle: L10n.Storage.cleaning, + isBusy: model.state.isCleaningStorage, + symbol: .sparkles, + tint: .accentColor + ) + } + // `.plain` so pressing the row dims the label instead of flipping it to + // the white selection-highlight that the default form button style uses. + .buttonStyle(.plain) + .disabled(isBusy) + } footer: { + Text(String(localized: L10n.Storage.freeUpSpaceCaption)) + } + + // Destructive: clears transfer history + cached share content. + Section { + Button { showDeleteConfirmation = true } label: { - HStack { - Text(model.state.isDeletingTransfers - ? String(localized: "storage_deleting") - : String(localized: "storage_delete_transfers")) - if model.state.isDeletingTransfers { - Spacer() - ProgressView() - } - } + actionLabel( + title: L10n.Storage.deleteTransfers, + busyTitle: L10n.Storage.deleting, + isBusy: model.state.isDeletingTransfers, + symbol: .trash, + tint: .red + ) } - .disabled(model.state.isDeletingTransfers) + .buttonStyle(.plain) + .disabled(isBusy) + } footer: { + Text(String(localized: L10n.Storage.deleteTransfersCaption)) } - .onAppear { model.loadStorageUsage() } + .task { model.loadStorageUsage() } .confirmationDialog( - Text(LocalizedStringKey("storage_delete_transfers")), + Text(String(localized: L10n.Storage.deleteTransfers)), isPresented: $showDeleteConfirmation, titleVisibility: .visible ) { - Button(String(localized: "storage_delete_transfers"), role: .destructive) { + Button(String(localized: L10n.Storage.deleteTransfers), role: .destructive) { model.deleteAllTransfers() } - Button(String(localized: "button_cancel"), role: .cancel) {} + Button(String(localized: L10n.Button.cancel), role: .cancel) {} } message: { - Text(LocalizedStringKey("storage_delete_transfers_description")) + Text(String(localized: L10n.Storage.deleteTransfersDescription)) } } + + @ViewBuilder + private var usageContent: some View { + if let storage = model.state.storage { + LabeledContent(String(localized: L10n.Storage.receivedFiles), value: formatBytes(storage.receivedFiles)) + LabeledContent(String(localized: L10n.Storage.transferData), value: formatBytes(storage.transferCache)) + LabeledContent(String(localized: L10n.Storage.appData), value: formatBytes(storage.appData)) + LabeledContent(String(localized: L10n.Storage.temporary), value: formatBytes(storage.temporary)) + LabeledContent(String(localized: L10n.Storage.total)) { + Text(formatBytes(storage.total)).fontWeight(.semibold) + } + } else if model.state.storageLoadFailed { + // Genuine failure (core reported an error) — offer a retry. + Button(action: model.loadStorageUsage) { + Label(String(localized: L10n.Storage.unavailable), systemSymbol: .arrowClockwise) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + } else { + // Loading, or waiting for the core to finish starting. + HStack { + Text(String(localized: L10n.Storage.calculating)).foregroundStyle(.secondary) + Spacer() + ProgressView().controlSize(.small) + } + } + } + + /// A tinted, full-width button label with a leading symbol and a trailing + /// spinner while busy. `.contentShape` keeps the whole row tappable. + private func actionLabel( + title: String.LocalizationValue, + busyTitle: String.LocalizationValue, + isBusy: Bool, + symbol: SFSymbol, + tint: Color + ) -> some View { + HStack { + Label(String(localized: isBusy ? busyTitle : title), systemSymbol: symbol) + Spacer() + if isBusy { + ProgressView().controlSize(.small) + } + } + .foregroundStyle(tint) + .contentShape(Rectangle()) + } } struct AboutSettings: View { @@ -290,40 +379,40 @@ struct AboutSettings: View { var body: some View { Section { - Text(LocalizedStringKey("about_tagline")).font(.headline) - Text(LocalizedStringKey("about_description")).foregroundStyle(.secondary) + Text(String(localized: L10n.About.tagline)).font(.headline) + Text(String(localized: L10n.About.description)).foregroundStyle(.secondary) } - Section(String(localized: "about_is_title")) { - AboutPoint("about_is_direct", "paperplane") - AboutPoint("about_is_no_account", "person.crop.circle.badge.xmark") - AboutPoint("about_is_in_control", "checkmark.shield") - AboutPoint("about_is_encrypted", "lock") - AboutPoint("about_is_open", "chevron.left.forwardslash.chevron.right") + Section(String(localized: L10n.About.isTitle)) { + AboutPoint(L10n.About.isDirect, .paperplane) + AboutPoint(L10n.About.isNoAccount, .personCropCircleBadgeXmark) + AboutPoint(L10n.About.isInControl, .checkmarkShield) + AboutPoint(L10n.About.isEncrypted, .lock) + AboutPoint(L10n.About.isOpen, .chevronLeftForwardslashChevronRight) } - Section(String(localized: "about_isnt_title")) { - AboutPoint("about_isnt_cloud", "icloud.slash") - AboutPoint("about_isnt_sync", "arrow.triangle.2.circlepath") - AboutPoint("about_isnt_public", "megaphone") + Section(String(localized: L10n.About.isntTitle)) { + AboutPoint(L10n.About.isntCloud, .icloudSlash) + AboutPoint(L10n.About.isntSync, .arrowTriangle2Circlepath) + AboutPoint(L10n.About.isntPublic, .megaphone) } - Section(String(localized: "about_privacy_title")) { - AboutPoint("about_privacy_capability", "qrcode") - AboutPoint("about_privacy_deny", "hand.raised") - AboutPoint("about_privacy_relay", "antenna.radiowaves.left.and.right") - AboutPoint("about_privacy_local", "internaldrive") + Section(String(localized: L10n.About.privacyTitle)) { + AboutPoint(L10n.About.privacyCapability, .qrcode) + AboutPoint(L10n.About.privacyDeny, .handRaised) + AboutPoint(L10n.About.privacyRelay, .antennaRadiowavesLeftAndRight) + AboutPoint(L10n.About.privacyLocal, .internaldrive) } - Section(String(localized: "about_title")) { - LabeledContent(String(localized: "version_title"), value: model.state.appVersion) + Section(String(localized: L10n.About.title)) { + LabeledContent(String(localized: L10n.Version.title), value: model.state.appVersion) if let device = model.state.deviceInfo { - LabeledContent(String(localized: "device_model_title"), value: device.deviceModel ?? "—") - LabeledContent(String(localized: "os_version_title"), value: device.operatingSystem) + LabeledContent(String(localized: L10n.Device.modelTitle), value: device.deviceModel ?? "—") + LabeledContent(String(localized: L10n.Os.versionTitle), value: device.operatingSystem) } - LabeledContent(String(localized: "about_license_label"), value: "Apache 2.0") + LabeledContent(String(localized: L10n.About.licenseLabel), value: "Apache 2.0") Link(destination: Self.privacyPolicyURL) { - Label(String(localized: "about_privacy_policy_label"), systemImage: "hand.raised") + Label(String(localized: L10n.About.privacyPolicyLabel), systemSymbol: .handRaised) } } @@ -333,7 +422,7 @@ struct AboutSettings: View { get: { model.state.diagnosticsEnabled }, set: { model.setDiagnosticsEnabled($0) } )) { - Text(LocalizedStringKey("diagnostics_title")) + Text(String(localized: L10n.Diagnostics.title)) } } } @@ -357,13 +446,13 @@ struct BugReportSheet: View { BugReportSettings(model: model, onSubmitted: { dismiss() }) } .formStyle(.grouped) - .navigationTitle(Text(LocalizedStringKey("about_bug_report"))) + .navigationTitle(Text(String(localized: L10n.About.bugReport))) #if os(iOS) .navigationBarTitleDisplayMode(.inline) #endif .toolbar { ToolbarItem(placement: .cancellationAction) { - Button(String(localized: "button_cancel")) { dismiss() } + Button(String(localized: L10n.Button.cancel)) { dismiss() } } } } @@ -373,21 +462,21 @@ struct BugReportSheet: View { /// A bullet-style informational row with an SF Symbol and wrapping localized text. private struct AboutPoint: View { - let key: String - let symbol: String + let key: String.LocalizationValue + let symbol: SFSymbol - init(_ key: String, _ symbol: String) { + init(_ key: String.LocalizationValue, _ symbol: SFSymbol) { self.key = key self.symbol = symbol } var body: some View { Label { - Text(LocalizedStringKey(key)) + Text(String(localized: key)) .font(.subheadline) .fixedSize(horizontal: false, vertical: true) } icon: { - Image(systemName: symbol).foregroundStyle(.tint) + Image(systemSymbol: symbol).foregroundStyle(.tint) } } } @@ -397,36 +486,36 @@ struct BugReportSettings: View { var onSubmitted: () -> Void = {} var body: some View { - Section(String(localized: "bug_report_what_label")) { + Section(String(localized: L10n.Bug.reportWhatLabel)) { TextField("", text: Binding(get: { model.state.bugWhatHappened }, set: { model.setBugWhatHappened($0) }), - prompt: Text(LocalizedStringKey("bug_report_what_hint")), axis: .vertical) + prompt: Text(String(localized: L10n.Bug.reportWhatHint)), axis: .vertical) .lineLimit(3, reservesSpace: true) .labelsHidden() } - Section(String(localized: "bug_report_expected_label")) { + Section(String(localized: L10n.Bug.reportExpectedLabel)) { TextField("", text: Binding(get: { model.state.bugExpected }, set: { model.setBugExpected($0) }), - prompt: Text(LocalizedStringKey("bug_report_expected_hint")), axis: .vertical) + prompt: Text(String(localized: L10n.Bug.reportExpectedHint)), axis: .vertical) .lineLimit(3, reservesSpace: true) .labelsHidden() } - Section(String(localized: "bug_report_steps_label")) { + Section(String(localized: L10n.Bug.reportStepsLabel)) { TextField("", text: Binding(get: { model.state.bugSteps }, set: { model.setBugSteps($0) }), - prompt: Text(LocalizedStringKey("bug_report_steps_hint")), axis: .vertical) + prompt: Text(String(localized: L10n.Bug.reportStepsHint)), axis: .vertical) .lineLimit(3, reservesSpace: true) .labelsHidden() } - Section(String(localized: "bug_report_contact_label")) { + Section(String(localized: L10n.Bug.reportContactLabel)) { TextField("", text: Binding(get: { model.state.bugContact }, set: { model.setBugContact($0) }), - prompt: Text(LocalizedStringKey("bug_report_contact_hint"))) + prompt: Text(String(localized: L10n.Bug.reportContactHint))) .labelsHidden() } Section { Toggle(isOn: Binding(get: { model.state.bugIncludeLogs }, set: { model.setBugIncludeLogs($0) })) { - Text(LocalizedStringKey("bug_report_include_logs")) + Text(String(localized: L10n.Bug.reportIncludeLogs)) } Button(action: { model.submitBugReport(onSuccess: onSubmitted) }) { Text(model.state.isSubmittingBugReport - ? String(localized: "bug_report_submitting") : String(localized: "bug_report_submit")) + ? String(localized: L10n.Bug.reportSubmitting) : String(localized: L10n.Bug.reportSubmit)) } .disabled(model.state.isSubmittingBugReport) } diff --git a/apple/VniDrop/Platform/AppDependencies+iOS.swift b/apple/VniDrop/Platform/AppDependencies+iOS.swift index 1c27c43..e4d5bd2 100644 --- a/apple/VniDrop/Platform/AppDependencies+iOS.swift +++ b/apple/VniDrop/Platform/AppDependencies+iOS.swift @@ -36,7 +36,7 @@ private struct IosDeviceInfoProvider: DeviceInfoProvider { device.isBatteryMonitoringEnabled = true defer { device.isBatteryMonitoringEnabled = wasMonitoring } let level = device.batteryLevel - return level >= 0 ? "\(Int(level * 100))%" : nil + return level >= 0 ? L10n.Battery.levelValue(level: "\(Int(level * 100))") : nil }() return DeviceInfo( deviceName: device.name, diff --git a/apple/VniDrop/Resources/Assets.xcassets/AccentColor.colorset/Contents.json b/apple/VniDrop/Resources/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..e4c3f7f --- /dev/null +++ b/apple/VniDrop/Resources/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.9685", + "green" : "0.3315", + "red" : "0.6606" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apple/VniDrop/Resources/Info.plist b/apple/VniDrop/Resources/Info.plist index 904953d..a2613e4 100644 --- a/apple/VniDrop/Resources/Info.plist +++ b/apple/VniDrop/Resources/Info.plist @@ -20,6 +20,10 @@ $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName VniDrop + + LSMultipleInstancesProhibited + CFBundleDocumentTypes @@ -63,6 +67,10 @@ VniDrop uses the camera to scan transfer QR codes. NSLocalNetworkUsageDescription VniDrop needs local network access to send to other local devices if needed. + + UIApplicationSupportsMultipleScenes + UIBackgroundModes fetch diff --git a/apple/VniDrop/Resources/Localizable.xcstrings b/apple/VniDrop/Resources/Localizable.xcstrings deleted file mode 100644 index 1d0b86e..0000000 --- a/apple/VniDrop/Resources/Localizable.xcstrings +++ /dev/null @@ -1,17426 +0,0 @@ -{ - "sourceLanguage": "en", - "strings": { - "%@": { - "comment": "Apple format passthrough placeholder (single value). Legacy literal key — rename to a semantic key.", - "extractionState": "manual" - }, - "%@ %@ · %@": { - "comment": "Apple format template composing three values with a middot separator (e.g. metadata rows). Legacy literal key — rename.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "%1$@ %2$@ · %3$@" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "%1$@ %2$@ · %3$@" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "%1$@ %2$@ · %3$@" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "%1$@ %2$@ · %3$@" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "%1$@ %2$@ · %3$@" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "%1$@ %2$@ · %3$@" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "%1$@ %2$@ · %3$@" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "%1$@ %2$@ · %3$@" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "%1$@ %2$@ · %3$@" - } - } - } - }, - "%@ · %@": { - "comment": "Apple format template composing two values with a middot separator. Legacy literal key — rename.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "%1$@ · %2$@" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "%1$@ · %2$@" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "%1$@ · %2$@" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "%1$@ · %2$@" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "%1$@ · %2$@" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "%1$@ · %2$@" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "%1$@ · %2$@" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "%1$@ · %2$@" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "%1$@ · %2$@" - } - } - } - }, - "%@%%": { - "comment": "Apple format template appending a percent sign to a value (e.g. battery level). Legacy literal key — rename.", - "extractionState": "manual" - }, - "about_bug_report": { - "comment": "About screen: button/link that opens the bug report form.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Fehler melden" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Report a bug" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Informar de un error" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Signaler un bug" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Segnala un bug" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Een fout melden" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Zgłoś błąd" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Comunicar um erro" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Сообщить об ошибке" - } - } - } - }, - "about_description": { - "comment": "About screen: intro paragraph describing what VniDrop does.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "VniDrop überträgt Dateien und Ordner direkt von einem Gerät auf ein anderes über das Netzwerk, ohne sie auf einen Hosting-Dienst hochzuladen. Es muss kein Konto erstellt werden, und nach Abschluss verbleibt keine Kopie Ihrer Übertragung in der Cloud." - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "VniDrop moves files and folders straight from one device to another over the network, without uploading them to any file-hosting service. There’s no account to create, and no copy of your transfer is left in the cloud once you’re done." - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "VniDrop transfiere archivos y carpetas directamente de un dispositivo a otro a través de la red, sin subirlos a ningún servicio de alojamiento. No hay que crear ninguna cuenta y no queda ninguna copia de su transferencia en la nube una vez que termina." - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "VniDrop transfère fichiers et dossiers directement d’un appareil à un autre sur le réseau, sans les téléverser vers un service d’hébergement. Aucun compte à créer, et aucune copie de votre transfert ne reste dans le cloud une fois terminé." - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "VniDrop trasferisce file e cartelle direttamente da un dispositivo a un altro sulla rete, senza caricarli su alcun servizio di hosting. Non c’è alcun account da creare e nessuna copia del suo trasferimento rimane nel cloud una volta terminato." - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "VniDrop verplaatst bestanden en mappen rechtstreeks van het ene apparaat naar het andere via het netwerk, zonder ze naar een hostingdienst te uploaden. U hoeft geen account aan te maken en er blijft geen kopie van uw overdracht in de cloud achter zodra u klaar bent." - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "VniDrop przesyła pliki i foldery bezpośrednio z jednego urządzenia na drugie przez sieć, bez przesyłania ich do jakiejkolwiek usługi hostingowej. Nie trzeba zakładać konta, a po zakończeniu żadna kopia transferu nie pozostaje w chmurze." - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "O VniDrop transfere ficheiros e pastas diretamente de um dispositivo para outro através da rede, sem os carregar para qualquer serviço de alojamento. Não é necessário criar qualquer conta e não fica nenhuma cópia da sua transferência na nuvem depois de terminar." - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "VniDrop передаёт файлы и папки напрямую с одного устройства на другое по сети, не загружая их в какой-либо хостинг-сервис. Не нужно создавать учётную запись, и после завершения ни одна копия вашей передачи не остаётся в облаке." - } - } - } - }, - "about_is_direct": { - "comment": "About screen, 'What VniDrop is' list: point about direct device-to-device transfer.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Eine direkte Gerät-zu-Gerät-Übertragung – Ihre Dateien gehen direkt an den Empfänger." - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "A direct device-to-device transfer — your files go straight to the receiver." - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Una transferencia directa entre dispositivos: sus archivos van directamente al destinatario." - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Un transfert direct d’appareil à appareil — vos fichiers vont droit au destinataire." - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Un trasferimento diretto da dispositivo a dispositivo: i suoi file vanno direttamente al destinatario." - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Een directe overdracht van apparaat naar apparaat — uw bestanden gaan rechtstreeks naar de ontvanger." - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Bezpośredni transfer między urządzeniami — pliki trafiają prosto do odbiorcy." - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Uma transferência direta entre dispositivos — os seus ficheiros vão diretamente para o destinatário." - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Прямая передача с устройства на устройство — ваши файлы попадают напрямую к получателю." - } - } - } - }, - "about_is_encrypted": { - "comment": "About screen, 'What VniDrop is' list: point about encrypted, verified connections.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Verbindungen sind authentifiziert und Ende-zu-Ende-verschlüsselt (über Iroh), und eingehende Dateien werden anhand ihres Inhalts-Hashs überprüft." - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Connections are authenticated and end-to-end encrypted (via Iroh), and incoming files are verified by their content hash." - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Las conexiones están autenticadas y cifradas de extremo a extremo (mediante Iroh), y los archivos entrantes se verifican por su huella de contenido." - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Les connexions sont authentifiées et chiffrées de bout en bout (via Iroh), et les fichiers entrants sont vérifiés par leur empreinte de contenu." - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Le connessioni sono autenticate e cifrate end-to-end (tramite Iroh) e i file in arrivo vengono verificati tramite l’impronta del loro contenuto." - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Verbindingen zijn geverifieerd en end-to-end versleuteld (via Iroh), en binnenkomende bestanden worden gecontroleerd aan de hand van hun inhouds-hash." - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Połączenia są uwierzytelniane i szyfrowane od końca do końca (przez Iroh), a przychodzące pliki są weryfikowane na podstawie skrótu ich zawartości." - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "As ligações são autenticadas e cifradas ponto a ponto (através do Iroh), e os ficheiros recebidos são verificados pela impressão digital do seu conteúdo." - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Соединения аутентифицированы и зашифрованы сквозным шифрованием (через Iroh), а входящие файлы проверяются по хешу их содержимого." - } - } - } - }, - "about_is_in_control": { - "comment": "About screen, 'What VniDrop is' list: point about controlling who receives.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Sie entscheiden, wer empfängt: Genehmigen Sie jede Anfrage oder öffnen Sie eine Übertragung für alle, die die Einladung besitzen." - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "You decide who receives: approve each request, or open a transfer to anyone holding the invitation." - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Usted decide quién recibe: apruebe cada solicitud o abra una transferencia a cualquiera que tenga la invitación." - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Vous décidez qui reçoit : approuvez chaque demande, ou ouvrez un transfert à toute personne disposant de l’invitation." - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Lei decide chi riceve: approvi ogni richiesta oppure apra un trasferimento a chiunque disponga dell’invito." - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "U bepaalt wie ontvangt: keur elk verzoek goed, of stel een overdracht open voor iedereen met de uitnodiging." - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "To Ty decydujesz, kto otrzymuje: zatwierdź każde żądanie lub udostępnij transfer każdemu, kto ma zaproszenie." - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "É você quem decide quem recebe: aprove cada pedido ou abra uma transferência a qualquer pessoa que tenha o convite." - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Вы решаете, кто получает: одобряйте каждый запрос или откройте передачу всем, у кого есть приглашение." - } - } - } - }, - "about_is_no_account": { - "comment": "About screen, 'What VniDrop is' list: point about being account-free.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Ohne Konto – es gibt nichts zu registrieren." - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Account-free — there’s nothing to sign up for." - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Sin cuenta: no hay nada que registrar." - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Sans compte — il n’y a rien à créer." - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Senza account: non c’è nulla da registrare." - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Zonder account — er is niets om aan te melden." - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Bez konta — nie trzeba się rejestrować." - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Sem conta — não há nada para registar." - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Без учётной записи — регистрироваться не нужно." - } - } - } - }, - "about_is_open": { - "comment": "About screen, 'What VniDrop is' list: point about being open source.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Open Source, veröffentlicht unter der Apache-2.0-Lizenz." - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Open source, released under the Apache 2.0 license." - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Código abierto, publicado bajo la licencia Apache 2.0." - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Open source, publié sous licence Apache 2.0." - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Open source, rilasciato con licenza Apache 2.0." - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Opensource, uitgebracht onder de Apache 2.0-licentie." - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Otwarte oprogramowanie, wydane na licencji Apache 2.0." - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Código aberto, publicado sob a licença Apache 2.0." - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Открытый исходный код, распространяется по лицензии Apache 2.0." - } - } - } - }, - "about_is_title": { - "comment": "About screen: section heading for the 'What VniDrop is' list.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Was VniDrop ist" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "What VniDrop is" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Qué es VniDrop" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Ce qu’est VniDrop" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Cos’è VniDrop" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Wat VniDrop is" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Czym jest VniDrop" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "O que o VniDrop é" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Что такое VniDrop" - } - } - } - }, - "about_isnt_cloud": { - "comment": "About screen, 'What VniDrop isn't' list: point about not being cloud storage.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Kein Cloud-Speicher – kein Server hält Ihre Dateien, und nach einer Übertragung wartet nichts in der Cloud." - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Not cloud storage — no server holds your files, and nothing waits in the cloud after a transfer." - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "No es almacenamiento en la nube: ningún servidor guarda sus archivos y nada queda en la nube después de una transferencia." - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Pas un stockage cloud — aucun serveur ne détient vos fichiers, et rien n’attend dans le cloud après un transfert." - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Non è un’archiviazione cloud: nessun server conserva i suoi file e nulla resta nel cloud dopo un trasferimento." - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Geen cloudopslag — geen enkele server bewaart uw bestanden en er wacht niets in de cloud na een overdracht." - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "To nie magazyn w chmurze — żaden serwer nie przechowuje Twoich plików i nic nie pozostaje w chmurze po transferze." - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Não é armazenamento na nuvem — nenhum servidor guarda os seus ficheiros e nada fica na nuvem após uma transferência." - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Это не облачное хранилище — ни один сервер не хранит ваши файлы, и после передачи в облаке ничего не остаётся." - } - } - } - }, - "about_isnt_public": { - "comment": "About screen, 'What VniDrop isn't' list: point about invitations not being public broadcasts.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Keine öffentliche Übertragung – eine Einladung ist ein privater Zugangslink, keine Ankündigung an alle in der Nähe." - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Not public broadcasting — an invitation is a private access link, not an announcement to everyone nearby." - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "No es difusión pública: una invitación es un enlace de acceso privado, no un anuncio para todos los que estén cerca." - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Pas une diffusion publique — une invitation est un lien d’accès privé, pas une annonce à tout le voisinage." - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Non è una diffusione pubblica: un invito è un link di accesso privato, non un annuncio a tutti nelle vicinanze." - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Geen openbare uitzending — een uitnodiging is een privétoegangslink, geen aankondiging aan iedereen in de buurt." - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "To nie publiczne rozgłaszanie — zaproszenie to prywatny link dostępu, a nie ogłoszenie dla wszystkich w pobliżu." - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Não é difusão pública — um convite é uma ligação de acesso privado, não um anúncio a toda a gente por perto." - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Это не публичная рассылка — приглашение является частной ссылкой доступа, а не объявлением для всех поблизости." - } - } - } - }, - "about_isnt_sync": { - "comment": "About screen, 'What VniDrop isn't' list: point about not being sync/backup.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Kein Synchronisierungs- oder Backup-Dienst." - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Not a sync or backup service." - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "No es un servicio de sincronización ni de copia de seguridad." - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Pas un service de synchronisation ou de sauvegarde." - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Non è un servizio di sincronizzazione o di backup." - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Geen synchronisatie- of back-updienst." - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "To nie usługa synchronizacji ani kopii zapasowej." - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Não é um serviço de sincronização ou de cópia de segurança." - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Это не служба синхронизации или резервного копирования." - } - } - } - }, - "about_isnt_title": { - "comment": "About screen: section heading for the 'What VniDrop isn't' list.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Was VniDrop nicht ist" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "What VniDrop isn’t" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Qué no es VniDrop" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Ce que VniDrop n’est pas" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Cosa non è VniDrop" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Wat VniDrop niet is" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Czym VniDrop nie jest" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "O que o VniDrop não é" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Чем VniDrop не является" - } - } - } - }, - "about_license_label": { - "comment": "About screen: label for the license row.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Lizenz" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "License" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Licencia" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Licence" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Licenza" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Licentie" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Licencja" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Licença" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Лицензия" - } - } - } - }, - "about_privacy": { - "comment": "About screen: link to the privacy policy.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Datenschutzrichtlinie" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Privacy policy" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Política de privacidad" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Politique de confidentialité" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Informativa sulla privacy" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Privacybeleid" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Polityka prywatności" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Política de privacidade" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Политика конфиденциальности" - } - } - } - }, - "about_privacy_capability": { - "comment": "About screen, Privacy & security list: point that invitations are capabilities to share carefully.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Einladungen sind Zugangsschlüssel. Behandeln Sie einen QR-Code, ein NFC-Tag oder eine .vnd-Datei wie einen privaten Zugangslink und teilen Sie ihn nur mit den vorgesehenen Personen – insbesondere bei „Jeder mit dieser Übertragung“." - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Invitations are capabilities. Treat a QR code, NFC tag, or .vnd file like a private access link and share it only with the people you intend — especially with “Anyone with this transfer.”" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Las invitaciones son claves de acceso. Trate un código QR, una etiqueta NFC o un archivo .vnd como un enlace de acceso privado y compártalo solo con las personas previstas, especialmente con «Cualquiera que tenga esta transferencia»." - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Les invitations sont des clés d’accès. Traitez un QR code, un tag NFC ou un fichier .vnd comme un lien d’accès privé et ne le partagez qu’avec les personnes visées — en particulier avec « Toute personne disposant de ce transfert »." - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Gli inviti sono chiavi di accesso. Tratti un codice QR, un tag NFC o un file .vnd come un link di accesso privato e lo condivida solo con le persone previste, soprattutto con «Chiunque abbia questo trasferimento»." - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Uitnodigingen zijn toegangssleutels. Behandel een QR-code, NFC-tag of .vnd-bestand als een privétoegangslink en deel deze alleen met de bedoelde personen — vooral bij ‘Iedereen met deze overdracht’." - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Zaproszenia są kluczami dostępu. Traktuj kod QR, tag NFC lub plik .vnd jak prywatny link dostępu i udostępniaj go tylko zamierzonym osobom — zwłaszcza przy opcji „Każdy, kto ma ten transfer”." - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Os convites são chaves de acesso. Trate um código QR, uma etiqueta NFC ou um ficheiro .vnd como uma ligação de acesso privado e partilhe-o apenas com as pessoas pretendidas — sobretudo com «Qualquer pessoa com esta transferência»." - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Приглашения — это ключи доступа. Относитесь к QR-коду, NFC-метке или файлу .vnd как к частной ссылке доступа и делитесь ими только с теми, для кого они предназначены, особенно при варианте «Любой, у кого есть эта передача»." - } - } - } - }, - "about_privacy_deny": { - "comment": "About screen, Privacy & security list: point about denying unknown requests by default.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Standardmäßig ablehnen – VniDrop stellt nur den Inhalt einer aktiven Freigabe bereit und weist unbekannte Anfragen ab." - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Deny by default — VniDrop serves only the content of an active share and rejects unknown requests." - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Denegación por defecto: VniDrop solo sirve el contenido de un recurso compartido activo y rechaza las solicitudes desconocidas." - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Refus par défaut — VniDrop ne sert que le contenu d’un partage actif et rejette les demandes inconnues." - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Rifiuto predefinito: VniDrop serve solo il contenuto di una condivisione attiva e rifiuta le richieste sconosciute." - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Standaard weigeren — VniDrop levert alleen de inhoud van een actieve deling en wijst onbekende verzoeken af." - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Domyślnie odmowa — VniDrop udostępnia tylko zawartość aktywnego udostępnienia i odrzuca nieznane żądania." - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Recusa por predefinição — o VniDrop apenas fornece o conteúdo de uma partilha ativa e rejeita pedidos desconhecidos." - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Отказ по умолчанию — VniDrop предоставляет только содержимое активной передачи и отклоняет неизвестные запросы." - } - } - } - }, - "about_privacy_local": { - "comment": "About screen, Privacy & security list: point about received files staying local and never overwriting.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Empfangene Dateien werden auf Ihrem Gerät gespeichert und überschreiben niemals eine vorhandene Datei." - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Received files are saved on your device and never silently overwrite an existing file." - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Los archivos recibidos se guardan en su dispositivo y nunca sobrescriben un archivo existente." - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Les fichiers reçus sont enregistrés sur votre appareil et n’écrasent jamais un fichier existant." - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "I file ricevuti vengono salvati sul suo dispositivo e non sovrascrivono mai un file esistente." - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Ontvangen bestanden worden op uw apparaat bewaard en overschrijven nooit een bestaand bestand." - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Odebrane pliki są zapisywane na Twoim urządzeniu i nigdy nie nadpisują istniejącego pliku." - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Os ficheiros recebidos são guardados no seu dispositivo e nunca substituem um ficheiro existente." - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Полученные файлы сохраняются на вашем устройстве и никогда не перезаписывают существующий файл." - } - } - } - }, - "about_privacy_policy_label": { - "comment": "About screen: label for the privacy policy row.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Datenschutzrichtlinie" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Privacy policy" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Política de privacidad" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Politique de confidentialité" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Informativa sulla privacy" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Privacybeleid" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Polityka prywatności" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Política de privacidade" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Политика конфиденциальности" - } - } - } - }, - "about_privacy_relay": { - "comment": "About screen, Privacy & security list: point explaining encrypted relays.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Wenn zwei Geräte sich nicht direkt verbinden können, kann die verschlüsselte Verbindung weitergeleitet werden. Relays leiten nur verschlüsselte Pakete weiter; sie speichern niemals Ihre Dateien." - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "If two devices can’t connect directly, the encrypted connection may be relayed. Relays forward encrypted packets only; they never store your files." - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Si dos dispositivos no pueden conectarse directamente, la conexión cifrada puede retransmitirse. Los relés solo reenvían paquetes cifrados; nunca almacenan sus archivos." - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Si deux appareils ne peuvent pas se connecter directement, la connexion chiffrée peut être relayée. Les relais ne transmettent que des paquets chiffrés ; ils ne stockent jamais vos fichiers." - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Se due dispositivi non riescono a connettersi direttamente, la connessione cifrata può essere instradata tramite relay. I relay inoltrano solo pacchetti cifrati; non memorizzano mai i suoi file." - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Als twee apparaten niet rechtstreeks verbinding kunnen maken, kan de versleutelde verbinding via een relay worden doorgestuurd. Relays sturen alleen versleutelde pakketten door; ze slaan uw bestanden nooit op." - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Jeśli dwa urządzenia nie mogą połączyć się bezpośrednio, szyfrowane połączenie może być przekazywane przez przekaźnik. Przekaźniki przekazują tylko zaszyfrowane pakiety; nigdy nie przechowują Twoich plików." - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Se dois dispositivos não conseguirem ligar-se diretamente, a ligação cifrada pode ser retransmitida. Os retransmissores apenas encaminham pacotes cifrados; nunca guardam os seus ficheiros." - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Если два устройства не могут соединиться напрямую, зашифрованное соединение может передаваться через ретранслятор. Ретрансляторы пересылают только зашифрованные пакеты; они никогда не хранят ваши файлы." - } - } - } - }, - "about_privacy_title": { - "comment": "About screen: section heading for the Privacy & security list.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Datenschutz & Sicherheit" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Privacy & security" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Privacidad y seguridad" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Confidentialité et sécurité" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Privacy e sicurezza" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Privacy en beveiliging" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Prywatność i bezpieczeństwo" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Privacidade e segurança" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Конфиденциальность и безопасность" - } - } - } - }, - "about_tagline": { - "comment": "About screen: short tagline under the app name.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Senden Sie Dateien direkt. Behalten Sie die Kontrolle darüber, wer sie empfängt." - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Send files directly. Stay in control of who receives them." - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Envíe archivos directamente. Mantenga el control de quién los recibe." - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Envoyez des fichiers directement. Gardez le contrôle de qui les reçoit." - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Invii file direttamente. Mantenga il controllo su chi li riceve." - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Verstuur bestanden rechtstreeks. Houd controle over wie ze ontvangt." - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Wysyłaj pliki bezpośrednio. Zachowaj kontrolę nad tym, kto je otrzymuje." - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Envie ficheiros diretamente. Mantenha o controlo sobre quem os recebe." - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Отправляйте файлы напрямую. Сохраняйте контроль над тем, кто их получает." - } - } - } - }, - "about_title": { - "comment": "About screen: navigation/screen title.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Über" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "About" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Acerca de" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "À propos" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Informazioni" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Over" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Informacje" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Acerca de" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "О приложении" - } - } - } - }, - "appearance_auto_description": { - "comment": "Settings > Appearance: description for the System/auto option.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Der hellen oder dunklen Darstellung dieses Geräts folgen." - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Match this device’s light or dark appearance." - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Seguir la apariencia clara u oscura de este dispositivo." - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Suivre l’apparence claire ou sombre de cet appareil." - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Segue l’aspetto chiaro o scuro di questo dispositivo." - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "De lichte of donkere weergave van dit apparaat volgen." - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Dopasuj do jasnego lub ciemnego wyglądu tego urządzenia." - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Acompanhar o aspeto claro ou escuro deste dispositivo." - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Следовать светлому или тёмному оформлению этого устройства." - } - } - } - }, - "appearance_dark_mode": { - "comment": "Settings > Appearance: label for the dark theme option.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Dunkelmodus" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Dark mode" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Modo oscuro" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Mode sombre" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Modalità scura" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Donkere modus" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Tryb ciemny" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Modo escuro" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Тёмный режим" - } - } - } - }, - "appearance_light_mode": { - "comment": "Settings > Appearance: label for the light theme option.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Hellmodus" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Light mode" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Modo claro" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Mode clair" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Modalità chiara" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Lichte modus" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Tryb jasny" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Modo claro" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Светлый режим" - } - } - } - }, - "appearance_system_mode": { - "comment": "Settings > Appearance: label for the follow-system option.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "System" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "System" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Sistema" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Système" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Sistema" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Systeem" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "System" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Sistema" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Системный" - } - } - } - }, - "appearance_title": { - "comment": "Settings > Appearance: section title.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Darstellung" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Appearance" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Apariencia" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Apparence" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Aspetto" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Weergave" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Wygląd" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Aspeto" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Оформление" - } - } - } - }, - "approval_connection_request": { - "comment": "Approval prompt: title when a receiver requests to download a transfer.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Empfangsanfrage" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Receive request" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Solicitud de recepción" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Demande de réception" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Richiesta di ricezione" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Ontvangstverzoek" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Prośba o odbiór" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Pedido de receção" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Запрос на получение" - } - } - } - }, - "approval_endpoint_id": { - "comment": "Approval prompt: shows the requesting device's ID. {arg1} = device/endpoint identifier.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Geräte-ID: %1$@" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Device ID: %1$@" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "ID del dispositivo: %1$@" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Identifiant de l’appareil : %1$@" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "ID dispositivo: %1$@" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Apparaat-ID: %1$@" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Identyfikator urządzenia: %1$@" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "ID do dispositivo: %1$@" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Идентификатор устройства: %1$@" - } - } - } - }, - "approval_nearby_device": { - "comment": "Approval prompt: fallback label for a requester with no display name.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Ein Gerät in der Nähe" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "A nearby device" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Un dispositivo cercano" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Un appareil à proximité" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Un dispositivo nelle vicinanze" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Een apparaat in de buurt" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Urządzenie w pobliżu" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Um dispositivo próximo" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Устройство поблизости" - } - } - } - }, - "approval_pending_count": { - "comment": "Send/transfer list: badge showing how many receive requests are awaiting approval. {count} = pending requests.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "%1$d Anfragen warten" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "%1$d requests waiting" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "%1$d solicitudes en espera" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "%1$d demandes en attente" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "%1$d richieste in attesa" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "%1$d verzoeken in behandeling" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Oczekujące prośby: %1$d" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "%1$d pedidos em espera" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Ожидающих запросов: %1$d" - } - } - } - }, - "approval_request_body": { - "comment": "Approval prompt body: '{arg1} wants to receive \"{arg2}\".' {arg1} = requester name, {arg2} = transfer name.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "%1$@ möchte „%2$@“ empfangen." - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "%1$@ wants to receive “%2$@”." - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "%1$@ quiere recibir «%2$@»." - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "%1$@ souhaite recevoir « %2$@ »." - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "%1$@ vuole ricevere «%2$@»." - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "%1$@ wil ‘%2$@’ ontvangen." - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "%1$@ chce odebrać „%2$@”." - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "%1$@ quer receber «%2$@»." - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "%1$@ хочет получить «%2$@»." - } - } - } - }, - "battery_level_title": { - "comment": "Device information row: battery level label.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Batteriestand" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Battery level" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Nivel de batería" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Niveau de batterie" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Livello batteria" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Batterijniveau" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Poziom baterii" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Nível da bateria" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Уровень заряда" - } - } - } - }, - "bug_report_contact_hint": { - "comment": "Bug report form: placeholder text in the contact email field.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "name@beispiel.com" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "name@example.com" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "nombre@ejemplo.com" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "nom@exemple.com" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "nome@esempio.com" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "naam@voorbeeld.com" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "nazwa@przyklad.com" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "nome@exemplo.com" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "имя@пример.com" - } - } - } - }, - "bug_report_contact_label": { - "comment": "Bug report form: label for the optional contact email field.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Kontakt-E-Mail (optional)" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Contact email (optional)" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Correo de contacto (opcional)" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "E-mail de contact (facultatif)" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Email di contatto (facoltativa)" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Contact-e-mail (optioneel)" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "E-mail kontaktowy (opcjonalnie)" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "E-mail de contacto (opcional)" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Контактный e-mail (необязательно)" - } - } - } - }, - "bug_report_description": { - "comment": "Bug report form: intro text explaining what gets attached.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Sagen Sie uns, was schiefgelaufen ist. Wir hängen Geräteinformationen und optional aktuelle Protokolle an (sensible Werte werden geschwärzt)." - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Tell us what went wrong. We attach device info and optional recent logs (with sensitive values redacted)." - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Cuéntenos qué salió mal. Adjuntamos información del dispositivo y, opcionalmente, registros recientes (con los valores sensibles ocultos)." - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Dites-nous ce qui s’est passé. Nous joignons les infos de l’appareil et, en option, les journaux récents (valeurs sensibles masquées)." - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Ci dica cosa è andato storto. Alleghiamo le informazioni sul dispositivo e, facoltativamente, i log recenti (con i valori sensibili oscurati)." - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Vertel ons wat er misging. We voegen apparaatgegevens toe en optioneel recente logbestanden (met gevoelige waarden onleesbaar gemaakt)." - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Napisz, co poszło nie tak. Dołączamy informacje o urządzeniu i opcjonalnie ostatnie dzienniki (z ukrytymi wrażliwymi wartościami)." - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Diga-nos o que correu mal. Anexamos informações do dispositivo e, opcionalmente, registos recentes (com os valores sensíveis ocultados)." - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Расскажите, что пошло не так. Мы прикладываем сведения об устройстве и, при желании, недавние журналы (конфиденциальные значения скрыты)." - } - } - } - }, - "bug_report_device_section": { - "comment": "Bug report form: heading for the attached device information section.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Geräteinformationen" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Device information" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Información del dispositivo" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Informations sur l’appareil" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Informazioni sul dispositivo" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Apparaatgegevens" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Informacje o urządzeniu" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Informações do dispositivo" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Сведения об устройстве" - } - } - } - }, - "bug_report_expected_hint": { - "comment": "Bug report form: placeholder in the 'what did you expect' field.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Beschreiben Sie, was Sie erwartet haben" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Describe what you expected to happen" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Describa lo que esperaba que ocurriera" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Décrivez ce à quoi vous vous attendiez" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Descriva cosa si aspettava che accadesse" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Beschrijf wat u verwachtte" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Opisz oczekiwane zachowanie" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Descreva o que esperava que acontecesse" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Опишите, что вы ожидали" - } - } - } - }, - "bug_report_expected_label": { - "comment": "Bug report form: label for the expected-behavior field.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Was haben Sie erwartet?" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "What did you expect?" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "¿Qué esperaba?" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "À quoi vous attendiez-vous ?" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Cosa si aspettava?" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Wat verwachtte u?" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Czego oczekiwano?" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "O que esperava?" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Что вы ожидали?" - } - } - } - }, - "bug_report_include_logs": { - "comment": "Bug report form: toggle label to attach recent logs.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Aktuelle Protokolle einschließen" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Include recent logs" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Incluir registros recientes" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Inclure les journaux récents" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Includi i log recenti" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Recente logbestanden meesturen" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Dołącz ostatnie dzienniki" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Incluir registos recentes" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Включить недавние журналы" - } - } - } - }, - "bug_report_include_logs_description": { - "comment": "Bug report form: explanation under the include-logs toggle.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Hilft uns, das Problem zu diagnostizieren. Sensible Werte werden vor dem Senden geschwärzt." - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Helps us diagnose the issue. Sensitive values are redacted before sending." - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Nos ayuda a diagnosticar el problema. Los valores sensibles se ocultan antes de enviarlos." - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Nous aide à diagnostiquer le problème. Les valeurs sensibles sont masquées avant l’envoi." - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Ci aiuta a diagnosticare il problema. I valori sensibili vengono oscurati prima dell’invio." - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Helpt ons het probleem te diagnosticeren. Gevoelige waarden worden vóór verzending onleesbaar gemaakt." - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Pomaga nam zdiagnozować problem. Wrażliwe wartości są ukrywane przed wysłaniem." - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Ajuda-nos a diagnosticar o problema. Os valores sensíveis são ocultados antes do envio." - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Помогает нам диагностировать проблему. Конфиденциальные значения скрываются перед отправкой." - } - } - } - }, - "bug_report_logs_size": { - "comment": "Bug report form: label showing the size of the log attachment.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Größe des Protokollanhangs" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Log attachment size" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Tamaño del archivo de registros" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Taille de la pièce jointe des journaux" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Dimensione dell’allegato dei log" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Grootte van logbijlage" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Rozmiar załącznika z dziennikami" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Tamanho do anexo de registos" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Размер вложения с журналами" - } - } - } - }, - "bug_report_missing_expected": { - "comment": "Bug report form: validation error when the expected-behavior field is empty.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Bitte beschreiben Sie, was Sie erwartet haben." - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Please describe what you expected." - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Describa lo que esperaba." - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Veuillez décrire ce à quoi vous vous attendiez." - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Descriva cosa si aspettava." - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Beschrijf wat u verwachtte." - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Opisz oczekiwane zachowanie." - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Descreva o que esperava." - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Опишите, что вы ожидали." - } - } - } - }, - "bug_report_missing_what": { - "comment": "Bug report form: validation error when the what-happened field is empty.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Bitte beschreiben Sie, was passiert ist." - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Please describe what happened." - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Describa lo que ocurrió." - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Veuillez décrire ce qui s’est passé." - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Descriva cosa è accaduto." - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Beschrijf wat er gebeurde." - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Opisz, co się stało." - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Descreva o que aconteceu." - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Опишите, что произошло." - } - } - } - }, - "bug_report_steps_hint": { - "comment": "Bug report form: placeholder in the steps-to-reproduce field.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Listen Sie die Schritte zum Reproduzieren des Problems auf" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "List the steps to reproduce the issue" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Enumere los pasos para reproducir el problema" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Listez les étapes pour reproduire le problème" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Elenchi i passaggi per riprodurre il problema" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Noem de stappen om het probleem te reproduceren" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Wymień kroki umożliwiające odtworzenie problemu" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Enumere os passos para reproduzir o problema" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Перечислите шаги для воспроизведения проблемы" - } - } - } - }, - "bug_report_steps_label": { - "comment": "Bug report form: label for the optional steps-to-reproduce field.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Schritte zum Reproduzieren (optional)" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Steps to reproduce (optional)" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Pasos para reproducir (opcional)" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Étapes pour reproduire (facultatif)" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Passaggi per riprodurre (facoltativi)" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Stappen om te reproduceren (optioneel)" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Kroki do odtworzenia (opcjonalnie)" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Passos para reproduzir (opcional)" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Шаги для воспроизведения (необязательно)" - } - } - } - }, - "bug_report_submit": { - "comment": "Bug report form: submit button.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Bericht senden" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Submit report" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Enviar informe" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Envoyer le rapport" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Invia segnalazione" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Rapport versturen" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Wyślij zgłoszenie" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Enviar relatório" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Отправить отчёт" - } - } - } - }, - "bug_report_submit_failed": { - "comment": "Bug report form: error message when submission fails.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Der Fehlerbericht konnte nicht gesendet werden. Bitte versuchen Sie es später erneut." - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Could not submit the bug report. Try again later." - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "No se pudo enviar el informe de error. Inténtelo de nuevo más tarde." - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Impossible d’envoyer le rapport de bug. Réessayez plus tard." - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Impossibile inviare la segnalazione. Riprovi più tardi." - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Het foutrapport kon niet worden verstuurd. Probeer het later opnieuw." - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Nie udało się wysłać zgłoszenia błędu. Spróbuj ponownie później." - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Não foi possível enviar o relatório de erro. Tente novamente mais tarde." - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Не удалось отправить отчёт об ошибке. Повторите попытку позже." - } - } - } - }, - "bug_report_submitted": { - "comment": "Bug report form: confirmation message after a successful submission.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Danke – Ihr Fehlerbericht wurde erfasst." - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Thanks — your bug report was recorded." - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Gracias: su informe de error se ha registrado." - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Merci — votre rapport de bug a été enregistré." - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Grazie: la sua segnalazione è stata registrata." - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Bedankt — uw foutrapport is vastgelegd." - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Dziękujemy — Twoje zgłoszenie błędu zostało zapisane." - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Obrigado — o seu relatório de erro foi registado." - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Спасибо — ваш отчёт об ошибке записан." - } - } - } - }, - "bug_report_submitting": { - "comment": "Bug report form: progress label while the report is being sent.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Wird gesendet…" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Submitting…" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Enviando…" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Envoi…" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Invio in corso…" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Bezig met versturen…" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Wysyłanie…" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "A enviar…" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Отправка…" - } - } - } - }, - "bug_report_what_hint": { - "comment": "Bug report form: placeholder in the what-happened field.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Beschreiben Sie, was passiert ist" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Describe what happened" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Describa lo que ocurrió" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Décrivez ce qui s’est passé" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Descriva cosa è accaduto" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Beschrijf wat er gebeurde" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Opisz, co się stało" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Descreva o que aconteceu" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Опишите, что произошло" - } - } - } - }, - "bug_report_what_label": { - "comment": "Bug report form: label for the what-happened field.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Was ist passiert?" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "What happened?" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "¿Qué ocurrió?" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Que s’est-il passé ?" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Cosa è accaduto?" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Wat is er gebeurd?" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Co się stało?" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "O que aconteceu?" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Что произошло?" - } - } - } - }, - "button_approve": { - "comment": "Button: approve a receiver's request.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Genehmigen" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Approve" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Aprobar" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Approuver" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Approva" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Goedkeuren" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Zatwierdź" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Aprovar" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Одобрить" - } - } - } - }, - "button_back": { - "comment": "Button: go back to the previous step/screen.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Zurück" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Back" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Atrás" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Retour" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Indietro" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Terug" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Wstecz" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Voltar" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Назад" - } - } - } - }, - "button_cancel": { - "comment": "Button: cancel the current action or dialog.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Abbrechen" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Cancel" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Cancelar" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Annuler" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Annulla" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Annuleren" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Anuluj" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Cancelar" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Отмена" - } - } - } - }, - "button_cancel_receive": { - "comment": "Button: cancel an in-progress receive.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Abbrechen" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Cancel" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Cancelar" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Annuler" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Annulla" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Annuleren" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Anuluj" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Cancelar" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Отмена" - } - } - } - }, - "button_change_files": { - "comment": "Button: change the selected files in the send flow.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Dateien ändern" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Change files" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Cambiar archivos" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Modifier les fichiers" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Cambia file" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Bestanden wijzigen" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Zmień pliki" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Alterar ficheiros" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Изменить файлы" - } - } - } - }, - "button_choose_files": { - "comment": "Button: open the file picker to choose files to send.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Dateien auswählen" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Choose files" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Elegir archivos" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Choisir des fichiers" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Scegli file" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Bestanden kiezen" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Wybierz pliki" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Escolher ficheiros" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Выбрать файлы" - } - } - } - }, - "button_choose_folder": { - "comment": "Button: open the folder picker (send selection or receive folder).", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Ordner auswählen" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Choose folder" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Elegir carpeta" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Choisir un dossier" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Scegli cartella" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Map kiezen" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Wybierz folder" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Escolher pasta" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Выбрать папку" - } - } - } - }, - "button_clear": { - "comment": "Button: clear the current input or selection.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Löschen" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Clear" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Borrar" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Effacer" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Cancella" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Wissen" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Wyczyść" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Limpar" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Очистить" - } - } - } - }, - "button_close": { - "comment": "Button: close the current sheet/dialog.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Schließen" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Close" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Cerrar" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Fermer" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Chiudi" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Sluiten" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Zamknij" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Fechar" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Закрыть" - } - } - } - }, - "button_create_new_transfer": { - "comment": "Button: start creating a new transfer (Send tab).", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Neue Übertragung" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "New transfer" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Nueva transferencia" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Nouveau transfert" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Nuovo trasferimento" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Nieuwe overdracht" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Nowy transfer" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Nova transferência" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Новая передача" - } - } - } - }, - "button_delete_transfer": { - "comment": "Button: delete a transfer.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Übertragung löschen" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Delete transfer" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Eliminar transferencia" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Supprimer le transfert" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Elimina trasferimento" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Overdracht verwijderen" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Usuń transfer" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Eliminar transferência" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Удалить передачу" - } - } - } - }, - "button_download_invitation": { - "comment": "Button: save the invitation as a .vnd file.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": ".vnd-Datei sichern" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Save .vnd file" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Guardar archivo .vnd" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Enregistrer le fichier .vnd" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Salva file .vnd" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": ".vnd-bestand bewaren" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Zapisz plik .vnd" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Guardar ficheiro .vnd" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Сохранить файл .vnd" - } - } - } - }, - "button_native_share": { - "comment": "Button: open the OS share sheet to share the invitation.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Einladung teilen" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Share invitation" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Compartir invitación" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Partager l’invitation" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Condividi invito" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Uitnodiging delen" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Udostępnij zaproszenie" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Partilhar convite" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Поделиться приглашением" - } - } - } - }, - "button_open_settings": { - "comment": "Button: open the OS Settings app (e.g. for permissions).", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Einstellungen öffnen" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Open Settings" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Abrir Ajustes" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Ouvrir les Réglages" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Apri Impostazioni" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Instellingen openen" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Otwórz Ustawienia" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Abrir Definições" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Открыть Настройки" - } - } - } - }, - "button_receive": { - "comment": "Button: receive label (Receive action).", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Empfangen" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Receive" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Recibir" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Recevoir" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Ricevi" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Ontvangen" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Odbierz" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Receber" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Получить" - } - } - } - }, - "button_receive_files": { - "comment": "Button: start receiving a transfer.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Empfang starten" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Start receiving" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Empezar a recibir" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Commencer à recevoir" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Inizia a ricevere" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Ontvangen starten" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Rozpocznij odbieranie" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Começar a receber" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Начать получение" - } - } - } - }, - "button_refuse": { - "comment": "Button: refuse a receiver's request.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Ablehnen" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Refuse" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Rechazar" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Refuser" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Rifiuta" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Weigeren" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Odrzuć" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Recusar" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Отклонить" - } - } - } - }, - "button_remove_file": { - "comment": "Button: remove a single file from the send selection.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Datei entfernen" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Remove file" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Quitar archivo" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Retirer le fichier" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Rimuovi file" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Bestand verwijderen" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Usuń plik" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Remover ficheiro" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Удалить файл" - } - } - } - }, - "button_reset_default": { - "comment": "Button: reset a setting to its default value.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Standard verwenden" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Use default" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Usar predeterminado" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Valeur par défaut" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Usa predefinito" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Standaard gebruiken" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Użyj domyślnego" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Usar predefinição" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "По умолчанию" - } - } - } - }, - "button_retry": { - "comment": "Button: retry after a failed operation.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Wiederholen" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Retry" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Reintentar" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Réessayer" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Riprova" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Opnieuw proberen" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Spróbuj ponownie" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Tentar novamente" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Повторить" - } - } - } - }, - "button_share_file": { - "comment": "Button: start sharing the transfer (make it available).", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Freigabe starten" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Start sharing" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Empezar a compartir" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Commencer le partage" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Inizia a condividere" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Delen starten" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Rozpocznij udostępnianie" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Começar a partilhar" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Начать общий доступ" - } - } - } - }, - "button_sharing_file": { - "comment": "Button: disabled/loading state while the transfer is being prepared.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Übertragung wird vorbereitet…" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Preparing transfer…" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Preparando la transferencia…" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Préparation du transfert…" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Preparazione del trasferimento…" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Overdracht voorbereiden…" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Przygotowywanie transferu…" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "A preparar a transferência…" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Подготовка передачи…" - } - } - } - }, - "button_show_in_files": { - "comment": "Button: reveal a received file in the Files app.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "In „Dateien“ anzeigen" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Show in Files" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Mostrar en Archivos" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Afficher dans Fichiers" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Mostra in File" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Toon in Bestanden" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Pokaż w Plikach" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Mostrar em Ficheiros" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Показать в Файлах" - } - } - } - }, - "button_write_nfc": { - "comment": "Button: write the invitation to an NFC tag.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Auf NFC-Tag schreiben" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Write to NFC tag" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Escribir en etiqueta NFC" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Écrire sur un tag NFC" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Scrivi su tag NFC" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Naar NFC-tag schrijven" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Zapisz na tagu NFC" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Escrever em etiqueta NFC" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Записать на NFC-метку" - } - } - } - }, - "device_model_title": { - "comment": "Device information row: device model label.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Gerätemodell" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Device model" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Modelo del dispositivo" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Modèle de l’appareil" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Modello del dispositivo" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Apparaatmodel" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Model urządzenia" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Modelo do dispositivo" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Модель устройства" - } - } - } - }, - "device_name_title": { - "comment": "Device information row: device name label.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Gerätename" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Device name" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Nombre del dispositivo" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Nom de l’appareil" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Nome del dispositivo" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Apparaatnaam" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Nazwa urządzenia" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Nome do dispositivo" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Имя устройства" - } - } - } - }, - "diagnostics_description": { - "comment": "Settings > Diagnostics: explanation of what anonymous diagnostics collect.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Anonyme Absturzberichte und Nutzungsereignisse senden, damit wir VniDrop verbessern können. Sie können dies jederzeit deaktivieren. Einladungen, Dateipfade und Übertragungsinhalte werden niemals einbezogen." - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Send anonymous crash reports and usage events so we can improve VniDrop. You can turn this off anytime. Invitations, file paths, and transfer contents are never included." - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Enviar informes de fallos y eventos de uso anónimos para ayudarnos a mejorar VniDrop. Puede desactivarlo en cualquier momento. Las invitaciones, las rutas de archivos y el contenido de las transferencias nunca se incluyen." - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Envoyer des rapports de plantage et des événements d’utilisation anonymes pour nous aider à améliorer VniDrop. Vous pouvez désactiver cela à tout moment. Les invitations, chemins de fichiers et contenus de transfert ne sont jamais inclus." - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Invia report di arresto anomalo ed eventi d’uso anonimi per aiutarci a migliorare VniDrop. Può disattivarlo in qualsiasi momento. Inviti, percorsi dei file e contenuti dei trasferimenti non vengono mai inclusi." - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Verstuur anonieme crashrapporten en gebruiksgebeurtenissen zodat we VniDrop kunnen verbeteren. U kunt dit op elk moment uitschakelen. Uitnodigingen, bestandspaden en overdrachtsinhoud worden nooit meegestuurd." - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Wysyłaj anonimowe raporty o awariach i zdarzenia użytkowania, aby pomóc nam ulepszać VniDrop. Możesz to wyłączyć w dowolnej chwili. Zaproszenia, ścieżki plików i zawartość transferów nigdy nie są dołączane." - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Enviar relatórios de falhas e eventos de utilização anónimos para nos ajudar a melhorar o VniDrop. Pode desativar isto a qualquer momento. Convites, caminhos de ficheiros e conteúdos das transferências nunca são incluídos." - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Отправлять анонимные отчёты о сбоях и события использования, чтобы помочь нам улучшать VniDrop. Вы можете отключить это в любой момент. Приглашения, пути к файлам и содержимое передач никогда не включаются." - } - } - } - }, - "diagnostics_disabled_message": { - "comment": "Settings > Diagnostics: confirmation shown when diagnostics are turned off.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Die Freigabe von Diagnosedaten ist deaktiviert." - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Diagnostics sharing is off." - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "El uso compartido de diagnósticos está desactivado." - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Le partage des diagnostics est désactivé." - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "La condivisione dei dati diagnostici è disattivata." - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Het delen van diagnostische gegevens is uitgeschakeld." - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Udostępnianie diagnostyki jest wyłączone." - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "A partilha de diagnósticos está desativada." - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Передача диагностики отключена." - } - } - } - }, - "diagnostics_enabled_message": { - "comment": "Settings > Diagnostics: confirmation shown when diagnostics are turned on.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Die Freigabe von Diagnosedaten ist aktiviert." - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Diagnostics sharing is on." - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "El uso compartido de diagnósticos está activado." - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Le partage des diagnostics est activé." - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "La condivisione dei dati diagnostici è attivata." - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Het delen van diagnostische gegevens is ingeschakeld." - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Udostępnianie diagnostyki jest włączone." - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "A partilha de diagnósticos está ativada." - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Передача диагностики включена." - } - } - } - }, - "diagnostics_title": { - "comment": "Settings > Diagnostics: toggle title.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Diagnosedaten teilen" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Share diagnostics" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Compartir diagnósticos" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Partager les diagnostics" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Condividi dati diagnostici" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Diagnostische gegevens delen" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Udostępniaj diagnostykę" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Partilhar diagnósticos" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Делиться диагностикой" - } - } - } - }, - "error_camera": { - "comment": "Error: camera permission is needed to scan a QR code.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Für das Scannen eines QR-Codes ist Kamerazugriff erforderlich." - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Camera access is required to scan a QR code." - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Se necesita acceso a la cámara para escanear un código QR." - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "L’accès à la caméra est nécessaire pour scanner un QR code." - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Per scansionare un codice QR è necessario l’accesso alla fotocamera." - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Voor het scannen van een QR-code is toegang tot de camera vereist." - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Do zeskanowania kodu QR wymagany jest dostęp do aparatu." - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "É necessário acesso à câmara para ler um código QR." - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Для сканирования QR-кода требуется доступ к камере." - } - } - } - }, - "error_destination_exists": { - "comment": "Error: a received file would overwrite an existing destination file.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Am Ziel ist bereits eine Datei mit demselben Namen vorhanden. Wählen Sie einen anderen Ordner oder entfernen Sie die vorhandene Datei." - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "A file with the same name already exists in the destination. Choose another folder or remove the existing file." - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Ya existe un archivo con el mismo nombre en el destino. Elija otra carpeta o elimine el archivo existente." - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Un fichier portant le même nom existe déjà dans la destination. Choisissez un autre dossier ou supprimez le fichier existant." - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Nella destinazione esiste già un file con lo stesso nome. Scelga un’altra cartella o rimuova il file esistente." - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Er staat al een bestand met dezelfde naam in de doelmap. Kies een andere map of verwijder het bestaande bestand." - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "W miejscu docelowym istnieje już plik o tej samej nazwie. Wybierz inny folder lub usuń istniejący plik." - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Já existe um ficheiro com o mesmo nome no destino. Escolha outra pasta ou remova o ficheiro existente." - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "В папке назначения уже есть файл с таким именем. Выберите другую папку или удалите существующий файл." - } - } - } - }, - "error_device_info": { - "comment": "Error: device information could not be loaded.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Geräteinformationen konnten nicht geladen werden." - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Could not load device information." - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "No se pudo cargar la información del dispositivo." - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Impossible de charger les informations de l’appareil." - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Impossibile caricare le informazioni sul dispositivo." - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Apparaatgegevens konden niet worden geladen." - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Nie udało się wczytać informacji o urządzeniu." - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Não foi possível carregar as informações do dispositivo." - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Не удалось загрузить сведения об устройстве." - } - } - } - }, - "error_filesystem": { - "comment": "Error: the selected files/folder could not be accessed.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "VniDrop konnte nicht auf die ausgewählten Dateien oder den Ordner zugreifen. Überprüfen Sie die Berechtigungen und versuchen Sie es erneut." - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "VniDrop could not access the selected files or folder. Check permissions and try again." - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "VniDrop no pudo acceder a los archivos o la carpeta seleccionados. Compruebe los permisos e inténtelo de nuevo." - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "VniDrop n’a pas pu accéder aux fichiers ou au dossier sélectionnés. Vérifiez les autorisations et réessayez." - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "VniDrop non ha potuto accedere ai file o alla cartella selezionati. Controlli le autorizzazioni e riprovi." - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "VniDrop kon geen toegang krijgen tot de geselecteerde bestanden of map. Controleer de machtigingen en probeer het opnieuw." - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "VniDrop nie mógł uzyskać dostępu do wybranych plików lub folderu. Sprawdź uprawnienia i spróbuj ponownie." - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "O VniDrop não conseguiu aceder aos ficheiros ou à pasta selecionados. Verifique as permissões e tente novamente." - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "VniDrop не удалось получить доступ к выбранным файлам или папке. Проверьте разрешения и повторите попытку." - } - } - } - }, - "error_generic": { - "comment": "Error: generic fallback message.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Etwas ist schiefgelaufen. Versuchen Sie es erneut." - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Something went wrong. Try again." - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Algo salió mal. Inténtelo de nuevo." - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Une erreur est survenue. Réessayez." - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Qualcosa è andato storto. Riprovi." - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Er is iets misgegaan. Probeer het opnieuw." - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Coś poszło nie tak. Spróbuj ponownie." - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Algo correu mal. Tente novamente." - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Что-то пошло не так. Повторите попытку." - } - } - } - }, - "error_initialization": { - "comment": "Error: the app failed to finish starting up.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "VniDrop konnte den Start nicht abschließen. Schließen Sie die App und versuchen Sie es erneut." - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "VniDrop could not finish starting up. Close the app and try again." - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "VniDrop no pudo terminar de iniciarse. Cierre la app e inténtelo de nuevo." - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "VniDrop n’a pas pu terminer son démarrage. Fermez l’app et réessayez." - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "VniDrop non ha potuto completare l’avvio. Chiuda l’app e riprovi." - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "VniDrop kon het opstarten niet voltooien. Sluit de app en probeer het opnieuw." - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "VniDrop nie mógł dokończyć uruchamiania. Zamknij aplikację i spróbuj ponownie." - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "O VniDrop não conseguiu concluir o arranque. Feche a app e tente novamente." - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "VniDrop не удалось завершить запуск. Закройте приложение и повторите попытку." - } - } - } - }, - "error_invalid_input": { - "comment": "Error: transfer input or metadata is invalid.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Einige Übertragungsinformationen sind ungültig. Prüfen Sie Ihre Auswahl oder bitten Sie den Absender, erneut zu teilen." - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Some transfer information is invalid. Review your selection or ask the sender to share again." - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Parte de la información de la transferencia no es válida. Revise la selección o pida al remitente que vuelva a compartirla." - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Certaines informations du transfert ne sont pas valides. Vérifiez votre sélection ou demandez à l’expéditeur de partager à nouveau." - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Alcune informazioni del trasferimento non sono valide. Controlli la selezione o chieda al mittente di condividere di nuovo." - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Sommige overdrachtsgegevens zijn ongeldig. Controleer uw selectie of vraag de afzender opnieuw te delen." - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Niektóre informacje o transferze są nieprawidłowe. Sprawdź wybór lub poproś nadawcę o ponowne udostępnienie." - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Algumas informações da transferência são inválidas. Reveja a seleção ou peça ao remetente para partilhar novamente." - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Некоторые данные передачи недействительны. Проверьте выбор или попросите отправителя поделиться снова." - } - } - } - }, - "error_invalid_ticket": { - "comment": "Error: the invitation/ticket could not be parsed.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Diese Einladung konnte nicht gelesen werden. Bitten Sie den Absender um eine neue." - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "This invitation could not be read. Ask the sender for a new one." - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "No se pudo leer esta invitación. Pida al remitente una nueva." - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Cette invitation n’a pas pu être lue. Demandez-en une nouvelle à l’expéditeur." - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Impossibile leggere questo invito. Ne chieda uno nuovo al mittente." - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Deze uitnodiging kon niet worden gelezen. Vraag de afzender om een nieuwe." - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Nie udało się odczytać tego zaproszenia. Poproś nadawcę o nowe." - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Não foi possível ler este convite. Peça um novo ao remetente." - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Не удалось прочитать это приглашение. Попросите отправителя прислать новое." - } - } - } - }, - "error_invitation_empty": { - "comment": "Error: the opened invitation contained no data.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Diese Einladung ist leer. Versuchen Sie, sie erneut zu öffnen." - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "That invitation is empty. Try opening it again." - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Esa invitación está vacía. Intente abrirla de nuevo." - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Cette invitation est vide. Essayez de l’ouvrir à nouveau." - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Questo invito è vuoto. Provi ad aprirlo di nuovo." - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Die uitnodiging is leeg. Probeer deze opnieuw te openen." - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "To zaproszenie jest puste. Spróbuj otworzyć je ponownie." - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Esse convite está vazio. Tente abri-lo novamente." - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Это приглашение пустое. Попробуйте открыть его снова." - } - } - } - }, - "error_missing_native_library": { - "comment": "Error: the native library is missing from the build.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Die native VniDrop-Bibliothek fehlt in diesem Build." - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "The native VniDrop library is missing from this build." - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Falta la biblioteca nativa de VniDrop en esta versión." - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "La bibliothèque native de VniDrop est absente de cette version." - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "La libreria nativa di VniDrop non è presente in questa build." - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "De native VniDrop-bibliotheek ontbreekt in deze build." - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "W tej kompilacji brakuje natywnej biblioteki VniDrop." - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "A biblioteca nativa do VniDrop está em falta nesta compilação." - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "В этой сборке отсутствует нативная библиотека VniDrop." - } - } - } - }, - "error_network": { - "comment": "Error: the sender could not be reached over the local network.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "VniDrop konnte den Absender nicht erreichen. Prüfen Sie die Verbindung auf beiden Geräten und versuchen Sie es erneut." - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "VniDrop could not reach the sender. Check the connection on both devices and try again." - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "VniDrop no pudo contactar con el remitente. Compruebe la conexión en ambos dispositivos e inténtelo de nuevo." - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "VniDrop n’a pas pu joindre l’expéditeur. Vérifiez la connexion sur les deux appareils et réessayez." - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "VniDrop non è riuscito a raggiungere il mittente. Controlli la connessione su entrambi i dispositivi e riprovi." - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "VniDrop kon de afzender niet bereiken. Controleer de verbinding op beide apparaten en probeer het opnieuw." - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "VniDrop nie mógł połączyć się z nadawcą. Sprawdź połączenie na obu urządzeniach i spróbuj ponownie." - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "O VniDrop não conseguiu contactar o remetente. Verifique a ligação nos dois dispositivos e tente novamente." - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "VniDrop не удалось связаться с отправителем. Проверьте подключение на обоих устройствах и повторите попытку." - } - } - } - }, - "error_nfc": { - "comment": "Error: the NFC tag could not be read/used.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Dieses NFC-Tag konnte nicht verwendet werden. Versuchen Sie ein anderes." - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "This NFC tag could not be used. Try another tag." - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "No se pudo usar esta etiqueta NFC. Pruebe con otra." - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Ce tag NFC n’a pas pu être utilisé. Essayez-en un autre." - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Impossibile usare questo tag NFC. Ne provi un altro." - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Deze NFC-tag kon niet worden gebruikt. Probeer een andere." - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Nie udało się użyć tego tagu NFC. Spróbuj innego." - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Não foi possível utilizar esta etiqueta NFC. Tente outra." - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Не удалось использовать эту NFC-метку. Попробуйте другую." - } - } - } - }, - "error_permission": { - "comment": "Error: the transfer was not approved or was refused by the sender.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Der Absender hat diese Übertragung nicht genehmigt oder sie wurde abgelehnt." - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "The sender has not approved this transfer, or it was refused." - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "El remitente no ha aprobado esta transferencia, o fue rechazada." - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "L’expéditeur n’a pas approuvé ce transfert, ou il a été refusé." - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Il mittente non ha approvato questo trasferimento, oppure è stato rifiutato." - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "De afzender heeft deze overdracht niet goedgekeurd, of deze is geweigerd." - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Nadawca nie zatwierdził tego transferu lub został on odrzucony." - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "O remetente não aprovou esta transferência, ou foi recusada." - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Отправитель не одобрил эту передачу, или она была отклонена." - } - } - } - }, - "error_repository": { - "comment": "Error: transfer data could not be saved locally.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "VniDrop konnte die Übertragungsdaten auf diesem Gerät nicht speichern." - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "VniDrop could not save transfer data on this device." - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "VniDrop no pudo guardar los datos de la transferencia en este dispositivo." - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "VniDrop n’a pas pu enregistrer les données de transfert sur cet appareil." - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "VniDrop non ha potuto salvare i dati del trasferimento su questo dispositivo." - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "VniDrop kon de overdrachtsgegevens niet op dit apparaat bewaren." - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "VniDrop nie mógł zapisać danych transferu na tym urządzeniu." - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "O VniDrop não conseguiu guardar os dados da transferência neste dispositivo." - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "VniDrop не удалось сохранить данные передачи на этом устройстве." - } - } - } - }, - "error_selection_failed": { - "comment": "Error: the selected item could not be opened.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Das ausgewählte Objekt konnte nicht geöffnet werden. Versuchen Sie, es erneut auszuwählen." - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Could not open the selected item. Try choosing it again." - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "No se pudo abrir el elemento seleccionado. Intente elegirlo de nuevo." - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Impossible d’ouvrir l’élément sélectionné. Essayez de le choisir à nouveau." - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Impossibile aprire l’elemento selezionato. Provi a sceglierlo di nuovo." - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Het geselecteerde item kon niet worden geopend. Probeer het opnieuw te kiezen." - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Nie udało się otworzyć wybranego elementu. Spróbuj wybrać go ponownie." - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Não foi possível abrir o item selecionado. Tente escolhê-lo novamente." - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Не удалось открыть выбранный объект. Попробуйте выбрать его снова." - } - } - } - }, - "error_share_empty": { - "comment": "Error: attempted to share with nothing selected.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Wählen Sie mindestens ein Objekt zum Teilen aus." - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Select at least one item to share." - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Seleccione al menos un elemento para compartir." - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Sélectionnez au moins un élément à partager." - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Selezioni almeno un elemento da condividere." - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Selecteer minstens één item om te delen." - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Wybierz co najmniej jeden element do udostępnienia." - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Selecione pelo menos um item para partilhar." - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Выберите хотя бы один объект для отправки." - } - } - } - }, - "error_socket_bind": { - "comment": "Error: the app could not open its network sockets.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "VniDrop konnte seine Netzwerk-Sockets auf diesem Gerät nicht öffnen." - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "VniDrop could not open its network sockets on this device." - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "VniDrop no pudo abrir sus sockets de red en este dispositivo." - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "VniDrop n’a pas pu ouvrir ses sockets réseau sur cet appareil." - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "VniDrop non ha potuto aprire i suoi socket di rete su questo dispositivo." - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "VniDrop kon zijn netwerksockets niet openen op dit apparaat." - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "VniDrop nie mógł otworzyć gniazd sieciowych na tym urządzeniu." - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "O VniDrop não conseguiu abrir os seus sockets de rede neste dispositivo." - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "VniDrop не удалось открыть сетевые сокеты на этом устройстве." - } - } - } - }, - "error_starting_up": { - "comment": "Error: an invitation was opened before startup finished.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "VniDrop startet noch. Öffnen Sie die Einladung gleich erneut." - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "VniDrop is still starting. Open the invitation again in a moment." - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "VniDrop todavía se está iniciando. Vuelva a abrir la invitación en un momento." - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "VniDrop démarre encore. Rouvrez l’invitation dans un instant." - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "VniDrop è ancora in fase di avvio. Riapra l’invito tra un momento." - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "VniDrop is nog aan het opstarten. Open de uitnodiging zo meteen opnieuw." - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "VniDrop jeszcze się uruchamia. Otwórz zaproszenie ponownie za chwilę." - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "O VniDrop ainda está a iniciar. Abra o convite novamente dentro de momentos." - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "VniDrop ещё запускается. Откройте приглашение снова через мгновение." - } - } - } - }, - "error_storage_full": { - "comment": "Error: the destination does not have enough free storage.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Zum Speichern dieser Übertragung ist nicht genügend Speicherplatz vorhanden. Geben Sie Speicherplatz frei und versuchen Sie es erneut." - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "There is not enough storage space to save this transfer. Free up space and try again." - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "No hay suficiente espacio de almacenamiento para guardar esta transferencia. Libere espacio e inténtelo de nuevo." - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "L’espace de stockage est insuffisant pour enregistrer ce transfert. Libérez de l’espace et réessayez." - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Lo spazio di archiviazione non è sufficiente per salvare il trasferimento. Liberi spazio e riprovi." - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Er is onvoldoende opslagruimte om deze overdracht op te slaan. Maak ruimte vrij en probeer het opnieuw." - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Brakuje miejsca na zapisanie tego transferu. Zwolnij miejsce i spróbuj ponownie." - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Não existe espaço de armazenamento suficiente para guardar esta transferência. Liberte espaço e tente novamente." - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Недостаточно места для сохранения этой передачи. Освободите место и повторите попытку." - } - } - } - }, - "error_transfer": { - "comment": "Error: transfer data could not be processed; network failures use error_network.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Die Übertragungsdaten konnten nicht verarbeitet werden. Bitten Sie den Absender, sie erneut zu teilen." - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "The transfer data could not be processed. Ask the sender to share it again." - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "No se pudieron procesar los datos de la transferencia. Pida al remitente que vuelva a compartirlos." - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Les données du transfert n’ont pas pu être traitées. Demandez à l’expéditeur de les partager à nouveau." - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Non è stato possibile elaborare i dati del trasferimento. Chieda al mittente di condividerli di nuovo." - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "De overdrachtsgegevens konden niet worden verwerkt. Vraag de afzender ze opnieuw te delen." - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Nie udało się przetworzyć danych transferu. Poproś nadawcę o ponowne udostępnienie." - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Não foi possível processar os dados da transferência. Peça ao remetente para os partilhar novamente." - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Не удалось обработать данные передачи. Попросите отправителя поделиться ими снова." - } - } - } - }, - "field_receiver_name": { - "comment": "Text field label: the receiver's display name.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Empfängername" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Receiver name" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Nombre del destinatario" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Nom du destinataire" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Nome del destinatario" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Naam van ontvanger" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Nazwa odbiorcy" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Nome do destinatário" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Имя получателя" - } - } - } - }, - "field_sender_name": { - "comment": "Text field label: the sender's display name.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Absendername" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Sender name" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Nombre del remitente" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Nom de l’expéditeur" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Nome del mittente" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Naam van afzender" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Nazwa nadawcy" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Nome do remetente" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Имя отправителя" - } - } - } - }, - "field_transfer_name": { - "comment": "Text field label: the name given to a transfer.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Übertragungsname" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Transfer name" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Nombre de la transferencia" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Nom du transfert" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Nome del trasferimento" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Naam van overdracht" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Nazwa transferu" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Nome da transferência" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Название передачи" - } - } - } - }, - "field_username": { - "comment": "Settings text field label: this device's display name.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Anzeigename" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Display name" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Nombre visible" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Nom d’affichage" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Nome visualizzato" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Weergavenaam" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Nazwa wyświetlana" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Nome a apresentar" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Отображаемое имя" - } - } - } - }, - "folder_status_permission_required": { - "comment": "Receive-folder status: permission is required to write to the chosen folder.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Berechtigung erforderlich" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Permission needed" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Permiso necesario" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Autorisation requise" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Autorizzazione necessaria" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Machtiging vereist" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Wymagane uprawnienie" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Permissão necessária" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Требуется разрешение" - } - } - } - }, - "folder_status_unavailable": { - "comment": "Receive-folder status: the chosen folder is unavailable.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Nicht verfügbar" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Unavailable" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "No disponible" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Indisponible" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Non disponibile" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Niet beschikbaar" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Niedostępny" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Indisponível" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Недоступно" - } - } - } - }, - "folder_status_validating": { - "comment": "Receive-folder status: the folder is being checked.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Ordner wird geprüft…" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Checking folder…" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Comprobando carpeta…" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Vérification du dossier…" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Controllo della cartella…" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Map controleren…" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Sprawdzanie folderu…" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "A verificar a pasta…" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Проверка папки…" - } - } - } - }, - "folder_status_writable": { - "comment": "Receive-folder status: the folder is valid and writable.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Bereit" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Ready" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Listo" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Prêt" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Pronta" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Gereed" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Gotowy" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Pronto" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Готово" - } - } - } - }, - "metadata_files": { - "comment": "Transfer metadata label: number of files.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Dateien" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Files" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Archivos" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Fichiers" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "File" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Bestanden" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Pliki" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Ficheiros" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Файлы" - } - } - } - }, - "metadata_size": { - "comment": "Transfer metadata label: total size.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Größe" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Size" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Tamaño" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Taille" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Dimensione" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Grootte" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Rozmiar" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Tamanho" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Размер" - } - } - } - }, - "metadata_status": { - "comment": "Transfer metadata label: current status.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Status" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Status" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Estado" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Statut" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Stato" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Status" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Status" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Estado" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Статус" - } - } - } - }, - "nav_receive": { - "comment": "Bottom navigation: Receive tab label.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Empfangen" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Receive" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Recibir" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Recevoir" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Ricevi" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Ontvangen" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Odbierz" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Receber" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Получить" - } - } - } - }, - "nav_send": { - "comment": "Bottom navigation: Send tab label.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Senden" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Send" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Enviar" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Envoyer" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Invia" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Versturen" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Wyślij" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Enviar" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Отправить" - } - } - } - }, - "nav_settings": { - "comment": "Bottom navigation: Settings tab label.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Einstellungen" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Settings" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Ajustes" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Réglages" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Impostazioni" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Instellingen" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Ustawienia" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Definições" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Настройки" - } - } - } - }, - "network_title": { - "comment": "Device information row: network label.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Netzwerk" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Network" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Red" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Réseau" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Rete" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Netwerk" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Sieć" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Rede" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Сеть" - } - } - } - }, - "notifications_description": { - "comment": "Settings > Notifications: explanation of what notifications are used for.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Werden Sie über neue Empfangsanfragen benachrichtigt, während VniDrop im Hintergrund läuft." - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Get notified about new receive requests while VniDrop is in the background." - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Reciba avisos sobre nuevas solicitudes de recepción cuando VniDrop está en segundo plano." - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Soyez averti des nouvelles demandes de réception lorsque VniDrop est en arrière-plan." - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Ricevi avvisi sulle nuove richieste di ricezione quando VniDrop è in background." - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Ontvang meldingen over nieuwe ontvangstverzoeken terwijl VniDrop op de achtergrond draait." - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Otrzymuj powiadomienia o nowych prośbach o odbiór, gdy VniDrop działa w tle." - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Seja notificado sobre novos pedidos de receção enquanto o VniDrop está em segundo plano." - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Получайте уведомления о новых запросах на получение, пока VniDrop работает в фоне." - } - } - } - }, - "notifications_enabled_message": { - "comment": "Settings > Notifications: confirmation when notifications are enabled.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Mitteilungen aktiviert." - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Notifications enabled." - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Notificaciones activadas." - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Notifications activées." - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Notifiche attivate." - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Meldingen ingeschakeld." - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Powiadomienia włączone." - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Notificações ativadas." - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Уведомления включены." - } - } - } - }, - "notifications_local_title": { - "comment": "Settings > Notifications: label for the allow-notifications action.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Mitteilungen erlauben" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Allow notifications" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Permitir notificaciones" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Autoriser les notifications" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Consenti le notifiche" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Meldingen toestaan" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Zezwól na powiadomienia" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Permitir notificações" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Разрешить уведомления" - } - } - } - }, - "notifications_permission_denied": { - "comment": "Settings > Notifications: message when the OS permission is denied.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Mitteilungen sind für VniDrop deaktiviert. Sie können sie in den Einstellungen aktivieren." - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Notifications are turned off for VniDrop. You can enable them in Settings." - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Las notificaciones están desactivadas para VniDrop. Puede activarlas en Ajustes." - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Les notifications sont désactivées pour VniDrop. Vous pouvez les activer dans les Réglages." - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Le notifiche sono disattivate per VniDrop. Può attivarle in Impostazioni." - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Meldingen zijn uitgeschakeld voor VniDrop. U kunt ze inschakelen in Instellingen." - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Powiadomienia są wyłączone dla VniDrop. Możesz je włączyć w Ustawieniach." - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "As notificações estão desativadas para o VniDrop. Pode ativá-las nas Definições." - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Уведомления отключены для VniDrop. Вы можете включить их в Настройках." - } - } - } - }, - "notifications_settings_open_failed": { - "comment": "Settings > Notifications: error when the OS notification settings can't be opened.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Die Mitteilungseinstellungen konnten nicht geöffnet werden." - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Could not open notification settings." - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "No se pudieron abrir los ajustes de notificaciones." - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Impossible d’ouvrir les réglages de notifications." - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Impossibile aprire le impostazioni delle notifiche." - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "De meldingsinstellingen konden niet worden geopend." - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Nie udało się otworzyć ustawień powiadomień." - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Não foi possível abrir as definições de notificações." - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Не удалось открыть настройки уведомлений." - } - } - } - }, - "notifications_title": { - "comment": "Settings > Notifications: section title.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Mitteilungen" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Notifications" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Notificaciones" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Notifications" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Notifiche" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Meldingen" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Powiadomienia" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Notificações" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Уведомления" - } - } - } - }, - "notifications_unsupported": { - "comment": "Settings > Notifications: message when notifications aren't supported on the device.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Mitteilungen sind auf diesem Gerät nicht verfügbar." - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Notifications are not available on this device." - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Las notificaciones no están disponibles en este dispositivo." - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Les notifications ne sont pas disponibles sur cet appareil." - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Le notifiche non sono disponibili su questo dispositivo." - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Meldingen zijn niet beschikbaar op dit apparaat." - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Powiadomienia nie są dostępne na tym urządzeniu." - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "As notificações não estão disponíveis neste dispositivo." - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Уведомления недоступны на этом устройстве." - } - } - } - }, - "os_version_title": { - "comment": "Device information row: operating system version label.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Betriebssystem" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Operating system" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Sistema operativo" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Système d’exploitation" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Sistema operativo" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Besturingssysteem" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "System operacyjny" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Sistema operativo" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Операционная система" - } - } - } - }, - "preferences_receive_folder_title": { - "comment": "Settings > Preferences: label for the received-files destination folder.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Empfangene Übertragungen sichern in" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Save received transfers to" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Guardar las transferencias recibidas en" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Enregistrer les transferts reçus dans" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Salva i trasferimenti ricevuti in" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Ontvangen overdrachten bewaren in" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Zapisuj odebrane transfery w" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Guardar as transferências recebidas em" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Сохранять полученные передачи в" - } - } - } - }, - "preferences_title": { - "comment": "Settings > Preferences: section title.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Voreinstellungen" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Preferences" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Preferencias" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Préférences" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Preferenze" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Voorkeuren" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Preferencje" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Preferências" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Параметры" - } - } - } - }, - "progress_cancelled": { - "comment": "Transfer progress label: cancelled.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Abgebrochen" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Cancelled" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Cancelado" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Annulé" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Annullato" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Geannuleerd" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Anulowano" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Cancelado" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Отменено" - } - } - } - }, - "progress_completed": { - "comment": "Transfer progress label: completed.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Abgeschlossen" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Completed" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Completado" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Terminé" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Completato" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Voltooid" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Ukończono" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Concluído" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Завершено" - } - } - } - }, - "progress_connected": { - "comment": "Transfer progress label: connected.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Verbunden" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Connected" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Conectado" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Connecté" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Connesso" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Verbonden" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Połączono" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Ligado" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Подключено" - } - } - } - }, - "progress_connecting": { - "comment": "Transfer progress label: connecting.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Verbinden" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Connecting" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Conectando" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Connexion" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Connessione" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Verbinden" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Łączenie" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "A ligar" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Подключение" - } - } - } - }, - "progress_downloading": { - "comment": "Transfer progress label: downloading.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Wird geladen" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Downloading" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Descargando" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Téléchargement" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Download" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Downloaden" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Pobieranie" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "A descarregar" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Загрузка" - } - } - } - }, - "progress_failed": { - "comment": "Transfer progress label: failed.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Fehlgeschlagen" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Failed" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Fallido" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Échec" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Non riuscito" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Mislukt" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Niepowodzenie" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Falhou" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Ошибка" - } - } - } - }, - "progress_getting_ready": { - "comment": "Transfer progress label: getting ready.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Wird bereitgemacht" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Getting ready" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Preparándose" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Préparation" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Preparazione" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Klaarmaken" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Przygotowywanie" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "A preparar-se" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Подготовка" - } - } - } - }, - "progress_interrupted": { - "comment": "Transfer progress label: the transfer was interrupted.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Übertragung unterbrochen" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Transfer interrupted" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Transferencia interrumpida" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Transfert interrompu" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Trasferimento interrotto" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Overdracht onderbroken" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Transfer przerwany" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Transferência interrompida" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Передача прервана" - } - } - } - }, - "progress_preparing": { - "comment": "Transfer progress label: preparing.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Wird vorbereitet" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Preparing" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Preparando" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Préparation" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Preparazione" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Voorbereiden" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Przygotowywanie" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "A preparar" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Подготовка" - } - } - } - }, - "progress_ready": { - "comment": "Transfer progress label: ready.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Bereit" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Ready" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Listo" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Prêt" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Pronto" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Gereed" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Gotowe" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Pronto" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Готово" - } - } - } - }, - "progress_receiving": { - "comment": "Transfer progress label: receiving.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Wird empfangen" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Receiving" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Recibiendo" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Réception" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Ricezione" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Ontvangen" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Odbieranie" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "A receber" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Получение" - } - } - } - }, - "progress_requesting_access": { - "comment": "Transfer progress label: requesting access from the sender.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Zugriff wird angefragt" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Requesting access" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Solicitando acceso" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Demande d’accès" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Richiesta di accesso" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Toegang aanvragen" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Prośba o dostęp" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "A pedir acesso" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Запрос доступа" - } - } - } - }, - "progress_saving": { - "comment": "Transfer progress label: saving received files.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Wird gesichert" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Saving" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Guardando" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Enregistrement" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Salvataggio" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Bewaren" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Zapisywanie" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "A guardar" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Сохранение" - } - } - } - }, - "progress_sending": { - "comment": "Transfer progress label: sending.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Wird gesendet" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Sending" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Enviando" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Envoi" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Invio" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Versturen" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Wysyłanie" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "A enviar" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Отправка" - } - } - } - }, - "progress_sending_to_count": { - "comment": "Transfer progress label: sending to N receivers. %lld = receiver count.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Senden an %1$d" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Sending to %1$d" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Enviando a %1$d" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Envoi à %1$d" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Invio a %1$d" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Versturen naar %1$d" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Wysyłanie do %1$d" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "A enviar para %1$d" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Отправка получателям: %1$d" - } - } - } - }, - "progress_share_ready": { - "comment": "Transfer progress label: the share is ready.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Bereit zum Teilen" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Ready to share" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Listo para compartir" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Prêt à partager" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Pronto per la condivisione" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Klaar om te delen" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Gotowe do udostępnienia" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Pronto para partilhar" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Готово к отправке" - } - } - } - }, - "progress_working": { - "comment": "Transfer progress label: generic working/in-progress state.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Wird bearbeitet…" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Working…" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Trabajando…" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "En cours…" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Elaborazione…" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Bezig…" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Przetwarzanie…" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "A processar…" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Обработка…" - } - } - } - }, - "receive_choose_method_body": { - "comment": "Receive flow: body text prompting the user to pick an invitation method.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Wählen Sie die Ihnen zur Verfügung stehende Einladungsmethode." - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Choose the invitation method available to you." - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Elija el método de invitación de que disponga." - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Choisissez la méthode d’invitation à votre disposition." - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Scelga il metodo di invito a sua disposizione." - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Kies de uitnodigingsmethode die u ter beschikking staat." - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Wybierz dostępną metodę zaproszenia." - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Escolha o método de convite ao seu dispor." - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Выберите доступный вам способ приглашения." - } - } - } - }, - "receive_choose_method_title": { - "comment": "Receive flow: title of the connect-method chooser.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Wie möchten Sie sich verbinden?" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "How would you like to connect?" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "¿Cómo quiere conectarse?" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Comment souhaitez-vous vous connecter ?" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Come vuole connettersi?" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Hoe wilt u verbinding maken?" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Jak chcesz się połączyć?" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Como pretende ligar-se?" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Как вы хотите подключиться?" - } - } - } - }, - "receive_clear_history": { - "comment": "Receive history: action to clear all history.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Verlauf löschen" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Clear history" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Borrar historial" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Effacer l’historique" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Cancella cronologia" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Geschiedenis wissen" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Wyczyść historię" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Limpar histórico" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Очистить историю" - } - } - } - }, - "receive_clear_history_description": { - "comment": "Receive history: confirmation dialog body for clearing all history.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Alle abgeschlossenen, fehlgeschlagenen und abgebrochenen Empfänge werden aus dem Verlauf von VniDrop entfernt. Heruntergeladene Dateien verbleiben auf diesem Gerät." - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "All completed, failed, and cancelled receives will be removed from VniDrop’s history. Downloaded files will remain on this device." - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Todas las recepciones completadas, fallidas y canceladas se eliminarán del historial de VniDrop. Los archivos descargados permanecerán en este dispositivo." - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Toutes les réceptions terminées, échouées et annulées seront retirées de l’historique de VniDrop. Les fichiers téléchargés resteront sur cet appareil." - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Tutte le ricezioni completate, non riuscite e annullate verranno rimosse dalla cronologia di VniDrop. I file scaricati rimarranno su questo dispositivo." - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Alle voltooide, mislukte en geannuleerde ontvangsten worden uit de geschiedenis van VniDrop verwijderd. Gedownloade bestanden blijven op dit apparaat." - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Wszystkie ukończone, nieudane i anulowane odbiory zostaną usunięte z historii VniDrop. Pobrane pliki pozostaną na tym urządzeniu." - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Todas as receções concluídas, falhadas e canceladas serão removidas do histórico do VniDrop. Os ficheiros descarregados permanecerão neste dispositivo." - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Все завершённые, неудачные и отменённые получения будут удалены из истории VniDrop. Загруженные файлы останутся на этом устройстве." - } - } - } - }, - "receive_clear_history_title": { - "comment": "Receive history: confirmation dialog title for clearing all history.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Empfangsverlauf löschen?" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Clear receive history?" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "¿Borrar el historial de recepción?" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Effacer l’historique de réception ?" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Cancellare la cronologia di ricezione?" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Ontvangstgeschiedenis wissen?" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Wyczyścić historię odbioru?" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Limpar o histórico de receção?" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Очистить историю получения?" - } - } - } - }, - "receive_completed": { - "comment": "Receive flow: toast/message when a transfer finishes downloading.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Übertragung empfangen." - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Transfer received." - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Transferencia recibida." - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Transfert reçu." - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Trasferimento ricevuto." - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Overdracht ontvangen." - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Transfer odebrany." - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Transferência recebida." - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Передача получена." - } - } - } - }, - "receive_delete_history_description": { - "comment": "Receive history: confirmation body for removing one item. {arg1} = transfer name.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "„%1$@“ wird aus dem Verlauf von VniDrop entfernt. Die heruntergeladene Datei verbleibt auf diesem Gerät." - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "“%1$@” will be removed from VniDrop’s history. The downloaded file will remain on this device." - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "«%1$@» se eliminará del historial de VniDrop. El archivo descargado permanecerá en este dispositivo." - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "« %1$@ » sera retiré de l’historique de VniDrop. Le fichier téléchargé restera sur cet appareil." - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "«%1$@» verrà rimosso dalla cronologia di VniDrop. Il file scaricato rimarrà su questo dispositivo." - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "‘%1$@’ wordt uit de geschiedenis van VniDrop verwijderd. Het gedownloade bestand blijft op dit apparaat." - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "„%1$@” zostanie usunięty z historii VniDrop. Pobrany plik pozostanie na tym urządzeniu." - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "«%1$@» será removido do histórico do VniDrop. O ficheiro descarregado permanecerá neste dispositivo." - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "«%1$@» будет удалён из истории VniDrop. Загруженный файл останется на этом устройстве." - } - } - } - }, - "receive_delete_history_item": { - "comment": "Receive history: swipe/menu action to delete a single history item.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Aus Empfangsverlauf löschen" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Delete from receive history" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Eliminar del historial de recepción" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Supprimer de l’historique de réception" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Elimina dalla cronologia di ricezione" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Uit ontvangstgeschiedenis verwijderen" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Usuń z historii odbioru" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Eliminar do histórico de receção" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Удалить из истории получения" - } - } - } - }, - "receive_delete_history_title": { - "comment": "Receive history: confirmation title for removing one item.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Aus dem Verlauf entfernen?" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Remove from history?" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "¿Quitar del historial?" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Retirer de l’historique ?" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Rimuovere dalla cronologia?" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Uit geschiedenis verwijderen?" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Usunąć z historii?" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Remover do histórico?" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Удалить из истории?" - } - } - } - }, - "receive_empty_body": { - "comment": "Receive tab empty state: instructions on how to receive.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Öffnen Sie eine VniDrop-Einladung, scannen Sie einen QR-Code oder halten Sie das Gerät an ein NFC-Tag." - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Open a VniDrop invitation, scan a QR code, or hold near an NFC tag." - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Abra una invitación de VniDrop, escanee un código QR o acerque una etiqueta NFC." - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Ouvrez une invitation VniDrop, scannez un QR code ou approchez un tag NFC." - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Apra un invito VniDrop, scansioni un codice QR o avvicini un tag NFC." - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Open een VniDrop-uitnodiging, scan een QR-code of houd het apparaat bij een NFC-tag." - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Otwórz zaproszenie VniDrop, zeskanuj kod QR lub zbliż tag NFC." - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Abra um convite do VniDrop, leia um código QR ou aproxime uma etiqueta NFC." - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Откройте приглашение VniDrop, отсканируйте QR-код или поднесите NFC-метку." - } - } - } - }, - "receive_empty_title": { - "comment": "Receive tab empty state: title when nothing has been received.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Noch nichts empfangen" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Nothing received yet" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Aún no se ha recibido nada" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Rien reçu pour l’instant" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Ancora nulla di ricevuto" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Nog niets ontvangen" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Nic jeszcze nie odebrano" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Ainda não recebeu nada" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Пока ничего не получено" - } - } - } - }, - "receive_history_cleared": { - "comment": "Receive history: toast confirming history was cleared.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Empfangsverlauf gelöscht." - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Receive history cleared." - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Historial de recepción borrado." - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Historique de réception effacé." - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Cronologia di ricezione cancellata." - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Ontvangstgeschiedenis gewist." - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Historia odbioru wyczyszczona." - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Histórico de receção limpo." - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "История получения очищена." - } - } - } - }, - "receive_history_title": { - "comment": "Receive tab: History section title.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Verlauf" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "History" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Historial" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Historique" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Cronologia" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Geschiedenis" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Historia" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Histórico" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "История" - } - } - } - }, - "receive_method_file": { - "comment": "Receive method: open a .vnd invitation file.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Eine .vnd-Einladung öffnen" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Open a .vnd invitation" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Abrir una invitación .vnd" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Ouvrir une invitation .vnd" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Apri un invito .vnd" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Een .vnd-uitnodiging openen" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Otwórz zaproszenie .vnd" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Abrir um convite .vnd" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Открыть приглашение .vnd" - } - } - } - }, - "receive_method_file_description": { - "comment": "Receive method description: open a saved/shared invitation file.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Wählen Sie eine auf diesem Gerät gespeicherte oder geteilte Einladung." - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Choose an invitation saved or shared to this device." - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Elija una invitación guardada o compartida en este dispositivo." - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Choisissez une invitation enregistrée ou partagée sur cet appareil." - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Scelga un invito salvato o condiviso su questo dispositivo." - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Kies een uitnodiging die op dit apparaat is bewaard of gedeeld." - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Wybierz zaproszenie zapisane lub udostępnione na tym urządzeniu." - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Escolha um convite guardado ou partilhado neste dispositivo." - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Выберите приглашение, сохранённое или отправленное на это устройство." - } - } - } - }, - "receive_method_nfc": { - "comment": "Receive method: read an NFC tag.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "NFC-Tag lesen" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Read NFC tag" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Leer etiqueta NFC" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Lire un tag NFC" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Leggi tag NFC" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "NFC-tag lezen" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Odczytaj tag NFC" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Ler etiqueta NFC" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Прочитать NFC-метку" - } - } - } - }, - "receive_method_nfc_description": { - "comment": "Receive method description: hold near the sender's NFC tag.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Halten Sie dieses Gerät an das Einladungs-Tag des Absenders." - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Hold this device near the sender’s invitation tag." - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Acerque este dispositivo a la etiqueta de invitación del remitente." - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Approchez cet appareil du tag d’invitation de l’expéditeur." - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Avvicini questo dispositivo al tag di invito del mittente." - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Houd dit apparaat bij de uitnodigingstag van de afzender." - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Zbliż to urządzenie do tagu zaproszenia nadawcy." - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Aproxime este dispositivo da etiqueta de convite do remetente." - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Поднесите это устройство к метке приглашения отправителя." - } - } - } - }, - "receive_method_scan": { - "comment": "Receive method: scan a QR code.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "QR-Code scannen" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Scan QR code" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Escanear código QR" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Scanner un QR code" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Scansiona codice QR" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "QR-code scannen" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Zeskanuj kod QR" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Ler código QR" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Сканировать QR-код" - } - } - } - }, - "receive_method_scan_description": { - "comment": "Receive method description: use the camera to scan the sender's code.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Verwenden Sie die Kamera, um den VniDrop-Code des Absenders zu scannen." - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Use the camera to scan the sender’s VniDrop code." - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Use la cámara para escanear el código de VniDrop del remitente." - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Utilisez la caméra pour scanner le code VniDrop de l’expéditeur." - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Usi la fotocamera per scansionare il codice VniDrop del mittente." - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Gebruik de camera om de VniDrop-code van de afzender te scannen." - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Użyj aparatu, aby zeskanować kod VniDrop nadawcy." - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Use a câmara para ler o código VniDrop do remetente." - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Используйте камеру, чтобы отсканировать код VniDrop отправителя." - } - } - } - }, - "receive_new_subtitle": { - "comment": "Receive tab: subtitle under the title.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Übertragungen, die Sie auf diesem Gerät empfangen haben." - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Transfers you’ve received on this device." - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Transferencias que ha recibido en este dispositivo." - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Transferts que vous avez reçus sur cet appareil." - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Trasferimenti che ha ricevuto su questo dispositivo." - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Overdrachten die u op dit apparaat hebt ontvangen." - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Transfery odebrane na tym urządzeniu." - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Transferências que recebeu neste dispositivo." - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Передачи, полученные на этом устройстве." - } - } - } - }, - "receive_nfc_waiting": { - "comment": "Receive flow: prompt while waiting to read an NFC tag.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Halten Sie das Gerät an das NFC-Tag…" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Hold near the NFC tag…" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Acerque a la etiqueta NFC…" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Approchez du tag NFC…" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Avvicini al tag NFC…" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Houd bij de NFC-tag…" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Zbliż do tagu NFC…" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Aproxime da etiqueta NFC…" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Поднесите к NFC-метке…" - } - } - } - }, - "receive_open_files_failed": { - "comment": "Receive flow: error when opening VniDrop's folder in Files fails.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "VniDrop konnte in „Dateien“ nicht geöffnet werden." - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Couldn’t open VniDrop in Files." - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "No se pudo abrir VniDrop en Archivos." - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Impossible d’ouvrir VniDrop dans Fichiers." - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Impossibile aprire VniDrop in File." - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "VniDrop kon niet worden geopend in Bestanden." - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Nie udało się otworzyć VniDrop w Plikach." - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Não foi possível abrir o VniDrop em Ficheiros." - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Не удалось открыть VniDrop в Файлах." - } - } - } - }, - "receive_review_title": { - "comment": "Receive flow: title of the review screen before accepting a transfer.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Übertragung prüfen" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Review transfer" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Revisar transferencia" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Vérifier le transfert" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Rivedi trasferimento" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Overdracht controleren" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Przejrzyj transfer" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Rever transferência" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Проверить передачу" - } - } - } - }, - "receive_title": { - "comment": "Receive tab: screen title.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Empfangen" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Receive" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Recibir" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Recevoir" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Ricevi" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Ontvangen" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Odbierz" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Receber" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Получить" - } - } - } - }, - "receive_unknown_transfer": { - "comment": "Receive flow: fallback name for a transfer with no title.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "VniDrop-Übertragung" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "VniDrop transfer" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Transferencia de VniDrop" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Transfert VniDrop" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Trasferimento VniDrop" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "VniDrop-overdracht" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Transfer VniDrop" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Transferência VniDrop" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Передача VniDrop" - } - } - } - }, - "relay_add_url": { - "comment": "Apple Network settings button that appends another custom relay URL field.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Relay-Server hinzufügen" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Add relay server" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Añadir servidor de retransmisión" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Ajouter un serveur relais" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Aggiungi server relay" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Relayserver toevoegen" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Dodaj serwer przekaźnikowy" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Adicionar servidor de retransmissão" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Добавить сервер-ретранслятор" - } - } - } - }, - "relay_apply": { - "comment": "Network settings button that activates the selected relay configuration.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Netzwerkeinstellungen anwenden" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Apply network settings" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Aplicar ajustes de red" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Appliquer les réglages réseau" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Applica impostazioni di rete" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Netwerkinstellingen toepassen" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Zastosuj ustawienia sieci" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Aplicar definições de rede" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Применить настройки сети" - } - } - } - }, - "relay_apply_active_transfers": { - "comment": "Network settings warning when relay configuration cannot change during active work.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Beenden Sie alle aktiven Übertragungen und Freigaben, bevor Sie die Netzwerkeinstellungen anwenden." - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Stop all active transfers and shares before applying network settings." - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Detenga todas las transferencias y elementos compartidos activos antes de aplicar los ajustes de red." - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Arrêtez tous les transferts et partages actifs avant d’appliquer les réglages réseau." - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Interrompa tutti i trasferimenti e le condivisioni attivi prima di applicare le impostazioni di rete." - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Stop alle actieve overdrachten en gedeelde items voordat u de netwerkinstellingen toepast." - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Zatrzymaj wszystkie aktywne transfery i udostępnienia przed zastosowaniem ustawień sieci." - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Pare todas as transferências e partilhas ativas antes de aplicar as definições de rede." - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Остановите все активные передачи и раздачи перед применением настроек сети." - } - } - } - }, - "relay_apply_failed": { - "comment": "Network settings error after a relay configuration fails and the previous one is restored.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Diese Einstellungen konnten nicht angewendet werden. Die vorherigen Netzwerkeinstellungen wurden wiederhergestellt." - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Could not apply these settings. The previous network settings were restored." - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "No se han podido aplicar estos ajustes. Se han restaurado los ajustes de red anteriores." - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Impossible d’appliquer ces réglages. Les réglages réseau précédents ont été restaurés." - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Impossibile applicare queste impostazioni. Sono state ripristinate le impostazioni di rete precedenti." - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Deze instellingen konden niet worden toegepast. De vorige netwerkinstellingen zijn hersteld." - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Nie udało się zastosować tych ustawień. Przywrócono poprzednie ustawienia sieci." - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Não foi possível aplicar estas definições. As definições de rede anteriores foram restauradas." - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Не удалось применить эти настройки. Предыдущие настройки сети восстановлены." - } - } - } - }, - "relay_apply_restart_description": { - "comment": "Network settings explanation of restart and invitation effects when applying relay changes.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Beim Anwenden wird die Netzwerkverbindung von VniDrop neu gestartet. Beenden Sie zuerst aktive Übertragungen und Freigaben. Vorhandene Einladungen müssen eventuell erneut geteilt werden." - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Applying restarts VniDrop’s network connection. Stop active transfers and shares first. Existing invitations may need to be shared again." - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Al aplicar los ajustes, se reinicia la conexión de red de VniDrop. Detenga primero las transferencias y los elementos compartidos activos. Es posible que tenga que volver a compartir las invitaciones existentes." - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "L’application de ces réglages redémarre la connexion réseau de VniDrop. Arrêtez d’abord les transferts et partages actifs. Il peut être nécessaire de partager à nouveau les invitations existantes." - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "L’applicazione riavvia la connessione di rete di VniDrop. Interrompa prima i trasferimenti e le condivisioni attivi. Potrebbe essere necessario condividere di nuovo gli inviti esistenti." - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Bij het toepassen wordt de netwerkverbinding van VniDrop opnieuw gestart. Stop eerst actieve overdrachten en gedeelde items. Bestaande uitnodigingen moeten mogelijk opnieuw worden gedeeld." - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Zastosowanie ustawień ponownie uruchamia połączenie sieciowe VniDrop. Najpierw zatrzymaj aktywne transfery i udostępnienia. Istniejące zaproszenia mogą wymagać ponownego udostępnienia." - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "A aplicação reinicia a ligação de rede do VniDrop. Pare primeiro as transferências e partilhas ativas. Poderá ser necessário voltar a partilhar os convites existentes." - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "При применении сетевое соединение VniDrop перезапускается. Сначала остановите активные передачи и раздачи. Возможно, существующие приглашения потребуется отправить повторно." - } - } - } - }, - "relay_applying": { - "comment": "Network settings button label while a relay configuration is being activated.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Wird angewendet…" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Applying…" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Aplicando…" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Application…" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Applicazione…" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Toepassen…" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Stosowanie…" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "A aplicar…" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Применение…" - } - } - } - }, - "relay_custom_urls_help": { - "comment": "Network settings help for entering custom relay server URLs.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Geben Sie pro Zeile eine HTTPS-Relay-URL ein. Anmeldedaten in URLs werden nicht unterstützt. Das TLS-Zertifikat muss von einer öffentlich vertrauenswürdigen Zertifizierungsstelle ausgestellt sein." - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Enter one HTTPS relay URL per line. URL credentials are not supported. The TLS certificate must be issued by a publicly trusted certificate authority." - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Introduzca una URL HTTPS de relé por línea. No se admiten credenciales en las URL. El certificado TLS debe ser emitido por una autoridad de certificación de confianza pública." - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Saisissez une URL de relais HTTPS par ligne. Les identifiants dans les URL ne sont pas pris en charge. Le certificat TLS doit être émis par une autorité de certification reconnue publiquement." - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Inserisca un URL relay HTTPS per riga. Le credenziali negli URL non sono supportate. Il certificato TLS deve essere emesso da un’autorità di certificazione pubblicamente attendibile." - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Voer per regel één HTTPS-relay-URL in. Aanmeldgegevens in URL's worden niet ondersteund. Het TLS-certificaat moet zijn uitgegeven door een openbaar vertrouwde certificeringsinstantie." - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Wprowadź po jednym adresie URL HTTPS przekaźnika w każdym wierszu. Dane logowania w adresach URL nie są obsługiwane. Certyfikat TLS musi być wystawiony przez publicznie zaufany urząd certyfikacji." - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Introduza um URL HTTPS de retransmissor por linha. Não são suportadas credenciais nos URLs. O certificado TLS tem de ser emitido por uma autoridade de certificação publicamente reconhecida." - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Введите по одному HTTPS-адресу ретранслятора в строке. Учётные данные в URL-адресах не поддерживаются. Сертификат TLS должен быть выдан общедоступным доверенным центром сертификации." - } - } - } - }, - "relay_custom_urls_label": { - "comment": "Network settings label for the custom relay URL input.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Relay-URLs" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Relay URLs" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "URL de relés" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "URL des relais" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "URL relay" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Relay-URL's" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Adresy URL przekaźników" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "URLs dos retransmissores" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "URL-адреса ретрансляторов" - } - } - } - }, - "relay_mode_automatic": { - "comment": "Network settings label for VniDrop's automatic public relay mode.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Automatisch (empfohlen)" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Automatic (recommended)" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Automático (recomendado)" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Automatique (recommandé)" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Automatica (consigliata)" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Automatisch (aanbevolen)" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Automatyczny (zalecany)" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Automático (recomendado)" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Автоматически (рекомендуется)" - } - } - } - }, - "relay_mode_automatic_description": { - "comment": "Network settings description of automatic public relay behavior.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Verwendet die öffentliche Standard-Relay-Infrastruktur von VniDrop, wenn keine direkte Verbindung möglich ist." - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Use VniDrop’s default public relay infrastructure when a direct connection is unavailable." - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Usa la infraestructura pública de relés predeterminada de VniDrop cuando no haya una conexión directa disponible." - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Utiliser l’infrastructure de relais publique par défaut de VniDrop lorsqu’une connexion directe est indisponible." - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Usa l’infrastruttura relay pubblica predefinita di VniDrop quando non è disponibile una connessione diretta." - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Gebruikt de standaard openbare relay-infrastructuur van VniDrop wanneer geen directe verbinding beschikbaar is." - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Używa domyślnej publicznej infrastruktury przekaźników VniDrop, gdy połączenie bezpośrednie jest niedostępne." - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Utiliza a infraestrutura pública de retransmissores predefinida do VniDrop quando não está disponível uma ligação direta." - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Использовать стандартную публичную инфраструктуру ретрансляторов VniDrop, если прямое соединение недоступно." - } - } - } - }, - "relay_mode_custom": { - "comment": "Network settings label for strict custom relay mode.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Strikt benutzerdefiniert" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Strict custom" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Personalizado estricto" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Personnalisé strict" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Personalizzata rigorosa" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Strikt aangepast" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Ścisły niestandardowy" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Personalizado estrito" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Строго пользовательский" - } - } - } - }, - "relay_mode_custom_description": { - "comment": "Network settings description of strict custom relay behavior.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Verwendet nur die konfigurierten eigenen Relays oder Direktverbindungen. Meldet einen Fehler, wenn kein eigenes Relay erreichbar ist." - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Use only the configured custom relays or direct connections. Report an error if no custom relay can be established." - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Usa solo los relés personalizados configurados o conexiones directas. Informa de un error si no se puede establecer ningún relé personalizado." - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Utilise uniquement les relais personnalisés configurés ou les connexions directes. Signale une erreur si aucun relais personnalisé ne peut être établi." - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Usa solo i relay personalizzati configurati o connessioni dirette. Segnala un errore se non è possibile stabilire alcun relay personalizzato." - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Gebruikt alleen de ingestelde aangepaste relays of rechtstreekse verbindingen. Meldt een fout als geen aangepaste relay bereikbaar is." - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Używa tylko skonfigurowanych własnych przekaźników lub połączeń bezpośrednich. Zgłasza błąd, jeśli nie można połączyć się z żadnym własnym przekaźnikiem." - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Utiliza apenas os retransmissores personalizados configurados ou ligações diretas. Apresenta um erro se não for possível estabelecer nenhum retransmissor personalizado." - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Использует только настроенные пользовательские ретрансляторы или прямые соединения. Сообщает об ошибке, если ни один пользовательский ретранслятор недоступен." - } - } - } - }, - "relay_mode_custom_direct_fallback": { - "comment": "Network settings label for custom relays that allow direct-only startup fallback.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Benutzerdefiniert mit direktem Rückfall" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Custom with direct fallback" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Personalizado con conexión directa de reserva" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Personnalisé avec repli direct" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Personalizzata con ripiego diretto" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Aangepast met directe terugval" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Niestandardowy z trybem bezpośrednim" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Personalizado com alternativa direta" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Пользовательский с прямым резервом" - } - } - } - }, - "relay_mode_custom_direct_fallback_description": { - "comment": "Network settings description of custom relays with direct-only startup fallback.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Bevorzugt die konfigurierten eigenen Relays. Sind sie nicht verfügbar, werden nur Direktverbindungen verwendet. Öffentliche Relays werden nie genutzt." - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Prefer the configured custom relays. If unavailable, continue with direct connections only. Never use public relays." - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Prefiere los relés personalizados configurados. Si no están disponibles, continúa solo con conexiones directas. Nunca usa relés públicos." - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Préfère les relais personnalisés configurés. S’ils sont indisponibles, continue uniquement avec des connexions directes. N’utilise jamais les relais publics." - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Preferisce i relay personalizzati configurati. Se non sono disponibili, continua solo con connessioni dirette. Non usa mai relay pubblici." - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Geeft de voorkeur aan de ingestelde aangepaste relays. Als die niet beschikbaar zijn, worden alleen rechtstreekse verbindingen gebruikt. Openbare relays worden nooit gebruikt." - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Preferuje skonfigurowane własne przekaźniki. Jeśli są niedostępne, kontynuuje tylko przez połączenia bezpośrednie. Nigdy nie używa publicznych przekaźników." - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Prefere os retransmissores personalizados configurados. Se não estiverem disponíveis, continua apenas com ligações diretas. Nunca utiliza retransmissores públicos." - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Предпочитает настроенные пользовательские ретрансляторы. Если они недоступны, продолжает работу только через прямые соединения. Публичные ретрансляторы не используются." - } - } - } - }, - "relay_mode_local_only": { - "comment": "Network settings label for direct connections without any relay.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Nur lokales Netzwerk" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Local only" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Solo red local" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Réseau local uniquement" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Solo rete locale" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Alleen lokaal netwerk" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Tylko sieć lokalna" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Apenas rede local" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Только локальная сеть" - } - } - } - }, - "relay_mode_local_only_description": { - "comment": "Network settings description of direct-only local-network mode.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Deaktiviert alle Relays und erlaubt nur Direktverbindungen, hauptsächlich für Geräte im selben Netzwerk." - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Disable all relays and allow only direct connections, primarily for devices on the same network." - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Desactiva todos los relés y permite solo conexiones directas, principalmente para dispositivos de la misma red." - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Désactive tous les relais et autorise uniquement les connexions directes, principalement pour les appareils sur le même réseau." - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Disattiva tutti i relay e consente solo connessioni dirette, soprattutto per dispositivi sulla stessa rete." - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Schakelt alle relays uit en staat alleen rechtstreekse verbindingen toe, vooral voor apparaten in hetzelfde netwerk." - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Wyłącza wszystkie przekaźniki i zezwala tylko na połączenia bezpośrednie, głównie dla urządzeń w tej samej sieci." - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Desativa todos os retransmissores e permite apenas ligações diretas, principalmente para dispositivos na mesma rede." - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Отключает все ретрансляторы и разрешает только прямые соединения, прежде всего для устройств в одной сети." - } - } - } - }, - "relay_privacy_description": { - "comment": "Network settings privacy note about what relay operators can observe.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Relays leiten verschlüsselten Datenverkehr weiter und können Ihre Dateien nicht lesen, ihr Betreiber kann jedoch Verbindungsmetadaten sehen." - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Relays forward encrypted traffic and cannot read your files, but their operator can observe connection metadata." - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Los relés reenvían tráfico cifrado y no pueden leer sus archivos, pero su operador puede observar los metadatos de conexión." - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Les relais transmettent du trafic chiffré et ne peuvent pas lire vos fichiers, mais leur opérateur peut observer les métadonnées de connexion." - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "I relay inoltrano traffico cifrato e non possono leggere i suoi file, ma il loro operatore può osservare i metadati di connessione." - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Relays sturen versleuteld verkeer door en kunnen uw bestanden niet lezen, maar de beheerder kan verbindingsmetadata bekijken." - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Przekaźniki przesyłają zaszyfrowany ruch i nie mogą odczytać plików, ale ich operator może obserwować metadane połączenia." - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Os retransmissores encaminham tráfego cifrado e não conseguem ler os seus ficheiros, mas o operador pode observar metadados da ligação." - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Ретрансляторы передают зашифрованный трафик и не могут читать ваши файлы, но их оператор может видеть метаданные соединения." - } - } - } - }, - "relay_remove_url": { - "comment": "Apple Network settings accessibility label for removing one custom relay URL field.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Relay-Server entfernen" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Remove relay server" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Eliminar servidor de retransmisión" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Supprimer le serveur relais" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Rimuovi server relay" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Relayserver verwijderen" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Usuń serwer przekaźnikowy" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Remover servidor de retransmissão" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Удалить сервер-ретранслятор" - } - } - } - }, - "relay_restore_failed": { - "comment": "Network settings severe error when neither new nor previous relay settings can initialize.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Die vorherigen Netzwerkeinstellungen konnten nicht wiederhergestellt werden. Starten Sie VniDrop neu und prüfen Sie Ihre Relay-Konfiguration." - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Could not restore the previous network settings. Restart VniDrop and review your relay configuration." - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "No se han podido restaurar los ajustes de red anteriores. Reinicie VniDrop y revise la configuración de relés." - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Impossible de restaurer les réglages réseau précédents. Redémarrez VniDrop et vérifiez votre configuration de relais." - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Impossibile ripristinare le impostazioni di rete precedenti. Riavvii VniDrop e verifichi la configurazione dei relay." - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "De vorige netwerkinstellingen konden niet worden hersteld. Start VniDrop opnieuw en controleer uw relayconfiguratie." - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Nie udało się przywrócić poprzednich ustawień sieci. Uruchom ponownie VniDrop i sprawdź konfigurację przekaźników." - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Não foi possível restaurar as definições de rede anteriores. Reinicie o VniDrop e reveja a configuração dos retransmissores." - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Не удалось восстановить предыдущие настройки сети. Перезапустите VniDrop и проверьте конфигурацию ретрансляторов." - } - } - } - }, - "relay_settings_applied": { - "comment": "Network settings confirmation after a relay configuration is activated.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Netzwerkeinstellungen angewendet." - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Network settings applied." - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Ajustes de red aplicados." - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Réglages réseau appliqués." - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Impostazioni di rete applicate." - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Netwerkinstellingen toegepast." - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Zastosowano ustawienia sieci." - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Definições de rede aplicadas." - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Настройки сети применены." - } - } - } - }, - "relay_strict_warning": { - "comment": "Network settings warning that custom relay mode has no public fallback or discovery.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Der strikt benutzerdefinierte Modus startet nur, wenn mindestens ein konfiguriertes Relay erreichbar ist. VniDrop verwendet in diesem Modus nie öffentliche Relays oder öffentliche Erkennung." - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Strict custom mode will not start unless at least one configured relay is reachable. VniDrop never uses public relays or public discovery in this mode." - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "El modo personalizado estricto no se inicia a menos que se pueda acceder al menos a un relé configurado. VniDrop nunca usa relés ni descubrimiento públicos en este modo." - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Le mode personnalisé strict ne démarre que si au moins un relais configuré est accessible. VniDrop n’utilise jamais de relais ni de découverte publics dans ce mode." - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "La modalità personalizzata rigorosa si avvia solo se almeno un relay configurato è raggiungibile. In questa modalità VniDrop non usa mai relay o rilevamento pubblici." - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "De strikt aangepaste modus start alleen als minstens één ingestelde relay bereikbaar is. VniDrop gebruikt in deze modus nooit openbare relays of openbare detectie." - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Ścisły tryb niestandardowy uruchamia się tylko wtedy, gdy co najmniej jeden skonfigurowany przekaźnik jest dostępny. VniDrop nigdy nie używa w tym trybie publicznych przekaźników ani publicznego wykrywania." - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "O modo personalizado estrito só inicia se pelo menos um retransmissor configurado estiver acessível. Neste modo, o VniDrop nunca utiliza retransmissores nem descoberta públicos." - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Строго пользовательский режим запускается, только если доступен хотя бы один настроенный ретранслятор. В этом режиме VniDrop никогда не использует публичные ретрансляторы или публичное обнаружение." - } - } - } - }, - "relay_validation_duplicate_url": { - "comment": "Network settings validation error for a repeated custom relay URL.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Die Relay-URL in Zeile %1$d ist bereits zuvor eingetragen." - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Relay URL on line %1$d duplicates an earlier entry." - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "La URL de relé de la línea %1$d duplica una entrada anterior." - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "L’URL de relais à la ligne %1$d est identique à une entrée précédente." - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "L’URL relay alla riga %1$d duplica una voce precedente." - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "De relay-URL op regel %1$d is gelijk aan een eerdere invoer." - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Adres URL przekaźnika w wierszu %1$d powtarza wcześniejszy wpis." - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "O URL do retransmissor na linha %1$d duplica uma entrada anterior." - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "URL-адрес ретранслятора в строке %1$d повторяет предыдущую запись." - } - } - } - }, - "relay_validation_https_required": { - "comment": "Network settings validation error when a custom relay URL is not HTTPS.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Die Relay-URL in Zeile %1$d muss mit https:// beginnen." - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Relay URL on line %1$d must start with https://." - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "La URL de relé de la línea %1$d debe empezar por https://." - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "L’URL de relais à la ligne %1$d doit commencer par https://." - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "L’URL relay alla riga %1$d deve iniziare con https://." - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "De relay-URL op regel %1$d moet beginnen met https://." - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Adres URL przekaźnika w wierszu %1$d musi zaczynać się od https://." - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "O URL do retransmissor na linha %1$d tem de começar por https://." - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "URL-адрес ретранслятора в строке %1$d должен начинаться с https://." - } - } - } - }, - "relay_validation_invalid_url": { - "comment": "Network settings validation error for a malformed custom relay URL.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Die Relay-URL in Zeile %1$d ist ungültig." - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Relay URL on line %1$d is not valid." - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "La URL de relé de la línea %1$d no es válida." - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "L’URL de relais à la ligne %1$d n’est pas valide." - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "L’URL relay alla riga %1$d non è valido." - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "De relay-URL op regel %1$d is ongeldig." - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Adres URL przekaźnika w wierszu %1$d jest nieprawidłowy." - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "O URL do retransmissor na linha %1$d não é válido." - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "URL-адрес ретранслятора в строке %1$d недействителен." - } - } - } - }, - "relay_validation_missing_url": { - "comment": "Network settings validation error when custom mode has no relay URL.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Fügen Sie mindestens eine Relay-URL hinzu." - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Add at least one relay URL." - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Añada al menos una URL de relé." - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Ajoutez au moins une URL de relais." - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Aggiunga almeno un URL relay." - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Voeg ten minste één relay-URL toe." - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Dodaj co najmniej jeden adres URL przekaźnika." - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Adicione pelo menos um URL de retransmissor." - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Добавьте хотя бы один URL-адрес ретранслятора." - } - } - } - }, - "relay_validation_too_many_urls": { - "comment": "Network settings validation error when too many custom relay URLs are entered.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Sie können bis zu %1$d Relay-Server konfigurieren." - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "You can configure up to %1$d relay servers." - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Puede configurar hasta %1$d servidores de retransmisión." - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Vous pouvez configurer jusqu’à %1$d serveurs relais." - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Può configurare fino a %1$d server relay." - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "U kunt maximaal %1$d relayservers instellen." - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Możesz skonfigurować maksymalnie %1$d serwerów przekaźnikowych." - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Pode configurar até %1$d servidores de retransmissão." - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Можно настроить до %1$d серверов-ретрансляторов." - } - } - } - }, - "send_access_anyone": { - "comment": "Send access option: anyone with the invitation can receive.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Jeder mit dieser Übertragung" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Anyone with this transfer" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Cualquiera que tenga esta transferencia" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Toute personne disposant de ce transfert" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Chiunque abbia questo trasferimento" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Iedereen met deze overdracht" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Każdy, kto ma ten transfer" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Qualquer pessoa com esta transferência" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Любой, у кого есть эта передача" - } - } - } - }, - "send_access_anyone_description": { - "comment": "Send access option description: no approval required.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Keine Genehmigung erforderlich. Verwenden Sie dies nur für Objekte, die Sie unbedenklich teilen können." - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "No approval is required. Only use this for items you are comfortable sharing." - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "No se requiere aprobación. Úselo solo para elementos que no le importe compartir." - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Aucune approbation requise. À n’utiliser que pour des éléments que vous êtes à l’aise de partager." - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Nessuna approvazione richiesta. Da usare solo per elementi che non ha problemi a condividere." - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Geen goedkeuring vereist. Gebruik dit alleen voor items die u gerust kunt delen." - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Nie jest wymagane zatwierdzenie. Używaj tylko dla elementów, które możesz swobodnie udostępniać." - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Não é necessária aprovação. Utilize apenas para itens que não se importe de partilhar." - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Одобрение не требуется. Используйте только для объектов, которыми вы готовы поделиться." - } - } - } - }, - "send_access_anyone_warning": { - "comment": "Send access option warning: caution about open access.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Jeder mit der Einladung kann herunterladen, bis Sie die Freigabe beenden. Verwenden Sie dies nicht für private oder sensible Objekte." - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Anyone with the invitation can download until you stop sharing. Do not use this for private or sensitive items." - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Cualquiera que tenga la invitación puede descargar hasta que deje de compartir. No lo use para elementos privados o sensibles." - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Toute personne disposant de l’invitation peut télécharger jusqu’à ce que vous arrêtiez le partage. Ne l’utilisez pas pour des éléments privés ou sensibles." - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Chiunque abbia l’invito può scaricare finché non interrompe la condivisione. Non lo usi per elementi privati o sensibili." - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Iedereen met de uitnodiging kan downloaden totdat u stopt met delen. Gebruik dit niet voor privé- of gevoelige items." - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Każdy, kto ma zaproszenie, może pobierać, dopóki nie zatrzymasz udostępniania. Nie używaj tego dla prywatnych ani wrażliwych elementów." - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Qualquer pessoa com o convite pode descarregar até parar de partilhar. Não utilize para itens privados ou sensíveis." - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Любой, у кого есть приглашение, может загружать, пока вы не остановите общий доступ. Не используйте это для личных или конфиденциальных объектов." - } - } - } - }, - "send_access_approval": { - "comment": "Send access option: approve each receiver.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Vor jedem Download fragen" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Ask before each download" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Preguntar antes de cada descarga" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Demander avant chaque téléchargement" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Chiedi prima di ogni download" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Vragen vóór elke download" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Pytaj przed każdym pobraniem" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Perguntar antes de cada descarga" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Спрашивать перед каждой загрузкой" - } - } - } - }, - "send_access_approval_description": { - "comment": "Send access option description: you approve/refuse each receiver.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Sie genehmigen oder lehnen jeden neuen Empfänger ab." - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "You approve or refuse every new receiver." - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Usted aprueba o rechaza a cada nuevo destinatario." - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Vous approuvez ou refusez chaque nouveau destinataire." - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Approva o rifiuta ogni nuovo destinatario." - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "U keurt elke nieuwe ontvanger goed of weigert deze." - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Zatwierdzasz lub odrzucasz każdego nowego odbiorcę." - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Aprova ou recusa cada novo destinatário." - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Вы одобряете или отклоняете каждого нового получателя." - } - } - } - }, - "send_access_title": { - "comment": "Send flow: heading for the who-can-receive access chooser.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Wer kann sie empfangen?" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Who can receive it?" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "¿Quién puede recibirla?" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Qui peut le recevoir ?" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Chi può riceverlo?" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Wie kan het ontvangen?" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Kto może to odebrać?" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Quem a pode receber?" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Кто может это получить?" - } - } - } - }, - "send_choose_file_body": { - "comment": "Send flow: body text for the choose-what-to-share step.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Wählen Sie Dateien oder einen Ordner auf diesem Gerät aus. Sie können die Auswahl überprüfen, bevor Sie die Übertragung erstellen." - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Select files or a folder from this device. You can review the selection before creating the transfer." - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Seleccione archivos o una carpeta de este dispositivo. Podrá revisar la selección antes de crear la transferencia." - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Sélectionnez des fichiers ou un dossier sur cet appareil. Vous pourrez vérifier la sélection avant de créer le transfert." - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Selezioni file o una cartella da questo dispositivo. Potrà rivedere la selezione prima di creare il trasferimento." - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Selecteer bestanden of een map op dit apparaat. U kunt de selectie controleren voordat u de overdracht aanmaakt." - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Wybierz pliki lub folder z tego urządzenia. Przed utworzeniem transferu możesz przejrzeć wybór." - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Selecione ficheiros ou uma pasta deste dispositivo. Poderá rever a seleção antes de criar a transferência." - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Выберите файлы или папку на этом устройстве. Вы сможете проверить выбор перед созданием передачи." - } - } - } - }, - "send_choose_file_title": { - "comment": "Send flow: title of the choose-what-to-share step.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Wählen Sie, was Sie teilen möchten" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Choose what to share" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Elija qué compartir" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Choisissez quoi partager" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Scelga cosa condividere" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Kies wat u wilt delen" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Wybierz, co udostępnić" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Escolha o que partilhar" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Выберите, чем поделиться" - } - } - } - }, - "send_empty_body": { - "comment": "Send tab empty state: instructions on how to create a transfer.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Erstellen Sie eine Übertragung, entscheiden Sie, wer sie empfangen darf, und laden Sie diese Personen dann mit einem QR-Code, einem NFC-Tag oder einer Einladungsdatei ein." - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Create a transfer, decide who can receive it, then invite them with a QR code, NFC tag, or invitation file." - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Cree una transferencia, decida quién puede recibirla y luego invite a esas personas con un código QR, una etiqueta NFC o un archivo de invitación." - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Créez un transfert, décidez qui peut le recevoir, puis invitez-les avec un QR code, un tag NFC ou un fichier d’invitation." - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Crei un trasferimento, decida chi può riceverlo, poi inviti queste persone con un codice QR, un tag NFC o un file di invito." - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Maak een overdracht aan, bepaal wie deze kan ontvangen en nodig die personen vervolgens uit met een QR-code, NFC-tag of uitnodigingsbestand." - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Utwórz transfer, zdecyduj, kto może go odebrać, a następnie zaproś te osoby za pomocą kodu QR, tagu NFC lub pliku zaproszenia." - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Crie uma transferência, decida quem a pode receber e depois convide essas pessoas com um código QR, uma etiqueta NFC ou um ficheiro de convite." - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Создайте передачу, решите, кто может её получить, затем пригласите этих людей с помощью QR-кода, NFC-метки или файла приглашения." - } - } - } - }, - "send_empty_title": { - "comment": "Send tab empty state: title when nothing has been shared.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Noch nichts geteilt" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Nothing shared yet" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Aún no se ha compartido nada" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Rien partagé pour l’instant" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Ancora nulla di condiviso" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Nog niets gedeeld" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Nic jeszcze nie udostępniono" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Ainda não partilhou nada" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Пока ничем не поделились" - } - } - } - }, - "send_file_size_unknown": { - "comment": "Send flow: shown when a selected file's size can't be determined.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Größe nicht verfügbar" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Size unavailable" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Tamaño no disponible" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Taille indisponible" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Dimensione non disponibile" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Grootte niet beschikbaar" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Rozmiar niedostępny" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Tamanho indisponível" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Размер недоступен" - } - } - } - }, - "send_folder_label": { - "comment": "Send flow: label indicating a selected item is a folder.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Ordner" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Folder" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Carpeta" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Dossier" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Cartella" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Map" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Folder" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Pasta" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Папка" - } - } - } - }, - "send_new_transfer_description": { - "comment": "Send flow: description for the create-new-transfer entry.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Eine neue Übertragung erstellen" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Create a new transfer" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Crear una nueva transferencia" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Créer un nouveau transfert" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Crea un nuovo trasferimento" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Een nieuwe overdracht aanmaken" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Utwórz nowy transfer" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Criar uma nova transferência" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Создать новую передачу" - } - } - } - }, - "send_new_transfer_title": { - "comment": "Send flow: title for the new-transfer step.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Neue Übertragung" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "New transfer" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Nueva transferencia" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Nouveau transfert" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Nuovo trasferimento" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Nieuwe overdracht" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Nowy transfer" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Nova transferência" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Новая передача" - } - } - } - }, - "send_review_title": { - "comment": "Send flow: title of the review-transfer step before creating it.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Übertragung prüfen" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Review transfer" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Revisar transferencia" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Vérifier le transfert" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Rivedi trasferimento" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Overdracht controleren" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Przejrzyj transfer" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Rever transferência" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Проверить передачу" - } - } - } - }, - "send_selected_files_count": { - "comment": "Send flow: count of files chosen. {count} = selected files. NOTE: singular case reads '1 files' — should become a plural.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "%1$d Dateien ausgewählt" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "%1$d files selected" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "%1$d archivos seleccionados" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "%1$d fichiers sélectionnés" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "%1$d file selezionati" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "%1$d bestanden geselecteerd" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Wybrane pliki: %1$d" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "%1$d ficheiros selecionados" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Выбрано файлов: %1$d" - } - } - } - }, - "send_stop_sharing": { - "comment": "Transfer details: action to stop sharing a transfer.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Freigabe beenden" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Stop sharing" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Dejar de compartir" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Arrêter le partage" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Interrompi condivisione" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Stoppen met delen" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Zatrzymaj udostępnianie" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Parar de partilhar" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Остановить общий доступ" - } - } - } - }, - "send_stop_sharing_description": { - "comment": "Transfer details: confirmation body for stopping sharing.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Dies beendet die Übertragung und unterbricht alle, die sie gerade herunterladen. Sie verbleibt in Ihrem Verlauf als „Beendet“." - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "This stops the transfer and interrupts anyone currently downloading it. It stays in your history as Stopped." - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Esto detiene la transferencia e interrumpe a quien esté descargándola. Permanece en su historial como Detenida." - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Cela arrête le transfert et interrompt toute personne en train de le télécharger. Il reste dans votre historique en tant qu’Arrêté." - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Questo interrompe il trasferimento e blocca chiunque lo stia scaricando. Rimane nella sua cronologia come Interrotto." - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Hiermee stopt de overdracht en wordt iedereen die deze op dit moment downloadt onderbroken. De overdracht blijft in uw geschiedenis staan als Gestopt." - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "To zatrzymuje transfer i przerywa każdego, kto go właśnie pobiera. Pozostaje w historii jako Zatrzymany." - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Isto para a transferência e interrompe quem estiver a descarregá-la. Permanece no seu histórico como Parada." - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Это остановит передачу и прервёт всех, кто сейчас её загружает. Она останется в вашей истории со статусом «Остановлена»." - } - } - } - }, - "send_subtitle": { - "comment": "Send tab: subtitle under the title.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Übertragungen, die Sie von diesem Gerät aus teilen." - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Transfers you’re sharing from this device." - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Transferencias que está compartiendo desde este dispositivo." - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Transferts que vous partagez depuis cet appareil." - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Trasferimenti che sta condividendo da questo dispositivo." - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Overdrachten die u vanaf dit apparaat deelt." - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Transfery udostępniane z tego urządzenia." - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Transferências que está a partilhar a partir deste dispositivo." - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Передачи, которыми вы делитесь с этого устройства." - } - } - } - }, - "send_title": { - "comment": "Send tab: screen title.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Senden" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Send" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Enviar" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Envoyer" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Invia" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Versturen" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Wyślij" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Enviar" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Отправить" - } - } - } - }, - "send_transfer_created": { - "comment": "Send flow: toast confirming a transfer was created.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Übertragung erstellt." - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Transfer created." - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Transferencia creada." - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Transfert créé." - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Trasferimento creato." - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Overdracht aangemaakt." - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Transfer utworzony." - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Transferência criada." - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Передача создана." - } - } - } - }, - "send_transfer_details_title": { - "comment": "Send flow: title of the transfer details screen.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Übertragungsdetails" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Transfer details" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Detalles de la transferencia" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Détails du transfert" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Dettagli del trasferimento" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Overdrachtsdetails" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Szczegóły transferu" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Detalhes da transferência" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Сведения о передаче" - } - } - } - }, - "send_transfers_title": { - "comment": "Send tab: 'Your transfers' list heading.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Ihre Übertragungen" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Your transfers" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Sus transferencias" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Vos transferts" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "I suoi trasferimenti" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Uw overdrachten" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Twoje transfery" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "As suas transferências" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Ваши передачи" - } - } - } - }, - "settings_advanced_title": { - "comment": "Settings overview section header for expert configuration.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Erweitert" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Advanced" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Avanzado" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Avancé" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Avanzate" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Geavanceerd" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Zaawansowane" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Avançado" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Дополнительно" - } - } - } - }, - "settings_network_title": { - "comment": "Settings overview row and Network settings screen title.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Netzwerk" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Network" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Red" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Réseau" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Rete" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Netwerk" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Sieć" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Rede" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Сеть" - } - } - } - }, - "settings_subtitle": { - "comment": "Settings screen: subtitle summarizing what's configurable.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Ihr Name, wo Übertragungen gesichert werden, Darstellung und Mitteilungen." - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Your name, where transfers are saved, appearance, and notifications." - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Su nombre, dónde se guardan las transferencias, la apariencia y las notificaciones." - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Votre nom, l’emplacement d’enregistrement des transferts, l’apparence et les notifications." - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Il suo nome, dove vengono salvati i trasferimenti, l’aspetto e le notifiche." - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Uw naam, waar overdrachten worden bewaard, weergave en meldingen." - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Twoja nazwa, miejsce zapisu transferów, wygląd i powiadomienia." - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "O seu nome, onde as transferências são guardadas, o aspeto e as notificações." - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Ваше имя, место сохранения передач, оформление и уведомления." - } - } - } - }, - "settings_title": { - "comment": "Settings screen: title.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Einstellungen" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Settings" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Ajustes" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Réglages" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Impostazioni" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Instellingen" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Ustawienia" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Definições" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Настройки" - } - } - } - }, - "snackbar_dismiss": { - "comment": "Snackbar: accessibility label / action to dismiss the snackbar.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Ausblenden" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Dismiss" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Descartar" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Ignorer" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Ignora" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Sluiten" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Zamknij" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Ignorar" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Закрыть" - } - } - } - }, - "status_available": { - "comment": "Transfer status: available (actively shared).", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Verfügbar" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Available" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Disponible" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Disponible" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Disponibile" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Beschikbaar" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Dostępny" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Disponível" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Доступно" - } - } - } - }, - "status_cancelled": { - "comment": "Transfer status: cancelled.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Abgebrochen" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Cancelled" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Cancelado" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Annulé" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Annullato" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Geannuleerd" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Anulowany" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Cancelado" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Отменено" - } - } - } - }, - "status_completed": { - "comment": "Transfer status: completed.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Abgeschlossen" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Completed" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Completado" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Terminé" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Completato" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Voltooid" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Ukończony" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Concluído" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Завершено" - } - } - } - }, - "status_failed": { - "comment": "Transfer status: failed.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Fehlgeschlagen" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Failed" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Fallido" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Échec" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Non riuscito" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Mislukt" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Niepowodzenie" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Falhou" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Ошибка" - } - } - } - }, - "status_preparing": { - "comment": "Transfer status: preparing.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Wird vorbereitet" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Preparing" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Preparando" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Préparation" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Preparazione" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Voorbereiden" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Przygotowywanie" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "A preparar" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Подготовка" - } - } - } - }, - "status_receiving": { - "comment": "Transfer status: receiving.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Wird empfangen" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Receiving" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Recibiendo" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Réception" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Ricezione" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Ontvangen" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Odbieranie" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "A receber" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Получение" - } - } - } - }, - "status_stopped": { - "comment": "Transfer status: stopped.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Beendet" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Stopped" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Detenido" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Arrêté" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Interrotto" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Gestopt" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Zatrzymany" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Parada" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Остановлено" - } - } - } - }, - "storage_app_data": { - "comment": "Settings > Storage: label for non-transfer application data.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "App-Daten" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "App data" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Datos de la aplicación" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Données de l’app" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Dati dell’app" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Appgegevens" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Dane aplikacji" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Dados da aplicação" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Данные приложения" - } - } - } - }, - "storage_calculating": { - "comment": "Settings > Storage: placeholder while a size is being calculated.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Wird berechnet…" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Calculating…" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Calculando…" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Calcul…" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Calcolo…" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Berekenen…" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Obliczanie…" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "A calcular…" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Вычисление…" - } - } - } - }, - "storage_delete_transfers": { - "comment": "Settings > Storage: button to delete all transfer records.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Alle Übertragungen löschen" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Delete all transfers" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Eliminar todas las transferencias" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Supprimer tous les transferts" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Elimina tutti i trasferimenti" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Alle overdrachten verwijderen" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Usuń wszystkie transfery" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Eliminar todas as transferências" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Удалить все передачи" - } - } - } - }, - "storage_delete_transfers_description": { - "comment": "Settings > Storage: confirmation body for deleting all transfer records.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Dadurch werden alle Datensätze gesendeter und empfangener Übertragungen aus Ihrem Verlauf gelöscht. Ihre empfangenen Dateien werden nicht gelöscht. Nicht mehr benötigte zwischengespeicherte freigegebene Inhalte werden automatisch bereinigt; dies kann etwas dauern. Dies kann nicht rückgängig gemacht werden." - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "This clears all sent and received transfer records from your history. Your received files are not deleted. Cached shared content that is no longer needed is reclaimed automatically, which may take a little time. This can’t be undone." - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Esto borra de su historial todos los registros de transferencias enviadas y recibidas. Sus archivos recibidos no se eliminan. El contenido compartido en caché que ya no se necesita se recupera automáticamente, lo que puede tardar un poco. Esto no se puede deshacer." - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Cela efface de votre historique tous les enregistrements de transferts envoyés et reçus. Vos fichiers reçus ne sont pas supprimés. Le contenu partagé mis en cache qui n’est plus nécessaire est récupéré automatiquement, ce qui peut prendre un peu de temps. Cette action est irréversible." - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Questo cancella dalla cronologia tutti i record dei trasferimenti inviati e ricevuti. I file ricevuti non vengono eliminati. Il contenuto condiviso nella cache che non serve più viene recuperato automaticamente, operazione che può richiedere un po’ di tempo. Questa azione non può essere annullata." - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Hiermee worden alle records van verzonden en ontvangen overdrachten uit uw geschiedenis gewist. Uw ontvangen bestanden worden niet verwijderd. Gedeelde inhoud in de cache die niet meer nodig is, wordt automatisch opgeruimd; dit kan enige tijd duren. Dit kan niet ongedaan worden gemaakt." - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Spowoduje to usunięcie z historii wszystkich rekordów wysłanych i odebranych transferów. Odebrane pliki nie zostaną usunięte. Niepotrzebna już zawartość udostępniona w pamięci podręcznej jest odzyskiwana automatycznie, co może chwilę potrwać. Tej operacji nie można cofnąć." - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Isto elimina do histórico todos os registos de transferências enviadas e recebidas. Os ficheiros recebidos não são eliminados. O conteúdo partilhado em cache que já não é necessário é recuperado automaticamente, o que pode demorar algum tempo. Esta ação não pode ser anulada." - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Это удалит из истории все записи об отправленных и полученных передачах. Полученные файлы не удаляются. Кэшированное общее содержимое, которое больше не требуется, освобождается автоматически; это может занять некоторое время. Это действие нельзя отменить." - } - } - } - }, - "storage_deleting": { - "comment": "Settings > Storage: progress label while transfers are being deleted.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Wird gelöscht…" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Deleting…" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Eliminando…" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Suppression…" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Eliminazione…" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Verwijderen…" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Usuwanie…" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "A eliminar…" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Удаление…" - } - } - } - }, - "storage_footer": { - "comment": "Settings > Storage: footer explaining how storage is managed.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Übertragungsdaten umfassen Ihren Verlauf und zwischengespeicherte Inhalte aktiver Freigaben. Nicht mehr benötigter Cache wird nach dem Löschen der Datensätze automatisch bereinigt; dies kann etwas dauern. Empfangene Dateien werden für diese Übersicht erfasst, aber hier niemals gelöscht." - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Transfer data includes your history and cached content for active shares. Unneeded cache is reclaimed automatically after transfer records are removed, which may take a little time. Received files are tracked for this summary but are never deleted here." - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Los datos de transferencia incluyen su historial y el contenido en caché de los recursos compartidos activos. La caché innecesaria se recupera automáticamente tras eliminar los registros, lo que puede tardar un poco. Los archivos recibidos se registran para este resumen, pero nunca se eliminan aquí." - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Les données de transfert comprennent votre historique et le contenu mis en cache pour les partages actifs. Le cache inutile est récupéré automatiquement après la suppression des enregistrements, ce qui peut prendre un peu de temps. Les fichiers reçus sont suivis pour ce récapitulatif, mais ne sont jamais supprimés ici." - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "I dati di trasferimento includono la cronologia e il contenuto nella cache per le condivisioni attive. La cache non necessaria viene recuperata automaticamente dopo la rimozione dei record, operazione che può richiedere un po’ di tempo. I file ricevuti vengono monitorati per questo riepilogo, ma non sono mai eliminati qui." - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Overdrachtsgegevens omvatten uw geschiedenis en inhoud in de cache voor actieve shares. Onnodige cache wordt automatisch opgeruimd nadat overdrachtsrecords zijn verwijderd; dit kan enige tijd duren. Ontvangen bestanden worden voor dit overzicht bijgehouden, maar hier nooit verwijderd." - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Dane transferu obejmują historię oraz zawartość w pamięci podręcznej dla aktywnych udostępnień. Niepotrzebna pamięć podręczna jest odzyskiwana automatycznie po usunięciu rekordów, co może chwilę potrwać. Odebrane pliki są śledzone na potrzeby tego podsumowania, ale nigdy nie są tu usuwane." - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Os dados de transferência incluem o histórico e o conteúdo em cache das partilhas ativas. A cache desnecessária é recuperada automaticamente após a remoção dos registos, o que pode demorar algum tempo. Os ficheiros recebidos são acompanhados para este resumo, mas nunca são eliminados aqui." - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Данные передачи включают историю и кэшированное содержимое активных раздач. Ненужный кэш освобождается автоматически после удаления записей; это может занять некоторое время. Полученные файлы учитываются в этой сводке, но никогда не удаляются здесь." - } - } - } - }, - "storage_received_files": { - "comment": "Settings > Storage: label for the received-files size row.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Empfangene Dateien" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Received files" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Archivos recibidos" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Fichiers reçus" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "File ricevuti" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Ontvangen bestanden" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Odebrane pliki" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Ficheiros recebidos" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Полученные файлы" - } - } - } - }, - "storage_temporary": { - "comment": "Settings > Storage: label for the temporary-files size row.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Temporäre Dateien" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Temporary files" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Archivos temporales" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Fichiers temporaires" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "File temporanei" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Tijdelijke bestanden" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Pliki tymczasowe" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Ficheiros temporários" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Временные файлы" - } - } - } - }, - "storage_title": { - "comment": "Settings > Storage: section title.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Speicher" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Storage" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Almacenamiento" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Stockage" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Archiviazione" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Opslag" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Pamięć" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Armazenamento" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Хранилище" - } - } - } - }, - "storage_total": { - "comment": "Settings > Storage: label for the total-size row.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Gesamt" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Total" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Total" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Total" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Totale" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Totaal" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Łącznie" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Total" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Всего" - } - } - } - }, - "storage_transfer_data": { - "comment": "Settings > Storage: label for the transfer-engine data size row.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Übertragungsdaten" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Transfer data" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Datos de transferencia" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Données de transfert" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Dati di trasferimento" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Overdrachtsgegevens" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Dane transferu" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Dados de transferência" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Данные передачи" - } - } - } - }, - "storage_transfers_deleted": { - "comment": "Settings > Storage: toast confirming all transfers were deleted.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Alle Übertragungen gelöscht" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "All transfers deleted" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Todas las transferencias eliminadas" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Tous les transferts supprimés" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Tutti i trasferimenti eliminati" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Alle overdrachten verwijderd" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Usunięto wszystkie transfery" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Todas as transferências eliminadas" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Все передачи удалены" - } - } - } - }, - "transfer_activity_description": { - "comment": "Transfer details: description under the Activity section.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Wichtige Aktualisierungen zu dieser Übertragung ansehen" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "See important updates for this transfer" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Vea las actualizaciones importantes de esta transferencia" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Consultez les mises à jour importantes de ce transfert" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Veda gli aggiornamenti importanti di questo trasferimento" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Bekijk belangrijke updates voor deze overdracht" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Zobacz ważne aktualizacje tego transferu" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Ver as atualizações importantes desta transferência" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Просматривайте важные обновления этой передачи" - } - } - } - }, - "transfer_activity_title": { - "comment": "Transfer details: Activity section title.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Aktivität" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Activity" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Actividad" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Activité" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Attività" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Activiteit" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Aktywność" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Atividade" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Активность" - } - } - } - }, - "transfer_delete_description": { - "comment": "Transfer details: confirmation body for deleting a transfer. {arg1} = transfer name.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "„%1$@“ wird nicht mehr geteilt und sein Übertragungsverlauf wird von diesem Gerät entfernt." - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "“%1$@” will stop being shared and its transfer history will be removed from this device." - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "«%1$@» dejará de compartirse y su historial de transferencia se eliminará de este dispositivo." - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "« %1$@ » cessera d’être partagé et son historique de transfert sera retiré de cet appareil." - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "«%1$@» non verrà più condiviso e la sua cronologia di trasferimento verrà rimossa da questo dispositivo." - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "‘%1$@’ wordt niet meer gedeeld en de overdrachtsgeschiedenis wordt van dit apparaat verwijderd." - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "„%1$@” przestanie być udostępniany, a jego historia transferu zostanie usunięta z tego urządzenia." - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "«%1$@» deixará de ser partilhado e o seu histórico de transferência será removido deste dispositivo." - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Общий доступ к «%1$@» будет остановлен, а история передачи будет удалена с этого устройства." - } - } - } - }, - "transfer_delete_title": { - "comment": "Transfer details: confirmation title for deleting a transfer.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Übertragung löschen?" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Delete transfer?" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "¿Eliminar la transferencia?" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Supprimer le transfert ?" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Eliminare il trasferimento?" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Overdracht verwijderen?" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Usunąć transfer?" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Eliminar a transferência?" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Удалить передачу?" - } - } - } - }, - "transfer_deleted": { - "comment": "Transfer details: toast confirming a transfer was deleted.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Übertragung gelöscht." - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Transfer deleted." - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Transferencia eliminada." - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Transfert supprimé." - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Trasferimento eliminato." - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Overdracht verwijderd." - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Transfer usunięty." - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Transferência eliminada." - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Передача удалена." - } - } - } - }, - "transfer_deleting": { - "comment": "Transfer details: progress label while a transfer is being deleted.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Wird gelöscht…" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Deleting…" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Eliminando…" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Suppression…" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Eliminazione…" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Verwijderen…" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Usuwanie…" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "A eliminar…" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Удаление…" - } - } - } - }, - "transfer_details_title": { - "comment": "Transfer details: screen title.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Übertragungsdetails" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Transfer details" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Detalles de la transferencia" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Détails du transfert" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Dettagli del trasferimento" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Overdrachtsdetails" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Szczegóły transferu" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Detalhes da transferência" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Сведения о передаче" - } - } - } - }, - "transfer_event_approved": { - "comment": "Transfer activity event: a receiver's access was approved.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Empfängerzugriff genehmigt" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Receiver access approved" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Acceso del destinatario aprobado" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Accès du destinataire approuvé" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Accesso del destinatario approvato" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Toegang ontvanger goedgekeurd" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Zatwierdzono dostęp odbiorcy" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Acesso do destinatário aprovado" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Доступ получателя одобрен" - } - } - } - }, - "transfer_event_completed": { - "comment": "Transfer activity event: a receiver completed the transfer.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Ein Empfänger hat die Übertragung abgeschlossen" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "A receiver completed the transfer" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Un destinatario completó la transferencia" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Un destinataire a terminé le transfert" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Un destinatario ha completato il trasferimento" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Een ontvanger heeft de overdracht voltooid" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Odbiorca ukończył transfer" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Um destinatário concluiu a transferência" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Получатель завершил передачу" - } - } - } - }, - "transfer_event_connecting": { - "comment": "Transfer activity event: connecting to the sender.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Verbindung zum Absender" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Connecting to sender" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Conectando con el remitente" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Connexion à l’expéditeur" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Connessione al mittente" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Verbinden met afzender" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Łączenie z nadawcą" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "A ligar ao remetente" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Подключение к отправителю" - } - } - } - }, - "transfer_event_downloading": { - "comment": "Transfer activity event: downloading.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Wird geladen" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Downloading" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Descargando" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Téléchargement" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Download" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Downloaden" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Pobieranie" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "A descarregar" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Загрузка" - } - } - } - }, - "transfer_event_failed": { - "comment": "Transfer activity event: the transfer hit a problem.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Bei der Übertragung ist ein Problem aufgetreten" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "The transfer encountered a problem" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "La transferencia tuvo un problema" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Le transfert a rencontré un problème" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Il trasferimento ha riscontrato un problema" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Er is een probleem opgetreden bij de overdracht" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Podczas transferu wystąpił problem" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "A transferência teve um problema" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "При передаче возникла проблема" - } - } - } - }, - "transfer_event_preparing": { - "comment": "Transfer activity event: preparing the transfer.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Ihre Übertragung wird vorbereitet" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Preparing your transfer" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Preparando su transferencia" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Préparation de votre transfert" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Preparazione del trasferimento" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Uw overdracht voorbereiden" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Przygotowywanie transferu" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "A preparar a sua transferência" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Подготовка вашей передачи" - } - } - } - }, - "transfer_event_ready": { - "comment": "Transfer activity event: ready to share.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Bereit zum Teilen" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Ready to share" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Listo para compartir" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Prêt à partager" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Pronto per la condivisione" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Klaar om te delen" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Gotowe do udostępnienia" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Pronto para partilhar" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Готово к отправке" - } - } - } - }, - "transfer_event_refused": { - "comment": "Transfer activity event: a receiver's access was refused.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Empfängerzugriff abgelehnt" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Receiver access refused" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Acceso del destinatario rechazado" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Accès du destinataire refusé" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Accesso del destinatario rifiutato" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Toegang ontvanger geweigerd" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Odrzucono dostęp odbiorcy" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Acesso do destinatário recusado" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Доступ получателя отклонён" - } - } - } - }, - "transfer_event_requested": { - "comment": "Transfer activity event: a receiver requested access.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Ein Empfänger hat Zugriff angefragt" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "A receiver requested access" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Un destinatario solicitó acceso" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Un destinataire a demandé l’accès" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Un destinatario ha richiesto l’accesso" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Een ontvanger heeft toegang aangevraagd" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Odbiorca poprosił o dostęp" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Um destinatário pediu acesso" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Получатель запросил доступ" - } - } - } - }, - "transfer_event_saving": { - "comment": "Transfer activity event: saving received files.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Wird gesichert" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Saving" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Guardando" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Enregistrement" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Salvataggio" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Bewaren" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Zapisywanie" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "A guardar" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Сохранение" - } - } - } - }, - "transfer_event_stopped": { - "comment": "Transfer activity event: sharing was stopped.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Freigabe beendet" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Sharing stopped" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Se dejó de compartir" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Partage arrêté" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Condivisione interrotta" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Delen gestopt" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Zatrzymano udostępnianie" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Partilha parada" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Раздача остановлена" - } - } - } - }, - "transfer_event_updated": { - "comment": "Transfer activity event: the transfer was updated.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Übertragung aktualisiert" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Transfer updated" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Transferencia actualizada" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Transfert mis à jour" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Trasferimento aggiornato" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Overdracht bijgewerkt" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Transfer zaktualizowany" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Transferência atualizada" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Передача обновлена" - } - } - } - }, - "transfer_file_count": { - "comment": "Transfer subtitle: file count with pluralization. {count} = number of files.", - "extractionState": "manual", - "localizations": { - "de": { - "variations": { - "plural": { - "one": { - "stringUnit": { - "state": "needs_review", - "value": "%1$d Datei" - } - }, - "other": { - "stringUnit": { - "state": "needs_review", - "value": "%1$d Dateien" - } - } - } - } - }, - "en": { - "variations": { - "plural": { - "one": { - "stringUnit": { - "state": "translated", - "value": "%1$d file" - } - }, - "other": { - "stringUnit": { - "state": "translated", - "value": "%1$d files" - } - } - } - } - }, - "es": { - "variations": { - "plural": { - "one": { - "stringUnit": { - "state": "needs_review", - "value": "%1$d archivo" - } - }, - "other": { - "stringUnit": { - "state": "needs_review", - "value": "%1$d archivos" - } - } - } - } - }, - "fr": { - "variations": { - "plural": { - "one": { - "stringUnit": { - "state": "needs_review", - "value": "%1$d fichier" - } - }, - "other": { - "stringUnit": { - "state": "needs_review", - "value": "%1$d fichiers" - } - } - } - } - }, - "it": { - "variations": { - "plural": { - "one": { - "stringUnit": { - "state": "needs_review", - "value": "%1$d file" - } - }, - "other": { - "stringUnit": { - "state": "needs_review", - "value": "%1$d file" - } - } - } - } - }, - "nl": { - "variations": { - "plural": { - "one": { - "stringUnit": { - "state": "needs_review", - "value": "%1$d bestand" - } - }, - "other": { - "stringUnit": { - "state": "needs_review", - "value": "%1$d bestanden" - } - } - } - } - }, - "pl": { - "variations": { - "plural": { - "few": { - "stringUnit": { - "state": "needs_review", - "value": "%1$d pliki" - } - }, - "many": { - "stringUnit": { - "state": "needs_review", - "value": "%1$d plików" - } - }, - "one": { - "stringUnit": { - "state": "needs_review", - "value": "%1$d plik" - } - }, - "other": { - "stringUnit": { - "state": "needs_review", - "value": "%1$d pliku" - } - } - } - } - }, - "pt": { - "variations": { - "plural": { - "one": { - "stringUnit": { - "state": "needs_review", - "value": "%1$d ficheiro" - } - }, - "other": { - "stringUnit": { - "state": "needs_review", - "value": "%1$d ficheiros" - } - } - } - } - }, - "ru": { - "variations": { - "plural": { - "few": { - "stringUnit": { - "state": "needs_review", - "value": "%1$d файла" - } - }, - "many": { - "stringUnit": { - "state": "needs_review", - "value": "%1$d файлов" - } - }, - "one": { - "stringUnit": { - "state": "needs_review", - "value": "%1$d файл" - } - }, - "other": { - "stringUnit": { - "state": "needs_review", - "value": "%1$d файла" - } - } - } - } - } - } - }, - "transfer_invitation_saved": { - "comment": "Transfer share: toast confirming the invitation file was saved.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Einladung gesichert." - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Invitation saved." - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Invitación guardada." - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Invitation enregistrée." - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Invito salvato." - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Uitnodiging bewaard." - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Zaproszenie zapisane." - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Convite guardado." - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Приглашение сохранено." - } - } - } - }, - "transfer_nearby_device": { - "comment": "Transfer share/receivers: label for a nearby device.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Gerät in der Nähe" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Nearby device" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Dispositivo cercano" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Appareil à proximité" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Dispositivo nelle vicinanze" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Apparaat in de buurt" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Urządzenie w pobliżu" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Dispositivo próximo" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Устройство поблизости" - } - } - } - }, - "transfer_nfc_unavailable": { - "comment": "Transfer share: message when NFC writing isn't available on the device.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Das Beschreiben von NFC-Tags ist auf diesem Gerät nicht verfügbar." - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "NFC tag writing is not available on this device." - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "La escritura de etiquetas NFC no está disponible en este dispositivo." - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "L’écriture de tag NFC n’est pas disponible sur cet appareil." - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "La scrittura dei tag NFC non è disponibile su questo dispositivo." - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Het schrijven van NFC-tags is niet beschikbaar op dit apparaat." - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Zapis tagów NFC nie jest dostępny na tym urządzeniu." - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "A escrita de etiquetas NFC não está disponível neste dispositivo." - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Запись NFC-меток недоступна на этом устройстве." - } - } - } - }, - "transfer_nfc_waiting": { - "comment": "Transfer share: prompt while waiting to write an NFC tag.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Halten Sie Ihr Gerät an ein beschreibbares NFC-Tag." - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Hold your device near a writable NFC tag." - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Acerque su dispositivo a una etiqueta NFC grabable." - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Approchez votre appareil d’un tag NFC inscriptible." - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Avvicini il dispositivo a un tag NFC scrivibile." - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Houd uw apparaat bij een beschrijfbare NFC-tag." - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Zbliż urządzenie do zapisywalnego tagu NFC." - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Aproxime o seu dispositivo de uma etiqueta NFC gravável." - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Поднесите устройство к записываемой NFC-метке." - } - } - } - }, - "transfer_nfc_written": { - "comment": "Transfer share: confirmation the invitation was written to the NFC tag.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Einladung auf das NFC-Tag geschrieben." - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Invitation written to the NFC tag." - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Invitación escrita en la etiqueta NFC." - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Invitation écrite sur le tag NFC." - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Invito scritto sul tag NFC." - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Uitnodiging naar de NFC-tag geschreven." - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Zaproszenie zapisane na tagu NFC." - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Convite escrito na etiqueta NFC." - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Приглашение записано на NFC-метку." - } - } - } - }, - "transfer_no_activity": { - "comment": "Transfer details: empty state for the Activity section.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Es gibt noch keine Aktivität anzuzeigen." - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "There is no activity to show yet." - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Todavía no hay actividad que mostrar." - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Aucune activité à afficher pour l’instant." - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Non c’è ancora alcuna attività da mostrare." - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Er is nog geen activiteit om weer te geven." - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Nie ma jeszcze aktywności do wyświetlenia." - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Ainda não há atividade para mostrar." - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Пока нет активности для отображения." - } - } - } - }, - "transfer_no_receivers": { - "comment": "Transfer details: empty state for the Receivers section.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Noch niemand hat diese Übertragung angefragt." - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Nobody has requested this transfer yet." - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Nadie ha solicitado aún esta transferencia." - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Personne n’a encore demandé ce transfert." - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Nessuno ha ancora richiesto questo trasferimento." - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Nog niemand heeft deze overdracht aangevraagd." - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Nikt jeszcze nie poprosił o ten transfer." - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Ainda ninguém pediu esta transferência." - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Никто ещё не запросил эту передачу." - } - } - } - }, - "transfer_qr_unavailable": { - "comment": "Transfer share: shown when an invitation is too large to encode as a QR code.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Für diese Einladung ist kein QR-Code verfügbar. Verwenden Sie stattdessen Teilen oder Herunterladen." - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "QR unavailable for this invitation. Use Share or Download instead." - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "El QR no está disponible para esta invitación. Use Compartir o Descargar." - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Le code QR n’est pas disponible pour cette invitation. Utilisez plutôt Partager ou Télécharger." - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Il codice QR non è disponibile per questo invito. Utilizzi invece Condividi o Scarica." - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "QR is niet beschikbaar voor deze uitnodiging. Gebruik in plaats daarvan Delen of Downloaden." - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Kod QR jest niedostępny dla tego zaproszenia. Zamiast tego użyj opcji Udostępnij lub Pobierz." - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "O código QR não está disponível para este convite. Utilize Partilhar ou Transferir." - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "QR-код недоступен для этого приглашения. Используйте «Поделиться» или «Скачать»." - } - } - } - }, - "transfer_receiver_accepted": { - "comment": "Receiver status: approved, waiting for the download to complete.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Genehmigt – wartet auf Abschluss" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Approved — waiting for completion" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Aprobado: esperando a que se complete" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Approuvé — en attente de la fin" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Approvato: in attesa del completamento" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Goedgekeurd — wachten op voltooiing" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Zatwierdzono — oczekiwanie na ukończenie" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Aprovado — a aguardar conclusão" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Одобрено — ожидание завершения" - } - } - } - }, - "transfer_receiver_completed": { - "comment": "Receiver status: received successfully.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Erfolgreich empfangen" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Received successfully" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Recibido correctamente" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Reçu avec succès" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Ricevuto correttamente" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Succesvol ontvangen" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Odebrano pomyślnie" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Recebido com sucesso" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Успешно получено" - } - } - } - }, - "transfer_receiver_failed": { - "comment": "Receiver status: the transfer failed.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Übertragung fehlgeschlagen" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Transfer failed" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Transferencia fallida" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Transfert échoué" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Trasferimento non riuscito" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Overdracht mislukt" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Przesyłanie nie powiodło się" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "A transferência falhou" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Передача не удалась" - } - } - } - }, - "transfer_receiver_expired": { - "comment": "Receiver status: the request expired.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Anfrage abgelaufen" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Request expired" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Solicitud caducada" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Demande expirée" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Richiesta scaduta" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Verzoek verlopen" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Prośba wygasła" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Pedido expirado" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Запрос истёк" - } - } - } - }, - "transfer_receiver_refused": { - "comment": "Receiver status: the request was refused.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Anfrage abgelehnt" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Request refused" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Solicitud rechazada" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Demande refusée" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Richiesta rifiutata" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Verzoek geweigerd" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Prośba odrzucona" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Pedido recusado" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Запрос отклонён" - } - } - } - }, - "transfer_receiver_requested": { - "comment": "Receiver status: waiting for the sender's approval.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Wartet auf Ihre Genehmigung" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Waiting for your approval" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Esperando su aprobación" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "En attente de votre approbation" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "In attesa della sua approvazione" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Wachten op uw goedkeuring" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Oczekiwanie na Twoje zatwierdzenie" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "A aguardar a sua aprovação" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Ожидание вашего одобрения" - } - } - } - }, - "transfer_receiver_unknown": { - "comment": "Receiver status: status unavailable/unknown.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Status nicht verfügbar" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Status unavailable" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Estado no disponible" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Statut indisponible" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Stato non disponibile" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Status niet beschikbaar" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Status niedostępny" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Estado indisponível" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Статус недоступен" - } - } - } - }, - "transfer_receivers_completed_count": { - "comment": "Receivers summary: how many receivers completed. {count} = completed receivers.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "%1$d abgeschlossen" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "%1$d completed" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "%1$d completados" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "%1$d terminés" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "%1$d completati" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "%1$d voltooid" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Ukończone: %1$d" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "%1$d concluídos" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Завершено: %1$d" - } - } - } - }, - "transfer_receivers_description": { - "comment": "Transfer details: description under the Receivers section.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Anfragen, Genehmigungen und abgeschlossene Zustellungen" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Requests, approvals, and completed deliveries" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Solicitudes, aprobaciones y entregas completadas" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Demandes, approbations et livraisons terminées" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Richieste, approvazioni e consegne completate" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Verzoeken, goedkeuringen en voltooide leveringen" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Prośby, zatwierdzenia i ukończone dostawy" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Pedidos, aprovações e entregas concluídas" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Запросы, одобрения и завершённые доставки" - } - } - } - }, - "transfer_receivers_pending": { - "comment": "Receivers summary: how many requests are waiting. {count} = pending receivers.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "%1$d warten" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "%1$d waiting" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "%1$d en espera" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "%1$d en attente" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "%1$d in attesa" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "%1$d in behandeling" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Oczekujące: %1$d" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "%1$d em espera" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Ожидают: %1$d" - } - } - } - }, - "transfer_receivers_title": { - "comment": "Transfer details: Receivers section title.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Empfänger" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Receivers" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Destinatarios" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Destinataires" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Destinatari" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Ontvangers" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Odbiorcy" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Destinatários" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Получатели" - } - } - } - }, - "transfer_scan_qr": { - "comment": "Transfer share: caption under the QR code.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Mit VniDrop scannen, um diese Übertragung zu empfangen" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Scan with VniDrop to receive this transfer" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Escanee con VniDrop para recibir esta transferencia" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Scannez avec VniDrop pour recevoir ce transfert" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Scansioni con VniDrop per ricevere questo trasferimento" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Scan met VniDrop om deze overdracht te ontvangen" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Zeskanuj za pomocą VniDrop, aby odebrać ten transfer" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Leia com o VniDrop para receber esta transferência" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Отсканируйте с помощью VniDrop, чтобы получить эту передачу" - } - } - } - }, - "transfer_share_description": { - "comment": "Transfer details: description under the Share section.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "QR-Code, Einladungsdatei und Optionen in der Nähe" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "QR code, invitation file, and nearby options" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Código QR, archivo de invitación y opciones cercanas" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "QR code, fichier d’invitation et options à proximité" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Codice QR, file di invito e opzioni nelle vicinanze" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "QR-code, uitnodigingsbestand en opties in de buurt" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Kod QR, plik zaproszenia i opcje w pobliżu" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Código QR, ficheiro de convite e opções por perto" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "QR-код, файл приглашения и варианты поблизости" - } - } - } - }, - "transfer_share_title": { - "comment": "Transfer details: Share section title.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Teilen" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Share" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Compartir" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Partager" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Condividi" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Delen" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Udostępnij" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Partilhar" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Поделиться" - } - } - } - }, - "value_unavailable": { - "comment": "Placeholder shown when a device-info or metadata value can't be read.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "Nicht verfügbar" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Not available" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "No disponible" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Non disponible" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Non disponibile" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "Niet beschikbaar" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Niedostępne" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Indisponível" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Недоступно" - } - } - } - }, - "version_title": { - "comment": "Device information / Settings row: app version label.", - "extractionState": "manual", - "localizations": { - "de": { - "stringUnit": { - "state": "needs_review", - "value": "App-Version" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "App version" - } - }, - "es": { - "stringUnit": { - "state": "needs_review", - "value": "Versión de la app" - } - }, - "fr": { - "stringUnit": { - "state": "needs_review", - "value": "Version de l’app" - } - }, - "it": { - "stringUnit": { - "state": "needs_review", - "value": "Versione dell’app" - } - }, - "nl": { - "stringUnit": { - "state": "needs_review", - "value": "App-versie" - } - }, - "pl": { - "stringUnit": { - "state": "needs_review", - "value": "Wersja aplikacji" - } - }, - "pt": { - "stringUnit": { - "state": "needs_review", - "value": "Versão da app" - } - }, - "ru": { - "stringUnit": { - "state": "needs_review", - "value": "Версия приложения" - } - } - } - } - }, - "version": "1.0" -} diff --git a/apple/VniDrop/UI/Components/AdaptiveDrawer.swift b/apple/VniDrop/UI/Components/AdaptiveDrawer.swift index 2d19e20..7b75752 100644 --- a/apple/VniDrop/UI/Components/AdaptiveDrawer.swift +++ b/apple/VniDrop/UI/Components/AdaptiveDrawer.swift @@ -41,7 +41,7 @@ private struct SheetChrome: View { ScrollView { content().padding(.top, 4) } .toolbar { ToolbarItem(placement: .cancellationAction) { - Button(String(localized: "button_close"), action: onClose) + Button(String(localized: L10n.Button.close), action: onClose) } } #if os(iOS) diff --git a/apple/VniDrop/UI/Components/Components.swift b/apple/VniDrop/UI/Components/Components.swift index 7d2b504..4fb2c0f 100644 --- a/apple/VniDrop/UI/Components/Components.swift +++ b/apple/VniDrop/UI/Components/Components.swift @@ -30,7 +30,7 @@ struct StatusPill: View { // MARK: - ProgressRow struct ProgressRow: View { - let labelKey: String + let labelKey: String.LocalizationValue let progress: Double? var detail: String? = nil /// Pre-resolved label; when set it overrides `labelKey`. @@ -42,7 +42,7 @@ struct ProgressRow: View { label.font(.subheadline).lineLimit(1) Spacer() if let progress { - Text("\(Int(progress * 100))%").font(.caption).foregroundStyle(.secondary) + Text(verbatim: "\(Int(progress * 100))%").font(.caption).foregroundStyle(.secondary) } } if let detail { @@ -62,7 +62,7 @@ struct ProgressRow: View { if let labelText { Text(labelText) } else { - Text(LocalizedStringKey(labelKey)) + Text(String(localized: labelKey)) } } } diff --git a/apple/VniDrop/UI/Feedback/SnackbarHost.swift b/apple/VniDrop/UI/Feedback/SnackbarHost.swift index c49ec51..b60a414 100644 --- a/apple/VniDrop/UI/Feedback/SnackbarHost.swift +++ b/apple/VniDrop/UI/Feedback/SnackbarHost.swift @@ -1,4 +1,5 @@ import SwiftUI +import SFSafeSymbols /// Bottom toast host driven by `UiMessageController`, ported from /// `ui/feedback/VniDropSnackbarHost.kt`. Tone drives the accent color; errors get @@ -52,7 +53,7 @@ struct SnackbarHost: View { .buttonStyle(.borderless) } Button(action: dismiss) { - Image(systemName: "xmark") + Image(systemSymbol: .xmark) .font(.footnote.weight(.semibold)) .foregroundStyle(.secondary) .frame(width: 36, height: 36) diff --git a/apple/VniDrop/UI/Feedback/UiMessage.swift b/apple/VniDrop/UI/Feedback/UiMessage.swift index 2a71c8c..2ab6c77 100644 --- a/apple/VniDrop/UI/Feedback/UiMessage.swift +++ b/apple/VniDrop/UI/Feedback/UiMessage.swift @@ -4,14 +4,14 @@ import Combine /// A localizable UI string: either a catalog key or dynamic text, ported from /// `UiText` in `ui/feedback/UiMessageController.kt`. enum UiText: Equatable { - case resource(String) // Localizable.xcstrings key + case resource(String.LocalizationValue) // Localizable.xcstrings key (use L10n.*) case dynamic(String) /// Resolves to display text. Keys go through the string catalog. func resolved() -> String { switch self { case .dynamic(let value): return value - case .resource(let key): return String(localized: String.LocalizationValue(key)) + case .resource(let value): return String(localized: value) } } } diff --git a/apple/VniDrop/UI/Feedback/UserFacingError.swift b/apple/VniDrop/UI/Feedback/UserFacingError.swift index bac00c4..77cab8d 100644 --- a/apple/VniDrop/UI/Feedback/UserFacingError.swift +++ b/apple/VniDrop/UI/Feedback/UserFacingError.swift @@ -8,34 +8,34 @@ extension Error { if let vni = self as? VnidropError { switch vni { case .Ticket: - return .resource("error_invalid_ticket") + return .resource(L10n.Error.invalidTicket) case .Permission: - return .resource("error_permission") + return .resource(L10n.Error.permission) case .Filesystem: - return .resource("error_filesystem") + return .resource(L10n.Error.filesystem) case .FilesystemPermission: - return .resource("error_filesystem") + return .resource(L10n.Error.filesystem) case .DestinationExists: - return .resource("error_destination_exists") + return .resource(L10n.Error.destinationExists) case .StorageFull: - return .resource("error_storage_full") + return .resource(L10n.Error.storageFull) case .Network: - return .resource("error_network") + return .resource(L10n.Error.network) case .Transfer(let reason): return transferUiText(reason) case .Repository: - return .resource("error_repository") + return .resource(L10n.Error.repository) case .Cancelled: - return .resource("error_generic") + return .resource(L10n.Error.generic) case .InvalidInput: - return .resource("error_invalid_input") + return .resource(L10n.Error.invalidInput) case .Initialization(let reason): return initializationUiText(reason) case .Internal(let reason): - return reasonHints(reason) ?? .resource("error_generic") + return reasonHints(reason) ?? .resource(L10n.Error.generic) } } - return reasonHints(technicalDetail) ?? .resource("error_generic") + return reasonHints(technicalDetail) ?? .resource(L10n.Error.generic) } /// True when the user intentionally backed out of a flow. @@ -76,23 +76,56 @@ extension Error { } } +/// Maps a receiver delivery/refusal reason code to a user-facing message, never +/// surfacing the raw core code (e.g. `destination_exists`). Unknown codes fall back +/// to the substring hints, then a generic message. +func receiverReasonUiText(_ reason: String) -> UiText { + switch reason { + case "destination_exists": + return .resource(L10n.Error.destinationExists) + case "filesystem", "filesystem_permission_denied": + return .resource(L10n.Error.filesystem) + case "permission_denied", "approval-required", "approval-expired", + "unknown-transfer", "missing-endpoint-id", "invalid-receipt": + return .resource(L10n.Error.permission) + case "storage_full": + return .resource(L10n.Error.storageFull) + case "network": + return .resource(L10n.Error.network) + case "invalid_ticket": + return .resource(L10n.Error.invalidTicket) + case "transfer": + return .resource(L10n.Error.transfer) + case "repository", "repository-error": + return .resource(L10n.Error.repository) + case "invalid_input": + return .resource(L10n.Error.invalidInput) + case "initialization": + return .resource(L10n.Error.initialization) + case "cancelled", "internal": + return .resource(L10n.Error.generic) + default: + return reasonHints(reason) ?? .resource(L10n.Error.generic) + } +} + private func transferUiText(_ reason: String) -> UiText { let detail = reason.lowercased() if detail.contains("refused") || detail.contains("denied") || detail.contains("not approved") { - return .resource("error_permission") + return .resource(L10n.Error.permission) } - return .resource("error_transfer") + return .resource(L10n.Error.transfer) } private func initializationUiText(_ reason: String) -> UiText { let detail = reason.lowercased() if detail.contains("native") && detail.contains("library") { - return .resource("error_missing_native_library") + return .resource(L10n.Error.missingNativeLibrary) } if detail.contains("socket") || detail.contains("bind") { - return .resource("error_socket_bind") + return .resource(L10n.Error.socketBind) } - return .resource("error_initialization") + return .resource(L10n.Error.initialization) } private func reasonHints(_ detailRaw: String) -> UiText? { @@ -100,50 +133,50 @@ private func reasonHints(_ detailRaw: String) -> UiText? { if detail.isEmpty { return nil } if detail.contains("still starting") || detail.contains("starting up") { - return .resource("error_starting_up") + return .resource(L10n.Error.startingUp) } if detail.contains("empty") && (detail.contains("invitation") || detail.contains("ticket") || detail.contains("qr")) { - return .resource("error_invitation_empty") + return .resource(L10n.Error.invitationEmpty) } if detail.contains("select at least one") || detail.contains("no files found") { - return .resource("error_share_empty") + return .resource(L10n.Error.shareEmpty) } if detail.contains("camera") { - return .resource("error_camera") + return .resource(L10n.Error.camera) } if detail.contains("nfc") || detail.contains("ndef") || (detail.contains("read-only") && detail.contains("tag")) || detail.contains("tag is too small") || detail.contains("no nfc tag") { - return .resource("error_nfc") + return .resource(L10n.Error.nfc) } if detail.contains("native") && detail.contains("library") { - return .resource("error_missing_native_library") + return .resource(L10n.Error.missingNativeLibrary) } if detail.contains("socket") || detail.contains("bind") { - return .resource("error_socket_bind") + return .resource(L10n.Error.socketBind) } if detail.contains("device information") || detail.contains("device info") { - return .resource("error_device_info") + return .resource(L10n.Error.deviceInfo) } if detail.contains("refused") || detail.contains("denied") || detail.contains("permission") || detail.contains("not approved") || detail.contains("waiting for approval") { - return .resource("error_permission") + return .resource(L10n.Error.permission) } if detail.contains("invalid ticket") || detail.contains("ticket error") || detail.contains("could not be read") || detail.contains("malformed") || detail.contains("invitation could not be opened") { - return .resource("error_invalid_ticket") + return .resource(L10n.Error.invalidTicket) } if detail.contains("selected") && (detail.contains("file") || detail.contains("folder") || detail.contains("document") || detail.contains("open")) { - return .resource("error_selection_failed") + return .resource(L10n.Error.selectionFailed) } if detail.contains("could not open the selected") || detail.contains("could not open selected") { - return .resource("error_selection_failed") + return .resource(L10n.Error.selectionFailed) } if detail.contains("document picker") || detail.contains("folder picker") || detail.contains("file descriptor") || detail.contains("view controller") { - return .resource("error_selection_failed") + return .resource(L10n.Error.selectionFailed) } return nil } diff --git a/apple/VniDrop/UI/Navigation/AppDestination.swift b/apple/VniDrop/UI/Navigation/AppDestination.swift index 1a755bd..de8804d 100644 --- a/apple/VniDrop/UI/Navigation/AppDestination.swift +++ b/apple/VniDrop/UI/Navigation/AppDestination.swift @@ -1,4 +1,5 @@ import Foundation +import SFSafeSymbols /// Top-level destinations, ported from `ui/navigation/AppDestination.kt`. enum AppDestination: String, CaseIterable, Identifiable { @@ -8,20 +9,20 @@ enum AppDestination: String, CaseIterable, Identifiable { var id: String { rawValue } - var labelKey: String { + var labelKey: String.LocalizationValue { switch self { - case .send: return "nav_send" - case .receive: return "nav_receive" - case .settings: return "nav_settings" + case .send: return L10n.Nav.send + case .receive: return L10n.Nav.receive + case .settings: return L10n.Nav.settings } } /// SF Symbol approximating the Compose line icon. - var systemImage: String { + var systemSymbol: SFSymbol { switch self { - case .send: return "paperplane" - case .receive: return "tray.and.arrow.down" - case .settings: return "gearshape" + case .send: return .paperplane + case .receive: return .trayAndArrowDown + case .settings: return .gearshape } } } diff --git a/apple/VniDrop/UI/Theme/VniDropColors.swift b/apple/VniDrop/UI/Theme/VniDropColors.swift index 3d7c8dd..f811b02 100644 --- a/apple/VniDrop/UI/Theme/VniDropColors.swift +++ b/apple/VniDrop/UI/Theme/VniDropColors.swift @@ -52,7 +52,9 @@ struct VniDropColors { } extension VniDropColors { - /// The single brand accent used app-wide as the SwiftUI tint. + /// The single brand accent used app-wide as the SwiftUI tint. Mirrored by the + /// `AccentColor` asset (the OS-level global accent for the macOS sidebar etc.); + /// keep the two in sync. static let brandPurple = Color.hsl(271, 91, 65) static let light = VniDropColors( diff --git a/apple/project.yml b/apple/project.yml index ab0d4c5..806fc85 100644 --- a/apple/project.yml +++ b/apple/project.yml @@ -1,6 +1,9 @@ # XcodeGen spec for the native SwiftUI VniDrop app (iOS/iPadOS/macOS). # Regenerate the project with: xcodegen generate (run from apple/) -# Requires the Rust core first: apple/scripts/build-core.sh debug +# Requires two generated inputs first (both gitignored), before xcodegen: +# - Rust core: apple/scripts/build-core.sh debug +# - Localization: (cd localization && bun run src/cli.ts generate) +# -> VniDrop/Resources/Localizable.xcstrings, VniDrop/Generated/L10n.swift name: VniDrop options: bundleIdPrefix: com.vnidrop @@ -9,9 +12,20 @@ options: macOS: "15.0" createIntermediateGroups: true +# Project-wide build settings (applied to every target/config). +settings: + base: + # Strip unreachable code from release binaries. + DEAD_CODE_STRIPPING: YES + # Flag user-facing strings that aren't localized (the app ships 9 languages). + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED: YES + packages: VnidropCore: path: VnidropCore + SFSafeSymbols: + url: https://github.com/SFSafeSymbols/SFSafeSymbols + from: "5.3.0" targets: VniDrop: @@ -41,6 +55,9 @@ targets: SWIFT_STRICT_CONCURRENCY: complete ENABLE_USER_SCRIPT_SANDBOXING: NO ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon + # App-wide accent (macOS sidebar selection, default control tints). The + # AccentColor asset mirrors VniDropColors.brandPurple — keep them in sync. + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor configs: debug: CODE_SIGN_ENTITLEMENTS: VniDrop/Resources/VniDrop.entitlements @@ -48,9 +65,25 @@ targets: CODE_SIGN_ENTITLEMENTS: VniDrop/Resources/VniDrop.entitlements dependencies: - package: VnidropCore + - package: SFSafeSymbols - sdk: SystemConfiguration.framework - sdk: Security.framework - sdk: libresolv.tbd + preBuildScripts: + # Enforce the typed-resources convention (see .swiftlint.yml). Required: fails + # the build if SwiftLint is missing so the rules can't be silently bypassed. + - name: SwiftLint (typed resources) + basedOnDependencyAnalysis: false + script: | + # Xcode runs build phases with a minimal PATH that omits Homebrew, so add + # the common Homebrew bin dirs (Apple Silicon + Intel) before resolving it. + export PATH="/opt/homebrew/bin:/usr/local/bin:$PATH" + if which swiftlint >/dev/null; then + swiftlint lint --config "${SRCROOT}/.swiftlint.yml" + else + echo "error: SwiftLint not installed — run 'brew install swiftlint'" + exit 1 + fi VniDropTests: type: bundle.unit-test diff --git a/crates/vnidrop/tests/approval.rs b/crates/vnidrop/tests/approval.rs index a09253b..726fa4d 100644 --- a/crates/vnidrop/tests/approval.rs +++ b/crates/vnidrop/tests/approval.rs @@ -73,14 +73,23 @@ fn public_share_receives_without_sender_approval() { assert_eq!(deliveries[0].receiver_name.as_deref(), Some("Receiver")); assert_eq!(deliveries[0].status, "completed"); assert!(deliveries[0].completed_at.is_some()); - assert!( - sender.sink.events().iter().any(|event| { + // The repository commit becomes visible just before the receipt handler + // emits its event, so completion and sink observation are not atomic. + let started = Instant::now(); + loop { + if sender.sink.events().iter().any(|event| { event.phase == "delivery" && event.kind == "receiver-completed" && event.transfer_id == Some(share.transfer_id) - }), - "delivery receipts must emit a delivery phase event for UI live updates" - ); + }) { + break; + } + assert!( + started.elapsed() < Duration::from_secs(5), + "delivery receipts must emit a delivery phase event for UI live updates" + ); + std::thread::sleep(Duration::from_millis(10)); + } } #[test] diff --git a/localization/src/commands/generate.ts b/localization/src/commands/generate.ts index b51fb90..222d496 100644 --- a/localization/src/commands/generate.ts +++ b/localization/src/commands/generate.ts @@ -5,8 +5,11 @@ */ import { mkdirSync } from "node:fs"; import { join } from "node:path"; +import { mkdir } from "node:fs/promises"; +import { dirname } from "node:path"; import { APPLE_INFO_PLIST, + APPLE_L10N_SWIFT, APPLE_XCSTRINGS, kmpValuesDir, KMP_RESOURCES, @@ -14,6 +17,7 @@ import { } from "../config"; import { renderAndroid, type ParsedAndroid } from "../lib/android-xml"; import { fromCanonical, type Flavor } from "../lib/placeholders"; +import { renderSwiftAccessors } from "../lib/swift-accessors"; import { renderXcstrings, type XcCatalog, type XcEntry } from "../lib/xcstrings"; import { targetsOf, type StringEntry, type StringsFile } from "../types"; @@ -111,6 +115,10 @@ export async function generate() { await Bun.write(APPLE_XCSTRINGS, renderXcstrings(buildXcstrings(doc))); console.log(`Wrote ${APPLE_XCSTRINGS}`); + await mkdir(dirname(APPLE_L10N_SWIFT), { recursive: true }); + await Bun.write(APPLE_L10N_SWIFT, renderSwiftAccessors(doc)); + console.log(`Wrote ${APPLE_L10N_SWIFT}`); + await syncInfoPlistLocalizations(doc.supportedLanguages); for (const lang of doc.supportedLanguages) { diff --git a/localization/src/config.ts b/localization/src/config.ts index ace576f..a1a35c7 100644 --- a/localization/src/config.ts +++ b/localization/src/config.ts @@ -18,6 +18,12 @@ export const APPLE_INFO_PLIST = join( "apple/VniDrop/Resources/Info.plist", ); +/** Generated Swift accessors (`L10n`) for compile-time-checked catalog keys. */ +export const APPLE_L10N_SWIFT = join( + REPO_ROOT, + "apple/VniDrop/Generated/L10n.swift", +); + /** KMP / Compose Multiplatform resources root; one values[-lang]/strings.xml per language. */ export const KMP_RESOURCES = join( REPO_ROOT, diff --git a/localization/src/lib/swift-accessors.ts b/localization/src/lib/swift-accessors.ts new file mode 100644 index 0000000..534662d --- /dev/null +++ b/localization/src/lib/swift-accessors.ts @@ -0,0 +1,139 @@ +/** + * Swift accessor generator. + * + * Emits a compile-time-checked stand-in for each localization key so Apple call + * sites stop passing raw string literals. The runtime path is unchanged: plain + * keys become `String.LocalizationValue` constants used with `String(localized:)` + * exactly as before, and Apple's catalog lookup still does 100% of the work. + * + * Keys group by their first `_`-delimited segment (`button_…` -> `enum Button`); + * the remainder becomes a camelCase member. Plain keys are `static let` + * constants; keys with args become `static func` with typed, named parameters + * derived from the entry's `args` metadata. + */ +import { targetsOf, type Arg, type StringEntry, type StringsFile } from "../types"; + +const HEADER = `// Generated by localization/ (bun run src/cli.ts generate). Do not edit. +// Keys resolve through Apple's String Catalog exactly as a literal would; these +// accessors only make the key compile-time-checked. If a runtime language +// switcher (live \`.environment(\\.locale)\`) is ever added, switch the plain +// \`static let\` constants to computed \`static var\` so the locale is not frozen. +import Foundation +`; + +/** Swift type for a localization arg. */ +function swiftType(arg: Arg): string { + switch (arg.type) { + case "int": + return "Int"; + case "double": + return "Double"; + case "string": + return "String"; + } +} + +/** `create_new_transfer` -> `createNewTransfer`. */ +function camel(segment: string): string { + const parts = segment.split("_").filter(Boolean); + return parts + .map((p, i) => (i === 0 ? p : p.charAt(0).toUpperCase() + p.slice(1))) + .join(""); +} + +/** `button` -> `Button`. */ +function pascal(segment: string): string { + const c = camel(segment); + return c.charAt(0).toUpperCase() + c.slice(1); +} + +/** A key is groupable only when it starts with an identifier-safe segment. */ +function isGroupableKey(key: string): boolean { + return /^[A-Za-z][A-Za-z0-9_]*$/.test(key); +} + +/** Collapse whitespace/newlines so a value fits on one doc-comment line. */ +function oneLine(text: string): string { + return text.replace(/\s*\n\s*/g, " ").trim(); +} + +/** The displayable text for a language (plural shows its `other` form). */ +function translationText(entry: StringEntry, lang: string): string | undefined { + return entry.translations?.[lang] ?? entry.plural?.[lang]?.other; +} + +/** + * A rich Quick Help block: source-language text as the abstract, then the raw + * key, the context note, and every translation. Quick Help renders the Markdown. + */ +function docComment(key: string, entry: StringEntry, doc: StringsFile): string { + const lines: string[] = []; + const source = translationText(entry, doc.sourceLanguage); + if (source) lines.push(oneLine(source), ""); + + lines.push(`Key: \`${key}\``); + if (entry.context) lines.push(`Context: ${oneLine(entry.context)}`); + lines.push(""); + + for (const lang of doc.supportedLanguages) { + const value = translationText(entry, lang); + if (value !== undefined) lines.push(`- ${lang}: ${oneLine(value)}`); + } + + return lines + .map((line) => (line ? ` /// ${line}` : " ///")) + .join("\n"); +} + +function memberFor( + key: string, + member: string, + entry: StringEntry, + doc: StringsFile, +): string { + const comment = docComment(key, entry, doc); + const args = entry.args ?? []; + + // Plain key: a #define-style constant. Apple resolves it via String(localized:). + if (args.length === 0 && !entry.plural) { + return `${comment}\n static let ${member}: String.LocalizationValue = "${key}"`; + } + + // Arg'd (or plural) key: a typed, named function that applies the arguments + // through the same String(format: String(localized:)) path used before. + const params = args.map((a) => `${a.name}: ${swiftType(a)}`).join(", "); + const callArgs = args.map((a) => a.name).join(", "); + // The positional format string lives in the catalog; look it up by key and + // apply the args exactly as the hand-written call sites did. + return `${comment}\n static func ${member}(${params}) -> String {\n String(format: String(localized: "${key}"), ${callArgs})\n }`; +} + +export function renderSwiftAccessors(doc: StringsFile): string { + // group segment -> rendered members + const groups = new Map(); + + for (const [key, entry] of Object.entries(doc.strings)) { + if (!targetsOf(entry).includes("apple")) continue; + if (!isGroupableKey(key)) continue; // skips legacy `%@` literal keys + + const underscore = key.indexOf("_"); + const groupSeg = underscore === -1 ? key : key.slice(0, underscore); + const memberSeg = underscore === -1 ? key : key.slice(underscore + 1); + const group = pascal(groupSeg); + const member = camel(memberSeg) || camel(groupSeg); + + const list = groups.get(group) ?? []; + list.push(memberFor(key, member, entry, doc)); + groups.set(group, list); + } + + const body = [...groups.keys()] + .sort() + .map((group) => { + const members = groups.get(group)!.join("\n"); + return ` enum ${group} {\n${members}\n }`; + }) + .join("\n"); + + return `${HEADER}\nenum L10n {\n${body}\n}\n`; +} diff --git a/localization/strings.json b/localization/strings.json index 50d077f..b02514d 100644 --- a/localization/strings.json +++ b/localization/strings.json @@ -13,76 +13,6 @@ "ru" ], "strings": { - "%@": { - "context": "Apple format passthrough placeholder (single value). Legacy literal key — rename to a semantic key.", - "targets": [ - "apple" - ] - }, - "%@ %@ · %@": { - "context": "Apple format template composing three values with a middot separator (e.g. metadata rows). Legacy literal key — rename.", - "targets": [ - "apple" - ], - "args": [ - { - "name": "arg1", - "type": "string" - }, - { - "name": "arg2", - "type": "string" - }, - { - "name": "arg3", - "type": "string" - } - ], - "translations": { - "en": "{arg1} {arg2} · {arg3}", - "fr": "{arg1} {arg2} · {arg3}", - "es": "{arg1} {arg2} · {arg3}", - "it": "{arg1} {arg2} · {arg3}", - "de": "{arg1} {arg2} · {arg3}", - "pt": "{arg1} {arg2} · {arg3}", - "pl": "{arg1} {arg2} · {arg3}", - "nl": "{arg1} {arg2} · {arg3}", - "ru": "{arg1} {arg2} · {arg3}" - } - }, - "%@ · %@": { - "context": "Apple format template composing two values with a middot separator. Legacy literal key — rename.", - "targets": [ - "apple" - ], - "args": [ - { - "name": "arg1", - "type": "string" - }, - { - "name": "arg2", - "type": "string" - } - ], - "translations": { - "en": "{arg1} · {arg2}", - "fr": "{arg1} · {arg2}", - "es": "{arg1} · {arg2}", - "it": "{arg1} · {arg2}", - "de": "{arg1} · {arg2}", - "pt": "{arg1} · {arg2}", - "pl": "{arg1} · {arg2}", - "nl": "{arg1} · {arg2}", - "ru": "{arg1} · {arg2}" - } - }, - "%@%%": { - "context": "Apple format template appending a percent sign to a value (e.g. battery level). Legacy literal key — rename.", - "targets": [ - "apple" - ] - }, "about_bug_report": { "context": "About screen: button/link that opens the bug report form.", "translations": { @@ -391,6 +321,20 @@ "ru": "О приложении" } }, + "app_starting": { + "context": "Shown briefly at launch while the core is still starting up.", + "translations": { + "en": "Starting…", + "fr": "Démarrage…", + "es": "Iniciando…", + "it": "Avvio…", + "de": "Wird gestartet…", + "pt": "A iniciar…", + "pl": "Uruchamianie…", + "nl": "Bezig met starten…", + "ru": "Запуск…" + } + }, "appearance_auto_description": { "context": "Settings > Appearance: description for the System/auto option.", "translations": { @@ -476,23 +420,23 @@ } }, "approval_endpoint_id": { - "context": "Approval prompt: shows the requesting device's ID. {arg1} = device/endpoint identifier.", + "context": "Approval prompt: shows the requesting device's ID. {deviceId} = device/endpoint identifier.", "args": [ { - "name": "arg1", + "name": "deviceId", "type": "string" } ], "translations": { - "en": "Device ID: {arg1}", - "fr": "Identifiant de l’appareil : {arg1}", - "es": "ID del dispositivo: {arg1}", - "it": "ID dispositivo: {arg1}", - "de": "Geräte-ID: {arg1}", - "pt": "ID do dispositivo: {arg1}", - "pl": "Identyfikator urządzenia: {arg1}", - "nl": "Apparaat-ID: {arg1}", - "ru": "Идентификатор устройства: {arg1}" + "en": "Device ID: {deviceId}", + "fr": "Identifiant de l’appareil : {deviceId}", + "es": "ID del dispositivo: {deviceId}", + "it": "ID dispositivo: {deviceId}", + "de": "Geräte-ID: {deviceId}", + "pt": "ID do dispositivo: {deviceId}", + "pl": "Identyfikator urządzenia: {deviceId}", + "nl": "Apparaat-ID: {deviceId}", + "ru": "Идентификатор устройства: {deviceId}" } }, "approval_nearby_device": { @@ -530,27 +474,27 @@ } }, "approval_request_body": { - "context": "Approval prompt body: '{arg1} wants to receive \"{arg2}\".' {arg1} = requester name, {arg2} = transfer name.", + "context": "Approval prompt body: '{receiver} wants to receive \"{transferName}\".' {receiver} = requester name, {transferName} = transfer name.", "args": [ { - "name": "arg1", + "name": "receiver", "type": "string" }, { - "name": "arg2", + "name": "transferName", "type": "string" } ], "translations": { - "en": "{arg1} wants to receive “{arg2}”.", - "fr": "{arg1} souhaite recevoir « {arg2} ».", - "es": "{arg1} quiere recibir «{arg2}».", - "it": "{arg1} vuole ricevere «{arg2}».", - "de": "{arg1} möchte „{arg2}“ empfangen.", - "pt": "{arg1} quer receber «{arg2}».", - "pl": "{arg1} chce odebrać „{arg2}”.", - "nl": "{arg1} wil ‘{arg2}’ ontvangen.", - "ru": "{arg1} хочет получить «{arg2}»." + "en": "{receiver} wants to receive “{transferName}”.", + "fr": "{receiver} souhaite recevoir « {transferName} ».", + "es": "{receiver} quiere recibir «{transferName}».", + "it": "{receiver} vuole ricevere «{transferName}».", + "de": "{receiver} möchte „{transferName}“ empfangen.", + "pt": "{receiver} quer receber «{transferName}».", + "pl": "{receiver} chce odebrać „{transferName}”.", + "nl": "{receiver} wil ‘{transferName}’ ontvangen.", + "ru": "{receiver} хочет получить «{transferName}»." } }, "battery_level_title": { @@ -567,6 +511,29 @@ "ru": "Уровень заряда" } }, + "battery_level_value": { + "context": "Device information: battery charge formatted as a percentage. {level} = integer percent value.", + "targets": [ + "apple" + ], + "args": [ + { + "name": "level", + "type": "string" + } + ], + "translations": { + "en": "{level}%", + "fr": "{level} %", + "es": "{level} %", + "it": "{level}%", + "de": "{level} %", + "pt": "{level}%", + "pl": "{level}%", + "nl": "{level}%", + "ru": "{level} %" + } + }, "bug_report_contact_hint": { "context": "Bug report form: placeholder text in the contact email field.", "translations": { @@ -861,6 +828,20 @@ "ru": "Назад" } }, + "button_more_actions": { + "context": "Accessibility label for a button that opens more actions for an item.", + "translations": { + "en": "More actions", + "fr": "Plus d’actions", + "es": "Más acciones", + "it": "Altre azioni", + "de": "Weitere Aktionen", + "pt": "Mais ações", + "pl": "Więcej działań", + "nl": "Meer acties", + "ru": "Другие действия" + } + }, "button_cancel": { "context": "Button: cancel the current action or dialog.", "translations": { @@ -1645,6 +1626,64 @@ "ru": "Готово" } }, + "format_separated_pair": { + "context": "Composes two already-localized values with a middot separator (e.g. size · status). {first} and {second} are the two values.", + "targets": [ + "apple" + ], + "args": [ + { + "name": "first", + "type": "string" + }, + { + "name": "second", + "type": "string" + } + ], + "translations": { + "en": "{first} · {second}", + "fr": "{first} · {second}", + "es": "{first} · {second}", + "it": "{first} · {second}", + "de": "{first} · {second}", + "pt": "{first} · {second}", + "pl": "{first} · {second}", + "nl": "{first} · {second}", + "ru": "{first} · {second}" + } + }, + "format_separated_triple": { + "context": "Composes three already-localized values, the first two space-joined then a middot before the third (e.g. count files · size). {first}, {second}, {third} are the values.", + "targets": [ + "apple" + ], + "args": [ + { + "name": "first", + "type": "string" + }, + { + "name": "second", + "type": "string" + }, + { + "name": "third", + "type": "string" + } + ], + "translations": { + "en": "{first} {second} · {third}", + "fr": "{first} {second} · {third}", + "es": "{first} {second} · {third}", + "it": "{first} {second} · {third}", + "de": "{first} {second} · {third}", + "pt": "{first} {second} · {third}", + "pl": "{first} {second} · {third}", + "nl": "{first} {second} · {third}", + "ru": "{first} {second} · {third}" + } + }, "metadata_files": { "context": "Transfer metadata label: number of files.", "translations": { @@ -1746,15 +1785,15 @@ "notifications_description": { "context": "Settings > Notifications: explanation of what notifications are used for.", "translations": { - "en": "Get notified about new receive requests while VniDrop is in the background.", - "fr": "Soyez averti des nouvelles demandes de réception lorsque VniDrop est en arrière-plan.", - "es": "Reciba avisos sobre nuevas solicitudes de recepción cuando VniDrop está en segundo plano.", - "it": "Ricevi avvisi sulle nuove richieste di ricezione quando VniDrop è in background.", - "de": "Werden Sie über neue Empfangsanfragen benachrichtigt, während VniDrop im Hintergrund läuft.", - "pt": "Seja notificado sobre novos pedidos de receção enquanto o VniDrop está em segundo plano.", - "pl": "Otrzymuj powiadomienia o nowych prośbach o odbiór, gdy VniDrop działa w tle.", - "nl": "Ontvang meldingen over nieuwe ontvangstverzoeken terwijl VniDrop op de achtergrond draait.", - "ru": "Получайте уведомления о новых запросах на получение, пока VniDrop работает в фоне." + "en": "Get notified about transfer activity while VniDrop is in the background.", + "fr": "Soyez averti de l’activité des transferts lorsque VniDrop est en arrière-plan.", + "es": "Reciba avisos sobre la actividad de las transferencias cuando VniDrop está en segundo plano.", + "it": "Ricevi avvisi sull’attività dei trasferimenti quando VniDrop è in background.", + "de": "Werden Sie über Übertragungsaktivitäten benachrichtigt, während VniDrop im Hintergrund läuft.", + "pt": "Seja notificado sobre a atividade das transferências enquanto o VniDrop está em segundo plano.", + "pl": "Otrzymuj powiadomienia o aktywności transferów, gdy VniDrop działa w tle.", + "nl": "Ontvang meldingen over overdrachtsactiviteit terwijl VniDrop op de achtergrond draait.", + "ru": "Получайте уведомления об активности передач, пока VniDrop работает в фоне." } }, "notifications_enabled_message": { @@ -1799,6 +1838,184 @@ "ru": "Уведомления отключены для VniDrop. Вы можете включить их в Настройках." } }, + "notifications_receive_completed_body": { + "context": "Notification body shown when an incoming transfer finishes downloading. {transferName} = transfer name.", + "args": [ + { + "name": "transferName", + "type": "string" + } + ], + "translations": { + "en": "“{transferName}” finished downloading.", + "fr": "« {transferName} » a fini de se télécharger.", + "es": "«{transferName}» terminó de descargarse.", + "it": "«{transferName}» è stato scaricato.", + "de": "„{transferName}“ wurde vollständig heruntergeladen.", + "pt": "«{transferName}» concluiu a transferência.", + "pl": "Zakończono pobieranie „{transferName}”.", + "nl": "‘{transferName}’ is volledig gedownload.", + "ru": "«{transferName}» завершил загрузку." + } + }, + "notifications_receive_completed_title": { + "context": "Notification title shown when an incoming transfer finishes downloading.", + "translations": { + "en": "Download complete", + "fr": "Téléchargement terminé", + "es": "Descarga completada", + "it": "Download completato", + "de": "Download abgeschlossen", + "pt": "Transferência concluída", + "pl": "Pobieranie zakończone", + "nl": "Download voltooid", + "ru": "Загрузка завершена" + } + }, + "notifications_receive_failed_body": { + "context": "Notification body shown when an incoming transfer fails. {transferName} = transfer name.", + "args": [ + { + "name": "transferName", + "type": "string" + } + ], + "translations": { + "en": "“{transferName}” couldn’t be received.", + "fr": "« {transferName} » n’a pas pu être reçu.", + "es": "No se pudo recibir «{transferName}».", + "it": "Impossibile ricevere «{transferName}».", + "de": "„{transferName}“ konnte nicht empfangen werden.", + "pt": "Não foi possível receber «{transferName}».", + "pl": "Nie udało się odebrać „{transferName}”.", + "nl": "‘{transferName}’ kon niet worden ontvangen.", + "ru": "Не удалось получить «{transferName}»." + } + }, + "notifications_receive_failed_title": { + "context": "Notification title shown when an incoming transfer fails.", + "translations": { + "en": "Download failed", + "fr": "Échec du téléchargement", + "es": "Error en la descarga", + "it": "Download non riuscito", + "de": "Download fehlgeschlagen", + "pt": "Falha na transferência", + "pl": "Pobieranie nie powiodło się", + "nl": "Download mislukt", + "ru": "Ошибка загрузки" + } + }, + "notifications_receiver_completed_body": { + "context": "Notification body shown to the sender when a receiver finishes downloading a shared transfer. {receiver} = receiver name, {transferName} = transfer name.", + "args": [ + { + "name": "receiver", + "type": "string" + }, + { + "name": "transferName", + "type": "string" + } + ], + "translations": { + "en": "{receiver} finished receiving “{transferName}”.", + "fr": "{receiver} a fini de recevoir « {transferName} ».", + "es": "{receiver} terminó de recibir «{transferName}».", + "it": "{receiver} ha finito di ricevere «{transferName}».", + "de": "{receiver} hat „{transferName}“ vollständig empfangen.", + "pt": "{receiver} terminou de receber «{transferName}».", + "pl": "{receiver} zakończył odbieranie „{transferName}”.", + "nl": "{receiver} heeft ‘{transferName}’ volledig ontvangen.", + "ru": "{receiver} завершил получение «{transferName}»." + } + }, + "notifications_receiver_completed_title": { + "context": "Notification title shown to the sender when a receiver finishes downloading a shared transfer.", + "translations": { + "en": "Transfer received", + "fr": "Transfert reçu", + "es": "Transferencia recibida", + "it": "Trasferimento ricevuto", + "de": "Übertragung empfangen", + "pt": "Transferência recebida", + "pl": "Transfer odebrany", + "nl": "Overdracht ontvangen", + "ru": "Передача получена" + } + }, + "notifications_receiver_failed_body": { + "context": "Notification body shown to the sender when a receiver's download fails. {receiver} = receiver name, {transferName} = transfer name.", + "args": [ + { + "name": "receiver", + "type": "string" + }, + { + "name": "transferName", + "type": "string" + } + ], + "translations": { + "en": "{receiver} couldn't receive “{transferName}”", + "fr": "{receiver} n'a pas pu recevoir « {transferName} »", + "es": "{receiver} no pudo recibir «{transferName}»", + "it": "{receiver} non ha potuto ricevere “{transferName}”", + "de": "{receiver} konnte „{transferName}“ nicht empfangen", + "pt": "{receiver} não conseguiu receber “{transferName}”", + "pl": "{receiver} nie mógł odebrać „{transferName}”", + "nl": "{receiver} kon “{transferName}” niet ontvangen", + "ru": "{receiver} не удалось получить «{transferName}»" + } + }, + "notifications_receiver_failed_title": { + "context": "Notification title shown to the sender when a receiver's download fails.", + "translations": { + "en": "Delivery failed", + "fr": "Échec de l'envoi", + "es": "Error en la entrega", + "it": "Consegna non riuscita", + "de": "Übertragung fehlgeschlagen", + "pt": "Falha na entrega", + "pl": "Dostarczenie nie powiodło się", + "nl": "Levering mislukt", + "ru": "Ошибка доставки" + } + }, + "notifications_send_failed_body": { + "context": "Notification body shown to the sender when a shared transfer fails. {transferName} = transfer name.", + "args": [ + { + "name": "transferName", + "type": "string" + } + ], + "translations": { + "en": "“{transferName}” couldn’t be shared.", + "fr": "« {transferName} » n’a pas pu être partagé.", + "es": "No se pudo compartir «{transferName}».", + "it": "Impossibile condividere «{transferName}».", + "de": "„{transferName}“ konnte nicht geteilt werden.", + "pt": "Não foi possível partilhar «{transferName}».", + "pl": "Nie udało się udostępnić „{transferName}”.", + "nl": "‘{transferName}’ kon niet worden gedeeld.", + "ru": "Не удалось поделиться «{transferName}»." + } + }, + "notifications_send_failed_title": { + "context": "Notification title shown to the sender when a shared transfer fails.", + "translations": { + "en": "Sharing failed", + "fr": "Échec du partage", + "es": "Error al compartir", + "it": "Condivisione non riuscita", + "de": "Freigabe fehlgeschlagen", + "pt": "Falha na partilha", + "pl": "Udostępnianie nie powiodło się", + "nl": "Delen mislukt", + "ru": "Не удалось поделиться" + } + }, "notifications_settings_open_failed": { "context": "Settings > Notifications: error when the OS notification settings can't be opened.", "translations": { @@ -2212,23 +2429,23 @@ } }, "receive_delete_history_description": { - "context": "Receive history: confirmation body for removing one item. {arg1} = transfer name.", + "context": "Receive history: confirmation body for removing one item. {transferName} = transfer name.", "args": [ { - "name": "arg1", + "name": "transferName", "type": "string" } ], "translations": { - "en": "“{arg1}” will be removed from VniDrop’s history. The downloaded file will remain on this device.", - "fr": "« {arg1} » sera retiré de l’historique de VniDrop. Le fichier téléchargé restera sur cet appareil.", - "es": "«{arg1}» se eliminará del historial de VniDrop. El archivo descargado permanecerá en este dispositivo.", - "it": "«{arg1}» verrà rimosso dalla cronologia di VniDrop. Il file scaricato rimarrà su questo dispositivo.", - "de": "„{arg1}“ wird aus dem Verlauf von VniDrop entfernt. Die heruntergeladene Datei verbleibt auf diesem Gerät.", - "pt": "«{arg1}» será removido do histórico do VniDrop. O ficheiro descarregado permanecerá neste dispositivo.", - "pl": "„{arg1}” zostanie usunięty z historii VniDrop. Pobrany plik pozostanie na tym urządzeniu.", - "nl": "‘{arg1}’ wordt uit de geschiedenis van VniDrop verwijderd. Het gedownloade bestand blijft op dit apparaat.", - "ru": "«{arg1}» будет удалён из истории VniDrop. Загруженный файл останется на этом устройстве." + "en": "“{transferName}” will be removed from VniDrop’s history. The downloaded file will remain on this device.", + "fr": "« {transferName} » sera retiré de l’historique de VniDrop. Le fichier téléchargé restera sur cet appareil.", + "es": "«{transferName}» se eliminará del historial de VniDrop. El archivo descargado permanecerá en este dispositivo.", + "it": "«{transferName}» verrà rimosso dalla cronologia di VniDrop. Il file scaricato rimarrà su questo dispositivo.", + "de": "„{transferName}“ wird aus dem Verlauf von VniDrop entfernt. Die heruntergeladene Datei verbleibt auf diesem Gerät.", + "pt": "«{transferName}» será removido do histórico do VniDrop. O ficheiro descarregado permanecerá neste dispositivo.", + "pl": "„{transferName}” zostanie usunięty z historii VniDrop. Pobrany plik pozostanie na tym urządzeniu.", + "nl": "‘{transferName}’ wordt uit de geschiedenis van VniDrop verwijderd. Het gedownloade bestand blijft op dit apparaat.", + "ru": "«{transferName}» будет удалён из истории VniDrop. Загруженный файл останется на этом устройстве." } }, "receive_delete_history_item": { @@ -3381,6 +3598,206 @@ "ru": "Вычисление…" } }, + "storage_cleaning": { + "context": "Settings > Storage: free-up-space button while cleanup runs.", + "translations": { + "en": "Cleaning up…", + "fr": "Nettoyage…", + "es": "Limpiando…", + "it": "Pulizia…", + "de": "Wird bereinigt…", + "pt": "A limpar…", + "pl": "Czyszczenie…", + "nl": "Opschonen…", + "ru": "Очистка…" + } + }, + "storage_cleanup_busy": { + "context": "Settings > Storage: shown when cleanup is blocked by in-flight transfers.", + "translations": { + "en": "Finish active transfers before freeing up space", + "fr": "Terminez les transferts en cours avant de libérer de l'espace", + "es": "Finaliza las transferencias activas antes de liberar espacio", + "it": "Completa i trasferimenti attivi prima di liberare spazio", + "de": "Beende aktive Übertragungen, bevor du Speicher freigibst", + "pt": "Conclui as transferências ativas antes de libertar espaço", + "pl": "Zakończ aktywne transfery przed zwolnieniem miejsca", + "nl": "Voltooi actieve overdrachten voordat je ruimte vrijmaakt", + "ru": "Завершите активные передачи перед освобождением места" + } + }, + "storage_cleanup_freed": { + "context": "Settings > Storage: cleanup success. {size} = amount freed.", + "args": [ + { + "name": "size", + "type": "string" + } + ], + "translations": { + "en": "Freed {size}", + "fr": "{size} libéré", + "es": "Se liberó {size}", + "it": "Liberati {size}", + "de": "{size} freigegeben", + "pt": "Libertado {size}", + "pl": "Zwolniono {size}", + "nl": "{size} vrijgemaakt", + "ru": "Освобождено {size}" + } + }, + "storage_clear_transfer_cache": { + "context": "Settings > Storage: button that clears cached transfer content (KMP).", + "targets": [ + "kmp" + ], + "translations": { + "en": "Clear transfer cache", + "fr": "Vider le cache des transferts", + "es": "Borrar caché de transferencias", + "it": "Svuota cache trasferimenti", + "de": "Übertragungscache leeren", + "pt": "Limpar cache de transferências", + "pl": "Wyczyść pamięć podręczną transferów", + "nl": "Overdrachtscache wissen", + "ru": "Очистить кэш передач" + } + }, + "storage_clear_transfer_cache_description": { + "context": "Settings > Storage: description under the clear-transfer-cache button (KMP).", + "targets": [ + "kmp" + ], + "translations": { + "en": "Removes cached transfer content after briefly restarting VniDrop. Finish ongoing transfers and stop active shares first. Received files and transfer history are not deleted.", + "fr": "Supprime le contenu de transfert en cache qui n’est pas utilisé par une réception en cours ou un partage actif. Les fichiers reçus et l’historique ne sont pas supprimés.", + "es": "Elimina el contenido de transferencia en caché que no esté siendo utilizado por una recepción en curso o un recurso compartido activo. Los archivos recibidos y el historial no se eliminan.", + "it": "Rimuove il contenuto dei trasferimenti memorizzato nella cache che non è usato da una ricezione in corso o da una condivisione attiva. I file ricevuti e la cronologia non vengono eliminati.", + "de": "Entfernt zwischengespeicherte Übertragungsinhalte, die nicht von einem laufenden Empfang oder einer aktiven Freigabe verwendet werden. Empfangene Dateien und der Übertragungsverlauf werden nicht gelöscht.", + "pt": "Remove conteúdo de transferência em cache que não esteja a ser utilizado por uma receção em curso ou partilha ativa. Os ficheiros recebidos e o histórico não são eliminados.", + "pl": "Usuwa zawartość transferów z pamięci podręcznej, która nie jest używana przez trwające odbieranie ani aktywne udostępnianie. Odebrane pliki i historia nie są usuwane.", + "nl": "Verwijdert overdrachtsinhoud uit de cache die niet wordt gebruikt door een lopende ontvangst of actieve share. Ontvangen bestanden en de overdrachtsgeschiedenis worden niet verwijderd.", + "ru": "Удаляет кэшированное содержимое передач, которое не используется текущим приёмом или активной раздачей. Полученные файлы и история передач не удаляются." + } + }, + "storage_clearing_transfer_cache": { + "context": "Settings > Storage: clear-transfer-cache button while clearing is in progress (KMP).", + "targets": [ + "kmp" + ], + "translations": { + "en": "Clearing cache…", + "fr": "Vidage du cache…", + "es": "Borrando caché…", + "it": "Svuotamento cache…", + "de": "Cache wird geleert…", + "pt": "A limpar cache…", + "pl": "Czyszczenie pamięci podręcznej…", + "nl": "Cache wissen…", + "ru": "Очистка кэша…" + } + }, + "storage_delete_transfers_caption": { + "context": "Settings > Storage: caption under the destructive delete-all button.", + "translations": { + "en": "Clears your send and receive history and the app’s cached share content. Received files on disk are kept.", + "fr": "Efface votre historique d’envois et de réceptions ainsi que le contenu de partage mis en cache par l’app. Les fichiers reçus sur le disque sont conservés.", + "es": "Borra tu historial de envíos y recepciones y el contenido compartido en caché de la app. Los archivos recibidos en el disco se conservan.", + "it": "Cancella la cronologia di invii e ricezioni e i contenuti di condivisione memorizzati dall’app. I file ricevuti sul disco vengono mantenuti.", + "de": "Löscht deinen Sende- und Empfangsverlauf sowie die zwischengespeicherten Freigabeinhalte der App. Empfangene Dateien auf dem Datenträger bleiben erhalten.", + "pt": "Limpa o teu histórico de envios e receções e o conteúdo de partilha em cache da app. Os ficheiros recebidos no disco são mantidos.", + "pl": "Czyści historię wysyłania i odbierania oraz zapisane w pamięci podręcznej udostępniane treści. Odebrane pliki na dysku zostają zachowane.", + "nl": "Wist je verzend- en ontvangstgeschiedenis en de gecachte deelinhoud van de app. Ontvangen bestanden op schijf blijven behouden.", + "ru": "Очищает историю отправки и получения и кэшированное содержимое общих ресурсов. Полученные файлы на диске сохраняются." + } + }, + "storage_free_up_space_caption": { + "context": "Settings > Storage: caption under the free-up-space button.", + "translations": { + "en": "Removes temporary files and leftover trash from earlier transfers. Your transfers and received files are kept.", + "fr": "Supprime les fichiers temporaires et les résidus des transferts précédents. Vos transferts et fichiers reçus sont conservés.", + "es": "Elimina los archivos temporales y los restos de transferencias anteriores. Tus transferencias y archivos recibidos se conservan.", + "it": "Rimuove i file temporanei e i residui dei trasferimenti precedenti. I tuoi trasferimenti e i file ricevuti vengono mantenuti.", + "de": "Entfernt temporäre Dateien und Reste früherer Übertragungen. Deine Übertragungen und empfangenen Dateien bleiben erhalten.", + "pt": "Remove ficheiros temporários e resíduos de transferências anteriores. As tuas transferências e ficheiros recebidos são mantidos.", + "pl": "Usuwa pliki tymczasowe i pozostałości po wcześniejszych transferach. Twoje transfery i odebrane pliki zostają zachowane.", + "nl": "Verwijdert tijdelijke bestanden en resten van eerdere overdrachten. Je overdrachten en ontvangen bestanden blijven behouden.", + "ru": "Удаляет временные файлы и остатки прошлых передач. Ваши передачи и полученные файлы сохраняются." + } + }, + "storage_refresh": { + "context": "Settings > Storage: label for the button that recalculates usage.", + "translations": { + "en": "Refresh", + "fr": "Actualiser", + "es": "Actualizar", + "it": "Aggiorna", + "de": "Aktualisieren", + "pt": "Atualizar", + "pl": "Odśwież", + "nl": "Vernieuwen", + "ru": "Обновить" + } + }, + "storage_transfer_cache_cleared": { + "context": "Settings > Storage: confirmation that the transfer cache was cleared (KMP).", + "targets": [ + "kmp" + ], + "translations": { + "en": "Transfer cache cleared", + "fr": "Cache des transferts vidé", + "es": "Caché de transferencias borrada", + "it": "Cache trasferimenti svuotata", + "de": "Übertragungscache geleert", + "pt": "Cache de transferências limpa", + "pl": "Wyczyszczono pamięć podręczną transferów", + "nl": "Overdrachtscache gewist", + "ru": "Кэш передач очищен" + } + }, + "storage_unavailable": { + "context": "Settings > Storage: shown when usage couldn't be calculated yet.", + "translations": { + "en": "Storage usage isn't available yet", + "fr": "L'utilisation du stockage n'est pas encore disponible", + "es": "El uso de almacenamiento aún no está disponible", + "it": "L'utilizzo dello spazio non è ancora disponibile", + "de": "Die Speichernutzung ist noch nicht verfügbar", + "pt": "A utilização do armazenamento ainda não está disponível", + "pl": "Wykorzystanie pamięci nie jest jeszcze dostępne", + "nl": "Opslaggebruik is nog niet beschikbaar", + "ru": "Данные об использовании хранилища пока недоступны" + } + }, + "storage_usage_header": { + "context": "Settings > Storage: header above the usage breakdown.", + "translations": { + "en": "On this device", + "fr": "Sur cet appareil", + "es": "En este dispositivo", + "it": "Su questo dispositivo", + "de": "Auf diesem Gerät", + "pt": "Neste dispositivo", + "pl": "Na tym urządzeniu", + "nl": "Op dit apparaat", + "ru": "На этом устройстве" + } + }, + "storage_free_up_space": { + "context": "Settings > Storage: button that clears temporary files and stray trash.", + "translations": { + "en": "Free up space", + "fr": "Libérer de l'espace", + "es": "Liberar espacio", + "it": "Libera spazio", + "de": "Speicher freigeben", + "pt": "Libertar espaço", + "pl": "Zwolnij miejsce", + "nl": "Ruimte vrijmaken", + "ru": "Освободить место" + } + }, "storage_delete_transfers": { "context": "Settings > Storage: button to delete all transfer records.", "translations": { @@ -3398,15 +3815,15 @@ "storage_delete_transfers_description": { "context": "Settings > Storage: confirmation body for deleting all transfer records.", "translations": { - "en": "This clears all sent and received transfer records from your history. Your received files are not deleted. Cached shared content that is no longer needed is reclaimed automatically, which may take a little time. This can’t be undone.", - "fr": "Cela efface de votre historique tous les enregistrements de transferts envoyés et reçus. Vos fichiers reçus ne sont pas supprimés. Le contenu partagé mis en cache qui n’est plus nécessaire est récupéré automatiquement, ce qui peut prendre un peu de temps. Cette action est irréversible.", - "es": "Esto borra de su historial todos los registros de transferencias enviadas y recibidas. Sus archivos recibidos no se eliminan. El contenido compartido en caché que ya no se necesita se recupera automáticamente, lo que puede tardar un poco. Esto no se puede deshacer.", - "it": "Questo cancella dalla cronologia tutti i record dei trasferimenti inviati e ricevuti. I file ricevuti non vengono eliminati. Il contenuto condiviso nella cache che non serve più viene recuperato automaticamente, operazione che può richiedere un po’ di tempo. Questa azione non può essere annullata.", - "de": "Dadurch werden alle Datensätze gesendeter und empfangener Übertragungen aus Ihrem Verlauf gelöscht. Ihre empfangenen Dateien werden nicht gelöscht. Nicht mehr benötigte zwischengespeicherte freigegebene Inhalte werden automatisch bereinigt; dies kann etwas dauern. Dies kann nicht rückgängig gemacht werden.", - "pt": "Isto elimina do histórico todos os registos de transferências enviadas e recebidas. Os ficheiros recebidos não são eliminados. O conteúdo partilhado em cache que já não é necessário é recuperado automaticamente, o que pode demorar algum tempo. Esta ação não pode ser anulada.", - "pl": "Spowoduje to usunięcie z historii wszystkich rekordów wysłanych i odebranych transferów. Odebrane pliki nie zostaną usunięte. Niepotrzebna już zawartość udostępniona w pamięci podręcznej jest odzyskiwana automatycznie, co może chwilę potrwać. Tej operacji nie można cofnąć.", - "nl": "Hiermee worden alle records van verzonden en ontvangen overdrachten uit uw geschiedenis gewist. Uw ontvangen bestanden worden niet verwijderd. Gedeelde inhoud in de cache die niet meer nodig is, wordt automatisch opgeruimd; dit kan enige tijd duren. Dit kan niet ongedaan worden gemaakt.", - "ru": "Это удалит из истории все записи об отправленных и полученных передачах. Полученные файлы не удаляются. Кэшированное общее содержимое, которое больше не требуется, освобождается автоматически; это может занять некоторое время. Это действие нельзя отменить." + "en": "This clears all sent and received transfer records from your history and immediately reclaims unused transfer cache. Ongoing transfers and received files are not deleted. This can’t be undone.", + "fr": "Cela efface tous les transferts envoyés et reçus de l’historique et libère immédiatement le cache inutilisé. Les transferts en cours et les fichiers reçus ne sont pas supprimés. Cette action est irréversible.", + "es": "Esto borra del historial todos los registros de transferencias enviadas y recibidas y libera inmediatamente la caché de transferencia no utilizada. Las transferencias en curso y los archivos recibidos no se eliminan. Esto no se puede deshacer.", + "it": "Elimina dalla cronologia tutti i trasferimenti inviati e ricevuti e libera immediatamente la cache inutilizzata. I trasferimenti in corso e i file ricevuti non vengono eliminati. Questa azione non può essere annullata.", + "de": "Dadurch werden alle Datensätze gesendeter und empfangener Übertragungen aus Ihrem Verlauf gelöscht und nicht benötigter Übertragungscache sofort freigegeben. Laufende Übertragungen und empfangene Dateien werden nicht gelöscht. Dies kann nicht rückgängig gemacht werden.", + "pt": "Isto elimina do histórico todas as transferências enviadas e recebidas e liberta imediatamente a cache não utilizada. As transferências em curso e os ficheiros recebidos não são eliminados. Esta ação não pode ser anulada.", + "pl": "Usuwa z historii wszystkie wysłane i odebrane transfery oraz natychmiast zwalnia nieużywaną pamięć podręczną. Trwające transfery i odebrane pliki nie są usuwane. Tej operacji nie można cofnąć.", + "nl": "Hiermee worden alle verzonden en ontvangen overdrachten uit de geschiedenis gewist en wordt ongebruikte overdrachtscache direct vrijgemaakt. Lopende overdrachten en ontvangen bestanden worden niet verwijderd. Dit kan niet ongedaan worden gemaakt.", + "ru": "Это удалит из истории все отправленные и полученные передачи и немедленно освободит неиспользуемый кэш. Текущие передачи и полученные файлы не удаляются. Это действие нельзя отменить." } }, "storage_app_data": { @@ -3564,23 +3981,23 @@ } }, "transfer_delete_description": { - "context": "Transfer details: confirmation body for deleting a transfer. {arg1} = transfer name.", + "context": "Transfer details: confirmation body for deleting a transfer. {transferName} = transfer name.", "args": [ { - "name": "arg1", + "name": "transferName", "type": "string" } ], "translations": { - "en": "“{arg1}” will stop being shared and its transfer history will be removed from this device.", - "fr": "« {arg1} » cessera d’être partagé et son historique de transfert sera retiré de cet appareil.", - "es": "«{arg1}» dejará de compartirse y su historial de transferencia se eliminará de este dispositivo.", - "it": "«{arg1}» non verrà più condiviso e la sua cronologia di trasferimento verrà rimossa da questo dispositivo.", - "de": "„{arg1}“ wird nicht mehr geteilt und sein Übertragungsverlauf wird von diesem Gerät entfernt.", - "pt": "«{arg1}» deixará de ser partilhado e o seu histórico de transferência será removido deste dispositivo.", - "pl": "„{arg1}” przestanie być udostępniany, a jego historia transferu zostanie usunięta z tego urządzenia.", - "nl": "‘{arg1}’ wordt niet meer gedeeld en de overdrachtsgeschiedenis wordt van dit apparaat verwijderd.", - "ru": "Общий доступ к «{arg1}» будет остановлен, а история передачи будет удалена с этого устройства." + "en": "“{transferName}” will stop being shared and its transfer history will be removed from this device.", + "fr": "« {transferName} » cessera d’être partagé et son historique de transfert sera retiré de cet appareil.", + "es": "«{transferName}» dejará de compartirse y su historial de transferencia se eliminará de este dispositivo.", + "it": "«{transferName}» non verrà più condiviso e la sua cronologia di trasferimento verrà rimossa da questo dispositivo.", + "de": "„{transferName}“ wird nicht mehr geteilt und sein Übertragungsverlauf wird von diesem Gerät entfernt.", + "pt": "«{transferName}» deixará de ser partilhado e o seu histórico de transferência será removido deste dispositivo.", + "pl": "„{transferName}” przestanie być udostępniany, a jego historia transferu zostanie usunięta z tego urządzenia.", + "nl": "‘{transferName}’ wordt niet meer gedeeld en de overdrachtsgeschiedenis wordt van dit apparaat verwijderd.", + "ru": "Общий доступ к «{transferName}» будет остановлен, а история передачи будет удалена с этого устройства." } }, "transfer_delete_title": { @@ -3998,6 +4415,20 @@ "ru": "Запрос истёк" } }, + "transfer_receiver_failed": { + "context": "Receiver status: the delivery to this receiver failed.", + "translations": { + "en": "Delivery failed", + "fr": "Échec de l'envoi", + "es": "Error en la entrega", + "it": "Consegna non riuscita", + "de": "Übertragung fehlgeschlagen", + "pt": "Falha na entrega", + "pl": "Dostarczenie nie powiodło się", + "nl": "Levering mislukt", + "ru": "Ошибка доставки" + } + }, "transfer_receiver_refused": { "context": "Receiver status: the request was refused.", "translations": { diff --git a/shared/AGENTS.md b/shared/AGENTS.md index 6add30a..ab70c02 100644 --- a/shared/AGENTS.md +++ b/shared/AGENTS.md @@ -32,7 +32,7 @@ lists, animation, accessibility: | Architecture | Keep **MVVM-style** ViewModels: immutable `*State`, `StateFlow`, **named methods**. Do not force MVI `onEvent` sealed hierarchies unless asked. | | Structure | Feature packages under `com.vnidrop.app.feature.*`; thin route/wiring + screen/composables. | | Theme | Only `LocalVniDropColors` / `VniDropThemeTokens` (`ui/theme/VniDropTheme.kt`). Brand primary light ≈ `#A855F7` (HSL 271, 91%, 65%). | -| Strings | CMP composeResources / `Res.string.*` — not Android `R` in `commonMain`. | +| Strings | CMP composeResources / `Res.string.*` — not Android `R` in `commonMain`. `values*/strings.xml` are **generated** from `localization/strings.json` (source of truth) via the loc CLI — add/edit keys there, never in the XML. | | DI | Follow existing `AppGraph` construction; no unprompted Hilt/Koin migration. | | Platform | `androidMain` / `jvmMain` for pickers, SAF, NFC/QR, and desktop integration. | | Dependencies | Before adding Jetpack/AndroidX to `commonMain`, verify multiplatform artifacts for all targets. | diff --git a/shared/src/androidMain/kotlin/com/vnidrop/app/core/FileSystemService.android.kt b/shared/src/androidMain/kotlin/com/vnidrop/app/core/FileSystemService.android.kt index 9bb4d11..02d9d2c 100644 --- a/shared/src/androidMain/kotlin/com/vnidrop/app/core/FileSystemService.android.kt +++ b/shared/src/androidMain/kotlin/com/vnidrop/app/core/FileSystemService.android.kt @@ -105,6 +105,30 @@ private class AndroidFileSystemService( override suspend fun temporaryUsage(receiveFolder: ReceiveFolder): ULong = directorySize(context.cacheDir) + override suspend fun reclaimTemporaryStorage(appDataDir: String, receiveFolder: ReceiveFolder): ULong { + var reclaimed = 0UL + context.cacheDir.listFiles().orEmpty().forEach { entry -> + val size = if (entry.isDirectory) directorySize(entry) else entry.length().coerceAtLeast(0L).toULong() + if (entry.deleteRecursively()) reclaimed += size + } + val appDataRoot = File(appDataDir) + val appDataIsOwned = runCatching { + val appDataPath = appDataRoot.canonicalPath + val filesPath = context.filesDir.canonicalPath + appDataPath == filesPath || appDataPath.startsWith(filesPath + File.separator) + }.getOrDefault(false) + if (appDataIsOwned) { + appDataRoot.walkTopDown() + .filter { it.isDirectory && it.name == ".Trash" } + .toList() + .forEach { trash -> + val size = directorySize(trash) + if (trash.deleteRecursively()) reclaimed += size + } + } + return reclaimed + } + override fun createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSinkV2? = when (folder.kind) { ReceiveFolderKind.AndroidPublicDownloads -> AndroidMediaStoreDownloadsSink(context) diff --git a/shared/src/commonMain/composeResources/drawable/icon_fluent_more_vertical.xml b/shared/src/commonMain/composeResources/drawable/icon_fluent_more_vertical.xml new file mode 100644 index 0000000..b61b8b9 --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/icon_fluent_more_vertical.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/shared/src/commonMain/composeResources/drawable/icon_fluent_share.xml b/shared/src/commonMain/composeResources/drawable/icon_fluent_share.xml new file mode 100644 index 0000000..06524e7 --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/icon_fluent_share.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/shared/src/commonMain/composeResources/drawable/icon_fluent_sparkles.xml b/shared/src/commonMain/composeResources/drawable/icon_fluent_sparkles.xml new file mode 100644 index 0000000..9119ff9 --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/icon_fluent_sparkles.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/shared/src/commonMain/composeResources/drawable/icon_fluent_stop_circle.xml b/shared/src/commonMain/composeResources/drawable/icon_fluent_stop_circle.xml new file mode 100644 index 0000000..f49022d --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/icon_fluent_stop_circle.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/shared/src/commonMain/composeResources/drawable/icon_lucide_more_vertical.xml b/shared/src/commonMain/composeResources/drawable/icon_lucide_more_vertical.xml new file mode 100644 index 0000000..8b1de96 --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/icon_lucide_more_vertical.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/shared/src/commonMain/composeResources/drawable/icon_lucide_share.xml b/shared/src/commonMain/composeResources/drawable/icon_lucide_share.xml new file mode 100644 index 0000000..d56f3d9 --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/icon_lucide_share.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/shared/src/commonMain/composeResources/drawable/icon_lucide_sparkles.xml b/shared/src/commonMain/composeResources/drawable/icon_lucide_sparkles.xml new file mode 100644 index 0000000..f50080a --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/icon_lucide_sparkles.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/shared/src/commonMain/composeResources/drawable/icon_lucide_stop_circle.xml b/shared/src/commonMain/composeResources/drawable/icon_lucide_stop_circle.xml new file mode 100644 index 0000000..611df28 --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/icon_lucide_stop_circle.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/shared/src/commonMain/composeResources/drawable/icon_material_more_vertical.xml b/shared/src/commonMain/composeResources/drawable/icon_material_more_vertical.xml new file mode 100644 index 0000000..dcd9c8a --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/icon_material_more_vertical.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/shared/src/commonMain/composeResources/drawable/icon_material_share.xml b/shared/src/commonMain/composeResources/drawable/icon_material_share.xml new file mode 100644 index 0000000..913817b --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/icon_material_share.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/shared/src/commonMain/composeResources/drawable/icon_material_sparkles.xml b/shared/src/commonMain/composeResources/drawable/icon_material_sparkles.xml new file mode 100644 index 0000000..00d1712 --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/icon_material_sparkles.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/shared/src/commonMain/composeResources/drawable/icon_material_stop_circle.xml b/shared/src/commonMain/composeResources/drawable/icon_material_stop_circle.xml new file mode 100644 index 0000000..ab13130 --- /dev/null +++ b/shared/src/commonMain/composeResources/drawable/icon_material_stop_circle.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/shared/src/commonMain/composeResources/values-de/strings.xml b/shared/src/commonMain/composeResources/values-de/strings.xml index 00dbdbc..4e18485 100644 --- a/shared/src/commonMain/composeResources/values-de/strings.xml +++ b/shared/src/commonMain/composeResources/values-de/strings.xml @@ -22,6 +22,7 @@ Datenschutz & Sicherheit Senden Sie Dateien direkt. Behalten Sie die Kontrolle darüber, wer sie empfängt. Über + Wird gestartet… Der hellen oder dunklen Darstellung dieses Geräts folgen. Dunkelmodus Hellmodus @@ -54,6 +55,7 @@ Was ist passiert? Genehmigen Zurück + Weitere Aktionen Abbrechen Abbrechen Dateien ändern @@ -117,10 +119,20 @@ Senden Einstellungen Netzwerk - Werden Sie über neue Empfangsanfragen benachrichtigt, während VniDrop im Hintergrund läuft. + Werden Sie über Übertragungsaktivitäten benachrichtigt, während VniDrop im Hintergrund läuft. Mitteilungen aktiviert. Mitteilungen erlauben Mitteilungen sind für VniDrop deaktiviert. Sie können sie in den Einstellungen aktivieren. + „%1$s“ wurde vollständig heruntergeladen. + Download abgeschlossen + „%1$s“ konnte nicht empfangen werden. + Download fehlgeschlagen + %1$s hat „%2$s“ vollständig empfangen. + Übertragung empfangen + %1$s konnte „%2$s“ nicht empfangen + Übertragung fehlgeschlagen + „%1$s“ konnte nicht geteilt werden. + Freigabe fehlgeschlagen Die Mitteilungseinstellungen konnten nicht geöffnet werden. Mitteilungen Mitteilungen sind auf diesem Gerät nicht verfügbar. @@ -175,7 +187,7 @@ Beenden Sie alle aktiven Übertragungen und Freigaben, bevor Sie die Netzwerkeinstellungen anwenden. Diese Einstellungen konnten nicht angewendet werden. Die vorherigen Netzwerkeinstellungen wurden wiederhergestellt. Beim Anwenden wird die Netzwerkverbindung von VniDrop neu gestartet. Beenden Sie zuerst aktive Übertragungen und Freigaben. Vorhandene Einladungen müssen eventuell erneut geteilt werden. - Fügen Sie jede HTTPS-Relay-URL separat hinzu. Anmeldedaten in URLs werden nicht unterstützt. Das TLS-Zertifikat muss von einer öffentlich vertrauenswürdigen Zertifizierungsstelle ausgestellt sein. + Geben Sie pro Zeile eine HTTPS-Relay-URL ein. Anmeldedaten in URLs werden nicht unterstützt. Das TLS-Zertifikat muss von einer öffentlich vertrauenswürdigen Zertifizierungsstelle ausgestellt sein. Relay-URLs Automatisch (empfohlen) Verwendet die öffentliche Standard-Relay-Infrastruktur von VniDrop, wenn keine direkte Verbindung möglich ist. @@ -231,20 +243,29 @@ Wird empfangen Beendet Wird berechnet… + Wird bereinigt… + Beende aktive Übertragungen, bevor du Speicher freigibst + %1$s freigegeben Übertragungscache leeren Entfernt zwischengespeicherte Übertragungsinhalte, die nicht von einem laufenden Empfang oder einer aktiven Freigabe verwendet werden. Empfangene Dateien und der Übertragungsverlauf werden nicht gelöscht. Cache wird geleert… + Löscht deinen Sende- und Empfangsverlauf sowie die zwischengespeicherten Freigabeinhalte der App. Empfangene Dateien auf dem Datenträger bleiben erhalten. + Entfernt temporäre Dateien und Reste früherer Übertragungen. Deine Übertragungen und empfangenen Dateien bleiben erhalten. + Aktualisieren + Übertragungscache geleert + Die Speichernutzung ist noch nicht verfügbar + Auf diesem Gerät + Speicher freigeben Alle Übertragungen löschen Dadurch werden alle Datensätze gesendeter und empfangener Übertragungen aus Ihrem Verlauf gelöscht und nicht benötigter Übertragungscache sofort freigegeben. Laufende Übertragungen und empfangene Dateien werden nicht gelöscht. Dies kann nicht rückgängig gemacht werden. App-Daten Wird gelöscht… - Der Übertragungscache enthält heruntergeladene oder importierte Inhalte, einschließlich Daten, die durch laufende Empfänge und aktive Freigaben geschützt sind. Beim Leeren wird nur nicht benötigter Cache entfernt. Empfangene Dateien werden hier niemals gelöscht. + Übertragungsdaten umfassen Ihren Verlauf und zwischengespeicherte Inhalte aktiver Freigaben. Nicht mehr benötigter Cache wird nach dem Löschen der Datensätze automatisch bereinigt; dies kann etwas dauern. Empfangene Dateien werden für diese Übersicht erfasst, aber hier niemals gelöscht. Empfangene Dateien Temporäre Dateien Speicher Gesamt - Übertragungscache - Übertragungscache geleert + Übertragungsdaten Alle Übertragungen gelöscht Wichtige Aktualisierungen zu dieser Übertragung ansehen Aktivität @@ -274,8 +295,8 @@ Noch niemand hat diese Übertragung angefragt. Genehmigt – wartet auf Abschluss Erfolgreich empfangen - Übertragung fehlgeschlagen Anfrage abgelaufen + Übertragung fehlgeschlagen Anfrage abgelehnt Wartet auf Ihre Genehmigung Status nicht verfügbar diff --git a/shared/src/commonMain/composeResources/values-es/strings.xml b/shared/src/commonMain/composeResources/values-es/strings.xml index 24bc04c..d8112f2 100644 --- a/shared/src/commonMain/composeResources/values-es/strings.xml +++ b/shared/src/commonMain/composeResources/values-es/strings.xml @@ -22,6 +22,7 @@ Privacidad y seguridad Envíe archivos directamente. Mantenga el control de quién los recibe. Acerca de + Iniciando… Seguir la apariencia clara u oscura de este dispositivo. Modo oscuro Modo claro @@ -54,6 +55,7 @@ ¿Qué ocurrió? Aprobar Atrás + Más acciones Cancelar Cancelar Cambiar archivos @@ -117,10 +119,20 @@ Enviar Ajustes Red - Reciba avisos sobre nuevas solicitudes de recepción cuando VniDrop está en segundo plano. + Reciba avisos sobre la actividad de las transferencias cuando VniDrop está en segundo plano. Notificaciones activadas. Permitir notificaciones Las notificaciones están desactivadas para VniDrop. Puede activarlas en Ajustes. + «%1$s» terminó de descargarse. + Descarga completada + No se pudo recibir «%1$s». + Error en la descarga + %1$s terminó de recibir «%2$s». + Transferencia recibida + %1$s no pudo recibir «%2$s» + Error en la entrega + No se pudo compartir «%1$s». + Error al compartir No se pudieron abrir los ajustes de notificaciones. Notificaciones Las notificaciones no están disponibles en este dispositivo. @@ -175,7 +187,7 @@ Detenga todas las transferencias y elementos compartidos activos antes de aplicar los ajustes de red. No se han podido aplicar estos ajustes. Se han restaurado los ajustes de red anteriores. Al aplicar los ajustes, se reinicia la conexión de red de VniDrop. Detenga primero las transferencias y los elementos compartidos activos. Es posible que tenga que volver a compartir las invitaciones existentes. - Añada cada URL HTTPS de relé por separado. No se admiten credenciales en las URL. El certificado TLS debe ser emitido por una autoridad de certificación de confianza pública. + Introduzca una URL HTTPS de relé por línea. No se admiten credenciales en las URL. El certificado TLS debe ser emitido por una autoridad de certificación de confianza pública. URL de relés Automático (recomendado) Usa la infraestructura pública de relés predeterminada de VniDrop cuando no haya una conexión directa disponible. @@ -231,20 +243,29 @@ Recibiendo Detenido Calculando… + Limpiando… + Finaliza las transferencias activas antes de liberar espacio + Se liberó %1$s Borrar caché de transferencias Elimina el contenido de transferencia en caché que no esté siendo utilizado por una recepción en curso o un recurso compartido activo. Los archivos recibidos y el historial no se eliminan. Borrando caché… + Borra tu historial de envíos y recepciones y el contenido compartido en caché de la app. Los archivos recibidos en el disco se conservan. + Elimina los archivos temporales y los restos de transferencias anteriores. Tus transferencias y archivos recibidos se conservan. + Actualizar + Caché de transferencias borrada + El uso de almacenamiento aún no está disponible + En este dispositivo + Liberar espacio Eliminar todas las transferencias Esto borra del historial todos los registros de transferencias enviadas y recibidas y libera inmediatamente la caché de transferencia no utilizada. Las transferencias en curso y los archivos recibidos no se eliminan. Esto no se puede deshacer. Datos de la aplicación Eliminando… - La caché de transferencias incluye contenido descargado o importado, incluidos los datos protegidos por recepciones en curso y recursos compartidos activos. Al borrarla solo se elimina la caché no utilizada. Los archivos recibidos nunca se eliminan aquí. + Los datos de transferencia incluyen su historial y el contenido en caché de los recursos compartidos activos. La caché innecesaria se recupera automáticamente tras eliminar los registros, lo que puede tardar un poco. Los archivos recibidos se registran para este resumen, pero nunca se eliminan aquí. Archivos recibidos Archivos temporales Almacenamiento Total - Caché de transferencias - Caché de transferencias borrada + Datos de transferencia Todas las transferencias eliminadas Vea las actualizaciones importantes de esta transferencia Actividad @@ -274,8 +295,8 @@ Nadie ha solicitado aún esta transferencia. Aprobado: esperando a que se complete Recibido correctamente - Transferencia fallida Solicitud caducada + Error en la entrega Solicitud rechazada Esperando su aprobación Estado no disponible diff --git a/shared/src/commonMain/composeResources/values-fr/strings.xml b/shared/src/commonMain/composeResources/values-fr/strings.xml index 6d239e5..871fcc8 100644 --- a/shared/src/commonMain/composeResources/values-fr/strings.xml +++ b/shared/src/commonMain/composeResources/values-fr/strings.xml @@ -22,6 +22,7 @@ Confidentialité et sécurité Envoyez des fichiers directement. Gardez le contrôle de qui les reçoit. À propos + Démarrage… Suivre l’apparence claire ou sombre de cet appareil. Mode sombre Mode clair @@ -54,6 +55,7 @@ Que s’est-il passé ? Approuver Retour + Plus d’actions Annuler Annuler Modifier les fichiers @@ -117,10 +119,20 @@ Envoyer Réglages Réseau - Soyez averti des nouvelles demandes de réception lorsque VniDrop est en arrière-plan. + Soyez averti de l’activité des transferts lorsque VniDrop est en arrière-plan. Notifications activées. Autoriser les notifications Les notifications sont désactivées pour VniDrop. Vous pouvez les activer dans les Réglages. + « %1$s » a fini de se télécharger. + Téléchargement terminé + « %1$s » n’a pas pu être reçu. + Échec du téléchargement + %1$s a fini de recevoir « %2$s ». + Transfert reçu + %1$s n\'a pas pu recevoir « %2$s » + Échec de l\'envoi + « %1$s » n’a pas pu être partagé. + Échec du partage Impossible d’ouvrir les réglages de notifications. Notifications Les notifications ne sont pas disponibles sur cet appareil. @@ -175,7 +187,7 @@ Arrêtez tous les transferts et partages actifs avant d’appliquer les réglages réseau. Impossible d’appliquer ces réglages. Les réglages réseau précédents ont été restaurés. L’application de ces réglages redémarre la connexion réseau de VniDrop. Arrêtez d’abord les transferts et partages actifs. Il peut être nécessaire de partager à nouveau les invitations existantes. - Ajoutez chaque URL de relais HTTPS séparément. Les identifiants dans les URL ne sont pas pris en charge. Le certificat TLS doit être émis par une autorité de certification reconnue publiquement. + Saisissez une URL de relais HTTPS par ligne. Les identifiants dans les URL ne sont pas pris en charge. Le certificat TLS doit être émis par une autorité de certification reconnue publiquement. URL des relais Automatique (recommandé) Utiliser l’infrastructure de relais publique par défaut de VniDrop lorsqu’une connexion directe est indisponible. @@ -231,20 +243,29 @@ Réception Arrêté Calcul… + Nettoyage… + Terminez les transferts en cours avant de libérer de l\'espace + %1$s libéré Vider le cache des transferts Supprime le contenu de transfert en cache qui n’est pas utilisé par une réception en cours ou un partage actif. Les fichiers reçus et l’historique ne sont pas supprimés. Vidage du cache… + Efface votre historique d’envois et de réceptions ainsi que le contenu de partage mis en cache par l’app. Les fichiers reçus sur le disque sont conservés. + Supprime les fichiers temporaires et les résidus des transferts précédents. Vos transferts et fichiers reçus sont conservés. + Actualiser + Cache des transferts vidé + L\'utilisation du stockage n\'est pas encore disponible + Sur cet appareil + Libérer de l\'espace Supprimer tous les transferts Cela efface tous les transferts envoyés et reçus de l’historique et libère immédiatement le cache inutilisé. Les transferts en cours et les fichiers reçus ne sont pas supprimés. Cette action est irréversible. Données de l’app Suppression… - Le cache des transferts comprend le contenu téléchargé ou importé, y compris les données protégées par les réceptions en cours et les partages actifs. Le vider ne supprime que le cache inutilisé. Les fichiers reçus ne sont jamais supprimés ici. + Les données de transfert comprennent votre historique et le contenu mis en cache pour les partages actifs. Le cache inutile est récupéré automatiquement après la suppression des enregistrements, ce qui peut prendre un peu de temps. Les fichiers reçus sont suivis pour ce récapitulatif, mais ne sont jamais supprimés ici. Fichiers reçus Fichiers temporaires Stockage Total - Cache des transferts - Cache des transferts vidé + Données de transfert Tous les transferts supprimés Consultez les mises à jour importantes de ce transfert Activité @@ -274,8 +295,8 @@ Personne n’a encore demandé ce transfert. Approuvé — en attente de la fin Reçu avec succès - Transfert échoué Demande expirée + Échec de l\'envoi Demande refusée En attente de votre approbation Statut indisponible diff --git a/shared/src/commonMain/composeResources/values-it/strings.xml b/shared/src/commonMain/composeResources/values-it/strings.xml index f6dbcb7..d8aab31 100644 --- a/shared/src/commonMain/composeResources/values-it/strings.xml +++ b/shared/src/commonMain/composeResources/values-it/strings.xml @@ -22,6 +22,7 @@ Privacy e sicurezza Invii file direttamente. Mantenga il controllo su chi li riceve. Informazioni + Avvio… Segue l’aspetto chiaro o scuro di questo dispositivo. Modalità scura Modalità chiara @@ -54,6 +55,7 @@ Cosa è accaduto? Approva Indietro + Altre azioni Annulla Annulla Cambia file @@ -117,10 +119,20 @@ Invia Impostazioni Rete - Ricevi avvisi sulle nuove richieste di ricezione quando VniDrop è in background. + Ricevi avvisi sull’attività dei trasferimenti quando VniDrop è in background. Notifiche attivate. Consenti le notifiche Le notifiche sono disattivate per VniDrop. Può attivarle in Impostazioni. + «%1$s» è stato scaricato. + Download completato + Impossibile ricevere «%1$s». + Download non riuscito + %1$s ha finito di ricevere «%2$s». + Trasferimento ricevuto + %1$s non ha potuto ricevere “%2$s” + Consegna non riuscita + Impossibile condividere «%1$s». + Condivisione non riuscita Impossibile aprire le impostazioni delle notifiche. Notifiche Le notifiche non sono disponibili su questo dispositivo. @@ -175,7 +187,7 @@ Interrompa tutti i trasferimenti e le condivisioni attivi prima di applicare le impostazioni di rete. Impossibile applicare queste impostazioni. Sono state ripristinate le impostazioni di rete precedenti. L’applicazione riavvia la connessione di rete di VniDrop. Interrompa prima i trasferimenti e le condivisioni attivi. Potrebbe essere necessario condividere di nuovo gli inviti esistenti. - Aggiungi separatamente ogni URL relay HTTPS. Le credenziali negli URL non sono supportate. Il certificato TLS deve essere emesso da un’autorità di certificazione pubblicamente attendibile. + Inserisca un URL relay HTTPS per riga. Le credenziali negli URL non sono supportate. Il certificato TLS deve essere emesso da un’autorità di certificazione pubblicamente attendibile. URL relay Automatica (consigliata) Usa l’infrastruttura relay pubblica predefinita di VniDrop quando non è disponibile una connessione diretta. @@ -231,20 +243,29 @@ Ricezione Interrotto Calcolo… + Pulizia… + Completa i trasferimenti attivi prima di liberare spazio + Liberati %1$s Svuota cache trasferimenti Rimuove il contenuto dei trasferimenti memorizzato nella cache che non è usato da una ricezione in corso o da una condivisione attiva. I file ricevuti e la cronologia non vengono eliminati. Svuotamento cache… + Cancella la cronologia di invii e ricezioni e i contenuti di condivisione memorizzati dall’app. I file ricevuti sul disco vengono mantenuti. + Rimuove i file temporanei e i residui dei trasferimenti precedenti. I tuoi trasferimenti e i file ricevuti vengono mantenuti. + Aggiorna + Cache trasferimenti svuotata + L\'utilizzo dello spazio non è ancora disponibile + Su questo dispositivo + Libera spazio Elimina tutti i trasferimenti Elimina dalla cronologia tutti i trasferimenti inviati e ricevuti e libera immediatamente la cache inutilizzata. I trasferimenti in corso e i file ricevuti non vengono eliminati. Questa azione non può essere annullata. Dati dell’app Eliminazione… - La cache dei trasferimenti include contenuti scaricati o importati, compresi i dati protetti da ricezioni in corso e condivisioni attive. Lo svuotamento rimuove solo la cache inutilizzata. I file ricevuti non vengono mai eliminati qui. + I dati di trasferimento includono la cronologia e il contenuto nella cache per le condivisioni attive. La cache non necessaria viene recuperata automaticamente dopo la rimozione dei record, operazione che può richiedere un po’ di tempo. I file ricevuti vengono monitorati per questo riepilogo, ma non sono mai eliminati qui. File ricevuti File temporanei Archiviazione Totale - Cache trasferimenti - Cache trasferimenti svuotata + Dati di trasferimento Tutti i trasferimenti eliminati Veda gli aggiornamenti importanti di questo trasferimento Attività @@ -274,8 +295,8 @@ Nessuno ha ancora richiesto questo trasferimento. Approvato: in attesa del completamento Ricevuto correttamente - Trasferimento non riuscito Richiesta scaduta + Consegna non riuscita Richiesta rifiutata In attesa della sua approvazione Stato non disponibile diff --git a/shared/src/commonMain/composeResources/values-nl/strings.xml b/shared/src/commonMain/composeResources/values-nl/strings.xml index c4dddac..f6940b5 100644 --- a/shared/src/commonMain/composeResources/values-nl/strings.xml +++ b/shared/src/commonMain/composeResources/values-nl/strings.xml @@ -22,6 +22,7 @@ Privacy en beveiliging Verstuur bestanden rechtstreeks. Houd controle over wie ze ontvangt. Over + Bezig met starten… De lichte of donkere weergave van dit apparaat volgen. Donkere modus Lichte modus @@ -54,6 +55,7 @@ Wat is er gebeurd? Goedkeuren Terug + Meer acties Annuleren Annuleren Bestanden wijzigen @@ -117,10 +119,20 @@ Versturen Instellingen Netwerk - Ontvang meldingen over nieuwe ontvangstverzoeken terwijl VniDrop op de achtergrond draait. + Ontvang meldingen over overdrachtsactiviteit terwijl VniDrop op de achtergrond draait. Meldingen ingeschakeld. Meldingen toestaan Meldingen zijn uitgeschakeld voor VniDrop. U kunt ze inschakelen in Instellingen. + ‘%1$s’ is volledig gedownload. + Download voltooid + ‘%1$s’ kon niet worden ontvangen. + Download mislukt + %1$s heeft ‘%2$s’ volledig ontvangen. + Overdracht ontvangen + %1$s kon “%2$s” niet ontvangen + Levering mislukt + ‘%1$s’ kon niet worden gedeeld. + Delen mislukt De meldingsinstellingen konden niet worden geopend. Meldingen Meldingen zijn niet beschikbaar op dit apparaat. @@ -175,7 +187,7 @@ Stop alle actieve overdrachten en gedeelde items voordat u de netwerkinstellingen toepast. Deze instellingen konden niet worden toegepast. De vorige netwerkinstellingen zijn hersteld. Bij het toepassen wordt de netwerkverbinding van VniDrop opnieuw gestart. Stop eerst actieve overdrachten en gedeelde items. Bestaande uitnodigingen moeten mogelijk opnieuw worden gedeeld. - Voeg elke HTTPS-relay-URL afzonderlijk toe. Aanmeldgegevens in URL\'s worden niet ondersteund. Het TLS-certificaat moet zijn uitgegeven door een openbaar vertrouwde certificeringsinstantie. + Voer per regel één HTTPS-relay-URL in. Aanmeldgegevens in URL\'s worden niet ondersteund. Het TLS-certificaat moet zijn uitgegeven door een openbaar vertrouwde certificeringsinstantie. Relay-URL\'s Automatisch (aanbevolen) Gebruikt de standaard openbare relay-infrastructuur van VniDrop wanneer geen directe verbinding beschikbaar is. @@ -231,20 +243,29 @@ Ontvangen Gestopt Berekenen… + Opschonen… + Voltooi actieve overdrachten voordat je ruimte vrijmaakt + %1$s vrijgemaakt Overdrachtscache wissen Verwijdert overdrachtsinhoud uit de cache die niet wordt gebruikt door een lopende ontvangst of actieve share. Ontvangen bestanden en de overdrachtsgeschiedenis worden niet verwijderd. Cache wissen… + Wist je verzend- en ontvangstgeschiedenis en de gecachte deelinhoud van de app. Ontvangen bestanden op schijf blijven behouden. + Verwijdert tijdelijke bestanden en resten van eerdere overdrachten. Je overdrachten en ontvangen bestanden blijven behouden. + Vernieuwen + Overdrachtscache gewist + Opslaggebruik is nog niet beschikbaar + Op dit apparaat + Ruimte vrijmaken Alle overdrachten verwijderen Hiermee worden alle verzonden en ontvangen overdrachten uit de geschiedenis gewist en wordt ongebruikte overdrachtscache direct vrijgemaakt. Lopende overdrachten en ontvangen bestanden worden niet verwijderd. Dit kan niet ongedaan worden gemaakt. Appgegevens Verwijderen… - De overdrachtscache bevat gedownloade of geïmporteerde inhoud, inclusief gegevens die door lopende ontvangsten en actieve shares worden beschermd. Wissen verwijdert alleen ongebruikte cache. Ontvangen bestanden worden hier nooit verwijderd. + Overdrachtsgegevens omvatten uw geschiedenis en inhoud in de cache voor actieve shares. Onnodige cache wordt automatisch opgeruimd nadat overdrachtsrecords zijn verwijderd; dit kan enige tijd duren. Ontvangen bestanden worden voor dit overzicht bijgehouden, maar hier nooit verwijderd. Ontvangen bestanden Tijdelijke bestanden Opslag Totaal - Overdrachtscache - Overdrachtscache gewist + Overdrachtsgegevens Alle overdrachten verwijderd Bekijk belangrijke updates voor deze overdracht Activiteit @@ -274,8 +295,8 @@ Nog niemand heeft deze overdracht aangevraagd. Goedgekeurd — wachten op voltooiing Succesvol ontvangen - Overdracht mislukt Verzoek verlopen + Levering mislukt Verzoek geweigerd Wachten op uw goedkeuring Status niet beschikbaar diff --git a/shared/src/commonMain/composeResources/values-pl/strings.xml b/shared/src/commonMain/composeResources/values-pl/strings.xml index 83ccdd5..9838356 100644 --- a/shared/src/commonMain/composeResources/values-pl/strings.xml +++ b/shared/src/commonMain/composeResources/values-pl/strings.xml @@ -22,6 +22,7 @@ Prywatność i bezpieczeństwo Wysyłaj pliki bezpośrednio. Zachowaj kontrolę nad tym, kto je otrzymuje. Informacje + Uruchamianie… Dopasuj do jasnego lub ciemnego wyglądu tego urządzenia. Tryb ciemny Tryb jasny @@ -54,6 +55,7 @@ Co się stało? Zatwierdź Wstecz + Więcej działań Anuluj Anuluj Zmień pliki @@ -117,10 +119,20 @@ Wyślij Ustawienia Sieć - Otrzymuj powiadomienia o nowych prośbach o odbiór, gdy VniDrop działa w tle. + Otrzymuj powiadomienia o aktywności transferów, gdy VniDrop działa w tle. Powiadomienia włączone. Zezwól na powiadomienia Powiadomienia są wyłączone dla VniDrop. Możesz je włączyć w Ustawieniach. + Zakończono pobieranie „%1$s”. + Pobieranie zakończone + Nie udało się odebrać „%1$s”. + Pobieranie nie powiodło się + %1$s zakończył odbieranie „%2$s”. + Transfer odebrany + %1$s nie mógł odebrać „%2$s” + Dostarczenie nie powiodło się + Nie udało się udostępnić „%1$s”. + Udostępnianie nie powiodło się Nie udało się otworzyć ustawień powiadomień. Powiadomienia Powiadomienia nie są dostępne na tym urządzeniu. @@ -175,7 +187,7 @@ Zatrzymaj wszystkie aktywne transfery i udostępnienia przed zastosowaniem ustawień sieci. Nie udało się zastosować tych ustawień. Przywrócono poprzednie ustawienia sieci. Zastosowanie ustawień ponownie uruchamia połączenie sieciowe VniDrop. Najpierw zatrzymaj aktywne transfery i udostępnienia. Istniejące zaproszenia mogą wymagać ponownego udostępnienia. - Dodaj każdy adres URL HTTPS serwera przekaźnikowego osobno. Dane logowania w adresach URL nie są obsługiwane. Certyfikat TLS musi być wystawiony przez publicznie zaufany urząd certyfikacji. + Wprowadź po jednym adresie URL HTTPS przekaźnika w każdym wierszu. Dane logowania w adresach URL nie są obsługiwane. Certyfikat TLS musi być wystawiony przez publicznie zaufany urząd certyfikacji. Adresy URL przekaźników Automatyczny (zalecany) Używa domyślnej publicznej infrastruktury przekaźników VniDrop, gdy połączenie bezpośrednie jest niedostępne. @@ -231,20 +243,29 @@ Odbieranie Zatrzymany Obliczanie… + Czyszczenie… + Zakończ aktywne transfery przed zwolnieniem miejsca + Zwolniono %1$s Wyczyść pamięć podręczną transferów Usuwa zawartość transferów z pamięci podręcznej, która nie jest używana przez trwające odbieranie ani aktywne udostępnianie. Odebrane pliki i historia nie są usuwane. Czyszczenie pamięci podręcznej… + Czyści historię wysyłania i odbierania oraz zapisane w pamięci podręcznej udostępniane treści. Odebrane pliki na dysku zostają zachowane. + Usuwa pliki tymczasowe i pozostałości po wcześniejszych transferach. Twoje transfery i odebrane pliki zostają zachowane. + Odśwież + Wyczyszczono pamięć podręczną transferów + Wykorzystanie pamięci nie jest jeszcze dostępne + Na tym urządzeniu + Zwolnij miejsce Usuń wszystkie transfery Usuwa z historii wszystkie wysłane i odebrane transfery oraz natychmiast zwalnia nieużywaną pamięć podręczną. Trwające transfery i odebrane pliki nie są usuwane. Tej operacji nie można cofnąć. Dane aplikacji Usuwanie… - Pamięć podręczna transferów zawiera pobraną lub zaimportowaną zawartość, w tym dane chronione przez trwające odbieranie i aktywne udostępnianie. Czyszczenie usuwa tylko nieużywaną pamięć podręczną. Odebrane pliki nigdy nie są tutaj usuwane. + Dane transferu obejmują historię oraz zawartość w pamięci podręcznej dla aktywnych udostępnień. Niepotrzebna pamięć podręczna jest odzyskiwana automatycznie po usunięciu rekordów, co może chwilę potrwać. Odebrane pliki są śledzone na potrzeby tego podsumowania, ale nigdy nie są tu usuwane. Odebrane pliki Pliki tymczasowe Pamięć Łącznie - Pamięć podręczna transferów - Wyczyszczono pamięć podręczną transferów + Dane transferu Usunięto wszystkie transfery Zobacz ważne aktualizacje tego transferu Aktywność @@ -274,8 +295,8 @@ Nikt jeszcze nie poprosił o ten transfer. Zatwierdzono — oczekiwanie na ukończenie Odebrano pomyślnie - Przesyłanie nie powiodło się Prośba wygasła + Dostarczenie nie powiodło się Prośba odrzucona Oczekiwanie na Twoje zatwierdzenie Status niedostępny diff --git a/shared/src/commonMain/composeResources/values-pt/strings.xml b/shared/src/commonMain/composeResources/values-pt/strings.xml index 8f8be6c..8cab366 100644 --- a/shared/src/commonMain/composeResources/values-pt/strings.xml +++ b/shared/src/commonMain/composeResources/values-pt/strings.xml @@ -22,6 +22,7 @@ Privacidade e segurança Envie ficheiros diretamente. Mantenha o controlo sobre quem os recebe. Acerca de + A iniciar… Acompanhar o aspeto claro ou escuro deste dispositivo. Modo escuro Modo claro @@ -54,6 +55,7 @@ O que aconteceu? Aprovar Voltar + Mais ações Cancelar Cancelar Alterar ficheiros @@ -117,10 +119,20 @@ Enviar Definições Rede - Seja notificado sobre novos pedidos de receção enquanto o VniDrop está em segundo plano. + Seja notificado sobre a atividade das transferências enquanto o VniDrop está em segundo plano. Notificações ativadas. Permitir notificações As notificações estão desativadas para o VniDrop. Pode ativá-las nas Definições. + «%1$s» concluiu a transferência. + Transferência concluída + Não foi possível receber «%1$s». + Falha na transferência + %1$s terminou de receber «%2$s». + Transferência recebida + %1$s não conseguiu receber “%2$s” + Falha na entrega + Não foi possível partilhar «%1$s». + Falha na partilha Não foi possível abrir as definições de notificações. Notificações As notificações não estão disponíveis neste dispositivo. @@ -175,7 +187,7 @@ Pare todas as transferências e partilhas ativas antes de aplicar as definições de rede. Não foi possível aplicar estas definições. As definições de rede anteriores foram restauradas. A aplicação reinicia a ligação de rede do VniDrop. Pare primeiro as transferências e partilhas ativas. Poderá ser necessário voltar a partilhar os convites existentes. - Adicione cada URL HTTPS de retransmissor separadamente. Não são suportadas credenciais nos URLs. O certificado TLS tem de ser emitido por uma autoridade de certificação publicamente reconhecida. + Introduza um URL HTTPS de retransmissor por linha. Não são suportadas credenciais nos URLs. O certificado TLS tem de ser emitido por uma autoridade de certificação publicamente reconhecida. URLs dos retransmissores Automático (recomendado) Utiliza a infraestrutura pública de retransmissores predefinida do VniDrop quando não está disponível uma ligação direta. @@ -231,20 +243,29 @@ A receber Parada A calcular… + A limpar… + Conclui as transferências ativas antes de libertar espaço + Libertado %1$s Limpar cache de transferências Remove conteúdo de transferência em cache que não esteja a ser utilizado por uma receção em curso ou partilha ativa. Os ficheiros recebidos e o histórico não são eliminados. A limpar cache… + Limpa o teu histórico de envios e receções e o conteúdo de partilha em cache da app. Os ficheiros recebidos no disco são mantidos. + Remove ficheiros temporários e resíduos de transferências anteriores. As tuas transferências e ficheiros recebidos são mantidos. + Atualizar + Cache de transferências limpa + A utilização do armazenamento ainda não está disponível + Neste dispositivo + Libertar espaço Eliminar todas as transferências Isto elimina do histórico todas as transferências enviadas e recebidas e liberta imediatamente a cache não utilizada. As transferências em curso e os ficheiros recebidos não são eliminados. Esta ação não pode ser anulada. Dados da aplicação A eliminar… - A cache de transferências inclui conteúdo descarregado ou importado, incluindo dados protegidos por receções em curso e partilhas ativas. A limpeza remove apenas a cache não utilizada. Os ficheiros recebidos nunca são eliminados aqui. + Os dados de transferência incluem o histórico e o conteúdo em cache das partilhas ativas. A cache desnecessária é recuperada automaticamente após a remoção dos registos, o que pode demorar algum tempo. Os ficheiros recebidos são acompanhados para este resumo, mas nunca são eliminados aqui. Ficheiros recebidos Ficheiros temporários Armazenamento Total - Cache de transferências - Cache de transferências limpa + Dados de transferência Todas as transferências eliminadas Ver as atualizações importantes desta transferência Atividade @@ -274,8 +295,8 @@ Ainda ninguém pediu esta transferência. Aprovado — a aguardar conclusão Recebido com sucesso - A transferência falhou Pedido expirado + Falha na entrega Pedido recusado A aguardar a sua aprovação Estado indisponível diff --git a/shared/src/commonMain/composeResources/values-ru/strings.xml b/shared/src/commonMain/composeResources/values-ru/strings.xml index 411b02c..897a67d 100644 --- a/shared/src/commonMain/composeResources/values-ru/strings.xml +++ b/shared/src/commonMain/composeResources/values-ru/strings.xml @@ -22,6 +22,7 @@ Конфиденциальность и безопасность Отправляйте файлы напрямую. Сохраняйте контроль над тем, кто их получает. О приложении + Запуск… Следовать светлому или тёмному оформлению этого устройства. Тёмный режим Светлый режим @@ -54,6 +55,7 @@ Что произошло? Одобрить Назад + Другие действия Отмена Отмена Изменить файлы @@ -117,10 +119,20 @@ Отправить Настройки Сеть - Получайте уведомления о новых запросах на получение, пока VniDrop работает в фоне. + Получайте уведомления об активности передач, пока VniDrop работает в фоне. Уведомления включены. Разрешить уведомления Уведомления отключены для VniDrop. Вы можете включить их в Настройках. + «%1$s» завершил загрузку. + Загрузка завершена + Не удалось получить «%1$s». + Ошибка загрузки + %1$s завершил получение «%2$s». + Передача получена + %1$s не удалось получить «%2$s» + Ошибка доставки + Не удалось поделиться «%1$s». + Не удалось поделиться Не удалось открыть настройки уведомлений. Уведомления Уведомления недоступны на этом устройстве. @@ -175,7 +187,7 @@ Остановите все активные передачи и раздачи перед применением настроек сети. Не удалось применить эти настройки. Предыдущие настройки сети восстановлены. При применении сетевое соединение VniDrop перезапускается. Сначала остановите активные передачи и раздачи. Возможно, существующие приглашения потребуется отправить повторно. - Добавляйте каждый HTTPS-адрес ретранслятора отдельно. Учётные данные в URL-адресах не поддерживаются. Сертификат TLS должен быть выдан общедоступным доверенным центром сертификации. + Введите по одному HTTPS-адресу ретранслятора в строке. Учётные данные в URL-адресах не поддерживаются. Сертификат TLS должен быть выдан общедоступным доверенным центром сертификации. URL-адреса ретрансляторов Автоматически (рекомендуется) Использовать стандартную публичную инфраструктуру ретрансляторов VniDrop, если прямое соединение недоступно. @@ -231,20 +243,29 @@ Получение Остановлено Вычисление… + Очистка… + Завершите активные передачи перед освобождением места + Освобождено %1$s Очистить кэш передач Удаляет кэшированное содержимое передач, которое не используется текущим приёмом или активной раздачей. Полученные файлы и история передач не удаляются. Очистка кэша… + Очищает историю отправки и получения и кэшированное содержимое общих ресурсов. Полученные файлы на диске сохраняются. + Удаляет временные файлы и остатки прошлых передач. Ваши передачи и полученные файлы сохраняются. + Обновить + Кэш передач очищен + Данные об использовании хранилища пока недоступны + На этом устройстве + Освободить место Удалить все передачи Это удалит из истории все отправленные и полученные передачи и немедленно освободит неиспользуемый кэш. Текущие передачи и полученные файлы не удаляются. Это действие нельзя отменить. Данные приложения Удаление… - Кэш передач содержит загруженные или импортированные данные, включая содержимое, защищённое текущими приёмами и активными раздачами. Очистка удаляет только неиспользуемый кэш. Полученные файлы здесь никогда не удаляются. + Данные передачи включают историю и кэшированное содержимое активных раздач. Ненужный кэш освобождается автоматически после удаления записей; это может занять некоторое время. Полученные файлы учитываются в этой сводке, но никогда не удаляются здесь. Полученные файлы Временные файлы Хранилище Всего - Кэш передач - Кэш передач очищен + Данные передачи Все передачи удалены Просматривайте важные обновления этой передачи Активность @@ -274,8 +295,8 @@ Никто ещё не запросил эту передачу. Одобрено — ожидание завершения Успешно получено - Передача не удалась Запрос истёк + Ошибка доставки Запрос отклонён Ожидание вашего одобрения Статус недоступен diff --git a/shared/src/commonMain/composeResources/values/strings.xml b/shared/src/commonMain/composeResources/values/strings.xml index 7ab31fb..611355c 100644 --- a/shared/src/commonMain/composeResources/values/strings.xml +++ b/shared/src/commonMain/composeResources/values/strings.xml @@ -22,6 +22,7 @@ Privacy & security Send files directly. Stay in control of who receives them. About + Starting… Match this device’s light or dark appearance. Dark mode Light mode @@ -54,6 +55,7 @@ What happened? Approve Back + More actions Cancel Cancel Change files @@ -117,10 +119,20 @@ Send Settings Network - Get notified about new receive requests while VniDrop is in the background. + Get notified about transfer activity while VniDrop is in the background. Notifications enabled. Allow notifications Notifications are turned off for VniDrop. You can enable them in Settings. + “%1$s” finished downloading. + Download complete + “%1$s” couldn’t be received. + Download failed + %1$s finished receiving “%2$s”. + Transfer received + %1$s couldn\'t receive “%2$s” + Delivery failed + “%1$s” couldn’t be shared. + Sharing failed Could not open notification settings. Notifications Notifications are not available on this device. @@ -175,7 +187,7 @@ Stop all active transfers and shares before applying network settings. Could not apply these settings. The previous network settings were restored. Applying restarts VniDrop’s network connection. Stop active transfers and shares first. Existing invitations may need to be shared again. - Add each HTTPS relay URL separately. URL credentials are not supported. The TLS certificate must be issued by a publicly trusted certificate authority. + Enter one HTTPS relay URL per line. URL credentials are not supported. The TLS certificate must be issued by a publicly trusted certificate authority. Relay URLs Automatic (recommended) Use VniDrop’s default public relay infrastructure when a direct connection is unavailable. @@ -231,20 +243,29 @@ Receiving Stopped Calculating… + Cleaning up… + Finish active transfers before freeing up space + Freed %1$s Clear transfer cache Removes cached transfer content after briefly restarting VniDrop. Finish ongoing transfers and stop active shares first. Received files and transfer history are not deleted. Clearing cache… + Clears your send and receive history and the app’s cached share content. Received files on disk are kept. + Removes temporary files and leftover trash from earlier transfers. Your transfers and received files are kept. + Refresh + Transfer cache cleared + Storage usage isn\'t available yet + On this device + Free up space Delete all transfers This clears all sent and received transfer records from your history and immediately reclaims unused transfer cache. Ongoing transfers and received files are not deleted. This can’t be undone. App data Deleting… - Transfer cache includes downloaded or imported content used by transfers and shares. It can be cleared when no transfer or share is active. Received files are tracked for this summary but are never deleted here. + Transfer data includes your history and cached content for active shares. Unneeded cache is reclaimed automatically after transfer records are removed, which may take a little time. Received files are tracked for this summary but are never deleted here. Received files Temporary files Storage Total - Transfer cache - Transfer cache cleared + Transfer data All transfers deleted See important updates for this transfer Activity @@ -274,8 +295,8 @@ Nobody has requested this transfer yet. Approved — waiting for completion Received successfully - Transfer failed Request expired + Delivery failed Request refused Waiting for your approval Status unavailable diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/App.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/App.kt index 72ebbcc..260a3ce 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/App.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/App.kt @@ -6,6 +6,13 @@ import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect @@ -14,6 +21,8 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.lifecycle.Lifecycle @@ -49,6 +58,9 @@ import com.vnidrop.app.ui.theme.rememberResolvedDarkTheme import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.first import kotlinx.coroutines.withTimeoutOrNull +import org.jetbrains.compose.resources.stringResource +import vnidrop.shared.generated.resources.Res +import vnidrop.shared.generated.resources.app_starting @Composable fun App( @@ -217,6 +229,32 @@ fun App( ) } windowChrome?.invoke() + val startingLabel = stringResource(Res.string.app_starting) + AnimatedVisibility( + visible = !sendCoreState.isInitialized, + enter = fadeIn(), + exit = fadeOut(), + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background(LocalVniDropColors.current.backgroundSurface100) + .semantics { contentDescription = startingLabel }, + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + CircularProgressIndicator() + Text( + startingLabel, + style = MaterialTheme.typography.titleMedium, + color = LocalVniDropColors.current.foregroundLighter, + ) + } + } + } } } } diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/AppGraph.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/AppGraph.kt index f9054ec..6f64179 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/AppGraph.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/AppGraph.kt @@ -8,6 +8,7 @@ import com.vnidrop.app.feature.approvals.ApprovalCoordinator import com.vnidrop.app.feature.send.AppFilePreviewRepository import com.vnidrop.app.feature.send.createPlatformPreviewStore import com.vnidrop.app.logging.AppLogger +import com.vnidrop.app.notifications.TransferNotificationCoordinator import com.vnidrop.app.platform.AppVisibility import com.vnidrop.app.preferences.AppPreferencesDefaults import com.vnidrop.app.preferences.AppPreferencesRepository @@ -62,6 +63,14 @@ class AppGraph( messages = messages, scope = applicationScope, ) + val transferNotificationCoordinator = TransferNotificationCoordinator( + repository = coreRepository, + preferencesRepository = preferencesRepository, + notifications = dependencies.localNotificationService, + visibility = visibility, + messages = messages, + scope = applicationScope, + ) init { AppLogger.initialize(dependencies.environment.defaultCoreDataDir) diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/core/FileSystemService.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/core/FileSystemService.kt index 50355d4..a36a4c1 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/core/FileSystemService.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/core/FileSystemService.kt @@ -41,6 +41,8 @@ interface FileSystemService { suspend fun validateReceiveFolder(folder: ReceiveFolder): FolderAccessStatus suspend fun inspectReceivedArtifacts(artifacts: List): ReceivedStorageInspection suspend fun temporaryUsage(receiveFolder: ReceiveFolder): ULong + /** Reclaims only app-owned temporary files and returns the number of bytes removed. */ + suspend fun reclaimTemporaryStorage(appDataDir: String, receiveFolder: ReceiveFolder): ULong fun createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSinkV2? fun canRevealReceiveFolder(folder: ReceiveFolder): Boolean = false suspend fun revealReceiveFolder(folder: ReceiveFolder): Result = diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ReceiveScreen.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ReceiveScreen.kt index e7f7dc0..0135444 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ReceiveScreen.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ReceiveScreen.kt @@ -294,7 +294,7 @@ private fun InvitationReviewPanel( Text( when (error) { is UiText.Dynamic -> error.value - is UiText.Resource -> stringResource(error.resource) + is UiText.Resource -> stringResource(error.resource, *error.formatArgs.toTypedArray()) }, color = LocalVniDropColors.current.destructiveDefault, style = MaterialTheme.typography.bodySmall, diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendCatalog.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendCatalog.kt index 278b9b9..601f048 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendCatalog.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendCatalog.kt @@ -19,17 +19,25 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +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.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow @@ -58,12 +66,16 @@ import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.decodeToImageBitmap import vnidrop.shared.generated.resources.Res import vnidrop.shared.generated.resources.button_create_new_transfer +import vnidrop.shared.generated.resources.button_delete_transfer +import vnidrop.shared.generated.resources.button_more_actions import vnidrop.shared.generated.resources.send_empty_body import vnidrop.shared.generated.resources.send_empty_title import vnidrop.shared.generated.resources.send_new_transfer_description import vnidrop.shared.generated.resources.send_new_transfer_title import vnidrop.shared.generated.resources.send_title +import vnidrop.shared.generated.resources.send_stop_sharing import vnidrop.shared.generated.resources.send_transfers_title +import vnidrop.shared.generated.resources.transfer_share_title @Composable internal fun SendFloatingAction(onClick: () -> Unit, modifier: Modifier = Modifier) { @@ -86,6 +98,9 @@ internal fun TransferCatalog( windowClass: WindowClass, onOpenComposer: () -> Unit, onTransferSelected: (ULong) -> Unit, + onShare: (ULong) -> Unit = {}, + onStopSharing: (ULong) -> Unit = {}, + onDelete: (ULong) -> Unit = {}, ) { val usesFloatingAction = usesMobilePresentation(LocalUiPlatform.current, windowClass) LazyColumn( @@ -129,6 +144,9 @@ internal fun TransferCatalog( thumbnailBytes = transferThumbnails[transfer.transferId], progress = progress, onClick = { onTransferSelected(transfer.transferId) }, + onShare = { onShare(transfer.transferId) }, + onStopSharing = { onStopSharing(transfer.transferId) }, + onDelete = { onDelete(transfer.transferId) }, ) } } @@ -192,6 +210,9 @@ private fun TransferListItem( thumbnailBytes: ByteArray?, progress: TransferProgress?, onClick: () -> Unit, + onShare: () -> Unit, + onStopSharing: () -> Unit, + onDelete: () -> Unit, ) { val colors = LocalVniDropColors.current Surface(onClick = onClick, modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(16.dp), color = colors.backgroundSurface200) { @@ -227,8 +248,68 @@ private fun TransferListItem( ProgressRow(label = progress.label, progress = progress.progress, detail = progress.detail) } } - Spacer(Modifier.width(8.dp)) - PlatformIcon(AppIcon.ChevronRight, contentDescription = null, tint = colors.foregroundLighter, modifier = Modifier.size(18.dp)) + TransferActionsMenu(transfer, onShare, onStopSharing, onDelete) + } + } +} + +@Composable +private fun TransferActionsMenu( + transfer: Transfer, + onShare: () -> Unit, + onStopSharing: () -> Unit, + onDelete: () -> Unit, +) { + var expanded by remember { mutableStateOf(false) } + val moreActionsLabel = stringResource(Res.string.button_more_actions) + Box { + IconButton( + onClick = { expanded = true }, + modifier = Modifier.semantics { + contentDescription = moreActionsLabel + }, + ) { + PlatformIcon( + AppIcon.MoreVertical, + contentDescription = null, + tint = LocalVniDropColors.current.foregroundLighter, + ) + } + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + if (transfer.ticket != null) { + DropdownMenuItem( + text = { Text(stringResource(Res.string.transfer_share_title)) }, + onClick = { + expanded = false + onShare() + }, + leadingIcon = { PlatformIcon(AppIcon.Share, contentDescription = null) }, + ) + } + if (transfer.status == TransferStatus.Sharing) { + DropdownMenuItem( + text = { Text(stringResource(Res.string.send_stop_sharing)) }, + onClick = { + expanded = false + onStopSharing() + }, + leadingIcon = { PlatformIcon(AppIcon.StopCircle, contentDescription = null) }, + ) + } + DropdownMenuItem( + text = { Text(stringResource(Res.string.button_delete_transfer)) }, + onClick = { + expanded = false + onDelete() + }, + leadingIcon = { + PlatformIcon( + AppIcon.Delete, + contentDescription = null, + tint = LocalVniDropColors.current.destructiveDefault, + ) + }, + ) } } } diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendRoute.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendRoute.kt index f8d2cab..0eae335 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendRoute.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendRoute.kt @@ -46,6 +46,11 @@ fun SendRoute( onAccessPolicyChanged = viewModel::setAccessPolicy, onCreateShare = viewModel::createShare, onTransferSelected = viewModel::openTransfer, + onShareTransfer = { transferId -> + viewModel.openTransfer(transferId) + viewModel.openShare() + }, + onStopSharing = viewModel::stopSharing, onCloseTransferDetails = viewModel::closeTransferDetails, onCopyTicket = viewModel::copyTicket, onActivity = viewModel::openActivity, @@ -53,7 +58,8 @@ fun SendRoute( onShare = viewModel::openShare, onCloseDetailPanel = viewModel::closeDetailPanel, onInvitationResult = viewModel::onInvitationResult, - onRequestDelete = viewModel::requestDeleteTransfer, + onRequestDelete = { viewModel.requestDeleteTransfer() }, + onRequestDeleteTransfer = { viewModel.requestDeleteTransfer(it) }, onDismissDelete = viewModel::dismissDeleteTransfer, onConfirmDelete = viewModel::confirmDeleteTransfer, ) diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendScreen.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendScreen.kt index b46a171..065e3c9 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendScreen.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendScreen.kt @@ -33,6 +33,8 @@ fun SendScreen( onAccessPolicyChanged: (ShareAccessPolicy) -> Unit, onCreateShare: () -> Unit, onTransferSelected: (ULong) -> Unit, + onShareTransfer: (ULong) -> Unit = {}, + onStopSharing: (ULong) -> Unit = {}, onCloseTransferDetails: () -> Unit, onCopyTicket: (String) -> Unit, onActivity: () -> Unit = {}, @@ -41,11 +43,13 @@ fun SendScreen( onCloseDetailPanel: () -> Unit = {}, onInvitationResult: (InvitationAction, Result) -> Unit = { _, _ -> }, onRequestDelete: () -> Unit = {}, + onRequestDeleteTransfer: (ULong) -> Unit = {}, onDismissDelete: () -> Unit = {}, onConfirmDelete: () -> Unit = {}, ) { val outgoingTransfers = coreState.transfers.filter { it.direction == TransferDirection.Send } val selectedTransfer = state.selectedTransferId?.let { id -> outgoingTransfers.firstOrNull { it.transferId == id } } + val deleteTarget = state.deleteTargetTransferId?.let { id -> outgoingTransfers.firstOrNull { it.transferId == id } } val qrCache = remember { mutableStateMapOf() } LaunchedEffect(outgoingTransfers.mapNotNull { it.ticket }) { qrCache.keys.retainAll(outgoingTransfers.mapNotNull { it.ticket }.toSet()) @@ -64,6 +68,7 @@ fun SendScreen( onActivity = onActivity, onReceivers = onReceivers, onShare = onShare, + onStopSharing = { onStopSharing(selectedTransfer.transferId) }, onDelete = onRequestDelete, ) } else { @@ -75,6 +80,9 @@ fun SendScreen( windowClass = windowClass, onOpenComposer = onOpenComposer, onTransferSelected = onTransferSelected, + onShare = onShareTransfer, + onStopSharing = onStopSharing, + onDelete = onRequestDeleteTransfer, ) } } @@ -123,10 +131,10 @@ fun SendScreen( } } - if (selectedTransfer != null && state.isDeleteConfirmationOpen) { + if (deleteTarget != null && state.isDeleteConfirmationOpen) { AdaptiveDrawer(windowClass = windowClass, onDismissRequest = onDismissDelete) { DeleteTransferPanel( - transferName = selectedTransfer.transferName, + transferName = deleteTarget.transferName, isDeleting = state.isDeleting, onCancel = onDismissDelete, onConfirm = onConfirmDelete, diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendViewModel.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendViewModel.kt index 8cfc7a1..93b81a6 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendViewModel.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/SendViewModel.kt @@ -44,6 +44,7 @@ data class SendState( val receiversByTransfer: Map> = emptyMap(), val isLoadingReceivers: Boolean = false, val isDeleteConfirmationOpen: Boolean = false, + val deleteTargetTransferId: ULong? = null, val isDeleting: Boolean = false, ) { val selectedFile: PickedShareFile? get() = selectedFiles.singleOrNull() @@ -221,12 +222,19 @@ class SendViewModel( refreshReceivers(transferId) } fun closeDetailPanel() = _state.update { it.copy(detailPanel = null) } - fun requestDeleteTransfer() = _state.update { it.copy(isDeleteConfirmationOpen = true) } + fun requestDeleteTransfer(transferId: ULong? = null) = _state.update { + it.copy( + isDeleteConfirmationOpen = true, + deleteTargetTransferId = transferId ?: it.selectedTransferId, + ) + } fun dismissDeleteTransfer() { - if (!_state.value.isDeleting) _state.update { it.copy(isDeleteConfirmationOpen = false) } + if (!_state.value.isDeleting) { + _state.update { it.copy(isDeleteConfirmationOpen = false, deleteTargetTransferId = null) } + } } fun confirmDeleteTransfer() { - val transferId = _state.value.selectedTransferId ?: return + val transferId = _state.value.deleteTargetTransferId ?: return if (_state.value.isDeleting) return viewModelScope.launch { _state.update { it.copy(isDeleting = true) } @@ -239,6 +247,7 @@ class SendViewModel( detailPanel = null, receiverHistory = emptyList(), isDeleteConfirmationOpen = false, + deleteTargetTransferId = null, isDeleting = false, ) } @@ -285,6 +294,7 @@ class SendViewModel( onSuccess = { share -> current.selectedFiles.firstNotNullOfOrNull { it.thumbnailBytes } ?.let { filePreviewRepository.save(share.transferId, it) } + repository.refresh() _state.update { it.copy( isComposerOpen = false, @@ -292,8 +302,11 @@ class SendViewModel( transferName = "", accessPolicy = ShareAccessPolicy.RequireApproval, isSharing = false, + selectedTransferId = share.transferId, + detailPanel = TransferDetailPanel.Share, ) } + refreshReceivers(share.transferId) messages.show(UiMessage(UiText.Resource(Res.string.send_transfer_created), UiMessageTone.Success)) }, onFailure = { error -> @@ -304,6 +317,15 @@ class SendViewModel( } } + fun stopSharing(transferId: ULong) { + viewModelScope.launch { + repository.cancel(transferId).fold( + onSuccess = { repository.refresh() }, + onFailure = messages::error, + ) + } + } + private fun defaultTransferName(files: List): String = when { files.isEmpty() -> "" files.size == 1 && files.first().isDirectory -> files.first().displayName diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/TransferComposer.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/TransferComposer.kt index 6ff956d..bb74a62 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/TransferComposer.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/TransferComposer.kt @@ -17,6 +17,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.RadioButton import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -174,22 +175,51 @@ private fun ReviewFileStep( style = MaterialTheme.typography.bodySmall, ) } - if (windowClass == WindowClass.Phone) { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - ShareButton(state, coreInitialized, onCreateShare, Modifier.fillMaxWidth()) - QuietButton(stringResource(Res.string.button_change_files), onClick = onSelectFile, modifier = Modifier.fillMaxWidth(), enabled = !state.isSharing) - QuietButton(stringResource(Res.string.button_choose_folder), onClick = onSelectFolder, modifier = Modifier.fillMaxWidth(), enabled = !state.isSharing) - } - } else { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - ShareButton(state, coreInitialized, onCreateShare) - QuietButton(stringResource(Res.string.button_change_files), onClick = onSelectFile, enabled = !state.isSharing) - QuietButton(stringResource(Res.string.button_choose_folder), onClick = onSelectFolder, enabled = !state.isSharing) - QuietButton(stringResource(Res.string.button_clear), onClick = onClearFile, enabled = !state.isSharing) + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + ShareButton(state, coreInitialized, onCreateShare, Modifier.fillMaxWidth()) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) { + SourceButton( + text = stringResource(Res.string.button_change_files), + icon = AppIcon.File, + onClick = onSelectFile, + modifier = Modifier.weight(1f), + enabled = !state.isSharing, + ) + SourceButton( + text = stringResource(Res.string.button_choose_folder), + icon = AppIcon.Folder, + onClick = onSelectFolder, + modifier = Modifier.weight(1f), + enabled = !state.isSharing, + ) + if (windowClass != WindowClass.Phone) { + SourceButton( + text = stringResource(Res.string.button_clear), + icon = AppIcon.Close, + onClick = onClearFile, + modifier = Modifier.weight(1f), + enabled = !state.isSharing, + ) + } } } } +@Composable +private fun SourceButton( + text: String, + icon: AppIcon, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean, +) { + OutlinedButton(onClick = onClick, modifier = modifier, enabled = enabled) { + PlatformIcon(icon, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text(text, maxLines = 1, overflow = TextOverflow.Ellipsis) + } +} + @Composable private fun ShareButton(state: SendState, coreInitialized: Boolean, onCreateShare: () -> Unit, modifier: Modifier = Modifier) { PrimaryButton( diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/TransferDetails.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/TransferDetails.kt index 422db5d..1917c55 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/TransferDetails.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/TransferDetails.kt @@ -84,6 +84,7 @@ internal fun TransferDetails( onActivity: () -> Unit, onReceivers: () -> Unit, onShare: () -> Unit, + onStopSharing: () -> Unit, onDelete: () -> Unit, ) { LazyColumn( @@ -100,8 +101,14 @@ internal fun TransferDetails( style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, ) - IconButton(onClick = onDelete) { - PlatformIcon(AppIcon.Delete, stringResource(Res.string.button_delete_transfer), tint = LocalVniDropColors.current.destructiveDefault) + if (transfer.status in setOf(TransferStatus.Importing, TransferStatus.Sharing)) { + IconButton(onClick = onShare) { + PlatformIcon( + AppIcon.Share, + stringResource(Res.string.transfer_share_title), + tint = LocalVniDropColors.current.brandLink, + ) + } } } } @@ -130,32 +137,31 @@ internal fun TransferDetails( count = pendingReceivers + completedReceivers, onClick = onReceivers, ) - when (transfer.status) { - TransferStatus.Sharing -> { - HorizontalDivider(color = LocalVniDropColors.current.borderDefault) - DetailDestination( - title = stringResource(Res.string.transfer_share_title), - description = stringResource(Res.string.transfer_share_description), - onClick = onShare, - ) - } - TransferStatus.Importing -> { - HorizontalDivider(color = LocalVniDropColors.current.borderDefault) - DetailDestination( - title = stringResource(Res.string.transfer_share_title), - description = stringResource(Res.string.transfer_event_preparing), - ) - } - TransferStatus.Receiving, - TransferStatus.Done, - TransferStatus.Failed, - TransferStatus.Cancelled, - TransferStatus.Stopped, - -> Unit - } } } } + item { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + if (transfer.status == TransferStatus.Sharing) { + DestructiveButton( + stringResource(Res.string.send_stop_sharing), + onClick = onStopSharing, + modifier = Modifier.fillMaxWidth(), + leadingIcon = { + PlatformIcon(AppIcon.StopCircle, null, modifier = Modifier.size(18.dp)) + }, + ) + } + DestructiveButton( + stringResource(Res.string.button_delete_transfer), + onClick = onDelete, + modifier = Modifier.fillMaxWidth(), + leadingIcon = { + PlatformIcon(AppIcon.Delete, null, modifier = Modifier.size(18.dp)) + }, + ) + } + } } } diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsRoute.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsRoute.kt index 16bbdd3..1f9fd18 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsRoute.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsRoute.kt @@ -42,5 +42,7 @@ fun SettingsRoute(viewModel: SettingsViewModel, windowClass: WindowClass) { onSubmitBugReport = viewModel::submitBugReport, onDeleteAllTransfers = viewModel::deleteAllTransfers, onClearTransferCache = viewModel::clearTransferCache, + onFreeUpSpace = viewModel::freeUpSpace, + onRefreshStorage = viewModel::loadStorageUsage, ) } diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsScreen.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsScreen.kt index 920f983..b303c13 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsScreen.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsScreen.kt @@ -32,6 +32,8 @@ fun SettingsScreen( onSubmitBugReport: () -> Unit, onDeleteAllTransfers: () -> Unit = {}, onClearTransferCache: () -> Unit = {}, + onFreeUpSpace: () -> Unit = {}, + onRefreshStorage: () -> Unit = {}, onRelayModeChanged: (RelayMode) -> Unit = {}, onRelayUrlChanged: (Int, String) -> Unit = { _, _ -> }, onAddRelayUrl: () -> Unit = {}, @@ -69,6 +71,8 @@ fun SettingsScreen( onSubmitBugReport = onSubmitBugReport, onDeleteAllTransfers = onDeleteAllTransfers, onClearTransferCache = onClearTransferCache, + onFreeUpSpace = onFreeUpSpace, + onRefreshStorage = onRefreshStorage, onRelayModeChanged = onRelayModeChanged, onRelayUrlChanged = onRelayUrlChanged, onAddRelayUrl = onAddRelayUrl, @@ -110,6 +114,8 @@ fun SettingsScreen( onSubmitBugReport = onSubmitBugReport, onDeleteAllTransfers = onDeleteAllTransfers, onClearTransferCache = onClearTransferCache, + onFreeUpSpace = onFreeUpSpace, + onRefreshStorage = onRefreshStorage, onRelayModeChanged = onRelayModeChanged, onRelayUrlChanged = onRelayUrlChanged, onAddRelayUrl = onAddRelayUrl, @@ -143,6 +149,8 @@ private fun SettingsSectionContent( onSubmitBugReport: () -> Unit, onDeleteAllTransfers: () -> Unit, onClearTransferCache: () -> Unit, + onFreeUpSpace: () -> Unit, + onRefreshStorage: () -> Unit, onRelayModeChanged: (RelayMode) -> Unit, onRelayUrlChanged: (Int, String) -> Unit, onAddRelayUrl: () -> Unit, @@ -169,6 +177,8 @@ private fun SettingsSectionContent( windowClass, onDeleteAllTransfers, onClearTransferCache, + onFreeUpSpace, + onRefreshStorage, onBack, showBack, ) diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsViewModel.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsViewModel.kt index 1bde443..f8ea529 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsViewModel.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/SettingsViewModel.kt @@ -25,6 +25,7 @@ import com.vnidrop.app.ui.feedback.UiMessage import com.vnidrop.app.ui.feedback.UiMessageController import com.vnidrop.app.ui.feedback.UiMessageTone import com.vnidrop.app.ui.feedback.UiText +import com.vnidrop.app.ui.state.formatBytes import com.vnidrop.app.ui.theme.ThemeMode import kotlinx.coroutines.Job import kotlinx.coroutines.channels.Channel @@ -51,6 +52,8 @@ import vnidrop.shared.generated.resources.notifications_unsupported import vnidrop.shared.generated.resources.relay_settings_applied import vnidrop.shared.generated.resources.storage_transfer_cache_cleared import vnidrop.shared.generated.resources.storage_transfers_deleted +import vnidrop.shared.generated.resources.storage_cleanup_busy +import vnidrop.shared.generated.resources.storage_cleanup_freed enum class SettingsSection { Overview, @@ -112,8 +115,10 @@ data class SettingsState( val bugLogPreviewBytes: Int = 0, val storage: StorageBreakdown? = null, val isCalculatingStorage: Boolean = false, + val storageLoadFailed: Boolean = false, val isDeletingTransfers: Boolean = false, val isClearingTransferCache: Boolean = false, + val isCleaningStorage: Boolean = false, ) { val hasRelaySettingsChanges: Boolean get() = relayMode != savedRelaySettings.mode || @@ -188,6 +193,14 @@ class SettingsViewModel( endpointId = coreState.status?.endpointId, ) } + if ( + coreState.isInitialized && + _state.value.selectedSection == SettingsSection.Storage && + _state.value.storage == null && + _state.value.storageLoadFailed + ) { + loadStorageUsage() + } } } refreshNotificationPermission() @@ -209,7 +222,7 @@ class SettingsViewModel( fun loadStorageUsage() { if (_state.value.isCalculatingStorage) return viewModelScope.launch { - _state.update { it.copy(isCalculatingStorage = true) } + _state.update { it.copy(isCalculatingStorage = true, storageLoadFailed = false) } try { val receiveFolder = _state.value.receiveFolder ?: fileSystemService.defaultReceiveFolder() val coreUsage = repository.storageUsage().getOrThrow() @@ -227,12 +240,42 @@ class SettingsViewModel( inaccessibleReceivedFileCount = received.inaccessibleCount, ), isCalculatingStorage = false, + storageLoadFailed = false, ) } } catch (error: CancellationException) { throw error } catch (error: Throwable) { - _state.update { it.copy(isCalculatingStorage = false) } + _state.update { it.copy(isCalculatingStorage = false, storageLoadFailed = true) } + messages.error(error) + } + } + } + + fun freeUpSpace() { + val current = _state.value + if (current.isCleaningStorage) return + if (current.hasActiveNetworkWork) { + messages.tryShow(UiMessage(UiText.Resource(Res.string.storage_cleanup_busy), UiMessageTone.Warning)) + return + } + viewModelScope.launch { + _state.update { it.copy(isCleaningStorage = true) } + try { + val receiveFolder = _state.value.receiveFolder ?: fileSystemService.defaultReceiveFolder() + val reclaimed = fileSystemService.reclaimTemporaryStorage(environment.defaultCoreDataDir, receiveFolder) + _state.update { it.copy(isCleaningStorage = false) } + loadStorageUsage() + messages.tryShow( + UiMessage( + UiText.Resource(Res.string.storage_cleanup_freed, formatArgs = listOf(formatBytes(reclaimed))), + UiMessageTone.Success, + ), + ) + } catch (error: CancellationException) { + throw error + } catch (error: Throwable) { + _state.update { it.copy(isCleaningStorage = false) } messages.error(error) } } diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/StorageSettings.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/StorageSettings.kt index d5d48fc..36442f4 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/StorageSettings.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/settings/StorageSettings.kt @@ -7,9 +7,8 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -25,6 +24,8 @@ import androidx.compose.ui.unit.dp import com.vnidrop.app.ui.components.AdaptiveDrawer import com.vnidrop.app.ui.components.DestructiveButton import com.vnidrop.app.ui.components.SecondaryButton +import com.vnidrop.app.ui.icons.AppIcon +import com.vnidrop.app.ui.icons.PlatformIcon import com.vnidrop.app.ui.state.formatBytes import com.vnidrop.app.ui.state.WindowClass import com.vnidrop.app.ui.theme.LocalVniDropColors @@ -36,9 +37,16 @@ import vnidrop.shared.generated.resources.storage_calculating import vnidrop.shared.generated.resources.storage_clear_transfer_cache import vnidrop.shared.generated.resources.storage_clear_transfer_cache_description import vnidrop.shared.generated.resources.storage_clearing_transfer_cache +import vnidrop.shared.generated.resources.storage_cleaning +import vnidrop.shared.generated.resources.storage_delete_transfers_caption import vnidrop.shared.generated.resources.storage_delete_transfers import vnidrop.shared.generated.resources.storage_delete_transfers_description import vnidrop.shared.generated.resources.storage_deleting +import vnidrop.shared.generated.resources.storage_free_up_space +import vnidrop.shared.generated.resources.storage_free_up_space_caption +import vnidrop.shared.generated.resources.storage_refresh +import vnidrop.shared.generated.resources.storage_unavailable +import vnidrop.shared.generated.resources.storage_usage_header import vnidrop.shared.generated.resources.storage_total import vnidrop.shared.generated.resources.storage_received_files import vnidrop.shared.generated.resources.storage_temporary @@ -52,6 +60,8 @@ internal fun StorageSettings( windowClass: WindowClass, onDeleteAllTransfers: () -> Unit, onClearTransferCache: () -> Unit, + onFreeUpSpace: () -> Unit, + onRefreshStorage: () -> Unit, onBack: () -> Unit, showBack: Boolean, ) { @@ -59,8 +69,37 @@ internal fun StorageSettings( var showClearCacheConfirmation by rememberSaveable { mutableStateOf(false) } Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { SettingsTopBar(stringResource(Res.string.storage_title), onBack, showBack) + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Text( + stringResource(Res.string.storage_usage_header), + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + if (state.isCalculatingStorage) { + CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp) + } else { + IconButton( + onClick = onRefreshStorage, + enabled = !state.isCleaningStorage && + !state.isDeletingTransfers && + !state.isClearingTransferCache, + ) { + PlatformIcon(AppIcon.Sync, stringResource(Res.string.storage_refresh)) + } + } + } val storage = state.storage - if (storage == null || state.isCalculatingStorage) { + if (storage == null && state.storageLoadFailed && !state.isCalculatingStorage) { + SecondaryButton( + stringResource(Res.string.storage_unavailable), + onClick = onRefreshStorage, + modifier = Modifier.fillMaxWidth(), + leadingIcon = { + PlatformIcon(AppIcon.Sync, null, modifier = Modifier.size(18.dp)) + }, + ) + } else if (storage == null) { SettingsGroup { StorageRow( title = stringResource(Res.string.storage_calculating), @@ -84,6 +123,30 @@ internal fun StorageSettings( ) } } + SecondaryButton( + text = stringResource( + if (state.isCleaningStorage) Res.string.storage_cleaning else Res.string.storage_free_up_space, + ), + onClick = onFreeUpSpace, + modifier = Modifier.fillMaxWidth(), + enabled = !state.isCleaningStorage && + !state.isDeletingTransfers && + !state.isClearingTransferCache && + !state.isCalculatingStorage && + !state.hasActiveNetworkWork, + leadingIcon = { + if (state.isCleaningStorage) { + CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp) + } else { + PlatformIcon(AppIcon.Sparkles, null, modifier = Modifier.size(18.dp)) + } + }, + ) + Text( + stringResource(Res.string.storage_free_up_space_caption), + style = MaterialTheme.typography.bodySmall, + color = LocalVniDropColors.current.foregroundLighter, + ) SecondaryButton( text = stringResource( if (state.isClearingTransferCache) { @@ -97,16 +160,27 @@ internal fun StorageSettings( !state.isClearingTransferCache && !state.isCalculatingStorage && !state.hasActiveNetworkWork, + leadingIcon = { + PlatformIcon(AppIcon.Storage, null, modifier = Modifier.size(18.dp)) + }, ) - Button( + DestructiveButton( + text = stringResource( + if (state.isDeletingTransfers) Res.string.storage_deleting else Res.string.storage_delete_transfers, + ), onClick = { showDeleteConfirmation = true }, enabled = !state.isDeletingTransfers && !state.isClearingTransferCache && !state.isCalculatingStorage, - colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error), - ) { - Text(stringResource(if (state.isDeletingTransfers) Res.string.storage_deleting else Res.string.storage_delete_transfers)) - } + leadingIcon = { + PlatformIcon(AppIcon.Delete, null, modifier = Modifier.size(18.dp)) + }, + ) + Text( + stringResource(Res.string.storage_delete_transfers_caption), + style = MaterialTheme.typography.bodySmall, + color = LocalVniDropColors.current.foregroundLighter, + ) Text( stringResource(Res.string.storage_footer), style = MaterialTheme.typography.bodySmall, diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/notifications/TransferNotificationCoordinator.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/notifications/TransferNotificationCoordinator.kt new file mode 100644 index 0000000..992a69c --- /dev/null +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/notifications/TransferNotificationCoordinator.kt @@ -0,0 +1,190 @@ +package com.vnidrop.app.notifications + +import com.vnidrop.app.core.CoreGateway +import com.vnidrop.app.core.CoreSignal +import com.vnidrop.app.core.ReceiverDeliveryStatus +import com.vnidrop.app.core.ReceiverRequestModel +import com.vnidrop.app.core.Transfer +import com.vnidrop.app.core.TransferDirection +import com.vnidrop.app.core.TransferStatus +import com.vnidrop.app.platform.AppVisibility +import com.vnidrop.app.preferences.PreferencesRepository +import com.vnidrop.app.ui.feedback.UiMessageController +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.getString +import vnidrop.shared.generated.resources.Res +import vnidrop.shared.generated.resources.approval_nearby_device +import vnidrop.shared.generated.resources.notifications_receive_completed_body +import vnidrop.shared.generated.resources.notifications_receive_completed_title +import vnidrop.shared.generated.resources.notifications_receive_failed_body +import vnidrop.shared.generated.resources.notifications_receive_failed_title +import vnidrop.shared.generated.resources.notifications_receiver_completed_body +import vnidrop.shared.generated.resources.notifications_receiver_completed_title +import vnidrop.shared.generated.resources.notifications_receiver_failed_body +import vnidrop.shared.generated.resources.notifications_receiver_failed_title +import vnidrop.shared.generated.resources.notifications_send_failed_body +import vnidrop.shared.generated.resources.notifications_send_failed_title +import vnidrop.shared.generated.resources.receive_unknown_transfer + +internal enum class TransferNotificationKind { + SendFailed, + ReceiveCompleted, + ReceiveFailed, + ReceiverCompleted, + ReceiverFailed, +} + +internal data class PlannedTransferNotification( + val id: String, + val kind: TransferNotificationKind, + val transferName: String?, + val receiver: String? = null, +) + +internal fun plannedTransferNotifications( + transfers: List, + published: Set, +): List = transfers.mapNotNull { transfer -> + val kind = when { + transfer.direction == TransferDirection.Send && transfer.status == TransferStatus.Failed -> + TransferNotificationKind.SendFailed + transfer.direction == TransferDirection.Receive && transfer.status == TransferStatus.Done -> + TransferNotificationKind.ReceiveCompleted + transfer.direction == TransferDirection.Receive && transfer.status == TransferStatus.Failed -> + TransferNotificationKind.ReceiveFailed + else -> return@mapNotNull null + } + val id = "${kind.idPrefix}-${transfer.transferId}" + PlannedTransferNotification(id, kind, transfer.transferName).takeUnless { id in published } +} + +internal fun plannedReceiverNotifications( + requests: List, + published: Set, +): List = requests.mapNotNull { request -> + val kind = when (request.status) { + ReceiverDeliveryStatus.Completed -> TransferNotificationKind.ReceiverCompleted + ReceiverDeliveryStatus.Failed -> TransferNotificationKind.ReceiverFailed + else -> return@mapNotNull null + } + val id = "${kind.idPrefix}-${request.id}" + PlannedTransferNotification( + id = id, + kind = kind, + transferName = request.transferName, + receiver = request.receiverName ?: request.receiverDeviceName, + ).takeUnless { id in published } +} + +class TransferNotificationCoordinator( + private val repository: CoreGateway, + private val preferencesRepository: PreferencesRepository, + private val notifications: LocalNotificationService, + private val visibility: AppVisibility, + private val messages: UiMessageController, + private val scope: CoroutineScope, +) { + private val published = mutableSetOf() + private var transfersPrimed = false + private var notificationsEnabled = false + + init { + scope.launch { + preferencesRepository.preferences.collectLatest { preferences -> + notificationsEnabled = preferences.notificationsEnabled + } + } + scope.launch { + repository.state.collect { core -> + if (core.isInitialized) syncTransfers(core.transfers) + } + } + scope.launch { + repository.signals.collect { signal -> + when (signal) { + is CoreSignal.ReceiverHistoryChanged -> syncReceivers(signal.transferId) + is CoreSignal.TransfersChanged -> syncReceivers(signal.transferId) + is CoreSignal.ApprovalChanged -> Unit + } + } + } + } + + private suspend fun syncTransfers(transfers: List) { + val planned = plannedTransferNotifications(transfers, published) + if (!transfersPrimed) { + transfersPrimed = true + published += planned.map(PlannedTransferNotification::id) + return + } + planned.forEach { deliver(it) } + } + + private suspend fun syncReceivers(transferId: ULong) { + val isOutgoing = repository.state.value.transfers.any { + it.transferId == transferId && it.direction == TransferDirection.Send + } + if (!isOutgoing) return + repository.receiverRequests(transferId).fold( + onSuccess = { requests -> + plannedReceiverNotifications(requests, published).forEach { deliver(it) } + }, + onFailure = messages::error, + ) + } + + private suspend fun deliver(plan: PlannedTransferNotification) { + published += plan.id + if ( + !notificationsEnabled || + visibility.isForeground.value || + notifications.permission.value != NotificationPermission.Granted + ) return + val transferName = plan.transferName ?: getString(Res.string.receive_unknown_transfer) + val notification = when (plan.kind) { + TransferNotificationKind.SendFailed -> LocalNotification( + plan.id, + getString(Res.string.notifications_send_failed_title), + getString(Res.string.notifications_send_failed_body, transferName), + ) + TransferNotificationKind.ReceiveCompleted -> LocalNotification( + plan.id, + getString(Res.string.notifications_receive_completed_title), + getString(Res.string.notifications_receive_completed_body, transferName), + ) + TransferNotificationKind.ReceiveFailed -> LocalNotification( + plan.id, + getString(Res.string.notifications_receive_failed_title), + getString(Res.string.notifications_receive_failed_body, transferName), + ) + TransferNotificationKind.ReceiverCompleted -> { + val receiver = plan.receiver ?: getString(Res.string.approval_nearby_device) + LocalNotification( + plan.id, + getString(Res.string.notifications_receiver_completed_title), + getString(Res.string.notifications_receiver_completed_body, receiver, transferName), + ) + } + TransferNotificationKind.ReceiverFailed -> { + val receiver = plan.receiver ?: getString(Res.string.approval_nearby_device) + LocalNotification( + plan.id, + getString(Res.string.notifications_receiver_failed_title), + getString(Res.string.notifications_receiver_failed_body, receiver, transferName), + ) + } + } + notifications.publish(notification).onFailure(messages::error) + } +} + +private val TransferNotificationKind.idPrefix: String + get() = when (this) { + TransferNotificationKind.SendFailed -> "send-failed" + TransferNotificationKind.ReceiveCompleted -> "receive-completed" + TransferNotificationKind.ReceiveFailed -> "receive-failed" + TransferNotificationKind.ReceiverCompleted -> "receiver-completed" + TransferNotificationKind.ReceiverFailed -> "receiver-failed" + } diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/Buttons.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/Buttons.kt index 78e8a79..2f5fc4f 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/Buttons.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/components/Buttons.kt @@ -1,6 +1,8 @@ package com.vnidrop.app.ui.components import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults @@ -17,7 +19,13 @@ import com.vnidrop.app.ui.platform.LocalUiPlatform import com.vnidrop.app.ui.theme.LocalVniDropColors @Composable -fun PrimaryButton(text: String, onClick: () -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true) { +fun PrimaryButton( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + leadingIcon: @Composable (() -> Unit)? = null, +) { val desktop = LocalUiPlatform.current.isDesktop Button( onClick = onClick, @@ -26,12 +34,22 @@ fun PrimaryButton(text: String, onClick: () -> Unit, modifier: Modifier = Modifi shape = RoundedCornerShape(if (desktop) 6.dp else 8.dp), colors = ButtonDefaults.buttonColors(containerColor = LocalVniDropColors.current.brandButton, contentColor = Color.White), ) { + leadingIcon?.let { + it() + Spacer(Modifier.width(8.dp)) + } Text(text, maxLines = 1, overflow = TextOverflow.Ellipsis) } } @Composable -fun SecondaryButton(text: String, onClick: () -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true) { +fun SecondaryButton( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + leadingIcon: @Composable (() -> Unit)? = null, +) { val desktop = LocalUiPlatform.current.isDesktop OutlinedButton( onClick = onClick, @@ -39,6 +57,10 @@ fun SecondaryButton(text: String, onClick: () -> Unit, modifier: Modifier = Modi modifier = modifier.heightIn(min = if (desktop) 36.dp else 44.dp), shape = RoundedCornerShape(if (desktop) 6.dp else 8.dp), ) { + leadingIcon?.let { + it() + Spacer(Modifier.width(8.dp)) + } Text(text, maxLines = 1, overflow = TextOverflow.Ellipsis) } } @@ -65,7 +87,13 @@ fun DestructiveQuietButton(text: String, onClick: () -> Unit, modifier: Modifier } @Composable -fun DestructiveButton(text: String, onClick: () -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true) { +fun DestructiveButton( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + leadingIcon: @Composable (() -> Unit)? = null, +) { val desktop = LocalUiPlatform.current.isDesktop Button( onClick = onClick, @@ -77,6 +105,10 @@ fun DestructiveButton(text: String, onClick: () -> Unit, modifier: Modifier = Mo contentColor = Color.White, ), ) { + leadingIcon?.let { + it() + Spacer(Modifier.width(8.dp)) + } Text(text, maxLines = 1, overflow = TextOverflow.Ellipsis) } } diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/feedback/UiMessageController.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/feedback/UiMessageController.kt index b77dfa8..fee8974 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/feedback/UiMessageController.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/feedback/UiMessageController.kt @@ -10,7 +10,7 @@ import kotlinx.coroutines.flow.receiveAsFlow import org.jetbrains.compose.resources.StringResource sealed interface UiText { - data class Resource(val resource: StringResource) : UiText + data class Resource(val resource: StringResource, val formatArgs: List = emptyList()) : UiText data class Dynamic(val value: String) : UiText } diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/feedback/VniDropSnackbarHost.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/feedback/VniDropSnackbarHost.kt index 7e8ce53..f5b3488 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/feedback/VniDropSnackbarHost.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/feedback/VniDropSnackbarHost.kt @@ -138,5 +138,5 @@ private fun DismissButton(onClick: () -> Unit) { private suspend fun UiText.resolve(): String = when (this) { is UiText.Dynamic -> value - is UiText.Resource -> getString(resource) + is UiText.Resource -> getString(resource, *formatArgs.toTypedArray()) } diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/icons/PlatformIcons.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/icons/PlatformIcons.kt index bd7e6a0..909041e 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/icons/PlatformIcons.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/icons/PlatformIcons.kt @@ -39,12 +39,19 @@ internal enum class AppIcon( Info(Res.drawable.icon_material_info, Res.drawable.icon_fluent_info, Res.drawable.icon_lucide_info), Lock(Res.drawable.icon_material_lock, Res.drawable.icon_fluent_lock, Res.drawable.icon_lucide_lock), Megaphone(Res.drawable.icon_material_megaphone, Res.drawable.icon_fluent_megaphone, Res.drawable.icon_lucide_megaphone), + MoreVertical( + Res.drawable.icon_material_more_vertical, + Res.drawable.icon_fluent_more_vertical, + Res.drawable.icon_lucide_more_vertical, + ), Moon(Res.drawable.icon_material_moon, Res.drawable.icon_fluent_moon, Res.drawable.icon_lucide_moon), Nfc(Res.drawable.icon_material_nfc, Res.drawable.icon_fluent_nfc, Res.drawable.icon_lucide_nfc), QrCode(Res.drawable.icon_material_qr_code, Res.drawable.icon_fluent_qr_code, Res.drawable.icon_lucide_qr_code), Radio(Res.drawable.icon_material_radio, Res.drawable.icon_fluent_radio, Res.drawable.icon_lucide_radio), Scan(Res.drawable.icon_material_scan, Res.drawable.icon_fluent_scan, Res.drawable.icon_lucide_scan), Send(Res.drawable.icon_material_send, Res.drawable.icon_fluent_send, Res.drawable.icon_lucide_send), + Share(Res.drawable.icon_material_share, Res.drawable.icon_fluent_share, Res.drawable.icon_lucide_share), + Sparkles(Res.drawable.icon_material_sparkles, Res.drawable.icon_fluent_sparkles, Res.drawable.icon_lucide_sparkles), Settings(Res.drawable.icon_material_settings, Res.drawable.icon_fluent_settings, Res.drawable.icon_lucide_settings), Shield(Res.drawable.icon_material_shield, Res.drawable.icon_fluent_shield, Res.drawable.icon_lucide_shield), ShieldCheck( @@ -52,6 +59,11 @@ internal enum class AppIcon( Res.drawable.icon_fluent_shield_check, Res.drawable.icon_lucide_shield_check, ), + StopCircle( + Res.drawable.icon_material_stop_circle, + Res.drawable.icon_fluent_stop_circle, + Res.drawable.icon_lucide_stop_circle, + ), Storage(Res.drawable.icon_material_storage, Res.drawable.icon_fluent_storage, Res.drawable.icon_lucide_storage), Sun(Res.drawable.icon_material_sun, Res.drawable.icon_fluent_sun, Res.drawable.icon_lucide_sun), Sync(Res.drawable.icon_material_sync, Res.drawable.icon_fluent_sync, Res.drawable.icon_lucide_sync), diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt index 7d9535a..2cd18a5 100644 --- a/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt @@ -5,6 +5,7 @@ import com.vnidrop.app.PlatformEnvironment import com.vnidrop.app.core.CoreState import com.vnidrop.app.core.CoreStatus import com.vnidrop.app.core.CoreSignal +import com.vnidrop.app.core.CoreStorageUsageModel import com.vnidrop.app.core.PickedShareFile import com.vnidrop.app.core.ReceiveFolder import com.vnidrop.app.core.ReceiveFolderKind @@ -26,6 +27,7 @@ import com.vnidrop.app.feature.app.AppViewModel import com.vnidrop.app.feature.receive.ReceiveHistoryDeleteTarget import com.vnidrop.app.feature.receive.ReceiveViewModel import com.vnidrop.app.feature.send.SendViewModel +import com.vnidrop.app.feature.send.TransferDetailPanel import com.vnidrop.app.feature.settings.SettingsSection import com.vnidrop.app.feature.settings.RelaySettingsApplyError import com.vnidrop.app.feature.settings.RelaySettingsInputError @@ -168,6 +170,43 @@ class ViewModelsTest { assertEquals(0, core.clearTransferCacheCount) } + @Test + fun settingsFreesOnlyPlatformOwnedTemporaryStorage() = runTest { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + val fileSystem = FakeFileSystemService(folder).apply { + reclaimedTemporaryBytes = 12_000UL + } + val viewModel = settingsViewModel(fileSystem = fileSystem) + advanceUntilIdle() + + viewModel.freeUpSpace() + advanceUntilIdle() + + assertEquals(1, fileSystem.reclaimTemporaryStorageCount) + assertFalse(viewModel.state.value.isCleaningStorage) + } + + @Test + fun settingsRetriesStorageAfterCoreFinishesStarting() = runTest { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + val core = FakeCoreGateway().apply { + storageUsageResult = Result.failure(IllegalStateException("not initialized")) + } + val viewModel = settingsViewModel(repository = core) + advanceUntilIdle() + + viewModel.selectSection(SettingsSection.Storage) + advanceUntilIdle() + assertTrue(viewModel.state.value.storageLoadFailed) + + core.storageUsageResult = Result.success(CoreStorageUsageModel(25UL, 10UL, 5UL, 0UL, 0UL)) + core.mutableState.value = core.mutableState.value.copy(isInitialized = true) + advanceUntilIdle() + + assertFalse(viewModel.state.value.storageLoadFailed) + assertEquals(25UL, viewModel.state.value.storage?.transferCacheBytes) + } + @Test fun settingsDeleteAllTransfersImmediatelyClearsUnusedCache() = runTest { Dispatchers.setMain(StandardTestDispatcher(testScheduler)) @@ -533,10 +572,33 @@ class ViewModelsTest { assertEquals(null, viewModel.state.value.selectedFile) assertEquals(ShareAccessPolicy.AnyoneWithTransfer, core.lastShareAccessPolicy) assertEquals(7UL, core.state.value.transfers.first().transferId) + assertEquals(7UL, viewModel.state.value.selectedTransferId) + assertEquals(TransferDetailPanel.Share, viewModel.state.value.detailPanel) assertContentEquals(thumbnail, previews.previews.value.getValue(7UL)) assertEquals(listOf(selected), fileSystem.discardedPickedFiles) } + @Test + fun sendViewModelStopsSharingFromCatalogAction() = runTest { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + val core = FakeCoreGateway().apply { + mutableState.value = CoreState(isInitialized = true, transfers = listOf(sentTransfer(7UL))) + } + val viewModel = SendViewModel( + core, + FakeFileSystemService(folder), + preferences(), + FakeFilePreviewRepository(), + UiMessageController(), + ) + advanceUntilIdle() + + viewModel.stopSharing(7UL) + advanceUntilIdle() + + assertEquals(listOf(7UL), core.cancelledTransfers) + } + @Test fun sendComposerStaysOpenWhenShareCreationFails() = runTest { Dispatchers.setMain(StandardTestDispatcher(testScheduler)) diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/notifications/TransferNotificationCoordinatorTest.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/notifications/TransferNotificationCoordinatorTest.kt new file mode 100644 index 0000000..66f7cbb --- /dev/null +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/notifications/TransferNotificationCoordinatorTest.kt @@ -0,0 +1,72 @@ +package com.vnidrop.app.notifications + +import com.vnidrop.app.core.ReceiverDeliveryStatus +import com.vnidrop.app.core.ReceiverRequestModel +import com.vnidrop.app.core.ShareAccessPolicy +import com.vnidrop.app.core.Transfer +import com.vnidrop.app.core.TransferDirection +import com.vnidrop.app.core.TransferStatus +import kotlin.test.Test +import kotlin.test.assertEquals + +class TransferNotificationCoordinatorTest { + @Test + fun plansOnlyNewTerminalTransferOutcomes() { + val transfers = listOf( + transfer(1UL, TransferDirection.Receive, TransferStatus.Done), + transfer(2UL, TransferDirection.Receive, TransferStatus.Failed), + transfer(3UL, TransferDirection.Send, TransferStatus.Failed), + transfer(4UL, TransferDirection.Send, TransferStatus.Sharing), + ) + + assertEquals( + listOf("receive-completed-1", "send-failed-3"), + plannedTransferNotifications(transfers, setOf("receive-failed-2")).map { it.id }, + ) + } + + @Test + fun plansCompletedAndFailedReceiverOutcomesOnce() { + val requests = listOf( + request("completed", ReceiverDeliveryStatus.Completed), + request("failed", ReceiverDeliveryStatus.Failed), + request("accepted", ReceiverDeliveryStatus.Accepted), + ) + + assertEquals( + listOf("receiver-failed-failed"), + plannedReceiverNotifications(requests, setOf("receiver-completed-completed")).map { it.id }, + ) + } + + private fun transfer(id: ULong, direction: TransferDirection, status: TransferStatus) = Transfer( + localId = "local-$id", + transferId = id, + direction = direction, + status = status, + peerId = null, + transferName = "Transfer $id", + contentHash = null, + fileCount = 1UL, + totalSize = 10UL, + ticket = null, + accessPolicy = ShareAccessPolicy.RequireApproval, + createdAt = 1L, + updatedAt = 1L, + ) + + private fun request(id: String, status: ReceiverDeliveryStatus) = ReceiverRequestModel( + id = id, + transferId = 1UL, + remoteEndpointId = "peer-$id", + transferName = "Transfer", + receiverName = "Receiver", + receiverDeviceName = null, + appVersion = "1.0", + status = status, + reason = null, + requestedAt = 1L, + respondedAt = 2L, + completedAt = 3L, + ) +} diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/support/Fakes.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/support/Fakes.kt index 4e09b09..a912d6d 100644 --- a/shared/src/commonTest/kotlin/com/vnidrop/app/support/Fakes.kt +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/support/Fakes.kt @@ -48,6 +48,9 @@ class FakeCoreGateway : CoreGateway { private var receiveGate: CompletableDeferred? = null var deleteResult: Result = Result.success(Unit) var clearTransferCacheResult: Result = Result.success(0UL) + var storageUsageResult: Result = Result.success( + CoreStorageUsageModel(0UL, 0UL, 0UL, 0UL, 0UL), + ) var clearReceiveHistoryResult: Result = Result.success(0UL) val deletedTransfers = mutableListOf() val cancelledTransfers = mutableListOf() @@ -153,9 +156,7 @@ class FakeCoreGateway : CoreGateway { awaitReceiveIfNeeded() return receiveResult } - override suspend fun storageUsage(): Result = Result.success( - CoreStorageUsageModel(0UL, 0UL, 0UL, 0UL, 0UL), - ) + override suspend fun storageUsage(): Result = storageUsageResult override suspend fun clearTransferCache(): Result { clearTransferCacheCount += 1 return clearTransferCacheResult @@ -248,6 +249,8 @@ class FakeFileSystemService( var supportsCustomFolders = true var effectiveFolder: ReceiveFolder? = null var canRevealFolder = false + var reclaimedTemporaryBytes = 0UL + var reclaimTemporaryStorageCount = 0 var revealFolderResult: Result = Result.success(Unit) val revealedFolders = mutableListOf() val discardedPickedFiles = mutableListOf() @@ -259,6 +262,10 @@ class FakeFileSystemService( override suspend fun inspectReceivedArtifacts(artifacts: List) = ReceivedStorageInspection(artifacts.fold(0UL) { total, item -> total + item.logicalSize }, artifacts.size, 0, 0) override suspend fun temporaryUsage(receiveFolder: ReceiveFolder): ULong = 0UL + override suspend fun reclaimTemporaryStorage(appDataDir: String, receiveFolder: ReceiveFolder): ULong { + reclaimTemporaryStorageCount += 1 + return reclaimedTemporaryBytes + } override fun createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSinkV2? = null override fun canRevealReceiveFolder(folder: ReceiveFolder) = canRevealFolder override suspend fun revealReceiveFolder(folder: ReceiveFolder): Result { diff --git a/shared/src/jvmMain/kotlin/com/vnidrop/app/core/FileSystemService.jvm.kt b/shared/src/jvmMain/kotlin/com/vnidrop/app/core/FileSystemService.jvm.kt index c566035..a6d17e1 100644 --- a/shared/src/jvmMain/kotlin/com/vnidrop/app/core/FileSystemService.jvm.kt +++ b/shared/src/jvmMain/kotlin/com/vnidrop/app/core/FileSystemService.jvm.kt @@ -48,6 +48,9 @@ private class JvmFileSystemService : FileSystemService { return desktopTemporaryUsage(receiveFolder) } + override suspend fun reclaimTemporaryStorage(appDataDir: String, receiveFolder: ReceiveFolder): ULong = + desktopReclaimTemporaryStorage(appDataDir, receiveFolder) + override fun createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSinkV2? = null override suspend fun sharePickedFiles( @@ -88,3 +91,35 @@ internal fun desktopTemporaryUsage(receiveFolder: ReceiveFolder): ULong { } }.getOrDefault(0UL) } + +internal fun desktopReclaimTemporaryStorage(appDataDir: String, receiveFolder: ReceiveFolder): ULong { + var reclaimed = 0UL + if (receiveFolder.kind == ReceiveFolderKind.FileSystemPath) { + val receiveRoot = File(receiveFolder.value) + receiveRoot.walkTopDown() + .filter { file -> + file.isFile && + file.name.startsWith(".") && + file.name.contains(".vnidrop-") && + file.name.endsWith(".part") + } + .toList() + .forEach { file -> + val size = file.length().coerceAtLeast(0L).toULong() + if (file.delete()) reclaimed += size + } + } + val appDataRoot = File(appDataDir) + if (appDataRoot.isDirectory) { + appDataRoot.walkTopDown() + .filter { it.isDirectory && it.name == ".Trash" } + .toList() + .forEach { trash -> + val size = trash.walkTopDown() + .filter(File::isFile) + .fold(0UL) { total, file -> total + file.length().coerceAtLeast(0L).toULong() } + if (trash.deleteRecursively()) reclaimed += size + } + } + return reclaimed +} diff --git a/shared/src/jvmTest/kotlin/com/vnidrop/app/core/FileSystemServiceTest.kt b/shared/src/jvmTest/kotlin/com/vnidrop/app/core/FileSystemServiceTest.kt index b8909f7..ae3a97d 100644 --- a/shared/src/jvmTest/kotlin/com/vnidrop/app/core/FileSystemServiceTest.kt +++ b/shared/src/jvmTest/kotlin/com/vnidrop/app/core/FileSystemServiceTest.kt @@ -5,6 +5,8 @@ import kotlin.io.path.createDirectories import kotlin.io.path.createTempDirectory import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue class FileSystemServiceTest { @Test @@ -26,4 +28,32 @@ class FileSystemServiceTest { root.toFile().deleteRecursively() } } + + @Test + fun desktopReclaimTemporaryStorageKeepsUserFiles() { + val root = createTempDirectory("vnidrop-storage-cleanup") + try { + val receive = root.resolve("receive").createDirectories() + val appData = root.resolve("app-data").createDirectories() + val partial = receive.resolve(".photo.jpg.vnidrop-test.part") + val received = receive.resolve("photo.jpg") + val trash = appData.resolve("nested/.Trash").createDirectories() + Files.write(partial, ByteArray(7)) + Files.write(received, ByteArray(13)) + Files.write(trash.resolve("stale.bin"), ByteArray(11)) + + assertEquals( + 18UL, + desktopReclaimTemporaryStorage( + appData.toString(), + ReceiveFolder(ReceiveFolderKind.FileSystemPath, receive.toString(), "Test"), + ), + ) + assertFalse(Files.exists(partial)) + assertFalse(Files.exists(trash)) + assertTrue(Files.exists(received)) + } finally { + root.toFile().deleteRecursively() + } + } } diff --git a/shared/src/jvmTest/kotlin/com/vnidrop/app/ui/FoundationComposeTest.kt b/shared/src/jvmTest/kotlin/com/vnidrop/app/ui/FoundationComposeTest.kt index bbf7060..3183bc9 100644 --- a/shared/src/jvmTest/kotlin/com/vnidrop/app/ui/FoundationComposeTest.kt +++ b/shared/src/jvmTest/kotlin/com/vnidrop/app/ui/FoundationComposeTest.kt @@ -40,6 +40,7 @@ import com.vnidrop.app.feature.receive.ReceiveState import com.vnidrop.app.feature.settings.SettingsScreen import com.vnidrop.app.feature.settings.SettingsSection import com.vnidrop.app.feature.settings.SettingsState +import com.vnidrop.app.feature.settings.StorageBreakdown import com.vnidrop.app.feature.settings.SettingsOverview import com.vnidrop.app.feature.send.SendScreen import com.vnidrop.app.feature.send.SendState @@ -111,7 +112,7 @@ class FoundationComposeTest { } } onNodeWithText("Notifications").performClick() - onNodeWithText("Get notified about new receive requests while VniDrop is in the background.").assertIsDisplayed() + onNodeWithText("Get notified about transfer activity while VniDrop is in the background.").assertIsDisplayed() } @Test @@ -222,6 +223,48 @@ class FoundationComposeTest { runOnIdle { assertTrue(deleteRequested) } } + @Test + fun storageKeepsCurrentUsageVisibleWhileRefreshing() = runComposeUiTest { + setContent { + VniDropTheme(isDarkTheme = false) { + SettingsScreen( + state = SettingsState( + selectedSection = SettingsSection.Storage, + isCalculatingStorage = true, + storage = StorageBreakdown( + transferCacheBytes = 1UL, + appDataBytes = 2UL, + temporaryBytes = 3UL, + receivedBytes = 4UL, + receivedFileCount = 1, + missingReceivedFileCount = 0, + inaccessibleReceivedFileCount = 0, + ), + ), + windowClass = WindowClass.Desktop, + onSectionSelected = {}, + onUsernameChanged = {}, + onThemeModeChanged = {}, + onChooseFolder = {}, + onResetFolder = {}, + onNotificationsChanged = {}, + onOpenNotificationSettings = {}, + onDiagnosticsChanged = {}, + onBugWhatChanged = {}, + onBugExpectedChanged = {}, + onBugStepsChanged = {}, + onBugContactChanged = {}, + onBugIncludeLogsChanged = {}, + onSubmitBugReport = {}, + ) + } + } + + onNodeWithText("Received files").assertIsDisplayed() + onNodeWithText("Transfer data").assertIsDisplayed() + onAllNodesWithText("Calculating storage usage…").assertCountEquals(0) + } + @Test fun aboutSettingsShowsTheSharedProductAndPrivacyContent() = runComposeUiTest { setContent { @@ -636,10 +679,13 @@ class FoundationComposeTest { } } - onNodeWithText("Share").assertIsDisplayed() + onNodeWithContentDescription("Share").assertIsDisplayed() onAllNodesWithText("Scan with VniDrop to receive this transfer").assertCountEquals(0) - onNode(hasText("Share") and hasClickAction()).performClick() + onNodeWithContentDescription("Share").performClick() runOnIdle { assertEquals(com.vnidrop.app.feature.send.TransferDetailPanel.Share, state.value.detailPanel) } + waitUntil(timeoutMillis = 5_000) { + onAllNodesWithText("Scan with VniDrop to receive this transfer").fetchSemanticsNodes().isNotEmpty() + } onNodeWithText("Scan with VniDrop to receive this transfer").assertIsDisplayed() onNodeWithText("Save .vnd file").assertIsDisplayed() onNodeWithContentDescription("Close").assertIsDisplayed() diff --git a/shared/tools/import_platform_icons.py b/shared/tools/import_platform_icons.py index 4dc0978..3633f87 100644 --- a/shared/tools/import_platform_icons.py +++ b/shared/tools/import_platform_icons.py @@ -48,15 +48,19 @@ ICONS = ( IconSource("info", "info", "Info", "info", "info"), IconSource("lock", "lock", "Lock Closed", "lock_closed", "lock"), IconSource("megaphone", "campaign", "Megaphone", "megaphone", "megaphone"), + IconSource("more_vertical", "more_vert", "More Vertical", "more_vertical", "ellipsis-vertical"), IconSource("moon", "dark_mode", "Weather Moon", "weather_moon", "moon"), IconSource("nfc", "nfc", "Tap Double", "tap_double", "nfc"), IconSource("qr_code", "qr_code_scanner", "QR Code", "qr_code", "qr-code"), IconSource("radio", "cell_tower", "Cellular Data 1", "cellular_data_1", "radio-tower"), IconSource("scan", "document_scanner", "Scan Type", "scan_type", "scan-line"), IconSource("send", "send", "Send", "send", "send"), + IconSource("share", "share", "Share", "share", "share-2"), + IconSource("sparkles", "auto_awesome", "Sparkle", "sparkle", "sparkles"), IconSource("settings", "settings", "Settings", "settings", "settings"), IconSource("shield", "shield", "Shield", "shield", "shield"), IconSource("shield_check", "verified_user", "Shield Checkmark", "shield_checkmark", "shield-check"), + IconSource("stop_circle", "stop_circle", "Stop", "stop", "circle-stop"), IconSource("storage", "database", "Database", "database", "database"), IconSource("sun", "light_mode", "Weather Sunny", "weather_sunny", "sun"), IconSource("sync", "sync", "Arrow Sync", "arrow_sync", "refresh-cw"),