first -working- stage
This commit is contained in:
parent
ff0c853f4d
commit
d02955e8b0
@ -40,6 +40,7 @@ dependencies {
|
|||||||
implementation(platform(libs.androidx.compose.bom))
|
implementation(platform(libs.androidx.compose.bom))
|
||||||
implementation(libs.androidx.activity.compose)
|
implementation(libs.androidx.activity.compose)
|
||||||
implementation(libs.androidx.compose.material3)
|
implementation(libs.androidx.compose.material3)
|
||||||
|
implementation(libs.play.services.location)
|
||||||
implementation(libs.androidx.compose.ui)
|
implementation(libs.androidx.compose.ui)
|
||||||
implementation(libs.androidx.compose.ui.graphics)
|
implementation(libs.androidx.compose.ui.graphics)
|
||||||
implementation(libs.androidx.compose.ui.tooling.preview)
|
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||||
|
|||||||
@ -2,6 +2,13 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:tools="http://schemas.android.com/tools">
|
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
|
<application
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
@ -23,6 +30,29 @@
|
|||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</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>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
File diff suppressed because it is too large
Load Diff
@ -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
|
||||||
|
)
|
||||||
@ -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"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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('_', ' '))
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
)
|
||||||
@ -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()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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() }
|
||||||
|
}
|
||||||
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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:"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,10 +2,10 @@ package com.example.nothingshortcuts.ui.theme
|
|||||||
|
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
val Purple80 = Color(0xFFD0BCFF)
|
val Purple80 = Color(0xFFE2E2E2)
|
||||||
val PurpleGrey80 = Color(0xFFCCC2DC)
|
val PurpleGrey80 = Color(0xFFBDBDBD)
|
||||||
val Pink80 = Color(0xFFEFB8C8)
|
val Pink80 = Color(0xFFFF8A86)
|
||||||
|
|
||||||
val Purple40 = Color(0xFF6650a4)
|
val Purple40 = Color(0xFF262626)
|
||||||
val PurpleGrey40 = Color(0xFF625b71)
|
val PurpleGrey40 = Color(0xFF666666)
|
||||||
val Pink40 = Color(0xFF7D5260)
|
val Pink40 = Color(0xFFC62828)
|
||||||
@ -1,8 +1,6 @@
|
|||||||
package com.example.nothingshortcuts.ui.theme
|
package com.example.nothingshortcuts.ui.theme
|
||||||
|
|
||||||
import android.app.Activity
|
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.darkColorScheme
|
import androidx.compose.material3.darkColorScheme
|
||||||
import androidx.compose.material3.dynamicDarkColorScheme
|
import androidx.compose.material3.dynamicDarkColorScheme
|
||||||
@ -10,17 +8,24 @@ import androidx.compose.material3.dynamicLightColorScheme
|
|||||||
import androidx.compose.material3.lightColorScheme
|
import androidx.compose.material3.lightColorScheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
private val DarkColorScheme = darkColorScheme(
|
private val DarkColorScheme = darkColorScheme(
|
||||||
primary = Purple80,
|
primary = Pink80,
|
||||||
|
onPrimary = Color.Black,
|
||||||
secondary = PurpleGrey80,
|
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(
|
private val LightColorScheme = lightColorScheme(
|
||||||
primary = Purple40,
|
primary = Pink40,
|
||||||
secondary = PurpleGrey40,
|
secondary = PurpleGrey40,
|
||||||
tertiary = Pink40
|
tertiary = Pink40,
|
||||||
|
|
||||||
/* Other default colors to override
|
/* Other default colors to override
|
||||||
background = Color(0xFFFFFBFE),
|
background = Color(0xFFFFFBFE),
|
||||||
@ -35,9 +40,9 @@ private val LightColorScheme = lightColorScheme(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun NothingshortcutsTheme(
|
fun NothingshortcutsTheme(
|
||||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
darkTheme: Boolean = true,
|
||||||
// Dynamic color is available on Android 12+
|
// Dynamic color is available on Android 12+
|
||||||
dynamicColor: Boolean = true,
|
dynamicColor: Boolean = false,
|
||||||
content: @Composable () -> Unit
|
content: @Composable () -> Unit
|
||||||
) {
|
) {
|
||||||
val colorScheme = when {
|
val colorScheme = when {
|
||||||
|
|||||||
@ -8,12 +8,40 @@ import androidx.compose.ui.unit.sp
|
|||||||
|
|
||||||
// Set of Material typography styles to start with
|
// Set of Material typography styles to start with
|
||||||
val Typography = Typography(
|
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(
|
bodyLarge = TextStyle(
|
||||||
fontFamily = FontFamily.Default,
|
fontFamily = FontFamily.Default,
|
||||||
fontWeight = FontWeight.Normal,
|
fontWeight = FontWeight.Normal,
|
||||||
fontSize = 16.sp,
|
fontSize = 16.sp,
|
||||||
lineHeight = 24.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
|
/* Other default text styles to override
|
||||||
titleLarge = TextStyle(
|
titleLarge = TextStyle(
|
||||||
|
|||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
23
app/src/main/res/layout/widget_shortcuts.xml
Normal file
23
app/src/main/res/layout/widget_shortcuts.xml
Normal 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>
|
||||||
@ -1,3 +1,4 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">nothingshortcuts</string>
|
<string name="app_name">nothingshortcuts</string>
|
||||||
|
<string name="widget_description">Favourite shortcuts</string>
|
||||||
</resources>
|
</resources>
|
||||||
9
app/src/main/res/xml/widget_info.xml
Normal file
9
app/src/main/res/xml/widget_info.xml
Normal 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" />
|
||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -8,6 +8,7 @@ lifecycleRuntimeKtx = "2.6.1"
|
|||||||
activityCompose = "1.8.0"
|
activityCompose = "1.8.0"
|
||||||
kotlin = "2.2.10"
|
kotlin = "2.2.10"
|
||||||
composeBom = "2026.02.01"
|
composeBom = "2026.02.01"
|
||||||
|
playServicesLocation = "21.3.0"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
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-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
|
||||||
androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
|
androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
|
||||||
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
|
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]
|
[plugins]
|
||||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user