Jump to content


Check out our Community Blogs

Register and join over 40,000 other developers!


Recent Status Updates

View All Updates

Photo
- - - - -

Creating a file browser in Android

android fragment file loader

  • Please log in to reply
1 reply to this topic

#1 farrell2k

farrell2k

    CC Addict

  • Advanced Member
  • PipPipPipPipPip
  • 169 posts

Posted 16 March 2015 - 01:26 PM

Android - A Simple File Explorer with Fragments and Loaders
Android - Building a simple file browser with loaders and fragments, using the passive model view presenter design pattern.   Android is designed around the MVC pattern with Activities or Fragments being controllers,  layouts (xml or code) as the views, and models.....well, you create them, or use Adapters, etc.   I thought it would be fun to try implementing a simple version of the MVP pattern. 
 
Android and Java are my hobbies.   When you don't program for a living and aren't forced to keep up with all the newer changes to the platform, it's easy to become set in your ways.  You stick with what's easy and tend to shun the newer things that upon first glance seem complicated.   Loaders in Android are some of the relatively newer changes in the platform that I have always found every excuse not to bother learning.  Considering that Loaders were originally released with Honeycomb in 2011, I decided that I had procrastinated long enough.   I sat down this weekend and wrote a simple file browser that uses an AsyncTaskLoader to populate a ListFragment.   
 
snapshot.jpg
 
The code isn't complicated, consists of five classes, and is only about 350 lines in total, including white space.  It allows you to browse external storage, entering and leaving directories at will, and even allows you to execute files with known mime types. 

I had two options for reading files from my device:

1. Do it on the UI thread and risk blocking it, lowering the usability of the application.   I mean, sure, I guess someone could have 10,000 images or other files in a folder where a read would potentially block the UI thread for long periods of time.

2. Be smart and use an AsyncTaskLoader to load data asynchronously, never assuming what a user will or will not have on their device.   

I have 5 seconds before Android would throw an Application Not Responding message, but considering the speed of even low-end devices these days, I could probably still get away without loading data asynchronously, right?  Obviously, the smart thing to do is number two, so me being a smart guy, that's what I did.

The absolute last thing you want to do when writing any application is to start on the UI first, so I'll begin with the model and its logic, as it is basically the guts of the entire application.

The model is responsible for all the important things that the application relies upon. e.g.  Getting a list of files, changing directories, remembering the previous directory, etc.

First, I need to declare some variables representing the data we need to work with from the model.  

I am going to be moving through the file system, so I need to keep track of a few things.  I need to know the current directory.   I also need to keep track of the previous directory when I move around the file system.  To keep track of where I was in relation to where I am now, I could use something like a Stack, a last-in-first-out collection.   This way, when I want to go back to where I came from, I can just pop() the previous location from the Stack.   How that works is simple.   If my current directory location /sdcard0 and I navigate to /sdcard0/downloads, I add /sdcard0 to my stack as my previous directory and /sdcard0/downloads as my current.  

With Java's File class I can get a list of files in a specific location, I can see if a file exists, delete one, check to see if one is a directory (yes, all directories are considered 'files' to Java), etc.   This means that java's File class will suit my needs perfectly.   

Here's the commented code for the model:
/**
* @Author Tom Farrell.   License: Whatever...
*/
public class Model {
    private File mCurrentDir; //Our current location.
    private File mPreviousDir; //Our previous location.
    private Stack<File> mHistory; //Our navigation History.
    public static final String TAG = "Current dir"; //for debugging purposes.

    public Model() {
        init();
    }

    private void init() {
        mHistory = new Stack<>();
    
    /* The first thing I need to do is check to see if the device's storage is read/write accessible.  If it is not,
    then why bother continuing?  I guess I could do everything in read only mode, but I'd rather not.
    */
    
        //if the storage device is writable and readable, set the current directory to the external storage location.
        if (Environment.getExternalStorageState().equals(Environment.MEDIA_MOUNTED)) {
            mCurrentDir = Environment.getExternalStorageDirectory();
            
            Log.i(TAG, String.valueOf(mCurrentDir));
        } else {
            Log.i(TAG, "External storage unavailable");
        }
    }

