Sponge Remote mobile client application


Table of Contents

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.

mobile architecture
Figure 1. The mobile application architecture

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 versions 0.minor. are compatible only with the Sponge versions 1.minor., e.g. the 0.16.5 version of the Sponge Remote will be compatible with the 1.16.1 version of the Sponge engine.

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.

drawer
Figure 2. The navigation drawer

The navigation drawer allows switching between the main screens.

3.2. Connections

actions connection list
Figure 3. Selecting a connection to the Sponge instance in the action list screen

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

connections
Figure 4. List of connections to Sponge instances

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

connections edit
Figure 5. Editing a connection to a Sponge instance

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

actions
Figure 6. The 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.

action call manage lcd
Figure 7. The action call that manages the Raspberry Pi LCD display

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.

The definition of the action that manages the Raspberry Pi LCD display
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)
action call manage sensors
Figure 8. The action call that manages the Grove Pi sensors and actuators

The action call screen allows editing the action arguments.

The definition of the action that manages the Grove Pi sensors and actuators
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
action call send sms
Figure 9. The action call that sends an SMS from the Raspberry Pi

Actions arguments may be edited in multiline text fields.

The definition of the action that sends an SMS from the Raspberry Pi
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)
action call color
Figure 10. The action call argument editor for a color type

The color picker widget allows a user to choose a color as an argument value.

The definition of the action that takes a color argument
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"
action call digit drawing
Figure 11. The action call argument editor for a digit drawing

The drawing panel allows a user to paint an image that will be set as an argument value in an action call.

The definition of the action that recognizes a handwritten digit
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())
action call digit
Figure 12. The action call for an attribute of type drawing

The action call screen shows all action arguments.

action call digit result
Figure 13. The action call result for a digit recognition

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.

action call doodle drawing
Figure 14. The action call argument editor for a doodle drawing

Drawing panels can be configured in a corresponding action definition, where a color, a background color etc. could be specified.

The definitions of actions that represent drawing and viewing a doodle
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")))
action call doodle
Figure 15. The action call for a doodle drawing as an argument

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

action call arg depends
Figure 16. The action call that shows argument dependencies

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.

The definition of the action that provides arguments with dependencies
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 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"])
action call arg depends value set
Figure 17. The action call that shows argument dependencies and value sets

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 binary result
Figure 18. The action binary result

Actions may return contents that can be viewed for example as a HTML or a PDF file using the mobile OS viewers.

The definitions of the actions that return a HTML and a PDF file respectively
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 console result
Figure 19. The action console formatted result

Actions may return a console output, for example the result of running the df -h command on the server.

The definitions of the actions that returns OS commands output
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 markdown result
Figure 20. The action Markdown formatted result

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

actions events
Figure 21. Event related actions

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.

event subscription management
Figure 22. Event subscription management

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.

event list
Figure 23. Event list
Event type with a default event handler action
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))
event handler action notification
Figure 24. Default event handler action
Event type with a custom event handler action
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")
event handler action memo
Figure 25. Custom event handler action

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.

Action with refresh events
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())
action call counter
Figure 26. Action with refresh events

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.

event send
Figure 27. Action that sends events

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 argument and result substitutions.

Table 1. Sub-action properties
Property Description

name

A sub-action name.

label

A sub-action label.

description

A sub-action description.

List<SubActionArg> args

Sub-action argument substitutions. An argument substitution is represented by the SubActionArg class. It can be added using the following methods: withArgs(List<SubActionArg> args), withArg(SubActionArg arg) and withArg(String target, String source).

SubActionResult result

A sub-action result substitution. A result substitution is represented by the SubActionResult class. It can be added using the following methods: withResult(SubActionResult result) and withResult(String target).

Map<String, Object> features

Sub-action features.

Table 2. Argument substitution properties
Property Description

String target

A target attribute name (i.e. an argument name of a sub-action).

String source

A source attribute (i.e. an argument name of a parent entity).

Map<String, Object> features

Argument substitution features.

