Skip to content

Commit 199374a

Browse files
feat: support non-standard windows added by WindowManager (#306)
## Summary - Support floating overlays, popups windows, splash windows - Improve decision when PixelCopy can and cannot be used. <img width="648" height="619" alt="image" src="https://github.com/user-attachments/assets/ac0b139c-6364-4c36-8952-997d749d82b8" /> ## How did you test this change? <!-- Frontend - Leave a screencast or a screenshot to visually describe the changes. --> ## Are there any deployment considerations? <!-- Backend - Do we need to consider migrations or backfilling data? --> <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Add floating/attached dialog examples and switch capture logic to use WindowManager.LayoutParams with PixelCopy candidacy checks. > > - **SDK**: > - **WindowEntry**: replace `wmType` with `layoutParams`; add `isPixelCopyCandidate()` (excludes non-activity, `TYPE_APPLICATION_STARTING`, and `FLAG_SECURE`). > - **CaptureSource**: pick base window via `layoutParams.type`; use PixelCopy only when `windowEntry.isPixelCopyCandidate()`. > - **WindowInspector**: populate `WindowEntry` with `layoutParams` while preserving `determineWindowType` behavior. > - **Tests**: add `WindowEntryTest` covering PixelCopy candidacy rules. > - **E2E app**: > - Add `FloatingPopupView` and buttons to show floating popup (`TYPE_APPLICATION_PANEL`) and attached dialog (`TYPE_APPLICATION_ATTACHED_DIALOG`). > - Update `activity_masking_bench.xml` with new controls. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 31bb519. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent f3369bc commit 199374a

File tree

6 files changed

+264
-5
lines changed

6 files changed

+264
-5
lines changed

e2e/android/app/src/main/java/com/example/androidobservability/masking/XMLMaskingActivity.kt

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,23 @@
11
package com.example.androidobservability.masking
22

3+
import android.content.Context
34
import android.content.Intent
45
import android.os.Bundle
56
import android.widget.Button
67
import android.widget.EditText
78
import android.widget.ImageButton
89
import android.widget.LinearLayout
910
import android.widget.Toast
11+
import android.graphics.Color
12+
import android.graphics.PixelFormat
13+
import android.view.Gravity
14+
import android.view.ViewGroup
15+
import android.view.WindowManager
16+
import android.widget.FrameLayout
17+
import android.widget.TextView
1018
import androidx.activity.ComponentActivity
1119
import android.app.AlertDialog
20+
import androidx.core.graphics.toColorInt
1221
import com.example.androidobservability.R
1322

1423
class XMLMaskingActivity : ComponentActivity() {
@@ -59,6 +68,62 @@ class XMLMaskingActivity : ComponentActivity() {
5968
findViewById<Button>(R.id.button_toast).setOnClickListener {
6069
Toast.makeText(this, "This is an example toast.", Toast.LENGTH_SHORT).show()
6170
}
71+
findViewById<Button>(R.id.button_floating_popup).setOnClickListener {
72+
val windowManager = getSystemService(WINDOW_SERVICE) as WindowManager
73+
74+
val params = WindowManager.LayoutParams().apply {
75+
width = WindowManager.LayoutParams.MATCH_PARENT
76+
height = WindowManager.LayoutParams.MATCH_PARENT
77+
format = PixelFormat.TRANSLUCENT
78+
flags = WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN
79+
type = WindowManager.LayoutParams.TYPE_APPLICATION_PANEL
80+
token = window.decorView.applicationWindowToken
81+
gravity = Gravity.CENTER
82+
}
83+
84+
val popupView = FloatingPopupView(this, "Floating Popup").apply {
85+
onSendClicked = {
86+
Toast.makeText(this@XMLMaskingActivity, "Send clicked", Toast.LENGTH_SHORT).show()
87+
}
88+
onDismissRequested = {
89+
try {
90+
windowManager.removeView(this)
91+
} catch (_: Exception) {
92+
}
93+
}
94+
}
95+
96+
windowManager.addView(popupView, params)
97+
}
98+
99+
findViewById<Button>(R.id.button_attached_dialog).setOnClickListener {
100+
val windowManager = getSystemService(WINDOW_SERVICE) as WindowManager
101+
102+
val params = WindowManager.LayoutParams().apply {
103+
width = WindowManager.LayoutParams.MATCH_PARENT
104+
height = WindowManager.LayoutParams.MATCH_PARENT
105+
format = PixelFormat.TRANSLUCENT
106+
flags = WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN
107+
type = WindowManager.LayoutParams.TYPE_APPLICATION_ATTACHED_DIALOG
108+
// Attach to this window so it behaves like an attached dialog
109+
token = window.decorView.windowToken
110+
gravity = Gravity.CENTER
111+
}
112+
113+
val popupView = FloatingPopupView(this, "Attached Dialog").apply {
114+
onSendClicked = {
115+
Toast.makeText(this@XMLMaskingActivity, "Send clicked", Toast.LENGTH_SHORT).show()
116+
}
117+
onDismissRequested = {
118+
try {
119+
windowManager.removeView(this)
120+
} catch (_: Exception) {
121+
}
122+
}
123+
}
124+
125+
windowManager.addView(popupView, params)
126+
}
62127

63128
val firstField = findViewById<EditText>(R.id.input_first)
64129
val secondField = findViewById<EditText>(R.id.input_second)
@@ -71,4 +136,75 @@ class XMLMaskingActivity : ComponentActivity() {
71136
}
72137
}
73138

139+
class FloatingPopupView(context: Context, private val headerTitle: String) : FrameLayout(context) {
140+
141+
var onSendClicked: (() -> Unit)? = null
142+
var onDismissRequested: (() -> Unit)? = null
143+
144+
init {
145+
setBackgroundColor("#80000000".toColorInt())
146+
isClickable = true
147+
isFocusable = true
148+
149+
// White card container with vertical layout to host header and row
150+
val contentContainer = LinearLayout(context).apply {
151+
orientation = LinearLayout.VERTICAL
152+
setBackgroundColor(Color.WHITE)
153+
elevation = 12f
154+
setPadding(48, 32, 32, 32)
155+
}
156+
157+
val header = TextView(context).apply {
158+
text = headerTitle
159+
setTextColor(Color.BLACK)
160+
textSize = 16f
161+
}
162+
163+
val row = LinearLayout(context).apply {
164+
orientation = LinearLayout.HORIZONTAL
165+
}
166+
167+
val label = TextView(context).apply {
168+
text = "UserName"
169+
setTextColor(Color.BLACK)
170+
}
171+
172+
val sendButton = ImageButton(context).apply {
173+
setImageResource(android.R.drawable.ic_menu_send)
174+
contentDescription = "Send"
175+
background = null
176+
}
177+
178+
row.addView(label)
179+
row.addView(
180+
sendButton,
181+
LinearLayout.LayoutParams(
182+
LayoutParams.WRAP_CONTENT,
183+
LayoutParams.WRAP_CONTENT
184+
).apply { leftMargin = 24 }
185+
)
186+
187+
contentContainer.addView(header)
188+
contentContainer.addView(row)
189+
190+
addView(
191+
contentContainer,
192+
LayoutParams(
193+
LayoutParams.WRAP_CONTENT,
194+
LayoutParams.WRAP_CONTENT,
195+
Gravity.CENTER
196+
)
197+
)
198+
199+
// Outside tap dismiss
200+
setOnClickListener { onDismissRequested?.invoke() }
201+
// Consume inner content clicks
202+
contentContainer.setOnClickListener { }
203+
sendButton.setOnClickListener {
204+
onSendClicked?.invoke()
205+
onDismissRequested?.invoke()
206+
}
207+
}
208+
}
209+
74210

e2e/android/app/src/main/res/layout/activity_masking_bench.xml

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,28 @@
8989
android:layout_height="wrap_content"
9090
android:layout_weight="1"
9191
android:text="Toast" />
92+
93+
<Space
94+
android:layout_width="12dp"
95+
android:layout_height="1dp" />
96+
97+
<Button
98+
android:id="@+id/button_floating_popup"
99+
android:layout_width="0dp"
100+
android:layout_height="wrap_content"
101+
android:layout_weight="1"
102+
android:text="Floating Popup" />
103+
104+
<Space
105+
android:layout_width="12dp"
106+
android:layout_height="1dp" />
107+
108+
<Button
109+
android:id="@+id/button_attached_dialog"
110+
android:layout_width="0dp"
111+
android:layout_height="wrap_content"
112+
android:layout_weight="1"
113+
android:text="Attached Dialog" />
92114
</LinearLayout>
93115

94116
<TextView

sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/capture/CaptureSource.kt

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,8 @@ class CaptureSource(
153153

154154
private fun pickBaseWindow(windowsEntries: List<WindowEntry>): WindowEntry? {
155155
windowsEntries.firstOrNull {
156-
(it.wmType == TYPE_APPLICATION || it.wmType == TYPE_BASE_APPLICATION)
156+
val wmType = it.layoutParams?.type ?: 0
157+
(wmType == TYPE_APPLICATION || wmType == TYPE_BASE_APPLICATION)
157158
}?.let { return it }
158159

159160
windowsEntries.firstOrNull { it.type == WindowType.ACTIVITY }?.let { return it }
@@ -172,10 +173,13 @@ class CaptureSource(
172173

173174
private suspend fun captureViewBitmap(windowEntry: WindowEntry): Bitmap? {
174175
val view = windowEntry.rootView
175-
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
176+
177+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && windowEntry.isPixelCopyCandidate()) {
176178
val window = windowInspector.findWindow(view)
177179
if (window != null) {
178-
pixelCopy(window, view, windowEntry.rect())?.let { return it }
180+
pixelCopy(window, view, windowEntry.rect())?.let {
181+
return it
182+
}
179183
}
180184
}
181185

sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/capture/WindowEntry.kt

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
package com.launchdarkly.observability.replay.capture
22

33
import android.graphics.Rect
4+
import android.os.Build
45
import android.view.View
6+
import android.view.WindowManager
7+
import android.view.WindowManager.LayoutParams.TYPE_APPLICATION_STARTING
58

69

710
enum class WindowType {
@@ -13,7 +16,7 @@ enum class WindowType {
1316
data class WindowEntry(
1417
val rootView: View,
1518
var type: WindowType,
16-
val wmType: Int,
19+
val layoutParams: WindowManager.LayoutParams?,
1720
val width: Int,
1821
val height: Int,
1922
val screenLeft: Int,
@@ -22,6 +25,23 @@ data class WindowEntry(
2225
fun rect(): Rect {
2326
return Rect(0, 0, width, height)
2427
}
28+
29+
fun isPixelCopyCandidate(): Boolean {
30+
if (type != WindowType.ACTIVITY) {
31+
return false
32+
}
33+
34+
if (layoutParams?.type == TYPE_APPLICATION_STARTING) { // Starting/Splash screen
35+
return false
36+
}
37+
38+
if (((layoutParams?.flags ?: 0) and WindowManager.LayoutParams.FLAG_SECURE) != 0) {
39+
// Secure window
40+
return false
41+
}
42+
43+
return true
44+
}
2545
}
2646

2747

sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/capture/WindowInspector.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ class WindowInspector(private val logger: LDLogger) {
3333
WindowEntry(
3434
rootView = view,
3535
type = determineWindowType(wmType),
36-
wmType = wmType,
36+
layoutParams = layoutParams,
3737
width = view.width,
3838
height = view.height,
3939
screenLeft = loc[0],
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
package com.launchdarkly.observability.replay.capture
2+
3+
import android.os.Build
4+
import android.view.View
5+
import android.view.WindowManager
6+
import org.junit.jupiter.api.Assertions.assertFalse
7+
import org.junit.jupiter.api.Assertions.assertTrue
8+
import org.junit.jupiter.api.Assumptions.assumeTrue
9+
import org.junit.jupiter.api.Test
10+
import io.mockk.mockk
11+
12+
class WindowEntryTest {
13+
private fun createEntry(
14+
type: WindowType = WindowType.ACTIVITY,
15+
layoutParams: WindowManager.LayoutParams? = null,
16+
width: Int = 100,
17+
height: Int = 100,
18+
screenLeft: Int = 0,
19+
screenTop: Int = 0
20+
): WindowEntry {
21+
val rootView: View = mockk(relaxed = true)
22+
return WindowEntry(
23+
rootView = rootView,
24+
type = type,
25+
layoutParams = layoutParams,
26+
width = width,
27+
height = height,
28+
screenLeft = screenLeft,
29+
screenTop = screenTop
30+
)
31+
}
32+
33+
@Test
34+
fun `non-activity windows are not candidates on O+`() {
35+
val entry = createEntry(
36+
type = WindowType.DIALOG,
37+
layoutParams = WindowManager.LayoutParams()
38+
)
39+
assertFalse(entry.isPixelCopyCandidate())
40+
}
41+
42+
@Test
43+
fun `starting window is not a candidate on O+`() {
44+
val lp = WindowManager.LayoutParams().apply {
45+
type = WindowManager.LayoutParams.TYPE_APPLICATION_STARTING
46+
}
47+
val entry = createEntry(
48+
type = WindowType.ACTIVITY,
49+
layoutParams = lp
50+
)
51+
assertFalse(entry.isPixelCopyCandidate())
52+
}
53+
54+
@Test
55+
fun `secure window is not a candidate on O+`() {
56+
val lp = WindowManager.LayoutParams().apply {
57+
flags = flags or WindowManager.LayoutParams.FLAG_SECURE
58+
}
59+
val entry = createEntry(
60+
type = WindowType.ACTIVITY,
61+
layoutParams = lp
62+
)
63+
assertFalse(entry.isPixelCopyCandidate())
64+
}
65+
66+
@Test
67+
fun `activity non-secure non-starting window is a candidate on O+`() {
68+
val lp = WindowManager.LayoutParams() // default: not TYPE_APPLICATION_STARTING, no FLAG_SECURE
69+
val entry = createEntry(
70+
type = WindowType.ACTIVITY,
71+
layoutParams = lp
72+
)
73+
assertTrue(entry.isPixelCopyCandidate())
74+
}
75+
}
76+
77+

0 commit comments

Comments
 (0)