ORIGINAL DRAFT

Software development is about creating useful programs and a large part of that goal involves making sure your users are satisfied with what they see. If a process takes a long time to execute, for example, and provides no feedback to the user, several unintended actions might be taken. For example:

  • If there is no indication that something is executing, the user may think the action they triggered is not actually running, leading to a negative impression of the software.
  • If you’ve provided the ability to cancel the operation, the user may assume that things are not proceeding as planned and eventually, hit the Cancel button before the action is complete.
  • If it isn’t clear that something useful is taking place, the user may start experimenting by clicking around the program or trying various key strokes, sometimes resulting in compounded problems which you’ll eventually have to deal with in other ways.

Providing good user feedback is crucial, but its also important to provide useful information that goes beyond the simplest approach. For example, the twirling icon found in major browsers indicates activity. But this simple visual cue may not be enough. Users also want to know how long it will take for an action to complete. The progress bar may help, but that may still not be enough. As an indicator it’s a big improvement over a twirling icon, but you may need to provide answers to more pressing questions like: How much longer do I have to wait?

The JFC ProgressMonitor goes a long way to improving this kind of feedback, providing visual feedback about relative progress so the user can estimate how long it will take to complete a lengthy task. The mechanism is easy to use but falls short when users start asking why the computer can’t tell them how much time remains. In fact, its typically not because this is difficult, but usually because it wasn’t a consideration at design time. If your needs are simple, ProgressMonitor is well worth a look. If your users demand the best, this solution will probably appeal to you.

At Atrieva, we’ve developed an online backup and retrieval service, founded on the idea that users need immediate access to their files when there is a problem. Traditional backup approaches make retrieval a painful process, but we believe the key question is really how fast you can get to your data and get back to business when there is a problem. Since most file loss is caused by inadvertent overwriting or deletion of single files, this is especially important. With Atrieva, you can be up and running in a few minutes. With other methods, you may have to wait for hours or days (or sometimes the media actually fails and the game is just plain over).

Our Atrieva software solution includes both a native Windows C/C++ client and a browser-based Java implementation. Both are independent (though format-compatible) and our Java solution is evolving toward a more comprehensive, Java Plug-in successor. This is our chance to use the JFC in production and, since one of the most demanded features has always been improved user feedback, our attention was focussed on this very issue. We move a lot of files across the Internet, so it makes sense that one of our priorities has been improving that part of our feedback process.

Figure 1: Progress Dialog.

Figure 1: Progress Dialog.

We developed a ProgressDialog class and a few supporting classes to help make this an integral part of our development effort. Since we plan to reuse this dialog box in a number of applications, we capitalized on the best available features in Java, and JFC to produce a set of classes we could count on to provide user effective feedback while monitoring progress and displaying time-related information. The result is a simple to use dialog box which shows the source and target paths, the number of bytes transferred, throughput and time remaining.

Designing from the Outside-In

Our design is largely modeled on the Netscape download dialog box. This is familiar to a large number of users and satisfies a number of our requirements. With JFC, of course, you can flip the look-and-feel to whatever you like and the dialog box looks quite effective under any of the platforms we’ve tried.

To make this as easy as possible to develop with, and to promote reuse, we decided to use a simple interface called ProgressReporter which looks like this:

public interface ProgressReporter
{
  public void setSize(int size);
  public void setProgress(int current);
	public boolean isCancelled();
}

The interface is sufficiently generic that it could be used for any progress reporting. The setSize method lets us set the total number of bytes to be counted. The setProgress method does the updating and needs to be called by the process being monitored. We stayed away from using an event or observer model because of the overhead, but mostly to keep it simple. Finally, the isCancelled method can be used by a calling process to abort if the user pressed the Cancel button. Note that a better approach might have been to use the JFC’s BoundedRangeModel with a new interface to handle cancellations, but this approach seemed more complicated than required for this solution.

The ProgressReporter interface is used by a pair of streams that make using the dialog box as non-intrusive as possible. At Atrieva, we compress and encrypt all user files. When compressing, we cannot predict the final size, so we must monitor the input stream from the file to show progress information. When decompressing, we monitor the output stream. We can use the same ProgressReporter interface and ProgressDialog implementation to develop a pair of filter streams that can be wrapped around any InputStream or OutputStream, typically a FileInputStream or FileOutputStream or one of the Socket streams.

