Business
blog image

Supercharge Your Android App with Offline Capability Using Supabase and Room

This image shows how supabase & android works together.
Android + Supabase

In today’s mobile landscape, providing offline capability in Android apps has become essential for delivering a seamless user experience. However, implementing offline functionality comes with its own set of challenges, including data synchronization and storage management. This is where Supabase, a powerful backend platform, and Room, a local database solution for Android apps, come into play.

Problem Statement and Importance of Offline Persistence

While the backend like Supabase offers powerful features, it lacks built-in offline persistence for now. This presents a challenge for developers who want to provide a seamless user experience in Android apps, especially when network connectivity is unreliable. Without offline support, users encounter issues such as:

  • Limited Functionality: App features that rely on data access become unavailable offline.
  • Frustrating User Experience: Users are forced to wait for a network connection to complete tasks or access information.
  • Data Loss Potential: If users make changes while offline, these changes might not be saved or synchronized properly upon reconnection.

There were existing solutions such as PowerSync, which offers a similar offline first solution with synchronization functionalities but at a cost. These paid solutions often provide comprehensive features but may not always align perfectly with specific project requirements or budget constraints. The motivation behind creating this new library was to provide a more accessible, tailor-made solution that aligns closely with the specific needs of developers using Supabase and Android. By developing a custom solution, it was possible to ensure that no unnecessary overhead or irrelevant functionalities were included, focusing solely on the necessary features that provide the most value for mobile applications requiring offline capabilities.

Introducing the Solution: Supabase Offline Support Library

Enter the Supabase Offline Support Library, a solution designed to simplify the implementation of offline capability in Android apps. This library leverages the strengths of Supabase as a flexible and scalable backend platform and Room as a robust local database framework to provide seamless offline functionality for native Android apps.

Understanding the Architecture

The architecture of the Supabase Offline Support Library is designed to be flexible, scalable, and easy to integrate into any Android app. Key components of the library include:

Class Diagram of Library integrated into Todo list sample
Class Diagram of Library integrated into Todo list sample

1. BaseDataSyncer

  • Purpose: This abstract class serves as the foundation and contains the abstract method syncToSupabase which must be implemented by any subclass to provide a data synchronization strategy/algorithm that must occur between the local database and remote Supabase database keeping data conflict in mind.
  • Usage: Extend this class to customize the synchronization logic as per the application’s specific data requirements.

1abstract class BaseDataSyncer(private val supabaseClient: 
2    /**
3     * Syncs the local table with the provided remote table
4     *
5     * Applies the synchronization algorithm uses Last-write wins Conflict resolution strategy
6     *
7     * @param T The type of the local entity, should be subclass of BaseSyncableEntity
8     * @param RDto The type of the remote entity, should be subclass of BaseRemoteEntity
9     * @param localTable The name of the local table needs to by synced
10     * @param localDao  Dao of that local table required to perform CRUD operations
11     * @param remoteTable The name of the remote table needs to be synced
12     * @param toMap A function that converts the remote DTO/Entity to the local DTO/Entity
13     * @param toMapWithoutLocal A function that converts the local DTO/Entity to the remote DTO/Entity
14     * @param serializer Serializer of remote entity required to perform decoding logic on Json of the entity received
15     * @param currentTimeStamp The current timestamp in milliseconds required to perform synchronization logic
16     * */
17    abstract suspend fun <T: BaseSyncableEntity,RDto: BaseRemoteEntity> syncToSupabase(
18        localTable: String,
19        localDao: GenericDao<T>,
20        remoteTable: String,
21        toMap: (RDto) -> T,
22        toMapWithoutLocal: (T) -> RDto,
23        serializer: KSerializer<RDto>,
24        currentTimeStamp: Long
25    )
26
27}

2. BaseRemoteEntity

  • Purpose: Acts as the base class for any data transfer object (DTO) or class that represents an entity corresponding to a remote table in Supabase. This class ensures that all remote entities follow a consistent structure and remote table consists necessary columns required to work with SyncManager.
  • Usage: Include the properties “id”, and “timestamp” as columns in a remote table then extend this class when creating classes that map directly to tables in the Supabase database, ensuring they can be easily managed and synchronized.

1/**
2 * Base class that needs to be extends by all remote DTOs/Entities that needs to participate in synchronization
3 * 
4 * @property id Primary key for remote table
5 * @property lastUpdatedTimestamp Contains milliseconds as timestamp to compare
6 * with local row's timestamp in case of conflict and perform synchronization
7 * 
8 * */
9@Serializable
10abstract class BaseRemoteEntity {
11    abstract val id: Int
12    abstract val lastUpdatedTimestamp: Long
13}

For example, if we take this task table in Supabase whose timestamp column represents lastUpdatedTimestamp and id represents id in DTO

Task table in supabase

1/**
2 * TaskDto represents the task table in supabase 
3 * @SerialName tag is used to map the column name to the property name
4 * We need to assign lastUpdatedTimestamp the SerialName "timestamp" as 
5 * it is hardcoded in SyncManager
6 * */
7@Serializable
8data class TaskDto(
9    override val id: Int,
10    val name: String,
11    val category_id: Int,
12    val emoji: String,
13    @SerialName("is_complete") val isComplete: Boolean,
14    val date: String,
15    val priority_id: Int,
16    @SerialName("timestamp")override var lastUpdatedTimestamp: Long,
17    @SerialName("is_delete") val isDelete: Boolean
18): BaseRemoteEntity()

Note: @SerialName(“timestamp”) is a must on the property lastUpdatedTimestamp and also the column named “timestamp” of type bigint / int8 , “id” of bigint/ int8 column as a primary key is required in Supabase table to work with SyncManager