Table 3. Result substitution properties
Property Description

String target

A target attribute for the result (i.e. a nargument name of a parent entity).

Map<String, Object> features

Result substitution features.

Table 4. Substitution keywords
Keyword Substitution Description

@this

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.

@this

result target

It represents all action arguments or a whole record value or a list element. If a sub-action returns null and the result substitution is @this, the result will be ignored.

@index

argument source

A list element index. Applies only for lists.

@parent

argument source

A whole list for a list element sub-actions. Applies only for lists.

@parent

result target

A whole list for a list element sub-actions. Applies only for lists.

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.

action context actions
Figure 28. The action with context actions
The definition of the action with context actions
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

action form list actions list
Figure 29. The list-details action in the action list
action form list details main action
Figure 30. The list-details main action screen
action form list details subactions
Figure 31. The CRUD and context actions
action form list details subaction create
Figure 32. The create/add sub-action
action form list details subaction read
Figure 33. The read/view sub-action
action form list details subaction update
Figure 34. The update/modify sub-action
action form list details subaction delete
Figure 35. The delete/remove sub-action
action form list details subaction context binary
Figure 36. The context action returning a binary result
action form list details subaction context args
Figure 37. The context action with an additional argument
action form list details subaction context args result
Figure 38. The result of the context action with an additional argument
The definitions of actions implementing the 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.

Interactive form example
import os

class MpdPlayer(Action):
    def onConfigure(self):
        self.withLabel("Player").withDescription("The MPD player.")
        self.withArgs([
            StringType("song").withLabel("Song").withNullable().withReadOnly().withFeatures({"multiline":True, "maxLines":2}).withProvided(
                ProvidedMeta().withValue()),
            StringType("album").withLabel("Album").withNullable().withReadOnly().withFeatures({"multiline":True, "maxLines":2}).withProvided(
                ProvidedMeta().withValue()),
            StringType("date").withLabel("Date").withNullable().withReadOnly().withProvided(
                ProvidedMeta().withValue()),
            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()),


            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()),

            IntegerType("volume").withLabel("Volume").withAnnotated().withMinValue(0).withMaxValue(100).withFeatures({"widget":"slider"}).withProvided(
                ProvidedMeta().withValue().withOverwrite().withSubmittable().withLazyUpdate()),

            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":"mixer"}).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 __ensureStatus(self, mpc, status):
        return status if mpc.isStatusOk(status) else mpc.getStatus()

    def onProvideArgs(self, context):
        mpc = sponge.getVariable("mpc")
        status = None
        (position, size) = (None, None)

        mpc.lock.lock()
        try:
            try:
                if "position" in context.submit:
                    if context.current["position"]:
                        status = mpc.seekByPercentage(context.current["position"].value)
                if "volume" in context.submit:
                        status = mpc.setVolume(context.current["volume"].value)
                if "play" in context.submit:
                    status = mpc.togglePlay(context.current["play"].value)
                if "prev" in context.submit:
                    status = mpc.prev()
                if "next" in context.submit:
                    status = mpc.next()

                if "repeat" in context.submit:
                    status = mpc.setMode("repeat", context.current["repeat"].value)
                if "single" in context.submit:
                    status = mpc.setMode("single", context.current["single"].value)
                if "random" in context.submit:
                    status = mpc.setMode("random", context.current["random"].value)
                if "consume" in context.submit:
                    status = mpc.setMode("consume", context.current["consume"].value)
            except:
                sponge.logger.warn("Submit error: {}", sys.exc_info()[1])

            currentSong = None
            if "song" in context.provide or "date" in context.provide:
                currentSong = mpc.getCurrentSong()
            if "song" in context.provide:
                context.provided["song"] = ProvidedValue().withValue(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)
            if "position" in context.provide or "context" in context.submit:
                status = self.__ensureStatus(mpc, status)
                context.provided["position"] = ProvidedValue().withValue(AnnotatedValue(mpc.getPositionByPercentage(status)).withFeature(
                    "enabled", mpc.isStatusPlayingOrPaused(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).withTypeLabel(
                    "Volume" + ((" (" + str(volume) + "%)") if volume else "")))
            if "play" in context.provide:
                status = self.__ensureStatus(mpc, status)
                playing = mpc.getPlay(status)
                context.provided["play"] = ProvidedValue().withValue(AnnotatedValue(playing).withFeature("icon",
                        IconInfo().withName("pause" if playing else "play").withSize(60)))

            if "prev" in context.provide or "next" in context.provide:
                status = self.__ensureStatus(mpc, status)
                (position, size) = mpc.getCurrentPlaylistPositionAndSize(status)
            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))

            if "repeat" in context.provide or "single" in context.provide or "random" in context.provide or "consume" in context.provide:
                status = self.__ensureStatus(mpc, status)
                modes = mpc.getModes(status)

                if "repeat" in context.provide:
                    context.provided["repeat"] = ProvidedValue().withValue(AnnotatedValue(modes.get("repeat", False)))
                if "single" in context.provide:
                    context.provided["single"] = ProvidedValue().withValue(AnnotatedValue(modes.get("single", False)))
                if "random" in context.provide:
                    context.provided["random"] = ProvidedValue().withValue(AnnotatedValue(modes.get("random", False)))
                if "consume" in context.provide:
                    context.provided["consume"] = ProvidedValue().withValue(AnnotatedValue(modes.get("consume", False)))
        finally:
            mpc.lock.unlock()
