Android Service startForeground and stopForeground tip

I had a use case for an application that required a long running Service (I know they are always frowned upon). The service seemed to be getting killed during the night and stuck in the “restarting” stage (Settings > Apps > Running). The Service would never restart at some point after being killed. So to raise its priority I used startForeground().

The Service already uses notifications so it wasn’t a big deal to have one required by startForeground(). The problem I ran into was when using stopForeground() to remove the the Service from the foreground followed by a stopService() call. This seemed to kill my Notification which I wanted to remain displaying the results of the service.

By default stopForeground() removes all Notifications when the service is stopped while still in the foreground:

“if you stop the service while it’s still running in the foreground, then the notification is also removed.”

What I concluded was that the order of the stopForeground() service is important. You have to create a notification after you use stopForeground() and before you use stopSelf() in order to preserve your last Notification. Just using stopForeground(false) does not keep the notification around if the service is then stopped (stopSelf()).

This might be must a niche use case (or corner case) but it’s one I needed and thought I might share.

-Mr

Android: TextView with Ellipsis Listener

I created an extension of the Android TextView component to adds the ability to receive updates when the text has an ellipses added. Short and sweet, here is the code and the link for the gist:

/**
 * Author: Michael Ritchie, ThanksMister LLC
 * Date: 10/16/12
 * Web: thanksmister.com
 *
 * Extension of <code>TextView</code> that adds listener for ellipses changes.  This can be used to determine
 * if a TextView has an ellipses or not.
 *
 * Derived from discussion on StackOverflow:
 *
 * http://stackoverflow.com/questions/4005933/how-do-i-tell-if-my-textview-has-been-ellipsized
 */
package com.cg.mobile.components;

import android.content.Context;
import android.text.Layout;
import android.util.AttributeSet;
import android.widget.TextView;

import java.util.ArrayList;
import java.util.List;

public class EllipsisTextView extends TextView
{
    public interface EllipsisListener
    {
        void ellipsisStateChanged(boolean ellipses);
    }

    private final List<EllipsisListener> ellipsesListeners = new ArrayList<EllipsisListener>();

    private boolean ellipses;

    public EllipsisTextView(Context context)
    {
        super(context);
    }

    public EllipsisTextView(Context context, AttributeSet attrs)
    {
        super(context, attrs);
    }

    public EllipsisTextView(Context context, AttributeSet attrs, int defStyle)
    {
        super(context, attrs, defStyle);
    }

    public void addEllipsesListener(EllipsisListener listener)
    {
        if (listener == null) {
            throw new NullPointerException();
        }
        ellipsesListeners.add(listener);
    }

    public void removeEllipsesListener(EllipsisListener listener)
    {
        ellipsesListeners.remove(listener);
    }

    public boolean hadEllipses() {
        return ellipses;
    }

    @Override
    public void layout(int l, int t, int r, int b)
    {
        super.layout(l, t, r, b);

        ellipses = false;
        Layout layout = getLayout();
        if ( layout != null){
            int lines = layout.getLineCount();
            if ( lines > 0)  {
                if ( layout.getEllipsisCount(lines-1) > 0) {
                    ellipses = true;
                }
            }
        }

        for (EllipsisListener listener : ellipsesListeners) {
            listener.ellipsisStateChanged(ellipses);
        }
    }
}

Gist: https://gist.github.com/3902660

-Mr

Loading local JSON issue with Sencha, PhoneGap and Android 4.0.3

This is just a little tip when trying to load local .json files using the Ext.data.Store ajax proxy type. The following code will work in Android 4.0.3+ but you need to be sure to add an extra couple lines to get it to work, noCache: false and enablePagingParams: false. Without those lines the code still works fine in Android 2.3.3 but for Android 4 and above, the ajax fails on loading local .json files because the requests url includes parameters added by Sencha.

Roads DataStore