    /* Now for the getters, setters, and utlity methods.*/
    
    //get the current directory.
    public File getmCurrentDir() {
        return mCurrentDir;
    }

    //set the current directory.
    public void setmCurrentDir(File mCurrentDir) {
        this.mCurrentDir = mCurrentDir;
    }

    //Returns whether or not we have a previous dir in our history.  If the stack is not empty, we have one.
    public boolean hasmPreviousDir() {
        return !mHistory.isEmpty();
    }

    //return the previous dir and remove it from the stack.
    public File getmPreviousDir() {
        return mHistory.pop();
    }

    //set the previous dir for navigation.
    public void setmPreviousDir(File mPreviousDir) {
        this.mPreviousDir = mPreviousDir;
        mHistory.add(mPreviousDir);

    }

    //Returns a sorted list of all dirs and files in a given directory.
    public List<File> getAllFiles(File f) {
        File[] allFiles = f.listFiles();

        /* I want all directories to appear before files do, so I have separate lists for both that are merged into one later.*/
        List<File> dirs = new ArrayList<>();
        List<File> files = new ArrayList<>();

        for (File file : allFiles) {
            if (file.isDirectory()) {
                dirs.add(file);
            } else {
                files.add(file);
            }
        }

        Collections.sort(dirs);
        Collections.sort(files);

        /*Both lists are sorted, so I can just add the files to the dirs list.
        This will give me a list of dirs on top and files on bottom. */
        dirs.addAll(files);

        return dirs;
    }
    
    //Try to determine the mime type of a file based on extension.
    public String getMimeType(Uri uri) {
        String mimeType = null;

        String extension = MimeTypeMap.getFileExtensionFromUrl(uri.getPath());

        if (MimeTypeMap.getSingleton().hasExtension(extension)) {

            mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension);
        }
        return mimeType;
    }
}

Now I can work on my user Interface.  Because my model contains methods that return common things i.e. Strings, arrays, etc, my model can be used with any user interface. 
There's not much of an option for having a text-based UI in Android, but the beauty of my model is that it can also be used in any Java application as well.  

For the user interface, I am going to use a ListView populated by an ArrayAdapter.   I start with a ListFragment that I will bind statically to my Activity.  

The first decision I have to make is how the rows of the ListView are going to look.  I want to keep it simple, but I also want to be able to distinguish visually between directories and files.  If you have ever used a file manager on any platform, you know that directories always show folder icons next to their names, and files show file icons specific to the type of file.   I want to do this as well, so the rows in my layout will use an image representing a folder or a file, followed by the file or directory name, then if the file is not a directory, I am going to use a textview below the file name to report the file size.  That's it.  

The XML code for the ListFragment and the XML code for its rows is below.

[listfragment_main.xml]
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="#33446A"
    tools:context=".MainActivity">
    <ListView
        android:id="@android:id/list"
        android:background="#33446A"
        android:layout_width="match_parent"
        android:layout_height="match_parent">
    </ListView>
    <!--- If there list is empty, display a text view that tells us that --->
    <TextView
        android:id="@android:id/empty"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:gravity="center"
        android:background="#FFFF00"
        android:textColor="@android:color/white"
        android:textSize="32sp"
        android:text="No files here."/>
</RelativeLayout>

[list_row.xml]
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent" android:layout_height="match_parent"
    android:background="#33446A" android:paddingLeft="10dip" android:paddingRight="10dip">
    <ImageView
        android:id="@+id/imageView"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_alignParentLeft="true"/>
    <TextView xmlns:android="http://schemas.android.com/apk/res/android"
        android:id="@+id/name_text_view"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_toRightOf="@id/imageView"
        android:padding="5dip"
        android:textColor="@android:color/white"
        android:textSize="25sp"/>
    <TextView xmlns:android="http://schemas.android.com/apk/res/android"
        android:id="@+id/details_text_view"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_below="@id/name_text_view"
        android:layout_toRightOf="@id/imageView"
        android:padding="5dip"
        android:textColor="@android:color/white"
        android:textSize="15sp"/>
</RelativeLayout>

