Show Navigation

Building an Android client powered by a Grails backend

This guide demonstrates how you can use Grails as a backend for an Android app

Authors: Sergio del Amo

Grails Version: 4.0.1

1 Grails Training

Grails Training - Developed and delivered by the folks who created and actively maintain the Grails framework!.

2 Getting Started

In this guide you are going to build a Grails application which will serve as a company intranet backend. It exposes a JSON API of company announcements.

Additionally, you are going to build an Android App, the intranet client, which consumes the JSON API offered by the backend.

The guide explores the support of diffent API versions.

2.1 What you will need

To complete this guide, you will need the following:

  • Some time on your hands

  • A decent text editor or IDE

  • JDK 1.8 or greater installed with JAVA_HOME configured appropriately

2.2 How to complete the guide

To complete this guide, you will need to checkout the source from Github and work through the steps presented by the guide.

To get started do the following:

To follow the Grails part:

  • cd into grails-guides/building-an-android-client-powered-by-a-grails-backend/initial

Alternatively, if you already have Grails installed then you can create a new application using the following command in a Terminal window:

$ grails create-app intranet.backend.grails-app --profile rest-api
$ cd grails-app

When the create-app command completes, Grails will create a grails-app directory with an application configured to create a REST application (due to the use of the parameter profile=rest-api ) and configured to use the hibernate feature with an H2 database.

You can go right to the completed Grails example if you cd into grails-guides/building-an-android-client-powered-by-a-grails-backend/complete

To follow the Android part:

  • cd into grails-guides/building-an-android-client-powered-by-a-grails-backend/initial-android

  • Head on over to the next section

Alternatively, you can create an android app using the New Project Wizard of Android Studio as illustrated in the next screenshots.

android studio newproject1
android studio newproject2
android studio newproject3
android studio newproject4
You can go right to the completed version 1 of the Android example if you cd into grails-guides/building-an-android-client-powered-by-a-grails-backend/complete-android-v1
You can go right to the completed version 2 of the Android example if you cd into grails-guides/building-an-android-client-powered-by-a-grails-backend/complete-android-v2

3 Overview

The next image illustrates the behavior of Android app version 1. The Android app is composed by two Activities.

  • When the Android application initial screen loads, it requests an announcements list.

  • The Grails app sends a JSON payload which includes a list of announcements. For each announcement, a unique identifier, a title and a HTML body are included.

  • The android app renders the JSON Payload in a ListView.

  • The user taps an announcement’s title and the app transition to a detail screen. The initial screen sends the detail screen the announcement identifier, title and HTML body. The latter will be rendered in a WebView.

overview

4 Writing the Grails Application

Now you are ready to start writing the Grails application.

4.1 Create a Domain Class - Persistent Entities

We need to create persistent entities to store company announcements. Grails handles persistence with the use of Grails Domain Classes:

A domain class fulfills the M in the Model View Controller (MVC) pattern and represents a persistent entity that is mapped onto an underlying database table. In Grails a domain is a class that lives in the grails-app/domain directory.

Grails simplifies the creation of domain classes with the create-domain-class command.

 ./grailsw create-domain-class Announcement
| Resolving Dependencies. Please wait...
CONFIGURE SUCCESSFUL
Total time: 4.53 secs
| Created grails-app/grails/company/intranet/Announcement.groovy
| Created src/test/groovy/grails/company/intranet/AnnouncementSpec.groovy

Just to keep it simple, we assume the company announcements just contain a title and a HTML body. We are going to modify the domain class generated in the previous step to store that information.

grails-app/domain/intranet/backend/Announcement.groovy
package intranet.backend

class Announcement {

    String title
    String body

    static constraints = {
        title size: 0..255
        body nullable: true
    }

    static mapping = {
        body type: 'text'  (1)
    }
}
1 it enables us to store strings with more than 255 characters in the body.

4.2 Domain Class Unit Testing

Grails makes testing easier from low-level unit testing to high level functional tests.

