Sponge Remote mobile client application
- 1. Introduction
- 2. Getting started
- 3. Functionalities
- 4. Advanced use cases
- 5. Settings
- 6. User experience
- 7. Supported Sponge concepts
- 8. Customized applications
- 9. Third party Dart/Flutter software
- 10. Sponge Remote privacy policy
- 11. Application development
1. Introduction
Sponge Remote is an open-source mobile application that provides a generic user interface to call remote Sponge actions. It could be used as the one app to rule them all for Sponge services that publish actions via the Sponge Remote 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 app is made with Flutter. It can be run on both Android and iOS.
Sponge Remote is in alpha phase and supports only a limited set of Sponge features. |
The Sponge Remote 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.
Sponge Remote supports heterogeneous Sponge services in a sense that each of the services can provide actions from a different scope (for example one for the MPD, the second for managing IoT device sensors, the third for image recognition etc.). All of the services have the same high level interface, but each of them can provide different low level interface.
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 Sponge Remote 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 as the Sponge Remote, using the upcoming Sponge Flutter API library.
Unless noted otherwise in the release notes, the Sponge Remote and the Sponge Flutter API library are compatible with the Sponge service that has the same Remote API protocol version.
The Sponge Remote layout is optimized for smartphones.
The Sponge Flutter API library is in alpha phase and supports only a limited set of Sponge features. |
2. Getting started
To try some of Sponge features you can connect to the Demo Service. However, to use the full potential of Sponge and Sponge Remote you should run a Sponge application service in your local network.
For information on how to run a predefined Sponge service (e.g. on Raspberry Pi) in Docker see Applications.
For information on how to run your own Sponge service in Docker see Remote Sponge service template.
3. Functionalities
The following chapters show the key functionalities of the mobile application.
3.1. Navigation

The navigation drawer allows switching between the main screens.
3.2. Connections

You may configure many connections to Sponge Remote 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.
The application can find nearby (i.e. located on the local network) Sponge services.
3.3. 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. |
3.4. Action call
When an action call screen is displayed, provided arguments will be fetched from the server. If an action call screen for the same action has been displayed earlier and a provided argument has been modified by a user, that argument will be fetched again only if it has the overwrite flag.
An action call screen can be closed by swiping right.

Actions may have read only, provided arguments to show data from the server (see the Current LCD text
attribute). The REFRESH
button retrieves the current values of such arguments manually.
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).withReadOnly().withFeatures({"maxLines":2})
.withLabel("Current LCD text").withDescription("The currently displayed LCD text.").withProvided(ProvidedMeta().withValue()),
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.withFeatures({"icon":"monitor", "showRefresh":True, "refreshEvents":["lcdChange"]})
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().withReadOnly().withLabel(u"Temperature sensor (°C)").withProvided(ProvidedMeta().withValue()),
NumberType("humiditySensor").withNullable().withReadOnly().withLabel(u"Humidity sensor (%)").withProvided(ProvidedMeta().withValue()),
NumberType("lightSensor").withNullable().withReadOnly().withLabel(u"Light sensor").withProvided(ProvidedMeta().withValue()),
NumberType("rotarySensor").withNullable().withReadOnly().withLabel(u"Rotary sensor").withProvided(ProvidedMeta().withValue()),
NumberType("soundSensor").withNullable().withReadOnly().withLabel(u"Sound sensor").withProvided(ProvidedMeta().withValue()),
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.withFeatures({"icon":"thermometer", "refreshEvents":["sensorChange"]})
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()).withFeatures({"icon":"format-color-fill", "showClear":True})
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):
if not sponge.getVariable("demo.readOnly", False):
filename = str(System.currentTimeMillis()) + ".png"
SpongeUtils.writeByteArrayToFile(image, sponge.getProperty("doodlesDir") + "/" + filename)
return "Uploaded as " + filename
else:
return "Uploading disabled in the read only mode"
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("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.withFeatures({"icon":"flag", "showClear":True, "showCancel":True})
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 onInit(self):
self.countries = {
"Africa":["Nigeria", "Ethiopia", "Egypt"],
"Asia":["China", "India", "Indonesia"],
"Europe":["Russia", "Germany", "Turkey"]
}
self.cities = {
"Nigeria":["Lagos", "Kano", "Ibadan"],
"Ethiopia":["Addis Ababa", "Gondar", "Mek'ele"],
"Egypt":["Cairo", "Alexandria", "Giza"],
"China":["Guangzhou", "Shanghai", "Chongqing"],
"India":["Mumbai", "Delhi", "Bangalore"],
"Indonesia":["Jakarta", "Surabaya", "Medan"],
"Russia":["Moscow", "Saint Petersburg", "Novosibirsk"],
"Germany":["Berlin", "Hamburg", "Munich"],
"Turkey":["Istanbul", "Ankara", "Izmir"]
}
self.rivers = {
"Africa":["Nile", "Chambeshi", "Niger"],
"Asia":["Yangtze", "Yellow River", "Mekong"],
"Europe":["Volga", "Danube", "Dnepr"]
}
def onProvideArgs(self, context):
if "continent" in context.provide:
context.provided["continent"] = ProvidedValue().withValueSet(["Africa", "Asia", "Europe"])
if "country" in context.provide:
context.provided["country"] = ProvidedValue().withValueSet(self.countries.get(context.current["continent"], []))
if "city" in context.provide:
context.provided["city"] = ProvidedValue().withValueSet(self.cities.get(context.current["country"], []))
if "river" in context.provide:
context.provided["river"] = ProvidedValue().withValueSet(self.rivers.get(context.current["continent"], []))
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.
3.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").withFeatures({"icon":"file-pdf"}))
self.withFeatures({"icon":"file-pdf"})
def onCall(self):
return sponge.process("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.
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("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("dmesg").outputAsString().run().outputString

Actions may return a Markdown formatted text.
3.6. Events
The Sponge Remote can subscribe to Sponge events. The subscription uses a Sponge gRPC service published on a default port (i.e. the Remote 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).

3.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.

3.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.remoteapi.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").withFeatures({"icon":"alarm-light"}))
class NotificationSender(Trigger):
def onConfigure(self):
self.withEvent("notificationSender")
def onRun(self, event):
notificationNo = sponge.getVariable("notificationNo").getAndIncrement()
eventNo = str(notificationNo)
sponge.event("notification").set({"source":"Sponge", "severity":10, "person":{"firstName":"James", "surname":"Joyce"}}).label(
"The notification " + eventNo).description("The new event " + eventNo + " notification").feature(
"icon", IconInfo().withName("alarm-light").withColor("FF0000" if (notificationNo % 2) == 0 else "00FF00")
).send()
def onStartup():
sponge.event("notificationSender").sendEvery(Duration.ofSeconds(10))

from org.openksavi.sponge.remoteapi.model import RemoteEvent
def onBeforeLoad():
sponge.addEventType("memo", RecordType().withFields([
StringType("message").withLabel("Message"),
]).withLabel("Memo").withFeatures({"handlerAction":"ViewMemoEvent", "icon":"note-text-outline"}))
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").withReadOnly().withProvided(
ProvidedMeta().withValue().withDependency("event")),
])
self.withNoResult()
self.withFeatures({"visible":False, "callLabel":"Dismiss", "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")

3.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").withReadOnly().withProvided(ProvidedMeta().withValue()),
]).withNonCallable()
# 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({"cancelLabel":"Close", "refreshEvents":["counterNotification"]})
def onProvideArgs(self, context):
if "counter" in context.provide:
context.provided["counter"] = ProvidedValue().withValue(sponge.getVariable("counter").get())

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

