@erkamyaman/capacitor-foldable 7.0.0

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 (43) hide show
  1. package/ErkamyamanCapacitorFoldable.podspec +17 -0
  2. package/LICENSE +21 -0
  3. package/Package.swift +28 -0
  4. package/README.md +457 -0
  5. package/android/build.gradle +71 -0
  6. package/android/src/main/AndroidManifest.xml +2 -0
  7. package/android/src/main/kotlin/io/github/erkamyaman/foldable/DisplayModes.kt +115 -0
  8. package/android/src/main/kotlin/io/github/erkamyaman/foldable/Foldable.kt +189 -0
  9. package/android/src/main/kotlin/io/github/erkamyaman/foldable/FoldablePlugin.kt +333 -0
  10. package/android/src/main/res/.gitkeep +0 -0
  11. package/dist/docs.json +584 -0
  12. package/dist/esm/css.d.ts +8 -0
  13. package/dist/esm/css.js +29 -0
  14. package/dist/esm/css.js.map +1 -0
  15. package/dist/esm/definitions.d.ts +267 -0
  16. package/dist/esm/definitions.js +2 -0
  17. package/dist/esm/definitions.js.map +1 -0
  18. package/dist/esm/index.d.ts +5 -0
  19. package/dist/esm/index.js +9 -0
  20. package/dist/esm/index.js.map +1 -0
  21. package/dist/esm/polyfill.d.ts +19 -0
  22. package/dist/esm/polyfill.js +76 -0
  23. package/dist/esm/polyfill.js.map +1 -0
  24. package/dist/esm/segments.d.ts +8 -0
  25. package/dist/esm/segments.js +26 -0
  26. package/dist/esm/segments.js.map +1 -0
  27. package/dist/esm/size-class.d.ts +2 -0
  28. package/dist/esm/size-class.js +33 -0
  29. package/dist/esm/size-class.js.map +1 -0
  30. package/dist/esm/web.d.ts +21 -0
  31. package/dist/esm/web.js +46 -0
  32. package/dist/esm/web.js.map +1 -0
  33. package/dist/plugin.cjs.js +222 -0
  34. package/dist/plugin.cjs.js.map +1 -0
  35. package/dist/plugin.js +225 -0
  36. package/dist/plugin.js.map +1 -0
  37. package/ios/Sources/FoldablePlugin/Foldable.swift +156 -0
  38. package/ios/Sources/FoldablePlugin/FoldablePlugin.swift +194 -0
  39. package/ios/Sources/FoldablePlugin/ReservedRegionFoldProvider.swift +69 -0
  40. package/ios/Sources/FoldablePlugin/UIKitFoldSource.swift +53 -0
  41. package/ios/Tests/FoldablePluginTests/FoldableTests.swift +104 -0
  42. package/ios/Tests/FoldablePluginTests/ReservedRegionFoldProviderTests.swift +143 -0
  43. package/package.json +96 -0
