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>).orEmpty() + val ids = presets.map { it["id"] as String } + + val fields = ids.joinToString("\n") { " val $it: PresetHandle = r[\"$it\"]" } + val idList = ids.joinToString(", ") { "\"$it\"" } + + return buildString { + appendLine("// Code generated by pulsar-gen. DO NOT EDIT.") + appendLine("// Bundle: $bundleId (${ids.size} preset${if (ids.size == 1) "" else "s"})") + appendLine("package $packageName") + appendLine() + appendLine("import com.swmansion.pulsar.bundle.BundleDescriptor") + appendLine("import com.swmansion.pulsar.bundle.BundleResolver") + appendLine("import com.swmansion.pulsar.bundle.PresetHandle") + appendLine() + appendLine("object $typeName {") + appendLine(" const val assetName = \"$assetName\"") + appendLine(" const val bundleId = \"$bundleId\"") + appendLine(" const val contentHash = \"$hash\"") + appendLine() + appendLine(" class Presets(r: BundleResolver) {") + appendLine(fields) + appendLine(" }") + appendLine() + appendLine(" val descriptor = BundleDescriptor(") + appendLine(" assetName = assetName,") + appendLine(" bundleId = bundleId,") + appendLine(" contentHash = contentHash,") + appendLine(" presetIds = listOf($idList),") + appendLine(" build = ::Presets,") + appendLine(" )") + appendLine("}") + } +} diff --git a/tools/pulsar-gen-gradle/src/main/kotlin/com/swmansion/pulsar/gradle/PulsarGenPlugin.kt b/tools/pulsar-gen-gradle/src/main/kotlin/com/swmansion/pulsar/gradle/PulsarGenPlugin.kt new file mode 100644 index 00000000..47b87b67 --- /dev/null +++ b/tools/pulsar-gen-gradle/src/main/kotlin/com/swmansion/pulsar/gradle/PulsarGenPlugin.kt @@ -0,0 +1,56 @@ +package com.swmansion.pulsar.gradle + +import com.android.build.gradle.BaseExtension +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.api.file.Directory +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.provider.Property +import org.gradle.api.provider.Provider +import org.gradle.api.tasks.TaskProvider + +abstract class PulsarGenExtension { + /** Directory scanned for `*.pulsar` bundles. Defaults to `src/pulsarBundles`. */ + abstract val bundlesDir: DirectoryProperty + + /** Package for the generated accessor objects. Defaults to `com.swmansion.pulsar.bundles`. */ + abstract val packageName: Property +} + +/** + * Apply with `id("com.swmansion.pulsar.gen")`. Generates typed Kotlin accessors for every + * `.pulsar` bundle in `src/pulsarBundles/` and packages the bundles into the APK assets, wired to + * run before compilation — the FlutterGen / Compose-Resources model for Android. + */ +class PulsarGenPlugin : Plugin { + override fun apply(project: Project) { + val ext = project.extensions.create("pulsarBundles", PulsarGenExtension::class.java) + + val genSrc: Provider = project.layout.buildDirectory.dir("generated/source/pulsar/main") + val genAssets: Provider = project.layout.buildDirectory.dir("generated/assets/pulsar") + + val task = project.tasks.register("generatePulsarBundles", GeneratePulsarBundlesTask::class.java) { t -> + t.bundlesDir.convention( + ext.bundlesDir.orElse(project.layout.projectDirectory.dir("src/pulsarBundles")), + ) + t.generatedSrcDir.set(genSrc) + t.generatedAssetsDir.set(genAssets) + t.packageName.convention(ext.packageName.orElse("com.swmansion.pulsar.bundles")) + } + + project.plugins.withId("com.android.application") { wireAndroid(project, task, genSrc, genAssets) } + project.plugins.withId("com.android.library") { wireAndroid(project, task, genSrc, genAssets) } + } + + private fun wireAndroid( + project: Project, + task: TaskProvider, + genSrc: Provider, + genAssets: Provider, + ) { + val android = project.extensions.findByType(BaseExtension::class.java) ?: return + android.sourceSets.getByName("main").java.srcDir(genSrc) + android.sourceSets.getByName("main").assets.srcDir(genAssets) + project.tasks.named("preBuild").configure { it.dependsOn(task) } + } +}