Figure 2: UML Class Diagram.

Figure 2: UML Class Diagram.

Figure 2 shows the UML (Universal Modeling Language) diagram for the classes in this project. The ProgressDialog class implements the ProgressReporter interface. Both the ProgressInputStream and ProgressOutputStream expect to be passed a ProgressReporter in their constructor. They then support the standard filter input and output stream interfaces. We throw a UserCancelledException when the user interrupts a file transfer by pressing the Cancel button.

If you’re not familiar with UML notation, the + symbol means public, - means private and # means protected. Variables are listed first, with methods and the constructor(s) at the bottom. The variable or method return type is listed after the colon for each entry. There is no return type for the constructor(s), but the color is still shown.

Streams of Progress

The ProgressInputStream extends FilterInputStream and overrides the read and skip methods, returning false for markSupported. The constructor needs a ProgressReporter, the InputStream to monitor and the size of the file to be monitored. Strictly speaking, the size should be a long integer but we don’t believe our customers are likely to need to transfer files larger than 2 gigabytes over the internet for a while. If this were being deployed as a third party re-usable component, however, you would probably want to use a long, even though tThe int data type is typically easier to work with.

import java.io.IOException;
import java.io.InputStream;
import java.io.FilterInputStream;

public class ProgressInputStream extends FilterInputStream
{
	ProgressReporter reporter;
	int position = 0;

	public ProgressInputStream(ProgressReporter reporter,
			InputStream in, int size)
	{
			super(in);
			position = 0;
			this.reporter = reporter;
			reporter.setSize(size);
			reporter.setProgress(position);
	}

	public int read() throws IOException
	{
			if (reporter.isCancelled())
					throw new UserCancelledException();
			int b = in.read();
			position++;
			reporter.setProgress(position);
			return b;
	}

	public int read(byte[] buf, int off, int len)
			throws IOException
	{
			if (reporter.isCancelled())
					throw new UserCancelledException();
			int count = in.read(buf, off, len);
			position += len;
			reporter.setProgress(position);
			return count;
	}

	public long skip(long len) throws IOException
	{
			if (reporter.isCancelled())
					throw new UserCancelledException();
			long count = in.skip(len);
			position += len;
			reporter.setProgress(position);
			return count;
	}

	public boolean markSupported()
	{
			return false;
	}
}

Note that a cancellation produces a UserCancelledException. Unfortunately, the read and skip methods cannot be extended to produce additional Exceptions, so the implementation extends RuntimeException. This is not ideal, but it functions well and it seems better to deal with cancellations in this manner, since the exception can be propagated to a higher level if necessary. We considered using the InterruptedException but felt it was important to distinguish between an intended user action and an unintended problem during processing.

public class UserCancelledException extends RuntimeException
{
		public UserCancelledException()
		{
				super();
		}

		public UserCancelledException(String msg)
		{
				super(msg);
		}
}

The ProgressOutputStream extends FilterOutputStream and overrides the write methods. Similar to the ProgressInputStream, the constructor needs a ProgressReporter, the OutputStream to monitor and the size of the file to be monitored.

import java.io.IOException;
import java.io.OutputStream;
import java.io.FilterOutputStream;

public class ProgressOutputStream extends FilterOutputStream
{
		int position = 0;
		ProgressReporter reporter;

		public ProgressOutputStream(ProgressReporter reporter,
				OutputStream out, int size)
		{
				super(out);
				position = 0;
				this.reporter = reporter;
				reporter.setSize(size);
				reporter.setProgress(position);
		}

		public void write(int b) throws IOException
		{
				if (reporter.isCancelled())
						throw new UserCancelledException();
				out.write(b);
				position++;
				reporter.setProgress(position);
		}

		public void write(byte[] buf, int off, int len)
				throws IOException
		{
				if (reporter.isCancelled())
						throw new UserCancelledException();
				out.write(buf, off, len);
				position += len;
				reporter.setProgress(position);
		}

}

Both the ProgressInputStream and ProgressOutputStream are as transparent as possible to the process being monitored.

Dialog with the User

The meat of our solution is the ProgressDialog class itself. It does all the real work and requires minimal input from developers. The constructor expects a parent JFrame, a frame title, source and target paths to display and the size/length of the process/stream to monitor.