We are going to test the constraints we defined in the Announcement domain Class in constraints property. In particular nullability and length of both title and body properties.

src/test/groovy/intranet/backend/AnnouncementSpec.groovy
package intranet.backend

import grails.testing.gorm.DomainUnitTest
import spock.lang.Specification

        expect:
        new Announcement(body: null).validate(['body'])
    }

    void "test title can not be null"() {
        expect:
        !new Announcement(title: null).validate(['title'])
    }

    void "test body can have a more than 255 characters"() {

        when: 'for a string of 256 characters'
        String str = ''
        256.times { str += 'a' }

        then: 'body validation passes'
        new Announcement(body: str).validate(['body'])
    }

    void "test title can have a maximum of 255 characters"() {

        when: 'for a string of 256 characters'
        String str = ''
        256.times { str += 'a' }

        then: 'title validation fails'
        !new Announcement(title: str).validate(['title'])

        when: 'for a string of 256 characters'
        str = ''
        255.times { str += 'a' }

        then: 'title validation passes'
        new Announcement(title: str).validate(['title'])
    }
}

We can run the every test, including the one we just created, with the command test_app

 ./grailsw test-app
 | Resolving Dependencies. Please wait...
 CONFIGURE SUCCESSFUL
 Total time: 2.534 secs
 :complete:compileJava UP-TO-DATE
 :complete:compileGroovy
 :complete:buildProperties
 :complete:processResources
 :complete:classes
 :complete:compileTestJava UP-TO-DATE
 :complete:compileTestGroovy
 :complete:processTestResources UP-TO-DATE
 :complete:testClasses
 :complete:test
 :complete:compileIntegrationTestJava UP-TO-DATE
 :complete:compileIntegrationTestGroovy UP-TO-DATE
 :complete:processIntegrationTestResources UP-TO-DATE
 :complete:integrationTestClasses UP-TO-DATE
 :complete:integrationTest UP-TO-DATE
 :complete:mergeTestReports

 BUILD SUCCESSFUL

 | Tests PASSED

4.3 Versioning

It is important to think about API versioning from the beginning, especially when you create an API consumed by mobile phone applications. Users will run different versions of the app, and you will need to version your API to create advanced functionality but keep supporting legacy versions.

Grails allows multiple ways to Version REST Resources.

  • Using the URI

  • Using the Accept-Version Header

  • Using Hypermedia/Mime Types

In this guide we are going to version the API using the Accept-Version HTTP header.

Devices running version 1.0 will invoke the announcements enpoint passing the 1.0 in the Accept Version Header.

$ curl -i -H "Accept-Version: 1.0" -X GET http://localhost:8080/announcements

Devices running version 2.0 will invoke the announcements enpoint passing the 2.0 in the Accept Version Header.

$ curl -i -H "Accept-Version: 2.0" -X GET http://localhost:8080/announcements

4.4 Create a Controller

We create a Controller for the Domain class we previously created. Our Controller extends RestfulController. This will provide us RESTful functionality to list, create, update and delete Announcement resources using different HTTP Methods.

grails-app/controllers/intranet/backend/v1/AnnouncementController.groovy
package intranet.backend.v1

import grails.rest.RestfulController
import intranet.backend.Announcement

class AnnouncementController extends RestfulController<Announcement> {
    static namespace = 'v1' (1)
    static responseFormats = ['json'] (2)

    AnnouncementController() {
        super(Announcement)
    }
}
1 this controller will handle v1 of our api
2 we want to respond only JSON Payloads

Url Mapping

We want our endpoint to listen in /announcements instead of /announcement. Moreover, we want the previous controller for which we declared a namespace of v1 to handle the requests with the Accept-Version Http Header set to 1.0.

Grails enables powerful URL mapping configuration to do that. Add the next line to the mappings closure:

/grails-app/controllers/intranet/backend/UrlMappings.groovy
        get "/announcements"(version:'1.0', controller: 'announcement', namespace:'v1')

4.5 Loading test data

