Skip to content

Commit f6edcc1

Browse files
authored
[andr][sdk] Screenshot taking capabilities (#122)
* Pass 1: Get rid of replayDependencies * Actually get rid of ReplayDependencies file and references * Pass 2: Restore internal replay diagnostic logging * Pass 3: Refactor ReplayPreviewClient * Pass 4: Flatten ReplayCaptureController * Fix DisplayManagers deps * Further simplify ReplayCaptureController * Undo unnecessary changes * fmt * fmt * Fix and refactor tests * fix main activity * Add class dependency plumbing for screenshot capturing flow * Hookup basic screenshot taking for Android U * Reduce jpeg compression quality to 0.1 (10) to match iOS * Cleanup and enrich collected metrics * Revert "Cleanup and enrich collected metrics" This reverts commit 66ec104. * Cleanup and enrich collected metrics * move the screenshot log call to rust * Simplify threading * better handling of error states * Fix calling the right rust method * Move logic to fun * First attempt at screenshots for API > 25 * Handle errors more gracefully * Re-organize code * Refactor time tracking * cleaning up threading * cleanup logging * Fix old tests * fixes * fmt * fmt * add polyform license to new files
1 parent cc2ff63 commit f6edcc1

32 files changed

+650
-150
lines changed

.github/workflows/android.yaml

-18
Original file line numberDiff line numberDiff line change
@@ -100,24 +100,6 @@ jobs:
100100
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
101101
sudo udevadm control --reload-rules
102102
sudo udevadm trigger --name-match=kvm
103-
- name: Install NDK
104-
run: |
105-
NDK_VERSION="27.2.12479018"
106-
NDK_PATH="${ANDROID_SDK_ROOT}/ndk/$NDK_VERSION"
107-
108-
if [ -d "$NDK_PATH" ]; then
109-
// The NDK is already installed.
110-
exit 0
111-
fi
112-
113-
ANDROID_HOME=$ANDROID_SDK_ROOT
114-
SDKMANAGER="${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin/sdkmanager"
115-
$SDKMANAGER --uninstall "ndk-bundle"
116-
echo "y" | $SDKMANAGER "ndk;$NDK_VERSION"
117-
ln -sfn "$NDK_PATH" "${ANDROID_SDK_ROOT}/ndk-bundle"
118-
119-
$SDKMANAGER --install "build-tools;34.0.0"
120-
echo "ANDROID_NDK_HOME=${ANDROID_HOME}/ndk/$NDK_VERSION" >> "$GITHUB_ENV"
121103
- name: Set up JDK 17
122104
uses: actions/setup-java@v4
123105
with:

examples/android/MainActivity.kt

+5-6
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,10 @@ import io.bitdrift.capture.Capture.Logger
3232
import io.bitdrift.capture.LogLevel
3333
import io.bitdrift.capture.common.ErrorHandler
3434
import io.bitdrift.capture.network.okhttp.CaptureOkHttpEventListenerFactory
35-
import io.bitdrift.capture.replay.ReplayLogger
35+
import io.bitdrift.capture.replay.IReplayLogger
36+
import io.bitdrift.capture.replay.ReplayCaptureMetrics
3637
import io.bitdrift.capture.replay.ReplayPreviewClient
3738
import io.bitdrift.capture.replay.SessionReplayConfiguration
38-
import io.bitdrift.capture.replay.internal.EncodedScreenMetrics
3939
import io.bitdrift.capture.replay.internal.FilteredCapture
4040
import okhttp3.Call
4141
import okhttp3.Callback
@@ -57,13 +57,13 @@ class MainActivity : ComponentActivity() {
5757
Log.e("HelloWorldApp", "Replay handleError: $detail $e")
5858
}
5959
},
60-
object: ReplayLogger {
60+
object: IReplayLogger {
6161
override fun onScreenCaptured(
6262
encodedScreen: ByteArray,
6363
screen: FilteredCapture,
64-
metrics: EncodedScreenMetrics
64+
metrics: ReplayCaptureMetrics
6565
) {
66-
Log.i("HelloWorldApp", "Replay onScreenCaptured: took=${metrics.captureTimeMs}ms")
66+
Log.i("HelloWorldApp", "Replay onScreenCaptured: took=${metrics.parseDuration.inWholeMilliseconds}ms")
6767
Log.i("HelloWorldApp", "Replay onScreenCaptured: screen=${screen}")
6868
Log.i("HelloWorldApp", "Replay onScreenCaptured: encodedScreen=${Base64.encodeToString(encodedScreen, 0)}")
6969
}
@@ -81,7 +81,6 @@ class MainActivity : ComponentActivity() {
8181
}
8282
},
8383
this.applicationContext,
84-
SessionReplayConfiguration(),
8584
)
8685
}
8786
private lateinit var clipboardManager: ClipboardManager

