package de.jeisfeld.augendiagnoselib.util.imagefile;
import android.app.Activity;
import android.content.ContentResolver;
import android.content.ContentValues;
import android.content.Context;
import android.net.Uri;
import android.os.Build;
import android.os.Build.VERSION_CODES;
import android.os.Environment;
import android.os.ParcelFileDescriptor;
import android.os.storage.StorageManager;
import android.provider.DocumentsContract;
import android.provider.MediaStore;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.support.annotation.RequiresApi;
import android.support.v4.provider.DocumentFile;
import android.util.Log;
import java.io.File;
import java.io.FileFilter;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.lang.reflect.Array;
import java.lang.reflect.Method;
import java.nio.channels.FileChannel;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import de.jeisfeld.augendiagnoselib.Application;
import de.jeisfeld.augendiagnoselib.R;
import de.jeisfeld.augendiagnoselib.util.DialogUtil;
import de.jeisfeld.augendiagnoselib.util.PreferenceUtil;
import de.jeisfeld.augendiagnoselib.util.SystemUtil;
/**
* Utility class for helping parsing file systems.
*/
public final class FileUtil {
/**
* The name of the primary volume (LOLLIPOP).
*/
private static final String PRIMARY_VOLUME_NAME = "primary";
/**
* Hide default constructor.
*/
private FileUtil() {
throw new UnsupportedOperationException();
}
/**
* Determine the camera folder. There seems to be no Android API to work for real devices, so this is a best guess.
*
* @return the default camera folder.
*/
public static String getDefaultCameraFolder() {
File path = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DCIM);
if (path.exists()) {
File test1 = new File(path, "Camera/");
if (test1.exists()) {
path = test1;
}
else {
File test2 = new File(path, "100ANDRO/");
if (test2.exists()) {
path = test2;
}
else {
path = new File(path, "100MEDIA/");
}
}
}
else {
path = new File(path, "Camera/");
}
return path.getAbsolutePath();
}
/**
* Determine the default output folder of the Eye-Fi app.
*
* @return the default Eye-Fi folder.
*/
public static String getDefaultEyeFiFolder() {
if (SystemUtil.isAppInstalled(Application.getResourceString(R.string.package_eyefi))) {
return getSdCardPath() + File.separator + "Eye-Fi";
}
return getSdCardPath() + File.separator + "Mobi";
}
/**
* Copy a file. The target file may even be on external SD card for Kitkat.
*
* @param source The source file
* @param target The target file
* @return true if the copying was successful.
*/
@SuppressWarnings("null")
public static boolean copyFile(@NonNull final File source, @NonNull final File target) {
FileInputStream inStream = null;
OutputStream outStream = null;
FileChannel inChannel = null;
FileChannel outChannel = null;
try {
inStream = new FileInputStream(source);
// First try the normal way
if (isWritable(target)) {
// standard way
outStream = new FileOutputStream(target);
inChannel = inStream.getChannel();
outChannel = ((FileOutputStream) outStream).getChannel();
inChannel.transferTo(0, inChannel.size(), outChannel);
}
else {
if (SystemUtil.isAndroid5()) {
// Storage Access Framework
DocumentFile targetDocument = getDocumentFile(target, false, true);
if (targetDocument != null) {
outStream = Application.getAppContext().getContentResolver().openOutputStream(targetDocument.getUri());
}
}
else if (SystemUtil.isKitkat()) {
// Workaround for Kitkat ext SD card
Uri uri = MediaStoreUtil.getUriFromFile(target.getAbsolutePath());
if (uri != null) {
outStream = Application.getAppContext().getContentResolver().openOutputStream(uri);
}
}
else {
return false;
}
if (outStream != null) {
// Both for SAF and for Kitkat, write to output stream.
byte[] buffer = new byte[4096]; // MAGIC_NUMBER
int bytesRead;
while ((bytesRead = inStream.read(buffer)) != -1) {
outStream.write(buffer, 0, bytesRead);
}
}
}
}
catch (Exception e) {
Log.e(Application.TAG,
"Error when copying file from " + source.getAbsolutePath() + " to " + target.getAbsolutePath(), e);
return false;
}
finally {
try {
inStream.close();
}
catch (Exception e) {
// ignore exception
}
try {
outStream.close();
}
catch (Exception e) {
// ignore exception
}
try {
inChannel.close();
}
catch (Exception e) {
// ignore exception
}
try {
outChannel.close();
}
catch (Exception e) {
// ignore exception
}
}
return true;
}
/**
* Delete a file. May be even on external SD card.
*
* @param file the file to be deleted.
* @return True if successfully deleted.
*/
public static boolean deleteFile(@NonNull final File file) {
// First try the normal deletion.
if (file.delete()) {
return true;
}
// Try with Storage Access Framework.
if (SystemUtil.isAndroid5()) {
DocumentFile document = getDocumentFile(file, false, true);
return document != null && document.delete();
}
// Try the Kitkat workaround.
if (SystemUtil.isKitkat()) {
ContentResolver resolver = Application.getAppContext().getContentResolver();
try {
Uri uri = MediaStoreUtil.getUriFromFile(file.getAbsolutePath());
if (uri != null) {
resolver.delete(uri, null, null);
}
return !file.exists();
}
catch (Exception e) {
Log.e(Application.TAG, "Error when deleting file " + file.getAbsolutePath(), e);
return false;
}
}
return !file.exists();
}
/**
* Move a file. The target file may even be on external SD card.
*
* @param source The source file
* @param target The target file
* @return true if the copying was successful.
*/
public static boolean moveFile(@NonNull final File source, @NonNull final File target) {
// First try the normal rename.
boolean success = source.renameTo(target);
if (!success) {
success = copyFile(source, target);
if (success) {
success = deleteFile(source);
}
}
if (success) {
PupilAndIrisDetector.notifyFileRename(source.getAbsolutePath(), target.getAbsolutePath());
}
return success;
}
/**
* Rename a folder. In case of extSdCard in Kitkat, the old folder stays in place, but files are moved.
*
* @param source The source folder.
* @param target The target folder.
* @return true if the renaming was successful.
*/
public static boolean renameFolder(@NonNull final File source, @NonNull final File target) {
// First try the normal rename.
if (source.renameTo(target)) {
return true;
}
if (target.exists()) {
return false;
}
// Try the Storage Access Framework if it is just a rename within the same parent folder.
if (SystemUtil.isAndroid5() && source.getParent().equals(target.getParent())) {
DocumentFile document = getDocumentFile(source, true, true);
if (document != null && document.renameTo(target.getName())) {
return true;
}
}
// Try the manual way, moving files individually.
if (!mkdir(target)) {
return false;
}
File[] sourceFiles = source.listFiles();
if (sourceFiles == null) {
return true;
}
for (File sourceFile : sourceFiles) {
String fileName = sourceFile.getName();
File targetFile = new File(target, fileName);
if (!copyFile(sourceFile, targetFile)) {
// stop on first error
return false;
}
}
// Only after successfully copying all files, delete files on source folder.
for (File sourceFile : sourceFiles) {
if (!deleteFile(sourceFile)) {
// stop on first error
return false;
}
}
return true;
}
/**
* Get a temp file.
*
* @param file The base file for which to create a temp file.
* @return The temp file.
*/
@NonNull
public static File getTempFile(@NonNull final File file) {
File extDir = new File(Application.getAppContext().getExternalCacheDir(), "temp");
if (!extDir.exists()) {
//noinspection ResultOfMethodCallIgnored
extDir.mkdirs();
}
return new File(extDir, file.getName());
}
/**
* Get a file for temporarily storing a Jpeg file.
*
* @return a non-existing Jpeg file in the cache dir.
*/
public static File getTempJpegFile() {
File tempDir = getTempCameraFolder();
File tempFile;
do {
int tempFileCounter = PreferenceUtil.incrementCounter(R.string.key_internal_counter_tempfiles);
tempFile = new File(tempDir, "tempFile_" + tempFileCounter + ".jpg");
}
while (tempFile.exists());
return tempFile;
}
/**
* Get all temp files.
*
* @return The list of existing temp files.
*/
public static File[] getTempCameraFiles() {
File tempDir = getTempCameraFolder();
File[] files = tempDir.listFiles(new FileFilter() {
@Override
public boolean accept(@NonNull final File file) {
return file.isFile();
}
});
if (files == null) {
files = new File[0];
}
Arrays.sort(files);
return files;
}
/**
* Get the folder where temporary files from the camera are stored.
*
* @return The temp folder.
*/
@NonNull
public static File getTempCameraFolder() {
File result = new File(Application.getAppContext().getExternalCacheDir(), "Camera");
if (!result.exists()) {
//noinspection ResultOfMethodCallIgnored
result.mkdirs();
}
return result;
}
/**
* Create a folder. The folder may even be on external SD card for Kitkat.
*
* @param file The folder to be created.
* @return True if creation was successful.
*/
public static boolean mkdir(@NonNull final File file) {
if (file.exists()) {
// nothing to create.
return file.isDirectory();
}
// Try the normal way
if (file.mkdir()) {
return true;
}
// Try with Storage Access Framework.
if (SystemUtil.isAndroid5()) {
DocumentFile document = getDocumentFile(file, true, true);
// getDocumentFile implicitly creates the directory.
return document != null && document.exists();
}
// Try the Kitkat workaround.
if (SystemUtil.isKitkat()) {
File tempFile = new File(file, "dummyImage.jpg");
File dummySong = copyDummyFiles();
if (dummySong == null) {
return false;
}
int albumId = MediaStoreUtil.getAlbumIdFromAudioFile(dummySong);
Uri albumArtUri = Uri.parse("content://media/external/audio/albumart/" + albumId);
ContentValues contentValues = new ContentValues();
contentValues.put(MediaStore.MediaColumns.DATA, tempFile.getAbsolutePath());
contentValues.put(MediaStore.Audio.AlbumColumns.ALBUM_ID, albumId);
ContentResolver resolver = Application.getAppContext().getContentResolver();
if (resolver.update(albumArtUri, contentValues, null, null) == 0) {
resolver.insert(Uri.parse("content://media/external/audio/albumart"), contentValues);
}
try {
ParcelFileDescriptor fd = resolver.openFileDescriptor(albumArtUri, "r");
if (fd != null) {
fd.close();
}
}
catch (Exception e) {
Log.e(Application.TAG, "Could not open file", e);
return false;
}
finally {
FileUtil.deleteFile(tempFile);
}
return true;
}
return false;
}
/**
* Delete a folder.
*
* @param file The folder name.
* @return true if successful.
*/
public static boolean rmdir(@NonNull final File file) {
if (!file.exists()) {
return true;
}
if (!file.isDirectory()) {
return false;
}
String[] fileList = file.list();
if (fileList != null && fileList.length > 0) {
// Delete only empty folder.
return false;
}
// Try the normal way
if (file.delete()) {
return true;
}
// Try with Storage Access Framework.
if (SystemUtil.isAndroid5()) {
DocumentFile document = getDocumentFile(file, true, true);
return document != null && document.delete();
}
// Try the Kitkat workaround.
if (SystemUtil.isKitkat()) {
ContentResolver resolver = Application.getAppContext().getContentResolver();
ContentValues values = new ContentValues();
values.put(MediaStore.MediaColumns.DATA, file.getAbsolutePath());
resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values);
// Delete the created entry, such that content provider will delete the file.
resolver.delete(MediaStore.Files.getContentUri("external"), MediaStore.MediaColumns.DATA + "=?",
new String[]{file.getAbsolutePath()});
}
return !file.exists();
}
/**
* Delete all files in a folder.
*
* @param folder the folder
* @return true if successful.
*/
public static boolean deleteFilesInFolder(@NonNull final File folder) {
boolean totalSuccess = true;
String[] children = folder.list();
if (children != null) {
for (String child : children) {
File file = new File(folder, child);
if (!file.isDirectory()) {
boolean success = FileUtil.deleteFile(file);
if (!success) {
Log.w(Application.TAG, "Failed to delete file" + child);
totalSuccess = false;
}
}
}
}
return totalSuccess;
}
/**
* Delete a directory asynchronously.
*
* @param activity The activity calling this method.
* @param file The folder name.
* @param postActions Commands to be executed after success.
*/
public static void rmdirAsynchronously(@NonNull final Activity activity, @NonNull final File file, final Runnable postActions) {
new Thread() {
@Override
public void run() {
int retryCounter = 5; // MAGIC_NUMBER
while (!FileUtil.rmdir(file) && retryCounter > 0) {
try {
Thread.sleep(100); // MAGIC_NUMBER
}
catch (InterruptedException e) {
// do nothing
}
retryCounter--;
}
if (file.exists()) {
DialogUtil.displayError(activity, R.string.message_dialog_failed_to_delete_folder, false,
file.getAbsolutePath());
}
else {
activity.runOnUiThread(postActions);
}
}
}.start();
}
/**
* Check is a file is writable. Detects write issues on external SD card.
*
* @param file The file
* @return true if the file is writable.
*/
public static boolean isWritable(@NonNull final File file) {
boolean isExisting = file.exists();
try {
FileOutputStream output = new FileOutputStream(file, true);
try {
output.close();
}
catch (IOException e) {
// do nothing.
}
}
catch (FileNotFoundException e) {
return false;
}
boolean result = file.canWrite();
// Ensure that file is not created during this process.
if (!isExisting) {
//noinspection ResultOfMethodCallIgnored
file.delete();
}
return result;
}
// Utility methods for Android 5
/**
* Check for a directory if it is possible to create files within this directory, either via normal writing or via
* Storage Access Framework.
*
* @param folder The directory
* @return true if it is possible to write in this directory.
*/
@RequiresApi(api = VERSION_CODES.LOLLIPOP)
public static boolean isWritableNormalOrSaf(@Nullable final File folder) {
// Verify that this is a directory.
if (folder == null || !folder.exists() || !folder.isDirectory()) {
return false;
}
// Find a non-existing file in this directory.
int i = 0;
File file;
do {
String fileName = "AugendiagnoseDummyFile" + (++i);
file = new File(folder, fileName);
}
while (file.exists());
// First check regular writability
if (isWritable(file)) {
return true;
}
// Next check SAF writability.
DocumentFile document;
try {
document = getDocumentFile(file, false, false);
}
catch (Exception e) {
return false;
}
if (document == null) {
return false;
}
// This should have created the file - otherwise something is wrong with access URL.
boolean result = document.canWrite() && file.exists();
// Ensure that the dummy file is not remaining.
document.delete();
return result;
}
/**
* Get the SD card directory.
*
* @return The SD card directory.
*/
@NonNull
public static String getSdCardPath() {
String sdCardDirectory = Environment.getExternalStorageDirectory().getAbsolutePath();
try {
sdCardDirectory = new File(sdCardDirectory).getCanonicalPath();
}
catch (IOException ioe) {
Log.e(Application.TAG, "Could not get SD directory", ioe);
}
return sdCardDirectory;
}
/**
* Get a list of external SD card paths. (Kitkat or higher.)
*
* @return A list of external SD card paths.
*/
@RequiresApi(Build.VERSION_CODES.KITKAT)
private static String[] getExtSdCardPaths() {
List<String> paths = new ArrayList<>();
for (File file : Application.getAppContext().getExternalFilesDirs("external")) {
if (file != null && !file.equals(Application.getAppContext().getExternalFilesDir("external"))) {
int index = file.getAbsolutePath().lastIndexOf("/Android/data");
if (index < 0) {
Log.w(Application.TAG, "Unexpected external file dir: " + file.getAbsolutePath());
}
else {
String path = file.getAbsolutePath().substring(0, index);
try {
path = new File(path).getCanonicalPath();
}
catch (IOException e) {
// Keep non-canonical path.
}
paths.add(path);
}
}
}
return paths.toArray(new String[paths.size()]);
}
/**
* Determine the main folder of the external SD card containing the given file.
*
* @param file the file.
* @return The main folder of the external SD card containing this file, if the file is on an SD card. Otherwise,
* null is returned.
*/
@RequiresApi(Build.VERSION_CODES.KITKAT)
public static String getExtSdCardFolder(@NonNull final File file) {
String[] extSdPaths = getExtSdCardPaths();
try {
for (String extSdPath : extSdPaths) {
if (file.getCanonicalPath().startsWith(extSdPath)) {
return extSdPath;
}
}
}
catch (IOException e) {
return null;
}
return null;
}
/**
* Determine if a file is on external sd card. (Kitkat or higher.)
*
* @param file The file.
* @return true if on external sd card.
*/
@RequiresApi(Build.VERSION_CODES.KITKAT)
public static boolean isOnExtSdCard(@NonNull final File file) {
return getExtSdCardFolder(file) != null;
}
/**
* Get a DocumentFile corresponding to the given file (for writing on ExtSdCard on Android 5). If the file is not
* existing, it is created.
*
* @param file The file.
* @param isDirectory flag indicating if the file should be a directory.
* @param createDirectories flag indicating if intermediate path directories should be created if not existing.
* @return The DocumentFile
*/
@RequiresApi(api = VERSION_CODES.LOLLIPOP)
private static DocumentFile getDocumentFile(@NonNull final File file, final boolean isDirectory,
final boolean createDirectories) {
Uri[] treeUris = PreferenceUtil.getTreeUris();
Uri treeUri = null;
if (treeUris.length == 0) {
return null;
}
String fullPath;
try {
fullPath = file.getCanonicalPath();
}
catch (IOException e) {
return null;
}
String baseFolder = null;
// First try to get the base folder via unofficial StorageVolume API from the URIs.
for (int i = 0; baseFolder == null && i < treeUris.length; i++) {
String treeBase = getFullPathFromTreeUri(treeUris[i]);
if (treeBase != null && fullPath.startsWith(treeBase)) {
treeUri = treeUris[i];
baseFolder = treeBase;
}
}
if (baseFolder == null) {
// Alternatively, take root folder from device and assume that base URI works.
treeUri = treeUris[0];
baseFolder = getExtSdCardFolder(file);
}
if (baseFolder == null) {
return null;
}
String relativePath = fullPath.substring(baseFolder.length() + 1);
// start with root of SD card and then parse through document tree.
DocumentFile document = DocumentFile.fromTreeUri(Application.getAppContext(), treeUri);
String[] parts = relativePath.split("\\/");
for (int i = 0; i < parts.length; i++) {
DocumentFile nextDocument = document.findFile(parts[i]);
if (nextDocument == null) {
if (i < parts.length - 1) {
if (createDirectories) {
nextDocument = document.createDirectory(parts[i]);
}
else {
return null;
}
}
else if (isDirectory) {
nextDocument = document.createDirectory(parts[i]);
}
else {
nextDocument = document.createFile("image", parts[i]);
}
}
document = nextDocument;
}
return document;
}
/**
* Get the full path of a document from its tree URI.
*
* @param treeUri The tree RI.
* @return The path (without trailing file separator).
*/
@RequiresApi(api = VERSION_CODES.LOLLIPOP)
@Nullable
private static String getFullPathFromTreeUri(@Nullable final Uri treeUri) {
if (treeUri == null) {
return null;
}
String volumePath = FileUtil.getVolumePath(FileUtil.getVolumeIdFromTreeUri(treeUri));
if (volumePath == null) {
return File.separator;
}
if (volumePath.endsWith(File.separator)) {
volumePath = volumePath.substring(0, volumePath.length() - 1);
}
String documentPath = FileUtil.getDocumentPathFromTreeUri(treeUri);
if (documentPath.endsWith(File.separator)) {
documentPath = documentPath.substring(0, documentPath.length() - 1);
}
if (documentPath.length() > 0) {
if (documentPath.startsWith(File.separator)) {
return volumePath + documentPath;
}
else {
return volumePath + File.separator + documentPath;
}
}
else {
return volumePath;
}
}
/**
* Get the path of a certain volume.
*
* @param volumeId The volume id.
* @return The path.
*/
private static String getVolumePath(final String volumeId) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
return null;
}
try {
StorageManager mStorageManager =
(StorageManager) Application.getAppContext().getSystemService(Context.STORAGE_SERVICE);
Class<?> storageVolumeClazz = Class.forName("android.os.storage.StorageVolume");
Method getVolumeList = mStorageManager.getClass().getMethod("getVolumeList");
Method getUuid = storageVolumeClazz.getMethod("getUuid");
Method getPath = storageVolumeClazz.getMethod("getPath");
Method isPrimary = storageVolumeClazz.getMethod("isPrimary");
Object result = getVolumeList.invoke(mStorageManager);
final int length = Array.getLength(result);
for (int i = 0; i < length; i++) {
Object storageVolumeElement = Array.get(result, i);
String uuid = (String) getUuid.invoke(storageVolumeElement);
Boolean primary = (Boolean) isPrimary.invoke(storageVolumeElement);
// primary volume?
if (primary && PRIMARY_VOLUME_NAME.equals(volumeId)) {
return (String) getPath.invoke(storageVolumeElement);
}
// other volumes?
if (uuid != null) {
if (uuid.equals(volumeId)) {
return (String) getPath.invoke(storageVolumeElement);
}
}
}
// not found.
return null;
}
catch (Exception ex) {
return null;
}
}
/**
* Get the volume ID from the tree URI.
*
* @param treeUri The tree URI.
* @return The volume ID.
*/
@RequiresApi(Build.VERSION_CODES.LOLLIPOP)
private static String getVolumeIdFromTreeUri(final Uri treeUri) {
final String docId = DocumentsContract.getTreeDocumentId(treeUri);
final String[] split = docId.split(":");
if (split.length > 0) {
return split[0];
}
else {
return null;
}
}
/**
* Get the document path (relative to volume name) for a tree URI (LOLLIPOP).
*
* @param treeUri The tree URI.
* @return the document path.
*/
@RequiresApi(Build.VERSION_CODES.LOLLIPOP)
private static String getDocumentPathFromTreeUri(final Uri treeUri) {
final String docId = DocumentsContract.getTreeDocumentId(treeUri);
final String[] split = docId.split(":");
if ((split.length >= 2) && (split[1] != null)) {
return split[1];
}
else {
return File.separator;
}
}
// Utility methods for Kitkat
/**
* Copy a resource file into a private target directory, if the target does not yet exist. Required for the Kitkat
* workaround.
*
* @param resource The resource file.
* @param folderName The folder below app folder where the file is copied to.
* @param targetName The name of the target file.
* @return the dummy file.
* @throws IOException thrown if there are issues while copying.
*/
private static File copyDummyFile(final int resource, final String folderName, @NonNull final String targetName)
throws IOException {
File externalFilesDir = Application.getAppContext().getExternalFilesDir(folderName);
if (externalFilesDir == null) {
return null;
}
File targetFile = new File(externalFilesDir, targetName);
if (!targetFile.exists()) {
InputStream in = null;
OutputStream out = null;
try {
in = Application.getAppContext().getResources().openRawResource(resource);
out = new FileOutputStream(targetFile);
byte[] buffer = new byte[4096]; // MAGIC_NUMBER
int bytesRead;
while ((bytesRead = in.read(buffer)) != -1) {
out.write(buffer, 0, bytesRead);
}
}
finally {
if (in != null) {
try {
in.close();
}
catch (IOException ex) {
// do nothing
}
}
if (out != null) {
try {
out.close();
}
catch (IOException ex) {
// do nothing
}
}
}
}
return targetFile;
}
/**
* Copy the dummy image and dummy mp3 into the private folder, if not yet there. Required for the Kitkat workaround.
*
* @return the dummy mp3.
*/
private static File copyDummyFiles() {
try {
copyDummyFile(R.raw.albumart, "mkdirFiles", "albumart.jpg");
return copyDummyFile(R.raw.silence, "mkdirFiles", "silence.mp3");
}
catch (IOException e) {
Log.e(Application.TAG, "Could not copy dummy files.", e);
return null;
}
}
}