diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..8da4981 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "Bash(swift test *)" + ] + } +} diff --git a/DESIGN-DEVICE-HISTORY.md b/DESIGN-DEVICE-HISTORY.md index ea7c9c9..225ba26 100644 --- a/DESIGN-DEVICE-HISTORY.md +++ b/DESIGN-DEVICE-HISTORY.md @@ -403,13 +403,27 @@ Dialing contacts on launch tells them when the app was opened and reveals the device's address to them — precisely the leak §1 avoids by refusing background presence polling. The pull is therefore bounded rather than automatic: -- It runs only for contacts the user has explicitly marked as allowed to be - checked, or on an explicit "check for incoming" action. +- It is **off by default**, behind a single setting whose own footer states the + cost, plus an explicit "Check now" action that works regardless. - It never runs in the background, only on an actual foreground transition. -- It is rate-limited per contact, so repeated app switching does not turn into a - presence beacon. +- It is rate-limited per contact (5 minutes), so repeated app switching does not + turn into a presence beacon. -### 11.4 Scope statement for the UI +**Deviation from the original draft, as built.** This specified a *per-contact* +opt-in. What shipped is one global toggle, which is coarser: enabling it polls +every contact rather than a chosen few. Per-contact control needs a schema +column and a control on each device's detail screen, and the global switch with +an honest footer covers the same threat — the user still decides whether their +app-open times are revealed at all. Worth revisiting if anyone keeps contacts +they would rather not signal to. + +### 11.4 What the sender sees + +A held offer is listed on the sender's device with its target, and withdrawing +it is cancelling the transfer — stopping the share deletes the waiting ticket, +so a cancelled transfer can never be collected afterwards. + +### 11.5 Scope statement for the UI Mobile-to-mobile transfer with both apps closed is not supported and must not be implied. The contact list distinguishes "reachable now" from "will be delivered diff --git a/apple/Tests/ContactsModelTests.swift b/apple/Tests/ContactsModelTests.swift index 22cee36..e0ee2a7 100644 --- a/apple/Tests/ContactsModelTests.swift +++ b/apple/Tests/ContactsModelTests.swift @@ -235,7 +235,13 @@ final class ContactsModelTests: XCTestCase { fileSystemService: files ) gateway.sendToContactResult = .success( - Share(transferId: 1, ticket: "vnd1:x", transferName: "doc", contentHash: "h", fileCount: 1, totalSize: 2) + ContactSendOutcome( + share: Share( + transferId: 1, ticket: "vnd1:x", transferName: "doc", + contentHash: "h", fileCount: 1, totalSize: 2 + ), + delivered: true + ) ) model.chooseFilesToSend(to: "peer") @@ -276,6 +282,98 @@ final class ContactsModelTests: XCTestCase { XCTAssertTrue(gateway.sentToContacts.isEmpty) } + /// Polling is opt-in: it tells every contact the app was opened. + func testForegroundCheckIsSkippedUnlessEnabled() async { + let gateway = FakeCoreGateway() + let (model, _) = makeModel(gateway) + + await model.checkForOffersOnForeground() + + XCTAssertEqual(gateway.pollCount, 0) + } + + func testForegroundCheckRunsOnceEnabled() async { + let gateway = FakeCoreGateway() + let (model, preferences) = makeModel(gateway) + + model.setCheckForOffersOnOpen(true) + await model.checkForOffersOnForeground() + + XCTAssertEqual(gateway.pollCount, 1) + XCTAssertTrue(preferences.preferences.checkForOffersOnOpen) + } + + /// The explicit "check now" ignores the setting: the user just asked. + func testExplicitCheckRunsEvenWhenTheSettingIsOff() async { + let gateway = FakeCoreGateway() + gateway.pollResult = .success(2) + let (model, _) = makeModel(gateway) + + let collected = await model.collectWaitingOffers() + + XCTAssertEqual(collected, 2) + XCTAssertEqual(gateway.pollCount, 1) + } + + /// A transfer that could not be delivered is reported as waiting, not as a + /// success nobody has received. + func testAnUndeliveredSendIsReportedAsWaiting() async { + let gateway = FakeCoreGateway() + let files = FakeFileSystemService() + let defaults = UserDefaults(suiteName: "contacts-held-\(UUID().uuidString)")! + let preferences = AppPreferencesRepository( + defaults: defaults, + fallback: AppPreferencesDefaults( + username: "tester", + receiveFolder: ReceiveFolder(kind: .fileSystemPath, value: "/tmp", displayName: "Downloads"), + themeMode: .system + ) + ) + let messages = UiMessageController() + let model = ContactsModel( + repository: gateway, + messages: messages, + preferences: preferences, + fileSystemService: files + ) + gateway.sendToContactResult = .success( + ContactSendOutcome( + share: Share( + transferId: 1, ticket: "vnd1:x", transferName: "doc", + contentHash: "h", fileCount: 1, totalSize: 2 + ), + delivered: false + ) + ) + + model.chooseFilesToSend(to: "peer") + await model.onFilesPicked([ + PickedShareFile(value: "/tmp/doc.txt", displayName: "doc.txt", isDirectory: false) + ]) + + XCTAssertEqual(messages.current?.tone, .info) + } + + func testHeldOffersAreLoadedForDisplay() async { + let gateway = FakeCoreGateway() + gateway.heldOffersResult = .success([ + HeldOfferModel( + offerId: "held-1", + endpointId: "peer", + transferId: 1, + transferName: "doc", + fileCount: 1, + totalBytes: 2, + createdAt: 0 + ) + ]) + let (model, _) = makeModel(gateway) + + await model.refresh() + + XCTAssertEqual(model.state.heldOffers.map(\.offerId), ["held-1"]) + } + func testUnreachableContactIsSurfacedForRepairing() async { let gateway = FakeCoreGateway() gateway.contactsResult = .success([contact("peer", canSend: false)]) diff --git a/apple/Tests/Fakes.swift b/apple/Tests/Fakes.swift index 5fb8798..925df28 100644 --- a/apple/Tests/Fakes.swift +++ b/apple/Tests/Fakes.swift @@ -90,7 +90,10 @@ final class FakeCoreGateway: CoreGateway { var respondToPairingResult: Result = .success(true) /// Ticket handed back when an offer is accepted; nil models a declined one. var offerTicket: String? = "vnd1:offered" - var sendToContactResult: Result = .failure(TestError.unimplemented) + var sendToContactResult: Result = .failure(TestError.unimplemented) + var heldOffersResult: Result<[HeldOfferModel], Error> = .success([]) + var pollResult: Result = .success(0) + private(set) var pollCount = 0 var forgetContactResult: Result = .success(()) var blockedResult: Result<[String], Error> = .success([]) @@ -129,10 +132,15 @@ final class FakeCoreGateway: CoreGateway { sources: [ShareSource], transferName: String, senderName: String - ) async -> Result { + ) async -> Result { sentToContacts.append(endpointId) return sendToContactResult } + func heldOffers() async -> Result<[HeldOfferModel], Error> { heldOffersResult } + func pollContactsForOffers() async -> Result { + pollCount += 1 + return pollResult + } func forgetContact(endpointId: String) async -> Result { forgottenContacts.append(endpointId) return forgetContactResult @@ -168,13 +176,14 @@ final class FakeFileSystemService: FileSystemService { func canRevealReceiveFolder(_ folder: ReceiveFolder) -> Bool { false } private(set) var shareDestinations: [ShareDestination] = [] - func sharePickedFiles(repository: CoreGateway, files: [PickedShareFile], transferName: String, senderName: String, destination: ShareDestination) async -> Result { + func sharePickedFiles(repository: CoreGateway, files: [PickedShareFile], transferName: String, senderName: String, destination: ShareDestination) async -> Result { shareDestinations.append(destination) switch destination { case .invitation(let accessPolicy): return await repository.shareSources( [], transferName: transferName, senderName: senderName, accessPolicy: accessPolicy ) + .map { ContactSendOutcome(share: $0, delivered: true) } case .contact(let endpointId): return await repository.sendToContact( endpointId: endpointId, sources: [], transferName: transferName, senderName: senderName diff --git a/apple/VniDrop/App/RootView.swift b/apple/VniDrop/App/RootView.swift index abc60a0..19bc71f 100644 --- a/apple/VniDrop/App/RootView.swift +++ b/apple/VniDrop/App/RootView.swift @@ -93,6 +93,9 @@ struct RootView: View { // unfocused/occluded (common on macOS) live events may not have // rendered, leaving progress/status stale. Task { _ = await graph.coreRepository.refresh() } + // Opt-in and foreground-only: collecting transfers held for this + // device also tells every contact that the app was opened. + Task { await graph.contactsModel.checkForOffersOnForeground() } case .background: graph.visibility.setForeground(false) // Hold the process open for iOS's grace window so an active diff --git a/apple/VniDrop/Core/AppPreferences.swift b/apple/VniDrop/Core/AppPreferences.swift index cf718ec..bef80db 100644 --- a/apple/VniDrop/Core/AppPreferences.swift +++ b/apple/VniDrop/Core/AppPreferences.swift @@ -127,6 +127,9 @@ struct AppPreferences: Equatable { /// Devices the user declined to remember. Persisted so a repeat transfer /// with the same device does not re-ask forever. var declinedPairingSuggestions: Set + /// Whether opening the app asks remembered devices for waiting transfers. + /// Off by default: it reveals app-open times to every contact. + var checkForOffersOnOpen: Bool } struct AppPreferencesDefaults { @@ -152,6 +155,7 @@ final class AppPreferencesRepository: ObservableObject { static let relayConfiguration = "relay_configuration" static let grantLifetime = "grant_lifetime" static let declinedPairingSuggestions = "declined_pairing_suggestions" + static let checkForOffersOnOpen = "check_for_offers_on_open" } init(defaults: UserDefaults = .standard, fallback: AppPreferencesDefaults) { @@ -175,7 +179,8 @@ final class AppPreferencesRepository: ObservableObject { diagnosticsInstallId: installId, relayConfiguration: resolveRelayConfiguration(defaults), grantLifetime: grantLifetime, - declinedPairingSuggestions: declined + declinedPairingSuggestions: declined, + checkForOffersOnOpen: defaults.bool(forKey: Key.checkForOffersOnOpen) ) } @@ -237,6 +242,11 @@ final class AppPreferencesRepository: ObservableObject { reload() } + func setCheckForOffersOnOpen(_ enabled: Bool) { + defaults.set(enabled, forKey: Key.checkForOffersOnOpen) + reload() + } + func setGrantLifetime(_ lifetime: GrantLifetimeOption) { defaults.set(lifetime.rawValue, forKey: Key.grantLifetime) reload() diff --git a/apple/VniDrop/Core/CoreGateway.swift b/apple/VniDrop/Core/CoreGateway.swift index e147889..1af3f03 100644 --- a/apple/VniDrop/Core/CoreGateway.swift +++ b/apple/VniDrop/Core/CoreGateway.swift @@ -65,7 +65,14 @@ protocol CoreGateway: AnyObject { sources: [ShareSource], transferName: String, senderName: String - ) async -> Result + ) async -> Result + /// Transfers this device is holding for contacts that were not running. + func heldOffers() async -> Result<[HeldOfferModel], Error> + /// Ask remembered devices whether they hold anything for this one. + /// + /// Only ever called from a foreground transition or an explicit user action: + /// it reveals to every contact that this device is awake. + func pollContactsForOffers() async -> Result func forgetContact(endpointId: String) async -> Result func forgetAllContacts() async -> Result func blockContact(endpointId: String) async -> Result diff --git a/apple/VniDrop/Core/CoreModels.swift b/apple/VniDrop/Core/CoreModels.swift index 89a25cd..c808edd 100644 --- a/apple/VniDrop/Core/CoreModels.swift +++ b/apple/VniDrop/Core/CoreModels.swift @@ -269,6 +269,27 @@ struct IncomingOfferModel: Equatable, Identifiable, Sendable { } } +/// A transfer waiting for its target device to come back online. +struct HeldOfferModel: Equatable, Identifiable, Sendable { + let offerId: String + let endpointId: String + let transferId: UInt64 + let transferName: String + let fileCount: UInt64 + let totalBytes: UInt64 + let createdAt: Int64 + + var id: String { offerId } +} + +/// Outcome of sending straight to a remembered device. +struct ContactSendOutcome: Equatable, Sendable { + let share: Share + /// False when the device was not running: the transfer is held locally and + /// collected the next time that device opens the app. + let delivered: Bool +} + /// How long a remembered device stays reachable while unused. The countdown /// restarts on every transfer. enum GrantLifetimeOption: String, CaseIterable, Identifiable, Sendable { diff --git a/apple/VniDrop/Core/CoreRepository.swift b/apple/VniDrop/Core/CoreRepository.swift index e957efd..c96a113 100644 --- a/apple/VniDrop/Core/CoreRepository.swift +++ b/apple/VniDrop/Core/CoreRepository.swift @@ -335,7 +335,7 @@ final class CoreRepository: ObservableObject, CoreGateway { sources: [ShareSource], transferName: String, senderName: String - ) async -> Result { + ) async -> Result { guard !isNetworkTransitionInProgress else { return .failure(CoreNetworkLifecycleError.transitionInProgress) } @@ -355,10 +355,18 @@ final class CoreRepository: ObservableObject, CoreGateway { accessMode: .approvalRequired ) ) - return result.toModel() + return ContactSendOutcome(share: result.share.toModel(), delivered: result.delivered) } } + func heldOffers() async -> Result<[HeldOfferModel], Error> { + await runCore { try self.requireCore().listHeldOffers().map { $0.toModel() } } + } + + func pollContactsForOffers() async -> Result { + await runCore { try self.requireCore().pollContactsForOffers() } + } + func forgetContact(endpointId: String) async -> Result { await runCore { try self.requireCore().forgetContact(endpointId: endpointId) } } @@ -657,6 +665,20 @@ extension IncomingOffer { } } +extension HeldOfferSummary { + func toModel() -> HeldOfferModel { + HeldOfferModel( + offerId: offerId, + endpointId: endpointId, + transferId: transferId, + transferName: transferName, + fileCount: fileCount, + totalBytes: totalBytes, + createdAt: createdAt + ) + } +} + extension GrantLifetimeOption { func toNative() -> GrantLifetimeSetting { switch self { diff --git a/apple/VniDrop/Core/FileSystemService.swift b/apple/VniDrop/Core/FileSystemService.swift index d6e1af7..35d3f58 100644 --- a/apple/VniDrop/Core/FileSystemService.swift +++ b/apple/VniDrop/Core/FileSystemService.swift @@ -42,7 +42,7 @@ protocol FileSystemService { transferName: String, senderName: String, destination: ShareDestination - ) async -> Result + ) async -> Result } extension FileSystemService { diff --git a/apple/VniDrop/Features/Contacts/ContactsModel.swift b/apple/VniDrop/Features/Contacts/ContactsModel.swift index 2fc0d15..d09da65 100644 --- a/apple/VniDrop/Features/Contacts/ContactsModel.swift +++ b/apple/VniDrop/Features/Contacts/ContactsModel.swift @@ -32,6 +32,10 @@ struct ContactsState: Equatable { var busyEndpoints: Set = [] var busyOfferIds: Set = [] var suggestions: [PairingSuggestion] = [] + /// Transfers this device is holding for contacts that were not running. + var heldOffers: [HeldOfferModel] = [] + var checkForOffersOnOpen = false + var isCheckingForOffers = false var selectedEndpointId: String? var selected: DeviceContact? { @@ -75,6 +79,7 @@ final class ContactsModel: ObservableObject { self.preferences = preferences self.fileSystemService = fileSystemService state.grantLifetime = preferences.preferences.grantLifetime + state.checkForOffersOnOpen = preferences.preferences.checkForOffersOnOpen repository.signals .sink { [weak self] signal in @@ -130,6 +135,9 @@ final class ContactsModel: ObservableObject { if case .success(let blocked) = await repository.blockedContacts() { state.blocked = blocked } + if case .success(let held) = await repository.heldOffers() { + state.heldOffers = held + } state.pendingPairings = await repository.pendingPairings() await refreshOffers() } @@ -205,6 +213,40 @@ final class ContactsModel: ObservableObject { preferences.declinePairingSuggestion(suggestion.endpointId) } + // MARK: - Collecting waiting transfers + + func setCheckForOffersOnOpen(_ enabled: Bool) { + state.checkForOffersOnOpen = enabled + preferences.setCheckForOffersOnOpen(enabled) + } + + /// Called when the app comes to the foreground. + /// + /// Opt-in, because asking every contact whether they have something waiting + /// also tells them the app was opened. Never runs in the background. + func checkForOffersOnForeground() async { + guard state.checkForOffersOnOpen else { return } + _ = await collectWaitingOffers() + } + + /// Explicit "check now". Returns how many transfers were collected so the + /// caller can report an empty result, which a silent refresh cannot. + @discardableResult + func collectWaitingOffers() async -> UInt64 { + guard !state.isCheckingForOffers else { return 0 } + state.isCheckingForOffers = true + defer { state.isCheckingForOffers = false } + + switch await repository.pollContactsForOffers() { + case .success(let collected): + await refreshOffers() + return collected + case .failure(let error): + messages.error(error) + return 0 + } + } + // MARK: - Selection func select(_ endpointId: String?) { state.selectedEndpointId = endpointId } @@ -290,8 +332,13 @@ final class ContactsModel: ObservableObject { ) await fileSystemService.discardPickedFiles(files) switch result { - case .success: - messages.tryShow(UiMessage(text: .resource(L10n.Send.transferCreated), tone: .success)) + case .success(let outcome): + // A closed app is a delay, not a failure: say so rather than + // reporting success for something nobody has received. + let text: UiText = outcome.delivered + ? .resource(L10n.Send.transferCreated) + : .resource(L10n.Contacts.offerHeld) + messages.tryShow(UiMessage(text: text, tone: outcome.delivered ? .success : .info)) await refresh() case .failure(let error): messages.error(error) diff --git a/apple/VniDrop/Features/Contacts/ContactsScreen.swift b/apple/VniDrop/Features/Contacts/ContactsScreen.swift index 6ecf3b1..e894c62 100644 --- a/apple/VniDrop/Features/Contacts/ContactsScreen.swift +++ b/apple/VniDrop/Features/Contacts/ContactsScreen.swift @@ -7,6 +7,8 @@ import SwiftUI /// not part of the send/receive flow. struct ContactsScreen: View { @ObservedObject var model: ContactsModel + /// Reports an empty result, which a silent refresh cannot convey. + let onNothingWaiting: () -> Void var body: some View { Form { @@ -30,6 +32,24 @@ struct ContactsScreen: View { } } + if !model.state.heldOffers.isEmpty { + Section(String(localized: L10n.Contacts.waitingTitle)) { + ForEach(model.state.heldOffers) { offer in + VStack(alignment: .leading, spacing: 2) { + Text(offer.transferName) + Text(String(offer.endpointId.prefix(16))) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } + } + Text(String(localized: L10n.Contacts.waitingHint)) + .font(.footnote) + .foregroundStyle(.secondary) + } + } + if !model.state.blocked.isEmpty { Section(String(localized: L10n.Contacts.blockedTitle)) { ForEach(model.state.blocked, id: \.self) { endpointId in @@ -43,6 +63,18 @@ struct ContactsScreen: View { } } + CollectOffersSection( + enabled: model.state.checkForOffersOnOpen, + isChecking: model.state.isCheckingForOffers, + onToggle: model.setCheckForOffersOnOpen, + onCheckNow: { + Task { + let collected = await model.collectWaitingOffers() + if collected == 0 { onNothingWaiting() } + } + } + ) + GrantLifetimeSection( selection: model.state.grantLifetime, onSelect: model.setGrantLifetime @@ -129,6 +161,36 @@ private struct BlockedRow: View { } } +private struct CollectOffersSection: View { + let enabled: Bool + let isChecking: Bool + let onToggle: (Bool) -> Void + let onCheckNow: () -> Void + + var body: some View { + Section { + Toggle( + String(localized: L10n.Contacts.checkOnOpen), + isOn: Binding(get: { enabled }, set: onToggle) + ) + Button(action: onCheckNow) { + HStack { + Text(String(localized: L10n.Contacts.checkNow)) + if isChecking { + Spacer() + ProgressView().controlSize(.small) + } + } + } + .disabled(isChecking) + } footer: { + // The privacy cost is the point of the setting, so it is stated + // where the switch is, not buried elsewhere. + Text(String(localized: L10n.Contacts.checkOnOpenHint)) + } + } +} + private struct GrantLifetimeSection: View { let selection: GrantLifetimeOption let onSelect: (GrantLifetimeOption) -> Void diff --git a/apple/VniDrop/Features/Send/SendModel.swift b/apple/VniDrop/Features/Send/SendModel.swift index 4ac328d..b5b5279 100644 --- a/apple/VniDrop/Features/Send/SendModel.swift +++ b/apple/VniDrop/Features/Send/SendModel.swift @@ -355,7 +355,7 @@ final class SendModel: ObservableObject { senderName: current.senderName.trimmingCharacters(in: .whitespacesAndNewlines), destination: .invitation(accessPolicy: current.accessPolicy) ) - switch result { + switch result.map(\.share) { case .success(let share): await fileSystemService.discardPickedFiles(current.selectedFiles) if let thumb = current.selectedFiles.compactMap(\.thumbnailData).first { diff --git a/apple/VniDrop/Features/Settings/SettingsModel.swift b/apple/VniDrop/Features/Settings/SettingsModel.swift index 2aab52e..8ae3a32 100644 --- a/apple/VniDrop/Features/Settings/SettingsModel.swift +++ b/apple/VniDrop/Features/Settings/SettingsModel.swift @@ -176,6 +176,12 @@ final class SettingsModel: ObservableObject { loadDeviceInfo() } + /// Surfaces "nothing waiting" from the contacts screen, which has no + /// message controller of its own. + func reportNothingWaiting() { + messages.tryShow(UiMessage(text: .resource(L10n.Contacts.checkNone), tone: .info)) + } + func selectSection(_ section: SettingsSection) { state.selectedSection = section if section == .about || section == .bugReport { diff --git a/apple/VniDrop/Features/Settings/SettingsScreen.swift b/apple/VniDrop/Features/Settings/SettingsScreen.swift index dc05ee5..efec59b 100644 --- a/apple/VniDrop/Features/Settings/SettingsScreen.swift +++ b/apple/VniDrop/Features/Settings/SettingsScreen.swift @@ -93,7 +93,9 @@ struct SettingsScreen: View { // Contacts brings its own Form and push destination, so it is not wrapped // in the shared section chrome. if section == .contacts { - ContactsScreen(model: contacts) + ContactsScreen(model: contacts) { + model.reportNothingWaiting() + } } else { settingsSectionForm(section) } diff --git a/apple/VniDrop/Platform/FileSystemService+iOS.swift b/apple/VniDrop/Platform/FileSystemService+iOS.swift index 5ebf468..efadde5 100644 --- a/apple/VniDrop/Platform/FileSystemService+iOS.swift +++ b/apple/VniDrop/Platform/FileSystemService+iOS.swift @@ -60,7 +60,7 @@ struct IosFileSystemService: FileSystemService { transferName: String, senderName: String, destination: ShareDestination - ) async -> Result { + ) async -> Result { guard !files.isEmpty else { return .failure(InvitationError.shareEmpty) } @@ -70,6 +70,7 @@ struct IosFileSystemService: FileSystemService { return await repository.shareSources( sources, transferName: transferName, senderName: senderName, accessPolicy: accessPolicy ) + .map { ContactSendOutcome(share: $0, delivered: true) } case .contact(let endpointId): return await repository.sendToContact( endpointId: endpointId, sources: sources, diff --git a/apple/VniDrop/Platform/FileSystemService+macOS.swift b/apple/VniDrop/Platform/FileSystemService+macOS.swift index b84eb5f..26aea47 100644 --- a/apple/VniDrop/Platform/FileSystemService+macOS.swift +++ b/apple/VniDrop/Platform/FileSystemService+macOS.swift @@ -40,7 +40,7 @@ struct MacFileSystemService: FileSystemService { transferName: String, senderName: String, destination: ShareDestination - ) async -> Result { + ) async -> Result { guard !files.isEmpty else { return .failure(InvitationError.shareEmpty) } @@ -68,6 +68,7 @@ struct MacFileSystemService: FileSystemService { return await repository.shareSources( sources, transferName: transferName, senderName: senderName, accessPolicy: accessPolicy ) + .map { ContactSendOutcome(share: $0, delivered: true) } case .contact(let endpointId): return await repository.sendToContact( endpointId: endpointId, sources: sources, diff --git a/localization/strings.json b/localization/strings.json index d8b8f3b..5b1f178 100644 --- a/localization/strings.json +++ b/localization/strings.json @@ -5124,6 +5124,104 @@ "other": "{count} дня" } } + }, + "contacts_check_on_open": { + "context": "Devices settings: toggle to check remembered devices for waiting transfers when the app opens.", + "translations": { + "en": "Check for waiting transfers", + "fr": "Rechercher les transferts en attente", + "es": "Buscar transferencias en espera", + "it": "Cerca trasferimenti in attesa", + "de": "Nach wartenden Übertragungen suchen", + "pt": "Procurar transferências em espera", + "pl": "Sprawdzaj oczekujące przesyłki", + "nl": "Controleren op wachtende overdrachten", + "ru": "Проверять ожидающие передачи" + } + }, + "contacts_check_on_open_hint": { + "context": "Devices settings: warns that checking reveals to remembered devices when the app is opened.", + "translations": { + "en": "When you open VniDrop, your remembered devices are asked whether they have anything for you. This tells them when you opened the app.", + "fr": "À l’ouverture de VniDrop, vos appareils enregistrés sont interrogés pour savoir s’ils ont quelque chose pour vous. Cela leur indique quand vous ouvrez l’application.", + "es": "Al abrir VniDrop, se pregunta a tus dispositivos guardados si tienen algo para ti. Esto les indica cuándo abriste la aplicación.", + "it": "All’apertura di VniDrop, ai dispositivi memorizzati viene chiesto se hanno qualcosa per te. Questo rivela loro quando apri l’app.", + "de": "Beim Öffnen von VniDrop werden Ihre gespeicherten Geräte gefragt, ob sie etwas für Sie haben. Dadurch erfahren sie, wann Sie die App geöffnet haben.", + "pt": "Ao abrir o VniDrop, os dispositivos guardados são questionados se têm algo para si. Isto revela-lhes quando abriu a aplicação.", + "pl": "Po otwarciu VniDrop zapamiętane urządzenia są pytane, czy mają coś dla Ciebie. Dzięki temu wiedzą, kiedy otwierasz aplikację.", + "nl": "Bij het openen van VniDrop wordt aan je onthouden apparaten gevraagd of ze iets voor je hebben. Zij weten daardoor wanneer je de app opende.", + "ru": "При открытии VniDrop сохранённые устройства опрашиваются, есть ли у них что-то для вас. Так они узнают, когда вы открыли приложение." + } + }, + "contacts_check_now": { + "context": "Devices screen: button that checks remembered devices for waiting transfers right now.", + "translations": { + "en": "Check now", + "fr": "Vérifier maintenant", + "es": "Comprobar ahora", + "it": "Controlla ora", + "de": "Jetzt prüfen", + "pt": "Verificar agora", + "pl": "Sprawdź teraz", + "nl": "Nu controleren", + "ru": "Проверить сейчас" + } + }, + "contacts_check_none": { + "context": "Devices screen: result message when no device had anything waiting.", + "translations": { + "en": "Nothing waiting", + "fr": "Rien en attente", + "es": "Nada en espera", + "it": "Nulla in attesa", + "de": "Nichts wartet", + "pt": "Nada em espera", + "pl": "Nic nie czeka", + "nl": "Niets in de wacht", + "ru": "Ничего не ожидает" + } + }, + "contacts_offer_held": { + "context": "Shown after sending to a device that was not running: the transfer waits for it to open the app.", + "translations": { + "en": "That device is not open. The transfer will be delivered the next time it opens VniDrop.", + "fr": "Cet appareil n’est pas ouvert. Le transfert sera remis à sa prochaine ouverture de VniDrop.", + "es": "Ese dispositivo no está abierto. La transferencia se entregará la próxima vez que abra VniDrop.", + "it": "Quel dispositivo non è aperto. Il trasferimento verrà consegnato alla prossima apertura di VniDrop.", + "de": "Dieses Gerät ist nicht geöffnet. Die Übertragung wird beim nächsten Öffnen von VniDrop zugestellt.", + "pt": "Esse dispositivo não está aberto. A transferência será entregue da próxima vez que abrir o VniDrop.", + "pl": "To urządzenie nie jest otwarte. Przesyłka zostanie dostarczona przy następnym uruchomieniu VniDrop.", + "nl": "Dat apparaat is niet geopend. De overdracht wordt bezorgd zodra het VniDrop weer opent.", + "ru": "Это устройство не открыто. Передача будет доставлена при следующем запуске VniDrop." + } + }, + "contacts_waiting_title": { + "context": "Devices screen: section listing transfers waiting for their target device to come online.", + "translations": { + "en": "Waiting to be delivered", + "fr": "En attente de remise", + "es": "Pendientes de entrega", + "it": "In attesa di consegna", + "de": "Wartet auf Zustellung", + "pt": "A aguardar entrega", + "pl": "Oczekuje na dostarczenie", + "nl": "Wacht op bezorging", + "ru": "Ожидает доставки" + } + }, + "contacts_waiting_hint": { + "context": "Devices screen: explains that a waiting transfer is withdrawn by cancelling it.", + "translations": { + "en": "Cancel the transfer to withdraw it.", + "fr": "Annulez le transfert pour le retirer.", + "es": "Cancela la transferencia para retirarla.", + "it": "Annulla il trasferimento per ritirarlo.", + "de": "Brechen Sie die Übertragung ab, um sie zurückzuziehen.", + "pt": "Cancele a transferência para a retirar.", + "pl": "Anuluj przesyłkę, aby ją wycofać.", + "nl": "Annuleer de overdracht om die in te trekken.", + "ru": "Отмените передачу, чтобы отозвать её." + } } } } diff --git a/packaging/apple/studio/assets/globe.png b/packaging/apple/studio/assets/globe.png new file mode 100644 index 0000000..76535c8 Binary files /dev/null and b/packaging/apple/studio/assets/globe.png differ diff --git a/packaging/apple/studio/assets/mask-rotated.png b/packaging/apple/studio/assets/mask-rotated.png new file mode 100644 index 0000000..fcb9f19 Binary files /dev/null and b/packaging/apple/studio/assets/mask-rotated.png differ diff --git a/packaging/apple/studio/assets/mask-straight.png b/packaging/apple/studio/assets/mask-straight.png new file mode 100644 index 0000000..bc5b504 Binary files /dev/null and b/packaging/apple/studio/assets/mask-straight.png differ diff --git a/packaging/apple/studio/assets/mockup-rotated.png b/packaging/apple/studio/assets/mockup-rotated.png new file mode 100644 index 0000000..a5b4b4e Binary files /dev/null and b/packaging/apple/studio/assets/mockup-rotated.png differ diff --git a/packaging/apple/studio/assets/mockup-straight.png b/packaging/apple/studio/assets/mockup-straight.png new file mode 100644 index 0000000..af41d15 Binary files /dev/null and b/packaging/apple/studio/assets/mockup-straight.png differ diff --git a/packaging/apple/studio/assets/shots/en/choose-receivers.device.png b/packaging/apple/studio/assets/shots/en/choose-receivers.device.png new file mode 100644 index 0000000..fbd3ae9 Binary files /dev/null and b/packaging/apple/studio/assets/shots/en/choose-receivers.device.png differ diff --git a/packaging/apple/studio/assets/shots/en/choose-receivers.png b/packaging/apple/studio/assets/shots/en/choose-receivers.png new file mode 100644 index 0000000..9eb1f83 Binary files /dev/null and b/packaging/apple/studio/assets/shots/en/choose-receivers.png differ diff --git a/packaging/apple/studio/assets/shots/en/send-anywhere.device.png b/packaging/apple/studio/assets/shots/en/send-anywhere.device.png new file mode 100644 index 0000000..acfce32 Binary files /dev/null and b/packaging/apple/studio/assets/shots/en/send-anywhere.device.png differ diff --git a/packaging/apple/studio/assets/shots/en/send-anywhere.png b/packaging/apple/studio/assets/shots/en/send-anywhere.png new file mode 100644 index 0000000..1bbefa4 Binary files /dev/null and b/packaging/apple/studio/assets/shots/en/send-anywhere.png differ diff --git a/packaging/apple/studio/assets/shots/en/send-anywhere.warped.png b/packaging/apple/studio/assets/shots/en/send-anywhere.warped.png new file mode 100644 index 0000000..eccbcb8 Binary files /dev/null and b/packaging/apple/studio/assets/shots/en/send-anywhere.warped.png differ diff --git a/packaging/apple/studio/assets/shots/en/share-securely.device.png b/packaging/apple/studio/assets/shots/en/share-securely.device.png new file mode 100644 index 0000000..05b48e0 Binary files /dev/null and b/packaging/apple/studio/assets/shots/en/share-securely.device.png differ diff --git a/packaging/apple/studio/assets/shots/en/share-securely.png b/packaging/apple/studio/assets/shots/en/share-securely.png new file mode 100644 index 0000000..f9112f3 Binary files /dev/null and b/packaging/apple/studio/assets/shots/en/share-securely.png differ diff --git a/packaging/apple/studio/assets/shots/fr/choose-receivers.png b/packaging/apple/studio/assets/shots/fr/choose-receivers.png new file mode 100644 index 0000000..8f8478a Binary files /dev/null and b/packaging/apple/studio/assets/shots/fr/choose-receivers.png differ diff --git a/packaging/apple/studio/assets/shots/fr/send-anywhere.png b/packaging/apple/studio/assets/shots/fr/send-anywhere.png new file mode 100644 index 0000000..09d5cd4 Binary files /dev/null and b/packaging/apple/studio/assets/shots/fr/send-anywhere.png differ diff --git a/packaging/apple/studio/assets/shots/fr/send-anywhere.warped.png b/packaging/apple/studio/assets/shots/fr/send-anywhere.warped.png new file mode 100644 index 0000000..96abe4c Binary files /dev/null and b/packaging/apple/studio/assets/shots/fr/send-anywhere.warped.png differ diff --git a/packaging/apple/studio/assets/shots/fr/share-securely.png b/packaging/apple/studio/assets/shots/fr/share-securely.png new file mode 100644 index 0000000..b9e13d0 Binary files /dev/null and b/packaging/apple/studio/assets/shots/fr/share-securely.png differ diff --git a/packaging/apple/studio/generated/English/Choose Receivers.png b/packaging/apple/studio/generated/English/Choose Receivers.png new file mode 100644 index 0000000..1130312 Binary files /dev/null and b/packaging/apple/studio/generated/English/Choose Receivers.png differ diff --git a/packaging/apple/studio/generated/English/Send Anywhere.png b/packaging/apple/studio/generated/English/Send Anywhere.png new file mode 100644 index 0000000..a920411 Binary files /dev/null and b/packaging/apple/studio/generated/English/Send Anywhere.png differ diff --git a/packaging/apple/studio/generated/English/Share Securely.png b/packaging/apple/studio/generated/English/Share Securely.png new file mode 100644 index 0000000..e336d60 Binary files /dev/null and b/packaging/apple/studio/generated/English/Share Securely.png differ diff --git a/packaging/apple/studio/generated/English/Stay private.png b/packaging/apple/studio/generated/English/Stay private.png new file mode 100644 index 0000000..5a5e737 Binary files /dev/null and b/packaging/apple/studio/generated/English/Stay private.png differ diff --git a/packaging/apple/studio/generated/French/Choose Receivers.png b/packaging/apple/studio/generated/French/Choose Receivers.png new file mode 100644 index 0000000..cf04a91 Binary files /dev/null and b/packaging/apple/studio/generated/French/Choose Receivers.png differ diff --git a/packaging/apple/studio/generated/French/Send Anywhere.png b/packaging/apple/studio/generated/French/Send Anywhere.png new file mode 100644 index 0000000..479beb9 Binary files /dev/null and b/packaging/apple/studio/generated/French/Send Anywhere.png differ diff --git a/packaging/apple/studio/generated/French/Share Securely.png b/packaging/apple/studio/generated/French/Share Securely.png new file mode 100644 index 0000000..172fcaa Binary files /dev/null and b/packaging/apple/studio/generated/French/Share Securely.png differ diff --git a/packaging/apple/studio/generated/French/Stay private.png b/packaging/apple/studio/generated/French/Stay private.png new file mode 100644 index 0000000..a5489c8 Binary files /dev/null and b/packaging/apple/studio/generated/French/Stay private.png differ diff --git a/shared/src/commonMain/composeResources/values-de/strings.xml b/shared/src/commonMain/composeResources/values-de/strings.xml index 77816f0..675746c 100644 --- a/shared/src/commonMain/composeResources/values-de/strings.xml +++ b/shared/src/commonMain/composeResources/values-de/strings.xml @@ -337,6 +337,13 @@ Ungenutzte Geräte entfernen nach Die Frist beginnt bei jeder Übertragung mit dem Gerät neu. Nie + Nach wartenden Übertragungen suchen + Beim Öffnen von VniDrop werden Ihre gespeicherten Geräte gefragt, ob sie etwas für Sie haben. Dadurch erfahren sie, wann Sie die App geöffnet haben. + Jetzt prüfen + Nichts wartet + Dieses Gerät ist nicht geöffnet. Die Übertragung wird beim nächsten Öffnen von VniDrop zugestellt. + Wartet auf Zustellung + Brechen Sie die Übertragung ab, um sie zurückzuziehen. %1$d Datei %1$d Dateien diff --git a/shared/src/commonMain/composeResources/values-es/strings.xml b/shared/src/commonMain/composeResources/values-es/strings.xml index bfe34c8..a08d012 100644 --- a/shared/src/commonMain/composeResources/values-es/strings.xml +++ b/shared/src/commonMain/composeResources/values-es/strings.xml @@ -337,6 +337,13 @@ Olvidar dispositivos sin usar tras La cuenta atrás se reinicia cada vez que transfieres con el dispositivo. Nunca + Buscar transferencias en espera + Al abrir VniDrop, se pregunta a tus dispositivos guardados si tienen algo para ti. Esto les indica cuándo abriste la aplicación. + Comprobar ahora + Nada en espera + Ese dispositivo no está abierto. La transferencia se entregará la próxima vez que abra VniDrop. + Pendientes de entrega + Cancela la transferencia para retirarla. %1$d archivo %1$d archivos diff --git a/shared/src/commonMain/composeResources/values-fr/strings.xml b/shared/src/commonMain/composeResources/values-fr/strings.xml index 0d1ca97..4194cd3 100644 --- a/shared/src/commonMain/composeResources/values-fr/strings.xml +++ b/shared/src/commonMain/composeResources/values-fr/strings.xml @@ -337,6 +337,13 @@ Oublier les appareils inutilisés après Le décompte redémarre à chaque transfert avec l’appareil. Jamais + Rechercher les transferts en attente + À l’ouverture de VniDrop, vos appareils enregistrés sont interrogés pour savoir s’ils ont quelque chose pour vous. Cela leur indique quand vous ouvrez l’application. + Vérifier maintenant + Rien en attente + Cet appareil n’est pas ouvert. Le transfert sera remis à sa prochaine ouverture de VniDrop. + En attente de remise + Annulez le transfert pour le retirer. %1$d fichier %1$d fichiers diff --git a/shared/src/commonMain/composeResources/values-it/strings.xml b/shared/src/commonMain/composeResources/values-it/strings.xml index 5c065b8..729b7b3 100644 --- a/shared/src/commonMain/composeResources/values-it/strings.xml +++ b/shared/src/commonMain/composeResources/values-it/strings.xml @@ -337,6 +337,13 @@ Dimentica i dispositivi inutilizzati dopo Il conteggio riparte a ogni trasferimento con il dispositivo. Mai + Cerca trasferimenti in attesa + All’apertura di VniDrop, ai dispositivi memorizzati viene chiesto se hanno qualcosa per te. Questo rivela loro quando apri l’app. + Controlla ora + Nulla in attesa + Quel dispositivo non è aperto. Il trasferimento verrà consegnato alla prossima apertura di VniDrop. + In attesa di consegna + Annulla il trasferimento per ritirarlo. %1$d file %1$d file diff --git a/shared/src/commonMain/composeResources/values-nl/strings.xml b/shared/src/commonMain/composeResources/values-nl/strings.xml index c97ee7f..7f39a09 100644 --- a/shared/src/commonMain/composeResources/values-nl/strings.xml +++ b/shared/src/commonMain/composeResources/values-nl/strings.xml @@ -337,6 +337,13 @@ Ongebruikte apparaten vergeten na De teller start opnieuw bij elke overdracht met het apparaat. Nooit + Controleren op wachtende overdrachten + Bij het openen van VniDrop wordt aan je onthouden apparaten gevraagd of ze iets voor je hebben. Zij weten daardoor wanneer je de app opende. + Nu controleren + Niets in de wacht + Dat apparaat is niet geopend. De overdracht wordt bezorgd zodra het VniDrop weer opent. + Wacht op bezorging + Annuleer de overdracht om die in te trekken. %1$d bestand %1$d bestanden diff --git a/shared/src/commonMain/composeResources/values-pl/strings.xml b/shared/src/commonMain/composeResources/values-pl/strings.xml index 7ad6732..dd56ddd 100644 --- a/shared/src/commonMain/composeResources/values-pl/strings.xml +++ b/shared/src/commonMain/composeResources/values-pl/strings.xml @@ -337,6 +337,13 @@ Zapomnij nieużywane urządzenia po Odliczanie zaczyna się od nowa przy każdym przesłaniu. Nigdy + Sprawdzaj oczekujące przesyłki + Po otwarciu VniDrop zapamiętane urządzenia są pytane, czy mają coś dla Ciebie. Dzięki temu wiedzą, kiedy otwierasz aplikację. + Sprawdź teraz + Nic nie czeka + To urządzenie nie jest otwarte. Przesyłka zostanie dostarczona przy następnym uruchomieniu VniDrop. + Oczekuje na dostarczenie + Anuluj przesyłkę, aby ją wycofać. %1$d plik %1$d pliki diff --git a/shared/src/commonMain/composeResources/values-pt/strings.xml b/shared/src/commonMain/composeResources/values-pt/strings.xml index 93bddea..c201221 100644 --- a/shared/src/commonMain/composeResources/values-pt/strings.xml +++ b/shared/src/commonMain/composeResources/values-pt/strings.xml @@ -337,6 +337,13 @@ Esquecer dispositivos não usados após A contagem reinicia sempre que transfere com o dispositivo. Nunca + Procurar transferências em espera + Ao abrir o VniDrop, os dispositivos guardados são questionados se têm algo para si. Isto revela-lhes quando abriu a aplicação. + Verificar agora + Nada em espera + Esse dispositivo não está aberto. A transferência será entregue da próxima vez que abrir o VniDrop. + A aguardar entrega + Cancele a transferência para a retirar. %1$d ficheiro %1$d ficheiros diff --git a/shared/src/commonMain/composeResources/values-ru/strings.xml b/shared/src/commonMain/composeResources/values-ru/strings.xml index e3bd92a..3f65363 100644 --- a/shared/src/commonMain/composeResources/values-ru/strings.xml +++ b/shared/src/commonMain/composeResources/values-ru/strings.xml @@ -337,6 +337,13 @@ Забывать неиспользуемые устройства через Отсчёт начинается заново при каждой передаче с устройством. Никогда + Проверять ожидающие передачи + При открытии VniDrop сохранённые устройства опрашиваются, есть ли у них что-то для вас. Так они узнают, когда вы открыли приложение. + Проверить сейчас + Ничего не ожидает + Это устройство не открыто. Передача будет доставлена при следующем запуске VniDrop. + Ожидает доставки + Отмените передачу, чтобы отозвать её. %1$d файл %1$d файла diff --git a/shared/src/commonMain/composeResources/values/strings.xml b/shared/src/commonMain/composeResources/values/strings.xml index 7ccff38..ba8ff93 100644 --- a/shared/src/commonMain/composeResources/values/strings.xml +++ b/shared/src/commonMain/composeResources/values/strings.xml @@ -337,6 +337,13 @@ Forget unused devices after The countdown restarts every time you transfer with the device. Never + Check for waiting transfers + When you open VniDrop, your remembered devices are asked whether they have anything for you. This tells them when you opened the app. + Check now + Nothing waiting + That device is not open. The transfer will be delivered the next time it opens VniDrop. + Waiting to be delivered + Cancel the transfer to withdraw it. %1$d files %1$d file diff --git a/version.properties b/version.properties index b159b87..615df83 100644 --- a/version.properties +++ b/version.properties @@ -1,3 +1,3 @@ -PRODUCT_VERSION=0.2.4 +PRODUCT_VERSION=0.2.5 RELEASE_CHANNEL=beta WINDOWS_VERSION_EPOCH=1