action mpd player main
Figure 39. The MPD player action
action mpd player context actions
Figure 40. The context actions

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.
Geographical map example
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)
action call geo map overview
Figure 41. The map overview
action call geo map menu
Figure 42. The main map menu
action call geo map no clusters
Figure 43. The map with expanded clusters
action call geo map badges
Figure 44. The map with element badges
action call geo map context actions
Figure 45. The element context actions menu
action call geo map context action call
Figure 46. The element context action call

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.

Choose dialog example
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([
            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"),
                    ]})
        ]).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]
action call choose dialog parent
Figure 47. The choose dialog parent action
action call choose dialog
Figure 48. The choose dialog action

5. Settings

settings
Figure 49. The application settings

6. User experience

light theme
Figure 50. The application light theme

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

Not supported.

7.1.13. 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.14. TypeType

Not supported.

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

Table 5. Supported data type formats
Format Description

phone

A phone number format. Applicable for StringType.

email

An email format. Applicable for StringType.

url

A URL format. Applicable for StringType.

console

A console format. Text is presented using a monospaced font. Applicable for StringType.

markdown

A Markdown format. Applicable for StringType.

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

Table 6. Supported features
Feature Type Applies to Description

visible

boolean

Action

If True, an action will be visible in the main list of actions in the application. Defaults to true.

visible

boolean

Type

If True, an action argument or a record field is visible in the action call screen. Defaults to true.

visible

boolean

GeoLayer

If True, a map layer will be initially visible on a map. Defaults to true.

enabled

boolean

Type

If True, an action argument or a record field is enabled for editing. Defaults to true.

refreshable

boolean

ListType

If True, a provided list will have a button to refresh (i.e. provide arguments from the server). Defaults to false.

icon

IconInfo or String

Action

An action icon. The icon feature converter supports an instance of IconInfo or String (an icon name) as a value. The supported set of icon names is limited to the material design icons (currently v5.3.45).

icon

IconInfo or String

Type

A type icon. Currently supported only for list elements.

icon

IconInfo or String

GeoMarkerLayer

A map marker layer icon. Shows a layer marker on a map as a specified icon.

widget

String

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.

group

String

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.

group

String

GeoLayer

Map layers that have the same group will be grouped and only one of them will be visible at the same time.

key

String

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.

responsive

boolean

Type

A responsive GUI widget. If this feature is set for a provided type, every change in GUI will cause invoking the provideActionArgs Remote API method. This feature may be resource consuming (especially when used with an argument submit option) because of possible many server roundtrips.

