Skip to content

Commit 5d669d4

Browse files
feat: take transformed coordinates, which are more precise in animation (#309)
## Summary Take transformed coordinates, which are more precise in animation for both XML and Compose case using 4 points method. <img width="1239" height="1416" alt="image" src="https://github.com/user-attachments/assets/80de7635-98c0-48f4-ad66-2c2725a05efd" /> ## 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] > Use transformed 4-point polygons for masking (more accurate under animations), update masking APIs, and add an animated demo section in the sample app. > > - **SDK/Masking**: > - Introduce `MaskContext` (matrix, root offsets, matchers) and change `MaskTarget.mask(...)` to accept context. > - Compute transformed 4-point polygons in `NativeMaskTarget.points(...)` and `ComposeMaskTarget.points(...)` (API ≥ Q), include as `Mask.points`. > - Add `Mask.draw(...)` and update `CaptureSource.drawMasks(...)` to render polygon paths (fallback to rects). > - Refactor `MaskCollector` traversal to pass `MaskContext` and use root screen offsets. > - **E2E Sample**: > - Wrap credit card inputs in `credit_card_section` and add continuous Y-rotation animation in `XMLUserFormActivity` to exercise masking during animations. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 16a967c. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent a93645f commit 5d669d4

File tree

8 files changed

+238
-128
lines changed

8 files changed

+238
-128
lines changed

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

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

3+
import android.animation.ObjectAnimator
4+
import android.animation.ValueAnimator
35
import android.os.Bundle
6+
import android.view.View
7+
import android.view.animation.LinearInterpolator
48
import android.widget.Button
59
import android.widget.EditText
610
import android.widget.Toast
@@ -13,6 +17,13 @@ class XMLUserFormActivity : ComponentActivity() {
1317
super.onCreate(savedInstanceState)
1418
setContentView(R.layout.activity_user_form)
1519

20+
val creditCardSection = findViewById<View>(R.id.credit_card_section)
21+
ObjectAnimator.ofFloat(creditCardSection, "rotationY", 0f, 360f).apply {
22+
duration = 2000
23+
repeatCount = ValueAnimator.INFINITE
24+
interpolator = LinearInterpolator()
25+
}.start()
26+
1627
val inputCardholderName = findViewById<EditText>(R.id.input_cardholder_name)
1728
val inputCardNumber = findViewById<EditText>(R.id.input_card_number)
1829
val inputExpiry = findViewById<EditText>(R.id.input_expiry)

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

Lines changed: 85 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -90,101 +90,108 @@
9090
</LinearLayout>
9191

9292
<!-- Credit Card Section -->
93-
<TextView
94-
android:layout_width="wrap_content"
95-
android:layout_height="wrap_content"
96-
android:text="Credit Card Information"
97-
android:textStyle="bold"
98-
android:paddingBottom="8dp" />
99-
100-
<TextView
101-
android:layout_width="wrap_content"
102-
android:layout_height="wrap_content"
103-
android:text="Cardholder Name" />
104-
105-
<EditText
106-
android:id="@+id/input_cardholder_name"
93+
<LinearLayout
94+
android:id="@+id/credit_card_section"
10795
android:layout_width="match_parent"
10896
android:layout_height="wrap_content"
109-
android:hint="e.g., Jane Appleseed"
110-
android:inputType="textPersonName"
111-
android:paddingBottom="8dp" />
112-
113-
<TextView
114-
android:layout_width="wrap_content"
115-
android:layout_height="wrap_content"
116-
android:text="Card Number" />
97+
android:orientation="vertical">
11798

118-
<EditText
119-
android:id="@+id/input_card_number"
120-
android:layout_width="match_parent"
121-
android:layout_height="wrap_content"
122-
android:hint="1234 5678 9012 3456"
123-
android:inputType="number"
124-
android:maxLength="19"
125-
android:paddingBottom="8dp" />
99+
<TextView
100+
android:layout_width="wrap_content"
101+
android:layout_height="wrap_content"
102+
android:text="Credit Card Information"
103+
android:textStyle="bold"
104+
android:paddingBottom="8dp" />
126105

127-
<LinearLayout
128-
android:layout_width="match_parent"
129-
android:layout_height="wrap_content"
130-
android:orientation="horizontal"
131-
android:weightSum="2"
132-
android:paddingBottom="8dp">
106+
<TextView
107+
android:layout_width="wrap_content"
108+
android:layout_height="wrap_content"
109+
android:text="Cardholder Name" />
133110

134-
<LinearLayout
135-
android:layout_width="0dp"
111+
<EditText
112+
android:id="@+id/input_cardholder_name"
113+
android:layout_width="match_parent"
136114
android:layout_height="wrap_content"
137-
android:layout_weight="1"
138-
android:orientation="vertical"
139-
android:paddingRight="8dp">
115+
android:hint="e.g., Jane Appleseed"
116+
android:inputType="textPersonName"
117+
android:paddingBottom="8dp" />
140118

141-
<TextView
142-
android:layout_width="wrap_content"
143-
android:layout_height="wrap_content"
144-
android:text="Expiry (MM/YY)" />
119+
<TextView
120+
android:layout_width="wrap_content"
121+
android:layout_height="wrap_content"
122+
android:text="Card Number" />
145123

146-
<EditText
147-
android:id="@+id/input_expiry"
148-
android:layout_width="match_parent"
149-
android:layout_height="wrap_content"
150-
android:hint="MM/YY"
151-
android:inputType="number" />
152-
</LinearLayout>
124+
<EditText
125+
android:id="@+id/input_card_number"
126+
android:layout_width="match_parent"
127+
android:layout_height="wrap_content"
128+
android:hint="1234 5678 9012 3456"
129+
android:inputType="number"
130+
android:maxLength="19"
131+
android:paddingBottom="8dp" />
153132

154133
<LinearLayout
155-
android:layout_width="0dp"
134+
android:layout_width="match_parent"
156135
android:layout_height="wrap_content"
157-
android:layout_weight="1"
158-
android:orientation="vertical"
159-
android:paddingLeft="8dp">
136+
android:orientation="horizontal"
137+
android:weightSum="2"
138+
android:paddingBottom="8dp">
160139

161-
<TextView
162-
android:layout_width="wrap_content"
140+
<LinearLayout
141+
android:layout_width="0dp"
163142
android:layout_height="wrap_content"
164-
android:text="CVV" />
165-
166-
<EditText
167-
android:id="@+id/input_cvv"
168-
android:layout_width="match_parent"
143+
android:layout_weight="1"
144+
android:orientation="vertical"
145+
android:paddingRight="8dp">
146+
147+
<TextView
148+
android:layout_width="wrap_content"
149+
android:layout_height="wrap_content"
150+
android:text="Expiry (MM/YY)" />
151+
152+
<EditText
153+
android:id="@+id/input_expiry"
154+
android:layout_width="match_parent"
155+
android:layout_height="wrap_content"
156+
android:hint="MM/YY"
157+
android:inputType="number" />
158+
</LinearLayout>
159+
160+
<LinearLayout
161+
android:layout_width="0dp"
169162
android:layout_height="wrap_content"
170-
android:hint="123"
171-
android:inputType="numberPassword"
172-
android:maxLength="4" />
163+
android:layout_weight="1"
164+
android:orientation="vertical"
165+
android:paddingLeft="8dp">
166+
167+
<TextView
168+
android:layout_width="wrap_content"
169+
android:layout_height="wrap_content"
170+
android:text="CVV" />
171+
172+
<EditText
173+
android:id="@+id/input_cvv"
174+
android:layout_width="match_parent"
175+
android:layout_height="wrap_content"
176+
android:hint="123"
177+
android:inputType="numberPassword"
178+
android:maxLength="4" />
179+
</LinearLayout>
173180
</LinearLayout>
174-
</LinearLayout>
175181

176-
<TextView
177-
android:layout_width="wrap_content"
178-
android:layout_height="wrap_content"
179-
android:text="ZIP / Postal Code" />
182+
<TextView
183+
android:layout_width="wrap_content"
184+
android:layout_height="wrap_content"
185+
android:text="ZIP / Postal Code" />
180186

181-
<EditText
182-
android:id="@+id/input_zip_code"
183-
android:layout_width="match_parent"
184-
android:layout_height="wrap_content"
185-
android:hint="e.g., 94105"
186-
android:inputType="textPostalAddress"
187-
android:paddingBottom="8dp" />
187+
<EditText
188+
android:id="@+id/input_zip_code"
189+
android:layout_width="match_parent"
190+
android:layout_height="wrap_content"
191+
android:hint="e.g., 94105"
192+
android:inputType="textPostalAddress"
193+
android:paddingBottom="8dp" />
194+
</LinearLayout>
188195

189196
<Button
190197
android:id="@+id/button_save_card"

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

Lines changed: 30 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import android.view.Window
1616
import android.view.WindowManager.LayoutParams.TYPE_APPLICATION
1717
import android.view.WindowManager.LayoutParams.TYPE_BASE_APPLICATION
1818
import androidx.annotation.RequiresApi
19+
import android.graphics.Path
1920
import com.launchdarkly.logging.LDLogger
2021
import com.launchdarkly.observability.coroutines.DispatcherProviderHolder
2122
import com.launchdarkly.observability.replay.masking.MaskMatcher
@@ -86,7 +87,10 @@ class CaptureSource(
8687
}
8788
}
8889
}
89-
90+
91+
val timestamp = System.currentTimeMillis()
92+
val session = sessionManager.getSessionId()
93+
9094
val windowsEntries = windowInspector.appWindows()
9195
if (windowsEntries.isEmpty()) {
9296
return@withContext null
@@ -104,8 +108,6 @@ class CaptureSource(
104108
// TODO: O11Y-625 - see if holding bitmap is more efficient than base64 encoding immediately after compression
105109
// TODO: O11Y-628 - use captureQuality option for scaling and adjust this bitmap accordingly, may need to investigate power of 2 rounding for performance
106110
// Create a bitmap with the window dimensions
107-
val timestamp = System.currentTimeMillis()
108-
val session = sessionManager.getSessionId()
109111
val baseResult = captureViewResult(baseWindowEntry) ?: return@withContext null
110112

111113
// capture rest of views on top of base
@@ -169,6 +171,7 @@ class CaptureSource(
169171
private suspend fun captureViewResult(windowEntry: WindowEntry): CaptureResult? {
170172
val bitmap = captureViewBitmap(windowEntry) ?: return null
171173
val masks = maskCollector.collectMasks(windowEntry.rootView, maskMatchers)
174+
172175
return CaptureResult(windowEntry, bitmap, masks)
173176
}
174177

@@ -281,14 +284,31 @@ class CaptureSource(
281284
* @param masks areas that will be masked
282285
*/
283286
private fun drawMasks(canvas: Canvas, masks: List<Mask>) {
287+
val path = Path()
284288
masks.forEach { mask ->
285-
val integerRect = Rect(
286-
mask.rect.left.toInt(),
287-
mask.rect.top.toInt(),
288-
mask.rect.right.toInt(),
289-
mask.rect.bottom.toInt()
290-
)
291-
canvas.drawRect(integerRect, maskPaint)
289+
drawMask(mask, path, canvas, maskPaint)
290+
}
291+
}
292+
293+
private val maskIntRect = Rect()
294+
private fun drawMask(mask: Mask, path: Path, canvas: Canvas, paint: Paint) {
295+
if (mask.points != null) {
296+
val pts = mask.points
297+
298+
path.reset()
299+
path.moveTo(pts[0], pts[1])
300+
path.lineTo(pts[2], pts[3])
301+
path.lineTo(pts[4], pts[5])
302+
path.lineTo(pts[6], pts[7])
303+
path.close()
304+
305+
canvas.drawPath(path, paint)
306+
} else {
307+
maskIntRect.left = mask.rect.left.toInt()
308+
maskIntRect.top = mask.rect.top.toInt()
309+
maskIntRect.right = mask.rect.right.toInt()
310+
maskIntRect.bottom = mask.rect.bottom.toInt()
311+
canvas.drawRect(maskIntRect, paint)
292312
}
293313
}
294314
}

sdk/@launchdarkly/observability-android/lib/src/main/kotlin/com/launchdarkly/observability/replay/masking/ComposeMaskTarget.kt

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package com.launchdarkly.observability.replay.masking
22

3+
import android.os.Build
34
import android.view.View
5+
import androidx.compose.ui.geometry.Offset
46
import androidx.compose.ui.graphics.toAndroidRectF
57
import androidx.compose.ui.platform.AbstractComposeView
68
import androidx.compose.ui.semantics.SemanticsNode
@@ -73,12 +75,14 @@ data class ComposeMaskTarget(
7375
return config.contains(SemanticsProperties.Text)
7476
}
7577

76-
override fun mask(): Mask? {
78+
override fun mask(context: MaskContext): Mask? {
7779
val rect = boundsInWindow.toAndroidRectF()
7880
if (rect.width() <= 0f || rect.height() <= 0f) {
7981
return null
8082
}
81-
return Mask(boundsInWindow.toAndroidRectF(), view.id)
83+
84+
val points: FloatArray? = points(context)
85+
return Mask(boundsInWindow.toAndroidRectF(), view.id, points)
8286
}
8387

8488
override fun hasLDMask(): Boolean {
@@ -103,6 +107,41 @@ data class ComposeMaskTarget(
103107
return hasSensitiveDescription
104108
}
105109

110+
// return 4 points of polygon under transformations
111+
private fun points(context: MaskContext): FloatArray? {
112+
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
113+
return null
114+
}
115+
116+
val coordinates = rootNode.layoutInfo.coordinates
117+
if (!coordinates.isAttached) {
118+
return null
119+
}
120+
121+
val size = coordinates.size
122+
if (size.width <= 0 || size.height <= 0) {
123+
return null
124+
}
125+
126+
val t1 = coordinates.localToScreen(Offset(0f, 0f))
127+
val t2 = coordinates.localToScreen(Offset(size.width.toFloat(), 0f))
128+
val t3 = coordinates.localToScreen(Offset(size.width.toFloat(), size.height.toFloat()))
129+
val t4 = coordinates.localToScreen(Offset(0f, size.height.toFloat()))
130+
131+
val pts = floatArrayOf(
132+
t1.x, t1.y,
133+
t2.x, t2.y,
134+
t3.x, t3.y,
135+
t4.x, t4.y
136+
)
137+
138+
for (i in pts.indices step 2) {
139+
pts[i] -= context.rootX
140+
pts[i + 1] -= context.rootY
141+
}
142+
143+
return pts
144+
}
106145
}
107146

108147

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,26 @@
11
package com.launchdarkly.observability.replay.masking
2+
23
import android.graphics.RectF
3-
import androidx.compose.ui.graphics.Matrix
44

55
data class Mask(
66
val rect: RectF,
77
val viewId: Int,
8-
val points: FloatArray? = null,
9-
val matrix: Matrix? = null
10-
){
8+
val points: FloatArray? = null
9+
) {
1110
// Implemented to suppress warning
1211
override fun equals(other: Any?): Boolean {
1312
if (this === other) return true
1413
if (other !is Mask) return false
1514
return rect == other.rect &&
1615
viewId == other.viewId &&
17-
points.contentEquals(other.points) &&
18-
matrix == other.matrix
16+
points.contentEquals(other.points)
1917
}
2018

2119
// Implemented to suppress warning
2220
override fun hashCode(): Int {
2321
var result = rect.hashCode()
2422
result = 31 * result + viewId
2523
result = 31 * result + points.contentHashCode()
26-
result = 31 * result + (matrix?.hashCode() ?: 0)
2724
return result
2825
}
29-
}
26+
}

0 commit comments

Comments
 (0)