From d02955e8b00e69612185d054d84c73063c6de075 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Milan=20Martin=20J=C3=A4ckel?= Date: Sat, 31 Oct 2026 17:43:07 +0100 Subject: [PATCH] first -working- stage --- app/build.gradle.kts | 1 + app/src/main/AndroidManifest.xml | 30 + .../example/nothingshortcuts/MainActivity.kt | 979 +++++++++++++++++- .../core/automation/Automation.kt | 19 + .../core/automation/AutomationReceiver.kt | 63 ++ .../core/automation/AutomationScheduler.kt | 103 ++ .../core/automation/GeofenceReceiver.kt | 41 + .../core/engine/ActionExecutor.kt | 19 + .../nothingshortcuts/core/engine/Workflow.kt | 65 ++ .../core/engine/WorkflowCodec.kt | 82 ++ .../core/engine/WorkflowEngine.kt | 97 ++ .../core/integration/AndroidActionExecutor.kt | 178 ++++ .../core/integration/SecretStore.kt | 44 + .../core/storage/WorkflowStore.kt | 109 ++ .../nothingshortcuts/ui/theme/Color.kt | 12 +- .../nothingshortcuts/ui/theme/Theme.kt | 21 +- .../example/nothingshortcuts/ui/theme/Type.kt | 30 +- .../widget/ShortcutsWidget.kt | 47 + app/src/main/res/layout/widget_shortcuts.xml | 23 + app/src/main/res/values/strings.xml | 1 + app/src/main/res/xml/widget_info.xml | 9 + .../nothingshortcuts/WorkflowEngineTest.kt | 80 ++ gradle/libs.versions.toml | 3 +- 23 files changed, 2023 insertions(+), 33 deletions(-) create mode 100644 app/src/main/java/com/example/nothingshortcuts/core/automation/Automation.kt create mode 100644 app/src/main/java/com/example/nothingshortcuts/core/automation/AutomationReceiver.kt create mode 100644 app/src/main/java/com/example/nothingshortcuts/core/automation/AutomationScheduler.kt create mode 100644 app/src/main/java/com/example/nothingshortcuts/core/automation/GeofenceReceiver.kt create mode 100644 app/src/main/java/com/example/nothingshortcuts/core/engine/ActionExecutor.kt create mode 100644 app/src/main/java/com/example/nothingshortcuts/core/engine/Workflow.kt create mode 100644 app/src/main/java/com/example/nothingshortcuts/core/engine/WorkflowCodec.kt create mode 100644 app/src/main/java/com/example/nothingshortcuts/core/engine/WorkflowEngine.kt create mode 100644 app/src/main/java/com/example/nothingshortcuts/core/integration/AndroidActionExecutor.kt create mode 100644 app/src/main/java/com/example/nothingshortcuts/core/integration/SecretStore.kt create mode 100644 app/src/main/java/com/example/nothingshortcuts/core/storage/WorkflowStore.kt create mode 100644 app/src/main/java/com/example/nothingshortcuts/widget/ShortcutsWidget.kt create mode 100644 app/src/main/res/layout/widget_shortcuts.xml create mode 100644 app/src/main/res/xml/widget_info.xml create mode 100644 app/src/test/java/com/example/nothingshortcuts/WorkflowEngineTest.kt 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" } -