mirror of
https://github.com/commons-app/apps-android-commons.git
synced 2025-10-26 12:23:58 +01:00
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:
parent
5690dd9d0b
commit
cc0b059595
13 changed files with 291 additions and 2 deletions
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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[]{};
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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">• </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>
|
||||
|
|
|
|||
|
|
@ -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.-->
|
||||
|
||||
|
|
|
|||
BIN
app/src/test/data/exif_redact_sample.jpg
Normal file
BIN
app/src/test/data/exif_redact_sample.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 160 KiB |
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
*/
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue