Supercharge Your Android App with Offline Capability Using Supabase and Room

Dhananjay Navlani
Novumlogic
Published in
20 min readMay 20, 2024
Picture depicting android app with supabase
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 backend like Supabase offer powerful features, it lack 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 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

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 data synchronization strategy / algorithm that must occurs 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.
abstract class BaseDataSyncer(private val supabaseClient: 
/**
* Syncs the local table with the provided remote table
*
* Applies the synchronization algorithm uses Last-write wins Conflict resolution strategy
*
* @param T The type of the local entity, should be subclass of BaseSyncableEntity
* @param RDto The type of the remote entity, should be subclass of BaseRemoteEntity
* @param localTable The name of the local table needs to by synced
* @param localDao Dao of that local table required to perform CRUD operations
* @param remoteTable The name of the remote table needs to be synced
* @param toMap A function that converts the remote DTO/Entity to the local DTO/Entity
* @param toMapWithoutLocal A function that converts the local DTO/Entity to the remote DTO/Entity
* @param serializer Serializer of remote entity required to perform decoding logic on Json of the entity received
* @param currentTimeStamp The current timestamp in milliseconds required to perform synchronization logic
* */
abstract suspend fun <T: BaseSyncableEntity,RDto: BaseRemoteEntity> syncToSupabase(
localTable: String,
localDao: GenericDao<T>,
remoteTable: String,
toMap: (RDto) -> T,
toMapWithoutLocal: (T) -> RDto,
serializer: KSerializer<RDto>,
currentTimeStamp: Long
)

}

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”, “timestamp” as columns in 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.
/**
* Base class that needs to be extends by all remote DTOs/Entities that needs to participate in synchronization
*
* @property id Primary key for remote table
* @property lastUpdatedTimestamp Contains milliseconds as timestamp to compare
* with local row's timestamp in case of conflict and perform synchronization
*
* */
@Serializable
abstract class BaseRemoteEntity {
abstract val id: Int
abstract val lastUpdatedTimestamp: Long
}

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

Task table in supabase
Task table in supabase
/**
* TaskDto represents the task table in supabase
* @SerialName tag is used to map the column name to the property name
* We need to assign lastUpdatedTimestamp the SerialName "timestamp" as
* it is hardcoded in SyncManager
* */
@Serializable
data class TaskDto(
override val id: Int,
val name: String,
val category_id: Int,
val emoji: String,
@SerialName("is_complete") val isComplete: Boolean,
val date: String,
val priority_id: Int,
@SerialName("timestamp")override var lastUpdatedTimestamp: Long,
@SerialName("is_delete") val isDelete: Boolean
): BaseRemoteEntity()

Note: @SerialName(“timestamp”) is must on the property lastUpdatedTimestamp and also the column named “timestamp” of type bigint / int8 , “id” of bigint/ int8 column as primary key is required in Supabase table in order 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 user is offline and performs CRUD operation on the table then OfflineFieldOpType (int field) determines which operation was during offline state which is used when the device gets online and synchronization is performed, it should have following values: (0 for no changes, 1 for insertion, 2 for update, 3 for delete).
/**
* Base class for all local entities
*
* Local Entity needs to extend this class in order to work with syncToSupabase() function of SyncManager
*
* @property id: Acts as Primary key for local table
* @property lastUpdatedTimestamp: Contains milliseconds as timestamp to compare
* with remote row's timestamp in case of conflict and perform synchronization
* @property offlineFieldOpType: Should be passed any 4 predefined int values to determine state of record
* on which CRUD operation was performed when device was offline
* 0 for no changes,
* 1 for new insertion,
* 2 for updation on row,
* 3 for deletion
* */

