Overview
This guide covers integration patterns for consuming the Invernaderos API in native mobile applications. The API provides REST endpoints for historical data and WebSocket connections for real-time greenhouse monitoring. API Base URL:https://api.invernaderos.example.com/api/v1
Architecture Overview
Platform-Specific Setup
// Package.swift dependencies
.package(url: "https://github.com/Alamofire/Alamofire.git", from: "5.8.0"),
.package(url: "https://github.com/daltoniam/Starscream.git", from: "4.0.0"),
// Or CocoaPods
pod 'Alamofire', '~> 5.8'
pod 'Starscream', '~> 4.0'
pod 'SwiftyJSON', '~> 5.0'
Authentication Flow
Android Implementation
Define API service interface
import retrofit2.Response
import retrofit2.http.*
data class LoginRequest(
val username: String,
val password: String
)
data class JwtResponse(
val token: String,
val type: String = "Bearer",
val username: String,
val roles: List<String>
)
interface AuthApi {
@POST("auth/login")
suspend fun login(@Body request: LoginRequest): Response<JwtResponse>
@POST("auth/register")
suspend fun register(@Body request: RegisterRequest): Response<JwtResponse>
}
Create authentication manager
import android.content.Context
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
class AuthManager(context: Context) {
private val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
private val securePrefs = EncryptedSharedPreferences.create(
context,
"secure_prefs",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
fun saveToken(token: String) {
securePrefs.edit().putString("jwt_token", token).apply()
}
fun getToken(): String? {
return securePrefs.getString("jwt_token", null)
}
fun clearToken() {
securePrefs.edit().remove("jwt_token").apply()
}
fun isAuthenticated(): Boolean = getToken() != null
}
Configure Retrofit with authentication
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.util.concurrent.TimeUnit
class ApiClient(private val authManager: AuthManager) {
private val authInterceptor = Interceptor { chain ->
val original = chain.request()
val requestBuilder = original.newBuilder()
authManager.getToken()?.let { token ->
requestBuilder.addHeader("Authorization", "Bearer $token")
}
chain.proceed(requestBuilder.build())
}
private val loggingInterceptor = HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
}
private val httpClient = OkHttpClient.Builder()
.addInterceptor(authInterceptor)
.addInterceptor(loggingInterceptor)
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build()
private val retrofit = Retrofit.Builder()
.baseUrl("https://api.invernaderos.example.com/api/v1/")
.client(httpClient)
.addConverterFactory(GsonConverterFactory.create())
.build()
val authApi: AuthApi = retrofit.create(AuthApi::class.java)
val greenhouseApi: GreenhouseApi = retrofit.create(GreenhouseApi::class.java)
val sensorApi: SensorApi = retrofit.create(SensorApi::class.java)
}
iOS Implementation
Create API models
import Foundation
struct LoginRequest: Codable {
let username: String
let password: String
}
struct JwtResponse: Codable {
let token: String
let type: String
let username: String
let roles: [String]
}
enum ApiError: Error {
case networkError
case authenticationError
case invalidResponse
case serverError(Int)
}
Implement token manager
import Security
class KeychainManager {
static let shared = KeychainManager()
private let service = "com.invernaderos.api"
func saveToken(_ token: String) {
let data = token.data(using: .utf8)!
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: "jwt_token",
kSecValueData as String: data
]
SecItemDelete(query as CFDictionary)
SecItemAdd(query as CFDictionary, nil)
}
func getToken() -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: "jwt_token",
kSecReturnData as String: true
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess,
let data = result as? Data,
let token = String(data: data, encoding: .utf8) else {
return nil
}
return token
}
func clearToken() {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: "jwt_token"
]
SecItemDelete(query as CFDictionary)
}
}
Create API service
import Alamofire
class ApiService {
static let shared = ApiService()
private let baseURL = "https://api.invernaderos.example.com/api/v1"
func login(username: String, password: String) async throws -> String {
let request = LoginRequest(username: username, password: password)
let response = try await AF.request(
"\(baseURL)/auth/login",
method: .post,
parameters: request,
encoder: JSONParameterEncoder.default
)
.validate()
.serializingDecodable(JwtResponse.self)
.value
KeychainManager.shared.saveToken(response.token)
return response.token
}
func request<T: Decodable>(
_ endpoint: String,
method: HTTPMethod = .get,
parameters: Parameters? = nil
) async throws -> T {
guard let token = KeychainManager.shared.getToken() else {
throw ApiError.authenticationError
}
let headers: HTTPHeaders = [
"Authorization": "Bearer \(token)"
]
return try await AF.request(
"\(baseURL)\(endpoint)",
method: method,
parameters: parameters,
headers: headers
)
.validate()
.serializingDecodable(T.self)
.value
}
}
Use platform-specific secure storage for JWT tokens:
- Android:
EncryptedSharedPreferenceswith AES256-GCM encryption - iOS:
Keychain Serviceswith proper access control
REST API Consumption
Data Models
import com.google.gson.annotations.SerializedName
import java.time.Instant
data class RealDataDto(
val timestamp: Instant,
@SerializedName("TEMPERATURA INVERNADERO 01")
val temperaturaInvernadero01: Double? = null,
@SerializedName("HUMEDAD INVERNADERO 01")
val humedadInvernadero01: Double? = null,
@SerializedName("TEMPERATURA INVERNADERO 02")
val temperaturaInvernadero02: Double? = null,
@SerializedName("HUMEDAD INVERNADERO 02")
val humedadInvernadero02: Double? = null,
@SerializedName("INVERNADERO_01_SECTOR_01")
val invernadero01Sector01: Double? = null,
@SerializedName("INVERNADERO_01_EXTRACTOR")
val invernadero01Extractor: Double? = null,
val greenhouseId: String? = null,
val tenantId: String? = null
)
data class SensorReadingResponse(
val time: Instant,
val sensorId: String,
val greenhouseId: String,
val sensorType: String,
val value: Double,
val unit: String?
)
API Interfaces
interface GreenhouseApi {
@GET("greenhouse/messages/recent")
suspend fun getRecentMessages(
@Query("tenantId") tenantId: String?,
@Query("limit") limit: Int = 100
): Response<List<RealDataDto>>
@GET("greenhouse/messages/range")
suspend fun getMessagesByTimeRange(
@Query("tenantId") tenantId: String?,
@Query("from") from: String,
@Query("to") to: String
): Response<List<RealDataDto>>
@GET("greenhouse/cache/info")
suspend fun getCacheInfo(
@Query("tenantId") tenantId: String?
): Response<Map<String, Any>>
}
interface SensorApi {
@GET("sensors/latest")
suspend fun getLatestReadings(
@Query("greenhouseId") greenhouseId: String?,
@Query("limit") limit: Int = 10
): Response<List<SensorReadingResponse>>
@GET("sensors/by-greenhouse/{greenhouseId}")
suspend fun getReadingsByGreenhouse(
@PathVariable greenhouseId: String,
@Query("hours") hours: Long = 24
): Response<List<SensorReadingResponse>>
@GET("sensors/current")
suspend fun getCurrentSensorValues(
@Query("greenhouseId") greenhouseId: Long
): Response<Map<String, Any>>
}
WebSocket Connection
Android Implementation
import okhttp3.*
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import com.google.gson.Gson
class WebSocketManager(private val authManager: AuthManager) {
private var webSocket: WebSocket? = null
private val gson = Gson()
private val _messages = MutableSharedFlow<RealDataDto>()
val messages: SharedFlow<RealDataDto> = _messages
private val _connectionState = MutableSharedFlow<ConnectionState>()
val connectionState: SharedFlow<ConnectionState> = _connectionState
fun connect() {
val token = authManager.getToken() ?: return
val client = OkHttpClient.Builder()
.pingInterval(30, TimeUnit.SECONDS)
.build()
val request = Request.Builder()
.url("wss://api.invernaderos.example.com/ws/greenhouse")
.build()
webSocket = client.newWebSocket(request, object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
_connectionState.tryEmit(ConnectionState.Connected)
// Send STOMP CONNECT
val connectFrame = """
CONNECT
accept-version:1.2
authorization:Bearer $token
\u0000
""".trimIndent()
webSocket.send(connectFrame)
}
override fun onMessage(webSocket: WebSocket, text: String) {
handleStompFrame(text)
}
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
_connectionState.tryEmit(ConnectionState.Disconnecting)
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
_connectionState.tryEmit(ConnectionState.Disconnected)
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
_connectionState.tryEmit(ConnectionState.Error(t.message ?: "Unknown error"))
}
})
}
private fun handleStompFrame(frame: String) {
when {
frame.startsWith("CONNECTED") -> {
// Subscribe to greenhouse messages
val subscribeFrame = """
SUBSCRIBE
id:sub-0
destination:/topic/greenhouse/messages
\u0000
""".trimIndent()
webSocket?.send(subscribeFrame)
}
frame.startsWith("MESSAGE") -> {
val bodyStart = frame.indexOf("\n\n") + 2
val body = frame.substring(bodyStart).trim('\u0000')
try {
val message = gson.fromJson(body, RealDataDto::class.java)
_messages.tryEmit(message)
} catch (e: Exception) {
e.printStackTrace()
}
}
}
}
fun disconnect() {
webSocket?.close(1000, "Client disconnecting")
webSocket = null
}
sealed class ConnectionState {
object Connected : ConnectionState()
object Disconnecting : ConnectionState()
object Disconnected : ConnectionState()
data class Error(val message: String) : ConnectionState()
}
}
iOS Implementation
import Starscream
import Combine
class WebSocketManager: WebSocketDelegate {
private var socket: WebSocket?
private let messageSubject = PassthroughSubject<RealDataDto, Never>()
private let connectionSubject = PassthroughSubject<ConnectionState, Never>()
var messagePublisher: AnyPublisher<RealDataDto, Never> {
messageSubject.eraseToAnyPublisher()
}
var connectionPublisher: AnyPublisher<ConnectionState, Never> {
connectionSubject.eraseToAnyPublisher()
}
func connect() {
guard let token = KeychainManager.shared.getToken() else { return }
var request = URLRequest(url: URL(string: "wss://api.invernaderos.example.com/ws/greenhouse")!)
request.timeoutInterval = 30
socket = WebSocket(request: request)
socket?.delegate = self
socket?.connect()
}
func didReceive(event: WebSocketEvent, client: WebSocketClient) {
switch event {
case .connected(_):
connectionSubject.send(.connected)
sendStompConnect()
case .text(let text):
handleStompFrame(text)
case .disconnected(let reason, let code):
connectionSubject.send(.disconnected)
case .error(let error):
connectionSubject.send(.error(error?.localizedDescription ?? "Unknown error"))
default:
break
}
}
private func sendStompConnect() {
guard let token = KeychainManager.shared.getToken() else { return }
let frame = """
CONNECT
accept-version:1.2
authorization:Bearer \(token)
\u{0000}
"""
socket?.write(string: frame)
}
private func handleStompFrame(_ frame: String) {
if frame.hasPrefix("CONNECTED") {
subscribeToMessages()
} else if frame.hasPrefix("MESSAGE") {
if let bodyStart = frame.range(of: "\n\n")?.upperBound {
let body = frame[bodyStart...].trimmingCharacters(in: CharacterSet(charactersIn: "\u{0000}"))
if let data = body.data(using: .utf8),
let message = try? JSONDecoder().decode(RealDataDto.self, from: data) {
messageSubject.send(message)
}
}
}
}
private func subscribeToMessages() {
let frame = """
SUBSCRIBE
id:sub-0
destination:/topic/greenhouse/messages
\u{0000}
"""
socket?.write(string: frame)
}
func disconnect() {
socket?.disconnect()
}
enum ConnectionState {
case connected
case disconnected
case error(String)
}
}
The WebSocket endpoint uses STOMP protocol over WebSocket. Always send a STOMP CONNECT frame before subscribing to topics.
Offline Data Handling
Android Room Database
Define Room entities
import androidx.room.*
import java.time.Instant
@Entity(tableName = "sensor_readings")
data class SensorReadingEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val timestamp: Long,
val sensorId: String,
val greenhouseId: String,
val value: Double,
val unit: String?,
val synced: Boolean = false
)
@Dao
interface SensorReadingDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(reading: SensorReadingEntity)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(readings: List<SensorReadingEntity>)
@Query("SELECT * FROM sensor_readings WHERE greenhouseId = :greenhouseId ORDER BY timestamp DESC LIMIT :limit")
suspend fun getRecentReadings(greenhouseId: String, limit: Int): List<SensorReadingEntity>
@Query("SELECT * FROM sensor_readings WHERE synced = 0")
suspend fun getUnsyncedReadings(): List<SensorReadingEntity>
@Query("UPDATE sensor_readings SET synced = 1 WHERE id IN (:ids)")
suspend fun markAsSynced(ids: List<Long>)
@Query("DELETE FROM sensor_readings WHERE timestamp < :timestamp")
suspend fun deleteOldReadings(timestamp: Long)
}
@Database(entities = [SensorReadingEntity::class], version = 1)
abstract class AppDatabase : RoomDatabase() {
abstract fun sensorReadingDao(): SensorReadingDao
}
Implement cache-first repository
class GreenhouseRepository(
private val api: GreenhouseApi,
private val dao: SensorReadingDao,
private val networkMonitor: NetworkMonitor
) {
suspend fun getRecentReadings(
greenhouseId: String,
limit: Int = 100
): List<SensorReadingResponse> {
// Try network first
if (networkMonitor.isConnected()) {
try {
val response = api.getReadingsByGreenhouse(greenhouseId, 24)
if (response.isSuccessful) {
response.body()?.let { readings ->
// Cache the results
val entities = readings.map { it.toEntity() }
dao.insertAll(entities)
return readings
}
}
} catch (e: Exception) {
// Fall through to cache
}
}
// Fall back to cache
val cachedReadings = dao.getRecentReadings(greenhouseId, limit)
return cachedReadings.map { it.toResponse() }
}
suspend fun syncPendingData() {
if (!networkMonitor.isConnected()) return
val unsyncedReadings = dao.getUnsyncedReadings()
if (unsyncedReadings.isEmpty()) return
try {
// Batch sync to server
// Implementation depends on your API
// Mark as synced
dao.markAsSynced(unsyncedReadings.map { it.id })
} catch (e: Exception) {
// Retry later
}
}
suspend fun cleanupOldData(daysToKeep: Int = 30) {
val cutoffTime = System.currentTimeMillis() - (daysToKeep * 24 * 60 * 60 * 1000L)
dao.deleteOldReadings(cutoffTime)
}
}
Setup WorkManager for sync
import androidx.work.*
import java.util.concurrent.TimeUnit
class SyncWorker(
context: Context,
params: WorkerParameters
) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
val repository = (applicationContext as App).repository
return try {
repository.syncPendingData()
repository.cleanupOldData()
Result.success()
} catch (e: Exception) {
Result.retry()
}
}
}
// Schedule periodic sync
val syncWorkRequest = PeriodicWorkRequestBuilder<SyncWorker>(
15, TimeUnit.MINUTES,
5, TimeUnit.MINUTES
)
.setConstraints(
Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
)
.build()
WorkManager.getInstance(context)
.enqueueUniquePeriodicWork(
"sync_greenhouse_data",
ExistingPeriodicWorkPolicy.KEEP,
syncWorkRequest
)
iOS Core Data
Define Core Data model
import CoreData
@objc(SensorReadingEntity)
class SensorReadingEntity: NSManagedObject {
@NSManaged var id: UUID
@NSManaged var timestamp: Date
@NSManaged var sensorId: String
@NSManaged var greenhouseId: String
@NSManaged var value: Double
@NSManaged var unit: String?
@NSManaged var synced: Bool
}
extension SensorReadingEntity {
@nonobjc class func fetchRequest() -> NSFetchRequest<SensorReadingEntity> {
return NSFetchRequest<SensorReadingEntity>(entityName: "SensorReadingEntity")
}
}
Implement repository with offline support
import CoreData
class GreenhouseRepository {
private let apiService: ApiService
private let context: NSManagedObjectContext
private let networkMonitor: NetworkMonitor
init(
apiService: ApiService,
context: NSManagedObjectContext,
networkMonitor: NetworkMonitor
) {
self.apiService = apiService
self.context = context
self.networkMonitor = networkMonitor
}
func getRecentReadings(
greenhouseId: String,
limit: Int = 100
) async throws -> [SensorReadingResponse] {
// Try network first
if networkMonitor.isConnected {
do {
let readings: [SensorReadingResponse] = try await apiService.request(
"/sensors/by-greenhouse/\(greenhouseId)",
parameters: ["hours": 24]
)
// Cache the results
await cacheReadings(readings)
return readings
} catch {
// Fall through to cache
}
}
// Fall back to cache
return try await fetchCachedReadings(greenhouseId: greenhouseId, limit: limit)
}
private func cacheReadings(_ readings: [SensorReadingResponse]) async {
await context.perform {
for reading in readings {
let entity = SensorReadingEntity(context: self.context)
entity.id = UUID()
entity.timestamp = reading.time
entity.sensorId = reading.sensorId
entity.greenhouseId = reading.greenhouseId
entity.value = reading.value
entity.unit = reading.unit
entity.synced = true
}
try? self.context.save()
}
}
private func fetchCachedReadings(
greenhouseId: String,
limit: Int
) async throws -> [SensorReadingResponse] {
try await context.perform {
let request = SensorReadingEntity.fetchRequest()
request.predicate = NSPredicate(format: "greenhouseId == %@", greenhouseId)
request.sortDescriptors = [NSSortDescriptor(key: "timestamp", ascending: false)]
request.fetchLimit = limit
let entities = try self.context.fetch(request)
return entities.map { $0.toResponse() }
}
}
}
Implement offline-first architecture for better user experience:
- Cache API responses locally
- Queue failed requests for retry
- Sync data when network becomes available
- Display cached data during offline mode
Best Practices
Performance Optimization
- Pagination: Load data in chunks to reduce memory usage
- Image Caching: Use Glide (Android) or SDWebImage (iOS) for image caching
- Background Sync: Use WorkManager (Android) or Background Tasks (iOS)
- Connection Pooling: Reuse HTTP connections
- Request Debouncing: Avoid excessive API calls
Security
- Certificate Pinning: Implement SSL pinning for production
- Token Rotation: Refresh tokens before expiration
- Secure Storage: Never store tokens in plain text
- HTTPS Only: Always use HTTPS endpoints
- Request Signing: Consider signing sensitive requests
Error Handling
// Android
sealed class Resource<T> {
data class Success<T>(val data: T) : Resource<T>()
data class Error<T>(val message: String, val data: T? = null) : Resource<T>()
class Loading<T> : Resource<T>()
}
class GreenhouseViewModel(private val repository: GreenhouseRepository) : ViewModel() {
private val _uiState = MutableStateFlow<Resource<List<RealDataDto>>>(Resource.Loading())
val uiState: StateFlow<Resource<List<RealDataDto>>> = _uiState
fun loadRecentData(tenantId: String? = null) {
viewModelScope.launch {
_uiState.value = Resource.Loading()
try {
val data = repository.getRecentMessages(tenantId, 100)
_uiState.value = Resource.Success(data)
} catch (e: Exception) {
_uiState.value = Resource.Error(
message = e.localizedMessage ?: "Unknown error",
data = null
)
}
}
}
}
Testing
Mock API Responses
@RunWith(MockitoJUnitRunner::class)
class GreenhouseRepositoryTest {
@Mock
private lateinit var api: GreenhouseApi
@Mock
private lateinit var dao: SensorReadingDao
private lateinit var repository: GreenhouseRepository
@Before
fun setup() {
repository = GreenhouseRepository(api, dao, NetworkMonitor())
}
@Test
fun `getRecentReadings returns network data when available`() = runTest {
val mockData = listOf(
SensorReadingResponse(
time = Instant.now(),
sensorId = "SENSOR_01",
greenhouseId = "001",
sensorType = "TEMPERATURE",
value = 25.5,
unit = "°C"
)
)
`when`(api.getReadingsByGreenhouse("001", 24))
.thenReturn(Response.success(mockData))
val result = repository.getRecentReadings("001")
assertEquals(mockData, result)
verify(dao).insertAll(any())
}
}
Next Steps
- Kotlin Multiplatform SDK - Shared code for Android/iOS
- WebSocket Integration - Real-time data streaming
- API Reference - Complete API documentation