Thursday, December 1, 2011

Using an ActionBar At The Bottom of a Screen

To make some actions easier for users, I added what I am calling an ActionBar to the bottom of the WebView.  It allows users to add a note, share the link and download the EPUB or PDF files without accessing the menu.  It is not an ActionBar in the pure Android way of doing things since it is not at the top of the screen and does not include a title.  However, an ActionBar at the top of the screen did not make sense for my app, thus I moved it to the botttom.

Creating the ActionBar involved a few steps:
  • modification of the webview layout
  • creation of the ActionBar style
  • adding code to handle the button events when an item on the ActionBar is selected
WebView Layout

The original WebView layout was very simple.  It was a RelativeLayout containing a WebView.  The new layout is a set of layouts.



  
   
  
  
   
       
       
       
       
   
  


The key to getting the layout correct (WebView on top, ActionBar on bottom) was adding the android:layout_weight="1" atttribute to the LinearLayout that contains the WebView. This caused the WebView to be displayed on top of the ActionBar. I used a RelativeLayout for the ActionBar ImageButtons so I could lay them out next to each other.

ActionBar Style
 
The look of the ActionBar contains the following styles
  • No borders around the ImageButtons 10sp of padding around the images 
  • All of the ImageButtons have the same background color 
  • The layout that contains the ImageButtons has the same backround color as the buttons.

I handled all of the button styling by modifying the existing button_background.xml and saving it as button_bar_background.xml

    
    
    
    
    
    
    


The main difference is the use of button_bar_default.xml

    
 
    
     


I removed the rounded corners and the stroke which was drawing the border around the button.

Code for ActionBar
 
In WebViewActivity, I added a method, setLayout(), that setup the ActionBar based on the URL being viewed. I did not want the ActionBar to be displayed when the user was viewing search results or the app help page. If either of those URLs are detected, the ActionBar is hidden.

private void setLayout(String url)
{
        RelativeLayout relLayout = (RelativeLayout)findViewById(R.id.relativeLayout1);
        int visibility = relLayout.getVisibility();
        if(url.contains("/search") || url.contains(".html"))
        {
            if(visibility == View.VISIBLE)
            {
                relLayout.setVisibility(View.GONE);
            }
        }
        else
        {
            if(visibility == View.GONE)
            {
                relLayout.setVisibility(View.VISIBLE);
            }
            
                
                ImageButton noteButton = (ImageButton)findViewById(R.id.noteButton);
                noteButton.setOnClickListener(new OnClickListener() 
                {
                          
                      public void onClick(View v) 
                      {
                          Intent noteintent = new Intent(getApplicationContext(), NoteEditorActivity.class);
                          ContentCache.setObject(getString(R.string.content), content);
                          startActivity(noteintent);
                      }
                  });
                
                ImageButton shareButton = (ImageButton)findViewById(R.id.shareButton);
                shareButton.setOnClickListener(new OnClickListener() 
                {
                          
                      public void onClick(View v) 
                      {
                          Intent intent = new Intent(Intent.ACTION_SEND);
                          intent.setType("text/plain");

                          intent.putExtra(Intent.EXTRA_SUBJECT, content.getTitle());
                          intent.putExtra(Intent.EXTRA_TEXT, content.getUrl().toString() + " " + getString(R.string.shared_via));

                          Intent chooser = Intent.createChooser(intent, getString(R.string.tell_friend) + " "+ content.getTitle());
                          startActivity(chooser);

                      }
                  });
                
                ImageButton epubButton = (ImageButton)findViewById(R.id.epubButton);
                epubButton.setOnClickListener(new OnClickListener() 
                {
                          
                      public void onClick(View v) 
                      {
                          download(Constants.EPUB_TYPE);

                      }
                  });
                
                ImageButton pdfButton = (ImageButton)findViewById(R.id.pdfButton);
                pdfButton.setOnClickListener(new OnClickListener() 
                {
                          
                      public void onClick(View v) 
                      {
                          download(Constants.PDF_TYPE);

                      }
                  });
            
        }
}

An OnClickListener is coded to handle each button's function. Since the WebView can be used to view an entire book, search or visit the help page, I had to call setLayout() in WebViewClient.onPageFinished() as well as onCreate().

You can browse the source or download a zip file of the source. Connexions for Android is available in the Android Market or from the Connexions website.

Monday, November 7, 2011

Using SQLite Database with Two Tables in Android