abstract class BaseSyncableEntity {
abstract val id: Int
abstract var lastUpdatedTimestamp: Long
abstract val offlineFieldOpType: Int
}

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 annonation to use dynamic table name 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.
interface GenericDao<T> {

@RawQuery
suspend fun query(query: SupportSQLiteQuery): List<T>

@Update
suspend fun update(entity: T): Int

@RawQuery
suspend fun update(query: SupportSQLiteQuery):Int

@Delete
suspend fun delete(entity: T): Int

@Insert
suspend fun insert(entity: T): Long

@RawQuery
suspend fun delete(query: SupportSQLiteQuery): Int


}

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 particular table was last synced with remote table in case of first sync it has 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.
class SyncManager(context: Context, private val supabaseClient: SupabaseClient) :
BaseDataSyncer(supabaseClient) {

init {
RetrofitClient.setupClient(supabaseClient.supabaseHttpUrl, supabaseClient.supabaseKey)
}
private val networkHelper = NetworkHelper(context.applicationContext)
private val sharedPreferences: SharedPreferences =
context.applicationContext.getSharedPreferences("sync_prefs", Context.MODE_PRIVATE)

fun isNetworkAvailable() = networkHelper.isNetworkAvailable()

fun observeNetwork() = networkHelper.getNetworkLiveData()

fun getLastSyncedTimeStamp(tableName: String): Long {
return sharedPreferences.getLong("${tableName}_last_synced_timestamp", 0)
}

private fun setLastSyncedTimeStamp(tableName: String, value: Long) {
with(sharedPreferences.edit()) {
putLong("${tableName}_last_synced_timestamp", value)
apply()
}
}

override suspend fun <T : BaseSyncableEntity, RDto : BaseRemoteEntity> syncToSupabase(
localTable: String,
localDao: GenericDao<T>,
remoteTable: String,
toMap: (RDto) -> T,
toMapWithoutLocal: (T) -> RDto,
serializer: KSerializer<RDto>,
currentTimeStamp: Long
) {
if (!networkHelper.isNetworkAvailable()) return

val lastSyncedTimeStamp = getLastSyncedTimeStamp(localTable)
val localItems =
localDao.query(SimpleSQLiteQuery("select * from $localTable where lastUpdatedTimestamp > $lastSyncedTimeStamp"))

// (local_id, remote_id) pairs - where after local row is inserted into remote, the local ids are replaced with newly generated remote ids
val insertedLocalToRemoteIds = mutableMapOf<Int, Int>()

var remoteItems: List<RDto>? = null
try {
remoteItems = supabaseClient.postgrest.from(remoteTable).select().data.decodeList<RDto>(
serializer
)
} catch (ex: Exception) {
Log.e(TAG, "exception while fetching remote items $ex")
}

for (localItem in localItems) {
var remoteItem: RDto? = null
try {
remoteItem = remoteItems?.find { it.id == localItem.id }
Log.d(TAG, "remote item id = ${remoteItem?.id}")
} catch (ex: Exception) {
Log.e(TAG, "exception for searching remote row for local row = $ex ")
}

if (remoteItem != null) {
Log.d(TAG, "SameIds found: localItem: $localItem and remoteItem: $remoteItem ")
when {
localItem.lastUpdatedTimestamp == remoteItem.lastUpdatedTimestamp -> {
//do nothing both items are same
}

localItem.lastUpdatedTimestamp > remoteItem.lastUpdatedTimestamp -> {
//local data is latest
when {
(localItem.offlineFieldOpType == OfflineCrudType.INSERT.ordinal) -> {
localItem.lastUpdatedTimestamp = currentTimeStamp

try {

val generatedRemoteId = rClient.insertReturnId(
remoteTable,
toMapWithoutLocal(localItem).prepareRequestBodyWithoutId(
serializer
)
).getId()

insertedLocalToRemoteIds[localItem.id] = generatedRemoteId
Log.d(
TAG,
"inserting record from local to remote: new remote id = $generatedRemoteId"
)

} catch (ex: Exception) {
Log.e(
TAG,
"exception while inserting item from local to remote = $ex",
)
}
}

(localItem.offlineFieldOpType == OfflineCrudType.UPDATE.ordinal) -> {
try {
localItem.lastUpdatedTimestamp = currentTimeStamp
rClient.update(
remoteTable,
remoteItem.id,
toMapWithoutLocal(localItem).prepareRequestBodyWithoutId(
serializer
)
)

localDao.update(SimpleSQLiteQuery("update $localTable set offlineFieldOpType = ${OfflineCrudType.NONE.ordinal}, lastUpdatedTimestamp = ${localItem.lastUpdatedTimestamp} where id = ${localItem.id}"))
Log.d(
TAG,
"updated local offline crud type for (${localItem.id}) timestamp = ${localItem.lastUpdatedTimestamp} "
)
} catch (ex: Exception) {
Log.e(
TAG,
"exception while updating item from local to remote = $ex",
)
}
}

(localItem.offlineFieldOpType == OfflineCrudType.DELETE.ordinal) -> {
try {
supabaseClient.postgrest.from(remoteTable).delete {
filter { eq(BaseRemoteEntity::id.name, remoteItem.id) }
}

localDao.delete(localItem)
Log.d(
TAG,
"deleting item from local and remote: id = ${localItem.id}"
)
} catch (ex: Exception) {
Log.e(
TAG,
"exception while deleting item from local and remote = $ex",
)
}
}
}
}

else -> {
// remote data is latest
// if local item with same id was inserted, it should be considered to be added to remote

when {
(localItem.offlineFieldOpType == OfflineCrudType.INSERT.ordinal) -> {
localItem.lastUpdatedTimestamp = currentTimeStamp
try {

val generatedRemoteId = rClient.insertReturnId(
remoteTable,
toMapWithoutLocal(localItem).prepareRequestBodyWithoutId(
serializer
)
).getId()

insertedLocalToRemoteIds[localItem.id] = generatedRemoteId
//also insert the newly added remote item to local db
try {
localDao.insert(toMap(remoteItem))
} catch (ex: Exception) {
Log.e(
TAG,
"error while inserting latest remote data to local $ex",
)
}

} catch (ex: Exception) {
Log.e(
TAG,
"exception while inserting item from local to remote $ex"
)
Log.d(TAG, "localItem = $localItem")
Log.d(TAG, "remoteItem = $remoteItem ")
}
}

(localItem.offlineFieldOpType == OfflineCrudType.DELETE.ordinal) -> {
//if local item was deleted, then delete in remote
try {
supabaseClient.postgrest.from(remoteTable).delete {
filter {
eq(BaseRemoteEntity::id.name, remoteItem.id)
}
}

localDao.delete(localItem)
Log.d(
TAG,
"deleting item from local and remote: id = ${localItem.id}"
)

} catch (ex: Exception) {
Log.e(TAG, "error while deleting item from local to remote $ex")
}
}

else -> {
//now update the latest remote data to local db
localDao.update(toMap(remoteItem))
Log.d(
TAG,
"updating local data with remote data = ${remoteItem.id}"
)
}
}
}
}
} else {
//remote data does not exists, means this local data can be newly inserted
when {
(localItem.offlineFieldOpType == OfflineCrudType.INSERT.ordinal || localItem.offlineFieldOpType == OfflineCrudType.UPDATE.ordinal) -> {
localItem.lastUpdatedTimestamp = currentTimeStamp
try {
// val generatedRemoteId = rClient.upsertReturnId(remoteTable,toMapWithoutLocal(localItem).prepareRequestBody(serializer)).getId()
val generatedRemoteId = rClient.insertReturnId(
remoteTable,
toMapWithoutLocal(localItem).prepareRequestBodyWithoutId(serializer)
).getId()
insertedLocalToRemoteIds[localItem.id] = generatedRemoteId

Log.d(
TAG,
"upserting item from local to remote: new remote id = $generatedRemoteId"
)
} catch (ex: Exception) {
Log.e(TAG, "exception while upserting item from local to remote = $ex")
Log.d(TAG, "localItem = $localItem")
Log.d(TAG, "remoteItem = $remoteItem")
}
}

(localItem.offlineFieldOpType == OfflineCrudType.DELETE.ordinal) -> {
// it was added and deleted from local but never synced with remote
// cascade delete from local db on child tables
localDao.delete(localItem)
}
}
}
}
Log.d(TAG, "Map of local items inserted in remote: $insertedLocalToRemoteIds")
// update all old local items with ids generated from adding the items to remote db
// also update local db to forget insert for this item as it is now synced with remote
insertedLocalToRemoteIds.forEach { (key, value) ->
val query =
"UPDATE $localTable set id = ${value}, offlineFieldOpType = ${OfflineCrudType.NONE.ordinal}, lastUpdatedTimestamp = $currentTimeStamp where id = $key"
Log.d(TAG, "syncToSupabase: query is $query")
localDao.update(SimpleSQLiteQuery(query))
Log.d(TAG, "updated local id $key with remote id ${value}")
}



if (lastSyncedTimeStamp != 0L) {
try {
remoteItems = supabaseClient.postgrest.from(remoteTable).select {
// filter { gt(BaseRemoteEntity::lastUpdatedTimestamp.name, lastSyncedTimeStamp) }
filter { gt("timestamp", lastSyncedTimeStamp) }
}.data.decodeList<RDto>(serializer)
} catch (ex: Exception) {
Log.e(TAG, "exception while fetching remote items $ex")
}
}

val localItemList = localDao.query(SimpleSQLiteQuery("select * from $localTable"))
remoteItems?.forEach { remoteItem ->

val localItem = localItemList.find { it.id == remoteItem.id }

if (localItem != null) {
when {
(localItem.lastUpdatedTimestamp == remoteItem.lastUpdatedTimestamp) -> {
//do nothing, both items are same
}

(localItem.lastUpdatedTimestamp > remoteItem.lastUpdatedTimestamp) -> {
//local data is latest
//we are comparing remote data, so for local only update and delete are valid considerations
when {
(localItem.offlineFieldOpType == OfflineCrudType.UPDATE.ordinal) -> {
try {
localItem.lastUpdatedTimestamp = currentTimeStamp
rClient.update(
remoteTable,
remoteItem.id,
toMapWithoutLocal(localItem).prepareRequestBodyWithoutId(
serializer
)
)
localDao.update(SimpleSQLiteQuery("update $localTable set offlineCrudType = ${OfflineCrudType.NONE.ordinal}, lastUpdatedTimestamp = $currentTimeStamp where id = ${localItem.id}"))
Log.d(
TAG,
"updated local offline crud type for ${localItem.id}"
)
} catch (ex: Exception) {
Log.e(
TAG,
"error while updating item from local to remote $ex",
)
}
}

(localItem.offlineFieldOpType == OfflineCrudType.DELETE.ordinal) -> {
try {
supabaseClient.postgrest.from(remoteTable).delete {
filter { eq(BaseRemoteEntity::id.name, remoteItem.id) }
}

localDao.delete(localItem)
Log.d(
TAG,
"deleting item from local and remote: id = ${localItem.id}"
)
} catch (ex: Exception) {
Log.e(
TAG,
"error while deleting item from local to remote $ex",
)
}
}
}
}

else -> {
//remote data is latest
//update local row with remote data
val count = localDao.update(toMap(remoteItem))
Log.d(
TAG,
"updated local record with remote data for ${remoteItem.id}, count = $count"
)
}
}
} else {
//no local data exists for this remote row, inserting in local table
try {
localDao.insert(toMap(remoteItem))
} catch (ex: Exception) {
Log.e(TAG, "error while inserting new remote data to local $ex")
Log.d(TAG, "remoteItem = $remoteItem")
}
}
}

try {
//now check whether data is deleted from remote and exists in local
val listOfLocalItems = localDao.query(SimpleSQLiteQuery("select * from $localTable"))
val listOfRemoteItems = supabaseClient.postgrest.from(remoteTable)
.select().data.decodeList<RDto>(serializer)

val idsOfRemoteItems = listOfRemoteItems.map { it.id }.toSet()
val toBeDeleted = listOfLocalItems.filter { it.id !in (idsOfRemoteItems) }
Log.d(
TAG,
"Delete check remoteItems count:${listOfRemoteItems.size}, localItem count:${listOfLocalItems.size} and toBeDeleted count:${toBeDeleted.size}"
)
toBeDeleted.forEach { localDao.delete(it) }
} catch (ex: Exception) {
Log.e(TAG, "error while deleting extra local entries $ex")
}


setLastSyncedTimeStamp(localTable, currentTimeStamp)
Log.d(TAG, "updating lastSyncedTimestamp for $localTable = $currentTimeStamp")


}

}

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 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.
object RetrofitClient {
private var BASE_URL = ""
private var apikey = ""
fun setupClient(baseUrl: String,apikey: String) {
this.BASE_URL = baseUrl
this.apikey = apikey
}
val rClient: SupabaseApiService by lazy {
val httpClient = OkHttpClient.Builder()
httpClient.addInterceptor(Interceptor {
val original = it.request()
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")
val request = original.newBuilder()
.header("apikey", apikey)
.header("Authorization", "Bearer $apikey")
.method(original.method, original.body)
.build()

it.proceed(request)
})
val client = httpClient.build()

val retrofit = Retrofit.Builder()
.baseUrl(BASE_URL)
.addConverterFactory(GsonConverterFactory.create())
.addConverterFactory(QueryParamConverter())
.client(client)
.build()

retrofit.create(SupabaseApiService::class.java)
}
}

