Image EXIF/XMP metadata removal routine (#2863)

* [WIP] Added preferences for EXIF tags

* [WIP] Added arrays, keys, strings to support EXIF preferences

* [WIP] Updated SettingsFragment to setup summary of added preferences(locationAccuracy)

* [WIP] Added methods getStringSet()in BasicKvStore, KeyValueStore to support Set<String> data type used in preferences (EXIF tags)

* [WIP] Added methods for removing EXIF tags and anonimyzing location coordinates in FileProcessor, GPSExtractor

* [WIP] Fixed errors in preferences EXIF tags, added XMP removal routine

* [WIP] Removed erroneous location accuracy handling

* [WIP] Fixed mistyped GPS Tags

* Reverted BasicKvStore. Removed Set<String> support in BasicKvStore as JsonKVStore already has it.

* FileProcessor: Replaced throwing runtime exception with warning if EXIF redaction fails.

* FileMetadataUtils: Javadoc added

* [WIP] FileMetadataUtilsTest added

* [WIP] FileMetadataUtilsTest: added javadoc

* [WIP] FileMetadataUtilsTest: added javadoc

* [WIP] FileProcessor: fixed disposing observables

* [WIP] FileMetadataUtils.getTagsFromPref: changed return type from observable to simple array

* [WIP] FileProcessorTest: added test for redactExifTags

* [WIP] FileProcessorTest: redactExifTags() doesn't work properly
This commit is contained in:
Vitaly V. Pinchuk 2019-06-04 15:38:01 +03:00 committed by neslihanturan
parent 5690dd9d0b
commit cc0b059595
13 changed files with 291 additions and 2 deletions

View file

@ -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";

View file

@ -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));

View file

@ -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);
}
}
}

View file

@ -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[]{};
}
}
}

View file

@ -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<String> getExifTagsToRedact(Context context) {
Type setType = new TypeToken<Set<String>>() {}.getType();
Set<String> prefManageEXIFTags = defaultKvStore.getJson(Prefs.MANAGED_EXIF_TAGS, setType);
Set<String> 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<String> 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

View file

@ -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);
}

View file

@ -14,4 +14,25 @@
<item>@string/license_pref_cc_by_sa_3_0</item>
<item>@string/license_pref_cc_by_sa_4_0</item>
</array>
<!--TODO add more EXIF tags-->
<array name="pref_exifTag_entries">
<item>@string/exif_tag_name_author</item>
<item>@string/exif_tag_name_copyright</item>
<item>@string/exif_tag_name_location</item>
<item>@string/exif_tag_name_cameraModel</item>
<item>@string/exif_tag_name_lensModel</item>
<item>@string/exif_tag_name_serialNumbers</item>
<item>@string/exif_tag_name_software</item>
</array>
<array name="pref_exifTag_values">
<item>@string/exif_tag_author</item>
<item>@string/exif_tag_copyright</item>
<item>@string/exif_tag_location</item>
<item>@string/exif_tag_cameraModel</item>
<item>@string/exif_tag_lensModel</item>
<item>@string/exif_tag_serialNumbers</item>
<item>@string/exif_tag_software</item>
</array>
</resources>

View file

@ -5,4 +5,13 @@
<string name="license_pref_cc_by_sa_3_0" translatable="false">CC BY-SA 3.0</string>
<string name="license_pref_cc_by_4_0" translatable="false">CC BY 4.0</string>
<string name="license_pref_cc_by_sa_4_0" translatable="false">CC BY-SA 4.0</string>
<string name="exif_tag_author" translatable="false">Author</string>
<string name="exif_tag_copyright" translatable="false">Copyright</string>
<string name="exif_tag_location" translatable="false">Location</string>
<string name="exif_tag_cameraModel" translatable="false">Camera Model</string>
<string name="exif_tag_lensModel" translatable="false">Lens Model</string>
<string name="exif_tag_serialNumbers" translatable="false">Serial Numbers</string>
<string name="exif_tag_software" translatable="false">Software</string>
</resources>

View file

@ -5,6 +5,7 @@
<string name="preference_category_appearance">Appearance</string>
<string name="preference_category_general">General</string>
<string name="preference_category_feedback">Feedback</string>
<string name="preference_category_privacy">Privacy</string>
<string name="preference_category_location">Location</string>
<string name="app_name">Commons</string>
<string name="bullet">&#8226; </string>
@ -537,6 +538,18 @@ Upload your first media by tapping on the add button.</string>
<string name="welcome_dont_upload_content_description">Examples of images not to upload</string>
<string name="skip_image">SKIP THIS IMAGE</string>
<string name="download_failed_we_cannot_download_the_file_without_storage_permission">Download Failed!!. We cannot download the file without external storage permission.</string>
<string name="manage_exif_tags">Manage EXIF Tags</string>
<string name="manage_exif_tags_summary">Select which EXIF tags to keep in uploads</string>
<string name="exif_tag_name_author">Author</string>
<string name="exif_tag_name_copyright">Copyright</string>
<string name="exif_tag_name_location">Location</string>
<string name="exif_tag_name_cameraModel">Camera Model</string>
<string name="exif_tag_name_lensModel">Lens Model</string>
<string name="exif_tag_name_serialNumbers">Serial Numbers</string>
<string name="exif_tag_name_software">Software</string>
<string name="share_text">Upload photos to Wikimedia Commons on your phone Download the Commons app: %1$s</string>
<string name="share_via">Share app via...</string>
<string name="image_info">Image Info</string>

View file

@ -59,6 +59,18 @@
</fr.free.nrw.commons.ui.LongTitlePreferences.LongTitlePreferenceCategory>
<fr.free.nrw.commons.ui.LongTitlePreferences.LongTitlePreferenceCategory
android:title="@string/preference_category_privacy">
<fr.free.nrw.commons.ui.LongTitlePreferences.LongTitleMultiSelectListPreference
android:entries="@array/pref_exifTag_entries"
android:entryValues="@array/pref_exifTag_values"
android:key="manageExifTags"
android:title="@string/manage_exif_tags"
android:summary="@string/manage_exif_tags_summary"/>
</fr.free.nrw.commons.ui.LongTitlePreferences.LongTitlePreferenceCategory>
<!-- The key 'allowGps' was used before and has since been removed based on the discussion at #1599.
Do not reuse this key unless you revive the same feature with the changes mentioned at #1599.-->

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

View file

@ -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))
}
}

View file

@ -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)
}
}
*/
}
}