Now that I have the xml defined or the ListView and its rows, I can work on the Adapter that will populate the rows of the ListView with data.   The Adapter is going to be backed by a List of File objects, so I am going to subclass ArrayAdapter.  The most complicated part of it all is getView() which will make a few determinations about what information goes into which text view. e.g. Should it show a folder image or a file image, etc.   The code is below.

[FileArrayAdapter.java]
/**
 * @Author Tom Farrell.   License: Whatever...
 */
public class FileArrayAdapter extends ArrayAdapter<File> {
    private Context mContext; //Activity context.
    private int mResource; //Represents the list_rowl file (our rows) as an int e.g. R.layout.list_row
    private List<File> mObjects; //The List of objects we got from our model.

    public FileArrayAdapter(Context c, int res, List<File> o) {
        super(c, res, o);
        mContext = c;
        mResource = res;
        mObjects = o;
    }

    public FileArrayAdapter(Context c, int res) {
        super(c, res);
        mContext = c;
        mResource = res;
    }

    /*Does exactly what it looks like.  Pulls out a specific File Object at a specified index.
    Remember that our FileArrayAdapter contains a list of Files it gets from our model's getAllFiles(),
    so getitem(0) is the first file in that List, getItem(1), the second, etc.  ListView uses this
    method internally.*/
    @Override
    public File getItem(int i) {
        return mObjects.get(i);
    }

    /** Allows me to pull out specific views from the row xml file for the ListView.   I can then
    *make any modifications I want to the ImageView and TextViews inside it.
    *@param position - The position of an item in the List received from my model.
    *@param convertView - list_row.xml as a View object.
    *@param parent - The parent ViewGroup that holds the rows.  In this case, the ListView.
    ***/
    @Override
    public View getView(int position, View convertView, ViewGroup parent) {
        /*This is the entire file [list_rowl] with its RelativeLayout, ImageView, and two
        TextViews.  It will always be null the very first time, so we need to inflate it with a
           LayoutInflater.*/
        View v = convertView;

        if (v == null) {
            LayoutInflater inflater = (LayoutInflater.from(mContext));

            v = inflater.inflate(mResource, null);
        }

        /* We pull out the ImageView and TextViews so we can set their properties.*/
        ImageView iv = (ImageView) v.findViewById(R.id.imageView);

        TextView nameView = (TextView) v.findViewById(R.id.name_text_view);

        TextView detailsView = (TextView) v.findViewById(R.id.details_text_view);

        File file = getItem(position);

        /* If the file is a dir, set the image view's image to a folder, else, a file. */
        if (file.isDirectory()) {
            iv.setImageResource(R.drawable.folderxxhdpi);
        } else {
            iv.setImageResource(R.drawable.filexxhdpi);
            if (file.length() > 0) {
                detailsView.setText(String.valueOf(file.length()));
            }
        }

        //Finally, set the name of the file or directory.
        nameView.setText(file.getName());

        //Send the view back so the ListView can show it as a row, the way we modified it.
        return v;
    }
}

So now that my FileArrayAdapter is complete, I can move on to my view.   The view is very simple.   It inflates the layout for ListView and passes all UI widget clicks to the Presenter so it can handle everything.   Here's the code for the view.

[UIView.java]
/**
 * @Author Tom Farrell.   License: Whatever...
 */
public class UiView extends ListFragment {
    //This is a passive view, so my presenter handles all of the updating, etc.
    private Presenter presenter;

    public void setPresenter(Presenter p) {
        presenter = p;
        
        /*I am not using this, but I like to enable it just in case I want to populate the overflow menu
        with menu options
         */
        setHasOptionsMenu(true);
    }