7. SupabaseApiService

  • Purpose: Provides asynchronous methods for performing CRUD operations on Supabase. It is utilized by SyncManager to execute insert and update operations, handling request bodies and parsing 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.
interface SupabaseApiService {
@Headers("Content-Type: application/json", "Prefer: return=minimal")
@POST("/rest/v1/{table}")
suspend fun insert(
@Path("table") tableName: String,
@Body data: RequestBody
): Response<Unit>

@Headers("Content-Type: application/json", "Prefer: return=representation")
@POST("/rest/v1/{table}?select=id")
suspend fun insertReturnId(
@Path("table") tableName: String,
@Body data: RequestBody
): Response<ResponseBody>

@Headers("Content-Type: application/json", "Prefer: return=minimal")
@PATCH("/rest/v1/{table}")
suspend fun update(
@Path("table") tableName: String,
@Query("id") @eq id: Int,
@Body data: RequestBody
): Response<Unit>

...
}

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.
class NetworkHelper(context: Context) {

private val connectivityManager =
context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager

private val networkLiveData: MutableLiveData<Boolean> by lazy {
MutableLiveData<Boolean>().also {
it.postValue( connectivityManager.getNetworkCapabilities(connectivityManager.activeNetwork)
?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) ?: false)
}
}

private val networkRequest = NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build()


