Skip to content
120 changes: 75 additions & 45 deletions data/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,63 +1,93 @@
import org.gradle.kotlin.dsl.invoke

plugins {
kotlin("jvm")
kotlin("multiplatform")
kotlin("plugin.serialization")
id("com.google.devtools.ksp")
// Apply the java-library plugin for API and implementation separation.
`java-library`
id("com.android.library")
id("de.jensklingenberg.ktorfit") version "2.6.4"
}


android {
namespace = "org.tidepool.data"
compileSdk = 34

defaultConfig {
minSdk = 26
}

compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
}

kotlin {
androidTarget {
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
}
}

compilerOptions {
freeCompilerArgs.add("-Xcontext-parameters")
}

sourceSets {
val commonMain by getting {
dependencies {
implementation(project(":TidepoolKotlinAPI:domain"))

// Ktorfit for networking
implementation("de.jensklingenberg.ktorfit:ktorfit-lib:2.6.4")
implementation("io.ktor:ktor-client-content-negotiation:3.3.1")
implementation("io.ktor:ktor-serialization-kotlinx-json:3.3.1")
implementation("io.ktor:ktor-client-logging:3.3.1")

// Serialization
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.1")

// Coroutines
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2")


// Room KMP dependencies for local storage
implementation("androidx.room:room-runtime:2.8.1")
implementation("androidx.sqlite:sqlite-bundled:2.5.0")

// Koin dependency injection
implementation("io.insert-koin:koin-core:4.1.0")
}
}

val commonTest by getting {
dependencies {
implementation(kotlin("test"))
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2")
implementation("io.insert-koin:koin-test:4.1.0")
}
}

val androidMain by getting {
dependencies {
implementation("io.ktor:ktor-client-logging:3.3.1")
implementation("io.ktor:ktor-client-content-negotiation:3.3.1")
}
}
val androidUnitTest by getting
}
}


repositories {
mavenCentral()
google()
}

dependencies {
implementation(project(":TidepoolKotlinAPI:domain"))
// Networking
implementation("com.squareup.retrofit2:retrofit:3.0.0")
implementation("com.squareup.okhttp3:okhttp:5.1.0")

// Serialization
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0")
implementation("com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter:1.0.0")

// Coroutines
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2")

implementation("io.mcarle:konvert-api:4.3.2")
ksp("io.mcarle:konvert:4.3.2")

// Room KMP dependencies for local storage
implementation("androidx.room:room-runtime:2.8.1")
implementation("androidx.sqlite:sqlite-bundled:2.5.0")
implementation("com.squareup.okhttp3:logging-interceptor:5.1.0")
add("ksp", "androidx.room:room-compiler:2.8.1")

// Koin dependency injection
implementation("io.insert-koin:koin-core:4.1.0")

// Testing
testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")
testImplementation("org.junit.jupiter:junit-jupiter-engine:5.10.2")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2")
testImplementation("com.squareup.okhttp3:mockwebserver:5.1.0")
testImplementation("io.insert-koin:koin-test:4.1.0")

testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}

java {
toolchain {
languageVersion.set(JavaLanguageVersion.of(17))
}
}

