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.
7
.claude/settings.local.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(swift test *)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)])
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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": "À 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": "Отмените передачу, чтобы отозвать её."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
packaging/apple/studio/assets/globe.png
Normal file
|
After Width: | Height: | Size: 4.9 MiB |
BIN
packaging/apple/studio/assets/mask-rotated.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
packaging/apple/studio/assets/mask-straight.png
Normal file
|
After Width: | Height: | Size: 6.1 KiB |
BIN
packaging/apple/studio/assets/mockup-rotated.png
Normal file
|
After Width: | Height: | Size: 2.7 MiB |
BIN
packaging/apple/studio/assets/mockup-straight.png
Normal file
|
After Width: | Height: | Size: 256 KiB |
|
After Width: | Height: | Size: 477 KiB |
BIN
packaging/apple/studio/assets/shots/en/choose-receivers.png
Normal file
|
After Width: | Height: | Size: 316 KiB |
BIN
packaging/apple/studio/assets/shots/en/send-anywhere.device.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
packaging/apple/studio/assets/shots/en/send-anywhere.png
Normal file
|
After Width: | Height: | Size: 222 KiB |
BIN
packaging/apple/studio/assets/shots/en/send-anywhere.warped.png
Normal file
|
After Width: | Height: | Size: 781 KiB |
BIN
packaging/apple/studio/assets/shots/en/share-securely.device.png
Normal file
|
After Width: | Height: | Size: 436 KiB |
BIN
packaging/apple/studio/assets/shots/en/share-securely.png
Normal file
|
After Width: | Height: | Size: 263 KiB |
BIN
packaging/apple/studio/assets/shots/fr/choose-receivers.png
Normal file
|
After Width: | Height: | Size: 394 KiB |
BIN
packaging/apple/studio/assets/shots/fr/send-anywhere.png
Normal file
|
After Width: | Height: | Size: 256 KiB |
BIN
packaging/apple/studio/assets/shots/fr/send-anywhere.warped.png
Normal file
|
After Width: | Height: | Size: 957 KiB |
BIN
packaging/apple/studio/assets/shots/fr/share-securely.png
Normal file
|
After Width: | Height: | Size: 349 KiB |
BIN
packaging/apple/studio/generated/English/Choose Receivers.png
Normal file
|
After Width: | Height: | Size: 490 KiB |
BIN
packaging/apple/studio/generated/English/Send Anywhere.png
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
BIN
packaging/apple/studio/generated/English/Share Securely.png
Normal file
|
After Width: | Height: | Size: 400 KiB |
BIN
packaging/apple/studio/generated/English/Stay private.png
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
packaging/apple/studio/generated/French/Choose Receivers.png
Normal file
|
After Width: | Height: | Size: 531 KiB |
BIN
packaging/apple/studio/generated/French/Send Anywhere.png
Normal file
|
After Width: | Height: | Size: 2.0 MiB |
BIN
packaging/apple/studio/generated/French/Share Securely.png
Normal file
|
After Width: | Height: | Size: 425 KiB |
BIN
packaging/apple/studio/generated/French/Stay private.png
Normal file
|
After Width: | Height: | Size: 46 KiB |
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 l’appareil.</string>
|
<string name="contacts_grant_lifetime_hint">Le décompte redémarre à chaque transfert avec l’appareil.</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">À 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.</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 n’est 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>
|
||||||
|
|||||||
@@ -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">All’apertura di VniDrop, ai dispositivi memorizzati viene chiesto se hanno qualcosa per te. Questo rivela loro quando apri l’app.</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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||