    //Return the view to the Activity for display.
    @Override
    public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
        return inflater.inflate(R.layout.listfragment_main, container, false);
    }

    //This is a good place to do final initialization as the Fragment is finished initializing itself.
    @Override
    public void onActivityCreated(Bundle savedInstanceState) {
        super.onActivityCreated(savedInstanceState);
        setPresenter(new Presenter(this));
    }

    //When we intercept a click, call through to the appropriate method in the presenter.
    @Override
    public void onListItemClick(ListView listView, android.view.View view, int position, long id) {
        super.onListItemClick(listView, view, position, id);
        presenter.listItemClicked(listView, view, position, id);
    }

    /* Populate options menu and or action bar with menu from res/menu/menu_main.xml*/
    @Override
    public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) {
        super.onCreateOptionsMenu(menu, inflater);
        inflater.inflate(R.menu.menu_main, menu);
    }

    //Called when an item in the menu, or the home button (if enabled) is selected.
    @Override
    public boolean onOptionsItemSelected(MenuItem item) {
        // Handle action bar item clicks here. The action bar will
        // automatically handle clicks on the Home/Up button, so long
        // as you specify a parent activity in AndroidManifest.xml.
        int id = item.getItemId();

        switch(id) {
            case android.R.id.home:
                presenter.homePressed();
                break;
            case R.id.settings:
                presenter.settings();
        }

        return super.onOptionsItemSelected(item);
    }
}

Next up is the Presenter, where all the action happens.   I use an AsyncTaskLoader to do a background load of data from the model, then I update the view acordingly.

So, what is an AsyncTaskLoader?   It's a subclass of Loader that loads data in a background thread and returns the results of its load to its registered receiving class, all while managing configuration changes so that I do not have to worry about what to do when the Activity is killed and restarted.  According to the Android documentation, Loaders are also supposed to monitor their data sources for changes and update accordingly.   That sounds nice and all, but I have found that to be a little misleading.   I still had to write the code to tell the Loader that the data needed to be updated.  

The very first thing that the Presenter does is register itself as the receiver of notifications from the loader, so that it can act appropriately when the Loader completes its loading.   The presenter implements LoaderManager.LoaderCallbacks, containing the methods:

-onCreateLoader() which is called when the Loader is initially created.
-onLoadFinished() which is called when the Loader acquires its load.
-onLoaderReset() which is called when the Loader is reset.  Not unsed in my example.

The Loader itself does all of its work in its method loadInBackground(), so this is where I want to query the model for a list of files.

Here's the commented Presenter code.

[Presenter.java]
/**
 * @Author Tom Farrell.   License: Whatever...
 *         The main job of the presenter is to marshall data to and from the view.   Logic in the
 *         presenter is kept to a minimum, with only the logic required to format and marshall data between
 *         the view and model done here.
 **/
public class Presenter implements LoaderManager.LoaderCallbacks<List<File>> {
    private UiView mView; //Our view.
    private Model mModel; //Our model.
    private FileArrayAdapter mFileArrayAdapter; //The adapter containing data for our list.
    private List<File> mData; //The list of all files for a specific dir.
    private AsyncTaskLoader<List<File>> mFileLoader; /*Loads the list of files from the model in
    a background thread.*/

    public Presenter(UiView mView) {
        this.mView = mView;
        mModel = new Model();
        mData = new ArrayList<>();
        init();
    }

    private void init() {
        //Instantiate and configure the file adapter with an empty list that our loader will update..
        mFileArrayAdapter = new FileArrayAdapter(mView.getActivity(),
                R.layout.list_row, mData);

        mView.setListAdapter(mFileArrayAdapter);

        /*Start the AsyncTaskLoader that will update the adapter for
        the ListView. We update the adapter in the onLoadFinished() callback.
        */
        mView.getActivity().getLoaderManager().initLoader(0, null, this);

        //Grab our first list of results from our loader.  onFinishLoad() will call updataAdapter().
        mFileLoader.forceLoad();
    }

    /*Called to update the Adapter with a new list of files when mCurrentDir changes.*/
    private void updateAdapter(List<File> data) {
        //clear the old data.
        mFileArrayAdapter.clear();
        //add the new data.
        mFileArrayAdapter.addAll(data);
        //inform the ListView to refrest itself with the new data.
        mFileArrayAdapter.notifyDataSetChanged();
    }

    public void listItemClicked(ListView l, View v, int position, long id) {
        //The file we clicked based on row position where we clicked.  I could probably word that better. :)
        File fileClicked = mFileArrayAdapter.getItem(position);

        if (fileClicked.isDirectory()) {
            //we are changing dirs, so save the previous dir as the one we are currently in.
            mModel.setmPreviousDir(mModel.getmCurrentDir());

            //set the current dir to the dir we clicked in the listview.
            mModel.setmCurrentDir(fileClicked);

            //Let the loader know that our content has changed and we need a new load.
            if (mFileLoader.isStarted()) {
                mFileLoader.onContentChanged();
            }
        } else { //Otherwise, we have clicked a file, so attempt to open it.
            openFile(Uri.fromFile(fileClicked));
        }
    }

