From abce458411ed3e73f6f6c45f0a6303fdd14c1073 Mon Sep 17 00:00:00 2001 From: Vibhor Goel Date: Thu, 25 Jun 2026 13:59:06 +0530 Subject: [PATCH 1/8] Build OpenMacro architecture foundation --- app/build.gradle.kts | 3 +- .../capability/CapabilityDefinition.kt | 57 ++++++ .../capability/CapabilityRegistry.kt | 41 +++++ .../capability/CapabilityValidation.kt | 69 +++++++ .../builtin/DeviceUnlockedCondition.kt | 33 ++++ .../builtin/NotificationShowAction.kt | 60 ++++++ .../builtin/PowerConnectedTrigger.kt | 33 ++++ .../openmacro/model/OpenMacroDocument.kt | 51 ++++++ .../zerobit/openmacro/runtime/RuntimePlan.kt | 38 ++++ .../openmacro/runtime/RuntimePlanCompiler.kt | 60 ++++++ .../validation/OpenMacroValidator.kt | 171 ++++++++++++++++++ .../capability/CapabilityRegistryTest.kt | 34 ++++ .../runtime/RuntimePlanCompilerTest.kt | 114 ++++++++++++ .../validation/OpenMacroValidatorTest.kt | 76 ++++++++ docs/architecture/openmacro-foundation.md | 109 +++++++++++ examples/charger-greeting.openmacro.yaml | 21 +++ gradle/libs.versions.toml | 3 +- schemas/openmacro/v0.1/openmacro.schema.json | 90 +++++++++ 18 files changed, 1061 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/com/vibhor1102/zerobit/openmacro/capability/CapabilityDefinition.kt create mode 100644 app/src/main/java/com/vibhor1102/zerobit/openmacro/capability/CapabilityRegistry.kt create mode 100644 app/src/main/java/com/vibhor1102/zerobit/openmacro/capability/CapabilityValidation.kt create mode 100644 app/src/main/java/com/vibhor1102/zerobit/openmacro/capability/builtin/DeviceUnlockedCondition.kt create mode 100644 app/src/main/java/com/vibhor1102/zerobit/openmacro/capability/builtin/NotificationShowAction.kt create mode 100644 app/src/main/java/com/vibhor1102/zerobit/openmacro/capability/builtin/PowerConnectedTrigger.kt create mode 100644 app/src/main/java/com/vibhor1102/zerobit/openmacro/model/OpenMacroDocument.kt create mode 100644 app/src/main/java/com/vibhor1102/zerobit/openmacro/runtime/RuntimePlan.kt create mode 100644 app/src/main/java/com/vibhor1102/zerobit/openmacro/runtime/RuntimePlanCompiler.kt create mode 100644 app/src/main/java/com/vibhor1102/zerobit/openmacro/validation/OpenMacroValidator.kt create mode 100644 app/src/test/java/com/vibhor1102/zerobit/openmacro/capability/CapabilityRegistryTest.kt create mode 100644 app/src/test/java/com/vibhor1102/zerobit/openmacro/runtime/RuntimePlanCompilerTest.kt create mode 100644 app/src/test/java/com/vibhor1102/zerobit/openmacro/validation/OpenMacroValidatorTest.kt create mode 100644 docs/architecture/openmacro-foundation.md create mode 100644 examples/charger-greeting.openmacro.yaml create mode 100644 schemas/openmacro/v0.1/openmacro.schema.json diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a8d35d1..ae66b44 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -62,6 +62,7 @@ dependencies { implementation(libs.androidx.compose.ui) implementation(libs.androidx.compose.ui.tooling.preview) + testImplementation(libs.junit) + debugImplementation(libs.androidx.compose.ui.tooling) } - diff --git a/app/src/main/java/com/vibhor1102/zerobit/openmacro/capability/CapabilityDefinition.kt b/app/src/main/java/com/vibhor1102/zerobit/openmacro/capability/CapabilityDefinition.kt new file mode 100644 index 0000000..996552b --- /dev/null +++ b/app/src/main/java/com/vibhor1102/zerobit/openmacro/capability/CapabilityDefinition.kt @@ -0,0 +1,57 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.vibhor1102.zerobit.openmacro.capability + +import com.vibhor1102.zerobit.openmacro.model.MacroBlock +import com.vibhor1102.zerobit.openmacro.runtime.RuntimeStep +import com.vibhor1102.zerobit.openmacro.validation.ValidationIssue + +/** + * One source of truth for a block's code shape, generated form, explanation, + * permission discovery, and runtime instruction. + */ +interface CapabilityDefinition { + val type: String + val lane: CapabilityLane + val displayName: String + val description: String + val fields: List + + fun validate(block: MacroBlock, path: String): List + + fun explain(block: MacroBlock): String + + fun requiredPermissions(block: MacroBlock): Set + + fun compile(block: MacroBlock): RuntimeStep +} + +enum class CapabilityLane { + TRIGGER, + CONDITION, + ACTION, +} + +data class CapabilityField( + val key: String, + val label: String, + val kind: CapabilityFieldKind, + val required: Boolean, + val help: String, + val advanced: Boolean = false, +) + +enum class CapabilityFieldKind { + TEXT, + MULTILINE_TEXT, + NUMBER, + BOOLEAN, +} + +enum class AndroidPermission( + val manifestName: String, +) { + POST_NOTIFICATIONS("android.permission.POST_NOTIFICATIONS"), +} diff --git a/app/src/main/java/com/vibhor1102/zerobit/openmacro/capability/CapabilityRegistry.kt b/app/src/main/java/com/vibhor1102/zerobit/openmacro/capability/CapabilityRegistry.kt new file mode 100644 index 0000000..42277e5 --- /dev/null +++ b/app/src/main/java/com/vibhor1102/zerobit/openmacro/capability/CapabilityRegistry.kt @@ -0,0 +1,41 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.vibhor1102.zerobit.openmacro.capability + +import com.vibhor1102.zerobit.openmacro.capability.builtin.DeviceUnlockedCondition +import com.vibhor1102.zerobit.openmacro.capability.builtin.NotificationShowAction +import com.vibhor1102.zerobit.openmacro.capability.builtin.PowerConnectedTrigger + +class CapabilityRegistry private constructor( + definitions: List, +) { + private val definitionsByType = definitions.associateBy { it.type } + + init { + require(definitionsByType.size == definitions.size) { + "Capability types must be unique." + } + } + + fun find(type: String): CapabilityDefinition? = definitionsByType[type] + + fun list(lane: CapabilityLane): List = + definitionsByType.values + .filter { it.lane == lane } + .sortedBy { it.displayName } + + companion object { + fun builtIn(): CapabilityRegistry = CapabilityRegistry( + definitions = listOf( + PowerConnectedTrigger, + DeviceUnlockedCondition, + NotificationShowAction, + ), + ) + + fun of(vararg definitions: CapabilityDefinition): CapabilityRegistry = + CapabilityRegistry(definitions.toList()) + } +} diff --git a/app/src/main/java/com/vibhor1102/zerobit/openmacro/capability/CapabilityValidation.kt b/app/src/main/java/com/vibhor1102/zerobit/openmacro/capability/CapabilityValidation.kt new file mode 100644 index 0000000..4d1db3a --- /dev/null +++ b/app/src/main/java/com/vibhor1102/zerobit/openmacro/capability/CapabilityValidation.kt @@ -0,0 +1,69 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.vibhor1102.zerobit.openmacro.capability + +import com.vibhor1102.zerobit.openmacro.model.MacroBlock +import com.vibhor1102.zerobit.openmacro.model.MacroValue +import com.vibhor1102.zerobit.openmacro.validation.ValidationIssue + +internal fun MacroBlock.rejectUnknownConfig( + allowedKeys: Set, + path: String, +): List = config.keys + .filterNot(allowedKeys::contains) + .sorted() + .map { key -> + ValidationIssue( + path = "$path.config.$key", + code = "unknown_config", + message = "Configuration '$key' is not supported by '$type'.", + ) + } + +internal fun MacroBlock.requireText( + key: String, + path: String, + maxLength: Int, +): List { + val value = config[key] + return when { + value == null -> listOf( + ValidationIssue( + path = "$path.config.$key", + code = "missing_config", + message = "Configuration '$key' is required.", + ), + ) + + value !is MacroValue.Text -> listOf( + ValidationIssue( + path = "$path.config.$key", + code = "wrong_config_type", + message = "Configuration '$key' must be text.", + ), + ) + + value.value.isBlank() -> listOf( + ValidationIssue( + path = "$path.config.$key", + code = "blank_config", + message = "Configuration '$key' must not be blank.", + ), + ) + + value.value.length > maxLength -> listOf( + ValidationIssue( + path = "$path.config.$key", + code = "config_too_long", + message = "Configuration '$key' must be $maxLength characters or fewer.", + ), + ) + + else -> emptyList() + } +} + +internal fun MacroBlock.text(key: String): String = + (config.getValue(key) as MacroValue.Text).value diff --git a/app/src/main/java/com/vibhor1102/zerobit/openmacro/capability/builtin/DeviceUnlockedCondition.kt b/app/src/main/java/com/vibhor1102/zerobit/openmacro/capability/builtin/DeviceUnlockedCondition.kt new file mode 100644 index 0000000..567cfc4 --- /dev/null +++ b/app/src/main/java/com/vibhor1102/zerobit/openmacro/capability/builtin/DeviceUnlockedCondition.kt @@ -0,0 +1,33 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.vibhor1102.zerobit.openmacro.capability.builtin + +import com.vibhor1102.zerobit.openmacro.capability.AndroidPermission +import com.vibhor1102.zerobit.openmacro.capability.CapabilityDefinition +import com.vibhor1102.zerobit.openmacro.capability.CapabilityField +import com.vibhor1102.zerobit.openmacro.capability.CapabilityLane +import com.vibhor1102.zerobit.openmacro.capability.rejectUnknownConfig +import com.vibhor1102.zerobit.openmacro.model.MacroBlock +import com.vibhor1102.zerobit.openmacro.runtime.RuntimeStep +import com.vibhor1102.zerobit.openmacro.validation.ValidationIssue + +object DeviceUnlockedCondition : CapabilityDefinition { + override val type = "android.device.unlocked" + override val lane = CapabilityLane.CONDITION + override val displayName = "Device is unlocked" + override val description = "Continues only when the device is currently unlocked." + override val fields: List = emptyList() + + override fun validate(block: MacroBlock, path: String): List = + block.rejectUnknownConfig(emptySet(), path) + + override fun explain(block: MacroBlock): String = + "Continue only if the phone is unlocked." + + override fun requiredPermissions(block: MacroBlock): Set = emptySet() + + override fun compile(block: MacroBlock): RuntimeStep = + RuntimeStep.CheckDeviceUnlocked(blockId = block.id) +} diff --git a/app/src/main/java/com/vibhor1102/zerobit/openmacro/capability/builtin/NotificationShowAction.kt b/app/src/main/java/com/vibhor1102/zerobit/openmacro/capability/builtin/NotificationShowAction.kt new file mode 100644 index 0000000..a85ad23 --- /dev/null +++ b/app/src/main/java/com/vibhor1102/zerobit/openmacro/capability/builtin/NotificationShowAction.kt @@ -0,0 +1,60 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.vibhor1102.zerobit.openmacro.capability.builtin + +import com.vibhor1102.zerobit.openmacro.capability.AndroidPermission +import com.vibhor1102.zerobit.openmacro.capability.CapabilityDefinition +import com.vibhor1102.zerobit.openmacro.capability.CapabilityField +import com.vibhor1102.zerobit.openmacro.capability.CapabilityFieldKind +import com.vibhor1102.zerobit.openmacro.capability.CapabilityLane +import com.vibhor1102.zerobit.openmacro.capability.rejectUnknownConfig +import com.vibhor1102.zerobit.openmacro.capability.requireText +import com.vibhor1102.zerobit.openmacro.capability.text +import com.vibhor1102.zerobit.openmacro.model.MacroBlock +import com.vibhor1102.zerobit.openmacro.runtime.RuntimeStep +import com.vibhor1102.zerobit.openmacro.validation.ValidationIssue + +object NotificationShowAction : CapabilityDefinition { + override val type = "android.notification.show" + override val lane = CapabilityLane.ACTION + override val displayName = "Show notification" + override val description = "Displays a local Android notification." + override val fields = listOf( + CapabilityField( + key = "title", + label = "Title", + kind = CapabilityFieldKind.TEXT, + required = true, + help = "The short heading shown in the notification.", + ), + CapabilityField( + key = "message", + label = "Message", + kind = CapabilityFieldKind.MULTILINE_TEXT, + required = true, + help = "The notification text.", + ), + ) + + override fun validate(block: MacroBlock, path: String): List = + buildList { + addAll(block.rejectUnknownConfig(setOf("title", "message"), path)) + addAll(block.requireText("title", path, maxLength = 120)) + addAll(block.requireText("message", path, maxLength = 1_000)) + } + + override fun explain(block: MacroBlock): String = + "Show a notification titled “${block.text("title")}” with the message “${block.text("message")}”." + + override fun requiredPermissions(block: MacroBlock): Set = + setOf(AndroidPermission.POST_NOTIFICATIONS) + + override fun compile(block: MacroBlock): RuntimeStep = + RuntimeStep.ShowNotification( + blockId = block.id, + title = block.text("title"), + message = block.text("message"), + ) +} diff --git a/app/src/main/java/com/vibhor1102/zerobit/openmacro/capability/builtin/PowerConnectedTrigger.kt b/app/src/main/java/com/vibhor1102/zerobit/openmacro/capability/builtin/PowerConnectedTrigger.kt new file mode 100644 index 0000000..654d564 --- /dev/null +++ b/app/src/main/java/com/vibhor1102/zerobit/openmacro/capability/builtin/PowerConnectedTrigger.kt @@ -0,0 +1,33 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.vibhor1102.zerobit.openmacro.capability.builtin + +import com.vibhor1102.zerobit.openmacro.capability.AndroidPermission +import com.vibhor1102.zerobit.openmacro.capability.CapabilityDefinition +import com.vibhor1102.zerobit.openmacro.capability.CapabilityField +import com.vibhor1102.zerobit.openmacro.capability.CapabilityLane +import com.vibhor1102.zerobit.openmacro.capability.rejectUnknownConfig +import com.vibhor1102.zerobit.openmacro.model.MacroBlock +import com.vibhor1102.zerobit.openmacro.runtime.RuntimeStep +import com.vibhor1102.zerobit.openmacro.validation.ValidationIssue + +object PowerConnectedTrigger : CapabilityDefinition { + override val type = "android.power.connected" + override val lane = CapabilityLane.TRIGGER + override val displayName = "Power connected" + override val description = "Starts when Android reports that external power was connected." + override val fields: List = emptyList() + + override fun validate(block: MacroBlock, path: String): List = + block.rejectUnknownConfig(emptySet(), path) + + override fun explain(block: MacroBlock): String = + "Start when the phone is connected to external power." + + override fun requiredPermissions(block: MacroBlock): Set = emptySet() + + override fun compile(block: MacroBlock): RuntimeStep = + RuntimeStep.ObservePowerConnected(blockId = block.id) +} diff --git a/app/src/main/java/com/vibhor1102/zerobit/openmacro/model/OpenMacroDocument.kt b/app/src/main/java/com/vibhor1102/zerobit/openmacro/model/OpenMacroDocument.kt new file mode 100644 index 0000000..46d3cf8 --- /dev/null +++ b/app/src/main/java/com/vibhor1102/zerobit/openmacro/model/OpenMacroDocument.kt @@ -0,0 +1,51 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.vibhor1102.zerobit.openmacro.model + +import java.math.BigDecimal + +/** + * The format-neutral meaning of one OpenMacro file. + * + * The visual editor and source editor must both read and write this model. + * YAML is an adapter around it, not the runtime's source of truth. + */ +data class OpenMacroDocument( + val format: String, + val metadata: MacroMetadata, + val triggers: List, + val conditions: List, + val actions: List, +) + +data class MacroMetadata( + val id: String, + val name: String, + val description: String? = null, +) + +/** + * A block remains generic at the file boundary so new capabilities can be + * preserved and explained even when this app version cannot execute them. + */ +data class MacroBlock( + val id: String, + val type: String, + val config: Map = emptyMap(), +) + +sealed interface MacroValue { + data class Text(val value: String) : MacroValue + + data class Number(val value: BigDecimal) : MacroValue + + data class Boolean(val value: kotlin.Boolean) : MacroValue + + data class ListValue(val values: List) : MacroValue + + data class ObjectValue(val values: Map) : MacroValue + + data object Null : MacroValue +} diff --git a/app/src/main/java/com/vibhor1102/zerobit/openmacro/runtime/RuntimePlan.kt b/app/src/main/java/com/vibhor1102/zerobit/openmacro/runtime/RuntimePlan.kt new file mode 100644 index 0000000..1fda1a1 --- /dev/null +++ b/app/src/main/java/com/vibhor1102/zerobit/openmacro/runtime/RuntimePlan.kt @@ -0,0 +1,38 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.vibhor1102.zerobit.openmacro.runtime + +import com.vibhor1102.zerobit.openmacro.capability.AndroidPermission + +/** + * Immutable, already-approved instructions consumed by the future runtime. + * The runtime does not parse source files or interpret capability config. + */ +data class RuntimePlan( + val macroId: String, + val sourceFingerprint: String, + val triggers: List, + val conditions: List, + val actions: List, + val requiredPermissions: Set, +) + +sealed interface RuntimeStep { + val blockId: String + + data class ObservePowerConnected( + override val blockId: String, + ) : RuntimeStep + + data class CheckDeviceUnlocked( + override val blockId: String, + ) : RuntimeStep + + data class ShowNotification( + override val blockId: String, + val title: String, + val message: String, + ) : RuntimeStep +} diff --git a/app/src/main/java/com/vibhor1102/zerobit/openmacro/runtime/RuntimePlanCompiler.kt b/app/src/main/java/com/vibhor1102/zerobit/openmacro/runtime/RuntimePlanCompiler.kt new file mode 100644 index 0000000..bd97376 --- /dev/null +++ b/app/src/main/java/com/vibhor1102/zerobit/openmacro/runtime/RuntimePlanCompiler.kt @@ -0,0 +1,60 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.vibhor1102.zerobit.openmacro.runtime + +import com.vibhor1102.zerobit.openmacro.capability.AndroidPermission +import com.vibhor1102.zerobit.openmacro.capability.CapabilityLane +import com.vibhor1102.zerobit.openmacro.capability.CapabilityRegistry +import com.vibhor1102.zerobit.openmacro.model.MacroBlock +import com.vibhor1102.zerobit.openmacro.model.OpenMacroDocument +import com.vibhor1102.zerobit.openmacro.validation.OpenMacroValidator +import com.vibhor1102.zerobit.openmacro.validation.ValidationIssue + +class RuntimePlanCompiler( + private val registry: CapabilityRegistry, +) { + fun compile( + document: OpenMacroDocument, + sourceFingerprint: String, + ): PlanCompilationResult { + val issues = OpenMacroValidator.validate(document, registry) + if (issues.isNotEmpty()) { + return PlanCompilationResult.Invalid(issues) + } + + val permissions = mutableSetOf() + val triggers = compileBlocks(document.triggers, CapabilityLane.TRIGGER, permissions) + val conditions = compileBlocks(document.conditions, CapabilityLane.CONDITION, permissions) + val actions = compileBlocks(document.actions, CapabilityLane.ACTION, permissions) + + return PlanCompilationResult.Success( + RuntimePlan( + macroId = document.metadata.id, + sourceFingerprint = sourceFingerprint, + triggers = triggers, + conditions = conditions, + actions = actions, + requiredPermissions = permissions, + ), + ) + } + + private fun compileBlocks( + blocks: List, + expectedLane: CapabilityLane, + permissions: MutableSet, + ): List = blocks.map { block -> + val definition = checkNotNull(registry.find(block.type)) + check(definition.lane == expectedLane) + permissions += definition.requiredPermissions(block) + definition.compile(block) + } +} + +sealed interface PlanCompilationResult { + data class Success(val plan: RuntimePlan) : PlanCompilationResult + + data class Invalid(val issues: List) : PlanCompilationResult +} diff --git a/app/src/main/java/com/vibhor1102/zerobit/openmacro/validation/OpenMacroValidator.kt b/app/src/main/java/com/vibhor1102/zerobit/openmacro/validation/OpenMacroValidator.kt new file mode 100644 index 0000000..b540ed7 --- /dev/null +++ b/app/src/main/java/com/vibhor1102/zerobit/openmacro/validation/OpenMacroValidator.kt @@ -0,0 +1,171 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.vibhor1102.zerobit.openmacro.validation + +import com.vibhor1102.zerobit.openmacro.capability.CapabilityLane +import com.vibhor1102.zerobit.openmacro.capability.CapabilityRegistry +import com.vibhor1102.zerobit.openmacro.model.MacroBlock +import com.vibhor1102.zerobit.openmacro.model.OpenMacroDocument + +object OpenMacroValidator { + const val SUPPORTED_FORMAT = "openmacro/v0.1" + + private val stableIdPattern = Regex("^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$") + private val capabilityTypePattern = + Regex("^[a-z][a-z0-9-]*(?:\\.[a-z][a-z0-9-]*)+$") + + fun validate( + document: OpenMacroDocument, + registry: CapabilityRegistry? = null, + ): List = buildList { + if (document.format != SUPPORTED_FORMAT) { + add( + ValidationIssue( + path = "$.format", + code = "unsupported_format", + message = "Expected format '$SUPPORTED_FORMAT'.", + ), + ) + } + + validateStableId(document.metadata.id, "$.metadata.id", "Macro", this) + + if (document.metadata.name.isBlank()) { + add( + ValidationIssue( + path = "$.metadata.name", + code = "blank_name", + message = "Macro name must not be blank.", + ), + ) + } else if (document.metadata.name.length > 120) { + add( + ValidationIssue( + path = "$.metadata.name", + code = "name_too_long", + message = "Macro name must be 120 characters or fewer.", + ), + ) + } + + if (document.triggers.isEmpty()) { + add( + ValidationIssue( + path = "$.triggers", + code = "missing_trigger", + message = "A macro needs at least one trigger.", + ), + ) + } + + if (document.actions.isEmpty()) { + add( + ValidationIssue( + path = "$.actions", + code = "missing_action", + message = "A macro needs at least one action.", + ), + ) + } + + val locationsById = mutableMapOf() + validateBlocks( + section = "triggers", + expectedLane = CapabilityLane.TRIGGER, + blocks = document.triggers, + locationsById = locationsById, + registry = registry, + issues = this, + ) + validateBlocks( + section = "conditions", + expectedLane = CapabilityLane.CONDITION, + blocks = document.conditions, + locationsById = locationsById, + registry = registry, + issues = this, + ) + validateBlocks( + section = "actions", + expectedLane = CapabilityLane.ACTION, + blocks = document.actions, + locationsById = locationsById, + registry = registry, + issues = this, + ) + } + + private fun validateBlocks( + section: String, + expectedLane: CapabilityLane, + blocks: List, + locationsById: MutableMap, + registry: CapabilityRegistry?, + issues: MutableList, + ) { + blocks.forEachIndexed { index, block -> + val path = "$.$section[$index]" + validateStableId(block.id, "$path.id", "Block", issues) + + val earlierPath = locationsById.putIfAbsent(block.id, path) + if (earlierPath != null) { + issues += ValidationIssue( + path = "$path.id", + code = "duplicate_block_id", + message = "Block id '${block.id}' is already used at $earlierPath.", + ) + } + + if (!capabilityTypePattern.matches(block.type)) { + issues += ValidationIssue( + path = "$path.type", + code = "invalid_capability_type", + message = "Capability type must use a dotted name such as 'android.notification.show'.", + ) + return@forEachIndexed + } + + if (registry != null) { + val definition = registry.find(block.type) + when { + definition == null -> issues += ValidationIssue( + path = "$path.type", + code = "unsupported_capability", + message = "This app version does not support '${block.type}'.", + ) + + definition.lane != expectedLane -> issues += ValidationIssue( + path = "$path.type", + code = "wrong_lane", + message = "'${block.type}' belongs in ${definition.lane.name.lowercase()}, not $section.", + ) + + else -> issues += definition.validate(block, path) + } + } + } + } + + private fun validateStableId( + value: String, + path: String, + label: String, + issues: MutableList, + ) { + if (!stableIdPattern.matches(value)) { + issues += ValidationIssue( + path = path, + code = "invalid_id", + message = "$label id must be 1-64 lowercase letters, numbers, or hyphens.", + ) + } + } +} + +data class ValidationIssue( + val path: String, + val code: String, + val message: String, +) diff --git a/app/src/test/java/com/vibhor1102/zerobit/openmacro/capability/CapabilityRegistryTest.kt b/app/src/test/java/com/vibhor1102/zerobit/openmacro/capability/CapabilityRegistryTest.kt new file mode 100644 index 0000000..54b94bb --- /dev/null +++ b/app/src/test/java/com/vibhor1102/zerobit/openmacro/capability/CapabilityRegistryTest.kt @@ -0,0 +1,34 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.vibhor1102.zerobit.openmacro.capability + +import com.vibhor1102.zerobit.openmacro.capability.builtin.NotificationShowAction +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Test + +class CapabilityRegistryTest { + @Test + fun listsCapabilitiesByVisualLane() { + val registry = CapabilityRegistry.builtIn() + + assertEquals( + listOf("Power connected"), + registry.list(CapabilityLane.TRIGGER).map { it.displayName }, + ) + assertSame( + NotificationShowAction, + registry.find("android.notification.show"), + ) + } + + @Test(expected = IllegalArgumentException::class) + fun rejectsDuplicateCapabilityTypes() { + CapabilityRegistry.of( + NotificationShowAction, + NotificationShowAction, + ) + } +} diff --git a/app/src/test/java/com/vibhor1102/zerobit/openmacro/runtime/RuntimePlanCompilerTest.kt b/app/src/test/java/com/vibhor1102/zerobit/openmacro/runtime/RuntimePlanCompilerTest.kt new file mode 100644 index 0000000..d548dcb --- /dev/null +++ b/app/src/test/java/com/vibhor1102/zerobit/openmacro/runtime/RuntimePlanCompilerTest.kt @@ -0,0 +1,114 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.vibhor1102.zerobit.openmacro.runtime + +import com.vibhor1102.zerobit.openmacro.capability.AndroidPermission +import com.vibhor1102.zerobit.openmacro.capability.CapabilityRegistry +import com.vibhor1102.zerobit.openmacro.model.MacroBlock +import com.vibhor1102.zerobit.openmacro.model.MacroMetadata +import com.vibhor1102.zerobit.openmacro.model.MacroValue +import com.vibhor1102.zerobit.openmacro.model.OpenMacroDocument +import com.vibhor1102.zerobit.openmacro.validation.OpenMacroValidator +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class RuntimePlanCompilerTest { + private val registry = CapabilityRegistry.builtIn() + private val compiler = RuntimePlanCompiler(registry) + + @Test + fun compilesValidatedDocumentAndDiscoversPermissions() { + val result = compiler.compile(validDocument(), sourceFingerprint = "sha256:example") + + require(result is PlanCompilationResult.Success) + assertEquals("charger-greeting", result.plan.macroId) + assertEquals("sha256:example", result.plan.sourceFingerprint) + assertEquals( + setOf(AndroidPermission.POST_NOTIFICATIONS), + result.plan.requiredPermissions, + ) + assertTrue(result.plan.triggers.single() is RuntimeStep.ObservePowerConnected) + assertTrue(result.plan.conditions.single() is RuntimeStep.CheckDeviceUnlocked) + assertEquals( + RuntimeStep.ShowNotification( + blockId = "show-message", + title = "Charging started", + message = "The charger is connected.", + ), + result.plan.actions.single(), + ) + } + + @Test + fun refusesCapabilityPlacedInWrongLane() { + val document = validDocument().copy( + actions = listOf( + MacroBlock( + id = "wrong-place", + type = "android.power.connected", + ), + ), + ) + + val issues = OpenMacroValidator.validate(document, registry) + + assertEquals(listOf("wrong_lane"), issues.map { it.code }) + } + + @Test + fun refusesUnknownOrInvalidCapabilityConfiguration() { + val document = validDocument().copy( + actions = listOf( + MacroBlock( + id = "show-message", + type = "android.notification.show", + config = mapOf( + "title" to MacroValue.Text("Charging started"), + "surprise" to MacroValue.Boolean(true), + ), + ), + ), + ) + + val result = compiler.compile(document, sourceFingerprint = "sha256:invalid") + + require(result is PlanCompilationResult.Invalid) + assertEquals( + listOf("unknown_config", "missing_config"), + result.issues.map { it.code }, + ) + } + + private fun validDocument() = OpenMacroDocument( + format = OpenMacroValidator.SUPPORTED_FORMAT, + metadata = MacroMetadata( + id = "charger-greeting", + name = "Charger greeting", + ), + triggers = listOf( + MacroBlock( + id = "charger-connected", + type = "android.power.connected", + ), + ), + conditions = listOf( + MacroBlock( + id = "device-is-unlocked", + type = "android.device.unlocked", + ), + ), + actions = listOf( + MacroBlock( + id = "show-message", + type = "android.notification.show", + config = mapOf( + "title" to MacroValue.Text("Charging started"), + "message" to MacroValue.Text("The charger is connected."), + ), + ), + ), + ) +} diff --git a/app/src/test/java/com/vibhor1102/zerobit/openmacro/validation/OpenMacroValidatorTest.kt b/app/src/test/java/com/vibhor1102/zerobit/openmacro/validation/OpenMacroValidatorTest.kt new file mode 100644 index 0000000..1019886 --- /dev/null +++ b/app/src/test/java/com/vibhor1102/zerobit/openmacro/validation/OpenMacroValidatorTest.kt @@ -0,0 +1,76 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.vibhor1102.zerobit.openmacro.validation + +import com.vibhor1102.zerobit.openmacro.model.MacroBlock +import com.vibhor1102.zerobit.openmacro.model.MacroMetadata +import com.vibhor1102.zerobit.openmacro.model.OpenMacroDocument +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class OpenMacroValidatorTest { + @Test + fun acceptsSmallValidMacro() { + val document = validDocument() + + assertTrue(OpenMacroValidator.validate(document).isEmpty()) + } + + @Test + fun rejectsDuplicateIdsAcrossSections() { + val document = validDocument().copy( + conditions = listOf( + MacroBlock( + id = "show-message", + type = "android.device.unlocked", + ), + ), + ) + + val issues = OpenMacroValidator.validate(document) + + assertEquals( + listOf("duplicate_block_id"), + issues.map { it.code }, + ) + } + + @Test + fun reportsMissingRequiredExecutionParts() { + val document = validDocument().copy( + triggers = emptyList(), + actions = emptyList(), + ) + + val issues = OpenMacroValidator.validate(document) + + assertEquals( + listOf("missing_trigger", "missing_action"), + issues.map { it.code }, + ) + } + + private fun validDocument() = OpenMacroDocument( + format = OpenMacroValidator.SUPPORTED_FORMAT, + metadata = MacroMetadata( + id = "charger-greeting", + name = "Charger greeting", + ), + triggers = listOf( + MacroBlock( + id = "charger-connected", + type = "android.power.connected", + ), + ), + conditions = emptyList(), + actions = listOf( + MacroBlock( + id = "show-message", + type = "android.notification.show", + ), + ), + ) +} diff --git a/docs/architecture/openmacro-foundation.md b/docs/architecture/openmacro-foundation.md new file mode 100644 index 0000000..7738f60 --- /dev/null +++ b/docs/architecture/openmacro-foundation.md @@ -0,0 +1,109 @@ +# OpenMacro foundation + +OpenMacro is a declarative automation format, not a general-purpose programming +language. Its first authoring syntax is a strict subset of YAML. + +## Core rule + +The visual editor and source editor are two views of the same macro: + +```text +YAML source <-> parser/formatter <-> OpenMacro model <-> visual editor + | + v + validator and explainer + | + v + approved runtime plan +``` + +Neither editor owns a second representation. A change made in either view must +pass through the same model and validator before it can become runnable. + +## Why YAML, rather than a new language + +YAML is readable, produces useful Git diffs, supports comments, and is familiar +to current AI coding tools. A new language would require ZeroBit to build and +maintain a parser, formatter, syntax highlighter, error recovery, editor +integration, and AI conventions before it adds any automation value. + +OpenMacro uses only YAML 1.2 data values: objects, lists, strings, numbers, +booleans, and null. The parser must reject duplicate keys, aliases, anchors, +merge keys, custom tags, and implicit YAML 1.1 values such as `yes` and `no`. +These restrictions keep files unsurprising and allow the same data to be +checked by JSON Schema. + +The normal extension is a new validated capability type, not new language +syntax. General scripting can be evaluated later as an explicit, sandboxed +capability; it must not become an invisible escape hatch in the normal runtime. + +## Version 0.1 semantics + +- One file contains one macro. +- `metadata.id` and every block `id` are stable identifiers. Display names may + change without breaking logs, references, or Git history. +- Multiple triggers mean “any trigger may start the macro.” +- All conditions must pass. +- Actions run in listed order. +- Runtime enabled/disabled state, secrets, approval state, and logs do not live + in the macro file. +- Capability types use dotted names, for example + `android.notification.show`. +- Each capability owns the schema, UI form, explanation, permission + requirements, and runtime implementation for its `config`. + +## UI and code equivalence + +A macro's normal visual outline has only three lanes: Triggers, Conditions, and +Actions. Keep that outline clean even when individual capabilities are +powerful. Complexity belongs inside a block's focused setup screen, where +advanced options can be progressively revealed without turning the macro +overview into a programming canvas. + +A capability is eligible for the normal visual editor only when its registry +entry provides: + +1. a configuration schema; +2. a visual editor; +3. a human-readable explanation; +4. validation and permission discovery; and +5. a deterministic runtime implementation. + +The source editor may eventually represent capabilities that the installed app +cannot visually edit. The app must preserve their source, label them as +unsupported, and refuse to enable the macro. It must never silently delete, +approximate, or execute an unknown block. + +To preserve user-owned comments and formatting, the future YAML adapter should +retain a syntax tree and source ranges. Visual changes should patch the +affected field where possible. A canonical formatter is the explicit fallback, +not an automatic side effect of merely opening a file. + +## Safe edit flow + +1. Keep the last approved document and its content hash. +2. Parse a proposed source or visual edit. +3. Validate the document structure and each registered capability. +4. Explain the behavioral and permission difference from the approved version. +5. Ask for approval when runnable behavior changes. +6. Compile the approved model into an immutable runtime plan. +7. Execute only that plan and record bounded diagnostic events. + +The runtime never interprets YAML directly and never asks AI what a block means. + +## First implementation boundary + +The initial implementation contains: + +- the format-neutral document model and validator; +- a capability registry divided into the three visual lanes; +- three small built-in capabilities covering a trigger, condition, and action; +- field descriptions that a visual form can render; +- capability validation, explanations, and permission discovery; and +- compilation into immutable runtime instructions. + +This proves that one capability definition can drive the code shape, future +form, validation, explanation, permissions, and runtime plan without premature +plugin machinery. The next slice should add YAML parsing and canonical writing, +including duplicate-key rejection and source-preservation tests, before either +editor becomes responsible for real user files. diff --git a/examples/charger-greeting.openmacro.yaml b/examples/charger-greeting.openmacro.yaml new file mode 100644 index 0000000..60afc28 --- /dev/null +++ b/examples/charger-greeting.openmacro.yaml @@ -0,0 +1,21 @@ +format: openmacro/v0.1 + +metadata: + id: charger-greeting + name: Charger greeting + description: Show a message when the phone starts charging. + +triggers: + - id: charger-connected + type: android.power.connected + +conditions: + - id: device-is-unlocked + type: android.device.unlocked + +actions: + - id: show-message + type: android.notification.show + config: + title: Charging started + message: The charger is connected. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4d64f67..e47ab03 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,6 +3,7 @@ agp = "8.13.2" kotlin = "2.2.21" activityCompose = "1.13.0" composeBom = "2026.06.00" +junit = "4.13.2" [libraries] androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" } @@ -11,9 +12,9 @@ androidx-compose-material3 = { module = "androidx.compose.material3:material3" } androidx-compose-ui = { module = "androidx.compose.ui:ui" } androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" } androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" } +junit = { module = "junit:junit", version.ref = "junit" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } - diff --git a/schemas/openmacro/v0.1/openmacro.schema.json b/schemas/openmacro/v0.1/openmacro.schema.json new file mode 100644 index 0000000..cb3ae4c --- /dev/null +++ b/schemas/openmacro/v0.1/openmacro.schema.json @@ -0,0 +1,90 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://openmacro.dev/schema/v0.1/openmacro.schema.json", + "title": "OpenMacro v0.1", + "type": "object", + "additionalProperties": false, + "required": [ + "format", + "metadata", + "triggers", + "actions" + ], + "properties": { + "format": { + "const": "openmacro/v0.1" + }, + "metadata": { + "$ref": "#/$defs/metadata" + }, + "triggers": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/block" + } + }, + "conditions": { + "type": "array", + "default": [], + "items": { + "$ref": "#/$defs/block" + } + }, + "actions": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/block" + } + } + }, + "$defs": { + "stableId": { + "type": "string", + "pattern": "^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$" + }, + "metadata": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "name" + ], + "properties": { + "id": { + "$ref": "#/$defs/stableId" + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 120 + }, + "description": { + "type": "string" + } + } + }, + "block": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "type" + ], + "properties": { + "id": { + "$ref": "#/$defs/stableId" + }, + "type": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*(?:\\.[a-z][a-z0-9-]*)+$" + }, + "config": { + "type": "object", + "default": {} + } + } + } + } +} From 67e899b4665fa2b915bd48068fa659aea6910671 Mon Sep 17 00:00:00 2001 From: Vibhor Goel Date: Thu, 25 Jun 2026 14:05:23 +0530 Subject: [PATCH 2/8] Add strict OpenMacro YAML adapter --- THIRD_PARTY_NOTICES.md | 14 + app/build.gradle.kts | 1 + .../openmacro/source/OpenMacroSource.kt | 37 ++ .../openmacro/source/OpenMacroYamlReader.kt | 391 ++++++++++++++++++ .../openmacro/source/OpenMacroYamlWriter.kt | 127 ++++++ .../source/OpenMacroYamlReaderTest.kt | 173 ++++++++ .../source/OpenMacroYamlWriterTest.kt | 85 ++++ docs/architecture/openmacro-foundation.md | 25 +- gradle/libs.versions.toml | 2 + 9 files changed, 847 insertions(+), 8 deletions(-) create mode 100644 THIRD_PARTY_NOTICES.md create mode 100644 app/src/main/java/com/vibhor1102/zerobit/openmacro/source/OpenMacroSource.kt create mode 100644 app/src/main/java/com/vibhor1102/zerobit/openmacro/source/OpenMacroYamlReader.kt create mode 100644 app/src/main/java/com/vibhor1102/zerobit/openmacro/source/OpenMacroYamlWriter.kt create mode 100644 app/src/test/java/com/vibhor1102/zerobit/openmacro/source/OpenMacroYamlReaderTest.kt create mode 100644 app/src/test/java/com/vibhor1102/zerobit/openmacro/source/OpenMacroYamlWriterTest.kt diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..47b881b --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,14 @@ +# Third-party notices + +ZeroBit uses the following third-party software: + +## SnakeYAML Engine + +- Project: +- Copyright: SnakeYAML contributors +- License: Apache License 2.0 +- License text: + +SnakeYAML Engine is used to parse the restricted YAML 1.2 syntax accepted by +OpenMacro. ZeroBit adds its own validation and rejects YAML features outside +that subset. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ae66b44..c602ada 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -61,6 +61,7 @@ dependencies { implementation(libs.androidx.compose.material3) implementation(libs.androidx.compose.ui) implementation(libs.androidx.compose.ui.tooling.preview) + implementation(libs.snakeyaml.engine) testImplementation(libs.junit) diff --git a/app/src/main/java/com/vibhor1102/zerobit/openmacro/source/OpenMacroSource.kt b/app/src/main/java/com/vibhor1102/zerobit/openmacro/source/OpenMacroSource.kt new file mode 100644 index 0000000..a03d2be --- /dev/null +++ b/app/src/main/java/com/vibhor1102/zerobit/openmacro/source/OpenMacroSource.kt @@ -0,0 +1,37 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.vibhor1102.zerobit.openmacro.source + +import com.vibhor1102.zerobit.openmacro.model.OpenMacroDocument + +/** + * Keeps the exact user-owned source beside its decoded meaning. + * + * Reading a file never reformats it. The canonical writer is used only for a + * new document or when the user explicitly chooses to format the source. + */ +data class OpenMacroSource( + val document: OpenMacroDocument, + val originalText: String, + val fingerprint: String, +) + +sealed interface OpenMacroSourceResult { + data class Success( + val source: OpenMacroSource, + ) : OpenMacroSourceResult + + data class Failure( + val issues: List, + ) : OpenMacroSourceResult +} + +data class SourceIssue( + val code: String, + val message: String, + val path: String = "$", + val line: Int? = null, + val column: Int? = null, +) diff --git a/app/src/main/java/com/vibhor1102/zerobit/openmacro/source/OpenMacroYamlReader.kt b/app/src/main/java/com/vibhor1102/zerobit/openmacro/source/OpenMacroYamlReader.kt new file mode 100644 index 0000000..a5cb17d --- /dev/null +++ b/app/src/main/java/com/vibhor1102/zerobit/openmacro/source/OpenMacroYamlReader.kt @@ -0,0 +1,391 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.vibhor1102.zerobit.openmacro.source + +import com.vibhor1102.zerobit.openmacro.model.MacroBlock +import com.vibhor1102.zerobit.openmacro.model.MacroMetadata +import com.vibhor1102.zerobit.openmacro.model.MacroValue +import com.vibhor1102.zerobit.openmacro.model.OpenMacroDocument +import java.math.BigDecimal +import java.security.MessageDigest +import org.snakeyaml.engine.v2.api.LoadSettings +import org.snakeyaml.engine.v2.api.lowlevel.Compose +import org.snakeyaml.engine.v2.api.lowlevel.Parse +import org.snakeyaml.engine.v2.common.ScalarStyle +import org.snakeyaml.engine.v2.events.AliasEvent +import org.snakeyaml.engine.v2.events.CollectionEndEvent +import org.snakeyaml.engine.v2.events.CollectionStartEvent +import org.snakeyaml.engine.v2.events.DocumentStartEvent +import org.snakeyaml.engine.v2.events.Event +import org.snakeyaml.engine.v2.events.ScalarEvent +import org.snakeyaml.engine.v2.exceptions.MarkedYamlEngineException +import org.snakeyaml.engine.v2.exceptions.YamlEngineException +import org.snakeyaml.engine.v2.nodes.MappingNode +import org.snakeyaml.engine.v2.nodes.Node +import org.snakeyaml.engine.v2.nodes.ScalarNode +import org.snakeyaml.engine.v2.nodes.SequenceNode +import org.snakeyaml.engine.v2.nodes.Tag +import org.snakeyaml.engine.v2.schema.JsonSchema + +object OpenMacroYamlReader { + const val MAX_SOURCE_CODE_POINTS = 256 * 1024 + const val MAX_NESTING_DEPTH = 64 + + private val legacyYamlBooleans = setOf( + "y", + "yes", + "n", + "no", + "on", + "off", + ) + + private val settings: LoadSettings = LoadSettings.builder() + .setLabel("OpenMacro source") + .setSchema(JsonSchema()) + .setAllowDuplicateKeys(false) + .setAllowRecursiveKeys(false) + .setAllowNonScalarKeys(false) + .setMaxAliasesForCollections(0) + .setCodePointLimit(MAX_SOURCE_CODE_POINTS) + .setUseMarks(true) + .build() + + fun read(sourceText: String): OpenMacroSourceResult { + if (sourceText.codePointCount(0, sourceText.length) > MAX_SOURCE_CODE_POINTS) { + return failure( + code = "source_too_large", + message = "OpenMacro files may contain at most $MAX_SOURCE_CODE_POINTS Unicode characters.", + ) + } + + return try { + val eventIssue = inspectEvents(sourceText) + if (eventIssue != null) { + OpenMacroSourceResult.Failure(listOf(eventIssue)) + } else { + val root = Compose(settings).composeString(sourceText).orElse(null) + ?: return failure("empty_source", "The OpenMacro file is empty.") + val document = decodeDocument(root) + OpenMacroSourceResult.Success( + OpenMacroSource( + document = document, + originalText = sourceText, + fingerprint = sha256(sourceText), + ), + ) + } + } catch (problem: SourceProblem) { + OpenMacroSourceResult.Failure(listOf(problem.issue)) + } catch (problem: MarkedYamlEngineException) { + val mark = problem.problemMark.orElse(null) + OpenMacroSourceResult.Failure( + listOf( + SourceIssue( + code = "invalid_yaml", + message = problem.problem ?: "The YAML source is invalid.", + line = mark?.line?.plus(1), + column = mark?.column?.plus(1), + ), + ), + ) + } catch (problem: YamlEngineException) { + failure( + code = "invalid_yaml", + message = problem.message ?: "The YAML source is invalid.", + ) + } + } + + private fun inspectEvents(sourceText: String): SourceIssue? { + var documentCount = 0 + var depth = 0 + + for (event in Parse(settings).parseString(sourceText)) { + when (event) { + is DocumentStartEvent -> { + documentCount += 1 + if (documentCount > 1) { + return event.issue( + code = "multiple_documents", + message = "One OpenMacro file may contain only one YAML document.", + ) + } + if (event.specVersion.isPresent || event.tags.isNotEmpty()) { + return event.issue( + code = "yaml_directive_not_allowed", + message = "OpenMacro does not allow YAML or tag directives.", + ) + } + } + + is AliasEvent -> return event.issue( + code = "alias_not_allowed", + message = "OpenMacro does not allow YAML aliases.", + ) + + is CollectionStartEvent -> { + if (event.anchor.isPresent) { + return event.issue( + code = "anchor_not_allowed", + message = "OpenMacro does not allow YAML anchors.", + ) + } + if (event.tag.isPresent) { + return event.issue( + code = "tag_not_allowed", + message = "OpenMacro does not allow explicit YAML tags.", + ) + } + depth += 1 + if (depth > MAX_NESTING_DEPTH) { + return event.issue( + code = "nesting_too_deep", + message = "OpenMacro YAML may be nested at most $MAX_NESTING_DEPTH levels.", + ) + } + } + + is CollectionEndEvent -> depth -= 1 + + is ScalarEvent -> { + if (event.anchor.isPresent) { + return event.issue( + code = "anchor_not_allowed", + message = "OpenMacro does not allow YAML anchors.", + ) + } + if (event.tag.isPresent) { + return event.issue( + code = "tag_not_allowed", + message = "OpenMacro does not allow explicit YAML tags.", + ) + } + if ( + event.scalarStyle == ScalarStyle.PLAIN && + event.value.lowercase() in legacyYamlBooleans + ) { + return event.issue( + code = "ambiguous_scalar", + message = "'${event.value}' is ambiguous YAML. Quote it if you mean text.", + ) + } + } + } + } + return null + } + + private fun decodeDocument(root: Node): OpenMacroDocument { + val map = root.mapping("$") + map.requireOnlyKeys( + allowed = setOf("format", "metadata", "triggers", "conditions", "actions"), + path = "$", + ) + + return OpenMacroDocument( + format = map.required("format", "$").text("$.format"), + metadata = decodeMetadata(map.required("metadata", "$")), + triggers = decodeBlocks(map.required("triggers", "$"), "$.triggers"), + conditions = map.optional("conditions")?.let { + decodeBlocks(it, "$.conditions") + }.orEmpty(), + actions = decodeBlocks(map.required("actions", "$"), "$.actions"), + ) + } + + private fun decodeMetadata(node: Node): MacroMetadata { + val path = "$.metadata" + val map = node.mapping(path) + map.requireOnlyKeys(setOf("id", "name", "description"), path) + return MacroMetadata( + id = map.required("id", path).text("$path.id"), + name = map.required("name", path).text("$path.name"), + description = map.optional("description")?.text("$path.description"), + ) + } + + private fun decodeBlocks(node: Node, path: String): List = + node.sequence(path).mapIndexed { index, blockNode -> + val blockPath = "$path[$index]" + val map = blockNode.mapping(blockPath) + map.requireOnlyKeys(setOf("id", "type", "config"), blockPath) + MacroBlock( + id = map.required("id", blockPath).text("$blockPath.id"), + type = map.required("type", blockPath).text("$blockPath.type"), + config = map.optional("config")?.let { + decodeConfigMap(it, "$blockPath.config") + }.orEmpty(), + ) + } + + private fun decodeConfigMap(node: Node, path: String): Map = + node.mapping(path).entries.associate { (key, value) -> + key to decodeMacroValue(value, "$path.$key") + } + + private fun decodeMacroValue(node: Node, path: String): MacroValue = when (node) { + is ScalarNode -> when (node.tag) { + Tag.STR -> MacroValue.Text(node.value) + Tag.INT, Tag.FLOAT -> { + val number = node.value.toBigDecimalOrNull() + ?: node.problem(path, "invalid_number", "Value must be a finite JSON number.") + MacroValue.Number(number) + } + Tag.BOOL -> MacroValue.Boolean(node.value.toBooleanStrict()) + Tag.NULL -> MacroValue.Null + else -> node.problem( + path, + "unsupported_value", + "Only text, numbers, booleans, null, lists, and objects are allowed.", + ) + } + + is SequenceNode -> MacroValue.ListValue( + node.value.mapIndexed { index, child -> + decodeMacroValue(child, "$path[$index]") + }, + ) + + is MappingNode -> MacroValue.ObjectValue(decodeConfigMap(node, path)) + + else -> node.problem( + path, + "unsupported_value", + "Only text, numbers, booleans, null, lists, and objects are allowed.", + ) + } + + private fun Node.mapping(path: String): SourceMap { + if (this !is MappingNode) { + problem(path, "expected_object", "Expected an object at $path.") + } + + val result = linkedMapOf() + for (tuple in value) { + val keyNode = tuple.keyNode + if (keyNode !is ScalarNode || keyNode.tag != Tag.STR) { + keyNode.problem( + path, + "invalid_key", + "Object keys must be plain text.", + ) + } + val key = keyNode.value + if (key == "<<") { + keyNode.problem( + path, + "merge_key_not_allowed", + "OpenMacro does not allow YAML merge keys.", + ) + } + if (result.put(key, tuple.valueNode) != null) { + keyNode.problem( + path, + "duplicate_key", + "The key '$key' appears more than once.", + ) + } + } + return SourceMap(result, this) + } + + private fun Node.sequence(path: String): List { + if (this !is SequenceNode) { + problem(path, "expected_list", "Expected a list at $path.") + } + return value + } + + private fun Node.text(path: String): String { + if (this !is ScalarNode || tag != Tag.STR) { + problem(path, "expected_text", "Expected text at $path.") + } + return value + } + + private fun Node.problem( + path: String, + code: String, + message: String, + ): Nothing { + val mark = startMark.orElse(null) + throw SourceProblem( + SourceIssue( + code = code, + message = message, + path = path, + line = mark?.line?.plus(1), + column = mark?.column?.plus(1), + ), + ) + } + + private fun Event.issue(code: String, message: String): SourceIssue { + val mark = startMark.orElse(null) + return SourceIssue( + code = code, + message = message, + line = mark?.line?.plus(1), + column = mark?.column?.plus(1), + ) + } + + private fun sha256(text: String): String { + val digest = MessageDigest.getInstance("SHA-256") + .digest(text.toByteArray(Charsets.UTF_8)) + return "sha256:" + digest.joinToString("") { byte -> "%02x".format(byte) } + } + + private fun failure(code: String, message: String) = + OpenMacroSourceResult.Failure(listOf(SourceIssue(code = code, message = message))) + + private data class SourceMap( + val entries: Map, + val node: Node, + ) { + fun required(key: String, path: String): Node = + entries[key] ?: fail( + node = node, + path = "$path.$key", + code = "missing_key", + message = "Required key '$key' is missing.", + ) + + fun optional(key: String): Node? = entries[key] + + fun requireOnlyKeys(allowed: Set, path: String) { + val unknown = entries.keys.firstOrNull { it !in allowed } ?: return + fail( + node = entries.getValue(unknown), + path = "$path.$unknown", + code = "unknown_key", + message = "Key '$unknown' is not allowed at $path.", + ) + } + + private fun fail( + node: Node, + path: String, + code: String, + message: String, + ): Nothing { + val mark = node.startMark.orElse(null) + throw SourceProblem( + SourceIssue( + code = code, + message = message, + path = path, + line = mark?.line?.plus(1), + column = mark?.column?.plus(1), + ), + ) + } + } + + private class SourceProblem( + val issue: SourceIssue, + ) : RuntimeException(issue.message) +} diff --git a/app/src/main/java/com/vibhor1102/zerobit/openmacro/source/OpenMacroYamlWriter.kt b/app/src/main/java/com/vibhor1102/zerobit/openmacro/source/OpenMacroYamlWriter.kt new file mode 100644 index 0000000..d15d75c --- /dev/null +++ b/app/src/main/java/com/vibhor1102/zerobit/openmacro/source/OpenMacroYamlWriter.kt @@ -0,0 +1,127 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.vibhor1102.zerobit.openmacro.source + +import com.vibhor1102.zerobit.openmacro.model.MacroBlock +import com.vibhor1102.zerobit.openmacro.model.MacroValue +import com.vibhor1102.zerobit.openmacro.model.OpenMacroDocument + +/** + * Produces stable, Git-friendly source for new files and explicit formatting. + */ +object OpenMacroYamlWriter { + fun write(document: OpenMacroDocument): String = buildString { + append("format: ") + appendQuoted(document.format) + append("\n\nmetadata:\n") + append(" id: ") + appendQuoted(document.metadata.id) + append("\n name: ") + appendQuoted(document.metadata.name) + document.metadata.description?.let { + append("\n description: ") + appendQuoted(it) + } + append("\n\n") + appendBlocks("triggers", document.triggers) + append("\n") + appendBlocks("conditions", document.conditions) + append("\n") + appendBlocks("actions", document.actions) + } + + private fun StringBuilder.appendBlocks( + name: String, + blocks: List, + ) { + if (blocks.isEmpty()) { + append("$name: []\n") + return + } + + append("$name:\n") + blocks.forEach { block -> + append(" - id: ") + appendQuoted(block.id) + append("\n type: ") + appendQuoted(block.type) + if (block.config.isNotEmpty()) { + append("\n config:\n") + appendObject(block.config, indent = 6) + } else { + append("\n") + } + } + } + + private fun StringBuilder.appendObject( + values: Map, + indent: Int, + ) { + values.toSortedMap().forEach { (key, value) -> + append(" ".repeat(indent)) + appendQuoted(key) + append(":") + appendValue(value, indent) + } + } + + private fun StringBuilder.appendValue(value: MacroValue, indent: Int) { + when (value) { + is MacroValue.Text -> { + append(" ") + appendQuoted(value.value) + append("\n") + } + is MacroValue.Number -> append(" ${value.value.toPlainString()}\n") + is MacroValue.Boolean -> append(" ${value.value}\n") + is MacroValue.ListValue -> { + if (value.values.isEmpty()) { + append(" []\n") + } else { + append("\n") + value.values.forEach { child -> + append(" ".repeat(indent + 2)) + append("-") + appendValue(child, indent + 2) + } + } + } + is MacroValue.ObjectValue -> { + if (value.values.isEmpty()) { + append(" {}\n") + } else { + append("\n") + appendObject(value.values, indent + 2) + } + } + MacroValue.Null -> append(" null\n") + } + } + + private fun StringBuilder.appendQuoted(value: String) { + append('"') + value.forEach { character -> + when (character) { + '"' -> append("\\\"") + '\\' -> append("\\\\") + '\b' -> append("\\b") + '\u000C' -> append("\\f") + '\n' -> append("\\n") + '\r' -> append("\\r") + '\t' -> append("\\t") + else -> { + if (character.code < 0x20) { + append("\\u") + append(character.code.toString(16).padStart(4, '0')) + } else { + append(character) + } + } + } + } + append('"') + } +} diff --git a/app/src/test/java/com/vibhor1102/zerobit/openmacro/source/OpenMacroYamlReaderTest.kt b/app/src/test/java/com/vibhor1102/zerobit/openmacro/source/OpenMacroYamlReaderTest.kt new file mode 100644 index 0000000..8180ce6 --- /dev/null +++ b/app/src/test/java/com/vibhor1102/zerobit/openmacro/source/OpenMacroYamlReaderTest.kt @@ -0,0 +1,173 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.vibhor1102.zerobit.openmacro.source + +import com.vibhor1102.zerobit.openmacro.model.MacroValue +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class OpenMacroYamlReaderTest { + @Test + fun readsStrictYamlAndPreservesTheExactSource() { + val text = """ + # This comment belongs to the user. + format: openmacro/v0.1 + metadata: + id: charger-greeting + name: Charger greeting + triggers: + - id: charger-connected + type: android.power.connected + conditions: [] + actions: + - id: show-message + type: android.notification.show + config: + title: Charging started + message: "The charger is connected." + attempts: 2 + quiet: false + optional: null + labels: + - home + - phone + """.trimIndent() + "\n" + + val result = OpenMacroYamlReader.read(text) + + require(result is OpenMacroSourceResult.Success) + assertEquals(text, result.source.originalText) + assertTrue(result.source.fingerprint.startsWith("sha256:")) + assertEquals("charger-greeting", result.source.document.metadata.id) + assertEquals( + MacroValue.Number("2".toBigDecimal()), + result.source.document.actions.single().config["attempts"], + ) + assertEquals( + MacroValue.ListValue( + listOf( + MacroValue.Text("home"), + MacroValue.Text("phone"), + ), + ), + result.source.document.actions.single().config["labels"], + ) + } + + @Test + fun rejectsYamlFeaturesOutsideTheOpenMacroSubset() { + assertIssue( + source = validSource().replace( + "title: Charging started", + "title: &shared Charging started\n other: *shared", + ), + expectedCode = "anchor_not_allowed", + ) + assertIssue( + source = validSource().replace( + "title: Charging started", + "title: !custom Charging started", + ), + expectedCode = "tag_not_allowed", + ) + assertIssue( + source = validSource() + "---\nformat: openmacro/v0.1\n", + expectedCode = "multiple_documents", + ) + assertIssue( + source = validSource().replace( + "name: Charger greeting", + "name: Charger greeting\n name: Duplicate", + ), + expectedCode = "duplicate_key", + ) + assertIssue( + source = validSource().replace( + "title: Charging started", + "<<: {title: Other}\n title: Charging started", + ), + expectedCode = "merge_key_not_allowed", + ) + assertIssue( + source = validSource().replace( + "title: Charging started", + "title: yes", + ), + expectedCode = "ambiguous_scalar", + ) + } + + @Test + fun rejectsUnknownStructureBeforeItCanBeSilentlyDiscarded() { + val source = validSource().replace( + "name: Charger greeting", + "name: Charger greeting\n mystery: hidden", + ) + + val result = OpenMacroYamlReader.read(source) + + require(result is OpenMacroSourceResult.Failure) + assertEquals("unknown_key", result.issues.single().code) + assertEquals("$.metadata.mystery", result.issues.single().path) + assertEquals(5, result.issues.single().line) + } + + @Test + fun requiresTextForIdentityFields() { + val source = validSource().replace( + "id: charger-greeting", + "id: 42", + ) + + assertIssue(source, expectedCode = "expected_text") + } + + @Test + fun boundsSourceSizeAndNestingBeforeDecoding() { + val oversized = "x".repeat(OpenMacroYamlReader.MAX_SOURCE_CODE_POINTS + 1) + val oversizedResult = OpenMacroYamlReader.read(oversized) + require(oversizedResult is OpenMacroSourceResult.Failure) + assertEquals("source_too_large", oversizedResult.issues.single().code) + + val nestedValue = buildString { + repeat(OpenMacroYamlReader.MAX_NESTING_DEPTH + 1) { append("[") } + append("null") + repeat(OpenMacroYamlReader.MAX_NESTING_DEPTH + 1) { append("]") } + } + val deeplyNested = validSource().replace( + "title: Charging started", + "title: $nestedValue", + ) + assertIssue(deeplyNested, expectedCode = "nesting_too_deep") + } + + private fun assertIssue(source: String, expectedCode: String) { + val result = OpenMacroYamlReader.read(source) + require(result is OpenMacroSourceResult.Failure) { + "Expected '$expectedCode', but source was accepted." + } + assertEquals(expectedCode, result.issues.single().code) + assertTrue(result.issues.single().line != null) + assertTrue(result.issues.single().column != null) + } + + private fun validSource() = """ + format: openmacro/v0.1 + metadata: + id: charger-greeting + name: Charger greeting + triggers: + - id: charger-connected + type: android.power.connected + conditions: [] + actions: + - id: show-message + type: android.notification.show + config: + title: Charging started + message: The charger is connected. + """.trimIndent() + "\n" +} diff --git a/app/src/test/java/com/vibhor1102/zerobit/openmacro/source/OpenMacroYamlWriterTest.kt b/app/src/test/java/com/vibhor1102/zerobit/openmacro/source/OpenMacroYamlWriterTest.kt new file mode 100644 index 0000000..467766e --- /dev/null +++ b/app/src/test/java/com/vibhor1102/zerobit/openmacro/source/OpenMacroYamlWriterTest.kt @@ -0,0 +1,85 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.vibhor1102.zerobit.openmacro.source + +import com.vibhor1102.zerobit.openmacro.model.MacroBlock +import com.vibhor1102.zerobit.openmacro.model.MacroMetadata +import com.vibhor1102.zerobit.openmacro.model.MacroValue +import com.vibhor1102.zerobit.openmacro.model.OpenMacroDocument +import com.vibhor1102.zerobit.openmacro.validation.OpenMacroValidator +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class OpenMacroYamlWriterTest { + @Test + fun writesStableSourceThatReadsBackWithoutMeaningChanges() { + val document = document() + + val first = OpenMacroYamlWriter.write(document) + val second = OpenMacroYamlWriter.write(document) + val parsed = OpenMacroYamlReader.read(first) + + assertEquals(first, second) + assertTrue(first.endsWith("\n")) + require(parsed is OpenMacroSourceResult.Success) + assertEquals(document, parsed.source.document) + } + + @Test + fun quotesEveryStringSoYamlLookingTextStaysText() { + val document = document().copy( + metadata = MacroMetadata( + id = "quoted-text", + name = "yes", + description = "Line one\nLine \"two\"", + ), + ) + + val yaml = OpenMacroYamlWriter.write(document) + val parsed = OpenMacroYamlReader.read(yaml) + + assertTrue(yaml.contains("name: \"yes\"")) + assertTrue(yaml.contains("description: \"Line one\\nLine \\\"two\\\"\"")) + require(parsed is OpenMacroSourceResult.Success) + assertEquals(document, parsed.source.document) + } + + private fun document() = OpenMacroDocument( + format = OpenMacroValidator.SUPPORTED_FORMAT, + metadata = MacroMetadata( + id = "charger-greeting", + name = "Charger greeting", + ), + triggers = listOf( + MacroBlock( + id = "charger-connected", + type = "android.power.connected", + ), + ), + conditions = emptyList(), + actions = listOf( + MacroBlock( + id = "show-message", + type = "android.notification.show", + config = linkedMapOf( + "title" to MacroValue.Text("Charging started"), + "message" to MacroValue.Text("The charger is connected."), + "priority" to MacroValue.Number("1.50".toBigDecimal()), + "silent" to MacroValue.Boolean(false), + "metadata" to MacroValue.ObjectValue( + mapOf("source" to MacroValue.Text("ZeroBit")), + ), + "labels" to MacroValue.ListValue( + listOf( + MacroValue.Text("home"), + MacroValue.Null, + ), + ), + ), + ), + ), + ) +} diff --git a/docs/architecture/openmacro-foundation.md b/docs/architecture/openmacro-foundation.md index 7738f60..98b0b6f 100644 --- a/docs/architecture/openmacro-foundation.md +++ b/docs/architecture/openmacro-foundation.md @@ -74,10 +74,12 @@ cannot visually edit. The app must preserve their source, label them as unsupported, and refuse to enable the macro. It must never silently delete, approximate, or execute an unknown block. -To preserve user-owned comments and formatting, the future YAML adapter should -retain a syntax tree and source ranges. Visual changes should patch the -affected field where possible. A canonical formatter is the explicit fallback, -not an automatic side effect of merely opening a file. +The YAML adapter retains the exact original source alongside the decoded model, +so merely reading a file never changes user-owned comments or formatting. +Visual changes should eventually patch the affected source range where +possible. The canonical writer is the explicit fallback for new files or a +user-requested format operation, not an automatic side effect of opening a +file. ## Safe edit flow @@ -100,10 +102,17 @@ The initial implementation contains: - three small built-in capabilities covering a trigger, condition, and action; - field descriptions that a visual form can render; - capability validation, explanations, and permission discovery; and -- compilation into immutable runtime instructions. +- compilation into immutable runtime instructions; +- strict YAML 1.2 reading with source locations and bounded input; and +- stable canonical writing without silently reformatting source on read. This proves that one capability definition can drive the code shape, future form, validation, explanation, permissions, and runtime plan without premature -plugin machinery. The next slice should add YAML parsing and canonical writing, -including duplicate-key rejection and source-preservation tests, before either -editor becomes responsible for real user files. +plugin machinery. The source adapter rejects aliases, anchors, merge keys, +custom tags, directives, duplicate keys, multiple documents, ambiguous YAML +1.1 booleans, excessive nesting, and oversized files. + +The next slice should connect source parsing, capability validation, +explanation, and runtime compilation into one proposal pipeline. That pipeline +will become the shared boundary used by both editors before approval storage or +runtime services are introduced. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e47ab03..6c36ff5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,6 +4,7 @@ kotlin = "2.2.21" activityCompose = "1.13.0" composeBom = "2026.06.00" junit = "4.13.2" +snakeYamlEngine = "3.0.1" [libraries] androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" } @@ -13,6 +14,7 @@ androidx-compose-ui = { module = "androidx.compose.ui:ui" } androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" } androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" } junit = { module = "junit:junit", version.ref = "junit" } +snakeyaml-engine = { module = "org.snakeyaml:snakeyaml-engine", version.ref = "snakeYamlEngine" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } From 3f20781364ce93c6d1ca2028aa98906c9a71212b Mon Sep 17 00:00:00 2001 From: Vibhor Goel Date: Thu, 25 Jun 2026 14:08:35 +0530 Subject: [PATCH 3/8] Fix OpenMacro config decoding --- .../zerobit/openmacro/source/OpenMacroYamlReader.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/vibhor1102/zerobit/openmacro/source/OpenMacroYamlReader.kt b/app/src/main/java/com/vibhor1102/zerobit/openmacro/source/OpenMacroYamlReader.kt index a5cb17d..8cd12d2 100644 --- a/app/src/main/java/com/vibhor1102/zerobit/openmacro/source/OpenMacroYamlReader.kt +++ b/app/src/main/java/com/vibhor1102/zerobit/openmacro/source/OpenMacroYamlReader.kt @@ -222,8 +222,8 @@ object OpenMacroYamlReader { } private fun decodeConfigMap(node: Node, path: String): Map = - node.mapping(path).entries.associate { (key, value) -> - key to decodeMacroValue(value, "$path.$key") + node.mapping(path).entries.mapValues { (key, value) -> + decodeMacroValue(value, "$path.$key") } private fun decodeMacroValue(node: Node, path: String): MacroValue = when (node) { From 7b98c6e553e95a873f74e1952ee3dc7db17c039d Mon Sep 17 00:00:00 2001 From: Vibhor Goel Date: Thu, 25 Jun 2026 14:14:54 +0530 Subject: [PATCH 4/8] Add OpenMacro proposal pipeline --- .../openmacro/proposal/MacroExplanation.kt | 27 ++ .../openmacro/proposal/OpenMacroProposal.kt | 47 ++++ .../proposal/OpenMacroProposalPipeline.kt | 239 ++++++++++++++++++ .../openmacro/proposal/ProposalComparison.kt | 34 +++ .../proposal/OpenMacroProposalPipelineTest.kt | 177 +++++++++++++ docs/architecture/openmacro-foundation.md | 26 +- 6 files changed, 545 insertions(+), 5 deletions(-) create mode 100644 app/src/main/java/com/vibhor1102/zerobit/openmacro/proposal/MacroExplanation.kt create mode 100644 app/src/main/java/com/vibhor1102/zerobit/openmacro/proposal/OpenMacroProposal.kt create mode 100644 app/src/main/java/com/vibhor1102/zerobit/openmacro/proposal/OpenMacroProposalPipeline.kt create mode 100644 app/src/main/java/com/vibhor1102/zerobit/openmacro/proposal/ProposalComparison.kt create mode 100644 app/src/test/java/com/vibhor1102/zerobit/openmacro/proposal/OpenMacroProposalPipelineTest.kt diff --git a/app/src/main/java/com/vibhor1102/zerobit/openmacro/proposal/MacroExplanation.kt b/app/src/main/java/com/vibhor1102/zerobit/openmacro/proposal/MacroExplanation.kt new file mode 100644 index 0000000..51a07b8 --- /dev/null +++ b/app/src/main/java/com/vibhor1102/zerobit/openmacro/proposal/MacroExplanation.kt @@ -0,0 +1,27 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.vibhor1102.zerobit.openmacro.proposal + +import com.vibhor1102.zerobit.openmacro.capability.AndroidPermission +import com.vibhor1102.zerobit.openmacro.capability.CapabilityLane + +data class MacroExplanation( + val macroId: String, + val name: String, + val blocks: List, + val requiredPermissions: Set, +) { + fun blocksIn(lane: CapabilityLane): List = + blocks.filter { it.lane == lane } +} + +data class BlockExplanation( + val blockId: String, + val capabilityType: String, + val lane: CapabilityLane, + val displayName: String, + val summary: String, + val requiredPermissions: Set, +) diff --git a/app/src/main/java/com/vibhor1102/zerobit/openmacro/proposal/OpenMacroProposal.kt b/app/src/main/java/com/vibhor1102/zerobit/openmacro/proposal/OpenMacroProposal.kt new file mode 100644 index 0000000..df79c70 --- /dev/null +++ b/app/src/main/java/com/vibhor1102/zerobit/openmacro/proposal/OpenMacroProposal.kt @@ -0,0 +1,47 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.vibhor1102.zerobit.openmacro.proposal + +import com.vibhor1102.zerobit.openmacro.runtime.RuntimePlan +import com.vibhor1102.zerobit.openmacro.source.OpenMacroSource +import com.vibhor1102.zerobit.openmacro.source.SourceIssue +import com.vibhor1102.zerobit.openmacro.validation.ValidationIssue + +data class OpenMacroProposal( + val source: OpenMacroSource, + val explanation: MacroExplanation, + val runtimePlan: RuntimePlan, + val comparison: ProposalComparison, +) + +data class ApprovedMacroSnapshot( + val source: OpenMacroSource, + val explanation: MacroExplanation, + val runtimePlan: RuntimePlan, +) { + companion object { + fun from(proposal: OpenMacroProposal): ApprovedMacroSnapshot = + ApprovedMacroSnapshot( + source = proposal.source, + explanation = proposal.explanation, + runtimePlan = proposal.runtimePlan, + ) + } +} + +sealed interface ProposalResult { + data class SourceRejected( + val issues: List, + ) : ProposalResult + + data class ValidationRejected( + val source: OpenMacroSource, + val issues: List, + ) : ProposalResult + + data class Ready( + val proposal: OpenMacroProposal, + ) : ProposalResult +} diff --git a/app/src/main/java/com/vibhor1102/zerobit/openmacro/proposal/OpenMacroProposalPipeline.kt b/app/src/main/java/com/vibhor1102/zerobit/openmacro/proposal/OpenMacroProposalPipeline.kt new file mode 100644 index 0000000..0f58717 --- /dev/null +++ b/app/src/main/java/com/vibhor1102/zerobit/openmacro/proposal/OpenMacroProposalPipeline.kt @@ -0,0 +1,239 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.vibhor1102.zerobit.openmacro.proposal + +import com.vibhor1102.zerobit.openmacro.capability.CapabilityLane +import com.vibhor1102.zerobit.openmacro.capability.CapabilityRegistry +import com.vibhor1102.zerobit.openmacro.model.MacroBlock +import com.vibhor1102.zerobit.openmacro.model.OpenMacroDocument +import com.vibhor1102.zerobit.openmacro.runtime.PlanCompilationResult +import com.vibhor1102.zerobit.openmacro.runtime.RuntimePlan +import com.vibhor1102.zerobit.openmacro.runtime.RuntimePlanCompiler +import com.vibhor1102.zerobit.openmacro.runtime.RuntimeStep +import com.vibhor1102.zerobit.openmacro.source.OpenMacroSource +import com.vibhor1102.zerobit.openmacro.source.OpenMacroSourceResult +import com.vibhor1102.zerobit.openmacro.source.OpenMacroYamlReader + +class OpenMacroProposalPipeline( + private val registry: CapabilityRegistry, +) { + private val compiler = RuntimePlanCompiler(registry) + + fun propose( + sourceText: String, + approved: ApprovedMacroSnapshot? = null, + ): ProposalResult { + val sourceResult = OpenMacroYamlReader.read(sourceText) + if (sourceResult is OpenMacroSourceResult.Failure) { + return ProposalResult.SourceRejected(sourceResult.issues) + } + + val source = (sourceResult as OpenMacroSourceResult.Success).source + return when ( + val compilation = compiler.compile( + document = source.document, + sourceFingerprint = source.fingerprint, + ) + ) { + is PlanCompilationResult.Invalid -> ProposalResult.ValidationRejected( + source = source, + issues = compilation.issues, + ) + + is PlanCompilationResult.Success -> { + val explanation = explain(source.document) + ProposalResult.Ready( + OpenMacroProposal( + source = source, + explanation = explanation, + runtimePlan = compilation.plan, + comparison = compare( + proposedSource = source, + proposedExplanation = explanation, + proposedPlan = compilation.plan, + approved = approved, + ), + ), + ) + } + } + } + + private fun explain(document: OpenMacroDocument): MacroExplanation { + val blocks = buildList { + addAll(explainBlocks(document.triggers, CapabilityLane.TRIGGER)) + addAll(explainBlocks(document.conditions, CapabilityLane.CONDITION)) + addAll(explainBlocks(document.actions, CapabilityLane.ACTION)) + } + return MacroExplanation( + macroId = document.metadata.id, + name = document.metadata.name, + blocks = blocks, + requiredPermissions = blocks + .flatMapTo(mutableSetOf()) { it.requiredPermissions }, + ) + } + + private fun explainBlocks( + blocks: List, + lane: CapabilityLane, + ): List = blocks.map { block -> + val definition = checkNotNull(registry.find(block.type)) + check(definition.lane == lane) + BlockExplanation( + blockId = block.id, + capabilityType = block.type, + lane = lane, + displayName = definition.displayName, + summary = definition.explain(block), + requiredPermissions = definition.requiredPermissions(block), + ) + } + + private fun compare( + proposedSource: OpenMacroSource, + proposedExplanation: MacroExplanation, + proposedPlan: RuntimePlan, + approved: ApprovedMacroSnapshot?, + ): ProposalComparison { + if (approved == null) { + return ProposalComparison( + sourceChanged = true, + behaviorChanged = true, + approvalRequired = true, + changes = listOf( + BehaviorChange( + kind = BehaviorChangeKind.NEW_MACRO, + after = "Create macro '${proposedExplanation.name}'.", + ), + ), + permissionsAdded = proposedPlan.requiredPermissions, + permissionsRemoved = emptySet(), + ) + } + + val sourceChanged = proposedSource.fingerprint != approved.source.fingerprint + val changes = buildList { + if (proposedPlan.macroId != approved.runtimePlan.macroId) { + add( + BehaviorChange( + kind = BehaviorChangeKind.MACRO_ID_CHANGED, + before = approved.runtimePlan.macroId, + after = proposedPlan.macroId, + ), + ) + } + addAll( + compareLane( + lane = CapabilityLane.TRIGGER, + beforeSteps = approved.runtimePlan.triggers, + afterSteps = proposedPlan.triggers, + beforeExplanation = approved.explanation, + afterExplanation = proposedExplanation, + ), + ) + addAll( + compareLane( + lane = CapabilityLane.CONDITION, + beforeSteps = approved.runtimePlan.conditions, + afterSteps = proposedPlan.conditions, + beforeExplanation = approved.explanation, + afterExplanation = proposedExplanation, + ), + ) + addAll( + compareLane( + lane = CapabilityLane.ACTION, + beforeSteps = approved.runtimePlan.actions, + afterSteps = proposedPlan.actions, + beforeExplanation = approved.explanation, + afterExplanation = proposedExplanation, + ), + ) + } + + val permissionsAdded = + proposedPlan.requiredPermissions - approved.runtimePlan.requiredPermissions + val permissionsRemoved = + approved.runtimePlan.requiredPermissions - proposedPlan.requiredPermissions + val behaviorChanged = changes.isNotEmpty() || + permissionsAdded.isNotEmpty() || + permissionsRemoved.isNotEmpty() + + return ProposalComparison( + sourceChanged = sourceChanged, + behaviorChanged = behaviorChanged, + approvalRequired = behaviorChanged, + changes = changes, + permissionsAdded = permissionsAdded, + permissionsRemoved = permissionsRemoved, + ) + } + + private fun compareLane( + lane: CapabilityLane, + beforeSteps: List, + afterSteps: List, + beforeExplanation: MacroExplanation, + afterExplanation: MacroExplanation, + ): List { + val beforeById = beforeSteps.associateBy { it.blockId } + val afterById = afterSteps.associateBy { it.blockId } + val beforeDescriptions = beforeExplanation.blocksIn(lane).associateBy { it.blockId } + val afterDescriptions = afterExplanation.blocksIn(lane).associateBy { it.blockId } + + return buildList { + beforeSteps.filter { it.blockId !in afterById }.forEach { step -> + add( + BehaviorChange( + kind = BehaviorChangeKind.BLOCK_REMOVED, + lane = lane, + blockId = step.blockId, + before = beforeDescriptions[step.blockId]?.summary, + ), + ) + } + afterSteps.filter { it.blockId !in beforeById }.forEach { step -> + add( + BehaviorChange( + kind = BehaviorChangeKind.BLOCK_ADDED, + lane = lane, + blockId = step.blockId, + after = afterDescriptions[step.blockId]?.summary, + ), + ) + } + afterSteps.forEachIndexed { afterIndex, step -> + if (step.blockId !in beforeById) { + return@forEachIndexed + } + val beforeStep = beforeById.getValue(step.blockId) + if (beforeStep != step) { + add( + BehaviorChange( + kind = BehaviorChangeKind.BLOCK_CHANGED, + lane = lane, + blockId = step.blockId, + before = beforeDescriptions[step.blockId]?.summary, + after = afterDescriptions[step.blockId]?.summary, + ), + ) + } + val beforeIndex = beforeSteps.indexOfFirst { it.blockId == step.blockId } + if (beforeIndex != afterIndex) { + add( + BehaviorChange( + kind = BehaviorChangeKind.BLOCK_REORDERED, + lane = lane, + blockId = step.blockId, + before = "Position ${beforeIndex + 1}", + after = "Position ${afterIndex + 1}", + ), + ) + } + } + } + } +} diff --git a/app/src/main/java/com/vibhor1102/zerobit/openmacro/proposal/ProposalComparison.kt b/app/src/main/java/com/vibhor1102/zerobit/openmacro/proposal/ProposalComparison.kt new file mode 100644 index 0000000..c696a19 --- /dev/null +++ b/app/src/main/java/com/vibhor1102/zerobit/openmacro/proposal/ProposalComparison.kt @@ -0,0 +1,34 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.vibhor1102.zerobit.openmacro.proposal + +import com.vibhor1102.zerobit.openmacro.capability.AndroidPermission +import com.vibhor1102.zerobit.openmacro.capability.CapabilityLane + +data class ProposalComparison( + val sourceChanged: Boolean, + val behaviorChanged: Boolean, + val approvalRequired: Boolean, + val changes: List, + val permissionsAdded: Set, + val permissionsRemoved: Set, +) + +data class BehaviorChange( + val kind: BehaviorChangeKind, + val lane: CapabilityLane? = null, + val blockId: String? = null, + val before: String? = null, + val after: String? = null, +) + +enum class BehaviorChangeKind { + NEW_MACRO, + MACRO_ID_CHANGED, + BLOCK_ADDED, + BLOCK_REMOVED, + BLOCK_CHANGED, + BLOCK_REORDERED, +} diff --git a/app/src/test/java/com/vibhor1102/zerobit/openmacro/proposal/OpenMacroProposalPipelineTest.kt b/app/src/test/java/com/vibhor1102/zerobit/openmacro/proposal/OpenMacroProposalPipelineTest.kt new file mode 100644 index 0000000..b7b6b82 --- /dev/null +++ b/app/src/test/java/com/vibhor1102/zerobit/openmacro/proposal/OpenMacroProposalPipelineTest.kt @@ -0,0 +1,177 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.vibhor1102.zerobit.openmacro.proposal + +import com.vibhor1102.zerobit.openmacro.capability.AndroidPermission +import com.vibhor1102.zerobit.openmacro.capability.CapabilityLane +import com.vibhor1102.zerobit.openmacro.capability.CapabilityRegistry +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class OpenMacroProposalPipelineTest { + private val pipeline = OpenMacroProposalPipeline(CapabilityRegistry.builtIn()) + + @Test + fun createsApprovalReadyProposalWithPlainEnglishExplanation() { + val result = pipeline.propose(validSource()) + + require(result is ProposalResult.Ready) + val proposal = result.proposal + assertEquals("charger-greeting", proposal.explanation.macroId) + assertEquals( + listOf("Start when the phone is connected to external power."), + proposal.explanation.blocksIn(CapabilityLane.TRIGGER).map { it.summary }, + ) + assertEquals( + listOf("Continue only if the phone is unlocked."), + proposal.explanation.blocksIn(CapabilityLane.CONDITION).map { it.summary }, + ) + assertEquals( + setOf(AndroidPermission.POST_NOTIFICATIONS), + proposal.explanation.requiredPermissions, + ) + assertTrue(proposal.comparison.approvalRequired) + assertEquals( + listOf(BehaviorChangeKind.NEW_MACRO), + proposal.comparison.changes.map { it.kind }, + ) + } + + @Test + fun keepsSourceAndValidationFailuresSeparate() { + val malformed = pipeline.propose("format: [") + require(malformed is ProposalResult.SourceRejected) + assertEquals("invalid_yaml", malformed.issues.single().code) + + val unsupported = pipeline.propose( + validSource().replace( + "android.notification.show", + "android.future.teleport", + ), + ) + require(unsupported is ProposalResult.ValidationRejected) + assertEquals("unsupported_capability", unsupported.issues.single().code) + assertTrue(unsupported.source.originalText.contains("android.future.teleport")) + } + + @Test + fun harmlessSourceAndMetadataEditsDoNotRequireBehaviorApproval() { + val original = ready(validSource()) + val approved = ApprovedMacroSnapshot.from(original) + val editedText = validSource() + .replace("name: Charger greeting", "name: Friendly charger greeting") + .replace( + "format: openmacro/v0.1", + "# Edited by a human\nformat: openmacro/v0.1", + ) + + val edited = ready(editedText, approved) + + assertTrue(edited.comparison.sourceChanged) + assertFalse(edited.comparison.behaviorChanged) + assertFalse(edited.comparison.approvalRequired) + assertTrue(edited.comparison.changes.isEmpty()) + assertTrue(edited.comparison.permissionsAdded.isEmpty()) + } + + @Test + fun actionConfigurationChangeRequiresApprovalAndExplainsTheDifference() { + val original = ready(validSource()) + val approved = ApprovedMacroSnapshot.from(original) + val changed = ready( + validSource().replace( + "message: The charger is connected.", + "message: Time to charge.", + ), + approved, + ) + + assertTrue(changed.comparison.behaviorChanged) + assertTrue(changed.comparison.approvalRequired) + assertEquals( + listOf(BehaviorChangeKind.BLOCK_CHANGED), + changed.comparison.changes.map { it.kind }, + ) + val change = changed.comparison.changes.single() + assertEquals("show-message", change.blockId) + assertTrue(change.before.orEmpty().contains("The charger is connected.")) + assertTrue(change.after.orEmpty().contains("Time to charge.")) + } + + @Test + fun actionOrderChangeIsVisibleAndRequiresApproval() { + val sourcePrefix = validSource().substringBefore("actions:") + val twoActions = sourcePrefix + """ + actions: + - id: first-message + type: android.notification.show + config: + title: First + message: First message. + - id: second-message + type: android.notification.show + config: + title: Second + message: Second message. + """.trimIndent() + "\n" + val reordered = sourcePrefix + """ + actions: + - id: second-message + type: android.notification.show + config: + title: Second + message: Second message. + - id: first-message + type: android.notification.show + config: + title: First + message: First message. + """.trimIndent() + "\n" + val approved = ApprovedMacroSnapshot.from(ready(twoActions)) + + val proposal = ready(reordered, approved) + + assertTrue(proposal.comparison.approvalRequired) + assertEquals( + listOf( + BehaviorChangeKind.BLOCK_REORDERED, + BehaviorChangeKind.BLOCK_REORDERED, + ), + proposal.comparison.changes.map { it.kind }, + ) + } + + private fun ready( + source: String, + approved: ApprovedMacroSnapshot? = null, + ): OpenMacroProposal { + val result = pipeline.propose(source, approved) + require(result is ProposalResult.Ready) { + "Expected an approval-ready proposal, got $result" + } + return result.proposal + } + + private fun validSource() = """ + format: openmacro/v0.1 + metadata: + id: charger-greeting + name: Charger greeting + triggers: + - id: charger-connected + type: android.power.connected + conditions: + - id: device-is-unlocked + type: android.device.unlocked + actions: + - id: show-message + type: android.notification.show + config: + title: Charging started + message: The charger is connected. + """.trimIndent() + "\n" +} diff --git a/docs/architecture/openmacro-foundation.md b/docs/architecture/openmacro-foundation.md index 98b0b6f..542136d 100644 --- a/docs/architecture/openmacro-foundation.md +++ b/docs/architecture/openmacro-foundation.md @@ -93,6 +93,20 @@ file. The runtime never interprets YAML directly and never asks AI what a block means. +The proposal pipeline is the shared trust boundary for source and visual edits. +It returns one of three outcomes: + +- source rejected, with YAML locations where available; +- validation rejected, while retaining the proposed source for correction; or +- approval-ready, with plain-English block explanations, permission impact, + behavior changes, and an immutable runtime plan. + +Approval is tied to runnable behavior rather than file churn. Comments, +formatting, macro display names, and descriptions may change without behavioral +re-approval when the compiled plan is unchanged. Trigger, condition, action, +ordering, configuration, macro identity, or permission changes require +approval. + ## First implementation boundary The initial implementation contains: @@ -104,7 +118,9 @@ The initial implementation contains: - capability validation, explanations, and permission discovery; and - compilation into immutable runtime instructions; - strict YAML 1.2 reading with source locations and bounded input; and -- stable canonical writing without silently reformatting source on read. +- stable canonical writing without silently reformatting source on read; and +- a proposal pipeline joining parsing, validation, explanation, permission + impact, behavioral comparison, and runtime-plan compilation. This proves that one capability definition can drive the code shape, future form, validation, explanation, permissions, and runtime plan without premature @@ -112,7 +128,7 @@ plugin machinery. The source adapter rejects aliases, anchors, merge keys, custom tags, directives, duplicate keys, multiple documents, ambiguous YAML 1.1 booleans, excessive nesting, and oversized files. -The next slice should connect source parsing, capability validation, -explanation, and runtime compilation into one proposal pipeline. That pipeline -will become the shared boundary used by both editors before approval storage or -runtime services are introduced. +The next slice should add durable workspace and approval storage around this +pipeline. It must keep source files, approved snapshots, encrypted secrets, +runtime state, and diagnostic logs separate as required by the repository +architecture. From 13f87622e79021dbe746413168bd1e11f69f4f61 Mon Sep 17 00:00:00 2001 From: Vibhor Goel Date: Thu, 25 Jun 2026 14:23:28 +0530 Subject: [PATCH 5/8] Add local OpenMacro approval storage --- .../openmacro/storage/ApprovalStore.kt | 410 ++++++++++++++++++ .../zerobit/openmacro/storage/AtomicFiles.kt | 45 ++ .../openmacro/storage/WorkspaceMacroStore.kt | 165 +++++++ .../storage/WorkspaceReviewService.kt | 71 +++ .../storage/WorkspaceAndApprovalStoreTest.kt | 200 +++++++++ docs/architecture/openmacro-foundation.md | 24 + 6 files changed, 915 insertions(+) create mode 100644 app/src/main/java/com/vibhor1102/zerobit/openmacro/storage/ApprovalStore.kt create mode 100644 app/src/main/java/com/vibhor1102/zerobit/openmacro/storage/AtomicFiles.kt create mode 100644 app/src/main/java/com/vibhor1102/zerobit/openmacro/storage/WorkspaceMacroStore.kt create mode 100644 app/src/main/java/com/vibhor1102/zerobit/openmacro/storage/WorkspaceReviewService.kt create mode 100644 app/src/test/java/com/vibhor1102/zerobit/openmacro/storage/WorkspaceAndApprovalStoreTest.kt diff --git a/app/src/main/java/com/vibhor1102/zerobit/openmacro/storage/ApprovalStore.kt b/app/src/main/java/com/vibhor1102/zerobit/openmacro/storage/ApprovalStore.kt new file mode 100644 index 0000000..2a9384f --- /dev/null +++ b/app/src/main/java/com/vibhor1102/zerobit/openmacro/storage/ApprovalStore.kt @@ -0,0 +1,410 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.vibhor1102.zerobit.openmacro.storage + +import com.vibhor1102.zerobit.openmacro.proposal.ApprovedMacroSnapshot +import com.vibhor1102.zerobit.openmacro.proposal.OpenMacroProposal +import com.vibhor1102.zerobit.openmacro.proposal.OpenMacroProposalPipeline +import com.vibhor1102.zerobit.openmacro.proposal.ProposalResult +import java.io.IOException +import java.nio.charset.StandardCharsets +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.StandardOpenOption + +/** + * App-private approval history. Revisions are immutable; an atomic pointer + * selects the exact snapshot the runtime may use. + */ +class ApprovalStore( + privateRoot: Path, + private val pipeline: OpenMacroProposalPipeline, + private val clock: MillisecondClock = MillisecondClock.System, +) { + private val approvalsDirectory = + privateRoot.toAbsolutePath().normalize().resolve("approvals") + + @Synchronized + fun approve(proposal: OpenMacroProposal): ApprovalStoreResult = + persist( + sourceText = proposal.source.originalText, + macroId = proposal.source.document.metadata.id, + fingerprint = proposal.source.fingerprint, + kind = ApprovalKind.APPROVAL, + restoredFromRevisionId = null, + ) + + fun loadCurrent(macroId: String): ApprovalStoreResult { + val safeId = safeMacroId(macroId) + ?: return ApprovalStoreResult.Failure("invalid_macro_id", "Invalid macro id.") + val pointer = macroDirectory(safeId).resolve(CURRENT_FILE) + if (!Files.exists(pointer)) { + return ApprovalStoreResult.Success(null) + } + return try { + val revisionId = + String(Files.readAllBytes(pointer), StandardCharsets.UTF_8).trim() + if (!RevisionId.isValid(revisionId)) { + failure("corrupt_approval_pointer", "The approval pointer is invalid.") + } else { + loadRevisionInternal(safeId, revisionId) + } + } catch (problem: IOException) { + failure("approval_read_failed", problem.message ?: "Could not read approval state.") + } + } + + fun listRevisions(macroId: String): ApprovalStoreResult> { + val safeId = safeMacroId(macroId) + ?: return failure("invalid_macro_id", "Invalid macro id.") + val revisions = revisionsDirectory(safeId) + if (!Files.isDirectory(revisions)) { + return ApprovalStoreResult.Success(emptyList()) + } + return try { + val summaries = Files.newDirectoryStream(revisions).use { paths -> + paths.mapNotNull { path -> + val id = path.fileName.toString() + RevisionId.parse(id)?.let { parsed -> + ApprovalRevisionSummary( + revisionId = id, + approvedAtEpochMillis = parsed.timestamp, + fingerprint = parsed.fingerprint, + ) + } + }.sortedByDescending { it.approvedAtEpochMillis } + } + ApprovalStoreResult.Success(summaries) + } catch (problem: IOException) { + failure("approval_read_failed", problem.message ?: "Could not list approvals.") + } + } + + fun loadRevision( + macroId: String, + revisionId: String, + ): ApprovalStoreResult { + val safeId = safeMacroId(macroId) + ?: return failure("invalid_macro_id", "Invalid macro id.") + return loadRevisionInternal(safeId, revisionId) + } + + @Synchronized + fun rollback( + macroId: String, + targetRevisionId: String, + ): ApprovalStoreResult { + val safeId = safeMacroId(macroId) + ?: return failure("invalid_macro_id", "Invalid macro id.") + val target = when (val result = loadRevisionInternal(safeId, targetRevisionId)) { + is ApprovalStoreResult.Failure -> return result + is ApprovalStoreResult.Success -> result.value + } + return persist( + sourceText = target.snapshot.source.originalText, + macroId = safeId, + fingerprint = target.snapshot.source.fingerprint, + kind = ApprovalKind.ROLLBACK, + restoredFromRevisionId = targetRevisionId, + ) + } + + private fun persist( + sourceText: String, + macroId: String, + fingerprint: String, + kind: ApprovalKind, + restoredFromRevisionId: String?, + ): ApprovalStoreResult { + val safeId = safeMacroId(macroId) + ?: return failure("invalid_macro_id", "Invalid macro id.") + val fingerprintHex = fingerprint.removePrefix(FINGERPRINT_PREFIX) + if (!FINGERPRINT_HEX.matches(fingerprintHex)) { + return failure("invalid_fingerprint", "The proposed source fingerprint is invalid.") + } + + return try { + val previousRevisionId = currentRevisionIdOrNull(safeId) + val revisionId = nextRevisionId(safeId, fingerprintHex) + val revisionDirectory = revisionsDirectory(safeId).resolve(revisionId) + val revisionsDirectory = revisionsDirectory(safeId) + Files.createDirectories(revisionsDirectory) + val temporaryDirectory = Files.createTempDirectory( + revisionsDirectory, + ".revision-", + ) + try { + Files.write( + temporaryDirectory.resolve(SOURCE_FILE), + sourceText.toByteArray(StandardCharsets.UTF_8), + StandardOpenOption.CREATE_NEW, + StandardOpenOption.WRITE, + ) + Files.write( + temporaryDirectory.resolve(METADATA_FILE), + metadataText( + macroId = safeId, + fingerprint = fingerprint, + kind = kind, + previousRevisionId = previousRevisionId, + restoredFromRevisionId = restoredFromRevisionId, + ).toByteArray(StandardCharsets.UTF_8), + StandardOpenOption.CREATE_NEW, + StandardOpenOption.WRITE, + ) + AtomicFiles.moveDirectory(temporaryDirectory, revisionDirectory) + } finally { + deleteEmptyDirectoryIfPresent(temporaryDirectory) + } + AtomicFiles.writeText( + macroDirectory(safeId).resolve(CURRENT_FILE), + "$revisionId\n", + ) + loadRevisionInternal(safeId, revisionId) + } catch (problem: IOException) { + failure("approval_write_failed", problem.message ?: "Could not save approval.") + } catch (problem: IllegalArgumentException) { + failure( + "corrupt_approval_pointer", + problem.message ?: "The current approval pointer is invalid.", + ) + } + } + + private fun loadRevisionInternal( + macroId: String, + revisionId: String, + ): ApprovalStoreResult { + val parsedId = RevisionId.parse(revisionId) + ?: return failure("invalid_revision_id", "The approval revision id is invalid.") + val directory = revisionsDirectory(macroId).resolve(revisionId).normalize() + if (!directory.startsWith(revisionsDirectory(macroId)) || !Files.isDirectory(directory)) { + return failure("approval_missing", "The requested approval revision does not exist.") + } + + return try { + val metadata = parseMetadata( + String( + Files.readAllBytes(directory.resolve(METADATA_FILE)), + StandardCharsets.UTF_8, + ), + ) + val sourceText = String( + Files.readAllBytes(directory.resolve(SOURCE_FILE)), + StandardCharsets.UTF_8, + ) + val proposal = pipeline.propose(sourceText) + if (proposal !is ProposalResult.Ready) { + return failure( + "corrupt_approval_source", + "The approved source no longer parses and validates.", + ) + } + val ready = proposal.proposal + if ( + ready.source.document.metadata.id != macroId || + ready.source.fingerprint != parsedId.fingerprint || + metadata["macroId"] != macroId || + metadata["fingerprint"] != parsedId.fingerprint + ) { + return failure( + "corrupt_approval_integrity", + "The approved snapshot failed its integrity check.", + ) + } + val kind = ApprovalKind.fromStorage(metadata["kind"]) + ?: return failure("corrupt_approval_metadata", "Unknown approval kind.") + val previousRevisionId = metadata["previousRevisionId"].emptyToNull() + val restoredFromRevisionId = metadata["restoredFromRevisionId"].emptyToNull() + if ( + previousRevisionId?.let(RevisionId::isValid) == false || + restoredFromRevisionId?.let(RevisionId::isValid) == false || + (kind == ApprovalKind.APPROVAL && restoredFromRevisionId != null) || + (kind == ApprovalKind.ROLLBACK && restoredFromRevisionId == null) || + previousRevisionId?.let { + !Files.isDirectory(revisionsDirectory(macroId).resolve(it)) + } == true || + restoredFromRevisionId?.let { + !Files.isDirectory(revisionsDirectory(macroId).resolve(it)) + } == true + ) { + return failure( + "corrupt_approval_metadata", + "The approval revision links are invalid.", + ) + } + ApprovalStoreResult.Success( + ApprovedRevision( + revisionId = revisionId, + approvedAtEpochMillis = parsedId.timestamp, + kind = kind, + previousRevisionId = previousRevisionId, + restoredFromRevisionId = restoredFromRevisionId, + snapshot = ApprovedMacroSnapshot.from(ready), + ), + ) + } catch (problem: IOException) { + failure("approval_read_failed", problem.message ?: "Could not read approval.") + } catch (problem: IllegalArgumentException) { + failure( + "corrupt_approval_metadata", + problem.message ?: "The approval metadata is invalid.", + ) + } + } + + private fun nextRevisionId(macroId: String, fingerprintHex: String): String { + var timestamp = clock.nowEpochMillis() + var candidate = RevisionId.format(timestamp, fingerprintHex) + while (Files.exists(revisionsDirectory(macroId).resolve(candidate))) { + timestamp += 1 + candidate = RevisionId.format(timestamp, fingerprintHex) + } + return candidate + } + + private fun currentRevisionIdOrNull(macroId: String): String? { + val path = macroDirectory(macroId).resolve(CURRENT_FILE) + if (!Files.exists(path)) return null + val revisionId = String(Files.readAllBytes(path), StandardCharsets.UTF_8).trim() + require(RevisionId.isValid(revisionId)) { "The current approval pointer is invalid." } + return revisionId + } + + private fun metadataText( + macroId: String, + fingerprint: String, + kind: ApprovalKind, + previousRevisionId: String?, + restoredFromRevisionId: String?, + ): String = buildString { + appendLine("version=1") + appendLine("macroId=$macroId") + appendLine("fingerprint=$fingerprint") + appendLine("kind=${kind.storageValue}") + appendLine("previousRevisionId=${previousRevisionId.orEmpty()}") + appendLine("restoredFromRevisionId=${restoredFromRevisionId.orEmpty()}") + } + + private fun parseMetadata(text: String): Map { + val entries = linkedMapOf() + text.lineSequence().filter(String::isNotEmpty).forEach { line -> + val separator = line.indexOf('=') + require(separator > 0) { "Malformed approval metadata." } + val key = line.substring(0, separator) + val value = line.substring(separator + 1) + require(entries.put(key, value) == null) { "Duplicate approval metadata key." } + } + require(entries.keys == METADATA_KEYS) { "Unexpected approval metadata keys." } + require(entries["version"] == "1") { "Unsupported approval metadata version." } + return entries + } + + private fun macroDirectory(macroId: String): Path = approvalsDirectory.resolve(macroId) + + private fun revisionsDirectory(macroId: String): Path = + macroDirectory(macroId).resolve(REVISIONS_DIRECTORY) + + private fun safeMacroId(value: String): String? = + runCatching { MacroStorageNames.requireMacroId(value) }.getOrNull() + + private fun deleteEmptyDirectoryIfPresent(path: Path) { + if (!Files.exists(path)) return + runCatching { + Files.walk(path).use { paths -> + paths.sorted(Comparator.reverseOrder()).forEach(Files::deleteIfExists) + } + } + } + + private fun String?.emptyToNull(): String? = this?.takeIf(String::isNotEmpty) + + private fun failure(code: String, message: String): ApprovalStoreResult = + ApprovalStoreResult.Failure(code, message) + + private companion object { + const val CURRENT_FILE = "current" + const val REVISIONS_DIRECTORY = "revisions" + const val SOURCE_FILE = "source.openmacro.yaml" + const val METADATA_FILE = "metadata" + const val FINGERPRINT_PREFIX = "sha256:" + val FINGERPRINT_HEX = Regex("^[a-f0-9]{64}$") + val METADATA_KEYS = setOf( + "version", + "macroId", + "fingerprint", + "kind", + "previousRevisionId", + "restoredFromRevisionId", + ) + } +} + +data class ApprovedRevision( + val revisionId: String, + val approvedAtEpochMillis: Long, + val kind: ApprovalKind, + val previousRevisionId: String?, + val restoredFromRevisionId: String?, + val snapshot: ApprovedMacroSnapshot, +) + +data class ApprovalRevisionSummary( + val revisionId: String, + val approvedAtEpochMillis: Long, + val fingerprint: String, +) + +enum class ApprovalKind( + val storageValue: String, +) { + APPROVAL("approval"), + ROLLBACK("rollback"); + + companion object { + fun fromStorage(value: String?): ApprovalKind? = + entries.firstOrNull { it.storageValue == value } + } +} + +sealed interface ApprovalStoreResult { + data class Success(val value: T) : ApprovalStoreResult + + data class Failure( + val code: String, + val message: String, + ) : ApprovalStoreResult +} + +fun interface MillisecondClock { + fun nowEpochMillis(): Long + + data object System : MillisecondClock { + override fun nowEpochMillis(): Long = java.lang.System.currentTimeMillis() + } +} + +private data class RevisionId( + val timestamp: Long, + val fingerprint: String, +) { + companion object { + private val pattern = Regex("^([0-9]{13})-([a-f0-9]{64})$") + + fun format(timestamp: Long, fingerprintHex: String): String = + "${timestamp.toString().padStart(13, '0')}-$fingerprintHex" + + fun parse(value: String): RevisionId? { + val match = pattern.matchEntire(value) ?: return null + val timestamp = match.groupValues[1].toLongOrNull() ?: return null + return RevisionId( + timestamp = timestamp, + fingerprint = "sha256:${match.groupValues[2]}", + ) + } + + fun isValid(value: String): Boolean = parse(value) != null + } +} diff --git a/app/src/main/java/com/vibhor1102/zerobit/openmacro/storage/AtomicFiles.kt b/app/src/main/java/com/vibhor1102/zerobit/openmacro/storage/AtomicFiles.kt new file mode 100644 index 0000000..a19902e --- /dev/null +++ b/app/src/main/java/com/vibhor1102/zerobit/openmacro/storage/AtomicFiles.kt @@ -0,0 +1,45 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.vibhor1102.zerobit.openmacro.storage + +import java.nio.charset.StandardCharsets +import java.nio.file.AtomicMoveNotSupportedException +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.StandardCopyOption + +internal object AtomicFiles { + fun writeText(target: Path, text: String) { + Files.createDirectories(target.parent) + val temporary = Files.createTempFile(target.parent, ".${target.fileName}.", ".tmp") + try { + Files.write(temporary, text.toByteArray(StandardCharsets.UTF_8)) + moveReplacing(temporary, target) + } finally { + Files.deleteIfExists(temporary) + } + } + + fun moveDirectory(temporary: Path, target: Path) { + try { + Files.move(temporary, target, StandardCopyOption.ATOMIC_MOVE) + } catch (_: AtomicMoveNotSupportedException) { + Files.move(temporary, target) + } + } + + private fun moveReplacing(source: Path, target: Path) { + try { + Files.move( + source, + target, + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING, + ) + } catch (_: AtomicMoveNotSupportedException) { + Files.move(source, target, StandardCopyOption.REPLACE_EXISTING) + } + } +} diff --git a/app/src/main/java/com/vibhor1102/zerobit/openmacro/storage/WorkspaceMacroStore.kt b/app/src/main/java/com/vibhor1102/zerobit/openmacro/storage/WorkspaceMacroStore.kt new file mode 100644 index 0000000..c11bfcc --- /dev/null +++ b/app/src/main/java/com/vibhor1102/zerobit/openmacro/storage/WorkspaceMacroStore.kt @@ -0,0 +1,165 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.vibhor1102.zerobit.openmacro.storage + +import com.vibhor1102.zerobit.openmacro.source.OpenMacroSource +import com.vibhor1102.zerobit.openmacro.source.OpenMacroSourceResult +import com.vibhor1102.zerobit.openmacro.source.OpenMacroYamlReader +import com.vibhor1102.zerobit.openmacro.source.SourceIssue +import java.io.IOException +import java.nio.charset.StandardCharsets +import java.nio.file.Files +import java.nio.file.Path + +/** + * User-owned, versionable source files. This store contains no approval state, + * secrets, runtime state, or logs. + */ +class WorkspaceMacroStore( + workspaceRoot: Path, +) { + private val macrosDirectory = workspaceRoot.toAbsolutePath().normalize().resolve("macros") + + fun write(source: OpenMacroSource): WorkspaceWriteResult { + val macroId = try { + MacroStorageNames.requireMacroId(source.document.metadata.id) + } catch (problem: IllegalArgumentException) { + return WorkspaceWriteResult.Failure( + code = "invalid_macro_id", + message = problem.message.orEmpty(), + ) + } + return try { + val path = pathFor(macroId) + if (Files.isSymbolicLink(macrosDirectory) || Files.isSymbolicLink(path)) { + return WorkspaceWriteResult.Failure( + code = "workspace_symlink_not_allowed", + message = "OpenMacro workspace paths may not be symbolic links.", + ) + } + AtomicFiles.writeText(path, source.originalText) + WorkspaceWriteResult.Success + } catch (problem: IOException) { + WorkspaceWriteResult.Failure( + code = "workspace_write_failed", + message = problem.message ?: "Could not write the macro file.", + ) + } + } + + fun read(macroId: String): WorkspaceMacroResult { + val safeId = try { + MacroStorageNames.requireMacroId(macroId) + } catch (problem: IllegalArgumentException) { + return WorkspaceMacroResult.InvalidId(problem.message.orEmpty()) + } + val path = pathFor(safeId) + if (!Files.exists(path)) { + return WorkspaceMacroResult.Missing + } + if (Files.isSymbolicLink(macrosDirectory) || Files.isSymbolicLink(path)) { + return WorkspaceMacroResult.IoFailure( + "OpenMacro workspace paths may not be symbolic links.", + ) + } + + return try { + when ( + val parsed = OpenMacroYamlReader.read( + String(Files.readAllBytes(path), StandardCharsets.UTF_8), + ) + ) { + is OpenMacroSourceResult.Failure -> + WorkspaceMacroResult.InvalidSource(parsed.issues) + + is OpenMacroSourceResult.Success -> { + if (parsed.source.document.metadata.id != safeId) { + WorkspaceMacroResult.InvalidSource( + listOf( + SourceIssue( + code = "workspace_id_mismatch", + message = "File '$safeId.openmacro.yaml' declares macro id " + + "'${parsed.source.document.metadata.id}'.", + path = "$.metadata.id", + ), + ), + ) + } else { + WorkspaceMacroResult.Success(parsed.source) + } + } + } + } catch (problem: IOException) { + WorkspaceMacroResult.IoFailure(problem.message ?: "Could not read the macro file.") + } + } + + fun listMacroIds(): WorkspaceMacroListResult { + if (!Files.isDirectory(macrosDirectory)) { + return WorkspaceMacroListResult.Success(emptyList()) + } + return try { + WorkspaceMacroListResult.Success( + Files.newDirectoryStream(macrosDirectory, "*.openmacro.yaml").use { paths -> + paths.mapNotNull { path -> + MacroStorageNames.idFromFileName(path.fileName.toString()) + }.sorted() + }, + ) + } catch (problem: IOException) { + WorkspaceMacroListResult.Failure( + problem.message ?: "Could not list workspace macros.", + ) + } + } + + private fun pathFor(macroId: String): Path = + macrosDirectory.resolve("$macroId.openmacro.yaml") +} + +sealed interface WorkspaceMacroResult { + data class Success(val source: OpenMacroSource) : WorkspaceMacroResult + + data object Missing : WorkspaceMacroResult + + data class InvalidId(val message: String) : WorkspaceMacroResult + + data class InvalidSource(val issues: List) : WorkspaceMacroResult + + data class IoFailure(val message: String) : WorkspaceMacroResult +} + +sealed interface WorkspaceWriteResult { + data object Success : WorkspaceWriteResult + + data class Failure( + val code: String, + val message: String, + ) : WorkspaceWriteResult +} + +sealed interface WorkspaceMacroListResult { + data class Success(val macroIds: List) : WorkspaceMacroListResult + + data class Failure(val message: String) : WorkspaceMacroListResult +} + +internal object MacroStorageNames { + private val macroIdPattern = Regex("^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$") + private const val SUFFIX = ".openmacro.yaml" + + fun requireMacroId(macroId: String): String { + require(macroIdPattern.matches(macroId)) { + "Macro id must be 1-64 lowercase letters, numbers, or hyphens." + } + return macroId + } + + fun idFromFileName(fileName: String): String? { + if (!fileName.endsWith(SUFFIX)) return null + val candidate = fileName.removeSuffix(SUFFIX) + return candidate.takeIf(macroIdPattern::matches) + } +} diff --git a/app/src/main/java/com/vibhor1102/zerobit/openmacro/storage/WorkspaceReviewService.kt b/app/src/main/java/com/vibhor1102/zerobit/openmacro/storage/WorkspaceReviewService.kt new file mode 100644 index 0000000..fafe1a8 --- /dev/null +++ b/app/src/main/java/com/vibhor1102/zerobit/openmacro/storage/WorkspaceReviewService.kt @@ -0,0 +1,71 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.vibhor1102.zerobit.openmacro.storage + +import com.vibhor1102.zerobit.openmacro.proposal.OpenMacroProposal +import com.vibhor1102.zerobit.openmacro.proposal.OpenMacroProposalPipeline +import com.vibhor1102.zerobit.openmacro.proposal.ProposalResult +import com.vibhor1102.zerobit.openmacro.source.SourceIssue +import com.vibhor1102.zerobit.openmacro.validation.ValidationIssue + +/** + * Reads the editable workspace and compares it with app-private approval state. + * It never changes approval state merely because a workspace file changed. + */ +class WorkspaceReviewService( + private val workspace: WorkspaceMacroStore, + private val approvals: ApprovalStore, + private val pipeline: OpenMacroProposalPipeline, +) { + fun review(macroId: String): WorkspaceReviewResult { + val workspaceSource = when (val result = workspace.read(macroId)) { + is WorkspaceMacroResult.Success -> result.source + WorkspaceMacroResult.Missing -> return WorkspaceReviewResult.Missing + is WorkspaceMacroResult.InvalidId -> + return WorkspaceReviewResult.StorageFailure("invalid_macro_id", result.message) + is WorkspaceMacroResult.InvalidSource -> + return WorkspaceReviewResult.SourceRejected(result.issues) + is WorkspaceMacroResult.IoFailure -> + return WorkspaceReviewResult.StorageFailure("workspace_read_failed", result.message) + } + + val approved = when (val result = approvals.loadCurrent(macroId)) { + is ApprovalStoreResult.Success -> result.value?.snapshot + is ApprovalStoreResult.Failure -> + return WorkspaceReviewResult.StorageFailure(result.code, result.message) + } + + return when ( + val proposal = pipeline.propose( + sourceText = workspaceSource.originalText, + approved = approved, + ) + ) { + is ProposalResult.Ready -> WorkspaceReviewResult.Ready(proposal.proposal) + is ProposalResult.SourceRejected -> + WorkspaceReviewResult.SourceRejected(proposal.issues) + is ProposalResult.ValidationRejected -> + WorkspaceReviewResult.ValidationRejected(proposal.issues) + } + } + + fun approve(proposal: OpenMacroProposal): ApprovalStoreResult = + approvals.approve(proposal) +} + +sealed interface WorkspaceReviewResult { + data class Ready(val proposal: OpenMacroProposal) : WorkspaceReviewResult + + data object Missing : WorkspaceReviewResult + + data class SourceRejected(val issues: List) : WorkspaceReviewResult + + data class ValidationRejected(val issues: List) : WorkspaceReviewResult + + data class StorageFailure( + val code: String, + val message: String, + ) : WorkspaceReviewResult +} diff --git a/app/src/test/java/com/vibhor1102/zerobit/openmacro/storage/WorkspaceAndApprovalStoreTest.kt b/app/src/test/java/com/vibhor1102/zerobit/openmacro/storage/WorkspaceAndApprovalStoreTest.kt new file mode 100644 index 0000000..9842a40 --- /dev/null +++ b/app/src/test/java/com/vibhor1102/zerobit/openmacro/storage/WorkspaceAndApprovalStoreTest.kt @@ -0,0 +1,200 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.vibhor1102.zerobit.openmacro.storage + +import com.vibhor1102.zerobit.openmacro.capability.CapabilityRegistry +import com.vibhor1102.zerobit.openmacro.proposal.OpenMacroProposal +import com.vibhor1102.zerobit.openmacro.proposal.OpenMacroProposalPipeline +import com.vibhor1102.zerobit.openmacro.proposal.ProposalResult +import com.vibhor1102.zerobit.openmacro.source.OpenMacroSourceResult +import com.vibhor1102.zerobit.openmacro.source.OpenMacroYamlReader +import java.nio.charset.StandardCharsets +import java.nio.file.Files +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +class WorkspaceAndApprovalStoreTest { + @get:Rule + val temporaryFolder = TemporaryFolder() + + private val pipeline = OpenMacroProposalPipeline(CapabilityRegistry.builtIn()) + + @Test + fun workspaceUsesStablePathSafeNamesAndChecksDeclaredId() { + val root = temporaryFolder.newFolder("workspace").toPath() + val store = WorkspaceMacroStore(root) + val source = parsed(validSource()) + + assertEquals(WorkspaceWriteResult.Success, store.write(source)) + + val listed = store.listMacroIds() + require(listed is WorkspaceMacroListResult.Success) + assertEquals(listOf("charger-greeting"), listed.macroIds) + val loaded = store.read("charger-greeting") + require(loaded is WorkspaceMacroResult.Success) + assertEquals(source, loaded.source) + assertTrue(store.read("../escape") is WorkspaceMacroResult.InvalidId) + + val path = root.resolve("macros/charger-greeting.openmacro.yaml") + Files.write( + path, + validSource() + .replace("id: charger-greeting", "id: different-id") + .toByteArray(StandardCharsets.UTF_8), + ) + val mismatch = store.read("charger-greeting") + require(mismatch is WorkspaceMacroResult.InvalidSource) + assertEquals("workspace_id_mismatch", mismatch.issues.single().code) + } + + @Test + fun externalWorkspaceEditCannotReplaceTheApprovedRuntimeSnapshot() { + val workspaceRoot = temporaryFolder.newFolder("workspace-edit").toPath() + val privateRoot = temporaryFolder.newFolder("private-edit").toPath() + val workspace = WorkspaceMacroStore(workspaceRoot) + val approvals = ApprovalStore(privateRoot, pipeline, IncrementingClock()) + val service = WorkspaceReviewService(workspace, approvals, pipeline) + assertEquals( + WorkspaceWriteResult.Success, + workspace.write(parsed(validSource())), + ) + + val initialReview = service.review("charger-greeting") + require(initialReview is WorkspaceReviewResult.Ready) + val firstApproval = service.approve(initialReview.proposal) + require(firstApproval is ApprovalStoreResult.Success) + + val workspacePath = + workspaceRoot.resolve("macros/charger-greeting.openmacro.yaml") + Files.write( + workspacePath, + changedSource().toByteArray(StandardCharsets.UTF_8), + ) + + val stillApproved = approvals.loadCurrent("charger-greeting") + require(stillApproved is ApprovalStoreResult.Success) + val approvedMessage = stillApproved.value + ?.snapshot + ?.explanation + ?.blocks + ?.single { it.blockId == "show-message" } + ?.summary + .orEmpty() + assertTrue(approvedMessage.contains("The charger is connected.")) + assertFalse(approvedMessage.contains("Time to charge.")) + + val changedReview = service.review("charger-greeting") + require(changedReview is WorkspaceReviewResult.Ready) + assertTrue(changedReview.proposal.comparison.approvalRequired) + } + + @Test + fun approvalsAreImmutableAndRollbackCreatesAnAuditableRevision() { + val privateRoot = temporaryFolder.newFolder("private-history").toPath() + val approvals = ApprovalStore(privateRoot, pipeline, IncrementingClock()) + + val first = approvals.approve(ready(validSource())) + require(first is ApprovalStoreResult.Success) + val second = approvals.approve(ready(changedSource())) + require(second is ApprovalStoreResult.Success) + + val historyBefore = approvals.listRevisions("charger-greeting") + require(historyBefore is ApprovalStoreResult.Success) + assertEquals(2, historyBefore.value.size) + + val rollback = approvals.rollback( + macroId = "charger-greeting", + targetRevisionId = first.value.revisionId, + ) + require(rollback is ApprovalStoreResult.Success) + assertEquals(ApprovalKind.ROLLBACK, rollback.value.kind) + assertEquals(first.value.revisionId, rollback.value.restoredFromRevisionId) + assertEquals(second.value.revisionId, rollback.value.previousRevisionId) + + val current = approvals.loadCurrent("charger-greeting") + require(current is ApprovalStoreResult.Success) + assertEquals(rollback.value.revisionId, current.value?.revisionId) + assertTrue( + current.value + ?.snapshot + ?.explanation + ?.blocks + ?.single { it.blockId == "show-message" } + ?.summary + .orEmpty() + .contains("The charger is connected."), + ) + + val historyAfter = approvals.listRevisions("charger-greeting") + require(historyAfter is ApprovalStoreResult.Success) + assertEquals(3, historyAfter.value.size) + } + + @Test + fun corruptedApprovedSourceIsNeverReturnedAsRunnable() { + val privateRoot = temporaryFolder.newFolder("private-corrupt").toPath() + val approvals = ApprovalStore(privateRoot, pipeline, IncrementingClock()) + val approved = approvals.approve(ready(validSource())) + require(approved is ApprovalStoreResult.Success) + val sourcePath = privateRoot.resolve( + "approvals/charger-greeting/revisions/" + + "${approved.value.revisionId}/source.openmacro.yaml", + ) + Files.write( + sourcePath, + changedSource().toByteArray(StandardCharsets.UTF_8), + ) + + val loaded = approvals.loadCurrent("charger-greeting") + + require(loaded is ApprovalStoreResult.Failure) + assertEquals("corrupt_approval_integrity", loaded.code) + } + + private fun parsed(text: String) = when (val result = OpenMacroYamlReader.read(text)) { + is OpenMacroSourceResult.Success -> result.source + is OpenMacroSourceResult.Failure -> error("Test source did not parse: ${result.issues}") + } + + private fun ready(text: String): OpenMacroProposal { + val result = pipeline.propose(text) + require(result is ProposalResult.Ready) + return result.proposal + } + + private fun changedSource() = validSource().replace( + "message: The charger is connected.", + "message: Time to charge.", + ) + + private fun validSource() = """ + format: openmacro/v0.1 + metadata: + id: charger-greeting + name: Charger greeting + triggers: + - id: charger-connected + type: android.power.connected + conditions: + - id: device-is-unlocked + type: android.device.unlocked + actions: + - id: show-message + type: android.notification.show + config: + title: Charging started + message: The charger is connected. + """.trimIndent() + "\n" + + private class IncrementingClock : MillisecondClock { + private var next = 1_800_000_000_000L + + override fun nowEpochMillis(): Long = next++ + } +} diff --git a/docs/architecture/openmacro-foundation.md b/docs/architecture/openmacro-foundation.md index 542136d..706a64d 100644 --- a/docs/architecture/openmacro-foundation.md +++ b/docs/architecture/openmacro-foundation.md @@ -132,3 +132,27 @@ The next slice should add durable workspace and approval storage around this pipeline. It must keep source files, approved snapshots, encrypted secrets, runtime state, and diagnostic logs separate as required by the repository architecture. + +## Local storage boundary + +The first storage implementation keeps two independent roots: + +- OpenMacro Workspace stores user-owned files at + `macros/.openmacro.yaml`. These files are suitable for Git and may + change externally. +- App-private approval storage keeps immutable source snapshots and an atomic + pointer to the currently approved revision. The runtime must use this + approved snapshot, never whichever workspace contents happen to be newest. + +Each approval or rollback creates a new revision linked to the previous +revision. Snapshot fingerprints are verified again when loaded. A malformed, +unsupported, missing, or modified approved snapshot is refused rather than +returned as runnable. + +Secrets, runtime enabled state, and diagnostic logs have no directory or record +in either of these stores. They remain separate future components. + +The next slice should define runtime ownership and lifecycle around approved +plans: enabling, disabling, event subscription, cancellation, and bounded +diagnostics. It should use event-driven Android signals and begin with the power +trigger proof capability. From bfe44b8c7042d50778262e7f8c6b7740bf339964 Mon Sep 17 00:00:00 2001 From: Vibhor Goel Date: Thu, 25 Jun 2026 14:32:10 +0530 Subject: [PATCH 6/8] Add approved macro runtime lifecycle --- app/src/main/AndroidManifest.xml | 3 +- .../openmacro/runtime/RuntimeCoordinator.kt | 391 ++++++++++++++++++ .../openmacro/runtime/RuntimeDiagnostics.kt | 84 ++++ .../zerobit/openmacro/runtime/RuntimeOwner.kt | 27 ++ .../zerobit/openmacro/runtime/RuntimePorts.kt | 93 +++++ .../runtime/android/AndroidRuntimePorts.kt | 177 ++++++++ .../runtime/RuntimeCoordinatorTest.kt | 366 ++++++++++++++++ docs/architecture/openmacro-foundation.md | 27 ++ 8 files changed, 1167 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/com/vibhor1102/zerobit/openmacro/runtime/RuntimeCoordinator.kt create mode 100644 app/src/main/java/com/vibhor1102/zerobit/openmacro/runtime/RuntimeDiagnostics.kt create mode 100644 app/src/main/java/com/vibhor1102/zerobit/openmacro/runtime/RuntimeOwner.kt create mode 100644 app/src/main/java/com/vibhor1102/zerobit/openmacro/runtime/RuntimePorts.kt create mode 100644 app/src/main/java/com/vibhor1102/zerobit/openmacro/runtime/android/AndroidRuntimePorts.kt create mode 100644 app/src/test/java/com/vibhor1102/zerobit/openmacro/runtime/RuntimeCoordinatorTest.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f41114f..db48685 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,6 +1,8 @@ + + - diff --git a/app/src/main/java/com/vibhor1102/zerobit/openmacro/runtime/RuntimeCoordinator.kt b/app/src/main/java/com/vibhor1102/zerobit/openmacro/runtime/RuntimeCoordinator.kt new file mode 100644 index 0000000..97aecd4 --- /dev/null +++ b/app/src/main/java/com/vibhor1102/zerobit/openmacro/runtime/RuntimeCoordinator.kt @@ -0,0 +1,391 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.vibhor1102.zerobit.openmacro.runtime + +import com.vibhor1102.zerobit.openmacro.capability.AndroidPermission + +/** + * Owns enabled macro subscriptions and deterministic executions. + * + * The coordinator only accepts plans loaded from ApprovedPlanProvider. Queued + * callbacks carry a generation token and become no-ops after disable/re-enable. + */ +class RuntimeCoordinator( + private val approvedPlans: ApprovedPlanProvider, + private val triggerRegistrar: RuntimeTriggerRegistrar, + private val conditionEvaluator: RuntimeConditionEvaluator, + private val actionExecutor: RuntimeActionExecutor, + private val permissionChecker: RuntimePermissionChecker, + private val dispatcher: RuntimeTaskDispatcher, + private val diagnostics: BoundedRuntimeDiagnostics, +) { + private val lock = Any() + private val sessions = mutableMapOf() + private var nextGeneration = 1L + private var nextRunId = 1L + + fun enable(macroId: String): RuntimeLifecycleResult { + val approved = when (val result = approvedPlans.loadCurrent(macroId)) { + is ApprovedPlanResult.Failure -> { + return enableFailure(macroId, result.message) + } + ApprovedPlanResult.Missing -> { + return enableFailure(macroId, "No approved snapshot is available.") + } + is ApprovedPlanResult.Success -> result + } + if (approved.plan.macroId != macroId) { + return enableFailure(macroId, "The approved plan belongs to a different macro.") + } + + val missingPermissions = + permissionChecker.missingPermissions(approved.plan.requiredPermissions) + if (missingPermissions.isNotEmpty()) { + return enableFailure( + macroId, + "Missing permissions: ${missingPermissions.sortedBy { it.name }.joinToString { it.manifestName }}", + missingPermissions, + ) + } + + val generation = synchronized(lock) { nextGeneration++ } + val subscriptions = mutableListOf() + approved.plan.triggers.forEach { trigger -> + val result = try { + triggerRegistrar.subscribe( + macroId = macroId, + trigger = trigger, + onTriggered = { queueTrigger(macroId, generation, trigger.blockId) }, + ) + } catch (problem: RuntimeException) { + TriggerSubscriptionResult.Failure( + problem.message ?: "Trigger subscription failed.", + ) + } + when (result) { + is TriggerSubscriptionResult.Failure -> { + cancelAll(subscriptions) + return enableFailure( + macroId, + "Could not subscribe trigger '${trigger.blockId}': ${result.message}", + ) + } + is TriggerSubscriptionResult.Success -> + subscriptions += result.cancellation + } + } + + val previous = synchronized(lock) { + sessions.put( + macroId, + EnabledSession( + generation = generation, + revisionId = approved.revisionId, + plan = approved.plan, + subscriptions = subscriptions, + ), + ) + } + previous?.let { cancelAll(it.subscriptions) } + diagnostics.record( + macroId = macroId, + kind = RuntimeDiagnosticKind.ENABLED, + message = "Enabled approved revision ${approved.revisionId}.", + ) + return RuntimeLifecycleResult.Enabled( + revisionId = approved.revisionId, + triggerCount = subscriptions.size, + ) + } + + fun disable(macroId: String): RuntimeLifecycleResult = + disable(macroId, recordWhenMissing = true) + + fun isEnabled(macroId: String): Boolean = synchronized(lock) { + macroId in sessions + } + + fun enabledMacroIds(): Set = synchronized(lock) { + sessions.keys.toSet() + } + + fun disableAll() { + enabledMacroIds().forEach(::disable) + } + + private fun disable( + macroId: String, + recordWhenMissing: Boolean, + ): RuntimeLifecycleResult { + val removed = synchronized(lock) { sessions.remove(macroId) } + if (removed == null) { + if (recordWhenMissing) { + diagnostics.record( + macroId = macroId, + kind = RuntimeDiagnosticKind.DISABLED, + message = "Macro was already disabled.", + ) + } + return RuntimeLifecycleResult.AlreadyDisabled + } + cancelAll(removed.subscriptions) + diagnostics.record( + macroId = macroId, + kind = RuntimeDiagnosticKind.DISABLED, + message = "Disabled approved revision ${removed.revisionId}.", + ) + return RuntimeLifecycleResult.Disabled + } + + private fun queueTrigger( + macroId: String, + generation: Long, + triggerBlockId: String, + ) { + try { + dispatcher.dispatch { + executeTrigger(macroId, generation, triggerBlockId) + } + } catch (problem: RuntimeException) { + diagnostics.record( + macroId = macroId, + kind = RuntimeDiagnosticKind.TRIGGER_DISPATCH_FAILED, + blockId = triggerBlockId, + message = problem.message ?: "Could not queue trigger work.", + ) + } + } + + private fun executeTrigger( + macroId: String, + generation: Long, + triggerBlockId: String, + ) { + val start = synchronized(lock) { + val session = sessions[macroId] + if (session == null || session.generation != generation) { + return + } + if (session.executing) { + null + } else { + session.executing = true + RunStart( + runId = nextRunId++, + plan = session.plan, + ) + } + } + if (start == null) { + diagnostics.record( + macroId = macroId, + kind = RuntimeDiagnosticKind.TRIGGER_IGNORED_BUSY, + blockId = triggerBlockId, + message = "Ignored trigger while this macro was already running.", + ) + return + } + + diagnostics.record( + macroId = macroId, + kind = RuntimeDiagnosticKind.TRIGGER_RECEIVED, + runId = start.runId, + blockId = triggerBlockId, + message = "Trigger started evaluation.", + ) + try { + if ( + !conditionsPass( + macroId, + generation, + start.runId, + start.plan.conditions, + ) + ) { + return + } + if ( + !actionsSucceed( + macroId, + generation, + start.runId, + start.plan.actions, + ) + ) { + return + } + if (!isSessionActive(macroId, generation)) { + recordCancellation(macroId, start.runId) + return + } + diagnostics.record( + macroId = macroId, + kind = RuntimeDiagnosticKind.RUN_SUCCEEDED, + runId = start.runId, + message = "All actions completed.", + ) + } finally { + synchronized(lock) { + sessions[macroId] + ?.takeIf { it.generation == generation } + ?.executing = false + } + } + } + + private fun conditionsPass( + macroId: String, + generation: Long, + runId: Long, + conditions: List, + ): Boolean { + conditions.forEach { condition -> + if (!isSessionActive(macroId, generation)) { + recordCancellation(macroId, runId) + return false + } + val result = try { + conditionEvaluator.evaluate(condition) + } catch (problem: RuntimeException) { + ConditionResult.Failed(problem.message ?: "Condition evaluation failed.") + } + when (result) { + ConditionResult.Passed -> diagnostics.record( + macroId = macroId, + kind = RuntimeDiagnosticKind.CONDITION_PASSED, + runId = runId, + blockId = condition.blockId, + message = "Condition passed.", + ) + is ConditionResult.Blocked -> { + diagnostics.record( + macroId = macroId, + kind = RuntimeDiagnosticKind.CONDITION_BLOCKED, + runId = runId, + blockId = condition.blockId, + message = result.reason, + ) + return false + } + is ConditionResult.Failed -> { + diagnostics.record( + macroId = macroId, + kind = RuntimeDiagnosticKind.CONDITION_FAILED, + runId = runId, + blockId = condition.blockId, + message = result.message, + ) + return false + } + } + } + return true + } + + private fun actionsSucceed( + macroId: String, + generation: Long, + runId: Long, + actions: List, + ): Boolean { + actions.forEach { action -> + if (!isSessionActive(macroId, generation)) { + recordCancellation(macroId, runId) + return false + } + val result = try { + actionExecutor.execute(action) + } catch (problem: RuntimeException) { + ActionResult.Failed(problem.message ?: "Action execution failed.") + } + when (result) { + ActionResult.Succeeded -> diagnostics.record( + macroId = macroId, + kind = RuntimeDiagnosticKind.ACTION_SUCCEEDED, + runId = runId, + blockId = action.blockId, + message = "Action completed.", + ) + is ActionResult.Failed -> { + diagnostics.record( + macroId = macroId, + kind = RuntimeDiagnosticKind.ACTION_FAILED, + runId = runId, + blockId = action.blockId, + message = result.message, + ) + return false + } + } + if (!isSessionActive(macroId, generation)) { + recordCancellation(macroId, runId) + return false + } + } + return true + } + + private fun isSessionActive(macroId: String, generation: Long): Boolean = + synchronized(lock) { + sessions[macroId]?.generation == generation + } + + private fun recordCancellation(macroId: String, runId: Long) { + diagnostics.record( + macroId = macroId, + kind = RuntimeDiagnosticKind.RUN_CANCELLED, + runId = runId, + message = "Run stopped because the macro was disabled or replaced.", + ) + } + + private fun enableFailure( + macroId: String, + message: String, + missingPermissions: Set = emptySet(), + ): RuntimeLifecycleResult.EnableFailed { + diagnostics.record( + macroId = macroId, + kind = RuntimeDiagnosticKind.ENABLE_FAILED, + message = message, + ) + return RuntimeLifecycleResult.EnableFailed(message, missingPermissions) + } + + private fun cancelAll(subscriptions: List) { + subscriptions.asReversed().forEach { cancellation -> + runCatching(cancellation::cancel) + } + } + + private data class EnabledSession( + val generation: Long, + val revisionId: String, + val plan: RuntimePlan, + val subscriptions: List, + var executing: Boolean = false, + ) + + private data class RunStart( + val runId: Long, + val plan: RuntimePlan, + ) +} + +sealed interface RuntimeLifecycleResult { + data class Enabled( + val revisionId: String, + val triggerCount: Int, + ) : RuntimeLifecycleResult + + data class EnableFailed( + val message: String, + val missingPermissions: Set = emptySet(), + ) : RuntimeLifecycleResult + + data object Disabled : RuntimeLifecycleResult + + data object AlreadyDisabled : RuntimeLifecycleResult +} diff --git a/app/src/main/java/com/vibhor1102/zerobit/openmacro/runtime/RuntimeDiagnostics.kt b/app/src/main/java/com/vibhor1102/zerobit/openmacro/runtime/RuntimeDiagnostics.kt new file mode 100644 index 0000000..8a094e4 --- /dev/null +++ b/app/src/main/java/com/vibhor1102/zerobit/openmacro/runtime/RuntimeDiagnostics.kt @@ -0,0 +1,84 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.vibhor1102.zerobit.openmacro.runtime + +class BoundedRuntimeDiagnostics( + private val capacity: Int = DEFAULT_CAPACITY, + private val clock: RuntimeClock = RuntimeClock.System, +) { + private val events = ArrayDeque(capacity) + private var nextSequence = 1L + + init { + require(capacity > 0) { "Diagnostic capacity must be positive." } + } + + @Synchronized + fun record( + macroId: String, + kind: RuntimeDiagnosticKind, + runId: Long? = null, + blockId: String? = null, + message: String, + ) { + if (events.size == capacity) { + events.removeFirst() + } + events.addLast( + RuntimeDiagnosticEvent( + sequence = nextSequence++, + timestampEpochMillis = clock.nowEpochMillis(), + macroId = macroId, + runId = runId, + blockId = blockId, + kind = kind, + message = message.take(MAX_MESSAGE_LENGTH), + ), + ) + } + + @Synchronized + fun snapshot(macroId: String? = null): List = + events.filter { macroId == null || it.macroId == macroId } + + companion object { + const val DEFAULT_CAPACITY = 500 + const val MAX_MESSAGE_LENGTH = 500 + } +} + +data class RuntimeDiagnosticEvent( + val sequence: Long, + val timestampEpochMillis: Long, + val macroId: String, + val runId: Long?, + val blockId: String?, + val kind: RuntimeDiagnosticKind, + val message: String, +) + +enum class RuntimeDiagnosticKind { + ENABLED, + ENABLE_FAILED, + DISABLED, + TRIGGER_RECEIVED, + TRIGGER_DISPATCH_FAILED, + TRIGGER_IGNORED_BUSY, + CONDITION_PASSED, + CONDITION_BLOCKED, + CONDITION_FAILED, + ACTION_SUCCEEDED, + ACTION_FAILED, + RUN_CANCELLED, + RUN_SUCCEEDED, +} + +fun interface RuntimeClock { + fun nowEpochMillis(): Long + + data object System : RuntimeClock { + override fun nowEpochMillis(): Long = java.lang.System.currentTimeMillis() + } +} diff --git a/app/src/main/java/com/vibhor1102/zerobit/openmacro/runtime/RuntimeOwner.kt b/app/src/main/java/com/vibhor1102/zerobit/openmacro/runtime/RuntimeOwner.kt new file mode 100644 index 0000000..e67fac8 --- /dev/null +++ b/app/src/main/java/com/vibhor1102/zerobit/openmacro/runtime/RuntimeOwner.kt @@ -0,0 +1,27 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.vibhor1102.zerobit.openmacro.runtime + +import java.io.Closeable + +/** + * Gives the runtime one explicit lifecycle owner. + */ +class RuntimeOwner( + val coordinator: RuntimeCoordinator, + private val ownedResources: List = emptyList(), +) : Closeable { + private var closed = false + + @Synchronized + override fun close() { + if (closed) return + closed = true + coordinator.disableAll() + ownedResources.asReversed().forEach { resource -> + runCatching(resource::close) + } + } +} diff --git a/app/src/main/java/com/vibhor1102/zerobit/openmacro/runtime/RuntimePorts.kt b/app/src/main/java/com/vibhor1102/zerobit/openmacro/runtime/RuntimePorts.kt new file mode 100644 index 0000000..f106052 --- /dev/null +++ b/app/src/main/java/com/vibhor1102/zerobit/openmacro/runtime/RuntimePorts.kt @@ -0,0 +1,93 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.vibhor1102.zerobit.openmacro.runtime + +import com.vibhor1102.zerobit.openmacro.capability.AndroidPermission +import com.vibhor1102.zerobit.openmacro.storage.ApprovalStoreResult +import com.vibhor1102.zerobit.openmacro.storage.ApprovedRevision + +fun interface RuntimeTaskDispatcher { + fun dispatch(task: () -> Unit) +} + +fun interface RuntimeCancellation { + fun cancel() +} + +interface RuntimeTriggerRegistrar { + fun subscribe( + macroId: String, + trigger: RuntimeStep, + onTriggered: () -> Unit, + ): TriggerSubscriptionResult +} + +sealed interface TriggerSubscriptionResult { + data class Success( + val cancellation: RuntimeCancellation, + ) : TriggerSubscriptionResult + + data class Failure( + val message: String, + ) : TriggerSubscriptionResult +} + +fun interface RuntimeConditionEvaluator { + fun evaluate(condition: RuntimeStep): ConditionResult +} + +sealed interface ConditionResult { + data object Passed : ConditionResult + + data class Blocked(val reason: String) : ConditionResult + + data class Failed(val message: String) : ConditionResult +} + +fun interface RuntimeActionExecutor { + fun execute(action: RuntimeStep): ActionResult +} + +sealed interface ActionResult { + data object Succeeded : ActionResult + + data class Failed(val message: String) : ActionResult +} + +fun interface RuntimePermissionChecker { + fun missingPermissions(required: Set): Set +} + +fun interface ApprovedPlanProvider { + fun loadCurrent(macroId: String): ApprovedPlanResult +} + +sealed interface ApprovedPlanResult { + data class Success( + val revisionId: String, + val plan: RuntimePlan, + ) : ApprovedPlanResult + + data object Missing : ApprovedPlanResult + + data class Failure( + val message: String, + ) : ApprovedPlanResult +} + +class ApprovalStorePlanProvider( + private val load: (String) -> ApprovalStoreResult, +) : ApprovedPlanProvider { + override fun loadCurrent(macroId: String): ApprovedPlanResult = + when (val result = load(macroId)) { + is ApprovalStoreResult.Failure -> ApprovedPlanResult.Failure(result.message) + is ApprovalStoreResult.Success -> result.value?.let { + ApprovedPlanResult.Success( + revisionId = it.revisionId, + plan = it.snapshot.runtimePlan, + ) + } ?: ApprovedPlanResult.Missing + } +} diff --git a/app/src/main/java/com/vibhor1102/zerobit/openmacro/runtime/android/AndroidRuntimePorts.kt b/app/src/main/java/com/vibhor1102/zerobit/openmacro/runtime/android/AndroidRuntimePorts.kt new file mode 100644 index 0000000..ef3b905 --- /dev/null +++ b/app/src/main/java/com/vibhor1102/zerobit/openmacro/runtime/android/AndroidRuntimePorts.kt @@ -0,0 +1,177 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.vibhor1102.zerobit.openmacro.runtime.android + +import android.Manifest +import android.annotation.SuppressLint +import android.app.KeyguardManager +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.pm.PackageManager +import android.os.Build +import com.vibhor1102.zerobit.R +import com.vibhor1102.zerobit.openmacro.capability.AndroidPermission +import com.vibhor1102.zerobit.openmacro.runtime.ActionResult +import com.vibhor1102.zerobit.openmacro.runtime.ConditionResult +import com.vibhor1102.zerobit.openmacro.runtime.RuntimeActionExecutor +import com.vibhor1102.zerobit.openmacro.runtime.RuntimeCancellation +import com.vibhor1102.zerobit.openmacro.runtime.RuntimeConditionEvaluator +import com.vibhor1102.zerobit.openmacro.runtime.RuntimePermissionChecker +import com.vibhor1102.zerobit.openmacro.runtime.RuntimeStep +import com.vibhor1102.zerobit.openmacro.runtime.RuntimeTaskDispatcher +import com.vibhor1102.zerobit.openmacro.runtime.RuntimeTriggerRegistrar +import com.vibhor1102.zerobit.openmacro.runtime.TriggerSubscriptionResult +import java.util.concurrent.Executor +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger + +class AndroidPowerTriggerRegistrar( + context: Context, +) : RuntimeTriggerRegistrar { + private val appContext = context.applicationContext + + override fun subscribe( + macroId: String, + trigger: RuntimeStep, + onTriggered: () -> Unit, + ): TriggerSubscriptionResult { + if (trigger !is RuntimeStep.ObservePowerConnected) { + return TriggerSubscriptionResult.Failure( + "Android power registrar does not support ${trigger::class.simpleName}.", + ) + } + + val receiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + if (intent?.action == Intent.ACTION_POWER_CONNECTED) { + onTriggered() + } + } + } + return try { + registerReceiver(receiver) + val cancelled = AtomicBoolean(false) + TriggerSubscriptionResult.Success( + RuntimeCancellation { + if (cancelled.compareAndSet(false, true)) { + appContext.unregisterReceiver(receiver) + } + }, + ) + } catch (problem: RuntimeException) { + TriggerSubscriptionResult.Failure( + problem.message ?: "Could not register Android power receiver.", + ) + } + } + + @Suppress("DEPRECATION") + private fun registerReceiver(receiver: BroadcastReceiver) { + val filter = IntentFilter(Intent.ACTION_POWER_CONNECTED) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + appContext.registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED) + } else { + appContext.registerReceiver(receiver, filter) + } + } +} + +class AndroidConditionEvaluator( + context: Context, +) : RuntimeConditionEvaluator { + private val keyguardManager = + context.getSystemService(KeyguardManager::class.java) + + override fun evaluate(condition: RuntimeStep): ConditionResult = when (condition) { + is RuntimeStep.CheckDeviceUnlocked -> { + if (keyguardManager == null) { + ConditionResult.Failed("Android keyguard service is unavailable.") + } else if (keyguardManager.isDeviceLocked) { + ConditionResult.Blocked("The device is locked.") + } else { + ConditionResult.Passed + } + } + else -> ConditionResult.Failed( + "Unsupported Android condition ${condition::class.simpleName}.", + ) + } +} + +class AndroidNotificationActionExecutor( + context: Context, +) : RuntimeActionExecutor { + private val appContext = context.applicationContext + private val notificationManager = + appContext.getSystemService(NotificationManager::class.java) + private val nextNotificationId = AtomicInteger(1) + + override fun execute(action: RuntimeStep): ActionResult = when (action) { + is RuntimeStep.ShowNotification -> show(action) + else -> ActionResult.Failed( + "Unsupported Android action ${action::class.simpleName}.", + ) + } + + @SuppressLint("MissingPermission") + private fun show(action: RuntimeStep.ShowNotification): ActionResult { + val manager = notificationManager + ?: return ActionResult.Failed("Android notification service is unavailable.") + return try { + manager.createNotificationChannel( + NotificationChannel( + CHANNEL_ID, + CHANNEL_NAME, + NotificationManager.IMPORTANCE_DEFAULT, + ), + ) + val notification = Notification.Builder(appContext, CHANNEL_ID) + .setSmallIcon(R.drawable.zerobit_mark) + .setContentTitle(action.title) + .setContentText(action.message) + .setAutoCancel(true) + .build() + manager.notify(nextNotificationId.getAndIncrement(), notification) + ActionResult.Succeeded + } catch (problem: RuntimeException) { + ActionResult.Failed(problem.message ?: "Could not show the notification.") + } + } + + private companion object { + const val CHANNEL_ID = "zerobit_macro_notifications" + const val CHANNEL_NAME = "Macro notifications" + } +} + +class AndroidRuntimePermissionChecker( + context: Context, +) : RuntimePermissionChecker { + private val appContext = context.applicationContext + + override fun missingPermissions( + required: Set, + ): Set = required.filterTo(mutableSetOf()) { permission -> + when (permission) { + AndroidPermission.POST_NOTIFICATIONS -> + Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && + appContext.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != + PackageManager.PERMISSION_GRANTED + } + } +} + +class ExecutorRuntimeTaskDispatcher( + private val executor: Executor, +) : RuntimeTaskDispatcher { + override fun dispatch(task: () -> Unit) { + executor.execute(task) + } +} diff --git a/app/src/test/java/com/vibhor1102/zerobit/openmacro/runtime/RuntimeCoordinatorTest.kt b/app/src/test/java/com/vibhor1102/zerobit/openmacro/runtime/RuntimeCoordinatorTest.kt new file mode 100644 index 0000000..d038808 --- /dev/null +++ b/app/src/test/java/com/vibhor1102/zerobit/openmacro/runtime/RuntimeCoordinatorTest.kt @@ -0,0 +1,366 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.vibhor1102.zerobit.openmacro.runtime + +import com.vibhor1102.zerobit.openmacro.capability.AndroidPermission +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class RuntimeCoordinatorTest { + @Test + fun enablesApprovedPlanAndRunsConditionsThenActions() { + val fixture = Fixture() + + val enabled = fixture.coordinator.enable("charger-greeting") + fixture.registrar.fire("charger-connected") + + assertEquals(RuntimeLifecycleResult.Enabled("revision-1", 1), enabled) + assertEquals(listOf("device-unlocked"), fixture.conditions.evaluatedBlockIds) + assertEquals(listOf("show-message"), fixture.actions.executedBlockIds) + assertEquals( + listOf( + RuntimeDiagnosticKind.ENABLED, + RuntimeDiagnosticKind.TRIGGER_RECEIVED, + RuntimeDiagnosticKind.CONDITION_PASSED, + RuntimeDiagnosticKind.ACTION_SUCCEEDED, + RuntimeDiagnosticKind.RUN_SUCCEEDED, + ), + fixture.diagnostics.snapshot().map { it.kind }, + ) + val runEvents = fixture.diagnostics.snapshot().filter { it.runId != null } + assertEquals(1, runEvents.map { it.runId }.distinct().size) + } + + @Test + fun refusesMissingApprovalOrPermissionBeforeSubscribing() { + val noApproval = Fixture(planResult = ApprovedPlanResult.Missing) + val missingResult = noApproval.coordinator.enable("charger-greeting") + require(missingResult is RuntimeLifecycleResult.EnableFailed) + assertTrue(missingResult.message.contains("No approved snapshot")) + assertTrue(noApproval.registrar.callbacks.isEmpty()) + + val missingPermission = Fixture( + missingPermissions = setOf(AndroidPermission.POST_NOTIFICATIONS), + ) + val permissionResult = missingPermission.coordinator.enable("charger-greeting") + require(permissionResult is RuntimeLifecycleResult.EnableFailed) + assertEquals( + setOf(AndroidPermission.POST_NOTIFICATIONS), + permissionResult.missingPermissions, + ) + assertTrue(missingPermission.registrar.callbacks.isEmpty()) + } + + @Test + fun blockedConditionExplainsWhyActionsDidNotRun() { + val fixture = Fixture() + fixture.conditions.result = ConditionResult.Blocked("The phone is locked.") + fixture.coordinator.enable("charger-greeting") + + fixture.registrar.fire("charger-connected") + + assertTrue(fixture.actions.executedBlockIds.isEmpty()) + assertEquals( + RuntimeDiagnosticKind.CONDITION_BLOCKED, + fixture.diagnostics.snapshot().last().kind, + ) + assertEquals("The phone is locked.", fixture.diagnostics.snapshot().last().message) + } + + @Test + fun failedActionStopsLaterActionsAndIsContained() { + val fixture = Fixture(plan = planWithTwoActions()) + fixture.actions.results["first-action"] = ActionResult.Failed("Notifications are unavailable.") + fixture.coordinator.enable("charger-greeting") + + fixture.registrar.fire("charger-connected") + + assertEquals(listOf("first-action"), fixture.actions.executedBlockIds) + assertEquals( + RuntimeDiagnosticKind.ACTION_FAILED, + fixture.diagnostics.snapshot().last().kind, + ) + } + + @Test + fun queuedTriggerBecomesHarmlessAfterDisable() { + val dispatcher = ManualDispatcher() + val fixture = Fixture(dispatcher = dispatcher) + fixture.coordinator.enable("charger-greeting") + fixture.registrar.fire("charger-connected") + + assertEquals(RuntimeLifecycleResult.Disabled, fixture.coordinator.disable("charger-greeting")) + dispatcher.runAll() + + assertFalse(fixture.coordinator.isEnabled("charger-greeting")) + assertTrue(fixture.conditions.evaluatedBlockIds.isEmpty()) + assertTrue(fixture.actions.executedBlockIds.isEmpty()) + assertTrue(fixture.registrar.cancelledBlockIds.contains("charger-connected")) + } + + @Test + fun overlappingTriggerIsIgnoredWhileMacroIsRunning() { + val fixture = Fixture() + fixture.actions.onExecute = { + fixture.registrar.fire("charger-connected") + } + fixture.coordinator.enable("charger-greeting") + + fixture.registrar.fire("charger-connected") + + assertEquals(listOf("show-message"), fixture.actions.executedBlockIds) + assertTrue( + fixture.diagnostics.snapshot() + .any { it.kind == RuntimeDiagnosticKind.TRIGGER_IGNORED_BUSY }, + ) + } + + @Test + fun disableDuringRunStopsBeforeTheNextAction() { + val fixture = Fixture(plan = planWithTwoActions()) + fixture.actions.onExecute = { action -> + if (action.blockId == "first-action") { + fixture.coordinator.disable("charger-greeting") + } + } + fixture.coordinator.enable("charger-greeting") + + fixture.registrar.fire("charger-connected") + + assertEquals(listOf("first-action"), fixture.actions.executedBlockIds) + assertEquals( + RuntimeDiagnosticKind.RUN_CANCELLED, + fixture.diagnostics.snapshot().last().kind, + ) + } + + @Test + fun dispatcherFailureIsContainedAndDiagnosed() { + val fixture = Fixture( + dispatcher = RuntimeTaskDispatcher { + throw IllegalStateException("Executor is closed.") + }, + ) + fixture.coordinator.enable("charger-greeting") + + fixture.registrar.fire("charger-connected") + + assertEquals( + RuntimeDiagnosticKind.TRIGGER_DISPATCH_FAILED, + fixture.diagnostics.snapshot().last().kind, + ) + assertTrue(fixture.actions.executedBlockIds.isEmpty()) + } + + @Test + fun failedReenableKeepsExistingSessionAlive() { + val fixture = Fixture() + fixture.coordinator.enable("charger-greeting") + fixture.registrar.failSubscriptions = true + + val result = fixture.coordinator.enable("charger-greeting") + fixture.registrar.fire("charger-connected") + + assertTrue(result is RuntimeLifecycleResult.EnableFailed) + assertTrue(fixture.coordinator.isEnabled("charger-greeting")) + assertEquals(listOf("show-message"), fixture.actions.executedBlockIds) + } + + @Test + fun successfulReenableReplacesOldSubscriptionsWithoutCancellingNewOnes() { + val fixture = Fixture() + fixture.coordinator.enable("charger-greeting") + + val result = fixture.coordinator.enable("charger-greeting") + fixture.registrar.fire("charger-connected") + + assertTrue(result is RuntimeLifecycleResult.Enabled) + assertEquals(listOf("show-message"), fixture.actions.executedBlockIds) + assertEquals(1, fixture.registrar.callbacks.size) + assertEquals(listOf("charger-connected"), fixture.registrar.cancelledBlockIds) + } + + @Test + fun partialEnableFailureCancelsOnlyNewSubscriptions() { + val fixture = Fixture(plan = planWithTwoTriggers()) + fixture.registrar.failOnBlockId = "second-trigger" + + val result = fixture.coordinator.enable("charger-greeting") + + assertTrue(result is RuntimeLifecycleResult.EnableFailed) + assertFalse(fixture.coordinator.isEnabled("charger-greeting")) + assertTrue(fixture.registrar.callbacks.isEmpty()) + assertEquals(listOf("first-trigger"), fixture.registrar.cancelledBlockIds) + } + + @Test + fun diagnosticsAreBoundedAndMessagesAreTruncated() { + val diagnostics = BoundedRuntimeDiagnostics( + capacity = 2, + clock = RuntimeClock { 123L }, + ) + repeat(3) { index -> + diagnostics.record( + macroId = "macro", + kind = RuntimeDiagnosticKind.ENABLE_FAILED, + message = if (index == 2) "x".repeat(600) else "event-$index", + ) + } + + val events = diagnostics.snapshot() + + assertEquals(2, events.size) + assertEquals(listOf(2L, 3L), events.map { it.sequence }) + assertEquals(BoundedRuntimeDiagnostics.MAX_MESSAGE_LENGTH, events.last().message.length) + } + + @Test + fun runtimeOwnerCancelsAllSubscriptionsAndOwnedResources() { + val fixture = Fixture() + fixture.coordinator.enable("charger-greeting") + var resourceClosed = false + val owner = RuntimeOwner( + coordinator = fixture.coordinator, + ownedResources = listOf(java.io.Closeable { resourceClosed = true }), + ) + + owner.close() + owner.close() + + assertTrue(resourceClosed) + assertTrue(fixture.coordinator.enabledMacroIds().isEmpty()) + assertEquals(listOf("charger-connected"), fixture.registrar.cancelledBlockIds) + } + + private class Fixture( + plan: RuntimePlan = validPlan(), + planResult: ApprovedPlanResult = ApprovedPlanResult.Success("revision-1", plan), + missingPermissions: Set = emptySet(), + dispatcher: RuntimeTaskDispatcher = RuntimeTaskDispatcher { it() }, + ) { + val registrar = FakeTriggerRegistrar() + val conditions = FakeConditionEvaluator() + val actions = FakeActionExecutor() + val diagnostics = BoundedRuntimeDiagnostics(clock = RuntimeClock { 1_000L }) + val coordinator = RuntimeCoordinator( + approvedPlans = ApprovedPlanProvider { planResult }, + triggerRegistrar = registrar, + conditionEvaluator = conditions, + actionExecutor = actions, + permissionChecker = RuntimePermissionChecker { missingPermissions }, + dispatcher = dispatcher, + diagnostics = diagnostics, + ) + } + + private class FakeTriggerRegistrar : RuntimeTriggerRegistrar { + val callbacks = linkedMapOf Unit>() + val cancelledBlockIds = mutableListOf() + var failSubscriptions = false + var failOnBlockId: String? = null + + override fun subscribe( + macroId: String, + trigger: RuntimeStep, + onTriggered: () -> Unit, + ): TriggerSubscriptionResult { + if (failSubscriptions || failOnBlockId == trigger.blockId) { + return TriggerSubscriptionResult.Failure("Receiver registration failed.") + } + callbacks[trigger.blockId] = onTriggered + return TriggerSubscriptionResult.Success( + RuntimeCancellation { + callbacks.remove(trigger.blockId, onTriggered) + cancelledBlockIds += trigger.blockId + }, + ) + } + + fun fire(blockId: String) { + callbacks.getValue(blockId).invoke() + } + } + + private class FakeConditionEvaluator : RuntimeConditionEvaluator { + val evaluatedBlockIds = mutableListOf() + var result: ConditionResult = ConditionResult.Passed + + override fun evaluate(condition: RuntimeStep): ConditionResult { + evaluatedBlockIds += condition.blockId + return result + } + } + + private class FakeActionExecutor : RuntimeActionExecutor { + val executedBlockIds = mutableListOf() + val results = mutableMapOf() + var onExecute: ((RuntimeStep) -> Unit)? = null + + override fun execute(action: RuntimeStep): ActionResult { + executedBlockIds += action.blockId + onExecute?.invoke(action) + return results[action.blockId] ?: ActionResult.Succeeded + } + } + + private class ManualDispatcher : RuntimeTaskDispatcher { + private val tasks = ArrayDeque<() -> Unit>() + + override fun dispatch(task: () -> Unit) { + tasks += task + } + + fun runAll() { + while (tasks.isNotEmpty()) { + tasks.removeFirst().invoke() + } + } + } + + companion object { + private fun validPlan() = RuntimePlan( + macroId = "charger-greeting", + sourceFingerprint = "sha256:test", + triggers = listOf( + RuntimeStep.ObservePowerConnected("charger-connected"), + ), + conditions = listOf( + RuntimeStep.CheckDeviceUnlocked("device-unlocked"), + ), + actions = listOf( + RuntimeStep.ShowNotification( + blockId = "show-message", + title = "Charging", + message = "Connected", + ), + ), + requiredPermissions = setOf(AndroidPermission.POST_NOTIFICATIONS), + ) + + private fun planWithTwoActions() = validPlan().copy( + actions = listOf( + RuntimeStep.ShowNotification( + blockId = "first-action", + title = "First", + message = "First", + ), + RuntimeStep.ShowNotification( + blockId = "second-action", + title = "Second", + message = "Second", + ), + ), + ) + + private fun planWithTwoTriggers() = validPlan().copy( + triggers = listOf( + RuntimeStep.ObservePowerConnected("first-trigger"), + RuntimeStep.ObservePowerConnected("second-trigger"), + ), + ) + } +} diff --git a/docs/architecture/openmacro-foundation.md b/docs/architecture/openmacro-foundation.md index 706a64d..1388481 100644 --- a/docs/architecture/openmacro-foundation.md +++ b/docs/architecture/openmacro-foundation.md @@ -156,3 +156,30 @@ The next slice should define runtime ownership and lifecycle around approved plans: enabling, disabling, event subscription, cancellation, and bounded diagnostics. It should use event-driven Android signals and begin with the power trigger proof capability. + +## Runtime lifecycle + +The first runtime coordinator accepts plans only through the approved-plan +provider. Enabling checks permissions before subscribing. Each enabled macro +owns its trigger subscriptions, and disabling or closing the runtime owner +cancels them. + +Trigger callbacks enqueue work with a generation token. Work queued by an older +enable session becomes harmless after disable or re-enable. One macro executes +at most one run at a time: overlapping trigger events are recorded and ignored. +Conditions run in order and fail closed; actions run in order and stop on the +first failure. + +Runtime diagnostics use a bounded in-memory ring. Events carry a macro id, +optional run id and block id, timestamp, outcome, and a capped message. This +answers why a macro ran or did not run without creating an unbounded history. + +The Android proof adapters use the `ACTION_POWER_CONNECTED` broadcast, +`KeyguardManager` lock state, and local `NotificationManager`. The power +trigger is callback-driven and exists only while a macro owns the subscription; +there is no polling loop or permanent background service. + +The next slice should provide the first app UI around these foundations: +the three-lane macro overview, source view, proposal review, and permission +discovery. Runtime enable state should then gain a separate durable store and +Android process-restoration owner. From b54557b68d12ad1d3882d4b0933891dc659625dd Mon Sep 17 00:00:00 2001 From: Vibhor Goel Date: Thu, 25 Jun 2026 14:40:18 +0530 Subject: [PATCH 7/8] Add visual and code macro editor --- AGENTS.md | 2 + THIRD_PARTY_NOTICES.md | 10 + app/build.gradle.kts | 1 + .../com/vibhor1102/zerobit/MainActivity.kt | 135 ++---- .../zerobit/ui/editor/MacroEditorScreen.kt | 407 ++++++++++++++++++ .../zerobit/ui/editor/MacroEditorSession.kt | 100 +++++ .../zerobit/ui/editor/SampleMacro.kt | 31 ++ .../ui/editor/MacroEditorSessionTest.kt | 112 +++++ docs/architecture/openmacro-foundation.md | 20 + gradle/libs.versions.toml | 2 + 10 files changed, 727 insertions(+), 93 deletions(-) create mode 100644 app/src/main/java/com/vibhor1102/zerobit/ui/editor/MacroEditorScreen.kt create mode 100644 app/src/main/java/com/vibhor1102/zerobit/ui/editor/MacroEditorSession.kt create mode 100644 app/src/main/java/com/vibhor1102/zerobit/ui/editor/SampleMacro.kt create mode 100644 app/src/test/java/com/vibhor1102/zerobit/ui/editor/MacroEditorSessionTest.kt diff --git a/AGENTS.md b/AGENTS.md index ed88768..b33b706 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,6 +12,8 @@ files should use the SPDX identifier `GPL-3.0-or-later` where practical. file format and OpenMacro Workspace is the user's versioned workspace. - Macros must be human-readable, AI-editable, versionable, portable, explainable, and locally executable. +- Keep the primary editor MacroDroid-simple: Triggers, Conditions, and Actions, + with an always-available code view backed by the same validated model. - The trust model is: **AI proposes. Schema validates. App explains. User approves. Engine runs. Logs prove.** - Runtime behavior must remain deterministic. Do not place an AI black box in diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 47b881b..56a34ad 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -12,3 +12,13 @@ ZeroBit uses the following third-party software: SnakeYAML Engine is used to parse the restricted YAML 1.2 syntax accepted by OpenMacro. ZeroBit adds its own validation and rejects YAML features outside that subset. + +## kotlinx.coroutines + +- Project: +- Copyright: JetBrains and Kotlin contributors +- License: Apache License 2.0 +- License text: + +kotlinx.coroutines provides cancellable background parsing for the OpenMacro +source editor without blocking Android's main UI thread. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c602ada..fbc8917 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -61,6 +61,7 @@ dependencies { implementation(libs.androidx.compose.material3) implementation(libs.androidx.compose.ui) implementation(libs.androidx.compose.ui.tooling.preview) + implementation(libs.kotlinx.coroutines.android) implementation(libs.snakeyaml.engine) testImplementation(libs.junit) diff --git a/app/src/main/java/com/vibhor1102/zerobit/MainActivity.kt b/app/src/main/java/com/vibhor1102/zerobit/MainActivity.kt index b3a0db8..3a5028e 100644 --- a/app/src/main/java/com/vibhor1102/zerobit/MainActivity.kt +++ b/app/src/main/java/com/vibhor1102/zerobit/MainActivity.kt @@ -3,111 +3,60 @@ package com.vibhor1102.zerobit import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent -import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import com.vibhor1102.zerobit.openmacro.capability.CapabilityRegistry +import com.vibhor1102.zerobit.openmacro.proposal.OpenMacroProposalPipeline +import com.vibhor1102.zerobit.ui.editor.MacroEditorScreen +import com.vibhor1102.zerobit.ui.editor.MacroEditorSession +import com.vibhor1102.zerobit.ui.editor.SampleMacro import com.vibhor1102.zerobit.ui.theme.ZeroBitTheme +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { ZeroBitTheme { - ZeroBitHome() - } - } - } -} - -@Composable -private fun ZeroBitHome() { - Surface( - modifier = Modifier.fillMaxSize(), - color = MaterialTheme.colorScheme.background, - ) { - Box( - modifier = Modifier - .fillMaxSize() - .padding(24.dp), - contentAlignment = Alignment.Center, - ) { - Card( - modifier = Modifier.widthIn(max = 520.dp), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainer, - ), - shape = RoundedCornerShape(28.dp), - ) { - Column( - modifier = Modifier.padding(32.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { - Image( - painter = painterResource(R.drawable.zerobit_mark), - contentDescription = null, - modifier = Modifier.size(128.dp), - contentScale = ContentScale.Fit, - ) - - Spacer(Modifier.size(24.dp)) - - Text( - text = "ZeroBit", - style = MaterialTheme.typography.headlineLarge, - fontWeight = FontWeight.Bold, - ) - - Spacer(Modifier.size(12.dp)) - - Text( - text = "Transparent automation for Android,\nbuilt for humans and AI.", - style = MaterialTheme.typography.titleMedium, - textAlign = TextAlign.Center, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - - Spacer(Modifier.size(24.dp)) - - Text( - text = "AI proposes. You approve. ZeroBit runs it locally.", - style = MaterialTheme.typography.bodyLarge, - textAlign = TextAlign.Center, - color = MaterialTheme.colorScheme.primary, - fontWeight = FontWeight.Medium, + val pipeline = remember { + OpenMacroProposalPipeline(CapabilityRegistry.builtIn()) + } + val editor = remember { + MacroEditorSession.withInitialSourceApproved( + pipeline = pipeline, + initialSource = SampleMacro.source, ) } + val session = editor.first + var state by remember { mutableStateOf(editor.second) } + + LaunchedEffect(state.sourceText) { + val baseState = state + val sourceToParse = state.sourceText + delay(SOURCE_PARSE_DEBOUNCE_MILLIS) + val parsed = withContext(Dispatchers.Default) { + session.updateSource(baseState, sourceToParse) + } + if (state.sourceText == sourceToParse) { + state = parsed.copy(mode = state.mode) + } + } + + MacroEditorScreen( + state = state, + onModeSelected = { state = session.selectMode(state, it) }, + onSourceChanged = { state = state.copy(sourceText = it) }, + ) } } } -} -@Preview(showBackground = true) -@Composable -private fun ZeroBitHomePreview() { - ZeroBitTheme { - ZeroBitHome() + private companion object { + const val SOURCE_PARSE_DEBOUNCE_MILLIS = 250L } } - diff --git a/app/src/main/java/com/vibhor1102/zerobit/ui/editor/MacroEditorScreen.kt b/app/src/main/java/com/vibhor1102/zerobit/ui/editor/MacroEditorScreen.kt new file mode 100644 index 0000000..d6f6f7a --- /dev/null +++ b/app/src/main/java/com/vibhor1102/zerobit/ui/editor/MacroEditorScreen.kt @@ -0,0 +1,407 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.vibhor1102.zerobit.ui.editor + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.weight +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.BottomAppBar +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.vibhor1102.zerobit.openmacro.capability.CapabilityLane +import com.vibhor1102.zerobit.openmacro.proposal.BlockExplanation +import com.vibhor1102.zerobit.openmacro.proposal.OpenMacroProposal +import com.vibhor1102.zerobit.openmacro.proposal.ProposalResult +import com.vibhor1102.zerobit.ui.theme.ZeroBitTheme + +@Composable +fun MacroEditorScreen( + state: MacroEditorState, + onModeSelected: (EditorMode) -> Unit, + onSourceChanged: (String) -> Unit, +) { + Scaffold( + bottomBar = { + EditorBottomBar( + selected = state.mode, + onSelected = onModeSelected, + ) + }, + ) { contentPadding -> + when (state.mode) { + EditorMode.VISUAL -> VisualEditor( + state = state, + modifier = Modifier.padding(contentPadding), + ) + EditorMode.CODE -> CodeEditor( + state = state, + onSourceChanged = onSourceChanged, + modifier = Modifier.padding(contentPadding), + ) + } + } +} + +@Composable +private fun VisualEditor( + state: MacroEditorState, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + EditorHeader(state.visibleProposal) + ProposalStatus(state) + + val proposal = state.visibleProposal + if (proposal == null) { + EmptyVisualState() + } else { + LaneCard( + title = "Triggers", + subtitle = "Any trigger can start this macro", + blocks = proposal.explanation.blocksIn(CapabilityLane.TRIGGER), + ) + LaneCard( + title = "Conditions", + subtitle = "Every condition must pass", + blocks = proposal.explanation.blocksIn(CapabilityLane.CONDITION), + ) + LaneCard( + title = "Actions", + subtitle = "Actions run from top to bottom", + blocks = proposal.explanation.blocksIn(CapabilityLane.ACTION), + ) + PermissionCard(proposal) + } + } +} + +@Composable +private fun EditorHeader(proposal: OpenMacroProposal?) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + text = proposal?.explanation?.name ?: "OpenMacro editor", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + ) + Text( + text = "Simple on the surface. Exact underneath.", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@Composable +private fun ProposalStatus(state: MacroEditorState) { + val ready = state.result as? ProposalResult.Ready + val color = when { + state.problems.isNotEmpty() -> MaterialTheme.colorScheme.errorContainer + ready?.proposal?.comparison?.approvalRequired == true -> + MaterialTheme.colorScheme.tertiaryContainer + else -> MaterialTheme.colorScheme.primaryContainer + } + val title = when { + state.problems.isNotEmpty() -> "Code needs attention" + ready?.proposal?.comparison?.approvalRequired == true -> "Review required" + else -> "Matches approved behavior" + } + val detail = when { + state.visualIsStale -> + "The visual view shows the last valid version while the code is corrected." + state.problems.isNotEmpty() -> + state.problems.first().message + ready?.proposal?.comparison?.approvalRequired == true -> + "Runnable behavior changed. Explain and approve it before enabling." + else -> + "Source and approved runtime behavior are aligned." + } + + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = color), + shape = RoundedCornerShape(20.dp), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text(title, fontWeight = FontWeight.SemiBold) + Text(detail, style = MaterialTheme.typography.bodyMedium) + } + } +} + +@Composable +private fun LaneCard( + title: String, + subtitle: String, + blocks: List, +) { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(24.dp), + ) { + Column( + modifier = Modifier.padding(18.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column { + Text( + text = title, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + ) + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Surface( + color = MaterialTheme.colorScheme.secondaryContainer, + shape = RoundedCornerShape(50), + ) { + Text( + text = blocks.size.toString(), + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), + fontWeight = FontWeight.Bold, + ) + } + } + + blocks.forEachIndexed { index, block -> + BlockCard(position = index + 1, block = block) + } + } + } +} + +@Composable +private fun BlockCard( + position: Int, + block: BlockExplanation, +) { + Surface( + color = MaterialTheme.colorScheme.surfaceContainerHigh, + shape = RoundedCornerShape(18.dp), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(14.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.Top, + ) { + Surface( + color = MaterialTheme.colorScheme.primaryContainer, + shape = RoundedCornerShape(12.dp), + ) { + Text( + text = position.toString(), + modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), + fontWeight = FontWeight.Bold, + ) + } + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(3.dp), + ) { + Text(block.displayName, fontWeight = FontWeight.SemiBold) + Text( + text = block.summary, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = block.capabilityType, + style = MaterialTheme.typography.labelSmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.primary, + ) + } + } + } +} + +@Composable +private fun PermissionCard(proposal: OpenMacroProposal) { + val permissions = proposal.explanation.requiredPermissions + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + Text("Permissions", fontWeight = FontWeight.SemiBold) + Text( + text = if (permissions.isEmpty()) { + "No Android permissions are required." + } else { + permissions.joinToString { it.manifestName } + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun EmptyVisualState() { + Card(modifier = Modifier.fillMaxWidth()) { + Text( + text = "Fix the code to restore the visual macro.", + modifier = Modifier.padding(20.dp), + ) + } +} + +@Composable +private fun CodeEditor( + state: MacroEditorState, + onSourceChanged: (String) -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxSize() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = "OpenMacro code", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + ) + Text( + text = "Edits are parsed and explained locally. Nothing runs from this text directly.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + OutlinedTextField( + value = state.sourceText, + onValueChange = onSourceChanged, + modifier = Modifier + .fillMaxWidth() + .weight(1f), + textStyle = MaterialTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace, + ), + isError = state.problems.isNotEmpty(), + label = { Text("charger-greeting.openmacro.yaml") }, + ) + state.problems.firstOrNull()?.let { problem -> + Text( + text = buildString { + append(problem.path) + if (problem.line != null) { + append(" · line ${problem.line}") + if (problem.column != null) append(":${problem.column}") + } + append("\n${problem.message}") + }, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) + } + } +} + +@Composable +private fun EditorBottomBar( + selected: EditorMode, + onSelected: (EditorMode) -> Unit, +) { + BottomAppBar { + Spacer(Modifier.weight(1f)) + ModeButton( + label = "Visual", + selected = selected == EditorMode.VISUAL, + onClick = { onSelected(EditorMode.VISUAL) }, + ) + ModeButton( + label = "Code", + selected = selected == EditorMode.CODE, + onClick = { onSelected(EditorMode.CODE) }, + ) + Spacer(Modifier.weight(1f)) + } +} + +@Composable +private fun ModeButton( + label: String, + selected: Boolean, + onClick: () -> Unit, +) { + if (selected) { + FilledTonalButton(onClick = onClick) { + Text(label) + } + } else { + TextButton(onClick = onClick) { + Text(label) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun MacroEditorScreenPreview() { + ZeroBitTheme { + val editor = rememberPreviewEditor() + MacroEditorScreen( + state = editor.second, + onModeSelected = {}, + onSourceChanged = {}, + ) + } +} + +@Composable +private fun rememberPreviewEditor(): Pair = + androidx.compose.runtime.remember { + val pipeline = com.vibhor1102.zerobit.openmacro.proposal.OpenMacroProposalPipeline( + com.vibhor1102.zerobit.openmacro.capability.CapabilityRegistry.builtIn(), + ) + MacroEditorSession.withInitialSourceApproved(pipeline, SampleMacro.source) + } diff --git a/app/src/main/java/com/vibhor1102/zerobit/ui/editor/MacroEditorSession.kt b/app/src/main/java/com/vibhor1102/zerobit/ui/editor/MacroEditorSession.kt new file mode 100644 index 0000000..efe685b --- /dev/null +++ b/app/src/main/java/com/vibhor1102/zerobit/ui/editor/MacroEditorSession.kt @@ -0,0 +1,100 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.vibhor1102.zerobit.ui.editor + +import com.vibhor1102.zerobit.openmacro.proposal.ApprovedMacroSnapshot +import com.vibhor1102.zerobit.openmacro.proposal.OpenMacroProposal +import com.vibhor1102.zerobit.openmacro.proposal.OpenMacroProposalPipeline +import com.vibhor1102.zerobit.openmacro.proposal.ProposalResult + +class MacroEditorSession( + private val pipeline: OpenMacroProposalPipeline, + private val approved: ApprovedMacroSnapshot?, +) { + fun create(initialSource: String): MacroEditorState { + val result = pipeline.propose(initialSource, approved) + return MacroEditorState( + sourceText = initialSource, + result = result, + visibleProposal = (result as? ProposalResult.Ready)?.proposal, + visualIsStale = false, + ) + } + + fun updateSource( + current: MacroEditorState, + sourceText: String, + ): MacroEditorState { + val result = pipeline.propose(sourceText, approved) + val ready = (result as? ProposalResult.Ready)?.proposal + return current.copy( + sourceText = sourceText, + result = result, + visibleProposal = ready ?: current.visibleProposal, + visualIsStale = ready == null && current.visibleProposal != null, + ) + } + + fun selectMode( + current: MacroEditorState, + mode: EditorMode, + ): MacroEditorState = current.copy(mode = mode) + + companion object { + fun withInitialSourceApproved( + pipeline: OpenMacroProposalPipeline, + initialSource: String, + ): Pair { + val initial = pipeline.propose(initialSource) + require(initial is ProposalResult.Ready) { + "The initial editor source must be a valid OpenMacro." + } + val session = MacroEditorSession( + pipeline = pipeline, + approved = ApprovedMacroSnapshot.from(initial.proposal), + ) + return session to session.create(initialSource) + } + } +} + +data class MacroEditorState( + val mode: EditorMode = EditorMode.VISUAL, + val sourceText: String, + val result: ProposalResult, + val visibleProposal: OpenMacroProposal?, + val visualIsStale: Boolean, +) { + val problems: List + get() = when (val current = result) { + is ProposalResult.Ready -> emptyList() + is ProposalResult.SourceRejected -> current.issues.map { + EditorProblem( + message = it.message, + path = it.path, + line = it.line, + column = it.column, + ) + } + is ProposalResult.ValidationRejected -> current.issues.map { + EditorProblem( + message = it.message, + path = it.path, + ) + } + } +} + +data class EditorProblem( + val message: String, + val path: String, + val line: Int? = null, + val column: Int? = null, +) + +enum class EditorMode { + VISUAL, + CODE, +} diff --git a/app/src/main/java/com/vibhor1102/zerobit/ui/editor/SampleMacro.kt b/app/src/main/java/com/vibhor1102/zerobit/ui/editor/SampleMacro.kt new file mode 100644 index 0000000..165d6a0 --- /dev/null +++ b/app/src/main/java/com/vibhor1102/zerobit/ui/editor/SampleMacro.kt @@ -0,0 +1,31 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.vibhor1102.zerobit.ui.editor + +object SampleMacro { + val source = """ + format: openmacro/v0.1 + + metadata: + id: charger-greeting + name: Charger greeting + description: Show a message when the phone starts charging. + + triggers: + - id: charger-connected + type: android.power.connected + + conditions: + - id: device-is-unlocked + type: android.device.unlocked + + actions: + - id: show-message + type: android.notification.show + config: + title: Charging started + message: The charger is connected. + """.trimIndent() + "\n" +} diff --git a/app/src/test/java/com/vibhor1102/zerobit/ui/editor/MacroEditorSessionTest.kt b/app/src/test/java/com/vibhor1102/zerobit/ui/editor/MacroEditorSessionTest.kt new file mode 100644 index 0000000..45bd661 --- /dev/null +++ b/app/src/test/java/com/vibhor1102/zerobit/ui/editor/MacroEditorSessionTest.kt @@ -0,0 +1,112 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.vibhor1102.zerobit.ui.editor + +import com.vibhor1102.zerobit.openmacro.capability.CapabilityRegistry +import com.vibhor1102.zerobit.openmacro.proposal.OpenMacroProposalPipeline +import com.vibhor1102.zerobit.openmacro.proposal.ProposalResult +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class MacroEditorSessionTest { + private val pipeline = OpenMacroProposalPipeline(CapabilityRegistry.builtIn()) + + @Test + fun startsWithEquivalentVisualAndCodeViews() { + val (_, state) = MacroEditorSession.withInitialSourceApproved( + pipeline = pipeline, + initialSource = SampleMacro.source, + ) + + require(state.result is ProposalResult.Ready) + assertEquals(EditorMode.VISUAL, state.mode) + assertFalse(state.result.proposal.comparison.approvalRequired) + assertEquals( + SampleMacro.source, + state.visibleProposal?.source?.originalText, + ) + assertFalse(state.visualIsStale) + } + + @Test + fun behaviorEditUpdatesVisualProposalAndRequiresApproval() { + val (session, initial) = MacroEditorSession.withInitialSourceApproved( + pipeline, + SampleMacro.source, + ) + + val changed = session.updateSource( + initial, + initial.sourceText.replace( + "message: The charger is connected.", + "message: Time to charge.", + ), + ) + + require(changed.result is ProposalResult.Ready) + assertTrue(changed.result.proposal.comparison.approvalRequired) + assertTrue( + changed.visibleProposal + ?.explanation + ?.blocks + ?.single { it.blockId == "show-message" } + ?.summary + .orEmpty() + .contains("Time to charge."), + ) + assertFalse(changed.visualIsStale) + } + + @Test + fun invalidCodeRetainsLastValidVisualVersion() { + val (session, initial) = MacroEditorSession.withInitialSourceApproved( + pipeline, + SampleMacro.source, + ) + + val invalid = session.updateSource(initial, "format: [") + + assertTrue(invalid.result is ProposalResult.SourceRejected) + assertTrue(invalid.problems.isNotEmpty()) + assertTrue(invalid.visualIsStale) + assertEquals( + initial.visibleProposal, + invalid.visibleProposal, + ) + } + + @Test + fun fixingCodeClearsProblemsAndStaleState() { + val (session, initial) = MacroEditorSession.withInitialSourceApproved( + pipeline, + SampleMacro.source, + ) + val invalid = session.updateSource(initial, "format: [") + + val fixed = session.updateSource(invalid, SampleMacro.source) + + assertTrue(fixed.result is ProposalResult.Ready) + assertTrue(fixed.problems.isEmpty()) + assertFalse(fixed.visualIsStale) + } + + @Test + fun modeSwitchDoesNotCreateASecondDocument() { + val (session, initial) = MacroEditorSession.withInitialSourceApproved( + pipeline, + SampleMacro.source, + ) + + val code = session.selectMode(initial, EditorMode.CODE) + val visual = session.selectMode(code, EditorMode.VISUAL) + + assertEquals(EditorMode.CODE, code.mode) + assertEquals(EditorMode.VISUAL, visual.mode) + assertEquals(initial.sourceText, visual.sourceText) + assertEquals(initial.result, visual.result) + } +} diff --git a/docs/architecture/openmacro-foundation.md b/docs/architecture/openmacro-foundation.md index 1388481..a545d37 100644 --- a/docs/architecture/openmacro-foundation.md +++ b/docs/architecture/openmacro-foundation.md @@ -183,3 +183,23 @@ The next slice should provide the first app UI around these foundations: the three-lane macro overview, source view, proposal review, and permission discovery. Runtime enable state should then gain a separate durable store and Android process-restoration owner. + +## First editor surface + +The app now opens into one editor session with two always-available views: + +- Visual keeps the macro overview limited to Triggers, Conditions, and Actions, + showing each block's plain-English explanation and permission needs. +- Code edits the exact OpenMacro YAML source. Parsing and validation run locally + after a short cancellable background debounce, rather than blocking typing. + +Both views share the proposal pipeline and one source string. Invalid source +does not erase the last valid visual explanation; the visual view is marked +stale until code becomes valid again. Behavior-changing edits show that review +is required, while comments, formatting, names, and descriptions remain +non-behavioral. + +This is the editor architecture proof, not yet the complete MacroDroid-level +form builder. The next UI slices should generate focused block configuration +forms from capability fields, patch source without discarding comments, and +connect approval and enable actions to the app-private stores and runtime. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6c36ff5..a78c5de 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,6 +4,7 @@ kotlin = "2.2.21" activityCompose = "1.13.0" composeBom = "2026.06.00" junit = "4.13.2" +kotlinxCoroutines = "1.10.2" snakeYamlEngine = "3.0.1" [libraries] @@ -14,6 +15,7 @@ androidx-compose-ui = { module = "androidx.compose.ui:ui" } androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" } androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" } junit = { module = "junit:junit", version.ref = "junit" } +kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" } snakeyaml-engine = { module = "org.snakeyaml:snakeyaml-engine", version.ref = "snakeYamlEngine" } [plugins] From b34ce44ae2e666ab46a677cbf6a8b2d8d542ffba Mon Sep 17 00:00:00 2001 From: Vibhor Goel Date: Thu, 25 Jun 2026 14:43:03 +0530 Subject: [PATCH 8/8] Fix Compose editor layout import --- .../java/com/vibhor1102/zerobit/ui/editor/MacroEditorScreen.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/app/src/main/java/com/vibhor1102/zerobit/ui/editor/MacroEditorScreen.kt b/app/src/main/java/com/vibhor1102/zerobit/ui/editor/MacroEditorScreen.kt index d6f6f7a..efea50e 100644 --- a/app/src/main/java/com/vibhor1102/zerobit/ui/editor/MacroEditorScreen.kt +++ b/app/src/main/java/com/vibhor1102/zerobit/ui/editor/MacroEditorScreen.kt @@ -11,7 +11,6 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.weight import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll