feat(apple): collect transfers held for this device

Adds the opt-in foreground check and an explicit Check now, the waiting-to-
be-delivered list on the sender side, and honest reporting when a send could
not be delivered: a closed app is a delay, not a success nobody received.

The setting is off by default and its footer states that checking reveals
app-open times to remembered devices, since that is the reason it is a
setting at all.

Records in the design doc that this shipped as one global toggle rather than
the per-contact opt-in originally specified.
This commit is contained in:
2026-08-06 19:00:04 +02:00
parent 94a8ba2103
commit 0f70663263
52 changed files with 492 additions and 21 deletions

View File

@@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(swift test *)"
]
}
}

View File

@@ -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 device's address to them — precisely the leak §1 avoids by refusing background
presence polling. The pull is therefore bounded rather than automatic: presence polling. The pull is therefore bounded rather than automatic:
- It runs only for contacts the user has explicitly marked as allowed to be - It is **off by default**, behind a single setting whose own footer states the
checked, or on an explicit "check for incoming" action. cost, plus an explicit "Check now" action that works regardless.
- It never runs in the background, only on an actual foreground transition. - 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 - It is rate-limited per contact (5 minutes), so repeated app switching does not
presence beacon. 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 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 implied. The contact list distinguishes "reachable now" from "will be delivered

View File