3. BaseSyncableEntity

  • Purpose: This abstract class is intended for entities stored in the local Room database, which need to synchronize with Supabase and provides properties for all entities that are required for synchronization.
  • Usage: When a user is offline and performs CRUD operation on the table then OfflineFieldOpType (int field) determines which operation was during the offline state which is used when the device gets online and synchronization is performed, it should have the following values: (0 for no changes, 1 for insertion, 2 for update, 3 for delete).

1/**
2 * Base class for all local entities
3 *
4 * Local Entity needs to extend this class in order to work with syncToSupabase() function of SyncManager
5 *
6 * @property id: Acts as Primary key for local table
7 * @property lastUpdatedTimestamp: Contains milliseconds as timestamp to compare
8 * with remote row's timestamp in case of conflict and perform synchronization
9 * @property offlineFieldOpType: Should be passed any 4 predefined int values to determine state of record
10 * on which CRUD operation was performed when device was offline
11 * 0 for no changes,
12 * 1 for new insertion,
13 * 2 for updation on row,
14 * 3 for deletion
15 * */
16
17abstract class BaseSyncableEntity {
18    abstract val id: Int
19    abstract var lastUpdatedTimestamp: Long
20    abstract val offlineFieldOpType: Int
21}
22

4. GenericDao

  • Purpose: Provides a set of generic CRUD (Create, Read, Update, Delete) methods that any DAO (Data Access Object) of a Room entity must implement to function with the SyncManager for data synchronization.
    This class uses @RawQuery annotation to use dynamic table names at runtime which is not possible with @Query
  • Usage: Extend this interface in your DAO implementations to provide essential data manipulation methods that are used by the library to perform synchronization tasks.

1interface GenericDao<T> {
2
3    @RawQuery
4    suspend fun query(query: SupportSQLiteQuery): List<T>
5
6    @Update
7    suspend fun update(entity: T): Int
8
9    @RawQuery
10    suspend fun update(query: SupportSQLiteQuery):Int
11
12    @Delete
13    suspend fun delete(entity: T): Int
14
15    @Insert
16    suspend fun insert(entity: T): Long
17
18    @RawQuery
19    suspend fun delete(query: SupportSQLiteQuery): Int
20
21
22}

5. SyncManager

  • Purpose: Implements the specific logic for synchronizing data with Supabase by extending BaseDataSyncer. It provides a concrete implementation of syncToSupabase, utilizing the SupabaseApiService to perform the actual data transmission to and from Supabase. getLastSyncedTimestamp(tableName: String) provides the timestamp when a particular table was last synced with a remote table in the case of the first sync it has a default value of 0.
  • Usage: Provide a ready-to-execute concrete class to manage data synchronization processes. It handles the logic for when and how to sync data based on network availability and data state.

