package com.door43.translationstudio.util; import android.annotation.TargetApi; import android.app.Activity; import android.content.Intent; import android.net.Uri; import android.os.Build; import android.os.Environment; import android.support.v4.os.EnvironmentCompat; import android.support.v4.provider.DocumentFile; import com.door43.tools.reporting.Logger; import com.door43.translationstudio.AppContext; import com.door43.translationstudio.SettingsActivity; import com.door43.util.StorageUtils; import org.apache.commons.io.FilenameUtils; import org.apache.commons.io.IOUtils; import java.io.BufferedReader; import java.io.File; import java.io.InputStream; import java.io.InputStreamReader; import java.io.OutputStream; import java.util.ArrayList; import java.util.List; /** * Created by blm on 12/30/15. * Utilities to make it easier to work with SD card access, and in particular * the special DOcumentFile access introduced with Lollipop */ public class SdUtils { public static final String DOWNLOAD_FOLDER = "/Download"; public static final String DOWNLOAD_TRANSLATION_STUDIO_FOLDER = DOWNLOAD_FOLDER + "/" + AppContext.TRANSLATION_STUDIO; private static String sdCardPath = ""; private static boolean alreadyReadSdCardDirectory = false; private static String verifiedSdCardPath = ""; public static final int REQUEST_CODE_STORAGE_ACCESS = 42; /** * combines string array into single string * @param parts * @param delimeter * @return */ public static String joinString(String[] parts, String delimeter) { StringBuilder sbStr = new StringBuilder(); for (int i = 0, il = parts.length; i < il; i++) { if (i > 0) { sbStr.append(delimeter); } sbStr.append(parts[i]); } return sbStr.toString(); } /** * Gets human readable path string * @param dir * @return */ public static String getPathString(DocumentFile dir) { if(null == dir) { return "<null>"; } String uriStr = dir.getUri().toString(); return getPathString(uriStr); } /** * Gets human readable path string * @param dir * @return */ public static String getPathString(final String dir) { final String FILE = "file://"; final String CONTENT = "content://"; final String CONTENT_DIVIDER = "%3A"; final String FOLDER_MARKER = "%2F"; if(null == dir) { return "<null>"; } String uriStr = dir; int pos = uriStr.indexOf(FILE); if(pos >= 0) { String showPath = uriStr.substring(pos + FILE.length()); Logger.i(SdUtils.class.getName(), "converting File path from '" + dir + "' to '" + showPath + "'"); return showPath; } pos = uriStr.indexOf(CONTENT); if(pos >= 0) { pos = uriStr.lastIndexOf(CONTENT_DIVIDER); if(pos >= 0) { String subPath = uriStr.substring(pos + CONTENT_DIVIDER.length()); String[] parts = subPath.split(FOLDER_MARKER); String showPath = "SD_CARD/" + joinString(parts, "/"); Logger.i(SdUtils.class.getName(), "converting SD card path from '" + dir + "' to '" + showPath + "'"); return showPath; } } return uriStr; } /** * returns true if we need to enable SD card access */ public static boolean doWeNeedToRequestSdCardAccess() { Logger.i(SdUtils.class.getName(), "version API: " + Build.VERSION.SDK_INT); Logger.i(SdUtils.class.getName(), "Environment.getExternalStorageDirectory(): " + Environment.getExternalStorageDirectory()); Logger.i(SdUtils.class.getName(), "Environment.getExternalStorageState(): " + Environment.getExternalStorageState()); restoreSdCardWriteAccess(); // only does something if supported on device if (!isSdCardAccessable()) { // if accessable, we do not need to request access if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { // can only request access if lollipop or greater if( isSdCardPresentLollipop() ) { return true; // if there is an SD card present, is there any point in requesting access } } } return false; } /** * if available, this triggers browser dialog for user to select SD card folder to allow access * @param context */ public static void triggerStorageAccessFramework(final Activity context) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { // doesn't look like this is possible on Kitkat Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE); context.startActivityForResult(intent, REQUEST_CODE_STORAGE_ACCESS); } else { Logger.w(SdUtils.class.toString(),"triggerStorageAccessFramework: not supported for " + Build.VERSION.SDK_INT); } } /** * persists write permission for SD card access * @param sdUri - uri to persist * @param flags - permission flags * @return */ @TargetApi(Build.VERSION_CODES.KITKAT) public static boolean validateSdCardWriteAccess(final Uri sdUri, final int flags) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) { return true; } boolean success = persistSdCardWriteAccess(sdUri, flags); String sdCardActualFolder = findSdCardFolder(); if(sdCardActualFolder != null) { Logger.i(SdUtils.class.getName(), "found card at = " + sdCardActualFolder); } else { Logger.i(SdUtils.class.getName(), "invalid access Uri = " + sdUri); storeSdCardAccess(null, 0); // clear value since invalid success = false; } return success; } /** * persists write permission for SD card access * @param sdUri - uri to persist * @param flags - permission flags * @return */ @TargetApi(Build.VERSION_CODES.KITKAT) public static boolean persistSdCardWriteAccess(final Uri sdUri, final int flags) { if(Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) { return true; } storeSdCardAccess(sdUri, flags); restoreSdCardWriteAccess(); // apply settings boolean success = isSdCardAccessable(); if(!success) { storeSdCardAccess(null, 0); // clear value since invalid } return success; } private static void storeSdCardAccess(Uri sdUri, int flags) { String uriStr = (null == sdUri) ? null : sdUri.toString(); AppContext.setUserString(SettingsActivity.KEY_SDCARD_ACCESS_URI, uriStr); AppContext.setUserString(SettingsActivity.KEY_SDCARD_ACCESS_FLAGS, String.valueOf(flags)); Logger.i(SdUtils.class.getName(), "URI = " + sdUri); verifiedSdCardPath = ""; // reset persisted path to SD card, will need to find it again } /** * restores previously granted write permission for SD card access * @return */ public static boolean restoreSdCardWriteAccess() { if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { String flagStr = AppContext.getUserString(SettingsActivity.KEY_SDCARD_ACCESS_FLAGS, null); String path = AppContext.getUserString(SettingsActivity.KEY_SDCARD_ACCESS_URI, null); if ((path != null) && (flagStr != null)) { Integer flags = Integer.parseInt(flagStr); Uri sdUri = Uri.parse(path); Logger.i(SdUtils.class.getName(), "Restore URI = " + sdUri.toString()); applyPermissions(sdUri, flags); return true; } } return false; } public static boolean applyPermissions(Uri sdUri, Integer flags) { if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { Logger.i(SdUtils.class.getName(), "Apply permissions to URI '" + sdUri.toString() + "' flags: " + flags); int takeFlags = flags & (Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION); AppContext.context().grantUriPermission(AppContext.context().getPackageName(), sdUri, takeFlags); //TODO 12/22/2015 need to find way to remove this warning AppContext.context().getContentResolver().takePersistableUriPermission(sdUri, takeFlags); return true; } return false; } /** * reads the stored URI for SD card access * @return */ public static String getSdCardAccessUriStr() { String path = AppContext.getUserString(SettingsActivity.KEY_SDCARD_ACCESS_URI, null); return path; } /** * gets the SD card downloads folder * @return */ public static DocumentFile getSdCardDownloadsFolder() { DocumentFile downloadFolder = sdCardMkdirs( DOWNLOAD_FOLDER); return downloadFolder; } /** * Returns true if an external SD card is present and writeable on Android Lollipop or greater * @return */ public static boolean isSdCardPresentLollipop() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { if (!verifiedSdCardPath.isEmpty()) { // see if we already have detected an SD card this session return true; } // see if we already have enabled access if( getSdCardAccessUriStr() != null) { // if user has already chosen a path for SD card if(sdCardMkdirs(null) != null) { // verify card is still present and writeable return true; } } // rough check to see if SD card is currently present File sdCard = getSdCardDirectory(); if(sdCard != null) { return true; } } return false; } /** * Returns the SD card directory. Warning this may not be writeable. * Just a rough check to see if one is possibly present. * @return */ public static File getSdCardDirectory() { if (!verifiedSdCardPath.isEmpty()) { return new File(verifiedSdCardPath); } if(alreadyReadSdCardDirectory) { if (!sdCardPath.isEmpty()) { return new File(sdCardPath); } else { return null; } } alreadyReadSdCardDirectory = true; String[] mounts = getExternalDirectories(); try { if( (mounts != null) && (mounts.length > 0)) { String path = null; for(String mount:mounts) { File mountFile = new File(mount); String state = EnvironmentCompat.getStorageState(mountFile); boolean mounted = Environment.MEDIA_MOUNTED.equals(state); if(mounted) { if(mount.toLowerCase().indexOf("emulated") < 0) { path = mount; break; } } } if(path != null) { File absolute = new File(path).getCanonicalFile(); sdCardPath = absolute.toString(); // cache value return absolute; } sdCardPath = ""; } } catch (Exception e) { Logger.w(SdUtils.class.toString(),"Error getting external card folder", e); } return null; } /** * Returns list of all the external directories. Requires additional checking to see if any of * these belong to external SD card. * @return */ public static String[] getExternalDirectories() { List<String> mounts = new ArrayList<>(); try { Runtime runtime = Runtime.getRuntime(); Process proc = runtime.exec("mount"); InputStream is = proc.getInputStream(); InputStreamReader isr = new InputStreamReader(is); String line; BufferedReader br = new BufferedReader(isr); while ((line = br.readLine()) != null) { if (line.contains("secure")) continue; if (line.contains("asec")) continue; Logger.i(SdUtils.class.getName(),"Checking: " + line); if (line.contains("fat")) {//TF card String columns[] = line.split(" "); if (columns != null && columns.length > 1) { mounts.add(0,columns[1]); Logger.i(SdUtils.class.getName(), "Adding: " + columns[1]); } } else if (line.contains("fuse")) {//internal storage String columns[] = line.split(" "); if (columns != null && columns.length > 1) { mounts.add(columns[1]); Logger.i(SdUtils.class.getName(), "Adding: " + columns[1]); } } } return mounts.toArray(new String[mounts.size()]); } catch (Exception e) { Logger.w(SdUtils.class.toString(),"Error getting external card folder", e); } return null; } /** * get external storage folder - may not be mounted * @return */ private static File getLegacyExternalStorageDirectory() { String path = System.getenv("EXTERNAL_STORAGE"); return new File(path); } /** * Checks if the external media is mounted and writeable * @return */ public static boolean isSdCardAccessable() { // TRICKY: KITKAT introduced changes to the external media that made sd cards read only, // and now starting with Lollipop the user has to grant access permission if(Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) { File sdCard = getSdCardDirectory(); return sdCard != null; } else { DocumentFile sdCard = sdCardMkdirs(null); return sdCard != null; } } /** * Searches and verifies write location on SD card * @return */ public static String findSdCardFolder() { if(!verifiedSdCardPath.isEmpty()) { return verifiedSdCardPath; } String[] mounts = getExternalDirectories(); String sdPath = null; final String testFolder = "__testing.dir__"; if(null == mounts) { return null; } DocumentFile sdCardTempFolder = sdCardMkdirs(testFolder); boolean success = sdCardTempFolder != null; if (success) { if (sdCardTempFolder.canWrite()) { DocumentFile file = sdCardTempFolder.createFile("text/plain", "_zzztestzzz_.txt"); // make sure URI write accessable String testData = "test Data"; success = documentFolderWrite(file, testData, false); // make sure we can write if(file.length() < testData.length()) { success = false; } file.delete(); // cleanup after use try { if(success) { if (mounts.length > 0) { for (String mount : mounts) { final String externalStorageState = EnvironmentCompat.getStorageState(new File(mount)); boolean mounted = Environment.MEDIA_MOUNTED.equals(externalStorageState); if (!mounted) { // do a double check continue; } File testFolderFile = new File(mount, testFolder); boolean isPresent = testFolderFile.exists(); if (isPresent) { Logger.i(SdUtils.class.toString(), "found folder: " + testFolderFile.toString()); sdPath = mount; break; } } // end for mounts } if (null == sdPath) { Logger.i(SdUtils.class.toString(), "SD card folder not found"); } else { verifiedSdCardPath = sdPath; } } sdCardTempFolder.delete(); // remove test folder } catch (Exception e) { Logger.w(SdUtils.class.toString(),"Error getting external card folder", e); } } } return sdPath; } /** * write string to document file * @param document - document to write * @param data - text to write to file * @param append - if true then data is appended to file, if false then it is overwritten * @return */ public static boolean documentFolderWrite(final DocumentFile document, final String data, final boolean append) { boolean success = true; OutputStream fout = null; try { fout = AppContext.context().getContentResolver().openOutputStream(document.getUri()); fout.write(data.getBytes()); fout.close(); } catch (Exception e) { Logger.i(SdUtils.class.getName(), "Could not write to folder"); success = false; // write failed } finally { IOUtils.closeQuietly(fout); } return success; } /** * creates and returns the selected subfolder on SD card. Returns null if error. * @param subFolderName - name of subfolder to move to * @return */ public static DocumentFile sdCardMkdirs(final String subFolderName) { String sdCardFolderUriStr = getSdCardAccessUriStr(); if(null == sdCardFolderUriStr) { return null; } Uri sdCardFolderUri = Uri.parse(sdCardFolderUriStr); DocumentFile document = DocumentFile.fromTreeUri(AppContext.context(), sdCardFolderUri); DocumentFile subDocument = documentFileMkdirs(document, subFolderName); if ( (subDocument != null) && subDocument.isDirectory() && subDocument.canWrite() ) { return subDocument; } return null; } /** * creates and returns the selected subfolder. Returns null if error. * @param baseFolder - base folder * @param subFolderName - name of subfolder to move to * @return */ public static DocumentFile documentFileMkdirs(final DocumentFile baseFolder, final String subFolderName) { return traverseSubDocFolders(baseFolder, subFolderName, true); } /** * get the selected subfolder recursively or null if not present * @param baseFolder - base folder * @param subFolderName - name of subfolder to move to * @return */ public static DocumentFile documentFileChgdirs(final DocumentFile baseFolder, final String subFolderName) { return traverseSubDocFolders(baseFolder, subFolderName, false); } /** * get the selected subfolder recursively or null if error * @param baseFolder - base folder * @param subFolderName - name of subfolder to move to * @param createFolders - if true then missing folders will be created * @return */ private static DocumentFile traverseSubDocFolders( DocumentFile baseFolder, final String subFolderName, boolean createFolders) { if(null == baseFolder) { return null; } if(subFolderName != null) { String[] parts = subFolderName.split("\\/"); if (parts.length < 1) { return null; } for (int i = 0; i < parts.length; i++) { if (parts[i].isEmpty()) { // skip over extraneous slashes continue; } DocumentFile nextDocument = documentFolderChgdir(baseFolder, parts[i], createFolders); if (null == nextDocument) { return null; } baseFolder = nextDocument; } } return baseFolder; } /** * get the selected subfolder or null if error * @param baseFolder - base folder * @param subFolderName - name of subfolder to move to * @param createFolders - if true then missing folders will be created * @return */ private static DocumentFile documentFolderChgdir(final DocumentFile baseFolder, final String subFolderName, boolean createFolders) { if(baseFolder == null) { return null; } DocumentFile nextDocument = baseFolder.findFile(subFolderName); try { if( (nextDocument == null) && createFolders) { nextDocument = baseFolder.createDirectory(subFolderName); } } catch (Exception e) { Logger.w(SdUtils.class.getName(),"Failed to create folder", e); return null; } return nextDocument; } /** * find first instance of file in folder or null if not found * @param folder - folder to search * @param fileName - filename to find * @return */ public static DocumentFile documentFileFind(final DocumentFile folder, final String fileName) { if(folder == null) { return null; } DocumentFile nextDocument = folder.findFile(fileName); return nextDocument; } /** * finds first instance of file type in folder * @param baseFolder - base folder for search * @param extension - extension to match * @return */ public static String searchFolderAndParentsForDocFile(final DocumentFile baseFolder, final String extension) { if(null == baseFolder) { return null; } String[] paths = new String[]{ DOWNLOAD_TRANSLATION_STUDIO_FOLDER, DOWNLOAD_FOLDER, ""}; // try in this order for(String path: paths) { DocumentFile document = documentFileChgdirs(baseFolder, path); if (null == document) { continue; } DocumentFile[] files = document.listFiles(); for(DocumentFile file: files) { String ext = FilenameUtils.getExtension(file.getName()); if(ext != null) { if (ext.toLowerCase().equals(extension)) { if ((file != null) && file.canRead()) { return path; } } } } } return null; } }