@onekeyfe/react-native-device-utils 6.7.0-alpha.385 → 6.7.0-alpha.386
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/android/src/main/java/com/margelo/nitro/reactnativedeviceutils/ReactNativeDeviceUtils.kt +29 -0
- package/ios/DualScreenDetector.swift +158 -0
- package/ios/MainMenuController.swift +170 -0
- package/ios/ReactNativeDeviceUtils.swift +29 -4
- package/ios/WindowControlsInsetsObserver.swift +149 -0
- package/lib/typescript/src/ReactNativeDeviceUtils.nitro.d.ts +46 -0
- package/nitrogen/generated/android/c++/JFunc_void_MainMenuCommand.hpp +77 -0
- package/nitrogen/generated/android/c++/JFunc_void_WindowControlsInsets.hpp +77 -0
- package/nitrogen/generated/android/c++/JHybridReactNativeDeviceUtilsSpec.cpp +45 -0
- package/nitrogen/generated/android/c++/JHybridReactNativeDeviceUtilsSpec.hpp +6 -0
- package/nitrogen/generated/android/c++/JMainMenuCommand.hpp +61 -0
- package/nitrogen/generated/android/c++/JMainMenuConfig.hpp +90 -0
- package/nitrogen/generated/android/c++/JMainMenuLink.hpp +61 -0
- package/nitrogen/generated/android/c++/JWindowControlsInsets.hpp +69 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativedeviceutils/Func_void_MainMenuCommand.kt +78 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativedeviceutils/Func_void_WindowControlsInsets.kt +78 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativedeviceutils/HybridReactNativeDeviceUtilsSpec.kt +34 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativedeviceutils/MainMenuCommand.kt +24 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativedeviceutils/MainMenuConfig.kt +66 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativedeviceutils/MainMenuLink.kt +56 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativedeviceutils/WindowControlsInsets.kt +66 -0
- package/nitrogen/generated/android/reactnativedeviceutilsOnLoad.cpp +4 -0
- package/nitrogen/generated/ios/ReactNativeDeviceUtils-Swift-Cxx-Bridge.cpp +16 -0
- package/nitrogen/generated/ios/ReactNativeDeviceUtils-Swift-Cxx-Bridge.hpp +73 -0
- package/nitrogen/generated/ios/ReactNativeDeviceUtils-Swift-Cxx-Umbrella.hpp +12 -0
- package/nitrogen/generated/ios/c++/HybridReactNativeDeviceUtilsSpecSwift.hpp +55 -1
- package/nitrogen/generated/ios/swift/Func_void_MainMenuCommand.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_WindowControlsInsets.swift +46 -0
- package/nitrogen/generated/ios/swift/HybridReactNativeDeviceUtilsSpec.swift +6 -0
- package/nitrogen/generated/ios/swift/HybridReactNativeDeviceUtilsSpec_cxx.swift +79 -0
- package/nitrogen/generated/ios/swift/MainMenuCommand.swift +44 -0
- package/nitrogen/generated/ios/swift/MainMenuConfig.swift +50 -0
- package/nitrogen/generated/ios/swift/MainMenuLink.swift +34 -0
- package/nitrogen/generated/ios/swift/WindowControlsInsets.swift +44 -0
- package/nitrogen/generated/shared/c++/HybridReactNativeDeviceUtilsSpec.cpp +6 -0
- package/nitrogen/generated/shared/c++/HybridReactNativeDeviceUtilsSpec.hpp +15 -0
- package/nitrogen/generated/shared/c++/MainMenuCommand.hpp +80 -0
- package/nitrogen/generated/shared/c++/MainMenuConfig.hpp +98 -0
- package/nitrogen/generated/shared/c++/MainMenuLink.hpp +87 -0
- package/nitrogen/generated/shared/c++/WindowControlsInsets.hpp +95 -0
- package/package.json +1 -1
- package/src/ReactNativeDeviceUtils.nitro.ts +54 -0
package/android/src/main/java/com/margelo/nitro/reactnativedeviceutils/ReactNativeDeviceUtils.kt
CHANGED
|
@@ -765,6 +765,35 @@ class ReactNativeDeviceUtils : HybridReactNativeDeviceUtilsSpec(), LifecycleEven
|
|
|
765
765
|
spanningChangedListeners.removeIf { it.id == id }
|
|
766
766
|
}
|
|
767
767
|
|
|
768
|
+
// Android has no window controls inside the app window: the value is always
|
|
769
|
+
// zero and never changes, so listeners are accepted but never called.
|
|
770
|
+
override fun getWindowControlsInsets(): WindowControlsInsets =
|
|
771
|
+
WindowControlsInsets(0.0, 0.0, 0.0, 0.0)
|
|
772
|
+
|
|
773
|
+
override fun addWindowControlsInsetsChangedListener(
|
|
774
|
+
callback: (insets: WindowControlsInsets) -> Unit
|
|
775
|
+
): Double = synchronized(this) {
|
|
776
|
+
val id = nextListenerId
|
|
777
|
+
nextListenerId++
|
|
778
|
+
return@synchronized id
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
override fun removeWindowControlsInsetsChangedListener(id: Double) {}
|
|
782
|
+
|
|
783
|
+
// Android has no app-owned main menu bar: the config is ignored, and
|
|
784
|
+
// listeners are accepted but never called.
|
|
785
|
+
override fun setMainMenuConfig(config: MainMenuConfig) {}
|
|
786
|
+
|
|
787
|
+
override fun addMainMenuCommandListener(
|
|
788
|
+
callback: (command: MainMenuCommand) -> Unit
|
|
789
|
+
): Double = synchronized(this) {
|
|
790
|
+
val id = nextListenerId
|
|
791
|
+
nextListenerId++
|
|
792
|
+
return@synchronized id
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
override fun removeMainMenuCommandListener(id: Double) {}
|
|
796
|
+
|
|
768
797
|
private fun startObservingLayoutChanges() {
|
|
769
798
|
if (isObservingLayoutChanges) {
|
|
770
799
|
return
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import UIKit
|
|
3
|
+
import ReactNativeNativeLogger
|
|
4
|
+
|
|
5
|
+
/// Detects foldable iPhones such as iPhone Duo.
|
|
6
|
+
///
|
|
7
|
+
/// Detection runs in two steps so the answer is available synchronously at
|
|
8
|
+
/// launch and still covers models that are not listed yet:
|
|
9
|
+
/// 1. The model identifier is matched against known foldable iPhones.
|
|
10
|
+
/// 2. A `UIHingeInteraction` (iOS 27.1+) confirms the hinge at runtime.
|
|
11
|
+
/// A positive result is persisted, so later launches answer synchronously
|
|
12
|
+
/// even for models that only the hinge check recognized.
|
|
13
|
+
final class DualScreenDetector {
|
|
14
|
+
static let shared = DualScreenDetector()
|
|
15
|
+
|
|
16
|
+
private static let cacheKey = "1k_is_dual_screen_device"
|
|
17
|
+
private static let foldableModelIdentifiers: Set<String> = [
|
|
18
|
+
"iPhone19,4", // iPhone Duo
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
private let lock = NSLock()
|
|
22
|
+
private var isDualScreen: Bool
|
|
23
|
+
private var spanning = false
|
|
24
|
+
private var listeners: [Double: (Bool) -> Void] = [:]
|
|
25
|
+
private var nextListenerId: Double = 1
|
|
26
|
+
private var hingeObserver: AnyObject?
|
|
27
|
+
|
|
28
|
+
private init() {
|
|
29
|
+
let modelIdentifier = DualScreenDetector.modelIdentifier()
|
|
30
|
+
let isKnownModel = DualScreenDetector.foldableModelIdentifiers.contains(modelIdentifier)
|
|
31
|
+
let isCached = UserDefaults.standard.bool(forKey: DualScreenDetector.cacheKey)
|
|
32
|
+
isDualScreen = isKnownModel || isCached
|
|
33
|
+
if isKnownModel && !isCached {
|
|
34
|
+
UserDefaults.standard.set(true, forKey: DualScreenDetector.cacheKey)
|
|
35
|
+
}
|
|
36
|
+
OneKeyLog.info(
|
|
37
|
+
"DeviceUtils",
|
|
38
|
+
"Dual screen detection: model=\(modelIdentifier), knownModel=\(isKnownModel), cached=\(isCached)"
|
|
39
|
+
)
|
|
40
|
+
installHingeObserver()
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
static func modelIdentifier() -> String {
|
|
44
|
+
#if targetEnvironment(simulator)
|
|
45
|
+
if let identifier = ProcessInfo.processInfo.environment["SIMULATOR_MODEL_IDENTIFIER"],
|
|
46
|
+
!identifier.isEmpty {
|
|
47
|
+
return identifier
|
|
48
|
+
}
|
|
49
|
+
#endif
|
|
50
|
+
var systemInfo = utsname()
|
|
51
|
+
uname(&systemInfo)
|
|
52
|
+
return withUnsafePointer(to: &systemInfo.machine) { pointer in
|
|
53
|
+
pointer.withMemoryRebound(to: CChar.self, capacity: MemoryLayout.size(ofValue: pointer.pointee)) {
|
|
54
|
+
String(cString: $0)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
var isDualScreenDevice: Bool {
|
|
60
|
+
lock.lock()
|
|
61
|
+
defer { lock.unlock() }
|
|
62
|
+
return isDualScreen
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
var isSpanning: Bool {
|
|
66
|
+
lock.lock()
|
|
67
|
+
defer { lock.unlock() }
|
|
68
|
+
return spanning
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
func addSpanningChangedListener(_ callback: @escaping (Bool) -> Void) -> Double {
|
|
72
|
+
lock.lock()
|
|
73
|
+
defer { lock.unlock() }
|
|
74
|
+
let id = nextListenerId
|
|
75
|
+
nextListenerId += 1
|
|
76
|
+
listeners[id] = callback
|
|
77
|
+
return id
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
func removeSpanningChangedListener(_ id: Double) {
|
|
81
|
+
lock.lock()
|
|
82
|
+
defer { lock.unlock() }
|
|
83
|
+
listeners.removeValue(forKey: id)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
private func installHingeObserver() {
|
|
87
|
+
// UIHinge ships in the iOS 27.1 SDK (Swift 6.4). Older toolchains keep
|
|
88
|
+
// the model identifier and cache only.
|
|
89
|
+
#if compiler(>=6.4)
|
|
90
|
+
guard #available(iOS 27.1, *) else { return }
|
|
91
|
+
DispatchQueue.main.async { [weak self] in
|
|
92
|
+
self?.attachHingeInteraction()
|
|
93
|
+
}
|
|
94
|
+
#endif
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
#if compiler(>=6.4)
|
|
98
|
+
@available(iOS 27.1, *)
|
|
99
|
+
private func attachHingeInteraction() {
|
|
100
|
+
guard hingeObserver == nil else { return }
|
|
101
|
+
guard let window = DualScreenDetector.keyWindow() else {
|
|
102
|
+
// Native modules can initialize before the first window exists.
|
|
103
|
+
hingeObserver = NotificationCenter.default.addObserver(
|
|
104
|
+
forName: UIWindow.didBecomeKeyNotification,
|
|
105
|
+
object: nil,
|
|
106
|
+
queue: .main
|
|
107
|
+
) { [weak self] _ in
|
|
108
|
+
guard let self, let observer = self.hingeObserver else { return }
|
|
109
|
+
NotificationCenter.default.removeObserver(observer)
|
|
110
|
+
self.hingeObserver = nil
|
|
111
|
+
self.attachHingeInteraction()
|
|
112
|
+
}
|
|
113
|
+
return
|
|
114
|
+
}
|
|
115
|
+
let interaction = UIHingeInteraction { [weak self] _, update in
|
|
116
|
+
self?.handleHingeUpdate(update.hinge)
|
|
117
|
+
}
|
|
118
|
+
window.addInteraction(interaction)
|
|
119
|
+
hingeObserver = interaction
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
@available(iOS 27.1, *)
|
|
123
|
+
private func handleHingeUpdate(_ hinge: UIHinge?) {
|
|
124
|
+
// A nil hinge means the hierarchy no longer provides hinge updates;
|
|
125
|
+
// keep the last known state.
|
|
126
|
+
guard let hinge else { return }
|
|
127
|
+
let nextSpanning = hinge.status == .fullyOpen || hinge.status == .partiallyOpen
|
|
128
|
+
|
|
129
|
+
lock.lock()
|
|
130
|
+
let isNewlyDetected = !isDualScreen
|
|
131
|
+
isDualScreen = true
|
|
132
|
+
let didChangeSpanning = spanning != nextSpanning
|
|
133
|
+
spanning = nextSpanning
|
|
134
|
+
let callbacks = Array(listeners.values)
|
|
135
|
+
lock.unlock()
|
|
136
|
+
|
|
137
|
+
if isNewlyDetected {
|
|
138
|
+
UserDefaults.standard.set(true, forKey: DualScreenDetector.cacheKey)
|
|
139
|
+
OneKeyLog.info(
|
|
140
|
+
"DeviceUtils",
|
|
141
|
+
"Dual screen detected by hinge: model=\(DualScreenDetector.modelIdentifier())"
|
|
142
|
+
)
|
|
143
|
+
}
|
|
144
|
+
// A newly detected device also notifies, so JS can react without
|
|
145
|
+
// waiting for the next fold.
|
|
146
|
+
if didChangeSpanning || isNewlyDetected {
|
|
147
|
+
callbacks.forEach { $0(nextSpanning) }
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
#endif
|
|
151
|
+
|
|
152
|
+
private static func keyWindow() -> UIWindow? {
|
|
153
|
+
UIApplication.shared.connectedScenes
|
|
154
|
+
.compactMap { $0 as? UIWindowScene }
|
|
155
|
+
.flatMap { $0.windows }
|
|
156
|
+
.first { $0.isKeyWindow }
|
|
157
|
+
}
|
|
158
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import UIKit
|
|
3
|
+
import ReactNativeNativeLogger
|
|
4
|
+
|
|
5
|
+
/// Owns the app's custom main menu (the iPadOS menu bar).
|
|
6
|
+
///
|
|
7
|
+
/// JS supplies localized titles and Help links through `setMainMenuConfig`.
|
|
8
|
+
/// The app delegate forwards `buildMenu(with:)` to `buildMenu(withBuilder:)`
|
|
9
|
+
/// (it reaches this class through `NSClassFromString`, because the app target
|
|
10
|
+
/// cannot import Nitro modules). App commands are `UIKeyCommand`s whose action
|
|
11
|
+
/// lives on `UIApplication`, which is always in the responder chain, so the
|
|
12
|
+
/// app delegate needs no command handler.
|
|
13
|
+
public final class MainMenuController: NSObject {
|
|
14
|
+
static let shared = MainMenuController()
|
|
15
|
+
|
|
16
|
+
private struct Link {
|
|
17
|
+
let title: String
|
|
18
|
+
let url: URL
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
private struct Config {
|
|
22
|
+
let aboutTitle: String
|
|
23
|
+
let preferencesTitle: String
|
|
24
|
+
let lockNowTitle: String
|
|
25
|
+
let helpLinks: [Link]
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
private static let aboutMenuIdentifier = UIMenu.Identifier("so.onekey.mainMenu.about")
|
|
29
|
+
private static let settingsMenuIdentifier = UIMenu.Identifier("so.onekey.mainMenu.settings")
|
|
30
|
+
|
|
31
|
+
private let lock = NSLock()
|
|
32
|
+
private var config: Config?
|
|
33
|
+
private var listeners: [Double: (MainMenuCommand) -> Void] = [:]
|
|
34
|
+
private var nextListenerId: Double = 1
|
|
35
|
+
|
|
36
|
+
func setConfig(_ config: MainMenuConfig) {
|
|
37
|
+
let links = config.helpLinks.compactMap { link -> Link? in
|
|
38
|
+
guard let url = URL(string: link.url) else {
|
|
39
|
+
OneKeyLog.warn("DeviceUtils", "Main menu: invalid help link URL")
|
|
40
|
+
return nil
|
|
41
|
+
}
|
|
42
|
+
return Link(title: link.title, url: url)
|
|
43
|
+
}
|
|
44
|
+
let next = Config(
|
|
45
|
+
aboutTitle: config.aboutTitle,
|
|
46
|
+
preferencesTitle: config.preferencesTitle,
|
|
47
|
+
lockNowTitle: config.lockNowTitle,
|
|
48
|
+
helpLinks: links
|
|
49
|
+
)
|
|
50
|
+
lock.lock()
|
|
51
|
+
self.config = next
|
|
52
|
+
lock.unlock()
|
|
53
|
+
DispatchQueue.main.async {
|
|
54
|
+
UIMenuSystem.main.setNeedsRebuild()
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
func addListener(_ callback: @escaping (MainMenuCommand) -> Void) -> Double {
|
|
59
|
+
lock.lock()
|
|
60
|
+
defer { lock.unlock() }
|
|
61
|
+
let id = nextListenerId
|
|
62
|
+
nextListenerId += 1
|
|
63
|
+
listeners[id] = callback
|
|
64
|
+
return id
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
func removeListener(_ id: Double) {
|
|
68
|
+
lock.lock()
|
|
69
|
+
defer { lock.unlock() }
|
|
70
|
+
listeners.removeValue(forKey: id)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/// Called by the app delegate's `buildMenu(with:)` on the main thread.
|
|
74
|
+
@objc(buildMenuWithBuilder:)
|
|
75
|
+
public static func buildMenu(withBuilder builder: UIMenuBuilder) {
|
|
76
|
+
shared.build(builder)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
fileprivate func perform(commandName: String) {
|
|
80
|
+
guard let command = MainMenuCommand(fromString: commandName) else { return }
|
|
81
|
+
lock.lock()
|
|
82
|
+
let callbacks = Array(listeners.values)
|
|
83
|
+
lock.unlock()
|
|
84
|
+
OneKeyLog.info("DeviceUtils", "Main menu command: \(commandName), listeners=\(callbacks.count)")
|
|
85
|
+
callbacks.forEach { $0(command) }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
private func build(_ builder: UIMenuBuilder) {
|
|
89
|
+
guard builder.system == .main else { return }
|
|
90
|
+
|
|
91
|
+
// A wallet has no documents to open or text to format.
|
|
92
|
+
builder.remove(menu: .file)
|
|
93
|
+
builder.remove(menu: .format)
|
|
94
|
+
|
|
95
|
+
lock.lock()
|
|
96
|
+
let config = self.config
|
|
97
|
+
lock.unlock()
|
|
98
|
+
// Keep the system app and Help menus until JS provides localized titles.
|
|
99
|
+
guard let config else { return }
|
|
100
|
+
|
|
101
|
+
let action = #selector(UIApplication.onekeyPerformMainMenuCommand(_:))
|
|
102
|
+
let about = UICommand(
|
|
103
|
+
title: config.aboutTitle,
|
|
104
|
+
action: action,
|
|
105
|
+
propertyList: MainMenuCommand.about.stringValue
|
|
106
|
+
)
|
|
107
|
+
let preferences = UIKeyCommand(
|
|
108
|
+
title: config.preferencesTitle,
|
|
109
|
+
action: action,
|
|
110
|
+
input: ",",
|
|
111
|
+
modifierFlags: .command,
|
|
112
|
+
propertyList: MainMenuCommand.preferences.stringValue
|
|
113
|
+
)
|
|
114
|
+
let lockNow = UIKeyCommand(
|
|
115
|
+
title: config.lockNowTitle,
|
|
116
|
+
action: action,
|
|
117
|
+
input: "l",
|
|
118
|
+
modifierFlags: [.command, .shift],
|
|
119
|
+
propertyList: MainMenuCommand.locknow.stringValue
|
|
120
|
+
)
|
|
121
|
+
let aboutMenu = UIMenu(
|
|
122
|
+
title: "",
|
|
123
|
+
identifier: MainMenuController.aboutMenuIdentifier,
|
|
124
|
+
options: .displayInline,
|
|
125
|
+
children: [about]
|
|
126
|
+
)
|
|
127
|
+
let settingsMenu = UIMenu(
|
|
128
|
+
title: "",
|
|
129
|
+
identifier: MainMenuController.settingsMenuIdentifier,
|
|
130
|
+
options: .displayInline,
|
|
131
|
+
children: [preferences, lockNow]
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
// The system Preferences item opens the app's page in the Settings app;
|
|
135
|
+
// replace it with the in-app preferences, which own the same shortcut.
|
|
136
|
+
if builder.menu(for: .preferences) != nil {
|
|
137
|
+
builder.replace(menu: .preferences, with: settingsMenu)
|
|
138
|
+
} else {
|
|
139
|
+
builder.insertChild(settingsMenu, atStartOfMenu: .application)
|
|
140
|
+
}
|
|
141
|
+
// An empty About title removes the item (Travel Mode hides About).
|
|
142
|
+
if config.aboutTitle.isEmpty {
|
|
143
|
+
builder.remove(menu: .about)
|
|
144
|
+
} else if builder.menu(for: .about) != nil {
|
|
145
|
+
builder.replace(menu: .about, with: aboutMenu)
|
|
146
|
+
} else {
|
|
147
|
+
builder.insertSibling(aboutMenu, beforeMenu: MainMenuController.settingsMenuIdentifier)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if config.helpLinks.isEmpty {
|
|
151
|
+
builder.remove(menu: .help)
|
|
152
|
+
} else if builder.menu(for: .help) != nil {
|
|
153
|
+
let links = config.helpLinks.map { link in
|
|
154
|
+
UIAction(title: link.title) { _ in
|
|
155
|
+
UIApplication.shared.open(link.url)
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
builder.replaceChildren(ofMenu: .help) { _ in links }
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
extension UIApplication {
|
|
164
|
+
/// Action of the main menu app commands; the command name is the
|
|
165
|
+
/// command's property list.
|
|
166
|
+
@objc func onekeyPerformMainMenuCommand(_ sender: UICommand) {
|
|
167
|
+
guard let name = sender.propertyList as? String else { return }
|
|
168
|
+
MainMenuController.shared.perform(commandName: name)
|
|
169
|
+
}
|
|
170
|
+
}
|
|
@@ -112,14 +112,14 @@ class ReactNativeDeviceUtils: HybridReactNativeDeviceUtilsSpec {
|
|
|
112
112
|
}
|
|
113
113
|
|
|
114
114
|
public func isDualScreenDevice() throws -> Bool {
|
|
115
|
-
return
|
|
115
|
+
return DualScreenDetector.shared.isDualScreenDevice
|
|
116
116
|
}
|
|
117
117
|
|
|
118
118
|
public func initEventListeners() throws -> Void {
|
|
119
119
|
}
|
|
120
|
-
|
|
120
|
+
|
|
121
121
|
public func isSpanning() throws -> Bool {
|
|
122
|
-
return
|
|
122
|
+
return DualScreenDetector.shared.isSpanning
|
|
123
123
|
}
|
|
124
124
|
|
|
125
125
|
public func getWindowRects() throws -> Promise<[DualScreenInfoRect]> {
|
|
@@ -132,10 +132,35 @@ class ReactNativeDeviceUtils: HybridReactNativeDeviceUtilsSpec {
|
|
|
132
132
|
|
|
133
133
|
|
|
134
134
|
func addSpanningChangedListener(callback: @escaping (Bool) -> Void) throws -> Double {
|
|
135
|
-
return
|
|
135
|
+
return DualScreenDetector.shared.addSpanningChangedListener(callback)
|
|
136
136
|
}
|
|
137
137
|
|
|
138
138
|
func removeSpanningChangedListener(id: Double) throws -> Void {
|
|
139
|
+
DualScreenDetector.shared.removeSpanningChangedListener(id)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
func getWindowControlsInsets() throws -> WindowControlsInsets {
|
|
143
|
+
return WindowControlsInsetsObserver.shared.currentInsets
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
func addWindowControlsInsetsChangedListener(callback: @escaping (WindowControlsInsets) -> Void) throws -> Double {
|
|
147
|
+
return WindowControlsInsetsObserver.shared.addListener(callback)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
func removeWindowControlsInsetsChangedListener(id: Double) throws -> Void {
|
|
151
|
+
WindowControlsInsetsObserver.shared.removeListener(id)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
func setMainMenuConfig(config: MainMenuConfig) throws -> Void {
|
|
155
|
+
MainMenuController.shared.setConfig(config)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
func addMainMenuCommandListener(callback: @escaping (MainMenuCommand) -> Void) throws -> Double {
|
|
159
|
+
return MainMenuController.shared.addListener(callback)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
func removeMainMenuCommandListener(id: Double) throws -> Void {
|
|
163
|
+
MainMenuController.shared.removeListener(id)
|
|
139
164
|
}
|
|
140
165
|
|
|
141
166
|
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import UIKit
|
|
3
|
+
import ReactNativeNativeLogger
|
|
4
|
+
|
|
5
|
+
/// Tracks how much room the key window's system window controls (the iPadOS
|
|
6
|
+
/// window controls in the top-leading corner, for example) take beyond the
|
|
7
|
+
/// safe area.
|
|
8
|
+
///
|
|
9
|
+
/// UIKit reports the controls through the safe area with horizontal corner
|
|
10
|
+
/// adaptation (iOS 26+). An invisible view spanning the window recomputes the
|
|
11
|
+
/// difference on every layout and safe area change, so window resizes and
|
|
12
|
+
/// entering or leaving full screen update the value.
|
|
13
|
+
final class WindowControlsInsetsObserver {
|
|
14
|
+
static let shared = WindowControlsInsetsObserver()
|
|
15
|
+
|
|
16
|
+
private let lock = NSLock()
|
|
17
|
+
private var insets = WindowControlsInsets(top: 0, left: 0, bottom: 0, right: 0)
|
|
18
|
+
private var listeners: [Double: (WindowControlsInsets) -> Void] = [:]
|
|
19
|
+
private var nextListenerId: Double = 1
|
|
20
|
+
private var keyWindowObserver: NSObjectProtocol?
|
|
21
|
+
private weak var trackingView: UIView?
|
|
22
|
+
|
|
23
|
+
private init() {
|
|
24
|
+
DispatchQueue.main.async { [weak self] in
|
|
25
|
+
self?.install()
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
var currentInsets: WindowControlsInsets {
|
|
30
|
+
lock.lock()
|
|
31
|
+
defer { lock.unlock() }
|
|
32
|
+
return insets
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
func addListener(_ callback: @escaping (WindowControlsInsets) -> Void) -> Double {
|
|
36
|
+
lock.lock()
|
|
37
|
+
defer { lock.unlock() }
|
|
38
|
+
let id = nextListenerId
|
|
39
|
+
nextListenerId += 1
|
|
40
|
+
listeners[id] = callback
|
|
41
|
+
return id
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
func removeListener(_ id: Double) {
|
|
45
|
+
lock.lock()
|
|
46
|
+
defer { lock.unlock() }
|
|
47
|
+
listeners.removeValue(forKey: id)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
private func install() {
|
|
51
|
+
// UIView.LayoutRegion ships in the iOS 26 SDK (Swift 6.2). Older
|
|
52
|
+
// toolchains and systems keep the all-zero value.
|
|
53
|
+
#if compiler(>=6.2)
|
|
54
|
+
guard #available(iOS 26.0, *) else { return }
|
|
55
|
+
guard trackingView == nil else { return }
|
|
56
|
+
guard let window = WindowControlsInsetsObserver.keyWindow() else {
|
|
57
|
+
// Native modules can initialize before the first window exists.
|
|
58
|
+
guard keyWindowObserver == nil else { return }
|
|
59
|
+
keyWindowObserver = NotificationCenter.default.addObserver(
|
|
60
|
+
forName: UIWindow.didBecomeKeyNotification,
|
|
61
|
+
object: nil,
|
|
62
|
+
queue: .main
|
|
63
|
+
) { [weak self] _ in
|
|
64
|
+
guard let self, let observer = self.keyWindowObserver else { return }
|
|
65
|
+
NotificationCenter.default.removeObserver(observer)
|
|
66
|
+
self.keyWindowObserver = nil
|
|
67
|
+
self.install()
|
|
68
|
+
}
|
|
69
|
+
return
|
|
70
|
+
}
|
|
71
|
+
let view = WindowControlsTrackingView(frame: window.bounds) { [weak self] view in
|
|
72
|
+
self?.update(from: view)
|
|
73
|
+
}
|
|
74
|
+
view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
|
75
|
+
view.isUserInteractionEnabled = false
|
|
76
|
+
view.isAccessibilityElement = false
|
|
77
|
+
// Behind the root view controller's view, so it never draws or
|
|
78
|
+
// receives touches above app content.
|
|
79
|
+
window.insertSubview(view, at: 0)
|
|
80
|
+
trackingView = view
|
|
81
|
+
update(from: view)
|
|
82
|
+
#endif
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
#if compiler(>=6.2)
|
|
86
|
+
@available(iOS 26.0, *)
|
|
87
|
+
private func update(from view: UIView) {
|
|
88
|
+
let safeArea = view.safeAreaInsets
|
|
89
|
+
let adapted = view.edgeInsets(for: .safeArea(cornerAdaptation: .horizontal))
|
|
90
|
+
let next = WindowControlsInsets(
|
|
91
|
+
top: Double(max(0, adapted.top - safeArea.top)),
|
|
92
|
+
left: Double(max(0, adapted.left - safeArea.left)),
|
|
93
|
+
bottom: Double(max(0, adapted.bottom - safeArea.bottom)),
|
|
94
|
+
right: Double(max(0, adapted.right - safeArea.right))
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
lock.lock()
|
|
98
|
+
let previous = insets
|
|
99
|
+
let didChange = previous.top != next.top
|
|
100
|
+
|| previous.left != next.left
|
|
101
|
+
|| previous.bottom != next.bottom
|
|
102
|
+
|| previous.right != next.right
|
|
103
|
+
if didChange {
|
|
104
|
+
insets = next
|
|
105
|
+
}
|
|
106
|
+
let callbacks = didChange ? Array(listeners.values) : []
|
|
107
|
+
lock.unlock()
|
|
108
|
+
|
|
109
|
+
guard didChange else { return }
|
|
110
|
+
OneKeyLog.debug(
|
|
111
|
+
"DeviceUtils",
|
|
112
|
+
"Window controls insets: top=\(next.top), left=\(next.left), bottom=\(next.bottom), right=\(next.right)"
|
|
113
|
+
)
|
|
114
|
+
callbacks.forEach { $0(next) }
|
|
115
|
+
}
|
|
116
|
+
#endif
|
|
117
|
+
|
|
118
|
+
private static func keyWindow() -> UIWindow? {
|
|
119
|
+
UIApplication.shared.connectedScenes
|
|
120
|
+
.compactMap { $0 as? UIWindowScene }
|
|
121
|
+
.flatMap { $0.windows }
|
|
122
|
+
.first { $0.isKeyWindow }
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/// Invisible full-window view that reports layout and safe area changes.
|
|
127
|
+
private final class WindowControlsTrackingView: UIView {
|
|
128
|
+
private let onChange: (UIView) -> Void
|
|
129
|
+
|
|
130
|
+
init(frame: CGRect, onChange: @escaping (UIView) -> Void) {
|
|
131
|
+
self.onChange = onChange
|
|
132
|
+
super.init(frame: frame)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
@available(*, unavailable)
|
|
136
|
+
required init?(coder: NSCoder) {
|
|
137
|
+
fatalError("init(coder:) has not been implemented")
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
override func layoutSubviews() {
|
|
141
|
+
super.layoutSubviews()
|
|
142
|
+
onChange(self)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
override func safeAreaInsetsDidChange() {
|
|
146
|
+
super.safeAreaInsetsDidChange()
|
|
147
|
+
onChange(self)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
@@ -9,6 +9,35 @@ export interface DualScreenInfoRect {
|
|
|
9
9
|
width: number;
|
|
10
10
|
height: number;
|
|
11
11
|
}
|
|
12
|
+
/**
|
|
13
|
+
* Extra room, in points beyond the safe area, that content near the window
|
|
14
|
+
* corners must leave for system window controls (the iPadOS window controls in
|
|
15
|
+
* the top-leading corner, for example). All zero when no controls are shown.
|
|
16
|
+
*/
|
|
17
|
+
export interface WindowControlsInsets {
|
|
18
|
+
top: number;
|
|
19
|
+
left: number;
|
|
20
|
+
bottom: number;
|
|
21
|
+
right: number;
|
|
22
|
+
}
|
|
23
|
+
/** App commands in the iPadOS menu bar that JS performs. */
|
|
24
|
+
export type MainMenuCommand = 'about' | 'preferences' | 'lockNow';
|
|
25
|
+
export interface MainMenuLink {
|
|
26
|
+
title: string;
|
|
27
|
+
url: string;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Localized content of the custom main menu (the iPadOS menu bar). Help links
|
|
31
|
+
* open in the system browser natively; the other items notify JS. An empty
|
|
32
|
+
* `aboutTitle` removes the About item and empty `helpLinks` remove the Help
|
|
33
|
+
* menu.
|
|
34
|
+
*/
|
|
35
|
+
export interface MainMenuConfig {
|
|
36
|
+
aboutTitle: string;
|
|
37
|
+
preferencesTitle: string;
|
|
38
|
+
lockNowTitle: string;
|
|
39
|
+
helpLinks: MainMenuLink[];
|
|
40
|
+
}
|
|
12
41
|
export interface LaunchOptions {
|
|
13
42
|
launchType: string;
|
|
14
43
|
deepLink?: string;
|
|
@@ -43,6 +72,23 @@ export interface ReactNativeDeviceUtils extends HybridObject<{
|
|
|
43
72
|
addSpanningChangedListener(callback: (isSpanning: boolean) => void): number;
|
|
44
73
|
removeSpanningChangedListener(id: number): void;
|
|
45
74
|
setUserInterfaceStyle(style: UserInterfaceStyle): void;
|
|
75
|
+
/**
|
|
76
|
+
* Window-controls insets of the app's main window, for content that adapts
|
|
77
|
+
* horizontally (a header row beside the controls). iOS 26+ reads UIKit's
|
|
78
|
+
* safe area with horizontal corner adaptation; Android and older iOS return
|
|
79
|
+
* all zeros. Listeners fire on the main thread when the value changes.
|
|
80
|
+
*/
|
|
81
|
+
getWindowControlsInsets(): WindowControlsInsets;
|
|
82
|
+
addWindowControlsInsetsChangedListener(callback: (insets: WindowControlsInsets) => void): number;
|
|
83
|
+
removeWindowControlsInsetsChangedListener(id: number): void;
|
|
84
|
+
/**
|
|
85
|
+
* Replaces the custom main menu content and rebuilds the menu bar. The menu
|
|
86
|
+
* only shows on iPadOS (and in windows with a menu bar); Android ignores it.
|
|
87
|
+
*/
|
|
88
|
+
setMainMenuConfig(config: MainMenuConfig): void;
|
|
89
|
+
/** Listeners fire on the main thread when the user picks an app command. */
|
|
90
|
+
addMainMenuCommandListener(callback: (command: MainMenuCommand) => void): number;
|
|
91
|
+
removeMainMenuCommandListener(id: number): void;
|
|
46
92
|
getLaunchOptions(): Promise<LaunchOptions>;
|
|
47
93
|
clearLaunchOptions(): Promise<boolean>;
|
|
48
94
|
getDeviceToken(): Promise<string>;
|