We are going to populate the database with several announcements when the application startups.

In order to do that, we edit grails-app/init/grails/company/intranet/BootStrap.groovy.

package grails.company.intranet

class BootStrap {

    def init = { servletContext ->
        announcements().each { it.save() }
    }
    def destroy = {
    }

    static List<Announcement> announcements() {
        [
                new Announcement(title: 'Grails Quickcast #1: Grails Interceptors'),
                new Announcement(title: 'Grails Quickcast #2: JSON Views')
        ]
    }
}
Announcements in the previous code snippet don’t contain body content to keep the code sample small. Checkout grails-app/init/intranet/backend/BootStrap.groovy to see the complete code.

4.6 Functional tests

Functional Tests involve making HTTP requests against the running application and verifying the resultant behavior.

We use the Rest Client Builder Grails Plugin whose dependency is added when we create an app with the rest-api profile.

/home/runner/work/building-an-android-client-powered-by-a-grails-backend/building-an-android-client-powered-by-a-grails-backend/complete

/src/integration-test/groovy/intranet/backend/AnnouncementControllerSpec.groovy
package intranet.backend

import grails.testing.mixin.integration.Integration
import grails.testing.spock.OnceBefore
import grails.web.http.HttpHeaders
import io.micronaut.http.HttpRequest
import io.micronaut.http.HttpResponse
import io.micronaut.http.HttpStatus
import io.micronaut.http.client.HttpClient
import spock.lang.AutoCleanup
import spock.lang.Shared
import spock.lang.Specification


@Integration
class AnnouncementControllerSpec extends Specification {

    @Shared
    @AutoCleanup
    HttpClient client

    @OnceBefore
    void init() {
        String baseUrl = "http://localhost:$serverPort"
        this.client  = HttpClient.create(baseUrl.toURL())
    }

