Wednesday, July 30, 2008

Processing HTML forms with the AIR HTMLLoader class

The Adobe AIR project I'm working on needs to authenticate with an appliance that my company sells. The form on the appliance's login page executes some fancy Javascript prior to submitting the form, which results in an additional session cookie being generated. The simplest approach seemed to be to use an HTMLLoader object to access the login page, fill out the form variables, and submit the form.

After a successful login I could extract the session cookies and tack them onto subsequent requests I was making with Flex's HTTPService. Doing all of this turned out to be pretty easy. Here's an outline of what I did, followed by some code snippets.

In my AIR application I am not rendering any of the HTML retrieved by the HTMLLoader. I'm just using it for authentication, so all of this happens behind the scenes and is not visible to the AIR app's user.

  1. Examine the structure of the login form, paying attention to any form variables and onSubmit methods required by the form.
  2. Instantiate an HTMLLoader, setup an event listener to handle the Event.COMPLETE event, and load the page.
  3. In the event handler, use DOM methods to specify values for the required form variables.
  4. If the form has an onSubmit function, call this function before you submit the form. When you use the DOM to submit a form, the onSubmitfunction is not executed like it normally would by a browser.
  5. Submit the HTML form.
  6. Extract any cookies and store them in a generic object. These cookies will be needed for any HTTPService that you'll be using after authentication.
  7. Use your HTTPService to fetch data as usual.

Note that there is one drawback to this approach. The HTMLLoader doesn't seem to fire an event if your page cannot be loaded. In my AIR app, the user supplies an IP address for the appliance they wish to authenticate with. If the user supplies an invalid IP, the HTMLLoader won't find any page to load and will never fire off an event to indicate that it failed to load the page.

Check out this nice post which describes some options for detecting when the page cannot be loaded.

Here's the code relating to the HTMLLoader, which I've taken from my Caringorm Command class's execute method. Forgive me for not showing the entire class...

import com.adobe.cairngorm.commands.ICommand;
import com.adobe.cairngorm.control.CairngormEvent;
import flash.events.Event;
import flash.html.HTMLLoader;
import flash.net.URLRequest;

// my Cairngorm model locator class which stores my app's variables
import com.sunild.model.AppModelLocator;

private modelLocator:AppModelLocator = AppModelLocator.getInstance();

private var loginAttempted:Boolean = false;

public function execute( event:CairngormEvent ):void {
    var htmlLoader:HTMLLoader = new HTMLLoader();

    // the appliance IP address is stored in my Cairngorm model locator class
    var url:URLRequest = new URLRequest("https://" + modelLocator.applianceIp);
    htmlLoader.addEventListener(Event.COMPLETE, handleHtmlLoaderComplete);
    htmlLoader.load(url);
}

/*
    This method gets called anytime content in the HTMLLoader object changes.
    That can happen if: we submit a form, get redirected, or otherwise navigate
    to some other page.  This function therefore needs logic to detect what
    page it's processing.
*/
private function handleHtmlLoaderComplete( event:Event ):void {

    var ldr:HTMLLoader = event.target as HTMLLoader;

    var doc:Object = ldr.window.document;

    // my login form is found in a frame on the login page
    var frame:Object = ldr.window.frames;

    // a simple method of determining which page we're processing
    trace("Title of page being loaded: " + doc.title);

    // login page, frame[0] has a form named "loginForm"
    if ( !loginAttempted && frame[0] && frame[0].loginForm ) {
        // set the userName and password form variables
        frame[0].loginForm.userName.value = modelLocator.username;
        frame[0].loginForm.password.value = modelLocator.password;

        // execute the form's onSubmit function if necessary
        frame[0].onSubmitFunction();

        // keep track of whether we submitted the form so we only do it once
        loginAttempted = true;

        frame[0].loginForm.submit();
    }
    else if (loginAttempted && frame[0] && frame[0].loginForm) {
        // if we attempted to login but still see the login form, the login attempt failed

        // set loginAttempted to false
        loginAttempted = false;

        // add code here to handle failed login

        // I'm removing the event listener, probably isn't necessary here
        ldr.removeEventListener(Event.COMPLETE, handleHtmlLoaderComplete);

    }
    else {
        // look for something on the page to verify that we were logged in
        // I'm simply using the page's title
        if ( String(doc.title).search("Authentication Succeeded") ) {

            // extract the session cookies and store in an anonymous object
            var tmpCookies:Array = String(doc.cookie).split("; ");
            for each ( var tmpString:String in tmpCookies ) {
                var tmpArray:Array = tmpString.split("=");
                modelLocator.cookieObject[ tmpArray[0] ] = tmpArray[1];
            }

            /* When we get to this point, we are logged in ... now we should clean up
                 and unregister the event handler for the this HTMLLoader object
            */
            ldr.removeEventListener(Event.COMPLETE, handleHtmlLoaderComplete);
        }   
    }
}