1class SyncManager(context: Context, private val supabaseClient: SupabaseClient) :
2    BaseDataSyncer(supabaseClient) {
3
4    init {
5        RetrofitClient.setupClient(supabaseClient.supabaseHttpUrl, supabaseClient.supabaseKey)
6    }
7    private val networkHelper = NetworkHelper(context.applicationContext)
8    private val sharedPreferences: SharedPreferences =
9        context.applicationContext.getSharedPreferences("sync_prefs", Context.MODE_PRIVATE)
10    
11    fun isNetworkAvailable() = networkHelper.isNetworkAvailable()
12    
13    fun observeNetwork() = networkHelper.getNetworkLiveData()
14
15    fun getLastSyncedTimeStamp(tableName: String): Long {
16        return sharedPreferences.getLong("${tableName}_last_synced_timestamp", 0)
17    }
18
19    private fun setLastSyncedTimeStamp(tableName: String, value: Long) {
20        with(sharedPreferences.edit()) {
21            putLong("${tableName}_last_synced_timestamp", value)
22            apply()
23        }
24    }
25
26    override suspend fun <T : BaseSyncableEntity, RDto : BaseRemoteEntity> syncToSupabase(
27        localTable: String,
28        localDao: GenericDao<T>,
29        remoteTable: String,
30        toMap: (RDto) -> T,
31        toMapWithoutLocal: (T) -> RDto,
32        serializer: KSerializer<RDto>,
33        currentTimeStamp: Long
34    ) {
35        if (!networkHelper.isNetworkAvailable()) return
36
37        val lastSyncedTimeStamp = getLastSyncedTimeStamp(localTable)
38        val localItems =
39            localDao.query(SimpleSQLiteQuery("select * from $localTable where lastUpdatedTimestamp > $lastSyncedTimeStamp"))
40
41        // (local_id, remote_id) pairs - where after local row is inserted into remote, the local ids are replaced with newly generated remote ids
42        val insertedLocalToRemoteIds = mutableMapOf<Int, Int>()
43
44        var remoteItems: List<RDto>? = null
45        try {
46            remoteItems = supabaseClient.postgrest.from(remoteTable).select().data.decodeList<RDto>(
47                serializer
48            )
49        } catch (ex: Exception) {
50            Log.e(TAG, "exception while fetching remote items $ex")
51        }
52
53        for (localItem in localItems) {
54            var remoteItem: RDto? = null
55            try {
56                remoteItem = remoteItems?.find { it.id == localItem.id }
57                Log.d(TAG, "remote item id = ${remoteItem?.id}")
58            } catch (ex: Exception) {
59                Log.e(TAG, "exception for searching remote row for local row = $ex ")
60            }
61
62            if (remoteItem != null) {
63                Log.d(TAG, "SameIds found: localItem: $localItem and remoteItem: $remoteItem ")
64                when {
65                    localItem.lastUpdatedTimestamp == remoteItem.lastUpdatedTimestamp -> {
66                        //do nothing both items are same
67                    }
68
69                    localItem.lastUpdatedTimestamp > remoteItem.lastUpdatedTimestamp -> {
70                        //local data is latest
71                        when {
72                            (localItem.offlineFieldOpType == OfflineCrudType.INSERT.ordinal) -> {
73                                localItem.lastUpdatedTimestamp = currentTimeStamp
74
75                                try {
76
77                                    val generatedRemoteId = rClient.insertReturnId(
78                                        remoteTable,
79                                        toMapWithoutLocal(localItem).prepareRequestBodyWithoutId(
80                                            serializer
81                                        )
82                                    ).getId()
83
84                                    insertedLocalToRemoteIds[localItem.id] = generatedRemoteId
85                                    Log.d(
86                                        TAG,
87                                        "inserting record from local to remote: new remote id = $generatedRemoteId"
88                                    )
89
90                                } catch (ex: Exception) {
91                                    Log.e(
92                                        TAG,
93                                        "exception while inserting item from local to remote  = $ex",
94                                    )
95                                }
96                            }
97
98                            (localItem.offlineFieldOpType == OfflineCrudType.UPDATE.ordinal) -> {
99                                try {
100                                    localItem.lastUpdatedTimestamp = currentTimeStamp
101                                    rClient.update(
102                                        remoteTable,
103                                        remoteItem.id,
104                                        toMapWithoutLocal(localItem).prepareRequestBodyWithoutId(
105                                            serializer
106                                        )
107                                    )
108
109                                    localDao.update(SimpleSQLiteQuery("update $localTable set offlineFieldOpType = ${OfflineCrudType.NONE.ordinal}, lastUpdatedTimestamp = ${localItem.lastUpdatedTimestamp} where id = ${localItem.id}"))
110                                    Log.d(
111                                        TAG,
112                                        "updated local offline crud type for (${localItem.id})  timestamp = ${localItem.lastUpdatedTimestamp} "
113                                    )
114                                } catch (ex: Exception) {
115                                    Log.e(
116                                        TAG,
117                                        "exception while updating item from local to remote  = $ex",
118                                    )
119                                }
120                            }
121
122                            (localItem.offlineFieldOpType == OfflineCrudType.DELETE.ordinal) -> {
123                                try {
124                                    supabaseClient.postgrest.from(remoteTable).delete {
125                                        filter { eq(BaseRemoteEntity::id.name, remoteItem.id) }
126                                    }
127
128                                    localDao.delete(localItem)
129                                    Log.d(
130                                        TAG,
131                                        "deleting item from local and remote: id = ${localItem.id}"
132                                    )
133                                } catch (ex: Exception) {
134                                    Log.e(
135                                        TAG,
136                                        "exception while deleting item from local and remote  =  $ex",
137                                    )
138                                }
139                            }
140                        }
141                    }
142
143                    else -> {
144                        // remote data is latest
145                        // if local item with same id was inserted, it should be considered to be added to remote
146
147                        when {
148                            (localItem.offlineFieldOpType == OfflineCrudType.INSERT.ordinal) -> {
149                                localItem.lastUpdatedTimestamp = currentTimeStamp
150                                try {
151
152                                    val generatedRemoteId = rClient.insertReturnId(
153                                        remoteTable,
154                                        toMapWithoutLocal(localItem).prepareRequestBodyWithoutId(
155                                            serializer
156                                        )
157                                    ).getId()
158
159                                    insertedLocalToRemoteIds[localItem.id] = generatedRemoteId
160                                    //also insert the newly added remote item to local db
161                                    try {
162                                        localDao.insert(toMap(remoteItem))
163                                    } catch (ex: Exception) {
164                                        Log.e(
165                                            TAG,
166                                            "error while inserting latest remote data to local $ex",
167                                        )
168                                    }
169
170                                } catch (ex: Exception) {
171                                    Log.e(
172                                        TAG,
173                                        "exception while inserting item from local to remote $ex"
174                                    )
175                                    Log.d(TAG, "localItem = $localItem")
176                                    Log.d(TAG, "remoteItem = $remoteItem ")
177                                }
178                            }
179
180                            (localItem.offlineFieldOpType == OfflineCrudType.DELETE.ordinal) -> {
181                                //if local item was deleted, then delete in remote
182                                try {
183                                    supabaseClient.postgrest.from(remoteTable).delete {
184                                        filter {
185                                            eq(BaseRemoteEntity::id.name, remoteItem.id)
186                                        }
187                                    }
188
189                                    localDao.delete(localItem)
190                                    Log.d(
191                                        TAG,
192                                        "deleting item from local and remote: id = ${localItem.id}"
193                                    )
194
195                                } catch (ex: Exception) {
196                                    Log.e(TAG, "error while deleting item from local to remote $ex")
197                                }
198                            }
199
200                            else -> {
201                                //now update the latest remote data to local db
202                                localDao.update(toMap(remoteItem))
203                                Log.d(
204                                    TAG,
205                                    "updating local data with remote data = ${remoteItem.id}"
206                                )
207                            }
208                        }
209                    }
210                }
211            } else {
212                //remote data does not exists, means this local data can be newly inserted
213                when {
214                    (localItem.offlineFieldOpType == OfflineCrudType.INSERT.ordinal || localItem.offlineFieldOpType == OfflineCrudType.UPDATE.ordinal) -> {
215                        localItem.lastUpdatedTimestamp = currentTimeStamp
216                        try {
217//                            val generatedRemoteId = rClient.upsertReturnId(remoteTable,toMapWithoutLocal(localItem).prepareRequestBody(serializer)).getId()
218                            val generatedRemoteId = rClient.insertReturnId(
219                                remoteTable,
220                                toMapWithoutLocal(localItem).prepareRequestBodyWithoutId(serializer)
221                            ).getId()
222                            insertedLocalToRemoteIds[localItem.id] = generatedRemoteId
223
224                            Log.d(
225                                TAG,
226                                "upserting item from local to remote: new remote id = $generatedRemoteId"
227                            )
228                        } catch (ex: Exception) {
229                            Log.e(TAG, "exception while upserting item from local to remote  = $ex")
230                            Log.d(TAG, "localItem = $localItem")
231                            Log.d(TAG, "remoteItem = $remoteItem")
232                        }
233                    }
234
235                    (localItem.offlineFieldOpType == OfflineCrudType.DELETE.ordinal) -> {
236                        // it was added and deleted from local but never synced with remote
237                        // cascade delete from local db on child tables
238                        localDao.delete(localItem)
239                    }
240                }
241            }
242        }
243        Log.d(TAG, "Map of local items inserted in remote: $insertedLocalToRemoteIds")
244        // update all old local items with ids generated from adding the items to remote db
245        // also update local db to forget insert for this item as it is now synced with remote
246        insertedLocalToRemoteIds.forEach { (key, value) ->
247            val query =
248                "UPDATE $localTable set id = ${value}, offlineFieldOpType = ${OfflineCrudType.NONE.ordinal}, lastUpdatedTimestamp = $currentTimeStamp where id = $key"
249            Log.d(TAG, "syncToSupabase: query is $query")
250            localDao.update(SimpleSQLiteQuery(query))
251            Log.d(TAG, "updated local id $key with remote id ${value}")
252        }
253
254
255
256        if (lastSyncedTimeStamp != 0L) {
257            try {
258                remoteItems = supabaseClient.postgrest.from(remoteTable).select {
259//                    filter { gt(BaseRemoteEntity::lastUpdatedTimestamp.name, lastSyncedTimeStamp) }
260                    filter { gt("timestamp", lastSyncedTimeStamp) }
261                }.data.decodeList<RDto>(serializer)
262            } catch (ex: Exception) {
263                Log.e(TAG, "exception while fetching remote items $ex")
264            }
265        }
266
267        val localItemList = localDao.query(SimpleSQLiteQuery("select * from $localTable"))
268        remoteItems?.forEach { remoteItem ->
269
270            val localItem = localItemList.find { it.id == remoteItem.id }
271
272            if (localItem != null) {
273                when {
274                    (localItem.lastUpdatedTimestamp == remoteItem.lastUpdatedTimestamp) -> {
275                        //do nothing, both items are same
276                    }
277
278                    (localItem.lastUpdatedTimestamp > remoteItem.lastUpdatedTimestamp) -> {
279                        //local data is latest
280                        //we are comparing remote data, so for local only update and delete are valid considerations
281                        when {
282                            (localItem.offlineFieldOpType == OfflineCrudType.UPDATE.ordinal) -> {
283                                try {
284                                    localItem.lastUpdatedTimestamp = currentTimeStamp
285                                    rClient.update(
286                                        remoteTable,
287                                        remoteItem.id,
288                                        toMapWithoutLocal(localItem).prepareRequestBodyWithoutId(
289                                            serializer
290                                        )
291                                    )
292                                    localDao.update(SimpleSQLiteQuery("update $localTable set offlineCrudType = ${OfflineCrudType.NONE.ordinal}, lastUpdatedTimestamp = $currentTimeStamp where id = ${localItem.id}"))
293                                    Log.d(
294                                        TAG,
295                                        "updated local offline crud type for ${localItem.id}"
296                                    )
297                                } catch (ex: Exception) {
298                                    Log.e(
299                                        TAG,
300                                        "error while updating item from local to remote $ex",
301                                    )
302                                }
303                            }
304
305                            (localItem.offlineFieldOpType == OfflineCrudType.DELETE.ordinal) -> {
306                                try {
307                                    supabaseClient.postgrest.from(remoteTable).delete {
308                                        filter { eq(BaseRemoteEntity::id.name, remoteItem.id) }
309                                    }
310
311                                    localDao.delete(localItem)
312                                    Log.d(
313                                        TAG,
314                                        "deleting item from local and remote: id = ${localItem.id}"
315                                    )
316                                } catch (ex: Exception) {
317                                    Log.e(
318                                        TAG,
319                                        "error while deleting item from local to remote $ex",
320                                    )
321                                }
322                            }
323                        }
324                    }
325
326                    else -> {
327                        //remote data is latest
328                        //update local row with remote data
329                        val count = localDao.update(toMap(remoteItem))
330                        Log.d(
331                            TAG,
332                            "updated local record with remote data for ${remoteItem.id}, count = $count"
333                        )
334                    }
335                }
336            } else {
337                //no local data exists for this remote row, inserting in local table
338                try {
339                    localDao.insert(toMap(remoteItem))
340                } catch (ex: Exception) {
341                    Log.e(TAG, "error while inserting new remote data to local $ex")
342                    Log.d(TAG, "remoteItem = $remoteItem")
343                }
344            }
345        }
346
347        try {
348            //now check whether data is deleted from remote and exists in local
349            val listOfLocalItems = localDao.query(SimpleSQLiteQuery("select * from $localTable"))
350            val listOfRemoteItems = supabaseClient.postgrest.from(remoteTable)
351                .select().data.decodeList<RDto>(serializer)
352
353            val idsOfRemoteItems = listOfRemoteItems.map { it.id }.toSet()
354            val toBeDeleted = listOfLocalItems.filter { it.id !in (idsOfRemoteItems) }
355            Log.d(
356                TAG,
357                "Delete check remoteItems count:${listOfRemoteItems.size}, localItem count:${listOfLocalItems.size} and toBeDeleted count:${toBeDeleted.size}"
358            )
359            toBeDeleted.forEach { localDao.delete(it) }
360        } catch (ex: Exception) {
361            Log.e(TAG, "error while deleting extra local entries $ex")
362        }
363
364
365        setLastSyncedTimeStamp(localTable, currentTimeStamp)
366        Log.d(TAG, "updating lastSyncedTimestamp for $localTable = $currentTimeStamp")
367
368
369    }
370
371}

