diff --git a/VoidLink/AppDelegate.m b/VoidLink/AppDelegate.m index 6339fd32..9f2bda4f 100644 --- a/VoidLink/AppDelegate.m +++ b/VoidLink/AppDelegate.m @@ -61,6 +61,7 @@ static NSString* DB_NAME = @"Limelight_iOS.sqlite"; // Override point for command tool customization after application launch (works only when user default is nil) [CommandManager presetDefaultCommands]; [GenericUtils installSegmentedControlPreviousSelectionTracking]; + [IAPManager shared]; // For iOS 12 and below, we need to manually create the window if (@available(iOS 13.0, *)) { diff --git a/VoidLink/Input/PencilHandler.swift b/VoidLink/Input/PencilHandler.swift index d907bb6a..cd198fff 100644 --- a/VoidLink/Input/PencilHandler.swift +++ b/VoidLink/Input/PencilHandler.swift @@ -260,17 +260,18 @@ import UIKit let strokePhase = getStrokePhase(sampleIndex: self.strokeSampleIndex) + let forceMapping = pressureCurveEnabled ? self.strokeLUT.value(at: force) : force switch strokePhase { case .phase1: - targetForce = pencilTickEnabled ? 0 : self.strokeLUT.value(at: force) + targetForce = pencilTickEnabled ? 0 : forceMapping case .phase2: - targetForce = self.strokeLUT.value(at: force) + targetForce = forceMapping targetForce = max(targetForce,previousTargetForce) equalizedForce = targetForce*(phase2EqualizationStrength - equalizationStep*Float(strokeSampleIndex-phase1StrokeSampleIndexEnd-1)) case .phase3: - targetForce = self.strokeLUT.value(at: force) + targetForce = forceMapping } - targetForce = self.pressureCurveEnabled ? targetForce : force + targetForce = pencilTickEnabled ? targetForce : force previousForce = force previousTargetForce = targetForce diff --git a/VoidLink/Localization/Localizable.xcstrings b/VoidLink/Localization/Localizable.xcstrings index 902512e7..3512d9fb 100644 --- a/VoidLink/Localization/Localizable.xcstrings +++ b/VoidLink/Localization/Localizable.xcstrings @@ -513,6 +513,29 @@ } } }, + "[%@] has been restored" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "【%@】已恢复" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "「%@」已恢復" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "「%@」已恢復" + } + } + } + }, "%@ is currently running" : { "extractionState" : "manual", "localizations" : { @@ -715,7 +738,7 @@ }, "zh-HK" : { "stringUnit" : { - "state" : "needs_review", + "state" : "translated", "value" : "方向鍵" } } @@ -6629,6 +6652,29 @@ } } }, + "Drawing Toolkit already purchased" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "您已拥有【绘画进阶套件】" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "您已擁有「繪畫進階套件」" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "您已擁有「繪畫進階套件」" + } + } + } + }, "Drift Correction" : { "extractionState" : "manual", "localizations" : { @@ -6721,7 +6767,7 @@ }, "zh-HK" : { "stringUnit" : { - "state" : "translated", + "state" : "needs_review", "value" : "一塊啥都不幹的擋板區域" } } @@ -9085,29 +9131,6 @@ } } }, - "I'm an artist" : { - "extractionState" : "manual", - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "我是画手" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "我是畫手" - } - }, - "zh-HK" : { - "stringUnit" : { - "state" : "translated", - "value" : "我是畫手" - } - } - } - }, "Import" : { "extractionState" : "manual", "localizations" : { @@ -9154,6 +9177,52 @@ } } }, + "In-app-purchase not available" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "无法使用 App 内购买" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "無法使用 App 內購買" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "無法使用 App 內購買" + } + } + } + }, + "In-app-purchase not available on this device." : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "此设备无法使用该 App 内购买项目。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "此裝置無法使用該 App 內購買項目。" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "此裝置無法使用該 App 內購買項目。" + } + } + } + }, "Indicator offset: %.0f" : { "extractionState" : "manual", "localizations" : { @@ -10971,19 +11040,19 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "购买 [%@] 可解锁:\n- 专业级压力曲线工具。\n- 在远程绘图流程中提升笔触传输效率与响应表现,并支持用户自定义的双击及捏合键盘/鼠标快捷键。\n- 专属绘图相关的屏显按钮。\n以及更多功能..." + "value" : "购买 [%@] 可解锁:\n- 专业级笔压曲线工具。\n- 远程绘画笔迹传输、笔触响应性能优化。\n- 支持用户自定义的双击及捏合键盘/鼠标快捷键。\n- 专属绘图相关的屏显按钮。\n以及更多...\n未购买该套件不影响基础绘画功能。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "解鎖 [%@] :\n- 專業級壓力曲線工具。\n- 在遠端繪圖流程中提升筆觸傳輸效率與響應表現,並支援使用者自訂的雙擊及捏合鍵盤/滑鼠快捷鍵。\n- 專屬繪圖相關的螢幕顯示按鈕。\n- 以及更多功能..." + "value" : "解鎖 [%@] :\n- 專業級筆壓曲線工具。\n- 最佳化遠端繪畫的筆跡傳輸與筆觸響應效能。\n- 並支援使用者自訂的雙擊及捏合鍵盤/滑鼠快捷鍵。\n- 專屬繪圖相關的螢幕顯示按鈕。\n以及更多...\n未購買此套件不影響基礎繪畫功能。" } }, "zh-HK" : { "stringUnit" : { "state" : "translated", - "value" : "解鎖 [%@] :\n- 專業級壓力曲線工具。\n- 在遠端繪圖流程中提升筆觸傳輸效率與響應表現,並支援使用者自訂的雙擊及捏合鍵盤/滑鼠快捷鍵。\n- 專屬繪圖相關的螢幕顯示按鈕。\n- 以及更多功能...\n基礎繪畫功能無需購買 [%@] 即可使用。" + "value" : "解鎖 [%@] :\n- 專業級筆壓曲線工具。\n- 最佳化遠端繪畫的筆跡傳輸與筆觸響應效能。\n- 並支援使用者自訂的雙擊及捏合鍵盤/滑鼠快捷鍵。\n- 專屬繪圖相關的螢幕顯示按鈕。\n以及更多...\n未購買此套件不影響基礎繪畫功能。" } } } @@ -16353,6 +16422,29 @@ } } }, + "Unable to detect purchased product: %@" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "未检测到已购产品【%@】" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "未偵測到已購產品「%@」" + } + }, + "zh-HK" : { + "stringUnit" : { + "state" : "translated", + "value" : "未偵測到已購產品「%@」" + } + } + } + }, "Undo" : { "extractionState" : "manual", "localizations" : { diff --git a/VoidLink/Utility/GenericUtils.swift b/VoidLink/Utility/GenericUtils.swift index 0e1ba9a9..5993297f 100644 --- a/VoidLink/Utility/GenericUtils.swift +++ b/VoidLink/Utility/GenericUtils.swift @@ -702,6 +702,44 @@ import UIKit } return nil } + + @objc static func rootViewController() -> UIViewController? { + if #available(iOS 13.0, *) { + return UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .first { $0.activationState == .foregroundActive }? + .windows + .first { $0.isKeyWindow }? + .rootViewController + } else { + return UIApplication.shared.keyWindow?.rootViewController + } + } + + @objc static func topViewController() -> UIViewController? { + return topViewController(from: rootViewController()) + } + + private static func topViewController(from rootViewController: UIViewController?) -> UIViewController? { + if let navigationController = rootViewController as? UINavigationController { + return topViewController(from: navigationController.visibleViewController) + } + + if let tabBarController = rootViewController as? UITabBarController { + return topViewController(from: tabBarController.selectedViewController) + } + + if let splitViewController = rootViewController as? UISplitViewController, + let lastViewController = splitViewController.viewControllers.last { + return topViewController(from: lastViewController) + } + + if let presentedViewController = rootViewController?.presentedViewController { + return topViewController(from: presentedViewController) + } + + return rootViewController + } } extension UIView { diff --git a/VoidLink/Utility/IAPManager.swift b/VoidLink/Utility/IAPManager.swift index 16e2a00e..f17d0d72 100644 --- a/VoidLink/Utility/IAPManager.swift +++ b/VoidLink/Utility/IAPManager.swift @@ -8,6 +8,7 @@ import Foundation import StoreKit +import UIKit @objc public enum PurchaseStatus: Int { case notPurchased @@ -135,6 +136,11 @@ import StoreKit await listenForTransactions() } } + if #available(iOS 16.4, *) { + Task { + await listenForPurchaseIntents() + } + } } // MARK: - Fetch Products (StoreKit 2) @@ -199,6 +205,18 @@ import StoreKit @available(iOS 15.0, *, *) private func purchaseStoreKit2(_ product: AddOnProduct) async { + guard IAPManager.canPurchase(product) else { + AlertControllerUtil.showAlert( + in: GenericUtils.topViewController(), + title: "In-app-purchase not available".localized, + message: "In-app-purchase not available on this device.".localized, + withCancel: false, + buttonTitle: "OK".localized, + countdown: 6 + ) + return + } + let pid = product.productId() do { let products = try await Product.products(for: [pid]) @@ -211,30 +229,7 @@ import StoreKit } let result = try await sk2p.purchase() - - switch result { - case .success(let verification): - switch verification { - case .verified(let transaction): - await MainActor.run { - self.delegate?.iapManagerDidPurchase(product) - } - await transaction.finish() - IAPManager.handlePurchaseSuccess(product) - NotificationCenter.default.post(name: product.purchaseSucceededNotification(), object: nil) - case .unverified(_, let err): - NotificationCenter.default.post(name: product.purchaseAbortedNotification(), object: PurchaseInterruption.unlockNow, userInfo:["interruption": PurchaseInterruption.unlockNow.rawValue]) - await MainActor.run { - self.delegate?.iapManagerDidFailWithError(err) - } - } - case .userCancelled: - NotificationCenter.default.post(name: product.purchaseAbortedNotification(), object: PurchaseInterruption.unlockNow, userInfo:["interruption": PurchaseInterruption.unlockNow.rawValue]) - break - default: - NotificationCenter.default.post(name: product.purchaseAbortedNotification(), object: PurchaseInterruption.unlockNow, userInfo:["interruption": PurchaseInterruption.unlockNow.rawValue]) - break - } + await handleStoreKit2PurchaseResult(result, product: product) } catch { await MainActor.run { NotificationCenter.default.post(name: product.purchaseAbortedNotification(), object: PurchaseInterruption.unlockNow, userInfo:["interruption": PurchaseInterruption.unlockNow.rawValue]) @@ -243,12 +238,47 @@ import StoreKit } } + private static func canPurchase(_ product: AddOnProduct) -> Bool { + switch product { + case .PencilProPack: + return GenericUtils.isIPad() + default: + return true + } + } + + @available(iOS 15.0, *) + private func handleStoreKit2PurchaseResult(_ result: Product.PurchaseResult, + product: AddOnProduct) async { + switch result { + case .success(let verification): + switch verification { + case .verified(let transaction): + await MainActor.run { + self.delegate?.iapManagerDidPurchase(product) + } + await transaction.finish() + IAPManager.handlePurchaseSuccess(product) + case .unverified(_, let err): + NotificationCenter.default.post(name: product.purchaseAbortedNotification(), object: PurchaseInterruption.unlockNow, userInfo:["interruption": PurchaseInterruption.unlockNow.rawValue]) + await MainActor.run { + self.delegate?.iapManagerDidFailWithError(err) + } + } + case .userCancelled: + NotificationCenter.default.post(name: product.purchaseAbortedNotification(), object: PurchaseInterruption.unlockNow, userInfo:["interruption": PurchaseInterruption.unlockNow.rawValue]) + default: + NotificationCenter.default.post(name: product.purchaseAbortedNotification(), object: PurchaseInterruption.unlockNow, userInfo:["interruption": PurchaseInterruption.unlockNow.rawValue]) + } + } + @objc static func handlePurchaseSuccess(_ product: AddOnProduct) { switch product { case .PencilProPack: let dataMan = DataManager() let settings = dataMan.retrieveSettings() settings?.onscreenControls = 1 + settings?.pencilTickMode = NSNumber(value: PencilTickMode.ManualTick.rawValue) dataMan.saveData() let profileMan = OSCProfilesManager.sharedManager(.zero) var toolkitProfileIndex = profileMan.getIndex(byName: "Pencil Pro") @@ -258,6 +288,7 @@ import StoreKit default: break } + NotificationCenter.default.post(name: product.purchaseSucceededNotification(), object: nil) } private func purchaseLegacy(_ product: AddOnProduct) { @@ -318,6 +349,28 @@ import StoreKit } } } + + @available(iOS 16.4, *) + private func listenForPurchaseIntents() async { + for await intent in PurchaseIntent.intents { + guard let product = AddOnProduct.from(productId: intent.product.id) else { + continue + } + guard IAPManager.canPurchase(product) else { + continue + } + + do { + let result = try await intent.product.purchase() + await handleStoreKit2PurchaseResult(result, product: product) + } catch { + await MainActor.run { + NotificationCenter.default.post(name: product.purchaseAbortedNotification(), object: PurchaseInterruption.unlockNow, userInfo:["interruption": PurchaseInterruption.unlockNow.rawValue]) + self.delegate?.iapManagerDidFailWithError(error) + } + } + } + } // MARK: Utils @objc static public func checkPurchaseInfo( @@ -382,8 +435,30 @@ import StoreKit do { if #available(iOS 15.0, *) { try await AppStore.sync() - } else { - // Fallback on earlier versions + IAPManager.checkPurchaseInfo(product) { info in + if info.valid { + IAPManager.handlePurchaseSuccess(product) + AlertControllerUtil.showAlert( + in: viewController, + title: "", + message: LocalizationHelper.localizedString(forKey:"[%@] has been restored", product.productName()), + withCancel: false, + buttonTitle: "OK".localized, + countdown: 0 + ) + } + else { + NotificationCenter.default.post(name: product.purchaseAbortedNotification(), object: PurchaseInterruption.restore, userInfo:["interruption": PurchaseInterruption.restore.rawValue]) + AlertControllerUtil.showAlert( + in: viewController, + title: "", + message: LocalizationHelper.localizedString(forKey:"Unable to detect purchased product: %@", product.productName()), + withCancel: false, + buttonTitle: "OK".localized, + countdown: 0 + ) + } + } } } catch { NotificationCenter.default.post(name: product.purchaseAbortedNotification(), object: PurchaseInterruption.restore, userInfo:["interruption": PurchaseInterruption.restore.rawValue]) diff --git a/VoidLink/ViewControllers/SettingsViewController.m b/VoidLink/ViewControllers/SettingsViewController.m index 62c11419..3bcdcd47 100644 --- a/VoidLink/ViewControllers/SettingsViewController.m +++ b/VoidLink/ViewControllers/SettingsViewController.m @@ -3849,6 +3849,7 @@ BOOL isCustomResolution(int resolutionSelected) { - (void)pencilProPurchaseSucceeded:(NSNotification *)notification{ self.onScreenWidgetSelector.selectedSegmentIndex = OnScreenControlsLevelCustom; + self.pencilTickSelector.selectedSegmentIndex = ManualTick; } - (void)pencilTickIntervalSliderMoved:(UISlider* )sender{