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 new file mode 100644 index 0000000..56a34ad --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,24 @@ +# 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. + +## 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 a8d35d1..fbc8917 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -61,7 +61,10 @@ 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) debugImplementation(libs.androidx.compose.ui.tooling) } - 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/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/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/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/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/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/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/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..8cd12d2 --- /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.mapValues { (key, value) -> + 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/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/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/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..efea50e --- /dev/null +++ b/app/src/main/java/com/vibhor1102/zerobit/ui/editor/MacroEditorScreen.kt @@ -0,0 +1,406 @@ +/* + * 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.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/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/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/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/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/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/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/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/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 new file mode 100644 index 0000000..a545d37 --- /dev/null +++ b/docs/architecture/openmacro-foundation.md @@ -0,0 +1,205 @@ +# 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. + +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 + +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. + +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: + +- 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; +- strict YAML 1.2 reading with source locations and bounded input; and +- 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 +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 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. + +## 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. + +## 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/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..a78c5de 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,6 +3,9 @@ agp = "8.13.2" 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] androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" } @@ -11,9 +14,11 @@ 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" } +kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" } +snakeyaml-engine = { module = "org.snakeyaml:snakeyaml-engine", version.ref = "snakeYamlEngine" } [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": {} + } + } + } + } +}