package cgeo.geocaching.storage; import cgeo.geocaching.CgeoApplication; import cgeo.geocaching.R; import cgeo.geocaching.activity.Progress; import cgeo.geocaching.settings.Settings; import cgeo.geocaching.settings.SettingsActivity; import cgeo.geocaching.ui.dialog.Dialogs; import cgeo.geocaching.utils.AndroidRxUtils; import cgeo.geocaching.utils.EnvironmentUtils; import cgeo.geocaching.utils.FileUtils; import cgeo.geocaching.utils.Log; import android.app.ProgressDialog; import android.os.Environment; import android.support.annotation.NonNull; import android.support.v4.content.ContextCompat; import android.support.v4.os.EnvironmentCompat; import java.io.File; import java.io.IOException; import java.util.ArrayList; import java.util.List; import java.util.concurrent.Callable; import java.util.regex.Pattern; import io.reactivex.Observable; import io.reactivex.functions.Consumer; import io.reactivex.schedulers.Schedulers; import org.apache.commons.lang3.ArrayUtils; import org.apache.commons.lang3.CharEncoding; import org.apache.commons.lang3.StringUtils; /** * Handle local storage issues on phone and SD card. */ public final class LocalStorage { public static final Pattern GEOCACHE_FILE_PATTERN = Pattern.compile("^(GC|TB|TC|CC|LC|EC|GK|MV|TR|VI|MS|EV|CT|GE|GA|WM|O)[A-Z0-9]{2,7}$"); private static final String FILE_SYSTEM_TABLE_PATH = "/system/etc/vold.fstab"; private static final String CGEO_DIRNAME = "cgeo"; private static final String DATABASES_DIRNAME = "databases"; private static final String BACKUP_DIR_NAME = "backup"; private static final String GPX_DIR_NAME = "gpx"; private static final String FIELD_NOTES_DIR_NAME = "field-notes"; private static final String LEGACY_CGEO_DIR_NAME = ".cgeo"; private static final String GEOCACHE_PHOTOS_DIR_NAME = "GeocachePhotos"; private static final String GEOCACHE_DATA_DIR_NAME = "GeocacheData"; private static final long LOW_DISKSPACE_THRESHOLD = 1024 * 1024 * 100; // 100 MB in bytes private static File internalCgeoDirectory; private static File externalPrivateCgeoDirectory; private static File externalPublicCgeoDirectory; private LocalStorage() { // utility class } /** * Usually <pre>/data/data/cgeo.geocaching</pre> */ @NonNull public static File getInternalCgeoDirectory() { if (internalCgeoDirectory == null) { // A race condition will do no harm as the operation is idempotent. No need to synchronize. internalCgeoDirectory = CgeoApplication.getInstance().getApplicationContext().getFilesDir().getParentFile(); } return internalCgeoDirectory; } /** * Returns all available external private cgeo directories, e.g.: * <pre> * /sdcard/Android/data/cgeo.geocaching/files * /storage/emulated/0/Android/data/cgeo.geocaching/files * /storage/extSdCard/Android/data/cgeo.geocaching/files * /mnt/sdcard/Android/data/cgeo.geocaching/files * /storage/sdcard1/Android/data/cgeo.geocaching/files * </pre> */ @NonNull public static List<File> getAvailableExternalPrivateCgeoDirectories() { final List<File> availableExtDirs = new ArrayList<>(); final File[] externalFilesDirs = ContextCompat.getExternalFilesDirs(CgeoApplication.getInstance(), null); for (final File dir : externalFilesDirs) { if (dir != null && EnvironmentCompat.getStorageState(dir).equals(Environment.MEDIA_MOUNTED)) { availableExtDirs.add(dir); Log.i("Added '" + dir + "' as available external dir"); } else { Log.w("'" + dir + "' is NOT available as external dir"); } } return availableExtDirs; } /** * Usually one of {@link LocalStorage#getAvailableExternalPrivateCgeoDirectories()}. * Fallback to {@link LocalStorage#getFirstExternalPrivateCgeoDirectory()} */ @NonNull public static File getExternalPrivateCgeoDirectory() { if (externalPrivateCgeoDirectory == null) { // find the one selected in preferences final String prefDirectory = Settings.getExternalPrivateCgeoDirectory(); for (final File dir : getAvailableExternalPrivateCgeoDirectories()) { if (dir.getAbsolutePath().equals(prefDirectory)) { externalPrivateCgeoDirectory = dir; break; } } // fallback to default external files dir if (externalPrivateCgeoDirectory == null) { Log.w("Chosen extCgeoDir " + prefDirectory + " is not an available external dir, falling back to default extCgeoDir"); externalPrivateCgeoDirectory = getFirstExternalPrivateCgeoDirectory(); } if (prefDirectory == null) { Settings.setExternalPrivateCgeoDirectory(externalPrivateCgeoDirectory.getAbsolutePath()); } } return externalPrivateCgeoDirectory; } /** * Uses {@link android.content.Context#getExternalFilesDir(String)} with "null". * This is usually the emulated external storage. * It falls back to {@link LocalStorage#getInternalCgeoDirectory()}. */ @NonNull public static File getFirstExternalPrivateCgeoDirectory() { final File externalFilesDir = CgeoApplication.getInstance().getExternalFilesDir(null); // fallback to internal dir if (externalFilesDir == null) { Log.w("No extCgeoDir is available, falling back to internal storage"); return getInternalCgeoDirectory(); } return externalFilesDir; } @NonNull public static File getExternalDbDirectory() { return new File(getFirstExternalPrivateCgeoDirectory(), DATABASES_DIRNAME); } @NonNull public static File getInternalDbDirectory() { return new File(getInternalCgeoDirectory(), DATABASES_DIRNAME); } /** * Get the primary geocache data directory for a geocode. A null or empty geocode will be replaced by a default * value. * * @param geocode * the geocode * @return the geocache data directory */ @NonNull public static File getGeocacheDataDirectory(@NonNull final String geocode) { return new File(getGeocacheDataDirectory(), geocode); } /** * Get the primary file corresponding to a geocode and a file name or an url. If it is an url, an appropriate * filename will be built by hashing it. The directory structure will be created if needed. * A null or empty geocode will be replaced by a default value. * * @param geocode * the geocode * @param fileNameOrUrl * the file name or url * @param isUrl * true if an url was given, false if a file name was given * @return the file */ @NonNull public static File getGeocacheDataFile(@NonNull final String geocode, @NonNull final String fileNameOrUrl, final boolean isUrl, final boolean createDirs) { return FileUtils.buildFile(getGeocacheDataDirectory(geocode), fileNameOrUrl, isUrl, createDirs); } /** * Check if an external media (SD card) is available for use. * * @return true if the external media is properly mounted */ public static boolean isExternalStorageAvailable() { return EnvironmentUtils.isExternalStorageAvailable(); } /** * Deletes all files from geocode cache directory with the given prefix. * * @param geocode * The geocode identifying the cache directory * @param prefix * The filename prefix */ public static void deleteCacheFilesWithPrefix(@NonNull final String geocode, @NonNull final String prefix) { FileUtils.deleteFilesWithPrefix(getGeocacheDataDirectory(geocode), prefix); } /** * Get all storages available on the device. * Will include paths like /mnt/sdcard /mnt/usbdisk /mnt/ext_card /mnt/sdcard/ext_card */ @NonNull public static List<File> getStorages() { final String extStorage = Environment.getExternalStorageDirectory().getAbsolutePath(); final List<File> storages = new ArrayList<>(); storages.add(new File(extStorage)); final File file = new File(FILE_SYSTEM_TABLE_PATH); if (file.canRead()) { try { for (final String str : org.apache.commons.io.FileUtils.readLines(file, CharEncoding.UTF_8)) { if (str.startsWith("dev_mount")) { final String[] tokens = StringUtils.split(str); if (tokens.length >= 3) { final String path = tokens[2]; // mountpoint if (!extStorage.equals(path)) { final File directory = new File(path); if (directory.exists() && directory.isDirectory()) { storages.add(directory); } } } } } } catch (final IOException e) { Log.e("Could not get additional mount points for user content. " + "Proceeding with external storage only (" + extStorage + ")", e); } } return storages; } /** * Returns the external public cgeo directory, something like <pre>/sdcard/cgeo</pre>. * It falls back to the internal cgeo directory if the external is not available. */ @NonNull public static File getExternalPublicCgeoDirectory() { if (externalPublicCgeoDirectory == null) { externalPublicCgeoDirectory = new File(Environment.getExternalStorageDirectory().getAbsolutePath(), CGEO_DIRNAME); FileUtils.mkdirs(externalPublicCgeoDirectory); if (!externalPublicCgeoDirectory.exists() || !externalPublicCgeoDirectory.canWrite()) { Log.w("External public cgeo directory '" + externalPublicCgeoDirectory + "' not available"); externalPublicCgeoDirectory = getInternalCgeoDirectory(); Log.i("Fallback to internal storage: " + externalPublicCgeoDirectory); } } return externalPublicCgeoDirectory; } @NonNull public static File getFieldNotesDirectory() { return new File(getExternalPublicCgeoDirectory(), FIELD_NOTES_DIR_NAME); } @NonNull public static File getLegacyFieldNotesDirectory() { return new File(Environment.getExternalStorageDirectory(), FIELD_NOTES_DIR_NAME); } @NonNull public static File getDefaultGpxDirectory() { return new File(getExternalPublicCgeoDirectory(), GPX_DIR_NAME); } @NonNull public static File getGpxExportDirectory() { return new File(Settings.getGpxExportDir()); } @NonNull public static File getGpxImportDirectory() { return new File(Settings.getGpxImportDir()); } @NonNull public static File getLegacyGpxDirectory() { return new File(Environment.getExternalStorageDirectory(), GPX_DIR_NAME); } @NonNull public static File getLegacyExternalCgeoDirectory() { return new File(Environment.getExternalStorageDirectory(), LEGACY_CGEO_DIR_NAME); } @NonNull public static File getBackupDirectory() { return new File(getExternalPublicCgeoDirectory(), BACKUP_DIR_NAME); } @NonNull public static File getGeocacheDataDirectory() { return new File(getExternalPrivateCgeoDirectory(), GEOCACHE_DATA_DIR_NAME); } @NonNull public static File getLocalSpoilersDirectory() { return new File(getExternalPublicCgeoDirectory(), GEOCACHE_PHOTOS_DIR_NAME); } @NonNull public static File getLegacyLocalSpoilersDirectory() { return new File(Environment.getExternalStorageDirectory(), GEOCACHE_PHOTOS_DIR_NAME); } @NonNull public static List<File> getMapDirectories() { final List<File> folders = new ArrayList<>(); for (final File dir : getStorages()) { folders.add(new File(dir, "mfmaps")); folders.add(new File(new File(dir, "Locus"), "mapsVector")); folders.add(new File(dir, CGEO_DIRNAME)); } return folders; } @NonNull public static File getLogPictureDirectory() { return new File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES), CGEO_DIRNAME); } public static void changeExternalPrivateCgeoDir(final SettingsActivity fromActivity, final String newExtDir) { final Progress progress = new Progress(); progress.show(fromActivity, fromActivity.getString(R.string.init_datadirmove_datadirmove), fromActivity.getString(R.string.init_datadirmove_running), ProgressDialog.STYLE_HORIZONTAL, null); AndroidRxUtils.bindActivity(fromActivity, Observable.defer(new Callable<Observable<Boolean>>() { @Override public Observable<Boolean> call() { final File newDataDir = new File(newExtDir, GEOCACHE_DATA_DIR_NAME); final File currentDataDir = new File(getExternalPrivateCgeoDirectory(), GEOCACHE_DATA_DIR_NAME); Log.i("Moving geocache data to " + newDataDir.getAbsolutePath()); final File[] files = currentDataDir.listFiles(); boolean success = true; if (ArrayUtils.isNotEmpty(files)) { progress.setMaxProgressAndReset(files.length); progress.setProgress(0); for (final File geocacheDataDir : files) { success &= FileUtils.moveTo(geocacheDataDir, newDataDir); progress.incrementProgressBy(1); } } Settings.setExternalPrivateCgeoDirectory(newExtDir); Log.i("Ext private c:geo dir was moved to " + newExtDir); externalPrivateCgeoDirectory = new File(newExtDir); return Observable.just(success); } }).subscribeOn(Schedulers.io())).subscribe(new Consumer<Boolean>() { @Override public void accept(final Boolean success) { progress.dismiss(); final String message = success ? fromActivity.getString(R.string.init_datadirmove_success) : fromActivity.getString(R.string.init_datadirmove_failed); Dialogs.message(fromActivity, R.string.init_datadirmove_datadirmove, message); } }); } public static boolean isRunningLowOnDiskSpace() { return FileUtils.getFreeDiskSpace(getExternalPrivateCgeoDirectory()) < LOW_DISKSPACE_THRESHOLD; } }