In the Connexions app, there are 2 tables in the SQlite database: Favorites and Notes.  The app originally had 1 table for Favorites and later the Notes table was added.  The code had to be refactored because it was easier to not handle both tables in the same ContentProvider.

First the SQLiteOpenHelper inner class was pulled out of ConnexionsProvider and moved to a separate DatabaseHelper class that is shared by both ContentProviders.  This class is used to create the tables if they do not exist or to upgrade the database by adding the Notes table if the users database version number is below the version in the code.  To update a database, place the needed code in the onUpgrade() method and raise the version number.

...
/**  database version */
    private static final int DATABASE_VERSION = 5;
...
@Override
public void onCreate(SQLiteDatabase db) 
{
    db.execSQL("CREATE TABLE " + ConnexionsProvider.FAVS_TABLE + " ("
            + Favs.ID + " INTEGER PRIMARY KEY,"
            + Favs.TITLE + " TEXT,"
            + Favs.URL + " TEXT,"
            + Favs.ICON + " TEXT,"
            + Favs.OTHER + " TEXT"
            + ");");
    db.execSQL("CREATE TABLE " + ConnexionsProvider.NOTES_TABLE + " ("
            + Notes._ID + " INTEGER PRIMARY KEY,"
            + Notes.TITLE + " TEXT,"
            + Notes.NOTE + " TEXT,"
            + Notes.URL + " TEXT"
            + ");");
}

@Override
public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) 
{
    //db.execSQL("alter table " + FAVS_TABLE + " add column " + Favs.OTHER + " text");
    db.execSQL("CREATE TABLE " + ConnexionsProvider.NOTES_TABLE + " ("
            + Notes._ID + " INTEGER PRIMARY KEY,"
            + Notes.TITLE + " TEXT,"
            + Notes.NOTE + " TEXT,"
            + Notes.URL + " TEXT"
            + ");");
}
Information about the table columns is coded in a class that implements BaseColumns. The class has static fields that are used to identify the columns of the table. Below is a code snippet from the Favs class for the Favorites table. The _id field is required as the default key for the table.
...
public class Favs implements BaseColumns
{
    /** Private constructor.  Cannot instanciate this class */
    private Favs()
    {
        
    }
    
    /** URI of code allowed to access table, I think*/
    public static final Uri CONTENT_URI = Uri.parse("content://org.cnx.android.providers.ConnexionsProvider");
    /** title column name*/
    public static final String TITLE = "fav_title";
    /** url column name*/
    public static final String URL = "fav_url";
    /** url column name*/
    public static final String ICON = "fav_icon";
    /** id column name*/
    public static final String ID = "_id";
    /** other column name*/
    public static final String OTHER = "fav_other";
}
The columns in the Favs class are mapped in a HashMap in the ConnexionsProvider class. The HashMap is used when performing a query. The NotesProvider class has similar code.
...
static
{
    FavsProjectionMap = new HashMap();
    FavsProjectionMap.put(Favs.ID, Favs.ID);
    FavsProjectionMap.put(Favs.TITLE, Favs.TITLE);
    FavsProjectionMap.put(Favs.URL, Favs.URL);
    FavsProjectionMap.put(Favs.ICON, Favs.ICON);
    FavsProjectionMap.put(Favs.OTHER, Favs.OTHER);
}
...
public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder)
{
    SQLiteQueryBuilder qb = new SQLiteQueryBuilder();
    qb.setTables(FAVS_TABLE);
    qb.setProjectionMap(FavsProjectionMap);
    SQLiteDatabase db = dbHelper.getReadableDatabase();
    return qb.query(db, projection, selection, selectionArgs, null, null, sortOrder);
}
...
The Favorites table should not store duplicates so there is code to check for the URL of the content being added before adding it. If it is found to be already in the database, the URL is not added.
...
private boolean checkForDuplicate(String url)
{
    boolean dup = false;
    Cursor c = query(Favs.CONTENT_URI,null,"fav_url='"+url+"'",null, null);
    int urlColumn = c.getColumnIndex(Favs.URL);
    if(c.getCount()>0)
    {
        c.moveToNext();
        if(c.getString(urlColumn).equals(url))
        {
            dup = true;
        }
    }
    c.close();
    return dup;
}
...
To be used by the app, ContentProviders must be addded to the AndroidManifest.xml file.
...


