0

I am attempting to write a utility function so that I can know if an iOS device is paired with an Apple Watch with something as simple as:

    func isPaired(
        timeoutAfter maxDuration: TimeInterval
    ) async throws -> Bool {

I came along with this code that works fine… except for the timeout part. If I comment out the code in the delegate, I can never catch the time out error that is thrown by the code. How come?

import Combine
import os
import SwiftUI
import WatchConnectivity

let logger = Logger(subsystem: "net.mickf.BlocksApp", category: "Watch")

class WatchPairingTask: NSObject {
    struct TimedOutError: Error, Equatable {}

    static var shared = WatchPairingTask()

    override private init() {
        logger.debug("Creating an instance of WatchPairingTask.")
        super.init()
    }

    private var activationContinuation: CheckedContinuation<Bool, Never>?

    func isPaired(
        timeoutAfter maxDuration: TimeInterval
    ) async throws -> Bool {
        logger.debug("Just called isPaired")
        return try await withThrowingTaskGroup(of: Bool.self) { group in
            group.addTask {
                try await self.doWork()
            }

            group.addTask {
                try await Task.sleep(nanoseconds: UInt64(maxDuration * 1_000_000_000))
                try Task.checkCancellation()
                // We’ve reached the timeout.
                logger.error("Throwing timeout")
                throw TimedOutError()
            }

            // First finished child task wins, cancel the other task.
            let result = try await group.next()!
            group.cancelAll()
            return result
        }
    }

    private func doWork() async throws -> Bool {
        let session = WCSession.default
        session.delegate = self

        return await withCheckedContinuation { continuation in
            activationContinuation = continuation
            session.activate()
        }
    }
}

extension WatchPairingTask: WCSessionDelegate {
    func session(_ session: WCSession, activationDidCompleteWith _: WCSessionActivationState, error: Error?) {
        logger.debug("Session activation did complete")

        if let error = error {
            logger.error("Session activation did complete with error: \(error.localizedDescription, privacy: .public)")
        }
        // Remove the comment to make things work
        // activationContinuation?.resume(with: .success(session.isPaired))
    }

    func sessionDidBecomeInactive(_: WCSession) {
        // Do nothing
    }

    func sessionDidDeactivate(_: WCSession) {
        // Do nothing
    }
}

class WatchState: ObservableObject {
    @Published var isPaired: Bool?

    func start() {
        Task {
            do {
                let value = try await WatchPairingTask.shared.isPaired(timeoutAfter: 1)
                await MainActor.run {
                    isPaired = value
                }
            } catch {
                print("WatchState caught an error.")
            }
        }
    }
}

struct ContentView: View {
    @ObservedObject var model: WatchState

    init(model: WatchState) {
        self.model = model
    }

    var body: some View {
        VStack {
            Text("Is a watch paired with this iPhone?")
            Text(model.isPaired?.description ?? "…")
        }
        .padding()
        .onAppear {
            model.start()
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView(model: WatchState())
    }
}

The console:

[Watch] Creating an instance of WatchPairingTask.
[Watch] Just called isPaired
[Watch] Session activation did complete
[WC] -[WCSession onqueue_handleUpdateSessionState:]_block_invoke dropping as pairingIDs no longer match. pairingID (null), client pairingID: (null)
[WC] WCSession is not paired
[Watch] Throwing timeout

And that's it.

Some notes:

Mick F
  • 7,312
  • 6
  • 51
  • 98
  • Probably because of those floating Task in your start method, it is bad practice to have those. Avoid MainActor.run and use a more natural flow with SwiftUI. https://stackoverflow.com/questions/76425953/how-to-update-core-data-and-then-ui-via-a-background-operation-from-a-button-pre/76426693#76426693 – lorem ipsum Jun 11 '23 at 19:12
  • @loremipsum I tried to keep a reference of the task in a `var floatingTask: Task?` prop in `WatchState` but I could not catch the error either. Is that what you meant by _floating_? – Mick F Jun 11 '23 at 19:15

1 Answers1

0

Got it.

My answer was here. It had to do with the cancellation of the continuation that has to be manual. Without it withThrowingTaskGroup won't return or throw.

So this implementation of doWork will do:

    private func doWork() async throws -> Bool {
        let session = WCSession.default
        session.delegate = self

        return await withTaskCancellationHandler(operation: {
            await withCheckedContinuation { continuation in
                    activationContinuation = continuation
                    session.activate()
            }
        }, onCancel: {
            activationContinuation?.resume(returning: false)
            activationContinuation = nil
        })
    }

Thanks @Rob for the detailed answer.

Mick F
  • 7,312
  • 6
  • 51
  • 98
  • 1
    A few observations: https://gist.github.com/robertmryan/f639bf0aa92c518419169d11afe896f8. – Rob Jun 12 '23 at 13:12
  • Wow! Thanks for this feedback @Rob. I will update the utility I had extracted from my code very soon here: https://github.com/dirtyhenry/blocks/blob/main/Sources/Blocks/Watch/WatchPairingUtil.swift – Mick F Jun 13 '23 at 14:22