Forecasting the Weather

The Weather sample module in the Sampler and SamplerJ projects shows a weather forecast for New York City. It gets the forecast from the US National Weather Service, which offers a Web service for retrieving forecasts. The forecast elements that the app uses includes the time, projected temperature, and an icon representing the expected weather (e.g., sunny, partly cloudy, mostly cloudy, rain, snow, fog, alien invasion, kaiju attack, or zombie infestation).

(OK, perhaps not those last three)

The icon is supplied in the form of a URL from which we can download the icon.

To get the weather forecast itself, we will use Retrofit. To populate ImageView widgets in a RecyclerView with the weather icons, we will use Glide.

The Dependencies

Glide is simple. Most of the time, you can just use the com.github.bumptech.glide:glide library:

  implementation 'com.github.bumptech.glide:glide:4.12.0'

Retrofit is a bit more complicated. You will usually need at least two libraries:

The US National Weather Service Web service API supports returning JSON. There are a few JSON converter options for Retrofit, each of which use a different JSON parser. If you are using a JSON parser elsewhere in your app. you can try to use the corresponding Retrofit converter library — that way, you do not wind up bundling two JSON parsing libraries in your app. The three most popular JSON parsers for Android are Gson, Jackson, and Moshi, and there are Retrofit converters for each of those. Moshi happens to be written by Square, the same team that created Retrofit itself, so this sample app uses Moshi:

  implementation "com.squareup.retrofit2:retrofit:2.9.0"
  implementation "com.squareup.retrofit2:converter-moshi:2.9.0"

The Response Classes

The converters that Retrofit supports are designed to take a raw response — such as a JSON string — and convert them into instances of classes that are designed to reflect the response structure. The US National Weather Service has some documentation about their Web service API, and that information can be used to create classes that match the JSON that they will serve.

Usually, your classes can skip portions of the response that you do not care about. Moshi or other JSON parsers will skip anything that appears in the JSON but does not have a corresponding spot in your Java or Kotlin classes.

The JSON that we get back from the Web service looks like this (with the list of forecast periods truncated to save space in the book):