6. Retrofit Client

  • Purpose: A singleton class responsible for setting up and configuring the Retrofit client with the necessary base URL and API key of the Supabase client. It includes an interceptor to add the API key to all requests and uses converter methods to handle CRUD operations with the Supabase REST API.
  • Usage: A point for network communication setup, ensuring that all network calls to Supabase are authenticated and correctly formatted.

1object RetrofitClient {
2    private var BASE_URL = ""
3    private var apikey = ""
4    fun setupClient(baseUrl: String,apikey: String) {
5        this.BASE_URL = baseUrl
6        this.apikey = apikey
7    }
8    val rClient: SupabaseApiService by lazy {
9        val httpClient = OkHttpClient.Builder()
10        httpClient.addInterceptor(Interceptor {
11            val original = it.request()
12            if(apikey.isEmpty() || BASE_URL.isEmpty()) throw Exception("The apikey/setupClient for Retroclient is not set. Use setupClient(baseUrl: String,apikey: String) to setup the client")
13            val request = original.newBuilder()
14                .header("apikey", apikey)
15                .header("Authorization", "Bearer $apikey")
16                .method(original.method, original.body)
17                .build()
18
19            it.proceed(request)
20        })
21        val client = httpClient.build()
22
23        val retrofit = Retrofit.Builder()
24            .baseUrl(BASE_URL)
25            .addConverterFactory(GsonConverterFactory.create())
26            .addConverterFactory(QueryParamConverter())
27            .client(client)
28            .build()
29
30        retrofit.create(SupabaseApiService::class.java)
31    }
32}

