@dvai-bridge/android-mediapipe-core 4.0.0 → 4.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,134 +1,211 @@
1
- buildscript {
2
- ext {
3
- kotlinVersion = '2.3.21'
4
- }
5
- repositories {
6
- google()
7
- mavenCentral()
8
- }
9
- dependencies {
10
- classpath 'com.android.tools.build:gradle:9.2.0'
11
- classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion"
12
- }
13
- }
14
-
15
- ext {
16
- junitVersion = '4.13.2'
17
- androidxAppCompatVersion = '1.7.1'
18
- coroutinesVersion = '1.10.2'
19
- // Pinned within Ktor 2.x — Ktor 3 has breaking API changes around server
20
- // configuration that would require non-trivial refactors. Stay on the
21
- // latest 2.x patch (2.3.13) until a deliberate Ktor 3 migration.
22
- ktorVersion = '2.3.13'
23
- // LiteRT-LM — replaces the deprecated `com.google.mediapipe:tasks-genai`
24
- // (Phase 3B, Tasks 18-19). `litertlm-android` bundles the full runtime;
25
- // no separate `tasks-core` companion is required.
26
- // Hosted on Google Maven (google() repo, already declared in allprojects).
27
- litertLmVersion = '0.10.2'
28
- }
29
-
30
- allprojects {
31
- repositories {
32
- google()
33
- mavenCentral()
34
- // Phase 3D: shared-core lives here in dev (after `publishToMavenLocal`).
35
- // Production publishes resolve from GitHub Packages.
36
- mavenLocal()
37
- }
38
- }
39
-
40
- // AGP 9+ ships built-in Kotlin support; the standalone 'kotlin-android'
41
- // plugin is no longer needed (and is rejected by the build).
42
- // See https://kotl.in/gradle/agp-built-in-kotlin
43
- apply plugin: 'com.android.library'
44
- apply plugin: 'maven-publish'
45
-
46
- android {
47
- namespace 'co.deepvoiceai.bridge.mediapipe.core'
48
- // Phase 2 Task 3 (examples) library fix: see shared-core/android/build.gradle
49
- // for the rationale — `-PcompileSdkOverride=35` lets Windows hosts sidestep
50
- // an AGP 9.2.0 parseLocalResources bug against android-36's public-final.xml.
51
- compileSdk(project.findProperty('compileSdkOverride')?.toInteger() ?: 36)
52
-
53
- defaultConfig {
54
- minSdk 24
55
- targetSdk(project.findProperty('compileSdkOverride')?.toInteger() ?: 36)
56
- testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
57
- }
58
-
59
- compileOptions {
60
- sourceCompatibility JavaVersion.VERSION_17
61
- targetCompatibility JavaVersion.VERSION_17
62
- }
63
- testOptions {
64
- unitTests.includeAndroidResources = true
65
- }
66
-
67
- publishing {
68
- singleVariant('release') {
69
- withSourcesJar()
70
- }
71
- }
72
- }
73
-
74
- // AGP 9 / Kotlin 2.x: the legacy android.kotlinOptions block is gone. The
75
- // replacement is the standalone `kotlin { compilerOptions { ... } }` extension
76
- // (provided by AGP's built-in Kotlin integration).
77
- kotlin {
78
- compilerOptions {
79
- jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
80
- }
81
- }
82
-
83
- dependencies {
84
- // Phase 3D: shared HTTP server + handler dispatch types live here.
85
- // `api` (not `implementation`) so consumer projects see them transitively.
86
- api "co.deepvoiceai:android-shared-core:$dvaiBridgeVersion"
87
-
88
- implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
89
- implementation "io.ktor:ktor-server-core:$ktorVersion"
90
- implementation "io.ktor:ktor-server-cio:$ktorVersion"
91
- implementation "io.ktor:ktor-server-cors:$ktorVersion"
92
- implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion"
93
- implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.11.0'
94
- implementation 'com.squareup.okhttp3:okhttp:5.3.2'
95
-
96
- // LiteRT-LM: non-deprecated successor to tasks-genai (Phase 3B Task 18-19).
97
- // Bundles the full LLM runtime including vision support via EngineConfig.visionBackend.
98
- // ~100MB native libs per ABI — first build will be slow.
99
- implementation "com.google.ai.edge.litertlm:litertlm-android:$litertLmVersion"
100
-
101
- testImplementation "junit:junit:$junitVersion"
102
- testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion"
103
- testImplementation 'org.robolectric:robolectric:4.16.1'
104
- testImplementation "io.ktor:ktor-server-test-host:$ktorVersion"
105
- testImplementation 'com.squareup.okhttp3:mockwebserver:5.3.2'
106
-
107
- androidTestImplementation 'androidx.test:runner:1.7.0'
108
- androidTestImplementation 'androidx.test.ext:junit:1.3.0'
109
- }
110
-
111
- // Phase 3D Task 4: copy-paste publishing block (see llama-core for the
112
- // rationale on why each Gradle root has its own copy).
113
- afterEvaluate {
114
- publishing {
115
- publications {
116
- release(MavenPublication) {
117
- groupId = 'co.deepvoiceai'
118
- artifactId = 'android-mediapipe-core'
119
- version = (project.findProperty('dvaiBridgeVersion') ?: '4.0.0').toString()
120
- from components.release
121
- }
122
- }
123
- repositories {
124
- maven {
125
- name = 'GitHubPackages'
126
- url = uri('https://maven.pkg.github.com/dvai-global/dvai-bridge')
127
- credentials {
128
- username = project.findProperty('gpr.user') ?: System.getenv('GITHUB_ACTOR')
129
- password = project.findProperty('gpr.key') ?: System.getenv('GITHUB_TOKEN')
130
- }
131
- }
132
- }
133
- }
134
- }
1
+ buildscript {
2
+ ext {
3
+ kotlinVersion = '2.3.21'
4
+ }
5
+ repositories {
6
+ google()
7
+ mavenCentral()
8
+ gradlePluginPortal()
9
+ }
10
+ dependencies {
11
+ classpath 'com.android.tools.build:gradle:9.2.0'
12
+ classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion"
13
+ // Maven Central publishing — wraps maven-publish + signing + the
14
+ // Central Portal upload API into one config block. See the
15
+ // mavenPublishing { ... } block below.
16
+ classpath 'com.vanniktech:gradle-maven-publish-plugin:0.36.0'
17
+ }
18
+ }
19
+
20
+ ext {
21
+ junitVersion = '4.13.2'
22
+ androidxAppCompatVersion = '1.7.1'
23
+ coroutinesVersion = '1.10.2'
24
+ // Pinned within Ktor 2.x Ktor 3 has breaking API changes around server
25
+ // configuration that would require non-trivial refactors. Stay on the
26
+ // latest 2.x patch (2.3.13) until a deliberate Ktor 3 migration.
27
+ ktorVersion = '2.3.13'
28
+ // LiteRT-LM — replaces the deprecated `com.google.mediapipe:tasks-genai`
29
+ // (Phase 3B, Tasks 18-19). `litertlm-android` bundles the full runtime;
30
+ // no separate `tasks-core` companion is required.
31
+ // Hosted on Google Maven (google() repo, already declared in allprojects).
32
+ litertLmVersion = '0.10.2'
33
+ }
34
+
35
+ allprojects {
36
+ repositories {
37
+ google()
38
+ mavenCentral()
39
+ // Phase 3D: shared-core lives here in dev (after `publishToMavenLocal`).
40
+ // Production publishes resolve from GitHub Packages.
41
+ mavenLocal()
42
+ }
43
+ }
44
+
45
+ // AGP 9+ ships built-in Kotlin support; the standalone 'kotlin-android'
46
+ // plugin is no longer needed (and is rejected by the build).
47
+ // See https://kotl.in/gradle/agp-built-in-kotlin
48
+ apply plugin: 'com.android.library'
49
+ apply plugin: 'com.vanniktech.maven.publish'
50
+
51
+ android {
52
+ namespace 'co.deepvoiceai.bridge.mediapipe.core'
53
+ // Phase 2 Task 3 (examples) library fix: see shared-core/android/build.gradle
54
+ // for the rationale — `-PcompileSdkOverride=35` lets Windows hosts sidestep
55
+ // an AGP 9.2.0 parseLocalResources bug against android-36's public-final.xml.
56
+ compileSdk(project.findProperty('compileSdkOverride')?.toInteger() ?: 36)
57
+
58
+ defaultConfig {
59
+ minSdk 24
60
+ targetSdk(project.findProperty('compileSdkOverride')?.toInteger() ?: 36)
61
+ testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
62
+ }
63
+
64
+ compileOptions {
65
+ sourceCompatibility JavaVersion.VERSION_17
66
+ targetCompatibility JavaVersion.VERSION_17
67
+ }
68
+ testOptions {
69
+ unitTests.includeAndroidResources = true
70
+ }
71
+
72
+ // NB: don't add `publishing { singleVariant('release') { withSourcesJar() } }`
73
+ // here. The vanniktech.maven.publish plugin auto-configures Android
74
+ // single-variant publication + sources + javadoc jars on its own; a
75
+ // manual singleVariant block collides with "Using singleVariant publishing
76
+ // DSL multiple times to publish variant 'release' to component 'release'
77
+ // is not allowed."
78
+ }
79
+
80
+ // AGP 9 / Kotlin 2.x: the legacy android.kotlinOptions block is gone. The
81
+ // replacement is the standalone `kotlin { compilerOptions { ... } }` extension
82
+ // (provided by AGP's built-in Kotlin integration).
83
+ kotlin {
84
+ compilerOptions {
85
+ jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
86
+ }
87
+ }
88
+
89
+ dependencies {
90
+ // Phase 3D: shared HTTP server + handler dispatch types live here.
91
+ // `api` (not `implementation`) so consumer projects see them transitively.
92
+ api "co.deepvoiceai:android-shared-core:$dvaiBridgeVersion"
93
+
94
+ implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
95
+ implementation "io.ktor:ktor-server-core:$ktorVersion"
96
+ implementation "io.ktor:ktor-server-cio:$ktorVersion"
97
+ implementation "io.ktor:ktor-server-cors:$ktorVersion"
98
+ implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion"
99
+ implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.11.0'
100
+ implementation 'com.squareup.okhttp3:okhttp:5.3.2'
101
+
102
+ // LiteRT-LM: non-deprecated successor to tasks-genai (Phase 3B Task 18-19).
103
+ // Bundles the full LLM runtime including vision support via EngineConfig.visionBackend.
104
+ // ~100MB native libs per ABI — first build will be slow.
105
+ implementation "com.google.ai.edge.litertlm:litertlm-android:$litertLmVersion"
106
+
107
+ testImplementation "junit:junit:$junitVersion"
108
+ testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion"
109
+ testImplementation 'org.robolectric:robolectric:4.16.1'
110
+ testImplementation "io.ktor:ktor-server-test-host:$ktorVersion"
111
+ testImplementation 'com.squareup.okhttp3:mockwebserver:5.3.2'
112
+
113
+ androidTestImplementation 'androidx.test:runner:1.7.0'
114
+ androidTestImplementation 'androidx.test.ext:junit:1.3.0'
115
+ }
116
+
117
+ // Maven Central publishing via Sonatype Central Portal. See the
118
+ // PUBLISHING-MAVEN-CENTRAL.md note at the repo root (gitignored — your
119
+ // operator playbook) for credentials + the per-release sequence.
120
+ //
121
+ // Coordinates and POM metadata live here; the actual publish is driven
122
+ // by `./gradlew publishAndReleaseToMavenCentral` (auto-release on
123
+ // validation passing) or `publishToMavenLocal` for a dry-run that
124
+ // writes the artifacts + signatures to ~/.m2/repository/.
125
+ mavenPublishing {
126
+ // AGP 9.2.0 bundles a Dokka build whose ASM can't read Kotlin 2.3.21's
127
+ // sealed-class bytecode (`PermittedSubclasses requires ASM9`) when it
128
+ // tries to resolve the api jar of android-shared-core during the
129
+ // `javaDocReleaseGeneration` task. We bypass AGP's Dokka javadoc and
130
+ // ship an empty javadoc.jar instead — Sonatype Central Portal validates
131
+ // the artifact's presence, not that it is populated. Re-enable once
132
+ // AGP upgrades its bundled ASM.
133
+ configure(new com.vanniktech.maven.publish.AndroidSingleVariantLibrary(
134
+ /* variant = */ 'release',
135
+ /* sourcesJar = */ true,
136
+ /* publishJavadoc = */ false,
137
+ ))
138
+
139
+ publishToMavenCentral(true)
140
+ // Sign only when the PGP key is configured — see shared-core for rationale.
141
+ if (project.findProperty('signingInMemoryKey') ||
142
+ System.getenv('ORG_GRADLE_PROJECT_signingInMemoryKey')) {
143
+ signAllPublications()
144
+ }
145
+
146
+ coordinates(
147
+ 'co.deepvoiceai',
148
+ 'android-mediapipe-core',
149
+ (project.findProperty('dvaiBridgeVersion') ?: '4.0.2').toString(),
150
+ )
151
+
152
+ pom {
153
+ name = 'DVAI Bridge Android — MediaPipe / LiteRT-LM core'
154
+ description = 'LiteRT-LM (Google AI Edge) backend for DVAI Bridge on Android. ' +
155
+ 'Replaces the deprecated MediaPipe tasks-genai runtime; bundles vision ' +
156
+ 'support via EngineConfig.visionBackend. Depends on android-shared-core ' +
157
+ 'for the HTTP/handler layer.'
158
+ inceptionYear = '2026'
159
+ url = 'https://github.com/dvai-global/dvai-bridge'
160
+
161
+ licenses {
162
+ license {
163
+ name = 'DVAI Bridge Community Licence v1.0'
164
+ url = 'https://bridge.deepvoiceai.co/licensing'
165
+ distribution = 'repo'
166
+ }
167
+ }
168
+ developers {
169
+ developer {
170
+ id = 'deepvoiceai'
171
+ name = 'Deep Voice AI Limited'
172
+ email = 'info@deepvoiceai.co'
173
+ organization = 'Deep Voice AI Limited'
174
+ organizationUrl = 'https://deepvoiceai.co'
175
+ }
176
+ }
177
+ scm {
178
+ url = 'https://github.com/dvai-global/dvai-bridge'
179
+ connection = 'scm:git:git://github.com/dvai-global/dvai-bridge.git'
180
+ developerConnection = 'scm:git:ssh://github.com:dvai-global/dvai-bridge.git'
181
+ }
182
+ }
183
+ }
184
+
185
+ // Switch Gradle signing from BouncyCastle-based in-memory mode to
186
+ // shell-out-to-gpg mode. The in-memory path can't parse GnuPG 2.4+
187
+ // keys that carry pref-aead-algos (subpkt 34) in their user-ID
188
+ // self-signature — `gpg --list-packets` of our v4.0.1 signing key
189
+ // confirms that subpacket is present. useGpgCmd() shells out to the
190
+ // local gpg binary (imported into the runner's keyring by the
191
+ // publish.yml workflow), which can parse it. Passphrase is supplied
192
+ // via ~/.gradle/gradle.properties (signing.gnupg.passphrase).
193
+ if (project.findProperty('signing.gnupg.keyName') ||
194
+ System.getenv('ORG_GRADLE_PROJECT_USE_GPG_CMD_SIGNING') == 'true') {
195
+ signing {
196
+ useGpgCmd()
197
+ }
198
+ }
199
+
200
+ // Empty javadoc.jar to satisfy Sonatype Central Portal validation while
201
+ // AGP 9.2.0's bundled Dokka is incompatible with our sealed-class bytecode.
202
+ // See the `configure(AndroidSingleVariantLibrary ...)` comment above.
203
+ tasks.register('emptyJavadocJar', Jar) {
204
+ archiveClassifier.set('javadoc')
205
+ }
206
+
207
+ afterEvaluate {
208
+ publishing.publications.withType(MavenPublication).configureEach { pub ->
209
+ pub.artifact(tasks.named('emptyJavadocJar'))
210
+ }
211
+ }
@@ -2,4 +2,4 @@ android.useAndroidX=true
2
2
  kotlin.code.style=official
