+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/java/android-filechooser-AS/app/app.iml b/src/java/android-filechooser-AS/app/app.iml
new file mode 100644
index 00000000..b6980838
--- /dev/null
+++ b/src/java/android-filechooser-AS/app/app.iml
@@ -0,0 +1,92 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/java/android-filechooser-AS/app/build.gradle b/src/java/android-filechooser-AS/app/build.gradle
new file mode 100644
index 00000000..bf256a58
--- /dev/null
+++ b/src/java/android-filechooser-AS/app/build.gradle
@@ -0,0 +1,22 @@
+apply plugin: 'com.android.library'
+
+android {
+ compileSdkVersion 23
+ buildToolsVersion "23.0.0"
+
+ defaultConfig {
+ minSdkVersion 15
+ targetSdkVersion 15
+ }
+
+ buildTypes {
+ release {
+ minifyEnabled false
+ proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.txt'
+ }
+ }
+}
+
+dependencies {
+ compile 'com.android.support:support-v4:18.0.0'
+}
diff --git a/src/java/android-filechooser-AS/app/src/main/AndroidManifest.xml b/src/java/android-filechooser-AS/app/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..e4ee7192
--- /dev/null
+++ b/src/java/android-filechooser-AS/app/src/main/AndroidManifest.xml
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/java/android-filechooser-AS/app/src/main/java/group/pals/android/lib/ui/filechooser/BaseFileAdapter.java b/src/java/android-filechooser-AS/app/src/main/java/group/pals/android/lib/ui/filechooser/BaseFileAdapter.java
new file mode 100644
index 00000000..7553634a
--- /dev/null
+++ b/src/java/android-filechooser-AS/app/src/main/java/group/pals/android/lib/ui/filechooser/BaseFileAdapter.java
@@ -0,0 +1,548 @@
+/*
+ * Copyright (c) 2012 Hai Bison
+ *
+ * See the file LICENSE at the root directory of this project for copying
+ * permission.
+ */
+
+package group.pals.android.lib.ui.filechooser;
+
+import group.pals.android.lib.ui.filechooser.prefs.DisplayPrefs;
+import group.pals.android.lib.ui.filechooser.prefs.DisplayPrefs.FileTimeDisplay;
+import group.pals.android.lib.ui.filechooser.providers.BaseFileProviderUtils;
+import group.pals.android.lib.ui.filechooser.providers.basefile.BaseFileContract.BaseFile;
+import group.pals.android.lib.ui.filechooser.utils.Converter;
+import group.pals.android.lib.ui.filechooser.utils.DateUtils;
+import group.pals.android.lib.ui.filechooser.utils.Utils;
+import group.pals.android.lib.ui.filechooser.utils.ui.ContextMenuUtils;
+import group.pals.android.lib.ui.filechooser.utils.ui.LoadingDialog;
+import group.pals.android.lib.ui.filechooser.utils.ui.Ui;
+
+import java.util.ArrayList;
+
+import android.content.Context;
+import android.database.Cursor;
+import android.net.Uri;
+import android.support.v4.widget.ResourceCursorAdapter;
+import android.util.Log;
+import android.util.SparseArray;
+import android.view.MotionEvent;
+import android.view.View;
+import android.widget.CheckBox;
+import android.widget.CompoundButton;
+import android.widget.GridView;
+import android.widget.ImageView;
+import android.widget.TextView;
+
+/**
+ * Adapter of base file.
+ *
+ * @author Hai Bison
+ *
+ */
+public class BaseFileAdapter extends ResourceCursorAdapter {
+
+ /**
+ * Used for debugging...
+ */
+ private static final String CLASSNAME = BaseFileAdapter.class.getName();
+
+ /**
+ * Listener for building context menu editor.
+ *
+ * @author Hai Bison
+ * @since v5.1 beta
+ */
+ public static interface OnBuildOptionsMenuListener {
+
+ /**
+ * Will be called after the user touched on the icon of the item.
+ *
+ * @param view
+ * the view displaying the item.
+ * @param cursor
+ * the item which its icon has been touched.
+ */
+ void onBuildOptionsMenu(View view, Cursor cursor);
+
+ /**
+ * Will be called after the user touched and held ("long click") on the
+ * icon of the item.
+ *
+ * @param view
+ * the view displaying the item.
+ * @param cursor
+ * the item which its icon has been touched.
+ */
+ void onBuildAdvancedOptionsMenu(View view, Cursor cursor);
+ }// OnBuildOptionsMenuListener
+
+ private final int mFilterMode;
+ private final FileTimeDisplay mFileTimeDisplay;
+ private final Integer[] mAdvancedSelectionOptions;
+ private boolean mMultiSelection;
+ private OnBuildOptionsMenuListener mOnBuildOptionsMenuListener;
+
+ public BaseFileAdapter(Context context, int filterMode,
+ boolean multiSelection) {
+ super(context, R.layout.afc_file_item, null, 0);
+ mFilterMode = filterMode;
+ mMultiSelection = multiSelection;
+
+ switch (mFilterMode) {
+ case BaseFile.FILTER_FILES_AND_DIRECTORIES:
+ mAdvancedSelectionOptions = new Integer[] {
+ R.string.afc_cmd_advanced_selection_all,
+ R.string.afc_cmd_advanced_selection_none,
+ R.string.afc_cmd_advanced_selection_invert,
+ R.string.afc_cmd_select_all_files,
+ R.string.afc_cmd_select_all_folders };
+ break;// FILTER_FILES_AND_DIRECTORIES
+ default:
+ mAdvancedSelectionOptions = new Integer[] {
+ R.string.afc_cmd_advanced_selection_all,
+ R.string.afc_cmd_advanced_selection_none,
+ R.string.afc_cmd_advanced_selection_invert };
+ break;// FILTER_DIRECTORIES_ONLY and FILTER_FILES_ONLY
+ }
+
+ mFileTimeDisplay = new FileTimeDisplay(
+ DisplayPrefs.isShowTimeForOldDaysThisYear(context),
+ DisplayPrefs.isShowTimeForOldDays(context));
+ }// BaseFileAdapter()
+
+ @Override
+ public int getCount() {
+ /*
+ * The last item is used for information from the provider, we ignore
+ * it.
+ */
+ int count = super.getCount();
+ return count > 0 ? count - 1 : 0;
+ }// getCount()
+
+ /**
+ * The "view holder"
+ *
+ * @author Hai Bison
+ */
+ private static final class Bag {
+
+ ImageView mImageIcon;
+ ImageView mImageLockedSymbol;
+ TextView mTxtFileName;
+ TextView mTxtFileInfo;
+ CheckBox mCheckboxSelection;
+ }// Bag
+
+ private static class BagInfo {
+
+ boolean mChecked = false;
+ boolean mMarkedAsDeleted = false;
+ Uri mUri;
+ }// BagChildInfo
+
+ /**
+ * Map of child IDs to {@link BagChildInfo}.
+ */
+ private final SparseArray mSelectedChildrenMap = new SparseArray();
+
+ @Override
+ public void bindView(View view, Context context, Cursor cursor) {
+ Bag bag = (Bag) view.getTag();
+
+ if (bag == null) {
+ bag = new Bag();
+ bag.mImageIcon = (ImageView) view
+ .findViewById(R.id.afc_imageview_icon);
+ bag.mImageLockedSymbol = (ImageView) view
+ .findViewById(R.id.afc_imageview_locked_symbol);
+ bag.mTxtFileName = (TextView) view
+ .findViewById(R.id.afc_textview_filename);
+ bag.mTxtFileInfo = (TextView) view
+ .findViewById(R.id.afc_textview_file_info);
+ bag.mCheckboxSelection = (CheckBox) view
+ .findViewById(R.id.afc_checkbox_selection);
+
+ view.setTag(bag);
+ }
+
+ final int id = cursor.getInt(cursor.getColumnIndex(BaseFile._ID));
+ final Uri uri = BaseFileProviderUtils.getUri(cursor);
+
+ final BagInfo bagInfo;
+ if (mSelectedChildrenMap.get(id) == null) {
+ bagInfo = new BagInfo();
+ bagInfo.mUri = uri;
+ mSelectedChildrenMap.put(id, bagInfo);
+ } else
+ bagInfo = mSelectedChildrenMap.get(id);
+
+ /*
+ * Update views.
+ */
+
+ /*
+ * Use single line for grid view, multiline for list view
+ */
+ bag.mTxtFileName.setSingleLine(view.getParent() instanceof GridView);
+
+ /*
+ * File icon.
+ */
+ bag.mImageLockedSymbol.setVisibility(cursor.getInt(cursor
+ .getColumnIndex(BaseFile.COLUMN_CAN_READ)) > 0 ? View.GONE
+ : View.VISIBLE);
+ bag.mImageIcon.setImageResource(cursor.getInt(cursor
+ .getColumnIndex(BaseFile.COLUMN_ICON_ID)));
+ bag.mImageIcon.setOnTouchListener(mImageIconOnTouchListener);
+ bag.mImageIcon.setOnClickListener(BaseFileProviderUtils
+ .isDirectory(cursor) ? newImageIconOnClickListener(cursor
+ .getPosition()) : null);
+
+ /*
+ * Filename.
+ */
+ bag.mTxtFileName.setText(BaseFileProviderUtils.getFileName(cursor));
+ Ui.strikeOutText(bag.mTxtFileName, bagInfo.mMarkedAsDeleted);
+
+ /*
+ * File info.
+ */
+ String time = DateUtils.formatDate(context, cursor.getLong(cursor
+ .getColumnIndex(BaseFile.COLUMN_MODIFICATION_TIME)),
+ mFileTimeDisplay);
+ if (BaseFileProviderUtils.isFile(cursor))
+ bag.mTxtFileInfo.setText(String.format("%s, %s", Converter
+ .sizeToStr(cursor.getLong(cursor
+ .getColumnIndex(BaseFile.COLUMN_SIZE))), time));
+ else
+ bag.mTxtFileInfo.setText(time);
+
+ /*
+ * Check box.
+ */
+ if (mMultiSelection) {
+ if (mFilterMode == BaseFile.FILTER_FILES_ONLY
+ && BaseFileProviderUtils.isDirectory(cursor)) {
+ bag.mCheckboxSelection.setVisibility(View.GONE);
+ } else {
+ bag.mCheckboxSelection.setVisibility(View.VISIBLE);
+
+ bag.mCheckboxSelection.setOnCheckedChangeListener(null);
+ bag.mCheckboxSelection.setChecked(bagInfo.mChecked);
+ bag.mCheckboxSelection
+ .setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() {
+
+ @Override
+ public void onCheckedChanged(
+ CompoundButton buttonView, boolean isChecked) {
+ bagInfo.mChecked = isChecked;
+ }// onCheckedChanged()
+ });
+
+ bag.mCheckboxSelection
+ .setOnLongClickListener(mCheckboxSelectionOnLongClickListener);
+ }
+ } else
+ bag.mCheckboxSelection.setVisibility(View.GONE);
+ }// bindView()
+
+ @Override
+ public void changeCursor(Cursor cursor) {
+ super.changeCursor(cursor);
+ synchronized (mSelectedChildrenMap) {
+ mSelectedChildrenMap.clear();
+ }
+ }// changeCursor()
+
+ /*
+ * UTILITIES.
+ */
+
+ /**
+ * Sets the listener {@link OnBuildOptionsMenuListener}.
+ *
+ * @param listener
+ * the listener.
+ */
+ public void setBuildOptionsMenuListener(OnBuildOptionsMenuListener listener) {
+ mOnBuildOptionsMenuListener = listener;
+ }// setBuildOptionsMenuListener()
+
+ /**
+ * Gets the listener {@link OnBuildOptionsMenuListener}.
+ *
+ * @return the listener.
+ */
+ public OnBuildOptionsMenuListener getOnBuildOptionsMenuListener() {
+ return mOnBuildOptionsMenuListener;
+ }// getOnBuildOptionsMenuListener()
+
+ /**
+ * Gets the short name of this path.
+ *
+ * @return the path name, can be {@code null} if there is no data.
+ */
+ public String getPathName() {
+ Cursor cursor = getCursor();
+ if (cursor == null || !cursor.moveToLast())
+ return null;
+ return BaseFileProviderUtils.getFileName(cursor);
+ }// getPathName()
+
+ /**
+ * Selects all items.
+ *
+ * Note: This will not notify data set for changes after done.
+ *
+ * @param fileType
+ * can be {@code -1} for all file types; or one of
+ * {@link BaseFile#FILE_TYPE_DIRECTORY},
+ * {@link BaseFile#FILE_TYPE_FILE}.
+ * @param selected
+ * {@code true} or {@code false}.
+ */
+ private void asyncSelectAll(int fileType, boolean selected) {
+ int count = getCount();
+ for (int i = 0; i < count; i++) {
+ Cursor cursor = (Cursor) getItem(i);
+
+ int itemFileType = cursor.getInt(cursor
+ .getColumnIndex(BaseFile.COLUMN_TYPE));
+ if ((mFilterMode == BaseFile.FILTER_DIRECTORIES_ONLY && itemFileType == BaseFile.FILE_TYPE_FILE)
+ || (mFilterMode == BaseFile.FILTER_FILES_ONLY && itemFileType == BaseFile.FILE_TYPE_DIRECTORY))
+ continue;
+
+ final int id = cursor.getInt(cursor.getColumnIndex(BaseFile._ID));
+ BagInfo b = mSelectedChildrenMap.get(id);
+ if (b == null) {
+ b = new BagInfo();
+ b.mUri = BaseFileProviderUtils.getUri(cursor);
+ mSelectedChildrenMap.put(id, b);
+ }
+
+ if (fileType >= 0 && itemFileType != fileType)
+ b.mChecked = false;
+ else if (b.mChecked != selected)
+ b.mChecked = selected;
+ }// for i
+ }// asyncSelectAll()
+
+ /**
+ * Selects all items.
+ *
+ * Note: This calls {@link #notifyDataSetChanged()} when done.
+ *
+ * @param selected
+ * {@code true} or {@code false}.
+ */
+ public synchronized void selectAll(boolean selected) {
+ asyncSelectAll(-1, selected);
+ notifyDataSetChanged();
+ }// selectAll()
+
+ /**
+ * Inverts selection of all items.
+ *
+ * Note: This will not notify data set for changes after done.
+ */
+ private void asyncInvertSelection() {
+ int count = getCount();
+ for (int i = 0; i < count; i++) {
+ Cursor cursor = (Cursor) getItem(i);
+
+ int fileType = cursor.getInt(cursor
+ .getColumnIndex(BaseFile.COLUMN_TYPE));
+ if ((mFilterMode == BaseFile.FILTER_DIRECTORIES_ONLY && fileType == BaseFile.FILE_TYPE_FILE)
+ || (mFilterMode == BaseFile.FILTER_FILES_ONLY && fileType == BaseFile.FILE_TYPE_DIRECTORY))
+ continue;
+
+ final int id = cursor.getInt(cursor.getColumnIndex(BaseFile._ID));
+ BagInfo b = mSelectedChildrenMap.get(id);
+ if (b == null) {
+ b = new BagInfo();
+ b.mUri = BaseFileProviderUtils.getUri(cursor);
+ mSelectedChildrenMap.put(id, b);
+ }
+ b.mChecked = !b.mChecked;
+ }// for i
+ }// asyncInvertSelection()
+
+ /**
+ * Inverts selection of all items.
+ *
+ * Note: This calls {@link #notifyDataSetChanged()} after done.
+ */
+ public synchronized void invertSelection() {
+ asyncInvertSelection();
+ notifyDataSetChanged();
+ }// invertSelection()
+
+ /**
+ * Checks if item with {@code id} is selected or not.
+ *
+ * @param id
+ * the database ID.
+ * @return {@code true} or {@code false}.
+ */
+ public boolean isSelected(int id) {
+ synchronized (mSelectedChildrenMap) {
+ return mSelectedChildrenMap.get(id) != null ? mSelectedChildrenMap
+ .get(id).mChecked : false;
+ }
+ }// isSelected()
+
+ /**
+ * Gets selected items.
+ *
+ * @return list of URIs, can be empty.
+ */
+ public ArrayList getSelectedItems() {
+ ArrayList res = new ArrayList();
+
+ synchronized (mSelectedChildrenMap) {
+ for (int i = 0; i < mSelectedChildrenMap.size(); i++)
+ if (mSelectedChildrenMap.get(mSelectedChildrenMap.keyAt(i)).mChecked)
+ res.add(mSelectedChildrenMap.get(mSelectedChildrenMap
+ .keyAt(i)).mUri);
+ }
+
+ return res;
+ }// getSelectedItems()
+
+ /**
+ * Marks all selected items as deleted.
+ *
+ * Note: This calls {@link #notifyDataSetChanged()} after done.
+ *
+ * @param deleted
+ * {@code true} or {@code false}.
+ */
+ public void markSelectedItemsAsDeleted(boolean deleted) {
+ synchronized (mSelectedChildrenMap) {
+ for (int i = 0; i < mSelectedChildrenMap.size(); i++)
+ if (mSelectedChildrenMap.get(mSelectedChildrenMap.keyAt(i)).mChecked)
+ mSelectedChildrenMap.get(mSelectedChildrenMap.keyAt(i)).mMarkedAsDeleted = deleted;
+ }
+
+ notifyDataSetChanged();
+ }// markSelectedItemsAsDeleted()
+
+ /**
+ * Marks specified item as deleted.
+ *
+ * Note: This calls {@link #notifyDataSetChanged()} after done.
+ *
+ * @param id
+ * the ID of the item.
+ * @param deleted
+ * {@code true} or {@code false}.
+ */
+ public void markItemAsDeleted(int id, boolean deleted) {
+ synchronized (mSelectedChildrenMap) {
+ if (mSelectedChildrenMap.get(id) != null) {
+ mSelectedChildrenMap.get(id).mMarkedAsDeleted = deleted;
+ notifyDataSetChanged();
+ }
+ }
+ }// markItemAsDeleted()
+
+ /*
+ * LISTENERS
+ */
+
+ /**
+ * If the user touches the list item, and the image icon declared a
+ * selector in XML, then that selector works. But we just want the selector
+ * to work only when the user touches the image, hence this listener.
+ */
+ private final View.OnTouchListener mImageIconOnTouchListener = new View.OnTouchListener() {
+
+ @Override
+ public boolean onTouch(View v, MotionEvent event) {
+ if (Utils.doLog())
+ Log.d(CLASSNAME,
+ "mImageIconOnTouchListener.onTouch() >> ACTION = "
+ + event.getAction());
+
+ switch (event.getAction()) {
+ case MotionEvent.ACTION_DOWN:
+ v.setBackgroundResource(R.drawable.afc_image_button_dark_pressed);
+ break;
+ case MotionEvent.ACTION_UP:
+ case MotionEvent.ACTION_CANCEL:
+ v.setBackgroundResource(0);
+ break;
+ }
+ return false;
+ }// onTouch()
+ };// mImageIconOnTouchListener
+
+ /**
+ * Creates new listener to handle click event of image icon.
+ *
+ * @param cursorPosition
+ * the cursor position.
+ * @return the listener.
+ */
+ private View.OnClickListener newImageIconOnClickListener(
+ final int cursorPosition) {
+ return new View.OnClickListener() {
+
+ @Override
+ public void onClick(View v) {
+ if (getOnBuildOptionsMenuListener() != null)
+ getOnBuildOptionsMenuListener().onBuildOptionsMenu(v,
+ (Cursor) getItem(cursorPosition));
+ }// onClick()
+ };
+ }// newImageIconOnClickListener()
+
+ private final View.OnLongClickListener mCheckboxSelectionOnLongClickListener = new View.OnLongClickListener() {
+
+ @Override
+ public boolean onLongClick(final View v) {
+ ContextMenuUtils.showContextMenu(v.getContext(), 0,
+ R.string.afc_title_advanced_selection,
+ mAdvancedSelectionOptions,
+ new ContextMenuUtils.OnMenuItemClickListener() {
+
+ @Override
+ public void onClick(final int resId) {
+ new LoadingDialog(v.getContext(),
+ R.string.afc_msg_loading, false) {
+
+ @Override
+ protected Void doInBackground(Void... params) {
+ if (resId == R.string.afc_cmd_advanced_selection_all)
+ asyncSelectAll(-1, true);
+ else if (resId == R.string.afc_cmd_advanced_selection_none)
+ asyncSelectAll(-1, false);
+ else if (resId == R.string.afc_cmd_advanced_selection_invert)
+ asyncInvertSelection();
+ else if (resId == R.string.afc_cmd_select_all_files)
+ asyncSelectAll(BaseFile.FILE_TYPE_FILE,
+ true);
+ else if (resId == R.string.afc_cmd_select_all_folders)
+ asyncSelectAll(
+ BaseFile.FILE_TYPE_DIRECTORY,
+ true);
+
+ return null;
+ }// doInBackground()
+
+ @Override
+ protected void onPostExecute(Void result) {
+ super.onPostExecute(result);
+ notifyDataSetChanged();
+ }// onPostExecute()
+ }.execute();
+ }// onClick()
+ });
+
+ return true;
+ }// onLongClick()
+ };// mCheckboxSelectionOnLongClickListener
+
+}
diff --git a/src/java/android-filechooser-AS/app/src/main/java/group/pals/android/lib/ui/filechooser/FileChooserActivity.java b/src/java/android-filechooser-AS/app/src/main/java/group/pals/android/lib/ui/filechooser/FileChooserActivity.java
new file mode 100644
index 00000000..b5b06ecd
--- /dev/null
+++ b/src/java/android-filechooser-AS/app/src/main/java/group/pals/android/lib/ui/filechooser/FileChooserActivity.java
@@ -0,0 +1,288 @@
+/*
+ * Copyright (c) 2012 Hai Bison
+ *
+ * See the file LICENSE at the root directory of this project for copying
+ * permission.
+ */
+
+package group.pals.android.lib.ui.filechooser;
+
+import group.pals.android.lib.ui.filechooser.prefs.DisplayPrefs;
+import group.pals.android.lib.ui.filechooser.providers.basefile.BaseFileContract.BaseFile;
+import group.pals.android.lib.ui.filechooser.providers.localfile.LocalFileContract;
+import group.pals.android.lib.ui.filechooser.utils.Utils;
+import group.pals.android.lib.ui.filechooser.utils.ui.Dlg;
+import group.pals.android.lib.ui.filechooser.utils.ui.Ui;
+
+import java.util.ArrayList;
+
+import android.content.Context;
+import android.content.res.Configuration;
+import android.net.Uri;
+import android.os.Bundle;
+import android.support.v4.app.FragmentActivity;
+import android.util.Log;
+import android.view.KeyEvent;
+import android.widget.GridView;
+import android.widget.ListView;
+
+/**
+ * Main activity for this library.
+ *
+ *
Notes:
+ *
+ *
+ *
About keys {@link FileChooserActivity#EXTRA_ROOTPATH},
+ * {@link FileChooserActivity#EXTRA_SELECT_FILE} and preference
+ * {@link DisplayPrefs#isRememberLastLocation(Context)}, the priorities of them
+ * are:
+ *
+ *
+ *
+ *
+ * @author Hai Bison
+ */
+public class FileChooserActivity extends FragmentActivity {
+
+ /**
+ * The full name of this class. Generally used for debugging.
+ */
+ private static final String CLASSNAME = FileChooserActivity.class.getName();
+
+ /**
+ * Types of view.
+ *
+ * @author Hai Bison
+ * @since v4.0 beta
+ */
+ public static enum ViewType {
+ /**
+ * Use {@link ListView} to display file list.
+ */
+ LIST,
+ /**
+ * Use {@link GridView} to display file list.
+ */
+ GRID
+ }// ViewType
+
+ /*---------------------------------------------
+ * KEYS
+ */
+
+ /**
+ * Sets value of this key to a theme which is one of {@code Afc_Theme_*}.
+ *
+ * @since v4.3 beta
+ */
+ public static final String EXTRA_THEME = CLASSNAME + ".theme";
+
+ /**
+ * Key to hold the root path.
+ *
+ * If {@link LocalFileProvider} is used, then default is SD card, if SD card
+ * is not available, {@code "/"} will be used.
+ *
+ * Note: The value of this key is a file provider's {@link Uri}. For
+ * example with {@link LocalFileProvider}, you can use this command:
+ *
+ *
+ */
+ public static final String EXTRA_ROOTPATH = CLASSNAME + ".rootpath";
+
+ /**
+ * Key to hold the authority of file provider.
+ *
+ * Default is {@link LocalFileContract#getAuthority(Context)}.
+ */
+ public static final String EXTRA_FILE_PROVIDER_AUTHORITY = CLASSNAME
+ + ".file_provider_authority";
+
+ // ---------------------------------------------------------
+
+ /**
+ * Key to hold filter mode, can be one of
+ * {@link BaseFile#FILTER_DIRECTORIES_ONLY},
+ * {@link BaseFile#FILTER_FILES_AND_DIRECTORIES},
+ * {@link BaseFile#FILTER_FILES_ONLY}.
+ *
+ * Default is {@link BaseFile#FILTER_FILES_ONLY}.
+ */
+ public static final String EXTRA_FILTER_MODE = CLASSNAME + ".filter_mode";
+
+ // flags
+
+ // ---------------------------------------------------------
+
+ /**
+ * Key to hold max file count that's allowed to be listed, default =
+ * {@code 1000}.
+ */
+ public static final String EXTRA_MAX_FILE_COUNT = CLASSNAME
+ + ".max_file_count";
+ /**
+ * Key to hold multi-selection mode, default = {@code false}.
+ */
+ public static final String EXTRA_MULTI_SELECTION = CLASSNAME
+ + ".multi_selection";
+ /**
+ * Key to hold the positive regex to filter files (not
+ * directories), default is {@code null}.
+ *
+ * @since v5.1 beta
+ */
+ public static final String EXTRA_POSITIVE_REGEX_FILTER = CLASSNAME
+ + ".positive_regex_filter";
+ /**
+ * Key to hold the negative regex to filter files (not
+ * directories), default is {@code null}.
+ *
+ * @since v5.1 beta
+ */
+ public static final String EXTRA_NEGATIVE_REGEX_FILTER = CLASSNAME
+ + ".negative_regex_filter";
+ /**
+ * Key to hold display-hidden-files, default = {@code false}.
+ */
+ public static final String EXTRA_DISPLAY_HIDDEN_FILES = CLASSNAME
+ + ".display_hidden_files";
+ /**
+ * Sets this to {@code true} to enable double tapping to choose files/
+ * directories. In older versions, double tapping is default. However, since
+ * v4.7 beta, single tapping is default. So if you want to keep the old way,
+ * please set this key to {@code true}.
+ *
+ * @since v4.7 beta
+ */
+ public static final String EXTRA_DOUBLE_TAP_TO_CHOOSE_FILES = CLASSNAME
+ + ".double_tap_to_choose_files";
+ /**
+ * Sets the file you want to select when starting this activity. This is a
+ * file provider's {@link Uri}. For example with {@link LocalFileProvider},
+ * you can use this command:
+ *
+ *
+ *
+ */
+ private void setupFooter() {
+ /*
+ * By default, view group footer and all its child views are hidden.
+ */
+
+ ViewGroup viewGroupFooterContainer = (ViewGroup) getView()
+ .findViewById(R.id.afc_viewgroup_footer_container);
+ ViewGroup viewGroupFooter = (ViewGroup) getView().findViewById(
+ R.id.afc_viewgroup_footer);
+
+ if (mIsSaveDialog) {
+ viewGroupFooterContainer.setVisibility(View.VISIBLE);
+ viewGroupFooter.setVisibility(View.VISIBLE);
+
+ mTextSaveas.setVisibility(View.VISIBLE);
+ mTextSaveas.setText(getArguments().getString(
+ FileChooserActivity.EXTRA_DEFAULT_FILENAME));
+ mTextSaveas
+ .setOnEditorActionListener(new TextView.OnEditorActionListener() {
+
+ @Override
+ public boolean onEditorAction(TextView v, int actionId,
+ KeyEvent event) {
+ if (actionId == EditorInfo.IME_ACTION_DONE) {
+ Ui.showSoftKeyboard(v, false);
+ mBtnOk.performClick();
+ return true;
+ }
+ return false;
+ }// onEditorAction()
+ });
+ mTextSaveas.addTextChangedListener(new TextWatcher() {
+
+ @Override
+ public void onTextChanged(CharSequence s, int start,
+ int before, int count) {
+ /*
+ * Do nothing.
+ */
+ }// onTextChanged()
+
+ @Override
+ public void beforeTextChanged(CharSequence s, int start,
+ int count, int after) {
+ /*
+ * Do nothing.
+ */
+ }// beforeTextChanged()
+
+ @Override
+ public void afterTextChanged(Editable s) {
+ /*
+ * If the user taps a file, the tag is set to that file's
+ * URI. But if the user types the file name, we remove the
+ * tag.
+ */
+ mTextSaveas.setTag(null);
+ }// afterTextChanged()
+ });
+
+ mBtnOk.setVisibility(View.VISIBLE);
+ mBtnOk.setOnClickListener(mBtnOk_SaveDialog_OnClickListener);
+ mBtnOk.setBackgroundResource(Ui.resolveAttribute(getActivity(),
+ R.attr.afc_selector_button_ok_saveas));
+
+ int size = getResources().getDimensionPixelSize(
+ R.dimen.afc_button_ok_saveas_size);
+ LinearLayout.LayoutParams lp = (LinearLayout.LayoutParams) mBtnOk
+ .getLayoutParams();
+ lp.width = size;
+ lp.height = size;
+ mBtnOk.setLayoutParams(lp);
+ }// this is in save mode
+ else {
+ if (mIsMultiSelection) {
+ viewGroupFooterContainer.setVisibility(View.VISIBLE);
+ viewGroupFooter.setVisibility(View.VISIBLE);
+
+ ViewGroup.LayoutParams lp = viewGroupFooter.getLayoutParams();
+ lp.width = ViewGroup.LayoutParams.WRAP_CONTENT;
+ viewGroupFooter.setLayoutParams(lp);
+
+ mBtnOk.setMinWidth(getResources().getDimensionPixelSize(
+ R.dimen.afc_single_button_min_width));
+ mBtnOk.setText(android.R.string.ok);
+ mBtnOk.setVisibility(View.VISIBLE);
+ mBtnOk.setOnClickListener(mBtnOk_OpenDialog_OnClickListener);
+ }
+ }// this is in open mode
+ }// setupFooter()
+
+ /**
+ * Shows footer view.
+ *
+ * @param show
+ * {@code true} or {@code false}.
+ * @param text
+ * the message you want to set.
+ * @param center
+ * {@code true} or {@code false}.
+ */
+ @SuppressLint("InlinedApi")
+ private void showFooterView(boolean show, String text, boolean center) {
+ if (show) {
+ mFooterView.setText(text);
+
+ RelativeLayout.LayoutParams lp = new RelativeLayout.LayoutParams(
+ RelativeLayout.LayoutParams.MATCH_PARENT,
+ RelativeLayout.LayoutParams.MATCH_PARENT);
+ if (!center)
+ lp.addRule(RelativeLayout.ABOVE,
+ R.id.afc_view_files_footer_view);
+ mViewFilesContainer.setLayoutParams(lp);
+
+ lp = (RelativeLayout.LayoutParams) mFooterView.getLayoutParams();
+ lp.addRule(RelativeLayout.CENTER_IN_PARENT, center ? 1 : 0);
+ lp.addRule(RelativeLayout.ALIGN_PARENT_BOTTOM, center ? 0 : 1);
+ mFooterView.setLayoutParams(lp);
+
+ mFooterView.setVisibility(View.VISIBLE);
+ } else
+ mFooterView.setVisibility(View.GONE);
+ }// showFooterView()
+
+ /**
+ * This should be called after the owner activity has been created
+ * successfully.
+ */
+ private void initGestureDetector() {
+ mListviewFilesGestureDetector = new GestureDetector(getActivity(),
+ new GestureDetector.SimpleOnGestureListener() {
+
+ private Object getData(float x, float y) {
+ int i = getSubViewId(x, y);
+ if (i >= 0)
+ return mViewFiles.getItemAtPosition(mViewFiles
+ .getFirstVisiblePosition() + i);
+ return null;
+ }// getSubView()
+
+ private int getSubViewId(float x, float y) {
+ Rect r = new Rect();
+ for (int i = 0; i < mViewFiles.getChildCount(); i++) {
+ mViewFiles.getChildAt(i).getHitRect(r);
+ if (r.contains((int) x, (int) y))
+ return i;
+ }
+
+ return -1;
+ }// getSubViewId()
+
+ /**
+ * Gets {@link Cursor} from {@code e}.
+ *
+ * @param e
+ * {@link MotionEvent}.
+ * @return the cursor, or {@code null} if not available.
+ */
+ private Cursor getData(MotionEvent e) {
+ Object o = getData(e.getX(), e.getY());
+ return o instanceof Cursor ? (Cursor) o : null;
+ }// getDataModel()
+
+ @Override
+ public void onLongPress(MotionEvent e) {
+ /*
+ * Do nothing.
+ */
+ }// onLongPress()
+
+ @Override
+ public boolean onSingleTapConfirmed(MotionEvent e) {
+ /*
+ * Do nothing.
+ */
+ return false;
+ }// onSingleTapConfirmed()
+
+ @Override
+ public boolean onDoubleTap(MotionEvent e) {
+ if (mDoubleTapToChooseFiles) {
+ if (mIsMultiSelection)
+ return false;
+
+ Cursor cursor = getData(e);
+ if (cursor == null)
+ return false;
+
+ if (BaseFileProviderUtils.isDirectory(cursor)
+ && BaseFile.FILTER_FILES_ONLY == mFilterMode)
+ return false;
+
+ /*
+ * If mFilterMode == FILTER_DIRECTORIES_ONLY, files
+ * won't be shown.
+ */
+
+ if (mIsSaveDialog) {
+ if (BaseFileProviderUtils.isFile(cursor)) {
+ mTextSaveas.setText(BaseFileProviderUtils
+ .getFileName(cursor));
+ /*
+ * Always set tag after setting text, or tag
+ * will be reset to null.
+ */
+ mTextSaveas.setTag(BaseFileProviderUtils
+ .getUri(cursor));
+ checkSaveasFilenameAndFinish();
+ } else
+ return false;
+ } else
+ finish(BaseFileProviderUtils.getUri(cursor));
+ }// double tap to choose files
+ else {
+ /*
+ * Do nothing.
+ */
+ return false;
+ }// single tap to choose files
+
+ return true;
+ }// onDoubleTap()
+
+ @Override
+ public boolean onFling(MotionEvent e1, MotionEvent e2,
+ float velocityX, float velocityY) {
+ /*
+ * Sometimes e1 or e2 can be null. This came from users'
+ * experiences.
+ */
+ if (e1 == null || e2 == null)
+ return false;
+
+ final int max_y_distance = 19;// 10 is too short :-D
+ final int min_x_distance = 80;
+ final int min_x_velocity = 200;
+ if (Math.abs(e1.getY() - e2.getY()) < max_y_distance
+ && Math.abs(e1.getX() - e2.getX()) > min_x_distance
+ && Math.abs(velocityX) > min_x_velocity) {
+ int pos = getSubViewId(e1.getX(), e1.getY());
+ if (pos >= 0) {
+ /*
+ * Don't let this event to be recognized as a
+ * single tap.
+ */
+ MotionEvent cancelEvent = MotionEvent
+ .obtain(e1);
+ cancelEvent
+ .setAction(MotionEvent.ACTION_CANCEL);
+ mViewFiles.onTouchEvent(cancelEvent);
+
+ deleteFile(mViewFiles.getFirstVisiblePosition()
+ + pos);
+ }
+ }
+
+ /*
+ * Always return false to let the default handler draw
+ * the item properly.
+ */
+ return false;
+ }// onFling()
+ });// mListviewFilesGestureDetector
+ }// initGestureDetector()
+
+ /**
+ * Connects to file provider service, then loads root directory. If can not,
+ * then finishes this activity with result code =
+ * {@link Activity#RESULT_CANCELED}
+ *
+ * @param savedInstanceState
+ */
+ private void loadInitialPath(final Bundle savedInstanceState) {
+ if (BuildConfig.DEBUG)
+ Log.d(CLASSNAME, String.format(
+ "loadInitialPath() >> authority=[%s] | mRoot=[%s]",
+ mFileProviderAuthority, mRoot));
+
+ /*
+ * Priorities for starting path:
+ *
+ * 1. Current location (in case the activity has been killed after
+ * configurations changed).
+ *
+ * 2. Selected file from key EXTRA_SELECT_FILE.
+ *
+ * 3. Root path from key EXTRA_ROOTPATH.
+ *
+ * 4. Last location.
+ */
+
+ new LoadingDialog(getActivity(), false) {
+
+ /**
+ * In onPostExecute(), if result is null then check this value. If
+ * this is not null, show a toast and finish. If this is null, call
+ * showCannotConnectToServiceAndWaitForTheUserToFinish().
+ */
+ String errMsg = null;
+
+ @Override
+ protected Bundle doInBackground(Void... params) {
+ /*
+ * Current location
+ */
+ Uri path = (Uri) (savedInstanceState != null ? savedInstanceState
+ .getParcelable(CURRENT_LOCATION) : null);
+
+ /*
+ * Selected file
+ */
+ if (path == null) {
+ path = (Uri) getArguments().getParcelable(
+ FileChooserActivity.EXTRA_SELECT_FILE);
+ if (path != null
+ && BaseFileProviderUtils.fileExists(getActivity(),
+ path))
+ path = BaseFileProviderUtils.getParentFile(
+ getActivity(), path);
+ }
+
+ /*
+ * Rootpath
+ */
+ if (path == null
+ || !BaseFileProviderUtils.isDirectory(getActivity(),
+ path)) {
+ path = mRoot;
+ }
+
+ /*
+ * Last location
+ */
+ if (path == null
+ && DisplayPrefs.isRememberLastLocation(getActivity())) {
+ String lastLocation = DisplayPrefs
+ .getLastLocation(getActivity());
+ if (lastLocation != null)
+ path = Uri.parse(lastLocation);
+ }
+
+ if (path == null
+ || !BaseFileProviderUtils.isDirectory(getActivity(),
+ path))
+ path = BaseFileProviderUtils.getDefaultPath(
+ getActivity(),
+ path == null ? mFileProviderAuthority : path
+ .getAuthority());
+
+ if (path == null)
+ return null;
+
+ if (BuildConfig.DEBUG)
+ Log.d(CLASSNAME, "loadInitialPath() >> " + path);
+
+ publishProgress(path);
+
+ if (BaseFileProviderUtils.fileCanRead(getActivity(), path)) {
+ Bundle result = new Bundle();
+ result.putParcelable(PATH, path);
+ return result;
+ } else {
+ errMsg = getString(R.string.afc_pmsg_cannot_access_dir,
+ BaseFileProviderUtils.getFileName(getActivity(),
+ path));
+ }
+
+ return null;
+ }// doInBackground()
+
+ @Override
+ protected void onProgressUpdate(Uri... progress) {
+ setCurrentLocation(progress[0]);
+ }// onProgressUpdate()
+
+ @Override
+ protected void onPostExecute(Bundle result) {
+ super.onPostExecute(result);
+
+ if (result != null) {
+ /*
+ * Prepare the loader. Either re-connect with an existing
+ * one, or start a new one.
+ */
+ getLoaderManager().initLoader(mIdLoaderData, result,
+ FragmentFiles.this);
+ } else if (errMsg != null) {
+ Dlg.toast(getActivity(), errMsg, Dlg.LENGTH_SHORT);
+ getActivity().finish();
+ } else
+ showCannotConnectToServiceAndWaitForTheUserToFinish();
+ }// onPostExecute()
+
+ }.execute();
+ }// loadInitialPath()
+
+ /**
+ * Checks if the fragment is loading files...
+ *
+ * @return {@code true} or {@code false}.
+ */
+ public boolean isLoading() {
+ return mLoading;
+ }// isLoading()
+
+ /**
+ * Cancels the loader in progress.
+ */
+ public void cancelPreviousLoader() {
+ /*
+ * Adds a fake path...
+ */
+ if (getCurrentLocation() != null
+ && getLoaderManager().getLoader(mIdLoaderData) != null)
+ BaseFileProviderUtils.cancelTask(getActivity(),
+ getCurrentLocation().getAuthority(), mIdLoaderData);
+
+ mLoading = false;
+ }// cancelPreviousLoader()
+
+ /**
+ * As the name means...
+ */
+ private void showCannotConnectToServiceAndWaitForTheUserToFinish() {
+ Dlg.showError(getActivity(),
+ R.string.afc_msg_cannot_connect_to_file_provider_service,
+ new DialogInterface.OnCancelListener() {
+
+ @Override
+ public void onCancel(DialogInterface dialog) {
+ getActivity().setResult(Activity.RESULT_CANCELED);
+ getActivity().finish();
+ }// onCancel()
+ });
+ }// showCannotConnectToServiceAndWaitForTheUserToFinish()
+
+ /**
+ * Gets last location.
+ *
+ * @return the last location.
+ */
+ private Uri getLastLocation() {
+ return mLastLocation;
+ }// getLastLocation()
+
+ /**
+ * Gets current location.
+ *
+ * @return the current location.
+ */
+ private Uri getCurrentLocation() {
+ return mCurrentLocation;
+ }// getCurrentLocation()
+
+ /**
+ * Sets current location.
+ *
+ * @param location
+ * the location to set.
+ */
+ private void setCurrentLocation(Uri location) {
+ /*
+ * Do this so history's listener will retrieve the right current
+ * location.
+ */
+ mLastLocation = mCurrentLocation;
+ mCurrentLocation = location;
+
+ if (mHistory.indexOf(location) < 0) {
+ mHistory.truncateAfter(mLastLocation);
+ mHistory.push(location);
+ } else
+ mHistory.notifyHistoryChanged();
+
+ updateDbHistory(location);
+ }// setCurrentLocation()
+
+ private void goHome() {
+ goTo(mRoot);
+ }// goHome()
+
+
+ private static final int[] BUTTON_SORT_IDS = {
+ R.id.afc_button_sort_by_name_asc,
+ R.id.afc_button_sort_by_name_desc,
+ R.id.afc_button_sort_by_size_asc,
+ R.id.afc_button_sort_by_size_desc,
+ R.id.afc_button_sort_by_date_asc, R.id.afc_button_sort_by_date_desc };
+
+ /**
+ * Show a dialog for sorting options and resort file list after user
+ * selected an option.
+ */
+ private void resortViewFiles() {
+ final Dialog dialog = new Dialog(getActivity(), Ui.resolveAttribute(
+ getActivity(), R.attr.afc_theme_dialog));
+ dialog.setCanceledOnTouchOutside(true);
+
+ // get the index of button of current sort type
+ int btnCurrentSortTypeIdx = 0;
+ switch (DisplayPrefs.getSortType(getActivity())) {
+ case BaseFile.SORT_BY_NAME:
+ btnCurrentSortTypeIdx = 0;
+ break;
+ case BaseFile.SORT_BY_SIZE:
+ btnCurrentSortTypeIdx = 2;
+ break;
+ case BaseFile.SORT_BY_MODIFICATION_TIME:
+ btnCurrentSortTypeIdx = 4;
+ break;
+ }
+ if (!DisplayPrefs.isSortAscending(getActivity()))
+ btnCurrentSortTypeIdx++;
+
+ View.OnClickListener listener = new View.OnClickListener() {
+
+ @Override
+ public void onClick(View v) {
+ dialog.dismiss();
+
+ if (v.getId() == R.id.afc_button_sort_by_name_asc) {
+ DisplayPrefs.setSortType(getActivity(),
+ BaseFile.SORT_BY_NAME);
+ DisplayPrefs.setSortAscending(getActivity(), true);
+ } else if (v.getId() == R.id.afc_button_sort_by_name_desc) {
+ DisplayPrefs.setSortType(getActivity(),
+ BaseFile.SORT_BY_NAME);
+ DisplayPrefs.setSortAscending(getActivity(), false);
+ } else if (v.getId() == R.id.afc_button_sort_by_size_asc) {
+ DisplayPrefs.setSortType(getActivity(),
+ BaseFile.SORT_BY_SIZE);
+ DisplayPrefs.setSortAscending(getActivity(), true);
+ } else if (v.getId() == R.id.afc_button_sort_by_size_desc) {
+ DisplayPrefs.setSortType(getActivity(),
+ BaseFile.SORT_BY_SIZE);
+ DisplayPrefs.setSortAscending(getActivity(), false);
+ } else if (v.getId() == R.id.afc_button_sort_by_date_asc) {
+ DisplayPrefs.setSortType(getActivity(),
+ BaseFile.SORT_BY_MODIFICATION_TIME);
+ DisplayPrefs.setSortAscending(getActivity(), true);
+ } else if (v.getId() == R.id.afc_button_sort_by_date_desc) {
+ DisplayPrefs.setSortType(getActivity(),
+ BaseFile.SORT_BY_MODIFICATION_TIME);
+ DisplayPrefs.setSortAscending(getActivity(), false);
+ }
+
+ /*
+ * Reload current location.
+ */
+ goTo(getCurrentLocation());
+ getActivity().supportInvalidateOptionsMenu();
+ }// onClick()
+ };// listener
+
+ View view = getLayoutInflater(null).inflate(
+ R.layout.afc_settings_sort_view, null);
+ for (int i = 0; i < BUTTON_SORT_IDS.length; i++) {
+ View v = view.findViewById(BUTTON_SORT_IDS[i]);
+ v.setOnClickListener(listener);
+ if (i == btnCurrentSortTypeIdx) {
+ v.setEnabled(false);
+ if (v instanceof Button)
+ ((Button) v).setText(R.string.afc_bullet);
+ }
+ }
+
+ dialog.setTitle(R.string.afc_title_sort_by);
+ dialog.setContentView(view);
+ dialog.show();
+ }// resortViewFiles()
+
+ /**
+ * Switch view type between {@link ViewType#LIST} and {@link ViewType#GRID}
+ */
+ private void switchViewType() {
+ switch (DisplayPrefs.getViewType(getActivity())) {
+ case GRID:
+ DisplayPrefs.setViewType(getActivity(), ViewType.LIST);
+ break;
+ case LIST:
+ DisplayPrefs.setViewType(getActivity(), ViewType.GRID);
+ break;
+ }
+
+ setupViewFiles();
+ getActivity().supportInvalidateOptionsMenu();
+ goTo(getCurrentLocation());
+ }// switchViewType()
+
+ /**
+ * Checks current conditions to see if we can create new directory. Then
+ * confirms user to do so.
+ */
+ private void checkConditionsThenConfirmUserToCreateNewDir() {
+ if (LocalFileContract.getAuthority(getActivity()).equals(
+ mFileProviderAuthority)
+ && !Utils.hasPermissions(getActivity(),
+ Manifest.permission.WRITE_EXTERNAL_STORAGE)) {
+ Dlg.toast(
+ getActivity(),
+ R.string.afc_msg_app_doesnot_have_permission_to_create_files,
+ Dlg.LENGTH_SHORT);
+ return;
+ }
+
+ new LoadingDialog(getActivity(), false) {
+
+ @Override
+ protected Boolean doInBackground(Void... params) {
+ return getCurrentLocation() != null
+ && BaseFileProviderUtils.fileCanWrite(getActivity(),
+ getCurrentLocation());
+ }// doInBackground()
+
+ @Override
+ protected void onPostExecute(Boolean result) {
+ super.onPostExecute(result);
+
+ if (result)
+ showNewDirectoryCreationDialog();
+ else
+ Dlg.toast(getActivity(),
+ R.string.afc_msg_cannot_create_new_folder_here,
+ Dlg.LENGTH_SHORT);
+ }// onProgressUpdate()
+
+ }.execute();
+ }// checkConditionsThenConfirmUserToCreateNewDir()
+
+ /**
+ * Confirms user to create new directory.
+ */
+ private void showNewDirectoryCreationDialog() {
+ final AlertDialog dialog = Dlg.newAlertDlg(getActivity());
+
+ View view = getLayoutInflater(null).inflate(
+ R.layout.afc_simple_text_input_view, null);
+ final EditText textFile = (EditText) view.findViewById(R.id.afc_text1);
+ textFile.setHint(R.string.afc_hint_folder_name);
+ textFile.setOnEditorActionListener(new TextView.OnEditorActionListener() {
+
+ @Override
+ public boolean onEditorAction(TextView v, int actionId,
+ KeyEvent event) {
+ if (actionId == EditorInfo.IME_ACTION_DONE) {
+ Ui.showSoftKeyboard(v, false);
+ dialog.getButton(DialogInterface.BUTTON_POSITIVE)
+ .performClick();
+ return true;
+ }
+ return false;
+ }
+ });
+
+ dialog.setView(view);
+ dialog.setTitle(R.string.afc_cmd_new_folder);
+ dialog.setIcon(android.R.drawable.ic_menu_add);
+ dialog.setButton(DialogInterface.BUTTON_POSITIVE,
+ getString(android.R.string.ok),
+ new DialogInterface.OnClickListener() {
+
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ final String name = textFile.getText().toString()
+ .trim();
+ if (!FileUtils.isFilenameValid(name)) {
+ Dlg.toast(
+ getActivity(),
+ getString(
+ R.string.afc_pmsg_filename_is_invalid,
+ name), Dlg.LENGTH_SHORT);
+ return;
+ }
+
+ new LoadingDialog(getActivity(), false) {
+
+ @Override
+ protected Uri doInBackground(Void... params) {
+ return getActivity()
+ .getContentResolver()
+ .insert(BaseFile
+ .genContentUriBase(
+ getCurrentLocation()
+ .getAuthority())
+ .buildUpon()
+ .appendPath(
+ getCurrentLocation()
+ .getLastPathSegment())
+ .appendQueryParameter(
+ BaseFile.PARAM_NAME,
+ name)
+ .appendQueryParameter(
+ BaseFile.PARAM_FILE_TYPE,
+ Integer.toString(BaseFile.FILE_TYPE_DIRECTORY))
+ .build(), null);
+ }// doInBackground()
+
+ @Override
+ protected void onPostExecute(Uri result) {
+ super.onPostExecute(result);
+
+ if (result != null) {
+ Dlg.toast(getActivity(),
+ getString(R.string.afc_msg_done),
+ Dlg.LENGTH_SHORT);
+ } else
+ Dlg.toast(
+ getActivity(),
+ getString(
+ R.string.afc_pmsg_cannot_create_folder,
+ name), Dlg.LENGTH_SHORT);
+ }// onPostExecute()
+
+ }.execute();
+ }// onClick()
+ });
+ dialog.show();
+ Ui.showSoftKeyboard(textFile, true);
+
+ final Button buttonOk = dialog
+ .getButton(DialogInterface.BUTTON_POSITIVE);
+ buttonOk.setEnabled(false);
+
+ textFile.addTextChangedListener(new TextWatcher() {
+
+ @Override
+ public void onTextChanged(CharSequence s, int start, int before,
+ int count) {
+ /*
+ * Do nothing.
+ */
+ }// onTextChanged()
+
+ @Override
+ public void beforeTextChanged(CharSequence s, int start, int count,
+ int after) {
+ /*
+ * Do nothing.
+ */
+ }// beforeTextChanged()
+
+ @Override
+ public void afterTextChanged(Editable s) {
+ buttonOk.setEnabled(FileUtils.isFilenameValid(s.toString()
+ .trim()));
+ }// afterTextChanged()
+ });
+ }// showNewDirectoryCreationDialog()
+
+ /**
+ * Deletes a file.
+ *
+ * @param position
+ * the position of item to be delete.
+ */
+ private void deleteFile(final int position) {
+ Cursor cursor = (Cursor) mFileAdapter.getItem(position);
+
+ /*
+ * The cursor can be changed if the list view is updated, so we take its
+ * properties here.
+ */
+ final boolean isFile = BaseFileProviderUtils.isFile(cursor);
+ final String filename = BaseFileProviderUtils.getFileName(cursor);
+
+ if (!BaseFileProviderUtils.fileCanWrite(cursor)) {
+ Dlg.toast(
+ getActivity(),
+ getString(R.string.afc_pmsg_cannot_delete_file,
+ isFile ? getString(R.string.afc_file)
+ : getString(R.string.afc_folder), filename),
+ Dlg.LENGTH_SHORT);
+ return;
+ }
+
+ if (LocalFileContract.getAuthority(getActivity()).equals(
+ mFileProviderAuthority)
+ && !Utils.hasPermissions(getActivity(),
+ Manifest.permission.WRITE_EXTERNAL_STORAGE)) {
+ Dlg.toast(
+ getActivity(),
+ R.string.afc_msg_app_doesnot_have_permission_to_delete_files,
+ Dlg.LENGTH_SHORT);
+ return;
+ }
+
+ /*
+ * The cursor can be changed if the list view is updated, so we take its
+ * properties here.
+ */
+ final int id = cursor.getInt(cursor.getColumnIndex(BaseFile._ID));
+ final Uri uri = BaseFileProviderUtils.getUri(cursor);
+
+ mFileAdapter.markItemAsDeleted(id, true);
+
+ Dlg.confirmYesno(
+ getActivity(),
+ getString(R.string.afc_pmsg_confirm_delete_file,
+ isFile ? getString(R.string.afc_file)
+ : getString(R.string.afc_folder), filename),
+ new DialogInterface.OnClickListener() {
+
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ new LoadingDialog(
+ getActivity(),
+ getString(
+ R.string.afc_pmsg_deleting_file,
+ isFile ? getString(R.string.afc_file)
+ : getString(R.string.afc_folder),
+ filename), true) {
+
+ final int taskId = EnvUtils.genId();
+
+ private void notifyFileDeleted() {
+ Dlg.toast(
+ getActivity(),
+ getString(
+ R.string.afc_pmsg_file_has_been_deleted,
+ isFile ? getString(R.string.afc_file)
+ : getString(R.string.afc_folder),
+ filename), Dlg.LENGTH_SHORT);
+ }// notifyFileDeleted()
+
+ @Override
+ protected Boolean doInBackground(Void... params) {
+ getActivity()
+ .getContentResolver()
+ .delete(uri
+ .buildUpon()
+ .appendQueryParameter(
+ BaseFile.PARAM_TASK_ID,
+ Integer.toString(taskId))
+ .build(), null, null);
+
+ return !BaseFileProviderUtils.fileExists(
+ getActivity(), uri);
+ }// doInBackground()
+
+ @Override
+ protected void onCancelled() {
+ if (getCurrentLocation() != null)
+ BaseFileProviderUtils.cancelTask(
+ getActivity(), getCurrentLocation()
+ .getAuthority(), taskId);
+
+ new LoadingDialog(
+ getActivity(), false) {
+
+ @Override
+ protected Boolean doInBackground(
+ Void... params) {
+ return BaseFileProviderUtils
+ .fileExists(getActivity(), uri);
+ }// doInBackground()
+
+ @Override
+ protected void onPostExecute(Boolean result) {
+ super.onPostExecute(result);
+
+ if (result) {
+ mFileAdapter.markItemAsDeleted(id,
+ false);
+ Dlg.toast(getActivity(),
+ R.string.afc_msg_cancelled,
+ Dlg.LENGTH_SHORT);
+ } else
+ notifyFileDeleted();
+ }// onPostExecute()
+
+ }.execute();
+
+ super.onCancelled();
+ }// onCancelled()
+
+ @Override
+ protected void onPostExecute(Boolean result) {
+ super.onPostExecute(result);
+
+ if (result) {
+ notifyFileDeleted();
+ } else {
+ mFileAdapter.markItemAsDeleted(id, false);
+ Dlg.toast(
+ getActivity(),
+ getString(
+ R.string.afc_pmsg_cannot_delete_file,
+ isFile ? getString(R.string.afc_file)
+ : getString(R.string.afc_folder),
+ filename), Dlg.LENGTH_SHORT);
+ }
+ }// onPostExecute()
+
+ }.execute();// LoadingDialog
+ }// onClick()
+ }, new DialogInterface.OnCancelListener() {
+
+ @Override
+ public void onCancel(DialogInterface dialog) {
+ mFileAdapter.markItemAsDeleted(id, false);
+ }// onCancel()
+ });
+ }// deleteFile()
+
+ /**
+ * As the name means.
+ */
+ private void checkSaveasFilenameAndFinish() {
+ new LoadingDialog(getActivity(), false) {
+
+ String filename;
+ Uri fileUri;
+ int fileType;
+
+ @Override
+ protected void onPreExecute() {
+ super.onPreExecute();
+
+ /*
+ * If the user tapped a file, its URI was stored here. If not,
+ * this is null.
+ */
+ fileUri = (Uri) mTextSaveas.getTag();
+
+ /*
+ * File name and extension.
+ */
+ filename = mTextSaveas.getText().toString().trim();
+ if (fileUri == null
+ && getArguments().containsKey(
+ FileChooserActivity.EXTRA_DEFAULT_FILE_EXT)) {
+ if (!TextUtils.isEmpty(filename)) {
+ String ext = getArguments().getString(
+ FileChooserActivity.EXTRA_DEFAULT_FILE_EXT);
+ if (!filename.matches("(?si)^.+"
+ + Pattern.quote(Texts.C_PERIOD + ext) + "$")) {
+ filename += Texts.C_PERIOD + ext;
+ mTextSaveas.setText(filename);
+ }
+ }
+ }
+ }// onPreExecute()
+
+ @Override
+ protected Uri doInBackground(Void... params) {
+ if (!BaseFileProviderUtils.fileCanWrite(getActivity(),
+ getCurrentLocation())) {
+ publishProgress(getString(R.string.afc_msg_cannot_save_a_file_here));
+ return null;
+ }
+
+ if (fileUri == null && !FileUtils.isFilenameValid(filename)) {
+ publishProgress(getString(
+ R.string.afc_pmsg_filename_is_invalid, filename));
+ return null;
+ }
+
+ if (fileUri == null)
+ fileUri = getCurrentLocation()
+ .buildUpon()
+ .appendQueryParameter(BaseFile.PARAM_APPEND_NAME,
+ filename).build();
+ final Cursor cursor = getActivity().getContentResolver().query(
+ fileUri, null, null, null, null);
+ try {
+ if (cursor == null || !cursor.moveToFirst())
+ return null;
+
+ fileType = cursor.getInt(cursor
+ .getColumnIndex(BaseFile.COLUMN_TYPE));
+ return BaseFileProviderUtils.getUri(cursor);
+ } finally {
+ if (cursor != null)
+ cursor.close();
+ }
+ }// doInBackground()
+
+ @Override
+ protected void onProgressUpdate(String... progress) {
+ Dlg.toast(getActivity(), progress[0], Dlg.LENGTH_SHORT);
+ }// onProgressUpdate()
+
+ @Override
+ protected void onPostExecute(final Uri result) {
+ super.onPostExecute(result);
+
+ if (result == null) {
+ /*
+ * TODO ?
+ */
+ return;
+ }
+
+ switch (fileType) {
+ case BaseFile.FILE_TYPE_DIRECTORY: {
+ Dlg.toast(
+ getActivity(),
+ getString(R.string.afc_pmsg_filename_is_directory,
+ filename), Dlg.LENGTH_SHORT);
+ break;
+ }// FILE_TYPE_DIRECTORY
+
+ case BaseFile.FILE_TYPE_FILE: {
+ Dlg.confirmYesno(
+ getActivity(),
+ getString(R.string.afc_pmsg_confirm_replace_file,
+ filename),
+ new DialogInterface.OnClickListener() {
+
+ @Override
+ public void onClick(DialogInterface dialog,
+ int which) {
+ finish(result, true);
+ }// onClick()
+ });
+
+ break;
+ }// FILE_TYPE_FILE
+
+ case BaseFile.FILE_TYPE_NOT_EXISTED: {
+ finish(result, false);
+ break;
+ }// FILE_TYPE_NOT_EXISTED
+ }
+ }// onPostExecute()
+
+ }.execute();
+ }// checkSaveasFilenameAndFinish()
+
+ /**
+ * Goes to a specified location.
+ *
+ * @param dir
+ * a directory, of course.
+ * @since v4.3 beta
+ */
+ private void goTo(Uri dir) {
+ new LoadingDialog(getActivity(), false) {
+
+ /**
+ * In onPostExecute(), if result is null then check this value. If
+ * this is not null, show a toast. If this is null, call
+ * showCannotConnectToServiceAndWaitForTheUserToFinish().
+ */
+ String errMsg = null;
+
+ @Override
+ protected Bundle doInBackground(Uri... params) {
+ if (params[0] == null)
+ params[0] = BaseFileProviderUtils.getDefaultPath(
+ getActivity(), mFileProviderAuthority);
+ if (params[0] == null)
+ return null;
+
+ /*
+ * Check if the path of `params[0]` is same as current location,
+ * then set `params[0]` to current location. This avoids of
+ * pushing two same paths into history, because we compare the
+ * pointers (not the paths) when pushing it to history.
+ */
+ if (params[0].equals(getCurrentLocation()))
+ params[0] = getCurrentLocation();
+
+ if (BaseFileProviderUtils.fileCanRead(getActivity(), params[0])) {
+ /*
+ * Cancel previous loader if there is one.
+ */
+ cancelPreviousLoader();
+
+ Bundle bundle = new Bundle();
+ bundle.putParcelable(PATH, params[0]);
+ return bundle;
+ }// if
+
+ errMsg = getString(R.string.afc_pmsg_cannot_access_dir,
+ BaseFileProviderUtils.getFileName(getActivity(),
+ params[0]));
+
+ return null;
+ }// doInBackground()
+
+ @Override
+ protected void onPostExecute(Bundle result) {
+ super.onPostExecute(result);
+
+ if (result != null) {
+ setCurrentLocation((Uri) result.getParcelable(PATH));
+ getLoaderManager().restartLoader(mIdLoaderData, result,
+ FragmentFiles.this);
+ } else if (errMsg != null)
+ Dlg.toast(getActivity(), errMsg, Dlg.LENGTH_SHORT);
+ else
+ showCannotConnectToServiceAndWaitForTheUserToFinish();
+ }// onPostExecute()
+
+ }.execute(dir);
+ }// goTo()
+
+ /**
+ * Updates or inserts {@code path} into history database.
+ */
+ private void updateDbHistory(Uri path) {
+ if (BuildConfig.DEBUG)
+ Log.d(CLASSNAME, "updateDbHistory() >> path = " + path);
+
+ Calendar cal = Calendar.getInstance();
+ final long beginTodayMillis = cal.getTimeInMillis()
+ - (cal.get(Calendar.HOUR_OF_DAY) * 60 * 60 * 1000
+ + cal.get(Calendar.MINUTE) * 60 * 1000 + cal
+ .get(Calendar.SECOND) * 1000);
+ if (BuildConfig.DEBUG) {
+ Log.d(CLASSNAME,
+ String.format("beginToday = %s (%s)", DbUtils
+ .formatNumber(beginTodayMillis), new Date(
+ beginTodayMillis)));
+ Log.d(CLASSNAME, String.format("endToday = %s (%s)", DbUtils
+ .formatNumber(beginTodayMillis + DateUtils.DAY_IN_MILLIS),
+ new Date(beginTodayMillis + DateUtils.DAY_IN_MILLIS)));
+ }
+
+ /*
+ * Does the update and returns the number of rows updated.
+ */
+ long time = new Date().getTime();
+ ContentValues values = new ContentValues();
+ values.put(HistoryContract.COLUMN_PROVIDER_ID,
+ BaseFileProviderUtils.getProviderId(path.getAuthority()));
+ values.put(HistoryContract.COLUMN_FILE_TYPE,
+ BaseFile.FILE_TYPE_DIRECTORY);
+ values.put(HistoryContract.COLUMN_URI, path.toString());
+ values.put(HistoryContract.COLUMN_MODIFICATION_TIME,
+ DbUtils.formatNumber(time));
+
+ int count = getActivity()
+ .getContentResolver()
+ .update(HistoryContract.genContentUri(getActivity()),
+ values,
+ String.format(
+ "%s >= '%s' and %s < '%s' and %s = %s and %s like %s",
+ HistoryContract.COLUMN_MODIFICATION_TIME,
+ DbUtils.formatNumber(beginTodayMillis),
+ HistoryContract.COLUMN_MODIFICATION_TIME,
+ DbUtils.formatNumber(beginTodayMillis
+ + DateUtils.DAY_IN_MILLIS),
+ HistoryContract.COLUMN_PROVIDER_ID,
+ DatabaseUtils.sqlEscapeString(values
+ .getAsString(HistoryContract.COLUMN_PROVIDER_ID)),
+ HistoryContract.COLUMN_URI,
+ DatabaseUtils.sqlEscapeString(values
+ .getAsString(HistoryContract.COLUMN_URI))),
+ null);
+ if (count <= 0) {
+ values.put(HistoryContract.COLUMN_CREATE_TIME,
+ DbUtils.formatNumber(time));
+ getActivity().getContentResolver().insert(
+ HistoryContract.genContentUri(getActivity()), values);
+ }
+ }// updateDbHistory()
+
+ /**
+ * As the name means.
+ */
+ private void buildAddressBar(final Uri path) {
+ if (path == null)
+ return;
+
+ mViewAddressBar.removeAllViews();
+
+ new LoadingDialog(getActivity(), false) {
+
+ LinearLayout.LayoutParams lpBtnLoc;
+ LinearLayout.LayoutParams lpDivider;
+ LayoutInflater inflater = getLayoutInflater(null);
+ final int dim = getResources().getDimensionPixelSize(
+ R.dimen.afc_5dp);
+ int count = 0;
+
+ @Override
+ protected void onPreExecute() {
+ super.onPreExecute();
+
+ lpBtnLoc = new LinearLayout.LayoutParams(
+ LinearLayout.LayoutParams.WRAP_CONTENT,
+ LinearLayout.LayoutParams.WRAP_CONTENT);
+ lpBtnLoc.gravity = Gravity.CENTER;
+ }// onPreExecute()
+
+ @Override
+ protected Void doInBackground(Void... params) {
+ Cursor cursor = getActivity().getContentResolver().query(path,
+ null, null, null, null);
+ while (cursor != null) {
+ if (cursor.moveToFirst()) {
+ publishProgress(cursor);
+ cursor.close();
+ } else
+ break;
+
+ /*
+ * Process the parent directory.
+ */
+ Uri uri = Uri.parse(cursor.getString(cursor
+ .getColumnIndex(BaseFile.COLUMN_URI)));
+ cursor = getActivity().getContentResolver().query(
+ BaseFile.genContentUriApi(uri.getAuthority())
+ .buildUpon()
+ .appendPath(BaseFile.CMD_GET_PARENT)
+ .appendQueryParameter(
+ BaseFile.PARAM_SOURCE,
+ uri.getLastPathSegment()).build(),
+ null, null, null, null);
+ }// while
+
+ return null;
+ }// doInBackground()
+
+ @Override
+ protected void onProgressUpdate(Cursor... progress) {
+ /*
+ * Add divider.
+ */
+ if (mViewAddressBar.getChildCount() > 0) {
+ View divider = inflater.inflate(
+ R.layout.afc_view_locations_divider, null);
+
+ if (lpDivider == null) {
+ lpDivider = new LinearLayout.LayoutParams(dim, dim);
+ lpDivider.gravity = Gravity.CENTER;
+ lpDivider.setMargins(dim, dim, dim, dim);
+ }
+ mViewAddressBar.addView(divider, 0, lpDivider);
+ }
+
+ Uri lastUri = Uri.parse(progress[0].getString(progress[0]
+ .getColumnIndex(BaseFile.COLUMN_URI)));
+
+ TextView btnLoc = (TextView) inflater.inflate(
+ R.layout.afc_button_location, null);
+ String name = BaseFileProviderUtils.getFileName(progress[0]);
+ btnLoc.setText(TextUtils.isEmpty(name) ? getString(R.string.afc_root)
+ : name);
+ btnLoc.setTag(lastUri);
+ btnLoc.setOnClickListener(mBtnLocationOnClickListener);
+ btnLoc.setOnLongClickListener(mBtnLocationOnLongClickListener);
+ mViewAddressBar.addView(btnLoc, 0, lpBtnLoc);
+
+ if (count++ == 0) {
+ Rect r = new Rect();
+ btnLoc.getPaint().getTextBounds(name, 0, name.length(), r);
+ if (r.width() >= getResources().getDimensionPixelSize(
+ R.dimen.afc_button_location_max_width)
+ - btnLoc.getPaddingLeft()
+ - btnLoc.getPaddingRight()) {
+ mTextFullDirName.setText(progress[0]
+ .getString(progress[0]
+ .getColumnIndex(BaseFile.COLUMN_NAME)));
+ mTextFullDirName.setVisibility(View.VISIBLE);
+ } else
+ mTextFullDirName.setVisibility(View.GONE);
+ }// if
+ }// onProgressUpdate()
+
+ @Override
+ protected void onPostExecute(Void result) {
+ super.onPostExecute(result);
+
+ /*
+ * Sometimes without delay time, it doesn't work...
+ */
+ mViewLocationsContainer.postDelayed(new Runnable() {
+
+ public void run() {
+ mViewLocationsContainer
+ .fullScroll(HorizontalScrollView.FOCUS_RIGHT);
+ }// run()
+ }, DisplayPrefs.DELAY_TIME_FOR_VERY_SHORT_ANIMATION);
+ }// onPostExecute()
+
+ }.execute();
+ }// buildAddressBar()
+
+ /**
+ * Finishes this activity when save-as.
+ *
+ * @param file
+ * @link Uri.
+ */
+ private void finish(Uri file, boolean fileExists) {
+ ArrayList list = new ArrayList();
+ list.add(file);
+ Intent intent = new Intent();
+ intent.setData(file);
+ intent.putParcelableArrayListExtra(FileChooserActivity.EXTRA_RESULTS,
+ list);
+ intent.putExtra(FileChooserActivity.EXTRA_RESULT_FILE_EXISTS,
+ fileExists);
+ getActivity().setResult(FileChooserActivity.RESULT_OK, intent);
+
+ getActivity().finish();
+ }// finish()
+
+ /**
+ * Finishes this activity.
+ *
+ * @param files
+ * list of {@link Uri}.
+ */
+ private void finish(Uri... files) {
+ List list = new ArrayList();
+ for (Uri uri : files)
+ list.add(uri);
+ finish((ArrayList) list);
+ }// finish()
+
+ /**
+ * Finishes this activity.
+ *
+ * @param files
+ * list of {@link Uri}.
+ */
+ private void finish(ArrayList files) {
+ if (files == null || files.isEmpty()) {
+ getActivity().setResult(Activity.RESULT_CANCELED);
+ getActivity().finish();
+ return;
+ }
+
+ Intent intent = new Intent();
+ if (files.size() == 1)
+ {
+ intent.setData(files.get(0));
+ }
+ intent.putParcelableArrayListExtra(FileChooserActivity.EXTRA_RESULTS,
+ files);
+
+ getActivity().setResult(FileChooserActivity.RESULT_OK, intent);
+
+ if (DisplayPrefs.isRememberLastLocation(getActivity())
+ && getCurrentLocation() != null)
+ DisplayPrefs.setLastLocation(getActivity(), getCurrentLocation()
+ .toString());
+ else
+ DisplayPrefs.setLastLocation(getActivity(), null);
+
+ getActivity().finish();
+ }// finish()
+
+ /*
+ * =========================================================================
+ * BUTTON LISTENERS
+ * =========================================================================
+ */
+
+ private final View.OnClickListener mBtnGoHomeOnClickListener = new View.OnClickListener() {
+
+ @Override
+ public void onClick(View v) {
+ goHome();
+ }// onClick()
+ };// mBtnGoHomeOnClickListener
+
+
+
+ private final View.OnClickListener mBtnGoBackOnClickListener = new View.OnClickListener() {
+
+ @Override
+ public void onClick(View v) {
+ /*
+ * If user deleted a dir which was one in history, then maybe there
+ * are duplicates, so we check and remove them here.
+ */
+ Uri currentLoc = getCurrentLocation();
+ Uri preLoc = null;
+
+ while (currentLoc.equals(preLoc = mHistory.prevOf(currentLoc)))
+ mHistory.remove(preLoc);
+
+ if (preLoc != null)
+ goTo(preLoc);
+ else
+ mViewGoBack.setEnabled(false);
+ }
+ };// mBtnGoBackOnClickListener
+
+ private final View.OnClickListener mBtnLocationOnClickListener = new View.OnClickListener() {
+
+ @Override
+ public void onClick(View v) {
+ if (v.getTag() instanceof Uri) {
+ goTo((Uri) v.getTag());
+ }
+ }// onClick()
+ };// mBtnLocationOnClickListener
+
+ private final View.OnLongClickListener mBtnLocationOnLongClickListener = new View.OnLongClickListener() {
+
+ @Override
+ public boolean onLongClick(View v) {
+ if (BaseFile.FILTER_FILES_ONLY == mFilterMode || mIsSaveDialog)
+ return false;
+
+ finish((Uri) v.getTag());
+
+ return false;
+ }// onLongClick()
+
+ };// mBtnLocationOnLongClickListener
+
+ private final View.OnClickListener mBtnGoForwardOnClickListener = new View.OnClickListener() {
+
+ @Override
+ public void onClick(View v) {
+ /*
+ * If user deleted a dir which was one in history, then maybe there
+ * are duplicates, so we check and remove them here.
+ */
+ Uri currentLoc = getCurrentLocation();
+ Uri nextLoc = null;
+
+ while (currentLoc.equals(nextLoc = mHistory.nextOf(currentLoc)))
+ mHistory.remove(nextLoc);
+
+ if (nextLoc != null)
+ goTo(nextLoc);
+ else
+ mViewGoForward.setEnabled(false);
+ }// onClick()
+ };// mBtnGoForwardOnClickListener
+
+
+ private final View.OnClickListener mBtnOk_SaveDialog_OnClickListener = new View.OnClickListener() {
+
+ @Override
+ public void onClick(View v) {
+ Ui.showSoftKeyboard(v, false);
+ checkSaveasFilenameAndFinish();
+ }// onClick()
+ };// mBtnOk_SaveDialog_OnClickListener
+
+ private final View.OnClickListener mBtnOk_OpenDialog_OnClickListener = new View.OnClickListener() {
+
+ @Override
+ public void onClick(View v) {
+ finish(mFileAdapter.getSelectedItems());
+ }// onClick()
+ };// mBtnOk_OpenDialog_OnClickListener
+
+ /*
+ * FRAGMENT LISTENERS
+ */
+
+
+ /*
+ * LISTVIEW HELPER
+ */
+
+ private final AdapterView.OnItemClickListener mViewFilesOnItemClickListener = new AdapterView.OnItemClickListener() {
+
+ @Override
+ public void onItemClick(AdapterView> parent, View view, int position,
+ long id) {
+ Cursor cursor = (Cursor) mFileAdapter.getItem(position);
+
+ if (BaseFileProviderUtils.isDirectory(cursor)) {
+ goTo(BaseFileProviderUtils.getUri(cursor));
+ return;
+ }
+
+ if (mIsSaveDialog) {
+ mTextSaveas.setText(BaseFileProviderUtils.getFileName(cursor));
+ /*
+ * Always set tag after setting text, or tag will be reset to
+ * null.
+ */
+ mTextSaveas.setTag(BaseFileProviderUtils.getUri(cursor));
+ }
+
+ if (mDoubleTapToChooseFiles) {
+ /*
+ * Do nothing.
+ */
+ return;
+ }// double tap to choose files
+ else {
+ if (mIsMultiSelection)
+ return;
+
+ if (mIsSaveDialog)
+ checkSaveasFilenameAndFinish();
+ else
+ finish(BaseFileProviderUtils.getUri(cursor));
+ }// single tap to choose files
+ }// onItemClick()
+ };// mViewFilesOnItemClickListener
+
+ private final AdapterView.OnItemLongClickListener mViewFilesOnItemLongClickListener = new AdapterView.OnItemLongClickListener() {
+
+ @Override
+ public boolean onItemLongClick(AdapterView> parent, View view,
+ int position, long id) {
+ Cursor cursor = (Cursor) mFileAdapter.getItem(position);
+
+ if (mDoubleTapToChooseFiles) {
+ // do nothing
+ }// double tap to choose files
+ else {
+ if (!mIsSaveDialog
+ && !mIsMultiSelection
+ && BaseFileProviderUtils.isDirectory(cursor)
+ && (BaseFile.FILTER_DIRECTORIES_ONLY == mFilterMode || BaseFile.FILTER_FILES_AND_DIRECTORIES == mFilterMode)) {
+ finish(BaseFileProviderUtils.getUri(cursor));
+ }
+ }// single tap to choose files
+
+ /*
+ * Notify that we already handled long click here.
+ */
+ return true;
+ }// onItemLongClick()
+ };// mViewFilesOnItemLongClickListener
+
+
+
+ /**
+ * We use a {@link LoadingDialog} to avoid of
+ * {@code NetworkOnMainThreadException}.
+ */
+ private LoadingDialog mFileSelector;
+
+ /**
+ * Creates new {@link #mFileSelector} to select appropriate file after
+ * loading a folder's content. It's either the parent path of last path, or
+ * the file provided by key {@link FileChooserActivity#EXTRA_SELECT_FILE}.
+ * Note that this also cancels previous selector if there is such one.
+ */
+ private void createFileSelector() {
+ if (mFileSelector != null)
+ mFileSelector.cancel(true);
+
+ mFileSelector = new LoadingDialog(getActivity(),
+ true) {
+
+ @Override
+ protected Integer doInBackground(Void... params) {
+ final Cursor cursor = mFileAdapter.getCursor();
+ if (cursor == null || cursor.isClosed())
+ return -1;
+
+ final Uri selectedFile = (Uri) getArguments().getParcelable(
+ FileChooserActivity.EXTRA_SELECT_FILE);
+ final int colUri = cursor.getColumnIndex(BaseFile.COLUMN_URI);
+ if (selectedFile != null)
+ getArguments()
+ .remove(FileChooserActivity.EXTRA_SELECT_FILE);
+
+ int shouldBeSelectedIdx = -1;
+ final Uri uri = selectedFile != null ? selectedFile
+ : getLastLocation();
+ if (uri == null
+ || !BaseFileProviderUtils
+ .fileExists(getActivity(), uri))
+ return -1;
+
+ final String fileName = BaseFileProviderUtils.getFileName(
+ getActivity(), uri);
+ if (fileName == null)
+ return -1;
+
+ Uri parentUri = BaseFileProviderUtils.getParentFile(
+ getActivity(), uri);
+ if ((uri == getLastLocation()
+ && !getCurrentLocation().equals(getLastLocation()) && BaseFileProviderUtils
+ .isAncestorOf(getActivity(), getCurrentLocation(),
+ uri))
+ || getCurrentLocation().equals(parentUri)) {
+ if (cursor.moveToFirst()) {
+ while (!cursor.isLast()) {
+ if (isCancelled())
+ return -1;
+
+ Uri subUri = Uri.parse(cursor.getString(colUri));
+ if (uri == getLastLocation()) {
+ if (cursor.getInt(cursor
+ .getColumnIndex(BaseFile.COLUMN_TYPE)) == BaseFile.FILE_TYPE_DIRECTORY) {
+ if (subUri.equals(uri)
+ || BaseFileProviderUtils
+ .isAncestorOf(
+ getActivity(),
+ subUri, uri)) {
+ shouldBeSelectedIdx = Math.max(0,
+ cursor.getPosition() - 2);
+ break;
+ }
+ }
+ } else {
+ if (uri.equals(subUri)) {
+ shouldBeSelectedIdx = Math.max(0,
+ cursor.getPosition() - 2);
+ break;
+ }
+ }
+
+ cursor.moveToNext();
+ }// while
+ }// if
+ }// if
+
+ return shouldBeSelectedIdx;
+ }// doInBackground()
+
+ @Override
+ protected void onPostExecute(final Integer result) {
+ super.onPostExecute(result);
+
+ if (isCancelled() || mFileAdapter.isEmpty())
+ return;
+
+ /*
+ * Use a Runnable to make sure this works. Because if the list
+ * view is handling data, this might not work.
+ *
+ * Also sometimes it doesn't work without a delay.
+ */
+ mViewFiles.postDelayed(new Runnable() {
+
+ @Override
+ public void run() {
+ if (result >= 0 && result < mFileAdapter.getCount())
+ mViewFiles.setSelection(result);
+ else if (!mFileAdapter.isEmpty())
+ mViewFiles.setSelection(0);
+ }// run()
+ }, DisplayPrefs.DELAY_TIME_FOR_VERY_SHORT_ANIMATION);
+ }// onPostExecute()
+
+ };
+
+ mFileSelector.execute();
+ }// createFileSelector()
+
+}
diff --git a/src/java/android-filechooser-AS/app/src/main/java/group/pals/android/lib/ui/filechooser/prefs/DisplayPrefs.java b/src/java/android-filechooser-AS/app/src/main/java/group/pals/android/lib/ui/filechooser/prefs/DisplayPrefs.java
new file mode 100644
index 00000000..72d0ec98
--- /dev/null
+++ b/src/java/android-filechooser-AS/app/src/main/java/group/pals/android/lib/ui/filechooser/prefs/DisplayPrefs.java
@@ -0,0 +1,313 @@
+/*
+ * Copyright (c) 2012 Hai Bison
+ *
+ * See the file LICENSE at the root directory of this project for copying
+ * permission.
+ */
+
+package group.pals.android.lib.ui.filechooser.prefs;
+
+import group.pals.android.lib.ui.filechooser.FileChooserActivity.ViewType;
+import group.pals.android.lib.ui.filechooser.R;
+import group.pals.android.lib.ui.filechooser.providers.basefile.BaseFileContract.BaseFile;
+import android.content.Context;
+
+/**
+ * Display preferences.
+ *
+ * @author Hai Bison
+ * @since v4.3 beta
+ */
+public class DisplayPrefs extends Prefs {
+
+ /**
+ * Delay time for waiting for other threads inside a thread... This is in
+ * milliseconds.
+ */
+ public static final int DELAY_TIME_WAITING_THREADS = 10;
+
+ /**
+ * Delay time for waiting for very short animation, in milliseconds.
+ */
+ public static final int DELAY_TIME_FOR_VERY_SHORT_ANIMATION = 199;
+
+ /**
+ * Delay time for waiting for short animation, in milliseconds.
+ */
+ public static final int DELAY_TIME_FOR_SHORT_ANIMATION = 499;
+
+ /**
+ * Delay time for waiting for simple animation, in milliseconds.
+ */
+ public static final int DELAY_TIME_FOR_SIMPLE_ANIMATION = 999;
+
+ /**
+ * Gets view type.
+ *
+ * @param c
+ * {@link Context}
+ * @return {@link ViewType}
+ */
+ public static ViewType getViewType(Context c) {
+ return ViewType.LIST.ordinal() == p(c).getInt(
+ c.getString(R.string.afc_pkey_display_view_type),
+ c.getResources().getInteger(
+ R.integer.afc_pkey_display_view_type_def)) ? ViewType.LIST
+ : ViewType.GRID;
+ }
+
+ /**
+ * Sets view type.
+ *
+ * @param c
+ * {@link Context}
+ * @param v
+ * {@link ViewType}, if {@code null}, default value will be used.
+ */
+ public static void setViewType(Context c, ViewType v) {
+ String key = c.getString(R.string.afc_pkey_display_view_type);
+ if (v == null)
+ p(c).edit()
+ .putInt(key,
+ c.getResources().getInteger(
+ R.integer.afc_pkey_display_view_type_def))
+ .commit();
+ else
+ p(c).edit().putInt(key, v.ordinal()).commit();
+ }
+
+ /**
+ * Gets sort type.
+ *
+ * @param c
+ * {@link Context}
+ * @return one of {@link BaseFile#SORT_BY_MODIFICATION_TIME},
+ * {@link BaseFile#SORT_BY_NAME}, {@link BaseFile#SORT_BY_SIZE}.
+ */
+ public static int getSortType(Context c) {
+ return p(c).getInt(
+ c.getString(R.string.afc_pkey_display_sort_type),
+ c.getResources().getInteger(
+ R.integer.afc_pkey_display_sort_type_def));
+ }
+
+ /**
+ * Sets {@link SortType}
+ *
+ * @param c
+ * {@link Context}
+ * @param v
+ * one of {@link BaseFile#SORT_BY_MODIFICATION_TIME},
+ * {@link BaseFile#SORT_BY_NAME}, {@link BaseFile#SORT_BY_SIZE}.,
+ * if {@code null}, default value will be used.
+ */
+ public static void setSortType(Context c, Integer v) {
+ String key = c.getString(R.string.afc_pkey_display_sort_type);
+ if (v == null)
+ p(c).edit()
+ .putInt(key,
+ c.getResources().getInteger(
+ R.integer.afc_pkey_display_sort_type_def))
+ .commit();
+ else
+ p(c).edit().putInt(key, v).commit();
+ }
+
+ /**
+ * Gets sort ascending.
+ *
+ * @param c
+ * {@link Context}
+ * @return {@code true} if sort is ascending, {@code false} otherwise.
+ */
+ public static boolean isSortAscending(Context c) {
+ return p(c).getBoolean(
+ c.getString(R.string.afc_pkey_display_sort_ascending),
+ c.getResources().getBoolean(
+ R.bool.afc_pkey_display_sort_ascending_def));
+ }
+
+ /**
+ * Sets sort ascending.
+ *
+ * @param c
+ * {@link Context}
+ * @param v
+ * {@link Boolean}, if {@code null}, default value will be used.
+ */
+ public static void setSortAscending(Context c, Boolean v) {
+ if (v == null)
+ v = c.getResources().getBoolean(
+ R.bool.afc_pkey_display_sort_ascending_def);
+ p(c).edit()
+ .putBoolean(
+ c.getString(R.string.afc_pkey_display_sort_ascending),
+ v).commit();
+ }
+
+ /**
+ * Checks setting of showing time for old days in this year. Default is
+ * {@code false}.
+ *
+ * @param c
+ * {@link Context}.
+ * @return {@code true} or {@code false}.
+ * @since v4.7 beta
+ */
+ public static boolean isShowTimeForOldDaysThisYear(Context c) {
+ return p(c)
+ .getBoolean(
+ c.getString(R.string.afc_pkey_display_show_time_for_old_days_this_year),
+ c.getResources()
+ .getBoolean(
+ R.bool.afc_pkey_display_show_time_for_old_days_this_year_def));
+ }
+
+ /**
+ * Enables or disables showing time of old days in this year.
+ *
+ * @param c
+ * {@link Context}.
+ * @param v
+ * your preferred flag. If {@code null}, default will be used (
+ * {@code false}).
+ * @since v4.7 beta
+ */
+ public static void setShowTimeForOldDaysThisYear(Context c, Boolean v) {
+ if (v == null)
+ v = c.getResources()
+ .getBoolean(
+ R.bool.afc_pkey_display_show_time_for_old_days_this_year_def);
+ p(c).edit()
+ .putBoolean(
+ c.getString(R.string.afc_pkey_display_show_time_for_old_days_this_year),
+ v).commit();
+ }
+
+ /**
+ * Checks setting of showing time for old days in last year and older.
+ * Default is {@code false}.
+ *
+ * @param c
+ * {@link Context}.
+ * @return {@code true} or {@code false}.
+ * @since v4.7 beta
+ */
+ public static boolean isShowTimeForOldDays(Context c) {
+ return p(c).getBoolean(
+ c.getString(R.string.afc_pkey_display_show_time_for_old_days),
+ c.getResources().getBoolean(
+ R.bool.afc_pkey_display_show_time_for_old_days_def));
+ }
+
+ /**
+ * Enables or disables showing time of old days in last year and older.
+ *
+ * @param c
+ * {@link Context}.
+ * @param v
+ * your preferred flag. If {@code null}, default will be used (
+ * {@code false}).
+ * @since v4.7 beta
+ */
+ public static void setShowTimeForOldDays(Context c, Boolean v) {
+ if (v == null)
+ v = c.getResources().getBoolean(
+ R.bool.afc_pkey_display_show_time_for_old_days_def);
+ p(c).edit()
+ .putBoolean(
+ c.getString(R.string.afc_pkey_display_show_time_for_old_days),
+ v).commit();
+ }
+
+ /**
+ * Checks if remembering last location is enabled or not.
+ *
+ * @param c
+ * {@link Context}.
+ * @return {@code true} if remembering last location is enabled.
+ * @since v4.7 beta
+ */
+ public static boolean isRememberLastLocation(Context c) {
+ return false; //KP2A: don't allow to remember because of different protocols
+ }
+
+ /**
+ * Enables or disables remembering last location.
+ *
+ * @param c
+ * {@link Context}.
+ * @param v
+ * your preferred flag. If {@code null}, default will be used (
+ * {@code true}).
+ * @since v4.7 beta
+ */
+ public static void setRememberLastLocation(Context c, Boolean v) {
+ if (v == null)
+ v = c.getResources().getBoolean(
+ R.bool.afc_pkey_display_remember_last_location_def);
+ p(c).edit()
+ .putBoolean(
+ c.getString(R.string.afc_pkey_display_remember_last_location),
+ v).commit();
+ }
+
+ /**
+ * Gets last location.
+ *
+ * @param c
+ * {@link Context}.
+ * @return the last location, or {@code null} if not available.
+ * @since v4.7 beta
+ */
+ public static String getLastLocation(Context c) {
+ return p(c).getString(
+ c.getString(R.string.afc_pkey_display_last_location), null);
+ }
+
+ /**
+ * Sets last location.
+ *
+ * @param c
+ * {@link Context}.
+ * @param v
+ * the last location.
+ */
+ public static void setLastLocation(Context c, String v) {
+ p(c).edit()
+ .putString(
+ c.getString(R.string.afc_pkey_display_last_location), v)
+ .commit();
+ }
+
+ /*
+ * HELPER CLASSES
+ */
+
+ /**
+ * File time display options.
+ *
+ * @author Hai Bison
+ * @see DisplayPrefs#isShowTimeForOldDaysThisYear(Context)
+ * @see DisplayPrefs#isShowTimeForOldDays(Context)
+ * @since v4.9 beta
+ */
+ public static class FileTimeDisplay {
+
+ public boolean showTimeForOldDaysThisYear;
+ public boolean showTimeForOldDays;
+
+ /**
+ * Creates new instance.
+ *
+ * @param showTimeForOldDaysThisYear
+ * @param showTimeForOldDays
+ */
+ public FileTimeDisplay(boolean showTimeForOldDaysThisYear,
+ boolean showTimeForOldDays) {
+ this.showTimeForOldDaysThisYear = showTimeForOldDaysThisYear;
+ this.showTimeForOldDays = showTimeForOldDays;
+ }// FileTimeDisplay()
+ }// FileTimeDisplay
+
+}
diff --git a/src/java/android-filechooser-AS/app/src/main/java/group/pals/android/lib/ui/filechooser/prefs/Prefs.java b/src/java/android-filechooser-AS/app/src/main/java/group/pals/android/lib/ui/filechooser/prefs/Prefs.java
new file mode 100644
index 00000000..983edd9d
--- /dev/null
+++ b/src/java/android-filechooser-AS/app/src/main/java/group/pals/android/lib/ui/filechooser/prefs/Prefs.java
@@ -0,0 +1,83 @@
+/*
+ * Copyright (c) 2012 Hai Bison
+ *
+ * See the file LICENSE at the root directory of this project for copying
+ * permission.
+ */
+
+package group.pals.android.lib.ui.filechooser.prefs;
+
+import group.pals.android.lib.ui.filechooser.utils.Sys;
+import android.annotation.TargetApi;
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.os.Build;
+import android.preference.PreferenceActivity;
+import android.preference.PreferenceFragment;
+import android.preference.PreferenceManager;
+
+/**
+ * Convenient class for working with preferences.
+ *
+ * @author Hai Bison
+ * @since v4.3 beta
+ */
+public class Prefs {
+
+ /**
+ * This unique ID is used for storing preferences.
+ *
+ * @since v4.9 beta
+ */
+ public static final String UID = "9795e88b-2ab4-4b81-a548-409091a1e0c6";
+
+ /**
+ * Generates global preference filename of this library.
+ *
+ * @return the global preference filename.
+ */
+ public static final String genPreferenceFilename() {
+ return String.format("%s_%s", Sys.LIB_NAME, UID);
+ }
+
+ /**
+ * Generates global database filename.
+ *
+ * @param name
+ * the database filename.
+ * @return the global database filename.
+ */
+ public static final String genDatabaseFilename(String name) {
+ return String.format("%s_%s_%s", Sys.LIB_NAME, UID, name);
+ }
+
+ /**
+ * Gets new {@link SharedPreferences}
+ *
+ * @param context
+ * the context.
+ * @return {@link SharedPreferences}
+ */
+ @TargetApi(Build.VERSION_CODES.HONEYCOMB)
+ public static SharedPreferences p(Context context) {
+ // always use application context
+ return context.getApplicationContext().getSharedPreferences(
+ genPreferenceFilename(), Context.MODE_MULTI_PROCESS);
+ }
+
+ /**
+ * Setup {@code pm} to use global unique filename and global access mode.
+ * You must use this method if you let the user change preferences via UI
+ * (such as {@link PreferenceActivity}, {@link PreferenceFragment}...).
+ *
+ * @param pm
+ * {@link PreferenceManager}.
+ * @since v4.9 beta
+ */
+ @TargetApi(Build.VERSION_CODES.HONEYCOMB)
+ public static void setupPreferenceManager(PreferenceManager pm) {
+ pm.setSharedPreferencesMode(Context.MODE_MULTI_PROCESS);
+ pm.setSharedPreferencesName(genPreferenceFilename());
+ }// setupPreferenceManager()
+
+}
diff --git a/src/java/android-filechooser-AS/app/src/main/java/group/pals/android/lib/ui/filechooser/providers/BaseColumns.java b/src/java/android-filechooser-AS/app/src/main/java/group/pals/android/lib/ui/filechooser/providers/BaseColumns.java
new file mode 100644
index 00000000..b7e3bc79
--- /dev/null
+++ b/src/java/android-filechooser-AS/app/src/main/java/group/pals/android/lib/ui/filechooser/providers/BaseColumns.java
@@ -0,0 +1,36 @@
+/*
+ * Copyright (c) 2012 Hai Bison
+ *
+ * See the file LICENSE at the root directory of this project for copying
+ * permission.
+ */
+
+package group.pals.android.lib.ui.filechooser.providers;
+
+/**
+ * The base columns.
+ *
+ * @author Hai Bison
+ * @since v5.1 beta
+ */
+public interface BaseColumns extends android.provider.BaseColumns {
+
+ /**
+ * Column name for the creation timestamp.
+ *
+ * Type: {@code String} representing {@code long} from
+ * {@link java.util.Date#getTime()}. This is because SQLite doesn't handle
+ * Java's {@code long} well.
+ */
+ public static final String COLUMN_CREATE_TIME = "create_time";
+
+ /**
+ * Column name for the modification timestamp.
+ *
+ * Type: {@code String} representing {@code long} from
+ * {@link java.util.Date#getTime()}. This is because SQLite doesn't handle
+ * Java's {@code long} well.
+ */
+ public static final String COLUMN_MODIFICATION_TIME = "modification_time";
+
+}
diff --git a/src/java/android-filechooser-AS/app/src/main/java/group/pals/android/lib/ui/filechooser/providers/BaseFileProviderUtils.java b/src/java/android-filechooser-AS/app/src/main/java/group/pals/android/lib/ui/filechooser/providers/BaseFileProviderUtils.java
new file mode 100644
index 00000000..d06988fe
--- /dev/null
+++ b/src/java/android-filechooser-AS/app/src/main/java/group/pals/android/lib/ui/filechooser/providers/BaseFileProviderUtils.java
@@ -0,0 +1,653 @@
+/*
+ * Copyright (c) 2012 Hai Bison
+ *
+ * See the file LICENSE at the root directory of this project for copying
+ * permission.
+ */
+
+package group.pals.android.lib.ui.filechooser.providers;
+
+import group.pals.android.lib.ui.filechooser.providers.basefile.BaseFileContract.BaseFile;
+import group.pals.android.lib.ui.filechooser.providers.localfile.LocalFileProvider;
+import group.pals.android.lib.ui.filechooser.utils.ui.Ui;
+
+import java.io.File;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Map.Entry;
+
+import android.content.Context;
+import android.database.Cursor;
+import android.database.MatrixCursor;
+import android.net.Uri;
+import android.os.Bundle;
+
+/**
+ * Utilities for base file provider.
+ *
+ * @author Hai Bison
+ * @since v5.1 beta
+ */
+public class BaseFileProviderUtils {
+
+ @SuppressWarnings("unused")
+ private static final String CLASSNAME = BaseFileProviderUtils.class
+ .getName();
+
+ /**
+ * Map of provider ID to its authority.
+ *
+ * Note for developers: If you provide your own provider, use
+ * {@link #registerProviderInfo(String, String)} to register it..
+ */
+ private static final Map MAP_PROVIDER_INFO = new HashMap();
+
+ private static final String COLUMN_AUTHORITY = "authority";
+
+ /**
+ * Registers a file provider.
+ *
+ * @param id
+ * the provider ID. It should be a UUID.
+ * @param authority
+ * the autority.
+ */
+ public static void registerProviderInfo(String id, String authority) {
+ Bundle bundle = new Bundle();
+ bundle.putString(COLUMN_AUTHORITY, authority);
+ MAP_PROVIDER_INFO.put(id, bundle);
+ }// registerProviderInfo()
+
+ /**
+ * Gets provider authority from its ID.
+ *
+ * @param providerId
+ * the provider ID.
+ * @return the provider authority, or {@code null} if not available.
+ */
+ public static String getProviderAuthority(String providerId) {
+ return MAP_PROVIDER_INFO.get(providerId).getString(COLUMN_AUTHORITY);
+ }// getProviderAuthority()
+
+ /**
+ * Gets provider ID from its authority.
+ *
+ * @param authority
+ * the provider authority.
+ * @return the provider ID, or {@code null} if not available.
+ */
+ public static String getProviderId(String authority) {
+ for (Entry entry : MAP_PROVIDER_INFO.entrySet())
+ if (entry.getValue().getString(COLUMN_AUTHORITY).equals(authority))
+ return entry.getKey();
+ return null;
+ }// getProviderId()
+
+ /**
+ * Gets provider name from its ID.
+ *
+ * Note: You should always use the method
+ * {@link #getProviderName(Context, String)} rather than this one whenever
+ * possible. Because this method does not guarantee the result.
+ *
+ * @param providerId
+ * the provider ID.
+ * @return the provider name, or {@code null} if not available.
+ */
+ private static String getProviderName(String providerId) {
+ return MAP_PROVIDER_INFO.get(providerId).getString(
+ BaseFile.COLUMN_PROVIDER_NAME);
+ }// getProviderName()
+
+ /**
+ * Gets provider name from its ID.
+ *
+ * @param context
+ * {@link Context}.
+ * @param providerId
+ * the provider ID.
+ * @return the provider name, can be {@code null} if not provided.
+ */
+ public static String getProviderName(Context context, String providerId) {
+ if (getProviderAuthority(providerId) == null)
+ return null;
+
+ String result = getProviderName(providerId);
+
+ if (result == null) {
+ Cursor cursor = context
+ .getContentResolver()
+ .query(BaseFile
+ .genContentUriApi(getProviderAuthority(providerId)),
+ null, null, null, null);
+ if (cursor == null)
+ return null;
+
+ try {
+ if (cursor.moveToFirst()) {
+ result = cursor.getString(cursor
+ .getColumnIndex(BaseFile.COLUMN_PROVIDER_NAME));
+ setProviderName(providerId, result);
+ } else
+ return null;
+ } finally {
+ cursor.close();
+ }
+ }
+
+ return result;
+ }// getProviderName()
+
+ /**
+ * Sets provider name.
+ *
+ * @param providerId
+ * the provider ID.
+ * @param providerName
+ * the provider name.
+ */
+ private static void setProviderName(String providerId, String providerName) {
+ MAP_PROVIDER_INFO.get(providerId).putString(
+ BaseFile.COLUMN_PROVIDER_NAME, providerName);
+ }// setProviderName()
+
+ /**
+ * Gets the provider icon (badge) resource ID.
+ *
+ * @param context
+ * the context. The resource ID will be retrieved based on this
+ * context's theme (for example light or dark).
+ * @param providerId
+ * the provider ID.
+ * @return the resource ID of the icon (badge).
+ */
+ public static int getProviderIconId(Context context, String providerId) {
+ int attr = MAP_PROVIDER_INFO.get(providerId).getInt(
+ BaseFile.COLUMN_PROVIDER_ICON_ATTR);
+ if (attr == 0) {
+ Cursor cursor = context
+ .getContentResolver()
+ .query(BaseFile
+ .genContentUriApi(getProviderAuthority(providerId)),
+ null, null, null, null);
+ if (cursor != null) {
+ try {
+ if (cursor.moveToFirst()) {
+ attr = cursor
+ .getInt(cursor
+ .getColumnIndex(BaseFile.COLUMN_PROVIDER_ICON_ATTR));
+ MAP_PROVIDER_INFO.get(providerId).putInt(
+ BaseFile.COLUMN_PROVIDER_ICON_ATTR, attr);
+ }
+ } finally {
+ cursor.close();
+ }
+ }
+ }
+
+ int res = Ui.resolveAttribute(context, attr);
+ if (res == 0)
+ res = attr;
+ return res;
+ }// getProviderIconId()
+
+ /**
+ * Default columns of a base file cursor.
+ *
+ * The column orders are:
+ *
+ *
+ *
{@link BaseFile#_ID}
+ *
{@link BaseFile#COLUMN_URI}
+ *
{@link BaseFile#COLUMN_REAL_URI}
+ *
{@link BaseFile#COLUMN_NAME}
+ *
{@link BaseFile#COLUMN_CAN_READ}
+ *
{@link BaseFile#COLUMN_CAN_WRITE}
+ *
{@link BaseFile#COLUMN_SIZE}
+ *
{@link BaseFile#COLUMN_TYPE}
+ *
{@link BaseFile#COLUMN_MODIFICATION_TIME}
+ *
{@link BaseFile#COLUMN_ICON_ID}
+ *
+ */
+ public static final String[] BASE_FILE_CURSOR_COLUMNS = { BaseFile._ID,
+ BaseFile.COLUMN_URI, BaseFile.COLUMN_REAL_URI,
+ BaseFile.COLUMN_NAME, BaseFile.COLUMN_CAN_READ,
+ BaseFile.COLUMN_CAN_WRITE, BaseFile.COLUMN_SIZE,
+ BaseFile.COLUMN_TYPE, BaseFile.COLUMN_MODIFICATION_TIME,
+ BaseFile.COLUMN_ICON_ID };
+
+ /**
+ * Creates new cursor which holds default properties of a base file for
+ * client to access.
+ *
+ * @return the new empty cursor. The columns are
+ * {@link #BASE_FILE_CURSOR_COLUMNS}.
+ */
+ public static MatrixCursor newBaseFileCursor() {
+ return new MatrixCursor(BASE_FILE_CURSOR_COLUMNS);
+ }// newBaseFileCursor()
+
+ /**
+ * Creates new cursor, closes it and returns it ^^
+ *
+ * @return the newly closed cursor.
+ */
+ public static MatrixCursor newClosedCursor() {
+ MatrixCursor cursor = new MatrixCursor(new String[0]);
+ cursor.close();
+ return cursor;
+ }// newClosedCursor()
+
+ /**
+ * Checks if {@code uri} is a directory.
+ *
+ * @param context
+ * {@link Context}.
+ * @param uri
+ * the URI you want to check.
+ * @return {@code true} if {@code uri} is a directory, {@code false}
+ * otherwise.
+ */
+ public static boolean isDirectory(Context context, Uri uri) {
+ Cursor cursor = context.getContentResolver().query(uri, null, null,
+ null, null);
+ if (cursor == null)
+ return false;
+
+ try {
+ if (cursor.moveToFirst())
+ return isDirectory(cursor);
+ return false;
+ } finally {
+ cursor.close();
+ }
+ }// isDirectory()
+
+ /**
+ * Checks if {@code cursor} is a directory.
+ *
+ * @param cursor
+ * the cursor points to a file.
+ * @return {@code true} if {@code cursor} is a directory, {@code false}
+ * otherwise.
+ */
+ public static boolean isDirectory(Cursor cursor) {
+ return cursor.getInt(cursor.getColumnIndex(BaseFile.COLUMN_TYPE)) == BaseFile.FILE_TYPE_DIRECTORY;
+ }// isDirectory()
+
+ /**
+ * Checks if {@code uri} is a file.
+ *
+ * @param context
+ * {@link Context}.
+ * @param uri
+ * the URI you want to check.
+ * @return {@code true} if {@code uri} is a file, {@code false} otherwise.
+ */
+ public static boolean isFile(Context context, Uri uri) {
+ Cursor cursor = context.getContentResolver().query(uri, null, null,
+ null, null);
+ if (cursor == null)
+ return false;
+
+ try {
+ if (cursor.moveToFirst())
+ return isFile(cursor);
+ return false;
+ } finally {
+ cursor.close();
+ }
+ }// isFile()
+
+ /**
+ * Checks if {@code cursor} is a file.
+ *
+ * @param cursor
+ * the cursor points to a file.
+ * @return {@code true} if {@code uri} is a file, {@code false} otherwise.
+ */
+ public static boolean isFile(Cursor cursor) {
+ return cursor.getInt(cursor.getColumnIndex(BaseFile.COLUMN_TYPE)) == BaseFile.FILE_TYPE_FILE;
+ }// isFile()
+
+ /**
+ * Gets file name of {@code uri}.
+ *
+ * @param context
+ * {@link Context}.
+ * @param uri
+ * the URI you want to get.
+ * @return the file name if {@code uri} is a file, {@code null} otherwise.
+ */
+ public static String getFileName(Context context, Uri uri) {
+ Cursor cursor = context.getContentResolver().query(uri, null, null,
+ null, null);
+ if (cursor == null)
+ return null;
+
+ try {
+ if (cursor.moveToFirst())
+ return getFileName(cursor);
+ return null;
+ } finally {
+ cursor.close();
+ }
+ }// getFileName()
+
+ /**
+ * Gets filename of {@code cursor}.
+ *
+ * @param cursor
+ * the cursor points to a file.
+ * @return the filename.
+ */
+ public static String getFileName(Cursor cursor) {
+ return cursor.getString(cursor.getColumnIndex(BaseFile.COLUMN_NAME));
+ }// getFileName()
+
+ /**
+ * Gets the real URI of {@code uri}. This is independent of the content
+ * provider's URI ({@code uri}). For example with {@link LocalFileProvider},
+ * this method gets the URI which you can create new {@link File} object
+ * directly from it.
+ *
+ * @param context
+ * {@link Context}.
+ * @param uri
+ * the content provider URI which you want to get real URI from.
+ * @return the real URI of {@code uri}.
+ */
+ public static Uri getRealUri(Context context, Uri uri) {
+ Cursor cursor = context.getContentResolver().query(uri, null, null,
+ null, null);
+ if (cursor == null)
+ return null;
+
+ try {
+ if (cursor.moveToFirst())
+ return getRealUri(cursor);
+ return null;
+ } finally {
+ cursor.close();
+ }
+ }// getRealUri()
+
+ /**
+ * Gets the real URI. This is independent of the content provider's URI
+ * which {@code cursor} points to. For example with
+ * {@link LocalFileProvider}, this method gets the URI which you can create
+ * new {@link File} object directly from it.
+ *
+ * @param cursor
+ * the cursor points to a file.
+ * @return the real URI.
+ */
+ public static Uri getRealUri(Cursor cursor) {
+ return Uri.parse(cursor.getString(cursor
+ .getColumnIndex(BaseFile.COLUMN_REAL_URI)));
+ }// getRealUri()
+
+ /**
+ * Gets file type of the file pointed by {@code uri}.
+ *
+ * @param context
+ * {@link Context}.
+ * @param uri
+ * the URI you want to get.
+ * @return the file type of {@code uri}, can be one of
+ * {@link #FILE_TYPE_DIRECTORY}, {@link #FILE_TYPE_FILE},
+ * {@link #FILE_TYPE_UNKNOWN}, {@link #FILE_TYPE_NOT_EXISTED}.
+ */
+ public static int getFileType(Context context, Uri uri) {
+ Cursor cursor = context.getContentResolver().query(uri, null, null,
+ null, null);
+ if (cursor == null)
+ return BaseFile.FILE_TYPE_NOT_EXISTED;
+
+ try {
+ if (cursor.moveToFirst())
+ return getFileType(cursor);
+ return BaseFile.FILE_TYPE_NOT_EXISTED;
+ } finally {
+ cursor.close();
+ }
+ }// getFileType()
+
+ /**
+ * Gets file type of the file pointed by {@code cursor}.
+ *
+ * @param cursor
+ * the cursor points to a file.
+ * @return the file type, can be one of {@link #FILE_TYPE_DIRECTORY},
+ * {@link #FILE_TYPE_FILE}, {@link #FILE_TYPE_UNKNOWN},
+ * {@link #FILE_TYPE_NOT_EXISTED}.
+ */
+ public static int getFileType(Cursor cursor) {
+ return cursor.getInt(cursor.getColumnIndex(BaseFile.COLUMN_TYPE));
+ }// getFileType()
+
+ /**
+ * Gets URI of {@code cursor}.
+ *
+ * @param cursor
+ * the cursor points to a file.
+ * @return the URI.
+ */
+ public static Uri getUri(Cursor cursor) {
+ return Uri.parse(cursor.getString(cursor
+ .getColumnIndex(BaseFile.COLUMN_URI)));
+ }// getFileName()
+
+ /**
+ * Checks if the file pointed by {@code uri} is existed or not.
+ *
+ * @param context
+ * {@link Context}.
+ * @param uri
+ * the URI you want to check.
+ * @return {@code true} or {@code false}.
+ */
+ public static boolean fileExists(Context context, Uri uri) {
+ Cursor cursor = context.getContentResolver().query(uri, null, null,
+ null, null);
+ if (cursor == null)
+ return false;
+
+ try {
+ if (cursor.moveToFirst())
+ return cursor.getInt(cursor
+ .getColumnIndex(BaseFile.COLUMN_TYPE)) != BaseFile.FILE_TYPE_NOT_EXISTED;
+ return false;
+ } finally {
+ cursor.close();
+ }
+ }// fileExists()
+
+ /**
+ * Checks if the file pointed by {@code uri} is readable or not.
+ *
+ * @param context
+ * {@link Context}.
+ * @param uri
+ * the URI you want to check.
+ * @return {@code true} or {@code false}.
+ */
+ public static boolean fileCanRead(Context context, Uri uri) {
+ Cursor cursor = context.getContentResolver().query(uri, null, null,
+ null, null);
+ if (cursor == null)
+ return false;
+
+ try {
+ if (cursor.moveToFirst())
+ return fileCanRead(cursor);
+ return false;
+ } finally {
+ cursor.close();
+ }
+ }// fileCanRead()
+
+ /**
+ * Checks if the file pointed be {@code cursor} is readable or not.
+ *
+ * @param cursor
+ * the cursor points to a file.
+ * @return {@code true} or {@code false}.
+ */
+ public static boolean fileCanRead(Cursor cursor) {
+ if (cursor.getInt(cursor.getColumnIndex(BaseFile.COLUMN_CAN_READ)) != 0) {
+ switch (cursor.getInt(cursor.getColumnIndex(BaseFile.COLUMN_TYPE))) {
+ case BaseFile.FILE_TYPE_DIRECTORY:
+ case BaseFile.FILE_TYPE_FILE:
+ return true;
+ }
+ }
+
+ return false;
+ }// fileCanRead()
+
+ /**
+ * Checks if the file pointed by {@code uri} is writable or not.
+ *
+ * @param context
+ * {@link Context}.
+ * @param uri
+ * the URI you want to check.
+ * @return {@code true} or {@code false}.
+ */
+ public static boolean fileCanWrite(Context context, Uri uri) {
+ Cursor cursor = context.getContentResolver().query(uri, null, null,
+ null, null);
+ if (cursor == null)
+ return false;
+
+ try {
+ if (cursor.moveToFirst())
+ return fileCanWrite(cursor);
+ return false;
+ } finally {
+ cursor.close();
+ }
+ }// fileCanWrite()
+
+ /**
+ * Checks if the file pointed by {@code cursor} is writable or not.
+ *
+ * @param cursor
+ * the cursor points to a file.
+ * @return {@code true} or {@code false}.
+ */
+ public static boolean fileCanWrite(Cursor cursor) {
+ if (cursor.getInt(cursor.getColumnIndex(BaseFile.COLUMN_CAN_WRITE)) != 0) {
+ switch (cursor.getInt(cursor.getColumnIndex(BaseFile.COLUMN_TYPE))) {
+ case BaseFile.FILE_TYPE_DIRECTORY:
+ case BaseFile.FILE_TYPE_FILE:
+ return true;
+ }
+ }
+
+ return false;
+ }// fileCanWrite()
+
+ /**
+ * Gets default path of a provider.
+ *
+ * @param context
+ * {@link Context}.
+ * @param authority
+ * the provider's authority.
+ * @return the default path, can be {@code null}.
+ */
+ public static Uri getDefaultPath(Context context, String authority) {
+ Cursor cursor = context.getContentResolver().query(
+ BaseFile.genContentUriApi(authority).buildUpon()
+ .appendPath(BaseFile.CMD_GET_DEFAULT_PATH).build(),
+ null, null, null, null);
+ if (cursor == null)
+ return null;
+
+ try {
+ if (cursor.moveToFirst())
+ return Uri.parse(cursor.getString(cursor
+ .getColumnIndex(BaseFile.COLUMN_URI)));
+ return null;
+ } finally {
+ cursor.close();
+ }
+ }// getDefaultPath()
+
+ /**
+ * Gets parent directory of {@code uri}.
+ *
+ * @param context
+ * {@link Context}.
+ * @param uri
+ * the URI of an existing file.
+ * @return the parent file if it exists, {@code null} otherwise.
+ */
+ public static Uri getParentFile(Context context, Uri uri) {
+ Cursor cursor = context.getContentResolver().query(
+ BaseFile.genContentUriApi(uri.getAuthority())
+ .buildUpon()
+ .appendPath(BaseFile.CMD_GET_PARENT)
+ .appendQueryParameter(BaseFile.PARAM_SOURCE,
+ uri.getLastPathSegment()).build(), null, null,
+ null, null);
+ if (cursor == null)
+ return null;
+
+ try {
+ if (cursor.moveToFirst())
+ return Uri.parse(cursor.getString(cursor
+ .getColumnIndex(BaseFile.COLUMN_URI)));
+ return null;
+ } finally {
+ cursor.close();
+ }
+ }// getParentFile()
+
+ /**
+ * Checks if {@code uri1} is ancestor of {@code uri2}.
+ *
+ * @param context
+ * {@link Context}.
+ * @param uri1
+ * the first URI.
+ * @param uri2
+ * the second URI.
+ * @return {@code true} if {@code uri1} is ancestor of {@code uri2},
+ * {@code false} otherwise.
+ */
+ public static boolean isAncestorOf(Context context, Uri uri1, Uri uri2) {
+ return context.getContentResolver().query(
+ BaseFile.genContentUriApi(uri1.getAuthority())
+ .buildUpon()
+ .appendPath(BaseFile.CMD_IS_ANCESTOR_OF)
+ .appendQueryParameter(BaseFile.PARAM_SOURCE,
+ uri1.getLastPathSegment())
+ .appendQueryParameter(BaseFile.PARAM_TARGET,
+ uri2.getLastPathSegment()).build(), null, null,
+ null, null) != null;
+ }// isAncestorOf()
+
+ /**
+ * Cancels a task with its ID.
+ *
+ * @param context
+ * the context.
+ * @param authority
+ * the file provider authority.
+ * @param taskId
+ * the task ID.
+ */
+ public static void cancelTask(Context context, String authority, int taskId) {
+ context.getContentResolver().query(
+ BaseFile.genContentUriApi(authority)
+ .buildUpon()
+ .appendPath(BaseFile.CMD_CANCEL)
+ .appendQueryParameter(BaseFile.PARAM_TASK_ID,
+ Integer.toString(taskId)).build(), null, null,
+ null, null);
+ }// cancelTask()
+
+}
diff --git a/src/java/android-filechooser-AS/app/src/main/java/group/pals/android/lib/ui/filechooser/providers/DbUtils.java b/src/java/android-filechooser-AS/app/src/main/java/group/pals/android/lib/ui/filechooser/providers/DbUtils.java
new file mode 100644
index 00000000..640be242
--- /dev/null
+++ b/src/java/android-filechooser-AS/app/src/main/java/group/pals/android/lib/ui/filechooser/providers/DbUtils.java
@@ -0,0 +1,86 @@
+/*
+ * Copyright (c) 2012 Hai Bison
+ *
+ * See the file LICENSE at the root directory of this project for copying
+ * permission.
+ */
+
+package group.pals.android.lib.ui.filechooser.providers;
+
+import android.database.DatabaseUtils;
+
+/**
+ * Database utilities.
+ *
+ * @author Hai Bison
+ * @since v5.1 beta
+ */
+public class DbUtils {
+
+ public static final String DATE_FORMAT = "yyyy:MM:dd'T'kk:mm:ss";
+ /**
+ * SQLite component FTS3.
+ *
+ * @since v4.6 beta
+ */
+ public static final String SQLITE_FTS3 = "FTS3";
+ /**
+ * SQLite component FTS4.
+ *
+ * @since v4.6 beta
+ */
+ public static final String SQLITE_FTS4 = "FTS4";
+
+ /**
+ * Hidden column of FTS virtual table.
+ */
+ public static final String SQLITE_FTS_COLUMN_ROW_ID = "rowid";
+
+ /**
+ * Joins all columns into one statement.
+ *
+ * @param cols
+ * array of columns.
+ * @return E.g: "col1,col2,col3"
+ */
+ public static String joinColumns(String[] cols) {
+ if (cols == null)
+ return "";
+
+ StringBuffer sb = new StringBuffer();
+ for (String col : cols) {
+ sb.append(col).append(",");
+ }
+
+ return sb.toString().replaceAll(",$", "");
+ }// joinColumns()
+
+ /**
+ * Formats {@code n} to text to store to database. This method prefixes the
+ * output string with {@code "0"} to make sure the results will always have
+ * same length (for a {@link Long}). So it will work when comparing
+ * different values as text.
+ *
+ * @param n
+ * a long value.
+ * @return the formatted string.
+ */
+ public static String formatNumber(long n) {
+ return String.format("%020d", n);
+ }// formatNumber()
+
+ /**
+ * Calls {@link DatabaseUtils#sqlEscapeString(String)}, then removes single
+ * quotes at the begin and the end of the returned string.
+ *
+ * @param value
+ * the string to escape. If {@code null}, empty string will
+ * return;
+ * @return the "raw" escaped-string.
+ */
+ public static String rawSqlEscapeString(String value) {
+ return value == null ? "" : DatabaseUtils.sqlEscapeString(value)
+ .replaceFirst("(?msi)^'", "").replaceFirst("(?msi)'$", "");
+ }// rawSqlEscapeString()
+
+}
diff --git a/src/java/android-filechooser-AS/app/src/main/java/group/pals/android/lib/ui/filechooser/providers/ProviderUtils.java b/src/java/android-filechooser-AS/app/src/main/java/group/pals/android/lib/ui/filechooser/providers/ProviderUtils.java
new file mode 100644
index 00000000..944be962
--- /dev/null
+++ b/src/java/android-filechooser-AS/app/src/main/java/group/pals/android/lib/ui/filechooser/providers/ProviderUtils.java
@@ -0,0 +1,104 @@
+/*
+ * Copyright (c) 2012 Hai Bison
+ *
+ * See the file LICENSE at the root directory of this project for copying
+ * permission.
+ */
+
+package group.pals.android.lib.ui.filechooser.providers;
+
+import android.content.ContentResolver;
+import android.net.Uri;
+
+/**
+ * Utilities for providers.
+ *
+ * @author Hai Bison
+ * @since v5.1 beta
+ */
+public class ProviderUtils {
+
+ /**
+ * The scheme part for default provider's URI.
+ */
+ public static final String SCHEME = ContentResolver.SCHEME_CONTENT + "://";
+
+ /**
+ * Gets integer parameter.
+ *
+ * @param uri
+ * the original URI.
+ * @param key
+ * the key of query parameter.
+ * @param defaultValue
+ * will be returned if nothing found or parsing value failed.
+ * @return the integer value.
+ */
+ public static int getIntQueryParam(Uri uri, String key, int defaultValue) {
+ try {
+ return Integer.parseInt(uri.getQueryParameter(key));
+ } catch (NumberFormatException e) {
+ return defaultValue;
+ }
+ }// getIntQueryParam()
+
+ /**
+ * Gets long parameter.
+ *
+ * @param uri
+ * the original URI.
+ * @param key
+ * the key of query parameter.
+ * @param defaultValue
+ * will be returned if nothing found or parsing value failed.
+ * @return the long value.
+ */
+ public static long getLongQueryParam(Uri uri, String key, long defaultValue) {
+ try {
+ return Long.parseLong(uri.getQueryParameter(key));
+ } catch (NumberFormatException e) {
+ return defaultValue;
+ }
+ }// getLongQueryParam()
+
+ /**
+ * Gets boolean parameter.
+ *
+ * @param uri
+ * the original URI.
+ * @param key
+ * the key of query parameter.
+ * @return {@code false} if the parameter does not exist, or it is either
+ * {@code "false"} or {@code "0"}. {@code true} otherwise.
+ */
+ public static boolean getBooleanQueryParam(Uri uri, String key) {
+ String param = uri.getQueryParameter(key);
+ if (param == null || Boolean.FALSE.toString().equalsIgnoreCase(param)
+ || Integer.toString(0).equalsIgnoreCase(param))
+ return false;
+ return true;
+ }// getBooleanQueryParam()
+
+ /**
+ * Gets boolean parameter.
+ *
+ * @param uri
+ * the original URI.
+ * @param key
+ * the key of query parameter.
+ * @param defaultValue
+ * the default value if the parameter does not exist.
+ * @return {@code defaultValue} if the parameter does not exist, or it is
+ * either {@code "false"} or {@code "0"}. {@code true} otherwise.
+ */
+ public static boolean getBooleanQueryParam(Uri uri, String key,
+ boolean defaultValue) {
+ String param = uri.getQueryParameter(key);
+ if (param == null)
+ return defaultValue;
+ if (param.matches("(?i)false|(0+)"))
+ return false;
+ return true;
+ }// getBooleanQueryParam()
+
+}
diff --git a/src/java/android-filechooser-AS/app/src/main/java/group/pals/android/lib/ui/filechooser/providers/basefile/BaseFileContract.java b/src/java/android-filechooser-AS/app/src/main/java/group/pals/android/lib/ui/filechooser/providers/basefile/BaseFileContract.java
new file mode 100644
index 00000000..705b3993
--- /dev/null
+++ b/src/java/android-filechooser-AS/app/src/main/java/group/pals/android/lib/ui/filechooser/providers/basefile/BaseFileContract.java
@@ -0,0 +1,537 @@
+/*
+ * Copyright (c) 2012 Hai Bison
+ *
+ * See the file LICENSE at the root directory of this project for copying
+ * permission.
+ */
+
+package group.pals.android.lib.ui.filechooser.providers.basefile;
+
+import group.pals.android.lib.ui.filechooser.providers.BaseColumns;
+import group.pals.android.lib.ui.filechooser.providers.ProviderUtils;
+import group.pals.android.lib.ui.filechooser.providers.localfile.FileObserverEx;
+import group.pals.android.lib.ui.filechooser.providers.localfile.LocalFileProvider;
+
+import java.io.File;
+
+import android.content.ContentResolver;
+import android.net.Uri;
+
+/**
+ * Base file contract.
+ *
+ * @author Hai Bison
+ * @since v5.1 beta
+ */
+public class BaseFileContract {
+
+ /**
+ * This class cannot be instantiated.
+ */
+ private BaseFileContract() {
+ }// BaseFileContract()
+
+ /**
+ * Base file.
+ *
+ * @author Hai Bison
+ * @since v5.1 beta
+ */
+ public static final class BaseFile implements BaseColumns {
+
+ /**
+ * This class cannot be instantiated.
+ */
+ private BaseFile() {
+ }// BaseFile()
+
+ /*
+ * FILE TYPE.
+ */
+
+ /**
+ * Directory.
+ */
+ public static final int FILE_TYPE_DIRECTORY = 0;
+ /**
+ * File.
+ */
+ public static final int FILE_TYPE_FILE = 1;
+ /**
+ * UNKNOWN file type.
+ */
+ public static final int FILE_TYPE_UNKNOWN = 2;
+ /**
+ * File is not existed.
+ */
+ public static final int FILE_TYPE_NOT_EXISTED = 3;
+
+ /*
+ * FILTER MODE.
+ */
+
+ /**
+ * Only files.
+ */
+ public static final int FILTER_FILES_ONLY = 0;
+ /**
+ * Only directories.
+ */
+ public static final int FILTER_DIRECTORIES_ONLY = 1;
+ /**
+ * Files and directories.
+ */
+ public static final int FILTER_FILES_AND_DIRECTORIES = 2;
+
+ /*
+ * SORT MODE.
+ */
+
+ /**
+ * Sort by name.
+ */
+ public static final int SORT_BY_NAME = 0;
+ /**
+ * Sort by size.
+ */
+ public static final int SORT_BY_SIZE = 1;
+ /**
+ * Sort by last modified.
+ */
+ public static final int SORT_BY_MODIFICATION_TIME = 2;
+
+ /*
+ * PATHS
+ */
+
+ /**
+ * This is internal field.
+ *
+ * The path to a single directory's contents. You query this path to get
+ * the contents of that directory.
+ */
+ public static final String PATH_DIR = "dir";
+ /**
+ * This is internal field.
+ *
+ * The path to a single file. This can be a file or a directory.
+ */
+ public static final String PATH_FILE = "file";
+ /**
+ * This is internal field.
+ *
+ * The path to query the provider's information such as name, ID...
+ */
+ public static final String PATH_API = "api";
+
+ /*
+ * COMMANDS.
+ */
+
+ /**
+ * Use this command to cancel a previous task you executed. You set the
+ * task ID with {@link #PARAM_TASK_ID}.
+ *
+ * @see #PARAM_TASK_ID
+ */
+ public static final String CMD_CANCEL = "cancel";
+
+ /**
+ * Use this command along with two parameters: a source directory ID (
+ * {@link #PARAM_SOURCE}) and a target file/ directory ID (
+ * {@link #PARAM_TARGET}). It will return a closed cursor if the
+ * given source file is a directory and it is ancestor of the target
+ * file.
+ *
+ * If the given file is not a directory or is not ancestor of the file
+ * provided by this parameter, the result will be {@code null}.
+ *
+ * For example, with local file, this query returns {@code true}:
+ *
+ * {@code content://local-file-authority/api/is_ancestor_of?source="/mnt/sdcard"&target="/mnt/sdcard/Android/data/cache"}
+ *
+ * Note that no matter how many levels between the ancestor and the
+ * descendant are, it is still the ancestor. This is not
+ * the same concept as "parent", which will return {@code false} in
+ * above example.
+ *
+ * @see #PARAM_SOURCE
+ * @see #PARAM_TARGET
+ */
+ public static final String CMD_IS_ANCESTOR_OF = "is_ancestor_of";
+
+ /**
+ * Use this command to get default path of a provider.
+ *
+ * Type: {@code String}
+ */
+ public static final String CMD_GET_DEFAULT_PATH = "get_default_path";
+
+ /**
+ * Use this parameter to get parent file of a file. You provide the
+ * source file ID with {@link #PARAM_SOURCE}.
+ *
+ * @see #PARAM_SOURCE
+ */
+ public static final String CMD_GET_PARENT = "get_parent";
+
+ /**
+ * Use this command when you don't need to work with the content
+ * provider anymore. Normally Android handles ContentProvider startup
+ * and shutdown automatically. But in case of
+ * {@link LocalFileProvider}, it uses {@link FileObserverEx} to watch
+ * for changes of files. The SDK doesn't clarify the ending events of a
+ * content provider. So the file-observer objects could continue to run
+ * even if your activity has stopped. Hence this command is useful to
+ * let the providers know when they can shutdown the background jobs.
+ */
+ public static final String CMD_SHUTDOWN = "shutdown";
+
+ /*
+ * PARAMETERS.
+ */
+
+ /**
+ * Use this parameter to provide the source file ID.
+ *
+ * Type: URI
+ */
+ public static final String PARAM_SOURCE = "source";
+
+ /**
+ * Use this parameter to provide the target file ID.
+ *
+ * Type: URI
+ */
+ public static final String PARAM_TARGET = "target";
+
+ /**
+ * Use this parameter to provide the name of new file/ directory you
+ * want to create.
+ *
+ * Type: {@code String}
+ *
+ * @see #PARAM_FILE_TYPE
+ */
+ public static final String PARAM_NAME = "name";
+
+ /**
+ * Use this parameter to provide the type of new file that you want to
+ * create. It can be {@link #FILE_TYPE_DIRECTORY} or
+ * {@link #FILE_TYPE_FILE}. If not provided, default is
+ * {@link #FILE_TYPE_DIRECTORY}.
+ *
+ * @see #PARAM_NAME
+ */
+ public static final String PARAM_FILE_TYPE = "file_type";
+
+ /**
+ * Use this parameter to set an ID to any task.
+ *
+ * Default: {@code 0} with all methods.
+ *
+ * Type: {@code Integer}
+ */
+ public static final String PARAM_TASK_ID = "task_id";
+
+ /**
+ * Use this parameter for operators which can work recursively, such as
+ * deleting a directory... The value can be {@code "true"} or
+ * {@code "1"} for {@code true}, {@code "false"} or {@code "0"} for
+ * {@code false}.
+ *
+ * Default:
+ *
+ *
+ *
{@code "true"} with {@code delete()}.
+ *
+ *
+ * Type: {@code Boolean}
+ */
+ public static final String PARAM_RECURSIVE = "recursive";
+
+ /**
+ * Use this parameter to show hidden files. The value can be
+ * {@code "true"} or {@code "1"} for {@code true}, {@code "false"} or
+ * {@code "0"} for {@code false}.
+ *
+ * Default: {@code "false"} with {@code query()}.
+ *
+ * Type: {@code Boolean}
+ */
+ public static final String PARAM_SHOW_HIDDEN_FILES = "show_hidden_files";
+
+ /**
+ * Use this parameter to filter file type. Can be one of
+ * {@link #FILTER_FILES_ONLY}, {@link #FILTER_DIRECTORIES_ONLY},
+ * {@link #FILTER_FILES_AND_DIRECTORIES}.
+ *
+ * Default: {@link #FILTER_FILES_AND_DIRECTORIES} with {@code query()}.
+ *
+ * Type: {@code Integer}
+ */
+ public static final String PARAM_FILTER_MODE = "filter_mode";
+
+ /**
+ * Use this parameter to sort files. Can be one of
+ * {@link #SORT_BY_MODIFICATION_TIME}, {@link #SORT_BY_NAME},
+ * {@link #SORT_BY_SIZE}.
+ *
+ * Default: {@link #SORT_BY_NAME} with {@code query()}.
+ *
+ * Type: {@code Integer}
+ */
+ public static final String PARAM_SORT_BY = "sort_by";
+
+ /**
+ * Use this parameter for sort order. Can be {@code "true"} or
+ * {@code "1"} for {@code true}, {@code "false"} or {@code "0"} for
+ * {@code false}.
+ *
+ * Default: {@code "true"} with {@code query()}.
+ *
+ * Type: {@code Boolean}
+ */
+ public static final String PARAM_SORT_ASCENDING = "sort_ascending";
+
+ /**
+ * Use this parameter to limit results.
+ *
+ * Default: {@code 1000} with {@code query()}.
+ *
+ * Type: {@code Integer}
+ */
+ public static final String PARAM_LIMIT = "limit";
+
+ /**
+ * This parameter is returned from the provider. It's only used for
+ * {@code query()} while querying directory contents. Can be
+ * {@code "true"} or {@code "1"} for {@code true}, {@code "false"} or
+ * {@code "0"} for {@code false}.
+ *
+ * Type: {@code Boolean}
+ */
+ public static final String PARAM_HAS_MORE_FILES = "has_more_files";
+
+ /**
+ * Use this parameter to append a file name to a full path of directory
+ * to obtains its full pathname.
+ *
+ * This parameter can be use together with {@link #PARAM_APPEND_PATH},
+ * the priority is lesser than that parameter.
+ *
+ *
+ *
+ * Type: {@code String}
+ */
+ public static final String PARAM_APPEND_NAME = "append_name";
+
+ /**
+ * Use this parameter to append a partial path to a full path of
+ * directory to obtains its full pathname. The value is a URI, every
+ * path segment of the URI is a partial name. You can build the URI with
+ * scheme {@link ContentResolver#SCHEME_FILE}, appending your paths with
+ * {@link Uri.Builder#appendPath(String)}.
+ *
+ * This parameter can be use together with {@link #PARAM_APPEND_NAME},
+ * the priority is higher than that parameter.
+ *
+ *
+ *
+ * Type: {@code String}
+ *
+ * @see #PARAM_APPEND_NAME
+ */
+ public static final String PARAM_APPEND_PATH = "append_path";
+
+ /**
+ * Use this parameter to set a positive regex to filter filename (with
+ * {@code query()}). If the regex can't be compiled due to syntax error,
+ * then it will be ignored.
+ *
+ * Type: {@code String}
+ */
+ public static final String PARAM_POSITIVE_REGEX_FILTER = "positive_regex_filter";
+
+ /**
+ * Use this parameter to set a negative regex to filter filename (with
+ * {@code query()}). If the regex can't be compiled due to syntax error,
+ * then it will be ignored.
+ *
+ * Type: {@code String}
+ */
+ public static final String PARAM_NEGATIVE_REGEX_FILTER = "negative_regex_filter";
+
+ /**
+ * Use this parameter to tell the provider to validate files or not.
+ *
+ * Type: {@code String} - can be {@code "true"} or {@code "1"} for
+ * {@code true}, {@code "false"} or {@code "0"} for {@code false}.
+ *
+ * Scope:
+ * {@link ContentResolver#query(Uri, String[], String, String[], String)}
+ * and related.
+ *
+ * Default: {@code true}
+ *
+ * @see #CMD_IS_ANCESTOR_OF
+ */
+ public static final String PARAM_VALIDATE = "validate";
+
+ /*
+ * URI builders.
+ */
+
+ /**
+ * Generates content URI API for a provider.
+ *
+ * @param authority
+ * the authority of file provider.
+ * @return The API URI for a provider. Default will return provider name
+ * and ID.
+ */
+ public static Uri genContentUriApi(String authority) {
+ return Uri.parse(ProviderUtils.SCHEME + authority + "/" + PATH_API);
+ }// genContentUriBase()
+
+ /**
+ * Generates content URI base for a single directory's contents. That
+ * means this URI is used to get the content of the given directory,
+ * not the attributes of its. To get the attributes of a
+ * directory (or a file), use {@link #genContentIdUriBase(String)}.
+ *
+ * @param authority
+ * the authority of file provider.
+ * @return The base URI for a single directory. You append it with the
+ * URI to full path of the directory.
+ */
+ public static Uri genContentUriBase(String authority) {
+ return Uri.parse(ProviderUtils.SCHEME + authority + "/" + PATH_DIR
+ + "/");
+ }// genContentUriBase()
+
+ /**
+ * Generates content URI base for a single file.
+ *
+ * @param authority
+ * the authority of file provider.
+ * @return The base URI for a single file. You append it with the URI to
+ * full path of a single file.
+ */
+ public static Uri genContentIdUriBase(String authority) {
+ return Uri.parse(ProviderUtils.SCHEME + authority + "/" + PATH_FILE
+ + "/");
+ }// genContentIdUriBase()
+
+ /*
+ * MIME type definitions.
+ */
+
+ /**
+ * The MIME type providing a directory of files.
+ */
+ public static final String CONTENT_TYPE = "vnd.android.cursor.dir/vnd.android-filechooser.basefile";
+
+ /**
+ * The MIME type of a single file.
+ */
+ public static final String CONTENT_ITEM_TYPE = "vnd.android.cursor.item/vnd.android-filechooser.basefile";
+
+ /*
+ * Column definitions
+ */
+
+ /**
+ * The URI of this file.
+ *
+ * Type: {@code String}
+ */
+ public static final String COLUMN_URI = "uri";
+
+ /**
+ * The real URI of this file. This URI is independent of the content
+ * provider's URI. For example with {@link LocalFileProvider}, this
+ * column contains the URI which you can create new {@link File} object
+ * directly from it.
+ *
+ * Type: {@code String}
+ */
+ public static final String COLUMN_REAL_URI = "real_uri";
+
+ /**
+ * The name of this file.
+ *
+ * Type: {@code String}
+ */
+ public static final String COLUMN_NAME = "name";
+
+ /**
+ * Size of this file.
+ *
+ * Type: {@code Long}
+ */
+ public static final String COLUMN_SIZE = "size";
+
+ /**
+ * Holds the readable attribute of this file, {@code 0 == false} and
+ * {@code 1 == true}.
+ *
+ * Type: {@code Integer}
+ */
+ public static final String COLUMN_CAN_READ = "can_read";
+
+ /**
+ * Holds the writable attribute of this file, {@code 0 == false} and
+ * {@code 1 == true}.
+ *
+ * Type: {@code Integer}
+ */
+ public static final String COLUMN_CAN_WRITE = "can_write";
+
+ /**
+ * The type of this file. Can be one of {@link #FILE_TYPE_DIRECTORY},
+ * {@link #FILE_TYPE_FILE}, {@link #FILE_TYPE_UNKNOWN},
+ * {@link #FILE_TYPE_NOT_EXISTED}.
+ *
+ * Type: {@code Integer}
+ */
+ public static final String COLUMN_TYPE = "type";
+
+ /**
+ * The resource ID of the file icon.
+ *
+ * Type: {@code Integer}
+ */
+ public static final String COLUMN_ICON_ID = "icon_id";
+
+ /**
+ * The name of this provider.
+ *
+ * Type: {@code String}
+ */
+ public static final String COLUMN_PROVIDER_NAME = "provider_name";
+
+ /**
+ * The ID of this provider.
+ *
+ * Type: {@code String}
+ */
+ public static final String COLUMN_PROVIDER_ID = "provider_id";
+
+ /**
+ * The resource ID ({@code R.attr}) of the badge (icon) of the provider.
+ *
+ * Type: {@code Integer}
+ */
+ public static final String COLUMN_PROVIDER_ICON_ATTR = "provider_icon_attr";
+ }// BaseFile
+
+}
diff --git a/src/java/android-filechooser-AS/app/src/main/java/group/pals/android/lib/ui/filechooser/providers/basefile/BaseFileProvider.java b/src/java/android-filechooser-AS/app/src/main/java/group/pals/android/lib/ui/filechooser/providers/basefile/BaseFileProvider.java
new file mode 100644
index 00000000..389aa755
--- /dev/null
+++ b/src/java/android-filechooser-AS/app/src/main/java/group/pals/android/lib/ui/filechooser/providers/basefile/BaseFileProvider.java
@@ -0,0 +1,127 @@
+/*
+ * Copyright (c) 2012 Hai Bison
+ *
+ * See the file LICENSE at the root directory of this project for copying
+ * permission.
+ */
+
+package group.pals.android.lib.ui.filechooser.providers.basefile;
+
+import group.pals.android.lib.ui.filechooser.providers.basefile.BaseFileContract.BaseFile;
+
+import java.text.Collator;
+
+import android.content.ContentProvider;
+import android.content.ContentValues;
+import android.content.UriMatcher;
+import android.database.Cursor;
+import android.net.Uri;
+import android.util.SparseBooleanArray;
+
+/**
+ * Base provider for files.
+ *
+ * @author Hai Bison
+ * @since v5.1 beta
+ */
+public abstract class BaseFileProvider extends ContentProvider {
+
+ /*
+ * Constants used by the Uri matcher to choose an action based on the
+ * pattern of the incoming URI.
+ */
+
+ /**
+ * The incoming URI matches the directory's contents URI pattern.
+ */
+ protected static final int URI_DIRECTORY = 1;
+
+ /**
+ * The incoming URI matches the single file URI pattern.
+ */
+ protected static final int URI_FILE = 2;
+
+ /**
+ * The incoming URI matches the identification URI pattern.
+ */
+ protected static final int URI_API = 3;
+
+ /**
+ * The incoming URI matches the API command URI pattern.
+ */
+ protected static final int URI_API_COMMAND = 4;
+
+ /**
+ * A {@link UriMatcher} instance.
+ */
+ protected static final UriMatcher URI_MATCHER = new UriMatcher(
+ UriMatcher.NO_MATCH);
+
+ /**
+ * Map of task IDs to their interruption signals.
+ */
+ protected final SparseBooleanArray mMapInterruption = new SparseBooleanArray();
+ /**
+ * This collator is used to compare file names.
+ */
+ protected final Collator mCollator = Collator.getInstance();
+
+ @Override
+ public boolean onCreate() {
+ return true;
+ }// onCreate()
+
+ @Override
+ public String getType(Uri uri) {
+ /*
+ * Chooses the MIME type based on the incoming URI pattern.
+ */
+ switch (URI_MATCHER.match(uri)) {
+ case URI_API:
+ case URI_API_COMMAND:
+ case URI_DIRECTORY:
+ return BaseFile.CONTENT_TYPE;
+
+ case URI_FILE:
+ return BaseFile.CONTENT_ITEM_TYPE;
+
+ default:
+ throw new IllegalArgumentException("UNKNOWN URI " + uri);
+ }
+ }// getType()
+
+ @Override
+ public int delete(Uri uri, String selection, String[] selectionArgs) {
+ /*
+ * Do nothing.
+ */
+ return 0;
+ }// delete()
+
+ @Override
+ public Uri insert(Uri uri, ContentValues values) {
+ /*
+ * Do nothing.
+ */
+ return null;
+ }// insert()
+
+ @Override
+ public Cursor query(Uri uri, String[] projection, String selection,
+ String[] selectionArgs, String sortOrder) {
+ /*
+ * Do nothing.
+ */
+ return null;
+ }// query()
+
+ @Override
+ public int update(Uri uri, ContentValues values, String selection,
+ String[] selectionArgs) {
+ /*
+ * Do nothing.
+ */
+ return 0;
+ }// update()
+
+}
diff --git a/src/java/android-filechooser-AS/app/src/main/java/group/pals/android/lib/ui/filechooser/providers/history/HistoryContract.java b/src/java/android-filechooser-AS/app/src/main/java/group/pals/android/lib/ui/filechooser/providers/history/HistoryContract.java
new file mode 100644
index 00000000..36552f7f
--- /dev/null
+++ b/src/java/android-filechooser-AS/app/src/main/java/group/pals/android/lib/ui/filechooser/providers/history/HistoryContract.java
@@ -0,0 +1,127 @@
+/*
+ * Copyright (c) 2012 Hai Bison
+ *
+ * See the file LICENSE at the root directory of this project for copying
+ * permission.
+ */
+
+package group.pals.android.lib.ui.filechooser.providers.history;
+
+import group.pals.android.lib.ui.filechooser.providers.BaseColumns;
+import group.pals.android.lib.ui.filechooser.providers.ProviderUtils;
+import group.pals.android.lib.ui.filechooser.providers.basefile.BaseFileContract.BaseFile;
+import android.content.Context;
+import android.net.Uri;
+
+/**
+ * History contract.
+ *
+ * @author Hai Bison
+ * @since v5.1 beta
+ */
+public final class HistoryContract implements BaseColumns {
+
+ /**
+ * The raw authority.
+ */
+ private static final String AUTHORITY = "android-filechooser.history";
+
+ /**
+ * Gets the authority of this provider.
+ *
+ * @param context
+ * the context.
+ * @return the authority.
+ */
+ public static final String getAuthority(Context context) {
+ return context.getPackageName() + "." + AUTHORITY;
+ }// getAuthority()
+
+ // This class cannot be instantiated
+ private HistoryContract() {
+ }
+
+ /**
+ * The table name offered by this provider.
+ */
+ public static final String TABLE_NAME = "history";
+
+ /*
+ * URI definitions.
+ */
+
+ /**
+ * Path parts for the URIs.
+ */
+
+ /**
+ * Path part for the History URI.
+ */
+ public static final String PATH_HISTORY = "history";
+
+ /**
+ * The content:// style URL for this table.
+ */
+ public static final Uri genContentUri(Context context) {
+ return Uri.parse(ProviderUtils.SCHEME + getAuthority(context) + "/"
+ + PATH_HISTORY);
+ }// genContentUri()
+
+ /**
+ * The content URI base for a single history item. Callers must append a
+ * numeric history ID to this Uri to retrieve a history item.
+ */
+ public static final Uri genContentIdUriBase(Context context) {
+ return Uri.parse(ProviderUtils.SCHEME + getAuthority(context) + "/"
+ + PATH_HISTORY + "/");
+ }
+
+ /*
+ * MIME type definitions.
+ */
+
+ /**
+ * The MIME type of {@link #_ContentUri} providing a directory of history
+ * items.
+ */
+ public static final String CONTENT_TYPE = "vnd.android.cursor.dir/vnd.android-filechooser.history";
+
+ /**
+ * The MIME type of a {@link #_ContentUri} sub-directory of a single history
+ * item.
+ */
+ public static final String CONTENT_ITEM_TYPE = "vnd.android.cursor.item/vnd.android-filechooser.history";
+
+ /**
+ * The default sort order for this table.
+ */
+ public static final String DEFAULT_SORT_ORDER = COLUMN_MODIFICATION_TIME
+ + " DESC";
+
+ /*
+ * Column definitions.
+ */
+
+ /**
+ * Column name for the ID of the provider.
+ *
+ * Type: {@code String}
+ */
+ public static final String COLUMN_PROVIDER_ID = "provider_id";
+
+ /**
+ * Column name for the type of history. The value can be one of
+ * {@link BaseFile#FILE_TYPE_DIRECTORY}, {@link BaseFile#FILE_TYPE_FILE}.
+ *
+ * Type: {@code Integer}
+ */
+ public static final String COLUMN_FILE_TYPE = "file_type";
+
+ /**
+ * Column name for the URI of history.
+ *
+ * Type: {@code URI}
+ */
+ public static final String COLUMN_URI = "uri";
+
+}
diff --git a/src/java/android-filechooser-AS/app/src/main/java/group/pals/android/lib/ui/filechooser/providers/history/HistoryHelper.java b/src/java/android-filechooser-AS/app/src/main/java/group/pals/android/lib/ui/filechooser/providers/history/HistoryHelper.java
new file mode 100644
index 00000000..53a369c0
--- /dev/null
+++ b/src/java/android-filechooser-AS/app/src/main/java/group/pals/android/lib/ui/filechooser/providers/history/HistoryHelper.java
@@ -0,0 +1,58 @@
+/*
+ * Copyright (c) 2012 Hai Bison
+ *
+ * See the file LICENSE at the root directory of this project for copying
+ * permission.
+ */
+
+package group.pals.android.lib.ui.filechooser.providers.history;
+
+import group.pals.android.lib.ui.filechooser.prefs.Prefs;
+import group.pals.android.lib.ui.filechooser.providers.DbUtils;
+import android.content.Context;
+import android.database.sqlite.SQLiteDatabase;
+import android.database.sqlite.SQLiteOpenHelper;
+import android.os.Build;
+
+/**
+ * SQLite helper for history database.
+ *
+ * @author Hai Bison
+ * @since v5.1 beta
+ */
+public class HistoryHelper extends SQLiteOpenHelper {
+
+ private static final String DB_FILENAME = "History.sqlite";
+ private static final int DB_VERSION = 1;
+
+ /**
+ * @since v5.1 beta
+ */
+ private static final String PATTERN_DB_CREATOR_V3 = String
+ .format("CREATE VIRTUAL TABLE " + HistoryContract.TABLE_NAME
+ + " USING %%s(" + HistoryContract.COLUMN_CREATE_TIME + ","
+ + HistoryContract.COLUMN_MODIFICATION_TIME + ","
+ + HistoryContract.COLUMN_PROVIDER_ID + ","
+ + HistoryContract.COLUMN_FILE_TYPE + ","
+ + HistoryContract.COLUMN_URI + ",tokenize=porter);");
+
+ public HistoryHelper(Context context) {
+ // always use application context
+ super(context.getApplicationContext(), Prefs
+ .genDatabaseFilename(DB_FILENAME), null, DB_VERSION);
+ }// HistoryHelper()
+
+ @Override
+ public void onCreate(SQLiteDatabase db) {
+ db.execSQL(String
+ .format(PATTERN_DB_CREATOR_V3,
+ Build.VERSION.SDK_INT < Build.VERSION_CODES.HONEYCOMB ? DbUtils.SQLITE_FTS3
+ : DbUtils.SQLITE_FTS4));
+ }// onCreate()
+
+ @Override
+ public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
+ // TODO
+ }// onUpgrade()
+
+}
diff --git a/src/java/android-filechooser-AS/app/src/main/java/group/pals/android/lib/ui/filechooser/providers/history/HistoryProvider.java b/src/java/android-filechooser-AS/app/src/main/java/group/pals/android/lib/ui/filechooser/providers/history/HistoryProvider.java
new file mode 100644
index 00000000..0475b412
--- /dev/null
+++ b/src/java/android-filechooser-AS/app/src/main/java/group/pals/android/lib/ui/filechooser/providers/history/HistoryProvider.java
@@ -0,0 +1,427 @@
+/*
+ * Copyright (c) 2012 Hai Bison
+ *
+ * See the file LICENSE at the root directory of this project for copying
+ * permission.
+ */
+
+package group.pals.android.lib.ui.filechooser.providers.history;
+
+import group.pals.android.lib.ui.filechooser.BuildConfig;
+import group.pals.android.lib.ui.filechooser.providers.BaseFileProviderUtils;
+import group.pals.android.lib.ui.filechooser.providers.DbUtils;
+import group.pals.android.lib.ui.filechooser.providers.basefile.BaseFileContract.BaseFile;
+import group.pals.android.lib.ui.filechooser.utils.Utils;
+
+import java.util.Arrays;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.Map;
+
+import android.content.ContentProvider;
+import android.content.ContentUris;
+import android.content.ContentValues;
+import android.content.UriMatcher;
+import android.database.Cursor;
+import android.database.MatrixCursor;
+import android.database.MatrixCursor.RowBuilder;
+import android.database.SQLException;
+import android.database.sqlite.SQLiteDatabase;
+import android.database.sqlite.SQLiteQueryBuilder;
+import android.net.Uri;
+import android.text.TextUtils;
+import android.util.Log;
+
+/**
+ * History provider.
+ *
+ * @author Hai Bison
+ * @since v5.1 beta
+ */
+public class HistoryProvider extends ContentProvider {
+
+ private static final String CLASSNAME = HistoryProvider.class.getName();
+
+ /*
+ * Constants used by the Uri matcher to choose an action based on the
+ * pattern of the incoming URI.
+ */
+ /**
+ * The incoming URI matches the history URI pattern.
+ */
+ private static final int URI_HISTORY = 1;
+
+ /**
+ * The incoming URI matches the history ID URI pattern.
+ */
+ private static final int URI_HISTORY_ID = 2;
+
+ /**
+ * A {@link UriMatcher} instance.
+ */
+ private static final UriMatcher URI_MATCHER = new UriMatcher(
+ UriMatcher.NO_MATCH);
+
+ private static final Map MAP_COLUMNS = new HashMap();
+
+ static {
+ MAP_COLUMNS
+ .put(DbUtils.SQLITE_FTS_COLUMN_ROW_ID,
+ DbUtils.SQLITE_FTS_COLUMN_ROW_ID + " AS "
+ + HistoryContract._ID);
+ MAP_COLUMNS.put(HistoryContract.COLUMN_PROVIDER_ID,
+ HistoryContract.COLUMN_PROVIDER_ID);
+ MAP_COLUMNS.put(HistoryContract.COLUMN_FILE_TYPE,
+ HistoryContract.COLUMN_FILE_TYPE);
+ MAP_COLUMNS.put(HistoryContract.COLUMN_URI, HistoryContract.COLUMN_URI);
+ MAP_COLUMNS.put(HistoryContract.COLUMN_CREATE_TIME,
+ HistoryContract.COLUMN_CREATE_TIME);
+ MAP_COLUMNS.put(HistoryContract.COLUMN_MODIFICATION_TIME,
+ HistoryContract.COLUMN_MODIFICATION_TIME);
+ }// static
+
+ private HistoryHelper mHistoryHelper;
+
+ @Override
+ public boolean onCreate() {
+ mHistoryHelper = new HistoryHelper(getContext());
+
+ URI_MATCHER.addURI(HistoryContract.getAuthority(getContext()),
+ HistoryContract.PATH_HISTORY, URI_HISTORY);
+ URI_MATCHER.addURI(HistoryContract.getAuthority(getContext()),
+ HistoryContract.PATH_HISTORY + "/#", URI_HISTORY_ID);
+
+ return true;
+ }// onCreate()
+
+ @Override
+ public String getType(Uri uri) {
+ /*
+ * Chooses the MIME type based on the incoming URI pattern.
+ */
+ switch (URI_MATCHER.match(uri)) {
+ case URI_HISTORY:
+ return HistoryContract.CONTENT_TYPE;
+
+ case URI_HISTORY_ID:
+ return HistoryContract.CONTENT_ITEM_TYPE;
+
+ default:
+ throw new IllegalArgumentException("UNKNOWN URI " + uri);
+ }
+ }// getType()
+
+ @Override
+ public synchronized int delete(Uri uri, String selection,
+ String[] selectionArgs) {
+ // Opens the database object in "write" mode.
+ SQLiteDatabase db = mHistoryHelper.getWritableDatabase();
+ String finalWhere;
+
+ int count;
+
+ // Does the delete based on the incoming URI pattern.
+ switch (URI_MATCHER.match(uri)) {
+ /*
+ * If the incoming pattern matches the general pattern for history
+ * items, does a delete based on the incoming "where" columns and
+ * arguments.
+ */
+ case URI_HISTORY:
+ count = db.delete(HistoryContract.TABLE_NAME, selection,
+ selectionArgs);
+ break;// URI_HISTORY
+
+ /*
+ * If the incoming URI matches a single note ID, does the delete based
+ * on the incoming data, but modifies the where clause to restrict it to
+ * the particular history item ID.
+ */
+ case URI_HISTORY_ID:
+ /*
+ * Starts a final WHERE clause by restricting it to the desired
+ * history item ID.
+ */
+ finalWhere = DbUtils.SQLITE_FTS_COLUMN_ROW_ID + " = "
+ + uri.getLastPathSegment();
+
+ /*
+ * If there were additional selection criteria, append them to the
+ * final WHERE clause
+ */
+ if (selection != null)
+ finalWhere = finalWhere + " AND " + selection;
+
+ // Performs the delete.
+ count = db.delete(HistoryContract.TABLE_NAME, finalWhere,
+ selectionArgs);
+ break;// URI_HISTORY_ID
+
+ // If the incoming pattern is invalid, throws an exception.
+ default:
+ throw new IllegalArgumentException("UNKNOWN URI " + uri);
+ }
+
+ /*
+ * Gets a handle to the content resolver object for the current context,
+ * and notifies it that the incoming URI changed. The object passes this
+ * along to the resolver framework, and observers that have registered
+ * themselves for the provider are notified.
+ */
+ getContext().getContentResolver().notifyChange(uri, null);
+
+ // Returns the number of rows deleted.
+ return count;
+ }// delete()
+
+ @Override
+ public synchronized Uri insert(Uri uri, ContentValues values) {
+ /*
+ * Validates the incoming URI. Only the full provider URI is allowed for
+ * inserts.
+ */
+ if (URI_MATCHER.match(uri) != URI_HISTORY)
+ throw new IllegalArgumentException("UNKNOWN URI " + uri);
+
+ // Gets the current time in milliseconds
+ long now = new Date().getTime();
+
+ /*
+ * If the values map doesn't contain the creation date/ modification
+ * date, sets the value to the current time.
+ */
+ for (String col : new String[] { HistoryContract.COLUMN_CREATE_TIME,
+ HistoryContract.COLUMN_MODIFICATION_TIME })
+ if (!values.containsKey(col))
+ values.put(col, DbUtils.formatNumber(now));
+
+ // Opens the database object in "write" mode.
+ SQLiteDatabase db = mHistoryHelper.getWritableDatabase();
+
+ // Performs the insert and returns the ID of the new note.
+ long rowId = db.insert(HistoryContract.TABLE_NAME, null, values);
+
+ // If the insert succeeded, the row ID exists.
+ if (rowId > 0) {
+ /*
+ * Creates a URI with the note ID pattern and the new row ID
+ * appended to it.
+ */
+ Uri noteUri = ContentUris.withAppendedId(
+ HistoryContract.genContentIdUriBase(getContext()), rowId);
+
+ /*
+ * Notifies observers registered against this provider that the data
+ * changed.
+ */
+ getContext().getContentResolver().notifyChange(noteUri, null);
+ return noteUri;
+ }
+
+ /*
+ * If the insert didn't succeed, then the rowID is <= 0. Throws an
+ * exception.
+ */
+ throw new SQLException("Failed to insert row into " + uri);
+ }// insert()
+
+ @Override
+ public synchronized Cursor query(Uri uri, String[] projection,
+ String selection, String[] selectionArgs, String sortOrder) {
+ if (Utils.doLog())
+ Log.d(CLASSNAME, String.format(
+ "query() >> uri = %s, selection = %s, sortOrder = %s", uri,
+ selection, sortOrder));
+
+ SQLiteQueryBuilder qb = new SQLiteQueryBuilder();
+ qb.setTables(HistoryContract.TABLE_NAME);
+ qb.setProjectionMap(MAP_COLUMNS);
+
+ SQLiteDatabase db = null;
+ Cursor cursor = null;
+
+ /*
+ * Choose the projection and adjust the "where" clause based on URI
+ * pattern-matching.
+ */
+ switch (URI_MATCHER.match(uri)) {
+ case URI_HISTORY: {
+ if (Arrays.equals(projection,
+ new String[] { HistoryContract._COUNT })) {
+ db = mHistoryHelper.getReadableDatabase();
+ cursor = db.rawQuery(
+ String.format(
+ "SELECT COUNT(*) AS %s FROM %s %s",
+ HistoryContract._COUNT,
+ HistoryContract.TABLE_NAME,
+ selection != null ? String.format("WHERE %s",
+ selection) : "").trim(), null);
+ }
+
+ break;
+ }// URI_HISTORY
+
+ /*
+ * If the incoming URI is for a single history item identified by its
+ * ID, chooses the history item ID projection, and appends
+ * "_ID = " to the where clause, so that it selects
+ * that single history item.
+ */
+ case URI_HISTORY_ID: {
+ qb.appendWhere(DbUtils.SQLITE_FTS_COLUMN_ROW_ID + " = "
+ + uri.getLastPathSegment());
+
+ break;
+ }// URI_HISTORY_ID
+
+ default:
+ throw new IllegalArgumentException("UNKNOWN URI " + uri);
+ }
+
+ if (TextUtils.isEmpty(sortOrder))
+ sortOrder = HistoryContract.DEFAULT_SORT_ORDER;
+
+ /*
+ * Opens the database object in "read" mode, since no writes need to be
+ * done.
+ */
+ if (Utils.doLog())
+ Log.d(CLASSNAME,
+ String.format("Going to SQLiteQueryBuilder >> db = %s", db));
+ if (db == null) {
+ db = mHistoryHelper.getReadableDatabase();
+ /*
+ * Performs the query. If no problems occur trying to read the
+ * database, then a Cursor object is returned; otherwise, the cursor
+ * variable contains null. If no records were selected, then the
+ * Cursor object is empty, and Cursor.getCount() returns 0.
+ */
+ cursor = qb.query(db, projection, selection, selectionArgs, null,
+ null, sortOrder);
+ }
+
+ cursor = appendNameAndRealUri(cursor);
+ cursor.setNotificationUri(getContext().getContentResolver(), uri);
+ return cursor;
+ }// query()
+
+ @Override
+ public synchronized int update(Uri uri, ContentValues values,
+ String selection, String[] selectionArgs) {
+ // Opens the database object in "write" mode.
+ SQLiteDatabase db = mHistoryHelper.getWritableDatabase();
+
+ int count;
+ String finalWhere;
+
+ // Does the update based on the incoming URI pattern
+ switch (URI_MATCHER.match(uri)) {
+ /*
+ * If the incoming URI matches the general history items pattern, does
+ * the update based on the incoming data.
+ */
+ case URI_HISTORY:
+ // Does the update and returns the number of rows updated.
+ count = db.update(HistoryContract.TABLE_NAME, values, selection,
+ selectionArgs);
+ break;
+
+ /*
+ * If the incoming URI matches a single history item ID, does the update
+ * based on the incoming data, but modifies the where clause to restrict
+ * it to the particular history item ID.
+ */
+ case URI_HISTORY_ID:
+ /*
+ * Starts creating the final WHERE clause by restricting it to the
+ * incoming item ID.
+ */
+ finalWhere = DbUtils.SQLITE_FTS_COLUMN_ROW_ID + " = "
+ + uri.getLastPathSegment();
+
+ /*
+ * If there were additional selection criteria, append them to the
+ * final WHERE clause
+ */
+ if (selection != null)
+ finalWhere = finalWhere + " AND " + selection;
+
+ // Does the update and returns the number of rows updated.
+ count = db.update(HistoryContract.TABLE_NAME, values, finalWhere,
+ selectionArgs);
+ break;
+
+ // If the incoming pattern is invalid, throws an exception.
+ default:
+ throw new IllegalArgumentException("UNKNOWN URI " + uri);
+ }
+
+ /*
+ * Gets a handle to the content resolver object for the current context,
+ * and notifies it that the incoming URI changed. The object passes this
+ * along to the resolver framework, and observers that have registered
+ * themselves for the provider are notified.
+ */
+ getContext().getContentResolver().notifyChange(uri, null);
+
+ // Returns the number of rows updated.
+ return count;
+ }// update()
+
+ private static final String[] ADDITIONAL_COLUMNS = { BaseFile.COLUMN_NAME,
+ BaseFile.COLUMN_REAL_URI };
+
+ /**
+ * Appends file name and real URI into {@code cursor}.
+ *
+ * @param cursor
+ * the original cursor. It will be closed when done.
+ * @return the new cursor.
+ */
+ private Cursor appendNameAndRealUri(Cursor cursor) {
+ if (cursor == null || cursor.getCount() == 0)
+ return cursor;
+
+ final int colUri = cursor.getColumnIndex(HistoryContract.COLUMN_URI);
+ if (colUri < 0)
+ return cursor;
+
+ String[] columns = new String[cursor.getColumnCount()
+ + ADDITIONAL_COLUMNS.length];
+ System.arraycopy(cursor.getColumnNames(), 0, columns, 0,
+ cursor.getColumnCount());
+ System.arraycopy(ADDITIONAL_COLUMNS, 0, columns,
+ cursor.getColumnCount(), ADDITIONAL_COLUMNS.length);
+
+ MatrixCursor result = new MatrixCursor(columns);
+ if (cursor.moveToFirst()) {
+ do {
+ RowBuilder builder = result.newRow();
+
+ Cursor fileInfo = null;
+ for (int i = 0; i < cursor.getColumnCount(); i++) {
+ String data = cursor.getString(i);
+ builder.add(data);
+
+ if (i == colUri)
+ fileInfo = getContext().getContentResolver().query(
+ Uri.parse(data), null, null, null, null);
+ }
+
+ if (fileInfo != null) {
+ if (fileInfo.moveToFirst()) {
+ builder.add(BaseFileProviderUtils.getFileName(fileInfo));
+ builder.add(BaseFileProviderUtils.getRealUri(fileInfo)
+ .toString());
+ }
+ fileInfo.close();
+ }
+ } while (cursor.moveToNext());
+ }// if
+
+ cursor.close();
+
+ return result;
+ }// appendNameAndRealUri()
+
+}
diff --git a/src/java/android-filechooser-AS/app/src/main/java/group/pals/android/lib/ui/filechooser/providers/history/HistoryProviderUtils.java b/src/java/android-filechooser-AS/app/src/main/java/group/pals/android/lib/ui/filechooser/providers/history/HistoryProviderUtils.java
new file mode 100644
index 00000000..9cdbf0d1
--- /dev/null
+++ b/src/java/android-filechooser-AS/app/src/main/java/group/pals/android/lib/ui/filechooser/providers/history/HistoryProviderUtils.java
@@ -0,0 +1,65 @@
+/*
+ * Copyright (c) 2012 Hai Bison
+ *
+ * See the file LICENSE at the root directory of this project for copying
+ * permission.
+ */
+
+package group.pals.android.lib.ui.filechooser.providers.history;
+
+import group.pals.android.lib.ui.filechooser.BuildConfig;
+import group.pals.android.lib.ui.filechooser.R;
+import group.pals.android.lib.ui.filechooser.providers.DbUtils;
+
+import java.util.Date;
+
+import android.content.Context;
+import android.text.format.DateUtils;
+import android.util.Log;
+
+/**
+ * Utilities for History provider.
+ *
+ * @author Hai Bison
+ * @since v5.1 beta
+ */
+public class HistoryProviderUtils {
+
+ private static final String CLASSNAME = HistoryProviderUtils.class
+ .getName();
+
+ /**
+ * Checks and cleans up out-dated history items.
+ *
+ * @param context
+ * {@link Context}.
+ */
+ public static void doCleanupOutdatedHistoryItems(Context context) {
+ if (BuildConfig.DEBUG)
+ Log.d(CLASSNAME, "doCleanupCache()");
+
+ try {
+ /*
+ * NOTE: be careful with math, use long values instead of integer
+ * ones.
+ */
+ final long validityInMillis = new Date().getTime()
+ - 0;
+
+ if (BuildConfig.DEBUG)
+ Log.d(CLASSNAME, String.format(
+ "doCleanupCache() - validity = %,d (%s)",
+ validityInMillis, new Date(validityInMillis)));
+ context.getContentResolver().delete(
+ HistoryContract.genContentUri(context),
+ String.format("%s < '%s'",
+ HistoryContract.COLUMN_MODIFICATION_TIME,
+ DbUtils.formatNumber(validityInMillis)), null);
+ } catch (Throwable t) {
+ /*
+ * Currently we just ignore it.
+ */
+ }
+ }// doCleanupOutdatedHistoryItems()
+
+}
diff --git a/src/java/android-filechooser-AS/app/src/main/java/group/pals/android/lib/ui/filechooser/providers/localfile/FileObserverEx.java b/src/java/android-filechooser-AS/app/src/main/java/group/pals/android/lib/ui/filechooser/providers/localfile/FileObserverEx.java
new file mode 100644
index 00000000..33c84323
--- /dev/null
+++ b/src/java/android-filechooser-AS/app/src/main/java/group/pals/android/lib/ui/filechooser/providers/localfile/FileObserverEx.java
@@ -0,0 +1,135 @@
+/*
+ * Copyright (c) 2012 Hai Bison
+ *
+ * See the file LICENSE at the root directory of this project for copying
+ * permission.
+ */
+
+package group.pals.android.lib.ui.filechooser.providers.localfile;
+
+import group.pals.android.lib.ui.filechooser.BuildConfig;
+import group.pals.android.lib.ui.filechooser.utils.Utils;
+import android.content.Context;
+import android.net.Uri;
+import android.os.Build;
+import android.os.FileObserver;
+import android.os.Handler;
+import android.os.HandlerThread;
+import android.os.Message;
+import android.os.SystemClock;
+import android.util.Log;
+
+/**
+ * Extended class of {@link FileObserver}, to watch for changes of a directory
+ * and notify clients of {@link LocalFileProvider} about those changes.
+ *
+ * @author Hai Bison
+ * @since v5.1 beta
+ */
+public class FileObserverEx extends FileObserver {
+
+ private static final String CLASSNAME = FileObserverEx.class.getName();
+
+ private static final int FILE_OBSERVER_MASK = FileObserver.CREATE
+ | FileObserver.DELETE | FileObserver.DELETE_SELF
+ | FileObserver.MOVE_SELF | FileObserver.MOVED_FROM
+ | FileObserver.MOVED_TO | FileObserver.ATTRIB | FileObserver.MODIFY;
+
+ private static final long MIN_TIME_BETWEEN_EVENTS = 5000;
+ private static final int MSG_NOTIFY_CHANGES = 0;
+ /**
+ * An unknown event, most likely a bug of the system.
+ */
+ private static final int FILE_OBSERVER_UNKNOWN_EVENT = 32768;
+
+ private final HandlerThread mHandlerThread = new HandlerThread(CLASSNAME);
+ private final Handler mHandler;
+ private long mLastEventTime = SystemClock.elapsedRealtime();
+ private boolean mWatching = false;
+
+ /**
+ * Creates new instance.
+ *
+ * @param context
+ * the context.
+ * @param path
+ * the path to the directory that you want to watch for changes.
+ */
+ public FileObserverEx(final Context context, final String path,
+ final Uri notificationUri) {
+ super(path, FILE_OBSERVER_MASK);
+
+ mHandlerThread.start();
+ mHandler = new Handler(mHandlerThread.getLooper()) {
+
+ @Override
+ public void handleMessage(Message msg) {
+ if (Utils.doLog())
+ Log.d(CLASSNAME,
+ String.format(
+ "mHandler.handleMessage() >> path = '%s' | what = %,d",
+ path, msg.what));
+
+ switch (msg.what) {
+ case MSG_NOTIFY_CHANGES:
+ context.getContentResolver().notifyChange(notificationUri,
+ null);
+ mLastEventTime = SystemClock.elapsedRealtime();
+ break;
+ }
+ }// handleMessage()
+ };
+ }// FileObserverEx()
+
+ @Override
+ public void onEvent(int event, String path) {
+ /*
+ * Some bugs of Android...
+ */
+ if (!mWatching || event == FILE_OBSERVER_UNKNOWN_EVENT || path == null
+ || mHandler.hasMessages(MSG_NOTIFY_CHANGES)
+ || !mHandlerThread.isAlive() || mHandlerThread.isInterrupted())
+ return;
+
+ try {
+ if (SystemClock.elapsedRealtime() - mLastEventTime <= MIN_TIME_BETWEEN_EVENTS)
+ mHandler.sendEmptyMessageDelayed(
+ MSG_NOTIFY_CHANGES,
+ Math.max(
+ 1,
+ MIN_TIME_BETWEEN_EVENTS
+ - (SystemClock.elapsedRealtime() - mLastEventTime)));
+ else
+ mHandler.sendEmptyMessage(MSG_NOTIFY_CHANGES);
+ } catch (Throwable t) {
+ mWatching = false;
+ if (Utils.doLog())
+ Log.e(CLASSNAME, "onEvent() >> " + t);
+ }
+ }// onEvent()
+
+ @Override
+ public void startWatching() {
+ super.startWatching();
+
+ if (Utils.doLog())
+ Log.d(CLASSNAME, String.format("startWatching() >> %s", hashCode()));
+
+ mWatching = true;
+ }// startWatching()
+
+ @Override
+ public void stopWatching() {
+ super.stopWatching();
+
+ if (Utils.doLog())
+ Log.d(CLASSNAME, String.format("stopWatching() >> %s", hashCode()));
+
+ mWatching = false;
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.ECLAIR)
+ HandlerThreadCompat_v5.quit(mHandlerThread);
+ mHandlerThread.interrupt();
+ }// stopWatching()
+
+}
diff --git a/src/java/android-filechooser-AS/app/src/main/java/group/pals/android/lib/ui/filechooser/providers/localfile/HandlerThreadCompat_v5.java b/src/java/android-filechooser-AS/app/src/main/java/group/pals/android/lib/ui/filechooser/providers/localfile/HandlerThreadCompat_v5.java
new file mode 100644
index 00000000..41a9e874
--- /dev/null
+++ b/src/java/android-filechooser-AS/app/src/main/java/group/pals/android/lib/ui/filechooser/providers/localfile/HandlerThreadCompat_v5.java
@@ -0,0 +1,30 @@
+/*
+ * Copyright (c) 2012 Hai Bison
+ *
+ * See the file LICENSE at the root directory of this project for copying
+ * permission.
+ */
+
+package group.pals.android.lib.ui.filechooser.providers.localfile;
+
+import android.os.HandlerThread;
+
+/**
+ * Helper class for backward compatibility of {@link HandlerThread} from API 5+.
+ *
+ * @author Hai Bison
+ * @since v5.1 beta
+ */
+public class HandlerThreadCompat_v5 {
+
+ /**
+ * Wrapper for {@link HandlerThread#quit()}.
+ *
+ * @param thread
+ * the handler thread.
+ */
+ public static void quit(HandlerThread thread) {
+ thread.quit();
+ }// quit()
+
+}
diff --git a/src/java/android-filechooser-AS/app/src/main/java/group/pals/android/lib/ui/filechooser/providers/localfile/LocalFileContract.java b/src/java/android-filechooser-AS/app/src/main/java/group/pals/android/lib/ui/filechooser/providers/localfile/LocalFileContract.java
new file mode 100644
index 00000000..4c5f5e33
--- /dev/null
+++ b/src/java/android-filechooser-AS/app/src/main/java/group/pals/android/lib/ui/filechooser/providers/localfile/LocalFileContract.java
@@ -0,0 +1,41 @@
+/*
+ * Copyright (c) 2012 Hai Bison
+ *
+ * See the file LICENSE at the root directory of this project for copying
+ * permission.
+ */
+
+package group.pals.android.lib.ui.filechooser.providers.localfile;
+
+import android.content.Context;
+
+/**
+ * Contract for local file.
+ *
+ * @author Hai Bison
+ * @since v5.1 beta
+ */
+public class LocalFileContract {
+
+ /**
+ * The raw authority of this provider.
+ */
+ private static final String AUTHORITY = "android-filechooser.localfile";
+
+ /**
+ * Gets the authority of this provider.
+ *
+ * @param context
+ * the context.
+ * @return the authority.
+ */
+ public static final String getAuthority(Context context) {
+ return context.getPackageName() + "." + AUTHORITY;
+ }// getAuthority()
+
+ /**
+ * The unique ID of this provider.
+ */
+ public static final String _ID = "7dab9818-0a8b-47ef-88cc-10fe538bfaf7";
+
+}
diff --git a/src/java/android-filechooser-AS/app/src/main/java/group/pals/android/lib/ui/filechooser/providers/localfile/LocalFileProvider.java b/src/java/android-filechooser-AS/app/src/main/java/group/pals/android/lib/ui/filechooser/providers/localfile/LocalFileProvider.java
new file mode 100644
index 00000000..f084ba1a
--- /dev/null
+++ b/src/java/android-filechooser-AS/app/src/main/java/group/pals/android/lib/ui/filechooser/providers/localfile/LocalFileProvider.java
@@ -0,0 +1,745 @@
+/*
+ * Copyright (c) 2012 Hai Bison
+ *
+ * See the file LICENSE at the root directory of this project for copying
+ * permission.
+ */
+
+package group.pals.android.lib.ui.filechooser.providers.localfile;
+
+import group.pals.android.lib.ui.filechooser.BuildConfig;
+import group.pals.android.lib.ui.filechooser.R;
+import group.pals.android.lib.ui.filechooser.providers.BaseFileProviderUtils;
+import group.pals.android.lib.ui.filechooser.providers.ProviderUtils;
+import group.pals.android.lib.ui.filechooser.providers.basefile.BaseFileContract.BaseFile;
+import group.pals.android.lib.ui.filechooser.providers.basefile.BaseFileProvider;
+import group.pals.android.lib.ui.filechooser.utils.FileUtils;
+import group.pals.android.lib.ui.filechooser.utils.TextUtils;
+import group.pals.android.lib.ui.filechooser.utils.Texts;
+import group.pals.android.lib.ui.filechooser.utils.Utils;
+import java.io.File;
+import java.io.FileFilter;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.List;
+import java.util.concurrent.CancellationException;
+import java.util.regex.Pattern;
+
+import android.content.ContentValues;
+import android.database.Cursor;
+import android.database.MatrixCursor;
+import android.database.MatrixCursor.RowBuilder;
+import android.net.Uri;
+import android.os.Environment;
+import android.util.Log;
+
+/**
+ * Local file provider.
+ *
+ * @author Hai Bison
+ * @since v5.1 beta
+ */
+public class LocalFileProvider extends BaseFileProvider {
+
+ /**
+ * Used for debugging or something...
+ */
+ private static final String CLASSNAME = LocalFileProvider.class.getName();
+
+ private FileObserverEx mFileObserverEx;
+
+ @Override
+ public boolean onCreate() {
+ BaseFileProviderUtils.registerProviderInfo(LocalFileContract._ID,
+ LocalFileContract.getAuthority(getContext()));
+
+ URI_MATCHER.addURI(LocalFileContract.getAuthority(getContext()),
+ BaseFile.PATH_DIR + "/*", URI_DIRECTORY);
+ URI_MATCHER.addURI(LocalFileContract.getAuthority(getContext()),
+ BaseFile.PATH_FILE + "/*", URI_FILE);
+ URI_MATCHER.addURI(LocalFileContract.getAuthority(getContext()),
+ BaseFile.PATH_API, URI_API);
+ URI_MATCHER.addURI(LocalFileContract.getAuthority(getContext()),
+ BaseFile.PATH_API + "/*", URI_API_COMMAND);
+
+ return true;
+ }// onCreate()
+
+ @Override
+ public int delete(Uri uri, String selection, String[] selectionArgs) {
+ if (Utils.doLog())
+ Log.d(CLASSNAME, "delete() >> " + uri);
+
+ int count = 0;
+
+ switch (URI_MATCHER.match(uri)) {
+ case URI_FILE: {
+ int taskId = ProviderUtils.getIntQueryParam(uri,
+ BaseFile.PARAM_TASK_ID, 0);
+
+ boolean isRecursive = ProviderUtils.getBooleanQueryParam(uri,
+ BaseFile.PARAM_RECURSIVE, true);
+ File file = extractFile(uri);
+ if (file.canWrite()) {
+ File parentFile = file.getParentFile();
+
+ if (file.isFile() || !isRecursive) {
+ if (file.delete())
+ count = 1;
+ } else {
+ mMapInterruption.put(taskId, false);
+ count = deleteFile(taskId, file, isRecursive);
+ if (mMapInterruption.get(taskId))
+ if (Utils.doLog())
+ Log.d(CLASSNAME, "delete() >> cancelled...");
+ mMapInterruption.delete(taskId);
+ }
+
+ if (count > 0) {
+ getContext()
+ .getContentResolver()
+ .notifyChange(
+ BaseFile.genContentUriBase(
+ LocalFileContract
+ .getAuthority(getContext()))
+ .buildUpon()
+ .appendPath(
+ Uri.fromFile(parentFile)
+ .toString())
+ .build(), null);
+ }
+ }
+
+ break;// URI_FILE
+ }
+
+ default:
+ throw new IllegalArgumentException("UNKNOWN URI " + uri);
+ }
+
+ if (Utils.doLog())
+ Log.d(CLASSNAME, "delete() >> count = " + count);
+
+ if (count > 0)
+ getContext().getContentResolver().notifyChange(uri, null);
+
+ return count;
+ }// delete()
+
+ @Override
+ public Uri insert(Uri uri, ContentValues values) {
+ if (Utils.doLog())
+ Log.d(CLASSNAME, "insert() >> " + uri);
+
+ switch (URI_MATCHER.match(uri)) {
+ case URI_DIRECTORY:
+ File file = extractFile(uri);
+ if (!file.isDirectory() || !file.canWrite())
+ return null;
+
+ File newFile = new File(String.format("%s/%s",
+ file.getAbsolutePath(),
+ uri.getQueryParameter(BaseFile.PARAM_NAME)));
+
+ switch (ProviderUtils.getIntQueryParam(uri,
+ BaseFile.PARAM_FILE_TYPE, BaseFile.FILE_TYPE_DIRECTORY)) {
+ case BaseFile.FILE_TYPE_DIRECTORY:
+ newFile.mkdir();
+ break;// FILE_TYPE_DIRECTORY
+
+ case BaseFile.FILE_TYPE_FILE:
+ try {
+ newFile.createNewFile();
+ } catch (IOException e) {
+ e.printStackTrace();
+ }
+ break;// FILE_TYPE_FILE
+
+ default:
+ return null;
+ }
+
+ if (newFile.exists()) {
+ Uri newUri = BaseFile
+ .genContentIdUriBase(
+ LocalFileContract.getAuthority(getContext()))
+ .buildUpon()
+ .appendPath(Uri.fromFile(newFile).toString()).build();
+ getContext().getContentResolver().notifyChange(uri, null);
+ return newUri;
+ }
+ return null;// URI_FILE
+
+ default:
+ throw new IllegalArgumentException("UNKNOWN URI " + uri);
+ }
+ }// insert()
+
+ @Override
+ public Cursor query(Uri uri, String[] projection, String selection,
+ String[] selectionArgs, String sortOrder) {
+ if (Utils.doLog())
+ Log.d(CLASSNAME, String.format(
+ "query() >> uri = %s (%s) >> match = %s", uri,
+ uri.getLastPathSegment(), URI_MATCHER.match(uri)));
+
+ switch (URI_MATCHER.match(uri)) {
+ case URI_API: {
+ /*
+ * If there is no command given, return provider ID and name.
+ */
+ MatrixCursor matrixCursor = new MatrixCursor(new String[] {
+ BaseFile.COLUMN_PROVIDER_ID, BaseFile.COLUMN_PROVIDER_NAME,
+ BaseFile.COLUMN_PROVIDER_ICON_ATTR });
+ matrixCursor.newRow().add(LocalFileContract._ID)
+ .add(getContext().getString(R.string.afc_phone))
+ .add(R.attr.afc_badge_file_provider_localfile);
+ return matrixCursor;
+ }
+ case URI_API_COMMAND: {
+ return doAnswerApiCommand(uri);
+ }// URI_API
+
+ case URI_DIRECTORY: {
+ return doListFiles(uri);
+ }// URI_DIRECTORY
+
+ case URI_FILE: {
+ return doRetrieveFileInfo(uri);
+ }// URI_FILE
+
+ default:
+ throw new IllegalArgumentException("UNKNOWN URI " + uri);
+ }
+ }// query()
+
+ /*
+ * UTILITIES
+ */
+
+ /**
+ * Answers the incoming URI.
+ *
+ * @param uri
+ * the request URI.
+ * @return the response.
+ */
+ private MatrixCursor doAnswerApiCommand(Uri uri) {
+ MatrixCursor matrixCursor = null;
+
+ if (BaseFile.CMD_CANCEL.equals(uri.getLastPathSegment())) {
+ int taskId = ProviderUtils.getIntQueryParam(uri,
+ BaseFile.PARAM_TASK_ID, 0);
+ synchronized (mMapInterruption) {
+ if (taskId == 0) {
+ for (int i = 0; i < mMapInterruption.size(); i++)
+ mMapInterruption.put(mMapInterruption.keyAt(i), true);
+ } else if (mMapInterruption.indexOfKey(taskId) >= 0)
+ mMapInterruption.put(taskId, true);
+ }
+ return null;
+ } else if (BaseFile.CMD_GET_DEFAULT_PATH.equals(uri
+ .getLastPathSegment())) {
+ matrixCursor = BaseFileProviderUtils.newBaseFileCursor();
+
+ File file = Environment.getExternalStorageDirectory();
+ if (file == null || !file.isDirectory())
+ file = new File("/");
+ int type = file.isFile() ? BaseFile.FILE_TYPE_FILE : (file
+ .isDirectory() ? BaseFile.FILE_TYPE_DIRECTORY
+ : BaseFile.FILE_TYPE_UNKNOWN);
+ RowBuilder newRow = matrixCursor.newRow();
+ newRow.add(0);// _ID
+ newRow.add(BaseFile
+ .genContentIdUriBase(
+ LocalFileContract.getAuthority(getContext()))
+ .buildUpon().appendPath(Uri.fromFile(file).toString())
+ .build().toString());
+ newRow.add(Uri.fromFile(file).toString());
+ newRow.add(file.getName());
+ newRow.add(file.canRead() ? 1 : 0);
+ newRow.add(file.canWrite() ? 1 : 0);
+ newRow.add(file.length());
+ newRow.add(type);
+ newRow.add(file.lastModified());
+ newRow.add(FileUtils.getResIcon(type, file.getName()));
+ }// get default path
+ else if (BaseFile.CMD_IS_ANCESTOR_OF.equals(uri.getLastPathSegment())) {
+ return doCheckAncestor(uri);
+ } else if (BaseFile.CMD_GET_PARENT.equals(uri.getLastPathSegment())) {
+ File file = new File(Uri.parse(
+ uri.getQueryParameter(BaseFile.PARAM_SOURCE)).getPath());
+ file = file.getParentFile();
+ if (file == null)
+ return null;
+
+ matrixCursor = BaseFileProviderUtils.newBaseFileCursor();
+
+ int type = file.isFile() ? BaseFile.FILE_TYPE_FILE : (file
+ .isDirectory() ? BaseFile.FILE_TYPE_DIRECTORY : (file
+ .exists() ? BaseFile.FILE_TYPE_UNKNOWN
+ : BaseFile.FILE_TYPE_NOT_EXISTED));
+
+ RowBuilder newRow = matrixCursor.newRow();
+ newRow.add(0);// _ID
+ newRow.add(BaseFile
+ .genContentIdUriBase(
+ LocalFileContract.getAuthority(getContext()))
+ .buildUpon().appendPath(Uri.fromFile(file).toString())
+ .build().toString());
+ newRow.add(Uri.fromFile(file).toString());
+ newRow.add(file.getName());
+ newRow.add(file.canRead() ? 1 : 0);
+ newRow.add(file.canWrite() ? 1 : 0);
+ newRow.add(file.length());
+ newRow.add(type);
+ newRow.add(file.lastModified());
+ newRow.add(FileUtils.getResIcon(type, file.getName()));
+ } else if (BaseFile.CMD_SHUTDOWN.equals(uri.getLastPathSegment())) {
+ /*
+ * TODO Stop all tasks. If the activity call this command in
+ * onDestroy(), it seems that this code block will be suspended and
+ * started next time the activity starts. So we comment out this.
+ * Let the Android system do what it wants to do!!!! I hate this.
+ */
+ // synchronized (mMapInterruption) {
+ // for (int i = 0; i < mMapInterruption.size(); i++)
+ // mMapInterruption.put(mMapInterruption.keyAt(i), true);
+ // }
+
+ if (mFileObserverEx != null) {
+ mFileObserverEx.stopWatching();
+ mFileObserverEx = null;
+ }
+ }
+
+ return matrixCursor;
+ }// doAnswerApiCommand()
+
+ /**
+ * Lists the content of a directory, if available.
+ *
+ * @param uri
+ * the URI pointing to a directory.
+ * @return the content of a directory, or {@code null} if not available.
+ */
+ private MatrixCursor doListFiles(Uri uri) {
+ MatrixCursor matrixCursor = BaseFileProviderUtils.newBaseFileCursor();
+
+ File dir = extractFile(uri);
+
+ if (Utils.doLog())
+ Log.d(CLASSNAME, "srcFile = " + dir);
+
+ if (!dir.isDirectory() || !dir.canRead())
+ return null;
+
+ /*
+ * Prepare params...
+ */
+ int taskId = ProviderUtils.getIntQueryParam(uri,
+ BaseFile.PARAM_TASK_ID, 0);
+ boolean showHiddenFiles = ProviderUtils.getBooleanQueryParam(uri,
+ BaseFile.PARAM_SHOW_HIDDEN_FILES);
+ boolean sortAscending = ProviderUtils.getBooleanQueryParam(uri,
+ BaseFile.PARAM_SORT_ASCENDING, true);
+ int sortBy = ProviderUtils.getIntQueryParam(uri,
+ BaseFile.PARAM_SORT_BY, BaseFile.SORT_BY_NAME);
+ int filterMode = ProviderUtils.getIntQueryParam(uri,
+ BaseFile.PARAM_FILTER_MODE,
+ BaseFile.FILTER_FILES_AND_DIRECTORIES);
+ int limit = ProviderUtils.getIntQueryParam(uri, BaseFile.PARAM_LIMIT,
+ 1000);
+ String positiveRegex = uri
+ .getQueryParameter(BaseFile.PARAM_POSITIVE_REGEX_FILTER);
+ String negativeRegex = uri
+ .getQueryParameter(BaseFile.PARAM_NEGATIVE_REGEX_FILTER);
+
+ mMapInterruption.put(taskId, false);
+
+ boolean[] hasMoreFiles = { false };
+ List files = new ArrayList();
+ listFiles(taskId, dir, showHiddenFiles, filterMode, limit,
+ positiveRegex, negativeRegex, files, hasMoreFiles);
+ if (!mMapInterruption.get(taskId)) {
+ sortFiles(taskId, files, sortAscending, sortBy);
+ if (!mMapInterruption.get(taskId)) {
+ for (int i = 0; i < files.size(); i++) {
+ if (mMapInterruption.get(taskId))
+ break;
+
+ File f = files.get(i);
+ int type = f.isFile() ? BaseFile.FILE_TYPE_FILE : (f
+ .isDirectory() ? BaseFile.FILE_TYPE_DIRECTORY
+ : BaseFile.FILE_TYPE_UNKNOWN);
+ RowBuilder newRow = matrixCursor.newRow();
+ newRow.add(i);// _ID
+ newRow.add(BaseFile
+ .genContentIdUriBase(
+ LocalFileContract
+ .getAuthority(getContext()))
+ .buildUpon().appendPath(Uri.fromFile(f).toString())
+ .build().toString());
+ newRow.add(Uri.fromFile(f).toString());
+ newRow.add(f.getName());
+ newRow.add(f.canRead() ? 1 : 0);
+ newRow.add(f.canWrite() ? 1 : 0);
+ newRow.add(f.length());
+ newRow.add(type);
+ newRow.add(f.lastModified());
+ newRow.add(FileUtils.getResIcon(type, f.getName()));
+ }// for files
+
+ /*
+ * The last row contains:
+ *
+ * - The ID;
+ *
+ * - The base file URI to original directory, which has
+ * parameter BaseFile.PARAM_HAS_MORE_FILES to indicate the
+ * directory has more files or not.
+ *
+ * - The system absolute path to original directory.
+ *
+ * - The name of original directory.
+ */
+ RowBuilder newRow = matrixCursor.newRow();
+ newRow.add(files.size());// _ID
+ newRow.add(BaseFile
+ .genContentIdUriBase(
+ LocalFileContract.getAuthority(getContext()))
+ .buildUpon()
+ .appendPath(Uri.fromFile(dir).toString())
+ .appendQueryParameter(BaseFile.PARAM_HAS_MORE_FILES,
+ Boolean.toString(hasMoreFiles[0])).build()
+ .toString());
+ newRow.add(Uri.fromFile(dir).toString());
+ newRow.add(dir.getName());
+ }
+ }
+
+ try {
+ if (mMapInterruption.get(taskId)) {
+ if (Utils.doLog())
+ Log.d(CLASSNAME, "query() >> cancelled...");
+ return null;
+ }
+ } finally {
+ mMapInterruption.delete(taskId);
+ }
+
+ if (mFileObserverEx != null)
+ mFileObserverEx.stopWatching();
+ mFileObserverEx = new FileObserverEx(getContext(),
+ dir.getAbsolutePath(), uri);
+ mFileObserverEx.startWatching();
+
+ /*
+ * Tells the Cursor what URI to watch, so it knows when its source data
+ * changes.
+ */
+ matrixCursor.setNotificationUri(getContext().getContentResolver(), uri);
+ return matrixCursor;
+ }// doListFiles()
+
+ /**
+ * Retrieves file information of a single file.
+ *
+ * @param uri
+ * the URI pointing to a file.
+ * @return the file information. Can be {@code null}, based on the input
+ * parameters.
+ */
+ private MatrixCursor doRetrieveFileInfo(Uri uri) {
+ MatrixCursor matrixCursor = BaseFileProviderUtils.newBaseFileCursor();
+
+ File file = extractFile(uri);
+ int type = file.isFile() ? BaseFile.FILE_TYPE_FILE : (file
+ .isDirectory() ? BaseFile.FILE_TYPE_DIRECTORY
+ : (file.exists() ? BaseFile.FILE_TYPE_UNKNOWN
+ : BaseFile.FILE_TYPE_NOT_EXISTED));
+ RowBuilder newRow = matrixCursor.newRow();
+ newRow.add(0);// _ID
+ newRow.add(BaseFile
+ .genContentIdUriBase(
+ LocalFileContract.getAuthority(getContext()))
+ .buildUpon().appendPath(Uri.fromFile(file).toString()).build()
+ .toString());
+ newRow.add(Uri.fromFile(file).toString());
+ newRow.add(file.getName());
+ newRow.add(file.canRead() ? 1 : 0);
+ newRow.add(file.canWrite() ? 1 : 0);
+ newRow.add(file.length());
+ newRow.add(type);
+ newRow.add(file.lastModified());
+ newRow.add(FileUtils.getResIcon(type, file.getName()));
+
+ return matrixCursor;
+ }// doRetrieveFileInfo()
+
+ /**
+ * Lists all file inside {@code dir}.
+ *
+ * @param taskId
+ * the task ID.
+ * @param dir
+ * the source directory.
+ * @param showHiddenFiles
+ * {@code true} or {@code false}.
+ * @param filterMode
+ * can be one of {@link BaseFile#FILTER_DIRECTORIES_ONLY},
+ * {@link BaseFile#FILTER_FILES_ONLY},
+ * {@link BaseFile#FILTER_FILES_AND_DIRECTORIES}.
+ * @param limit
+ * the limit.
+ * @param positiveRegex
+ * the positive regex filter.
+ * @param negativeRegex
+ * the negative regex filter.
+ * @param results
+ * the results.
+ * @param hasMoreFiles
+ * the first item will contain a value representing that there is
+ * more files (exceeding {@code limit}) or not.
+ */
+ private void listFiles(final int taskId, final File dir,
+ final boolean showHiddenFiles, final int filterMode,
+ final int limit, String positiveRegex, String negativeRegex,
+ final List results, final boolean hasMoreFiles[]) {
+ final Pattern positivePattern = Texts.compileRegex(positiveRegex);
+ final Pattern negativePattern = Texts.compileRegex(negativeRegex);
+
+ hasMoreFiles[0] = false;
+ try {
+ dir.listFiles(new FileFilter() {
+
+ @Override
+ public boolean accept(File pathname) {
+ if (mMapInterruption.get(taskId))
+ throw new CancellationException();
+
+ final boolean isFile = pathname.isFile();
+ final String name = pathname.getName();
+
+ /*
+ * Filters...
+ */
+ if (filterMode == BaseFile.FILTER_DIRECTORIES_ONLY
+ && isFile)
+ return false;
+ if (!showHiddenFiles && name.startsWith("."))
+ return false;
+ if (isFile && positivePattern != null
+ && !positivePattern.matcher(name).find())
+ return false;
+ if (isFile && negativePattern != null
+ && negativePattern.matcher(name).find())
+ return false;
+
+ /*
+ * Limit...
+ */
+ if (results.size() >= limit) {
+ hasMoreFiles[0] = true;
+ throw new CancellationException("Exceeding limit...");
+ }
+ results.add(pathname);
+
+ return false;
+ }// accept()
+ });
+ } catch (CancellationException e) {
+ if (Utils.doLog())
+ Log.d(CLASSNAME, "listFiles() >> cancelled... >> " + e);
+ }
+ }// listFiles()
+
+ /**
+ * Sorts {@code files}.
+ *
+ * @param taskId
+ * the task ID.
+ * @param files
+ * list of files.
+ * @param ascending
+ * {@code true} or {@code false}.
+ * @param sortBy
+ * can be one of {@link BaseFile.#_SortByModificationTime},
+ * {@link BaseFile.#_SortByName}, {@link BaseFile.#_SortBySize}.
+ */
+ private void sortFiles(final int taskId, final List files,
+ final boolean ascending, final int sortBy) {
+ try {
+ Collections.sort(files, new Comparator() {
+
+ @Override
+ public int compare(File lhs, File rhs) {
+ if (mMapInterruption.get(taskId))
+ throw new CancellationException();
+
+ if (lhs.isDirectory() && !rhs.isDirectory())
+ return -1;
+ if (!lhs.isDirectory() && rhs.isDirectory())
+ return 1;
+
+ /*
+ * Default is to compare by name (case insensitive).
+ */
+ int res = mCollator.compare(lhs.getName(), rhs.getName());
+
+ switch (sortBy) {
+ case BaseFile.SORT_BY_NAME:
+ break;// SortByName
+
+ case BaseFile.SORT_BY_SIZE:
+ if (lhs.length() > rhs.length())
+ res = 1;
+ else if (lhs.length() < rhs.length())
+ res = -1;
+ break;// SortBySize
+
+ case BaseFile.SORT_BY_MODIFICATION_TIME:
+ if (lhs.lastModified() > rhs.lastModified())
+ res = 1;
+ else if (lhs.lastModified() < rhs.lastModified())
+ res = -1;
+ break;// SortByDate
+ }
+
+ return ascending ? res : -res;
+ }// compare()
+ });
+ } catch (CancellationException e) {
+ if (Utils.doLog())
+ Log.d(CLASSNAME, "sortFiles() >> cancelled...");
+ }
+ }// sortFiles()
+
+ /**
+ * Deletes {@code file}.
+ *
+ * @param taskId
+ * the task ID.
+ * @param file
+ * {@link File}.
+ * @param recursive
+ * if {@code true} and {@code file} is a directory, this thread
+ * will delete all sub files/ folders of it recursively.
+ * @return the total files deleted.
+ */
+ private int deleteFile(final int taskId, final File file,
+ final boolean recursive) {
+ final int[] count = { 0 };
+ if (mMapInterruption.get(taskId))
+ return count[0];
+
+ if (file.isFile()) {
+ if (file.delete())
+ count[0]++;
+ return count[0];
+ }
+
+ /*
+ * If the directory is empty, try to delete it and return here.
+ */
+ if (file.delete()) {
+ count[0]++;
+ return count[0];
+ }
+
+ if (!recursive)
+ return count[0];
+
+ try {
+ try {
+ file.listFiles(new FileFilter() {
+
+ @Override
+ public boolean accept(File pathname) {
+ if (mMapInterruption.get(taskId))
+ throw new CancellationException();
+
+ if (pathname.isFile()) {
+ if (pathname.delete())
+ count[0]++;
+ } else if (pathname.isDirectory()) {
+ if (recursive)
+ count[0] += deleteFile(taskId, pathname,
+ recursive);
+ else if (pathname.delete())
+ count[0]++;
+ }
+
+ return false;
+ }// accept()
+ });
+ } catch (CancellationException e) {
+ return count[0];
+ }
+
+ if (file.delete())
+ count[0]++;
+ } catch (Throwable t) {
+ // TODO
+ }
+
+ return count[0];
+ }// deleteFile()
+
+ /**
+ * Checks ancestor with {@link BaseFile#CMD_IS_ANCESTOR_OF},
+ * {@link BaseFile#PARAM_SOURCE} and {@link BaseFile#PARAM_TARGET}.
+ *
+ * @param uri
+ * the original URI from client.
+ * @return {@code null} if source is not ancestor of target; or a
+ * non-null but empty cursor if the source is.
+ */
+ private MatrixCursor doCheckAncestor(Uri uri) {
+ File source = new File(Uri.parse(
+ uri.getQueryParameter(BaseFile.PARAM_SOURCE)).getPath());
+ File target = new File(Uri.parse(
+ uri.getQueryParameter(BaseFile.PARAM_TARGET)).getPath());
+ if (source == null || target == null)
+ return null;
+
+ boolean validate = ProviderUtils.getBooleanQueryParam(uri,
+ BaseFile.PARAM_VALIDATE, true);
+ if (validate) {
+ if (!source.isDirectory() || !target.exists())
+ return null;
+ }
+
+ if (source.equals(target.getParentFile())
+ || (target.getParent() != null && target.getParent()
+ .startsWith(source.getAbsolutePath())))
+ return BaseFileProviderUtils.newClosedCursor();
+
+ return null;
+ }// doCheckAncestor()
+
+ /**
+ * Extracts source file from request URI.
+ *
+ * @param uri
+ * the original URI.
+ * @return the file.
+ */
+ private static File extractFile(Uri uri) {
+ String fileName = Uri.parse(uri.getLastPathSegment()).getPath();
+ if (uri.getQueryParameter(BaseFile.PARAM_APPEND_PATH) != null)
+ fileName += Uri.parse(
+ uri.getQueryParameter(BaseFile.PARAM_APPEND_PATH))
+ .getPath();
+ if (uri.getQueryParameter(BaseFile.PARAM_APPEND_NAME) != null)
+ fileName += "/" + uri.getQueryParameter(BaseFile.PARAM_APPEND_NAME);
+
+ if (Utils.doLog())
+ Log.d(CLASSNAME, "extractFile() >> " + fileName);
+
+ return new File(fileName);
+ }// extractFile()
+
+}
diff --git a/src/java/android-filechooser-AS/app/src/main/java/group/pals/android/lib/ui/filechooser/ui/widget/AfcSearchView.java b/src/java/android-filechooser-AS/app/src/main/java/group/pals/android/lib/ui/filechooser/ui/widget/AfcSearchView.java
new file mode 100644
index 00000000..133e5e2e
--- /dev/null
+++ b/src/java/android-filechooser-AS/app/src/main/java/group/pals/android/lib/ui/filechooser/ui/widget/AfcSearchView.java
@@ -0,0 +1,475 @@
+/*
+ * Copyright (c) 2012 Hai Bison
+ *
+ * See the file LICENSE at the root directory of this project for copying
+ * permission.
+ */
+
+package group.pals.android.lib.ui.filechooser.ui.widget;
+
+import group.pals.android.lib.ui.filechooser.BuildConfig;
+import group.pals.android.lib.ui.filechooser.R;
+import group.pals.android.lib.ui.filechooser.utils.Utils;
+import group.pals.android.lib.ui.filechooser.utils.ui.Ui;
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.os.Handler;
+import android.text.Editable;
+import android.text.TextUtils;
+import android.text.TextWatcher;
+import android.util.AttributeSet;
+import android.util.Log;
+import android.view.KeyEvent;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.inputmethod.EditorInfo;
+import android.widget.EditText;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+
+/**
+ * AFC Search view.
+ *
+ * @author Hai Bison
+ * @since v5.1 beta
+ */
+public class AfcSearchView extends LinearLayout {
+
+ private static final String CLASSNAME = AfcSearchView.class.getName();
+
+ /**
+ * Callbacks for changes to the query text.
+ */
+ public static interface OnQueryTextListener {
+
+ /**
+ * Called when the user submits the query. This could be due to a key
+ * press on the keyboard or due to pressing a submit button.
+ *
+ * Note: This method is called before setting the new search
+ * query to last search query (which can be obtained with
+ * {@link AfcSearchView#getSearchText()}).
+ *
+ *
+ * @param query
+ * the query text that is to be submitted.
+ */
+ void onQueryTextSubmit(String query);
+ }// OnQueryTextListener
+
+ public static interface OnStateChangeListener {
+
+ /**
+ * The user is attempting to open the SearchView.
+ */
+ void onOpen();
+
+ /**
+ * The user is attempting to close the SearchView.
+ */
+ void onClose();
+ }// OnStateChangeListener
+
+ /*
+ * CONTROLS
+ */
+
+ private final View mButtonSearch;
+ private final EditText mTextSearch;
+ private final View mButtonClear;
+
+ /*
+ * FIELDS
+ */
+
+ private int mDelayTimeSubmission;
+ private boolean mIconified;
+ private boolean mClosable;
+ private CharSequence mSearchText;
+
+ /*
+ * LISTENERS
+ */
+
+ private OnQueryTextListener mOnQueryTextListener;
+ private OnStateChangeListener mOnStateChangeListener;
+
+ /**
+ * Creates new instance.
+ *
+ * @param context
+ * {@link Context}.
+ */
+ public AfcSearchView(Context context) {
+ this(context, null);
+ }// AfcSearchView()
+
+ /**
+ * Creates new instance.
+ *
+ * @param context
+ * {@link Context}.
+ * @param attrs
+ * {@link AttributeSet}.
+ */
+ public AfcSearchView(Context context, AttributeSet attrs) {
+ super(context, attrs);
+
+ /*
+ * LOADS LAYOUTS
+ */
+
+ LayoutInflater inflater = (LayoutInflater) context
+ .getSystemService(Context.LAYOUT_INFLATER_SERVICE);
+ inflater.inflate(R.layout.afc_widget_search_view, this, true);
+
+ mButtonSearch = findViewById(R.id.afc_widget_search_view_button_search);
+ mTextSearch = (EditText) findViewById(R.id.afc_widget_search_view_textview_search);
+ mButtonClear = findViewById(R.id.afc_widget_search_view_button_clear);
+
+ /*
+ * ASSIGNS LISTENERS & ATTRIBUTES
+ */
+
+ mButtonSearch.setOnClickListener(mButtonSearchOnClickListener);
+ mTextSearch.addTextChangedListener(mTextSearchTextWatcher);
+ mTextSearch.setOnKeyListener(mTextSearchOnKeyListener);
+ mTextSearch
+ .setOnEditorActionListener(mTextSearchOnEditorActionListener);
+ mButtonClear.setOnClickListener(mButtonClearOnClickListener);
+
+ /*
+ * LOADS ATTRIBUTES
+ */
+
+ TypedArray a = context.obtainStyledAttributes(attrs,
+ R.styleable.AfcSearchView);
+
+ setDelayTimeSubmission(a.getInt(
+ R.styleable.AfcSearchView_delayTimeSubmission, 0));
+ updateViewsVisibility(
+ a.getBoolean(R.styleable.AfcSearchView_iconified, true), false);
+ setClosable(a.getBoolean(R.styleable.AfcSearchView_closable, true));
+ setEnabled(a.getBoolean(R.styleable.AfcSearchView_enabled, true));
+ mTextSearch.setHint(a.getString(R.styleable.AfcSearchView_hint));
+
+ a.recycle();
+ }// AfcSearchView()
+
+ /**
+ * Gets the search text.
+ *
+ * @return the search text, can be {@code null}.
+ */
+ public CharSequence getSearchText() {
+ return mSearchText;
+ }// getSearchText()
+
+ /**
+ * Gets delay time submission. This is the time that after the user entered
+ * a search term and waited for, then the handler will be invoked to process
+ * that search term.
+ *
+ * @return the delay time, in milliseconds.
+ * @see #setDelayTimeSubmission(int)
+ */
+ public int getDelayTimeSubmission() {
+ return mDelayTimeSubmission;
+ }// getDelayTimeSubmission()
+
+ /**
+ * Sets delay time submission. This is the time that after the user entered
+ * a search term and waited for, then the handler will be invoked to process
+ * that search term.
+ *
+ * @param millis
+ * delay time, in milliseconds. If {@code <= 0}, auto-submission
+ * will be disabled.
+ * @see #getDelayTimeSubmission()
+ */
+ public void setDelayTimeSubmission(int millis) {
+ if (mDelayTimeSubmission != millis) {
+ mDelayTimeSubmission = Math.max(0, millis);
+ if (mDelayTimeSubmission <= 0)
+ mAutoSubmissionHandler.removeCallbacksAndMessages(null);
+ }
+ }// setDelayTimeSubmission()
+
+ /**
+ * Checks if this search view is iconfied or not.
+ *
+ * @return {@code true} or {@code false}.
+ * @see #close()
+ * @see #open()
+ */
+ public boolean isIconified() {
+ return mIconified;
+ }// isIconfied()
+
+ /**
+ * Updates views visibility.
+ *
+ * @param collapsed
+ * {@code true} or {@code false}.
+ * @param showSoftKeyboard
+ * set to {@code true} if you want to force show the soft
+ * keyboard in expanded state.
+ * @see #isIconified()
+ */
+ protected void updateViewsVisibility(boolean collapsed,
+ boolean showSoftKeyboard) {
+ if (Utils.doLog())
+ Log.d(CLASSNAME, "updateViewsVisibility() >> " + collapsed);
+
+ mIconified = collapsed;
+
+ /*
+ * Always remove this trap first...
+ */
+ if (mIconified)
+ mAutoSubmissionHandler.removeCallbacksAndMessages(null);
+
+ if (getOnStateChangeListener() != null)
+ if (mIconified)
+ getOnStateChangeListener().onClose();
+ else
+ getOnStateChangeListener().onOpen();
+
+ mTextSearch.setVisibility(mIconified ? GONE : VISIBLE);
+ if (mIconified) {
+ mSearchText = null;
+
+ mTextSearch.removeTextChangedListener(mTextSearchTextWatcher);
+ mTextSearch.setText(null);
+
+ mTextSearch.setFocusable(false);
+ mTextSearch.setFocusableInTouchMode(false);
+ mTextSearch.clearFocus();
+
+ setEnabled(false);
+ Ui.showSoftKeyboard(mTextSearch, false);
+ } else {
+ mTextSearch.addTextChangedListener(mTextSearchTextWatcher);
+
+ mTextSearch.setFocusable(true);
+ mTextSearch.setFocusableInTouchMode(true);
+
+ if (showSoftKeyboard) {
+ mTextSearch.requestFocus();
+ Ui.showSoftKeyboard(mTextSearch, true);
+ }
+ setEnabled(true);
+ }
+ }// updateViewsVisibility()
+
+ /**
+ * Minimizes this search view. Does nothing if this search view is not
+ * closable.
+ *
+ * @see #isIconified()
+ * @see #isClosable()
+ * @see #open()
+ */
+ public void close() {
+ if (isClosable() && !isIconified())
+ updateViewsVisibility(true, true);
+ }// close()
+
+ /**
+ * Maximizes the view, lets the user to be able to enter search term.
+ *
+ * @see #close()
+ * @see #isIconified()
+ */
+ public void open() {
+ if (isIconified())
+ updateViewsVisibility(false, true);
+ }// open()
+
+ /**
+ * Checks if this search view is closable or not.
+ *
+ * @return {@code true} or {@code false}.
+ */
+ public boolean isClosable() {
+ return mClosable;
+ }
+
+ /**
+ * Sets closable.
+ *
+ * @param closable
+ * {@code true} or {@code false}.
+ */
+ public void setClosable(boolean closable) {
+ mClosable = closable;
+ if (mClosable)
+ mButtonClear.setVisibility(VISIBLE);
+ }
+
+ /**
+ * Sets the query text listener.
+ *
+ * @param listener
+ * {@link OnQueryTextListener}.
+ * @see #getOnQueryTextListener()
+ */
+ public void setOnQueryTextListener(OnQueryTextListener listener) {
+ mOnQueryTextListener = listener;
+ }
+
+ /**
+ * Gets the on query text listener.
+ *
+ * @return {@link OnQueryTextListener}, can be {@code null}.
+ * @see #setOnQueryTextListener(OnQueryTextListener)
+ */
+ public OnQueryTextListener getOnQueryTextListener() {
+ return mOnQueryTextListener;
+ }
+
+ /**
+ * Sets on close listener.
+ *
+ * @param listener
+ * {@link OnClickListener}.
+ * @see #getOnStateChangeListener()
+ */
+ public void setOnStateChangeListener(OnStateChangeListener listener) {
+ mOnStateChangeListener = listener;
+ }
+
+ /**
+ * Gets on close listener.
+ *
+ * @return {@link OnStateChangeListener}, can be {@code null}.
+ * @see #setOnStateChangeListener(OnStateChangeListener)
+ */
+ public OnStateChangeListener getOnStateChangeListener() {
+ return mOnStateChangeListener;
+ }
+
+ @Override
+ public void setEnabled(boolean enabled) {
+ if (isEnabled() == enabled)
+ return;
+
+ for (View v : new View[] { mButtonSearch, mTextSearch, mButtonClear })
+ v.setEnabled(enabled);
+ super.setEnabled(enabled);
+ }// setEnabled()
+
+ /*
+ * LISTENERS
+ */
+
+ private final View.OnClickListener mButtonSearchOnClickListener = new View.OnClickListener() {
+
+ @Override
+ public void onClick(View v) {
+ if (isIconified()) {
+ updateViewsVisibility(false, false);
+ } else {
+ mAutoSubmissionHandler.removeCallbacksAndMessages(null);
+
+ if (getOnQueryTextListener() != null)
+ getOnQueryTextListener().onQueryTextSubmit(
+ mTextSearch.getText().toString());
+ mSearchText = mTextSearch.getText();
+ }
+ }// onClick()
+ };// mButtonSearchOnClickListener
+
+ private final Handler mAutoSubmissionHandler = new Handler();
+
+ private final Runnable mAutoSubmissionRunnable = new Runnable() {
+
+ @Override
+ public void run() {
+ if (Utils.doLog())
+ Log.d(CLASSNAME, "mAutoSubmissionRunnable.run()");
+ mButtonSearch.performClick();
+ }// run()
+ };// mAutoSubmissionRunnable
+
+ private final TextWatcher mTextSearchTextWatcher = new TextWatcher() {
+
+ @Override
+ public void onTextChanged(CharSequence s, int start, int before,
+ int count) {
+ /*
+ * Do nothing.
+ */
+ }// onTextChanged()
+
+ @Override
+ public void beforeTextChanged(CharSequence s, int start, int count,
+ int after) {
+ if (Utils.doLog())
+ Log.d(CLASSNAME, "beforeTextChanged()");
+ mAutoSubmissionHandler.removeCallbacksAndMessages(null);
+ }// beforeTextChanged()
+
+ @Override
+ public void afterTextChanged(Editable s) {
+ if (Utils.doLog())
+ Log.d(CLASSNAME,
+ "afterTextChanged() >>> delayTimeSubmission = "
+ + getDelayTimeSubmission());
+
+ if (TextUtils.isEmpty(mTextSearch.getText())) {
+ if (!isClosable())
+ mButtonClear.setVisibility(GONE);
+ } else
+ mButtonClear.setVisibility(VISIBLE);
+
+ if (getDelayTimeSubmission() > 0)
+ mAutoSubmissionHandler.postDelayed(mAutoSubmissionRunnable,
+ getDelayTimeSubmission());
+ }// afterTextChanged()
+ };// mTextSearchTextWatcher
+
+ private final View.OnKeyListener mTextSearchOnKeyListener = new View.OnKeyListener() {
+
+ @Override
+ public boolean onKey(View v, int keyCode, KeyEvent event) {
+ if (event.getAction() == KeyEvent.ACTION_UP) {
+ switch (keyCode) {
+ case KeyEvent.KEYCODE_ENTER:
+ mButtonSearch.performClick();
+ return true;
+ case KeyEvent.KEYCODE_ESCAPE:
+ mButtonClear.performClick();
+ return true;
+ }
+ }
+
+ return false;
+ }// onKey()
+ };// mTextSearchOnKeyListener
+
+ private final TextView.OnEditorActionListener mTextSearchOnEditorActionListener = new TextView.OnEditorActionListener() {
+
+ @Override
+ public boolean onEditorAction(TextView v, int actionId, KeyEvent event) {
+ if (actionId == EditorInfo.IME_ACTION_SEARCH) {
+ mButtonSearch.performClick();
+ return true;
+ }
+
+ return false;
+ }// onEditorAction()
+ };// mTextSearchOnEditorActionListener
+
+ private final View.OnClickListener mButtonClearOnClickListener = new View.OnClickListener() {
+
+ @Override
+ public void onClick(View v) {
+ if (TextUtils.isEmpty(mTextSearch.getText()))
+ close();
+ else
+ mTextSearch.setText(null);
+ }// onClick()
+ };// mButtonClearOnClickListener
+
+}
diff --git a/src/java/android-filechooser-AS/app/src/main/java/group/pals/android/lib/ui/filechooser/utils/Converter.java b/src/java/android-filechooser-AS/app/src/main/java/group/pals/android/lib/ui/filechooser/utils/Converter.java
new file mode 100644
index 00000000..42e5a41f
--- /dev/null
+++ b/src/java/android-filechooser-AS/app/src/main/java/group/pals/android/lib/ui/filechooser/utils/Converter.java
@@ -0,0 +1,51 @@
+/*
+ * Copyright (c) 2012 Hai Bison
+ *
+ * See the file LICENSE at the root directory of this project for copying
+ * permission.
+ */
+
+package group.pals.android.lib.ui.filechooser.utils;
+
+/**
+ * The converter.
+ *
+ * @author Hai Bison
+ *
+ */
+public class Converter {
+
+ /**
+ * Converts {@code size} (in bytes) to string. This tip is from:
+ * {@code http://stackoverflow.com/a/5599842/942821}.
+ *
+ * @param size
+ * the size in bytes.
+ * @return e.g.:
+ *
+ *