...
The ContentProviders are accessed by calling getContentResolver() in an Activity. An example in the NoteEditorAvtivity class, the checkDBForNote() method queries the database to see if a note exists for the content URL. It also shows usage of the Notes class that implements BaseColumns.
...
private void checkDBForNote()
{
    cursor = getContentResolver().query(Notes.CONTENT_URI, null, "notes_url='" + content.getUrl().toString() + "'", null, null);
    if(cursor.getCount()>0)
    {
        cursor.moveToNext();
        int notesColumn = cursor.getColumnIndex(Notes.NOTE);
        editText.append(cursor.getString(notesColumn)); 
        editText.setSelection(editText.getText().length());
        state = STATE_UPDATE;
    }
    else
    {
        state = STATE_EDIT;
        editText.setText("");
    }
}
...
You can browse the source or download a zip file of the source. Connexions for Android is available in the Android Market or from the Connexions website.

Sunday, October 2, 2011

Deleting An Item From a ListActivity

One of the features in the Connexions for Android app is the ability to save modules, collections, lenses or searches to a list of Favorites.  The Favorites are stored in a SQLite database on the user's phone and displayed on the Favorites tab.  A long click on an item in the list displays a menu with a Delete option.

To remove an item from the Favorites list, two obvious things must happen:
  1. Remove the item from the database. 
  2. Remove the item from the display.
Originally, I wrote code to delete the selected favorite from the database and then refreshed the list from the database.  The performance was poor to say the least, but it worked.  Later, I came up with a better solution. 
The Favorites are displayed by the ViewFavsActivity class which uses the LensListAdaper class.  LensListAdapter extends the Android ArrayAdapter class and has an Array of Content objects.   The ViewFavsActivity calls MenuHandler.handleContextMenu() which deletes the selected item from the database. Here is a code snip where the item is deleted.

...
case R.id.delete_from__favs:
       context.getContentResolver().delete(Favs.CONTENT_URI, "_id="+ currentContent.getId(), null);
       return true;
...

To remove the item from the display, ArrayAdapter.remove() is called from ViewFavsActivity.  ArrayAdapter.remove() takes an Object as a parameter so I pass in the selected Content object.

public boolean onContextItemSelected(MenuItem item) 
{
          AdapterContextMenuInfo info= (AdapterContextMenuInfo) item.getMenuInfo();
          Content content = (Content)getListView().getItemAtPosition(info.position);
          MenuHandler mh = new MenuHandler();
          boolean returnVal = mh.handleContextMenu(item, this, content);
          if(item.getItemId() == R.id.delete_from__favs)
          {
              //readDB();
              adapter.remove(content);
          }
          if(returnVal)
          {
              return returnVal;
          }
          else
          {
              return super.onContextItemSelected(item);
          }
}

Using the remove() method tremendously improved the performance.

You can browse the source or download a zip file of the source. Connexions for Android is available in the Android Market or from the Connexions website.

Monday, September 5, 2011

Using a Base ListActivity for Different Atom Feeds

In the Connexions for Android app, there are 3 different Atom feeds that are used to display 3 different types of Lenses. A Lens is a group of Connexions content that is endorsed by or affiliated with a particular group. Users of Connexions can also create their own Lens to store content they find useful. Each of these types (Endorsement, Affiliation, Member) have an Atom feed that is used to display the list of Lenses in the Android app. I didn't want to have 3 separate Activities since their functionality is the same for each type of lens so I created a base Activity with the needed functionality and then extended it for each type of lens. The subclasses contain the unique data needed for each type.

The base Activity has the cleaver name of BaseLensesActivity. It does not do anything out of the ordinary for a ListActivity, but it has 4 Class variables that are the key to the subclasses being able to extend BaseLensesActivity. The 4 fields are currentContext, title, storedKey and atomFeedURL. These 4 fields are overridden by the subclasses so the correct data is stored and displayed.

Details of the 4 fields:
  • currentContext - used when the Context is passed to the MenuHandler object.  It is set to the current subclass.
  • title - the title displayed on the activity.  It is set to the Lens type.
  • storedKey - used when storing the list of lenses in the ContextCache object.  Lists are stored when the Activity is paused so it does not have to be reloaded from the Connexions site.
  • atomFeedURL - the URL for the Feed for the Lens type.
One method also had to be overridden, onOptionsItemSelected().  I'm not sure why, but the method would not work correctly without being overridden.  No code was changed in the overridden version, just the location of the code.

You can browse the source or download a zip file of the source. Connexions for Android is available in the Android Market or from the Connexions website.

Wednesday, August 10, 2011