Ext.define("Roads", {
    extend:"Ext.data.Store",
    requires:"Ext.data.proxy.LocalStorage",
    config:{
        fields: [
            { name: 'id', type: 'int' },
            { name: 'title', type: 'string' },
            { name: 'subtitle', type: 'string' }
        ],
        proxy:{
            type:'ajax',
            url:'resources/data/roads.json',
            reader:{
                type:'json',
                           noCache: false,
            enablePagingParams: false,
                rootProperty:'roads'
            }
        },
        listeners:{
            load:function (s, r) {
                console.log(r)
            }
        }
    }
});

roads.json

{"roads":[
    {
        "id":"i10",
        "title":"Interstate 10 Arizona",
        "subtitle":"Papago Freeway/Maricopa Freeway"
    },
    {
        "id":"i17",
        "title":"Interstate 17 Arizona",
        "subtitle":"Black Canyon Freeway"
    },
    {
        "id":"l101",
        "title":"Arizona State Route 101",
        "subtitle":"Loop 101"
    }
]}

References where the solution was found:

http://www.sencha.com/forum/showthread.php?190878-Android-local-json-store-not-loading

http://www.sencha.com/forum/showthread.php?162322-Sencha-Touch-2-PhoneGap-are-not-working-on-Android-4/page3

-Mister

Handle Android hardware back button using PhoneGap and Sencha Touch 2

Lately I have been experimenting with PhoneGap and Sencha Touch 2 recently and discovered an issue with accessing the hardware back button for Android applications. What you want is the back button to navigate back through the views of the application rather than exit, unless of course you are on the first screen of your application. This is the default user experience on Android phones when pressing the hardware back button, so it’s a good thing to implement it.