And later in my AIR application, I can use an HTTPService to request data from the appliance. Here's an MXML snippet that defines the HTTPService which includes the cookie object we created above. Now when I call the HTTPService's send() method, my session cookies will be included for me!

<mx:HTTPService id="fetchSomeData"
    url="https://{modelLocator.applianceIp}/some_path_to_data.xml"
    resultFormat="e4x"
    headers="{modelLocator.cookieObject}" />

This all seems a little hoaky, and is not the final code that we'll be using in the AIR application. Eventually, authentication with the appliance will all be done via an HTTPService that posts XML data to the appliance. But this functionality has not been implemented on our appliance yet!

Manipulating the DOM with an HTMLLoader is quite easy, so this seemed like a good solution to move the project along in the meantime.

Tuesday, July 1, 2008

Data binding in ActionScript

Most examples you see with Flex do data binding in MXML. This seems to suffice for most cases I can think of, but recently I found the need to change a binding dynamically at run time.

At the time the only way I could think of doing this was to was to do it in ActionScript. I didn't know how to do that, and the documentation seemed a little terse on this topic. So here's my little ditty on how to bind (and "unbind") data in ActionScript.

As a side note, I didn't use this in my application. Instead I used an interim variable to bind my custom control to. I stored my actual data in an array of arrays. Then, in my Cairngorm Command class I copied the appropriate array element into the actual variable that was bound to my custom control. But since I haven't posted anything here for a while, I thought I'd post this article about performing binding operations in ActionScript...

You can use the BindUtils class, which provides two static methods for performing data binding operations: bindProperty() and bindSetter(). I used the bindProperty() method. Here's some example code, and my explanation of what it does:


<?xml version="1.0" encoding="utf-8"?>
<mx:Application xmlns:mx="http://www.adobe.com/2006/mxml"
 layout="absolute"
 creationComplete="init()" viewSourceURL="BindExampleSrc/index.html">
 
 <mx:Form >
  <mx:FormHeading label="ActionScript Binding Example" />
  <mx:FormItem>
   <mx:Label id="labelTextInput1" text="Text Input 1 (original binding source)" />
   <mx:TextInput id="textInput1" />
  </mx:FormItem>
  <mx:FormItem>
   <mx:Label id="labelTextInput2" text="Text Input 2 (original binding destination)" />
   <mx:TextInput id="textInput2" />
  </mx:FormItem>
  <mx:FormItem>
   <mx:Label text="Click this button to reverse the source/dest of the binding" />
   <mx:Button label="Reverse Bindings" click="reverseButtonHandler()" />
  </mx:FormItem>
 </mx:Form>
 
 <mx:Script>
  <![CDATA[
   import mx.binding.utils.ChangeWatcher;
   import mx.binding.utils.BindingUtils;
   
   private var changeWatcher:ChangeWatcher;
   private var bindToggle:Boolean = true;
   
   private function init():void {
    
    changeWatcher = BindingUtils.bindProperty(textInput2, "text", textInput1, "text");
    
   }
   
   private function reverseButtonHandler():void {
    // undo the data binding
    changeWatcher.unwatch();
    
    if (bindToggle) {
     changeWatcher = BindingUtils.bindProperty(textInput1, "text", textInput2, "text");
     bindToggle = false;
     labelTextInput1.text = "Text Input 1 (now the binding destination)";
     labelTextInput2.text = "Text Input 2 (now the binding source)";
    }
    else {
     changeWatcher = BindingUtils.bindProperty(textInput2, "text", textInput1, "text");
     bindToggle = true;
     labelTextInput1.text = "Text Input 1 (now the binding source)";
     labelTextInput2.text = "Text Input 2 (now the binding destination)";
    }
        
   }

  ]]>
 </mx:Script>
</mx:Application>

The function signature for the bindProperty has confusing argument names for the uninitiated:


public static function bindProperty(site:Object, prop:String, host:Object, chain:Object, commitOnly:Boolean = false):ChangeWatcher

But it's not that bad:

  1. The first argument "site" is the destination object, the object that we're binding data to.
  2. The second argument "prop" is a string that contains the name of the property we are binding data to.
  3. The third argument "host" is the object that is the source of the binding.
  4. The fourth argument "chain" is a string that contains the name of the property on the binding source object.
  5. The fifth argument is a boolean, which provides finer grain control over when to update the binding destination, and you probably won't need it for most situations.
  6. The bindProperty() method returns a ChangeWatcher object, we can use this object to undo the data binding.

Note that the BindingUtils class also has a bindSetter method, which should be used when the binding destination object has a setter method to set the value of the desired property.

Here's the simple app listed above, you can also right click on it to the Flex source code. When the app is initialized, the value of the 1st text input field is bound to the second text input. If you click the "Reverse Bindings" button, the data binding is undone and then performed in the opposite direction. Simple, but you should get the point. Cheers!