Using a PopupWindow in Android


If a user selects Search from the menu, a PopupWindow is displayed for the user to enter some criteria and search Connexions. I am using a PopupWindow because the usual Android search box will not work with the Tabs displayed when the app opens. I will probably fix this soon by switching to an ActionBar for the tabs, but for now, I am using a PopupWindow. The PopupWindow allowed me to use the same logic for Search on every screen.

Using a PopupWindow requires a couple of things: a layout and the code to create, display and handle the response to the PopupWindow.

The Layout I used is pretty simple. It is in the search_popup.xml file.

android:id="@+id/popup_menu_root"
android:background="#006699"
android:orientation="vertical"
android:layout_width="fill_parent"
android:layout_height="fill_parent" >
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:textStyle="bold"
android:textSize = "18sp"
android:text="Search"
android:textColor="#FFFFFF"
android:padding="3px"
/>
android:id="@+id/searchCriteria"
android:singleLine="true"
android:layout_width="fill_parent"
android:layout_height="wrap_content"
/>
android:id="@+id/search_button"
android:src="@drawable/magnify"
android:background="@drawable/button_background"
android:layout_width="40px"
android:layout_height="40px"
android:layout_marginLeft="3px"/>



There is a TextView for the label, an EditText for the search criteria and an ImageButton for the search icon. There are some bad coding practices I haven't cleaned up yet like putting the colors directly in the layout instead of in the colors.xml file.

The code for the PopupWindow is in the SearchHandler class.

public void displayPopup(final Context context)
{
popUp = new PopupWindow(context);
LayoutInflater inflater = (LayoutInflater)context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
View layout = inflater.inflate(R.layout.search_popup, null, true);
popUp = new PopupWindow(layout, 300, 125, true);
popUp.setBackgroundDrawable(new BitmapDrawable());
popUp.setOutsideTouchable(true);
popUp.setAnimationStyle(R.style.Animations_GrowFromBottom);
popUp.setTouchInterceptor(new OnTouchListener() {

public boolean onTouch(View v, MotionEvent event)
{
if(event.getAction() == MotionEvent.ACTION_OUTSIDE)
{
popUp.dismiss();
return true;
}
return false;
}
});
final EditText searchCriteria = (EditText)layout.findViewById(R.id.searchCriteria);
ImageButton searchButton = (ImageButton)layout.findViewById(R.id.search_button);
searchButton.setOnClickListener(new OnClickListener()
{

public void onClick(View v)
{
String searchFor = searchCriteria.getText().toString();
performSearch(searchFor, Constants.CNX_SEARCH, context);
}
});
popUp.showAtLocation(layout, Gravity.TOP, 0, 30);
}


The displayPopup() method sets the properties on the popup. These are the layout, the outside touchable property and an animation. The layout is setup much like using a Menu. The outside touchable property allows the user to touch outside the popup. If the user does, the popup is dismissed. The animation runs when the popup is opened. It appears to come from the bottom of the screen. If the user selects the Search button, performSearch() is called.

private void performSearch(String searchFor, int searchType, Context context)
{
try
{
Content content = new Content();
content.setUrl(new URL(createQueryString(searchFor, searchType)));
content.setTitle(context.getString(R.string.search_title) + searchFor);
Intent webintent = new Intent(context, WebViewActivity.class);
ContentCache.setObject(context.getString(R.string.webcontent), content);
context.startActivity(webintent);
}
catch (MalformedURLException e)
{
e.printStackTrace();
}
}



The performSearch() method creates the search query URL and passes it to the WebViewActivity which displays the results from Connexions.

You can browse the source or download a zip file of the source. Connexions for Android is available in the Android Market or from the Connexions website.

Tuesday, July 12, 2011

Loading a ListView from a SQLite Database


Users can add Connexions content they like to a list of Favorites. The Favorites are saved in a SQLite database on the phone. These Favorites are not to be confused with the Favorites that are part of the Connexions web site. On cnx.org, the user has to log in to add content to their favorites. In the Android app, no login is required because the favorites are stored on the phone.

In ViewFavsActivity, readDB() displays a ProgressDialog and calls a ContentProvider to read the contents of the favs table. DBUtils.readCursorIntoList() takes the Cursor object returned by the ContentProvider and reads the query results into a List of Content objects. This list is then sorted and added to the ListAdapter where it is displayed.