confirmation

boolean

Action

If True then before calling the action a confirmation dialog will be shown. Defaults to false.

characteristic

String

Type

A value of this feature indicates a special meaning of the type.

filename

String

BinaryType

A filename associated with a binary value.

intent

String

Action

An action with an intent is handled by the application in a specific way.

intent

String

Type

A type with an intent is handled by the application in a specific way.

refreshEvents

List<String>

Action

Refresh event names for an action.

handlerAction

String

Event type

An event handler action name.

multiline

boolean

StringType

If True, a string will be multilined in the GUI. Defaults to false.

maxLines

int

StringType

A maximum number of lines in the GUI.

obscure

boolean

StringType

If True, a string will be obscured in the GUI, e.g. in case of passwords. Defaults to false.

showCall

boolean

Action

An action call button will be shown in the action call screen. Defaults to true.

showRefresh

boolean

Action

An action arguments refresh button will be shown in the action call screen. Defaults to false. The refresh button fetches provided action arguments from the server. Only arguments provided with read only or overwrite flags will be refreshed.

showClear

boolean

Action

An action arguments clear button will be shown in the action call screen. Defaults to false. The clear button resets the action argument values.

showCancel

boolean

Action

A cancel button will be shown in the action call screen. Defaults to false.

callLabel

String

Action

An action call button label in the action call screen. Defaults to RUN if the feature is not set. If set to None, the button will not be shown.

refreshLabel

String

Action

An action refresh button label in the action call screen. Defaults to REFRESH if the feature is not set. If set to None, the button will not be shown.

clearLabel

String

Action

An action clear button label in the action call screen. Defaults to CLEAR if the feature is not set. If set to None, the button will not be shown.

cancelLabel

String

Action

An action cancel button label in the action call screen. Defaults to CANCEL if the feature is not set. If set to None, the button will not be shown.

contextActions

List<SubAction>

Action, RecordType, ListType action argument

Context actions. For more information on context actions and sub-actions see the Advanced use cases chapter.

cacheableArgs

boolean

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

cacheableContextArgs

boolean

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 false. If set to true the cacheableArgs should be true as well.

createAction

SubAction

ListType action argument

A create sub-action for a list element.

readAction

SubAction

ListType action argument

A read sub-action for a list element.

updateAction

SubAction

ListType action argument

A update sub-action for a list element.

deleteAction

SubAction

ListType action argument

A delete sub-action for a list element.

activateAction

SubAction

ListType action argument

An action that will be called on list element tap. If the action name has the special value @submit, then instead of calling an action, a provideActionArgs will be invoked with the submitted list element.

width

int

BinaryType drawing

An image width.

height

int

BinaryType drawing

An image height.

strokeWidth

number

BinaryType drawing

A drawing stroke width.

background

String

BinaryType drawing

A drawing background color.

color

String

BinaryType drawing

A drawing pen color.

color

String

GeoMap

A map background color.

opacity

boolean

GeoTileLayer, GeoWmsLayer

A map tile layer opacity as a double value between 0 and 1.

scroll

boolean

ListType

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

submittableBlocking

boolean

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

geoMap

GeoMap

ListType

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 ListType that has this feature should not be pageable.

tms

boolean

GeoTileLayer

A flag that informs if a tile layer service is TMS. Defaults to false.

align

String

Type

A GUI alignment for a widget. Supported values: "left", "right", "center". Defaults to "left". If some of action arguments belong to the same group, the whole group will have the alignment like the first argument of this group.

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

Table 7. Supported values for the intent feature
Intent Applies to Description

login

Action

Should be set in an action that represents a user login in the user management functionality. See the user management example project.

logout

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.

signUp

Action

Should be set in an action that implements a user sign up in the user management functionality.

subscription

Action

Should be set in an action that manages event subscriptions.

defaultEventHandler

Action

A default event handler action.

reload

Action

Should be set in an action that reloads knowledge bases. Refreshes actions cached in a mobile application.

reset

Action

