Mobile client application
1. Introduction
The Sponge mobile client application is a Flutter application that provides a generic GUI to call remote Sponge actions. It can be run on both Android and iOS. It could be used as the one app to rule them all for Sponge services that publish actions via the Sponge REST API. All business logic has to be placed in Sponge actions, so the only requirement to have the application working is to create your own Sponge service by writing a knowledge base that define actions that will be visible and ready to run in the application or to connect to an existing Sponge service.
The application is currently under development. It will be released as an open source. |
The application is especially useful when data and functionality are more important than visual aspects of a GUI, e.g. for prototyping, rapid application development, low cost solutions, etc. The reason is that the application provides only a generic and opinionated GUI whose customization is limited.
One of many use cases of the application is to connect to IoT devices that have Sponge installed and execute available actions. Different types of such devices provide different sets of actions that are specific to a device features. For example one device could have a camera and provide an action to take a picture. Another device could have a temperature sensor and provide its readings or have a machine learning model and provide predictions.
The mobile application uses several Sponge concepts such as actions and action arguments metadata, data types metadata, action and data type features, annotated values, provided action arguments, categories and events. It supports a convention over configuration paradigm.
You could build a customized GUI using your own Flutter code that will call the same Sponge actions. In that case the generic Sponge mobile client would be used as a Flutter library in your Flutter project.
2. Functionalities
The following chapters show the key functionalities of the mobile application.
2.1. Connections

You may configure many connections to Sponge REST API services. The application allows you to connect to a single service at the same time.

You may add, edit and remove connections to Sponge instances as well as activate a connection. To remove a connection swipe the corresponding element.

A Sponge address is the URL of the Sponge instance.
2.2. Action list

The main screen shows the list of actions defined in the connected Sponge engine. Only actions that have argument and result metadata are available. This is because the application uses a generic access to the actions utilizing their data types, labels, descriptions, features and so on. The number in the action icon is the number of action arguments.
To call an action or set action attributes tap the triangular icon on the right side of the action label.
The floating button allows to refresh the action list from the server. The refresh button clears all entered action arguments and received results.
The application currently doesn’t supports all Sponge data types. |
2.3. Navigation

The navigation drawer allows switching between the available main views.
2.4. Action call

Actions may have read only, provided arguments only to show a data from the server (see the Current LCD text
attribute). The REFRESH
button retrieves the current values of read only, provided arguments from the server.
class ManageLcd(Action):
def onConfigure(self):
self.withLabel("Manage the LCD text and color")
self.withDescription("Provides management of the LCD properties (display text and color). A null value doesn't change an LCD property.")
self.withArgs([
StringType("currentText").withMaxLength(256).withNullable(True).withFeatures({"maxLines":2})
.withLabel("Current LCD text").withDescription("The currently displayed LCD text.").withProvided(ProvidedMeta().withValue().withReadOnly()),
StringType("text").withMaxLength(256).withNullable(True).withFeatures({"maxLines":2})
.withLabel("Text to display").withDescription("The text that will be displayed in the LCD.").withProvided(ProvidedMeta().withValue()),
StringType("color").withMaxLength(6).withNullable(True).withFeatures({"characteristic":"color"})
.withLabel("LCD color").withDescription("The LCD color.").withProvided(ProvidedMeta().withValue().withOverwrite()),
BooleanType("clearText").withNullable(True).withDefaultValue(False)
.withLabel("Clear text").withDescription("The text the LCD will be cleared.")
]).withNoResult()
self.withFeature("icon", "monitor")
def onCall(self, currentText, text, color, clearText = None):
sponge.call("SetLcd", [text, color, clearText])
def onProvideArgs(self, context):
grovePiDevice = sponge.getVariable("grovePiDevice")
if "currentText" in context.provide:
context.provided["currentText"] = ProvidedValue().withValue(grovePiDevice.getLcdText())
if "text" in context.provide:
context.provided["text"] = ProvidedValue().withValue(grovePiDevice.getLcdText())
if "color" in context.provide:
context.provided["color"] = ProvidedValue().withValue(grovePiDevice.getLcdColor())
class SetLcd(Action):
def onCall(self, text, color, clearText = None):
sponge.getVariable("grovePiDevice").setLcd("" if (clearText or text is None) else text, color)