    def "test body is present in announcements json payload of Api 1.0"() {
        given:
        HttpRequest request = HttpRequest.GET("/announcements/").header("Accept-Version", "1.0")

        when: 'Requesting announcements for version 1.0'
        HttpResponse<List<Map>> resp = client.toBlocking().exchange(request, List) (1)

        then: 'the request was successful'
        resp.status == HttpStatus.OK (3)

        and: 'the response is a JSON Payload'
        and: 'json payload contains the complete announcement'
1 serverPort is automatically injected. It contains the random port where the grails application runs during the functional test
2 Pass the api version as an Http header
3 Verify the response code is 200; OK
4 Body is present in the JSON paylod

Grails command test-app runs unit, integration and functional tests.

4.7 Running the Application

To run the application use the ./gradlew bootRun command which will start the application on port 8080.

5 Writing the Android Application

5.1 Fetching the Announcements

Next image illustrates the classes involved in the fetching and rendering of the announcements exposed by the Grails application.

android announcements overview

5.2 Networking code

We have a class where serveral constants are initialized:

/app/src/main/java/intranet/client/network/Constants.java
package intranet.client.network;

public class Constants {
    static final String GRAILS_APP_URL = "http://192.168.1.42:8080/"; (1)
    static final String ANNOUNCEMENTS_PATH = "announcements"; (2)
    static final String ACCEPT_VERSION = "1.0"; (3)
}
1 Grails App server url.
2 The path we configured in the Grails app in UrlMappings.groovy
3 The version of the API
You may need to change the ip address to match your local machine.

Model

The announcements sent by the server are gonna be rendered into this POJO:

/app/src/main/java/intranet/client/network/model/Announcement.java
package intranet.client.network.model;

public class Announcement {
    private Long id;
    private String title;
    private String body;

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getTitle() {
        return title;
    }

    public void setTitle(String title) {
        this.title = title;
    }

    public String getBody() {
        return body;
    }

    public void setBody(String body) {
        this.body = body;
    }
}

Networking Dependencies

You will need to add INTERNET permission to app/src/main/AndroidManifest.xml

/app/src/main/AndroidManifest.xml
    <uses-permission android:name="android.permission.INTERNET"/>

OkHttp

We are going to use one of the most popular http clients for Android, OkHttp

To add OkHttp as a dependency, edit the file app/build.gradle and add, in the dependencies block, the next line:

/app/build.gradle
    implementation 'com.squareup.okhttp3:okhttp:4.2.2'

Gson

Gson is a Java library that can be used to convert Java Objects into their JSON representation. It can also be used to convert a JSON string to an equivalent Java object.

To add Gson as a dependency, edit the file app/build.gradle and add, in the dependencies block, the next line:

/app/build.gradle
    implementation 'com.google.code.gson:gson:2.8.6'

We are going to encapsulate the instantiation of OkHttp Request in a class to ensure the Accept-Version Http header is always set with the value defined in the Constants.java class

/app/src/main/java/intranet/client/network/NetworkTask.java
package intranet.client.network;

import okhttp3.Request;

class NetworkTask {
    static Request requestWithUrl(String url) {
        return new Request.Builder()
                .url(url)
                .header("Accept-Version", Constants.ACCEPT_VERSION)
                .build();
    }
}

The next class fetches and returns a list of Announcements.

/app/src/main/java/intranet/client/network/AnnouncementsFetcher.java
package intranet.client.network;

import android.util.Log;

import com.google.gson.Gson;
import com.google.gson.reflect.TypeToken;

import java.io.IOException;
import java.lang.reflect.Type;
import java.util.ArrayList;
import java.util.List;

import intranet.client.network.model.Announcement;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;

public class AnnouncementsFetcher {

    private final static String TAG = AnnouncementsFetcher.class.getSimpleName();

    private OkHttpClient client = new OkHttpClient();
    private final Gson gson = new Gson();

    public List<Announcement> fetchAnnouncements() {
        Type listType = new TypeToken<List<Announcement>>() {}.getType();
        try {
            String url = Constants.GRAILS_APP_URL + Constants.ANNOUNCEMENTS_PATH;
            String jsonString = fetchAnnouncementsJsonString(url);
            return gson.fromJson(jsonString, listType);

        } catch (IOException e) {
            Log.e(TAG, e.toString());
            return new ArrayList<>();
        }
    }

    private String fetchAnnouncementsJsonString(String url) throws IOException {
        Request request = NetworkTask.requestWithUrl(url);
        Response response = client.newCall(request).execute();
        return response.body().string();
    }
}

In order to avoid running networking code in the UI thread we are going to encapsulate the networking code in an AsyncTask

/app/src/main/java/intranet/client/android/asynctasks/RetrieveAnnouncementsTask.java
package intranet.client.android.asynctasks;

import android.os.AsyncTask;

import java.util.List;

import intranet.client.android.delegates.RetrieveAnnouncementsDelegate;
import intranet.client.network.AnnouncementsFetcher;
import intranet.client.network.model.Announcement;

public class RetrieveAnnouncementsTask extends AsyncTask<Void, Void, List<Announcement>> {

    private AnnouncementsFetcher fetcher = new AnnouncementsFetcher();
    private RetrieveAnnouncementsDelegate delegate;

    public RetrieveAnnouncementsTask(RetrieveAnnouncementsDelegate delegate) {
        this.delegate = delegate;
    }

    @Override
    protected List<Announcement> doInBackground(Void... voids) {
        return fetcher.fetchAnnouncements();
    }

    protected void onPostExecute(List<Announcement> announcements) {
        if ( delegate != null ) {
            delegate.onAnnouncementsFetched(announcements);
        }
    }
}

Once we get a list of announcements, we will communicate the response to classes implementing the delegate

/app/src/main/java/intranet/client/android/delegates/RetrieveAnnouncementsDelegate.java
package intranet.client.android.delegates;

import java.util.List;

import intranet.client.network.model.Announcement;

public interface RetrieveAnnouncementsDelegate {
    void onAnnouncementsFetched(List<Announcement> announcements);
}

The class which implements the delegate receiving the announcements is the initial Activity

/app/src/main/java/intranet/client/android/activities/MainActivity.java
package intranet.client.android.activities;

import android.app.Activity;
import android.content.Intent;
import android.os.Bundle;
import android.widget.ListView;

import java.util.ArrayList;
import java.util.List;

import intranet.client.R;
import intranet.client.android.adapters.AnnouncementAdapter;
import intranet.client.android.asynctasks.RetrieveAnnouncementsTask;
import intranet.client.android.delegates.AnnouncementAdapterDelegate;
import intranet.client.android.delegates.RetrieveAnnouncementsDelegate;
import intranet.client.network.model.Announcement;

public class MainActivity extends Activity
        implements RetrieveAnnouncementsDelegate, AnnouncementAdapterDelegate {

    public static final String EXTRA_ID = "id";
    public static final String EXTRA_TITLE = "title";
    public static final String EXTRA_BODY = "body";
    private AnnouncementAdapter adapter;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        ListView announcementsListView = (ListView) findViewById(R.id.announcementsListView);
        adapter = new AnnouncementAdapter(this, new ArrayList<Announcement>(), this);
        announcementsListView.setAdapter(adapter);

        new RetrieveAnnouncementsTask(this).execute(); (1)
    }

    @Override
    public void onAnnouncementsFetched(List<Announcement> announcements) {  (2)
        adapter.clear();
        adapter.addAll(announcements);
    }

    @Override
    public void onAnnouncementTapped(Announcement announcement) {
        segueToAnnouncementActivity(announcement);
    }

    private void segueToAnnouncementActivity(Announcement announcement) {
        Intent i = new Intent(this, AnnouncementActivity.class);
        i.putExtra(EXTRA_ID, announcement.getId());
        i.putExtra(EXTRA_TITLE, announcement.getTitle());
        i.putExtra(EXTRA_BODY, announcement.getBody());
        startActivity(i);
    }
}
1 Triggers the async tasks to fetch the announcements asynchronously.
2 Refreshes the UI once we get a list of announcements

MainActivity uses a ListView defined in:

/app/src/main/res/layout/activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/activity_main"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:paddingBottom="@dimen/activity_vertical_margin"
    android:paddingLeft="@dimen/activity_horizontal_margin"
    android:paddingRight="@dimen/activity_horizontal_margin"
    android:paddingTop="@dimen/activity_vertical_margin"
    tools:context="grails.company.client.intranet.client.MainActivity">

