
Facilitates local data storage and synchronization with a remote server, ensuring data security and integrity. Supports file uploads, entity restrictions, and provides an intuitive interface.
Please note: This library currently is testing stage until publish the version 1.0.0. Meanwhile, it could have breaking changes in the API.
Horus is a client library for Kotlin Multiplatform aimed at providing an easy and simple way to store data locally and synchronize it with a remote server, ensuring data security and integrity.
Use Horus in server side to synchronize the data with the clients.
Add the repository and dependency in your build.gradle file:
kotlin {
/** ... Another configurations ... */
sourceSets {
androidMain.dependencies {
implementation("org.apptank.horus:client-android:{version}") // Android
}
commonMain.dependencies {
/** ... Dependencies for common module ... */
}
iosMain.dependencies {
implementation("org.apptank.horus:client:{version}") // IOS
}
}
}
Horusync needs the INTERNET and ACCESS_NETWORK_STATE permissions to be implemented in the AndroidManifest.xml of your application.
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
In the Application of your app configure horus using the HorusConfigurator class passing a HorusConfig object with the base server URL and the configuration of the pending actions.
The UploadFilesConfig class defines the settings for uploading files to the server.
The PushPendingActionsConfig class defines the settings for managing pending actions before
synchronization.
It includes the size of batches and the time expiration threshold to do synchronization.
It is also necessary to register HorusActivityLifeCycle to listen to the application's life cycle.
class MainApplication : Application() {
override fun onCreate() {
super.onCreate()
setupHorus()
}
private fun setupHorus() {
val BASE_SERVER_URL = "https://api.yourdomain.com/sync"
val uploadFileConfig = UploadFilesConfig(
baseStoragePath = filesDir.absolutePath,
mimeTypesAllowed = listOf(FileMimeType.IMAGE_JPEG_IMAGE_JPG, FileMimeType.IMAGE_PORTABLE_NETWORK_GRAPHICS),
maxFileSize = 1024 * 1024 * 5 // 5MB
)
// Configure Horus
val config = HorusConfig(
BASE_SERVER_URL,
uploadFileConfig,
PushPendingActionsConfig(batchSize = 10, expirationTime = 60 * 60 * 12L),
mapOf("custom-header" to "custom-value"),
isDebug = true
)
HorusConfigurator(config).configure(this)
// Register the activity lifecycle callbacks
registerActivityLifecycleCallbacks(HorusActivityLifeCycle())
}
} -lsqlite3 in the application's linker flags in XCode > Build
Settings > "Other Linker Flags". class AppDelegate: NSObject, UIApplicationDelegate {
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
IOSHorusLifeCycle().onCreate()
IOSHorusLifeCycle().onResume()
return true
}
func applicationDidBecomeActive(_ application: UIApplication) {
IOSHorusLifeCycle().onResume()
}
func applicationWillResignActive(_ application: UIApplication) {
IOSHorusLifeCycle().onPause()
}
}final class NetworkValidator: ClientINetworkValidator {
static let shared = NetworkValidator()
private let queue = DispatchQueue(label: "NetworkMonitor")
private let mutableQueue = DispatchQueue(label: "NetworkMonitor.mutable")
private let monitor = NWPathMonitor()
private var networkChangeCallback: (() -> Void)?
private var isMonitoring = false
private init() {
monitor.pathUpdateHandler = { [weak self] path in
guard let self = self else { return }
if self.isMonitoring {
self.networkChangeCallback?()
}
}
monitor.start(queue: queue)
}
func isNetworkAvailable() -> Bool {
let path = monitor.currentPath
return path.status == .satisfied
}
func onNetworkChange(callback: @escaping () -> Void) {
self.networkChangeCallback = callback
callback()
}
func registerNetworkCallback() {
guard !isMonitoring else { return }
setIsMonitoring(true)
}
func unregisterNetworkCallback() {
guard isMonitoring else { return }
setIsMonitoring(false)
}
private func setIsMonitoring(_ bool: Bool) {
mutableQueue.sync {
isMonitoring = bool
}
}
}@main
struct iOSApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
init(){
IOSHorusConfigurator().configure(networkValidator: NetworkValidator.shared)
}
var body: some Scene {
WindowGroup {
ContentView()
}
}
}The main way to interact with Horus is through its Facade class called HorusDataFacade, with it you will manage all data operations of your application.
Horus requires an internal validation and check before starting to be used, to ensure that Horus is ready to operate, use the onReady method of the HorusDataFacade class to know when this happens.
HorusDataFacade.onReady {
/** PUT YOUR CODE **/
} To know when a record is inserted, updated or deleted in an entity, subscribe to data changes by adding a DataChangeListener using the addDataChangeListener method of the **HorusDataFacade ** class.
HorusDataFacade.addDataChangeListener(object : DataChangeListener {
override fun onInsert(entity: String, id: String, data: DataMap) {
/** WHEN IS INSERTED A NEW RECORD **/
}
override fun onUpdate(entity: String, id: String, data: DataMap) {
/** WHEN IS UPDATED A RECORD **/
}
override fun onDelete(entity: String, id: String) {
/** WHEN IS DELETED A RECORD **/
}
}) Remove the listener
HorusDataFacade.removeDataChangeListener(listener)Clear all listeners
HorusDataFacade.removeAllDataChangeListeners()Horus internally validates whether there is an internet connection or not to synchronize the information with the server. The operations do not depend on an internet connection, Horus will always first register in the local database of the device.
To add a new record, use the insert method passing the entity name and a map with the record attributes. The method will return a DataResult with the new record ID if the operation was successful.
val entityName = "users"
val newData = mapOf("name":"Aston", "lastname":"Coleman")
val result = HorusDataFacade.insert(entityName, newData)
when (result) {
is DataResult.Success -> {
val entityId = result.data
/** YOUR CODE HERE WHEN INSERT IS SUCCESSFUL */
}
is DataResult.Failure -> {
/** YOUR CODE HERE WHEN INSERT FAILS */
}
is DataResult.NotAuthorized -> {
/** YOUR CODE HERE WHEN INSERT FAILS BECAUSE OF NOT AUTHORIZED */
}
} Alternative result validation
result.fold(
onSuccess = {
val entityId = result.data
/** YOUR CODE HERE WHEN INSERT IS SUCCESSFUL */
},
onFailure = {
/** YOUR CODE HERE WHEN INSERT FAILS */
}) To update a record, use the update method passing the entity name, the record ID, and a map with the attributes to update.
val userId = "0ca2caa1-74f1-4e58-a6a7-29e79efedfe4"
val newName = "Elton"
val result = HorusDataFacade.update(
"users", userId, mapOf(
"name" to newName
)
)
when (result) {
is DataResult.Success -> {
/** YOUR CODE HERE WHEN SUCCESS */
}
is DataResult.Failure -> {
/** YOUR CODE HERE WHEN FAILURE */
}
is DataResult.NotAuthorized -> {
/** YOUR CODE HERE WHEN UPDATE FAILS BECAUSE OF NOT AUTHORIZED */
}
} To delete a record, use the delete method passing the entity name and the record ID.
val userId = "0ca2caa1-74f1-4e58-a6a7-29e79efedfe4"
val result = HorusDataFacade.delete("users", userId)
when (result) {
is DataResult.Success -> {
/** YOUR CODE HERE WHEN SUCCESS */
}
is DataResult.Failure -> {
/** YOUR CODE HERE WHEN FAILURE */
}
is DataResult.NotAuthorized -> {
/** YOUR CODE HERE WHEN UPDATE FAILS BECAUSE OF NOT AUTHORIZED */
}
} To query the records of an entity, use the querySimple method passing the entity name and a list of search conditions.
Optional parameters:
val whereConditions = listOf(
SQL.WhereCondition(SQL.ColumnValue("age", 10), SQL.Comparator.GREATER_THAN_OR_EQUALS),
)
HorusDataFacade.querySimple("users", whereConditions, orderBy = "name")
.fold(
onSuccess = { users ->
//** YOUR CODE HERE WHEN SUCCESS */
},
onFailure = {
//** YOUR CODE HERE WHEN FAILURE */
})
To do a query with more complex conditions, use the query method passing a query builder object.
val builder = SimpleQueryBuilder("entity").where(
SQL.WhereCondition(SQL.ColumnValue("name", "John%"), SQL.Comparator.LIKE)
).whereOr(
SQL.WhereCondition(SQL.ColumnValue("lastname","John%"), SQL.Comparator.LIKE)
)
HorusDataFacade.query(builder)To find records within a certain distance from a geographic point, use the SQL.Coordinates.WithIn extension. This is useful for location-based queries, such as finding nearby places or points of interest.
The coordinates must be stored in the database as a string with the format "latitude,longitude" (e.g., "4.6097,-74.0817").
Parameters:
column: The name of the column containing the coordinatespoint: The reference point (Horus.Point) containing latitude and longitude.distanceInKm: The maximum distance in kilometers from the reference point.// Reference point (e.g., user's current location)
val currentLocation = Horus.Point(
latitude = 4.6097,
longitude = -74.0817
)
// Find all places within 5 km from the current location
val builder = SimpleQueryBuilder("places").withExtension(
SQL.Coordinates.WithIn(
column = "location",
point = currentLocation,
distanceInKm = 5.0
)
)
HorusDataFacade.query(builder).fold(
onSuccess = { nearbyPlaces ->
// Process the nearby places
nearbyPlaces.forEach { place ->
println("Found: ${place["name"]}")
}
},
onFailure = { error ->
// Handle error
}
)Note: The coordinate extension uses a bounding box approximation for efficient filtering. This works well for distances up to several hundred kilometers.
To get a record by its ID, use the getById method passing the entity name and the record ID.
val userId = "0ca2caa1-74f1-4e58-a6a7-29e79efedfe4"
val user = HorusDataFacade.getById("users", userId)
if (user != null) {
//** YOUR CODE HERE WHEN RECORD EXISTS **/
}
To get the number of records in an entity, use the countRecordFromEntity method passing the entity name.
HorusDataFacade.countRecordFromEntity("users").fold(
onSuccess = { count ->
//** YOUR CODE HERE WHEN SUCCESS */
},
onFailure = {
//** YOUR CODE HERE WHEN FAILURE */
}
)To query data shared by another user, use the queryShared method passing the entity name, the entity ID and a list of attributes to filter the data.
HorusDataFacade.queryDataShared(
entityName,
entityId,
listOf(Horus.Attribute("attr", "value"))
)To upload files to the server is simple, use the uploadFile method passing the file data in bytes and then use the getFileUrl method to get the file URL to use where you need it.
val fileReference = HorusDataFacade.uploadFile(fileData)
val fileUrl = HorusDataFacade.getFileUrl(fileReference)
To get the list of entities that are being managed by Horus, use the getEntityNames method.
val entityNames = HorusDataFacade.getEntityNames()To force the synchronization of the data with the server, use the forceSync method.
HorusDataFacade.forceSync(onSuccess = {
/** YOUR CODE HERE WHEN SUCCESS */
}, onFailure = {
/** YOUR CODE HERE WHEN FAILURE */
})To validate if there is data to synchronize, use the hasDataToSync method.
val hasDataToSync = HorusDataFacade.hasDataToSync()To get the last synchronization date, use the getLastSyncDate method. This method will return a timestamp in seconds.
val lastSyncDate = HorusDataFacade.getLastSyncDate()Horus needs a user access token in session to be able to send the information to the remote server, for this use the HorusAuthentication class within the life cycle of the user session of your application to be able to configure the access token.
HorusAuthentication.setupUserAccessToken("{ACCESS_TOKEN}") HorusAuthentication.clearSession() If the user must act on behalf of another user by invitation, the owner user of the entities must be configured as follows:
HorusAuthentication.setUserActingAs("{USER_OWNER_ID}") You can add specific restrictions for an entity, for example, to limit the number of records that can be stored in an entity. If the restriction is reached, the operation will fail and data result will be DataResult.NotAuthorized.
Use the setEntityRestrictions method of the HorusDataFacade class to set the restrictions.
HorusDataFacade.setEntityRestrictions(
listOf(
MaxCountEntityRestriction("tasks", 100) // Limit to 100 records in the "tasks" entity
)
)../gradlew publishToMavenLocalLocation of the dependencies
C:\Users[User].m2\repository\com\apptank\horus\client\core
Please note: This library currently is testing stage until publish the version 1.0.0. Meanwhile, it could have breaking changes in the API.
Horus is a client library for Kotlin Multiplatform aimed at providing an easy and simple way to store data locally and synchronize it with a remote server, ensuring data security and integrity.
Use Horus in server side to synchronize the data with the clients.
Add the repository and dependency in your build.gradle file:
kotlin {
/** ... Another configurations ... */
sourceSets {
androidMain.dependencies {
implementation("org.apptank.horus:client-android:{version}") // Android
}
commonMain.dependencies {
/** ... Dependencies for common module ... */
}
iosMain.dependencies {
implementation("org.apptank.horus:client:{version}") // IOS
}
}
}
Horusync needs the INTERNET and ACCESS_NETWORK_STATE permissions to be implemented in the AndroidManifest.xml of your application.
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
In the Application of your app configure horus using the HorusConfigurator class passing a HorusConfig object with the base server URL and the configuration of the pending actions.
The UploadFilesConfig class defines the settings for uploading files to the server.
The PushPendingActionsConfig class defines the settings for managing pending actions before
synchronization.
It includes the size of batches and the time expiration threshold to do synchronization.
It is also necessary to register HorusActivityLifeCycle to listen to the application's life cycle.
class MainApplication : Application() {
override fun onCreate() {
super.onCreate()
setupHorus()
}
private fun setupHorus() {
val BASE_SERVER_URL = "https://api.yourdomain.com/sync"
val uploadFileConfig = UploadFilesConfig(
baseStoragePath = filesDir.absolutePath,
mimeTypesAllowed = listOf(FileMimeType.IMAGE_JPEG_IMAGE_JPG, FileMimeType.IMAGE_PORTABLE_NETWORK_GRAPHICS),
maxFileSize = 1024 * 1024 * 5 // 5MB
)
// Configure Horus
val config = HorusConfig(
BASE_SERVER_URL,
uploadFileConfig,
PushPendingActionsConfig(batchSize = 10, expirationTime = 60 * 60 * 12L),
mapOf("custom-header" to "custom-value"),
isDebug = true
)
HorusConfigurator(config).configure(this)
// Register the activity lifecycle callbacks
registerActivityLifecycleCallbacks(HorusActivityLifeCycle())
}
} -lsqlite3 in the application's linker flags in XCode > Build
Settings > "Other Linker Flags". class AppDelegate: NSObject, UIApplicationDelegate {
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
IOSHorusLifeCycle().onCreate()
IOSHorusLifeCycle().onResume()
return true
}
func applicationDidBecomeActive(_ application: UIApplication) {
IOSHorusLifeCycle().onResume()
}
func applicationWillResignActive(_ application: UIApplication) {
IOSHorusLifeCycle().onPause()
}
}final class NetworkValidator: ClientINetworkValidator {
static let shared = NetworkValidator()
private let queue = DispatchQueue(label: "NetworkMonitor")
private let mutableQueue = DispatchQueue(label: "NetworkMonitor.mutable")
private let monitor = NWPathMonitor()
private var networkChangeCallback: (() -> Void)?
private var isMonitoring = false
private init() {
monitor.pathUpdateHandler = { [weak self] path in
guard let self = self else { return }
if self.isMonitoring {
self.networkChangeCallback?()
}
}
monitor.start(queue: queue)
}
func isNetworkAvailable() -> Bool {
let path = monitor.currentPath
return path.status == .satisfied
}
func onNetworkChange(callback: @escaping () -> Void) {
self.networkChangeCallback = callback
callback()
}
func registerNetworkCallback() {
guard !isMonitoring else { return }
setIsMonitoring(true)
}
func unregisterNetworkCallback() {
guard isMonitoring else { return }
setIsMonitoring(false)
}
private func setIsMonitoring(_ bool: Bool) {
mutableQueue.sync {
isMonitoring = bool
}
}
}@main
struct iOSApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
init(){
IOSHorusConfigurator().configure(networkValidator: NetworkValidator.shared)
}
var body: some Scene {
WindowGroup {
ContentView()
}
}
}The main way to interact with Horus is through its Facade class called HorusDataFacade, with it you will manage all data operations of your application.
Horus requires an internal validation and check before starting to be used, to ensure that Horus is ready to operate, use the onReady method of the HorusDataFacade class to know when this happens.
HorusDataFacade.onReady {
/** PUT YOUR CODE **/
} To know when a record is inserted, updated or deleted in an entity, subscribe to data changes by adding a DataChangeListener using the addDataChangeListener method of the **HorusDataFacade ** class.
HorusDataFacade.addDataChangeListener(object : DataChangeListener {
override fun onInsert(entity: String, id: String, data: DataMap) {
/** WHEN IS INSERTED A NEW RECORD **/
}
override fun onUpdate(entity: String, id: String, data: DataMap) {
/** WHEN IS UPDATED A RECORD **/
}
override fun onDelete(entity: String, id: String) {
/** WHEN IS DELETED A RECORD **/
}
}) Remove the listener
HorusDataFacade.removeDataChangeListener(listener)Clear all listeners
HorusDataFacade.removeAllDataChangeListeners()Horus internally validates whether there is an internet connection or not to synchronize the information with the server. The operations do not depend on an internet connection, Horus will always first register in the local database of the device.
To add a new record, use the insert method passing the entity name and a map with the record attributes. The method will return a DataResult with the new record ID if the operation was successful.
val entityName = "users"
val newData = mapOf("name":"Aston", "lastname":"Coleman")
val result = HorusDataFacade.insert(entityName, newData)
when (result) {
is DataResult.Success -> {
val entityId = result.data
/** YOUR CODE HERE WHEN INSERT IS SUCCESSFUL */
}
is DataResult.Failure -> {
/** YOUR CODE HERE WHEN INSERT FAILS */
}
is DataResult.NotAuthorized -> {
/** YOUR CODE HERE WHEN INSERT FAILS BECAUSE OF NOT AUTHORIZED */
}
} Alternative result validation
result.fold(
onSuccess = {
val entityId = result.data
/** YOUR CODE HERE WHEN INSERT IS SUCCESSFUL */
},
onFailure = {
/** YOUR CODE HERE WHEN INSERT FAILS */
}) To update a record, use the update method passing the entity name, the record ID, and a map with the attributes to update.
val userId = "0ca2caa1-74f1-4e58-a6a7-29e79efedfe4"
val newName = "Elton"
val result = HorusDataFacade.update(
"users", userId, mapOf(
"name" to newName
)
)
when (result) {
is DataResult.Success -> {
/** YOUR CODE HERE WHEN SUCCESS */
}
is DataResult.Failure -> {
/** YOUR CODE HERE WHEN FAILURE */
}
is DataResult.NotAuthorized -> {
/** YOUR CODE HERE WHEN UPDATE FAILS BECAUSE OF NOT AUTHORIZED */
}
} To delete a record, use the delete method passing the entity name and the record ID.
val userId = "0ca2caa1-74f1-4e58-a6a7-29e79efedfe4"
val result = HorusDataFacade.delete("users", userId)
when (result) {
is DataResult.Success -> {
/** YOUR CODE HERE WHEN SUCCESS */
}
is DataResult.Failure -> {
/** YOUR CODE HERE WHEN FAILURE */
}
is DataResult.NotAuthorized -> {
/** YOUR CODE HERE WHEN UPDATE FAILS BECAUSE OF NOT AUTHORIZED */
}
} To query the records of an entity, use the querySimple method passing the entity name and a list of search conditions.
Optional parameters:
val whereConditions = listOf(
SQL.WhereCondition(SQL.ColumnValue("age", 10), SQL.Comparator.GREATER_THAN_OR_EQUALS),
)
HorusDataFacade.querySimple("users", whereConditions, orderBy = "name")
.fold(
onSuccess = { users ->
//** YOUR CODE HERE WHEN SUCCESS */
},
onFailure = {
//** YOUR CODE HERE WHEN FAILURE */
})
To do a query with more complex conditions, use the query method passing a query builder object.
val builder = SimpleQueryBuilder("entity").where(
SQL.WhereCondition(SQL.ColumnValue("name", "John%"), SQL.Comparator.LIKE)
).whereOr(
SQL.WhereCondition(SQL.ColumnValue("lastname","John%"), SQL.Comparator.LIKE)
)
HorusDataFacade.query(builder)To find records within a certain distance from a geographic point, use the SQL.Coordinates.WithIn extension. This is useful for location-based queries, such as finding nearby places or points of interest.
The coordinates must be stored in the database as a string with the format "latitude,longitude" (e.g., "4.6097,-74.0817").
Parameters:
column: The name of the column containing the coordinatespoint: The reference point (Horus.Point) containing latitude and longitude.distanceInKm: The maximum distance in kilometers from the reference point.// Reference point (e.g., user's current location)
val currentLocation = Horus.Point(
latitude = 4.6097,
longitude = -74.0817
)
// Find all places within 5 km from the current location
val builder = SimpleQueryBuilder("places").withExtension(
SQL.Coordinates.WithIn(
column = "location",
point = currentLocation,
distanceInKm = 5.0
)
)
HorusDataFacade.query(builder).fold(
onSuccess = { nearbyPlaces ->
// Process the nearby places
nearbyPlaces.forEach { place ->
println("Found: ${place["name"]}")
}
},
onFailure = { error ->
// Handle error
}
)Note: The coordinate extension uses a bounding box approximation for efficient filtering. This works well for distances up to several hundred kilometers.
To get a record by its ID, use the getById method passing the entity name and the record ID.
val userId = "0ca2caa1-74f1-4e58-a6a7-29e79efedfe4"
val user = HorusDataFacade.getById("users", userId)
if (user != null) {
//** YOUR CODE HERE WHEN RECORD EXISTS **/
}
To get the number of records in an entity, use the countRecordFromEntity method passing the entity name.
HorusDataFacade.countRecordFromEntity("users").fold(
onSuccess = { count ->
//** YOUR CODE HERE WHEN SUCCESS */
},
onFailure = {
//** YOUR CODE HERE WHEN FAILURE */
}
)To query data shared by another user, use the queryShared method passing the entity name, the entity ID and a list of attributes to filter the data.
HorusDataFacade.queryDataShared(
entityName,
entityId,
listOf(Horus.Attribute("attr", "value"))
)To upload files to the server is simple, use the uploadFile method passing the file data in bytes and then use the getFileUrl method to get the file URL to use where you need it.
val fileReference = HorusDataFacade.uploadFile(fileData)
val fileUrl = HorusDataFacade.getFileUrl(fileReference)
To get the list of entities that are being managed by Horus, use the getEntityNames method.
val entityNames = HorusDataFacade.getEntityNames()To force the synchronization of the data with the server, use the forceSync method.
HorusDataFacade.forceSync(onSuccess = {
/** YOUR CODE HERE WHEN SUCCESS */
}, onFailure = {
/** YOUR CODE HERE WHEN FAILURE */
})To validate if there is data to synchronize, use the hasDataToSync method.
val hasDataToSync = HorusDataFacade.hasDataToSync()To get the last synchronization date, use the getLastSyncDate method. This method will return a timestamp in seconds.
val lastSyncDate = HorusDataFacade.getLastSyncDate()Horus needs a user access token in session to be able to send the information to the remote server, for this use the HorusAuthentication class within the life cycle of the user session of your application to be able to configure the access token.
HorusAuthentication.setupUserAccessToken("{ACCESS_TOKEN}") HorusAuthentication.clearSession() If the user must act on behalf of another user by invitation, the owner user of the entities must be configured as follows:
HorusAuthentication.setUserActingAs("{USER_OWNER_ID}") You can add specific restrictions for an entity, for example, to limit the number of records that can be stored in an entity. If the restriction is reached, the operation will fail and data result will be DataResult.NotAuthorized.
Use the setEntityRestrictions method of the HorusDataFacade class to set the restrictions.
HorusDataFacade.setEntityRestrictions(
listOf(
MaxCountEntityRestriction("tasks", 100) // Limit to 100 records in the "tasks" entity
)
)../gradlew publishToMavenLocalLocation of the dependencies
C:\Users[User].m2\repository\com\apptank\horus\client\core