diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 7bf57df..b354a8a 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -40,6 +40,7 @@ dependencies {
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.compose.material3)
+ implementation(libs.play.services.location)
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.graphics)
implementation(libs.androidx.compose.ui.tooling.preview)
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index fa2346a..0b12ab0 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -2,6 +2,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/java/com/example/nothingshortcuts/MainActivity.kt b/app/src/main/java/com/example/nothingshortcuts/MainActivity.kt
index bd19b20..024ca95 100644
--- a/app/src/main/java/com/example/nothingshortcuts/MainActivity.kt
+++ b/app/src/main/java/com/example/nothingshortcuts/MainActivity.kt
@@ -1,28 +1,518 @@
package com.example.nothingshortcuts
+import android.Manifest
+import android.app.Activity
+import android.content.pm.PackageManager
+import android.content.Intent
+import android.provider.Settings
+import android.content.pm.ShortcutInfo
+import android.content.pm.ShortcutManager
+import android.graphics.drawable.Icon
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+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.height
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.Divider
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Switch
import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateListOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.compose.ui.platform.LocalContext
+import androidx.core.app.ActivityCompat
+import androidx.core.content.ContextCompat
import com.example.nothingshortcuts.ui.theme.NothingshortcutsTheme
+import com.example.nothingshortcuts.core.engine.ActionType
+import com.example.nothingshortcuts.core.engine.WorkflowAction
+import com.example.nothingshortcuts.core.engine.WorkflowEngine
+import com.example.nothingshortcuts.core.integration.AndroidActionExecutor
+import com.example.nothingshortcuts.core.integration.SecretStore
+import com.example.nothingshortcuts.core.engine.WorkflowDocument
+import com.example.nothingshortcuts.core.storage.WorkflowStore
+import com.example.nothingshortcuts.widget.ShortcutsWidget
+import com.example.nothingshortcuts.core.automation.Automation
+import com.example.nothingshortcuts.core.automation.TriggerType
+import com.example.nothingshortcuts.core.automation.AutomationScheduler
+import java.util.UUID
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+private val Ink = Color(0xFFF5F5F7)
+private val Muted = Color(0xFFA4A4AA)
+private val Paper = Color(0xFF0F0F10)
+private val CardSurface = Color(0xFF1C1C1E)
+private val Border = Color(0xFF343438)
+private val Red = Color(0xFFFF453A)
+private val Blue = Color(0xFF4F8CFF)
+private val Green = Color(0xFF39C98A)
+private val Violet = Color(0xFF9B7AFF)
+private const val NOTIFICATION_PERMISSION_REQUEST = 1001
+private const val LOCATION_PERMISSION_REQUEST = 1002
+
+data class ShortcutAction(
+ val title: String,
+ val detail: String,
+ val category: String,
+ val color: Color,
+ val type: ActionType = ActionType.TEXT,
+ val parameters: Map = emptyMap()
+)
+
+data class Shortcut(
+ val name: String,
+ val subtitle: String,
+ val color: Color,
+ val actions: List
+)
+
+private val actionCatalog = listOf(
+ ShortcutAction("Get current date", "Today · Date", "Time", Blue, ActionType.CURRENT_DATE),
+ ShortcutAction("Get current time", "Now · Time", "Time", Blue, ActionType.CURRENT_TIME),
+ ShortcutAction("Text", "Write or combine text", "Text", Violet, ActionType.TEXT),
+ ShortcutAction("Set variable", "Save a value for later", "Variables", Violet, ActionType.SET_VARIABLE),
+ ShortcutAction("Get variable", "Use a saved value", "Variables", Violet, ActionType.GET_VARIABLE),
+ ShortcutAction("If", "Choose between two paths", "Control Flow", Muted, ActionType.IF),
+ ShortcutAction("Repeat", "Run actions more than once", "Control Flow", Muted, ActionType.REPEAT),
+ ShortcutAction("Stop shortcut", "End this shortcut", "Control Flow", Muted, ActionType.STOP),
+ ShortcutAction("Show notification", "Title and message", "Device", Red, ActionType.SHOW_NOTIFICATION),
+ ShortcutAction("Copy to clipboard", "Text", "Text", Violet, ActionType.COPY_CLIPBOARD),
+ ShortcutAction("Read battery level", "Current percentage", "Device", Red, ActionType.READ_BATTERY),
+ ShortcutAction("Check charging status", "Charging or not charging", "Device", Red, ActionType.READ_CHARGING),
+ ShortcutAction("Ask for input", "Question · Text", "Text", Violet, ActionType.ASK_INPUT),
+ ShortcutAction("Launch app", "Package name", "Apps", Green, ActionType.LAUNCH_APP),
+ ShortcutAction("Open settings", "Android settings page", "Device", Red, ActionType.OPEN_SETTINGS),
+ ShortcutAction("HTTP request", "GET an HTTPS URL", "Internet", Blue, ActionType.HTTP_REQUEST),
+ ShortcutAction("Home Assistant service", "Call a configured service", "Smart Home", Green, ActionType.HOME_ASSISTANT_SERVICE),
+ ShortcutAction("Set media volume", "Volume · 50%", "Device", Red, ActionType.SET_MEDIA_VOLUME),
+ ShortcutAction("Enable Gym Focus", "Do Not Disturb · Priority", "Focus", Violet, ActionType.SET_FOCUS_MODE, mapOf("value" to "on")),
+ ShortcutAction("Wait", "Duration · 5 seconds", "Control Flow", Muted, ActionType.WAIT)
+)
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
- setContent {
- NothingshortcutsTheme {
- Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
- Greeting(
- name = "Android",
- modifier = Modifier.padding(innerPadding)
+ publishDynamicShortcuts()
+ val shared = intent.getStringExtra(Intent.EXTRA_TEXT)
+ if (intent.action == Intent.ACTION_SEND && !shared.isNullOrBlank()) {
+ WorkflowStore(this).importWorkflows(shared)
+ }
+ setContent { NothingshortcutsTheme(darkTheme = true, dynamicColor = false) { ShortcutsApp() } }
+ }
+
+ private fun publishDynamicShortcuts() {
+ val manager = getSystemService(ShortcutManager::class.java) ?: return
+ val shortcuts = WorkflowStore(this).readWorkflows().take(4).map { workflow ->
+ ShortcutInfo.Builder(this, workflow.id)
+ .setShortLabel(workflow.name.take(25))
+ .setLongLabel("Run ${workflow.name}")
+ .setIcon(Icon.createWithResource(this, R.mipmap.ic_launcher))
+ .setIntent(
+ Intent(this, MainActivity::class.java)
+ .setAction(Intent.ACTION_VIEW)
+ .putExtra("workflow_id", workflow.id)
+ )
+ .build()
+ }
+ manager.dynamicShortcuts = shortcuts
+ }
+
+ override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) {
+ super.onRequestPermissionsResult(requestCode, permissions, grantResults)
+ if (requestCode == LOCATION_PERMISSION_REQUEST &&
+ android.os.Build.VERSION.SDK_INT >= 29 &&
+ ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED &&
+ ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_BACKGROUND_LOCATION) != PackageManager.PERMISSION_GRANTED
+ ) {
+ ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.ACCESS_BACKGROUND_LOCATION), LOCATION_PERMISSION_REQUEST)
+ }
+ if (requestCode == LOCATION_PERMISSION_REQUEST &&
+ ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED &&
+ (android.os.Build.VERSION.SDK_INT < 29 ||
+ ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_BACKGROUND_LOCATION) == PackageManager.PERMISSION_GRANTED)
+ ) {
+ AutomationScheduler(this).rescheduleAll()
+ }
+ }
+}
+
+@Composable
+private fun ShortcutsApp() {
+ val context = LocalContext.current
+ val workflowStore = remember { WorkflowStore(context) }
+ val automationScheduler = remember { AutomationScheduler(context) }
+ LaunchedEffect(Unit) { automationScheduler.rescheduleAll() }
+ var selectedTab by remember { mutableIntStateOf(0) }
+ var editorShortcut by remember { mutableStateOf(null) }
+ val shortcuts = remember {
+ val seeds = listOf(
+ Shortcut("Good Morning", "Date, weather and your day", Blue, actionCatalog.take(2)),
+ Shortcut("Focus Mode", "Quiet the noise for 45 minutes", Violet, listOf(
+ actionCatalog.first { it.type == ActionType.SET_MEDIA_VOLUME },
+ actionCatalog.first { it.type == ActionType.WAIT }
+ )),
+ Shortcut("Going Home", "Connect your home in one tap", Green, listOf(
+ actionCatalog.first { it.type == ActionType.LAUNCH_APP }
+ ))
+ )
+ val persisted = workflowStore.readWorkflows().map(::shortcutFromWorkflow)
+ if (persisted.isEmpty()) seeds.forEach { workflowStore.saveWorkflow(workflowFor(it)) }
+ mutableStateListOf().apply { addAll(if (persisted.isEmpty()) seeds else persisted) }
+ }
+
+ if (editorShortcut != null) {
+ ShortcutEditor(
+ shortcut = editorShortcut!!,
+ store = workflowStore,
+ onBack = { editorShortcut = null },
+ onSave = { updated ->
+ val old = shortcuts.indexOfFirst { it.name == editorShortcut!!.name }
+ if (old >= 0) shortcuts[old] = updated else shortcuts.add(updated)
+ if (editorShortcut!!.name != updated.name) {
+ workflowStore.deleteWorkflow(workflowFor(editorShortcut!!).id)
+ }
+ workflowStore.saveWorkflow(workflowFor(updated))
+ ShortcutsWidget.updateAll(context)
+ editorShortcut = null
+ }
+ )
+ return
+ }
+
+ Scaffold(
+ containerColor = Paper,
+ bottomBar = {
+ NavigationBar(selectedTab = selectedTab, onSelect = { selectedTab = it })
+ }
+ ) { padding ->
+ when (selectedTab) {
+ 0 -> LibraryScreen(
+ shortcuts = shortcuts,
+ modifier = Modifier.padding(padding),
+ onOpen = { editorShortcut = it },
+ onNew = { editorShortcut = Shortcut("New Shortcut", "Tap to add actions", Red, emptyList()) },
+ onExport = {
+ context.startActivity(
+ Intent.createChooser(
+ Intent(Intent.ACTION_SEND).apply {
+ type = "text/plain"
+ putExtra(Intent.EXTRA_TEXT, workflowStore.exportWorkflows())
+ },
+ "Export shortcuts"
+ )
+ )
+ }
+ )
+ 1 -> AutomationScreen(
+ modifier = Modifier.padding(padding),
+ shortcuts = shortcuts,
+ store = workflowStore,
+ scheduler = automationScheduler
+ )
+ else -> GalleryScreen(Modifier.padding(padding), onInstall = { name ->
+ val installed = if (name == "Gym Focus") {
+ Shortcut("Gym Focus", "Enable Do Not Disturb when you arrive", Violet, listOf(
+ actionCatalog.first { it.type == ActionType.SET_FOCUS_MODE }
+ ))
+ } else {
+ Shortcut("Daily Reset", "A tiny reset for your day", Green, listOf(
+ actionCatalog.first { it.type == ActionType.SHOW_NOTIFICATION },
+ actionCatalog.first { it.type == ActionType.WAIT }
+ ))
+ }
+ shortcuts.add(installed)
+ workflowStore.saveWorkflow(workflowFor(installed))
+ ShortcutsWidget.updateAll(context)
+ selectedTab = 0
+ })
+ }
+ }
+}
+
+private fun workflowFor(shortcut: Shortcut): com.example.nothingshortcuts.core.engine.WorkflowDocument =
+ com.example.nothingshortcuts.core.engine.WorkflowDocument(
+ id = shortcut.name.lowercase().replace(Regex("[^a-z0-9]+"), "-").trim('-'),
+ name = shortcut.name,
+ actions = shortcut.actions.mapIndexed { index, action ->
+ WorkflowAction("${shortcut.name}-$index", action.type, action.parameters)
+ }
+ )
+
+private fun shortcutFromWorkflow(workflow: com.example.nothingshortcuts.core.engine.WorkflowDocument): Shortcut {
+ val actions = workflow.actions.map { action ->
+ actionCatalog.firstOrNull { template -> template.type == action.type }?.copy(
+ detail = action.parameters["value"] ?: action.parameters["message"] ?: "Configured action",
+ parameters = action.parameters
+ ) ?: ShortcutAction(
+ title = action.type.name.lowercase().replace('_', ' ').replaceFirstChar(Char::uppercase),
+ detail = action.parameters["value"].orEmpty(),
+ category = "Workflow",
+ color = Muted,
+ type = action.type,
+ parameters = action.parameters
+ )
+ }
+ return Shortcut(workflow.name, "${actions.size} saved actions", actions.firstOrNull()?.color ?: Red, actions)
+}
+
+@Composable
+private fun LibraryScreen(
+ shortcuts: List,
+ modifier: Modifier,
+ onOpen: (Shortcut) -> Unit,
+ onNew: () -> Unit,
+ onExport: () -> Unit
+) {
+ var query by remember { mutableStateOf("") }
+ val visibleShortcuts = shortcuts.filter {
+ query.isBlank() ||
+ it.name.contains(query, ignoreCase = true) ||
+ it.subtitle.contains(query, ignoreCase = true) ||
+ it.actions.any { action -> action.title.contains(query, ignoreCase = true) }
+ }
+ LazyColumn(
+ modifier = modifier.fillMaxSize(),
+ state = rememberLazyListState(),
+ contentPadding = androidx.compose.foundation.layout.PaddingValues(20.dp, 24.dp, 20.dp, 28.dp),
+ verticalArrangement = Arrangement.spacedBy(14.dp)
+ ) {
+ item {
+ Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
+ Column(modifier = Modifier.weight(1f)) {
+ Text("Shortcuts", style = MaterialTheme.typography.displaySmall, fontWeight = FontWeight.Bold, color = Ink)
+ Text("Make everyday things one tap away.", color = Muted, fontSize = 14.sp)
+ }
+ Box(
+ modifier = Modifier
+ .size(48.dp)
+ .clip(CircleShape)
+ .background(Ink)
+ .clickable { onNew() }
+ .semantics { contentDescription = "Create shortcut" },
+ contentAlignment = Alignment.Center
+ ) { Text("+", color = Color.White, fontSize = 28.sp, fontWeight = FontWeight.Light) }
+ }
+ TextButton(onClick = onExport) { Text("Export shortcuts", color = Red) }
+ }
+ item {
+ OutlinedTextField(
+ value = query,
+ onValueChange = { query = it },
+ modifier = Modifier.fillMaxWidth(),
+ placeholder = { Text("Search shortcuts") },
+ leadingIcon = { Text("⌕", color = Muted, fontSize = 22.sp) },
+ trailingIcon = {
+ if (query.isNotEmpty()) TextButton(onClick = { query = "" }) { Text("Clear", color = Red) }
+ },
+ singleLine = true,
+ shape = RoundedCornerShape(16.dp)
+ )
+ }
+ item {
+ Text("My shortcuts", fontWeight = FontWeight.Bold, color = Ink, fontSize = 18.sp)
+ }
+ if (visibleShortcuts.isEmpty()) {
+ item { Text("No shortcuts match \"$query\".", color = Muted, modifier = Modifier.padding(vertical = 24.dp)) }
+ }
+ itemsIndexed(visibleShortcuts) { _, shortcut ->
+ ShortcutCard(shortcut, onClick = { onOpen(shortcut) })
+ }
+ }
+}
+
+@Composable
+private fun SearchPill() {
+ Surface(
+ modifier = Modifier.fillMaxWidth().height(48.dp),
+ shape = RoundedCornerShape(16.dp),
+ color = CardSurface,
+ border = androidx.compose.foundation.BorderStroke(1.dp, Border)
+ ) {
+ Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(horizontal = 16.dp)) {
+ Text("⌕", fontSize = 24.sp, color = Muted)
+ Spacer(Modifier.width(10.dp))
+ Text("Search shortcuts", color = Muted, fontSize = 15.sp)
+ }
+ }
+}
+
+@Composable
+private fun ShortcutCard(shortcut: Shortcut, onClick: () -> Unit) {
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable { onClick() }
+ .semantics { contentDescription = "${shortcut.name}, ${shortcut.actions.size} actions" },
+ shape = RoundedCornerShape(22.dp),
+ colors = CardDefaults.cardColors(containerColor = CardSurface),
+ elevation = CardDefaults.cardElevation(defaultElevation = 1.dp)
+ ) {
+ Row(modifier = Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
+ Box(
+ modifier = Modifier.size(56.dp).clip(RoundedCornerShape(17.dp)).background(shortcut.color),
+ contentAlignment = Alignment.Center
+ ) { Text(shortcut.name.take(1).uppercase(), color = Color.White, fontSize = 24.sp, fontWeight = FontWeight.Bold) }
+ Spacer(Modifier.width(14.dp))
+ Column(modifier = Modifier.weight(1f)) {
+ Text(shortcut.name, fontWeight = FontWeight.Bold, color = Ink, fontSize = 17.sp)
+ Text(shortcut.subtitle, color = Muted, fontSize = 13.sp, maxLines = 1, overflow = TextOverflow.Ellipsis)
+ Spacer(Modifier.height(7.dp))
+ Text("${shortcut.actions.size} actions", color = shortcut.color, fontSize = 12.sp, fontWeight = FontWeight.SemiBold)
+ }
+ Text("›", color = Muted, fontSize = 28.sp)
+ }
+ }
+}
+
+@Composable
+private fun NavigationBar(selectedTab: Int, onSelect: (Int) -> Unit) {
+ Surface(color = CardSurface, shadowElevation = 8.dp) {
+ Row(
+ Modifier.fillMaxWidth().padding(horizontal = 18.dp, vertical = 10.dp),
+ horizontalArrangement = Arrangement.SpaceEvenly
+ ) {
+ listOf("Shortcuts", "Automation", "Gallery").forEachIndexed { index, label ->
+ Column(
+ modifier = Modifier.weight(1f).clip(RoundedCornerShape(16.dp)).clickable { onSelect(index) }.padding(vertical = 6.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Text(if (index == selectedTab) "●" else "○", color = if (index == selectedTab) Red else Muted, fontSize = 17.sp)
+ Text(label, color = if (index == selectedTab) Ink else Muted, fontSize = 11.sp, fontWeight = if (index == selectedTab) FontWeight.Bold else FontWeight.Normal)
+ }
+ }
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun AutomationScreen(
+ modifier: Modifier,
+ shortcuts: List,
+ store: WorkflowStore,
+ scheduler: AutomationScheduler
+) {
+ val automations = remember { mutableStateListOf().apply { addAll(store.readAutomations()) } }
+ var showCreator by remember { mutableStateOf(false) }
+ if (showCreator) {
+ AutomationCreator(
+ shortcuts = shortcuts,
+ onDismiss = { showCreator = false },
+ onSave = {
+ store.saveAutomation(it)
+ scheduler.schedule(it)
+ automations.removeAll { existing -> existing.id == it.id }
+ automations.add(it)
+ showCreator = false
+ }
+ )
+ }
+ LazyColumn(
+ modifier.fillMaxSize(),
+ contentPadding = androidx.compose.foundation.layout.PaddingValues(20.dp, 24.dp, 20.dp, 28.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ item {
+ Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
+ Column(Modifier.weight(1f)) {
+ Text("Automation", style = MaterialTheme.typography.displaySmall, fontWeight = FontWeight.Bold, color = Ink)
+ Text("Let your shortcuts happen for you.", color = Muted, fontSize = 14.sp)
+ }
+ Button(onClick = { showCreator = true }, colors = ButtonDefaults.buttonColors(containerColor = Red)) { Text("+") }
+ }
+ }
+ if (automations.isEmpty()) {
+ item {
+ Card(shape = RoundedCornerShape(22.dp), colors = CardDefaults.cardColors(CardSurface)) {
+ Column(Modifier.padding(18.dp)) {
+ Text("No automations yet", fontWeight = FontWeight.Bold, fontSize = 18.sp, color = Ink)
+ Spacer(Modifier.height(6.dp))
+ Text("Create a trigger for a time, battery level or connection.", color = Muted, fontSize = 14.sp)
+ Spacer(Modifier.height(14.dp))
+ OutlinedButton(onClick = { showCreator = true }, border = androidx.compose.foundation.BorderStroke(1.dp, Red)) {
+ Text("Create automation", color = Red)
+ }
+ }
+ }
+ }
+ } else {
+ itemsIndexed(automations) { index, automation ->
+ AutomationCard(
+ automation = automation,
+ onToggle = {
+ val updated = automation.copy(enabled = !automation.enabled)
+ store.saveAutomation(updated)
+ scheduler.schedule(updated)
+ automations[index] = updated
+ },
+ onDelete = {
+ store.deleteAutomation(automation.id)
+ scheduler.cancel(automation.id)
+ automations.removeAt(index)
+ }
+ )
+ }
+ }
+ item {
+ val history = store.readHistory().take(5)
+ if (history.isNotEmpty()) {
+ Text("Recent runs", fontWeight = FontWeight.Bold, fontSize = 18.sp, color = Ink)
+ Spacer(Modifier.height(4.dp))
+ history.forEach { record ->
+ Text(
+ "${if (record.success) "✓" else "!"} ${record.workflowName} · ${record.message}",
+ color = if (record.success) Green else Red,
+ fontSize = 13.sp,
+ modifier = Modifier.padding(vertical = 3.dp)
)
}
}
@@ -31,17 +521,472 @@ class MainActivity : ComponentActivity() {
}
@Composable
-fun Greeting(name: String, modifier: Modifier = Modifier) {
- Text(
- text = "Hello $name!",
- modifier = modifier
- )
+private fun AutomationCard(automation: Automation, onToggle: () -> Unit, onDelete: () -> Unit) {
+ Card(shape = RoundedCornerShape(20.dp), colors = CardDefaults.cardColors(CardSurface)) {
+ Column(Modifier.padding(16.dp)) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Column(Modifier.weight(1f)) {
+ Text(automation.name, fontWeight = FontWeight.Bold, fontSize = 17.sp, color = Ink)
+ Text(if (automation.enabled) "Active" else "Disabled", color = if (automation.enabled) Green else Muted, fontSize = 12.sp)
+ }
+ Switch(checked = automation.enabled, onCheckedChange = { onToggle() })
+ }
+ Spacer(Modifier.height(8.dp))
+ Text("${triggerLabel(automation.trigger)}${automation.triggerValue.takeIf { it.isNotBlank() }?.let { " · $it" } ?: ""}", color = Muted, fontSize = 14.sp)
+ Text("Runs: ${automation.workflowId}", color = Blue, fontSize = 12.sp)
+ TextButton(onClick = onDelete) { Text("Delete", color = Red) }
+ }
+ }
}
-@Preview(showBackground = true)
+private fun triggerLabel(trigger: TriggerType): String = when (trigger) {
+ TriggerType.AT_TIME -> "At a specific time"
+ TriggerType.BATTERY_LEVEL -> "Battery reaches"
+ TriggerType.CHARGER_CONNECTED -> "Charger connected"
+ TriggerType.CHARGER_DISCONNECTED -> "Charger disconnected"
+ TriggerType.ENTER_LOCATION -> "Arrive at location"
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
@Composable
-fun GreetingPreview() {
- NothingshortcutsTheme {
- Greeting("Android")
+private fun AutomationCreator(
+ shortcuts: List,
+ onDismiss: () -> Unit,
+ onSave: (Automation) -> Unit
+) {
+ val context = LocalContext.current
+ var name by remember { mutableStateOf("New automation") }
+ var trigger by remember { mutableStateOf(TriggerType.BATTERY_LEVEL) }
+ var triggerValue by remember { mutableStateOf("20") }
+ var shortcutIndex by remember { mutableIntStateOf(0) }
+ ModalBottomSheet(onDismissRequest = onDismiss) {
+ Column(Modifier.fillMaxWidth().padding(20.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
+ Text("Create automation", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, color = Ink)
+ Text("Choose a trigger, then select the shortcut it should run.", color = Muted, fontSize = 13.sp)
+ OutlinedTextField(name, { name = it }, label = { Text("Name") }, singleLine = true, modifier = Modifier.fillMaxWidth())
+ Text("Trigger", fontWeight = FontWeight.Bold, color = Ink)
+ Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
+ listOf(TriggerType.BATTERY_LEVEL, TriggerType.AT_TIME).forEach { option ->
+ OutlinedButton(onClick = {
+ trigger = option
+ triggerValue = if (option == TriggerType.BATTERY_LEVEL) "20" else "08:00"
+ }) {
+ Text(if (option == TriggerType.BATTERY_LEVEL) "Battery" else "Time")
+ }
+ }
+ }
+ Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
+ listOf(TriggerType.CHARGER_CONNECTED, TriggerType.CHARGER_DISCONNECTED).forEach { option ->
+ OutlinedButton(onClick = { trigger = option; triggerValue = "" }) {
+ Text(if (option == TriggerType.CHARGER_CONNECTED) "Plugged in" else "Unplugged")
+ }
+ }
+ }
+ OutlinedButton(onClick = {
+ trigger = TriggerType.ENTER_LOCATION
+ triggerValue = "51.0000, -0.1000, 150"
+ }) { Text("Arrive at place") }
+ if (trigger == TriggerType.BATTERY_LEVEL || trigger == TriggerType.AT_TIME || trigger == TriggerType.ENTER_LOCATION) {
+ OutlinedTextField(
+ triggerValue,
+ { triggerValue = it },
+ label = {
+ Text(
+ when (trigger) {
+ TriggerType.BATTERY_LEVEL -> "Battery percentage"
+ TriggerType.AT_TIME -> "Time, HH:mm"
+ else -> "Latitude, longitude, radius (m)"
+ }
+ )
+ },
+ singleLine = true,
+ modifier = Modifier.fillMaxWidth()
+ )
+ if (trigger == TriggerType.ENTER_LOCATION) {
+ Text("Use the gym coordinates, for example 51.0000, -0.1000, 150. Allow location access when prompted.", color = Muted, fontSize = 12.sp)
+ }
+ }
+ Text("Run shortcut", fontWeight = FontWeight.Bold, color = Ink)
+ if (shortcuts.isNotEmpty()) {
+ Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
+ shortcuts.forEachIndexed { index, shortcut ->
+ OutlinedButton(onClick = { shortcutIndex = index }, modifier = Modifier.fillMaxWidth()) {
+ Text(shortcut.name)
+ }
+ }
+ }
+ }
+ Button(
+ onClick = {
+ shortcuts.getOrNull(shortcutIndex)?.let {
+ if (trigger == TriggerType.ENTER_LOCATION &&
+ ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED
+ ) {
+ (context as? Activity)?.let { activity ->
+ ActivityCompat.requestPermissions(activity, arrayOf(Manifest.permission.ACCESS_FINE_LOCATION), LOCATION_PERMISSION_REQUEST)
+ }
+ } else if (
+ trigger == TriggerType.ENTER_LOCATION &&
+ android.os.Build.VERSION.SDK_INT >= 29 &&
+ ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_BACKGROUND_LOCATION) != PackageManager.PERMISSION_GRANTED
+ ) {
+ (context as? Activity)?.let { activity ->
+ ActivityCompat.requestPermissions(activity, arrayOf(Manifest.permission.ACCESS_BACKGROUND_LOCATION), LOCATION_PERMISSION_REQUEST)
+ }
+ }
+ onSave(Automation(UUID.randomUUID().toString(), it.name.lowercase().replace(" ", "-"), name, trigger, triggerValue))
+ }
+ },
+ enabled = shortcuts.isNotEmpty(),
+ modifier = Modifier.fillMaxWidth().padding(bottom = 20.dp),
+ colors = ButtonDefaults.buttonColors(containerColor = Ink)
+ ) { Text("Save automation") }
+ }
}
-}
\ No newline at end of file
+}
+
+@Composable
+private fun GalleryScreen(modifier: Modifier, onInstall: (String) -> Unit) {
+ LazyColumn(modifier.fillMaxSize(), contentPadding = androidx.compose.foundation.layout.PaddingValues(20.dp, 24.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) {
+ item {
+ Text("Gallery", style = MaterialTheme.typography.displaySmall, fontWeight = FontWeight.Bold, color = Ink)
+ Text("A head start for the things you do often.", color = Muted, fontSize = 14.sp)
+ Spacer(Modifier.height(12.dp))
+ }
+ item { GalleryCard("Daily Reset", "Clear the noise and start fresh.", "Everyday", Green, onInstall) }
+ item { GalleryCard("Battery Check", "Get a notification when your battery is low.", "Device", Red, onInstall) }
+ item { GalleryCard("Gym Focus", "Turn on Do Not Disturb when you arrive.", "Location", Violet, onInstall) }
+ }
+}
+
+@Composable
+private fun GalleryCard(name: String, description: String, category: String, color: Color, onInstall: (String) -> Unit) {
+ Card(shape = RoundedCornerShape(22.dp), colors = CardDefaults.cardColors(CardSurface)) {
+ Column(Modifier.padding(17.dp)) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Box(Modifier.size(42.dp).clip(RoundedCornerShape(13.dp)).background(color), contentAlignment = Alignment.Center) {
+ Text(name.take(1), color = Color.White, fontSize = 19.sp, fontWeight = FontWeight.Bold)
+ }
+ Spacer(Modifier.width(12.dp))
+ Column(Modifier.weight(1f)) {
+ Text(name, fontWeight = FontWeight.Bold, fontSize = 17.sp, color = Ink)
+ Text(category.uppercase(), color = color, fontSize = 10.sp, fontWeight = FontWeight.Bold)
+ }
+ }
+ Spacer(Modifier.height(10.dp))
+ Text(description, color = Muted, fontSize = 14.sp)
+ Spacer(Modifier.height(12.dp))
+ Button(onClick = { onInstall(name) }, colors = ButtonDefaults.buttonColors(containerColor = Ink), modifier = Modifier.fillMaxWidth()) {
+ Text("Add to my shortcuts")
+ }
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun ShortcutEditor(
+ shortcut: Shortcut,
+ store: WorkflowStore,
+ onBack: () -> Unit,
+ onSave: (Shortcut) -> Unit
+) {
+ val context = LocalContext.current
+ val scope = rememberCoroutineScope()
+ var name by remember { mutableStateOf(shortcut.name) }
+ var actions by remember { mutableStateOf(shortcut.actions) }
+ var showPicker by remember { mutableStateOf(false) }
+ var configuringIndex by remember { mutableStateOf(null) }
+ var isRunning by remember { mutableStateOf(false) }
+ var runMessage by remember { mutableStateOf(null) }
+
+ if (showPicker) {
+ ModalBottomSheet(onDismissRequest = { showPicker = false }, sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)) {
+ ActionPicker(onAdd = { action ->
+ actions = actions + action
+ showPicker = false
+ })
+ }
+ }
+ configuringIndex?.let { index ->
+ ActionConfigurationSheet(
+ action = actions[index],
+ onDismiss = { configuringIndex = null },
+ onSave = { updated ->
+ actions = actions.toMutableList().also { it[index] = updated }
+ configuringIndex = null
+ }
+ )
+ }
+
+ Scaffold(containerColor = Paper) { padding ->
+ Column(Modifier.fillMaxSize().padding(padding)) {
+ Row(Modifier.fillMaxWidth().padding(horizontal = 18.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically) {
+ TextButton(onClick = onBack) { Text("Cancel", color = Muted) }
+ Text("Edit shortcut", Modifier.weight(1f), textAlign = androidx.compose.ui.text.style.TextAlign.Center, fontWeight = FontWeight.Bold, color = Ink)
+ TextButton(onClick = { onSave(Shortcut(name.ifBlank { "Untitled Shortcut" }, "Custom shortcut", shortcut.color, actions)) }) { Text("Done", color = Red, fontWeight = FontWeight.Bold) }
+ }
+ LazyColumn(contentPadding = androidx.compose.foundation.layout.PaddingValues(20.dp, 8.dp, 20.dp, 28.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
+ item {
+ OutlinedTextField(value = name, onValueChange = { name = it }, label = { Text("Shortcut name") }, singleLine = true, modifier = Modifier.fillMaxWidth())
+ Spacer(Modifier.height(6.dp))
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Text("Actions", fontWeight = FontWeight.Bold, fontSize = 20.sp, color = Ink, modifier = Modifier.weight(1f))
+ Button(onClick = {
+ if (
+ android.os.Build.VERSION.SDK_INT >= 33 &&
+ actions.any { it.type == ActionType.SHOW_NOTIFICATION } &&
+ ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
+ ) {
+ (context as? Activity)?.let {
+ ActivityCompat.requestPermissions(it, arrayOf(Manifest.permission.POST_NOTIFICATIONS), NOTIFICATION_PERMISSION_REQUEST)
+ }
+ runMessage = "Allow notifications, then run again."
+ return@Button
+ }
+ if (
+ actions.any { it.type == ActionType.SET_FOCUS_MODE } &&
+ !(context.getSystemService(android.app.NotificationManager::class.java)?.isNotificationPolicyAccessGranted ?: false)
+ ) {
+ context.startActivity(Intent(Settings.ACTION_NOTIFICATION_POLICY_ACCESS_SETTINGS))
+ runMessage = "Allow Do Not Disturb access for Shortcuts, then run again."
+ return@Button
+ }
+ isRunning = true
+ val workflow = WorkflowDocument(
+ id = name.lowercase().replace(Regex("[^a-z0-9]+"), "-").trim('-'),
+ name = name,
+ actions = actions.mapIndexed { index, action ->
+ WorkflowAction("${shortcut.name}-$index", action.type, action.parameters)
+ }
+ )
+ scope.launch {
+ val result = withContext(Dispatchers.IO) {
+ WorkflowEngine().execute(
+ workflow,
+ executor = AndroidActionExecutor(context)
+ )
+ }
+ isRunning = false
+ runMessage = if (result.stopped) {
+ "Stopped after ${result.events.size} steps"
+ } else {
+ "Finished ${result.events.size} steps"
+ }
+ store.appendExecution(
+ com.example.nothingshortcuts.core.storage.ExecutionRecord(
+ workflowId = workflow.id,
+ workflowName = workflow.name,
+ timestamp = System.currentTimeMillis(),
+ success = !result.stopped,
+ message = runMessage.orEmpty()
+ )
+ )
+ }
+ }, enabled = !isRunning, colors = ButtonDefaults.buttonColors(containerColor = Red)) {
+ Text(if (isRunning) "Running" else "Run")
+ }
+ }
+
+ if (runMessage != null) {
+ Text(runMessage!!, color = Green, fontSize = 13.sp, modifier = Modifier.padding(top = 5.dp))
+ }
+ }
+ itemsIndexed(actions) { index, action ->
+ EditorActionCard(action, index, actions.size, onUp = {
+ if (index > 0) actions = actions.toMutableList().also { it.add(index - 1, it.removeAt(index)) }
+ }, onDown = {
+ if (index < actions.lastIndex) actions = actions.toMutableList().also { it.add(index + 1, it.removeAt(index)) }
+ }, onDelete = { actions = actions.toMutableList().also { it.removeAt(index) } },
+ onConfigure = { configuringIndex = index })
+ }
+ item {
+ OutlinedButton(onClick = { showPicker = true }, modifier = Modifier.fillMaxWidth(), border = androidx.compose.foundation.BorderStroke(1.dp, Red), shape = RoundedCornerShape(16.dp)) {
+ Text("+ Add action", color = Red, fontWeight = FontWeight.Bold)
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun EditorActionCard(
+ action: ShortcutAction,
+ index: Int,
+ count: Int,
+ onUp: () -> Unit,
+ onDown: () -> Unit,
+ onDelete: () -> Unit,
+ onConfigure: () -> Unit
+) {
+ Card(shape = RoundedCornerShape(18.dp), colors = CardDefaults.cardColors(CardSurface), modifier = Modifier.fillMaxWidth()) {
+ Column(Modifier.padding(15.dp)) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Box(Modifier.size(34.dp).clip(RoundedCornerShape(10.dp)).background(action.color), contentAlignment = Alignment.Center) {
+ Text("${index + 1}", color = Color.White, fontWeight = FontWeight.Bold)
+ }
+ Spacer(Modifier.width(11.dp))
+ Column(Modifier.weight(1f)) {
+ Text(action.title, color = Ink, fontWeight = FontWeight.Bold)
+ Text(action.detail, color = Muted, fontSize = 12.sp)
+ }
+ TextButton(onClick = onDelete) { Text("×", color = Muted, fontSize = 22.sp) }
+ }
+ Divider(Modifier.padding(vertical = 10.dp), color = Border)
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ TextButton(onClick = onUp, enabled = index > 0) { Text("↑ Move", color = if (index > 0) Ink else Color.LightGray) }
+ TextButton(onClick = onDown, enabled = index < count - 1) { Text("↓ Move", color = if (index < count - 1) Ink else Color.LightGray) }
+ Spacer(Modifier.weight(1f))
+ Text(
+ "Configure ›",
+ color = action.color,
+ fontSize = 13.sp,
+ modifier = Modifier.padding(top = 12.dp).clickable { onConfigure() }
+ )
+ }
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun ActionConfigurationSheet(
+ action: ShortcutAction,
+ onDismiss: () -> Unit,
+ onSave: (ShortcutAction) -> Unit
+) {
+ var value by remember { mutableStateOf(action.parameters["value"].orEmpty()) }
+ var variable by remember { mutableStateOf(action.parameters["variable"].orEmpty()) }
+ var equals by remember { mutableStateOf(action.parameters["equals"].orEmpty()) }
+ var count by remember { mutableStateOf(action.parameters["count"].orEmpty()) }
+ var url by remember { mutableStateOf(action.parameters["url"].orEmpty()) }
+ var baseUrl by remember { mutableStateOf(action.parameters["baseUrl"].orEmpty()) }
+ val secretStore = remember { SecretStore() }
+ var token by remember { mutableStateOf(runCatching { secretStore.reveal(action.parameters["token"].orEmpty()) }.getOrDefault("")) }
+ var domain by remember { mutableStateOf(action.parameters["domain"].orEmpty()) }
+ var service by remember { mutableStateOf(action.parameters["service"].orEmpty()) }
+ ModalBottomSheet(onDismissRequest = onDismiss) {
+ Column(
+ Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 8.dp),
+ verticalArrangement = Arrangement.spacedBy(10.dp)
+ ) {
+ Text("Configure ${action.title}", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, color = Ink)
+ Text("Give this action the details it needs.", color = Muted, fontSize = 13.sp)
+ when (action.type) {
+ ActionType.HTTP_REQUEST -> {
+ OutlinedTextField(url, { url = it }, label = { Text("HTTPS URL") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
+ OutlinedTextField(value, { value = it }, label = { Text("Optional request body") }, modifier = Modifier.fillMaxWidth())
+ }
+ ActionType.HOME_ASSISTANT_SERVICE -> {
+ OutlinedTextField(baseUrl, { baseUrl = it }, label = { Text("Home Assistant HTTPS URL") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
+ OutlinedTextField(token, { token = it }, label = { Text("Long-lived access token") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
+ OutlinedTextField(domain, { domain = it }, label = { Text("Service domain, e.g. light") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
+ OutlinedTextField(service, { service = it }, label = { Text("Service, e.g. turn_on") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
+ OutlinedTextField(value, { value = it }, label = { Text("Service body JSON") }, modifier = Modifier.fillMaxWidth())
+ }
+ ActionType.TEXT, ActionType.SHOW_NOTIFICATION, ActionType.COPY_CLIPBOARD,
+ ActionType.ASK_INPUT, ActionType.SET_MEDIA_VOLUME, ActionType.SET_FOCUS_MODE, ActionType.WAIT,
+ ActionType.SET_VARIABLE, ActionType.LAUNCH_APP, ActionType.OPEN_SETTINGS,
+ ActionType.HTTP_REQUEST, ActionType.HOME_ASSISTANT_SERVICE -> {
+ OutlinedTextField(
+ value = value,
+ onValueChange = { value = it },
+ label = {
+ Text(
+ when (action.type) {
+ ActionType.WAIT -> "Duration in seconds"
+ ActionType.LAUNCH_APP -> "Application package"
+ ActionType.OPEN_SETTINGS -> "Settings page (battery, notifications)"
+ ActionType.SET_FOCUS_MODE -> "on or off"
+ ActionType.HTTP_REQUEST -> "HTTPS URL"
+ ActionType.HOME_ASSISTANT_SERVICE -> "Service body JSON"
+ else -> "Value or message"
+ }
+ )
+ },
+ modifier = Modifier.fillMaxWidth(),
+ singleLine = true
+ )
+ }
+ ActionType.IF -> {
+ OutlinedTextField(value = value, onValueChange = { value = it }, label = { Text("Value to check") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
+ OutlinedTextField(value = equals, onValueChange = { equals = it }, label = { Text("Equals") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
+ }
+ ActionType.REPEAT -> {
+ OutlinedTextField(value = count, onValueChange = { count = it }, label = { Text("Number of repetitions") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
+ }
+ ActionType.GET_VARIABLE, ActionType.CURRENT_DATE, ActionType.CURRENT_TIME,
+ ActionType.OPEN_APP,
+ ActionType.READ_BATTERY, ActionType.READ_CHARGING -> Unit
+ ActionType.STOP -> Text("This ends the shortcut immediately.", color = Muted)
+ }
+ if (action.type in setOf(
+ ActionType.TEXT, ActionType.SET_VARIABLE, ActionType.CURRENT_DATE,
+ ActionType.CURRENT_TIME, ActionType.READ_BATTERY, ActionType.READ_CHARGING
+ )
+ ) {
+ OutlinedTextField(value = variable, onValueChange = { variable = it }, label = { Text("Save output as (optional)") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
+ }
+ if (action.type == ActionType.GET_VARIABLE || action.type == ActionType.SET_VARIABLE) {
+ OutlinedTextField(value = variable, onValueChange = { variable = it }, label = { Text("Variable name") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
+ }
+ Button(
+ onClick = {
+ val parameters = buildMap {
+ if (value.isNotBlank()) put("value", value)
+ if (variable.isNotBlank()) {
+ put("variable", variable)
+ if (action.type == ActionType.GET_VARIABLE || action.type == ActionType.SET_VARIABLE) put("name", variable)
+ }
+ if (equals.isNotBlank()) put("equals", equals)
+ if (count.isNotBlank()) put("count", count)
+ if (url.isNotBlank()) put("url", url)
+ if (baseUrl.isNotBlank()) put("baseUrl", baseUrl)
+ if (token.isNotBlank()) put("token", secretStore.protect(token))
+ if (domain.isNotBlank()) put("domain", domain)
+ if (service.isNotBlank()) put("service", service)
+ if (action.type == ActionType.HOME_ASSISTANT_SERVICE && value.isNotBlank()) put("body", value)
+ }
+ val detail = when (action.type) {
+ ActionType.IF -> "If $value equals $equals"
+ ActionType.REPEAT -> "Repeat ${count.ifBlank { "0" }} times"
+ ActionType.GET_VARIABLE, ActionType.SET_VARIABLE -> variable.ifBlank { "Choose a variable" }
+ else -> value.ifBlank { action.detail }
+ }
+ onSave(action.copy(detail = detail, parameters = parameters))
+ },
+ modifier = Modifier.fillMaxWidth().padding(bottom = 20.dp),
+ colors = ButtonDefaults.buttonColors(containerColor = Ink)
+ ) { Text("Save action") }
+ }
+ }
+}
+
+@Composable
+private fun ActionPicker(onAdd: (ShortcutAction) -> Unit) {
+ var query by remember { mutableStateOf("") }
+ val filtered = actionCatalog.filter { it.title.contains(query, ignoreCase = true) || it.category.contains(query, ignoreCase = true) }
+ Column(Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 8.dp)) {
+ Text("Add action", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, color = Ink)
+ Text("Choose something for your shortcut to do.", color = Muted, fontSize = 13.sp)
+ Spacer(Modifier.height(14.dp))
+ OutlinedTextField(value = query, onValueChange = { query = it }, placeholder = { Text("Search actions") }, singleLine = true, modifier = Modifier.fillMaxWidth())
+ Spacer(Modifier.height(12.dp))
+ LazyColumn(Modifier.height(420.dp), verticalArrangement = Arrangement.spacedBy(8.dp), contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = 20.dp)) {
+ itemsIndexed(filtered) { _, action ->
+ Row(
+ Modifier.fillMaxWidth().clip(RoundedCornerShape(15.dp)).border(1.dp, Border, RoundedCornerShape(15.dp)).clickable { onAdd(action) }.padding(12.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Box(Modifier.size(34.dp).clip(RoundedCornerShape(10.dp)).background(action.color), contentAlignment = Alignment.Center) { Text("+", color = Color.White, fontSize = 20.sp) }
+ Spacer(Modifier.width(11.dp))
+ Column {
+ Text(action.title, color = Ink, fontWeight = FontWeight.SemiBold)
+ Text(action.category, color = Muted, fontSize = 12.sp)
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/example/nothingshortcuts/core/automation/Automation.kt b/app/src/main/java/com/example/nothingshortcuts/core/automation/Automation.kt
new file mode 100644
index 0000000..c477005
--- /dev/null
+++ b/app/src/main/java/com/example/nothingshortcuts/core/automation/Automation.kt
@@ -0,0 +1,19 @@
+package com.example.nothingshortcuts.core.automation
+
+enum class TriggerType {
+ AT_TIME,
+ BATTERY_LEVEL,
+ CHARGER_CONNECTED,
+ CHARGER_DISCONNECTED,
+ ENTER_LOCATION
+}
+
+data class Automation(
+ val id: String,
+ val workflowId: String,
+ val name: String,
+ val trigger: TriggerType,
+ val triggerValue: String = "",
+ val enabled: Boolean = true,
+ val requiresConfirmation: Boolean = false
+)
diff --git a/app/src/main/java/com/example/nothingshortcuts/core/automation/AutomationReceiver.kt b/app/src/main/java/com/example/nothingshortcuts/core/automation/AutomationReceiver.kt
new file mode 100644
index 0000000..aa72946
--- /dev/null
+++ b/app/src/main/java/com/example/nothingshortcuts/core/automation/AutomationReceiver.kt
@@ -0,0 +1,63 @@
+package com.example.nothingshortcuts.core.automation
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import com.example.nothingshortcuts.core.engine.WorkflowEngine
+import com.example.nothingshortcuts.core.integration.AndroidActionExecutor
+import com.example.nothingshortcuts.core.storage.ExecutionRecord
+import com.example.nothingshortcuts.core.storage.WorkflowStore
+
+class AutomationReceiver : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent) {
+ val store = WorkflowStore(context)
+ when (intent.action) {
+ AutomationScheduler.ACTION_RUN -> {
+ intent.getStringExtra(AutomationScheduler.EXTRA_AUTOMATION_ID)?.let {
+ runAutomation(context, store, it)
+ store.readAutomations().firstOrNull { automation -> automation.id == it }?.let {
+ AutomationScheduler(context).schedule(it)
+ }
+ }
+ }
+ Intent.ACTION_POWER_CONNECTED -> runForTrigger(context, store, TriggerType.CHARGER_CONNECTED)
+ Intent.ACTION_POWER_DISCONNECTED -> runForTrigger(context, store, TriggerType.CHARGER_DISCONNECTED)
+ Intent.ACTION_BOOT_COMPLETED -> AutomationScheduler(context).rescheduleAll()
+ }
+ }
+
+ private fun runForTrigger(context: Context, store: WorkflowStore, trigger: TriggerType) {
+ store.readAutomations().filter { it.enabled && it.trigger == trigger }.forEach {
+ runAutomation(context, store, it.id)
+ }
+ }
+
+ private fun runAutomation(context: Context, store: WorkflowStore, automationId: String) {
+ val automation = store.readAutomations().firstOrNull { it.id == automationId } ?: return
+ if (!automation.enabled) return
+ val workflow = store.readWorkflows().firstOrNull { it.id == automation.workflowId } ?: return
+ runCatching {
+ WorkflowEngine().execute(workflow, executor = AndroidActionExecutor(context))
+ }.onSuccess { result ->
+ store.appendExecution(
+ ExecutionRecord(
+ workflowId = workflow.id,
+ workflowName = workflow.name,
+ timestamp = System.currentTimeMillis(),
+ success = true,
+ message = "Automation completed (${result.events.size} steps)"
+ )
+ )
+ }.onFailure { error ->
+ store.appendExecution(
+ ExecutionRecord(
+ workflowId = workflow.id,
+ workflowName = workflow.name,
+ timestamp = System.currentTimeMillis(),
+ success = false,
+ message = error.message ?: "Automation failed"
+ )
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/com/example/nothingshortcuts/core/automation/AutomationScheduler.kt b/app/src/main/java/com/example/nothingshortcuts/core/automation/AutomationScheduler.kt
new file mode 100644
index 0000000..3d213f3
--- /dev/null
+++ b/app/src/main/java/com/example/nothingshortcuts/core/automation/AutomationScheduler.kt
@@ -0,0 +1,103 @@
+package com.example.nothingshortcuts.core.automation
+
+import android.app.AlarmManager
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.Manifest
+import androidx.core.content.ContextCompat
+import com.example.nothingshortcuts.core.storage.WorkflowStore
+import com.google.android.gms.location.Geofence
+import com.google.android.gms.location.GeofencingClient
+import com.google.android.gms.location.LocationServices
+import java.text.SimpleDateFormat
+import java.util.Calendar
+import java.util.Locale
+
+class AutomationScheduler(private val context: Context) {
+ private val alarmManager = context.getSystemService(AlarmManager::class.java)
+ private val geofencingClient: GeofencingClient = LocationServices.getGeofencingClient(context)
+
+ fun schedule(automation: Automation) {
+ cancel(automation.id)
+ if (!automation.enabled) return
+ if (automation.trigger == TriggerType.ENTER_LOCATION) {
+ registerLocation(automation)
+ return
+ }
+ if (automation.trigger != TriggerType.AT_TIME) return
+ val parser = SimpleDateFormat("HH:mm", Locale.US).apply { isLenient = false }
+ val time = runCatching { parser.parse(automation.triggerValue) }.getOrNull() ?: return
+ val now = Calendar.getInstance()
+ val next = Calendar.getInstance().apply {
+ set(Calendar.HOUR_OF_DAY, time.hours)
+ set(Calendar.MINUTE, time.minutes)
+ set(Calendar.SECOND, 0)
+ set(Calendar.MILLISECOND, 0)
+ if (!after(now)) add(Calendar.DAY_OF_YEAR, 1)
+ }
+ alarmManager.setAndAllowWhileIdle(
+ AlarmManager.RTC_WAKEUP,
+ next.timeInMillis,
+ pendingIntent(automation.id)
+ )
+ }
+
+ fun cancel(id: String) {
+ alarmManager.cancel(pendingIntent(id))
+ geofencingClient.removeGeofences(listOf("$GEOFENCE_PREFIX$id"))
+ }
+
+ fun rescheduleAll() {
+ WorkflowStore(context).readAutomations().forEach(::schedule)
+ }
+
+ private fun pendingIntent(id: String): PendingIntent =
+ PendingIntent.getBroadcast(
+ context,
+ id.hashCode(),
+ Intent(context, AutomationReceiver::class.java)
+ .setAction(ACTION_RUN)
+ .putExtra(EXTRA_AUTOMATION_ID, id),
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )
+
+ private fun registerLocation(automation: Automation) {
+ if (ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED) return
+ if (android.os.Build.VERSION.SDK_INT >= 29 &&
+ ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_BACKGROUND_LOCATION) != PackageManager.PERMISSION_GRANTED
+ ) return
+ val parts = automation.triggerValue.split(",").mapNotNull { it.trim().toDoubleOrNull() }
+ if (parts.size < 2) return
+ val radius = parts.getOrNull(2)?.toFloat()?.coerceIn(100f, 10_000f) ?: 150f
+ val geofence = Geofence.Builder()
+ .setRequestId("$GEOFENCE_PREFIX${automation.id}")
+ .setCircularRegion(parts[0], parts[1], radius)
+ .setExpirationDuration(Geofence.NEVER_EXPIRE)
+ .setTransitionTypes(Geofence.GEOFENCE_TRANSITION_ENTER)
+ .build()
+ val request = com.google.android.gms.location.GeofencingRequest.Builder()
+ .setInitialTrigger(com.google.android.gms.location.GeofencingRequest.INITIAL_TRIGGER_ENTER)
+ .addGeofence(geofence)
+ .build()
+ runCatching {
+ geofencingClient.addGeofences(request, geofencePendingIntent())
+ }
+ }
+
+ private fun geofencePendingIntent(): PendingIntent =
+ PendingIntent.getBroadcast(
+ context,
+ GEOFENCE_REQUEST_CODE,
+ Intent(context, GeofenceReceiver::class.java),
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )
+
+ companion object {
+ const val ACTION_RUN = "com.example.nothingshortcuts.RUN_AUTOMATION"
+ const val EXTRA_AUTOMATION_ID = "automation_id"
+ const val GEOFENCE_PREFIX = "location:"
+ private const val GEOFENCE_REQUEST_CODE = 4107
+ }
+}
diff --git a/app/src/main/java/com/example/nothingshortcuts/core/automation/GeofenceReceiver.kt b/app/src/main/java/com/example/nothingshortcuts/core/automation/GeofenceReceiver.kt
new file mode 100644
index 0000000..9c50e80
--- /dev/null
+++ b/app/src/main/java/com/example/nothingshortcuts/core/automation/GeofenceReceiver.kt
@@ -0,0 +1,41 @@
+package com.example.nothingshortcuts.core.automation
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import com.google.android.gms.location.GeofencingEvent
+import com.example.nothingshortcuts.core.engine.WorkflowEngine
+import com.example.nothingshortcuts.core.integration.AndroidActionExecutor
+import com.example.nothingshortcuts.core.storage.ExecutionRecord
+import com.example.nothingshortcuts.core.storage.WorkflowStore
+
+class GeofenceReceiver : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent) {
+ val event = GeofencingEvent.fromIntent(intent)
+ if (event == null || event.hasError() ||
+ event.geofenceTransition != com.google.android.gms.location.Geofence.GEOFENCE_TRANSITION_ENTER
+ ) return
+ val pendingResult = goAsync()
+ Thread {
+ try {
+ val store = WorkflowStore(context)
+ event.triggeringGeofences.orEmpty().forEach { geofence ->
+ val automationId = geofence.requestId.removePrefix(AutomationScheduler.GEOFENCE_PREFIX)
+ val automation = store.readAutomations().firstOrNull { it.id == automationId }
+ if (automation?.enabled == true && automation.trigger == TriggerType.ENTER_LOCATION) {
+ runCatching {
+ val workflow = store.readWorkflows().firstOrNull { it.id == automation.workflowId } ?: return@runCatching
+ val result = WorkflowEngine().execute(workflow, executor = AndroidActionExecutor(context))
+ store.appendExecution(
+ ExecutionRecord(workflow.id, workflow.name, System.currentTimeMillis(), !result.stopped,
+ "Gym entry automation completed (${result.events.size} steps)")
+ )
+ }
+ }
+ }
+ } finally {
+ pendingResult.finish()
+ }
+ }.start()
+ }
+}
diff --git a/app/src/main/java/com/example/nothingshortcuts/core/engine/ActionExecutor.kt b/app/src/main/java/com/example/nothingshortcuts/core/engine/ActionExecutor.kt
new file mode 100644
index 0000000..d9e373c
--- /dev/null
+++ b/app/src/main/java/com/example/nothingshortcuts/core/engine/ActionExecutor.kt
@@ -0,0 +1,19 @@
+package com.example.nothingshortcuts.core.engine
+
+interface ActionExecutor {
+ fun execute(
+ action: WorkflowAction,
+ variables: WorkflowVariables
+ ): ActionExecution
+}
+
+data class ActionExecution(
+ val message: String,
+ val output: String? = null,
+ val handled: Boolean = true
+)
+
+object NoOpActionExecutor : ActionExecutor {
+ override fun execute(action: WorkflowAction, variables: WorkflowVariables): ActionExecution =
+ ActionExecution(action.parameters["message"] ?: action.type.name.lowercase().replace('_', ' '))
+}
diff --git a/app/src/main/java/com/example/nothingshortcuts/core/engine/Workflow.kt b/app/src/main/java/com/example/nothingshortcuts/core/engine/Workflow.kt
new file mode 100644
index 0000000..793cdf1
--- /dev/null
+++ b/app/src/main/java/com/example/nothingshortcuts/core/engine/Workflow.kt
@@ -0,0 +1,65 @@
+package com.example.nothingshortcuts.core.engine
+
+enum class ActionType {
+ CURRENT_DATE,
+ CURRENT_TIME,
+ TEXT,
+ SET_VARIABLE,
+ GET_VARIABLE,
+ SHOW_NOTIFICATION,
+ COPY_CLIPBOARD,
+ READ_BATTERY,
+ READ_CHARGING,
+ ASK_INPUT,
+ OPEN_APP,
+ OPEN_SETTINGS,
+ LAUNCH_APP,
+ HTTP_REQUEST,
+ HOME_ASSISTANT_SERVICE,
+ SET_MEDIA_VOLUME,
+ SET_FOCUS_MODE,
+ WAIT,
+ IF,
+ REPEAT,
+ STOP
+}
+
+data class WorkflowAction(
+ val id: String,
+ val type: ActionType,
+ val parameters: Map = emptyMap(),
+ val children: List = emptyList(),
+ val otherwise: List = emptyList()
+)
+
+data class WorkflowDocument(
+ val version: Int = 1,
+ val id: String,
+ val name: String,
+ val actions: List
+)
+
+class WorkflowVariables(initial: Map = emptyMap()) {
+ private val values = initial.toMutableMap()
+
+ fun get(name: String): String? = values[name]
+
+ fun set(name: String, value: String) {
+ values[name] = value
+ }
+
+ fun snapshot(): Map = values.toMap()
+}
+
+data class ExecutionEvent(
+ val actionId: String,
+ val type: ActionType,
+ val message: String,
+ val output: String? = null
+)
+
+data class ExecutionResult(
+ val events: List,
+ val variables: Map,
+ val stopped: Boolean = false
+)
diff --git a/app/src/main/java/com/example/nothingshortcuts/core/engine/WorkflowCodec.kt b/app/src/main/java/com/example/nothingshortcuts/core/engine/WorkflowCodec.kt
new file mode 100644
index 0000000..13138f4
--- /dev/null
+++ b/app/src/main/java/com/example/nothingshortcuts/core/engine/WorkflowCodec.kt
@@ -0,0 +1,82 @@
+package com.example.nothingshortcuts.core.engine
+
+/**
+ * A small length-prefixed format keeps exports deterministic and avoids leaking
+ * platform JSON implementation details into the workflow core.
+ */
+object WorkflowCodec {
+ fun encode(workflow: WorkflowDocument): String {
+ val writer = TokenWriter()
+ writer.int(workflow.version)
+ writer.text(workflow.id)
+ writer.text(workflow.name)
+ writer.actions(workflow.actions)
+ return "shortcuts-v1:${writer.value()}"
+ }
+
+ fun decode(encoded: String): WorkflowDocument {
+ require(encoded.startsWith("shortcuts-v1:")) { "Unsupported workflow format" }
+ val reader = TokenReader(encoded.removePrefix("shortcuts-v1:"))
+ return WorkflowDocument(
+ version = reader.int(),
+ id = reader.text(),
+ name = reader.text(),
+ actions = reader.actions()
+ )
+ }
+
+ private class TokenWriter {
+ private val output = StringBuilder()
+
+ fun int(value: Int) = text(value.toString())
+
+ fun text(value: String) {
+ output.append(value.length).append(':').append(value)
+ }
+
+ fun actions(actions: List) {
+ int(actions.size)
+ actions.forEach { action ->
+ text(action.id)
+ text(action.type.name)
+ int(action.parameters.size)
+ action.parameters.forEach { (key, value) ->
+ text(key)
+ text(value)
+ }
+ actions(action.children)
+ actions(action.otherwise)
+ }
+ }
+
+ fun value(): String = output.toString()
+ }
+
+ private class TokenReader(private val input: String) {
+ private var position = 0
+
+ fun text(): String {
+ val separator = input.indexOf(':', position)
+ require(separator >= 0) { "Invalid workflow data" }
+ val length = input.substring(position, separator).toInt()
+ val start = separator + 1
+ val end = start + length
+ require(end <= input.length) { "Invalid workflow data" }
+ position = end
+ return input.substring(start, end)
+ }
+
+ fun int(): Int = text().toInt()
+
+ fun actions(): List = buildList {
+ repeat(int()) {
+ val id = text()
+ val type = ActionType.valueOf(text())
+ val parameters = buildMap {
+ repeat(int()) { put(text(), text()) }
+ }
+ add(WorkflowAction(id, type, parameters, actions(), actions()))
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/example/nothingshortcuts/core/engine/WorkflowEngine.kt b/app/src/main/java/com/example/nothingshortcuts/core/engine/WorkflowEngine.kt
new file mode 100644
index 0000000..faebd6c
--- /dev/null
+++ b/app/src/main/java/com/example/nothingshortcuts/core/engine/WorkflowEngine.kt
@@ -0,0 +1,97 @@
+package com.example.nothingshortcuts.core.engine
+
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+
+class WorkflowEngine {
+ fun execute(
+ workflow: WorkflowDocument,
+ initialVariables: Map = emptyMap(),
+ executor: ActionExecutor = NoOpActionExecutor
+ ): ExecutionResult {
+ val variables = WorkflowVariables(initialVariables)
+ val events = mutableListOf()
+ val stopped = executeActions(workflow.actions, variables, events, executor)
+ return ExecutionResult(events, variables.snapshot(), stopped)
+ }
+
+ private fun executeActions(
+ actions: List,
+ variables: WorkflowVariables,
+ events: MutableList,
+ executor: ActionExecutor
+ ): Boolean {
+ actions.forEach { action ->
+ when (action.type) {
+ ActionType.CURRENT_DATE -> {
+ val output = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).format(Date())
+ action.parameters["variable"]?.let { variables.set(it, output) }
+ events += ExecutionEvent(action.id, action.type, "Got today's date", output)
+ }
+ ActionType.CURRENT_TIME -> {
+ val output = SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date())
+ action.parameters["variable"]?.let { variables.set(it, output) }
+ events += ExecutionEvent(action.id, action.type, "Got the current time", output)
+ }
+ ActionType.TEXT -> {
+ val output = resolve(action.parameters["value"].orEmpty(), variables)
+ action.parameters["variable"]?.let { variables.set(it, output) }
+ events += ExecutionEvent(action.id, action.type, "Created text", output)
+ }
+ ActionType.SET_VARIABLE -> {
+ val name = action.parameters["name"].orEmpty()
+ val value = resolve(action.parameters["value"].orEmpty(), variables)
+ variables.set(name, value)
+ events += ExecutionEvent(action.id, action.type, "Saved $name", value)
+ }
+ ActionType.GET_VARIABLE -> {
+ val name = action.parameters["name"].orEmpty()
+ val output = variables.get(name)
+ events += ExecutionEvent(action.id, action.type, "Read $name", output)
+ }
+ ActionType.IF -> {
+ val actual = resolve(action.parameters["value"].orEmpty(), variables)
+ val expected = action.parameters["equals"].orEmpty()
+ val branch = if (actual == expected) action.children else action.otherwise
+ events += ExecutionEvent(action.id, action.type, if (actual == expected) "Condition matched" else "Otherwise branch")
+ if (executeActions(branch, variables, events, executor)) return true
+ }
+ ActionType.REPEAT -> {
+ val count = action.parameters["count"].orEmpty().toIntOrNull()?.coerceIn(0, 100) ?: 0
+ repeat(count) {
+ if (executeActions(action.children, variables, events, executor)) return true
+ }
+ events += ExecutionEvent(action.id, action.type, "Repeated $count times")
+ }
+ ActionType.STOP -> {
+ events += ExecutionEvent(action.id, action.type, "Shortcut stopped")
+ return true
+ }
+ ActionType.SHOW_NOTIFICATION,
+ ActionType.COPY_CLIPBOARD,
+ ActionType.READ_BATTERY,
+ ActionType.READ_CHARGING,
+ ActionType.ASK_INPUT,
+ ActionType.OPEN_APP,
+ ActionType.OPEN_SETTINGS,
+ ActionType.LAUNCH_APP,
+ ActionType.HTTP_REQUEST,
+ ActionType.HOME_ASSISTANT_SERVICE,
+ ActionType.SET_MEDIA_VOLUME,
+ ActionType.SET_FOCUS_MODE,
+ ActionType.WAIT -> {
+ val execution = executor.execute(action, variables)
+ execution.output?.let { output ->
+ action.parameters["variable"]?.let { variables.set(it, output) }
+ }
+ events += ExecutionEvent(action.id, action.type, execution.message, execution.output)
+ }
+ }
+ }
+ return false
+ }
+
+ private fun resolve(value: String, variables: WorkflowVariables): String =
+ Regex("\\{\\{\\s*([^}]+)\\s*\\}\\}").replace(value) { variables.get(it.groupValues[1]).orEmpty() }
+}
diff --git a/app/src/main/java/com/example/nothingshortcuts/core/integration/AndroidActionExecutor.kt b/app/src/main/java/com/example/nothingshortcuts/core/integration/AndroidActionExecutor.kt
new file mode 100644
index 0000000..ac8e0ed
--- /dev/null
+++ b/app/src/main/java/com/example/nothingshortcuts/core/integration/AndroidActionExecutor.kt
@@ -0,0 +1,178 @@
+package com.example.nothingshortcuts.core.integration
+
+import android.Manifest
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.content.ClipData
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.media.AudioManager
+import android.os.BatteryManager
+import android.provider.Settings
+import androidx.core.app.NotificationCompat
+import androidx.core.content.ContextCompat
+import com.example.nothingshortcuts.R
+import com.example.nothingshortcuts.core.engine.ActionExecution
+import com.example.nothingshortcuts.core.engine.ActionExecutor
+import com.example.nothingshortcuts.core.engine.ActionType
+import com.example.nothingshortcuts.core.engine.WorkflowAction
+import com.example.nothingshortcuts.core.engine.WorkflowVariables
+import java.net.HttpURLConnection
+import java.net.URL
+
+class AndroidActionExecutor(private val context: Context) : ActionExecutor {
+ private val secrets = SecretStore()
+ override fun execute(action: WorkflowAction, variables: WorkflowVariables): ActionExecution {
+ return when (action.type) {
+ ActionType.READ_BATTERY -> {
+ val battery = context.registerReceiver(null, android.content.IntentFilter(Intent.ACTION_BATTERY_CHANGED))
+ val level = battery?.getIntExtra(BatteryManager.EXTRA_LEVEL, -1) ?: -1
+ val scale = battery?.getIntExtra(BatteryManager.EXTRA_SCALE, 100) ?: 100
+ val percentage = if (level >= 0) (level * 100 / scale.coerceAtLeast(1)).toString() else null
+ ActionExecution(
+ message = percentage?.let { "Battery is at $it%" } ?: "Battery level unavailable",
+ output = percentage
+ )
+ }
+ ActionType.READ_CHARGING -> {
+ val battery = context.registerReceiver(null, android.content.IntentFilter(Intent.ACTION_BATTERY_CHANGED))
+ val status = battery?.getIntExtra(BatteryManager.EXTRA_STATUS, -1) ?: -1
+ val charging = status == BatteryManager.BATTERY_STATUS_CHARGING || status == BatteryManager.BATTERY_STATUS_FULL
+ ActionExecution(if (charging) "Device is charging" else "Device is not charging", charging.toString())
+ }
+ ActionType.COPY_CLIPBOARD -> {
+ val text = resolve(action.parameters["value"].orEmpty(), variables)
+ val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager
+ clipboard.setPrimaryClip(ClipData.newPlainText("Shortcuts", text))
+ ActionExecution("Copied text to clipboard", text)
+ }
+ ActionType.SHOW_NOTIFICATION -> showNotification(resolve(action.parameters["value"].orEmpty(), variables))
+ ActionType.LAUNCH_APP, ActionType.OPEN_APP -> launchApp(action.parameters["package"].orEmpty())
+ ActionType.OPEN_SETTINGS -> openSettings(action.parameters["screen"].orEmpty())
+ ActionType.SET_MEDIA_VOLUME -> setMediaVolume(action.parameters["value"].orEmpty())
+ ActionType.SET_FOCUS_MODE -> setFocusMode(action.parameters["value"].orEmpty())
+ ActionType.HTTP_REQUEST -> httpRequest(action, variables)
+ ActionType.HOME_ASSISTANT_SERVICE -> homeAssistantService(action, variables)
+ else -> ActionExecution("Action is not an Android device action")
+ }
+ }
+
+ private fun showNotification(message: String): ActionExecution {
+ if (android.os.Build.VERSION.SDK_INT >= 33 &&
+ ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
+ ) {
+ return ActionExecution("Notification permission required")
+ }
+ val manager = context.getSystemService(NotificationManager::class.java)
+ if (android.os.Build.VERSION.SDK_INT >= 26) {
+ manager.createNotificationChannel(NotificationChannel(CHANNEL_ID, "Shortcuts", NotificationManager.IMPORTANCE_DEFAULT))
+ }
+ manager.notify(
+ message.hashCode(),
+ NotificationCompat.Builder(context, CHANNEL_ID)
+ .setSmallIcon(R.mipmap.ic_launcher)
+ .setContentTitle("Shortcuts")
+ .setContentText(message.ifBlank { "Shortcut completed" })
+ .setAutoCancel(true)
+ .build()
+ )
+ return ActionExecution("Notification shown")
+ }
+
+ private fun launchApp(packageName: String): ActionExecution {
+ val intent = context.packageManager.getLaunchIntentForPackage(packageName)
+ ?: return ActionExecution("App not found: $packageName")
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ context.startActivity(intent)
+ return ActionExecution("Opened $packageName")
+ }
+
+ private fun openSettings(screen: String): ActionExecution {
+ val action = when (screen.lowercase()) {
+ "battery" -> Settings.ACTION_BATTERY_SAVER_SETTINGS
+ "notifications" -> Settings.ACTION_APP_NOTIFICATION_SETTINGS
+ else -> Settings.ACTION_SETTINGS
+ }
+ val intent = Intent(action).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ if (action == Settings.ACTION_APP_NOTIFICATION_SETTINGS) {
+ intent.putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
+ }
+ context.startActivity(intent)
+ return ActionExecution("Opened Android settings")
+ }
+
+ private fun setMediaVolume(value: String): ActionExecution {
+ val volume = value.toIntOrNull()?.coerceIn(0, 100)
+ ?: return ActionExecution("Enter a volume from 0 to 100")
+ val audio = context.getSystemService(AudioManager::class.java)
+ val max = audio.getStreamMaxVolume(AudioManager.STREAM_MUSIC)
+ audio.setStreamVolume(AudioManager.STREAM_MUSIC, (max * volume / 100f).toInt(), 0)
+ return ActionExecution("Media volume set to $volume%")
+ }
+
+ private fun setFocusMode(value: String): ActionExecution {
+ val notificationManager = context.getSystemService(NotificationManager::class.java)
+ if (!notificationManager.isNotificationPolicyAccessGranted) {
+ return ActionExecution("Allow Do Not Disturb access for Gym Focus in Android settings")
+ }
+ val enabled = value.equals("on", ignoreCase = true) ||
+ value.equals("true", ignoreCase = true) ||
+ value.equals("priority", ignoreCase = true)
+ notificationManager.setInterruptionFilter(
+ if (enabled) NotificationManager.INTERRUPTION_FILTER_PRIORITY
+ else NotificationManager.INTERRUPTION_FILTER_ALL
+ )
+ return ActionExecution(if (enabled) "Gym Focus enabled" else "Gym Focus disabled")
+ }
+
+ private fun httpRequest(action: WorkflowAction, variables: WorkflowVariables): ActionExecution {
+ val url = resolve(action.parameters["url"].orEmpty(), variables)
+ if (!url.startsWith("https://")) return ActionExecution("HTTPS is required for network actions")
+ return runCatching {
+ val connection = URL(url).openConnection() as HttpURLConnection
+ connection.requestMethod = action.parameters["method"]?.uppercase() ?: "GET"
+ connection.connectTimeout = 10_000
+ connection.readTimeout = 10_000
+ connection.setRequestProperty("Accept", "application/json")
+ val status = connection.responseCode
+ val body = (if (status in 200..399) connection.inputStream else connection.errorStream)
+ ?.bufferedReader()?.use { it.readText().take(8_000) }.orEmpty()
+ connection.disconnect()
+ ActionExecution(
+ message = "HTTP $status",
+ output = body,
+ )
+ }.getOrElse { ActionExecution("HTTP request failed: ${it.message ?: "network error"}") }
+ }
+
+ private fun homeAssistantService(action: WorkflowAction, variables: WorkflowVariables): ActionExecution {
+ val baseUrl = resolve(action.parameters["baseUrl"].orEmpty(), variables).trimEnd('/')
+ val token = runCatching { secrets.reveal(action.parameters["token"].orEmpty()) }.getOrDefault("")
+ val domain = action.parameters["domain"].orEmpty()
+ val service = action.parameters["service"].orEmpty()
+ if (!baseUrl.startsWith("https://") || token.isBlank() || domain.isBlank() || service.isBlank()) {
+ return ActionExecution("Configure an HTTPS Home Assistant URL, token, domain, and service")
+ }
+ return runCatching {
+ val connection = URL("$baseUrl/api/services/$domain/$service").openConnection() as HttpURLConnection
+ connection.requestMethod = "POST"
+ connection.doOutput = true
+ connection.connectTimeout = 10_000
+ connection.readTimeout = 10_000
+ connection.setRequestProperty("Authorization", "Bearer $token")
+ connection.setRequestProperty("Content-Type", "application/json")
+ connection.outputStream.use { it.write(resolve(action.parameters["body"].orEmpty(), variables).toByteArray()) }
+ val status = connection.responseCode
+ connection.disconnect()
+ ActionExecution("Home Assistant returned HTTP $status")
+ }.getOrElse { ActionExecution("Home Assistant request failed: ${it.message ?: "network error"}") }
+ }
+
+ private fun resolve(value: String, variables: WorkflowVariables): String =
+ Regex("\\{\\{\\s*([^}]+)\\s*\\}\\}").replace(value) { variables.get(it.groupValues[1]).orEmpty() }
+
+ private companion object {
+ const val CHANNEL_ID = "shortcut_runs"
+ }
+}
diff --git a/app/src/main/java/com/example/nothingshortcuts/core/integration/SecretStore.kt b/app/src/main/java/com/example/nothingshortcuts/core/integration/SecretStore.kt
new file mode 100644
index 0000000..f114897
--- /dev/null
+++ b/app/src/main/java/com/example/nothingshortcuts/core/integration/SecretStore.kt
@@ -0,0 +1,44 @@
+package com.example.nothingshortcuts.core.integration
+
+import android.content.Context
+import android.util.Base64
+import java.nio.charset.StandardCharsets
+import java.security.KeyStore
+import javax.crypto.Cipher
+import javax.crypto.KeyGenerator
+import javax.crypto.SecretKey
+import javax.crypto.spec.GCMParameterSpec
+
+class SecretStore {
+ private val key: SecretKey
+ get() {
+ val store = KeyStore.getInstance(ANDROID_KEY_STORE).apply { load(null) }
+ val existing = store.getKey(KEY_ALIAS, null) as? SecretKey
+ if (existing != null) return existing
+ return KeyGenerator.getInstance("AES", ANDROID_KEY_STORE).apply {
+ init(256)
+ }.generateKey()
+ }
+
+ fun protect(value: String): String {
+ if (value.isBlank() || value.startsWith(PREFIX)) return value
+ val cipher = Cipher.getInstance(TRANSFORMATION).apply { init(Cipher.ENCRYPT_MODE, key) }
+ val encrypted = cipher.doFinal(value.toByteArray(StandardCharsets.UTF_8))
+ return PREFIX + Base64.encodeToString(cipher.iv + encrypted, Base64.NO_WRAP)
+ }
+
+ fun reveal(value: String): String {
+ if (!value.startsWith(PREFIX)) return value
+ val bytes = Base64.decode(value.removePrefix(PREFIX), Base64.NO_WRAP)
+ val cipher = Cipher.getInstance(TRANSFORMATION)
+ cipher.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(128, bytes.copyOfRange(0, 12)))
+ return cipher.doFinal(bytes.copyOfRange(12, bytes.size)).toString(StandardCharsets.UTF_8)
+ }
+
+ private companion object {
+ const val ANDROID_KEY_STORE = "AndroidKeyStore"
+ const val KEY_ALIAS = "shortcuts_home_assistant"
+ const val TRANSFORMATION = "AES/GCM/NoPadding"
+ const val PREFIX = "enc:"
+ }
+}
diff --git a/app/src/main/java/com/example/nothingshortcuts/core/storage/WorkflowStore.kt b/app/src/main/java/com/example/nothingshortcuts/core/storage/WorkflowStore.kt
new file mode 100644
index 0000000..57c334d
--- /dev/null
+++ b/app/src/main/java/com/example/nothingshortcuts/core/storage/WorkflowStore.kt
@@ -0,0 +1,109 @@
+package com.example.nothingshortcuts.core.storage
+
+import android.content.Context
+import com.example.nothingshortcuts.core.engine.WorkflowCodec
+import com.example.nothingshortcuts.core.engine.WorkflowDocument
+import com.example.nothingshortcuts.core.automation.Automation
+import com.example.nothingshortcuts.core.automation.TriggerType
+
+data class ExecutionRecord(
+ val workflowId: String,
+ val workflowName: String,
+ val timestamp: Long,
+ val success: Boolean,
+ val message: String
+)
+
+class WorkflowStore(context: Context) {
+ private val preferences = context.getSharedPreferences(FILE_NAME, Context.MODE_PRIVATE)
+
+ fun readWorkflows(): List =
+ preferences.getStringSet(WORKFLOWS, emptySet())
+ .orEmpty()
+ .mapNotNull { encoded -> runCatching { WorkflowCodec.decode(encoded) }.getOrNull() }
+ .sortedBy { it.name.lowercase() }
+
+ fun saveWorkflow(workflow: WorkflowDocument) {
+ val workflows = readWorkflows().filterNot { it.id == workflow.id } + workflow
+ preferences.edit().putStringSet(WORKFLOWS, workflows.map(WorkflowCodec::encode).toSet()).apply()
+ }
+
+ fun exportWorkflows(): String = readWorkflows()
+ .map { workflow ->
+ workflow.copy(actions = workflow.actions.map(::withoutSecrets))
+ }
+ .joinToString("\n", transform = WorkflowCodec::encode)
+
+ private fun withoutSecrets(action: com.example.nothingshortcuts.core.engine.WorkflowAction):
+ com.example.nothingshortcuts.core.engine.WorkflowAction = action.copy(
+ parameters = action.parameters - setOf("token"),
+ children = action.children.map(::withoutSecrets),
+ otherwise = action.otherwise.map(::withoutSecrets)
+ )
+
+ fun importWorkflows(bundle: String): Int {
+ val imported = bundle.lineSequence().mapNotNull { line ->
+ runCatching { WorkflowCodec.decode(line.trim()) }.getOrNull()
+ }.toList()
+ imported.forEach(::saveWorkflow)
+ return imported.size
+ }
+
+ fun deleteWorkflow(id: String) {
+ val workflows = readWorkflows().filterNot { it.id == id }
+ preferences.edit().putStringSet(WORKFLOWS, workflows.map(WorkflowCodec::encode).toSet()).apply()
+ }
+
+ fun appendExecution(record: ExecutionRecord) {
+ val existing = preferences.getStringSet(HISTORY, emptySet()).orEmpty().toMutableSet()
+ existing += listOf(
+ record.workflowId,
+ record.workflowName,
+ record.timestamp.toString(),
+ record.success.toString(),
+ record.message
+ ).joinToString("|") { it.replace("|", " ") }
+ preferences.edit().putStringSet(HISTORY, existing.toList().takeLast(MAX_HISTORY).toSet()).apply()
+ }
+
+ fun readHistory(): List =
+ preferences.getStringSet(HISTORY, emptySet()).orEmpty().mapNotNull { value ->
+ val fields = value.split("|", limit = 5)
+ if (fields.size != 5) return@mapNotNull null
+ ExecutionRecord(fields[0], fields[1], fields[2].toLongOrNull() ?: return@mapNotNull null, fields[3].toBoolean(), fields[4])
+ }.sortedByDescending { it.timestamp }
+
+ fun readAutomations(): List =
+ preferences.getStringSet(AUTOMATIONS, emptySet()).orEmpty().mapNotNull { value ->
+ val fields = value.split("|", limit = 7)
+ if (fields.size != 7) return@mapNotNull null
+ runCatching {
+ Automation(fields[0], fields[1], fields[2], TriggerType.valueOf(fields[3]), fields[4], fields[5].toBoolean(), fields[6].toBoolean())
+ }.getOrNull()
+ }.sortedBy { it.name.lowercase() }
+
+ fun saveAutomation(automation: Automation) {
+ val updated = readAutomations().filterNot { it.id == automation.id } + automation
+ preferences.edit().putStringSet(AUTOMATIONS, updated.map(::encodeAutomation).toSet()).apply()
+ }
+
+ fun deleteAutomation(id: String) {
+ preferences.edit().putStringSet(
+ AUTOMATIONS,
+ readAutomations().filterNot { it.id == id }.map(::encodeAutomation).toSet()
+ ).apply()
+ }
+
+ private fun encodeAutomation(automation: Automation): String = listOf(
+ automation.id, automation.workflowId, automation.name, automation.trigger.name,
+ automation.triggerValue, automation.enabled.toString(), automation.requiresConfirmation.toString()
+ ).joinToString("|") { it.replace("|", " ") }
+
+ private companion object {
+ const val FILE_NAME = "shortcuts_store"
+ const val WORKFLOWS = "workflows"
+ const val HISTORY = "execution_history"
+ const val AUTOMATIONS = "automations"
+ const val MAX_HISTORY = 30
+ }
+}
diff --git a/app/src/main/java/com/example/nothingshortcuts/ui/theme/Color.kt b/app/src/main/java/com/example/nothingshortcuts/ui/theme/Color.kt
index a27a39b..f92a931 100644
--- a/app/src/main/java/com/example/nothingshortcuts/ui/theme/Color.kt
+++ b/app/src/main/java/com/example/nothingshortcuts/ui/theme/Color.kt
@@ -2,10 +2,10 @@ package com.example.nothingshortcuts.ui.theme
import androidx.compose.ui.graphics.Color
-val Purple80 = Color(0xFFD0BCFF)
-val PurpleGrey80 = Color(0xFFCCC2DC)
-val Pink80 = Color(0xFFEFB8C8)
+val Purple80 = Color(0xFFE2E2E2)
+val PurpleGrey80 = Color(0xFFBDBDBD)
+val Pink80 = Color(0xFFFF8A86)
-val Purple40 = Color(0xFF6650a4)
-val PurpleGrey40 = Color(0xFF625b71)
-val Pink40 = Color(0xFF7D5260)
\ No newline at end of file
+val Purple40 = Color(0xFF262626)
+val PurpleGrey40 = Color(0xFF666666)
+val Pink40 = Color(0xFFC62828)
\ No newline at end of file
diff --git a/app/src/main/java/com/example/nothingshortcuts/ui/theme/Theme.kt b/app/src/main/java/com/example/nothingshortcuts/ui/theme/Theme.kt
index 69eb719..03517d9 100644
--- a/app/src/main/java/com/example/nothingshortcuts/ui/theme/Theme.kt
+++ b/app/src/main/java/com/example/nothingshortcuts/ui/theme/Theme.kt
@@ -1,8 +1,6 @@
package com.example.nothingshortcuts.ui.theme
-import android.app.Activity
import android.os.Build
-import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
@@ -10,17 +8,24 @@ import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.graphics.Color
private val DarkColorScheme = darkColorScheme(
- primary = Purple80,
+ primary = Pink80,
+ onPrimary = Color.Black,
secondary = PurpleGrey80,
- tertiary = Pink80
+ tertiary = Pink80,
+ background = Color(0xFF0F0F10),
+ surface = Color(0xFF1C1C1E),
+ surfaceVariant = Color(0xFF2C2C2E),
+ onBackground = Color(0xFFF5F5F7),
+ onSurface = Color(0xFFF5F5F7),
)
private val LightColorScheme = lightColorScheme(
- primary = Purple40,
+ primary = Pink40,
secondary = PurpleGrey40,
- tertiary = Pink40
+ tertiary = Pink40,
/* Other default colors to override
background = Color(0xFFFFFBFE),
@@ -35,9 +40,9 @@ private val LightColorScheme = lightColorScheme(
@Composable
fun NothingshortcutsTheme(
- darkTheme: Boolean = isSystemInDarkTheme(),
+ darkTheme: Boolean = true,
// Dynamic color is available on Android 12+
- dynamicColor: Boolean = true,
+ dynamicColor: Boolean = false,
content: @Composable () -> Unit
) {
val colorScheme = when {
diff --git a/app/src/main/java/com/example/nothingshortcuts/ui/theme/Type.kt b/app/src/main/java/com/example/nothingshortcuts/ui/theme/Type.kt
index 2483305..b2d941d 100644
--- a/app/src/main/java/com/example/nothingshortcuts/ui/theme/Type.kt
+++ b/app/src/main/java/com/example/nothingshortcuts/ui/theme/Type.kt
@@ -8,12 +8,40 @@ import androidx.compose.ui.unit.sp
// Set of Material typography styles to start with
val Typography = Typography(
+ displaySmall = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Bold,
+ fontSize = 34.sp,
+ lineHeight = 40.sp,
+ letterSpacing = (-0.5).sp
+ ),
+ headlineSmall = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Bold,
+ fontSize = 24.sp,
+ lineHeight = 30.sp,
+ letterSpacing = 0.sp
+ ),
+ titleLarge = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Bold,
+ fontSize = 20.sp,
+ lineHeight = 26.sp,
+ letterSpacing = 0.sp
+ ),
bodyLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
- letterSpacing = 0.5.sp
+ letterSpacing = 0.2.sp
+ ),
+ labelSmall = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Medium,
+ fontSize = 11.sp,
+ lineHeight = 16.sp,
+ letterSpacing = 0.3.sp
)
/* Other default text styles to override
titleLarge = TextStyle(
diff --git a/app/src/main/java/com/example/nothingshortcuts/widget/ShortcutsWidget.kt b/app/src/main/java/com/example/nothingshortcuts/widget/ShortcutsWidget.kt
new file mode 100644
index 0000000..9074fe5
--- /dev/null
+++ b/app/src/main/java/com/example/nothingshortcuts/widget/ShortcutsWidget.kt
@@ -0,0 +1,47 @@
+package com.example.nothingshortcuts.widget
+
+import android.app.PendingIntent
+import android.appwidget.AppWidgetManager
+import android.appwidget.AppWidgetProvider
+import android.content.ComponentName
+import android.content.Context
+import android.content.Intent
+import android.widget.RemoteViews
+import com.example.nothingshortcuts.MainActivity
+import com.example.nothingshortcuts.R
+import com.example.nothingshortcuts.core.storage.WorkflowStore
+
+class ShortcutsWidget : AppWidgetProvider() {
+ override fun onUpdate(context: Context, manager: AppWidgetManager, ids: IntArray) {
+ ids.forEach { id -> update(context, manager, id) }
+ }
+
+ override fun onEnabled(context: Context) {
+ updateAll(context)
+ }
+
+ companion object {
+ fun updateAll(context: Context) {
+ val manager = AppWidgetManager.getInstance(context)
+ val component = ComponentName(context, ShortcutsWidget::class.java)
+ manager.getAppWidgetIds(component).forEach { update(context, manager, it) }
+ }
+
+ private fun update(context: Context, manager: AppWidgetManager, id: Int) {
+ val views = RemoteViews(context.packageName, R.layout.widget_shortcuts)
+ val names = WorkflowStore(context).readWorkflows().take(3).joinToString("\n") { "• ${it.name}" }
+ views.setTextViewText(R.id.widget_title, "Shortcuts")
+ views.setTextViewText(R.id.widget_items, names.ifBlank { "Create your first shortcut" })
+ views.setOnClickPendingIntent(
+ R.id.widget_root,
+ PendingIntent.getActivity(
+ context,
+ id,
+ Intent(context, MainActivity::class.java),
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )
+ )
+ manager.updateAppWidget(id, views)
+ }
+ }
+}
diff --git a/app/src/main/res/layout/widget_shortcuts.xml b/app/src/main/res/layout/widget_shortcuts.xml
new file mode 100644
index 0000000..ab6aa10
--- /dev/null
+++ b/app/src/main/res/layout/widget_shortcuts.xml
@@ -0,0 +1,23 @@
+
+
+
+
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 36beae3..3b1c074 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -1,3 +1,4 @@
nothingshortcuts
+ Favourite shortcuts
\ No newline at end of file
diff --git a/app/src/main/res/xml/widget_info.xml b/app/src/main/res/xml/widget_info.xml
new file mode 100644
index 0000000..fc3ff2c
--- /dev/null
+++ b/app/src/main/res/xml/widget_info.xml
@@ -0,0 +1,9 @@
+
+
diff --git a/app/src/test/java/com/example/nothingshortcuts/WorkflowEngineTest.kt b/app/src/test/java/com/example/nothingshortcuts/WorkflowEngineTest.kt
new file mode 100644
index 0000000..6306d47
--- /dev/null
+++ b/app/src/test/java/com/example/nothingshortcuts/WorkflowEngineTest.kt
@@ -0,0 +1,80 @@
+package com.example.nothingshortcuts
+
+import com.example.nothingshortcuts.core.engine.ActionType
+import com.example.nothingshortcuts.core.engine.WorkflowAction
+import com.example.nothingshortcuts.core.engine.WorkflowDocument
+import com.example.nothingshortcuts.core.engine.WorkflowEngine
+import com.example.nothingshortcuts.core.engine.WorkflowCodec
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class WorkflowEngineTest {
+ private val engine = WorkflowEngine()
+
+ @Test
+ fun variablesArePassedBetweenActions() {
+ val result = engine.execute(
+ WorkflowDocument(
+ id = "test",
+ name = "Greeting",
+ actions = listOf(
+ WorkflowAction("one", ActionType.SET_VARIABLE, mapOf("name" to "person", "value" to "Milan")),
+ WorkflowAction("two", ActionType.TEXT, mapOf("value" to "Hello {{person}}", "variable" to "message"))
+ )
+ )
+ )
+ assertEquals("Hello Milan", result.variables["message"])
+ }
+
+ @Test
+ fun ifUsesMatchingBranch() {
+ val result = engine.execute(
+ WorkflowDocument(
+ id = "test",
+ name = "Condition",
+ actions = listOf(
+ WorkflowAction(
+ "if",
+ ActionType.IF,
+ mapOf("value" to "yes", "equals" to "yes"),
+ children = listOf(WorkflowAction("then", ActionType.SET_VARIABLE, mapOf("name" to "result", "value" to "then"))),
+ otherwise = listOf(WorkflowAction("else", ActionType.SET_VARIABLE, mapOf("name" to "result", "value" to "else")))
+ )
+ )
+ )
+ )
+ assertEquals("then", result.variables["result"])
+ }
+
+ @Test
+ fun repeatAndStopAreHonoured() {
+ val result = engine.execute(
+ WorkflowDocument(
+ id = "test",
+ name = "Repeat",
+ actions = listOf(
+ WorkflowAction("repeat", ActionType.REPEAT, mapOf("count" to "3"), children = listOf(WorkflowAction("set", ActionType.SET_VARIABLE, mapOf("name" to "value", "value" to "done")))),
+ WorkflowAction("stop", ActionType.STOP),
+ WorkflowAction("never", ActionType.SET_VARIABLE, mapOf("name" to "value", "value" to "wrong"))
+ )
+ )
+ )
+ assertTrue(result.stopped)
+ assertEquals("done", result.variables["value"])
+ }
+
+ @Test
+ fun workflowRoundTripsThroughVersionedFormat() {
+ val original = WorkflowDocument(
+ version = 1,
+ id = "daily",
+ name = "Daily",
+ actions = listOf(
+ WorkflowAction("if", ActionType.IF, mapOf("value" to "a", "equals" to "a"), children = listOf(WorkflowAction("then", ActionType.STOP)))
+ )
+ )
+ val restored = WorkflowCodec.decode(WorkflowCodec.encode(original))
+ assertEquals(original, restored)
+ }
+}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index b8cd75e..ba00b2d 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -8,6 +8,7 @@ lifecycleRuntimeKtx = "2.6.1"
activityCompose = "1.8.0"
kotlin = "2.2.10"
composeBom = "2026.02.01"
+playServicesLocation = "21.3.0"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -24,8 +25,8 @@ androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "u
androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
+play-services-location = { group = "com.google.android.gms", name = "play-services-location", version.ref = "playServicesLocation" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
-