{
    "@context": [
        "https://raw.githubusercontent.com/geojson/geojson-ld/master/contexts/geojson-base.jsonld",
        {
            "wx": "https://api.weather.gov/ontology#",
            "geo": "https://www.opengis.net/ont/geosparql#",
            "unit": "https://codes.wmo.int/common/unit/",
            "@vocab": "https://api.weather.gov/ontology#"
        }
    ],
    "type": "Feature",
    "geometry": {
        "type": "GeometryCollection",
        "geometries": [
            {
                "type": "Point",
                "coordinates": [
                    -74.0130202,
                    40.714515800000001
                ]
            },
            {
                "type": "Polygon",
                "coordinates": [
                    [
                        [
                            -74.025095199999996,
                            40.727052399999998
                        ],
                        [
                            -74.0295579,
                            40.705361699999997
                        ],
                        [
                            -74.000948300000005,
                            40.701977499999998
                        ],
                        [
                            -73.996479800000003,
                            40.723667800000001
                        ],
                        [
                            -74.025095199999996,
                            40.727052399999998
                        ]
                    ]
                ]
            }
        ]
    },
    "properties": {
        "updated": "2019-06-05T19:48:39+00:00",
        "units": "us",
        "forecastGenerator": "BaselineForecastGenerator",
        "generatedAt": "2019-06-05T22:38:08+00:00",
        "updateTime": "2019-06-05T19:48:39+00:00",
        "validTimes": "2019-06-05T13:00:00+00:00/P8D",
        "elevation": {
            "value": 2.1335999999999999,
            "unitCode": "unit:m"
        },
        "periods": [
            {
                "number": 1,
                "name": "Tonight",
                "startTime": "2019-06-05T18:00:00-04:00",
                "endTime": "2019-06-06T06:00:00-04:00",
                "isDaytime": false,
                "temperature": 67,
                "temperatureUnit": "F",
                "temperatureTrend": null,
                "windSpeed": "10 to 14 mph",
                "windDirection": "SW",
                "icon": "https://api.weather.gov/icons/land/night/tsra,80?size=medium",
                "shortForecast": "Showers And Thunderstorms",
                "detailedForecast": "Showers and thunderstorms. Cloudy, with a low around 67. Southwest wind 10 to 14 mph, with gusts as high as 24 mph. Chance of precipitation is 80%. New rainfall amounts between a quarter and half of an inch possible."
            },
            {
                "number": 2,
                "name": "Thursday",
                "startTime": "2019-06-06T06:00:00-04:00",
                "endTime": "2019-06-06T18:00:00-04:00",
                "isDaytime": true,
                "temperature": 83,
                "temperatureUnit": "F",
                "temperatureTrend": null,
                "windSpeed": "10 mph",
                "windDirection": "W",
                "icon": "https://api.weather.gov/icons/land/day/rain_showers,50/rain_showers,20?size=medium",
                "shortForecast": "Chance Rain Showers",
                "detailedForecast": "A chance of rain showers before 3pm. Partly sunny, with a high near 83. West wind around 10 mph. Chance of precipitation is 50%. New rainfall amounts between a tenth and quarter of an inch possible."
            },
            {
                "number": 3,
                "name": "Thursday Night",
                "startTime": "2019-06-06T18:00:00-04:00",
                "endTime": "2019-06-07T06:00:00-04:00",
                "isDaytime": false,
                "temperature": 66,
                "temperatureUnit": "F",
                "temperatureTrend": null,
                "windSpeed": "7 to 10 mph",
                "windDirection": "N",
                "icon": "https://api.weather.gov/icons/land/night/sct?size=medium",
                "shortForecast": "Partly Cloudy",
                "detailedForecast": "Partly cloudy, with a low around 66. North wind 7 to 10 mph."
            }
        ]
    }
}

For the purposes of this app, we only need a small subset of this information. We need the periods list of objects from the properties property of the root JSON object. And, for each period, we need the name, temperature, temperatureUnit, and icon properties. A full-featured US weather app could use a lot more of the properties, but we will keep this simple.

So, we have these classes to model that response in Java:

package com.commonsware.jetpack.weather;

import java.util.List;

public class WeatherResponse {
  public final Properties properties=null;

  public static class Properties {
    public final List<Forecast> periods=null;
  }
}
package com.commonsware.jetpack.weather;

public class Forecast {
  final String name;
  final int temperature;
  final String temperatureUnit;
  final String icon;

  public Forecast(String name, int temperature,
                  String temperatureUnit, String icon) {
    this.name = name;
    this.temperature = temperature;
    this.temperatureUnit = temperatureUnit;
    this.icon = icon;
  }
}

…and Kotlin:

package com.commonsware.jetpack.weather

class WeatherResponse {
  val properties: Properties? = null

  class Properties {
    val periods: List<Forecast>? = null
  }
}
package com.commonsware.jetpack.weather

data class Forecast(
  val name: String,
  val temperature: Int,
  val temperatureUnit: String,
  val icon: String
)

The overall Web service response is a WeatherResponse, which holds a Properties object, which in turn host a List of Forecast objects.

Moshi does not care about the package structure, so long as it can create instances of the class and fill in the fields or properties. Here, we have a mix of top-level classes (WeatherResponse, Forecast) and nested classes (Properties) — you can organize your classes as you see fit.

The Retrofit API Declaration

The next step for using Retrofit is to declare an interface that describes the API that we are invoking on the Web service and maps it to functions that we want to be able to call from our Java/Kotlin code.

In our case, we are only invoking a single Web service URL, where we tell it a location for a forecast, and we get back the corresponding forecast data. So, our interface has only a getForecast() function:

package com.commonsware.jetpack.weather;

import retrofit2.Call;
import retrofit2.http.GET;
import retrofit2.http.Headers;
import retrofit2.http.Path;

