The CloudLink Function Mapper App is a Gluon code sample. Refer to the Gluon website for a full list of Gluon code samples.

In this tutorial, we’ll explain how you can retrieve external data in your Java Mobile application by configuring Remote Functions in Gluon CloudLink. The mobile code calls a function with parameters, and this function makes a request to Gluon CloudLink, where the Function Mapper will then call the corresponding external endpoint, providing the required parameters, and will return an observable object to the mobile code.

To define the remote functions and to create the client application with a service that calls those functions, we will use the Gluon Dashboard.

We’ll be using a StackOverflow endpoint, based on its Stack Exchange API v2.2. As shown in the following picture, the mobile application will talk only to the CloudLink to do searches based on some keywords. Using that API, CloudLink will perform the request to StackOverflow, and it will process the JSON response, finally returning a JavaFX observable object to the mobile code. Within the Dashboard, the developer will be able to create the functions with the required endpoints and parameters (keywords).

Overview

This sample is a follow-up of this sreencast.

Before you start, make sure to check the list of prerequisites for each platform, and you have also installed the Gluon plugin for your IDE.

Note: This tutorial will also use the plugin for NetBeans, but it works as well on IntelliJ and Eclipse.

Code: The code for this project can be found in the samples repository at GitHub. The sample is located under the directory cloudlink-function-mapper. The reader can clone this repository or create the entire project from scratch, based on the following steps.

Gluon Dashboard

For this sample, you’ll need a valid subscription to Gluon CloudLink. If you don’t have it yet, get it from here (there is also a 30-day free trial). Sign up and get a valid account on Gluon CloudLink and a link to access the Gluon Dashboard.

Dashboard

Open the Dashboard in your browser, and sign in using your Gluon account credentials (those provided when creating the account).

Sign in

Creating the remote functions

The purpose of the application is performing searches at StackOverflow based on tags, to retrieve a list of questions and, for each selected question, search for a list of answers. For this, we need two remote functions, and we will create them in the Dashboard.

Go to the API Management link, select the functions tab, and click on the + button, to insert a new remote function.

query tagged - javafx

variable questionID - 1000000

API Management view

searchStackOverflow

Our first function, named searchStackOverflow, will make use of the search API to look for questions based on a given tag.

The remote function requires a name: searchStackOverflow; the request method will be GET, and the endpoint will be:

search function

We click save, and we’ll see the function information:

function information

Now we need to add a query parameter to the request, by clicking the function parameters + button.

adding function parameter

By default, a form parameter is defined. By double clicking on it, we enter edit mode, and we select query param from the list.

adding function parameter type

As defined by the StackExchange API, we will add tagged as the query parameter name. It will return a list of questions that include this tag. Note that we don’t need to add the parameter to the endpoint string, it is added automatically.

We don’t need to specify a value for it, as it will be defined by the client app later on, based on one of the many tags available at StackOverflow, i.e. java.

adding function parameter name

We can press enter or click on Save to commit the changes.

defined function

Finally, we can test the remote function from the Dashboard. We double click on the cell under the Test value column header, to add a value to the tagged parameter, like javafx, and press enter or click Save.

adding test value

Now we click the Test button. You should see the response, with a 200 on top (HTTP status code OK), meaning that the endpoint is working. Otherwise, review its URL.

test the function

answersStackOverflow

For the second function, we’ll select one question from the list of questions returned from the first function, and we’ll check for all its answers, if any. We’ll name it answersStackOverflow. The endpoint will be based on the answers on questions API: https://api.stackexchange.com/2.2/questions/$questionID/answers?order=desc&sort=activity&site=stackoverflow.

answers function

Note we have included $questionID as part of the URI, so we need to add a variable parameter for it, named questionID. The value will be defined in the request from the client app.

answers function parameter

We can test it as well, adding a test value to the table (i.e. 1000000) and clicking the Test button.

test the function

Finally, you can visualize the list of remote functions you have defined in your project from the drop down list, editing, removing or testing them at any time.