The action call screen allows editing the action arguments.
class ManageSensorActuatorValues(Action):
def onConfigure(self):
self.withLabel("Manage the sensor and actuator values").withDescription("Provides management of the sensor and actuator values.")
self.withArgs([
NumberType("temperatureSensor").withNullable().withLabel(u"Temperature sensor (°C)").withProvided(ProvidedMeta().withValue().withReadOnly()),
NumberType("humiditySensor").withNullable().withLabel(u"Humidity sensor (%)").withProvided(ProvidedMeta().withValue().withReadOnly()),
NumberType("lightSensor").withNullable().withLabel(u"Light sensor").withProvided(ProvidedMeta().withValue().withReadOnly()),
NumberType("rotarySensor").withNullable().withLabel(u"Rotary sensor").withProvided(ProvidedMeta().withValue().withReadOnly()),
NumberType("soundSensor").withNullable().withLabel(u"Sound sensor").withProvided(ProvidedMeta().withValue().withReadOnly()),
BooleanType("redLed").withLabel("Red LED").withProvided(ProvidedMeta().withValue().withOverwrite()),
IntegerType("blueLed").withMinValue(0).withMaxValue(255).withLabel("Blue LED").withProvided(ProvidedMeta().withValue().withOverwrite()),
BooleanType("buzzer").withLabel("Buzzer").withProvided(ProvidedMeta().withValue().withOverwrite())
]).withNoResult()
self.withFeature("icon", "thermometer")
def onCall(self, temperatureSensor, humiditySensor, lightSensor, rotarySensor, soundSensor, redLed, blueLed, buzzer):
grovePiDevice = sponge.getVariable("grovePiDevice")
grovePiDevice.setRedLed(redLed)
grovePiDevice.setBlueLed(blueLed)
grovePiDevice.setBuzzer(buzzer)
def onProvideArgs(self, context):
values = sponge.call("GetSensorActuatorValues", [context.provide])
for name, value in values.iteritems():
context.provided[name] = ProvidedValue().withValue(value)
class GetSensorActuatorValues(Action):
def onCall(self, names):
values = {}
grovePiDevice = sponge.getVariable("grovePiDevice")
if "temperatureSensor" or "humiditySensor" in names:
th = grovePiDevice.getTemperatureHumiditySensor()
if "temperatureSensor" in names:
values["temperatureSensor"] = th.temperature if th else None
if "humiditySensor" in names:
values["humiditySensor"] = th.humidity if th else None
if "lightSensor" in names:
values["lightSensor"] = grovePiDevice.getLightSensor()
if "rotarySensor" in names:
values["rotarySensor"] = grovePiDevice.getRotarySensor().factor
if "soundSensor" in names:
values["soundSensor"] = grovePiDevice.getSoundSensor()
if "redLed" in names:
values["redLed"] = grovePiDevice.getRedLed()
if "blueLed" in names:
values["blueLed"] = grovePiDevice.getBlueLed()
if "buzzer" in names:
values["buzzer"] = grovePiDevice.getBuzzer()
return values

Actions arguments may be edited in multiline text fields.
class SendSms(Action):
def onConfigure(self):
self.withLabel("Send an SMS").withDescription("Sends a new SMS.")
self.withArgs([
StringType("recipient").withFormat("phone").withLabel("Recipient").withDescription("The SMS recipient."),
StringType("message").withMaxLength(160).withFeatures({"maxLines":5}).withLabel("Message").withDescription("The SMS message.")
]).withNoResult()
self.withFeature("icon", "cellphone-text")
def onCall(self, recipient, message):
gsm.sendSms(recipient, message)

The color picker widget allows a user to choose a color as an argument value.
class ChooseColor(Action):
def onConfigure(self):
self.withLabel("Choose a color").withDescription("Shows a color argument.")
self.withArg(
StringType("color").withMaxLength(6).withNullable(True).withFeatures({"characteristic":"color"})
.withLabel("Color").withDescription("The color.")
).withResult(StringType())
self.withFeatures({"icon":"format-color-fill"})
def onCall(self, color):
return ("The chosen color is " + color) if color else "No color chosen"

The drawing panel allows a user to paint an image that will be set as an argument value in an action call.
class DigitsPredict(Action):
def onConfigure(self):
self.withLabel("Recognize a digit").withDescription("Recognizes a handwritten digit")
self.withArg(createImageType("image")).withResult(IntegerType().withLabel("Recognized digit"))
self.withFeature("icon", "brain")
def onCall(self, image):
predictions = py4j.facade.predict(image)
prediction = max(predictions, key=predictions.get)
probability = predictions[prediction]
# Handle the optional predictionThreshold Sponge variable.
predictionThreshold = sponge.getVariable("predictionThreshold", None)
if predictionThreshold and probability < float(predictionThreshold):
self.logger.debug("The prediction {} probability {} is lower than the threshold {}.", prediction, probability, predictionThreshold)
return None
else:
self.logger.debug("Prediction: {}, probability: {}", prediction, probability)
return int(prediction)
def imageClassifierServiceInit(py4jPlugin):
SpongeUtils.awaitUntil(lambda: py4jPlugin.facade.isReady())

The action call screen shows all action arguments.

If the action has been called, the result is shown below the action label. If the result can’t be fully shown in the action list, you may tap the result to see the details.