    <ListView
        android:id="@+id/announcementsListView"
        android:layout_width="match_parent"
        android:layout_height="match_parent">
    </ListView>

</RelativeLayout>

The next classes and layout files render the different announcements in a ListView and handle the user tap.

/app/src/main/java/intranet/client/android/adapters/AnnouncementAdapter.java
package intranet.client.android.adapters;

import android.content.Context;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ArrayAdapter;
import android.widget.TextView;

import java.util.List;

import androidx.annotation.NonNull;
import intranet.client.R;
import intranet.client.android.delegates.AnnouncementAdapterDelegate;
import intranet.client.network.model.Announcement;

public class AnnouncementAdapter extends ArrayAdapter<Announcement> {
    private AnnouncementAdapterDelegate delegate;

    public AnnouncementAdapter(Context context, List<Announcement> announcements, AnnouncementAdapterDelegate delegate) {
        super(context, 0, announcements);
        this.delegate = delegate;
    }

    @NonNull
    public View getView(int position, View convertView, @NonNull ViewGroup parent) {
        if (convertView == null) {
            convertView = LayoutInflater.from(getContext()).inflate(R.layout.item_announcement, parent, false);
        }
        TextView tvTitle = (TextView) convertView.findViewById(R.id.tvTitle);
        Announcement announcement = getItem(position);
        if ( announcement != null ) {
            tvTitle.setText(announcement.getTitle());
            tvTitle.setOnClickListener(new AnnouncementClickListener(announcement));
        }
        return convertView;
    }