4. Advanced use cases
4.1. Sub-actions
Sub-actions provide references to actions associated with a parent entity (e.g. a parent action, a record). A sub-action is represented by the SubAction
class.
Sub-actions support simple argument and result substitutions.
Property | Description |
---|---|
|
A sub-action name. |
|
A sub-action label. |
|
A sub-action description. |
|
Sub-action argument substitutions. An argument substitution is represented by the |
|
A sub-action result substitution. A result substitution is represented by the |
|
Sub-action features. |
Property | Description |
---|---|
|
A target attribute name (i.e. an argument name of a sub-action). |
|
A source attribute (i.e. an argument name of a parent entity). |
|
Argument substitution features. |
Property | Description |
---|---|
|
A target attribute for the result (i.e. a nargument name of a parent entity). |
|
Result substitution features. |
Notation | Substitution | Description |
---|---|---|
|
argument source |
In case of a sub-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. |
|
result target |
It represents all action arguments or a whole record value or a list element. If a sub-action returns |
|
argument source |
A list element index. Applies only for lists. |
|
argument source |
A whole list for a list element sub-actions. Applies only for lists. |
|
result target |
A whole list for a list element sub-actions. Applies only for lists. |
|
argument source |
An action argument relative to the action arguments root, e.g. |
|
result target |
An action argument relative to the action arguments root, e.g. |
4.1.1. Argument substitution
A target argument must have the same type as a source value. A source argument name could be a path if a source value is a record, e.g. "arg1.field1"
.
If a sub-action has any visible arguments, a new action call screen will be shown.
The sub-action related features are not propagated to a sub-action in an annotated value feature.
Sub-actions can use globally saved (in a mobile application) action arguments but only if there is no argument substitutions (i.e. there are no arguments passed from the main action). It is necessary to avoid inconsistency.
4.1.2. Result substitution
A sub-action result can be assigned to a parent action argument.
4.2. Context actions
Context actions are sub-actions that can be specified for an action, a record and a list element (see the list-details). 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", [
SubAction("ActionWithContextActionsContextAction1").withArg("arg", "@this"),
SubAction("ActionWithContextActionsContextAction2").withArg("arg", "arg2"),
SubAction("ActionWithContextActionsContextAction3").withArg("arg2", "arg2"),
SubAction("ActionWithContextActionsContextAction4").withArg("arg1NotVisible", "arg1"),
SubAction("ActionWithContextActionsContextAction5").withArg("arg", "@this"),
SubAction("ActionWithContextActionsContextAction6").withArg("arg", "@this"),
SubAction("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")
]).withFeature("visible", False)
]).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
class ActionWithContextActionsContextAction6(Action):
def onConfigure(self):
self.withLabel("Context action 6").withArgs([
RecordType("arg").withFields([
StringType("arg1").withLabel("Argument 1"),
StringType("arg2").withLabel("Argument 2")
])
]).withNoResult()
self.withFeatures({"visible":False, "icon":"tortoise"})
def onCall(self, arg):
pass
4.2.1. Argument substitution
If a context value is an annotated value, the screen will show a header containing a label of the annotated value.
4.2.2. Active or inactive context actions
Before showing a list of context actions the application checks (by connecting to the server) if the context actions are active. Inactive actions will be greyed out.
4.3. List-details










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"),
StringType("cover").withNullable().withReadOnly().withFeatures({"characteristic":"networkImage"}),
])
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").withElement(createBookRecordType("book").withAnnotated()).withFeatures({
"createAction":SubAction("RecordCreateBook"),
"readAction":SubAction("RecordReadBook").withArg("book", "@this"),
"updateAction":SubAction("RecordUpdateBook").withArg("book", "@this"),
"deleteAction":SubAction("RecordDeleteBook").withArg("book", "@this"),
"refreshable":True,
# Provided with overwrite to allow GUI refresh.
}).withProvided(ProvidedMeta().withValue().withOverwrite().withDependencies(["search", "order"]))
]).withNonCallable().withFeature("icon", "library")
def onProvideArgs(self, context):
global LIBRARY
if "order" in context.provide:
context.provided["order"] = ProvidedValue().withValue("author").withAnnotatedValueSet([
AnnotatedValue("author").withValueLabel("Author"), AnnotatedValue("title").withValueLabel("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()).withValueLabel("{} - {}".format(book.author, book.title)).withFeatures({
"contextActions":[
SubAction("RecordBookContextBinaryResult").withArg("book", "@this"),
SubAction("RecordBookContextNoResult").withArg("book", "@this"),
SubAction("RecordBookContextAdditionalArgs").withArg("book", "@this")
],
"icon":(IconInfo().withUrl(book.cover) if book.cover else None)}),
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 and cover fields.
StringType("author").withLabel("Author").withProvided(ProvidedMeta().withValueSet(ValueSetMeta().withNotLimited())),
StringType("cover").withNullable().withFeatures({"visible":False}),
])
).withNoResult()
self.withFeatures({"visible":False, "callLabel":"Save", "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").withReadOnly().withProvided(
ProvidedMeta().withValue().withOverwrite().withDependency("book.id")))
self.withNonCallable().withFeatures({"visible":False, "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, "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"], book.value["cover"])
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().withFeature("visible", False)).withNoResult()
self.withFeatures({"visible":False, "callLabel":"Save", "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("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.
A create CRUD action has some limitations. It doesn’t support argument substitutions and checking if the create action is inactive.
4.4. Interactive form
An interactive form provides live update in a GUI and an instant modifications of a server state. It can be implemented by an action 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([
# The song info arguments.
StringType("song").withLabel("Song").withNullable().withReadOnly()
.withProvided(ProvidedMeta().withValue()),
StringType("album").withLabel("Album").withNullable().withReadOnly()
.withProvided(ProvidedMeta().withValue()),
StringType("date").withLabel("Date").withNullable().withReadOnly()
.withProvided(ProvidedMeta().withValue()),
# The position arguments.
IntegerType("position").withLabel("Position").withNullable().withAnnotated()
.withMinValue(0).withMaxValue(100)
.withFeatures({"widget":"slider", "group":"position"})
.withProvided(ProvidedMeta().withValue().withOverwrite().withSubmittable()),
StringType("time").withLabel("Time").withNullable().withReadOnly()
.withFeatures({"group":"position"}).withProvided(ProvidedMeta().withValue()),
# The navigation arguments.
VoidType("prev").withLabel("Previous").withAnnotated()
.withFeatures({"icon":IconInfo().withName("skip-previous").withSize(30),
"group":"navigation", "align":"center"})
.withProvided(ProvidedMeta().withValue().withOverwrite().withSubmittable()),
BooleanType("play").withLabel("Play").withAnnotated().withFeatures({"group":"navigation"})
.withProvided(ProvidedMeta().withValue().withOverwrite().withSubmittable().withLazyUpdate()),
VoidType("next").withLabel("Next").withAnnotated()
.withFeatures({"icon":IconInfo().withName("skip-next").withSize(30), "group":"navigation"})
.withProvided(ProvidedMeta().withValue().withOverwrite().withSubmittable()),
# The volume argument.
IntegerType("volume").withLabel("Volume").withAnnotated().withMinValue(0).withMaxValue(100)
.withFeatures({"widget":"slider"})
.withProvided(ProvidedMeta().withValue().withOverwrite().withSubmittable().withLazyUpdate()),
# The mode arguments.
BooleanType("repeat").withLabel("Repeat").withAnnotated()
.withFeatures({"group":"mode", "widget":"toggleButton", "icon":"repeat", "align":"right"})
.withProvided(ProvidedMeta().withValue().withOverwrite().withSubmittable().withLazyUpdate()),
BooleanType("single").withLabel("Single").withAnnotated()
.withFeatures({"group":"mode", "widget":"toggleButton", "icon":"numeric-1"})
.withProvided(ProvidedMeta().withValue().withOverwrite().withSubmittable().withLazyUpdate()),
BooleanType("random").withLabel("Random").withAnnotated()
.withFeatures({"group":"mode", "widget":"toggleButton", "icon":"shuffle"})
.withProvided(ProvidedMeta().withValue().withOverwrite().withSubmittable().withLazyUpdate()),
BooleanType("consume").withLabel("Consume").withAnnotated()
.withFeatures({"group":"mode", "widget":"toggleButton", "icon":"pac-man"})
.withProvided(ProvidedMeta().withValue().withOverwrite().withSubmittable().withLazyUpdate())
]).withNonCallable().withActivatable()
self.withFeatures({"refreshEvents":["statusPolling", "mpdNotification_.*"], "icon":"music", "contextActions":[
SubAction("MpdPlaylist"),
SubAction("MpdFindAndAddToPlaylist"),
SubAction("ViewSongInfo"),
SubAction("ViewSongLyrics"),
SubAction("MpdLibrary"),
SubAction("ViewMpdStatus"),
]})
def onIsActive(self, context):
return sponge.getVariable("mpc").isConnected()
def onProvideArgs(self, context):
"""This callback method:
a) Modifies the MPD state by using the argument values submitted by the user. The names
are specified in the context.submit set, the values are stored in the context.current map.
b) Sets the values of arguments that are to be provided to the client. The names are
specified in the context.provide set, the values are to be stored in the context.provided map.
"""
MpdPlayerProvideArgsRuntime(context).run()
class MpdPlayerProvideArgsRuntime:
def __init__(self, context):
self.context = context
# An instance of the Mpc class that is a wrapper for the mpc (MPD client)
# commandline calls.
self.mpc = sponge.getVariable("mpc")
# Stores and caches the latest MPD status as returned by the mpc command.
self.status = None
# Providers for the submittable arguments.
self.submitProviders = {
"position":lambda value: self.mpc.seekByPercentage(value),
"volume":lambda value: self.mpc.setVolume(value),
"play":lambda value: self.mpc.togglePlay(value),
"prev":lambda value: self.mpc.prev(),
"next":lambda value: self.mpc.next(),
"repeat":lambda value: self.mpc.setMode("repeat", value),
"single":lambda value: self.mpc.setMode("single", value),
"random":lambda value: self.mpc.setMode("random", value),
"consume":lambda value: self.mpc.setMode("consume", value),
}
# The MPD mode types (that correspond to the action arguments).
self.modeTypes = ["repeat", "single" , "random", "consume"]
def run(self):
# Enter the critical section.
self.mpc.lock.lock()
try:
# Pass the context as an argument to the private methods in order
# to simplify the code.
# Submit arguments sent from the client.
self.__submitArgs(self.context)
# Provide arguments to the client.
self.__provideInfoArgs(self.context)
self.__providePositionArgs(self.context)
self.__provideVolumeArg(self.context)
self.__provideNavigationArgs(self.context)
self.__provideModeArgs(self.context)
finally:
self.mpc.lock.unlock()
def __submitArgs(self, context):
"""Submits arguments sent from the client by changing the state of the MPD daemon.
"""
# Use simple providers to submit values.
for name in context.submit:
provider = self.submitProviders.get(name)
if provider:
try:
# Cache the current MPD status.
self.status = provider(
context.current[name].value if context.current[name] else None)
except:
sponge.logger.warn("Submit error: {}", sys.exc_info()[1])
def __getStatus(self):
# Cache the mpc command status text.
if not self.mpc.isStatusOk(self.status):
self.status = self.mpc.getStatus()
return self.status
def __provideInfoArgs(self, context):
"""Provides the song info arguments to the client.
"""
# Read the current song from the MPD daemon only if necessary.
currentSong = self.mpc.getCurrentSong() if any(
arg in context.provide for arg in ["song", "album" , "date"]) else None
if "song" in context.provide:
context.provided["song"] = ProvidedValue().withValue(
self.mpc.getSongLabel(currentSong) if currentSong else None)
if "album" in context.provide:
context.provided["album"] = ProvidedValue().withValue(
currentSong["album"] if currentSong else None)
if "date" in context.provide:
context.provided["date"] = ProvidedValue().withValue(
currentSong["date"] if currentSong else None)
def __providePositionArgs(self, context):
"""Provides the position arguments to the client.
"""
# If submitted, provide an updated annotated position too.
if "position" in context.provide or "context" in context.submit:
context.provided["position"] = ProvidedValue().withValue(
AnnotatedValue(self.mpc.getPositionByPercentage(self.__getStatus()))
.withFeature("enabled",
self.mpc.isStatusPlayingOrPaused(self.__getStatus())))
if "time" in context.provide:
context.provided["time"] = ProvidedValue().withValue(
self.mpc.getTimeStatus(self.__getStatus()))
def __provideVolumeArg(self, context):
"""Provides the volume argument to the client.
"""
# If submitted, provide an updated annotated volume too.
if "volume" in context.provide or "volume" in context.submit:
volume = self.mpc.getVolume(self.__getStatus())
context.provided["volume"] = ProvidedValue().withValue(
AnnotatedValue(volume)
.withTypeLabel("Volume" + ((" (" + str(volume) + "%)") if volume else "")))
def __provideNavigationArgs(self, context):
"""Provides the navigation arguments to the client.
"""
if "play" in context.provide:
playing = self.mpc.getPlay(self.__getStatus())
context.provided["play"] = ProvidedValue().withValue(
AnnotatedValue(playing).withFeature("icon",
IconInfo().withName("pause" if playing else "play").withSize(60)))
# Read the current playlist position and size from the MPD daemon only if necessary.
(position, size) = (None, None)
if "prev" in context.provide or "next" in context.provide:
(position, size) = self.mpc.getCurrentPlaylistPositionAndSize(self.__getStatus())
if "prev" in context.provide:
context.provided["prev"] = ProvidedValue().withValue(
AnnotatedValue(None).withFeature("enabled", position is not None))
if "next" in context.provide:
context.provided["next"] = ProvidedValue().withValue(
AnnotatedValue(None).withFeature("enabled",
position is not None and size is not None))
def __provideModeArgs(self, context):
"""Provides the mode arguments to the client.
"""
currentModes = None
# Provide only required modes (i.e. specified in the context.provide set).
for arg in [a for a in self.modeTypes if a in context.provide]:
if currentModes is None:
# Read the current modes from the MPD daemon only if necessary.
currentModes = self.mpc.getModes(self.__getStatus())
context.provided[arg] = ProvidedValue().withValue(
AnnotatedValue(currentModes.get(arg, False)))
4.5. Geographical map
ListType
elements can be shown on a geographical map if its list type has the geoMap
feature configured.
Each list element that has the geoPosition
feature will be displayed on the map. You can tap on an icon that represents an element to see the element label and its context actions.
If an action has only one argument which is a list with a geo map and the action has no buttons, the map will be displayed in a full action call screen. If a list with a geo map is an argument in an action that doesn’t meet that criteria, the action call screen will display only a button for this argument. If the button is tapped a map screen will be shown.
If a list element has the geoLayerName
feature that corresponds to a configured marker layer name, it will be assigned to that layer. If there is no marker layer configured, a default one with the null
name will be created.
The map functionality is experimental. |
class ActionWithGeoMap(Action):
def onConfigure(self):
self.withLabel("Geo map")
self.withArgs([
ListType("locations").withLabel("Locations").withAnnotated().withFeatures({
"geoMap":GeoMap().withCenter(GeoPosition(50.06143, 19.93658)).withZoom(15).withLayers([
# Use the same "group" feature to allow only one basemap to be visible at the same time.
# See the OpenStreetMap Tile Usage Policy at https://operations.osmfoundation.org/policies/tiles/
GeoTileLayer().withUrlTemplate("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png").withSubdomains(["a", "b", "c"])
.withLabel("OpenStreetMap")
.withFeatures({"visible":True, "attribution":u"© OpenStreetMap contributors", "group":"basemap"}),
# See the Google Maps Terms of Service at https://cloud.google.com/maps-platform/terms
GeoTileLayer().withUrlTemplate("https://mt0.google.com/vt/lyrs=y&hl=en&x={x}&y={y}&z={z}")
.withLabel("Google Hybrid")
.withFeatures({"visible":False, "attribution":u"Imagery ©2020 CNES/Airbus, MGGP Aero, Maxar Technologies, Map data ©2020 Google",
"group":"basemap", "opacity":0.9}),
GeoMarkerLayer("buildings").withLabel("Buildings").withFeature("icon", IconInfo().withName("home").withSize(50)),
GeoMarkerLayer("persons").withLabel("Persons")
]).withFeatures({"color":"FFFFFF"})
}).withProvided(
ProvidedMeta().withValue().withOverwrite()
).withElement(
StringType("location").withAnnotated()
)
]).withNonCallable().withFeatures({"icon":"map"})
def onProvideArgs(self, context):
if "locations" in context.provide:
locations = [
AnnotatedValue("building1").withValueLabel("Building (with actions)").withValueDescription("Description of building 1").withFeatures({
"geoPosition":GeoPosition(50.06043, 19.93558), "icon":IconInfo().withName("home").withColor("FF0000").withSize(50),
"geoLayerName":"buildings"}).withFeature(
"contextActions", [SubAction("ActionWithGeoMapViewLocation").withArg("location", "@this")]),
AnnotatedValue("building2").withValueLabel("Building (without actions)").withValueDescription("Description of building 2").withFeatures({
"geoPosition":GeoPosition(50.06253, 19.93768),
"geoLayerName":"buildings"}),
AnnotatedValue("person1").withValueLabel("Person 1 (without actions)").withValueDescription("Description of person 1").withFeatures({
"geoPosition":GeoPosition(50.06143, 19.93658), "icon":IconInfo().withName("face").withColor("000000").withSize(30),
"geoLayerName":"persons"}),
AnnotatedValue("person2").withValueLabel("Person 2 (without actions)").withValueDescription("Description of person 2").withFeatures({
"geoPosition":GeoPosition(50.06353, 19.93868), "icon":IconInfo().withName("face").withColor("0000FF").withSize(30),
"geoLayerName":"persons"})
]
context.provided["locations"] = ProvidedValue().withValue(AnnotatedValue(locations))
class ActionWithGeoMapViewLocation(Action):
def onConfigure(self):
self.withLabel("View the location")
# Must set withOverwrite to replace with the current value.
self.withArgs([
StringType("location").withAnnotated().withLabel("Location").withFeature("visible", False),
NumberType("label").withLabel("Label").withReadOnly().withProvided(
ProvidedMeta().withValue().withOverwrite().withDependency("location")),
NumberType("description").withLabel("Description").withReadOnly().withProvided(
ProvidedMeta().withValue().withOverwrite().withDependency("location")),
NumberType("latitude").withLabel("Latitude").withReadOnly().withProvided(
ProvidedMeta().withValue().withOverwrite().withDependency("location")),
NumberType("longitude").withLabel("Longitude").withReadOnly().withProvided(
ProvidedMeta().withValue().withOverwrite().withDependency("location")),
])
self.withNonCallable().withFeatures({"visible":False, "cancelLabel":"Close", "icon":"map-marker"})
def onProvideArgs(self, context):
if "label" in context.provide:
context.provided["label"] = ProvidedValue().withValue(context.current["location"].valueLabel)
if "description" in context.provide:
context.provided["description"] = ProvidedValue().withValue(context.current["location"].valueDescription)
if "latitude" in context.provide:
context.provided["latitude"] = ProvidedValue().withValue(context.current["location"].features["geoPosition"].latitude)
if "longitude" in context.provide:
context.provided["longitude"] = ProvidedValue().withValue(context.current["location"].features["geoPosition"].longitude)






4.6. Choose dialog
A choose dialog can be used to pick a value and pass it as a result to a parent action argument.
def createFruitWithColorRecordType(name = None):
return RecordType(name).withLabel("Fruit").withAnnotated().withFields([
StringType("name").withLabel("Name"),
StringType("color").withLabel("Color")
])
class FruitsWithColorsContextSetter(Action):
def onConfigure(self):
self.withLabel("Fruits with colors - context setter").withArgs([
StringType("header").withLabel("Header").withNullable(),
ListType("fruits").withLabel("Fruits").withElement(createFruitWithColorRecordType("fruit")).withDefaultValue([
AnnotatedValue({"name":"Orange", "color":"orange"}),
AnnotatedValue({"name":"Lemon", "color":"yellow"}),
AnnotatedValue({"name":"Apple", "color":"red"})]).withFeatures({
"updateAction":SubAction("FruitsWithColorsContextSetter_Update").withArg("fruit", "@this").withResult("@this"),
"contextActions":[
SubAction("FruitsWithColorsContextSetter_Choose").withLabel("Choose a new fruit").withArg("chosenFruit", "@this").withResult("@this"),
SubAction("FruitsWithColorsContextSetter_Index").withArg("indexArg", "@index"),
SubAction("FruitsWithColorsContextSetter_Parent").withArg("parentArg", "@parent").withResult("@parent"),
SubAction("FruitsWithColorsContextSetter_Root").withArg("header", "/header").withResult("/header"),
SubAction("FruitsWithColorsContextSetter_ActionRecord").withArg("record", "/").withResult("/"),
SubAction("FruitsWithColorsContextSetter_ActionRecordFull").withArg("/", "/").withResult("/"),
]})
]).withNonCallable()
class FruitsWithColorsContextSetter_Update(Action):
def onConfigure(self):
self.withLabel("Update a fruit").withArgs([
createFruitWithColorRecordType("fruit")
]).withResult(createFruitWithColorRecordType("fruit"))
self.withFeatures({"callLabel":"Save", "visible":False})
def onCall(self, fruit):
return fruit
class FruitsWithColorsContextSetter_Choose(Action):
def onConfigure(self):
self.withLabel("Choose a fruit").withDescription("Choose a fruit. The action icon has a custom color.").withArgs([
createFruitWithColorRecordType("chosenFruit").withNullable().withFeature("visible", False).withProvided(
ProvidedMeta().withValue().withOverwrite().withImplicitMode()),
ListType("fruits").withLabel("Fruits").withElement(
createFruitWithColorRecordType("fruit").withProvided(ProvidedMeta().withSubmittable())
).withProvided(
ProvidedMeta().withValue().withDependency("chosenFruit").withOptionalMode().withOverwrite()
).withFeatures({"activateAction":SubAction("@submit")})
]).withResult(createFruitWithColorRecordType())
self.withFeatures({"callLabel":"Choose", "icon":IconInfo().withName("palm-tree").withColor("00FF00"), "visible":True})
def onCall(self, chosenFruit, fruits):
if chosenFruit:
chosenFruit.valueLabel = None
return chosenFruit
def onProvideArgs(self, context):
chosenFruit = None
if "fruits.fruit" in context.submit:
chosenFruit = context.current["fruits.fruit"]
if "chosenFruit" in context.provide or "fruits.fruit" in context.submit:
context.provided["chosenFruit"] = ProvidedValue().withValue(chosenFruit)
if "fruits" in context.provide or "fruits.fruit" in context.submit:
# The context.initial check is to ensure that for the initial request the previously chosen fruit (if any) will be cleared.
# This behavior is only for the purpose of this example.
if chosenFruit is None and not context.initial:
chosenFruit = context.current["chosenFruit"]
chosenFruitName = chosenFruit.value["name"] if chosenFruit else None
context.provided["fruits"] = ProvidedValue().withValue([
AnnotatedValue({"name":"Kiwi", "color":"green"}).withValueLabel("Kiwi").withFeature("icon", "star" if chosenFruitName == "Kiwi" else None),
AnnotatedValue({"name":"Banana", "color":"yellow"}).withValueLabel("Banana").withFeature("icon", "star" if chosenFruitName == "Banana" else None)
])
context.provided["chosenFruit"] = ProvidedValue().withValue(chosenFruit)
class FruitsWithColorsContextSetter_Index(Action):
def onConfigure(self):
self.withLabel("Get list index").withArgs([
IntegerType("indexArg").withFeature("visible", False)
]).withResult(IntegerType().withLabel("Index"))
self.withFeatures({"visible":False})
def onCall(self, indexArg):
return indexArg
class FruitsWithColorsContextSetter_Parent(Action):
def onConfigure(self):
self.withLabel("Update a whole list").withArgs([
ListType("parentArg", createFruitWithColorRecordType("fruit")).withFeature("visible", False)
]).withResult(ListType().withElement(createFruitWithColorRecordType("fruit")))
self.withFeatures({"visible":False})
def onCall(self, parentArg):
if len(parentArg) < 4:
return parentArg + [AnnotatedValue({"name":"Strawberry", "color":"red"})]
else:
return parentArg[:-1]
class FruitsWithColorsContextSetter_Root(Action):
def onConfigure(self):
self.withLabel("Update a header").withArgs([
StringType("header").withLabel("Header").withNullable(),
]).withResult(StringType())
self.withFeatures({"visible":False})
def onCall(self, header):
return header
class FruitsWithColorsContextSetter_ActionRecord(Action):
def onConfigure(self):
self.withLabel("Action record").withArg(self.createActionRecordType("record")).withResult(self.createActionRecordType())
self.withFeatures({"visible":False})
def createActionRecordType(self, name = None):
return RecordType(name).withFields([
StringType("header").withLabel("Header").withNullable(),
ListType("fruits").withLabel("Fruits").withElement(createFruitWithColorRecordType("fruit"))
])
def onCall(self, record):
return record
class FruitsWithColorsContextSetter_ActionRecordFull(Action):
def onConfigure(self):
self.withLabel("Action record full").withArgs([
StringType("header").withLabel("Header").withNullable(),
ListType("fruits").withLabel("Fruits").withElement(createFruitWithColorRecordType("fruit"))
]).withResult(self.createActionRecordType())
self.withFeatures({"visible":False})
def createActionRecordType(self, name = None):
return RecordType(name).withFields([
StringType("header").withLabel("Header").withNullable(),
ListType("fruits").withLabel("Fruits").withElement(createFruitWithColorRecordType("fruit"))
])
def onCall(self, header, fruits):
return {"header":header, "fruits":fruits}


5. Settings

6. User experience

The application may be switched to the dark or the light theme in the settings.
7. Supported Sponge concepts
7.1. Data types
7.1.1. AnyType
Not supported.
7.1.2. BinaryType
Partially supported.
Editing (as an action attribute) is supported only for a image/png
mime type with the drawing
characteristic feature. Viewing is supported for image formats supported by Flutter and other binary content supported by the open_file
Flutter plugin that is used by the application.
Support for nullable values is limited.
7.1.3. BooleanType
Supported.
7.1.4. DateTimeType
Partially supported.
DATE_TIME_ZONE
and INSTANT
editing is not supported.
The default format for DATE
is "yyyy-dd-MM"
. A format for TIME
is required for the Sponge Remote API Dart client.
7.1.5. DynamicType
Partially supported. This functionality is considered experimental.
A new dynamic value can be created only on the server side, i.e. in an onProvideArgs
action callback method. A provided DynamicValue.type
name will be ignored because a name of a parent dynamic type is used in the application.
7.1.6. IntegerType
Supported.
7.1.7. ListType
Partially supported. This functionality is considered experimental.
A unique list with a provided element value set is represented as a multichoice widget. In most cases a complex list element should be annotated with a value label in order to be nicely displayed in the mobile GUI.
See the Advanced use cases chapter.
Support for nullable values is limited.
7.1.8. MapType
Partially supported. This functionality is considered experimental.
Editing is not supported.
7.1.9. NumberType
Supported.
7.1.10. ObjectType
Partially supported. This functionality is considered experimental.
Supported only if an object type defines a companion type that is supported.
7.1.11. RecordType
Supported. This functionality is considered experimental.
7.1.12. InputStreamType
Not supported.
7.1.13. OutputStreamType
Not supported.
7.1.14. StringType
Supported.
A StringType
value in a text field is always trimmed when the field is submitted and converted to null
if it is empty. So for example you can’t enter only white spaces in such text field if the type is not nullable.
7.1.15. TypeType
Not supported.
7.1.16. VoidType
Supported.
A chip
widget is presented as an editor. A user can tap the chip widget to submit an argument if it is configured as submittable.
7.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 |
7.3. Provided action arguments
7.3.1. Non-limited value sets
A non-limited value set is supported only for StringType
.
7.3.2. Submittable arguments with dependant arguments
If a provided argument is submittable
with the responsive
feature set to true
and has other arguments that depend on it, not only those dependant arguments have to point to that argument but also:
-
the submittable argument should have the
influences
property set to point to the dependant arguments, -
the dependant arguments should be provided with the
OPTIONAL
mode (withOptionalMode()
).
7.4. Features
Feature | Type | Applies to | Description |
---|---|---|---|
|
|
Action |
If |
|
|
Type |
If |
|
|
|
If |
|
|
Type |
If |
|
|
|
If |
|
|
Action |
An action icon. The icon feature converter supports an instance of |
|
|
Type |
A type icon. Currently supported only for list elements. |
|
|
|
A map marker layer icon. Shows a layer marker on a map as a specified icon. |
|
|
Type |
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 name of a group of action arguments or record fields. Grouped values will be placed in a compact GUI panel close to each other, if it is possible. |
|
|
|
Map layers that have the same |
|
|
Action argument type |
A name of other argument whose value will be used as a key (or a key fragment) of a GUI widget. For example it enables saving a scroll position in a list. This feature is experimental and is currently implemented only for pageable lists. |
|
|
Type |
A responsive GUI widget. If this feature is set for a provided type, every change in GUI will cause invoking the |
|
|
Action |
If |
|
|
Type |
A value of this feature indicates a special meaning of the type. |
|
|
|
A filename associated with a binary value. |
|
|
Action |
An action with an intent is handled by the application in a specific way. |
|
|
Type |
A type with an intent is handled by the application in a specific way. |
|
|
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 will be shown in the action call screen. Defaults to |
|
|
Action |
An action arguments refresh button will be shown in the action call screen. Defaults to |
|
|
Action |
An action arguments clear button will be shown in the action call screen. Defaults to |
|
|
Action |
A cancel button will be shown in the action call screen. Defaults to |
|
|
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. |
|
|
Action |
A flag indicating that the action argument values can be cached (i.e. preserved between this action call screens) in a client. Defaults to |
|
|
Action |
A flag indicating that argument values of the action that is used as a context action can be cached (i.e. preserved between this action call screens) in a client. Defaults to |
|
|
|
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 action that will be called on list element tap. If the action name has the special value |
|
|
|
An image width. |
|
|
|
An image height. |
|
number |
|
A drawing stroke width. |
|
|
|
A drawing background color. |
|
|
|
A drawing pen color. |
|
|
|
A map background color. |
|
|
|
A map tile layer opacity as a double value between 0 and 1. |
|
|
|
A flag indicating that a list will have its own scroll. This feature turns off the main scroll in an action screen. It is useful for long lists. This feature is only supported for main action arguments (i.e. not nested). Defaults to |
|
|
Type |
A flag that causes GUI to block until a value is submitted (a value of a submittable provided value that is sent from a client to a server). Defaults to |
|
|
|
Shows list elements in a geographical map. List elements should be of an annotated type and should contain geo position annotations. Supports XYZ tiling schemes as basemaps for list data. A |
|
|
|
A flag that informs if a tile layer service is TMS. Defaults to |
|
|
Type |
A GUI alignment for a widget. Supported values: |
Colors are represented by a hexadecimal value specified with RRGGBB
(Red, Green, Blue), e.g. "FF0000"
.
A number can be an integer or a floating point value.
7.4.1. Intents
Intent | Applies to | Description |
---|---|---|
|
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. User logout is equivalent to setting a connection to anonymous. |
|
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. |
|
Action |
Should be set in an action that reloads knowledge bases. Refreshes actions cached in a mobile application. |
|
Action |
Refreshes actions cached in a mobile application after an action call. |
|
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. |
7.4.2. Characteristic
Characteristic | Applies to | Description |
---|---|---|
|
|
A |
|
|
A |
|
|
A |
|
|
A |
7.4.3. Widgets
Widget | Description |
---|---|
|
Supported for an |
|
Supported for the |
|
Supported for the |
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 Remote.
7.4.4. List pagination
Feature | Type | Applies to | Description |
---|---|---|---|
|
|
|
If |
|
|
|
An offset of the first element of a page. |
|
|
|
A limit of a page, i.e. a maximum number of elements in a page. |
|
|
|
A count of all available elements of a list. This value is optional. |
|
|
|
An optional index of a single indicated element in a list. This feature is experimental and is implemented only for pageable lists with a limited functionality. If an element is indicated, the GUI will show a button enabling a user to jump to that element (but only if a page containing such element is already loaded from the server). Another limitation is that a jump resets a saved scroll position of a list. To use this feature in the application, you have to turn the application setting "Use scrollable indexed list" on. |
A pagination uses the provideActionArgs
Remote API method. The features offset
and limit
have to be set when invoking provideActionArgs
. A provided, annotated list must have the offset
and the limit
set and optionally the count
. A client code is responsible for setting offset
and limit
. An action onProvideArgs
callback method is not required to support offset
or limit
if they are not set in the request. A page size is established by a client code.
The pagination is supported only for primary (i.e. not nested) action arguments and only for read provided annotated lists.
The pagination works only forward, i.e. all previous list elements fetched from the server are stored in mobile device memory.
7.4.5. Geographical map
A geographical map feature is represented by the GeoMap
class that contains layers as instances of GeoLayer
:
-
GeoTileLayer
represents a tile layer, -
GeoWmsLayer
represents a WMS layer, -
GeoMarkerLayer
represents a marker layer,
A geographical position is represented by GeoPosition
.
Property | Type | Description |
---|---|---|
|
|
A map center. |
|
|
A map zoom. |
|
|
A map minimum zoom. |
|
|
A map maximum zoom. |
|
|
Map base layers. |
|
|
A Coordinate Reference System. |
|
|
Map features. |
Property | Type | Description |
---|---|---|
|
|
A layer name. |
|
|
A layer label. |
|
|
A layer description. |
|
|
Layer features. |
Property | Type | Description |
---|---|---|
|
|
A tile service URL template. |
|
|
An optional list of map server subdomains, e.g. |
|
|
Additional layer options. |
Property | Type | Description |
---|---|---|
|
|
A WMS base URL, e.g. |
|
|
WMS layer names. |
|
|
A Coordinate Reference System. |
|
|
An optional map image format, e.g. |
|
|
An optional WMS version, e.g. |
|
|
Optional WMS styles. |
|
|
An optional transparency flag. |
|
|
Optional other WMS request parameters. |
Property | Type | Description |
---|---|---|
|
|
Latitude. |
|
|
Longitude. |
Property | Type | Description |
---|---|---|
|
|
A CRS code, e.g. |
|
|
An optional projection definition string, e.g. |
|
|
Optional supported resolutions, e.g. |
8. Customized applications
Access to actions in the Sponge Remote is generic. However you could build a customized GUI using your own Flutter code using the upcoming Sponge Flutter API library.
8.1. Sponge Digits
The Sponge Digits mobile app is a demo of using a customized Flutter UI with the Sponge service to recognize handwritten digits.

The application allows drawing a digit that will be recognized by a Sponge action. After each stroke the remote action call will be made and the result will be shown in the circle.
9. Third party Dart/Flutter software
The Sponge Remote and the Sponge Flutter API library use third party software released under various open-source licenses. Besides the dependencies stated in the respective Flutter projects, the following components use modified versions of other open-source software:
-
AsyncPopupMenuButton
- a modified FlutterPopupMenuButton
widget that adds support for an asynchronous item builder. -
Painter
- a modified https://pub.dartlang.org/packages/painter2 library. It is used for drawings.
10. Sponge Remote privacy policy
The Sponge Remote privacy policy can be found here.
11. Application development
11.1. State management
The Sponge Flutter API library uses the MVP pattern mixed with BLoC and Provider.