Drawing panels can be configured in a corresponding action definition, where a color, a background color etc. could be specified.
from java.lang import System
from os import listdir
from os.path import isfile, join, isdir
class DrawAndUploadDoodle(Action):
def onConfigure(self):
self.withLabel("Draw and upload a doodle").withDescription("Shows a canvas to draw a doodle and uploads it to the server")
self.withArg(
BinaryType("image").withLabel("Doodle").withMimeType("image/png")
.withFeatures({"characteristic":"drawing", "width":300, "height":250, "background":"FFFFFF", "color":"000000", "strokeWidth":2})
)
self.withResult(StringType().withLabel("Status"))
self.withFeatures({"icon":"brush"})
def onCall(self, image):
filename = str(System.currentTimeMillis()) + ".png"
SpongeUtils.writeByteArrayToFile(image, sponge.getProperty("doodlesDir") + "/" + filename)
return "Uploaded as " + filename
class ListDoodles(Action):
def onConfigure(self):
self.withLabel("List doodles").withDescription("Returns a list of doodle filenames").withFeatures({"visible":False})
self.withNoArgs().withResult(ListType(StringType()).withLabel("Doodles"))
def onCall(self):
dir = sponge.getProperty("doodlesDir")
doodles = [f for f in listdir(dir) if isfile(join(dir, f)) and f.endswith(".png")] if isdir(dir) else []
return sorted(doodles, reverse=True)
class ViewDoodle(Action):
def onConfigure(self):
self.withLabel("View a doodle").withDescription("Views a doodle")
self.withArg(StringType("image").withLabel("Doodle name").withProvided(ProvidedMeta().withValue().withValueSet().withOverwrite()))
self.withResult(BinaryType().withAnnotated().withMimeType("image/png").withLabel("Doodle image"))
self.withFeature("icon", "drawing")
def onCall(self, name):
return AnnotatedValue(SpongeUtils.readFileToByteArray(sponge.getProperty("doodlesDir") + "/" + name)).withFeatures({"filename":"doodle_" + name})
def onProvideArgs(self, context):
if "image" in context.provide:
doodles = sponge.call("ListDoodles")
context.provided["image"] = ProvidedValue().withValue(doodles[0] if doodles else None).withValueSet(doodles)
def onStartup():
sponge.logger.info(str(sponge.call("ListDoodles")))

The action call screen shows all action arguments, for example a drawing.

Action arguments may depend on each other. Argument dependencies are supported in the action call panel and allow creating simple, interactive forms where some arguments are provided by the server, some entered by the user, some read only and some depend on the values of others. The important thing is that all that configuration is defined in an action in a knowledge base placed on the server side, not in the mobile application.
class DependingArgumentsAction(Action):
def onConfigure(self):
self.withLabel("Action with depending arguments")
self.withArgs([
StringType("continent").withLabel("Continent").withProvided(ProvidedMeta().withValueSet()),
StringType("country").withLabel("Country").withProvided(ProvidedMeta().withValueSet().withDependency("continent")),
StringType("city").withLabel("City").withProvided(ProvidedMeta().withValueSet().withDependency("country")),
StringType("river").withLabel("River").withProvided(ProvidedMeta().withValueSet().withDependency("continent")),
StringType("weather").withLabel("Weather").withProvided(ProvidedMeta().withValueSet())
]).withResult(StringType().withLabel("Sentences"))
self.withFeature("icon", "flag")
def onCall(self, continent, country, city, river, weather):
return "There is a city {} in {} in {}. The river {} flows in {}. It's {}.".format(city, country, continent, river, continent, weather.lower())
def onProvideArgs(self, context):
if "continent" in context.provide:
context.provided["continent"] = ProvidedValue().withValueSet(["Africa", "Asia", "Europe"])
if "country" in context.provide:
continent = context.current["continent"]
if continent == "Africa":
countries = ["Nigeria", "Ethiopia", "Egypt"]
elif continent == "Asia":
countries = ["China", "India", "Indonesia"]
elif continent == "Europe":
countries = ["Russia", "Germany", "Turkey"]
else:
countries = []
context.provided["country"] = ProvidedValue().withValueSet(countries)
if "city" in context.provide:
country = context.current["country"]
if country == "Nigeria":
cities = ["Lagos", "Kano", "Ibadan"]
elif country == "Ethiopia":
cities = ["Addis Ababa", "Gondar", "Mek'ele"]
elif country == "Egypt":
cities = ["Cairo", "Alexandria", "Giza"]
elif country == "China":
cities = ["Guangzhou", "Shanghai", "Chongqing"]
elif country == "India":
cities = ["Mumbai", "Delhi", "Bangalore"]
elif country == "Indonesia":
cities = ["Jakarta", "Surabaya", "Medan"]
elif country == "Russia":
cities = ["Moscow", "Saint Petersburg", "Novosibirsk"]
elif country == "Germany":
cities = ["Berlin", "Hamburg", "Munich"]
elif country == "Turkey":
cities = ["Istanbul", "Ankara", "Izmir"]
else:
cities = []
context.provided["city"] = ProvidedValue().withValueSet(cities)
if "river" in context.provide:
continent = context.current["continent"]
if continent == "Africa":
rivers = ["Nile", "Chambeshi", "Niger"]
elif continent == "Asia":
rivers = ["Yangtze", "Yellow River", "Mekong"]
elif continent == "Europe":
rivers = ["Volga", "Danube", "Dnepr"]
else:
rivers = []
context.provided["river"] = ProvidedValue().withValueSet(rivers)
if "weather" in context.provide:
context.provided["weather"] = ProvidedValue().withValueSet(["Sunny", "Cloudy", "Raining", "Snowing"])

Allowed argument values can be defined in an action and provided from the server every time the action call screen is shown or an argument dependency value changes.
2.5. Action result