7. SupabaseApiService

  • Purpose: Provides asynchronous methods for performing CRUD operations on Supabase. It is utilized by SyncManager to execute insert and update operations, handle request bodies, and parse the responses to retrieve IDs or error messages.
  • Usage: This service abstracts the REST API calls and is integral to the synchronization process, ensuring data consistency between local and remote states.

1interface SupabaseApiService {
2    @Headers("Content-Type: application/json", "Prefer: return=minimal")
3    @POST("/rest/v1/{table}")
4    suspend fun insert(
5        @Path("table") tableName: String,
6        @Body data: RequestBody
7    ): Response<Unit>
8
9    @Headers("Content-Type: application/json", "Prefer: return=representation")
10    @POST("/rest/v1/{table}?select=id")
11    suspend fun insertReturnId(
12        @Path("table") tableName: String,
13        @Body data: RequestBody
14    ): Response<ResponseBody>
15
16    @Headers("Content-Type: application/json", "Prefer: return=minimal")
17    @PATCH("/rest/v1/{table}")
18    suspend fun update(
19        @Path("table") tableName: String,
20        @Query("id") @eq id: Int,
21        @Body data: RequestBody
22    ): Response<Unit>
23
24...
25}

8. NetworkHelper

  • Purpose: Offers utility methods to check internet connectivity. It includes a method for instantaneous network checks (isNetworkAvailable()) and a LiveData provider (getNetworkLiveData()) that updates observers with the current network state.
  • Usage: Essential for determining when to initiate or halt synchronization processes based on network availability.

1class NetworkHelper(context: Context) {
2
3    private val connectivityManager =
4        context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
5
6    private val networkLiveData: MutableLiveData<Boolean> by lazy {
7        MutableLiveData<Boolean>().also {
8            it.postValue( connectivityManager.getNetworkCapabilities(connectivityManager.activeNetwork)
9                ?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) ?: false)
10        }
11    }
12
13    private val networkRequest = NetworkRequest.Builder()
14        .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
15        .build()
16
17
18    fun isNetworkAvailable(): Boolean {
19        val network = connectivityManager.activeNetwork ?: return false
20        val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false
21        return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
22    }
23
24    fun getNetworkLiveData(): LiveData<Boolean> {
25        val networkCallback = object : ConnectivityManager.NetworkCallback() {
26            override fun onAvailable(network: Network) {
27                networkLiveData.postValue(true)
28            }
29
30            override fun onLost(network: Network) {
31                networkLiveData.postValue(false)
32            }
33        }
34
35        connectivityManager.registerNetworkCallback(networkRequest, networkCallback)
36
37        return networkLiveData
38    }
39}

9. Converters.kt

  • Purpose: Contains annotation classes (eq, lt, gt) used to define query parameters for REST API calls. This functionality is part of a custom converter factory (QueryParamConverter) used in the Retrofit client to modify URLs dynamically based on query requirements.
  • Usage: Enables dynamic insertion of query conditions in REST API URLs, facilitating insert, update, and delete queries via simple annotations.

1@Retention(AnnotationRetention.RUNTIME)
2@Target(AnnotationTarget.VALUE_PARAMETER)
3annotation class eq
4
5@Retention(AnnotationRetention.RUNTIME)
6@Target(AnnotationTarget.VALUE_PARAMETER)
7annotation class gt
8
9@Retention(AnnotationRetention.RUNTIME)
10@Target(AnnotationTarget.VALUE_PARAMETER)
11annotation class lt
12
13
14class QueryParamConverter: Converter.Factory(){
15    override fun stringConverter(
16        type: Type,
17        annotations: Array<out Annotation>,
18        retrofit: Retrofit
19    ): Converter<*, String>? {
20        return when {
21            annotations.any { it is eq } -> {
22                Converter<Any, String> { value ->
23                    "eq.$value"
24                }
25            }
26            annotations.any { it is gt } -> {
27                Converter<Any, String> { value ->
28                    "gt.$value"
29                }
30            }
31            annotations.any { it is lt } -> {
32                Converter<Any, String> { value ->
33                    "lt.$value"
34                }
35            }
36            else -> null
37        }
38    }
39}

