changeset 33:3d86f0391168

Work on improving the build system.
author David Barts <davidb@stashtea.com>
date Fri, 24 Apr 2020 19:45:57 -0700
parents c06edc56669b
children d175593317a8
files Building.html Makefile.doc build.xml lib/ant-contrib/ant-contrib-1.0b3.jar lib/ant-contrib/lib/bcel-5.1.jar lib/ant-contrib/lib/commons-httpclient-3.0.1.jar lib/ant-contrib/lib/commons-logging-1.0.4.jar lib/ant-contrib/lib/ivy-1.3.1.jar lib/jarbundler-core-3.3.0.jar lib/mod/build_mod.xml lib/mod/mod.jar lib/mod/src/de/masters_of_disaster/ant/tasks/ar/Ar.java lib/mod/src/de/masters_of_disaster/ant/tasks/ar/ArConstants.java lib/mod/src/de/masters_of_disaster/ant/tasks/ar/ArEntry.java lib/mod/src/de/masters_of_disaster/ant/tasks/ar/ArOutputStream.java lib/mod/src/de/masters_of_disaster/ant/tasks/ar/ArUtils.java lib/mod/src/de/masters_of_disaster/ant/tasks/calculatesize/CalculateSize.java lib/mod/src/de/masters_of_disaster/ant/tasks/deb/Deb.java src/name/blackcap/exifwasher/Test.kt src/name/blackcap/exifwasher/Test2.kt
diffstat 20 files changed, 1893 insertions(+), 102 deletions(-) [+]
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/Building.html	Fri Apr 24 19:45:57 2020 -0700
@@ -0,0 +1,63 @@
+<!DOCTYPE html>
+<!-- Skeleton or template web page, in the standard style. -->
+<html>
+  <head>
+    <meta http-equiv="content-type" content="text/html; charset=UTF-8">
+    <title>Building JpegWasher</title>
+    <style>
+html { font-family: "TeX Gyre Schola", serif; }
+h1, h2, h3, h4, h5, h6 { font-family: "Avenir Next", sans-serif; }
+pre, code, kbd, samp { font-family: "Menlo", monospace; ; font-size: 85%; }
+    </style>
+  </head>
+  <body>
+    <h1>Building JpegWasher</h1>
+    <h2>Prerequisites</h2>
+    <ul>
+      <li><a href="https://ant.apache.org/">Apache Ant</a>, with the following
+        extensions (note that the extensions are already present in the lib
+        subdirectory, but you will need to install Ant).</li>
+      <ul>
+        <li><a href="http://ant-contrib.sourceforge.net/">Ant-Contrib</a></li>
+        <li><a href="https://github.com/UltraMixer/JarBundler">JarBundler</a>
+          (if building on a Mac)</li>
+        <li><a href="http://launch4j.sourceforge.net/">launch4j</a> (if building
+          on Windows)</li>
+      </ul>
+      <li>Java JDK 1.8 or better (see notes).</li>
+      <li>Kotlin</li>
+      <li>Exiv2</li>
+      <li>A C++ Compiler</li>
+      <li>Make (Nmake on Windows)</li>
+    </ul>
+    <h2>JpegWasher Is Not Pure Java</h2>
+    <p>This means two things: </p>
+    <ol>
+      <li>You need a C++ compiler in addition to a Kotlin compiler (and a Java
+        one) to build&nbsp; JpegWasher.</li>
+      <li>The result of a build will run only on the architecture you built it
+        on.</li>
+    </ol>
+    <p>It <em>is</em> possible to create a semi-portable JAR that runs on both
+      Linux and Windows (it contains both <code>*.so</code> libraries and <code>*.dll</code>
+      ones, and loads the correct ones at run time, see <code>linwin.jar</code>),
+      but the process for doing so is not totally automated. It is alas not
+      possible to support the Macintosh in the same JAR as well, because a Mac
+      app in Java 1.8 requires making a few nonportable, Apple-only API calls,
+      whose presence will cause <code>ClassNotFoundException</code> to be
+      thrown at runtime on non-Macintosh systems.</p>
+    <p>As to why, the answer is simple: Try as I could, I could not find any
+      pure Java libraries that could read <em>and write</em> image metadata.
+      Therefore I had to use a C++ library.</p>
+    <h2>Which Version of Java to Use?</h2>
+    <p>In short, Java 1.8. Most systems don't yet have OpenJDK 11 or greater
+      installed, so using a compiler newer than 1.8 is asking for trouble. All
+      code <em>should</em> build on OpenJDK 11 or greater, with the exception
+      of the OS-dependent code for the Macintosh (which will have to be recoded
+      to use the <code>java.awt.Desktop</code> class). The latter would be a
+      net win, as it is portable, and would spell the death of the only bit of
+      OS-dependent Kotlin code in this application.</p>
+    <p>In another year or two, I will probably make OpenJDK 11 or greater the
+      preferred version.</p>
+  </body>
+</html>
--- a/Makefile.doc	Fri Apr 24 14:01:03 2020 -0700
+++ b/Makefile.doc	Fri Apr 24 19:45:57 2020 -0700
@@ -1,6 +1,6 @@
 .PHONY: clean
 
-all: Readme.rst Readme.pdf
+all: Readme.rst Readme.pdf Building.rst Building.pdf
 
 clean:
 	-rm *.rst *.pdf *.ps *.nrf
--- a/build.xml	Fri Apr 24 14:01:03 2020 -0700
+++ b/build.xml	Fri Apr 24 19:45:57 2020 -0700
@@ -1,5 +1,9 @@
 <?xml version="1.0" encoding="UTF-8"?>
-<project name="JpegWasher" default="help" basedir="." xmlns:fx="javafx:com.sun.javafx.tools.ant">
+<project name="JpegWasher" default="help" basedir="."
+  xmlns:contrib="antlib:net.sf.antcontrib"
+  xmlns:jarbundler="antlib:com.ultramixer.jarbundler"
+  xmlns:launch4j="antlib:net.sf.launch4j.ant"
+  xmlns:mod="antlib:de.masters_of_disaster.ant.tasks">
   <!-- import all environment variables as env.* -->
   <property environment="env"/>
 
@@ -17,15 +21,8 @@
   <env-require name="JRE_HOME"/>
   <env-require name="KOTLIN_HOME"/>
 
-  <!-- define the kotlin task -->
-  <property name="kotlin.lib" value="${env.KOTLIN_HOME}/lib"/>
-  <typedef resource="org/jetbrains/kotlin/ant/antlib.xml"
-           classpath="${kotlin.lib}/kotlin-ant.jar"/>
-
-  <!-- define the package-building tasks -->
-  <taskdef resource="com/sun/javafx/tools/ant/antlib.xml"
-           uri="javafx:com.sun.javafx.tools.ant"
-           classpath="${env.JRE_HOME}/lib/ant-javafx.jar"/>
+  <!-- load launch4j (Windows app builder) -->
+  <!-- load m-o-d (Linux Gnome app builder) -->
 
   <!-- cribbed from https://stackoverflow.com/questions/7129672/uppercase-lowercase-capitalize-an-ant-property -->
   <scriptdef language="javascript" name="toLowerCase">
@@ -49,6 +46,66 @@
   <property name="nat.dir"       value="${src.home}/name/blackcap/exifwasher/exiv2"/>
   <property name="bin.dir"       value="${src.home}/name/blackcap/exifwasher/binaries"/>
 
+  <!-- load the ant-contrib tasks -->
+  <taskdef resource="net/sf/antcontrib/antlib.xml"
+           uri="antlib:net.sf.antcontrib">
+    <classpath>
+      <fileset dir="${lib.home}/ant-contrib" includes="*.jar"/>
+    </classpath>
+  </taskdef>
+
+  <!-- load jarbundler (Mac app bundler) tasks -->
+  <contrib:if>
+    <os family="mac"/>
+    <contrib:then>
+      <taskdef name="create"
+               classname="com.ultramixer.jarbundler.JarBundler"
+               classpath="${lib.home}/jarbundler-core-3.3.0.jar"
+               uri="antlib:com.ultramixer.jarbundler">
+      </taskdef>
+    </contrib:then>
+  </contrib:if>
+
+  <!-- load launch4j (Windows app bundler) tasks -->
+  <contrib:if>
+    <os family="windows"/>
+    <contrib:then>
+      <taskdef name="create"
+               classname="net.sf.launch4j.ant.Launch4jTask"
+               uri="antlib:net.sf.launch4j.ant">
+        <classpath>
+          <fileset dir="${lib.dir}/launch4j" includes="*.jar"/>
+        </classpath>
+      </taskdef>
+    </contrib:then>
+  </contrib:if>
+
+  <!-- load mod (Gnome app bundler) tasks -->
+  <contrib:if>
+    <os family="unix"/>
+    <contrib:then>
+      <taskdef name="calculatesize"
+               classname="de.masters_of_disaster.ant.tasks.calculatesize.CalculateSize"
+               uri="antlib:de.masters_of_disaster.ant.tasks">
+        <classpath>
+          <fileset dir="${lib.dir}/mod" includes="*.jar"/>
+        </classpath>
+      </taskdef>
+      <taskdef name="deb"
+               classname="de.masters_of_disaster.ant.tasks.deb.Deb"
+               uri="antlib:de.masters_of_disaster.ant.tasks"/>
+        <classpath>
+          <fileset dir="${lib.dir}/mod" includes="*.jar"/>
+        </classpath>
+      </taskdef>
+    </contrib:then>
+  </contrib:if>
+
+  <!-- define the kotlin task -->
+  <property name="kotlin.lib" value="${env.KOTLIN_HOME}/lib"/>
+  <typedef resource="org/jetbrains/kotlin/ant/antlib.xml"
+           classpath="${kotlin.lib}/kotlin-ant.jar"/>
+
   <!-- help message -->
   <target name="help">
     <echo>You can use the following targets:</echo>
