Merge pull request #31 from sudosylabs/feat/apple-typed-resources-and-fixes

Apple: typed resources and UX fixes
This commit is contained in:
Hammed Abass
2026-07-24 21:16:56 +02:00
committed by GitHub
102 changed files with 3476 additions and 18304 deletions

View File

@@ -12,6 +12,7 @@ on:
- "config.mk" - "config.mk"
- "make/**" - "make/**"
- ".github/workflows/apple.yml" - ".github/workflows/apple.yml"
- "localization/**"
push: push:
branches: branches:
- master - master
@@ -25,6 +26,7 @@ on:
- "config.mk" - "config.mk"
- "make/**" - "make/**"
- ".github/workflows/apple.yml" - ".github/workflows/apple.yml"
- "localization/**"
permissions: permissions:
contents: read contents: read
@@ -63,5 +65,14 @@ jobs:
- name: Install XcodeGen - name: Install XcodeGen
run: brew 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 - name: Build and test Apple app
run: make check-apple run: make check-apple

View File

@@ -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. 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) 9. After code changes, run the **relevant** checks in [Build and test](#build-and-test)
and fix failures before finishing. 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 - Flaky multi-minute sleeps in tests
- Unsigned commits when signing is required - Unsigned commits when signing is required
- Force-push or secret commits without explicit user direction - 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`
--- ---

View File

@@ -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; } @test "$(HOST_OS)" = macos || { printf 'Apple builds require macOS.\n' >&2; exit 1; }
cd $(ROOT) && apple/scripts/build-core.sh $(APPLE_PROFILE) 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 cd $(ROOT)/apple && $(XCODEGEN) generate
open-apple-project: apple-project ## Generate and open the native Apple Xcode project. open-apple-project: apple-project ## Generate and open the native Apple Xcode project.

4
apple/.gitignore vendored
View File

@@ -3,6 +3,10 @@
VnidropCore/vnidrop.xcframework/ VnidropCore/vnidrop.xcframework/
VnidropCore/Sources/VnidropCore/Vnidrop.swift 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 # Generated by XcodeGen from project.yml
VniDrop.xcodeproj/ VniDrop.xcodeproj/

34
apple/.swiftlint.yml Normal file
View File

@@ -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

View File

@@ -19,7 +19,6 @@ final class AppPreferencesRepositoryTests: XCTestCase {
let repo = AppPreferencesRepository(defaults: defaults(), fallback: fallback()) let repo = AppPreferencesRepository(defaults: defaults(), fallback: fallback())
XCTAssertEqual(repo.preferences.username, "Default") XCTAssertEqual(repo.preferences.username, "Default")
XCTAssertEqual(repo.preferences.themeMode, .system) XCTAssertEqual(repo.preferences.themeMode, .system)
XCTAssertFalse(repo.preferences.notificationsEnabled)
XCTAssertEqual(repo.preferences.relayConfiguration, .automatic) XCTAssertEqual(repo.preferences.relayConfiguration, .automatic)
} }
@@ -29,7 +28,6 @@ final class AppPreferencesRepositoryTests: XCTestCase {
let repo = AppPreferencesRepository(defaults: store, fallback: fb) let repo = AppPreferencesRepository(defaults: store, fallback: fb)
repo.setUsername("Bob") repo.setUsername("Bob")
repo.setThemeMode(.dark) repo.setThemeMode(.dark)
repo.setNotificationsEnabled(true)
repo.setReceiveFolder(ReceiveFolder(kind: .iosSecurityScopedUrl, value: "file:///x", displayName: "Custom")) repo.setReceiveFolder(ReceiveFolder(kind: .iosSecurityScopedUrl, value: "file:///x", displayName: "Custom"))
repo.setRelayConfiguration(RelayConfiguration( repo.setRelayConfiguration(RelayConfiguration(
mode: .strictCustom, mode: .strictCustom,
@@ -40,7 +38,6 @@ final class AppPreferencesRepositoryTests: XCTestCase {
let reloaded = AppPreferencesRepository(defaults: store, fallback: fb) let reloaded = AppPreferencesRepository(defaults: store, fallback: fb)
XCTAssertEqual(reloaded.preferences.username, "Bob") XCTAssertEqual(reloaded.preferences.username, "Bob")
XCTAssertEqual(reloaded.preferences.themeMode, .dark) XCTAssertEqual(reloaded.preferences.themeMode, .dark)
XCTAssertTrue(reloaded.preferences.notificationsEnabled)
XCTAssertEqual(reloaded.preferences.receiveFolder.displayName, "Custom") XCTAssertEqual(reloaded.preferences.receiveFolder.displayName, "Custom")
XCTAssertEqual(reloaded.preferences.receiveFolder.kind, .iosSecurityScopedUrl) XCTAssertEqual(reloaded.preferences.receiveFolder.kind, .iosSecurityScopedUrl)
XCTAssertEqual(reloaded.preferences.relayConfiguration, RelayConfiguration( XCTAssertEqual(reloaded.preferences.relayConfiguration, RelayConfiguration(

View File

@@ -11,7 +11,6 @@ final class ApprovalCoordinatorTests: XCTestCase {
private func makeCoordinator(_ core: FakeCoreGateway) -> ApprovalCoordinator { private func makeCoordinator(_ core: FakeCoreGateway) -> ApprovalCoordinator {
ApprovalCoordinator( ApprovalCoordinator(
repository: core, repository: core,
preferences: Fixtures.preferences(),
notifications: LocalNotificationService(), notifications: LocalNotificationService(),
visibility: AppVisibility(), visibility: AppVisibility(),
messages: UiMessageController() messages: UiMessageController()

View File

@@ -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)
}
}
}

View File

@@ -40,7 +40,7 @@ final class ProgressDerivationTests: XCTestCase {
event(phase: "import", kind: "started", json: "{}"), event(phase: "import", kind: "started", json: "{}"),
] ]
let progress = progressForTransfer(events: events, transferId: 1) let progress = progressForTransfer(events: events, transferId: 1)
XCTAssertEqual(progress?.labelKey, "progress_preparing") XCTAssertEqual(progress?.labelKey, L10n.Progress.preparing)
XCTAssertEqual(progress?.progress, 0.3) XCTAssertEqual(progress?.progress, 0.3)
} }
@@ -57,15 +57,15 @@ final class ProgressDerivationTests: XCTestCase {
remoteEndpointId: "peer-a", remoteEndpointId: "peer-a",
totalSizeHint: 100 totalSizeHint: 100
) )
XCTAssertEqual(progress?.kind, "completed") XCTAssertEqual(progress?.kind, .completed)
XCTAssertEqual(progress?.labelKey, "progress_completed") XCTAssertEqual(progress?.labelKey, L10n.Progress.completed)
XCTAssertEqual(progress?.progress, 1) XCTAssertEqual(progress?.progress, 1)
} }
func testStatusLabelKeys() { func testStatusLabelKeys() {
XCTAssertEqual(statusLabelKey(.sharing), "status_available") XCTAssertEqual(statusLabelKey(.sharing), L10n.Status.available)
XCTAssertEqual(statusLabelKey(.receiving), "status_receiving") XCTAssertEqual(statusLabelKey(.receiving), L10n.Status.receiving)
XCTAssertEqual(statusLabelKey(.done), "status_completed") XCTAssertEqual(statusLabelKey(.done), L10n.Status.completed)
} }
private func event(phase: String, kind: String, json: String) -> CoreEventModel { private func event(phase: String, kind: String, json: String) -> CoreEventModel {

View File

@@ -24,6 +24,9 @@ final class ReceiveModelTests: XCTestCase {
XCTAssertEqual(model.state.historyDeleteTarget, .transfer(transferId: 5)) XCTAssertEqual(model.state.historyDeleteTarget, .transfer(transferId: 5))
model.confirmHistoryDelete() 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) } await waitUntil { core.deletedTransfers.contains(5) }
XCTAssertEqual(core.deletedTransfers, [5]) XCTAssertEqual(core.deletedTransfers, [5])
XCTAssertNil(model.state.historyDeleteTarget) XCTAssertNil(model.state.historyDeleteTarget)

View File

@@ -32,6 +32,9 @@ final class SendModelTests: XCTestCase {
XCTAssertTrue(model.state.isDeleteConfirmationOpen) XCTAssertTrue(model.state.isDeleteConfirmationOpen)
model.confirmDeleteTransfer() 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) } await waitUntil { core.deletedTransfers.contains(3) }
XCTAssertEqual(core.deletedTransfers, [3]) XCTAssertEqual(core.deletedTransfers, [3])
XCTAssertNil(model.state.selectedTransferId) XCTAssertNil(model.state.selectedTransferId)

View File

@@ -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)
}
}

View File

@@ -42,22 +42,22 @@ final class UserFacingErrorTests: XCTestCase {
} }
func testToUiTextMapsKnownReasons() { func testToUiTextMapsKnownReasons() {
XCTAssertEqual(InvitationError.message("The transfer was refused").toUiText(), .resource("error_permission")) XCTAssertEqual(InvitationError.message("The transfer was refused").toUiText(), .resource(L10n.Error.permission))
XCTAssertEqual(InvitationError.message("invalid ticket").toUiText(), .resource("error_invalid_ticket")) XCTAssertEqual(InvitationError.message("invalid ticket").toUiText(), .resource(L10n.Error.invalidTicket))
XCTAssertEqual(InvitationError.message("Select at least one file to share").toUiText(), .resource("error_share_empty")) XCTAssertEqual(InvitationError.message("Select at least one file to share").toUiText(), .resource(L10n.Error.shareEmpty))
XCTAssertEqual(InvitationError.message("Camera access is required").toUiText(), .resource("error_camera")) XCTAssertEqual(InvitationError.message("Camera access is required").toUiText(), .resource(L10n.Error.camera))
} }
func testToUiTextFallsBackToGeneric() { 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() { func testToUiTextMapsTypedTransferFailures() {
XCTAssertEqual(VnidropError.FilesystemPermission(reason: "read-only folder").toUiText(), .resource("error_filesystem")) XCTAssertEqual(VnidropError.FilesystemPermission(reason: "read-only folder").toUiText(), .resource(L10n.Error.filesystem))
XCTAssertEqual(VnidropError.DestinationExists(reason: "target exists").toUiText(), .resource("error_destination_exists")) XCTAssertEqual(VnidropError.DestinationExists(reason: "target exists").toUiText(), .resource(L10n.Error.destinationExists))
XCTAssertEqual(VnidropError.StorageFull(reason: "disk full").toUiText(), .resource("error_storage_full")) XCTAssertEqual(VnidropError.StorageFull(reason: "disk full").toUiText(), .resource(L10n.Error.storageFull))
XCTAssertEqual(VnidropError.Network(reason: "offline").toUiText(), .resource("error_network")) XCTAssertEqual(VnidropError.Network(reason: "offline").toUiText(), .resource(L10n.Error.network))
XCTAssertEqual(VnidropError.InvalidInput(reason: "bad path").toUiText(), .resource("error_invalid_input")) XCTAssertEqual(VnidropError.InvalidInput(reason: "bad path").toUiText(), .resource(L10n.Error.invalidInput))
XCTAssertFalse(VnidropError.FilesystemPermission(reason: "read-only").canRetryWithoutChangingInput) XCTAssertFalse(VnidropError.FilesystemPermission(reason: "read-only").canRetryWithoutChangingInput)
XCTAssertFalse(VnidropError.DestinationExists(reason: "target exists").canRetryWithoutChangingInput) XCTAssertFalse(VnidropError.DestinationExists(reason: "target exists").canRetryWithoutChangingInput)
XCTAssertTrue(VnidropError.Network(reason: "offline").canRetryWithoutChangingInput) XCTAssertTrue(VnidropError.Network(reason: "offline").canRetryWithoutChangingInput)

View File

@@ -12,6 +12,7 @@ final class AppGraph: ObservableObject {
let preferencesRepository: AppPreferencesRepository let preferencesRepository: AppPreferencesRepository
let filePreviewRepository: FilePreviewRepository let filePreviewRepository: FilePreviewRepository
let approvalCoordinator: ApprovalCoordinator let approvalCoordinator: ApprovalCoordinator
let transferNotificationCoordinator: TransferNotificationCoordinator
init(dependencies: AppDependencies, coreRepository: CoreRepository? = nil) { init(dependencies: AppDependencies, coreRepository: CoreRepository? = nil) {
self.dependencies = dependencies self.dependencies = dependencies
@@ -23,13 +24,17 @@ final class AppGraph: ObservableObject {
username: dependencies.environment.defaultUsername, username: dependencies.environment.defaultUsername,
receiveFolder: dependencies.fileSystemService.defaultReceiveFolder(), receiveFolder: dependencies.fileSystemService.defaultReceiveFolder(),
themeMode: .system, themeMode: .system,
notificationsEnabled: false,
diagnosticsEnabled: false diagnosticsEnabled: false
) )
) )
self.approvalCoordinator = ApprovalCoordinator( self.approvalCoordinator = ApprovalCoordinator(
repository: coreRepository, repository: coreRepository,
preferences: preferencesRepository, notifications: dependencies.notificationService,
visibility: visibility,
messages: messages
)
self.transferNotificationCoordinator = TransferNotificationCoordinator(
repository: coreRepository,
notifications: dependencies.notificationService, notifications: dependencies.notificationService,
visibility: visibility, visibility: visibility,
messages: messages messages: messages

View File

@@ -1,3 +1,4 @@
import SFSafeSymbols
import SwiftUI import SwiftUI
/// App root, ported from `App.kt`. Owns the object graph and feature models, wires /// App root, ported from `App.kt`. Owns the object graph and feature models, wires
@@ -62,6 +63,14 @@ struct RootView: View {
onRefuse: approvals.refuse 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) .vniDropTheme(isDark: isDark)
.preferredColorScheme(appModel.themeMode.preferredColorScheme) .preferredColorScheme(appModel.themeMode.preferredColorScheme)
.environment(\.vniColors, isDark ? .dark : .light) .environment(\.vniColors, isDark ? .dark : .light)
@@ -111,7 +120,7 @@ struct RootView: View {
#if os(macOS) #if os(macOS)
NavigationSplitView { NavigationSplitView {
List(AppDestination.allCases, selection: sidebarBinding) { destination in List(AppDestination.allCases, selection: sidebarBinding) { destination in
Label(LocalizedStringKey(destination.labelKey), systemImage: destination.systemImage) Label(String(localized: destination.labelKey), systemSymbol: destination.systemSymbol)
.tag(destination) .tag(destination)
} }
.navigationSplitViewColumnWidth(min: 180, ideal: 200, max: 260) .navigationSplitViewColumnWidth(min: 180, ideal: 200, max: 260)
@@ -123,7 +132,7 @@ struct RootView: View {
ForEach(AppDestination.allCases) { destination in ForEach(AppDestination.allCases) { destination in
screen(for: destination, windowClass: windowClass) screen(for: destination, windowClass: windowClass)
.tabItem { .tabItem {
Label(LocalizedStringKey(destination.labelKey), systemImage: destination.systemImage) Label(String(localized: destination.labelKey), systemSymbol: destination.systemSymbol)
} }
.tag(destination) .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) #if os(iOS)
import UIKit import UIKit
#else #else

View File

@@ -1,5 +1,8 @@
import SwiftUI import SwiftUI
/// Scene identifier for the single main window.
private let mainWindowId = "main"
/// Native app entry point for iOS, iPadOS, and macOS. /// Native app entry point for iOS, iPadOS, and macOS.
/// Opens `.vnd` invitations via `onOpenURL` and routes them to the receive flow. /// Opens `.vnd` invitations via `onOpenURL` and routes them to the receive flow.
@main @main
@@ -7,11 +10,21 @@ struct VniDropApp: App {
@StateObject private var externalInvitations = ExternalInvitationController() @StateObject private var externalInvitations = ExternalInvitationController()
var body: some Scene { 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)) RootView(dependencies: makeAppDependencies(externalInvitations: externalInvitations))
.ignoresSafeArea() .ignoresSafeArea()
.onOpenURL(perform: openInvitation) .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 /// Reads a `.vnd` invitation document under a security scope, enforcing the

View File

@@ -120,7 +120,6 @@ struct AppPreferences: Equatable {
var username: String var username: String
var receiveFolder: ReceiveFolder var receiveFolder: ReceiveFolder
var themeMode: ThemeMode var themeMode: ThemeMode
var notificationsEnabled: Bool
var diagnosticsEnabled: Bool var diagnosticsEnabled: Bool
var diagnosticsInstallId: String var diagnosticsInstallId: String
var relayConfiguration: RelayConfiguration var relayConfiguration: RelayConfiguration
@@ -130,7 +129,6 @@ struct AppPreferencesDefaults {
let username: String let username: String
let receiveFolder: ReceiveFolder let receiveFolder: ReceiveFolder
let themeMode: ThemeMode let themeMode: ThemeMode
var notificationsEnabled: Bool = false
var diagnosticsEnabled: Bool = false var diagnosticsEnabled: Bool = false
} }
@@ -147,7 +145,6 @@ final class AppPreferencesRepository: ObservableObject {
static let receiveFolderValue = "receive_folder_value" static let receiveFolderValue = "receive_folder_value"
static let receiveFolderDisplayName = "receive_folder_display_name" static let receiveFolderDisplayName = "receive_folder_display_name"
static let themeMode = "theme_mode" static let themeMode = "theme_mode"
static let notificationsEnabled = "notifications_enabled"
static let diagnosticsEnabled = "diagnostics_enabled" static let diagnosticsEnabled = "diagnostics_enabled"
static let diagnosticsInstallId = "diagnostics_install_id" static let diagnosticsInstallId = "diagnostics_install_id"
static let relayConfiguration = "relay_configuration" 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 username = (defaults.string(forKey: Key.username)).flatMap { $0.isEmpty ? nil : $0 } ?? fallback.username
let folder = resolveReceiveFolder(defaults, fallback: fallback.receiveFolder) let folder = resolveReceiveFolder(defaults, fallback: fallback.receiveFolder)
let themeMode = defaults.string(forKey: Key.themeMode).flatMap(ThemeMode.init(rawValue:)) ?? fallback.themeMode 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 diagnostics = defaults.object(forKey: Key.diagnosticsEnabled) as? Bool ?? fallback.diagnosticsEnabled
let installId = defaults.string(forKey: Key.diagnosticsInstallId) ?? "" let installId = defaults.string(forKey: Key.diagnosticsInstallId) ?? ""
return AppPreferences( return AppPreferences(
username: username, username: username,
receiveFolder: folder, receiveFolder: folder,
themeMode: themeMode, themeMode: themeMode,
notificationsEnabled: notifications,
diagnosticsEnabled: diagnostics, diagnosticsEnabled: diagnostics,
diagnosticsInstallId: installId, diagnosticsInstallId: installId,
relayConfiguration: resolveRelayConfiguration(defaults) relayConfiguration: resolveRelayConfiguration(defaults)
@@ -224,11 +219,6 @@ final class AppPreferencesRepository: ObservableObject {
reload() reload()
} }
func setNotificationsEnabled(_ enabled: Bool) {
defaults.set(enabled, forKey: Key.notificationsEnabled)
reload()
}
func setDiagnosticsEnabled(_ enabled: Bool) { func setDiagnosticsEnabled(_ enabled: Bool) {
defaults.set(enabled, forKey: Key.diagnosticsEnabled) defaults.set(enabled, forKey: Key.diagnosticsEnabled)
reload() reload()

View File

@@ -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<T: Sendable>(_ block: @escaping @Sendable () throws -> T) async -> Result<T, Error> {
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<T: Sendable>(_ block: @escaping @Sendable () throws -> T) async -> Result<T, Error> {
await withCheckedContinuation { continuation in
interruptQueue.async { continuation.resume(returning: Result { try block() }) }
}
}
}

View File

@@ -15,10 +15,56 @@ struct CoreEventModel: Equatable, Identifiable, Sendable {
let timestamp: Int64 let timestamp: Int64
let scope: String let scope: String
let transferId: UInt64? 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 direction: String?
let phase: String let phase: String
let kind: String let kind: String
let dataJson: 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 { enum ShareAccessPolicy: Equatable, Sendable {

View File

@@ -80,7 +80,9 @@ final class CoreRepository: ObservableObject, CoreGateway {
// access the handle from the main actor. The underlying core is internally // access the handle from the main actor. The underlying core is internally
// synchronized, and `nonisolated(unsafe)` documents that crossing for Swift 6. // synchronized, and `nonisolated(unsafe)` documents that crossing for Swift 6.
private nonisolated(unsafe) var core: VnidropCore? 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 let coreFactory: any CoreBindingFactory
private var isNetworkTransitionInProgress = false private var isNetworkTransitionInProgress = false
private lazy var sink = RepositoryEventSink { [weak self] event in private lazy var sink = RepositoryEventSink { [weak self] event in
@@ -222,7 +224,9 @@ final class CoreRepository: ObservableObject, CoreGateway {
// MARK: - Lifecycle actions // MARK: - Lifecycle actions
func cancel(transferId: UInt64) async -> Result<Void, Error> { func cancel(transferId: UInt64) async -> Result<Void, Error> {
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) try self.requireCore().cancelTransfer(transferId: transferId)
}.map { self.refreshSnapshot() } }.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. /// Runs a blocking core call off the main actor and hops the result back.
private nonisolated func runCore<T: Sendable>(_ block: @escaping @Sendable () throws -> T) async -> Result<T, Error> { private nonisolated func runCore<T: Sendable>(_ block: @escaping @Sendable () throws -> T) async -> Result<T, Error> {
await withCheckedContinuation { continuation in await dispatcher.run(block)
queue.async {
let result: Result<T, Error>
do {
result = .success(try block())
} catch {
result = .failure(error)
}
continuation.resume(returning: result)
}
} }
/// 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<T: Sendable>(_ block: @escaping @Sendable () throws -> T) async -> Result<T, Error> {
await dispatcher.runInterrupt(block)
} }
private nonisolated static func nextTransferId() -> UInt64 { private nonisolated static func nextTransferId() -> UInt64 {
@@ -401,14 +402,15 @@ private extension CoreEvent {
} }
} }
private let refreshPhases: Set<String> = ["lifecycle", "error", "ticket", "import", "download", "export", "handshake"] private let refreshPhases: Set<EventPhase> = [.lifecycle, .error, .ticket, .importing, .download, .export, .handshake]
private let refreshKinds: Set<String> = [ private let refreshKinds: Set<EventKind> = [
"started", "done", "created", "failed", "cancelled", "share-stopped", "found-collection", "connected", .started, .done, .created, .failed, .cancelled, .shareStopped, .foundCollection, .connected,
] ]
private extension CoreEventModel { private extension CoreEventModel {
var shouldRefreshTransfers: Bool { var shouldRefreshTransfers: Bool {
refreshPhases.contains(phase) && refreshKinds.contains(kind) guard let eventPhase, let eventKind else { return false }
return refreshPhases.contains(eventPhase) && refreshKinds.contains(eventKind)
} }
} }

View File

@@ -16,12 +16,53 @@ struct LocalNotification {
let body: String 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`. /// Local notification service backed by `UNUserNotificationCenter`.
@MainActor @MainActor
final class LocalNotificationService: ObservableObject { final class LocalNotificationService: ObservableObject {
@Published private(set) var permission: NotificationPermission = .notDetermined @Published private(set) var permission: NotificationPermission = .notDetermined
private let center = UNUserNotificationCenter.current() 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 { func refreshPermission() async -> NotificationPermission {
let settings = await center.notificationSettings() let settings = await center.notificationSettings()
@@ -75,11 +116,6 @@ final class LocalNotificationService: ObservableObject {
center.removeDeliveredNotifications(withIdentifiers: [id]) center.removeDeliveredNotifications(withIdentifiers: [id])
} }
func cancelAll() {
center.removeAllPendingNotificationRequests()
center.removeAllDeliveredNotifications()
}
private static func map(_ status: UNAuthorizationStatus) -> NotificationPermission { private static func map(_ status: UNAuthorizationStatus) -> NotificationPermission {
switch status { switch status {
case .authorized, .provisional, .ephemeral: return .granted case .authorized, .provisional, .ephemeral: return .granted

View File

@@ -18,9 +18,9 @@ func windowClassFor(width: Double) -> WindowClass {
/// resolved at the view layer. /// resolved at the view layer.
struct TransferProgress: Equatable { struct TransferProgress: Equatable {
let transferId: UInt64? let transferId: UInt64?
let phase: String let phase: EventPhase
let kind: String let kind: EventKind
let labelKey: String let labelKey: String.LocalizationValue
let progress: Double? let progress: Double?
var detail: String? = nil var detail: String? = nil
/// Pre-resolved label that overrides `labelKey` when set (e.g. "Sending to 2", /// Pre-resolved label that overrides `labelKey` when set (e.g. "Sending to 2",
@@ -28,44 +28,31 @@ struct TransferProgress: Equatable {
var label: String? = nil var label: String? = nil
} }
func statusLabelKey(_ status: TransferStatus) -> String { func statusLabelKey(_ status: TransferStatus) -> String.LocalizationValue {
switch status { switch status {
case .importing: return "status_preparing" case .importing: return L10n.Status.preparing
case .sharing: return "status_available" case .sharing: return L10n.Status.available
case .receiving: return "status_receiving" case .receiving: return L10n.Status.receiving
case .done: return "status_completed" case .done: return L10n.Status.completed
case .cancelled: return "status_cancelled" case .cancelled: return L10n.Status.cancelled
case .stopped: return "status_stopped" case .stopped: return L10n.Status.stopped
case .failed: return "status_failed" case .failed: return L10n.Status.failed
} }
} }
private let progressPhases: Set<String> = [ /// Latest progress snapshot for a transfer. Events are newest-first. Only events
"import", "ticket", "access", "transfer", "download", "export", /// whose `phase` and `kind` map to known cases participate.
"lifecycle", "network", "handshake", "error",
]
private let progressKinds: Set<String> = [
"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.
func progressForTransfer(events: [CoreEventModel], transferId: UInt64) -> TransferProgress? { func progressForTransfer(events: [CoreEventModel], transferId: UInt64) -> TransferProgress? {
let relevant = events.filter { event in let relevant = events.filter { event in
event.transferId == transferId event.transferId == transferId && event.eventPhase != nil && event.eventKind != nil
&& progressPhases.contains(event.phase)
&& progressKinds.contains(event.kind)
} }
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) let sizeHint = findKnownSize(events: events, transferId: transferId)
return TransferProgress( return TransferProgress(
transferId: transferId, transferId: transferId,
phase: latest.phase, phase: phase,
kind: latest.kind, kind: kind,
labelKey: humanProgressLabel(latest), labelKey: humanProgressLabel(phase: phase, kind: kind),
progress: parseProgress(latest.dataJson, sizeHint: sizeHint), progress: parseProgress(latest.dataJson, sizeHint: sizeHint),
detail: progressDetail(latest) detail: progressDetail(latest)
) )
@@ -80,33 +67,34 @@ func progressForReceiver(
) -> TransferProgress? { ) -> TransferProgress? {
if remoteEndpointId.isEmpty { return nil } if remoteEndpointId.isEmpty { return nil }
let connectionIds = connectionIdsForEndpoint(events: events, remoteEndpointId: remoteEndpointId) let connectionIds = connectionIdsForEndpoint(events: events, remoteEndpointId: remoteEndpointId)
let receiverKinds: Set<EventKind> = [.started, .progress, .completed, .aborted]
let transferEvents = events.filter { event in let transferEvents = events.filter { event in
event.transferId == transferId event.transferId == transferId
&& event.direction == "send" && event.eventDirection == .send
&& event.phase == "transfer" && event.eventPhase == .transfer
&& ["started", "progress", "completed", "aborted"].contains(event.kind) && (event.eventKind.map(receiverKinds.contains) ?? false)
&& eventBelongsToReceiver(event, remoteEndpointId: remoteEndpointId, connectionIds: connectionIds) && eventBelongsToReceiver(event, remoteEndpointId: remoteEndpointId, connectionIds: connectionIds)
} }
if transferEvents.isEmpty { return nil } guard let latest = transferEvents.first, let latestKind = latest.eventKind else { return nil }
if latestKind == .aborted {
let latest = transferEvents[0]
if latest.kind == "aborted" {
return TransferProgress( return TransferProgress(
transferId: transferId, phase: "transfer", kind: "aborted", transferId: transferId, phase: .transfer, kind: .aborted,
labelKey: "progress_interrupted", progress: nil, detail: nil 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) 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( return TransferProgress(
transferId: transferId, phase: "transfer", kind: latest.kind, transferId: transferId, phase: .transfer, kind: latestKind,
labelKey: "progress_sending", progress: progress, detail: progressDetail(latest) 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) // MARK: - Internals (ported literally from AppUiModels.kt)
private func humanProgressLabel(_ event: CoreEventModel) -> String { private func humanProgressLabel(phase: EventPhase, kind: EventKind) -> String.LocalizationValue {
switch (event.phase, event.kind) { switch (phase, kind) {
case ("import", "copy-progress"), ("import", "outboard-progress"), ("import", "started"): case (.importing, .copyProgress), (.importing, .outboardProgress), (.importing, .started):
return "progress_preparing" return L10n.Progress.preparing
case ("import", "done"): return "progress_ready" case (.importing, .done): return L10n.Progress.ready
case ("ticket", "created"): return "progress_share_ready" case (.ticket, .created): return L10n.Progress.shareReady
case ("network", "connecting"): return "progress_connecting" case (.network, .connecting): return L10n.Progress.connecting
case ("network", "connected"): return "progress_connected" case (.network, .connected): return L10n.Progress.connected
case ("download", "found-collection"): return "progress_getting_ready" case (.download, .foundCollection): return L10n.Progress.gettingReady
case ("download", "progress"): return "progress_downloading" case (.download, .progress): return L10n.Progress.downloading
case ("export", "progress"): return "progress_saving" case (.export, .progress): return L10n.Progress.saving
case ("transfer", "progress"): return "progress_sending" case (.transfer, .progress): return L10n.Progress.sending
case ("transfer", "started"): return "progress_connected" case (.transfer, .started): return L10n.Progress.connected
case ("transfer", "completed"): return "progress_completed" case (.transfer, .completed): return L10n.Progress.completed
case ("lifecycle", "done"): return "progress_completed" case (.lifecycle, .done): return L10n.Progress.completed
case ("lifecycle", "cancelled"): return "progress_cancelled" case (.lifecycle, .cancelled): return L10n.Progress.cancelled
default: default:
if event.phase == "handshake" { return "progress_requesting_access" } if phase == .handshake { return L10n.Progress.requestingAccess }
if event.kind == "failed" { return "progress_failed" } if kind == .failed { return L10n.Progress.failed }
return "progress_working" return L10n.Progress.working
} }
} }
@@ -217,14 +205,14 @@ private func aggregateReceiverProgress(events: [CoreEventModel], totalSizeHint:
order.append(requestKey) order.append(requestKey)
} }
if let size, size > 0 { state.size = size } if let size, size > 0 { state.size = size }
switch event.kind { switch event.eventKind {
case "progress", "started": case .progress, .started:
if let endOffset { state.offset = max(state.offset, endOffset) } if let endOffset { state.offset = max(state.offset, endOffset) }
state.aborted = false state.aborted = false
case "completed": case .completed:
state.completed = true state.completed = true
if let s = state.size { state.offset = s } if let s = state.size { state.offset = s }
case "aborted": case .aborted:
state.aborted = true state.aborted = true
default: default:
break break

View File

@@ -27,7 +27,6 @@ final class ApprovalCoordinator: ObservableObject {
@Published private(set) var state = ApprovalState() @Published private(set) var state = ApprovalState()
private let repository: CoreGateway private let repository: CoreGateway
private let preferences: AppPreferencesRepository
private let notifications: LocalNotificationService private let notifications: LocalNotificationService
private let visibility: AppVisibility private let visibility: AppVisibility
private let messages: UiMessageController private let messages: UiMessageController
@@ -37,13 +36,11 @@ final class ApprovalCoordinator: ObservableObject {
init( init(
repository: CoreGateway, repository: CoreGateway,
preferences: AppPreferencesRepository,
notifications: LocalNotificationService, notifications: LocalNotificationService,
visibility: AppVisibility, visibility: AppVisibility,
messages: UiMessageController messages: UiMessageController
) { ) {
self.repository = repository self.repository = repository
self.preferences = preferences
self.notifications = notifications self.notifications = notifications
self.visibility = visibility self.visibility = visibility
self.messages = messages self.messages = messages
@@ -68,17 +65,15 @@ final class ApprovalCoordinator: ObservableObject {
.store(in: &cancellables) .store(in: &cancellables)
// Recompute notifications when any input changes. // Recompute notifications when any input changes.
Publishers.CombineLatest4( Publishers.CombineLatest3(
preferences.$preferences,
visibility.$isForeground, visibility.$isForeground,
$state, $state,
notifications.$permission notifications.$permission
) )
.sink { [weak self] preferences, foreground, approvalState, permission in .sink { [weak self] foreground, approvalState, permission in
guard let self else { return } guard let self else { return }
Task { Task {
await self.synchronizeNotifications( await self.synchronizeNotifications(
enabled: preferences.notificationsEnabled,
foreground: foreground, foreground: foreground,
pending: approvalState.pending, pending: approvalState.pending,
permission: permission permission: permission
@@ -137,30 +132,41 @@ final class ApprovalCoordinator: ObservableObject {
} }
private func synchronizeNotifications( private func synchronizeNotifications(
enabled: Bool,
foreground: Bool, foreground: Bool,
pending: [PendingApproval], pending: [PendingApproval],
permission: NotificationPermission permission: NotificationPermission
) async { ) async {
if foreground || !enabled || permission != .granted { // iOS suppresses notifications while the user is in the app (the modal shows
notifications.cancelAll() // 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 return
} }
for request in pending where !publishedNotificationIds.contains(request.id) { 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 let receiver = request.receiverName
?? request.receiverDeviceName ?? request.receiverDeviceName
?? String(localized: "approval_nearby_device") ?? String(localized: L10n.Approval.nearbyDevice)
let title = String(localized: "approval_connection_request") let title = String(localized: L10n.Approval.connectionRequest)
let body = String( let body = L10n.Approval.requestBody(receiver: receiver, transferName: request.transferName)
format: String(localized: "approval_request_body"),
receiver, request.transferName
)
let result = await notifications.publish( let result = await notifications.publish(
LocalNotification(id: Self.notificationId(request.id), title: title, body: body) LocalNotification(id: Self.notificationId(request.id), title: title, body: body)
) )
switch result { if case .failure(let error) = result {
case .success: publishedNotificationIds.insert(request.id) publishedNotificationIds.remove(request.id)
case .failure(let error): messages.error(error) messages.error(error)
} }
} }
} }

View File

@@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import SFSafeSymbols
/// Non-dismissable receiver-approval modal, presented as a native sheet that can't /// 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 /// 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 { var body: some View {
let busy = state.respondingIds.contains(request.id) 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) { VStack(spacing: 16) {
Image(systemName: "checkmark.shield.fill") Image(systemSymbol: .checkmarkShieldFill)
.font(.system(size: 44)) .font(.system(size: 44))
.foregroundStyle(.tint) .foregroundStyle(.tint)
.padding(.top, 12) .padding(.top, 12)
Text(LocalizedStringKey("approval_connection_request")) Text(String(localized: L10n.Approval.connectionRequest))
.font(.title2).fontWeight(.semibold) .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) .multilineTextAlignment(.center)
Text(String(format: String(localized: "approval_endpoint_id"), request.remoteEndpointId)) Text(L10n.Approval.endpointId(deviceId: request.remoteEndpointId))
.font(.caption).foregroundStyle(.secondary) .font(.caption).foregroundStyle(.secondary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
if state.pending.count > 1 { 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) .font(.caption).foregroundStyle(.secondary)
} }
Spacer(minLength: 0) Spacer(minLength: 0)
if busy { ProgressView() } if busy { ProgressView() }
VStack(spacing: 10) { VStack(spacing: 10) {
Button(action: { onAccept(request.id) }) { 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) .buttonStyle(.borderedProminent).controlSize(.large).disabled(busy)
Button(role: .destructive, action: { onRefuse(request.id) }) { 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) .buttonStyle(.bordered).controlSize(.large).disabled(busy)
} }

View File

@@ -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<String>) -> [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<String>) -> [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<String>()
private var primedTransfers = false
private var cancellables = Set<AnyCancellable>()
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)
}
}
}

View File

@@ -1,8 +1,9 @@
import SFSafeSymbols
import SwiftUI import SwiftUI
enum ReceiveMethodAvailability { case available, unavailable, hidden } enum ReceiveMethodAvailability { case available, unavailable, hidden }
/// Invitation acquisition actions shared by the native Apple feature models. /// Invitation acquisition actions, ported from `ReceiveInvitationActions` (iosMain).
@MainActor @MainActor
protocol ReceiveInvitationActions: AnyObject { protocol ReceiveInvitationActions: AnyObject {
var fileAvailability: ReceiveMethodAvailability { get } var fileAvailability: ReceiveMethodAvailability { get }
@@ -23,25 +24,25 @@ struct ReceiveMethodPanel: View {
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
Text(LocalizedStringKey("receive_choose_method_title")).font(VniType.titleLarge) Text(String(localized: L10n.Receive.chooseMethodTitle)).font(VniType.titleLarge)
Text(LocalizedStringKey("receive_choose_method_body")).foregroundStyle(colors.foregroundLighter) Text(String(localized: L10n.Receive.chooseMethodBody)).foregroundStyle(colors.foregroundLighter)
MethodRow( 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 availability: actions.fileAvailability
) { actions.pickInvitation { model.onInvitationResult(.invitationFile, $0) } } ) { actions.pickInvitation { model.onInvitationResult(.invitationFile, $0) } }
if actions.qrAvailability != .hidden { if actions.qrAvailability != .hidden {
MethodRow( 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 availability: actions.qrAvailability
) { actions.scanQrCode { model.onInvitationResult(.qrCode, $0) } } ) { actions.scanQrCode { model.onInvitationResult(.qrCode, $0) } }
} }
if actions.nfcAvailability != .hidden { if actions.nfcAvailability != .hidden {
MethodRow( MethodRow(
icon: "wave.3.right", icon: .wave3Right,
titleOverride: model.state.isWaitingForNfc ? String(localized: "receive_nfc_waiting") : nil, titleOverride: model.state.isWaitingForNfc ? String(localized: L10n.Receive.nfcWaiting) : nil,
titleKey: "receive_method_nfc", descKey: "receive_method_nfc_description", titleKey: L10n.Receive.methodNfc, descKey: L10n.Receive.methodNfcDescription,
availability: model.state.isWaitingForNfc ? .unavailable : actions.nfcAvailability availability: model.state.isWaitingForNfc ? .unavailable : actions.nfcAvailability
) { ) {
model.setWaitingForNfc(true) model.setWaitingForNfc(true)
@@ -56,10 +57,10 @@ struct ReceiveMethodPanel: View {
private struct MethodRow: View { private struct MethodRow: View {
@Environment(\.vniColors) private var colors @Environment(\.vniColors) private var colors
let icon: String let icon: SFSymbol
var titleOverride: String? = nil var titleOverride: String? = nil
let titleKey: String let titleKey: String.LocalizationValue
let descKey: String let descKey: String.LocalizationValue
let availability: ReceiveMethodAvailability let availability: ReceiveMethodAvailability
let onTap: () -> Void let onTap: () -> Void
@@ -67,20 +68,20 @@ private struct MethodRow: View {
let enabled = availability == .available let enabled = availability == .available
Button(action: onTap) { Button(action: onTap) {
HStack(spacing: 14) { HStack(spacing: 14) {
Image(systemName: icon).font(.system(size: 22)) Image(systemSymbol: icon).font(.system(size: 22))
.foregroundStyle(enabled ? colors.brandLink : colors.foregroundLighter) .foregroundStyle(enabled ? colors.brandLink : colors.foregroundLighter)
.frame(width: 24) .frame(width: 24)
VStack(alignment: .leading, spacing: 3) { VStack(alignment: .leading, spacing: 3) {
if let titleOverride { if let titleOverride {
Text(titleOverride).font(VniType.bodyLarge) Text(titleOverride).font(VniType.bodyLarge)
} else { } 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() Spacer()
if availability == .unavailable { 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) .padding(16)
@@ -102,7 +103,7 @@ struct InvitationReviewPanel: View {
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 14) { 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 { if state.isInspecting {
ProgressView().frame(maxWidth: .infinity).padding(40) ProgressView().frame(maxWidth: .infinity).padding(40)
} }
@@ -110,28 +111,31 @@ struct InvitationReviewPanel: View {
let metadata = inspection.metadata let metadata = inspection.metadata
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
Text(metadata.transferName).font(VniType.bodyLarge).lineLimit(2) 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) .foregroundStyle(colors.foregroundLighter)
} }
.padding(16) .padding(16)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.background(colors.backgroundSurface200, in: RoundedRectangle(cornerRadius: 14)) .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) })) 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) .font(VniType.bodySmall)
.foregroundStyle(state.folderAccessStatus == .writable ? colors.foregroundLight : colors.destructiveDefault) .foregroundStyle(state.folderAccessStatus == .writable ? colors.foregroundLight : colors.destructiveDefault)
if state.isReceiving { if state.isReceiving {
let progressId = state.activeReceiveTransferId 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) } let progress = progressId.flatMap { progressForTransfer(events: model.coreState.events, transferId: $0) }
ProgressRow(labelKey: progress?.labelKey ?? "progress_receiving", progress: progress?.progress, detail: progress?.detail) ProgressRow(labelKey: progress?.labelKey ?? L10n.Progress.receiving, progress: progress?.progress, detail: progress?.detail)
SecondaryButton(title: String(localized: "button_cancel_receive"), action: model.cancelActiveReceive) SecondaryButton(title: String(localized: L10n.Button.cancelReceive), action: model.cancelActiveReceive)
} else { } else {
PrimaryButton( 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) enabled: state.canReceive(coreInitialized: model.coreState.isInitialized)
) )
} }

View File

@@ -121,6 +121,10 @@ final class ReceiveModel: ObservableObject {
func confirmHistoryDelete() { func confirmHistoryDelete() {
guard let target = state.historyDeleteTarget, !state.isDeletingHistory else { return } guard let target = state.historyDeleteTarget, !state.isDeletingHistory else { return }
state.isDeletingHistory = true 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 { Task {
let result: Result<Void, Error> let result: Result<Void, Error>
switch target { switch target {
@@ -133,7 +137,7 @@ final class ReceiveModel: ObservableObject {
case .success: case .success:
state.historyDeleteTarget = nil state.historyDeleteTarget = nil
state.isDeletingHistory = false 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)) messages.tryShow(UiMessage(text: .resource(key), tone: .success))
case .failure(let error): case .failure(let error):
state.isDeletingHistory = false state.isDeletingHistory = false
@@ -173,9 +177,9 @@ final class ReceiveModel: ObservableObject {
resetAcquisition() resetAcquisition()
let canReveal = fileSystemService.canRevealReceiveFolder(folder) let canReveal = fileSystemService.canRevealReceiveFolder(folder)
messages.tryShow(UiMessage( messages.tryShow(UiMessage(
text: .resource("receive_completed"), text: .resource(L10n.Receive.completed),
tone: .success, tone: .success,
actionLabel: canReveal ? .resource("button_show_in_files") : nil, actionLabel: canReveal ? .resource(L10n.Button.showInFiles) : nil,
onAction: canReveal ? { self.revealReceiveFolder(folder) } : nil onAction: canReveal ? { self.revealReceiveFolder(folder) } : nil
)) ))
case .failure(let error): case .failure(let error):
@@ -192,8 +196,8 @@ final class ReceiveModel: ObservableObject {
messages.tryShow(UiMessage( messages.tryShow(UiMessage(
text: uiText, text: uiText,
tone: .error, tone: .error,
actionLabel: error.canRetryWithoutChangingInput ? .resource("button_retry") : nil, actionLabel: .resource(L10n.Button.retry),
onAction: error.canRetryWithoutChangingInput ? { self.receive() } : nil onAction: { self.receive() }
)) ))
} }
} }
@@ -202,7 +206,7 @@ final class ReceiveModel: ObservableObject {
func cancelActiveReceive() { func cancelActiveReceive() {
let transferId = state.activeReceiveTransferId let transferId = state.activeReceiveTransferId
?? coreState.transfers.first { $0.direction == .receive && $0.status == .receiving }?.transferId ?? 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 } guard let transferId else { return }
Task { Task {
let result = await repository.cancel(transferId: transferId) let result = await repository.cancel(transferId: transferId)
@@ -222,14 +226,14 @@ final class ReceiveModel: ObservableObject {
Task { Task {
let result = await fileSystemService.revealReceiveFolder(folder) let result = await fileSystemService.revealReceiveFolder(folder)
if case .failure = result { 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) { private func inspectInvitation(_ method: ReceiveMethod, _ raw: String) {
let ticket = raw.trimmingCharacters(in: .whitespacesAndNewlines) 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.isAcquisitionOpen = true
state.ticket = ticket state.ticket = ticket
state.method = method state.method = method

View File

@@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import SFSafeSymbols
/// Receive screen, rebuilt on native SwiftUI. A grouped `List` of received /// Receive screen, rebuilt on native SwiftUI. A grouped `List` of received
/// transfers with swipe-to-delete, and the acquisition flow as a native sheet. /// transfers with swipe-to-delete, and the acquisition flow as a native sheet.
@@ -22,17 +23,17 @@ struct ReceiveScreen: View {
history history
} }
} }
.navigationTitle(Text(LocalizedStringKey("receive_title"))) .navigationTitle(Text(String(localized: L10n.Receive.title)))
.toolbar { .toolbar {
ToolbarItem(placement: .primaryAction) { ToolbarItem(placement: .primaryAction) {
Button(action: model.openAcquisition) { Button(action: model.openAcquisition) {
Label(String(localized: "button_receive_files"), systemImage: "plus") Label(String(localized: L10n.Button.receiveFiles), systemSymbol: .plus)
} }
} }
if !deletable.isEmpty { if !deletable.isEmpty {
ToolbarItem(placement: .primaryAction) { ToolbarItem(placement: .primaryAction) {
Button(role: .destructive, action: model.requestClearHistory) { 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( .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() } } }) 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: L10n.Button.cancel), role: .cancel, action: model.dismissHistoryDelete)
Button(String(localized: clearAllPending ? "receive_clear_history" : "button_delete_transfer"), Button(String(localized: clearAllPending ? L10n.Receive.clearHistory : L10n.Button.deleteTransfer),
role: .destructive, action: model.confirmHistoryDelete) role: .destructive, action: model.confirmHistoryDelete)
} message: { } message: {
historyDeleteMessage historyDeleteMessage
@@ -74,27 +75,36 @@ struct ReceiveScreen: View {
Button(role: .destructive) { Button(role: .destructive) {
model.requestDeleteHistoryItem(transfer.transferId) model.requestDeleteHistoryItem(transfer.transferId)
} label: { } 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: { } header: {
Text(LocalizedStringKey("receive_history_title")) Text(String(localized: L10n.Receive.historyTitle))
} footer: { } footer: {
Text(LocalizedStringKey("receive_new_subtitle")) Text(String(localized: L10n.Receive.newSubtitle))
} }
} }
} }
private var emptyState: some View { private var emptyState: some View {
ContentUnavailableView { ContentUnavailableView {
Label(String(localized: "receive_empty_title"), systemImage: "tray.and.arrow.down") Label(String(localized: L10n.Receive.emptyTitle), systemSymbol: .trayAndArrowDown)
} description: { } description: {
Text(LocalizedStringKey("receive_empty_body")) Text(String(localized: L10n.Receive.emptyBody))
} actions: { } actions: {
Button(action: model.openAcquisition) { Button(action: model.openAcquisition) {
Label(String(localized: "button_receive_files"), systemImage: "plus") Label(String(localized: L10n.Button.receiveFiles), systemSymbol: .plus)
} }
.buttonStyle(.borderedProminent) .buttonStyle(.borderedProminent)
.controlSize(.large) .controlSize(.large)
@@ -107,10 +117,10 @@ struct ReceiveScreen: View {
private var historyDeleteMessage: some View { private var historyDeleteMessage: some View {
if let target = model.state.historyDeleteTarget { if let target = model.state.historyDeleteTarget {
if target == .all { if target == .all {
Text(LocalizedStringKey("receive_clear_history_description")) Text(String(localized: L10n.Receive.clearHistoryDescription))
} else { } else {
Text(String(format: String(localized: "receive_delete_history_description"), Text(L10n.Receive.deleteHistoryDescription(
transferName(for: target) ?? String(localized: "receive_unknown_transfer"))) transferName: transferName(for: target) ?? String(localized: L10n.Receive.unknownTransfer)))
} }
} }
} }
@@ -129,14 +139,14 @@ private struct ReceiveTransferRow: View {
var body: some View { var body: some View {
HStack(spacing: 12) { HStack(spacing: 12) {
Image(systemName: "doc") Image(systemSymbol: .doc)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.frame(width: 40, height: 40) .frame(width: 40, height: 40)
.background(.quaternary, in: RoundedRectangle(cornerRadius: 9)) .background(.quaternary, in: RoundedRectangle(cornerRadius: 9))
VStack(alignment: .leading, spacing: 3) { 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) .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) .font(.caption).foregroundStyle(.secondary)
if transfer.status == .receiving, let progress { if transfer.status == .receiving, let progress {
ProgressRow(labelKey: progress.labelKey, progress: progress.progress, detail: progress.detail) ProgressRow(labelKey: progress.labelKey, progress: progress.progress, detail: progress.detail)

View File

@@ -207,6 +207,9 @@ final class SendModel: ObservableObject {
func confirmDeleteTransfer() { func confirmDeleteTransfer() {
guard let transferId = state.selectedTransferId, !state.isDeleting else { return } guard let transferId = state.selectedTransferId, !state.isDeleting else { return }
state.isDeleting = true 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 { Task {
let result = await repository.delete(transferId: transferId) let result = await repository.delete(transferId: transferId)
switch result { switch result {
@@ -217,7 +220,32 @@ final class SendModel: ObservableObject {
state.receiverHistory = [] state.receiverHistory = []
state.isDeleteConfirmationOpen = false state.isDeleteConfirmationOpen = false
state.isDeleting = 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): case .failure(let error):
state.isDeleting = false state.isDeleting = false
messages.error(error) messages.error(error)
@@ -249,7 +277,7 @@ final class SendModel: ObservableObject {
switch result { switch result {
case .success: case .success:
_ = await repository.refresh() _ = 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): case .failure(let error):
messages.error(error) messages.error(error)
} }
@@ -261,10 +289,10 @@ final class SendModel: ObservableObject {
func onInvitationResult(_ action: InvitationAction, _ result: Result<Void, Error>) { func onInvitationResult(_ action: InvitationAction, _ result: Result<Void, Error>) {
switch result { switch result {
case .success: case .success:
let key: String? let key: String.LocalizationValue?
switch action { switch action {
case .export: key = "transfer_invitation_saved" case .export: key = L10n.Transfer.invitationSaved
case .nfc: key = "transfer_nfc_written" case .nfc: key = L10n.Transfer.nfcWritten
case .share: key = nil // system share sheet already confirms case .share: key = nil // system share sheet already confirms
} }
if let key { messages.tryShow(UiMessage(text: .resource(key), tone: .success)) } if let key { messages.tryShow(UiMessage(text: .resource(key), tone: .success)) }
@@ -297,7 +325,14 @@ final class SendModel: ObservableObject {
state.transferName = "" state.transferName = ""
state.accessPolicy = .requireApproval state.accessPolicy = .requireApproval
state.isSharing = false 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): case .failure(let error):
state.isSharing = false state.isSharing = false
messages.error(error) messages.error(error)

View File

@@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import SFSafeSymbols
/// Send screen, rebuilt on native SwiftUI. A grouped `List` of outgoing transfers, /// 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. /// 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 @ObservedObject var model: SendModel
let windowClass: WindowClass 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] { private var outgoing: [Transfer] {
model.coreState.transfers.filter { $0.direction == .send } model.coreState.transfers.filter { $0.direction == .send }
} }
@@ -27,11 +33,11 @@ struct SendScreen: View {
catalog catalog
} }
} }
.navigationTitle(Text(LocalizedStringKey("send_title"))) .navigationTitle(Text(String(localized: L10n.Send.title)))
.toolbar { .toolbar {
ToolbarItem(placement: .primaryAction) { ToolbarItem(placement: .primaryAction) {
Button(action: model.openComposer) { 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) 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( .adaptiveDrawer(
isPresented: Binding(get: { model.state.isComposerOpen }, set: { _ in }), isPresented: Binding(get: { model.state.isComposerOpen }, set: { _ in }),
@@ -48,7 +66,23 @@ struct SendScreen: View {
) { ) {
TransferComposer(model: model, windowClass: windowClass) 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 /// 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 /// alert attached here so they present from the detail's own context (presenting
@@ -65,14 +99,14 @@ struct SendScreen: View {
} }
} }
.alert( .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() } } }) 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: L10n.Button.cancel), role: .cancel, action: model.dismissDeleteTransfer)
Button(String(localized: "button_delete_transfer"), role: .destructive, action: model.confirmDeleteTransfer) Button(String(localized: L10n.Button.deleteTransfer), role: .destructive, action: model.confirmDeleteTransfer)
} message: { } message: {
Text(String(format: String(localized: "transfer_delete_description"), Text(L10n.Transfer.deleteDescription(
transfer.transferName ?? String(localized: "send_new_transfer_title"))) transferName: transfer.transferName ?? String(localized: L10n.Send.newTransferTitle)))
} }
} }
@@ -90,23 +124,45 @@ struct SendScreen: View {
) )
} }
.buttonStyle(.plain) .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: { } header: {
Text(LocalizedStringKey("send_transfers_title")) Text(String(localized: L10n.Send.transfersTitle))
} footer: { } footer: {
Text(LocalizedStringKey("send_subtitle")) Text(String(localized: L10n.Send.subtitle))
} }
} }
} }
private var emptyState: some View { private var emptyState: some View {
ContentUnavailableView { ContentUnavailableView {
Label(String(localized: "send_empty_title"), systemImage: "paperplane") Label(String(localized: L10n.Send.emptyTitle), systemSymbol: .paperplane)
} description: { } description: {
Text(LocalizedStringKey("send_empty_body")) Text(String(localized: L10n.Send.emptyBody))
} actions: { } actions: {
Button(action: model.openComposer) { Button(action: model.openComposer) {
Label(String(localized: "button_create_new_transfer"), systemImage: "plus") Label(String(localized: L10n.Button.createNewTransfer), systemSymbol: .plus)
} }
.buttonStyle(.borderedProminent) .buttonStyle(.borderedProminent)
.controlSize(.large) .controlSize(.large)
@@ -127,21 +183,18 @@ struct SendScreen: View {
private func sharingProgress(for transfer: Transfer) -> TransferProgress? { private func sharingProgress(for transfer: Transfer) -> TransferProgress? {
let active = (model.receiversByTransfer[transfer.transferId] ?? []).filter { $0.status == .accepted } let active = (model.receiversByTransfer[transfer.transferId] ?? []).filter { $0.status == .accepted }
if active.isEmpty { return nil } if active.isEmpty { return nil }
let fractions: [Double] = active.compactMap { receiver -> Double? in let fractions = active.compactMap {
let progress = progressForReceiver(events: model.coreState.events, transferId: transfer.transferId, progressForReceiver(events: model.coreState.events, transferId: transfer.transferId,
remoteEndpointId: receiver.remoteEndpointId, totalSizeHint: transfer.totalSize) remoteEndpointId: $0.remoteEndpointId, totalSizeHint: transfer.totalSize)?.progress
guard progress?.kind == "started" || progress?.kind == "progress" else { return nil }
return progress?.progress
} }
guard !fractions.isEmpty else { return nil } let combined = fractions.isEmpty ? nil : fractions.reduce(0, +) / Double(fractions.count)
let combined = fractions.reduce(0, +) / Double(fractions.count)
if active.count == 1 { if active.count == 1 {
return TransferProgress(transferId: transfer.transferId, phase: "transfer", kind: "progress", return TransferProgress(transferId: transfer.transferId, phase: .transfer, kind: .progress,
labelKey: "progress_sending", progress: combined) labelKey: L10n.Progress.sending, progress: combined)
} }
return TransferProgress(transferId: transfer.transferId, phase: "transfer", kind: "progress", return TransferProgress(transferId: transfer.transferId, phase: .transfer, kind: .progress,
labelKey: "progress_sending", progress: combined, labelKey: L10n.Progress.sending, progress: combined,
label: String(format: String(localized: "progress_sending_to_count"), active.count)) label: L10n.Progress.sendingToCount(count: active.count))
} }
} }
@@ -157,19 +210,19 @@ private struct TransferListItem: View {
.background(.quaternary, in: RoundedRectangle(cornerRadius: 9)) .background(.quaternary, in: RoundedRectangle(cornerRadius: 9))
VStack(alignment: .leading, spacing: 3) { VStack(alignment: .leading, spacing: 3) {
HStack { HStack {
Text(transfer.transferName ?? String(localized: "send_new_transfer_title")) Text(transfer.transferName ?? String(localized: L10n.Send.newTransferTitle))
.font(.body).lineLimit(1) .font(.body).lineLimit(1)
Spacer() Spacer()
StatusPill(label: statusLabel(transfer.status), tone: transfer.status.pillTone) 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) .font(.caption).foregroundStyle(.secondary).lineLimit(1)
if let progress, transfer.status == .importing || transfer.status == .sharing { if let progress, transfer.status == .importing || transfer.status == .sharing {
ProgressRow(labelKey: progress.labelKey, progress: progress.progress, detail: progress.detail, labelText: progress.label) ProgressRow(labelKey: progress.labelKey, progress: progress.progress, detail: progress.detail, labelText: progress.label)
.padding(.top, 2) .padding(.top, 2)
} }
} }
Image(systemName: "chevron.forward") Image(systemSymbol: .chevronForward)
.font(.footnote.weight(.semibold)).foregroundStyle(.tertiary) .font(.footnote.weight(.semibold)).foregroundStyle(.tertiary)
} }
.contentShape(Rectangle()) .contentShape(Rectangle())
@@ -184,7 +237,7 @@ struct FileArtwork: View {
image.resizable().aspectRatio(contentMode: .fill) image.resizable().aspectRatio(contentMode: .fill)
.clipShape(RoundedRectangle(cornerRadius: 8)) .clipShape(RoundedRectangle(cornerRadius: 8))
} else { } else {
Image(systemName: "doc") Image(systemSymbol: .doc)
.font(.system(size: 18)) .font(.system(size: 18))
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
@@ -192,13 +245,13 @@ struct FileArtwork: View {
} }
func statusLabel(_ status: TransferStatus) -> String { func statusLabel(_ status: TransferStatus) -> String {
String(localized: String.LocalizationValue(statusLabelKey(status))) String(localized: statusLabelKey(status))
} }
func accessPolicyLabel(_ policy: ShareAccessPolicy) -> String { func accessPolicyLabel(_ policy: ShareAccessPolicy) -> String {
switch policy { switch policy {
case .requireApproval: return String(localized: "send_access_approval") case .requireApproval: return String(localized: L10n.Send.accessApproval)
case .anyoneWithTransfer: return String(localized: "send_access_anyone") case .anyoneWithTransfer: return String(localized: L10n.Send.accessAnyone)
} }
} }

View File

@@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import SFSafeSymbols
/// Transfer composer drawer, ported from `feature/send/TransferComposer.kt`. /// Transfer composer drawer, ported from `feature/send/TransferComposer.kt`.
/// Two steps: choose files/folder, then review + name + access policy + share. /// Two steps: choose files/folder, then review + name + access policy + share.
@@ -23,13 +24,13 @@ struct TransferComposer: View {
private var chooseStep: some View { private var chooseStep: some View {
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
Text(LocalizedStringKey("send_choose_file_title")).font(.title2).fontWeight(.semibold) Text(String(localized: L10n.Send.chooseFileTitle)).font(.title2).fontWeight(.semibold)
Text(LocalizedStringKey("send_choose_file_body")) Text(String(localized: L10n.Send.chooseFileBody))
.font(.subheadline).foregroundStyle(.secondary) .font(.subheadline).foregroundStyle(.secondary)
VStack(spacing: 14) { VStack(spacing: 14) {
Image(systemName: "doc").font(.system(size: 30)).foregroundStyle(.tint) Image(systemSymbol: .doc).font(.system(size: 30)).foregroundStyle(.tint)
PrimaryButton(title: String(localized: "button_choose_files"), action: model.selectFile).fixedSize() PrimaryButton(title: String(localized: L10n.Button.chooseFiles), action: model.selectFile).fixedSize()
QuietButton(title: String(localized: "button_choose_folder"), action: model.selectFolder) QuietButton(title: String(localized: L10n.Button.chooseFolder), action: model.selectFolder)
} }
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.padding(28) .padding(28)
@@ -39,9 +40,9 @@ struct TransferComposer: View {
private var reviewStep: some View { private var reviewStep: some View {
VStack(alignment: .leading, spacing: 16) { 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 { 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) .font(.subheadline).foregroundStyle(.secondary)
} }
ForEach(state.selectedFiles) { file in ForEach(state.selectedFiles) { file in
@@ -51,52 +52,64 @@ struct TransferComposer: View {
onRemove: { model.removeSelectedFile(file.value) } 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) })) 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) })) 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( 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, selected: state.accessPolicy == .requireApproval,
onTap: { model.setAccessPolicy(.requireApproval) } onTap: { model.setAccessPolicy(.requireApproval) }
) )
PolicyOption( 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, selected: state.accessPolicy == .anyoneWithTransfer,
onTap: { model.setAccessPolicy(.anyoneWithTransfer) } onTap: { model.setAccessPolicy(.anyoneWithTransfer) }
) )
if state.accessPolicy == .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) .font(.caption).foregroundStyle(.orange)
} }
actions actions
} }
} }
@ViewBuilder
private var actions: some View { private var actions: some View {
let shareTitle = state.isSharing let shareTitle = state.isSharing
? String(localized: "button_sharing_file") : String(localized: "button_share_file") ? String(localized: L10n.Button.sharingFile) : String(localized: L10n.Button.shareFile)
let shareButton = PrimaryButton( return VStack(spacing: 10) {
PrimaryButton(
title: shareTitle, action: model.createShare, title: shareTitle, action: model.createShare,
enabled: state.canCreateShare(coreInitialized: model.coreState.isInitialized) enabled: state.canCreateShare(coreInitialized: model.coreState.isInitialized)
) )
if windowClass == .phone { // Secondary source actions as an even row of bordered buttons rather than
VStack(spacing: 8) { // bare text links, so they read as controls and align with the primary.
shareButton HStack(spacing: 10) {
QuietButton(title: String(localized: "button_change_files"), action: model.selectFile, enabled: !state.isSharing) sourceButton(title: L10n.Button.changeFiles, symbol: .docBadgeArrowUp, action: model.selectFile)
QuietButton(title: String(localized: "button_choose_folder"), action: model.selectFolder, enabled: !state.isSharing) sourceButton(title: L10n.Button.chooseFolder, symbol: .folder, action: model.selectFolder)
} if windowClass != .phone {
} else { sourceButton(title: L10n.Button.clear, symbol: .xmark, action: model.clearSelectedSource)
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)
} }
} }
} }
}
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 { private struct SelectedFileCard: View {
@@ -116,7 +129,7 @@ private struct SelectedFileCard: View {
Spacer() Spacer()
if canRemove { if canRemove {
Button(role: .destructive, action: onRemove) { Button(role: .destructive, action: onRemove) {
Image(systemName: "trash") Image(systemSymbol: .trash)
} }
.buttonStyle(.borderless) .buttonStyle(.borderless)
.tint(.red) .tint(.red)
@@ -128,32 +141,32 @@ private struct SelectedFileCard: View {
} }
private var subtitle: String { 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) } 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 { private struct PolicyOption: View {
let icon: String let icon: SFSymbol
let titleKey: String let titleKey: String.LocalizationValue
let descKey: String let descKey: String.LocalizationValue
let selected: Bool let selected: Bool
let onTap: () -> Void let onTap: () -> Void
var body: some View { var body: some View {
Button(action: onTap) { Button(action: onTap) {
HStack(spacing: 12) { HStack(spacing: 12) {
Image(systemName: icon) Image(systemSymbol: icon)
.font(.system(size: 20)) .font(.system(size: 20))
.foregroundStyle(selected ? AnyShapeStyle(.tint) : AnyShapeStyle(.secondary)) .foregroundStyle(selected ? AnyShapeStyle(.tint) : AnyShapeStyle(.secondary))
.frame(width: 22) .frame(width: 22)
VStack(alignment: .leading, spacing: 3) { VStack(alignment: .leading, spacing: 3) {
Text(LocalizedStringKey(titleKey)) Text(String(localized: titleKey))
Text(LocalizedStringKey(descKey)).font(.caption).foregroundStyle(.secondary) Text(String(localized: descKey)).font(.caption).foregroundStyle(.secondary)
} }
Spacer() Spacer()
Image(systemName: selected ? "checkmark.circle.fill" : "circle") Image(systemSymbol: selected ? .checkmarkCircleFill : .circle)
.foregroundStyle(selected ? AnyShapeStyle(.tint) : AnyShapeStyle(.tertiary)) .foregroundStyle(selected ? AnyShapeStyle(.tint) : AnyShapeStyle(.tertiary))
} }
.padding(14) .padding(14)

View File

@@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import SFSafeSymbols
import CoreImage.CIFilterBuiltins import CoreImage.CIFilterBuiltins
/// Transfer details + drawer panels, ported from `feature/send/TransferDetails.kt`. /// Transfer details + drawer panels, ported from `feature/send/TransferDetails.kt`.
@@ -23,80 +24,78 @@ struct TransferDetailsView: View {
var body: some View { var body: some View {
Form { Form {
Section { Section {
LabeledContent(String(localized: "metadata_status"), value: statusLabel(transfer.status)) LabeledContent(String(localized: L10n.Metadata.status), value: statusLabel(transfer.status))
LabeledContent(String(localized: "metadata_size"), value: formatBytes(transfer.totalSize)) LabeledContent(String(localized: L10n.Metadata.size), value: formatBytes(transfer.totalSize))
LabeledContent(String(localized: "send_access_title"), value: accessPolicyLabel(transfer.accessPolicy)) LabeledContent(String(localized: L10n.Send.accessTitle), value: accessPolicyLabel(transfer.accessPolicy))
} header: { } header: {
Text(transfer.transferName ?? String(localized: "send_new_transfer_title")) Text(transfer.transferName ?? String(localized: L10n.Send.newTransferTitle))
} }
Section { Section {
DetailDestination( DetailDestination(
title: String(localized: "transfer_activity_title"), title: String(localized: L10n.Transfer.activityTitle),
description: String(localized: "transfer_activity_description"), description: String(localized: L10n.Transfer.activityDescription),
count: events.filter { $0.transferId == transfer.transferId && $0.isMeaningfulActivity }.count, count: events.filter { $0.transferId == transfer.transferId && $0.isMeaningfulActivity }.count,
onTap: model.openActivity onTap: model.openActivity
) )
DetailDestination( DetailDestination(
title: String(localized: "transfer_receivers_title"), title: String(localized: L10n.Transfer.receiversTitle),
description: receiversDescription(pendingReceivers, completedReceivers), description: receiversDescription(pendingReceivers, completedReceivers),
count: pendingReceivers + completedReceivers, count: pendingReceivers + completedReceivers,
onTap: model.openReceivers 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) { Button(role: .destructive) {
showStopConfirmation = true showStopConfirmation = true
} label: { } 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) .formStyle(.grouped)
.navigationTitle(Text(LocalizedStringKey("send_transfer_details_title"))) .navigationTitle(Text(String(localized: L10n.Send.transferDetailsTitle)))
#if os(iOS) #if os(iOS)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
#endif #endif
.toolbar { .toolbar {
ToolbarItem(placement: .primaryAction) { ToolbarItem(placement: .primaryAction) {
Button(role: .destructive, action: model.requestDeleteTransfer) { Button(action: model.openShare) {
Image(systemName: "trash") Label(String(localized: L10n.Transfer.shareTitle), systemSymbol: .squareAndArrowUp)
} }
.help(String(localized: L10n.Transfer.shareTitle))
} }
} }
.confirmationDialog( .confirmationDialog(
Text(LocalizedStringKey("send_stop_sharing")), Text(String(localized: L10n.Send.stopSharing)),
isPresented: $showStopConfirmation, isPresented: $showStopConfirmation,
titleVisibility: .visible titleVisibility: .visible
) { ) {
Button(String(localized: "send_stop_sharing"), role: .destructive) { Button(String(localized: L10n.Send.stopSharing), role: .destructive) {
model.stopSharing(transferId: transfer.transferId) model.stopSharing(transferId: transfer.transferId)
} }
Button(String(localized: "button_cancel"), role: .cancel) {} Button(String(localized: L10n.Button.cancel), role: .cancel) {}
} message: { } message: {
Text(LocalizedStringKey("send_stop_sharing_description")) Text(String(localized: L10n.Send.stopSharingDescription))
} }
} }
} }
private func receiversDescription(_ pending: Int, _ completed: Int) -> String { private func receiversDescription(_ pending: Int, _ completed: Int) -> String {
if pending > 0 && completed > 0 { 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 pending > 0 { return L10n.Transfer.receiversPending(count: pending) }
if completed > 0 { return String(format: String(localized: "transfer_receivers_completed_count"), completed) } if completed > 0 { return L10n.Transfer.receiversCompletedCount(count: completed) }
return String(localized: "transfer_receivers_description") return String(localized: L10n.Transfer.receiversDescription)
} }
private struct DetailDestination: View { private struct DetailDestination: View {
@@ -113,11 +112,11 @@ private struct DetailDestination: View {
} }
Spacer() Spacer()
if count > 0 { if count > 0 {
Text("\(count)") Text(verbatim: "\(count)")
.font(.footnote) .font(.footnote)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
Image(systemName: "chevron.forward") Image(systemSymbol: .chevronForward)
.font(.footnote.weight(.semibold)).foregroundStyle(.tertiary) .font(.footnote.weight(.semibold)).foregroundStyle(.tertiary)
} }
.contentShape(Rectangle()) .contentShape(Rectangle())
@@ -173,13 +172,13 @@ struct TransferActivityPanel: View {
let visible = events let visible = events
.filter { $0.transferId == transferId && $0.isMeaningfulActivity } .filter { $0.transferId == transferId && $0.isMeaningfulActivity }
.sorted { $0.timestamp > $1.timestamp } .sorted { $0.timestamp > $1.timestamp }
PanelContainer(title: String(localized: "transfer_activity_title")) { PanelContainer(title: String(localized: L10n.Transfer.activityTitle)) {
if visible.isEmpty { if visible.isEmpty {
Text(LocalizedStringKey("transfer_no_activity")).foregroundStyle(colors.foregroundLighter) Text(String(localized: L10n.Transfer.noActivity)).foregroundStyle(colors.foregroundLighter)
} else { } else {
ForEach(Array(visible.enumerated()), id: \.offset) { index, event in ForEach(Array(visible.enumerated()), id: \.offset) { index, event in
if index > 0 { Divider().overlay(colors.borderDefault) } if index > 0 { Divider().overlay(colors.borderDefault) }
Text(LocalizedStringKey(event.activityTitleKey)) Text(String(localized: event.activityTitleKey))
.fontWeight(.medium).padding(.vertical, 14) .fontWeight(.medium).padding(.vertical, 14)
} }
} }
@@ -196,11 +195,11 @@ struct ReceiverHistoryPanel: View {
let onCancel: (String) -> Void let onCancel: (String) -> Void
var body: some View { var body: some View {
PanelContainer(title: String(localized: "transfer_receivers_title")) { PanelContainer(title: String(localized: L10n.Transfer.receiversTitle)) {
if loading { if loading {
ProgressView().frame(maxWidth: .infinity).padding(40) ProgressView().frame(maxWidth: .infinity).padding(40)
} else if receivers.isEmpty { } else if receivers.isEmpty {
Text(LocalizedStringKey("transfer_no_receivers")).foregroundStyle(colors.foregroundLighter) Text(String(localized: L10n.Transfer.noReceivers)).foregroundStyle(colors.foregroundLighter)
} else { } else {
ForEach(Array(receivers.enumerated()), id: \.element.id) { index, receiver in ForEach(Array(receivers.enumerated()), id: \.element.id) { index, receiver in
if index > 0 { Divider().overlay(colors.borderDefault) } if index > 0 { Divider().overlay(colors.borderDefault) }
@@ -234,7 +233,7 @@ private struct ReceiverRow: View {
} }
var body: some 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 let showLive = sendProgress != nil && receiver.status != .completed
&& receiver.status != .refused && receiver.status != .expired && receiver.status != .refused && receiver.status != .expired
&& receiver.status != .failed && receiver.status != .failed
@@ -247,12 +246,13 @@ private struct ReceiverRow: View {
if showLive, let sendProgress { if showLive, let sendProgress {
ProgressRow(labelKey: sendProgress.labelKey, progress: sendProgress.progress, detail: sendProgress.detail, labelText: sendProgress.label) ProgressRow(labelKey: sendProgress.labelKey, progress: sendProgress.progress, detail: sendProgress.detail, labelText: sendProgress.label)
} else { } else {
Text(LocalizedStringKey(receiver.status.statusTextKey)) Text(String(localized: receiver.status.statusTextKey))
.font(VniType.bodySmall).fontWeight(.medium) .font(VniType.bodySmall).fontWeight(.medium)
.foregroundStyle(receiver.status.statusColor(colors)) .foregroundStyle(receiver.status.statusColor(colors))
} }
if let reason = receiver.reason, !reason.isEmpty { 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) .frame(maxWidth: .infinity, alignment: .leading)
@@ -260,7 +260,7 @@ private struct ReceiverRow: View {
Button(role: .destructive) { Button(role: .destructive) {
onCancel(receiver.id) onCancel(receiver.id)
} label: { } label: {
Text(LocalizedStringKey("button_refuse")) Text(String(localized: L10n.Button.refuse))
.font(VniType.bodySmall) .font(VniType.bodySmall)
} }
.buttonStyle(.borderless) .buttonStyle(.borderless)
@@ -278,23 +278,21 @@ struct TransferSharePanel: View {
let transfer: Transfer let transfer: Transfer
var body: some View { var body: some View {
PanelContainer(title: String(localized: "transfer_share_title")) { PanelContainer(title: String(localized: L10n.Transfer.shareTitle)) {
switch transfer.invitationPresentation { switch transfer.invitationPresentation {
case .ready(let ticket): case .ready(let ticket):
let qrImage = QRCode.generate(from: ticket) let qrImage = QRCode.generate(from: ticket)
qrCard(image: qrImage) qrCard(image: qrImage)
if qrImage != nil { if qrImage != nil {
Text(LocalizedStringKey("transfer_scan_qr")) Text(String(localized: L10n.Transfer.scanQr))
.font(VniType.bodySmall).foregroundStyle(colors.foregroundLighter) .font(VniType.bodySmall).foregroundStyle(colors.foregroundLighter)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
} }
ShareActionsView(model: model, transfer: transfer, ticket: ticket) ShareActionsView(model: model, transfer: transfer, ticket: ticket)
case .preparing: case .preparing:
Text(LocalizedStringKey("transfer_event_preparing")).foregroundStyle(colors.foregroundLighter) Text(String(localized: L10n.Transfer.eventPreparing)).foregroundStyle(colors.foregroundLighter)
case .unavailable: case .unavailable:
Text(LocalizedStringKey( Text(String(localized: transfer.status == .failed ? L10n.Transfer.eventFailed : L10n.Transfer.eventStopped))
transfer.status == .failed ? "transfer_event_failed" : "transfer_event_stopped"
))
.foregroundStyle(colors.foregroundLighter) .foregroundStyle(colors.foregroundLighter)
} }
} }
@@ -306,9 +304,9 @@ struct TransferSharePanel: View {
image.interpolation(.none).resizable().scaledToFit().padding(14) image.interpolation(.none).resizable().scaledToFit().padding(14)
} else { } else {
VStack(spacing: 10) { VStack(spacing: 10) {
Image(systemName: "qrcode") Image(systemSymbol: .qrcode)
.font(.system(size: 36, weight: .medium)) .font(.system(size: 36, weight: .medium))
Text(LocalizedStringKey("transfer_qr_unavailable")) Text(String(localized: L10n.Transfer.qrUnavailable))
.font(VniType.bodySmall) .font(VniType.bodySmall)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
} }
@@ -374,32 +372,32 @@ extension CoreEventModel {
"receiver-refused", "receiver-completed", "share-stopped", "failed"].contains(kind) "receiver-refused", "receiver-completed", "share-stopped", "failed"].contains(kind)
} }
var activityTitleKey: String { var activityTitleKey: String.LocalizationValue {
if phase == "import" && kind == "started" { return "transfer_event_preparing" } if phase == "import" && kind == "started" { return L10n.Transfer.eventPreparing }
if phase == "ticket" && kind == "created" { return "transfer_event_ready" } if phase == "ticket" && kind == "created" { return L10n.Transfer.eventReady }
if phase == "network" { return "transfer_event_connecting" } if phase == "network" { return L10n.Transfer.eventConnecting }
if phase == "download" { return "transfer_event_downloading" } if phase == "download" { return L10n.Transfer.eventDownloading }
if phase == "export" { return "transfer_event_saving" } if phase == "export" { return L10n.Transfer.eventSaving }
if kind == "receiver-requested" { return "transfer_event_requested" } if kind == "receiver-requested" { return L10n.Transfer.eventRequested }
if kind == "receiver-accepted" || kind == "receiver-auto-approved" { return "transfer_event_approved" } if kind == "receiver-accepted" || kind == "receiver-auto-approved" { return L10n.Transfer.eventApproved }
if kind == "receiver-refused" { return "transfer_event_refused" } if kind == "receiver-refused" { return L10n.Transfer.eventRefused }
if kind == "receiver-completed" { return "transfer_event_completed" } if kind == "receiver-completed" { return L10n.Transfer.eventCompleted }
if kind == "share-stopped" || (phase == "lifecycle" && kind == "cancelled") { return "transfer_event_stopped" } if kind == "share-stopped" || (phase == "lifecycle" && kind == "cancelled") { return L10n.Transfer.eventStopped }
if kind == "failed" { return "transfer_event_failed" } if kind == "failed" { return L10n.Transfer.eventFailed }
return "transfer_event_updated" return L10n.Transfer.eventUpdated
} }
} }
extension ReceiverDeliveryStatus { extension ReceiverDeliveryStatus {
var statusTextKey: String { var statusTextKey: String.LocalizationValue {
switch self { switch self {
case .requested: return "transfer_receiver_requested" case .requested: return L10n.Transfer.receiverRequested
case .accepted: return "transfer_receiver_accepted" case .accepted: return L10n.Transfer.receiverAccepted
case .refused: return "transfer_receiver_refused" case .refused: return L10n.Transfer.receiverRefused
case .expired: return "transfer_receiver_expired" case .expired: return L10n.Transfer.receiverExpired
case .completed: return "transfer_receiver_completed" case .completed: return L10n.Transfer.receiverCompleted
case .failed: return "transfer_receiver_failed" case .failed: return L10n.Transfer.receiverFailed
case .unknown: return "transfer_receiver_unknown" case .unknown: return L10n.Transfer.receiverUnknown
} }
} }

View File

@@ -2,7 +2,7 @@ import SwiftUI
enum NfcShareAvailability { case available, unavailable, hidden } 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. /// Platform implementations perform export, native share, and NFC write.
@MainActor @MainActor
protocol TransferShareActions: AnyObject { protocol TransferShareActions: AnyObject {
@@ -30,7 +30,7 @@ struct ShareActionsView: View {
VStack(spacing: 12) { VStack(spacing: 12) {
if actions.nfcAvailability != .hidden { if actions.nfcAvailability != .hidden {
SecondaryButton( 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: { action: {
writingNfc = true writingNfc = true
actions.writeInvitationToNfc(ticket: ticket) { result in actions.writeInvitationToNfc(ticket: ticket) { result in
@@ -41,16 +41,16 @@ struct ShareActionsView: View {
enabled: actions.nfcAvailability == .available && !writingNfc enabled: actions.nfcAvailability == .available && !writingNfc
) )
if actions.nfcAvailability == .unavailable { if actions.nfcAvailability == .unavailable {
Text(LocalizedStringKey("transfer_nfc_unavailable")) Text(String(localized: L10n.Transfer.nfcUnavailable))
.font(VniType.bodySmall).foregroundStyle(colors.foregroundLighter) .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 ?? "") { actions.exportInvitation(ticket: ticket, transferName: transfer.transferName ?? "") {
model.onInvitationResult(.export, $0) 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 ?? "") { actions.shareInvitation(ticket: ticket, transferName: transfer.transferName ?? "") {
model.onInvitationResult(.share, $0) model.onInvitationResult(.share, $0)
} }

View File

@@ -12,16 +12,16 @@ enum SettingsSection: Hashable {
case about case about
case bugReport case bugReport
var titleKey: String { var titleKey: String.LocalizationValue {
switch self { switch self {
case .overview: return "settings_title" case .overview: return L10n.Settings.title
case .preferences: return "preferences_title" case .preferences: return L10n.Preferences.title
case .appearance: return "appearance_title" case .appearance: return L10n.Appearance.title
case .notifications: return "notifications_title" case .notifications: return L10n.Notifications.title
case .network: return "settings_network_title" case .network: return L10n.Settings.networkTitle
case .storage: return "storage_title" case .storage: return L10n.Storage.title
case .about: return "about_title" case .about: return L10n.About.title
case .bugReport: return "about_bug_report" case .bugReport: return L10n.About.bugReport
} }
} }
} }
@@ -43,7 +43,6 @@ struct SettingsState: Equatable {
var isValidatingFolder = false var isValidatingFolder = false
var supportsCustomReceiveFolders = true var supportsCustomReceiveFolders = true
var themeMode: ThemeMode = .system var themeMode: ThemeMode = .system
var notificationsEnabled = false
var notificationPermission: NotificationPermission = .notDetermined var notificationPermission: NotificationPermission = .notDetermined
var diagnosticsEnabled = false var diagnosticsEnabled = false
var relayMode: RelayPreferenceMode = .automatic var relayMode: RelayPreferenceMode = .automatic
@@ -53,7 +52,7 @@ struct SettingsState: Equatable {
var isApplyingRelayConfiguration = false var isApplyingRelayConfiguration = false
var hasActiveNetworkWork = false var hasActiveNetworkWork = false
var endpointId: String? var endpointId: String?
var relayApplyErrorKey: String? var relayApplyErrorKey: String.LocalizationValue?
var deviceInfo: DeviceInfo? var deviceInfo: DeviceInfo?
var appVersion = "" var appVersion = ""
var isLoadingDeviceInfo = false var isLoadingDeviceInfo = false
@@ -66,14 +65,16 @@ struct SettingsState: Equatable {
var bugLogPreviewBytes = 0 var bugLogPreviewBytes = 0
var storage: StorageBreakdown? var storage: StorageBreakdown?
var isCalculatingStorage = false var isCalculatingStorage = false
var storageLoadFailed = false
var isDeletingTransfers = false var isDeletingTransfers = false
var isCleaningStorage = false
static func == (lhs: SettingsState, rhs: SettingsState) -> Bool { static func == (lhs: SettingsState, rhs: SettingsState) -> Bool {
lhs.selectedSection == rhs.selectedSection && lhs.username == rhs.username lhs.selectedSection == rhs.selectedSection && lhs.username == rhs.username
&& lhs.receiveFolder == rhs.receiveFolder && lhs.folderAccessStatus == rhs.folderAccessStatus && lhs.receiveFolder == rhs.receiveFolder && lhs.folderAccessStatus == rhs.folderAccessStatus
&& lhs.isValidatingFolder == rhs.isValidatingFolder && lhs.isValidatingFolder == rhs.isValidatingFolder
&& lhs.supportsCustomReceiveFolders == rhs.supportsCustomReceiveFolders && lhs.supportsCustomReceiveFolders == rhs.supportsCustomReceiveFolders
&& lhs.themeMode == rhs.themeMode && lhs.notificationsEnabled == rhs.notificationsEnabled && lhs.themeMode == rhs.themeMode
&& lhs.notificationPermission == rhs.notificationPermission && lhs.notificationPermission == rhs.notificationPermission
&& lhs.diagnosticsEnabled == rhs.diagnosticsEnabled && lhs.appVersion == rhs.appVersion && lhs.diagnosticsEnabled == rhs.diagnosticsEnabled && lhs.appVersion == rhs.appVersion
&& lhs.relayMode == rhs.relayMode && lhs.relayURLs == rhs.relayURLs && lhs.relayMode == rhs.relayMode && lhs.relayURLs == rhs.relayURLs
@@ -89,7 +90,9 @@ struct SettingsState: Equatable {
&& lhs.bugIncludeLogs == rhs.bugIncludeLogs && lhs.isSubmittingBugReport == rhs.isSubmittingBugReport && lhs.bugIncludeLogs == rhs.bugIncludeLogs && lhs.isSubmittingBugReport == rhs.isSubmittingBugReport
&& lhs.bugLogPreviewBytes == rhs.bugLogPreviewBytes && lhs.bugLogPreviewBytes == rhs.bugLogPreviewBytes
&& lhs.storage == rhs.storage && lhs.isCalculatingStorage == rhs.isCalculatingStorage && lhs.storage == rhs.storage && lhs.isCalculatingStorage == rhs.isCalculatingStorage
&& lhs.storageLoadFailed == rhs.storageLoadFailed
&& lhs.isDeletingTransfers == rhs.isDeletingTransfers && lhs.isDeletingTransfers == rhs.isDeletingTransfers
&& lhs.isCleaningStorage == rhs.isCleaningStorage
&& lhs.deviceInfo?.operatingSystem == rhs.deviceInfo?.operatingSystem && lhs.deviceInfo?.operatingSystem == rhs.deviceInfo?.operatingSystem
} }
} }
@@ -110,7 +113,6 @@ final class SettingsModel: ObservableObject {
private let bugReports: BugReportService private let bugReports: BugReportService
private let diagnosticsIncluded: Bool private let diagnosticsIncluded: Bool
private var enableNotificationsAfterSettings = false
private var usernamePersistTask: Task<Void, Never>? private var usernamePersistTask: Task<Void, Never>?
private var hasLocalUsernameDraft = false private var hasLocalUsernameDraft = false
private var hasRelayConfigurationDraft = 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.username = self.hasLocalUsernameDraft ? self.state.username : prefs.username
self.state.receiveFolder = folder self.state.receiveFolder = folder
self.state.themeMode = prefs.themeMode self.state.themeMode = prefs.themeMode
self.state.notificationsEnabled = prefs.notificationsEnabled
self.state.diagnosticsEnabled = prefs.diagnosticsEnabled self.state.diagnosticsEnabled = prefs.diagnosticsEnabled
if !self.hasRelayConfigurationDraft { if !self.hasRelayConfigurationDraft {
self.state.relayMode = prefs.relayConfiguration.mode self.state.relayMode = prefs.relayConfiguration.mode
@@ -168,7 +169,7 @@ final class SettingsModel: ObservableObject {
|| coreState.transfers.contains(where: { $0.status.isActiveTransfer }) || coreState.transfers.contains(where: { $0.status.isActiveTransfer })
self.state.hasActiveNetworkWork = hasActiveWork self.state.hasActiveNetworkWork = hasActiveWork
self.state.endpointId = coreState.status?.endpointId 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 self.state.relayApplyErrorKey = nil
} }
} }
@@ -208,26 +209,23 @@ final class SettingsModel: ObservableObject {
func onReceiveFolderPickFailed(_ reason: String) { messages.error(InvitationError.message(reason)) } func onReceiveFolderPickFailed(_ reason: String) { messages.error(InvitationError.message(reason)) }
func resetReceiveFolder() { preferences.resetReceiveFolder() } func resetReceiveFolder() { preferences.resetReceiveFolder() }
func setNotificationsEnabled(_ enabled: Bool) { /// Whether the current receive folder is the platform default (so the reset
Task { /// action can be hidden when it would be a no-op). Compared by location, not
if !enabled { /// display name, which can differ once resolved.
preferences.setNotificationsEnabled(false) var isUsingDefaultReceiveFolder: Bool {
notifications.cancelAll() guard let folder = state.receiveFolder else { return true }
return 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 {
let permission = await notifications.requestPermission() let permission = await notifications.requestPermission()
state.notificationPermission = permission state.notificationPermission = permission
if permission == .granted { if permission == .unsupported {
await enableNotifications() messages.show(UiMessage(text: .resource(L10n.Notifications.unsupported), tone: .warning))
} 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
))
} }
} }
} }
@@ -237,7 +235,7 @@ final class SettingsModel: ObservableObject {
Task { Task {
preferences.setDiagnosticsEnabled(enabled) preferences.setDiagnosticsEnabled(enabled)
messages.show(UiMessage( messages.show(UiMessage(
text: .resource(enabled ? "diagnostics_enabled_message" : "diagnostics_disabled_message"), text: .resource(enabled ? L10n.Diagnostics.enabledMessage : L10n.Diagnostics.disabledMessage),
tone: .success tone: .success
)) ))
} }
@@ -298,8 +296,8 @@ final class SettingsModel: ObservableObject {
|| coreState.transfers.contains(where: { $0.status.isActiveTransfer }) || coreState.transfers.contains(where: { $0.status.isActiveTransfer })
guard !hasActiveWork else { guard !hasActiveWork else {
state.hasActiveNetworkWork = true state.hasActiveNetworkWork = true
state.relayApplyErrorKey = "relay_apply_active_transfers" state.relayApplyErrorKey = L10n.Relay.applyActiveTransfers
messages.show(UiMessage(text: .resource("relay_apply_active_transfers"), tone: .warning)) messages.show(UiMessage(text: .resource(L10n.Relay.applyActiveTransfers), tone: .warning))
return return
} }
@@ -318,16 +316,16 @@ final class SettingsModel: ObservableObject {
preferences.setRelayConfiguration(configuration) preferences.setRelayConfiguration(configuration)
state.isApplyingRelayConfiguration = false state.isApplyingRelayConfiguration = false
state.relayConfigurationIsDirty = 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): case .failure(let error):
if let lifecycleError = error as? CoreNetworkLifecycleError { if let lifecycleError = error as? CoreNetworkLifecycleError {
state.isApplyingRelayConfiguration = false state.isApplyingRelayConfiguration = false
switch lifecycleError { switch lifecycleError {
case .activeNetworkWork: case .activeNetworkWork:
state.hasActiveNetworkWork = true state.hasActiveNetworkWork = true
state.relayApplyErrorKey = "relay_apply_active_transfers" state.relayApplyErrorKey = L10n.Relay.applyActiveTransfers
case .transitionInProgress: case .transitionInProgress:
state.relayApplyErrorKey = "relay_apply_failed" state.relayApplyErrorKey = L10n.Relay.applyFailed
} }
return return
} }
@@ -337,11 +335,11 @@ final class SettingsModel: ObservableObject {
) )
state.isApplyingRelayConfiguration = false state.isApplyingRelayConfiguration = false
if case .success = rollbackResult { if case .success = rollbackResult {
state.relayApplyErrorKey = "relay_apply_failed" state.relayApplyErrorKey = L10n.Relay.applyFailed
messages.show(UiMessage(text: .resource("relay_apply_failed"), tone: .error)) messages.show(UiMessage(text: .resource(L10n.Relay.applyFailed), tone: .error))
} else { } else {
state.relayApplyErrorKey = "relay_restore_failed" state.relayApplyErrorKey = L10n.Relay.restoreFailed
messages.show(UiMessage(text: .resource("relay_restore_failed"), tone: .error)) 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 what = snapshot.bugWhatHappened.trimmingCharacters(in: .whitespacesAndNewlines)
let expected = snapshot.bugExpected.trimmingCharacters(in: .whitespacesAndNewlines) let expected = snapshot.bugExpected.trimmingCharacters(in: .whitespacesAndNewlines)
if what.isEmpty { 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 return
} }
if expected.isEmpty { 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 return
} }
state.isSubmittingBugReport = true state.isSubmittingBugReport = true
@@ -392,58 +390,55 @@ final class SettingsModel: ObservableObject {
state.bugSteps = "" state.bugSteps = ""
state.bugContact = "" state.bugContact = ""
state.bugIncludeLogs = true state.bugIncludeLogs = true
messages.show(UiMessage(text: .resource("bug_report_submitted"), tone: .success)) messages.show(UiMessage(text: .resource(L10n.Bug.reportSubmitted), tone: .success))
onSuccess() onSuccess()
case .failure: case .failure:
state.isSubmittingBugReport = false 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() { func openNotificationSettings() {
Task { Task {
enableNotificationsAfterSettings = true if case .failure = await notifications.openSettings() {
let result = await notifications.openSettings() messages.show(UiMessage(text: .resource(L10n.Notifications.settingsOpenFailed), tone: .error))
if case .failure = result {
enableNotificationsAfterSettings = false
messages.show(UiMessage(text: .resource("notifications_settings_open_failed"), 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() { func refreshNotificationPermission() {
Task { Task {
let permission = await notifications.refreshPermission() state.notificationPermission = 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()
} }
} }
}
private func enableNotifications() async {
preferences.setNotificationsEnabled(true)
messages.show(UiMessage(text: .resource("notifications_enabled_message"), tone: .success))
}
// MARK: - Storage // 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() { func loadStorageUsage() {
if state.isCalculatingStorage { return } if state.isCalculatingStorage { return }
state.isCalculatingStorage = true state.isCalculatingStorage = true
state.storageLoadFailed = false
let tempDir = NSTemporaryDirectory() let tempDir = NSTemporaryDirectory()
Task { 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 coreResult = await repository.storageUsage()
let artifactsResult = await repository.receivedArtifacts() let artifactsResult = await repository.receivedArtifacts()
guard case .success(let core) = coreResult, guard case .success(let core) = coreResult,
case .success(let artifacts) = artifactsResult else { case .success(let artifacts) = artifactsResult else {
state.isCalculatingStorage = false state.isCalculatingStorage = false
state.storageLoadFailed = true
return return
} }
let diskSizes = await Task.detached { let diskSizes = await Task.detached {
@@ -478,13 +473,90 @@ final class SettingsModel: ObservableObject {
state.isDeletingTransfers = false state.isDeletingTransfers = false
if failures == 0 { if failures == 0 {
loadStorageUsage() loadStorageUsage()
messages.show(UiMessage(text: .resource("storage_transfers_deleted"), tone: .success)) messages.show(UiMessage(text: .resource(L10n.Storage.transfersDeleted), tone: .success))
} else { } else {
messages.error(InvitationError.message("Could not delete \(failures) transfer records")) 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 { nonisolated static func fileSize(_ path: String) -> UInt64 {
let values = try? URL(fileURLWithPath: path).resourceValues( let values = try? URL(fileURLWithPath: path).resourceValues(
forKeys: [.isRegularFileKey, .totalFileAllocatedSizeKey, .fileSizeKey] forKeys: [.isRegularFileKey, .totalFileAllocatedSizeKey, .fileSizeKey]

View File

@@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import SFSafeSymbols
/// Settings screen, rebuilt on a native `Form` with `NavigationStack` push /// Settings screen, rebuilt on a native `Form` with `NavigationStack` push
/// navigation. The model stays the source of truth via a derived path binding. /// navigation. The model stays the source of truth via a derived path binding.
@@ -25,37 +26,37 @@ struct SettingsScreen: View {
Form { Form {
Section { Section {
NavigationLink(value: SettingsSection.preferences) { 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) { 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 { Section {
NavigationLink(value: SettingsSection.notifications) { 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) { 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) { NavigationLink(value: SettingsSection.network) {
SettingsRow( SettingsRow(
icon: "network", icon: .network,
title: String(localized: "settings_network_title"), title: String(localized: L10n.Settings.networkTitle),
value: relayModeLabel(model.state.relayMode) value: relayModeLabel(model.state.relayMode)
) )
} }
} }
Section { Section {
NavigationLink(value: SettingsSection.about) { 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) .formStyle(.grouped)
.navigationTitle(Text(LocalizedStringKey("settings_title"))) .navigationTitle(Text(String(localized: L10n.Settings.title)))
.navigationDestination(for: SettingsSection.self) { section in .navigationDestination(for: SettingsSection.self) { section in
sectionForm(section) sectionForm(section)
} }
@@ -68,7 +69,7 @@ struct SettingsScreen: View {
SettingsSectionContent(model: model, section: section) SettingsSectionContent(model: model, section: section)
} }
.formStyle(.grouped) .formStyle(.grouped)
.navigationTitle(Text(LocalizedStringKey(section.titleKey))) .navigationTitle(Text(String(localized: section.titleKey)))
if section == .about { if section == .about {
content content
@@ -80,7 +81,7 @@ struct SettingsScreen: View {
Button { Button {
showBugReport = true showBugReport = true
} label: { } 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 { func relayModeLabel(_ mode: RelayPreferenceMode) -> String {
switch mode { switch mode {
case .automatic: return String(localized: "relay_mode_automatic") case .automatic: return String(localized: L10n.Relay.modeAutomatic)
case .strictCustom: return String(localized: "relay_mode_custom") case .strictCustom: return String(localized: L10n.Relay.modeCustom)
case .customWithDirectFallback: return String(localized: "relay_mode_custom_direct_fallback") case .customWithDirectFallback: return String(localized: L10n.Relay.modeCustomDirectFallback)
case .localOnly: return String(localized: "relay_mode_local_only") case .localOnly: return String(localized: L10n.Relay.modeLocalOnly)
} }
} }
func relayModeDescriptionKey(_ mode: RelayPreferenceMode) -> String { func relayModeDescription(_ mode: RelayPreferenceMode) -> String.LocalizationValue {
switch mode { switch mode {
case .automatic: return "relay_mode_automatic_description" case .automatic: return L10n.Relay.modeAutomaticDescription
case .strictCustom: return "relay_mode_custom_description" case .strictCustom: return L10n.Relay.modeCustomDescription
case .customWithDirectFallback: return "relay_mode_custom_direct_fallback_description" case .customWithDirectFallback: return L10n.Relay.modeCustomDirectFallbackDescription
case .localOnly: return "relay_mode_local_only_description" case .localOnly: return L10n.Relay.modeLocalOnlyDescription
} }
} }
struct SettingsRow: View { struct SettingsRow: View {
let icon: String let icon: SFSymbol
let title: String let title: String
let value: String? let value: String?
var body: some View { var body: some View {
HStack(spacing: 12) { HStack(spacing: 12) {
Image(systemName: icon) Image(systemSymbol: icon)
.foregroundStyle(.tint) .foregroundStyle(.tint)
.frame(width: 26) .frame(width: 26)
Text(title).foregroundStyle(.primary) Text(title).foregroundStyle(.primary)
@@ -161,8 +162,8 @@ struct SettingsRow: View {
func themeModeLabel(_ mode: ThemeMode) -> String { func themeModeLabel(_ mode: ThemeMode) -> String {
switch mode { switch mode {
case .system: return String(localized: "appearance_system_mode") case .system: return String(localized: L10n.Appearance.systemMode)
case .light: return String(localized: "appearance_light_mode") case .light: return String(localized: L10n.Appearance.lightMode)
case .dark: return String(localized: "appearance_dark_mode") case .dark: return String(localized: L10n.Appearance.darkMode)
} }
} }

View File

@@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import SFSafeSymbols
/// Settings section detail views, rebuilt as native `Form` content. Each view is /// Settings section detail views, rebuilt as native `Form` content. Each view is
/// placed inside a parent `Form`, so it returns `Section`s / rows directly. /// placed inside a parent `Form`, so it returns `Section`s / rows directly.
@@ -7,16 +8,26 @@ struct PreferencesSettings: View {
@ObservedObject var model: SettingsModel @ObservedObject var model: SettingsModel
var body: some View { var body: some View {
Section(String(localized: "field_username")) { Section(String(localized: L10n.Field.username)) {
TextField(String(localized: "field_username"), TextField(String(localized: L10n.Field.username),
text: Binding(get: { model.state.username }, set: { model.setUsername($0) })) text: Binding(get: { model.state.username }, set: { model.setUsername($0) }))
} }
if model.state.supportsCustomReceiveFolders { if model.state.supportsCustomReceiveFolders {
Section(String(localized: "preferences_receive_folder_title")) { Section(String(localized: L10n.Preferences.receiveFolderTitle)) {
Text(model.state.receiveFolder?.displayName ?? String(localized: "value_unavailable")) LabeledContent {
.foregroundStyle(.secondary) Button(String(localized: L10n.Button.chooseFolder), action: model.chooseReceiveFolder)
Button(String(localized: "button_choose_folder"), action: model.chooseReceiveFolder) } label: {
Button(String(localized: "button_reset_default"), action: model.resetReceiveFolder) 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 { var body: some View {
Section { Section {
Picker(String(localized: "appearance_title"), Picker(String(localized: L10n.Appearance.title),
selection: Binding(get: { model.state.themeMode }, set: { model.setThemeMode($0) })) { selection: Binding(get: { model.state.themeMode }, set: { model.setThemeMode($0) })) {
ForEach(ThemeMode.allCases, id: \.self) { mode in ForEach(ThemeMode.allCases, id: \.self) { mode in
Text(themeModeLabel(mode)).tag(mode) Text(themeModeLabel(mode)).tag(mode)
@@ -44,16 +55,22 @@ struct NotificationSettings: View {
var body: some View { var body: some View {
Section { Section {
Toggle(isOn: Binding( Text(String(localized: L10n.Notifications.description)).foregroundStyle(.secondary)
get: { model.state.notificationsEnabled }, switch model.state.notificationPermission {
set: { model.setNotificationsEnabled($0) } case .notDetermined:
)) { Button(String(localized: L10n.Notifications.localTitle), action: model.requestNotifications)
Text(LocalizedStringKey("notifications_local_title")) case .granted:
} // Allowed the OS Settings app is where you disable or fine-tune.
if model.state.notificationPermission == .denied { Text(String(localized: L10n.Notifications.enabledMessage)).foregroundStyle(.secondary)
Button(String(localized: "button_open_settings"), action: model.openNotificationSettings) 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 { var body: some View {
Section { Section {
Picker( Picker(
String(localized: "settings_network_title"), "",
selection: Binding(get: { model.state.relayMode }, set: { model.setRelayMode($0) }) selection: Binding(get: { model.state.relayMode }, set: { model.setRelayMode($0) })
) { ) {
ForEach(RelayPreferenceMode.allCases, id: \.self) { mode in ForEach(RelayPreferenceMode.allCases, id: \.self) { mode in
@@ -71,24 +88,27 @@ struct NetworkSettings: View {
} }
} }
.pickerStyle(.inline) .pickerStyle(.inline)
.labelsHidden()
.disabled(model.state.isApplyingRelayConfiguration) .disabled(model.state.isApplyingRelayConfiguration)
} header: {
Text(String(localized: L10n.Settings.networkTitle))
} footer: { } footer: {
Text(LocalizedStringKey(relayModeDescriptionKey(model.state.relayMode))) Text(String(localized: relayModeDescription(model.state.relayMode)))
} }
Section { Section {
Label { Label {
Text(LocalizedStringKey("relay_privacy_description")) Text(String(localized: L10n.Relay.privacyDescription))
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
} icon: { } icon: {
Image(systemName: "lock.shield") Image(systemSymbol: .lockShield)
} }
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
if let endpointId = model.state.endpointId, !endpointId.isEmpty { if let endpointId = model.state.endpointId, !endpointId.isEmpty {
Section { Section {
Text(String(format: String(localized: "approval_endpoint_id"), endpointId)) Text(L10n.Approval.endpointId(deviceId: endpointId))
.font(.footnote.monospaced()) .font(.footnote.monospaced())
.textSelection(.enabled) .textSelection(.enabled)
} }
@@ -98,10 +118,10 @@ struct NetworkSettings: View {
Section { Section {
if model.state.relayMode == .strictCustom { if model.state.relayMode == .strictCustom {
Label { Label {
Text(LocalizedStringKey("relay_strict_warning")) Text(String(localized: L10n.Relay.strictWarning))
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
} icon: { } icon: {
Image(systemName: "exclamationmark.shield.fill") Image(systemSymbol: .exclamationmarkShieldFill)
} }
.foregroundStyle(.orange) .foregroundStyle(.orange)
} }
@@ -110,7 +130,7 @@ struct NetworkSettings: View {
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
HStack { HStack {
TextField( TextField(
"https://relay.example.com", "",
text: Binding( text: Binding(
get: { get: {
model.state.relayURLs.indices.contains(index) model.state.relayURLs.indices.contains(index)
@@ -118,8 +138,12 @@ struct NetworkSettings: View {
: "" : ""
}, },
set: { model.setRelayURL($0, at: index) } 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) #if os(iOS)
.keyboardType(.URL) .keyboardType(.URL)
.textInputAutocapitalization(.never) .textInputAutocapitalization(.never)
@@ -130,10 +154,10 @@ struct NetworkSettings: View {
Button(role: .destructive) { Button(role: .destructive) {
model.removeRelayURL(at: index) model.removeRelayURL(at: index)
} label: { } label: {
Image(systemName: "minus.circle.fill") Image(systemSymbol: .minusCircleFill)
} }
.buttonStyle(.borderless) .buttonStyle(.borderless)
.accessibilityLabel(Text(LocalizedStringKey("relay_remove_url"))) .accessibilityLabel(Text(String(localized: L10n.Relay.removeUrl)))
.disabled(model.state.isApplyingRelayConfiguration) .disabled(model.state.isApplyingRelayConfiguration)
} }
@@ -146,16 +170,16 @@ struct NetworkSettings: View {
} }
Button(action: model.addRelayURL) { Button(action: model.addRelayURL) {
Label(String(localized: "relay_add_url"), systemImage: "plus.circle") Label(String(localized: L10n.Relay.addUrl), systemSymbol: .plusCircle)
} }
.disabled( .disabled(
model.state.relayURLs.count >= RelayConfigurationValidator.maximumRelayCount model.state.relayURLs.count >= RelayConfigurationValidator.maximumRelayCount
|| model.state.isApplyingRelayConfiguration || model.state.isApplyingRelayConfiguration
) )
} header: { } header: {
Text(LocalizedStringKey("relay_custom_urls_label")) Text(String(localized: L10n.Relay.customUrlsLabel))
} footer: { } footer: {
Text(LocalizedStringKey("relay_custom_urls_help")) Text(String(localized: L10n.Relay.customUrlsHelp))
} }
} }
@@ -164,7 +188,7 @@ struct NetworkSettings: View {
Label { Label {
Text(relayValidationMessage(error)) Text(relayValidationMessage(error))
} icon: { } icon: {
Image(systemName: "exclamationmark.triangle.fill") Image(systemSymbol: .exclamationmarkTriangleFill)
} }
.foregroundStyle(.red) .foregroundStyle(.red)
} }
@@ -173,13 +197,9 @@ struct NetworkSettings: View {
if model.state.hasActiveNetworkWork || model.state.relayApplyErrorKey != nil { if model.state.hasActiveNetworkWork || model.state.relayApplyErrorKey != nil {
Section { Section {
Label { Label {
Text(LocalizedStringKey( Text(String(localized: model.state.hasActiveNetworkWork ? L10n.Relay.applyActiveTransfers : (model.state.relayApplyErrorKey ?? L10n.Relay.applyFailed)))
model.state.hasActiveNetworkWork
? "relay_apply_active_transfers"
: model.state.relayApplyErrorKey ?? "relay_apply_failed"
))
} icon: { } icon: {
Image(systemName: "exclamationmark.triangle.fill") Image(systemSymbol: .exclamationmarkTriangleFill)
} }
.foregroundStyle(.red) .foregroundStyle(.red)
} }
@@ -188,9 +208,7 @@ struct NetworkSettings: View {
Section { Section {
Button(action: model.applyRelayConfiguration) { Button(action: model.applyRelayConfiguration) {
HStack { HStack {
Text(LocalizedStringKey( Text(String(localized: model.state.isApplyingRelayConfiguration ? L10n.Relay.applying : L10n.Relay.apply))
model.state.isApplyingRelayConfiguration ? "relay_applying" : "relay_apply"
))
if model.state.isApplyingRelayConfiguration { if model.state.isApplyingRelayConfiguration {
Spacer() Spacer()
ProgressView() ProgressView()
@@ -203,7 +221,7 @@ struct NetworkSettings: View {
|| model.state.hasActiveNetworkWork || model.state.hasActiveNetworkWork
) )
} footer: { } 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 { private func relayValidationMessage(_ error: RelayConfigurationValidationError) -> String {
switch error { switch error {
case .missingURL: case .missingURL:
return String(localized: "relay_validation_missing_url") return String(localized: L10n.Relay.validationMissingUrl)
case .tooManyURLs: case .tooManyURLs:
return String( return L10n.Relay.validationTooManyUrls(maximum: RelayConfigurationValidator.maximumRelayCount)
format: String(localized: "relay_validation_too_many_urls"),
RelayConfigurationValidator.maximumRelayCount
)
case .httpsRequired(let index): 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): 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): 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 @ObservedObject var model: SettingsModel
@State private var showDeleteConfirmation = false @State private var showDeleteConfirmation = false
private var isBusy: Bool {
model.state.isCalculatingStorage || model.state.isCleaningStorage || model.state.isDeletingTransfers
}
var body: some View { var body: some View {
Section { Section {
if let storage = model.state.storage { usageContent
LabeledContent(String(localized: "storage_received_files"), value: formatBytes(storage.receivedFiles)) } header: {
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 { HStack {
Text(LocalizedStringKey("storage_calculating")).foregroundStyle(.secondary) Text(String(localized: L10n.Storage.usageHeader))
Spacer() Spacer()
ProgressView() 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: { } footer: {
Text(LocalizedStringKey("storage_footer")) Text(String(localized: L10n.Storage.footer))
} }
// Reclaim reversible junk (temp + trash) non-destructive to history.
Section { 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 showDeleteConfirmation = true
} label: { } label: {
HStack { actionLabel(
Text(model.state.isDeletingTransfers title: L10n.Storage.deleteTransfers,
? String(localized: "storage_deleting") busyTitle: L10n.Storage.deleting,
: String(localized: "storage_delete_transfers")) isBusy: model.state.isDeletingTransfers,
if model.state.isDeletingTransfers { symbol: .trash,
Spacer() tint: .red
ProgressView() )
} }
.buttonStyle(.plain)
.disabled(isBusy)
} footer: {
Text(String(localized: L10n.Storage.deleteTransfersCaption))
} }
} .task { model.loadStorageUsage() }
.disabled(model.state.isDeletingTransfers)
}
.onAppear { model.loadStorageUsage() }
.confirmationDialog( .confirmationDialog(
Text(LocalizedStringKey("storage_delete_transfers")), Text(String(localized: L10n.Storage.deleteTransfers)),
isPresented: $showDeleteConfirmation, isPresented: $showDeleteConfirmation,
titleVisibility: .visible titleVisibility: .visible
) { ) {
Button(String(localized: "storage_delete_transfers"), role: .destructive) { Button(String(localized: L10n.Storage.deleteTransfers), role: .destructive) {
model.deleteAllTransfers() model.deleteAllTransfers()
} }
Button(String(localized: "button_cancel"), role: .cancel) {} Button(String(localized: L10n.Button.cancel), role: .cancel) {}
} message: { } 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 { struct AboutSettings: View {
@@ -290,40 +379,40 @@ struct AboutSettings: View {
var body: some View { var body: some View {
Section { Section {
Text(LocalizedStringKey("about_tagline")).font(.headline) Text(String(localized: L10n.About.tagline)).font(.headline)
Text(LocalizedStringKey("about_description")).foregroundStyle(.secondary) Text(String(localized: L10n.About.description)).foregroundStyle(.secondary)
} }
Section(String(localized: "about_is_title")) { Section(String(localized: L10n.About.isTitle)) {
AboutPoint("about_is_direct", "paperplane") AboutPoint(L10n.About.isDirect, .paperplane)
AboutPoint("about_is_no_account", "person.crop.circle.badge.xmark") AboutPoint(L10n.About.isNoAccount, .personCropCircleBadgeXmark)
AboutPoint("about_is_in_control", "checkmark.shield") AboutPoint(L10n.About.isInControl, .checkmarkShield)
AboutPoint("about_is_encrypted", "lock") AboutPoint(L10n.About.isEncrypted, .lock)
AboutPoint("about_is_open", "chevron.left.forwardslash.chevron.right") AboutPoint(L10n.About.isOpen, .chevronLeftForwardslashChevronRight)
} }
Section(String(localized: "about_isnt_title")) { Section(String(localized: L10n.About.isntTitle)) {
AboutPoint("about_isnt_cloud", "icloud.slash") AboutPoint(L10n.About.isntCloud, .icloudSlash)
AboutPoint("about_isnt_sync", "arrow.triangle.2.circlepath") AboutPoint(L10n.About.isntSync, .arrowTriangle2Circlepath)
AboutPoint("about_isnt_public", "megaphone") AboutPoint(L10n.About.isntPublic, .megaphone)
} }
Section(String(localized: "about_privacy_title")) { Section(String(localized: L10n.About.privacyTitle)) {
AboutPoint("about_privacy_capability", "qrcode") AboutPoint(L10n.About.privacyCapability, .qrcode)
AboutPoint("about_privacy_deny", "hand.raised") AboutPoint(L10n.About.privacyDeny, .handRaised)
AboutPoint("about_privacy_relay", "antenna.radiowaves.left.and.right") AboutPoint(L10n.About.privacyRelay, .antennaRadiowavesLeftAndRight)
AboutPoint("about_privacy_local", "internaldrive") AboutPoint(L10n.About.privacyLocal, .internaldrive)
} }
Section(String(localized: "about_title")) { Section(String(localized: L10n.About.title)) {
LabeledContent(String(localized: "version_title"), value: model.state.appVersion) LabeledContent(String(localized: L10n.Version.title), value: model.state.appVersion)
if let device = model.state.deviceInfo { if let device = model.state.deviceInfo {
LabeledContent(String(localized: "device_model_title"), value: device.deviceModel ?? "") LabeledContent(String(localized: L10n.Device.modelTitle), value: device.deviceModel ?? "")
LabeledContent(String(localized: "os_version_title"), value: device.operatingSystem) 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) { 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 }, get: { model.state.diagnosticsEnabled },
set: { model.setDiagnosticsEnabled($0) } 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() }) BugReportSettings(model: model, onSubmitted: { dismiss() })
} }
.formStyle(.grouped) .formStyle(.grouped)
.navigationTitle(Text(LocalizedStringKey("about_bug_report"))) .navigationTitle(Text(String(localized: L10n.About.bugReport)))
#if os(iOS) #if os(iOS)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
#endif #endif
.toolbar { .toolbar {
ToolbarItem(placement: .cancellationAction) { 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. /// A bullet-style informational row with an SF Symbol and wrapping localized text.
private struct AboutPoint: View { private struct AboutPoint: View {
let key: String let key: String.LocalizationValue
let symbol: String let symbol: SFSymbol
init(_ key: String, _ symbol: String) { init(_ key: String.LocalizationValue, _ symbol: SFSymbol) {
self.key = key self.key = key
self.symbol = symbol self.symbol = symbol
} }
var body: some View { var body: some View {
Label { Label {
Text(LocalizedStringKey(key)) Text(String(localized: key))
.font(.subheadline) .font(.subheadline)
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
} icon: { } icon: {
Image(systemName: symbol).foregroundStyle(.tint) Image(systemSymbol: symbol).foregroundStyle(.tint)
} }
} }
} }
@@ -397,36 +486,36 @@ struct BugReportSettings: View {
var onSubmitted: () -> Void = {} var onSubmitted: () -> Void = {}
var body: some View { 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) }), 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) .lineLimit(3, reservesSpace: true)
.labelsHidden() .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) }), 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) .lineLimit(3, reservesSpace: true)
.labelsHidden() .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) }), 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) .lineLimit(3, reservesSpace: true)
.labelsHidden() .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) }), 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() .labelsHidden()
} }
Section { Section {
Toggle(isOn: Binding(get: { model.state.bugIncludeLogs }, set: { model.setBugIncludeLogs($0) })) { 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) }) { Button(action: { model.submitBugReport(onSuccess: onSubmitted) }) {
Text(model.state.isSubmittingBugReport 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) .disabled(model.state.isSubmittingBugReport)
} }

View File

@@ -36,7 +36,7 @@ private struct IosDeviceInfoProvider: DeviceInfoProvider {
device.isBatteryMonitoringEnabled = true device.isBatteryMonitoringEnabled = true
defer { device.isBatteryMonitoringEnabled = wasMonitoring } defer { device.isBatteryMonitoringEnabled = wasMonitoring }
let level = device.batteryLevel let level = device.batteryLevel
return level >= 0 ? "\(Int(level * 100))%" : nil return level >= 0 ? L10n.Battery.levelValue(level: "\(Int(level * 100))") : nil
}() }()
return DeviceInfo( return DeviceInfo(
deviceName: device.name, deviceName: device.name,

View File

@@ -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
}
}

View File

@@ -20,6 +20,10 @@
<string>$(DEVELOPMENT_LANGUAGE)</string> <string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key> <key>CFBundleDisplayName</key>
<string>VniDrop</string> <string>VniDrop</string>
<!-- macOS: a single instance only; re-launching activates the running app
instead of spawning another copy. -->
<key>LSMultipleInstancesProhibited</key>
<true/>
<key>CFBundleDocumentTypes</key> <key>CFBundleDocumentTypes</key>
<array> <array>
<dict> <dict>
@@ -63,6 +67,10 @@
<string>VniDrop uses the camera to scan transfer QR codes.</string> <string>VniDrop uses the camera to scan transfer QR codes.</string>
<key>NSLocalNetworkUsageDescription</key> <key>NSLocalNetworkUsageDescription</key>
<string>VniDrop needs local network access to send to other local devices if needed.</string> <string>VniDrop needs local network access to send to other local devices if needed.</string>
<!-- iPadOS: a single scene only — no second window via Stage Manager / split
view. Mirrors the single-window macOS behavior. -->
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
<key>UIBackgroundModes</key> <key>UIBackgroundModes</key>
<array> <array>
<string>fetch</string> <string>fetch</string>

File diff suppressed because it is too large Load Diff

View File

@@ -41,7 +41,7 @@ private struct SheetChrome<Content: View>: View {
ScrollView { content().padding(.top, 4) } ScrollView { content().padding(.top, 4) }
.toolbar { .toolbar {
ToolbarItem(placement: .cancellationAction) { ToolbarItem(placement: .cancellationAction) {
Button(String(localized: "button_close"), action: onClose) Button(String(localized: L10n.Button.close), action: onClose)
} }
} }
#if os(iOS) #if os(iOS)

View File

@@ -30,7 +30,7 @@ struct StatusPill: View {
// MARK: - ProgressRow // MARK: - ProgressRow
struct ProgressRow: View { struct ProgressRow: View {
let labelKey: String let labelKey: String.LocalizationValue
let progress: Double? let progress: Double?
var detail: String? = nil var detail: String? = nil
/// Pre-resolved label; when set it overrides `labelKey`. /// Pre-resolved label; when set it overrides `labelKey`.
@@ -42,7 +42,7 @@ struct ProgressRow: View {
label.font(.subheadline).lineLimit(1) label.font(.subheadline).lineLimit(1)
Spacer() Spacer()
if let progress { if let progress {
Text("\(Int(progress * 100))%").font(.caption).foregroundStyle(.secondary) Text(verbatim: "\(Int(progress * 100))%").font(.caption).foregroundStyle(.secondary)
} }
} }
if let detail { if let detail {
@@ -62,7 +62,7 @@ struct ProgressRow: View {
if let labelText { if let labelText {
Text(labelText) Text(labelText)
} else { } else {
Text(LocalizedStringKey(labelKey)) Text(String(localized: labelKey))
} }
} }
} }

View File

@@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import SFSafeSymbols
/// Bottom toast host driven by `UiMessageController`, ported from /// Bottom toast host driven by `UiMessageController`, ported from
/// `ui/feedback/VniDropSnackbarHost.kt`. Tone drives the accent color; errors get /// `ui/feedback/VniDropSnackbarHost.kt`. Tone drives the accent color; errors get
@@ -52,7 +53,7 @@ struct SnackbarHost: View {
.buttonStyle(.borderless) .buttonStyle(.borderless)
} }
Button(action: dismiss) { Button(action: dismiss) {
Image(systemName: "xmark") Image(systemSymbol: .xmark)
.font(.footnote.weight(.semibold)) .font(.footnote.weight(.semibold))
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.frame(width: 36, height: 36) .frame(width: 36, height: 36)

View File

@@ -4,14 +4,14 @@ import Combine
/// A localizable UI string: either a catalog key or dynamic text, ported from /// A localizable UI string: either a catalog key or dynamic text, ported from
/// `UiText` in `ui/feedback/UiMessageController.kt`. /// `UiText` in `ui/feedback/UiMessageController.kt`.
enum UiText: Equatable { enum UiText: Equatable {
case resource(String) // Localizable.xcstrings key case resource(String.LocalizationValue) // Localizable.xcstrings key (use L10n.*)
case dynamic(String) case dynamic(String)
/// Resolves to display text. Keys go through the string catalog. /// Resolves to display text. Keys go through the string catalog.
func resolved() -> String { func resolved() -> String {
switch self { switch self {
case .dynamic(let value): return value case .dynamic(let value): return value
case .resource(let key): return String(localized: String.LocalizationValue(key)) case .resource(let value): return String(localized: value)
} }
} }
} }

View File

@@ -8,34 +8,34 @@ extension Error {
if let vni = self as? VnidropError { if let vni = self as? VnidropError {
switch vni { switch vni {
case .Ticket: case .Ticket:
return .resource("error_invalid_ticket") return .resource(L10n.Error.invalidTicket)
case .Permission: case .Permission:
return .resource("error_permission") return .resource(L10n.Error.permission)
case .Filesystem: case .Filesystem:
return .resource("error_filesystem") return .resource(L10n.Error.filesystem)
case .FilesystemPermission: case .FilesystemPermission:
return .resource("error_filesystem") return .resource(L10n.Error.filesystem)
case .DestinationExists: case .DestinationExists:
return .resource("error_destination_exists") return .resource(L10n.Error.destinationExists)
case .StorageFull: case .StorageFull:
return .resource("error_storage_full") return .resource(L10n.Error.storageFull)
case .Network: case .Network:
return .resource("error_network") return .resource(L10n.Error.network)
case .Transfer(let reason): case .Transfer(let reason):
return transferUiText(reason) return transferUiText(reason)
case .Repository: case .Repository:
return .resource("error_repository") return .resource(L10n.Error.repository)
case .Cancelled: case .Cancelled:
return .resource("error_generic") return .resource(L10n.Error.generic)
case .InvalidInput: case .InvalidInput:
return .resource("error_invalid_input") return .resource(L10n.Error.invalidInput)
case .Initialization(let reason): case .Initialization(let reason):
return initializationUiText(reason) return initializationUiText(reason)
case .Internal(let 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. /// 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 { private func transferUiText(_ reason: String) -> UiText {
let detail = reason.lowercased() let detail = reason.lowercased()
if detail.contains("refused") || detail.contains("denied") || detail.contains("not approved") { 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 { private func initializationUiText(_ reason: String) -> UiText {
let detail = reason.lowercased() let detail = reason.lowercased()
if detail.contains("native") && detail.contains("library") { 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") { 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? { private func reasonHints(_ detailRaw: String) -> UiText? {
@@ -100,50 +133,50 @@ private func reasonHints(_ detailRaw: String) -> UiText? {
if detail.isEmpty { return nil } if detail.isEmpty { return nil }
if detail.contains("still starting") || detail.contains("starting up") { 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")) { 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") { 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") { if detail.contains("camera") {
return .resource("error_camera") return .resource(L10n.Error.camera)
} }
if detail.contains("nfc") || detail.contains("ndef") if detail.contains("nfc") || detail.contains("ndef")
|| (detail.contains("read-only") && detail.contains("tag")) || (detail.contains("read-only") && detail.contains("tag"))
|| detail.contains("tag is too small") || detail.contains("no nfc 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") { 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") { 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") { 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") if detail.contains("refused") || detail.contains("denied") || detail.contains("permission")
|| detail.contains("not approved") || detail.contains("waiting for approval") { || 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") if detail.contains("invalid ticket") || detail.contains("ticket error")
|| detail.contains("could not be read") || detail.contains("malformed") || detail.contains("could not be read") || detail.contains("malformed")
|| detail.contains("invitation could not be opened") { || detail.contains("invitation could not be opened") {
return .resource("error_invalid_ticket") return .resource(L10n.Error.invalidTicket)
} }
if detail.contains("selected") if detail.contains("selected")
&& (detail.contains("file") || detail.contains("folder") || detail.contains("document") || detail.contains("open")) { && (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") { 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") if detail.contains("document picker") || detail.contains("folder picker") || detail.contains("file descriptor")
|| detail.contains("view controller") { || detail.contains("view controller") {
return .resource("error_selection_failed") return .resource(L10n.Error.selectionFailed)
} }
return nil return nil
} }

View File

@@ -1,4 +1,5 @@
import Foundation import Foundation
import SFSafeSymbols
/// Top-level destinations, ported from `ui/navigation/AppDestination.kt`. /// Top-level destinations, ported from `ui/navigation/AppDestination.kt`.
enum AppDestination: String, CaseIterable, Identifiable { enum AppDestination: String, CaseIterable, Identifiable {
@@ -8,20 +9,20 @@ enum AppDestination: String, CaseIterable, Identifiable {
var id: String { rawValue } var id: String { rawValue }
var labelKey: String { var labelKey: String.LocalizationValue {
switch self { switch self {
case .send: return "nav_send" case .send: return L10n.Nav.send
case .receive: return "nav_receive" case .receive: return L10n.Nav.receive
case .settings: return "nav_settings" case .settings: return L10n.Nav.settings
} }
} }
/// SF Symbol approximating the Compose line icon. /// SF Symbol approximating the Compose line icon.
var systemImage: String { var systemSymbol: SFSymbol {
switch self { switch self {
case .send: return "paperplane" case .send: return .paperplane
case .receive: return "tray.and.arrow.down" case .receive: return .trayAndArrowDown
case .settings: return "gearshape" case .settings: return .gearshape
} }
} }
} }

View File

@@ -52,7 +52,9 @@ struct VniDropColors {
} }
extension 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 brandPurple = Color.hsl(271, 91, 65)
static let light = VniDropColors( static let light = VniDropColors(

View File

@@ -1,6 +1,9 @@
# XcodeGen spec for the native SwiftUI VniDrop app (iOS/iPadOS/macOS). # XcodeGen spec for the native SwiftUI VniDrop app (iOS/iPadOS/macOS).
# Regenerate the project with: xcodegen generate (run from apple/) # 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 name: VniDrop
options: options:
bundleIdPrefix: com.vnidrop bundleIdPrefix: com.vnidrop
@@ -9,9 +12,20 @@ options:
macOS: "15.0" macOS: "15.0"
createIntermediateGroups: true 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: packages:
VnidropCore: VnidropCore:
path: VnidropCore path: VnidropCore
SFSafeSymbols:
url: https://github.com/SFSafeSymbols/SFSafeSymbols
from: "5.3.0"
targets: targets:
VniDrop: VniDrop:
@@ -41,6 +55,9 @@ targets:
SWIFT_STRICT_CONCURRENCY: complete SWIFT_STRICT_CONCURRENCY: complete
ENABLE_USER_SCRIPT_SANDBOXING: NO ENABLE_USER_SCRIPT_SANDBOXING: NO
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon 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: configs:
debug: debug:
CODE_SIGN_ENTITLEMENTS: VniDrop/Resources/VniDrop.entitlements CODE_SIGN_ENTITLEMENTS: VniDrop/Resources/VniDrop.entitlements
@@ -48,9 +65,25 @@ targets:
CODE_SIGN_ENTITLEMENTS: VniDrop/Resources/VniDrop.entitlements CODE_SIGN_ENTITLEMENTS: VniDrop/Resources/VniDrop.entitlements
dependencies: dependencies:
- package: VnidropCore - package: VnidropCore
- package: SFSafeSymbols
- sdk: SystemConfiguration.framework - sdk: SystemConfiguration.framework
- sdk: Security.framework - sdk: Security.framework
- sdk: libresolv.tbd - 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: VniDropTests:
type: bundle.unit-test type: bundle.unit-test

View File

@@ -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].receiver_name.as_deref(), Some("Receiver"));
assert_eq!(deliveries[0].status, "completed"); assert_eq!(deliveries[0].status, "completed");
assert!(deliveries[0].completed_at.is_some()); assert!(deliveries[0].completed_at.is_some());
assert!( // The repository commit becomes visible just before the receipt handler
sender.sink.events().iter().any(|event| { // 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.phase == "delivery"
&& event.kind == "receiver-completed" && event.kind == "receiver-completed"
&& event.transfer_id == Some(share.transfer_id) && event.transfer_id == Some(share.transfer_id)
}), }) {
break;
}
assert!(
started.elapsed() < Duration::from_secs(5),
"delivery receipts must emit a delivery phase event for UI live updates" "delivery receipts must emit a delivery phase event for UI live updates"
); );
std::thread::sleep(Duration::from_millis(10));
}
} }
#[test] #[test]

View File

@@ -5,8 +5,11 @@
*/ */
import { mkdirSync } from "node:fs"; import { mkdirSync } from "node:fs";
import { join } from "node:path"; import { join } from "node:path";
import { mkdir } from "node:fs/promises";
import { dirname } from "node:path";
import { import {
APPLE_INFO_PLIST, APPLE_INFO_PLIST,
APPLE_L10N_SWIFT,
APPLE_XCSTRINGS, APPLE_XCSTRINGS,
kmpValuesDir, kmpValuesDir,
KMP_RESOURCES, KMP_RESOURCES,
@@ -14,6 +17,7 @@ import {
} from "../config"; } from "../config";
import { renderAndroid, type ParsedAndroid } from "../lib/android-xml"; import { renderAndroid, type ParsedAndroid } from "../lib/android-xml";
import { fromCanonical, type Flavor } from "../lib/placeholders"; import { fromCanonical, type Flavor } from "../lib/placeholders";
import { renderSwiftAccessors } from "../lib/swift-accessors";
import { renderXcstrings, type XcCatalog, type XcEntry } from "../lib/xcstrings"; import { renderXcstrings, type XcCatalog, type XcEntry } from "../lib/xcstrings";
import { targetsOf, type StringEntry, type StringsFile } from "../types"; import { targetsOf, type StringEntry, type StringsFile } from "../types";
@@ -111,6 +115,10 @@ export async function generate() {
await Bun.write(APPLE_XCSTRINGS, renderXcstrings(buildXcstrings(doc))); await Bun.write(APPLE_XCSTRINGS, renderXcstrings(buildXcstrings(doc)));
console.log(`Wrote ${APPLE_XCSTRINGS}`); 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); await syncInfoPlistLocalizations(doc.supportedLanguages);
for (const lang of doc.supportedLanguages) { for (const lang of doc.supportedLanguages) {

View File

@@ -18,6 +18,12 @@ export const APPLE_INFO_PLIST = join(
"apple/VniDrop/Resources/Info.plist", "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. */ /** KMP / Compose Multiplatform resources root; one values[-lang]/strings.xml per language. */
export const KMP_RESOURCES = join( export const KMP_RESOURCES = join(
REPO_ROOT, REPO_ROOT,

View File

@@ -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<string, string[]>();
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`;
}

View File

@@ -13,76 +13,6 @@
"ru" "ru"
], ],
"strings": { "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": { "about_bug_report": {
"context": "About screen: button/link that opens the bug report form.", "context": "About screen: button/link that opens the bug report form.",
"translations": { "translations": {
@@ -391,6 +321,20 @@
"ru": "О приложении" "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": { "appearance_auto_description": {
"context": "Settings > Appearance: description for the System/auto option.", "context": "Settings > Appearance: description for the System/auto option.",
"translations": { "translations": {
@@ -476,23 +420,23 @@
} }
}, },
"approval_endpoint_id": { "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": [ "args": [
{ {
"name": "arg1", "name": "deviceId",
"type": "string" "type": "string"
} }
], ],
"translations": { "translations": {
"en": "Device ID: {arg1}", "en": "Device ID: {deviceId}",
"fr": "Identifiant de lappareil : {arg1}", "fr": "Identifiant de lappareil : {deviceId}",
"es": "ID del dispositivo: {arg1}", "es": "ID del dispositivo: {deviceId}",
"it": "ID dispositivo: {arg1}", "it": "ID dispositivo: {deviceId}",
"de": "Geräte-ID: {arg1}", "de": "Geräte-ID: {deviceId}",
"pt": "ID do dispositivo: {arg1}", "pt": "ID do dispositivo: {deviceId}",
"pl": "Identyfikator urządzenia: {arg1}", "pl": "Identyfikator urządzenia: {deviceId}",
"nl": "Apparaat-ID: {arg1}", "nl": "Apparaat-ID: {deviceId}",
"ru": "Идентификатор устройства: {arg1}" "ru": "Идентификатор устройства: {deviceId}"
} }
}, },
"approval_nearby_device": { "approval_nearby_device": {
@@ -530,27 +474,27 @@
} }
}, },
"approval_request_body": { "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": [ "args": [
{ {
"name": "arg1", "name": "receiver",
"type": "string" "type": "string"
}, },
{ {
"name": "arg2", "name": "transferName",
"type": "string" "type": "string"
} }
], ],
"translations": { "translations": {
"en": "{arg1} wants to receive “{arg2}”.", "en": "{receiver} wants to receive “{transferName}”.",
"fr": "{arg1} souhaite recevoir « {arg2} ».", "fr": "{receiver} souhaite recevoir « {transferName} ».",
"es": "{arg1} quiere recibir «{arg2}».", "es": "{receiver} quiere recibir «{transferName}».",
"it": "{arg1} vuole ricevere «{arg2}».", "it": "{receiver} vuole ricevere «{transferName}».",
"de": "{arg1} möchte „{arg2}“ empfangen.", "de": "{receiver} möchte „{transferName}“ empfangen.",
"pt": "{arg1} quer receber «{arg2}».", "pt": "{receiver} quer receber «{transferName}».",
"pl": "{arg1} chce odebrać „{arg2}”.", "pl": "{receiver} chce odebrać „{transferName}”.",
"nl": "{arg1} wil {arg2} ontvangen.", "nl": "{receiver} wil {transferName} ontvangen.",
"ru": "{arg1} хочет получить «{arg2}»." "ru": "{receiver} хочет получить «{transferName}»."
} }
}, },
"battery_level_title": { "battery_level_title": {
@@ -567,6 +511,29 @@
"ru": "Уровень заряда" "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": { "bug_report_contact_hint": {
"context": "Bug report form: placeholder text in the contact email field.", "context": "Bug report form: placeholder text in the contact email field.",
"translations": { "translations": {
@@ -861,6 +828,20 @@
"ru": "Назад" "ru": "Назад"
} }
}, },
"button_more_actions": {
"context": "Accessibility label for a button that opens more actions for an item.",
"translations": {
"en": "More actions",
"fr": "Plus dactions",
"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": { "button_cancel": {
"context": "Button: cancel the current action or dialog.", "context": "Button: cancel the current action or dialog.",
"translations": { "translations": {
@@ -1645,6 +1626,64 @@
"ru": "Готово" "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": { "metadata_files": {
"context": "Transfer metadata label: number of files.", "context": "Transfer metadata label: number of files.",
"translations": { "translations": {
@@ -1746,15 +1785,15 @@
"notifications_description": { "notifications_description": {
"context": "Settings > Notifications: explanation of what notifications are used for.", "context": "Settings > Notifications: explanation of what notifications are used for.",
"translations": { "translations": {
"en": "Get notified about new receive requests while VniDrop is in the background.", "en": "Get notified about transfer activity while VniDrop is in the background.",
"fr": "Soyez averti des nouvelles demandes de réception lorsque VniDrop est en arrière-plan.", "fr": "Soyez averti de lactivité des transferts lorsque VniDrop est en arrière-plan.",
"es": "Reciba avisos sobre nuevas solicitudes de recepción cuando VniDrop está en segundo plano.", "es": "Reciba avisos sobre la actividad de las transferencias cuando VniDrop está en segundo plano.",
"it": "Ricevi avvisi sulle nuove richieste di ricezione quando VniDrop è in background.", "it": "Ricevi avvisi sullattività dei trasferimenti quando VniDrop è in background.",
"de": "Werden Sie über neue Empfangsanfragen benachrichtigt, während VniDrop im Hintergrund läuft.", "de": "Werden Sie über Übertragungsaktivitäten benachrichtigt, während VniDrop im Hintergrund läuft.",
"pt": "Seja notificado sobre novos pedidos de receção enquanto o VniDrop está em segundo plano.", "pt": "Seja notificado sobre a atividade das transferências enquanto o VniDrop está em segundo plano.",
"pl": "Otrzymuj powiadomienia o nowych prośbach o odbiór, gdy VniDrop działa w tle.", "pl": "Otrzymuj powiadomienia o aktywności transferów, gdy VniDrop działa w tle.",
"nl": "Ontvang meldingen over nieuwe ontvangstverzoeken terwijl VniDrop op de achtergrond draait.", "nl": "Ontvang meldingen over overdrachtsactiviteit terwijl VniDrop op de achtergrond draait.",
"ru": "Получайте уведомления о новых запросах на получение, пока VniDrop работает в фоне." "ru": "Получайте уведомления об активности передач, пока VniDrop работает в фоне."
} }
}, },
"notifications_enabled_message": { "notifications_enabled_message": {
@@ -1799,6 +1838,184 @@
"ru": "Уведомления отключены для VniDrop. Вы можете включить их в Настройках." "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}” couldnt be received.",
"fr": "« {transferName} » na 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}” couldnt be shared.",
"fr": "« {transferName} » na 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": { "notifications_settings_open_failed": {
"context": "Settings > Notifications: error when the OS notification settings can't be opened.", "context": "Settings > Notifications: error when the OS notification settings can't be opened.",
"translations": { "translations": {
@@ -2212,23 +2429,23 @@
} }
}, },
"receive_delete_history_description": { "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": [ "args": [
{ {
"name": "arg1", "name": "transferName",
"type": "string" "type": "string"
} }
], ],
"translations": { "translations": {
"en": "“{arg1}” will be removed from VniDrops history. The downloaded file will remain on this device.", "en": "“{transferName}” will be removed from VniDrops history. The downloaded file will remain on this device.",
"fr": "« {arg1} » sera retiré de lhistorique de VniDrop. Le fichier téléchargé restera sur cet appareil.", "fr": "« {transferName} » sera retiré de lhistorique 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.", "es": "«{transferName}» 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.", "it": "«{transferName}» 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.", "de": "„{transferName}“ 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.", "pt": "«{transferName}» 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.", "pl": "„{transferName}” 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.", "nl": "{transferName} wordt uit de geschiedenis van VniDrop verwijderd. Het gedownloade bestand blijft op dit apparaat.",
"ru": "«{arg1}» будет удалён из истории VniDrop. Загруженный файл останется на этом устройстве." "ru": "«{transferName}» будет удалён из истории VniDrop. Загруженный файл останется на этом устройстве."
} }
}, },
"receive_delete_history_item": { "receive_delete_history_item": {
@@ -3381,6 +3598,206 @@
"ru": "Вычисление…" "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 nest pas utilisé par une réception en cours ou un partage actif. Les fichiers reçus et lhistorique 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 apps cached share content. Received files on disk are kept.",
"fr": "Efface votre historique denvois et de réceptions ainsi que le contenu de partage mis en cache par lapp. 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 dallapp. 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": { "storage_delete_transfers": {
"context": "Settings > Storage: button to delete all transfer records.", "context": "Settings > Storage: button to delete all transfer records.",
"translations": { "translations": {
@@ -3398,15 +3815,15 @@
"storage_delete_transfers_description": { "storage_delete_transfers_description": {
"context": "Settings > Storage: confirmation body for deleting all transfer records.", "context": "Settings > Storage: confirmation body for deleting all transfer records.",
"translations": { "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 cant be undone.", "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 cant 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 nest plus nécessaire est récupéré automatiquement, ce qui peut prendre un peu de temps. Cette action est irréversible.", "fr": "Cela efface tous les transferts envoyés et reçus de lhistorique 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 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.", "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": "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.", "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. 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.", "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 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.", "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": "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ąć.", "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 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.", "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": "Это удалит из истории все записи об отправленных и полученных передачах. Полученные файлы не удаляются. Кэшированное общее содержимое, которое больше не требуется, освобождается автоматически; это может занять некоторое время. Это действие нельзя отменить." "ru": "Это удалит из истории все отправленные и полученные передачи и немедленно освободит неиспользуемый кэш. Текущие передачи и полученные файлы не удаляются. Это действие нельзя отменить."
} }
}, },
"storage_app_data": { "storage_app_data": {
@@ -3564,23 +3981,23 @@
} }
}, },
"transfer_delete_description": { "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": [ "args": [
{ {
"name": "arg1", "name": "transferName",
"type": "string" "type": "string"
} }
], ],
"translations": { "translations": {
"en": "“{arg1}” will stop being shared and its transfer history will be removed from this device.", "en": "“{transferName}” 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.", "fr": "« {transferName} » 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.", "es": "«{transferName}» 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.", "it": "«{transferName}» 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.", "de": "„{transferName}“ 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.", "pt": "«{transferName}» 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.", "pl": "„{transferName}” 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.", "nl": "{transferName} wordt niet meer gedeeld en de overdrachtsgeschiedenis wordt van dit apparaat verwijderd.",
"ru": "Общий доступ к «{arg1}» будет остановлен, а история передачи будет удалена с этого устройства." "ru": "Общий доступ к «{transferName}» будет остановлен, а история передачи будет удалена с этого устройства."
} }
}, },
"transfer_delete_title": { "transfer_delete_title": {
@@ -3998,6 +4415,20 @@
"ru": "Запрос истёк" "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": { "transfer_receiver_refused": {
"context": "Receiver status: the request was refused.", "context": "Receiver status: the request was refused.",
"translations": { "translations": {

View File

@@ -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. | | 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. | | 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%). | | 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. | | DI | Follow existing `AppGraph` construction; no unprompted Hilt/Koin migration. |
| Platform | `androidMain` / `jvmMain` for pickers, SAF, NFC/QR, and desktop integration. | | Platform | `androidMain` / `jvmMain` for pickers, SAF, NFC/QR, and desktop integration. |
| Dependencies | Before adding Jetpack/AndroidX to `commonMain`, verify multiplatform artifacts for all targets. | | Dependencies | Before adding Jetpack/AndroidX to `commonMain`, verify multiplatform artifacts for all targets. |

View File

@@ -105,6 +105,30 @@ private class AndroidFileSystemService(
override suspend fun temporaryUsage(receiveFolder: ReceiveFolder): ULong = directorySize(context.cacheDir) 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? = override fun createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSinkV2? =
when (folder.kind) { when (folder.kind) {
ReceiveFolderKind.AndroidPublicDownloads -> AndroidMediaStoreDownloadsSink(context) ReceiveFolderKind.AndroidPublicDownloads -> AndroidMediaStoreDownloadsSink(context)

View File

@@ -0,0 +1,5 @@
<?xml version='1.0' encoding='utf-8'?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<!-- fluent icon imported by shared/tools/import_platform_icons.py. -->
<path android:pathData="M12 7.75C11.0335 7.75 10.25 6.9665 10.25 6C10.25 5.0335 11.0335 4.25 12 4.25C12.9665 4.25 13.75 5.0335 13.75 6C13.75 6.9665 12.9665 7.75 12 7.75ZM12 13.75C11.0335 13.75 10.25 12.9665 10.25 12C10.25 11.0335 11.0335 10.25 12 10.25C12.9665 10.25 13.75 11.0335 13.75 12C13.75 12.9665 12.9665 13.75 12 13.75ZM10.25 18C10.25 18.9665 11.0335 19.75 12 19.75C12.9665 19.75 13.75 18.9665 13.75 18C13.75 17.0335 12.9665 16.25 12 16.25C11.0335 16.25 10.25 17.0335 10.25 18Z" android:fillColor="#FF000000" />
</vector>

View File

@@ -0,0 +1,5 @@
<?xml version='1.0' encoding='utf-8'?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<!-- fluent icon imported by shared/tools/import_platform_icons.py. -->
<path android:pathData="M10.2499 2.99985C10.6641 2.99985 10.9999 3.33571 10.9999 3.74985C10.9999 4.16407 10.6642 4.49985 10.2499 4.49985H6.74994C5.50735 4.49985 4.50002 5.50728 4.49994 6.74985V17.2499L4.51166 17.4803C4.62708 18.6147 5.58511 19.4999 6.74994 19.4999H17.2499C18.4926 19.4999 19.4999 18.4925 19.4999 17.2499V15.2499C19.5 14.8357 19.8358 14.4999 20.2499 14.4999C20.6641 14.4999 20.9999 14.8357 20.9999 15.2499V17.2499C20.9999 19.3209 19.321 20.9999 17.2499 20.9999H6.74994C4.74372 20.9999 3.10545 19.4244 3.00482 17.4432L2.99994 17.2499V6.74985C3.00002 4.67885 4.67892 2.99985 6.74994 2.99985H10.2499ZM14.9365 2.06821C15.2025 1.94588 15.5159 1.98997 15.7382 2.18052L22.7382 8.18052C22.8985 8.31798 22.9937 8.5173 22.9999 8.72837C23.006 8.93935 22.9224 9.14336 22.7704 9.28989L15.7704 16.0399C15.554 16.2486 15.2337 16.3077 14.957 16.1903C14.6801 16.0726 14.4999 15.8007 14.4999 15.4999V12.5243C13.4856 12.5878 12.5295 12.7971 11.5605 13.2928C10.4241 13.8742 9.21636 14.8744 7.87103 16.5956L7.59955 16.9501C7.40582 17.2081 7.06877 17.3138 6.76263 17.2118C6.4564 17.1097 6.24994 16.8227 6.24994 16.4999C6.24996 13.6426 6.93653 10.9108 8.41791 8.87192C9.79374 6.97863 11.8288 5.72528 14.4999 5.52817V2.74985L14.5078 2.64146C14.5441 2.39271 14.7037 2.17528 14.9365 2.06821ZM15.9999 6.24985C15.9999 6.66407 15.6642 6.99985 15.2499 6.99985C12.6932 6.99985 10.8549 8.06982 9.63177 9.75278C8.75381 10.961 8.17788 12.5099 7.91498 14.2459C8.91528 13.1985 9.89191 12.4608 10.8769 11.9569C12.3601 11.1981 13.8009 10.9999 15.2499 10.9999C15.4488 10.9999 15.6396 11.079 15.7802 11.2196C15.9208 11.3602 15.9999 11.551 15.9999 11.7499V13.7342L21.1347 8.78208L15.9999 4.37974V6.24985Z" android:fillColor="#FF000000" />
</vector>

View File

@@ -0,0 +1,5 @@
<?xml version='1.0' encoding='utf-8'?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<!-- fluent icon imported by shared/tools/import_platform_icons.py. -->
<path android:pathData="M8.66535 15.7345C8.90969 15.9075 9.20192 15.9999 9.50129 15.9989V15.9949C9.78358 15.9955 10.0598 15.9132 10.2957 15.7582C10.5316 15.6031 10.7168 15.3822 10.8283 15.1229L11.4413 13.2589C11.5829 12.8366 11.8207 12.4531 12.1361 12.1386C12.4514 11.8241 12.8356 11.5873 13.2583 11.4469L15.0363 10.8689C15.3227 10.7683 15.5701 10.5799 15.7432 10.3305C15.9164 10.0812 16.0065 9.7836 16.0007 9.48009C15.9949 9.17659 15.8936 8.88262 15.7111 8.64003C15.5286 8.39744 15.2743 8.21858 14.9843 8.12889L13.2293 7.55889C12.8048 7.41763 12.419 7.1793 12.1027 6.86283C11.7864 6.54637 11.5483 6.1605 11.4073 5.73589L10.8293 3.95889C10.7285 3.67586 10.542 3.43127 10.2958 3.25908C10.0496 3.0869 9.7559 2.99567 9.45547 2.99809C9.15504 3.00051 8.86282 3.09645 8.61942 3.27258C8.37602 3.44871 8.19353 3.69628 8.09729 3.98089L7.51429 5.77289C7.37371 6.18515 7.14135 6.56012 6.83474 6.8695C6.52813 7.17888 6.15528 7.4146 5.74429 7.55889L3.96729 8.12989C3.75592 8.20382 3.56463 8.32589 3.40855 8.48647C3.25247 8.64704 3.13587 8.84172 3.06798 9.05511C3.00008 9.2685 2.98274 9.49475 3.01733 9.716C3.05193 9.93724 3.1375 10.1474 3.26729 10.3299C3.44418 10.5789 3.69508 10.7657 3.98429 10.8639L5.73829 11.4329C6.16392 11.5753 6.55049 11.815 6.86729 12.1329C6.95236 12.2173 7.03186 12.3072 7.10529 12.4019C7.30447 12.6597 7.45833 12.9495 7.56029 13.2589L8.13829 15.0339C8.23676 15.3166 8.42102 15.5615 8.66535 15.7345ZM8.29129 11.4849C8.17979 11.3392 8.05851 11.2012 7.92829 11.0719H7.93129C7.44841 10.5886 6.85948 10.2247 6.21129 10.0089L4.61129 9.50089L6.22229 8.97689C6.85983 8.75615 7.43806 8.39176 7.91229 7.91189C8.38447 7.42897 8.74047 6.84487 8.95329 6.20389L9.46829 4.62189L9.98429 6.20889C10.1997 6.85585 10.5633 7.44351 11.0462 7.92497C11.529 8.40643 12.1177 8.76837 12.7653 8.98189L14.3843 9.50389L12.7953 10.0189C12.1489 10.2338 11.5616 10.5965 11.08 11.0782C10.5984 11.56 10.2359 12.1474 10.0213 12.7939L9.50629 14.3759L8.99129 12.7909C8.83406 12.3189 8.59732 11.8772 8.29129 11.4849ZM16.332 20.7819C16.1415 20.6461 15.9981 20.4541 15.922 20.2329L15.594 19.2259C15.5302 19.0344 15.4231 18.8602 15.281 18.7169C15.1385 18.574 14.9645 18.4664 14.773 18.4029L13.782 18.0799C13.5516 18.0026 13.3516 17.8541 13.211 17.6559C13.0737 17.4636 13.0002 17.2331 13.001 16.9969C13.0013 16.7591 13.0757 16.5274 13.214 16.3339C13.3512 16.1394 13.5466 15.9934 13.772 15.9169L14.778 15.5909C14.965 15.5245 15.1343 15.4162 15.273 15.2742C15.4117 15.1322 15.516 14.9604 15.578 14.7719L15.902 13.7799C15.9782 13.5557 16.1222 13.3606 16.3141 13.2218C16.5059 13.0829 16.7362 13.0071 16.973 13.0048C17.2099 13.0025 17.4416 13.0738 17.6361 13.2089C17.8306 13.344 17.9784 13.5362 18.059 13.7589L18.388 14.7729C18.4524 14.963 18.5599 15.1356 18.7022 15.2771C18.8445 15.4187 19.0176 15.5254 19.208 15.5889L20.2 15.9119C20.4287 15.9834 20.629 16.1251 20.7727 16.3169C20.9163 16.5086 20.9959 16.7408 21.0002 16.9803C21.0045 17.2199 20.9333 17.4547 20.7967 17.6516C20.6601 17.8484 20.4649 17.9972 20.239 18.0769L19.225 18.4059C19.0349 18.4707 18.8622 18.5781 18.72 18.7199C18.5777 18.8627 18.4705 19.0366 18.407 19.2279L18.085 20.2169C18.0075 20.4472 17.8586 20.6469 17.66 20.7869C17.468 20.9249 17.2375 20.9991 17.001 20.9989C16.7607 20.9989 16.5265 20.9229 16.332 20.7819ZM15.3014 16.999C15.6943 17.1386 16.051 17.3646 16.3452 17.6602C16.6394 17.956 16.8635 18.314 17.001 18.7079C17.1399 18.3158 17.3646 17.9597 17.6587 17.6656C17.9528 17.3715 18.3089 17.1468 18.701 17.0079C18.3026 16.8685 17.9406 16.6412 17.642 16.3429C17.3469 16.049 17.1217 15.6926 16.983 15.2999C16.8449 15.6898 16.6223 16.0444 16.3312 16.3383C16.0407 16.6316 15.689 16.8572 15.3014 16.999Z" android:fillColor="#FF000000" />
</vector>

View File

@@ -0,0 +1,5 @@
<?xml version='1.0' encoding='utf-8'?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<!-- fluent icon imported by shared/tools/import_platform_icons.py. -->
<path android:pathData="M19.25 4.5C19.3881 4.5 19.5 4.61193 19.5 4.75V19.25C19.5 19.3881 19.3881 19.5 19.25 19.5H4.75C4.61193 19.5 4.5 19.3881 4.5 19.25V4.75C4.5 4.61193 4.61193 4.5 4.75 4.5H19.25ZM4.75 3C3.7835 3 3 3.7835 3 4.75V19.25C3 20.2165 3.7835 21 4.75 21H19.25C20.2165 21 21 20.2165 21 19.25V4.75C21 3.7835 20.2165 3 19.25 3H4.75Z" android:fillColor="#FF000000" />
</vector>

View File

@@ -0,0 +1,7 @@
<?xml version='1.0' encoding='utf-8'?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<!-- lucide icon imported by shared/tools/import_platform_icons.py. -->
<path android:pathData="M 13 12 A 1 1 0 1 0 11 12 A 1 1 0 1 0 13 12" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
<path android:pathData="M 13 5 A 1 1 0 1 0 11 5 A 1 1 0 1 0 13 5" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
<path android:pathData="M 13 19 A 1 1 0 1 0 11 19 A 1 1 0 1 0 13 19" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
</vector>

View File

@@ -0,0 +1,9 @@
<?xml version='1.0' encoding='utf-8'?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<!-- lucide icon imported by shared/tools/import_platform_icons.py. -->
<path android:pathData="M 21 5 A 3 3 0 1 0 15 5 A 3 3 0 1 0 21 5" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
<path android:pathData="M 9 12 A 3 3 0 1 0 3 12 A 3 3 0 1 0 9 12" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
<path android:pathData="M 21 19 A 3 3 0 1 0 15 19 A 3 3 0 1 0 21 19" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
<path android:pathData="M 8.59 13.51 L 15.42 17.49" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
<path android:pathData="M 15.41 6.51 L 8.59 10.49" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
</vector>

View File

@@ -0,0 +1,8 @@
<?xml version='1.0' encoding='utf-8'?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<!-- lucide icon imported by shared/tools/import_platform_icons.py. -->
<path android:pathData="M11.017 2.814a1 1 0 0 1 1.966 0l1.051 5.558a2 2 0 0 0 1.594 1.594l5.558 1.051a1 1 0 0 1 0 1.966l-5.558 1.051a2 2 0 0 0-1.594 1.594l-1.051 5.558a1 1 0 0 1-1.966 0l-1.051-5.558a2 2 0 0 0-1.594-1.594l-5.558-1.051a1 1 0 0 1 0-1.966l5.558-1.051a2 2 0 0 0 1.594-1.594z" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
<path android:pathData="M20 2v4" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
<path android:pathData="M22 4h-4" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
<path android:pathData="M 6 20 A 2 2 0 1 0 2 20 A 2 2 0 1 0 6 20" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
</vector>

View File

@@ -0,0 +1,6 @@
<?xml version='1.0' encoding='utf-8'?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<!-- lucide icon imported by shared/tools/import_platform_icons.py. -->
<path android:pathData="M 22 12 A 10 10 0 1 0 2 12 A 10 10 0 1 0 22 12" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
<path android:pathData="M 10 9 H 14 A 1 1 0 0 1 15 10 V 14 A 1 1 0 0 1 14 15 H 10 A 1 1 0 0 1 9 14 V 10 A 1 1 0 0 1 10 9 Z" android:fillColor="#00000000" android:strokeColor="#FF000000" android:strokeWidth="2" android:strokeLineCap="round" android:strokeLineJoin="round" />
</vector>

View File

@@ -0,0 +1,5 @@
<?xml version='1.0' encoding='utf-8'?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="960" android:viewportHeight="960">
<!-- material icon imported by shared/tools/import_platform_icons.py. -->
<path android:fillColor="#FF000000" android:pathData="M480,800Q447,800 423.5,776.5Q400,753 400,720Q400,687 423.5,663.5Q447,640 480,640Q513,640 536.5,663.5Q560,687 560,720Q560,753 536.5,776.5Q513,800 480,800ZM480,560Q447,560 423.5,536.5Q400,513 400,480Q400,447 423.5,423.5Q447,400 480,400Q513,400 536.5,423.5Q560,447 560,480Q560,513 536.5,536.5Q513,560 480,560ZM480,320Q447,320 423.5,296.5Q400,273 400,240Q400,207 423.5,183.5Q447,160 480,160Q513,160 536.5,183.5Q560,207 560,240Q560,273 536.5,296.5Q513,320 480,320Z" />
</vector>

View File

@@ -0,0 +1,5 @@
<?xml version='1.0' encoding='utf-8'?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="960" android:viewportHeight="960">
<!-- material icon imported by shared/tools/import_platform_icons.py. -->
<path android:fillColor="#FF000000" android:pathData="M720,880Q670,880 635,845Q600,810 600,760Q600,753 601,745.5Q602,738 604,732L322,568Q305,583 284,591.5Q263,600 240,600Q190,600 155,565Q120,530 120,480Q120,430 155,395Q190,360 240,360Q263,360 284,368.5Q305,377 322,392L604,228Q602,222 601,214.5Q600,207 600,200Q600,150 635,115Q670,80 720,80Q770,80 805,115Q840,150 840,200Q840,250 805,285Q770,320 720,320Q697,320 676,311.5Q655,303 638,288L356,452Q358,458 359,465.5Q360,473 360,480Q360,487 359,494.5Q358,502 356,508L638,672Q655,657 676,648.5Q697,640 720,640Q770,640 805,675Q840,710 840,760Q840,810 805,845Q770,880 720,880ZM720,240Q737,240 748.5,228.5Q760,217 760,200Q760,183 748.5,171.5Q737,160 720,160Q703,160 691.5,171.5Q680,183 680,200Q680,217 691.5,228.5Q703,240 720,240ZM240,520Q257,520 268.5,508.5Q280,497 280,480Q280,463 268.5,451.5Q257,440 240,440Q223,440 211.5,451.5Q200,463 200,480Q200,497 211.5,508.5Q223,520 240,520ZM720,800Q737,800 748.5,788.5Q760,777 760,760Q760,743 748.5,731.5Q737,720 720,720Q703,720 691.5,731.5Q680,743 680,760Q680,777 691.5,788.5Q703,800 720,800ZM720,200Q720,200 720,200Q720,200 720,200Q720,200 720,200Q720,200 720,200Q720,200 720,200Q720,200 720,200Q720,200 720,200Q720,200 720,200ZM240,480Q240,480 240,480Q240,480 240,480Q240,480 240,480Q240,480 240,480Q240,480 240,480Q240,480 240,480Q240,480 240,480Q240,480 240,480ZM720,760Q720,760 720,760Q720,760 720,760Q720,760 720,760Q720,760 720,760Q720,760 720,760Q720,760 720,760Q720,760 720,760Q720,760 720,760Z" />
</vector>

View File

@@ -0,0 +1,5 @@
<?xml version='1.0' encoding='utf-8'?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<!-- material icon imported by shared/tools/import_platform_icons.py. -->
<path android:fillColor="#FF000000" android:pathData="M19,8.3Q18.875,8.3 18.738,8.225Q18.6,8.15 18.55,8L17.75,6.25L16,5.45Q15.85,5.4 15.775,5.262Q15.7,5.125 15.7,5Q15.7,4.875 15.775,4.737Q15.85,4.6 16,4.55L17.75,3.75L18.55,2Q18.6,1.85 18.738,1.775Q18.875,1.7 19,1.7Q19.125,1.7 19.263,1.775Q19.4,1.85 19.45,2L20.25,3.75L22,4.55Q22.15,4.6 22.225,4.737Q22.3,4.875 22.3,5Q22.3,5.125 22.225,5.262Q22.15,5.4 22,5.45L20.25,6.25L19.45,8Q19.4,8.15 19.263,8.225Q19.125,8.3 19,8.3ZM19,22.3Q18.875,22.3 18.738,22.225Q18.6,22.15 18.55,22L17.75,20.25L16,19.45Q15.85,19.4 15.775,19.262Q15.7,19.125 15.7,19Q15.7,18.875 15.775,18.738Q15.85,18.6 16,18.55L17.75,17.75L18.55,16Q18.6,15.85 18.738,15.775Q18.875,15.7 19,15.7Q19.125,15.7 19.263,15.775Q19.4,15.85 19.45,16L20.25,17.75L22,18.55Q22.15,18.6 22.225,18.738Q22.3,18.875 22.3,19Q22.3,19.125 22.225,19.262Q22.15,19.4 22,19.45L20.25,20.25L19.45,22Q19.4,22.15 19.263,22.225Q19.125,22.3 19,22.3ZM9,18.575Q8.725,18.575 8.475,18.425Q8.225,18.275 8.1,18L6.5,14.5L3,12.9Q2.725,12.775 2.575,12.525Q2.425,12.275 2.425,12Q2.425,11.725 2.575,11.475Q2.725,11.225 3,11.1L6.5,9.5L8.1,6Q8.225,5.725 8.475,5.575Q8.725,5.425 9,5.425Q9.275,5.425 9.525,5.575Q9.775,5.725 9.9,6L11.5,9.5L15,11.1Q15.275,11.225 15.425,11.475Q15.575,11.725 15.575,12Q15.575,12.275 15.425,12.525Q15.275,12.775 15,12.9L11.5,14.5L9.9,18Q9.775,18.275 9.525,18.425Q9.275,18.575 9,18.575ZM9,15.15 L10,13 12.15,12 10,11 9,8.85 8,11 5.85,12 8,13ZM9,12Z" />
</vector>

View File

@@ -0,0 +1,5 @@
<?xml version='1.0' encoding='utf-8'?>
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="960" android:viewportHeight="960">
<!-- material icon imported by shared/tools/import_platform_icons.py. -->
<path android:fillColor="#FF000000" android:pathData="M360,640L600,640Q617,640 628.5,628.5Q640,617 640,600L640,360Q640,343 628.5,331.5Q617,320 600,320L360,320Q343,320 331.5,331.5Q320,343 320,360L320,600Q320,617 331.5,628.5Q343,640 360,640ZM480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880ZM480,800Q614,800 707,707Q800,614 800,480Q800,346 707,253Q614,160 480,160Q346,160 253,253Q160,346 160,480Q160,614 253,707Q346,800 480,800ZM480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Z" />
</vector>

View File

@@ -22,6 +22,7 @@
<string name="about_privacy_title">Datenschutz &amp; Sicherheit</string> <string name="about_privacy_title">Datenschutz &amp; Sicherheit</string>
<string name="about_tagline">Senden Sie Dateien direkt. Behalten Sie die Kontrolle darüber, wer sie empfängt.</string> <string name="about_tagline">Senden Sie Dateien direkt. Behalten Sie die Kontrolle darüber, wer sie empfängt.</string>
<string name="about_title">Über</string> <string name="about_title">Über</string>
<string name="app_starting">Wird gestartet…</string>
<string name="appearance_auto_description">Der hellen oder dunklen Darstellung dieses Geräts folgen.</string> <string name="appearance_auto_description">Der hellen oder dunklen Darstellung dieses Geräts folgen.</string>
<string name="appearance_dark_mode">Dunkelmodus</string> <string name="appearance_dark_mode">Dunkelmodus</string>
<string name="appearance_light_mode">Hellmodus</string> <string name="appearance_light_mode">Hellmodus</string>
@@ -54,6 +55,7 @@
<string name="bug_report_what_label">Was ist passiert?</string> <string name="bug_report_what_label">Was ist passiert?</string>
<string name="button_approve">Genehmigen</string> <string name="button_approve">Genehmigen</string>
<string name="button_back">Zurück</string> <string name="button_back">Zurück</string>
<string name="button_more_actions">Weitere Aktionen</string>
<string name="button_cancel">Abbrechen</string> <string name="button_cancel">Abbrechen</string>
<string name="button_cancel_receive">Abbrechen</string> <string name="button_cancel_receive">Abbrechen</string>
<string name="button_change_files">Dateien ändern</string> <string name="button_change_files">Dateien ändern</string>
@@ -117,10 +119,20 @@
<string name="nav_send">Senden</string> <string name="nav_send">Senden</string>
<string name="nav_settings">Einstellungen</string> <string name="nav_settings">Einstellungen</string>
<string name="network_title">Netzwerk</string> <string name="network_title">Netzwerk</string>
<string name="notifications_description">Werden Sie über neue Empfangsanfragen benachrichtigt, während VniDrop im Hintergrund läuft.</string> <string name="notifications_description">Werden Sie über Übertragungsaktivitäten benachrichtigt, während VniDrop im Hintergrund läuft.</string>
<string name="notifications_enabled_message">Mitteilungen aktiviert.</string> <string name="notifications_enabled_message">Mitteilungen aktiviert.</string>
<string name="notifications_local_title">Mitteilungen erlauben</string> <string name="notifications_local_title">Mitteilungen erlauben</string>
<string name="notifications_permission_denied">Mitteilungen sind für VniDrop deaktiviert. Sie können sie in den Einstellungen aktivieren.</string> <string name="notifications_permission_denied">Mitteilungen sind für VniDrop deaktiviert. Sie können sie in den Einstellungen aktivieren.</string>
<string name="notifications_receive_completed_body">„%1$s“ wurde vollständig heruntergeladen.</string>
<string name="notifications_receive_completed_title">Download abgeschlossen</string>
<string name="notifications_receive_failed_body">„%1$s“ konnte nicht empfangen werden.</string>
<string name="notifications_receive_failed_title">Download fehlgeschlagen</string>
<string name="notifications_receiver_completed_body">%1$s hat „%2$s“ vollständig empfangen.</string>
<string name="notifications_receiver_completed_title">Übertragung empfangen</string>
<string name="notifications_receiver_failed_body">%1$s konnte „%2$s“ nicht empfangen</string>
<string name="notifications_receiver_failed_title">Übertragung fehlgeschlagen</string>
<string name="notifications_send_failed_body">„%1$s“ konnte nicht geteilt werden.</string>
<string name="notifications_send_failed_title">Freigabe fehlgeschlagen</string>
<string name="notifications_settings_open_failed">Die Mitteilungseinstellungen konnten nicht geöffnet werden.</string> <string name="notifications_settings_open_failed">Die Mitteilungseinstellungen konnten nicht geöffnet werden.</string>
<string name="notifications_title">Mitteilungen</string> <string name="notifications_title">Mitteilungen</string>
<string name="notifications_unsupported">Mitteilungen sind auf diesem Gerät nicht verfügbar.</string> <string name="notifications_unsupported">Mitteilungen sind auf diesem Gerät nicht verfügbar.</string>
@@ -175,7 +187,7 @@
<string name="relay_apply_active_transfers">Beenden Sie alle aktiven Übertragungen und Freigaben, bevor Sie die Netzwerkeinstellungen anwenden.</string> <string name="relay_apply_active_transfers">Beenden Sie alle aktiven Übertragungen und Freigaben, bevor Sie die Netzwerkeinstellungen anwenden.</string>
<string name="relay_apply_failed">Diese Einstellungen konnten nicht angewendet werden. Die vorherigen Netzwerkeinstellungen wurden wiederhergestellt.</string> <string name="relay_apply_failed">Diese Einstellungen konnten nicht angewendet werden. Die vorherigen Netzwerkeinstellungen wurden wiederhergestellt.</string>
<string name="relay_apply_restart_description">Beim Anwenden wird die Netzwerkverbindung von VniDrop neu gestartet. Beenden Sie zuerst aktive Übertragungen und Freigaben. Vorhandene Einladungen müssen eventuell erneut geteilt werden.</string> <string name="relay_apply_restart_description">Beim Anwenden wird die Netzwerkverbindung von VniDrop neu gestartet. Beenden Sie zuerst aktive Übertragungen und Freigaben. Vorhandene Einladungen müssen eventuell erneut geteilt werden.</string>
<string name="relay_custom_urls_help">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.</string> <string name="relay_custom_urls_help">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.</string>
<string name="relay_custom_urls_label">Relay-URLs</string> <string name="relay_custom_urls_label">Relay-URLs</string>
<string name="relay_mode_automatic">Automatisch (empfohlen)</string> <string name="relay_mode_automatic">Automatisch (empfohlen)</string>
<string name="relay_mode_automatic_description">Verwendet die öffentliche Standard-Relay-Infrastruktur von VniDrop, wenn keine direkte Verbindung möglich ist.</string> <string name="relay_mode_automatic_description">Verwendet die öffentliche Standard-Relay-Infrastruktur von VniDrop, wenn keine direkte Verbindung möglich ist.</string>
@@ -231,20 +243,29 @@
<string name="status_receiving">Wird empfangen</string> <string name="status_receiving">Wird empfangen</string>
<string name="status_stopped">Beendet</string> <string name="status_stopped">Beendet</string>
<string name="storage_calculating">Wird berechnet…</string> <string name="storage_calculating">Wird berechnet…</string>
<string name="storage_cleaning">Wird bereinigt…</string>
<string name="storage_cleanup_busy">Beende aktive Übertragungen, bevor du Speicher freigibst</string>
<string name="storage_cleanup_freed">%1$s freigegeben</string>
<string name="storage_clear_transfer_cache">Übertragungscache leeren</string> <string name="storage_clear_transfer_cache">Übertragungscache leeren</string>
<string name="storage_clear_transfer_cache_description">Entfernt zwischengespeicherte Übertragungsinhalte, die nicht von einem laufenden Empfang oder einer aktiven Freigabe verwendet werden. Empfangene Dateien und der Übertragungsverlauf werden nicht gelöscht.</string> <string name="storage_clear_transfer_cache_description">Entfernt zwischengespeicherte Übertragungsinhalte, die nicht von einem laufenden Empfang oder einer aktiven Freigabe verwendet werden. Empfangene Dateien und der Übertragungsverlauf werden nicht gelöscht.</string>
<string name="storage_clearing_transfer_cache">Cache wird geleert…</string> <string name="storage_clearing_transfer_cache">Cache wird geleert…</string>
<string name="storage_delete_transfers_caption">Löscht deinen Sende- und Empfangsverlauf sowie die zwischengespeicherten Freigabeinhalte der App. Empfangene Dateien auf dem Datenträger bleiben erhalten.</string>
<string name="storage_free_up_space_caption">Entfernt temporäre Dateien und Reste früherer Übertragungen. Deine Übertragungen und empfangenen Dateien bleiben erhalten.</string>
<string name="storage_refresh">Aktualisieren</string>
<string name="storage_transfer_cache_cleared">Übertragungscache geleert</string>
<string name="storage_unavailable">Die Speichernutzung ist noch nicht verfügbar</string>
<string name="storage_usage_header">Auf diesem Gerät</string>
<string name="storage_free_up_space">Speicher freigeben</string>
<string name="storage_delete_transfers">Alle Übertragungen löschen</string> <string name="storage_delete_transfers">Alle Übertragungen löschen</string>
<string name="storage_delete_transfers_description">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.</string> <string name="storage_delete_transfers_description">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.</string>
<string name="storage_app_data">App-Daten</string> <string name="storage_app_data">App-Daten</string>
<string name="storage_deleting">Wird gelöscht…</string> <string name="storage_deleting">Wird gelöscht…</string>
<string name="storage_footer">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.</string> <string name="storage_footer">Ü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.</string>
<string name="storage_received_files">Empfangene Dateien</string> <string name="storage_received_files">Empfangene Dateien</string>
<string name="storage_temporary">Temporäre Dateien</string> <string name="storage_temporary">Temporäre Dateien</string>
<string name="storage_title">Speicher</string> <string name="storage_title">Speicher</string>
<string name="storage_total">Gesamt</string> <string name="storage_total">Gesamt</string>
<string name="storage_transfer_data">Übertragungscache</string> <string name="storage_transfer_data">Übertragungsdaten</string>
<string name="storage_transfer_cache_cleared">Übertragungscache geleert</string>
<string name="storage_transfers_deleted">Alle Übertragungen gelöscht</string> <string name="storage_transfers_deleted">Alle Übertragungen gelöscht</string>
<string name="transfer_activity_description">Wichtige Aktualisierungen zu dieser Übertragung ansehen</string> <string name="transfer_activity_description">Wichtige Aktualisierungen zu dieser Übertragung ansehen</string>
<string name="transfer_activity_title">Aktivität</string> <string name="transfer_activity_title">Aktivität</string>
@@ -274,8 +295,8 @@
<string name="transfer_no_receivers">Noch niemand hat diese Übertragung angefragt.</string> <string name="transfer_no_receivers">Noch niemand hat diese Übertragung angefragt.</string>
<string name="transfer_receiver_accepted">Genehmigt wartet auf Abschluss</string> <string name="transfer_receiver_accepted">Genehmigt wartet auf Abschluss</string>
<string name="transfer_receiver_completed">Erfolgreich empfangen</string> <string name="transfer_receiver_completed">Erfolgreich empfangen</string>
<string name="transfer_receiver_failed">Übertragung fehlgeschlagen</string>
<string name="transfer_receiver_expired">Anfrage abgelaufen</string> <string name="transfer_receiver_expired">Anfrage abgelaufen</string>
<string name="transfer_receiver_failed">Übertragung fehlgeschlagen</string>
<string name="transfer_receiver_refused">Anfrage abgelehnt</string> <string name="transfer_receiver_refused">Anfrage abgelehnt</string>
<string name="transfer_receiver_requested">Wartet auf Ihre Genehmigung</string> <string name="transfer_receiver_requested">Wartet auf Ihre Genehmigung</string>
<string name="transfer_receiver_unknown">Status nicht verfügbar</string> <string name="transfer_receiver_unknown">Status nicht verfügbar</string>

View File

@@ -22,6 +22,7 @@
<string name="about_privacy_title">Privacidad y seguridad</string> <string name="about_privacy_title">Privacidad y seguridad</string>
<string name="about_tagline">Envíe archivos directamente. Mantenga el control de quién los recibe.</string> <string name="about_tagline">Envíe archivos directamente. Mantenga el control de quién los recibe.</string>
<string name="about_title">Acerca de</string> <string name="about_title">Acerca de</string>
<string name="app_starting">Iniciando…</string>
<string name="appearance_auto_description">Seguir la apariencia clara u oscura de este dispositivo.</string> <string name="appearance_auto_description">Seguir la apariencia clara u oscura de este dispositivo.</string>
<string name="appearance_dark_mode">Modo oscuro</string> <string name="appearance_dark_mode">Modo oscuro</string>
<string name="appearance_light_mode">Modo claro</string> <string name="appearance_light_mode">Modo claro</string>
@@ -54,6 +55,7 @@
<string name="bug_report_what_label">¿Qué ocurrió?</string> <string name="bug_report_what_label">¿Qué ocurrió?</string>
<string name="button_approve">Aprobar</string> <string name="button_approve">Aprobar</string>
<string name="button_back">Atrás</string> <string name="button_back">Atrás</string>
<string name="button_more_actions">Más acciones</string>
<string name="button_cancel">Cancelar</string> <string name="button_cancel">Cancelar</string>
<string name="button_cancel_receive">Cancelar</string> <string name="button_cancel_receive">Cancelar</string>
<string name="button_change_files">Cambiar archivos</string> <string name="button_change_files">Cambiar archivos</string>
@@ -117,10 +119,20 @@
<string name="nav_send">Enviar</string> <string name="nav_send">Enviar</string>
<string name="nav_settings">Ajustes</string> <string name="nav_settings">Ajustes</string>
<string name="network_title">Red</string> <string name="network_title">Red</string>
<string name="notifications_description">Reciba avisos sobre nuevas solicitudes de recepción cuando VniDrop está en segundo plano.</string> <string name="notifications_description">Reciba avisos sobre la actividad de las transferencias cuando VniDrop está en segundo plano.</string>
<string name="notifications_enabled_message">Notificaciones activadas.</string> <string name="notifications_enabled_message">Notificaciones activadas.</string>
<string name="notifications_local_title">Permitir notificaciones</string> <string name="notifications_local_title">Permitir notificaciones</string>
<string name="notifications_permission_denied">Las notificaciones están desactivadas para VniDrop. Puede activarlas en Ajustes.</string> <string name="notifications_permission_denied">Las notificaciones están desactivadas para VniDrop. Puede activarlas en Ajustes.</string>
<string name="notifications_receive_completed_body">«%1$s» terminó de descargarse.</string>
<string name="notifications_receive_completed_title">Descarga completada</string>
<string name="notifications_receive_failed_body">No se pudo recibir «%1$s».</string>
<string name="notifications_receive_failed_title">Error en la descarga</string>
<string name="notifications_receiver_completed_body">%1$s terminó de recibir «%2$s».</string>
<string name="notifications_receiver_completed_title">Transferencia recibida</string>
<string name="notifications_receiver_failed_body">%1$s no pudo recibir «%2$s»</string>
<string name="notifications_receiver_failed_title">Error en la entrega</string>
<string name="notifications_send_failed_body">No se pudo compartir «%1$s».</string>
<string name="notifications_send_failed_title">Error al compartir</string>
<string name="notifications_settings_open_failed">No se pudieron abrir los ajustes de notificaciones.</string> <string name="notifications_settings_open_failed">No se pudieron abrir los ajustes de notificaciones.</string>
<string name="notifications_title">Notificaciones</string> <string name="notifications_title">Notificaciones</string>
<string name="notifications_unsupported">Las notificaciones no están disponibles en este dispositivo.</string> <string name="notifications_unsupported">Las notificaciones no están disponibles en este dispositivo.</string>
@@ -175,7 +187,7 @@
<string name="relay_apply_active_transfers">Detenga todas las transferencias y elementos compartidos activos antes de aplicar los ajustes de red.</string> <string name="relay_apply_active_transfers">Detenga todas las transferencias y elementos compartidos activos antes de aplicar los ajustes de red.</string>
<string name="relay_apply_failed">No se han podido aplicar estos ajustes. Se han restaurado los ajustes de red anteriores.</string> <string name="relay_apply_failed">No se han podido aplicar estos ajustes. Se han restaurado los ajustes de red anteriores.</string>
<string name="relay_apply_restart_description">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.</string> <string name="relay_apply_restart_description">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.</string>
<string name="relay_custom_urls_help">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.</string> <string name="relay_custom_urls_help">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.</string>
<string name="relay_custom_urls_label">URL de relés</string> <string name="relay_custom_urls_label">URL de relés</string>
<string name="relay_mode_automatic">Automático (recomendado)</string> <string name="relay_mode_automatic">Automático (recomendado)</string>
<string name="relay_mode_automatic_description">Usa la infraestructura pública de relés predeterminada de VniDrop cuando no haya una conexión directa disponible.</string> <string name="relay_mode_automatic_description">Usa la infraestructura pública de relés predeterminada de VniDrop cuando no haya una conexión directa disponible.</string>
@@ -231,20 +243,29 @@
<string name="status_receiving">Recibiendo</string> <string name="status_receiving">Recibiendo</string>
<string name="status_stopped">Detenido</string> <string name="status_stopped">Detenido</string>
<string name="storage_calculating">Calculando…</string> <string name="storage_calculating">Calculando…</string>
<string name="storage_cleaning">Limpiando…</string>
<string name="storage_cleanup_busy">Finaliza las transferencias activas antes de liberar espacio</string>
<string name="storage_cleanup_freed">Se liberó %1$s</string>
<string name="storage_clear_transfer_cache">Borrar caché de transferencias</string> <string name="storage_clear_transfer_cache">Borrar caché de transferencias</string>
<string name="storage_clear_transfer_cache_description">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.</string> <string name="storage_clear_transfer_cache_description">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.</string>
<string name="storage_clearing_transfer_cache">Borrando caché…</string> <string name="storage_clearing_transfer_cache">Borrando caché…</string>
<string name="storage_delete_transfers_caption">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.</string>
<string name="storage_free_up_space_caption">Elimina los archivos temporales y los restos de transferencias anteriores. Tus transferencias y archivos recibidos se conservan.</string>
<string name="storage_refresh">Actualizar</string>
<string name="storage_transfer_cache_cleared">Caché de transferencias borrada</string>
<string name="storage_unavailable">El uso de almacenamiento aún no está disponible</string>
<string name="storage_usage_header">En este dispositivo</string>
<string name="storage_free_up_space">Liberar espacio</string>
<string name="storage_delete_transfers">Eliminar todas las transferencias</string> <string name="storage_delete_transfers">Eliminar todas las transferencias</string>
<string name="storage_delete_transfers_description">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.</string> <string name="storage_delete_transfers_description">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.</string>
<string name="storage_app_data">Datos de la aplicación</string> <string name="storage_app_data">Datos de la aplicación</string>
<string name="storage_deleting">Eliminando…</string> <string name="storage_deleting">Eliminando…</string>
<string name="storage_footer">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í.</string> <string name="storage_footer">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í.</string>
<string name="storage_received_files">Archivos recibidos</string> <string name="storage_received_files">Archivos recibidos</string>
<string name="storage_temporary">Archivos temporales</string> <string name="storage_temporary">Archivos temporales</string>
<string name="storage_title">Almacenamiento</string> <string name="storage_title">Almacenamiento</string>
<string name="storage_total">Total</string> <string name="storage_total">Total</string>
<string name="storage_transfer_data">Caché de transferencias</string> <string name="storage_transfer_data">Datos de transferencia</string>
<string name="storage_transfer_cache_cleared">Caché de transferencias borrada</string>
<string name="storage_transfers_deleted">Todas las transferencias eliminadas</string> <string name="storage_transfers_deleted">Todas las transferencias eliminadas</string>
<string name="transfer_activity_description">Vea las actualizaciones importantes de esta transferencia</string> <string name="transfer_activity_description">Vea las actualizaciones importantes de esta transferencia</string>
<string name="transfer_activity_title">Actividad</string> <string name="transfer_activity_title">Actividad</string>
@@ -274,8 +295,8 @@
<string name="transfer_no_receivers">Nadie ha solicitado aún esta transferencia.</string> <string name="transfer_no_receivers">Nadie ha solicitado aún esta transferencia.</string>
<string name="transfer_receiver_accepted">Aprobado: esperando a que se complete</string> <string name="transfer_receiver_accepted">Aprobado: esperando a que se complete</string>
<string name="transfer_receiver_completed">Recibido correctamente</string> <string name="transfer_receiver_completed">Recibido correctamente</string>
<string name="transfer_receiver_failed">Transferencia fallida</string>
<string name="transfer_receiver_expired">Solicitud caducada</string> <string name="transfer_receiver_expired">Solicitud caducada</string>
<string name="transfer_receiver_failed">Error en la entrega</string>
<string name="transfer_receiver_refused">Solicitud rechazada</string> <string name="transfer_receiver_refused">Solicitud rechazada</string>
<string name="transfer_receiver_requested">Esperando su aprobación</string> <string name="transfer_receiver_requested">Esperando su aprobación</string>
<string name="transfer_receiver_unknown">Estado no disponible</string> <string name="transfer_receiver_unknown">Estado no disponible</string>

View File

@@ -22,6 +22,7 @@
<string name="about_privacy_title">Confidentialité et sécurité</string> <string name="about_privacy_title">Confidentialité et sécurité</string>
<string name="about_tagline">Envoyez des fichiers directement. Gardez le contrôle de qui les reçoit.</string> <string name="about_tagline">Envoyez des fichiers directement. Gardez le contrôle de qui les reçoit.</string>
<string name="about_title">À propos</string> <string name="about_title">À propos</string>
<string name="app_starting">Démarrage…</string>
<string name="appearance_auto_description">Suivre lapparence claire ou sombre de cet appareil.</string> <string name="appearance_auto_description">Suivre lapparence claire ou sombre de cet appareil.</string>
<string name="appearance_dark_mode">Mode sombre</string> <string name="appearance_dark_mode">Mode sombre</string>
<string name="appearance_light_mode">Mode clair</string> <string name="appearance_light_mode">Mode clair</string>
@@ -54,6 +55,7 @@
<string name="bug_report_what_label">Que sest-il passé ?</string> <string name="bug_report_what_label">Que sest-il passé ?</string>
<string name="button_approve">Approuver</string> <string name="button_approve">Approuver</string>
<string name="button_back">Retour</string> <string name="button_back">Retour</string>
<string name="button_more_actions">Plus dactions</string>
<string name="button_cancel">Annuler</string> <string name="button_cancel">Annuler</string>
<string name="button_cancel_receive">Annuler</string> <string name="button_cancel_receive">Annuler</string>
<string name="button_change_files">Modifier les fichiers</string> <string name="button_change_files">Modifier les fichiers</string>
@@ -117,10 +119,20 @@
<string name="nav_send">Envoyer</string> <string name="nav_send">Envoyer</string>
<string name="nav_settings">Réglages</string> <string name="nav_settings">Réglages</string>
<string name="network_title">Réseau</string> <string name="network_title">Réseau</string>
<string name="notifications_description">Soyez averti des nouvelles demandes de réception lorsque VniDrop est en arrière-plan.</string> <string name="notifications_description">Soyez averti de lactivité des transferts lorsque VniDrop est en arrière-plan.</string>
<string name="notifications_enabled_message">Notifications activées.</string> <string name="notifications_enabled_message">Notifications activées.</string>
<string name="notifications_local_title">Autoriser les notifications</string> <string name="notifications_local_title">Autoriser les notifications</string>
<string name="notifications_permission_denied">Les notifications sont désactivées pour VniDrop. Vous pouvez les activer dans les Réglages.</string> <string name="notifications_permission_denied">Les notifications sont désactivées pour VniDrop. Vous pouvez les activer dans les Réglages.</string>
<string name="notifications_receive_completed_body">« %1$s » a fini de se télécharger.</string>
<string name="notifications_receive_completed_title">Téléchargement terminé</string>
<string name="notifications_receive_failed_body">« %1$s » na pas pu être reçu.</string>
<string name="notifications_receive_failed_title">Échec du téléchargement</string>
<string name="notifications_receiver_completed_body">%1$s a fini de recevoir « %2$s ».</string>
<string name="notifications_receiver_completed_title">Transfert reçu</string>
<string name="notifications_receiver_failed_body">%1$s n\'a pas pu recevoir « %2$s »</string>
<string name="notifications_receiver_failed_title">Échec de l\'envoi</string>
<string name="notifications_send_failed_body">« %1$s » na pas pu être partagé.</string>
<string name="notifications_send_failed_title">Échec du partage</string>
<string name="notifications_settings_open_failed">Impossible douvrir les réglages de notifications.</string> <string name="notifications_settings_open_failed">Impossible douvrir les réglages de notifications.</string>
<string name="notifications_title">Notifications</string> <string name="notifications_title">Notifications</string>
<string name="notifications_unsupported">Les notifications ne sont pas disponibles sur cet appareil.</string> <string name="notifications_unsupported">Les notifications ne sont pas disponibles sur cet appareil.</string>
@@ -175,7 +187,7 @@
<string name="relay_apply_active_transfers">Arrêtez tous les transferts et partages actifs avant dappliquer les réglages réseau.</string> <string name="relay_apply_active_transfers">Arrêtez tous les transferts et partages actifs avant dappliquer les réglages réseau.</string>
<string name="relay_apply_failed">Impossible dappliquer ces réglages. Les réglages réseau précédents ont été restaurés.</string> <string name="relay_apply_failed">Impossible dappliquer ces réglages. Les réglages réseau précédents ont été restaurés.</string>
<string name="relay_apply_restart_description">Lapplication de ces réglages redémarre la connexion réseau de VniDrop. Arrêtez dabord les transferts et partages actifs. Il peut être nécessaire de partager à nouveau les invitations existantes.</string> <string name="relay_apply_restart_description">Lapplication de ces réglages redémarre la connexion réseau de VniDrop. Arrêtez dabord les transferts et partages actifs. Il peut être nécessaire de partager à nouveau les invitations existantes.</string>
<string name="relay_custom_urls_help">Ajoutez chaque URL de relais HTTPS 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.</string> <string name="relay_custom_urls_help">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.</string>
<string name="relay_custom_urls_label">URL des relais</string> <string name="relay_custom_urls_label">URL des relais</string>
<string name="relay_mode_automatic">Automatique (recommandé)</string> <string name="relay_mode_automatic">Automatique (recommandé)</string>
<string name="relay_mode_automatic_description">Utiliser linfrastructure de relais publique par défaut de VniDrop lorsquune connexion directe est indisponible.</string> <string name="relay_mode_automatic_description">Utiliser linfrastructure de relais publique par défaut de VniDrop lorsquune connexion directe est indisponible.</string>
@@ -231,20 +243,29 @@
<string name="status_receiving">Réception</string> <string name="status_receiving">Réception</string>
<string name="status_stopped">Arrêté</string> <string name="status_stopped">Arrêté</string>
<string name="storage_calculating">Calcul…</string> <string name="storage_calculating">Calcul…</string>
<string name="storage_cleaning">Nettoyage…</string>
<string name="storage_cleanup_busy">Terminez les transferts en cours avant de libérer de l\'espace</string>
<string name="storage_cleanup_freed">%1$s libéré</string>
<string name="storage_clear_transfer_cache">Vider le cache des transferts</string> <string name="storage_clear_transfer_cache">Vider le cache des transferts</string>
<string name="storage_clear_transfer_cache_description">Supprime le contenu de transfert en cache qui nest pas utilisé par une réception en cours ou un partage actif. Les fichiers reçus et lhistorique ne sont pas supprimés.</string> <string name="storage_clear_transfer_cache_description">Supprime le contenu de transfert en cache qui nest pas utilisé par une réception en cours ou un partage actif. Les fichiers reçus et lhistorique ne sont pas supprimés.</string>
<string name="storage_clearing_transfer_cache">Vidage du cache…</string> <string name="storage_clearing_transfer_cache">Vidage du cache…</string>
<string name="storage_delete_transfers_caption">Efface votre historique denvois et de réceptions ainsi que le contenu de partage mis en cache par lapp. Les fichiers reçus sur le disque sont conservés.</string>
<string name="storage_free_up_space_caption">Supprime les fichiers temporaires et les résidus des transferts précédents. Vos transferts et fichiers reçus sont conservés.</string>
<string name="storage_refresh">Actualiser</string>
<string name="storage_transfer_cache_cleared">Cache des transferts vidé</string>
<string name="storage_unavailable">L\'utilisation du stockage n\'est pas encore disponible</string>
<string name="storage_usage_header">Sur cet appareil</string>
<string name="storage_free_up_space">Libérer de l\'espace</string>
<string name="storage_delete_transfers">Supprimer tous les transferts</string> <string name="storage_delete_transfers">Supprimer tous les transferts</string>
<string name="storage_delete_transfers_description">Cela efface tous les transferts envoyés et reçus de lhistorique 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.</string> <string name="storage_delete_transfers_description">Cela efface tous les transferts envoyés et reçus de lhistorique 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.</string>
<string name="storage_app_data">Données de lapp</string> <string name="storage_app_data">Données de lapp</string>
<string name="storage_deleting">Suppression…</string> <string name="storage_deleting">Suppression…</string>
<string name="storage_footer">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.</string> <string name="storage_footer">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.</string>
<string name="storage_received_files">Fichiers reçus</string> <string name="storage_received_files">Fichiers reçus</string>
<string name="storage_temporary">Fichiers temporaires</string> <string name="storage_temporary">Fichiers temporaires</string>
<string name="storage_title">Stockage</string> <string name="storage_title">Stockage</string>
<string name="storage_total">Total</string> <string name="storage_total">Total</string>
<string name="storage_transfer_data">Cache des transferts</string> <string name="storage_transfer_data">Données de transfert</string>
<string name="storage_transfer_cache_cleared">Cache des transferts vidé</string>
<string name="storage_transfers_deleted">Tous les transferts supprimés</string> <string name="storage_transfers_deleted">Tous les transferts supprimés</string>
<string name="transfer_activity_description">Consultez les mises à jour importantes de ce transfert</string> <string name="transfer_activity_description">Consultez les mises à jour importantes de ce transfert</string>
<string name="transfer_activity_title">Activité</string> <string name="transfer_activity_title">Activité</string>
@@ -274,8 +295,8 @@
<string name="transfer_no_receivers">Personne na encore demandé ce transfert.</string> <string name="transfer_no_receivers">Personne na encore demandé ce transfert.</string>
<string name="transfer_receiver_accepted">Approuvé — en attente de la fin</string> <string name="transfer_receiver_accepted">Approuvé — en attente de la fin</string>
<string name="transfer_receiver_completed">Reçu avec succès</string> <string name="transfer_receiver_completed">Reçu avec succès</string>
<string name="transfer_receiver_failed">Transfert échoué</string>
<string name="transfer_receiver_expired">Demande expirée</string> <string name="transfer_receiver_expired">Demande expirée</string>
<string name="transfer_receiver_failed">Échec de l\'envoi</string>
<string name="transfer_receiver_refused">Demande refusée</string> <string name="transfer_receiver_refused">Demande refusée</string>
<string name="transfer_receiver_requested">En attente de votre approbation</string> <string name="transfer_receiver_requested">En attente de votre approbation</string>
<string name="transfer_receiver_unknown">Statut indisponible</string> <string name="transfer_receiver_unknown">Statut indisponible</string>

View File

@@ -22,6 +22,7 @@
<string name="about_privacy_title">Privacy e sicurezza</string> <string name="about_privacy_title">Privacy e sicurezza</string>
<string name="about_tagline">Invii file direttamente. Mantenga il controllo su chi li riceve.</string> <string name="about_tagline">Invii file direttamente. Mantenga il controllo su chi li riceve.</string>
<string name="about_title">Informazioni</string> <string name="about_title">Informazioni</string>
<string name="app_starting">Avvio…</string>
<string name="appearance_auto_description">Segue laspetto chiaro o scuro di questo dispositivo.</string> <string name="appearance_auto_description">Segue laspetto chiaro o scuro di questo dispositivo.</string>
<string name="appearance_dark_mode">Modalità scura</string> <string name="appearance_dark_mode">Modalità scura</string>
<string name="appearance_light_mode">Modalità chiara</string> <string name="appearance_light_mode">Modalità chiara</string>
@@ -54,6 +55,7 @@
<string name="bug_report_what_label">Cosa è accaduto?</string> <string name="bug_report_what_label">Cosa è accaduto?</string>
<string name="button_approve">Approva</string> <string name="button_approve">Approva</string>
<string name="button_back">Indietro</string> <string name="button_back">Indietro</string>
<string name="button_more_actions">Altre azioni</string>
<string name="button_cancel">Annulla</string> <string name="button_cancel">Annulla</string>
<string name="button_cancel_receive">Annulla</string> <string name="button_cancel_receive">Annulla</string>
<string name="button_change_files">Cambia file</string> <string name="button_change_files">Cambia file</string>
@@ -117,10 +119,20 @@
<string name="nav_send">Invia</string> <string name="nav_send">Invia</string>
<string name="nav_settings">Impostazioni</string> <string name="nav_settings">Impostazioni</string>
<string name="network_title">Rete</string> <string name="network_title">Rete</string>
<string name="notifications_description">Ricevi avvisi sulle nuove richieste di ricezione quando VniDrop è in background.</string> <string name="notifications_description">Ricevi avvisi sullattività dei trasferimenti quando VniDrop è in background.</string>
<string name="notifications_enabled_message">Notifiche attivate.</string> <string name="notifications_enabled_message">Notifiche attivate.</string>
<string name="notifications_local_title">Consenti le notifiche</string> <string name="notifications_local_title">Consenti le notifiche</string>
<string name="notifications_permission_denied">Le notifiche sono disattivate per VniDrop. Può attivarle in Impostazioni.</string> <string name="notifications_permission_denied">Le notifiche sono disattivate per VniDrop. Può attivarle in Impostazioni.</string>
<string name="notifications_receive_completed_body">«%1$s» è stato scaricato.</string>
<string name="notifications_receive_completed_title">Download completato</string>
<string name="notifications_receive_failed_body">Impossibile ricevere «%1$s».</string>
<string name="notifications_receive_failed_title">Download non riuscito</string>
<string name="notifications_receiver_completed_body">%1$s ha finito di ricevere «%2$s».</string>
<string name="notifications_receiver_completed_title">Trasferimento ricevuto</string>
<string name="notifications_receiver_failed_body">%1$s non ha potuto ricevere “%2$s”</string>
<string name="notifications_receiver_failed_title">Consegna non riuscita</string>
<string name="notifications_send_failed_body">Impossibile condividere «%1$s».</string>
<string name="notifications_send_failed_title">Condivisione non riuscita</string>
<string name="notifications_settings_open_failed">Impossibile aprire le impostazioni delle notifiche.</string> <string name="notifications_settings_open_failed">Impossibile aprire le impostazioni delle notifiche.</string>
<string name="notifications_title">Notifiche</string> <string name="notifications_title">Notifiche</string>
<string name="notifications_unsupported">Le notifiche non sono disponibili su questo dispositivo.</string> <string name="notifications_unsupported">Le notifiche non sono disponibili su questo dispositivo.</string>
@@ -175,7 +187,7 @@
<string name="relay_apply_active_transfers">Interrompa tutti i trasferimenti e le condivisioni attivi prima di applicare le impostazioni di rete.</string> <string name="relay_apply_active_transfers">Interrompa tutti i trasferimenti e le condivisioni attivi prima di applicare le impostazioni di rete.</string>
<string name="relay_apply_failed">Impossibile applicare queste impostazioni. Sono state ripristinate le impostazioni di rete precedenti.</string> <string name="relay_apply_failed">Impossibile applicare queste impostazioni. Sono state ripristinate le impostazioni di rete precedenti.</string>
<string name="relay_apply_restart_description">Lapplicazione riavvia la connessione di rete di VniDrop. Interrompa prima i trasferimenti e le condivisioni attivi. Potrebbe essere necessario condividere di nuovo gli inviti esistenti.</string> <string name="relay_apply_restart_description">Lapplicazione riavvia la connessione di rete di VniDrop. Interrompa prima i trasferimenti e le condivisioni attivi. Potrebbe essere necessario condividere di nuovo gli inviti esistenti.</string>
<string name="relay_custom_urls_help">Aggiungi separatamente ogni URL relay HTTPS. Le credenziali negli URL non sono supportate. Il certificato TLS deve essere emesso da unautorità di certificazione pubblicamente attendibile.</string> <string name="relay_custom_urls_help">Inserisca un URL relay HTTPS per riga. Le credenziali negli URL non sono supportate. Il certificato TLS deve essere emesso da unautorità di certificazione pubblicamente attendibile.</string>
<string name="relay_custom_urls_label">URL relay</string> <string name="relay_custom_urls_label">URL relay</string>
<string name="relay_mode_automatic">Automatica (consigliata)</string> <string name="relay_mode_automatic">Automatica (consigliata)</string>
<string name="relay_mode_automatic_description">Usa linfrastruttura relay pubblica predefinita di VniDrop quando non è disponibile una connessione diretta.</string> <string name="relay_mode_automatic_description">Usa linfrastruttura relay pubblica predefinita di VniDrop quando non è disponibile una connessione diretta.</string>
@@ -231,20 +243,29 @@
<string name="status_receiving">Ricezione</string> <string name="status_receiving">Ricezione</string>
<string name="status_stopped">Interrotto</string> <string name="status_stopped">Interrotto</string>
<string name="storage_calculating">Calcolo…</string> <string name="storage_calculating">Calcolo…</string>
<string name="storage_cleaning">Pulizia…</string>
<string name="storage_cleanup_busy">Completa i trasferimenti attivi prima di liberare spazio</string>
<string name="storage_cleanup_freed">Liberati %1$s</string>
<string name="storage_clear_transfer_cache">Svuota cache trasferimenti</string> <string name="storage_clear_transfer_cache">Svuota cache trasferimenti</string>
<string name="storage_clear_transfer_cache_description">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.</string> <string name="storage_clear_transfer_cache_description">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.</string>
<string name="storage_clearing_transfer_cache">Svuotamento cache…</string> <string name="storage_clearing_transfer_cache">Svuotamento cache…</string>
<string name="storage_delete_transfers_caption">Cancella la cronologia di invii e ricezioni e i contenuti di condivisione memorizzati dallapp. I file ricevuti sul disco vengono mantenuti.</string>
<string name="storage_free_up_space_caption">Rimuove i file temporanei e i residui dei trasferimenti precedenti. I tuoi trasferimenti e i file ricevuti vengono mantenuti.</string>
<string name="storage_refresh">Aggiorna</string>
<string name="storage_transfer_cache_cleared">Cache trasferimenti svuotata</string>
<string name="storage_unavailable">L\'utilizzo dello spazio non è ancora disponibile</string>
<string name="storage_usage_header">Su questo dispositivo</string>
<string name="storage_free_up_space">Libera spazio</string>
<string name="storage_delete_transfers">Elimina tutti i trasferimenti</string> <string name="storage_delete_transfers">Elimina tutti i trasferimenti</string>
<string name="storage_delete_transfers_description">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.</string> <string name="storage_delete_transfers_description">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.</string>
<string name="storage_app_data">Dati dellapp</string> <string name="storage_app_data">Dati dellapp</string>
<string name="storage_deleting">Eliminazione…</string> <string name="storage_deleting">Eliminazione…</string>
<string name="storage_footer">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.</string> <string name="storage_footer">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.</string>
<string name="storage_received_files">File ricevuti</string> <string name="storage_received_files">File ricevuti</string>
<string name="storage_temporary">File temporanei</string> <string name="storage_temporary">File temporanei</string>
<string name="storage_title">Archiviazione</string> <string name="storage_title">Archiviazione</string>
<string name="storage_total">Totale</string> <string name="storage_total">Totale</string>
<string name="storage_transfer_data">Cache trasferimenti</string> <string name="storage_transfer_data">Dati di trasferimento</string>
<string name="storage_transfer_cache_cleared">Cache trasferimenti svuotata</string>
<string name="storage_transfers_deleted">Tutti i trasferimenti eliminati</string> <string name="storage_transfers_deleted">Tutti i trasferimenti eliminati</string>
<string name="transfer_activity_description">Veda gli aggiornamenti importanti di questo trasferimento</string> <string name="transfer_activity_description">Veda gli aggiornamenti importanti di questo trasferimento</string>
<string name="transfer_activity_title">Attività</string> <string name="transfer_activity_title">Attività</string>
@@ -274,8 +295,8 @@
<string name="transfer_no_receivers">Nessuno ha ancora richiesto questo trasferimento.</string> <string name="transfer_no_receivers">Nessuno ha ancora richiesto questo trasferimento.</string>
<string name="transfer_receiver_accepted">Approvato: in attesa del completamento</string> <string name="transfer_receiver_accepted">Approvato: in attesa del completamento</string>
<string name="transfer_receiver_completed">Ricevuto correttamente</string> <string name="transfer_receiver_completed">Ricevuto correttamente</string>
<string name="transfer_receiver_failed">Trasferimento non riuscito</string>
<string name="transfer_receiver_expired">Richiesta scaduta</string> <string name="transfer_receiver_expired">Richiesta scaduta</string>
<string name="transfer_receiver_failed">Consegna non riuscita</string>
<string name="transfer_receiver_refused">Richiesta rifiutata</string> <string name="transfer_receiver_refused">Richiesta rifiutata</string>
<string name="transfer_receiver_requested">In attesa della sua approvazione</string> <string name="transfer_receiver_requested">In attesa della sua approvazione</string>
<string name="transfer_receiver_unknown">Stato non disponibile</string> <string name="transfer_receiver_unknown">Stato non disponibile</string>

View File

@@ -22,6 +22,7 @@
<string name="about_privacy_title">Privacy en beveiliging</string> <string name="about_privacy_title">Privacy en beveiliging</string>
<string name="about_tagline">Verstuur bestanden rechtstreeks. Houd controle over wie ze ontvangt.</string> <string name="about_tagline">Verstuur bestanden rechtstreeks. Houd controle over wie ze ontvangt.</string>
<string name="about_title">Over</string> <string name="about_title">Over</string>
<string name="app_starting">Bezig met starten…</string>
<string name="appearance_auto_description">De lichte of donkere weergave van dit apparaat volgen.</string> <string name="appearance_auto_description">De lichte of donkere weergave van dit apparaat volgen.</string>
<string name="appearance_dark_mode">Donkere modus</string> <string name="appearance_dark_mode">Donkere modus</string>
<string name="appearance_light_mode">Lichte modus</string> <string name="appearance_light_mode">Lichte modus</string>
@@ -54,6 +55,7 @@
<string name="bug_report_what_label">Wat is er gebeurd?</string> <string name="bug_report_what_label">Wat is er gebeurd?</string>
<string name="button_approve">Goedkeuren</string> <string name="button_approve">Goedkeuren</string>
<string name="button_back">Terug</string> <string name="button_back">Terug</string>
<string name="button_more_actions">Meer acties</string>
<string name="button_cancel">Annuleren</string> <string name="button_cancel">Annuleren</string>
<string name="button_cancel_receive">Annuleren</string> <string name="button_cancel_receive">Annuleren</string>
<string name="button_change_files">Bestanden wijzigen</string> <string name="button_change_files">Bestanden wijzigen</string>
@@ -117,10 +119,20 @@
<string name="nav_send">Versturen</string> <string name="nav_send">Versturen</string>
<string name="nav_settings">Instellingen</string> <string name="nav_settings">Instellingen</string>
<string name="network_title">Netwerk</string> <string name="network_title">Netwerk</string>
<string name="notifications_description">Ontvang meldingen over nieuwe ontvangstverzoeken terwijl VniDrop op de achtergrond draait.</string> <string name="notifications_description">Ontvang meldingen over overdrachtsactiviteit terwijl VniDrop op de achtergrond draait.</string>
<string name="notifications_enabled_message">Meldingen ingeschakeld.</string> <string name="notifications_enabled_message">Meldingen ingeschakeld.</string>
<string name="notifications_local_title">Meldingen toestaan</string> <string name="notifications_local_title">Meldingen toestaan</string>
<string name="notifications_permission_denied">Meldingen zijn uitgeschakeld voor VniDrop. U kunt ze inschakelen in Instellingen.</string> <string name="notifications_permission_denied">Meldingen zijn uitgeschakeld voor VniDrop. U kunt ze inschakelen in Instellingen.</string>
<string name="notifications_receive_completed_body">%1$s is volledig gedownload.</string>
<string name="notifications_receive_completed_title">Download voltooid</string>
<string name="notifications_receive_failed_body">%1$s kon niet worden ontvangen.</string>
<string name="notifications_receive_failed_title">Download mislukt</string>
<string name="notifications_receiver_completed_body">%1$s heeft %2$s volledig ontvangen.</string>
<string name="notifications_receiver_completed_title">Overdracht ontvangen</string>
<string name="notifications_receiver_failed_body">%1$s kon “%2$s” niet ontvangen</string>
<string name="notifications_receiver_failed_title">Levering mislukt</string>
<string name="notifications_send_failed_body">%1$s kon niet worden gedeeld.</string>
<string name="notifications_send_failed_title">Delen mislukt</string>
<string name="notifications_settings_open_failed">De meldingsinstellingen konden niet worden geopend.</string> <string name="notifications_settings_open_failed">De meldingsinstellingen konden niet worden geopend.</string>
<string name="notifications_title">Meldingen</string> <string name="notifications_title">Meldingen</string>
<string name="notifications_unsupported">Meldingen zijn niet beschikbaar op dit apparaat.</string> <string name="notifications_unsupported">Meldingen zijn niet beschikbaar op dit apparaat.</string>
@@ -175,7 +187,7 @@
<string name="relay_apply_active_transfers">Stop alle actieve overdrachten en gedeelde items voordat u de netwerkinstellingen toepast.</string> <string name="relay_apply_active_transfers">Stop alle actieve overdrachten en gedeelde items voordat u de netwerkinstellingen toepast.</string>
<string name="relay_apply_failed">Deze instellingen konden niet worden toegepast. De vorige netwerkinstellingen zijn hersteld.</string> <string name="relay_apply_failed">Deze instellingen konden niet worden toegepast. De vorige netwerkinstellingen zijn hersteld.</string>
<string name="relay_apply_restart_description">Bij het toepassen wordt de netwerkverbinding van VniDrop opnieuw gestart. Stop eerst actieve overdrachten en gedeelde items. Bestaande uitnodigingen moeten mogelijk opnieuw worden gedeeld.</string> <string name="relay_apply_restart_description">Bij het toepassen wordt de netwerkverbinding van VniDrop opnieuw gestart. Stop eerst actieve overdrachten en gedeelde items. Bestaande uitnodigingen moeten mogelijk opnieuw worden gedeeld.</string>
<string name="relay_custom_urls_help">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.</string> <string name="relay_custom_urls_help">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.</string>
<string name="relay_custom_urls_label">Relay-URL\'s</string> <string name="relay_custom_urls_label">Relay-URL\'s</string>
<string name="relay_mode_automatic">Automatisch (aanbevolen)</string> <string name="relay_mode_automatic">Automatisch (aanbevolen)</string>
<string name="relay_mode_automatic_description">Gebruikt de standaard openbare relay-infrastructuur van VniDrop wanneer geen directe verbinding beschikbaar is.</string> <string name="relay_mode_automatic_description">Gebruikt de standaard openbare relay-infrastructuur van VniDrop wanneer geen directe verbinding beschikbaar is.</string>
@@ -231,20 +243,29 @@
<string name="status_receiving">Ontvangen</string> <string name="status_receiving">Ontvangen</string>
<string name="status_stopped">Gestopt</string> <string name="status_stopped">Gestopt</string>
<string name="storage_calculating">Berekenen…</string> <string name="storage_calculating">Berekenen…</string>
<string name="storage_cleaning">Opschonen…</string>
<string name="storage_cleanup_busy">Voltooi actieve overdrachten voordat je ruimte vrijmaakt</string>
<string name="storage_cleanup_freed">%1$s vrijgemaakt</string>
<string name="storage_clear_transfer_cache">Overdrachtscache wissen</string> <string name="storage_clear_transfer_cache">Overdrachtscache wissen</string>
<string name="storage_clear_transfer_cache_description">Verwijdert overdrachtsinhoud uit de cache die niet wordt gebruikt door een lopende ontvangst of actieve share. Ontvangen bestanden en de overdrachtsgeschiedenis worden niet verwijderd.</string> <string name="storage_clear_transfer_cache_description">Verwijdert overdrachtsinhoud uit de cache die niet wordt gebruikt door een lopende ontvangst of actieve share. Ontvangen bestanden en de overdrachtsgeschiedenis worden niet verwijderd.</string>
<string name="storage_clearing_transfer_cache">Cache wissen…</string> <string name="storage_clearing_transfer_cache">Cache wissen…</string>
<string name="storage_delete_transfers_caption">Wist je verzend- en ontvangstgeschiedenis en de gecachte deelinhoud van de app. Ontvangen bestanden op schijf blijven behouden.</string>
<string name="storage_free_up_space_caption">Verwijdert tijdelijke bestanden en resten van eerdere overdrachten. Je overdrachten en ontvangen bestanden blijven behouden.</string>
<string name="storage_refresh">Vernieuwen</string>
<string name="storage_transfer_cache_cleared">Overdrachtscache gewist</string>
<string name="storage_unavailable">Opslaggebruik is nog niet beschikbaar</string>
<string name="storage_usage_header">Op dit apparaat</string>
<string name="storage_free_up_space">Ruimte vrijmaken</string>
<string name="storage_delete_transfers">Alle overdrachten verwijderen</string> <string name="storage_delete_transfers">Alle overdrachten verwijderen</string>
<string name="storage_delete_transfers_description">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.</string> <string name="storage_delete_transfers_description">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.</string>
<string name="storage_app_data">Appgegevens</string> <string name="storage_app_data">Appgegevens</string>
<string name="storage_deleting">Verwijderen…</string> <string name="storage_deleting">Verwijderen…</string>
<string name="storage_footer">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.</string> <string name="storage_footer">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.</string>
<string name="storage_received_files">Ontvangen bestanden</string> <string name="storage_received_files">Ontvangen bestanden</string>
<string name="storage_temporary">Tijdelijke bestanden</string> <string name="storage_temporary">Tijdelijke bestanden</string>
<string name="storage_title">Opslag</string> <string name="storage_title">Opslag</string>
<string name="storage_total">Totaal</string> <string name="storage_total">Totaal</string>
<string name="storage_transfer_data">Overdrachtscache</string> <string name="storage_transfer_data">Overdrachtsgegevens</string>
<string name="storage_transfer_cache_cleared">Overdrachtscache gewist</string>
<string name="storage_transfers_deleted">Alle overdrachten verwijderd</string> <string name="storage_transfers_deleted">Alle overdrachten verwijderd</string>
<string name="transfer_activity_description">Bekijk belangrijke updates voor deze overdracht</string> <string name="transfer_activity_description">Bekijk belangrijke updates voor deze overdracht</string>
<string name="transfer_activity_title">Activiteit</string> <string name="transfer_activity_title">Activiteit</string>
@@ -274,8 +295,8 @@
<string name="transfer_no_receivers">Nog niemand heeft deze overdracht aangevraagd.</string> <string name="transfer_no_receivers">Nog niemand heeft deze overdracht aangevraagd.</string>
<string name="transfer_receiver_accepted">Goedgekeurd — wachten op voltooiing</string> <string name="transfer_receiver_accepted">Goedgekeurd — wachten op voltooiing</string>
<string name="transfer_receiver_completed">Succesvol ontvangen</string> <string name="transfer_receiver_completed">Succesvol ontvangen</string>
<string name="transfer_receiver_failed">Overdracht mislukt</string>
<string name="transfer_receiver_expired">Verzoek verlopen</string> <string name="transfer_receiver_expired">Verzoek verlopen</string>
<string name="transfer_receiver_failed">Levering mislukt</string>
<string name="transfer_receiver_refused">Verzoek geweigerd</string> <string name="transfer_receiver_refused">Verzoek geweigerd</string>
<string name="transfer_receiver_requested">Wachten op uw goedkeuring</string> <string name="transfer_receiver_requested">Wachten op uw goedkeuring</string>
<string name="transfer_receiver_unknown">Status niet beschikbaar</string> <string name="transfer_receiver_unknown">Status niet beschikbaar</string>

View File

@@ -22,6 +22,7 @@
<string name="about_privacy_title">Prywatność i bezpieczeństwo</string> <string name="about_privacy_title">Prywatność i bezpieczeństwo</string>
<string name="about_tagline">Wysyłaj pliki bezpośrednio. Zachowaj kontrolę nad tym, kto je otrzymuje.</string> <string name="about_tagline">Wysyłaj pliki bezpośrednio. Zachowaj kontrolę nad tym, kto je otrzymuje.</string>
<string name="about_title">Informacje</string> <string name="about_title">Informacje</string>
<string name="app_starting">Uruchamianie…</string>
<string name="appearance_auto_description">Dopasuj do jasnego lub ciemnego wyglądu tego urządzenia.</string> <string name="appearance_auto_description">Dopasuj do jasnego lub ciemnego wyglądu tego urządzenia.</string>
<string name="appearance_dark_mode">Tryb ciemny</string> <string name="appearance_dark_mode">Tryb ciemny</string>
<string name="appearance_light_mode">Tryb jasny</string> <string name="appearance_light_mode">Tryb jasny</string>
@@ -54,6 +55,7 @@
<string name="bug_report_what_label">Co się stało?</string> <string name="bug_report_what_label">Co się stało?</string>
<string name="button_approve">Zatwierdź</string> <string name="button_approve">Zatwierdź</string>
<string name="button_back">Wstecz</string> <string name="button_back">Wstecz</string>
<string name="button_more_actions">Więcej działań</string>
<string name="button_cancel">Anuluj</string> <string name="button_cancel">Anuluj</string>
<string name="button_cancel_receive">Anuluj</string> <string name="button_cancel_receive">Anuluj</string>
<string name="button_change_files">Zmień pliki</string> <string name="button_change_files">Zmień pliki</string>
@@ -117,10 +119,20 @@
<string name="nav_send">Wyślij</string> <string name="nav_send">Wyślij</string>
<string name="nav_settings">Ustawienia</string> <string name="nav_settings">Ustawienia</string>
<string name="network_title">Sieć</string> <string name="network_title">Sieć</string>
<string name="notifications_description">Otrzymuj powiadomienia o nowych prośbach o odbiór, gdy VniDrop działa w tle.</string> <string name="notifications_description">Otrzymuj powiadomienia o aktywności transferów, gdy VniDrop działa w tle.</string>
<string name="notifications_enabled_message">Powiadomienia włączone.</string> <string name="notifications_enabled_message">Powiadomienia włączone.</string>
<string name="notifications_local_title">Zezwól na powiadomienia</string> <string name="notifications_local_title">Zezwól na powiadomienia</string>
<string name="notifications_permission_denied">Powiadomienia są wyłączone dla VniDrop. Możesz je włączyć w Ustawieniach.</string> <string name="notifications_permission_denied">Powiadomienia są wyłączone dla VniDrop. Możesz je włączyć w Ustawieniach.</string>
<string name="notifications_receive_completed_body">Zakończono pobieranie „%1$s”.</string>
<string name="notifications_receive_completed_title">Pobieranie zakończone</string>
<string name="notifications_receive_failed_body">Nie udało się odebrać „%1$s”.</string>
<string name="notifications_receive_failed_title">Pobieranie nie powiodło się</string>
<string name="notifications_receiver_completed_body">%1$s zakończył odbieranie „%2$s”.</string>
<string name="notifications_receiver_completed_title">Transfer odebrany</string>
<string name="notifications_receiver_failed_body">%1$s nie mógł odebrać „%2$s”</string>
<string name="notifications_receiver_failed_title">Dostarczenie nie powiodło się</string>
<string name="notifications_send_failed_body">Nie udało się udostępnić „%1$s”.</string>
<string name="notifications_send_failed_title">Udostępnianie nie powiodło się</string>
<string name="notifications_settings_open_failed">Nie udało się otworzyć ustawień powiadomień.</string> <string name="notifications_settings_open_failed">Nie udało się otworzyć ustawień powiadomień.</string>
<string name="notifications_title">Powiadomienia</string> <string name="notifications_title">Powiadomienia</string>
<string name="notifications_unsupported">Powiadomienia nie są dostępne na tym urządzeniu.</string> <string name="notifications_unsupported">Powiadomienia nie są dostępne na tym urządzeniu.</string>
@@ -175,7 +187,7 @@
<string name="relay_apply_active_transfers">Zatrzymaj wszystkie aktywne transfery i udostępnienia przed zastosowaniem ustawień sieci.</string> <string name="relay_apply_active_transfers">Zatrzymaj wszystkie aktywne transfery i udostępnienia przed zastosowaniem ustawień sieci.</string>
<string name="relay_apply_failed">Nie udało się zastosować tych ustawień. Przywrócono poprzednie ustawienia sieci.</string> <string name="relay_apply_failed">Nie udało się zastosować tych ustawień. Przywrócono poprzednie ustawienia sieci.</string>
<string name="relay_apply_restart_description">Zastosowanie ustawień ponownie uruchamia połączenie sieciowe VniDrop. Najpierw zatrzymaj aktywne transfery i udostępnienia. Istniejące zaproszenia mogą wymagać ponownego udostępnienia.</string> <string name="relay_apply_restart_description">Zastosowanie ustawień ponownie uruchamia połączenie sieciowe VniDrop. Najpierw zatrzymaj aktywne transfery i udostępnienia. Istniejące zaproszenia mogą wymagać ponownego udostępnienia.</string>
<string name="relay_custom_urls_help">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.</string> <string name="relay_custom_urls_help">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.</string>
<string name="relay_custom_urls_label">Adresy URL przekaźników</string> <string name="relay_custom_urls_label">Adresy URL przekaźników</string>
<string name="relay_mode_automatic">Automatyczny (zalecany)</string> <string name="relay_mode_automatic">Automatyczny (zalecany)</string>
<string name="relay_mode_automatic_description">Używa domyślnej publicznej infrastruktury przekaźników VniDrop, gdy połączenie bezpośrednie jest niedostępne.</string> <string name="relay_mode_automatic_description">Używa domyślnej publicznej infrastruktury przekaźników VniDrop, gdy połączenie bezpośrednie jest niedostępne.</string>
@@ -231,20 +243,29 @@
<string name="status_receiving">Odbieranie</string> <string name="status_receiving">Odbieranie</string>
<string name="status_stopped">Zatrzymany</string> <string name="status_stopped">Zatrzymany</string>
<string name="storage_calculating">Obliczanie…</string> <string name="storage_calculating">Obliczanie…</string>
<string name="storage_cleaning">Czyszczenie…</string>
<string name="storage_cleanup_busy">Zakończ aktywne transfery przed zwolnieniem miejsca</string>
<string name="storage_cleanup_freed">Zwolniono %1$s</string>
<string name="storage_clear_transfer_cache">Wyczyść pamięć podręczną transferów</string> <string name="storage_clear_transfer_cache">Wyczyść pamięć podręczną transferów</string>
<string name="storage_clear_transfer_cache_description">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.</string> <string name="storage_clear_transfer_cache_description">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.</string>
<string name="storage_clearing_transfer_cache">Czyszczenie pamięci podręcznej…</string> <string name="storage_clearing_transfer_cache">Czyszczenie pamięci podręcznej…</string>
<string name="storage_delete_transfers_caption">Czyści historię wysyłania i odbierania oraz zapisane w pamięci podręcznej udostępniane treści. Odebrane pliki na dysku zostają zachowane.</string>
<string name="storage_free_up_space_caption">Usuwa pliki tymczasowe i pozostałości po wcześniejszych transferach. Twoje transfery i odebrane pliki zostają zachowane.</string>
<string name="storage_refresh">Odśwież</string>
<string name="storage_transfer_cache_cleared">Wyczyszczono pamięć podręczną transferów</string>
<string name="storage_unavailable">Wykorzystanie pamięci nie jest jeszcze dostępne</string>
<string name="storage_usage_header">Na tym urządzeniu</string>
<string name="storage_free_up_space">Zwolnij miejsce</string>
<string name="storage_delete_transfers">Usuń wszystkie transfery</string> <string name="storage_delete_transfers">Usuń wszystkie transfery</string>
<string name="storage_delete_transfers_description">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ąć.</string> <string name="storage_delete_transfers_description">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ąć.</string>
<string name="storage_app_data">Dane aplikacji</string> <string name="storage_app_data">Dane aplikacji</string>
<string name="storage_deleting">Usuwanie…</string> <string name="storage_deleting">Usuwanie…</string>
<string name="storage_footer">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.</string> <string name="storage_footer">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.</string>
<string name="storage_received_files">Odebrane pliki</string> <string name="storage_received_files">Odebrane pliki</string>
<string name="storage_temporary">Pliki tymczasowe</string> <string name="storage_temporary">Pliki tymczasowe</string>
<string name="storage_title">Pamięć</string> <string name="storage_title">Pamięć</string>
<string name="storage_total">Łącznie</string> <string name="storage_total">Łącznie</string>
<string name="storage_transfer_data">Pamięć podręczna transferów</string> <string name="storage_transfer_data">Dane transferu</string>
<string name="storage_transfer_cache_cleared">Wyczyszczono pamięć podręczną transferów</string>
<string name="storage_transfers_deleted">Usunięto wszystkie transfery</string> <string name="storage_transfers_deleted">Usunięto wszystkie transfery</string>
<string name="transfer_activity_description">Zobacz ważne aktualizacje tego transferu</string> <string name="transfer_activity_description">Zobacz ważne aktualizacje tego transferu</string>
<string name="transfer_activity_title">Aktywność</string> <string name="transfer_activity_title">Aktywność</string>
@@ -274,8 +295,8 @@
<string name="transfer_no_receivers">Nikt jeszcze nie poprosił o ten transfer.</string> <string name="transfer_no_receivers">Nikt jeszcze nie poprosił o ten transfer.</string>
<string name="transfer_receiver_accepted">Zatwierdzono — oczekiwanie na ukończenie</string> <string name="transfer_receiver_accepted">Zatwierdzono — oczekiwanie na ukończenie</string>
<string name="transfer_receiver_completed">Odebrano pomyślnie</string> <string name="transfer_receiver_completed">Odebrano pomyślnie</string>
<string name="transfer_receiver_failed">Przesyłanie nie powiodło się</string>
<string name="transfer_receiver_expired">Prośba wygasła</string> <string name="transfer_receiver_expired">Prośba wygasła</string>
<string name="transfer_receiver_failed">Dostarczenie nie powiodło się</string>
<string name="transfer_receiver_refused">Prośba odrzucona</string> <string name="transfer_receiver_refused">Prośba odrzucona</string>
<string name="transfer_receiver_requested">Oczekiwanie na Twoje zatwierdzenie</string> <string name="transfer_receiver_requested">Oczekiwanie na Twoje zatwierdzenie</string>
<string name="transfer_receiver_unknown">Status niedostępny</string> <string name="transfer_receiver_unknown">Status niedostępny</string>

View File

@@ -22,6 +22,7 @@
<string name="about_privacy_title">Privacidade e segurança</string> <string name="about_privacy_title">Privacidade e segurança</string>
<string name="about_tagline">Envie ficheiros diretamente. Mantenha o controlo sobre quem os recebe.</string> <string name="about_tagline">Envie ficheiros diretamente. Mantenha o controlo sobre quem os recebe.</string>
<string name="about_title">Acerca de</string> <string name="about_title">Acerca de</string>
<string name="app_starting">A iniciar…</string>
<string name="appearance_auto_description">Acompanhar o aspeto claro ou escuro deste dispositivo.</string> <string name="appearance_auto_description">Acompanhar o aspeto claro ou escuro deste dispositivo.</string>
<string name="appearance_dark_mode">Modo escuro</string> <string name="appearance_dark_mode">Modo escuro</string>
<string name="appearance_light_mode">Modo claro</string> <string name="appearance_light_mode">Modo claro</string>
@@ -54,6 +55,7 @@
<string name="bug_report_what_label">O que aconteceu?</string> <string name="bug_report_what_label">O que aconteceu?</string>
<string name="button_approve">Aprovar</string> <string name="button_approve">Aprovar</string>
<string name="button_back">Voltar</string> <string name="button_back">Voltar</string>
<string name="button_more_actions">Mais ações</string>
<string name="button_cancel">Cancelar</string> <string name="button_cancel">Cancelar</string>
<string name="button_cancel_receive">Cancelar</string> <string name="button_cancel_receive">Cancelar</string>
<string name="button_change_files">Alterar ficheiros</string> <string name="button_change_files">Alterar ficheiros</string>
@@ -117,10 +119,20 @@
<string name="nav_send">Enviar</string> <string name="nav_send">Enviar</string>
<string name="nav_settings">Definições</string> <string name="nav_settings">Definições</string>
<string name="network_title">Rede</string> <string name="network_title">Rede</string>
<string name="notifications_description">Seja notificado sobre novos pedidos de receção enquanto o VniDrop está em segundo plano.</string> <string name="notifications_description">Seja notificado sobre a atividade das transferências enquanto o VniDrop está em segundo plano.</string>
<string name="notifications_enabled_message">Notificações ativadas.</string> <string name="notifications_enabled_message">Notificações ativadas.</string>
<string name="notifications_local_title">Permitir notificações</string> <string name="notifications_local_title">Permitir notificações</string>
<string name="notifications_permission_denied">As notificações estão desativadas para o VniDrop. Pode ativá-las nas Definições.</string> <string name="notifications_permission_denied">As notificações estão desativadas para o VniDrop. Pode ativá-las nas Definições.</string>
<string name="notifications_receive_completed_body">«%1$s» concluiu a transferência.</string>
<string name="notifications_receive_completed_title">Transferência concluída</string>
<string name="notifications_receive_failed_body">Não foi possível receber «%1$s».</string>
<string name="notifications_receive_failed_title">Falha na transferência</string>
<string name="notifications_receiver_completed_body">%1$s terminou de receber «%2$s».</string>
<string name="notifications_receiver_completed_title">Transferência recebida</string>
<string name="notifications_receiver_failed_body">%1$s não conseguiu receber “%2$s”</string>
<string name="notifications_receiver_failed_title">Falha na entrega</string>
<string name="notifications_send_failed_body">Não foi possível partilhar «%1$s».</string>
<string name="notifications_send_failed_title">Falha na partilha</string>
<string name="notifications_settings_open_failed">Não foi possível abrir as definições de notificações.</string> <string name="notifications_settings_open_failed">Não foi possível abrir as definições de notificações.</string>
<string name="notifications_title">Notificações</string> <string name="notifications_title">Notificações</string>
<string name="notifications_unsupported">As notificações não estão disponíveis neste dispositivo.</string> <string name="notifications_unsupported">As notificações não estão disponíveis neste dispositivo.</string>
@@ -175,7 +187,7 @@
<string name="relay_apply_active_transfers">Pare todas as transferências e partilhas ativas antes de aplicar as definições de rede.</string> <string name="relay_apply_active_transfers">Pare todas as transferências e partilhas ativas antes de aplicar as definições de rede.</string>
<string name="relay_apply_failed">Não foi possível aplicar estas definições. As definições de rede anteriores foram restauradas.</string> <string name="relay_apply_failed">Não foi possível aplicar estas definições. As definições de rede anteriores foram restauradas.</string>
<string name="relay_apply_restart_description">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.</string> <string name="relay_apply_restart_description">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.</string>
<string name="relay_custom_urls_help">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.</string> <string name="relay_custom_urls_help">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.</string>
<string name="relay_custom_urls_label">URLs dos retransmissores</string> <string name="relay_custom_urls_label">URLs dos retransmissores</string>
<string name="relay_mode_automatic">Automático (recomendado)</string> <string name="relay_mode_automatic">Automático (recomendado)</string>
<string name="relay_mode_automatic_description">Utiliza a infraestrutura pública de retransmissores predefinida do VniDrop quando não está disponível uma ligação direta.</string> <string name="relay_mode_automatic_description">Utiliza a infraestrutura pública de retransmissores predefinida do VniDrop quando não está disponível uma ligação direta.</string>
@@ -231,20 +243,29 @@
<string name="status_receiving">A receber</string> <string name="status_receiving">A receber</string>
<string name="status_stopped">Parada</string> <string name="status_stopped">Parada</string>
<string name="storage_calculating">A calcular…</string> <string name="storage_calculating">A calcular…</string>
<string name="storage_cleaning">A limpar…</string>
<string name="storage_cleanup_busy">Conclui as transferências ativas antes de libertar espaço</string>
<string name="storage_cleanup_freed">Libertado %1$s</string>
<string name="storage_clear_transfer_cache">Limpar cache de transferências</string> <string name="storage_clear_transfer_cache">Limpar cache de transferências</string>
<string name="storage_clear_transfer_cache_description">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.</string> <string name="storage_clear_transfer_cache_description">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.</string>
<string name="storage_clearing_transfer_cache">A limpar cache…</string> <string name="storage_clearing_transfer_cache">A limpar cache…</string>
<string name="storage_delete_transfers_caption">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.</string>
<string name="storage_free_up_space_caption">Remove ficheiros temporários e resíduos de transferências anteriores. As tuas transferências e ficheiros recebidos são mantidos.</string>
<string name="storage_refresh">Atualizar</string>
<string name="storage_transfer_cache_cleared">Cache de transferências limpa</string>
<string name="storage_unavailable">A utilização do armazenamento ainda não está disponível</string>
<string name="storage_usage_header">Neste dispositivo</string>
<string name="storage_free_up_space">Libertar espaço</string>
<string name="storage_delete_transfers">Eliminar todas as transferências</string> <string name="storage_delete_transfers">Eliminar todas as transferências</string>
<string name="storage_delete_transfers_description">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.</string> <string name="storage_delete_transfers_description">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.</string>
<string name="storage_app_data">Dados da aplicação</string> <string name="storage_app_data">Dados da aplicação</string>
<string name="storage_deleting">A eliminar…</string> <string name="storage_deleting">A eliminar…</string>
<string name="storage_footer">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.</string> <string name="storage_footer">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.</string>
<string name="storage_received_files">Ficheiros recebidos</string> <string name="storage_received_files">Ficheiros recebidos</string>
<string name="storage_temporary">Ficheiros temporários</string> <string name="storage_temporary">Ficheiros temporários</string>
<string name="storage_title">Armazenamento</string> <string name="storage_title">Armazenamento</string>
<string name="storage_total">Total</string> <string name="storage_total">Total</string>
<string name="storage_transfer_data">Cache de transferências</string> <string name="storage_transfer_data">Dados de transferência</string>
<string name="storage_transfer_cache_cleared">Cache de transferências limpa</string>
<string name="storage_transfers_deleted">Todas as transferências eliminadas</string> <string name="storage_transfers_deleted">Todas as transferências eliminadas</string>
<string name="transfer_activity_description">Ver as atualizações importantes desta transferência</string> <string name="transfer_activity_description">Ver as atualizações importantes desta transferência</string>
<string name="transfer_activity_title">Atividade</string> <string name="transfer_activity_title">Atividade</string>
@@ -274,8 +295,8 @@
<string name="transfer_no_receivers">Ainda ninguém pediu esta transferência.</string> <string name="transfer_no_receivers">Ainda ninguém pediu esta transferência.</string>
<string name="transfer_receiver_accepted">Aprovado — a aguardar conclusão</string> <string name="transfer_receiver_accepted">Aprovado — a aguardar conclusão</string>
<string name="transfer_receiver_completed">Recebido com sucesso</string> <string name="transfer_receiver_completed">Recebido com sucesso</string>
<string name="transfer_receiver_failed">A transferência falhou</string>
<string name="transfer_receiver_expired">Pedido expirado</string> <string name="transfer_receiver_expired">Pedido expirado</string>
<string name="transfer_receiver_failed">Falha na entrega</string>
<string name="transfer_receiver_refused">Pedido recusado</string> <string name="transfer_receiver_refused">Pedido recusado</string>
<string name="transfer_receiver_requested">A aguardar a sua aprovação</string> <string name="transfer_receiver_requested">A aguardar a sua aprovação</string>
<string name="transfer_receiver_unknown">Estado indisponível</string> <string name="transfer_receiver_unknown">Estado indisponível</string>

View File

@@ -22,6 +22,7 @@
<string name="about_privacy_title">Конфиденциальность и безопасность</string> <string name="about_privacy_title">Конфиденциальность и безопасность</string>
<string name="about_tagline">Отправляйте файлы напрямую. Сохраняйте контроль над тем, кто их получает.</string> <string name="about_tagline">Отправляйте файлы напрямую. Сохраняйте контроль над тем, кто их получает.</string>
<string name="about_title">О приложении</string> <string name="about_title">О приложении</string>
<string name="app_starting">Запуск…</string>
<string name="appearance_auto_description">Следовать светлому или тёмному оформлению этого устройства.</string> <string name="appearance_auto_description">Следовать светлому или тёмному оформлению этого устройства.</string>
<string name="appearance_dark_mode">Тёмный режим</string> <string name="appearance_dark_mode">Тёмный режим</string>
<string name="appearance_light_mode">Светлый режим</string> <string name="appearance_light_mode">Светлый режим</string>
@@ -54,6 +55,7 @@
<string name="bug_report_what_label">Что произошло?</string> <string name="bug_report_what_label">Что произошло?</string>
<string name="button_approve">Одобрить</string> <string name="button_approve">Одобрить</string>
<string name="button_back">Назад</string> <string name="button_back">Назад</string>
<string name="button_more_actions">Другие действия</string>
<string name="button_cancel">Отмена</string> <string name="button_cancel">Отмена</string>
<string name="button_cancel_receive">Отмена</string> <string name="button_cancel_receive">Отмена</string>
<string name="button_change_files">Изменить файлы</string> <string name="button_change_files">Изменить файлы</string>
@@ -117,10 +119,20 @@
<string name="nav_send">Отправить</string> <string name="nav_send">Отправить</string>
<string name="nav_settings">Настройки</string> <string name="nav_settings">Настройки</string>
<string name="network_title">Сеть</string> <string name="network_title">Сеть</string>
<string name="notifications_description">Получайте уведомления о новых запросах на получение, пока VniDrop работает в фоне.</string> <string name="notifications_description">Получайте уведомления об активности передач, пока VniDrop работает в фоне.</string>
<string name="notifications_enabled_message">Уведомления включены.</string> <string name="notifications_enabled_message">Уведомления включены.</string>
<string name="notifications_local_title">Разрешить уведомления</string> <string name="notifications_local_title">Разрешить уведомления</string>
<string name="notifications_permission_denied">Уведомления отключены для VniDrop. Вы можете включить их в Настройках.</string> <string name="notifications_permission_denied">Уведомления отключены для VniDrop. Вы можете включить их в Настройках.</string>
<string name="notifications_receive_completed_body">«%1$s» завершил загрузку.</string>
<string name="notifications_receive_completed_title">Загрузка завершена</string>
<string name="notifications_receive_failed_body">Не удалось получить «%1$s».</string>
<string name="notifications_receive_failed_title">Ошибка загрузки</string>
<string name="notifications_receiver_completed_body">%1$s завершил получение «%2$s».</string>
<string name="notifications_receiver_completed_title">Передача получена</string>
<string name="notifications_receiver_failed_body">%1$s не удалось получить «%2$s»</string>
<string name="notifications_receiver_failed_title">Ошибка доставки</string>
<string name="notifications_send_failed_body">Не удалось поделиться «%1$s».</string>
<string name="notifications_send_failed_title">Не удалось поделиться</string>
<string name="notifications_settings_open_failed">Не удалось открыть настройки уведомлений.</string> <string name="notifications_settings_open_failed">Не удалось открыть настройки уведомлений.</string>
<string name="notifications_title">Уведомления</string> <string name="notifications_title">Уведомления</string>
<string name="notifications_unsupported">Уведомления недоступны на этом устройстве.</string> <string name="notifications_unsupported">Уведомления недоступны на этом устройстве.</string>
@@ -175,7 +187,7 @@
<string name="relay_apply_active_transfers">Остановите все активные передачи и раздачи перед применением настроек сети.</string> <string name="relay_apply_active_transfers">Остановите все активные передачи и раздачи перед применением настроек сети.</string>
<string name="relay_apply_failed">Не удалось применить эти настройки. Предыдущие настройки сети восстановлены.</string> <string name="relay_apply_failed">Не удалось применить эти настройки. Предыдущие настройки сети восстановлены.</string>
<string name="relay_apply_restart_description">При применении сетевое соединение VniDrop перезапускается. Сначала остановите активные передачи и раздачи. Возможно, существующие приглашения потребуется отправить повторно.</string> <string name="relay_apply_restart_description">При применении сетевое соединение VniDrop перезапускается. Сначала остановите активные передачи и раздачи. Возможно, существующие приглашения потребуется отправить повторно.</string>
<string name="relay_custom_urls_help">Добавляйте каждый HTTPS-адрес ретранслятора отдельно. Учётные данные в URL-адресах не поддерживаются. Сертификат TLS должен быть выдан общедоступным доверенным центром сертификации.</string> <string name="relay_custom_urls_help">Введите по одному HTTPS-адресу ретранслятора в строке. Учётные данные в URL-адресах не поддерживаются. Сертификат TLS должен быть выдан общедоступным доверенным центром сертификации.</string>
<string name="relay_custom_urls_label">URL-адреса ретрансляторов</string> <string name="relay_custom_urls_label">URL-адреса ретрансляторов</string>
<string name="relay_mode_automatic">Автоматически (рекомендуется)</string> <string name="relay_mode_automatic">Автоматически (рекомендуется)</string>
<string name="relay_mode_automatic_description">Использовать стандартную публичную инфраструктуру ретрансляторов VniDrop, если прямое соединение недоступно.</string> <string name="relay_mode_automatic_description">Использовать стандартную публичную инфраструктуру ретрансляторов VniDrop, если прямое соединение недоступно.</string>
@@ -231,20 +243,29 @@
<string name="status_receiving">Получение</string> <string name="status_receiving">Получение</string>
<string name="status_stopped">Остановлено</string> <string name="status_stopped">Остановлено</string>
<string name="storage_calculating">Вычисление…</string> <string name="storage_calculating">Вычисление…</string>
<string name="storage_cleaning">Очистка…</string>
<string name="storage_cleanup_busy">Завершите активные передачи перед освобождением места</string>
<string name="storage_cleanup_freed">Освобождено %1$s</string>
<string name="storage_clear_transfer_cache">Очистить кэш передач</string> <string name="storage_clear_transfer_cache">Очистить кэш передач</string>
<string name="storage_clear_transfer_cache_description">Удаляет кэшированное содержимое передач, которое не используется текущим приёмом или активной раздачей. Полученные файлы и история передач не удаляются.</string> <string name="storage_clear_transfer_cache_description">Удаляет кэшированное содержимое передач, которое не используется текущим приёмом или активной раздачей. Полученные файлы и история передач не удаляются.</string>
<string name="storage_clearing_transfer_cache">Очистка кэша…</string> <string name="storage_clearing_transfer_cache">Очистка кэша…</string>
<string name="storage_delete_transfers_caption">Очищает историю отправки и получения и кэшированное содержимое общих ресурсов. Полученные файлы на диске сохраняются.</string>
<string name="storage_free_up_space_caption">Удаляет временные файлы и остатки прошлых передач. Ваши передачи и полученные файлы сохраняются.</string>
<string name="storage_refresh">Обновить</string>
<string name="storage_transfer_cache_cleared">Кэш передач очищен</string>
<string name="storage_unavailable">Данные об использовании хранилища пока недоступны</string>
<string name="storage_usage_header">На этом устройстве</string>
<string name="storage_free_up_space">Освободить место</string>
<string name="storage_delete_transfers">Удалить все передачи</string> <string name="storage_delete_transfers">Удалить все передачи</string>
<string name="storage_delete_transfers_description">Это удалит из истории все отправленные и полученные передачи и немедленно освободит неиспользуемый кэш. Текущие передачи и полученные файлы не удаляются. Это действие нельзя отменить.</string> <string name="storage_delete_transfers_description">Это удалит из истории все отправленные и полученные передачи и немедленно освободит неиспользуемый кэш. Текущие передачи и полученные файлы не удаляются. Это действие нельзя отменить.</string>
<string name="storage_app_data">Данные приложения</string> <string name="storage_app_data">Данные приложения</string>
<string name="storage_deleting">Удаление…</string> <string name="storage_deleting">Удаление…</string>
<string name="storage_footer">Кэш передач содержит загруженные или импортированные данные, включая содержимое, защищённое текущими приёмами и активными раздачами. Очистка удаляет только неиспользуемый кэш. Полученные файлы здесь никогда не удаляются.</string> <string name="storage_footer">Данные передачи включают историю и кэшированное содержимое активных раздач. Ненужный кэш освобождается автоматически после удаления записей; это может занять некоторое время. Полученные файлы учитываются в этой сводке, но никогда не удаляются здесь.</string>
<string name="storage_received_files">Полученные файлы</string> <string name="storage_received_files">Полученные файлы</string>
<string name="storage_temporary">Временные файлы</string> <string name="storage_temporary">Временные файлы</string>
<string name="storage_title">Хранилище</string> <string name="storage_title">Хранилище</string>
<string name="storage_total">Всего</string> <string name="storage_total">Всего</string>
<string name="storage_transfer_data">Кэш передач</string> <string name="storage_transfer_data">Данные передачи</string>
<string name="storage_transfer_cache_cleared">Кэш передач очищен</string>
<string name="storage_transfers_deleted">Все передачи удалены</string> <string name="storage_transfers_deleted">Все передачи удалены</string>
<string name="transfer_activity_description">Просматривайте важные обновления этой передачи</string> <string name="transfer_activity_description">Просматривайте важные обновления этой передачи</string>
<string name="transfer_activity_title">Активность</string> <string name="transfer_activity_title">Активность</string>
@@ -274,8 +295,8 @@
<string name="transfer_no_receivers">Никто ещё не запросил эту передачу.</string> <string name="transfer_no_receivers">Никто ещё не запросил эту передачу.</string>
<string name="transfer_receiver_accepted">Одобрено — ожидание завершения</string> <string name="transfer_receiver_accepted">Одобрено — ожидание завершения</string>
<string name="transfer_receiver_completed">Успешно получено</string> <string name="transfer_receiver_completed">Успешно получено</string>
<string name="transfer_receiver_failed">Передача не удалась</string>
<string name="transfer_receiver_expired">Запрос истёк</string> <string name="transfer_receiver_expired">Запрос истёк</string>
<string name="transfer_receiver_failed">Ошибка доставки</string>
<string name="transfer_receiver_refused">Запрос отклонён</string> <string name="transfer_receiver_refused">Запрос отклонён</string>
<string name="transfer_receiver_requested">Ожидание вашего одобрения</string> <string name="transfer_receiver_requested">Ожидание вашего одобрения</string>
<string name="transfer_receiver_unknown">Статус недоступен</string> <string name="transfer_receiver_unknown">Статус недоступен</string>

View File

@@ -22,6 +22,7 @@
<string name="about_privacy_title">Privacy &amp; security</string> <string name="about_privacy_title">Privacy &amp; security</string>
<string name="about_tagline">Send files directly. Stay in control of who receives them.</string> <string name="about_tagline">Send files directly. Stay in control of who receives them.</string>
<string name="about_title">About</string> <string name="about_title">About</string>
<string name="app_starting">Starting…</string>
<string name="appearance_auto_description">Match this devices light or dark appearance.</string> <string name="appearance_auto_description">Match this devices light or dark appearance.</string>
<string name="appearance_dark_mode">Dark mode</string> <string name="appearance_dark_mode">Dark mode</string>
<string name="appearance_light_mode">Light mode</string> <string name="appearance_light_mode">Light mode</string>
@@ -54,6 +55,7 @@
<string name="bug_report_what_label">What happened?</string> <string name="bug_report_what_label">What happened?</string>
<string name="button_approve">Approve</string> <string name="button_approve">Approve</string>
<string name="button_back">Back</string> <string name="button_back">Back</string>
<string name="button_more_actions">More actions</string>
<string name="button_cancel">Cancel</string> <string name="button_cancel">Cancel</string>
<string name="button_cancel_receive">Cancel</string> <string name="button_cancel_receive">Cancel</string>
<string name="button_change_files">Change files</string> <string name="button_change_files">Change files</string>
@@ -117,10 +119,20 @@
<string name="nav_send">Send</string> <string name="nav_send">Send</string>
<string name="nav_settings">Settings</string> <string name="nav_settings">Settings</string>
<string name="network_title">Network</string> <string name="network_title">Network</string>
<string name="notifications_description">Get notified about new receive requests while VniDrop is in the background.</string> <string name="notifications_description">Get notified about transfer activity while VniDrop is in the background.</string>
<string name="notifications_enabled_message">Notifications enabled.</string> <string name="notifications_enabled_message">Notifications enabled.</string>
<string name="notifications_local_title">Allow notifications</string> <string name="notifications_local_title">Allow notifications</string>
<string name="notifications_permission_denied">Notifications are turned off for VniDrop. You can enable them in Settings.</string> <string name="notifications_permission_denied">Notifications are turned off for VniDrop. You can enable them in Settings.</string>
<string name="notifications_receive_completed_body">“%1$s” finished downloading.</string>
<string name="notifications_receive_completed_title">Download complete</string>
<string name="notifications_receive_failed_body">“%1$s” couldnt be received.</string>
<string name="notifications_receive_failed_title">Download failed</string>
<string name="notifications_receiver_completed_body">%1$s finished receiving “%2$s”.</string>
<string name="notifications_receiver_completed_title">Transfer received</string>
<string name="notifications_receiver_failed_body">%1$s couldn\'t receive “%2$s”</string>
<string name="notifications_receiver_failed_title">Delivery failed</string>
<string name="notifications_send_failed_body">“%1$s” couldnt be shared.</string>
<string name="notifications_send_failed_title">Sharing failed</string>
<string name="notifications_settings_open_failed">Could not open notification settings.</string> <string name="notifications_settings_open_failed">Could not open notification settings.</string>
<string name="notifications_title">Notifications</string> <string name="notifications_title">Notifications</string>
<string name="notifications_unsupported">Notifications are not available on this device.</string> <string name="notifications_unsupported">Notifications are not available on this device.</string>
@@ -175,7 +187,7 @@
<string name="relay_apply_active_transfers">Stop all active transfers and shares before applying network settings.</string> <string name="relay_apply_active_transfers">Stop all active transfers and shares before applying network settings.</string>
<string name="relay_apply_failed">Could not apply these settings. The previous network settings were restored.</string> <string name="relay_apply_failed">Could not apply these settings. The previous network settings were restored.</string>
<string name="relay_apply_restart_description">Applying restarts VniDrops network connection. Stop active transfers and shares first. Existing invitations may need to be shared again.</string> <string name="relay_apply_restart_description">Applying restarts VniDrops network connection. Stop active transfers and shares first. Existing invitations may need to be shared again.</string>
<string name="relay_custom_urls_help">Add each HTTPS relay URL separately. URL credentials are not supported. The TLS certificate must be issued by a publicly trusted certificate authority.</string> <string name="relay_custom_urls_help">Enter one HTTPS relay URL per line. URL credentials are not supported. The TLS certificate must be issued by a publicly trusted certificate authority.</string>
<string name="relay_custom_urls_label">Relay URLs</string> <string name="relay_custom_urls_label">Relay URLs</string>
<string name="relay_mode_automatic">Automatic (recommended)</string> <string name="relay_mode_automatic">Automatic (recommended)</string>
<string name="relay_mode_automatic_description">Use VniDrops default public relay infrastructure when a direct connection is unavailable.</string> <string name="relay_mode_automatic_description">Use VniDrops default public relay infrastructure when a direct connection is unavailable.</string>
@@ -231,20 +243,29 @@
<string name="status_receiving">Receiving</string> <string name="status_receiving">Receiving</string>
<string name="status_stopped">Stopped</string> <string name="status_stopped">Stopped</string>
<string name="storage_calculating">Calculating…</string> <string name="storage_calculating">Calculating…</string>
<string name="storage_cleaning">Cleaning up…</string>
<string name="storage_cleanup_busy">Finish active transfers before freeing up space</string>
<string name="storage_cleanup_freed">Freed %1$s</string>
<string name="storage_clear_transfer_cache">Clear transfer cache</string> <string name="storage_clear_transfer_cache">Clear transfer cache</string>
<string name="storage_clear_transfer_cache_description">Removes cached transfer content after briefly restarting VniDrop. Finish ongoing transfers and stop active shares first. Received files and transfer history are not deleted.</string> <string name="storage_clear_transfer_cache_description">Removes cached transfer content after briefly restarting VniDrop. Finish ongoing transfers and stop active shares first. Received files and transfer history are not deleted.</string>
<string name="storage_clearing_transfer_cache">Clearing cache…</string> <string name="storage_clearing_transfer_cache">Clearing cache…</string>
<string name="storage_delete_transfers_caption">Clears your send and receive history and the apps cached share content. Received files on disk are kept.</string>
<string name="storage_free_up_space_caption">Removes temporary files and leftover trash from earlier transfers. Your transfers and received files are kept.</string>
<string name="storage_refresh">Refresh</string>
<string name="storage_transfer_cache_cleared">Transfer cache cleared</string>
<string name="storage_unavailable">Storage usage isn\'t available yet</string>
<string name="storage_usage_header">On this device</string>
<string name="storage_free_up_space">Free up space</string>
<string name="storage_delete_transfers">Delete all transfers</string> <string name="storage_delete_transfers">Delete all transfers</string>
<string name="storage_delete_transfers_description">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 cant be undone.</string> <string name="storage_delete_transfers_description">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 cant be undone.</string>
<string name="storage_app_data">App data</string> <string name="storage_app_data">App data</string>
<string name="storage_deleting">Deleting…</string> <string name="storage_deleting">Deleting…</string>
<string name="storage_footer">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.</string> <string name="storage_footer">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.</string>
<string name="storage_received_files">Received files</string> <string name="storage_received_files">Received files</string>
<string name="storage_temporary">Temporary files</string> <string name="storage_temporary">Temporary files</string>
<string name="storage_title">Storage</string> <string name="storage_title">Storage</string>
<string name="storage_total">Total</string> <string name="storage_total">Total</string>
<string name="storage_transfer_data">Transfer cache</string> <string name="storage_transfer_data">Transfer data</string>
<string name="storage_transfer_cache_cleared">Transfer cache cleared</string>
<string name="storage_transfers_deleted">All transfers deleted</string> <string name="storage_transfers_deleted">All transfers deleted</string>
<string name="transfer_activity_description">See important updates for this transfer</string> <string name="transfer_activity_description">See important updates for this transfer</string>
<string name="transfer_activity_title">Activity</string> <string name="transfer_activity_title">Activity</string>
@@ -274,8 +295,8 @@
<string name="transfer_no_receivers">Nobody has requested this transfer yet.</string> <string name="transfer_no_receivers">Nobody has requested this transfer yet.</string>
<string name="transfer_receiver_accepted">Approved — waiting for completion</string> <string name="transfer_receiver_accepted">Approved — waiting for completion</string>
<string name="transfer_receiver_completed">Received successfully</string> <string name="transfer_receiver_completed">Received successfully</string>
<string name="transfer_receiver_failed">Transfer failed</string>
<string name="transfer_receiver_expired">Request expired</string> <string name="transfer_receiver_expired">Request expired</string>
<string name="transfer_receiver_failed">Delivery failed</string>
<string name="transfer_receiver_refused">Request refused</string> <string name="transfer_receiver_refused">Request refused</string>
<string name="transfer_receiver_requested">Waiting for your approval</string> <string name="transfer_receiver_requested">Waiting for your approval</string>
<string name="transfer_receiver_unknown">Status unavailable</string> <string name="transfer_receiver_unknown">Status unavailable</string>

View File

@@ -6,6 +6,13 @@ import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding 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.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
@@ -14,6 +21,8 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color 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.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
@@ -49,6 +58,9 @@ import com.vnidrop.app.ui.theme.rememberResolvedDarkTheme
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.withTimeoutOrNull
import org.jetbrains.compose.resources.stringResource
import vnidrop.shared.generated.resources.Res
import vnidrop.shared.generated.resources.app_starting
@Composable @Composable
fun App( fun App(
@@ -217,6 +229,32 @@ fun App(
) )
} }
windowChrome?.invoke() 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,
)
}
}
}
} }
} }
} }

View File

@@ -8,6 +8,7 @@ import com.vnidrop.app.feature.approvals.ApprovalCoordinator
import com.vnidrop.app.feature.send.AppFilePreviewRepository import com.vnidrop.app.feature.send.AppFilePreviewRepository
import com.vnidrop.app.feature.send.createPlatformPreviewStore import com.vnidrop.app.feature.send.createPlatformPreviewStore
import com.vnidrop.app.logging.AppLogger import com.vnidrop.app.logging.AppLogger
import com.vnidrop.app.notifications.TransferNotificationCoordinator
import com.vnidrop.app.platform.AppVisibility import com.vnidrop.app.platform.AppVisibility
import com.vnidrop.app.preferences.AppPreferencesDefaults import com.vnidrop.app.preferences.AppPreferencesDefaults
import com.vnidrop.app.preferences.AppPreferencesRepository import com.vnidrop.app.preferences.AppPreferencesRepository
@@ -62,6 +63,14 @@ class AppGraph(
messages = messages, messages = messages,
scope = applicationScope, scope = applicationScope,
) )
val transferNotificationCoordinator = TransferNotificationCoordinator(
repository = coreRepository,
preferencesRepository = preferencesRepository,
notifications = dependencies.localNotificationService,
visibility = visibility,
messages = messages,
scope = applicationScope,
)
init { init {
AppLogger.initialize(dependencies.environment.defaultCoreDataDir) AppLogger.initialize(dependencies.environment.defaultCoreDataDir)

View File

@@ -41,6 +41,8 @@ interface FileSystemService {
suspend fun validateReceiveFolder(folder: ReceiveFolder): FolderAccessStatus suspend fun validateReceiveFolder(folder: ReceiveFolder): FolderAccessStatus
suspend fun inspectReceivedArtifacts(artifacts: List<ReceivedArtifactModel>): ReceivedStorageInspection suspend fun inspectReceivedArtifacts(artifacts: List<ReceivedArtifactModel>): ReceivedStorageInspection
suspend fun temporaryUsage(receiveFolder: ReceiveFolder): ULong 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 createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSinkV2?
fun canRevealReceiveFolder(folder: ReceiveFolder): Boolean = false fun canRevealReceiveFolder(folder: ReceiveFolder): Boolean = false
suspend fun revealReceiveFolder(folder: ReceiveFolder): Result<Unit> = suspend fun revealReceiveFolder(folder: ReceiveFolder): Result<Unit> =

View File

@@ -294,7 +294,7 @@ private fun InvitationReviewPanel(
Text( Text(
when (error) { when (error) {
is UiText.Dynamic -> error.value is UiText.Dynamic -> error.value
is UiText.Resource -> stringResource(error.resource) is UiText.Resource -> stringResource(error.resource, *error.formatArgs.toTypedArray())
}, },
color = LocalVniDropColors.current.destructiveDefault, color = LocalVniDropColors.current.destructiveDefault,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,

View File

@@ -19,17 +19,25 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.FloatingActionButton 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.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.testTag 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.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
@@ -58,12 +66,16 @@ import org.jetbrains.compose.resources.stringResource
import org.jetbrains.compose.resources.decodeToImageBitmap import org.jetbrains.compose.resources.decodeToImageBitmap
import vnidrop.shared.generated.resources.Res import vnidrop.shared.generated.resources.Res
import vnidrop.shared.generated.resources.button_create_new_transfer 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_body
import vnidrop.shared.generated.resources.send_empty_title 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_description
import vnidrop.shared.generated.resources.send_new_transfer_title import vnidrop.shared.generated.resources.send_new_transfer_title
import vnidrop.shared.generated.resources.send_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.send_transfers_title
import vnidrop.shared.generated.resources.transfer_share_title
@Composable @Composable
internal fun SendFloatingAction(onClick: () -> Unit, modifier: Modifier = Modifier) { internal fun SendFloatingAction(onClick: () -> Unit, modifier: Modifier = Modifier) {
@@ -86,6 +98,9 @@ internal fun TransferCatalog(
windowClass: WindowClass, windowClass: WindowClass,
onOpenComposer: () -> Unit, onOpenComposer: () -> Unit,
onTransferSelected: (ULong) -> Unit, onTransferSelected: (ULong) -> Unit,
onShare: (ULong) -> Unit = {},
onStopSharing: (ULong) -> Unit = {},
onDelete: (ULong) -> Unit = {},
) { ) {
val usesFloatingAction = usesMobilePresentation(LocalUiPlatform.current, windowClass) val usesFloatingAction = usesMobilePresentation(LocalUiPlatform.current, windowClass)
LazyColumn( LazyColumn(
@@ -129,6 +144,9 @@ internal fun TransferCatalog(
thumbnailBytes = transferThumbnails[transfer.transferId], thumbnailBytes = transferThumbnails[transfer.transferId],
progress = progress, progress = progress,
onClick = { onTransferSelected(transfer.transferId) }, 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?, thumbnailBytes: ByteArray?,
progress: TransferProgress?, progress: TransferProgress?,
onClick: () -> Unit, onClick: () -> Unit,
onShare: () -> Unit,
onStopSharing: () -> Unit,
onDelete: () -> Unit,
) { ) {
val colors = LocalVniDropColors.current val colors = LocalVniDropColors.current
Surface(onClick = onClick, modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(16.dp), color = colors.backgroundSurface200) { 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) ProgressRow(label = progress.label, progress = progress.progress, detail = progress.detail)
} }
} }
Spacer(Modifier.width(8.dp)) TransferActionsMenu(transfer, onShare, onStopSharing, onDelete)
PlatformIcon(AppIcon.ChevronRight, contentDescription = null, tint = colors.foregroundLighter, modifier = Modifier.size(18.dp)) }
}
}
@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,
)
},
)
} }
} }
} }

View File

@@ -46,6 +46,11 @@ fun SendRoute(
onAccessPolicyChanged = viewModel::setAccessPolicy, onAccessPolicyChanged = viewModel::setAccessPolicy,
onCreateShare = viewModel::createShare, onCreateShare = viewModel::createShare,
onTransferSelected = viewModel::openTransfer, onTransferSelected = viewModel::openTransfer,
onShareTransfer = { transferId ->
viewModel.openTransfer(transferId)
viewModel.openShare()
},
onStopSharing = viewModel::stopSharing,
onCloseTransferDetails = viewModel::closeTransferDetails, onCloseTransferDetails = viewModel::closeTransferDetails,
onCopyTicket = viewModel::copyTicket, onCopyTicket = viewModel::copyTicket,
onActivity = viewModel::openActivity, onActivity = viewModel::openActivity,
@@ -53,7 +58,8 @@ fun SendRoute(
onShare = viewModel::openShare, onShare = viewModel::openShare,
onCloseDetailPanel = viewModel::closeDetailPanel, onCloseDetailPanel = viewModel::closeDetailPanel,
onInvitationResult = viewModel::onInvitationResult, onInvitationResult = viewModel::onInvitationResult,
onRequestDelete = viewModel::requestDeleteTransfer, onRequestDelete = { viewModel.requestDeleteTransfer() },
onRequestDeleteTransfer = { viewModel.requestDeleteTransfer(it) },
onDismissDelete = viewModel::dismissDeleteTransfer, onDismissDelete = viewModel::dismissDeleteTransfer,
onConfirmDelete = viewModel::confirmDeleteTransfer, onConfirmDelete = viewModel::confirmDeleteTransfer,
) )

View File

@@ -33,6 +33,8 @@ fun SendScreen(
onAccessPolicyChanged: (ShareAccessPolicy) -> Unit, onAccessPolicyChanged: (ShareAccessPolicy) -> Unit,
onCreateShare: () -> Unit, onCreateShare: () -> Unit,
onTransferSelected: (ULong) -> Unit, onTransferSelected: (ULong) -> Unit,
onShareTransfer: (ULong) -> Unit = {},
onStopSharing: (ULong) -> Unit = {},
onCloseTransferDetails: () -> Unit, onCloseTransferDetails: () -> Unit,
onCopyTicket: (String) -> Unit, onCopyTicket: (String) -> Unit,
onActivity: () -> Unit = {}, onActivity: () -> Unit = {},
@@ -41,11 +43,13 @@ fun SendScreen(
onCloseDetailPanel: () -> Unit = {}, onCloseDetailPanel: () -> Unit = {},
onInvitationResult: (InvitationAction, Result<Unit>) -> Unit = { _, _ -> }, onInvitationResult: (InvitationAction, Result<Unit>) -> Unit = { _, _ -> },
onRequestDelete: () -> Unit = {}, onRequestDelete: () -> Unit = {},
onRequestDeleteTransfer: (ULong) -> Unit = {},
onDismissDelete: () -> Unit = {}, onDismissDelete: () -> Unit = {},
onConfirmDelete: () -> Unit = {}, onConfirmDelete: () -> Unit = {},
) { ) {
val outgoingTransfers = coreState.transfers.filter { it.direction == TransferDirection.Send } val outgoingTransfers = coreState.transfers.filter { it.direction == TransferDirection.Send }
val selectedTransfer = state.selectedTransferId?.let { id -> outgoingTransfers.firstOrNull { it.transferId == id } } 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<String, ImageBitmap>() } val qrCache = remember { mutableStateMapOf<String, ImageBitmap>() }
LaunchedEffect(outgoingTransfers.mapNotNull { it.ticket }) { LaunchedEffect(outgoingTransfers.mapNotNull { it.ticket }) {
qrCache.keys.retainAll(outgoingTransfers.mapNotNull { it.ticket }.toSet()) qrCache.keys.retainAll(outgoingTransfers.mapNotNull { it.ticket }.toSet())
@@ -64,6 +68,7 @@ fun SendScreen(
onActivity = onActivity, onActivity = onActivity,
onReceivers = onReceivers, onReceivers = onReceivers,
onShare = onShare, onShare = onShare,
onStopSharing = { onStopSharing(selectedTransfer.transferId) },
onDelete = onRequestDelete, onDelete = onRequestDelete,
) )
} else { } else {
@@ -75,6 +80,9 @@ fun SendScreen(
windowClass = windowClass, windowClass = windowClass,
onOpenComposer = onOpenComposer, onOpenComposer = onOpenComposer,
onTransferSelected = onTransferSelected, 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) { AdaptiveDrawer(windowClass = windowClass, onDismissRequest = onDismissDelete) {
DeleteTransferPanel( DeleteTransferPanel(
transferName = selectedTransfer.transferName, transferName = deleteTarget.transferName,
isDeleting = state.isDeleting, isDeleting = state.isDeleting,
onCancel = onDismissDelete, onCancel = onDismissDelete,
onConfirm = onConfirmDelete, onConfirm = onConfirmDelete,

View File

@@ -44,6 +44,7 @@ data class SendState(
val receiversByTransfer: Map<ULong, List<ReceiverRequestModel>> = emptyMap(), val receiversByTransfer: Map<ULong, List<ReceiverRequestModel>> = emptyMap(),
val isLoadingReceivers: Boolean = false, val isLoadingReceivers: Boolean = false,
val isDeleteConfirmationOpen: Boolean = false, val isDeleteConfirmationOpen: Boolean = false,
val deleteTargetTransferId: ULong? = null,
val isDeleting: Boolean = false, val isDeleting: Boolean = false,
) { ) {
val selectedFile: PickedShareFile? get() = selectedFiles.singleOrNull() val selectedFile: PickedShareFile? get() = selectedFiles.singleOrNull()
@@ -221,12 +222,19 @@ class SendViewModel(
refreshReceivers(transferId) refreshReceivers(transferId)
} }
fun closeDetailPanel() = _state.update { it.copy(detailPanel = null) } 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() { 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() { fun confirmDeleteTransfer() {
val transferId = _state.value.selectedTransferId ?: return val transferId = _state.value.deleteTargetTransferId ?: return
if (_state.value.isDeleting) return if (_state.value.isDeleting) return
viewModelScope.launch { viewModelScope.launch {
_state.update { it.copy(isDeleting = true) } _state.update { it.copy(isDeleting = true) }
@@ -239,6 +247,7 @@ class SendViewModel(
detailPanel = null, detailPanel = null,
receiverHistory = emptyList(), receiverHistory = emptyList(),
isDeleteConfirmationOpen = false, isDeleteConfirmationOpen = false,
deleteTargetTransferId = null,
isDeleting = false, isDeleting = false,
) )
} }
@@ -285,6 +294,7 @@ class SendViewModel(
onSuccess = { share -> onSuccess = { share ->
current.selectedFiles.firstNotNullOfOrNull { it.thumbnailBytes } current.selectedFiles.firstNotNullOfOrNull { it.thumbnailBytes }
?.let { filePreviewRepository.save(share.transferId, it) } ?.let { filePreviewRepository.save(share.transferId, it) }
repository.refresh()
_state.update { _state.update {
it.copy( it.copy(
isComposerOpen = false, isComposerOpen = false,
@@ -292,8 +302,11 @@ class SendViewModel(
transferName = "", transferName = "",
accessPolicy = ShareAccessPolicy.RequireApproval, accessPolicy = ShareAccessPolicy.RequireApproval,
isSharing = false, isSharing = false,
selectedTransferId = share.transferId,
detailPanel = TransferDetailPanel.Share,
) )
} }
refreshReceivers(share.transferId)
messages.show(UiMessage(UiText.Resource(Res.string.send_transfer_created), UiMessageTone.Success)) messages.show(UiMessage(UiText.Resource(Res.string.send_transfer_created), UiMessageTone.Success))
}, },
onFailure = { error -> 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<PickedShareFile>): String = when { private fun defaultTransferName(files: List<PickedShareFile>): String = when {
files.isEmpty() -> "" files.isEmpty() -> ""
files.size == 1 && files.first().isDirectory -> files.first().displayName files.size == 1 && files.first().isDirectory -> files.first().displayName

View File

@@ -17,6 +17,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.RadioButton import androidx.compose.material3.RadioButton
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -174,22 +175,51 @@ private fun ReviewFileStep(
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
) )
} }
if (windowClass == WindowClass.Phone) { Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
ShareButton(state, coreInitialized, onCreateShare, Modifier.fillMaxWidth()) ShareButton(state, coreInitialized, onCreateShare, Modifier.fillMaxWidth())
QuietButton(stringResource(Res.string.button_change_files), onClick = onSelectFile, modifier = Modifier.fillMaxWidth(), enabled = !state.isSharing) Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
QuietButton(stringResource(Res.string.button_choose_folder), onClick = onSelectFolder, modifier = Modifier.fillMaxWidth(), enabled = !state.isSharing) 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,
)
} }
} 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)
} }
} }
} }
@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 @Composable
private fun ShareButton(state: SendState, coreInitialized: Boolean, onCreateShare: () -> Unit, modifier: Modifier = Modifier) { private fun ShareButton(state: SendState, coreInitialized: Boolean, onCreateShare: () -> Unit, modifier: Modifier = Modifier) {
PrimaryButton( PrimaryButton(

View File

@@ -84,6 +84,7 @@ internal fun TransferDetails(
onActivity: () -> Unit, onActivity: () -> Unit,
onReceivers: () -> Unit, onReceivers: () -> Unit,
onShare: () -> Unit, onShare: () -> Unit,
onStopSharing: () -> Unit,
onDelete: () -> Unit, onDelete: () -> Unit,
) { ) {
LazyColumn( LazyColumn(
@@ -100,8 +101,14 @@ internal fun TransferDetails(
style = MaterialTheme.typography.headlineSmall, style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
) )
IconButton(onClick = onDelete) { if (transfer.status in setOf(TransferStatus.Importing, TransferStatus.Sharing)) {
PlatformIcon(AppIcon.Delete, stringResource(Res.string.button_delete_transfer), tint = LocalVniDropColors.current.destructiveDefault) IconButton(onClick = onShare) {
PlatformIcon(
AppIcon.Share,
stringResource(Res.string.transfer_share_title),
tint = LocalVniDropColors.current.brandLink,
)
}
} }
} }
} }
@@ -130,31 +137,30 @@ internal fun TransferDetails(
count = pendingReceivers + completedReceivers, count = pendingReceivers + completedReceivers,
onClick = onReceivers, onClick = onReceivers,
) )
when (transfer.status) { }
TransferStatus.Sharing -> { }
HorizontalDivider(color = LocalVniDropColors.current.borderDefault) }
DetailDestination( item {
title = stringResource(Res.string.transfer_share_title), Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
description = stringResource(Res.string.transfer_share_description), if (transfer.status == TransferStatus.Sharing) {
onClick = onShare, DestructiveButton(
stringResource(Res.string.send_stop_sharing),
onClick = onStopSharing,
modifier = Modifier.fillMaxWidth(),
leadingIcon = {
PlatformIcon(AppIcon.StopCircle, null, modifier = Modifier.size(18.dp))
},
) )
} }
TransferStatus.Importing -> { DestructiveButton(
HorizontalDivider(color = LocalVniDropColors.current.borderDefault) stringResource(Res.string.button_delete_transfer),
DetailDestination( onClick = onDelete,
title = stringResource(Res.string.transfer_share_title), modifier = Modifier.fillMaxWidth(),
description = stringResource(Res.string.transfer_event_preparing), leadingIcon = {
PlatformIcon(AppIcon.Delete, null, modifier = Modifier.size(18.dp))
},
) )
} }
TransferStatus.Receiving,
TransferStatus.Done,
TransferStatus.Failed,
TransferStatus.Cancelled,
TransferStatus.Stopped,
-> Unit
}
}
}
} }
} }
} }

View File

@@ -42,5 +42,7 @@ fun SettingsRoute(viewModel: SettingsViewModel, windowClass: WindowClass) {
onSubmitBugReport = viewModel::submitBugReport, onSubmitBugReport = viewModel::submitBugReport,
onDeleteAllTransfers = viewModel::deleteAllTransfers, onDeleteAllTransfers = viewModel::deleteAllTransfers,
onClearTransferCache = viewModel::clearTransferCache, onClearTransferCache = viewModel::clearTransferCache,
onFreeUpSpace = viewModel::freeUpSpace,
onRefreshStorage = viewModel::loadStorageUsage,
) )
} }

View File

@@ -32,6 +32,8 @@ fun SettingsScreen(
onSubmitBugReport: () -> Unit, onSubmitBugReport: () -> Unit,
onDeleteAllTransfers: () -> Unit = {}, onDeleteAllTransfers: () -> Unit = {},
onClearTransferCache: () -> Unit = {}, onClearTransferCache: () -> Unit = {},
onFreeUpSpace: () -> Unit = {},
onRefreshStorage: () -> Unit = {},
onRelayModeChanged: (RelayMode) -> Unit = {}, onRelayModeChanged: (RelayMode) -> Unit = {},
onRelayUrlChanged: (Int, String) -> Unit = { _, _ -> }, onRelayUrlChanged: (Int, String) -> Unit = { _, _ -> },
onAddRelayUrl: () -> Unit = {}, onAddRelayUrl: () -> Unit = {},
@@ -69,6 +71,8 @@ fun SettingsScreen(
onSubmitBugReport = onSubmitBugReport, onSubmitBugReport = onSubmitBugReport,
onDeleteAllTransfers = onDeleteAllTransfers, onDeleteAllTransfers = onDeleteAllTransfers,
onClearTransferCache = onClearTransferCache, onClearTransferCache = onClearTransferCache,
onFreeUpSpace = onFreeUpSpace,
onRefreshStorage = onRefreshStorage,
onRelayModeChanged = onRelayModeChanged, onRelayModeChanged = onRelayModeChanged,
onRelayUrlChanged = onRelayUrlChanged, onRelayUrlChanged = onRelayUrlChanged,
onAddRelayUrl = onAddRelayUrl, onAddRelayUrl = onAddRelayUrl,
@@ -110,6 +114,8 @@ fun SettingsScreen(
onSubmitBugReport = onSubmitBugReport, onSubmitBugReport = onSubmitBugReport,
onDeleteAllTransfers = onDeleteAllTransfers, onDeleteAllTransfers = onDeleteAllTransfers,
onClearTransferCache = onClearTransferCache, onClearTransferCache = onClearTransferCache,
onFreeUpSpace = onFreeUpSpace,
onRefreshStorage = onRefreshStorage,
onRelayModeChanged = onRelayModeChanged, onRelayModeChanged = onRelayModeChanged,
onRelayUrlChanged = onRelayUrlChanged, onRelayUrlChanged = onRelayUrlChanged,
onAddRelayUrl = onAddRelayUrl, onAddRelayUrl = onAddRelayUrl,
@@ -143,6 +149,8 @@ private fun SettingsSectionContent(
onSubmitBugReport: () -> Unit, onSubmitBugReport: () -> Unit,
onDeleteAllTransfers: () -> Unit, onDeleteAllTransfers: () -> Unit,
onClearTransferCache: () -> Unit, onClearTransferCache: () -> Unit,
onFreeUpSpace: () -> Unit,
onRefreshStorage: () -> Unit,
onRelayModeChanged: (RelayMode) -> Unit, onRelayModeChanged: (RelayMode) -> Unit,
onRelayUrlChanged: (Int, String) -> Unit, onRelayUrlChanged: (Int, String) -> Unit,
onAddRelayUrl: () -> Unit, onAddRelayUrl: () -> Unit,
@@ -169,6 +177,8 @@ private fun SettingsSectionContent(
windowClass, windowClass,
onDeleteAllTransfers, onDeleteAllTransfers,
onClearTransferCache, onClearTransferCache,
onFreeUpSpace,
onRefreshStorage,
onBack, onBack,
showBack, showBack,
) )

View File

@@ -25,6 +25,7 @@ import com.vnidrop.app.ui.feedback.UiMessage
import com.vnidrop.app.ui.feedback.UiMessageController import com.vnidrop.app.ui.feedback.UiMessageController
import com.vnidrop.app.ui.feedback.UiMessageTone import com.vnidrop.app.ui.feedback.UiMessageTone
import com.vnidrop.app.ui.feedback.UiText import com.vnidrop.app.ui.feedback.UiText
import com.vnidrop.app.ui.state.formatBytes
import com.vnidrop.app.ui.theme.ThemeMode import com.vnidrop.app.ui.theme.ThemeMode
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.Channel 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.relay_settings_applied
import vnidrop.shared.generated.resources.storage_transfer_cache_cleared import vnidrop.shared.generated.resources.storage_transfer_cache_cleared
import vnidrop.shared.generated.resources.storage_transfers_deleted 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 { enum class SettingsSection {
Overview, Overview,
@@ -112,8 +115,10 @@ data class SettingsState(
val bugLogPreviewBytes: Int = 0, val bugLogPreviewBytes: Int = 0,
val storage: StorageBreakdown? = null, val storage: StorageBreakdown? = null,
val isCalculatingStorage: Boolean = false, val isCalculatingStorage: Boolean = false,
val storageLoadFailed: Boolean = false,
val isDeletingTransfers: Boolean = false, val isDeletingTransfers: Boolean = false,
val isClearingTransferCache: Boolean = false, val isClearingTransferCache: Boolean = false,
val isCleaningStorage: Boolean = false,
) { ) {
val hasRelaySettingsChanges: Boolean val hasRelaySettingsChanges: Boolean
get() = relayMode != savedRelaySettings.mode || get() = relayMode != savedRelaySettings.mode ||
@@ -188,6 +193,14 @@ class SettingsViewModel(
endpointId = coreState.status?.endpointId, endpointId = coreState.status?.endpointId,
) )
} }
if (
coreState.isInitialized &&
_state.value.selectedSection == SettingsSection.Storage &&
_state.value.storage == null &&
_state.value.storageLoadFailed
) {
loadStorageUsage()
}
} }
} }
refreshNotificationPermission() refreshNotificationPermission()
@@ -209,7 +222,7 @@ class SettingsViewModel(
fun loadStorageUsage() { fun loadStorageUsage() {
if (_state.value.isCalculatingStorage) return if (_state.value.isCalculatingStorage) return
viewModelScope.launch { viewModelScope.launch {
_state.update { it.copy(isCalculatingStorage = true) } _state.update { it.copy(isCalculatingStorage = true, storageLoadFailed = false) }
try { try {
val receiveFolder = _state.value.receiveFolder ?: fileSystemService.defaultReceiveFolder() val receiveFolder = _state.value.receiveFolder ?: fileSystemService.defaultReceiveFolder()
val coreUsage = repository.storageUsage().getOrThrow() val coreUsage = repository.storageUsage().getOrThrow()
@@ -227,12 +240,42 @@ class SettingsViewModel(
inaccessibleReceivedFileCount = received.inaccessibleCount, inaccessibleReceivedFileCount = received.inaccessibleCount,
), ),
isCalculatingStorage = false, isCalculatingStorage = false,
storageLoadFailed = false,
) )
} }
} catch (error: CancellationException) { } catch (error: CancellationException) {
throw error throw error
} catch (error: Throwable) { } 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) messages.error(error)
} }
} }

View File

@@ -7,9 +7,8 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size 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.CircularProgressIndicator
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable 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.AdaptiveDrawer
import com.vnidrop.app.ui.components.DestructiveButton import com.vnidrop.app.ui.components.DestructiveButton
import com.vnidrop.app.ui.components.SecondaryButton 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.formatBytes
import com.vnidrop.app.ui.state.WindowClass import com.vnidrop.app.ui.state.WindowClass
import com.vnidrop.app.ui.theme.LocalVniDropColors 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
import vnidrop.shared.generated.resources.storage_clear_transfer_cache_description 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_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
import vnidrop.shared.generated.resources.storage_delete_transfers_description import vnidrop.shared.generated.resources.storage_delete_transfers_description
import vnidrop.shared.generated.resources.storage_deleting 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_total
import vnidrop.shared.generated.resources.storage_received_files import vnidrop.shared.generated.resources.storage_received_files
import vnidrop.shared.generated.resources.storage_temporary import vnidrop.shared.generated.resources.storage_temporary
@@ -52,6 +60,8 @@ internal fun StorageSettings(
windowClass: WindowClass, windowClass: WindowClass,
onDeleteAllTransfers: () -> Unit, onDeleteAllTransfers: () -> Unit,
onClearTransferCache: () -> Unit, onClearTransferCache: () -> Unit,
onFreeUpSpace: () -> Unit,
onRefreshStorage: () -> Unit,
onBack: () -> Unit, onBack: () -> Unit,
showBack: Boolean, showBack: Boolean,
) { ) {
@@ -59,8 +69,37 @@ internal fun StorageSettings(
var showClearCacheConfirmation by rememberSaveable { mutableStateOf(false) } var showClearCacheConfirmation by rememberSaveable { mutableStateOf(false) }
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
SettingsTopBar(stringResource(Res.string.storage_title), onBack, showBack) 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 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 { SettingsGroup {
StorageRow( StorageRow(
title = stringResource(Res.string.storage_calculating), 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( SecondaryButton(
text = stringResource( text = stringResource(
if (state.isClearingTransferCache) { if (state.isClearingTransferCache) {
@@ -97,16 +160,27 @@ internal fun StorageSettings(
!state.isClearingTransferCache && !state.isClearingTransferCache &&
!state.isCalculatingStorage && !state.isCalculatingStorage &&
!state.hasActiveNetworkWork, !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 }, onClick = { showDeleteConfirmation = true },
enabled = !state.isDeletingTransfers && enabled = !state.isDeletingTransfers &&
!state.isClearingTransferCache && !state.isClearingTransferCache &&
!state.isCalculatingStorage, !state.isCalculatingStorage,
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error), leadingIcon = {
) { PlatformIcon(AppIcon.Delete, null, modifier = Modifier.size(18.dp))
Text(stringResource(if (state.isDeletingTransfers) Res.string.storage_deleting else Res.string.storage_delete_transfers)) },
} )
Text(
stringResource(Res.string.storage_delete_transfers_caption),
style = MaterialTheme.typography.bodySmall,
color = LocalVniDropColors.current.foregroundLighter,
)
Text( Text(
stringResource(Res.string.storage_footer), stringResource(Res.string.storage_footer),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,

View File

@@ -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<Transfer>,
published: Set<String>,
): List<PlannedTransferNotification> = 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<ReceiverRequestModel>,
published: Set<String>,
): List<PlannedTransferNotification> = 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<String>()
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<Transfer>) {
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"
}

View File

@@ -1,6 +1,8 @@
package com.vnidrop.app.ui.components package com.vnidrop.app.ui.components
import androidx.compose.foundation.layout.heightIn 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.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
@@ -17,7 +19,13 @@ import com.vnidrop.app.ui.platform.LocalUiPlatform
import com.vnidrop.app.ui.theme.LocalVniDropColors import com.vnidrop.app.ui.theme.LocalVniDropColors
@Composable @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 val desktop = LocalUiPlatform.current.isDesktop
Button( Button(
onClick = onClick, onClick = onClick,
@@ -26,12 +34,22 @@ fun PrimaryButton(text: String, onClick: () -> Unit, modifier: Modifier = Modifi
shape = RoundedCornerShape(if (desktop) 6.dp else 8.dp), shape = RoundedCornerShape(if (desktop) 6.dp else 8.dp),
colors = ButtonDefaults.buttonColors(containerColor = LocalVniDropColors.current.brandButton, contentColor = Color.White), 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) Text(text, maxLines = 1, overflow = TextOverflow.Ellipsis)
} }
} }
@Composable @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 val desktop = LocalUiPlatform.current.isDesktop
OutlinedButton( OutlinedButton(
onClick = onClick, 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), modifier = modifier.heightIn(min = if (desktop) 36.dp else 44.dp),
shape = RoundedCornerShape(if (desktop) 6.dp else 8.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) Text(text, maxLines = 1, overflow = TextOverflow.Ellipsis)
} }
} }
@@ -65,7 +87,13 @@ fun DestructiveQuietButton(text: String, onClick: () -> Unit, modifier: Modifier
} }
@Composable @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 val desktop = LocalUiPlatform.current.isDesktop
Button( Button(
onClick = onClick, onClick = onClick,
@@ -77,6 +105,10 @@ fun DestructiveButton(text: String, onClick: () -> Unit, modifier: Modifier = Mo
contentColor = Color.White, contentColor = Color.White,
), ),
) { ) {
leadingIcon?.let {
it()
Spacer(Modifier.width(8.dp))
}
Text(text, maxLines = 1, overflow = TextOverflow.Ellipsis) Text(text, maxLines = 1, overflow = TextOverflow.Ellipsis)
} }
} }

View File

@@ -10,7 +10,7 @@ import kotlinx.coroutines.flow.receiveAsFlow
import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.StringResource
sealed interface UiText { sealed interface UiText {
data class Resource(val resource: StringResource) : UiText data class Resource(val resource: StringResource, val formatArgs: List<Any> = emptyList()) : UiText
data class Dynamic(val value: String) : UiText data class Dynamic(val value: String) : UiText
} }

View File

@@ -138,5 +138,5 @@ private fun DismissButton(onClick: () -> Unit) {
private suspend fun UiText.resolve(): String = when (this) { private suspend fun UiText.resolve(): String = when (this) {
is UiText.Dynamic -> value is UiText.Dynamic -> value
is UiText.Resource -> getString(resource) is UiText.Resource -> getString(resource, *formatArgs.toTypedArray())
} }

View File

@@ -39,12 +39,19 @@ internal enum class AppIcon(
Info(Res.drawable.icon_material_info, Res.drawable.icon_fluent_info, Res.drawable.icon_lucide_info), 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), 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), 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), 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), 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), 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), 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), 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), 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), 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), Shield(Res.drawable.icon_material_shield, Res.drawable.icon_fluent_shield, Res.drawable.icon_lucide_shield),
ShieldCheck( ShieldCheck(
@@ -52,6 +59,11 @@ internal enum class AppIcon(
Res.drawable.icon_fluent_shield_check, Res.drawable.icon_fluent_shield_check,
Res.drawable.icon_lucide_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), 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), 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), Sync(Res.drawable.icon_material_sync, Res.drawable.icon_fluent_sync, Res.drawable.icon_lucide_sync),

View File

@@ -5,6 +5,7 @@ import com.vnidrop.app.PlatformEnvironment
import com.vnidrop.app.core.CoreState import com.vnidrop.app.core.CoreState
import com.vnidrop.app.core.CoreStatus import com.vnidrop.app.core.CoreStatus
import com.vnidrop.app.core.CoreSignal import com.vnidrop.app.core.CoreSignal
import com.vnidrop.app.core.CoreStorageUsageModel
import com.vnidrop.app.core.PickedShareFile import com.vnidrop.app.core.PickedShareFile
import com.vnidrop.app.core.ReceiveFolder import com.vnidrop.app.core.ReceiveFolder
import com.vnidrop.app.core.ReceiveFolderKind 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.ReceiveHistoryDeleteTarget
import com.vnidrop.app.feature.receive.ReceiveViewModel import com.vnidrop.app.feature.receive.ReceiveViewModel
import com.vnidrop.app.feature.send.SendViewModel 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.SettingsSection
import com.vnidrop.app.feature.settings.RelaySettingsApplyError import com.vnidrop.app.feature.settings.RelaySettingsApplyError
import com.vnidrop.app.feature.settings.RelaySettingsInputError import com.vnidrop.app.feature.settings.RelaySettingsInputError
@@ -168,6 +170,43 @@ class ViewModelsTest {
assertEquals(0, core.clearTransferCacheCount) 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 @Test
fun settingsDeleteAllTransfersImmediatelyClearsUnusedCache() = runTest { fun settingsDeleteAllTransfersImmediatelyClearsUnusedCache() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler)) Dispatchers.setMain(StandardTestDispatcher(testScheduler))
@@ -533,10 +572,33 @@ class ViewModelsTest {
assertEquals(null, viewModel.state.value.selectedFile) assertEquals(null, viewModel.state.value.selectedFile)
assertEquals(ShareAccessPolicy.AnyoneWithTransfer, core.lastShareAccessPolicy) assertEquals(ShareAccessPolicy.AnyoneWithTransfer, core.lastShareAccessPolicy)
assertEquals(7UL, core.state.value.transfers.first().transferId) 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)) assertContentEquals(thumbnail, previews.previews.value.getValue(7UL))
assertEquals(listOf(selected), fileSystem.discardedPickedFiles) 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 @Test
fun sendComposerStaysOpenWhenShareCreationFails() = runTest { fun sendComposerStaysOpenWhenShareCreationFails() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler)) Dispatchers.setMain(StandardTestDispatcher(testScheduler))

View File

@@ -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,
)
}

View File

@@ -48,6 +48,9 @@ class FakeCoreGateway : CoreGateway {
private var receiveGate: CompletableDeferred<Unit>? = null private var receiveGate: CompletableDeferred<Unit>? = null
var deleteResult: Result<Unit> = Result.success(Unit) var deleteResult: Result<Unit> = Result.success(Unit)
var clearTransferCacheResult: Result<ULong> = Result.success(0UL) var clearTransferCacheResult: Result<ULong> = Result.success(0UL)
var storageUsageResult: Result<CoreStorageUsageModel> = Result.success(
CoreStorageUsageModel(0UL, 0UL, 0UL, 0UL, 0UL),
)
var clearReceiveHistoryResult: Result<ULong> = Result.success(0UL) var clearReceiveHistoryResult: Result<ULong> = Result.success(0UL)
val deletedTransfers = mutableListOf<ULong>() val deletedTransfers = mutableListOf<ULong>()
val cancelledTransfers = mutableListOf<ULong>() val cancelledTransfers = mutableListOf<ULong>()
@@ -153,9 +156,7 @@ class FakeCoreGateway : CoreGateway {
awaitReceiveIfNeeded() awaitReceiveIfNeeded()
return receiveResult return receiveResult
} }
override suspend fun storageUsage(): Result<CoreStorageUsageModel> = Result.success( override suspend fun storageUsage(): Result<CoreStorageUsageModel> = storageUsageResult
CoreStorageUsageModel(0UL, 0UL, 0UL, 0UL, 0UL),
)
override suspend fun clearTransferCache(): Result<ULong> { override suspend fun clearTransferCache(): Result<ULong> {
clearTransferCacheCount += 1 clearTransferCacheCount += 1
return clearTransferCacheResult return clearTransferCacheResult
@@ -248,6 +249,8 @@ class FakeFileSystemService(
var supportsCustomFolders = true var supportsCustomFolders = true
var effectiveFolder: ReceiveFolder? = null var effectiveFolder: ReceiveFolder? = null
var canRevealFolder = false var canRevealFolder = false
var reclaimedTemporaryBytes = 0UL
var reclaimTemporaryStorageCount = 0
var revealFolderResult: Result<Unit> = Result.success(Unit) var revealFolderResult: Result<Unit> = Result.success(Unit)
val revealedFolders = mutableListOf<ReceiveFolder>() val revealedFolders = mutableListOf<ReceiveFolder>()
val discardedPickedFiles = mutableListOf<PickedShareFile>() val discardedPickedFiles = mutableListOf<PickedShareFile>()
@@ -259,6 +262,10 @@ class FakeFileSystemService(
override suspend fun inspectReceivedArtifacts(artifacts: List<ReceivedArtifactModel>) = override suspend fun inspectReceivedArtifacts(artifacts: List<ReceivedArtifactModel>) =
ReceivedStorageInspection(artifacts.fold(0UL) { total, item -> total + item.logicalSize }, artifacts.size, 0, 0) ReceivedStorageInspection(artifacts.fold(0UL) { total, item -> total + item.logicalSize }, artifacts.size, 0, 0)
override suspend fun temporaryUsage(receiveFolder: ReceiveFolder): ULong = 0UL 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 createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSinkV2? = null
override fun canRevealReceiveFolder(folder: ReceiveFolder) = canRevealFolder override fun canRevealReceiveFolder(folder: ReceiveFolder) = canRevealFolder
override suspend fun revealReceiveFolder(folder: ReceiveFolder): Result<Unit> { override suspend fun revealReceiveFolder(folder: ReceiveFolder): Result<Unit> {

View File

@@ -48,6 +48,9 @@ private class JvmFileSystemService : FileSystemService {
return desktopTemporaryUsage(receiveFolder) return desktopTemporaryUsage(receiveFolder)
} }
override suspend fun reclaimTemporaryStorage(appDataDir: String, receiveFolder: ReceiveFolder): ULong =
desktopReclaimTemporaryStorage(appDataDir, receiveFolder)
override fun createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSinkV2? = null override fun createReceiveOutputSink(folder: ReceiveFolder): ReceiveOutputSinkV2? = null
override suspend fun sharePickedFiles( override suspend fun sharePickedFiles(
@@ -88,3 +91,35 @@ internal fun desktopTemporaryUsage(receiveFolder: ReceiveFolder): ULong {
} }
}.getOrDefault(0UL) }.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
}

View File

@@ -5,6 +5,8 @@ import kotlin.io.path.createDirectories
import kotlin.io.path.createTempDirectory import kotlin.io.path.createTempDirectory
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class FileSystemServiceTest { class FileSystemServiceTest {
@Test @Test
@@ -26,4 +28,32 @@ class FileSystemServiceTest {
root.toFile().deleteRecursively() 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()
}
}
} }

Some files were not shown because too many files have changed in this diff Show More