My example is a modification of the Notes application Sencha Touch 2 example (found at http://miamicoder.com/2012/how-to-create-a-sencha-touch-2-app-part-1/) for PhoneGap delivery. This is a great multi-part series that walks you through the Sencha Touch 2 MVC framework to create a simple Notes application.
I used a Sencha Routes to navigate the history and a PhoneGap method for overriding the back button behavior for Android.

The first step was getting Sencha to play nice with PhoneGap when developing for Android 4+. The trick is to import each of your JS files within the index.html.

<!DOCTYPE html>
 <html>
 <head>
     <meta charset="utf-8">
     <meta name="viewport" content="width=device-width, initial-scale=1">

     <title>PhoneGap Notes Application</title>
     <link rel="stylesheet" href="css/master.css" type="text/css">
     <link rel="stylesheet" href="lib/resources/css/sencha-touch.css" type="text/css">

     <script type="text/javascript" charset="utf-8" src="lib/cordova-1.8.1.js"></script>

     <script type="text/javascript" src="lib/builds/sencha-touch-all-compat.js"></script>

     <!-- this had to be done for Android 4+ to work -->
     <script type="text/javascript" src="app/model/Note.js"></script>
     <script type="text/javascript" src="app/store/Notes.js"></script>
     <script type="text/javascript" src="app/controller/Notes.js"></script>
     <script type="text/javascript" src="app/view/NotesListContainer.js"></script>
     <script type="text/javascript" src="app/view/NotesList.js"></script>
     <script type="text/javascript" src="app/view/NoteEditor.js"></script>

     <script type="text/javascript" src="app.js"></script>
 </head>
 <body></body>
 </html>

Now we set up the app.js file to add the hooks for capturing the Android back button:

app.js

Ext.application({
    name: "NotesApp",
    models: ["Note"],
    stores: ["Notes"],
    controllers: ["Notes"],
    views: ["NotesList", "NotesListContainer", "NoteEditor"],

    launch: function () {

        var notesListContainer = {
            xtype: "noteslistcontainer"
        };
        var noteEditor = {
            xtype: "noteeditor"
        };

        Ext.Viewport.add([notesListContainer, noteEditor]);

        // set up a listener to handle the back button for Android 
        if (Ext.os.is('Android')) {
          document.addEventListener("backbutton", Ext.bind(onBackKeyDown, this), false);  // add back button listener

          function onBackKeyDown(e) {
              e.preventDefault();

              // you are at the home screen
              if (Ext.Viewport.getActiveItem().xtype == notesListContainer.xtype ) {
                  navigator.app.exitApp();
              } else {
                  this.getApplication().getHistory().add(Ext.create('Ext.app.Action', {
                      url: 'noteslistcontainer'
                  }));
              }
          }
       }
    }
});

I used the Ext.bind method so I could have access to “this” within the scope of the “onBackKeyDown” function. Notice that if the application is not currently on the “notesListContainer” view port we add a new url to the application history. This is part of using Sencha Routes, we will later use this in the main controller to navigate the application in the Notes.js controller file:

app/controller/Notes.js

Ext.define("NotesApp.controller.Notes", {

    extend: "Ext.app.Controller",
    config: {
        refs: {
            // We're going to lookup our views by xtype.
            notesListContainer: "noteslistcontainer",
            noteEditor: "noteeditor"
        },
        control: {
            notesListContainer: {
                // The commands fired by the notes list container.
                newNoteCommand: "onNewNoteCommand",
                editNoteCommand: "onEditNoteCommand"
            },
            noteEditor: {
                // The commands fired by the note editor.
                saveNoteCommand: "onSaveNoteCommand",
                deleteNoteCommand: "onDeleteNoteCommand",
                backToHomeCommand: "onBackToHomeCommand"
            }
        },
        routes: {
            'noteslistcontainer': 'activateNotesListenerPage'
        }
    },

    // override back button navigation and navigate back if viewing note editor
    activateNotesListenerPage: function ()
    {
        this.activateNotesList();
    },

    // handle new note command
    onNewNoteCommand: function ()
    {
        console.log("onNewNoteCommand");

        var now = new Date();
        var noteId = (now.getTime()).toString() + (this.getRandomInt(0, 100)).toString();

        var newNote = Ext.create("NotesApp.model.Note", {
            id: noteId,
            dateCreated: now,
            title: "",
            narrative: ""
        });

        this.activateNoteEditor(newNote);
    },

    // handle edit note command
    onEditNoteCommand: function (list, record) {

        console.log("onEditNoteCommand");
        this.activateNoteEditor(record);

    },

    // handle save note command
    onSaveNoteCommand: function () {

        console.log("onSaveNoteCommand");

        var noteEditor = this.getNoteEditor();

        var currentNote = noteEditor.getRecord();
        var newValues = noteEditor.getValues();

        // Update the current note's fields with form values.
        currentNote.set("title", newValues.title);
        currentNote.set("narrative", newValues.narrative);

        var errors = currentNote.validate();

        if (!errors.isValid()) {
            Ext.Msg.alert('Wait!', errors.getByField("title")[0].getMessage(), Ext.emptyFn);
            currentNote.reject();
            return;
        }

        var notesStore = Ext.getStore("Notes");

        if (null == notesStore.findRecord('id', currentNote.data.id)) {
            notesStore.add(currentNote);
        }

        notesStore.sync();

        notesStore.sort([{ property: 'dateCreated', direction: 'DESC'}]);

        this.activateNotesList();
    },
    onDeleteNoteCommand: function() {
        console.debug("onDeleteNoteCommand");

        navigator.notification.confirm(
            'You want to delete this note?',  // message
            Ext.bind(onConfirm, this),              // callback to invoke with index of button pressed
            'Warning',            // title
            'No,Yes'          // buttonLabels
        );

        // process the confirmation dialog result
        function onConfirm(selectedButtonIndex) {
            if(selectedButtonIndex == '2') {
                var noteEditor = this.getNoteEditor();
                var currentNote = noteEditor.getRecord();
                var notesStore = Ext.getStore("Notes");
                notesStore.remove(currentNote);
                notesStore.sync();
                this.activateNotesList();
            }
        }
    },

    onBackToHomeCommand: function ()
    {
        console.log("onBackToHomeCommand");
        this.activateNotesList();
    },

    // EditNote Class functions
    getRandomInt: function (min, max)
    {
        return Math.floor(Math.random() * (max - min + 1)) + min;
    },

    activateNoteEditor: function (record)
    {
        var noteEditor = this.getNoteEditor();
        noteEditor.setRecord(record); // load() is deprecated.

        this.getApplication().getHistory().add(Ext.create('Ext.app.Action', {
            url: 'noteslistcontainer/noteeditor'
        }));

        Ext.Viewport.animateActiveItem(noteEditor, this.slideLeftTransition);
    },

    activateNotesList: function ()
    {
        Ext.Viewport.animateActiveItem(this.getNotesListContainer(), this.slideRightTransition);
    },

    // transitions
    slideRightTransition: { type: 'slide', direction: 'right' },
    slideLeftTransition: { type: 'slide', direction: 'left' },

    // Base Class functions.
    launch: function () {
        this.callParent(arguments);
        Ext.getStore("Notes").load();
        console.log("launch");
    },
    init: function () {
        this.callParent(arguments);
        console.log("init");
    }
});

Here we setup a route within the config that will call the method “activateNotesList” when the url “noteslistcontainer” is added to the history. If the user creates/edits a new note, the method in the controller that navigates the application to the NoteEditor view adds to the history a new url for “noteslistcontainer/noteeditor”.

Hitting the Android hardware back button from the NoteEditor view is captured by our “onBackKeyDown” handler in the app.js file. Since the active container is not the NotesListContainer view, the history url will be set to “noteslistcontainer” and the method “activateNotesList” will be fired. If our active container was the NotesListContainer, the application would exit as normal.

That is pretty much it. The rest of the code isn’t modified from the Notes example (I didn’t do step 5 of that series). I am sure there is probably more than a few ways to implement the proper behavior for the Android hardware back button, but for me this works. If you have any other suggestions or improvements, do pass them along.

Source References:

Sencha Touch Routes and Back Button
How to create a Sencha Touch 2 app

-Mister

Android: Changing the Default Indeterminate Progress Size in ActionBarSherlock

Just a quick post on how to change the default size of the Indeterminate Progress animation when using ActionBarSherlock (ABS). This uses the dark halo them for Android 4.0.1 but tested and working from version 2.3.2 to 4.0.1 of the SDK. Here are the before and after shots:

Grab the progress_small_holo.xml and associated images from the Android SDK (15) and move them to your project (from your SDK location: android/platforms/android-15/data/res/drawable). We will be using this to style the progress animation in the ActionBar for ABS Set up your style.xml as follows:

values/styles.xml

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <style name="Dark" parent="Theme.Sherlock">
        <item name="actionBarStyle">@style/Widget.Styled.ActionBar</item>
        <item name="android:actionBarStyle">@style/Widget.Styled.ActionBar</item>
    </style>

    <style name="Widget.Styled.ActionBar" parent="Widget.Sherlock.Light.ActionBar">
        <item name="android:indeterminateProgressStyle">@style/IndeterminateProgress</item> 
        <item name="indeterminateProgressStyle">@style/IndeterminateProgress</item> 
    </style>

    <style name="IndeterminateProgress" parent="@android:style/Widget.ProgressBar.Small"> 
       <item name="android:indeterminateDrawable">@drawable/progress_small_holo</item> 
   </style> 
</resource>

values-v14/styles.xml (ICS)

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <style name="Dark" parent="Theme.Sherlock">
        <item name="actionBarStyle">@style/Widget.Styled.ActionBar</item>
        <item name="android:actionBarStyle">@style/Widget.Styled.ActionBar</item>
    </style>

    <style name="Widget.Styled.ActionBar" parent="Widget.Sherlock.Light.ActionBar">
        <item name="android:indeterminateProgressStyle">@style/IndeterminateProgress</item> 
        <item name="indeterminateProgressStyle">@style/IndeterminateProgress</item> 
    </style>

    <style name="IndeterminateProgress" parent="@android:style/Widget.ProgressBar.Small"/> 

</resource>

You could also add some additional sizing if you want to center the progress animation in the actionbar:

<style name="IndeterminateProgress" parent="@android:style/Widget.ProgressBar.Small">
       <item name="android:minWidth">48dp</item> 
</style>

You can see more of the discussion on this thread or from StackOver.

-Mister

Android: Null data returned from Camera Intent

Anyone who has tried calling the image or video capture intent using the default Camera activity probably has been met with much frustration. There are many approaches and workarounds for various phones and API levels because of the insane fragmentation of Android. Most of us just want a simple way to call the default Camera activity, have the video or image stored in the Gallery, and retrieve a the Camera intent results for further processing.

The Android documentation provides what appear to be a very straight forward way to capture images and video and either save them in the default location or a folder of your choosing. The resources available for doing so can be found here:

Image capture intent
Saving Media Files

However, I am not sure Google actually tests their Android examples on real phones (developer phones). Testing the method for capturing images on a Galaxy Nexus or Nexus One both return a null value for the data when receiving the camera intent result. However, capturing video seems to work as expected. Though both the image and video files are written to the specified folders on the device.

So naturally, as a developer you are stuck with a borked example that must be modified for a real world implementation. Luckily for my needs I didn’t need to store images or video in an external folder, I wanted the camera application to store the requested media in the default location with a default name. This is usually the Camera folder.

Below is my modification to the onActivityResult() method of the Activity for retrieving the Uri of the captured image. I have left the rest of the code from the Android Camera example for storing video and images in the activity in case you are feeling particularly brave and want to store your images in a different location.

You might ask why I couldn’t insert the video into the MediaStore the same way I am inserting the captured image (by passing the Uri and ContentValues). Doing this for video actually created two files for me, one in the Media Store, and an 0kb file in the external video folder on the SD card. This only happened on the Nexus One (Android 2.3.4) and not the Galaxy Nexus (4.0.2). So you end up with two different methods for making sure media appears in the MediaStore.

AndroidCameraTestsActivity

package com.thanksmister.mobile;

import java.io.File;
import java.text.SimpleDateFormat;
import java.util.Date;

import android.app.Activity;
import android.content.ContentValues;
import android.content.Intent;
import android.database.Cursor;
import android.net.Uri;
import android.os.Bundle;
import android.os.Environment;
import android.provider.MediaStore;
import android.util.Log;
import android.view.View;

public class AndroidCameraTestsActivity extends Activity 
{
	private static final String TAG = AndroidCameraTestsActivity.class.getSimpleName(); 
	
	private static final int CAPTURE_IMAGE_ACTIVITY_REQUEST_CODE = 100;
	private static final int CAPTURE_VIDEO_ACTIVITY_REQUEST_CODE = 200;
	public static final int MEDIA_TYPE_IMAGE = 1;
    public static final int MEDIA_TYPE_VIDEO = 2;

    private Uri fileUri;
    
    /** Called when the activity is first created. */
    @Override
    public void onCreate(Bundle savedInstanceState) 
    {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.main);
    }

    /** 
     * https://developer.android.com/guide/topics/media/camera.html 
     * **/
    public void onCaptureImage(View v) 
    {
        // give the image a name so we can store it in the phone's default location
    	String timeStamp = new SimpleDateFormat("yyyyMMdd_HHmmss").format(new Date());
    	
        ContentValues values = new ContentValues();
		values.put(MediaStore.Images.Media.TITLE, "IMG_" + timeStamp + ".jpg");

        Intent intent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE);
        
        //fileUri = getOutputMediaFileUri(MEDIA_TYPE_IMAGE); // create a file to save the image (this doesn't work at all for images)
        fileUri = getContentResolver().insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values); // store content values
		intent.putExtra( MediaStore.EXTRA_OUTPUT,  fileUri);
       
        // start the image capture Intent
        startActivityForResult(intent, CAPTURE_IMAGE_ACTIVITY_REQUEST_CODE);
    }
    
    /** 
     * https://developer.android.com/guide/topics/media/camera.html 
     * **/
    public void onCaptureVideo(View v) 
    {
    	 //create new Intent
        Intent intent = new Intent(MediaStore.ACTION_VIDEO_CAPTURE);

        //fileUri = getOutputMediaFileUri(MEDIA_TYPE_VIDEO);  // create a file to save the video in specific folder (this works for video only)
        //intent.putExtra(MediaStore.EXTRA_OUTPUT, fileUri);  // set the image file name
      
        intent.putExtra(MediaStore.EXTRA_VIDEO_QUALITY, 1); // set the video image quality to high

        // start the Video Capture Intent
        startActivityForResult(intent, CAPTURE_VIDEO_ACTIVITY_REQUEST_CODE);
    }
    
    @Override
    protected void onActivityResult(int requestCode, int resultCode, Intent data) 
    {
    	super.onActivityResult(requestCode, resultCode, data);
    	
        if (requestCode == CAPTURE_IMAGE_ACTIVITY_REQUEST_CODE) {
            if (resultCode == RESULT_OK) {
            	
            	// Originally I was going to iterate through the list of images and grab last added to the MediaStore.
            	// But this is not necessary if we store the Uri in the image
            	/*
            	String[] projection = {MediaStore.Images.ImageColumns._ID};
            	String sort = MediaStore.Images.ImageColumns._ID + " DESC";

            	Cursor cursor = this.managedQuery(MediaStore.Images.Thumbnails.EXTERNAL_CONTENT_URI, projection, null, null, sort);

            	try{
            		cursor.moveToFirst();
            		Long id = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Images.ImageColumns._ID));
            		fileUri = Uri.withAppendedPath(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, String.valueOf(id));
            	} finally{
            		cursor.close();
            	}
            	*/
                
				if(fileUri != null) {
					Log.d(TAG, "Image saved to:\n" + fileUri);
					Log.d(TAG, "Image path:\n" + fileUri.getPath());
					Log.d(TAG, "Image name:\n" + getName(fileUri)); // use uri.getLastPathSegment() if store in folder
				}
                
            } else if (resultCode == RESULT_CANCELED) {
                // User cancelled the image capture
            } else {
                // Image capture failed, advise user
            }
        }

        if (requestCode == CAPTURE_VIDEO_ACTIVITY_REQUEST_CODE) {
            if (resultCode == RESULT_OK) {
            	
                // Video captured and saved to fileUri specified in the Intent
            	fileUri = (Uri) data.getData();
				
				if(fileUri != null) {
					Log.d(TAG, "Video saved to:\n" + fileUri);
					Log.d(TAG, "Video path:\n" + fileUri.getPath());
					Log.d(TAG, "Video name:\n" + getName(fileUri)); // use uri.getLastPathSegment() if store in folder
				}
				
            } else if (resultCode == RESULT_CANCELED) {
                // User cancelled the video capture
            } else {
                // Video capture failed, advise user
            }
        }
    }
    
    /** Create a file Uri for saving an image or video to specific folder
     * https://developer.android.com/guide/topics/media/camera.html#saving-media
     * */
    private static Uri getOutputMediaFileUri(int type)
    {
          return Uri.fromFile(getOutputMediaFile(type));
    }

    /** Create a File for saving an image or video */
    private static File getOutputMediaFile(int type)
    {
        // To be safe, you should check that the SDCard is mounted
        
    	if(Environment.getExternalStorageState() != null) {
    		// this works for Android 2.2 and above
    		File mediaStorageDir = new File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES), "AndroidCameraTestsFolder");
            
            // This location works best if you want the created images to be shared
            // between applications and persist after your app has been uninstalled.

            // Create the storage directory if it does not exist
            if (! mediaStorageDir.exists()) {
                if (! mediaStorageDir.mkdirs()) {
                    Log.d(TAG, "failed to create directory");
                    return null;
                }
            }

            // Create a media file name
            String timeStamp = new SimpleDateFormat("yyyyMMdd_HHmmss").format(new Date());
            File mediaFile;
            if (type == MEDIA_TYPE_IMAGE){
                mediaFile = new File(mediaStorageDir.getPath() + File.separator +
                "IMG_"+ timeStamp + ".jpg");
            } else if(type == MEDIA_TYPE_VIDEO) {
                mediaFile = new File(mediaStorageDir.getPath() + File.separator +
                "VID_"+ timeStamp + ".mp4");
            } else {
                return null;
            }

            return mediaFile;
    	}
        
    	return null;
    }

    // grab the name of the media from the Uri
    protected String getName(Uri uri) 
	{
		String filename = null;

		try {
			String[] projection = { MediaStore.Images.Media.DISPLAY_NAME };
			Cursor cursor = managedQuery(uri, projection, null, null, null);

			if(cursor != null && cursor.moveToFirst()){
				int column_index = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DISPLAY_NAME);
				filename = cursor.getString(column_index);
			} else {
				filename = null;
			}
		} catch (Exception e) {
			Log.e(TAG, "Error getting file name: " + e.getMessage());
		}

		return filename;
	}
}