Refreshes actions cached in a mobile application after an action call.

username

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

password

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

eventNames

Action argument type

Indicates that the action argument represents event names to subscribe. Applies only to event subscription actions.

subscribe

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

Table 8. Supported values for the characteristic feature
Characteristic Applies to Description

drawing

BinaryType

A BinaryType that represents a drawing. Currently supported only for the image/png mime type.

color

StringType

A StringType that represents a color as a hexadecimal RGB string.

networkImage

StringType

A StringType that represents a network image URL.

7.4.3. Widgets

Table 9. Supported widget types for the widget feature
Widget Description

slider

Supported for an IntegerType but only if it has min and max values defined. The default behaviour of a slider is lazy, i.e. a value is set on an end of drag. If an integer type has a responsive feature set to true, the value will be set while dragging.

switch

Supported for the BooleanType. The default widget for BooleanType is a checkbox.

toggleButton

Supported for the BooleanType.

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

Table 10. List pagination features
Feature Type Applies to Description

pageable

boolean

ListType

If True, a list will be pageable. Defaults to false. A pageable list type has to be annotated, because values of offset, limit and count are passed by features in an annotated list value. A pageable list is assumed to have the scroll feature set to true.

offset

int

ListType annotated value

An offset of the first element of a page.

limit

int

ListType annotated value

A limit of a page, i.e. a maximum number of elements in a page.

count

int

ListType annotated value

A count of all available elements of a list. This value is optional.

indicatedIndex

int

ListType annotated value

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.

Table 11. Map (GeoMap) properties
Property Type Description

center

GeoPosition

A map center.

zoom

Double

A map zoom.

minZoom

Double

A map minimum zoom.

maxZoom

Double

A map maximum zoom.

layers

List<GeoLayer>

Map base layers.

crs

GeoCrs

A Coordinate Reference System.

features

Map<String, Object>

Map features.

Table 12. Common map layer (GeoLayer) properties
Property Type Description

name

String

A layer name.

label

String

A layer label.

description

String

A layer description.

features

Map<String, Object>

Layer features.

Table 13. Additional tile layer (GeoTileLayer) properties
Property Type Description

urlTemplate

String

A tile service URL template.

subdomains

List<String>

An optional list of map server subdomains, e.g. GeoLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png").withSubdomains(["a", "b", "c"]).

options

Map<String, String>

Additional layer options.

Table 14. Additional WMS layer (GeoWmsLayer) properties
Property Type Description

baseUrl

String

A WMS base URL, e.g. "https://www.gebco.net/data_and_products/gebco_web_services/north_polar_view_wms/mapserv?".

layers

List<String>

WMS layer names.

crs

GeoCrs

A Coordinate Reference System.

format

String

An optional map image format, e.g. "image/png".

version

String

An optional WMS version, e.g. "1.1.1".

styles

List<String>

Optional WMS styles.

transparent

Boolean

An optional transparency flag.

otherParameters

Map<String, String>

Optional other WMS request parameters.

Table 15. Geographical position (GeoPosition) properties
Property Type Description

latitude

Double

Latitude.

longitude

Double

Longitude.

Table 16. Coordinate Reference System (GeoCrs) properties
Property Type Description

code

String

A CRS code, e.g. "EPSG:4326".

projection

String

An optional projection definition string, e.g."+proj=stere +lat_0=90 +lat_ts=70 +lon_0=-45 +k=1 +x_0=0 +y_0=0 +datum=WGS84 +units=m +no_defs". The Sponge Flutter API library supports definitions compatible with the Proj4dart library.

resolutions

List<Double>

Optional supported resolutions, e.g. [32768, 16384, 8192, 4096, 2048, 1024, 512, 256, 128].

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 an example of using a customized Flutter UI with the Sponge service to recognize handwritten digits.

digits drawing
Figure 51. The digit recognition - drawing a digit

The application allows drawing a digit that will be recognized by a Sponge action. After each stroke the remote action call is made and the result is 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 Flutter PopupMenuButton 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.