@@ -235,7 +235,13 @@ final class ContactsModelTests: XCTestCase {
fileSystemService: files fileSystemService: files
) )
gateway.sendToContactResult = .success( 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") model.chooseFilesToSend(to: "peer")
@@ -276,6 +282,98 @@ final class ContactsModelTests: XCTestCase {
XCTAssertTrue(gateway.sentToContacts.isEmpty) 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 { func testUnreachableContactIsSurfacedForRepairing() async {
let gateway = FakeCoreGateway() let gateway = FakeCoreGateway()
gateway.contactsResult = .success([contact("peer", canSend: false)]) gateway.contactsResult = .success([contact("peer", canSend: false)])

View File

@@ -90,7 +90,10 @@ final class FakeCoreGateway: CoreGateway {
var respondToPairingResult: Result<Bool, Error> = .success(true) var respondToPairingResult: Result<Bool, Error> = .success(true)
/// Ticket handed back when an offer is accepted; nil models a declined one. /// Ticket handed back when an offer is accepted; nil models a declined one.
var offerTicket: String? = "vnd1:offered" var offerTicket: String? = "vnd1:offered"
var sendToContactResult: Result<Share, Error> = .failure(TestError.unimplemented) var sendToContactResult: Result<ContactSendOutcome, Error> = .failure(TestError.unimplemented)
var heldOffersResult: Result<[HeldOfferModel], Error> = .success([])
var pollResult: Result<UInt64, Error> = .success(0)
private(set) var pollCount = 0
var forgetContactResult: Result<Void, Error> = .success(()) var forgetContactResult: Result<Void, Error> = .success(())
var blockedResult: Result<[String], Error> = .success([]) var blockedResult: Result<[String], Error> = .success([])
@@ -129,10 +132,15 @@ final class FakeCoreGateway: CoreGateway {
sources: [ShareSource], sources: [ShareSource],
transferName: String, transferName: String,
senderName: String senderName: String
) async -> Result<Share, Error> { ) async -> Result<ContactSendOutcome, Error> {
sentToContacts.append(endpointId) sentToContacts.append(endpointId)
return sendToContactResult return sendToContactResult
} }
func heldOffers() async -> Result<[HeldOfferModel], Error> { heldOffersResult }
func pollContactsForOffers() async -> Result<UInt64, Error> {
pollCount += 1
return pollResult
}
func forgetContact(endpointId: String) async -> Result<Void, Error> { func forgetContact(endpointId: String) async -> Result<Void, Error> {
forgottenContacts.append(endpointId) forgottenContacts.append(endpointId)
return forgetContactResult return forgetContactResult
@@ -168,13 +176,14 @@ final class FakeFileSystemService: FileSystemService {
func canRevealReceiveFolder(_ folder: ReceiveFolder) -> Bool { false } func canRevealReceiveFolder(_ folder: ReceiveFolder) -> Bool { false }
private(set) var shareDestinations: [ShareDestination] = [] private(set) var shareDestinations: [ShareDestination] = []
func sharePickedFiles(repository: CoreGateway, files: [PickedShareFile], transferName: String, senderName: String, destination: ShareDestination) async -> Result<Share, Error> { func sharePickedFiles(repository: CoreGateway, files: [PickedShareFile], transferName: String, senderName: String, destination: ShareDestination) async -> Result<ContactSendOutcome, Error> {
shareDestinations.append(destination) shareDestinations.append(destination)
switch destination { switch destination {
case .invitation(let accessPolicy): case .invitation(let accessPolicy):
return await repository.shareSources( return await repository.shareSources(
[], transferName: transferName, senderName: senderName, accessPolicy: accessPolicy [], transferName: transferName, senderName: senderName, accessPolicy: accessPolicy
) )
.map { ContactSendOutcome(share: $0, delivered: true) }
case .contact(let endpointId): case .contact(let endpointId):
return await repository.sendToContact( return await repository.sendToContact(
endpointId: endpointId, sources: [], transferName: transferName, senderName: senderName endpointId: endpointId, sources: [], transferName: transferName, senderName: senderName

View File

@@ -93,6 +93,9 @@ struct RootView: View {
// unfocused/occluded (common on macOS) live events may not have // unfocused/occluded (common on macOS) live events may not have
// rendered, leaving progress/status stale. // rendered, leaving progress/status stale.
Task { _ = await graph.coreRepository.refresh() } 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: case .background:
graph.visibility.setForeground(false) graph.visibility.setForeground(false)
// Hold the process open for iOS's grace window so an active // Hold the process open for iOS's grace window so an active

View File

@@ -127,6 +127,9 @@ struct AppPreferences: Equatable {
/// Devices the user declined to remember. Persisted so a repeat transfer /// Devices the user declined to remember. Persisted so a repeat transfer
/// with the same device does not re-ask forever. /// with the same device does not re-ask forever.
var declinedPairingSuggestions: Set<String> var declinedPairingSuggestions: Set<String>
/// 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 { struct AppPreferencesDefaults {
@@ -152,6 +155,7 @@ final class AppPreferencesRepository: ObservableObject {
static let relayConfiguration = "relay_configuration" static let relayConfiguration = "relay_configuration"
static let grantLifetime = "grant_lifetime" static let grantLifetime = "grant_lifetime"
static let declinedPairingSuggestions = "declined_pairing_suggestions" static let declinedPairingSuggestions = "declined_pairing_suggestions"
static let checkForOffersOnOpen = "check_for_offers_on_open"
} }
init(defaults: UserDefaults = .standard, fallback: AppPreferencesDefaults) { init(defaults: UserDefaults = .standard, fallback: AppPreferencesDefaults) {
@@ -175,7 +179,8 @@ final class AppPreferencesRepository: ObservableObject {
diagnosticsInstallId: installId, diagnosticsInstallId: installId,
relayConfiguration: resolveRelayConfiguration(defaults), relayConfiguration: resolveRelayConfiguration(defaults),
grantLifetime: grantLifetime, grantLifetime: grantLifetime,
declinedPairingSuggestions: declined declinedPairingSuggestions: declined,
checkForOffersOnOpen: defaults.bool(forKey: Key.checkForOffersOnOpen)
) )
} }
@@ -237,6 +242,11 @@ final class AppPreferencesRepository: ObservableObject {
reload() reload()
} }
func setCheckForOffersOnOpen(_ enabled: Bool) {
defaults.set(enabled, forKey: Key.checkForOffersOnOpen)
reload()
}
func setGrantLifetime(_ lifetime: GrantLifetimeOption) { func setGrantLifetime(_ lifetime: GrantLifetimeOption) {
defaults.set(lifetime.rawValue, forKey: Key.grantLifetime) defaults.set(lifetime.rawValue, forKey: Key.grantLifetime)
reload() reload()

View File

@@ -65,7 +65,14 @@ protocol CoreGateway: AnyObject {
sources: [ShareSource], sources: [ShareSource],
transferName: String, transferName: String,
senderName: String senderName: String
) async -> Result<Share, Error> ) async -> Result<ContactSendOutcome, Error>
/// 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<UInt64, Error>
func forgetContact(endpointId: String) async -> Result<Void, Error> func forgetContact(endpointId: String) async -> Result<Void, Error>
func forgetAllContacts() async -> Result<UInt64, Error> func forgetAllContacts() async -> Result<UInt64, Error>
func blockContact(endpointId: String) async -> Result<Void, Error> func blockContact(endpointId: String) async -> Result<Void, Error>

View File

@@ -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 /// How long a remembered device stays reachable while unused. The countdown
/// restarts on every transfer. /// restarts on every transfer.
enum GrantLifetimeOption: String, CaseIterable, Identifiable, Sendable { enum GrantLifetimeOption: String, CaseIterable, Identifiable, Sendable {

View File

@@ -335,7 +335,7 @@ final class CoreRepository: ObservableObject, CoreGateway {
sources: [ShareSource], sources: [ShareSource],
transferName: String, transferName: String,
senderName: String senderName: String
) async -> Result<Share, Error> { ) async -> Result<ContactSendOutcome, Error> {
guard !isNetworkTransitionInProgress else { guard !isNetworkTransitionInProgress else {
return .failure(CoreNetworkLifecycleError.transitionInProgress) return .failure(CoreNetworkLifecycleError.transitionInProgress)
} }
@@ -355,10 +355,18 @@ final class CoreRepository: ObservableObject, CoreGateway {
accessMode: .approvalRequired 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<UInt64, Error> {
await runCore { try self.requireCore().pollContactsForOffers() }
}
func forgetContact(endpointId: String) async -> Result<Void, Error> { func forgetContact(endpointId: String) async -> Result<Void, Error> {
await runCore { try self.requireCore().forgetContact(endpointId: endpointId) } 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 { extension GrantLifetimeOption {
func toNative() -> GrantLifetimeSetting { func toNative() -> GrantLifetimeSetting {
switch self { switch self {

View File

@@ -42,7 +42,7 @@ protocol FileSystemService {
transferName: String, transferName: String,
senderName: String, senderName: String,
destination: ShareDestination destination: ShareDestination
) async -> Result<Share, Error> ) async -> Result<ContactSendOutcome, Error>
} }
extension FileSystemService { extension FileSystemService {

View File

@@ -32,6 +32,10 @@ struct ContactsState: Equatable {
var busyEndpoints: Set<String> = [] var busyEndpoints: Set<String> = []
var busyOfferIds: Set<String> = [] var busyOfferIds: Set<String> = []
var suggestions: [PairingSuggestion] = [] 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 selectedEndpointId: String?
var selected: DeviceContact? { var selected: DeviceContact? {
@@ -75,6 +79,7 @@ final class ContactsModel: ObservableObject {
self.preferences = preferences self.preferences = preferences
self.fileSystemService = fileSystemService self.fileSystemService = fileSystemService
state.grantLifetime = preferences.preferences.grantLifetime state.grantLifetime = preferences.preferences.grantLifetime
state.checkForOffersOnOpen = preferences.preferences.checkForOffersOnOpen
repository.signals repository.signals
.sink { [weak self] signal in .sink { [weak self] signal in
@@ -130,6 +135,9 @@ final class ContactsModel: ObservableObject {
if case .success(let blocked) = await repository.blockedContacts() { if case .success(let blocked) = await repository.blockedContacts() {
state.blocked = blocked state.blocked = blocked
} }
if case .success(let held) = await repository.heldOffers() {
state.heldOffers = held
}
state.pendingPairings = await repository.pendingPairings() state.pendingPairings = await repository.pendingPairings()
await refreshOffers() await refreshOffers()
} }
@@ -205,6 +213,40 @@ final class ContactsModel: ObservableObject {
preferences.declinePairingSuggestion(suggestion.endpointId) 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 // MARK: - Selection
func select(_ endpointId: String?) { state.selectedEndpointId = endpointId } func select(_ endpointId: String?) { state.selectedEndpointId = endpointId }
@@ -290,8 +332,13 @@ final class ContactsModel: ObservableObject {
) )
await fileSystemService.discardPickedFiles(files) await fileSystemService.discardPickedFiles(files)
switch result { switch result {
case .success: case .success(let outcome):
messages.tryShow(UiMessage(text: .resource(L10n.Send.transferCreated), tone: .success)) // 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() await refresh()
case .failure(let error): case .failure(let error):
messages.error(error) messages.error(error)

View File

@@ -7,6 +7,8 @@ import SwiftUI
/// not part of the send/receive flow. /// not part of the send/receive flow.
struct ContactsScreen: View { struct ContactsScreen: View {
@ObservedObject var model: ContactsModel @ObservedObject var model: ContactsModel
/// Reports an empty result, which a silent refresh cannot convey.
let onNothingWaiting: () -> Void
var body: some View { var body: some View {
Form { 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 { if !model.state.blocked.isEmpty {
Section(String(localized: L10n.Contacts.blockedTitle)) { Section(String(localized: L10n.Contacts.blockedTitle)) {
ForEach(model.state.blocked, id: \.self) { endpointId in 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( GrantLifetimeSection(
selection: model.state.grantLifetime, selection: model.state.grantLifetime,
onSelect: model.setGrantLifetime 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 { private struct GrantLifetimeSection: View {
let selection: GrantLifetimeOption let selection: GrantLifetimeOption
let onSelect: (GrantLifetimeOption) -> Void let onSelect: (GrantLifetimeOption) -> Void

View File

@@ -355,7 +355,7 @@ final class SendModel: ObservableObject {
senderName: current.senderName.trimmingCharacters(in: .whitespacesAndNewlines), senderName: current.senderName.trimmingCharacters(in: .whitespacesAndNewlines),
destination: .invitation(accessPolicy: current.accessPolicy) destination: .invitation(accessPolicy: current.accessPolicy)
) )
switch result { switch result.map(\.share) {
case .success(let share): case .success(let share):
await fileSystemService.discardPickedFiles(current.selectedFiles) await fileSystemService.discardPickedFiles(current.selectedFiles)
if let thumb = current.selectedFiles.compactMap(\.thumbnailData).first { if let thumb = current.selectedFiles.compactMap(\.thumbnailData).first {

View File

@@ -176,6 +176,12 @@ final class SettingsModel: ObservableObject {
loadDeviceInfo() 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) { func selectSection(_ section: SettingsSection) {
state.selectedSection = section state.selectedSection = section
if section == .about || section == .bugReport { if section == .about || section == .bugReport {

View File

@@ -93,7 +93,9 @@ struct SettingsScreen: View {
// Contacts brings its own Form and push destination, so it is not wrapped // Contacts brings its own Form and push destination, so it is not wrapped
// in the shared section chrome. // in the shared section chrome.
if section == .contacts { if section == .contacts {
ContactsScreen(model: contacts) ContactsScreen(model: contacts) {
model.reportNothingWaiting()
}
} else { } else {
settingsSectionForm(section) settingsSectionForm(section)
} }

View File

@@ -60,7 +60,7 @@ struct IosFileSystemService: FileSystemService {
transferName: String, transferName: String,
senderName: String, senderName: String,
destination: ShareDestination destination: ShareDestination
) async -> Result<Share, Error> { ) async -> Result<ContactSendOutcome, Error> {
guard !files.isEmpty else { guard !files.isEmpty else {
return .failure(InvitationError.shareEmpty) return .failure(InvitationError.shareEmpty)
} }
@@ -70,6 +70,7 @@ struct IosFileSystemService: FileSystemService {
return await repository.shareSources( return await repository.shareSources(
sources, transferName: transferName, senderName: senderName, accessPolicy: accessPolicy sources, transferName: transferName, senderName: senderName, accessPolicy: accessPolicy
) )
.map { ContactSendOutcome(share: $0, delivered: true) }
case .contact(let endpointId): case .contact(let endpointId):
return await repository.sendToContact( return await repository.sendToContact(
endpointId: endpointId, sources: sources, endpointId: endpointId, sources: sources,

View File

@@ -40,7 +40,7 @@ struct MacFileSystemService: FileSystemService {
transferName: String, transferName: String,
senderName: String, senderName: String,
destination: ShareDestination destination: ShareDestination
) async -> Result<Share, Error> { ) async -> Result<ContactSendOutcome, Error> {
guard !files.isEmpty else { guard !files.isEmpty else {
return .failure(InvitationError.shareEmpty) return .failure(InvitationError.shareEmpty)
} }
@@ -68,6 +68,7 @@ struct MacFileSystemService: FileSystemService {
return await repository.shareSources( return await repository.shareSources(
sources, transferName: transferName, senderName: senderName, accessPolicy: accessPolicy sources, transferName: transferName, senderName: senderName, accessPolicy: accessPolicy
) )
.map { ContactSendOutcome(share: $0, delivered: true) }
case .contact(let endpointId): case .contact(let endpointId):
return await repository.sendToContact( return await repository.sendToContact(
endpointId: endpointId, sources: sources, endpointId: endpointId, sources: sources,

View File

@@ -5124,6 +5124,104 @@
"other": "{count} дня" "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": "À louverture de VniDrop, vos appareils enregistrés sont interrogés pour savoir sils ont quelque chose pour vous. Cela leur indique quand vous ouvrez lapplication.",
"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": "Allapertura di VniDrop, ai dispositivi memorizzati viene chiesto se hanno qualcosa per te. Questo rivela loro quando apri lapp.",
"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 nest 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": "Отмените передачу, чтобы отозвать её."
}
} }
} }
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 256 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 477 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 316 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 781 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 436 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 263 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 394 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 256 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 957 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 349 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 490 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 400 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 531 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 425 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

View File

@@ -337,6 +337,13 @@
<string name="contacts_grant_lifetime_title">Ungenutzte Geräte entfernen nach</string> <string name="contacts_grant_lifetime_title">Ungenutzte Geräte entfernen nach</string>
<string name="contacts_grant_lifetime_hint">Die Frist beginnt bei jeder Übertragung mit dem Gerät neu.</string> <string name="contacts_grant_lifetime_hint">Die Frist beginnt bei jeder Übertragung mit dem Gerät neu.</string>
<string name="contacts_grant_lifetime_never">Nie</string> <string name="contacts_grant_lifetime_never">Nie</string>
<string name="contacts_check_on_open">Nach wartenden Übertragungen suchen</string>
<string name="contacts_check_on_open_hint">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.</string>
<string name="contacts_check_now">Jetzt prüfen</string>
<string name="contacts_check_none">Nichts wartet</string>
<string name="contacts_offer_held">Dieses Gerät ist nicht geöffnet. Die Übertragung wird beim nächsten Öffnen von VniDrop zugestellt.</string>
<string name="contacts_waiting_title">Wartet auf Zustellung</string>
<string name="contacts_waiting_hint">Brechen Sie die Übertragung ab, um sie zurückzuziehen.</string>
<plurals name="transfer_file_count"> <plurals name="transfer_file_count">
<item quantity="one">%1$d Datei</item> <item quantity="one">%1$d Datei</item>
<item quantity="other">%1$d Dateien</item> <item quantity="other">%1$d Dateien</item>

View File

@@ -337,6 +337,13 @@
<string name="contacts_grant_lifetime_title">Olvidar dispositivos sin usar tras</string> <string name="contacts_grant_lifetime_title">Olvidar dispositivos sin usar tras</string>
<string name="contacts_grant_lifetime_hint">La cuenta atrás se reinicia cada vez que transfieres con el dispositivo.</string> <string name="contacts_grant_lifetime_hint">La cuenta atrás se reinicia cada vez que transfieres con el dispositivo.</string>
<string name="contacts_grant_lifetime_never">Nunca</string> <string name="contacts_grant_lifetime_never">Nunca</string>
<string name="contacts_check_on_open">Buscar transferencias en espera</string>
<string name="contacts_check_on_open_hint">Al abrir VniDrop, se pregunta a tus dispositivos guardados si tienen algo para ti. Esto les indica cuándo abriste la aplicación.</string>
<string name="contacts_check_now">Comprobar ahora</string>
<string name="contacts_check_none">Nada en espera</string>
<string name="contacts_offer_held">Ese dispositivo no está abierto. La transferencia se entregará la próxima vez que abra VniDrop.</string>
<string name="contacts_waiting_title">Pendientes de entrega</string>
<string name="contacts_waiting_hint">Cancela la transferencia para retirarla.</string>
<plurals name="transfer_file_count"> <plurals name="transfer_file_count">
<item quantity="one">%1$d archivo</item> <item quantity="one">%1$d archivo</item>
<item quantity="other">%1$d archivos</item> <item quantity="other">%1$d archivos</item>

View File

@@ -337,6 +337,13 @@
<string name="contacts_grant_lifetime_title">Oublier les appareils inutilisés après</string> <string name="contacts_grant_lifetime_title">Oublier les appareils inutilisés après</string>
<string name="contacts_grant_lifetime_hint">Le décompte redémarre à chaque transfert avec lappareil.</string> <string name="contacts_grant_lifetime_hint">Le décompte redémarre à chaque transfert avec lappareil.</string>
<string name="contacts_grant_lifetime_never">Jamais</string> <string name="contacts_grant_lifetime_never">Jamais</string>
<string name="contacts_check_on_open">Rechercher les transferts en attente</string>
<string name="contacts_check_on_open_hint">À louverture de VniDrop, vos appareils enregistrés sont interrogés pour savoir sils ont quelque chose pour vous. Cela leur indique quand vous ouvrez lapplication.</string>
<string name="contacts_check_now">Vérifier maintenant</string>
<string name="contacts_check_none">Rien en attente</string>
<string name="contacts_offer_held">Cet appareil nest pas ouvert. Le transfert sera remis à sa prochaine ouverture de VniDrop.</string>
<string name="contacts_waiting_title">En attente de remise</string>
<string name="contacts_waiting_hint">Annulez le transfert pour le retirer.</string>
<plurals name="transfer_file_count"> <plurals name="transfer_file_count">
<item quantity="one">%1$d fichier</item> <item quantity="one">%1$d fichier</item>
<item quantity="other">%1$d fichiers</item> <item quantity="other">%1$d fichiers</item>

View File

@@ -337,6 +337,13 @@
<string name="contacts_grant_lifetime_title">Dimentica i dispositivi inutilizzati dopo</string> <string name="contacts_grant_lifetime_title">Dimentica i dispositivi inutilizzati dopo</string>
<string name="contacts_grant_lifetime_hint">Il conteggio riparte a ogni trasferimento con il dispositivo.</string> <string name="contacts_grant_lifetime_hint">Il conteggio riparte a ogni trasferimento con il dispositivo.</string>
<string name="contacts_grant_lifetime_never">Mai</string> <string name="contacts_grant_lifetime_never">Mai</string>
<string name="contacts_check_on_open">Cerca trasferimenti in attesa</string>
<string name="contacts_check_on_open_hint">Allapertura di VniDrop, ai dispositivi memorizzati viene chiesto se hanno qualcosa per te. Questo rivela loro quando apri lapp.</string>
<string name="contacts_check_now">Controlla ora</string>
<string name="contacts_check_none">Nulla in attesa</string>
<string name="contacts_offer_held">Quel dispositivo non è aperto. Il trasferimento verrà consegnato alla prossima apertura di VniDrop.</string>
<string name="contacts_waiting_title">In attesa di consegna</string>
<string name="contacts_waiting_hint">Annulla il trasferimento per ritirarlo.</string>
<plurals name="transfer_file_count"> <plurals name="transfer_file_count">
<item quantity="one">%1$d file</item> <item quantity="one">%1$d file</item>
<item quantity="other">%1$d file</item> <item quantity="other">%1$d file</item>

View File

@@ -337,6 +337,13 @@
<string name="contacts_grant_lifetime_title">Ongebruikte apparaten vergeten na</string> <string name="contacts_grant_lifetime_title">Ongebruikte apparaten vergeten na</string>
<string name="contacts_grant_lifetime_hint">De teller start opnieuw bij elke overdracht met het apparaat.</string> <string name="contacts_grant_lifetime_hint">De teller start opnieuw bij elke overdracht met het apparaat.</string>
<string name="contacts_grant_lifetime_never">Nooit</string> <string name="contacts_grant_lifetime_never">Nooit</string>
<string name="contacts_check_on_open">Controleren op wachtende overdrachten</string>
<string name="contacts_check_on_open_hint">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.</string>
<string name="contacts_check_now">Nu controleren</string>
<string name="contacts_check_none">Niets in de wacht</string>
<string name="contacts_offer_held">Dat apparaat is niet geopend. De overdracht wordt bezorgd zodra het VniDrop weer opent.</string>
<string name="contacts_waiting_title">Wacht op bezorging</string>
<string name="contacts_waiting_hint">Annuleer de overdracht om die in te trekken.</string>
<plurals name="transfer_file_count"> <plurals name="transfer_file_count">
<item quantity="one">%1$d bestand</item> <item quantity="one">%1$d bestand</item>
<item quantity="other">%1$d bestanden</item> <item quantity="other">%1$d bestanden</item>

View File

@@ -337,6 +337,13 @@
<string name="contacts_grant_lifetime_title">Zapomnij nieużywane urządzenia po</string> <string name="contacts_grant_lifetime_title">Zapomnij nieużywane urządzenia po</string>
<string name="contacts_grant_lifetime_hint">Odliczanie zaczyna się od nowa przy każdym przesłaniu.</string> <string name="contacts_grant_lifetime_hint">Odliczanie zaczyna się od nowa przy każdym przesłaniu.</string>
<string name="contacts_grant_lifetime_never">Nigdy</string> <string name="contacts_grant_lifetime_never">Nigdy</string>
<string name="contacts_check_on_open">Sprawdzaj oczekujące przesyłki</string>
<string name="contacts_check_on_open_hint">Po otwarciu VniDrop zapamiętane urządzenia są pytane, czy mają coś dla Ciebie. Dzięki temu wiedzą, kiedy otwierasz aplikację.</string>
<string name="contacts_check_now">Sprawdź teraz</string>
<string name="contacts_check_none">Nic nie czeka</string>
<string name="contacts_offer_held">To urządzenie nie jest otwarte. Przesyłka zostanie dostarczona przy następnym uruchomieniu VniDrop.</string>
<string name="contacts_waiting_title">Oczekuje na dostarczenie</string>
<string name="contacts_waiting_hint">Anuluj przesyłkę, aby ją wycofać.</string>
<plurals name="transfer_file_count"> <plurals name="transfer_file_count">
<item quantity="one">%1$d plik</item> <item quantity="one">%1$d plik</item>
<item quantity="few">%1$d pliki</item> <item quantity="few">%1$d pliki</item>

View File

@@ -337,6 +337,13 @@
<string name="contacts_grant_lifetime_title">Esquecer dispositivos não usados após</string> <string name="contacts_grant_lifetime_title">Esquecer dispositivos não usados após</string>
<string name="contacts_grant_lifetime_hint">A contagem reinicia sempre que transfere com o dispositivo.</string> <string name="contacts_grant_lifetime_hint">A contagem reinicia sempre que transfere com o dispositivo.</string>
<string name="contacts_grant_lifetime_never">Nunca</string> <string name="contacts_grant_lifetime_never">Nunca</string>
<string name="contacts_check_on_open">Procurar transferências em espera</string>
<string name="contacts_check_on_open_hint">Ao abrir o VniDrop, os dispositivos guardados são questionados se têm algo para si. Isto revela-lhes quando abriu a aplicação.</string>
<string name="contacts_check_now">Verificar agora</string>
<string name="contacts_check_none">Nada em espera</string>
<string name="contacts_offer_held">Esse dispositivo não está aberto. A transferência será entregue da próxima vez que abrir o VniDrop.</string>
<string name="contacts_waiting_title">A aguardar entrega</string>
<string name="contacts_waiting_hint">Cancele a transferência para a retirar.</string>
<plurals name="transfer_file_count"> <plurals name="transfer_file_count">
<item quantity="one">%1$d ficheiro</item> <item quantity="one">%1$d ficheiro</item>
<item quantity="other">%1$d ficheiros</item> <item quantity="other">%1$d ficheiros</item>

View File

@@ -337,6 +337,13 @@
<string name="contacts_grant_lifetime_title">Забывать неиспользуемые устройства через</string> <string name="contacts_grant_lifetime_title">Забывать неиспользуемые устройства через</string>
<string name="contacts_grant_lifetime_hint">Отсчёт начинается заново при каждой передаче с устройством.</string> <string name="contacts_grant_lifetime_hint">Отсчёт начинается заново при каждой передаче с устройством.</string>
<string name="contacts_grant_lifetime_never">Никогда</string> <string name="contacts_grant_lifetime_never">Никогда</string>
<string name="contacts_check_on_open">Проверять ожидающие передачи</string>
<string name="contacts_check_on_open_hint">При открытии VniDrop сохранённые устройства опрашиваются, есть ли у них что-то для вас. Так они узнают, когда вы открыли приложение.</string>
<string name="contacts_check_now">Проверить сейчас</string>
<string name="contacts_check_none">Ничего не ожидает</string>
<string name="contacts_offer_held">Это устройство не открыто. Передача будет доставлена при следующем запуске VniDrop.</string>
<string name="contacts_waiting_title">Ожидает доставки</string>
<string name="contacts_waiting_hint">Отмените передачу, чтобы отозвать её.</string>
<plurals name="transfer_file_count"> <plurals name="transfer_file_count">
<item quantity="one">%1$d файл</item> <item quantity="one">%1$d файл</item>
<item quantity="few">%1$d файла</item> <item quantity="few">%1$d файла</item>

View File

@@ -337,6 +337,13 @@
<string name="contacts_grant_lifetime_title">Forget unused devices after</string> <string name="contacts_grant_lifetime_title">Forget unused devices after</string>
<string name="contacts_grant_lifetime_hint">The countdown restarts every time you transfer with the device.</string> <string name="contacts_grant_lifetime_hint">The countdown restarts every time you transfer with the device.</string>
<string name="contacts_grant_lifetime_never">Never</string> <string name="contacts_grant_lifetime_never">Never</string>
<string name="contacts_check_on_open">Check for waiting transfers</string>
<string name="contacts_check_on_open_hint">When you open VniDrop, your remembered devices are asked whether they have anything for you. This tells them when you opened the app.</string>
<string name="contacts_check_now">Check now</string>
<string name="contacts_check_none">Nothing waiting</string>
<string name="contacts_offer_held">That device is not open. The transfer will be delivered the next time it opens VniDrop.</string>
<string name="contacts_waiting_title">Waiting to be delivered</string>
<string name="contacts_waiting_hint">Cancel the transfer to withdraw it.</string>
<plurals name="transfer_file_count"> <plurals name="transfer_file_count">
<item quantity="other">%1$d files</item> <item quantity="other">%1$d files</item>
<item quantity="one">%1$d file</item> <item quantity="one">%1$d file</item>

View File

@@ -1,3 +1,3 @@
PRODUCT_VERSION=0.2.4 PRODUCT_VERSION=0.2.5
RELEASE_CHANNEL=beta RELEASE_CHANNEL=beta
WINDOWS_VERSION_EPOCH=1 WINDOWS_VERSION_EPOCH=1