Main.xml

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="fill_parent"
    android:layout_height="fill_parent"
    android:orientation="vertical" >

    <Button android:text="Capture Image" android:onClick="onCaptureImage" 
        android:layout_width="wrap_content"
    android:layout_height="wrap_content"/>
    
	<Button android:text="Capture Video" android:onClick="onCaptureVideo"
	    android:layout_width="wrap_content"
    android:layout_height="wrap_content"/>
	
</LinearLayout>

ApplicationManifest.xml

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.thanksmister.mobile"
    android:versionCode="1"
    android:versionName="1.0" >

    <uses-sdk android:targetSdkVersion="15" android:minSdkVersion="8"/>
    
    <uses-permission android:name="android.permission.CAMERA"/>
 	<uses-feature android:name="android.hardware.camera" />
 	<uses-feature android:name="android.hardware.camera.autofocus"/>
  	<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>

    <application
        android:icon="@drawable/ic_launcher"
        android:label="@string/app_name" >
        <activity
            android:name="AndroidCameraTestsActivity"
            android:label="@string/app_name" >
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />

                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
    </application>

</manifest>

Additional Resources:

Android Camera Test Project
Null Intent passed back On Samsung Galaxy Tab…

-Mister

Custom Android TitleBar with Drop Shadow

Recently I ran into a little challenge with some designs for an Android application. The designs called for a drop shadow to appear on the application TitleBar and the footer bar. The drop shadow effect should appear on top of the scrolling list items. However, I learned that with the Android ListView component there is already an effect called the fading edge. An additional problem is producing a realistic drop shadow effect that appears to float over the items below it rather than take up space between the TitleBar and the ListView.