private void readDB()
{
progressDialog = ProgressDialog.show(
ViewFavsActivity.this,
getResources().getString(R.string.loading_favs_title),
getResources().getString(R.string.loading_favs_description)
);
Thread loadFavsThread = new Thread()
{
public void run()
{

content = DBUtils.readCursorIntoList(getContentResolver().query(Favs.CONTENT_URI, null, null, null, null));

Collections.sort((List)content);

fillData(content);
handler.post(finishedLoadingListTask);
}
};
loadFavsThread.start();

}


The ContentProvider used is the ConnexionsProvider class. All of the work is done in a separate Thread which is the proper handling of any task in Android. The ProgressDialog does not display for very long unless the user has saved a long list of Favorites.

You can browse the source or download a zip file of the source. Connexions for Android is available in the Android Market or from the Connexions website.

Sunday, June 26, 2011

Simple RSS Reader in Android


The Connexions app reads both Atom and RSS feeds. The implementations are similar, but the Atom reader is a bit Connexions specific because of the way we add metadata in the content element of the feed. Our RSS feeds are pretty straightforward, so the reader is also. RSS is currently used for Recently Published Content. The reader is basically a Sax parser that is looking for specific elements and grabbing the attribute or text from the needed elements.

To be a Sax parser, the RssHandler class implements the basic Sax methods of startElement(), endElement() and characters(). The 3 elements I needed from the feed were item, title and link. I set a boolean when I encounter one of the elements in startElement(). I also create a Content object when an item element is found. The Content object will be used in the other methods as well.

public void startElement(String uri, String name, String qName, Attributes atts)
{
if(name.trim().equals("item"))
{
currentContent = new Content();
}
if (name.trim().equals("title"))
{
inTitle = true;
}
else if (name.trim().equals("link"))
{
inLink = true;
}
}


In characters(), I get the text of the elements if one of the booleans have been set. Since there is no guarantee that the entire buffer will be delivered, there is code to concatenate the remaining text to the initial String is necessary. There is also code to hack the cnx.org domain sent with the feed to be mobile.cnx.org.

public void characters(char ch[], int start, int length)
{

String chars = (new String(ch).substring(start, start + length));

if (inTitle && currentContent != null)
{
if(currentContent.title == null || currentContent.title.equals("") )
{
currentContent.title = chars;currentContent.title = chars;
}
else
{
currentContent.title = currentContent.title + chars;
}
}
else if(inLink && currentContent != null)
{
String link = new String(chars);
try
{
currentContent.setUrl(new URL("http://mobile.cnx.org" + link.substring(14)));
}
catch (MalformedURLException e)
{
e.printStackTrace();
}

}
}


The method endElement() resets the booleans to false, calls a method to set the correct icon on the Content object and adds the Content object to an ArrayList.

public void endElement(String uri, String name, String qName) throws SAXException
{
inTitle = false;
inLink = false;

if(currentContent != null && currentContent.url != null && currentContent.title != null && !contentList.contains(currentContent))
{
if(currentContent.getIconImage() == null)
{
setIcon();
}
contentList.add(currentContent);
}

}


The 3 methods described above are triggered by the parseFeed() method.

public ArrayList parseFeed(Context ctx, Feed feed)
{
try
{

SAXParserFactory spf = SAXParserFactory.newInstance();
SAXParser sp = spf.newSAXParser();
XMLReader xr = sp.getXMLReader();
xr.setContentHandler(this);
xr.parse(new InputSource(feed.url.openStream()));

}
catch (IOException e)
{
Log.e(HANDLER, e.toString());
}
catch (SAXException e)
{
Log.e(HANDLER, e.toString());
}
catch (ParserConfigurationException e)
{
Log.e(HANDLER, e.toString());
}
return contentList;
}


You can browse the source or download a zip file of the source. Connexions for Android is available in the Android Market or from the Connexions website.

Saturday, June 4, 2011

Creating a File Browser in Android

The Connexions app can be used to download PDF and EPUB files so it was a natural to allow the user to view the downloaded files, select one and do something with it. The code for most of this functionality is in the FileBrowserActivity class. The readFileList() method is called by onCreate(). readFileList() checks to see if the Connexions directory exists. If it does, the user has downloaded a PDF or EPUB, so it calls handleFIle().


public void readFileList()
{
currentDirectory = new File(Environment.getExternalStorageDirectory(), "Connexions/");
if(currentDirectory.exists())
{
handleFile(currentDirectory);
}

}