public interface NWSInterface {
  @Headers("Accept: application/geo+json")
  @GET("/gridpoints/{office}/{gridX},{gridY}/forecast")
  Call<WeatherResponse> getForecast(@Path("office") String office,
                                    @Path("gridX") int gridX,
                                    @Path("gridY") int gridY);
}
package com.commonsware.jetpack.weather

import retrofit2.http.GET
import retrofit2.http.Headers
import retrofit2.http.Path

interface NWSInterface {
  @Headers("Accept: application/geo+json")
  @GET("/gridpoints/{office}/{gridX},{gridY}/forecast")
  suspend fun getForecast(
    @Path("office") office: String,
    @Path("gridX") gridX: Int,
    @Path("gridY") gridY: Int
  ): WeatherResponse
}

Every Retrofit interface function will have at least one annotation, one that indicates the HTTP operation to perform and a relative path on which to perform it. In our case, that is @GET("/gridpoints/{office}/{gridX},{gridY}/forecast"), saying that we want to perform an HTTP GET operation on… something.

The @GET annotation takes a relative path, where portions of that path can come from parameters to the annotated function. Our three parameters — office, gridX, and gridY — are themselves annotated with @Path. @Path says “this function parameter can be used to help assemble the relative path”. The name given as a parameter to @Path can be used in the relative path, wrapped in braces. Retrofit will then replace that brace-wrapped name with the actual runtime value of the parameter when we call it. So, "/gridpoints/{office}/{gridX},{gridY}/forecast" will turn into something like "/gridpoints/OKX/32,34/forecast", if we call getForecast("OKX", 32, 34).

The US National Weather Service Web service API divides its forecasts into gridded areas served by offices. We are supplying the ID of an office (office) plus the X/Y coordinates of a particular grid cell (gridX and gridY). We will receive a forecast for that particular cell of that specific office. There is a separate Web service URL that gives us the office and cell for a given latitude and longitude. An app that provided weather information for an arbitrary location — such as one that is retrieved from LocationManager and the GPS hardware on the device — would use that URL to get the office and grid, then use the URL associated with getForecast() to get the weather. Here, we will supply the office and cell from values hard-coded in our activity, to help simplify the example.

Retrofit has a variety of additional annotations that you can add as needed. In our case, we have a @Headers annotation to add an HTTP header to our Web service call, indicating that we want a GeoJSON-encoded response.

The Repository

We have a WeatherRepository that is responsible for working with Retrofit and obtaining our weather forecast. As with some of the previous samples, the Java and Kotlin implementations diverge a bit, as Kotlin uses coroutines, while Java returns a LiveData instead.

Java

Our Java implementation of WeatherRepository looks like this:

package com.commonsware.jetpack.weather;

import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import retrofit2.Call;
import retrofit2.Callback;
import retrofit2.Response;
import retrofit2.Retrofit;
import retrofit2.converter.moshi.MoshiConverterFactory;

class WeatherRepository {
  private static volatile WeatherRepository INSTANCE;
  private final NWSInterface api;

  synchronized static WeatherRepository get() {
    if (INSTANCE == null) {
      INSTANCE = new WeatherRepository();
    }

    return INSTANCE;
  }

  private WeatherRepository() {
    Retrofit retrofit=
      new Retrofit.Builder()
        .baseUrl("https://api.weather.gov")
        .addConverterFactory(MoshiConverterFactory.create())
        .build();

    api = retrofit.create(NWSInterface.class);
  }

  LiveData<WeatherResult> load(String office, int gridX, int gridY) {
    final MutableLiveData<WeatherResult> result = new MutableLiveData<>();

    result.setValue(new WeatherResult(true, null, null));

    api.getForecast(office, gridX, gridY).enqueue(
      new Callback<WeatherResponse>() {
        @Override
        public void onResponse(Call<WeatherResponse> call,
                               Response<WeatherResponse> response) {
          result.postValue(new WeatherResult(false, response.body().properties.periods, null));
        }

        @Override
        public void onFailure(Call<WeatherResponse> call, Throwable t) {
          result.postValue(new WeatherResult(false, null, t));
        }
      });

    return result;
  }
}