Actions may return contents that can be viewed for example as a HTML or a PDF file using the mobile OS viewers.
class HtmlFileOutput(Action):
def onConfigure(self):
self.withLabel("HTML file output").withDescription("Returns the HTML file.")
self.withNoArgs().withResult(BinaryType().withMimeType("text/html").withLabel("HTML file"))
self.withFeatures({"icon":"web"})
def onCall(self):
return String("""
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01//EN">
<html>
<head>
<title>HTML page</title>
</head>
<body>
<!-- Main content -->
<h1>Header</h1>
<p>Some text
</body>
</html>
""").getBytes("UTF-8")
class PdfFileOutput(Action):
def onConfigure(self):
self.withLabel("PDF file output").withDescription("Returns the PDF file.")
self.withNoArgs().withResult(BinaryType().withMimeType("application/pdf").withLabel("PDF file"))
self.withFeatures({"icon":"file-pdf"})
def onCall(self):
return sponge.process(ProcessConfiguration.builder("curl", "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf")
.outputAsBinary()).run().outputBinary

Actions may return a console output, for example the result of running the df -h
command on the server.
from org.openksavi.sponge.util.process import ProcessConfiguration
class OsGetDiskSpaceInfo(Action):
def onConfigure(self):
self.withLabel("Get disk space info").withDescription("Returns the disk space info.")
self.withNoArgs().withResult(StringType().withFormat("console").withLabel("Disk space info"))
self.withFeature("icon", "console")
def onCall(self):
return sponge.process(ProcessConfiguration.builder("df", "-h").outputAsString()).run().outputString
class OsDmesg(Action):
def onConfigure(self):
self.withLabel("Run dmesg").withDescription("Returns the dmesg output.")
self.withNoArgs().withResult(StringType().withFormat("console").withLabel("The dmesg output"))
self.withFeature("icon", "console")
def onCall(self):
return sponge.process(ProcessConfiguration.builder("dmesg").outputAsString()).run().outputString

Actions may return a Markdown formatted text.
2.6. Events
The application can subscribe to Sponge events. The subscription uses a Sponge gRPC service published on a default port (i.e. the REST API port plus 1
). A user must have priviliges to subscribe to events and to send events.
There are a few places where Sponge events are directly used in the application:
-
An event list screen (handles events subscribed globally for the application).
-
An action call screen for actions that have refresh events configured (handles events subscribed locally for an action).
2.6.1. Event subscription
Events can be subscribed globally for the application. Subscription management is performed by a subscription action, i.e. an action that has the intent
feature set to subscription
. Sponge provides a default subscription action GrpcApiManageSubscription
.
In case of a new event an operating system notification will be displayed.

2.6.2. Event list
The event list screen shows all events the application has subscribed for but only those that has been sent when the application is running.
By tapping on an event the user will be directed to a screen presenting an action associated with this event. This action is called an event handler action and is set up in the event type definition as a feature handlerAction
. The action is required to have an ObjectType
argument with object class RemoteEvent
. An event instance will be passed to that argument. After the action is called, the event is automatically dismissed from the GUI.
If there is no event handler action defined for a specific event type, a default event handler action will be shown. The default action is searched by its intent
feature which has to be defaultEventHandler
. Sponge provides a default event handler action GrpcApiViewEvent
.

from java.util.concurrent.atomic import AtomicLong
from org.openksavi.sponge.restapi.model import RemoteEvent
def onInit():
sponge.setVariable("notificationNo", AtomicLong(1))
def onBeforeLoad():
sponge.addType("Person", lambda: RecordType().withFields([
StringType("firstName").withLabel("First name"),
StringType("surname").withLabel("Surname")
]).withLabel("Person"))
sponge.addEventType("notification", RecordType().withFields([
StringType("source").withLabel("Source"),
IntegerType("severity").withLabel("Severity").withNullable(),
sponge.getType("Person", "person").withNullable()
]).withLabel("Notification"))
class NotificationSender(Trigger):
def onConfigure(self):
self.withEvent("notificationSender")
def onRun(self, event):
eventNo = str(sponge.getVariable("notificationNo").getAndIncrement())
sponge.event("notification").set({"source":"Sponge", "severity":10, "person":{"firstName":"James", "surname":"Joyce"}}).label(
"The notification " + eventNo).description("The new event " + eventNo + " notification").send()
def onStartup():
sponge.event("notificationSender").sendEvery(Duration.ofSeconds(10))

from org.openksavi.sponge.restapi.model import RemoteEvent
def onBeforeLoad():
sponge.addEventType("memo", RecordType().withFields([
StringType("message").withLabel("Message"),
]).withLabel("Memo").withFeature("handlerAction", "ViewMemoEvent"))
class ViewMemoEvent(Action):
def onConfigure(self):
self.withLabel("Memo").withDescription("Shows the memo event.")
self.withArgs([
ObjectType("event", RemoteEvent).withFeature("visible", False),
StringType("uppercaseMessage").withLabel("Upper case message").withProvided(
ProvidedMeta().withValue().withReadOnly().withDependency("event")),
])
self.withNoResult()
self.withFeatures({"visible":False, "callLabel":"Dismiss", "refreshLabel":None, "clearLabel":None, "cancelLabel":"Close"})
def onCall(self, event, uppercaseMessage):
pass
def onProvideArgs(self, context):
if "uppercaseMessage" in context.provide:
message = context.current["event"].attributes["message"]
context.provided["uppercaseMessage"] = ProvidedValue().withValue(message.upper() if message else "NO MESSAGE")