The elements are laid out using nested panels. Figure 3 shows how these panels are organized. This layout is not the only way to achieve the same results, but it was chosen as a way of retaining flexibility. The JLabel class is used to show the prompt and information. These are set vertically using a GridLayout in separate panels. By putting these in separate panels, we can let the BorderLayout manger keep the prompts aligned to a minimum width while allowing the information fields to remain resizable, by virtue of their Center location. The JProgressBar is placed in a panel so that the EmptyBorder can be used to frame the display, and the JButton is handled in a similar way to ensure proper spacing.

ProgressDialogLayout.GIF (6717 bytes)

Figure 3: Dialog Layout

Most of the code in the constructor is there to set up the layout. The leaf elements are declared as protected instance variables so they can be accessed elsewhere in the class. You’ll see that the time and number formatters are instantiated in the constructor as well, so that they can be reused without the extra overhead. Finally, the setSource and setTarget calls are made after showing the dialog box. This is not a perfect solution since it might be desirable to show the dialog box only after doing additional setup work, however the setSource and setTarget calls rely on collapsePath to make sure the path is formatted according to the width of the JLabel fields and this will only work once the dialog box is displayed. Since the purpose of the dialog box was to minimize programmer effort, this seems like an acceptable compromise in a first version effort.

import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import java.awt.BorderLayout;
import java.awt.GridLayout;
import java.awt.FontMetrics;
import java.awt.Dimension;
import java.util.Date;
import java.util.TimeZone;
import java.text.SimpleDateFormat;
import java.text.NumberFormat;

import com.sun.java.swing.JLabel;
import com.sun.java.swing.JPanel;
import com.sun.java.swing.JFrame;
import com.sun.java.swing.JDialog;
import com.sun.java.swing.JButton;
import com.sun.java.swing.JProgressBar;
import com.sun.java.swing.border.EmptyBorder;

