diff --git a/.gitignore b/.gitignore index aa724b7..e4ac3d6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ *.iml +*.log .gradle /local.properties /.idea/caches diff --git a/app/build.gradle b/app/build.gradle index 3e76c3d..2b4da45 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -33,7 +33,25 @@ android { } buildFeatures { viewBinding true + buildConfig true } + buildscript { + dependencies { + classpath "com.google.android.libraries.mapsplatform.secrets-gradle-plugin:secrets-gradle-plugin:2.0.1" + } + } +} + +secrets { + // To add your Maps API key to this project: + // 1. If the secrets.properties file does not exist, create it in the same folder as the local.properties file. + // 2. Add this line, where YOUR_API_KEY is your API key: + // MAPS_API_KEY=YOUR_API_KEY + propertiesFileName = "secrets.properties" + + // A properties file containing default secret values. This file can be + // checked in version control. + defaultPropertiesFileName = "local.properties" } dependencies { @@ -41,14 +59,16 @@ dependencies { implementation 'androidx.core:core-ktx:1.15.0' implementation 'androidx.appcompat:appcompat:1.7.0' implementation 'com.google.android.material:material:1.12.0' - implementation 'com.google.android.gms:play-services-maps:19.0.0' - implementation 'androidx.constraintlayout:constraintlayout:2.2.0' + implementation 'com.google.android.gms:play-services-maps:19.1.0' + implementation 'androidx.constraintlayout:constraintlayout:2.2.1' implementation 'androidx.camera:camera-core:1.4.1' implementation 'androidx.camera:camera-lifecycle:1.4.1' implementation 'androidx.camera:camera-camera2:1.4.1' implementation 'androidx.camera:camera-view:1.4.1' implementation 'com.google.android.gms:play-services-location:21.3.0' - implementation 'androidx.exifinterface:exifinterface:1.3.7' + implementation 'androidx.exifinterface:exifinterface:1.4.0' + implementation 'com.github.bumptech.glide:glide:4.15.1' // или актуальная версия + annotationProcessor 'com.github.bumptech.glide:compiler:4.12.0' testImplementation 'junit:junit:4.13.2' androidTestImplementation 'androidx.test.ext:junit:1.2.1' androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1' diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 7a1883f..0062b03 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,6 +2,25 @@ + + + + + + + + + + + + + + + tools:targetApi="31" + > + android:value="${MAPS_API_KEY}" /> () + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { // Android 14+ + permissions.add(android.Manifest.permission.READ_MEDIA_IMAGES) + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { // Android 13 + permissions.add(android.Manifest.permission.READ_MEDIA_IMAGES) + } else { + permissions.add(android.Manifest.permission.READ_EXTERNAL_STORAGE) + } + + if (ContextCompat.checkSelfPermission(this, android.Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED) { + permissions.add(android.Manifest.permission.ACCESS_FINE_LOCATION) + } + + if (permissions.isNotEmpty()) { + ActivityCompat.requestPermissions(this, permissions.toTypedArray(), REQUEST_CODE_PERMISSIONS) + } + } + + // Обрабатываем результат запроса + override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + if (requestCode == REQUEST_CODE_PERMISSIONS) { + val deniedPermissions = permissions.zip(grantResults.toList()).filter { it.second != PackageManager.PERMISSION_GRANTED } + + if (deniedPermissions.isNotEmpty()) { + Toast.makeText(this, + getString(R.string.permissions_must_be_provided), Toast.LENGTH_LONG).show() + } else { + Toast.makeText(this, + getString(R.string.permissions_have_been_received), Toast.LENGTH_SHORT).show() + showPreviewsOnMap() + } + } } override fun onCreateOptionsMenu(menu: Menu?): Boolean { @@ -66,29 +120,113 @@ class MapsActivity : AppCompatActivity(), OnMapReadyCallback { override fun onMapReady(googleMap: GoogleMap) { map = googleMap + // Включаем кнопки масштабирования + map.uiSettings.isZoomControlsEnabled = true + + // Включаем жесты масштабирования (pinch-to-zoom) + map.uiSettings.isZoomGesturesEnabled = true + + // Включаем кнопки компаса и вращение карты + map.uiSettings.isCompassEnabled = true + map.uiSettings.isRotateGesturesEnabled = true + map.uiSettings.isMapToolbarEnabled = true + + if (ContextCompat.checkSelfPermission(this, android.Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED) { + map.isMyLocationEnabled = true // Включаем кнопку "Моё местоположение" + } + showPreviewsOnMap() } private fun showPreviewsOnMap() { map.clear() + + if (ContextCompat.checkSelfPermission(this, android.Manifest.permission.READ_MEDIA_IMAGES) != PackageManager.PERMISSION_GRANTED) { + Toast.makeText(this, + getString(R.string.there_is_no_permission_to_read_images), Toast.LENGTH_SHORT).show() + return + } + val folder = File("${filesDir.absolutePath}/photos/") - folder.listFiles()?.forEach { - val exifInterface = ExifInterface(it) + var lastLocation: LatLng? = null // Для хранения местоположения первого фото + + // Получаем список файлов и сортируем их по дате изменения (новейшие в конце) + val files = folder.listFiles() + ?.sortedBy { it.lastModified() } // Сортируем по последней дате изменения + + + files?.forEach { file -> + val exifInterface = ExifInterface(file) val location = locationDataUtils.getLocationFromExif(exifInterface) val point = LatLng(location.latitude, location.longitude) + + val density = resources.displayMetrics.density + val markerSize = (64 * density).toInt() // 64 dp в пикселях + + // Создаём Bitmap для маркера val pinBitmap = Bitmap.createScaledBitmap( BitmapFactory.decodeFile( - it.path, + file.path, BitmapFactory.Options().apply { inPreferredConfig = Bitmap.Config.ARGB_8888 - }), 64, 64, false + }), markerSize, markerSize, false ) - // TODO("Указать pinBitmap как иконку для маркера") - map.addMarker( + + // Создаём BitmapDescriptor + val bitmapDescriptor = BitmapDescriptorFactory.fromBitmap(pinBitmap) + + // Добавляем маркер с иконкой + val marker = map.addMarker( MarkerOptions() .position(point) + .icon(bitmapDescriptor) // Используем превью фотографии как иконку ) - // TODO("Передвинуть карту к местоположению последнего фото") + + marker?.tag = file.absolutePath + + // Запоминаем местоположение первого файла + lastLocation = point + } + + // Устанавливаем обработчик кликов на маркер + map.setOnMarkerClickListener { marker -> + val photoPath = marker.tag as? String + photoPath?.let { showPhotoDialog(it) } + true + } + + // Если есть хотя бы одно фото, перемещаем камеру к первому фото + lastLocation?.let { location -> + // Применяем moveCamera для перемещения камеры + val cameraUpdate = CameraUpdateFactory.newLatLngZoom(location, 15f) + //map.moveCamera(cameraUpdate) + + // Или используйте animateCamera для анимации камеры + map.animateCamera(cameraUpdate) } } + + private fun showPhotoDialog(photoPath: String) { + + // Используем MaterialAlertDialogBuilder для создания диалога + val dialogView = layoutInflater.inflate(R.layout.dialog_photo, null) + val imageView = dialogView.findViewById(R.id.dialogImageView) + + val width = imageView.width* 2 + Glide.with(this) + .load(photoPath) + .override(width) // Устанавливаем ширину изображения, равную ширине контейнера + .fitCenter() // Масштабируем изображение по ширине контейнера + .into(imageView) + + // Создаём MDC диалог + val dialog = MaterialAlertDialogBuilder(this) + .setView(dialogView) // Устанавливаем наш кастомный layout + .setCancelable(true) // Позволяет закрыть диалог + .create() + + dialog.show() + } + + } \ No newline at end of file diff --git a/app/src/main/java/com/sample/otuslocationmapshw/camera/CameraActivity.kt b/app/src/main/java/com/sample/otuslocationmapshw/camera/CameraActivity.kt index 076ade5..eab1889 100644 --- a/app/src/main/java/com/sample/otuslocationmapshw/camera/CameraActivity.kt +++ b/app/src/main/java/com/sample/otuslocationmapshw/camera/CameraActivity.kt @@ -1,12 +1,15 @@ package com.sample.otuslocationmapshw.camera +import android.Manifest import android.annotation.SuppressLint +import android.content.Context import android.content.pm.PackageManager import android.hardware.Sensor import android.hardware.SensorEvent import android.hardware.SensorEventListener import android.hardware.SensorManager import android.location.Location +import android.net.Uri import android.os.Bundle import android.util.Log import android.view.View @@ -14,15 +17,20 @@ import android.widget.Toast import androidx.appcompat.app.AppCompatActivity import androidx.camera.core.CameraSelector import androidx.camera.core.ImageCapture +import androidx.camera.core.ImageCaptureException import androidx.camera.core.Preview import androidx.camera.lifecycle.ProcessCameraProvider import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat +import androidx.exifinterface.media.ExifInterface import com.google.android.gms.location.FusedLocationProviderClient import com.google.android.gms.location.LocationServices +import com.google.android.gms.location.Priority import com.google.common.util.concurrent.ListenableFuture +import com.sample.otuslocationmapshw.R import com.sample.otuslocationmapshw.databinding.ActivityCameraBinding import java.io.File +import java.io.IOException import java.text.SimpleDateFormat import java.util.Date import java.util.Locale @@ -47,6 +55,10 @@ class CameraActivity : AppCompatActivity() { binding = ActivityCameraBinding.inflate(layoutInflater) setContentView(binding.root) + // Получаем экземпляр SensorManager + sensorManager = getSystemService(Context.SENSOR_SERVICE) as SensorManager + + // Прочие действия, например, проверка разрешений и запуск камеры if (allPermissionsGranted()) { startCamera() } else { @@ -58,9 +70,9 @@ class CameraActivity : AppCompatActivity() { fusedLocationClient = LocationServices.getFusedLocationProviderClient(this) cameraProviderFuture = ProcessCameraProvider.getInstance(this) - // TODO("Получить экземпляр SensorManager") - // TODO("Добавить проверку на наличие датчика акселерометра и присвоить значение tiltSensor") - tiltSensor = TODO("Get tilt sensor") + // Получить экземпляр датчика акселерометра и присвоить значение tiltSensor + tiltSensor = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) + cameraProviderFuture.addListener({ cameraProvider = cameraProviderFuture.get() }, ContextCompat.getMainExecutor(this)) @@ -81,9 +93,22 @@ class CameraActivity : AppCompatActivity() { } } - // TODO("Подписаться на получение событий обновления датчика") - // TODO("Остановить получение событий от датчика") + // Подписаться на получение событий обновления датчика + override fun onResume() { + super.onResume() + // Подписываемся на получение данных от датчика акселерометра + tiltSensor?.let { + sensorManager.registerListener(sensorEventListener, it, SensorManager.SENSOR_DELAY_UI) + } + } + + // Остановить получение событий от датчика + override fun onPause() { + super.onPause() + // Отписываемся от получения данных от датчика + sensorManager.unregisterListener(sensorEventListener) + } override fun onRequestPermissionsResult( requestCode: Int, @@ -97,7 +122,7 @@ class CameraActivity : AppCompatActivity() { } else { Toast.makeText( this, - "Permissions not granted by the user.", + getString(R.string.permissions_not_granted_by_the_user), Toast.LENGTH_SHORT ).show() finish() @@ -115,21 +140,72 @@ class CameraActivity : AppCompatActivity() { if (!folder.exists()) { folder.mkdirs() } - val filePath = folderPath + SimpleDateFormat(FILENAME_FORMAT, Locale.getDefault()).format(Date()) + val filePath = folderPath + SimpleDateFormat(FILENAME_FORMAT, Locale.getDefault()).format(Date()) + ".jpg" - // TODO("4. Добавить установку местоположения в метаданные фото") - val outputFileOptions = ImageCapture.OutputFileOptions.Builder(File(filePath)) - .build() + // Создаем объект OutputFileOptions для сохранения фотографии + val outputFileOptionsBuilder = ImageCapture.OutputFileOptions.Builder(File(filePath)) + + val outputFileOptions = outputFileOptionsBuilder.build() + + // Создаем callback для обработки результата сохранения изображения + val imageSavedCallback = object : ImageCapture.OnImageSavedCallback { + override fun onImageSaved(outputFileResults: ImageCapture.OutputFileResults) { + // Фотография успешно сохранена + val savedUri = Uri.fromFile(File(filePath)) + Log.d(TAG, getString(R.string.photo_saved, savedUri)) + + // Добавляем метаданные местоположения в EXIF + try { + val exif = ExifInterface(filePath) + if (location != null) { + exif.setLatLong(location.latitude, location.longitude) + exif.saveAttributes() + } + } catch (e: IOException) { + Log.e(TAG, getString(R.string.error_saving_exif_metadata), e) + } + + // Выводим Toast с информацией о том, что фото сохранено + Toast.makeText(applicationContext, + getString(R.string.photo_saved_to, savedUri), Toast.LENGTH_SHORT).show() - // TODO("Добавить вызов CameraX для фото") - // TODO("Вывести Toast о том, что фото успешно сохранено и закрыть текущее активити c указанием кода результата SUCCESS_RESULT_CODE") - // imageCapture... + // Устанавливаем результат работы активити с помощью setResult + setResult(SUCCESS_RESULT_CODE) + + // Завершаем активити + finish() + } + + override fun onError(exception: ImageCaptureException) { + // Логируем ошибку + Log.e(TAG, getString(R.string.error_saving_photo), exception) + + // Выводим Toast с сообщением об ошибке + Toast.makeText(applicationContext, + getString( + R.string.an_error_occurred_while_saving_the_photo, + exception.message + ), Toast.LENGTH_LONG).show() + } + } + + // Вызываем метод takePicture() для захвата фото + imageCapture.takePicture(outputFileOptions, ContextCompat.getMainExecutor(this), imageSavedCallback) } } + + @SuppressLint("MissingPermission") private fun getLastLocation(callback: (location: Location?) -> Unit) { - // TODO("Добавить получение местоположения от fusedLocationClient и передать результат в callback после получения") + fusedLocationClient.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, null) + .addOnSuccessListener { location -> + callback.invoke(location) // или просто callback(location) + } + .addOnFailureListener { exception -> + Log.e(TAG, getString(R.string.error_getting_location), exception) + callback.invoke(null) // передаем null, если не удалось получить местоположение + } } private fun startCamera() { @@ -153,7 +229,7 @@ class CameraActivity : AppCompatActivity() { this, cameraSelector, preview, imageCapture ) } catch (exc: Exception) { - Log.e(TAG, "Use case binding failed", exc) + Log.e(TAG, getString(R.string.use_case_binding_failed), exc) } }, ContextCompat.getMainExecutor(this)) @@ -168,10 +244,12 @@ class CameraActivity : AppCompatActivity() { private const val TAG = "CameraXApp" private const val FILENAME_FORMAT = "yyyy-MM-dd-HH-mm-ss-SSS" private const val REQUEST_CODE_PERMISSIONS = 10 - // TODO("Указать набор требуемых разрешений") - private val REQUIRED_PERMISSIONS: Array = mutableListOf( - // TODO("Добавить требуемые разрешения") - ).toTypedArray() + // Указать набор требуемых разрешений + private val REQUIRED_PERMISSIONS: Array = arrayOf( + Manifest.permission.CAMERA, + Manifest.permission.ACCESS_FINE_LOCATION, + Manifest.permission.ACCESS_COARSE_LOCATION + ) const val SUCCESS_RESULT_CODE = 15 } diff --git a/app/src/main/res/layout/dialog_photo.xml b/app/src/main/res/layout/dialog_photo.xml new file mode 100644 index 0000000..b3dee78 --- /dev/null +++ b/app/src/main/res/layout/dialog_photo.xml @@ -0,0 +1,33 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 73d8c42..fc24337 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -3,4 +3,16 @@ MapsActivity Добавить Рекомендуем разместить телефон прямо + dialog image view + Permissions not granted by the user. + Photo saved: %1$s + Error saving EXIF metadata + Photo saved to: %1$s + Error saving photo + An error occurred while saving the photo: %1$s + Error getting location + Use case binding failed + Permissions must be provided! + Permissions have been received! + There is no permission to read images! \ No newline at end of file