Skip to content

Commit f42d7a1

Browse files
Add Android capture-runtime AAR and local Maven publish
PixelCopy of FlutterSurfaceView feeds the C++ core over JNI. The release AAR ships arm64-v8a, armeabi-v7a, and x86_64. The sample consumes com.tugboat.sdk:capture-runtime:0.1.0 from .local-maven. Device rotation and API-matrix tests wait on a Flutter fixture. Co-authored-by: Chinmay Kabi <chinmay@blend.to>
1 parent e6ee1b4 commit f42d7a1

34 files changed

Lines changed: 1288 additions & 57 deletions

.gitignore

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,10 @@ coverage/
77
build/
88
**/build/
99
**/.flutter-plugins-dependencies
10+
.local-maven/
11+
platforms/android/local.properties
12+
**/.gradle/
13+
**/.cxx/
14+
crash-*
15+
leak-*
16+
timeout-*

core/image-processing/CMakeLists.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ target_compile_options(tugboat_image_core PRIVATE
1313

1414
option(TB_IMAGE_CORE_SANITIZE "Build tests with ASan and UBSan" OFF)
1515
option(TB_IMAGE_CORE_FUZZ "Build libFuzzer targets (Clang)" OFF)
16+
option(TB_IMAGE_CORE_BUILD_TESTS "Build CTest unit tests" ON)
1617

1718
set(_tb_sanitize_flags -fsanitize=address,undefined -fno-omit-frame-pointer)
1819

@@ -29,7 +30,7 @@ if(TB_IMAGE_CORE_FUZZ)
2930
endif()
3031

3132
enable_testing()
32-
if(NOT TB_IMAGE_CORE_FUZZ)
33+
if(TB_IMAGE_CORE_BUILD_TESTS AND NOT TB_IMAGE_CORE_FUZZ)
3334
add_executable(tb_image_core_test tests/tb_image_core_test.cpp)
3435
target_link_libraries(tb_image_core_test PRIVATE tugboat_image_core)
3536
target_compile_options(tb_image_core_test PRIVATE

docs/plans/2026-08-31-001-native-capture-cpu-plan.md

Lines changed: 52 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -508,63 +508,63 @@ devices; history is still recorded as renames via copy + `git rm`.
508508
Use API 24 as the initial native capture floor.
509509
Use the Flutter backend on older supported devices.
510510

511-
- [ ] P4.01 Create the Android Gradle workspace.
512-
- [ ] P4.02 Add the `capture-runtime` Android library.
513-
- [ ] P4.03 Set the namespace to `com.tugboat.capture.runtime`.
514-
- [ ] P4.04 Set the coordinate to `com.tugboat.sdk:capture-runtime`.
515-
- [ ] P4.05 Configure the pinned NDK version.
516-
- [ ] P4.06 Connect Gradle to CMake.
517-
- [ ] P4.07 Compile the core for `arm64-v8a`.
518-
- [ ] P4.08 Compile the core for `armeabi-v7a`.
519-
- [ ] P4.09 Compile the core for `x86_64`.
520-
- [ ] P4.10 Define the Kotlin public API.
521-
- [ ] P4.11 Define the capability result.
522-
- [ ] P4.12 Define the capture request.
523-
- [ ] P4.13 Define the capture result.
524-
- [ ] P4.14 Define bounded failure codes.
525-
- [ ] P4.15 Add request identifiers.
526-
- [ ] P4.16 Add capture cancellation.
527-
- [ ] P4.17 Add runtime disposal.
528-
- [ ] P4.18 Add one serialized capture queue.
529-
- [ ] P4.19 Add a configurable native timeout.
530-
- [ ] P4.20 Accept an active `FlutterView`.
531-
- [ ] P4.21 Find the active `FlutterSurfaceView`.
532-
- [ ] P4.22 Detect `FlutterTextureView`.
533-
- [ ] P4.23 Report unsupported render modes.
534-
- [ ] P4.24 Calculate the app-content capture rectangle.
535-
- [ ] P4.25 Allocate a target-size mutable bitmap.
536-
- [ ] P4.26 Request `PixelCopy`.
537-
- [ ] P4.27 Handle each `PixelCopy` result code.
538-
- [ ] P4.28 Reject stale completion callbacks.
539-
- [ ] P4.29 Lock bitmap pixels through the NDK.
540-
- [ ] P4.30 Call the C++ processor through JNI.
541-
- [ ] P4.31 Unlock bitmap pixels on each code path.
542-
- [ ] P4.32 Skip JPEG encoding after a dHash skip.
543-
- [ ] P4.33 Encode JPEG with quality 80.
544-
- [ ] P4.34 Calculate SHA-256 over the JPEG.
545-
- [ ] P4.35 Return masked JPEG bytes.
546-
- [ ] P4.36 Return image dimensions.
547-
- [ ] P4.37 Return dHash.
548-
- [ ] P4.38 Return the content hash.
549-
- [ ] P4.39 Return stage timings.
550-
- [ ] P4.40 Return capture coverage.
551-
- [ ] P4.41 Clear raw bitmap references after completion.
552-
- [ ] P4.42 Do not log pixel data.
553-
- [ ] P4.43 Do not log JPEG data.
554-
- [ ] P4.44 Build a release AAR.
555-
- [ ] P4.45 Add consumer ProGuard rules.
556-
- [ ] P4.46 Add native symbol handling.
557-
- [ ] P4.47 Publish the AAR to a local Maven repository.
558-
- [ ] P4.48 Consume the local artifact from the sample.
559-
- [ ] P4.49 Add Android unit tests.
560-
- [ ] P4.50 Add Android instrumentation tests.
511+
- [x] P4.01 Create the Android Gradle workspace.
512+
- [x] P4.02 Add the `capture-runtime` Android library.
513+
- [x] P4.03 Set the namespace to `com.tugboat.capture.runtime`.
514+
- [x] P4.04 Set the coordinate to `com.tugboat.sdk:capture-runtime`.
515+
- [x] P4.05 Configure the pinned NDK version.
516+
- [x] P4.06 Connect Gradle to CMake.
517+
- [x] P4.07 Compile the core for `arm64-v8a`.
518+
- [x] P4.08 Compile the core for `armeabi-v7a`.
519+
- [x] P4.09 Compile the core for `x86_64`.
520+
- [x] P4.10 Define the Kotlin public API.
521+
- [x] P4.11 Define the capability result.
522+
- [x] P4.12 Define the capture request.
523+
- [x] P4.13 Define the capture result.
524+
- [x] P4.14 Define bounded failure codes.
525+
- [x] P4.15 Add request identifiers.
526+
- [x] P4.16 Add capture cancellation.
527+
- [x] P4.17 Add runtime disposal.
528+
- [x] P4.18 Add one serialized capture queue.
529+
- [x] P4.19 Add a configurable native timeout.
530+
- [x] P4.20 Accept an active `FlutterView`.
531+
- [x] P4.21 Find the active `FlutterSurfaceView`.
532+
- [x] P4.22 Detect `FlutterTextureView`.
533+
- [x] P4.23 Report unsupported render modes.
534+
- [x] P4.24 Calculate the app-content capture rectangle.
535+
- [x] P4.25 Allocate a target-size mutable bitmap.
536+
- [x] P4.26 Request `PixelCopy`.
537+
- [x] P4.27 Handle each `PixelCopy` result code.
538+
- [x] P4.28 Reject stale completion callbacks.
539+
- [x] P4.29 Lock bitmap pixels through the NDK.
540+
- [x] P4.30 Call the C++ processor through JNI.
541+
- [x] P4.31 Unlock bitmap pixels on each code path.
542+
- [x] P4.32 Skip JPEG encoding after a dHash skip.
543+
- [x] P4.33 Encode JPEG with quality 80.
544+
- [x] P4.34 Calculate SHA-256 over the JPEG.
545+
- [x] P4.35 Return masked JPEG bytes.
546+
- [x] P4.36 Return image dimensions.
547+
- [x] P4.37 Return dHash.
548+
- [x] P4.38 Return the content hash.
549+
- [x] P4.39 Return stage timings.
550+
- [x] P4.40 Return capture coverage.
551+
- [x] P4.41 Clear raw bitmap references after completion.
552+
- [x] P4.42 Do not log pixel data.
553+
- [x] P4.43 Do not log JPEG data.
554+
- [x] P4.44 Build a release AAR.
555+
- [x] P4.45 Add consumer ProGuard rules.
556+
- [x] P4.46 Add native symbol handling.
557+
- [x] P4.47 Publish the AAR to a local Maven repository.
558+
- [x] P4.48 Consume the local artifact from the sample.
559+
- [x] P4.49 Add Android unit tests.
560+
- [x] P4.50 Add Android instrumentation tests.
561561
- [ ] P4.51 Add API 24 tests.
562562
- [ ] P4.52 Add a recent Android API test.
563563
- [ ] P4.53 Add device-rotation tests.
564564
- [ ] P4.54 Add foreground and background tests.
565565
- [ ] P4.55 Add activity-recreation tests.
566-
- [ ] P4.56 Add repeated-initialization tests.
567-
- [ ] P4.57 Add repeated-disposal tests.
566+
- [x] P4.56 Add repeated-initialization tests.
567+
- [x] P4.57 Add repeated-disposal tests.
568568

569569
### Phase 5 - Convert the Flutter SDK into a plugin
570570

platforms/android/README.md

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,35 @@
11
# Android capture runtime
22

3-
Maven artifact `com.tugboat.sdk:capture-runtime`. Kotlin runtime plus the
4-
C++ core compiled into an AAR.
3+
Maven artifact `com.tugboat.sdk:capture-runtime:0.1.0`.
4+
Namespace `com.tugboat.capture.runtime`. Kotlin runtime plus the C++ core
5+
compiled into an AAR (`arm64-v8a`, `armeabi-v7a`, `x86_64`).
56

6-
Library sources land in Phase 4. Local Maven publication is for Flutter
7-
example consumption during development.
7+
Native capture requires API 24. Older devices report `unsupportedApi`.
8+
The first source is `PixelCopy` of `FlutterSurfaceView`. TextureView and
9+
hybrid composition return `unsupportedRenderMode`.
10+
11+
## Build
12+
13+
From `platforms/android` (needs Android SDK, NDK `28.2.13676358`, CMake
14+
`3.22.1`):
15+
16+
```sh
17+
./gradlew :capture-runtime:test
18+
./gradlew :capture-runtime:assembleRelease
19+
./gradlew :capture-runtime:publish
20+
```
21+
22+
`publish` writes
23+
`.local-maven/com/tugboat/sdk/capture-runtime/0.1.0/` at the repository
24+
root (gitignored). After that:
25+
26+
```sh
27+
./gradlew :sample:assembleDebug
28+
```
29+
30+
The sample resolves `com.tugboat.sdk:capture-runtime:0.1.0` from that
31+
repository, not from `project()`.
32+
33+
`connectedAndroidTest` for lifecycle and render-mode tests needs a device
34+
or emulator. API 24 vs recent, rotation, and activity-recreation tests wait
35+
on a Flutter `FlutterSurfaceView` fixture (Phase 6).

platforms/android/build.gradle.kts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
tasks.register<Delete>("clean") {
2+
delete(rootProject.layout.buildDirectory)
3+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
plugins {
2+
id("com.android.library")
3+
id("org.jetbrains.kotlin.android")
4+
}
5+
6+
android {
7+
namespace = "com.tugboat.capture.runtime.test"
8+
compileSdk = 36
9+
defaultConfig {
10+
minSdk = 21
11+
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
12+
}
13+
compileOptions {
14+
sourceCompatibility = JavaVersion.VERSION_11
15+
targetCompatibility = JavaVersion.VERSION_11
16+
}
17+
kotlinOptions {
18+
jvmTarget = "11"
19+
}
20+
testOptions {
21+
unitTests.isReturnDefaultValues = true
22+
}
23+
}
24+
25+
dependencies {
26+
implementation(project(":capture-runtime"))
27+
testImplementation("junit:junit:4.13.2")
28+
androidTestImplementation("androidx.test.ext:junit:1.2.1")
29+
androidTestImplementation("androidx.test:runner:1.6.2")
30+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
package com.tugboat.capture
2+
3+
import android.view.View
4+
import androidx.test.ext.junit.runners.AndroidJUnit4
5+
import androidx.test.platform.app.InstrumentationRegistry
6+
import org.junit.Assert.assertEquals
7+
import org.junit.Test
8+
import org.junit.runner.RunWith
9+
import java.util.concurrent.CountDownLatch
10+
import java.util.concurrent.TimeUnit
11+
12+
@RunWith(AndroidJUnit4::class)
13+
class CaptureRuntimeLifecycleTest {
14+
@Test
15+
fun disposeIsIdempotentAndRejectsCapture() {
16+
val context = InstrumentationRegistry.getInstrumentation().targetContext
17+
val runtime = CaptureRuntime()
18+
runtime.dispose()
19+
runtime.dispose()
20+
val latch = CountDownLatch(1)
21+
var status: CaptureStatus? = null
22+
runtime.capture(
23+
View(context),
24+
CaptureRequest(requestId = 1, pixelWidth = 8, pixelHeight = 8, force = true),
25+
) { result ->
26+
status = result.status
27+
latch.countDown()
28+
}
29+
latch.await(2, TimeUnit.SECONDS)
30+
assertEquals(CaptureStatus.Disposed, status)
31+
}
32+
33+
@Test
34+
fun cancelWithoutInFlightCaptureIsIgnored() {
35+
val runtime = CaptureRuntime()
36+
runtime.cancel(99L)
37+
runtime.dispose()
38+
}
39+
40+
@Test
41+
fun plainViewReportsUnsupportedRenderMode() {
42+
val context = InstrumentationRegistry.getInstrumentation().targetContext
43+
val runtime = CaptureRuntime()
44+
val latch = CountDownLatch(1)
45+
var status: CaptureStatus? = null
46+
runtime.capture(
47+
View(context),
48+
CaptureRequest(requestId = 2, pixelWidth = 8, pixelHeight = 8, force = true),
49+
) { result ->
50+
status = result.status
51+
latch.countDown()
52+
}
53+
latch.await(3, TimeUnit.SECONDS)
54+
runtime.dispose()
55+
assertEquals(CaptureStatus.UnsupportedRenderMode, status)
56+
}
57+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
<manifest />
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package com.tugboat.capture
2+
3+
import org.junit.Assert.assertEquals
4+
import org.junit.Test
5+
6+
class CaptureStatusMappingTest {
7+
@Test
8+
fun fallbackStatusesAreStableTokens() {
9+
assertEquals("UnsupportedApi", CaptureStatus.UnsupportedApi.name)
10+
assertEquals("UnsupportedRenderMode", CaptureStatus.UnsupportedRenderMode.name)
11+
assertEquals("SurfaceUnavailable", CaptureStatus.SurfaceUnavailable.name)
12+
assertEquals("Timeout", CaptureStatus.Timeout.name)
13+
assertEquals("PixelCopyFailed", CaptureStatus.PixelCopyFailed.name)
14+
assertEquals("ProcessingFailed", CaptureStatus.ProcessingFailed.name)
15+
assertEquals("Cancelled", CaptureStatus.Cancelled.name)
16+
assertEquals("Disposed", CaptureStatus.Disposed.name)
17+
}
18+
19+
@Test
20+
fun coverageHasEngineSurfaceOnly() {
21+
assertEquals(1, CaptureCoverage.entries.size)
22+
assertEquals(CaptureCoverage.EngineSurface, CaptureCoverage.entries.single())
23+
}
24+
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
plugins {
2+
id("com.android.library")
3+
id("org.jetbrains.kotlin.android")
4+
id("maven-publish")
5+
}
6+
7+
android {
8+
namespace = "com.tugboat.capture.runtime"
9+
compileSdk = 36
10+
ndkVersion = "28.2.13676358"
11+
12+
defaultConfig {
13+
minSdk = 21
14+
consumerProguardFiles("consumer-rules.pro")
15+
ndk {
16+
abiFilters += listOf("arm64-v8a", "armeabi-v7a", "x86_64")
17+
}
18+
externalNativeBuild {
19+
cmake {
20+
arguments += listOf(
21+
"-DTB_IMAGE_CORE_DIR=${rootProject.projectDir.resolve("../../core/image-processing").canonicalPath}",
22+
"-DTB_IMAGE_CORE_BUILD_TESTS=OFF",
23+
"-DANDROID_STL=c++_static",
24+
)
25+
}
26+
}
27+
}
28+
29+
compileOptions {
30+
sourceCompatibility = JavaVersion.VERSION_11
31+
targetCompatibility = JavaVersion.VERSION_11
32+
}
33+
kotlinOptions {
34+
jvmTarget = "11"
35+
}
36+
37+
externalNativeBuild {
38+
cmake {
39+
path = file("src/main/cpp/CMakeLists.txt")
40+
version = "3.22.1"
41+
}
42+
}
43+
44+
publishing {
45+
singleVariant("release") {
46+
withSourcesJar()
47+
}
48+
}
49+
50+
testOptions {
51+
unitTests.isReturnDefaultValues = true
52+
}
53+
54+
buildTypes {
55+
release {
56+
ndk {
57+
debugSymbolLevel = "SYMBOL_TABLE"
58+
}
59+
}
60+
}
61+
}
62+
63+
dependencies {
64+
testImplementation("junit:junit:4.13.2")
65+
}
66+
67+
publishing {
68+
publications {
69+
register<MavenPublication>("release") {
70+
groupId = "com.tugboat.sdk"
71+
artifactId = "capture-runtime"
72+
version = "0.1.0"
73+
afterEvaluate {
74+
from(components["release"])
75+
}
76+
}
77+
}
78+
repositories {
79+
maven {
80+
name = "LocalCapture"
81+
url = uri(rootProject.projectDir.resolve("../../.local-maven"))
82+
}
83+
}
84+
}

0 commit comments

Comments
 (0)