public class ProgressDialog extends JDialog
		implements ProgressReporter, ActionListener
{
		public static final int K = 1024;
		
		protected SimpleDateFormat timeFormatter;
		protected NumberFormat numFormatter;

		protected JProgressBar progress;
		protected JButton button;
		protected JLabel source, target,
				status, timing, percent;
		protected boolean cancelled = false;
		protected long mark = System.currentTimeMillis();
		protected int current, size;

		public ProgressDialog(JFrame parent, String title,
				String sourcePath, String targetPath, int size)
		{
				super(parent, title);

				timeFormatter = new SimpleDateFormat( "HH:mm:ss ");
				timeFormatter.setTimeZone(
						TimeZone.getTimeZone( "GMT "));
				numFormatter =
						NumberFormat.getInstance().getPercentInstance();
				
				JPanel panel = new JPanel();
				panel.setLayout(new BorderLayout());
				setSize(375, 191);
				
				JPanel promptPanel = new JPanel();
				promptPanel.setPreferredSize(new Dimension(70, 80));
				promptPanel.setLayout(new GridLayout(4, 1));
				promptPanel.add(new JLabel( "Source: "));
				promptPanel.add(new JLabel( "Target: "));
				promptPanel.add(new JLabel( "Status: "));
				promptPanel.add(new JLabel( "Time Left: "));

				JPanel infoPanel = new JPanel();
				infoPanel.setLayout(new GridLayout(4, 1));
				infoPanel.add(source = new JLabel());
				infoPanel.add(target = new JLabel());
				infoPanel.add(status = new JLabel());
				infoPanel.add(timing = new JLabel());

				JPanel messagePanel = new JPanel();
				messagePanel.setLayout(new BorderLayout());
				messagePanel.setBorder(
						new EmptyBorder(5, 10, 5, 10));
				messagePanel.add( "West ", promptPanel);
				messagePanel.add( "Center ", infoPanel);
				
				panel.add( "North ", messagePanel);

				progress = new JProgressBar();
				progress.setValue(50);
				progress.setPreferredSize(new Dimension(300, 20));
				JPanel progressPanel = new JPanel();
				progressPanel.setLayout(new BorderLayout());
				progressPanel.setBorder(
						new EmptyBorder(3, 10, 3, 10));
				progressPanel.add( "Center ", progress);
				percent = new JLabel( " ");
				percent.setPreferredSize(new Dimension(45, 23));
				progressPanel.add( "East ", percent);
				panel.add( "Center ", progressPanel);
				
				button = new JButton( "Cancel ");
				button.addActionListener(this);
				JPanel buttonPanel = new JPanel();
				buttonPanel.setBorder(new EmptyBorder(5, 0, 7, 0));
				buttonPanel.add(button);
				panel.add( "South ", buttonPanel);
				
				getContentPane().add(panel);

				show();
				setSource(sourcePath);
				setTarget(targetPath);
				setSize(size);
		}

		public void setSource(String name)
		{
				source.setText(collapsePath(source, name));
		}
		
		public void setTarget(String name)
		{
				target.setText(collapsePath(target, name));
		}
		
		public void setSize(int size)
		{
				this.size = size;
				progress.setMaximum(size);
				mark = System.currentTimeMillis();
				current = 0;
		}
		
		public void setProgress(int current)
		{
				this.current = current;
				float per = (float)current / (float)size;
				
				percent.setText( "  " + numFormatter.format(per));
				progress.setValue(current);
				updateStatus();
				timing.setText(
						formatTime(timeLeftInMillis(current)));
				if (cancelled || current >= size)
				{
						// If completed, give the dialog a chance
						// to hit 100% before disposing of it.
						try
						{
								Thread.currentThread().sleep(300);
						}
						catch (InterruptedException e) {}
						setVisible(false);
						dispose();
				}
		}
		
		private String collapsePath(JLabel field, String text)
		{
				FontMetrics metrics =
						getGraphics().getFontMetrics();
				int textWidth = metrics.stringWidth(text);
				int fieldWidth = field.getSize().width;
				if (textWidth > fieldWidth)
				{
						int center = text.length() / 3;
						String left = text.substring(0, center);
						String right = text.substring(center + 1);
						while(metrics.stringWidth(text) > fieldWidth)
						{
								left = left.substring(0, left.length() - 1);
								right = right.substring(1);
								text = left +  "... " + right;
						}
				}
				return text;
		}

		private void updateStatus()
		{
				String stat =  " " + formatSize(current) +
						 " of  " + formatSize(size) +  "
				(at  " + formatSize(bytesPerSecond(current)) +  "/sec) ";
				status.setText(stat);
		}

		private String formatSize(int size)
		{
				if (size >= K) return  " " + (size / K) +  "K ";
				else return  " " + size;
		}

		private int bytesPerSecond(int current)
		{
				long elapsed = System.currentTimeMillis() - mark;
				double speed = current / elapsed;
				return (int)(1000 * speed);
		}

		public int timeLeftInMillis(int current)
		{
				long elapsed = System.currentTimeMillis() - mark;
				double speed = elapsed / (double)current;
				double remaining = size - current;
				int expected = (int)(speed * remaining);
				return expected + 1;
		}
		
		private String formatTime(long millis)
		{
				return timeFormatter.format(new Date(millis));
		}
		
		public boolean isCancelled()
		{
				return cancelled;
		}

		public void actionPerformed(ActionEvent event)
		{
				if (event.getSource() instanceof JButton)
				{
						cancelled = true;
				}
		}
		
}

The setSize method implements the ProgressReporter interface and sets the total range to monitor. It also marks the current time in milliseconds so that time-related progress can be reported. At the same time, the JProgressBar model is updated and the current position is set to zero (0).

The setProgress method implements another ProgressReporter interface and does most of the real work, much of it by calling internal methods like updateStatus, formatSize, bytesPerSecond and timeLeftInMillis which provide calculations and formatting for the progress text. The updateStatus method builds the status field string and calls on formatSize and bytesPerSecond to format the information. The bytesPerSecond method calls on timeLeftInMillis to estimate elapsed and remaining time.

The isCancelled method implements the final ProgressReporter interface and works with actionPerformed to set the flag if the users presses the Cancel button. The ProgressDialog is registered as an ActionListener to catch the button press from JButton.

Conclusions

The JFC provides a solid foundation for user interface design, making the construction of reusable classes virtually effortless. This article showed you how to put together a reusable progress dialog class with easy to use input and output stream filters. The net effect is a clean user interface element that can ensure users have a good experience when bytes are transferred over a stream when the time it takes may be longer than a few seconds. The user feedback is concise and the implementation is simple and flexible. The JFC stands well above the crowd when it comes to simple user interface programming, without sacrificing flexibility. The progress dialog and supporting classes provide a quick example of how you can develop components that enrich the user experience and enhance your program in valuable ways, without requiring much effort.