diff --git a/shared/src/commonMain/kotlin/pl/llp/aircasting/data/network/AccountApi.kt b/shared/src/commonMain/kotlin/pl/llp/aircasting/data/network/AccountApi.kt index d791ae3..764fb64 100644 --- a/shared/src/commonMain/kotlin/pl/llp/aircasting/data/network/AccountApi.kt +++ b/shared/src/commonMain/kotlin/pl/llp/aircasting/data/network/AccountApi.kt @@ -12,16 +12,16 @@ import io.ktor.http.contentType class AccountApi(private val client: HttpClient) { suspend fun user(token: String): UserDto = - client.get("/api/user.json") { basicAuth(token, PasswordPlaceholder) }.body() + client.get(ApiEndpoints.User) { basicAuth(token, PasswordPlaceholder) }.body() suspend fun requestPasswordReset(login: String) { - client.post("/users/password.json") { + client.post(ApiEndpoints.PasswordReset) { contentType(ContentType.Application.Json) setBody(PasswordResetBody(PasswordResetBody.Login(login))) } } suspend fun sendAccountDeletionCode(token: String) { - client.post("/api/user/delete_account_send_code") { basicAuth(token, PasswordPlaceholder) } + client.post(ApiEndpoints.DeleteAccountSendCode) { basicAuth(token, PasswordPlaceholder) } } suspend fun confirmAccountDeletion(token: String, code: String) { diff --git a/shared/src/commonMain/kotlin/pl/llp/aircasting/data/network/AircastingAuth.kt b/shared/src/commonMain/kotlin/pl/llp/aircasting/data/network/AircastingAuth.kt new file mode 100644 index 0000000..e3bcb67 --- /dev/null +++ b/shared/src/commonMain/kotlin/pl/llp/aircasting/data/network/AircastingAuth.kt @@ -0,0 +1,17 @@ +package pl.llp.aircasting.data.network + + +import io.ktor.client.request.HttpRequestBuilder +import io.ktor.client.request.basicAuth +import io.ktor.client.request.header +import io.ktor.http.HttpHeaders + +fun HttpRequestBuilder.aircastingBearerAuth(userToken: String) { + header(HttpHeaders.Authorization, "Bearer $userToken") +} +/** + * Fixed session measurement upload token: HTTP Bearer with the session_token. + */ +fun HttpRequestBuilder.aircastingSessionTokenAuth(sessionToken: String) { + header(HttpHeaders.Authorization, "Bearer $sessionToken") +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/pl/llp/aircasting/data/network/ApiEndpoints.kt b/shared/src/commonMain/kotlin/pl/llp/aircasting/data/network/ApiEndpoints.kt new file mode 100644 index 0000000..cec1ae6 --- /dev/null +++ b/shared/src/commonMain/kotlin/pl/llp/aircasting/data/network/ApiEndpoints.kt @@ -0,0 +1,42 @@ +package pl.llp.aircasting.data.network + +object ApiEndpoints { + + // ========================================================================= + // Mobile app: Account & auth (HTTP Basic Auth: ":X") + // ========================================================================= + const val User = "/api/user.json" + const val UserSettings = "/api/user/settings" + const val DeleteAccountSendCode = "/api/user/delete_account_send_code" + const val DeleteAccountConfirm = "/api/user/delete_account_confirm" + const val PasswordReset = "/users/password.json" + // ========================================================================= + // Mobile app: Mobile sessions v3 (HTTP Bearer Auth: Bearer ) + // ========================================================================= + const val V3MobileSessions = "/api/v3/mobile_sessions" + fun v3MobileSession(uuid: String) = "/api/v3/mobile_sessions/$uuid" + fun v3MobileSessionMeasurements(uuid: String) = "/api/v3/mobile_sessions/$uuid/measurements" + fun v3MobileSessionNotes(uuid: String) = "/api/v3/mobile_sessions/$uuid/notes" + fun v3MobileSessionNote(uuid: String, noteId: Long) = "/api/v3/mobile_sessions/$uuid/notes/$noteId" + // ========================================================================= + // Mobile app: Fixed sessions v3 (HTTP Bearer Auth: user token or session token) + // ========================================================================= + const val V3FixedSessions = "/api/v3/fixed_sessions" + fun v3FixedSession(uuid: String) = "/api/v3/fixed_sessions/$uuid" + fun v3FixedSessionMeasurements(uuid: String) = "/api/v3/fixed_sessions/$uuid/measurements" + // ========================================================================= + // Mobile app: Threshold alerts (HTTP Basic Auth: ":X") + // ========================================================================= + const val ThresholdAlerts = "/api/fixed/threshold_alerts" + fun thresholdAlert(id: Long) = "/api/fixed/threshold_alerts/$id" + // ========================================================================= + // Mobile app: Export & sharing + // ========================================================================= + const val ExportSessionByUuid = "/api/sessions/export_by_uuid.json" + // ========================================================================= + // Fixed Stations & Map Query (Home screen & explore) + // ========================================================================= + const val FixedStationsInRegion = "/api/fixed/active/sessions.json" + fun fixedSessionStreams(sessionId: Long) = "/api/fixed/sessions/$sessionId/streams.json" + fun fixedStream(streamId: Long) = "/api/fixed/streams/$streamId.json" +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/pl/llp/aircasting/data/network/Dto.kt b/shared/src/commonMain/kotlin/pl/llp/aircasting/data/network/Dto.kt new file mode 100644 index 0000000..2859544 --- /dev/null +++ b/shared/src/commonMain/kotlin/pl/llp/aircasting/data/network/Dto.kt @@ -0,0 +1,119 @@ +package pl.llp.aircasting.data.network + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +/** + * Standard error envelope returned on 4xx/5xx responses across all /api/v3 endpoints. + */ +@Serializable +data class ErrorDto( + @SerialName("error_code") val errorCode: String, + val message: String, + val fields: Map>? = null, +) +/** + * Pagination metadata returned in v3 collection queries. + */ +@Serializable +data class PaginationMetaDto( + val total: Int, + val page: Int, + @SerialName("per_page") val perPage: Int, + @SerialName("total_pages") val totalPages: Int, +) +// --- Mobile Sessions v3 --- +@Serializable +data class MobileSessionListResponseDto( + val sessions: List = emptyList(), + val meta: PaginationMetaDto, +) +@Serializable +data class MobileSessionSummaryDto( + val uuid: String, + val title: String, + val type: String = "MobileSession", + @SerialName("tag_list") val tagList: String? = null, + val contribute: Boolean = true, + @SerialName("time_zone") val timeZone: String, + @SerialName("start_time") val startTime: Long? = null, + @SerialName("end_time") val endTime: Long? = null, + val version: Int = 0, + val latitude: Double? = null, + val longitude: Double? = null, + @SerialName("share_url") val shareUrl: String? = null, +) +@Serializable +data class CreateMobileSessionRequestDto( + val session: MobileSessionPayloadDto, +) +@Serializable +data class MobileSessionPayloadDto( + val uuid: String, + val title: String, + @SerialName("time_zone") val timeZone: String, + val contribute: Boolean = true, + val tags: List = emptyList(), + val streams: List = emptyList(), +) +@Serializable +data class CreateStreamPayloadDto( + @SerialName("sensor_name") val sensorName: String, + @SerialName("unit_symbol") val unitSymbol: String, + @SerialName("measurement_type") val measurementType: String? = null, + @SerialName("measurement_short_type") val measurementShortType: String? = null, + @SerialName("unit_name") val unitName: String? = null, +) +@Serializable +data class UpdateMobileSessionRequestDto( + val title: String? = null, + @SerialName("tag_list") val tagList: String? = null, + val contribute: Boolean? = null, +) +@Serializable +data class NoteDto( + val id: Long, + val number: Int? = null, + val text: String, + val date: String, + val latitude: Double, + val longitude: Double, + @SerialName("photo_location") val photoLocation: String? = null, +) +// --- Fixed Sessions v3 --- +@Serializable +data class FixedSessionListResponseDto( + val sessions: List = emptyList(), + val meta: PaginationMetaDto, +) +@Serializable +data class FixedSessionSummaryDto( + val uuid: String, + val title: String, + @SerialName("share_url") val shareUrl: String, + @SerialName("session_token") val sessionToken: String? = null, +) +@Serializable +data class CreateFixedSessionRequestDto( + val session: FixedSessionPayloadDto, +) +@Serializable +data class FixedSessionPayloadDto( + val uuid: String, + val title: String, + @SerialName("time_zone") val timeZone: String, + val latitude: Double, + val longitude: Double, + @SerialName("is_indoor") val isIndoor: Boolean = false, + val streams: List = emptyList(), +) +@Serializable +data class CreateFixedSessionResponseDto( + @SerialName("share_url") val shareUrl: String, + @SerialName("session_token") val sessionToken: String, + val streams: List = emptyList(), +) +@Serializable +data class CreatedStreamDto( + @SerialName("sensor_name") val sensorName: String, + @SerialName("sensor_type_id") val sensorTypeId: Int, +) \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/pl/llp/aircasting/data/network/ExportApi.kt b/shared/src/commonMain/kotlin/pl/llp/aircasting/data/network/ExportApi.kt new file mode 100644 index 0000000..9452a9c --- /dev/null +++ b/shared/src/commonMain/kotlin/pl/llp/aircasting/data/network/ExportApi.kt @@ -0,0 +1,15 @@ +package pl.llp.aircasting.data.network + +import io.ktor.client.HttpClient +import io.ktor.client.request.get +import io.ktor.client.request.parameter + +class ExportApi(private val client: HttpClient) { + /** Requests the backend to email a CSV export of the session with [sessionUuid] to [email]. */ + suspend fun exportSession(email: String, sessionUuid: String) { + client.get(ApiEndpoints.ExportSessionByUuid) { + parameter("email", email) + parameter("uuid", sessionUuid) + } + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/pl/llp/aircasting/data/network/FixedStationsApi.kt b/shared/src/commonMain/kotlin/pl/llp/aircasting/data/network/FixedStationsApi.kt index 513851e..9f354ec 100644 --- a/shared/src/commonMain/kotlin/pl/llp/aircasting/data/network/FixedStationsApi.kt +++ b/shared/src/commonMain/kotlin/pl/llp/aircasting/data/network/FixedStationsApi.kt @@ -4,11 +4,72 @@ import io.ktor.client.HttpClient import io.ktor.client.call.body import io.ktor.client.request.get import io.ktor.client.request.parameter +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable -/** Thin wrapper over the region-search endpoint. One sensor triple per call. */ + +@Serializable +data class FixedSessionWithStreamsDto( + val id: Long, + val title: String, + val username: String? = null, + @SerialName("start_time") val startTime: Long? = null, + @SerialName("end_time") val endTime: Long? = null, + val latitude: Double? = null, + val longitude: Double? = null, + @SerialName("is_indoor") val isIndoor: Boolean = false, + val streams: List = emptyList(), +) + +@Serializable +data class FixedStreamDetailsDto( + @SerialName("stream_id") val streamId: Long, + @SerialName("sensor_name") val sensorName: String, + @SerialName("sensor_unit") val sensorUnit: String, + @SerialName("unit_name") val unitName: String? = null, + @SerialName("measurement_type") val measurementType: String, + @SerialName("measurement_short_type") val measurementShortType: String? = null, + @SerialName("last_measurement_value") val lastMeasurementValue: Double? = null, + @SerialName("threshold_very_low") val thresholdVeryLow: Double = 0.0, + @SerialName("threshold_low") val thresholdLow: Double = 0.0, + @SerialName("threshold_medium") val thresholdMedium: Double = 0.0, + @SerialName("threshold_high") val thresholdHigh: Double = 0.0, + @SerialName("threshold_very_high") val thresholdVeryHigh: Double = 0.0, + val measurements: List = emptyList(), +) + +@Serializable +data class FixedMeasurementDto( + val value: Double, + val time: Long, // Epoch ms + val latitude: Double? = null, + val longitude: Double? = null, +) + + +/** + * Accesses active fixed stations in a region and fixed session stream/measurement details. + * Public endpoints (@NonAuthenticated) used by the Home screen and map queries. + */ class FixedStationsApi(private val client: HttpClient) { + /** Active fixed sessions in a bounding box for one sensor triple. */ suspend fun activeInRegion(query: FixedStationsQuery): SessionsInRegionDto = - client.get("/api/fixed/active/sessions.json") { + client.get(ApiEndpoints.FixedStationsInRegion) { parameter("q", AircastingJson.encodeToString(query)) - }.body() -} + } + .body() + + /** Fixed session with all streams and measurement history (for weekly charts). */ + suspend fun sessionStreams( + sessionId: Long, + measurementsLimit: Int = 168, + ): FixedSessionWithStreamsDto = client.get(ApiEndpoints.fixedSessionStreams(sessionId)) { + parameter("measurements_limit", measurementsLimit) + } + .body() + + /** Single fixed stream with measurement history. */ + suspend fun stream(streamId: Long): FixedStreamDetailsDto = + client.get(ApiEndpoints.fixedStream(streamId)) + .body() +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/pl/llp/aircasting/data/network/HttpClientFactory.kt b/shared/src/commonMain/kotlin/pl/llp/aircasting/data/network/HttpClientFactory.kt index a98c545..cf7f22a 100644 --- a/shared/src/commonMain/kotlin/pl/llp/aircasting/data/network/HttpClientFactory.kt +++ b/shared/src/commonMain/kotlin/pl/llp/aircasting/data/network/HttpClientFactory.kt @@ -5,9 +5,19 @@ import io.ktor.client.engine.HttpClientEngine import io.ktor.client.plugins.DefaultRequest import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.serialization.kotlinx.json.json - - const val DefaultBackendUrl = "https://aircasting.org" +/** + * AirCasting requires HTTPS for all traffic to prevent 301 redirects from dropping POST bodies. + * Strips trailing slashes, enforces the https:// protocol, and preserves custom ports. + */ +fun normalizeBackendUrl(url: String): String { + val trimmed = url.trim().removeSuffix("/") + return when { + trimmed.startsWith("http://", ignoreCase = true) -> "https://" + trimmed.substring(7) + trimmed.startsWith("https://", ignoreCase = true) -> trimmed + else -> "https://$trimmed" + } +} /** * Builds the AirCasting HttpClient over a given engine. Tests inject MockEngine; * DI injects the platform engine (OkHttp on Android, Darwin on iOS). @@ -16,7 +26,7 @@ fun createAircastingHttpClient( engine: HttpClientEngine, baseUrl: () -> String = { DefaultBackendUrl }, ): HttpClient = HttpClient(engine) { - expectSuccess = true // 401/5xx throw instead of surfacing as a JSON-decode error + expectSuccess = true // 401/5xx throw instead of surfacing as JSON-decode errors install(ContentNegotiation) { json(AircastingJson) } - install(DefaultRequest) { url(baseUrl()) } + install(DefaultRequest) { url(normalizeBackendUrl(baseUrl())) } } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/pl/llp/aircasting/data/network/MobileSessionsApi.kt b/shared/src/commonMain/kotlin/pl/llp/aircasting/data/network/MobileSessionsApi.kt new file mode 100644 index 0000000..ff2f16f --- /dev/null +++ b/shared/src/commonMain/kotlin/pl/llp/aircasting/data/network/MobileSessionsApi.kt @@ -0,0 +1,131 @@ +package pl.llp.aircasting.data.network + +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.request.delete +import io.ktor.client.request.forms.formData +import io.ktor.client.request.forms.submitFormWithBinaryData +import io.ktor.client.request.get +import io.ktor.client.request.parameter +import io.ktor.client.request.patch +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.http.ContentType +import io.ktor.http.Headers +import io.ktor.http.HttpHeaders +import io.ktor.http.contentType + +class MobileSessionsApi(private val client: HttpClient) { + /** List the signed-in user's mobile sessions, paginated. */ + suspend fun listSessions( + userToken: String, + page: Int = 1, + perPage: Int = 500, + ): MobileSessionListResponseDto = client.get(ApiEndpoints.V3MobileSessions) { + aircastingBearerAuth(userToken) + parameter("page", page) + parameter("per_page", perPage) + } + .body() + + /** Create a mobile session before recording begins. */ + suspend fun createSession( + userToken: String, + request: CreateMobileSessionRequestDto, + ): MobileSessionSummaryDto = client.post(ApiEndpoints.V3MobileSessions) { + aircastingBearerAuth(userToken) + contentType(ContentType.Application.Json) + setBody(request) + } + .body() + + /** Fetch details for one mobile session by UUID. */ + suspend fun getSession(userToken: String, uuid: String): MobileSessionSummaryDto = + client.get(ApiEndpoints.v3MobileSession(uuid)) { + aircastingBearerAuth(userToken) + } + .body() + + /** Update title, tags, or crowd contribution status. */ + suspend fun updateSession( + userToken: String, + uuid: String, + request: UpdateMobileSessionRequestDto, + ): MobileSessionSummaryDto = client.patch(ApiEndpoints.v3MobileSession(uuid)) { + aircastingBearerAuth(userToken) + contentType(ContentType.Application.Json) + setBody(request) + } + .body() + + /** Delete a mobile session. */ + suspend fun deleteSession(userToken: String, uuid: String) { + client.delete(ApiEndpoints.v3MobileSession(uuid)) { + aircastingBearerAuth(userToken) + } + } + + /** Upload recorded binary measurements for a mobile session. */ + suspend fun uploadMeasurementsBinary( + userToken: String, + uuid: String, + binaryData: ByteArray, + ) { + client.post(ApiEndpoints.v3MobileSessionMeasurements(uuid)) { + aircastingBearerAuth(userToken) + contentType(ContentType.Application.OctetStream) + setBody(binaryData) + } + } + // --- Notes --- + /** List all notes attached to a mobile session. */ + suspend fun listNotes(userToken: String, uuid: String): List = + client.get(ApiEndpoints.v3MobileSessionNotes(uuid)) { + aircastingBearerAuth(userToken) + } + .body() + + /** Add a note, optionally attaching photo bytes. */ + suspend fun addNote( + userToken: String, + uuid: String, + noteJson: String, + photoBytes: ByteArray? = null, + ): NoteDto = client.submitFormWithBinaryData( + url = ApiEndpoints.v3MobileSessionNotes(uuid), + formData = formData { + append("note", noteJson, Headers.build { + append(HttpHeaders.ContentType, "application/json") + }) + if (photoBytes != null) { + append("photo", photoBytes, Headers.build { + append(HttpHeaders.ContentType, "image/jpeg") + append(HttpHeaders.ContentDisposition, "filename=\"photo.jpg\"") + }) + } + }, + ) { + aircastingBearerAuth(userToken) + } + .body() + + /** Edit text of an existing note. */ + suspend fun editNote( + userToken: String, + uuid: String, + noteId: Long, + text: String, + ): NoteDto = client.patch(ApiEndpoints.v3MobileSessionNote(uuid, noteId)) { + aircastingBearerAuth(userToken) + contentType(ContentType.Application.Json) + setBody(mapOf("text" to text)) + } + .body() + + /** Delete a note by ID. */ + suspend fun deleteNote(userToken: String, uuid: String, noteId: Long) { + client.delete(ApiEndpoints.v3MobileSessionNote(uuid, noteId)) { + aircastingBearerAuth(userToken) + } + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/pl/llp/aircasting/data/network/ThresholdAlertApi.kt b/shared/src/commonMain/kotlin/pl/llp/aircasting/data/network/ThresholdAlertApi.kt new file mode 100644 index 0000000..5b2d342 --- /dev/null +++ b/shared/src/commonMain/kotlin/pl/llp/aircasting/data/network/ThresholdAlertApi.kt @@ -0,0 +1,45 @@ +package pl.llp.aircasting.data.network + +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.request.delete +import io.ktor.client.request.get +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.http.ContentType +import io.ktor.http.contentType +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + + +@Serializable +data class ThresholdAlertDto( + val id: Long? = null, + @SerialName("sensor_name") val sensorName: String, + val threshold: Int, + val direction: String = "above", + val email: Boolean = true, + val notification: Boolean = true, +) + +class ThresholdAlertsApi(private val client: HttpClient) { + suspend fun alerts(userToken: String): List = + client.get(ApiEndpoints.ThresholdAlerts) { + aircastingBearerAuth(userToken) + } + .body() + + suspend fun createAlert(userToken: String, alert: ThresholdAlertDto): ThresholdAlertDto = + client.post(ApiEndpoints.ThresholdAlerts) { + aircastingBearerAuth(userToken) + contentType(ContentType.Application.Json) + setBody(alert) + } + .body() + + suspend fun deleteAlert(userToken: String, alertId: Long) { + client.delete(ApiEndpoints.thresholdAlert(alertId)) { + aircastingBearerAuth(userToken) + } + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/pl/llp/aircasting/di/AppModule.kt b/shared/src/commonMain/kotlin/pl/llp/aircasting/di/AppModule.kt index 876fb0a..72cbeab 100644 --- a/shared/src/commonMain/kotlin/pl/llp/aircasting/di/AppModule.kt +++ b/shared/src/commonMain/kotlin/pl/llp/aircasting/di/AppModule.kt @@ -17,10 +17,13 @@ import pl.llp.aircasting.data.auth.StoredAuthSession import pl.llp.aircasting.data.network.AccountApi import pl.llp.aircasting.data.network.AuthApi import pl.llp.aircasting.data.network.DefaultBackendUrl +import pl.llp.aircasting.data.network.ExportApi import pl.llp.aircasting.data.network.FixedStationsApi import pl.llp.aircasting.data.network.FixedStationsRepository import pl.llp.aircasting.data.network.HttpServerProbe +import pl.llp.aircasting.data.network.MobileSessionsApi import pl.llp.aircasting.data.network.ServerProbe +import pl.llp.aircasting.data.network.ThresholdAlertsApi import pl.llp.aircasting.data.network.createAircastingHttpClient import pl.llp.aircasting.home.FakeHomeRepository import pl.llp.aircasting.home.HomeRepository @@ -58,6 +61,7 @@ val appSettingsModule = module { viewModelOf(::CustomDataServerViewModel) } + val networkModule = module { single { val settings = get() @@ -65,12 +69,19 @@ val networkModule = module { settings.preferences.value.dataServerUrl ?: DefaultBackendUrl } } - single { HttpServerProbe(platformHttpEngine()) as ServerProbe } - single { createAircastingHttpClient(platformHttpEngine()) } + single { HttpServerProbe(platformHttpEngine()) } + // Modern v3 APIs + single { MobileSessionsApi(get()) } + // single { FixedSessionsApi(get()) } + // Search & Regional Exploration (Home screen) single { FixedStationsApi(get()) } - single { Clock.System } single { FixedStationsRepository(get(), get()) } -// single { NetworkHomeRepository(get()) } TODO: restore + // Alerts & Export + single { ThresholdAlertsApi(get()) } + single { ExportApi(get()) } + // Deprecated Sync Fallback + // single { LegacySessionsApi(get()) } + single { Clock.System } single { FakeHomeRepository() } viewModelOf(::HomeViewModel) }