commit 726acd3e09276431fa94b45887f1aa4322069a97 Author: LJ5O <75009579+LJ5O@users.noreply.github.com> Date: Sun Jan 25 16:11:02 2026 +0100 Mobile TP8 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aa724b7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..26d3352 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/.idea/AndroidProjectSystem.xml b/.idea/AndroidProjectSystem.xml new file mode 100644 index 0000000..4a53bee --- /dev/null +++ b/.idea/AndroidProjectSystem.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/compiler.xml b/.idea/compiler.xml new file mode 100644 index 0000000..b86273d --- /dev/null +++ b/.idea/compiler.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml new file mode 100644 index 0000000..bb83a66 --- /dev/null +++ b/.idea/deploymentTargetSelector.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/deviceManager.xml b/.idea/deviceManager.xml new file mode 100644 index 0000000..91f9558 --- /dev/null +++ b/.idea/deviceManager.xml @@ -0,0 +1,13 @@ + + + + + + \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml new file mode 100644 index 0000000..639c779 --- /dev/null +++ b/.idea/gradle.xml @@ -0,0 +1,19 @@ + + + + + + + \ No newline at end of file diff --git a/.idea/markdown.xml b/.idea/markdown.xml new file mode 100644 index 0000000..c61ea33 --- /dev/null +++ b/.idea/markdown.xml @@ -0,0 +1,8 @@ + + + + + + \ No newline at end of file diff --git a/.idea/migrations.xml b/.idea/migrations.xml new file mode 100644 index 0000000..f8051a6 --- /dev/null +++ b/.idea/migrations.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..b2c751a --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,9 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/runConfigurations.xml b/.idea/runConfigurations.xml new file mode 100644 index 0000000..16660f1 --- /dev/null +++ b/.idea/runConfigurations.xml @@ -0,0 +1,17 @@ + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..517ed2f --- /dev/null +++ b/README.md @@ -0,0 +1,8 @@ +# Rendu TP8, Kévin Taccoen + +L'application permet d'ouvrir une image pour voir les coordonnées GPS, +de rechercher d'autres images prises à proximité, +ou de chercher des images prises autour de la position actuelle de l'utilisateur. + +![image](img.png) +![image](img_1.png) \ No newline at end of file diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..76cf112 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,48 @@ +plugins { + alias(libs.plugins.android.application) +} + +android { + namespace = "com.example.tp8" + compileSdk { + version = release(36) + } + + defaultConfig { + applicationId = "com.example.tp8" + minSdk = 24 + targetSdk = 36 + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } +} + +dependencies { + implementation(libs.appcompat) + implementation(libs.material) + implementation(libs.activity) + implementation(libs.constraintlayout) + implementation("androidx.exifinterface:exifinterface:1.3.7") + implementation("com.google.android.gms:play-services-location:21.2.0") + implementation(libs.exifinterface) + implementation(libs.play.services.location) + testImplementation(libs.junit) + androidTestImplementation(libs.ext.junit) + androidTestImplementation(libs.espresso.core) +} \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/app/src/androidTest/java/com/example/tp8/ExampleInstrumentedTest.java b/app/src/androidTest/java/com/example/tp8/ExampleInstrumentedTest.java new file mode 100644 index 0000000..4b6ec8d --- /dev/null +++ b/app/src/androidTest/java/com/example/tp8/ExampleInstrumentedTest.java @@ -0,0 +1,26 @@ +package com.example.tp8; + +import android.content.Context; + +import androidx.test.platform.app.InstrumentationRegistry; +import androidx.test.ext.junit.runners.AndroidJUnit4; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import static org.junit.Assert.*; + +/** + * Instrumented test, which will execute on an Android device. + * + * @see Testing documentation + */ +@RunWith(AndroidJUnit4.class) +public class ExampleInstrumentedTest { + @Test + public void useAppContext() { + // Context of the app under test. + Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext(); + assertEquals("com.example.tp8", appContext.getPackageName()); + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..e684c3b --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/com/example/tp8/MainActivity.java b/app/src/main/java/com/example/tp8/MainActivity.java new file mode 100644 index 0000000..7d29bb8 --- /dev/null +++ b/app/src/main/java/com/example/tp8/MainActivity.java @@ -0,0 +1,148 @@ +package com.example.tp8; + +import android.content.Intent; +import android.location.Address; +import android.location.Geocoder; +import android.net.Uri; +import android.os.Bundle; +import android.view.View; +import android.widget.Button; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.activity.EdgeToEdge; +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts; +import androidx.appcompat.app.AppCompatActivity; +import androidx.core.graphics.Insets; +import androidx.core.view.ViewCompat; +import androidx.core.view.WindowInsetsCompat; +import androidx.exifinterface.media.ExifInterface; + +import java.io.IOException; +import java.io.InputStream; +import java.util.List; +import java.util.Locale; + +public class MainActivity extends AppCompatActivity { + + private ImageView imageView; + private TextView latiTextView; + private TextView longiTextView; + private TextView locationTextView; + private Button loadImageButton; + private Button showLocationButton; + private Button findNearbyPhotosButton; // Exo3 + private double[] currentLatLong; + + // Result launcher pour gérer la récupération et l'affichage de l'image choisie + private final ActivityResultLauncher getImageLauncher = registerForActivityResult( + new ActivityResultContracts.GetContent(), // Récupérer le chemin de l'image + uri -> { + if (uri != null) { // S'il y a bien une image + imageView.setImageURI(uri); // L'afficher sur son emplacement + showExifData(uri); // Puis récupérer les coords GPS si possible + } + } + ); + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + EdgeToEdge.enable(this); + setContentView(R.layout.activity_main); + ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main), (v, insets) -> { + Insets systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()); + v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom); + return insets; + }); + + imageView = findViewById(R.id.imageView); // Récupération des éléments de l'interface + latiTextView = findViewById(R.id.lati); + longiTextView = findViewById(R.id.longi); + locationTextView = findViewById(R.id.location); + loadImageButton = findViewById(R.id.button_load_image); + showLocationButton = findViewById(R.id.button_show_location); // Bouton caché au lancement, affiché si des coords GPS sont trouvées + findNearbyPhotosButton = findViewById(R.id.button_find_nearby_photos); // Bouton exo3 + Button findPhotosByUserLocationButton = findViewById(R.id.button_find_photos_by_user_location); // Bouton exo4 + + + loadImageButton.setOnClickListener(event -> { // Quand le bouton de chargement est utilisé + getImageLauncher.launch("image/*"); // On lance la demande d'image + }); + + showLocationButton.setOnClickListener(event -> { + if (currentLatLong != null) { + reverseGeocode(currentLatLong[0], currentLatLong[1]); // GPS -> Lieu + } + }); + + findNearbyPhotosButton.setOnClickListener(event -> { + if (currentLatLong != null) { + Intent intent = new Intent(MainActivity.this, PhotoListActivity.class); + intent.putExtra("referenceLatLong", currentLatLong); // Passer les coordonnées de la photo actuelle + startActivity(intent); + } + }); + + findPhotosByUserLocationButton.setOnClickListener(event -> { + Intent intent = new Intent(MainActivity.this, NearbyPhotosActivity.class); + startActivity(intent); + }); + } + + private void showExifData(Uri imageUri) { // Fonction qui extrait les coords GPS + try (InputStream inputStream = getContentResolver().openInputStream(imageUri)) { + // J'essaie de récupérer les données de l'image + if (inputStream != null) { + ExifInterface exifInterface = new ExifInterface(inputStream); // Permet d'intéragir avec l'image + currentLatLong = exifInterface.getLatLong(); // Obtenir les coordonnées + if (currentLatLong != null) { // Si les coords sont bien définies https://developer.android.com/reference/android/media/ExifInterface#getLatLong(float[]) + latiTextView.setText(getString(R.string.lati) + " " + currentLatLong[0]); // Je peux les écrire + longiTextView.setText(getString(R.string.longi) + " " + currentLatLong[1]); + showLocationButton.setVisibility(View.VISIBLE); // Afficher le bouton pour trouver le lieu + findNearbyPhotosButton.setVisibility(View.VISIBLE); // Afficher le bouton pour trouver les photos proches + } else { + latiTextView.setText(getString(R.string.lati) + " Inconnue"); // Sinon, inconnu + longiTextView.setText(getString(R.string.longi) + " Inconnue"); + showLocationButton.setVisibility(View.GONE); // Cacher le bouton pour trouver le lieu + findNearbyPhotosButton.setVisibility(View.GONE); // Cacher le bouton pour trouver les photos proches + } + locationTextView.setText(""); // Retirer le lieu affiché + } + } catch (IOException e) { // En cas de problème avec la récupération de l'image ou ses données + e.printStackTrace(); // J'affiche l'erreur + latiTextView.setText(getString(R.string.lati) + " Erreur"); + longiTextView.setText(getString(R.string.longi) + " Erreur"); + showLocationButton.setVisibility(View.GONE); + findNearbyPhotosButton.setVisibility(View.GONE); + } + } + + private void reverseGeocode(double latitude, double longitude) { + // DOC: https://developer.android.com/reference/android/location/Geocoder + new Thread(() -> { // Pour ne pas bloquer l'application, je lance la récupération dans un sous-processus + Geocoder geocoder = new Geocoder(this, Locale.getDefault()); + try { + List
addresses = geocoder.getFromLocation(latitude, longitude, 1); // J'essaie de récupérer 1 adresse qui correspond + if (addresses != null && !addresses.isEmpty()) { // Si l'on a trouvé quelque chose + Address address = addresses.get(0); + StringBuilder addressText = new StringBuilder(); // Je prépare une String + if (address.getLocality() != null) { // Si j'ai la bille + addressText.append(address.getLocality()).append(", "); // J'ajoute à la String + } + if (address.getCountryName() != null) { + addressText.append(address.getCountryName()); // Idem pour le pays + } + // https://stackoverflow.com/a/11140342 pour retourner les données vers le thread principal + runOnUiThread(() -> locationTextView.setText(addressText.toString())); // Et je retourne ça au thread principal + } else { + runOnUiThread(() -> locationTextView.setText(getString(R.string.unknown_place))); // Pas d'adresse trouvée + } + } catch (IOException e) { + e.printStackTrace(); // Crash + runOnUiThread(() -> locationTextView.setText(getString(R.string.error_place))); + } + }).start(); + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/tp8/NearbyPhotosActivity.java b/app/src/main/java/com/example/tp8/NearbyPhotosActivity.java new file mode 100644 index 0000000..efc46f8 --- /dev/null +++ b/app/src/main/java/com/example/tp8/NearbyPhotosActivity.java @@ -0,0 +1,256 @@ +package com.example.tp8; + +import android.Manifest; +import android.content.ContentResolver; +import android.content.pm.PackageManager; +import android.database.Cursor; +import android.location.Location; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.provider.MediaStore; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.EditText; +import android.widget.ImageView; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AppCompatActivity; +import androidx.core.app.ActivityCompat; +import androidx.core.content.ContextCompat; +import androidx.core.graphics.Insets; +import androidx.core.view.ViewCompat; +import androidx.core.view.WindowInsetsCompat; +import androidx.exifinterface.media.ExifInterface; +import androidx.recyclerview.widget.GridLayoutManager; +import androidx.recyclerview.widget.ListAdapter; +import androidx.recyclerview.widget.RecyclerView; +import androidx.recyclerview.widget.DiffUtil; + + +import com.google.android.gms.location.FusedLocationProviderClient; +import com.google.android.gms.location.LocationServices; + +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; + +public class NearbyPhotosActivity extends AppCompatActivity { + + private static final int PERMISSIONS_REQUEST_LOCATION_AND_MEDIA = 101; // ID de la requête de permission + + private FusedLocationProviderClient fusedLocationClient; //https://developers.google.com/android/reference/com/google/android/gms/location/FusedLocationProviderClient + private Location currentUserLocation; + private EditText distanceInput; + private Button searchButton; + private RecyclerView recyclerView; + private NearbyPhotoAdapter adapter; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_nearby_photos); + ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.photosAroundBackground), (v, insets) -> { + Insets systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()); + v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom); + return insets; + }); + + distanceInput = findViewById(R.id.distance_input); // éléménts de l'interface + searchButton = findViewById(R.id.search_button); + recyclerView = findViewById(R.id.nearby_photos_recycler_view); + + // Fournisseur de localisation + fusedLocationClient = LocationServices.getFusedLocationProviderClient(this); // Permet de gérer les deux types de position sans devoir y prêter attention + + // RecyclerView qui gère l'affichage des éléments, mits en page selon GridLayoutManager + recyclerView.setLayoutManager(new GridLayoutManager(this, 3)); // 3 images par ligne dans la grille + adapter = new NearbyPhotoAdapter(); // Adapteur local pour les photos + recyclerView.setAdapter(adapter); + + searchButton.setOnClickListener(v -> { // Click bouton recherche + String distanceStr = distanceInput.getText().toString(); // Je récupère la distance de recherche + if (currentUserLocation == null) { + Toast.makeText(this, getText(R.string.no_position_available), Toast.LENGTH_SHORT).show(); // Impossible d'avoir la position de l'utilisateur + return; + } + if (distanceStr.isEmpty()) { // Pas de distance entrée pour le moment + Toast.makeText(this, getText(R.string.input_distance), Toast.LENGTH_SHORT).show(); + return; + } + try { // Et j'essaie de convertir la distance en nombre0 + double maxDistance = Double.parseDouble(distanceStr); // Distance maximale de recherche + loadNearbyPhotos(maxDistance); // Et je recherche les images dans ce rayon + } catch (NumberFormatException e) { + Toast.makeText(this, getText(R.string.invalid_distance), Toast.LENGTH_SHORT).show(); // Nombre invalide + } + }); // Fin listener bouton + + checkPermissionsAndGetLocation(); // Au lancement, je vérifie les permissions et obtiens la position + } + + // Permission et position + private void checkPermissionsAndGetLocation() { + List permissionsToRequest = new ArrayList<>(); // Liste des permissions qui seront demandées + if (ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED) { + permissionsToRequest.add(Manifest.permission.ACCESS_FINE_LOCATION); // Si je n'ai pas l'accès à la position précise, je demande + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { // Suggéré par l'IDE pour gérer tous les cas de versions Android + if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_MEDIA_IMAGES) != PackageManager.PERMISSION_GRANTED) { + permissionsToRequest.add(Manifest.permission.READ_MEDIA_IMAGES); + } + } else { + if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) { + permissionsToRequest.add(Manifest.permission.READ_EXTERNAL_STORAGE); + } + } + if (ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_MEDIA_LOCATION) != PackageManager.PERMISSION_GRANTED) { + // Voir la position des images dans les métadonnées + permissionsToRequest.add(Manifest.permission.ACCESS_MEDIA_LOCATION); + } + + if (!permissionsToRequest.isEmpty()) { + // S'il y a des permissions à demander, j'envois la demande + ActivityCompat.requestPermissions(this, permissionsToRequest.toArray(new String[0]), PERMISSIONS_REQUEST_LOCATION_AND_MEDIA); + } else { + fetchUserLocation(); // J'ai tout, je récupère la position de l'utilisateur + } + } + + @Override // Résultat de la demande de permission + public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults); + if (requestCode == PERMISSIONS_REQUEST_LOCATION_AND_MEDIA) { + boolean allGranted = true; + for (int grantResult : grantResults) { // Vérifier que tous les accès ont été donnés + if (grantResult != PackageManager.PERMISSION_GRANTED) { + allGranted = false; + break; + } + } + if (allGranted) { + fetchUserLocation(); // Tout y est, je récupère la position + } else { + Toast.makeText(this, getString(R.string.missing_permission), Toast.LENGTH_LONG).show(); // Il manque des accès + finish(); + } + } + } + + private void fetchUserLocation() { // Récupération de la positon de l'utilisateur + if (ActivityCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED && ActivityCompat.checkSelfPermission(this, Manifest.permission.ACCESS_COARSE_LOCATION) != PackageManager.PERMISSION_GRANTED) { + return; // Echec si je n'ai aucun accès à la position + } + fusedLocationClient.getLastLocation() // Récupérer la position + .addOnSuccessListener(this, location -> { + if (location != null) { + currentUserLocation = location; // Position récupérée + Toast.makeText(this, getString(R.string.position_got), Toast.LENGTH_SHORT).show(); + } else { + Toast.makeText(this, getString(R.string.position_error), Toast.LENGTH_LONG).show(); // Erreur + } + }); + } + + private void loadNearbyPhotos(double maxDistance) { // Chargement des photos proches du lieu + new Thread(() -> { // Dans un nouveau thread + List nearbyPhotos = new ArrayList<>(); + ContentResolver contentResolver = getContentResolver(); // Je prépare de quoi chercher dans le stockage + Uri queryUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI; + String[] projection = {MediaStore.Images.Media._ID}; + + try (Cursor cursor = contentResolver.query(queryUri, projection, null, null, null)) { + if (cursor != null) { + int idColumn = cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID); // Récupérer l'ID de la colone qui gère les images + while (cursor.moveToNext()) { // Tant qu'il reste des images à récupérer + long id = cursor.getLong(idColumn); + Uri photoUri = Uri.withAppendedPath(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, Long.toString(id)); // Puis je récupère l'URI de l'image + + try (InputStream inputStream = contentResolver.openInputStream(photoUri)) { // Je tente de récupérer l'ExifInterfacve + if (inputStream != null) { + ExifInterface exifInterface = new ExifInterface(inputStream); + double[] latLong = exifInterface.getLatLong(); // Récupérer les coords + if (latLong != null) { // Si elles existent + double distance = distance(currentUserLocation.getLatitude(), currentUserLocation.getLongitude(), latLong[0], latLong[1]);// Calcul distance km + //System.out.println(currentUserLocation.getLatitude()+"/"+currentUserLocation.getLongitude()); + //System.out.println(maxDistance); + if (distance <= maxDistance) { + // OK, dans le rayon de recherche + nearbyPhotos.add(photoUri); + } + } + } + } catch (Exception e) { + // Impossible de récupérer ExifInterface, je passe cette image + } + } + } + } + runOnUiThread(() -> { // Une fois que j'ai finit de récupérer les images + // Dans le thread principal + adapter.submitList(nearbyPhotos); // J'envois la liste à l'adapter + if(nearbyPhotos.isEmpty()){ + Toast.makeText(this, getString(R.string.no_pictures), Toast.LENGTH_SHORT).show(); + } + }); + }).start(); + } + + // J'aurai aussi pu passer par https://developer.android.com/reference/android/location/Location#distanceBetween(double,%20double,%20double,%20double,%20float[]) + private static double distance(double lat1, double lon1, double lat2, double lon2) { + double R = 6371; // Rayon de la Terre, utilisé pour https://fr.wikipedia.org/wiki/Formule_de_haversine + double dLat = Math.toRadians(lat2 - lat1); + double dLon = Math.toRadians(lon2 - lon1); + double a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + + Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2)) * + Math.sin(dLon / 2) * Math.sin(dLon / 2); + double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + return R * c; + } + + private static class NearbyPhotoAdapter extends ListAdapter { + //Adapteur pour l'affichage en grille des images + protected NearbyPhotoAdapter() { + super(DIFF_CALLBACK); // Implémentation du diff callback de https://developer.android.com/reference/androidx/leanback/widget/DiffCallback + } + + @NonNull + @Override + public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + // Créer l'afifchage en utilisant le layour en grille + View view = LayoutInflater.from(parent.getContext()) + .inflate(R.layout.grid_item_photo, parent, false); + return new ViewHolder(view); + } + + @Override + public void onBindViewHolder(@NonNull ViewHolder holder, int position) { + holder.imageView.setImageURI(getItem(position)); // Ajouter une image à une position dans la grille + } + + public static class ViewHolder extends RecyclerView.ViewHolder { + public ImageView imageView; + public ViewHolder(View view) { + super(view); + imageView = view.findViewById(R.id.photo_grid_item); + } + } + } + + private static final DiffUtil.ItemCallback DIFF_CALLBACK = // Android se sert de cette partie pour comparer les éléments https://developer.android.com/reference/androidx/leanback/widget/DiffCallback + new DiffUtil.ItemCallback() { + @Override + public boolean areItemsTheSame(@NonNull Uri oldItem, @NonNull Uri newItem) { + return oldItem.equals(newItem); + } + + @Override + public boolean areContentsTheSame(@NonNull Uri oldItem, @NonNull Uri newItem) { + return oldItem.equals(newItem); + } + }; +} diff --git a/app/src/main/java/com/example/tp8/PhotoListActivity.java b/app/src/main/java/com/example/tp8/PhotoListActivity.java new file mode 100644 index 0000000..488eb06 --- /dev/null +++ b/app/src/main/java/com/example/tp8/PhotoListActivity.java @@ -0,0 +1,249 @@ +package com.example.tp8; + +import android.Manifest; +import android.content.ContentResolver; +import android.content.pm.PackageManager; +import android.database.Cursor; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.provider.MediaStore; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.activity.EdgeToEdge; +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts; +import androidx.annotation.NonNull; +import androidx.appcompat.app.AppCompatActivity; +import androidx.core.app.ActivityCompat; +import androidx.core.content.ContextCompat; +import androidx.core.graphics.Insets; +import androidx.core.view.ViewCompat; +import androidx.core.view.WindowInsetsCompat; +import androidx.exifinterface.media.ExifInterface; +import androidx.recyclerview.widget.DiffUtil; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.ListAdapter; +import androidx.recyclerview.widget.RecyclerView; + +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Locale; + +public class PhotoListActivity extends AppCompatActivity { + + private static final int PERMISSIONS_REQUEST_READ_IMAGES = 100; // ID de la requête de permission + + private PhotoAdapter adapter; + private double[] referenceLatLong; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + EdgeToEdge.enable(this); + setContentView(R.layout.activity_photo_list); + ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.photo_list_layout), (v, insets) -> { // Changed R.id.main to R.id.photo_list_layout + Insets systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()); + v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom); + return insets; + }); + + referenceLatLong = getIntent().getDoubleArrayExtra("referenceLatLong"); // Je récupère la valeur de référence pour la position GPS + if (referenceLatLong == null) { + finish(); // Ne devrait pas se produire normalement + return; + } + + RecyclerView recyclerView = findViewById(R.id.photo_recycler_view); // https://developer.android.com/develop/ui/views/layout/recyclerview?hl=fr + recyclerView.setLayoutManager(new LinearLayoutManager(this)); + adapter = new PhotoAdapter(); // Affichage d'une liste dynamique de photos + recyclerView.setAdapter(adapter); + + checkPermissionAndLoadPhotos(); // Vérification des permissions, puis chargement des photos + } + + private void checkPermissionAndLoadPhotos() { + List permissionsToRequest = new ArrayList<>(); + + // Choisir la permission de stockage appropriée + String storagePermission; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { // If-else recommandé par Android Studio pour gérer toutes les versions d'Android + storagePermission = Manifest.permission.READ_MEDIA_IMAGES; // https://developer.android.com/about/versions/14/changes/partial-photo-video-access?hl=fr + } else { + storagePermission = Manifest.permission.READ_EXTERNAL_STORAGE; + } + + if (ContextCompat.checkSelfPermission(this, storagePermission) != PackageManager.PERMISSION_GRANTED) { + permissionsToRequest.add(storagePermission); // Je n'ai pas la permission, j'ajoute à la liste de perms à demander + } + + // Ajouter la permission pour la localisation des médias, pour lire les métadonnées des images + if (ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_MEDIA_LOCATION) != PackageManager.PERMISSION_GRANTED) { + permissionsToRequest.add(Manifest.permission.ACCESS_MEDIA_LOCATION); // J'ajoute aussi si je ne l'ai pas + } + + if (!permissionsToRequest.isEmpty()) { + // Demander les permissions nécessaires + ActivityCompat.requestPermissions(this, permissionsToRequest.toArray(new String[0]), PERMISSIONS_REQUEST_READ_IMAGES); + } else { + // OK, toutes les permissions sont déjà accordées + loadPhotos(); + } + } + + @Override // Fonction appelée quand le système a reçu une réponse de la demande de permission + public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults); + if (requestCode == PERMISSIONS_REQUEST_READ_IMAGES) { + // Vérifier si toutes les permissions demandées ont été accordées + boolean allGranted = true; + for (int grantResult : grantResults) { + if (grantResult != PackageManager.PERMISSION_GRANTED) { + allGranted = false; + break; + } + } + + if (allGranted) { + // Permissions OK + loadPhotos(); + } else { + // Au moins une permission a été refusée + Toast.makeText(this, getText(R.string.missing_permission), Toast.LENGTH_LONG).show(); + finish(); // Terminer ici + } + } + } + + // Fonction de chargement des photos + private void loadPhotos() { + new Thread(() -> { // Dans un thread, comme vu dans la doc' Android + List photoItems = new ArrayList<>(); // Liste principale des images + ContentResolver contentResolver = getContentResolver(); + Uri queryUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI; // Champ recherché + String[] projection = {MediaStore.Images.Media._ID}; + + try (Cursor cursor = contentResolver.query(queryUri, projection, null, null, MediaStore.Images.Media.DATE_ADDED + " DESC")) { // Je tente de prioriser les images récentes + if (cursor != null) { + int idColumn = cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID); // ID colone rechercheé + while (cursor.moveToNext()) { // Tant qu'il y a des images + long id = cursor.getLong(idColumn); + Uri photoUri = Uri.withAppendedPath(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, Long.toString(id)); // Récupérer l'URI + + try (InputStream inputStream = contentResolver.openInputStream(photoUri)) { // Je tente d'avoir l'InputStream + if (inputStream != null) { + ExifInterface exifInterface = new ExifInterface(inputStream); // Que je convertis en ExifInterface + double[] latLong = exifInterface.getLatLong(); + if (latLong != null) { + double distance = distance(referenceLatLong[0], referenceLatLong[1], latLong[0], latLong[1]); // D'où je récupère la distance + photoItems.add(new PhotoItem(photoUri, distance)); // Et j'ajoute ça à la liste + } + } + } catch (IOException e) { + // J'ignore les images impossibles à lire, possiblement corrompues + } catch (Exception e) { + // Juste au cas où, je collecte ici tout le reste pour le débug + e.printStackTrace(); + } + } + } + } + + Collections.sort(photoItems); // Je lance un tri sur la liste de photos (méthode de tri sur la distance) + runOnUiThread(() -> { + if (photoItems.isEmpty()) { // Message s'il n'y a pas de photos + Toast.makeText(PhotoListActivity.this, getString(R.string.no_pictures_found), Toast.LENGTH_LONG).show(); + } + adapter.submitList(photoItems);// Puis envoi de la liste + }); + }).start(); + } + + // J'aurai aussi pu passer par https://developer.android.com/reference/android/location/Location#distanceBetween(double,%20double,%20double,%20double,%20float[]) + private static double distance(double lat1, double lon1, double lat2, double lon2) { + double R = 6371; // Rayon de la Terre, utilisé pour https://fr.wikipedia.org/wiki/Formule_de_haversine + double dLat = Math.toRadians(lat2 - lat1); + double dLon = Math.toRadians(lon2 - lon1); + double a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + + Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2)) * + Math.sin(dLon / 2) * Math.sin(dLon / 2); + double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + return R * c; + } + + private static class PhotoItem implements Comparable { + final Uri uri; + final double distance; + + PhotoItem(Uri uri, double distance) { + this.uri = uri; + this.distance = distance; + } + + @Override + public int compareTo(PhotoItem other) { + return Double.compare(this.distance, other.distance); + } + } + + + // Classe que je garde là pour gérer proprement les photos + private static class PhotoAdapter extends ListAdapter { + // https://developer.android.com/reference/androidx/recyclerview/widget/ListAdapter + protected PhotoAdapter() { + super(DIFF_CALLBACK); // utilisé par Android pour savoir s'il faut rebuild la liste + } + + @NonNull + @Override // Afficher les images à la création du widget + public PhotoAdapter.PhotoViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.list_item_photo, parent, false); + return new PhotoAdapter.PhotoViewHolder(view); + } + + @Override + public void onBindViewHolder(@NonNull PhotoAdapter.PhotoViewHolder holder, int position) { + PhotoItem item = getItem(position); + holder.bind(item); + } + + static class PhotoViewHolder extends RecyclerView.ViewHolder { + // La sous-classe qui gère quelques méthodes d'affichage des images + final ImageView thumbnail; + final TextView distance; + + PhotoViewHolder(@NonNull View itemView) { + super(itemView); + thumbnail = itemView.findViewById(R.id.photo_thumbnail); + distance = itemView.findViewById(R.id.photo_distance); + } + + void bind(PhotoItem item) { + thumbnail.setImageURI(item.uri); // Image affichée + distance.setText(String.format(Locale.getDefault(), "Distance: %.2f km", item.distance)); // Texte + } + } + + private static final DiffUtil.ItemCallback DIFF_CALLBACK = // Android se sert de cette partie pour comparer les éléments https://developer.android.com/reference/androidx/leanback/widget/DiffCallback + new DiffUtil.ItemCallback() { + @Override + public boolean areItemsTheSame(@NonNull PhotoItem oldItem, @NonNull PhotoItem newItem) { + return oldItem.uri.equals(newItem.uri); + } + + @Override + public boolean areContentsTheSame(@NonNull PhotoItem oldItem, @NonNull PhotoItem newItem) { + return oldItem.distance == newItem.distance; + } + }; + } + +} \ No newline at end of file diff --git a/app/src/main/res/drawable/folder.png b/app/src/main/res/drawable/folder.png new file mode 100644 index 0000000..027afaf Binary files /dev/null and b/app/src/main/res/drawable/folder.png differ diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..07d5da9 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..2b068d1 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/placeholder.png b/app/src/main/res/drawable/placeholder.png new file mode 100644 index 0000000..0451695 Binary files /dev/null and b/app/src/main/res/drawable/placeholder.png differ diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..1151a5c --- /dev/null +++ b/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,94 @@ + + + + + + + +