    private class AnnouncementClickListener implements View.OnClickListener {
        private Announcement announcement;

        AnnouncementClickListener(Announcement announcement) {
            this.announcement = announcement;
        }

        @Override
        public void onClick(View view) {
            if ( delegate != null ) {
                delegate.onAnnouncementTapped(announcement);
            }
        }
    }
}
/app/src/main/java/intranet/client/android/delegates/AnnouncementAdapterDelegate.java
package intranet.client.android.delegates;

import intranet.client.network.model.Announcement;

public interface AnnouncementAdapterDelegate {
    void onAnnouncementTapped(Announcement announcement);
}
/app/src/main/res/layout/item_announcement.xml
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent" >
    <TextView
        android:id="@+id/tvTitle"
        android:paddingTop="@dimen/activity_vertical_margin"
        android:paddingBottom="@dimen/activity_vertical_margin"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"/>
</LinearLayout>

5.3 Detail Activity

When the user taps an announcement, the announcement is transmited using Android Intent Extras

extras

You will need to add a second activity two the manifest.

/app/src/main/AndroidManifest.xml
        <activity android:name=".android.activities.AnnouncementActivity" />
/app/src/main/java/intranet/client/android/activities/AnnouncementActivity.java
package intranet.client.android.activities;

import android.app.Activity;
import android.content.Intent;
import android.os.Bundle;
import android.webkit.WebView;
import android.widget.TextView;

import intranet.client.R;

public class AnnouncementActivity extends Activity {

    private TextView tvTitle;
    private WebView wvBody;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_announcement);

        tvTitle = (TextView) findViewById(R.id.tvTitle);
        wvBody = (WebView) findViewById(R.id.wvBody);
        populateUi();
    }

    private void populateUi() {
        Intent intent = getIntent();
        final String title = intent.getStringExtra(MainActivity.EXTRA_TITLE);
        final String body = intent.getStringExtra(MainActivity.EXTRA_BODY);
        populateUiWithTitleAndBody(title, body);
    }

    private void populateUiWithTitleAndBody(final String title, final String body) {
        tvTitle.setText(title);
        final String mime = "text/html";
        final String encoding = "utf-8";
        wvBody.loadDataWithBaseURL(null, body, mime, encoding, null);
    }
}
/app/src/main/res/layout/activity_announcement.xml
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/activity_main"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:paddingBottom="@dimen/activity_vertical_margin"
    android:paddingLeft="@dimen/activity_horizontal_margin"
    android:paddingRight="@dimen/activity_horizontal_margin"
    android:paddingTop="@dimen/activity_vertical_margin"
    tools:context="grails.company.client.intranet.client.MainActivity">

    <TextView
        android:id="@+id/tvTitle"
        tools:text="Announcement Title"
        android:textSize="20dp"
        android:layout_width="match_parent"
        android:layout_height="wrap_content" />

    <WebView
        android:id="@+id/wvBody"
        android:layout_below="@+id/tvTitle"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content" />

</RelativeLayout>

6 API version 2.0

The problem with the first version of the API is that we include every announcement body in the payload used to displayed the list. An announcement’s body can be a large block of HTML. A user will probably just wants to check a couple of announcements. It will save bandwidth and make the app faster if we don’t send the announcement body in the initial request. Instead, we will ask the API for a complete announcement (including body) once the user taps the announcement.

version2overview

6.1 Grails V2 Changes

We are going to use create Controller to handle the version 2 of the API. We are going to use a Criteria query with a projection to fetch only the id and title of the announcements.

grails-app/controllers/intranet/backend/v2/AnnouncementController.groovy
package intranet.backend.v2

import grails.rest.RestfulController
import intranet.backend.Announcement

class AnnouncementController extends RestfulController<Announcement> {
    static namespace = 'v2'
    static responseFormats = ['json']

    def announcementService

    AnnouncementController() {
        super(Announcement)
    }

