diff --git a/Android/Pulsar/src/main/java/com/swmansion/pulsar/Pulsar.kt b/Android/Pulsar/src/main/java/com/swmansion/pulsar/Pulsar.kt
index 6074a8ef..90b41f1a 100644
--- a/Android/Pulsar/src/main/java/com/swmansion/pulsar/Pulsar.kt
+++ b/Android/Pulsar/src/main/java/com/swmansion/pulsar/Pulsar.kt
@@ -3,12 +3,19 @@ package com.swmansion.pulsar
import android.app.Activity
import android.content.Context
import com.swmansion.pulsar.audio.AudioSimulator
+import com.swmansion.pulsar.bundle.BundleDescriptor
+import com.swmansion.pulsar.bundle.BundleLoaderImpl
+import com.swmansion.pulsar.bundle.BundleResolver
+import com.swmansion.pulsar.bundle.LoadedBundle
+import com.swmansion.pulsar.bundle.PulsarBundle
+import com.swmansion.pulsar.bundle.PulsarBundleException
import com.swmansion.pulsar.composers.PatternComposer
import com.swmansion.pulsar.composers.RealtimeComposer
import com.swmansion.pulsar.haptics.HapticEngineWrapper
import com.swmansion.pulsar.presets.PresetsWrapper
import com.swmansion.pulsar.types.CompatibilityMode
import com.swmansion.pulsar.types.RealtimeComposerStrategy
+import java.io.File
open class Pulsar(protected var context: Context) {
protected val engine = HapticEngineWrapper(context)
@@ -87,4 +94,39 @@ open class Pulsar(protected var context: Context) {
fun enableImpulseCompositionMode(state: Boolean) {
engine.enableImpulseCompositionMode(state)
}
+
+ // region: preset bundles
+
+ /** Load a `.pulsar` bundle from raw bytes (used by the React Native / Flutter bridges). */
+ fun loadBundle(bytes: ByteArray): LoadedBundle = BundleLoaderImpl.load(this, context, bytes)
+
+ /** Load a `.pulsar` bundle from a file path. */
+ fun loadBundle(path: String): LoadedBundle = loadBundle(File(path).readBytes())
+
+ /** Load a `.pulsar` bundle bundled under `src/main/assets/`. */
+ fun loadBundleFromAsset(assetName: String): LoadedBundle =
+ context.assets.open(assetName).use { loadBundle(it.readBytes()) }
+
+ /**
+ * Typed load for Kotlin consumers, using a `pulsar-gen`-generated descriptor.
+ *
+ * val bundle = pulsar.loadBundle(AcmePack.descriptor)
+ * bundle.presets.heartbeatV2.play()
+ */
+ fun
loadBundle(descriptor: BundleDescriptor
, strict: Boolean = false): PulsarBundle
{
+ val loaded = loadBundleFromAsset(descriptor.assetName)
+ if (strict && descriptor.contentHash.isNotEmpty() && loaded.contentHash != descriptor.contentHash) {
+ throw PulsarBundleException(
+ "Bundle content hash mismatch: generated types expect ${descriptor.contentHash} " +
+ "but the loaded bundle is ${loaded.contentHash}. Re-export the bundle or regenerate the types.",
+ )
+ }
+ val missing = descriptor.presetIds.filter { loaded.handle(it) == null }
+ if (missing.isNotEmpty()) {
+ throw PulsarBundleException("Bundle is missing preset(s) $missing — regenerate types with pulsar-gen")
+ }
+ return PulsarBundle(loaded, descriptor.build(BundleResolver(loaded)))
+ }
+
+ // endregion
}
diff --git a/Android/Pulsar/src/main/java/com/swmansion/pulsar/bundle/BundleLoader.kt b/Android/Pulsar/src/main/java/com/swmansion/pulsar/bundle/BundleLoader.kt
new file mode 100644
index 00000000..7cb299d6
--- /dev/null
+++ b/Android/Pulsar/src/main/java/com/swmansion/pulsar/bundle/BundleLoader.kt
@@ -0,0 +1,68 @@
+package com.swmansion.pulsar.bundle
+
+import android.content.Context
+import com.swmansion.pulsar.Pulsar
+import com.swmansion.pulsar.types.SoundData
+import kotlinx.serialization.json.Json
+import java.io.File
+
+/** Decodes a `.pulsar` archive into a [LoadedBundle]. Invoked by the `Pulsar.loadBundle*` members. */
+internal object BundleLoaderImpl {
+ private val json = Json { ignoreUnknownKeys = true }
+ private const val SCHEMA = "pulsar.bundle/1"
+
+ fun load(haptics: Pulsar, context: Context, bytes: ByteArray): LoadedBundle {
+ val files = Unzip.read(bytes)
+ val manifestBytes = files["manifest.json"]
+ ?: throw PulsarBundleException("Bundle is missing manifest.json")
+ val manifest = json.decodeFromString(BundleManifest.serializer(), manifestBytes.decodeToString())
+ if (manifest.schema != SCHEMA) {
+ throw PulsarBundleException("Unsupported bundle schema \"${manifest.schema}\" (expected $SCHEMA)")
+ }
+
+ val mediaDir = File(context.cacheDir, "PulsarBundles/${manifest.id}").apply { mkdirs() }
+ val handles = LinkedHashMap()
+
+ for (preset in manifest.presets) {
+ val hapticsBytes = files[preset.haptics]
+ ?: throw PulsarBundleException("Bundle is missing referenced entry \"${preset.haptics}\"")
+ // Device wire shape decodes directly, then maps into the SDK's PatternData.
+ val pattern = json.decodeFromString(DevicePatternDto.serializer(), hapticsBytes.decodeToString())
+ .toPatternData()
+
+ val sound = preset.audio?.let { audio ->
+ files[audio.src]?.let { data ->
+ val dest = File(mediaDir, audio.src.substringAfterLast('/'))
+ dest.writeBytes(data)
+ SoundData(
+ uri = dest.absolutePath,
+ volume = audio.volume ?: 1f,
+ offset = (audio.offset ?: 0.0).toLong(),
+ // Bundle audio is plain music: always play Pulsar's own haptics alongside it.
+ hapticChannels = false,
+ )
+ }
+ }
+
+ val animation = preset.animation?.let { anim ->
+ files[anim.src]?.let { BundleAnimation(it, anim.frameRate ?: 0.0, anim.totalFrames ?: 0) }
+ }
+
+ handles[preset.id] = PresetHandle(
+ id = preset.id,
+ duration = (preset.duration ?: 0.0).toLong(),
+ animation = animation,
+ haptics = haptics,
+ pattern = pattern,
+ sound = sound,
+ )
+ }
+
+ return LoadedBundle(
+ id = manifest.id,
+ contentHash = manifest.hash ?: "",
+ revision = manifest.revision ?: 0,
+ handles = handles,
+ )
+ }
+}
diff --git a/Android/Pulsar/src/main/java/com/swmansion/pulsar/bundle/BundleManifest.kt b/Android/Pulsar/src/main/java/com/swmansion/pulsar/bundle/BundleManifest.kt
new file mode 100644
index 00000000..0671a86a
--- /dev/null
+++ b/Android/Pulsar/src/main/java/com/swmansion/pulsar/bundle/BundleManifest.kt
@@ -0,0 +1,61 @@
+package com.swmansion.pulsar.bundle
+
+import com.swmansion.pulsar.types.ConfigPoint
+import com.swmansion.pulsar.types.ContinuousPattern
+import com.swmansion.pulsar.types.PatternData
+import com.swmansion.pulsar.types.ValuePoint
+import kotlinx.serialization.Serializable
+
+// Codable mirror of manifest.json — see docs/bundle-format.md.
+
+@Serializable
+internal data class BundleManifest(
+ val schema: String,
+ val generator: String? = null,
+ val id: String,
+ val name: String,
+ val revision: Int? = null,
+ val hash: String? = null,
+ val presets: List,
+)
+
+@Serializable
+internal data class PresetEntry(
+ val id: String,
+ val name: String,
+ val duration: Double? = null,
+ val haptics: String,
+ val audio: AudioRef? = null,
+ val animation: AnimationRef? = null,
+)
+
+@Serializable
+internal data class AudioRef(val src: String, val volume: Float? = null, val offset: Double? = null)
+
+@Serializable
+internal data class AnimationRef(val src: String, val frameRate: Double? = null, val totalFrames: Int? = null)
+
+// Device wire shape of a haptics payload; decodes directly, then maps into the SDK's PatternData.
+
+@Serializable
+internal data class ValuePointDto(val time: Double, val value: Float)
+
+@Serializable
+internal data class ConfigPointDto(val time: Double, val amplitude: Float, val frequency: Float)
+
+@Serializable
+internal data class ContinuousDto(val amplitude: List, val frequency: List)
+
+@Serializable
+internal data class DevicePatternDto(
+ val continuousPattern: ContinuousDto,
+ val discretePattern: List,
+) {
+ fun toPatternData(): PatternData = PatternData(
+ continuousPattern = ContinuousPattern(
+ amplitude = continuousPattern.amplitude.map { ValuePoint(it.time.toLong(), it.value) },
+ frequency = continuousPattern.frequency.map { ValuePoint(it.time.toLong(), it.value) },
+ ),
+ discretePattern = discretePattern.map { ConfigPoint(it.time.toLong(), it.amplitude, it.frequency) },
+ )
+}
diff --git a/Android/Pulsar/src/main/java/com/swmansion/pulsar/bundle/PulsarBundle.kt b/Android/Pulsar/src/main/java/com/swmansion/pulsar/bundle/PulsarBundle.kt
new file mode 100644
index 00000000..3fe1e393
--- /dev/null
+++ b/Android/Pulsar/src/main/java/com/swmansion/pulsar/bundle/PulsarBundle.kt
@@ -0,0 +1,95 @@
+package com.swmansion.pulsar.bundle
+
+import com.swmansion.pulsar.Pulsar
+import com.swmansion.pulsar.composers.PatternComposer
+import com.swmansion.pulsar.types.PatternData
+import com.swmansion.pulsar.types.SoundData
+
+/** Lottie bytes + timing for a preset's animation; the host app's own Lottie view renders it. */
+class BundleAnimation internal constructor(
+ val data: ByteArray,
+ val frameRate: Double,
+ val totalFrames: Int,
+)
+
+/** A single playable preset from a loaded bundle. Parses its pattern lazily on first play. */
+class PresetHandle internal constructor(
+ val id: String,
+ val duration: Long,
+ val animation: BundleAnimation?,
+ private val haptics: Pulsar,
+ private val pattern: PatternData,
+ private val sound: SoundData?,
+) {
+ private var composer: PatternComposer? = null
+
+ private fun ensureParsed() {
+ if (composer == null) {
+ val c = haptics.getPatternComposer()
+ if (sound != null) c.parsePatternWithSound(pattern, sound) else c.parsePattern(pattern)
+ composer = c
+ }
+ }
+
+ fun play() {
+ ensureParsed()
+ composer?.play()
+ }
+
+ fun stop() {
+ composer?.stop()
+ }
+
+ internal fun dispose() {
+ composer?.release()
+ composer = null
+ }
+}
+
+/** Untyped loaded bundle — the surface the React Native / Flutter bridges use (string ids). */
+class LoadedBundle internal constructor(
+ val id: String,
+ val contentHash: String,
+ val revision: Int,
+ private val handles: Map,
+) {
+ fun handle(id: String): PresetHandle? = handles[id]
+ val presetIds: List get() = handles.keys.toList()
+ fun play(id: String): Boolean {
+ val h = handles[id] ?: return false
+ h.play()
+ return true
+ }
+ fun dispose() = handles.values.forEach { it.dispose() }
+}
+
+/**
+ * Looks up preset handles by id when a generated descriptor builds its typed presets view.
+ * `loadBundle` guarantees every id in the descriptor exists before this is used.
+ */
+class BundleResolver internal constructor(private val loaded: LoadedBundle) {
+ operator fun get(id: String): PresetHandle = loaded.handle(id)!!
+}
+
+/** Emitted by pulsar-gen: binds a bundle asset + hash to a typed presets builder. */
+class BundleDescriptor(
+ val assetName: String,
+ val bundleId: String,
+ val contentHash: String,
+ val presetIds: List,
+ val build: (BundleResolver) -> P,
+)
+
+/** The typed bundle returned by `pulsar.loadBundle(SomeBundle.descriptor)`. */
+class PulsarBundle internal constructor(
+ private val loaded: LoadedBundle,
+ val presets: P,
+) {
+ val id: String get() = loaded.id
+ val revision: Int get() = loaded.revision
+ val contentHash: String get() = loaded.contentHash
+ fun get(id: String): PresetHandle? = loaded.handle(id)
+ fun dispose() = loaded.dispose()
+}
+
+class PulsarBundleException(message: String) : Exception(message)
diff --git a/Android/Pulsar/src/main/java/com/swmansion/pulsar/bundle/README.md b/Android/Pulsar/src/main/java/com/swmansion/pulsar/bundle/README.md
new file mode 100644
index 00000000..5a9f1232
--- /dev/null
+++ b/Android/Pulsar/src/main/java/com/swmansion/pulsar/bundle/README.md
@@ -0,0 +1,44 @@
+# Pulsar bundles (Android)
+
+Load a `.pulsar` bundle authored in Pulsar Studio at runtime and play its presets with full
+autocomplete. See [`docs/bundle-format.md`](../../../../../../../docs/bundle-format.md) for the format.
+
+## Kotlin usage
+
+```kotlin
+val pulsar = Pulsar(context)
+val bundle = pulsar.loadBundle(AcmePack.descriptor) // AcmePack is generated
+bundle.presets.heartbeatV2.play() // ← autocompletes
+bundle.presets.explosion.stop()
+
+// Animation bytes for the app's own Lottie view (Pulsar times, the app renders):
+bundle.presets.heartbeatV2.animation?.let { myLottieView.setAnimation(it.data.inputStream(), null) }
+```
+
+`loadBundle(descriptor, strict = true)` asserts the loaded bundle's content hash matches the
+generated types, failing loudly on a stale bundle/types mismatch.
+
+## Zero-manual codegen (Gradle plugin)
+
+```kotlin
+plugins { id("com.swmansion.pulsar.gen") }
+```
+
+Drop `.pulsar` files into `src/pulsarBundles/`. On every build the plugin generates the typed
+`object` per bundle and packages the bundle into the APK assets (under `assets/pulsar/`) — the
+FlutterGen / Compose-Resources model, no manual step. Configure via:
+
+```kotlin
+pulsarBundles {
+ // bundlesDir.set(layout.projectDirectory.dir("src/pulsarBundles")) // default
+ packageName.set("com.acme.haptics")
+}
+```
+
+## Bridge surface (React Native / Flutter)
+
+```kotlin
+val loaded = pulsar.loadBundle(bytes) // or loadBundle(path) / loadBundleFromAsset("pulsar/acme-pack.pulsar")
+loaded.presetIds // -> List
+loaded.play("heartbeatV2") // -> Boolean
+```
diff --git a/Android/Pulsar/src/main/java/com/swmansion/pulsar/bundle/Unzip.kt b/Android/Pulsar/src/main/java/com/swmansion/pulsar/bundle/Unzip.kt
new file mode 100644
index 00000000..ffd2b830
--- /dev/null
+++ b/Android/Pulsar/src/main/java/com/swmansion/pulsar/bundle/Unzip.kt
@@ -0,0 +1,22 @@
+package com.swmansion.pulsar.bundle
+
+import java.io.ByteArrayInputStream
+import java.util.zip.ZipInputStream
+
+/** Reads a `.pulsar` (zip) into a map of entry path -> bytes using the JDK's zip support. */
+internal object Unzip {
+ fun read(bytes: ByteArray): Map {
+ val out = LinkedHashMap()
+ ZipInputStream(ByteArrayInputStream(bytes)).use { zis ->
+ var entry = zis.nextEntry
+ while (entry != null) {
+ if (!entry.isDirectory) {
+ out[entry.name] = zis.readBytes()
+ }
+ zis.closeEntry()
+ entry = zis.nextEntry
+ }
+ }
+ return out
+ }
+}
diff --git a/Android/Pulsar/src/test/java/com/swmansion/pulsar/bundle/BundleUnitTest.kt b/Android/Pulsar/src/test/java/com/swmansion/pulsar/bundle/BundleUnitTest.kt
new file mode 100644
index 00000000..97c1e531
--- /dev/null
+++ b/Android/Pulsar/src/test/java/com/swmansion/pulsar/bundle/BundleUnitTest.kt
@@ -0,0 +1,67 @@
+package com.swmansion.pulsar.bundle
+
+import kotlinx.serialization.json.Json
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import java.io.ByteArrayOutputStream
+import java.util.zip.ZipEntry
+import java.util.zip.ZipOutputStream
+
+/** JVM-level checks for the bundle decode path (no Android Context needed). */
+class BundleUnitTest {
+ private val json = Json { ignoreUnknownKeys = true }
+
+ private fun zip(entries: Map): ByteArray {
+ val bos = ByteArrayOutputStream()
+ ZipOutputStream(bos).use { z ->
+ entries.forEach { (name, content) ->
+ z.putNextEntry(ZipEntry(name))
+ z.write(content.toByteArray())
+ z.closeEntry()
+ }
+ }
+ return bos.toByteArray()
+ }
+
+ private val manifestJson = """
+ {"schema":"pulsar.bundle/1","id":"com.acme.haptics","name":"Acme Pack","revision":7,
+ "hash":"sha256-test","presets":[
+ {"id":"heartbeatV2","name":"Heartbeat V2","duration":1200,"haptics":"haptics/heartbeatV2.json",
+ "audio":{"src":"audio/boom.ogg","volume":1.0,"offset":0}},
+ {"id":"explosion","name":"Explosion","duration":800,"haptics":"haptics/explosion.json"}]}
+ """.trimIndent()
+
+ private val hapticsJson = """
+ {"continuousPattern":{"amplitude":[{"time":0,"value":0.0},{"time":10,"value":0.8}],
+ "frequency":[{"time":0,"value":0.2}]},"discretePattern":[{"time":0,"amplitude":0.9,"frequency":0.2}]}
+ """.trimIndent()
+
+ @Test
+ fun unzipReadsAllEntries() {
+ val files = Unzip.read(zip(mapOf("manifest.json" to manifestJson, "haptics/heartbeatV2.json" to hapticsJson)))
+ assertNotNull(files["manifest.json"])
+ assertNotNull(files["haptics/heartbeatV2.json"])
+ }
+
+ @Test
+ fun manifestDecodesWithOptionalFields() {
+ val manifest = json.decodeFromString(BundleManifest.serializer(), manifestJson)
+ assertEquals("com.acme.haptics", manifest.id)
+ assertEquals("sha256-test", manifest.hash)
+ assertEquals(listOf("heartbeatV2", "explosion"), manifest.presets.map { it.id })
+ assertEquals("audio/boom.ogg", manifest.presets[0].audio?.src)
+ assertTrue(manifest.presets[1].audio == null)
+ }
+
+ @Test
+ fun devicePatternMapsIntoPatternData() {
+ val pattern = json.decodeFromString(DevicePatternDto.serializer(), hapticsJson).toPatternData()
+ assertEquals(listOf(0L, 10L), pattern.continuousPattern.amplitude.map { it.time })
+ assertEquals(0.8f, pattern.continuousPattern.amplitude[1].value)
+ assertEquals(1, pattern.discretePattern.size)
+ assertEquals(0.9f, pattern.discretePattern[0].amplitude)
+ assertEquals(0.2f, pattern.discretePattern[0].frequency)
+ }
+}
diff --git a/tools/pulsar-gen-gradle/build.gradle.kts b/tools/pulsar-gen-gradle/build.gradle.kts
new file mode 100644
index 00000000..97d1fbff
--- /dev/null
+++ b/tools/pulsar-gen-gradle/build.gradle.kts
@@ -0,0 +1,28 @@
+plugins {
+ `kotlin-dsl`
+ `java-gradle-plugin`
+}
+
+group = "com.swmansion.pulsar"
+version = "0.1.0"
+
+gradlePlugin {
+ plugins {
+ create("pulsarGen") {
+ id = "com.swmansion.pulsar.gen"
+ implementationClass = "com.swmansion.pulsar.gradle.PulsarGenPlugin"
+ displayName = "Pulsar bundle codegen"
+ description = "Generates typed Kotlin accessors for .pulsar bundles and packages them into assets."
+ }
+ }
+}
+
+repositories {
+ mavenCentral()
+ google()
+}
+
+dependencies {
+ // AGP types for wiring generated sources/assets into the Android build (not shipped).
+ compileOnly("com.android.tools.build:gradle:8.7.2")
+}
diff --git a/tools/pulsar-gen-gradle/settings.gradle.kts b/tools/pulsar-gen-gradle/settings.gradle.kts
new file mode 100644
index 00000000..c6c3dfe6
--- /dev/null
+++ b/tools/pulsar-gen-gradle/settings.gradle.kts
@@ -0,0 +1 @@
+rootProject.name = "pulsar-gen-gradle"
diff --git a/tools/pulsar-gen-gradle/src/main/kotlin/com/swmansion/pulsar/gradle/GeneratePulsarBundlesTask.kt b/tools/pulsar-gen-gradle/src/main/kotlin/com/swmansion/pulsar/gradle/GeneratePulsarBundlesTask.kt
new file mode 100644
index 00000000..57092522
--- /dev/null
+++ b/tools/pulsar-gen-gradle/src/main/kotlin/com/swmansion/pulsar/gradle/GeneratePulsarBundlesTask.kt
@@ -0,0 +1,116 @@
+package com.swmansion.pulsar.gradle
+
+import groovy.json.JsonSlurper
+import org.gradle.api.DefaultTask
+import org.gradle.api.file.DirectoryProperty
+import org.gradle.api.provider.Property
+import org.gradle.api.tasks.Input
+import org.gradle.api.tasks.InputDirectory
+import org.gradle.api.tasks.Optional
+import org.gradle.api.tasks.OutputDirectory
+import org.gradle.api.tasks.TaskAction
+import java.io.ByteArrayInputStream
+import java.io.File
+import java.util.zip.ZipInputStream
+
+/**
+ * Reads every `*.pulsar` bundle in [bundlesDir], emits a typed Kotlin accessor per bundle into
+ * [generatedSrcDir], and copies the bundle into `[generatedAssetsDir]/pulsar/` so it ships in the
+ * APK's assets. Runs before Kotlin compilation, so dropping in an updated bundle refreshes the types.
+ */
+abstract class GeneratePulsarBundlesTask : DefaultTask() {
+ @get:InputDirectory @get:Optional
+ abstract val bundlesDir: DirectoryProperty
+
+ @get:OutputDirectory
+ abstract val generatedSrcDir: DirectoryProperty
+
+ @get:OutputDirectory
+ abstract val generatedAssetsDir: DirectoryProperty
+
+ @get:Input
+ abstract val packageName: Property
+
+ @TaskAction
+ fun generate() {
+ val dir = bundlesDir.orNull?.asFile
+ val srcOut = generatedSrcDir.get().asFile.also { it.mkdirs() }
+ val assetsOut = generatedAssetsDir.get().asFile.resolve("pulsar").also { it.mkdirs() }
+ if (dir == null || !dir.exists()) return
+
+ dir.listFiles { f -> f.isFile && f.extension == "pulsar" }?.sortedBy { it.name }?.forEach { file ->
+ val entries = unzip(file.readBytes())
+ val manifestBytes = entries["manifest.json"]
+ ?: error("Pulsar: ${file.name} is missing manifest.json")
+
+ @Suppress("UNCHECKED_CAST")
+ val manifest = JsonSlurper().parseText(String(manifestBytes)) as Map
+ val name = manifest["name"] as? String ?: error("Pulsar: ${file.name} manifest missing 'name'")
+ val typeName = pascalCase(name)
+
+ val code = emitKotlin(manifest, assetName = "pulsar/${file.name}", packageName = packageName.get())
+ File(srcOut, "$typeName.kt").writeText(code)
+ file.copyTo(File(assetsOut, file.name), overwrite = true)
+ logger.lifecycle("pulsar-gen: ${file.name} -> $typeName.kt")
+ }
+ }
+
+ private fun unzip(bytes: ByteArray): Map {
+ val out = LinkedHashMap()
+ ZipInputStream(ByteArrayInputStream(bytes)).use { zis ->
+ var entry = zis.nextEntry
+ while (entry != null) {
+ if (!entry.isDirectory) out[entry.name] = zis.readBytes()
+ zis.closeEntry()
+ entry = zis.nextEntry
+ }
+ }
+ return out
+ }
+}
+
+internal fun pascalCase(input: String): String {
+ val parts = input.split(Regex("[^A-Za-z0-9]+")).filter { it.isNotEmpty() }
+ val p = parts.joinToString("") { it.replaceFirstChar { c -> c.uppercaseChar() } }
+ return if (p.firstOrNull()?.isLetter() == true || p.firstOrNull() == '_') p else "Bundle$p"
+}
+
+@Suppress("UNCHECKED_CAST")
+internal fun emitKotlin(manifest: Map, assetName: String, packageName: String): String {
+ val bundleId = manifest["id"] as? String ?: ""
+ val hash = manifest["hash"] as? String ?: ""
+ val typeName = pascalCase(manifest["name"] as String)
+ val presets = (manifest["presets"] as? List