The method handleFile() checks if the file/directory passed in is the Connexions directory. If it is, the list of files is loaded into a List object. It is the behavior when onCreate() calls the method. If a user selects a file from the displayed list, the onListItemClick() method calls handleFile() which opens an alert to ask if the user wants to open the file.


private void handleFile(final File dirOrFile)
{
//Log.d("FileBrowserActivity.browseTo()", "Called");
if (dirOrFile.isDirectory() && !dirOrFile.getPath().endsWith("Connexions/"))
{
this.currentDirectory = dirOrFile;
loadList(dirOrFile.listFiles());
}
else
{

AlertDialog alertDialog = new AlertDialog.Builder(this).create();
alertDialog.setTitle(getString(R.string.file_dialog_title));
alertDialog.setMessage("Open file " + dirOrFile.getName() + "?");
alertDialog.setButton(AlertDialog.BUTTON_POSITIVE, "OK", new DialogInterface.OnClickListener()
{
public void onClick(DialogInterface dialog, int which)
{
openFile(dirOrFile);

} });
alertDialog.setButton(AlertDialog.BUTTON_NEGATIVE, "Cancel", new DialogInterface.OnClickListener()
{
public void onClick(DialogInterface dialog, int which)
{
//do nothing

} });
alertDialog.show();

}
}


If the user selects to open the file, openFile() is called and it tries to open the file in a different application for the file mime-type. If there is no app for the mime-type, a Toast message is displayed to notify the user.


private void openFile(File file)
{

File newFile = new File(Environment.getExternalStorageDirectory() + "/Connexions/" + file.getName());
Uri path = Uri.fromFile(newFile);
//Log.d("FileBrowserActivity", "path: " + path.toString());
Intent intent = new Intent(Intent.ACTION_VIEW);
intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
if(file.getAbsolutePath().indexOf(".pdf") > -1)
{
intent.setDataAndType(path, "application/pdf");
}
else if(file.getAbsolutePath().indexOf(".epub") > -1)
{
intent.setDataAndType(path, "application/epub+zip");
}

try
{
startActivity(intent);
}
catch (ActivityNotFoundException e)
{
Toast.makeText(FileBrowserActivity.this, getString(R.string.file_browser_toast), Toast.LENGTH_SHORT).show();
}
}


As always, you can browse the source or download a zip file of the source. Connexions for Android is available in the Android Market or from the Connexions website.

Sunday, May 15, 2011

Downloading Files in Android

In the Connexions app, users can select to download a PDF or EPUB version of a textbook or chapter of a book. The PDF or EPUB is downloaded to a Connexions folder on their SD Card or on the Phone's storage if there is no SD Card. The code to handle the download is in the downloadFile() method of the DownloadHandler class.

public void downloadFile(final Context context, final String url,final String fileName)
{
/**
* The directory to store the files in
*/
final String STORAGE_PATH = "Connexions/";
/**
* Download buffer size
*/
final int BUFFER_SIZE = 1024 * 23;
final PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, new Intent(context,context.getClass()), 0);

// configure the notification
final Notification notification = new Notification(R.drawable.download_icon, "Downloading " + fileName, System.currentTimeMillis());
notification.flags = notification.flags |=Notification.FLAG_AUTO_CANCEL;
notification.contentIntent = pendingIntent;
notification.setLatestEventInfo(context, "Downloading file", "Downloading " + fileName, pendingIntent);

final NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);

notificationManager.notify(42, notification);

Thread downloadThread = new Thread()
{
public void run()
{

try
{
File cnxDir = new File(Environment.getExternalStorageDirectory(), STORAGE_PATH);
if(!cnxDir.exists())
{
cnxDir.mkdir();
}
File file = new File(cnxDir, fileName);
URL urlObj = new URL(url);
URLConnection con = urlObj.openConnection();
//Log.d("MenuHandler.download", "length = " + length);
BufferedInputStream bis = new BufferedInputStream(con.getInputStream(), BUFFER_SIZE);

FileOutputStream fos = new FileOutputStream(file);
byte[] bArray = new byte[BUFFER_SIZE];
int current = 0;
int read = 0;
while(current != -1)
{
fos.write(bArray,0,current);
current = bis.read(bArray, 0, BUFFER_SIZE);
read = read + current;
}
fos.close();
bis.close();
notificationManager.cancel(42);

}
catch(Exception ioe)
{
Log.d("DownloadHandler.download", "Error: " + ioe.toString(), ioe);
}
}
};
downloadThread.start();
}

