Skip to content

Commit 72cff08

Browse files
Thomas Gorisseclaude
andcommitted
feat(android-demo): scaffold Sketchfab API client (search + download + LRU cache)
Mirrors the iOS scaffold (918faac). Same architecture: streaming on-demand from api.sketchfab.com instead of bundling ~100 MB of GLB / USDZ in the APK. New files under samples/android-demo/src/main/.../sketchfab/: - SketchfabConfig.kt: BuildConfig-backed API key + cache dir constants - SketchfabModels.kt: Codable types for /v3/search and /v3/models/<uid>/download - SketchfabService.kt: singleton with search() / featured() / downloadUrl() / downloadModel(); OkHttp + kotlinx-serialization; on-disk LRU cache in context.cacheDir/sketchfab capped at 500 MB - (test) SketchfabServiceTest.kt: offline URL-path + missing-key tests build.gradle wiring follows the existing ARCORE_API_KEY pattern: 1. SKETCHFAB_API_KEY env var (CI, store builds — supplied via GitHub Secret) 2. local.properties → sketchfab.api.key (developer machines — gitignored) Result is injected into BuildConfig.SKETCHFAB_API_KEY. When neither source has a value, the gallery falls back to bundled featured models and disables Sketchfab search at runtime (SketchfabError.MissingApiKey). Deps added in gradle/libs.versions.toml: - okhttp 5.x - kotlinx-serialization-json 1.8.x - kotlin-plugin-serialization build-apks.yml already updated in commit 7858051 to forward secrets.SKETCHFAB_API_KEY to the Gradle build. Build verified: ./gradlew :samples:android-demo:compileDebugKotlin SUCCESSFUL. TODO V1.1: route through the mcp-gateway proxy so the master key isn't shipped inside the APK (extractable by any user with `apktool`). The proxy holds the real key server-side and authenticates the demo app via a short-lived token. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 7858051 commit 72cff08

6 files changed

Lines changed: 482 additions & 0 deletions

File tree

gradle/libs.versions.toml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ arCore = "1.53.0"
2525

2626
# Networking
2727
fuel = "2.3.1"
28+
okhttp = "5.0.0"
29+
kotlinxSerialization = "1.8.0"
2830

2931
# UI
3032
googleMaterial = "1.13.0"
@@ -92,6 +94,12 @@ fuel = { group = "com.github.kittinunf.fuel", name = "fuel", version.ref = "fuel
9294
fuel-android = { group = "com.github.kittinunf.fuel", name = "fuel-android", version.ref = "fuel" }
9395
fuel-coroutines = { group = "com.github.kittinunf.fuel", name = "fuel-coroutines", version.ref = "fuel" }
9496

97+
# OkHttp (HTTP client used by Sketchfab service in android-demo)
98+
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
99+
100+
# Kotlinx Serialization (JSON parsing for Sketchfab API responses)
101+
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" }
102+
95103
# Material
96104
google-material = { group = "com.google.android.material", name = "material", version.ref = "googleMaterial" }
97105

@@ -133,6 +141,7 @@ android-library = { id = "com.android.library", version.ref = "agp" }
133141
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
134142
kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
135143
compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
144+
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
136145
compose-multiplatform = { id = "org.jetbrains.compose", version.ref = "composeMultiplatform" }
137146
publish = { id = "com.vanniktech.maven.publish", version.ref = "mavenPublish" }
138147
dokka = { id = "org.jetbrains.dokka", version.ref = "dokka" }

samples/android-demo/build.gradle

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ plugins {
22
alias(libs.plugins.android.application)
33
alias(libs.plugins.kotlin.android)
44
alias(libs.plugins.compose.compiler)
5+
alias(libs.plugins.kotlin.serialization)
56
alias(libs.plugins.roborazzi)
67
}
78

@@ -37,6 +38,27 @@ android {
3738
}
3839
}
3940
manifestPlaceholders["arcoreApiKey"] = arcoreApiKey
41+
42+
// Inject the Sketchfab API key into BuildConfig so the demo's
43+
// SketchfabService can authenticate against api.sketchfab.com without
44+
// hard-coding a token in source. The key is read from one of:
45+
// 1. SKETCHFAB_API_KEY environment variable (CI / store builds)
46+
// 2. local.properties → sketchfab.api.key (developer machines)
47+
// 3. fallback empty string (Sketchfab features stay disabled —
48+
// SketchfabConfig.apiKey returns null and the service throws
49+
// MissingApiKey).
50+
// TODO V1.1: route requests via the mcp-gateway proxy so end-users
51+
// never see the master key in the APK. See SketchfabConfig.kt.
52+
def sketchfabApiKey = System.getenv("SKETCHFAB_API_KEY") ?: ""
53+
if (sketchfabApiKey.isEmpty()) {
54+
def localPropsFile = rootProject.file("local.properties")
55+
if (localPropsFile.exists()) {
56+
def localProps = new Properties()
57+
localPropsFile.withInputStream { localProps.load(it) }
58+
sketchfabApiKey = localProps.getProperty("sketchfab.api.key", "")
59+
}
60+
}
61+
buildConfigField "String", "SKETCHFAB_API_KEY", "\"${sketchfabApiKey}\""
4062
}
4163

