first -working- stage
This commit is contained in:
parent
ff0c853f4d
commit
d02955e8b0
@ -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)
|
||||
|
||||
@ -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>
|
||||
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
|
||||
|
||||
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)
|
||||
@ -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 {
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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>
|
||||
<string name="app_name">nothingshortcuts</string>
|
||||
<string name="widget_description">Favourite shortcuts</string>
|
||||
</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"
|
||||
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" }
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user