package com.seafile.seadroid2.data;
import android.content.Context;
import android.media.MediaScannerConnection;
import android.net.Uri;
import android.os.Build;
import android.os.Environment;
import android.support.v4.os.EnvironmentCompat;
import android.text.format.Formatter;
import android.util.Log;
import com.seafile.seadroid2.R;
import com.seafile.seadroid2.SeadroidApplication;
import com.seafile.seadroid2.SettingsManager;
import com.seafile.seadroid2.account.Account;
import com.seafile.seadroid2.account.AccountManager;
import org.apache.commons.io.FileUtils;
import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collection;
/**
* This class decides where to store Seadroid's data in the file system.
*
* Up till API 18, there was not much choice. Only CLASSIC_LOCATION did make any sense
* to use. Starting with KitKat new options have appeared. API 19+ offers an API to get a list
* of possible storage locations, which will include things like SD cards or USB connected flash
* drives.
*
* This StorageManager allows the user to make the choice where to store its data. It remembers
* this choice in the SettingsManager.
*
* This StorageManager also does an auto-selection of the best storage on first use of Seadroid.
*
* The following data falls into the scope of this StorageManager:
*
* - Downloaded repository files
* -> Indexed by Gallery
* -> synced server content, might be deleted locally
* -> persistent, as deletion would cause user inconvenience
*
* - Temp files (e.g. pending for upload, download in progress)
* -> NOT indexed by Gallery
* -> important content, not to be deleted prematurely
* -> only temporary
* -> might be moved to "Downloaded repository files", so should be same mount point
*
* - Image thumbnails
* -> NOT indexed by Gallery
* -> long term storage
* -> not important content, can be deleted if necessary
*
* - JSON Cache files
* -> NOT indexed by Gallery
* -> long term storage
* -> not important content, can be deleted if necessary
*
* This class offers a set of methods to retrieve the base dir for specific types of data.
*
* Useful links:
* - https://developer.android.com/guide/topics/data/data-storage.html
*/
public abstract class StorageManager implements MediaScannerConnection.OnScanCompletedListener {
protected static final String DEBUG_TAG = "StorageManager";
private static StorageManager instance = null;
private final Location CLASSIC_LOCATION;
public StorageManager() {
CLASSIC_LOCATION = buildClassicLocation();
}
/**
* Fetch instance of the StorageManager
* @return
*/
public final static StorageManager getInstance() {
if (instance == null) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
instance = new StorageManagerLollipop();
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
instance = new StorageManagerKitKat();
} else {
instance = new StorageManagerGingerbread();
}
}
return instance;
}
private Location buildClassicLocation() {
Location classic = new Location();
classic.id = -1; // Android IDs start at 0. so "-1" is safe for us
classic.mediaPath = new File(Environment.getExternalStorageDirectory().getAbsolutePath() + "/Seafile/");
classic.cachePath = new File(classic.mediaPath, "cache");
fillLocationInfo(classic);
return classic;
}
private void fillLocationInfo(Location loc) {
loc.available = loc.mediaPath != null && EnvironmentCompat.getStorageState(loc.mediaPath).equals(Environment.MEDIA_MOUNTED);
String label;
// labels "primary/secondary" are as defined by https://possiblemobile.com/2014/03/android-external-storage/
if (loc.id <= 0) {
label = getContext().getString(R.string.storage_manager_primary_storage);
} else {
label = getContext().getString(R.string.storage_manager_secondary_storage);
}
if (loc.available) {
loc.description = getContext().getString(R.string.storage_manager_storage_description,
label,
Formatter.formatFileSize(getContext(), getStorageFreeSpace(loc.mediaPath)),
Formatter.formatFileSize(getContext(), getStorageSize(loc.mediaPath)));
} else {
loc.description = getContext().getString(R.string.storage_manager_storage_description_not_available,
label);
}
}
/**
* Get the media directories offered by Android.
*
* @return
*/
protected abstract File[] getSystemMediaDirs();
/**
* Get the cache directories offered by Android.
*
* @return
*/
protected abstract File[] getSystemCacheDirs();
/**
* Get partition size of the mount point containing dir
*
* @param dir
* @return
*/
protected abstract long getStorageSize(File dir);
/**
* Get free size of the mount point containing dir
*
* @param dir
* @return
*/
protected abstract long getStorageFreeSpace(File dir);
/**
* Allows this device multiple storage locations?
* @return
*/
public abstract boolean supportsMultipleStorageLocations();
/**
* Callback when a file has been added/removed by us in the Android Media Store
*
* @param path
* @param uri
*/
public void onScanCompleted(String path, Uri uri) {
}
public final ArrayList<Location> getStorageLocations() {
ArrayList<Location> retList = new ArrayList<>();
int selectedDir = SettingsManager.instance().getStorageDir();
retList.add(CLASSIC_LOCATION);
File[] dirs = getSystemMediaDirs();
File[] cacheDirs = getSystemCacheDirs();
for (int i = 0; i < dirs.length; i++) {
// omit the mount point where CLASSIC_LOCATION lies (would be duplicate)
if (i == 0)
continue;
Location location = new Location();
location.id = i;
location.mediaPath = dirs[i];
location.cachePath = cacheDirs[i];
retList.add(location);
}
// add current selection at the end, even if the storage is currently unavailable
if (selectedDir != CLASSIC_LOCATION.id && selectedDir >= dirs.length) {
Location location = new Location();
location.id = selectedDir;
location.mediaPath = null;
location.cachePath = null;
retList.add(location);
}
for (Location loc: retList) {
loc.currentSelection = (loc.id == selectedDir);
fillLocationInfo(loc); // fill in size & description info
}
return retList;
}
/**
* Set the new storage directory.
*
* This will change the settings and move files from the old to the new location.
* Therefore, this method might take a while to finish.
*
* @param id the ID of the new storage location
*/
public final void setStorageDir(int id) {
int oldID = SettingsManager.instance().getStorageDir();
if (oldID == id)
return;
Location newLocation = lookupStorageLocation(id);
File newMediaDir = newLocation.mediaPath;
if (newLocation.mediaPath == null || !EnvironmentCompat.getStorageState(newMediaDir).equals(Environment.MEDIA_MOUNTED)) {
Log.i(DEBUG_TAG, "Selected storage dir is unavailable! " + newMediaDir);
return;
}
Location oldLocation = lookupStorageLocation(oldID);
if (oldLocation != null) {
AccountManager manager = new AccountManager(getContext());
try {
// move cached files from old location to new location (might take a while)
for (Account account: manager.getAccountList()) {
DataManager dataManager = new DataManager(account);
File oldAccountDir = new File(dataManager.getAccountDir());
if (oldAccountDir.isDirectory()) {
FileUtils.copyDirectoryToDirectory(oldAccountDir, newMediaDir);
}
}
notifyAndroidGalleryDirectoryChange(FileUtils.listFiles(newMediaDir, null, true));
} catch (IOException e) {
Log.e(DEBUG_TAG, "Could not move cache to new location", e);
return;
}
// remove everything in the old cache directories (thumbnails, etc).
clearCache();
}
Log.i(DEBUG_TAG, "Setting storage directory to " + newMediaDir);
SettingsManager.instance().setStorageDir(id);
}
/**
* Decide, which storage to use. This will be called only once,
* on first start of Seadroid. After that, it's read from the Settings.
*
* -> API 19+
* It selects the media with the most free space in it.
*
* -> API 1-18
* On pre-KitKat, only CLASSIC_LOCATION is available. So there is no choice.
*
* This method does not change the Settings. It just evaluates what the best storage location
* might be.
*
* @return storage ID with the most free space
*/
private Location getPreferredStorage() {
/* Backwards compatibility on upgrade:
* If there is already CLASSIC_LOCATION present on the system, prefer it
*/
if (CLASSIC_LOCATION.mediaPath.exists() && CLASSIC_LOCATION.mediaPath.isDirectory()) {
return CLASSIC_LOCATION;
} else {
// auto-select the location with the most free space available
Location best = null;
for (Location location: getStorageLocations()) {
if (!location.available)
continue;
if (best == null || getStorageFreeSpace(best.mediaPath) < getStorageFreeSpace(location.mediaPath))
best = location;
}
if (best == null)
return CLASSIC_LOCATION;
return best;
}
}
/**
* Return the base directory for media storage to be used in Seadroid.
*
* It guaranties to always return a valid directory.
* However, this directory might change at runtime, So it should never be cached.
*
* @return the base directory for media storage to be used in Seadroid.
*/
public final File getMediaDir() {
return getDirectoryCreateIfNeeded(getStorageLocation().mediaPath);
}
private Location lookupStorageLocation(int id) {
for (Location location: getStorageLocations()) {
if (location.id == id)
return location;
}
return null;
}
/**
* Return the storage location to be used in Seadroid.
*
* It guaranties to always return a valid one.
*
* @return Location info
*/
public Location getStorageLocation() {
int storageDirID = SettingsManager.instance().getStorageDir();
Location storageLocation = lookupStorageLocation(storageDirID);
// if there is one configured but unavailable, use fallback
if (storageDirID >= 0 && !storageLocation.available) {
Log.i(DEBUG_TAG, "Configured storage location " + storageDirID + " has become unavailable, falling back.");
return CLASSIC_LOCATION;
}
// on first start of Seadroid, no location is configured yet
if (storageDirID == Integer.MIN_VALUE) {
storageLocation = getPreferredStorage();
Log.i(DEBUG_TAG, "First start of Seadroid, auto-setting storage directory to " + storageLocation.id);
SettingsManager.instance().setStorageDir(storageLocation.id);
}
if (storageLocation == null || !storageLocation.available) {
Log.i(DEBUG_TAG, "Storage location " + storageLocation.id + " has become unavailable, falling back.");
return CLASSIC_LOCATION;
}
// an explicit path is configured
return storageLocation;
}
private File getDirectoryCreateIfNeeded(File dir) {
if (dir.exists()) {
return dir;
} else {
dir.mkdirs();
}
return dir;
}
protected final Context getContext() {
return SeadroidApplication.getAppContext();
}
/**
* Store temp files in a subdirectory below the media directory.
*
* This should be a subdirectory of getMediaDir() so that temp files
* can be efficiently moved into the media storage.
*
* @return base of where to store temp files
*/
public final File getTempDir() {
File base = getMediaDir();
File tmpDir = new File(base, "temp");
return getDirectoryCreateIfNeeded(tmpDir);
}
/**
* Store JSON cache files in private internal cache.
*
* This cache directory will contain json files listing the repositories and directory listings.
* this can be pretty private (especially the repository listing). So these should not be readable
* by other apps. Therefore we save them in internal storage, where only Seadroid has access to.
*
* @return base of where to store JSON cache files
*/
public final File getJsonCacheDir() {
File base = getContext().getCacheDir();
return getDirectoryCreateIfNeeded(base);
}
/**
* Store thumbnails in a subdirectory below the Seadroid cache directory.
*
* @return base of where to store thumbnails
*/
public final File getThumbnailsDir() {
File base = getStorageLocation().cachePath;
File thumbnailsDir = new File(base, "thumbnails");
return getDirectoryCreateIfNeeded(thumbnailsDir);
}
/**
* A file was added, changed or removed. Notify the gallery.
*
* @param file
*/
public final void notifyAndroidGalleryFileChange(File file) {
MediaScannerConnection.scanFile(getContext(),
new String[]{file.toString()},
null,
this);
}
/**
* A directory was added, changed or removed. Notify the gallery.
*
* @param fileList
*/
private final void notifyAndroidGalleryDirectoryChange(Collection<File> fileList) {
int count = 0;
String[] list = new String[fileList.size()];
for (File f: fileList) {
list[count++] = f.getAbsolutePath();
}
MediaScannerConnection.scanFile(getContext(),
list, null, this);
}
/**
* Deletes full cache
* remember to clear cache from database after called this method
*/
public final void clearCache() {
Collection<File> fileList = FileUtils.listFiles(getMediaDir(), null, true);
FileUtils.deleteQuietly(getMediaDir());
FileUtils.deleteQuietly(getJsonCacheDir());
FileUtils.deleteQuietly(getTempDir());
FileUtils.deleteQuietly(getThumbnailsDir());
notifyAndroidGalleryDirectoryChange(fileList);
}
/**
* Deletes cache directory under a specific account<br>
* remember to clear cache from database after called this method
*/
public final void clearAccount(Account account) {
DataManager dataManager = new DataManager(account);
File accountDir = new File(dataManager.getAccountDir());
Collection<File> fileList = FileUtils.listFiles(accountDir, null, true);
FileUtils.deleteQuietly(accountDir);
notifyAndroidGalleryDirectoryChange(fileList);
}
/**
* Return space used by Seadroid
*
* @return
*/
public final long getUsedSpace() {
File mediaDir = getMediaDir();
File thumbDir = getThumbnailsDir();
if (!mediaDir.exists() || !thumbDir.exists()) return 0L;
return FileUtils.sizeOfDirectory(mediaDir) + FileUtils.sizeOfDirectory(thumbDir);
}
public static class Location {
/**
* Our internal ID of this storage.
*/
public int id;
/**
* Base media directory
*/
public File mediaPath;
/**
* Base cache directory
*/
public File cachePath;
/**
* Text description
*/
public String description;
/**
* Is this location available (mounted)?
*/
public boolean available;
/**
* Is this the currently selected location?
*/
public boolean currentSelection;
}
}