Hello!
A while ago, when I published my first project here (Project: Smart Bulb Cop Car), I discovered a need for Exif metadata cleaning.
A few days later, Simple Python Ex-if remover was developed.
Now is the time for the final weapon against Exif – Android EXIF cleaner!
Let’s go 💣!
Concept
If you want to get more details about Why privacy is so important with examples such as people giving out personal passwords in a street poll, a geotagging trace of Russians in Ukrainian territory or social media sites, and their attitude to metadata, I strongly recommend to get back to Simple Python Ex-if remover and give it a closer look 😊
Now, according to the DRY (don’t repeat yourself) principle, I’ll focus on building an Android app instead of explaining why we should clear metadata.
I want to build a simple application where:
- The end-user will load data (images),
- Exif data can be inspected,
- The whole list is going to be (copied) cleaned and saved in a different directory.
Let’s get down to it!
User Interface
For this article purposes, I’ll keep the interface as simple as possible – I need only a grid view (to present a grid of images) and two buttons – load images and clear images.
So the UI presents itself as follows:
I also wanted EXIF data to be shown after the click on the image:

I used two linear layouts (which I believe are still common): one main linear layout with grid view and nested linear layout for buttons.
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical"
android:layout_width="fill_parent"
android:layout_height="fill_parent">
<GridView
android:id="@+id/gvImgs"
android:layout_width="wrap_content"
android:layout_height="0dp"
android:numColumns="2"
android:verticalSpacing="10dp"
android:horizontalSpacing="10dp"
android:stretchMode="columnWidth"
android:gravity="center"
android:columnWidth="60dp"
android:layout_weight="1" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginLeft="70dp"
android:gravity="center">
<Button
android:id="@+id/btLoad"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:layout_weight="0"
android:text="Load Pictures" />
<Button
android:id="@+id/btClear"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:layout_weight="0"
android:text="Clear Pictures"/>
</LinearLayout>
</LinearLayout>
Note: Sure, it needs improvement before publishing the app, but for article purposes, it’s fine 😊
If you want to learn more about layouts, check out Android guides: Layout
Android permissions
Since Android 6.0 (introduced in 2015), we have a crucial security mechanism – permissions.
As we can read in the official documentation:
The purpose of permission is to protect the privacy of an Android user. Android apps must request permission to access sensitive user data (such as contacts and SMS) and certain system features (such as camera and internet). Depending on the feature, the system might automatically grant permission or prompt the user to approve the request.
A central design point of the Android security architecture is that no app, by default, has permission to perform any operations that would adversely impact other apps, the operating system, or the user. This includes reading or writing the user’s private data (such as contacts or emails), reading or writing another app’s files, performing network access, keeping the device awake, and so on.
Permissions overview – Android Developers
We have to bear in mind that we are willing to read external storage (to read images from storage) and write to external storage (to write cleaned images)
So, in AndroidManifest.xml
, we have to add those two lines:
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
And also, “ask” the user to grant those permissions quickly (we can force the user to grant permissions via Settings ->Apps -> I’M BORED -> close app -> Uninstall, but we do not want this scenario to come true). We will invoke the verifyStoragePermissions()
method:
public static void verifyStoragePermissions(Activity activity) {
// Check if we have write permission
int permission = ActivityCompat.checkSelfPermission(activity, Manifest.permission.WRITE_EXTERNAL_STORAGE);
if (permission != PackageManager.PERMISSION_GRANTED) {
// We don't have permission so prompt the user
ActivityCompat.requestPermissions(
activity,
PERMISSIONS_STORAGE,
REQUEST_EXTERNAL_STORAGE
);
}
}
Source: https://stackoverflow.com/questions/33719170/android-6-0-file-write-permission-denied
I always admire it when somebody asks a question, and after finding the solution is sharing it!
With this method invoked on onCreate()
(MainActivity.java
), the permission issue is sorted out.
Backend – Main Activity
Main Activity is, in my opinion, the most critical Activity in the android app 😊
For those not familiar with android programming, quick catch up:
An activity provides the window in which the app draws its UI. (…) Generally, one Activity implements one screen in an app. For instance, one of an app’s activities may implement a Preferences screen, while another activity implements a Select Photo screen.
Most apps contain multiple screens, which means they comprise multiple activities. Typically, one Activity in an app is specified as the main Activity, which is the first screen to appear when the user launches the app. Each Activity can then start another activity in order to perform different actions. (…)
Although activities work together to form a cohesive user experience in an app, each Activity is only loosely bound to the other activities; there are usually minimal dependencies among the activities in an app. In fact, activities often start up activities belonging to other apps. For example, a browser app might launch the Share activity of a social-media app.
The concept of activities – Introduction to activities | Android Developers
On my onCreate()
method, I need to wrap my UI objects with their representation in the layout, create an AlertDialog builder, and set onClick listeners for buttons. Also, I’ll initialize the array list of URIs to the images we like to clear.
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
gvImgs = findViewById(R.id.gvImgs);
btLoad = findViewById(R.id.btLoad);
btClear = findViewById(R.id.btClear);
exif.mContext = this;
imgs = new ArrayList();
adapter = new ImageAdapter(this);
builder = new AlertDialog.Builder(this);
btLoad.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
loadImages();
}
});
btClear.setOnClickListener(new View.OnClickListener() {
@RequiresApi(api = Build.VERSION_CODES.O)
@Override
public void onClick(View v) {
clearFiles();
}
});
verifyStoragePermissions(this);
}
As you can see, we are assigning two methods to buttons: loadImages()
to btLoad
and clearFiles()
to btClear
.
private void loadImages() {
Intent gallery = new Intent(Intent.ACTION_PICK, MediaStore.Images.Media.EXTERNAL_CONTENT_URI);
gallery.setType("image/*");
gallery.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true);
startActivityForResult(gallery, PICK_IMAGE);
}
This method invokes the Intent to pick files from external storage.
What is the Intent? As we can read on documentation:
An Intent provides a facility for performing late runtime binding between the code in different applications. Its most significant use is in the launching of activities, where it can be thought of as the glue between activities. It is basically a passive data structure holding an abstract description of an action to be performed.
Intent page – Android Developers
So we will invoke Intent to pick images from the gallery, and it will work both with default and custom gallery application 😉
The Intent is a specific tyle of Activity, so we have to call this Activity and wait for the result. Then, we would like to handle the received data with our logic.
@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data){
super.onActivityResult(requestCode, resultCode, data);
if (resultCode == RESULT_OK && requestCode == PICK_IMAGE){
if(data.getClipData() != null) { // MULTIPLE IMAGES
int count = data.getClipData().getItemCount();
for(int i = 0; i < count; i++)
imgs.add(data.getClipData().getItemAt(i).getUri());
}else if(data.getData() != null) { // ONE IMAGE
String imagePath = data.getData().getPath();
imgs.add(data.getData());
}
adapter.images = imgs;
gvImgs.setAdapter(adapter);
}
}
In our case, it is adding the images to the ArrayList of URI to the images and set the list to the adapter (I’ll demystify it in a second).
We want to clearImages(), so we are building AlertDialog (with a summary of the operation), and for all images in the list, we are:
- Getting the path, filename, and extension,
- Setting “destination” to /Camera/Cleaned directory (needs to be created manually),
- Trying to copy the file (if there is an issue – we will let the user know) with _cleaned suffix,
- Clean “cleaned” file with ExifUtils object,
- Show the message to the user.
Easy? Easy. Because Exif Utils is doing the trick.
Backend – Additional classes
ExifUtils – my Exif tool to manage exifs.
I always was a big fan of Ulility Class (aka. Helper class). Sure thing, my implementation is not “canonic” while I created an Exif Utils object, but… whatever.
In this simple class, I have two big, static arrays of Strings:
- exifAttributes with a list of all metadata attributes than can be modified via encapsulation (we have a getter for them and they can be set),
- exifAttributesToClean – my list of attributes to clean.
Here we have such methods as:
- getPathFromUri() – self-describing,
- getExifFromUri() – also, to get EXIF to present it to the user,
- getThumbnail)_ – self-describing as well,
- getExtension() – as you can see above, I’m a sluggish guy – instead of complex string manipulation to add _cleaned suffix, I’m just replacing the extension .JPG into _cleaned.JPG
- clearFiles() – the most essential five lines here – for each attribute we are setting “” – empty String
Below you can see all of them:
public String getPathFromURI(Uri contentUri) {
String res = null;
String[] proj = {MediaStore.Images.Media.DATA};
Cursor cursor = mContext.getContentResolver().query(contentUri, proj, null, null, null);
if (cursor.moveToFirst()) {
int column_index = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATA);
res = cursor.getString(column_index);
}
cursor.close();
return res;
}
public String getPathFromURI(Uri contentUri) {
String res = null;
String[] proj = {MediaStore.Images.Media.DATA};
Cursor cursor = mContext.getContentResolver().query(contentUri, proj, null,
null, null);
if (cursor.moveToFirst()) {
int column_index = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATA);
res = cursor.getString(column_index);
}
cursor.close();
return res;
}
public String getExifFromUri(Uri contentUri){
String path = getPathFromURI(contentUri);
StringBuilder attributes = new StringBuilder();
Path source= Paths.get(path);
attributes.append("parent: "+source.getParent().toString()+ "\n");
attributes.append(path+"\n\n");
try {
exif = new ExifInterface(path);
for(String attribute : exifAttributesToClean){
attributes.append(attribute+": "+exif.getAttribute(attribute)+"\n");
}
} catch (IOException e) {
attributes.append("\nUnable to load ExIF \n"+e.toString());
}
return attributes.toString();
}
public Bitmap getThumbnail(Uri contentUri) throws IOException {
exif = new ExifInterface(getPathFromURI(contentUri));
return exif.getThumbnailBitmap();
}
public String getExtension(String fileName) {
String extension = "";
int i = fileName.lastIndexOf('.');
if (i > 0) {
extension = fileName.substring(i+1);
}
return "."+extension;
}
public void clearFile(String filePath) throws IOException{
exif = new ExifInterface(filePath);
for(String attribute : exifAttributes){ // or exifAttributesToClean
exif.setAttribute(attribute, "");
}
exif.saveAttributes();
}
And a list of attributes to clean:
private static final String[] exifAttributes = new String[] {
"FNumber", "ApertureValue", "Artist", "BitsPerSample", "BrightnessValue", "CFAPattern",
"ColorSpace", "ComponentsConfiguration", "CompressedBitsPerPixel", "Compression",
"Contrast", "Copyright", "CustomRendered", "DateTime", "DateTimeDigitized",
"DateTimeOriginal", "DefaultCropSize", "DeviceSettingDescription","DigitalZoomRatio",
"DNGVersion", "ExifVersion", "ExposureBiasValue", "ExposureIndex", "ExposureMode",
"ExposureProgram", "ExposureTime", "FileSource", "Flash", "FlashpixVersion",
"FlashEnergy", "FocalLength", "FocalLengthIn35mmFilm", "FocalPlaneResolutionUnit",
"FocalPlaneXResolution", "FocalPlaneYResolution", "FNumber", "GainControl",
"GPSAltitude", "GPSAltitudeRef", "GPSAreaInformation", "GPSDateStamp", "GPSDestBearing",
"GPSDestBearingRef", "GPSDestDistance", "GPSDestDistanceRef", "GPSDestLatitude",
"GPSDestLatitudeRef", "GPSDestLongitude", "GPSDestLongitudeRef", "GPSDifferential",
"GPSDOP", "GPSImgDirection", "GPSImgDirectionRef", "GPSLatitude", "GPSLatitudeRef",
"GPSLongitude", "GPSLongitudeRef", "GPSMapDatum", "GPSMeasureMode",
"GPSProcessingMethod", "GPSSatellites", "GPSSpeed", "GPSSpeedRef", "GPSStatus",
"GPSTimeStamp", "GPSTrack", "GPSTrackRef", "GPSVersionID", "ImageDescription",
"ImageLength", "ImageUniqueID", "ImageWidth", "InteroperabilityIndex",
"ISOSpeedRatings", "ISOSpeedRatings", "JPEGInterchangeFormat",
"JPEGInterchangeFormatLength", "LightSource", "Make", "MakerNote", "MaxApertureValue",
"MeteringMode", "Model", "NewSubfileType", "OECF", "AspectFrame", "PreviewImageLength",
"PreviewImageStart", "ThumbnailImage", "Orientation", "PhotometricInterpretation",
"PixelXDimension", "PixelYDimension", "PlanarConfiguration", "PrimaryChromaticities",
"ReferenceBlackWhite", "RelatedSoundFile", "ResolutionUnit", "RowsPerStrip", "ISO",
"JpgFromRaw", "SensorBottomBorder", "SensorLeftBorder", "SensorRightBorder",
"SensorTopBorder", "SamplesPerPixel", "Saturation", "SceneCaptureType", "SceneType",
"SensingMethod", "Sharpness", "ShutterSpeedValue", "Software",
"SpatialFrequencyResponse", "SpectralSensitivity", "StripByteCounts", "StripOffsets",
"SubfileType", "SubjectArea", "SubjectDistance", "SubjectDistanceRange",
"SubjectLocation", "SubSecTime", "SubSecTimeDigitized", "SubSecTimeDigitized",
"SubSecTimeOriginal", "SubSecTimeOriginal", "ThumbnailImageLength",
"ThumbnailImageWidth", "TransferFunction", "UserComment", "WhiteBalance",
"WhitePoint", "XResolution", "YCbCrCoefficients", "YCbCrPositioning",
"YCbCrSubSampling", "YResolution"
};
private static final String[] exifAttributesToClean = new String[] {
"Copyright", "CustomRendered", "DateTime", "DateTimeDigitized", "DateTimeOriginal",
"ExifVersion", "ExposureBiasValue", "ExposureMode", "ExposureProgram", "ExposureTime",
"ExposureIndex", "FileSource", "FNumber", "GPSAltitude", "GPSAltitudeRef",
"GPSAreaInformation", "GPSDateStamp", "GPSDestBearing", "GPSDestBearingRef",
"GPSDestDistance", "GPSDestDistanceRef", "GPSDestLatitude", "GPSDestLatitudeRef",
"GPSDestLongitude", "GPSDestLongitudeRef", "GPSDifferential", "GPSDOP",
"GPSImgDirection", "GPSImgDirectionRef", "GPSLatitude", "GPSLatitudeRef",
"GPSLongitude", "GPSLongitudeRef", "GPSMapDatum", "GPSMeasureMode",
"GPSProcessingMethod", "GPSSatellites", "GPSSpeed", "GPSSpeedRef", "GPSStatus",
"GPSTimeStamp", "GPSTrack", "GPSTrackRef", "GPSVersionID", "ImageDescription",
"ImageUniqueID", "Make", "MakerNote", "Model","ISO",
};
The output before and after the cleaning is presented below:
ImageAdapter – custom implementation.
ImageAdapter is a custom implementation of BaseAdapter class. It means that if you want to customize GridView behavior, functionalities, and content, you have to extend BaseAdapter and implement your solution, and then set the adapter to the grid view.
My implementation was based on the Tulane tutorial – Android Grid view with examples
The most critical function to implement is getView()
:
public View getView(final int position, View convertView, ViewGroup parent) {
exif.mContext = mContext;
builder = new AlertDialog.Builder(mContext);
ImageView imageView = new ImageView(mContext);
imageView.setLayoutParams(new GridView.LayoutParams(450, 400));
imageView.setPadding(10,0,0,0);
try {
// here we are trying to introduce some optimisation
// by reducing required memory and lower loading the time
Bitmap img = exif.getThumbnail(images.get(position));
imageView.setImageBitmap(img);
} catch (IOException e) {
// if there is an exception we can do it less efficient way
imageView.setImageURI(images.get(position));
}
imageView.setClickable(true);
imageView.bringToFront();
imageView.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
builder.setMessage(exif.getExifFromUri(images.get(position)))
.setCancelable(false)
.setPositiveButton("OK", new DialogInterface.OnClickListener() {
public void onClick(DialogInterface dialog, int id) {
dialog.cancel();
}
});
AlertDialog alert = builder.create();
String filename = new File(exif.getPathFromURI(images.get(position))).getName();
alert.setTitle("ExIF " +filename);
alert.show();
}
});
return imageView;
}
Here is where all of the magic happens – I’m creating ImageView dynamically for all images, with thumbnail instead of the whole image, make it clickable, and create AlertBuilder – to present Exif Data to the user (using ExifUtils).
And basically, that’s it.
Conclusion
It’s been a while since I’ve developed an Android app, so this ‘come back’ was a great pleasure for me 😊
This simple application is designed to clear Exif Data before publishing them to social media – I think it’s another layer we have to add to protect our privacy.
I’m planning to redesign it a little and publish it on Play Store, so please keep your fingers crossed and stay safe!
Source code for this project on my Github: HeadFullOfCiphers/AndroidExifCleaner
That it for today 😊 if you liked this post, have any suggestions, questions, or want to contact me – do not hesitate!
Reference list:
- Layout page – Android Developers
- Permissions overview – Android Developers
- Introduction to activities – Android Developers
- Intent page – Android Developers
- Android Grid view with examples – Tutlane tutorial
Check out related posts: