Android Retrofit OkHttp Offline Caching

Filed Under: Android

In this tutorial, we’ll be discussing and implementing Offline caching in our android application.
We’ll be using Retrofit and Okhttp libraries.

Offline Caching

Opening your application with no internet and seeing no previous data is a very common occurrence.
Two ways to deal with loading network requests that come first to our minds are:

  • Shared Preferences
  • SQLite

Using either of them comes with its fair share of cons (and more code to write).

While Adding Data in SharedPreferences is easy. It’s time-consuming to retrieve the required data. Plus scalability is an issue.

SQLite with tables makes it hard to do many changes in the future. Plus SQLite operations are heavy.

Why use both when OkHtttp caches the HTTP responses built-in?

Caching Requests

We know that OkHttp is the default HttpClient for Retrofit.
OkHttp comes with a powerful component Interceptors.

Interceptors are generally of two types:

  • Application Interceptors – Gets you the final response.
  • Network Interceptors – To intercept intermediate requests.

Using Interceptors you can read and modify the requests. And obviously, we’ll add Cache control on the responses.

Cache-control is an header used to specify caching policies in client requests and server responses.

You cannot cache POST requests. Only GET requests can be cached.

Inside the interceptors, you need to get chain.request() to get the current request and add Cache options to it.

For example, we can add a header “Cache-Control” to the request as: "public, only-if-cached, max-stale=60"

Then do a chain.proceed(request) to proceed with the modified request to return the response.

max-age vs max-stale

max-age is the oldest limit ( lower limit) till which the response can be returned from the cache.
max-stale is the highest limit beyond which cache cannot be returned.

In the following section, we’ll do a Retrofit Request with OkHttp as the Client and using RxJava.
We’ll cache the requests such that they can be displayed the next time if there is no internet/problem in getting the latest request.

Project Structure

android retrofit offline caching project

The app‘s build.gradle is given below:


implementation('com.squareup.retrofit2:retrofit:2.1.0') {
        exclude module: 'okhttp'
    }
    implementation 'com.google.code.gson:gson:2.8.2'
    implementation 'com.squareup.retrofit2:converter-gson:2.3.0'
    implementation 'com.squareup.okhttp3:logging-interceptor:3.9.1'
    implementation 'com.squareup.okhttp3:okhttp:3.10.0'

    implementation 'io.reactivex.rxjava2:rxjava:2.1.9'
    implementation 'com.squareup.retrofit2:adapter-rxjava2:2.3.0'
    implementation 'io.reactivex.rxjava2:rxandroid:2.0.1'


Code

The code for the activity_main.xml layout is given below:


<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout 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"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <TextView
        android:id="@+id/textView"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Hello World! See this space for jokes"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent" />


    <Button
        android:id="@+id/button"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="16dp"
        android:text="GET RANDOM JOKE"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/textView" />


</android.support.constraint.ConstraintLayout>

The code for the APIService.java is given below:


package com.journaldev.androidretrofitofflinecaching;

import io.reactivex.Observable;
import retrofit2.http.GET;
import retrofit2.http.Path;

public interface APIService {


    String BASE_URL = "https://api.chucknorris.io/jokes/";

    @GET("{path}")
    Observable<Jokes> getRandomJoke(@Path("path") String path);
}


The code for the Jokes.java model class is given below:


package com.journaldev.androidretrofitofflinecaching;

import com.google.gson.annotations.SerializedName;

public class Jokes {


    @SerializedName("url")
    public String url;
    @SerializedName("icon_url")
    public String icon_url;
    @SerializedName("value")
    public String value;
}


The code for the MainActivity.java is given below:


package com.journaldev.androidretrofitofflinecaching;

import android.support.v7.app.AppCompatActivity;
import android.os.Bundle;
import android.view.View;
import android.widget.Button;
import android.widget.TextView;
import android.widget.Toast;

import com.google.gson.Gson;

import java.io.File;
import java.io.IOException;
import java.util.concurrent.TimeUnit;

import io.reactivex.Observable;
import io.reactivex.Observer;
import io.reactivex.android.schedulers.AndroidSchedulers;
import io.reactivex.disposables.Disposable;
import io.reactivex.functions.Function;
import io.reactivex.schedulers.Schedulers;
import okhttp3.Cache;
import okhttp3.CacheControl;
import okhttp3.Interceptor;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
import okhttp3.logging.HttpLoggingInterceptor;
import retrofit2.Retrofit;
import retrofit2.adapter.rxjava2.RxJava2CallAdapterFactory;
import retrofit2.converter.gson.GsonConverterFactory;

import static com.journaldev.androidretrofitofflinecaching.APIService.BASE_URL;

public class MainActivity extends AppCompatActivity {

    TextView textView;
    Button btnGetRandomJoke;

    APIService apiService;

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

        textView = findViewById(R.id.textView);
        btnGetRandomJoke = findViewById(R.id.button);