    def index(Integer max) {
        params.max = Math.min(max ?: 10, 100)
        def announcements = announcementService.findAllIdAndTitleProjections(params)
        respond announcements, model: [("${resourceName}Count".toString()): countResources()]
    }
}

We encapsulate the querying in a service

grails-app/services/intranet/backend/AnnouncementService.groovy
package intranet.backend

import grails.gorm.transactions.Transactional

@Transactional(readOnly = true)
class AnnouncementService {

    List<Map> findAllIdAndTitleProjections(Map params) {
        def c = Announcement.createCriteria()
        def announcements = c.list(params) {
            projections {
                property('id')
                property('title')
            }
        }.collect { [id: it[0], title: it[1]] } as List<Map>
    }
}

and we test it:

/src/test/groovy/intranet/backend/AnnouncementServiceSpec.groovy
package intranet.backend

import grails.test.hibernate.HibernateSpec
import grails.testing.services.ServiceUnitTest

class AnnouncementServiceSpec extends HibernateSpec implements ServiceUnitTest<AnnouncementService> {

    def "test criteria query with projection returns a list of maps"() {

        when: 'Save some announcements'
        [new Announcement(title: 'Grails Quickcast #1: Grails Interceptors'),
        new Announcement(title: 'Grails Quickcast #2: JSON Views'),
        new Announcement(title: 'Grails Quickcast #3: Multi-Project Builds'),
        new Announcement(title: 'Grails Quickcast #4: Angular Scaffolding'),
        new Announcement(title: 'Retrieving Runtime Config Values In Grails 3'),
        new Announcement(title: 'Developing Grails 3 Applications With IntelliJ IDEA')].each {
            it.save()
        }

        then: 'announcements are saved'
        Announcement.count() == 6

        when: 'fetching the projection'
        def resp = service.findAllIdAndTitleProjections([:])

        then: 'there are six maps in the response'
        resp
        resp.size() == 6

        and: 'the maps contain only id and title'
        resp.each {
            it.keySet() == ['title', 'id'] as Set<String>
         }

        and: 'non empty values'
        resp.each {
            assert it.title
            assert it.id
        }

    }
}

Url Mapping

We need to map the version 2.0 of the Accept-Header to the namespace v2

/grails-app/controllers/intranet/backend/UrlMappings.groovy
        get "/announcements"(version:'2.0', controller: 'announcement', namespace:'v2')
        get "/announcements/$id(.$format)?"(version:'2.0', controller: 'announcement', action: 'show', namespace:'v2')

6.2 Api 2.0 Functional tests

We want to test the Api version 2.0 does not include the body property when receiving a GET request to the announcements endpoint. The next functional test verifies that behaviour.

/home/runner/work/building-an-android-client-powered-by-a-grails-backend/building-an-android-client-powered-by-a-grails-backend/complete

/src/integration-test/groovy/intranet/backend/AnnouncementControllerSpec.groovy
package intranet.backend

import grails.testing.mixin.integration.Integration
import grails.testing.spock.OnceBefore
import grails.web.http.HttpHeaders
import io.micronaut.http.HttpRequest
import io.micronaut.http.HttpResponse
import io.micronaut.http.HttpStatus
import io.micronaut.http.client.HttpClient
import spock.lang.AutoCleanup
import spock.lang.Shared
import spock.lang.Specification


