@onekeyfe/react-native-device-utils 6.7.0-2 → 6.7.0-21
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 +61 -6
- package/ios/DualScreenDetector.swift +170 -0
- package/ios/MainMenuController.swift +170 -0
- package/ios/ReactNativeDeviceUtils.swift +29 -4
- package/ios/WindowControlsInsetsObserver.swift +151 -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
|
@@ -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
|
-
|
|
913
|
-
|
|
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 (
|
|
920
|
-
|
|
921
|
-
|
|
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
|
|
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,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
|
+
}
|