10. Extension.kt

  • Purpose: Contains extension functions such as decodeSingle/decodeList to parse JSON responses from Supabase REST API calls. It also includes methods like prepareRequestBody and prepareRequestBodyWithoutId to correctly format and encode request bodies for API calls.
  • Usage: Streamlines data handling by providing utility functions that aid in preparing and processing data exchanged with the Supabase API.

1fun <RDto : BaseRemoteEntity> String.decodeSingle(serializer: KSerializer<RDto>): RDto {
2    return Json.decodeFromString(ListSerializer(serializer), this).first()
3}
4
5fun <RDto : BaseRemoteEntity> String.decodeList(serializer: KSerializer<RDto>): List<RDto> {
6    return Json.decodeFromString(ListSerializer(serializer), this)
7}
8
9fun Response<ResponseBody>.getId(): Int {
10    val element = Json.parseToJsonElement(this.body()!!.string())
11    return if(element is JsonArray)
12        Json.decodeFromJsonElement<List<JsonId>>(element).first().id
13    else{
14        val error = Json.decodeFromJsonElement<JsonError>(element)
15        throw Exception("code: ${error.code}, hint: ${error.hint}, details: ${error.details}, message: ${error.message}")
16    }
17}
18@Serializable
19data class JsonId(val id: Int)
20
21@Serializable
22data class JsonError(val code: String?,val details: String?, val hint: String?, val message:String? )
23
24fun <T:BaseRemoteEntity> T.prepareRequestBody(serializer: KSerializer<T>): RequestBody { 
25    val jsonString = Json.encodeToString(serializer,this)
26
27    val jsonMediaType = "application/json; charset=utf-8".toMediaType()
28    return jsonString.toRequestBody(jsonMediaType)
29}
30fun <T:BaseRemoteEntity> T.prepareRequestBodyWithoutId(serializer: KSerializer<T>): RequestBody {
31    val jsonString = Json.encodeToString(serializer,this)
32    val modifiedJson = removeIdFromJson(jsonString)
33    Log.d("Utils", "prepareRequestBodyWithoutId: original $jsonString and modified $modifiedJson ")
34    val jsonMediaType = "application/json; charset=utf-8".toMediaType()
35    return modifiedJson.toRequestBody(jsonMediaType)
36}
37
38fun removeIdFromJson(str: String): String{
39    val original = Json.parseToJsonElement(str).jsonObject
40    val modifiedObj = buildJsonObject {
41        original.entries.forEach {
42                (key,value)->
43            if(key != "id")
44                put(key,value)
45        }
46    }
47    return Json.encodeToString(JsonObject.serializer(),modifiedObj)
48}

Triggering Synchronization: Mastering Offline-Online Consistency

A crucial aspect of enabling offline functionality is efficiently determining when to synchronize data between the local and remote databases. In our library, the SyncManager plays a pivotal role in this process. We have provided network-based triggers but you can use your triggering methods like on particular user action, scheduled interval, on each app launch.

Network-Based Synchronization with SyncManager:

Our SyncManager includes a method, observeNetwork(), which listens for changes in network availability. Here’s how it works:

  • observeNetwork() Method: This method leverages the NetworkHelper class to observe the device’s network state. It returns LiveData<Boolean>, when an Internet is detected after being offline, it returns true else false.
  • Implementation Details: Upon detecting network availability, SyncManager initiates the synchronization algorithm, ensuring that all local changes are pushed to the remote server and any updates from the remote server are pulled into the local database.

Example Code:

1syncManager.observeNetwork().observe(lifecycleOwner, { isAvailable ->
2    if (isAvailable) {
3        syncManager.syncToSupabase(...)
4    }
5})

The Synchronization algorithm:

Algo’s flowchart
Algo’s flowchart

11.Check Network Availability:
2  Verify if the device has an active network connection. If not, exit the synchronization process.
3
42.Retrieve Local Changes:
5  Query the local database for items that have been updated since the last synchronization. 
6  If no items are updated then skip step 4
7
83.Retrieve Remote Data:
9  Fetch data from the remote database to compare with local changes.
10
114.Handle Local Changes and Remote Conflict:
12  For each local item:
13   - Check if a corresponding item exists in the remote database.
14   - If a match is found:
15
16       -Compare timestamps to determine which version is more recent.
17        Apply the "Last Write Wins" strategy:
18         -If the local version is newer:
19              -Update the remote database with the local changes.
20              -Handle insertions, updates, and deletions accordingly.
21         -If the remote version is newer:
22              -Update the local database with the remote changes.
23    -If no match is found:
24
25         -Insert the local item into the remote database.
26         -Update the local item with the generated remote ID.
27
285.Update Local Data with Remote Changes:
29  For each remote item:
30   -Check if a corresponding item exists in the local database.
31   -If a match is found:
32      -Compare timestamps to determine which version is more recent.
33       Apply the "Last Write Wins" strategy:
34         -If the remote version is newer:
35              -Update the local database with the remote changes.
36         -If the local version is newer:
37              -Update the remote database with the local changes.
38    -If no match is found:
39         -Insert the remote item into the local database.
40
416.Cleanup:
42  Delete local items that no longer exist in the remote database.
43
447.Update Last Synced Timestamp:
45  Store the timestamp of last synchronization for each table for future reference.

Getting Started

To get started with the Supabase Offline Support Library, follow these simple steps:

  1. Set up a Supabase project and obtain your API key and base URL, along with the Room dependency.