@@ -63,26 +120,40 @@
     <echo>prompt> ant all </echo>
   </target>
 
+  <!-- Determine OS type -->
+  <condition property="os.type" value="mac">
+    <os family="mac"/>
+  </condition>
+  <condition property="os.type" value="windows">
+    <os family="windows"/>
+  </condition>
+  <condition property="os.type" value="linux">
+    <!-- notes: 1) XXX not all non-mac UNIX systems are Linux, but this assumes
+         so; 2) this clause MUST appear AFTER the "mac" one -->
+    <os family="unix"/>
+  </condition>
+  <property name="os.type" value="unknown"/>
+
   <!-- Define the CLASSPATH -->
   <target name="classpath">
-    <path id="std.classpath">
-      <fileset dir="${lib.home}">
-        <include name="*.jar"/>
-      </fileset>
-    </path>
     <path id="compile.classpath">
-      <path refid="std.classpath"/>
       <pathelement location="${src.home}"/>
     </path>
-    <path id="test.classpath">
-      <path refid="std.classpath"/>
-      <pathelement location="${work.home}"/>
-    </path>
   </target>
 
   <!-- make needed directories -->
   <target name="mkdirs">
     <mkdir dir="${lib.home}"/>
+    <mkdir dir="${src.home}/name/blackcap/exifwasher/binaries/${os.type}"/>
+  </target>
+
+  <!-- rename files containing OS-dependant code -->
+  <target name="osdep">
+    <exec executable="${env.JRE_HOME}/bin/java">
+      <arg value="-jar"/>
+      <arg value="../Osdep/osdep.jar"/>
+      <arg value="${src.home}"/>
+    </exec>
   </target>
 
   <!-- do everything but install -->
@@ -90,7 +161,7 @@
           description="Clean work dirs, compile, make JAR."/>
 
   <!-- compile *.kt to *.class -->
-  <target name="compile" depends="mkdirs,classpath"
+  <target name="compile" depends="mkdirs,osdep,classpath"
           description="Compile Java sources to ${work.home}">
     <kotlinc src="${src.home}" output="${work.jar}"
              classpathref="compile.classpath">
