588 lines
20 KiB
Swift
588 lines
20 KiB
Swift
import Flutter
|
|
import UIKit
|
|
import AVFoundation
|
|
import AVKit
|
|
|
|
private let thumbnailHTTPHeaderFieldsKey = "AVURLAssetHTTPHeaderFieldsKey"
|
|
|
|
private final class ThumbnailResponseGate {
|
|
private let lock = NSLock()
|
|
private var completed = false
|
|
|
|
func send(_ result: @escaping FlutterResult, data: Data?) {
|
|
lock.lock()
|
|
if completed {
|
|
lock.unlock()
|
|
return
|
|
}
|
|
completed = true
|
|
lock.unlock()
|
|
DispatchQueue.main.async {
|
|
result(data)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Keeps the system LaunchScreen as the only splash. Flutter would otherwise
|
|
/// instantiate that storyboard again and the icon appears a second time.
|
|
final class RunnerFlutterViewController: FlutterViewController {
|
|
override func loadDefaultSplashScreenView() -> Bool {
|
|
false
|
|
}
|
|
|
|
override func viewDidLoad() {
|
|
super.viewDidLoad()
|
|
view.backgroundColor = .black
|
|
}
|
|
}
|
|
|
|
/// iOS 影子播放器:独立 AVPlayer + AVPlayerLayer 专供画中画窗口。
|
|
/// Flutter 的 video_player 走纹理渲染,没有 AVPlayerLayer,无法直接创建
|
|
/// AVPictureInPictureController;小窗的画面与声音由影子接管,退出时把
|
|
/// 影子进度回传给 Dart,由 Flutter 播放器 seek 对齐后继续。
|
|
final class PipShadowCoordinator: NSObject, AVPictureInPictureControllerDelegate {
|
|
static let shared = PipShadowCoordinator()
|
|
|
|
private var player: AVPlayer?
|
|
private var playerLayer: AVPlayerLayer?
|
|
private var controller: AVPictureInPictureController?
|
|
private var endObserver: NSObjectProtocol?
|
|
private var possibleObservation: NSKeyValueObservation?
|
|
/// 画中画还原过渡层:原生窗口级红晕渐变,在系统还原动画开始时立即盖上,
|
|
/// 避免回前台瞬间 Flutter 表面未恢复显示黑帧;视频出帧后由 Dart 通知淡出。
|
|
private var restoreOverlay: UIView?
|
|
/// 系统要求 playerLayer 位于真实的 view 层级中才会判定画中画可用,
|
|
/// 因此用 1x1 透明宿主 view 承载,而不是直接挂在 window.layer 上。
|
|
private var hostView: UIView?
|
|
/// 影子图层就绪稍晚于创建,置位后由 KVO 自动拉起画中画。
|
|
private var pendingStart = false
|
|
private var startRetryWorkItems: [DispatchWorkItem] = []
|
|
|
|
weak var channel: FlutterMethodChannel?
|
|
|
|
/// active 变化回调;停止时把影子最终进度(毫秒)一并提供。
|
|
var onPipChanged: ((Bool, Int64) -> Void)?
|
|
/// 影子在画中画里播完当前集。
|
|
var onPlaybackEnded: (() -> Void)?
|
|
|
|
static var isSupported: Bool {
|
|
AVPictureInPictureController.isPictureInPictureSupported()
|
|
}
|
|
|
|
private var keyWindow: UIWindow? {
|
|
UIApplication.shared.connectedScenes
|
|
.compactMap { $0 as? UIWindowScene }
|
|
.flatMap { $0.windows }
|
|
.first { $0.isKeyWindow }
|
|
}
|
|
|
|
/// 首次创建影子:1x1 图层挂在 keyWindow 的宿主 view 里(肉眼不可见),静音待命。
|
|
/// [autoEnter] 为 true 时退后台由系统自动拉起画中画(需影子在播,见 armAutoEnter)。
|
|
func prepare(urlString: String, positionMs: Int64, autoEnter: Bool) {
|
|
teardown()
|
|
log("prepare begin supported=\(PipShadowCoordinator.isSupported) autoEnter=\(autoEnter)")
|
|
guard Self.isSupported else { return }
|
|
guard let url = URL(string: urlString) else {
|
|
log("prepare failed bad url \(urlString)")
|
|
return
|
|
}
|
|
guard let window = keyWindow else {
|
|
log("prepare failed no key window")
|
|
return
|
|
}
|
|
let item = AVPlayerItem(url: url)
|
|
let p = AVPlayer(playerItem: item)
|
|
p.isMuted = true
|
|
p.actionAtItemEnd = .pause
|
|
let layer = AVPlayerLayer()
|
|
layer.player = p
|
|
let host = UIView(frame: CGRect(x: 0, y: 0, width: 1, height: 1))
|
|
host.isUserInteractionEnabled = false
|
|
host.backgroundColor = .clear
|
|
window.addSubview(host)
|
|
layer.frame = host.bounds
|
|
host.layer.addSublayer(layer)
|
|
hostView = host
|
|
guard let c = AVPictureInPictureController(playerLayer: layer) else {
|
|
p.pause()
|
|
log("prepare failed no pip controller")
|
|
return
|
|
}
|
|
c.canStartPictureInPictureAutomaticallyFromInline = autoEnter
|
|
c.delegate = self
|
|
player = p
|
|
playerLayer = layer
|
|
controller = c
|
|
// 静音起播让 item 完成首帧解码,isPictureInPicturePossible 才会变 true。
|
|
p.play()
|
|
possibleObservation = c.observe(
|
|
\.isPictureInPicturePossible,
|
|
options: [.new, .initial]
|
|
) { [weak self] controller, change in
|
|
let value = change.newValue ?? controller.isPictureInPicturePossible
|
|
self?.log("possible changed to \(value)")
|
|
guard let self, self.pendingStart, controller.isPictureInPicturePossible else { return }
|
|
self.pendingStart = false
|
|
// 刚翻 true 的同一 runloop 里调用会被系统静默拒绝,推到下一拍再进。
|
|
DispatchQueue.main.async {
|
|
guard self.controller === controller, controller.isPictureInPicturePossible else {
|
|
self.log("start via kvo aborted (shadow replaced)")
|
|
return
|
|
}
|
|
self.log("start via kvo")
|
|
controller.startPictureInPicture()
|
|
}
|
|
}
|
|
log("shadow ready, seeking to \(positionMs)ms")
|
|
if positionMs > 0 {
|
|
p.seek(
|
|
to: CMTime(value: positionMs, timescale: 1000),
|
|
toleranceBefore: .zero,
|
|
toleranceAfter: .zero
|
|
)
|
|
}
|
|
registerEndObserver(for: item)
|
|
activateAudioSession()
|
|
}
|
|
|
|
/// 退出 App 前(inactive)把影子对齐到当前进度并静音起播:
|
|
/// auto PiP 只在「player 正在播放 + 图层离屏」时由系统触发。
|
|
func armAutoEnter(positionMs: Int64) {
|
|
guard let player, let controller else {
|
|
log("arm failed, shadow missing")
|
|
return
|
|
}
|
|
guard controller.canStartPictureInPictureAutomaticallyFromInline else {
|
|
log("arm failed, autoEnter disabled on shadow")
|
|
return
|
|
}
|
|
log("arm auto enter at \(positionMs)ms")
|
|
if positionMs > 0 {
|
|
player.seek(
|
|
to: CMTime(value: positionMs, timescale: 1000),
|
|
toleranceBefore: CMTime(value: 800, timescale: 1000),
|
|
toleranceAfter: CMTime(value: 800, timescale: 1000)
|
|
)
|
|
}
|
|
player.play()
|
|
}
|
|
|
|
/// 回前台后取消待命:影子暂停回静音,等待下一次退后台。
|
|
func disarmAutoEnter() {
|
|
guard let player else { return }
|
|
log("disarm auto enter")
|
|
player.pause()
|
|
player.isMuted = true
|
|
}
|
|
|
|
/// 进入画中画:图层就绪则立即进入,否则 KVO/延时重试在就绪后自动进入。
|
|
/// 仅当影子从未创建成功(prepare 失败)时返回 false。
|
|
func start() -> Bool {
|
|
guard let controller else {
|
|
log("start failed, shadow not created")
|
|
return false
|
|
}
|
|
log("start requested possible=\(controller.isPictureInPicturePossible)")
|
|
pendingStart = true
|
|
if controller.isPictureInPicturePossible {
|
|
pendingStart = false
|
|
controller.startPictureInPicture()
|
|
}
|
|
scheduleStartRetries()
|
|
return true
|
|
}
|
|
|
|
/// KVO 偶发不触发或流加载较慢时兜底:就绪后再试两次。
|
|
private func scheduleStartRetries() {
|
|
startRetryWorkItems.forEach { $0.cancel() }
|
|
startRetryWorkItems.removeAll()
|
|
for delay in [1.0, 2.5] {
|
|
let item = DispatchWorkItem { [weak self] in
|
|
guard
|
|
let self,
|
|
self.pendingStart,
|
|
let controller = self.controller,
|
|
controller.isPictureInPicturePossible
|
|
else { return }
|
|
self.log("start via retry after \(delay)s")
|
|
self.pendingStart = false
|
|
controller.startPictureInPicture()
|
|
}
|
|
startRetryWorkItems.append(item)
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: item)
|
|
}
|
|
}
|
|
|
|
/// 画中画内换集:只换 AVPlayerItem,不动 player/layer,窗口不中断。
|
|
func replaceItem(urlString: String, positionMs: Int64) {
|
|
guard let player, let url = URL(string: urlString) else { return }
|
|
let item = AVPlayerItem(url: url)
|
|
player.replaceCurrentItem(with: item)
|
|
registerEndObserver(for: item)
|
|
if positionMs > 0 {
|
|
player.seek(
|
|
to: CMTime(value: positionMs, timescale: 1000),
|
|
toleranceBefore: .zero,
|
|
toleranceAfter: .zero
|
|
)
|
|
}
|
|
}
|
|
|
|
/// 进入画中画后解除静音并起播(此时 Flutter 播放器已被暂停)。
|
|
func unmuteAndPlay() {
|
|
player?.isMuted = false
|
|
player?.play()
|
|
}
|
|
|
|
func positionMs() -> Int64 {
|
|
Int64(max(0, player?.currentTime().seconds ?? 0) * 1000)
|
|
}
|
|
|
|
func stopPip() {
|
|
controller?.stopPictureInPicture()
|
|
}
|
|
|
|
func teardown() {
|
|
startRetryWorkItems.forEach { $0.cancel() }
|
|
startRetryWorkItems.removeAll()
|
|
possibleObservation?.invalidate()
|
|
possibleObservation = nil
|
|
pendingStart = false
|
|
if let endObserver {
|
|
NotificationCenter.default.removeObserver(endObserver)
|
|
}
|
|
endObserver = nil
|
|
player?.pause()
|
|
player?.replaceCurrentItem(with: nil)
|
|
playerLayer?.removeFromSuperlayer()
|
|
playerLayer = nil
|
|
hostView?.removeFromSuperview()
|
|
hostView = nil
|
|
controller?.delegate = nil
|
|
controller = nil
|
|
player = nil
|
|
}
|
|
|
|
/// 诊断日志:NSLog 之外同步转发到 Dart,便于在 flutter run 控制台观察。
|
|
private func log(_ message: String) {
|
|
NSLog("pip: %@", message)
|
|
channel?.invokeMethod("pipDebug", arguments: message)
|
|
}
|
|
|
|
private func registerEndObserver(for item: AVPlayerItem) {
|
|
if let endObserver {
|
|
NotificationCenter.default.removeObserver(endObserver)
|
|
}
|
|
endObserver = NotificationCenter.default.addObserver(
|
|
forName: .AVPlayerItemDidPlayToEndTime,
|
|
object: item,
|
|
queue: .main
|
|
) { [weak self] _ in
|
|
self?.log("item did play to end")
|
|
self?.onPlaybackEnded?()
|
|
}
|
|
}
|
|
|
|
/// 画中画的声音走影子播放器,需要 playback 类别的音频会话保活。
|
|
private func activateAudioSession() {
|
|
do {
|
|
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .moviePlayback)
|
|
try AVAudioSession.sharedInstance().setActive(true)
|
|
} catch {
|
|
// 会话激活失败只影响后台出声,不阻断画中画。
|
|
}
|
|
}
|
|
|
|
func pictureInPictureControllerWillStartPictureInPicture(
|
|
_ pictureInPictureController: AVPictureInPictureController
|
|
) {
|
|
log("will start")
|
|
pendingStart = false
|
|
onPipChanged?(true, 0)
|
|
}
|
|
|
|
func pictureInPictureControllerWillStopPictureInPicture(
|
|
_ pictureInPictureController: AVPictureInPictureController
|
|
) {
|
|
log("will stop, showing restore overlay")
|
|
showRestoreOverlay()
|
|
}
|
|
|
|
func pictureInPictureControllerDidStopPictureInPicture(
|
|
_ pictureInPictureController: AVPictureInPictureController
|
|
) {
|
|
log("did stop")
|
|
showRestoreOverlay()
|
|
onPipChanged?(false, positionMs())
|
|
}
|
|
|
|
/// 盖原生过渡层(幂等):主题红晕渐变,不依赖 Flutter 渲染。
|
|
func showRestoreOverlay() {
|
|
guard let window = keyWindow else { return }
|
|
restoreOverlay?.removeFromSuperview()
|
|
let overlay = UIView(frame: window.bounds)
|
|
overlay.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
|
let gradient = CAGradientLayer()
|
|
gradient.frame = overlay.bounds
|
|
gradient.colors = [
|
|
UIColor(red: 0.298, green: 0.086, blue: 0.102, alpha: 1).cgColor,
|
|
UIColor(red: 0.118, green: 0.043, blue: 0.047, alpha: 1).cgColor,
|
|
UIColor(red: 0.063, green: 0.063, blue: 0.071, alpha: 1).cgColor,
|
|
]
|
|
gradient.locations = [0, 0.22, 0.62]
|
|
gradient.startPoint = CGPoint(x: 0.5, y: 0)
|
|
gradient.endPoint = CGPoint(x: 0.5, y: 1)
|
|
overlay.layer.addSublayer(gradient)
|
|
window.addSubview(overlay)
|
|
restoreOverlay = overlay
|
|
}
|
|
|
|
/// 淡出并移除过渡层(视频首帧已确认后由 Dart 调用)。
|
|
func hideRestoreOverlay() {
|
|
guard let overlay = restoreOverlay else { return }
|
|
restoreOverlay = nil
|
|
UIView.animate(
|
|
withDuration: 0.25,
|
|
animations: { overlay.alpha = 0 }
|
|
) { _ in overlay.removeFromSuperview() }
|
|
}
|
|
}
|
|
|
|
@main
|
|
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
|
|
private let wirelessDataPrompt = WirelessDataPrompt()
|
|
private let thumbnailQueue = DispatchQueue(
|
|
label: "com.stormg.lumadrama.thumbnail",
|
|
qos: .userInitiated
|
|
)
|
|
|
|
override func application(
|
|
_ application: UIApplication,
|
|
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
|
) -> Bool {
|
|
UIApplication.shared.isIdleTimerDisabled = true
|
|
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
|
}
|
|
|
|
override func applicationDidBecomeActive(_ application: UIApplication) {
|
|
super.applicationDidBecomeActive(application)
|
|
requestWirelessDataPrompt()
|
|
}
|
|
|
|
func requestWirelessDataPrompt() {
|
|
wirelessDataPrompt.requestSystemPrompt()
|
|
}
|
|
|
|
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
|
|
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
|
|
registerWirelessDataChannel(engineBridge)
|
|
registerDeviceIdChannel(engineBridge)
|
|
registerPipChannel(engineBridge)
|
|
let channel = FlutterMethodChannel(
|
|
name: "com.stormg.lumadrama/share",
|
|
binaryMessenger: engineBridge.applicationRegistrar.messenger()
|
|
)
|
|
channel.setMethodCallHandler { [weak self] call, result in
|
|
guard call.method == "shareText",
|
|
let arguments = call.arguments as? [String: Any],
|
|
let text = arguments["text"] as? String,
|
|
!text.isEmpty,
|
|
let controller = self?.window?.rootViewController else {
|
|
result(FlutterError(code: "share_unavailable", message: "Share sheet is unavailable", details: nil))
|
|
return
|
|
}
|
|
let activity = UIActivityViewController(activityItems: [text], applicationActivities: nil)
|
|
activity.popoverPresentationController?.sourceView = controller.view
|
|
activity.completionWithItemsHandler = { _, _, _, _ in result(nil) }
|
|
controller.present(activity, animated: true)
|
|
}
|
|
|
|
let thumbnailChannel = FlutterMethodChannel(
|
|
name: "com.stormg.lumadrama/thumbnail",
|
|
binaryMessenger: engineBridge.applicationRegistrar.messenger()
|
|
)
|
|
thumbnailChannel.setMethodCallHandler { [weak self] call, result in
|
|
guard call.method == "frame" else {
|
|
result(FlutterMethodNotImplemented)
|
|
return
|
|
}
|
|
guard let self,
|
|
let arguments = call.arguments as? [String: Any],
|
|
let timeNumber = arguments["timeMs"] as? NSNumber else {
|
|
result(nil)
|
|
return
|
|
}
|
|
let localPath = arguments["localPath"] as? String ?? ""
|
|
let remoteUrl = arguments["url"] as? String ?? ""
|
|
let headers = arguments["headers"] as? [String: String] ?? [:]
|
|
let assetUrl: URL?
|
|
if !localPath.isEmpty {
|
|
assetUrl = localPath.hasPrefix("file:")
|
|
? URL(string: localPath)
|
|
: URL(fileURLWithPath: localPath)
|
|
} else if !remoteUrl.isEmpty {
|
|
assetUrl = URL(string: remoteUrl)
|
|
} else {
|
|
assetUrl = nil
|
|
}
|
|
guard let assetUrl else {
|
|
result(nil)
|
|
return
|
|
}
|
|
|
|
let options: [String: Any]? = headers.isEmpty
|
|
? nil
|
|
: [thumbnailHTTPHeaderFieldsKey: headers]
|
|
let asset = AVURLAsset(url: assetUrl, options: options)
|
|
let generator = AVAssetImageGenerator(asset: asset)
|
|
generator.appliesPreferredTrackTransform = true
|
|
generator.maximumSize = CGSize(width: 240, height: 426)
|
|
generator.requestedTimeToleranceBefore = CMTime(seconds: 0.5, preferredTimescale: 600)
|
|
generator.requestedTimeToleranceAfter = CMTime(seconds: 0.5, preferredTimescale: 600)
|
|
let time = CMTime(value: timeNumber.int64Value, timescale: 1000)
|
|
let gate = ThumbnailResponseGate()
|
|
|
|
self.thumbnailQueue.async {
|
|
do {
|
|
let image = try generator.copyCGImage(at: time, actualTime: nil)
|
|
let data = UIImage(cgImage: image).jpegData(compressionQuality: 0.7)
|
|
gate.send(result, data: data)
|
|
} catch {
|
|
gate.send(result, data: nil)
|
|
}
|
|
}
|
|
DispatchQueue.global(qos: .userInitiated).asyncAfter(
|
|
deadline: .now() + 1.5
|
|
) {
|
|
gate.send(result, data: nil)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// iOS 画中画:影子播放器通道,与 Android 共用同一 channel 名。
|
|
private func registerPipChannel(_ engineBridge: FlutterImplicitEngineBridge) {
|
|
let channel = FlutterMethodChannel(
|
|
name: "com.stormg.lumadrama/pip",
|
|
binaryMessenger: engineBridge.applicationRegistrar.messenger()
|
|
)
|
|
let pip = PipShadowCoordinator.shared
|
|
pip.channel = channel
|
|
channel.setMethodCallHandler { call, result in
|
|
switch call.method {
|
|
case "isAvailable":
|
|
result(PipShadowCoordinator.isSupported)
|
|
case "enter":
|
|
guard let args = call.arguments as? [String: Any],
|
|
let url = args["url"] as? String else {
|
|
result(false)
|
|
return
|
|
}
|
|
let positionMs = (args["positionMs"] as? NSNumber)?.int64Value ?? 0
|
|
pip.prepare(urlString: url, positionMs: positionMs, autoEnter: false)
|
|
// 等一拍让 AVPlayerLayer 完成挂载再请求进入画中画。
|
|
DispatchQueue.main.async {
|
|
result(pip.start())
|
|
}
|
|
case "prepareShadow":
|
|
guard let args = call.arguments as? [String: Any],
|
|
let url = args["url"] as? String else {
|
|
result(nil)
|
|
return
|
|
}
|
|
pip.prepare(
|
|
urlString: url,
|
|
positionMs: (args["positionMs"] as? NSNumber)?.int64Value ?? 0,
|
|
autoEnter: (args["autoEnter"] as? NSNumber)?.boolValue ?? false
|
|
)
|
|
result(nil)
|
|
case "armAutoEnter":
|
|
pip.armAutoEnter(
|
|
positionMs: (call.arguments as? [String: Any])?["positionMs"] as? Int64 ?? 0
|
|
)
|
|
result(nil)
|
|
case "showRestoreOverlay":
|
|
pip.showRestoreOverlay()
|
|
result(nil)
|
|
case "hideRestoreOverlay":
|
|
pip.hideRestoreOverlay()
|
|
result(nil)
|
|
case "disarmAutoEnter":
|
|
pip.disarmAutoEnter()
|
|
result(nil)
|
|
case "unmute":
|
|
pip.unmuteAndPlay()
|
|
result(nil)
|
|
case "replaceItem":
|
|
guard let args = call.arguments as? [String: Any],
|
|
let url = args["url"] as? String else {
|
|
result(nil)
|
|
return
|
|
}
|
|
pip.replaceItem(
|
|
urlString: url,
|
|
positionMs: (args["positionMs"] as? NSNumber)?.int64Value ?? 0
|
|
)
|
|
result(nil)
|
|
case "stop":
|
|
pip.stopPip()
|
|
result(nil)
|
|
case "teardown":
|
|
pip.teardown()
|
|
result(nil)
|
|
default:
|
|
result(FlutterMethodNotImplemented)
|
|
}
|
|
}
|
|
pip.onPipChanged = { active, positionMs in
|
|
channel.invokeMethod(
|
|
"onPipChanged",
|
|
arguments: ["active": active, "positionMs": positionMs]
|
|
)
|
|
}
|
|
pip.onPlaybackEnded = {
|
|
channel.invokeMethod("onPipPlaybackEnded", arguments: nil)
|
|
}
|
|
}
|
|
|
|
/// 设备标识:IDFV 免权限,卸载本 App 不变,避免客户端自生成的 id 每次重装都变。
|
|
/// 同通道顺带提供设备区域(ISO-2),注册时作为服务端 IP 解析失败后的兜底。
|
|
private func registerDeviceIdChannel(_ engineBridge: FlutterImplicitEngineBridge) {
|
|
let channel = FlutterMethodChannel(
|
|
name: "com.stormg.lumadrama/device",
|
|
binaryMessenger: engineBridge.applicationRegistrar.messenger()
|
|
)
|
|
channel.setMethodCallHandler { call, result in
|
|
switch call.method {
|
|
case "idfv":
|
|
result(UIDevice.current.identifierForVendor?.uuidString)
|
|
case "region":
|
|
if #available(iOS 16.0, *) {
|
|
result(Locale.current.region?.identifier)
|
|
} else {
|
|
result(Locale.current.regionCode)
|
|
}
|
|
default:
|
|
result(FlutterMethodNotImplemented)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func registerWirelessDataChannel(_ engineBridge: FlutterImplicitEngineBridge) {
|
|
let channel = FlutterMethodChannel(
|
|
name: WirelessDataPrompt.channelName,
|
|
binaryMessenger: engineBridge.applicationRegistrar.messenger()
|
|
)
|
|
channel.setMethodCallHandler { [weak self] call, result in
|
|
guard call.method == WirelessDataPrompt.awaitDecisionMethod else {
|
|
result(FlutterMethodNotImplemented)
|
|
return
|
|
}
|
|
guard let self else {
|
|
result(nil)
|
|
return
|
|
}
|
|
self.wirelessDataPrompt.awaitDecision(result)
|
|
}
|
|
}
|
|
}
|