@onekeyfe/react-native-device-utils 6.7.0-2 → 6.7.0-20

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.
Files changed (42) hide show
  1. package/android/src/main/java/com/margelo/nitro/reactnativedeviceutils/ReactNativeDeviceUtils.kt +61 -6
  2. package/ios/DualScreenDetector.swift +170 -0
  3. package/ios/MainMenuController.swift +170 -0
  4. package/ios/ReactNativeDeviceUtils.swift +29 -4
  5. package/ios/WindowControlsInsetsObserver.swift +151 -0
  6. package/lib/typescript/src/ReactNativeDeviceUtils.nitro.d.ts +46 -0
  7. package/nitrogen/generated/android/c++/JFunc_void_MainMenuCommand.hpp +77 -0
  8. package/nitrogen/generated/android/c++/JFunc_void_WindowControlsInsets.hpp +77 -0
  9. package/nitrogen/generated/android/c++/JHybridReactNativeDeviceUtilsSpec.cpp +45 -0
  10. package/nitrogen/generated/android/c++/JHybridReactNativeDeviceUtilsSpec.hpp +6 -0
  11. package/nitrogen/generated/android/c++/JMainMenuCommand.hpp +61 -0
  12. package/nitrogen/generated/android/c++/JMainMenuConfig.hpp +90 -0
  13. package/nitrogen/generated/android/c++/JMainMenuLink.hpp +61 -0
  14. package/nitrogen/generated/android/c++/JWindowControlsInsets.hpp +69 -0
  15. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativedeviceutils/Func_void_MainMenuCommand.kt +78 -0
  16. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativedeviceutils/Func_void_WindowControlsInsets.kt +78 -0
  17. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativedeviceutils/HybridReactNativeDeviceUtilsSpec.kt +34 -0
  18. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativedeviceutils/MainMenuCommand.kt +24 -0
  19. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativedeviceutils/MainMenuConfig.kt +66 -0
  20. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativedeviceutils/MainMenuLink.kt +56 -0
  21. package/nitrogen/generated/android/kotlin/com/margelo/nitro/reactnativedeviceutils/WindowControlsInsets.kt +66 -0
  22. package/nitrogen/generated/android/reactnativedeviceutilsOnLoad.cpp +4 -0
  23. package/nitrogen/generated/ios/ReactNativeDeviceUtils-Swift-Cxx-Bridge.cpp +16 -0
  24. package/nitrogen/generated/ios/ReactNativeDeviceUtils-Swift-Cxx-Bridge.hpp +73 -0
  25. package/nitrogen/generated/ios/ReactNativeDeviceUtils-Swift-Cxx-Umbrella.hpp +12 -0
  26. package/nitrogen/generated/ios/c++/HybridReactNativeDeviceUtilsSpecSwift.hpp +55 -1
  27. package/nitrogen/generated/ios/swift/Func_void_MainMenuCommand.swift +46 -0
  28. package/nitrogen/generated/ios/swift/Func_void_WindowControlsInsets.swift +46 -0
  29. package/nitrogen/generated/ios/swift/HybridReactNativeDeviceUtilsSpec.swift +6 -0
  30. package/nitrogen/generated/ios/swift/HybridReactNativeDeviceUtilsSpec_cxx.swift +79 -0
  31. package/nitrogen/generated/ios/swift/MainMenuCommand.swift +44 -0
  32. package/nitrogen/generated/ios/swift/MainMenuConfig.swift +50 -0
  33. package/nitrogen/generated/ios/swift/MainMenuLink.swift +34 -0
  34. package/nitrogen/generated/ios/swift/WindowControlsInsets.swift +44 -0
  35. package/nitrogen/generated/shared/c++/HybridReactNativeDeviceUtilsSpec.cpp +6 -0
  36. package/nitrogen/generated/shared/c++/HybridReactNativeDeviceUtilsSpec.hpp +15 -0
  37. package/nitrogen/generated/shared/c++/MainMenuCommand.hpp +80 -0
  38. package/nitrogen/generated/shared/c++/MainMenuConfig.hpp +98 -0
  39. package/nitrogen/generated/shared/c++/MainMenuLink.hpp +87 -0
  40. package/nitrogen/generated/shared/c++/WindowControlsInsets.hpp +95 -0
  41. package/package.json +1 -1
  42. package/src/ReactNativeDeviceUtils.nitro.ts +54 -0
@@ -4,9 +4,11 @@ import android.app.Activity
4
4
  import android.content.Context
5
5
  import android.content.pm.PackageManager
6
6
  import android.graphics.Color
7
+ import android.graphics.drawable.GradientDrawable
7
8
  import android.graphics.Rect
8
9
  import android.os.Build
9
10
  import android.view.Gravity