2.6.3. Action with refresh events
Every action can have refresh events configured. When such an action is shown in the GUI, the application will subscribe to refresh events. When such event arrives, the action arguments will be automatically refreshed. Event arguments are ignored.
class ViewCounter(Action):
def onConfigure(self):
self.withLabel("Counter").withDescription("Shows the counter.")
self.withArgs([
NumberType("counter").withLabel("Counter").withProvided(ProvidedMeta().withValue().withReadOnly()),
]).withCallable(False)
# This action when open in a GUI will subscribe to counterNotification events. When such event arrives, the action arguments
# will be automatically refreshed, so the counter argument will be read from the variable and provided to a GUI.
self.withFeatures({"callLabel":None, "refreshLabel":None, "clearLabel":None, "cancelLabel":"Close", "refreshEvents":["counterNotification"]})
def onProvideArgs(self, context):
if "counter" in context.provide:
context.provided["counter"] = ProvidedValue().withValue(sponge.getVariable("counter").get())

2.6.4. Sending events
Events can be sent by the application using an action. Sponge provides a default, generic action for sending events GrpcApiSendEvent
.

3. Advanced use cases
3.1. Context actions
Context actions can be specified for actions, record arguments and list elements (see the list-details) to provide related, customized sub-actions. Context actions should be specified as the contextActions
feature statically for a type or an action or dynamically for an annotated value. The latter option takes precedence.

class ActionWithContextActions(Action):
def onConfigure(self):
self.withLabel("Action with context actions").withArgs([
StringType("arg1").withLabel("Argument 1"),
StringType("arg2").withLabel("Argument 2")
]).withNoResult().withFeature("contextActions", [
"ActionWithContextActionsContextAction1", "ActionWithContextActionsContextAction2(arg2)", "ActionWithContextActionsContextAction3(arg2=arg2)",
"ActionWithContextActionsContextAction4(arg1)", "ActionWithContextActionsContextAction5", "MarkdownText()"
])
self.withFeature("icon", "attachment")
def onCall(self, arg1, arg2):
pass
class ActionWithContextActionsContextAction1(Action):
def onConfigure(self):
self.withLabel("Context action 1").withArgs([
RecordType("arg").withFields([
StringType("arg1").withLabel("Argument 1"),
StringType("arg2").withLabel("Argument 2")
])
]).withResult(StringType())
self.withFeatures({"visible":False, "icon":"tortoise"})
def onCall(self, arg):
return arg["arg1"]
class ActionWithContextActionsContextAction2(Action):
def onConfigure(self):
self.withLabel("Context action 2").withArgs([
StringType("arg").withLabel("Argument"),
StringType("additionalText").withLabel("Additional text"),
]).withResult(StringType())
self.withFeatures({"visible":False, "icon":"tortoise"})
def onCall(self, arg, additionalText):
return arg + " " + additionalText
class ActionWithContextActionsContextAction3(Action):
def onConfigure(self):
self.withLabel("Context action 3").withArgs([
StringType("arg1").withLabel("Argument 1"),
StringType("arg2").withLabel("Argument 2"),
StringType("additionalText").withLabel("Additional text"),
]).withResult(StringType())
self.withFeatures({"visible":False, "icon":"tortoise"})
def onCall(self, arg1, arg2, additionalText):
return arg1 + " " + arg2 + " " + additionalText
class ActionWithContextActionsContextAction4(Action):
def onConfigure(self):
self.withLabel("Context action 4").withArgs([
StringType("arg1NotVisible").withLabel("Argument 1 not visible").withFeatures({"visible":False}),
StringType("arg2").withLabel("Argument 2"),
]).withResult(StringType())
self.withFeatures({"visible":False, "icon":"tortoise"})
def onCall(self, arg1NotVisible, arg2):
return arg1NotVisible + " " + arg2
class ActionWithContextActionsContextAction5(Action):
def onConfigure(self):
self.withLabel("Context action 5").withArgs([
RecordType("arg").withFields([
StringType("arg1").withLabel("Argument 1"),
StringType("arg2").withLabel("Argument 2")
]).withFeatures({"visible":False}),
StringType("additionalText").withLabel("Additional text")
]).withResult(StringType())
self.withFeatures({"visible":False, "icon":"tortoise"})
def onCall(self, arg, additionalText):
return arg["arg1"] + " " + additionalText
Modes of passing arguments to a context action:
-
Default (e.g.
"ContextAction"
). In case of a context action for an action, all action arguments as a record (represented as a map) will be passed as the first argument of the context action. In case of a record argument or a list element, the record or the list element value will be passed as the first argument respectively. -
Explicit substitution (format
"ContextAction(targetArgName=sourceArgName,…)"
, e.g."ContextAction(contextArg1=arg1,contextArg2=arg2)"
). In case of a context action for an action, the argumentcontextArg1
of the context action will be set to a value of the argumentarg1
of the parent action and the argumentcontextArg2
of the context action will be set to a value of the argumentarg2
. -
Implicit substitution (format
"ContextAction(sourceArgName,…)"
, e.g."ContextAction(arg1)"
). In case of a context action for an action, the first argument of the context action will be set to a value of the argumentarg1
of the parent action. -
No arguments passed (e.g.
"ContextAction()"
).
The target argument must be have the same type as the source value.
A source argument name could be a path if a source value is a record, e.g. "ContextAction(contextArg1: arg1.field1)"
. The this
keyword can be used as the source argument name. In case of a context action for an action it is a record of all parent action arguments. In case of a record argument or a list element it is the value itself.
If a context action requires more arguments than passed from a parent action, a new action call screen will be shown. If a context value is an annotated value, the screen will show a header containing a label of the annotated value.
The context actions feature is not propagated in an annotated value feature to sub-actions.
3.2. List-details