1//project's build.gradle
2plugins{
3    id 'org.jetbrains.kotlin.android' version '1.9.10' apply false
4    id 'org.jetbrains.kotlin.plugin.serialization' version '1.9.10' apply false
5    id 'org.jetbrains.kotlin.kapt' version '1.9.10' apply false
6}
7//app's build.gradle
8plugins{
9    id 'org.jetbrains.kotlin.plugin.serialization'
10    id 'org.jetbrains.kotlin.kapt'
11    id 'androidx.room'
12 }
13
14dependencies{
15//for supabase
16implementation platform("io.github.jan-tennert.supabase:bom:2.2.3")
17implementation 'io.github.jan-tennert.supabase:postgrest-kt'
18implementation 'io.ktor:ktor-client-android:2.3.9'
19
20//for room components
21implementation "androidx.room:room-runtime:2.6.1"
22implementation "androidx.room:room-ktx:2.6.1"
23annotationProcessor("androidx.room:room-compiler:2.6.1")
24// To use Kotlin annotation processing tool (kapt)
25kapt("androidx.room:room-compiler:2.6.1")
26
27}

  1. Add the library to your Android project’s dependencies using Gradle.
  2. Configure the library by initializing  SyncManager and setting up your database entities.

Each local entity that is participating in the synchronization process needs to extend BaseSyncableEntity and their corresponding DAOs need to extend GenericDAO<T>, Each Remote DTO/ Entity representing Supabase Table and wants to sync with the corresponding local table needs to extend BaseRemoteEntity to work with SyncManager

Integration Example and Explanation

Let’s dive into a hands-on tutorial to see how easy it is to implement offline capability in an Android app using the Supabase Offline Support Library with Room. In this tutorial, we’ll integrate our library into a simple task management app that allows users to add, update, and delete tasks both online and offline.

Note: You can write your own synchronization algorithm by extending our BaseDataSyncer class. We have provided a ready to use concrete class named SyncManager with our synchronization algorithm which is explained later.

Let’s start with integration, We will be having 3 tables namely Task, Category, Priority

Structure for local task table and category that are participating in the synchronization process:

1@Entity(
2    tableName = "tasks",
3    foreignKeys = [ForeignKey(
4        Category::class,
5        parentColumns = ["id"],
6        childColumns = ["category_id"],
7        onDelete = ForeignKey.CASCADE,
8        onUpdate = ForeignKey.CASCADE
9    ), ForeignKey(
10        Priority::class,
11        ["id"],
12        ["priority_id"],
13        ForeignKey.CASCADE,
14        ForeignKey.CASCADE
15    )]
16)
17data class Task(
18    @PrimaryKey override val id: Int,
19    val name: String,
20    @ColumnInfo("category_id") val categoryId: Int,
21    val emoji: String,
22    val date: String,
23    @ColumnInfo("is_complete") var isComplete: Boolean,
24    @ColumnInfo("priority_id") val priorityId: Int,
25    override var lastUpdatedTimestamp: Long,
26    @ColumnInfo("is_delete") var isDelete: Boolean,
27    override var offlineFieldOpType: Int
28) : BaseSyncableEntity()
29
30@Dao
31interface TaskDao: GenericDao<Task>

1@Entity(tableName = "categories")
2data class Category(
3    @PrimaryKey(autoGenerate = true) override val id: Int,
4    val name: String,
5    override var lastUpdatedTimestamp: Long,
6    override val offlineFieldOpType: Int
7) : BaseSyncableEntity()
8
9@Dao
10interface CategoryDao: GenericDao<Category>

Similarly for Remote entities/ DTOs

1@Serializable
2data class TaskDto(
3    override val id: Int,
4    val name: String,
5    val category_id: Int,
6    val emoji: String,
7    @SerialName("is_complete") val isComplete: Boolean,
8    val date: String,
9    val priority_id: Int,
10    @SerialName("timestamp")override var lastUpdatedTimestamp: Long,
11    @SerialName("is_delete") val isDelete: Boolean
12): BaseRemoteEntity()

1@Serializable
2data class CategoryDto(
3    val name: String,
4    @SerialName("timestamp") override var lastUpdatedTimestamp: Long,
5    override val id: Int
6) : BaseRemoteEntity()

After configuring the entities and DAOs, initialize the SyncManager in your class as follows:

1class TaskRepository(
2    private val context: Context,
3) {
4
5    private val syncManager = SyncManager(context, SupabaseModule.provideSupabaseClient())
6    val networkConnected = syncManager.observeNetwork()
7    ...
8    suspend fun forceUpdateTasks() {
9          syncManager.syncToSupabase(
10              "tasks",
11              taskLocalDataSource.taskDao,
12              "task",
13              { it.toEntity(0) },
14              { it.toDto() },
15              TaskDto.serializer(),
16              System.currentTimeMillis()
17          )
18       }
19
20    suspend fun forceUpdateCategories() {
21          syncManager.syncToSupabase(
22              "categories",
23              categoryLocalDataSource.categoryDao,
24              "categories",
25              { it.toEntity(0) },
26              { it.toDto() },
27              CategoryDto.serializer(),
28              System.currentTimeMillis()
29          )   
30    }
31
32}
33//This is extension function which is passed in above forceUpdateCategories() parameters
34fun Category.toDto(): CategoryDto = CategoryDto(
35    this.name,
36    this.lastUpdatedTimestamp,
37    this.id
38)
39
40fun CategoryDto.toEntity(crud: Int): Category =
41    Category(
42        this.id,
43        this.name,
44        this.lastUpdatedTimestamp,
45        crud
46    )