    //Called when settings is clicked from UIView menu.
    public void settings() {
        Toast.makeText(mView.getActivity(), "settings cclicked", Toast.LENGTH_LONG).show();
    }

    //Fires intents to handle files of known mime types.
    private void openFile(Uri fileUri) {

        String mimeType = mModel.getMimeType(fileUri);

        if (mimeType != null) { //we have determined a mime type and can probably handle the file.
            try {
                /*Implicit intent representing the action we want.  The system will determine is it
                can handle the request.*/
                Intent i = new Intent(Intent.ACTION_VIEW);
                i.setDataAndType(fileUri, mimeType);

                //We ask the Activity to start this intent.
                mView.getActivity().startActivity(i);
            } catch (ActivityNotFoundException e) {
                /*If we have figured out the mime type of the file, but have no application installed
                to handle it, send the user a message.
                 */
                Toast.makeText(mView.getActivity(), "The System understands this file type," +
                                "but no applications are installed to handle it.",
                        Toast.LENGTH_LONG).show();
            }
        } else {
            /*if we can't figure out the mime type of the file, let the user know.*/
            Toast.makeText(mView.getActivity(), "System doesn't know how to handle that file type!",
                    Toast.LENGTH_LONG).show();
        }
    }

    /*Called when the user presses the home button on the ActionBar to navigate back to
     our previous location, if we have one.*/
    public void homePressed() {
        //If we have a previous dir to go back to, do it.
        if (mModel.hasmPreviousDir()) {
            mModel.setmCurrentDir(mModel.getmPreviousDir());

            //Our content has changed, so we need a new load.
            mFileLoader.onContentChanged();
        }
    }

    //Loader callbacks.
    @Override
    public Loader<List<File>> onCreateLoader(int id, Bundle args) {
        mFileLoader = new AsyncTaskLoader<List<File>>(mView.getActivity()) {

            //Get our new data load.
            @Override
            public List<File> loadInBackground() {
                Log.i("Loader", "loadInBackground()");
                return mModel.getAllFiles(mModel.getmCurrentDir());
            }
        };

        return mFileLoader;
    }

    //Called when the loader has finished acquiring its load.
    @Override
    public void onLoadFinished(Loader<List<File>> loader, List<File> data) {

        this.mData = data;

        /* My data source has changed so now the adapter needs to be reset to reflect the changes
        in the ListView.*/
        updateAdapter(data);
    }

    @Override
    public void onLoaderReset(Loader<List<File>> loader) {
        //not used for this data source.
    }
}

And finally my MainActivity and its XML layout file.

[MainActivity.java]
public class MainActivity extends Activity {
    private UiView mView;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        getActionBar().setDisplayHomeAsUpEnabled(true);

        setContentView(R.layout.activity_main);

        mView = (UiView) getFragmentManager().findFragmentById(R.id.file_list);
    }
}
[activity_main.xml]
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="#33446A"
    tools:context=".MainActivity">
   <fragment android:id="@+id/file_list"
       android:name="com.averageloser.file.captainfile_filemanager.ui.UiView"
       android:layout_width="fill_parent"
       android:layout_height="fill_parent"/>
</RelativeLayout>

Everything you need to add more functionality to this is contained in the File class.  I am thinking of a delete function.  Moving a file is just copying its contents from one place, writing them to another, then deleting the original file.   Copying is the same way, without the file deletion.  File.create().


Edited by farrell2k, 16 March 2015 - 01:29 PM.

  • 1

Averageloser.com - I used to be a programmer like you, then I took a -> in the knee. 


#2 ahx89

ahx89

    CC Lurker

  • Just Joined
  • Pip
  • 1 posts

Posted 14 October 2015 - 09:49 AM

Hi

 

    Do you have the android project of the application? 

    I mean github, sourcetree, etc..

    If so could you please share the link ? 


  • 0