3
3
  android.nonTransitiveRClass=true
4
4
  org.gradle.jvmargs=-Xmx4096m
5
- dvaiBridgeVersion=4.0.0
5
+ dvaiBridgeVersion=4.0.2
@@ -1 +1 @@
1
- rootProject.name = 'dvai-bridge-android-mediapipe-core'
1
+ rootProject.name = 'dvai-bridge-android-mediapipe-core'
@@ -1,14 +1,14 @@
1
- <?xml version="1.0" encoding="utf-8"?>
2
- <manifest xmlns:android="http://schemas.android.com/apk/res/android">
3
- <application>
4
- <!-- LiteRT-LM GPU backend optional native libs (Phase 3B Task 18-19).
5
- android:required="false" lets devices without these still run on
6
- the CPU backend rather than failing installation outright. -->
7
- <uses-native-library
8
- android:name="libvndksupport.so"
9
- android:required="false" />
10
- <uses-native-library
11
- android:name="libOpenCL.so"
12
- android:required="false" />
13
- </application>
14
- </manifest>
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android">
3
+ <application>
4
+ <!-- LiteRT-LM GPU backend optional native libs (Phase 3B Task 18-19).
5
+ android:required="false" lets devices without these still run on
6
+ the CPU backend rather than failing installation outright. -->
7
+ <uses-native-library
8
+ android:name="libvndksupport.so"
9
+ android:required="false" />
10
+ <uses-native-library
11
+ android:name="libOpenCL.so"
12
+ android:required="false" />
13
+ </application>
14
+ </manifest>
@@ -1,115 +1,115 @@
1
- package co.deepvoiceai.bridge.mediapipe.core
2
-
3
- import okhttp3.OkHttpClient
4
- import okhttp3.Request
5
- import java.io.File
6
- import java.net.URI
7
- import java.net.URLDecoder
8
- import java.util.Base64
9
- import java.util.concurrent.TimeUnit
10
-
11
- /**
12
- * Errors thrown by [ImageDecoder.resolve] when the input URL string can't
13
- * be turned into image bytes.
14
- */
15
- sealed class ImageSourceError(msg: String) : Exception(msg) {
16
- class MalformedDataURL(url: String) : ImageSourceError("malformed data URL: $url")
17
- class MalformedURL(url: String) : ImageSourceError("malformed URL: $url")
18
- class InvalidScheme(scheme: String) : ImageSourceError("unsupported URL scheme: $scheme")
19
- class HttpError(val status: Int) : ImageSourceError("HTTP $status")
20
- class Base64DecodeFailed : ImageSourceError("base64 decode failed")
21
- }
22
-
23
- /**
24
- * Resolves any of the three image URL schemes accepted by the DVAI bridge
25
- * (`data:`, `https:`/`http:`, `file:`) into the raw encoded image bytes
26
- * (PNG/JPEG/etc.). Format decoding into pixel buffers is performed downstream
27
- * by Android's `BitmapFactory` (then wrapped in `MPImage` via
28
- * `BitmapImageBuilder`) — this layer just materializes the encoded bytes.
29
- *
30
- * Byte-identical to capacitor-llama's `ImageDecoder` modulo package
31
- * declaration. If you fix a bug here, mirror it there (and vice versa).
32
- *
33
- * Blocking: HTTP fetches use OkHttp synchronously. Call from a background
34
- * thread or coroutine on `Dispatchers.IO` (the plugin layer already does).
35
- */
36
- object ImageDecoder {
37
- private val httpClient: OkHttpClient by lazy {
38
- // `callTimeout` caps the entire HTTP exchange (DNS + connect + write
39
- // + read + redirect) at 30s so worst-case latency matches iOS, where
40
- // `URLRequest.timeoutInterval = 30` covers the whole request.
41
- OkHttpClient.Builder()
42
- .callTimeout(30, TimeUnit.SECONDS)
43
- .build()
44
- }
45
-
46
- /**
47
- * Resolve any supported URL scheme into raw image bytes.
48
- *
49
- * - `data:` URLs are parsed for an optional `;base64` token and decoded
50
- * accordingly (URL-encoded payloads are also supported).
51
- * - `https:` / `http:` URLs are fetched via OkHttp with a 30s timeout;
52
- * non-2xx responses throw [ImageSourceError.HttpError].
53
- * - `file:` URLs are read off disk via [File.readBytes].
54
- * - Any other scheme throws [ImageSourceError.InvalidScheme].
55
- * - URLs that fail to parse, lack a scheme, or have a missing/empty
56
- * `file:` path throw [ImageSourceError.MalformedURL].
57
- */
58
- fun resolve(url: String): ByteArray {
59
- if (url.startsWith("data:")) {
60
- return resolveDataURL(url)
61
- }
62
- return resolveWithClient(url, httpClient)
63
- }
64
-
65
- /**
66
- * Test seam: same contract as [resolve] but lets the caller inject an
67
- * [OkHttpClient] (e.g. one pointed at MockWebServer). Data URLs still
68
- * short-circuit before the client is consulted.
69
- */
70
- internal fun resolveWithClient(url: String, client: OkHttpClient): ByteArray {
71
- if (url.startsWith("data:")) {
72
- return resolveDataURL(url)
73
- }
74
- val parsed = try {
75
- URI(url)
76
- } catch (_: Exception) {
77
- throw ImageSourceError.MalformedURL(url)
78
- }
79
- val scheme = parsed.scheme?.lowercase() ?: throw ImageSourceError.MalformedURL(url)
80
- return when (scheme) {
81
- "https", "http" -> fetchHttp(url, client)
82
- "file" -> {
83
- val path = parsed.path
84
- if (path.isNullOrEmpty()) throw ImageSourceError.MalformedURL(url)
85
- File(path).readBytes()
86
- }
87
- else -> throw ImageSourceError.InvalidScheme(scheme)
88
- }
89
- }
90
-
91
- /** Parse a `data:[<mediatype>][;base64],<payload>` URL into raw bytes. */
92
- private fun resolveDataURL(url: String): ByteArray {
93
- val commaIdx = url.indexOf(',')
94
- if (commaIdx < 0) throw ImageSourceError.MalformedDataURL(url)
95
- // Skip the leading "data:" (5 chars) and isolate header / body.
96
- val header = url.substring(5, commaIdx)
97
- val body = url.substring(commaIdx + 1)
98
- if (header.contains(";base64")) {
99
- return try {
100
- Base64.getDecoder().decode(body)
101
- } catch (_: IllegalArgumentException) {
102
- throw ImageSourceError.Base64DecodeFailed()
103
- }
104
- }
105
- // Non-base64: payload is percent-encoded text per RFC 2397.
106
- return URLDecoder.decode(body, Charsets.UTF_8.name()).toByteArray(Charsets.UTF_8)
107
- }
108
-
109
- private fun fetchHttp(url: String, client: OkHttpClient): ByteArray {
110
- client.newCall(Request.Builder().url(url).build()).execute().use { resp ->
111
- if (!resp.isSuccessful) throw ImageSourceError.HttpError(resp.code)
112
- return resp.body?.bytes() ?: ByteArray(0)
113
- }
114
- }
115
- }
1
+ package co.deepvoiceai.bridge.mediapipe.core
2
+
3
+ import okhttp3.OkHttpClient
4
+ import okhttp3.Request
5
+ import java.io.File
6
+ import java.net.URI
7
+ import java.net.URLDecoder
8
+ import java.util.Base64
9
+ import java.util.concurrent.TimeUnit
10
+
11
+ /**
12
+ * Errors thrown by [ImageDecoder.resolve] when the input URL string can't
13
+ * be turned into image bytes.
14
+ */
15
+ sealed class ImageSourceError(msg: String) : Exception(msg) {
16
+ class MalformedDataURL(url: String) : ImageSourceError("malformed data URL: $url")
17
+ class MalformedURL(url: String) : ImageSourceError("malformed URL: $url")
18
+ class InvalidScheme(scheme: String) : ImageSourceError("unsupported URL scheme: $scheme")
19
+ class HttpError(val status: Int) : ImageSourceError("HTTP $status")
20
+ class Base64DecodeFailed : ImageSourceError("base64 decode failed")
21
+ }
22
+
23
+ /**
24
+ * Resolves any of the three image URL schemes accepted by the DVAI bridge
25
+ * (`data:`, `https:`/`http:`, `file:`) into the raw encoded image bytes
26
+ * (PNG/JPEG/etc.). Format decoding into pixel buffers is performed downstream
27
+ * by Android's `BitmapFactory` (then wrapped in `MPImage` via
28
+ * `BitmapImageBuilder`) — this layer just materializes the encoded bytes.
29
+ *
30
+ * Byte-identical to capacitor-llama's `ImageDecoder` modulo package
31
+ * declaration. If you fix a bug here, mirror it there (and vice versa).
32
+ *
33
+ * Blocking: HTTP fetches use OkHttp synchronously. Call from a background
34
+ * thread or coroutine on `Dispatchers.IO` (the plugin layer already does).
35
+ */
36
+ object ImageDecoder {
37
+ private val httpClient: OkHttpClient by lazy {
38
+ // `callTimeout` caps the entire HTTP exchange (DNS + connect + write
39
+ // + read + redirect) at 30s so worst-case latency matches iOS, where
40
+ // `URLRequest.timeoutInterval = 30` covers the whole request.
41
+ OkHttpClient.Builder()
42
+ .callTimeout(30, TimeUnit.SECONDS)
43
+ .build()
44
+ }
45
+
46
+ /**
47
+ * Resolve any supported URL scheme into raw image bytes.
48
+ *
49
+ * - `data:` URLs are parsed for an optional `;base64` token and decoded
50
+ * accordingly (URL-encoded payloads are also supported).
51
+ * - `https:` / `http:` URLs are fetched via OkHttp with a 30s timeout;
52
+ * non-2xx responses throw [ImageSourceError.HttpError].
53
+ * - `file:` URLs are read off disk via [File.readBytes].
54
+ * - Any other scheme throws [ImageSourceError.InvalidScheme].
55
+ * - URLs that fail to parse, lack a scheme, or have a missing/empty
56
+ * `file:` path throw [ImageSourceError.MalformedURL].
57
+ */
58
+ fun resolve(url: String): ByteArray {
59
+ if (url.startsWith("data:")) {
60
+ return resolveDataURL(url)
61
+ }
62
+ return resolveWithClient(url, httpClient)
63
+ }
64
+
65
+ /**
66
+ * Test seam: same contract as [resolve] but lets the caller inject an
67
+ * [OkHttpClient] (e.g. one pointed at MockWebServer). Data URLs still
68
+ * short-circuit before the client is consulted.
69
+ */
70
+ internal fun resolveWithClient(url: String, client: OkHttpClient): ByteArray {
71
+ if (url.startsWith("data:")) {
72
+ return resolveDataURL(url)
73
+ }
74
+ val parsed = try {
75
+ URI(url)
76
+ } catch (_: Exception) {
77
+ throw ImageSourceError.MalformedURL(url)
78
+ }
79
+ val scheme = parsed.scheme?.lowercase() ?: throw ImageSourceError.MalformedURL(url)
80
+ return when (scheme) {
81
+ "https", "http" -> fetchHttp(url, client)
82
+ "file" -> {
83
+ val path = parsed.path
84
+ if (path.isNullOrEmpty()) throw ImageSourceError.MalformedURL(url)
85
+ File(path).readBytes()
86
+ }
87
+ else -> throw ImageSourceError.InvalidScheme(scheme)
88
+ }
89
+ }
90
+
91
+ /** Parse a `data:[<mediatype>][;base64],<payload>` URL into raw bytes. */
92
+ private fun resolveDataURL(url: String): ByteArray {
93
+ val commaIdx = url.indexOf(',')
94
+ if (commaIdx < 0) throw ImageSourceError.MalformedDataURL(url)
95
+ // Skip the leading "data:" (5 chars) and isolate header / body.
96
+ val header = url.substring(5, commaIdx)
97
+ val body = url.substring(commaIdx + 1)
98
+ if (header.contains(";base64")) {
99
+ return try {
100
+ Base64.getDecoder().decode(body)
101
+ } catch (_: IllegalArgumentException) {
102
+ throw ImageSourceError.Base64DecodeFailed()
103
+ }
104
+ }
105
+ // Non-base64: payload is percent-encoded text per RFC 2397.
106
+ return URLDecoder.decode(body, Charsets.UTF_8.name()).toByteArray(Charsets.UTF_8)
107
+ }
108
+
109
+ private fun fetchHttp(url: String, client: OkHttpClient): ByteArray {
110
+ client.newCall(Request.Builder().url(url).build()).execute().use { resp ->
111
+ if (!resp.isSuccessful) throw ImageSourceError.HttpError(resp.code)
112
+ return resp.body?.bytes() ?: ByteArray(0)
113
+ }
114
+ }
115
+ }