@@ -0,0 +1,115 @@
1
+ package io.github.erkamyaman.foldable
2
+
3
+ import android.app.Activity
4
+ import android.content.Context
5
+ import android.view.View
6
+ import androidx.core.content.ContextCompat
7
+ import androidx.window.area.WindowAreaCapability
8
+ import androidx.window.area.WindowAreaController
9
+ import androidx.window.area.WindowAreaInfo
10
+ import androidx.window.area.WindowAreaPresentationSessionCallback
11
+ import androidx.window.area.WindowAreaSession
12
+ import androidx.window.area.WindowAreaSessionCallback
13
+ import androidx.window.area.WindowAreaSessionPresenter
14
+ import androidx.window.core.ExperimentalWindowApi
15
+ import kotlinx.coroutines.flow.Flow
16
+ import kotlinx.coroutines.flow.distinctUntilChanged
17
+ import kotlinx.coroutines.flow.map
18
+ import kotlinx.coroutines.flow.onEach
19
+
20
+ data class DisplayModeStatuses(val rearDisplay: String, val dualScreen: String) {
21
+ companion object {
22
+ val UNSUPPORTED = DisplayModeStatuses("unsupported", "unsupported")
23
+ }
24
+ }
25
+
26
+ @OptIn(ExperimentalWindowApi::class)
27
+ class DisplayModes(private val activity: Activity) {
28
+
29
+ private val controller = WindowAreaController.getOrCreate()
30
+ private val executor = ContextCompat.getMainExecutor(activity)
31
+ private var rearDisplayArea: WindowAreaInfo? = null
32
+ private var rearDisplaySession: WindowAreaSession? = null
33
+ private var dualScreenSession: WindowAreaSessionPresenter? = null
34
+
35
+ fun statuses(): Flow<DisplayModeStatuses> =
36
+ controller.windowAreaInfos
37
+ .map { areas -> areas.firstOrNull { it.type == WindowAreaInfo.Type.TYPE_REAR_FACING } }
38
+ .onEach { rearDisplayArea = it }
39
+ .map { area ->
40
+ DisplayModeStatuses(
41
+ rearDisplay = statusOf(area, WindowAreaCapability.Operation.OPERATION_TRANSFER_ACTIVITY_TO_AREA),
42
+ dualScreen = statusOf(area, WindowAreaCapability.Operation.OPERATION_PRESENT_ON_AREA)
43
+ )
44
+ }
45
+ .distinctUntilChanged()
46
+
47
+ fun startRearDisplay(onStarted: () -> Unit, onEnded: (Throwable?) -> Unit) {
48
+ val area = rearDisplayArea ?: return onEnded(null)
49
+
50
+ controller.transferActivityToWindowArea(
51
+ area.token,
52
+ activity,
53
+ executor,
54
+ object : WindowAreaSessionCallback {
55
+ override fun onSessionStarted(session: WindowAreaSession) {
56
+ rearDisplaySession = session
57
+ onStarted()
58
+ }
59
+
60
+ override fun onSessionEnded(t: Throwable?) {
61
+ rearDisplaySession = null
62
+ onEnded(t)
63
+ }
64
+ }
65
+ )
66
+ }
67
+
68
+ fun stopRearDisplay() {
69
+ rearDisplaySession?.close()
70
+ rearDisplaySession = null
71
+ }
72
+
73
+ fun startDualScreen(createContent: (Context) -> View, onStarted: () -> Unit, onEnded: (Throwable?) -> Unit) {
74
+ dualScreenSession?.let { session ->
75
+ session.setContentView(createContent(session.context))
76
+ onStarted()
77
+ return
78
+ }
79
+
80
+ val area = rearDisplayArea ?: return onEnded(null)
81
+
82
+ controller.presentContentOnWindowArea(
83
+ area.token,
84
+ activity,
85
+ executor,
86
+ object : WindowAreaPresentationSessionCallback {
87
+ override fun onSessionStarted(session: WindowAreaSessionPresenter) {
88
+ dualScreenSession = session
89
+ session.setContentView(createContent(session.context))
90
+ onStarted()
91
+ }
92
+
93
+ override fun onSessionEnded(t: Throwable?) {
94
+ dualScreenSession = null
95
+ onEnded(t)
96
+ }
97
+
98
+ override fun onContainerVisibilityChanged(isVisible: Boolean) = Unit
99
+ }
100
+ )
101
+ }
102
+
103
+ fun stopDualScreen() {
104
+ dualScreenSession?.close()
105
+ dualScreenSession = null
106
+ }
107
+
108
+ private fun statusOf(area: WindowAreaInfo?, operation: WindowAreaCapability.Operation): String =
109
+ when (area?.getCapability(operation)?.status) {
110
+ WindowAreaCapability.Status.WINDOW_AREA_STATUS_ACTIVE -> "active"
111
+ WindowAreaCapability.Status.WINDOW_AREA_STATUS_AVAILABLE -> "available"
112
+ WindowAreaCapability.Status.WINDOW_AREA_STATUS_UNAVAILABLE -> "unavailable"
113
+ else -> "unsupported"
114
+ }
115
+ }
@@ -0,0 +1,189 @@
1
+ package io.github.erkamyaman.foldable
2
+
3
+ import android.app.Activity
4
+ import android.content.Context
5
+ import android.content.pm.PackageManager
6
+ import android.graphics.Rect
7
+ import android.hardware.Sensor
8
+ import android.hardware.SensorEvent
9
+ import android.hardware.SensorEventListener
10
+ import android.hardware.SensorManager
11
+ import android.os.Build
12
+ import android.view.View
13
+ import androidx.core.view.ViewCompat
14
+ import androidx.window.layout.FoldingFeature
15
+ import androidx.window.layout.SupportedPosture
16
+ import androidx.window.layout.WindowInfoTracker
17
+ import androidx.window.layout.WindowLayoutInfo
18
+ import androidx.window.layout.WindowMetricsCalculator
19
+ import kotlinx.coroutines.channels.awaitClose
20
+ import kotlinx.coroutines.flow.Flow
21
+ import kotlinx.coroutines.flow.callbackFlow
22
+ import kotlinx.coroutines.flow.combine
23
+ import kotlinx.coroutines.flow.conflate
24
+ import kotlinx.coroutines.flow.distinctUntilChanged
25
+ import kotlinx.coroutines.flow.emptyFlow
26
+ import kotlin.math.roundToInt
27
+
28
+ data class FoldBounds(val x: Int, val y: Int, val width: Int, val height: Int)
29
+
30
+ data class FoldState(
31
+ val state: String,
32
+ val hingeOrientation: String? = null,
33
+ val hingeBounds: FoldBounds? = null,
34
+ val occludedBounds: FoldBounds? = null,
35
+ val isSeparating: Boolean = false,
36
+ val cameraBounds: List<FoldBounds> = emptyList(),
37
+ val posture: String = "flat"
38
+ ) {
39
+ companion object {
40
+ val FLAT = FoldState("flat")
41
+ }
42
+ }
43
+
44
+ data class SizeClass(
45
+ val horizontal: String,
46
+ val vertical: String,
47
+ val widthClass: String,
48
+ val heightClass: String
49
+ )
50
+
51
+ private const val MEDIUM_WIDTH_DP = 600f
52
+ private const val EXPANDED_WIDTH_DP = 840f
53
+ private const val LARGE_WIDTH_DP = 1200f
54
+ private const val EXTRA_LARGE_WIDTH_DP = 1600f
55
+ private const val MEDIUM_HEIGHT_DP = 480f
56
+ private const val EXPANDED_HEIGHT_DP = 900f
57
+
58
+ internal fun sizeClassOf(widthDp: Float, heightDp: Float) = SizeClass(
59
+ horizontal = if (widthDp >= MEDIUM_WIDTH_DP) "regular" else "compact",
60
+ vertical = if (heightDp >= MEDIUM_HEIGHT_DP) "regular" else "compact",
61
+ widthClass = when {
62
+ widthDp >= EXTRA_LARGE_WIDTH_DP -> "extraLarge"
63
+ widthDp >= LARGE_WIDTH_DP -> "large"
64
+ widthDp >= EXPANDED_WIDTH_DP -> "expanded"
65
+ widthDp >= MEDIUM_WIDTH_DP -> "medium"
66
+ else -> "compact"
67
+ },
68
+ heightClass = when {
69
+ heightDp >= EXPANDED_HEIGHT_DP -> "expanded"
70
+ heightDp >= MEDIUM_HEIGHT_DP -> "medium"
71
+ else -> "compact"
72
+ }
73
+ )
74
+
75
+ internal fun foldStateOf(
76
+ fold: FoldingFeature?,
77
+ density: Float,
78
+ offsetX: Int,
79
+ offsetY: Int,
80
+ cutouts: List<Rect> = emptyList()
81
+ ): FoldState {
82
+ val cameraBounds = cutouts.map { toCssPixels(it, density, offsetX, offsetY) }
83
+ fold ?: return FoldState.FLAT.copy(cameraBounds = cameraBounds)
84
+
85
+ val state = when (fold.state) {
86
+ FoldingFeature.State.HALF_OPENED -> "half-opened"
87
+ else -> "flat"
88
+ }
89
+
90
+ val orientation = when (fold.orientation) {
91
+ FoldingFeature.Orientation.HORIZONTAL -> "horizontal"
92
+ else -> "vertical"
93
+ }
94
+
95
+ val bounds = toCssPixels(fold.bounds, density, offsetX, offsetY)
96
+
97
+ val occluded = if (fold.occlusionType == FoldingFeature.OcclusionType.FULL) {
98
+ bounds
99
+ } else {
100
+ null
101
+ }
102
+
103
+ val posture = when {
104
+ fold.state != FoldingFeature.State.HALF_OPENED -> "flat"
105
+ fold.orientation == FoldingFeature.Orientation.HORIZONTAL -> "tabletop"
106
+ else -> "book"
107
+ }
108
+
109
+ return FoldState(state, orientation, bounds, occluded, fold.isSeparating, cameraBounds, posture)
110
+ }
111
+
112
+ private fun toCssPixels(rect: Rect, density: Float, offsetX: Int, offsetY: Int) = FoldBounds(
113
+ x = ((rect.left - offsetX) / density).roundToInt(),
114
+ y = ((rect.top - offsetY) / density).roundToInt(),
115
+ width = ((rect.right - rect.left) / density).roundToInt(),
116
+ height = ((rect.bottom - rect.top) / density).roundToInt()
117
+ )
118
+
119
+ class Foldable(private val activity: Activity, private val webView: View) {
120
+
121
+ private val tracker = WindowInfoTracker.getOrCreate(activity)
122
+ private val sensorManager = activity.getSystemService(Context.SENSOR_SERVICE) as SensorManager
123
+ private val hingeSensor: Sensor? =
124
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
125
+ sensorManager.getDefaultSensor(Sensor.TYPE_HINGE_ANGLE)
126
+ } else {
127
+ null
128
+ }
129
+
130
+ fun isDeviceFoldable(): Boolean {
131
+ if (supportedPostures().isNotEmpty()) return true
132
+
133
+ return Build.VERSION.SDK_INT >= Build.VERSION_CODES.R &&
134
+ activity.packageManager.hasSystemFeature(PackageManager.FEATURE_SENSOR_HINGE_ANGLE)
135
+ }
136
+
137
+ fun supportsTabletop(): Boolean = SupportedPosture.TABLETOP in supportedPostures()
138
+
139
+ fun hasHingeSensor(): Boolean = hingeSensor != null
140
+
141
+ fun sizeClass(): SizeClass {
142
+ val bounds = WindowMetricsCalculator.getOrCreate().computeCurrentWindowMetrics(activity).bounds
143
+ val density = activity.resources.displayMetrics.density
144
+ return sizeClassOf(bounds.width() / density, bounds.height() / density)
145
+ }
146
+
147
+ fun foldStates(): Flow<FoldState> =
148
+ combine(tracker.windowLayoutInfo(activity), webViewLayouts()) { info, _ -> toFoldState(info) }
149
+ .distinctUntilChanged()
150
+
151
+ private fun webViewLayouts(): Flow<Unit> = callbackFlow {
152
+ val listener = View.OnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> trySend(Unit) }
153
+ trySend(Unit)
154
+ webView.addOnLayoutChangeListener(listener)
155
+ awaitClose { webView.removeOnLayoutChangeListener(listener) }
156
+ }.conflate()
157
+
158
+ fun hingeAngles(): Flow<Float> {
159
+ val sensor = hingeSensor ?: return emptyFlow()
160
+
161
+ return callbackFlow {
162
+ val listener = object : SensorEventListener {
163
+ override fun onSensorChanged(event: SensorEvent) {
164
+ if (event.values.isEmpty()) return
165
+ trySend(event.values[0].coerceIn(0f, 360f))
166
+ }
167
+
168
+ override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) = Unit
169
+ }
170
+ sensorManager.registerListener(listener, sensor, SensorManager.SENSOR_DELAY_NORMAL)
171
+ awaitClose { sensorManager.unregisterListener(listener) }
172
+ }.distinctUntilChanged()
173
+ }
174
+
175
+ private fun supportedPostures(): List<SupportedPosture> =
176
+ runCatching { tracker.supportedPostures }.getOrDefault(emptyList())
177
+
178
+ private fun toFoldState(info: WindowLayoutInfo): FoldState {
179
+ val webViewOffset = IntArray(2).also(webView::getLocationInWindow)
180
+ val cutouts = ViewCompat.getRootWindowInsets(webView)?.displayCutout?.boundingRects.orEmpty()
181
+ return foldStateOf(
182
+ info.displayFeatures.filterIsInstance<FoldingFeature>().firstOrNull(),
183
+ activity.resources.displayMetrics.density,
184
+ webViewOffset[0],
185
+ webViewOffset[1],
186
+ cutouts
187
+ )
188
+ }
189
+ }
@@ -0,0 +1,333 @@
1
+ package io.github.erkamyaman.foldable
2
+
3
+ import android.annotation.SuppressLint
4
+ import android.content.Context
5
+ import android.net.Uri
6
+ import android.webkit.WebResourceRequest
7
+ import android.webkit.WebResourceResponse
8
+ import android.webkit.WebView
9
+ import android.webkit.WebViewClient
10
+ import androidx.lifecycle.Lifecycle
11
+ import androidx.lifecycle.LifecycleOwner
12
+ import androidx.lifecycle.repeatOnLifecycle
13
+ import com.getcapacitor.JSArray
14
+ import com.getcapacitor.JSObject
15
+ import com.getcapacitor.Plugin
16
+ import com.getcapacitor.PluginCall
17
+ import com.getcapacitor.PluginMethod
18
+ import com.getcapacitor.annotation.CapacitorPlugin
19
+ import kotlinx.coroutines.CoroutineScope
20
+ import kotlinx.coroutines.Dispatchers
21
+ import kotlinx.coroutines.Job
22
+ import kotlinx.coroutines.SupervisorJob
23
+ import kotlinx.coroutines.cancel
24
+ import kotlinx.coroutines.delay
25
+ import kotlinx.coroutines.flow.conflate
26
+ import kotlinx.coroutines.flow.first
27
+ import kotlinx.coroutines.flow.firstOrNull
28
+ import kotlinx.coroutines.launch
29
+ import kotlinx.coroutines.withTimeoutOrNull
30
+ import org.json.JSONObject
31
+ import java.net.URL
32
+
33
+ @CapacitorPlugin(name = "Foldable")
34
+ class FoldablePlugin : Plugin() {
35
+
36
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
37
+
38
+ private var implementation: Foldable? = null
39
+ private var displayModes: DisplayModes? = null
40
+ private var lastKnownState: FoldState? = null
41
+ private var hingeAngleJob: Job? = null
42
+ private var lastKnownAngle: Float? = null
43
+ private var lastSizeClass: SizeClass? = null
44
+ private var lastDisplayModes: DisplayModeStatuses? = null
45
+
46
+ override fun load() {
47
+ val activity = this.activity ?: return
48
+ val implementation = Foldable(activity, bridge.webView)
49
+ this.implementation = implementation
50
+ val displayModes = DisplayModes(activity)
51
+ this.displayModes = displayModes
52
+
53
+ lastSizeClass = implementation.sizeClass()
54
+ bridge.webView.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> notifySizeClassIfChanged() }
55
+
56
+ val owner = activity as? LifecycleOwner ?: return
57
+ scope.launch {
58
+ owner.repeatOnLifecycle(Lifecycle.State.STARTED) {
59
+ implementation.foldStates().collect { state ->
60
+ lastKnownState = state
61
+ notifyListeners("foldStateChange", state.toJSObject())
62
+ }
63
+ }
64
+ }
65
+ scope.launch {
66
+ owner.repeatOnLifecycle(Lifecycle.State.STARTED) {
67
+ displayModes.statuses().collect { statuses ->
68
+ lastDisplayModes = statuses
69
+ notifyListeners(DISPLAY_MODE_CHANGE, statuses.toJSObject())
70
+ }
71
+ }
72
+ }
73
+ }
74
+
75
+ override fun handleOnDestroy() {
76
+ displayModes?.stopRearDisplay()
77
+ displayModes?.stopDualScreen()
78
+ scope.cancel()
79
+ }
80
+
81
+ @PluginMethod(returnType = PluginMethod.RETURN_NONE)
82
+ override fun addListener(call: PluginCall) {
83
+ super.addListener(call)
84
+ scope.launch { updateHingeAngleUpdates() }
85
+ }
86
+
87
+ @PluginMethod(returnType = PluginMethod.RETURN_NONE)
88
+ override fun removeListener(call: PluginCall) {
89
+ super.removeListener(call)
90
+ scope.launch { updateHingeAngleUpdates() }
91
+ }
92
+
93
+ @PluginMethod
94
+ override fun removeAllListeners(call: PluginCall) {
95
+ super.removeAllListeners(call)
96
+ scope.launch { updateHingeAngleUpdates() }
97
+ }
98
+
99
+ @PluginMethod
100
+ fun isDeviceFoldable(call: PluginCall) {
101
+ val implementation = this.implementation
102
+ call.resolve(
103
+ JSObject()
104
+ .put("foldable", implementation?.isDeviceFoldable() ?: false)
105
+ .put("supportsTabletop", implementation?.supportsTabletop() ?: false)
106
+ )
107
+ }
108
+
109
+ @PluginMethod
110
+ fun getFoldState(call: PluginCall) {
111
+ lastKnownState?.let {
112
+ call.resolve(it.toJSObject())
113
+ return
114
+ }
115
+
116
+ val implementation = this.implementation
117
+ if (implementation == null) {
118
+ call.resolve(FoldState.FLAT.toJSObject())
119
+ return
120
+ }
121
+
122
+ scope.launch {
123
+ val state = withTimeoutOrNull(FIRST_EMISSION_TIMEOUT_MS) {
124
+ implementation.foldStates().first()
125
+ } ?: FoldState.FLAT
126
+ lastKnownState = state
127
+ call.resolve(state.toJSObject())
128
+ }
129
+ }
130
+
131
+ @PluginMethod
132
+ fun getHingeAngle(call: PluginCall) {
133
+ lastKnownAngle?.let {
134
+ call.resolve(angleResult(it))
135
+ return
136
+ }
137
+
138
+ val implementation = this.implementation
139
+ if (implementation == null || !implementation.hasHingeSensor()) {
140
+ call.resolve(angleResult(null))
141
+ return
142
+ }
143
+
144
+ scope.launch {
145
+ val angle = withTimeoutOrNull(FIRST_EMISSION_TIMEOUT_MS) {
146
+ implementation.hingeAngles().firstOrNull()
147
+ }
148
+ call.resolve(angleResult(angle))
149
+ }
150
+ }
151
+
152
+ @PluginMethod
153
+ fun getSizeClass(call: PluginCall) {
154
+ val sizeClass = implementation?.sizeClass() ?: sizeClassOf(0f, 0f)
155
+ call.resolve(sizeClass.toJSObject())
156
+ }
157
+
158
+ @PluginMethod
159
+ fun getBarPlacement(call: PluginCall) {
160
+ call.resolve(JSObject().put("verticalBarEdge", JSONObject.NULL))
161
+ }
162
+
163
+ @PluginMethod
164
+ fun getDisplayModes(call: PluginCall) {
165
+ lastDisplayModes?.let {
166
+ call.resolve(it.toJSObject())
167
+ return
168
+ }
169
+
170
+ val displayModes = this.displayModes ?: return call.resolve(DisplayModeStatuses.UNSUPPORTED.toJSObject())
171
+
172
+ scope.launch {
173
+ val statuses = withTimeoutOrNull(FIRST_EMISSION_TIMEOUT_MS) {
174
+ displayModes.statuses().first()
175
+ } ?: DisplayModeStatuses.UNSUPPORTED
176
+ call.resolve(statuses.toJSObject())
177
+ }
178
+ }
179
+
180
+ @PluginMethod
181
+ fun startRearDisplay(call: PluginCall) {
182
+ val displayModes = this.displayModes ?: return call.unavailable("Rear display mode needs an activity.")
183
+
184
+ scope.launch {
185
+ when (lastDisplayModes?.rearDisplay) {
186
+ "active" -> call.resolve()
187
+ "available" -> {
188
+ val settle = Settle(call, "Rear display mode")
189
+ displayModes.startRearDisplay(settle::started, settle::ended)
190
+ }
191
+ else -> call.unavailable("Rear display mode is not available on this device right now.")
192
+ }
193
+ }
194
+ }
195
+
196
+ @PluginMethod
197
+ fun stopRearDisplay(call: PluginCall) {
198
+ scope.launch {
199
+ displayModes?.stopRearDisplay()
200
+ call.resolve()
201
+ }
202
+ }
203
+
204
+ @PluginMethod
205
+ fun startDualScreen(call: PluginCall) {
206
+ val url = call.getString("url") ?: return call.reject("url is required.")
207
+ val displayModes = this.displayModes ?: return call.unavailable("Dual-screen mode needs an activity.")
208
+
209
+ scope.launch {
210
+ when (lastDisplayModes?.dualScreen) {
211
+ "available", "active" -> {
212
+ val settle = Settle(call, "Dual-screen mode")
213
+ displayModes.startDualScreen({ context -> dualScreenWebView(context, url) }, settle::started, settle::ended)
214
+ }
215
+ else -> call.unavailable("Dual-screen mode is not available on this device right now.")
216
+ }
217
+ }
218
+ }
219
+
220
+ @PluginMethod
221
+ fun stopDualScreen(call: PluginCall) {
222
+ scope.launch {
223
+ displayModes?.stopDualScreen()
224
+ call.resolve()
225
+ }
226
+ }
227
+
228
+ private fun notifySizeClassIfChanged() {
229
+ val sizeClass = implementation?.sizeClass() ?: return
230
+ if (sizeClass == lastSizeClass) return
231
+
232
+ lastSizeClass = sizeClass
233
+ notifyListeners(SIZE_CLASS_CHANGE, sizeClass.toJSObject())
234
+ }
235
+
236
+ private fun updateHingeAngleUpdates() {
237
+ if (!hasListeners(HINGE_ANGLE_CHANGE)) {
238
+ hingeAngleJob?.cancel()
239
+ hingeAngleJob = null
240
+ lastKnownAngle = null
241
+ return
242
+ }
243
+ if (hingeAngleJob != null) return
244
+
245
+ val implementation = this.implementation ?: return
246
+ val owner = activity as? LifecycleOwner ?: return
247
+ hingeAngleJob = scope.launch {
248
+ owner.repeatOnLifecycle(Lifecycle.State.STARTED) {
249
+ try {
250
+ implementation.hingeAngles().conflate().collect { angle ->
251
+ lastKnownAngle = angle
252
+ notifyListeners(HINGE_ANGLE_CHANGE, angleResult(angle))
253
+ delay(HINGE_ANGLE_FRAME_MS)
254
+ }
255
+ } finally {
256
+ lastKnownAngle = null
257
+ }
258
+ }
259
+ }
260
+ }
261
+
262
+ @SuppressLint("SetJavaScriptEnabled")
263
+ private fun dualScreenWebView(context: Context, url: String): WebView = WebView(context).apply {
264
+ settings.javaScriptEnabled = true
265
+ settings.domStorageEnabled = true
266
+ webViewClient = object : WebViewClient() {
267
+ override fun shouldInterceptRequest(view: WebView, request: WebResourceRequest): WebResourceResponse? =
268
+ bridge.localServer.shouldInterceptRequest(request)
269
+ }
270
+ loadUrl(resolveUrl(url))
271
+ }
272
+
273
+ private fun resolveUrl(url: String): String =
274
+ if (Uri.parse(url).scheme != null) url else URL(URL(bridge.appUrl), url).toString()
275
+
276
+ private inner class Settle(private val call: PluginCall, private val mode: String) {
277
+ private var settled = false
278
+
279
+ fun started() {
280
+ if (settled) return
281
+ settled = true
282
+ call.resolve()
283
+ }
284
+
285
+ fun ended(error: Throwable?) {
286
+ if (settled) return
287
+ settled = true
288
+ call.reject(error?.message ?: "$mode ended before it started.")
289
+ }
290
+ }
291
+
292
+ private fun angleResult(angle: Float?): JSObject =
293
+ JSObject().put("angle", angle?.toDouble() ?: JSONObject.NULL)
294
+
295
+ private fun FoldState.toJSObject(): JSObject = JSObject().apply {
296
+ put("state", state)
297
+ put("isSeparating", isSeparating)
298
+ put("posture", posture)
299
+ hingeOrientation?.let { put("hingeOrientation", it) }
300
+ hingeBounds?.let { put("hingeBounds", it.toJSObject()) }
301
+ occludedBounds?.let { put("occludedBounds", it.toJSObject()) }
302
+ if (cameraBounds.isNotEmpty()) {
303
+ put("cameraBounds", JSArray().apply { cameraBounds.forEach { put(it.toJSObject()) } })
304
+ }
305
+ }
306
+
307
+ private fun SizeClass.toJSObject(): JSObject = JSObject().apply {
308
+ put("horizontal", horizontal)
309
+ put("vertical", vertical)
310
+ put("widthClass", widthClass)
311
+ put("heightClass", heightClass)
312
+ }
313
+
314
+ private fun DisplayModeStatuses.toJSObject(): JSObject = JSObject().apply {
315
+ put("rearDisplay", rearDisplay)
316
+ put("dualScreen", dualScreen)
317
+ }
318
+
319
+ private fun FoldBounds.toJSObject(): JSObject = JSObject().apply {
320
+ put("x", x)
321
+ put("y", y)
322
+ put("width", width)
323
+ put("height", height)
324
+ }
325
+
326
+ private companion object {
327
+ const val FIRST_EMISSION_TIMEOUT_MS = 1_000L
328
+ const val HINGE_ANGLE_CHANGE = "hingeAngleChange"
329
+ const val HINGE_ANGLE_FRAME_MS = 16L
330
+ const val SIZE_CLASS_CHANGE = "sizeClassChange"
331
+ const val DISPLAY_MODE_CHANGE = "displayModeChange"
332
+ }
333
+ }
File without changes