first -working- stage

This commit is contained in:
Milan Martin Jäckel 2026-10-31 17:43:07 +01:00
parent ff0c853f4d
commit d02955e8b0
23 changed files with 2023 additions and 33 deletions

View File

@ -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)

View File

@ -2,6 +2,13 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_NOTIFICATION_POLICY" />
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
@ -23,6 +30,29 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<receiver
android:name=".core.automation.AutomationReceiver"
android:exported="false">
<intent-filter>
<action android:name="com.example.nothingshortcuts.RUN_AUTOMATION" />
<action android:name="android.intent.action.ACTION_POWER_CONNECTED" />
<action android:name="android.intent.action.ACTION_POWER_DISCONNECTED" />
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
<receiver
android:name=".core.automation.GeofenceReceiver"
android:exported="false" />
<receiver
android:name=".widget.ShortcutsWidget"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/widget_info" />
</receiver>
</application>
</manifest>

View File

@ -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
)

View File

@ -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"
)
)
}
}
}

View File

@ -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
}
}

View File

@ -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()
}
}

View File

@ -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('_', ' '))
}

View File

@ -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<String, String> = emptyMap(),
val children: List<WorkflowAction> = emptyList(),
val otherwise: List<WorkflowAction> = emptyList()
)
data class WorkflowDocument(
val version: Int = 1,
val id: String,
val name: String,
val actions: List<WorkflowAction>
)
class WorkflowVariables(initial: Map<String, String> = 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<String, String> = values.toMap()
}
data class ExecutionEvent(
val actionId: String,
val type: ActionType,
val message: String,
val output: String? = null
)
data class ExecutionResult(
val events: List<ExecutionEvent>,
val variables: Map<String, String>,
val stopped: Boolean = false
)

View File

@ -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<WorkflowAction>) {
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<WorkflowAction> = 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()))
}
}
}
}

View File

@ -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<String, String> = emptyMap(),
executor: ActionExecutor = NoOpActionExecutor
): ExecutionResult {
val variables = WorkflowVariables(initialVariables)
val events = mutableListOf<ExecutionEvent>()
val stopped = executeActions(workflow.actions, variables, events, executor)
return ExecutionResult(events, variables.snapshot(), stopped)
}
private fun executeActions(
actions: List<WorkflowAction>,
variables: WorkflowVariables,
events: MutableList<ExecutionEvent>,
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() }
}

View File

@ -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"
}
}

View File

@ -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:"
}
}

View File

@ -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<WorkflowDocument> =
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<ExecutionRecord> =
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<Automation> =
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
}
}

View File

@ -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)
val Purple40 = Color(0xFF262626)
val PurpleGrey40 = Color(0xFF666666)
val Pink40 = Color(0xFFC62828)

View File

@ -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 {

View File

@ -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(

View File

@ -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)
}
}
}

View File

@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/widget_root"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="16dp"
android:background="#171717">
<TextView
android:id="@+id/widget_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="#FFFFFF"
android:textSize="18sp"
android:textStyle="bold" />
<TextView
android:id="@+id/widget_items"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:textColor="#D0D0D0"
android:textSize="14sp" />
</LinearLayout>

View File

@ -1,3 +1,4 @@
<resources>
<string name="app_name">nothingshortcuts</string>
<string name="widget_description">Favourite shortcuts</string>
</resources>

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:description="@string/widget_description"
android:initialLayout="@layout/widget_shortcuts"
android:minWidth="180dp"
android:minHeight="100dp"
android:resizeMode="horizontal|vertical"
android:updatePeriodMillis="0"
android:widgetCategory="home_screen" />

View File

@ -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)
}
}

View File

@ -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" }