4264
signingConfigs {
@@ -117,6 +139,12 @@ dependencies {
117139
implementation libs.play.app.update
118140
implementation libs.play.app.update.ktx
119141

142+
// Sketchfab API client — OkHttp for network, kotlinx-serialization for JSON.
143+
// Used by io.github.sceneview.demo.sketchfab.SketchfabService to stream
144+
// models from api.sketchfab.com instead of bundling GLBs in the APK.
145+
implementation libs.okhttp
146+
implementation libs.kotlinx.serialization.json
147+
120148
// Google Fused Location Provider — required by ARCore Geospatial mode
121149
// (Config.GeospatialMode.ENABLED) used by ARStreetscapeDemo. Without this
122150
// dependency on the classpath, Session.configure() throws an
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package io.github.sceneview.demo.sketchfab
2+
3+
import io.github.sceneview.demo.BuildConfig
4+
5+
/**
6+
* Configuration for the Sketchfab Data API v3.
7+
*
8+
* The API key is injected at build time via [BuildConfig.SKETCHFAB_API_KEY]
9+
* (populated from the `SKETCHFAB_API_KEY` environment variable or the
10+
* `sketchfab.api.key` property in `local.properties` — see this module's
11+
* `build.gradle`). A `SKETCHFAB_API_KEY` env variable at runtime is also
12+
* checked as a last-resort fallback (useful when running unit tests from a
13+
* shell that exports the variable but doesn't propagate it to Gradle).
14+
*
15+
* TODO V1.1: move to backend proxy via mcp-gateway to avoid bundling the key
16+
* directly in the Android app binary. End-users should authenticate against
17+
* the proxy (which holds the master key server-side) so we don't ship a
18+
* long-lived token that can be extracted from `.apk` files.
19+
*/
20+
object SketchfabConfig {
21+
22+
/** Base URL of the Sketchfab Data API v3. Always ends with a trailing slash. */
23+
const val BASE_URL: String = "https://api.sketchfab.com/v3/"
24+
25+
/**
26+
* API key injected at build time, or `null` when missing.
27+
*
28+
* Callers should surface [SketchfabService.SketchfabError.MissingApiKey]
29+
* in that case rather than firing unauthenticated requests.
30+
*/
31+
val apiKey: String?
32+
get() {
33+
val fromBuildConfig = BuildConfig.SKETCHFAB_API_KEY
34+
if (fromBuildConfig.isNotBlank()) return fromBuildConfig
35+
val fromEnv = System.getenv("SKETCHFAB_API_KEY")
36+
return fromEnv?.takeIf { it.isNotBlank() }
37+
}
38+
39+
/** Subdirectory under `Context.cacheDir` where downloaded GLB files live. */
40+
const val CACHE_DIR_NAME: String = "sketchfab"
41+
42+
/** Maximum cache size on disk, in bytes (500 MB). */
43+
const val CACHE_MAX_BYTES: Long = 500L * 1024 * 1024
44+
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
package io.github.sceneview.demo.sketchfab
2+
3+
import kotlinx.serialization.SerialName
4+
import kotlinx.serialization.Serializable
5+
6+
/**
7+
* A Sketchfab model entry returned by `/v3/search` and `/v3/models`.
8+
*
9+
* Fields mirror the iOS scaffold (`SketchfabModels.swift`) so both demo apps
10+
* speak the same wire format — keep them in sync when adding properties.
11+
*/
12+
@Serializable
13+
data class SketchfabModel(
14+
val uid: String,
15+
val name: String,
16+
val description: String? = null,
17+
val thumbnails: SketchfabThumbnails,
18+
@SerialName("viewerUrl") val viewerUrl: String,
19+
val downloadable: Boolean = false,
20+
val tags: List<SketchfabTag>? = null,
21+
)
22+
23+
/** Wrapper around the `images` array returned by Sketchfab for each model. */
24+
@Serializable
25+
data class SketchfabThumbnails(
26+
val images: List<SketchfabThumbnail>,
27+
)
28+
29+
/** A single thumbnail at a specific resolution. */
30+
@Serializable
31+
data class SketchfabThumbnail(
32+
val url: String,
33+
val width: Int,
34+
val height: Int,
35+
)
36+
37+
/**
38+
* A tag attached to a model. Sketchfab returns more fields (slug, uri, …) but
39+
* only [name] is needed for filtering/display in the demo app.
40+
*/
41+
@Serializable
42+
data class SketchfabTag(
43+
val name: String,
44+
)
45+
46+
/** Paginated search/list response. */
47+
@Serializable
48+
data class SketchfabSearchResponse(
49+
val results: List<SketchfabModel>,
50+
val next: String? = null,
51+
val previous: String? = null,
52+
)
53+
54+
/**
55+
* Response of `GET /v3/models/{uid}/download`.
56+
*
57+
* Sketchfab returns up to three format entries (`gltf`, `glb`, `usdz`); all
58+
* are optional because availability depends on the model. The demo prefers
59+
* `glb` (single binary, no companion files) and falls back to `gltf`, then
60+
* `usdz` as a last resort (which SceneView can't load on Android — useful
61+
* only for cross-platform parity reporting).
62+
*/
63+
@Serializable
64+
data class SketchfabDownloadResponse(
65+
val gltf: SketchfabDownloadUrl? = null,
66+
val glb: SketchfabDownloadUrl? = null,
67+
val usdz: SketchfabDownloadUrl? = null,
68+
) {
69+
/** Best format for SceneView consumption. */
70+
val preferred: SketchfabDownloadUrl?
71+
get() = glb ?: gltf ?: usdz
72+
}
73+
74+
/** A signed download URL with its size and expiration timestamp (epoch seconds). */
75+
@Serializable
76+
data class SketchfabDownloadUrl(
77+
val url: String,
78+
val size: Long,
79+
val expires: Long,
80+
)

0 commit comments

Comments
 (0)