11
+ import android.view.WindowInsetsController
10
12
  import android.view.View
11
13
  import android.view.ViewGroup
12
14
  import android.widget.FrameLayout
@@ -763,6 +765,35 @@ class ReactNativeDeviceUtils : HybridReactNativeDeviceUtilsSpec(), LifecycleEven
763
765
  spanningChangedListeners.removeIf { it.id == id }
764
766
  }
765
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
+
766
797
  private fun startObservingLayoutChanges() {
767
798
  if (isObservingLayoutChanges) {
768
799
  return
@@ -909,16 +940,30 @@ class ReactNativeDeviceUtils : HybridReactNativeDeviceUtilsSpec(), LifecycleEven
909
940
  val window = activity.window
910
941
  val decorView = window.decorView
911
942
 
912
- WindowCompat.getInsetsController(window, decorView)
913
- .isAppearanceLightNavigationBars = appearance.useDarkIcons
943
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
944
+ // WindowInsetsControllerCompat leaves the previous value in place on
945
+ // Android 11-14 once the window is appearance-controlled, so runtime
946
+ // theme switches kept stale button colors. Use the platform API.
947
+ window.insetsController?.setSystemBarsAppearance(
948
+ if (appearance.useDarkIcons) WindowInsetsController.APPEARANCE_LIGHT_NAVIGATION_BARS else 0,
949
+ WindowInsetsController.APPEARANCE_LIGHT_NAVIGATION_BARS,
950
+ )
951
+ } else {
952
+ WindowCompat.getInsetsController(window, decorView)
953
+ .isAppearanceLightNavigationBars = appearance.useDarkIcons
954
+ }
914
955
 
915
956
  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
916
957
  window.isNavigationBarContrastEnforced = false
917
958
  }
918
959
 
919
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) {
920
- // Android 15 enforces edge-to-edge. Keep the platform bar transparent
921
- // and draw the requested color only behind three-button navigation.
960
+ if (
961
+ Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM ||
962
+ com.facebook.react.views.view.isEdgeToEdgeFeatureFlagOn
963
+ ) {
964
+ // Edge-to-edge windows (enforced on Android 15, opted in below it) keep
965
+ // the platform bar transparent and draw the requested color only
966
+ // behind three-button navigation.
922
967
  window.navigationBarColor = Color.TRANSPARENT
923
968
  installNavigationBarProtection(decorView, appearance.color)
924
969
  } else {
@@ -953,7 +998,6 @@ class ReactNativeDeviceUtils : HybridReactNativeDeviceUtilsSpec(), LifecycleEven
953
998
  )
954
999
  }
955
1000
 
956
- protectionView.setBackgroundColor(color)
957
1001
  protectionView.visibility = View.VISIBLE
958
1002
  protectionView.bringToFront()
959
1003
  ViewCompat.setOnApplyWindowInsetsListener(protectionView) { view, windowInsets ->
@@ -985,6 +1029,17 @@ class ReactNativeDeviceUtils : HybridReactNativeDeviceUtilsSpec(), LifecycleEven
985
1029
  )
986
1030
  else -> Triple(0, 0, Gravity.BOTTOM)
987
1031
  }