In the constructor, we get an instance of our NWSInterface from Retrofit. Specifically, we:

Retrofit can handle background threading for us. Our Java implementation of NWSInterface returns our WeatherResponse wrapped in a Call object. The two main methods on Call are execute() (to perform the Web service request synchronously) and enqueue() (to perform the Web service request asynchronously, on a Retrofit-supplied background thread). load() on WeatherRepository uses enqueue(), so Retrofit will handle our threading for us. We need to then supply a Callback to receive either our WeatherResponse or a Throwable if there is some problem (e.g., the Web service is down for maintenance). In our case, we wrap those results in a WeatherResult and update a MutableLiveData with that result. WeatherResult encapsulates an isLoading flag, along with our WeatherResponse and Throwable from the callback:

package com.commonsware.jetpack.weather;

import java.util.List;

public class WeatherResult {
  final boolean isLoading;
  final List<Forecast> forecasts;
  final Throwable error;

  WeatherResult(boolean isLoading, List<Forecast> forecasts, Throwable error) {
    this.isLoading = isLoading;
    this.forecasts = forecasts;
    this.error = error;
  }
}

Hence, if we call load() on our WeatherRepository singleton, we will get a LiveData that we can observe, where it will give us our WeatherResult as we progress from the loading state to either the success or failure states.

Kotlin

The Kotlin code is far simpler, because Retrofit (as of 2.6.0) has built-in support for coroutines. So, our Kotlin NWSInterface returns a WeatherResponse directly (without the Call wrapper), and its getForecast() is marked with the suspend keyword. So, our WeatherRepository can just have its own suspend function that delegates to Retrofit and converts the WeatherResponse or caught exception into WeatherResult objects:

package com.commonsware.jetpack.weather

import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory

object WeatherRepository {
  private val api = Retrofit.Builder()
    .baseUrl("https://api.weather.gov")
    .addConverterFactory(MoshiConverterFactory.create())
    .build()
    .create(NWSInterface::class.java)

  suspend fun load(office: String, gridX: Int, gridY: Int) = try {
    val response = api.getForecast(office, gridX, gridY)

    WeatherResult.Content(response.properties?.periods ?: listOf())
  } catch (t: Throwable) {
    WeatherResult.Error(t)
  }
}

In our case, then, we only have Content and Error states — load() returns the end result of the API call, so it has no opportunity to return a Loading state:

package com.commonsware.jetpack.weather

sealed class WeatherResult {
  data class Content(val forecasts: List<Forecast>) : WeatherResult()
  data class Error(val throwable: Throwable) : WeatherResult()
}

The Motor and the View States

The Forecast objects in our WeatherResult have an integer temperature value plus a temperatureUnit string (e.g., F for Fahrenheit, C for Celsius). For display purposes, it would be nice to convert those into a single string, one that we can data bind into a TextView.

So, our MainMotor implementations will have a LiveData of MainViewState objects. MainViewState, in turn, will have a List of RowState objects, where we have a single property for the visual representation of the temperature. MainMotor will get the WeatherResult from the Web service and convert it into a MainViewState object as they come in.

Java

MainViewState and RowState look a lot like WeatherResult and Forecast, just with the single temperature field:

package com.commonsware.jetpack.weather;

import java.util.List;

class MainViewState {
  final boolean isLoading;
  final List<RowState> forecasts;
  final Throwable error;

  MainViewState(boolean isLoading, List<RowState> forecasts, Throwable error) {
    this.isLoading = isLoading;
    this.forecasts = forecasts;
    this.error = error;
  }
}
package com.commonsware.jetpack.weather;

public class RowState {
  public final String name;
  public final String temp;
  public final String icon;

  RowState(String name, String temp, String icon) {
    this.name = name;
    this.temp = temp;
    this.icon = icon;
  }
}