fun isNetworkAvailable(): Boolean {
val network = connectivityManager.activeNetwork ?: return false
val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false
return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
}

fun getNetworkLiveData(): LiveData<Boolean> {
val networkCallback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
networkLiveData.postValue(true)
}

override fun onLost(network: Network) {
networkLiveData.postValue(false)
}
}

connectivityManager.registerNetworkCallback(networkRequest, networkCallback)

return networkLiveData
}
}

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, delete queries via simple annotations.
@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.VALUE_PARAMETER)
annotation class eq

@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.VALUE_PARAMETER)
annotation class gt

@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.VALUE_PARAMETER)
annotation class lt


class QueryParamConverter: Converter.Factory(){
override fun stringConverter(
type: Type,
annotations: Array<out Annotation>,
retrofit: Retrofit
): Converter<*, String>? {
return when {
annotations.any { it is eq } -> {
Converter<Any, String> { value ->
"eq.$value"
}
}
annotations.any { it is gt } -> {
Converter<Any, String> { value ->
"gt.$value"
}
}
annotations.any { it is lt } -> {
Converter<Any, String> { value ->
"lt.$value"
}
}
else -> null
}
}
}

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.
fun <RDto : BaseRemoteEntity> String.decodeSingle(serializer: KSerializer<RDto>): RDto {
return Json.decodeFromString(ListSerializer(serializer), this).first()
}