@Integration
        resp.headers.getFirst(HttpHeaders.CONTENT_TYPE).get() == 'application/json;charset=UTF-8'

        and: 'json payload contains an array of annoucements with id, title and body'
        resp.body().each {
            assert it.id
            assert it.title
            assert it.body (4)
        }
    }

    def "test body is NOT present in announcements json payload of Api 2.0"() {
        given:
        HttpRequest request = HttpRequest.GET("/announcements/").header("Accept-Version", "2.0")

        when: 'Requesting announcements for version 2.0'
        HttpResponse<List<Map>> resp = client.toBlocking().exchange(request, List)

        then: 'the request was successful'
        resp.status == HttpStatus.OK (3)

        and: 'the response is a JSON Payload'
        resp.headers.getFirst(HttpHeaders.CONTENT_TYPE).get() == 'application/json;charset=UTF-8'
        and: 'json payload contains the complete announcement'
1 Body is not present in the JSON paylod

Grails command test-app runs unit, integration and functional tests.

6.3 Android V2 Changes

First we need to change the api version defined in Constants.java

/app/src/main/java/intranet/client/network/Constants.java
package intranet.client.network;

public class Constants {
    static final String GRAILS_APP_URL = "http://192.168.1.42:8080/";
    static final String ANNOUNCEMENTS_PATH = "announcements";
    static final String ACCEPT_VERSION = "2.0";
}

The detail activity uses an asyncTask to fetch a complete announcement.

/app/src/main/java/intranet/client/android/asynctasks/RetrieveAnnouncementTask.java
package intranet.client.android.asynctasks;

import android.os.AsyncTask;

import intranet.client.android.delegates.RetrieveAnnouncementDelegate;
import intranet.client.network.AnnouncementFetcher;
import intranet.client.network.model.Announcement;

public class RetrieveAnnouncementTask extends AsyncTask<Long, Void, Announcement> {
    private static final String TAG = RetrieveAnnouncementTask.class.getSimpleName();
    AnnouncementFetcher fetcher = new AnnouncementFetcher();
    private RetrieveAnnouncementDelegate delegate;

    public RetrieveAnnouncementTask(RetrieveAnnouncementDelegate delegate) {
        this.delegate = delegate;
    }

    @Override
    protected Announcement doInBackground(Long... ids) {
        if ( ids != null && ids.length >= 1) {
            Long announcementId = ids[0];
            return fetcher.fetchAnnouncement(announcementId);
        }

        return null;
    }

    protected void onPostExecute(Announcement announcement) {
        if ( delegate != null ) {
            delegate.onAnnouncementFetched(announcement);
        }
    }
}

Once we get an announcement we will communicate the response to classes implementing the delegate

/app/src/main/java/intranet/client/android/delegates/RetrieveAnnouncementDelegate.java
package intranet.client.android.delegates;

import intranet.client.network.model.Announcement;

public interface RetrieveAnnouncementDelegate {
    void onAnnouncementFetched(Announcement announcement);
}

The Announcement Activity implements the delegate and renders the announcement.

/app/src/main/java/intranet/client/android/activities/AnnouncementActivity.java
package intranet.client.android.activities;

import android.app.Activity;
import android.content.Intent;
import android.os.Bundle;
import android.webkit.WebView;
import android.widget.TextView;

import intranet.client.R;
import intranet.client.android.asynctasks.RetrieveAnnouncementTask;
import intranet.client.android.delegates.RetrieveAnnouncementDelegate;
import intranet.client.network.model.Announcement;

public class AnnouncementActivity extends Activity implements RetrieveAnnouncementDelegate {

    private TextView tvTitle;
    private WebView wvBody;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_announcement);

        tvTitle = (TextView) findViewById(R.id.tvTitle);
        wvBody = (WebView) findViewById(R.id.wvBody);

        Intent intent = getIntent();
        final Long announcementId = intent.getLongExtra(MainActivity.EXTRA_ID, 0L);
        new RetrieveAnnouncementTask(this).execute(announcementId);
    }

    private void populateUiWithTitleAndBody(final String title, final String body) {
        tvTitle.setText(title);
        final String mime = "text/html";
        final String encoding = "utf-8";
        wvBody.loadDataWithBaseURL(null, body, mime, encoding, null);
    }

    @Override
    public void onAnnouncementFetched(Announcement announcement) {
        populateUiWithTitleAndBody(announcement.getTitle(), announcement.getBody());
    }
}

7 Conclusion

Thanks to Grails ease of API versioning we can now support two Android applications running different versions.

8 Do you need help with Grails?

Object Computing, Inc. (OCI) sponsored the creation of this Guide. A variety of consulting and support services are available.

OCI is Home to Grails

Meet the Team