platform/jvm/capture-apollo3/build.gradle.kts

+1-1
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ dependencies {
5757
implementation(project(":capture"))
5858
implementation("com.apollographql.apollo3:apollo-runtime:3.8.3")
5959

60-
testImplementation("com.google.truth:truth:1.1.4")
60+
testImplementation("com.google.truth:truth:1.4.4")
6161
testImplementation("junit:junit:4.13.2")
6262
testImplementation("org.mockito.kotlin:mockito-kotlin:4.1.0") // last version with Java 8 support
6363
}

platform/jvm/capture/src/main/kotlin/io/bitdrift/capture/CaptureJniLibrary.kt

+15-2
Original file line numberDiff line numberDiff line change
@@ -167,20 +167,33 @@ internal object CaptureJniLibrary : IBridge {
167167
*
168168
* @param loggerId the ID of the logger to write to.
169169
* @param fields the fields to include with the log.
170-
* @param durationMs the duration of time the preparation of the session replay log took.
170+
* @param duration the duration of time the preparation of the session replay log took, in seconds.
171171
*/
172172
external fun writeSessionReplayScreenLog(
173173
loggerId: Long,
174174
fields: Map<String, FieldValue>,
175175
duration: Double,
176176
)
177177

178+
/**
179+
* Writes a session replay screenshot log.
180+
*
181+
* @param loggerId the ID of the logger to write to.
182+
* @param fields the fields to include with the log.
183+
* @param duration the duration of time the preparation of the session replay log took, in seconds.
184+
*/
185+
external fun writeSessionReplayScreenshotLog(
186+
loggerId: Long,
187+
fields: Map<String, FieldValue>,
188+
duration: Double,
189+
)
190+
178191
/**
179192
* Writes a resource utilization log.
180193
*
181194
* @param loggerId the ID of the logger to write to.
182195
* @param fields the fields to include with the log.
183-
* @param durationMs the duration of time the preparation of the resource log took.
196+
* @param duration the duration of time the preparation of the resource log took, in seconds.
184197
*/
185198
external fun writeResourceUtilizationLog(
186199
loggerId: Long,

platform/jvm/capture/src/main/kotlin/io/bitdrift/capture/LoggerImpl.kt

+8
Original file line numberDiff line numberDiff line change
@@ -380,6 +380,14 @@ internal class LoggerImpl(
380380
)
381381
}
382382

383+
internal fun logSessionReplayScreenshot(fields: Map<String, FieldValue>, duration: Duration) {
384+
CaptureJniLibrary.writeSessionReplayScreenshotLog(
385+
this.loggerId,
386+
fields,
387+
duration.toDouble(DurationUnit.SECONDS),
388+
)
389+
}
390+
383391
internal fun logResourceUtilization(fields: Map<String, String>, duration: Duration) {
384392
CaptureJniLibrary.writeResourceUtilizationLog(
385393
this.loggerId,

platform/jvm/capture/src/main/kotlin/io/bitdrift/capture/events/SessionReplayTarget.kt

+24-13
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,15 @@ import io.bitdrift.capture.common.Runtime
1818
import io.bitdrift.capture.common.RuntimeFeature
1919
import io.bitdrift.capture.providers.toFieldValue
2020
import io.bitdrift.capture.providers.toFields
21-
import io.bitdrift.capture.replay.ReplayCaptureController
22-
import io.bitdrift.capture.replay.ReplayLogger
21+
import io.bitdrift.capture.replay.IReplayLogger
22+
import io.bitdrift.capture.replay.IScreenshotLogger
23+
import io.bitdrift.capture.replay.ReplayCaptureMetrics
24+
import io.bitdrift.capture.replay.ScreenshotCaptureMetrics
2325
import io.bitdrift.capture.replay.SessionReplayConfiguration
24-
import io.bitdrift.capture.replay.internal.EncodedScreenMetrics
26+
import io.bitdrift.capture.replay.SessionReplayController
2527
import io.bitdrift.capture.replay.internal.FilteredCapture
28+
import kotlin.time.DurationUnit
29+
import kotlin.time.toDuration
2630

2731
// Controls the replay feature
2832
internal class SessionReplayTarget(
@@ -31,14 +35,15 @@ internal class SessionReplayTarget(
3135
context: Context,
3236
private val logger: LoggerImpl,
3337
mainThreadHandler: MainThreadHandler = MainThreadHandler(),
34-
) : ISessionReplayTarget, ReplayLogger {
38+
) : ISessionReplayTarget, IReplayLogger, IScreenshotLogger {
3539
// TODO(Augustyniak): Make non nullable and pass at initialization time after
3640
// `sessionReplayTarget` argument is moved from logger creation time to logger start time.
3741
// Refer to TODO in `LoggerImpl` for more details.
3842
internal var runtime: Runtime? = null
39-
private val replayCaptureController: ReplayCaptureController = ReplayCaptureController(
43+
private val sessionReplayController: SessionReplayController = SessionReplayController(
4044
errorHandler,
4145
this,
46+
this,
4247
configuration,
4348
context,
4449
mainThreadHandler,
@@ -49,16 +54,10 @@ internal class SessionReplayTarget(
4954
runtime?.isEnabled(RuntimeFeature.SESSION_REPLAY_COMPOSE)
5055
?: RuntimeFeature.SESSION_REPLAY_COMPOSE.defaultValue
5156
)
52-
replayCaptureController.captureScreen(skipReplayComposeViews)
53-
}
54-
55-
override fun captureScreenshot() {
56-
// TODO(Augustyniak): Implement this method to add support for screenshot capture on Android.
57-
// As currently implemented, the function must emit a session replay screenshot log.
58-
// Without this emission, the SDK is blocked from requesting additional screenshots.
57+
sessionReplayController.captureScreen(skipReplayComposeViews)
5958
}
6059

61-
override fun onScreenCaptured(encodedScreen: ByteArray, screen: FilteredCapture, metrics: EncodedScreenMetrics) {
60+
override fun onScreenCaptured(encodedScreen: ByteArray, screen: FilteredCapture, metrics: ReplayCaptureMetrics) {
6261
val fields = buildMap {
6362
put("screen", encodedScreen.toFieldValue())
6463
putAll(metrics.toMap().toFields())
@@ -67,6 +66,18 @@ internal class SessionReplayTarget(
6766
logger.logSessionReplayScreen(fields, metrics.parseDuration)
6867
}
6968

69+
override fun captureScreenshot() {
70+
sessionReplayController.captureScreenshot()
71+
}
72+
73+
override fun onScreenshotCaptured(compressedScreen: ByteArray, metrics: ScreenshotCaptureMetrics) {
74+
val fields = buildMap {
75+
put("screen_px", compressedScreen.toFieldValue())
76+
putAll(metrics.toMap().toFields())
77+
}
78+
logger.logSessionReplayScreenshot(fields, metrics.screenshotTimeMs.toDuration(DurationUnit.MILLISECONDS))
79+
}
80+
7081
override fun logVerboseInternal(message: String, fields: Map<String, String>?) {
7182
logger.log(LogType.INTERNALSDK, LogLevel.TRACE, fields.toFields()) { message }
7283
}

platform/jvm/capture/src/main/kotlin/io/bitdrift/capture/providers/FieldProvider.kt

+1-2
Original file line numberDiff line numberDiff line change
@@ -72,8 +72,7 @@ sealed class FieldValue {
7272
if (this === other) return true
7373
if (javaClass != other?.javaClass) return false
7474
other as BinaryField
75-
if (!byteArrayValue.contentEquals(other.byteArrayValue)) return false
76-
return true
75+
return byteArrayValue.contentEquals(other.byteArrayValue)
7776
}
7877

7978
override fun hashCode(): Int {

platform/jvm/capture/src/test/kotlin/io/bitdrift/capture/AppUpdateListenerLoggerTest.kt

+1-1
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ class AppUpdateListenerLoggerTest {
3333
private val logger: LoggerImpl = mock()
3434
private val clientAttributes: ClientAttributes = mock()
3535
private val runtime: Runtime = mock()
36-
private val executor: ExecutorService = Executors.newSingleThreadScheduledExecutor()
36+
private val executor: ExecutorService = Executors.newSingleThreadExecutor()
3737

3838
private lateinit var appUpdateLogger: AppUpdateListenerLogger
3939

platform/jvm/capture/src/test/kotlin/io/bitdrift/capture/ResourceUtilizationTargetTest.kt

+1-1
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ class ResourceUtilizationTargetTest {
3232
private val diskUsageMonitor: DiskUsageMonitor = mock()
3333
private val errorHandler: ErrorHandler = mock()
3434
private val logger: LoggerImpl = mock()
35-
private val executor: ExecutorService = Executors.newSingleThreadScheduledExecutor()
35+
private val executor: ExecutorService = Executors.newSingleThreadExecutor()
3636
private val clock: IClock = mock()
3737

3838
private val reporter = ResourceUtilizationTarget(

platform/jvm/common/src/main/kotlin/io/bitdrift/capture/common/MainThreadHandler.kt

+4-1
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,10 @@ import android.os.Looper
1414
* Helper class to run code on the main thread
1515
*/
1616
class MainThreadHandler {
17-
private val mainHandler = Handler(Looper.getMainLooper())
17+
/**
18+
* Handler for the main thread
19+
*/
20+
val mainHandler = Handler(Looper.getMainLooper())
1821

1922
/**
2023
* Schedule the given code to run on the main thread

platform/jvm/gradle-test-app/src/androidTest/java/io/bitdrift/gradletestapp/AndroidViewReplayTest.kt

+2-2
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,9 @@ import androidx.lifecycle.Lifecycle
1313
import androidx.test.internal.runner.junit4.statement.UiThreadStatement
1414
import androidx.test.platform.app.InstrumentationRegistry
1515
import com.google.common.truth.Truth.assertThat
16+
import io.bitdrift.capture.replay.ReplayCaptureMetrics
1617
import io.bitdrift.capture.replay.ReplayPreviewClient
1718
import io.bitdrift.capture.replay.ReplayType
18-
import io.bitdrift.capture.replay.internal.EncodedScreenMetrics
1919
import io.bitdrift.capture.replay.internal.FilteredCapture
2020
import io.bitdrift.capture.replay.internal.ReplayRect
2121
import org.junit.Before
@@ -32,7 +32,7 @@ class AndroidViewReplayTest {
3232
private lateinit var scenario: FragmentScenario<FirstFragment>
3333

3434
private lateinit var replayClient: ReplayPreviewClient
35-
private val replay: AtomicReference<Pair<FilteredCapture, EncodedScreenMetrics>?> = AtomicReference(null)
35+
private val replay: AtomicReference<Pair<FilteredCapture, ReplayCaptureMetrics>?> = AtomicReference(null)
3636
private lateinit var latch: CountDownLatch
3737

3838
@Before

platform/jvm/gradle-test-app/src/androidTest/java/io/bitdrift/gradletestapp/ComposeReplayTest.kt

+2-2
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,9 @@ import androidx.compose.ui.viewinterop.AndroidView
4141
import androidx.compose.ui.window.Dialog
4242
import androidx.test.platform.app.InstrumentationRegistry
4343
import com.google.common.truth.Truth.assertThat
44+
import io.bitdrift.capture.replay.ReplayCaptureMetrics
4445
import io.bitdrift.capture.replay.ReplayPreviewClient
4546
import io.bitdrift.capture.replay.ReplayType
46-
import io.bitdrift.capture.replay.internal.EncodedScreenMetrics
4747
import io.bitdrift.capture.replay.internal.FilteredCapture
4848
import io.bitdrift.capture.replay.internal.ReplayRect
4949
import org.junit.Before
@@ -68,7 +68,7 @@ class ComposeReplayTest {
6868
@get:Rule
6969
val composeRule = createComposeRule()
7070
private lateinit var replayClient: ReplayPreviewClient
71-
private val replay: AtomicReference<Pair<FilteredCapture, EncodedScreenMetrics>?> = AtomicReference(null)
71+
private val replay: AtomicReference<Pair<FilteredCapture, ReplayCaptureMetrics>?> = AtomicReference(null)
7272
private lateinit var latch: CountDownLatch
7373

7474
@Before

platform/jvm/gradle-test-app/src/androidTest/java/io/bitdrift/gradletestapp/TestUtils.kt

+6-7
Original file line numberDiff line numberDiff line change
@@ -11,18 +11,17 @@ import android.content.Context
1111
import android.util.Base64
1212
import android.util.Log
1313
import io.bitdrift.capture.common.ErrorHandler
14-
import io.bitdrift.capture.replay.ReplayLogger
14+
import io.bitdrift.capture.replay.IReplayLogger
15+
import io.bitdrift.capture.replay.ReplayCaptureMetrics
1516
import io.bitdrift.capture.replay.ReplayPreviewClient
16-
import io.bitdrift.capture.replay.SessionReplayConfiguration
17-
import io.bitdrift.capture.replay.internal.EncodedScreenMetrics
1817
import io.bitdrift.capture.replay.internal.FilteredCapture
1918
import java.util.concurrent.CountDownLatch
2019
import java.util.concurrent.atomic.AtomicReference
2120

2221
object TestUtils {
2322

2423
fun createReplayPreviewClient(
25-
replay: AtomicReference<Pair<FilteredCapture, EncodedScreenMetrics>?>,
24+
replay: AtomicReference<Pair<FilteredCapture, ReplayCaptureMetrics>?>,
2625
latch: CountDownLatch,
2726
context: Context
2827
): ReplayPreviewClient {
@@ -32,13 +31,13 @@ object TestUtils {
3231
Log.e("Replay Tests", "error: $detail $e")
3332
}
3433
},
35-
object : ReplayLogger {
34+
object : IReplayLogger {
3635
override fun onScreenCaptured(
3736
encodedScreen: ByteArray,
3837
screen: FilteredCapture,
39-
metrics: EncodedScreenMetrics
38+
metrics: ReplayCaptureMetrics
4039
) {
41-
Log.d("Replay Tests", "took ${metrics.captureTimeMs}ms")
40+
Log.d("Replay Tests", "took ${metrics.parseDuration.inWholeMilliseconds}ms")
4241
Log.d("Replay Tests", "Captured a total of ${screen.size} ReplayRect views.")
4342
Log.d("Replay Tests", screen.toString())
4443
Log.d(

platform/jvm/jni_symbols.lds

+1
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ Java_io_bitdrift_capture_CaptureJniLibrary_addLogField
1111
Java_io_bitdrift_capture_CaptureJniLibrary_removeLogField
1212
Java_io_bitdrift_capture_CaptureJniLibrary_writeLog
1313
Java_io_bitdrift_capture_CaptureJniLibrary_writeSessionReplayScreenLog
14+
Java_io_bitdrift_capture_CaptureJniLibrary_writeSessionReplayScreenshotLog
1415
Java_io_bitdrift_capture_CaptureJniLibrary_writeResourceUtilizationLog
1516
Java_io_bitdrift_capture_CaptureJniLibrary_writeSDKStartLog
1617
Java_io_bitdrift_capture_CaptureJniLibrary_shouldWriteAppUpdateLog

platform/jvm/replay/src/main/kotlin/io/bitdrift/capture/replay/ReplayLogger.kt renamed to platform/jvm/replay/src/main/kotlin/io/bitdrift/capture/replay/IInternalLogger.kt

+2-13
Original file line numberDiff line numberDiff line change
@@ -7,21 +7,10 @@
77

88
package io.bitdrift.capture.replay
99

10-
import io.bitdrift.capture.replay.internal.EncodedScreenMetrics
11-
import io.bitdrift.capture.replay.internal.FilteredCapture
12-
1310
/**
14-
* Screen captures will be received through this interface
11+
* Forwards messages as type Internal to the bitdrift Logger
1512
*/
16-
interface ReplayLogger {
17-
/**
18-
* Called when a screen capture is received
19-
* @param encodedScreen The encoded screen capture in binary format
20-
* @param screen The list of captured elements after filtering
21-
* @param metrics Metrics about the screen capture
22-
*/
23-
fun onScreenCaptured(encodedScreen: ByteArray, screen: FilteredCapture, metrics: EncodedScreenMetrics)
24-
13+
interface IInternalLogger {
2514
/**
2615
* Forwards a verbose message internally to the SDK
2716
*/
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
// capture-sdk - bitdrift's client SDK
2+
// Copyright Bitdrift, Inc. All rights reserved.
3+
//
4+
// Use of this source code is governed by a source available license that can be found in the
5+
// LICENSE file or at:
6+
// https://polyformproject.org/wp-content/uploads/2020/06/PolyForm-Shield-1.0.0.txt
7+
8+
package io.bitdrift.capture.replay
9+
10+
import io.bitdrift.capture.replay.internal.FilteredCapture
11+
12+
/**
13+
* Screen captures will be received through this interface
14+
*/
15+
interface IReplayLogger : IInternalLogger {
16+
/**
17+
* Called when a screen capture is received
18+
* @param encodedScreen The encoded screen capture in binary format
19+
* @param screen The list of captured elements after filtering
20+
* @param metrics Metrics about the screen capture
21+
*/
22+
fun onScreenCaptured(encodedScreen: ByteArray, screen: FilteredCapture, metrics: ReplayCaptureMetrics)
23+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
// capture-sdk - bitdrift's client SDK
2+
// Copyright Bitdrift, Inc. All rights reserved.
3+
//
4+
// Use of this source code is governed by a source available license that can be found in the
5+
// LICENSE file or at:
6+
// https://polyformproject.org/wp-content/uploads/2020/06/PolyForm-Shield-1.0.0.txt
7+
8+
package io.bitdrift.capture.replay
9+
10+
/**
11+
* Screenshots will be received through this interface
12+
*/
13+
interface IScreenshotLogger : IInternalLogger {
14+
15+
/**
16+
* Called when a screenshot is received
17+
* @param compressedScreen The compressed screenshot in binary format
18+
* @param metrics Metrics about the screenshot and compression process
19+
*/
20+
fun onScreenshotCaptured(compressedScreen: ByteArray, metrics: ScreenshotCaptureMetrics)
21+
}

0 commit comments

Comments
 (0)