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).
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.
Open the Dashboard in your browser, and sign in using your Gluon account credentials (those provided when creating the account).
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
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:
We click save
, and we’ll see the function information:
Now we need to add a query parameter to the request, by clicking the function parameters +
button.
By default, a form
parameter is defined. By double clicking on it, we enter edit mode, and we select query param
from the list.
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
.
We can press enter or click on Save
to commit the changes.
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
.
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.
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.
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.
We can test it as well, adding a test value to the table (i.e. 1000000) and clicking the Test button.
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.
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:
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.
Press Next
and change the name of the primary and secondary views, to Main
for the former and Details
to the latter.
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.
The content of the file is a JSON object with the key and secret that will grant access to Gluon CloudLink:
{
"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.
For instance, for this request https://api.stackexchange.com/2.2/questions/1000000/answers?order=desc&sort=activity&site=stackoverflow, we have this response:
{
"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:
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:
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:
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.
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:
@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.
Then, in the MainPresenter
we’ll add the custom cells for the listView, and set the service call, when a new tag is selected:
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.
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.
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.
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.
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:
@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:
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.
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"}]}
Now we can test the function. No matter what tag the user selects, the response will always be the 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.