list of functions

Now that we have the functions ready, we will create the client app.

Creating the project

Let’s create a new project using the Gluon plugin. In NetBeans, click File→New Project…​ and select Gluon on the left. Select Gluon Mobile - Glisten-Afterburner Project from the list of available Projects:

Plugins Window

Add a proper name to the application (CloudLink-Function-Mapper), find a proper location, add the package name and change the main class name if required.

Name and Location

Press Next and change the name of the primary and secondary views, to Main for the former and Details to the latter.

Name of Views

Press Finish and the project will be created and opened.

Editing the project

Let’s open the project with NetBeans.

In order to connect the project with the Gluon CloudLink, go to the Dashboard, Credentials link, Client tab, and you will find a pair of key/secret tokens. Click on the download button to download the file gluoncloudlink_config.json, and then store it under your project src/main/resources/ folder.

Configuration File

The content of the file is a JSON object with the key and secret that will grant access to Gluon CloudLink:

src/main/resources/gluoncloudlink_config.json
{
  "gluonCredentials": {
    "applicationKey" : "f88XXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX",
    "applicationSecret": "7fbXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX"
  }
}

The model

Given that we’ll be dealing with StackOverflow JSON responses, our model should have some of the fields in those responses.

{
  "items": [
    {
      "owner": {
        "reputation": 126,
        "user_id": 77153,
        "user_type": "registered",
        "accept_rate": 29,
        "profile_image": "https://www.gravatar.com/avatar/006e73b99474923474943609ed35037b?s=128&d=identicon&r=PG",
        "display_name": "Jack Njiri",
        "link": "http://stackoverflow.com/users/77153/jack-njiri"
      },
      "is_accepted": false,
      "score": 0,
      "last_activity_date": 1245242062,
      "creation_date": 1245242062,
      "answer_id": 1006730,
      "question_id": 1000000
    },
    {
      "owner": {
        "reputation": 30025,
        "user_id": 22595,
        "user_type": "registered",
        "accept_rate": 81,
        "profile_image": "https://www.gravatar.com/avatar/32fef6b6cf057874c189a9ff40029573?s=128&d=identicon&r=PG",
        "display_name": "Michał Niklas",
        "link": "http://stackoverflow.com/users/22595/micha%c5%82-niklas"
      },
      "is_accepted": false,
      "score": 2,
      "last_activity_date": 1245146157,
      "creation_date": 1245146157,
      "answer_id": 1000537,
      "question_id": 1000000
    }
  ],
  "has_more": false,
  "quota_max": 10000,
  "quota_remaining": 9995
}

we’ll create a StackOwner class with just three fields:

StackOwner.java
public class StackOwner {
    private String display_name;
    private String profile_image;
    private int reputation;

    // getters & setters, toString
}

and a StackEntry class that will be valid for both questions and answers:

StackEntry.java
public class StackEntry {
    private int answer_count;
    private long creation_date;
    private boolean is_accepted;
    private String link;
    private StackOwner owner;
    private long question_id;
    private int score;
    private String title;

    // getters & setters, toString
}

Finally, the items array that will define the response will require the StackResponse class:

StackResponse.java
public class StackResponse {
    private List<StackEntry> items = new LinkedList();

    // getters & setters, toString
}

Note that you can add more fields to the model, if required.

The RemoteService

We’ll add a new class to deal with the remote functions requests. For each remote function, we’ll create a RemoteFunction, based on their name defined in the CloudLink, and the param name and its value:

RemoteFunctionObject function = RemoteFunctionBuilder
                .create("searchStackOverflow")
                .param("tagged", value)
                .object();

call() uses a DataProvider to retrieve data and returns a GluonObservableObject<StackResponse>.

This is all it takes to generate an observable object from a remote endpoint, based only in a given value passed to the query.

RemoteService.java
public class RemoteService {

    public GluonObservableObject<StackResponse> answersStackOverflow(String value) {
        RemoteFunctionObject function = RemoteFunctionBuilder
                .create("answersStackOverflow")
                .param("questionID", value)
                .object();
        return function.call(StackResponse.class);
    }