from org.openksavi.sponge.util.process import ProcessConfiguration
def createBookRecordType(name):
""" Creates a book record type.
"""
return RecordType(name).withFields([
IntegerType("id").withLabel("ID").withNullable().withFeature("visible", False),
StringType("author").withLabel("Author"),
StringType("title").withLabel("Title")
])
class RecordLibraryForm(Action):
def onConfigure(self):
self.withLabel("Library (books as records)")
self.withArgs([
StringType("search").withNullable().withLabel("Search").withFeature("responsive", True),
StringType("order").withLabel("Sort by").withProvided(ProvidedMeta().withValue().withValueSet()),
ListType("books").withLabel("Books").withFeatures({
"createAction":"RecordCreateBook", "readAction":"RecordReadBook", "updateAction":"RecordUpdateBook", "deleteAction":"RecordDeleteBook",
# Provided with overwrite to allow GUI refresh.
}).withProvided(ProvidedMeta().withValue().withOverwrite().withDependencies(["search", "order"])).withElement(
createBookRecordType("book").withAnnotated()
)
]).withNoResult().withCallable(False)
self.withFeatures({
"refreshLabel":None, "clearLabel":None, "cancelLabel":None,
})
self.withFeature("icon", "library-books")
def onProvideArgs(self, context):
global LIBRARY
if "order" in context.provide:
context.provided["order"] = ProvidedValue().withValue("author").withAnnotatedValueSet([
AnnotatedValue("author").withLabel("Author"), AnnotatedValue("title").withLabel("Title")])
if "books" in context.provide:
context.provided["books"] = ProvidedValue().withValue(
# Context actions are provided dynamically in an annotated value.
map(lambda book: AnnotatedValue(book.toMap()).withLabel("{} - {}".format(book.author, book.title)).withFeature("contextActions", [
"RecordBookContextBinaryResult", "RecordBookContextNoResult", "RecordBookContextAdditionalArgs"]),
sorted(LIBRARY.findBooks(context.current["search"]), key = lambda book: book.author.lower() if context.current["order"] == "author" else book.title.lower())))
class RecordCreateBook(Action):
def onConfigure(self):
self.withLabel("Add a new book")
self.withArg(
createBookRecordType("book").withLabel("Book").withProvided(ProvidedMeta().withValue()).withFields([
# Overwrite the author field.
StringType("author").withLabel("Author").withProvided(ProvidedMeta().withValueSet(ValueSetMeta().withNotLimited())),
])
).withNoResult()
self.withFeatures({"visible":False, "callLabel":"Save", "clearLabel":None, "cancelLabel":"Cancel", "icon":"plus-box"})
def onCall(self, book):
global LIBRARY
LIBRARY.addBook(book["author"], book["title"])
def onProvideArgs(self, context):
global LIBRARY
if "book" in context.provide:
# Create an initial, blank instance of a book and provide it to GUI.
context.provided["book"] = ProvidedValue().withValue({})
if "book.author" in context.provide:
context.provided["book.author"] = ProvidedValue().withValueSet(LIBRARY.getAuthors())
class RecordReadBook(Action):
def onConfigure(self):
self.withLabel("View the book")
# Must set withOverwrite to replace with the current value.
self.withArg(createBookRecordType("book").withAnnotated().withLabel("Book").withProvided(
ProvidedMeta().withValue().withOverwrite().withDependency("book.id").withReadOnly()))
self.withNoResult().withCallable(False)
self.withFeatures({"visible":False, "clearLabel":None, "callLabel":None, "cancelLabel":"Close", "icon":"book-open"})
def onProvideArgs(self, context):
global LIBRARY
if "book" in context.provide:
context.provided["book"] = ProvidedValue().withValue(AnnotatedValue(LIBRARY.getBook(context.current["book.id"]).toMap()))
class RecordUpdateBook(Action):
def onConfigure(self):
self.withLabel("Modify the book")
self.withArg(
# Must set withOverwrite to replace with the current value.
createBookRecordType("book").withAnnotated().withLabel("Book").withProvided(
ProvidedMeta().withValue().withOverwrite().withDependency("book.id")).withFields([
StringType("author").withLabel("Author").withProvided(ProvidedMeta().withValueSet(ValueSetMeta().withNotLimited())),
])
).withNoResult()
self.withFeatures({"visible":False, "clearLabel":None, "callLabel":"Save", "cancelLabel":"Cancel", "icon":"square-edit-outline"})
def onCall(self, book):
global LIBRARY
LIBRARY.updateBook(book.value["id"], book.value["author"], book.value["title"])
def onProvideArgs(self, context):
global LIBRARY
if "book" in context.provide:
context.provided["book"] = ProvidedValue().withValue(AnnotatedValue(LIBRARY.getBook(context.current["book.id"]).toMap()))
if "book.author" in context.provide:
context.provided["book.author"] = ProvidedValue().withValueSet(LIBRARY.getAuthors())
class RecordDeleteBook(Action):
def onConfigure(self):
self.withLabel("Remove the book")
self.withArg(createBookRecordType("book").withAnnotated()).withNoResult()
self.withFeatures({"visible":False, "callLabel":"Save", "clearLabel":None, "cancelLabel":"Cancel", "icon":"delete", "confirmation":True})
def onCall(self, book):
global LIBRARY
self.logger.info("Deleting book id: {}", book.value["id"])
LIBRARY.removeBook(book.value["id"])
class RecordBookContextBinaryResult(Action):
def onConfigure(self):
self.withLabel("Text sample as PDF")
self.withArg(
createBookRecordType("book").withAnnotated().withFeature("visible", False)
).withResult(BinaryType().withAnnotated().withMimeType("application/pdf").withLabel("PDF"))
self.withFeatures({"visible":False, "icon":"file-pdf"})
def onCall(self, book):
return AnnotatedValue(sponge.process(ProcessConfiguration.builder("curl", "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf")
.outputAsBinary()).run().outputBinary)
class RecordBookContextNoResult(Action):
def onConfigure(self):
self.withLabel("Return the book")
self.withArg(
createBookRecordType("book").withAnnotated().withFeature("visible", False)
).withNoResult().withFeatures({"visible":False, "icon":"arrow-left-bold"})
def onCall(self, book):
pass
class RecordBookContextAdditionalArgs(Action):
def onConfigure(self):
self.withLabel("Add book comment")
self.withArgs([
createBookRecordType("book").withAnnotated().withFeature("visible", False),
StringType("comment").withLabel("Comment").withFeatures({"multiline":True, "maxLines":2})
]).withResult(StringType().withLabel("Added comment"))
self.withFeatures({"visible":False, "icon":"comment-outline"})
def onCall(self, book, message):
return message
The main action should have a list argument that is provided with the overwrite option. The action shouldn’t be callable. The list argument type can be annotated and the provided list elements may have labels (AnnotatedValue().withLabel()
) and descriptions. The list argument may have the following features: createAction
, readAction
, updateAction
, deleteAction
. Their values are the sub-action names that will be called to perform the CRUD operations.
There are two types of sub-actions: CRUD actions and context actions. CRUD actions implement create, read, update and delete operations. Context actions implement customized operations related to a list element.
The CRUD actions should not be visible in the actions list so they should have the visible
feature set to False
.
In the default scenario read, update and delete actions should have the first argument corresponding to the value of the list element. In most cases the argument visible
feature should be set to False
to hide it. Its type should be the same as the list element’s type. The value of the list element will be passed as this argument. In the case of a create action, no argument corresponding to any list element is necessary.
The result of a create, an update and a delete action is ignored and should be set to withNoResult
.
After calling a CRUD action the main action arguments are refreshed.
3.3. Interactive forms
Interactive forms provide live updates in a GUI and an instant modifications of a server state. They can be implemented by actions with provided and submittable arguments and action refresh events. Interactive forms can be used for example to manage IoT devices.
The following example shows a very simple MPD player implemented as a Sponge action. A change in an MPD server state generates a Sponge event. Such event is subscribed by the action in the mobile application and causes the action to refresh its arguments. On the other hand, a change made by a user in the GUI will cause such argument to be submitted to the server.
class MpdPlayer(Action):
def onConfigure(self):
self.withLabel("Player").withDescription("The MPD player.")
self.withArgs([
StringType("song").withLabel("Song").withFeatures({"multiline":True, "maxLines":2}).withProvided(
ProvidedMeta().withValue().withReadOnly()),
IntegerType("position").withLabel("Position").withMinValue(0).withMaxValue(100).withFeatures({"widget":"slider"}).withProvided(
ProvidedMeta().withValue().withOverwrite().withSubmittable()),
StringType("time").withLabel("Time").withNullable().withProvided(
ProvidedMeta().withValue().withReadOnly()),
IntegerType("volume").withLabel("Volume (%)").withAnnotated().withMinValue(0).withMaxValue(100).withFeatures({"widget":"slider"}).withProvided(
ProvidedMeta().withValue().withOverwrite().withSubmittable()),
VoidType("prev").withLabel("Previous").withProvided(ProvidedMeta().withSubmittable()),
BooleanType("play").withLabel("Play").withProvided(
ProvidedMeta().withValue().withOverwrite().withSubmittable()).withFeatures({"widget":"switch"}),
VoidType("next").withLabel("Next").withProvided(ProvidedMeta().withSubmittable())
]).withNoResult().withCallable(False)
self.withFeatures({"clearLabel":None, "cancelLabel":"Close", "refreshLabel":None, "refreshEvents":["statusPolling", "mpdNotification"],
"icon":"music"})
self.withFeature("contextActions", [
"MpdSetAndPlayPlaylist()", "ViewSongLyrics()", "ViewMpdStatus()",
])
def __ensureStatus(self, mpc, status):
return status if mpc.isStatusOk(status) else mpc.getStatus()
def onProvideArgs(self, context):
mpc = sponge.getVariable("mpc")
status = None
mpc.lock.lock()
try:
if "position" in context.submit:
status = mpc.seekByPercentage(context.current["position"])
if "volume" in context.submit:
status = mpc.setVolume(context.current["volume"].value)
if "play" in context.submit:
status = mpc.togglePlay(context.current["play"])
if "prev" in context.submit:
status = mpc.prev()
if "next" in context.submit:
status = mpc.next()
if "song" in context.provide:
context.provided["song"] = ProvidedValue().withValue(mpc.getCurrentSong())
if "position" in context.provide or "context" in context.submit:
status = self.__ensureStatus(mpc, status)
context.provided["position"] = ProvidedValue().withValue(mpc.getPositionByPercentage(status))
if "time" in context.provide:
status = self.__ensureStatus(mpc, status)
context.provided["time"] = ProvidedValue().withValue(mpc.getTimeStatus(status))
# Provide an annotated volume value at once if submitted.
if "volume" in context.provide or "volume" in context.submit:
status = self.__ensureStatus(mpc, status)
volume = mpc.getVolume(status)
context.provided["volume"] = ProvidedValue().withValue(AnnotatedValue(volume).withLabel("Volume (" + str(volume) + "%)"))
if "play" in context.provide:
status = self.__ensureStatus(mpc, status)
context.provided["play"] = ProvidedValue().withValue(mpc.getPlay(status))
finally:
mpc.lock.unlock()