fun <RDto : BaseRemoteEntity> String.decodeList(serializer: KSerializer<RDto>): List<RDto> {
return Json.decodeFromString(ListSerializer(serializer), this)
}

fun Response<ResponseBody>.getId(): Int {
val element = Json.parseToJsonElement(this.body()!!.string())
return if(element is JsonArray)
Json.decodeFromJsonElement<List<JsonId>>(element).first().id
else{
val error = Json.decodeFromJsonElement<JsonError>(element)
throw Exception("code: ${error.code}, hint: ${error.hint}, details: ${error.details}, message: ${error.message}")
}
}
@Serializable
data class JsonId(val id: Int)

@Serializable
data class JsonError(val code: String?,val details: String?, val hint: String?, val message:String? )

fun <T:BaseRemoteEntity> T.prepareRequestBody(serializer: KSerializer<T>): RequestBody {
val jsonString = Json.encodeToString(serializer,this)

val jsonMediaType = "application/json; charset=utf-8".toMediaType()
return jsonString.toRequestBody(jsonMediaType)
}
fun <T:BaseRemoteEntity> T.prepareRequestBodyWithoutId(serializer: KSerializer<T>): RequestBody {
val jsonString = Json.encodeToString(serializer,this)
val modifiedJson = removeIdFromJson(jsonString)
Log.d("Utils", "prepareRequestBodyWithoutId: original $jsonString and modified $modifiedJson ")
val jsonMediaType = "application/json; charset=utf-8".toMediaType()
return modifiedJson.toRequestBody(jsonMediaType)
}