    public GluonObservableObject<StackResponse> searchStackOverflow(String value) {
        RemoteFunctionObject function = RemoteFunctionBuilder
                .create("searchStackOverflow")
                .param("tagged", value)
                .object();
        return function.call(StackResponse.class);
    }
}

Testing the service

At this point we can already test the remote service. For that, we’ll add two requests in the Main view, replacing the pregenerated code in the buttonClick method, with this one:

MainPresenter.java
    @Inject
    private RemoteService remoteService;

    @FXML
    void buttonClick() {
        System.out.println("searchStackOverflow");
        GluonObservableObject<StackResponse> searchStackOverflow = remoteService.searchStackOverflow(StackResponse.class, "gluon");
        searchStackOverflow.initializedProperty().addListener((obs, ov, nv) -> {
            if (nv) {
                System.out.println(searchStackOverflow.get());
            }
        });

        System.out.println("\nanswersStackOverflow");
        GluonObservableObject<StackResponse> answersStackOverflow = remoteService.answersStackOverflow(StackResponse.class, "1000000");
        answersStackOverflow.initializedProperty().addListener((obs, ov, nv) -> {
            if (nv) {
                System.out.println(answersStackOverflow.get());
            }
        });
    }

If you run ./gradlew run you should get a response like this:

searchStackOverflow
StackResponse{items=[StackEntry{answer_count=0, creation_date=1490624501, is_accepted=false, link=http://stackoverflow.com/questions/43049006/scenebuilder-8-3-0-disappearing-menus-and-no-resize, owner=StackOwner{display_name=JeanPhi, profile_image=https://www.gravatar.com/avatar/add1641f18223ddfd443404332ecc49f?s=128&d=identicon&r=PG&f=1, reputation=1}, question_id=43049006, score=0, title=scenebuilder 8.3.0 disappearing menus and no resize},
... }

answersStackOverflow
StackResponse{items=[StackEntry{answer_count=0, creation_date=1245242062, is_accepted=false, link=null, owner=StackOwner{display_name=Jack Njiri, profile_image=https://www.gravatar.com/avatar/006e73b99474923474943609ed35037b?s=128&d=identicon&r=PG, reputation=126}, question_id=1000000, score=0, title=null}, StackEntry{answer_count=0, creation_date=1245146157, is_accepted=false, link=null, owner=StackOwner{display_name=Michał Niklas, profile_image=https://www.gravatar.com/avatar/32fef6b6cf057874c189a9ff40029573?s=128&d=identicon&r=PG, reputation=30025}, question_id=1000000, score=2, title=null}]}

Enhancing the main view

Editing main.fxml with Scene Builder, we’ll add a CharmListView to show the list of entries, and a ComboBox to add a list of valid tags, and let the user type any other one.

Main view

Then, in the MainPresenter we’ll add the custom cells for the listView, and set the service call, when a new tag is selected:

MainPresenter.java
public class MainPresenter extends GluonPresenter<FunctionMapper> {

    private static final List<String> TAGS = Arrays.asList(new String[] { "gluon", "gluon-mobile", "javafxports", "javafx", "scenebuilder" });

    @Inject
    private RemoteService remoteService;

    @FXML
    private View main;

    @FXML
    private ComboBox<String> tagComboBox;

    @FXML
    private CharmListView<StackEntry, Integer> charmListView;

    public void initialize() {
        tagComboBox.getItems().addAll(TAGS);
        tagComboBox.setEditable(true);

        charmListView.setPlaceholder(new Label("No items yet\nSelect a tag"));
        charmListView.setHeadersFunction(StackEntry::getScore);
        charmListView.setHeaderComparator((e1, e2) -> e2.compareTo(e1));
        charmListView.setComparator((e1, e2) -> e2.getAnswer_count() - e1.getAnswer_count());
        charmListView.setHeaderCellFactory(p -> new CharmListCell<StackEntry>() {

            private final Label label;
            private final Icon up, down;
            {
                label = new Label();
                up = new Icon(MaterialDesignIcon.THUMB_UP);
                up.getStyleClass().add("up");
                down = new Icon(MaterialDesignIcon.THUMB_DOWN);
                down.getStyleClass().add("down");
            }
            @Override
            public void updateItem(StackEntry item, boolean empty) {
                super.updateItem(item, empty);
                if (item != null && !empty) {
                    final int score = item.getScore();
                    label.setGraphic(score >= 0 ? up : down);
                    label.setText("Score: " + score);
                    setGraphic(label);
                } else {
                    setGraphic(null);
                }
            }

        });

        charmListView.setCellFactory(p -> new CharmListCell<StackEntry>() {

            private final ListTile tile;
            private final ImageView imageView;
            private final Icon icon;
            {
                tile = new ListTile();
                imageView = new ImageView();
                tile.setPrimaryGraphic(imageView);
                icon = new Icon(MaterialDesignIcon.CHEVRON_RIGHT);
                tile.setSecondaryGraphic(icon);
            }

            @Override
            public void updateItem(StackEntry item, boolean empty) {
                super.updateItem(item, empty);
                if (item != null && !empty) {
                    imageView.setImage(Util.getImage(item.getOwner().getProfile_image()));
            tile.textProperty().setAll(item.getTitle(),
                    item.getOwner().getDisplay_name(),
                    "Created: " + Util.FORMATTER.format(LocalDateTime.ofEpochSecond(item.getCreation_date(), 0, ZoneOffset.UTC)) +
                            " - Answers: " + item.getAnswer_count());
                    setGraphic(tile);
                } else {
                    setGraphic(null);
                }
            }

        });

        tagComboBox.getSelectionModel().selectedItemProperty().addListener((obs, ov, nv) -> search(nv));

        main.showingProperty().addListener((obs, oldValue, newValue) -> {
            if (newValue) {
                AppBar appBar = getApp().getAppBar();
                appBar.setNavIcon(MaterialDesignIcon.MENU.button(e -> getApp().getDrawer().open()));
                appBar.setTitleText("StackOverflow questions");
            }
        });
    }

    private void search(String tag) {
        charmListView.setItems(FXCollections.emptyObservableList());
        GluonObservableObject<StackResponse> searchStackOverflow = remoteService.searchStackOverflow(StackResponse.class, tag);
        searchStackOverflow.initializedProperty().addListener((obs, ov, nv) -> {
            if (nv) {
                charmListView.setItems(FXCollections.observableArrayList(searchStackOverflow.get().getItems()));
            }
        });
    }

}

Note the use of the Util class, that contains a proper DateTimeFormatter and the image cache, to avoid downloading the images over and over again when scrolling the listView. We add the Cache plugin to the build script using the Gluon Mobile Settings option.

Util.java
public class Util {

    public static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("dd/MMM/yyyy");

    private static final Cache<String, Image> CACHE = Services.get(CacheService.class)
            .map(cache -> cache.<String, Image>getCache("images"))
            .orElseThrow(() -> new RuntimeException("No cache service"));


    public static Image getImage(String imageName) {
        if ((imageName == null) || (imageName.isEmpty())) {
            return null;
        }

        Image image = CACHE.get(imageName);
        if (image == null) {
            image = new Image(imageName, 36.0, 36.0, true, true, true);
            CACHE.put(imageName, image);
        }
        return image;
    }

}

Let’s run the app now to check that everything is in place.

Testing the main view

Enhancing the detail view

We’ll edit now detail.fxml with Scene Builder, adding a CharmListView to show the list of answers, and a ListTile on top to show the question.

Details view

Then, in the DetailPresenter we’ll add the custom cells for the listView, and set the service call based on the id of the question.

DetailPresenter.java
public class DetailPresenter extends GluonPresenter<FunctionMapper> {

    @Inject
    private RemoteService remoteService;

    @FXML
    private View detail;

    @FXML
    private ListTile listTile;

    @FXML
    private CharmListView<StackEntry, Integer> charmListView;

    public void initialize() {
        charmListView.setPlaceholder(new Label("No answers yet"));
        charmListView.setHeadersFunction(StackEntry::getScore);
        charmListView.setHeaderComparator((e1, e2) -> e2.compareTo(e1));
        charmListView.setComparator((e1, e2) -> e2.getOwner().getReputation() - e1.getOwner().getReputation());
        charmListView.setHeaderCellFactory(p -> new CharmListCell<StackEntry>() {

            private final Label label;
            private final Icon up, down;
            {
                label = new Label();
                up = new Icon(MaterialDesignIcon.THUMB_UP);
                up.getStyleClass().add("up");
                down = new Icon(MaterialDesignIcon.THUMB_DOWN);
                down.getStyleClass().add("down");
            }
            @Override
            public void updateItem(StackEntry item, boolean empty) {
                super.updateItem(item, empty);
                if (item != null && !empty) {
                    final int score = item.getScore();
                    label.setGraphic(score >= 0 ? up : down);
                    label.setText("Score: " + score);
                    setGraphic(label);
                } else {
                    setGraphic(null);
                }
            }

        });
        charmListView.setCellFactory(p -> new CharmListCell<StackEntry>() {

            private final ListTile tile;
            private final ImageView imageView;
            private final Icon icon;
            {
                tile = new ListTile();
                imageView = new ImageView();
                tile.setPrimaryGraphic(imageView);
                icon = new Icon(MaterialDesignIcon.CHECK_CIRCLE);
                tile.setSecondaryGraphic(icon);
            }

            @Override
            public void updateItem(StackEntry item, boolean empty) {
                super.updateItem(item, empty);
                if (item != null && !empty) {
                    imageView.setImage(Util.getImage(item.getOwner().getProfile_image()));
                    tile.textProperty().setAll(item.getOwner().getDisplay_name(),
                            "Reputation: " + item.getOwner().getReputation(),
                            "Answered: " + Util.FORMATTER.format(LocalDateTime.ofEpochSecond(item.getCreation_date(), 0, ZoneOffset.UTC)));
                    icon.setVisible(item.isIs_accepted());
                    setGraphic(tile);
                } else {
                    setGraphic(null);
                }
            }

        });

        detail.showingProperty().addListener((obs, oldValue, newValue) -> {
            if (newValue) {
                AppBar appBar = getApp().getAppBar();
                appBar.setNavIcon(MaterialDesignIcon.CHEVRON_LEFT.button(e ->
                        getApp().goHome()));
                appBar.setTitleText("Question Details");
            }
        });
    }

    public void setStackEntry(StackEntry stackEntry) {
        StackOwner stackOwner = stackEntry.getOwner();
        if (stackOwner != null) {
            ImageView imageView = new ImageView(Util.getImage(stackOwner.getProfile_image()));
            imageView.setPreserveRatio(true);
            listTile.setPrimaryGraphic(imageView);
            listTile.setWrapText(true);
            listTile.textProperty().setAll(stackEntry.getTitle(),
                    stackOwner.getDisplay_name(),
                    "Created: " + Util.FORMATTER.format(LocalDateTime.ofEpochSecond(stackEntry.getCreation_date(), 0, ZoneOffset.UTC)) +
                        " - Answers: " + stackEntry.getAnswer_count());
            Icon icon = new Icon(MaterialDesignIcon.OPEN_IN_BROWSER);
            listTile.setSecondaryGraphic(icon);
        }
        search(String.valueOf(stackEntry.getQuestion_id()));
      }

    private void search(String questionId) {
        charmListView.setItems(FXCollections.emptyObservableList());
        GluonObservableObject<StackResponse> answersStackOverflow = remoteService.answersStackOverflow(StackResponse.class, questionId);
        answersStackOverflow.initializedProperty().addListener((obs, ov, nv) -> {
            if (nv) {
                charmListView.setItems(FXCollections.observableArrayList(answersStackOverflow.get().getItems()));
            }
        });
    }
}

Note that we’ll have to pass the question id value from the main view: When the user clicks on the right icon of a given cell, the view is switched to the Detail view, and setStackEntry is called:

MainPresenter.java
    @Override
    public void updateItem(StackEntry item, boolean empty) {
        super.updateItem(item, empty);
        if (item != null && !empty) {
            ...
            icon.setOnMouseClicked(e -> AppViewManager.DETAIL_VIEW.switchView()
                            .ifPresent(presenter -> ((DetailPresenter) presenter).setStackEntry(item)));

            setGraphic(tile);
        } else {
            setGraphic(null);
        }
    }

Finally, we’ll add the BrowserService plugin to open the question in an external browser. Again, we add the plugin to the build script using the Gluon Mobile Settings option. And then we add an event handler to the icon:

DetailPresenter.java
    public void setStackEntry(StackEntry stackEntry) {
        StackOwner stackOwner = stackEntry.getOwner();
        if (stackOwner != null) {
            ...
            Icon icon = new Icon(MaterialDesignIcon.OPEN_IN_BROWSER);
            icon.setOnMouseClicked(e -> Services.get(BrowserService.class)
                    .ifPresent(browser -> {
                        try {
                            browser.launchExternalBrowser(stackEntry.getLink());
                        } catch (IOException | URISyntaxException ex) {}
                    }));
            listTile.setSecondaryGraphic(icon);
        }
        search(String.valueOf(stackEntry.getQuestion_id()));
      }

Again, let’s run the application to test the latest changes.

Testing details view

Mocking responses

Sometimes the endpoints won’t be available yet or the real response doesn’t include certain values, but having a response, or those values in it, comes in handy when developing and testing the application. In that case, we can easily add mock data to the remote function.

For that, we edit one of the endpoints, check Enable Mock, and, in this case, select application/json format from the list. Then we add some mock data:

{"items":[{"tags":["gluon","gluon-mobile"],"owner":{"reputation":100000,"profile_image":"http://gluonhq.com/wp-content/uploads/2015/01/363970-brand-energy-fas@2x.png","display_name":"gluon"},"is_answered":false,"answer_count":10,"score":100,"creation_date":1490340785,"question_id":1000000,"link":"http://gluonhq.com","title":"Mock Data"}]}
Adding mock data

Now we can test the function. No matter what tag the user selects, the response will always be the mock data.

Testing mock data

Later on, this can disable this feature by unselecting the Enable Mock check.

Deploy on mobile

Time to try the application on mobile:

Android

Connect your Android device and run ./gradlew androidInstall

iOS

Connect your iOS device and run ./gradlew launchIOSDevice

Conclusion

During this tutorial we have accomplished several tasks:

  • We have used the Cloudlink Dashboard to create remote functions that process REST requests from a given endpoint and return JavaFX observable objects.

  • We have created a client project that can be deployed on Desktop, Andriod and iOS, with a remote service to use those functions, and the required configuration file.

  • We have modified the views to include a CharmListView control that will render the response from the CloudLink.

  • We have added two plugins to the project: Cache and Browser.

  • We have added mock data in case the real response is not available but we need to do some testing.

One of the main adventages from the point of view of a mobile/front-end developer is the complete decoupling from the back-end side implementation: no HTTP protocols, no REST services, no JSON parsing, no URL dependencies…​ : the client side just gets observable data, that can be easily rendered using the controls provided by the Gluon Mobile library, allowing fast development and short time to market. It also allows changes in the back-end, without the need of redeployment the application.

If you have made it this far, congratulations, we hope this sample and the documentation was helpful to you! In case you have any questions, your first stop should be the Gluon support page, where you can access the latest documentation and the Gluon knowledge base. Gluon also recommends the community to support each other over at the Gluon StackOverflow page. Finally, Gluon offers commercial support as well, to kick start your projects.