The first problem, the built in fading edge effect of the ListView makes the list item appear to fade from the screen as it scrolls to the top or bottom of the list. In general this is a nice effect as the ListView produces it’s own shadow and fade as the list is scrolled under the TitleBar. However, this effect clashes with a custom drop shadow effect on the TitleBar because when list items are pressed or selected they produce a slight glow and blur effect at the top/bottom of the list just below the drop shadow. There are two properties of the ListView component that need to be turned “off” for the effects to be removed:

<?xml version="1.0" encoding="utf-8"?>
<ListView
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_height="fill_parent"
android:layout_width="fill_parent"
android:cacheColorHint="#00000000"
android:fadingEdge="none"/>

https://gist.github.com/997273

The fadingEdge=”none” setting removes the blurring and fading of list items as they scroll off screen and the cachColorHint=”#0000000″ removes the list from turning black while scrolled even though the background maybe set to be transparent (you can also set a custom list item drawable background to take care of this problem). There is an interesting article about why this effect is part of the ListView component that can be read on the Android Developer blog.

So now you have a list that has no shadow, fade, or blur effects. To get the shadow to appear to float above the scrolling content I created a a nice drop shadow PNG file in Photoshop with transparent background. I then used the Android Draw 9-Patch tool to make the shadow file scale horizontally across the top and bottom of the application. You now get the following results:

