diff --git a/api/api.d.ts b/api/api.d.ts index 7e584a6..d417c8e 100644 --- a/api/api.d.ts +++ b/api/api.d.ts @@ -111,3 +111,5 @@ declare function require(module: "http"): HTTPService; declare function require(module: "volume"): VolumeService; declare function require(module: "media"): MediaService; declare function require(module: "location"): LocationService; + +declare const params: Record; diff --git a/app/schemas/net.pipe01.pinepartner.data.AppDatabase/13.json b/app/schemas/net.pipe01.pinepartner.data.AppDatabase/13.json index 9e11f83..ea10392 100644 --- a/app/schemas/net.pipe01.pinepartner.data.AppDatabase/13.json +++ b/app/schemas/net.pipe01.pinepartner.data.AppDatabase/13.json @@ -2,7 +2,7 @@ "formatVersion": 1, "database": { "version": 13, - "identityHash": "7373061d995feb6177a83d4ddeb71134", + "identityHash": "18bb291464b45777c7b265b8330ec047", "entities": [ { "tableName": "Watch", @@ -59,7 +59,7 @@ }, { "tableName": "Plugin", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `description` TEXT, `author` TEXT, `sourceCode` TEXT NOT NULL, `checksum` TEXT NOT NULL, `permissions` TEXT NOT NULL, `downloadUrl` TEXT, `enabled` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `description` TEXT, `author` TEXT, `sourceCode` TEXT NOT NULL, `checksum` TEXT NOT NULL, `permissions` TEXT NOT NULL, `parameters` TEXT NOT NULL, `downloadUrl` TEXT, `enabled` INTEGER NOT NULL, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", @@ -103,6 +103,12 @@ "affinity": "TEXT", "notNull": true }, + { + "fieldPath": "parameters", + "columnName": "parameters", + "affinity": "TEXT", + "notNull": true + }, { "fieldPath": "downloadUrl", "columnName": "downloadUrl", @@ -129,7 +135,7 @@ "views": [], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '7373061d995feb6177a83d4ddeb71134')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '18bb291464b45777c7b265b8330ec047')" ] } } \ No newline at end of file diff --git a/app/schemas/net.pipe01.pinepartner.data.AppDatabase/14.json b/app/schemas/net.pipe01.pinepartner.data.AppDatabase/14.json new file mode 100644 index 0000000..76f3be5 --- /dev/null +++ b/app/schemas/net.pipe01.pinepartner.data.AppDatabase/14.json @@ -0,0 +1,186 @@ +{ + "formatVersion": 1, + "database": { + "version": 14, + "identityHash": "50f62b0e1b18edde27a001c6c8e5f0a5", + "entities": [ + { + "tableName": "Watch", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`address` TEXT NOT NULL, `name` TEXT NOT NULL, `autoConnect` INTEGER NOT NULL DEFAULT true, PRIMARY KEY(`address`))", + "fields": [ + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "autoConnect", + "columnName": "autoConnect", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "true" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "address" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "AllowedNotifApp", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`packageName` TEXT NOT NULL, PRIMARY KEY(`packageName`))", + "fields": [ + { + "fieldPath": "packageName", + "columnName": "packageName", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "packageName" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Plugin", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `description` TEXT, `author` TEXT, `sourceCode` TEXT NOT NULL, `checksum` TEXT NOT NULL, `permissions` TEXT NOT NULL, `parameters` TEXT NOT NULL, `downloadUrl` TEXT, `enabled` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sourceCode", + "columnName": "sourceCode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "checksum", + "columnName": "checksum", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "permissions", + "columnName": "permissions", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parameters", + "columnName": "parameters", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "downloadUrl", + "columnName": "downloadUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "ParameterValue", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`pluginId` TEXT NOT NULL, `paramName` TEXT NOT NULL, `value` TEXT NOT NULL, PRIMARY KEY(`pluginId`, `paramName`), FOREIGN KEY(`pluginId`) REFERENCES `Plugin`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "pluginId", + "columnName": "pluginId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paramName", + "columnName": "paramName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "pluginId", + "paramName" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "Plugin", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "pluginId" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '50f62b0e1b18edde27a001c6c8e5f0a5')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/net.pipe01.pinepartner.data.AppDatabase/15.json b/app/schemas/net.pipe01.pinepartner.data.AppDatabase/15.json new file mode 100644 index 0000000..97de491 --- /dev/null +++ b/app/schemas/net.pipe01.pinepartner.data.AppDatabase/15.json @@ -0,0 +1,186 @@ +{ + "formatVersion": 1, + "database": { + "version": 15, + "identityHash": "50f62b0e1b18edde27a001c6c8e5f0a5", + "entities": [ + { + "tableName": "Watch", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`address` TEXT NOT NULL, `name` TEXT NOT NULL, `autoConnect` INTEGER NOT NULL DEFAULT true, PRIMARY KEY(`address`))", + "fields": [ + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "autoConnect", + "columnName": "autoConnect", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "true" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "address" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "AllowedNotifApp", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`packageName` TEXT NOT NULL, PRIMARY KEY(`packageName`))", + "fields": [ + { + "fieldPath": "packageName", + "columnName": "packageName", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "packageName" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Plugin", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `description` TEXT, `author` TEXT, `sourceCode` TEXT NOT NULL, `checksum` TEXT NOT NULL, `permissions` TEXT NOT NULL, `parameters` TEXT NOT NULL, `downloadUrl` TEXT, `enabled` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sourceCode", + "columnName": "sourceCode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "checksum", + "columnName": "checksum", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "permissions", + "columnName": "permissions", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parameters", + "columnName": "parameters", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "downloadUrl", + "columnName": "downloadUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "ParameterValue", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`pluginId` TEXT NOT NULL, `paramName` TEXT NOT NULL, `value` TEXT NOT NULL, PRIMARY KEY(`pluginId`, `paramName`), FOREIGN KEY(`pluginId`) REFERENCES `Plugin`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "pluginId", + "columnName": "pluginId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paramName", + "columnName": "paramName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "pluginId", + "paramName" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "Plugin", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "pluginId" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '50f62b0e1b18edde27a001c6c8e5f0a5')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/net.pipe01.pinepartner.data.AppDatabase/16.json b/app/schemas/net.pipe01.pinepartner.data.AppDatabase/16.json new file mode 100644 index 0000000..c5e0447 --- /dev/null +++ b/app/schemas/net.pipe01.pinepartner.data.AppDatabase/16.json @@ -0,0 +1,174 @@ +{ + "formatVersion": 1, + "database": { + "version": 16, + "identityHash": "d0ad1d3ddd199234aad119fa18cfdc47", + "entities": [ + { + "tableName": "Watch", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`address` TEXT NOT NULL, `name` TEXT NOT NULL, `autoConnect` INTEGER NOT NULL DEFAULT true, PRIMARY KEY(`address`))", + "fields": [ + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "autoConnect", + "columnName": "autoConnect", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "true" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "address" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "AllowedNotifApp", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`packageName` TEXT NOT NULL, PRIMARY KEY(`packageName`))", + "fields": [ + { + "fieldPath": "packageName", + "columnName": "packageName", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "packageName" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Plugin", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `description` TEXT, `author` TEXT, `sourceCode` TEXT NOT NULL, `checksum` TEXT NOT NULL, `permissions` TEXT NOT NULL, `parameters` TEXT NOT NULL, `downloadUrl` TEXT, `enabled` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sourceCode", + "columnName": "sourceCode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "checksum", + "columnName": "checksum", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "permissions", + "columnName": "permissions", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parameters", + "columnName": "parameters", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "downloadUrl", + "columnName": "downloadUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "ParameterValue", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`pluginId` TEXT NOT NULL, `paramName` TEXT NOT NULL, `value` TEXT NOT NULL, PRIMARY KEY(`pluginId`, `paramName`))", + "fields": [ + { + "fieldPath": "pluginId", + "columnName": "pluginId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "paramName", + "columnName": "paramName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "pluginId", + "paramName" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'd0ad1d3ddd199234aad119fa18cfdc47')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/net/pipe01/pinepartner/data/AppDatabase.kt b/app/src/main/java/net/pipe01/pinepartner/data/AppDatabase.kt index 3329eb0..87b60c2 100644 --- a/app/src/main/java/net/pipe01/pinepartner/data/AppDatabase.kt +++ b/app/src/main/java/net/pipe01/pinepartner/data/AppDatabase.kt @@ -11,8 +11,9 @@ import androidx.room.TypeConverters Watch::class, AllowedNotifApp::class, Plugin::class, + ParameterValue::class, ], - version = 13, + version = 16, exportSchema = true, ) @TypeConverters(Converters::class) diff --git a/app/src/main/java/net/pipe01/pinepartner/data/Converters.kt b/app/src/main/java/net/pipe01/pinepartner/data/Converters.kt index f914df2..004c03f 100644 --- a/app/src/main/java/net/pipe01/pinepartner/data/Converters.kt +++ b/app/src/main/java/net/pipe01/pinepartner/data/Converters.kt @@ -1,6 +1,7 @@ package net.pipe01.pinepartner.data import androidx.room.TypeConverter +import net.pipe01.pinepartner.scripting.Parameter import net.pipe01.pinepartner.scripting.Permission class Converters { @@ -16,4 +17,17 @@ class Converters { else value.split(",").map { Permission.valueOf(it) }.toSet() } + + @TypeConverter + fun fromParameterList(value: List): String { + return value.joinToString("\n") { it.toString() } + } + + @TypeConverter + fun toParameterList(value: String): List { + return if (value.isBlank()) + emptyList() + else + value.split("\n").mapNotNull { Parameter.parse(it) } + } } \ No newline at end of file diff --git a/app/src/main/java/net/pipe01/pinepartner/data/Plugin.kt b/app/src/main/java/net/pipe01/pinepartner/data/Plugin.kt index a2bf4f8..bc57911 100644 --- a/app/src/main/java/net/pipe01/pinepartner/data/Plugin.kt +++ b/app/src/main/java/net/pipe01/pinepartner/data/Plugin.kt @@ -3,6 +3,7 @@ package net.pipe01.pinepartner.data import androidx.room.Entity import androidx.room.Ignore import androidx.room.PrimaryKey +import net.pipe01.pinepartner.scripting.Parameter import net.pipe01.pinepartner.scripting.Permission import net.pipe01.pinepartner.utils.md5 import java.util.Scanner @@ -18,6 +19,7 @@ data class Plugin @JvmOverloads constructor( val sourceCode: String, val checksum: String, val permissions: Set, + val parameters: List, val downloadUrl: String?, val enabled: Boolean, @Ignore val isBuiltIn: Boolean = false, @@ -28,7 +30,8 @@ data class Plugin @JvmOverloads constructor( var id: String? = null var description: String? = null var author: String? = null - var permissions = mutableSetOf() + val permissions = mutableSetOf() + val parameters = mutableListOf() var foundFooter = false @@ -73,6 +76,7 @@ data class Plugin @JvmOverloads constructor( "@description" -> description = value "@author" -> author = value "@permission" -> permissions.add(Permission.valueOf(value)) + "@param" -> Parameter.parse(value)?.let { parameters.add(it) } ?: throw PluginParseException("Invalid parameter: $value") else -> throw PluginParseException("Unknown plugin header key: $key") } } @@ -100,6 +104,7 @@ data class Plugin @JvmOverloads constructor( sourceCode = source, checksum = source.md5(), permissions = permissions, + parameters = parameters, enabled = false, downloadUrl = downloadUrl, isBuiltIn = isBuiltIn, diff --git a/app/src/main/java/net/pipe01/pinepartner/data/PluginDao.kt b/app/src/main/java/net/pipe01/pinepartner/data/PluginDao.kt index 6e4bf38..258a8f7 100644 --- a/app/src/main/java/net/pipe01/pinepartner/data/PluginDao.kt +++ b/app/src/main/java/net/pipe01/pinepartner/data/PluginDao.kt @@ -27,4 +27,13 @@ interface PluginDao { @Query("UPDATE Plugin SET enabled = :enabled WHERE id = :id") suspend fun setEnabled(id: String, enabled: Boolean) + + @Query("SELECT * FROM parametervalue WHERE pluginId = :id") + suspend fun getParameterValues(id: String): List? + + @Query("SELECT value FROM ParameterValue WHERE pluginId = :pluginId AND paramName = :paramName") + suspend fun getParameterValue(pluginId: String, paramName: String): String? + + @Query("INSERT INTO ParameterValue (pluginId, paramName, value) VALUES (:pluginId, :paramName, :value) ON CONFLICT(pluginId, paramName) DO UPDATE SET value = :value") + suspend fun setParameterValue(pluginId: String, paramName: String, value: String) } \ No newline at end of file diff --git a/app/src/main/java/net/pipe01/pinepartner/data/PluginParameter.kt b/app/src/main/java/net/pipe01/pinepartner/data/PluginParameter.kt new file mode 100644 index 0000000..b2da68a --- /dev/null +++ b/app/src/main/java/net/pipe01/pinepartner/data/PluginParameter.kt @@ -0,0 +1,10 @@ +package net.pipe01.pinepartner.data + +import androidx.room.Entity + +@Entity(primaryKeys = ["pluginId", "paramName"]) +data class ParameterValue( + val pluginId: String, + val paramName: String, + val value: String, +) diff --git a/app/src/main/java/net/pipe01/pinepartner/pages/plugins/ImportPluginPage.kt b/app/src/main/java/net/pipe01/pinepartner/pages/plugins/ImportPluginPage.kt index 0b4a2fa..a822452 100644 --- a/app/src/main/java/net/pipe01/pinepartner/pages/plugins/ImportPluginPage.kt +++ b/app/src/main/java/net/pipe01/pinepartner/pages/plugins/ImportPluginPage.kt @@ -222,6 +222,7 @@ private fun ImportStepPreview() { sourceCode = "", checksum = "", permissions = Permission.entries.toSet(), + parameters = emptyList(), downloadUrl = "", enabled = false, isBuiltIn = false, diff --git a/app/src/main/java/net/pipe01/pinepartner/pages/plugins/PluginPage.kt b/app/src/main/java/net/pipe01/pinepartner/pages/plugins/PluginPage.kt index 8675323..2834f3a 100644 --- a/app/src/main/java/net/pipe01/pinepartner/pages/plugins/PluginPage.kt +++ b/app/src/main/java/net/pipe01/pinepartner/pages/plugins/PluginPage.kt @@ -4,12 +4,19 @@ import android.widget.Toast import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Refresh import androidx.compose.material3.Button +import androidx.compose.material3.FilledIconButton +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch import androidx.compose.material3.Text +import androidx.compose.material3.TextField import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -18,6 +25,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Color @@ -33,10 +41,14 @@ import kotlinx.coroutines.launch import net.pipe01.pinepartner.components.LoadingStandIn import net.pipe01.pinepartner.data.Plugin import net.pipe01.pinepartner.data.PluginDao +import net.pipe01.pinepartner.scripting.BooleanType import net.pipe01.pinepartner.scripting.BuiltInPlugins import net.pipe01.pinepartner.scripting.EventSeverity +import net.pipe01.pinepartner.scripting.IntegerType import net.pipe01.pinepartner.scripting.LogEvent +import net.pipe01.pinepartner.scripting.Parameter import net.pipe01.pinepartner.scripting.Permission +import net.pipe01.pinepartner.scripting.StringType import net.pipe01.pinepartner.scripting.downloadPlugin import net.pipe01.pinepartner.service.BackgroundService import java.time.ZoneOffset @@ -53,10 +65,15 @@ fun PluginPage( val coroutineScope = rememberCoroutineScope() var plugin by remember { mutableStateOf(null) } + var paramValues by remember { mutableStateOf?>(null) } val events = remember { mutableStateListOf() } LaunchedEffect(id) { plugin = BuiltInPlugins.get(id) ?: pluginDao.getById(id) ?: throw IllegalArgumentException("Plugin not found") + paramValues = pluginDao + .getParameterValues(id) + ?.associateBy({ it.paramName }, { it.value }) + ?: emptyMap() while (true) { val resp = backgroundService.getPluginEvents(id, events.lastOrNull()?.time?.toEpochSecond(ZoneOffset.UTC) ?: 0) @@ -67,9 +84,10 @@ fun PluginPage( } } - LoadingStandIn(isLoading = plugin == null) { + LoadingStandIn(isLoading = plugin == null || paramValues == null) { Plugin( plugin = plugin!!, + paramValues = paramValues!!, events = events, onRemove = { coroutineScope.launch { @@ -99,6 +117,15 @@ fun PluginPage( } }, onViewCode = onViewCode, + onSetParameter = { name, value -> + val newParamValues = paramValues!!.toMutableMap() + newParamValues[name] = value + paramValues = newParamValues + + coroutineScope.launch { + pluginDao.setParameterValue(id, name, value) + } + } ) } } @@ -106,10 +133,12 @@ fun PluginPage( @Composable private fun Plugin( plugin: Plugin, + paramValues: Map = emptyMap(), events: List, onRemove: () -> Unit = { }, onUpdate: () -> Unit = { }, onViewCode: () -> Unit = { }, + onSetParameter: (name: String, value: String) -> Unit = { _, _ -> }, ) { Column( modifier = Modifier.padding(16.dp), @@ -164,6 +193,18 @@ private fun Plugin( } } + Property(name = "Parameters") { + for (param in plugin.parameters) { + val value = paramValues[param.name] ?: param.defaultValue ?: continue + + Parameter( + param = param, + value = value, + onSetValue = { onSetParameter(param.name, it) } + ) + } + } + Spacer(modifier = Modifier.height(24.dp)) Column { @@ -214,6 +255,51 @@ private fun Property(name: String, value: @Composable () -> Unit) { } } +@Composable +private fun Parameter(param: Parameter, value: String, onSetValue: (String) -> Unit) { + Row( + modifier = Modifier.defaultMinSize(minHeight = 48.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + modifier = Modifier.weight(40f), + text = param.name, + style = MaterialTheme.typography.titleMedium, + ) + + Row(modifier = Modifier.weight(60f)) { + if (param.defaultValue != null) { + FilledIconButton(onClick = { + onSetValue(param.defaultValue) + }) { + Icon(Icons.Filled.Refresh, contentDescription = "Reset to default") + } + } + + when (param.type) { + StringType -> TextField( + value = StringType.unmarshal(value), + onValueChange = { onSetValue(StringType.marshal(it)) }, + singleLine = true, + ) + + IntegerType -> TextField( + value = IntegerType.unmarshal(value).toString(), + onValueChange = { onSetValue(IntegerType.marshal(it.filter(Char::isDigit).toInt())) }, + singleLine = true, + ) + + BooleanType -> { + Switch( + checked = BooleanType.unmarshal(value), + onCheckedChange = { onSetValue(BooleanType.marshal(it)) }, + ) + } + } + } + } +} + @Preview(showBackground = true) @Composable fun PluginPreview() { @@ -226,6 +312,23 @@ fun PluginPreview() { sourceCode = "", checksum = "", permissions = Permission.entries.toSet(), + parameters = listOf( + Parameter( + name = "param1", + type = StringType, + defaultValue = "default", + ), + Parameter( + name = "param2", + type = IntegerType, + defaultValue = "123", + ), + Parameter( + name = "param3", + type = BooleanType, + defaultValue = "true", + ), + ), downloadUrl = "", enabled = true, isBuiltIn = false, @@ -245,6 +348,7 @@ fun PluginPreviewNoPermissions() { author = "author", sourceCode = "", permissions = emptySet(), + parameters = emptyList(), checksum = "", downloadUrl = "", enabled = true, @@ -265,6 +369,7 @@ fun PluginPreviewNoDescription() { author = "author", sourceCode = "", permissions = emptySet(), + parameters = emptyList(), checksum = "", downloadUrl = "", enabled = true, diff --git a/app/src/main/java/net/pipe01/pinepartner/pages/plugins/PluginsPage.kt b/app/src/main/java/net/pipe01/pinepartner/pages/plugins/PluginsPage.kt index 725a0ca..c7717ce 100644 --- a/app/src/main/java/net/pipe01/pinepartner/pages/plugins/PluginsPage.kt +++ b/app/src/main/java/net/pipe01/pinepartner/pages/plugins/PluginsPage.kt @@ -200,6 +200,7 @@ private fun PluginItemPreview() { sourceCode = "", checksum = "", permissions = emptySet(), + parameters = emptyList(), downloadUrl = null, enabled = i % 2 == 1, isBuiltIn = i % 2 == 0, diff --git a/app/src/main/java/net/pipe01/pinepartner/scripting/Parameter.kt b/app/src/main/java/net/pipe01/pinepartner/scripting/Parameter.kt new file mode 100644 index 0000000..a3d38db --- /dev/null +++ b/app/src/main/java/net/pipe01/pinepartner/scripting/Parameter.kt @@ -0,0 +1,75 @@ +package net.pipe01.pinepartner.scripting + +data class Parameter( + val name: String, + val type: ParameterType, + val defaultValue: String?, +) { + companion object { + fun parse(str: String): Parameter? { + val parts = str.split(" ") + + if (parts.size < 2 || parts.size > 3) { + return null + } + + val name = parts[0] + if (!name.matches(Regex("^[a-zA-Z0-9_]+$"))) { + return null + } + + return Parameter( + name = parts[0], + type = when (parts[1]) { + "string" -> StringType + "int" -> IntegerType + "boolean" -> BooleanType + else -> return null + }, + defaultValue = parts.getOrNull(2) + ) + } + } + + override fun toString(): String { + val typeName = when (type) { + StringType -> "string" + IntegerType -> "int" + BooleanType -> "bool" + else -> throw IllegalStateException("Unknown type") + } + + return "$name $typeName${if (defaultValue != null) " $defaultValue" else ""}" + } +} + +interface ParameterType { + fun validate(value: Any): Boolean + + fun marshal(value: Any): String + fun unmarshal(str: String): Any +} + +object StringType : ParameterType { + override fun validate(value: Any) = value is String + + override fun marshal(value: Any) = value as String + + override fun unmarshal(str: String) = str +} + +object IntegerType : ParameterType { + override fun validate(value: Any) = value is Int + + override fun marshal(value: Any) = value.toString() + + override fun unmarshal(str: String) = str.toInt() +} + +object BooleanType : ParameterType { + override fun validate(value: Any) = value is Boolean + + override fun marshal(value: Any) = value.toString() + + override fun unmarshal(str: String) = str.toBoolean() +} \ No newline at end of file diff --git a/app/src/main/java/net/pipe01/pinepartner/scripting/Runner.kt b/app/src/main/java/net/pipe01/pinepartner/scripting/Runner.kt index a7bbce0..701ed4f 100644 --- a/app/src/main/java/net/pipe01/pinepartner/scripting/Runner.kt +++ b/app/src/main/java/net/pipe01/pinepartner/scripting/Runner.kt @@ -17,6 +17,7 @@ import net.pipe01.pinepartner.scripting.api.HTTPService import net.pipe01.pinepartner.scripting.api.LocationService import net.pipe01.pinepartner.scripting.api.MediaService import net.pipe01.pinepartner.scripting.api.NotificationsService +import net.pipe01.pinepartner.scripting.api.Parameters import net.pipe01.pinepartner.scripting.api.Require import net.pipe01.pinepartner.scripting.api.VolumeService import net.pipe01.pinepartner.scripting.api.WatchesService @@ -31,8 +32,6 @@ import net.pipe01.pinepartner.service.DeviceManager import net.pipe01.pinepartner.service.NotificationsManager import org.mozilla.javascript.Context import org.mozilla.javascript.ContextFactory -import org.mozilla.javascript.ErrorReporter -import org.mozilla.javascript.EvaluatorException import org.mozilla.javascript.NativeConsole import org.mozilla.javascript.ScriptableObject import java.time.LocalDateTime @@ -70,20 +69,6 @@ class Runner(val plugin: Plugin, deps: ScriptDependencies) { override fun contextCreated(cx: Context) { cx.optimizationLevel = -1 // Disable code generation because Android doesn't support it cx.languageVersion = Context.VERSION_ES6 - - cx.setErrorReporter(object : ErrorReporter { - override fun warning(p0: String?, p1: String?, p2: Int, p3: String?, p4: Int) { - TODO("Not yet implemented") - } - - override fun error(p0: String?, p1: String?, p2: Int, p3: String?, p4: Int) { - TODO("Not yet implemented") - } - - override fun runtimeError(p0: String?, p1: String?, p2: Int, p3: String?, p4: Int): EvaluatorException { - TODO("Not yet implemented") - } - }) } override fun contextReleased(cx: Context) { @@ -112,6 +97,7 @@ class Runner(val plugin: Plugin, deps: ScriptDependencies) { ScriptableObject.defineClass(scope, PlaybackStateAdapter::class.java) ScriptableObject.defineClass(scope, LocationAdapter::class.java) + ScriptableObject.putProperty(scope, "params", Parameters(scope, plugin.name, plugin.parameters, deps.db)) ScriptableObject.putProperty(scope, "require", Require(deps, plugin.permissions, contextFactory, dispatcher, ::addEvent)) NativeConsole.init(scope, true, ConsolePrinter(::addEvent)) diff --git a/app/src/main/java/net/pipe01/pinepartner/scripting/api/Parameters.kt b/app/src/main/java/net/pipe01/pinepartner/scripting/api/Parameters.kt new file mode 100644 index 0000000..88764c7 --- /dev/null +++ b/app/src/main/java/net/pipe01/pinepartner/scripting/api/Parameters.kt @@ -0,0 +1,80 @@ +package net.pipe01.pinepartner.scripting.api + +import kotlinx.coroutines.runBlocking +import net.pipe01.pinepartner.data.AppDatabase +import net.pipe01.pinepartner.scripting.Parameter +import org.mozilla.javascript.Context +import org.mozilla.javascript.ScriptRuntime +import org.mozilla.javascript.Scriptable +import org.mozilla.javascript.Undefined + +class Parameters( + private var parentScope: Scriptable?, + private val pluginName: String, + private val parameters: List, + private val db: AppDatabase, +) : Scriptable { + private fun throwReadOnly(): Nothing { + throw ScriptRuntime.throwError(Context.getCurrentContext(), this, "Parameters are read-only") + } + + override fun getClassName() = "Parameters" + + override fun get(p0: String?, p1: Scriptable?): Any { + val param = parameters.find { it.name == p0 } ?: return Undefined.instance + + return runBlocking { + val str = db.pluginDao().getParameterValue(pluginName, p0!!) + + if (str == null) { + if (param.defaultValue != null) + param.type.unmarshal(param.defaultValue) + else + Undefined.instance + } else { + param.type.unmarshal(str) + } + } + } + + override fun get(p0: Int, p1: Scriptable?): Any = Undefined.instance + + override fun has(p0: String?, p1: Scriptable?) = p0 != null && parameters.any { it.name == p0 } + + override fun has(p0: Int, p1: Scriptable?) = false + + override fun put(p0: String?, p1: Scriptable?, p2: Any?) = throwReadOnly() + + override fun put(p0: Int, p1: Scriptable?, p2: Any?) = throwReadOnly() + + override fun delete(p0: String?) = throwReadOnly() + + override fun delete(p0: Int) = throwReadOnly() + + override fun getPrototype(): Scriptable { + TODO("Not yet implemented") + } + + override fun setPrototype(p0: Scriptable?) { + TODO("Not yet implemented") + } + + override fun getParentScope() = parentScope + + override fun setParentScope(p0: Scriptable?) { + parentScope = p0 + } + + override fun getIds(): Array { + TODO("Not yet implemented") + } + + override fun getDefaultValue(p0: Class<*>?): Any { + TODO("Not yet implemented") + } + + override fun hasInstance(p0: Scriptable?): Boolean { + TODO("Not yet implemented") + } + +} \ No newline at end of file