As with some of the previous examples, MainMotor uses MediatorLiveData, to fold one or more load() calls into a single results field that contains our LiveData of MainViewState objects:

package com.commonsware.jetpack.weather;

import android.app.Application;
import java.util.ArrayList;
import androidx.annotation.NonNull;
import androidx.lifecycle.AndroidViewModel;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MediatorLiveData;

public class MainMotor extends AndroidViewModel {
  final private WeatherRepository repo = WeatherRepository.get();
  final MediatorLiveData<MainViewState> results = new MediatorLiveData<>();
  private LiveData<WeatherResult> lastResult;

  public MainMotor(@NonNull Application application) {
    super(application);
  }

  void load(String office, int gridX, int gridY) {
    if (lastResult != null) {
      results.removeSource(lastResult);
    }

    lastResult = repo.load(office, gridX, gridY);
    results.addSource(lastResult, weather -> {
      ArrayList<RowState> rows = new ArrayList<>();

      if (weather.forecasts != null) {
        for (Forecast forecast : weather.forecasts) {
          String temp =
            getApplication().getString(R.string.temp, forecast.temperature,
              forecast.temperatureUnit);

          rows.add(new RowState(forecast.name, temp, forecast.icon));
        }
      }

      results.postValue(
        new MainViewState(weather.isLoading, rows, weather.error));
    });
  }
}

To create the temperature string, we use a string resource that contains placeholders for the number and unit:

  <string name="temp">%d%s</string>

Then, we use getString() on a Context to retrieve that string resource and fill in those placeholders with our desired values.

Kotlin

The Kotlin implementation is similar, just using the MutableLiveData and viewModelScope approach that we saw in previous examples:

package com.commonsware.jetpack.weather

sealed class MainViewState {
  object Loading : MainViewState()
  data class Content(val forecasts: List<RowState>) : MainViewState()
  data class Error(val throwable: Throwable) : MainViewState()
}
package com.commonsware.jetpack.weather

data class RowState(
  val name: String,
  val temp: String,
  val icon: String
)
package com.commonsware.jetpack.weather

import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch

class MainMotor(application: Application) : AndroidViewModel(application) {
  private val _results = MutableLiveData<MainViewState>()
  val results: LiveData<MainViewState> = _results

  fun load(office: String, gridX: Int, gridY: Int) {
    _results.value = MainViewState.Loading

    viewModelScope.launch(Dispatchers.Main) {
      val result = WeatherRepository.load(office, gridX, gridY)

      _results.value = when (result) {
        is WeatherResult.Content -> {
          val rows = result.forecasts.map { forecast ->
            val temp = getApplication<Application>()
              .getString(
                R.string.temp,
                forecast.temperature,
                forecast.temperatureUnit
              )

            RowState(forecast.name, temp, forecast.icon)
          }

          MainViewState.Content(rows)
        }
        is WeatherResult.Error -> MainViewState.Error(result.throwable)
      }
    }
  }
}

The Image Loading

Eventually, our List of RowState objects makes it over to a WeatherAdapter. This is a ListAdapter that we use to fill in a RecyclerView that will show the list of forecasts.