fun removeIdFromJson(str: String): String{
val original = Json.parseToJsonElement(str).jsonObject
val modifiedObj = buildJsonObject {
original.entries.forEach {
(key,value)->
if(key != "id")
put(key,value)
}
}
return Json.encodeToString(JsonObject.serializer(),modifiedObj)
}

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 trigger but you can use your own 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 a 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:

syncManager.observeNetwork().observe(lifecycleOwner, { isAvailable ->
if (isAvailable) {
syncManager.syncToSupabase(...)
}
})

The Synchronization algorithm:

Algo’s flowchart
1.Check Network Availability:
Verify if the device has an active network connection. If not, exit the synchronization process.

2.Retrieve Local Changes:
Query the local database for items that have been updated since the last synchronization.
If no items are updated then skip step 4

3.Retrieve Remote Data:
Fetch data from the remote database to compare with local changes.

4.Handle Local Changes and Remote Conflict:
For each local item:
- Check if a corresponding item exists in the remote database.
- If a match is found:

-Compare timestamps to determine which version is more recent.
Apply the "Last Write Wins" strategy:
-If the local version is newer:
-Update the remote database with the local changes.
-Handle insertions, updates, and deletions accordingly.
-If the remote version is newer:
-Update the local database with the remote changes.
-If no match is found:

-Insert the local item into the remote database.
-Update the local item with the generated remote ID.

5.Update Local Data with Remote Changes:
For each remote item:
-Check if a corresponding item exists in the local database.
-If a match is found:
-Compare timestamps to determine which version is more recent.
Apply the "Last Write Wins" strategy:
-If the remote version is newer:
-Update the local database with the remote changes.
-If the local version is newer:
-Update the remote database with the local changes.
-If no match is found:
-Insert the remote item into the local database.

6.Cleanup:
Delete local items that no longer exist in the remote database.

7.Update Last Synced Timestamp:
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.
//project's build.gradle
plugins{
id 'org.jetbrains.kotlin.android' version '1.9.10' apply false
id 'org.jetbrains.kotlin.plugin.serialization' version '1.9.10' apply false
id 'org.jetbrains.kotlin.kapt' version '1.9.10' apply false
}
//app's build.gradle
plugins{
id 'org.jetbrains.kotlin.plugin.serialization'
id 'org.jetbrains.kotlin.kapt'
id 'androidx.room'
}

