Friday, August 28, 2015

Getting rid of the Java beep

This has been quite an adventure. I didn't think that something as simple-sounding as adding an option to jEdit to turn off the beeping noise that some of the components make would be a big deal. I thought it would be something along the lines of adding a utility method that checked a property to see if the beep should happen or not, then change all the beep calls to use this new utility method. Not even close to the right solution. The problem with this approach is that many of the JVM provided Swing components also beep and, of course, those components would have no idea about my utility method.
After some research on the internet and perusing the Swing source code, the right way to cause a component to beep is to use the look and feel to provide error feedback to the user. In fact, that is the exact method in the look and feel to use: provideErrorFeedback. By default, this method simply calls Toolkit.beep(). Here is the complete method as implemented in Java 1.8:

    /**
     * Invoked when the user attempts an invalid operation,
     * such as pasting into an uneditable <code>JTextField</code>
     * that has focus. The default implementation beeps. Subclasses
     * that wish different behavior should override this and provide
     * the additional feedback.
     *
     * @param component the <code>Component</code> the error occurred in,
     *                  may be <code>null</code>
     *                  indicating the error condition is not directly
     *                  associated with a <code>Component</code>
     * @since 1.4
     */
    public void provideErrorFeedback(Component component) {
        Toolkit toolkit = null;
        if (component != null) {
            toolkit = component.getToolkit();
        } else {
            toolkit = Toolkit.getDefaultToolkit();
        }
        toolkit.beep();
    } // provideErrorFeedback()

I grepped the Swing source code, and no component calls Toolkit.beep() directly. The only call to Toolkit.beep() is in the provideErrorFeedback method. So this leads me to believe that the proper way to implement a beep is via the look and feel rather than calling Toolkit.beep() directly. My next thought was to use one of the byte code manipulation libraries like BCEL or Javaassist in the LookAndFeel plugin to change the method to look like this:
 public void provideErrorFeedback(Component component) {
     if (!"true".equals(System.getProperty("allowBeep")))
         return;
     Toolkit toolkit = null;
     if (component != null) {
         toolkit = component.getToolkit();
     } else {
         toolkit = Toolkit.getDefaultToolkit();
     }
     toolkit.beep();
 } // provideErrorFeedback()

That seemed pretty straightforward, so I wrote a class loader for the LookAndFeel plugin that would insert the line or the whole method. This worked well, until I realized that this will only work for a look and feel loaded through the LookAndFeel plugin, not any of the Swing look and feels. I know many people are satisfied with Metal or Nimbus, and this approach wouldn't work for those. I did go ahead and give it a go anyway, adjusting the LookAndFeel plugin to let the user select the built-in look and feels and attempt to patch them on the fly, but the JVM security manager wouldn't allow it.
A note about the byte code manipulation libraries, I ended up using Javassist as it is easier to work with. BCEL is fine, but works at a lower level and has a fairly steep learning curve.
My last approach, and the most successful, is to use the Java instrumentation API and use that to patch javax.swing.LookAndFeel directly. This works well since every look and feel that I have the source code to does not override the provideErrorFeedback method in that class, so it's the single place where all look and feels, and thus all components, go to make a beep. The downside of this is the code must kick in at JVM start up time, which means it can't be in a regular jEdit plugin, since those don't get loaded until well after the JVM classes.
Using the instrumentation API is really simple. All that is needed is a Java agent. The one I wrote looks like this:

package javassist;

import java.io.*;
import java.lang.instrument.*;
import java.security.ProtectionDomain;

/**
 * Simple java agent to fix the <code>provideErrorFeedback</code> in javax.swing.LookAndFeel.
 * This adds a line to that method to check the System property "allowBeep" before it 
 * proceeds with beeping. If "allowBeep" is anything other than "true", the method returns
 * immediately without providing any error feedback.
 */
public final class LNFAgent implements ClassFileTransformer {

    private static final String CLASS_TO_PATCH = "javax/swing/LookAndFeel";

    public static void premain( String agentArgument, final Instrumentation instrumentation ) {
        LNFAgent agent = null;

        try {
            agent = new LNFAgent();
        } catch ( Exception e ) {
            System.out.println("LNFAgent not installed.");
            return;
        }
        instrumentation.addTransformer( agent );
        System.setProperty("LNFAgentInstalled", "true");
    }

