diff --git a/README.md b/README.md index a72a264..969cfee 100644 --- a/README.md +++ b/README.md @@ -125,11 +125,16 @@ val startLocation = GeoLocation(1637744753012L, 51.1, 13.1) val endLocation = GeoLocation(1637744993000, 51.2, 13.2) val modality = "BICYCLE" val formatVersion = RequestMetaData.CURRENT_TRANSFER_FILE_FORMAT_VERSION +val logCount = 0 +val imageCount = 0 +val videoCount = 0 +val filesSize = 0L val metaData = RequestMetaData( deviceId, measurementId, osVersion, deviceType, appVersion, length, locationCount, startLocation, endLocation, - modality, formatVersion + modality, formatVersion, + logCount, imageCount, videoCount, filesSize ) // Replace with a Cyface Binary or another file when using a custom Collector diff --git a/build.gradle.kts b/build.gradle.kts index 598ff4c..a71c016 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -61,8 +61,6 @@ version = "0.0.0" // Automatically overwritten by CI // Versions of dependencies extra["slf4jVersion"] = "2.0.7" -// TODO: Check if we can remove this after we port the new auth from android-backend to this library -extra["cyfaceSerializationVersion"] = "3.2.0" extra["googleApiClientVersion"] = "2.2.0" // transmission protocol extra["gradleWrapperVersion"] = "7.6.1" @@ -100,7 +98,6 @@ tasks.test { dependencies { // Internal Cyface Dependencies - api("de.cyface:serializer:${project.extra["cyfaceSerializationVersion"]}") implementation("com.google.api-client:google-api-client:${project.extra["googleApiClientVersion"]}") // Kotlin Support diff --git a/src/main/kotlin/de/cyface/uploader/DefaultUploader.kt b/src/main/kotlin/de/cyface/uploader/DefaultUploader.kt index 7993025..dc24048 100644 --- a/src/main/kotlin/de/cyface/uploader/DefaultUploader.kt +++ b/src/main/kotlin/de/cyface/uploader/DefaultUploader.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023 Cyface GmbH + * Copyright 2023-2024 Cyface GmbH * * This file is part of the Cyface Uploader. * @@ -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 @@ -66,8 +68,6 @@ import javax.net.ssl.SSLException * To use this interface just call [DefaultUploader.uploadMeasurement] or [DefaultUploader.uploadAttachment]. * * @author Armin Schnabel - * @version 2.0.0 - * @since 1.0.0 * @property apiEndpoint An API endpoint running a Cyface data collector service, like `https://some.url/api/v3` */ class DefaultUploader(private val apiEndpoint: String) : Uploader { @@ -75,45 +75,46 @@ 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, - measurementId: Long, + uploadable: Attachment, file: File, fileName: String, - progressListener: UploadProgressListener + progressListener: UploadProgressListener, ): Result { - val endpoint = attachmentsEndpoint(measurementId) - return uploadFile(jwtToken, metaData, file, endpoint, progressListener) + val measurementId = uploadable.identifier.measurementIdentifier + val deviceId = uploadable.identifier.deviceIdentifier.toString() + val endpoint = attachmentsEndpoint(deviceId, measurementId) + return uploadFile(jwtToken, uploadable, file, endpoint, progressListener) } override fun measurementsEndpoint(): URL { return URL(returnUrlWithTrailingSlash(apiEndpoint) + "measurements") } - override fun attachmentsEndpoint(measurementId: Long): URL { - return URL(returnUrlWithTrailingSlash(apiEndpoint) + "measurements/$measurementId/attachments") + override fun attachmentsEndpoint(deviceId: String, measurementId: Long): URL { + return URL(returnUrlWithTrailingSlash(apiEndpoint) + "measurements/$deviceId/$measurementId/attachments") } @Throws(UploadFailed::class) 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. @@ -126,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 @@ -151,7 +151,7 @@ class DefaultUploader(private val apiEndpoint: String) : Uploader { private fun initializeUploader( jwtToken: String, - metaData: RequestMetaData, + uploadable: Uploadable, fileInputStream: FileInputStream, file: File ): MediaHttpUploader { @@ -161,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) } @@ -171,81 +171,98 @@ class DefaultUploader(private val apiEndpoint: String) : Uploader { * We wrap errors with [UploadFailed] so that the caller can handle this without crashing. * This way the SDK's `SyncPerformer` can determine if the sync should be repeated. */ - @Suppress("ComplexMethod") private fun handleUploadException(exception: Exception): Nothing { - fun handleIOException(e: IOException): Nothing { - LOGGER.warn("Caught IOException: ${e.message}") - // Unstable Wi-Fi connection [DAT-742]. transmission stream ended too early, likely because the sync - // thread was interrupted (sync canceled). Try again later. - if (e.message?.contains("unexpected end of stream") == true) { - throw SynchronizationInterruptedException("Upload interrupted", e) - } - // IOException while reading the response. Try again later. - throw UploadFailed(SynchronisationException(e)) - } - - fun handleSSLException(e: SSLException): Nothing { - LOGGER.warn("Caught SSLException: ${e.message}") - // Thrown by OkHttp when the network is no longer available [DAT-740]. Try again later. - if (e.message?.contains("I/O error during system call, Broken pipe") == true) { - throw UploadFailed(NetworkUnavailableException("Network became unavailable during upload.")) - } - throw UploadFailed(SynchronisationException(e)) - } - when (exception) { // Crash unexpected errors hard is MalformedURLException -> error(exception) // Soft caught errors - // Happened on emulator when endpoint is local network instead of 10.0.2.2 [DAT-727] - // Server not reachable. Try again later. - is SocketTimeoutException -> throw UploadFailed(ServerUnavailableException(exception)) - is SSLException -> handleSSLException(exception) - is InterruptedIOException -> { - LOGGER.warn("Caught InterruptedIOException: ${exception.message}") - // Request interrupted [DAT-741]. Try again later. - if (exception.message?.contains("thread interrupted") == true) { - throw UploadFailed(NetworkUnavailableException("Network interrupted during upload", exception)) - } - // InterruptedIOException while reading the response. Try again later. - throw UploadFailed(SynchronisationException(exception)) - } - is IOException -> handleIOException(exception) // File is too large to be uploaded. Handle in caller (e.g. skip the upload). // The max size is currently static and set to 100 MB which should be about 44 hours of 100 Hz measurement. - is MeasurementTooLarge -> throw UploadFailed(exception) + is MeasurementTooLarge, // `HTTP_BAD_REQUEST` (400). - is BadRequestException -> throw UploadFailed(exception) + is BadRequestException, // `HTTP_UNAUTHORIZED` (401). - is UnauthorizedException -> throw UploadFailed(exception) + is UnauthorizedException, // `HTTP_FORBIDDEN` (403). Seems to happen when server is unavailable. Handle in caller. - is ForbiddenException -> throw UploadFailed(exception) + is ForbiddenException, // `HTTP_CONFLICT` (409). Already uploaded. Handle in caller (e.g. mark as synced). - is ConflictException -> throw UploadFailed(exception) + is ConflictException, // `HTTP_ENTITY_NOT_PROCESSABLE` (422). - is EntityNotParsableException -> throw UploadFailed(exception) + is EntityNotParsableException, // `HTTP_INTERNAL_ERROR` (500). - is InternalServerErrorException -> throw UploadFailed(exception) + is InternalServerErrorException, // `HTTP_TOO_MANY_REQUESTS` (429). Try again later. - is TooManyRequestsException -> throw UploadFailed(exception) + is TooManyRequestsException, // IOException while reading the response. Try again later. - is SynchronisationException -> throw UploadFailed(exception) + is SynchronisationException, // `HTTP_NOT_FOUND` (404). Try again. - is UploadSessionExpired -> throw UploadFailed(exception) + is UploadSessionExpired, // Unexpected response code. Should be reported to the server admin. - is UnexpectedResponseCode -> throw UploadFailed(exception) + is UnexpectedResponseCode, // `PRECONDITION_REQUIRED` (428). Shouldn't happen during upload, report to server admin. - is AccountNotActivated -> throw UploadFailed(exception) + is AccountNotActivated + + -> throw UploadFailed(exception) + // This is not yet thrown as a specific exception. // Network without internet connection. Try again later. // is HostUnresolvable -> throw LoginFailed(e) + // Happened on emulator when endpoint is local network instead of 10.0.2.2 [DAT-727] + // Server not reachable. Try again later. + is SocketTimeoutException -> throw UploadFailed(ServerUnavailableException(exception)) + + is SSLException, is InterruptedIOException, is IOException -> handleNetworkException(exception) + else -> throw UploadFailed(SynchronisationException(exception)) } } + private fun handleNetworkException(exception: Exception): Nothing { + fun logAndThrow(message: String, e: Exception, wrap: (Exception) -> Exception): Nothing { + LOGGER.warn("$message: ${e.message}") + throw wrap(e) + } + + when (exception) { + is SSLException -> { + // Thrown by OkHttp when the network is no longer available [DAT-740]. Try again later. + if (exception.message?.contains("I/O error during system call, Broken pipe") == true) { + logAndThrow("Network became unavailable during upload", exception) { + UploadFailed(NetworkUnavailableException(it)) + } + } + logAndThrow("Caught SSLException", exception) { + UploadFailed(SynchronisationException(it)) + } + } + is InterruptedIOException -> { + // Request interrupted [DAT-741]. Try again later. + if (exception.message?.contains("thread interrupted") == true) { + logAndThrow("Network interrupted during upload", exception) { + UploadFailed(NetworkUnavailableException(it)) + } + } + // InterruptedIOException while reading the response. Try again later. + logAndThrow("Caught InterruptedIOException", exception) { + UploadFailed(SynchronisationException(it)) + } + } + is IOException -> { + // Unstable Wi-Fi connection [DAT-742]. transmission stream ended too early, likely because the sync + // thread was interrupted (sync canceled). Try again later. + if (exception.message?.contains("unexpected end of stream") == true) { + logAndThrow("Upload interrupted", exception, ::SynchronizationInterruptedException) + } + // IOException while reading the response. Try again later. + logAndThrow("Caught IOException", exception, ::SynchronisationException) + } + else -> throw exception + } + } + @Throws( BadRequestException::class, UnauthorizedException::class, @@ -310,15 +327,9 @@ class DefaultUploader(private val apiEndpoint: String) : Uploader { } } - internal class ProgressHandler(progressListener: UploadProgressListener) : + internal class ProgressHandler(private val progressListener: UploadProgressListener) : MediaHttpUploaderProgressListener { - private val progressListener: UploadProgressListener - - init { - this.progressListener = progressListener - } - @Throws(IOException::class) override fun progressChanged(uploader: MediaHttpUploader) { LOGGER.debug("progress: ${uploader.progress}, uploaded: ${uploader.numBytesUploaded} Bytes") @@ -404,44 +415,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.startLocation?.let { startLocation -> - attributes["startLocLat"] = startLocation.latitude.toString() - attributes["startLocLon"] = startLocation.longitude.toString() - attributes["startLocTS"] = startLocation.timestamp.toString() - } - metaData.endLocation?.let { endLocation -> - attributes["endLocLat"] = endLocation.latitude.toString() - attributes["endLocLon"] = endLocation.longitude.toString() - attributes["endLocTS"] = endLocation.timestamp.toString() - } - attributes["locationCount"] = metaData.locationCount.toString() - - // Remaining meta data - attributes["deviceId"] = metaData.deviceIdentifier - attributes["measurementId"] = metaData.measurementIdentifier - attributes["deviceType"] = metaData.deviceType - attributes["osVersion"] = metaData.operatingSystemVersion - attributes["appVersion"] = metaData.applicationVersion - attributes["length"] = metaData.length.toString() - attributes["modality"] = metaData.modality - attributes["formatVersion"] = metaData.formatVersion.toString() - attributes["logCount"] = metaData.logCount.toString() - attributes["imageCount"] = metaData.imageCount.toString() - attributes["videoCount"] = metaData.videoCount.toString() - attributes["filesSize"] = metaData.filesSize.toString() - return attributes - } - @Suppress("MemberVisibilityCanBePrivate") // Part of the API @JvmStatic fun handleSuccess(response: HttpResponse): Result { @@ -547,24 +520,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 9863e98..8704faa 100644 --- a/src/main/kotlin/de/cyface/uploader/RequestInitializeHandler.kt +++ b/src/main/kotlin/de/cyface/uploader/RequestInitializeHandler.kt @@ -1,5 +1,5 @@ /* - * Copyright 2021-2023 Cyface GmbH + * Copyright 2021-2024 Cyface GmbH * * This file is part of the Cyface Uploader. * @@ -21,20 +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 - * @version 1.0.0 - * @since 7.0.0 - * @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, + private val uploadable: Uploadable, private val jwtBearer: String ) : HttpRequestInitializer { @Throws(IOException::class) @@ -42,39 +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.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.endLocation?.let { endLocation -> - headers["endLocLat"] = endLocation.latitude.toString() - headers["endLocLon"] = endLocation.longitude.toString() - headers["endLocTS"] = endLocation.timestamp.toString() - } - headers["locationCount"] = metaData.locationCount.toString() - - // Remaining meta data - headers["deviceId"] = metaData.deviceIdentifier - headers["measurementId"] = java.lang.Long.valueOf(metaData.measurementIdentifier).toString() - headers["deviceType"] = metaData.deviceType - headers["osVersion"] = metaData.operatingSystemVersion - headers["appVersion"] = metaData.applicationVersion - headers["length"] = metaData.length.toString() - headers["modality"] = metaData.modality - headers["formatVersion"] = metaData.formatVersion.toString() - headers["logCount"] = metaData.logCount - headers["imageCount"] = metaData.imageCount - headers["videoCount"] = metaData.videoCount - headers["filesSize"] = metaData.filesSize } } diff --git a/src/main/kotlin/de/cyface/uploader/Uploader.kt b/src/main/kotlin/de/cyface/uploader/Uploader.kt index 7fe18a8..7828a98 100644 --- a/src/main/kotlin/de/cyface/uploader/Uploader.kt +++ b/src/main/kotlin/de/cyface/uploader/Uploader.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023 Cyface GmbH + * Copyright 2023-2024 Cyface GmbH * * This file is part of the Cyface Uploader. * @@ -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 @@ -28,8 +30,6 @@ import java.net.URL * Interface for uploading files to a Cyface Data Collector. * * @author Armin Schnabel - * @version 2.0.0 - * @since 1.0.0 */ interface Uploader { @@ -37,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. @@ -47,7 +47,7 @@ interface Uploader { @Throws(UploadFailed::class) fun uploadMeasurement( jwtToken: String, - metaData: RequestMetaData, + uploadable: Measurement, file: File, progressListener: UploadProgressListener ): Result @@ -56,8 +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 measurementId The id of the measurement the file is attached to. + * @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. @@ -68,11 +67,10 @@ interface Uploader { @Throws(UploadFailed::class) fun uploadAttachment( jwtToken: String, - metaData: RequestMetaData, - measurementId: Long, + uploadable: Attachment, file: File, fileName: String, - progressListener: UploadProgressListener + progressListener: UploadProgressListener, ): Result /** @@ -85,10 +83,11 @@ interface Uploader { /** * Determines the URL endpoint for uploading attachment files associated with a specific measurement. * + * @param deviceId The ID of the device the measurement is attached to. * @param measurementId The ID of the measurement the files are attached to. * @return The URL endpoint used for uploading attachment files. * @throws MalformedURLException if the endpoint address is malformed. */ @Throws(MalformedURLException::class) - fun attachmentsEndpoint(measurementId: Long): URL + fun attachmentsEndpoint(deviceId: String, measurementId: Long): URL } 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..b0b0bb0 --- /dev/null +++ b/src/main/kotlin/de/cyface/uploader/model/Attachment.kt @@ -0,0 +1,104 @@ +/* + * 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 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 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 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..fcd201e --- /dev/null +++ b/src/main/kotlin/de/cyface/uploader/model/Measurement.kt @@ -0,0 +1,112 @@ +/* + * 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 java.util.UUID + +data class Measurement( + val identifier: MeasurementIdentifier, + val deviceMetaData: DeviceMetaData, + val applicationMetaData: ApplicationMetaData, + val measurementMetaData: MeasurementMetaData, + val attachmentMetaData: AttachmentMetaData, +) : Uploadable { + 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 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.") + } + } +} 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..918355e --- /dev/null +++ b/src/main/kotlin/de/cyface/uploader/model/Uploadable.kt @@ -0,0 +1,89 @@ +/* + * 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.metadata.AttachmentMetaData + +/** + * Interface for object types which the Collector accepts as upload. + * + * @author Klemens Muthmann + */ +interface Uploadable { + /** + * Transform this object into a `Map` representation which can be injected into the upload request header. + */ + fun toMap(): Map +} + +/** + * Factory interface for creating uploadable objects. + * + * @author Klemens Muthmann + */ +interface UploadableFactory : + DeviceMetaDataFactory, + ApplicationMetaDataFactory, + MeasurementMetaDataFactory, + AttachmentMetaDataFactory + +/** + * Factory for creating device-specific metadata objects. + * + * @author Klemens Muthmann + */ +interface DeviceMetaDataFactory + +/** + * Factory for creating application-specific metadata objects. + * + * @author Klemens Muthmann + */ +interface ApplicationMetaDataFactory + +/** + * Factory for creating measurement-specific metadata objects. + * + * @author Klemens Muthmann + + */ +interface MeasurementMetaDataFactory + +/** + * 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 +} 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..c0c2f27 --- /dev/null +++ b/src/main/kotlin/de/cyface/uploader/model/metadata/ApplicationMetaData.kt @@ -0,0 +1,61 @@ +/* + * 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 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()}") + } + } + + 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..5f468c3 --- /dev/null +++ b/src/main/kotlin/de/cyface/uploader/model/metadata/AttachmentMetaData.kt @@ -0,0 +1,52 @@ +/* + * 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 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" } + } + + 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..5748913 --- /dev/null +++ b/src/main/kotlin/de/cyface/uploader/model/metadata/DeviceMetaData.kt @@ -0,0 +1,55 @@ +/* + * 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 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()}" + } + } + + 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..c841799 --- /dev/null +++ b/src/main/kotlin/de/cyface/uploader/model/metadata/MeasurementMetaData.kt @@ -0,0 +1,118 @@ +/* + * 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 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()}" + } + } + + 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 +) + +/** + * 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..957b2e0 --- /dev/null +++ b/src/main/kotlin/de/cyface/uploader/model/metadata/MetaData.kt @@ -0,0 +1,29 @@ +/* + * 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 + +interface MetaData { + 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 516911f..5ab78bd 100644 --- a/src/test/kotlin/de/cyface/uploader/DefaultUploaderTest.kt +++ b/src/test/kotlin/de/cyface/uploader/DefaultUploaderTest.kt @@ -1,5 +1,5 @@ /* - * Copyright 2018-2023 Cyface GmbH + * Copyright 2018-2024 Cyface GmbH * * This file is part of the Cyface Uploader. * @@ -20,17 +20,20 @@ package de.cyface.uploader import com.natpryce.hamkrest.assertion.assertThat import com.natpryce.hamkrest.equalTo -import de.cyface.model.MeasurementIdentifier -import de.cyface.model.Modality -import de.cyface.model.RequestMetaData +import de.cyface.uploader.model.Measurement +import de.cyface.uploader.model.MeasurementIdentifier +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.GeoLocation +import de.cyface.uploader.model.metadata.MeasurementMetaData import org.junit.jupiter.api.Test +import java.util.UUID /** * Tests whether our default implementation of the HTTP protocol works as expected. * * @author Armin Schnabel - * @version 2.1.0 - * @since 1.0.0 */ class DefaultUploaderTest { /** @@ -38,30 +41,37 @@ class DefaultUploaderTest { */ @Test fun testPreRequestBody() { - val deviceId = "testDevi-ce00-42b6-a840-1b70d30094b8" // Must be a valid UUID - val id = MeasurementIdentifier(deviceId, 78) - val startLocation = RequestMetaData.GeoLocation(1000000000L, 51.1, 13.1) - val endLocation = RequestMetaData.GeoLocation(1000010000L, 51.2, 13.2) - val metaData = RequestMetaData( - id.deviceIdentifier, - id.measurementIdentifier.toString(), - "test_osVersion", - "test_deviceType", - "test_appVersion", - 10.0, - 5, - startLocation, - endLocation, - Modality.BICYCLE.databaseIdentifier, - 3, - 0, - 0, - 0, - 0 + val deviceId = UUID.randomUUID() + val measurementId = 78L + val startLocation = GeoLocation(1000000000L, 51.1, 13.1) + val endLocation = GeoLocation(1000010000L, 51.2, 13.2) + val uploadable = Measurement( + MeasurementIdentifier(deviceId, measurementId), + DeviceMetaData( + "test_osVersion", + "test_deviceType", + ), + ApplicationMetaData( + "test_appVersion", + 3, + ), + MeasurementMetaData( + 10.0, + 5, + startLocation, + endLocation, + "BICYCLE", + ), + AttachmentMetaData( + 0, + 0, + 0, + 0, + ), ) // Act - val result: Map = DefaultUploader.preRequestBody(metaData) + val result: Map = uploadable.toMap() // Assert val expected: MutableMap = HashMap() @@ -71,7 +81,7 @@ class DefaultUploaderTest { expected["endLocLat"] = "51.2" expected["endLocLon"] = "13.2" expected["endLocTS"] = "1000010000" - expected["deviceId"] = deviceId + expected["deviceId"] = deviceId.toString() expected["measurementId"] = "78" expected["deviceType"] = "test_deviceType" expected["osVersion"] = "test_osVersion"