To get the shadow to “float” on top of the ListView, I used a Relative layout to position the items. I also used my own custom TitleBar and ListAdapter in the application to get the desired appearance, as well as added a static footer graphic. What you get a nice drop shadow on the top and bottom of the application that is always on top of the list. This approach as the advantage that the list is underneath the drop shadow not just position below it as it might be if you used a LinearLayout.

You can download the full project code on GitHub.

-Mr

Remove Underline from Clickable text in TextView on Android

Been working with the Android SDK (v 2.2) lately and needed to a clickable text area in an TextView but one without any underlined links (so it doesn’t appear like a hyperlink). I also wanted to capture the click event and launch my own Activity within the same Activty, rather than use something as complicated as Linkify for this use case.

This example builds the text value for a TextView component dynamically using a SpannableStringBuilder. To create the span for a given length of text, I created a custom class file that extends ClickableSpan. This custom class overrides the the “updateDrawState” method and removes the underline. This seems pretty straight forward, but it was not easy assembling all the pieces from various examples to get the results I desired.

Here is a image preview of the application output:

Full Code: https://gist.github.com/249970d811d84a529d37.

AS3 P2P Helper Class

This is just a simple class I created to allow to clients (AIR Desktop, AIR Mobile) to send and receive commands from each other using Flash Player 10 P2P (Peer to Peer). This class is used to drive a AIR application running on a large wall mounted flat screen monitor from an Android mobile device. These are two different applications but are talking to each other through this common library while connected on the same network.