tasks.named<Test>("test") {
useJUnitPlatform()
add("kspCommonMainMetadata", "androidx.room:room-compiler:2.8.1")
add("kspAndroid", "androidx.room:room-compiler:2.8.1")
add("kspCommonMainMetadata", "de.jensklingenberg.ktorfit:ktorfit-ksp:2.6.4")
add("kspAndroid", "de.jensklingenberg.ktorfit:ktorfit-ksp:2.6.4")
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package org.tidepool.sdk

import android.content.Context
import android.content.SharedPreferences
import org.tidepool.sdk.repository.KeyValueStorage

class AndroidKeyValueStorage(context: Context) : KeyValueStorage {
private val preferences = context.getSharedPreferences("loop-kit-storage", Context.MODE_PRIVATE)

override fun getString(key: String): String? = preferences.getString(key, null)

override fun putString(key: String, value: String?) = preferences.edit(key, value)

override fun getInt(key: String): Int? = if (preferences.contains(key)) {
preferences.getInt(key, 0)
} else {
null
}

override fun putInt(key: String, value: Int?) = preferences.edit(key, value)

override fun getLong(key: String): Long? = if (preferences.contains(key)) {
preferences.getLong(key, 0L)
} else {
null
}

override fun putLong(key: String, value: Long?) = preferences.edit(key, value)

override fun getFloat(key: String): Float? = if (preferences.contains(key)) {
preferences.getFloat(key, 0f)
} else {
null
}

override fun putFloat(key: String, value: Float?) = preferences.edit(key, value)

override fun getBoolean(key: String): Boolean? = if (preferences.contains(key)) {
preferences.getBoolean(key, false)
} else {
null
}

override fun putBoolean(key: String, value: Boolean?) = preferences.edit(key, value)

private fun <T : Any> SharedPreferences.edit(
key: String,
value: T?,
) = edit().apply {
when (value) {
null -> remove(key)
is String -> putString(key, value)
is Int -> putInt(key, value)
is Long -> putLong(key, value)
is Float -> putFloat(key, value)
is Boolean -> putBoolean(key, value)
else -> throw IllegalArgumentException("Unsupported type")
}
}.apply()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package org.tidepool.sdk.di

import androidx.room.Room
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import org.koin.core.module.Module
import org.koin.dsl.module
import org.tidepool.sdk.database.LoopKitDatabase
import org.tidepool.sdk.api.createAlertApi
import org.tidepool.sdk.api.createAuthorizationApi
import org.tidepool.sdk.api.createBlobApi
import org.tidepool.sdk.api.createClinicApi
import org.tidepool.sdk.api.createConfirmationApi
import org.tidepool.sdk.api.createDataApi
import org.tidepool.sdk.api.createGeneralApi
import org.tidepool.sdk.api.createMessageApi
import org.tidepool.sdk.api.createMetadataApi
import org.tidepool.sdk.api.createMetricsApi
import org.tidepool.sdk.api.createPrescriptionApi
import org.tidepool.sdk.api.createSummaryApi
import org.tidepool.sdk.api.createTaskApi
import org.tidepool.sdk.api.createUserApi
import de.jensklingenberg.ktorfit.Ktorfit
import io.ktor.client.plugins.logging.ANDROID
import io.ktor.client.plugins.logging.Logger
import org.tidepool.sdk.AndroidKeyValueStorage
import org.tidepool.sdk.repository.KeyValueStorage

actual val platformDataModule: Module
get() = module {
single {
Room.databaseBuilder<LoopKitDatabase>(
context = get(),
name = "loop-kit-database",
)
.setDriver(BundledSQLiteDriver())
.build()
}
single<Logger> {
Logger.ANDROID
}
single<KeyValueStorage> {
AndroidKeyValueStorage(context = get())
}
}

actual fun provideAlertApi(ktorfit: Ktorfit) = ktorfit.createAlertApi()
actual fun provideAuthorizationApi(ktorfit: Ktorfit) = ktorfit.createAuthorizationApi()
actual fun provideBlobApi(ktorfit: Ktorfit) = ktorfit.createBlobApi()
actual fun provideClinicApi(ktorfit: Ktorfit) = ktorfit.createClinicApi()
actual fun provideConfirmationApi(ktorfit: Ktorfit) = ktorfit.createConfirmationApi()
actual fun provideDataApi(ktorfit: Ktorfit) = ktorfit.createDataApi()

//actual fun provideExportApi(ktorfit: Ktorfit) = ktorfit.createExportApi()
actual fun provideGeneralApi(ktorfit: Ktorfit) = ktorfit.createGeneralApi()
actual fun provideMessageApi(ktorfit: Ktorfit) = ktorfit.createMessageApi()
actual fun provideMetadataApi(ktorfit: Ktorfit) = ktorfit.createMetadataApi()
actual fun provideMetricsApi(ktorfit: Ktorfit) = ktorfit.createMetricsApi()
actual fun providePrescriptionApi(ktorfit: Ktorfit) = ktorfit.createPrescriptionApi()
actual fun provideSummaryApi(ktorfit: Ktorfit) = ktorfit.createSummaryApi()
actual fun provideTaskApi(ktorfit: Ktorfit) = ktorfit.createTaskApi()
actual fun provideUserApi(ktorfit: Ktorfit) = ktorfit.createUserApi()
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,15 @@ enum class EnvironmentInternal(
val auth: AuthenticationServerInternal
) {

Production("https://api.tidepool.org", "tidepool", AuthenticationServerInternal.Production),
Production("https://api.tidepool.org/", "tidepool", AuthenticationServerInternal.Production),
Integration(
"https://external.integration.tidepool.org",
"https://external.integration.tidepool.org/",
"integration",
AuthenticationServerInternal.External
),
Dev1("https://dev1.dev.tidepool.org", "dev", AuthenticationServerInternal.Development),
Qa1("https://qa1.development.tidepool.org", "qa1", AuthenticationServerInternal.QA),
Qa2("https://qa2.development.tidepool.org", "qa2", AuthenticationServerInternal.QA);
Dev1("https://dev1.dev.tidepool.org/", "dev", AuthenticationServerInternal.Development),
Qa1("https://qa1.development.tidepool.org/", "qa1", AuthenticationServerInternal.QA),
Qa2("https://qa2.development.tidepool.org/", "qa2", AuthenticationServerInternal.QA);
}

internal fun Environment.toInternal() = when (this) {
Expand Down
21 changes: 21 additions & 0 deletions data/src/commonMain/kotlin/org/tidepool/sdk/NetworkExceptions.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package org.tidepool.sdk

import io.ktor.client.plugins.ResponseException

/**
* Helper function to map HttpException to specific Tidepool network exceptions
*/
fun ResponseException.toTidepoolException() = when (response.status.value) {
400 -> BadRequestException("Bad request: ${message}", this)
401 -> UnauthorizedException("Unauthorized: ${message}", this)
403 -> ForbiddenException("Forbidden: ${message}", this)
404 -> NotFoundException("Not found: ${message}", this)
409 -> ConflictException("Conflict: ${message}", this)
422 -> ValidationException("Validation error: ${message}", this)
429 -> TooManyRequestsException("Too many requests: ${message}", this)
500 -> InternalServerErrorException("Internal server error: ${message}", this)
502 -> BadGatewayException("Bad gateway: ${message}", this)
503 -> ServiceUnavailableException("Service unavailable: ${message}", this)
504 -> GatewayTimeoutException("Gateway timeout: ${message}", this)
else -> UnknownNetworkException("HTTP ${response.status.value}: ${message}", this)
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
package org.tidepool.sdk

import io.ktor.client.plugins.ResponseException
import kotlinx.coroutines.delay
import retrofit2.HttpException
import org.tidepool.sdk.dto.ResponseDto
import java.io.IOException
import java.net.ConnectException
import java.net.SocketTimeoutException
Expand Down Expand Up @@ -29,7 +30,7 @@ suspend fun <T : Any> runCatchingNetworkExceptions(
block: suspend () -> T
): Result<T> = try {
Result.success(block())
} catch (ex: HttpException) {
} catch (ex: ResponseException) {
// Map HTTP exceptions to specific Tidepool exceptions
Result.failure(ex.toTidepoolException())
} catch (ex: UnknownHostException) {
Expand Down Expand Up @@ -69,9 +70,9 @@ suspend fun <T : Any> runWithRetry(
onFailure = {
if (maxRetries > 0 && it::class in retriableExceptions) {
val jitteredDelay = delay + Random.nextLong(-delay / 10, delay / 10)

delay(jitteredDelay)

runWithRetry(
block = block,
maxRetries = maxRetries - 1,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,31 +1,31 @@
package org.tidepool.sdk.api

import org.tidepool.sdk.dto.alert.AlertConfigDto
import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.POST
import retrofit2.http.Path
import de.jensklingenberg.ktorfit.http.Body
import de.jensklingenberg.ktorfit.http.DELETE
import de.jensklingenberg.ktorfit.http.GET
import de.jensklingenberg.ktorfit.http.Header
import de.jensklingenberg.ktorfit.http.POST
import de.jensklingenberg.ktorfit.http.Path

interface AlertApi {

@GET("/v1/users/{userId}/followers/{followerUserId}/alerts")
@GET("v1/users/{userId}/followers/{followerUserId}/alerts")
suspend fun getAlertsConfiguration(
@Header("X-Tidepool-Session-Token") sessionToken: String,
@Path("userId") userId: String,
@Path("followerUserId") followerUserId: String
): AlertConfigDto

@POST("/v1/users/{userId}/followers/{followerUserId}/alerts")
@POST("v1/users/{userId}/followers/{followerUserId}/alerts")
suspend fun upsertAlertsConfiguration(
@Header("X-Tidepool-Session-Token") sessionToken: String,
@Path("userId") userId: String,
@Path("followerUserId") followerUserId: String,
@Body alertsConfig: AlertConfigDto
)

@DELETE("/v1/users/{userId}/followers/{followerUserId}/alerts")
@DELETE("v1/users/{userId}/followers/{followerUserId}/alerts")
suspend fun deleteAlertsConfiguration(
@Header("X-Tidepool-Session-Token") sessionToken: String,
@Path("userId") userId: String,
Expand Down
Loading