Binary file lib/ant-contrib/ant-contrib-1.0b3.jar has changed
Binary file lib/ant-contrib/lib/bcel-5.1.jar has changed
Binary file lib/ant-contrib/lib/commons-httpclient-3.0.1.jar has changed
Binary file lib/ant-contrib/lib/commons-logging-1.0.4.jar has changed
Binary file lib/ant-contrib/lib/ivy-1.3.1.jar has changed
Binary file lib/jarbundler-core-3.3.0.jar has changed
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/lib/mod/build_mod.xml	Fri Apr 24 19:45:57 2020 -0700
@@ -0,0 +1,82 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project name="mod" default="help" basedir=".">
+  <!-- import all environment variables as env.* -->
+  <property environment="env"/>
+
+  <!-- ensure required environment variables are set -->
+  <macrodef name="env-require">
+    <attribute name="name"/>
+    <sequential>
+      <fail message="Environment variable @{name} not set!">
+        <condition>
+          <not><isset property="env.@{name}"/></not>
+        </condition>
+      </fail>
+    </sequential>
+  </macrodef>
+  <env-require name="JRE_HOME"/>
+
+  <!-- load launch4j (Windows app builder) -->
+  <!-- load m-o-d (Linux Gnome app builder) -->
+
+  <!-- cribbed from https://stackoverflow.com/questions/7129672/uppercase-lowercase-capitalize-an-ant-property -->
+  <scriptdef language="javascript" name="toLowerCase">
+    <attribute name="value" />
+    <attribute name="target" />
+    <![CDATA[
+      project.setProperty( attributes.get( "target" ),
+                           attributes.get( "value" ).toLowerCase() );
+    ]]>
+  </scriptdef>
+
+  <!-- Define the properties used by the build -->
+  <property name="app.name"      value="${ant.project.name}"/>
+  <toLowerCase target="lc.app.name" value="${app.name}"/>
+  <property name="jar.name"      value="${basedir}/${lc.app.name}.jar"/>
+  <property name="src.home"      value="${basedir}/src"/>
+  <property name="work.home"     value="${basedir}/work"/>
+
+  <!-- help message -->
+  <target name="help">
+    <echo>You can use the following targets:</echo>
+    <echo> </echo>
+    <echo>  help    : (default) Prints this message </echo>
+    <echo>  clean   : Deletes work directories</echo>
+    <echo>  compile : Compiles source into class files</echo>
+    <echo>  jar     : Make JAR file.</echo>
+    <echo> </echo>
+    <echo>For example, to clean, compile, and package all at once, run:</echo>
+    <echo>prompt> ant -f build_mod.xml all </echo>
+  </target>
+
+  <!-- do everything -->
+  <target name="all" depends="clean,jar"
+          description="Clean work dirs, compile, make JAR."/>
+
+  <!-- clean old cruft out of our way -->
+  <target name="clean"
+          description="Delete old work and dist directories.">
+    <delete dir="${work.home}"/>
+  </target>
+
+  <!-- make new dist and work trees -->
+  <target name="mkdirs" description="Create working dirs">
+    <mkdir dir="${work.home}"/>
+  </target>
+
+  <!-- compile *.java to *.class -->
+  <target name="compile" depends="mkdirs"
+          description="Compile Java sources to ${work.home}">
+    <javac srcdir="${src.home}" destdir="${work.home}" debug="true"
+           includeAntRuntime="true">
+    </javac>
+  </target>
+
+  <!-- make .jar file -->
+  <target name="jar" depends="compile" description="Create JAR file.">
+    <jar destfile="${jar.name}">
+      <fileset dir="${work.home}"/>
+    </jar>
+  </target>
+
+</project>
Binary file lib/mod/mod.jar has changed
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/lib/mod/src/de/masters_of_disaster/ant/tasks/ar/Ar.java	Fri Apr 24 19:45:57 2020 -0700
@@ -0,0 +1,468 @@
+package de.masters_of_disaster.ant.tasks.ar;
+
+import java.io.BufferedOutputStream;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.util.Enumeration;
+import java.util.Vector;
+import org.apache.tools.ant.BuildException;
+import org.apache.tools.ant.DirectoryScanner;
+import org.apache.tools.ant.Project;
+import org.apache.tools.ant.taskdefs.MatchingTask;
+import org.apache.tools.ant.types.EnumeratedAttribute;
+import org.apache.tools.ant.types.FileSet;
+import org.apache.tools.ant.util.FileUtils;
+import org.apache.tools.ant.util.MergingMapper;
+import org.apache.tools.ant.util.SourceFileScanner;
+import org.apache.tools.zip.UnixStat;
+
+/**
+ * Creates an ar archive.
+ *
+ * @ant.task category="packaging"
+ */
+public class Ar extends MatchingTask {
+    File destFile;
+    File baseDir;
+
+    private ArLongFileMode longFileMode = new ArLongFileMode();
+
+    Vector filesets = new Vector();
+
+    /**
+     * Indicates whether the user has been warned about long files already.
+     */
+    private boolean longWarningGiven = false;
+
+    /**
+     * Add a new fileset with the option to specify permissions
+     * @return the ar fileset to be used as the nested element.
+     */
+    public ArFileSet createArFileSet() {
+        ArFileSet fileset = new ArFileSet();
+        filesets.addElement(fileset);
+        return fileset;
+    }
+
+
+    /**
+     * Set the name/location of where to create the ar file.
+     * @param destFile The output of the tar
+     */
+    public void setDestFile(File destFile) {
+        this.destFile = destFile;
+    }
+
+    /**
+     * This is the base directory to look in for things to ar.
+     * @param baseDir the base directory.
+     */
+    public void setBasedir(File baseDir) {
+        this.baseDir = baseDir;
+    }
+
+    /**
+     * Set how to handle long files, those with a name&gt;16 chars or containing spaces.
+     * Optional, default=warn.
+     * <p>
+     * Allowable values are
+     * <ul>
+     * <li>  truncate - names are truncated to the maximum length, spaces are replaced by '_'
+     * <li>  fail - names greater than the maximum cause a build exception
+     * <li>  warn - names greater than the maximum cause a warning and TRUNCATE is used
+     * <li>  bsd - BSD variant is used if any names are greater than the maximum.
+     * <li>  gnu - GNU variant is used if any names are greater than the maximum.
+     * <li>  omit - files with a name greater than the maximum are omitted from the archive
+     * </ul>
+     * @param mode the mode to handle long file names.
+     */
+    public void setLongfile(ArLongFileMode mode) {
+        this.longFileMode = mode;
+    }
+
+    /**
+     * do the business
+     * @throws BuildException on error
+     */
+    public void execute() throws BuildException {
+        if (destFile == null) {
+            throw new BuildException("destFile attribute must be set!",
+                                     getLocation());
+        }
+
+        if (destFile.exists() && destFile.isDirectory()) {
+            throw new BuildException("destFile is a directory!",
+                                     getLocation());
+        }
+
+        if (destFile.exists() && !destFile.canWrite()) {
+            throw new BuildException("Can not write to the specified destFile!",
+                                     getLocation());
+        }
+
+        Vector savedFileSets = (Vector) filesets.clone();
+        try {
+            if (baseDir != null) {
+                if (!baseDir.exists()) {
+                    throw new BuildException("basedir does not exist!",
+                                             getLocation());
+                }
+
+                // add the main fileset to the list of filesets to process.
+                ArFileSet mainFileSet = new ArFileSet(fileset);
+                mainFileSet.setDir(baseDir);
+                filesets.addElement(mainFileSet);
+            }
+
+            if (filesets.size() == 0) {
+                throw new BuildException("You must supply either a basedir "
+                                         + "attribute or some nested filesets.",
+                                         getLocation());
+            }
+
+            // check if ar is out of date with respect to each
+            // fileset
+            boolean upToDate = true;
+            for (Enumeration e = filesets.elements(); e.hasMoreElements();) {
+                ArFileSet fs = (ArFileSet) e.nextElement();
+                String[] files = fs.getFiles(getProject());
+
+                if (!archiveIsUpToDate(files, fs.getDir(getProject()))) {
+                    upToDate = false;
+                }
+
+                for (int i = 0; i < files.length; ++i) {
+                    if (destFile.equals(new File(fs.getDir(getProject()),
+                                                files[i]))) {
+                        throw new BuildException("An ar file cannot include "
+                                                 + "itself", getLocation());
+                    }
+                }
+            }
+
+            if (upToDate) {
+                log("Nothing to do: " + destFile.getAbsolutePath()
+                    + " is up to date.", Project.MSG_INFO);
+                return;
+            }
+
+            log("Building ar: " + destFile.getAbsolutePath(), Project.MSG_INFO);
+
+            ArOutputStream aOut = null;
+            try {
+                aOut = new ArOutputStream(
+                    new BufferedOutputStream(
+                        new FileOutputStream(destFile)));
+                if (longFileMode.isTruncateMode()
+                     || longFileMode.isWarnMode()) {
+                    aOut.setLongFileMode(ArOutputStream.LONGFILE_TRUNCATE);
+                } else if (longFileMode.isFailMode()
+                            || longFileMode.isOmitMode()) {
+                    aOut.setLongFileMode(ArOutputStream.LONGFILE_ERROR);
+                } else if (longFileMode.isBsdMode()) {
+                    aOut.setLongFileMode(ArOutputStream.LONGFILE_BSD);
+                } else {
+                    // GNU
+                    aOut.setLongFileMode(ArOutputStream.LONGFILE_GNU);
+                }
+
+                longWarningGiven = false;
+                for (Enumeration e = filesets.elements();
+                     e.hasMoreElements();) {
+                    ArFileSet fs = (ArFileSet) e.nextElement();
+                    String[] files = fs.getFiles(getProject());
+                    if (files.length > 1 && fs.getFullpath().length() > 0) {
+                        throw new BuildException("fullpath attribute may only "
+                                                 + "be specified for "
+                                                 + "filesets that specify a "
+                                                 + "single file.");
+                    }
+                    for (int i = 0; i < files.length; i++) {
+                        File f = new File(fs.getDir(getProject()), files[i]);
+                        arFile(f, aOut, fs);
+                    }
+                }
+            } catch (IOException ioe) {
+                String msg = "Problem creating AR: " + ioe.getMessage();
+                throw new BuildException(msg, ioe, getLocation());
+            } finally {
+                FileUtils.close(aOut);
+            }
+        } finally {
+            filesets = savedFileSets;
+        }
+    }
+
+    /**
+     * ar a file
+     * @param file the file to ar
+     * @param aOut the output stream
+     * @param arFileSet the fileset that the file came from.
+     * @throws IOException on error
+     */
+    protected void arFile(File file, ArOutputStream aOut, ArFileSet arFileSet)
+        throws IOException {
+        FileInputStream fIn = null;
+
+        if (file.isDirectory()) {
+            return;
+        }
+
+        String fileName = file.getName();
+
+        String fullpath = arFileSet.getFullpath();
+        if (fullpath.length() > 0) {
+            fileName = fullpath.substring(fullpath.lastIndexOf('/'));
+        }
+
+        // don't add "" to the archive
+        if (fileName.length() <= 0) {
+            return;
+        }
+
+        try {
+            if ((fileName.length() >= ArConstants.NAMELEN)
+                  || (-1 != fileName.indexOf(' '))) {
+                if (longFileMode.isOmitMode()) {
+                    log("Omitting: " + fileName, Project.MSG_INFO);
+                    return;
+                } else if (longFileMode.isWarnMode()) {
+                    if (!longWarningGiven) {
+                        log("Resulting ar file contains truncated or space converted filenames",
+                            Project.MSG_WARN);
+                        longWarningGiven = true;
+                    }
+                    log("Entry: \"" + fileName + "\" longer than "
+                        + ArConstants.NAMELEN + " characters or containing spaces.",
+                        Project.MSG_WARN);
+                } else if (longFileMode.isFailMode()) {
+                    throw new BuildException("Entry: \"" + fileName
+                        + "\" longer than " + ArConstants.NAMELEN
+                        + "characters or containting spaces.", getLocation());
+                }
+            }
+
+            ArEntry ae = new ArEntry(fileName);
+            ae.setFileDate(file.lastModified());
+            ae.setUserId(arFileSet.getUid());
+            ae.setGroupId(arFileSet.getGid());
+            ae.setMode(arFileSet.getMode());
+            ae.setSize(file.length());
+
+            aOut.putNextEntry(ae);
+
+            fIn = new FileInputStream(file);
+
+            byte[] buffer = new byte[8 * 1024];
+            int count = 0;
+            do {
+                aOut.write(buffer, 0, count);
+                count = fIn.read(buffer, 0, buffer.length);
+            } while (count != -1);
+
+            aOut.closeEntry();
+        } finally {
+            if (fIn != null) {
+                fIn.close();
+            }
+        }
+    }
+
+    /**
+     * Is the archive up to date in relationship to a list of files.
+     * @param files the files to check
+     * @param dir   the base directory for the files.
+     * @return true if the archive is up to date.
+     */
+    protected boolean archiveIsUpToDate(String[] files, File dir) {
+        SourceFileScanner sfs = new SourceFileScanner(this);
+        MergingMapper mm = new MergingMapper();
+        mm.setTo(destFile.getAbsolutePath());
+        return sfs.restrict(files, dir, null, mm).length == 0;
+    }
+
+    /**
+     * This is a FileSet with the option to specify permissions
+     * and other attributes.
+     */
+    public static class ArFileSet extends FileSet {
+        private String[] files = null;
+
+        private int fileMode = UnixStat.FILE_FLAG | UnixStat.DEFAULT_FILE_PERM;
+        private int    uid;
+        private int    gid;
+        private String fullpath = "";
+
+        /**
+         * Creates a new <code>ArFileSet</code> instance.
+         * Using a fileset as a constructor argument.
+         *
+         * @param fileset a <code>FileSet</code> value
+         */
+        public ArFileSet(FileSet fileset) {
+            super(fileset);
+        }
+
+        /**
+         * Creates a new <code>ArFileSet</code> instance.
+         *
+         */
+        public ArFileSet() {
+            super();
+        }
+
+        /**
+         *  Get a list of files and directories specified in the fileset.
+         * @param p the current project.
+         * @return a list of file and directory names, relative to
+         *    the baseDir for the project.
+         */
+        public String[] getFiles(Project p) {
+            if (files == null) {
+                DirectoryScanner ds = getDirectoryScanner(p);
+                files = ds.getIncludedFiles();
+            }
+
+            return files;
+        }
+
+        /**
+         * A 3 digit octal string, specify the user, group and
+         * other modes in the standard Unix fashion;
+         * optional, default=0644
+         * @param octalString a 3 digit octal string.
+         */
+        public void setMode(String octalString) {
+            this.fileMode =
+                UnixStat.FILE_FLAG | Integer.parseInt(octalString, 8);
+        }
+
+        /**
+         * @return the current mode.
+         */
+        public int getMode() {
+            return fileMode;
+        }
+
+        /**
+         * The UID for the ar entry; optional, default="0"
+         * @param uid the id of the user for the ar entry.
+         */
+        public void setUid(int uid) {
+            this.uid = uid;
+        }
+
+        /**
+         * @return the UID for the ar entry
+         */
+        public int getUid() {
+            return uid;
+        }
+
+        /**
+         * The GID for the ar entry; optional, default="0"
+         * @param gid the group id.
+         */
+        public void setGid(int gid) {
+            this.gid = gid;
+        }
+
+        /**
+         * @return the group identifier.
+         */
+        public int getGid() {
+            return gid;
+        }
+
+        /**
+         * If the fullpath attribute is set, the file in the fileset
+         * is written with the last part of the path in the archive.
+         * If the fullpath ends in '/' the file is omitted from the archive.
+         * It is an error to have more than one file specified in such a fileset.
+         * @param fullpath the path to use for the file in a fileset.
+         */
+        public void setFullpath(String fullpath) {
+            this.fullpath = fullpath;
+        }
+
+        /**
+         * @return the path to use for a single file fileset.
+         */
+        public String getFullpath() {
+            return fullpath;
+        }
+    }
+
+    /**
+     * Set of options for long file handling in the task.
+     */
+    public static class ArLongFileMode extends EnumeratedAttribute {
+        /** permissible values for longfile attribute */
+        public static final String
+            WARN = "warn",
+            FAIL = "fail",
+            TRUNCATE = "truncate",
+            GNU = "gnu",
+            BSD = "bsd",
+            OMIT = "omit";
+
+        private final String[] validModes = {WARN, FAIL, TRUNCATE, GNU, BSD, OMIT};
+
+        /** Constructor, defaults to "warn" */
+        public ArLongFileMode() {
+            super();
+            setValue(WARN);
+        }
+
+        /**
+         * @return the possible values for this enumerated type.
+         */
+        public String[] getValues() {
+            return validModes;
+        }
+
+        /**
+         * @return true if value is "truncate".
+         */
+        public boolean isTruncateMode() {
+            return TRUNCATE.equalsIgnoreCase(getValue());
+        }
+
+        /**
+         * @return true if value is "warn".
+         */
+        public boolean isWarnMode() {
+            return WARN.equalsIgnoreCase(getValue());
+        }
+
+        /**
+         * @return true if value is "gnu".
+         */
+        public boolean isGnuMode() {
+            return GNU.equalsIgnoreCase(getValue());
+        }
+
+        /**
+         * @return true if value is "bsd".
+         */
+        public boolean isBsdMode() {
+            return BSD.equalsIgnoreCase(getValue());
+        }
+
+        /**
+         * @return true if value is "fail".
+         */
+        public boolean isFailMode() {
+            return FAIL.equalsIgnoreCase(getValue());
+        }
+
+        /**
+         * @return true if value is "omit".
+         */
+        public boolean isOmitMode() {
+            return OMIT.equalsIgnoreCase(getValue());
+        }
+    }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/lib/mod/src/de/masters_of_disaster/ant/tasks/ar/ArConstants.java	Fri Apr 24 19:45:57 2020 -0700
@@ -0,0 +1,62 @@
+package de.masters_of_disaster.ant.tasks.ar;
+
+/**
+ * This interface contains all the definitions used in the package.
+ */
+
+public interface ArConstants {
+    /**
+     * The length of the name field in a file header.
+     */
+    int    NAMELEN = 16;
+
+    /**
+     * The length of the file date field in a file header.
+     */
+    int    FILEDATELEN = 12;
+
+    /**
+     * The length of the user id field in a file header.
+     */
+    int    UIDLEN = 6;
+
+    /**
+     * The length of the group id field in a file header.
+     */
+    int    GIDLEN = 6;
+
+    /**
+     * The length of the mode field in a file header.
+     */
+    int    MODELEN = 8;
+
+    /**
+     * The length of the size field in a file header.
+     */
+    int    SIZELEN = 10;
+
+    /**
+     * The length of the magic field in a file header.
+     */
+    int    MAGICLEN = 2;
+
+    /**
+     * The magic tag put at the end of a file header.
+     */
+    String HEADERMAGIC = "`\n";
+
+    /**
+     * The headerlength of a file header.
+     */
+    int    HEADERLENGTH = NAMELEN + FILEDATELEN + UIDLEN + GIDLEN + MODELEN + SIZELEN + MAGICLEN;
+
+    /**
+     * The length of the magic field in a file header.
+     */
+    byte[] PADDING = { '\n' };
+
+    /**
+     * The magic tag representing an ar archive.
+     */
+    byte[] ARMAGIC = { '!', '<', 'a', 'r', 'c', 'h', '>', '\n' };
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/lib/mod/src/de/masters_of_disaster/ant/tasks/ar/ArEntry.java	Fri Apr 24 19:45:57 2020 -0700
@@ -0,0 +1,355 @@
+package de.masters_of_disaster.ant.tasks.ar;
+
+import java.io.File;
+import java.util.Date;
+
+/**
+ * This class represents an entry in an Ar archive. It consists
+ * of the entry's header, as well as the entry's File. Entries
+ * can be instantiated in one of three ways, depending on how
+ * they are to be used.
+ * <p>
+ * ArEntries that are created from the header bytes read from
+ * an archive are instantiated with the ArEntry( byte[] )
+ * constructor. These entries will be used when extracting from
+ * or listing the contents of an archive. These entries have their
+ * header filled in using the header bytes. They also set the File
+ * to null, since they reference an archive entry not a file.
+ * <p>
+ * ArEntries that are created from Files that are to be written
+ * into an archive are instantiated with the ArEntry( File )
+ * constructor. These entries have their header filled in using
+ * the File's information. They also keep a reference to the File
+ * for convenience when writing entries.
+ * <p>
+ * Finally, ArEntries can be constructed from nothing but a name.
+ * This allows the programmer to construct the entry by hand, for
+ * instance when only an InputStream is available for writing to
+ * the archive, and the header information is constructed from
+ * other information. In this case the header fields are set to
+ * defaults and the File is set to null.
+ *
+ * <p>
+ * The C structure for an Ar Entry's header is:
+ * <pre>
+ * struct header {
+ * char filename[16];
+ * char filedate[12];
+ * char uid[6];
+ * char gid[6];
+ * char mode[8];
+ * char size[10];
+ * char magic[2];
+ * } header;
+ * </pre>
+ *
+ */
+
+public class ArEntry implements ArConstants {
+    /** The entry's filename. */
+    private StringBuffer filename;
+
+    /** The entry's file date. */
+    private long fileDate;
+
+    /** The entry's user id. */
+    private int userId;
+
+    /** The entry's group id. */
+    private int groupId;
+
+    /** The entry's permission mode. */
+    private int mode;
+
+    /** The entry's size. */
+    private long size;
+
+    /** The entry's magic tag. */
+    private StringBuffer magic;
+
+    /** The entry's file reference */
+    private File file;
+
+    /** Default permissions bits for files */
+    public static final int DEFAULT_FILE_MODE = 0100644;
+
+    /** Convert millis to seconds */
+    public static final int MILLIS_PER_SECOND = 1000;
+
+    /**
+     * Construct an empty entry and prepares the header values.
+     */
+    private ArEntry () {
+        this.magic = new StringBuffer(HEADERMAGIC);
+        this.filename = new StringBuffer();
+        this.userId = 0;
+        this.groupId = 0;
+        this.file = null;
+    }
+
+    /**
+     * Construct an entry with only a name. This allows the programmer
+     * to construct the entry's header "by hand". File is set to null.
+     *
+     * @param name the entry name
+     */
+    public ArEntry(String name) {
+        this();
+        if (name.endsWith("/")) {
+        	throw new IllegalArgumentException("ar archives can only contain files");
+        }
+        this.filename = new StringBuffer(name);
+        this.mode = DEFAULT_FILE_MODE;
+        this.userId = 0;
+        this.groupId = 0;
+        this.size = 0;
+        this.fileDate = (new Date()).getTime() / MILLIS_PER_SECOND;
+    }
+
+    /**
+     * Construct an entry for a file. File is set to file, and the
+     * header is constructed from information from the file.
+     *
+     * @param file The file that the entry represents.
+     */
+    public ArEntry(File file) {
+        this();
+        if (file.isDirectory()) {
+        	throw new IllegalArgumentException("ar archives can only contain files");
+        }
+        this.file = file;
+        this.filename = new StringBuffer(file.getName());
+        this.fileDate = file.lastModified() / MILLIS_PER_SECOND;
+        this.mode = DEFAULT_FILE_MODE;
+        this.size = file.length();
+    }
+
+    /**
+     * Construct an entry from an archive's header bytes. File is set
+     * to null.
+     *
+     * @param headerBuf The header bytes from an ar archive entry.
+     */
+    public ArEntry(byte[] headerBuf) {
+        this();
+        this.parseArHeader(headerBuf);
+    }
+
+    /**
+     * Determine if the two entries are equal. Equality is determined
+     * by the header names being equal.
+     *
+     * @param it Entry to be checked for equality.
+     * @return True if the entries are equal.
+     */
+    public boolean equals(ArEntry it) {
+        return this.getFilename().equals(it.getFilename());
+    }
+
+    /**
+     * Determine if the two entries are equal. Equality is determined
+     * by the header names being equal.
+     *
+     * @param it Entry to be checked for equality.
+     * @return True if the entries are equal.
+     */
+    public boolean equals(Object it) {
+        if (it == null || getClass() != it.getClass()) {
+            return false;
+        }
+        return equals((ArEntry) it);
+    }
+
+    /**
+     * Hashcodes are based on entry names.
+     *
+     * @return the entry hashcode
+     */
+    public int hashCode() {
+        return getFilename().hashCode();
+    }
+
+    /**
+     * Get this entry's name.
+     *
+     * @return This entry's name.
+     */
+    public String getFilename() {
+        return this.filename.toString();
+    }
+
+    /**
+     * Set this entry's name.
+     *
+     * @param name This entry's new name.
+     */
+    public void setFilename(String filename) {
+        this.filename = new StringBuffer(filename);
+    }
+
+    /**
+     * Set the mode for this entry
+     *
+     * @param mode the mode for this entry
+     */
+    public void setMode(int mode) {
+        this.mode = mode;
+    }
+
+    /**
+     * Get this entry's user id.
+     *
+     * @return This entry's user id.
+     */
+    public int getUserId() {
+        return this.userId;
+    }
+
+    /**
+     * Set this entry's user id.
+     *
+     * @param userId This entry's new user id.
+     */
+    public void setUserId(int userId) {
+        this.userId = userId;
+    }
+
+    /**
+     * Get this entry's group id.
+     *
+     * @return This entry's group id.
+     */
+    public int getGroupId() {
+        return this.groupId;
+    }
+
+    /**
+     * Set this entry's group id.
+     *
+     * @param groupId This entry's new group id.
+     */
+    public void setGroupId(int groupId) {
+        this.groupId = groupId;
+    }
+
+    /**
+     * Convenience method to set this entry's group and user ids.
+     *
+     * @param userId This entry's new user id.
+     * @param groupId This entry's new group id.
+     */
+    public void setIds(int userId, int groupId) {
+        this.setUserId(userId);
+        this.setGroupId(groupId);
+    }
+
+    /**
+     * Set this entry's modification time. The parameter passed
+     * to this method is in "Java time".
+     *
+     * @param time This entry's new modification time.
+     */
+    public void setFileDate(long time) {
+        this.fileDate = time / MILLIS_PER_SECOND;
+    }
+
+    /**
+     * Set this entry's modification time.
+     *
+     * @param time This entry's new modification time.
+     */
+    public void setFileDate(Date time) {
+        this.fileDate = time.getTime() / MILLIS_PER_SECOND;
+    }
+
+    /**
+     * Get this entry's modification time.
+     *
+     * @return time This entry's new modification time.
+     */
+    public Date getFileDate() {
+        return new Date(this.fileDate * MILLIS_PER_SECOND);
+    }
+
+    /**
+     * Get this entry's file.
+     *
+     * @return This entry's file.
+     */
+    public File getFile() {
+        return this.file;
+    }
+
+    /**
+     * Get this entry's mode.
+     *
+     * @return This entry's mode.
+     */
+    public int getMode() {
+        return this.mode;
+    }
+
+    /**
+     * Get this entry's file size.
+     *
+     * @return This entry's file size.
+     */
+    public long getSize() {
+        return this.size;
+    }
+
+    /**
+     * Set this entry's file size.
+     *
+     * @param size This entry's new file size.
+     */
+    public void setSize(long size) {
+        this.size = size;
+    }
+
+    /**
+     * Write an entry's header information to a header buffer.
+     *
+     * @param outbuf The tar entry header buffer to fill in.
+     */
+    public void writeEntryHeader(byte[] outbuf) {
+        int offset = 0;
+
+        offset = ArUtils.getNameBytes(this.filename, outbuf, offset, NAMELEN);
+        offset = ArUtils.getLongBytes(this.fileDate, outbuf, offset, FILEDATELEN);
+        offset = ArUtils.getIntegerBytes(this.userId, outbuf, offset, UIDLEN);
+        offset = ArUtils.getIntegerBytes(this.groupId, outbuf, offset, GIDLEN);
+        offset = ArUtils.getOctalBytes(this.mode, outbuf, offset, MODELEN);
+        offset = ArUtils.getLongBytes(this.size, outbuf, offset, SIZELEN);
+        offset = ArUtils.getNameBytes(this.magic, outbuf, offset, MAGICLEN);
+
+        while (offset < outbuf.length) {
+            outbuf[offset++] = 0;
+        }
+    }
+
+    /**
+     * Parse an entry's header information from a header buffer.
+     *
+     * @param header The ar entry header buffer to get information from.
+     */
+    public void parseArHeader(byte[] header) {
+        throw new UnsupportedOperationException("parseArHeader(byte[]) not yet implmented");
+//        int offset = 0;
+//
+//        this.filename = TarUtils.parseName(header, offset, NAMELEN);
+//        offset += NAMELEN;
+//        this.fileDate = TarUtils.parseOctal(header, offset, FILEDATELEN);
+//        offset += FILEDATELEN;
+//        this.userId = (int) TarUtils.parseOctal(header, offset, UIDLEN);
+//        offset += UIDLEN;
+//        this.groupId = (int) TarUtils.parseOctal(header, offset, GIDLEN);
+//        offset += GIDLEN;
+//        this.mode = (int) TarUtils.parseOctal(header, offset, MODELEN);
+//        offset += MODELEN;
+//        this.size = TarUtils.parseOctal(header, offset, SIZELEN);
+//        offset += SIZELEN;
+//        this.magic = TarUtils.parseName(header, offset, MAGICLEN);
+//        offset += MAGICLEN;
+    }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/lib/mod/src/de/masters_of_disaster/ant/tasks/ar/ArOutputStream.java	Fri Apr 24 19:45:57 2020 -0700
@@ -0,0 +1,167 @@
+package de.masters_of_disaster.ant.tasks.ar;
+
+import java.io.FilterOutputStream;
+import java.io.OutputStream;
+import java.io.IOException;
+
+/**
+ * The ArOutputStream writes an ar archive as an OutputStream.
+ * Methods are provided to put entries, and then write their contents
+ * by writing to this stream using write().
+ */
+public class ArOutputStream extends FilterOutputStream {
+    /** Fail if a long file name is required in the archive or the name contains spaces. */
+    public static final int LONGFILE_ERROR = 0;
+
+    /** Long paths will be truncated in the archive. Spaces are replaced by '_' */
+    public static final int LONGFILE_TRUNCATE = 1;
+
+    /** GNU ar variant is used to store long file names and file names with spaced in the archive. */
+    public static final int LONGFILE_GNU = 2;
+
+    /** BSD ar variant is used to store long file names and file names with spaced in the archive. */
+    public static final int LONGFILE_BSD = 3;
+
+    protected int       currSize;
+    protected int       currBytes;
+    protected byte[]    oneBuf;
+    protected int       longFileMode = LONGFILE_ERROR;
+    protected boolean   writingStarted = false;
+    protected boolean   inEntry = false;
+
+    public ArOutputStream(OutputStream os) throws IOException {
+        super(os);
+    	if (null == os) {
+    	    throw new NullPointerException("os must not be null");
+    	}
+        this.out.write(ArConstants.ARMAGIC,0,ArConstants.ARMAGIC.length);
+        this.oneBuf = new byte[1];
+    }
+
+    public void setLongFileMode(int longFileMode) {
+    	if (writingStarted) {
+    	    throw new IllegalStateException("longFileMode cannot be changed after writing to the archive has begun");
+    	}
+        if (LONGFILE_GNU == longFileMode) {
+            throw new UnsupportedOperationException("GNU variant isn't implemented yet");
+        }
+        if (LONGFILE_BSD == longFileMode) {
+            throw new UnsupportedOperationException("BSD variant isn't implemented yet");
+        }
+        this.longFileMode = longFileMode;
+    }
+
+    /**
+     * Put an entry on the output stream. This writes the entry's
+     * header record and positions the output stream for writing
+     * the contents of the entry. Once this method is called, the
+     * stream is ready for calls to write() to write the entry's
+     * contents. Once the contents are written, closeEntry()
+     * <B>MUST</B> be called to ensure that all buffered data
+     * is completely written to the output stream.
+     *
+     * @param entry The ArEntry to be written to the archive.
+     */
+    public void putNextEntry(ArEntry entry) throws IOException {
+    	writingStarted = true;
+    	if (inEntry) {
+    	    throw new IOException("the current entry has to be closed before starting a new one");
+    	}
+        String filename = entry.getFilename();
+        if ((filename.length() >= ArConstants.NAMELEN)
+              && (longFileMode != LONGFILE_TRUNCATE)) {
+            throw new RuntimeException("file name \"" + entry.getFilename()
+                                         + "\" is too long ( > "
+                                         + ArConstants.NAMELEN + " bytes )");
+        }
+        if (-1 != filename.indexOf(' ')) {
+            if (longFileMode == LONGFILE_TRUNCATE) {
+                entry.setFilename(filename.replace(' ','_'));
+            } else {
+                throw new RuntimeException("file name \"" + entry.getFilename()
+                                             + "\" contains spaces");
+            }
+        }
+
+        byte[] headerBuf = new byte[ArConstants.HEADERLENGTH];
+        entry.writeEntryHeader(headerBuf);
+        this.out.write(headerBuf,0,ArConstants.HEADERLENGTH);
+
+        this.currBytes = 0;
+        this.currSize = (int) entry.getSize();
+        inEntry = true;
+    }
+
+    /**
+     * Close an entry. This method MUST be called for all file
+     * entries that contain data. The reason is that we must
+     * pad an entries data if it is of odd size.
+     */
+    public void closeEntry() throws IOException {
+        if (!inEntry) {
+            throw new IOException("we are not in an entry currently");
+        }
+
+        if (this.currBytes < this.currSize) {
+            throw new IOException("entry closed at '" + this.currBytes
+                                  + "' before the '" + this.currSize
+                                  + "' bytes specified in the header were written");
+        }
+
+        if (1 == (this.currSize & 1)) {
+            this.out.write(ArConstants.PADDING,0,1);
+        }
+
+        inEntry = false;
+    }
+
+    /**
+     * Writes a byte to the current ar archive entry.
+     *
+     * This method simply calls write( byte[], int, int ).
+     *
+     * @param b The byte to write to the archive.
+     */
+    public void write(int b) throws IOException {
+        this.oneBuf[0] = (byte) b;
+        this.write(this.oneBuf, 0, 1);
+    }
+
+    /**
+     * Writes bytes to the current ar archive entry.
+     *
+     * This method simply calls write( byte[], int, int ).
+     *
+     * @param wBuf The buffer to write to the archive.
+     */
+    public void write(byte[] wBuf) throws IOException {
+        this.write(wBuf, 0, wBuf.length);
+    }
+
+    /**
+     * Writes bytes to the current ar archive entry. This method
+     * is aware of the current entry and will throw an exception if
+     * you attempt to write bytes past the length specified for the
+     * current entry.
+     *
+     * @param wBuf The buffer to write to the archive.
+     * @param wOffset The offset in the buffer from which to get bytes.
+     * @param numToWrite The number of bytes to write.
+     */
+    public void write(byte[] wBuf, int wOffset, int numToWrite) throws IOException {
+        if (!inEntry) {
+            throw new IOException("we are not in an entry currently");
+        }
+
+        if ((this.currBytes + numToWrite) > this.currSize) {
+            throw new IOException("request to write '" + numToWrite
+                                  + "' bytes exceeds size in header of '"
+                                  + this.currSize + "' bytes");
+        }
+
+        if (numToWrite > 0) {
+            this.out.write(wBuf,wOffset,numToWrite);
+            this.currBytes += numToWrite;
+        }
+    }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/lib/mod/src/de/masters_of_disaster/ant/tasks/ar/ArUtils.java	Fri Apr 24 19:45:57 2020 -0700
@@ -0,0 +1,155 @@
+package de.masters_of_disaster.ant.tasks.ar;
+
+/**
+ * This class provides static utility methods to work with byte streams.
+ */
+public class ArUtils {
+    /**
+     * Parse an octal string from a header buffer. This is used for the
+     * file permission mode value.
+     *
+     * @param header The header buffer from which to parse.
+     * @param offset The offset into the buffer from which to parse.
+     * @param length The number of header bytes to parse.
+     * @return The long value of the octal string.
+     */
+    public static long parseOctal(byte[] header, int offset, int length) {
+        long    result = 0;
+        int     end = offset + length;
+
+        for (int i=offset ; i<end ; i++) {
+            if (header[i] == (byte) ' ') {
+                break;
+            }
+            result = (result << 3) + (header[i] - '0');
+        }
+
+        return result;
+    }
+
+    /**
+     * Parse an entry name from a header buffer.
+     *
+     * @param header The header buffer from which to parse.
+     * @param offset The offset into the buffer from which to parse.
+     * @param length The number of header bytes to parse.
+     * @return The header's entry name.
+     */
+    public static StringBuffer parseName(byte[] header, int offset, int length) {
+        StringBuffer result = new StringBuffer(length);
+        int          end = offset + length;
+
+        for (int i=offset ; i<end ; i++) {
+            if (header[i] == ' ') {
+                break;
+            }
+
+            result.append((char) header[i]);
+        }
+
+        return result;
+    }
+
+    /**
+     * Write a name into a byte array.
+     *
+     * @param name The name to write.
+     * @param buf The byte array into which to write.
+     * @param offset The offset into the buffer from which to write.
+     * @param length The number of header bytes to write.
+     * @return The number of bytes written to the buffer.
+     */
+    public static int getNameBytes(StringBuffer name, byte[] buf, int offset, int length) {
+        int i;
+        int c = name.length();
+
+        for (i=0 ; i<length && i<c ; i++) {
+            buf[offset+i] = (byte) name.charAt(i);
+        }
+
+        while (i<length) {
+            buf[offset+i] = (byte) ' ';
+            i++;
+        }
+
+        return offset + length;
+    }
+
+    /**
+     * Write a long value into a byte array.
+     *
+     * @param value The value to write.
+     * @param buf The byte array into which to write.
+     * @param offset The offset into the buffer from which to write.
+     * @param length The number of header bytes to write.
+     * @return The number of bytes written to the buffer.
+     */
+    public static int getLongBytes(long value, byte[] buf, int offset, int length) {
+        int i;
+        String tmp = Long.toString(value);
+        int c = tmp.length();
+
+        for (i=0 ; i<length && i<c ; i++) {
+            buf[offset+i] = (byte) tmp.charAt(i);
+        }
+
+        while (i<length) {
+            buf[offset+i] = (byte) ' ';
+            i++;
+        }
+
+        return offset + length;
+    }
+
+    /**
+     * Write an int value into a byte array.
+     *
+     * @param value The value to write.
+     * @param buf The byte array into which to write.
+     * @param offset The offset into the buffer from which to write.
+     * @param length The number of header bytes to write.
+     * @return The number of bytes written to the buffer.
+     */
+    public static int getIntegerBytes(int value, byte[] buf, int offset, int length) {
+        int i;
+        String tmp = Integer.toString(value);
+        int c = tmp.length();
+
+        for (i=0 ; i<length && i<c ; i++) {
+            buf[offset+i] = (byte) tmp.charAt(i);
+        }
+
+        while (i<length) {
+            buf[offset+i] = (byte) ' ';
+            i++;
+        }
+
+        return offset + length;
+    }
+
+    /**
+     * Write an octal value into a byte array.
+     *
+     * @param value The value to write.
+     * @param buf The byte array into which to write.
+     * @param offset The offset into the buffer from which to write.
+     * @param length The number of header bytes to write.
+     * @return The number of bytes written to the buffer.
+     */
+    public static int getOctalBytes(long value, byte[] buf, int offset, int length) {
+        int i;
+        String tmp = Long.toOctalString(value);
+        int c = tmp.length();
+
+        for (i=0 ; i<length && i<c ; i++) {
+            buf[offset+i] = (byte) tmp.charAt(i);
+        }
+
+        while (i<length) {
+            buf[offset+i] = (byte) ' ';
+            i++;
+        }
+
+        return offset + length;
+    }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/lib/mod/src/de/masters_of_disaster/ant/tasks/calculatesize/CalculateSize.java	Fri Apr 24 19:45:57 2020 -0700
@@ -0,0 +1,94 @@
+package de.masters_of_disaster.ant.tasks.calculatesize;
+
+import java.io.File;
+import java.util.Enumeration;
+import java.util.Vector;
+import org.apache.tools.ant.BuildException;
+import org.apache.tools.ant.taskdefs.MatchingTask;
+import org.apache.tools.ant.types.FileSet;
+
+/**
+ * Calculates the "Installed-Size" of a deb package for the "control"-file.
+ *
+ * @ant.task category="packaging"
+ */
+public class CalculateSize extends MatchingTask {
+    String realSizeProperty = null;
+    String diskSizeProperty = null;
+    Vector fileSets = new Vector();
+    File baseDir;
+
+    /**
+     * Add a new fileset
+     * 
+     * @return the fileset to be used as the nested element.
+     */
+    public FileSet createFileSet() {
+        FileSet fileSet = new FileSet();
+        fileSets.addElement(fileSet);
+        return fileSet;
+    }
+
+    /**
+     * This is the base directory to look in for things to include.
+     * 
+     * @param baseDir the base directory.
+     */
+    public void setBaseDir(File baseDir) {
+        this.baseDir = baseDir;
+        fileset.setDir(baseDir);
+    }
+
+    /**
+     * This is the property to set to the real size.
+     * 
+     * @param realSizeProperty The property to set to the real size
+     */
+    public void setRealSizeProperty(String realSizeProperty) {
+        this.realSizeProperty = realSizeProperty;
+    }
+
+    /**
+     * This is the property to set to the disk size.
+     * 
+     * @param diskSizeProperty The property to set to the disk size
+     */
+    public void setDiskSizeProperty(String diskSizeProperty) {
+        this.diskSizeProperty = diskSizeProperty;
+    }
+
+    /**
+     * do the business
+     * 
+     * @throws BuildException on error
+     */
+    public void execute() throws BuildException {
+        if ((null == realSizeProperty) && (null == diskSizeProperty)) {
+            throw new BuildException("realSizeProperty or diskSizeProperty must be set for <CalculateSize>");
+        }
+
+        if (null != baseDir) {
+            // add the main fileset to the list of filesets to process.
+            fileSets.addElement(fileset);
+        }
+
+        long realSize = 0;
+        long diskSize = 0;
+        for (Enumeration e=fileSets.elements() ; e.hasMoreElements() ; ) {
+            FileSet fileSet = (FileSet)e.nextElement();
+            String[] files = fileSet.getDirectoryScanner(getProject()).getIncludedFiles();
+            File fileSetDir = fileSet.getDir(getProject());
+            for (int i=0, c=files.length ; i<c ; i++) {
+                long fileLength = new File(fileSetDir,files[i]).length();
+                realSize += fileLength / 1024;
+                diskSize += (fileLength / 4096 + 1) * 4;
+            }
+        }
+        if (null != realSizeProperty) {
+            getProject().setNewProperty(realSizeProperty,Long.toString(realSize));
+        }
+        if (null != diskSizeProperty) {
+            getProject().setNewProperty(diskSizeProperty,Long.toString(diskSize));
+        }
+    }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/lib/mod/src/de/masters_of_disaster/ant/tasks/deb/Deb.java	Fri Apr 24 19:45:57 2020 -0700
@@ -0,0 +1,354 @@
+package de.masters_of_disaster.ant.tasks.deb;
+
+import de.masters_of_disaster.ant.tasks.ar.Ar;
+import de.masters_of_disaster.ant.tasks.ar.Ar.ArFileSet;
+import java.io.File;
+import java.util.Enumeration;
+import java.util.Vector;
+import org.apache.tools.ant.BuildException;
+import org.apache.tools.ant.Project;
+import org.apache.tools.ant.Task;
+import org.apache.tools.ant.taskdefs.Checksum;
+import org.apache.tools.ant.taskdefs.Echo;
+import org.apache.tools.ant.taskdefs.Echo.EchoLevel;
+import org.apache.tools.ant.taskdefs.Mkdir;
+import org.apache.tools.ant.taskdefs.MatchingTask;
+import org.apache.tools.ant.taskdefs.Tar;
+import org.apache.tools.ant.taskdefs.Tar.TarCompressionMethod;
+import org.apache.tools.ant.taskdefs.Tar.TarFileSet;
+import org.apache.tools.ant.util.FileUtils;
+import org.apache.tools.ant.util.MergingMapper;
+import org.apache.tools.ant.util.SourceFileScanner;
+
+/**
+ * Creates a deb package.
+ *
+ * @ant.task category="packaging"
+ */
+public class Deb extends MatchingTask {
+    Vector controlFileSets = new Vector();
+    Vector dataFileSets = new Vector();
+    File baseDir;
+    File destFile;
+    File tempDir;
+    boolean deleteTempFiles = true;
+    boolean includeMd5sums = false;
+    Tar controlTarGz = new Tar();
+    Tar dataTarGz = new Tar();
+    Ar debPackage = new Ar();
+
+    {
+        fileset = dataTarGz.createTarFileSet();
+    }
+
+    /**
+     * Add a new fileset for the control files with the option to specify permissions
+     * 
+     * @return the tar fileset to be used as the nested element.
+     */
+    public TarFileSet createControlFileSet() {
+        TarFileSet fileSet = controlTarGz.createTarFileSet();
+        controlFileSets.addElement(fileSet);
+        return fileSet;
+    }
+
+    /**
+     * Add a new fileset for the data files with the option to specify permissions
+     * 
+     * @return the tar fileset to be used as the nested element.
+     */
+    public TarFileSet createDataFileSet() {
+        TarFileSet fileSet = dataTarGz.createTarFileSet();
+        dataFileSets.addElement(fileSet);
+        return fileSet;
+    }
+
+    /**
+     * Set the name/location of where to create the deb file.
+     * 
+     * @param destFile The output of the deb
+     */
+    public void setDestFile(File destFile) {
+        this.destFile = destFile;
+        debPackage.setDestFile(destFile);
+    }
+
+    /**
+     * This is the base directory to look in for things to include in the data files.
+     * 
+     * @param baseDir the base directory.
+     */
+    public void setBaseDir(File baseDir) {
+        this.baseDir = baseDir;
+        fileset.setDir(baseDir);
+    }
+
+    /**
+     * This is the temp directory where to create the temporary files.
+     * If not set, the current projects baseDir is used.
+     * 
+     * @param tempDir the temp directory.
+     */
+    public void setTempDir(File tempDir) {
+        this.tempDir = tempDir;
+    }
+
+    /**
+     * This specifies if the temporary files should get deleted.
+     * 
+     * @param deleteTempFiles whether to delete the temporary files or not.
+     */
+    public void setDeleteTempFiles(boolean deleteTempFiles) {
+        this.deleteTempFiles = deleteTempFiles;
+    }
+
+    /**
+     * This specifies if the MD5 sums of the files in the data section should be
+     * included in the file "md5sums" in the control section.
+     * 
+     * @param includeMd5sums whether to include MD5 sums in the control section or not.
+     */
+    public void setIncludeMd5sums(boolean includeMd5sums) {
+        this.includeMd5sums = includeMd5sums;
+    }
+
+    /**
+     * do the business
+     * 
+     * @throws BuildException on error
+     */
+    public void execute() throws BuildException {
+        prepareTask(controlTarGz);
+        prepareTask(dataTarGz);
+        prepareTask(debPackage);
+        TarFileSet tarFileSet = controlTarGz.createTarFileSet();
+        tarFileSet.setFile(new File(System.getProperty("user.dir")));
+        tarFileSet.setUserName("root");
+        tarFileSet.setGroup("root");
+        tarFileSet.setFullpath("./");
+        tarFileSet = dataTarGz.createTarFileSet();
+        tarFileSet.setFile(new File(System.getProperty("user.dir")));
+        tarFileSet.setUserName("root");
+        tarFileSet.setGroup("root");
+        tarFileSet.setFullpath("./");
+
+        if (null == tempDir) {
+            tempDir = getProject().getBaseDir();
+        }
+
+        if (null != baseDir) {
+            // add the main fileset to the list of filesets to process.
+            dataFileSets.addElement(fileset);
+        } else {
+            fileset.setDir(new File(System.getProperty("user.dir")));
+            fileset.setExcludes("**");
+        }
+
+        boolean controlFound = false;
+        for (Enumeration e=controlFileSets.elements() ; e.hasMoreElements() ; ) {
+            TarFileSet fileSet = (TarFileSet)e.nextElement();
+            String[] files = fileSet.getFiles(getProject());
+            int i = 0;
+            int c;
+
+            for (c=files.length ; i<c && !controlFound ; i++) {
+                if (files[i].endsWith("control")
+                      && (new File(fileSet.getDir(getProject()),files[i])).isFile()) {
+                    controlFound = true;
+                }
+            }
+        }
+        if (!controlFound) {
+            throw new BuildException("The control fileset must contain a file \"control\"", getLocation());
+        }
+
+        // check if deb is out of date with respect to each fileset
+        boolean upToDate = true;
+        for (Enumeration e=controlFileSets.elements() ; e.hasMoreElements() ; ) {
+            TarFileSet fileSet = (TarFileSet)e.nextElement();
+            String[] files = fileSet.getFiles(getProject());
+
+            if (!packageIsUpToDate(files,fileSet.getDir(getProject()))) {
+                upToDate = false;
+            }
+        }
+
+        for (Enumeration e=dataFileSets.elements() ; e.hasMoreElements() ; ) {
+            TarFileSet fileSet = (TarFileSet)e.nextElement();
+            String[] files = fileSet.getFiles(getProject());
+
+            if (!packageIsUpToDate(files,fileSet.getDir(getProject()))) {
+                upToDate = false;
+            }
+        }
+
+        if (upToDate) {
+            log("Nothing to do: " + destFile.getAbsolutePath()
+                + " is up to date.", Project.MSG_INFO);
+            return;
+        }
+
+        log("Building deb: " + destFile.getAbsolutePath(), Project.MSG_INFO);
+
+        Mkdir mkdir = new Mkdir();
+        prepareTask(mkdir);
+        mkdir.setDir(tempDir);
+        mkdir.perform();
+
+        EchoLevel echoLevel = new EchoLevel();
+        echoLevel.setValue("error");
+        File debianBinaryFile = new File(tempDir,"debian-binary");
+        Echo echo = new Echo();
+        prepareTask(echo);
+        echo.setFile(debianBinaryFile);
+        echo.setLevel(echoLevel);
+        echo.setMessage("2.0\n");
+        echo.perform();
+
+        for (Enumeration e=controlFileSets.elements() ; e.hasMoreElements() ; ) {
+            TarFileSet fileSet = (TarFileSet)e.nextElement();
+            String prefix = fileSet.getPrefix();
+            String fullpath = fileSet.getFullpath();
+            if ("".equals(fullpath) && !prefix.startsWith("./")) {
+                if (prefix.startsWith("/")) {
+                    fileSet.setPrefix("." + prefix);
+                } else {
+                    fileSet.setPrefix("./" + prefix);
+                }
+            }
+            if ((fullpath.length() > 0) && !fullpath.startsWith("./")) {
+                fileSet.setPrefix("");
+                if (fullpath.startsWith("/")) {
+                    fileSet.setFullpath("." + fullpath);
+                } else {
+                    fileSet.setFullpath("./" + fullpath);
+                }
+            }
+            if ((0 == fileSet.getUid()) && ("" == fileSet.getUserName())) {
+                fileSet.setUserName("root");
+            }
+            if ((0 == fileSet.getGid()) && ("" == fileSet.getGroup())) {
+                fileSet.setGroup("root");
+            }
+        }
+
+        for (Enumeration e=dataFileSets.elements() ; e.hasMoreElements() ; ) {
+            TarFileSet fileSet = (TarFileSet)e.nextElement();
+            String prefix = fileSet.getPrefix();
+            String fullpath = fileSet.getFullpath();
+            if ("".equals(fullpath) && !prefix.startsWith("./")) {
+                if (prefix.startsWith("/")) {
+                    fileSet.setPrefix("." + prefix);
+                } else {
+                    fileSet.setPrefix("./" + prefix);
+                }
+            }
+            if ((fullpath.length() > 0) && !fullpath.startsWith("./")) {
+                fileSet.setPrefix("");
+                if (fullpath.startsWith("/")) {
+                    fileSet.setFullpath("." + fullpath);
+                } else {
+                    fileSet.setFullpath("./" + fullpath);
+                }
+            }
+            if ((0 == fileSet.getUid()) && ("" == fileSet.getUserName())) {
+                fileSet.setUserName("root");
+            }
+            if ((0 == fileSet.getGid()) && ("" == fileSet.getGroup())) {
+                fileSet.setGroup("root");
+            }
+        }
+
+        File md5sumsFile = new File(tempDir,"md5sums");
+        if (includeMd5sums) {
+            Checksum md5 = new Checksum();
+            prepareTask(md5);
+            int md5Count = 0;
+            StringBuffer md5sums = new StringBuffer();
+            for (Enumeration e=dataFileSets.elements() ; e.hasMoreElements() ; ) {
+                TarFileSet fileSet = (TarFileSet)e.nextElement();
+                String[] files = fileSet.getDirectoryScanner(getProject()).getIncludedFiles();
+                File fileSetDir = fileSet.getDir(getProject());
+                for (int i=0, c=files.length ; i<c ; i++) {
+                    md5.setFile(new File(fileSetDir,files[i]));
+                    md5.setProperty("md5_"+md5Count);
+                    md5.perform();
+                    md5sums.append(getProject().getProperty("md5_"+md5Count)).append("  ");
+                    String fullpath = fileSet.getFullpath();
+                    if (fullpath.length() > 0) {
+                        md5sums.append(fullpath.substring(2));
+                    } else {
+                        md5sums.append(fileSet.getPrefix().substring(2)).append(files[i].replace('\\','/'));
+                    }
+                    md5sums.append("\n");
+                    md5Count++;
+                }
+            }
+            echo = new Echo();
+            prepareTask(echo);
+            echo.setFile(md5sumsFile);
+            echo.setLevel(echoLevel);
+            echo.setMessage(md5sums.toString());
+            echo.perform();
+            tarFileSet = controlTarGz.createTarFileSet();
+            tarFileSet.setFile(md5sumsFile);
+            tarFileSet.setUserName("root");
+            tarFileSet.setGroup("root");
+            tarFileSet.setPrefix("./");
+        }
+
+        TarCompressionMethod tarCompressionMethod = new TarCompressionMethod();
+        tarCompressionMethod.setValue("gzip");
+        controlTarGz.setCompression(tarCompressionMethod);
+        File controlTarGzFile = new File(tempDir,"control.tar.gz");
+        controlTarGz.setDestFile(controlTarGzFile);
+        controlTarGz.perform();
+
+        dataTarGz.setCompression(tarCompressionMethod);
+        File dataTarGzFile = new File(tempDir,"data.tar.gz");
+        dataTarGz.setDestFile(dataTarGzFile);
+        dataTarGz.perform();
+
+        FileUtils.delete(destFile);
+        ArFileSet fileSet = debPackage.createArFileSet();
+        fileSet.setFile(debianBinaryFile);
+        fileSet = debPackage.createArFileSet();
+        fileSet.setFile(controlTarGzFile);
+        fileSet = debPackage.createArFileSet();
+        fileSet.setFile(dataTarGzFile);
+        debPackage.perform();
+
+        if (deleteTempFiles) {
+            FileUtils.delete(debianBinaryFile);
+            FileUtils.delete(controlTarGzFile);
+            FileUtils.delete(dataTarGzFile);
+            FileUtils.delete(md5sumsFile);
+        }
+    }
+
+    /**
+     * Checks whether the package is up to date in relationship to a list of files.
+     * 
+     * @param files the files to check
+     * @param dir   the base directory for the files.
+     * @return true if the archive is up to date.
+     */
+    protected boolean packageIsUpToDate(String[] files, File dir) {
+        SourceFileScanner sfs = new SourceFileScanner(this);
+        MergingMapper mm = new MergingMapper();
+        mm.setTo(destFile.getAbsolutePath());
+        return sfs.restrict(files, dir, null, mm).length == 0;
+    }
+
+    /**
+     * Prepares a task for execution.
+     * 
+     * @param task the task to prepare
+     */
+    protected void prepareTask(Task task) {
+        task.setProject(getProject());
+        task.setOwningTarget(getOwningTarget());
+        task.setTaskName(getTaskName());
+        task.setTaskType(getTaskType());
+    }
+}
--- a/src/name/blackcap/exifwasher/Test.kt	Fri Apr 24 14:01:03 2020 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,24 +0,0 @@
-/*
- * A basic test of the library: try to use it to print out the EXIF
- * data.
- */
-package name.blackcap.exifwasher
-
-import name.blackcap.exifwasher.exiv2.*
-
-/* entry point */
-fun main(args: Array<String>) {
-    println("java.class.path = " + System.getProperty("java.class.path"))
-    if (args.size != 1) {
-        System.err.println("expecting a file name")
-        System.exit(1)
-    }
-    val image = Image(args[0])
-    val meta = image.metadata
-    val keys = meta.keys
-    keys.sort()
-    keys.forEach {
-        val v = meta[it]
-        println("${it}: ${v.type} = ${v.value}")
-    }
-}
--- a/src/name/blackcap/exifwasher/Test2.kt	Fri Apr 24 14:01:03 2020 -0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,56 +0,0 @@
-/*
- * A basic test of the library: try to use it to print out the EXIF
- * data.
- */
-package name.blackcap.exifwasher
-
-import java.io.File
-import java.io.FileInputStream
-import java.io.FileOutputStream
-import name.blackcap.exifwasher.exiv2.*
-
-/* entry point */
-fun main(args: Array<String>) {
-    /* must have a file name */
-    if (args.size != 1) {
-        System.err.println("expecting a file name")
-        System.exit(1)
-    }
-
-    /* copy the file; we don't want to scribble on the original */
-    val (name, ext) = splitext(args[0])
-    val newName = "${name}_washed${ext}"
-    FileInputStream(args[0]).use { source ->
-        FileOutputStream(newName).use { target ->
-            source.copyTo(target)
-        }
-    }
-
-    /* load the whitelist and image data */
-    val white = Whitelist.parse(PROPERTIES.getProperty("whitelist"))
-    val image = Image(newName)
-
-    /* do the washing */
-    val meta = image.metadata
-    val keys = meta.keys
-    val keysin = keys.size
-    var deleted = 0
-    keys.forEach {
-        if (!white.contains(it)) {
-            meta.erase(it)
-            deleted += 1
-        }
-    }
-
-    /* save and summarize */
-    image.store()
-    println("${keysin} in - ${deleted} deleted = ${keysin - deleted} out")
-}
-
-fun splitext(s: String): Pair<String, String> {
-    val pos = s.lastIndexOf('.')
-    if (pos == -1) {
-        return Pair(s, "")
-    }
-    return Pair(s.substring(0, pos), s.substring(pos))
-}