Skip to content

Commit

Permalink
[andr][sdk] Screenshot taking capabilities (#122)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
murki authored Nov 15, 2024
1 parent cc2ff63 commit f6edcc1
Show file tree
Hide file tree
Showing 32 changed files with 650 additions and 150 deletions.
18 changes: 0 additions & 18 deletions .github/workflows/android.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -100,24 +100,6 @@ jobs:
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
- name: Install NDK
run: |
NDK_VERSION="27.2.12479018"
NDK_PATH="${ANDROID_SDK_ROOT}/ndk/$NDK_VERSION"
if [ -d "$NDK_PATH" ]; then
// The NDK is already installed.
exit 0
fi
ANDROID_HOME=$ANDROID_SDK_ROOT
SDKMANAGER="${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin/sdkmanager"
$SDKMANAGER --uninstall "ndk-bundle"
echo "y" | $SDKMANAGER "ndk;$NDK_VERSION"
ln -sfn "$NDK_PATH" "${ANDROID_SDK_ROOT}/ndk-bundle"
$SDKMANAGER --install "build-tools;34.0.0"
echo "ANDROID_NDK_HOME=${ANDROID_HOME}/ndk/$NDK_VERSION" >> "$GITHUB_ENV"
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
Expand Down
11 changes: 5 additions & 6 deletions examples/android/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,10 @@ import io.bitdrift.capture.Capture.Logger
import io.bitdrift.capture.LogLevel
import io.bitdrift.capture.common.ErrorHandler
import io.bitdrift.capture.network.okhttp.CaptureOkHttpEventListenerFactory
import io.bitdrift.capture.replay.ReplayLogger
import io.bitdrift.capture.replay.IReplayLogger
import io.bitdrift.capture.replay.ReplayCaptureMetrics
import io.bitdrift.capture.replay.ReplayPreviewClient
import io.bitdrift.capture.replay.SessionReplayConfiguration
import io.bitdrift.capture.replay.internal.EncodedScreenMetrics
import io.bitdrift.capture.replay.internal.FilteredCapture
import okhttp3.Call
import okhttp3.Callback
Expand All @@ -57,13 +57,13 @@ class MainActivity : ComponentActivity() {
Log.e("HelloWorldApp", "Replay handleError: $detail $e")
}
},
object: ReplayLogger {
object: IReplayLogger {
override fun onScreenCaptured(
encodedScreen: ByteArray,
screen: FilteredCapture,
metrics: EncodedScreenMetrics
metrics: ReplayCaptureMetrics
) {
Log.i("HelloWorldApp", "Replay onScreenCaptured: took=${metrics.captureTimeMs}ms")
Log.i("HelloWorldApp", "Replay onScreenCaptured: took=${metrics.parseDuration.inWholeMilliseconds}ms")
Log.i("HelloWorldApp", "Replay onScreenCaptured: screen=${screen}")
Log.i("HelloWorldApp", "Replay onScreenCaptured: encodedScreen=${Base64.encodeToString(encodedScreen, 0)}")
}
Expand All @@ -81,7 +81,6 @@ class MainActivity : ComponentActivity() {
}
},
this.applicationContext,
SessionReplayConfiguration(),
)
}
private lateinit var clipboardManager: ClipboardManager
Expand Down
2 changes: 1 addition & 1 deletion platform/jvm/capture-apollo3/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ dependencies {
implementation(project(":capture"))
implementation("com.apollographql.apollo3:apollo-runtime:3.8.3")

testImplementation("com.google.truth:truth:1.1.4")
testImplementation("com.google.truth:truth:1.4.4")
testImplementation("junit:junit:4.13.2")
testImplementation("org.mockito.kotlin:mockito-kotlin:4.1.0") // last version with Java 8 support
}
Original file line number Diff line number Diff line change
Expand Up @@ -167,20 +167,33 @@ internal object CaptureJniLibrary : IBridge {
*
* @param loggerId the ID of the logger to write to.
* @param fields the fields to include with the log.
* @param durationMs the duration of time the preparation of the session replay log took.
* @param duration the duration of time the preparation of the session replay log took, in seconds.
*/
external fun writeSessionReplayScreenLog(
loggerId: Long,
fields: Map<String, FieldValue>,
duration: Double,
)

/**
* Writes a session replay screenshot log.
*
* @param loggerId the ID of the logger to write to.
* @param fields the fields to include with the log.
* @param duration the duration of time the preparation of the session replay log took, in seconds.
*/
external fun writeSessionReplayScreenshotLog(
loggerId: Long,
fields: Map<String, FieldValue>,
duration: Double,
)

/**
* Writes a resource utilization log.
*
* @param loggerId the ID of the logger to write to.
* @param fields the fields to include with the log.
* @param durationMs the duration of time the preparation of the resource log took.
* @param duration the duration of time the preparation of the resource log took, in seconds.
*/
external fun writeResourceUtilizationLog(
loggerId: Long,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,14 @@ internal class LoggerImpl(
)
}

internal fun logSessionReplayScreenshot(fields: Map<String, FieldValue>, duration: Duration) {
CaptureJniLibrary.writeSessionReplayScreenshotLog(
this.loggerId,
fields,
duration.toDouble(DurationUnit.SECONDS),
)
}

internal fun logResourceUtilization(fields: Map<String, String>, duration: Duration) {
CaptureJniLibrary.writeResourceUtilizationLog(
this.loggerId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,15 @@ import io.bitdrift.capture.common.Runtime
import io.bitdrift.capture.common.RuntimeFeature
import io.bitdrift.capture.providers.toFieldValue
import io.bitdrift.capture.providers.toFields
import io.bitdrift.capture.replay.ReplayCaptureController
import io.bitdrift.capture.replay.ReplayLogger
import io.bitdrift.capture.replay.IReplayLogger
import io.bitdrift.capture.replay.IScreenshotLogger
import io.bitdrift.capture.replay.ReplayCaptureMetrics
import io.bitdrift.capture.replay.ScreenshotCaptureMetrics
import io.bitdrift.capture.replay.SessionReplayConfiguration
import io.bitdrift.capture.replay.internal.EncodedScreenMetrics
import io.bitdrift.capture.replay.SessionReplayController
import io.bitdrift.capture.replay.internal.FilteredCapture
import kotlin.time.DurationUnit
import kotlin.time.toDuration

// Controls the replay feature
internal class SessionReplayTarget(
Expand All @@ -31,14 +35,15 @@ internal class SessionReplayTarget(
context: Context,
private val logger: LoggerImpl,
mainThreadHandler: MainThreadHandler = MainThreadHandler(),
) : ISessionReplayTarget, ReplayLogger {
) : ISessionReplayTarget, IReplayLogger, IScreenshotLogger {
// TODO(Augustyniak): Make non nullable and pass at initialization time after
// `sessionReplayTarget` argument is moved from logger creation time to logger start time.
// Refer to TODO in `LoggerImpl` for more details.
internal var runtime: Runtime? = null
private val replayCaptureController: ReplayCaptureController = ReplayCaptureController(
private val sessionReplayController: SessionReplayController = SessionReplayController(
errorHandler,
this,
this,
configuration,
context,
mainThreadHandler,
Expand All @@ -49,16 +54,10 @@ internal class SessionReplayTarget(
runtime?.isEnabled(RuntimeFeature.SESSION_REPLAY_COMPOSE)
?: RuntimeFeature.SESSION_REPLAY_COMPOSE.defaultValue
)
replayCaptureController.captureScreen(skipReplayComposeViews)
}

override fun captureScreenshot() {
// TODO(Augustyniak): Implement this method to add support for screenshot capture on Android.
// As currently implemented, the function must emit a session replay screenshot log.
// Without this emission, the SDK is blocked from requesting additional screenshots.
sessionReplayController.captureScreen(skipReplayComposeViews)
}

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

override fun captureScreenshot() {
sessionReplayController.captureScreenshot()
}

override fun onScreenshotCaptured(compressedScreen: ByteArray, metrics: ScreenshotCaptureMetrics) {
val fields = buildMap {
put("screen_px", compressedScreen.toFieldValue())
putAll(metrics.toMap().toFields())
}
logger.logSessionReplayScreenshot(fields, metrics.screenshotTimeMs.toDuration(DurationUnit.MILLISECONDS))
}

override fun logVerboseInternal(message: String, fields: Map<String, String>?) {
logger.log(LogType.INTERNALSDK, LogLevel.TRACE, fields.toFields()) { message }
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,7 @@ sealed class FieldValue {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as BinaryField
if (!byteArrayValue.contentEquals(other.byteArrayValue)) return false
return true
return byteArrayValue.contentEquals(other.byteArrayValue)
}

override fun hashCode(): Int {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ class AppUpdateListenerLoggerTest {
private val logger: LoggerImpl = mock()
private val clientAttributes: ClientAttributes = mock()
private val runtime: Runtime = mock()
private val executor: ExecutorService = Executors.newSingleThreadScheduledExecutor()
private val executor: ExecutorService = Executors.newSingleThreadExecutor()

private lateinit var appUpdateLogger: AppUpdateListenerLogger

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ class ResourceUtilizationTargetTest {
private val diskUsageMonitor: DiskUsageMonitor = mock()
private val errorHandler: ErrorHandler = mock()
private val logger: LoggerImpl = mock()
private val executor: ExecutorService = Executors.newSingleThreadScheduledExecutor()
private val executor: ExecutorService = Executors.newSingleThreadExecutor()
private val clock: IClock = mock()

private val reporter = ResourceUtilizationTarget(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@ import android.os.Looper
* Helper class to run code on the main thread
*/
class MainThreadHandler {
private val mainHandler = Handler(Looper.getMainLooper())
/**
* Handler for the main thread
*/
val mainHandler = Handler(Looper.getMainLooper())

/**
* Schedule the given code to run on the main thread
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@ import androidx.lifecycle.Lifecycle
import androidx.test.internal.runner.junit4.statement.UiThreadStatement
import androidx.test.platform.app.InstrumentationRegistry
import com.google.common.truth.Truth.assertThat
import io.bitdrift.capture.replay.ReplayCaptureMetrics
import io.bitdrift.capture.replay.ReplayPreviewClient
import io.bitdrift.capture.replay.ReplayType
import io.bitdrift.capture.replay.internal.EncodedScreenMetrics
import io.bitdrift.capture.replay.internal.FilteredCapture
import io.bitdrift.capture.replay.internal.ReplayRect
import org.junit.Before
Expand All @@ -32,7 +32,7 @@ class AndroidViewReplayTest {
private lateinit var scenario: FragmentScenario<FirstFragment>

private lateinit var replayClient: ReplayPreviewClient
private val replay: AtomicReference<Pair<FilteredCapture, EncodedScreenMetrics>?> = AtomicReference(null)
private val replay: AtomicReference<Pair<FilteredCapture, ReplayCaptureMetrics>?> = AtomicReference(null)
private lateinit var latch: CountDownLatch

@Before
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,9 @@ import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.window.Dialog
import androidx.test.platform.app.InstrumentationRegistry
import com.google.common.truth.Truth.assertThat
import io.bitdrift.capture.replay.ReplayCaptureMetrics
import io.bitdrift.capture.replay.ReplayPreviewClient
import io.bitdrift.capture.replay.ReplayType
import io.bitdrift.capture.replay.internal.EncodedScreenMetrics
import io.bitdrift.capture.replay.internal.FilteredCapture
import io.bitdrift.capture.replay.internal.ReplayRect
import org.junit.Before
Expand All @@ -68,7 +68,7 @@ class ComposeReplayTest {
@get:Rule
val composeRule = createComposeRule()
private lateinit var replayClient: ReplayPreviewClient
private val replay: AtomicReference<Pair<FilteredCapture, EncodedScreenMetrics>?> = AtomicReference(null)
private val replay: AtomicReference<Pair<FilteredCapture, ReplayCaptureMetrics>?> = AtomicReference(null)
private lateinit var latch: CountDownLatch

@Before
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,18 +11,17 @@ import android.content.Context
import android.util.Base64
import android.util.Log
import io.bitdrift.capture.common.ErrorHandler
import io.bitdrift.capture.replay.ReplayLogger
import io.bitdrift.capture.replay.IReplayLogger
import io.bitdrift.capture.replay.ReplayCaptureMetrics
import io.bitdrift.capture.replay.ReplayPreviewClient
import io.bitdrift.capture.replay.SessionReplayConfiguration
import io.bitdrift.capture.replay.internal.EncodedScreenMetrics
import io.bitdrift.capture.replay.internal.FilteredCapture
import java.util.concurrent.CountDownLatch
import java.util.concurrent.atomic.AtomicReference

object TestUtils {

fun createReplayPreviewClient(
replay: AtomicReference<Pair<FilteredCapture, EncodedScreenMetrics>?>,
replay: AtomicReference<Pair<FilteredCapture, ReplayCaptureMetrics>?>,
latch: CountDownLatch,
context: Context
): ReplayPreviewClient {
Expand All @@ -32,13 +31,13 @@ object TestUtils {
Log.e("Replay Tests", "error: $detail $e")
}
},
object : ReplayLogger {
object : IReplayLogger {
override fun onScreenCaptured(
encodedScreen: ByteArray,
screen: FilteredCapture,
metrics: EncodedScreenMetrics
metrics: ReplayCaptureMetrics
) {
Log.d("Replay Tests", "took ${metrics.captureTimeMs}ms")
Log.d("Replay Tests", "took ${metrics.parseDuration.inWholeMilliseconds}ms")
Log.d("Replay Tests", "Captured a total of ${screen.size} ReplayRect views.")
Log.d("Replay Tests", screen.toString())
Log.d(
Expand Down
1 change: 1 addition & 0 deletions platform/jvm/jni_symbols.lds
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ Java_io_bitdrift_capture_CaptureJniLibrary_addLogField
Java_io_bitdrift_capture_CaptureJniLibrary_removeLogField
Java_io_bitdrift_capture_CaptureJniLibrary_writeLog
Java_io_bitdrift_capture_CaptureJniLibrary_writeSessionReplayScreenLog
Java_io_bitdrift_capture_CaptureJniLibrary_writeSessionReplayScreenshotLog
Java_io_bitdrift_capture_CaptureJniLibrary_writeResourceUtilizationLog
Java_io_bitdrift_capture_CaptureJniLibrary_writeSDKStartLog
Java_io_bitdrift_capture_CaptureJniLibrary_shouldWriteAppUpdateLog
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,10 @@

package io.bitdrift.capture.replay

import io.bitdrift.capture.replay.internal.EncodedScreenMetrics
import io.bitdrift.capture.replay.internal.FilteredCapture

/**
* Screen captures will be received through this interface
* Forwards messages as type Internal to the bitdrift Logger
*/
interface ReplayLogger {
/**
* Called when a screen capture is received
* @param encodedScreen The encoded screen capture in binary format
* @param screen The list of captured elements after filtering
* @param metrics Metrics about the screen capture
*/
fun onScreenCaptured(encodedScreen: ByteArray, screen: FilteredCapture, metrics: EncodedScreenMetrics)

interface IInternalLogger {
/**
* Forwards a verbose message internally to the SDK
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// capture-sdk - bitdrift's client SDK
// Copyright Bitdrift, Inc. All rights reserved.
//
// Use of this source code is governed by a source available license that can be found in the
// LICENSE file or at:
// https://polyformproject.org/wp-content/uploads/2020/06/PolyForm-Shield-1.0.0.txt

package io.bitdrift.capture.replay

import io.bitdrift.capture.replay.internal.FilteredCapture

/**
* Screen captures will be received through this interface
*/
interface IReplayLogger : IInternalLogger {
/**
* Called when a screen capture is received
* @param encodedScreen The encoded screen capture in binary format
* @param screen The list of captured elements after filtering
* @param metrics Metrics about the screen capture
*/
fun onScreenCaptured(encodedScreen: ByteArray, screen: FilteredCapture, metrics: ReplayCaptureMetrics)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// capture-sdk - bitdrift's client SDK
// Copyright Bitdrift, Inc. All rights reserved.
//
// Use of this source code is governed by a source available license that can be found in the
// LICENSE file or at:
// https://polyformproject.org/wp-content/uploads/2020/06/PolyForm-Shield-1.0.0.txt

package io.bitdrift.capture.replay

/**
* Screenshots will be received through this interface
*/
interface IScreenshotLogger : IInternalLogger {

/**
* Called when a screenshot is received
* @param compressedScreen The compressed screenshot in binary format
* @param metrics Metrics about the screenshot and compression process
*/
fun onScreenshotCaptured(compressedScreen: ByteArray, metrics: ScreenshotCaptureMetrics)
}
Loading

0 comments on commit f6edcc1

Please sign in to comment.