diff --git a/src/main/kotlin/de/cyface/uploader/DefaultUploader.kt b/src/main/kotlin/de/cyface/uploader/DefaultUploader.kt index a8d2051..92e4952 100644 --- a/src/main/kotlin/de/cyface/uploader/DefaultUploader.kt +++ b/src/main/kotlin/de/cyface/uploader/DefaultUploader.kt @@ -27,7 +27,6 @@ import com.google.api.client.http.javanet.NetHttpTransport import com.google.api.client.http.json.JsonHttpContent import com.google.api.client.json.JsonFactory import com.google.api.client.json.gson.GsonFactory -import de.cyface.model.RequestMetaData import de.cyface.uploader.exception.AccountNotActivated import de.cyface.uploader.exception.BadRequestException import de.cyface.uploader.exception.ConflictException @@ -44,6 +43,9 @@ import de.cyface.uploader.exception.UnauthorizedException import de.cyface.uploader.exception.UnexpectedResponseCode import de.cyface.uploader.exception.UploadFailed import de.cyface.uploader.exception.UploadSessionExpired +import de.cyface.uploader.model.Attachment +import de.cyface.uploader.model.Measurement +import de.cyface.uploader.model.Uploadable import org.slf4j.LoggerFactory import java.io.BufferedInputStream import java.io.BufferedReader @@ -73,25 +75,25 @@ class DefaultUploader(private val apiEndpoint: String) : Uploader { @Suppress("unused", "CyclomaticComplexMethod", "LongMethod") // Part of the API override fun uploadMeasurement( jwtToken: String, - metaData: RequestMetaData, + uploadable: Measurement, file: File, progressListener: UploadProgressListener ): Result { val endpoint = measurementsEndpoint() - return uploadFile(jwtToken, metaData, file, endpoint, progressListener) + return uploadFile(jwtToken, uploadable, file, endpoint, progressListener) } override fun uploadAttachment( jwtToken: String, - metaData: RequestMetaData, + uploadable: Attachment, file: File, fileName: String, progressListener: UploadProgressListener, ): Result { - val measurementId = metaData.identifier.measurementId.toLong() - val deviceId = metaData.identifier.deviceId + val measurementId = uploadable.identifier.measurementIdentifier + val deviceId = uploadable.identifier.deviceIdentifier.toString() val endpoint = attachmentsEndpoint(deviceId, measurementId) - return uploadFile(jwtToken, metaData, file, endpoint, progressListener) + return uploadFile(jwtToken, uploadable, file, endpoint, progressListener) } override fun measurementsEndpoint(): URL { @@ -103,16 +105,16 @@ class DefaultUploader(private val apiEndpoint: String) : Uploader { } @Throws(UploadFailed::class) - private fun uploadFile( + private fun uploadFile( jwtToken: String, - metaData: RequestMetaData, + uploadable: Uploadable, file: File, endpoint: URL, progressListener: UploadProgressListener ): Result { return try { FileInputStream(file).use { fis -> - val uploader = initializeUploader(jwtToken, metaData, fis, file) + val uploader = initializeUploader(jwtToken, uploadable, fis, file) // We currently cannot merge multiple upload-chunk requests into one file on server side. // Thus, we prevent slicing the file into multiple files by increasing the chunk size. @@ -125,8 +127,7 @@ class DefaultUploader(private val apiEndpoint: String) : Uploader { // Add meta data to PreRequest val jsonFactory = GsonFactory() - val preRequestBody = preRequestBody(metaData) - uploader.metadata = JsonHttpContent(jsonFactory, preRequestBody) + uploader.metadata = JsonHttpContent(jsonFactory, uploadable.toMap()) // Vert.X currently only supports compressing "down-stream" out of the box uploader.disableGZipContent = true @@ -148,9 +149,9 @@ class DefaultUploader(private val apiEndpoint: String) : Uploader { } } - private fun initializeUploader( + private fun initializeUploader( jwtToken: String, - metaData: RequestMetaData, + uploadable: Uploadable, fileInputStream: FileInputStream, file: File ): MediaHttpUploader { @@ -160,7 +161,7 @@ class DefaultUploader(private val apiEndpoint: String) : Uploader { LOGGER.debug("mediaContent.length: ${mediaContent.length}") val transport = NetHttpTransport() // Use Builder to modify behaviour val jwtBearer = "Bearer $jwtToken" - val httpRequestInitializer = RequestInitializeHandler(metaData, jwtBearer) + val httpRequestInitializer = RequestInitializeHandler(uploadable, jwtBearer) return MediaHttpUploader(mediaContent, transport, httpRequestInitializer) } @@ -398,53 +399,6 @@ class DefaultUploader(private val apiEndpoint: String) : Uploader { } } - /** - * Assembles a `HttpContent` object which contains the metadata. - * - * @param metaData The metadata to convert. - * @return The meta data as `HttpContent`. - */ - fun preRequestBody(metaData: RequestMetaData): - Map { - val attributes: MutableMap = HashMap() - - // Location meta data - metaData.measurementMetaData.startLocation?.let { startLocation -> - attributes["startLocLat"] = startLocation.latitude.toString() - attributes["startLocLon"] = startLocation.longitude.toString() - attributes["startLocTS"] = startLocation.timestamp.toString() - } - metaData.measurementMetaData.endLocation?.let { endLocation -> - attributes["endLocLat"] = endLocation.latitude.toString() - attributes["endLocLon"] = endLocation.longitude.toString() - attributes["endLocTS"] = endLocation.timestamp.toString() - } - attributes["locationCount"] = metaData.measurementMetaData.locationCount.toString() - - // Attachment meta data - when (metaData.identifier) { - is RequestMetaData.AttachmentIdentifier -> { - val identifier = metaData.identifier as RequestMetaData.AttachmentIdentifier - attributes["attachmentId"] = identifier.attachmentId - } - } - attributes["logCount"] = metaData.attachmentMetaData.logCount.toString() - attributes["imageCount"] = metaData.attachmentMetaData.imageCount.toString() - attributes["videoCount"] = metaData.attachmentMetaData.videoCount.toString() - attributes["filesSize"] = metaData.attachmentMetaData.filesSize.toString() - - // Remaining meta data - attributes["deviceId"] = metaData.identifier.deviceId - attributes["measurementId"] = metaData.identifier.measurementId - attributes["deviceType"] = metaData.deviceMetaData.deviceType - attributes["osVersion"] = metaData.deviceMetaData.operatingSystemVersion - attributes["appVersion"] = metaData.applicationMetaData.applicationVersion - attributes["length"] = metaData.measurementMetaData.length.toString() - attributes["modality"] = metaData.measurementMetaData.modality - attributes["formatVersion"] = metaData.applicationMetaData.formatVersion.toString() - return attributes - } - @Suppress("MemberVisibilityCanBePrivate") // Part of the API @JvmStatic fun handleSuccess(response: HttpResponse): Result { @@ -550,24 +504,20 @@ class DefaultUploader(private val apiEndpoint: String) : Uploader { * @return the [String] read from the InputStream. If an I/O error occurs while reading from the stream, the * already read string is returned which might my empty or cut short. */ - @Suppress("MemberVisibilityCanBePrivate", "NestedBlockDepth") // Part of the API + @Suppress("MemberVisibilityCanBePrivate") // Part of the API @JvmStatic fun readInputStream(inputStream: InputStream): String { - try { - try { - BufferedReader( - InputStreamReader(inputStream, DEFAULT_CHARSET) - ).use { bufferedReader -> - val responseString = StringBuilder() - var line: String? - while (bufferedReader.readLine().also { line = it } != null) { - responseString.append(line) - } - return responseString.toString() + return try { + BufferedReader(InputStreamReader(inputStream, DEFAULT_CHARSET)).use { bufferedReader -> + val responseString = StringBuilder() + var line: String? + while (bufferedReader.readLine().also { line = it } != null) { + responseString.append(line) } - } catch (e: UnsupportedEncodingException) { - error(e) + responseString.toString() } + } catch (e: UnsupportedEncodingException) { + error(e) } catch (e: IOException) { error(e) } diff --git a/src/main/kotlin/de/cyface/uploader/RequestInitializeHandler.kt b/src/main/kotlin/de/cyface/uploader/RequestInitializeHandler.kt index 4a6abbd..8704faa 100644 --- a/src/main/kotlin/de/cyface/uploader/RequestInitializeHandler.kt +++ b/src/main/kotlin/de/cyface/uploader/RequestInitializeHandler.kt @@ -21,18 +21,18 @@ package de.cyface.uploader import com.google.api.client.http.HttpHeaders import com.google.api.client.http.HttpRequest import com.google.api.client.http.HttpRequestInitializer -import de.cyface.model.RequestMetaData +import de.cyface.uploader.model.Uploadable import java.io.IOException /** * Assembles a request as requested to upload data. * * @author Armin Schnabel - * @property metaData the `MetaData` used to request the upload permission from the server + * @property uploadable the `MetaData` used to request the upload permission from the server * @property jwtBearer the JWT token to authenticate the upload requests */ -class RequestInitializeHandler( - private val metaData: RequestMetaData, +class RequestInitializeHandler( + private val uploadable: Uploadable, private val jwtBearer: String ) : HttpRequestInitializer { @Throws(IOException::class) @@ -40,42 +40,17 @@ class RequestInitializeHandler( override fun initialize(request: HttpRequest) { val headers = HttpHeaders() headers.authorization = jwtBearer - addMetaData(metaData, headers) + applyMetaToHttpHeaders(headers) // sets the metadata in both requests but until we don't use the session-URI // feature we can't store the metadata from the pre-request to be used in the upload // and the library does not support just to set the upload request header request.headers = headers } - private fun addMetaData( - metaData: RequestMetaData, - headers: HttpHeaders - ) { - // Location meta data - metaData.measurementMetaData.startLocation?.let { startLocation -> - headers["startLocLat"] = startLocation.latitude.toString() - headers["startLocLon"] = startLocation.longitude.toString() - headers["startLocTS"] = startLocation.timestamp.toString() + private fun applyMetaToHttpHeaders(headers: HttpHeaders) { + val map = uploadable.toMap() + map.forEach { (key, value) -> + headers[key] = value } - metaData.measurementMetaData.endLocation?.let { endLocation -> - headers["endLocLat"] = endLocation.latitude.toString() - headers["endLocLon"] = endLocation.longitude.toString() - headers["endLocTS"] = endLocation.timestamp.toString() - } - headers["locationCount"] = metaData.measurementMetaData.locationCount.toString() - - // Remaining meta data - headers["deviceId"] = metaData.identifier.deviceId - headers["measurementId"] = java.lang.Long.valueOf(metaData.identifier.measurementId).toString() - headers["deviceType"] = metaData.deviceMetaData.deviceType - headers["osVersion"] = metaData.deviceMetaData.operatingSystemVersion - headers["appVersion"] = metaData.applicationMetaData.applicationVersion - headers["length"] = metaData.measurementMetaData.length.toString() - headers["modality"] = metaData.measurementMetaData.modality - headers["formatVersion"] = metaData.applicationMetaData.formatVersion.toString() - headers["logCount"] = metaData.attachmentMetaData.logCount - headers["imageCount"] = metaData.attachmentMetaData.imageCount - headers["videoCount"] = metaData.attachmentMetaData.videoCount - headers["filesSize"] = metaData.attachmentMetaData.filesSize } } diff --git a/src/main/kotlin/de/cyface/uploader/Uploader.kt b/src/main/kotlin/de/cyface/uploader/Uploader.kt index 4fd7f2f..7828a98 100644 --- a/src/main/kotlin/de/cyface/uploader/Uploader.kt +++ b/src/main/kotlin/de/cyface/uploader/Uploader.kt @@ -18,8 +18,10 @@ */ package de.cyface.uploader -import de.cyface.model.RequestMetaData import de.cyface.uploader.exception.UploadFailed +import de.cyface.uploader.model.Attachment +import de.cyface.uploader.model.Measurement +import de.cyface.uploader.model.Uploadable import java.io.File import java.net.MalformedURLException import java.net.URL @@ -35,7 +37,7 @@ interface Uploader { * Uploads the provided measurement file to the server. * * @param jwtToken A String in the format "eyXyz123***". - * @param metaData The [RequestMetaData] required for the upload request. + * @param uploadable The [Uploadable] describing the data to upload. * @param file The data file to upload via this post request. * @param progressListener The [UploadProgressListener] to be informed about the upload progress. * @throws UploadFailed when an error occurred. @@ -45,7 +47,7 @@ interface Uploader { @Throws(UploadFailed::class) fun uploadMeasurement( jwtToken: String, - metaData: RequestMetaData, + uploadable: Measurement, file: File, progressListener: UploadProgressListener ): Result @@ -54,7 +56,7 @@ interface Uploader { * Uploads the provided attachment file to the server, associated with a specific measurement. * * @param jwtToken A String in the format "eyXyz123***". - * @param metaData The [RequestMetaData] required for the upload request. + * @param uploadable The [Uploadable] describing the data to upload. * @param file The attachment file to upload via this post request. * @param fileName How the transfer file should be named when uploading. * @param progressListener The [UploadProgressListener] to be informed about the upload progress. @@ -65,7 +67,7 @@ interface Uploader { @Throws(UploadFailed::class) fun uploadAttachment( jwtToken: String, - metaData: RequestMetaData, + uploadable: Attachment, file: File, fileName: String, progressListener: UploadProgressListener, diff --git a/src/main/kotlin/de/cyface/uploader/exception/DeprecatedFormatVersion.kt b/src/main/kotlin/de/cyface/uploader/exception/DeprecatedFormatVersion.kt new file mode 100644 index 0000000..d481dbe --- /dev/null +++ b/src/main/kotlin/de/cyface/uploader/exception/DeprecatedFormatVersion.kt @@ -0,0 +1,27 @@ +/* + * Copyright 2024 Cyface GmbH + * + * This file is part of the Cyface Uploader. + * + * The Cyface Uploader is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * The Cyface Uploader is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with the Cyface Uploader. If not, see . + */ +package de.cyface.uploader.exception + +/** + * Exception thrown when the client tries to upload a file in a formatVersion older than the current version. + * + * @author Armin Schnabel + * @param message Details about the reason for this exception. + */ +class DeprecatedFormatVersion(message: String) : Exception(message) diff --git a/src/main/kotlin/de/cyface/uploader/exception/InvalidMetaData.kt b/src/main/kotlin/de/cyface/uploader/exception/InvalidMetaData.kt new file mode 100644 index 0000000..fbac3b3 --- /dev/null +++ b/src/main/kotlin/de/cyface/uploader/exception/InvalidMetaData.kt @@ -0,0 +1,30 @@ +/* + * Copyright 2021-2024 Cyface GmbH + * + * This file is part of the Cyface Uploader. + * + * The Cyface Uploader is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * The Cyface Uploader is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with the Cyface Uploader. If not, see . + */ +package de.cyface.uploader.exception + +/** + * Exception thrown when the upload or pre-request does not contain the expected metadata. + * + * @author Armin Schnabel + */ +class InvalidMetaData : Exception { + constructor(message: String, cause: Throwable) : super(message, cause) + constructor(message: String) : super(message) + constructor(cause: Throwable) : super(cause) +} diff --git a/src/main/kotlin/de/cyface/uploader/exception/NetworkUnavailableException.kt b/src/main/kotlin/de/cyface/uploader/exception/NetworkUnavailableException.kt index 89a52aa..667092b 100644 --- a/src/main/kotlin/de/cyface/uploader/exception/NetworkUnavailableException.kt +++ b/src/main/kotlin/de/cyface/uploader/exception/NetworkUnavailableException.kt @@ -1,5 +1,5 @@ /* - * Copyright 2021-2023 Cyface GmbH + * Copyright 2021-2024 Cyface GmbH * * This file is part of the Cyface Uploader. * @@ -24,10 +24,13 @@ package de.cyface.uploader.exception * This is usually indicated by OkHttp via `SSLException`. * * @author Armin Schnabel - * @version 1.0.2 - * @since 1.0.0 */ class NetworkUnavailableException : Exception { + /** + * @param exception The `Exception` that caused this one. + */ + constructor(exception: Exception) : super(exception) + /** * @param detailedMessage A more detailed message explaining the context for this `Exception`. */ diff --git a/src/main/kotlin/de/cyface/uploader/exception/SynchronisationException.kt b/src/main/kotlin/de/cyface/uploader/exception/SynchronisationException.kt index d21c59c..9bc639c 100644 --- a/src/main/kotlin/de/cyface/uploader/exception/SynchronisationException.kt +++ b/src/main/kotlin/de/cyface/uploader/exception/SynchronisationException.kt @@ -1,5 +1,5 @@ /* - * Copyright 2021-2023 Cyface GmbH + * Copyright 2021-2024 Cyface GmbH * * This file is part of the Cyface Uploader. * @@ -23,8 +23,6 @@ package de.cyface.uploader.exception * via a message or another wrapped `Exception`. * * @author Klemens Muthmann - * @version 1.1.4 - * @since 1.0.0 */ class SynchronisationException : Exception { /** diff --git a/src/main/kotlin/de/cyface/uploader/exception/SynchronizationInterruptedException.kt b/src/main/kotlin/de/cyface/uploader/exception/SynchronizationInterruptedException.kt index 9707bda..d27f171 100644 --- a/src/main/kotlin/de/cyface/uploader/exception/SynchronizationInterruptedException.kt +++ b/src/main/kotlin/de/cyface/uploader/exception/SynchronizationInterruptedException.kt @@ -1,5 +1,5 @@ /* - * Copyright 2019-2023 Cyface GmbH + * Copyright 2019-2024 Cyface GmbH * * This file is part of the Cyface Uploader. * @@ -22,10 +22,13 @@ package de.cyface.uploader.exception * An `Exception` thrown when the synchronization is interrupted. * * @author Armin Schnabel - * @version 1.0.1 - * @since 1.0.0 */ class SynchronizationInterruptedException : Exception { + /** + * @param exception The `Exception` that caused this one. + */ + constructor(exception: Exception) : super(exception) + /** * @param detailedMessage A more detailed message explaining the context for this `Exception`. */ diff --git a/src/main/kotlin/de/cyface/uploader/exception/TooFewLocations.kt b/src/main/kotlin/de/cyface/uploader/exception/TooFewLocations.kt new file mode 100644 index 0000000..c8c0077 --- /dev/null +++ b/src/main/kotlin/de/cyface/uploader/exception/TooFewLocations.kt @@ -0,0 +1,27 @@ +/* + * Copyright 2024 Cyface GmbH + * + * This file is part of the Cyface Uploader. + * + * The Cyface Uploader is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * The Cyface Uploader is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with the Cyface Uploader. If not, see . + */ +package de.cyface.uploader.exception + +/** + * Exception thrown when the client tries to upload a file with the locationCount smaller than required. + * + * @author Armin Schnabel + * @param message Details about the reason for this exception. + */ +class TooFewLocations(message: String) : Exception(message) diff --git a/src/main/kotlin/de/cyface/uploader/exception/UnknownFormatVersion.kt b/src/main/kotlin/de/cyface/uploader/exception/UnknownFormatVersion.kt new file mode 100644 index 0000000..3daef86 --- /dev/null +++ b/src/main/kotlin/de/cyface/uploader/exception/UnknownFormatVersion.kt @@ -0,0 +1,27 @@ +/* + * Copyright 2024 Cyface GmbH + * + * This file is part of the Cyface Uploader. + * + * The Cyface Uploader is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * The Cyface Uploader is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with the Cyface Uploader. If not, see . + */ +package de.cyface.uploader.exception + +/** + * Exception thrown when the client tries to upload a file in an unknown formatVersion. + * + * @author Armin Schnabel + * @param message Details about the reason for this exception. + */ +class UnknownFormatVersion(message: String) : Exception(message) diff --git a/src/main/kotlin/de/cyface/uploader/exception/UploadFailed.kt b/src/main/kotlin/de/cyface/uploader/exception/UploadFailed.kt index 8dd0885..d75095b 100644 --- a/src/main/kotlin/de/cyface/uploader/exception/UploadFailed.kt +++ b/src/main/kotlin/de/cyface/uploader/exception/UploadFailed.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023 Cyface GmbH + * Copyright 2023-2024 Cyface GmbH * * This file is part of the Cyface Uploader. * @@ -22,8 +22,6 @@ package de.cyface.uploader.exception * An `Exception` thrown when there is an expected error with the upload request so the UI can handle this. * * @author Armin Schnabel - * @version 1.0.0 - * @since 7.7.0 */ class UploadFailed : Exception { /** diff --git a/src/main/kotlin/de/cyface/uploader/model/Attachment.kt b/src/main/kotlin/de/cyface/uploader/model/Attachment.kt new file mode 100644 index 0000000..7480db6 --- /dev/null +++ b/src/main/kotlin/de/cyface/uploader/model/Attachment.kt @@ -0,0 +1,208 @@ +/* + * Copyright 2024 Cyface GmbH + * + * This file is part of the Cyface Uploader. + * + * The Cyface Uploader is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * The Cyface Uploader is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with the Cyface Uploader. If not, see . + */ +package de.cyface.uploader.model + +import de.cyface.uploader.exception.DeprecatedFormatVersion +import de.cyface.uploader.exception.InvalidMetaData +import de.cyface.uploader.exception.UnknownFormatVersion +import de.cyface.uploader.model.Uploadable.Companion.DEVICE_ID_FIELD +import de.cyface.uploader.model.Uploadable.Companion.MEASUREMENT_ID_FIELD +import de.cyface.uploader.model.metadata.ApplicationMetaData +import de.cyface.uploader.model.metadata.AttachmentMetaData +import de.cyface.uploader.model.metadata.DeviceMetaData +import de.cyface.uploader.model.metadata.MeasurementMetaData +/*import io.vertx.core.Future +import io.vertx.core.MultiMap +import io.vertx.core.Promise +import io.vertx.core.json.JsonObject +import io.vertx.ext.web.Session*/ +import java.util.Locale +import java.util.UUID + +data class Attachment( + val identifier: AttachmentIdentifier, + private val deviceMetaData: DeviceMetaData, + private val applicationMetaData: ApplicationMetaData, + private val measurementMetaData: MeasurementMetaData, + private val attachmentMetaData: AttachmentMetaData, +) : Uploadable { + /*override fun toJson(): JsonObject { + val ret = JsonObject() + ret.put(FormAttributes.DEVICE_ID.value, identifier.deviceIdentifier.toString()) + ret.put(FormAttributes.MEASUREMENT_ID.value, identifier.measurementIdentifier.toString()) + ret.put(FormAttributes.ATTACHMENT_ID.value, identifier.attachmentIdentifier.toString()) + ret + .mergeIn(deviceMetaData.toJson(), true) + .mergeIn(applicationMetaData.toJson(), true) + .mergeIn(measurementMetaData.toJson(), true) + .mergeIn(attachmentMetaData.toJson(), true) + return ret + } + + override fun toGeoJson(): JsonObject { + val geoJson = toGeoJson(deviceMetaData, applicationMetaData, measurementMetaData, attachmentMetaData) + + val properties = geoJson.getJsonObject("properties") + properties.put(FormAttributes.DEVICE_ID.value, identifier.deviceIdentifier) + properties.put(FormAttributes.MEASUREMENT_ID.value, identifier.measurementIdentifier) + properties.put(FormAttributes.ATTACHMENT_ID.value, identifier.attachmentIdentifier) + + return geoJson + }*/ + + companion object { + /** + * The field name for the session entry which contains the attachment id if this is an attachment upload. + * + * This field is set in the [PreRequestHandler] to ensure sessions are bound to attachments and + * uploads are only accepted with an accepted pre request. + */ + const val ATTACHMENT_ID_FIELD = "attachment-id" + } + + override fun toMap(): Map { + val map = mutableMapOf() + + map[FormAttributes.DEVICE_ID.value] = identifier.deviceIdentifier.toString() + map[FormAttributes.MEASUREMENT_ID.value] = identifier.measurementIdentifier.toString() + map[FormAttributes.ATTACHMENT_ID.value] = identifier.attachmentIdentifier.toString() + + map[FormAttributes.OS_VERSION.value] = deviceMetaData.operatingSystemVersion + map[FormAttributes.DEVICE_TYPE.value] = deviceMetaData.deviceType + + map[FormAttributes.APPLICATION_VERSION.value] = applicationMetaData.applicationVersion + map[FormAttributes.FORMAT_VERSION.value] = applicationMetaData.formatVersion.toString() + + measurementMetaData.startLocation?.let { startLocation -> + map[FormAttributes.START_LOCATION_LAT.value] = startLocation.latitude.toString() + map[FormAttributes.START_LOCATION_LON.value] = startLocation.longitude.toString() + map[FormAttributes.START_LOCATION_TS.value] = startLocation.timestamp.toString() + } + measurementMetaData.endLocation?.let { endLocation -> + map[FormAttributes.END_LOCATION_LAT.value] = endLocation.latitude.toString() + map[FormAttributes.END_LOCATION_LON.value] = endLocation.longitude.toString() + map[FormAttributes.END_LOCATION_TS.value] = endLocation.timestamp.toString() + } + map[FormAttributes.LENGTH.value] = measurementMetaData.length.toString() + map[FormAttributes.LOCATION_COUNT.value] = measurementMetaData.locationCount.toString() + map[FormAttributes.MODALITY.value] = measurementMetaData.modality + + map[FormAttributes.LOG_COUNT.value] = attachmentMetaData.logCount.toString() + map[FormAttributes.IMAGE_COUNT.value] = attachmentMetaData.imageCount.toString() + map[FormAttributes.VIDEO_COUNT.value] = attachmentMetaData.videoCount.toString() + map[FormAttributes.FILES_SIZE.value] = attachmentMetaData.filesSize.toString() + + return map + } +} + +data class AttachmentIdentifier( + val deviceIdentifier: UUID, + val measurementIdentifier: Long, + val attachmentIdentifier: Long +) + +class AttachmentFactory : UploadableFactory { + /*override fun from(json: JsonObject): Uploadable { + try { + val deviceIdentifier = UUID.fromString(json.getString(FormAttributes.DEVICE_ID.value)) + val measurementIdentifier = json.getString(FormAttributes.MEASUREMENT_ID.value).toLong() + val attachmentIdentifier = json.getString(FormAttributes.ATTACHMENT_ID.value).toLong() + + val applicationMetaData = applicationMetaData(json) + val attachmentMetaData = attachmentMetaData(json) + val deviceMetaData = deviceMetaData(json) + val measurementMetaData = measurementMetaData(json) + + return Attachment( + AttachmentIdentifier(deviceIdentifier, measurementIdentifier, attachmentIdentifier), + deviceMetaData, + applicationMetaData, + measurementMetaData, + attachmentMetaData, + ) + } catch (e: TooFewLocations) { + throw SkipUpload(e) + } catch (e: IllegalArgumentException) { + throw InvalidMetaData("Data was not parsable!", e) + } catch (e: NullPointerException) { + throw InvalidMetaData("Data was not parsable!", e) + } + } + + override fun from(headers: MultiMap): Uploadable { + try { + val deviceId = UUID.fromString(requireNotNull(headers.get(FormAttributes.DEVICE_ID.value))) + val measurementId = requireNotNull(headers.get(FormAttributes.MEASUREMENT_ID.value)).toLong() + val attachmentIdentifier = requireNotNull(headers.get(FormAttributes.ATTACHMENT_ID.value)).toLong() + + val attachmentMetaData = attachmentMetaData(headers) + val applicationMetaData = applicationMetaData(headers) + val measurementMetaData = measurementMetaData(headers) + val deviceMetaData = deviceMetaData(headers) + + return Attachment( + AttachmentIdentifier(deviceId, measurementId, attachmentIdentifier), + deviceMetaData, + applicationMetaData, + measurementMetaData, + attachmentMetaData, + ) + } catch (e: IllegalArgumentException) { + throw InvalidMetaData("Data incomplete!", e) + } catch (e: NumberFormatException) { + throw InvalidMetaData("Data incomplete!", e) + } catch (e: DeprecatedFormatVersion) { + throw SkipUpload(e) + } catch (e: UnknownFormatVersion) { + throw InvalidMetaData(e) + } catch (e: TooFewLocations) { + throw SkipUpload(e) + } catch (e: RuntimeException) { + throw InvalidMetaData("Data was not parsable!", e) + } + }*/ + + override fun attachmentMetaData( + logCount: String?, + imageCount: String?, + videoCount: String?, + filesSize: String? + ): AttachmentMetaData { + if (logCount == null) throw InvalidMetaData("Data incomplete logCount was null!") + if (imageCount == null) throw InvalidMetaData("Data incomplete imageCount was null!") + if (videoCount == null) throw InvalidMetaData("Data incomplete videoCount was null!") + if (filesSize == null) throw InvalidMetaData("Data incomplete filesSize was null!") + if (logCount.toInt() == 0 && imageCount.toInt() == 0 && videoCount.toInt() == 0) { + throw InvalidMetaData("No files registered for attachment.") + } + if (logCount.toInt() < 0 || imageCount.toInt() < 0 || videoCount.toInt() < 0) { + throw InvalidMetaData("Invalid file count for attachment.") + } + if (filesSize.toLong() <= 0L) { + throw InvalidMetaData("Files size for attachment must be greater than 0.") + } + return AttachmentMetaData( + logCount.toInt(), + imageCount.toInt(), + videoCount.toInt(), + filesSize.toLong(), + ) + } +} diff --git a/src/main/kotlin/de/cyface/uploader/model/FormAttributes.kt b/src/main/kotlin/de/cyface/uploader/model/FormAttributes.kt new file mode 100644 index 0000000..db2ba97 --- /dev/null +++ b/src/main/kotlin/de/cyface/uploader/model/FormAttributes.kt @@ -0,0 +1,140 @@ +/* + * Copyright 2018-2024 Cyface GmbH + * + * This file is part of the Cyface Uploader. + * + * The Cyface Uploader is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * The Cyface Uploader is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with the Cyface Uploader. If not, see . + */ +package de.cyface.uploader.model + +/** + * Attributes supported by the APIs upload endpoint. + * + * @author Klemens Muthmann + * @author Armin Schnabel + * @property value The value identifying the attribute in the multipart form request. + */ +enum class FormAttributes(val value: String) { + /** + * The worldwide unique identifier of the device uploading the data. + */ + DEVICE_ID("deviceId"), + + /** + * The device wide unique identifier of the uploaded measurement. + */ + MEASUREMENT_ID("measurementId"), + + /** + * The type of device uploading the data, such as Pixel 3 or iPhone 6 Plus. + */ + DEVICE_TYPE("deviceType"), + + /** + * The operating system version, such as Android 9.0.0 or iOS 11.2. + */ + OS_VERSION("osVersion"), + + /** + * The version of the app that transmitted the measurement. + */ + APPLICATION_VERSION("appVersion"), + + /** + * The length of the measurement in meters. + */ + LENGTH("length"), + + /** + * The count of geographical locations in the transmitted measurement. + */ + LOCATION_COUNT("locationCount"), + + /** + * The latitude of the geolocation at the beginning of the track represented by the transmitted measurement. This + * value is optional and may not be available for measurements without locations. For measurements with one location + * this equals the [.END_LOCATION_LAT]. + */ + START_LOCATION_LAT("startLocLat"), + + /** + * The longitude of the geolocation at the beginning of the track represented by the transmitted measurement. This + * value is optional and may not be available for measurements without locations. For measurements with one location + * this equals the [.END_LOCATION_LON]. + */ + START_LOCATION_LON("startLocLon"), + + /** + * The timestamp in milliseconds of the geolocation at the beginning of the track represented by the transmitted + * measurement. This value is optional and may not be available for measurements without locations. For measurements + * with one location this equals the [.END_LOCATION_TS]. + */ + START_LOCATION_TS("startLocTS"), + + /** + * The latitude of the geographical location at the end of the track represented by the transmitted measurement. + * This value is optional and may not be available for measurements without locations. For measurements + * with one location this equals the [.START_LOCATION_LAT]. + */ + END_LOCATION_LAT("endLocLat"), + + /** + * The longitude of the geographical location at the end of the track represented by the transmitted measurement. + * This value is optional and may not be available for measurements without locations. For measurements + * with one location this equals the [.START_LOCATION_LON]. + */ + END_LOCATION_LON("endLocLon"), + + /** + * The timestamp in milliseconds of the geolocation at the end of the track represented by the transmitted + * measurement. This value is optional and may not be available for measurements without locations. For measurements + * with one location this equals the [.START_LOCATION_TS]. + */ + END_LOCATION_TS("endLocTS"), + + /** + * The modality type used to capture the measurement. + */ + MODALITY("modality"), + + /** + * The format version of the transfer file. + */ + FORMAT_VERSION("formatVersion"), + + /** + * The count of log files which will be uploaded for the transmitted measurement. + */ + LOG_COUNT("logCount"), + + /** + * The count of image files which will be uploaded for the transmitted measurement. + */ + IMAGE_COUNT("imageCount"), + + /** + * The count of video files which will be uploaded for the transmitted measurement. + */ + VIDEO_COUNT("videoCount"), + + /** + * The number of bytes of the attachments which will be uploaded for this measurement (log, image and video data). + */ + FILES_SIZE("filesSize"), + + /** + * The identifier of the attachment, if this upload is an attachment. + */ + ATTACHMENT_ID("attachmentId"), +} diff --git a/src/main/kotlin/de/cyface/uploader/model/Measurement.kt b/src/main/kotlin/de/cyface/uploader/model/Measurement.kt new file mode 100644 index 0000000..ebb47db --- /dev/null +++ b/src/main/kotlin/de/cyface/uploader/model/Measurement.kt @@ -0,0 +1,203 @@ +/* + * Copyright 2024 Cyface GmbH + * + * This file is part of the Cyface Uploader. + * + * The Cyface Uploader is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * The Cyface Uploader is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with the Cyface Uploader. If not, see . + */ +package de.cyface.uploader.model + +import de.cyface.uploader.exception.InvalidMetaData +import de.cyface.uploader.model.metadata.ApplicationMetaData +import de.cyface.uploader.model.metadata.AttachmentMetaData +import de.cyface.uploader.model.metadata.DeviceMetaData +import de.cyface.uploader.model.metadata.MeasurementMetaData +/*import io.vertx.core.Future +import io.vertx.core.MultiMap +import io.vertx.core.Promise +import io.vertx.core.json.JsonObject +import io.vertx.ext.web.Session*/ +import java.util.Locale +import java.util.UUID + +data class Measurement( + val identifier: MeasurementIdentifier, + private val deviceMetaData: DeviceMetaData, + private val applicationMetaData: ApplicationMetaData, + private val measurementMetaData: MeasurementMetaData, + private val attachmentMetaData: AttachmentMetaData, +) : Uploadable { + /*override fun toJson(): JsonObject { + val ret = JsonObject() + ret.put(FormAttributes.DEVICE_ID.value, identifier.deviceIdentifier.toString()) + ret.put(FormAttributes.MEASUREMENT_ID.value, identifier.measurementIdentifier.toString()) + ret + .mergeIn(deviceMetaData.toJson(), true) + .mergeIn(applicationMetaData.toJson(), true) + .mergeIn(measurementMetaData.toJson(), true) + .mergeIn(attachmentMetaData.toJson(), true) + return ret + } + + override fun toGeoJson(): JsonObject { + val geoJson = toGeoJson(deviceMetaData, applicationMetaData, measurementMetaData, attachmentMetaData) + + geoJson + .getJsonArray("features") + .getJsonObject(0) + .getJsonObject("properties") + .put(FormAttributes.DEVICE_ID.value, identifier.deviceIdentifier.toString()) + .put(FormAttributes.MEASUREMENT_ID.value, identifier.measurementIdentifier.toString()) + + return geoJson + }*/ + + override fun toMap(): Map { + val map = mutableMapOf() + + map[FormAttributes.DEVICE_ID.value] = identifier.deviceIdentifier.toString() + map[FormAttributes.MEASUREMENT_ID.value] = identifier.measurementIdentifier.toString() + + map[FormAttributes.OS_VERSION.value] = deviceMetaData.operatingSystemVersion + map[FormAttributes.DEVICE_TYPE.value] = deviceMetaData.deviceType + + map[FormAttributes.APPLICATION_VERSION.value] = applicationMetaData.applicationVersion + map[FormAttributes.FORMAT_VERSION.value] = applicationMetaData.formatVersion.toString() + + measurementMetaData.startLocation?.let { startLocation -> + map[FormAttributes.START_LOCATION_LAT.value] = startLocation.latitude.toString() + map[FormAttributes.START_LOCATION_LON.value] = startLocation.longitude.toString() + map[FormAttributes.START_LOCATION_TS.value] = startLocation.timestamp.toString() + } + measurementMetaData.endLocation?.let { endLocation -> + map[FormAttributes.END_LOCATION_LAT.value] = endLocation.latitude.toString() + map[FormAttributes.END_LOCATION_LON.value] = endLocation.longitude.toString() + map[FormAttributes.END_LOCATION_TS.value] = endLocation.timestamp.toString() + } + map[FormAttributes.LENGTH.value] = measurementMetaData.length.toString() + map[FormAttributes.LOCATION_COUNT.value] = measurementMetaData.locationCount.toString() + map[FormAttributes.MODALITY.value] = measurementMetaData.modality + + map[FormAttributes.LOG_COUNT.value] = attachmentMetaData.logCount.toString() + map[FormAttributes.IMAGE_COUNT.value] = attachmentMetaData.imageCount.toString() + map[FormAttributes.VIDEO_COUNT.value] = attachmentMetaData.videoCount.toString() + map[FormAttributes.FILES_SIZE.value] = attachmentMetaData.filesSize.toString() + + return map + } +} + +data class MeasurementIdentifier(val deviceIdentifier: UUID, val measurementIdentifier: Long) + +class MeasurementFactory : UploadableFactory { + /*override fun from(json: JsonObject): Uploadable { + try { + val deviceIdentifier = UUID.fromString(json.getString(FormAttributes.DEVICE_ID.value)) + val measurementIdentifier = json.getString(FormAttributes.MEASUREMENT_ID.value).toLong() + + val applicationMetaData = applicationMetaData(json) + val attachmentMetaData = attachmentMetaData(json) + val deviceMetaData = deviceMetaData(json) + val measurementMetaData = measurementMetaData(json) + + return Measurement( + MeasurementIdentifier(deviceIdentifier, measurementIdentifier), + deviceMetaData, + applicationMetaData, + measurementMetaData, + attachmentMetaData, + ) + } catch (e: TooFewLocations) { + throw SkipUpload(e) + } catch (e: IllegalArgumentException) { + throw InvalidMetaData("Data was not parsable!", e) + } catch (e: NullPointerException) { + throw InvalidMetaData("Data was not parsable!", e) + } + }*/ + + override fun attachmentMetaData( + logCount: String?, + imageCount: String?, + videoCount: String?, + filesSize: String? + ): AttachmentMetaData { + // For backward compatibility we support measurement upload requests without attachment metadata + val attachmentMetaMissing = logCount == null && imageCount == null && videoCount == null && filesSize == null + if (attachmentMetaMissing) { + return AttachmentMetaData(0, 0, 0, 0) + } else { + validateAttachmentMetaData(logCount, imageCount, videoCount, filesSize) + return AttachmentMetaData( + logCount!!.toInt(), + imageCount!!.toInt(), + videoCount!!.toInt(), + filesSize!!.toLong(), + ) + } + } + + private fun validateAttachmentMetaData( + logCount: String?, + imageCount: String?, + videoCount: String?, + filesSize: String? + ) { + if (logCount == null) throw InvalidMetaData("Data incomplete logCount was null!") + if (imageCount == null) throw InvalidMetaData("Data incomplete imageCount was null!") + if (videoCount == null) throw InvalidMetaData("Data incomplete videoCount was null!") + if (filesSize == null) throw InvalidMetaData("Data incomplete filesSize was null!") + if (logCount.toInt() < 0 || imageCount.toInt() < 0 || videoCount.toInt() < 0) { + throw InvalidMetaData("Invalid file count for attachment.") + } + val attachmentCount = logCount.toInt() + imageCount.toInt() + videoCount.toInt() + if ( attachmentCount > 0 && filesSize.toLong() <= 0L) { + throw InvalidMetaData("Files size for attachment must be greater than 0.") + } + } + + /*override fun from(headers: MultiMap): Uploadable { + try { + val deviceId = UUID.fromString(requireNotNull(headers.get(FormAttributes.DEVICE_ID.value))) + val measurementId = requireNotNull(headers.get(FormAttributes.MEASUREMENT_ID.value)).toLong() + + val measurementIdentifier = MeasurementIdentifier(deviceId, measurementId) + + val attachmentMetaData = attachmentMetaData(headers) + val applicationMetaData = applicationMetaData(headers) + val measurementMetaData = measurementMetaData(headers) + val deviceMetaData = deviceMetaData(headers) + + return Measurement( + measurementIdentifier, + deviceMetaData, + applicationMetaData, + measurementMetaData, + attachmentMetaData, + ) + } catch (e: IllegalArgumentException) { + throw InvalidMetaData("Data incomplete!", e) + } catch (e: NumberFormatException) { + throw InvalidMetaData("Data incomplete!", e) + } catch (e: DeprecatedFormatVersion) { + throw SkipUpload(e) + } catch (e: UnknownFormatVersion) { + throw InvalidMetaData(e) + } catch (e: TooFewLocations) { + throw SkipUpload(e) + } catch (e: RuntimeException) { + throw InvalidMetaData("Data was not parsable!", e) + } + }*/ +} diff --git a/src/main/kotlin/de/cyface/uploader/model/Uploadable.kt b/src/main/kotlin/de/cyface/uploader/model/Uploadable.kt new file mode 100644 index 0000000..5de631a --- /dev/null +++ b/src/main/kotlin/de/cyface/uploader/model/Uploadable.kt @@ -0,0 +1,339 @@ +/* + * Copyright 2024 Cyface GmbH + * + * This file is part of the Cyface Uploader. + * + * The Cyface Uploader is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * The Cyface Uploader is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with the Cyface Uploader. If not, see . + */ +package de.cyface.uploader.model + +import de.cyface.uploader.model.FormAttributes +import de.cyface.uploader.model.metadata.ApplicationMetaData +import de.cyface.uploader.model.metadata.AttachmentMetaData +import de.cyface.uploader.model.metadata.DeviceMetaData +import de.cyface.uploader.model.metadata.GeoLocationFactory +import de.cyface.uploader.model.metadata.MeasurementMetaData +/*import io.vertx.core.Future +import io.vertx.core.MultiMap +import io.vertx.core.json.JsonArray +import io.vertx.core.json.JsonObject +import io.vertx.ext.web.Session*/ + +/** + * Interface for object types which the Collector accepts as upload. + * + * @author Klemens Muthmann + */ +interface Uploadable { + /** + * Transform this object into a generic JSON representation. + * + * @return The JSON representation of this object. + */ + //fun toJson(): JsonObject + + /** + * Transform this object into a GeoJSON Feature. + * + * @return The GeoJSON representation of this object. + */ + //fun toGeoJson(): JsonObject + + /** + * Transform this object into a GeoJSON FeatureCollection. + * + * @param deviceMetaData The metadata of the device. + * @param applicationMetaData The metadata of the application. + * @param measurementMetaData The metadata of the measurement. + * @param attachmentMetaData The metadata of the attachments. + * @return The GeoJSON representation of this object. + */ + /*fun toGeoJson( + deviceMetaData: DeviceMetaData, + applicationMetaData: ApplicationMetaData, + measurementMetaData: MeasurementMetaData, + attachmentMetaData: AttachmentMetaData, + ): JsonObject { + val feature = JsonObject() + val properties = JsonObject() + val geometry = JsonObject() + + if (measurementMetaData.startLocation != null && measurementMetaData.endLocation != null) { + val startCoordinates = JsonArray( + mutableListOf( + measurementMetaData.startLocation.longitude, + measurementMetaData.startLocation.latitude + ) + ) + val endCoordinates = JsonArray( + mutableListOf( + measurementMetaData.endLocation.longitude, + measurementMetaData.endLocation.latitude + ) + ) + val coordinates = JsonArray(mutableListOf(startCoordinates, endCoordinates)) + geometry + .put("type", "MultiPoint") + .put("coordinates", coordinates) + } else { + geometry + .put("type", "MultiPoint") + .put("coordinates", null) + } + + properties.put(FormAttributes.OS_VERSION.value, deviceMetaData.operatingSystemVersion) + properties.put(FormAttributes.DEVICE_TYPE.value, deviceMetaData.deviceType) + properties.put(FormAttributes.APPLICATION_VERSION.value, applicationMetaData.applicationVersion) + properties.put(FormAttributes.LENGTH.value, measurementMetaData.length) + properties.put(FormAttributes.LOCATION_COUNT.value, measurementMetaData.locationCount) + properties.put(FormAttributes.MODALITY.value, measurementMetaData.modality) + properties.put(FormAttributes.FORMAT_VERSION.value, applicationMetaData.formatVersion) + properties.put(FormAttributes.LOG_COUNT.value, attachmentMetaData.logCount) + properties.put(FormAttributes.IMAGE_COUNT.value, attachmentMetaData.imageCount) + properties.put(FormAttributes.VIDEO_COUNT.value, attachmentMetaData.videoCount) + properties.put(FormAttributes.FILES_SIZE.value, attachmentMetaData.filesSize) + + feature + .put("type", "Feature") + .put("geometry", geometry) + .put("properties", properties) + val ret = JsonObject() + ret.put("type", "FeatureCollection") + ret.put("features", JsonArray().add(feature)) + return ret + }*/ + + /** + * Transform this object into a `Map` representation which can be injected into the upload request header. + */ + fun toMap(): Map + + companion object { + /** + * The field name for the session entry which contains the measurement id. + * + * This field is set in the [PreRequestHandler] to ensure sessions are bound to measurements and + * uploads are only accepted with an accepted pre request. + */ + const val MEASUREMENT_ID_FIELD = "measurement-id" + + /** + * The field name for the session entry which contains the device id. + * + * This field is set in the [PreRequestHandler] to ensure sessions are bound to measurements and + * uploads are only accepted with an accepted pre request. + */ + const val DEVICE_ID_FIELD = "device-id" + } +} + +/** + * Factory interface for creating uploadable objects. + * + * @author Klemens Muthmann + */ +interface UploadableFactory : + DeviceMetaDataFactory, + ApplicationMetaDataFactory, + MeasurementMetaDataFactory, + AttachmentMetaDataFactory { + /** + * Creates an uploadable object from the metadata body. + * + * @param json The metadata body sent in the pre-request. + * @return The created uploadable object. + */ + //fun from(json: JsonObject): Uploadable + + /** + * Creates an uploadable object from the metadata header. + * + * @param headers The metadata headers sent in the pre-request. + * @return The created uploadable object. + */ + //fun from(headers: MultiMap): Uploadable +} + +/** + * Factory for creating device-specific metadata objects. + * + * @author Klemens Muthmann + */ +interface DeviceMetaDataFactory { + /** + * Extracts the device specific metadata from the request body. + * + * @param json The request body containing the metadata. + * @return The extracted metadata. + */ + /*fun deviceMetaData(json: JsonObject): DeviceMetaData { + val osVersion = json.getString(FormAttributes.OS_VERSION.value) + val deviceType = json.getString(FormAttributes.DEVICE_TYPE.value) + return DeviceMetaData(osVersion, deviceType) + }*/ + + /** + * Extracts the device specific metadata from the request headers. + * + * @param headers The request headers containing the metadata. + * @return The extracted metadata. + */ + /*fun deviceMetaData(headers: MultiMap): DeviceMetaData { + val osVersion = headers.get(FormAttributes.OS_VERSION.value) + val deviceType = headers.get(FormAttributes.DEVICE_TYPE.value) + return DeviceMetaData(osVersion, deviceType) + }*/ +} + +/** + * Factory for creating application-specific metadata objects. + * + * @author Klemens Muthmann + */ +interface ApplicationMetaDataFactory { + /** + * Extracts the device specific metadata from the request body. + * + * @param json The request body containing the metadata. + * @return The extracted metadata. + */ + /*fun applicationMetaData(json: JsonObject): ApplicationMetaData { + val appVersion = json.getString(FormAttributes.APPLICATION_VERSION.value) + val formatVersion = json.getString(FormAttributes.FORMAT_VERSION.value).toInt() + return ApplicationMetaData(appVersion, formatVersion) + }*/ + + /** + * Extracts the application specific metadata from the request headers. + * + * @param headers The request headers containing the metadata. + * @return The extracted metadata. + */ + /*fun applicationMetaData(headers: MultiMap): ApplicationMetaData { + val appVersion = headers.get(FormAttributes.APPLICATION_VERSION.value) + val formatVersion = headers.get(FormAttributes.FORMAT_VERSION.value).toInt() + + return ApplicationMetaData(appVersion, formatVersion) + }*/ +} + +/** + * Factory for creating measurement-specific metadata objects. + * + * @author Klemens Muthmann + + */ +interface MeasurementMetaDataFactory { + /** + * Extracts the measurement specific metadata from the request body. + * + * @param json The request body containing the metadata. + * @return The extracted metadata. + */ + /*fun measurementMetaData(json: JsonObject): MeasurementMetaData { + val length = json.getString(FormAttributes.LENGTH.value).toDouble() + val locationCount = json.getString(FormAttributes.LOCATION_COUNT.value).toLong() + val startLocationLat = json.getString(FormAttributes.START_LOCATION_LAT.value) + val startLocationLon = json.getString(FormAttributes.START_LOCATION_LON.value) + val startLocationTs = json.getString(FormAttributes.START_LOCATION_TS.value) + val endLocationLat = json.getString(FormAttributes.END_LOCATION_LAT.value) + val endLocationLon = json.getString(FormAttributes.END_LOCATION_LON.value) + val endLocationTs = json.getString(FormAttributes.END_LOCATION_TS.value) + val locationFactory = GeoLocationFactory() + val startLocation = locationFactory.from(startLocationTs, startLocationLat, startLocationLon) + val endLocation = locationFactory.from(endLocationTs, endLocationLat, endLocationLon) + val modality = json.getString(FormAttributes.MODALITY.value) + return MeasurementMetaData(length, locationCount, startLocation, endLocation, modality) + }*/ + + /** + * Extracts the measurement specific metadata from the request headers. + * + * @param headers The request headers containing the metadata. + * @return The extracted metadata. + */ + /*fun measurementMetaData(headers: MultiMap): MeasurementMetaData { + val length = headers.get(FormAttributes.LENGTH.value).toDouble() + val locationCount = headers.get(FormAttributes.LOCATION_COUNT.value).toLong() + val startLocationLat = headers.get(FormAttributes.START_LOCATION_LAT.value) + val startLocationLon = headers.get(FormAttributes.START_LOCATION_LON.value) + val startLocationTs = headers.get(FormAttributes.START_LOCATION_TS.value) + val endLocationLat = headers.get(FormAttributes.END_LOCATION_LAT.value) + val endLocationLon = headers.get(FormAttributes.END_LOCATION_LON.value) + val endLocationTs = headers.get(FormAttributes.END_LOCATION_TS.value) + val locationFactory = GeoLocationFactory() + val startLocation = locationFactory.from(startLocationTs, startLocationLat, startLocationLon) + val endLocation = locationFactory.from(endLocationTs, endLocationLat, endLocationLon) + val modality = headers.get(FormAttributes.MODALITY.value) + + return MeasurementMetaData( + length, + locationCount, + startLocation, + endLocation, + modality + ) + }*/ +} + +/** + * Factory for creating attachment-specific metadata objects. + * + * @author Klemens Muthmann + */ +interface AttachmentMetaDataFactory { + /** + * Creates an attachment metadata object from the given values. + * + * @param logCount The number of log files captured for this measurement. + * @param imageCount The number of image files captured for this measurement. + * @param videoCount The number of video files captured for this measurement. + * @param filesSize The number of bytes of the attachment files. + * @return The created attachment metadata object. + */ + fun attachmentMetaData( + logCount: String?, + imageCount: String?, + videoCount: String?, + filesSize: String?, + ): AttachmentMetaData + + /** + * Extracts the attachment specific metadata from the request body. + * + * @param body The request body containing the metadata. + * @return The extracted metadata. + */ + /*fun attachmentMetaData(body: JsonObject): AttachmentMetaData { + val logCount = body.getString(FormAttributes.LOG_COUNT.value) + val imageCount = body.getString(FormAttributes.IMAGE_COUNT.value) + val videoCount = body.getString(FormAttributes.VIDEO_COUNT.value) + val filesSize = body.getString(FormAttributes.FILES_SIZE.value) + return attachmentMetaData(logCount, imageCount, videoCount, filesSize) + }*/ + + /** + * Extracts the attachment specific metadata from the request headers. + * + * @param headers The request headers containing the metadata. + * @return The extracted metadata. + */ + /*fun attachmentMetaData(headers: MultiMap): AttachmentMetaData { + val logCount = headers.get(FormAttributes.LOG_COUNT.value) + val imageCount = headers.get(FormAttributes.IMAGE_COUNT.value) + val videoCount = headers.get(FormAttributes.VIDEO_COUNT.value) + val filesSize = headers.get(FormAttributes.FILES_SIZE.value) + return attachmentMetaData(logCount, imageCount, videoCount, filesSize) + }*/ +} diff --git a/src/main/kotlin/de/cyface/uploader/model/metadata/ApplicationMetaData.kt b/src/main/kotlin/de/cyface/uploader/model/metadata/ApplicationMetaData.kt new file mode 100644 index 0000000..8afe28b --- /dev/null +++ b/src/main/kotlin/de/cyface/uploader/model/metadata/ApplicationMetaData.kt @@ -0,0 +1,69 @@ +/* + * Copyright 2024 Cyface GmbH + * + * This file is part of the Cyface Uploader. + * + * The Cyface Uploader is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * The Cyface Uploader is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with the Cyface Uploader. If not, see . + */ +package de.cyface.uploader.model.metadata + +import de.cyface.uploader.exception.DeprecatedFormatVersion +import de.cyface.uploader.exception.UnknownFormatVersion +import de.cyface.uploader.model.metadata.MetaData.Companion.MAX_GENERIC_METADATA_FIELD_LENGTH +//import io.vertx.core.json.JsonObject +import java.io.Serializable + +/** + * The metadata which describes the application which collected the data. + * + * @author Armin Schnabel + * @property applicationVersion The version of the app that transmitted the measurement. + * @property formatVersion The format version of the upload file. + */ +data class ApplicationMetaData( + val applicationVersion: String, + val formatVersion: Int, +) : MetaData, Serializable { + init { + require(applicationVersion.isNotEmpty() && applicationVersion.length <= MAX_GENERIC_METADATA_FIELD_LENGTH) { + "Field applicationVersion had an invalid length of ${applicationVersion.length.toLong()}" + } + if (formatVersion < CURRENT_TRANSFER_FILE_FORMAT_VERSION) { + throw DeprecatedFormatVersion("Deprecated formatVersion: ${formatVersion.toLong()}") + } else if (formatVersion != CURRENT_TRANSFER_FILE_FORMAT_VERSION) { + throw UnknownFormatVersion("Unknown formatVersion: ${formatVersion.toLong()}") + } + } + + /*override fun toJson(): JsonObject { + val ret = JsonObject() + ret.put(FormAttributes.APPLICATION_VERSION.value, applicationVersion) + ret.put(FormAttributes.FORMAT_VERSION.value, formatVersion) + return ret + }*/ + + companion object { + /** + * Used to serialize objects of this class. Only change this value if this classes attribute set changes. + */ + @Suppress("ConstPropertyName") + private const val serialVersionUID = 1L + + /** + * The current version of the transferred file. This is always specified by the first two bytes of the file + * transferred and helps compatible APIs to process data from different client versions. + */ + const val CURRENT_TRANSFER_FILE_FORMAT_VERSION = 3 + } +} diff --git a/src/main/kotlin/de/cyface/uploader/model/metadata/AttachmentMetaData.kt b/src/main/kotlin/de/cyface/uploader/model/metadata/AttachmentMetaData.kt new file mode 100644 index 0000000..fd79e82 --- /dev/null +++ b/src/main/kotlin/de/cyface/uploader/model/metadata/AttachmentMetaData.kt @@ -0,0 +1,62 @@ +/* + * Copyright 2024 Cyface GmbH + * + * This file is part of the Cyface Uploader. + * + * The Cyface Uploader is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * The Cyface Uploader is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with the Cyface Uploader. If not, see . + */ +package de.cyface.uploader.model.metadata + +//import io.vertx.core.json.JsonObject +import java.io.Serializable + +/** + * The metadata which describes the attachments collected together with the measurement. + * + * @author Armin Schnabel + * @property logCount Number of log files captured for this measurement, e.g. image capturing metrics. + * @property imageCount Number of image files captured for this measurement. + * @property videoCount Number of video files captured for this measurement. + * @property filesSize The number of bytes of the files collected for this measurement (log, image and video data). + */ +data class AttachmentMetaData( + val logCount: Int, + val imageCount: Int, + val videoCount: Int, + val filesSize: Long, +) : MetaData, Serializable { + init { + require(logCount >= 0) { "Invalid logCount: $logCount" } + require(imageCount >= 0) { "Invalid imageCount: $imageCount" } + require(videoCount >= 0) { "Invalid videoCount: $videoCount" } + require(filesSize >= 0) { "Invalid filesSize: $filesSize" } + } + + /*override fun toJson(): JsonObject { + val ret = JsonObject() + ret.put(FormAttributes.LOG_COUNT.value, logCount) + ret.put(FormAttributes.IMAGE_COUNT.value, imageCount) + ret.put(FormAttributes.VIDEO_COUNT.value, videoCount) + ret.put(FormAttributes.FILES_SIZE.value, filesSize) + return ret + }*/ + + companion object { + /** + * Used to serialize objects of this class. Only change this value if this classes attribute set changes. + */ + @Suppress("ConstPropertyName") + private const val serialVersionUID = 1L + } +} diff --git a/src/main/kotlin/de/cyface/uploader/model/metadata/DeviceMetaData.kt b/src/main/kotlin/de/cyface/uploader/model/metadata/DeviceMetaData.kt new file mode 100644 index 0000000..c3ccd2a --- /dev/null +++ b/src/main/kotlin/de/cyface/uploader/model/metadata/DeviceMetaData.kt @@ -0,0 +1,63 @@ +/* + * Copyright 2024 Cyface GmbH + * + * This file is part of the Cyface Uploader. + * + * The Cyface Uploader is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * The Cyface Uploader is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with the Cyface Uploader. If not, see . + */ +package de.cyface.uploader.model.metadata + +import de.cyface.uploader.model.metadata.MetaData.Companion.MAX_GENERIC_METADATA_FIELD_LENGTH +//import io.vertx.core.json.JsonObject +import java.io.Serializable + +/** + * The metadata which describes the device which collected the data. + * + * @author Armin Schnabel + * @property operatingSystemVersion The operating system version, such as Android 9.0.0 or iOS 11.2. + * @property deviceType The type of device uploading the data, such as Pixel 3 or iPhone 6 Plus. + */ +data class DeviceMetaData( + val operatingSystemVersion: String, + val deviceType: String, +) : MetaData, Serializable { + + init { + require( + operatingSystemVersion.isNotEmpty() && + operatingSystemVersion.length <= MAX_GENERIC_METADATA_FIELD_LENGTH + ) { + "Field osVersion had an invalid length of ${operatingSystemVersion.length.toLong()}" + } + require(deviceType.isNotEmpty() && deviceType.length <= MAX_GENERIC_METADATA_FIELD_LENGTH) { + "Field deviceType had an invalid length of ${deviceType.length.toLong()}" + } + } + + /*override fun toJson(): JsonObject { + val ret = JsonObject() + ret.put(FormAttributes.OS_VERSION.value, operatingSystemVersion) + ret.put(FormAttributes.DEVICE_TYPE.value, deviceType) + return ret + }*/ + + companion object { + /** + * Used to serialize objects of this class. Only change this value if this classes attribute set changes. + */ + @Suppress("ConstPropertyName") + private const val serialVersionUID = 1L + } +} diff --git a/src/main/kotlin/de/cyface/uploader/model/metadata/MeasurementMetaData.kt b/src/main/kotlin/de/cyface/uploader/model/metadata/MeasurementMetaData.kt new file mode 100644 index 0000000..da1164d --- /dev/null +++ b/src/main/kotlin/de/cyface/uploader/model/metadata/MeasurementMetaData.kt @@ -0,0 +1,150 @@ +/* + * Copyright 2024 Cyface GmbH + * + * This file is part of the Cyface Uploader. + * + * The Cyface Uploader is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * The Cyface Uploader is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with the Cyface Uploader. If not, see . + */ +package de.cyface.uploader.model.metadata + +import de.cyface.uploader.exception.TooFewLocations +import de.cyface.uploader.model.metadata.MetaData.Companion.MAX_GENERIC_METADATA_FIELD_LENGTH +//import io.vertx.core.json.JsonArray +//import io.vertx.core.json.JsonObject +import java.io.Serializable + +/** + * The metadata which describes the measurement the data was collected for. + * + * @author Armin Schnabel + * @property length The length of the measurement in meters. + * @property locationCount The count of geolocations in the transmitted measurement. + * @property startLocation The first [GeoLocation] captured by the transmitted measurement. + * @property endLocation The last [GeoLocation] captured by the transmitted measurement. + * @property modality The modality type used to capture the measurement. + */ +data class MeasurementMetaData( + val length: Double, + val locationCount: Long, + val startLocation: GeoLocation?, + val endLocation: GeoLocation?, + val modality: String, +) : MetaData, Serializable { + init { + if (locationCount < MINIMUM_LOCATION_COUNT) { + throw TooFewLocations("LocationCount smaller than required: $locationCount") + } + requireNotNull(startLocation) { + "Data incomplete startLocation was null!" + } + requireNotNull(endLocation) { + "Data incomplete endLocation was null!" + } + require(length >= MINIMUM_TRACK_LENGTH) { + "Field length had an invalid value smaller then 0.0: $length" + } + require(modality.isNotEmpty() && modality.length <= MAX_GENERIC_METADATA_FIELD_LENGTH) { + "Field modality had an invalid length of ${modality.length.toLong()}" + } + } + + /*override fun toJson(): JsonObject { + val ret = JsonObject() + ret.put(FormAttributes.LENGTH.value, length) + ret.put(FormAttributes.LOCATION_COUNT.value, locationCount) + if (startLocation != null) { + ret.put("start", startLocation.geoJson()) + } + if (endLocation != null) { + ret.put("end", endLocation.geoJson()) + } + ret.put(FormAttributes.MODALITY.value, modality) + return ret + }*/ + + companion object { + /** + * Used to serialize objects of this class. Only change this value if this classes attribute set changes. + */ + @Suppress("ConstPropertyName") + private const val serialVersionUID = 1L + + /** + * The minimum length of a track stored with a measurement. + */ + private const val MINIMUM_TRACK_LENGTH = 0.0 + + /** + * The minimum valid amount of locations stored inside a measurement, or else skip the upload. + */ + private const val MINIMUM_LOCATION_COUNT = 2L + } +} + +/** + * This class represents a geolocation at the start or end of a track. + * + * @author Armin Schnabel + * @property timestamp The Unix timestamp this location was captured on in milliseconds. + * @property latitude Geographical latitude (decimal fraction) raging from -90° (south) to 90° (north). + * @property longitude Geographical longitude (decimal fraction) ranging from -180° (west) to 180° (east). + */ +data class GeoLocation( + val timestamp: Long, + val latitude: Double, + val longitude: Double +) { + /** + * Converts this location record into `JSON` which supports the mongoDB `GeoJSON` format: + * https://docs.mongodb.com/manual/geospatial-queries/ + * + * @return the converted location record as JSON + */ + /*fun geoJson(): JsonObject { + val ret = JsonObject() + ret.put("timestamp", timestamp) + val geometry = JsonObject() + .put("type", "Point") + .put("coordinates", JsonArray().add(longitude).add(latitude)) + ret.put("location", geometry) + return ret + }*/ +} + +/** + * Factory to create [GeoLocation] objects from given parameters. + * + * @author Armin Schnabel + */ +class GeoLocationFactory { + /** + * Creates a new [GeoLocation] object from the given parameters. + * + * @param timestamp The timestamp of the location. + * @param latitude The latitude of the location. + * @param longitude The longitude of the location. + * @return The created geographical location object or `null` if any of the parameters is `null`. + */ + fun from(timestamp: String?, latitude: String?, longitude: String?): GeoLocation? { + return if (timestamp != null && latitude != null && longitude != null) { + GeoLocation( + timestamp.toLong(), + latitude.toDouble(), + longitude.toDouble(), + ) + } else { + null + } + } +} diff --git a/src/main/kotlin/de/cyface/uploader/model/metadata/MetaData.kt b/src/main/kotlin/de/cyface/uploader/model/metadata/MetaData.kt new file mode 100644 index 0000000..d08966d --- /dev/null +++ b/src/main/kotlin/de/cyface/uploader/model/metadata/MetaData.kt @@ -0,0 +1,33 @@ +/* + * Copyright 2024 Cyface GmbH + * + * This file is part of the Cyface Uploader. + * + * The Cyface Uploader is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * The Cyface Uploader is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with the Cyface Uploader. If not, see . + */ +package de.cyface.uploader.model.metadata + +//import io.vertx.core.json.JsonObject + +interface MetaData { + //fun toJson(): JsonObject + + companion object { + /** + * Maximum size of a metadata field, with plenty space for future development. Prevents attackers from putting + * arbitrary long data into these fields. + */ + const val MAX_GENERIC_METADATA_FIELD_LENGTH = 30 + } +} diff --git a/src/test/kotlin/de/cyface/uploader/DefaultUploaderTest.kt b/src/test/kotlin/de/cyface/uploader/DefaultUploaderTest.kt index a5f068c..96f9b1c 100644 --- a/src/test/kotlin/de/cyface/uploader/DefaultUploaderTest.kt +++ b/src/test/kotlin/de/cyface/uploader/DefaultUploaderTest.kt @@ -68,7 +68,7 @@ class DefaultUploaderTest { ) // Act - val result: Map = DefaultUploader.preRequestBody(metaData) + val result: Map = metaData.toMap() // Assert val expected: MutableMap = HashMap()