NativeHostApiStubs.swift 7.33 KB
import Flutter
import Foundation
import PhotosUI
import UIKit
import Vision

private let unsupported = "HealthKit / WearEngine / Alipay are not supported on iOS in this build."

final class HealthKitHostApiStub: HealthKitHostApi {
  func checkHealthAppAuthorization() throws -> Bool { false }
  func getHealthServerAuthUrl() throws -> String { "" }
  func requestHealthClientAuthorization() throws -> Bool { false }
  func cancelHealthAppAuthorization() throws -> Bool { false }
  func performHealthUpload() throws -> HealthUploadResult {
    HealthUploadResult(commonUploadSuccess: false, sleepUploadSuccess: false, errorMessage: unsupported)
  }
}

final class WearEngineHostApiStub: WearEngineHostApi {
  func hasAvailableDevices() throws -> Bool { false }
  func checkConnectedDevice() throws -> WearDeviceInfo? { nil }
  func registerMessageReceiver() throws -> Bool { false }
  func sendTextMessage(message: String) throws -> Bool { false }
  func sendWatchSyncPayload(jsonPayload: String) throws -> Bool { false }
  func pickImageAndRemoveBackground() throws -> String? {
    if #available(iOS 14.0, *) {
      return WatchThemeImagePicker().pickImageAndRemoveBackground()
    }
    return nil
  }
}

final class AlipayHostApiStub: AlipayHostApi {
  func launchAliPay(prepayData: String) throws -> AliPayResultCode { .unsupported }
}

enum NativePigeonRegistrar {
  static func register(binaryMessenger: FlutterBinaryMessenger) {
    HealthKitHostApiSetup.setUp(binaryMessenger: binaryMessenger, api: HealthKitHostApiStub())
    WearEngineHostApiSetup.setUp(binaryMessenger: binaryMessenger, api: WearEngineHostApiStub())
    AlipayHostApiSetup.setUp(binaryMessenger: binaryMessenger, api: AlipayHostApiStub())
  }
}

@available(iOS 14.0, *)
private final class WatchThemeImagePicker: NSObject, PHPickerViewControllerDelegate {
  private var continuation: CheckedContinuation<String?, Never>?

  func pickImageAndRemoveBackground() -> String? {
    if Thread.isMainThread {
      return runOnMain()
    }

    var result: String?
    let semaphore = DispatchSemaphore(value: 0)
    DispatchQueue.main.async {
      result = self.runOnMain()
      semaphore.signal()
    }
    semaphore.wait()
    return result
  }

  private func runOnMain() -> String? {
    var configuration = PHPickerConfiguration(photoLibrary: .shared())
    configuration.filter = .images
    configuration.selectionLimit = 1

    guard let presenter = UIApplication.shared.topMostViewController else {
      return nil
    }

    let picker = PHPickerViewController(configuration: configuration)
    picker.delegate = self

    return waitForPickerResult(picker: picker, presenter: presenter)
  }

  private func waitForPickerResult(
    picker: PHPickerViewController,
    presenter: UIViewController
  ) -> String? {
    var pickedPath: String?
    let semaphore = DispatchSemaphore(value: 0)

    Task { @MainActor in
      pickedPath = await withCheckedContinuation { continuation in
        self.continuation = continuation
        presenter.present(picker, animated: true)
      }
      semaphore.signal()
    }

    while semaphore.wait(timeout: .now() + 0.05) == .timedOut {
      RunLoop.main.run(mode: .default, before: Date(timeIntervalSinceNow: 0.05))
    }
    return pickedPath
  }

  nonisolated func picker(
    _ picker: PHPickerViewController,
    didFinishPicking results: [PHPickerResult]
  ) {
    Task { @MainActor in
      picker.dismiss(animated: true)

      guard let provider = results.first?.itemProvider,
            provider.canLoadObject(ofClass: UIImage.self) else {
        continuation?.resume(returning: nil)
        continuation = nil
        return
      }

      provider.loadObject(ofClass: UIImage.self) { object, _ in
        Task { @MainActor in
          guard let image = object as? UIImage else {
            self.continuation?.resume(returning: nil)
            self.continuation = nil
            return
          }
          let processed = await WatchThemeImageProcessor.removeBackground(from: image)
          let path = WatchThemeImageProcessor.savePNG(processed)
          self.continuation?.resume(returning: path)
          self.continuation = nil
        }
      }
    }
  }
}

private enum WatchThemeImageProcessor {
  static func removeBackground(from image: UIImage) async -> UIImage {
    guard #available(iOS 17.0, *),
          let cgImage = image.normalizedCGImage else {
      return image
    }

    return await Task.detached(priority: .userInitiated) {
      let request = VNGenerateForegroundInstanceMaskRequest()
      let handler = VNImageRequestHandler(cgImage: cgImage)

      do {
        try handler.perform([request])
        guard let observation = request.results?.first else {
          return image
        }

        let mask = try observation.generateScaledMaskForImage(
          forInstances: observation.allInstances,
          from: handler
        )
        return composite(image: cgImage, mask: mask) ?? image
      } catch {
        return image
      }
    }.value
  }

  static func savePNG(_ image: UIImage) -> String? {
    guard let data = image.pngData() else { return nil }
    let directory = FileManager.default.temporaryDirectory
      .appendingPathComponent("watch_theme", isDirectory: true)

    do {
      try FileManager.default.createDirectory(
        at: directory,
        withIntermediateDirectories: true
      )
      let file = directory.appendingPathComponent("\(UUID().uuidString).png")
      try data.write(to: file, options: .atomic)
      return file.path
    } catch {
      return nil
    }
  }

  private static func composite(image: CGImage, mask: CVPixelBuffer) -> UIImage? {
    let ciImage = CIImage(cgImage: image)
    let ciMask = CIImage(cvPixelBuffer: mask)
    guard let filter = CIFilter(name: "CIBlendWithMask") else {
      return nil
    }
    filter.setValue(ciImage, forKey: kCIInputImageKey)
    filter.setValue(ciMask, forKey: kCIInputMaskImageKey)
    filter.setValue(
      CIImage(color: .clear).cropped(to: ciImage.extent),
      forKey: kCIInputBackgroundImageKey
    )

    guard let output = filter.outputImage,
          let cgOutput = CIContext().createCGImage(output, from: ciImage.extent) else {
      return nil
    }
    return UIImage(cgImage: cgOutput, scale: 1, orientation: .up)
  }
}

private extension UIImage {
  var normalizedCGImage: CGImage? {
    if imageOrientation == .up, let cgImage {
      return cgImage
    }

    let format = UIGraphicsImageRendererFormat.default()
    format.scale = scale
    let renderer = UIGraphicsImageRenderer(size: size, format: format)
    return renderer.image { _ in
      draw(in: CGRect(origin: .zero, size: size))
    }.cgImage
  }
}

private extension UIApplication {
  var topMostViewController: UIViewController? {
    connectedScenes
      .compactMap { $0 as? UIWindowScene }
      .flatMap(\.windows)
      .first { $0.isKeyWindow }?
      .rootViewController?
      .topMostPresented
  }
}

private extension UIViewController {
  var topMostPresented: UIViewController {
    if let presentedViewController {
      return presentedViewController.topMostPresented
    }
    if let navigationController = self as? UINavigationController {
      return navigationController.visibleViewController?.topMostPresented ?? navigationController
    }
    if let tabBarController = self as? UITabBarController {
      return tabBarController.selectedViewController?.topMostPresented ?? tabBarController
    }
    return self
  }
}