mirror of
https://github.com/usatiuk/cardboy.git
synced 2025-10-28 23:27:49 +01:00
background sync
This commit is contained in:
@@ -22,8 +22,8 @@ This SwiftUI app connects to the Cardboy device over Bluetooth Low Energy and up
|
|||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
1. Open `cardboy-companion/cardboy-companion.xcodeproj` in Xcode.
|
1. Open `cardboy-companion/cardboy-companion.xcodeproj` in Xcode.
|
||||||
2. Enable the `CoreBluetooth` capability for the `cardboy-companion` target (Signing & Capabilities tab).
|
2. Ensure the `CoreBluetooth` capability is enabled for the `cardboy-companion` target and keep the *Uses Bluetooth LE accessories* background mode on (preconfigured in this project).
|
||||||
3. Build & run on a real device (BLE is not available in the simulator).
|
3. Build & run on a real device (BLE is not available in the simulator).
|
||||||
4. Allow Bluetooth permissions when prompted, then tap **Sync Now**.
|
4. Allow Bluetooth permissions when prompted. The app keeps scanning in the background, so the Cardboy can request a sync even while the companion is not foregrounded. Tap **Sync Now** any time you want to trigger a manual refresh.
|
||||||
|
|
||||||
Optionally bundle this code into your existing app—`TimeSyncManager` is self‑contained and can be reused.
|
Optionally bundle this code into your existing app—`TimeSyncManager` is self‑contained and can be reused.
|
||||||
|
|||||||
@@ -10,9 +10,22 @@
|
|||||||
ECAB9A832EA550D9004BA9DE /* cardboy-companion.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "cardboy-companion.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
ECAB9A832EA550D9004BA9DE /* cardboy-companion.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "cardboy-companion.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
|
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||||
|
ECAB9ABA2EA562CD004BA9DE /* Exceptions for "cardboy-companion" folder in "cardboy-companion" target */ = {
|
||||||
|
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||||
|
membershipExceptions = (
|
||||||
|
Info.plist,
|
||||||
|
);
|
||||||
|
target = ECAB9A822EA550D9004BA9DE /* cardboy-companion */;
|
||||||
|
};
|
||||||
|
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||||
|
|
||||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||||
ECAB9A852EA550D9004BA9DE /* cardboy-companion */ = {
|
ECAB9A852EA550D9004BA9DE /* cardboy-companion */ = {
|
||||||
isa = PBXFileSystemSynchronizedRootGroup;
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
|
exceptions = (
|
||||||
|
ECAB9ABA2EA562CD004BA9DE /* Exceptions for "cardboy-companion" folder in "cardboy-companion" target */,
|
||||||
|
);
|
||||||
path = "cardboy-companion";
|
path = "cardboy-companion";
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
@@ -254,10 +267,12 @@
|
|||||||
DEVELOPMENT_TEAM = WX524QS7SH;
|
DEVELOPMENT_TEAM = WX524QS7SH;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_FILE = "cardboy-companion/Info.plist";
|
||||||
INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Cardboy Companion needs Bluetooth to sync time with your handheld.";
|
INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Cardboy Companion needs Bluetooth to sync time with your handheld.";
|
||||||
INFOPLIST_KEY_NSBluetoothPeripheralUsageDescription = "Cardboy Companion needs Bluetooth to sync time with your handheld.";
|
INFOPLIST_KEY_NSBluetoothPeripheralUsageDescription = "Cardboy Companion needs Bluetooth to sync time with your handheld.";
|
||||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
|
INFOPLIST_KEY_UIBackgroundModes = "bluetooth-central";
|
||||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
@@ -288,10 +303,12 @@
|
|||||||
DEVELOPMENT_TEAM = WX524QS7SH;
|
DEVELOPMENT_TEAM = WX524QS7SH;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_FILE = "cardboy-companion/Info.plist";
|
||||||
INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Cardboy Companion needs Bluetooth to sync time with your handheld.";
|
INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Cardboy Companion needs Bluetooth to sync time with your handheld.";
|
||||||
INFOPLIST_KEY_NSBluetoothPeripheralUsageDescription = "Cardboy Companion needs Bluetooth to sync time with your handheld.";
|
INFOPLIST_KEY_NSBluetoothPeripheralUsageDescription = "Cardboy Companion needs Bluetooth to sync time with your handheld.";
|
||||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||||
|
INFOPLIST_KEY_UIBackgroundModes = "bluetooth-central";
|
||||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>UIBackgroundModes</key>
|
||||||
|
<array>
|
||||||
|
<string>bluetooth-central</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -19,28 +19,37 @@ final class TimeSyncManager: NSObject, ObservableObject {
|
|||||||
private let serviceUUID = CBUUID(string: "00000001-CA7B-4EFD-B5A6-10C3F4D3F230")
|
private let serviceUUID = CBUUID(string: "00000001-CA7B-4EFD-B5A6-10C3F4D3F230")
|
||||||
private let characteristicUUID = CBUUID(string: "00000002-CA7B-4EFD-B5A6-10C3F4D3F231")
|
private let characteristicUUID = CBUUID(string: "00000002-CA7B-4EFD-B5A6-10C3F4D3F231")
|
||||||
|
|
||||||
private var central: CBCentralManager!
|
private lazy var central: CBCentralManager = CBCentralManager(
|
||||||
|
delegate: self,
|
||||||
|
queue: nil,
|
||||||
|
options: [
|
||||||
|
CBCentralManagerOptionShowPowerAlertKey: true,
|
||||||
|
CBCentralManagerOptionRestoreIdentifierKey: "com.usatiuk.cardboy-companion.central"
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
private var targetPeripheral: CBPeripheral?
|
private var targetPeripheral: CBPeripheral?
|
||||||
private var timeCharacteristic: CBCharacteristic?
|
private var timeCharacteristic: CBCharacteristic?
|
||||||
|
|
||||||
private var retryTimer: Timer?
|
private var retryWorkItem: DispatchWorkItem?
|
||||||
|
private var shouldKeepScanning = true
|
||||||
|
private var isScanning = false
|
||||||
|
|
||||||
override init() {
|
override init() {
|
||||||
super.init()
|
super.init()
|
||||||
central = CBCentralManager(delegate: self, queue: nil)
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
retryWorkItem?.cancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
func forceRescan() {
|
func forceRescan() {
|
||||||
statusMessage = "Restarting scan…"
|
statusMessage = "Restarting scan…"
|
||||||
connectionState = .scanning
|
shouldKeepScanning = true
|
||||||
if central.state == .poweredOn {
|
stopScanning()
|
||||||
central.stopScan()
|
targetPeripheral = nil
|
||||||
targetPeripheral = nil
|
timeCharacteristic = nil
|
||||||
timeCharacteristic = nil
|
startScanning()
|
||||||
central.scanForPeripherals(withServices: [serviceUUID], options: [
|
|
||||||
CBCentralManagerScanOptionAllowDuplicatesKey: false
|
|
||||||
])
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendCurrentTime() {
|
func sendCurrentTime() {
|
||||||
@@ -70,9 +79,55 @@ final class TimeSyncManager: NSObject, ObservableObject {
|
|||||||
statusMessage = "Sending current time…"
|
statusMessage = "Sending current time…"
|
||||||
peripheral.writeValue(payload, for: characteristic, type: .withResponse)
|
peripheral.writeValue(payload, for: characteristic, type: .withResponse)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func startScanning() {
|
||||||
|
guard shouldKeepScanning, central.state == .poweredOn else { return }
|
||||||
|
if isScanning { return }
|
||||||
|
|
||||||
|
central.scanForPeripherals(withServices: [serviceUUID], options: [
|
||||||
|
CBCentralManagerScanOptionAllowDuplicatesKey: false
|
||||||
|
])
|
||||||
|
|
||||||
|
isScanning = true
|
||||||
|
connectionState = .scanning
|
||||||
|
statusMessage = "Scanning for Cardboy…"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stopScanning() {
|
||||||
|
guard isScanning else { return }
|
||||||
|
central.stopScan()
|
||||||
|
isScanning = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func scheduleRetry(after delay: TimeInterval = 2.5) {
|
||||||
|
shouldKeepScanning = true
|
||||||
|
retryWorkItem?.cancel()
|
||||||
|
let workItem = DispatchWorkItem { [weak self] in
|
||||||
|
self?.startScanning()
|
||||||
|
}
|
||||||
|
retryWorkItem = workItem
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension TimeSyncManager: CBCentralManagerDelegate {
|
extension TimeSyncManager: CBCentralManagerDelegate {
|
||||||
|
func centralManager(_ central: CBCentralManager, willRestoreState dict: [String: Any]) {
|
||||||
|
shouldKeepScanning = true
|
||||||
|
|
||||||
|
if let peripherals = dict[CBCentralManagerRestoredStatePeripheralsKey] as? [CBPeripheral],
|
||||||
|
let restored = peripherals.first {
|
||||||
|
statusMessage = "Restoring connection…"
|
||||||
|
connectionState = .connecting
|
||||||
|
targetPeripheral = restored
|
||||||
|
restored.delegate = self
|
||||||
|
if central.state == .poweredOn {
|
||||||
|
central.connect(restored, options: nil)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
startScanning()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func centralManagerDidUpdateState(_ central: CBCentralManager) {
|
func centralManagerDidUpdateState(_ central: CBCentralManager) {
|
||||||
switch central.state {
|
switch central.state {
|
||||||
case .unknown, .resetting:
|
case .unknown, .resetting:
|
||||||
@@ -87,12 +142,9 @@ extension TimeSyncManager: CBCentralManagerDelegate {
|
|||||||
case .poweredOff:
|
case .poweredOff:
|
||||||
connectionState = .idle
|
connectionState = .idle
|
||||||
statusMessage = "Turn on Bluetooth to continue."
|
statusMessage = "Turn on Bluetooth to continue."
|
||||||
|
stopScanning()
|
||||||
case .poweredOn:
|
case .poweredOn:
|
||||||
connectionState = .scanning
|
startScanning()
|
||||||
statusMessage = "Scanning for Cardboy…"
|
|
||||||
central.scanForPeripherals(withServices: [serviceUUID], options: [
|
|
||||||
CBCentralManagerScanOptionAllowDuplicatesKey: false
|
|
||||||
])
|
|
||||||
@unknown default:
|
@unknown default:
|
||||||
connectionState = .failed
|
connectionState = .failed
|
||||||
statusMessage = "Unknown Bluetooth state."
|
statusMessage = "Unknown Bluetooth state."
|
||||||
@@ -103,9 +155,10 @@ extension TimeSyncManager: CBCentralManagerDelegate {
|
|||||||
rssi RSSI: NSNumber) {
|
rssi RSSI: NSNumber) {
|
||||||
statusMessage = "Found \(peripheral.name ?? "device"), connecting…"
|
statusMessage = "Found \(peripheral.name ?? "device"), connecting…"
|
||||||
connectionState = .connecting
|
connectionState = .connecting
|
||||||
|
shouldKeepScanning = false
|
||||||
|
stopScanning()
|
||||||
targetPeripheral = peripheral
|
targetPeripheral = peripheral
|
||||||
peripheral.delegate = self
|
peripheral.delegate = self
|
||||||
central.stopScan()
|
|
||||||
central.connect(peripheral, options: nil)
|
central.connect(peripheral, options: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,21 +171,19 @@ extension TimeSyncManager: CBCentralManagerDelegate {
|
|||||||
func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) {
|
func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) {
|
||||||
statusMessage = "Failed to connect. \(error?.localizedDescription ?? "")"
|
statusMessage = "Failed to connect. \(error?.localizedDescription ?? "")"
|
||||||
connectionState = .failed
|
connectionState = .failed
|
||||||
|
targetPeripheral = nil
|
||||||
|
timeCharacteristic = nil
|
||||||
scheduleRetry()
|
scheduleRetry()
|
||||||
}
|
}
|
||||||
|
|
||||||
func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) {
|
func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) {
|
||||||
statusMessage = "Disconnected. \(error?.localizedDescription ?? "")"
|
let reason = error?.localizedDescription ?? "Disconnected."
|
||||||
|
statusMessage = reason
|
||||||
connectionState = .idle
|
connectionState = .idle
|
||||||
|
targetPeripheral = nil
|
||||||
|
timeCharacteristic = nil
|
||||||
scheduleRetry()
|
scheduleRetry()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func scheduleRetry() {
|
|
||||||
retryTimer?.invalidate()
|
|
||||||
retryTimer = Timer.scheduledTimer(withTimeInterval: 2.5, repeats: false) { [weak self] _ in
|
|
||||||
self?.forceRescan()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension TimeSyncManager: CBPeripheralDelegate {
|
extension TimeSyncManager: CBPeripheralDelegate {
|
||||||
@@ -182,11 +233,21 @@ extension TimeSyncManager: CBPeripheralDelegate {
|
|||||||
if let error {
|
if let error {
|
||||||
statusMessage = "Write failed: \(error.localizedDescription)"
|
statusMessage = "Write failed: \(error.localizedDescription)"
|
||||||
connectionState = .failed
|
connectionState = .failed
|
||||||
|
scheduleRetry()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
lastSyncDate = Date()
|
lastSyncDate = Date()
|
||||||
statusMessage = "Time synced at \(DateFormatter.localizedString(from: lastSyncDate!, dateStyle: .none, timeStyle: .medium))."
|
if let date = lastSyncDate {
|
||||||
|
let formatted = DateFormatter.localizedString(from: date, dateStyle: .none, timeStyle: .medium)
|
||||||
|
statusMessage = "Time synced at \(formatted)."
|
||||||
|
} else {
|
||||||
|
statusMessage = "Time synced."
|
||||||
|
}
|
||||||
connectionState = .ready
|
connectionState = .ready
|
||||||
|
|
||||||
|
shouldKeepScanning = true
|
||||||
|
central.cancelPeripheralConnection(peripheral)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -163,9 +163,9 @@ void startAdvertising() {
|
|||||||
std::memset(&advParams, 0, sizeof(advParams));
|
std::memset(&advParams, 0, sizeof(advParams));
|
||||||
advParams.conn_mode = BLE_GAP_CONN_MODE_UND;
|
advParams.conn_mode = BLE_GAP_CONN_MODE_UND;
|
||||||
advParams.disc_mode = BLE_GAP_DISC_MODE_GEN;
|
advParams.disc_mode = BLE_GAP_DISC_MODE_GEN;
|
||||||
const uint16_t advIntervalMin = BLE_GAP_ADV_ITVL_MS(3000);
|
const uint16_t advIntervalMin = BLE_GAP_ADV_ITVL_MS(1000);
|
||||||
advParams.itvl_min = advIntervalMin;
|
advParams.itvl_min = advIntervalMin;
|
||||||
advParams.itvl_max = BLE_GAP_ADV_ITVL_MS(4200);
|
advParams.itvl_max = BLE_GAP_ADV_ITVL_MS(1200);
|
||||||
|
|
||||||
rc = ble_gap_adv_start(g_ownAddrType, nullptr, BLE_HS_FOREVER, &advParams, gapEventHandler, nullptr);
|
rc = ble_gap_adv_start(g_ownAddrType, nullptr, BLE_HS_FOREVER, &advParams, gapEventHandler, nullptr);
|
||||||
if (rc != 0) {
|
if (rc != 0) {
|
||||||
|
|||||||
Reference in New Issue
Block a user