The method sets up a NotificationManager so the user can know that the file is being downloaded. The Notification is a set of download icons that appear on the Notification bar until the download is complete. The download itself happens in a separate Thread. Any operation outside the UI should be done in a separate thread. The download can be a long running process, so DownloadHandler is called by a Service. See this post for details. The number 42 is just an identifier for the Thread. It can be any number. In the Thread code, I check to see if the Connexions directory exists and create it if it does not. The actual download is basic Java IO type code. After the download, I stop the notification and close all the resources.

I previously was including a progress bar on the notifications drawer, but it was causing an OutOfMemory error on most downloads. Once I removed it, the error went away.

As always, you can browse the source or download a zip file of the source. Connexions for Android is available in the Android Market or from the Connexions website.

Sunday, April 24, 2011

Creating and Using an Android Service

Services are used in an Android application to perform actions that do not require a user interface. The code called by a Service should run in a separate Thread from the application so it can continue even if the user closes the application. I used a service to download PDF and EPUB files from Connexions. Implementing the service called for creating the Service object, adding it to the ApplicationManifest.xml file, and calling the service from the MenuHandler class.

The Service is created by a class extending IntentService, creating a Constructor that calls the super() constructor and overriding the onHandleIntent() method. The onHandleIntent() method should contain the action to be performed by the Service. In the Connexions app, a DownloadHandler object is created to perform the download in a new Thread.

public class DownloadService extends IntentService
{
public DownloadService()
{
super("DownloadService");
}

@Override
protected void onHandleIntent(Intent intent)
{
DownloadHandler dh = new DownloadHandler();
String url = intent.getStringExtra(DOWNLOAD_URL);
String fileName = intent.getStringExtra(DOWNLOAD_FILE_NAME);
dh.downloadFile(getApplicationContext(), url, fileName);

}

}


Adding the Service to the ApplicationManifest.xml is a simple one line addition.



Calling the Service happens in the displayAlert() method in MenuHandler. If the user selects to download the file, the Service is created as an Intent and started.

public void onClick(DialogInterface dialog, int which)
{
String url = currentContent.getUrl().toString();
Intent intent = new Intent(context, DownloadService.class);

if(type.equals("pdf"))
{
intent.putExtra(DownloadService.DOWNLOAD_URL, MenuUtil.fixPdfURL(currentContent.getUrl().toString(), MenuUtil.getContentType(url)));
intent.putExtra(DownloadService.DOWNLOAD_FILE_NAME, MenuUtil.getTitle(currentContent.getTitle()) + ".pdf");
}
else
{
intent.putExtra(DownloadService.DOWNLOAD_URL, MenuUtil.fixEpubURL(currentContent.getUrl().toString(), MenuUtil.getContentType(url)));
intent.putExtra(DownloadService.DOWNLOAD_FILE_NAME, MenuUtil.getTitle(currentContent.getTitle()) + ".epub");
}
context.startService(intent);

} });


There are 2 Extras added to the Intent. These are used to pass parameters values that are needed by DownloadHandler. The URL of the file to download and the file name are passed to the Service and are retrieved in the onHandleIntent() method in the service. You can see the code for retrieving them in the onHandleIntent() example above.

As always, you can browse the source or download a zip file of the source. Connexions for Android is available in the Android Market or from the Connexions website.

Monday, April 4, 2011

Returning to a Specific Tab from Another Activity

In the Connexions for Android app, there is a TabActivity that has 3 tabs as shown in the screenshot. If the user preforms an action in any of the tabs, the next Activity moves outside the TabActivity.

On all of the Activities outside the TabActivity, there is a menu option to return to one of the Tabs (Search, Lenses, Favorites). One of the problems I had to solve was how to get the TabActivity to display the tab selected by the user outside of the TabActivity. All of the menu code is in one class, MenuHandler. In MenuHandler, if a tab is selected from the menu, an extra is added to the intent for the TabActivity. The extra tells the TabActivity which Tab to display.
'''
case R.id.go_to_favs:
  Intent intent = new Intent(context, TabWidget.class);
  intent.putExtra(TabWidget.TAB_ID, new Integer(2));
  context.startActivity(intent);
  return true;
'''

The extra is retrieved in the onCreate() of TabWidget (the TabActivity in the app). If there is a value for the extra, the current tab is set to it, Otherwise the current tab is set to the first tab.
...
Integer bigInt = (Integer)getIntent().getSerializableExtra(TAB_ID);
if(bigInt != null)
{
  tabHost.setCurrentTab(bigInt.intValue());
}
else
{
  tabHost.setCurre ntTab(0);
}