        setupRetrofitAndOkHttp();

        btnGetRandomJoke.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View view) {
                getRandomJokeFromAPI();

            }
        });

    }

    private void setupRetrofitAndOkHttp() {

        HttpLoggingInterceptor httpLoggingInterceptor = new HttpLoggingInterceptor();
        httpLoggingInterceptor.setLevel(HttpLoggingInterceptor.Level.BODY);

        File httpCacheDirectory = new File(getCacheDir(), "offlineCache");

        //10 MB
        Cache cache = new Cache(httpCacheDirectory, 10 * 1024 * 1024);

        OkHttpClient httpClient = new OkHttpClient.Builder()
                .cache(cache)
                .addInterceptor(httpLoggingInterceptor)
                .addNetworkInterceptor(provideCacheInterceptor())
                .addInterceptor(provideOfflineCacheInterceptor())
                .build();

        Retrofit retrofit = new Retrofit.Builder()
                .addCallAdapterFactory(RxJava2CallAdapterFactory.create())
                .addConverterFactory(GsonConverterFactory.create(new Gson()))
                .client(httpClient)
                .baseUrl(BASE_URL)
                .build();

        apiService = retrofit.create(APIService.class);

    }

    public void getRandomJokeFromAPI() {
        Observable<Jokes> observable = apiService.getRandomJoke("random");
        observable.subscribeOn(Schedulers.newThread()).
                observeOn(AndroidSchedulers.mainThread())
                .map(new Function<Jokes, String>() {
                    @Override
                    public String apply(Jokes jokes) throws Exception {
                        return jokes.value;
                    }
                }).subscribe(new Observer<String>() {
            @Override
            public void onSubscribe(Disposable d) {
            }

            @Override
            public void onNext(String s) {
                textView.setText(s);
            }

            @Override
            public void onError(Throwable e) {
                Toast.makeText(getApplicationContext(), "An error occurred in the Retrofit request. Perhaps no response/cache", Toast.LENGTH_SHORT).show();
            }

            @Override
            public void onComplete() {

            }
        });

    }

    private Interceptor provideCacheInterceptor() {
        
        return new Interceptor() {
            @Override
            public Response intercept(Chain chain) throws IOException {
                Request request = chain.request();
                Response originalResponse = chain.proceed(request);
                String cacheControl = originalResponse.header("Cache-Control");

                if (cacheControl == null || cacheControl.contains("no-store") || cacheControl.contains("no-cache") ||
                        cacheControl.contains("must-revalidate") || cacheControl.contains("max-stale=0")) {


                    CacheControl cc = new CacheControl.Builder()
                            .maxStale(1, TimeUnit.DAYS)
                            .build();



                    request = request.newBuilder()
                            .cacheControl(cc)
                            .build();

                    return chain.proceed(request);

                } else {
                    return originalResponse;
                }
            }
        };

    }


    private Interceptor provideOfflineCacheInterceptor() {
        
        return new Interceptor() {
            @Override
            public Response intercept(Chain chain) throws IOException {
                try {
                    return chain.proceed(chain.request());
                } catch (Exception e) {


                    CacheControl cacheControl = new CacheControl.Builder()
                            .onlyIfCached()
                            .maxStale(1, TimeUnit.DAYS)
                            .build();

                    Request offlineRequest = chain.request().newBuilder()
                            .cacheControl(cacheControl)
                            .build();
                    return chain.proceed(offlineRequest);
                }
            }
        };
    }
}

The order of the interceptors in the OkHttpClient Builder is important.
The addNetworkInterceptor adds the cache control to the request.

In the addInterceptor, provideOfflineCacheInterceptor is called. If there is an exception which would typically be a ConnectException or a NoRouteFoundException, the request is retried again, this time with a header to get the response from the Cache.

Alternatively, you can set the cache in the header as:


return originalResponse.newBuilder()
                            .header("Cache-Control", "public, max-stale=" + 60 * 60 * 24)
                            .build();

It’s a good practice to add removeHeader("Pragma").

The output of the above application in action is given below:

android retrofit offline caching output

So we’ve disabled the wifi on our device after the first Retrofit Request.
And we are still able to load the response from the Http Cache.

This brings an end to this tutorial. You can download the project from the link below:

Comments

  1. SadBa says:

    Hello. Thanks for the tutorial. I tried to implement caching method in a recyclerView with a list of values. But the caching method does not work. Can you help?

    1. Anupam says:

      Can you log and paste the headers here?

      1. SadBa says:

        I got this “http 504 unsatisfied request retrofit””
        I can share my code if you want

        1. Anupam says:

          504 means that the interceptor needs to use your cache in the request but it did not find any cache.

          Make sure removeHeader(“pragma”) is set.
          The request type isn’t POST.

Leave a Reply

Your email address will not be published. Required fields are marked *

close
Generic selectors
Exact matches only
Search in title
Search in content
Search in posts
Search in pages