4. User experience

The application may be switched to the dark theme in the settings.
5. Supported Sponge concepts
5.1. Data types
Type | Description |
---|---|
|
Not supported. |
|
Editing (as an action attribute) is supported only for |
|
Supported. |
|
Viewing supported. Editing is currently limited to the |
|
A limited support. This functionality is experimental. |
|
Supported. |
|
Editing is supported with limitations (see the Advanced use cases chapter). A unique list with a provided element value set is represented as a multichoice widget. Viewing is not supported. This functionality is experimental. |
|
Not supported. |
|
Supported. |
|
Not supported. |
|
Editing supported. Viewing support is limited to fields that have simple data types. This functionality is experimental. |
|
Not supported. |
|
Supported. |
|
Not supported. |
|
Supported. A |
5.2. Data type formats
Format | Description |
---|---|
|
A phone number format. Applicable for |
|
An email format. Applicable for |
|
A URL format. Applicable for |
|
A console format. Text is presented using a monospaced font. Applicable for |
|
A Markdown format. Applicable for |
5.3. Features
Feature | Applies to | Description |
---|---|---|
|
Action |
If |
|
Type |
If |
|
Action |
An action icon name. The supported set of icons is limited to the material design icons (currently v3.6.95). |
|
Widget |
A GUI widget type. See the table below for supported values. Support for this feature is limited. In most cases a default, opinionated widget will be used. |
|
Type |
A responsive GUI widget. If this feature is set for a provided type, every change in GUI will cause invoking a |
|
Action |
If |
|
Type |
A value of this feature indicates a special meaning of the type. |
|
|
A |
|
|
A |
|
|
A filename associated with a binary value. |
|
Action |
An action with an intent is handled by the application in a specific way. |
|
Action |
Should be set in an action that represents a user login in the user management functionality. See the user management example project. |
|
Action |
Should be set in an action that represents a user logout in the user management functionality. |
|
Action |
Should be set in an action that implements a user sign up in the user management functionality. |
|
Action |
Should be set in an action that manages event subscriptions. |
|
Action |
A default event handler action. |
|
Type |
A type with an intent is handled by the application in a specific way. |
|
Action argument type |
Indicates that the action argument represents a username. Applies only to actions that implement the user management functionality. This intent may be omitted if an action argument name is |
|
Action argument type |
Indicates that the action argument represents a password. Applies only to actions that implement the user management functionality. This intent may be omitted if an action argument name is |
|
Action argument type |
Indicates that the action argument represents event names to subscribe. Applies only to event subscription actions. |
|
Action argument type |
Indicates that the action argument represents a flag telling if to turn on or off an event subscribtion. Applies only to event subscription actions. |
|
Action |
Refresh event names for an action. |
|
Event type |
An event handler action name. |
|
|
If |
|
|
A maximum number of lines in the GUI. |
|
|
If |
|
Action |
An action call button label in the action call screen. Defaults to |
|
Action |
An action refresh button label in the action call screen. Defaults to |
|
Action |
An action clear button label in the action call screen. Defaults to |
|
Action |
An action cancel button label in the action call screen. Defaults to |
|
Action, |
Context actions. For more information on context actions and sub-actions see the Advanced use cases chapter. |
|
|
A create sub-action for a list element. |
|
|
A read sub-action for a list element. |
|
|
A update sub-action for a list element. |
|
|
A delete sub-action for a list element. |
|
|
An image width. |
|
|
An image height. |
|
|
A drawing stroke width. |
|
|
A drawing pen color. |
|
|
A drawing background color. |
Widget | Description |
---|---|
|
Supported for an |
|
Supported for a |
A data type property can be dynamically overwritten by a corresponding feature in an AnnotatedValue
. The feature name has to be exactly the same as the data type property name. The overwrite is handled by the Sponge mobile client application.
6. Included demos
The access to actions in the mobile application is generic. However the application may include demos that use a customized UI.
6.1. Handwritten digit recognition

If the current connection points to a Sponge instance that has the required action that performs a handwritten digit recognition, this demo is enabled in the navigation drawer.


The digit recognition demo screen allows drawing a digit that will be recognized by the Sponge action. After each stroke the remote action call is made and the result is shown in the circle.