We use data binding for the rows, where our row.xml layout has binding expressions to populate its widgets from a RowState:

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
  xmlns:app="http://schemas.android.com/apk/res-auto"
  xmlns:tools="http://schemas.android.com/tools">

  <data>

    <variable
      name="state"
      type="com.commonsware.jetpack.weather.RowState" />
  </data>

  <androidx.constraintlayout.widget.ConstraintLayout
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:paddingBottom="8dp"
    android:paddingTop="8dp">

    <TextView
      android:id="@+id/name"
      android:layout_width="wrap_content"
      android:layout_height="wrap_content"
      android:text="@{state.name}"
      android:textAppearance="@style/TextAppearance.AppCompat.Large"
      app:layout_constraintBottom_toBottomOf="@id/icon"
      app:layout_constraintEnd_toStartOf="@id/temp"
      app:layout_constraintStart_toStartOf="parent"
      app:layout_constraintTop_toTopOf="@id/icon"
      tools:text="Tonight" />

    <ImageView
      android:id="@+id/icon"
      android:layout_width="0dp"
      android:layout_height="64dp"
      android:contentDescription="@string/icon"
      app:imageUrl="@{state.icon}"
      app:layout_constraintDimensionRatio="1:1"
      app:layout_constraintEnd_toEndOf="parent"
      app:layout_constraintTop_toTopOf="parent" />

    <TextView
      android:id="@+id/temp"
      android:layout_width="wrap_content"
      android:layout_height="wrap_content"
      android:layout_marginEnd="16dp"
      android:layout_marginStart="16dp"
      android:text="@{state.temp}"
      android:textAppearance="@style/TextAppearance.AppCompat.Large"
      app:layout_constraintBottom_toBottomOf="@id/icon"
      app:layout_constraintEnd_toStartOf="@+id/icon"
      app:layout_constraintTop_toTopOf="@id/icon"
      tools:text="72F" />

  </androidx.constraintlayout.widget.ConstraintLayout>
</layout>

In particular, our ImageView for the weather icon uses app:imageUrl="@{state.icon}" to pull the icon property out of the RowState and apply it to the ImageView. ImageView does not have an app:imageUrl attribute, though — we are using a BindingAdapter that in turn uses Glide to load the image:

package com.commonsware.jetpack.weather;

import android.widget.ImageView;
import com.bumptech.glide.Glide;
import com.bumptech.glide.load.model.GlideUrl;
import java.util.HashMap;
import java.util.Map;
import androidx.databinding.BindingAdapter;

public class BindingAdapters {
  private static final Map<String, String> HEADERS = new HashMap<>();

  static {
    HEADERS.put("User-Agent", "me");
  }

  @BindingAdapter("imageUrl")
  public static void loadImage(ImageView view, String url) {
    if (url != null) {
      Glide.with(view.getContext())
        .load(new GlideUrl(url, () -> HEADERS))
        .into(view);
    }
  }
}
package com.commonsware.jetpack.weather

import android.widget.ImageView
import androidx.databinding.BindingAdapter
import com.bumptech.glide.Glide
import com.bumptech.glide.load.model.GlideUrl

private val HEADERS = mapOf("User-Agent" to "me")

@BindingAdapter("imageUrl")
fun ImageView.loadImage(url: String?) {
  url?.let {
    Glide.with(context)
      .load(GlideUrl(it) { HEADERS })
      .into(this)
  }
}

For simple cases like this one, Glide has a really simple API:

And that’s it. Glide will handle doing the network I/O and populating the ImageView with the resulting image, using a background thread for the I/O work. It also handles recycling — if we call into() with an ImageView that already has an outstanding request, Glide will cancel the old request for us automatically.

Ordinarily, you can call load() with just the URL. Unfortunately, the US National Weather Service does not like the default Glide user agent header, so we have to call another version of load() that takes a GlideUrl parameter. A GlideUrl wraps up the details of an HTTPS request, including a custom set of headers to blend into the request.

The Results

Our MainActivity starts all of this off by calling load() on the MainMotor:

    motor.load("OKX", 32, 34)

Here, OKX, 32, and 34 were determined by manually invoking another Web service URL, providing the latitude and longitude for One World Trade Center in lower Manhattan.

MainActivity observes the LiveData from the motor, and for successful Web service calls forwards the List of RowState objects to the WeatherAdapter that it created and attached to a RecyclerView.

If you run the app, you should see an upcoming forecast for New York City:

Weather Sample App, As Initially Launched
Weather Sample App, As Initially Launched

Here, we see that there will be a 20% chance of rain today, then clear skies for the next few days, with no signs of kaiju.

(then again, kaiju can be sneaky…)


Prev Table of Contents Next

This book is licensed under the Creative Commons Attribution-ShareAlike 4.0 International license.