diff --git a/app/src/main/java/fr/free/nrw/commons/settings/Prefs.java b/app/src/main/java/fr/free/nrw/commons/settings/Prefs.java index d059c862e..9a40acc55 100644 --- a/app/src/main/java/fr/free/nrw/commons/settings/Prefs.java +++ b/app/src/main/java/fr/free/nrw/commons/settings/Prefs.java @@ -7,6 +7,7 @@ public class Prefs { public static final String DEFAULT_LICENSE = "defaultLicense"; public static final String UPLOADS_SHOWING = "uploadsshowing"; public static final String IS_CONTRIBUTION_COUNT_CHANGED = "ccontributionCountChanged"; + public static final String MANAGED_EXIF_TAGS = "managedExifTags"; public static class Licenses { public static final String CC_BY_SA_3 = "CC BY-SA 3.0"; diff --git a/app/src/main/java/fr/free/nrw/commons/settings/SettingsFragment.java b/app/src/main/java/fr/free/nrw/commons/settings/SettingsFragment.java index cf0e8aaee..6d527c91d 100644 --- a/app/src/main/java/fr/free/nrw/commons/settings/SettingsFragment.java +++ b/app/src/main/java/fr/free/nrw/commons/settings/SettingsFragment.java @@ -4,6 +4,7 @@ import android.Manifest; import android.net.Uri; import android.os.Bundle; import android.preference.EditTextPreference; +import android.preference.MultiSelectListPreference; import android.preference.Preference; import android.preference.PreferenceFragment; import android.preference.SwitchPreference; @@ -14,6 +15,11 @@ import com.karumi.dexter.Dexter; import com.karumi.dexter.listener.PermissionGrantedResponse; import com.karumi.dexter.listener.single.BasePermissionListener; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + import javax.inject.Inject; import javax.inject.Named; @@ -59,6 +65,14 @@ public class SettingsFragment extends PreferenceFragment { return true; }); + MultiSelectListPreference multiSelectListPref = (MultiSelectListPreference) findPreference("manageExifTags"); + if (multiSelectListPref != null) { + multiSelectListPref.setOnPreferenceChangeListener((preference, newValue) -> { + defaultKvStore.putJson(Prefs.MANAGED_EXIF_TAGS, newValue); + return true; + }); + } + final EditTextPreference uploadLimit = (EditTextPreference) findPreference("uploads"); int currentUploadLimit = defaultKvStore.getInt(Prefs.UPLOADS_SHOWING, 100); uploadLimit.setText(Integer.toString(currentUploadLimit)); diff --git a/app/src/main/java/fr/free/nrw/commons/ui/LongTitlePreferences/LongTitleMultiSelectListPreference.java b/app/src/main/java/fr/free/nrw/commons/ui/LongTitlePreferences/LongTitleMultiSelectListPreference.java new file mode 100644 index 000000000..9c5f327ac --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/ui/LongTitlePreferences/LongTitleMultiSelectListPreference.java @@ -0,0 +1,38 @@ +package fr.free.nrw.commons.ui.LongTitlePreferences; + +import android.content.Context; +import android.preference.MultiSelectListPreference; +import android.util.AttributeSet; +import android.view.View; +import android.widget.TextView; + +public class LongTitleMultiSelectListPreference extends MultiSelectListPreference { + /* + public LongTitleMultiSelectListPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + } + + public LongTitleMultiSelectListPreference(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + */ + public LongTitleMultiSelectListPreference(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public LongTitleMultiSelectListPreference(Context context) { + super(context); + } + + @Override + protected void onBindView(View view) + { + super.onBindView(view); + + TextView title= view.findViewById(android.R.id.title); + if (title != null) { + title.setSingleLine(false); + } + } + +} diff --git a/app/src/main/java/fr/free/nrw/commons/upload/FileMetadataUtils.java b/app/src/main/java/fr/free/nrw/commons/upload/FileMetadataUtils.java new file mode 100644 index 000000000..70dec35a4 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/upload/FileMetadataUtils.java @@ -0,0 +1,43 @@ +package fr.free.nrw.commons.upload; + +import timber.log.Timber; + +import static androidx.exifinterface.media.ExifInterface.*; + +/** + * Support utils for EXIF metadata handling + * + */ +public class FileMetadataUtils { + + /** + * Takes EXIF label from sharedPreferences as input and returns relevant EXIF tags + * + * @param pref EXIF sharedPreference label + * @return EXIF tags + */ + public static String[] getTagsFromPref(String pref) { + Timber.d("Retuning tags for pref:%s", pref); + switch (pref) { + case "Author": + return new String[]{TAG_ARTIST, TAG_CAMARA_OWNER_NAME}; + case "Copyright": + return new String[]{TAG_COPYRIGHT}; + case "Location": + return new String[]{TAG_GPS_LATITUDE, TAG_GPS_LATITUDE_REF, + TAG_GPS_LONGITUDE, TAG_GPS_LONGITUDE_REF, + TAG_GPS_ALTITUDE, TAG_GPS_ALTITUDE_REF}; + case "Camera Model": + return new String[]{TAG_MAKE, TAG_MODEL}; + case "Lens Model": + return new String[]{TAG_LENS_MAKE, TAG_LENS_MODEL, TAG_LENS_SPECIFICATION}; + case "Serial Numbers": + return new String[]{TAG_BODY_SERIAL_NUMBER, TAG_LENS_SERIAL_NUMBER}; + case "Software": + return new String[]{TAG_SOFTWARE}; + default: + return new String[]{}; + } + } + +} diff --git a/app/src/main/java/fr/free/nrw/commons/upload/FileProcessor.java b/app/src/main/java/fr/free/nrw/commons/upload/FileProcessor.java index 16fc5712a..1d4497586 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/FileProcessor.java +++ b/app/src/main/java/fr/free/nrw/commons/upload/FileProcessor.java @@ -2,22 +2,34 @@ package fr.free.nrw.commons.upload; import android.annotation.SuppressLint; import android.content.ContentResolver; +import android.content.Context; import android.net.Uri; import androidx.annotation.NonNull; import java.io.File; import java.io.IOException; +import java.lang.reflect.Type; +import java.util.Arrays; +import java.util.HashSet; import java.util.List; +import java.util.Set; import javax.inject.Inject; import javax.inject.Named; import javax.inject.Singleton; import androidx.exifinterface.media.ExifInterface; + +import com.google.gson.reflect.TypeToken; + +import fr.free.nrw.commons.R; import fr.free.nrw.commons.caching.CacheController; import fr.free.nrw.commons.kvstore.JsonKvStore; import fr.free.nrw.commons.mwapi.CategoryApi; +import fr.free.nrw.commons.settings.Prefs; +import io.reactivex.Observable; import io.reactivex.disposables.CompositeDisposable; +import io.reactivex.disposables.Disposable; import io.reactivex.schedulers.Schedulers; import timber.log.Timber; @@ -66,7 +78,10 @@ public class FileProcessor implements SimilarImageDialogFragment.onResponse { /** * Processes filePath coordinates, either from EXIF data or user location */ - GPSExtractor processFileCoordinates(SimilarImageInterface similarImageInterface) { + GPSExtractor processFileCoordinates(SimilarImageInterface similarImageInterface, Context context) { + // Redact EXIF data as indicated in preferences. + redactExifTags(exifInterface, getExifTagsToRedact(context)); + Timber.d("Calling GPSExtractor"); imageObj = new GPSExtractor(exifInterface); decimalCoords = imageObj.getCoords(); @@ -81,6 +96,55 @@ public class FileProcessor implements SimilarImageDialogFragment.onResponse { return imageObj; } + /** + * Gets EXIF Tags from preferences to be redacted. + * + * @param context application context + * @return tags to be redacted + */ + private Set getExifTagsToRedact(Context context) { + Type setType = new TypeToken>() {}.getType(); + Set prefManageEXIFTags = defaultKvStore.getJson(Prefs.MANAGED_EXIF_TAGS, setType); + + Set redactTags = new HashSet<>(Arrays.asList( + context.getResources().getStringArray(R.array.pref_exifTag_values))); + Timber.d(redactTags.toString()); + + if (prefManageEXIFTags != null) redactTags.removeAll(prefManageEXIFTags); + + return redactTags; + } + + /** + * Redacts EXIF metadata as indicated in preferences. + * + * @param exifInterface ExifInterface object + * @param redactTags tags to be redacted + */ + public static void redactExifTags(ExifInterface exifInterface, Set redactTags) { + if(redactTags.isEmpty()) return; + + Disposable disposable = Observable.fromIterable(redactTags) + .flatMap(tag -> Observable.fromArray(FileMetadataUtils.getTagsFromPref(tag))) + .forEach(tag -> { + Timber.d("Checking for tag: %s", tag); + String oldValue = exifInterface.getAttribute(tag); + if (oldValue != null && !oldValue.isEmpty()) { + Timber.d("Exif tag %s with value %s redacted.", tag, oldValue); + exifInterface.setAttribute(tag, null); + } + }); + CompositeDisposable disposables = new CompositeDisposable(); + disposables.add(disposable); + disposables.clear(); + + try { + exifInterface.saveAttributes(); + } catch (IOException e) { + Timber.w("EXIF redaction failed: %s", e.toString()); + } + } + /** * Find other images around the same location that were taken within the last 20 sec * @param similarImageInterface diff --git a/app/src/main/java/fr/free/nrw/commons/upload/UploadModel.java b/app/src/main/java/fr/free/nrw/commons/upload/UploadModel.java index afd59f4ca..c94f9f496 100644 --- a/app/src/main/java/fr/free/nrw/commons/upload/UploadModel.java +++ b/app/src/main/java/fr/free/nrw/commons/upload/UploadModel.java @@ -109,7 +109,7 @@ public class UploadModel { createdTimestampSource = dateTimeWithSource.getSource(); } Timber.d("File created date is %d", fileCreatedDate); - GPSExtractor gpsExtractor = fileProcessor.processFileCoordinates(similarImageInterface); + GPSExtractor gpsExtractor = fileProcessor.processFileCoordinates(similarImageInterface, context); return new UploadItem(uploadableFile.getContentUri(), Uri.parse(uploadableFile.getFilePath()), uploadableFile.getMimeType(context), source, gpsExtractor, place, fileCreatedDate, createdTimestampSource); } diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml index 3cfe500d4..ca389d5b3 100644 --- a/app/src/main/res/values/arrays.xml +++ b/app/src/main/res/values/arrays.xml @@ -14,4 +14,25 @@ @string/license_pref_cc_by_sa_3_0 @string/license_pref_cc_by_sa_4_0 + + + + @string/exif_tag_name_author + @string/exif_tag_name_copyright + @string/exif_tag_name_location + @string/exif_tag_name_cameraModel + @string/exif_tag_name_lensModel + @string/exif_tag_name_serialNumbers + @string/exif_tag_name_software + + + @string/exif_tag_author + @string/exif_tag_copyright + @string/exif_tag_location + @string/exif_tag_cameraModel + @string/exif_tag_lensModel + @string/exif_tag_serialNumbers + @string/exif_tag_software + + \ No newline at end of file diff --git a/app/src/main/res/values/keys.xml b/app/src/main/res/values/keys.xml index a2bb6860d..60d0b566a 100644 --- a/app/src/main/res/values/keys.xml +++ b/app/src/main/res/values/keys.xml @@ -5,4 +5,13 @@ CC BY-SA 3.0 CC BY 4.0 CC BY-SA 4.0 + + Author + Copyright + Location + Camera Model + Lens Model + Serial Numbers + Software + \ 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 15b04a61e..276f9e2c1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -5,6 +5,7 @@ Appearance General Feedback + Privacy Location Commons @@ -537,6 +538,18 @@ Upload your first media by tapping on the add button. Examples of images not to upload SKIP THIS IMAGE Download Failed!!. We cannot download the file without external storage permission. + + Manage EXIF Tags + Select which EXIF tags to keep in uploads + + Author + Copyright + Location + Camera Model + Lens Model + Serial Numbers + Software + Upload photos to Wikimedia Commons on your phone Download the Commons app: %1$s Share app via... Image Info diff --git a/app/src/main/res/xml/preferences.xml b/app/src/main/res/xml/preferences.xml index 21df4c045..9ab4d5f5f 100644 --- a/app/src/main/res/xml/preferences.xml +++ b/app/src/main/res/xml/preferences.xml @@ -59,6 +59,18 @@ + + + + + + diff --git a/app/src/test/data/exif_redact_sample.jpg b/app/src/test/data/exif_redact_sample.jpg new file mode 100644 index 000000000..177e42aad Binary files /dev/null and b/app/src/test/data/exif_redact_sample.jpg differ diff --git a/app/src/test/kotlin/fr/free/nrw/commons/upload/FileMetadataUtilsTest.kt b/app/src/test/kotlin/fr/free/nrw/commons/upload/FileMetadataUtilsTest.kt new file mode 100644 index 000000000..6dde026a9 --- /dev/null +++ b/app/src/test/kotlin/fr/free/nrw/commons/upload/FileMetadataUtilsTest.kt @@ -0,0 +1,23 @@ +package fr.free.nrw.commons.upload + +import androidx.exifinterface.media.ExifInterface.* +import junit.framework.Assert.assertTrue +import org.junit.Test +import java.util.* + +/** + * Test cases for FileMetadataUtils + */ +class FileMetadataUtilsTest { + + /** + * Test method to verify EXIF tags + */ + @Test + fun getTagsFromPref() { + val author = FileMetadataUtils.getTagsFromPref("Author") + val authorRef = arrayOf(TAG_ARTIST, TAG_CAMARA_OWNER_NAME); + + assertTrue(Arrays.deepEquals(author, authorRef)) + } +} \ No newline at end of file diff --git a/app/src/test/kotlin/fr/free/nrw/commons/upload/FileProcessorTest.kt b/app/src/test/kotlin/fr/free/nrw/commons/upload/FileProcessorTest.kt index 3d5803a1c..98f6a2e22 100644 --- a/app/src/test/kotlin/fr/free/nrw/commons/upload/FileProcessorTest.kt +++ b/app/src/test/kotlin/fr/free/nrw/commons/upload/FileProcessorTest.kt @@ -1,6 +1,7 @@ package fr.free.nrw.commons.upload import android.content.SharedPreferences +import androidx.exifinterface.media.ExifInterface import fr.free.nrw.commons.caching.CacheController import fr.free.nrw.commons.mwapi.CategoryApi import org.junit.Before @@ -11,6 +12,9 @@ import org.mockito.MockitoAnnotations import javax.inject.Inject import javax.inject.Named +import java.io.FileInputStream +import java.io.FileOutputStream + class FileProcessorTest { @Mock @@ -35,4 +39,51 @@ class FileProcessorTest { fun processFileCoordinates() { } + + /** + * Test method to verify redaction Exif metadata + */ + @Test + fun redactExifTags() { + /* + val filePathRef: String? = "src/test/data/exif_redact_sample.jpg" + val filePathTmp: String? = "" + System.getProperty("java.io.tmpdir") + "exif_redact_sample_tmp.jpg" + + val inStream = FileInputStream(filePathRef) + val outStream = FileOutputStream(filePathTmp) + val inChannel = inStream.getChannel() + val outChannel = outStream.getChannel() + inChannel.transferTo(0, inChannel.size(), outChannel) + inStream.close() + outStream.close() + + val redactTags = mutableSetOf("Author", "Copyright", "Location", "Camera Model", + "Lens Model", "Serial Numbers", "Software") + + val exifInterface : ExifInterface? = ExifInterface(filePathTmp.toString()) + + var nonEmptyTag = false + for (redactTag in redactTags) { + for (tag in FileMetadataUtils.getTagsFromPref(redactTag)) { + val tagValue = exifInterface?.getAttribute(tag) + if(tagValue != null) { + nonEmptyTag = true + break + } + } + if (nonEmptyTag) break + } + // all tags are empty, can't test redaction + assert(nonEmptyTag) + + FileProcessor.redactExifTags(exifInterface, redactTags) + + for (redactTag in redactTags) { + for (tag in FileMetadataUtils.getTagsFromPref(redactTag)) { + val oldValue = exifInterface?.getAttribute(tag) + assert(oldValue == null) + } + } + */ + } } \ No newline at end of file