As always, you can browse the source or download a zip file of the source. Connexions for Android is available in the Android Market or from the Connexions website.

Monday, March 14, 2011

Using a TabActivity in Android

Tabs are not great in Android, but are usable in limited situations. The biggest problem with the TabActivity is trying to move to a new Activity and stay in the tab view. This was really difficult when using a WebView in a tab and the WebView content has links to additional pages. The traditional way of staying within the tabs is to have the TabActivity keep up with the current and previous Activity and switch activities when the user uses the Back button. With a WebView, this goes awray because the TabView cannot track the previous page displayed by the WebView. Since the Connexions app had this problem, I decided to exit the TabActivity after the initial Activity was used in a tab. Most apps that use tabs also do this.

Items needed to use a TabActivty
1. Create an Activity for each tab
2. Create class that extends TabActivty
3. Add entry to AndroidManifest.xml file
4. Use a layout

1. Create an Activity for each tab: Each tab will need to have an initial Activity to display when a tab is selected. My application has 3 tabs, so there are 3 activities
  • SearchActivity
  • ViewLensesActivity
  • ViewFavsActivity

2, Create class that extends TabActivty: In the onCreate() method, remove the title, ceate a TabHost and add activities for each tab to the TabHost

public void onCreate(Bundle savedInstanceState)
{
super.onCreate(savedInstanceState);
requestWindowFeature(Window.FEATURE_NO_TITLE);
setContentView(R.layout.main);

Resources res = getResources();
TabHost tabHost = getTabHost();
TabHost.TabSpec spec;

//add tabs to tab host
spec = tabHost.newTabSpec("search").setIndicator("Search", res.getDrawable(R.drawable.ic_tab_search)).setContent(new Intent().setClass(this, SearchActivity.class));
tabHost.addTab(spec);

spec = tabHost.newTabSpec("lenses").setIndicator("Lenses", res.getDrawable(R.drawable.ic_tab_lenses)).setContent(new Intent().setClass(this, ViewLensesActivity.class));
tabHost.addTab(spec);

spec = tabHost.newTabSpec("favs").setIndicator("Favorites", res.getDrawable(R.drawable.ic_tab_favs)).setContent(new Intent().setClass(this, ViewFavsActivity.class));
tabHost.addTab(spec);

...
}


3. Add entry to AndroidManifest.xml file: Since the TabActivity is the first Activity displayed the Connexions application, the manifest entry contains the launcher information.










4. Use a layout: The Connexions app uses main.xml as the intial layout of the app. This layout handles the .









The Tabs in the Connexions App.


As always, you can browse the source or download a zip file of the source.

Sunday, February 27, 2011

Handling Orientation Changes in Android

I spent a lot of time trying to figure out how to prevent my WebViews and ListViews from reloading when I changed the orientation (portrait to landscape or landscape to portrait). It is not difficult to prevent the reloading, but it involves 2 different solutions.

WebView

To prevent a WebView from reloading, add the following android:configChanges attribute as part of the WebView Activity declaration in your application AndroidManifest.xml file.




ListView

For the ListView, there were 4 code changes needed.

1. In the ListAdapters, add a method to access the data used by the Adapter.


public ArrayList... getItems()
{
return contentList;
}



2. Override the onRetainNonConfigurationInstance() method in the ListActivity classes to get the data from the ListAdapter.

@Override
public Object onRetainNonConfigurationInstance()
{
return this.adapter.getItems();
}


3. Override the onSaveInstanceState() method in the ListActivity classes. The method caches the data for the next instance of the ListActivity.

protected void onSaveInstanceState(Bundle outState)
{
super.onSaveInstanceState(outState);

ContentCache.setObject(storedKey, content);

}



4. In the ListActivity onCreate() method, add this code, which checks to see if there is existing data before loading it again.


content = (ArrayList...)ContentCache.getObject(storedKey);
if(content==null && savedInstanceState != null)
{

content = (ArrayList...)savedInstanceState.getSerializable(storedKey);
}
if(content == null)
{
//no previous data, so RSS feed must be read
readFeed();
}
else
{
//reuse existing feed data
adapter = new LensesAdapter(currentContext, content);
setListAdapter(adapter);
}

I had to modify the ArrayList declarations because Blogger is wanting to close the part of the declaration. You can browse the source or download a zip file of the source.