diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 938ef19..42719a8 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -12,8 +12,8 @@ android { applicationId = "pl.firmatpp.kierowca" minSdk = 26 targetSdk = 35 - versionCode = 8 - versionName = "1.0.7" + versionCode = 9 + versionName = "1.0.8" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" buildConfigField("String", "API_BASE_URL", "\"https://api-intranet.firmatpp.pl/api/\"") diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 87617fe..ed437c6 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,6 +2,8 @@ + + = buildMap { + takenAt?.let { put("takenAt", it.toPlainTextBody()) } + latitude?.let { put("latitude", it.toString().toPlainTextBody()) } + longitude?.let { put("longitude", it.toString().toPlainTextBody()) } + locationAccuracyMeters?.let { put("locationAccuracyMeters", it.toString().toPlainTextBody()) } +} + +private fun String.toPlainTextBody(): RequestBody = toRequestBody("text/plain".toMediaTypeOrNull()) diff --git a/app/src/main/java/pl/firmatpp/kierowca/data/api/MobileDriverApi.kt b/app/src/main/java/pl/firmatpp/kierowca/data/api/MobileDriverApi.kt index be7ee5f..b6dd78c 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/data/api/MobileDriverApi.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/data/api/MobileDriverApi.kt @@ -47,5 +47,9 @@ interface MobileDriverApi { @Path("routeId") routeId: String, @Part photo: MultipartBody.Part, @Part("source") source: RequestBody, + @Part("takenAt") takenAt: RequestBody?, + @Part("latitude") latitude: RequestBody?, + @Part("longitude") longitude: RequestBody?, + @Part("locationAccuracyMeters") locationAccuracyMeters: RequestBody?, ): PhotoUploadResponse } diff --git a/app/src/main/java/pl/firmatpp/kierowca/data/model/DriverModels.kt b/app/src/main/java/pl/firmatpp/kierowca/data/model/DriverModels.kt index afced88..5114807 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/data/model/DriverModels.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/data/model/DriverModels.kt @@ -85,6 +85,10 @@ data class RoutePhotoDto( val mimeType: String?, val size: Long, val url: String, + val takenAt: String?, + val latitude: Double?, + val longitude: Double?, + val locationAccuracyMeters: Double?, val createdAt: String?, ) diff --git a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverApp.kt b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverApp.kt index 5da28fe..3dcabfc 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverApp.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverApp.kt @@ -7,6 +7,8 @@ import android.content.Context import android.content.Intent import android.content.IntentFilter import android.content.pm.PackageManager +import android.location.Location +import android.location.LocationManager import android.net.Uri import android.os.Build import androidx.activity.compose.BackHandler @@ -103,12 +105,14 @@ import com.google.android.gms.auth.api.phone.SmsRetriever import com.google.android.gms.common.api.CommonStatusCodes import com.google.android.gms.common.api.Status import java.io.File +import java.time.Instant import java.time.LocalDate import java.time.format.DateTimeFormatter import kotlin.math.ceil import kotlinx.coroutines.delay import java.util.Locale import pl.firmatpp.kierowca.R +import pl.firmatpp.kierowca.data.PhotoUploadMetadata import pl.firmatpp.kierowca.data.model.DriverRouteDto import pl.firmatpp.kierowca.data.model.RoutePhotoDto import pl.firmatpp.kierowca.domain.OtpCodeExtractor @@ -567,21 +571,23 @@ private fun routeStatusColor(status: String): Color { private fun DetailScreen( state: DriverUiState, onBack: () -> Unit, - onUpload: (Uri, String) -> Unit, + onUpload: (Uri, String, PhotoUploadMetadata) -> Unit, onPhoto: (RoutePhotoDto) -> Unit, ) { val context = LocalContext.current var cameraUri by remember { mutableStateOf(null) } val cameraLauncher = rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { ok -> val capturedUri = cameraUri - if (ok && capturedUri != null) onUpload(capturedUri, "camera") + if (ok && capturedUri != null) onUpload(capturedUri, "camera", cameraPhotoMetadata(context)) + cameraUri = null } - val cameraPermissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> + val cameraPermissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { grants -> val pendingUri = cameraUri - if (granted && pendingUri != null) cameraLauncher.launch(pendingUri) + val cameraGranted = grants[Manifest.permission.CAMERA] == true || hasPermission(context, Manifest.permission.CAMERA) + if (cameraGranted && pendingUri != null) cameraLauncher.launch(pendingUri) } val pickerLauncher = rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri -> - if (uri != null) onUpload(uri, "gallery") + if (uri != null) onUpload(uri, "gallery", PhotoUploadMetadata()) } val route = state.selectedRoute @@ -607,10 +613,11 @@ private fun DetailScreen( onCamera = { val newUri = createCameraUri(context) cameraUri = newUri - if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) { + val missingPermissions = cameraCapturePermissions(context) + if (missingPermissions.isEmpty()) { cameraLauncher.launch(newUri) } else { - cameraPermissionLauncher.launch(Manifest.permission.CAMERA) + cameraPermissionLauncher.launch(missingPermissions) } }, onGallery = { @@ -938,6 +945,55 @@ private fun createCameraUri(context: Context): Uri { return FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file) } +private fun cameraCapturePermissions(context: Context): Array = buildList { + if (!hasPermission(context, Manifest.permission.CAMERA)) { + add(Manifest.permission.CAMERA) + } + if (!hasLocationPermission(context)) { + add(Manifest.permission.ACCESS_FINE_LOCATION) + add(Manifest.permission.ACCESS_COARSE_LOCATION) + } +}.toTypedArray() + +private fun cameraPhotoMetadata(context: Context): PhotoUploadMetadata { + val location = bestLastKnownLocation(context) + + return PhotoUploadMetadata( + takenAt = Instant.now().toString(), + latitude = location?.latitude, + longitude = location?.longitude, + locationAccuracyMeters = location?.accuracy?.toDouble(), + ) +} + +private fun hasPermission(context: Context, permission: String): Boolean = + ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED + +private fun hasLocationPermission(context: Context): Boolean = + hasPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) || + hasPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) + +private fun bestLastKnownLocation(context: Context): Location? { + if (!hasLocationPermission(context)) return null + + val manager = context.getSystemService(Context.LOCATION_SERVICE) as? LocationManager ?: return null + val providers = listOf( + LocationManager.GPS_PROVIDER, + LocationManager.NETWORK_PROVIDER, + LocationManager.PASSIVE_PROVIDER, + ) + + return providers.mapNotNull { provider -> + runCatching { + if (manager.isProviderEnabled(provider) || provider == LocationManager.PASSIVE_PROVIDER) { + manager.getLastKnownLocation(provider) + } else { + null + } + }.getOrNull() + }.maxByOrNull { it.time } +} + @Composable private fun SmsUserConsentEffect(enabled: Boolean, onCode: (String) -> Unit) { val context = LocalContext.current diff --git a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverViewModel.kt b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverViewModel.kt index 544e672..135ce5b 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverViewModel.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverViewModel.kt @@ -10,6 +10,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import java.time.LocalDate import pl.firmatpp.kierowca.data.DriverRepository +import pl.firmatpp.kierowca.data.PhotoUploadMetadata import pl.firmatpp.kierowca.data.model.DriverDto import pl.firmatpp.kierowca.data.model.DriverRouteDto import pl.firmatpp.kierowca.data.model.RoutePhotoDto @@ -116,9 +117,9 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) _state.update { it.copy(screen = DriverScreen.Profile, error = null) } } - fun uploadPhoto(uri: Uri, source: String) = runLoading { + fun uploadPhoto(uri: Uri, source: String, metadata: PhotoUploadMetadata = PhotoUploadMetadata()) = runLoading { val route = _state.value.selectedRoute ?: return@runLoading - repository.uploadPhoto(route.id, uri, source) + repository.uploadPhoto(route.id, uri, source, metadata) openRoute(route.id) } diff --git a/app/src/test/java/pl/firmatpp/kierowca/data/PhotoUploadMetadataTest.kt b/app/src/test/java/pl/firmatpp/kierowca/data/PhotoUploadMetadataTest.kt new file mode 100644 index 0000000..b58c4f0 --- /dev/null +++ b/app/src/test/java/pl/firmatpp/kierowca/data/PhotoUploadMetadataTest.kt @@ -0,0 +1,38 @@ +package pl.firmatpp.kierowca.data + +import okio.Buffer +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Test + +class PhotoUploadMetadataTest { + @Test + fun convertsPresentCameraMetadataToMultipartTextParts() { + val parts = PhotoUploadMetadata( + takenAt = "2026-06-30T07:38:12Z", + latitude = 50.0619474, + longitude = 19.9368564, + locationAccuracyMeters = 8.5, + ).toMultipartTextParts() + + assertEquals("2026-06-30T07:38:12Z", parts.getValue("takenAt").readUtf8()) + assertEquals("50.0619474", parts.getValue("latitude").readUtf8()) + assertEquals("19.9368564", parts.getValue("longitude").readUtf8()) + assertEquals("8.5", parts.getValue("locationAccuracyMeters").readUtf8()) + } + + @Test + fun skipsLocationPartsWhenMetadataIsEmpty() { + val parts = PhotoUploadMetadata().toMultipartTextParts() + + assertFalse(parts.containsKey("latitude")) + assertFalse(parts.containsKey("longitude")) + assertFalse(parts.containsKey("locationAccuracyMeters")) + } + + private fun okhttp3.RequestBody.readUtf8(): String { + val buffer = Buffer() + writeTo(buffer) + return buffer.readUtf8() + } +}