open-keychain/OpenKeychain/src/main/java/org/sufficientlysecure/keychain/util/FileHelper.java

393 lines
14 KiB
Java

/*
* Copyright (C) 2017 Schürmann & Breitmoser GbR
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.sufficientlysecure.keychain.util;
import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.BufferedReader;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.io.UnsupportedEncodingException;
import java.security.SecureRandom;
import java.text.DecimalFormat;
import java.util.List;
import android.annotation.TargetApi;
import android.content.ActivityNotFoundException;
import android.content.ContentResolver;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.content.pm.ResolveInfo;
import android.database.Cursor;
import android.graphics.Bitmap;
import android.graphics.Point;
import android.net.Uri;
import android.os.Build;
import android.os.Build.VERSION_CODES;
import android.os.Environment;
import android.provider.DocumentsContract;
import android.provider.OpenableColumns;
import android.support.v4.app.Fragment;
import android.widget.Toast;
import org.sufficientlysecure.keychain.Constants;
import org.sufficientlysecure.keychain.R;
import timber.log.Timber;
/** This class offers a number of helper functions for saving documents.
*
* There are three entry points here: openDocument, saveDocument and
* saveDocumentDialog. Each behaves a little differently depending on whether
* the Android version used is pre or post KitKat.
*
* - openDocument queries for a document for reading. Used in "open encrypted
* file" ui flow. On pre-kitkat, this relies on an external file manager,
* and will fail with a toast message if none is installed.
*
* - saveDocument queries for a document name for saving. on pre-kitkat, this
* shows a dialog where a filename can be input. on kitkat and up, it
* directly triggers a "save document" intent. Used in "save encrypted file"
* ui flow.
*
* - saveDocumentDialog queries for a document. this shows a dialog on all
* versions of android. the browse button opens an external browser on
* pre-kitkat or the "save document" intent on post-kitkat devices. Used in
* "backup key" ui flow.
*
* It is noteworthy that the "saveDocument" call is essentially substituted
* by the "saveDocumentDialog" on pre-kitkat devices.
*
*/
public class FileHelper {
private static Boolean hasOpenDocumentIntent;
@TargetApi(VERSION_CODES.KITKAT)
public static void saveDocument(Fragment fragment, String targetName, int requestCode) {
saveDocument(fragment, targetName, "*/*", requestCode);
}
/** Opens the storage browser on Android 4.4 or later for saving a file. */
@TargetApi(VERSION_CODES.KITKAT)
public static void saveDocument(Fragment fragment, String suggestedName, String mimeType, int requestCode) {
Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT);
intent.addCategory(Intent.CATEGORY_OPENABLE);
intent.setType(mimeType);
// Note: This is not documented, but works: Show the Internal Storage menu item in the drawer!
intent.putExtra("android.content.extra.SHOW_ADVANCED", true);
intent.putExtra(Intent.EXTRA_TITLE, suggestedName);
fragment.startActivityForResult(intent, requestCode);
}
public static void openDocument(Fragment fragment, String mimeType, boolean multiple, int requestCode) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT
&& hasOpenDocumentIntent(fragment.getContext())) {
openDocumentKitKat(fragment, mimeType, multiple, requestCode);
} else {
openDocumentPreKitKat(fragment, mimeType, multiple, requestCode);
}
}
/** Opens the preferred installed file manager on Android and shows a toast
* if no manager is installed. */
private static void openDocumentPreKitKat(Fragment fragment, String mimeType, boolean multiple, int requestCode) {
Intent intent = new Intent(Intent.ACTION_GET_CONTENT);
intent.addCategory(Intent.CATEGORY_OPENABLE);
if (Build.VERSION.SDK_INT >= VERSION_CODES.JELLY_BEAN_MR2) {
intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, multiple);
}
intent.setType(mimeType);
try {
fragment.startActivityForResult(intent, requestCode);
} catch (ActivityNotFoundException e) {
// No compatible file manager was found.
Toast.makeText(fragment.getActivity(), R.string.no_filemanager_installed,
Toast.LENGTH_SHORT).show();
}
}
/** Opens the storage browser on Android 4.4 or later for opening a file */
@TargetApi(Build.VERSION_CODES.KITKAT)
private static void openDocumentKitKat(Fragment fragment, String mimeType, boolean multiple, int requestCode) {
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
intent.addCategory(Intent.CATEGORY_OPENABLE);
intent.setType(mimeType);
// Note: This is not documented, but works: Show the Internal Storage menu item in the drawer!
intent.putExtra("android.content.extra.SHOW_ADVANCED", true);
intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, multiple);
fragment.startActivityForResult(intent, requestCode);
}
/**
* Does the device actually have a ACTION_OPEN_DOCUMENT Intent? Looks like some Android
* distributions are missing the ACTION_OPEN_DOCUMENT Intent even on Android 4.4,
* see https://github.com/open-keychain/open-keychain/issues/1625
*
* @return True, if the device supports ACTION_OPEN_DOCUMENT. False, otherwise.
*/
@TargetApi(VERSION_CODES.KITKAT)
private static boolean hasOpenDocumentIntent(Context context) {
if (hasOpenDocumentIntent == null) {
PackageManager packageManager = context.getPackageManager();
List<ResolveInfo> resolveInfoList = packageManager.queryIntentActivities(
new Intent(Intent.ACTION_OPEN_DOCUMENT), 0);
hasOpenDocumentIntent = !resolveInfoList.isEmpty();
}
return hasOpenDocumentIntent;
}
public static String getFilename(Context context, Uri uri) {
String filename = null;
try {
Cursor cursor = context.getContentResolver().query(uri, new String[]{OpenableColumns.DISPLAY_NAME}, null, null, null);
if (cursor != null) {
if (cursor.moveToNext()) {
filename = cursor.getString(0);
}
cursor.close();
}
} catch (Exception ignored) {
// This happens in rare cases (eg: document deleted since selection) and should not cause a failure
}
if (filename == null) {
String[] split = uri.toString().split("/");
filename = split[split.length - 1];
}
return filename;
}
public static long getFileSize(Context context, Uri uri) {
return getFileSize(context, uri, -1);
}
public static long getFileSize(Context context, Uri uri, long def) {
if (ContentResolver.SCHEME_FILE.equals(uri.getScheme())) {
long size = new File(uri.getPath()).length();
if (size == 0) {
size = def;
}
return size;
}
long size = def;
try {
Cursor cursor = context.getContentResolver().query(uri, new String[]{OpenableColumns.SIZE}, null, null, null);
if (cursor != null) {
if (cursor.moveToNext()) {
size = cursor.getLong(0);
}
cursor.close();
}
} catch (Exception ignored) {
// This happens in rare cases (eg: document deleted since selection) and should not cause a failure
}
return size;
}
/**
* Retrieve thumbnail of file, document api feature and thus KitKat only
*/
public static Bitmap getThumbnail(Context context, Uri uri, Point size) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
try {
return DocumentsContract.getDocumentThumbnail(context.getContentResolver(), uri, size, null);
} catch (Exception e) {
return null;
}
} else {
return null;
}
}
public static boolean isEncryptedFile(Context context, Uri uri) throws IOException {
boolean isEncrypted = false;
BufferedReader br = null;
try {
InputStream is = context.getContentResolver().openInputStream(uri);
br = new BufferedReader(new InputStreamReader(is));
String header = "-----BEGIN PGP MESSAGE-----";
int length = header.length();
char[] buffer = new char[length];
if (br.read(buffer, 0, length) == length) {
isEncrypted = new String(buffer).equals(header);
}
} finally {
try {
if (br != null)
br.close();
} catch (IOException e) {
Timber.e(e, "Error closing file");
}
}
return isEncrypted;
}
public static String readableFileSize(long size) {
if (size <= 0) return "0";
final String[] units = new String[]{"B", "KB", "MB", "GB", "TB"};
int digitGroups = (int) (Math.log10(size) / Math.log10(1024));
return new DecimalFormat("#,##0.#").format(size / Math.pow(1024, digitGroups)) + " " + units[digitGroups];
}
public static String readTextFromUri(Context context, Uri outputUri, String charset)
throws IOException {
byte[] decryptedMessage;
{
InputStream in = context.getContentResolver().openInputStream(outputUri);
ByteArrayOutputStream out = new ByteArrayOutputStream();
byte[] buf = new byte[256];
int read;
while ( (read = in.read(buf)) > 0) {
out.write(buf, 0, read);
}
in.close();
out.close();
decryptedMessage = out.toByteArray();
}
String plaintext;
if (charset != null) {
try {
plaintext = new String(decryptedMessage, charset);
} catch (UnsupportedEncodingException e) {
// if we can't decode properly, just fall back to utf-8
plaintext = new String(decryptedMessage);
}
} else {
plaintext = new String(decryptedMessage);
}
return plaintext;
}
public static void copyUriData(Context context, Uri fromUri, Uri toUri) throws IOException {
BufferedInputStream bis = null;
BufferedOutputStream bos = null;
try {
ContentResolver resolver = context.getContentResolver();
bis = new BufferedInputStream(resolver.openInputStream(fromUri));
bos = new BufferedOutputStream(resolver.openOutputStream(toUri));
byte[] buf = new byte[1024];
int len;
while ( (len = bis.read(buf)) > 0) {
bos.write(buf, 0, len);
}
} finally {
try {
if (bis != null) {
bis.close();
}
if (bos != null) {
bos.close();
}
} catch (IOException e) {
// ignore, it's just stream closin'
}
}
}
/**
* Deletes data at a URI securely by overwriting it with random data
* before deleting it. This method is fail-fast - if we can't securely
* delete the file, we don't delete it at all.
*/
public static int deleteFileSecurely(Context context, Uri uri)
throws IOException {
ContentResolver resolver = context.getContentResolver();
long lengthLeft = FileHelper.getFileSize(context, uri);
if (lengthLeft == -1) {
throw new IOException("Error opening file!");
}
SecureRandom random = new SecureRandom();
byte[] randomData = new byte[1024];
OutputStream out = resolver.openOutputStream(uri, "w");
if (out == null) {
throw new IOException("Error opening file!");
}
out = new BufferedOutputStream(out);
while (lengthLeft > 0) {
random.nextBytes(randomData);
out.write(randomData, 0, lengthLeft > randomData.length ? randomData.length : (int) lengthLeft);
lengthLeft -= randomData.length;
}
out.close();
if (ContentResolver.SCHEME_FILE.equals(uri.getScheme())) {
return new File(uri.getPath()).delete() ? 1 : 0;
} else {
return resolver.delete(uri, null, null);
}
}
/** Checks if external storage is mounted if file is located on external storage. */
public static boolean isStorageMounted(String file) {
if (file.startsWith(Environment.getExternalStorageDirectory().getAbsolutePath())) {
if (!Environment.getExternalStorageState().equals(Environment.MEDIA_MOUNTED)) {
return false;
}
}
return true;
}
/** A replacement for ContentResolver.openInputStream() that does not allow
* the usage of "file" Uris that point to private files owned by the
* application only, *on Lollipop devices*.
*
* The check will be performed on devices >= Lollipop only, which have the
* necessary API to stat filedescriptors.
*
* @see FileHelperLollipop
*/
public static InputStream openInputStreamSafe(ContentResolver resolver, Uri uri)
throws FileNotFoundException {
// Not supported on Android < 5
if (Build.VERSION.SDK_INT >= VERSION_CODES.LOLLIPOP) {
return FileHelperLollipop.openInputStreamSafe(resolver, uri);
} else {
return resolver.openInputStream(uri);
}
}
}