Add AirDrop detection for iOS

This commit is contained in:
Leon Böttger committed 2026-06-14 21:01:59 +02:00
1 parent d98be9c23c
commit 7d82520c2f
8 files changed
+5161 -1708

No files matched your search

+12
View File
@@ -37,6 +37,10 @@
A7D000000000000000000102 /* AndroidAirDropModeIssueView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7D000000000000000000001 /* AndroidAirDropModeIssueView.swift */; };
A7D000000000000000000103 /* AndroidAirDropModeIssueView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7D000000000000000000001 /* AndroidAirDropModeIssueView.swift */; };
A7D000000000000000000104 /* AndroidAirDropModeIssueView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7D000000000000000000001 /* AndroidAirDropModeIssueView.swift */; };
A8D000000000000000000101 /* BluetoothPermissionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3D000000000000000000002 /* BluetoothPermissionManager.swift */; };
A8D000000000000000000102 /* AndroidAirDropModeDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6D000000000000000000001 /* AndroidAirDropModeDetector.swift */; };
A8D000000000000000000103 /* AndroidAirDropModeIssueView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7D000000000000000000001 /* AndroidAirDropModeIssueView.swift */; };
A8D000000000000000000104 /* IssueView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EBC05C7F2E33B58300EECC18 /* IssueView.swift */; };
EB05DFBE2E59FC8900EF567A /* TrustedDevicesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB05DFBD2E59FC8000EF567A /* TrustedDevicesView.swift */; };
EB05DFBF2E59FD0A00EF567A /* TrustedDevicesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB05DFBD2E59FC8000EF567A /* TrustedDevicesView.swift */; };
EB05DFC02E5A2EAA00EF567A /* TrustedDevicesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB05DFBD2E59FC8000EF567A /* TrustedDevicesView.swift */; };
@@ -1624,6 +1628,8 @@
EB886E532E4E19390052B7D9 /* HKDF.swift in Sources */,
EB5C37F82E377CE70052EFD7 /* SleepManager.swift in Sources */,
EB5C37F92E377CE70052EFD7 /* securegcm.pb.swift in Sources */,
A8D000000000000000000101 /* BluetoothPermissionManager.swift in Sources */,
A8D000000000000000000102 /* AndroidAirDropModeDetector.swift in Sources */,
EB5C37FA2E377CE70052EFD7 /* Helpers.swift in Sources */,
EB5C37FB2E377CE70052EFD7 /* InboundNearbyConnection.swift in Sources */,
EB0A22E62FBCF59300B78FF5 /* NearbyBonjourServiceResolver.swift in Sources */,
@@ -1640,6 +1646,8 @@
EB979F942E50CA23008B8C5C /* Configuration.swift in Sources */,
EB979F952E50CA23008B8C5C /* ContentView.swift in Sources */,
EB6FE7CF2EFEB42800FF2BCF /* NoLocalNetworkAccessAlertView.swift in Sources */,
A8D000000000000000000103 /* AndroidAirDropModeIssueView.swift in Sources */,
A8D000000000000000000104 /* IssueView.swift in Sources */,
EBBEE1462EC9D93400AA7831 /* PhotoManager.swift in Sources */,
EB97A01A2E51EAA2008B8C5C /* UIAlert.swift in Sources */,
EB979F962E50CA23008B8C5C /* FilePicker.swift in Sources */,
@@ -2407,6 +2415,8 @@
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace = YES;
INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "QuickDrop uses Bluetooth for enhanced nearby device detection.";
INFOPLIST_KEY_NSBluetoothPeripheralUsageDescription = "QuickDrop uses Bluetooth for enhanced nearby device detection.";
INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2025-2026 Leon Böttger";
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Local network access is needed to detect devices and transfer files over your Wi-Fi network.";
INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "The add photos permission is required to save received photos and videos to your photo library.";
@@ -2452,6 +2462,8 @@
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
INFOPLIST_KEY_LSSupportsOpeningDocumentsInPlace = YES;
INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "QuickDrop uses Bluetooth for enhanced nearby device detection.";
INFOPLIST_KEY_NSBluetoothPeripheralUsageDescription = "QuickDrop uses Bluetooth for enhanced nearby device detection.";
INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2025-2026 Leon Böttger";
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "Local network access is needed to detect devices and transfer files over your Wi-Fi network.";
INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "The add photos permission is required to save received photos and videos to your photo library.";
File diff suppressed because it is too large. Load diff
@@ -13,11 +13,23 @@ struct AndroidAirDropAdvertisement: Equatable {
let rssi: Int
let manufacturerDataHex: String?
let serviceUUIDs: Set<String>
let serviceDataHexByUUID: [String: String]
let isConnectable: Bool?
init(rssi: Int, manufacturerDataHex: String?, serviceUUIDs: Set<String>) {
init(
rssi: Int,
manufacturerDataHex: String?,
serviceUUIDs: Set<String>,
serviceDataHexByUUID: [String: String] = [:],
isConnectable: Bool? = nil
) {
self.rssi = rssi
self.manufacturerDataHex = manufacturerDataHex?.lowercased()
self.serviceUUIDs = Set(serviceUUIDs.map { $0.uppercased() })
self.serviceDataHexByUUID = Dictionary(
uniqueKeysWithValues: serviceDataHexByUUID.map { ($0.key.uppercased(), $0.value.lowercased()) }
)
self.isConnectable = isConnectable
}
}
@@ -82,8 +94,19 @@ struct AndroidAirDropModeClassifier {
}
static func isAirDropModeAdvertisement(_ advertisement: AndroidAirDropAdvertisement) -> Bool {
#if os(iOS)
// iOS exposes the AirDrop-compatible Android share-mode service UUID,
// but hides the Apple manufacturer payload from third-party apps. The
// share sheet emits a non-connectable FCF1 packet without service data;
// background AirDrop-compatible discovery emits connectable FCF1 packets
// with FCF1 service data.
advertisement.serviceUUIDs.contains(airDropModeServiceUUID) &&
advertisement.isConnectable == false &&
advertisement.serviceDataHexByUUID[airDropModeServiceUUID] == nil
#else
advertisement.manufacturerDataHex?.hasPrefix(airDropModeManufacturerPrefix) == true &&
advertisement.serviceUUIDs.contains(airDropModeServiceUUID)
#endif
}
private static func strongerRSSI(_ current: Int?, _ candidate: Int) -> Int {
@@ -184,7 +207,8 @@ final class AndroidAirDropModeDetector: NSObject, CBCentralManagerDelegate {
private func beginScanning() {
guard !isScanning, let centralManager, centralManager.state == .poweredOn else { return }
log("[AndroidAirDropModeDetector] BLE scan started for service \(AndroidAirDropModeClassifier.airDropModeServiceUUID).")
let scanTargetDescription = Self.scanServiceUUIDs.map(\.uuidString).joined(separator: ", ")
log("[AndroidAirDropModeDetector] BLE scan started for \(scanTargetDescription).")
// Duplicate delivery is intentional: the classifier waits for repeated
// matching packets so one transient advertisement does not trigger the alert.
@@ -200,6 +224,7 @@ final class AndroidAirDropModeDetector: NSObject, CBCentralManagerDelegate {
rssi: Int
) -> AndroidAirDropAdvertisement {
var serviceUUIDs = Set<String>()
var serviceDataHexByUUID: [String: String] = [:]
[
CBAdvertisementDataServiceUUIDsKey,
@@ -211,13 +236,18 @@ final class AndroidAirDropModeDetector: NSObject, CBCentralManagerDelegate {
}
if let serviceData = advertisementData[CBAdvertisementDataServiceDataKey] as? [CBUUID: Data] {
serviceData.keys.forEach { serviceUUIDs.insert($0.uuidString) }
serviceData.forEach { uuid, data in
serviceUUIDs.insert(uuid.uuidString)
serviceDataHexByUUID[uuid.uuidString] = data.androidAirDropModeHexString
}
}
return AndroidAirDropAdvertisement(
rssi: rssi,
manufacturerDataHex: (advertisementData[CBAdvertisementDataManufacturerDataKey] as? Data)?.androidAirDropModeHexString,
serviceUUIDs: serviceUUIDs
serviceUUIDs: serviceUUIDs,
serviceDataHexByUUID: serviceDataHexByUUID,
isConnectable: advertisementData[CBAdvertisementDataIsConnectable] as? Bool
)
}
}
+1 -2
View File
@@ -102,7 +102,6 @@ struct IssueView: View {
#Preview {
NetworkFilterIssueView()
IssueView(image: .smartphone, header: "Header", description: "Description")
.frame(width: 600, height: 400)
}
@@ -43,6 +43,31 @@ struct AndroidAirDropModeDetectorTests {
#expect(observation.airDropModePacketCount == 0)
}
#if os(iOS)
@Test
func iOSDetectsNonConnectableFCF1AdvertisementsWithoutServiceData() {
var classifier = AndroidAirDropModeClassifier(minimumRSSI: -70, requiredPacketsPerSignal: 2)
let firstAirDropMode = classifier.record(.iOSAirDropMode(rssi: -62))
#expect(!firstAirDropMode.isDetected)
let detected = classifier.record(.iOSAirDropMode(rssi: -61))
#expect(detected.isDetected)
#expect(detected.airDropModePacketCount == 2)
#expect(detected.strongestAirDropModeRSSI == -61)
}
@Test
func iOSIgnoresBackgroundFCF1AdvertisementsWithServiceData() {
var classifier = AndroidAirDropModeClassifier(minimumRSSI: -70, requiredPacketsPerSignal: 1)
classifier.record(.iOSBackgroundFCF1(rssi: -55))
let observation = classifier.record(.iOSBackgroundFCF1(rssi: -56))
#expect(!observation.isDetected)
#expect(observation.airDropModePacketCount == 0)
}
#else
@Test
func ignoresNearbyAppleAndBackgroundFCF1Advertisements() {
var classifier = AndroidAirDropModeClassifier(minimumRSSI: -70, requiredPacketsPerSignal: 1)
@@ -61,6 +86,7 @@ struct AndroidAirDropModeDetectorTests {
#expect(!observation.isDetected)
#expect(observation.airDropModePacketCount == 0)
}
#endif
@Test
func scansOnlyForAirDropModeServiceAndAllowsDuplicatePackets() {
@@ -70,6 +96,25 @@ struct AndroidAirDropModeDetectorTests {
}
private extension AndroidAirDropAdvertisement {
static func iOSAirDropMode(rssi: Int) -> Self {
advertisement(
rssi: rssi,
manufacturerDataHex: nil,
serviceUUIDs: ["FCF1"],
isConnectable: false
)
}
static func iOSBackgroundFCF1(rssi: Int) -> Self {
advertisement(
rssi: rssi,
manufacturerDataHex: nil,
serviceUUIDs: ["FCF1"],
serviceDataHexByUUID: ["FCF1": "0407329497cd97ce4739a79920b877a45bb8d6d1a7"],
isConnectable: true
)
}
static func airDropMode(rssi: Int) -> Self {
airDropMode(manufacturerDataSuffix: "28b429412f624af300", rssi: rssi)
}
@@ -85,12 +130,16 @@ private extension AndroidAirDropAdvertisement {
static func advertisement(
rssi: Int,
manufacturerDataHex: String?,
serviceUUIDs: Set<String>
serviceUUIDs: Set<String>,
serviceDataHexByUUID: [String: String] = [:],
isConnectable: Bool? = nil
) -> Self {
AndroidAirDropAdvertisement(
rssi: rssi,
manufacturerDataHex: manufacturerDataHex,
serviceUUIDs: serviceUUIDs
serviceUUIDs: serviceUUIDs,
serviceDataHexByUUID: serviceDataHexByUUID,
isConnectable: isConnectable
)
}
}
+466
View File
@@ -25,6 +25,472 @@
}
}
},
"NSBluetoothAlwaysUsageDescription" : {
"comment" : "Privacy - Bluetooth Always Usage Description",
"localizations" : {
"ar" : {
"stringUnit" : {
"state" : "translated",
"value" : "يستخدم QuickDrop تقنية Bluetooth لتحسين اكتشاف الأجهزة القريبة."
}
},
"ca" : {
"stringUnit" : {
"state" : "translated",
"value" : "QuickDrop utilitza Bluetooth per millorar la detecció de dispositius propers."
}
},
"cs" : {
"stringUnit" : {
"state" : "translated",
"value" : "QuickDrop používá Bluetooth pro vylepšenou detekci zařízení v okolí."
}
},
"da" : {
"stringUnit" : {
"state" : "translated",
"value" : "QuickDrop bruger Bluetooth til forbedret registrering af enheder i nærheden."
}
},
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "QuickDrop verwendet Bluetooth für eine verbesserte Erkennung von Geräten in der Nähe."
}
},
"el" : {
"stringUnit" : {
"state" : "translated",
"value" : "Το QuickDrop χρησιμοποιεί Bluetooth για βελτιωμένο εντοπισμό κοντινών συσκευών."
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "QuickDrop uses Bluetooth for enhanced nearby device detection."
}
},
"en-AU" : {
"stringUnit" : {
"state" : "translated",
"value" : "QuickDrop uses Bluetooth for enhanced nearby device detection."
}
},
"en-CA" : {
"stringUnit" : {
"state" : "translated",
"value" : "QuickDrop uses Bluetooth for enhanced nearby device detection."
}
},
"en-GB" : {
"stringUnit" : {
"state" : "translated",
"value" : "QuickDrop uses Bluetooth for enhanced nearby device detection."
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "QuickDrop usa Bluetooth para mejorar la detección de dispositivos cercanos."
}
},
"es-419" : {
"stringUnit" : {
"state" : "translated",
"value" : "QuickDrop usa Bluetooth para mejorar la detección de dispositivos cercanos."
}
},
"fi" : {
"stringUnit" : {
"state" : "translated",
"value" : "QuickDrop käyttää Bluetoothia läheisten laitteiden parempaan havaitsemiseen."
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "QuickDrop utilise le Bluetooth pour améliorer la détection des appareils à proximité."
}
},
"fr-CA" : {
"stringUnit" : {
"state" : "translated",
"value" : "QuickDrop utilise le Bluetooth pour améliorer la détection des appareils à proximité."
}
},
"he" : {
"stringUnit" : {
"state" : "translated",
"value" : "QuickDrop משתמש ב-Bluetooth לזיהוי משופר של מכשירים בקרבת מקום."
}
},
"hi" : {
"stringUnit" : {
"state" : "translated",
"value" : "QuickDrop आस-पास के डिवाइस की बेहतर पहचान के लिए Bluetooth का उपयोग करता है।"
}
},
"hr" : {
"stringUnit" : {
"state" : "translated",
"value" : "QuickDrop koristi Bluetooth za poboljšano otkrivanje uređaja u blizini."
}
},
"hu" : {
"stringUnit" : {
"state" : "translated",
"value" : "A QuickDrop Bluetoothot használ a közeli eszközök fejlettebb felismeréséhez."
}
},
"id" : {
"stringUnit" : {
"state" : "translated",
"value" : "QuickDrop menggunakan Bluetooth untuk deteksi perangkat terdekat yang lebih baik."
}
},
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "QuickDrop usa il Bluetooth per un rilevamento migliorato dei dispositivi nelle vicinanze."
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "QuickDropは、近くのデバイスをより正確に検出するためにBluetoothを使用します。"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "QuickDrop은 주변 기기를 더 잘 감지하기 위해 Bluetooth를 사용합니다."
}
},
"ms" : {
"stringUnit" : {
"state" : "translated",
"value" : "QuickDrop menggunakan Bluetooth untuk pengesanan peranti berdekatan yang dipertingkatkan."
}
},
"nb" : {
"stringUnit" : {
"state" : "translated",
"value" : "QuickDrop bruker Bluetooth for forbedret oppdagelse av enheter i nærheten."
}
},
"nl" : {
"stringUnit" : {
"state" : "translated",
"value" : "QuickDrop gebruikt Bluetooth voor verbeterde detectie van apparaten in de buurt."
}
},
"pl" : {
"stringUnit" : {
"state" : "translated",
"value" : "QuickDrop używa Bluetooth do ulepszonego wykrywania pobliskich urządzeń."
}
},
"pt-BR" : {
"stringUnit" : {
"state" : "translated",
"value" : "O QuickDrop usa Bluetooth para melhorar a detecção de dispositivos próximos."
}
},
"pt-PT" : {
"stringUnit" : {
"state" : "translated",
"value" : "O QuickDrop usa Bluetooth para melhorar a deteção de dispositivos próximos."
}
},
"ro" : {
"stringUnit" : {
"state" : "translated",
"value" : "QuickDrop folosește Bluetooth pentru detectarea îmbunătățită a dispozitivelor din apropiere."
}
},
"ru" : {
"stringUnit" : {
"state" : "translated",
"value" : "QuickDrop использует Bluetooth для улучшенного обнаружения устройств поблизости."
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
"value" : "QuickDrop använder Bluetooth för förbättrad upptäckt av enheter i närheten."
}
},
"th" : {
"stringUnit" : {
"state" : "translated",
"value" : "QuickDrop ใช้ Bluetooth เพื่อการตรวจจับอุปกรณ์ใกล้เคียงที่ดียิ่งขึ้น"
}
},
"tr" : {
"stringUnit" : {
"state" : "translated",
"value" : "QuickDrop, yakındaki cihazları daha iyi algılamak için Bluetooth kullanır."
}
},
"uk" : {
"stringUnit" : {
"state" : "translated",
"value" : "QuickDrop використовує Bluetooth для покращеного виявлення пристроїв поблизу."
}
},
"vi" : {
"stringUnit" : {
"state" : "translated",
"value" : "QuickDrop sử dụng Bluetooth để phát hiện thiết bị lân cận tốt hơn."
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "QuickDrop 使用蓝牙来增强附近设备检测。"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "QuickDrop 使用藍牙來增強附近裝置偵測。"
}
}
}
},
"NSBluetoothPeripheralUsageDescription" : {
"comment" : "Privacy - Bluetooth Peripheral Usage Description",
"localizations" : {
"ar" : {
"stringUnit" : {
"state" : "translated",
"value" : "يستخدم QuickDrop تقنية Bluetooth لتحسين اكتشاف الأجهزة القريبة."
}
},
"ca" : {
"stringUnit" : {
"state" : "translated",
"value" : "QuickDrop utilitza Bluetooth per millorar la detecció de dispositius propers."
}
},
"cs" : {
"stringUnit" : {
"state" : "translated",
"value" : "QuickDrop používá Bluetooth pro vylepšenou detekci zařízení v okolí."
}
},
"da" : {
"stringUnit" : {
"state" : "translated",
"value" : "QuickDrop bruger Bluetooth til forbedret registrering af enheder i nærheden."
}
},
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "QuickDrop verwendet Bluetooth für eine verbesserte Erkennung von Geräten in der Nähe."
}
},
"el" : {
"stringUnit" : {
"state" : "translated",
"value" : "Το QuickDrop χρησιμοποιεί Bluetooth για βελτιωμένο εντοπισμό κοντινών συσκευών."
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "QuickDrop uses Bluetooth for enhanced nearby device detection."
}
},
"en-AU" : {
"stringUnit" : {
"state" : "translated",
"value" : "QuickDrop uses Bluetooth for enhanced nearby device detection."
}
},
"en-CA" : {
"stringUnit" : {
"state" : "translated",
"value" : "QuickDrop uses Bluetooth for enhanced nearby device detection."
}
},
"en-GB" : {
"stringUnit" : {
"state" : "translated",
"value" : "QuickDrop uses Bluetooth for enhanced nearby device detection."
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "QuickDrop usa Bluetooth para mejorar la detección de dispositivos cercanos."
}
},
"es-419" : {
"stringUnit" : {
"state" : "translated",
"value" : "QuickDrop usa Bluetooth para mejorar la detección de dispositivos cercanos."
}
},
"fi" : {
"stringUnit" : {
"state" : "translated",
"value" : "QuickDrop käyttää Bluetoothia läheisten laitteiden parempaan havaitsemiseen."
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "QuickDrop utilise le Bluetooth pour améliorer la détection des appareils à proximité."
}
},
"fr-CA" : {
"stringUnit" : {
"state" : "translated",
"value" : "QuickDrop utilise le Bluetooth pour améliorer la détection des appareils à proximité."
}
},
"he" : {
"stringUnit" : {
"state" : "translated",
"value" : "QuickDrop משתמש ב-Bluetooth לזיהוי משופר של מכשירים בקרבת מקום."
}
},
"hi" : {
"stringUnit" : {
"state" : "translated",
"value" : "QuickDrop आस-पास के डिवाइस की बेहतर पहचान के लिए Bluetooth का उपयोग करता है।"
}
},
"hr" : {
"stringUnit" : {
"state" : "translated",
"value" : "QuickDrop koristi Bluetooth za poboljšano otkrivanje uređaja u blizini."
}
},
"hu" : {
"stringUnit" : {
"state" : "translated",
"value" : "A QuickDrop Bluetoothot használ a közeli eszközök fejlettebb felismeréséhez."
}
},
"id" : {
"stringUnit" : {
"state" : "translated",
"value" : "QuickDrop menggunakan Bluetooth untuk deteksi perangkat terdekat yang lebih baik."
}
},
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "QuickDrop usa il Bluetooth per un rilevamento migliorato dei dispositivi nelle vicinanze."
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "QuickDropは、近くのデバイスをより正確に検出するためにBluetoothを使用します。"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "QuickDrop은 주변 기기를 더 잘 감지하기 위해 Bluetooth를 사용합니다."
}
},
"ms" : {
"stringUnit" : {
"state" : "translated",
"value" : "QuickDrop menggunakan Bluetooth untuk pengesanan peranti berdekatan yang dipertingkatkan."
}
},
"nb" : {
"stringUnit" : {
"state" : "translated",
"value" : "QuickDrop bruker Bluetooth for forbedret oppdagelse av enheter i nærheten."
}
},
"nl" : {
"stringUnit" : {
"state" : "translated",
"value" : "QuickDrop gebruikt Bluetooth voor verbeterde detectie van apparaten in de buurt."
}
},
"pl" : {
"stringUnit" : {
"state" : "translated",
"value" : "QuickDrop używa Bluetooth do ulepszonego wykrywania pobliskich urządzeń."
}
},
"pt-BR" : {
"stringUnit" : {
"state" : "translated",
"value" : "O QuickDrop usa Bluetooth para melhorar a detecção de dispositivos próximos."
}
},
"pt-PT" : {
"stringUnit" : {
"state" : "translated",
"value" : "O QuickDrop usa Bluetooth para melhorar a deteção de dispositivos próximos."
}
},
"ro" : {
"stringUnit" : {
"state" : "translated",
"value" : "QuickDrop folosește Bluetooth pentru detectarea îmbunătățită a dispozitivelor din apropiere."
}
},
"ru" : {
"stringUnit" : {
"state" : "translated",
"value" : "QuickDrop использует Bluetooth для улучшенного обнаружения устройств поблизости."
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
"value" : "QuickDrop använder Bluetooth för förbättrad upptäckt av enheter i närheten."
}
},
"th" : {
"stringUnit" : {
"state" : "translated",
"value" : "QuickDrop ใช้ Bluetooth เพื่อการตรวจจับอุปกรณ์ใกล้เคียงที่ดียิ่งขึ้น"
}
},
"tr" : {
"stringUnit" : {
"state" : "translated",
"value" : "QuickDrop, yakındaki cihazları daha iyi algılamak için Bluetooth kullanır."
}
},
"uk" : {
"stringUnit" : {
"state" : "translated",
"value" : "QuickDrop використовує Bluetooth для покращеного виявлення пристроїв поблизу."
}
},
"vi" : {
"stringUnit" : {
"state" : "translated",
"value" : "QuickDrop sử dụng Bluetooth để phát hiện thiết bị lân cận tốt hơn."
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "QuickDrop 使用蓝牙来增强附近设备检测。"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "QuickDrop 使用藍牙來增強附近裝置偵測。"
}
}
}
},
"NSHumanReadableCopyright" : {
"comment" : "Copyright (human-readable)",
"extractionState" : "extracted_with_value",
+113 -3
View File
@@ -13,8 +13,52 @@ struct ContentView: View {
@ObservedObject var luiSettings = LUISettings.sharedInstance
@ObservedObject var iapManager = IAPManager.sharedInstance
#if !EXTENSION
@ObservedObject private var bluetoothPermission = BluetoothPermissionManager.shared
@Environment(\.scenePhase) private var scenePhase
@State private var showAndroidAirDropModeIssue = false
@State private var didShowAndroidAirDropModeIssue = false
@State private var shouldRequestBluetoothPermissionAutomatically = LUISettings.sharedInstance.appLaunchedBefore
#endif
var body: some View {
#if !EXTENSION
rootContent
.sheet(isPresented: $showAndroidAirDropModeIssue) {
NavigationView {
AndroidAirDropModeIssueView()
.navigationTitle(String("QuickDrop"))
.navigationBarTitleDisplayMode(.inline)
.navigationBarItems(trailing: XButton(action: { showAndroidAirDropModeIssue = false }))
}
}
.onAppear {
requestBluetoothPermissionForExistingUserIfNeeded()
startAndroidAirDropModeDetectionIfAllowed()
}
.onChange(of: bluetoothPermission.authorization) { _ in
startAndroidAirDropModeDetectionIfAllowed()
}
.onChange(of: scenePhase) { newValue in
switch newValue {
case .active:
requestBluetoothPermissionForExistingUserIfNeeded()
startAndroidAirDropModeDetectionIfAllowed()
case .background:
AndroidAirDropModeDetector.shared.stop()
case .inactive:
break
@unknown default:
break
}
}
#else
rootContent
#endif
}
private var rootContent: some View {
AppRootView(phoneView: {
DeviceListView()
.environment(\.sheetActive, isShareExtension())
@@ -26,16 +70,82 @@ struct ContentView: View {
NavigationLinkLabel(imageName: "folder.fill", text: "BrowseDownloadedFiles")
}
LUILink(destination: NavigationSubView(header: "TrustedDevices") {
TrustedDevicesView()
}) {
LUILink(destination: TrustedDevicesView()) {
NavigationLinkLabel(imageName: "checkmark.shield.fill", text: "ManageTrustedDevices", backgroundColor: .green)
}
}
CustomSection {
LUILink(destination: QuickDropFAQSettingsView()) {
NavigationLinkLabel(imageName: "questionmark.circle.fill", text: "TroubleshootingAndFaq", backgroundColor: .orange)
}
}
}, firstIntroductionView: {
LocalNetworkPermissionView()
})
}
#if !EXTENSION
private func requestBluetoothPermissionForExistingUserIfNeeded() {
guard shouldRequestBluetoothPermissionAutomatically else { return }
requestBluetoothPermissionIfNeeded()
}
private func requestBluetoothPermissionIfNeeded() {
bluetoothPermission.refresh()
guard bluetoothPermission.canRequestPermission else { return }
log("[ContentView] Requesting Bluetooth permission for Android AirDrop mode detection.")
bluetoothPermission.requestPermission { allowed in
log("[ContentView] Bluetooth permission request completed. allowed=\(allowed)")
guard allowed else { return }
DispatchQueue.main.async {
startAndroidAirDropModeDetectionIfAllowed()
}
}
}
private func startAndroidAirDropModeDetectionIfAllowed() {
guard !didShowAndroidAirDropModeIssue else { return }
bluetoothPermission.refresh()
guard bluetoothPermission.isAllowed else { return }
AndroidAirDropModeDetector.shared.start { _ in
DispatchQueue.main.async {
guard !didShowAndroidAirDropModeIssue else { return }
didShowAndroidAirDropModeIssue = true
showAndroidAirDropModeIssue = true
}
}
}
#endif
}
private struct QuickDropFAQSettingsView: View {
private let faqItems: [FAQItem] = [
.init(question: "FaqNotVisibleOrConnectingQuestion", answer: "FaqNotVisibleOrConnectingAnswer"),
.init(question: "FaqPhotoDateNotPreservedQuestion", answer: "FaqPhotoDateNotPreservedAnswer"),
.init(question: "FaqAndroidDeviceNotVisibleQuestion", answer: "FaqAndroidDeviceNotVisibleAnswer"),
.init(question: "FaqTrustedDevicesQuestion", answer: "FaqTrustedDevicesAnswer"),
.init(question: "MultipleFilesSendingQuestion", answer: "MultipleFilesSendingAnswer"),
.init(question: "FaqBugQuestion", answer: "FaqBugAnswer"),
]
var body: some View {
NavigationSubView(header: "TroubleshootingAndFaq") {
FAQView(faqItems: faqItems)
.padding(.top)
}
}
}
struct DeviceListView: View {
@@ -28,7 +28,11 @@ public struct LocalNetworkPermissionView: View {
manager.startDeviceDiscovery()
}
}, continueLabel: "introduction_continue", canSkip: false, nextView: {
#if !EXTENSION
BluetoothPermissionView()
#else
IntroductionDoneView()
#endif
}, nextViewSkip: {}, topView: {
IntroductionIconView(icon: "wifi")
})
@@ -41,6 +45,53 @@ public struct LocalNetworkPermissionView: View {
}
}
#if !EXTENSION
private struct BluetoothPermissionView: View {
@ObservedObject private var bluetoothPermission = BluetoothPermissionManager.shared
@State private var requestedBluetoothAccess = false
var body: some View {
PermissionView(title: "BluetoothPermissionRequiredTitle",
watchSymbol: "",
subtitle: "BluetoothPermissionRequiredSubtitle",
permissionAction: requestBluetoothAccess,
continueLabel: "introduction_continue",
canSkip: false,
nextView: {
IntroductionDoneView()
},
nextViewSkip: {
IntroductionDoneView()
},
topView: {
IntroductionIconView(icon: "antenna.radiowaves.left.and.right")
})
}
private func requestBluetoothAccess() {
requestedBluetoothAccess = true
bluetoothPermission.refresh()
if bluetoothPermission.isAllowed {
IntroductionViewController.sharedInstance.canProceed = true
return
}
guard bluetoothPermission.canRequestPermission else {
IntroductionViewController.sharedInstance.canProceed = true
return
}
bluetoothPermission.requestPermission { _ in
DispatchQueue.main.async {
IntroductionViewController.sharedInstance.canProceed = true
}
}
}
}
#endif
#Preview {
NavigationView {