Search code examples
androidkotlincookiesretrofitdagger-hilt

Using Cookies with Retrofit and Hilt and recommended architecture


I'm fairly new to Android and Java / Kotlin so I've been struggling to implement cookies in the recommended architecture. I looked in many places, read the documentation and watched many videos and everyone had such different ways to implement things that I was still confused. How does it all fit together?


Solution

  • I would have thought this was such a common use case that I can't believe the answer isn't all over the net, but I've had to work hard to put all the pieces together. Below is what worked for me from the Repository down. I haven't included the database side of things since that is well documented in many places and I found it easy enough to follow (if anyone needs me to include that, let me know). I switched to Kotlin part way through because I could only find some parts of the answer in Java. My example is to log in a user and get basic profile details.

    Repository sends login details to server and saves response in database then pulls that info to save as LiveData

    package com.example.myapplication
    
    import androidx.lifecycle.LiveData
    import androidx.lifecycle.MutableLiveData
    import com.example.myapplication.*
    import com.example.myapplication.asDomainModel
    import com.example.myapplication.asDBEntity
    import kotlinx.coroutines.Dispatchers
    import kotlinx.coroutines.withContext
    import java.io.IOException
    import javax.inject.Inject
    
    class LoginRepository @Inject constructor(
        private val myDao: MyDao,
        private val myNetwork: Network
    ) {
    
        private val _profile: MutableLiveData<Profile> = MutableLiveData()
        val profile: LiveData<Profile>
            get() = _profile
    
        suspend fun login(name: String, password: String) {
    
            withContext(Dispatchers.IO) {
    
                // log in to server and get profile data
                val profileNWEntity = myNetwork.login("login", name, password)
    
                // process response
                when (profileNWEntity.status) {
                    "PROFLOGINOK" -> {
                        // save profile in database then retrieve
                        myDao.insertProfile(profileNWEntity.asDBEntity())
                        _profile.postValue(myDao.getProfile(profileNWEntity.user).asDomainModel())
                    }
                    else -> {
                        throw IOException (profileNWEntity.status)
                    }
                }
            }
        }
    }
    

    Retrofit endpoint defines the login process

    package com.example.myapplication
    
    import com.example.myapplication.ProfileNWEntity
    import retrofit2.http.Field
    import retrofit2.http.FormUrlEncoded
    import retrofit2.http.POST
    
    interface Network  {
    
        @FormUrlEncoded
        @POST("server_api")
        suspend fun login(
            @Field("action") action: String,
            @Field("name") name: String,
            @Field("pass") password: String
        ): ProfileNWEntity
    }
    

    Entity - used by Gson to parse the network response and by the repository to adapt for the database

    package com.example.myapplication
    
    import com.example.myapplication.AccountDBEntity
    import com.example.myapplication.ProfileDBEntity
    
    /**
     * Base profile response from network query
     */
    data class ProfileNWEntity(
        val user: Int,
        val name: String,
        val status: String
    )
    
    // map the profile from network to database format
    fun ProfileNWEntity.asDBEntity(): ProfileDBEntity {
        return ProfileDBEntity(
            id = user,
            name = name
        )
    }
    

    Retrofit class to enable inclusion of cookies (together with the interceptors included below, this comes from the work of tsuharesu and Nikhil Jha found at https://gist.github.com/nikhiljha/52d45ca69a8415c6990d2a63f61184ff)

    package com.example.myapplication
    
    import android.content.Context
    import dagger.hilt.android.qualifiers.ApplicationContext
    import okhttp3.OkHttpClient
    import retrofit2.Retrofit
    import retrofit2.converter.gson.GsonConverterFactory
    import javax.inject.Inject
    
    class RetrofitWithCookie @Inject constructor(
        context: Context, // uses Hilt to inject the context to be passed to the interceptors
        gson: Gson
    ) {
        private val mContext = context
        private val gson = gson
    
        fun createRetrofit(): Retrofit {
            val client: OkHttpClient
            val builder = OkHttpClient.Builder()
            builder.addInterceptor(AddCookiesInterceptor(mContext)) // VERY VERY IMPORTANT
            builder.addInterceptor(ReceivedCookiesInterceptor(mContext)) // VERY VERY IMPORTANT
            client = builder.build()
    
            return Retrofit.Builder()
                .baseUrl("myServer URL") // REQUIRED
                .client(client) // VERY VERY IMPORTANT
                .addConverterFactory(GsonConverterFactory.create(gson))
                .build() // REQUIRED
        }
    }
    

    Receiving Interceptor catches the inbound cookies and saves them in sharedpreferences

    package com.example.myapplication
    
    import android.content.Context
    import androidx.preference.PreferenceManager
    import okhttp3.Interceptor
    import okhttp3.Response
    import java.io.IOException
    import java.util.*
    
    // Original written by tsuharesu
    // Adapted to create a "drop it in and watch it work" approach by Nikhil Jha.
    // Just add your package statement and drop it in the folder with all your other classes.
    class ReceivedCookiesInterceptor(context: Context?) : Interceptor {
        private val context: Context?
        @Throws(IOException::class)
        override fun intercept(chain: Interceptor.Chain): Response {
            val originalResponse = chain.proceed(chain.request())
            if (!originalResponse.headers("Set-Cookie").isEmpty()) {
                val cookies = PreferenceManager.getDefaultSharedPreferences(context)
                    .getStringSet("PREF_COOKIES", HashSet()) as HashSet<String>?
                for (header in originalResponse.headers("Set-Cookie")) {
                    cookies!!.add(header)
                }
                val memes = PreferenceManager.getDefaultSharedPreferences(context).edit()
                memes.putStringSet("PREF_COOKIES", cookies).apply()
                memes.commit()
            }
            return originalResponse
        }
    
        init {
            this.context = context
        } // AddCookiesInterceptor()
    }
    

    AddCookies interceptor adds the cookie back into future requests

    package com.example.myapplication
    
    import android.content.Context
    import androidx.preference.PreferenceManager
    import dagger.hilt.android.qualifiers.ActivityContext
    import okhttp3.Interceptor
    import okhttp3.Response
    import timber.log.Timber
    import java.io.IOException
    import java.util.*
    
    // Original written by tsuharesu
    // Adapted to create a "drop it in and watch it work" approach by Nikhil Jha.
    // Just add your package statement and drop it in the folder with all your other classes.
    /**
     * This interceptor put all the Cookies in Preferences in the Request.
     * Your implementation on how to get the Preferences may ary, but this will work 99% of the time.
     */
    class AddCookiesInterceptor(@ActivityContext context: Context?) : Interceptor {
        // We're storing our stuff in a database made just for cookies called PREF_COOKIES.
        // I reccomend you do this, and don't change this default value.
        private val context: Context?
        @Throws(IOException::class)
        override fun intercept(chain: Interceptor.Chain): Response {
            val builder = chain.request().newBuilder()
            val preferences = PreferenceManager.getDefaultSharedPreferences(context).getStringSet(
                PREF_COOKIES, HashSet()
            ) as HashSet<String>?
    
            // Use the following if you need everything in one line.
            // Some APIs die if you do it differently.
            /*String cookiestring = "";
            for (String cookie : preferences) {
                String[] parser = cookie.split(";");
                cookiestring = cookiestring + parser[0] + "; ";
            }
            builder.addHeader("Cookie", cookiestring);
            */for (cookie in preferences!!) {
                builder.addHeader("Cookie", cookie)
                Timber.d("adding cookie %s", cookie)
            }
            return chain.proceed(builder.build())
        }
    
        companion object {
            const val PREF_COOKIES = "PREF_COOKIES"
        }
    
        init {
            this.context = context
        }
    }
    

    Hilt Module to tie it all together

    package com.example.myapplication
    
    import android.content.Context
    import com.example.myapplication.Network
    import com.google.gson.Gson
    import com.google.gson.GsonBuilder
    import dagger.Module
    import dagger.Provides
    import dagger.hilt.InstallIn
    import dagger.hilt.android.qualifiers.ApplicationContext
    import dagger.hilt.components.SingletonComponent
    import retrofit2.Retrofit
    import retrofit2.converter.gson.GsonConverterFactory
    import javax.inject.Singleton
    
    @InstallIn(SingletonComponent::class)
    @Module
    class NetworkModule {
    
        @Singleton
        @Provides
        fun provideNetwork(retrofit: Retrofit)
            : Network = retrofit.create(Network::class.java)
    
        @Singleton
        @Provides
        fun provideRetrofitWithCookie(
            @ApplicationContext context: Context,
            gson: Gson
        ): Retrofit = RetrofitWithCookie(context, gson).createRetrofit()
    
        @Singleton
        @Provides
        fun provideGson(): Gson = GsonBuilder()
            .setDateFormat("yyyy-MM-dd'T'HH:mm:ssZ") // used for parsing other responses
            .create()
    }