dependencies{
//for supabase
implementation platform("io.github.jan-tennert.supabase:bom:2.2.3")
implementation 'io.github.jan-tennert.supabase:postgrest-kt'
implementation 'io.ktor:ktor-client-android:2.3.9'

//for room components
implementation "androidx.room:room-runtime:2.6.1"
implementation "androidx.room:room-ktx:2.6.1"
annotationProcessor("androidx.room:room-compiler:2.6.1")
// To use Kotlin annotation processing tool (kapt)
kapt("androidx.room:room-compiler:2.6.1")

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

Each local entity which is participating in 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 corresponding local table need to extend BaseRemoteEntity in order 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.

Lets start with integration, We will be having 3 tables namely Task, Category, Priority

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

@Entity(
tableName = "tasks",
foreignKeys = [ForeignKey(
Category::class,
parentColumns = ["id"],
childColumns = ["category_id"],
onDelete = ForeignKey.CASCADE,
onUpdate = ForeignKey.CASCADE
), ForeignKey(
Priority::class,
["id"],
["priority_id"],
ForeignKey.CASCADE,
ForeignKey.CASCADE
)]
)
data class Task(
@PrimaryKey override val id: Int,
val name: String,
@ColumnInfo("category_id") val categoryId: Int,
val emoji: String,
val date: String,
@ColumnInfo("is_complete") var isComplete: Boolean,
@ColumnInfo("priority_id") val priorityId: Int,
override var lastUpdatedTimestamp: Long,
@ColumnInfo("is_delete") var isDelete: Boolean,
override var offlineFieldOpType: Int
) : BaseSyncableEntity()

@Dao
interface TaskDao: GenericDao<Task>
@Entity(tableName = "categories")
data class Category(
@PrimaryKey(autoGenerate = true) override val id: Int,
val name: String,
override var lastUpdatedTimestamp: Long,
override val offlineFieldOpType: Int
) : BaseSyncableEntity()

@Dao
interface CategoryDao: GenericDao<Category>

Similarly for Remote entities/ DTOs

@Serializable
data class TaskDto(
override val id: Int,
val name: String,
val category_id: Int,
val emoji: String,
@SerialName("is_complete") val isComplete: Boolean,
val date: String,
val priority_id: Int,
@SerialName("timestamp")override var lastUpdatedTimestamp: Long,
@SerialName("is_delete") val isDelete: Boolean
): BaseRemoteEntity()
@Serializable
data class CategoryDto(
val name: String,
@SerialName("timestamp") override var lastUpdatedTimestamp: Long,
override val id: Int
) : BaseRemoteEntity()

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

class TaskRepository(
private val context: Context,
) {

private val syncManager = SyncManager(context, SupabaseModule.provideSupabaseClient())
val networkConnected = syncManager.observeNetwork()
...
suspend fun forceUpdateTasks() {
syncManager.syncToSupabase(
"tasks",
taskLocalDataSource.taskDao,
"task",
{ it.toEntity(0) },
{ it.toDto() },
TaskDto.serializer(),
System.currentTimeMillis()
)
}

suspend fun forceUpdateCategories() {
syncManager.syncToSupabase(
"categories",
categoryLocalDataSource.categoryDao,
"categories",
{ it.toEntity(0) },
{ it.toDto() },
CategoryDto.serializer(),
System.currentTimeMillis()
)
}

}
//This is extension function which is passed in above forceUpdateCategories() parameters
fun Category.toDto(): CategoryDto = CategoryDto(
this.name,
this.lastUpdatedTimestamp,
this.id
)

fun CategoryDto.toEntity(crud: Int): Category =
Category(
this.id,
this.name,
this.lastUpdatedTimestamp,
crud
)

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

class MainActivity: AppCompatActivity(){
override fun onCreate(savedInstanceState: Bundle?) {
viewModel.networkConnected.observe(this@MainActivity, Observer {
var str = if(it){
//This method calls TaskRepository's method forceUpdateTasks()
viewModel.forceUpdate()
getString(R.string.device_online)
} else {
getString(R.string.device_offline)
}
Toast.makeText(this@MainActivity, str, Toast.LENGTH_SHORT).show()
})
}
}

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


class TaskRepository(private val context: Context){
//The task passed here has OfflineFieldOpType set to 1
suspend fun insertTask(task: Task): Result<Task> {

//perform operation based on network connection as local copy of remote data is present
if (networkConnected.value!!) {
//insert in local as well as remote
taskLocalDataSource.insert(task)
val result = taskRemoteDataSource.insert(task.toDto())
return if (result is Result.Success) {
//if remote insertion is successful then reset the OfflineFieldOpType to 0
taskLocalDataSource.setCrudById(task.id, 0)
Result.Success(task)
} else {
//remote insertion has failed so let the OfflineFieldOpType remain 1 in local to push it when synchronizing
Result.Failure((result as Result.Failure).exception)
}
} else {
//user is offline
taskLocalDataSource.insert(task)
return Result.Success(task)
}

}

private fun isSyncFirstRun(tableName: String) =
syncManager.getLastSyncedTimeStamp(tableName) == 0L

suspend fun updateTask(task: Task): Boolean {
if (isSyncFirstRun("tasks")) {
if (task.offlineFieldOpType == OfflineCrudType.UPDATE.ordinal) task.offlineFieldOpType =
OfflineCrudType.INSERT.ordinal
}
//updating the task in local table first so that changes are reflected fast in the UI
val count = taskLocalDataSource.updateTask(task)
if (networkConnected.value!!) {
val result = taskRemoteDataSource.updateTask(task.toDto())
if (result.succeeded) {
//if the task is updated in remote we can set OfflineFieldOpType to 0
taskLocalDataSource.setCrudById(task.id, 0)
} else {
//let the OfflineFieldOpType remain 2
Log.d(TAG, "updateTask: $result")
}
}
return count > 0
}

}

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 the 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

--

--