    @Override
    public byte[] transform( final ClassLoader loader, String className, final Class classBeingRedefined, final ProtectionDomain protectionDomain, final byte[] classfileBuffer ) throws IllegalClassFormatException {
        byte[] result = null;

        if ( className.equals( CLASS_TO_PATCH ) ) {
            try {
                CtClass ctClass = ClassPool.getDefault().makeClass( new DataInputStream( new ByteArrayInputStream( classfileBuffer ) ) );
                CtMethod ctMethod = ctClass.getDeclaredMethod( "provideErrorFeedback" );
                ctMethod.insertBefore( "if (!\"true\".equals(System.getProperty(\"allowBeep\"))) return;" );
                result = ctClass.toBytecode();
            } catch ( Exception e ) {
                System.err.println("Unable to patch javax.swing.LookAndFeel to disable beeps.");
            }
        }
        return result;
    }
}
Here's a quick run through of this code:
  • First, I put this code in the javassist package. My thought is to go ahead and make a plugin out of Javassist anyway, and insert this agent into the Javassist jar file.
  • The premain method is the method required by the instrumentation API. This method gets called on JVM start up and initializes the agent. For this purpose. I'm setting a System property to indicate that the agent has been installed so that my LookAndFeel plugin can know.
  • The transform method is where the actual code gets inserted into the provideErrorFeedback method. Again, this method is specified by the instrumentation API. Notice that the Javassist code is only 3 lines. The corresponding BCEL code was about 30.
To get the JVM to load this code on start up requires two more adjustments.
First, the manifest in the jar needs a couple of settings. These 3 lines need to be added:

Premain-Class: javassist.LNFAgent
Agent-Class: javassist.LNFAgent
Boot-Class-Path: JavassistPlugin.jar
Technically, all that is required by the instrumentation API is the Premain-Class and Agent-Class settings. The Boot-Class-Path is required to get the rest of Javassist loaded.
Once this adjusted manifest is added to the jar file, all coding is complete.
The last step is to adjust the command line to start jEdit to invoke the agent on start up. I have a small script I use to start jEdit that sets a couple of environment variables. It looks like this:

#!/bin/sh
export JEDIT_HOME=/home/danson/apps/jedit/current

# Antialias fonts everywhere possible.
ANTIALIAS_ALL="-Dawt.useSystemAAFontSettings=on -Dswing.aatext=true"
export VISUAL=/home/danson/bin/jeditcommit
export EDITOR=$VISUAL

# this one for general usage
#java -Xmx620m -Xms512m ${ANTIALIAS_ALL} "-Djedit.home=$JEDIT_HOME" -jar "$JEDIT_HOME/jedit.jar" -reuseview "$@"


# this one for debugging
#java ${ANTIALIAS_ALL} -Xdebug -Xrunjdwp:transport=dt_socket,server=y,address=8001,suspend=n -Xmx512m -jar /home/danson/apps/jedit/current/jedit.jar -reuseview > /dev/null 2>&1 &

# this one for no beeps
java -javaagent:/home/danson/.jedit/jars/JavassistPlugin.jar -Xmx620m -Xms512m ${ANTIALIAS_ALL} "-Djedit.home=$JEDIT_HOME" -jar "$JEDIT_HOME/jedit.jar" -reuseview "$@"

The key piece here is that -javaagent part. This tells the JVM to look in JavassistPlugin.jar for a java agent. The manifest tells the JVM the rest of the details. Once the agent is loaded, every single class that the JVM loads is passed to the transform method in the agent, which looks only for the javax.swing.LookAndFeel class, and inserts the tiny bit of code needed to toggle the beep.
That's really all there is to it. My main problems with this approach are
  • The command line to start jEdit needs to be set by hand. There isn't a way to get the agent code to run without including the -javaagent parameter.
  • Packaging this as a jEdit plugin is not right, since jEdit loads plugins late in the start up process and loads them each in their own classloader. Since this agent code is loaded way before jEdit, it can't take advantage of any of the usual plugin facilities.
  • When used as an agent, the plugin cannot be reloaded since it is loaded in the system classloader.
  • And the biggest problem is that I shouldn't need to patch a JVM class. Really, Sun/Oracle should have included what is basically a one-liner to allow apps to turn off the error feedback. It's a simple fix that quite a few people would take advantage of, myself included.
One more note: the right way to make a beep is to make a call like this:
javax.swing.UIManager.getLookAndFeel().provideErrorFeedback(null);
Instead of null, the appropriate component can be passed. Using Toolkit.beep() directly short-circuits the ability of the look and feel to provide it's own implementation of appropriate error feedback, so don't do it! Now I need to go through the jEdit code itself and change out all 156 of those calls to Toolkit.beep()



















No comments: