Thursday, July 29, 2004

Automatic email support for Java

I got tired of asking for more information in bug reports in the Costello editor, so I figured I'd put in a link to automatically post to the mailing list with the user's environment info. It would also help direct all traffic of that sort to the proper place, rather than having some people post to forums, some to the mailing list, or others filing bug reports or RFEs (even when they're just asking a question).

You'd think that eons after the introduction of email and bug reports and that sort of thing it'd take no more than a line of code, but alas, not so for java. And I certainly don't want to introduce yet another library dependency. So I says to meself, how hard can it be? I mean, OSX already gives you a nice "open" command to launch something appropriate for whatever argument you give it (so much faster than digging through folders, too). Windows has the "start" command, and it's easy enough to pass an arbitrary script to /bin/sh for any *nix variants. So I set off to get my Runtime.exec on...

Check out the landscape

First off, someone on the Apple java-dev list suggested throwing a mailto: URL at the "open" command.

% open mailto:abbot-users@lists.sf.net

Cool, that works, now what about subject and body? Quick google for proper mailto: syntax, reveals
% open 'mailto:abbot-users@lists.sf.net?subject=bug report&body=java version'

open[]: No such file mailto:abbot-users@lists.sf.net?subject=bug report
Bummer, doesn't quite work, must be those spaces. Duh, can you say URL encoding. Try again...
(For you *nix newbies, an ampersand is normally interpreted by the shell as direction to start the preceding command as a background process, thus the single quotes are required. When we stuff this into Runtime.exec, we can drop the quotes because Runtime.exec doesn't use the shell)
% open 'mailto:abbot-users@lists.sf.net?subject=bug%20report&body=java%20version'

Aha, note to self: make sure you URL-encode the subject and body, but make sure the encoder uses %20 and not '+', b/c the pluses won't get converted back into spaces.

Over to w32 now, and see if "start" works the same way. I normally use bash on windows, but this test wants to be in the Windows command shell.
% cmd.exe

c:\> start mailto:abbot-users@lists.sf.net?subject=bug%20report&body=java%20version
'body' is not recognized as an internal or external command,
operable program or batch file.
Somehow I didn't feel quite so insulted when OSX said it. A quick look at the cmd.exe "man page" (actually not so quick. the MSDN page was way down the google search, and fourth even on the MSDN search). Seems some characters are special. We won't tell you all of them, but here are some of them. Oh great. In this case, seems like the ampersand. Well, let's just try it with quotes (supposedly you can escape individual characters with '^', but then things start getting nasty).
c:\> start "mailto:abbot-users@lists.sf.net?subject=bug%20report&body=java%20version"

What the @#$%! I get a new command prompt with the title set. Let's try
c:> start /help

@#$%!
c:> start /?

Ah, there it is. If you pass the first argument in quotes, it becomes the title of whatever you "start". Try again
c:> start "title" "mailto:abbot-users@lists.sf.net?subject=bug%20report&body=java%20version"

Ah, finally. Up comes some flavor of Outlook. Good enough. What about *nix? Probably as easy as just picking a few popular mail programs and seeing if they exist. I'm sure *nix programs can handle a silly little mailto URL. Actually, most of the lame bug reports I get are from w32 users. The *nix and OSX users usually have much more esoteric problems. So off to the java code to provide some minimal automatic bug reporting.

Write up a TestCase

I'd previously written a simple exec handler that would capture all the program output and error streams, so I'll use that here. I'm not concerned with the output (I don't expect any) and only want to throw up an error if the command somehow fails. So write up a little JUnit test case for it, let's call it the LauncherTest. I've rigged the test to only run if I invoke it manually; since it requires user interaction to get rid of the launched processes, I'd rather it not run with the rest of the automated suite. Writing the test case makes me think about exactly how I'd like to use this thing. Namely, I just want to send a post to the support mailing list with a prepared body. And to make sure I don't get any lame "Help!" subject lines, might as well fill that in as well. I just want to invoke a single line of code to preformat an email. Fill in the target address, subject, and sample body. No need for a complex API.

package abbot.util;


import junit.framework.*;

public class LauncherTest extends TestCase {

// only run these tests manually, since we can't control the launched apps automatically
private static boolean run = false;

public void testMailTo() throws Exception {
if (!run)
return;
String SUBJECT = "subject";
String BODY = "body";
Launcher.mail("abbot-users@lists.sf.net", SUBJECT, BODY);
}

public static void main(String[] args) {
run = true;
junit.textui.TestRunner.main(new String[] { "-c", LauncherTest.class.getName() });
}
}

Make the TestCase work

Now, whipping out my trusty Runtime.exec wrapper, I simply wrap up the tricks I was playing in the console.

package abbot.util;


import java.net.URLEncoder;
import java.util.ArrayList;
import java.util.Arrays;
import java.io.IOException;
import abbot.Log;
import abbot.Platform;

/** Mail and browser launcher which augments Runtime.exec()
* functions. Provides for built-in email and web browser support.
*/

public class Launcher {

/** Sort of URL-encode the given string. */
public static String encode(String s) {
// simple implementation for now
return s;
}

/** Format a message to the given user with the given subject and message
body, including CC and BCC lists. */

public static void mail(String user, String subject, String body) throws IOException {
StringBuffer mailto = new StringBuffer("mailto:" + user + "?");

mailto.append("Subject=" + encode(subject)
+ "&" + "Body=" + encode(body) + "");
open(mailto.toString());
}

/** Use the given command/program to open the given target. */
public static void open(String command, String target) throws IOException {
ArrayList args = new ArrayList();
if (Platform.isOSX()) {
args.add("open");
}
else if (Platform.isWindows()) {
// Probably won't work on win9x, but just in case
args.add(Platform.isWindows9X()
? "command.com" : "cmd.exe");
args.add("/c");
args.add("start");
args.add("\"Title\"");
// Always quote the argument, just in case
// See MS docs for cmd.exe; &, |, and () must be escaped with
// ^ or double-quoted. semicolon and comma are command
// argument separators, and probably require quoting as well.

target = "\"" + target + "\"";
}
else {
throw new Error("Not done yet!");
}
args.add(target);

String[] cmd = (String[])args.toArray(new String[args.size()]);
String output = ProcessOutputHandler.exec(cmd);
}
}

The windows version actually required a bit of trial and error to get the argument accepted properly. I know I'm going to have to encode the subject and body, but I don't know all the details yet. So true to TDD form, I start with a very basic encoding implementation. Running the unit test on OSX brings up the Mail app, and running it on w32 brings up Outlook.

Add just the features I need

So far, so good. Now I want a little more complex subject and body. First, adjust the test to use more representative values:

    String SUBJECT = "with spaces";

String BODY = "with spaces and <[special % characters]>";
I already know I need some flavor of URL encoding, so I'll take a slight shortcut and throw that in immediately:
    public static String encode(String s) {

return URLEncoder.encode(s);
}

Running the test we find that OSX's Mail will decode %20 but not '+', and URLEncoder.encode(String) turns spaces into '+'. So we need to pre-escape spaces, then replace them with the proper %20 encoding. What about w32? Outlook won't handle either encoding, but it will accept the spaces directly. So we immediately add a test for the encoding:

    public void testEncoding() {

String[][] strings = {
{ "with space", Platform.isOSX() ? "with%20space" : "with space" },
//TODO check encoding of other characters
};
for(int i=0;i < strings.length;i++) {
assertEquals("Wrong string encoding for '" + strings[i][0] + "'", strings[i][1], Launcher.encode(strings[i][0]));
}
}
Now that the testEncoding() test properly fails, on we go to code up a proper solution.
    public static String encode(String s) {

StringBuffer buf = new StringBuffer(s);
// Avoid URLEncoder.encode for spaces; it replaces them with plus
// signs, which remain pluses when decoded.

String SPACE = "--SPACE--";
for (int idx = buf.toString().indexOf(" ");
idx != -1;idx = buf.toString().indexOf(" ")) {
Buf.replace(idx, idx+1, SPACE);
}
buf = new StringBuffer(URLEncoder.encode(buf.toString()));
for (int idx = buf.toString().indexOf(SPACE);
idx != -1;idx = buf.toString().indexOf(SPACE)) {
if (Platform.isOSX()) {
// The "open" command parses spaces
buf.replace(idx, idx + SPACE.length(), "%20");
}
else {
buf.replace(idx, idx + SPACE.length(), " ");
}
}
return buf.toString();
}

You can stop coding now

Hoo-rah. Success on both OSX and Windows. The subject and body show up with exactly the strings I passed in. Now I just do the Launcher.mail invocation in response to a menu selection, and no more asking for more basic information!

I'll publish the finished Launcher code in the next installment.

Next: Do it on *nix, please.

Tuesday, July 13, 2004

I want my username

Back in the day you could choose whatever username you wanted, and it had a lot more meaning than a display name that could be changed at a whim. Nowadays, your username is something like outtawhack2356 and good luck getting anything much recognizable.

Maybe it's no big deal, just set your nickname to whatever you want and ignore the username. If more people recognize your nickname you might be less likely to change it.

Still, there's not the kind of tight binding from username to user like I remember. Nobody says "yeah, that outtawhack2356, he's a real funny guy".