Mundorévès

Cada día me veo en un mundo al revés

Backward compatibility in Confluence Plugins

Had a chat a couple of weeks ago with Guy Fraser and Alain Moran on achieving Confluence Plugin Nirvana: same binary for most of the current versions of confluence. They have managed to do that with their Theme Builder, so I had to get the Approvals Workflow Plugin up to the challenge.

In the last year or so, Atlassian have thrown a few monkey wrenches to plugin developers that have caused binary incompatibility:

So this is how I have dealt with these problems to come up with a single binary of the Approvals Workflow Plugin starting in version 1.4.

I18NBean change

That one was easy. Use ConfluenceActionSupport.getText() and ConfluenceActionSupport.getTextStatic() instead.

Lucene upgrade in 2.7

This one wasn't that easy. Lucene 2.2.0's Document.add changed to take a Fieldable instead of a Field of earlier version.

This one required to create a wrapper, which uses reflection to obtain the appropriate method:

public class DocumentIndexer {

    private Method addMethod;
    public DocumentIndexer() {
        Class fieldClass;
        try {
            fieldClass = Class.forName("org.apache.lucene.document.Fieldable");
        } catch (ClassNotFoundException e) {
            try {
                fieldClass = Class.forName("org.apache.lucene.document.Field");
            } catch (ClassNotFoundException e1) {
                throw new RuntimeException(e);
            }
        }
        try {
            addMethod = Document.class.getMethod("add", new Class[] {fieldClass});
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }

    public void add(Document document, String key, String value) {
        try {
            Field field = new Field(key, value, Field.Store.YES,Field.Index.TOKENIZED);
            addMethod.invoke(document,new Object[] {field});
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }
}

CacheManager/Cache package changes

Solution here was wrapping Cache and CacheManager and do the magic through reflection. The plugin would use then the wrapped classes instead.

package com.comalatech.confluence.workflow.wrappers.caching;
public interface Cache  {

    public String getName();

    public Object get(Object key);

    public void put(Object key, Object value);

    public void remove(Object key);

    public void removeAll();
package com.comalatech.confluence.workflow.wrappers.caching;
public interface CacheManager {

    public Cache getCache(String name);    
}
package com.comalatech.confluence.workflow.wrappers.caching;

import com.atlassian.spring.container.ContainerManager;

import java.lang.reflect.Method;

public class WrappedCacheManager implements CacheManager {
    private static Method getNameMethod;
    private static Method getMethod;
    private static Method putMethod;
    private static Method removeMethod;
    private static Method removeAllMethod;

    public Cache getCache(String name) {
        Object cacheManager = ContainerManager.getComponent("cacheManager");
        if (cacheManager == null) {
            throw new RuntimeException("cannot get cacheManager");
        }
        try {
            Method m = cacheManager.getClass().getMethod("getCache", new Class [] {String.class});
            Object cache = m.invoke(cacheManager,new Object[] {name});
            if (getNameMethod == null) {
                initMethods(cache.getClass());
            }
            return new WrappedCache(cache);
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }

    private void initMethods(Class cacheClass) {
        try {
            getNameMethod = cacheClass.getMethod("getName",new Class[] {});
            getMethod = cacheClass.getMethod("get",new Class[] {Object.class});
            putMethod = cacheClass.getMethod("put",new Class[] {Object.class, Object.class});
            removeMethod = cacheClass.getMethod("remove",new Class[] {Object.class});
            removeAllMethod = cacheClass.getMethod("removeAll",new Class[] {} );
        } catch (NoSuchMethodException e) {
            throw new RuntimeException(e);
        }
    }

    private class WrappedCache implements Cache {

        private Object cache;

        private WrappedCache(Object cache) {
            this.cache = cache;
        }

        public String getName() {
            try {
                return (String)getNameMethod.invoke(cache,new Object[] {});
            } catch (Exception e) {
                throw new RuntimeException(e);
            }
        }

        public Object get(Object key) {
            try {
                return getMethod.invoke(cache,new Object[] {key});
            } catch (Exception e) {
                throw new RuntimeException(e);
            }
        }

        public void put(Object key, Object value) {
            try {
                putMethod.invoke(cache,new Object[] {key, value});
            } catch (Exception e) {
                throw new RuntimeException(e);
            }
        }

        public void remove(Object key) {
            try {
                removeMethod.invoke(cache,new Object[] {key});
            } catch (Exception e) {
                throw new RuntimeException(e);
            }
        }

        public void removeAll() {
            try {
                removeAllMethod.invoke(cache,new Object[] {});
            } catch (Exception e) {
                throw new RuntimeException(e);
            }
        }
    }
}

web-item location changes in 2.8

Web item locations changed in 2.8 and the old locations are just ignored.

Solution is simple: defining two web items, one for each location in atlassian-plugin.xml. For example:

    <web-item key="snipedititem" name="Snip-edit Page Action" 
         section="system.page.actions" weight="3">
        <label key="snipeditmark"/>
        <link></link>
    </web-item>

    <web-item key="snipedititemfor28" name="Snip-edit Page Action for 2.8 and newer"
         section="system.content.action/marker" weight="3">
        <label key="snipeditmark"/>
        <link></link>
    </web-item>

ViewPageAction changes in 2.8

Because of the ways page actions (i.e. edit, view, info, etc) are handled in 2.8, ViewPageAction was changed to set visibility of at least the Edit Page action.

I am extending ViewPageAction so this got me in trouble. The solution had to be very creative

First I have MyViewPageAction class that does the stuff I want:

public class MyViewPageAction extends ViewPageAction implements PageAware {
    ...
}

The I created MyViewPageAction28 that would extend the new interface CommentAware and add the required hack change to make the Edit Page action:

public class MyViewPageAction28 extends MyViewPageAction implements PageAware, CommentAware {
    public WebInterfaceContext getWebInterfaceContext() {
        DefaultWebInterfaceContext result = new DefaultWebInterfaceContext(super.getWebInterfaceContext());
        if (getClass().equals(MyViewPageAction28.class)) {
            result.setParameter(ViewingContentCondition.CONTEXT_KEY, Boolean.TRUE);
        }
        return result;
    }
}

Note that getWebInterfaceContext is only replicated from ViewPageAction but using MyViewPageAction28.class instead.

In addition to the xwork package required to override confluence's ViewPage action, I created yet-another action override in atlassian-confluence.xml:

<xwork name="Approvals Page Actions, extension for 2.8" key="myactions28" state="disabled">
    <package name="pages" extends="pages" namespace="/pages">
        <default-interceptor-ref name="validatingStack"/>
        <action name="viewpage" class="com.comalatech.confluence.workflow.actions.MyViewPageAction28">
        ....
        </action>
    </package>
</xwork>

Note that the state is disabled, therefore the module is going to be disabled even for 2.8.

Now here is the magic. Later on atlassian-plugin.xml I have a component call WorkflowsInitializer, which is StateAware and does the magic to enable the 2.8 xwork module:

public class WorkflowsInitializer implements StateAware {
    public void enabled() {
        boolean is2point8 = Integer.parseInt(GeneralUtil.getBuildNumber()) >= 1314;
        if (is2point8) {
            pluginController.enablePluginModule("com.comalatech.workflow:myactions28");
        } else {
            pluginController.disablePluginModule("com.comalatech.workflow:myactions28");
        }
    }    
}

Labels