1032
+ // Fade in over the first third from the content edge so the protection never
1033
+ // draws a hard line against the page; the buttons sit on the solid rest.
1034
+ val fadeOrientation = when (gravity) {
1035
+ Gravity.LEFT -> GradientDrawable.Orientation.RIGHT_LEFT
1036
+ Gravity.RIGHT -> GradientDrawable.Orientation.LEFT_RIGHT
1037
+ else -> GradientDrawable.Orientation.TOP_BOTTOM
1038
+ }
1039
+ view.background = GradientDrawable(
1040
+ fadeOrientation,
1041
+ intArrayOf(color and 0x00FFFFFF, color, color, color),
1042
+ )
988
1043
  if (
989
1044
  layoutParams.width != width ||
990
1045
  layoutParams.height != height ||
@@ -0,0 +1,170 @@
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 hingeInteraction: UIInteraction?
27
+ private var keyWindowObserver: NSObjectProtocol?
28
+ private var resignKeyWindowObserver: NSObjectProtocol?
29
+
30
+ private init() {
31
+ let modelIdentifier = DualScreenDetector.modelIdentifier()
32
+ let isKnownModel = DualScreenDetector.foldableModelIdentifiers.contains(modelIdentifier)
33
+ let isCached = UserDefaults.standard.string(forKey: DualScreenDetector.cacheKey) == modelIdentifier
34
+ isDualScreen = isKnownModel || isCached
35
+ if isKnownModel && !isCached {
36
+ UserDefaults.standard.set(modelIdentifier, forKey: DualScreenDetector.cacheKey)
37
+ }
38
+ OneKeyLog.info(
39
+ "DeviceUtils",
40
+ "Dual screen detection: model=\(modelIdentifier), knownModel=\(isKnownModel), cached=\(isCached)"
41
+ )
42
+ installHingeObserver()
43
+ }
44
+
45
+ static func modelIdentifier() -> String {
46
+ #if targetEnvironment(simulator)
47
+ if let identifier = ProcessInfo.processInfo.environment["SIMULATOR_MODEL_IDENTIFIER"],
48
+ !identifier.isEmpty {
49
+ return identifier
50
+ }
51
+ #endif
52
+ var systemInfo = utsname()
53
+ uname(&systemInfo)
54
+ return withUnsafePointer(to: &systemInfo.machine) { pointer in
55
+ pointer.withMemoryRebound(to: CChar.self, capacity: MemoryLayout.size(ofValue: pointer.pointee)) {
56
+ String(cString: $0)
57
+ }
58
+ }
59
+ }
60
+
61
+ var isDualScreenDevice: Bool {
62
+ lock.lock()
63
+ defer { lock.unlock() }
64
+ return isDualScreen
65
+ }
66
+
67
+ var isSpanning: Bool {
68
+ lock.lock()
69
+ defer { lock.unlock() }
70
+ return spanning
71
+ }
72
+
73
+ func addSpanningChangedListener(_ callback: @escaping (Bool) -> Void) -> Double {
74
+ lock.lock()
75
+ defer { lock.unlock() }
76
+ let id = nextListenerId
77
+ nextListenerId += 1
78
+ listeners[id] = callback
79
+ return id
80
+ }
81
+
82
+ func removeSpanningChangedListener(_ id: Double) {
83
+ lock.lock()
84
+ defer { lock.unlock() }
85
+ listeners.removeValue(forKey: id)
86
+ }
87
+
88
+ private func installHingeObserver() {
89
+ // UIHinge ships in the iOS 27.1 SDK (Swift 6.4). Older toolchains keep
90
+ // the model identifier and cache only.
91
+ #if compiler(>=6.4)
92
+ guard #available(iOS 27.1, *) else { return }
93
+ DispatchQueue.main.async { [weak self] in
94
+ guard let self else { return }
95
+ self.keyWindowObserver = NotificationCenter.default.addObserver(
96
+ forName: UIWindow.didBecomeKeyNotification,
97
+ object: nil,
98
+ queue: .main
99
+ ) { [weak self] notification in
100
+ guard let window = notification.object as? UIWindow else { return }
101
+ self?.attachHingeInteraction(in: window)
102
+ }
103
+ self.resignKeyWindowObserver = NotificationCenter.default.addObserver(
104
+ forName: UIWindow.didResignKeyNotification,
105
+ object: nil,
106
+ queue: .main
107
+ ) { [weak self] notification in
108
+ guard let self, let window = notification.object as? UIWindow,
109
+ self.hingeInteraction?.view === window else { return }
110
+ self.attachHingeInteraction(in: nil)
111
+ }
112
+ self.attachHingeInteraction(in: DualScreenDetector.keyWindow())
113
+ }
114
+ #endif
115
+ }
116
+
117
+ #if compiler(>=6.4)
118
+ @available(iOS 27.1, *)
119
+ private func attachHingeInteraction(in window: UIWindow?) {
120
+ if let interaction = hingeInteraction, interaction.view === window { return }
121
+ let previousInteraction = hingeInteraction
122
+ hingeInteraction = nil
123
+ if let interaction = previousInteraction {
124
+ interaction.view?.removeInteraction(interaction)
125
+ }
126
+ handleHingeUpdate(nil)
127
+ // A listener can change the key window while spanning is reset.
128
+ guard let window, window.isKeyWindow, hingeInteraction == nil else { return }
129
+ let interaction = UIHingeInteraction { [weak self] interaction, update in
130
+ guard let self, self.hingeInteraction === interaction else { return }
131
+ self.handleHingeUpdate(update.hinge)
132
+ }
133
+ hingeInteraction = interaction
134
+ window.addInteraction(interaction)
135
+ }
136
+
137
+ @available(iOS 27.1, *)
138
+ private func handleHingeUpdate(_ hinge: UIHinge?) {
139
+ let nextSpanning = hinge?.status == .fullyOpen || hinge?.status == .partiallyOpen
140
+
141
+ lock.lock()
142
+ let isNewlyDetected = hinge != nil && !isDualScreen
143
+ if hinge != nil { isDualScreen = true }
144
+ let didChangeSpanning = spanning != nextSpanning
145
+ spanning = nextSpanning
146
+ let callbacks = Array(listeners.values)
147
+ lock.unlock()
148
+
149
+ if isNewlyDetected {
150
+ UserDefaults.standard.set(DualScreenDetector.modelIdentifier(), forKey: DualScreenDetector.cacheKey)
151
+ OneKeyLog.info(
152
+ "DeviceUtils",
153
+ "Dual screen detected by hinge: model=\(DualScreenDetector.modelIdentifier())"
154
+ )
155
+ }
156
+ // A newly detected device also notifies, so JS can react without
157
+ // waiting for the next fold.
158
+ if didChangeSpanning || isNewlyDetected {
159
+ callbacks.forEach { $0(nextSpanning) }
160
+ }
161
+ }
162
+ #endif
163
+
164
+ private static func keyWindow() -> UIWindow? {
165
+ UIApplication.shared.connectedScenes
166
+ .compactMap { $0 as? UIWindowScene }
167
+ .flatMap { $0.windows }
168
+ .first { $0.isKeyWindow }
169
+ }
170
+ }
@@ -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 false
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 false
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 0
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,151 @@
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(in keyWindow: UIWindow? = nil) {
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
+ if keyWindowObserver == nil {
56
+ keyWindowObserver = NotificationCenter.default.addObserver(
57
+ forName: UIWindow.didBecomeKeyNotification,
58
+ object: nil,
59
+ queue: .main
60
+ ) { [weak self] notification in
61
+ guard let window = notification.object as? UIWindow else { return }
62
+ self?.install(in: window)
63
+ }
64
+ }
65
+ guard let window = keyWindow ?? WindowControlsInsetsObserver.keyWindow() else { return }
66
+ if let view = trackingView, view.window === window {
67
+ update(from: view)
68
+ return
69
+ }
70
+ let view = WindowControlsTrackingView(frame: window.bounds) { [weak self] view in
71
+ self?.update(from: view)
72
+ }
73
+ view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
74
+ view.isUserInteractionEnabled = false
75
+ view.isAccessibilityElement = false
76
+ // Behind the root view controller's view, so it never draws or
77
+ // receives touches above app content.
78
+ let previousView = trackingView
79
+ trackingView = view
80
+ previousView?.removeFromSuperview()
81
+ window.insertSubview(view, at: 0)
82
+ update(from: view)
83
+ #endif
84
+ }
85
+
86
+ #if compiler(>=6.2)
87
+ @available(iOS 26.0, *)
88
+ private func update(from view: UIView) {
89
+ guard view === trackingView else { return }
90
+ let safeArea = view.safeAreaInsets
91
+ let adapted = view.edgeInsets(for: .safeArea(cornerAdaptation: .horizontal))
92
+ let next = WindowControlsInsets(
93
+ top: Double(max(0, adapted.top - safeArea.top)),
94
+ left: Double(max(0, adapted.left - safeArea.left)),
95
+ bottom: Double(max(0, adapted.bottom - safeArea.bottom)),
96
+ right: Double(max(0, adapted.right - safeArea.right))
97
+ )
98
+
99
+ lock.lock()
100
+ let previous = insets
101
+ let didChange = previous.top != next.top
102
+ || previous.left != next.left
103
+ || previous.bottom != next.bottom
104
+ || previous.right != next.right
105
+ if didChange {
106
+ insets = next
107
+ }
108
+ let callbacks = didChange ? Array(listeners.values) : []
109
+ lock.unlock()
110
+
111
+ guard didChange else { return }
112
+ OneKeyLog.debug(
113
+ "DeviceUtils",
114
+ "Window controls insets: top=\(next.top), left=\(next.left), bottom=\(next.bottom), right=\(next.right)"
115
+ )
116
+ callbacks.forEach { $0(next) }
117
+ }
118
+ #endif
119
+
120
+ private static func keyWindow() -> UIWindow? {
121
+ UIApplication.shared.connectedScenes
122
+ .compactMap { $0 as? UIWindowScene }
123
+ .flatMap { $0.windows }
124
+ .first { $0.isKeyWindow }
125
+ }
126
+ }
127
+
128
+ /// Invisible full-window view that reports layout and safe area changes.
129
+ private final class WindowControlsTrackingView: UIView {
130
+ private let onChange: (UIView) -> Void
131
+
132
+ init(frame: CGRect, onChange: @escaping (UIView) -> Void) {
133
+ self.onChange = onChange
134
+ super.init(frame: frame)
135
+ }
136
+
137
+ @available(*, unavailable)
138
+ required init?(coder: NSCoder) {
139
+ fatalError("init(coder:) has not been implemented")
140
+ }
141
+
142
+ override func layoutSubviews() {
143
+ super.layoutSubviews()
144
+ onChange(self)
145
+ }
146
+
147
+ override func safeAreaInsetsDidChange() {
148
+ super.safeAreaInsetsDidChange()
149
+ onChange(self)
150
+ }
151
+ }