There is a great little starter video by Paul Trani with CS5. Peter Elst is also working on a P2P library called Cocoon that allows more robust communication. Also check out Renaun Erickson’s post for setting up your Mac as a hub for local P2P connections without WiFi.

Full code: https://gist.github.com/673f0a77f701d4ae98a7

-Mister

AIR for Android: Phoenix Traffic Released in Android Market


Phoenix Traffic for Android

I finally released my first application into the Android Market. This application takes data for Phoenix traffic cameras and displays live camera images. That part was simple enough, but deciding to go with either Flex or Flash for development was the hard part. Originally, I built the application using Flex 4 (this was pre-Hero and Burrito) and talked about it in a previous post. However, I found the Flex application to be memory intensive, lethargic, and the scrolling list functionality simply unusable.

For the final release of the application, I decided to use Flash CS5. This allowed me to build a somewhat smaller version of the application that could be easily be deployed using Adobe’s AIR technology for Android directly from Flash. As part of this project I built (or rather modified code from others) an AS3 scrolling list that responds to touch events and behaves similar to native Android scrolling lists.

In my opinion, the list still performs better than some of the current controls in being built for Flex Burrito. This might change in the near future, but for now, AS3 only applications on Android seem to be lighter and perform better than Flex. If you are interested, you can see the code and example files for the scrolling list here in this post.

To download the Phoenix Traffic application, you can visit the Phoenix Traffic application page from this site or just search for “Phoenix Traffic” in the Android Market. Please feel free to leave me any comments or feedback about the application. I will continue to tinker with the application and improve it over time.

Update

This application has since been migrated from AIR to a native Android application.

– Mister