SyncManager has 3 utility methods:
fun isNetworkAvailable(): Returns Boolean representing the network availability at that moment
fun observeNetwork(): Returns LiveData<Boolean> which can be observed where Boolean value reflect the network availability
fun getLastSyncedTimeStamp(tableName: String): Returns Long value which contains the milliseconds when the local table was last synced with its corresponding remote table.

Observing the network and triggering the synchronization process each the time the network changes i.e. internet goes on/off

1class MainActivity: AppCompatActivity(){
2 override fun onCreate(savedInstanceState: Bundle?) {
3    viewModel.networkConnected.observe(this@MainActivity, Observer {
4        var str = if(it){
5            //This method calls TaskRepository's method forceUpdateTasks()
6            viewModel.forceUpdate()
7            getString(R.string.device_online)
8        } else {
9            getString(R.string.device_offline)
10        }
11        Toast.makeText(this@MainActivity, str, Toast.LENGTH_SHORT).show()
12    })
13  }
14}

Here is the snippet to handle insert/update/delete on the task table, here we are using concept of soft delete i.e. when user deletes the item in UI, the is_delete column of Task table is set true instead of deleting the row immediately when offline

1class TaskRepository(private val context: Context){
2  //The task passed here has OfflineFieldOpType set to 1 
3  suspend fun insertTask(task: Task): Result<Task> {
4
5      //perform operation based on network connection as local copy of remote data is present
6      if (networkConnected.value!!) {
7          //insert in local as well as remote
8          taskLocalDataSource.insert(task)
9          val result = taskRemoteDataSource.insert(task.toDto())
10          return if (result is Result.Success) {
11              //if remote insertion is successful then reset the OfflineFieldOpType to 0
12              taskLocalDataSource.setCrudById(task.id, 0)
13              Result.Success(task)
14          } else {
15              //remote insertion has failed so let the OfflineFieldOpType remain 1 in local to push it when synchronizing
16              Result.Failure((result as Result.Failure).exception)
17          }
18      } else {
19          //user is offline
20          taskLocalDataSource.insert(task)
21          return Result.Success(task)
22      }
23  
24  }
25  
26  private fun isSyncFirstRun(tableName: String) =
27      syncManager.getLastSyncedTimeStamp(tableName) == 0L
28  
29  suspend fun updateTask(task: Task): Boolean {
30      if (isSyncFirstRun("tasks")) {
31          if (task.offlineFieldOpType == OfflineCrudType.UPDATE.ordinal) task.offlineFieldOpType =
32              OfflineCrudType.INSERT.ordinal
33      }
34      //updating the task in local table first so that changes are reflected fast in the UI
35      val count = taskLocalDataSource.updateTask(task)
36      if (networkConnected.value!!) {
37          val result = taskRemoteDataSource.updateTask(task.toDto())
38          if (result.succeeded) {
39              //if the task is updated in remote we can set OfflineFieldOpType to 0
40              taskLocalDataSource.setCrudById(task.id, 0)
41          } else {
42              //let the OfflineFieldOpType remain 2
43              Log.d(TAG, "updateTask: $result")
44          }
45      }
46      return count > 0
47  }
48
49} 

Challenges Faced

One of the significant hurdles faced during the development of the library involved dealing with the Room persistence library’s constraints, specifically its inability to dynamically handle table names in queries. Room, designed for compile-time safety and ease of use, requires that all SQL queries, including table names, be known at compile-time. This design ensures that queries are verified for correctness during compilation, thus preventing runtime errors and improving stability. However, this feature also restricts the library’s flexibility in applications that require dynamic table handling, such as a generic synchronization library.

To address this limitation, I utilized the @RawQuery annotation that Room provides, which allows executing dynamic SQL queries. By leveraging @RawQuery, it was possible to craft queries where table names and other parameters are specified at runtime, offering the necessary flexibility for SyncManager to interact with different entities generically.

Another considerable challenge involved interfacing with the Supabase Kotlin client SDK. The SDK’s methods, such as insert(), update(), and upsert(), require specifying a type parameter at compile time. However, in a library designed to handle data synchronization generically across various tables, the specific type of object being operated on is only known at runtime. This posed a problem when attempting to use these methods in a base class intended to serve a wide array of data entities.

Initially, making the syncToSupabase() method of the SyncManager class reified seemed like a viable solution, as it would allow type-safe usage of these functions. However, Kotlin’s reified types do not support inheritance or use within interfaces and inner functions. This restriction would have severely limited the method’s applicability, preventing it from being defined in a base interface like BaseDataSyncer, which was designed to be implemented by various data synchronizing classes.

Due to these limitations with the Supabase Kotlin client SDK, I opted to use the Supabase REST API directly. This approach bypassed the type constraints and allowed for more dynamic handling of synchronization tasks. It also involved manually decoding the JSON responses from the API, as the specific model classes to be used could only be determined at runtime. This manual handling of JSON ensures that our synchronization logic remains as flexible and adaptable as necessary, albeit at the cost of some additional complexity in the data processing logic.

Conclusion

By leveraging the power of Supabase and Room, developers can supercharge their Android apps with offline capability, providing users with a seamless experience even in challenging network conditions. The Supabase Offline Support Library simplifies the implementation process and offers a robust solution for managing data synchronization. Try it out in your next Android project and experience the benefits firsthand!

GitHub Repository

For those interested in exploring the code further or contributing to the project, the source code for both our sample application and the underlying library is available on GitHub.

https://github.com/novumlogic/Android-Offline-First-Supabase-Library

Heading 1

Heading 2

Heading 3

Heading 4

Heading 5
Heading 6

Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.

Block quote

Ordered list

  1. Item 1
  2. Item 2
  3. Item 3

Unordered list

  • Item A
  • Item B
  • Item C

Text link

Bold text

Emphasis

Superscript

Subscript